使用 Docker 完成 PA 实验!
导引
本文档是 2026 ICS-PA 课程新增的导引文档,介绍如何用 Docker 搭建尽量接近评测机的实验环境,作为传统虚拟机方案之外的选择。文档不会详细指引你每个命令应该怎么敲,而是告诉你需要达到什么目标、哪些地方值得自己推导和排错,剩下的由你和 AI 协同完成。如果无法配置好环境,也可以采用传统的虚拟机方案。最后更新:2026-08。
为什么要用 Docker
PA 实验对编译环境极其敏感:GCC 版本、glibc、libreadline、RISC-V 交叉编译器,甚至 <regex.h> 是 GNU 实现还是 BSD 实现,都会影响你的代码行为。官方指定的环境是 Ubuntu 22.04 (x86_64)——也就是评测机的配置。
传统方案是装一个虚拟机(VirtualBox / VMware),在虚拟机里装好整个 Ubuntu。它能用,但体验一般:
| 维度 | 虚拟机方案 | Docker 方案 |
|---|---|---|
| 启动速度 | 分钟级(要启动完整系统) | 秒级(只是一个进程) |
| 资源占用 | 独占几个 GB 内存和磁盘 | 随用随走,轻量 |
| 文件共享 | 要配共享文件夹,体验别扭 | 目录挂载,宿主机编辑容器内实时可见 |
| 环境一致性 | 取决于你自己装的软件 | 镜像固定用户空间,便于复现评测机环境 |
核心工作流:代码在宿主机编辑(用你熟悉的 IDE,甚至配合上一份文档里的 Agent),编译和运行全部在容器内完成。容器通过绑定挂载(bind mount)看到你的项目目录——挂载是实时的,改了代码进容器直接 make 就能看到效果。镜像解决的是用户空间的一致性;CPU 架构、宿主机内核和图形系统仍然可能不同。
Docker 是什么
把 Docker 拆开看,只需要掌握三个概念:
- 镜像(Image):一个打包好的"环境快照"——包含 Ubuntu 22.04、gcc、SDL2、交叉编译器……它是一份只读模板,由 Dockerfile 描述如何构建。
- 容器(Container):镜像的一次运行实例。你可以把它理解成一台"迷你电脑",但它共享宿主机的内核,只隔离用户空间,所以远比虚拟机轻快。
- 卷挂载(Volume):把宿主机的一个目录"借给"容器用。容器里
/ics2026就是你项目根目录的实时镜像。
用一句话类比:虚拟机是"在房间里再盖一间带全套家电的房子";Docker 是"把一套工具打包进背包,走到哪背到哪"。
Docker 与虚拟机最本质的区别:虚拟机模拟整台计算机(虚拟硬件 + 完整操作系统),Docker 共享宿主机内核,只做进程级隔离。隔离性不如虚拟机,但对本课程绰绰有余;换来的是极致的轻快与一致。
前置环境要求(三系统)
Docker 在三大平台上都能跑,要求各不相同:
| 宿主系统 | 需要安装 | 图形转发(GUI 测试时需要) | 备注 |
|---|---|---|---|
| macOS | Docker Desktop(或轻量的 OrbStack) | XQuartz(X11 服务器) | Apple Silicon 直接用 arm64 原生镜像,无需强制 x64 |
| Windows | Docker Desktop(WSL2 后端) | VcXsrv(X11 服务器) | 代码放在 WSL2 目录下体验最佳 |
| Linux | 官方脚本一行安装 | 无需额外安装(自带 X11) | 最省心的平台 |
验证 Docker 装好了没有:
docker --version
docker run --rm hello-world # 能打印出 Hello from Docker 即成功
先决定你要复现哪一种环境。默认情况下,Docker 会按宿主机架构选择镜像;不要为了“看起来和评测机一样”而无条件开启模拟。
| 目的 | 建议 |
|---|---|
| 日常学习、快速编译 | 使用宿主机原生架构;Apple Silicon 直接使用 arm64 |
| 排查只在评测机出现的问题 | 显式选择 linux/amd64,并在切换后清理或隔离旧的构建产物 |
| 需要同时支持两种架构 | 分别构建、分别运行,不要让同一个 build/ 目录混用二进制文件 |
可以用下面的命令观察自己实际运行的架构;不要只根据宿主机型号猜测:
docker run --rm ubuntu:22.04 uname -m
x86_64、aarch64 等输出的含义,以及 --platform 应该放在 build 还是 run 命令中,建议先查 Docker 文档或询问 AI,再做选择。
让 AI 帮你完成基础安装
这些基础安装步骤完全可以交给 AI 完成——把你遇到的报错原样丢给它,让它给出你所在系统的安装命令。但请记住:每一个命令都要看懂再执行。
hint::本文的验收方式
不要以“Dockerfile 能 build”作为终点。你最终需要自己确认:容器中的编译器和库版本符合预期;宿主机修改代码后容器能看到;NEMU 能编译和运行;需要 GUI 时 X11 能单独通过 xeyes 验证。本文只给出这些目标和关键线索,不替你补齐所有中间步骤。
镜像:认识 Dockerfile 常用语法
镜像由 Dockerfile 描述。课程不会直接提供现成的 Dockerfile——自己写一个(并让 AI 帮你写、帮你解释),才是这门课该有的打开方式。基础镜像统一选 ubuntu:22.04(用户空间版本与评测机一致;Docker 默认按你机器的架构拉取对应变体)。架构既可以通过 Dockerfile 的 FROM 参数,也可以通过 docker build/run --platform 选择,三者需要保持一致。
常用指令就几个,几分钟就能掌握:
| 指令 | 作用 |
|---|---|
FROM |
指定基础镜像(如 ubuntu:22.04) |
RUN |
构建时执行命令(如 apt-get install 装依赖) |
ENV |
设置环境变量(如 NEMU_HOME、AM_HOME) |
WORKDIR |
设置工作目录 |
COPY |
把宿主机文件拷进镜像 |
CMD / ENTRYPOINT |
容器启动时的默认命令 |
练习建议
自己写出 Dockerfile 后,逐行向 AI 请教每一行在做什么(APT 源、gcc、libreadline-dev、libsdl2-dev、RISC-V 交叉编译器……),直到你能向它反向解释;再让 AI 从零写一份,与你的版本对比异同——这能让你同时练到"读代码"和"审 AI 输出"两种能力。
构建镜像的命令长这样(在项目根目录执行,-f 指向你自己写的 Dockerfile):
docker build -f <你的 Dockerfile 路径> -t ics-pa:latest .
关于架构
课程文档要求的环境架构是 x86_64(linux/amd64),评测机也是这个架构。助教已经验证:日常 PA 开发在 arm64 上通常也可以完成,但它并不等于和 amd64 评测机完全相同。所以你不必为了"贴合文档"而一开始就强制用 x64——按自己的任务选择架构:
- Apple Silicon 用户直接用原生 arm64 镜像(更快、无额外模拟开销),
ubuntu:22.04会自动匹配你的架构; - 只有当你遇到只在评测时才出现的问题、需要复现评测环境时,才构建/运行 x64 镜像(加上
--platform linux/amd64),并且务必全量重新编译——两种架构的构建产物互不通用。
基础镜像同样可以主动指定架构拉取。比如在 ARM 机器上想拉一份 x64 版的 ubuntu:22.04:
docker pull --platform linux/amd64 ubuntu:22.04
之后 docker build / docker run 也要带上 --platform linux/amd64 才会用到这份镜像(Apple Silicon 上跑 x64 容器需要 Docker Desktop 开启 Rosetta 转译,性能会打折)。反过来,在 x64 机器上想用 arm64 镜像也是同样的做法,把参数换成 linux/arm64 即可。
为了防止同学们遇到一些和实验本身无关的坑,下面是你可以参考的Dockerfile 基础模板,你可以在此基础上让 AI 帮你写出完整的镜像构建脚本:
FROM --platform=<your prefered platform> ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
# 先安装 ca-certificates(ubuntu:22.04 镜像缺少,导致 HTTPS 源无法使用,替换源的必要前置任务)
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
# 替换为南京大学 APT 镜像源加速下载。
# arm64 的包位于 ubuntu-ports 仓库(NJU 镜像站已同步,含 security)。
RUN printf '%s\n' \
'deb https://mirror.nju.edu.cn/ubuntu-ports/ jammy main restricted universe multiverse' \
'# deb-src https://mirror.nju.edu.cn/ubuntu-ports/ jammy main restricted universe multiverse' \
'deb https://mirror.nju.edu.cn/ubuntu-ports/ jammy-updates main restricted universe multiverse' \
'# deb-src https://mirror.nju.edu.cn/ubuntu-ports/ jammy-updates main restricted universe multiverse' \
'deb https://mirror.nju.edu.cn/ubuntu-ports/ jammy-backports main restricted universe multiverse' \
'# deb-src https://mirror.nju.edu.cn/ubuntu-ports/ jammy-backports main restricted universe multiverse' \
'deb https://mirror.nju.edu.cn/ubuntu-ports/ jammy-security main restricted universe multiverse' \
'# deb-src https://mirror.nju.edu.cn/ubuntu-ports/ jammy-security main restricted universe multiverse' \
> /etc/apt/sources.list
# 安装所有构建和运行依赖(与 x86_64 镜像清单一致,arm64 均有对应包)
RUN apt-get update && apt-get install -y --no-install-recommends \
# ... 在此补全所有 PA 依赖
# 修复 RISC-V 32位交叉编译头文件 (rv32i targets are not supported by default)
# libc6-dev-riscv64-cross 默认的 wordsize.h 和 stubs.h 不支持 rv32,
# 需要手动修复,使其支持 32 位 RISC-V 编译目标。
# (交叉编译目标 sysroot 路径与宿主架构无关,此修复与 x86_64 镜像完全相同)
RUN echo '#if __riscv_xlen == (__SIZEOF_POINTER__ * 8)' > /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '# define __WORDSIZE __riscv_xlen' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '#endif' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '#if __riscv_xlen == 64' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '# define __WORDSIZE_TIME64_COMPAT32 1' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '#endif' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '#if __riscv_xlen == 32' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '# define __WORDSIZE_TIME64_COMPAT32 0' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h && \
echo '#endif' >> /usr/riscv64-linux-gnu/include/bits/wordsize.h
RUN echo '/* This file is automatically generated.' > /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo ' This file selects the right generated file of __stub_FUNCTION macros' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo ' based on the architecture being compiled for. */' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#include <bits/wordsize.h>' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#if __WORDSIZE == 32 && defined __riscv_float_abi_soft' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '// # include <gnu/stubs-ilp32.h>' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#endif' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#if __WORDSIZE == 32 && defined __riscv_float_abi_double' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '# include <gnu/stubs-ilp32d.h>' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#endif' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#if __WORDSIZE == 64 && defined __riscv_float_abi_soft' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '# include <gnu/stubs-lp64.h>' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#endif' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#if __WORDSIZE == 64 && defined __riscv_float_abi_double' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '# include <gnu/stubs-lp64d.h>' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h && \
echo '#endif' >> /usr/riscv64-linux-gnu/include/gnu/stubs.h
# 设置工作目录环境变量
ENV NEMU_HOME=/ics2026/nemu
ENV AM_HOME=/ics2026/abstract-machine
ENV NAVY_HOME=/ics2026/navy-apps
# 配置 git(用于 PA 的自动 commit 追踪)
RUN git config --global user.name "<change this to your name>" && \
git config --global user.email "<change this to your email>" && \
git config --global safe.directory '*'
# 默认工作目录
WORKDIR /ics2026
CMD ["/bin/bash"]
挂载与运行:让 AI 帮你写启动脚本
容器和项目目录连接起来的关键是一条 docker run 命令。你不需要死记参数,但要知道每个参数在做什么:
| 参数 | 含义 |
|---|---|
-it |
交互式终端(make menuconfig 这种交互程序必须要) |
--rm |
退出后自动删除容器(用完即走,不占资源) |
--mount type=bind,src=...,dst=/ics2026 |
把当前目录绑定挂载到容器内 /ics2026;写法更明确,适合放进脚本 |
-v "$(pwd):/ics2026" |
上一行的简写形式;知道它的含义即可 |
-e KEY=VALUE |
传入环境变量(如 NEMU_HOME=/ics2026/nemu) |
--platform linux/amd64 |
强制使用 x64 镜像(按本机架构运行时不需要加) |
-e DISPLAY=... |
X11 转发相关(见下一节) |
--user UID:GID |
Linux 上可避免容器以 root 身份在宿主机生成文件;是否需要取决于你的宿主机和脚本设计 |
每次敲一长串命令很痛苦,所以我们需要两个启动脚本:
- 跑命令的脚本:
./docker-run.sh "cd /ics2026/nemu && make run"——把命令传进容器执行,跑完即退。日常编译、跑测试都靠它。 - 交互式 shell 脚本:
./docker-shell.sh——进入容器内一个持久的 bash,在里面随意操作(make、make menuconfig、gdb……)。
课程同样不会提供现成的启动脚本——自己写,AI 代劳,把它描述清楚就行。建议先写出参数清单和伪代码,再决定如何处理引号、退出码、架构参数和 GUI 环境变量;不要把一条能跑的命令直接复制成脚本就算完成。
两个脚本至少应满足这些验收条件:
- 不在项目目录之外猜测或创建挂载源路径;挂载错目录时能尽早报错。
docker-run.sh能把一个命令传入容器并返回正确的退出码;命令中含有空格、管道或&&时,你能解释它经过了几层 shell。docker-shell.sh进入的工作目录正确,退出后不会留下不必要的容器。- 容器内
cd /ics2026/nemu && make能编译出 NEMU;宿主机修改代码后容器内立即生效。 - Git 提交仍由你理解和负责;如果 Linux 宿主机出现 root-owned 文件,先解决权限设计,再继续编译。
绑定挂载默认可写,容器中的程序因此能够修改宿主机文件。只把项目目录挂进去,避免把整个家目录、SSH 私钥或 Docker socket 一起暴露给容器。更多参数可查阅 Docker bind mounts 文档。
图形转发:X11 是什么
PA2 后期开始,NEMU 的 VGA 和 FCEUX 需要弹出图形窗口。但容器里没有屏幕——这是 X11 登场的时刻。
X11 是一套几十年前设计、至今仍在服役的图形协议,核心是客户端-服务器分离:
容器内 (X Client) 宿主机 (X Server)
NEMU / FCEUX / xeyes XQuartz / VcXsrv / Linux X11
│ │
└── X11 协议 ────────────────────────▶│ 负责真正把窗口画到屏幕
- X Server:真正拥有屏幕的一方,跑在你的宿主机上(macOS 装 XQuartz,Windows 装 VcXsrv,Linux 自带)。
- X Client:要画窗口的程序(容器里的 NEMU),它通过环境变量
DISPLAY知道该去哪里找 X Server。
所以配置图形转发的本质就一句话:让容器知道"屏幕"在哪,并允许它连过去。macOS/Windows 走 TCP(DISPLAY=host.docker.internal:0),Linux 更直接,把 X11 的 UNIX socket 挂载进容器即可。
SDL2(NEMU 的图形库)还需要两个环境变量配合:SDL_VIDEODRIVER=x11(让 SDL2 走 X11 后端)和 SDL_RENDER_DRIVER=software(容器内没有 GPU,用 CPU 软件渲染)。
验证方法:进入容器跑 xeyes——宿主机屏幕上出现一双跟随鼠标的眼睛,说明 X11 转发全链路打通。这是最经典的 X11 测试程序,比跑 NEMU 更能定位问题在哪一端。注意 Ubuntu 最小镜像通常不会预装 xeyes,你需要在依赖清单中自行找到并加入提供它的包,或者换一个已安装的 X11 客户端测试。
这一步可以等到 PA2 需要图形输出时再做。先把“容器能编译、挂载能同步、NEMU 能运行”这条无 GUI 路径打通,能减少同时排查编译、网络和显示系统的变量。
⚠️ macOS 的坑:X11 转发 GLX 问题(助教踩坑实录)
这是本课程在 macOS 上遇到过的、比较容易误判的问题,务必知道如何把它与普通的 X11 连接问题区分开。
现象:X11 转发配好了,xeyes 也正常,但一旦跑涉及 OpenGL 的程序(比如 NEMU VGA 测试),窗口黑屏、报 X Error ... GLX ... BadValue,甚至直接崩溃。
一种常见原因:GLX 是 X11 协议上的 OpenGL 扩展。某些 macOS、XQuartz 与容器内 Mesa(OpenGL 实现)的组合存在兼容性问题,SDL2 默认尝试走 OpenGL 渲染时可能触发这个坑。先用软件渲染验证,比直接修改业务代码更容易定位问题。
修复一:测试代码的修正(软件渲染) 写 SDL 图形测试程序时,创建渲染器不要用加速(OpenGL)模式:
// ❌ 错误:SDL2 会尝试走 OpenGL/GLX,触发 macOS 的 GLX bug
SDL_Renderer *ren = SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED);
// ✅ 正确:软件渲染,CPU 画图,不碰 OpenGL
SDL_Renderer *ren = SDL_CreateRenderer(win, -1, 0);
// 或显式声明:SDL_CreateRenderer(win, -1, SDL_RENDERER_SOFTWARE);
修复二:启动脚本的额外参数(强制 EGL)
即使程序本身没写错,SDL2 也可能默认选择 GLX。在启动脚本的 docker run 中加一个环境变量,强制 SDL2 改用 EGL 绕开 GLX:
-e SDL_VIDEO_X11_FORCE_EGL=1
记得把这个参数加进你自己启动脚本的 macOS 分支(Windows 的 VcXsrv 同理;Linux 原生 X11 一般不需要)。
补充说明
XQuartz 2.8.7 之后的版本理论上修复了相关问题,可以尝试回退到 OpenGL 渲染,但保险起见,本课程推荐一律软件渲染 + 强制 EGL。判断问题是否与 GLX 有关的快速方法:把 SDL_RENDER_DRIVER=software 配上后窗口恢复,那就是 GLX 的锅。
xhost 会改变 X Server 的访问控制。可以把它当作临时排错手段,用完后恢复原设置;不要为了省事使用无范围限制的 xhost +,也不要把这类命令无脑交给启动脚本长期执行。
常见问题速查
| 症状 | 原因 | 解决 |
|---|---|---|
Cannot connect to the Docker daemon |
Docker Desktop/服务未启动,或当前用户没有访问权限 | 先确认 Docker 服务状态,再区分“服务未启动”和“权限不足”,不要直接用 sudo 掩盖问题 |
apt 报 404 或找不到软件包 |
APT 源与镜像架构不匹配,或索引没有更新 | 检查 uname -m、基础镜像架构和 sources 配置;不要把 ubuntu-ports 源盲目用于 amd64 |
Cannot open display |
X Server 没启动 | 启动 XQuartz / VcXsrv,重新运行 |
| macOS 上窗口一直不出现 | XQuartz 未允许网络连接 | XQuartz 偏好设置 > 安全 > 勾选"允许来自网络客户端的连接",并 xhost +localhost |
强制 --platform linux/amd64 时报 not found |
Apple Silicon 未开启转译 | Docker Desktop 设置中启用 Rosetta(仅当你需要 x64 环境时) |
| 容器里改代码不生效 | 挂载路径不对 | 确认 -v 挂的是项目根目录,且容器内路径为 /ics2026 |
| 宿主机出现 root-owned 文件 | 容器进程以 root 身份写入绑定挂载目录 | 重新检查 --user、目录权限和脚本设计;不要只靠反复 sudo chown 收尾 |
xeyes: command not found |
镜像中没有安装 X11 测试程序 | 回到依赖清单,判断需要哪个包;这和 X11 连接失败是两类问题 |
| 窗口黑屏 / GLX 报错 | macOS GLX bug | 按上文两个修复操作 |
Exec format error |
x86 与 arm64 构建产物混用 | 切换架构后必须全量重新编译:make clean && make |
开始你的 Docker 之旅
推荐的路线:
- 装好 Docker,跑通
hello-world。 - 自己列出 Dockerfile 的依赖,再让 AI 检查遗漏(基础镜像
ubuntu:22.04+ PA 所需依赖),逐行读懂它。 - 自己设计两个启动脚本(跑命令 + 交互式 shell),让 AI 帮你审查参数、引号和权限边界。
- 先完成无 GUI 的编译和挂载验收;PA2 需要图形输出时,再装 X11 环境(macOS 装 XQuartz),用
xeyes验证转发。 - 最后跑通
make && make run,看到(nemu)提示符——环境搭建完成,PA0 验收通过!
遇到 Docker 语法问题,优先查 Dockerfile reference;遇到挂载问题,查 bind mounts;遇到架构问题,查 multi-platform builds。学会从官方文档定位参数,比记住本文某一条命令更重要。
整个过程中,AI 是你的"随行老师":让它解释每一行 Dockerfile 和脚本的含义,让它排查你遇到的每一个报错——但每个命令、每个参数,都要确保自己能讲明白。祝大家 PA 顺利!