先楫 HPM 与 STM32G491 CAN FD 通信延迟排查:从 4 个周期优化到同周期反馈
最近在人形机器人通信链路调试中,发现了一个比较典型、也比较容易被忽略的 CAN FD 时序问题。
先楫板周期下发控制指令,关节模组接收控制量、完成控制处理后,再通过 CAN FD 返回状态数据。最开始从抓包看,一条控制指令从发送到在反馈中体现出来,时间差达到了 8.033 ms,约 4 个 CAN FD 收发周期。
单看 CAN FD 总线本身,这个结果很容易让人先怀疑波特率、总线负载或者驱动性能。但后面的排查证明,真正的问题不在“CAN 帧在线上跑得慢”,而在接收 FIFO 中的旧数据积压,以及控制处理和反馈发送之间的软件时序。
这篇文章记录一下完整的定位过程。
1. 先定义清楚:这里测到的“延迟”是什么
本文说的通信延迟,不是 CAN FD 帧在总线上的物理传播时间,也不是单纯的硬件收发时间。
测试方法是在控制帧中携带 frameNum,模组处理这条控制帧后,在反馈帧中返回对应的 frameNum。通过比较主站当前发送的帧号和反馈中携带的帧号,可以判断:
当前收到的反馈,到底对应几个周期之前的控制指令。
因此本文关注的更准确说法其实是:
控制数据的新鲜度(data age)或者控制—反馈软件流水线延迟。
最初抓包中观察到:
16.661262 s – 16.653229 s = 8.033 ms
约等于 4 个 CAN FD 收发周期。
对于机器人关节控制来说,这种周期级延迟比单纯几十微秒的总线传输时间更值得关注,因为它会直接增加闭环中的数据年龄。
2. 一个很奇怪的现象:换个上电顺序,延迟就变了
进一步测试时发现,延迟和上电顺序存在明显关系。
| 先给模组上电,再给先楫板上电 | 约 1 个周期 |
| 先给先楫板上电,再给模组上电 | 约 4 个周期 |
这个现象非常关键。
如果问题主要来自 CAN FD 波特率、报文长度或者总线负载,那么仅仅改变上电顺序,不应该让延迟从 1 个周期突然变成 4 个周期。
所以排查重点开始转向:
模组启动过程中,CAN FD 接收链路什么时候开始收数据?软件任务又是什么时候开始消费这些数据?
3. 问题一:模组后上电,Rx FIFO 里已经堆了旧控制帧
模组使用 STM32G491。
模组后上电时,先楫 HPM 已经启动,并且在持续周期发送控制帧。例如总线上可能已经依次出现:
frame 8
frame 9
frame 10
frame 11
…
问题在于,STM32 上电初始化并不是“所有软件模块在同一个时刻一起开始工作”。
FDCAN 外设可能已经初始化完成,可以正常把总线报文放进 Rx FIFO;但控制任务、反馈任务以及应用层处理流程还没有完全进入稳定运行状态。
于是启动过程中会出现这样的情况:
总线已经发送:frame 8、frame 9、frame 10 …
Rx FIFO 中仍然排着:frame 8、frame 9 …
应用层开始工作以后,从 FIFO 头部按顺序读取
此时模组处理到的是“旧控制帧”,而不是总线上刚刚到达的最新控制量。

