凡亿教育-佳佳
凡事用心,一起进步
打开APP
公司名片
凡亿专栏 | 中断的定义,如何划分优先级?运行时触发,程序怎么处理?
中断的定义,如何划分优先级?运行时触发,程序怎么处理?

中断怎么定义?如何规划优先级?运行时触发,程序怎么处理? 

 

我一想,这个问题极大概率是高频的面试题,所以写篇文章来记录下。

 

为了更好理解,我先来举例一个需求。

 

比如写个流水灯,写个按键检测,大多数新手的做法是一个 while(1) 大循环,里面老老实实地顺序执行。

 

然后,需求来了。老板(或者你自己的脑洞)说:我要流水灯亮着,同时按下按键,灯就灭了,松开又恢复。或者更狠:我要一个按键实现按下点亮,再按下熄灭,而且不能有任何误触或延迟。

 

你吭哧吭哧写代码:

 

C                  
void main(void)                  
{                  
    // ...初始化 LED, 按键...                  

    while (1)                  
    {                  
        // 流水灯代码                  
        LED1_ON();                  
        delay_ms(200);                  
        LED1_OFF();                  
        LED2_ON();                  
        delay_ms(200);                  
        LED2_OFF();                  
        // ...以此类推...                  

        // 按键检测代码                  
        if (KEY_IS_PRESSED())                  
        {                  
            LED_ALL_OFF();                  
            // 可能还要处理按键消抖...                  
            while (KEY_IS_PRESSED()); // 等待按键释放... 天哪                  
        }                  
    }                  
}

 

运行一看,啪!脸肿了。按键有时候灵,有时候不灵。流水灯跑得好好的,但当你按住按键不放,或者快速连击时,整个程序感觉都卡住了,流水灯也停了。你挠头:按键代码就那么几行,怎么会影响到上面的流水灯呢?

 

更要命的是,如果现在又加一个需求:定时器每 100ms 翻转一个 LED 的状态。你再把定时器的逻辑也塞到 while(1) 里,用软件延时或者一个变量计数来实现,代码很快就变得像一坨。。。耦合严重,逻辑混乱,bug 百出。你改了定时器的 bug,按键又不灵了;修好了按键,发现串口接收数据又丢了。

 

为什么会这样?因为你的程序是线性思维,它总是从上往下,一行一行执行。

 

当它在执行流水灯的延时时,它是“听不见”按键被按下的声音的;当它在等待按键释放时,它也“看不见”定时器已经溢出了。

 

你试图用一个 while(1) 把所有事情串起来,不断地去“询问”每个外设状态好了没(这就是所谓的轮询 Polling),这在任务少的时候勉强可以,但任务一多、时效性要求一高,立刻捉襟见肘,顾此失彼。CPU看起来很忙,但效率低下,响应迟钝。

 

这些痛点不解决,你的代码永远只是玩具,无法应对真实世界的复杂性和实时性要求。产品不稳定,调试周期漫长,你将花费大量时间追查那些看似随机出现的 bug,最终可能对单片机编程产生深深的挫败感。

 

而要解决这些问题,成为一个真正能写出稳定、高效、实时性强单片机程序的工程师,你必须掌握一项核心技能——中断。

 

本文将带你:

1.彻底理解什么是中断,它和函数调用有何本质区别。

2.弄清楚中断是如何被触发,CPU 又是如何响应的。

3.搞明白单片机是如何处理多个中断同时到来的——中断优先级。

4.学习在中断服务程序(ISR)中应该做什么,不应该做什么,以及如何与主程序协同工作。

 

理解这4点,你将吊打大部分关于中断的面试题了。

 

一、中断的真面目:不是打断,而是高效响应

 

中断(Interrupt),顾名思义,就是在程序正常执行过程中,被一个“事件”打断。但请注意,这个“打断”不是粗暴地戛然而止,而是一种有序、高效的流程切换。

 

想象一下,你正在专心看书(主程序 while(1) 循环),突然门铃响了(中断事件发生)。你不会直接把书撕了冲出去,对吧?你会:

1.记住看到第几页第几行了(保存当前程序的上下文,比如寄存器状态、程序计数器 PC 的值)。


2.合上书,起身去开门(CPU 跳转到中断服务程序 Interrupt Service Routine,ISR 的入口地址)。


