文章

CAN 接收中断与队列:最重的活不在最急的地方干(以 STM32F4 为例)

那颗从 收发器篇 埋下、被 位同步篇 和 电路篇 两次按下不表、排了三次队的炸弹,这篇终于拆。先把雷管念一遍原文:「接收 FIFO 只有 3 级——500kbps 下一帧标准数据帧约 222µs,3 级满打满算 666µs 的余量,一次比一次长的中断风暴就能把它灌爆。轮询路线在真实机器人上活不过第一版」。

再看它爆炸的真实现场。机器人主控跑着 1ms 周期的控制循环,六七个关节电机各以 1kHz 回报位置——500kbps 被吃掉八九成,总线上报文几乎背靠背,约每 222µs 一帧。你调电机参数时顺手在主循环里加了一行调试打印——电机开始偶发抖动,偶发到抓包工具都未必撞得见。你查遍 CAN 代码:仲裁没错、CRC 没错、位同步篇 的采样点没错、电路篇 的 60Ω 也量过了。全对,但帧就是少了——杀死你 CAN 报文的,常常是一行和 CAN 无关的 printf。

《CAN 总线篇》 里收报文用的是轮询:while (!can_recv(...)) 空转等货。L1 演示板上一块板子自己聊自己,怎么都够快;真实机器人上主循环里的每一行代码都在和这 666µs 抢时间。这篇分三层:L1 把接收从中断到队列整条链路配通,L2 拆开两级缓冲各自的账和死法,L3 想明白这套架构到底「解耦」了什么——以及 《FreeRTOS 队列》 没来得及讲的那个入场资格。

L1:先让它跑起来——四处改动,一条链路

你在第一层。目标:把 《CAN 总线篇》 的轮询接收改成「中断搬运、队列中转、任务处理」,跑在 FreeRTOS 上。

链路长这样:

1
2
CAN 总线 → bxCAN 接收 FIFO(3 格,硬件) → 软件队列(16 格,内核)
        → 处理任务(解析/PID/日志,随便慢)

改动一共四处。第一处,can_init() 末尾把 FIFO0 的「挂号中断」打开,再给 NVIC 开门、定优先级(F407 上 CAN1_RX0 的中断号是 21,换其他系列要查启动文件,别照抄):

1
2
3
4
5
6
7
#define CAN_IER    (*(volatile unsigned int*)(CAN1_BASE + 0x014))
#define NVIC_ISER0 (*(volatile unsigned int*)0xE000E100)

CAN_IER |= 1u << 1;                /* FMPIE0:FIFO0 有货就敲门 */
CAN_IER |= 1u << 5;                /* FOVIE0:FIFO 溢出也敲门(见 L2 一,强烈建议开) */
NVIC_ISER0 |= 1u << 21;            /* CAN1_RX0:NVIC 开门 */
(*(volatile unsigned char*)0xE000E415) = 5u << 4;  /* IPR[21]:优先级 5——为什么必须 ≥5,L2 三 */

第二处,建队列和处理任务。一条消息 = ID + 8 字节载荷,16 字节,按值拷进队列(队列篇 讲过为什么传值):

1
2
3
4
5
6
7
8
9
typedef struct { unsigned int id; unsigned char d[8]; } can_msg_t;
static QueueHandle_t can_q;

void can_irq_init(void)
{
    can_q = xQueueCreate(16, sizeof(can_msg_t));   /* 深度的账 L2 二算 */
    xTaskCreate(vCanRx, "canrx", 256, NULL, 3, NULL);
    /* 顺序纪律:先建队列、再开中断——ISR 敲门时队列必须已经在岗 */
}

