ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32 CAN总线开发实战:从物理层到HAL库与联调排查

STM32 CAN总线开发实战:从物理层到HAL库与联调排查

前阵子帮朋友调一套基于STM32的CAN通信平台,两个节点怎么都对不上,示波器量CAN_H和CAN_L差分电压有正常的显隐性电平,但一旦跑完整业务指令,总线上就全是错误帧。折腾了一下午,最后发现两个节点板子上的终端电阻焊盘都没贴片,整条总线处于四五十米的悬空状态,反射严重、波形振铃,通信自然崩了。这种问题在CAN总线入门阶段太典型了,而且坑人的地方在于——你越盯着软件配置看,越找不到原因。

借着这段经历,我想把CAN总线从物理层原理、协议报文,到STM32外设配置、HAL库收发代码、最后到联调排查的整个过程完整讲一遍。这篇文章适合刚接触CAN总线、想在STM32上跑通第一帧报文的开发者,也适合那些已经能收发但一联调就出奇怪问题的朋友。文章里的代码以STM32F1系列和HAL库为例,但思路同样适用于其他型号。

1. 从硬件到架构:CAN 凭什么能在恶劣环境里跑得稳

1.1 一次让我差点怀疑人生的联调现场

那次联调的具体表现是:单节点自发自收完全正常,两个节点一接上总线,主控发送就报ACK错误,用CAN分析仪抓包,能看到总线上全是错误帧和重发帧。一开始我怀疑是波特率不对,于是反复改分频系数,改同步段,毫无改善。后来静下心来拿万用表去量终端电阻,发现两个节点的CAN_H和CAN_L之间直流电阻都在120欧以上,这说明板上预留的电阻根本没焊。等我把两个120欧电阻分别补到总线的两端,错误帧瞬间消失,心跳帧稳定收发。

这个场景我想先放在最前面,因为很多新手学CAN,都是从波特率、ID、过滤器开始,很少有人认真对待物理层。但CAN总线是一个闭合的物理网络,电气特性不合格,后面协议层写得再对也白搭。

1.2 和 UART、I2C、SPI 相比,CAN 到底强在哪里

选通信接口之前,得先明白AN不是用来替代UART和SPI的。UART简单但只能点对点;I2C多主机但地址空间小、总线电容受限;SPI高速全双工却是典型的主从结构。CAN则是为“多节点、长距离、高可靠、现场总线”而生的。

特性UARTI2CSPICAN
典型拓扑点对点多主机总线一主多从多主机总线
最大节点2受地址和电容限制受片选限制可到数十甚至上百
通信距离短短极短低速时可达千米级
仲裁机制无有,但较弱无逐位非破坏仲裁
错误处理基本没有简单校验基本没有完整错误检测与自动重发
抗干扰较弱较弱弱强,差分信号

CAN的1Mbps看似不如SPI动辄几十Mbps,但在几十米线缆上稳定传输,并且支持多个节点同时竞争总线,这是短距离高速的SPI做不到的。工业现场、车载网络、运动控制、传感器采集,只要节点多、环境恶劣、要求可靠性,CAN都是非常务实的选择。

1.3 显性与隐性:一根线是说不清的

CAN使用两根线CAN_H和CAN_L,靠两根线之间的差分电压来传递信息。总线空闲时,两线电压都接近2.5V,差压接近0V,这被称为隐性电平,对应逻辑1。发送显性时,CAN_H被拉到约3.5V,CAN_L被拉到约1.5V,差压约2V,对应逻辑0。

有个关键点必须理解:显性电平会覆盖隐性电平。整个总线就是“线与”关系,只要有任何一个节点拉出显性,总线就是显性;只有所有节点都释放为隐性,总线才回到隐性。可以想象成一根绳子:隐性就是大家同时松手,绳子松弛;显性就是任何一个人用力拉紧,绳子立刻绷直。这个特性决定了CAN的仲裁机制,也决定了为什么ID小的报文优先级更高。

