刚接触STM32的CAN通信时,我最直观的感觉是:串口太简单,CAN才是工业场景里真正耐打的东西。这次不绕弯子,直接拿两块STM32F103最小系统板加两个CAN收发器模块,用CubeMX配合HAL库,从头到尾把双机通信这件事跑通。文章里会把CubeMX的每一项配置、每一行关键代码、排查思路都讲清楚,适合刚入门CAN总线、想用HAL库快速实现通信,或者之前用寄存器写过CAN但想转到CubeMX生态的开发者参考。
1. 项目概述与方案选型
1.1 为什么选STM32F103 + CubeMX + HAL库
市面上关于STM32 CAN通信的教程不少,但大多是寄存器版本,移植性差,换个芯片型号就得重新啃参考手册。选CubeMX加HAL库,核心原因是它的工程生成效率实在太高。时钟树怎么走、外设时钟开没开、中断优先级怎么分,这些事图形化配置一次,代码自动生成,比起对着参考手册翻寄存器位定义,省下的精力可以用来真正理解CAN协议本身。
很多人纠结HAL库和LL库哪个好。我的观点是,对于CAN这种带硬件FIFO和硬件位定时器的外设,HAL库的性能开销完全在可接受范围内。LL库虽然更接近寄存器操作,但代码量会成倍增加,调试起来也更费劲。STM32F103的bxCAN控制器最高支持1Mbps的通信速率,配套的HAL库封装了报文发送、接收、错误管理、滤波器配置等核心操作,对绝大多数工控和车载场景完全够用。
另外一个选型理由是生态。CubeMX生成的工程自带完整的外设初始化框架,后续加个串口打印调试信息、加个定时器做周期发送,都是几分钟的事。这次双机通信做完了,如果你想在这个基础上扩展成多机CAN网络或者CANopen协议栈,HAL库的驱动框架也能直接复用,不用推翻重写。
1.2 硬件清单与双机连接方法
硬件需求不复杂,清单如下:
| 器件 | 型号/规格 | 数量 | 备注 |
|---|---|---|---|
| MCU板 | STM32F103C8T6最小系统板 | 2块 | 带板载LED和按键更佳 |
| CAN收发器 | TJA1050 或 SN65HVD230 模块 | 2个 | 买带120欧姆终端电阻配置的模块 |
| 连接线 | 杜邦线或焊接线 | 若干 | 尽量短,CAN总线建议双绞线 |
| 调试工具 | ST-Link 或 J-Link | 2个 | 单仿真器也可,先烧录再脱机运行 |
| 示波器/逻辑分析仪 | 可选 | 1台 | 排查波形问题用 |
CAN控制器的收发引脚是固定的,STM32F103C8T6的CAN1_TX在PA12,CAN1_RX在PA11。接线分两个层面:MCU与收发器模块之间是一组TTL电平信号,两个模块之间是差分总线。
节点A和节点B各自独立连接:
- PA12 (CAN_TX) 连接收发器模块的TXD
- PA11 (CAN_RX) 连接收发器模块的RXD
- VCC和GND按模块要求供电,TJA1050需要5V供电,SN65HVD230可以用3.3V
两个节点之间的总线连接:
- 节点A的CANH接节点B的CANH
- 节点A的CANL接节点B的CANL
- 两个节点的GND必须共地,否则差分信号的共模电压会漂移,严重时直接烧毁收发器
终端电阻是两个120欧姆,分别并联在总线的物理最两端。如果用的是现成的收发器模块,很多模块上已经焊好了120欧姆电阻并且用跳线帽控制,确认一下即可。两个节点通信时,如果总线上只有一个120欧姆电阻在中间位置,反射问题会导致波形畸变,短距离低速可能侥幸能通,但实际项目里千万别这么干。
1.3 CAN总线基础:一帧数据长什么样
CAN和串口最大的区别是,CAN总线上所有节点共享一对差分线,没有主机从机之分,谁想发就发,靠标识符(ID)决定优先级,靠滤波决定谁接收。
一帧标准CAN数据帧由这些部分组成:
- SOF:帧起始,一个显性位,标志一帧开始
- 仲裁场:11位标识符加RTR位,这个ID就是优先级,数值越小优先级越高
- 控制场:IDE位、DLC数据长度代码,表示后面跟几个数据字节
- 数据场:0到8个字节,这就是我们传输的业务数据
- CRC场:循环冗余校验,接收方用它判断数据是否被干扰
- ACK场:发送节点在ACK槽发送显性位,任何接收正常的节点会在此处回一个显性位进行应答。这个机制很关键,它决定了单节点在总线上无法正常发送
- EOF和IFS:帧结束和帧间隔
这里最值得强调的是ACK机制。每当你往CAN总线上发一帧数据,发送节点的收发器会在ACK槽等待总线上出现显性电平。如果总线上没有任何其他节点正常接收,ACK槽就会一直是隐性电平,发送节点就会判定发送失败,重试到一定次数后报错。这意味着哪怕你只是测试发送功能,也必须在总线上挂至少一个能正常接收的节点。
数据长度最多8字节,这在看惯了串口大包传输的场景里会觉得不够用。但实际工业协议像CANopen的PDO、J1939都是基于8字节载荷设计的,对于状态量、控制量、传感器数值这类小数据包,8字节绰绰有余。如果确实要传更长的数据,可以自己去定义多帧组包协议,那是后话。
2. CubeMX工程配置全流程
2.1 时钟树与CAN挂载总线分析
CAN外设挂在APB1总线上,这一点在配置时钟树时特别容易忽略。很多人习惯把APB1分频设成4倍,即72MHz系统时钟下APB1只有36MHz,而CAN的位定时器就是从这个36MHz时钟派生出来的。波特率配置时如果用错时钟源数值,计算出的波特率会差整整一倍。
我这次采用的时钟配置是:
- 外部8MHz晶振(HSE)作为时钟源
- PLL倍频9倍,系统时钟72MHz
- APB1分频系数2,APB1外设时钟36MHz
- APB1定时器时钟倍频到72MHz
在CubeMX的Clock Configuration页面操作时,把HCLK输入72,软件会自动算好各总线分频。注意CAN外设的时钟源是APB1外设时钟,也就是36MHz,不是72MHz。记住这个数字,下面算波特率全靠它。
顺带提一句,工程生成时在Project Manager页面里把"Generate peripheral initialization as a pair of .c/.h files per peripheral"勾上,这样每个外设生成独立的c和h文件,代码结构清晰很多。后续加外设时也不会所有代码都堆在main.c里。
2.2 CAN外设参数配置与波特率计算方法
在CubeMX的Connectivity里找到CAN1,勾选Activate后进入参数配置。CAN的位时序由三段组成:
- 同步段(Sync Segment):固定为1个时间量子(Time Quantum,简称Tq)
- 时间段1(BS1):包含传播段和相位缓冲段1
- 时间段2(BS2):相位缓冲段2
波特率计算公式是: 波特率 = APB1时钟 / (预分频值 × (1 + BS1 + BS2))
CubeMX里对应的参数名分别是Prescaler、Time Quanta in Bit Segment 1、Time Quantum in Bit Segment 2。这里的1就是同步段默认占用的那一个Tq。
我要配一个最常见的500kbps,APB1是36MHz。反推: 36MHz / 500kbps = 72个Tq,这72个Tq要分给预分频值和位时序。一种常见的分配方式是预分频6,位时序总共12个Tq,即BS1取7,BS2取4,因为1 + 7 + 4 = 12,6 × 12 = 72,正好500kHz。
CubeMX界面上按此填写:
- Prescaler (for Time Quantum):6
- Time Quanta in Bit Segment 1:7
- Time Quanta in Bit Segment 2:4
- ReSynchronization Jump Width:3
ReSynchronization Jump Width是重同步跳转宽度,一般取BS1和BS2中较小值的约1/4,这里取3。采样点位置为(1 + 7) / (1 + 7 + 4) ≈ 66.7%,这个采样点对于500kbps是比较推荐的范围。
如果你要跑1Mbps,则总Tq数变成36,预分频3加位时序12即可;跑250kbps则可以预分频12加位时序12。关键在于让总Tq数配合预分频落在合理范围。注意BS1范围是1到16,BS2范围是1到8,超出要调整组合。
2.3 滤波器和中断的配置
CAN的接收滤波器是一个很容易栽跟头的地方。STM32F103的bxCAN有28个滤波器组,每个滤波器组可以配置成屏蔽模式或者列表模式,可以关联到FIFO0或FIFO1。
屏蔽模式的意思是:掩码位为1的位必须精确匹配,掩码位为0的位不关心。如果掩码全部为0,那这个滤波器就是接受所有报文,这对调试阶段最友好。列表模式则是精确匹配,掩码位用不到,ID必须完全一致才能通过。
这次双机通信的调试阶段,我建议两个节点都先用"接收所有帧"的配置,也就是掩码全0。先把链路调通,再根据项目需求收紧滤波规则。滤波器配置CubeMX没有图形化界面,需要在代码里手动写。
中断方面,CAN接收有两种方式处理:轮询加中断。由于报文到达是异步的,轮询会浪费CPU,尤其在多节点、高负载的场合,所以推荐用中断。CubeMX中在CAN1的NVIC Settings里使能CAN RX0 interrupt,也就是FIFO0消息挂起中断。发送完成中断在双机场景可以不开,发送时通过函数返回值判断结果就够用了。
关于中断接收和DMA接收的取舍,这里多说一句。STM32F103的bxCAN接收FIFO本身就是硬件缓冲,每个FIFO深度3帧,加上HAL库的中断回调机制,处理一个8字节的报文只在中断里待几微秒。对绝大多数场景来说,中断接收完全够用。DMA更适合串口那种高频大流量数据流形态,CAN报文本身小而离散,强行上DMA收益不大,还白费很多配置精力。
3. HAL库双机通信代码实现
3.1 CAN初始化与启动流程
CubeMX生成的CAN初始化代码主要做了两件事:调用HAL_CAN_Init配置位时序,调用HAL_CAN_MspInit配置GPIO复用和时钟。MspInit里PA11、PA12被配置成复用推挽输出和复用输入,这些代码框架自动生成,不用动。
自己需要补的代码有两段。第一段是滤波器配置,要放在HAL_CAN_Init之后。第二段是启动CAN外设和使能接收中断,我习惯放在main函数里MX_CAN1_Init之后,用一个单独的CAN_User_Init函数管理。
滤波器配置代码如下:
void CAN_Filter_Config(void) { CAN_FilterTypeDef can_filter_config; can_filter_config.FilterActivation = ENABLE; can_filter_config.FilterBank = 0; can_filter_config.FilterMode = CAN_FILTERMODE_IDMASK; can_filter_config.FilterScale = CAN_FILTERSCALE_32BIT; can_filter_config.FilterIdHigh = 0; can_filter_config.FilterIdLow = 0; can_filter_config.FilterMaskIdHigh = 0; can_filter_config.FilterMaskIdLow = 0; can_filter_config.FilterFIFOAssignment = CAN_RX_FIFO0; HAL_CAN_ConfigFilter(&hcan, &can_filter_config); }FilterBank写0到27任意值都可以,前提是多个CAN外设之间不要冲突。FilterFIFOAssignment绑定到FIFO0,这样接收中断就只关心FIFO0的回调。掩码全0,所有ID都能进。
启动和使能中断的代码:
void CAN_User_Init(void) { HAL_CAN_Start(&hcan); HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING); }这里有个顺序问题要强调:必须先HAL_CAN_Start再ActivateNotification。虽然反过来也能跑,但先注册通知再启动外设,存在一种极端时序,中断在外设启动的瞬间触发但通知还没完全准备好,可能丢第一帧报文。
配置完成后,可以用HAL_CAN_IsTxMessagePending查询发送状态,或者直接用HAL_CAN_GetTxMailboxesFreeLevel看一下邮箱余量。调试时我会把这两个信息通过串口打印出来,非常直观。
3.2 发送端代码:从数据到报文
HAL库发送CAN报文的核心函数是HAL_CAN_AddTxMessage,签名如下:
HAL_StatusTypeDef HAL_CAN_AddTxMessage(CAN_HandleTypeDef *hcan, CAN_TxHeaderTypeDef *pHeader, uint8_t aData[], uint32_t *pTxMailbox);发送前要填充CAN_TxHeaderTypeDef结构体。以下是我在一个按键触发发送示例中的完整代码:
CAN_TxHeaderTypeDef TxHeader; uint8_t TxData[8] = {0}; uint32_t TxMailbox; TxHeader.StdId = 0x01; // 标准帧ID TxHeader.ExtId = 0; // 扩展帧ID用不到,置0 TxHeader.IDE = CAN_ID_STD; // 使用标准帧 TxHeader.RTR = CAN_RTR_DATA; // 数据帧 TxHeader.DLC = 8; // 发送8字节 TxData[0] = 0xAA; TxData[1] = send_counter++; // 其他字节自行填充 if (HAL_CAN_AddTxMessage(&hcan, &TxHeader, TxData, &TxMailbox) != HAL_OK) { Error_Handler(); }StdId我设为0x01是作为这台节点的身份标识。另一个节点发回来的数据,ID用0x02,这样每台设备发出去的数据,接收方看一眼ID就知道是谁发的。
关于IDE和RTR:IDE决定用标准帧还是扩展帧,扩展帧多出18位ID,总线上本来资源就紧张,没必要用它。RTR通常用数据帧,远程帧在实际应用中使用较少,至少双机通信这个阶段完全不需要。
HAL_CAN_AddTxMessage的返回值值得仔细看。返回HAL_OK表示报文已经进入邮箱等待发送,并不代表总线上发送成功。真正的发送结果要看总线是否产生错误。如果总线上只有发送节点这一个节点,ACK没人回应,这个函数可能反复重发然后返回HAL_ERROR,底层错误码是HAL_CAN_ERROR_ACK。
发送端还有一个经验之谈:不要在主循环里无延时地持续猛发报文,尤其是用阻塞方式调用HAL_CAN_AddTxMessage时,邮箱满了会一直等待。我一般用定时器做周期发送,或者用按键做事件触发,这样既方便观察,又不会阻塞主循环。
3.3 接收端代码:中断回调与数据处理
CAN接收中断使能后,当FIFO0收到一帧报文,HAL库会回调HAL_CAN_RxFifo0MsgPendingCallback。需要在自己的用户代码里重写这个函数。
接收报文用HAL_CAN_GetRxMessage,它会把报文从FIFO取出来放入我们提供的结构体和数组:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef RxHeader; uint8_t RxData[8]; if (hcan->Instance == CAN1) { HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, RxData); if (RxHeader.StdId == 0x01) { received_counter = RxData[1]; HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } } }这里有几个细节需要特别注意。
第一,RxFF0MsgPendingCallback是弱函数,CubeMX生成的main.c里没有默认实现,你在哪个c文件里写都可以,只要函数名正确,链接时就会覆盖弱定义。我习惯在单独的user_can.c里维护所有CAN相关回调,这样工程结构清爽。
第二,GetRxMessage调用后,FIFO就会释放一个新空位。所以这个函数必须在中断回调里及时调用,否则FIFO存满3帧后,后续报文会被硬件丢弃,而且不会产生新的中断通知。
第三,中断回调里定义的RxData是栈上的局部变量,数据处理完就失效。如果只是用一下这个数据(比如翻转LED、比较某个值),没问题。但如果你想跨函数保存数据,必须用全局数组或者结构体拷贝一份,否则出了回调数据就没了。这个坑很多新手踩过。
第四,回调函数里尽量不做耗时操作,比如串口打印大量日志、调用HAL_Delay这些统统避免。CAN报文进来时,中断上下文中不允许被其他同优先级或低优先级中断打扰太久。
如果需要区分不同ID的报文走不同处理逻辑,可以用switch或者多个if判断RxHeader.StdId。多机场景下这个ID分发逻辑会扩展成一张路由表,但双机阶段简单判断即可。
3.4 双机交互逻辑设计
双机通信不只是把数据发出去就算完事,而是要有来有往,形成一个可验证的闭环。我设计了一个简单但完整的交互逻辑:
- 节点A(ID 0x01):按键PA0触发发送,发送8字节,其中第2字节是递增计数器
- 节点B(ID 0x02):当收到ID为0x01的数据帧,翻转板载LED,然后原样回发一帧同样的数据,ID改为0x02
- 节点A收到ID为0x02的回复帧后,把接收计数器的值通过串口打印出来
这个逻辑的好处是:
- 按键触发发送,可以人为控制通信节奏,方便观察
- 按键每按下一次,节点B的LED就应该翻转一次,这是肉眼可见的证据
- 节点A串口打印的接收计数,验证了双向链路都是通的
- 如果A能发出去但收不到回复,大概率是B的接收或发送有问题,问题定位范围一下就缩小了
节点B的接收回调代码如下:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef RxHeader; uint8_t RxData[8]; uint32_t TxMailbox; if (hcan->Instance == CAN1) { HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, RxData); if (RxHeader.StdId == 0x01) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); TxHeader.StdId = 0x02; TxHeader.IDE = CAN_ID_STD; TxHeader.RTR = CAN_RTR_DATA; TxHeader.DLC = 8; HAL_CAN_AddTxMessage(hcan, &TxHeader, RxData, &TxMailbox); } } }回发时我没有重新填充TxData,直接把接收缓冲区的RxData作为发送数据用,因为数据格式一样,这也体现了8字节载荷的拷贝成本很低。
如果要进一步加上接收确认机制,可以在节点A收到回复后,把接收到的计数器和本地发送计数器做差值比较,如果对不上就说明链路丢帧。这个验证方法可以留着,等你想测CAN总线在干扰环境下的稳定性时再用。
4. 联调验证、常见问题与扩展思考
4.1 上电联调:如何判断通信是否建立
硬件连接完成后,先别急着烧代码。第一步做静态检查:万用表量一下CANH和CANL之间的电阻,在总线上所有节点都断电时,理论上应该测到60欧姆左右。因为两个120欧姆终端电阻并联,这是最直接的判断总线连接是否正确的方法。如果测到120欧姆,说明只有一端接了终端电阻;如果测到无穷大,说明两端都没接或者总线断线。
第二步是烧录后观察现象。按节点A的按键,观察节点B的LED是否翻转。理论上按一次,节点B上的LED就应该闪一次。如果你的收发器模块上带总线活动指示灯,能看到CAN总线上的波形活动。
第三步用串口看数据。把节点A的PA9(USART1_TX)接USB转TTL,打印发送和接收到的计数器值。看到数值递增,说明完整链路正常。
如果一切正常,你将会看到的现象有:
- 按A按键,A串口打印"Send: 3",过几十毫秒打印"Recv: 3"
- B的LED随每次按键翻转一次
- 两个节点的计数器数值一致,表明确实是同一帧数据被原样送回
有条件的可以上示波器看CANH和CANL之间的差分波形。空闲时CANH和CANL都是2.5V,差分电压为0;显性位时CANH拉到3.5V,CANL拉到1.5V,差分约2V。这个波形一出,整个链路的状态就一览无余了。
4.2 常见问题排查速查表
排查问题时,保持一个信念:CAN总线的问题大多是物理层问题和配置问题,逻辑问题反而很少。我从实际调试中整理了一些高频问题,做成速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 发送函数返回HAL_ERROR,错误码为ACK | 总线上只有发送节点一个节点,无应答 | 确认另一个节点已正确烧录并接入总线 |
| 发送函数一直超时 | 邮箱满,之前发送未完成 | 检查是否有节点应答;断电重启 |
| 接收中断从不触发 | 滤波器配置错误或未使能中断 | 检查FilterMaskId是否全0,检查NVIC是否使能 |
| 能接收但ID不对 | 标准帧扩展帧设置不一致 | 确认收发两端IDE字段一致 |
| 总线波形异常畸变 | 缺少终端电阻或共地不好 | 测CANH-CANL电阻,确认共地 |
| 收发器芯片发烫 | 供电电压错误 | TJA1050用5V,SN65HVD230用3.3V |
| 两个节点都在总线却收不到 | 波特率不一致 | 用逻辑分析仪抓波形数位宽 |
还有一类典型问题是CubeMX配置里CAN引脚被复用成其他功能。比如你同时开了USB、SPI或者调试串口,PA11和PA12可能被其他外设占据,导致CAN引脚在MspInit里没有被正确配置成CAN复用功能。这时可以检查一下GPIO_InitStruct的Alternate参数是否正确。
关于波特率不一致的排查,我这里分享一个土办法。用逻辑分析仪抓到CAN_TX引脚发送端的波形,数一个位的宽度。500kbps的位宽是2微秒,250kbps是4微秒。数出来多少微秒,波特率就是1除以位宽。这个方法比看代码配置直观多了。
4.3 从双机到多机:项目进阶方向与避坑
双机通信跑通后,往多机扩展是自然而然的方向。CAN总线理论上最多支持110个节点,但实际受限于收发器驱动能力和总线电容,推荐节点数在32个以内比较稳妥。
多机场景下,ID规划和滤波策略变得更加重要。我给一个建议方案。把ID划分成几个区段:0x01到0x0F用于主站到从站的控制指令,从站ID不同对应不同设备;0x10到0x1F用于从站上报状态给主站;0x20到0x2F用于设备间点对点通信。每个从站的滤波器配置成只接收和自己的ID或者广播ID匹配的帧,可以大幅降低主站的接收负载。
滤波器配置成列表模式时,FilterIdHigh和FilterIdLow按位拆分。32位滤波器后,标准帧ID占前11位,RTR位占第12位,其他位可以设置成0。直接给一段示例代码:
can_filter_config.FilterMode = CAN_FILTERMODE_IDLIST; can_filter_config.FilterScale = CAN_FILTERSCALE_32BIT; // 列表模式ID匹配,0x111(标准帧) can_filter_config.FilterIdHigh = (0x111 << 5); can_filter_config.FilterIdLow = 0x0000; can_filter_config.FilterMaskIdHigh = (0x222 << 5); can_filter_config.FilterMaskIdLow = 0x0000;这种配置对新手不太友好,建议先用掩码全0跑通功能,再升级成列表模式做精准过滤。
多机场景还有一个必须考虑的问题:总线负载。负载率 = 实际传输的位时间 / 可用总时间。一帧标准CAN数据帧在500kbps下大约占222微秒。假设你要用20ms周期发送10帧报文,总占用时间大约是222微秒×10等于2.22毫秒,负载率就是2.22/20,约11.1%。对于总线设计,负载率建议控制在50%以下,超过70%就要警惕错误帧和丢失报文。
另外,如果你使用的场景对实时性要求高,建议打开CAN的硬件自动重传不影响,但要做好错误处理。HAL库提供了HAL_CAN_ErrorCallback回调,可以在总线错误时捕获错误码。实际项目中这些错误信息最好能记录下来,方便后续分析总线健康度。
最后说一个调试技巧。如果调不通,不要一直加打印信息看现象。先用逻辑分析仪抓CAN_TX引脚,看发送端有没有波形。有波形但没接收,问题在后级;连波形都没有,问题在前期配置或者引脚复用。这个排查思路能帮你把问题范围快速缩小到具体的分层,避免在错误的方向上反复猜测。
一些额外的收尾体会
双机CAN通信做到这里,其实已经把一个工业级现场总线从应用层到物理层完整过了一遍。我自己做过不少CAN相关的项目,最大的体会是:CAN总线的强大不在于它单帧能传多少数据,而在于它的可靠性和确定性。搞清楚波特率怎么算、ID怎么规划、滤波器怎么配、错误怎么排查这四件事,万变不离其宗。
最后再分享一个小技巧。调试阶段,如果两个节点需要同时连接调试器,但又只有一个电脑,最好先把两个程序都烧录好,然后断开烧录器,用外接电源给板子供电跑脱机。因为调试器接地和CAN总线共地有时候会形成地环路,导致偶发通信异常,排查起来非常折腾。断开调试器用独立供电,这个问题就从根上避免了。