CAN 总线这个东西搞嵌入式的早晚都得碰见。尤其做汽车电子、工业控制、机器人、医疗器械这些方向CAN 总线几乎是事实标准。我最早接触 CAN 是给某跨平台系统做通信底板当时对协议理解得不深拿着 STM32 的库函数一顿配结果总线一直报错查了两天才发现是波特率算错了。从那之后我把 CAN 的协议细节、硬件设计和 STM32 的外设机制完整过了一遍才算真正“搞懂”。这篇博文就把我梳理过的内容从头到尾讲一遍从物理层到协议层从硬件设计到 STM32 的 HAL 库实战再到排查故障的经验尽量一次说透。1. CAN 总线到底解决什么问题1.1 为什么工业现场都在用 CANCANController Area Network是上世纪 80 年代为了汽车内的线束减重而设计出来的串行通信协议。那个年代一辆车的 ECU 越来越多每个 ECU 之间如果都拉独立的信号线线束会重得离谱故障率也高。CAN 的核心思路就是所有节点挂在同两根线上通过报文 ID 区分消息内容谁想发数据谁就往总线上写这与点对点的 UART 或者主从式的 I2C 有着本质区别。CAN 相比其他串行总线有几个关键优势多主结构任何节点都可以主动发起通信不需要主机轮询。非破坏性仲裁多个节点同时发送时ID 小的优先不会损坏数据。错误检测和自动重发机制完善硬件级别保证可靠性。传输距离远1 Mbps 下也能跑 40 米左右低速时比如 5 kbps能到 10 公里级别配合隔离中继还能更远。走差分信号抗干扰能力强适合电机、变频器这些噪声恶劣的环境。后来 CAN 不止用在车上工业现场总线、伺服驱动器、BMS 电池管理系统、楼宇自动化里到处都是它的身影。可以说会调试 CAN 总线已经是嵌入式工程师的基本功了。1.2 CAN 与 UART、I2C、RS-485 的定位差异很多人刚接触 CAN 时会拿它和 RS-485 比较因为两者都是差分信号都能挂多个节点。但 CAN 和 RS-485 的哲学完全不同RS-485 只是物理层的电气标准协议需要自己定而 CAN 从物理层到数据链路层都有完整规范包括报文格式、仲裁、错误处理、流量控制。换句话说RS-485 给你的是“马路和交警”CAN 给你的是“完整的交通法规”。I2C 也是多设备总线但它是主从架构且是开漏输出速度上限一般也就几 Mbps加上地址位数有限在多节点长距离场景下优势不大。UART 就更不用说了基本是一对一通信组网需要额外协议栈。CAN 的定位是在强干扰、长距离、多节点的工业环境中提供可靠的数据交换。理解了这个定位你就知道为什么 CAN 的协议设计得“这么重”——它把可靠性放在了第一位甚至可以说是“为了可靠不惜代价”。2. 从物理层到数据链路层CAN 协议核心机制拆解2.1 物理层特性为什么用差分信号CAN 总线物理层用的是两根线CAN_H 和 CAN_L。逻辑 1 叫隐性电平两根线的电压差接近 0逻辑 0 叫显性电平CAN_H 和 CAN_L 之间有大约 2V 的电压差。这里有个很有意思的机制显性电平会覆盖隐性电平。意思是如果一个节点发隐性电平、另一个节点发显性电平总线上最终呈现的是显性电平。打个比方显性就是“有人按了喇叭”隐性就是“没人按喇叭”只要有人按大家就都能听见。这个特性是 CAN 仲裁机制的基础很重要后面讲仲裁时你会反复用到它。差分信号的好处在于干扰信号通常同时作用在两根线上而接收端只看两根线之间的差值因此共模干扰会被抵消掉这也是 CAN 能抗干扰的根本原因。另外 CAN 收发器内部有滞回比较器输入信号需要超过一定阈值才会翻转进一步减少噪声误触发。2.2 报文帧格式详解标准帧和扩展帧CAN 报文有四种帧类型数据帧承载数据最常用。远程帧请求其它节点发送数据。错误帧节点检测到错误时主动发送。过载帧节点接收能力不足时发送。实际开发中 90% 的时间只跟数据帧打交道。数据帧又分标准帧和扩展帧区别主要在 ID 长度类型ID 位数ID 范围仲裁场长度标准帧11 位0x000 ~ 0x7FF12 位扩展帧29 位0x00000000 ~ 0x1FFFFFFF32 位标准帧的格式从前往后依次是帧起始、仲裁场ID RTR 位、控制场IDE、DLC、数据场最多 8 字节、CRC 场、ACK 场、帧结束。很多人问 CAN FD 的 64 字节和传统 CAN 的 8 字节有什么区别——传统 CAN 数据场上限就是 8 字节CAN FD 才扩展到 64 字节但这是后来定的规范。DLCData Length Code表示数据长度值可以是 0 到 8对应 0 到 8 个字节。这里有个容易踩的坑如果收到一帧 DLC8 但只填了 3 个字节数据剩下的字节是未定义的可能读到乱码所以发数据时一定要把 DLC 和实际长度严格对应起来。2.3 非破坏性仲裁机制为什么 ID 越小优先级越高多个节点同时发送数据时CAN 总线怎么决定谁先发答案就是前面提到的显性和隐性的覆盖特性。假设节点 A 发 ID0x100二进制 00100000000节点 B 发 ID0x200二进制 01000000000。两个节点同时开始发送从最高位开始逐位比较A 的最高位是 0显性B 的最高位是 0显性一致第二位A 是 0B 也是 0一致到第三位A 是 1隐性B 是 0显性此时总线上呈现的是显性电平A 发送的隐性电平被覆盖了。于是 A 判断“有人在发更高优先级的数据”主动退出转为接收状态B 继续发送完成整帧传输。整个过程完全由硬件完成不影响数据不浪费时间所以叫非破坏性仲裁。重点结论ID 越小优先级别越高。这个机制直接决定了系统设计时要把关键控制报文分配较小的 ID比如转向、制动这些安全相关报文ID 必须比车窗、空调这类舒适性报文小。2.4 错误检测和容错机制五种错误与三种状态CAN 的可靠性核心在错误处理上。它一共有五种错误检测机制位错误发送方在发送时同时监控总线电平如果发送的位和监控到的位不一致就说明出错了。但要注意在仲裁期间发送隐性电平却读到显性电平是正常现象不算错误只有在发送显性电平却读到隐性电平时才判错。填充错误CAN 规定连续发送 5 个相同电平后必须插入一个反相电平这叫位填充。如果接收方发现连续 6 个相同电平就说明违反填充规则。CRC 错误接收方重新计算接收帧的 CRC如果和发送方传来的 CRC 不一致报错。格式错误固定位段的电平不符合规范比如帧结束必须是 7 个隐性位如果出现显性位判错。ACK 错误发送方在 ACK 槽发送隐性电平如果没有节点拉低应答说明总线上没有节点成功接收。每个节点都有发送错误计数器和接收错误计数器。错误状态分为三档错误主动TEC128可以正常发送、错误被动128≤TEC≤255发送延迟只能等到总线空闲再发、总线关闭TEC255完全退出总线不再参与任何通信。总线关闭是最严重的状态需要靠软件或硬件恢复机制把节点拉回来实际项目中要通过读取错误寄存器来判断节点是否处于总线关闭状态并及时做恢复处理。3. STM32 上的 CAN 外设CubeMX 配置与工程初始化3.1 以 STM32F103 为例CAN 外设架构STM32 的 bxCAN 外设是基本扩展 CAN 外设和标准 CAN 2.0A/B 协议兼容。F103 系列的 CAN1 和 CAN2 共用一组筛选器主用 CAN1CAN2 多用于需要多路 CAN 的场景。bxCAN 由这几块组成位时序单元负责波特率生成和采样点控制。发送邮箱有 3 个可以缓存待发送的报文硬件自动调度。接收 FIFO有 2 个每个 FIFO 能存 3 帧报文。筛选器共有 28 组F103 系列是 28 组每组可以配置为列表模式或掩码模式。中断和错误管理单元提供各种中断源和错误状态寄存器。硬件设计上STM32F103 的 CAN 引脚默认是 PA11RX和 PA12TX但通过重映射也可以放到 PB8/PB9。这部分在 CubeMX 里配置起来很简单但理解底层架构有助于排查问题尤其是在用多个筛选器或者多个 CAN 外设时。3.2 CubeMX 图形化配置引脚下拉和波特率计算用 CubeMX 配置 CAN 外设最核心的是波特率。波特率由预分频器、BS1位段 1、BS2位段 2共同决定公式为$$波特率 \frac{F_{CAN}}{(BRP 1) \times (BS1 BS2 3)}$$其中 $F_{CAN}$ 是 CAN 外设的输入时钟F103 上 APB1 时钟为 36 MHzCAN 外设时钟也取自这里。在 CubeMX 界面里Prescaler 就是 BRP 值。Time Quanta in Bit Segment 1 对应 BS1。Time Quanta in Bit Segment 2 对应 BS2。ReSynchronization Jump Width 是重新同步跳转宽度一般设为 1。举个实际例子要配置 250 kbps 的波特率36 MHz 时钟需要 144 个时间量子TQ每位。设 BRP11实际分频就是 12得到 3 MHz 的位时间频率再除以 250 kHz每位占 12 个 TQ。然后让 BS17、BS22则 BS1BS2312完全符合。这个配置下采样点在第 9 个 TQ 处占比 75%。如果你用 500 kbps需要 6 个 TQ设 BRP11每位只需 3 MHz/500 kHz6BS12、BS21 满足条件采样点在 4/6≈66.7%。这个值在标准范围内实测没问题。3.3 HAL 库初始化代码逐行解读CubeMX 生成的初始化函数长这样void MX_CAN1_Init(void) { CAN_FilterTypeDef sFilterConfig; hcan1.Instance CAN1; hcan1.Init.Prescaler 11; hcan1.Init.Mode CAN_MODE_NORMAL; hcan1.Init.SyncJumpWidth CAN_SJW_1TQ; hcan1.Init.TimeSeg1 CAN_BS1_7TQ; hcan1.Init.TimeSeg2 CAN_BS2_2TQ; hcan1.Init.TimeTriggeredMode DISABLE; hcan1.Init.AutoBusOff ENABLE; hcan1.Init.AutoWakeUp DISABLE; hcan1.Init.AutoRetransmission ENABLE; hcan1.Init.ReceiveFifoLocked DISABLE; hcan1.Init.TransmitFifoPriority DISABLE; HAL_CAN_Init(hcan1); sFilterConfig.FilterBank 0; sFilterConfig.FilterMode CAN_FILTERMODE_IDMASK; sFilterConfig.FilterScale CAN_FILTERSCALE_32BIT; sFilterConfig.FilterIdHigh 0x0000; sFilterConfig.FilterIdLow 0x0000; sFilterConfig.FilterMaskIdHigh 0x0000; sFilterConfig.FilterMaskIdLow 0x0000; sFilterConfig.FilterFIFOAssignment CAN_RX_FIFO0; sFilterConfig.FilterActivation ENABLE; HAL_CAN_ConfigFilter(hcan1, sFilterConfig); }几个关键点AutoBusOff ENABLE意思是硬件自动退出总线关闭状态这对稳定性很重要建议开启。AutoRetransmission ENABLE表示发送失败后硬件自动重发防止应用层丢帧。TimeTriggeredMode和时间戳功能一般不开启除非你做同步控制。滤波器组 0 配成掩码模式ID 高 16 位和低 16 位都填 0掩码也填 0表示接收所有报文适合入门测试实际项目里要按需过滤。配置完之后还需要调用HAL_CAN_Start(hcan1)让 CAN 进入正常模式再调用HAL_CAN_ActivateNotification(hcan1, CAN_IT_RX_FIFO0_MSG_PENDING)打开接收中断之后才能正常收发。4. 实战手写 CAN 收发完整流程4.1 报文发送Transmit Mailbox 机制用 HAL 库发送 CAN 报文标准写法如下CAN_TxHeaderTypeDef txHeader; uint8_t txData[8]; uint32_t txMailbox; txHeader.DLC 8; txHeader.ExtId 0x180; txHeader.IDE CAN_ID_EXT; txHeader.RTR CAN_RTR_DATA; txHeader.TransmitGlobalTime DISABLE; txData[0] 0x01; txData[1] 0x02; txData[2] 0x03; txData[3] 0x04; txData[4] 0x00; txData[5] 0x00; txData[6] 0x00; txData[7] 0x00; HAL_CAN_AddTxMessage(hcan1, txHeader, txData, txMailbox);HAL_CAN_AddTxMessage会把报文放入发送邮箱硬件随后自动发送。需要注意返回值如果返回HAL_ERROR或HAL_BUSY说明三个邮箱都满了此时你需要做丢帧处理或者等待空闲邮箱。实际项目中发送频率高而总线负载大时邮箱满是很常见的盲目重发只会让情况恶化建议设计消息队列或者降频。4.2 报文接收中断 环形缓冲区接收数据我用的是中断加环形缓冲区的方案。收到报文后中断函数里把数据拷贝到缓冲区主循环再从缓冲区取数据进行业务处理。这样不阻塞中断也不丢数据。#define RX_BUFFER_SIZE 32 typedef struct { CAN_RxHeaderTypeDef header; uint8_t data[8]; } CAN_RxFrame; volatile CAN_RxFrame rx_buffer[RX_BUFFER_SIZE]; volatile uint8_t rx_head 0; volatile uint8_t rx_tail 0; void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { if (hcan-Instance CAN1) { CAN_RxHeaderTypeDef rxHeader; uint8_t rxData[8]; uint8_t next_head (rx_head 1) % RX_BUFFER_SIZE; if (next_head ! rx_tail) { // 缓冲区未满 HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, rxHeader, rxData); rx_buffer[rx_head].header rxHeader; memcpy(rx_buffer[rx_head].data, rxData, 8); rx_head next_head; } else { // 缓冲区满丢弃最旧的一帧 rx_tail (rx_tail 1) % RX_BUFFER_SIZE; HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, rxHeader, rxData); rx_buffer[rx_head].header rxHeader; memcpy(rx_buffer[rx_head].data, rxData, 8); rx_head next_head; } } }这个模式有几个细节值得注意缓冲区大小必须足够容纳接收峰值否则会丢帧。可以先估算总线负载比如 1 Mbps 总线下一帧 8 字节标准帧加上开销大约 108 位传输一帧约 108 µs中断触发频率是约 9 kHz。如果主循环处理不及时缓冲区必须至少能撑住 32 帧以上。环形缓冲区判断满的条件是(rx_head 1) % RX_BUFFER_SIZE rx_tail这要求缓冲区大小是 2 的幂方便取模。中断回调里不能做耗时操作拷贝 8 字节数据是允许的但如果要做协议解析、日志打印必须在主循环里做。4.3 过滤器配置掩码模式与列表模式的实际用法STM32 的 CAN 筛选器是很多人容易搞混的地方。简单说筛选器有两类模式列表模式严格匹配只接收 ID 完全匹配的报文。掩码模式按位匹配掩码位为 0 的位置不关心为 1 的位置必须匹配。32 位掩码格式下ID 被拆成两段高 16 位和低 16 位分别对应FilterIdHigh/Low和FilterMaskIdHigh/Low。标准帧只有 11 位 ID存在低 16 位的高位部分扩展帧有 29 位 ID跨高 16 位和低 16 位两段。实际配置时如果我只想接收 ID0x180 的扩展帧可以用列表模式sFilterConfig.FilterMode CAN_FILTERMODE_IDLIST; sFilterConfig.FilterScale CAN_FILTERSCALE_32BIT; sFilterConfig.FilterIdHigh (0x180 3) 0xFFFF; // 高 16 位 sFilterConfig.FilterIdLow 0x0000; sFilterConfig.FilterMaskIdHigh (0x180 3) 0xFFFF; sFilterConfig.FilterMaskIdLow 0x0000;等等列表模式不填掩码直接填两个要匹配的 ID 到FilterIdHigh/Low和FilterMaskIdHigh/Low。我上面写的例子是掩码模式只匹配单帧的写法列表模式应该这样sFilterConfig.FilterMode CAN_FILTERMODE_IDLIST; sFilterConfig.FilterScale CAN_FILTERSCALE_32BIT; sFilterConfig.FilterIdHigh id1_high; sFilterConfig.FilterIdLow id1_low; sFilterConfig.FilterMaskIdHigh id2_high; sFilterConfig.FilterMaskIdLow id2_low;如果系统里报文 ID 比较多用列表模式会非常消耗筛选器资源28 组筛选器不够用。更实用的方案是掩码模式配合 ID 规划比如把所有关键报文的高 8 位设计成同一前缀只匹配这个前缀其他位全部屏蔽这样一组筛选器就能覆盖一批报文。4.4 波特率计算实战250 kbps 的完整推导这部分我单独拿出来说因为太多人在这里栽跟头。以 F103 的 APB1 时钟 36 MHz 为例配置 250 kbps 的完整路径$$36\ \text{MHz} \div 12 3\ \text{MHz}$$每位需要 $3\ \text{MHz} \div 250\ \text{kHz} 12$ 个 TQ。按照BS1 BS2 3 12可选组合有BS17、BS22采样点 $(72)/1275%$BS18、BS21采样点 $(81)/1275%$BS15、BS24采样点 $(52)/1258.3%$理论上都可以但实际推荐 75% 这个采样点——也就是 BS17、BS22。原因是 75% 采样点对总线传播延迟和时钟容错的容忍度更均衡。试过 58% 采样点的配置在短线缆下也能工作但长线缆或节点较多时更容易出错。如果总线上的设备全部支持同一个波特率那么波特率误差必须控制在 ±1% 以内否则总线可能出现间歇性通信故障——不是完全不通而是偶尔丢帧、报错这种问题最难排查。5. 调试实录常见故障与排查思路5.1 用示波器看总线状态波形正确性判断调试 CAN 第一件事是拿示波器看波形。把示波器探头挂在 CAN_H 和 CAN_L 上正常通信时应该能看到隐性状态两根线都在 2.5V 附近差分约 0V。显性状态CAN_H 拉到 3.5VCAN_L 拉到 1.5V差分约 2V。数据帧波形是连续的不规则方波帧与帧之间有明显的空闲电平。如果波形上看到回波振铃检查终端电阻如果电压幅值偏低比如显性差分只有 1V检查收发器供电或总线是否过载挂节点太多、线缆过长如果波形上是杂乱噪声可能是没有终端电阻或总线没有被正确驱动。判断波特率最直接的方式是用示波器测量最短显性脉冲的宽度。在位填充规则下最短是“1 显性位 1 隐性位”一个周期实测往往能看到规律性的时间间隔。比如 250 kbps 下单个位时间是 4 µs脉冲宽度也就是 4 µs 的整数倍。测出来对不上说明波特率配置有问题。5.2 错误状态寄存器让芯片自己告诉我们哪里错了STM32 的 CAN 外设有错误状态寄存器CAN_ESR里面存了错误代码LEC、发送错误计数TEC、接收错误计数REC。通过调试器实时查看这几个值能快速定位故障方向现象可能原因排查方法TEC 快速增长达到 256 进入 BusOff发送时总线无 ACK检查是否有其他节点在线、波特率是否匹配、终端电阻是否接好REC 快速增长收到错误帧或物理层信号质量差查波形、查干扰源、查接地LEC1位错误总线竞争或硬件时序问题检查是否有两个节点 ID 完全相同导致仲裁异常LEC3ACK 错误总线上只有发送方没有接收方回复检查接收节点是否初始化成功是否处于环回模式通信间歇性成功、间歇性失败波特率误差临界或终端电阻接触不良精调波特率检查线缆连接5.3 三个非常隐蔽的坑第一个坑是共地问题。CAN 是差分信号理论上不依赖地线但收发器的工作电压参考还是需要共地。如果两个节点的电源模块相互隔离而 CAN 收发器的地没拉通总线上会有很大的共模电压通信就变得极不稳定。我的做法是如果系统设计上必须隔离就选带隔离的 CAN 收发器模块比如用带 DC-DC 的隔离收发器如果不隔离务必保证各节点地电位差在 -7V 到 12V 以内。第二个坑是终端电阻的位置和数量。CAN 规范要求总线两端各接一个 120Ω 终端电阻不是每个节点都接。如果每个节点都接 120Ω并联之后阻抗变低信号反射加剧通信质量反而下降。测量终端电阻时要在总线断电状态下从任意接入点量正常应该是约 60Ω。如果量到 120Ω说明只接了一端如果量到 0Ω说明短路如果量到无穷大说明两端都没接。第三个坑是中断优先级和阻塞。CAN 接收中断处理函数里如果执行了耗时操作比如调用HAL_UART_Transmit同步发送日志波特率低时一个字符就要几十微秒一帧日志上百字符就是几毫秒CAN 接收中断一直被占用硬件 FIFO 溢出丢帧。中断回调里只做数据拷贝日志、解析、处理全部丢到主循环是必须遵守的纪律。5.4 工具链CAN 分析仪和日志时间戳强推每个做 CAN 开发的人都备一个 USB CAN 分析仪这东西能把总线上的每一帧报文都抓下来带精确到微秒的时间戳。排查问题时有了时间戳才能判断丢帧是否集中在某段总线上或者某个节点发送是否周期性异常。我用过的几款入门分析仪价格在几十到几百不等软件支持 PCAN-View 风格的总线监控够用了。如果没有分析仪只靠 STM32 的调试器也能看一些东西在HAL_CAN_GetRxMessage里打个断点看接收的 ID 和 DLC 是否正常。但这个方式局限很大只能看到本节点的收发情况看不到总线全局状态。有条件还是建议配一个分析仪调试效率提升明显。6. 在实际项目中总结的 CAN 开发要点把前面的内容串成一个项目管理视角的清单方便你做方案阶段就对关键点心中有数波特率和采样点预先根据总线拓扑长度、节点数选定波特率和采样点CAN 2.0 常见配置是 250 kbps / 500 kbps / 1 Mbps尽量统一全系统。如果系统内新旧设备混用尽量选较低波特率兼容性和稳定性更好。ID 规划按报文优先级分配 ID 段。控制类 0x000~0x1FF状态类 0x200~0x3FF诊断类 0x400~0x5FF留出扩展空间。ID 一旦批量生产后再改牵涉所有节点的软件和文档成本很高一定要提前规划。滤波器设计先确定节点需要接收哪些报文再决定掩码。不要图省事全接收——全接收会让 MCU 中断负担加重高负载下更容易丢帧。错误恢复开启硬件自动 BusOff 恢复同时在应用层监控CAN_ESR寄存器一旦进入 BusOff主动重新初始化外设而不是死等硬件恢复。线缆和接地所用线缆的阻抗尽量靠近 120Ω双绞线必须使用双绞结构不要用平行线。接地点要可靠避免出现地环路。软件架构接收用中断环形缓冲区处理放主循环发送用队列或者直接邮箱发送超时退出防止死等。我在实际项目里拿着这套流程排查过不少问题有一回通信偶发中断查下来是某个节点的终端电阻虚焊还有一回波特率算错了一位导致两个节点都收不到对方报文但自己发自己却能通过分析仪看到——这种问题不踩过一次很难想到是波特率算错。CAN 总线的调试本质上就是在“物理层、数据链路层、应用层”三层之间来回定位先把硬件波形看明白再看协议报错最后查应用逻辑基本都能解开。最后分享一个我后来一直沿用的习惯每块板子预留一个 CAN 调试接口连上分析仪后可以随时抓包。这个习惯前期只花几十块钱的材料费后期排查问题能省下大量时间。硬件设计时就把调试接口接出来比出了问题再飞线要靠谱得多。