第三处,ISR 本体——全系列最短的搬运工,一个 while 把 FIFO 清干净:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void CAN1_RX0_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;
    while (CAN_RF0R & 3u) {             /* FMP:FIFO 里还有货 */
        can_msg_t m;
        m.id = CAN_RIR >> 21;
        for (int i = 0; i < 4; i++) m.d[i]     = CAN_RDLR >> (8 * i);
        for (int i = 0; i < 4; i++) m.d[4 + i] = CAN_RDHR >> (8 * i);
        if (xQueueSendFromISR(can_q, &m, &woken) != pdPASS)
            can_hw_drops++;             /* 队列满:丢帧记账,别装没看见 */
        CAN_RF0R = 1u << 4;             /* RFOM0:释放顶格。勘误:总线篇示例误写
                                           1u<<5(bit 5 是保留位),本篇已随旧文一并改正 */
    }
    portYIELD_FROM_ISR(woken);          /* 唤醒的任务若更急,出门立刻切过去 */
}

第四处,处理任务——重活全在这里,它拥有一天下的「随便慢」的权利:

1
2
3
4
5
6
7
8
void vCanRx(void *pv)
{
    can_msg_t m;
    for (;;) {
        xQueueReceive(can_q, &m, portMAX_DELAY);   /* 空了就睡,一个周期不烧 */
        motor_on_frame(&m);      /* 解析、闭环、日志——阻塞、慢、都合法 */
    }
}

会用了。但每个数字背后都欠着账:那行 printf 是怎么精确杀帧的?硬件只有 3 格、软件队列凭什么敢开 16 格?以及那个悬着的注释——优先级为什么必须 ≥ 5,队列篇 只教了 FromISR 的用法,没讲它的入场资格。

L2:懂原理——两级缓冲,各自记着各自的死法

到这层,问题变了:两级蓄水池谁在防谁?各自怎么死?

一、666µs 验尸:printf 是凶器

先把轮询的死刑判决书念清楚。打印走 UART 115200,一字节 10 个位,一个字符 87µs。一行再短的调试日志(比如 [dbg] enter\r\n,14 个字符)也要 1.2ms。这 1.2ms 里,500kbps 的总线上来了 1200 / 222 ≈ 5 帧;FIFO 只有 3 格——后到的两帧被硬件扔了。

bxCAN 的溢出行为是整个陷阱的核心:FIFO 满了,新帧直接丢弃,RF0R 的 FOVR 位(bit 3)置 1——仅此而已。没有中断(除非你开了 FOVIE)、没有报错、没有重传(CAN 的重传保护发送方,接收方丢了就是丢了)。软件不主动查 FOVR,这具尸体永远不会被发现。这就是轮询路线「时好时坏」的解剖结果:死线不在 CAN 代码里,在主循环最长的那段代码里——printf、一次超时的 I2C、一个贪吃的算法,谁来都一样。L1 里你开了 FOVIE0,就是给这具尸体装了警报器。

顺带把轮询的另外半张死刑判决书也念了:while (!can_recv()) 空转烧掉整个 CPU 的空闲,电池项目直接出局(中断篇 讲过三种「等」法的账)。

二、蓄水池的账:3 格、16 格、各防各的

中断路线的本质是把「兜住突发」拆成两级,每一级只对上一级的突发负责:

级容量防的是什么抖动死法计数器
硬件 FIFO3 格 ≈ 666µs中断响应延迟(µs 级:更高优先级中断、临界区)FOVR 置位,无声丢帧查 RF0R bit 3(开了 FOVIE 就有中断)
软件队列16 格 ≈ 3.5ms任务调度延迟(ms 级:高优先级任务压住、日志闪断)errQUEUE_FULL,有声丢帧查 send 返回值,can_hw_drops 这类计数
处理任务业务节奏消费本身慢水位爬升,延迟越积越大uxQueueMessagesWaiting 曲线

队列深度 16 不是拍的:深度 = 消费任务最坏被压多久 ÷ 帧间隔。密流下消费任务偶尔被 2ms 的高优先级任务压住(写日志那次),2ms / 222µs ≈ 9 帧,16 格留了将近一倍余量。深度设太大是用 RAM 给「消费太慢」打掩护——队列篇 说得直白:深度 16 掩盖不了的积压,深度 160 只是让它晚 10 倍爆。

