2. 中断服务函数(ISR)的编写规则:ISR的命名规范、参数与返回值、可重入性

好,咱们直接进入正题。中断服务函数,也就是 ISR,是嵌入式系统里最敏感、最容易出问题的地方之一。我见过太多因为 ISR 写得随意,导致系统莫名其妙死机、跑飞的案例。说白了,ISR 不是普通的函数,它有自己的脾气和规矩。今天我就把这几条核心规则掰开揉碎了讲给你听。

2.1 ISR 的命名规范

命名这件事,看起来是小事,但我在项目里吃过亏。有一次接手一个老项目,里面 ISR 的名字五花八门,有的叫 timer_isr(),有的叫 ISR_TIMER(),还有的直接叫 func1()。你想想看,维护起来有多痛苦?

我个人习惯,ISR 的命名要遵循几个原则:

  • 明确标识中断源:名字里要能一眼看出是哪个外设或事件触发的。比如 Timer0_ISR()UART1_RX_ISR()
  • 使用统一前缀或后缀:团队内约定好,要么都用 _ISR 结尾,要么都用 isr_ 开头。我推荐用 _ISR 后缀,因为编译器或链接器有时会特殊处理带这个后缀的函数。
  • 避免与普通函数混淆:千万不要把 ISR 命名成 handle_timer() 这种,别人根本分不清这是中断处理还是普通调用。

推荐命名示例:

// 好的命名
void TIMER0_ISR(void);
void EXTI0_ISR(void);
void DMA1_CH2_ISR(void);

// 差的命名
void timer(void);
void isr(void);
void handle_interrupt(void);

嗯,这里要注意一点:有些芯片厂商的库函数已经定义了中断向量表里的函数名,比如 STM32 的 HAL_TIM_PeriodElapsedCallback()。这种情况下,我建议你遵循厂商的命名,不要自己另起炉灶,否则容易跟启动文件里的弱定义冲突。

2.2 参数与返回值

这条规则很简单,但很多人就是记不住:ISR 不能有参数,也不能有返回值。为什么?

你想想看,中断是硬件触发的。CPU 在响应中断时,硬件会自动把当前程序的上下文(PC、PSW、寄存器等)压栈,然后跳转到中断向量表里指定的地址。这个地址对应的函数,硬件期望它是个 void func(void) 的形式。如果你给它传参数,谁来传?硬件可不会帮你压参数。

我在项目中遇到过有人这么写:

// 错误的写法
int my_isr(int flag) {
    // 处理中断
    return status;
}

这种代码编译可能不会报错,但运行时绝对会出问题。因为调用者(硬件)不会传参数,函数内部拿到的 flag 是个随机值,返回值也没人接收。更严重的是,有些编译器会为这种带参数和返回值的函数生成不同的栈帧处理代码,导致中断返回时栈指针错乱,系统直接崩溃。

警告: 永远不要给 ISR 定义参数或返回值。ISR 的标准原型必须是 void ISR_Name(void)。如果你需要传递数据,请使用全局变量或共享内存。

那如果我真的需要在 ISR 里传递数据怎么办?比如我想把接收到的 UART 数据传给主循环?答案是:用全局变量。但要注意,这个全局变量要加 volatile 关键字,防止编译器优化掉对它的访问。

// 正确的做法
volatile uint8_t g_uart_rx_data;
volatile uint8_t g_uart_rx_flag;

void UART1_ISR(void) {
    g_uart_rx_data = UART1->DR;  // 读取接收数据寄存器
    g_uart_rx_flag = 1;           // 设置标志位
}

2.3 可重入性

可重入性,说白了就是一个函数能不能被多个任务或中断同时调用,而不会把数据搞乱。对于 ISR 来说,这个问题尤其重要。

我举个例子你就明白了。假设你有一个全局变量 counter,ISR 里对它做自增操作:

volatile uint32_t counter;

void TIMER_ISR(void) {
    counter++;  // 这条语句在 ARM Cortex-M 上可能被编译成多条指令
}

看起来没问题对吧?但 counter++ 在底层可能是三条指令:读、加、写。如果在这个 ISR 执行到一半时,另一个更高优先级的中断抢占了 CPU,也去操作 counter,那结果就乱套了。这就是典型的不可重入问题。

我的经验: 我曾经在一个多中断嵌套的项目里,因为一个全局变量在多个 ISR 里被操作,导致数据偶尔错乱。查了整整两天才定位到问题。从那以后,我给自己定了个规矩:ISR 里尽量只操作本中断私有的寄存器,或者用原子操作访问共享变量

那怎么保证 ISR 的可重入性?我给你几个实用建议:

  • 避免使用全局变量:如果非用不可,加 volatile,并且考虑用原子操作(比如 ARM 的 LDREX/STREX 指令,或者关中断保护)。
  • 不要调用不可重入的函数:像 printf()malloc()sprintf() 这些标准库函数,很多都不是可重入的。在 ISR 里调用它们,轻则死锁,重则堆栈溢出。
  • ISR 要短小精悍:只做最必要的事,比如读取数据、设置标志位。复杂的处理放到主循环或任务里去做。
  • 如果必须用锁,用中断安全的锁:比如关中断来保护临界区,但关中断的时间要尽可能短。

可重入 ISR 的典型模式:

// 推荐的做法:ISR 只做标记,主循环处理
volatile uint8_t g_event_flag;

void EXTI_ISR(void) {
    g_event_flag = 1;  // 只设置标志,不做复杂处理
    // 不要在这里调用 printf 或 malloc
}

void main_loop(void) {
    while(1) {
        if(g_event_flag) {
            g_event_flag = 0;
            // 在这里做真正的处理
            process_event();
        }
    }
}

最后再啰嗦一句:可重入性不是只有 ISR 才需要考虑,但在 ISR 里它是最致命的。你想想看,普通函数出问题可能只是某个功能异常,ISR 出问题,整个系统的实时性就没了,甚至直接死机。所以,写 ISR 的时候,脑子里要时刻绷着这根弦。

好,关于 ISR 的命名、参数返回值、可重入性,我就讲这么多。下一节我们会聊 ISR 的执行时间控制和临界区保护,那又是另一个容易踩坑的地方。