第三章 中断服务程序编写规范:最小化原则、临界区保护、可重入性设计

中断服务程序(ISR)怎么写,其实是个老生常谈的话题。但我看过太多项目,明明硬件设计没问题,就是被ISR拖垮了。说白了,ISR写得好不好,直接决定了系统的实时性和稳定性。这一章,我把自己这些年踩过的坑和总结的经验,跟你好好聊聊。

核心三原则:最小化原则、临界区保护、可重入性设计。这三条是ISR编写的铁律,缺一不可。

3.1 最小化原则:ISR不是让你写业务逻辑的地方

我刚开始做嵌入式那会儿,总觉得ISR里能干的事越多越好。结果有一次,一个串口中断里做了大量数据解析,导致低优先级中断被长时间阻塞,系统直接跑飞了。嗯,从那以后,我给自己定了个规矩:ISR里只做最必要的事。

什么叫最必要的事?说白了就是:

  • 读取硬件寄存器,清除中断标志
  • 把数据搬到缓冲区(用最快的拷贝方式)
  • 设置一个标志位或发一个信号量
  • 然后立刻退出

剩下的处理,交给任务级代码去做。你想想看,ISR是抢占式的,它占用的时间越长,其他中断和任务的响应就越慢。

我的习惯:ISR代码行数控制在20行以内。超过这个数,我就会问自己——这段逻辑能不能挪到任务里?

来看一个对比示例:

// ❌ 错误做法:ISR里做大量处理
void UART_ISR_Wrong(void) {
    uint8_t data = UART->DR;
    // 这里开始做协议解析、校验、状态机更新...
    if (data == 0xAA) {
        // 几十行解析逻辑
    }
    // 还调用了printf?天哪!
    printf("Received: %c\n", data);
}

// ✅ 正确做法:ISR只做最小操作
void UART_ISR_Correct(void) {
    uint8_t data = UART->DR;
    // 仅做一件事:把数据塞进环形缓冲区
    if (ringbuf_write(&uart_rx_buf, data) == 0) {
        // 缓冲区满了?设置溢出标志
        g_uart_overflow = 1;
    }
    // 通知任务来处理
    sem_give(&uart_sem);
}

我曾经在一个项目中,把ISR里的协议解析全部挪到任务里,系统响应时间从原来的2ms降到了200μs。这就是最小化原则的力量。

3.2 临界区保护:别让中断把你的数据撕碎

临界区,说白了就是一段不能被中断打断的代码。为什么需要保护?因为ISR和主循环(或其他ISR)可能同时访问同一个变量或硬件资源。

我记得有一次调试一个电机控制程序,电流采样ISR和主循环都在读写同一个PID参数结构体。结果电机偶尔会抽风一下,查了三天才发现是临界区没保护。

常见的临界区保护方法有三种:

方法 适用场景 开销 注意事项
关中断 ISR与任务共享数据 低 关中断时间要极短,否则影响实时性
关调度 任务间共享数据 中 只禁止任务切换,不关中断
原子操作 单变量读写 最低 依赖硬件支持,如LDREX/STREX

来看一个关中断保护的典型写法:

// 共享变量
volatile uint32_t g_shared_counter = 0;

// 任务中访问
void task_function(void) {
    uint32_t local_copy;
    
    // 进入临界区:关中断
    __disable_irq();
    local_copy = g_shared_counter;
    local_copy++;
    g_shared_counter = local_copy;
    // 退出临界区:开中断
    __enable_irq();
}

// ISR中访问
void TIM_ISR(void) {
    // ISR本身就在中断上下文,不需要关中断
    // 但要注意:如果ISR和任务共享数据,任务侧必须保护
    g_shared_counter++;
}

我曾经踩过的坑:在关中断的临界区里调用了printf()。printf内部可能触发其他中断或系统调用,导致死锁。记住:临界区里不要调用任何可能阻塞或触发调度的函数。

3.3 可重入性设计:让ISR经得起嵌套

可重入性,听起来高大上,其实意思很简单:一个函数在被调用执行的过程中,可以被再次调用,而不会产生错误。

为什么ISR需要可重入?因为中断可以嵌套。高优先级中断可以打断低优先级中断。如果两个中断都调用了同一个函数,而这个函数不是可重入的,那数据就乱套了。

我见过一个案例:两个ISR都调用了同一个CRC计算函数,函数内部用了静态变量保存中间结果。结果高优先级ISR一进来,把低优先级ISR的CRC计算状态给覆盖了。嗯,校验失败,系统直接复位。

实现可重入性的关键点:

  • 不使用全局或静态变量——所有数据都通过参数传入
  • 只操作局部变量——局部变量在栈上,每个调用实例独立
  • 不调用不可重入的函数——比如某些标准库函数

来看对比:

// ❌ 不可重入:使用了静态变量
uint32_t crc_calc_wrong(const uint8_t *data, uint32_t len) {
    static uint32_t crc = 0;  // 静态变量!中断嵌套就完蛋
    for (uint32_t i = 0; i < len; i++) {
        crc = crc_table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
    }
    return crc;
}

// ✅ 可重入:所有状态由调用者管理
uint32_t crc_calc_right(const uint8_t *data, uint32_t len, uint32_t crc_init) {
    uint32_t crc = crc_init;  // 局部变量,每个调用实例独立
    for (uint32_t i = 0; i < len; i++) {
        crc = crc_table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
    }
    return crc;
}

我的建议:在ISR中调用的所有函数,都默认按可重入来设计。即使当前系统不支持中断嵌套,也要养成这个习惯。因为你不知道哪天需求变了,或者代码被移植到别的平台。

3.4 三原则的关系与实战框架

这三条原则不是孤立的。最小化原则让你少写代码,临界区保护让你写对代码,可重入性设计让你写的代码经得起考验。它们共同构成了ISR编写的安。

下面这张图,是我自己总结的ISR编写决策流程:

ISR编写决策流程 中断事件到达 原则1:最小化 只做读寄存器、清标志、搬数据、发信号 原则2:临界区保护 共享数据访问必须关中断或原子操作 原则3:可重入性设计 无全局/静态变量,只操作局部变量 安全退出ISR ⚠ 三条原则必须同时满足,缺一不可 任何一条没做到,都可能导致系统崩溃或数据错误

在实际项目中,我一般这样组织ISR代码:

// 一个符合三原则的ISR模板
void EXTI_ISR_Handler(void) {
    // 1. 最小化:只做必要操作
    uint32_t status = EXTI->PR;
    EXTI->PR = status;  // 清标志
    
    // 2. 临界区保护:写共享缓冲区前关中断
    __disable_irq();
    ringbuf_write(&g_sensor_buf, status);
    __enable_irq();
    
    // 3. 可重入:调用的函数都是可重入的
    sem_give(&sensor_sem);  // 信号量操作本身是可重入的
}

重要提醒:关中断的时间一定要短。我一般控制在几十个CPU周期以内。如果临界区代码太长,考虑用其他同步机制,比如信号量或互斥锁。

好了,这一章的内容就这些。三条原则看起来简单,但真正用好需要大量实践。你写ISR的时候,不妨把这三条贴在显示器旁边,每写一行代码都问自己:够最小化吗?临界区保护了吗?可重入吗?