简介:一套基于STM32F105微控制器的CAN总线通信例程,核心设计为CAN1接口负责接收总线报文,CAN2接口负责发送数据,非常适合嵌入式入门开发者学习,也可为工业自动化、汽车电子或物联网节点间的可靠通信提供参考。压缩包共收录201个文件,整体大小仅2.12MB,以C语言源文件和H头文件为主,同时包含工程配置、编译链接产物、备份文件及少量文本说明,属于典型的标准外设库工程,可直接导入开发环境查看和重新编译。目前已有984人学习下载。代码涵盖CAN控制器初始化、接收及发送邮箱管理、报文滤波器设置、中断服务程序设计、发送错误与重传处理等关键环节,并附带可烧录验证的十六进制文件与调试映射文件,便于边看边调。通过该例程可以深入理解双CAN接口如何独立配置位时钟与波特率并保持同步,掌握多主站总线中仲裁、错误检测与实时收发的完整流程,也能学会在中断与轮询机制之间做出合理选择,是一份兼具教学价值与实用价值的参考资料。
1. 双CAN例程:看似简单,实则考验硬件分配的STM32F105工程
用STM32F105做CAN1接收、CAN2发送,听起来就是把两个CAN控制器初始化一遍的简单工程。实际拆开代码之后你会发现,这类例程的难点根本不在收发本身,而在F105双CAN外设的共享资源分配:28组筛选器怎么切给两个控制器、两个CAN的中断向量为什么要分开使能、以及为什么网上那么多F103单CAN示例一移植过来就翻车。这份工程正好把最小可运行的收发骨架搭好了,没用协议栈也不做复杂路由,非常适合刚接触双CAN的开发者拿来打底,也是之后做CAN中继器或网关的一个干净起点。
2. 双CAN硬件架构:F105的CAN2为什么不能脱离CAN1单独使用
2.1 时钟、复位与中断向量之间的关系
无论从哪里拿到这个工程,第一件事不是去看main.c里的业务逻辑,而是先翻启动文件,把与CAN有关的向量名全部列出来。F105属于互联型产品,它的中断向量和F103不完全一样,尤其是CAN2相关的向量在F103上根本没有。与这个工程直接相关的向量通常是这几个:CAN1发送共用USB_HP_CAN1_TX_IRQHandler、CAN1接收0共用USB_LP_CAN1_RX0_IRQHandler,以及CAN1_RX1_IRQHandler、CAN1_SCE_IRQHandler。CAN2侧还有一组独立的接收、发送、错误向量。注意CAN1的TX和RX0向量与USB功能是共用的,如果你的工程里同时开启了USB设备,要考虑向量冲突,这是F105项目里常见的隐藏问题。
时钟上,CAN1和CAN2都挂在APB1总线上,但使能位分开,代码里通常这样写:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1 | RCC_APB1Periph_CAN2, ENABLE);很多人在这个位置只写了CAN2,以为CAN2是独立的外设,结果后面初始化CAN2时寄存器完全写不进去。把CAN1的时钟一并打开,是F105双CAN驱动的第一个约定。实际项目中,我一般把CAN1的接收中断作为唯一的数据入口,在中断里做转发到CAN2,这样两个CAN的中断向量不用都做复杂处理;但如果后续要扩展CAN2的接收,就必须单独配置CAN2对应的中断通道,不能只在CAN1的ISR里顺带处理,否则NVIC里根本没使能CAN2的向量,标志位来了也进不了中断。
2.2 筛选器组的分配逻辑:28组滤波器如何切给两个CAN
F105的bxCAN提供了28组筛选器,这28组由CAN1和CAN2共享。共享的含义是:并不存在物理上“CAN1专属筛选器”或“CAN2专属筛选器”这种隔离,而是通过配置把某一组筛选器关联到CAN1或CAN2的FIFO。在标准外设库的CAN_FilterInit函数里,决定归属的核心参数有四个:CAN_FilterNumber、CAN_FilterFIFOAssignment、CAN_FilterMode、CAN_FilterScale。其中CAN_FilterFIFOAssignment不仅决定进入哪个FIFO,还决定这一组筛选器挂在哪个CAN名下。
典型分配做法是:低编号组(0~13)给CAN1,高编号组(14~27)给CAN2,两组之间不要重叠。在“CAN1收、CAN2发”的场景里,CAN2没有接收需求,理论上不需要给CAN2单独分配筛选器;但如果你怕后面调试麻烦,也可以预留一组给CAN2并保持激活状态。这里还有一个寄存器级细节:CAN_FMR里有一个筛选器初始化位,只有当所有CAN都处于软件初始化状态时,筛选器寄存器才能被正常改写。如果忘记了把CAN1退出发送模式,筛选器可能写不进,现象就是初始化代码执行成功了,但接收就是不通。
关于筛选器模式,掩码模式适合放行一簇ID,列表模式适合精确匹配单个ID。这个工程用掩码模式把掩码设为全0,效果等于接收总线上的全部帧,转发时再根据业务决定丢弃哪些,走的是一个典型的“接收网关”思路。做二次开发时,只需要把掩码改成目标ID,就成了一个单ID接收的节点。
2.3 波特率计算:F103迁移到F105最先踩的坑
双CAN工程的波特率参数比单CAN工程更容易错,因为F105的APB1时钟是36MHz,而很多F103工程的APB1时钟也是36MHz,看起来一样,但F103的CAN外设可以来自APB1,而F105的CAN时钟电路略有差异,导致很多人复制过来的预分频值直接不能用。标准外设库里的波特率公式是:
波特率 = APB1时钟 / ((1 + BS1 + BS2) * Prescaler)
如果板子主频72MHz,APB1通常是36MHz,想要500Kbps,可以让1 + BS1 + BS2 = 18个时间单元,Prescaler取4,即36MHz除以72。这个组合在CAN_InitTypeDef里对应CAN_BS1_10tq、CAN_BS2_7tq、CAN_Prescaler=4。算完以后可以先在代码里打印CAN_GetPrescaler之类的寄存器值核对,别急着接到总线上。
3. 核心代码拆解:一条CAN帧从进入到转发出去的完整链路
3.1 引脚与时钟初始化:顺序错了后面全是黑的
硬件初始化顺序决定了后面调试是否顺利。这个工程中,CAN1引脚固定复用为PA11(RX)和PA12(TX),CAN2引脚复用为PB12(RX)和PB13(TX)。初始化代码通常长这样:
void CAN_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // 先开GPIO时钟,再开外设时钟,这是标准库的习惯顺序 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1 | RCC_APB1Periph_CAN2, ENABLE); // CAN1引脚 PA11( RX), PA12( TX) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11 | GPIO_Pin_12; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); // CAN2引脚 PB12( RX), PB13( TX) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13; GPIO_Init(GPIOB, &GPIO_InitStructure); }引脚模式必须是复用推挽GPIO_Mode_AF_PP,不能设成普通的推挽输出,CAN控制器才能接管引脚。GPIO_Speed取50MHz通常没问题,CAN通信速率本身不高,但引脚翻转速度不够会影响波形边沿。如果你用的是某些把CAN引脚和串口共用映射的板子,还要检查板级原理图有没有跳线冲突,否则软件怎么配置都收不到数据。
3.2 CAN控制器初始化与筛选器配置:接收门槛只对CAN1生效
控制器初始化直接决定波特率和工作模式。双CAN工程里,两个CAN都要调一次CAN_Init,但参数可以完全一致。示例配置如下:
CAN_InitTypeDef CAN_InitStructure; CAN_DeInit(CAN1); CAN_DeInit(CAN2); CAN_InitStructure.CAN_TTCM = DISABLE; // 关闭时间触发模式 CAN_InitStructure.CAN_ABOM = ENABLE; // 自动离线恢复,总线错误后能自己回来 CAN_InitStructure.CAN_AWUM = ENABLE; // 自动唤醒 CAN_InitStructure.CAN_NART = DISABLE; // 关闭非自动重传,即出错自动重传 CAN_InitStructure.CAN_RFLM = DISABLE; // FIFO溢出时丢弃新帧,不锁定 CAN_InitStructure.CAN_TXFP = DISABLE; // 发送优先级由报文ID决定 CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; // 36MHz / ((1 + 10 + 7) * 4) = 500Kbps CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; CAN_InitStructure.CAN_BS1 = CAN_BS1_10tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_7tq; CAN_InitStructure.CAN_Prescaler = 4; CAN_Init(CAN1, &CAN_InitStructure); CAN_Init(CAN2, &CAN_InitStructure);注意CAN_SJW是同步跳转宽度,处理时钟偏差,一般取1tq够了。BS1是传播段加相位缓冲段1,BS2是相位缓冲段2,它们直接影响采样点位置。对500Kbps而言,把10tq和7tq组合起来,采样点大约在(1+10)/(1+10+7)=61%的位置,这是CAN通信中比较常用的值。如果总线上有别的设备,最好算清楚对方的采样点,避免边沿误差。
筛选器配置比控制器配置更容易写错。只做发送的CAN2其实不需要筛选器,真正决定数据进不进FIFO的是CAN1的筛选器,所以代码里会出现这样一段:
CAN_FilterInitTypeDef CAN_FilterInitStructure; CAN_FilterInitStructure.CAN_FilterNumber = 0; // 使用筛选器组0 CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0000; // 标准ID高16位 CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000; // 掩码全0 = 全收 CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_FIFO0; // 挂到CAN1的FIFO0 CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(&CAN_FilterInitStructure);掩码全0表示“掩码位不参与比较”,也就是说任何ID都能通过。如果想要只收某一个ID,把掩码设为0xFFFF,把FilterIdHigh/Low填成目标ID即可。32位长度下,标准帧的ID只占高16位中的11位,扩展帧的ID占整个32位,配置时容易漏掉这种位布局差异,我一般会直接按下表对应填值。
3.3 CAN2发送:往邮箱里塞帧的几个关键参数
标准CAN的发送路径是:构造一条CanTxMsg结构体,调用CAN_Transmit把它压入三个发送邮箱中的一个,再由硬件按照优先级自动移出到总线上。这个工程把发送放在CAN2上,所以每次转发都是一次发送流程。典型代码:
uint8_t CAN2_SendData(uint32_t id, uint8_t *buf, uint8_t len) { CanTxMsg txMsg; txMsg.StdId = id; // 标准帧ID txMsg.ExtId = 0; // 扩展ID用不到就填0 txMsg.IDE = CAN_Id_Standard; // 使用标准帧 txMsg.RTR = CAN_RTR_Data; // 数据帧 txMsg.DLC = len; // 数据长度0到8 for (uint8_t i = 0; i < len; i++) { txMsg.Data[i] = buf[i]; } uint8_t mailbox = CAN_Transmit(CAN2, &txMsg); return (mailbox != CAN_TxStatus_Failed) ? 1 : 0; }CAN_Transmit的返回值有三种:如果返回0到2,表示成功占用某个邮箱;返回CAN_TxStatus_Failed时表示三个邮箱都满,此时需要等待或丢弃。这个返回值是判断发送是否成功的第一依据,比只看函数返回后有没有立刻发出去可靠得多。DLC最好控制在8以内,超长帧在标准CAN里不受支持。如果ID超过0x7FF,那就应该走扩展帧,把IDE改成CAN_Id_Extended,并使用ExtId字段。
3.4 中断接收与转发:FIFO到邮箱的接力逻辑
CAN1接收中断是整条链路的入口。中断处理的基本原则是:能少做事就少做事,不能在中断里长时间等待发送邮箱,否则后续帧会因FIFO溢出被丢弃。工程里最简单的转发写法如下:
void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rxMsg; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET) { CAN_Receive(CAN1, CAN_FIFO0, &rxMsg); // 这里直接调用CAN2发送,不等待发送结果 // 如果发送失败,可以选择丢弃这帧或交给主循环补偿 uint8_t ok = CAN2_SendData(rxMsg.StdId, rxMsg.Data, rxMsg.DLC); if (!ok) { // 邮箱满时最简单的策略是丢掉,避免中断卡死 } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); } }注意这个中断函数的名称必须和启动文件里的向量名完全一致,写成CAN1_RX0_IRQHandler这种简化名在链接时不会报错,但中断进不来。F105的启动文件里CAN1的RX0向量名是USB_LP_CAN1_RX0_IRQHandler,这是很多第一次接触F105的开发者踩得最凶的坑。
当CAN2的发送邮箱满时,我一般不会在中断里用while死等CAN_TransmitStatus,那会把整个中断堵住。更好的做法是先把帧丢进一个环形队列,在主循环里统一发送,队列满了再丢弃或者置一个错误标志。这个工程直接调用发送函数虽然简单,但适合低负载场景,后续改造空间很大。
4. 编译运行与联调:让双CAN例程在你的板子上真正跑通
4.1 工程目录结构与启动文件选择
这个工程是典型的STM32标准外设库结构,目录大致如下:
STM32f105 CAN1收 CAN2发/ ├─ Libraries/ │ ├─ CMSIS/ │ └─ STM32F10x_StdPeriph_Driver/ ├─ User/ │ ├─ main.c │ ├─ stm32f10x_it.c │ └─ stm32f10x_conf.h ├─ Hardware/ │ ├─ can.c │ └─ can.h └─ Project/ ├─ 工程文件.uvprojx └─ JLinkSettings.ini在MDK里打开工程后,第一件事是确认器件型号和启动文件。F105属于互联型产品线,必须使用startup_stm32f10x_cl.s启动文件。有人会用startup_stm32f10x_hd.s替代,程序能编译过,但中断向量表对不上,CAN2和USB相关的向量全部错位,跑起来就是各种诡异问题。在C/C++选项卡里还需要定义STM32F10X_CL,否则标准外设库的条件编译不会打开互联型产品的支持。
4.2 接线与收发器连接:只连芯片引脚是不够的
CAN控制器引脚输出的是差分信号,必须先经过CAN收发器才能接到总线。常见接法如下:
| 外设功能 | 芯片引脚 | 接往收发器 |
|---|---|---|
| CAN1_RX | PA11 | 收发器1的RXD |
| CAN1_TX | PA12 | 收发器1的TXD |
| CAN2_RX | PB12 | 收发器2的RXD |
| CAN2_TX | PB13 | 收发器2的TXD |
收发器的CANH和CANL分别接总线上的CAN_H、CAN_L,在总线两端各接一个120Ω终端电阻。这个电阻必须有,否则帧波形反射严重,高速率时几乎必然丢帧甚至无法通信。收发器在与MCU连接时要注意电平匹配,如果收发器是5V供电,它的TXD输出到3.3V的STM32引脚时需要确认是否兼容,很多收发器本身就做成了3.3V兼容,但个别老型号需要加电阻分压。
4.3 两种验证方式:双板对测与单板自环
验证整条收发链路的办法,我推荐优先做双板对测。准备两块刷了同一份固件的板子,把两板的CAN1总线并在一起,CAN2总线也并在一起,然后在一端接上USB转CAN分析仪。用分析仪往CAN1总线上发一帧标准帧,比如ID 0x123加8字节数据,两块板子的CAN1都会收到,各自从CAN2转发出去。再在CAN2总线侧接另一路分析工具,观察到了帧,链路就通了。
如果手头只有一块板,也可以做单板自环。只要在CAN1总线侧用工具发帧,再用示波器或USB转CAN工具挂在CAN2的PB12/PB13上观察。如果没有额外分析工具,可以把工程临时改动:初始化时先让CAN1自己发一帧测试报文,然后看CAN2的发送引脚有没有动作。这个变通办法虽然验证不了“外部收到”,但足以确认中断转发逻辑没有断。
5. 避坑指南:双CAN调试中我踩过的5个具体问题
5.1 CAN2发送一直返回失败,邮箱始终满
现象:CAN1收发正常,但调用CAN_Transmit(CAN2, &txMsg)时频繁返回CAN_TxStatus_Failed,或者偶尔发送一次成功后后续全部失败。
原因:最常见的是RCC只开了CAN2的时钟,没有开CAN1时钟。F105双CAN模块内部是一个整体,CAN2的运行依赖CAN1的时钟域。其次,GPIO的端口的复用功能没有配置正确,CAN2的TX引脚PB13如果还是默认上拉状态,波形送不出去。
解决:初始化里把CAN1和CAN2的时钟位一起打开,严格按照外部收发器时序给TX引脚配置成复用推挽。如果这两步都确认了,再用CAN_TransmitStatus查一下邮箱实际状态,别只看CAN_Transmit的返回值。
5.2 两个CAN都在接收,帧只出现在其中一个
现象:给CAN1和CAN2都配置了筛选器后,总线上的帧全部进了CAN1,CAN2一个都收不到;或者反过来CAN2全收,CAN1收不到。
原因:28组筛选器是共享的,两个CAN配置了相同的筛选器组号,或者CAN_FilterFIFOAssignment写错了,把本应挂在CAN2的筛选器挂到了CAN1的FIFO上。筛选器本身没有“归属”关系,只有在哪里登记,数据就从哪里进。
解决:给两个CAN分配不同的筛选器组,比如低14组给CAN1,高14组给CAN2,并在CAN_FilterInit前用CAN_FMR确认筛选器启动模式。接收中断里也要分别处理对应的FIFO标志,不能把两个CAN的中断处理混在同一个函数里。
5.3 中断函数写了却不触发,程序像死了一样
现象:主循环正常运行,CAN1的接收中断标志位也确实置位了,但断点打不进中断处理函数。
原因:第一,函数名和启动文件里的向量名不一致,最常见的是把USB_LP_CAN1_RX0_IRQHandler写成了CAN1_RX0_IRQHandler;第二,NVIC里没有使能对应的中断通道;第三,启动文件选错型号,向量表位置偏移了。
解决:打开startup_stm32f10x_cl.s,把CAN相关向量名全部列出来,逐个与工程里的中断函数名比对。编译完以后打开map文件,搜索IRQHandler字符,确认它确实被链接到了向量表对应的位置。NVIC配置里除了使能CAN1_RX0_IRQn,还要把优先级分组设置好,否则中断嵌套时也可能出现进不来的现象。
5.4 总线上有波形但对面收不到数据
现象:用示波器能看到CAN_H和CAN_L之间有明显的差分信号,但另一块板子或者分析仪就是收不到帧,有时候能看到错误帧波形。
原因:多半是波特率参数和对方不一致。F105的APB1时钟为36MHz,大家从F103工程里复制过来时经常把Prescaler照搬,而F103工程的APB1可能是不同的外设时钟配置,导致实际波特率偏离目标值。另一个常见原因是终端电阻没接或者只接了一端。
解决:核算一遍波特率公式,然后把采样点也确认好。标准库初始化里改变CAN_BS1和CAN_BS2后,CAN_SJW也要重新检查。测试时在CAN_H和CAN_L之间直接并一个120Ω电阻,两边都并联或者使用标准总线结构,不要只靠收发器内部电阻。
5.5 高速率长测试后开始丢帧,FIFO溢出
现象:低速500Kbps下测试一切正常,连续发数据半小时后开始丢包;换成1Mbps后刚开始正常,过一会儿丢帧越来越严重。
原因:中断里做转发时直接调用CAN2_SendData,如果CAN2三个邮箱刚好全满,这一帧就丢弃了。此时CAN1的FIFO还在继续接收,FIFO溢出后也会把后续帧丢掉。简单轮询发送的结果就是:负载越高,丢帧越不可控。
解决:不要在中断里等待邮箱可用。改进方法有两种:一是用CAN_TXFP把发送优先级设为顺序发送,配合三个邮箱做多帧缓冲;二是引入环形队列,中断里只入队,主循环统一发送。对于更高的吞吐,可以把接收FIFO的溢出中断打开,对溢出次数做统计,而不是任由数据静默丢失。
6. 二次开发:把它改造成带路由功能的双CAN转发网关
6.1 三个可落地的改造方向
第一个方向是加ID路由。当前筛选器全收,任何一帧都会从CAN2转发出去。如果在接收中断里增加一张路由表,只把特定ID的帧转发给CAN2,其他ID直接丢弃或转到CAN1,这就成了一个简单的双CAN网关节点。
第二个方向是数据缓冲。当前实现里,接收中断直接调用发送函数,邮箱满时丢帧不可控。我一般会在工程里加一个环形队列,把收到的帧先存进去,主循环里再做发送。队列长度取64条即可覆盖大多数中继场景,队列满了再统计丢帧次数。
第三个方向是错误管理。F105的CAN错误状态寄存器能区分被动错误和总线关闭,可以在检测到总线错误时自动恢复,或者把错误帧的计数通过串口打印出来。做设备联调时,这个错误统计非常有用,能告诉你丢帧到底是因为总线干扰还是因为协议层处理不过来。
6.2 一个具体技巧:用路由表替代全量转发
下面这个结构体数组可以放在can.c里,接收中断中先查表再决定转发的目标,比起全量转发更灵活:
typedef struct { uint32_t id; uint8_t enable; // 1:转发 0:丢弃 uint8_t dest_can; // 1:走CAN1 2:走CAN2 } CanRouteItem; const CanRouteItem route_table[] = { {0x123, 1, 2}, // ID 0x123转CAN2 {0x321, 1, 1}, // ID 0x321转CAN1 {0x456, 0, 2}, // ID 0x456直接丢弃 }; uint8_t CanRoute_Find(uint32_t id, uint8_t *dest) { uint8_t count = sizeof(route_table) / sizeof(route_table[0]); for (uint8_t i = 0; i < count; i++) { if (route_table[i].id == id) { if (route_table[i].enable) { *dest = route_table[i].dest_can; return 1; } return 0; } } return 0; }在接收中断的转发逻辑里,把原来直接调用CAN2_SendData的地方改成先查表,根据查到的目标决定调用CAN1还是CAN2。这样改动不需要动寄存器配置,只是把数据流控制从硬件筛选器挪到软件层,后续增加协议转换、DBC映射或者帧格式重组都在这张表上扩展。
从那以后我每次拿到双CAN工程,第一件事都是先打开启动文件核对向量名和筛选器组分配,确认基础资源不打架再动业务逻辑,这个习惯帮我少熬了很多夜。希望帮到你。
本文还有配套的精品资源,点击获取