第三章 中断服务程序编写规范:最小化原则、临界区保护、可重入性设计
中断服务程序(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代码:
// 一个符合三原则的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的时候,不妨把这三条贴在显示器旁边,每写一行代码都问自己:够最小化吗?临界区保护了吗?可重入吗?