4. 中断与状态机结合:中断中触发状态迁移、中断服务函数设计原则、临界区保护

中断和状态机,这两个东西单独拎出来都不难。但把它们捏在一起,才是真正考验嵌入式工程师功底的地方。我见过太多项目,状态机跑得挺好,一开中断就乱套。说白了,中断是异步的,状态机是同步的,怎么让它们和平共处,这就是本章要聊的核心。

4.1 中断触发状态迁移:两种主流模式

中断里能不能直接改状态?能,但有讲究。我个人习惯把中断触发状态迁移分成两种模式,你先看看这个对比:

模式 做法 适用场景 风险
直接迁移 ISR 中直接修改 state 变量 极简状态机,中断频率低 临界区问题,状态跳转不可控
标志位通知 ISR 只置位标志位,主循环处理迁移 大多数实际项目 延迟响应,但安全可控

直接迁移,我早期做电机控制时用过。当时觉得反正就两个状态,中断里直接 state = STATE_STOP 不就完了?结果有一次在中断里改了状态,主循环刚好读到一半,状态机直接跑飞。嗯,从那以后我再也不敢在 ISR 里直接写 state 了。

标志位通知才是正道。看代码:

// 状态机主循环
while(1) {
    if (g_irq_flag & IRQ_BUTTON_PRESS) {
        g_irq_flag &= ~IRQ_BUTTON_PRESS;
        state_machine_handle_event(EVENT_BUTTON_PRESS);
    }
    state_machine_run(current_state);
}

