@[toc]

在这里插入图片描述

一次由 C 位域非原子写入引发的状态上报异常

在一套嵌入式控制固件中,出现过一类很典型、但又很容易被忽略的异常:传感器物理状态已经持续触发,心跳报文却长时间仍然上报为 0;重新手动触发一次后,状态又恢复正常。

表面上看,这类问题很像采样抖动、消息队列满、或者 CAN 发送延迟。但沿着发送链路继续深挖后,真正的问题并不在总线,也不在消息队列,而是在 C 语言位域写入不是原子操作 这一点上。


先看现象,不要先怪 CAN

异常表现有几个很强的特征:

  • 传感器已经进入触发状态,并且维持了很长时间。
  • 心跳报文仍然持续发送,但目标状态位始终是 0
  • 再次手动触发一次传感器后,状态恢复正常。
  • 不是“完全没报文”,而是“报文一直有,但某一位一直不对”。

这类现象说明一件事:发送通道本身大概率是通的。如果 CAN 队列、发送线程、或总线仲裁是主因,更常见的表现会是丢帧、延迟、或整帧不发,而不是“整帧一直发,但某一位长期错误”。

因此,排查重点不该先放在“是否发出去”,而应该放在“发出去的那 8 个字节是谁构造的,以及构造时有没有被并发写坏”。


位域写入通常是读改写,不是原子更新

问题的核心在于心跳报文使用了 packed 位域结构。多个状态位被压缩在同一个字节中,例如:

  • 某一路传感器状态位
  • 5V 电源错误位
  • 某些电机失步错误位

这些位在代码里看起来像是独立字段:

1
2
3
heartbeat.Sensor8 = 1;
heartbeat.SensorPowerError = 0;
heartbeat.Motor1StepErrFlag = 1;

但在机器执行层面,这种写法通常不会变成“只改 1 个 bit 的原子写入”,而更接近下面这种读改写流程:

1
2
3
4
tmp = byte5;
tmp &= ~MASK;
tmp |= new_value;
byte5 = tmp;

也就是说,编译器通常会:

  1. 先读出这个字段所在的整个字节;
  2. 清掉目标位;
  3. 填入新值;
  4. 再把整个字节写回去。

这就是典型的 read-modify-write

一旦两个上下文同时对同一个字节中的不同位做位域赋值,就有机会出现 丢更新

  • 上下文 A 读到旧值,准备把 bit0 置 1
  • 上下文 B 也读到同一个旧值,准备把 bit1 清 0
  • A 先写回
  • B 再基于旧值写回

最终结果是:A 刚刚写进去的 bit0 被 B 覆盖掉了。

从源码视角看,两个字段互不相关;从内存视角看,它们其实在争抢同一个字节。


多上下文并发写才是问题真正的触发条件

单纯使用位域,并不一定会出错。真正把问题引爆的,是 多个执行上下文同时写同一个心跳对象

该场景中,至少存在三类写者:

  • 传感器滤波逻辑更新传感器状态位;
  • 电源状态检查逻辑更新 5V 错误位;
  • 电机状态同步逻辑更新失步错误位。

更关键的是,这些写者并不在同一个上下文里顺序执行:

  • 有的在高频周期处理中运行;
  • 有的在任务线程中运行;
  • 有的在另一个状态同步路径中运行。

这样一来,同一个心跳报文对象就成了一个“共享可写结构体”,而且多个字段恰好被 packed 到同一个字节中。只要时序踩中窗口,覆盖就会发生。

问题并不需要高概率复现。只要某一次在恰当的时间发生一次覆盖,就可能把状态推入一个很难自动恢复的错误状态。


“最后一个 1 之后长期变成 0”为什么会出现

这个现象最容易让人误判。

表面看,像是某次状态变化没有发出去。实际上,更合理的解释是:

  1. 传感器状态已经被确认成 1,并且某一帧心跳也确实正常发出了这个 1
  2. 随后,另一个上下文对同一心跳对象中的相邻位做了位域写入。
  3. 由于位域赋值是读改写,目标传感器位被连带覆盖回 0
  4. 从这一刻开始,心跳缓存中的该位已经错了。
  5. 但传感器滤波逻辑内部保存的“上一次状态”仍然是 1

