ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CAN总线自定义协议设计实战:帧格式、ID分配与采样点调优指南

CAN总线自定义协议设计实战:帧格式、ID分配与采样点调优指南 1. 协议设计的第一道坎想清楚帧格式与ID分配再动笔写代码做CAN通信开发的人十有八九都经历过这种场景硬件画好了收发器焊上去了MCU的CAN外设也初始化了自测回环一切正常结果两台设备一对接要么收不到要么收到一堆乱码要么总线上两台设备互相打架抢总线。问题往往不是出在CAN控制器本身而是出在你压根没设计好一套能落地的自定义协议。很多人以为CAN自定义协议就是我定个ID再把数据塞进8个字节里发出去然后就开始写代码。这种做法不能说错但做的项目越多越会发现——协议的坑十有八九是在帧格式和ID分配阶段埋下的。CAN和UART、SPI这类点对点通信最大的区别是CAN是一个多主、广播式的总线网络总线上的每个节点都能发送也都能收到所有报文。这意味着你不仅要定义数据怎么放还要回答谁在什么时候能说话多个节点同时说话时听谁的这个报文是给谁的这三大问题。自定义协议的第一步就是把这三个问题的答案写明白。先说ID分配。CAN的帧ID不只是报文的名字它同时承担着仲裁优先级的作用——ID数值越小优先级越高。在经典CANCAN 2.0A/B中标准帧ID是11位扩展帧ID是29位。我的习惯是设计协议之前先把整个系统的报文清单拉出来按功能安全等级和实时性要求给每个报文排一个优先级顺序然后从低ID往高ID分配。举个实际项目里的例子一套电机控制传感器采集上位机监控的系统我通常这样分配报文功能CAN ID标准帧发送周期/触发方式优先级理由电机急停/使能控制0x001~0x003事件触发立即发送安全相关必须最短仲裁时间电机转速/扭矩闭环指令0x010~0x01F周期1ms~10ms实时控制环等不得状态反馈转速、电流、温度0x100~0x1FF周期10ms~100ms周期性上报允许少量延迟配置参数读写0x200~0x2FF事件触发非实时优先级最低诊断/故障码0x300~0x3FF事件触发人机交互频率低这个表看起来简单但它解决了仲裁冲突、实时性分层、后期扩展三个问题。只要ID范围分段了后续加新报文就不会打乱已有优先级。我最开始做CAN协议时没分段全部ID随手定义结果项目到中后期要加一个紧急停机报文发现找不出一个合适的ID段最后只能整体改ID所有节点的配置全部重刷那个教训记忆犹新。再说帧格式里的DLC。经典CAN一帧最多8字节数据看起来怎么放都行但在设计协议时必须把数据字节数放进协议规范里写死。为什么因为接收方判断这帧报文数据是否完整依据的不是ID而是DLC数据长度码。如果你的协议里同一帧报文有时候发5字节有时候发8字节接收方就不得不用超时判断、DLC判断甚至内容判断来确认数据有效性这在实时性要求高的场景里就是灾难。我的做法是每帧报文的数据字节数固定不足部分补零。比如转速反馈需要4字节两个16位数据那我就固定发8字节后4个字节清零备扩展用。这样接收方的校验逻辑可以极其简单先查ID再查DLC不匹配直接丢。还有一个容易忽视的细节——帧类型。是只用数据帧还是可能用到远程帧RTR我的经验是尽量避免使用远程帧。远程帧在总线上看起来是请求数据但它有一个非常坑的特性——仲裁时RTR位为1的数据帧优先级高于RTR位为0的远程帧。如果总线上有节点用远程帧请求数据同时又有节点正在发这条数据就会产生优先级反转触发意外仲裁。而且远程帧不带数据出错排查时总线上看波形少了一大块数据内容非常难调试。真需要请求上报的场景用一条带DLC0或带请求码的数据帧来实现比远程帧稳妥得多。协议格式的另一个重点是要不要做多帧拆包。经典CAN一帧只有8字节如果你的应用数据超过8字节比如一次要下发一个32字节的参数表就得拆成多帧发送。自定义协议里做拆包我推荐参考ISO-TP的思路但不一定要完全照搬拆包帧用首帧连续帧的方式首帧里带上总数据长度和总帧数连续帧里带帧序号。这个设计要说清楚随便拿个自定义的拆包格式接收方很容易在丢了中间某帧时卡死。所以多帧协议里一定要加超时重传和接收超时退出机制否则总线上垃圾帧一多接收方缓冲区就填满了。2. 定时参数不是抄例程就完事位时序、采样点与时钟误差的账要自己算很多嵌入式工程师配置CAN外设时用的都是厂商例程里的参数改改波特率就收工了。但CAN的位时序和UART还不一样UART只要收发双方波特率误差在一定范围内就能通CAN由于有位仲裁和重同步机制对位时序的要求更细采样点的位置直接影响错误率。我见过不止一个项目波特率设对了、终端电阻也加了但总线在高负载率下就偶尔冒错误帧查了半天最后发现是BS1/BS2配得不合理采样点偏了。先解释一下CAN的一个位时间是怎么构成的一个位时间 同步段SYNC_SEG 传播段PROP_SEG 相位缓冲段1PS1 相位缓冲段2PS2。同步段固定1个时间量子Tq传播段和相位缓冲段可配采样点就落在PS1和PS2交界处。以STM32的bxCAN为例BS1对应PS1PROP_SEGBS2对应PS2两者都要配置成整数个Tq最终波特率 外设时钟 / 1 BS1 BS2 / 分频系数。那采样点放在哪里最合适行业惯例是采样点位于位时间的75%~85%之间推荐值常见的取80%或者87.5%。原理是CAN的位仲裁和显性/隐性电平翻转决定了采样点必须在位的后段才能可靠地采样到真实的电平状态太靠前容易采到跳变沿附近的不稳定电平太靠后留给重同步的缓冲空间就不够。我实测过同一个500kbps的配置采样点从80%改到68%之后总线长度从20米增加到50米时错误帧立刻变多改回80%后恢复正常。所以一旦总线拓扑变长、节点数变多采样点才是最先要复核的参数。再说时钟误差。CAN总线上每个节点都有自己的晶振晶振精度决定了位时间的累计误差。CAN控制器是通过硬同步和重同步来容忍节点间时钟偏差的。重同步的核心机制是每个位时间有一个同步跳转宽度SJW当控制器检测到总线电平跳变沿和本地位时间有偏移时会通过缩短或延长相位缓冲段来追上发送节点。SJW配置的上限是PS1和PS2中的较小值实际设计中我会取1~2个TqSJW取大了抗干扰能力会下降取小了容错能力不够。这里有一个必须算的账——波特率误差容忍公式。对于采样点位于位的50%之后的情况两个节点间的最大时钟偏差容限约为容差上限 段缓冲时间 / 2 × 10 × 位时间 × 100%简化理解就是位时间越长波特率越低容差能力越弱同步段缓冲越长容差能力越强。所以同样是20ppm的晶振在1Mbps下可能勉强能用在125kbps长距离总线下就不一定稳。工业级项目我建议用精度在±20ppm以内的晶振并且在全温范围内验证不能用那种便宜的陶瓷谐振器——在CAN上吃过亏的人都知道温漂导致的总线错误是最难查的故障之一。配位时序还有一种实用技巧用总线分析仪或者示波器抓实际波形来验证采样点。不要只信任理论计算。我自己调CAN时有个习惯——在总线上挂一个CAN分析仪开启位采样点回放或者用示波器抓显性到隐性的跳变沿观察采到的电平和实际电平是否一致。如果波形边沿和采样点之间距离太近就得微调BS1/BS2。另外终端电阻的匹配也会影响位时间。按照ISO 11898CAN总线两端各需要一个120Ω终端电阻但实际节点数量多、总线分支多时等效阻抗会偏离60Ω导致信号反射加剧位时间边沿出现振铃这也会直接影响采样稳定性。3. 应用层设计信号打包、字节序、周期抖动与DBC一个都不能省帧格式和定时参数搞定之后真正让协议好用的是应用层的数据组织方式。很多人在这里翻车不是因为不会而是因为没想过还能这么设计。应用层设计的核心是数据字典——把每个信号从物理值映射到总线上的原始值这一整套规则。先说信号打包。一个16位的电机转速物理范围是0~3000rpm那在总线上怎么表示直接发3000这个数不合适因为CAN一个字节只有0~255两个字节能表示0~65535直接用原值的话15位就够用了但规范化做法是定义分辨率scale和偏移量offset。比如转速分辨率设为0.125rpm/LSB偏移量为0那3000rpm对应的总线原始值就是3000 / 0.125 24000十六进制是0x5DC0占16位。接收方拿到0x5DC0后反算0x5DC0 × 0.125 3000。配套的数据类型里负温度怎么表示比如温度范围-40°C~125°C分辨率0.1°C那原始值0表示-40°C原始值1650表示125°C。偏移量必须写进协议文档里否则换个工程师接手根本对不上。字节序也是一个必须统一的地方。CAN的8个字节在总线上是从Byte0发到Byte7但一个16位或者32位的数据低字节在前小端还是高字节在前大端得在全项目里统一。Motorola格式大端和Intel格式小端各有拥趸但在汽车电子里整车厂通常用大端因为诊断协议UDS默认大端。我的建议是如果没有强制要求选Intel格式小端因为在8位MCU上取数据时小端可以直接把低字节和地址对应起来内存映射更好处理。如果团队里有人习惯大端那也没问题但DBC文件里必须标注清楚。说到DBCCAN Database文件这是自定义协议里上了规模之后就绕不开的工具。DBC是一个文本格式的文件用什么工具都能打开编辑它定义了每条报文的消息ID、发送周期、包含哪些信号、信号在哪些字节、分辨率偏移量、取值范围、单位等全部信息。有了DBC你可以在CANalyzer、PCAN-View、周立功CANPro、甚至开源的cantools库里直接解析总线上的原始数据并显示物理值Debug效率提升不是一星半点。我用过的几个DBC编辑工具工具平台特点Vector CANdbWindows工业标准功能全但上手成本高周立功CANProWindows中文界面配套国产分析仪好用cantoolsPython库跨平台开源免费能在脚本里直接解析DBC适合做自动化测试SavvyCAN跨平台开源集成了DBC编辑和总线分析如果你用的是开源的cantools把DBC解析逻辑直接写进自己的上位机或者测试脚本里效果比手写解析函数强太多。我之前在用Python做CAN自动化测试时就是DBC cantools python-can三个库配合配置变化时只改DBC不改脚本省了无数改代码的功夫。再来说周期抖动。协议里定义了发送周期比如状态反馈10ms一帧但MCU的定时器唤醒时间、CAN控制器发送缓冲区的排队、总线仲裁竞争都会导致实际的报文周期不是严格的10ms。如果接收方以10ms没收到就判定通信故障在总线负载高的时候很容易误判。我的做法是接收方的超时判断设置成3~5倍周期。比如状态反馈10ms周期接收方30ms~50ms内没收到才报超时控制指令1ms~5ms周期超时设10ms~20ms。这个倍率不绝对要看应用场景——安全相关的报文超时判据要严普通状态上报判据要松。信号打包里还有一个常被忽略的细节——信号跨字节的位序。一个8字节报文里塞多个信号时比如转速占位0~15电流占16~31温度占32~39信号的起始位、长度这些信息在DBC里有明确定义但你在底层代码里自己解析时如果没按位处理很容易错位。我强烈建议避免信号跨字节即一个信号的位范围不跨越字节边界除非信号本身就是16位/32位的整数。比如一个12位的ADC采样值如果是小端模式可以放在一个字节的低8位下一个字节的低4位但这种跨字节信号在单片机上的位操作比较绕解析代码可读性很差。能用一个字节塞进一个字节能用两个字节对齐两个字节能放整就放整。4. 错误处理与异常调试错误帧、总线关闭和无声故障的排查链路协议设计得再完善总线上也难免出状况。CAN总线不同于其他总线的一个重要特性是自带错误检测和错误处理机制比如位错误、填充错误、CRC错误、格式错误、ACK错误。这些机制能让总线在干扰下自动重发错误帧但如果错误累积到一定程度节点会进入**Bus-Off总线关闭**状态彻底退出总线通信。所以自定义协议里必须定义错误之后怎么办。先说错误帧怎么排查。CAN分析仪录到的错误帧是长成6个显性位的特殊波形——正常数据帧或远程帧之间不会有6个连续的显性位AN控制器检测到这个特征就知道是错误帧被发出来了。排查错误帧的基本思路是先确认物理层。用示波器看总线两端的CAN_H和CAN_L之间的电压差显性位应该是2V左右CAN_H约3.5VCAN_L约1.5V隐性位应该是0V差分电压为0。如果共模电压不对、上升沿太平滑或者波形上有振荡回勾优先检查终端电阻、总线线缆、接地、分支长度。如果物理层正常再检查位时序参数——采样点和SJW对不对。物理层和时序都没问题才轮到排查上层协议——是不是有节点以错误的波特率在发数据或者两个节点发了相同的ID但内容对不上造成连续位错误。再说Bus-Off。一个节点错误计数达到256发送错误计数或接收错误计数超过255就会进入Bus-Off这时候该节点会主动断开和总线的联系不再发送任何报文。问题在于如果不做恢复处理这个节点就永远哑巴了。CAN控制器提供了两种恢复方式一种是等待128次总线空闲后自动恢复一种是由软件主动清零恢复。实际项目中我通常选择软件主动恢复MCU检测到CAN控制器进入了Bus-Off状态状态寄存器置位先延时一小段比如10ms然后把CAN外设重新初始化再恢复正常通信。为什么不等自动恢复因为有些控制器的自动恢复逻辑实现不完全或者应用层需要知道我断过线这个事件来做故障记录。还有一种最诡异的情况——节点发不出去也不报错总线上看起来一切正常但就是收不到某个特定节点的心跳报文。这种情况十有八九是ACCCode和ACCMask验收码和验收掩码配置问题。很多CAN控制器特别是STM32的bxCAN有个滤波功能接收报文时先用验收码和掩码过滤一遍不匹配的直接丢弃不进接收FIFO。初学者最容易踩的坑是掩码设置得太宽或者太窄导致想要的报文被过滤掉了。排查方法很简单——把验收掩码设成全0即不过滤任何报文看能不能收到。能收到说明是滤波配置问题收不到再从物理层开始排查。我自己的习惯是调试阶段先不过滤跑通了再开启滤波并把掩码规则写进代码注释里方便后人修改。错误的另一个容易被忽略的源头是波特率实际值和标称值不一致。比如标称500kbps但MCU的时钟树配置改动后CAN外设的实际输入时钟变了波特率实际变成480kbps两个节点一个按500k发一个按500k收仲裁字段还能对得上数据字段就可能因为位时间错位产生填充错误。这就是为什么我上面强调位时序的账要自己算而且要在项目里留一个波特率自动检测/自动配置的机制——很多CAN分析工具支持主动扫描总线上的波特率你可以用它验证实际波特率。等到你能准确地解释为什么这帧报文的CRC和接收方算出来的不一致的时候CAN协议设计的基本功才算过关。5. 从经典CAN到CAN FD自定义协议如果要升级这些差异必须提前知道如果你现在做的项目还有两三年生命周期建议直接考虑用CAN FD而不是经典CAN。CAN FDCAN with Flexible Data-rate在原有CAN基础上做了几个关键扩展数据段最高支持64字节数据段波特率可以从仲裁段的波特率提升到2Mbps甚至5Mbps还增加了新的CRC算法。但CAN FD并不完全向下兼容经典CAN——两者可以共存在同一条总线上但只有支持CAN FD的节点才能完整解析CAN FD报文老节点收到CAN FD帧会直接报错。所以协议设计时如果你的系统里混有旧节点要么全部换新要么做好经典CAN模式和CAN FD模式的切换开关。CAN FD的自定义协议和经典CAN最大的区别除了载荷变长还有两个不能忽视的变化。第一个是DLC编码——CAN FD的DLC不是简单的0~8而是0~15的映射表0~8对应0~8字节9对应12字节10对应16字节11对应20字节12对应24字节13对应32字节14对应48字节15对应64字节。如果你沿用经典CAN的思维写死DLC8就是8字节那在CAN FD下已经过时了解析时必须用映射表。第二个是波特率切换位BRS——CAN FD报文里有一个BRS位置1表示数据段切换到更高的波特率。如果总线上某个节点的收发器不支持CAN FD模式的数据段高速切换它解析带BRS的帧时就会出错。这一点在选型收发器时要特别注意很多新出的控制器和收发器是支持CAN FD的但老片子不一定。从自定义协议设计的角度CAN FD带来的最大利好是可以把多个经典CAN帧合并成一帧CAN FD帧减少总线仲裁次数和帧间隔开销。比如一套电池管理系统原来每100ms要上报电压、电流、温度、SOC四帧经典CAN报文用CAN FD后可以合成一帧64字节的报文占用的总线时间大幅缩短总线负载率明显下降。我用CAN FD替换过一套经典CAN的BMS通信同样的数据内容总线负载率从42%降到了12%左右相当可观。但代价是——报文合并后关键数据的实时性颗粒度变粗了。比如原来电流是独立一帧10ms周期发送合并后可能就得和电压、温度一起100ms发送控制响应速度反而变慢。所以报文合并是有取舍的我的原则是实时性要求高的信号仍然独立成帧实时性要求低、数据量大的信号合并成大帧。CAN FD还有一个新特性是发送延迟补偿TDC。在高速数据段比如5Mbps下收发器的发送延迟从控制器发出到总线上的传播延迟会导致采样点偏移所以CAN FD控制器需要配置TDC来补偿这个延迟。做CAN FD自定义协议时配置TDC需要知道收发器的实际环路延迟参数这个值通常在收发器数据手册里有——但不同厂家的收发器延迟参数差异不小设计时一定要按实际的收发器型号去查。最后提一个应对总线负载率上升的方案——不要盲目降低波特率优先优化报文内容和发送策略。我发现很多项目遇到总线负载高第一反应是把波特率提上去但在硬件不变的情况下提波特率会牺牲传输距离容易引入新的稳定性问题。更稳妥的做法是先审查每条报文是否都需要那么高的周期能不能事件触发代替周期发送能不能把多个信号打包到一帧里。如果能从协议设计层面降低总线负载率比改波特率可靠得多。回到开头那句话CAN自定义协议看着是改改寄存器、发发数据的活但真正能扛住现场恶劣环境和长期运行考验的协议都是在帧格式、ID分配、位时序、应用层DBC、错误处理这些环节一步一个坑踩过来才打磨出来的。这篇分享里写到的每一个细节我都曾经在实际项目中付出过代价希望能帮你少走几步弯路。如果你手上的项目正卡在两台设备怎么都通不上报文偶尔丢一帧错误帧不定时出现这类问题上按着上面提到的链路从头到尾捋一遍大概率能定位到根因。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进