☰
CAN通信协议重写实战:从丢帧到零丢帧的嵌入式升级方案
2026/10/16 5:24:26 网站建设 项目流程

原以为第一版CAN通信代码已经足够应付日常业务,直到有一次联调时,对方工程师问了一句:“如果总线上突然出现错误帧,代码会不会自己恢复?”我当场没有正面回答。后来那个项目果然在电磁干扰比较强的环境下连续丢了三帧转速报文,上位机直接报了故障。所以第二版重写时,我给自己定了个目标:不能再有“侥幸能用”的代码结构。这篇文章我会把升级版CAN通信协议代码的改动逻辑、核心模块设计、实测结果和踩坑过程都摊开讲,适合正在做嵌入式通信、被丢帧和总线锁死折磨过的开发者参考。

1. 第一版代码的瓶颈:为什么非升不可

重写这事不是拍脑袋决定的。第一版代码在正常工况下表现还可以,但一旦总线负载提高、干扰加剧,问题就一串一串冒出来。

1.1 丢帧现象与排查过程

最开始发现丢帧是在500kbps波特率下做压力测试。总线上每秒有上千帧报文,其中一类周期性速度报文和另一类诊断请求报文同时到达接收邮箱时,接收队列经常溢出。我当时先怀疑CAN控制器中断优先级配低了,把接收中断提到最高,结果没有改善。后来用总线分析仪抓报文,发现丢失的报文在应用层日志里完全没有出现过,但总线上确实有。说明不是CAN控制器没收发,而是中断服务函数处理不过来。

排查到最后,问题出在中断里直接解析报文并调用了应用层回调。接收中断触发时,我做了ID匹配、字节序转换、循环冗余校验、还要往应用层环形队列里写,整套流程跑完要几百微秒。这期间后续报文不断进来,硬件接收邮箱很快被填满,新报文只能丢弃。升级版的第一步就是砍掉中断里的多余工作,只保留最必要的拷贝动作。

1.2 协议设计上的先天不足

除了丢帧,第一版协议本身也有几个让我头疼的地方。

第一,没有多帧重组机制。当时有个固件升级功能,升级包最大要两兆,但每帧最多承载8字节数据,只能拆成几百帧连续发送。接收端完全没有排序和重组,只能靠上层按顺序硬接,一旦丢一帧,整个升级包就废掉。

第二,没有时间戳。应用层收到报文后,无法判断这个数据是什么时候采集的。比如某个状态量本来已经过期了,但应用层不知道,照样拿去做控制逻辑,这是很危险的。

第三,错误帧处理过于简单。第一版代码在CAN控制器进入总线关闭态之后,只是重新初始化外设,既不通知应用层,也不做退避和恢复等待。在强干扰环境下,这会导致总线反复关闭又马上开通,反而更容易造成通信瘫痪。

2. 升级版总体架构:双向缓冲与分时调度

第二版的设计思路很明确:把“协议栈”和“业务逻辑”彻底剥离开。硬件层只负责收发原始帧,协议栈负责解析和组装,业务层只管消费和发送。剥离开的关键就是缓冲区和调度机制。

2.1 环形缓冲区与消息队列的选择

在MCU上,我选了无锁环形缓冲区。原因是嵌入式环境不适合动态分配内存,环形缓冲区的内存占用固定、入队出队都是常数时间,非常适合中断上下文。

接收方向,我在RAM里划了一块uint8_t数组,按帧存入原始数据。中断处理函数只做一件事:把硬件收到的每个字节以及元信息写入环形缓冲区,然后立刻退出中断。真正干活的是主循环里的接收处理任务,它定期从缓冲区取出一帧、交给解析器、再分发到业务层。这个改动直接把接收中断的耗时缩短到原来的三分之一。

发送方向也是类似思路。应用层需要发报文时,不是直接调CAN发送寄存器,而是先把报文放进发送环形队列。CAN发送中断被触发后,从队列中取出下一帧写入硬件发送邮箱。这样即使多个业务模块同时抢着发报文,也不会出现寄存器被覆盖的问题。

2.2 帧解析器的输入输出模型

升级版的帧解析器是一个独立模块,输入是“原始帧结构体”,输出是“标准化消息结构体”。这个结构体包含几个关键字段:

  • 报文ID:29位扩展ID已经拆成标准ID和扩展标志;
  • 帧类型:数据帧、远程帧、错误帧;
  • 数据长度:从DLC映射得到实际字节数;
  • 数据载荷:统一按小端序存放;
  • 时间戳:接收瞬间打上的16位定时器值;
  • 状态标记:标识帧是否完整、校验是否通过。

上层业务拿到这个结构体后,不需要关心底层是哪个CAN外设,也不需要手动拼接ID和移位解析DLC。这使得后续如果换用不同型号的CAN控制器,只需要重写接口层,协议栈和业务层完全不用动。这个设计让升级版的可维护性比第一版强了一个量级。

