ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103 CAN通信实战:从位定时计算到滤波器配置的避坑指南

STM32F103 CAN通信实战:从位定时计算到滤波器配置的避坑指南

简介:这份资源面向嵌入式开发初学者与STM32工程师,聚焦STM32F103系列微控制器的CAN总线通信实现,适用于汽车电子、工业自动化及物联网等需要多节点可靠通信的场景。压缩包共206个文件,约4.73MB,以C源码、头文件、编译中间文件及Keil工程配置为主,涵盖CAN外设初始化、滤波器设置、收发函数与错误管理等核心模块,并附带LCD、定时器、Flash、RCC等外设驱动,便于在完整工程中理解CAN通信的集成方式。资源围绕CAN控制器与收发器的硬件连接、标准ID与扩展ID帧格式、波特率配置、中断收发及错误状态处理展开,读者可据此掌握HAL_CAN_Transmit与HAL_CAN_GetRxMessage等关键接口的使用,并参考工程目录结构快速搭建可运行的CAN通信实验。目前已有1909人学习下载,适合需要从零实现STM32F103 CAN通信、排查总线异常或完善嵌入式通信方案的开发者参考。

1. stm32f103can通信:从点灯到总线,一个让新手翻车的分水岭

很多人第一次接触 stm32f103can通信,都是被“CAN 比串口高级”这句话带进来的。我当年也是,串口收发跑通了,觉得总线不过如此,结果接上收发器、写了两行发送代码,示波器上连个毛刺都没有。问题不在代码,在于 CAN 不是“你发我就收”的透明管道,它是一套带仲裁、带位定时、带错误帧的差分总线协议。STM32F103 的 bxCAN 外设把协议层几乎全包了,但前提是你得把 GPIO 重映射、波特率分频、滤波器、终端电阻这几件事同时做对。这篇笔记面向已经能跑通 GPIO 和串口、准备把两块 F103 板子用 CAN 连起来的工程师,我会把标准库和 HAL 两条路都拆开讲,参数怎么算、坑在哪、怎么用回环模式先自检,全部落到可复现的命令和代码上。

2. 先把物理层和位定时算清楚:CAN 不通,八成卡在这

2.1 差分线、120 欧终端电阻和共地,一个都不能少

CAN 的物理层是差分信号,CAN_H 和 CAN_L 之间的电压差决定显性位和隐性位。显性位(逻辑 0)压差约 2V,隐性位(逻辑 1)压差约 0V。这意味着两件事:第一,总线上任意一个节点发显性位,就能覆盖隐性位,这是仲裁的物理基础;第二,如果 CAN_H 和 CAN_L 接反,或者终端电阻缺失,差分电压根本建立不起来,收发器不会输出有效电平。

我一般会先拿万用表量三样东西:CAN_H 对 CAN_L 的静态电阻,两个 120 欧并联后应该是 60 欧左右;CAN_H 对地、CAN_L 对地的静态电压,收发器正常工作时都在 2.5V 附近;两块板子的 GND 必须连在一起,否则共模电压漂到收发器承受范围之外,通信时好时坏,这种玄学问题最耗时间。

终端电阻只在总线两端各放一个,中间节点不要放。很多开发板已经把 120 欧焊在板上了,如果你再外接一个,总线负载变成 40 欧,波形幅度被拉低,短距离可能还能跑,长距离直接丢帧。常见做法是:两块板点对点通信,两端各一个 120 欧;三块以上,只有物理最远的两端放。

2.2 位定时参数怎么算:以 500kbps 为例手算一遍

STM32F103 的 bxCAN 挂在 APB1 上,默认 36MHz。位定时由三段组成:同步段(SYNC_SEG,固定 1Tq)、时间段 1(BS1)、时间段 2(BS2)。采样点落在 BS1 结束处。波特率 = 1 / (Tq × (1 + BS1 + BS2)),其中 Tq = (BRP) / 36MHz。

目标 500kbps,周期 2us。取 BRP=4,则 Tq = 4/36MHz ≈ 111.1ns。需要总 Tq 数 = 2us / 111.1ns ≈ 18。于是 1 + BS1 + BS2 = 18,取 BS1=15,BS2=2,采样点 = (1+15)/18 ≈ 88.9%,这是 CAN 推荐的采样点位置(75%~87.5% 之间偏上)。

