1. 从一行代码到月球:Apollo-11 制导计算机到底硬核在哪
第一次看到“Apollo-11”这个标题,很多人脑子里蹦出来的可能是那段著名的“个人一小步、人类一大步”。但如果你是个搞技术的,尤其是写过嵌入式、做过实时系统的人,你盯着这个词看久了,心里冒出来的其实是另一个念头:那台只有 2MHz 主频、内存小得可怜的制导计算机,到底是怎么把三个人精准送到月球再带回来的?
这就是我写这篇东西的出发点。Apollo-11 任务里真正值得技术人反复琢磨的,不是火箭有多大推力,而是那套AGC(Apollo Guidance Computer,阿波罗制导计算机)的软件设计哲学。它用今天看来近乎“寒酸”的硬件资源,完成了一个容错要求极高、实时性要求极强、且几乎不允许失败的任务。关键词就三个:实时调度、优先级抢占、容错重启。这套东西放到今天的车载、航天、工业控制场景里,依然是教科书级别的参考。
这篇文章适合谁看?如果你写过 RTOS、做过任务调度、被“优先级反转”坑过、或者单纯好奇“资源极度受限时软件该怎么设计”,那这篇就是写给你的。我会把 AGC 的执行模型、调度机制、著名的 1201/1202 报警背后的原理,以及它留给现代嵌入式开发的几条硬核经验,一条条拆开讲清楚。不吹神话,只讲能落地的工程逻辑。
2. AGC 的硬件底子:为什么“慢”反而逼出了好设计
2.1 一块只有 36K 字内存的计算机,能干什么
先把硬件参数摆出来,你才能理解后面所有设计决策的无奈与精妙。AGC 的核心指标大致是这样的:
| 项目 | 参数 | 今天的对照 |
|---|---|---|
| 主频 | 约 2.048 MHz | 一块普通单片机都能到 100MHz+ |
| 字长 | 15 位(数据)+ 1 位奇偶校验 | 现代普遍 32/64 位 |
| 只读存储 | 约 36K 字(核心绳存储器) | 相当于几十 KB |
| 读写内存 | 约 2K 字 | 比很多 MCU 的 RAM 还小 |
| 任务数 | 同时管理数十个作业 | 现代 RTOS 轻松上百 |
注意那个“核心绳存储器”,它是靠工人手工把铜线穿过磁环、穿“1”或“0”来烧录程序的。也就是说,程序一旦编好,物理上就改不了了。这个约束极其残酷:你没法上线后打个补丁,没法热更新,所有逻辑必须在写代码阶段就想清楚。正是这种“改不了”的压力,逼出了 AGC 极度严谨的模块划分和错误处理机制。
我个人的体会是,今天很多团队之所以软件质量差,恰恰是因为“反正能热更新”。资源太充裕、修改太容易,反而让人失去了对每一行代码的敬畏。AGC 的环境逼着工程师把每个字节都当成稀缺资源来对待,这种心态值得借鉴。
2.2 绳存储器带来的“不可修改”约束,如何反向塑造软件架构
因为程序烧死在绳子里,AGC 的软件被拆成了非常清晰的层次:执行层(Executor)、等待列表(Waitlist)、以及一堆独立的功能作业(Job)。每个作业负责一件小事,比如“读取加速度计”“更新导航方程”“刷新显示”。作业之间通过固定的接口通信,谁也不能随便改谁。
这种设计的好处是:任何一个作业出问题,不会直接拖垮整个系统。因为作业是独立的、可被调度器单独管理的单元。你可以把它理解成一种极简版的“微内核”思想——核心只负责调度和资源分配,具体功能都是可插拔的作业。
这里有个细节特别值得说:AGC 的作业分为短作业和长作业。短作业执行时间短,可以被随时打断;长作业则会被拆成多个小段,分时执行。为什么要这么分?因为如果让一个长作业霸占 CPU,其他高优先级的作业(比如姿态控制)就没法及时响应,飞船可能就失控了。这种“把长任务切片”的思路,今天在实时系统里叫任务分解与时间片管理,是保证响应性的基本手段。
3. 执行层与等待列表:AGC 的调度器是怎么转起来的
3.1 优先级不是拍脑袋定的,而是按“错过会死”来排的
AGC 的调度核心叫Executive(执行层),它维护一个Waitlist(等待列表)。所有需要定时执行或延迟执行的作业,都挂在这个列表上,按“下次执行时间”排序。执行层每次从列表头部取出最早到期的作业来跑。
但光有时间排序还不够,真正决定谁先跑的是优先级。AGC 的优先级划分逻辑非常朴素但极其有效:按“如果这个作业没及时执行,后果有多严重”来排。
- 最高优先级:姿态控制、发动机指令这类,晚几毫秒就可能船毁人亡。
- 中等优先级:导航更新、雷达数据读取,晚一点问题不大,但不能一直不跑。
- 最低优先级:显示刷新、键盘扫描,晚几百毫秒用户也感觉不到。
这个排序逻辑听起来简单,但落地时有个关键问题:高优先级作业如果一直来,低优先级会不会被饿死?AGC 的答案是:会,但这是可接受的。因为在那个场景下,显示刷新晚一点真的没关系,而姿态控制晚一点就是灾难。优先级设计的第一原则,是承认“有些事可以牺牲”。
我见过太多项目,优先级排得一团糟,所有任务都标成“高”,结果等于没有优先级。AGC 的做法提醒我们:优先级的意义在于区分,而不是全都重要。
3.2 抢占式调度:一个作业跑到一半被踢下去,怎么保证不出错
AGC 支持抢占。也就是说,一个低优先级作业正在跑,突然来了个高优先级作业,执行层会立刻保存当前作业的现场,切过去跑高优先级的,等它跑完再切回来。
这里的技术难点在于现场保护。AGC 用的是 15 位字长,寄存器数量有限,每次抢占都要把当前作业的关键状态(累加器、程序计数器、部分中间变量)压栈。压栈本身也要时间,如果抢占太频繁,光保存现场的开销就把 CPU 吃光了。
所以 AGC 的设计里有个隐含的取舍:不是所有作业都能被抢占。一些关键的短作业会被标记为“不可中断”,必须一口气跑完。这些作业通常执行时间极短(几十微秒级),所以即使稍微延迟了其他作业,影响也可控。
这个思路今天依然适用。你在写中断服务程序(ISR)时,也应该尽量短小精悍,把耗时操作丢给主循环或低优先级任务去处理。中断里做的事越少,系统的实时性就越有保障。
3.3 1201 和 1202 报警:一次教科书级的“过载保护”实战
Apollo-11 登月过程中最惊心动魄的技术瞬间,就是着陆前几分钟,AGC 突然报出1201和1202报警。这两个代码的意思是:执行层发现作业队列满了,无法再接收新的作业请求。
当时地面控制中心一片紧张,因为如果计算机彻底卡死,登月舱就会失控。但最终任务成功了,靠的就是 AGC 设计里一个极其聪明的机制:当系统过载时,主动丢弃低优先级作业,保住高优先级作业。
具体来说,1201 表示“等待列表已满”,1202 表示“执行层队列已满”。当这些报警出现时,AGC 并没有崩溃,而是继续执行当前最高优先级的任务(也就是着陆相关的制导计算),把那些不重要的作业(比如雷达数据更新、显示刷新)直接扔掉。等负载降下来,再恢复正常调度。
这个机制的本质是优雅降级:系统不追求在所有情况下都完成所有任务,而是保证最关键的任务永远能完成。这跟今天很多系统“一过载就雪崩”的表现形成了鲜明对比。
我后来在做一个工业控制项目时,就借鉴了这个思路。当时系统在高负载下会丢数据包,导致整个控制回路震荡。后来我们加了一个“过载保护层”:当队列深度超过阈值时,主动丢弃非关键的状态上报,只保留控制指令。效果立竿见影,系统再也没崩过。AGC 在五十多年前就证明了:会“丢”的系统,比会“崩”的系统可靠得多。
4. 从 AGC 到现代嵌入式:几条能直接抄的工程经验
4.1 优先级反转:AGC 没踩的坑,今天很多人还在踩
优先级反转是个经典问题:高优先级任务等一个被低优先级任务持有的锁,而低优先级任务又被中优先级任务抢占,导致高优先级任务被无限期阻塞。AGC 因为设计得足够简单,作业之间很少共享可变的全局状态,所以基本没遇到这个问题。
但今天的系统复杂得多,共享资源到处都是。我踩过最惨的一次坑,是在一个多任务环境里,一个高优先级的通信任务和一个低优先级的日志任务共用了一个串口。日志任务持有串口锁的时候被抢占了,通信任务就一直等,结果通信超时,整个链路断了。
解决办法就是优先级继承:当高优先级任务等待低优先级任务持有的锁时,临时把低优先级任务的优先级提到和高优先级一样,让它尽快跑完释放锁。很多现代 RTOS 都支持这个机制,但默认不一定开启,需要你手动配置。
提示:如果你在用 RTOS,先去确认一下优先级继承有没有打开。这个开关不开,系统在压力测试下大概率会出诡异问题。
4.2 看门狗不是万能的:AGC 的重启策略更值得学
现代嵌入式系统普遍有看门狗(Watchdog),程序跑飞了就复位重启。但看门狗有个致命问题:重启之后呢?如果重启后系统状态全丢了,那重启只是把崩溃推迟了几秒钟。
AGC 的做法更高级:它有一套重启保护机制。当检测到严重错误时,系统会尝试从最近的检查点恢复,而不是从头开始。关键的导航数据、姿态信息会被定期备份到不易失的内存区域。即使发生重启,也能快速回到一个已知的安全状态。
这个思路在今天叫状态检查点与恢复。我在做一个数据采集项目时,就用了类似的方法:每采集一批数据,就把关键状态写到一个带电池备份的 RAM 里。后来有一次现场断电,恢复供电后系统自动从检查点继续,一条数据都没丢。客户当时就震惊了。看门狗只能救急,检查点才能救命。
4.3 资源受限下的代码优化:少即是多
AGC 的程序是用一种叫AGC Assembly的汇编语言写的,每个指令都精打细算。今天我们用 C、C++ 甚至 Python 写嵌入式,资源看似充裕,但资源浪费的代价往往在系统压力大时才暴露出来。
我总结了几条从 AGC 时代就适用、今天依然有效的优化原则:
- 能算一次就别算两次:把不变的计算结果缓存起来,别在循环里反复算。
- 数据结构要紧凑:别为了“可读性”用一堆嵌套结构体,内存紧张时该用数组就用数组。
- 避免动态内存分配:AGC 根本没有 malloc,所有内存都是静态分配的。今天很多高可靠性系统也禁止在运行时动态分配内存,因为碎片化和分配失败是难以预测的。
- 中断要短:AGC 的短作业设计就是中断思想的体现。中断里只做最紧急的事,剩下的交给主循环。
这些原则听起来老生常谈,但真正在项目里坚持做到的人不多。我的经验是:在系统设计阶段就把资源预算定死,然后像 AGC 工程师那样,对每一个字节、每一个时钟周期都斤斤计较。这种态度,比任何高级工具都管用。
5. 如果今天让你复刻一个 AGC 式的调度器,该从哪下手
5.1 最小可行调度器的核心组件
假设你要在一个现代 MCU 上实现一个类似 AGC 执行层的调度器,不需要搞那么复杂,但核心思想可以借鉴。你需要这几个东西:
- 作业控制块(Job Control Block):每个作业一个结构体,存作业函数指针、优先级、下次执行时间、状态(就绪/等待/运行)。
- 等待列表:一个按“下次执行时间”排序的链表或最小堆。每次调度时取最早到期的作业。
- 优先级队列:就绪状态的作业按优先级排队,高优先级先跑。
- 抢占逻辑:在中断或定时器 tick 里检查是否有更高优先级的作业就绪,有就触发切换。
- 过载保护:当就绪队列长度超过阈值时,拒绝低优先级作业的入队请求,并记录报警。
用 C 语言写的话,核心调度循环大概长这样:
void scheduler_run(void) { while (1) { job_t *next = pick_highest_priority_ready_job(); if (next == NULL) { enter_idle_or_sleep(); continue; } if (next->priority > current_job_priority) { save_context(¤t_job); restore_context(next); } execute_job(next); update_waitlist(); } }当然,真实的实现要考虑更多细节,比如上下文切换的汇编代码、临界区保护、时钟节拍管理等。但骨架就是这么简单。AGC 的伟大之处不在于它有多复杂,而在于它用最简单的机制解决了最复杂的问题。
5.2 测试策略:怎么验证你的调度器不会在关键时刻掉链子
调度器写完了,怎么测?AGC 当年是靠大量的地面模拟和冗余设计来保证可靠性的。今天你可以用更系统的方法:
- 压力测试:人为制造大量高优先级作业,看系统会不会饿死低优先级作业,或者反过来被低优先级拖垮。
- 故障注入:随机让某个作业执行时间变长,或者随机丢弃作业,观察系统的降级行为是否符合预期。
- 边界测试:把等待列表填满,触发过载保护,验证报警机制和恢复逻辑。
- 长时间运行:让系统连续跑几天,看有没有内存泄漏或计数器溢出。
我自己的习惯是,在调度器里加一个统计模块,记录每个作业的执行次数、最长执行时间、被抢占次数。这些数据在调试时非常有用,能帮你快速定位是哪个作业在捣乱。
注意:统计模块本身也会消耗资源,所以要么只在调试版本里开启,要么把统计数据的更新放在低优先级作业里做。
5.3 从 AGC 学到的“设计哲学”,比代码更重要
最后说点虚的,但我觉得是最重要的。AGC 留给我们的不只是技术方案,更是一种工程态度:
- 承认资源有限:不要假设 CPU 永远够快、内存永远够用。把限制当成设计的前提,而不是事后补救的理由。
- 为失败而设计:系统一定会出错,问题是怎么错。是优雅降级,还是彻底崩溃?AGC 选择了前者。
- 优先级就是价值观:你把什么排在高优先级,就代表你认为什么最重要。这个排序必须经过深思熟虑,不能拍脑袋。
- 简单才能可靠:AGC 的调度逻辑简单到可以用一页纸描述清楚。复杂的系统更容易出 bug,也更容易在压力下失控。
我在实际项目里越来越体会到,好的嵌入式软件不是功能最多的那个,而是最不容易挂的那个。Apollo-11 的 AGC 用 2MHz 的主频做到了这一点,我们今天用几百 MHz 的芯片,没有理由做不到。
如果你正在设计一个对可靠性有要求的系统,不妨把 AGC 的这套思路拿出来对照一下:你的优先级排对了吗?你的过载保护做了吗?你的状态能恢复吗?这三个问题答好了,系统的底子就稳了。