使用 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_64aarch64 等输出的含义,以及 --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_HOMEAM_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 身份在宿主机生成文件;是否需要取决于你的宿主机和脚本设计

每次敲一长串命令很痛苦,所以我们需要两个启动脚本

  1. 跑命令的脚本./docker-run.sh "cd /ics2026/nemu && make run"——把命令传进容器执行,跑完即退。日常编译、跑测试都靠它。
  2. 交互式 shell 脚本./docker-shell.sh——进入容器内一个持久的 bash,在里面随意操作(makemake menuconfiggdb……)。

课程同样不会提供现成的启动脚本——自己写,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 掩盖问题
apt404 或找不到软件包 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 之旅

推荐的路线:

  1. 装好 Docker,跑通 hello-world
  2. 自己列出 Dockerfile 的依赖,再让 AI 检查遗漏(基础镜像 ubuntu:22.04 + PA 所需依赖),逐行读懂它。
  3. 自己设计两个启动脚本(跑命令 + 交互式 shell),让 AI 帮你审查参数、引号和权限边界。
  4. 先完成无 GUI 的编译和挂载验收;PA2 需要图形输出时,再装 X11 环境(macOS 装 XQuartz),用 xeyes 验证转发。
  5. 最后跑通 make && make run,看到 (nemu) 提示符——环境搭建完成,PA0 验收通过!

遇到 Docker 语法问题,优先查 Dockerfile reference;遇到挂载问题,查 bind mounts;遇到架构问题,查 multi-platform builds。学会从官方文档定位参数,比记住本文某一条命令更重要。

整个过程中,AI 是你的"随行老师":让它解释每一行 Dockerfile 和脚本的含义,让它排查你遇到的每一个报错——但每个命令、每个参数,都要确保自己能讲明白。祝大家 PA 顺利!

results matching ""

    No results matching ""