也正是因为采用差分信号,CAN对外部的共模干扰有天然的抑制能力。外界的电磁干扰同时作用在CAN_H和CAN_L上,差压不变,接收端依然能正确解析。所以CAN才敢用在火花塞点火、电机驱动这种噪声环境里。

1.4 为什么必须在两端各接一个 120 欧电阻

CAN总线的特性阻抗约为120欧,为了让信号在总线末端不产生反射,规范的CAN网络需要在物理链路的两端各接入一个120欧终端电阻。中间节点不要接,否则会导致等效阻抗过低。

终端电阻是否接好,会直接影响差分波形的质量。没有终端电阻,信号到达总线末端会反射回源端,形成振铃,导致接收端误判电平。有的板子明明标注了120欧焊盘却空贴,就会踩我刚才说的坑。

测量终端电阻也有讲究:不能直接在总线上在线测,因为节点里还有收发器等效电阻并联,读数会不准确。断电后,从总线一端量CAN_H到CAN_L的直流电阻,如果看到约60欧,说明两端电阻都在;如果约120欧,说明只有一端有;如果几千欧,说明两端都没有。这个方法在我后来的联调中从来没有失手过。

2. 报文帧与优先级仲裁:协议层的关键机制

2.1 一帧数据里到底装了什么

经典CAN标准帧由以下几个场组成:SOF起始位、仲裁场、控制场、数据场、CRC场、ACK场、EOF结束位。标准帧使用11位ID,扩展帧使用29位ID,数据场最多8字节。

帧段标准帧说明
SOF1 bit起始位,必须是显性,用于同步
仲裁场11 bit ID + RTRRTR为0表示数据帧,为1表示远程帧
控制场IDE、DLC等标准帧IDE=0,扩展帧IDE=1
数据场0~8 字节用户数据
CRC场15 bit CRC + 1 bit 分隔校验整个帧
ACK场2 bit发送方发隐性,接收方拉显性应答
EOF7 bit隐性结束

ST的HAL库里,CAN_TxHeaderTypeDef结构体里的StdId、ExtId、IDE、RTR、DLC就对应这些字段。这里特别提一点:远程帧在实际工程中很少使用,大多数场景都是各节点主动周期或事件驱动发数据帧。远程帧的存在容易造成误解,新手不用死磕。

2.2 仲裁机制:为什么 ID 越小优先级越高

当两个节点同时在总线上发送时,从SOF之后的第一位也就是ID的最高位开始逐位仲裁。因为显性0覆盖隐性1,每个发送节点一边发送一边监听总线。如果自己发送的是1,却在总线上读到0,说明有另一个节点正在发送0,那么自己立刻放弃发送转入接收状态。整个过程中,高优先级节点根本不受影响,所以叫非破坏性仲裁。

举例:ID 0x100和ID 0x200同时发送。0x100的二进制是00100000000,0x200的二进制是01000000000。逐位比较时,在ID的最高位之后某一位上,0x100输出0,0x200输出1,于是0x200在总线上读到了0,知道自己输了,马上收手。所以ID数值越小,优先级越高。

当两个节点发送的标准ID相同时,一个是数据帧(RTR=0),一个是远程帧(RTR=1),那么RTR为0的数据帧在仲裁时呈显性,优先发送。在多节点工程里,我通常会做一个ID规划表:紧急控制类报文用小ID,比如0x001;状态帧用中等ID;参数配置帧用大ID。这样即使报文同时涌上总线,控制类也能先到达。

2.3 位填充和 CRC:看似不起眼却影响全局稳定

CAN规范要求发送端在连续发出5个相同极性位后,自动插入1个反极性位;接收端收到后把插入位删除。这样总线不会长时间没有跳变,接收方才能持续从波形边沿恢复时钟。位填充覆盖范围从SOF开始到CRC结束,ACK和EOF不参与。