注意一个对比:硬件 3 格、软件 16 格,差五倍——不是硬件抠门,是两级防的东西不同。硬件 FIFO 只需要撑到 ISR 进门(几 µs 的事,3 格绰绰有余,这是 1990 年代硅片预算里划算的保险);软件队列要撑到任务被调度(ms 级的事,RAM 换的)。ISR 本身的成本也顺带结账:进出中断 12 周期加拷贝 16 字节,全算上 µs 级——对 222µs 的帧间隔,余量两个数量级,安心。

还有那个 while 循环的妙处:ISR 里清 FIFO 时新帧恰好进来,不用重新敲门——循环条件自己就看见了。一次进门清空积压,把 N 次进出中断压成一次(中断篇 的尾链优化替你省的第二次进场钱,这里你直接省掉了第一次)。

三、FromISR 的入场资格:那张优先级合同

队列篇 讲过「ISR 里调任务版接口 = 自杀」,FromISR 是唯一合法的门。但为什么 FromISR 自己还有一个限制?这要往内核深处看一步。

FreeRTOS 的临界区不是关全局中断。Cortex-M 端口用 BASEPRI 寄存器设一道挡板:把「逻辑优先级数字 ≥ 5」的中断全部屏蔽(数字小 = 更急,中断篇 的规矩),而 0~4 照常放行。这是个故意的慷慨:内核说「我改链表这几百纳秒里,真正十万火急的中断(硬实时控制、安全中断)不许被我挡住;但你要调我的 API,就别站在比我的挡板还急的位置上」。configMAX_SYSCALL_INTERRUPT_PRIORITY(官方 demo 常见默认 5,以你的 FreeRTOSConfig.h 为准——印象中的默认值,未核对文档原文,欢迎指正)就是这道挡板的数字。

于是 L1 那行优先级注释的含义就清楚了:CAN 中断优先级数值必须 ≥ 5,否则它会在内核改队列链表改到一半时破门而入,xQueueSendFromISR 接着去碰那条改了一半的链表。死法特征极其阴险:99.9% 的时候不崩——只有恰好撞进临界区才崩,症状是偶发 HardFault 或队列链表莫名损坏,和 位同步篇 那个「时好时坏把排查方向引向软件」的踩线时钟源同一个套路。救命稻草是 configASSERT:印象中 v10 的 Cortex-M 端口在 FromISR 入口有优先级校验(vPortValidateInterruptPriority 一类),开着断言能当场抓住;没开,就是玄学。

到这里两级缓冲和那份合同都齐了。但回头看整条链路,最值得问的还是那个架构问题:为什么非要「中断—队列—任务」三段式?在中断里直接把活干了不行吗?

L3:想得透——把「发生」和「处理」拆到三张时间表上

到这层,问题只剩一个:这套架构解耦的到底是什么?

本质

去掉所有术语:轮询是 CPU 不停问「到了吗」;中断+队列是「到了请登记,我按我的节奏来取」。而登记处分三层,各自对着一张不同的时间表:硬件 FIFO 登记微秒级的「到达」——它对冲中断响应的抖动;软件队列登记毫秒级的「积压」——它对冲任务调度的抖动;处理任务按业务自己的节奏消化——它终于赢得了「慢慢想」的权利。每一层只对上一层的突发负责,谁也不为别人兜底。

心智模型:医院。前台护士只挂号不看病(ISR 只搬运不处理),候诊的椅子是有限的(队列深度),医生按自己的节奏叫号(任务阻塞在 receive 上)。急诊病人来了护士喊一嗓子让医生优先处理(portYIELD_FROM_ISR 唤醒更高优先级的任务)。拆脚手架:真医院的前台是雇来的、永远在岗,ISR 的「前台」是被迫营业——每帧报文都要付一次进出中断的税(µs 级,便宜但不是免费);真医院的椅子满了病人会回家,队列满了货是真的没了——所以那两个计数器不是可选装饰,是这家医院的财务报表。

三个被否决的方案

被否决的方案一:把主循环写快,守住 666µs。 不上中断,主循环里每个分支都掐着秒表写。死因是「不可持续」:死线属于全部主循环代码的合集,任何未来加进来的一行(一行日志、一次重试、一个新传感器)都是新炸弹,而且没有警报器——你连它响了都不知道(FOVR 无声)。这条路线要求整个系统永远处于「证明自己无罪」的状态,中断路线只要求一次把搬运工配好。

