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 写得好不好,直接反映了一个人的系统思维。
具体来说,我总结了五条铁律:
- 不做耗时操作——printf、延时、复杂计算,统统不要出现在 ISR 里。我在一个项目中见过有人在 ISR 里调了 sprintf,结果中断延迟直接飙到毫秒级,整个系统卡死。
- 不调用不可重入函数——malloc、printf 这些大多不可重入。你想想看,如果主循环刚好在 malloc,中断里又来一次,堆管理器的链表就乱套了。
- 不修改共享资源——除非你用临界区保护。但临界区本身也会关中断,关久了又影响实时性。所以最好的办法就是:ISR 只写标志位,不碰业务数据。
- 保持原子操作——对全局变量的读写,尽量用一次性的赋值或位操作。比如 g_irq_flag |= IRQ_FLAG,一条指令搞定,不会被中断打断。
- 快速进出——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 知识结构图:中断与状态机结合的核心逻辑
下面这张图,把中断触发状态迁移的完整流程画出来了。你看一眼就能明白:
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 只置标志位、主循环做状态迁移、临界区保护要精准。把这三点做到位,你的系统就稳了八成。