做嵌入式这么多年,CAN总线项目接触了不少,从车载BMS到工业设备互联,最让我头疼的不是硬件电路,反而是协议怎么定义。早期接过一个项目,两块单板都能正常收发,示波器上波形也漂亮,可设备一上电就是疯狂报错帧,排查了大半天,最后发现是报文ID和字节序两边各定各的,根本没有形成一份可以共同遵守的协议。这种问题,其实在协议设计阶段花十分钟就能避免。
这篇文章把我这些年做CAN自定义协议的设计方法完整梳理一遍,覆盖物理层基础、帧结构、ID规划、数据编码、波特率采样点计算、CAN FD对协议的影响、调试手段和常见坑。无论你是刚接触CAN的新手,还是被通信问题折磨过的老开发,都能从这里找到可以直接照搬的套路。我自己一直遵循一个原则:协议在设计阶段花的时间越多,后面联调和排查省的时间就越多。
1. 协议设计前必须搞清楚的基础:从波形到帧格式
1.1 物理层与电平变化:CAN总线电压差是怎么来的
很多人设计CAN协议时容易犯一个错误,上来就直接画报文帧结构,完全忽略物理层。实际上,物理层决定了总线能不能稳定通信,连带着也会影响协议里的重传策略和超时判断。
CAN总线物理上只有两根线,CANH和CANL,靠差分电压传输信号。总线空闲时,两条线都在2.5V左右,差分电压接近0V,这个状态叫隐性位;发送显性位时,收发器会把CANH拉高到3.5V左右,CANL拉低到1.5V左右,差分电压约为2V。接收端不看单根线的绝对电压,只看CANH减CANL的差值,所以抗共模干扰能力很强,地电位漂移影响也不大。
我在实测中经常用示波器直接夹在CANH和CANL之间看差分波形,这是最直观的排查手段。如果隐性电平不在2.5V附近,或者显性压差不到1.5V,基本可以判定收发器或者供电有问题。总线两端各接一个120欧姆终端电阻,目的是匹配传输线阻抗、减少信号反射。如果终端电阻缺失,高速率下波形会产生振铃,错误帧率会明显上升。
1.2 帧格式:标准帧、扩展帧、远程帧与DLC
CAN协议栈的帧格式是标准化的,但很多人对其中的细节理解模糊。自定义协议用到最多的是数据帧,标准帧有11位ID,扩展帧有29位ID,数据场最多8字节。DLC字段表示数据长度,取值范围0到8,接收端会严格按DLC裁剪数据。
远程帧在实际项目中我很少用,它本身不带数据段,靠ID请求对方发送数据。听起来很方便,但远程帧在实际总线上容易引发优先级反转和时序不确定的问题,很多上层协议栈甚至不推荐使用。自定义协议设计时尽量绕开远程帧,统一用周期上报和事件上报来替代。
扩展帧和标准帧不能混用在一个报文里,ID虽然可以相同,但仲裁时候的位序列不同,会被当成两个不同报文。协议设计时最好全项目统一标准帧,因为29位ID对大多数应用来说都用不满,反而增加解析复杂度。
1.3 仲裁机制:为什么ID越小优先级越高
CAN的仲裁机制是它区别于RS485、以太网的重要特性。多个节点同时发送时,总线会逐位比较ID,显性位会覆盖隐性位,发送隐性位但读到显性位的节点会立即退出仲裁,转为接收状态。因为ID前面越多的显性位就越晚退出,所以ID数值越小,优先级越高。
实际设计协议时,我会把实时性要求最高的报文放在最低的ID段。比如电机控制器的使能报文ID设为0x001,电池管理系统的电压报文设为0x0A1,车身状态报文放到0x300以后。这样即使总线上突发大量报文,关键控制信号也不会被阻塞。
1.4 位时序与SJW:采样点为什么如此关键
CAN总线上没有独立的时钟线,所有节点靠同步段和重同步来对齐位时间。一个位时间在STM32里被划分为SYNC_SEG、BS1、BS2三段,采样点位于BS1和BS2交界处。BS1可以理解为一个位时间的“前半场”,BS2是“后半场”,接收节点在交界处采样总线电平。
SJW(同步跳跃宽度)的作用是当节点检测到总线边沿与本地时序有偏差时,允许调整的最大时间量子数。SJW太小,长距离传输或温漂大的场景下容易失步;SJW太大,抗干扰能力会下降。常规配置SJW=1,如果总线拓扑比较复杂、最远节点距离超过几十米,可以适当增加到2。
采样点的选择对通信稳定性影响极大。推荐采样点落在75%到90%之间,常见标准是87.5%。采样点太后,容易采到下一个位的边沿;采样点太前,抗干扰能力不足,采样到信号毛刺的概率增加。后面第三章我会详细介绍具体怎么计算。
2. 自定义协议的设计步骤:从信号清单到完整报文
2.1 第一步:把信号清单列成表格
协议不是拍脑袋画出来的,而是先从需求里把要传的信号全部挖出来。做过三五个CAN项目后你会发现,信号清单这一步几乎决定了后面所有设计工作的质量。
我做协议前一定会先整理一张表格,明确每个信号的名称、物理单位、范围、精度、发送周期和实时性要求。电压可能范围0到1000V,精度0.1V;温度范围-40到125摄氏度,精度1摄氏度;SOC范围0到100%,精度1%。把这些梳理清楚后,才能决定每个信号占用几个字节、有没有必要用偏移量。
这类表格看似繁琐,但它的价值在于让大家在动手写代码前就把需求对齐。项目里不同工程师对同一个信号的理解经常有偏差,比如“电流”到底是有符号还是无符号、单位是安培还是毫安,如果不提前定义,后面联调就是灾难。
2.2 第二步:报文ID规划与优先级分配
ID规划是协议设计里最能体现水平的环节,也是最容易后期返工的地方。我常用的做法是把ID空间按功能分区,高字节表示消息类别,低字节表示具体报文。比如0x00到0x0F留给系统管理和网络管理,0x10到0x2F给动力系统,0x30到0x4F给车身系统,0x50到0x7F给诊断。
分区后还要在分区内部按周期和实时性排列。周期越短、实时性越高的报文ID越小。比如电机使能是10ms周期,ID用0x11;整车状态100ms周期,ID用0x12。这样即使报文重叠,高优先级报文也能先发出去。
ID一旦在量产项目里定了,基本不能改,因为所有节点和诊断工具都要跟着变。所以设计ID时最好预留一段空间,比如0x500到0x7FF留作未来扩展,不要一上来就把空间占满。
2.3 第三步:数据段编码与字节序
CAN协议标准里没有规定数据场里的字节序,这一块完全由应用层自己定义。常用的有小端模式和大端模式,小端的意思是低字节在前,大端是高字节在前。绝大多数ARM单片机的内部数据就是小端,协议如果也定义成小端,代码里直接取地址拷贝就行,省去字节序转换的麻烦。
多字节信号还需要处理符号和偏移。以总电流为例,范围-1000A到+1000A,精度0.1A,原始数据范围是-10000到+10000,超出16位无符号范围,可以把电流本身转成有符号16位,或者加一个偏移量做无符号存储。我习惯用无符号加偏移,比如电流偏移32768,0表示-3276.8A,65535表示+3276.7A,这样解析代码简单,还顺便兼容了“0xFFFF表示传感器故障”这类异常标志。
数据场里每个bit最好都有明确含义,不要留空洞。能按字节对齐就按字节对齐,虽然浪费一点空间,但解析效率和可读性会好很多。位域packed的方式能省则省,除非总线负载特别紧张,否则别为了省两个字节把一个信号拆得七零八落。
2.4 第四步:报文类型与发送周期设计
报文按发送方式可以分为周期报文、事件报文和应答报文。周期报文按照固定时间间隔发送,用于连续状态量,比如电压、电流、温度;事件报文只在信号变化或故障发生时发送,用于开关量、报警;应答报文则是对诊断命令或配置命令的回复。
周期设计有一个关键点:事件报文必须配套接收超时检查。如果接收端超过比如500ms没有收到事件报文,应该认为链路异常或对端失联,进入安全状态。不要因为事件报文平时不发,就省略超时判断,否则节点出现硬故障时,接收端完全不知道。
周期报文的周期也要考虑总线负载率。500kbps的CAN总线理论负载能力是每秒50000位左右,实际上要留安全余量,控制在50%以下比较稳妥。一个100ms周期的8字节报文,加上帧头帧尾和填充位,大约占用130位左右,算下来单帧负载约0.026%,一条总线上跑几十个报文完全没有问题。
2.5 实例:一个BMS自定义协议报文设计
空讲不好理解,我直接给出一套实际项目中用过的BMS报文设计。电压、温度、SOC和状态标志是动力系统最核心的几组信号。
第一帧0x0A1,100ms周期,8字节。Byte0和Byte1放总电压,小端,单位0.1V,直接用无符号16位整数;Byte2和Byte3放总电流,小端,偏移32768,单位0.1A,0xFFFF表示电流传感器故障;Byte4放SOC,范围0到200,对应0%到100%;Byte5放最高单体温度,Byte6放最低单体温度,都用偏移40存储,0对应-40摄氏度,160对应120摄氏度;Byte7放状态位,bit0是充电状态,bit1是放电状态,bit2是故障标志,bit3是继电器状态。
第二帧0x0A2,500ms周期,8字节,专门放单体电压。Byte0到Byte7依次存放8个单体电压的低字节和高字节,一条总线上如果有多个BMS从板,就通过ID区分从板序号。实际上单体电压数量多的话,一帧放不下,就得拆成多帧,ID继续递增。
这套设计的思路是核心实时信号用短周期、小ID,保障控制链路;大数据量的单体电压信息用长周期通过多帧轮询发送。解析时对照协议文档很直观,调试效率明显高很多。
3. 波特率与采样点计算:用STM32F103算给你看
3.1 波特率公式与参数约束
CAN波特率不是随便填的,它由外设时钟、预分频器和位时间三部分组成。以STM32F103为例,CAN外设挂载的APB1时钟最高36MHz,然后经过预分频得到时间量子时钟,最后每个位时间由1个同步段加BS1加BS2组成。
波特率计算公式是:波特率 = 外设时钟 / (预分频值 * (1 + BS1 + BS2))。采样点 = (1 + BS1) / (1 + BS1 + BS2)。
这里最容易踩的坑是,STM32的BS1和BS2配置范围有限制,BS1是4位寄存器,最大15,BS2是3位寄存器,最大7。所以不是任意组合都能算出目标波特率,很多教程里随手写的参数其实并不合法。
3.2 500kbps采样点最佳配置
拿最常用的500kbps来举例。APB1时钟36MHz,如果预分频设为9,得到4MHz时间量子时钟,每个位时间需要8个时间量子,也就是1 + BS1 + BS2 = 8。设BS1 = 6,BS2 = 1,采样点 = (1 + 6) / 8 = 87.5%,正好落在推荐区间。
对应的代码配置是这样的:
CAN_InitTypeDef CAN_InitStructure; CAN_InitStructure.CAN_Prescaler = 9; // 预分频9 CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; // 同步跳跃宽度1个时间量子 CAN_InitStructure.CAN_BS1 = CAN_BS1_6tq; // BS1 = 6 CAN_InitStructure.CAN_BS2 = CAN_BS2_1tq; // BS2 = 1 CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; CAN_InitStructure.CAN_SJW = CAN_SJW_1tq;这个配置在绝大多数工业场景下都很稳。如果你的总线拓扑特别长或者干扰特别大,可以把采样点往90%附近调,比如BS1 = 7,BS2 = 0不合法,因为BS2最小是1,所以要保持SP = 87.5%以上,只能选BS1 = 6,BS2 = 1这种组合。
3.3 其他常用波特率配置对照表
实际项目里250kbps和1Mbps也常用,我把几组常用配置直接整理出来,方便照抄。还是基于APB1 = 36MHz来计算。
| 波特率 | 预分频 | BS1 | BS2 | 采样点 | 实际波特率 |
|---|---|---|---|---|---|
| 1Mbps | 9 | 3 | 0? 不合法 | ||
| 1Mbps | 4 | 3 | 5? 不对,重新算 |
这里我直接给出几组验证过的值。1Mbps:36M / 4 = 9MHz,位时间9个tq,BS1 = 5,BS2 = 3,1 + 5 + 3 = 9,采样点 = 6/9 = 66.7%,偏低。1Mbps标准87.5%需要1 + BS1 + BS2 = 8,BS1 = 6,BS2 = 1,那么时间量子时钟 = 8MHz,预分频 = 36M / 8M = 4.5,不整除。所以APB1 = 36MHz下,严格的87.5%采样点对1Mbps来说做不到,工业上常用配置是预分频3,时间量子12MHz,位时间12个tq,BS1 = 9,BS2 = 2,采样点 = 10/12 = 83.3%,也算在75%~90%的安全区间内。
250kbps的话,36M / 9 = 4MHz,位时间需要16个tq,BS1 = 13,BS2 = 2,采样点 = 14/16 = 87.5%,很标准。预分频也可以取18,2MHz时钟,8个tq,BS1 = 6,BS2 = 1,采样点同样是87.5%。
我把常用配置列一张表:
| 目标波特率 | 外设时钟 | 预分频 | BS1 | BS2 | 采样点 |
|---|---|---|---|---|---|
| 1Mbps | 36MHz | 3 | 9 | 2 | 83.3% |
| 500kbps | 36MHz | 9 | 6 | 1 | 87.5% |
| 250kbps | 36MHz | 9 | 13 | 2 | 87.5% |
| 125kbps | 36MHz | 16 | 15 | 2 | 88.9% |
每次新建项目,我会先确认外设时钟,再按表验证参数,最后用示波器量一下总线上的实际位宽确认。500kbps下一个位应该是2微秒,量出来如果差很多,说明配置有问题。
4. CAN FD来了,协议设计要跟着变
4.1 CAN FD与CAN 2.0的核心差异
CAN FD和经典CAN最直观的差别是数据场变长了,最多可以到64字节,而经典CAN只有8字节。仲裁部分波特率不变,但从BRS位开始,数据段和CRC段可以切换到更高速率,最高能做到几Mbps甚至更高。
这个可变速率特性很关键。仲裁段低速是为了保证多个节点仲裁的可靠性,数据段高速是为了提高传输效率。一帧CAN FD报文,既能享受经典CAN那种仲裁机制,又能把大量用户数据快速搬到总线上,这是CAN FD对协议设计最大的改变。
另外CAN FD的CRC校验更强,有17位和21位两种,还会对填充位做CRC保护。这意味着CAN FD帧的数据完整性比经典CAN更可靠,在安全相关场景里更有优势。
4.2 对自定义协议设计的影响
CAN FD出现后,以前需要拆成3到4帧才能发完的数据,现在一帧就能搞定。比如固件升级,一个64字节的CAN FD帧能承载大量程序数据,升级效率和成功率都大幅提升。协议设计时信号打包的颗粒度可以更大,不再为8字节限制绞尽脑汁。
数据段波特率提高以后,单个报文在总线上占用的时间变短了,总线空余时间变多,可以容纳更多低优先级报文或者增加诊断报文的发送频率。但同时要注意,如果总线上混跑经典CAN和CAN FD,经典CAN节点收到CAN FD帧会报错,因为它们不识别新帧格式。
4.3 兼容性设计:老节点怎么办
混合网络中如果确实有老节点,一个折中方案是让CAN FD节点工作在“CAN FD tolerant”模式,发送时用经典CAN帧,接收时能收CAN FD帧。更稳妥的做法是按功能分区,支持CAN FD的子系统内部用FD通信,跨子系统的关键信号仍然用经典CAN兼容帧。
我在协议文档里会明确标注每个报文是经典CAN帧还是CAN FD帧。如果一帧里既有必须兼容老节点的关键信号,又有大量扩展数据,就把关键信号放在前8字节,后面的扩展数据用另一条CAN FD报文发送。这样老节点读不到扩展数据也不会影响它的核心控制逻辑。
5. 协议调试与验证:用工具把帧一层层剥开
5.1 调试工具怎么选
CAN调试工具我用过不少,简单好用的是PCAN-View,界面简洁,抓帧速度快,适合现场快速验证。ZLG的CANTest和CANPro功能更全,支持报文回放、脚本触发、错误帧统计。CANalyzer功能非常强大但价格也高,大部分项目用不到那个层级。
开源方案里,USB-CAN适配器加Python-can库也是很好的组合。特别是做协议自动化测试的时候,写一个小脚本循环发帧、校验接收帧,比手动点上位机高效太多。我做回归测试时经常用这个方法。
5.2 实测步骤:从回环模式到真实总线
新板子第一次调CAN,我不会直接接到总线上,而是先让CAN外设工作在回环模式。回环模式下数据只在本机内部兜一圈,不需要收发器也能验证MCU的CAN控制器配置是否正确。回环通了以后,再把收发器接上,用自发自收模式验证物理链路。最后才接入真实总线,多节点联调。
接入真实总线后第一件事是抓一帧,看帧ID、DLC、数据域是否符合协议。如果看到一堆错误帧,优先怀疑波特率不一致,用示波器量CANH和CANL之间的差分波形,一个位的时间宽度就能反推出实际波特率。500kbps的位宽2微秒,如果量出来2.2微秒,说明某一边配置的和预期不符。
5.3 报文解析与大小端核对技巧
解析报文我习惯先手动解一帧,再和工具自动解析结果对照。这里有个经验:上位机显示的数据通常是原始字节,如果解析出来数值明显离谱,比如总电压显示65535V,多半是字节序反了,或者偏移量没加。
写Python解析脚本也很简单,先把帧数据按字节拆开,再用struct模块的unpack处理大小端和符号。多字节信号我会在协议文档里同时标注起始字节、长度、字节序、缩放因子、偏移量,脚本解析时严格按这个元数据算,能避免很多人为错误。
6. CAN自定义协议常见坑与排查速查表
6.1 硬件层典型故障
硬件层的问题通常最隐蔽,但排查起来也最有章可循。总线完全不通信,先量收发器供电、看STB或RS引脚电平是否正常。很多CAN收发器模块有standby引脚,悬空时可能进入待机模式,收发功能被关掉,这是新板子最容易犯的错。
终端电阻问题也很常见。短距离直连两块板子,可以只在其中一端接120欧姆;但只要总线长度超过几米或者节点数超过两个,必须两端都接120欧姆。这个我吃过亏,以为短距离调试不接也行,结果高速率下波形反射严重,错误帧率降不下来。
6.2 协议层典型故障
协议层最大问题永远是两边对不上。ID不一致、DLC不一致、字节序不一致、缩放因子不一致,这些在协议文档缺失或更新不及时时反复出现。
解决这类问题我有个笨但有效的办法:联调时先从协议里挑一帧最简单的固定报文,比如状态字或者版本号,手动算一帧数据,让设备发出来,再用上位机抓帧对照。这一帧对上了,再逐步扩展到浮点、负数、多字节信号。一次只验证一组信号,出了问题定位就是分钟级。
6.3 排查顺序建议
遇到CAN通信问题时,我习惯按这个顺序排查:先看工具能不能收到帧,收不到先查物理层;能收到但全是错误帧,查波特率和采样点;错误帧很少但周期不稳定,查终端电阻和线缆质量;帧内容和期望值对不上,查协议里的ID、DLC、字节序和偏移量。
Bus Off频繁出现时,除了查物理层,还要检查应用层的恢复策略。总线关闭后的恢复策略一般有两种:一种是等待硬件自动恢复,速度快但干扰持续时容易反复震荡;另一种是应用层检测到Bus Off后主动重新初始化CAN外设,恢复清晰可控。安全相关项目建议用第二种,同时通过一个网络管理报文把错误状态上报给主控。
我建议每个节点在协议里预留一个状态报文,哪怕只占一个字节,用来上报发送错误计数、接收错误计数和Bus Off状态。一次两个节点莫名其妙丢帧,就是靠这个状态报文定位到是从节点采样点配置太靠前导致的。类似这种问题,如果没有状态上报,靠示波器抓偶发波形,真的能抓到怀疑人生。
最后再说一下我的习惯。协议设计完后,我会把所有信号定义导入DBC文件,作为项目交付物的一部分。DBC文件不只是给CANalyzer用的,它还是团队沟通的基准。芯片可以换,硬件工程师可以换,但DBC文件和协议文档保持一致,这个项目就算换三拨人也不怕接不上。CAN底层机制已经非常成熟,工程师真正比拼的是协议设计层面的规范程度。项目开始前花半天时间把信号矩阵、ID分配表、协议文档建好,后面能省下几周调Bug的时间。