这会形成一个很关键的分叉:

  • 滤波状态机认为当前输入没有变化
  • 心跳缓存里的该位却已经被覆盖成 0

而滤波逻辑通常只在“检测到变化”时才重写状态位,不会在每个周期都重复刷新。因此,只要物理状态一直保持触发,逻辑层就不会再去补写一次 1

结果就是:

  • 心跳报文还在持续发送;
  • 但发送出去的一直是已经被写坏的缓存;
  • 错误可以持续很久,直到下一次真实边沿重新触发状态更新。

这也解释了为什么“重新手动触发一下就恢复正常”。


队列快照机制只能保护单帧,保护不了共享状态

很多发送框架会在入队前做一次 payload 快照,例如:

  1. 调编码函数拿到数据指针;
  2. memcpy 到本地队列结构;
  3. 将队列结构入队发送。

这个机制确实有价值,因为它能保证:

  • 已经入队的这条消息 不会被后续再修改。

但它保护不了另一件事:

  • 共享心跳对象在快照之前或快照之后被别的上下文写坏。

因此,快照机制只能说明“当前这帧一旦入队就是稳定的”,不能说明“全局状态源是稳定的”。

如果共享对象本身已经被并发读改写破坏,那么快照拷走的只是一个稳定的错误值。


这类问题为什么很容易被误判成硬件问题

这类异常有几个特征,天然会把怀疑方向带偏:

  • 低概率复现;
  • 触发后能持续很久;
  • 再次手动触发后又恢复;
  • 表面上又像“状态没采上来”。

因此,第一反应通常会是:

  • 输入引脚抖动;
  • 电平不稳定;
  • 采样窗口太窄;
  • 消息队列满导致边沿丢失。

这些方向都不是没有可能,但它们解释不了一个关键现象:

某一位长期错误,而整帧持续正常发送。

只要出现这种模式,就必须把“共享状态对象是否被并发改坏”放到更靠前的位置。尤其当代码里用了 packed 位域,而且多个上下文都在直接写它时,这条路径几乎一定要重点检查。


解决思路不在生成代码里,而在写入策略上

这类问题不一定要去修改自动生成的编码文件。更合适的做法,是从架构上减少并发写入共享位域对象的机会。

更稳妥的思路有三类:

1. 不让多个上下文直接写心跳位域对象

把心跳对象视为“发送缓存”,而不是“业务状态源”。
业务状态分别保存在各自的权威源中,发送时统一汇总。

2. 对心跳更新建立单写者模型

由一个固定上下文负责组帧,其余模块只更新各自状态,不直接操作心跳位域。

3. 至少把同一报文的写入与快照发送放进同一保护域

如果短期内无法重构,也需要保证:

  • 写心跳字段时不会互相覆盖;
  • 取发送快照时不会与写操作并发交错。

无论采用哪一种形式,核心都不是“让位域变成原子”,而是:

不要让多个上下文无保护地对同一个 packed 位域对象做直接写入。


经验教训应该写进编码规范

这类问题的价值,不只在于修复一个现场故障,更在于形成明确的工程约束。

以下规则值得固化:

不要把 packed 位域结构体当作共享状态容器

位域适合描述协议格式,不适合作为多上下文共享状态的直接存储对象。

不要假设位域赋值是原子操作

源码里写的是一个字段,机器层面改的往往是整个字节、甚至整个字。

不要让 ISR、任务线程、周期服务同时直接写同一报文对象

只要多个字段落在同一存储单元,竞态就已经存在。

不要因为“报文在持续发送”就排除共享状态错误

发送稳定,只能证明链路通;不能证明 payload 正确。


这个问题的根因可以总结成一句话

问题并不是传感器没有触发,也不是 CAN 没有发送,而是多个上下文同时对同一个心跳位域对象进行非原子写入,导致状态位被覆盖,最终把系统推入了“缓存错误但状态机不再重写”的异常状态。

这类问题最容易被忽略的点,就是那句看似普通、实际非常关键的话:

C 语言位域写入不是原子操作。

只要这一点没有被明确意识到,任何一个 packed 协议对象都可能在并发环境里变成隐蔽故障源。