简介:这份资源面向嵌入式开发初学者与需要快速上手CAN通信的工程师,提供基于STM32F103RBT6、STM32CubeMX与HAL库的完整CAN开发工程,代码已在Keil uVision 4环境下调试通过,可直接用于工业控制、汽车电子等场景的通信功能验证与二次开发。压缩包共586个文件,约6.01MB,以337个c源文件与104个h头文件为主体,配合43个s启动文件、28个icf及1个sct链接脚本、1个uvproj工程文件与hex、axf等编译产物,完整保留了从外设配置到构建输出的工程结构。资源涵盖CAN时钟与GPIO配置、1Mbps波特率设置、标准帧与扩展帧收发、滤波器配置及收发回调等关键环节,读者可据此理解HAL_CAN_Init、HAL_CAN_Transmit、HAL_CAN_Receive与HAL_CAN_ConfigFilter的调用方式,并借助逻辑分析仪或串口输出验证总线数据。目前已有4449人学习下载,适合作为CAN入门与工程移植的参考模板。
1. 拿到一块 F103 板子想跑 CAN,最省事的起点是什么
手上有一块 STM32F103RBT6 的核心板,外挂一个 TJA1050 收发器,想跟另一台设备做 CAN 通信,结果 CubeMX 里点完 CAN 外设、生成代码、烧进去,串口一行日志都没有,总线也静默——这种场景我见过太多次。这份资源就是给这种时刻准备的:一套基于 HAL 库、在 STM32F103RBT6 上已经调通的 CAN 开发代码,包含初始化、过滤器配置、发送、接收中断这几块能直接跑的最小闭环。它不适合想研究 CANopen 协议栈的人,适合的是手里有板子、有收发器、想先把「一帧数据发出去、再收回来」这件事跑通,然后在此基础上做二次开发的工程师和学生。HAL 库把寄存器操作包了一层,好处是移植快,代价是很多人只记住了HAL_CAN_Start,却不知道过滤器没配对时接收中断永远不会来,这份代码的价值就在于把这些容易翻车的点都摆在了明面上。
2. 工程结构与 CAN 外设初始化:从时钟到波特率的完整链路
2.1 目录组织与文件职责
这套代码沿用的是 CubeMX 生成工程的骨架,没有另起炉灶,所以拿到手之后不需要重新理解一套目录约定。核心文件集中在Core/Src和Core/Inc下,CAN 相关的逻辑被拆成初始化、发送、接收三块,主循环里只留调用。下面是我整理后的典型结构,实际工程里文件名可能略有差异,但职责划分基本一致:
CAN_F103_HAL/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── can.h // CAN 句柄与函数声明 │ │ └── stm32f1xx_hal_conf.h │ └── Src/ │ ├── main.c // 时钟、GPIO、主循环 │ ├── can.c // 初始化、过滤器、收发封装 │ ├── stm32f1xx_it.c // CAN_RX0_IRQHandler 等中断入口 │ └── stm32f1xx_hal_msp.c// CAN 引脚与时钟使能 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ // HAL 库源码 └── MDK-ARM/ 或 STM32CubeIDE/ // 工程文件can.c是二次开发时改动最频繁的文件,发送和接收的封装都放在这里,主循环只负责组织业务数据。stm32f1xx_hal_msp.c里的HAL_CAN_MspInit负责打开 CAN 时钟和配置 PA11/PA12 复用功能,这一块如果漏了,后面所有配置都是空谈。
2.2 时钟与引脚:容易被跳过的一步
STM32F103RBT6 的 CAN 挂在 APB1 总线上,APB1 默认最高 36MHz。CAN 波特率的计算依赖这个时钟,所以第一步必须确认SystemClock_Config里 APB1 的分频没被改乱。常见做法是系统时钟 72MHz、APB1 二分频得到 36MHz,然后 CAN 位时序按36MHz / (Prescaler × (1 + BS1 + BS2))去凑 500kbps。引脚方面,CAN 默认复用 PA11(CAN_RX)和 PA12(CAN_TX),配置成复用推挽输出,接收脚可以加上拉,外部收发器一侧再按芯片手册接终端电阻。
void HAL_CAN_MspInit(CAN_HandleTypeDef *hcan) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_CAN1_CLK_ENABLE(); // 打开 CAN1 外设时钟 __HAL_RCC_GPIOA_CLK_ENABLE(); // PA11/PA12 所在端口时钟 GPIO_InitStruct.Pin = GPIO_PIN_11; // CAN_RX GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_12; // CAN_TX GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // 复用推挽 GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); }这段代码的关键在于两个时钟使能缺一不可,__HAL_RCC_CAN1_CLK_ENABLE漏掉时HAL_CAN_Init会返回错误但很多人不看返回值。接收脚配成上拉输入是稳妥做法,因为收发器的 RXD 在空闲时输出高电平,浮空输入容易在总线无设备时读到抖动。发送脚必须是复用推挽,配成普通输出的话 CAN 控制器根本接管不了引脚。
2.3 位时序参数怎么定
初始化里最容易出错的是CAN_InitTypeDef的四个时序参数。以 36MHz 的 APB1 时钟、目标 500kbps 为例,分频系数取 4,则时间量子为 1/9MHz,一个位需要 18 个量子,BS1 取 15、BS2 取 2、SJW 取 1,加起来正好 18。这套参数在多数 500k 总线上都能稳定工作,采样点落在 87.5% 附近,属于偏保守但兼容性好的配置。
CAN_HandleTypeDef hcan; void CAN_Init(void) { hcan.Instance = CAN1; hcan.Init.Prescaler = 4; // 36MHz / 4 = 9MHz 时间量子 hcan.Init.Mode = CAN_MODE_NORMAL; // 正常模式,调试时可改 LOOPBACK hcan.Init.SyncJumpWidth = CAN_SJW_1TQ; hcan.Init.TimeSeg1 = CAN_BS1_15TQ; // 传播段 + 相位段1 hcan.Init.TimeSeg2 = CAN_BS2_2TQ; // 相位段2 hcan.Init.TimeTriggeredMode = DISABLE; hcan.Init.AutoBusOff = ENABLE; // 总线关闭后自动恢复 hcan.Init.AutoWakeUp = DISABLE; hcan.Init.AutoRetransmission = ENABLE;// 发送失败自动重传 hcan.Init.ReceiveFifoLocked = DISABLE; hcan.Init.TransmitFifoPriority = DISABLE; if (HAL_CAN_Init(&hcan) != HAL_OK) { Error_Handler(); // 初始化失败必须停下来查 } }AutoBusOff打开后,节点因为错误计数超限进入 Bus-Off 状态时硬件会自动尝试恢复,省掉手动干预。AutoRetransmission在调试阶段建议保留,否则一帧发不出去就丢了,排查时很难判断是总线问题还是代码问题。Mode字段在只有一块板子、没有第二个节点的时候可以临时改成CAN_MODE_LOOPBACK,自发自收能验证收发链路是否通,这个技巧在手上只有一个收发器时特别有用。
3. 过滤器、发送与接收中断:让一帧数据真正流动起来
3.1 过滤器组配置:接收中断不来的头号原因
HAL 库的 CAN 接收依赖过滤器,过滤器没配或者配错,HAL_CAN_GetRxMessage永远拿不到数据,中断也不会触发。F103 的 CAN1 有 14 个过滤器组,每个组可以配成屏蔽位模式或标识符列表模式。最省事的做法是开一组、配成屏蔽位模式、掩码全 0,等于接收所有帧,先把链路跑通再收紧。
void CAN_Filter_Config(void) { CAN_FilterTypeDef filter; filter.FilterBank = 0; // 使用过滤器组 0 filter.FilterMode = CAN_FILTERMODE_IDMASK; // 屏蔽位模式 filter.FilterScale = CAN_FILTERSCALE_32BIT; // 32 位尺度 filter.FilterIdHigh = 0x0000; // 标识符高 16 位 filter.FilterIdLow = 0x0000; filter.FilterMaskIdHigh = 0x0000; // 掩码全 0,全部放行 filter.FilterMaskIdLow = 0x0000; filter.FilterFIFOAssignment = CAN_RX_FIFO0; // 关联到 FIFO0 filter.FilterActivation = ENABLE; filter.SlaveStartFilterBank = 14; // 单 CAN 时无实际作用 if (HAL_CAN_ConfigFilter(&hcan, &filter) != HAL_OK) { Error_Handler(); } }掩码全 0 意味着「不关心任何位」,所有帧都进 FIFO0。等业务稳定后,把FilterIdHigh/Low填成目标标识符、掩码填成需要匹配的位,就能只收关心的帧。这里有个细节:32 位尺度下标识符要左移 5 位再填,因为标准帧的 11 位 ID 在寄存器里是从 bit5 开始对齐的,直接填原始 ID 会导致匹配不上,这是很多人第一次配过滤器时踩的坑。
3.2 发送一帧标准数据帧
发送流程是「填报文头 → 填数据 → 调发送函数 → 等邮箱空」。HAL 库的HAL_CAN_AddTxMessage会把报文放进三个发送邮箱之一,返回 OK 只代表进了邮箱,不代表已经发到总线上,真正发完要看HAL_CAN_GetTxMailboxesFreeLevel或者等发送完成回调。
uint8_t CAN_SendStd(uint32_t id, uint8_t *data, uint8_t len) { CAN_TxHeaderTypeDef txHeader; uint32_t txMailbox; txHeader.StdId = id; // 标准帧 11 位标识符 txHeader.ExtId = 0; txHeader.IDE = CAN_ID_STD; // 标准帧 txHeader.RTR = CAN_RTR_DATA; // 数据帧 txHeader.DLC = len; // 数据长度 0~8 txHeader.TransmitGlobalTime = DISABLE; if (HAL_CAN_AddTxMessage(&hcan, &txHeader, data, &txMailbox) != HAL_OK) { return 1; // 邮箱满或总线异常 } return 0; }DLC超过 8 会被硬件截断,经典 CAN 一帧最多 8 字节,这是协议本身的限制,不是 HAL 的问题。txMailbox是输出参数,告诉你这帧进了哪个邮箱,调试时可以打印出来确认三个邮箱的占用情况。如果连续发送时返回失败,多半是三个邮箱都满了,说明总线速率跟不上发送节奏,要么降发送频率,要么检查总线上有没有节点一直不 ACK。
3.3 接收中断与回调
接收走中断方式时,需要在初始化后调用HAL_CAN_ActivateNotification打开CAN_IT_RX_FIFO0_MSG_PENDING,然后在stm32f1xx_it.c里确保CAN_RX0_IRQHandler调用了HAL_CAN_IRQHandler,最后在HAL_CAN_RxFifo0MsgPendingCallback里取数据。这条链路任何一环断了,数据都到不了应用层。
void CAN_StartReceiveIT(void) { HAL_CAN_Start(&hcan); // 必须先 Start 才能开中断 HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING); } void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rxHeader; uint8_t rxData[8]; if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rxHeader, rxData) != HAL_OK) { return; } // rxHeader.StdId 为标识符,rxHeader.DLC 为实际长度 // 业务处理放在这里,注意不要做耗时操作 }回调里只做取数据和轻量处理,耗时逻辑丢到主循环用标志位触发,否则中断里拖太久会影响后续帧的接收。HAL_CAN_Start必须在开中断之前调用,顺序反了中断使能了但外设没启动,同样收不到数据。
4. 避坑与排查:CAN 调不通时按这个顺序查
4.1 现象:发送返回 OK,但总线上测不到波形
原因通常是收发器没工作或者引脚复用没生效。先量 TJA1050 的 VCC 和 STB 引脚,STB 拉低才是正常模式;再确认 PA12 是否真的被 CAN 外设接管,用示波器看发送瞬间有没有电平跳变。如果收发器供电正常、引脚也对,检查HAL_CAN_MspInit是否真的被调用——有时候 CubeMX 重新生成代码会把用户手写的 MspInit 覆盖掉。
4.2 现象:接收中断一次都不进
按顺序查三处:过滤器是否FilterActivation = ENABLE、HAL_CAN_Start是否在ActivateNotification之前、中断向量表里CAN_RX0_IRQHandler是否指向了HAL_CAN_IRQHandler。这三处任意一处缺失都会导致中断静默。还有一种情况是过滤器配了但标识符对齐错了,帧被硬件过滤掉,表现和没配过滤器一样。
4.3 现象:两块板子单独都能发,接一起就互相收不到
十有八九是波特率不一致或者终端电阻缺失。CAN 总线两端各需要 120 欧终端电阻,只接一块板子做回环测试时不需要,但两个节点通信时缺了终端电阻,信号反射会让采样出错。用万用表量 CAN_H 和 CAN_L 之间的电阻,两个节点都接上时应该接近 60 欧,只有 120 欧说明只接了一端。
4.4 现象:跑一段时间后发送全部失败
查HAL_CAN_GetError的返回值,如果是 Bus-Off,说明错误计数超限,常见于总线上有节点频繁发送错误帧或者波特率偏差过大。AutoBusOff打开后硬件会尝试恢复,但根本原因还得从布线和波特率上找。另外注意AutoRetransmission打开时,一帧一直发不出去会反复占用邮箱,表现就是三个邮箱全满、后续发送全部返回失败。
5. 二次开发:把最小闭环改成能用的业务代码
拿到这套代码之后,真正要做的改造集中在三个方向。第一是过滤器收紧,把掩码全 0 改成按业务标识符匹配,避免无关帧占用中断;第二是发送侧加队列,主循环里不要直接调HAL_CAN_AddTxMessage,而是把待发帧塞进环形缓冲区,用一个发送任务按邮箱空闲情况逐帧发出,这样总线繁忙时不会丢帧;第三是接收侧做协议解析,回调里只把StdId和DLC连同数据拷进接收队列,解析放到主循环,避免中断里做分支判断。
下面这个发送队列的骨架是我一般会先搭的,改动量不大但能明显提升稳定性:
typedef struct { uint32_t id; uint8_t data[8]; uint8_t len; } CanFrame_t; #define TX_QUEUE_SIZE 16 static CanFrame_t txQueue[TX_QUEUE_SIZE]; static volatile uint8_t txHead = 0, txTail = 0; // 主循环里周期调用,邮箱有空位就发一帧 void CAN_TxPoll(void) { if (txHead == txTail) return; // 队列空 if (HAL_CAN_GetTxMailboxesFreeLevel(&hcan) == 0) return; // 无空闲邮箱 CanFrame_t *f = &txQueue[txTail]; if (CAN_SendStd(f->id, f->data, f->len) == 0) { txTail = (txTail + 1) % TX_QUEUE_SIZE; // 发送成功才出队 } }队列长度 16 是按常见控制场景估的,帧率高的时候可以加大,但要注意 RAM 占用,F103RBT6 只有 20KB SRAM,队列别开太大。txTail只在发送成功后推进,失败时下一轮重试,配合AutoRetransmission能覆盖大部分瞬时拥塞。
验证改造是否有效,我习惯用两步:先用CAN_MODE_LOOPBACK自发自收跑一遍队列逻辑,确认收发和解析都对;再切回CAN_MODE_NORMAL接真实节点,用逻辑分析仪抓总线看帧间隔和错误帧计数。回环模式能排除硬件和接线因素,把问题锁定在软件层,这个习惯帮我省过不少排查时间。
还有一个容易被忽略的点是时间戳。HAL 的接收回调里拿不到硬件时间戳,如果业务需要判断帧的到达顺序或者做超时检测,得在回调里用HAL_GetTick()打一个软件时间戳一起入队。精度到毫秒级,对大多数控制场景够用,要求更高就得开 CAN 的定时器触发模式,那是另一个复杂度了。
从那以后我每次拿到一份「已调通」的 CAN 代码,都强制先跑一遍回环、再量一遍终端电阻、最后才接真实总线,这三步走完再谈业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取