对应到代码里,标准库的配置如下:

// 标准库:CAN 位定时配置,APB1=36MHz,目标 500kbps CAN_InitTypeDef CAN_InitStructure; 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; // FIFO 不锁定,溢出覆盖 CAN_InitStructure.CAN_TXFP = DISABLE; // 发送优先级由标识符决定 CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; // 同步跳转宽度 1Tq CAN_InitStructure.CAN_BS1 = CAN_BS1_15tq; // 时间段1 = 15Tq CAN_InitStructure.CAN_BS2 = CAN_BS2_2tq; // 时间段2 = 2Tq CAN_InitStructure.CAN_Prescaler = 4; // 分频系数 4 CAN_Init(CAN1, &CAN_InitStructure);

逻辑说明:SJW 取 1Tq 是为了在时钟偏差不大时保持稳定,如果两个节点晶振精度差,可以加到 2Tq 或 3Tq。BS1 和 BS2 的比例决定了采样点,BS1 越大采样点越靠后,对长线缆的延迟容忍更好,但太小会压缩相位缓冲。Prescaler 就是 BRP,改这个值直接改波特率,但改完必须重新算 BS1/BS2,不能只动一个。

HAL 库的写法更直观,用CAN_Init里的Prescaler、TimeSeg1、TimeSeg2、SyncJumpWidth四个字段,含义完全一样。注意 HAL 里TimeSeg1对应的是 BS1,不包含同步段,别多算一个 Tq。

2.3 回环模式先自检,别一上来就接总线

新画的板子或者新写的驱动,我强烈建议先跑回环模式(Loopback)。bxCAN 支持静默回环和正常回环,回环模式下发送的帧不经过收发器,直接在内部被接收,用来验证滤波器、FIFO、中断配置是否正确。如果回环都收不到,问题一定在软件;回环能收,接上总线收不到,再去查物理层。

// 回环模式初始化,用于软件自检 CAN_InitStructure.CAN_Mode = CAN_Mode_LoopBack; CAN_Init(CAN1, &CAN_InitStructure); // 发送一帧标准数据帧,标识符 0x123,数据长度 8 CanTxMsg TxMessage; TxMessage.StdId = 0x123; TxMessage.ExtId = 0; TxMessage.IDE = CAN_Id_Standard; TxMessage.RTR = CAN_RTR_Data; TxMessage.DLC = 8; for (uint8_t i = 0; i < 8; i++) { TxMessage.Data[i] = i; // 填充 0~7 便于比对 } uint8_t mailbox = CAN_Transmit(CAN1, &TxMessage); // 等待发送完成,超时退出避免死等 uint32_t timeout = 0xFFFFF; while (CAN_TransmitStatus(CAN1, mailbox) != CAN_TxStatus_Ok && timeout--);

这段代码里CAN_Transmit返回的是邮箱编号(0~2),不是成功失败。必须用CAN_TransmitStatus查状态,或者等CAN_IT_TME中断。timeout递减是防止硬件异常时卡死,实际项目里我会用定时器做超时。回环模式下如果接收中断能进,且数据对得上,说明 bxCAN 的收发路径是通的。

3. 滤波器、FIFO 和中断:让接收端只拿你想要的那几帧

3.1 滤波器组怎么配:掩码模式和列表模式的选择

STM32F103 有 14 个滤波器组(互联型是 28 个),每个组可以配成 32 位或 16 位。32 位掩码模式最常用:一个寄存器放标识符,一个放掩码,掩码位为 1 表示该位必须匹配,为 0 表示不关心。比如我只想收标准帧 ID 0x123,掩码全 1,就是精确匹配;如果想收 0x120~0x12F,掩码低 4 位设 0,其余设 1。

// 滤波器 0:32 位掩码模式,只接收标准帧 ID 0x123 CAN_FilterInitTypeDef CAN_FilterInitStructure; CAN_FilterInitStructure.CAN_FilterNumber = 0; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = 0x123 << 5; // 标准帧 ID 左移 5 位 CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0xFFFF; // 高 16 位全匹配 CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; // 低 16 位不关心 CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_Filter_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(&CAN_FilterInitStructure);

