Apollo-11 这个名字一出现,大多数人第一时间想到的是载人登月那段历史。但我今天想聊的不是航天工程,而是一个借这个名字做的嵌入式仿真项目。项目代号就叫 Apollo-11,目标很简单:用现代桌面环境,复刻一套精简版的飞行器制导计算机,让喜欢底层开发的人能在终端里写汇编、跑任务、看面板指示灯闪烁。
这个项目能解决什么问题?说白了,是解决“想学底层但不想碰老古董”的问题。现在的人想接触汇编,要么得弄一块单片机开发板,要么专门去搜一个复古计算机模拟器,门槛都不低。Apollo-11 把 CPU、内存、输入输出、面板显示都封装成一个模拟器,再配一个专用汇编器,你只要有一个终端和一份源码,就能体验从零写一个飞行控制循环的过程。适合三类人来玩:想补计算机组成原理基础的同学、有单片机开发经验但想更进一步理解受限环境下编程方式的人,以及纯粹对复古计算感兴趣的技术爱好者。
我自己在本地完整跑了一遍,实话讲,踩了不少坑。这篇文章就把整个项目的背景、设计思路、实操过程、常见问题一次性说清楚,给准备动手的人一份可以直接照着做的参考。
1. 项目概述:这个 Apollo-11 到底“模拟”了什么
1.1 不是模拟飞船,而是模拟飞船上的计算机
先把最容易误会的地方说清:Apollo-11 这个仿真项目,模拟的核心对象不是飞船本身,而是飞船上的那台导航计算机。在真实的航天任务场景里,这类制导计算机承担的是导航解算和飞行姿态控制,需要在高振动、高低温、强辐射环境下可靠运行。它没有现代 CPU 那么高的主频,也没有几 GB 内存,整套系统追求的是“在极其有限的资源里把关键任务做对”。
这个教学项目把上述概念简化成一套可执行的虚拟系统:一台 16 位字长的虚拟 CPU、一块 4K 字的可写内存、一块 64K 字的只读存储区、一个可编程定时器、一组输入输出通道,以及一个看起来很像老式仪表盘的显示面板。你在这里写的不是 Python 或 C,而是真正的汇编程序。每一条指令都会由模拟器逐条解释执行,CPU 状态、内存内容、面板输出都能实时观察。
这样设计有一个非常大的好处:学习者能直接感受到“程序怎么操作内存、怎么控制外设、怎么做实时循环”,而不是被高级语言的抽象层掩盖掉底层过程。换个说法,这个项目不是让你看结果的,而是让你看过程。你把一条指令写进去,模拟器就能告诉你它执行前后寄存器发生了什么变化,这对建立“计算机究竟在干什么”的心智模型很有帮助。
1.2 需要先具备的基础概念
如果你是完全零基础,可能觉得“仿真器 + 汇编器”的组合听起来很吓人,其实需要的预备知识并不多。
- 知道“寄存器”是什么:可以把它理解成 CPU 内部的小纸片,速度快,数量少,用来临时存放正在操作的数。
- 知道“内存地址”是什么:内存像一个大柜子,每个抽屉都有编号,汇编指令就是按编号去存取东西。
- 知道“程序计数器”是什么:它记录下一条指令应该从哪里取,普通的顺序执行会一条一条走下去,遇到跳转指令就换个地方继续。
这些概念在常规的计算机组成课里都会讲,只是课堂上学的时候比较多停留在概念层。Apollo-11 的价值在于,它让你亲手写几段代码,看着这些概念一条条变成真实行为。如果你以前写过单片机,哪怕是几千行的 Arduino 代码,上手这个项目会非常快,因为很多思维方式是相通的:读寄存器、查标志位、操作外设地址、控制循环节拍。
1.3 项目适合什么样的人
第一类,准备走嵌入式方向的学生。学校课程往往到“中断”“定时器”就结束了,但这台模拟器允许你观察中断前后的现场保存和恢复,能把课堂上支离破碎的知识串起来。
第二类,做上位机或 Web 开发但想横向拓宽视野的人。天天写高级语言,难免会好奇底层的分支预测、栈操作到底长什么样。Apollo-11 就是一个性价比极高的“底层体验舱”,不需要买硬件就能体验汇编时代写程序的思维方式。
第三类,纯粹为了玩复古硬件模拟器的极客。这类人可能会对 DSKY式的面板、旋钮、灯光很着迷,能在终端里看到老式界面亮起来,本身就是一件很有成就感的事。
2. 整体设计与技术选型:为什么要做一套自己的模拟器
2.1 用指令集模拟器而不是直接编译到宿主机
先回答一个很多人会问的问题:项目为什么选择自己写一个指令集模拟器,而不是把所有逻辑直接用 C 语言跑在宿主机上?
因为“直接编译”会丢掉最核心的学习价值。如果我用普通 C 函数模拟“飞船姿态解算”,那我只需要调一个函数,传入角度参数,返回修正量。过程中内存怎么排布、寄存器的值怎么流转、位运算怎么做,全部被编译器隐藏了。而用指令集模拟器,是我亲手给虚拟 CPU 设计一套指令规则,再让汇编器把助记符翻译成二进制字,最后模拟器按字解释执行。每一条指令背后都有一个明确的“状态变化前后对比”。
打个比方:直接编译像是坐自动驾驶汽车到目的地,你只关心结果对不对;指令集模拟器像是骑一辆没有挡风和导航的老式摩托,路上每一段颠簸、每个转弯你都得亲自处理。后者慢,但你真的学会了路。
2.2 指令集为什么要精简
真实飞行器上的代码,通常要求可预测、低开销、高可靠。Apollo-11 在设计指令集时也刻意保持了精简,只保留最核心的几类操作:
- 装载与存储:在寄存器和内存之间搬数据。
- 算术与逻辑:加、减、与、或、移位。
- 跳转与条件分支:改变程序执行流向。
- 输入输出:往面板或通道写数据,从外部读状态。
- 等待/定时:让程序在时间上可控。
这样的指令集,熟练之后可能不到一个小时就能全部掌握。但你会立刻意识到一个现实:指令越少,完成一件稍微复杂的任务就需要更多指令组合。这正是项目想让你体会的“硬件受限”感。一台现代处理器可能有几百条指令,可真正在编译器和操作系统的调度下高频使用的也往往就那几十条核心指令。精简指令集反而更能暴露程序设计的本质。
2.3 内存和外设的取舍逻辑
项目把内存分成两部分:4K 字的 RAM 和 64K 字的只读存储区。RAM 用来放运行期需要修改的数据,比如计数值、临时变量、状态标志;只读存储区放固定程序代码,以及一些不会改变的常数表。
为什么这样设计?因为真实系统中,程序代码通常固化在只读区里,防止意外改写;变量则放在可写区,允许程序动态更新。Apollo-11 保留这个划分,强迫你在写程序前先想清楚:“哪些东西是代码,哪些东西是数据”。很多初学者第一次写汇编时,往往会把数据串在代码区中间,结果执行到数据当指令去解析,机器立刻跑飞。这种错误在真实项目中也是相当危险的。
面板显示部分,项目采用了一个简单的“输出通道”机制。程序只需要向某个通道号写入一个字符或状态码,模拟器就把它渲染到屏幕面板上。这个设计把复杂 UI 隔离在核心模拟之外,让初学者不用关心图形渲染细节,只管数据的产生与发送。这种模块化思路在实际工程里也很常见:UI 和逻辑分离,调试时更方便。
3. 实操过程:在本地跑通 Apollo-11
3.1 准备本地环境
Apollo-11 的模拟器主体代码推荐用 C 语言实现,所以你需要的工具其实很少:
- 一个能运行 C 代码的编译环境,比如 gcc 或 clang。
- CMake 工具,方便管理构建过程。
- Python 3,主要用在辅助工具脚本上,比如把汇编器输出转成模拟器需要的格式。
- 一个支持 ANSI 转义的终端,Windows Terminal 或任意 Linux/macOS 终端都行。
项目本身是跨平台的,并不依赖特定操作系统。我在本地的 Linux 环境下编译运行,过程中没有遇到系统相关的坑。Windows 上只要安装了 MinGW 或 WSL 环境,流程也完全一样。
提示:CMake 版本不用太追求新,3.16 以上基本够用。旧版本可能无法识别某些新写法,但核心代码几乎不依赖高级 CMake 特性。
3.2 获取源码、编译模拟器
拿到源码的步骤很简单,找一个合适的工作目录,把仓库克隆下来。我这里不写死具体地址,因为项目可能在不同平台都有镜像,你用自己的 fork 或课程提供的仓库地址都行。
git clone <your-apollo11-repo> cd apollo11 mkdir build cd build cmake .. make -j4 make install编译过程通常很顺利。如果cmake ..提示找不到某些依赖,多半是缺少 readline 库或 ncurses 库。这类问题一般在发行版软件源里都能直接装,装好之后重新执行 cmake 即可。
构建完成后,会在build/bin下面生成两个核心命令:a11-asm和a11-sim。前者是汇编器,把.asm结尾的源文件编译成可加载的镜像;后者是模拟器,加载镜像并逐条执行。
有一点需要注意:汇编器和模拟器是两个独立的程序,中间通过一个文本格式的镜像文件衔接。这样设计的好处是,你随时可以只运行汇编器,检查生成的二进制数据,不用启动模拟器。对学习汇编的人来说,这个中间产物是很好的调试抓手。
3.3 编写并运行第一个汇编程序
为了验证整条工具链,我写了一个最简单的程序:点亮面板第一路指示灯,然后进入停机状态。源文件我命名为first.asm:
; first.asm ; 把 1 写入 30 号输出通道,点亮面板第一盏灯 START LOAD ONE OUT #30 HALT ONE DEC 1 END START保存后,在源码目录下执行:
a11-asm first.asm -o first.bin a11-sim first.bin如果一切正常,终端上会出现一个模拟面板,第一路指示灯亮起。这个程序虽然短,但完整走完了“写汇编、汇编器翻译、模拟器加载、CPU 执行、外设输出”的链路。第一次跑通的时候,那种“这行代码真的驱动了一个虚拟硬件”的感觉,还是挺上头的。
3.4 理解汇编器的输出格式
为了让“调试抓手”更可用,可以打开生成的first.bin看一看。它的每一行通常包含地址和数据两部分,比如:
0000 0001 0001 0000 0002 0000地址是程序将要被加载到内存的位置,数据是该位置上放的字。如果后续程序中有跳转指令,汇编器会计算JMP后面的目标地址,并把它填入指令字中。手动查看这种文件,可以直观理解“源代码里的标签”最终是如何被翻译成数字地址的。
在这里我想额外说一句:很多人写汇编时会忘记指令与数据在内存中其实是“同一种东西”。汇编器不会区分某段内存到底是代码还是数据,它只是按照你的声明把数字填进去。因此,如果标签位置写错,程序跳到数据区,那个数也会被当成指令执行。这个坑我们会在第 5 章详细说。
4. 核心细节解析:指令、内存与实时循环
4.1 内存布局与寄存器定义
Apollo-11 的模拟器为 CPU 定义了以下核心寄存器:
| 寄存器 | 宽度 | 作用 |
|---|---|---|
| A | 16 位 | 主累加器,几乎所有算术运算都经过它 |
| L | 16 位 | 扩展累加器,配合 A 做长数据和移位操作 |
| Q | 16 位 | 乘商寄存器,存放乘法结果或除法商 |
| Z | 15 位 | 程序计数器,指向下一条指令所在地址 |
| B | 15 位 | 基址寄存器,用于某些变址寻址 |
| S | 16 位 | 程序状态字,保存进位、零、溢出等条件标志 |
寄存器数量不多,和现代处理器的寄存器堆相比显得寒酸,但恰恰因为少,你才会不得不频繁使用内存来中转数据。举个例子,如果要计算x = y + z,你得先把 y 装载到 A,再把 z 加到 A,最后把 A 存回 x 的地址。每一步都清晰可见。
内存布局采用“低地址为 RAM,高地址为只读区”的方式。RAM 从0x0000到0x0FFF,只读区从0x1000开始。汇编器会把.ram段的内容放到低地址,把.rom段的内容放到高地址。程序入口默认指向只读区的起始位置,这要求你在代码里用END START之类的伪指令声明入口。
4.2 指令系统速览
不需要把每一条指令背下来,只要掌握几类基本模式,就能开始写了。Apollo-11 的指令通常由一个记忆符加一个操作数组成,常见的有:
LOAD addr:把内存地址addr里的值装载到寄存器 A。STORE addr:把寄存器 A 的值存到内存地址addr。ADD addr:把内存地址里的值加到 A,结果留在 A。SUB addr:把内存地址里的值从 A 中减去,结果留在 A。JMP addr:无条件跳转到addr。JZ addr:如果上次结果为 0,则跳转到addr。JNZ addr:如果上次结果不为 0,则跳转到addr。IN port/OUT port:从一个输入通道读数据,或把数据写到输出通道。WAIT ticks:让 CPU 等待固定节拍,用于实现定时任务。HALT:停止指令执行。
指令的寻址方式也很简单:多数是直接地址寻址,LOAD 100就是把地址 100 的内容读出来。项目里还有一个间接寻址模式,操作数前加I,比如LOAD I 100表示“先读 100 地址里的值,把它当成新地址,再读新地址里的值”。这种间接寻址在实现指针和跳转表时很管用。
注意:间接寻址时,如果第一个地址里存放的值超过了内存范围,模拟器会直接报出“越界访问”错误。这实际上是帮你在运行早期就发现 bug,而不是像某些系统那样静默返回错误值。
4.3 写一个真正的控制循环
光点亮一盏灯还不够,我试着实现了一个 5 秒定时闪烁的程序。飞行器控制程序里非常依赖周期性执行:读传感器、算偏差、输出修正量,然后再等下一拍。Apollo-11 里可以用WAIT指令制造这个节拍。
; 每秒切换一次面板第二路指示灯状态 START LOAD #0 ; 初始状态 STORE FLAG LOOP LOAD FLAG XOR MASK ; 取反 FLAG STORE FLAG OUT #31 ; 输出到第二路通道 WAIT #20 ; 等待 20 个节拍 JMP LOOP MASK DEC 1 FLAG DEC 0 END START这个程序虽然简单,但涉及了几个重要概念:
- 用
#0这种写法表示立即数,也就是“把 0 这个值本身放进寄存器”,而不是“去地址 0 里取值”。 - 用 XOR 指令做位翻转。FLAG 在 0 和 1 之间切换。
- 用
OUT #31把状态写进输出通道,模拟器会据此更新面板灯。 - 用
WAIT指令让循环在时间上保持稳定。
如果你学过现代操作系统的延时函数,可能会觉得写法有点原始。但这个循环非常直白地展示了“忙等”的实现基础:CPU 并没有神奇的睡眠能力,它只是按时钟节拍数数,数完了继续执行下一条指令。
4.4 面板与外设通道的工作逻辑
面板显示被称为 DSKY,全称是“显示与键盘单元”。Apollo-11 用文本界面模拟了一个简化版,主要包含三块区域:指示灯区域、七段数字显示区域、键盘输入区域。
在程序里,输出通道的编号被设计成与面板区域一一对应:
| 通道号 | 对应外设 |
|---|---|
| 30 | 第一路指示灯 |
| 31 | 第二路指示灯 |
| 32 | 数字显示区低四位 |
| 33 | 数字显示区高四位 |
| 40 | 读取键盘输入 |
我刚开始以为OUT #30直接写一个数,面板就能显示完整的十进制。实际上,模拟器把通道看成“一组二进制位”,它只负责接收数据,不负责转换进制。要在面板上显示一个十进制数,需要自己把二进制转换成二进制编码的十进制(BCD)格式,然后分两次写入高低两组通道。这个细节让我瞬间明白了当年为什么会有“编码转换专用子程序”。
写程序时的建议是:把外设操作封装成一小段子程序,比如PUT_DEC,这样主程序里就不用到处写转换逻辑。在寄存器这么少的环境里,封装思路比高级语言里更重要,因为一个不经意的寄存器覆盖,可能让后面所有计算全乱套。
5. 常见问题与排查技巧实录
5.1 汇编阶段的报错与应对
我实际跑的过程里,汇编器报错最频繁的是“符号未定义”。符号就是你在代码里写的那种标签,比如LOOP、FLAG。汇编器在编译时会把标签翻译成地址,但如果某个标签在末尾找不到,它自然就报错。排查方法非常简单:逐行检查标签名是否一致,特别是大小写。Loop和LOOP在不同项目里处理方式可能不同,Apollo-11 是严格区分大小写的。
另一个常见问题是“重复定义”。同一个标签出现在两处,汇编器不知道该用哪个地址。这种情况多半是复制粘贴代码时没有改名字。把重复的标签改掉,或者干脆给每个模块加一个前缀,比如TMR_FLAG、NAV_FLAG,就清爽很多。
5.2 运行阶段的崩溃与死循环
程序能汇编成功,不代表能正确运行。我第一个循环程序一运行就卡死,终端看起来像失去响应。检查后发现,问题出在WAIT指令的节拍数太大,模拟器默认时钟又设得很慢,CPU 每执行一条指令都需要刷新一次终端。最终表现就是界面长时间不更新。
解决办法是在启动模拟器时加一个性能参数,把模拟时钟调快:
a11-sim first.bin --speed 10--speed 10表示模拟器按 10 倍速运行。这样循环刷新的频率能降下来,终端不会再卡。
还有一个典型的运行期问题是“跳到了数据区”。如果你的程序包含了类似MASK DEC 1这样的数据定义,而这些数据紧跟在代码后面,程序又通过顺序执行落到了数据行,模拟器就会把DEC 1当成指令。此时可能发生两种结果:要么指令完全无意义,程序行为诡异;要么模拟器直接报非法指令错误。这个问题的本质就是我在 4.2 节里说过的“数据和指令没有放到不同段”。建议把所有数据集中在代码开头或末尾,并确保主程序用JMP或HALT明确止住,不要让执行流自然滑进数据区。
5.3 调试技巧速查表
下面这个表格是我在调试时用到的排查顺序,基本覆盖了绝大多数问题:
| 现象 | 优先排查点 | 处理建议 |
|---|---|---|
| 汇编提示符号未定义 | 标签拼写、大小写 | 统一标签命名规则,避免缩写 |
| 程序启动后立即崩溃 | 入口地址是否错误 | 检查END伪指令后面的标签 |
| 面板灯不亮 | 输出通道号是否写错 | 核对通道表,确认没有写反高低位 |
| 循环卡死 | WAIT 节拍设置过大 | 降低节拍数或调快模拟器速度 |
| 显示内容乱码 | 是否缺 BCD 转换 | 先确认原始通道数据是否正确 |
| 某个寄存器值莫名变化 | 子程序覆盖了寄存器 | 进入子程序前保存 A 或 L,返回前恢复 |
调试时我最依赖的功能是模拟器自带的指令跟踪模式。启动时加上--trace参数,它会每执行一条指令就打印一行信息,包括程序计数器、指令名、目标地址、A 寄存器的值。这个信息量大得惊人,但定位“哪一条指令造成了错误状态”非常高效。建议不要用眼睛扫,直接重定向到日志文件:
a11-sim first.bin --trace > trace.log然后看最后一次执行前 A 寄存器变成异常值的那条指令,基本就能锁定问题。
6. 实测心得与一点扩展建议
我在跑 Apollo-11 的过程中感受最深的一点是:现代的编程工具链把太多东西隐藏得太好了。我们习惯了数组越界有运行时检查、变量名随便起、函数层层抽象,很少去想如果一切都要自己维护会是什么状态。这个模拟器虽然教学属性很强,但它的“笨拙感”反而是最宝贵的教学资源。
如果你准备继续深挖,我建议按这个顺序做扩展练习:
- 基础版:用 Apollo-11 实现一个简单计数器,在面板上倒计时。
- 进阶版:为一个虚拟传感器实现滤波,把连续 10 次读取结果求平均。
- 挑战版:写一个反应式控制循环,每 5 个节拍读一次输入,根据输入值切换输出通道。
每一步都会逼着你更仔细地处理寄存器冲突、内存布局和循环边界。我实际做完这三步之后,再回去写普通单片机的代码,明显感觉到自己对“每一行代码背后发生了什么”的把握更扎实了。
最后再分享一个小技巧:不要只在理想环境下写指令。你可以在模拟器里故意构造一个非法内存访问或者非法指令错误,看看模拟器会输出什么样的诊断信息。了解错误长什么样,以后真正遇到时才不会被吓倒。毕竟,能控制错误的程序,才算真正掌握了自己的程序。