4、中断服务程序(ISR)设计:ISR编写规范、临界区保护、中断嵌套与抢占
好,咱们接着聊中断。前面几章我们把中断的硬件机制、向量表、优先级这些底层东西都捋了一遍。现在终于到了最核心的部分——怎么写中断服务程序(ISR)。
说实话,我见过太多工程师在ISR里翻车了。有的把ISR写得跟主程序一样长,有的在中断里调了printf,还有的直接在ISR里等信号量……嗯,这些坑我基本都踩过。今天就把我这些年总结的经验,一次性倒给你们。
4.1 ISR编写规范:短、平、快
ISR的编写原则,说白了就三个字:短、平、快。
- 短:代码行数尽量少。我个人的习惯是,ISR里不超过20行C代码。超过这个数,你就该想想是不是设计有问题了。
- 平:不要调用阻塞函数。比如delay、等待信号量、互斥锁这些,统统不要出现在ISR里。
- 快:执行时间要短。你想想看,ISR执行的时候,其他中断都在排队等着呢。
核心原则:ISR里只做最必要的事——读取硬件状态、清除中断标志、把数据扔进缓冲区。真正的处理逻辑,交给后台任务去干。
我曾经在一个项目中,看到同事在ISR里做了浮点运算。结果呢?中断响应时间从5微秒飙到了200微秒,整个系统直接卡死。所以记住:ISR里别做复杂计算。
4.1.1 典型ISR模板
下面这个模板,是我在多个项目里打磨出来的。你可以直接拿来用:
// 定时器中断服务程序示例
void TIM2_IRQHandler(void)
{
// 1. 检查中断源(多中断源共享时尤其重要)
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
{
// 2. 清除中断标志(必须第一时间做!)
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
// 3. 读取硬件数据(越快越好)
uint32_t capture = TIM_GetCapture1(TIM2);
// 4. 数据入队(使用无锁队列)
if (ringbuf_write(&capture_buf, &capture) != RINGBUF_OK)
{
// 溢出处理:要么丢弃,要么置标志
overflow_flag = 1;
}
// 5. 触发后台处理(通过信号量或事件标志)
// 注意:这里只是置位,不阻塞
task_notify_from_isr(processing_task, EVENT_DATA_READY);
}
}
我的经验:清除中断标志一定要放在读取数据之前。为什么?因为有些硬件在清除标志后,会立即更新寄存器。如果你先读数据再清标志,可能会读到旧数据。这个顺序问题,我当年调试了整整两天才找到原因。
4.2 临界区保护:别让中断坏了你的数据
临界区,说白了就是一段不能被中断打断的代码。比如你在更新一个全局变量,突然来了个中断,中断里也改了同一个变量——那结果就乱套了。
你想想看,这种问题多难排查。它可能跑一万次才出一次错,而且出错时机完全随机。我当年在数控系统里就遇到过,刀具轨迹偶尔会跳变,查了三个月才发现是临界区没保护好。
4.2.1 临界区保护方法
| 方法 | 适用场景 | 缺点 |
|---|---|---|
| 关中断 | 短临界区(<10条指令) | 影响中断响应实时性 |
| 关特定中断 | 只保护特定资源 | 需要知道哪个中断会访问该资源 |
| 无锁数据结构 | 生产者-消费者模式 | 实现复杂,适用场景有限 |
| 中断屏蔽寄存器 | 多级中断系统 | 硬件相关,移植性差 |
我个人最推荐的是关中断方式,但前提是临界区要足够短。下面是一个标准实现:
// 临界区保护示例
volatile uint32_t system_tick = 0;
volatile uint32_t position_counter = 0;
void enter_critical_section(void)
{
// 保存当前中断状态并关中断
__disable_irq();
}
void exit_critical_section(void)
{
// 恢复中断状态
__enable_irq();
}
// 安全的更新操作
void safe_update_position(uint32_t delta)
{
enter_critical_section();
position_counter += delta;
system_tick++;
exit_critical_section();
}
注意:关中断的时间绝对不能长!在CNC系统中,伺服驱动器的位置更新周期通常是1ms。如果你关中断超过100μs,电机就可能出现抖动。我曾经见过一个案例,工程师在临界区里做了EEPROM写入操作,关中断长达5ms,结果伺服轴直接飞车了。
4.2.2 无锁队列:ISR与主线程的桥梁
ISR和主线程之间传递数据,最优雅的方式就是无锁队列。它不需要关中断,也不需要互斥锁,性能极高。
// 环形缓冲区(无锁实现)
#define RINGBUF_SIZE 256
typedef struct {
volatile uint32_t head; // 写指针(ISR使用)
volatile uint32_t tail; // 读指针(主线程使用)
uint32_t data[RINGBUF_SIZE];
} ringbuf_t;
// ISR中写入(只修改head)
ringbuf_write(ringbuf_t *buf, uint32_t value)
{
uint32_t next_head = (buf->head + 1) % RINGBUF_SIZE;
if (next_head == buf->tail) {
return RINGBUF_FULL; // 缓冲区满
}
buf->data[buf->head] = value;
buf->head = next_head;
return RINGBUF_OK;
}
// 主线程中读取(只修改tail)
ringbuf_read(ringbuf_t *buf, uint32_t *value)
{
if (buf->head == buf->tail) {
return RINGBUF_EMPTY; // 缓冲区空
}
*value = buf->data[buf->tail];
buf->tail = (buf->tail + 1) % RINGBUF_SIZE;
return RINGBUF_OK;
}
避坑指南:我曾经在无锁队列上栽过跟头——缓冲区大小设成了2的幂次方,但取模运算用了%而不是位运算。结果在ARM Cortex-M3上,%运算耗时是&运算的10倍。后来改成 (head + 1) & (SIZE - 1),性能直接起飞。
4.3 中断嵌套与抢占:谁先谁后?
中断嵌套,就是高优先级中断打断低优先级中断。在CNC系统中,这几乎是必然发生的。比如位置比较中断(优先级高)打断了定时器中断(优先级低)。
你想想看,如果处理不好嵌套,会出现什么情况?低优先级ISR刚读到一半的数据,被高优先级ISR改写了——数据一致性就崩了。
4.3.1 中断嵌套的规则
- 规则1:高优先级中断可以打断低优先级中断
- 规则2:同级中断不能互相打断(取决于硬件设计)
- 规则3:ISR中再次开中断,才能支持嵌套
下面这张图,是我手绘的中断嵌套流程,你们感受一下:
4.3.2 嵌套时的临界区保护
当存在中断嵌套时,临界区保护就变得更复杂了。你不能简单地关中断,因为关中断会阻止高优先级中断响应。
正确的做法是:只关当前ISR优先级及以下的中断。在ARM Cortex-M系列中,可以通过BASEPRI寄存器实现:
// 基于优先级的临界区保护(Cortex-M3/4)
void enter_critical_by_priority(void)
{
// 设置BASEPRI,屏蔽优先级低于或等于当前值的中断
// 假设当前ISR优先级为3,则屏蔽优先级3及以下的中断
__set_BASEPRI(3 << 4); // Cortex-M中优先级寄存器使用高4位
}
void exit_critical_by_priority(void)
{
// 恢复BASEPRI为0,允许所有中断
__set_BASEPRI(0);
}
重要提醒:使用BASEPRI时,一定要确保高优先级中断(比如位置比较中断)的优先级数值小于BASEPRI设置的值。在Cortex-M中,优先级数值越小,优先级越高。所以如果你设置BASEPRI=3,那么优先级0、1、2的中断仍然可以响应。
4.4 实战经验总结
说了这么多,最后给大家几个我这些年总结的铁律:
- ISR里别调printf——printf是阻塞的,而且可能触发重入问题。调试用GPIO翻转代替。
- ISR里别动态分配内存——malloc不是线程安全的,而且耗时不确定。
- ISR里别等锁——如果你在ISR里等一个被主线程持有的锁,那就是死锁。
- ISR里别做浮点运算——除非你的MCU有硬件FPU,而且你确认不会导致上下文切换开销过大。
- ISR的栈空间要预留充足——嵌套中断时,每个ISR都会消耗栈空间。我一般会预留至少512字节给中断栈。
嗯,关于ISR设计,今天就聊这么多。这些规范和方法,都是我一个个坑踩出来的。你们在实际项目中,一定要根据具体的硬件平台和实时性要求来灵活调整。记住:没有银弹,只有最适合的方案。
最后一句:写ISR的时候,多想想「如果这个中断现在被打断,数据会不会乱?」——想明白了,你的ISR就稳了。
公众号:蓝海资料掘金营,微信deep3321