简介这份资源是面向嵌入式初学者与STM32开发者的CAN总线通信程序例程基于STM32F103ZET6单片机与官方固件库编写已在开发板上调试通过可用于学习CAN外设初始化、报文收发与中断处理等核心环节帮助读者快速搭建总线通信实验环境。压缩包共70个文件约188KB以30个h头文件与26个c源文件为主体涵盖外设驱动、中断服务与主程序逻辑另含uvproj、uvopt等Keil工程配置及若干备份文件目录结构清晰便于直接打开工程编译与二次修改。目前已有2542人学习下载适合作为CAN通信入门与固件库工程组织的参考范例读者可从中掌握CAN波特率配置、过滤器设置与收发流程的完整实现思路并对照源码理解各模块的调用关系为后续寄存器版本学习打下基础。1. 从一块工控板说起为什么CAN通信例程值得单独拆一遍前阵子帮一个做工业控制的朋友调板子STM32F103ZET6 最小系统板挂了两路 CAN 节点上电后主站发心跳从站死活不回。示波器一打CAN_H 和 CAN_L 上波形干净得很就是没有 ACK。折腾到后半夜才发现是终端电阻只焊了一端另一端的 120 欧姆被漏掉了。这件事让我意识到CAN 总线通信程序例程这东西代码本身不难难的是那些藏在硬件配置和协议细节里的坑。这份 STM32F103ZET6 的 CAN 通信例程就是围绕这颗经典芯片把 bxCAN 外设的初始化、收发、过滤器和中断处理完整走了一遍。它适合正在做工业控制、汽车电子、储能 BMS 或者多节点设备通信的开发者尤其是那些第一次从串口转向 CAN 总线、需要一份能直接跑通的参考代码的人。STM32F103ZET6 自带两路 bxCAN 控制器支持 CAN 2.0A 和 2.0B 协议最高速率 1Mbps配合 TJA1050 或者 SN65HVD230 这类收发器就能搭起一个最小通信系统。例程的价值不在于代码有多复杂而在于它把时钟配置、GPIO 复用、波特率计算、过滤器设置这些容易翻车的环节都摆在了明面上。2. 项目定位与技术选型这颗芯片为什么还在被反复使用2.1 核心功能与适用场景这份例程的核心功能很明确让 STM32F103ZET6 通过 bxCAN 外设以标准帧或扩展帧格式在 CAN 总线上完成数据的发送和接收。它通常包含两个部分一个是发送节点周期性或者按键触发发送数据帧另一个是接收节点通过中断或者轮询方式接收数据并把内容通过串口打印出来方便调试。适用场景集中在几个方向工业现场的多设备通信比如 PLC 和变频器之间的控制指令汽车电子里的车身控制模块车窗、车灯、座椅的节点通信储能系统的 BMS主控和从控之间的电压温度数据上报还有医疗器械里对可靠性要求较高的设备互联。这些场景的共同点是节点数量不多但对实时性和抗干扰能力有要求CAN 总线的差分信号和仲裁机制正好匹配。2.2 为什么是 STM32F103ZET6 加 bxCANSTM32F103ZET6 属于 F1 系列里的高配型号144 引脚512KB Flash64KB SRAM带两路 CAN 控制器。选它做 CAN 通信例程有几个现实原因。第一F1 系列的资料和库函数支持非常成熟标准外设库和 HAL 库都能用新手照着改不容易卡住。第二bxCAN 外设的寄存器结构清晰过滤器组有 14 个可以灵活配置标识符屏蔽模式适合教学和二次开发。第三这颗芯片在工业现场的实际存量很大很多老设备的主控就是它维护和替换的需求一直存在。收发器方面常见搭配是 TJA1050它把 CAN 控制器的逻辑电平转换成差分信号速率能到 1Mbps和 F103 的 CAN 引脚直接相连。硬件上需要注意的是 CAN_TX 和 CAN_RX 要接到收发器的对应引脚收发器的 CAN_H 和 CAN_L 接总线总线两端各加一个 120 欧姆终端电阻。2.3 软件架构与目录结构例程的软件架构通常分成三层。底层是硬件驱动层负责 GPIO 初始化、CAN 控制器初始化、收发器使能这部分和芯片手册强相关。中间是协议处理层负责帧的封装和解析包括标识符、数据长度、数据内容以及过滤器的配置。上层是应用逻辑层决定什么时候发、发什么、收到之后怎么处理。目录结构上一个典型的工程会包含这些文件夹Project/ ├── CMSIS/ # 内核启动文件与系统初始化 ├── STM32F10x_StdPeriph_Driver/ │ ├── inc/ # 标准外设库头文件 │ └── src/ # 标准外设库源文件含 stm32f10x_can.c ├── User/ │ ├── main.c # 主循环与业务逻辑 │ ├── can.c # CAN 初始化与收发函数 │ ├── can.h │ ├── usart.c # 串口打印用于调试 │ └── stm32f10x_it.c # 中断服务函数含 CAN RX 中断 └── Hardware/ └── led.c # 状态指示这个结构的好处是职责分明can.c 里只放和 CAN 相关的操作main.c 里只写业务调度。二次开发的时候改过滤器或者改波特率直接定位到 can.c 就行不用在 main 里翻来翻去。3. 跑通第一步从时钟配置到CAN初始化的完整链路3.1 时钟与GPIO的底层配置STM32 的 CAN 外设挂在 APB1 总线上F103 的 APB1 最高 36MHz。CAN 的波特率计算依赖这个时钟所以第一步必须把 RCC 配置对。常见做法是外部 8MHz 晶振倍频到 72MHzAPB1 分频后得到 36MHz。然后使能 CAN1 和对应 GPIO 的时钟。CAN 的引脚通常是 PA11 作为 CAN_RXPA12 作为 CAN_TX或者用重映射到 PB8 和 PB9。GPIO 模式要设成复用推挽输出和上拉输入。// 使能 CAN1 和 GPIOA 时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 配置 PA11 为 CAN_RX上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); // 配置 PA12 为 CAN_TX复用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure);这段代码里PA11 设成上拉输入是因为 CAN_RX 在空闲时应该是隐性电平上拉能保证电平稳定。PA12 设成复用推挽是因为它要输出到收发器推挽驱动能力足够。速度选 50MHz 是为了保证边沿陡峭减少通信误码。如果用的是重映射引脚还需要打开 AFIO 时钟并调用 GPIO_PinRemapConfig。3.2 CAN控制器初始化与波特率计算CAN 初始化的核心是填 CAN_InitTypeDef 结构体然后调用 CAN_Init。波特率的计算是新手最容易迷糊的地方。F103 的 CAN 位时序分成三段同步段、时间段 1、时间段 2。波特率 APB1 时钟 / (分频系数 × (1 BS1 BS2))。假设 APB1 是 36MHz想要 500Kbps可以设分频系数为 4BS1 为 8BS2 为 1这样 36M / (4 × (181)) 900K不对。重新算36M / (4 × 18) 500K所以 BS1 加 BS2 加 1 等于 18比如 BS115BS22。采样点位置在 (1BS1)/(1BS1BS2) 16/18 ≈ 88.9%这个位置对大多数总线来说比较合适。CAN_InitTypeDef CAN_InitStructure; 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; // 发送优先级由标识符决定 CAN_InitStructure.CAN_Mode CAN_Mode_Normal; // 正常模式 CAN_InitStructure.CAN_SJW CAN_SJW_1tq; // 同步跳转宽度1个时间单位 CAN_InitStructure.CAN_BS1 CAN_BS1_15tq; // 时间段1为15个时间单位 CAN_InitStructure.CAN_BS2 CAN_BS2_2tq; // 时间段2为2个时间单位 CAN_InitStructure.CAN_Prescaler 4; // 分频系数4 CAN_Init(CAN1, CAN_InitStructure);这里 CAN_ABOM 设成 ENABLE 很重要它让控制器在检测到 128 次 11 位隐性位后自动恢复不用手动干预。CAN_AWUM 设成 ENABLE 是为了在总线活动时自动退出睡眠。CAN_NART 设成 DISABLE 表示允许自动重传这在工业现场能提高可靠性但如果是实时性极高的场景可能会考虑关掉它避免一直重传阻塞。波特率参数要根据实际总线的速率来改如果总线上其他节点是 250K这里也得改成对应的分频和 BS 值。3.3 过滤器配置与中断接收CAN 的过滤器决定了哪些帧会被接收进 FIFO。F103 有 14 个过滤器组每个组可以配置成屏蔽位模式或者标识符列表模式。屏蔽位模式下可以指定一个标识符和一个掩码只有匹配的帧才通过。比如只接收标识符为 0x123 的标准帧掩码设成 0x7FF表示所有位都要匹配。CAN_FilterInitTypeDef CAN_FilterInitStructure; CAN_FilterInitStructure.CAN_FilterNumber 0; // 使用过滤器组0 CAN_FilterInitStructure.CAN_FilterMode CAN_FilterMode_IdMask; // 屏蔽位模式 CAN_FilterInitStructure.CAN_FilterScale CAN_FilterScale_32bit; // 32位 CAN_FilterInitStructure.CAN_FilterIdHigh 0x123 5; // 标识符高16位 CAN_FilterInitStructure.CAN_FilterIdLow 0x0000; // 标识符低16位 CAN_FilterInitStructure.CAN_FilterMaskIdHigh 0x7FF 5; // 掩码高16位 CAN_FilterInitStructure.CAN_FilterMaskIdLow 0x0000; // 掩码低16位 CAN_FilterInitStructure.CAN_FilterFIFOAssignment CAN_Filter_FIFO0; // 关联FIFO0 CAN_FilterInitStructure.CAN_FilterActivation ENABLE; // 使能过滤器 CAN_FilterInit(CAN_FilterInitStructure);标识符左移 5 位是因为在 32 位过滤器里标准帧的标识符存放在高 16 位的前 11 位后面跟着 RTR 和 IDE 位。掩码 0x7FF 左移 5 位表示这 11 位都要比较。如果想让某个范围都通过可以把掩码设成 0x700只比较高 3 位。过滤器配置完之后还要使能 CAN 的接收中断在中断服务函数里读取 FIFO。// 使能 FIFO0 消息挂起中断 CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); // 中断服务函数 void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg RxMessage; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) ! RESET) { CAN_Receive(CAN1, CAN_FIFO0, RxMessage); // 处理接收到的数据 CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); } }中断服务函数的名字要和启动文件里的向量表对应F103 的 CAN1 RX0 中断是 USB_LP_CAN1_RX0_IRQHandler。接收完之后必须清除中断标志否则会反复进入中断。CanRxMsg 结构体里包含 StdId、ExtId、IDE、RTR、DLC 和 Data 数组根据这些字段就能判断帧的类型和内容。4. 收发逻辑与调试手段让数据真正在总线上跑起来4.1 发送函数的封装与调用发送一个 CAN 帧需要先选一个空的发送邮箱然后填 CanTxMsg 结构体最后调用 CAN_Transmit。F103 有三个发送邮箱如果都满了CAN_Transmit 会返回 CAN_TxStatus_NoMailBox。稳妥的做法是等待邮箱空出来或者用中断方式发送。uint8_t CAN_SendMsg(uint32_t id, uint8_t *data, uint8_t len) { CanTxMsg TxMessage; uint8_t mailbox; TxMessage.StdId id; // 标准标识符 TxMessage.ExtId 0; // 扩展标识符标准帧不用 TxMessage.IDE CAN_Id_Standard; // 标准帧 TxMessage.RTR CAN_RTR_Data; // 数据帧 TxMessage.DLC len; // 数据长度0到8 for (uint8_t i 0; i len; i) { TxMessage.Data[i] data[i]; } mailbox CAN_Transmit(CAN1, TxMessage); if (mailbox CAN_TxStatus_NoMailBox) { return 1; // 发送失败没有空邮箱 } // 等待发送完成 while (CAN_TransmitStatus(CAN1, mailbox) ! CAN_TxStatus_Ok); return 0; }这个函数把标识符、数据和长度作为参数返回 0 表示成功1 表示失败。DLC 最大是 8超过 8 字节的数据需要分帧发送这是 CAN 协议本身的限制。等待发送完成的循环在实际项目里可能会加超时避免总线异常时死等。如果总线上没有其他节点应答发送会一直重传这时候 CAN_TransmitStatus 不会返回 Ok需要根据业务逻辑决定是否放弃。4.2 接收处理与串口打印接收到的数据通常需要打印出来看串口是最方便的调试手段。把 CAN 接收中断里拿到的数据通过 USART 发送到电脑用串口助手就能看到。串口配置一般是 115200 波特率8 位数据无校验。void CAN_PrintMsg(CanRxMsg *msg) { printf(ID: 0x%03X DLC: %d Data: , msg-StdId, msg-DLC); for (uint8_t i 0; i msg-DLC; i) { printf(%02X , msg-Data[i]); } printf(\r\n); }printf 需要重定向 fputc 到 USART1这个在标准库工程里很常见。打印的时候把标识符、数据长度和每个字节的十六进制值都输出方便对照发送端。如果收到的数据不对先看标识符是不是被过滤器放行了再看 DLC 和 Data 是不是和发送端一致。有时候串口打印本身会占用时间如果 CAN 接收频率很高中断里直接 printf 可能导致丢帧这时候可以在中断里只把数据存到缓冲区在主循环里再打印。4.3 回环模式与静默模式的自测在没有第二个节点的情况下可以用回环模式验证代码。回环模式下CAN 控制器自己发送的帧会被自己接收不需要外部收发器参与。把 CAN_Mode 改成 CAN_Mode_LoopBack发送一帧看接收中断能不能进数据对不对。这个模式适合在焊接收发器之前先验证软件逻辑。静默模式下控制器只接收不发送可以用来监听总线上的流量不会干扰其他节点。这两个模式在调试阶段很有用能快速定位是软件问题还是硬件问题。// 回环模式初始化 CAN_InitStructure.CAN_Mode CAN_Mode_LoopBack; CAN_Init(CAN1, CAN_InitStructure);改成回环模式后发送函数照常调用接收中断里应该能收到一模一样的数据。如果收不到检查过滤器的标识符和掩码是不是匹配检查中断有没有使能检查 FIFO 有没有被正确读取。回环模式通过之后再切回正常模式接上收发器和另一个节点基本就能通信了。5. 避坑与排查那些让CAN通信翻车的细节5.1 终端电阻缺失导致无应答现象是发送端一直重传CAN_TransmitStatus 永远不返回 Ok示波器看差分信号幅度正常但没有 ACK 位。原因是总线两端没有各接一个 120 欧姆终端电阻或者只接了一端。CAN 总线是差分信号终端电阻用来匹配阻抗消除反射。解决方法是检查总线两端确保各有一个 120 欧姆电阻用万用表量 CAN_H 和 CAN_L 之间的电阻正常应该是 60 欧姆左右因为两个 120 欧姆并联。5.2 波特率不一致导致错误帧现象是总线上频繁出现错误帧节点进入错误被动甚至总线关闭状态。原因是各个节点的波特率配置不一致比如一个节点是 500K另一个是 250K。CAN 没有自动波特率协商所有节点必须用相同的速率。解决方法是统一计算波特率确认 APB1 时钟、分频系数、BS1 和 BS2 的组合一致。用示波器测量一位的时间500K 波特率下一位是 2 微秒250K 是 4 微秒量出来对不上就说明配置错了。5.3 过滤器配置错误导致收不到帧现象是发送端显示发送成功但接收端中断一直不进。原因是过滤器的标识符或者掩码设错了把该放行的帧过滤掉了。比如发送端用标准帧标识符 0x123接收端过滤器掩码设成 0x7FF 但标识符写成了 0x123 左移的位数不对。解决方法是先用过滤器组 0 的屏蔽位模式掩码全设 0让所有帧都通过确认能收到之后再逐步收紧。另外注意 FIFO0 和 FIFO1 的关联中断使能的是 FMP0 就要读 FIFO0。5.4 中断优先级与主循环冲突现象是 CAN 接收偶尔丢帧或者主循环里的任务被中断打断后时序错乱。原因是 CAN 接收中断优先级设得太高或者中断里执行了耗时操作比如 printf。解决方法是把 CAN 中断优先级设成中等中断里只做数据拷贝把处理逻辑放到主循环。如果用了 RTOS可以用消息队列把数据从中断传到任务里。另外注意 CAN 中断和 USB 中断共用向量如果工程里同时用了 USB要确认中断服务函数没有冲突。5.5 收发器供电与电平不匹配现象是 CAN 控制器初始化正常但总线上没有任何波形。原因是收发器没有供电或者供电电压不对。TJA1050 需要 5V 供电SN65HVD230 需要 3.3V供错了要么不工作要么损坏。解决方法是先量收发器的 VCC 引脚确认电压正确再量 CAN_H 和 CAN_L 的静态电平隐性状态下两者都应该在 2.5V 左右。如果收发器的 TXD 引脚有波形但 CAN_H 和 CAN_L 没变化基本就是收发器没工作或者坏了。6. 二次开发改造从例程到产品代码的几步拿到这份例程之后直接用在产品里通常还需要做几件事。第一件是把发送和接收做成环形缓冲区中断里只负责存和取业务逻辑在主循环或者任务里处理避免中断里做耗时操作。第二件是加超时和错误处理CAN_Transmit 等待完成的地方加一个超时计数超时后返回错误码让上层决定重发还是报警。第三件是把标识符和数据的定义抽成头文件用宏或者枚举管理不要散落在各个 c 文件里。第四件是如果节点多考虑用扩展帧29 位标识符能容纳更多信息但过滤器的配置也要相应改成 32 位扩展模式。// 环形缓冲区定义 #define CAN_RX_BUF_SIZE 32 typedef struct { CanRxMsg msg[CAN_RX_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } CanRxBuffer; CanRxBuffer canRxBuf; // 中断里只存 void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg msg; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) ! RESET) { CAN_Receive(CAN1, CAN_FIFO0, msg); uint16_t next (canRxBuf.head 1) % CAN_RX_BUF_SIZE; if (next ! canRxBuf.tail) // 缓冲区未满 { canRxBuf.msg[canRxBuf.head] msg; canRxBuf.head next; } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); } } // 主循环里取 void CAN_Process(void) { while (canRxBuf.tail ! canRxBuf.head) { CanRxMsg *msg canRxBuf.msg[canRxBuf.tail]; // 业务处理 canRxBuf.tail (canRxBuf.tail 1) % CAN_RX_BUF_SIZE; } }这个环形缓冲区的写法在 CAN 通信里很实用head 和 tail 用 volatile 修饰因为它们在中断和主循环里都会被访问。缓冲区大小根据实际流量调整32 帧一般够用。如果流量更大可以改成动态分配或者用 RTOS 的队列。验证的时候先发一帧看能不能进缓冲区再连续发多帧看会不会溢出溢出的时候可以加一个计数器方便定位。另一个改造点是过滤器的动态配置。产品里可能需要根据上位机指令切换接收的标识符范围这时候不能只在初始化时配一次。F103 的过滤器在正常模式下不能直接改需要先进入初始化模式改完再退出。封装一个函数传入标识符和掩码内部处理模式切换这样上层调用就简单了。void CAN_ConfigFilter(uint16_t id, uint16_t mask) { CAN1-FMR | CAN_FMR_FINIT; // 进入过滤器初始化模式 CAN1-FA1R ~CAN_FA1R_FACT0; // 禁用过滤器组0 CAN1-FS1R | CAN_FS1R_FSC0; // 32位模式 CAN1-FM1R ~CAN_FM1R_FBM0; // 屏蔽位模式 CAN1-sFilterRegister[0].FR1 (id 5) | 0x0000; CAN1-sFilterRegister[0].FR2 (mask 5) | 0x0000; CAN1-FA1R | CAN_FA1R_FACT0; // 使能过滤器组0 CAN1-FMR ~CAN_FMR_FINIT; // 退出初始化模式 }这段代码直接操作寄存器比标准库函数更直接适合在需要频繁切换过滤器的场景。注意 FR1 和 FR2 的赋值标准帧的标识符和掩码都要左移 5 位。改完之后要确认 CAN1-FMR 的 FINIT 位已经清零否则 CAN 不会正常工作。我一般会在改完过滤器之后发一帧测试帧确认接收端能收到再继续后面的逻辑。从那以后每次改过滤器配置我都会先跑一遍回环模式确认软件链路没问题再切到正常模式接总线。希望这些经验帮到你。本文还有配套的精品资源点击获取