CRC15是CAN帧里用于检测传输错误的核心校验字段,由硬件自动计算和验证。接收方校验失败会走错误处理流程,发送方收到错误帧后会尝试重发。这些机制共同保证了CAN在恶劣环境下的可靠传输。

2.4 错误帧与节点状态机:总线出了问题谁在“反思”

CAN的错误检测涵盖位错误、填充错误、CRC错误、格式错误和ACK错误。每个节点都维护发送错误计数器和接收错误计数器。当节点检测到错误,硬件会自动发送错误帧并申请重发。

节点状态根据计数器值分为三类:主动错误状态、被动错误状态、总线关闭状态。主动错误节点还可以正常收发并参与总线仲裁;被动错误节点发送的帧只能以隐性电平表示,且重发前需要等待额外时间;总线关闭节点则完全退出通信,不再干扰其他节点。

这个机制把故障隔离在每个节点内部。但也带来了麻烦:如果某个节点的线缆短路或者收发器损坏,它可能一直发错误帧,整个总线被它“带崩”,其他节点全部等待。排查时遇到大面积通信失败,第一反应应该是逐个断开节点,而不是先怀疑主控代码。

3. STM32 bxCAN 外设的正确打开方式

3.1 发送邮箱和接收 FIFO:资源到底够不够用

STM32的bxCAN是集成在MCU内部的CAN控制器,需要配合外部收发器芯片使用。bxCAN提供3个发送邮箱,可以理解为3个发送缓冲槽位;同时提供2个接收FIFO,每个FIFO能缓存3帧报文。

发送时,通过接口把一个报文填入一个空闲邮箱,外设会按顺序发送。如果3个邮箱都满了,再发起发送请求就会返回忙。接收时,经过过滤器的报文会被放入FIFO0或FIFO1。FIFO满了以后,新到的报文不会覆盖旧数据,而是直接丢弃并置位溢出标志。

在实际项目中,如果报文的产生速率比较高,我通常会把高频周期性报文放到高优先级,并保证发送线程有足够间隔留出邮箱空闲时间。否则一旦三个邮箱都被占满,后续请求就会失败,代码需要做好重试。

3.2 过滤器:不是简单的 ID 相等,而是位匹配游戏

bxCAN的过滤器组是CAN外设里最容易迷惑人的地方。过滤器有两种模式,掩码模式和列表模式;位宽可选16位或32位。

列表模式必须完全匹配ID,适合接收固定几个ID的场景。掩码模式则灵活得多:掩码位为1,表示这一位必须匹配;掩码位为0,表示这一位不关心。因此掩码模式能实现“接收ID范围内的一组报文”。

举个例子:想接收0x320~0x323这4个ID,可以把标识符设为0x320,掩码设为0x7FC。0x7FC的低两位是0,表示ID的低两位可以变化;高9位是1,表示高9位必须匹配。这样0x320、0x321、0x322、0x323都能通过过滤。如果想接收所有ID,把掩码全部置0即可,调试时特别好用。

注意:32位模式下的位映射比16位复杂,初学者如果只需要标准ID,建议先用16位位宽。我的做法是,第一步永远先配置成“接收所有ID”跑通链路,等通信正常了再按业务需求收窄过滤范围。这样能排除过滤器配置错误导致“看起来正常却收不到”的尴尬。

3.3 波特率计算:不背公式,自己算一次就懂

CAN外设把一位时间拆成多个时间份,一个称为“Tq”的最小时间单位。每一位由固定同步段、时间段1、时间段2组成,采样点位于同步段加时间段1之后的那一刻。

波特率计算公式很简单:

CAN波特率 = 外设时钟 / (预分频 × (1 + 时间段1 + 时间段2))

以STM32F103为例,系统72MHz时APB1时钟固定为36MHz。目标是500kbps。每个位需要36MHz / 500kbps = 72个时钟周期。如果预分频取4,每个Tq是4个时钟周期,那么一个位需要18个Tq。于是 1 + 时间段1 + 时间段2 = 18。取时间段1为13,时间段2为4,得到采样点 = (1 + 13) / 18 ≈ 77.8%。这在许多参考设计里都是一个常用位置。