3.处理敲门这件事(执行 ISR 里的代码,比如看看是谁,拿个快递)。

4.处理完后,关上门(ISR 执行完毕)。


5.回到书桌前,翻到刚才记住的那页那行(恢复之前保存的上下文)。


6.继续看书,好像什么都没发生过一样(CPU 返回到主程序被打断的地方,继续往下执行)。

 

这就是中断的基本流程。中断的本质是 CPU 暂停当前任务,转而去处理一个更紧急或优先级更高的事件,处理完后再返回原来的任务继续执行。

 

它和函数调用有什么区别?

•触发方式不同: 函数调用是你自己在代码里明确写 FunctionName() 去调用的,是程序主动的行为。中断是外部事件(硬件信号、定时器溢出、串口接收到数据等)或特定软件指令被动触发的。

 

•发生时刻不确定: 你知道函数调用会在你写的那一行发生。中断的发生时刻则取决于外部事件,可能在你程序执行的任何指令之后发生。

 

•CPU 处理流程不同: 函数调用涉及到将返回地址压栈,然后跳转。中断的流程更复杂,需要硬件自动或软件辅助保存和恢复整个运行环境(寄存器组、状态字等),并且通常会涉及中断向量表的查找。

 

•目的不同: 函数调用是为了复用代码或模块化功能。中断是为了在不确定时间点快速响应异步事件,提高系统的实时性和效率。

 

二、中断的触发与CPU的响应

中断的触发源多种多样,几乎单片机的所有外设都可以产生中断请求:

•外部中断: 某个引脚的电平变化(上升沿、下降沿或高低电平)。比如按键按下或释放。

•定时器中断: 定时器计数到设定值溢出,或者捕获/比较匹配。用于实现精确延时、周期性任务、波形生成等。

•通信中断: 串口接收到数据、发送完成、SPI/I2C 传输完成等。

•ADC 中断: 模数转换完成。

•其他: 比较器中断、DMA 传输完成中断、掉电检测中断等等,取决于具体的单片机型号。

 

一个中断的发生,通常需要满足几个条件:

 

1.中断源产生请求: 比如按键按下导致引脚电平变化,定时器计数溢出。这会在外设的一个状态寄存器中设置一个“中断标志位”(Interrupt Flag)。

 

2.该中断源被使能: 你需要在软件中配置允许这个特定的中断源发出请求。

 

3.全局中断被使能: 有一个总开关控制 CPU 是否响应任何中断请求。通常是一个寄存器位(如 ARM Cortex-M 的 PRIMASK 或总的使能位)。全局中断关闭时,外设中断请求依然会产生并设置标志位,但 CPU 不会理会,直到全局中断再次开启。

 

4.优先级允许: 如果当前 CPU 正在处理一个中断(或全局中断关闭),新到来的中断优先级必须高于当前正在处理的中断,CPU 才会暂停当前 ISR 转而处理新的中断(这叫中断嵌套或抢占)。

 

当满足条件后,CPU 会执行以下响应流程(不同架构略有差异,但核心思想一致):

1.完成当前指令: CPU 会等待当前正在执行的指令执行完毕。

 

2.保存上下文: CPU 会将当前重要的寄存器值(包括程序计数器 PC、状态寄存器 SREG/PSR 等)自动或通过硬件机制压入栈中,以便将来恢复。

 

3.确定中断源与 ISR 地址: CPU 会通过中断向量表(Interrupt Vector Table)查找与该中断源对应的中断服务程序的入口地址。中断向量表是存储在闪存(Flash)中,由一系列地址组成的表格。每个中断源对应表中的一个固定位置,该位置存储的就是其 ISR 的地址。

 

4.跳转执行 ISR: CPU 修改 PC 的值,跳转到对应的 ISR 地址,开始执行中断服务程序中的指令。

 

5.清除中断标志: 在 ISR 的开头或合适位置,必须通过软件清零该中断源对应的中断标志位。这是至关重要的一步!如果不清,ISR 执行完返回后,CPU 会发现标志位依然是置位的,就会认为中断事件还在发生,于是又立刻再次进入同一个 ISR,导致程序卡死在这里。

 

6.执行 ISR 内容: 处理中断事件。

 

