1. 项目缘起与核心定位
第一次看到“Apollo-11”这个标题,很多人脑子里蹦出来的可能是那段人类首次载人登月的历史。但在我这个常年泡在代码和开源项目里的人眼里,它指向的是另一个东西——一个在GitHub上被反复fork、star数一路走高的开源项目,全称通常写作chrislgarry/Apollo-11。这个仓库做的事情非常纯粹:把当年阿波罗11号制导计算机(AGC,Apollo Guidance Computer)上运行的原始汇编源代码,完整地数字化、整理并公开出来。
你可能会问,一堆五十多年前的汇编代码,放到今天有什么好看的?这恰恰是这个项目最迷人的地方。AGC是整个人类历史上第一批真正意义上的嵌入式数字计算机之一,它的主频只有大约2MHz,内存小得可怜——核心存储(Core Rope Memory)里固化着指令,擦写存储(Erasable Memory)只有区区几KB。就是在这种今天看来连一个电子表都不如的硬件条件下,它完成了登月舱下降、上升、交会对接、惯性导航等一系列极其复杂的实时控制任务。这个项目能让你直接读到当年那批工程师是怎么在极端资源约束下写出“能飞”的代码的。
这个内容适合谁?我认为有三类人值得花时间研究。第一类是嵌入式开发和底层系统工程师,想看看在没有现代编译器和操作系统的年代,软件工程是怎么做的。第二类是对计算机历史、航天史感兴趣的爱好者,想从代码层面理解登月这件事。第三类是想学习“极限优化”思路的开发者,AGC的很多设计取舍,放到今天的性能优化场景里依然有启发。不管你是哪一类,这个项目都不是“随便看看”就完事的,它需要你带着问题去读。
我个人的判断是,Apollo-11这个开源项目的价值,不在于它能不能被“跑起来”——实际上它确实可以被模拟器跑起来——而在于它是一份活生生的工程档案。它把“软件如何在硬件资源极度受限、容错要求极高、且无法现场调试的环境下工作”这个问题,用最原始的方式回答了一遍。接下来我会从整体设计、核心细节、实操复现、问题排查几个角度,把这个项目拆开讲透。
2. 内容整体设计与思路拆解
2.1 为什么要把汇编源码数字化公开
先说这个项目最核心的动作:把纸质打印稿上的汇编代码,逐行转录成可读的文本文件,再配上注释和说明。这件事听起来简单,做起来极其枯燥。当年的源代码是以打印清单的形式存档的,字迹、格式、页码都有不少问题,有些地方甚至有手写修改。项目维护者做的事情,本质上是一次“代码考古”。
为什么要费这个劲?因为AGC的软件是人类早期大规模实时控制软件的代表作之一。它的设计思路和今天的主流做法差别很大。今天的开发者习惯了抽象层、框架、依赖管理,而AGC的代码是直接对着硬件寄存器操作的,每一条指令都要考虑执行周期和存储占用。把这份代码公开,等于把那个年代的工程决策过程完整地展示出来。你可以看到他们怎么分配那几KB的擦写存储,怎么设计中断优先级,怎么在极小的内存里实现复杂的导航算法。
从方案选型的角度看,项目选择了“保持原貌”而不是“现代化重构”。也就是说,公开的是当年的汇编指令,而不是把它翻译成C或者Python。这个选择我认为是对的。一旦翻译,很多和硬件紧密相关的细节就丢失了,比如指令周期、寄存器约定、内存分页方式。保持原貌虽然提高了阅读门槛,但保住了信息的完整性。对于想真正理解AGC的人来说,读原版汇编是绕不开的。
2.2 代码组织结构与模块划分
AGC的软件并不是一个单一的大程序,而是按任务划分成多个模块。项目在整理时基本保留了这种结构。你会看到类似Luminary099这样的目录名,这是登月舱使用的软件版本代号。指令舱用的是另一个版本,叫Comanche055。这两个版本分别对应不同的飞行阶段和硬件配置,不能混用。
在每个版本内部,代码又按功能分成若干段。比如有负责姿态控制的、有负责导航的、有负责发动机点火的、有负责显示和键盘交互的。这种划分方式在今天看来就是“模块化”,但当年的模块化不是靠函数库实现的,而是靠固定的内存地址和跳转表。每个模块知道自己该在什么时候被调用,调用方和被调用方通过约定好的内存位置传递参数。这种设计在资源受限时非常高效,但可读性差,因为你看一个模块的时候,必须同时知道它依赖哪些全局内存位置。
项目在整理时加了不少注释,帮助读者理解这些约定。但我要提醒一句,注释是后人加的,不是原始代码的一部分。读的时候要区分哪些是当年的设计,哪些是整理者的解读。这个区分很重要,否则容易把后人的理解当成历史事实。
2.3 资源约束下的设计取舍
AGC最让人震撼的地方是它的资源约束。擦写存储只有2048个16位字,核心存储里固化的指令大约有36K字。听起来好像也不少,但你要知道,这些空间要同时容纳导航算法、控制逻辑、故障检测、人机交互等所有功能。今天的开发者写一个“Hello World”可能就要占掉几KB,而AGC要在几KB里实现登月。
为了做到这一点,当年的工程师做了大量取舍。比如,他们大量使用“自修改代码”——程序在运行时会修改自己的指令。这在今天是被严格禁止的,因为会带来不可预测的行为,但在当年,这是节省存储的有效手段。他们还把很多常量以“核心绳存储”的方式固化,因为核心绳存储一旦编好就不能改,但读取速度快、可靠性高。擦写存储则留给需要动态修改的数据。
这些取舍背后的逻辑是:在极端约束下,可维护性要让位于资源效率。今天的开发者可能觉得这种做法“不优雅”,但如果你把自己放到那个硬件条件下,就会发现这是唯一可行的路径。项目把这些代码公开出来,最大的价值就是让你看到“约束如何塑造设计”。
3. 核心细节解析与实操要点
3.1 汇编指令集与寻址方式解析
AGC使用的是一套自定义的指令集,通常被称为“AGC汇编”。它的指令长度是16位,其中一部分位表示操作码,一部分表示地址。寻址方式有好几种,包括直接寻址、间接寻址、相对寻址等。理解这些寻址方式是读懂代码的前提。
举个例子,AGC里有一条指令叫TC,意思是“Transfer Control”,相当于今天的函数调用。它会跳转到指定地址,同时把返回地址存到一个固定的寄存器里。还有CCS指令,用于比较和跳转,但它会同时修改累加器的值,用起来要特别小心。这些指令的行为和现代CPU差别很大,不能想当然地套用今天的经验。
我在读代码时踩过的一个坑是:AGC的很多指令会“顺带”修改某些寄存器的状态,而这些副作用在指令助记符里并不明显。比如某些算术指令会同时影响溢出标志和累加器。如果你不熟悉这些细节,读代码时就会觉得逻辑对不上。解决办法是准备一份指令集手册,读每条指令时都查一下它的完整行为,包括所有副作用。
提示:AGC汇编里的地址是八进制表示的,不是十进制也不是十六进制。读代码时看到
037这样的数字,要按八进制理解,也就是十进制的31。这个细节很容易搞错,一旦搞错,整个跳转逻辑就全乱了。
3.2 核心存储与擦写存储的区别
AGC的存储分为两大类:核心存储和擦写存储。核心存储是用“核心绳”工艺制作的,里面的内容在制造时就已经固化,无法修改。它的优点是可靠性极高,即使断电也不会丢失,而且读取速度快。擦写存储则是可读写的,用于存放运行时的变量和临时数据。
这个区分对理解代码至关重要。你在代码里看到的常量、固定的跳转表、不变的算法参数,通常放在核心存储里。而变量、计数器、传感器读数,则放在擦写存储里。项目在整理时,会用不同的标记来区分这两类存储,读的时候要注意看。
为什么这个区分重要?因为核心存储的读取和擦写存储的读取在指令层面是不同的。有些指令只能操作擦写存储,有些可以操作核心存储。如果你把两者搞混,就会觉得某些指令“用不了”。我在刚开始读的时候,就曾经困惑于为什么某些数据明明定义了却访问不到,后来才发现它被放在了核心存储里,需要用特定的方式读取。
3.3 中断处理与实时性保障
AGC是一个实时系统,它必须及时响应来自传感器、键盘、时钟等各种事件。为了做到这一点,它使用了中断机制。中断的优先级是固定的,高优先级的中断可以打断低优先级的处理。项目里的代码会明确标注每个中断服务程序的入口和优先级。
实时性保障的另一个关键是“周期任务”。AGC里有一个叫WAITLIST的机制,本质上是一个任务调度表。每个任务登记自己需要被调用的时间间隔,系统按时触发。这个机制和今天的定时器任务类似,但实现方式更原始,需要手动管理任务表。
我在研究这部分时最大的体会是:实时系统的设计核心不是“快”,而是“可预测”。AGC的代码里有很多地方看起来“绕”,但绕的目的是保证最坏情况下的响应时间是可计算的。今天的开发者如果做实时系统,这一点依然适用。不要只盯着平均性能,要盯着最坏情况。
3.4 人机交互界面的实现
AGC的人机交互界面非常简陋,只有一个数字键盘和几行数字显示。但就是在这个界面上,宇航员要完成复杂的操作。代码里有一整套“名词-动词”交互逻辑:宇航员输入一个“名词”(比如“当前高度”),再输入一个“动词”(比如“显示”),系统就执行对应操作。
这个设计的有趣之处在于,它把复杂的操作分解成了两个简单的选择。宇航员不需要记住复杂的命令,只需要知道名词和动词的编号。这种设计在资源受限、操作者训练时间有限的情况下非常有效。项目里的代码会展示这个交互逻辑是怎么实现的,包括输入缓冲、命令解析、显示刷新等。
读这部分代码时,我建议你重点关注“错误处理”。宇航员输入错误是常有的事,系统必须能识别并给出提示,而不是崩溃。AGC的代码里有大量的输入校验逻辑,这些逻辑在今天看来可能很繁琐,但正是它们保证了系统的健壮性。
4. 实操过程与核心环节实现
4.1 获取源码与目录结构梳理
要复现这个项目,第一步是把源码拿到本地。项目托管在公开的代码托管平台上,直接克隆即可。克隆下来之后,你会看到根目录下有几个主要文件夹,分别对应不同的软件版本。每个版本文件夹里,是大量的.agc文件,这些就是汇编源文件。
我建议先不要急着读代码,而是先花点时间梳理目录结构。看看有哪些文件,文件名大概暗示了什么功能。比如文件名里带NAV的可能和导航有关,带CONTROL的可能和控制有关。这个初步印象会帮助你在后面读代码时建立上下文。
梳理完结构后,可以找一份“入口点”文件。AGC的软件在启动时会从一个固定的地址开始执行,这个地址对应的代码通常在某个文件的开头。找到入口点,顺着执行流程往下读,比随机翻文件效率高得多。
4.2 搭建模拟器环境
AGC的代码不能直接在今天的电脑上运行,需要模拟器。社区里有好几个成熟的AGC模拟器,有的用C写,有的用Python写。我推荐先用Python写的模拟器,因为安装简单,代码可读性也好,方便你边跑边看。
搭建环境的步骤大致是:先安装Python运行环境,然后用包管理工具安装模拟器依赖,最后把AGC的二进制镜像加载进去。这里有个关键点:AGC的源码是汇编文本,不能直接运行,需要先“汇编”成二进制。项目里通常会提供一个汇编脚本,或者直接提供已经汇编好的二进制文件。如果你用的是后者,就省去了汇编这一步。
注意:不同模拟器对二进制格式的要求可能不同。有的要求特定字节序,有的要求特定的文件头。加载失败时,先检查格式是否匹配,再检查文件是否完整。我遇到过因为文件下载不完整导致加载失败的情况,重新下载就好了。
4.3 汇编源码并生成可执行镜像
如果你想从源码自己生成可执行镜像,就需要用到AGC的汇编器。项目里通常会附带一个叫yaYUL之类的汇编器,它是用其他语言写的,需要先编译。编译过程不复杂,但依赖一些工具链,比如C编译器。
汇编的过程是:把所有的.agc文件作为输入,汇编器会检查语法、解析符号、生成二进制。这个过程可能会报错,尤其是当你修改了源码之后。报错信息通常会指出文件和行号,顺着查就行。我建议第一次汇编时不要改任何代码,先确保原始源码能顺利通过,这样你就有了一个“基准”。
汇编成功后,你会得到一个二进制文件,这个文件就是可以加载到模拟器里的镜像。把它和模拟器配合起来,就能看到AGC“活”起来的样子。
4.4 运行与基础交互验证
加载镜像后,模拟器通常会显示一个类似AGC面板的界面,有数字显示和键盘输入。你可以尝试输入一些基本的“名词-动词”组合,看看系统怎么响应。比如输入“名词65”(表示“当前高度”之类的量),再输入“动词01”(表示“显示”),看看显示区有没有变化。
这个阶段的目标不是“登月”,而是验证系统能正常运行。如果显示区有反应,说明模拟器和镜像都工作正常。如果没反应,可能是输入格式不对,或者模拟器的键盘映射和真实AGC不同。查一下模拟器的说明文档,通常会有键位对照表。
验证通过后,你可以尝试更复杂的操作,比如启动某个任务、查看内存内容等。这些操作在模拟器里通常有对应的命令。我建议边操作边对照源码,看看你输入的命令在代码里是怎么被处理的。这种“操作-代码”对照的学习方式,比单纯读代码有效得多。
5. 常见问题与排查技巧实录
5.1 汇编报错与语法问题排查
汇编报错是最常见的问题。AGC汇编的语法和现代汇编差别很大,容易写错。常见的错误包括:地址格式不对(忘了用八进制)、指令操作数类型不匹配、标签重复定义等。排查时,先看报错的行号,然后对照指令集手册检查那一行的语法。
如果报错信息很模糊,可以尝试“二分法”:把源码分成两半,分别汇编,看哪一半报错。这样能快速定位问题区域。我遇到过因为一个隐藏字符导致报错的情况,这种问题用二分法很容易找出来。
提示:AGC汇编对大小写敏感,而且对空格和制表符的处理和现代汇编不同。建议用纯文本编辑器,不要用带自动格式化的IDE,否则可能引入不可见字符。
5.2 模拟器加载失败的原因分析
模拟器加载失败通常有几个原因:镜像格式不对、镜像不完整、模拟器版本和镜像不匹配。排查时,先确认镜像文件的来源和格式,再看模拟器的文档要求什么格式。如果格式对但还失败,检查文件大小是否和预期一致。
另一个常见原因是路径问题。有些模拟器要求镜像文件放在特定目录下,或者要求用绝对路径。如果相对路径不对,就会加载失败。我建议第一次使用时用绝对路径,排除路径干扰。
5.3 运行时的异常行为与调试思路
模拟器跑起来之后,可能会遇到异常行为,比如显示乱码、任务不响应、系统“卡死”。这些问题的排查思路是:先确认是不是输入错误导致的,再确认是不是模拟器本身的bug,最后才怀疑源码。
AGC的代码里有自检逻辑,如果检测到异常,会进入特定的错误处理流程。你可以通过模拟器的调试功能查看当前执行到哪条指令、内存里是什么值。这些信息对定位问题很有帮助。
我个人的经验是:大部分“异常”其实是操作不当导致的。比如输入了未定义的名词或动词,系统不知道该怎么处理,就会看起来像“卡死”。这时候查一下名词动词表,确认输入合法,往往就能解决。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 汇编报错,提示语法错误 | 地址格式、操作数类型、标签问题 | 对照指令集手册,检查报错行 |
| 模拟器加载镜像失败 | 格式不匹配、文件不完整、路径错误 | 检查格式要求,验证文件大小,用绝对路径 |
| 运行后显示无反应 | 输入格式错误、键盘映射不对 | 查模拟器文档,确认键位和输入格式 |
| 任务不响应 | 优先级设置问题、任务未注册 | 检查任务表,确认任务已正确登记 |
| 系统行为异常 | 源码被修改、模拟器bug | 用原始源码复现,排除修改因素 |
6. 从AGC代码里能学到的工程思维
6.1 极限约束下的代码优化思路
AGC的代码里到处都是“省”的痕迹。省存储、省周期、省寄存器。比如,他们会把多个布尔标志打包到一个字里,用位操作来读写。这种做法在今天看来很“抠”,但在当年是必须的。今天的开发者在做嵌入式或者高性能计算时,依然会遇到类似的约束,AGC的思路可以直接借鉴。
具体来说,AGC的优化有几个层次:指令级优化(选择周期更短的指令)、存储级优化(复用内存位置)、算法级优化(用近似代替精确)。每个层次都有取舍,取舍的依据是“这个操作有多频繁”。频繁的操作值得花大力气优化,不频繁的操作可以牺牲一点效率换取可读性。
6.2 可靠性设计与容错机制
AGC的可靠性设计非常值得研究。它有一套“自检”机制,会定期检查关键内存和硬件状态。如果发现异常,会尝试恢复或者切换到备份系统。这套机制的核心思想是“假设故障一定会发生”,而不是“假设系统不会出错”。
代码里有很多“冗余”逻辑,看起来是重复的,但目的是在某个部件失效时还能继续工作。比如,关键数据会在多个位置存储副本,读取时做多数表决。这种做法在今天的安全关键系统里依然常见,比如航空电子和医疗设备。
6.3 对现代嵌入式开发的启示
AGC的很多做法在今天看来已经过时,但背后的思维没有过时。比如,它对实时性的理解、对资源约束的应对、对可靠性的执着,都是现代嵌入式开发依然要面对的问题。读AGC的代码,最大的收获不是学会写AGC汇编,而是学会在约束下做设计。
我个人的体会是:今天的开发者太容易依赖“资源无限”的假设,写代码时不考虑内存和周期。一旦遇到真正的嵌入式环境,就会手足无措。AGC的代码提醒我们,好的工程不是在资源充足时把事情做对,而是在资源不足时依然能把事情做成。
7. 项目延展与个人实践建议
7.1 如何基于AGC代码做二次学习
如果你想深入学习,我建议从“复现一个简单功能”开始。比如,找到控制显示的那段代码,理解它是怎么把内存里的数字转换成显示信号的。然后尝试修改它,比如改变显示格式,看看模拟器里的显示有什么变化。这种“改一点、看一点”的方式,比通读全部代码有效得多。
另一个方向是“对比学习”。把AGC的某个算法和现代实现对比,看看当年的工程师是怎么在资源受限下实现同样功能的。比如导航算法,AGC用的是非常精简的版本,和今天的复杂算法差别很大,但核心思想是相通的。
7.2 适合练手的几个小任务
第一个任务是“让显示区显示一个自定义数字”。这需要你理解显示刷新逻辑,找到对应的内存位置,然后修改它。第二个任务是“修改一个任务的执行周期”,这需要你理解任务表的结构。第三个任务是“添加一个简单的输入响应”,这需要你理解名词动词的解析流程。
这些任务都不大,但能让你把AGC的代码结构和运行机制摸清楚。做完之后,你对整个系统的理解会上升一个层次。
7.3 我踩过的坑与经验总结
我在研究这个项目时,最大的坑是“想当然”。比如,我以为AGC的地址是十六进制,结果浪费了很多时间。又比如,我以为模拟器加载失败是镜像问题,结果是路径里有空格。这些坑都不大,但很耗时间。
我的经验是:遇到问题先查文档,文档没有就查社区讨论,社区没有就自己动手验证。不要凭猜测下结论。另外,保持耐心。AGC的代码不是一天能读懂的,每天读一点,积累起来就很可观。
最后分享一个小技巧:读代码时准备一个笔记本,把关键的内存地址、跳转目标、任务周期记下来。这些信息散落在各处,不记下来很容易忘。记下来之后,你会发现整个系统的脉络慢慢就清晰了。