对应的HAL配置:

hcan1.Init.Prescaler = 4; hcan1.Init.TimeSeg1 = CAN_BS1_13TQ; hcan1.Init.TimeSeg2 = CAN_BS2_4TQ;

最终波特率 = 36MHz / (4 × 18) = 500kbps。写代码前先拿纸算一遍,而不是直接从网上抄别人的分频参数,这个习惯能帮你绕开很多时钟树上的坑。

4. 基于 HAL 库的第一个 CAN 程序:初始化、发送、接收

4.1 动手写代码前先想清楚三件事

第一,波特率多少,所有节点都必须一致。第二,当前节点用哪些ID发送业务报文,用哪些ID接收其他节点报文。第三,过滤器是“全收”还是“只收固定ID”。把这些定下来再进CubeMX,不然配置界面里填什么都是蒙的。

我在项目文档里通常会定一张表:设备A发送ID 0x001状态帧,接收ID 0x101指令帧;设备B发送ID 0x101状态帧,接收ID 0x001指令帧。测试阶段统一波特率500kbps。这样后面联调时,一眼就能看出是哪边发错ID。

4.2 CubeMX 配置要点

在CubeMX左侧外设列表里选择CAN1,模式选Normal。调试时可以先选Loopback模式,后面说理由。点击Parameter Settings配置波特率相关参数,填入上面的预设值。时钟配置确保APB1外设时钟是你计算时用的值,比如F103系统72MHz时APB1是36MHz;如果CubeMX生成代码后发现时钟树里去掉了HSE,波特率会跟着错。

引脚方面,F103的CAN1默认是PA11接收、PA12发送。外部需要再接一个CAN收发器芯片,负责把TTL电平转成CAN_H/CAN_L差分信号。另外记得在NVIC里打开CAN1_RX0中断,需要监控错误的话也把CAN1_SCE中断打开。

4.3 初始化与过滤器代码

HAL库生成代码之后,核心的初始化函数大概是这样的:

CAN_HandleTypeDef hcan1; void MX_CAN1_Init(void) { hcan1.Instance = CAN1; hcan1.Init.Prescaler = 4; hcan1.Init.Mode = CAN_MODE_NORMAL; 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.AutoRetry = ENABLE; hcan1.Init.ReceiveFifoLocked = DISABLE; hcan1.Init.TransmitFifoPriority = DISABLE; if (HAL_CAN_Init(&hcan1) != HAL_OK) { Error_Handler(); } }

过滤器配成“接收所有ID”:

void CAN_Filter_Config(void) { CAN_FilterTypeDef sFilterConfig; 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 = CAN_FILTER_ENABLE; if (HAL_CAN_ConfigFilter(&hcan1, &sFilterConfig) != HAL_OK) { Error_Handler(); } }

掩码全0意味着“任何ID都接收”。这段先用,等通信真正通了再改固定ID过滤。如果一开始就配死ID,结果线序反了或者ID规划错位,排查难度会大大增加。

4.4 发送一帧报文:代码不多,但返回值一定要看

发送接口的HAL库代码:

CAN_TxHeaderTypeDef TxHeader; uint8_t TxData[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; uint32_t Mailbox; TxHeader.StdId = 0x001; TxHeader.ExtId = 0; TxHeader.IDE = CAN_ID_STD; TxHeader.RTR = CAN_RTR_DATA; TxHeader.DLC = 8; if (HAL_CAN_AddTxMessage(&hcan1, &TxHeader, TxData, &Mailbox) != HAL_OK) { // 可以用 hcan1.ErrorCode 判断原因 }

HAL_CAN_AddTxMessage只负责把报文压入发送邮箱并触发发送请求,不保证已经发完。如果需要确认发送完成,可以查询空闲邮箱数量HAL_CAN_GetTxMailboxesFreeLevel,或者开启发送邮箱空中断。

新手最容易遇到的两个问题:一是没有调用HAL_CAN_Start就开始发送,此时外设还没进入正常模式,AddTxMessage直接返回错误;二是总线上只有当前节点,没有接收节点响应ACK,导致发送失败并出现ACK错误。所以发送之前先确认外设已启动,接收端也在总线上。

4.5 中断接收:从硬件中断到回调,完整链路

在中断优先级配置里打开CAN1_RX0中断,然后实现中断服务函数:

void CAN1_RX0_IRQHandler(void) { HAL_CAN_IRQHandler(&hcan1); }

接下来在HAL库的弱函数中覆盖接收回调:

uint8_t RxData[8]; CAN_RxHeaderTypeDef RxHeader; void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { if (hcan->Instance == CAN1) { HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, RxData); if (RxHeader.StdId == 0x101) { CommandProcess(RxData, RxHeader.DLC); } } }

中断回调里尽量只做拷贝和置标志,不要调用HAL_Delay,也不要做复杂协议解析。我见过有人把业务逻辑全塞进回调,报文一密集,FIFO溢出丢帧,然后开始怀疑CAN可靠性,其实是被自己在中断里干的重活拖垮了。

启动顺序建议这样:

HAL_CAN_Start(&hcan1); HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING | CAN_IT_ERROR);

先启动外设,再打开中断上报。如果先开中断再启动,有的HAL库版本也能工作,但统一先Start更稳定。

4.6 Loopback 模式:不接外部节点也能自测整条收发链路

把CubeMX里CAN模式改成Loopback,然后发送一帧,如果本机接收回调能拿到自己发出的帧,说明MCU内部发送、过滤器、接收、中断整条链路是通的。这个模式下不需要外部收发器,非常适合在没有对端设备时验证代码。

我第一次调CAN时,因为网络里只有一个节点,Normal模式下自测死活报ACK错误,后来才意识到这是正常现象——没有其他节点回ACK。但用Loopback就能把“自身代码bug”和“外部网络问题”分开。联调前先跑Loopback,能省掉大半的查询时间。

5. 联调排查九条实操经验:从怀疑人生到豁然开朗

5.1 第一件事永远不是查代码,而是查物理层

按终端电阻、CAN_H/CAN_L线序、地线共地、波特率设置的顺序排查。很多人项目里用网线延长CAN总线,结果一对网线中收发两根线的颜色与水晶头里不匹配,导致两个节点分别单测都正常,并联后全乱。检查方法很简单:量CAN_H到CAN_L在发送时的电压极性,或者把接头拆开核对线序。

5.2 看波形不是看有没有方波,而是看电平对不对

示波器CH1接CAN_H,CH2接CAN_L,用数学通道CH1减CH2。正常隐性时两线都约2.5V,差压0V;显性时CAN_H约3.5V、CAN_L约1.5V,差压2V。如果波形边沿出现剧烈振铃或电平无法稳定,优先怀疑终端电阻;如果连显性电平都达不到,考虑收发器供电或地电位问题。

没有示波器也可以借助CAN分析仪看错误帧比例。只要错误帧成片出现,先别急着怀疑软件,回头查硬件。

5.3 两个节点先只跑心跳帧,把业务从通信里摘出去

刚联调时,不要直接传完整业务协议。让主控每100ms发一帧ID=0x001的固定数据,从设备收到后回一帧ID=0x002。两边都在串口打印发送计数和接收计数。只要这个小循环稳定跑10分钟以上,再逐步增加报文类型和复杂度。很多“高速丢帧”问题,其实是在业务逻辑已经掺进来的时候就失去了排查锚点。

5.4 学会读 CAN 状态寄存器和 HAL 的 ErrorCode

