如果你在代码托管平台上以 Apollo-11 为关键字翻项目,大概率会看到一个不太一样的仓库:README 里没有炫酷截图,没有“快速开始”的漂亮徽章,整整齐齐摆着的是一堆汇编源码、一份古董级仿真器说明,以及让现代程序员既兴奋又头皮发麻的八进制数字。我第一次打开它时,还以为自己误入了某个计算机博物馆的档案室,直到我把这套源码编译通过、把模拟器跑起来,屏幕弹出一个带着数字按键和指示灯的复古面板时,才真正意识到——自己正在操作的是那台把人类送上月球的导航计算机。
这个项目能做什么?一句话概括:它让半个多世纪前阿波罗制导计算机(AGC)的原始飞行软件,在今天的普通电脑上重新编译、运行、并通过模拟器面板与之交互。如果没有它,你只能在论文和回忆录里远远观望那套系统;有了它,你可以亲手敲进一条指令,观察寄存器翻转,再看着程序把模拟飞船的实时状态刷在屏幕上。
它解决的并不是“再开发一套登月系统”这种工程问题,而是“如何把一段古老且关键的系统代码,变成今天还能用的学习材料”这个传播与教育问题。适合谁去啃?嵌入式开发者、对实时操作系统感兴趣的人、复古计算爱好者、想做代码考古的程序员,都能从里面挖到不少东西。就算你完全没接触过汇编,把它当成一部“六十年代程序员如何写实时系统”的活历史来看,也完全不亏。
1. 项目到底在讲什么:把那台登月计算机带回现代
1.1 一个“代码考古现场”能做什么
你可能见过形形色色的开源项目:Web 框架、游戏引擎、命令行工具、机器学习库……但 Apollo-11 属于非常罕见的一类——它本质上是一个代码考古现场。项目把当年飞行计算机里的软件从磁芯存储和纸带中还原成文本文件,再配上模拟器,让你能在现代硬件上重新“考古发掘”这段代码的每个细节。
这个仓库包含的不仅是一堆.asm源码,还有配套的编译工具、模拟器脚本、说明文档,甚至一些历史注释被原样保留。你对着一行行汇编,能隐约看到当年程序员在硬纸卡上画流程图时留下来的严谨痕迹。在真实的历史中,这套系统的代码是硬生生“织”进存储单元的,修改一个常数可能就要重新穿线打孔,而开源版本让我们可以自由地编辑、编译、烧录、运行,这种体验放到当年是想都不敢想的。
更重要的是,这个项目让“登月导航”不再是教科书上的神话。你可以在模拟器里看到:程序如何采集惯性测量单元数据、如何计算剩余速度、如何根据引力模型不断更新位置,又是如何在主任务间隙处理来自地面指挥中心的指令。屏幕上的数字按键和指示灯,就是当年宇航员唯一能看到的交互界面。项目把这些链条完整还原,你完全可以把它当成一个实时的嵌入式系统教学沙盘——带浓厚历史滤镜的那种。
1.2 为什么老掉牙的硬件反而值得读
我最近几年看过不少“旧代码”复盘:有些是十年前的大型单体应用,有些是上世纪的大型机程序,但没有任何一段像 AGC 源码那样让我感到“零件越旧,结构越精致”。原因在于,那台计算机的硬件资源,今天看来已经逼近“不可能”的边缘:内存容量如果换算成现代标准,大概只有数十万字节的量级,CPU 频率也不过是如今单片机的零头,但就是这套配置,要在几十万公里的奔月途中完成实时制导、姿态调整、引擎点火和备份切换。
硬件弱,反而逼出了软件设计上的极致克制。没有高级语言编译器帮你隐藏复杂性,没有现成的操作系统帮你管理任务,所有东西都摊在一张很低的抽象层上。你读到的每一行指令,几乎都能直接对应到某个寄存器、某块物理存储或者某个外设接口。现代开发者被框架和中间件包得严严实实,很多时候反而不容易理解“一条指令到底对硬件做了什么”。而 AGC 源码把这个过程完整打开了:想刹车就直接写引擎控制位,想切换程序就直接改任务表指针,没有多余的一层“封装”。
这也是为什么很多嵌入式老兵在读了 AGC 代码之后会感叹:当下很多所谓“实时系统”的设计思路,其实在这套半个世纪前的软件里已经以某种更朴素的形式出现了。资源受限不是负担,反而是打磨架构的契机。你不需要认同它的每一个写法,但你很难不被那种在刀尖上跳舞的工程决策所启发。
1.3 适合谁去啃这份源码
先说结论:不需要你是航天工程师,也不需要你会登月轨道动力学,但至少对“CPU、内存、寄存器”这些词不陌生会舒服很多。如果你写过一点 C 语言,哪怕四年没碰汇编,也能在查表的过程中慢慢读懂。真正需要的准备工作只有两样:一点耐心,一份好奇心。
如果你是做实时操作系统的,建议直接从任务调度模块开始读。看它如何把飞行阶段切分成带优先级的任务环,如何在中断服务里更新导航参数,如何用软件计时器驱动周期性的陀螺平台校准。这套逻辑和你今天在小型 RTOS 上看到的消息队列、任务状态表、临界区保护惊人地相似,但它完全建立在手工管理的“信任模型”上,没有任何内存保护,任务之间一旦越界,谁都不好过。
如果你是做应用开发、平时只跟业务逻辑打交道,那我更推荐当“游客”来逛:看看 DSKY 模拟器长什么样,敲几个程序编号,感受一下六十年代的人机交互。你不需要理解每条指令,只要看到一套完整系统如何在一堆简陋的寄存器上构筑出来,就已经值回票价了。
2. 在跑代码之前,先搞懂 AGC 这台远古机器的脾气
2.1 硬件实力:几万字节内存如何撑起登月导航
读代码最怕的就是“不知道它为什么这样写”。所以在我开始看源码之前,花了大概一个晚上把它当年的硬件手册捋了一遍,这才发现很多看似奇怪的代码,其实是硬件约束下的必然产物。
AGC 的数据字长是 15 位(外加 1 位校验位),这让它在很多场合天然适合用“三位八进制”来表示:15 位刚好拆成 5 个八进制字符,什么地址、常数、指令,在人的记忆里都很直观。它的存储分两类:一类是只读的固定存储,保存着全部飞行程序和常量,容量大概只有几万“字”;另一类是易失的磁芯内存,只有几千字,用于存放运行中的变量、临时计算结果和任务状态。这个比例意味着,程序员必须把绝大多数东西放到只读区,可变数据要精打细算,能复用就绝不多占。
更让人紧张的是计算性能。以今天的标准来看,它的速度大概只相当于教科书里教“计算机组成原理”时用来举例的老掉牙处理器。但飞行控制是一套硬实时系统,很多计算必须在一个固定时间片内完成。于是软件必须采用大量查表法,提前把三角函数、轨迹参数、滤波系数都算好存成常数表,运行时只做最精简的运算。你在源码里看到一堆堆魔法数字,背后全是当年工程师为了在几微秒内拿到结果而预付的成本。
2.2 存储工艺像织毛衣:理解绳索存储器
关于 AGC 有一个特别出圈的冷知识:它的只读存储被称为“绳索存储”,因为存储数据的过程确实像在用磁芯穿绳子——不,准确说是像织毛衣。每一根导线穿过某个磁芯就代表一个 1,绕开就代表一个 0。软件一旦以这种物理方式“固化”,想改一个 bit,理论上就得把绳子抽出来重新穿一遍。
这个背景直接影响了软件质量控制。既然改代码成本如此之高,那么在“织”进硬件之前,必须经过极其严格的模拟验证。你会在项目文档里看到很多测试清单、仿真流程,这正是为了把“返工”的代价前置到软件阶段解决。现代开发者总觉得编译和热重载是理所当然的,但在那时,代码冻结的仪式感比发布会要重得多。
理解了绳索存储,你再看源码时会多一层敬畏:那些看起来平凡无奇的常量表,可能固定了登月舱在某个阶段的质量特性、引擎推力的预期值、甚至某个子系统的逻辑误判上限。很多注释写得尤其详细,因为一旦代码物理固化,这些注释就成了后人调试时唯一能依赖的线索。开源版本保留了这种“注释即文档”的优良传统,读起来有种穿越时光的对话感。
2.3 硬件限制为软件架构埋下哪些伏笔
硬件不够、软件来凑的典型例子,发生在“中断”和“任务调度”上。AGC 没有我们今天常见的高频时钟和独立的 DMA 通道,它的日常工作几乎是“被逼着”设计成一种可抢占的协作式调度:程序主体按固定周期循环,中断服务程序在需要时插入,把所有紧急的实时更新放在最高等级。
因为硬件不提供复杂的中断向量表,它只能用一个相对简单的跳变机制来捕获事件。程序员必须自己在合适的位置打开或关闭中断窗口,手动保存和恢复关键寄存器,稍不留神就会导致任务被破坏。你在源码里会看到大量“分阶段”处理的程序块,正是为了避免在修改同一个数据时被中断打断。这个习惯放到今天依然经典,它教会你:临界区保护不是某种 RTOS 专属魔法,而是原始且必须的底层生存法则。
另一个被硬件约束逼出来的设计,是“表格驱动”。软件把飞行过程中的各种阶段状态、指令长度、寄存器用途都塞进固定的表里。主程序通过翻表来决定下一步动作,很像现代状态机引擎的早期形态:没有面向对象,没有状态模式,但状态转移的逻辑已经非常清晰。你读着读着会发现,所谓“好的架构”其实和语言无关,和有没有对象也没关系,核心永远是“如何用最朴素的资源把问题边界切清楚”。
3. 源码结构与核心技术点拆解
3.1 仓库布局:一次能装进内存的软件
把仓库克隆到本地之后,第一件事是看目录结构。它并不是一个单体大项目,而是按历史任务模块拆开的:有面向登月舱的软件包,也有面向指令舱的软件包,各自都包含完整的汇编源码、构建脚本和配套文档。按照源码里的导览注释,登月舱软件的体积比指令舱软件要大不少,因为登月阶段涉及的传感器、推进器控制和着陆决策逻辑更复杂。
代码规模其实相当克制。放到今天,一个最小的 Web 微服务摘出来可能都比这整套飞行软件大。但你千万别小看它,内容密度极高——没有一行冗余的库函数调用,没有一句空泛的封装,每个子程序的名字就是它的全部文档,剩下的细节全靠代码本体说话。你可以按照这样的大致结构去翻阅:
Apollo-11/ ├── LM_software/ # 登月舱飞行软件,着陆阶段主战场 ├── CSM_software/ # 指令舱飞行软件,负责奔月与返回 ├── simulator/ # 仿真器源码,负责解释执行 AGC 汇编 ├── docs/ # 历史手册、流程说明、交叉参考 └── tools/ # 构建、检验、转换脚本如果你第一次进去不知道从哪里开始,我的建议是:先找软件自检模块,再看导航主循环。自检模块会让你很快理解寄存器怎么用,主循环则会带你进入整个状态下轮的节奏。这两块啃下来,再往后看中断处理和飞控逻辑,就不会迷路。
3.2 AGC 汇编语言与常见指令
AGC 使用自己的汇编语言,网上可以找到详细指令手册。这里只挑几个出现频率最高、也最能帮你读代码的指令说。
TC:转移控制。它的作用很像函数调用和跳转的混合体,可以把程序计数器的值切到目标地址。调用子程序的时候,代码里往往先把它要返回的地址塞进某个寄存器,再用TC跳走,等子程序结束再跳回来。CCS:清零并跳转。它会把累加器的值取出来,清零累加器,然后根据原值是负数、零、正数中的哪一种,跳到不同的分支。这兼顾了“取值、清零、条件分支”三件事,是典型的硬件受限时代“一条顶三条”设计。AD/CS:加法与取反。现代汇编里司空见惯,但它的特殊之处在于,加法结果会影响后续的状态位,很多任务流程正是靠这些状态位串联起来的。INDEX:索引修改。它可以在执行下一条指令之前,用累加器的值去改动下一条指令的操作数地址。你可以把它理解成最原始的“指针间接访问”,当年程序员为了遍历数组和查表没少用它。
下面拿一小段伪代码示意一下它的逻辑,具体细节并不重要,你只需感受那个风格:
TC CHKFAIL # 跳到故障检查子程序 CCS A # 取出累加器并清零,根据正负分支 AD TABLE1 # 将某个常数表内容加到累加器 INDEX A # 用累加器修正下一条地址 TC PROCESS # 根据索引跳转到对应处理流程这种写法看起来非常底层,但它直白、高效、不藏任何私货。现代开发者读这类汇编最大的障碍不是指令语法,而是“为什么不用数组、为什么不用函数指针”的惯性思维。其实INDEX加TC的组合,就是一套手搓的函数指针表:通过累加器当参数,动态选择接下来进入哪段逻辑。当你理解它之后,反而会有点上瘾,因为每一个操作都能在脑海里映射成硬件动作,没有一点玄学。
3.3 实时任务调度与现代 RTOS 的遥远呼应
Apollo-11 源码中最有价值的部分,我认为是它的任务调度设计。网上简略浏览的人看到的是成堆的跳转,但真正沉下心看的人会发现,它已经具备现代 RTOS 的原型:一个“任务列表”存放着若干待处理工作,每个任务都有优先级;调度循环不断检查有哪些任务被触发,按优先级挑一个执行,执行完挂起再等下一个。
这套机制的载体不是结构体,不是链表,而是一块固定区域的内存里的一组地址和状态位。任务触发方式也很有意思:有些是时间触发,比如周期性校准陀螺平台;有些是事件触发,比如引擎点火信号到达;有些则是地面指令触发,由宇航员在 DSKY 面板上输入程序编号来主动激活。多个任务“同时”到达的时候,就会按照预设优先级排队。
因为存储区域极小,它甚至没有一份完整的“当前任务状态保存区”,很多任务之间的切换信息要依赖共享的寄存器区域传递。这带来一个非常犀利的软件工程启示:当你的系统必须无内存保护运行时,任务之间绝不能轻易共享可变数据。当年的代码通过“把变量绑定到特定任务”和“临界区短小精悍”来解决这个问题。今天我们在 RTOS 里做数据隔离和互斥锁,本质上是把这些问题标准化、工具化,而不是消灭问题。
4. 实操:把 Apollo-11 工程编译并运行起来
4.1 准备环境:编译六十年代代码也没那么难
在动手之前,我以为这套老古董源码会有各种花式兼容性问题,结果发现比我预想的顺利。我当时用的开发机是一台 Linux 环境,已经装好了常见编译器工具链。需要额外准备的主要是一个简单的图形依赖库,用于让模拟器画出 DSKY 面板;缺少它的话,编译到一半会报一个找不到某个头文件的错误。
如果你打算按同样路线走完整个流程,大致步骤是这样:先把仓库克隆到本地,进入项目根目录,查看哪个子目录包含用于构建模拟器的 Makefile。通常在现代 Linux 发行版下不需要修改任何源码参数,只要保证依赖齐了,直接执行构建命令就能得到模拟器可执行文件。构建过程会编译两个部分:一部分是把 AGC 汇编源码翻译成可加载的二进制镜像的整合工具,另一部分是实时解释执行这个镜像的模拟器本体。
整个编译耗时在同类型项目中属于很轻量的,哪怕普通配置的电脑也就一两分钟。如果遇到编译错误,先别急着怀疑代码太老,多数情况是缺依赖而不是逻辑不兼容。你不必理解所有编译选项,只需要确保能够产出可执行文件、同时拿到处理后的镜像文件,这两样就是你“登月”的第一批装备。
4.2 构建仿真器并启动 DSKY 面板
构建完成后,直接运行模拟器可执行文件,应该会跳出一个黑乎乎但很有年代感的窗口,窗口中央是 DSKY 面板:一堆数字按键、几排 5 位八进制数字显示灯和少量状态指示灯。我第一眼看到满屏 0 和几个测试模式亮灯时,其实有点“这算启动成功了吗”的犹豫。确认方式很简单:看面板上的程序编号是否进入了一个固定值,同时数字显示区按节奏刷新,说明内置自检已经在跑。
如果你希望把某个特定历史版本的飞行软件加载进去,通常可以给模拟器传一个指向软件镜像的参数,或者通过启动器界面选择软件版本。我第一次启动时用的就是默认版本,它会先跑一段上电自检,然后进入待机状态。此时你能控制台上切换不同的瞬时程序,比如把系统切入一个更具体的制导计算逻辑,观察寄存器变化和屏幕显示。
这个模拟器并不会给你提供漂亮的图形化月面场景,它的“画面”全部通过 DSKY 面板上的数字和灯来表达。看似简陋,但当你意识到当年宇航员看到的也完全是这样一组数字时,那种亲手“驾驶”历史载具的沉浸感,反而比任何 3D 动画都强烈。你甚至可以按面板上的按键改变程序状态,体会一次地面指挥与机载计算机之间的“人机协作”流程。
4.3 在模拟面板上验证程序状态
打开面板后,建议按下面的思路做一次简单验证:先观察当前显示的 verb/noun 状态,这是 DSKY 界面的核心概念。verb是动作类型,noun是数据项编号。比如执行verb 06表示“显示指定数据”,后面输入noun 25就代表显示某个导航状态。看到数据在八进制灯上刷新,就说明计算机确实在按软件逻辑计算,而不是静止的一幅图。
还可以切换到程序编号模式,看看主程序目前挂载的是哪一个飞行阶段的软件。通过输入新的程序编号,你可以让系统离开当前阶段,切到另一个状态。每次切换,模拟器内部会完成一次任务表的重新装载,这在源码中的体现是一连串的分配和跳转。如果你已经会看一点寄存器转储,甚至能手动单步执行几条汇编,观察指令指针如何沿着代码跳跃,那体验会更上一层楼。
在我跑通之后,最想提醒的其实是:别急着追求“能飞到月球”。模拟器最大的价值是让你理解“这个软件的每一个数字变化都有意义”。你只要验证面板能响应键盘输入、显示能跟随状态变化、模拟器日志会实时输出寄存器和内存访问记录,说明这套历史环境已经在你机器上活过来了。接下来是深入读源码、还是跑更多仿真场景,都随你。
5. 我在折腾这个项目时踩过的坑
5.1 编译阶段:环境差异是第一道坎
我在第一次编译时,就撞上一个很典型的兼容问题:系统缺少 SDL 相关头文件,编译进度走到模拟器图形部分时直接报错退出。报错信息看起来非常底层,像是“找不到某个 OpenGL 扩展头”,容易让人误以为需要改源码。其实解决办法特别朴素:把对应的开发库包装上,重新执行构建,问题就消失了。如果你用的是其他发行版或 macOS,基本也是同样思路,先确认有可用的编译环境,再把系统提示缺的依赖补上。
还有个容易被忽略的坑:某些老源码是用旧版本语法写的,默认编译器可能只是警告,不影响结果,但如果你在构建时开了“把警告当错误”的严格模式,构建会中断。我的建议是保持默认选项,先让它顺利产出可执行文件,再考虑是否警告清零。毕竟我们是来学历史的,不是来做代码规范审计。
如果你远程连到一台没有图形界面的服务器,建议先安装图形转发或者在本地跑;否则模拟器可能启动了,窗口却没地方显示,看起来就像进程挂起。我当时就是在服务器上折腾了半天,最后老老实实回到带桌面的环境才看到 DSKY 面板。
5.2 运行阶段:模拟器有画面但没反应
DSKY 窗口启动后,如果按键没反应,先别怀疑程序坏了。常见的原因有两个:一是窗口焦点迷路,鼠标点了别的地方再回来,键盘输入却还在旧窗口;二是某些模拟器版本需要先点击一下 DSKY 面板区域,才能把键盘事件绑定到面板的按键上。解决办法也很简单,反复点击数字键区域,观察状态灯有没有变化即可。
还有一种情况:程序确实在执行,但显示刷新非常缓慢,看起来像“死机”。你可以打开模拟器自带的日志输出,观察周期性的计算记录。如果日志在持续滚动,说明核心计算没问题,只是可视化界面比较朴素,并不是卡死。这时候可以尝试通过菜单或快捷键切换不同的显示模式,把调试信息显示打开,你会看到更多的内部状态。
最后,如果你加载了一个自己修改过的软件镜像,但镜像格式和模拟器预期不一致,程序可能跑到一半就进入异常模式。这个问题的典型特征是:面板显示全零、状态灯乱跳、日志里出现奇怪的地址访问。别慌,重新用原始版本跑一遍,若一切正常,就说明问题出在你的修改或加载格式上,而不是环境本身。
5.3 读源码阶段:那些魔数和八进制约定
读 AGC 汇编时,新手最容易掉进去的陷阱是数字进制。源码里到处都是以0开头的常数,很多其实是八进制字面量,不是十进制。你用现代的“0 开头代表八进制”习惯去读,反而没问题,问题在于你会下意识把它换算成十进制去理解逻辑,然后总觉得哪里对不上。更好的办法是,阅读的时候优先关注这个常数是给谁用的:如果是存地址,就按地址去地图里查它在哪个模块;如果是运算系数,就找到对应的物理推导过程。
另外一个常见疑问是:某些指令明明看起来像一个嵌套的子程序调用,却找不到先前的返回地址压栈操作。原因在于 AGC 没有专门的堆栈,返回地址是手动塞进某个寄存器或内存单元里的。你按照现代思维读代码时,会觉得它“不太对劲”,但只要接受了“手工管理返回链”这个设定,一切就通顺了。
我在读的时候会随手做一件很有用的事:把所有子程序入口和调用点记录在一张草稿纸上。两个小时后,这张纸就成了原始的调用图,许多看似无关的跳转立刻变成了一组清晰的模块关系。这比追问每条指令在做什么高效得多。
5.4 从复古项目里带走的工程启发
折腾完这个项目一段时间后,我复盘出了几条对今天依然有用的经验。第一,资源受限不是恶,它是架构的试金石。现代工程动辄引入庞大依赖,很多时候不是因为问题复杂,而是因为我们对“边界”的感知变钝了。AGC 团队没有无限内存,所以他们必须把每个字节都当作战略资源来规划,最终催生出清晰的分层和精确的任务划分。
第二,注释和命名是真正的长期投资。那些五六十年前的注释,也许在今天看来有点“过于详尽”,但正因如此,后人才能隔着几十年把逻辑读通。如果你写代码时想着“这地方以后会不会有人考古”,自然就会少写一些只有你能看懂的魔法操作。
第三,仿真器是学习老系统的最佳入口。把历史软件跑起来,比读十篇技术普及文都管用。你不需要搭太空飞船,就能验证飞行软件的大部分核心算法;这种“能运行、能观察、能修改”的体验,是任何文字材料都无法替代的。
最后说说我现在的感受:第一次在模拟器上看到 DSKY 数字跳起来的那一刻,我确实愣了一下。它没有想象中震撼,却有一种极朴素的说服力——原来靠这么简单的界面、这么有限的资源,人就敢把自己送往一个完全陌生的地方。我后来每次向朋友推荐这个项目,都会加一句话:别急着把它当作能在简历上写的技术栈,把它当成一座需要你弯下腰走进去的老房子,砖缝里藏着的才是真正有点年头的好东西。