STM32F103 CAN通信实战:从初始化到收发避坑指南
简介:面向嵌入式开发初学者与工业控制、汽车电子方向工程师的STM32F103 CAN通信实例代码包,针对CAN总线开发入门与工程实践需求,提供从控制器初始化、波特率与滤波器配置到收发邮箱操作、中断回调及错误处理的完整参考实现。压缩包共81个文件、约280KB,以28个c源文件与30个h头文件构成核心驱动与应用逻辑,辅以12个s启动文件及工程配置类文件,目录按Source、App、CMSIS与标准外设库分层组织,便于对照阅读与移植。已有2876人学习下载,说明其在CAN入门场景中具备一定参考价值。代码覆盖CAN1/CAN2控制器配置、帧格式处理与HAL库调用示例,读者可据此理解差分总线通信流程、调试收发时序并排查常见错误状态,快速搭建可运行的CAN通信验证环境。
1. 从一块“哑火”的板子说起:STM32F103 CAN 实例代码能解决什么
去年帮一个做工业控制的朋友调板子,两块 STM32F103 板子通过 CAN 收发器对接,代码烧进去之后串口打印一切正常,但总线上就是收不到任何一帧数据。示波器一挂,波形干净得很,问题出在初始化顺序和过滤器配置上——这种“代码看着没错、总线就是不通”的场景,几乎是每个第一次碰 CAN 的开发者都会踩的坑。这份 STM32F103 CAN 实例代码,就是围绕这类真实痛点整理出来的一套可跑通、可对照、可二次开发的工程参考:它把 CAN 外设的时钟使能、GPIO 复用、位时序计算、过滤器组配置、发送邮箱与接收 FIFO 的中断处理串成一条完整链路,适合正在做车载节点、工控从站、多机通信的嵌入式开发者直接拿来对照自己的工程。你不需要从零啃参考手册的寄存器位定义,先把这套代码跑起来,再回头理解每一行配置为什么这么写,效率会高很多。
2. 工程结构与技术选型:这套 CAN 实例代码是怎么搭起来的
2.1 目录组织与文件职责划分
拿到一份 STM32F103 的 CAN 实例代码,第一件事不是急着编译,而是先看清目录里每个文件管什么。这类工程通常沿用标准外设库(Standard Peripheral Library)的组织方式,核心目录大致如下:
CAN_Demo/ ├── CMSIS/ # 内核启动文件与系统初始化 ├── STM32F10x_FWLib/ # 标准外设库源码(can.c、gpio.c、rcc.c 等) ├── User/ │ ├── main.c # 主流程:初始化 + 收发循环 │ ├── can_app.c # CAN 应用层封装:发送、接收、过滤器配置 │ ├── can_app.h │ └── stm32f10x_it.c # 中断服务函数(CAN RX0 / TX 中断) ├── Hardware/ │ ├── can_port.c # CAN 引脚与收发器控制 │ └── led.c # 状态指示 └── Project/ # Keil 或 IAR 工程文件这个划分的关键在于把“外设驱动”和“应用逻辑”分开:can_app.c里只关心“发一帧、收一帧、配过滤器”,不掺和 GPIO 和时钟;can_port.c负责 PA11/PA12 的复用推挽配置和收发器使能引脚。二次开发时你改应用层不会碰到底层引脚,换板子时只动can_port.c,这是这套结构最实用的地方。
2.2 为什么选标准库而不是 HAL
STM32F103 这一代芯片,社区里流传的 CAN 实例代码绝大多数基于标准外设库,原因很直接:标准库对 CAN 的寄存器封装更贴近手册,CAN_InitTypeDef里CAN_SJW、CAN_BS1、CAN_BS2、CAN_Prescaler四个字段和参考手册的位时序图一一对应,调波特率时你能清楚知道自己在改哪一段。HAL 库把位时序包在HAL_CAN_Init里,抽象层更厚,新手反而容易搞不清 500kbps 到底是怎么算出来的。
常见做法是:如果项目已经用了标准库,就直接沿用;如果是新项目且芯片资源紧张,标准库编译出来的体积也更小。这套实例代码的定位是“看得懂、改得动”,所以标准库是更合适的选择。需要提醒的是,标准库的stm32f10x_can.c里CAN_Transmit返回的是邮箱号,不是成功/失败标志,这个细节后面会专门讲。
2.3 CAN 外设初始化的完整链路
CAN 初始化的顺序有讲究,错一步总线就不通。下面这段是can_app.c里最核心的初始化函数,我按实际工程习惯补全了注释:
void CAN_App_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; CAN_InitTypeDef CAN_InitStructure; CAN_FilterInitTypeDef CAN_FilterInitStructure; /* 1. 使能 CAN 和 GPIO 时钟,CAN 挂在 APB1 上 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* 2. PA11 作为 CAN_RX 上拉输入,PA12 作为 CAN_TX 复用推挽 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); 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); /* 3. 位时序:APB1=36MHz,分频4,BS1=9tq,BS2=4tq,SJW=1tq 波特率 = 36MHz / (4 * (1 + 9 + 4)) = 500kbps */ CAN_InitStructure.CAN_TTCM = DISABLE; CAN_InitStructure.CAN_ABOM = ENABLE; /* 自动离线恢复,调试期建议开 */ CAN_InitStructure.CAN_AWUM = DISABLE; CAN_InitStructure.CAN_NART = DISABLE; /* 允许自动重传 */ CAN_InitStructure.CAN_RFLM = DISABLE; CAN_InitStructure.CAN_TXFP = DISABLE; CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; CAN_InitStructure.CAN_BS1 = CAN_BS1_9tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_4tq; CAN_InitStructure.CAN_Prescaler = 4; CAN_Init(CAN1, &CAN_InitStructure); /* 4. 过滤器:组0,32位掩码模式,接收所有帧进 FIFO0 */ CAN_FilterInitStructure.CAN_FilterNumber = 0; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_Filter_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(&CAN_FilterInitStructure); /* 5. 使能 FIFO0 消息挂起中断 */ CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); }逐段说明几个容易翻车的参数。CAN_Prescaler是分频系数,取值 1~1024,它和 BS1、BS2 一起决定波特率,公式是APB1时钟 / (Prescaler * (1 + BS1 + BS2)),其中 1 是同步段。上面 36MHz 除以 4 再除以 14,正好 500kbps,这是工业现场最常用的速率之一。CAN_ABOM打开后,节点进入离线状态会自动恢复,调试阶段强烈建议开,否则一旦因为错误计数超限掉线,你得手动复位才能继续。过滤器那段把 ID 和掩码全设 0,意思是“全部接收”,先跑通再收紧,这是我一贯的顺序——先让数据流起来,再谈过滤精度。
3. 收发链路拆解:一帧 CAN 报文从发送到接收的完整路径
3.1 发送:三个邮箱怎么用
STM32F103 的 CAN1 有 3 个发送邮箱,标准库的CAN_Transmit会返回一个邮箱号(0/1/2),如果三个邮箱都满则返回CAN_TxStatus_NoMailBox。很多实例代码直接忽略返回值,结果在高负载时丢帧还找不到原因。正确的写法是先判邮箱是否可用:
uint8_t CAN_App_Send(uint32_t id, uint8_t *data, uint8_t len) { CanTxMsg TxMessage; uint8_t mailbox; TxMessage.StdId = id; /* 标准帧 ID,11 位 */ TxMessage.ExtId = 0x00; 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; /* 邮箱满,调用方决定重试或丢弃 */ } /* 等待该邮箱发送完成,实际工程建议用中断或超时轮询 */ uint32_t timeout = 0xFFFFF; while (CAN_TransmitStatus(CAN1, mailbox) != CAN_TxStatus_Ok) { if (--timeout == 0) { CAN_CancelTransmit(CAN1, mailbox); return 2; /* 超时,取消发送 */ } } return 0; }这里有两个参数值得说清楚。DLC最大是 8,超过 8 字节标准库不会帮你截断,写 9 会得到未定义行为,这是血泪经验。CAN_TransmitStatus的返回值有三种:CAN_TxStatus_Failed、CAN_TxStatus_Ok、CAN_TxStatus_Pending,轮询时必须等Ok,只判“不等于 Failed”是不够的。另外CAN_CancelTransmit在超时后调用,能避免邮箱被长期占用导致后续帧发不出去。
3.2 接收:中断里只做搬运,不做业务
接收侧最常见的错误是在中断服务函数里直接跑业务逻辑,比如解析报文、驱动电机、打印日志。CAN 接收中断触发频率可能很高,业务一慢就丢帧。稳妥的做法是中断里只把报文搬进环形缓冲区,主循环再消费:
/* 环形缓冲区,容量按实际总线负载调整 */ #define CAN_RX_BUF_SIZE 32 static CanRxMsg rx_buf[CAN_RX_BUF_SIZE]; static volatile uint16_t rx_head = 0; static volatile uint16_t rx_tail = 0; void USB_LP_CAN1_RX0_IRQHandler(void) { if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET) { uint16_t next = (rx_head + 1) % CAN_RX_BUF_SIZE; if (next != rx_tail) { /* 缓冲区未满才写入 */ CAN_Receive(CAN1, CAN_FIFO0, &rx_buf[rx_head]); rx_head = next; } else { /* 缓冲区满,丢弃并计数,便于排查负载问题 */ CAN_Receive(CAN1, CAN_FIFO0, &rx_buf[rx_head]); } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); } }CAN_Receive的第二个参数是 FIFO 编号,过滤器分配到 FIFO0 就必须读 FIFO0,读错 FIFO 会一直读不到数据。中断函数名USB_LP_CAN1_RX0_IRQHandler是 STM32F103 的固定向量名,写在startup_stm32f10x_md.s里,改名会导致中断进不去——这个坑我在早期项目里踩过,编译不报错,就是收不到数据,查了半天。
3.3 主循环消费与状态指示
主循环从环形缓冲区取数据,做业务解析,同时用 LED 指示总线活动,方便现场判断节点是否活着:
int main(void) { uint8_t tx_data[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; SystemInit(); LED_Init(); CAN_App_Init(); NVIC_Config(); /* 配置 CAN RX0 中断优先级 */ while (1) { /* 发送一帧心跳,ID 0x123 */ CAN_App_Send(0x123, tx_data, 8); /* 消费接收缓冲区 */ while (rx_tail != rx_head) { CanRxMsg *msg = &rx_buf[rx_tail]; if (msg->StdId == 0x456) { LED_Toggle(); /* 收到对端帧,翻转指示灯 */ } rx_tail = (rx_tail + 1) % CAN_RX_BUF_SIZE; } Delay_ms(100); } }NVIC_Config里要把 CAN RX0 中断的抢占优先级设得比 SysTick 高,否则在Delay_ms里可能被延迟响应。这段主循环刻意写得简单,是为了让读者先确认“发得出、收得到”,再往里加协议解析。实际项目里心跳周期、ID 分配、数据字段都要按协议文档来,但骨架就是这三步:初始化、发、收。
4. 避坑与排查:CAN 总线不通时先看这几处
4.1 现象:串口打印正常,总线上测不到波形
原因通常是 GPIO 复用没配对。PA11 必须是上拉输入(GPIO_Mode_IPU),PA12 必须是复用推挽(GPIO_Mode_AF_PP),如果 PA12 配成普通推挽,CAN 外设根本接管不了引脚。解决方法是回头检查can_port.c里的 GPIO 配置,并用万用表确认收发器供电和使能引脚电平。
4.2 现象:能发不能收,或者收发都失败
先查过滤器。过滤器组没使能(CAN_FilterActivation为 DISABLE)、FIFO 分配和中断读取的 FIFO 不一致、掩码模式配错,都会导致“帧到了但进不了 FIFO”。排查手段是把过滤器临时设成全接收(ID 和掩码全 0),确认链路通了再逐步收紧。另一个高频原因是终端电阻:CAN 总线两端各需要 120Ω,只接一端或者一个都没接,短距离可能勉强通信,稍长一点就大量错误帧。
4.3 现象:波特率对不上,错误计数器飙升
位时序参数和实际 APB1 时钟不匹配是主因。STM32F103 在 72MHz 主频下 APB1 是 36MHz,但如果你改过时钟树,APB1 可能不是 36MHz,此时按 36MHz 算出来的分频值就全错了。解决方法是先确认SystemCoreClock和 APB1 分频,再套公式重算CAN_Prescaler。采样点位置也有讲究,BS1 和 BS2 的比例影响采样点,工业现场常用 87.5% 左右,即(1+BS1)/(1+BS1+BS2)接近 0.875。
4.4 现象:运行一段时间后节点掉线,复位才能恢复
这是没开CAN_ABOM的典型表现。总线干扰导致错误计数超过 255,节点进入离线状态,标准库默认不会自动恢复。把CAN_ABOM设为 ENABLE 后,硬件会在检测到 128 次连续 11 位隐性后自动回到正常状态。同时建议在应用层加一个错误计数监控,通过CAN_GetLastErrorCode读取最近错误类型,便于定位是位错误、格式错误还是应答错误。
4.5 现象:中断进不去,或者进去一次就卡死
中断进不去先查向量表里的函数名是否和启动文件一致,再查NVIC_Init是否使能了对应通道。进去一次就卡死,多半是中断里没清挂起标志,CAN_ClearITPendingBit漏掉就会反复触发。还有一种情况是中断优先级配置冲突,CAN RX0 和 USB 共用中断向量,如果工程里同时用了 USB,要确认两者没有互相覆盖。
5. 二次开发改造:从跑通到落地的几个实用手法
把这套实例代码跑通只是起点,真正落地时要按项目需求改造。我一般从三个方向动手:协议层封装、错误处理强化、可测试性设计。
协议层封装上,建议在can_app.c之上再加一层can_protocol.c,把“ID 含义、数据字段、周期”从收发逻辑里剥离出来。比如定义一个结构体描述每条报文:
typedef struct { uint32_t id; uint8_t dlc; uint16_t period_ms; void (*on_recv)(uint8_t *data, uint8_t len); } CanMsgDef; static const CanMsgDef msg_table[] = { {0x123, 8, 100, NULL}, /* 本节点发送的心跳 */ {0x456, 8, 0, HandleCtrlFrame}, /* 接收的控制帧 */ };这样新增一条报文只改表,不动收发主流程,后期维护成本低很多。错误处理上,把CAN_App_Send的返回值利用起来,邮箱满时进重试队列而不是直接丢,同时统计发送失败次数,通过调试串口输出,现场排查时这就是黑匣子。可测试性上,建议留一个“回环模式”开关,把CAN_Mode临时改成CAN_Mode_LoopBack,不接收发器也能自测收发链路,验证过滤器 and 中断配置是否正确,这个习惯帮我省过很多次来回插拔线的时间。
还有一个容易被忽略的点:CAN 帧的字节序。多字节数据在总线上是大端传输,如果你的 MCU 是小端,组帧和解析时要做转换,否则对端读出来的数值是反的。我一般会在协议层统一用移位拼装,不直接 memcpy 结构体,避免对齐和字节序双重坑。
从那以后我每次拿到一份新的 CAN 实例代码,都强制先跑回环模式确认收发链路,再上真实总线,最后才调过滤器——这个顺序帮我避开了太多“以为是硬件问题、其实是配置问题”的弯路。希望这套拆解能帮到你,把板子上的 CAN 真正跑起来。
本文还有配套的精品资源,点击获取