被否决的方案二:在 ISR 里直接处理。 不排队了,ISR 里把解析、闭环、日志全干完。两笔账直接判死:其一,中断篇 的铁律一——ISR 抢占的是全世界,你把 PID 写进 ISR,等于宣布所有低优先级任务和所有低优先级中断在你的解析时间面前排队;其二,ISR 里不能阻塞(没有任务上下文),所有处理被迫塞进一个不可抢占的窗口——666µs 的死线刚拆掉,换上了「比 222µs 还短」的新死线,更紧了。把活放进最高优先级的地方,不是更快,是让全世界变慢。

被否决的方案三:手工队列(全局缓冲 + 标志位)。 队列 L1 的代码用裸数组也能写——队列篇 开头已经验过尸:竞态,时有时无。这里补上另一半死因:手工方案里「通知任务」只能靠轮询标志位(任务自旋)——阻塞语义没了,你亲手把消费任务改回了轮询路线;而缓冲满了没有返回值可查,无声丢帧比有声丢帧危险一个量级。队列这几十字节的管理结构,买的是「拷贝 + 阻塞 + 唤醒 + 原子性」一整套,自己造一遍,造出来的是缺件的。

一个反直觉收尾

还记得开头那行被判死刑的 printf 吗?中断+队列架起来之后,它复活了,而且无罪。同一行打印现在阻塞的只是处理任务——FIFO 和队列在它身后接盘,666µs 的死线不再属于它。这就是这套架构真正的赠品:它的本事不是消灭长代码,是给每一行代码找到一个杀不了人的位置。printf 没变,变的是它站的队列。调试如此,慢速的日志闪存写入、一次卡顿的参数解析,全都如此——架构替你把「偶尔慢」和「永远赶不上」分开了。

动手环节:一个计算器,三个预测

本机没有 STM32 硬件,实验是纸面账——每一笔都是确定的算术,欢迎拿真机复算。

实验一:printf 的刑期。 115200 波特、8N1,一个字符 10 位 = 87µs。多电机反馈把 500kbps 吃到八九成满,报文几乎背靠背(每 222µs 一帧)——这是最坏情况,验尸就该验最坏的。先预测:要让 3 格 FIFO 必死无疑,一行日志最少几个字符?你惯用的那行 20 字符日志,杀几帧?

答案:3 格 FIFO 满格需要第 4 帧在处理开始前到达——4 × 222 = 888µs 的封闭窗口里主循环没醒过即可;printf 独占超过 888µs 需要 10.2 个字符。而 20 字符的日志占线 1.7ms ≈ 7.8 帧间隔,FIFO 只有 3 格——约 5 帧蒸发。1kHz 的反馈流丢 5 帧就是位置环 5ms 的盲区,电机抖那一下,账就是这么来的。

实验二:队列深度的公式。 你的消费任务优先级 3,但有个优先级 5 的日志任务,最坏会压住你 2ms(写 SPI flash 的那一次)。先预测:16 格够吗?改成 10 格呢?

答案:2ms / 222µs ≈ 9 帧积压。16 格:余 7 格,安全;10 格:差 1 格,写 flash 那一次必丢 1 帧。公式记成一条:深度 = 最坏调度延迟 ÷ 帧间隔,再乘 1.5 的余量。更狠的自检是拿 uxQueueMessagesWaiting 画曲线:水位长期趴在 0 是健康,贴着顶是消费端有病——治消费,别治深度。

实验三:优先级 0 的 CAN 中断。 有人把 CAN_RX0 的优先级设成 0(最急),理由是「总线数据最重要,必须第一时间处理」。先预测:程序会怎么死?死在哪一行?