关键点:标准帧的 11 位 ID 在过滤器里是左对齐的,CAN_FilterIdHigh的高 11 位才是 ID,所以必须左移 5 位。这一点手册里写得清楚,但很多人第一次配完发现收不到,就是忘了移位。掩码的对应关系是:FilterIdHigh和FilterMaskIdHigh逐位对应,掩码为 1 的位参与比较。

列表模式适合需要精确接收多个不连续 ID 的场景,但会消耗更多滤波器组。如果 ID 数量少且固定,列表模式更省 CPU;如果是一段连续 ID,掩码模式更划算。

3.2 FIFO0 和 FIFO1 的分流策略

bxCAN 有两个接收 FIFO,每个 3 级深度。滤波器可以指定把匹配的帧丢进 FIFO0 还是 FIFO1。我一般把高优先级、需要快速响应的 ID 放 FIFO0,用更高抢占优先级的中断处理;低优先级、可以批量处理的放 FIFO1。这样避免低优先级帧把 FIFO 占满导致高优先级帧丢失。

FIFO 溢出是个隐蔽的坑:如果中断没及时取走,第 4 帧进来时 FIFO 溢出,CAN_RF0R的FOVR0位会置 1,但默认不报错,只是新帧被丢弃。调试期我会在中断里读一下溢出标志,确认没有静默丢帧。

// FIFO0 接收中断处理,读取一帧并打印 void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg RxMessage; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET) { CAN_Receive(CAN1, CAN_FIFO0, &RxMessage); // 检查是否溢出,溢出说明中断响应太慢 if (CAN_GetFlagStatus(CAN1, CAN_FLAG_FOV0) != RESET) { CAN_ClearFlag(CAN1, CAN_FLAG_FOV0); } // RxMessage.StdId / Data[0..DLC-1] 可用 } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); }

中断向量名取决于芯片型号和启动文件,F103 通常是USB_LP_CAN1_RX0_IRQHandler。如果编译报未定义,去启动文件里搜CAN1_RX0确认。CAN_Receive会同时清掉 FMP0 标志,但保险起见再清一次中断挂起位。

3.3 发送优先级和邮箱占用

三个发送邮箱,优先级由标识符数值决定,数值越小优先级越高。如果三个邮箱都满,CAN_Transmit会返回CAN_TxStatus_NoMailBox。我一般会在发送前检查返回值,如果没邮箱就等一个发送完成中断再重试,而不是死循环。

// 带邮箱检查的发送函数 uint8_t CAN_SendStd(uint16_t std_id, uint8_t *data, uint8_t len) { CanTxMsg tx; tx.StdId = std_id; tx.IDE = CAN_Id_Standard; tx.RTR = CAN_RTR_Data; tx.DLC = len; for (uint8_t i = 0; i < len; i++) tx.Data[i] = data[i]; uint8_t mb = CAN_Transmit(CAN1, &tx); if (mb == CAN_TxStatus_NoMailBox) { return 1; // 无空闲邮箱,调用者稍后重试 } return 0; }

参数说明:len最大 8,超过 8 会被截断或触发错误。标准帧 ID 范围 0x000~0x7FF,扩展帧 0x00000000~0x1FFFFFFF。如果传了超出范围的值,bxCAN 不会自动截断,可能发出意外 ID,所以调用前自己 clamp 一下。

4. 避坑与排查:CAN 不通时我按这个顺序查

4.1 现象:发送成功但接收端毫无反应

原因:最常见的是终端电阻缺失或 CAN_H/CAN_L 接反。其次是波特率不匹配,两个节点一个 500k 一个 250k,仲裁阶段就出错。还有一种情况是滤波器把所有帧都过滤掉了,接收端 FIFO 始终为空。

解决:先用示波器看 CAN_H 和 CAN_L 的差分波形,正常发送时应该有明显的显性位脉冲。如果没有波形,查收发器供电和使能引脚(有些收发器有 STB 或 EN 脚,低电平才工作)。如果有波形但接收不到,把滤波器临时改成全通过(掩码全 0),确认是不是滤波问题。波特率用回环模式自测,回环能通说明位定时算对了。

4.2 现象:通信一段时间后突然全部报错,总线瘫痪

原因:某个节点进入错误被动或总线关闭状态,持续发错误帧。常见诱因是某个节点晶振偏差太大,或者总线某段接线松动导致反射严重。CAN_ABOM没开的话,节点进入离线后不会自动恢复。