7.返回: ISR 执行完毕后,通过一条特殊的返回指令(如 ARM Cortex-M 的 BX LR 配合特定的链接寄存器值,或者 AVR 的 RETI)告知 CPU 中断处理完成。

 

8.恢复上下文: CPU 从栈中弹出之前保存的寄存器值,恢复到中断发生前的状态。

 

9.继续主程序: CPU 修改 PC 的值,回到主程序之前被打断的地方,继续执行下一条指令。

 

整个过程是硬件和软件协同完成的,非常高效。主程序甚至“感受不到”被打断的过程,除了执行时间稍微变长了一点。

 

所以真正理解中断的触发和响应机制,不用死记硬背,不一定非得怼这些专业术语,哪怕能用业余的话说出来,面试大概率也没啥问题。

 

三、中断优先级:谁更重要,谁说了算?

 

如果多个中断事件几乎同时发生,或者一个中断正在被服务时,另一个中断也发生了,CPU 该怎么办?这就需要中断优先级机制。

 

中断优先级决定了:

 

1.服务顺序: 如果多个中断请求同时到达,CPU 会优先响应优先级最高的中断。

2.是否可以打断: 一个正在执行的低优先级 ISR 可以被更高优先级的中断打断(中断嵌套),但一个高优先级 ISR 不会被低优先级中断打断。

 

中断优先级的实现方式取决于单片机架构:

 

•简单架构: 可能只有固定优先级,比如51单片机,或者通过中断向量表中位置的先后决定优先级。

 

•复杂架构(如 ARM Cortex-M): 提供灵活的优先级配置。通常有一个主优先级(Preempt Priority)和一个子优先级(Subpriority),比如STM32。

○主优先级: 决定了中断是否可以抢占(打断)另一个正在执行的 ISR。主优先级高的可以打断主优先级低的。

○子优先级: 当多个中断请求具有相同的主优先级时,子优先级决定了它们的服务顺序。子优先级高的先被服务,但在某个 ISR 执行期间,同主优先级的其他中断请求会被挂起,不会发生抢占。

 

配置中断优先级通常涉及到设置中断控制器(如 ARM 的 NVIC)的寄存器。

 

你需要为每个使能的中断源分配一个优先级数值。记住,优先级数值和优先级高低的方向可能相反(比如数值越小优先级越高,或反之),具体取决于芯片手册的规定,查手册是王道!

 

合理划分中断优先级至关重要:

 

•高优先级: 赋予那些对响应时间要求极高的中断,比如紧急告警、高速数据接收(避免丢帧)。但要注意,高优先级 ISR 越短越好,因为它会阻止所有同级或低级中断以及主程序的执行。

 

•低优先级: 赋予那些时间要求不那么严格的中断,比如按键、周期性但不紧急的任务。

 

•同优先级: 具有相同主优先级的中断不能相互抢占,这可以简化共享资源的访问控制,但可能导致较高子优先级的中断被较低子优先级的“堵塞”一会儿。

 

一个常见的误区是,认为优先级越高越好。优先级过高且 ISR 过长的中断,可能导致低优先级中断长时间得不到响应,甚至丢失事件。所以,优先级设置是一个权衡的过程。

 

四、中断服务程序(ISR)里能干什么?运行时程序怎么处理?

 

ISR 是中断机制的核心,它是你编写的处理特定中断事件的代码块。不过,ISR 的执行环境非常特殊,有严格的行为规范。

 

ISR 的黄金法则:快进快出!

 

在 ISR 里,你应该只做最紧急、最核心、能快速完成的事情。原因如下:

 

1.阻塞效应: 大多数单片机在执行 ISR 时,会默认关闭同级或低优先级中断,高优先级中断可以抢占。ISR 执行时间越长,其他中断被延迟或主程序被暂停的时间就越长,可能导致系统响应变慢,甚至丢失其他事件。

 

2.栈空间有限: 中断会使用栈来保存上下文,如果 ISR 调用了太多函数或使用了大量局部变量,可能导致栈溢出。

 

3.共享资源问题: ISR 和主程序(或其他 ISR)可能同时访问同一个全局变量或硬件资源,如果不加以保护,可能发生数据混乱,即竞态条件(Race Condition)。

 