答案:大概率不死,跑几千次才崩一次,崩的时候没有稳定的死点。机理:优先级 0 比内核的 BASEPRI 挡板(5)还急,内核在队列链表改到一半的几百纳秒里,它照样破门而入;xQueueSendFromISR 撞上中间态,链表损坏或 HardFault。要它稳定死,把某个高频中断也设 0 并让它也调 FromISR,撞车概率翻倍。「最重要的中断」和「调内核 API 的中断」是两个身份,后者必须站在挡板之外排队——这是 L2 三那张合同的全部内容。

三个自测问题,能答上说明这三层你真爬过了:

  1. 硬件 FIFO 3 格、软件队列 16 格——为什么差五倍?各自对冲哪张时间表上的抖动?(答案在 L2 二:FIFO 对冲 µs 级中断响应延迟,3 格即够;队列对冲 ms 级调度延迟,深度按「最坏卡顿 ÷ 帧间隔」算,RAM 出钱。)
  2. ISR 里漏写 portYIELD_FROM_ISR,系统会坏吗?666µs 的死线受影响吗?(答案在 L1/L2:不会坏、死线也不受影响——FIFO 和队列照样收货,只是被唤醒的任务要等到下个 tick 才跑,延迟变差。队列篇 说这叫「毫秒级迟滞,极难查」。)
  3. 同样是「在 ISR 里干活」,搬运 16 字节合法、解析帧非法——界线在哪?(答案在 L3 方案二:搬运在 µs 级完成且不阻塞;处理一旦要阻塞(日志、算法、等资源)就塞不进不可抢占窗口,等于把新死线强加给全世界。)

最后留一个我自己没想干净的开放问题:位置反馈流,到底该用队列还是「最新值覆盖」? 电机控制要的永远是此刻的位置——可 FIFO 语义给的是「按到达顺序排队」:消费端一旦卡了 5ms,你从队列里读出来的是 5ms 前的旧位置,拿它做闭环等于闭环在一个延迟的世界里。直觉上该用「只保留最新值」的覆盖式语义(一个全局 latest + 任务通知),可「丢掉的中间值」对某些算法(测速、滤波)又是真实信息。反馈流和控制流对中间值的需求相反,我一直没见到把这笔账讲透的资料——如果你的项目里对这两种语义做过取舍,我很想听。

收尾地图

一张「症状 → 查什么」的速查表收尾:

症状/场景查什么一句话理由
偶发丢帧、硬件无声RF0R 的 FOVR(bit 3);建议开 FOVIE0bxCAN 溢出只置位不报警(L2 一)
电机偶发抖动、查 CAN 无果主循环里最长的代码(printf/阻塞 I2C)666µs 死线属于全主循环(实验一)
队列返 errQUEUE_FULL消费任务为什么慢,uxQueueMessagesWaiting 曲线满即有声丢帧,先治消费再谈深度(L2 二)
偶发 HardFault / 队列链表损坏中断优先级是否 ≥ configMAX_SYSCALL(默认 5)FromISR 的入场资格(L2 三、实验三)
ISR 写了不会崩但延迟怪漏没漏 portYIELD_FROM_ISR唤醒推迟到下个 tick(自测 2)
组新网络前深度 = 最坏卡顿 ÷ 帧间隔 × 1.5用 RAM 买缓冲,别用缓冲盖病(实验二)
任何「要不要在 ISR 里干 X」X 需要阻塞吗?µs 级能完吗?搬运合法,处理违法(L3 方案二)

下一站走到 CAN 系列的门口了:协议、位时序、收发器、电路、中断,五篇铺垫全部到位,CANopen 的门该推了。给你看门后第一眼:几乎所有的 CANopen 电机驱动器,上电后的第一条指令都是往对象 0x6040 写 0x06、再写 0x07、再写 0x0F——三步「启用序列」,像给电机行了个固定的握手礼。为什么机器人业界连「开机流程」都要编成标准化的哑剧?你会发现今天队列里躺着的每一帧,到了那边都有名字、有编号、有户口。而 《电机篇》 结尾欠的编码器 + PID 闭环也还在另一条轨道上排队——它和 CANopen 在「位置反馈怎么变成转速指令」那一点会合。

本文由作者按照 CC BY 4.0 进行授权