干这行久了,很多东西尝个鲜就放下了,但“Apollo-11”这个项目我前后磨了三版,从纯软件模拟一路做到带实体按键的桌面小装置,越做越上瘾。你可能觉得奇怪,半个多世纪前的登月制导计算机,和今天的开发环境完全不是一个物种,折腾它到底图什么?我的答案就一句:那是人类用最有限的硬件条件,硬生生解决了一个超高复杂度控制问题的巅峰样本。哪怕放到现在,它身上可挖的东西依然比很多现代工程多得多。
这个项目不是单纯做个怀旧皮肤,而是把阿波罗登月任务的核心计算系统——包括那个只有2K内存的制导计算机(常被简称为AGC)和它的交互终端DSKY——用现代手段重新实现一遍。你双击可执行文件,屏幕上会亮起那个标志性的数字键盘面板;你在键盘上敲动词和名词代码,就能切换登月程序、读取高度和速度;你甚至可以完整跑一遍从进入月球轨道到最终着陆的下降流程,看着燃料消耗和高度变化实时刷出来。适合谁?对航天历史感兴趣的人、想理解“资源受限下如何设计系统”的嵌入式开发者、以及单纯想在桌面上拥有一台能玩的复古设备的人。不管你是哪一类,这篇文章都能给你一套能落地的参考路径。
1. 项目整体设计与思路拆解
1.1 为什么选“登月计算机”作为复现目标
先说一个背景:阿波罗登月任务里,真正让飞船安全落到月球表面的,不是地面控制中心,而是飞船上那台“小得可怜”的计算机。它要同时处理惯性测量数据、控制发动机推力方向、调整姿态、跟宇航员交互,还得在内存几乎用尽的情况下保持系统不死机。这种“戴着镣铐跳舞”的设计方式,放到今天是完全反直觉的——我们习惯了买大内存、堆算力,但它证明了:只要算法和架构足够精妙,极低配置也能完成极高难度的任务。
对我个人来说,复现它的价值有三层:
- 理解实时系统的本质:AGC不是跑Linux再装个什么框架,它是单线程、轮询、直接操作寄存器的裸机程序。每一行指令都精确对应硬件行为,没有任何多余的抽象。复现过程中你会被逼着去思考“这条指令到底被哪段电路执行”“这周期怎么对齐”。
- 学习一种“语法式”人机交互设计:DSKY没有屏幕菜单,只有数字键盘和少量指示灯。宇航员通过输入“动词-名词”代码来指挥计算机。这种设计在信息密度极低的情况下,依然能覆盖上百种操作场景,现代嵌入式设备的人机交互设计中依然值得借鉴。
- 把零散知识串成体系:复现这个项目,你至少要碰到内存映射、bank切换、汇编指令集、数值积分、姿态控制、开关电源信号处理这些领域。它们不是割裂的知识,而是被一个真实任务串成的有机整体。
换句话说,这不只是写个模拟器,更像一次系统性的“计算机考古”。你会重新理解计算机是怎么一步步走到今天的。
1.2 关键路线取舍:硬件还原还是软件模拟
做这类复现项目,第一个要拍板的问题就是:走硬件路线还是软件路线。
硬件路线的极致是用FPGA去还原AGC的原始逻辑电路,或者用现代微控制器加实体按键、数码管做一个“桌面上能亮的DSKY”。优点当然是有实物感,敲按键的反馈、指示灯闪烁的仪式感是屏幕模拟给不了的。但缺点也很明显:调试成本高、周期长,而且如果你只是想研究算法和人机交互,硬件部分很容易喧宾夺主。
软件路线则更轻量,你可以用浏览器、Python、甚至一个MCU的裸机工程把核心逻辑跑起来。重点是“逻辑等价”,而不是“电路等价”。用这种方式,你可以专注分析AGC的内存布局、指令执行流程、登月下降算法,而不用先被示波器和逻辑分析仪拖住。
我自己最终采用的是“软件内核为主,硬件面板为辅”的分层方案:
- 内核层:用C语言模拟AGC的寄存器、内存池、指令执行循环,保证指令级别兼容。
- 交互层:编写DSKY键盘事件处理逻辑,把按键映射为动词-名词指令。
- 显示层:提供一个简易的图形界面,模拟数码管显示和告警灯状态。
- 可选外设层:通过串口连接一个实体DSKY面板,按键和指示灯是真实可用的。
这个分层最大的好处是:你可以先不管硬件,把内核和算法跑通,验证逻辑正确后再接实体面板。万一实体面板卡住了,也不会导致整个项目瘫痪。很多模拟器项目失败,就是因为在第一步就选了最重的方案,结果连基础逻辑都没跑通就失去了动力。
1.3 项目的总体架构设计
(以下是软件内核版的架构说明)
整个系统我分成了四个模块,彼此之间用清晰的接口切分:
- CPU模拟模块:维护AGC的寄存器组(如累加器、程序计数器、溢出寄存器等)、随机存储器(可擦写内存)、只读存储(核心内存)的镜像,逐条执行指令。
- 输入输出模块:处理DSKY的按键输入和灯阵状态。AGC和DSKY之间靠一组数据通路交互,你需要按它真实的“输出通道”格式去模拟。
- 轨迹计算模块:通过惯性测量数据(模拟数据,可以是预设曲线或用户手动设置)计算位置、速度、姿态,并驱动着陆雷达的高度测量。
- 任务调度模块:模拟真实任务中“程序”的切换,比如从轨道下降程序切到着陆减速程序。
这里有一个容易被忽略的设计关键:AGC的内存不是一块平坦的RAM,而是分bank(存储区)的。代码从某个bank切到另一个bank需要额外的指令,而且某些地址会因为bank切换而产生不同的含义。很多第一次做模拟器的人会在这一步被坑。所以模块设计时,我特意把整个存储空间抽象成一个“带bank上下文”的数据结构,而不是简单的数组。这一步代价不大,但避免了后期无穷无尽的地址错乱问题。
架构层面还有个小建议:把内核和界面逻辑做彻底解耦。不要因为想快速看到效果,就把按键处理和指令执行搅在一个函数里。真实情况是,界面代码会频繁改动,内核则保持稳定。如果你耦合在一起,每改一次UI都要重新验证一遍核心逻辑,效率会非常低。
2. 核心细节解析与实操要点
2.1 再看一次AGC的基本硬件结构
想真正复现而不是“画个皮”,你必须理解AGC的几个关键数字:
- 字长:15位二进制(另有1位符号位,所以很多资料写“15位”或“16位”要看清)。这意味着它的数值范围极窄,整数大约是正负16000多,内存地址空间也是按这个位数设计的。
- 可擦写内存(RAM):只有2048字。这就是它存储变量的一切空间。具体又分成多个bank,每个bank 256字,程序区和数据区在不同条件下复用。
- 只读内存(ROM):大约36K字。指令序列和常量都存在这里,通过“核心内存”工艺做成,本质上靠物理走线固化,飞行途中不可修改。
- 循环周期:一条基本的加减法指令需要约12微秒?更准确地说,它有一个脉冲定时系统,每个操作码都有固定的执行周期。在模拟器里,你需要把“周期”抽象出来,否则后面做实时性统计时会很被动。
用现代计算机的视角看,2K内存连一张普通图片都放不下,但这台机器承载了登月全程的制导运算。它靠的不是大存储,而是“精心设计的数据结构”。比如,位置和速度变量不是用通用的浮点格式,而是用一组定标小数(类似定点数)表示,数值范围、精度都提前想好了。复现时,我强烈建议你用定标小数而不是直接上浮点,否则你根本无法量化它的精度损失,也就看不懂它程序里那些补偿计算的由来。
这一点在实际开发中特别容易踩雷:现代程序员习惯了double,会觉得“用浮点是天经地义”。但在模拟AGC时,必须把数据类型当成“硬件的一部分”来模拟,因为原始程序的每一步都是在跟定标误差较劲。跳过这一步,你看到的就是一条条莫名其妙乘以常数的指令,整个逻辑链会断掉。
2.2 DSKY交互:一场基于“动词-名词”的对话
DSKY的全称翻译过来是“显示键盘”,它没有鼠标、没有命令行,只有:
- 一个数字键盘(0-9、正号、负号、清除等)
- 三个程序状态按钮(比如“功能键”之类)
- 一些显示数码管和告警灯
它的和人交互的核心句式是“动词 名词”。举个例子:
- 动词代码比如“指定数据显示”(不同版本编号略有差异),名词用来指向对象比如“高度”或“速度”。
- 宇航员先输入两位数字的动词代码,再输入两位数字的名词代码,然后按下执行键,计算机就明白“把某个数据显示出来”或者“执行某项操作”。
这种交互设计放在今天的视角依然很先进:它用极少的硬件实现了语义分层——动词表示“做什么”,名词表示“对什么做”,通过组合可以覆盖大量场景。你甚至不需要理解底层寄存器,就能设计出几十种指令组合。
在复现时,我建议你把DSKY的输入解析模块单独拆出来,做一个有限状态机:
- 状态一:等待动词输入(最多两位数字)
- 状态二:等待名词输入(最多两位数字)
- 状态三:确认执行或清除
这个状态机不难写,但它会引导你注意一个实际问题“DSKY的操作是异步的,而AGC是同步执行的”。也就是说,宇航员可能在计算机正忙时输入指令,而计算机只有在一个程序循环的特定时间点才会去扫描输入通道。如果你的模拟器只是“来一个事件按一下处理”,就无法还原真实任务中的排队和丢弃机制。
2.3 最值钱的部分:登月下降段的导航与控制思路
如果说键盘交互是皮肉,那登月下降程序就是骨架。真实的登月后期分了好几个阶段:从月球轨道高度约100公里左右的近月点开始,先用发动机减速进入下降轨道;然后是“着陆减速段”,逐步降低高度和水平速度;最后是“最终逼近段”,由宇航员透过舷窗观察月面选择着陆点,配合计算机给出的引导信息手动或半自动操纵。
这个过程里面有三个关键的算法问题:
状态估算:航天器位置和速度不能直接测出来,只能通过惯性测量单元(IMU)积分得到,再通过地面测控或交会雷达修正。模拟时,你可以用一组常微分方程来抽象描述飞船运动,外加一些测量噪声,然后让“机载计算机”用简单滤波(真实AGC里也有延迟锁定环之类的方式做雷达测量处理)去估算状态。这一步建议先用模拟数据把积分算对,再加噪声。
制导律设计:发动机推力方向和大小需要实时调整,让飞船按预定的高度-速度曲线落下去。上世纪那个时代还没有太多现代控制理论,实际的制导逻辑非常依赖预先计算好的“标称轨迹”和偏差修正表。复现时,你可以做一个简化的闭环:按预设轨迹插值得到期望高度和速度,再根据实际值和期望值的偏差,输出一个修正加速度指令。
燃料预算:登月舱的燃料极其有限,每个阶段的发动机开机时间都被严格规划。在模拟器里,我会把燃料消耗量和推力积分挂钩,一旦燃料耗尽就触发“引擎停火”状态,你会发现飞船直接变成自由落体——这一步能极大增强项目的“紧张感”。
实操建议:不要一开始就试图完整复刻整套登月流程。先做一个简化版本——飞船从100公里高度开始下降,发动机持续推动,手动控制推力大小,目标是让高度平滑降到0且速度也为0。把这条闭环跑通,再逐步加引力场变化、姿态限制、雷达切换这些细节。
3. 实操过程与核心环节实现
3.1 环境准备与工具链选型
我最终的软件核心里面用了C语言作为主力,原因有两点:一是C的指针和内存模型能很自然地映射AGC的地址空间;二是后续想移植到单片机上驱动实体面板,C几乎不需要改动。界面部分我用了简单的图形窗口框架,只用到了最基础的文字渲染和绘图功能。如果你更熟悉Web技术,也可以用浏览器作为界面,内核通过编译成WebAssembly或干脆用JavaScript重写。重要的是内核和界面分离,语言不是硬性门槛。
工具链建议如下:
- 编译器:本机C编译器(GCC或Clang均可)
- 构建工具:一个简单的Makefile或CMake脚本
- 调试工具:GDB或等价调试器
- 版本管理:随便用什么,但建议早期就为“算法调整”单独开分支
这里有必要再强调一次:不要因为界面简单就跳过“开发时的观察工具”。你的模拟器需要能随时打印如下信息:当前指令地址、当前bank、寄存器值、最近执行的几条指令。否则遇到bug时,你只能像盲人摸象一样猜。我在第一版就是因为没有日志系统,排查一个地址错乱问题花了整整两个晚上。
3.2 用代码还原DSKY输入输出链路
下面我用一个极简但结构完整的例子来说明DSKY输入处理的核心逻辑。这不是完整AGC模拟,但展示了状态机思路。
typedef enum { VERB_PENDING, NOUN_PENDING, EXEC_PENDING, IDLE } dsky_state_t; typedef struct { int verb; int noun; dsky_state_t state; } dsky_t; void dsky_init(dsky_t *dsky) { dsky->verb = 0; dsky->noun = 0; dsky->state = VERB_PENDING; } int dsky_digit(dsky_t *dsky, int digit) { // 返回1表示产生了完整指令 switch (dsky->state) { case VERB_PENDING: dsky->verb = dsky->verb * 10 + digit; if (dsky->verb >= 10) { dsky->state = NOUN_PENDING; } return 0; case NOUN_PENDING: dsky->noun = dsky->noun * 10 + digit; if (dsky->noun >= 10) { dsky->state = EXEC_PENDING; } return 0; case EXEC_PENDING: // 此时更多输入可视为无效或重新开始 return 0; default: return 0; } }这个状态机最大的优点是“防呆”:宇航员按错了键,可以通过清除键重新开始,不会有难以恢复的死锁。同时,它精确地还原了“代码必须输满两位才进入下一状态”的约束。实际AGC的真实输入处理还要更复杂,它要同时处理“程序中断”和“操作错误告警”,但状态机主骨架不会变。
对应地,显示侧需要把“当前值”刷新到一组数码管上。我建议用一组“分段模拟”对象而非直接贴图片,这样如果你以后接实体数码管,只要替换渲染函数即可。
3.3 实现登月下降段的轨迹计算
登月下降的物理模型可以简化成二维(或三维)空间内,受到月球重力吸引、同时受到发动机推力作用的问题。为了专注核心算法,我从最简单的“一维垂直起降”模型开始,逐步扩展到含水平速度的二维下降。
一维模型下的动力学方程:
- h(t) 表示高度,向下为正
- v(t) 表示速度,向下为正
- 重力加速度 g 恒定(简化)
- 发动机推力提供向上的加速度 a_engine,它受燃料质量影响
最朴素的计算循环如下:
# 简化的一维登月下降模拟(Python示例) # 设定参数 g = 1.62 # 月球重力加速度 m/s^2 max_thrust = 30.0 # 最大推力加速度 m/s^2 fuel = 1000.0 # 燃料剩余单位 fuel_rate = 10.0 # 燃料消耗率,跟推力成正比 h = 20000.0 # 初始高度 米 v = -50.0 # 初始速度,负代表下降速度 m/s dt = 0.1 while h > 0: # 你想给多大推力?这里简化为手动输入 throttle = 0.3 # 0~1之间的油门指令 # 推力加速度不能超过最大推力 a_engine = max_thrust * throttle # 更新加速度(向上为正,所以这里用减法) # 注意:在向下为正的坐标系里,重力加速度g是正的,推力使v增速变缓 v_next = v + (g - a_engine) * dt h_next = h + v_next * dt # 计算燃料消耗,并且推力随着燃料减少而下降 fuel -= fuel_rate * throttle * dt if fuel <= 0: max_thrust = 0 # 引擎没燃料了 v = v_next h = h_next print(f"着陆时速度: {v:.2f} m/s, 剩余燃料: {fuel:.2f}")这个例子虽然简单,但它复现了核心逻辑:推力越大,减速越快,但燃料消耗也越快,如何在落地瞬间让速度归零,本质上是“最优控制”的雏形。
加二维化之后,你需要维护两组状态量:水平位置和速度、垂直高度和速度。发动机可以朝不同方向喷,控制器必须同时修正水平速度(为了不对准着陆点偏离太远)和垂直下降率(为了不砸到地面)。我建议在实现时把控制器分解为两个独立的PID通道:一个管高度变化率,一个管水平漂移。你会发现,当两个通道同时工作时,耦合是个麻烦,这在真实任务里也正是最考验制导逻辑的地方。
3.4 把系统跑起来与验证方法
把内核和DSKY拼起来后,验证方法很重要。不能只看界面亮了就认为“项目成功了”,应该按测试用例逐条验证:
- 基础指令测试:加载一段预先写好的小程序,按单步执行,比对寄存器和内存值。
- 内存越界测试:故意访问非法bank,看系统是否给出告警或保持静默,这点和AGC的真实行为对照。
- 场景测试:从某个初始位置启动下降程序,看高度曲线和速度曲线是否平滑,是否出现“振荡”或“燃料突然耗尽”。
- 交互测试:模拟按键操作,看看状态机能否正确处理错误输入(比如动词只输入一位就按执行)。
我当时还做了一件事:记录每一次运行的数据,画成高度-速度曲线图。你会看到,如果控制器增益调得太高,曲线会在目标线附近反复穿越,象征飞船“在空中抖动”;如果增益太低,则偏离目标线,最后要么撞地,要么燃料耗完才勉强减速。这种可视化方式比看一串数字直观太多,能快速帮你调参。
4. 常见问题与排查技巧实录
4.1 内存bank切换导致的地址错位
这是我在复现时踩的第一个大坑。AGC的程序存储区是分bank的,每条跳转指令除了给出目标地址,还可能隐含“下一条指令去哪个bank”。如果你的模拟器没有维护bank上下文,指令很容易从错误的存储区取到垃圾数据,表现是“程序飞了”,界面随机亮灯。
排查技巧:给指令执行循环加“当前bank号”和“当前地址”的日志输出。一旦出现执行地址跳变到异常区域,立刻回看之前执行的跳转指令,基本都能发现是“隐含bank切换没生效”。解决方式就是严格按照硬件手册去实现那几条特殊的跳转指令,而不是自己发明简化版。
4.2 DSKY输入乱码和按键抖动
实体按键存在抖动,即一次物理按键会在电信号上产生多次脉冲,软件不做处理就会一次输入多个数字。真实的DSKY电路有消除抖动的设计,但你在自己的板子上必须额外处理。
最简单的软件消抖方法是“延迟确认”:检测到按下事件后,等若干毫秒再读一次,如果电平依然有效才接受。不要小看这几十毫秒,在实时系统里,它必须被纳入输入等待流程,否则你会出现“想输12结果变成1122”的喜剧效果。
4.3 模拟精度和速度的权衡
如果你完全按真实AGC的周期来执行每一条指令,在今天的电脑上依然能跑得很流畅,但如果你在模拟器里又加入了实时轨迹计算、UI刷新,就可能出现“内核很快、显示跟不上”的撕裂感。我用了一个全局时钟抽象:内核每执行完“一条模拟指令”,就累积模拟微秒数,UI刷新时读取这个时钟,到了某个阈值才重绘一次。这样显示逻辑和CPU执行节奏就脱钩了,无论机器多快多慢,显示频率基本稳定。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 程序执行跳飞 | bank切换未正确实现 | 检查跳转指令里隐含bank上下文是否更新 |
| 输入两位数字后无反应 | 状态机卡在等待名词状态 | 检查当前状态和清除键逻辑,确认“动词已满两位”标志是否提前触发 |
| 高度曲线振荡减小不了 | 控制器增益过高 | 降低比例增益,增加阻尼项,观察稳定余量 |
| 燃料消耗速度异常 | 推力与消耗率换算不一致 | 统一推力加速度和燃料消耗量的量纲 |
| 界面刷新闪烁 | 重绘时机不协调 | 引入全局模拟时钟,定时合并刷新 |
| 实体面板按键偶发失灵 | 按键抖动或电平不稳 | 加入软件消抖和错误重读机制,必要时加滤波电路 |
这张表同样适用于你未来做任何“复古计算系统模拟”类项目。很多坑不是AGC特有的,而是“模拟旧硬件”这件事本身的共性。
5. 一些个人经验
复现Apollo-11这类系统,最让我受益的其实不是“我写出了多少行代码”,而是被迫重新审视了很多现代开发中习以为常的“默认条件”。内存大了,我们就不太关心变量布局;CPU快了,我们就不太在乎循环效率;浮点精确了,我们就不去想定标和误差。但AGC这个项目,每一个节省都是被逼出来的,每一步都是精算过的。
如果你也想尝试,我的建议是先定一个“能亮起来”的版本目标,比如:屏幕上出现DSKY面板,敲三个键调用出一个程序,显示一个飞速下降的高度数字。别看这个目标小,它已经能帮你绕过最复杂的地基,把整个闭环建立起来。一旦这个循环通了,后面加什么功能都是水到渠成。
另外一个小技巧:留着折腾日志。不是记录“今天做了什么”,而是当你踩完一个坑后,用几行话把这个坑的起因、排查过程、最终修法写下来。这个动作对“将来维护项目”的作用远比你想象的大。我就是在一次版本迭代时,因为没写日志,改坏了一个本不该动的初始化函数,然后对着正常代码改了半天才意识到问题。
这个项目后续还能往哪个方向拓展?你可以试着把轨迹算法换成更通用的形式,比如加入随机风场干扰、测距仪噪声,看看系统是否还能稳定着陆。甚至可以在模拟的DSKY上实现“手把手教学模式”——把每一步操作的含义解释弹出来,变成一个面向大众的交互科普作品。祝你也早日造出自己桌面上那台“微型登月飞控”。