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();
}
}
你看,中断里只设标志位,主循环里轮询处理。这样中断执行时间极短,系统响应快,而且不会因为某个任务卡住而影响其他中断。
三个铁律:
- 标志位必须用
volatile修饰,防止编译器优化掉。 - 中断里只设标志,不做复杂处理。
- 主循环处理完事件后立即清标志,避免重复处理。
3.5 前后台系统的知识结构
下面这张图,把前后台系统的核心逻辑串起来了。你看一遍,应该能对整个体系有个直观认识。
3.6 前后台系统的优缺点
任何架构都有取舍。前后台系统也不例外:
| 优点 | 缺点 |
|---|---|
| 结构简单,容易理解和实现 | 任务优先级只能靠中断优先级硬编码 |
| 中断响应快,适合实时性要求高的场景 | 主循环是顺序执行,一个任务卡住,后面的全等 |
| 资源占用少,不需要RTOS那样的任务栈 | 任务间通信只能靠全局变量,容易出bug |
| 调试方便,逻辑清晰 | 任务多了以后,主循环轮询效率下降 |
我的建议:前后台系统适合任务数少于10个、实时性要求明确的中小型项目。比如智能家电、传感器采集、简单控制类产品。如果任务超过15个,或者有复杂的同步需求,就该考虑上RTOS了。
3.7 避坑指南
最后,我把自己这些年踩过的坑总结一下,你记好了:
- 中断里别调printf:printf是阻塞的,而且重入不安全。我见过有人中断里调printf,结果死锁了。
- 标志位用位操作:多个标志位可以用一个uint8_t的位域,节省内存,操作也快。
- 中断优先级别全设成一样:如果所有中断优先级相同,同时触发时只能按硬件向量号排序,你控制不了顺序。
- 关中断前先保存状态:如果关中断前中断已经关了,开中断时就会把之前的状态破坏掉。用
__disable_irq()和__enable_irq()要小心。