3. 任务与调度核心概念

好,咱们进入第三章。前面两章我们把裸机轮询和定时器扫描讲透了。这一章,我要跟你聊聊真正让单片机“活”起来的东西——中断。

说实话,我刚入行那会儿,觉得中断就是个“自动跳转”的功能。后来被坑了几次才明白,中断系统玩得好不好,直接决定了你的产品稳不稳定。你想想看,一个按键按下去,系统要立刻响应,还是等主循环转一圈再说?这就是中断存在的意义。

3.1 前后台系统到底长什么样?

前后台系统,也叫中断驱动系统。名字听着玄乎,其实结构特别简单:

  • 前台(中断服务程序):处理紧急事件,比如按键按下、数据接收完成、定时器溢出。
  • 后台(主循环):处理非紧急任务,比如刷新屏幕、更新状态机、处理数据。

前后台通过“标志位”通信。前台只负责“报告事件”,后台负责“处理事件”。

核心原则:中断服务程序要短、快、干净。只做最必要的事——设置标志位、读取数据、清除中断标志。剩下的,交给主循环。

我在一个智能家居项目里见过同事把整个LCD刷新函数塞进中断里,结果系统一卡一卡的,按键响应延迟半秒。嗯,这就是典型的“前台干后台的活”。

3.2 中断优先级:谁先抢到CPU?

多个中断同时触发怎么办?谁先执行?这就涉及到中断优先级了。

大多数MCU支持多级中断优先级。以STM32的NVIC为例,它可以配置抢占优先级和子优先级:

优先级分组 抢占优先级位数 子优先级位数 说明
NVIC_PriorityGroup_0 0 4 无抢占,只有子优先级
NVIC_PriorityGroup_1 1 3 2级抢占,8级子优先级
NVIC_PriorityGroup_2 2 2 4级抢占,4级子优先级
NVIC_PriorityGroup_3 3 1 8级抢占,2级子优先级
NVIC_PriorityGroup_4 4 0 16级抢占,无子优先级

抢占优先级决定能否打断正在执行的中断。数字越小,优先级越高。子优先级决定同时触发时谁先执行,但不能打断。

我的习惯:一般用NVIC_PriorityGroup_2,4级抢占够用,4级子优先级做精细排序。定时器中断给最高抢占优先级,串口中断次之,GPIO外部中断再次之。这样系统时钟不会被其他中断卡住。

3.3 中断嵌套:中断里还能进中断?

中断嵌套,说白了就是一个中断正在执行,另一个更高优先级的中断来了,CPU暂停当前中断,去处理更紧急的那个。处理完再回来继续。

这个过程由硬件自动完成——压栈、跳转、执行、出栈、返回。但有几个坑你得注意:

  • 栈空间要够:每嵌套一层,栈就多压一批寄存器。嵌套太深,栈溢出,程序就飞了。
  • 可重入问题:中断和主循环共享的变量,要用volatile修饰。中断和中断之间共享的变量,要考虑原子操作。
  • 不要关中断太久:关中断等于把紧急事件挡在门外。我曾经在项目中关中断做EEPROM写入,结果串口数据丢包了。

我曾经踩过的坑:一个电机控制项目,我开了3层中断嵌套。结果有一次栈溢出,程序跑飞到异常处理里,电机直接失控。从那以后,我严格控制嵌套层数不超过2层,并且给每个中断任务分配独立的栈空间监控。

3.4 在中断中设置标志位:前后台通信的基石

这是前后台系统的核心机制。前台(中断)只做一件事:把标志位置1。后台(主循环)轮询标志位,发现置1就处理对应事件,处理完清0。

看个典型代码:

// 标志位定义
volatile uint8_t g_flag_uart_rx = 0;  // 串口收到数据
volatile uint8_t g_flag_timer_1ms = 0; // 1ms定时到
volatile uint8_t g_flag_key_press = 0; // 按键按下

// 串口接收中断
void USART1_IRQHandler(void)
{
    if(USART_GetITStatus(USART1, USART_IT_RXNE))
    {
        g_rx_byte = USART_ReceiveData(USART1);
        g_flag_uart_rx = 1;  // 只设标志,不处理
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

// 主循环
void main(void)
{
    while(1)
    {
        if(g_flag_uart_rx)
        {
            g_flag_uart_rx = 0;  // 清标志
            ProcessUartData(g_rx_byte);  // 处理数据
        }

        if(g_flag_timer_1ms)
        {
            g_flag_timer_1ms = 0;
            UpdateSystemTick();
        }

        if(g_flag_key_press)
        {
            g_flag_key_press = 0;
            HandleKeyEvent();
        }

        // 其他后台任务
        IdleTask();
    }
}

你看,中断里只设标志位,主循环里轮询处理。这样中断执行时间极短,系统响应快,而且不会因为某个任务卡住而影响其他中断。

三个铁律:

  1. 标志位必须用 volatile 修饰,防止编译器优化掉。
  2. 中断里只设标志,不做复杂处理。
  3. 主循环处理完事件后立即清标志,避免重复处理。

3.5 前后台系统的知识结构

下面这张图,把前后台系统的核心逻辑串起来了。你看一遍,应该能对整个体系有个直观认识。

前后台系统(中断驱动)核心结构 中断源 定时器溢出 串口接收完成 GPIO外部中断 ADC转换完成 中断优先级 抢占优先级 子优先级 中断嵌套控制 中断服务程序(前台) 读取硬件数据 设置标志位 清除中断标志 立即返回 全局标志位 volatile uint8_t g_flag_xxx 主循环(后台) 轮询标志位 处理事件 → 清标志 前后台通信机制 前台(中断)只设标志,不处理数据 后台(主循环)轮询标志,处理完清0 标志位必须用 volatile 修饰

3.6 前后台系统的优缺点

任何架构都有取舍。前后台系统也不例外:

优点 缺点
结构简单,容易理解和实现 任务优先级只能靠中断优先级硬编码
中断响应快,适合实时性要求高的场景 主循环是顺序执行,一个任务卡住,后面的全等
资源占用少,不需要RTOS那样的任务栈 任务间通信只能靠全局变量,容易出bug
调试方便,逻辑清晰 任务多了以后,主循环轮询效率下降

我的建议:前后台系统适合任务数少于10个、实时性要求明确的中小型项目。比如智能家电、传感器采集、简单控制类产品。如果任务超过15个,或者有复杂的同步需求,就该考虑上RTOS了。

3.7 避坑指南

最后,我把自己这些年踩过的坑总结一下,你记好了:

  • 中断里别调printf:printf是阻塞的,而且重入不安全。我见过有人中断里调printf,结果死锁了。
  • 标志位用位操作:多个标志位可以用一个uint8_t的位域,节省内存,操作也快。
  • 中断优先级别全设成一样:如果所有中断优先级相同,同时触发时只能按硬件向量号排序,你控制不了顺序。
  • 关中断前先保存状态:如果关中断前中断已经关了,开中断时就会把之前的状态破坏掉。用 __disable_irq() 和 __enable_irq() 要小心。