
简介这是一套面向高校嵌入式教学场景的在线虚拟实验平台资源基于Docker容器化技术实现多用户并发访问与远程Web操作可对嵌入式开发板进行状态监控和实时交互适用于嵌入式系统课程实验、远程实训及在线教学环境。资源共915个文件压缩包大小26.63MB以css、png、js、svg等前端资源为主搭配java/class/jar等后端逻辑文件以及xml、json、sql等配置与数据文件整体覆盖Web端展示、交互控制、服务端处理与数据库支撑等完整环节。平台涉及登录控制、开发板实体、实验动作等后端处理类并配合完善的前端页面与配置脚本整体结构清晰便于学习者部署理解。已有53人学习下载适合嵌入式方向学生、教师及实验平台二次开发者参考使用。1. 一个 60 人班、20 块板卡在线嵌入式实验怎么做才不翻车一个 60 人班、20 块板卡在线嵌入式实验怎么做才不翻车这个问题我接过不止一次。传统机房方案要么给学生装 VMware要么让每个学生配置自己的交叉编译环境结果往往是环境版本不一致、驱动装不上、板卡被误刷一节课有一半时间在修电脑。基于 Docker 容器化技术的嵌入式开发在线虚拟实验教学平台本质上是把“编译环境、运行环境、板卡访问通道”三者拆开用容器隔离用户用 Web 页面替代物理终端用独立的数据通道上报开发板状态。学生打开浏览器就能进入一个可编译、可运行、可操作 ARM 程序的虚拟实验环境教师也不再需要逐台电脑去折腾工具链。这篇文章讲的是这一类平台最常见的落地架构、可复现的部署步骤以及多用户并发访问时最容易踩的几个坑。2. 先想清楚架构容器里跑什么什么必须留在宿主机标题里同时出现了“容器化”“远程 Web 访问”“多用户并发”“开发板实时监控”这四个词如果不能拆成一条清晰的数据流部署后一定会互相打架。我一般会把平台拆成三层实验容器层、Web 接入层、板卡接入层。实验容器层负责提供交叉编译工具链和模拟运行环境Web 接入层负责把容器里的终端通过浏览器交到学生手里板卡接入层负责真实开发板的状态采集和指令下发。2.1 边界划分工具链进容器设备驱动留在宿主机Docker 做的是进程级隔离不是硬件虚拟化所以不是什么都能塞进容器。最常见的划分方式是交叉编译器、链接器、调试器、工程模板、QEMU 用户态模拟器、OpenOCD 这类纯软件工具全部进容器真实板卡的 USB 设备节点、串口设备、网络接口则保留在宿主机上按需映射或者通过网关转发。这样做的理由是软件环境最容易复制而硬件权限不能随便复制。把整个/dev/ttyUSB0同时塞给 30 个容器不等于 30 个学生都能操作板卡串口协议本身就是独占的。我见过把 KVM 虚拟机也卷进来的方案但那是另一种成本量级。基于 Docker 的做法更适合教学场景一个实验镜像里固定好arm-linux-gnueabihf-gcc、gdb-multiarch、qemu-arm-static学生无论连到哪个容器看到的工具链版本完全一致。这种一致性是虚拟机方案很难给的也是这个标题最值钱的地方。2.2 多用户并发模型一人一容器还是共享板卡网关“多用户并发”这四个字是部署时最容易算错账的地方。常见的做法有两种。第一种是“一人一容器”每个学生启动一个独立的实验容器容器的 CPU、内存、PID 数都被限制住互不干扰。这种模型适合交叉编译、QEMU 模拟、Linux 驱动模块实验因为每个学生的操作空间是独立的。第二种是“共享板卡网关”开发板本身只有一块网络接口或串口通过一个独立网关容器统一暴露出来学生可以从自己的实验容器里 SSH 到板卡或者通过 WebSocket 通道把串口流量分发到浏览器。这两种模型不是二选一而是同一个平台里的两级结构学生先进入自己的容器再从容器访问共享板卡。如果标题里的“支持多用户并发访问的嵌入式系统开发环境”指的是这种混合模型那么设计重点就是不能让一个学生的误操作把板卡独占死。后面我会细讲串口网关和 SSH 授权的区分。2.3 Web 访问链路浏览器、Nginx、WebSocket 与 PTY远程 Web 访问的链路看起来长核心只有一条浏览器里的终端页面通过 WebSocket 连到容器内的终端服务终端服务再通过 Linux PTY 启动一个 shell。这个 shell 里跑的是交叉编译工具链学生敲的命令最终作用在容器的工作目录里。如果平台要操作真板卡shell 里还能通过 SSH 进入开发板或者通过串口转发工具下发指令。这链路里最容易出问题的环节是 Nginx 反向代理。默认的 HTTP 代理不会自动转发 Upgrade 头WebSocket 握手会失败表现就是浏览器页面刷不出来或者终端提示Connection closed。我用 Shellinabox 或 ttyd 做 Web 终端服务时nginx 配置里一定会带proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection upgrade。另外状态监控不要和终端通道走同一个 socket否则板卡上报频率一高终端输入会被卡成幻灯片。3. 把实验环境固化成镜像Dockerfile 与 docker-compose 的最小可跑版本这一章直接给出一套能跑起来的实验环境镜像。我不建议一开始就做几十个服务的重磅编排而是先把“一个学生能用的容器”做出来再考虑批量创建。3.1 最小 Dockerfile交叉工具链与 Web 终端一起打包下面这个 Dockerfile 是基于 Ubuntu 22.04 搭建的嵌入式 Linux 实验环境包含 ARM 交叉编译工具链、QEMU 用户态模拟器、Shellinabox Web 终端和通用开发工具。# 实验环境镜像交叉编译工具链 浏览器终端 FROM ubuntu:22.04 ENV DEBIAN_FRONTENDnoninteractive RUN apt-get update apt-get install -y \ build-essential \ gcc-arm-linux-gnueabihf \ g-arm-linux-gnueabihf \ qemu-user-static \ shellinabox \ openssh-server \ sudo \ curl \ vim \ git \ rm -rf /var/lib/apt/lists/* # 独立学生账户后续 Web 登录和 SSH 都用这个用户 RUN useradd -m -s /bin/bash student \ echo student:student123 | chpasswd \ echo student ALL(ALL) NOPASSWD:ALL /etc/sudoers RUN mkdir -p /workspace chown student:student /workspace RUN echo cd /workspace /home/student/.bashrc EXPOSE 4200 # shellinaboxd 提供 Web 终端走系统用户认证 CMD [shellinaboxd, -t, --no-beep, -s, /:LOGIN, -p, 4200]这个 Dockerfile 的关键点有三个。一是gcc-arm-linux-gnueabihf提供 32 位 ARM 硬浮点交叉编译器命令名是arm-linux-gnueabihf-gcc这是嵌入式 Linux 实验最常见的一种工具链如果实验涉及 64 位 ARM把包名换成gcc-aarch64-linux-gnu即可。二是qemu-user-static配合binfmt_misc可以让宿主机直接运行 ARM 二进制文件弥补“没有真实板卡时只能编译不能运行”的问题。三是shellinaboxd -s /:LOGIN会在 4200 端口起一个登录页学生在网页里输入student / student123进入终端依赖的是容器内的 PAM 认证不需要额外写后端登录逻辑。3.2 用 docker-compose 启动单机验证环境Dockerfile 只是把环境固化下来真正对外服务还要靠编排。先给一个用于本地验证的最小 docker-compose 文件# docker-compose.yml本地单实例验证 services: dev-env: build: . image: embedded-lab/dev-env:22.04 ports: - 8081:4200 restart: unless-stopped执行docker compose up -d后打开http://服务器IP:8081输入student / student123就能进入终端。这里面我没有加privileged和devices因为初期验证阶段不应该直接给真实设备权限。跑通这一步后再考虑把开发板接进来。如果你是在 Windows 上用 Docker Desktop先确认虚拟化已开启否则 Docker 引擎本身都起不来后面的一切都无从谈起。3.3 批量创建多用户容器的脚本端口段、资源配额与模拟器回退单实例跑通后多用户并发就是把这个容器实例复制多份。我一般不用 docker-compose 的scale去做多用户扩展因为它不方便给每个实例分配独立端口和独立名字。下面这个脚本是更直接的做法#!/usr/bin/env bash # 批量创建 10 个实验容器端口从 8280 开始每实例限制 1 核 / 512MB IMAGEembedded-lab/dev-env:22.04 BASE_PORT8280 CONTAINER_PREFIXlab student_count10 for i in $(seq 0 $((student_count - 1))); do port$((BASE_PORT i)) docker rm -f ${CONTAINER_PREFIX}-${i} /dev/null 21 || true docker run -d \ --name ${CONTAINER_PREFIX}-${i} \ --cpus1 \ --memory512m \ --pids-limit 256 \ -p 127.0.0.1:${port}:4200 \ $IMAGE done这里把端口绑定在127.0.0.1上由前面的 Nginx 统一对外暴露避免用户直接访问到容器的原始端口这样后续加权限、加访问控制都要方便得多。--cpus1限制容器最多使用一个 CPU 核--memory512m限制内存上限--pids-limit防止学生在一个容器里 fork 出太多进程把宿主机拖垮。如果你的服务器是 8 核 16G我建议首批只开 10 个容器后续压测后再逐步增加。4. 把开发板状态实时监控与远程操作打通采集、上报、推送三板斧平台如果只能提供终端那只是“网页版 SSH”还不能叫“嵌入式开发板状态实时监控与操作的教学系统”。要兑现标题里的这部分能力需要一条独立于终端的数据通道板卡上的采集脚本负责读状态服务端负责接收和广播浏览器页面负责渲染。操作通道仍然走终端但操作权限需要在教师端可控。4.1 板卡端采集脚本一条无第三方依赖的 Python agent嵌入式开发板那边通常是一个运行 Linux 的 ARM 板子比如树莓派、RK3588、全志平台。采集脚本不需要装额外 Python 包只依赖标准库#!/usr/bin/env python3 # 开发板上运行的采集脚本每 2 秒上报一次状态 import json import socket import time import urllib.request WEB_API http://192.168.1.10:9000/api/state # 按实际服务端地址修改 def read_file(path, default0): try: with open(path, r, errorsignore) as f: return f.read().strip() except FileNotFoundError: return default def collect(): return { board: socket.gethostname(), load: read_file(/proc/loadavg, 0 0 0).split()[0], mem: read_file(/proc/meminfo, MemTotal: 0 kB).split()[1], temp: int(read_file(/sys/class/thermal/thermal_zone0/temp, 0)) / 1000.0, ts: int(time.time()), online: True } while True: try: data json.dumps(collect()).encode() req urllib.request.Request(WEB_API, datadata, headers{Content-Type: application/json}) urllib.request.urlopen(req, timeout2).read() time.sleep(2) except Exception as exc: # 网络抖动不退出下一轮继续 time.sleep(2) print([agent], exc)这段脚本读取/proc/loadavg、/proc/meminfo和温度文件时全都做了容错。原因很现实不同开发板的温度传感器路径不一样有些板子上根本不存在thermal_zone0一旦读取失败脚本如果用open()直接打开文件agent 就会崩溃退出。默认值0虽然会让温度曲线出现 0 度但至少平台不会因为一块板子的传感器路径不同就收不到任何数据。4.2 服务端接收状态并广播到浏览器FastAPI 与 WebSocket服务端我把状态接收做成一个轻量接口负责把板卡 POST 上来的数据实时推给所有在线浏览器# status_server.py from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from pydantic import BaseModel import uvicorn class State(BaseModel): board: str load: str mem: str temp: float ts: int online: bool class Hub: def __init__(self): self.connections: list[WebSocket] [] async def broadcast(self, state: State): data state.model_dump_json() for ws in self.connections[:]: try: await ws.send_text(data) except Exception: self.connections.remove(ws) hub Hub() app FastAPI() app.post(/api/state) async def recv(state: State): hub.broadcast(state) return {ok: True} app.websocket(/ws/state) async def ws_state(ws: WebSocket): await ws.accept() hub.connections.append(ws) try: while True: await ws.receive_text() except WebSocketDisconnect: hub.connections.remove(ws) if __name__ __main__: uvicorn.run(app, host0.0.0.0, port9000)这段代码用的是 Pydantic v2 的model_dump_json()如果你还在用 Pydantic v1需要换成.json()。广播逻辑很简单每块板卡上报后遍历所有 WebSocket 连接把 JSON 推出去。连接断开时send_text会抛异常代码在异常分支里移除连接。实际部署中如果板卡数量超过几十块我会把hub替换成 Redis Pub/Sub让多个服务实例共享同一份实时状态避免单个 FastAPI 进程成为瓶颈。4.3 页面侧订阅 WebSocket 并渲染曲线浏览器侧最小可用的做法是建立一个 WebSocket 连接收到状态后更新页面const ws new WebSocket(ws://${location.host}/ws/state); const tempEl document.getElementById(board-temp); const loadEl document.getElementById(board-load); ws.onmessage (ev) { const state JSON.parse(ev.data); tempEl.innerText state.temp.toFixed(2) °C; loadEl.innerText state.load; };实际项目里通常会把最近一分钟的数据缓存到前端画成折线图。要注意的是WebSocket 数据的实时性并不等于可靠性如果页面刷新导致 WebSocket 短暂断开缺失的几秒钟数据不会自动补回来。教学平台可以接受这种短时缺口但如果要做实验过程回放就要在后端把上报数据落一份到 Redis 或时序数据库。4.4 远程操作权限终端里的 SSH 通道要可控学生通过 Web 终端操作开发板常见做法是让学生在容器内用 SSH 登录板卡或者通过串口转发工具访问板卡调试串口。我的建议是不要把 SSH 密钥平分给学生而是由教师端在容器内部预置一个受限用户的 SSH 私钥只允许执行预置命令。板卡的 root 权限更不能直接给。否则只要有一个学生把板卡文件系统写坏整节课的实验就中断了。5. 多用户并发访问的排查与避坑不是开几十个容器就完事多数平台在演示环境里跑得很好一到上课高峰就开始卡顿。这里最核心的问题不是 Docker 本身而是“并发数”到底指什么、资源怎么分配、WebSocket 连接怎么代理。5.1 先算并发数在线数、编译数、板卡独占数是三件事“并发数”经常被混为一谈。我一般会把并发数拆成三个指标同时在线人数、同时编译人数、同时独占板卡人数。在线人数只消耗一个 WebSocket 连接和内存在页面侧占用的少量资源可以比较大编译人数受 CPU 配额限制1 个 CPU 核在满负载下只能给到 1 个容器超配太多就会全部变慢独占板卡人数则是串口等独占设备决定的同一时刻只能有一个人持有。规划时先问清楚这三个数再决定服务器规格。5.2 Nginx 最大并发连接数与 WebSocket 代理参数Nginx 默认的worker_connections通常是 1024看起来不少但 WebSocket 是长连接一个浏览器页面在实验期间会一直占着一条连接。再加上静态资源、API 调用40 人同时在线就能逼近上千连接数。所以我会在 nginx.conf 里提高连接数并给代理 Web 终端的位置单独设置超时worker_processes auto; events { worker_connections 4096; } server { listen 80; location / { proxy_pass http://127.0.0.1:8280; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }这里proxy_pass指向批量创建脚本里第一个容器的宿主机端口8280。如果你用不同的端口段创建容器就按实际端口调整。proxy_read_timeout 3600s是为了防止 WebSocket 终端长时间没有输入时被 Nginx 掐断。很多浏览器终端“莫名其妙断开”问题不在容器而在 Nginx 默认 60 秒超时。5.3 状态存储不要直接写 MySQL数据库并发锁会成为瓶颈板卡状态每 2 秒上报一次如果 20 块板卡同时在线每分钟会产生 600 条状态记录。直接写入 MySQL 不是不能扛但每次插入都要拿表锁或行锁加上 WebSocket 广播数据库连接池很容易被打满。常见做法是把最新状态放在 Redis 里# 使用 Redis List 保存最近 100 条状态供回放和调试 RPUSH lab:board:state json LTRIM lab:board:state 0 99这样浏览器 WebSocket 断开重连后还能从 Redis 拉取最近的历史数据补画面。我一般不把实时状态表设计成需要SELECT加锁的关联结构因为这种高频写入在并发高时会不断碰撞数据库并发锁最终让整条链路响应变慢。5.4 避坑四个真实环境里的典型故障Web 终端连上十秒就断。现象是学生刚登录就提示连接关闭。原因多半是 Nginx 没有正确转发 WebSocket 的 Upgrade 头或者代理超时太短。解决方法是加上proxy_set_header Upgrade $http_upgrade、proxy_set_header Connection upgrade并把proxy_read_timeout调到 3600 秒。容器里能编译但运行 ARM 程序时提示 exec format error。现象是qemu-arm-static已安装但执行./hello仍然报格式错误。原因是binfmt_misc没有注册或未启用。解决方法是执行update-binfmts --enable或者在容器内检查/proc/sys/fs/binfmt_misc/下是否有对应条目。没有真实板卡的实验环境最容易翻在这一步。板卡温度曲线长期是 0 度。现象是页面能收到 JSON但温度恒为 0。原因一般是/sys/class/thermal/thermal_zone0/temp路径与板卡实际的传感器路径不一致。解决方法是先在板卡上执行find /sys/class/thermal -name temp找到真实路径再改 agent 里的路径而不是改服务端逻辑。创建 20 个容器后 Docker 响应明显变慢。现象是docker run命令要等好几秒才返回。原因通常是宿主机镜像层多、磁盘 IO 达到瓶颈或者同时执行的 build 任务太多。解决方法是提前把镜像docker pull到宿主机实验开始前用docker create预创建容器等学生登录时只执行docker start避免高峰时段反复创建容器。6. 把容器快照做成学生的“后悔药”一项值得提前做的收敛技巧平台上线后班里总有几个学生会把环境折腾坏可能是误删系统目录也可能是安装了冲突的库。与其让管理员半夜手工修复容器不如把docker commit当成平台的默认功能。每节实验结束时自动备份一次容器状态生成一个标记了学生和实验序号的新镜像。# 备份某个学生当前容器状态为新镜像 docker commit lab-03 embedded-lab/snapshot:exp3-student03 # 下次实验直接用备份镜像恢复 docker run -d \ --name lab-03-restored \ -p 127.0.0.1:8283:4200 \ embedded-lab/snapshot:exp3-student03这套做法的好处是容错粒度很细如果学生只是把工具链装坏了直接从他受伤前的快照重新创建一个容器如果学生是因为实验步骤错误导致系统崩溃反而能把崩溃状态保留下来作为排查依据。我会让教师端脚本在每天课程结束后自动执行 commit并且只保留最近三个快照防止磁盘被镜像占满。这里有个实际教训曾有一次我们只备份了容器没备份/workspace里的实验代码结果容器删除后学生交作业时才发现代码没了。后来我把工作目录也挂载到宿主机独立目录或者实验结束前让学生把代码提交到平台 Git 服务镜像备份只负责系统环境不负责代码作业。希望这个思路帮你在自己的平台部署上少走一段弯路。本文还有配套的精品资源点击获取