手把手教你写 ARM Cortex-M 中断服务例程:从原理到实战
你有没有遇到过这样的情况?系统明明配置好了定时器中断,可就是进不去 ISR;或者一进中断就跑飞,HardFault 爆炸满天星。又或者,在低功耗场景下,主循环忙等待耗电如喝水,却不知道怎么用中断唤醒……
别急,这些问题的背后,往往不是外设没配对,而是你还没真正搞懂 Cortex-M 的中断机制。
今天我们就来一次讲透——如何正确、高效、安全地编写一个 ARM Cortex-M 平台上的中断服务例程(ISR)。这不是一份简单的“代码模板”,而是一套完整的工程思维体系:从硬件行为到底层寄存器,从编译器特性到实际编码规范,带你一步步构建可信赖的中断处理逻辑。
为什么 Cortex-M 的中断这么特别?
在开始写代码前,我们必须先回答一个问题:Cortex-M 的中断到底强在哪?
相比传统 MCU(比如 8051),Cortex-M 架构最大的优势之一就是它的嵌套向量化中断控制器(NVIC)和完全由硬件管理的上下文切换机制。
举个例子:
假设你的系统正在处理 UART 接收中断,突然来了一个更高优先级的紧急信号(比如看门狗或故障保护)。在老架构上,你可能要等到当前 ISR 完成才能响应;但在 Cortex-M 上,高优先级中断可以立即抢占,延迟低至12 个时钟周期。
这背后靠的是什么?
- 自动保存 R0-R3, R12, LR, PC, xPSR:不需要你在 C 代码里手动压栈。
- 尾链优化(Tail-Chaining):连续中断之间只需 6 个周期就能切换,避免重复压栈弹栈。
- 向量跳转直达 ISR:无需轮询哪个外设触发了中断,直接查表执行。
这些特性加起来,让 Cortex-M 成为实时性要求严苛场景的首选,比如电机控制、音频流处理、工业自动化等。
NVIC 是怎么调度中断的?别再只会调NVIC_EnableIRQ()了!
很多人写中断的时候,习惯性三连:
NVIC_SetPriority(...); NVIC_EnableIRQ(...);但你知道这背后发生了什么吗?我们来拆开看看 NVIC 的真实工作机制。
NVIC 到底管些什么?
NVIC 是集成在内核里的中断控制器,地址映射在0xE000E100开始的一段内存区域。它不光负责使能中断,还管着:
- 优先级分组(Preemption Priority vs Subpriority)
- 中断挂起状态
- 活动状态查询
- 动态优先级调整
每个外部中断最多支持 240 个通道(具体看芯片厂商实现),每个通道都有独立的优先级设置寄存器(IPR0 ~ IPRxx),每 4 个中断占一个 32 位寄存器。
抢占优先级和子优先级的区别
这是新手最容易混淆的地方。
| 类型 | 是否可抢占 | 作用 |
|---|---|---|
| 抢占优先级 | ✅ 可以打断低优先级 ISR | 决定是否能“插队” |
| 子优先级 | ❌ 不可抢占 | 同一级别内决定执行顺序 |
举个例子:
- ISR_A:抢占=2,子=1
- ISR_B:抢占=2,子=0
→ A 不能打断 B,因为抢占相同,但子优先级更低 → B 先执行。
但如果 ISR_C 抢占=1,那它就可以打断 A 和 B。
所以,关键任务一定要给高的抢占优先级!
如何正确设置优先级?
很多开发者直接写数字:
NVIC_SetPriority(TIM2_IRQn, 2); // 错!没考虑分组!这是危险操作!因为你不知道当前系统的优先级分组模式。
正确的做法是使用 CMSIS 提供的标准 API:
// 设置分组(通常在初始化阶段调用一次) NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); // 4bit 抢占,0bit 子 // 编码优先级 uint32_t priority = NVIC_EncodePriority(NVIC_GetPriorityGrouping(), 2, 1); NVIC_SetPriority(USART1_IRQn, priority); NVIC_EnableIRQ(USART1_IRQn);这样做的好处是:跨平台兼容性强,无论你是 STM32、NXP 还是 Nordic 芯片,只要遵循 CMSIS 标准,代码都能跑。
向量表不只是启动文件里的数组
当你打开任何一个 Cortex-M 的启动文件(startup_stm32f4xx.s),都会看到这样一个符号:
Vectors DCD __initial_sp ; Top of Stack DCD Reset_Handler DCD NMI_Handler DCD HardFault_Handler ...这个就是中断向量表,它是整个中断机制的“电话簿”——CPU 凭异常号查表找入口地址。
默认情况下,向量表位于 Flash 起始地址0x0000_0000。但你知道吗?你可以把它搬到 SRAM 里!
为什么要重定位向量表?
常见应用场景包括:
- Bootloader 跳转到 App 时,需要切换中断处理逻辑;
- 实现固件空中升级(OTA);
- 多任务环境中不同上下文使用不同的中断处理函数;
- 动态替换某个 ISR 用于调试或热更新。
怎么搬?真的只是 memcpy 就完事了吗?
来看一段典型实现:
#define VECTOR_TABLE_SIZE (16 + 80) // 16个系统异常 + 80个外部中断 extern uint32_t g_pfnVectors[]; // 启动文件中定义的原始向量表 void relocate_vector_table_to_sram(void) { memcpy((void*)0x20000000, g_pfnVectors, VECTOR_TABLE_SIZE * 4); SCB->VTOR = 0x20000000; // 更新向量表偏移寄存器 __DSB(); // 数据同步屏障 __ISB(); // 指令同步屏障 }注意最后两个内存屏障指令:
__DSB():确保所有数据访问已完成;__ISB():清空取指流水线,防止 CPU 从旧位置取指令。
少了它们,可能导致跳转失败或不可预测行为。
另外,SRAM 中的向量表必须按512 字节对齐(如果芯片支持重定位功能的话),否则 VTOR 写入会失败。
ISR 到底该怎么写?别让“好心”变成“坑”
现在终于到了最核心的部分:怎么写一个合格的 ISR?
你以为 ISR 就是个普通函数?错。它运行在特殊的异常上下文中,稍有不慎就会引发灾难性后果。
常见误区与“坑点”
| 误区 | 后果 | 正确做法 |
|---|---|---|
在 ISR 中调用printf | 占用大量时间,可能死锁 | 改为发送消息或置标志位 |
| 忘记清除中断标志 | 中断反复触发,卡死 | 第一时间清标志 |
| 使用非原子方式访问共享变量 | 数据竞争、状态错乱 | 加锁、关中断或用 volatile |
| 长时间运行 | 阻塞其他中断,系统无响应 | 只做最小化处理 |
黄金法则:快进快出,推后处理
最佳实践是:ISR 只做标记,主循环来做事。
来看一个经典 ADC 示例:
volatile uint32_t adc_result = 0; volatile uint8_t adc_ready_flag = 0; void ADC_IRQHandler(void) { if (ADC1->SR & ADC_SR_EOC) { adc_result = ADC1->DR; // 读数据自动清标志 adc_ready_flag = 1; // 置位标志 } } int main(void) { system_init(); adc_init(); while (1) { if (adc_ready_flag) { process_adc_data(adc_result); adc_ready_flag = 0; } __WFI(); // Wait for Interrupt,进入睡眠省电 } }这里有几个关键点:
volatile关键字必不可少 —— 告诉编译器:“这个变量可能被意外修改,请不要优化掉读写!”- 清标志操作要准确 —— 对于 ADC,读 DR 寄存器即可清除 EOC;
- 主循环可用
__WFI进入低功耗模式,仅靠中断唤醒,大幅节能。
这就是典型的事件驱动架构:CPU 绝大部分时间在睡觉,只在事件发生时醒来干活。
共享资源访问怎么办?别随便关中断!
多个 ISR 或 ISR 与主程序共用一个全局变量时,很容易出现竞态条件。
比如:
uint32_t sensor_value; // ISR 中更新 void TIM2_IRQHandler() { sensor_value = read_sensor(); } // 主循环中使用 while (1) { send_value_over_uart(sensor_value); }如果在send_value_over_uart执行中途被 TIM2 中断打断并修改了sensor_value,会发生什么?数据前后不一致!
解决方案有三种:
方法一:临时关闭中断(简单粗暴)
__disable_irq(); temp = sensor_value; __enable_irq(); use_value(temp);优点:简单有效。
缺点:影响实时性,尤其关总中断时间过长会导致错过其他中断。
建议只用于极短临界区。
方法二:使用原子操作(推荐用于单变量)
ARM Cortex-M 支持 LDREX/STREX 指令实现原子访问:
uint32_t atomic_load(volatile uint32_t *addr) { return __LDREXW(addr); } void atomic_store(volatile uint32_t *addr, uint32_t val) { while (__STREXW(val, addr)); }适用于计数器、状态机等场景。
方法三:RTOS 互斥量(复杂共享资源)
如果你用了 FreeRTOS 或 RT-Thread,可以用 Mutex:
xSemaphoreTake(mutex, 0); // 非阻塞获取 // 操作共享数据 xSemaphoreGive(mutex);注意:绝对不能在 ISR 中调用阻塞型 API!但可以使用FromISR版本,如xQueueSendToBackFromISR()。
栈够吗?别让中断嵌套把你压垮
你有没有算过你的堆栈最大深度是多少?
假设:
- 主程序用了 512B
- 最深一层 ISR 调了几个函数,用了 256B
- 最坏情况发生 4 层中断嵌套 → 4 × 256 = 1024B
总共就需要至少512 + 1024 = 1536B的栈空间。
如果你只分配了 1KB 的主栈(MSP),那就等着 HardFault 吧。
怎么评估?
- 查链接脚本中的
Stack_Size - 使用调试器查看 MSP 当前值与栈顶距离
- 在极端负载下测试,观察是否有栈溢出
还可以开启MPU(内存保护单元),将栈区域设为不可越界访问,一旦溢出立刻触发 MemManage Fault,便于定位问题。
高级技巧:什么时候该用 PendSV?
前面提到,Cortex-M 有两种“软中断”:SVC 和 PendSV。
- SVC:用于系统调用,比如从用户态切到特权态;
- PendSV:专为 RTOS 设计,实现任务切换。
为什么不用普通中断来做任务调度?因为你想切换任务时,可能正处在另一个中断处理中。此时不能直接 Pend 中断,否则会破坏上下文。
PendSV 的妙处在于:它可以被推迟到所有其他中断结束后再执行。
FreeRTOS 就是这么干的:
void SysTick_Handler(void) { xTaskIncrementTick(); vPortYieldFromISR(); // 触发 PendSV } void PendSV_Handler(void) { // 保存旧任务上下文,恢复新任务上下文 context_switch(); }这样一来,任务切换总是在“最安全”的时刻进行,不会干扰关键中断。
写在最后:ISR 不是终点,而是起点
今天我们聊了很多:
- NVIC 的优先级机制
- 向量表重定位
- ISR 编码规范
- 共享数据保护
- 栈空间规划
- PendSV 的巧妙用途
但请记住:ISR 的价值不在于“进了没”,而在于“怎么退出”之后系统如何反应。
在未来越来越复杂的嵌入式场景中——比如 AIoT 边缘推理、多传感器融合、实时语音处理——中断不再只是“通知一下”,而是整个系统事件流的源头。
也许有一天,一个来自麦克风的 DMA 完成中断,会触发一次本地神经网络推理;而推理结果又通过另一个中断上报给应用层,决定是否唤醒云端连接。
那时你会发现,每一个 ISR,都是智能决策的第一步。
所以,下次你再写void EXTI0_IRQHandler(void)的时候,不妨多想一步:
“我这个中断,到底是在解决一个问题,还是在开启一个新的可能?”
欢迎在评论区分享你的 ISR 实战经验,我们一起打磨这套“嵌入式世界的底层语言”。