1. 从代号到工程:Apollo-11 项目的整体设计思路
第一次看到“Apollo-11”这个代号,很多人会下意识联想到那段人类探索太空的历史。但在我所从事的技术圈子里,Apollo-11 更多时候指的是一套面向高可靠场景的嵌入式控制系统参考实现,或者更宽泛地说,是一类以“极限条件下依然能完成任务”为设计目标的工程范式。我最早接触这个代号,是在一个模拟航天器姿态控制的仿真项目里,当时团队内部就把这套控制逻辑的基线版本命名为 Apollo-11,取的就是那种“一次成功、不容有失”的意味。
这个项目要解决的问题很具体:在资源极度受限、通信间歇中断、传感器噪声极大的环境下,让一套控制系统依然能够稳定地完成既定动作序列。它适合谁参考?如果你正在做无人机飞控、工业机器人运动控制、远程数据采集终端,或者任何对“掉线也不能失控”有硬性要求的嵌入式项目,那这套思路就值得你花时间拆解。它不依赖某个特定芯片或操作系统,核心是一套分层决策加状态机兜底的架构。
1.1 为什么选择分层状态机而不是单一控制循环
很多初学者写控制逻辑,习惯在一个while(1)里把所有判断都塞进去:读传感器、算偏差、输出控制量、处理异常。这种写法在实验室里跑得通,一旦现场环境复杂起来,代码就会变成一团乱麻。Apollo-11 的设计思路完全不同,它把整个系统拆成三个明确的层次。
最底层是硬件抽象层,负责把不同型号的传感器和执行器统一成标准接口。这一层不包含任何业务逻辑,只做数据格式转换和基础校验。中间层是状态估计与决策层,它接收抽象层传来的数据,结合当前系统状态,决定下一步该进入哪个工作模式。最上层是任务调度层,它根据决策层的输出,调用具体的控制算法,并管理任务队列。
这么分的好处在于,当某个传感器突然失效时,你只需要在硬件抽象层做降级处理,决策层完全感知不到底层换了数据源。我试过在一个多旋翼项目里用这套结构,把气压计和超声波高度计做冗余切换,切换逻辑只改了抽象层的十几行代码,上层控制参数一个都没动。
1.2 核心设计原则:确定性优先于最优性
Apollo-11 项目里有一条铁律:任何时刻的行为都必须是可预测的。这意味着在算法选型时,宁可选择一个稍慢但执行时间固定的方案,也不要选一个平均更快但最坏情况不可控的方案。举个例子,在姿态解算环节,很多人喜欢用扩展卡尔曼滤波,因为它精度高。但 EKF 的迭代次数在某些矩阵条件下会波动,导致单次解算时间不确定。Apollo-11 的参考实现里,姿态解算用的是互补滤波加固定步长的梯度下降,虽然精度略低,但每次解算耗时几乎恒定。
这个选择背后的逻辑是:控制系统最怕的不是精度不够,而是时序抖动。如果控制周期是 5 毫秒,但某一次解算花了 8 毫秒,那整个控制律的相位裕度就被吃掉了,严重时直接发散。我在实际调试中就踩过这个坑,当时用了一个自适应滤波算法,地面测试一切正常,上了振动台之后电机直接啸叫,后来查了一周才发现是滤波迭代次数在振动环境下变多,导致控制周期被拉长。
注意:确定性优先的原则不意味着放弃精度,而是要把精度优化放在确定性满足之后。你可以先保证固定步长,再在步长内做有限次迭代优化。
2. 核心模块拆解与关键参数解析
Apollo-11 的参考实现里,有几个模块是绕不开的:时间基准管理、传感器融合、故障检测与隔离、控制律计算。每个模块都有一些容易被忽略但影响巨大的参数。下面我按实际调试中遇到的顺序,逐个拆开讲。
2.1 时间基准管理:为什么不能用系统滴答
很多嵌入式项目直接用 SysTick 或者定时器中断来驱动控制循环,这在简单场景下没问题。但 Apollo-11 要求时间基准必须独立于操作系统调度。原因是,一旦系统里有了其他中断或者任务,SysTick 的优先级可能被抢占,导致控制周期被拉长。参考实现里用的是硬件定时器加捕获比较单元,直接触发 ADC 采样和 PWM 更新,完全不经过中断服务程序。
具体配置上,定时器的主频设为 168 MHz,预分频系数设为 0,自动重装载值设为 8399,这样定时器溢出频率就是 20 kHz。控制循环每 5 个溢出周期执行一次,也就是 4 kHz 的控制频率。这个频率的选择有讲究:太低则带宽不够,太高则 CPU 负载过重。对于大多数电机和舵机,4 kHz 已经能覆盖到 1 kHz 以下的动态响应。
这里有个细节:定时器溢出中断里只做一件事,就是置位一个标志位,真正的控制计算放在主循环里轮询这个标志。这样做的好处是中断服务程序极短,不会阻塞其他高优先级中断。我实测过,如果把控制计算直接放在中断里,当计算量稍大时,串口接收中断就会被延迟,导致通信丢包。
2.2 传感器融合:互补滤波的系数怎么定
互补滤波的核心公式很简单:角度 = α × (角度 + 角速度 × dt) + (1 - α) × 加速度计角度。难点在于 α 的选取。α 越大,越信任陀螺仪积分,动态响应好但会漂移;α 越小,越信任加速度计,静态稳但动态噪声大。
Apollo-11 的参考实现里,α 不是固定值,而是根据角速度幅值动态调整。当角速度小于 10 度每秒时,α 取 0.98;当角速度大于 50 度每秒时,α 降到 0.90。中间线性插值。这个策略的逻辑是:慢速运动时,加速度计的信噪比高,可以多信任它来抑制漂移;快速运动时,加速度计会受到运动加速度的干扰,必须多信任陀螺仪。
我一开始不理解为什么要做动态调整,直到在一次快速翻滚测试中,固定 α=0.98 的方案在翻滚结束后角度收敛慢了近 2 秒,而动态 α 方案只用了 0.3 秒就恢复了。后来分析数据发现,翻滚过程中加速度计输出饱和了,如果还按 0.98 信任陀螺仪,积分误差会累积;但翻滚结束后加速度计恢复,需要快速拉回来,动态 α 正好满足了这个需求。
| 角速度范围 (度/秒) | α 取值 | 信任倾向 |
|---|---|---|
| 0 ~ 10 | 0.98 | 陀螺仪为主,抑制高频噪声 |
| 10 ~ 50 | 线性插值 | 过渡区,平衡响应与稳定 |
| > 50 | 0.90 | 加速度计为主,快速收敛 |
2.3 故障检测与隔离:阈值不能拍脑袋定
故障检测的逻辑是:如果某个传感器的读数与其余传感器的融合结果偏差超过阈值,且持续超过一定时间,就判定该传感器故障,将其从融合中剔除。难点在于阈值的设定。阈值太小,正常噪声会触发误报;阈值太大,真实故障会被漏掉。
Apollo-11 的做法是基于历史数据统计。系统启动后的前 30 秒为学习期,记录每个传感器与融合值的偏差均值和标准差。学习期结束后,阈值设为均值加 6 倍标准差。这个 6 倍是经验值,对应正态分布下约 99.99966% 的置信区间,误报率极低。同时,持续时间的阈值设为 200 毫秒,也就是连续 800 个控制周期都超限才判定故障,避免单次尖峰干扰。
我在一个工业振动监测项目里套用了这个逻辑,把学习期延长到 5 分钟,因为工业现场的启动振动比多旋翼复杂得多。结果误报率从原来的每天几次降到了每周不到一次。这里的关键是学习期必须覆盖所有正常工况,如果学习期太短,某些正常但少见的工况没被学到,后续就会被误判为故障。
2.4 控制律计算:PID 的积分限幅与微分滤波
Apollo-11 的控制律以 PID 为基础,但在两个地方做了强化。第一是积分限幅:积分项的累加值被限制在输出限幅的 1.5 倍以内。这个 1.5 倍不是随便定的,它保证了即使积分项饱和,系统仍有一定的调节余量。如果限幅太小,积分作用被削弱,稳态误差消除慢;如果限幅太大,积分饱和后恢复时间长,容易超调。
第二是微分项低通滤波。原始微分对噪声极其敏感,参考实现里在微分项后面加了一个一阶低通滤波器,截止频率设为控制频率的十分之一。以 4 kHz 控制频率为例,截止频率就是 400 Hz。这个频率的选择依据是:大多数机械系统的共振频率在 100 Hz 以下,400 Hz 的低通既能保留有用的微分信息,又能滤掉高频噪声。
我试过把截止频率设到 1 kHz,结果电机在低速时出现明显的高频抖动;设到 100 Hz,又感觉系统响应变肉了。400 Hz 是在我的测试平台上反复试出来的平衡点。当然,不同机械结构的共振频率不同,这个值需要根据实际系统调整。
3. 实操过程:从零搭建一个 Apollo-11 风格的控制节点
光讲原理不够,下面我以一个三轴云台稳定控制为例,把 Apollo-11 的核心思路落地一遍。硬件平台选的是常见的 Cortex-M4 内核微控制器,传感器是六轴惯性测量单元,执行器是三个无刷电机驱动器。整个搭建过程分为五个阶段。
3.1 硬件抽象层的接口定义
第一步是定义统一的传感器接口。我创建了一个结构体sensor_data_t,包含加速度三轴、角速度三轴、温度、时间戳。然后为每个物理传感器写一个读取函数,函数内部完成原始数据到物理单位的转换。比如加速度计量程设为正负 4g,灵敏度是 8192 LSB/g,那么转换就是accel_g = raw / 8192.0f。
执行器接口也类似,定义actuator_cmd_t结构体,包含三个电机的目标力矩。底层驱动函数负责把力矩转换成 PWM 占空比。这里有个细节:PWM 频率设为 20 kHz,高于人耳听觉范围,避免电机啸叫。同时死区时间设为 500 纳秒,防止上下桥臂直通。
typedef struct { float accel[3]; // 单位:g float gyro[3]; // 单位:度/秒 float temperature; // 单位:摄氏度 uint32_t timestamp; // 单位:微秒 } sensor_data_t; typedef struct { float torque[3]; // 单位:牛米 } actuator_cmd_t;3.2 时间基准与调度器配置
定时器配置为 20 kHz 溢出,溢出中断里只做control_tick_flag = 1。主循环里检测到这个标志后,先清零标志,再执行控制流程。控制流程的顺序是:读取传感器、更新状态估计、执行故障检测、计算控制律、输出执行器指令。整个流程必须在 250 微秒内完成,否则下一个控制周期就会延迟。
我实测过每个环节的耗时:传感器读取约 30 微秒,状态估计约 80 微秒,故障检测约 20 微秒,控制律计算约 60 微秒,输出约 10 微秒。总计约 200 微秒,留了 50 微秒的余量。这个余量很重要,因为实际运行时可能会有中断嵌套或者缓存未命中的情况。
提示:如果你用的微控制器有浮点运算单元,确保在编译选项里开启硬件浮点。软件浮点会让状态估计的耗时增加三到五倍,直接吃掉所有余量。
3.3 传感器融合的代码实现
互补滤波的实现很简短,但有几个坑要注意。第一是时间戳的获取,必须用定时器的计数器直接读取,不能用系统滴答,因为系统滴答的精度只有毫秒级,而控制周期是 250 微秒。第二是角度归一化,每次更新后要把角度限制在负 180 度到正 180 度之间,否则角度会无限累积。
float alpha; float gyro_mag = sqrtf(gyro[0]*gyro[0] + gyro[1]*gyro[1] + gyro[2]*gyro[2]); if (gyro_mag < 10.0f) { alpha = 0.98f; } else if (gyro_mag > 50.0f) { alpha = 0.90f; } else { alpha = 0.98f - (gyro_mag - 10.0f) * 0.002f; } for (int i = 0; i < 3; i++) { float accel_angle = atan2f(accel[(i+1)%3], accel[(i+2)%3]) * 57.2958f; angle[i] = alpha * (angle[i] + gyro[i] * dt) + (1.0f - alpha) * accel_angle; if (angle[i] > 180.0f) angle[i] -= 360.0f; if (angle[i] < -180.0f) angle[i] += 360.0f; }3.4 故障检测的学习期实现
学习期需要记录每个传感器的偏差统计量。我定义了一个结构体数组,每个元素包含偏差累加和、偏差平方累加和、样本计数。学习期结束后计算均值和标准差,然后设置阈值。学习期标志用一个全局变量控制,30 秒后自动关闭。
typedef struct { float sum; float sum_sq; uint32_t count; float threshold; } fault_stats_t; fault_stats_t stats[3]; void update_fault_stats(int axis, float deviation) { if (!learning_phase) return; stats[axis].sum += deviation; stats[axis].sum_sq += deviation * deviation; stats[axis].count++; if (stats[axis].count > 1000) { float mean = stats[axis].sum / stats[axis].count; float var = stats[axis].sum_sq / stats[axis].count - mean * mean; stats[axis].threshold = mean + 6.0f * sqrtf(var); } }3.5 控制律的参数整定流程
PID 参数整定我习惯用先比例后积分再微分的顺序。先把积分和微分置零,逐渐增大比例增益,直到系统出现等幅振荡,记录此时的比例增益为 Ku,振荡周期为 Tu。然后按齐格勒-尼科尔斯法计算:比例增益取 0.6 倍 Ku,积分时间取 0.5 倍 Tu,微分时间取 0.125 倍 Tu。
但齐格勒-尼科尔斯法整定出来的参数通常超调较大,实际使用时我会把比例增益再降 20%,积分时间加 30%。这样响应稍慢但超调小,对云台稳定来说更合适。整定过程中一定要用阶跃响应来观察,给一个 10 度的目标角度突变,看实际角度的上升时间和超调量。
| 整定阶段 | 比例增益 | 积分时间 | 微分时间 | 观察现象 |
|---|---|---|---|---|
| 纯比例 | 逐渐增大 | 0 | 0 | 等幅振荡,记录 Ku 和 Tu |
| 加入积分 | 0.6Ku | 0.5Tu | 0 | 稳态误差消除,可能超调 |
| 加入微分 | 0.6Ku | 0.5Tu | 0.125Tu | 超调减小,响应变快 |
| 微调 | 0.48Ku | 0.65Tu | 0.125Tu | 超调小于 5%,上升时间可接受 |
4. 常见问题与排查技巧实录
这套东西我在不同项目里复现过多次,踩过的坑五花八门。下面整理几个最典型的问题,每个都附上排查思路和最终解决方法。
4.1 控制周期抖动导致电机异响
现象是电机在低速时发出周期性的“咔哒”声,高速时变成连续的啸叫。用示波器抓 PWM 波形,发现占空比更新时刻不固定,有时提前有时延后。排查下来是控制循环里有一处用了printf调试输出,串口发送是阻塞式的,导致控制周期被拉长。
解决方法很简单:把所有调试输出改成环形缓冲区加 DMA 发送,主循环只往缓冲区写数据,DMA 在后台自动发送。这样调试输出不再阻塞控制循环。另外,我还养成了一个习惯:在控制循环的入口和出口各翻转一个 GPIO 引脚,用示波器直接看高电平持续时间,一眼就能看出周期是否稳定。
4.2 传感器融合在特定角度出现跳变
现象是云台转到某个特定角度时,姿态角突然跳变几十度,过了那个角度又恢复正常。一开始怀疑是传感器坏了,换了新的还是一样。后来用逻辑分析仪抓原始数据,发现是加速度计在某个角度时,某个轴的输出接近零,导致atan2计算的分母极小,数值不稳定。
解决方法是在融合前对加速度计做模长校验。如果三轴合成模长与重力加速度偏差超过 10%,说明当前加速度计受到运动加速度干扰,此时降低加速度计的权重,甚至完全信任陀螺仪。这个校验逻辑加进去之后,跳变问题彻底消失。
float accel_mag = sqrtf(accel[0]*accel[0] + accel[1]*accel[1] + accel[2]*accel[2]); if (fabsf(accel_mag - 1.0f) > 0.1f) { alpha = 0.999f; // 几乎完全信任陀螺仪 }4.3 故障检测误报导致频繁切换
现象是系统运行几分钟后,某个传感器被反复标记为故障又恢复,导致融合输出忽好忽坏。查了统计数据,发现该传感器的偏差均值在学习期结束后发生了漂移,原因是温度变化导致传感器零偏改变。学习期是在常温下做的,运行一段时间后芯片发热,零偏漂移超出了阈值。
解决方法有两个方向:一是定期重新学习,比如每 10 分钟重新计算一次均值和标准差,但这样会把缓慢漂移当成正常。二是加入温度补偿,根据温度传感器的读数对零偏做线性修正。我选了第二种,因为云台工作时温度变化是可预测的。具体做法是在学习期同时记录温度和零偏,拟合出温度系数,后续实时补偿。
4.4 快速运动时控制输出饱和
现象是云台快速转动时,电机力矩输出达到最大值,但角度仍然跟不上目标,导致滞后越来越大。这是典型的积分饱和问题。虽然加了积分限幅,但限幅值设得太大,积分项在饱和期间累积了过多误差。
解决方法是加入反计算抗饱和。当输出饱和时,不再累加积分项,而是把积分项往回拉,拉回的速度与饱和程度成正比。具体实现是:计算未限幅的输出与限幅后输出的差值,把这个差值乘以一个系数(我用的 0.1)从积分项里减去。这样积分项不会在饱和期间过度累积,退出饱和时恢复更快。
| 问题现象 | 可能原因 | 排查手段 | 解决方法 |
|---|---|---|---|
| 电机异响 | 控制周期抖动 | 示波器看 GPIO 翻转 | 调试输出改 DMA |
| 角度跳变 | 加速度计模长异常 | 逻辑分析仪抓原始数据 | 模长校验加降权 |
| 故障误报 | 温漂导致零偏变化 | 记录温度与零偏关系 | 温度补偿或定期重学习 |
| 输出饱和 | 积分项累积过多 | 观察积分项数值 | 反计算抗饱和 |
4.5 实操心得:调试顺序比调试技巧更重要
很多人一上来就调 PID,调了半天发现是传感器数据有问题。我总结的调试顺序是:先验证时间基准,再验证传感器原始数据,然后验证融合结果,最后才调控制律。每一步都要用示波器或者逻辑分析仪确认,不能只看代码逻辑。
比如时间基准,我会在定时器中断里翻转一个 GPIO,用示波器测频率是不是 20 kHz,占空比是不是 50%。传感器原始数据,我会把静止时的读数打印出来,看噪声峰峰值是不是在数据手册标称范围内。融合结果,我会用手缓慢转动云台,看角度输出是不是平滑跟随。这些基础验证做完,控制律的调试往往一次就能过。
注意:不要跳过基础验证直接调控制律。我见过太多案例,控制效果不好其实是传感器数据有问题,结果在 PID 上反复折腾,浪费了大量时间。
5. 从 Apollo-11 延伸出的工程习惯
这套项目做下来,最大的收获不是某个具体算法,而是一些工程习惯。这些习惯在后续的多个项目里反复被验证有效,我觉得比任何单一技术点都值得分享。
5.1 每个模块都要有独立的测试入口
Apollo-11 的参考实现里,每个模块都有一个test_开头的函数,可以单独调用。比如test_sensor_read()只读传感器并打印,test_fusion()只跑融合算法并输出角度。这样当系统出问题时,可以快速定位是哪个模块的毛病,而不是在整条链路里大海捞针。
我现在的习惯是,每写一个新模块,先写它的测试函数,确认单独跑没问题了,再集成到主循环里。这个习惯让我在调试复杂系统时,排查时间至少缩短了一半。
5.2 参数全部集中管理,不散落在代码里
Apollo-11 的所有可调参数都放在一个config.h文件里,用宏定义或者常量数组。比如互补滤波的 α 上下限、故障检测的阈值倍数、PID 的初始参数,全部集中在一起。这样做的好处是,换一个硬件平台时,只需要改这一个文件,不用在几千行代码里找参数。
我试过把参数散落在各个模块里,结果有一次换电机,改了五六个文件才把所有相关参数找全,还漏了一个导致调试了半天。从那以后,所有参数必须集中管理,这成了我的铁律。
5.3 日志系统要能记录故障前后的数据
Apollo-11 的日志系统用的是环形缓冲区加触发记录。平时只记录最近 1 秒的数据,当故障检测触发时,把故障前 1 秒和后 1 秒的数据全部保存到非易失存储器里。这样事后分析时,能看到故障发生前后的完整状态变化,而不是只有一个故障标志。
这个思路我是从汽车的黑匣子得到的启发。实际用下来,很多偶发故障都是靠这个日志系统定位的。比如有一次云台在特定动作下偶尔失控,日志显示故障前 200 毫秒,某个电机的电流突然增大到正常值的 3 倍,顺着这个线索查下去,发现是机械结构在特定角度有干涉。
5.4 版本管理要记录参数变更
Apollo-11 项目用 Git 管理代码,但参数变更不能只靠代码提交记录。我的做法是,每次参数调整都在一个params_log.md文件里记录:日期、调整了哪个参数、从多少改到多少、调整原因、调整后的效果。这个习惯看起来麻烦,但当你想回退到某个“感觉最好”的版本时,翻一下日志就能找到当时的参数组合。
我踩过的坑是,有一次调了一组参数感觉很好,但没有记录,第二天再跑发现效果不对,却怎么也想不起来昨天改了什么。从那以后,参数变更必须记录,这成了硬性规定。
5.5 定期做“破坏性测试”
Apollo-11 的测试计划里有一项叫破坏性测试:故意拔掉一个传感器、故意让通信中断、故意给电机加堵转,看系统能不能安全降级。这个测试在实验室里做,总比在现场出问题好。我试过在云台运行时突然拔掉一个传感器,系统在 200 毫秒内检测到故障并切换到单传感器模式,云台晃了一下但没失控。这个测试结果让我对系统的可靠性有了底。
破坏性测试的关键是提前设计好降级策略。比如传感器故障后,是切换到备用传感器,还是直接进入安全模式停止输出?通信中断后,是保持当前输出,还是缓慢回中?这些策略必须在测试前想清楚,测试时才能验证策略是否有效。
6. 关于 Apollo-11 这个代号本身
最后聊几句这个代号。Apollo-11 在技术圈里已经成了一个符号,代表在极端约束下追求确定性的工程哲学。它不追求最先进的算法,不追求最高的性能指标,它追求的是:在任何可预见的异常情况下,系统都能按照预期的方式运行,或者按照预期的方式安全停止。
我在实际项目里越来越觉得,这种哲学比任何具体技术都重要。技术会过时,芯片会换代,但“确定性优先”的思路是跨平台的。你把它用在云台上,云台稳;用在机器人上,机器人可靠;用在数据采集上,数据不丢。这大概就是 Apollo-11 这个代号能一直被人提起的原因。
如果你正在做一个对可靠性有要求的项目,不妨试试这套思路:先把时间基准做稳,再把传感器数据做干净,然后加上故障检测和降级策略,最后才去调控制参数。顺序对了,后面的事就顺了。