27、函数与中断服务程序:嵌入式中的ISR编写,中断上下文与普通函数的区别,volatile关键字
各位好,今天我们来聊一个嵌入式开发中绕不开的话题——中断服务程序(ISR)。
说实话,我见过不少工程师,写普通函数写得行云流水,一到写ISR就翻车。为什么?因为中断上下文和普通函数上下文,根本就是两码事。你想想看,普通函数是你主动调用的,你知道它什么时候跑、跑多久、参数怎么传。但中断呢?它随时可能发生,在你毫无防备的时候抢走CPU的控制权。
嗯,咱们今天就把这事儿彻底讲透。
中断上下文 vs 普通函数上下文
先看一张图,帮你建立整体认知:
这张图把两者的核心差异都列出来了。我重点挑几个容易踩坑的点展开说。
ISR的特殊约束
中断服务程序,说白了就是一段在特定事件触发时自动执行的代码。但它和普通函数最大的区别在于——你永远不知道它什么时候会跑。
我在项目中遇到过这样一个问题:一个UART接收中断,ISR里调用了printf()。结果呢?printf()内部用了全局缓冲区,主循环也在用这个缓冲区。中断一进来,两个上下文同时操作同一块内存,数据全乱了。嗯,这就是典型的不可重入问题。
核心原则:ISR中调用的任何函数,都必须是可重入的。所谓可重入,就是函数可以被多次同时调用而不会产生数据错乱。
那什么样的函数是可重入的?
- 只使用局部变量(栈上的变量)
- 不访问全局变量(除非用volatile+关中断保护)
- 不调用不可重入的库函数(如
malloc()、printf())
volatile关键字——ISR的守护神
说到ISR和全局变量的交互,就不得不提volatile。这个关键字,我敢说有一半的嵌入式工程师没真正理解它。
先看一个经典翻车案例:
// 错误示例
int flag = 0;
void main() {
while (!flag) {
// 等待中断设置flag
}
// 处理事件
}
void ISR() {
flag = 1;
}
这段代码,在开启编译器优化后,很可能永远跳不出循环。为什么?
编译器看到while(!flag),心想:这个flag在循环里没被修改啊,那我干脆把它优化成while(1)算了,省得每次去内存读。结果中断改了内存里的flag,CPU却不知道,还在死循环里转。
注意:volatile告诉编译器:这个变量的值可能被意想不到的方式改变(比如中断、硬件寄存器、多线程),每次访问都必须从内存重新读取,不能优化到寄存器里。
正确的写法:
// 正确示例
volatile int flag = 0;
void main() {
while (!flag) {
// 现在编译器会老老实实每次从内存读flag
}
}
void ISR() {
flag = 1;
}
我个人习惯,只要是被中断和主循环共享的全局变量,一律加volatile。宁可多写,不可漏写。我曾经因为漏掉一个volatile,在产线上排查了整整两天——那个bug只在特定温度下复现,因为编译器优化策略和温度有关,你说邪门不邪门?
volatile的三大应用场景
| 场景 | 说明 | 示例 |
|---|---|---|
| 中断共享变量 | 主循环和ISR共同访问的变量 | volatile uint8_t rx_flag; |
| 硬件寄存器 | 内存映射的硬件寄存器,值可能被外设改变 | volatile uint32_t *reg = (volatile uint32_t *)0x4000; |
| 多线程共享变量 | RTOS中多个任务访问的变量 | volatile int shared_data; |
避坑指南:volatile不能保证原子性。如果一个32位变量在8位MCU上被中断修改,主循环读它时可能读到一半的值(高16位被改了,低16位还没改)。这种情况需要关中断保护,或者用原子操作。
ISR编写的最佳实践
说了这么多约束,那ISR到底该怎么写?我总结了几条经验:
- 越短越好——ISR里只做最必要的事,比如置个标志位、读个数据。耗时操作放到主循环或任务里处理。
- 不要阻塞——别在ISR里用
delay()、while()等待。你阻塞了,其他中断就进不来,系统就卡死了。 - 保存和恢复上下文——虽然硬件会自动压栈PC和状态寄存器,但如果你在ISR里修改了通用寄存器,记得保存和恢复。C编译器通常会自动处理,但如果你嵌入汇编,就得自己操心。
- 关中断要谨慎——关中断时间越长,系统实时性越差。我一般控制在几十个指令周期以内。
来看一个规范的ISR示例:
// 规范的ISR写法
volatile uint8_t g_rx_buffer[128];
volatile uint16_t g_rx_index = 0;
void USART1_IRQHandler(void) {
uint8_t data;
// 1. 检查中断源
if (USART1->SR & USART_SR_RXNE) {
// 2. 读取数据(清中断标志)
data = USART1->DR;
// 3. 存储数据(仅做必要操作)
if (g_rx_index < 128) {
g_rx_buffer[g_rx_index++] = data;
}
// 4. 错误处理(可选)
if (g_rx_index >= 128) {
// 溢出标志,主循环会处理
g_rx_index = 128;
}
}
}
你看,这个ISR只做了三件事:读数据、存数据、检查边界。没有调用任何库函数,没有阻塞等待,干净利落。
中断嵌套与优先级
有些MCU支持中断嵌套——高优先级中断可以打断低优先级中断。这时候要注意:
- 高优先级ISR里访问的共享变量,可能被低优先级ISR修改
- 如果两个ISR共享同一个变量,要么都用volatile,要么在访问时关掉对方的中断
我遇到过最头疼的情况是:两个中断共享一个环形缓冲区,一个负责写,一个负责读。不加保护的话,读指针可能追上写指针,或者写指针覆盖了还没读的数据。最后我用了一个无锁环形缓冲区,配合volatile和内存屏障才搞定。
总结一下:写ISR,脑子里要时刻绷紧一根弦——这个函数随时可能被调用,调用的时候主程序可能在干任何事。所有共享资源都要用volatile标记,所有非可重入函数都不能碰,所有耗时操作都要扔出去。
嗯,关于ISR和volatile,今天就聊到这儿。这些东西看着简单,但真正用好,得靠项目经验一点点积累。下次你写ISR的时候,多想想我今天说的这些,能帮你省不少调试时间。
公众号:蓝海资料掘金营,微信deep3321