文章

中断:CPU 的分心与回头(以 Cortex-M4 为例)

你把按键中断调通了,LED 应手而亮,很有成就感。然后你往”中断处理函数”里加了一行调试用的 printf——板子死了。更气人的是它死得毫无规律:有时按三下就死,有时按三十下都没事,同一个按键,这次按死、下次不按死。换成延时,死得更干脆。

你反复检查 GPIO、时钟、库版本,全都没问题。问题确实不在那儿。真正的原因藏在一个更基本的地方:你写的那个”函数”,根本不是被”调用”的。

它是被 CPU 硬件硬塞进执行流的。它出现时,你的主程序正跑到哪一行,就停在哪一行;它消失时,主程序从停下的地方无缝续跑,连”自己被打断过”都不知道——main 里的循环不会察觉中间插进了一段别的时间。这篇文章以应用最广的 32 位内核之一 Cortex-M4 为例,分三层拆开这套”分心与回头”:先让一个中断跑起来,再看硬件在你眼皮底下替你做了什么,最后想明白——为什么这个世界非要这么设计。

L1:先让它跑起来——一个几行的定时中断

你在第一层。目标:用最小代码让一个中断跑起来,先建立”中断到底长什么样”的地图感。

本系列第一篇 《Cortex-M4 与 MCU 开发》 讲过 CPU 的三种”等”法:轮询、中断、DMA。中断是硬件”敲门”通知 CPU,不用空等。现在我们把门敲起来。最简单也最常见的敲门人是 SysTick——Cortex-M4 内核自带的 24 位倒计时定时器,也是 《FreeRTOS 核心原理》 里那个每 1ms 敲一次的节拍来源。用它学中断,没有外设配置的干扰。

让一个 LED 每秒闪一次,完整代码就这些(STM32F407,168MHz):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void SysTick_Handler(void)          /* 中断处理函数,嵌入式黑话叫 ISR */
{
    GPIOD_ODR ^= 1u << 12;          /* 翻转 PD12,灯亮/灭 */
}

int main(void)
{
    RCC_AHB1ENR |= 1u << 3;         /* 打开 GPIOD 时钟 */
    GPIOD_MODER |= 1u << 24;        /* PD12 设为输出 */
    SysTick->LOAD = 168000000 - 1;  /* 倒计时 1.68 亿下 = 1 秒 */
    SysTick->VAL  = 0;              /* 写一下 VAL,清空计数 */
    SysTick->CTRL = 7;              /* 0b111:使能 + 到 0 申请中断 + 用内核时钟 */
    while (1)
        ;
}

(GPIOD_ODR 那几个宏的定义,和本系列第一篇的点灯代码一模一样。)

看这段代码,任何中断都逃不出这三件套:

  1. 配触发源:告诉 SysTick”数到 0 就申请中断”(CTRL 里那位 TICKINT)。
  2. 让 CPU 接:把这个中断从”关着”变成”会敲门”。SysTick 是系统异常,没有 NVIC 的使能位——它默认就在线上,只是不产生中断;CTRL = 7 让它开始敲门。普通外设中断(比如按键的 EXTI)则多一步:在 NVIC 的 ISER 寄存器里手动打开。
  3. 写处理函数:SysTick_Handler。函数名不能改——它必须和向量表(上一篇那本”电话簿”)里约定的名字一致,CPU 才查得到你。你做的事本质是填一个硬件约定好的空位。

注意”翻转”那行的位置:LED 的亮灭发生在中断里,而不是 main 的循环里。main 那个 while(1) 除了空转什么都不干,全靠每秒一次的中断”踹一脚”来翻灯。

到这里你已经会让一个中断跑起来了。但它身上有件你亲手写、却解释不了的怪事:SysTick_Handler 从头到尾没有任何一行代码”调用”它,它却自动跑了,跑完还知道回到 while(1) 里继续。谁干的?

L2:懂原理——被打断的那 12 个周期里,硬件做了什么

到这一层,问题变了:不再问”怎么写”,而是问”底下发生了什么”。