所以在 ISR 里通常做什么?

 

•清除中断标志位! (再次强调,非常重要)

•快速记录事件: 比如置一个标志位,表示某个事件发生了。

•存取关键数据: 如果是通信中断,可能需要将接收到的数据快速读到缓冲区里。如果是 ADC 中断,读取转换结果。

•少量寄存器操作: 直接对外设寄存器进行简单配置。

 

在 ISR 里应避免什么?

 

•耗时操作: 浮点运算、复杂的数学计算、长时间的循环、延时函数(delay\_ms())、打印输出(printf)。

•可能会阻塞的操作: 等待一个标志位、等待一个外设完成(除非是该 ISR 对应的外设)。

•动态内存分配(malloc/free)。

•调用非重入函数: 如果一个函数在被 ISR 打断后,又在 ISR 里被调用,而这个函数内部使用了非可重入的资源(比如全局变量或静态变量没有保护),就会出问题。标准的库函数(如部分字符串处理函数、动态内存函数)往往不是可重入的,要小心使用。

•复杂的同步机制: 信号量、互斥锁等在 ISR 中使用需要非常谨慎,容易引发死锁或性能问题。

 

那那些耗时的处理逻辑放哪里?

 

通常的做法是:ISR 只负责“感知”和“记录”,真正的“处理”放在主程序 while(1) 循环里。

 

ISR 在检测到事件后,快速设置一个全局标志位,或者将数据存入一个 FIFO 队列。

 

比如我们无际单片机特训营的项目,对于串口中断这种数据流,一般会存在队列里。

8beb0b15c9111be0f66e863bb6ea52.jpg

然后主程序在 while(1) 循环中不断地检查这些队列,发现有事件发生,就取出数据进行处理。

225467dbdf3613dca5ea8861270f66.jpg

 

这是一种经典的中断处理模式:中断通知  主循环处理,大多企业级项目都采用这种方式。

 

来看前面提到的定时器翻转 LED 的例子,使用中断怎么实现:


volatile unsigned char timer_100ms_flag = 0; // volatile 告诉编译器,这个变量可能在编译器不知道的时候被改变(比如在中断里)// 定时器中断服务程序 (伪代码)void Timer_IRQHandler(void){    if (TIMER_IS_EXPIRED()) // 检查是不是这个定时器触发的中断    {        timer_100ms_flag = 1; // 设置标志位,通知主循环        TIMER_CLEAR_FLAG();   // 清除硬件中断标志 - 必须做!    }}int main(void){    // ...初始化 LED, 定时器...    // 配置定时器,使其每 100ms 产生一次中断请求    // 使能定时器中断    TIMER_INTERRUPT_ENABLE();    // 使能全局中断    GLOBAL_INTERRUPT_ENABLE(); // 通常是 __enable_irq() 或者 sei() 等指令    while (1)    {        // 主循环做其他事情...        if (timer_100ms_flag) // 检查定时器标志        {            timer_100ms_flag = 0; // 清除标志位            // 在主循环里执行耗时的处理            LED_TOGGLE(); // 翻转 LED 状态        }        // 主循环还可以检查其他标志位,处理其他事件...        // 比如检查按键中断设置的标志位,处理按键逻辑        // 检查串口接收中断设置的标志位,处理接收到的数据    }}

定时器中断每 100ms 发生一次,ISR 只是简单地设置一个标志位并清除硬件标志。LED 的翻转操作放在了主循环里。这样,即使 LED_TOGGLE() 函数有点慢,它也只影响主循环的处理速度,不会阻塞定时器中断本身(当然,如果主循环处理太慢,可能会导致标志位一直为 1,但中断本身不会丢失,只是后续处理延迟了)。

 

这种模式分离了中断的快速响应和主循环的复杂处理,提高了系统的并发性和稳定性。

 

volatile 关键字对于在 ISR 和主程序之间共享的变量至关重要,它强制编译器每次都从内存中读取该变量的值,而不是使用寄存器中的缓存值,避免了优化导致的错误。

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表凡亿课堂立场。文章及其配图仅供工程师学习之用,如有内容图片侵权或者其他问题,请联系本站作侵删。
相关阅读
进入分区查看更多精彩内容>
精彩评论

暂无评论