3.1 为什么这个延迟不会自己追上来?
这才是这个问题最容易忽略的地方。
假设进入稳定运行以后:
- 先楫板每个周期产生 1 条新控制帧;
- 模组每个周期也只消费 1 条控制帧。
如果启动时 Rx FIFO 已经积压了若干帧,那么后面虽然模组一直在处理数据,但:
每个周期进来 1 帧,同时只处理 1 帧。
生产速度和消费速度相同,之前形成的队列深度就不会自然减少。
所以系统不会“运行一会儿自动恢复”。
它会一直稳定地落后若干周期。
文档里的 frame 8 / frame 9 / frame 10 是用来说明这个机制的示例。现场测试最终表现为总延迟从约 4 个周期降到 1 个周期,说明启动阶段旧帧积压贡献了额外的周期级数据年龄。
实际积压多少帧,会受到 FDCAN 初始化完成时间、任务启动时间和控制周期相位的影响,并不一定每次固定为 2 帧。
4. 第一轮优化:控制数据只处理 Rx FIFO 中的最新帧
对于这种周期控制协议,旧控制指令通常已经失去意义。
比如主站连续发送:
frame 100:目标位置 10.0°
frame 101:目标位置 10.2°
frame 102:目标位置 10.5°
如果模组现在已经收到 frame 102,再花两个控制周期依次执行 frame 100 和 frame 101,不仅没有收益,反而人为增加控制数据年龄。
因此第一轮优化思路很直接:
收到一批积压帧时,丢弃旧控制帧,只保留并处理 Rx FIFO 中最新的一帧。
修改前,逻辑更接近:
if (FDCAN_RxFifoHasData())
{
read_one_frame_from_fifo();
process_control_frame();
}
优化后的思想是:
while (FDCAN_RxFifoHasData())
{
read_one_frame_from_fifo();
/*
* 持续覆盖,只保留最后读取到的最新帧
*/
latest_frame = rx_frame;
}
process_control_frame(&latest_frame);
具体工程实现可以根据 HAL/LL 或 MCAN 驱动接口调整,核心不是这几行代码本身,而是把接收语义从:
FIFO 消息队列
改成:
最新控制状态缓存
部署以后重新测试,控制—反馈延迟从约 4 个周期降低到 1 个周期。
这说明启动阶段的旧帧积压确实是主要问题之一。
4.1 这里有一个使用前提
“丢掉旧帧,只处理最新帧”并不是所有 CAN 协议都适用。
它适合的是周期位置、速度、力矩目标值,以及其他 latest-value wins 类型的数据。
如果报文承载的是增量命令、轨迹分片、必须逐条执行的事件或者参数写入事务,就不能简单丢弃旧帧。
所以这次优化成立的根本原因,是我们的 CAN FD 控制帧属于周期覆盖型控制量。
5. 还有 1 个周期,到底慢在哪里?
第一轮修改完成以后,问题没有完全结束。
抓包已经从 4 个周期降到了 1 个周期,但 frameNum 仍然能观察到稳定的一拍延迟。
这时再去检查 Rx FIFO 已经没有意义,因为旧帧积压已经解决了。
继续把模组内部的软件执行时序展开后,发现剩下的 1 个周期来自 CAN FD 反馈帧进入 Tx FIFO 的时机。
6. 问题二:反馈帧组织得太早,发送的是“上一拍状态”
原来的处理方式大致是:
问题就出在第 3 步。
CAN FD 接收中断虽然已经收到了本周期最新控制指令,但此时 FOC 控制还没有基于这个新控制量完成运算。
所以填入 Tx FIFO 的状态,本质上仍然对应上一个控制周期。
可以把这个时序理解成:
收到控制帧 N
CANFD Rx IRQ:
更新控制参数 N
立即组织反馈
此时状态仍然是 N-1
FOC ISR:
根据控制参数 N 完成控制处理
状态更新为 N
等 CAN FD 回复发出去时,Tx FIFO 里早已经放好了 N-1 的状态。
于是从抓包看,就表现为:
控制帧已经是 frame N,但反馈数据仍然对应 frame N-1。
这就是剩余 1 个周期延迟的来源。