上一篇让你记住了一个数字:Cortex-M4 的中断延迟是 12 个时钟周期。这句广告语式的数字,其实是”硬件替你干活”的清单——从外设拉高中断请求,到你的 ISR 第一条指令执行,中间只有 12 个周期,而这段时间里硬件必须完成四件事:

  1. 裁决(NVIC):谁申请的?使能了吗?它比当前正在跑的优先级高吗?
    • 比当前高 → 抢占,走下一步;
    • 不比当前高 → 挂起(pending),先记着,现在不处理。
  2. 自动压栈:把当前这趟执行的”现场”——R0–R3、R12、LR、PC、xPSR 这 8 个寄存器——推进栈里。这就是”被打断那一刻”的全部状态。
  3. 查向量表:按中断号查出处理函数的地址。
  4. 换身份:把 LR 换成特殊魔数 EXC_RETURN,切到”处理模式(Handler mode)”,跳进你的 ISR。

书签:中断的”回头”靠什么保证

把第 2、4 步连起来看,中断”回头”的奥秘就全在栈里:进中断前,硬件把那 8 个寄存器压进栈;ISR 结束时,硬件按 EXC_RETURN 的指示把它们弹回来——其中就包含 PC,即被打断那条指令的地址。于是 while(1) 从被打断的那一行无缝续跑。

打个比方:你在读书,有人敲门,你夹一张书签在正在读的那页,起身开门,回来翻到书签处接着读。书签就是那 8 个寄存器,读书人就是 PC——main 甚至不知道你离开过,因为书页从头到尾没丢。

(EXC_RETURN 这个魔数还顺便告诉 CPU:回去时用主栈还是任务栈(MSP/PSP)。裸机上无所谓,在《FreeRTOS 核心原理》里,任务跑在 PSP 上、ISR 跑在 MSP 上,全靠它区分。)

嵌套:优先级更高的,可以再次打断

光有”能打断”还不够,现实是中断之间有贵贱之分。NVIC 给每个中断配一个优先级,数字越小越急,0 是最高。同一时刻只有一个中断在被处理;如果来了一个优先级更高的,正在跑的 ISR 会像主程序一样被压栈冻结,让新来的先跑——这叫嵌套(nesting)。

想象一间手术室,一位主刀医生(CPU)正在给脚趾扭伤的病人缝合(低优先级中断),护士推来一位大出血的急诊(高优先级中断)——医生立刻停下手里的活去抢救,救完回来接着缝。脚趾病人不会怪医生,因为抢救期间他被”冻”在手术台上,医生回来时接着刚才那针。

有一个容易被忽略的细节:只有更高的优先级才能抢占。 同级或更低的中断到了,只能挂起排队,等正在跑的 ISR 结束。因为每次嵌套都要压一次栈、恢复时弹一次栈,等级相同的中断没必要互相踩。

两个省钱绝活:尾链与晚到

进出一次中断要压栈、弹栈,各有 8 个寄存器,挺贵。Cortex-M 有两个硬件优化堵这个窟窿:

  • 尾链(tail-chaining):一个 ISR 刚结束、下一个更高或同级的中断已在排队时,省掉”先弹栈再压栈”,直接切到下一个。连续中断时,每次只付一次进出场的钱。
  • 晚到(late-arriving):一个中断正在压栈时,来了个更紧急的。硬件让紧急的插队,把刚压好的那套现场直接给它用。

这两个优化是”你以为要付两次钱,其实只付一次”——细节不用背,记住这个感觉就行。

优先级还分两块:抢占优先级与子优先级

Cortex-M4 给每个中断的优先级留了 8 位(NVIC 的 IPR 寄存器),但大多数芯片只用其中 4 位 → 共 16 级(0–15)。这几位还能拆成两半(由 AIRCR 的 PRIGROUP 控制):

  • 抢占优先级:决定能不能插队(上面的嵌套就是它管的)。
  • 子优先级:只在”抢占优先级相同、同时排队”时,用来决定谁先。子优先级不参与抢占。

绝大多数项目不碰 PRIGROUP,用默认值就好。值得钉一下的是那个容易记串的点:中断优先级是数字越小越优先,FreeRTOS 任务优先级正好反过来(数字越大越优先)——《FreeRTOS 核心原理》结尾提醒过,这里再钉一次。当年为什么这么设计,说不清了,大概是”0 在级别表里最靠前,正好也让’没有中断’的默认态好表达”。

三条铁律:机制一懂,规矩自然长出来