2.3 分时调度避免总线堵塞

还有一个容易被忽略的问题:如果好几个周期报文同时到期,一股脑写进发送队列,可能在极短时间内把总线塞满,导致本该优先发送的实时报文被挤没。为了解决这个问题,我在发送方向上加了简单的令牌桶调度。

具体做法是:每5毫秒产生一个发送令牌,一个周期最多允许发出5帧;如果队列里还有剩余报文,则顺延到下一个周期。高优先级报文会优先占用令牌,低优先级报文只能等待。实测在三个周期报文叠加时,高优先级报文的发送抖动从第一版的平均4.2毫秒降到了1.1毫秒,代价就是低优先级报文偶尔会晚发几十毫秒,但这种代价在非实时业务中完全可以接受。

3. 核心模块改写详解:从裸收发到状态机

这次升级最核心的改动是:把原来“收到一帧解一个”的裸收发逻辑,改成完整的分层状态机。下面几个模块是重点。

3.1 标准帧与扩展帧识别

升级版代码需要同时兼容标准ID和扩展ID。第一版只用了标准帧,升级后因为要对接新的网关模块,必须支持29位扩展帧。

解析扩展ID时有个常见坑:硬件寄存器里IDE位和RTR位的位置容易搞混。我封装了一个低位解析函数,先把原始寄存器值做掩码处理,再逐位提取。核心逻辑大致是:

#define CAN_ID_STD 0x000007FF #define CAN_ID_EXT 0x1FFFFFFF #define CAN_FLAG_IDE (1 << 0) #define CAN_FLAG_RTR (1 << 1) void can_parse_id(uint32_t raw_id, uint8_t ide, uint8_t rtr, uint16_t *std_id, uint32_t *ext_id, uint8_t *is_ext) { if (ide) { *is_ext = 1; *ext_id = raw_id & CAN_ID_EXT; } else { *is_ext = 0; *std_id = (uint16_t)(raw_id & CAN_ID_STD); } // RTR位单独处理,决定是否为远程帧 }

这个函数在中断外调用,避免位运算长时间占用中断上下文。实测多帧混合负载下解析一帧大约耗时几个微秒,完全不影响主循环实时性。

3.2 错误帧处理与总线恢复

升级版对错误处理的改动最大。我把CAN控制器的错误状态做成一个状态机,状态迁移依据控制器自带的错误计数器数值。

状态定义很简单:

  • ERR_ACTIVE:错误计数小于127,正常通信;
  • ERR_PASSIVE:错误计数在128到255之间,控制器进入被动错误状态,不再主动影响总线,但还能接收;
  • ERR_BUSOFF:错误计数超过255,控制器自动断开与总线的连接,这个时候必须等待协议要求的128个总线空闲位才能重新恢复。

第一版遇到被动错误就直接查表重启,升级版的做法是:在状态迁移到被动错误时先不做强硬复位,而是尝试通过发送错误帧来同步总线;只有确认进入总线关闭态之后,才执行恢复流程。恢复流程也不是立即重启外设,而是先关闭发送使能、等待一个退避时间(通常是几百毫秒,具体数值按波特率试验得出),再重新初始化,同时通过事件回调告诉应用层发生了总线关闭。

这里有一小段输出错误状态的代码供参考:

void can_err_state_handler(can_err_state_t new_state) { switch (new_state) { case CAN_ERR_ACTIVE: can_send_event(CAN_EVENT_ERR_ACTIVE); break; case CAN_ERR_PASSIVE: can_send_event(CAN_EVENT_ERR_PASSIVE); // 尝试发送一次错误计数器宣告帧,等待总线空闲 can_try_sync_frame(); break; case CAN_ERR_BUSOFF: can_send_event(CAN_EVENT_BUSOFF); can_disable_transmit(); can_busoff_recovery(); break; } }

这段逻辑看起来简单,但第一版就是因为它不在,面对强干扰时只能靠外部看门狗复位才能恢复通信。

3.3 时间戳与多帧重组逻辑

时间戳是升级版新加的。我在CAN接收中断里读取一个16位自由运行定时器,把计数器值存进消息结构体。应用层通过两次时间戳之间差值判断报文是否更新,超过预设阈值就当作通道超时处理。

多帧重组用了一个固定深度的重组表。以ISO-TP类似的机制为例,发送方会先发一个首帧(包含总长度和帧序号),之后发连续帧,每帧带序号,接收方按序号填入缓冲区。升级版代码里,我维护了一个8字节数组,每当收到首帧就分配一个通道,连续帧到来时校验序号是否连续,如果序号跳变则丢弃整个重组包并向应用层上报异常。

需要注意的是,重组缓冲区是有限的。如果总线上同时有多个发送方做多帧传输,缓冲区会爆。所以我顺手加了一个准入策略:只对指定范围的CAN ID开启重组,其余报文走单帧直通模式。这样既保住了业务灵活性,又控制了内存消耗。

4. 升级版实测:总线负载、抖动与兼容性对比

代码写完了,是不是真的比第一版好,得用数据说话。我在实验室里用两个CAN节点做了实测,同时用总线分析仪抓取线频上的报文。

4.1 250kbps与500kbps下的实测数据

先跑250kbps,总线负载大约百分之四十五,这时候两个版本都能正常收发,看不出明显差距。再把波特率提高到500kbps,同时人为注入强干扰信号(每隔100毫秒叠加一段脉冲噪声),持续跑一个小时。

结果如下:

指标第一版升级版
总线上发送报文数216,000216,000
应用层实际接收数207,360216,000
丢帧率4.0%0.0%
最大报文延迟12.3 ms3.1 ms
总线关闭触发次数8次1次
从总线关闭到恢复时间无法自动恢复约180 ms

这个结果让我挺意外的。第一版在干扰下频繁触发总线关闭,而且每次都需要重启才回来,升级版只触发了一次,并且自动恢复。主要原因就是错误状态机里加的退避等待和同步帧。

4.2 与第一版代码的同场景对比

从代码结构的维度看,两个版本的差异也很大,我整理了一张表:

对比项第一版升级版
接收中断工作量解析+回调+校验仅拷贝原始字节
发送策略无调度,来一帧发一帧令牌桶分时调度
错误处理无状态检测,遇错重启主动/被动/关闭三态管理
多帧重组不支持带序号检错的重组表
时间戳无16位定时器打点
代码耦合度业务代码与寄存器操作混在一起分层接口,各模块独立

升级版的代码量大概是第一版的两倍,但每一层都可以单独调试和复用,换来的是稳定性翻倍和排查问题的时间大大缩短。

4.3 兼容第三方设备的注意事项

升级过程中还碰过一些兼容性问题,这里专门提一下。有次对接某第三方控制器,对方发送的远程帧要求我们回复数据帧,而我们的升级版默认只处理数据帧,远程帧直接丢弃。后来在帧解析模块里增加了远程帧类型的分发逻辑,允许业务层注册远程帧处理回调,才正常联调通过。

另外,扩展ID的位序在不同控制器上可能不一样,有的把IDE位放在寄存器最高位,有的放在最低位。如果你的代码只在自己的硬件上跑,可能永远遇不到这个坑;但只要想换芯片或者做不同品牌外设的移植,这个细节就特别容易踩。我建议在初始化时打印一次实际寄存器值,验证ID解析结果,避免“本地跑得好好的,一上整车就乱套”的窘境。

5. 踩过的坑与后续计划

升级版也不是一蹴而就的,调试过程中踩了不少坑,有些甚至是低级错误。记录下来算给自己提个醒。

5.1 中断上下文中的危险操作

第一次升级时,我在发送中断里调了一个软件延时函数,打算等几个毫秒再继续发送。结果一旦总线上有突发负载,发送中断被延时卡住,接收中断也被同一优先级阻塞,整个CAN外设几乎瘫痪。后来把延时挪到主循环状态机里,用定时器计时来替代阻塞延时,问题才消失。

教训很直白:中断服务函数里绝对不要放延时、打印、或者任何可能阻塞的调用。如果确实需要延迟,只记时间戳,由主循环判断是否超时。

5.2 升级版仍然存在的限制

这版代码虽然稳定了很多,但还是有最后几块短板。一个是环形缓冲区大小固定,如果做大数据量的诊断下载,缓冲区还是可能被超大重组包占满。另一种方案是用动态链表申请内存,但在MCU上我不太敢用,宁可牺牲一点灵活性换风险可控。

另一个是时间戳溢出处理。16位定时器在500kbps下大约130毫秒翻转一次,如果两个报文时间差超过这个值,时间戳差值就会出错。目前我在应用层加了周期同步逻辑规避,但根源上需要用32位时间戳或者软件扩展计数,这也是后续计划之一。

此外,目前只支持经典CAN,CAN FD的新特性还没启用。如果明年有新产品要求更高带宽,我可能会在升级版的框架上再出一个支持可变波特率的版本,保留帧解析状态机和缓冲区的核心结构,只是把数据段从8字节扩展到64字节。工作量和这次重写相比会小很多,因为分层的好处已经体现出来了。

最后再分享一个小技巧:在调试CAN协议栈时,强烈建议把错误事件统一走一个日志队列,不要直接打印。否则错误状态一多,打印本身就会抢占中断,反而把问题搞得更复杂。我用这个方法排查总线关闭问题时,效率提升很明显。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询