7. 第二轮优化:等 FOC 更新完成后,再把反馈数据放进 Tx FIFO
解决思路不是让 CAN FD 发得更快,而是重新安排**“什么时候生成反馈数据”**。
优化后,CAN FD 接收中断只负责两件事:
- 更新最新控制参数;
- 设置 recvNewCanFdFrame = true,表示本周期收到了一条新的控制指令。
不再在 CAN FD Rx ISR 退出前立即填充 Tx FIFO。
例如:
void CANFD_Rx_IRQHandler(void)
{
update_control_parameter();
recvNewCanFdFrame = true;
/*
* 这里不再立即组织 CAN FD 反馈数据
*/
}
随后进入 FOC 中断,根据最新控制参数完成本周期控制计算和状态更新。
在 FOC ISR 退出前检查:
if (recvNewCanFdFrame)
{
recvNewCanFdFrame = false;
/*
* 软件触发 UART4_IRQ
*/
trigger_uart4_irq();
}
最后在 UART4_IRQ 中,读取已经由 FOC 更新完成的最新模组状态,再填充到 CAN FD Tx FIFO:
void UART4_IRQHandler(void)
{
update_canfd_feedback_from_latest_motor_state();
fill_canfd_tx_fifo();
}
这里 UART4_IRQ 是当前项目中用于承接这一发送处理的软件触发中断,名称本身不是关键。
真正关键的是执行顺序发生了变化:
CAN FD 收到最新控制量
FOC 使用最新控制量完成状态更新
再组织本周期反馈
最后放入 CAN FD Tx FIFO
这样反馈帧中的状态数据才和本周期刚收到的控制指令对应。
8. 为什么这个优化能消除最后 1 个周期?
修改前,控制参数和反馈状态属于两个不同时间点:
控制参数:N
反馈状态:N-1
所以即使 CAN FD 总线没有任何堵塞,从 frameNum 看也天然落后一拍。
修改以后,反馈数据的采样点被移动到了 FOC 更新之后:
控制参数:N
FOC 处理:N
反馈状态:N
因此反馈不再携带上一周期的旧状态。
这次优化解决的其实不是传统意义上的“发送性能”,而是一个很典型的:
数据采样时刻错误。
在实时控制系统里,“数据什么时候被读取和打包”经常和“数据传输用了多久”同样重要。
9. 最终测试结果
完成两轮优化以后重新抓包。
测试时把控制模式从:
0
切换为:
7
可以看到先楫板在本周期发送模式 7 后,模组在对应反馈帧中已经可以同步返回模式状态 7。
同时:
Tx frameNum == Rx frameNum
不再观察到之前的周期级滞后。
速度参数测试也得到相同结果:新的速度控制量可以在对应周期反馈中体现出来。
最终整个问题可以概括成两部分:
| 启动阶段旧控制帧积压 | 上电顺序不同,延迟可从 1T 变为约 4T | FDCAN 已收帧,但应用处理尚未启动;之后生产/消费速率相同,积压无法自然消失 | 清理旧帧,只处理最新周期控制帧 |
| 反馈生成时机过早 | 清掉 FIFO 后仍稳定落后 1T | CANFD Rx ISR 中过早把状态放入 Tx FIFO,FOC 还没更新本周期状态 | FOC 更新完成后再组织反馈并填入 Tx FIFO |
10. 这次问题给我的几个启发
10.1 周期通信延迟不一定是总线性能问题
看到 8 ms 延迟,第一反应很容易去看:
CAN FD 波特率
总线利用率
中断响应时间
发送 FIFO
这些当然需要检查,但这次真正占主要部分的是软件流水线。
一条 CAN FD 帧可能只花很短时间就完成传输,但如果应用层一直处理几周期前的数据,对控制系统来说它仍然是“高延迟”。
10.2 实时控制更关心数据年龄,而不是消息有没有丢
普通通信程序往往强调:
每一帧都不能丢。
但实时控制有时恰恰相反。
对于高频周期目标值,旧数据最大的价值可能就是尽快被新数据覆盖。
控制系统真正需要的是:
尽量使用最新的控制指令。
所以 Rx FIFO 的使用策略必须和上层数据语义一致。
10.3 中断里“马上回复”不一定延迟最低
乍一看,在 CAN FD 接收中断里立即把反馈放入 Tx FIFO,似乎是最快的做法。
但如果反馈内容依赖后续 FOC 运算,那么“立即发送”只是更快地发送了一份旧数据。
这种情况下,更合理的目标不是:
越早放进 Tx FIFO 越好。
而应该是:
在最新状态已经生成以后,尽早把它放进 Tx FIFO。
二者差一个状态更新边界,结果就是一个完整控制周期。
11. 总结
这次 CAN FD 通信延迟问题最终不是一个单点故障,而是两个软件时序问题叠加。
第一部分来自启动时的 Rx FIFO 旧控制帧积压。模组后上电时,FDCAN 已经开始收帧,但应用层还没有及时消费;进入稳定运行后又因为生产速度和消费速度基本一致,历史积压一直保留下来。
第二部分来自 反馈数据生成得太早。CAN FD Rx ISR 收到最新指令以后,在 FOC 完成最新状态更新之前就把反馈装入了 Tx FIFO,因此天然返回上一周期状态。
最终的处理方式也对应两条原则:
对周期覆盖型控制量,优先保证“最新数据”,不要机械地逐帧补历史数据。
对依赖控制计算结果的反馈量,要在最新状态真正生成以后再进行采样和发送。
从最后的抓包结果看,控制模式和速度参数都已经能够在对应周期内完成更新,frameNum 也能保持一致。
这次问题对我最大的提醒是:
做实时通信优化时,不要只盯着总线。把“接收、缓存、任务消费、控制计算、状态采样、反馈入队”整个软件时序拉出来,很多所谓的通信延迟其实藏在这里。