HAL库的CAN句柄里有一个ErrorCode字段,异常时会保留错误标志。常见值对照:

HAL CAN ErrorCode含义
HAL_CAN_ERROR_ACK发送帧没有被任何节点ACK
HAL_CAN_ERROR_PAR报文参数非法
HAL_CAN_ERROR_EWG错误计数达到警告等级
HAL_CAN_ERROR_EPV节点进入被动错误状态
HAL_CAN_ERROR_BOFF节点进入总线关闭状态
HAL_CAN_ERROR_STF位填充错误
HAL_CAN_ERROR_FOR格式错误
HAL_CAN_ERROR_CRCCRC校验错误

如果发送后一直报ACK错误,可能是总线上只有自己,没有对端ACK。这在联调初期很常见,并不代表代码坏了。如果是CRC错误或填充错误,则要回到物理层看波形。

5.5 过滤器排查四步法

第一步,把掩码改成全0,调成接收所有ID,排除过滤器问题。第二步,用Loopback模式验证收发全链路。第三步,核对标准帧与扩展帧配置,确认收发两端用的都是CAN_ID_STD还是CAN_ID_EXT。第四步,检查回调函数名称是否和HAL库的弱函数名一致,比如HAL_CAN_RxFifo0MsgPendingCallback,拼写错了编译器不报错,但永远不会被调用。

5.6 总线空闲时一直显性:多半有节点在“霸占总线”

如果示波器上CAN_H和CAN_L的差压恒为2V,说明总线一直被某个节点拉在显性。可能是发送端TX引脚被配错,也可能是收发器芯片已损坏。排查时逐个断开节点,每断开一个量一次总线电压。如果从某节点断开后总线恢复隐性,问题就在那个节点。

5.7 长线传输的共地问题经常被低估

CAN虽然是差分协议,但收发器芯片内部需要以本地地作为参考。两个相距几十米的节点,如果电源不隔离,GND之间可能产生几伏甚至更高的压差,超出收发器允许的共模输入范围,即使差分波形看起来“正常”,接收端也解不出数据。遇到长距离通信间歇性失败,优先考虑用隔离CAN收发器,或者在节点间用DC-DC隔离电源。这个问题靠软件完全无法解决。

5.8 波特率误差不只看标称值

两个节点都设500kbps,但一个用内部HSI晶振,一个用外部晶振,实际误差可能差出很远。STM32如果配置成内部RC,温度变化会影响频率,CAN位时间误差会超出协议要求。联调前确认所有节点的时钟源,最好都使用外部晶振。需要定量检查时,可以用示波器测一帧的位宽,反推实际波特率。

5.9 别在中断回调里做重活

这条前面提过,但值得单独占一条。CAN接收中断在报文密集时触发频率很高,回调里只要有一句printf跟着UART阻塞发送,或是一段长时间的CRC软件计算,都可能让CPU来不及清FIFO,导致后续报文溢出丢弃。协议解析和业务处理应该放到主循环或独立任务里,中断只做数据搬移和标志置位。

6. 写在最后的几条个人习惯

调CAN调多了,我慢慢养成了一套固定动作。每次新项目用CAN,先在文档里写下ID规划表、波特率、终端电阻位置;上电第一件事不是发业务数据,而是先跑Loopback验证板级硬件;联调时永远先跑心跳帧,再逐步升级到复杂业务;凡是改了CAN初始化参数,都顺手把hcan.ErrorCode打印出来看一眼。这些动作看起来不起眼,但能帮我快速把问题定位到物理层、协议层还是外设配置层。

也有人问我,CAN总线最难的是什么。其实难点从来不是某个算法,而是这些细碎的基础环节:两个120欧电阻、一根接反的线、一个忘记打开的接收中断、一个偏离太远的采样点。把这一层打好底子,后面再复杂的CAN应用,也无非是在同一套收发框架上按需求扩展。希望这篇能帮你少走几天弯路。

RELATED READING

延伸阅读

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