附录 3:使用 Docker 搭建开发环境
本附录说明一种实用的开发方式:在 macOS 上编辑代码,但把编译和测试放到 Linux 环境中执行。这样既可以继续使用熟悉的编辑器和终端配置,又能保证本书中的汇编、链接和运行结果与 Linux 环境一致。
Docker 提供的是一种轻量级的 Linux 运行环境。
完整虚拟机会模拟一整套硬件,再在其上运行一个普通操作系统;Docker 则更接近于把 Linux 用户态环境封装起来,让程序在隔离的环境中使用 Linux 系统调用。
这个差异使 Docker 的启动速度更快,资源占用也更低。它也常用于服务器部署,因为同一套镜像可以在不同机器上启动出相近的运行环境。
在 macOS 上借助 Docker 开发 Linux 程序,大体有两种做法:
- 把 Docker 容器当成一台独立的 Linux 机器,编辑、Git 操作、构建、测试全部在容器里完成。
- 编辑代码、提交 Git 等平台无关的工作仍在 macOS 上完成;只有编译、运行测试这类必须依赖 Linux 的命令放到容器中执行。
第一种方式概念简单,等价于另外准备一台 Linux 机器。但它要求你在容器里配置编辑器、Git、Shell 等完整开发环境,准备成本较高。第二种方式不需要“住”在容器里,日常编辑和提交仍在宿主机完成,只在需要 Linux 行为时显式进入容器;缺点是必须理解 Docker 内外两个文件系统如何通过挂载对应起来。
本书采用第二种方式。也就是说,源代码仍保存在宿主机上;需要运行 make、make test 或其他 Linux 命令时,再通过 docker run 把当前项目目录挂载到容器中执行。
搭建步骤
首先安装 Docker Desktop for Mac。安装完成后,执行下面的命令构建本书使用的 Docker 镜像:
$ docker build -t compilerbook https://www.sigbus.info/compilerbook/Dockerfile
镜像可以理解为“容器的模板”:其中包含 Linux 环境所需的文件、工具和配置。由镜像启动出来的运行实例称为 Docker 容器。镜像和容器的关系,近似于可执行文件和进程的关系:可执行文件是静态文件,进程是运行中的实例;镜像是静态模板,容器是运行中的环境。
创建容器并在其中执行命令时,可以使用 docker run。下面的例子在 compilerbook 镜像创建出的容器里执行 ls /:
$ docker run --rm compilerbook ls /
bin
boot
dev
etc
home
lib
lib64
proc
root
usr
var
--rm 表示命令执行完后自动删除这个容器。对本书的用法来说,我们通常只需要临时进入 Linux 环境执行一次构建或测试,不需要让容器长期保留。因此每次运行命令时创建一个干净容器,命令结束后销毁它,是最简单的做法。
使用容器构建项目
如果要在容器里执行 make,容器必须能看到宿主机上的源代码。docker run 的 -v 选项可以完成这件事:
-v 宿主机路径:容器内路径
这个选项会把宿主机上的某个目录挂载到容器中。再配合 -w 选项指定容器内的工作目录,就可以像在普通 Linux 目录中一样运行 make。
假设你的编译器项目放在宿主机的 $HOME/9cc 目录下,可以用下面的命令在容器中执行测试:
$ docker run --rm -v $HOME/9cc:/9cc -w /9cc compilerbook make test
这条命令的含义是:把宿主机的 $HOME/9cc 挂载到容器里的 /9cc,把当前目录切到 /9cc,然后在 compilerbook 容器中运行 make test。以后构建和测试都可以按这个模式执行。
如果需要进入容器里的交互式 Shell,可以加上 -it:
$ docker run --rm -it -v $HOME/9cc:/9cc -w /9cc compilerbook
进入后你会得到一个 Linux Shell,可以手动运行 cc、make、objdump 等命令。退出时输入 exit 即可。
向镜像中添加程序
上面构建出的镜像已经包含本书需要的一般开发工具,但有时你可能还想安装额外程序。需要注意的是,容器默认是临时运行环境:你在容器里安装的软件,不会自动写回原来的镜像。下一次从同一镜像启动容器时,又会回到原始状态。
如果确实想把容器里的修改保存成新镜像,需要显式使用 docker commit。下面以安装 curl 为例说明。
首先启动一个不带 --rm 的交互式容器:
$ docker run -it compilerbook
注意这里没有加 --rm,否则退出后容器会被自动删除。进入容器后,安装 curl 并退出:
$ sudo apt update
$ sudo apt install -y curl
$ exit
退出后,容器会以停止状态保留在本机。可以用下面的命令查看:
$ docker container ls -a
CONTAINER ID IMAGE COMMAND STATUS NAMES
a377e570d1da compilerbook "/bin/bash" Exited (0) 5 seconds ago pedantic_noyce
其中 a377e570d1da 是容器 ID。用 docker commit 可以把这个停止状态的容器保存回名为 compilerbook 的镜像:
$ docker commit a377e570d1da compilerbook
这样以后从 compilerbook 镜像启动的新容器就会包含刚刚安装的 curl。
停止的容器和旧镜像通常只是占用一些磁盘空间,不会影响正在运行的程序。如果想清理这些临时对象,可以执行:
$ docker system prune
该命令会删除未使用的容器、网络、镜像等对象。执行前请确认其中没有你还想保留的内容。