// 中断服务函数
void EXTI0_IRQHandler(void) {
    if (EXTI_GetITStatus(EXTI_Line0) != RESET) {
        g_irq_flag |= IRQ_BUTTON_PRESS;  // 只置位,不迁移
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

你想想看,这样设计的好处是什么?中断只做最轻量的事——置位一个标志位。状态迁移的逻辑全部集中在主循环里,可读性、可调试性都强很多。

核心原则:中断负责「通知」,主循环负责「执行」。把状态迁移的决定权交给主循环,而不是交给中断。

4.2 中断服务函数设计原则:短、快、稳

写 ISR 其实就三个字:短、快、稳。我面试嵌入式工程师时,经常拿这个来考人。为什么?因为 ISR 写得好不好,直接反映了一个人的系统思维。

具体来说,我总结了五条铁律:

  1. 不做耗时操作——printf、延时、复杂计算,统统不要出现在 ISR 里。我在一个项目中见过有人在 ISR 里调了 sprintf,结果中断延迟直接飙到毫秒级,整个系统卡死。
  2. 不调用不可重入函数——malloc、printf 这些大多不可重入。你想想看,如果主循环刚好在 malloc,中断里又来一次,堆管理器的链表就乱套了。
  3. 不修改共享资源——除非你用临界区保护。但临界区本身也会关中断,关久了又影响实时性。所以最好的办法就是:ISR 只写标志位,不碰业务数据。
  4. 保持原子操作——对全局变量的读写,尽量用一次性的赋值或位操作。比如 g_irq_flag |= IRQ_FLAG,一条指令搞定,不会被中断打断。
  5. 快速进出——ISR 里只做必须做的事,然后立刻退出。我曾经接手过一个项目,ISR 里做了 200 行的数据处理,你说这还能叫中断吗?

我的习惯:ISR 的代码行数控制在 10 行以内。超过 10 行,我就开始怀疑是不是设计有问题了。

4.3 临界区保护:别让共享数据「裂开」

临界区,说白了就是一段不能被打断的代码。为什么不能被打断?因为你在读写共享数据,中断一来,数据读到一半就被改了,读出来的东西既不是旧的也不是新的——这叫「数据撕裂」。

举个例子:

// 错误示范:没有临界区保护
uint32_t g_counter = 0;

void main_loop(void) {
    if (g_counter > 100) {   // 读 g_counter
        // 此时中断发生,ISR 修改了 g_counter
        // 回到这里时,g_counter 已经不是刚才读到的值了
        g_counter = 0;        // 写 g_counter
    }
}

void TIM_IRQHandler(void) {
    g_counter++;  // 中断里也在写 g_counter
}

你想想看,g_counter 在主循环里被读了一次,然后中断改了它,主循环再写回去——这个写操作可能覆盖了中断的增量。这就是典型的竞态条件。

解决办法?加临界区保护:

// 正确做法:临界区保护
void main_loop(void) {
    __disable_irq();          // 关中断,进入临界区
    if (g_counter > 100) {
        g_counter = 0;
    }
    __enable_irq();           // 开中断,退出临界区
}

但注意,关中断的时间越短越好。我见过有人把整个状态机的主循环都包在临界区里,结果中断响应延迟得一塌糊涂。临界区只保护「读-改-写」那几条指令就够了。

避坑指南:我曾经在一个项目中,临界区里调了一个函数,那个函数里又调了另一个函数,层层嵌套,最后关中断的时间超过了 100 微秒。结果串口接收中断丢了数据。从那以后,我定了一条规矩:临界区里只放原子操作,绝不调函数。

4.4 知识结构图:中断与状态机结合的核心逻辑

下面这张图,把中断触发状态迁移的完整流程画出来了。你看一眼就能明白:

中断与状态机结合核心流程 外部中断源 ISR 置位标志位 全局标志位 主循环检测标志位 状态迁移处理 状态机执行新状态 关键设计要点: 1. ISR 只置位标志位,不做状态迁移 2. 主循环检测到标志位后,调用状态机处理 3. 状态迁移逻辑集中管理,便于调试 4. 临界区保护只在必要时使用 避坑提醒: 不要在 ISR 中直接修改 state 变量 临界区关中断时间不要超过 10 微秒

4.5 实战中的临界区保护策略

临界区保护,说起来简单,做起来坑不少。我根据实际项目经验,整理了一个策略表:

场景 保护方式 推荐度 说明
单字节变量读写 原子操作(无需保护) ★★★★★ ARM Cortex-M 对 8/16/32 位对齐访问是原子的
多字节变量(如结构体) 关中断 ★★★★ 关中断时间控制在几微秒内
中断与主循环共享队列 关中断 + 队列锁 ★★★★★ 推荐使用无锁环形队列
多中断嵌套共享数据 中断优先级 + 关中断 ★★★ 尽量设计为不共享,或只用标志位

这里我要特别说一下无锁环形队列。如果你在 ISR 和主循环之间传递数据,用环形队列是最优雅的方案。ISR 只往队列里写,主循环只从队列里读,只要队列深度够,根本不需要临界区保护。

// 无锁环形队列示例(单生产者-单消费者)
#define QUEUE_SIZE 16

typedef struct {
    uint8_t buffer[QUEUE_SIZE];
    volatile uint8_t head;  // 写指针,ISR 修改
    volatile uint8_t tail;  // 读指针,主循环修改
} ring_queue_t;

// ISR 中写入
uint8_t queue_push(ring_queue_t *q, uint8_t data) {
    uint8_t next = (q->head + 1) % QUEUE_SIZE;
    if (next == q->tail) return 0;  // 队列满
    q->buffer[q->head] = data;
    q->head = next;
    return 1;
}

// 主循环中读取
uint8_t queue_pop(ring_queue_t *q, uint8_t *data) {
    if (q->head == q->tail) return 0;  // 队列空
    *data = q->buffer[q->tail];
    q->tail = (q->tail + 1) % QUEUE_SIZE;
    return 1;
}

你想想看,这个设计妙在哪里?head 只有 ISR 写,tail 只有主循环写,没有竞争。而且读写都是单条赋值指令,天然原子。我在一个 200kHz 的中断频率下用过这个方案,稳得很。

总结一下:中断与状态机结合,核心就三件事——ISR 只置标志位、主循环做状态迁移、临界区保护要精准。把这三点做到位,你的系统就稳了八成。