简介:本资源是一套面向STM32初学者与嵌入式开发者的CAN总线通信实战工程,聚焦两个STM32F1系列开发板(如MINI板)间的可靠双机通信实现,适用于汽车电子、工业控制等需高抗干扰通信的场景。压缩包含109个文件,涵盖31个头文件(.h)、29个源码文件(.c,含stm32f10x_can.c等核心驱动)、13个编译中间文件(.o/.d)、Keil工程配置(.uvprojx/.uvoptx)、可执行镜像(.axf/.hex)及调试辅助文件(.map/.lst),整体883KB,结构完整,开箱即用。已有1259人学习下载,项目基于HAL库构建,包含CAN初始化、双滤波器配置、标准帧收发、中断响应与错误状态处理等关键模块,代码注释清晰,配合实际硬件可快速验证通信时序与数据一致性,是掌握STM32 CAN底层机制与工程落地的典型参考范例。 之前群里有人问:两片STM32用CAN总线做数据交换,到底怎么搞?我把手头的F103板子翻出来,搭了个最简单的“一收一发”实验,顺带把调试过程中踩的坑都记了下来。这篇内容从CAN协议基础、硬件接法、CubeMX配置、HAL库收发代码,一直写到实际调试时的波形分析和问题排查,适合刚接触CAN通信、想用两块STM32板快速跑通总线通信的人。
先说结论:两片STM32之间做CAN通讯,核心就三件事——物理层接线不要接反、波特率两边必须一致、过滤器别把消息全挡掉。绝大多数调不通的案例,最后都能归到这三类问题上。
1. 项目概述与整体设计思路
1.1 为什么用CAN而不是串口或I2C
很多人在两板通信时第一反应是USART,简单直接。但如果你需要在一条总线上挂多个节点、节点之间对等地互相发数据、而且现场电磁环境还比较恶劣(比如电机旁边、工业控制柜里),串口就不太合适了。串口是一对一的,想多节点就得自己搞分时复用或者额外加控制逻辑;I2C虽然能挂多设备,但抗干扰和传输距离又比较弱。CAN总线当初就是为汽车环境设计的,差分信号、双绞线传输、多主仲裁、错误检测与重发机制,这些特性决定了它在工业控制和车载场景里几乎是默认选择。
用两片STM32做CAN通讯,本质上是在验证三个能力:第一,CAN控制器本身怎么配置和驱动;第二,收发器(Transceiver)和物理层接线怎么处理;第三,应用层怎么处理帧的发送、接收和错误状态。这三个能力打通之后,往上面加协议、加节点、加CAN FD,都是顺水推舟的事。
1.2 方案选型:F103 + TJA1050 + STM32CubeMX
硬件上的选型我建议用最常见的组合:STM32F103C8T6(或者F103RCT6)加一颗TJA1050收发器。F103系列自带bxCAN外设,支持CAN协议2.0A和2.0B,标准帧扩展帧都能处理。TJA1050是目前最常用的CAN收发器芯片之一,3.3V或者5V供电都能配合使用,把STM32 CAN控制器的TTL电平转成总线上的差分电平。
软件方面,我强烈建议用STM32CubeMX生成初始化代码,配上HAL库,用C语言写业务逻辑。原因很简单:CubeMX里CAN的波特率、工作模式、过滤器这些参数都有图形化界面,配置完直接生成代码,不太容易因为寄存器操作写错位导致低级错误。当然,如果习惯寄存器操作,直接操作bxCAN寄存器也完全可以,但调试成本会高一些。至于标题里提到的C++,说实话在STM32裸机环境下用纯C更省心,但如果你打算上RTOS(比如FreeRTOS)或者写一套可复用的CAN驱动层,用C++做封装是可行的,后面我会单独说。
1.3 通信模型的确定:一收一发带反馈
我给这次实验定的通信模型很简单:节点A作为发送端,按键按下后向总线发送一帧数据;节点B作为接收端,收到合法帧后翻转板载LED,并立刻向节点A回发一帧“确认帧”。节点A收到确认帧后翻转自己的LED。这样一来,发送、接收、应答三个环节全部验证到位,而且可以直观看到双向通信是否正常。
为什么非要做成“带回执”而不是单纯单向发?因为在调试CAN时,有一个非常典型的坑是“发送端自以为发出去了,但接收端根本没收到”。如果只有单向通信,你很难判断是发送端没发出去,还是接收端没收到,还是总线上压根没有信号。加了回执之后,只要两个板子的LED都在动,说明整条链路是通的;哪个灯不动,问题就缩小到哪一侧。
2. CAN硬件连线的关键细节
2.1 CAN物理层:差分信号与总线电平
CAN总线的物理层使用两根线:CAN_H和CAN_L。它不像串口那样用高低电平表示0和1,而是用两根线之间的电压差来表示。显性电平(逻辑0)时CAN_H被拉到高、CAN_L被拉到低,差分电压大约2V左右;隐性电平(逻辑1)时CAN_H和CAN_L都被偏置到2.5V左右,差分电压接近0V。这种差分结构的好处很明显:外部干扰如果同时作用在两根线上,差分电压基本不受影响,抗干扰能力自然强。
这个特性和调试有什么关系?最大关系就是:CAN_H和CAN_L接反,通信一定失败。因为控制器发出的显性位被反相成了总线上的错误信号,接收端根本解析不出正确数据。很多新手第一次接线,两根线颜色又不区分,插上去发现通信超时,第一反应是查程序,查了半天才发现是杜邦线颜色不一致。这个事情我干过一次之后,再也不敢乱用线色了。
2.2 收发器的作用与供电
STM32的CAN控制器内部是没有差分驱动能力的,它输出的只是普通的TTL电平。要让信号跑到总线上并传100米、抗住共模干扰,就必须外接一颗CAN收发器,比如TJA1050。收发器负责把控制器的“发送数据”引脚(CAN_TX)信号转换成CAN_H/CAN_L上的差分电平,同时把总线上的差分电平转换成控制器能读的“接收数据”引脚(CAN_RX)信号。
TJA1050的VCC一般接5V,CAN_TX和CAN_RX直接连到STM32对应引脚。需要注意,TJA1050是5V逻辑供电的芯片,但它和3.3V的STM32之间通过TX/RX引脚连接通常是兼容的,因为TJA1050的输出在STM32高电平阈值以内,实际使用中问题不大。如果用的是3.3V版本的收发器(比如SN65HVD230),接法类似,但供电电压要注意。
2.3 终端电阻:两个120欧,一个都不能少
CAN总线规范要求在总线两端各接一个120欧姆终端电阻,作用是消除信号反射。很多人做实验的时候只在一端接或者干脆不接,短距离低速(比如桌面上一共二十厘米的线)时可能也能跑通,但在线长、节点多或者波特率较高的情况下,表现就会很不稳定:偶发错误帧、节点莫名其妙进bus-off、CRC错误计数增长。
我做这个实验的习惯是:每个板的CAN_H和CAN_L之间都放一个120欧电阻(有的开发板已经集成),然后用导线把两个板串联起来,这样总线自然就是两端各有一个120欧。注意:电阻一定要接在总线的物理两端,不是接在每个节点旁边就完事了。如果只有两个节点,那两个板子本身就构成总线两端。
2.4 接线表:照着接就不会错
| 节点A(发送端) | 节点B(接收端) | 说明 |
|---|---|---|
| STM32 PA11 (CAN_RX) | —— | 板上连接TJA1050 RXD |
| STM32 PA12 (CAN_TX) | —— | 板上连接TJA1050 TXD |
| CAN_H | CAN_H | 两根总线高压线 |
| CAN_L | CAN_L | 两根总线低压线 |
| GND | GND | 两个板子必须共地 |
| 120欧在A端 | 120欧在B端 | 位于总线物理两端 |
这里最容易被忽略的是“共地”。CAN虽然靠差分信号传输,但收发器的共模范围是有限度的。如果两个板子各自供电、地线又没有连在一起,总线上的共模电压可能会漂移,轻则通信不稳定,重则根本收不到帧。我做实验时两个板子都用USB线接着电脑,以为共地了,结果其中一根USB线只给了电没给地,折腾了半小时。后来老老实实从其中一个板的GND引脚拉一根线到另一个板的GND,问题立刻消失。
3. 软件实现:基于HAL库的CAN收发
3.1 CubeMX配置与滤波器参数说明
在STM32CubeMX里,选择CAN1外设之后只需要填几个参数:波特率相关的预分频器(Prescaler)、时间段1(BS1)、时间段2(BS2),以及工作模式(Loopback或Normal)。Loopback模式是内部回环,主要用于自测;两个板子之间通信必须选Normal模式。
F103的CAN1挂载在APB1总线上,APB1时钟典型值是36MHz。目标波特率设为500kbps,采样点尽量落在75%到80%左右,因为采样点靠后一些,信号在一位的后半段已经足够稳定,抗干扰更好。计算方法如下:
CAN波特率 = APB1时钟 / (Prescaler × (1 + BS1 + BS2))
以APB1 = 36MHz为例,要得到500kbps:
位时间 = 1 / 500k = 2微秒
选择Prescaler = 6,则(1 + BS1 + BS2) = 36MHz × 2us / 6 = 12
取BS1 = 8,BS2 = 3,则采样点 = (1 + BS1) / (1 + BS1 + BS2) = 9 / 12 = 75%,符合推荐范围。
所以CubeMX里填:Prescaler = 6,BS1 = 8,BS2 = 3,波特率会自动显示500kbit/s。如果你用的开发板APB1时钟是别的值,按这个公式自己算一遍,别直接抄网上的参数。我以前就用过一个外部晶振配错导致APB1变成54MHz的工程,波特率参数照搬例程,结果发一帧错误一帧,查了一下午。
Filter(过滤器)配置方面,F103的CAN1有14个过滤器组,每个组可以工作在列表模式或掩码模式。两个板子通信最省事的方式是:发送端用标准帧ID,接收端把过滤器配成掩码模式,让它可以接收某个范围的ID。比如发送端发ID为0x180的标准帧,接收端滤波器组0配成:
- 过滤器模式:Mask mode
- 过滤器ID:0x180
- 掩码:0x7F8
这样凡是ID高11位和0x180匹配的帧都能通过,如果你后续想发0x181、0x182这些扩展ID,也不需要改过滤器。注意标准帧ID只有11位,掩码计算时只关心ID区域,其他位置零即可。
3.2 发送端代码:按键触发发送
这是发送端的主要逻辑。初始化流程里,先启动CAN外设,再启动发送邮箱,按下按键后调用发送函数:
CAN_TxHeaderTypeDef tx_header; uint8_t tx_data[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; uint32_t tx_mailbox; void Send_Can_Frame(void) { tx_header.ExtId = 0; tx_header.IDE = CAN_ID_STD; tx_header.RTR = CAN_RTR_DATA; tx_header.DLC = 8; tx_header.StdId = 0x180; tx_header.TransmitGlobalTime = DISABLE; if (HAL_CAN_AddTxMessage(&hcan1, &tx_header, tx_data, &tx_mailbox) != HAL_OK) { // 发送请求失败,检查CAN外设状态 Error_Handler(); } }这里要注意一点:HAL_CAN_AddTxMessage只是把消息提交到发送邮箱,并不等于已经发出去。你真正确认发送成功,要么用发送完成中断回调HAL_CAN_TxMailbox0CompleteCallback,要么在短时间内查询邮箱状态。很多新手在这个函数返回HAL_OK后以为数据已经上总线了,结果调试发现对方根本没收到,实际上邮箱一直发送失败(比如ACK错误)。所以我在按键任务里加了一个简单状态机:按键置位发送标志,主循环里调用发送函数,然后通过变量记录返回值并打印。
实际贴代码时,发送端主循环里可以这样组织:
while (1) { if (button_pressed) { Send_Can_Frame(); button_pressed = 0; } }3.3 接收端代码:中断回调处理
接收端我的建议是用中断唤醒方式而不是轮询查FIFO。理由很简单:CAN帧到达的时间不确定,轮询会占用主循环大量时间,而且万一在处理其他任务时错过了FIFO溢出窗口,帧就丢了。中断回调的方式不会漏帧,代码也更清晰。
初始化时激活FIFO0消息挂起中断:
HAL_CAN_ActivateNotification(&hcan1, HAL_CAN_RX_FIFO0_MSG_PENDING);然后在回调函数里接收数据:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rx_header; uint8_t rx_data[8]; if (hcan->Instance == CAN1) { HAL_CAN_GetRxMessage(hcan, HAL_CAN_RX_FIFO0, &rx_header, rx_data); if (rx_header.StdId == 0x180) { // 收到发送端的帧,翻转LED,并回发确认帧 HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); Send_Ack_Frame(); } } }注意一点:回调函数的执行上下文是中断。在中断里不要做大量数据处理,尽量只做标志位设置,把真正的处理放到主循环里。为什么?CAN波特率500kbps时,一位2微秒,一帧标准帧大约120位左右,也就是一帧耗时约240微秒。如果中断里耗时太长,下一帧到达时中断还挂着,轻则影响实时性,重则FIFO溢出丢帧。我见过有人在CAN回调里直接做浮点运算和字符串格式化,结果系统卡得不像样。把接收标志置上,主循环再处理业务逻辑,才是稳妥做法。
发送端如果想接收确认帧,同样要配置好过滤器(比如接收0x280),并启动接收中断通知。这样两个板子就形成了一来一回的通路。
3.4 关于C++在CAN驱动中的应用
虽然这个例子完全是C语言写的,但标题里带了C++,我就多说几句。如果你是在FreeRTOS或者RT-Thread这类支持C++的环境上做开发,完全可以用类来封装CAN设备。比如定义CanDevice类,成员方法包括Init()、Send()、RegisterRxCallback(),内部封装HAL句柄和过滤器配置。业务层只需要维护好设备对象和回调函数指针,代码复用性会好些。
但有一点要提醒:在STM32裸机工程里开C++,需要注意启动文件支持、全局构造函数调用这些细节,工具链处理不好会踩很多坑。我的建议是:如果系统复杂度不高,一门C语言足够;如果要做协议栈、多节点管理这类相对复杂的软件架构,才考虑引入C++。两者没有绝对的好坏,只看团队维护能力和项目阶段。
4. 实验调试过程与波形验证
4.1 调试硬件准备
调试CAN通信,我建议至少准备这几样东西:两个STM32开发板、两个TJA1050模块(或者双CAN口开发板)、一个USB转CAN分析仪(比如兼容周立功的CANalyst-II或者国产的PCAN),再加一个简单的数字示波器或者逻辑分析仪。
没有CAN分析仪也能调通,但效率会低很多。分析仪挂在总线上能实时看到总线上每一个帧的ID、数据内容、错误帧计数,能帮你一眼判断出“是协议问题还是硬件问题”。示波器则用来观察CAN_H和CAN_L的差分波形,可以直观判断收发器有没有工作、总线电平正不正常。如果实在没有分析仪,把电脑串口和STM32 USB转串口接起来,用串口打印错误标志,也能凑合排查。
4.2 测试步骤:从单板自测到双板联调
我调试时的顺序是:先单板自测,再双板联调,不要一上来就把两个板子都烧录好,然后望着空气发呆。单板自测的方法很简单,CubeMX里把CAN工作模式选成Loopback,发送一帧,在接收回调里看能不能收到自己发的帧。Loopback模式下,CAN1发出的帧会直接被自己接收过滤器收到,不需要其他节点。如果Loopback能通,说明CAN外设的发送路径和接收路径基本没问题,接下来排查重点就只剩物理层和过滤器了。
双板联调时,我先把发送端程序只发送不接收,接收端程序只接收不发,用分析仪或者串口打印确认发送端帧“上总线”成功,接收端能解析出正确的ID和数据。这一步通过后,再启用回执帧功能。回执帧如果收不到,问题往往出在回执帧的过滤器和发送优先级上,优先级考虑一下ID设置,回执帧的ID可以设得比数据帧高一点(CAN仲裁ID越小优先级越高),避免两个板子同时发数据时低优先级的帧持续被抢占。
4.3 参数计算的验证
假设你要在CAN分析仪上看到500kbps的波特率和正确的帧格式,需要确认几个数值:
- 标准帧ID = 0x180,二进制是001 1000 0000,DLC=8
- 对应数据段:01 02 03 04 05 06 07 08
- 如果你配置了DBE调试或者CAN错误主动,帧尾还会带CRC、ACK slot等,这些是协议自动完成的
用示波器抓CAN_H对GND的波形,会看到一串显性/隐性电平,以2微秒为基本位时间。如果一位的宽度明显偏离2微秒(比如变成了4微秒),多半是预分频或BS1/BS2配置不对。我习惯直接把示波器时间轴调到10us/div,看一个完整帧大约占据多少格:标准帧8字节约为120位 × 2us = 240us,也就是24格。如果波形显示一帧很短很长,基本可以断定波特率参数没生效。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| HAL_CAN_AddTxMessage返回HAL_ERROR | 发送邮箱被占满或CAN总线关闭 | 检查接收端是否正常接入,波特率是否一致 |
| 发送端一直报ACK错误 | 总线上只有自己,没有其他节点应答 | 用分析仪看ACK slot,确认物理层接线 |
| 接收端收不到但发送端说成功 | 过滤器ID/掩码配置错误 | 把过滤器改成接收全部(掩码全0)测试 |
| 有错误帧干扰 | 终端电阻缺失、线太长、共地不良 | 两端接120欧,确认电源地连接 |
| 通信偶尔中断,恢复后正常 | 总线干扰或位时序容限不足 | 优化采样点位置,改用屏蔽双绞线 |
| 两个板子都发数据时相互冲突 | 仲裁机制正常,但优先级不满足业务 | 给小ID数据帧更高优先权,或分时发送 |
4.5 一个典型的排查案例
我在实验过程中遇到过这样一个问题:发送端一直返回HAL_OK,但接收端一个字节都收不到。用CAN分析仪挂上去,发现总线上确实没有帧。这就奇怪了:发送邮箱都空了,为什么总线上没有波形?
后来查HAL库的代码,发现HAL_CAN_AddTxMessage返回HAL_OK并不是指“发送成功”,而是指“成功加入发送邮箱”,什么时候真正发出、有没有发成功,需要看邮箱状态是否转成CAN_TX_MAILBOX_EMPTY。我加了发送完成中断回调,打印发送完成标志,发现根本没进回调。这时候再查物理层,发现TJA1050模块的CAN_TX和CAN_RX两个引脚从STM32侧被板载跳线帽短接成了回环状态,等于控制器发给自己的回环数据,根本没有驱动总线。把跳线帽拔掉,正常接到收发器上,问题解决。
这个案例说明一个道理:调试CAN这类带物理层协议栈的通信,分层排查很重要。先确认控制器侧发送邮箱正常,再确认收发器是否有差分输出,最后确认接收端有没有正确解析,逐层往下推进,比盲目改代码高效得多。
5. 避坑经验与工程化建议
5.1 一定不要忘记的物理层检查项
把CAN通信从“实验室能跑通”升级到“现场不容易出问题”,我认为至少要检查下面几项:
第一,总线两端必须各有一个120欧终端电阻。电阻放中间会导致反射信号在采样点附近来回震荡,位错误率显著升高。第二,CAN_H和CAN_L必须用双绞线,不要用两根平行杜邦线凑合。双绞线能有效抑制共模干扰,现场布线时哪怕多花几分钟,也不要省这一步。第三,所有节点的GND必须连在一起。CAN规范里虽然要求隔离,但大多数低成本实验板没有隔离电路,GND不共地,总线共模电压会漂移,严重时收发器会损坏。
我自己用过最长的一根CAN线是三十多米的屏蔽双绞线,500kbps下跑得很稳,但前提是终端电阻良好、屏蔽层单端接地、波特率采样点设置在80%。如果你的应用会走长线,建议实际测试一下误码率,不要想当然。
5.2 软件架构上的一点建议
在两个板子的实验阶段,代码怎么写都无所谓,但一旦准备上多节点工程,我建议在C语言层面做一层薄薄的驱动封装。比如:
typedef struct { uint8_t node_id; uint32_t base_id; uint16_t filter_mask; void (*rx_callback)(uint32_t id, uint8_t *data, uint8_t len); } CanNode_TypeDef;然后写一套CanNode_Init、CanNode_Send、CanNode_Process接口。这样每个节点只需要维护自己的CanNode_TypeDef实例,应用层不需要关心底层是CAN1还是CAN2,后续换芯片平台时只需要重写底层,上层协议和应用逻辑不用动。如果你非要上C++,这套结构可以平滑改成纯虚接口加派生类,但骨架思路是一致的。
5.3 扩展路径:从双板到多节点和CAN FD
双板CAN通信跑通后,值得做的扩展方向有三个。最简单的是增加节点数量,三个四个板子并联在总线上,每个板子分配一组ID范围,测试仲裁机制是否按预期工作。再进一步是自定义应用层协议,比如仿照J1939那样的“源地址+PGN”模式,或者参考CANopen的PDO/SDO模型,把数据帧和命令帧区分开。如果板子支持CAN FD(比如F103不行,但G0/G4系列支持),也可以体验一下64字节数据和更高波特率带来的吞吐量提升。
不过我要泼一盆冷水:CAN FD虽然好,但需要总线上所有节点都支持FD格式。混合组网时,FD帧和普通CAN帧的兼容处理是个大话题,别一上来就全换。先把基础标准帧玩明白,再谈演进。
5.4 给新手的一句话总结
我在这个双板CAN通讯项目里最大的体会就是:CAN并不复杂,真正容易出问题的地方往往在软件之外。协议本身是可靠的,你只要把波特率算对、过滤器配对、物理层接对,它就像一个很听话的管道。反过来,如果这三个环节有一个出错,现象就会表现为各种“玄学问题”:有时能发有时不能发、距离一远就不行、干扰一来就断。别慌,按物理层、数据链路层、应用层逐层拆,问题一定能定位到。
最后再分享一个实用小技巧:在你第一次调CAN代码的时候,别急着写业务逻辑。先用CubeMX生成一个空工程,打开Loopback模式,跑一个1秒周期发送、接收回调里翻转LED的测试程序。如果LED闪起来,说明CAN外设这一条链路是通的。这个测试程序不要删,以后怀疑问题出在CAN时,先烧回去验证硬件,能帮你快速区分是硬件还是业务代码的问题。
本文还有配套的精品资源,点击获取