资讯动态

Apollo-11高可靠实时系统设计:从架构到故障注入的工程实践

发布时间:2026/10/15 1:47:12 来源:尧图企业网站定制
1. 从代号到工程现场Apollo-11 到底在做什么第一次看到“Apollo-11”这个代号很多人脑子里蹦出来的可能是上世纪的登月计划。但在我们这行尤其是做嵌入式、工业控制、机器人或者高可靠系统的人眼里Apollo-11 更像是一个“代号”——它代表着一类对实时性、确定性、可追溯性要求极高的系统工程项目。我最早接触这个代号是在一个做运动控制器的团队里当时他们把整套固件框架命名为 Apollo-11理由很简单登月舱的导航计算机算力还不如现在一颗蓝牙芯片却能把人精准送到月球再带回来靠的就是极致的资源调度和容错设计。这个类比很贴切也奠定了这类项目的基调——不是堆功能而是把每一个时钟周期和每一字节内存都花在刀刃上。所以这篇博文我想把 Apollo-11 当作一个典型的“高可靠实时系统”项目来拆解。它适合谁看如果你正在做单片机裸机开发、RTOS 应用、工业网关、飞控、或者任何“跑飞了会出大事”的系统那这里面的思路和踩坑记录对你会有直接帮助。如果你只是好奇一个代号背后能有多少工程细节也可以当故事看。我会从整体设计思路讲到具体实现再到问题排查尽量把“为什么这么做”说透而不是只丢一堆代码。核心关键词我先摆出来实时调度、状态机、看门狗、内存布局、故障注入、日志回溯。这些词会贯穿全文也是 Apollo-11 这类项目绕不开的硬骨头。2. 整体设计思路为什么“简单”比“聪明”更难2.1 需求拆解先搞清楚“不能做什么”很多项目一上来就列功能清单Apollo-11 这类项目恰恰相反第一步是列禁止清单。我参与过的那个控制器项目需求文档第一页写的不是“支持多少路 PWM”而是任何单点故障不得导致执行器输出失控超过 50ms系统必须在 200ms 内从看门狗复位中恢复并进入安全态所有关键状态变更必须落盘掉电后可回溯最近 1000 条事件不允许动态内存分配不允许阻塞式延时超过 1ms你看这些约束直接决定了后面的架构选型。为什么不允许动态内存因为堆碎片在长时间运行后会导致分配失败而失败点往往在异常处理路径上那里最不能出错。为什么阻塞延时卡 1ms因为控制环路周期就是 1ms你阻塞一下整个环路就塌了。这些数字不是拍脑袋来的是从执行器物理特性和安全标准反推出来的。提示如果你也在做类似项目建议先把“最坏情况下的时间预算”算出来再倒推每个任务能占多少 CPU。这个预算表比任何架构图都重要。2.2 架构选型RTOS 还是裸机大循环这是被问得最多的问题。我的经验是看你的最坏执行时间WCET能不能静态算清楚。裸机大循环的好处是执行流完全确定你可以拿示波器一个引脚一个引脚地量。但一旦任务超过三个优先级反转和中断嵌套就会让你痛不欲生。RTOS 的好处是任务隔离和优先级调度代价是上下文切换开销和内核本身的不确定性。Apollo-11 项目最后选的是RTOS 静态分区的混合方案。具体说用了一个裁剪到只有 4KB Flash 的实时内核只开了三个任务控制任务最高优先级1ms 周期、通信任务中优先级事件驱动、监控任务最低优先级100ms 周期。所有任务栈都是静态分配的任务间通信用静态消息队列不用信号量做复杂同步。为什么不用信号量因为信号量持有时间一旦超过一个控制周期优先级反转就会让控制任务错过截止时间。我们宁可让通信任务丢包重发也不让它阻塞控制任务。这里有个关键决策点中断服务程序ISR里只做两件事——清标志、发消息。所有实际处理都推到任务里做。这样 ISR 的执行时间可以控制在 2us 以内中断延迟对控制环路的影响就微乎其微了。我见过太多项目在 ISR 里做浮点运算或者查表结果高优先级中断一来整个时序全乱。2.3 状态机设计把“意外”变成“状态”Apollo-11 的另一个核心思路是全状态机覆盖。系统不允许有“未定义行为”所有可能的输入组合都必须映射到一个明确的状态。我们当时画了一张状态迁移图大概有 17 个状态包括初始化自检待机校准正常运行降级运行部分传感器失效安全停机故障锁存看门狗复位恢复掉电保存固件升级等等每个状态都有明确的进入条件、退出条件和超时处理。比如“校准”状态如果 30 秒内没收到有效数据自动跳到“故障锁存”并点亮故障灯。这个超时不是用软件计时器做的而是用硬件看门狗的分频输出做的确保即使软件跑飞硬件也能把系统拉回来。注意状态机不要用 switch-case 硬写建议用状态表驱动。状态表就是一个二维数组行是当前状态列是事件单元格是下一个状态和动作函数指针。这样加状态、改迁移逻辑不用动核心代码也方便做单元测试。3. 核心细节解析内存、时钟与故障注入3.1 内存布局每一字节都要有户口Apollo-11 项目用的是一颗 128KB Flash、32KB RAM 的 MCU。听起来寒酸但正是这种限制逼出了好设计。我们的内存布局是这样的区域起始地址大小用途属性Bootloader0x080000008KB固件升级、自检只读永不擦除应用代码0x0800200088KB主程序只读参数区 A0x080180004KB配置参数读写带 CRC参数区 B0x080190004KB参数备份读写带 CRC日志区0x0801A00024KB循环日志读写带序号栈区0x200000008KB主栈任务栈读写堆区0x200020000KB禁用-数据区0x2000200024KB全局变量、静态缓冲读写注意堆区是 0KB完全禁用 malloc。所有缓冲区都是静态数组大小在编译期确定。参数区做了双备份加 CRC写入时先擦 B 区、写 B 区、校验、再擦 A 区、写 A 区。这样即使写过程中掉电至少有一个区是完整的。日志区用循环队列每条日志 32 字节包含时间戳、事件码、两个参数和一个 CRC。24KB 能存 768 条够回溯最近几次故障了。实操心得参数区擦写次数有限别频繁写。我们当时把“运行时长”这种每秒都变的值放在 RAM 里只在掉电前或每 10 分钟才同步到参数区。这样 Flash 寿命从几个月延长到了十年以上。3.2 时钟与看门狗时间基准不能只有一个Apollo-11 用了三个时间源外部 8MHz 晶振主时钟、内部 32kHz RC低功耗定时、独立看门狗IWDG。主时钟给系统提供 72MHz 的 PLL 输出但所有控制环路的计时都用SysTick 中断而不是定时器外设。为什么因为 SysTick 是内核的一部分优先级和中断嵌套行为完全可预测不受外设总线延迟影响。看门狗这块踩过坑。一开始我们用窗口看门狗喂狗时间窗口设得太窄结果在高温测试时 RC 振荡器漂移导致系统频繁复位。后来换成独立看门狗喂狗周期设为 50ms但喂狗动作放在监控任务里而不是控制任务里。这样如果控制任务跑飞但监控任务还活着看门狗不会复位但监控任务会发现控制任务超时并主动触发安全停机。如果监控任务也挂了看门狗才复位。这叫分级容错。注意喂狗代码不要放在中断里。中断可能因为某种原因一直触发导致看门狗被“合法”地喂饱但主循环已经死了。喂狗必须放在你能确认“系统整体健康”的地方。3.3 故障注入自己给自己找麻烦Apollo-11 项目最让我自豪的一点是我们建了一套故障注入测试框架。简单说就是在固件里埋了一组“后门”命令可以通过串口触发以下故障强制某个任务进入死循环模拟某个传感器返回超范围值翻转某块 RAM 的某个 bit延迟某个中断的响应时间模拟 Flash 写入失败触发看门狗复位然后我们跑一个自动化测试脚本依次注入这些故障观察系统是否能按预期进入降级或安全状态并记录恢复时间。这个框架帮我们发现了至少三个隐藏 bug一个是任务栈溢出检测在优化等级 -O2 下被编译器优化掉了一个是日志写入在 Flash 忙时没有正确处理导致日志丢失还有一个是看门狗复位后某些外设寄存器没有重新初始化导致 PWM 输出异常。提示故障注入不需要复杂的工具一个串口命令解析器加几个宏定义就能做。关键是要在项目早期就埋进去后期补会非常痛苦。4. 实操过程从零搭建一个 Apollo-11 风格的最小系统4.1 环境准备与工具链选择我建议的工具链组合是GCC ARM Embedded Makefile OpenOCD GDB。不用 IDE 的原因很简单IDE 会隐藏太多编译细节而 Apollo-11 这类项目需要你精确控制每一个编译选项。比如-ffunction-sections -fdata-sections配合--gc-sections可以剔除未使用的代码节省 Flash-fno-common可以避免变量重复定义-Wall -Wextra -Werror把警告当错误强迫你写出干净的代码。链接脚本是另一个关键。你需要手动定义 MEMORY 区域和 SECTIONS把中断向量表放在最前面把关键函数放到 RAM 里执行如果 MCU 支持。下面是一个简化版的链接脚本片段MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 128K RAM (rwx) : ORIGIN 0x20000000, LENGTH 32K } SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } FLASH .text : { *(.text*) } FLASH .rodata : { *(.rodata*) } FLASH .data : { *(.data*) } RAM AT FLASH .bss : { *(.bss*) } RAM .stack : { . ALIGN(8); . . 0x2000; _estack .; } RAM }注意.data段用了AT FLASH意思是初始化数据存在 Flash 里启动时拷贝到 RAM。这个拷贝过程必须在main之前完成通常在启动文件里做。如果你忘了这一步全局变量的初始值就是随机垃圾。4.2 启动流程与自检实现Apollo-11 的启动流程分五步硬件初始化时钟、看门狗、GPIO、串口。这一步不依赖任何 RTOS。RAM 自检用 March C 算法遍历所有 RAM检测 stuck-at 故障。32KB 大概耗时 15ms。Flash CRC 校验对应用代码区做 CRC32和存储在固定地址的预期值比对。88KB 大概耗时 80ms。参数区校验读 A 区和 B 区选 CRC 正确的那个加载。如果两个都错加载默认参数并记录故障。RTOS 启动创建任务启动调度器。自检过程中如果任何一步失败系统进入“故障锁存”状态只保留串口输出等待维护人员介入。这里有个细节自检代码本身不能被自检所以它必须尽量简单最好用汇编或者极简 C 写避免依赖任何库函数。实操心得RAM 自检会破坏 RAM 内容所以必须在.data段拷贝之前做或者先把.data段备份到 Flash 的临时区。我们当时是放在启动文件的最前面用汇编做的确保不碰任何 C 运行时。4.3 控制任务与通信任务的实现细节控制任务是一个 1ms 周期的硬实时任务它的伪代码大概长这样void control_task(void *arg) { static uint32_t last_wake 0; while (1) { // 等待下一个周期 vTaskDelayUntil(last_wake, pdMS_TO_TICKS(1)); // 读传感器SPI DMA非阻塞 sensor_read_async(sensor_buf); // 执行控制算法纯计算无阻塞 control_output pid_calculate(sensor_buf, setpoint); // 写执行器PWM 寄存器直接写 pwm_set_duty(control_output); // 喂监控任务软件看门狗 monitor_feed(CONTROL_TASK_ID); } }注意vTaskDelayUntil是绝对延时不是相对延时。相对延时vTaskDelay会导致周期漂移因为任务执行时间会累加。绝对延时保证每个周期从固定时间点开始抖动更小。通信任务处理串口和 CAN 消息它用事件驱动收到完整帧才唤醒。这里的关键是帧解析不能阻塞。我们用一个环形缓冲区收字节在空闲中断里检查帧头帧尾凑齐一帧后发消息给通信任务。通信任务解析命令如果是参数写入先写日志再写参数区确保可追溯。4.4 日志系统与掉电保护日志系统是 Apollo-11 的“黑匣子”。每条日志 32 字节格式如下偏移长度内容04时间戳ms42事件码62参数182参数2102保留124数据1164数据2204数据3244数据4284CRC32写入时先写数据最后写 CRC。读取时校验 CRC不通过就跳过。日志区用循环队列写满后覆盖最旧的。为了掉电保护每写 10 条日志强制同步一次 Flash。同步操作放在监控任务里不在控制任务里做避免影响实时性。掉电检测用的是一个比较器监测电源电压。当电压低于阈值时触发高优先级中断在中断里立刻保存关键状态到备份寄存器Backup Register这些寄存器由 VBAT 供电掉电不丢。主电源恢复后启动时先读备份寄存器判断上次是否是异常掉电然后写一条日志。注意备份寄存器数量有限通常只有几十个字节。所以只能存最关键的几个变量比如当前状态、错误码、运行时长。别想着把所有东西都存进去。5. 常见问题与排查技巧实录5.1 系统频繁复位但看门狗没记录这是最让人头疼的问题之一。看门狗复位后你可以读一个标志位判断是否是看门狗触发的。但如果标志位显示不是看门狗系统却确实复位了那可能是电源跌落用示波器看电源纹波特别是执行器动作瞬间。我们遇到过电机启动时 3.3V 跌到 2.7V导致 MCU 欠压复位。解决办法是加一个大电容和 TVS 管。时钟失效外部晶振停振系统自动切换到内部 RC但某些外设配置没更新导致总线错误。解决办法是启用时钟安全系统CSS在时钟失效中断里重新配置外设。堆栈溢出任务栈溢出会踩到相邻内存可能导致返回地址被改程序跑飞后触发硬件异常。解决办法是启用栈溢出检测或者在每个任务栈末尾放一个魔术字定期检查。排查顺序建议先看电源再看时钟最后看软件。电源问题占复位原因的 60% 以上。5.2 控制环路偶尔抖动但找不到规律控制环路抖动通常表现为执行器输出有毛刺或者周期偶尔变长。可能的原因和对策现象可能原因排查方法解决措施周期偶尔变长 100us高优先级中断执行时间过长用 GPIO 翻转示波器测量 ISR 时间把 ISR 里的耗时操作移到任务里输出有规律毛刺传感器采样与 PWM 更新冲突检查是否在同一时刻读写调整采样时刻避开 PWM 更新点随机抖动DMA 传输与 CPU 访问 RAM 冲突查看总线矩阵仲裁配置把 DMA 缓冲区和 CPU 数据区放在不同 RAM 块温度高时抖动加剧晶振频偏或 Flash 等待周期不足高温箱测试读 Flash 等待周期寄存器增加 Flash 等待周期或降低主频我们当时遇到的是 DMA 和 CPU 抢 RAM 总线导致控制任务读传感器数据时偶尔多等几个周期。解决办法是把 DMA 缓冲区放到 CCM RAM内核紧耦合内存CPU 数据放到普通 SRAM物理上分开就不抢了。5.3 日志丢失或乱码日志系统出问题通常是因为写入时被中断打断日志写入不是原子操作如果写一半被高优先级中断打断中断里又写日志就会乱。解决办法是日志写入关中断或者用双缓冲。Flash 擦除期间写入Flash 擦除时整个扇区不可读如果此时读日志就会出错。解决办法是日志读取前先检查 Flash 忙标志。CRC 计算错误CRC 多项式或初始值不对导致所有日志校验失败。解决办法是先用已知数据测试 CRC 函数。循环队列索引溢出索引变量类型太小写满后回绕出错。解决办法是用uint32_t做索引取模用位与运算如果大小是 2 的幂。实操心得日志系统一定要在项目早期就做并且要写单元测试。我们当时是写了一个 Python 脚本模拟写入 10000 条日志然后逐条读取校验确保循环覆盖和 CRC 都正确。5.4 固件升级失败导致变砖Apollo-11 支持串口升级但早期版本有过升级失败变砖的案例。原因是 Bootloader 在擦除应用区时掉电导致应用区不完整而 Bootloader 又不会自动回滚。后来我们改成A/B 分区升级Flash 里放两份应用Bootloader 根据标志位决定启动哪份。升级时写另一份写完校验通过再切换标志位。这样即使升级过程中掉电原来的应用还是完整的。升级流程收到升级命令进入 Bootloader 模式擦除 B 区接收数据写入 B 区校验 B 区 CRC如果校验通过写标志位指向 B 区重启如果校验失败保持 A 区返回错误这个方案需要 Flash 空间翻倍但换来了“永不砖”的保证。对于现场设备来说这个代价是值得的。6. 一些不成文的经验法则做 Apollo-11 这类项目久了会形成一些直觉。比如中断优先级不要超过三级。级别太多你根本记不住谁打断谁。我们当时只分三级故障处理最高、控制环路、通信。其他全部放任务里。所有外设操作都要有超时。等一个标志位不要用while(flag);要用while(flag timeout--);。超时后返回错误让上层决定怎么办。变量命名要带单位。timeout_ms、speed_rpm、voltage_mv。这样看代码时不用猜也避免单位换算错误。我们有过一次因为把ms当成us导致看门狗喂狗周期差了 1000 倍系统不断复位。注释写“为什么”不写“是什么”。i; // 增加 i这种注释毫无价值。要写i; // 跳过帧头因为帧头不参与 CRC 计算。每个函数都要能单独测试。控制算法、CRC、状态机迁移这些都应该能在 PC 上编译运行用单元测试覆盖。我们当时用了一个宏#ifdef UNIT_TEST把硬件相关代码隔离掉在 PC 上跑了几百个测试用例。最后分享一个我个人的习惯每次改代码先想好怎么回滚。Git 提交要原子化一个提交只做一件事。改完先在自己工位上跑 24 小时老化测试再上测试台最后才去现场。现场设备一旦变砖维修成本可能是开发成本的几十倍。这个项目后续还可以往形式化验证方向扩展比如用 TLA 或者 SPIN 对状态机建模证明不存在死锁和活锁。也可以往硬件冗余方向走双 MCU 热备主备之间用高速串口同步状态。不过那是另一个故事了有机会再聊。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