CAN总线通信原理与STM32 bxCAN外设开发实战
以前一直觉得搞懂 CAN 总线是个“早晚要做但不着急”的事。真正被逼着去弄明白是某个项目里两块主控板之间需要用一根线传状态板子距离两三米线还从电机旁边走UART 直接废掉I2C 更是想都别想。换成 CAN 总线之后问题一次解决后面所有做过的多节点通信项目我都默认优先考虑它。这篇文章就把 CAN 总线从协议底层到 STM32 的 bxCAN 外设应用完整梳理一遍适合刚接触 CAN、被各种帧结构和寄存器搞晕的人也适合已经跑通 Demo 但没想清楚原理的开发者。全程按我自己项目里的实际操作讲不绕弯子。1. 为什么很多嵌入式项目最终选了 CAN 总线而不是串口先说我自己的结论选通信方案本质是选“物理层数据链路层”的组合是否匹配现场环境。UART 简单但单点对单点、无仲裁、抗干扰靠运气。I2C/SPI 适合板内短距离一根排线可以搞定但超过半米就开始看人品。RS485 虽然也是差分但它本质上还是主从结构谁先发谁后发要软件说了算一旦两个节点同时开口就是数据碰撞没有总线层面的解决办法。CAN 总线能把这些问题一次性收掉原因在于它从设计之初就是为“多主、远距离、强干扰、高可靠”准备的。总线上任何一个节点都可以主动发起通信多个节点同时发也不会冲突因为控制器和协议栈会通过硬件仲裁决定谁先用总线。这个能力在分布式系统里太重要了比串口加 Modbus 轮询的方式省心太多。做个简单对比会更直观特性UARTI2CSPIRS485CAN通信距离1-2米内板级板级可达1000米可达几千米多节点点对点多主可仲裁但复杂一主多从主从轮询多主仲裁抗干扰弱弱弱差分较好差分错误处理强芯片支持几乎所有MCU几乎所有MCU几乎所有MCU需收发器需控制器收发器是否需要软件轮询不需要简单仲裁由主机控制需要不需要硬件仲裁所以选型逻辑很清楚如果你的系统里节点数量超过三个节点之间距离超过一米或者环境里电机、继电器、变频器这类干扰源多直接考虑 CAN 总线。反过来板内两个芯片通信UART 或 I2C 更简单没必要为了“高级”而上 CAN。CAN 总线常见的技术参数也顺便列一下方便做方案评估经典 CAN 最高速率 1Mbps高速 CANCAN FD可以做到 5Mbps 以上报文数据段最长 8 字节CAN FD 扩展后可以到 64 字节物理介质用双绞线CAN_H 和 CAN_L 一组差分信号一条主干线理论上可以挂 100 多个节点实际受收发器驱动能力和线缆质量影响几十个节点很轻松。2. 物理层先看明白差分信号、显性位与隐性位还有那两颗120欧电阻很多人学 CAN 卡在物理层总觉得“电平”两个字抽象。其实 CAN 物理层的核心就一句话它用一对导线上的电压差来传数据而不是像 TTL 串口那样用单根线对地的绝对电压。这个设计带来的好处是外界的共模干扰比如电机启动时在两根线上同时感应的噪声会被差分接收器直接抵消掉。具体电平状态也很有特点。CAN 总线上的位只有两种显性位和隐性位。显性位代表逻辑 0隐性位代表逻辑 1。总线空闲时CAN_H 和 CAN_L 都在 2.5V 附近差分电压接近 0V总线处于隐性状态。当某个节点发送显性位时CAN_H 被拉到约 3.5VCAN_L 被拉到约 1.5V差分电压约 2V。这个“显性位电压差”足够让所有接收节点稳定识别。从协议设计的角度理解一下为什么用“显性/隐性”而不是“高/低”。多节点共享一条总线如果某个节点在发隐性位1另一个节点同时在发显性位0两者“开车”碰在一起谁赢物理层上显性位主动驱动差分电压隐性位相当于“释放总线”所以总线最终表现为显性0 覆盖 1。这就是后面仲裁机制能成立的基础。终端电阻这块是新手最容易忽略的。CAN 总线规范要求线缆两端各接一个 120Ω 电阻而不是每个节点都接也不是随便接一个。原因是 CAN 信号在总线上以很高的速率跳变如果传输线末端阻抗不匹配信号会产生反射导致波形畸变严重时直接误码。120Ω 是从双绞线的特性阻抗定出来的经典值在两端并联两个 120Ω 后电源关闭状态下从任一接入点量 CAN_H 到 CAN_L 之间电阻应该在 60Ω 左右。我用这个办法快速排查过好几块板子很灵。收发器不能省。STM32 内部集成的只是 CAN 控制器负责协议处理、滤波、错误状态这些逻辑工作但它引脚出来的不是差分电平。控制器必须外接一个 CAN 收发器芯片把控制器的 TX/RX 信号转换成 CAN_H/CAN_L 差分信号市面上常见的型号比如高速收发器 TJA1050、SN65HVD230 都可以。5V 供电的收发器接 3.3V 的 STM32需要留意逻辑电平兼容很多收发器是 5V 供电但逻辑引脚兼容 3.3V具体看数据手册的 VIO 或逻辑电平说明布线前一定确认一遍否则会出现“连上了但收不到数据”的邪门问题。3. 数据链路层的几件大事帧结构、仲裁机制、位填充与错误处理物理层搞定了下面才是 CAN 真正有魅力的地方数据链路层。CAN 的报文叫“数据帧”最常见的标准格式数据帧由这么几段组成起始位 SOF、仲裁段、控制段、数据段、CRC 校验段、ACK 应答段和帧结束 EOF。SOF 就是一个显性位表示一帧开始。仲裁段里最关键的是标识符 ID标准帧是 11 位 ID扩展帧是 29 位 ID。控制段里有数据长度代码 DLC表示后面数据段有几个字节经典 CAN 一帧最多 8 字节。数据段就是业务数据。CRC 是 15 位校验码由发送节点硬件算好接收节点硬件核对。ACK 段是接收节点回给发送节点的“我收到了”信号这有个隐藏知识点发送节点在 ACK 段会释放总线接收节点如果完整无误地收到这一帧就会拉一个显性位来应答。如果总线上只有发送节点、没有任何节点在收发送节点的控制器会在 ACK 段检测不到显性位直接报 ACK 错误。仲裁机制值得单独讲透一点。假设节点 A 和节点 B 同时开始发帧它们先各发一个 SOF然后在仲裁段逐位发送 ID。CAN 的规则是“显性位优先级高于隐性位”也就是在某个位时刻A 发的是隐性1B 发的是显性0总线电平变成显性A 会发现自己发出的隐性位和总线实际电平不一致判定自己仲裁失败立刻停止发送转为接收状态。B 则继续发送完整帧。整个过程由硬件完成微秒级结束软件根本不需要介入。这也解释了为什么 ID 小的节点优先权更高因为 ID 数值越小二进制里高位 0 越多越容易在仲裁段压制其他节点。优先级设计好之后要理解位填充。CAN 总线不是通过独立时钟线同步的接收方靠总线上的电平跳变来恢复位时序。如果一长串数据全是连续的隐性位或连续显性位信号长期没有跳变接收方的采样时钟就会逐渐漂移最终采错采样点。所以 CAN 协议规定发送端在连续发送 5 个相同电平的位之后必须自动插入一个相反电平的位这就是位填充。接收端收到数据后会按规则把这个填充位去掉。这个机制保证了总线上每隔最多 5 个位就至少有一次跳变时钟同步就不会丢。错误处理也说一下因为这是 CAN 和普通串口在可靠性上拉开差距的关键。CAN 控制器内置五种错误检测机制位错误、填充错误、CRC 错误、格式错误、ACK 错误。每个节点内部维护发送错误计数器和接收错误计数器。错误计数小于 128 时节点处于“错误主动”状态出错时可以主动发出错误帧通知全网计数在 128 到 255 之间时进入“错误被动”状态只能被动响应错误帧计数超过 255 就变成“总线关闭”直接隔离出总线。这个设计非常像现实里的“隔离病房”个别节点哪怕彻底短路也不会把整条总线拖死。4. STM32 的 bxCAN 外设硬件已经帮你做掉一半现在把视角切到 STM32。大部分常见型号内置的 CAN 控制器叫 bxCAN它挂在 APB1 总线上也就是说 CAN 外设的工作频率来自 APB1 时钟而不是系统主频。这个细节很重要因为后面算波特率要用到 APB1 的准确值很多人翻车就是因为拿 72MHz 去算忘了 APB1 通常是 36MHz。bxCAN 的资源可以这样概括3 个发送邮箱、2 个接收 FIFO、若干筛选器组。发送邮箱相当于三个发送缓冲区只要有一个邮箱空闲就可以把待发帧丢进去让控制器自己排队发送。接收 FIFO 是硬件做的接收队列FIFO0 和 FIFO1 各能暂存 3 帧。帧进来会先经过筛选器再进入 FIFO然后触发中断或者被查询读到。筛选器组是 STM32 的 CAN 外设里比较特色的硬件功能。每个筛选器可以工作在两种模式掩码模式和列表模式。掩码模式下的逻辑是硬件把接收帧的 ID 和筛选器寄存器里的 ID 做比较比较结果再和掩码寄存器做与运算如果结果为 0 就算匹配。说白了掩码决定“哪些位必须严格相等哪些位可以随便”。列表模式则更简单粗暴硬件维护一个 ID 列表只要帧的 ID 能和列表里任意一项匹配就放行。工程项目里如果只关心少数几个 ID 的报文用列表模式最省心如果希望接收一段连续 ID 的报文用掩码模式。bxCAN 还有四种工作模式正常模式、环回模式、静默模式和环回静默模式。调试阶段强烈推荐先玩环回模式。环回模式下控制器发送的报文不会真正跑到总线上去而是被控制器内部直接回环到自己的接收路径上。换句话说在没有外接任何收发器、没有接总线的情况下你也能验证“我的 CAN 初始化对不对、我的发送和接收代码通不通”。这个模式我几乎每个项目调试都先用等环回跑通了再接真实总线能省出一大截排错时间。说回协议无关的细节bxCAN 会自动完成前面提到的仲裁、位填充、CRC 校验、错误处理软件只需要做四件事配置 GPIO 复用、配置 CAN 波特率和采样点、配置筛选器、然后按结构体收发报文。这部分我放下一章直接写代码。5. 从零写一套 CAN 收发程序初始化、筛选器与中断收发这里给出一套我实际在用的 HAL 库示例代码基于常见型号的 72MHz 主频配置APB1 为 36MHz。第一步先配置引脚和使能时钟以 PA11 为 RX、PA12 为 TX 为例void CAN_GPIO_Init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_CAN1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin GPIO_PIN_11; /* CAN1_RX */ gpio.Mode GPIO_MODE_AF_INPUT; gpio.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, gpio); gpio.Pin GPIO_PIN_12; /* CAN1_TX */ gpio.Mode GPIO_MODE_AF_PP; /* 推挽复用输出 */ gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio); }注意 RX 引脚是浮空输入TX 引脚是复用推挽输出。这个配置如果写反常常出现总线波形一直在但就是收不到数据的情况。接着配置 CAN 的工作参数。波特率的计算公式是波特率 APB1时钟 / (预分频系数 × (1 BS1 BS2))其中 BS1、BS2 的单位是 tq时间量子1 是固定的同步段。采样点位置为(1 BS1) / (1 BS1 BS2)一般设计在 75%~80% 比较稳。以 APB136MHz 为例我用得最多的一组参数是预分频 4BS113tqBS24tq得到位时间 18tq波特率 500kbps采样点约 77.8%。CAN_HandleTypeDef hcan1; void CAN_Init_500k(void) { hcan1.Instance CAN1; hcan1.Init.Prescaler 4; /* 36MHz / 4 9MHz每tq为1/9MHz */ hcan1.Init.SyncJumpWidth CAN_SJW_1TQ; hcan1.Init.TimeSeg1 CAN_BS1_13TQ; hcan1.Init.TimeSeg2 CAN_BS2_4TQ; hcan1.Init.TimeTriggeredMode DISABLE; hcan1.Init.AutoBusOff ENABLE; /* 总线关闭后自动恢复省得手动复位 */ hcan1.Init.AutoWakeUp DISABLE; hcan1.Init.AutoRetransmission ENABLE; /* 发送失败自动重发适合可靠性场景 */ hcan1.Init.ReceiveFifoLocked DISABLE; hcan1.Init.TransmitFifoPriority ENABLE; HAL_CAN_Init(hcan1); }这里两个参数多说一句。AutoRetransmission开启后如果一帧数据因为仲裁失败或者错误没能发出去控制器会自动重传。对大多数应用这是好事但也要知道它会占住邮箱极端情况下会影响新数据的发送。AutoBusOff开启以后总线关闭状态会自动恢复连接免去手动调用软件复位流程。接下来是筛选器。刚调试时最简单的是把所有报文全部放进来掩码全部写成 0 即可void CAN_Filter_AllPass(void) { CAN_FilterTypeDef filter {0}; filter.FilterBank 0; /* 用筛选器组0 */ filter.FilterMode CAN_FILTERMODE_IDMASK; filter.FilterScale CAN_FILTERSCALE_32BIT; filter.FilterIdHigh 0x0000; filter.FilterIdLow 0x0000; filter.FilterMaskIdHigh 0x0000; /* 掩码为0不校验任何位 */ filter.FilterMaskIdLow 0x0000; filter.FilterFIFOAssignment CAN_RX_FIFO0; filter.FilterActivation ENABLE; HAL_CAN_ConfigFilter(hcan1, filter); HAL_CAN_Start(hcan1); HAL_CAN_ActivateNotification(hcan1, CAN_IT_RX_FIFO0_MSG_PENDING); }筛选器配置有个容易犯错的地方掩码是 0 代表“所有位都忽略”而不是“所有位都要匹配”。很多人第一次写都反了导致一条消息也收不到。发送一帧数据步骤是先看有没有空闲邮箱再填报文头结构体最后呼叫发送接口uint8_t CAN_Send_Std(uint32_t id, uint8_t *data, uint8_t len) { CAN_TxHeaderTypeDef txHeader; uint32_t mailbox 0; if (len 8) len 8; txHeader.StdId id; txHeader.IDE CAN_ID_STD; txHeader.RTR CAN_RTR_DATA; txHeader.DLC len; txHeader.TransmitGlobalTime DISABLE; if (HAL_CAN_GetTxMailboxesFreeLevel(hcan1) 0) { return 0; /* 三个邮箱全满发送失败 */ } if (HAL_CAN_AddTxMessage(hcan1, txHeader, data, mailbox) ! HAL_OK) { return 0; } return 1; }接收则推荐用中断代码在回调函数里处理。这比在 while 循环里轮询 FIFO 干净得多也不容易丢帧void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rxHeader; uint8_t rxData[8]; HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, rxHeader, rxData); if (rxHeader.StdId 0x123) { /* 这里处理你的业务数据 */ } }FIFO0 收到帧时硬件自动触发这个回调记得在主程序里已经打开了 CAN_IT_RX_FIFO0_MSG_PENDING 中断并且正确设置了 CAN 中断优先级。如果不走中断也可以在主循环里先查询HAL_CAN_GetRxFifoFillLevel有数据再读。另外一个常见疑问是为什么我发了标准帧接收端收到的 StdId 和我发的不一样因为接收结构体里的 StdId 是经过逻辑右移处理的而发送结构体里的 StdId 是原始 11 位数值。两边都走 HAL 库接口这个问题通常不太会遇到。一旦你直接操作寄存器或者混用标准库寄存器写法就很容易被这个位段偏移折腾一番。建议收发两端代码风格保持一致别混用。6. 实测中踩过的 CAN 坑一次讲清楚代码能跑起来只是开始真正让 CAN 项目“稳”的是调试阶段排除掉的那堆坑。下面这几个都是我实际遇到过的按出现频率排个序。第一个坑是终端电阻位置不对或数量不对。曾经有一块板子单板测试时收发都正常接到总线网络上就偶发超时。排查到最后原因就是总线上只有两块板子但板子上的终端电阻被设计成了默认开启导致两端各有两只 120Ω 并联总线负载明显偏重。后来改成用跳线手动插拔终端电阻只保留总线物理两端各一只 120Ω问题消失。快速判断方法断电后在总线任意接入点量 CAN_H 和 CAN_L 之间的电阻如果在 60Ω 左右说明终端电阻工作正常如果量到 120Ω说明只有一端有如果量到接近 0Ω说明有短路。第二个坑是正常模式下单节点自发自收失败。这是新手最容易懵的地方我明明发了也没有任何报错但就是自己收不到。原因在于正常模式下发送节点在 ACK 段等待接收节点回显性应答如果总线上没有任何其他节点发送节点检测不到 ACK于是报 ACK 错误同时因为AutoRetransmission开着帧会被反复重发看起来就像“卡死在发送里”。解决办法很简单自测阶段用环回模式或者外接一个 USB-CAN 分析工具当接收端别让发送节点一个人唱独角戏。第三个坑是波特率误差和采样点设置不合理。两个控制器通讯一方的晶体频率有偏差或者 APB1 分频算出的 tq 组合导致实际波特率和目标值差了 1%短期内可能没事但总线越长、节点越多出错概率越高。我的习惯是给自己定一条省钱原则目标波特率和实际波特率的误差控制在 1% 以内采样点放在 75% 到 80% 之间。如果项目里波特率不太常规比如要用 125kbps就先算出 APB1 时钟和波特率之间的整除关系优先保证预分频系数和位时间组合是整数结果。第四个坑是筛选器把报文“吃掉”了软件层面没有任何感觉。这个坑隐蔽在筛选器里写的东西本身没错但你用错了 ID 布局。CAN 的 ID 在 32 位寄存器里不是从 bit0 开始的标准 ID 占了高 11 位所以直接把收到的StdId当成筛选器里的 ID 结果是错的。用 HAL 库HAL_CAN_ConfigFilter时填写 FilterIdHigh/FilterIdLow 要按 ID 布局换算。初学阶段我强烈建议先做“全通”筛选器确认数据链路通了再去算掩码过滤否则误把自己坑了找不到原因。第五个坑是错误状态没有恢复机制。常见场景是总线上某个节点反复出错错误计数器越过 255节点进入 Bus-Off 状态。如果没有开AutoBusOff节点就彻底脱离总线你要在调试器里看 CAN 外设状态才发现它“消极怠工”了。更稳妥的做法是初始化里开自动恢复同时在应用层做周期性的健康检查比如每 500ms 读一次错误计数超过阈值就主动复位 CAN 外设让系统自愈。第六个坑也是最基础的一个CAN_H 和 CAN_L 接反。很多收发器带反接保护不会立刻烧掉但差分信号极性反了通信必然失败。我吃过一次亏是在转接板线序上板子丝印写的是 H结果杜邦线插到了另一排示波器一看波形是反的才反应过来。接线之前先用万用表确认收发器的 CANH、CANL 引脚定义再对接线颜色做统一约定比如 CANH 用橙线CANL 用蓝线所有节点统一标准。调试工具方面做 CAN 项目有条件的话备一个 USB-CAN 分析工具几百块的够用配合电脑软件能直接看 ACK 错误、帧 ID、总线负载率。没有工具的话用示波器也能诊断隐性状态时 CAN_H 和 CAN_L 都在 2.5V 附近显性位 CAN_H 抬到 3.5V、CAN_L 压到 1.5V差分约 2V。如果波形上沿明显过冲或者方波边沿有振铃基本都是终端电阻和布线的问题不是芯片的问题。还有一个容易被忽略的系统级坑不同节点的地电位不一致。CAN 虽然是差分通信但收发器本身需要参考地如果两块板子用两个不同的开关电源供电地之间压差过大共模电压超出收发器的允许范围会导致持续错误。解决方法是总线两端或中间用一根地线把各个节点的工作地连起来尤其是远距离节点之间地线不能省。这个点是我做分布式项目踩过最深的一个坑换了很多代码逻辑都没用最后发现是两个电源模块的地没有共接。整套跑通之后建议再按这个路径进阶试试先自己焊两块板子用双绞线互联分别写上“节点 A 每 100ms 发一帧、节点 B 收到后回一帧”的循环测试然后把环回模式切换成正常模式确认 ACK 逻辑接着加第三块板子故意让两个节点同时发帧观察仲裁是否正常最后再接入网络和电机负载用 USB-CAN 工具长时间抓包统计错误帧。整套操作走下来CAN 总线在你的项目里基本就不会再给你惊喜了。我的体会是CAN 总线相比串口真正贵重的不是协议本身而是它把可靠性内建在了控制器硬件里自动仲裁、自动重发、自动错误计数、自动故障隔离。你要做的只是配置好外设再把业务逻辑按帧收发去组织。这个特性让它在工业现场和车载环境里经久不衰也让 STM32 这类集成 bxCAN 的芯片成了嵌入式项目里最省心的通信方案之一。下一步如果想深入可以试试加一个 CAN 应用层协议比如开源的 CANopen 协议栈让总线上的节点具备参数配置和状态上报能力那又是另外一层天地了。