27、函数与中断服务程序:嵌入式中的ISR编写,中断上下文与普通函数的区别,volatile关键字

各位好,今天我们来聊一个嵌入式开发中绕不开的话题——中断服务程序(ISR)。

说实话,我见过不少工程师,写普通函数写得行云流水,一到写ISR就翻车。为什么?因为中断上下文和普通函数上下文,根本就是两码事。你想想看,普通函数是你主动调用的,你知道它什么时候跑、跑多久、参数怎么传。但中断呢?它随时可能发生,在你毫无防备的时候抢走CPU的控制权。

嗯,咱们今天就把这事儿彻底讲透。

中断上下文 vs 普通函数上下文

先看一张图,帮你建立整体认知:

中断上下文 vs 普通函数上下文 普通函数上下文 中断上下文 ✅ 可重入(默认) ✅ 可调用任意函数 ✅ 可访问任意全局变量 ✅ 可阻塞/等待 ✅ 可传参/返回值 ✅ 调用者控制栈 ✅ 可嵌套调用 ❌ 必须可重入 ❌ 只能调用可重入函数 ⚠️ 全局变量需volatile ❌ 不能阻塞/等待 ❌ 无参数/无返回值 ❌ 硬件自动压栈 ❌ 尽量短小精悍 本质差异

这张图把两者的核心差异都列出来了。我重点挑几个容易踩坑的点展开说。

ISR的特殊约束

中断服务程序,说白了就是一段在特定事件触发时自动执行的代码。但它和普通函数最大的区别在于——你永远不知道它什么时候会跑。

我在项目中遇到过这样一个问题:一个UART接收中断,ISR里调用了printf()。结果呢?printf()内部用了全局缓冲区,主循环也在用这个缓冲区。中断一进来,两个上下文同时操作同一块内存,数据全乱了。嗯,这就是典型的不可重入问题。

核心原则:ISR中调用的任何函数,都必须是可重入的。所谓可重入,就是函数可以被多次同时调用而不会产生数据错乱。

那什么样的函数是可重入的?

  • 只使用局部变量(栈上的变量)
  • 不访问全局变量(除非用volatile+关中断保护)
  • 不调用不可重入的库函数(如malloc()printf()

volatile关键字——ISR的守护神

说到ISR和全局变量的交互,就不得不提volatile。这个关键字,我敢说有一半的嵌入式工程师没真正理解它。

先看一个经典翻车案例:

// 错误示例
int flag = 0;

void main() {
    while (!flag) {
        // 等待中断设置flag
    }
    // 处理事件
}

void ISR() {
    flag = 1;
}

这段代码,在开启编译器优化后,很可能永远跳不出循环。为什么?

编译器看到while(!flag),心想:这个flag在循环里没被修改啊,那我干脆把它优化成while(1)算了,省得每次去内存读。结果中断改了内存里的flag,CPU却不知道,还在死循环里转。

注意:volatile告诉编译器:这个变量的值可能被意想不到的方式改变(比如中断、硬件寄存器、多线程),每次访问都必须从内存重新读取,不能优化到寄存器里。

正确的写法:

// 正确示例
volatile int flag = 0;

void main() {
    while (!flag) {
        // 现在编译器会老老实实每次从内存读flag
    }
}

void ISR() {
    flag = 1;
}

我个人习惯,只要是被中断和主循环共享的全局变量,一律加volatile。宁可多写,不可漏写。我曾经因为漏掉一个volatile,在产线上排查了整整两天——那个bug只在特定温度下复现,因为编译器优化策略和温度有关,你说邪门不邪门?

volatile的三大应用场景

场景 说明 示例
中断共享变量 主循环和ISR共同访问的变量 volatile uint8_t rx_flag;
硬件寄存器 内存映射的硬件寄存器,值可能被外设改变 volatile uint32_t *reg = (volatile uint32_t *)0x4000;
多线程共享变量 RTOS中多个任务访问的变量 volatile int shared_data;

避坑指南:volatile不能保证原子性。如果一个32位变量在8位MCU上被中断修改,主循环读它时可能读到一半的值(高16位被改了,低16位还没改)。这种情况需要关中断保护,或者用原子操作。

ISR编写的最佳实践

说了这么多约束,那ISR到底该怎么写?我总结了几条经验:

  1. 越短越好——ISR里只做最必要的事,比如置个标志位、读个数据。耗时操作放到主循环或任务里处理。
  2. 不要阻塞——别在ISR里用delay()while()等待。你阻塞了,其他中断就进不来,系统就卡死了。
  3. 保存和恢复上下文——虽然硬件会自动压栈PC和状态寄存器,但如果你在ISR里修改了通用寄存器,记得保存和恢复。C编译器通常会自动处理,但如果你嵌入汇编,就得自己操心。
  4. 关中断要谨慎——关中断时间越长,系统实时性越差。我一般控制在几十个指令周期以内。

来看一个规范的ISR示例:

// 规范的ISR写法
volatile uint8_t g_rx_buffer[128];
volatile uint16_t g_rx_index = 0;

void USART1_IRQHandler(void) {
    uint8_t data;
    
    // 1. 检查中断源
    if (USART1->SR & USART_SR_RXNE) {
        // 2. 读取数据(清中断标志)
        data = USART1->DR;
        
        // 3. 存储数据(仅做必要操作)
        if (g_rx_index < 128) {
            g_rx_buffer[g_rx_index++] = data;
        }
        
        // 4. 错误处理(可选)
        if (g_rx_index >= 128) {
            // 溢出标志,主循环会处理
            g_rx_index = 128;
        }
    }
}

你看,这个ISR只做了三件事:读数据、存数据、检查边界。没有调用任何库函数,没有阻塞等待,干净利落。

中断嵌套与优先级

有些MCU支持中断嵌套——高优先级中断可以打断低优先级中断。这时候要注意:

  • 高优先级ISR里访问的共享变量,可能被低优先级ISR修改
  • 如果两个ISR共享同一个变量,要么都用volatile,要么在访问时关掉对方的中断

我遇到过最头疼的情况是:两个中断共享一个环形缓冲区,一个负责写,一个负责读。不加保护的话,读指针可能追上写指针,或者写指针覆盖了还没读的数据。最后我用了一个无锁环形缓冲区,配合volatile和内存屏障才搞定。

总结一下:写ISR,脑子里要时刻绷紧一根弦——这个函数随时可能被调用,调用的时候主程序可能在干任何事。所有共享资源都要用volatile标记,所有非可重入函数都不能碰,所有耗时操作都要扔出去。

嗯,关于ISR和volatile,今天就聊到这儿。这些东西看着简单,但真正用好,得靠项目经验一点点积累。下次你写ISR的时候,多想想我今天说的这些,能帮你省不少调试时间。


公众号:蓝海资料掘金营,微信deep3321