一段话讲清根因
根因:启用 CanSMBorTxConfirmationPolling 之后,CanSM 不把「控制器脱离 BusOff」当作恢复完成。状态 CanSM_FullCom_S_Bus_off_Check 会一直轮询 CanIf_GetTxConfirmationState(),直到返回 CANIF_TX_RX_NOTIFICATION —— 也就是至少有一帧报文真的发出去了。这一帧只能等应用自己的周期报文轮到发送时机。最快的周期是 40 ms,所以平均多等 20 ms、最坏 40 ms。50 + 25 = 75,235 - 200 = 35,两个偏差都落在这同一个区间里。修复:在 FULLCOM_S_BUS_OFF_CHECK 状态期间用 5 ms 周期主动调一次 CanIf_Transmit,把最坏等待从 40 ms 压到 5 ms。
这件事值得记下来,不是因为那个修法 —— 修法只有四行。而是因为这个偏差看起来像时序容差,实际上量的是另一件事。一旦知道恢复被一次发送确认卡着,那 25 ms 就不再是噪声,而变成一个可以预测、可以给上界、可以测的数字。
两个对不上的数字
配置很普通,测试也很普通,对不上的是结果:
| 阶段 | 配置 | 实测 | 偏差 |
|---|---|---|---|
| 快恢复 | 50 ms | 75 ms | +25 ms |
| 慢恢复 | 200 ms | 235 ms | +35 ms |
关于这张表有两件事需要说清楚,因为大多数记录就错在这里。
第一,偏差不是常数。25 和 35 不是一条直线上的两个点。两个都落在 0–40 ms 里,而 40 ms 是总线上最快的那条周期报文(这个项目里是一条 40 ms 的 HCM_L)。等多久取决于恢复完成的那一刻,落在这 40 ms 窗口的哪个位置:如果刚好在报文该发之前完成,几乎不用等;如果刚好错过,就要等满 40。两次采样无法区分「固定偏移」和「均匀分布」 —— 而这里是后者。
第二,也是真正影响工程判断的一点:50 ms 的恢复上带 ±20 ms 的抖动,就是 40% 的不确定度。如果网络要求某个节点必须在 50 ms 内回来,那它有一半的概率回不来 —— 不是因为配置错了,而是因为恢复在等一个配置管不到的数据面事件。只看到一次 75 ms 就下结论「协议栈慢了 25 ms」的人,会把一天时间花在调错的参数上。
快慢恢复本该长什么样
在怀疑任何东西之前,先把标准规定的行为说准 —— 因为控制器那一半是固定的,问题不在那里:
-
发送错误计数器
TEC每发送失败+8,每成功-1。超过255时控制器进入 BusOff,完全把自己从总线上摘下来。 -
要脱离 BusOff,必须连续观察到
128 × 11个隐性位。如果短路还在,这些位永远不会出现,控制器就一直重试、一直恢复不了。 - 快恢复以很短的固定间隔重试。每次尝试立刻失败就记一次;达到配置的次数(通常是 5 次)之后,控制器放弃快速重试。
- 慢恢复接着用更长的间隔接管,避免对一条彻底坏掉的总线持续冲击。延迟递增的目的是让真正有故障的节点不再和健康节点抢总线。
这些全部是控制器侧的、确定性的、以位时间为单位的行为。它解释不了 25 ms 的偏移。也就是说,偏移必然来自控制器之上的软件 —— 而它之上唯一持有定时器的东西,是 CanSM。
慢的那一截不在控制器里
沿着「恢复应该完成」的那一刻走一遍调用链。这是普华栈里的路径,其他实现的形状也一样:
CanSM_MainFunction() ← 周期任务
└─ CanSM_BsmSFullCom() ← FULL_COM 模式下的主状态机
└─ CanSM_FullCom_S_Bus_off_Check() ← ★ 恢复判定就在这里
if (CANIF_TX_RX_NOTIFICATION ==
CanIf_GetTxConfirmationState(controllerID)) ← 唯一的出口条件
{
/* 认为总线真的可用,退出 BUS_OFF_CHECK */
CanSM_FullCom_S_Restart_CC(...); ← 重启控制器
}
/* 否则:什么也不做,等下一次 CanSM_MainFunction */
把这个出口条件逐字读一遍。它问的不是控制器有没有退出 BusOff —— 控制器早就自己退出了,在硬件层。它问的是有没有发生过一次发送确认。这是两个不同的问题,而它们之间的差距,就是那 25 ms。
这个区分就是全文的核心。控制器恢复了是一个关于节点内部状态的事实;总线可用是一个关于那根线的断言,而它唯一的证据是有一帧过去了。这样配置的 CanSM 不接受推断,只接受证据。
这条配置到底换来了什么
这个行为由 CanSMBorTxConfirmationPolling 打开。它不改变任何等待时长,它改变的是什么算作完成。
| 配置 | 恢复条件 | 风险 |
|---|---|---|
FALSE |
控制器退出 BusOff 即算恢复 | 如果总线还短路、或还有故障节点在霸占,会立刻被踢回 BusOff |
TRUE |
控制器退出 BusOff 并且 有一帧被确认 | 恢复时间从此依赖应用的发送调度 |
设计意图是合理的。反复重进 BusOff 的控制器会浪费总线时间,更糟的是,它会让一条真正有故障的总线永远无法安静下来。在宣布恢复之前要求「这根线能通」的证据,把一个重试风暴变成「一次干净的尝试 + 一次测量」。
代价是一次归属权的转移,而这一点在评审时很容易被漏掉:恢复时间不再单纯是 CanSM 配置的属性。它是 CanSM 配置加上应用里最快周期发送间隔的函数。而写下 CanSM 配置文件的人,看不到第二项。
这个结构性教训值得带到别的项目上:任何一个让状态机等待数据面事件的配置开关,都在耦合两个本来独立设计的子系统。这个耦合在两份配置文件里都看不见,它只以一个「对不上的数字」的形式出现 —— 而这正是这次问题浮现的方式。
满足这个检查的两条路
CanIf_GetTxConfirmationState() 读的是一个数组元素 CanIf_TxConfirmationState[]。这个数组只有两个地方会被置成 CANIF_TX_RX_NOTIFICATION,而两者的区别就是整个修法所在:
| 置位点 | 前置条件 | 延迟 |
|---|---|---|
CanIf_TxConfirmation |
帧真的发出去了,控制器给出了确认 | 受发送调度约束 |
CanIf_TransmitHandle |
使能 CANIF_TX_OFFLINE_ACTIVE_SUPPORT |
只要有人调 CanIf_Transmit 就立刻 |
第二行才是关键,而它很容易被一眼扫过去。CanIf_TransmitHandle 是在 CanIf_Transmit 内部被调用的。所以只要调一次 CanIf_Transmit,就足以把状态推进 —— 你不需要那一帧真的到总线上。这正是下面那个重试之所以可行的原因:它给这个检查提供了一个更便宜的第二触发源,从而把它和应用的发送调度解耦。
必须在自己栈上核实的限定:CANIF_TX_OFFLINE_ACTIVE_SUPPORT 是一个配置开关,它的具体行为因实现而异。这里的主张很窄、也可以验证:开启它之后,CanIf_Transmit 能走到 CanIf_TransmitHandle,从而在没有真实总线确认的情况下把确认状态置上。你的 CanIf 是不是这样、以及在控制器 offline 时是否也这样,要用断点确认,不能靠文档。
断点:没人发帧,就永远等不到
到这里故障就很清楚了。BusOff 期间,这个检查停在等待状态。应用还在产生信号、还在请求发送 —— 但在 CanIf 这一层,这些请求产生不了确认,因为控制器根本没法把东西放到线上。唯一能满足这个检查的是一帧成功发送,而总线坏着的时候没有任何一帧能成功。
t0 TEC > 255 → 控制器进入 BusOff,脱离总线
t1 硬件等待 128 × 11 隐性位 → 恢复成功,回到 Error Active
t2 CanSM 进入 FULLCOM_S_BUS_OFF_CHECK,开始轮询 TxConfirmationState
t3 应用仍在调发送,但 CanIf 层不产生确认 → 状态不变
t4 ★ 只能等周期报文轮到发送时机
最快周期 40 ms → 平均等 20 ms,最坏 40 ms
t5 那一帧发出去 → CanIf_TxConfirmation → 状态置 NOTIFICATION
t6 CanSM 判定恢复完成 → Restart_CC → 通信恢复
→ 实测 = 配置 + 等待时间
快恢复:50 + 25 = 75 ms
慢恢复:200 + 35 = 235 ms
注意,这段等待不是任何人写出来的延时。它是一个采样问题:状态机只能在一帧发送发生的那一刻观察到确认,所以「恢复时钟」的分辨率就是帧周期。40 ms 的报文意味着 40 ms 粒度的恢复时钟。这就是为什么偏差落在 0–40 而不是别处。
还有一个比「平均值」更麻烦的二阶后果。如果最快的那条周期报文所在的 IPduGroup 恰好在 BusOff 期间被关掉了 —— 也就是另一篇文章里那个修法 —— 那就根本没有帧被调度,等待不是 40 ms 而是无界。这种情况下面单独一节讲,因为它是真的死锁,不是舍入误差。
修复:5 毫秒重试
既然这个检查可以通过调用 CanIf_Transmit 来满足,而唯一的问题是没人在正确的时刻调它,那么修法就是刻意去调 —— 但只在需要它的那个窗口里调:
/* 5 ms 周期任务 */
void HCM_LR_TransmitRetry(void)
{
/* ★ 只在 BUS_OFF_CHECK 期间动作,正常通信时完全沉默 */
if (CanSM_GetCurrentSubState(CANSM_CONTROLLER_0)
== CANSM_FULLCOM_S_BUS_OFF_CHECK)
{
/* 调一次即可推进 CanIf_TxConfirmationState
—— 不需要这一帧真的到总线上 */
(void)CanIf_Transmit(HCM_LR_TX_PDU_ID, &HCM_LR_TxPduInfo);
}
}
两个性质让这是工程手段而不是打补丁。状态门控意味着它在正常运行时贡献零总线负载 —— 除了故障恢复期间,它一直休眠。5 ms 周期把最坏等待从 40 ms 压到 5 ms,于是恢复时间变成 50 + ≤5,而不是 50 + 0–40。
| 修复前 | 修复后 | |
|---|---|---|
| 快恢复实测 | 75 ms | 50 ms |
| 最坏额外等待 | 40 ms | 5 ms |
| 50 ms 预算上的抖动 | ±20 ms | ±2.5 ms |
| 正常运行时开销 | — | 0 |
实测回到 50 ms,也就是配置值本身 —— 这才是有用的那种确认:你看到的数字就是配置文件里的数字,而制造旧数字的机制是消失了,不只是被掩盖了。
为什么是 5 ms 不是 1 ms
下一个自然会问「为什么不再快一点」。三个理由,第三个是决定性的:
- 收益递减。残余误差就是重试周期本身。从 40 ms 降到 5 ms 消掉 35 ms 抖动;从 5 ms 降到 1 ms 再消掉 4 ms。在 50 ms 的预算上,第二次收益不到 8%。
- 过采样已有余量。相对 40 ms 的帧周期,5 ms 是 8 倍过采样。即使以后变型车里最快的帧变成 10 ms,5 ms 仍然有 2 倍余量。
-
每次调用都有代价。
CanIf_Transmit不是免费的:它要穿过 PDU 路径、碰发送缓冲、可能和真实流量争用。BUS_OFF_CHECK 期间这无所谓,但这个周期同时也是「万一状态门控写错了,你要付多少」的下界。5 ms 的下界是个有界的错误;1 ms 的下界在故障期间就是个 CPU 预算问题。
还有一个「保持慢一点更正确」的论证:这个重试是一次探针,而一个打得太频繁的探针就不再是探针,而是流量。目的是尽快知道总线能不能用,不是一直去戳它。
和「关组」方案之间的死锁
这一节是我最希望评审仔细读的部分,因为这里是两个各自正确的修法组合成一个挂死的地方。它也是本文中属于推理而非实测的部分,所以请当作一个需要在自己栈上验证的假设。
另一篇的修法是:BusOff 时 BswM 调 Com_IpduGroupControl(group, FALSE) 停掉发送 I-PDU,避免泄漏的 Transmiting 标志把它们永久堵死。修法本身没问题,也验证过有效。但看看它对本文这条恢复路径做了什么:
t0 BusOff → BswM 关掉 TX 组(ActiveEnable = FALSE)
t1 控制器硬件恢复(128 × 11 隐性位)→ Error Active
t2 CanSM 进入 FULLCOM_S_BUS_OFF_CHECK,等一次发送确认
t3 组是关的 → Com 不再把任何 I-PDU 交给 PduR → 没有帧被调度
t4 ★ 没有帧 → 没有 TxConfirmation → 状态永远不变
t5 CanSM 永远停在 BUS_OFF_CHECK
t6 BswM 收不到「已恢复」事件 → 不会去重开组
→ 回到 t3,闭环
→ 控制器是好的、总线是好的、协议栈活着,但这个节点永远回不来
这个环是:CanSM 等一帧,帧等组,组等 BswM,BswM 等 CanSM。四个等待,零进展。这个环里没有一环单独看是坏的 —— 这正是这类挂死能通过代码评审的原因。
| 规避方式 | 做法 | 代价 |
|---|---|---|
| 重试帧不放在被关的组里 | 留一个最小常开组,只放探针帧 | 多一个组要维护;探针帧必须是总线上所有节点都能接受的 |
| 重试绕过 Com |
直接调 CanIf_Transmit,不经过 Com
|
绕过 Com 自己的记账;要手工构造 PDU,需仔细评审 |
| 不关探针所在的组 | 只关那些帧不参与恢复的组 | 要求你知道恢复路径依赖哪些帧 —— 而这正是问题所在 |
第二行才是这套实现实际做的事,也解释了为什么两个修法在这个项目里能共存。但那是有人做出的设计决定,不是任何一个修法自带的性质。如果后来有人加了一个经过 Com 路径的重试,死锁就会回来 —— 而且是静默地回来,只在 BusOff 下发生,而那恰好是测得最少的条件。
一般化的形式:每当一条恢复路径等待某个数据面事件,就要检查系统里还有什么会压制这个事件。两个各自看起来局部的修法,可以经由故障处理层形成一个环。最便宜的防御是把这个环保显式地写下来 —— 上面那六行时间线比任何数量的测试都有用,因为测试只有在恰好覆盖了这个组合时才会发现它。
怎么测才算测过
单次恢复时间不是对这个行为的测量 —— 它只是一个样本,而你真正要刻画的是这个分布的宽度。所以应该这样测:
- 用 VH6501 或等效的故障注入通道确定性地制造 BusOff,不要手工短接线。短接能制造 BusOff,但控制不了时机,而时机正是决定偏差的东西。
- 至少注入 20 次,记录每一次的恢复时长。20 个样本是谈分布的最低门槛;50 个更好,自动化之后不额外花时间。
- 报 P50 和 P95,不要报平均值。平均值掩盖尾部,而尾部才是打破时序要求的东西。均值 60 ms、P95 88 ms 的系统,不是「60 ms 的系统」。
- 拿两组数字对照:配置值,以及「配置值 + 最快帧周期」。修复前 P95 应接近 50 + 40 = 90 ms;修复后应接近 50 + 5 = 55 ms。在测量之前把两个数字都预测出来,结果才算证据,而不只是观察。
- 同时测快慢切换点:第 5 次快恢复应该是最后一次。如果计数差一,你就有两个问题,而其中一个不是本文这个。
还有一件值得做的事:把重试计数留在一个可读的调试变量里。等这个故障最终出现在客户 trace 里时,知道探针到底被触发了几次,能立刻告诉你恢复路径有没有被走进去。
这套修法不解决什么
| 不解决的 | 为什么 |
|---|---|
| BusOff 本身的发生 | 重试让恢复变快,没有让总线变健康。如果短路是真的,恢复得快也没意义 |
| 中断期间丢掉的报文 | 物理事实。要不要补发是应用层的决定 |
| 轮询本身的取舍 | 重试缩短了等待,没有消除「恢复依赖发送路径」这个耦合 |
| Bor 设计不同的其他栈 | 上面的链条是普华实现;状态名乃至轮询行为都因实现而异 |
| 慢恢复参数的取值 | 200 ms 对这个网络是不是对的数字,是系统问题,不是协议栈问题 |
把通用教训说一次,说直白:当一条恢复路径等待数据面的证据,恢复时间就不再是恢复配置的属性。它变成恢复配置、发送调度、以及一切能压制发送的东西的共同属性。三个所有者,通常没有一个知道另外两个的存在。
常见问题
为什么 CAN BusOff 快恢复实测比配置多 25 ms?
因为开了 CanSMBorTxConfirmationPolling 之后,CanSM 不把「控制器退出 BusOff」当作恢复完成。CanSM_FullCom_S_Bus_off_Check 会一直轮询 CanIf_GetTxConfirmationState,直到返回 CANIF_TX_RX_NOTIFICATION —— 也就是至少有一帧真的发出去了。这一帧只能来自应用的周期调度。最快周期 40 ms 时,额外等待平均 20 ms、最坏 40 ms,所以 50 + 25 = 75 正好落在这个窗口里。
CanSMBorTxConfirmationPolling 这个配置到底在做什么?
它把「控制器退出 BusOff」和「总线可用」拆成两个条件。硬件在 128 × 11 个隐性位后回到 Error Active,这只能证明这个节点愿意重新说话,不能证明线上是干净的。开启这个开关后,CanSM 额外要求一次成功的发送确认作为活体检测。意图是合理的;代价是恢复时间不再由 CanSM 配置单独决定。
CanIf_TxConfirmationState 是在哪里被置位的?
两个地方。CanIf_TxConfirmation 是正常路径:帧真的发出去了,控制器给了确认。另一个是 CanIf_TransmitHandle,需要使能 CANIF_TX_OFFLINE_ACTIVE_SUPPORT,它在 CanIf_Transmit 内部被调用。第二条路径正是重试能生效的原因 —— 调一次 CanIf_Transmit 就能推进状态,不需要真实的总线确认。
为什么快恢复慢 25 ms,慢恢复却慢 35 ms?
两个都落在同一个 0–40 ms 窗口里 —— 一个最快帧周期。等待时间取决于恢复完成时相对于那帧下一次发送时机的位置,是均匀分布而不是固定值。25 和 35 是两个采样点,不是规律。要刻画它需要 20 次以上注入和 P50/P95:修复前 P95 应接近配置值 + 40 ms,修复后接近配置值 + 5 ms。
5 ms 重试应该怎么实现?
建一个 5 ms 周期任务,调 CanIf_Transmit 发一条短报文,并由「CanSM 处于 FULLCOM_S_BUS_OFF_CHECK」这个条件门控。门控比周期更重要:没有它,你在正常运行时凭空增加总线负载。有了它,除了故障恢复期间成本为零。实测效果:快恢复从 75 ms 回到配置的 50 ms。
BusOff 时关闭 IPduGroup 和这个 5 ms 重试会冲突吗?
可能会死锁。如果关掉的组正是唯一会发帧的那一组,CanSM 就永远看不到确认,永远不退出 BUS_OFF_CHECK,于是 BswM 也永远收不到恢复事件、不会重开组。环是这样的:CanSM 等帧,帧等组,组等 BswM,BswM 等 CanSM。规避方式:把探针放在被关的组之外,或者让重试直接调 CanIf_Transmit,绕过 Com 的组开关。