懂机制之后,ISR 的编写规矩就再也不是背出来的了:

  1. ISR 要短。 它抢占的是整个世界——一个跑 10ms 的 ISR,让所有更紧急的中断和主循环一起干等 10ms。ISR 的正确定位是”记下发生了什么,立刻返回”,具体处理放主循环里做。
  2. 别在 ISR 里调 printf 或长延时。 printf 会阻塞、会碰全局锁(不可重入)、还可能自己触发一次异常(比如走 semihosting 调试口),在一个”不能被打断”的环境里做”会打断”的事,死法千奇百怪——这就是开头那个坑。ISR 里要通知外面,用”置标志位 / 放队列 / 唤醒一个任务”这种不阻塞的方式(RTOS 里要用 FromISR 后缀的接口,见《FreeRTOS 核心原理》)。
  3. ISR 和主循环共享的变量,必须 volatile。 主循环和 ISR 是两条”看起来并行”的执行线,编译器可不知道 ISR 会改它——上一篇演示过删掉 volatile 的后果。当然 volatile 管不了原子性:两边都做 i++ 照样丢数,那是另一个话题。

到这里你已经懂原理了。但有个从 L1 就压着的问题:为什么这套”回头”能回得这么准、这么快? 更根本的是——为什么非得用硬件压栈,让软件做不行吗?

L3:想得透——为什么非要用硬件做这件事

到这一层,问题变成:这些设计是理所当然的吗?换掉行不行?

本质:中断到底是什么

去掉所有术语,中断是这样一件事:

CPU 把自己正在做的事,做成一张随时能放下、随时能捡起的书签;紧要的事一来,立刻放下去处理,处理完把书签翻回来接着读。而”做书签”“翻书签”这两个动作,是硬件替 CPU 完成的——快、准、每个中断都一样。

对照前面两层:书签 = 压进栈的那 8 个寄存器;”紧要的事” = 优先级更高的中断;”随时” = 抢占与嵌套;”硬件替你做” = NVIC + 自动压栈。给同事一句话:中断就是硬件帮你做的、比函数调用更”霸道”的临时换人——函数调用是你自愿跳走,中断是不管你愿不愿意,都得先停下。

再追问一层:为什么是这个方案,而不是显然的替代方案?

被否决的方案一:软件保存现场(老 ARM 的路)。 Cortex-M 之前的 ARM7TDMI 等老内核,中断来后只跳到一个固定入口,保存寄存器得靠 ISR 开头自己写——16 个寄存器,每个中断先存后取,又啰嗦又慢。当时的补救是 FIQ:ARM7 只有两条中断线,FIQ 那条”VIP 通道”用一组专用寄存器免去现场保存,但代价是只有一条通道能享受这待遇。Cortex-M 的决定是:把压栈做进硬件,让所有中断都享受 FIQ 级别的待遇。12 周期的确定性就是这么来的。

被否决的方案二:没有优先级,按先来后到排队(FIFO)。 最公平的方案。失败场景:低速设备(比如一次按键)先占住 CPU,真正十万火急的(比如电机换向的定时信号)就得排队等。实时系统的本质是”重要的不能等”——所以必须有优先级。代价也清楚:优先级低的中断可能被反复饿着,所以设计上要把”来得勤、不紧急”的中断优先级压低,并靠调度策略兜底。

被否决的方案三:统一关中断处理。 最简单粗暴:中断一来先关全局中断,处理完再开。单任务的极简系统够用。失败场景:一个耗时 ISR 会让所有中断(包括更紧急的)干等;而”关全局中断”意味着期间整段代码变成原子,实时时钟、串口数据全可能丢。NVIC 的优先级分级,本质就是”只关住不必要的,放行更重要的”——真正的关中断仍然存在,只是被压缩成保护共享数据时那几条指令的短临界区。

顺带一段历史:中断这个概念是 1950 年代巨型机时代发明的。当时的计算机靠”轮询”伺候外设——打印机打一个字符,CPU 就得停下来等,等一台慢打印机要烧掉上百万个时钟周期。IBM 704 等早期机器引入了”程序中断”:让外设”做完再来叫你”,CPU 平时专心算数。今天你熟悉的”事件驱动”思想,源头就在这里。

再点破一层关联:在《FreeRTOS 核心原理》里,任务切换靠的是 PendSV——那本质上是软件故意触发一次异常,借走整套硬件压栈/恢复的机制来换栈。中断这套”硬件替你保存/恢复现场”的能力,是整个 RTOS 调度的地基。