解决:初始化时把CAN_ABOM置 ENABLE,让硬件在检测到 128 次连续 11 个隐性位后自动恢复。同时检查各节点晶振,CAN 对时钟精度要求比串口高,陶瓷谐振器往往不够,要用晶振。总线分支尽量短,星形拓扑在 CAN 里是大忌。

4.3 现象:回环模式正常,一接总线就进不了接收中断

原因:GPIO 重映射没做,或者重映射后没使能 AFIO 时钟。F103 的 CAN 默认引脚是 PA11/PA12,重映射到 PB8/PB9 需要开 AFIO 时钟并配置GPIO_PinRemapConfig。另一个可能是收发器的 TX/RX 和 MCU 的 CAN_TX/CAN_RX 交叉接错,TX 必须接 TX,RX 接 RX,不能像串口那样交叉。

解决:确认RCC_APB2Periph_AFIO时钟已开,重映射配置在 GPIO 初始化之前。用万用表通断档查 MCU 的 CAN_TX 到收发器 TXD 是否直连。如果用的是带隔离的收发器模块,还要确认隔离侧供电是否正常。

4.4 现象:中断里读到的数据长度对,但内容错位

原因:CAN_Receive之后没有及时取走数据,FIFO 里下一帧覆盖了当前帧的缓冲区。或者发送端 DLC 设了 8,但实际只填了 4 个字节,接收端按 8 字节读,后 4 字节是上一次的残留。

解决:接收中断里先把数据拷到自己的缓冲区,再清中断。发送端严格按实际数据长度设 DLC,不要图省事固定 8。如果数据本身有结构体,注意字节对齐和大小端,CAN 传输的是原始字节流,没有类型信息。

4.5 现象:两个节点单独通信正常,三个以上就随机丢帧

原因:总线负载过高,或者某个节点发送过于频繁,仲裁失败后不断重试,把总线占满。CAN 没有重传次数上限(NART关闭时),失败就无限重发,低优先级帧可能永远发不出去。

解决:算一下总线负载率,500kbps 下每帧约 108 位(标准帧含填充),如果每秒发 1000 帧,负载约 21.6%,还算安全;超过 50% 就要考虑降频或分批。调试期可以临时开NART(禁止自动重传),让发送失败快速返回,暴露问题而不是掩盖问题。

5. 进阶:用 CAN 做固件升级和双机冗余的实操要点

CAN 除了传传感器数据,还能做固件升级(IAP)和双机冗余。IAP 的思路是:Bootloader 常驻,通过 CAN 接收固件包,写入 Flash 的应用区,校验通过后跳转。关键点是 Flash 分页要和 F103 的页大小对齐(中容量 1KB/页,大容量 2KB/页),擦除前先解锁,写入时按半字(16 位)编程。

// CAN IAP 写入一帧固件数据到 Flash // 假设应用区起始 0x08004000,数据已按 2 字节对齐 uint8_t flash_write(uint32_t addr, uint8_t *data, uint16_t len) { FLASH_Unlock(); for (uint16_t i = 0; i < len; i += 2) { uint16_t half = data[i] | (data[i+1] << 8); if (FLASH_ProgramHalfWord(addr + i, half) != FLASH_COMPLETE) { FLASH_Lock(); return 1; // 写入失败 } } FLASH_Lock(); return 0; }

参数说明:addr必须是 2 的倍数,且落在应用区范围内。写入前该页必须已擦除,F103 的 Flash 只能把 1 写成 0,不能把 0 写回 1。固件包建议加 CRC32 校验,收完整个包再跳转,避免半包变砖。跳转前关掉所有中断和外设,设置 MSP 和复位向量。

双机冗余则是两个节点互发心跳帧,标识符固定,周期比如 100ms。如果连续 3 个周期收不到对方心跳,判定对方离线,接管关键输出。这里要注意心跳帧的优先级要设高,避免被大数据帧阻塞。我一般把心跳 ID 设成 0x000,数值最小,仲裁必赢。

最后说一个我踩过的坑:调试 CAN 时不要开着NART又开着回环模式跑压力测试,发送失败不重传,回环 FIFO 又满,最后现象是“发送成功但接收不到”,查了半天才发现是邮箱和 FIFO 同时溢出。现在的习惯是,任何 CAN 工程先跑通回环,再跑通点对点,最后上多节点,每一步都用示波器和溢出标志确认,不跳步。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进