以前一直觉得,搞懂 CAN 总线是个“早晚要做但不着急”的事。真正被逼着去弄明白,是某个项目里两块主控板之间需要用一根线传状态,板子距离两三米,线还从电机旁边走,UART 直接废掉,I2C 更是想都别想。换成 CAN 总线之后,问题一次解决,后面所有做过的多节点通信项目我都默认优先考虑它。这篇文章就把 CAN 总线从协议底层到 STM32 的 bxCAN 外设应用完整梳理一遍,适合刚接触 CAN、被各种帧结构和寄存器搞晕的人,也适合已经跑通 Demo 但没想清楚原理的开发者。全程按我自己项目里的实际操作讲,不绕弯子。
1. 为什么很多嵌入式项目最终选了 CAN 总线而不是串口
先说我自己的结论:选通信方案,本质是选“物理层+数据链路层”的组合是否匹配现场环境。UART 简单,但单点对单点、无仲裁、抗干扰靠运气。I2C/SPI 适合板内短距离,一根排线可以搞定,但超过半米就开始看人品。RS485 虽然也是差分,但它本质上还是主从结构,谁先发谁后发要软件说了算,一旦两个节点同时开口就是数据碰撞,没有总线层面的解决办法。
CAN 总线能把这些问题一次性收掉,原因在于它从设计之初就是为“多主、远距离、强干扰、高可靠”准备的。总线上任何一个节点都可以主动发起通信,多个节点同时发也不会冲突,因为控制器和协议栈会通过硬件仲裁决定谁先用总线。这个能力在分布式系统里太重要了,比串口加 Modbus 轮询的方式省心太多。
做个简单对比会更直观:
| 特性 | UART | I2C | SPI | RS485 | CAN |
|---|---|---|---|---|---|
| 通信距离 | 1-2米内 | 板级 | 板级 | 可达1000米 | 可达几千米 |
| 多节点 | 点对点 | 多主可仲裁但复杂 | 一主多从 | 主从轮询 | 多主仲裁 |
| 抗干扰 | 弱 | 弱 | 弱 | 差分,较好 | 差分+错误处理,强 |
| 芯片支持 | 几乎所有MCU | 几乎所有MCU | 几乎所有MCU | 需收发器 | 需控制器+收发器 |
| 是否需要软件轮询 | 不需要 | 简单仲裁 | 由主机控制 | 需要 | 不需要,硬件仲裁 |
所以选型逻辑很清楚:如果你的系统里节点数量超过三个,节点之间距离超过一米,或者环境里电机、继电器、变频器这类干扰源多,直接考虑 CAN 总线。反过来,板内两个芯片通信,UART 或 I2C 更简单,没必要为了“高级”而上 CAN。
CAN 总线常见的技术参数也顺便列一下,方便做方案评估:经典 CAN 最高速率 1Mbps,高速 CAN(CAN 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.5V,CAN_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 发的是隐性(1),B 发的是显性(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% 比较稳。以 APB1=36MHz 为例,我用得最多的一组参数是:预分频 4,BS1=13tq,BS2=4tq,得到位时间 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 协议栈,让总线上的节点具备参数配置和状态上报能力,那又是另外一层天地了。