1. 项目缘起与整体设计思路
第一次看到“Apollo-11”这个标题,很多人脑子里蹦出来的可能是那个著名的登月计划。但在我们这群折腾代码的人眼里,它其实是另一个东西——一份被完整开源出来的、阿波罗11号制导计算机(AGC)的源代码与仿真项目。我第一次接触它是在一个深夜,当时正在翻找一些老式嵌入式系统的资料,结果一头扎进了这个由汇编指令和注释组成的“时间胶囊”里。这个项目能做什么?简单说,它把1969年那台只有约2MHz主频、几KB内存的导航计算机,从指令集到任务逻辑,几乎原汁原味地搬到了现代开发环境里,让你能在自己的电脑上编译、运行、甚至单步调试当年登月舱下降段的那段代码。
这个项目适合谁来参考?如果你是对计算机体系结构、汇编语言、实时操作系统或者航天软件工程感兴趣的人,那它简直是一座富矿。哪怕你只是个刚学编程的新手,想看看“没有高级语言、没有现代IDE的年代,人类是怎么把代码送上月球的”,它也能给你非常直观的冲击。我写这篇东西,就是想把我自己从“下载源码”到“跑通仿真”这一路上踩过的坑、想明白的道理、以及那些文档里不会写的细节,原原本本地分享出来。核心关键词就三个:AGC汇编、指令集仿真、任务线程调度。你把这三点吃透了,整个项目在你眼里就不再是一堆天书,而是一台可以拆解的精密钟表。
为什么这个项目值得花时间?因为现代软件开发太“厚”了。我们写一行代码,背后有操作系统、有框架、有虚拟机、有容器,层层抽象把硬件细节遮得严严实实。而Apollo-11的代码是“薄”的,薄到你能看见每一条指令如何操作累加器,每一个周期如何被精确分配。这种体验,就像开惯了自动挡突然去开一台手动挡的老爷车,一开始手忙脚乱,但开顺了之后,你对“车”的理解会完全不一样。我选择从源码结构入手,而不是直接跑仿真,就是因为只有先看懂它的组织方式,后面遇到报错时你才知道该往哪个方向查。
2. 核心细节解析与实操要点
2.1 源码目录结构与模块划分
拿到源码包之后,第一件事不是急着编译,而是把目录结构摸清楚。Apollo-11的源码主要分为几个大块:制导与控制模块、导航模块、显示与键盘接口、任务调度器,以及最底层的指令集定义。每个模块下面又按功能分成若干汇编文件,文件名通常很短,比如P63代表下降段动力着陆程序,P64代表上升段,这些编号是当年任务规划时就定好的,你在源码里会反复看到它们。
我建议你先把Luminary099这个目录(对应登月舱的软件版本)和Comanche055(对应指令舱的版本)区分开。很多人一开始搞混了,编译出来的东西行为不对,查了半天才发现是版本选错了。这两个版本虽然共享大量底层代码,但任务逻辑差异很大。登月舱的代码更关注下降和上升的推力控制,指令舱的代码则侧重导航和返回。你如果只是想体验“登月”过程,那就盯住Luminary099。
注意:源码里有很多以
.agc为后缀的文件,这些是汇编源文件。不要用普通的文本编辑器直接改,因为AGC汇编对格式极其敏感,列位置错了都可能编译不过。我习惯用支持固定列宽显示的编辑器,把制表符设置成8个空格,这样能看清每一列的对齐关系。
2.2 AGC指令集的特点与理解难点
AGC的指令集和现代CPU完全不是一个路数。它是单地址指令,也就是说大部分指令只带一个操作数,另一个操作数默认是累加器A。这听起来简单,但实际写起来非常绕。比如你想做加法,得先把一个数加载到A,再执行加法指令,把内存里的另一个数加到A上。没有乘法指令,乘法靠移位和加法循环实现;没有浮点单元,所有小数都用定点数表示,小数点位置由程序员自己约定。
理解这套指令集的关键,是搞清楚基本指令周期。AGC的一个基本周期是11.7微秒左右,大部分指令需要1到2个周期。但有些指令,比如乘法,需要的时间长得多。当年程序员为了省时间,会把一些常用操作写成“短指令”,把不常用的写成“长指令”。你在读源码时会看到很多注释,标着“T1”“T2”之类的,那就是周期计数。我一开始忽略这些注释,结果仿真跑起来发现时序完全不对,后来才明白这些周期数直接决定了任务调度的节奏。
还有一个坑是内存寻址。AGC的内存是分页的,每页256个字,寻址范围有限。所以你会看到大量“切换存储体”的指令,就像在图书馆里先走到某个书架前,再取书。这种设计在现代人看来很笨拙,但在当时是无奈之举——内存太小,地址线不够。理解这一点,你才能看懂为什么源码里到处是BANK相关的操作。
2.3 任务调度与中断处理机制
Apollo-11的软件最让我佩服的地方,是它的任务调度器。它没有现代操作系统的进程、线程概念,而是用一套“执行器”和“等待列表”来管理任务。每个任务有一个优先级,调度器按优先级从高到低扫描,找到第一个就绪的任务就执行。如果所有任务都在等待,就执行“空闲任务”。
中断处理也很特别。AGC有两个中断入口:一个是定时器中断,每11.7微秒触发一次,用来驱动调度器;另一个是程序报警中断,当某个任务超时或者出错时触发。我实测下来,定时器中断的优先级最高,它会打断当前任务,保存现场,然后跳转到调度器。这个过程在源码里写得非常紧凑,只有几十条指令,但逻辑滴水不漏。
实操心得:如果你想单步跟踪中断处理,一定要在仿真器里把“周期计数”打开。因为中断的触发和返回都依赖精确的周期数,差一个周期,后面的行为就可能完全乱套。我试过用现代调试器的“断点”功能去跟,结果发现断点本身会引入额外周期,导致仿真结果不可信。后来改用仿真器自带的“周期步进”功能,才把中断流程看清楚。
3. 实操过程与核心环节实现
3.1 环境搭建与仿真器选择
要跑Apollo-11,你有两条路:一是用现成的仿真器,比如yaAGC,它是最经典的选择;二是自己写一个指令集模拟器,这适合想深入理解底层的人。我两条路都走过,先说第一条。
yaAGC的安装不算复杂,但依赖一些老版本的库。我在一台Linux机器上编译时,遇到了readline库版本不兼容的问题,报了一堆链接错误。解决办法是手动指定库路径,或者干脆用Docker跑一个老版本的镜像。如果你不想折腾环境,可以直接下载预编译的二进制包,但要注意版本匹配——仿真器版本和源码版本必须对应,否则指令集可能有细微差异。
自己写模拟器的话,核心是指令译码和内存模型。AGC有8条基本指令类型,每条指令的操作码占3位,剩下的位用来表示地址或子指令。你需要实现一个循环:取指、译码、执行、更新周期计数。内存模型要模拟分页机制,还要处理“奇偶校验位”——AGC的内存每个字有15位数据加1位奇偶校验,写入时要计算校验位,读取时要验证。我一开始偷懒没做校验,结果仿真跑了几百万周期后突然崩溃,查了半天才发现是某个内存位翻转了。
3.2 编译源码与加载可执行文件
源码编译需要用到agc汇编器,它会把.agc文件转换成二进制机器码。编译命令通常长这样:
agc Luminary099/MAIN.agc -o luminary099.bin但这里有个细节:AGC的汇编器对符号表的处理很特殊。源码里定义了大量符号,比如P63、RADAR之类的,汇编器会生成一个符号表文件,仿真器加载二进制后还需要读这个符号表,才能在调试时显示符号名而不是裸地址。我第一次编译时只拿了.bin文件,结果调试时满屏都是数字,根本不知道自己在哪。后来把.sym文件也带上,世界才清晰起来。
加载二进制到仿真器后,你需要设置初始状态。AGC上电后会从固定地址开始执行,但那个地址的代码通常是自检程序。如果你想直接跳到某个任务,比如下降段,就得手动设置程序计数器。我建议先跑一遍完整的自检,确认仿真器工作正常,再跳转。
3.3 运行下降段任务与参数观察
下降段任务(P63)是Apollo-11里最精彩的部分之一。它要控制登月舱从月球轨道下降,经过多个阶段:制动段、接近段、着陆段。每个阶段都有不同的推力策略和导航更新。你在仿真器里运行P63时,可以观察几个关键变量:高度、下降速率、剩余燃料。
我实测时发现,如果初始高度设得不对,比如设成100公里而不是实际的15公里左右,P63会在制动段就耗尽燃料,然后触发报警。这是因为P63的推力计算是基于预设的高度和速度曲线的,你给一个离谱的初值,它就按离谱的曲线去算。所以,如果你想复现历史,最好查一下当年的任务参数,把初始状态设准。
注意:仿真器里的“燃料”是一个模拟量,不是真实物理量。它根据推力指令和比冲计算消耗,但不会考虑燃料晃动、管路延迟这些细节。所以仿真结果只能用来理解逻辑,不能用来做工程分析。
3.4 调试技巧与日志分析
调试AGC代码,最有效的工具是指令跟踪日志。仿真器可以输出每一条执行的指令、操作数、累加器值、周期计数。这个日志文件会非常大,跑一秒仿真可能产生几十万行。我的做法是:先跑一小段,比如几千个周期,然后用脚本过滤出关键指令,比如跳转、中断、内存写入。
另一个技巧是设置条件断点。比如你想知道什么时候高度低于1000米,可以在仿真器里设一个断点,条件是“内存地址XXX的值小于某个数”。这样就不用盯着日志看了。我试过用Python写一个简单的日志分析脚本,把关键变量的变化画成曲线,一眼就能看出任务是否正常。
4. 常见问题与排查技巧实录
4.1 编译报错与符号冲突
编译时最常见的错误是符号重复定义。AGC汇编器不允许同一个符号在多个文件里定义,但源码里有些符号是全局的,有些是局部的。如果你不小心把两个模块的局部符号搞成了全局,就会报错。解决办法是检查符号的作用域声明,确保局部符号用=而不是EQU定义。
另一个坑是地址溢出。AGC的内存地址是12位,最大4096个字。但源码里有些模块会超过这个限制,需要手动调整存储体分配。我遇到过一次,编译通过了,但运行时报“非法地址”,查了半天发现是某个数组越界,写到了保留区。
4.2 仿真运行时的异常行为
仿真跑起来后,最常见的异常是死循环。AGC代码里有很多等待循环,比如等待某个标志位清零。如果标志位永远不清零,程序就卡住了。排查方法是看周期计数是否还在增加——如果增加,说明在循环;如果不增加,说明仿真器挂了。
还有一种异常是中断风暴。如果某个中断处理程序没有正确清除中断标志,定时器中断会不断触发,导致调度器永远在跑中断,普通任务得不到执行。我遇到过一次,原因是中断处理程序里少写了一条“清除中断”的指令。加上之后,一切正常。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 编译报符号重复 | 局部符号误声明为全局 | 检查符号定义处的关键字 | 改用局部符号定义方式 |
| 运行时报非法地址 | 数组越界或存储体分配错误 | 查看报错地址附近的代码 | 调整存储体或修复越界 |
| 程序卡死不动 | 等待循环条件永不满足 | 检查周期计数是否增加 | 修正标志位设置逻辑 |
| 中断频繁触发 | 中断标志未清除 | 查看中断处理程序末尾 | 添加清除中断指令 |
| 仿真结果与预期不符 | 初始状态设置错误 | 核对任务参数 | 按历史数据重新设置 |
避坑技巧:每次修改源码后,先跑一个“最小测试”——只执行几条指令,确认基本功能正常,再跑完整任务。这样能把问题范围缩小,避免大海捞针。
5. 从Apollo-11学到的工程思维
5.1 资源受限下的极致优化
Apollo-11的代码让我重新理解了“优化”这个词。我们现在说优化,往往是“把响应时间从100毫秒降到50毫秒”,但AGC的优化是“把内存占用从2KB降到1.5KB”,因为总共就那么多内存。源码里有很多“复用”技巧:同一个内存地址在不同任务阶段存不同的东西,同一个子程序被多个任务调用但通过参数区分行为。
这种思维方式在今天依然有用。比如你在写嵌入式代码时,内存和CPU周期都是有限的,学会“一物多用”能让你在同样的硬件上跑更多功能。我后来在做一个物联网项目时,就借鉴了AGC的“分时复用”思路,把几个传感器的数据处理合并到一个循环里,省下了一个定时器资源。
5.2 冗余设计与故障恢复
AGC的软件里有大量冗余检查。比如导航数据会同时用雷达和惯性测量单元交叉验证,如果两者差异过大,就触发报警并切换到备用方案。这种“不信任单一数据源”的思想,在现代分布式系统里也很常见。我后来在设计一个数据采集系统时,就模仿了这种思路:每个关键数据点都从两个独立通道采集,比较后再决定是否采用。
还有一个细节是看门狗。AGC有一个硬件看门狗,如果软件超过一定时间没有“喂狗”,就会自动重启。源码里喂狗的操作被放在调度器的空闲任务里,这样只要调度器还在跑,看门狗就不会触发。这个设计很巧妙,因为如果调度器都挂了,那软件确实该重启了。
5.3 代码可读性与注释文化
最后说一个让我感触很深的点:AGC源码里的注释非常详细。每一条关键指令旁边都有注释,解释“为什么这么做”,而不是“做了什么”。比如有一段代码是计算推力方向的,注释里写了“这里用近似算法,因为精确算法需要太多周期,而任务周期预算不够”。这种注释在今天看来简直是奢侈品——我们写代码时往往只写“做了什么”,很少写“为什么”。
我后来在自己的项目里强制要求:每个非显而易见的决策都要写注释,说明当时的权衡和约束。这不仅能帮别人理解,也能帮未来的自己回忆。有一次我翻出半年前写的一段代码,看到注释里写着“这里用轮询而不是中断,因为中断延迟会导致数据丢失”,瞬间就想起了当时的场景。
6. 后续可以继续折腾的方向
如果你已经把基本的仿真跑通了,接下来可以试试几个进阶玩法。一是修改任务参数,比如把月球重力改成地球重力,看看下降段会怎么变——这能帮你理解控制算法的鲁棒性。二是自己写一个简单的AGC程序,比如让显示面板输出一段文字,这能让你彻底掌握指令集。三是把AGC的调度器移植到现代平台上,用C或Rust重写一个简化版,对比一下性能差异。
我目前正在做第三个方向,用Rust写了一个小型的任务调度器,模仿AGC的优先级扫描机制。实测下来,在同样的任务集下,AGC的调度器虽然简单,但上下文切换开销极低,因为它是“非抢占式”的——任务自己决定什么时候让出CPU。这种设计在现代RTOS里也有影子,比如一些协作式调度器。如果你对实时系统感兴趣,这条路值得一走。
最后分享一个小技巧:读AGC源码时,不要试图一次看懂所有文件。挑一个你最感兴趣的任务,比如P63,然后顺着它的调用链往下读,遇到不认识的指令就查指令手册,遇到不认识的符号就查符号表。这样一圈下来,你对整个系统的理解会比泛泛地浏览深得多。我当初就是这么干的,花了三个晚上,把下降段的代码从头到尾跟了一遍,那种“原来如此”的感觉,比跑通仿真本身还过瘾。