仓库里装的是什么
打开仓库,第一眼看到的不是一堆代码,而是两套完全独立的飞行程序,各自带着自己的内存布局、任务编号和编译日期:
| 程序 | 代号 | 用途 | 定版时间 | 规模 |
|---|---|---|---|---|
| Comanche 055 | Colossus 2A | 指令舱(CM) | 1969-04-01 10:28 | 85 个文件 / 65,348 行 |
| Luminary 099 | LMY99 | 登月舱(LM) | 1969-07-14 16:27 | 90 个文件 / 64,838 行 |
这两个日期值得停一下:Comanche 定版离发射还有三个多月,Luminary 定版离发射只剩 两天。也就是说,登月舱里跑的那套代码,是在阿波罗 11 号发射前两天才最终定下来的。
代码的来源也不是什么“泄露版”或逆向工程。每一行都来自 MIT 博物馆收藏的纸质打印件扫描,由 Virtual AGC 项目和 Ron Burkey 等人逐页转录、校对,再以公有领域的形式公开。源文件头部的页码注释(Pages: 1208-1220)就是给校对的人用的——这一行代码,对应扫描件第 1208 页。
目录结构其实很简单
整个仓库只有两层实质内容:Comanche055/ 和 Luminary099/。剩下的 Translations/ 是四十种语言的 README 和贡献指南,跟飞行代码无关;package.json 里只挂了一个 markdownlint,用来检查文档格式。编译不在这里做——真正的汇编器是 Virtual AGC 项目里的 yaYUL。
先看约束,再看代码
不理解硬件,读这份代码会处处别扭。阿波罗制导计算机(AGC)是一台为“能在太空里活下来”而设计的机器,不是为程序员舒服设计的。
每个 15 位字里塞两条指令:3 位操作码加 12 位地址。地址不够用,就靠“银行切换”把固定内存分页,用 8 位的 FBANK、EBANK、BBANK 寄存器来选页。寄存器只有四个主角:累加器 A、链接寄存器 L、Q 和 Z。
这套约束直接决定了软件的形态:
没有浮点硬件
所有三角函数、向量运算、双精度加减,都得靠软件模拟。于是有了 Interpreter——一个跑在 AGC 上的虚拟机,制导程序基本都写在它的指令集里。
内存按“字”计算
2K 的可擦写内存要同时装下导航状态、任务队列、显示缓冲。所以代码里到处是复用、覆盖、精打细算的变量分配表(ERASABLE_ASSIGNMENTS)。
不能蓝屏
飞船上没人能拔电源重启。掉电、瞬断、程序异常都必须能自己恢复,于是有了 FRESH START 和 RESTART 这两套重建逻辑。
人机界面只有 verb / noun
DSKY 没有屏幕,只有数字键和指示灯。所有交互被压缩成“动词 + 名词 + 数据”的三段式,由 PINBALL 程序解释。
这套分层在 1969 年是超前的。解释器把“飞行逻辑”和“机器指令”隔开,任务程序不用关心内存页和字长;执行程序负责调度和恢复;硬件被彻底包在底层。今天操作系统课上的那套分层,它基本都有,只是每层都薄得可怜。
代码解剖:数字不会说谎
把 175 个 .agc 文件全部扔进统计器,得到的第一个结论是:这不是“随手写的汇编”,而是一份高度文档化的工程产物。两套程序的注释占比分别是 26.9% 和 26.6%——每四行里就有一行在解释“这段为什么这么写”。
代码都花在哪了
按文件名粗分类(有交叉,只看大方向),两套程序的重心明显不同:
最直观的差异:LM 的制导代码是 CM 的两倍(12,064 对 6,025 行)。原因不复杂——指令舱的任务是“飞过去、回来”,登月舱的任务是“分离、下降、着陆、起飞、交会”,每一步都要实时算。着陆制导、上升制导、卡尔曼滤波、油门控制,这些模块只存在于 Luminary 里。
体量最大的文件
P20-P25 是交会与对接程序,逻辑分支多、状态机复杂,成为全仓库最大的文件并不意外。PINBALL 接近四千行,说明“让宇航员用两个数字键和一堆灯完成操作”这件事,本身就不便宜。
指令分布:一台累加器机器的指纹
把两套程序合起来统计助记符,出现频率最高的几条是:
TC、TCF 加起来接近一万条,说明这套代码的控制流极其密集——大量子程序调用、状态跳转、条件分支。TS 排第二,是累加器机器的典型特征:算完一步就得赶紧存回去,因为没有通用寄存器堆。OCT 和 MASK 的高频出现,则指向另一件事:大量硬件接口操作是靠位运算完成的,一个离散量输入就是某一位的 0 或 1。
两套程序,一半是共用的
虽然指令舱和登月舱是两台独立的计算机,两套代码却共享了 51 个模块。执行程序、等待列表、解释器、报警处理、DSKY 驱动、IMU 补偿、轨道积分、交会程序(P20-P25)、点火程序(P40-P47)……这些都是同一份代码,分别编进两个程序。
差异部分很能说明两台机器的性格:
再入与指令舱本体
再入控制(REENTRY_CONTROL)、服务舱主发动机(SPS)、推力矢量控制(TVC 系列)、指令舱姿态与几何、星表、再入词汇表。它要解决的是“从月球回来,穿过大气层,落在海上”。
下降、着陆与上升
着陆制导方程、上升制导、卡尔曼滤波、主点火程序、油门控制、RCS 自动驾驶、着陆模拟显示。它要解决的是“从轨道上下去,停在月面,再飞起来”。
任务编号速查
阿波罗的任务程序用 P 编号(Program)。数字不是顺序编号,而是按功能分段的——P20 段是交会,P40 段是点火,P50 段是对准,P60 段是着陆。下面是从源码文件名里能直接读出来的分布:
| 程序 | 功能 | 指令舱(CM) | 登月舱(LM) |
|---|---|---|---|
| P11 | 地球轨道入轨监测 | P11.agc | — |
| P12 | 下降前准备:阶段切换与 IMU 状态检查 | — | P12.agc |
| P20-P25 | 交会导航(Rendezvous Navigation) | ✓ | ✓ |
| P30-P37 | 轨道机动、外部速度增量瞄准 | P30-P37、P32-33/P72-73、P37/P70 | P30/P37、P32-35/P72-75 |
| P40-P47 | DPS / APS 点火与推力控制 | ✓ | ✓ |
| P51-P53 | IMU 对准与姿态参考 | ✓ | ✓ |
| P61-P63(CM) | 再入三件套:EMS 初始化 → CM/SM 分离 → 再入初始化 | P61-P67.agc | — |
| P63(LM) | 动力下降与着陆(P63LM 入口) | —(CM 的 P63 是再入) | THE_LUNAR_LANDING.agc |
| P70-P71 | 中止(Abort)程序 | — | P70-P71.agc |
| P76 | 目标速度增量:更新对方飞行器的状态矢量 | P76.agc | P76.agc |
两个容易踩的坑。第一,两台机器共用编号,但含义可能完全不同:CM 的 P63 是“再入初始化”,LM 的 P63 才是“动力下降着陆”。第二,文件名叫 P63 的并不存在——LM 的 P63 入口写在 THE_LUNAR_LANDING.agc 里。文件名不是任务编号的镜像,这套代码按“功能段”组织文件,P 编号只是程序号。读之前先看文件头的 LOG SECTION,别按名字猜。
一台 1969 年的实时内核
这套代码最容易被低估的部分,是它的运行时。任务程序只是“应用”,真正让整台机器有序运转的,是下面这几个模块。它们合起来,就是一套完整的实时操作系统。
Executive:优先级调度器
执行程序维护 7 个 core set,每个 11 个寄存器,外加 5 个 VAC 区(Vacant Area,给解释性任务用的临时工作区)。任务要运行,先申请一个 core set;解释性任务还要再申请一个 VAC 区。申请不到,就报警。
# LOCATE AN AVAILABLE VAC AREA FINDVAC2 TS EXECTEM1 CCS VAC1USE TCF VACFOUND CCS VAC2USE TCF VACFOUND CCS VAC3USE TCF VACFOUND CCS VAC4USE TCF VACFOUND CCS VAC5USE TCF VACFOUND LXCH EXECTEM1 CA Q TC BAILOUT1 OCT 1201 # NO VAC AREAS.
这段代码朴素得近乎直白:依次问五个 VAC 区“你空着吗”,全满就跳去报警。没有链表、没有红黑树、没有优先级队列的抽象——五个 CCS 比较,就是全部。
Waitlist:按厘秒计时的任务队列
定时任务不叫“定时器”,叫 waitlist。调用者给一个延迟值(单位:厘秒,1/100 秒),waitlist 把它插入一个最多 9 个任务的链里,然后用 TIME3 倒数。源码注释写得很清楚:
# C(TIME3) = 16384 - (T1-T) CENTISECONDS # C(LST1) = -(T2-T1)+1 # ... # WARNINGS- # 1) 1 <= C(A) <= 16250D (1 CENTISECOND TO 162.5 SEC) # 2) 9 TASKS MAXIMUM
最大延迟 162.5 秒,最多 9 个任务。以今天的标准看少得可笑,但对一次着陆来说,需要“过一会儿再做”的事情本来就不多:更新显示、采样雷达、切换阶段、触发报警。
Interpreter:跑在 AGC 上的虚拟机
解释器是整套软件里最“现代”的部分。它定义了一套自己的指令集,专门处理向量、矩阵、双精度加减乘除、三角函数。任务程序用这套指令写,解释器负责翻译成 AGC 原生指令。
为什么要多这一层?因为 AGC 的硬件只认 15 位整数和累加器操作,而制导方程需要的是浮点向量运算。与其让每个任务程序自己实现一套数学库,不如做一台虚拟机,把“数学”变成指令。代价是速度——解释执行比原生代码慢得多——但换来了代码密度和开发效率。这是在 36K 字的内存里做取舍的必然结果。
解释器源码本身分了三节:调度器(SECTION 1: DISPATCHER)、加载与存储(SECTION 2)、加减法(SECTION 3)。三万行里的三千行,换来的是整个制导程序能用“类高级语言”的方式书写。
T4RUPT 与 PINBALL:一个管硬件,一个管人
T4RUPT 是定时中断程序,周期性扫描硬件状态、处理离散量输入、刷新显示。源码里能看到大量“每 480 毫秒”的调用约定——对一台 1969 年的机器来说,这已经是相当从容的节奏。
PINBALL 负责 DSKY。它的名字来自弹球机:宇航员按下一排发光的按钮,机器给出反馈,本质上和弹球机的交互没有区别。程序把操作压缩成 verb(动词)+ noun(名词) 两段两位数编码:verb 说明“要做什么”,noun 说明“对什么做”。键盘中断 KEYRUPT1/2 把 5 位键码塞进累加器,上行链路 UPRUPT 走同一套入口。
RESTART:把“重启”当成一等公民
FRESH_START_AND_RESTART 是整个系统里最不显眼、但最救命的模块。它要处理的情况包括:电源瞬断、程序异常跳飞、报警后的恢复。做法不是“从头再来”,而是重建关键的易失状态,再让任务从已知点继续。这套机制在登月过程中被反复触发,具体见下一节。
1202:源码里的那两行
1969 年 7 月 20 日,鹰号下降到最后几百米时,AGC 的报警灯亮了,DSKY 上跳出 1202。地面控制室里一片紧张,而飞控计算机负责人 Jack Garman 说了一句大意是“这个报警我们见过,可以继续”的话。最终着陆成功。
这个故事的软件侧,在源码里其实就是两行八进制常数:
# 申请 VAC 区失败 LXCH EXECTEM1 CA Q TC BAILOUT1 OCT 1201 # NO VAC AREAS. # 遍历 7 个 core set 失败 NEXTCORE CAF COREINC ADS LOCCTR CCS EXECTEM2 TCF NOVAC3 LXCH EXECTEM1 CA Q TC BAILOUT1 # NO CORE SETS AVAILABLE. OCT 1202
两个报警的含义写得很直白:1201 = 没有空闲的 VAC 区;1202 = 没有空闲的 core set。换句话说,不是算错了,是排不上号。下降过程中,交会雷达把大量数据灌进计算机,同时制导程序又要实时解算,任务请求超过了这台每秒八万次运算的机器能承载的上限。
关键在于接下来的设计:报警不是崩溃。执行程序会触发一次重启流程,丢掉优先级最低的任务(比如部分雷达数据处理),保住制导和显示,然后继续飞。宇航员看到的是灯亮了一下,程序号闪了闪,然后一切照常。
顺带说一句:那次下降中 1202 出现了多次,1201 也出现过。它们没有导致任务中止,因为软件本来就允许“过载—丢任务—继续”这条路径存在。真正救命的不是某一行代码,而是“允许降级”这个决定。
代码里的性格
这份代码全是冷冰冰的大写字母,但翻久了会发现,写它的人并没有把自己当成机器。最出名的一处,在登月舱的主点火程序里:
这段来历写在 BURN_BABY_BURN--MASTER_IGNITION_ROUTINE.agc 的注释里:1965 年洛杉矶骚乱期间,电台主持人 Magnificent Montague 在放最热的唱片时总爱喊这句话。几年后,Don Eyles 和 Peter Adler 把它变成了登月舱主点火程序的名字——字面意思也刚好对得上:点火,全力点火。
类似的东西还有不少。着陆程序里有这么一段:
FLAGORGY TC INTPRET # DIONYSIAN FLAG WAVING
CLEAR CLEAR
NOTHROTL
REDFLAG
CLEAR SET
LRBYPASS
MUNFLAG
FLAGORGY(标志狂欢)、DIONYSIAN FLAG WAVING(酒神式的挥旗)——这段代码干的事其实很规矩:按顺序清掉、置位几个标志位。但写它的人显然觉得,既然是“一次性挥舞一堆旗子”,不如就叫它狂欢。
这些命名不是彩蛋式的轻浮,反而说明了一件事:在极度紧张的工程里,幽默是一种维持清醒的方式。代码要被人读、被人校对、被人半夜叫起来排查,一个好名字比一段漂亮的注释更有用。
还有一个细节:每一份源文件的头部都写着页码范围、校对历史、修改人。比如 EXECUTIVE.agc 的 Pages: 1208-1220,INTERPRETER.agc 的 Pages: 1107-1199。这不是版本控制系统的产物,而是纸面工程的痕迹——那会儿的“commit”是打印出来、签字、归档。
顺带一提,指令舱这套程序(Colossus 2A)的编程负责人是 Margaret Hamilton。她在 CONTRACT_AND_APPROVALS.agc 里签的字,日期是 1969 年 3 月 28 日。软件工程这个词后来才流行,但这份签字文件基本就是它的出生证明之一。
今天为什么还值得读
不是为了怀旧。这份代码解决的几个问题,今天的系统仍然在解决,只是换了名字:
解释器 = 受控的抽象层
用一台软件虚拟机换取代码密度和开发效率,代价是性能。今天从 JVM 到 WebAssembly,做的是同一笔交易,只是预算不同。
优先级 + 资源配额
core set 和 VAC 区就是 1969 年的“线程栈 + 堆配额”。申请不到就报警,而不是无限等待——这是实时系统的基本自觉。
重启是功能,不是事故
FRESH START 的设计前提是“它一定会坏”。把恢复路径当成正常代码来写、来测试,比事后加 try/catch 可靠得多。
宁可少做,不能不做
1202 的处理方式是丢掉低优先级任务,保住制导。今天云服务的限流、熔断、降级,逻辑完全一样,只是规模大了几个数量级。
还有一点更朴素:这份代码是可验证的。每一行都能对到扫描件的某一页,每一个修改都有记录,每一个报警都有编号和出口。今天很多系统做不到这一点——不是技术不行,是没把“可核对”当成要求。
怎么读,怎么编译
仓库本身不含汇编器。package.json 里只有 markdownlint,bun.lockb 也只是文档工具链的锁文件。真要把它编成可执行的二进制,得用 Virtual AGC 项目里的 yaYUL:
# 1. 取汇编器(Virtual AGC 项目) git clone https://github.com/rburkey2005/virtualagc.git # 2. 按项目文档构建 yaYUL # 3. 汇编 Comanche055 / Luminary099 yaYUL --help
如果只是想读,不必编译。推荐按下面的顺序走,从“骨架”到“血肉”,避免一上来就被三千行的解释器劝退:
-
先看人和合同
读 CONTRACT_AND_APPROVALS.agc,知道这份代码是谁签字放行的,再看 README.md 了解数字化来源。十分钟,建立“这是一份真实工程文件”的实感。
-
看程序怎么启动
MAIN.agc 是入口和总体调用关系。先搞清楚“开机之后先跑什么”,后面读任何模块都有坐标系。
-
读调度,不读算法
EXECUTIVE.agc → WAITLIST.agc。这两个文件决定了“任务怎么排班”,是理解全部代码的前提。重点看 core set、VAC 区、优先级比较。
-
看人和机器怎么说话
PINBALL_GAME_BUTTONS_AND_LIGHTS.agc。别被名字骗了,这是 DSKY 的全部交互逻辑。看完你会明白为什么 verb/noun 是一套优秀的人机接口设计。
-
最后碰制导
先读 INTERPRETER.agc 的前两节,再看 THE_LUNAR_LANDING.agc 和 LUNAR_LANDING_GUIDANCE_EQUATIONS.agc。到这里,P63 的入口、相位切换、制导方程才连得起来。
-
彩蛋收尾
BURN_BABY_BURN--MASTER_IGNITION_ROUTINE.agc。看名字和注释就够了,顺便理解“点火”这件事在软件里有多重。
数据附录
仓库规模
.agc高频指令(两套程序合计)
| 助记符 | 含义 | 出现次数 |
|---|---|---|
| TC | 转移并返回(子程序调用) | 5,924 |
| TS | 存入累加器 | 4,109 |
| TCF | 无条件转移 | 3,750 |
| CAF | 取常量 | 1,894 |
| CA | 取数 | 1,871 |
| OCT | 八进制常量 | 1,539 |
| MASK | 位掩码 | 1,422 |
| DXCH | 双字交换 | 1,288 |