YAOTU INSIGHTS

CAN通信协议重写实战:从丢帧到零丢帧的嵌入式升级方案

CAN通信协议重写实战:从丢帧到零丢帧的嵌入式升级方案
原以为第一版CAN通信代码已经足够应付日常业务直到有一次联调时对方工程师问了一句“如果总线上突然出现错误帧代码会不会自己恢复”我当场没有正面回答。后来那个项目果然在电磁干扰比较强的环境下连续丢了三帧转速报文上位机直接报了故障。所以第二版重写时我给自己定了个目标不能再有“侥幸能用”的代码结构。这篇文章我会把升级版CAN通信协议代码的改动逻辑、核心模块设计、实测结果和踩坑过程都摊开讲适合正在做嵌入式通信、被丢帧和总线锁死折磨过的开发者参考。1. 第一版代码的瓶颈为什么非升不可重写这事不是拍脑袋决定的。第一版代码在正常工况下表现还可以但一旦总线负载提高、干扰加剧问题就一串一串冒出来。1.1 丢帧现象与排查过程最开始发现丢帧是在500kbps波特率下做压力测试。总线上每秒有上千帧报文其中一类周期性速度报文和另一类诊断请求报文同时到达接收邮箱时接收队列经常溢出。我当时先怀疑CAN控制器中断优先级配低了把接收中断提到最高结果没有改善。后来用总线分析仪抓报文发现丢失的报文在应用层日志里完全没有出现过但总线上确实有。说明不是CAN控制器没收发而是中断服务函数处理不过来。排查到最后问题出在中断里直接解析报文并调用了应用层回调。接收中断触发时我做了ID匹配、字节序转换、循环冗余校验、还要往应用层环形队列里写整套流程跑完要几百微秒。这期间后续报文不断进来硬件接收邮箱很快被填满新报文只能丢弃。升级版的第一步就是砍掉中断里的多余工作只保留最必要的拷贝动作。1.2 协议设计上的先天不足除了丢帧第一版协议本身也有几个让我头疼的地方。第一没有多帧重组机制。当时有个固件升级功能升级包最大要两兆但每帧最多承载8字节数据只能拆成几百帧连续发送。接收端完全没有排序和重组只能靠上层按顺序硬接一旦丢一帧整个升级包就废掉。第二没有时间戳。应用层收到报文后无法判断这个数据是什么时候采集的。比如某个状态量本来已经过期了但应用层不知道照样拿去做控制逻辑这是很危险的。第三错误帧处理过于简单。第一版代码在CAN控制器进入总线关闭态之后只是重新初始化外设既不通知应用层也不做退避和恢复等待。在强干扰环境下这会导致总线反复关闭又马上开通反而更容易造成通信瘫痪。2. 升级版总体架构双向缓冲与分时调度第二版的设计思路很明确把“协议栈”和“业务逻辑”彻底剥离开。硬件层只负责收发原始帧协议栈负责解析和组装业务层只管消费和发送。剥离开的关键就是缓冲区和调度机制。2.1 环形缓冲区与消息队列的选择在MCU上我选了无锁环形缓冲区。原因是嵌入式环境不适合动态分配内存环形缓冲区的内存占用固定、入队出队都是常数时间非常适合中断上下文。接收方向我在RAM里划了一块uint8_t数组按帧存入原始数据。中断处理函数只做一件事把硬件收到的每个字节以及元信息写入环形缓冲区然后立刻退出中断。真正干活的是主循环里的接收处理任务它定期从缓冲区取出一帧、交给解析器、再分发到业务层。这个改动直接把接收中断的耗时缩短到原来的三分之一。发送方向也是类似思路。应用层需要发报文时不是直接调CAN发送寄存器而是先把报文放进发送环形队列。CAN发送中断被触发后从队列中取出下一帧写入硬件发送邮箱。这样即使多个业务模块同时抢着发报文也不会出现寄存器被覆盖的问题。2.2 帧解析器的输入输出模型升级版的帧解析器是一个独立模块输入是“原始帧结构体”输出是“标准化消息结构体”。这个结构体包含几个关键字段报文ID29位扩展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位时间戳或者软件扩展计数这也是后续计划之一。此外目前只支持经典CANCAN FD的新特性还没启用。如果明年有新产品要求更高带宽我可能会在升级版的框架上再出一个支持可变波特率的版本保留帧解析状态机和缓冲区的核心结构只是把数据段从8字节扩展到64字节。工作量和这次重写相比会小很多因为分层的好处已经体现出来了。最后再分享一个小技巧在调试CAN协议栈时强烈建议把错误事件统一走一个日志队列不要直接打印。否则错误状态一多打印本身就会抢占中断反而把问题搞得更复杂。我用这个方法排查总线关闭问题时效率提升很明显。