动手环节:在 PC 上亲眼看一次”打断与恢复”

下面是最重要的实验——它能在你的电脑上直接跑,因为你的操作系统提供了和中断同构的机制:信号(signal)。SIGALRM 信号会异步插进主程序的执行流,就像中断插进 while(1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile long n = 0;                 /* 主循环正在数数 */

void isr(int sig) {                   /* "中断处理函数":信号一响就被插入 */
    const char msg[] = "   [中断] 主循环跑得正欢,我插入!\n";
    write(1, msg, sizeof(msg) - 1);
}

int main(void) {
    signal(SIGALRM, isr);
    alarm(1);                         /* 1 秒后触发 SIGALRM */
    while (n < 5000000000L) n++;      /* 疯狂数数,中断会插进来 */
    printf("主循环跑完了,n = %ld\n", n);
    return 0;
}

先预测再看答案。 编译跑之前先猜:程序会打印几行?”[中断]”那行出现在”主循环跑完了”之前还是之后?

答案——gcc 文件名.c && ./a.out,典型输出(n 的具体值随机器波动):

1
2
   [中断] 主循环跑得正欢,我插入!
主循环跑完了,n = 5000000000

中断插在 while 循环的中间——n 数到一半被硬生生打断,又无缝续上;主循环从头到尾没停过,也”不知道”自己被插了一脚。这就是”分心与回头”在真机器上的样子。

两个补充观察:

  • 去掉 volatile 再 -O2 编译,循环可能被优化掉——还记得上一篇吗?
  • 这个演示里我们故意用 write 而不是 printf 打印。因为信号处理函数(相当于 ISR)里调 printf 在 POSIX 里是未定义行为,可能死锁。看,这就是本文开头那个坑的原型——ISR 里别用”重活”,连 PC 上都得小心。

再做一个纸面实验,测你对嵌套的理解。优先级数字越小越急:

  • 主程序在跑(最低优先级,谁都能打断)。
  • t=1ms:UART 中断(优先级 2)到达,处理需要 2ms。
  • t=2ms:TIM 中断(优先级 1)到达,处理需要 1ms,比正在跑的 UART 更急。

先预测:从 t=0 到 t=5ms,每毫秒谁在跑?

答案:

时间0–1ms1–2ms2–3ms3–4ms4–5ms
谁在跑主程序UARTTIM(抢断 UART)UART(回来续上)主程序

对一下你 L2 的模型:TIM 后来居上、先跑完(后进先出,因为它是最高优先级);UART 被冻在”处理到一半”,回来时接着那针缝,不用从头开始。这就是嵌套的压栈/弹栈——摞盘子,最上面的先拿走。

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

  1. 主程序在跑,优先级 3 的中断正在处理;又来了一个优先级 3 的中断。它会抢断前一个吗?(答案在 L2 嵌套:不会——只有更高的优先级才能抢占,同级只能挂起排队。)
  2. 中断返回时,while(1) 为什么能无缝续跑,而不是从 main 开头重来?(答案在 L2 自动压栈:硬件把 PC 等 8 个寄存器压了栈,返回时弹回来,PC 装回被打断的那条指令。)

收尾地图

把整篇文章收进一张速查表:

场景做法一句话理由
有外部事件要立刻响应(按键、脉冲)外部中断(EXTI)硬件敲门,不空等
周期性干活(定时、节拍)定时器中断(SysTick 等)精确计时,CPU 空闲时睡大觉
ISR 里该做什么只置标志、放队列、唤醒任务ISR 抢的是全世界,必须短
主循环与 ISR 共享变量volatile + 必要时关中断编译器不知道 ISR 会改它
两个中断同时来,谁先看抢占优先级,数字小者先子优先级只在同抢占时排序

下一站,两条路任选。往硬件里走:买一块板子,把文首的 SysTick 代码烧进去,用 GDB 在 SysTick_Handler 打断点,亲眼看看压栈/弹栈的现场;或者接一个按键中断,试着在 ISR 里加一行 printf,亲自复现本文开头那个坑。往回走:重读 《FreeRTOS 核心原理》,PendSV 那十几条指令就是”软件借用的中断”——你会发现整个 RTOS 调度,地基正是本文这套”硬件帮你保存/恢复现场”。再往后是 DMA 和中断驱动的串口收发,那才是”中断思维”真正发光的地方。

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