第二部分:STM32 实现 CAN 通信
第一部分讲了 CAN 协议"是什么",第二部分讲 STM32 “怎么做”。
每个概念都会回溯到第一部分的理论,帮你建立对应关系。
9. STM32 的 CAN 外设长什么样?
9.1 先看全貌
STM32F1 内置的 CAN 控制器叫 bxCAN(Basic Extended CAN)。它把第一部分讲的协议逻辑全部用硬件实现了——你不需要手动拼帧、算 CRC、处理仲裁,只需要告诉硬件"发什么"和"收什么"。
整体框图:
┌─────────────────────────────────────────────────────────────────┐
│ bxCAN 控制器 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 发送邮箱0 │ │ 发送邮箱1 │ │ 发送邮箱2 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ └──────────────┬────┴─────────────────┘ │
│ ↓ │
│ ┌─────────────────┐ │
│ │ 发送调度器 │ ← 按 ID 优先级决定谁先发 │
│ └────────┬────────┘ │
│ ↓ │
│ ─────────────── CAN TX ────────────────────────── │
│ │
│ ─────────────── CAN RX ────────────────────────── │
│ ↓ │
│ ┌───────────────────────┐ │
│ │ 过滤器组 (14个Bank) │ ← 硬件筛选,决定收不收 │
│ └─────────┬─────────────┘ │
│ ┌─────┴─────┐ │
│ ↓ ↓ │
│ ┌──────────┐ ┌──────────┐ │
│ │ FIFO 0 │ │ FIFO 1 │ ← 各3层深度 │
│ │ (3条消息) │ │ (3条消息) │ │
│ └────┬─────┘ └────┬─────┘ │
│ ↓ ↓ │
│ 中断通知 CPU 来取 │
│ │
└─────────────────────────────────────────────────────────────────┘
9.2 三大组件详解
发送邮箱(3个)
"邮箱"就是发送缓冲区。你把消息(ID + 数据)写进邮箱,硬件自动完成:
你写入邮箱的内容: 硬件自动完成的事情:
┌──────────────┐ ┌──────────────────────┐
│ ID = 0x100 │ │ 加上 SOF │
│ DLC = 8 │ ──硬件──→ │ 加上 CRC │
│ DATA = ... │ │ 处理仲裁 │
│ RTR = 数据帧 │ │ 等待 ACK │
└──────────────┘ │ 位填充 │
│ 错误重发 │
└──────────────────────┘
三个邮箱可以同时排队。当多个邮箱都有待发消息时,由发送调度器决定先发哪个。调度器有两种模式:
| 模式 | 行为 | 配置 |
|---|---|---|
| ID 优先级(默认) | ID 值最小的邮箱先发,和总线仲裁逻辑一致(第 3 章) | TransmitFifoPriority = DISABLE |
| FIFO 顺序 | 谁先写入邮箱谁先发,先来后到 | TransmitFifoPriority = ENABLE |
什么时候用 FIFO 模式?当你的应用需要保证消息按发送顺序到达时(比如多帧协议),FIFO 模式更合适。如果用 ID 优先级模式,后写入的低 ID 消息可能插队到前面。
接收 FIFO(2个,各3层深度)
每个 FIFO 能缓存 3 条消息,新消息自动入队:
过滤器通过的消息 → ┌────┬────┬────┐
│ 消息1│ 消息2│ 消息3│ ← 3层深度
└────┴────┴────┘
↓
CPU 从这里读取
如果 FIFO 满了还没取走:
- 溢出:新消息丢弃(默认),或覆盖最旧的(可配置)
这就是为什么我们在代码中加了环形缓冲区——中断里尽快把 FIFO 的消息搬走,避免溢出。
过滤器组(14个 Bank)
这就是第 8 章讲的"硬件过滤器"在 STM32 上的实现。
每个 Bank 是一个 64 位的寄存器对(两个 32 位寄存器),可以灵活配置为不同模式:
一个 Bank = 64 位
32位模式(常用):
┌────────────────────────────────────┐
│ 寄存器1 (32位): ID / 过滤ID │
│ 寄存器2 (32位): MASK / 过滤ID │
└────────────────────────────────────┘
→ 掩码模式: 1组 (ID + MASK)
→ 列表模式: 2个精确ID
16位模式:
┌────────────────────────────────────┐
│ 寄存器1: [ID1(16)] [ID2(16)] │
│ 寄存器2: [MASK1(16)] [MASK2(16)] │
└────────────────────────────────────┘
→ 掩码模式: 2组 (ID + MASK)
→ 列表模式: 4个精确ID
每个 Bank 可以独立指定把匹配的消息放进 FIFO 0 还是 FIFO 1。
9.3 与第一部分的对应关系
| 第一部分(协议概念) | STM32 bxCAN(硬件实现) |
|---|---|
| 帧格式(SOF、ID、CRC、ACK、EOF) | 硬件自动处理,你只管填 ID 和 DATA |
| 总线仲裁(ID 优先级) | 硬件自动完成;发送调度器也按 ID 排序 |
| 位填充 | 硬件自动插入和去除 |
| 位时序 / 波特率 | 通过 BS1、BS2、Prescaler 寄存器配置 |
| ACK 机制 | 硬件自动处理,发送失败会自动重发 |
| 5 种错误检测 | 硬件检测,结果记录在 ESR 寄存器 |
| 错误状态机(主动/被动/离线) | ESR 寄存器的 EWGF、EPVF、BOFF 标志位 |
| 硬件过滤器 | 14 个 Filter Bank,支持掩码/列表模式 |
一句话总结:第一部分讲的所有机制,bxCAN 都用硬件干了。软件只负责三件事:配置参数、填发送数据、取接收数据。
10. 怎么让 CAN 跑起来?
10.1 CubeMX 配置速览
在写代码之前,先用 CubeMX 生成基础框架。CAN 配置分三块:
位时序参数(Bit Timings Parameters)
┌─────────────────────────────────────────────────────┐
│ Prescaler (for Time Quantum) 9 │
│ Time Quantum 250.0 ns │ ← 自动计算
│ Time Quanta in Bit Segment 1 5 Times │
│ Time Quanta in Bit Segment 2 2 Times │
│ Time for one Bit 2000 ns │ ← 自动计算
│ Baud Rate 500000 bit/s │ ← 自动计算
│ ReSynchronization Jump Width 1 Time │
└─────────────────────────────────────────────────────┘
这些参数直接对应第 4 章讲的位时序概念:
| CubeMX 参数 | 对应概念 | 说明 |
|---|---|---|
| Prescaler | 分频系数 | APB1 时钟 ÷ Prescaler = CAN 时钟 |
| BS1 | 相位缓冲段1 | 包含 PROP_SEG,采样点前的时间 |
| BS2 | 相位缓冲段2 | 采样点后的时间 |
| SJW | 同步跳转宽度 | 重同步时允许调整的最大 Tq 数 |
波特率验算(假设 APB1 = 36MHz):
Tq = Prescaler / APB1 = 9 / 36MHz = 250ns
1 bit = (1 + BS1 + BS2) × Tq = (1 + 5 + 2) × 250ns = 2000ns
波特率 = 1 / 2000ns = 500 kbps ✓
采样点 = (1 + BS1) / (1 + BS1 + BS2) = 6/8 = 75%
基础参数(Basic Parameters)
┌─────────────────────────────────────────────────────┐
│ Time Triggered Communication Mode Disable │
│ Automatic Bus-Off Management Enable │ ← 重要
│ Automatic Retransmission Enable │ ← 重要
│ Automatic Wake-Up Mode Disable │
│ Receive FIFO Locked Mode Disable │
│ Transmit FIFO Priority Disable │ ← 第9章讲过
└─────────────────────────────────────────────────────┘
| 参数 | 建议值 | 说明 |
|---|---|---|
| Automatic Bus-Off Management | Enable | 离线后自动恢复(第 7 章的离线恢复机制) |
| Automatic Retransmission | Enable | 发送失败自动重发,保证可靠性 |
| Receive FIFO Locked Mode | Disable | FIFO 满时丢新消息(Disable)还是丢旧消息(Enable) |
| Transmit FIFO Priority | 按需 | 发送调度模式(第 9 章讲过) |
工作模式(Advanced Parameters)
┌─────────────────────────────────────────────────────┐
│ Test Mode Loopback │
└─────────────────────────────────────────────────────┘
| 模式 | 用途 |
|---|---|
| Normal | 正常通信,连接真实总线 |
| Loopback | 自发自收,不需要外部连线,调试首选 |
| Silent | 只听不发,用于监控/嗅探总线 |
| Silent_Loopback | 内部回环 + 不影响总线,纯软件测试 |
调试技巧:新板子先用 Loopback 模式验证软件逻辑,排除硬件问题。
中断配置(NVIC Settings)
┌──────────────────────────────────────┬─────────┐
│ USB high priority or CAN TX │ ✓ │ ← 发送完成中断
│ USB low priority or CAN RX0 │ ✓ │ ← FIFO0 接收中断
│ CAN RX1 interrupt │ ☐ │ ← FIFO1(没用到可不开)
│ CAN SCE interrupt │ ✓ │ ← 错误/状态变化中断
└──────────────────────────────────────┴─────────┘
注意:STM32F1 的 CAN 和 USB 共享中断向量,所以名字带 “USB”。
10.2 CubeMX 生成了什么?
CubeMX 生成的 can.c 主要做两件事:
// 1. 填充 hcan.Init 结构体
hcan.Init.Prescaler = 9;
hcan.Init.Mode = CAN_MODE_LOOPBACK;
hcan.Init.SyncJumpWidth = CAN_SJW_1TQ;
hcan.Init.TimeSeg1 = CAN_BS1_5TQ;
hcan.Init.TimeSeg2 = CAN_BS2_2TQ;
hcan.Init.AutoBusOff = ENABLE;
hcan.Init.AutoRetransmission = ENABLE;
// ...
// 2. 配置 GPIO(在 HAL_CAN_MspInit 中)
// PA11 = CAN_RX, PA12 = CAN_TX(或重映射到 PB8/PB9)
但它没有:
- 配置过滤器(必须手动配,否则收不到任何消息!)
- 调用
HAL_CAN_Start() - 使能中断通知
这些需要我们在应用代码中完成。
10.3 完整的启动流程
结合驱动代码,CAN 启动的正确顺序是:
┌─────────────────────────────────────────────────────────────┐
│ CAN 启动流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ① HAL_CAN_Init() CubeMX 自动调用,配置基础参数 │
│ ↓ │
│ ② 配置过滤器 必须!否则所有消息都被丢弃 │
│ ↓ │
│ ③ HAL_CAN_Start() 启动 CAN 外设 │
│ ↓ │
│ ④ HAL_CAN_ActivateNotification() 使能中断 │
│ ↓ │
│ 开始收发 │
│ │
└─────────────────────────────────────────────────────────────┘
对应代码(can_app.c):
uint8_t can_app_init(void)
{
// 初始化环形缓冲区
can_drv_init(rx_buffer, CAN_RX_BUF_SIZE, tx_buffer, CAN_TX_BUF_SIZE);
// ① 设置波特率(内部会修改 hcan.Init 并调用 HAL_CAN_Init)
if (!can_set_baudrate(500000))
return 1;
// 设置工作模式
can_set_mode(CAN_DRV_LOOPBACK);
// ② 配置过滤器
if (!can_filter_init())
return 2;
// ③④ 启动 CAN + 使能中断
if (!can_drv_start())
return 3;
return 0;
}
10.4 波特率的动态配置
CubeMX 只能设置一个固定波特率。如果想在运行时切换(比如支持多种波特率的设备),就需要代码动态计算。
波特率公式回顾
波特率 = APB1时钟 / (Prescaler × (1 + BS1 + BS2))
反过来,给定目标波特率,需要找到合适的 Prescaler、BS1、BS2 组合。
自动搜索算法
驱动中的 can_set_baudrate() 实现了自动搜索:
bool can_set_baudrate(uint32_t baudrate)
{
uint32_t apb1_clk = HAL_RCC_GetPCLK1Freq(); // 获取实际时钟
// 遍历所有可能的 Prescaler
for (prescaler = 1; prescaler <= 1024; prescaler++)
{
uint32_t can_clk = apb1_clk / prescaler;
uint32_t tq_count = can_clk / baudrate; // 每个 bit 需要多少 Tq
// 检查是否整除(不能有小数)
if (can_clk != baudrate * tq_count)
continue;
// Tq 数量必须在合理范围(3~22)
if (tq_count < 3 || tq_count > 22)
continue;
// 分配 BS1 和 BS2,优先保证采样点在 75%~80%
for (bs2 = 5; bs2 >= 1; bs2--)
{
bs1 = tq_count - 1 - bs2; // 1 是 SYNC_SEG
if (bs1 < 1 || bs1 > 16)
continue;
// 确保 BS1 >= 3*BS2 - 1(采样点约 75%+)
if (bs1 < bs2 * 3 - 1)
continue;
goto found; // 找到合适组合
}
}
return false; // 无法实现该波特率
found:
// 应用配置...
}
算法思路:
- 遍历 Prescaler(1~1024)
- 计算每个 bit 需要多少 Tq,必须整除
- 分配 BS1/BS2,优先保证采样点 ≥ 75%
- 找到第一个合法组合就返回
常用波特率参数参考
假设 APB1 = 36MHz:
| 波特率 | Prescaler | BS1 | BS2 | Tq数 | 采样点 |
|---|---|---|---|---|---|
| 1 Mbps | 4 | 5 | 3 | 9 | 66.7% |
| 500 kbps | 9 | 5 | 2 | 8 | 75% |
| 250 kbps | 9 | 13 | 2 | 16 | 87.5% |
| 125 kbps | 18 | 13 | 2 | 16 | 87.5% |
10.5 工作模式切换
驱动支持运行时切换模式:
typedef enum {
CAN_DRV_NORMAL, // 正常模式
CAN_DRV_LOOPBACK, // 回环模式
CAN_DRV_SILENT, // 静默模式
CAN_DRV_SILENT_LOOPBACK // 静默回环
} can_mode_t;
bool can_set_mode(can_mode_t mode)
{
bool was_running = can_running;
if (was_running)
can_hw_stop(); // 先停止
hcan.Init.Mode = mode_map[mode]; // 修改模式
if (was_running)
return can_hw_start(); // 重新启动
return true;
}
注意:模式切换需要先停止 CAN,修改配置后重新启动。
10.6 初始化常见问题
| 现象 | 可能原因 |
|---|---|
| 发送成功但收不到 | 忘记配置过滤器(最常见!) |
| HAL_CAN_Start 返回错误 | 波特率参数不合法,或 GPIO 未配置 |
| Loopback 正常,Normal 不通 | 检查终端电阻、CAN_H/CAN_L 接线 |
| 波特率不匹配 | 两端的 Prescaler/BS1/BS2 配置不一致 |
11. STM32 的过滤器怎么配?
第 8 章讲了过滤器的概念(掩码模式 vs 列表模式),这一章看 STM32 具体怎么实现。
11.1 过滤器的硬件架构
STM32F1 的 bxCAN 有 14 个过滤器组(Filter Bank 0~13),每个 Bank 可以独立配置。
收到的 CAN 帧
│
▼
┌─────────────────────────────────────────────────────────┐
│ 过滤器阵列 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Bank 0 │ │ Bank 1 │ │ Bank 2 │ ... │ Bank 13 │ │
│ │ │ │ │ │ │ │ │ │
│ │ →FIFO0 │ │ →FIFO1 │ │ →FIFO0 │ │ →FIFO1 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ 匹配? 匹配? 匹配? 匹配? │
│ │
└─────────────────────────────────────────────────────────┘
│
任意一个 Bank 匹配即通过
│
┌──────────┴──────────┐
▼ ▼
FIFO 0 FIFO 1
关键点:
- 每个 Bank 可以单独指定把匹配的消息放进 FIFO0 还是 FIFO1
- 多个 Bank 可以同时激活,任意一个匹配就通过
- 消息会记录是被哪个 Bank 匹配的(
FilterMatchIndex)
11.2 一个 Bank 能配成什么样?
每个 Bank 有两个 32 位寄存器,可以灵活组合:
一个 Bank = 64 位 = 2 × 32 位寄存器
┌────────────────────────────────────────────────────────────────┐
│ 配置组合 │
├────────────────┬───────────────┬───────────────────────────────┤
│ 位宽 │ 模式 │ 能配多少 │
├────────────────┼───────────────┼───────────────────────────────┤
│ 32位 │ 掩码模式 │ 1 组 (1个ID + 1个MASK) │
│ 32位 │ 列表模式 │ 2 个精确ID │
│ 16位 │ 掩码模式 │ 2 组 (2个ID + 2个MASK) │
│ 16位 │ 列表模式 │ 4 个精确ID │
└────────────────┴───────────────┴───────────────────────────────┘
32 位模式适合扩展帧(29位ID)或需要精确匹配标准帧。
16 位模式只能匹配标准帧的 ID(11位),但数量多。
本驱动采用 32 位掩码模式,最通用。
11.3 寄存器布局的坑
这是配置过滤器最容易踩的坑:ID 不能直接写进寄存器,要按特定格式摆放!
32 位过滤器寄存器的布局
31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ STID[10:0] / EXID[28:18] │ EXID[17:0] │IDE│RTR│ 0 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
│ │ │ │
│ │ │ └── 保留位(必须为0)
│ │ └────── RTR 位
│ └────────── IDE 位 (0=标准帧, 1=扩展帧)
│
└── 标准帧: 11位ID放在[31:21],其余补0
扩展帧: 29位ID放在[31:3]
标准帧 ID 转换(以 ID = 0x123 为例):
用户给的 ID: 0x123 = 0b001 0010 0011 (11位)
需要放到 [31:21],所以要左移 21 位:
0x123 << 21 = 0x24600000
再左移 3 位腾出 IDE/RTR/保留位:
实际上是先左移 18 位变成 29 位格式,再统一左移 3 位
最终寄存器值: 0x24600000 (IDE=0 表示标准帧)
扩展帧 ID 转换(以 ID = 0x12345678 为例):
用户给的 ID: 0x12345678 (29位有效)
直接左移 3 位:
0x12345678 << 3 = 0x91A2B3C0
然后设置 IDE 位 = 1:
0x91A2B3C0 | 0x04 = 0x91A2B3C4
驱动中的转换函数
这个坑在驱动里用 format_filter_id() 封装掉了:
static uint32_t format_filter_id(uint32_t id, can_id_type_t id_type, bool is_mask)
{
uint32_t reg;
// 标准帧: 先左移 18 位,对齐到 29 位扩展帧格式
if (id_type == CAN_ID_TYPE_STD)
{
id <<= 18;
}
// 统一左移 3 位,腾出 IDE/RTR/保留位
reg = id << 3;
// 设置 IDE 位
// - 扩展帧: IDE=1
// - 掩码寄存器: IDE=1 (表示"需要检查 IDE 位")
if (is_mask || id_type == CAN_ID_TYPE_EXT)
{
reg |= (1 << 2); // bit2 = IDE
}
return reg;
}
为什么掩码也要设置 IDE=1?
掩码的每一位表示"是否关心":
- 掩码位 = 1:必须匹配
- 掩码位 = 0:忽略
如果掩码的 IDE 位 = 0,就会同时匹配标准帧和扩展帧。通常我们希望明确区分,所以掩码的 IDE 位要设成 1。
11.4 三种过滤器封装
驱动提供了三个层级的 API:
1. 接收所有消息
bool can_filter_init(void)
{
// Bank 0: ID=0, MASK=0 (全部不关心) → 接收所有
can_filter_set_raw(0, 0, 0, CAN_FILTERMODE_IDMASK, true);
// 关闭其他 Bank
for (uint8_t i = 1; i < 14; i++)
can_filter_enable(i, false);
return true;
}
原理:掩码全 0 表示"所有位都不关心",任何 ID 都能匹配。
收到 ID: 任意
过滤器 ID: 0x00000000
掩码: 0x00000000 (全不关心)
匹配结果: (收到的ID & 掩码) == (过滤器ID & 掩码)
(任意 & 0) == (0 & 0)
0 == 0 ✓ 永远通过
2. 掩码模式(范围匹配)
bool can_filter_set_mask(uint8_t bank, uint32_t id, uint32_t mask, can_id_type_t id_type)
{
uint32_t id_reg = format_filter_id(id, id_type, false);
uint32_t mask_reg = format_filter_id(mask, id_type, true);
return can_filter_set_raw(bank, id_reg, mask_reg, CAN_FILTERMODE_IDMASK, true);
}
示例:只接收 ID = 0x200 ~ 0x2FF
// 0x200 = 0b010 0000 0000
// 0x2FF = 0b010 1111 1111
// ↑↑↑
// 前3位必须是 010,后8位任意
can_filter_set_mask(0, 0x200, 0x700, CAN_ID_TYPE_STD);
// ID MASK
// MASK=0x700 = 0b111 0000 0000 (前3位必须匹配)
3. 精确匹配(单个 ID)
bool can_filter_set_id(uint8_t bank, uint32_t id, can_id_type_t id_type)
{
// 掩码设为全 1,所有位都必须匹配
uint32_t mask = (id_type == CAN_ID_TYPE_STD) ? 0x7FF : 0x1FFFFFFF;
return can_filter_set_mask(bank, id, mask, id_type);
}
示例:只接收 ID = 0x123
can_filter_set_id(0, 0x123, CAN_ID_TYPE_STD);
// 等价于:
can_filter_set_mask(0, 0x123, 0x7FF, CAN_ID_TYPE_STD);
// MASK=0x7FF 表示 11 位全部必须匹配
11.5 多个过滤器组合使用
14 个 Bank 可以同时激活,实现复杂的过滤逻辑:
// 场景:接收三类消息
// - 0x100 (控制命令,精确匹配)
// - 0x200~0x2FF (传感器数据,范围匹配)
// - 0x7FF (广播消息,精确匹配)
can_filter_set_id(0, 0x100, CAN_ID_TYPE_STD); // Bank 0
can_filter_set_mask(1, 0x200, 0x700, CAN_ID_TYPE_STD); // Bank 1
can_filter_set_id(2, 0x7FF, CAN_ID_TYPE_STD); // Bank 2
// 其他 Bank 保持关闭
11.6 FilterMatchIndex 的用处
接收消息时,硬件会告诉你是哪个 Bank 匹配的:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan_ptr)
{
// ...
msg.filter_index = rx_header.FilterMatchIndex; // 记录匹配的 Bank 号
// ...
}
应用层可以根据 filter_index 快速分类处理,不用再判断 ID:
void can_task(void)
{
can_msg_t msg;
while (can_read(&msg))
{
switch (msg.filter_index)
{
case 0: handle_control_cmd(&msg); break; // Bank 0 = 控制命令
case 1: handle_sensor_data(&msg); break; // Bank 1 = 传感器
case 2: handle_broadcast(&msg); break; // Bank 2 = 广播
}
}
}
11.7 过滤器配置速查
| 需求 | 调用 |
|---|---|
| 接收所有消息 | can_filter_init() |
| 只收一个 ID | can_filter_set_id(bank, id, type) |
| 收一段 ID 范围 | can_filter_set_mask(bank, id, mask, type) |
| 关闭某个 Bank | can_filter_enable(bank, false) |
常见错误:
- 忘记调用
can_filter_init()→ 收不到任何消息 - 掩码写反(0 和 1 的含义搞混)→ 收到的消息不对
- Bank 号超过 13 → 配置失败
12. 消息怎么收?怎么发?
过滤器配好了,CAN 也启动了,现在看消息怎么流动。
12.1 数据流全景图
发送流程
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 应用层 驱动层 硬件层 │
│ │
│ can_write() ──→ 邮箱有空? ──Y──→ 写入邮箱 ──→ 自动发送到总线 │
│ │ │ │
│ N │ │
│ ↓ ↓ │
│ 存入TX缓冲区 ←────────────── TX完成中断 │
│ (环形队列) (取出继续发) │
│ │
└─────────────────────────────────────────────────────────────────────┘
接收流程
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 硬件层 驱动层 应用层 │
│ │
│ 总线收到 ──→ 过滤器 ──→ FIFO ──→ RX中断 ──→ RX缓冲区 ──→ can_read()│
│ 匹配? (3层) 触发 (环形队列) 轮询取 │
│ │ │ │
│ N ↓ │
│ ↓ can_task() │
│ 丢弃 处理消息 │
│ │
└─────────────────────────────────────────────────────────────────────┘
12.2 发送流程详解
为什么需要 TX 缓冲区?
硬件只有 3 个发送邮箱。如果应用层发送速度超过总线速度:
应用层: can_write() can_write() can_write() can_write() can_write() ...
↓ ↓ ↓ ↓ ↓
硬件邮箱: [邮箱0满] [邮箱1满] [邮箱2满] ??? ???
没有缓冲区 → 第4条消息丢失!
解决方案:TX 环形缓冲区作为"蓄水池":
应用层: can_write() × 5
↓
┌───────────────────────┐
│ 邮箱0 邮箱1 邮箱2 │ ← 前3条直接进邮箱
└───────────────────────┘
┌───────────────────────┐
│ [msg4] [msg5] [ ] │ ← 后2条存入缓冲区
└───────────────────────┘
↑
TX完成中断自动取出
can_write() 的实现逻辑
bool can_write(const can_msg_t *msg)
{
bool ret = true;
// ① 关闭TX中断,防止竞态
__HAL_CAN_DISABLE_IT(&hcan, CAN_IT_TX_MAILBOX_EMPTY);
// ② 尝试直接写入硬件邮箱
if (!can_send_to_mailbox(msg))
{
// ③ 邮箱满,存入软件缓冲区
if (!can_ringbuf_push(&tx_ring, msg))
{
ret = false; // 缓冲区也满了,发送失败
}
}
// ④ 恢复TX中断
__HAL_CAN_ENABLE_IT(&hcan, CAN_IT_TX_MAILBOX_EMPTY);
return ret;
}
流程图:
can_write(msg)
│
▼
┌───────────────┐
│ 关闭TX中断 │ ← 防止与中断并发修改缓冲区
└───────┬───────┘
│
▼
邮箱有空闲?──Y──→ 写入邮箱 ──→ 返回成功
│
N
↓
缓冲区有空?──Y──→ 存入缓冲区 ──→ 返回成功
│
N
↓
返回失败
│
▼
┌───────────────┐
│ 恢复TX中断 │
└───────────────┘
TX 完成中断:自动续发
当邮箱发送完成,硬件触发中断,驱动自动从缓冲区取下一条:
static void can_tx_complete_handler(void)
{
can_msg_t msg;
// 缓冲区有待发消息?取出来发
if (can_ringbuf_pop(&tx_ring, &msg))
{
can_send_to_mailbox(&msg);
}
}
// 三个邮箱的回调都调用同一个处理函数
void HAL_CAN_TxMailbox0CompleteCallback(CAN_HandleTypeDef *hcan_ptr)
{
can_tx_complete_handler();
}
发送 5 条消息的时序:
时间 ──────────────────────────────────────────────────────────────→
can_write(1) → 邮箱0
can_write(2) → 邮箱1
can_write(3) → 邮箱2
can_write(4) → TX缓冲区[0]
can_write(5) → TX缓冲区[1]
邮箱0发完 → 中断 → 取缓冲区[0]发
邮箱1发完 → 中断 → 取缓冲区[1]发
...
12.3 接收流程详解
中断接收 + 缓冲区
硬件 FIFO 只有 3 层深度,必须尽快取走,否则溢出。所以用中断 + 环形缓冲区:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan_ptr)
{
CAN_RxHeaderTypeDef rx_header;
can_msg_t msg;
// 循环取完 FIFO 中所有消息
while (HAL_CAN_GetRxFifoFillLevel(hcan_ptr, CAN_RX_FIFO0) > 0)
{
if (HAL_CAN_GetRxMessage(hcan_ptr, CAN_RX_FIFO0, &rx_header, msg.buf) == HAL_OK)
{
// 解析标准帧/扩展帧
if (rx_header.IDE == CAN_ID_STD)
{
msg.id = rx_header.StdId;
msg.flags.extended = 0;
}
else
{
msg.id = rx_header.ExtId;
msg.flags.extended = 1;
}
msg.flags.remote = (rx_header.RTR == CAN_RTR_REMOTE) ? 1 : 0;
msg.len = rx_header.DLC;
msg.filter_index = rx_header.FilterMatchIndex; // 哪个过滤器匹配的
msg.timestamp = rx_header.Timestamp;
// 存入软件缓冲区
can_ringbuf_push(&rx_ring, &msg);
}
}
}
为什么用 while 循环?
中断触发时,FIFO 里可能不止一条消息(比如总线上连续来了几帧)。用 while 一次性取完,减少中断次数。
应用层轮询消费
应用层通过 can_read() 从缓冲区取消息:
bool can_read(can_msg_t *msg)
{
// ① 关闭RX中断
__HAL_CAN_DISABLE_IT(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING);
// ② 从缓冲区取
bool ret = can_ringbuf_pop(&rx_ring, msg);
// ③ 恢复RX中断
__HAL_CAN_ENABLE_IT(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING);
return ret;
}
典型用法(can_task 周期调用):
void can_task(void)
{
can_msg_t msg;
// 取完所有待处理消息
while (can_read(&msg))
{
// 处理消息...
printf("ID=0x%X LEN=%d\r\n", msg.id, msg.len);
}
}
12.4 为什么要关中断?(临界区保护)
环形缓冲区的 head 和 tail 指针会被两个上下文同时访问:
| 操作 | 上下文 | 访问的指针 |
|---|---|---|
can_read() |
主循环(前台) | 修改 tail |
| RX 中断回调 | 中断(后台) | 修改 head |
如果不加保护,可能出现竞态条件:
场景:缓冲区有1条消息,head=1, tail=0
主循环: RX中断:
────────────────────────────────────────────────────
读取 tail=0
读取 head=1
判断: head != tail, 有消息
来了新消息
push: buffer[1] = new_msg
head = 2
读取 buffer[0] ← 正确
tail = 1 ← 此时 head=2, tail=1, 还剩1条
但如果时序稍微变一下...
主循环: RX中断:
────────────────────────────────────────────────────
读取 tail=0
来了新消息
push: buffer[1] = new_msg
head = 2
读取 head=2
判断: head != tail (2 != 0), 有消息
计算数量: (2 - 0) = 2条 ← 错了!实际只有1条有效
解决方案:操作缓冲区时,暂时关闭对应的中断:
// 读取时关闭RX中断
__HAL_CAN_DISABLE_IT(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING);
can_ringbuf_pop(&rx_ring, msg);
__HAL_CAN_ENABLE_IT(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING);
// 写入时关闭TX中断
__HAL_CAN_DISABLE_IT(&hcan, CAN_IT_TX_MAILBOX_EMPTY);
can_ringbuf_push(&tx_ring, msg);
__HAL_CAN_ENABLE_IT(&hcan, CAN_IT_TX_MAILBOX_EMPTY);
为什么不用全局关中断? 只关特定中断,影响范围最小。如果用
__disable_irq()会阻塞所有中断,影响系统实时性。
12.5 消息结构体
驱动定义了统一的消息结构:
typedef struct {
uint32_t id; // 消息ID(标准帧11位,扩展帧29位)
uint8_t len; // 数据长度 (0~8)
uint8_t buf[8]; // 数据内容
struct {
uint8_t extended : 1; // 1=扩展帧, 0=标准帧
uint8_t remote : 1; // 1=远程帧, 0=数据帧
} flags;
uint8_t filter_index; // 匹配的过滤器 Bank 号
uint16_t timestamp; // 时间戳(如果使能)
} can_msg_t;
发送示例:
can_msg_t tx_msg = {
.id = 0x123,
.len = 4,
.buf = {0x11, 0x22, 0x33, 0x44},
.flags.extended = 0, // 标准帧
.flags.remote = 0, // 数据帧
};
can_write(&tx_msg);
12.6 收发流程小结
| 阶段 | 发送 | 接收 |
|---|---|---|
| 入口 | can_write() |
RX 中断回调 |
| 硬件资源 | 3 个邮箱 | FIFO(3 层深度) |
| 软件缓冲 | TX 环形缓冲区 | RX 环形缓冲区 |
| 触发续传 | TX 完成中断 | — |
| 应用获取 | — | can_read() + can_task() |
| 临界保护 | 关 TX 中断 | 关 RX 中断 |
13. 出错了驱动怎么处理?
第 7 章讲了 CAN 协议的错误检测和状态机。这一章看 STM32 怎么实现,以及驱动怎么封装。
13.1 回顾:CAN 的错误状态机
TEC/REC < 96 96 ≤ TEC/REC < 128
┌──────────┐ ┌──────────────────┐
│ │ │ │
▼ │ ▼ │
┌──────────┐ 错误─→ ┌──────────────┐ 错误─→ ┌─────────────────┐
│ 主动错误 │←───── │ 警告状态 │←───── │ 被动错误 │
│ (Active) │ 恢复 │ (Warning) │ 恢复 │ (Passive) │
└──────────┘ └──────────────┘ └────────┬────────┘
↑ │
│ TEC > 255
│ │
│ 检测到128次 ▼
└──────────── 11位连续隐性 ←──────────── ┌──────────┐
│ 离线 │
│ (Bus-Off) │
└──────────┘
- TEC:发送错误计数器
- REC:接收错误计数器
- 发送出错 +8,成功 -1;累积到一定程度状态降级
13.2 STM32 的 ESR 寄存器
STM32 用 ESR(Error Status Register) 记录错误状态:
ESR 寄存器关键位:
位 名称 含义
┌─────┬───────┬────────────────────────────────┐
│ 2 │ EWGF │ 错误警告标志 (TEC/REC ≥ 96) │
│ 1 │ EPVF │ 错误被动标志 (TEC/REC ≥ 128) │
│ 0 │ BOFF │ 离线标志 (TEC > 255) │
├─────┼───────┼────────────────────────────────┤
│23:16│ REC │ 接收错误计数器值 │
│15:8 │ TEC │ 发送错误计数器值 │
│ 6:4 │ LEC │ 最后一次错误码 │
└─────┴───────┴────────────────────────────────┘
LEC 错误码:
0 = 无错误
1 = 填充错误 (Stuff Error)
2 = 格式错误 (Form Error)
3 = ACK 错误
4 = 隐性位错误
5 = 显性位错误
6 = CRC 错误
7 = 由软件设置
13.3 驱动的状态枚举
驱动把 ESR 的标志位映射为简洁的枚举:
typedef enum {
CAN_BUS_OK, // 正常(TEC/REC < 96)
CAN_BUS_WARNING, // 警告(TEC/REC ≥ 96)
CAN_BUS_PASSIVE, // 被动错误(TEC/REC ≥ 128)
CAN_BUS_OFF, // 离线(TEC > 255)
} can_bus_state_t;
读取当前状态
can_bus_state_t can_get_bus_state(void)
{
uint32_t esr = hcan.Instance->ESR; // 直接读寄存器
// 按严重程度从高到低判断
if (esr & CAN_ESR_BOFF) return CAN_BUS_OFF; // 离线最严重
if (esr & CAN_ESR_EPVF) return CAN_BUS_PASSIVE; // 其次被动
if (esr & CAN_ESR_EWGF) return CAN_BUS_WARNING; // 再次警告
return CAN_BUS_OK; // 都没有就是正常
}
对应关系
| 协议概念 | ESR 标志 | 驱动枚举 |
|---|---|---|
| 主动错误(Error Active) | 无标志 | CAN_BUS_OK |
| 警告状态 | EWGF = 1 | CAN_BUS_WARNING |
| 被动错误(Error Passive) | EPVF = 1 | CAN_BUS_PASSIVE |
| 离线(Bus-Off) | BOFF = 1 | CAN_BUS_OFF |
13.4 错误回调机制
驱动提供回调接口,当状态发生变化时通知应用层:
注册回调
typedef void (*can_error_cb_t)(can_bus_state_t state, uint32_t error_code);
void can_set_error_callback(can_error_cb_t cb);
使用示例
void my_error_handler(can_bus_state_t state, uint32_t err)
{
switch (state)
{
case CAN_BUS_WARNING:
printf("警告:错误计数较高\r\n");
break;
case CAN_BUS_PASSIVE:
printf("被动错误:通信能力受限\r\n");
break;
case CAN_BUS_OFF:
printf("离线!需要重启CAN\r\n");
// 可以在这里触发重新初始化
break;
case CAN_BUS_OK:
printf("恢复正常\r\n");
break;
}
}
// 初始化时注册
can_set_error_callback(my_error_handler);
内部实现
static can_error_cb_t error_callback;
static can_bus_state_t last_bus_state = CAN_BUS_OK;
void HAL_CAN_ErrorCallback(CAN_HandleTypeDef *hcan_ptr)
{
uint32_t err = HAL_CAN_GetError(hcan_ptr);
can_bus_state_t state = can_get_bus_state();
// 只在状态变化时通知,避免重复回调
if (state != last_bus_state)
{
last_bus_state = state;
if (error_callback)
{
error_callback(state, err);
}
}
}
为什么只在状态变化时回调?
错误中断可能频繁触发(每次出错都触发),如果每次都回调会淹没应用层。只在状态跃迁时通知,应用层才能有效响应。
13.5 自动恢复 vs 手动恢复
自动恢复(推荐)
CubeMX 配置 Automatic Bus-Off Management = Enable 后,离线状态会自动恢复:
离线 ──→ 检测到 128 次 (11位隐性) ──→ 自动回到主动错误状态
驱动启动时默认使能了此功能。
手动恢复
如果想手动控制恢复时机:
// CubeMX 配置 AutoBusOff = DISABLE
// 然后在代码中:
if (can_get_bus_state() == CAN_BUS_OFF)
{
// 执行一些诊断或等待...
// 触发恢复
HAL_CAN_Stop(&hcan);
HAL_CAN_Start(&hcan);
}
13.6 错误计数器的读取
有时候想看具体的 TEC/REC 值:
void print_error_counters(void)
{
uint32_t esr = hcan.Instance->ESR;
uint8_t tec = (esr >> 16) & 0xFF; // 发送错误计数
uint8_t rec = (esr >> 24) & 0xFF; // 接收错误计数
printf("TEC=%d, REC=%d\r\n", tec, rec);
}
注意:这个读取方式是直接访问寄存器,没有封装成驱动 API。如果需要可以自己加。
13.7 常见错误场景
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 持续 ACK 错误 | 没有其他节点 / 波特率不匹配 | 检查接线,确认对端在线且波特率一致 |
| 持续 CRC 错误 | 信号质量差 / 终端电阻问题 | 用示波器看波形,检查 120Ω 电阻 |
| 快速进入 Bus-Off | 总线短路 / 收发器损坏 | 断开总线测试 Loopback 模式 |
| 偶发错误但能恢复 | 正常现象(干扰) | 错误计数器会自动回落,无需处理 |
13.8 调试建议
调试 CAN 问题的顺序:
1. 先用 Loopback 模式
└─ 排除软件配置问题
2. 检查硬件
├─ 万用表测 CAN_H - CAN_L 阻值 ≈ 60Ω
├─ 确认收发器供电正常
└─ 检查接线(H-H, L-L,不要交叉)
3. 示波器看波形
├─ 显性:CAN_H ≈ 3.5V, CAN_L ≈ 1.5V
├─ 隐性:CAN_H ≈ CAN_L ≈ 2.5V
└─ 波形要方正,没有明显过冲/振铃
4. 打印错误信息
└─ 注册 error_callback,观察状态变化