第四章 任务切换与上下文:Cortex-M3/M4内核的“换人”艺术
各位同学,欢迎来到第四章。这一章,可以说是整个RTOS内核的“心脏起搏器”。
任务切换,说白了就是CPU在多个任务之间来回“串门”。你想想看,一个单核CPU,凭什么能同时运行好几个任务?靠的就是快速切换——把当前任务的所有“状态”保存好,再把下一个任务的“状态”恢复出来。这个“状态”,就是上下文。
我个人习惯把上下文切换比作“换人”。就像打篮球,A队员下场,B队员上场。下场前得把A的球衣、球鞋、战术板都收好;上场时B得穿上自己的装备。CPU也是一样,寄存器就是它的“装备”。
核心要点:任务切换的本质,就是保存当前任务的寄存器现场,恢复下一个任务的寄存器现场。
4.1 Cortex-M3/M4内核寄存器组:CPU的“家底”
先来看看Cortex-M3/M4有哪些寄存器。这些寄存器,就是任务切换时要保存和恢复的“家底”。
| 寄存器组 | 数量 | 说明 |
|---|---|---|
| R0-R12 | 13个 | 通用寄存器,用于数据运算和地址存储 |
| R13 (SP) | 1个 | 栈指针寄存器,分MSP和PSP |
| R14 (LR) | 1个 | 链接寄存器,保存函数返回地址 |
| R15 (PC) | 1个 | 程序计数器,指向当前执行指令 |
| xPSR | 1个 | 程序状态寄存器,包含条件标志等 |
| 特殊寄存器 | 3个 | PRIMASK, FAULTMASK, BASEPRI(中断屏蔽相关) |
嗯,这里要注意一点。Cortex-M3/M4有两个栈指针:MSP(主栈指针)和PSP(进程栈指针)。MSP通常给中断服务程序和内核代码用,PSP给用户任务用。这个设计很巧妙,后面讲任务栈布局时会用到。
我的经验:刚开始学RTOS时,我总搞混MSP和PSP。后来我记住一句话:“中断用MSP,任务用PSP”。这样就好记多了。你在写移植代码时,一定要搞清楚当前用的是哪个栈指针,否则压栈压错地方,系统立马崩溃。
4.2 PendSV异常与任务切换:软中断的妙用
为什么任务切换要用PendSV?直接在一个函数里切换不行吗?
当然不行。你想想看,如果任务A正在访问一个全局变量,突然被切换走了,任务B也来访问这个变量,数据就乱套了。所以,任务切换必须等到“安全”的时候才能进行。
PendSV异常,就是专门干这个的。它的优先级可以设置为最低,这样它就会等到所有高优先级中断处理完毕后,才被触发执行。说白了,PendSV就是“等大家都忙完了,我再开始换人”。
我曾经在一个项目中,因为没处理好PendSV的优先级,导致任务切换时被其他中断打断,结果栈指针乱了,系统跑飞。嗯,从那以后,我每次移植RTOS,第一件事就是检查PendSV的优先级设置。
PendSV的触发方式很简单:往ICSR寄存器的第28位写1。就像按下一个“延迟切换”按钮。
// 触发PendSV异常
*(volatile uint32_t *)0xE000ED04 = (1 << 28); // ICSR寄存器
4.3 任务栈空间布局:每个任务的“私人储物柜”
每个任务都有自己的栈空间。这个栈,就是任务切换时保存寄存器的“储物柜”。
任务栈的布局,从上到下一般是这样的:
- 栈顶(高地址):初始时SP指向这里
- 自动保存的寄存器:R0-R3, R12, LR, PC, xPSR(由硬件自动压栈)
- 手动保存的寄存器:R4-R11(由软件手动压栈)
- 任务控制块指针:通常指向当前任务的TCB
- 栈底(低地址):栈的边界
为什么硬件只自动保存一部分寄存器?这是Cortex-M3/M4的硬件特性。当异常发生时,CPU会自动把R0-R3、R12、LR、PC、xPSR这8个寄存器压栈。剩下的R4-R11,就得靠我们自己在PendSV处理函数里手动保存了。
关键点:任务栈的初始状态,必须模拟成“刚发生过一次异常”的样子。这样当任务第一次被切换进来时,CPU的异常返回机制就能正常工作。
4.4 上下文切换的汇编代码逐行解析:手把手教你“换人”
好了,到了最硬核的部分。我们来看看PendSV处理函数里到底发生了什么。这段代码,我建议你一行一行地读,最好能背下来。
; PendSV_Handler - 任务切换的核心
PendSV_Handler:
; 1. 保存当前任务的上下文
MRS R0, PSP ; 获取当前任务的栈指针
STMFD R0!, {R4-R11} ; 手动保存R4-R11到任务栈
; 此时R0指向新的栈顶
; 2. 更新当前任务的栈指针到TCB
LDR R1, =CurrentTCB ; 获取当前TCB的地址
LDR R1, [R1] ; 获取当前TCB的值(指向当前任务控制块)
STR R0, [R1] ; 将新的栈顶保存到TCB的第一个字段
; 3. 切换到下一个任务
LDR R0, =NextTask ; 获取下一个任务的TCB地址
LDR R0, [R0] ; 获取下一个任务的TCB值
LDR R1, =CurrentTCB ; 更新当前TCB指针
STR R0, [R1] ; 将下一个任务设为当前任务
; 4. 恢复下一个任务的上下文
LDR R0, [R0] ; 从TCB中取出栈顶指针
LDMFD R0!, {R4-R11} ; 恢复R4-R11
MSR PSP, R0 ; 更新PSP
; 5. 异常返回,硬件自动恢复R0-R3, R12, LR, PC, xPSR
BX LR ; 返回,触发异常返回序列
来,我们逐行分析:
- MRS R0, PSP:获取当前任务的进程栈指针。注意,这里用的是PSP,不是MSP。因为任务运行在PSP上。
- STMFD R0!, {R4-R11}:把R4到R11压入任务栈。STMFD是“Store Multiple Full Descending”的缩写,就是满递减栈,地址从高往低走。
- LDR R1, =CurrentTCB:加载当前任务控制块的地址。CurrentTCB是一个全局变量,保存着当前运行任务的TCB指针。
- STR R0, [R1]:把新的栈顶指针存入TCB的第一个字段。通常TCB的第一个字段就是栈顶指针。
- LDR R0, =NextTask:获取下一个要运行的任务的TCB。NextTask是调度器选出来的。
- LDR R0, [R0]:从下一个任务的TCB中取出它的栈顶指针。
- LDMFD R0!, {R4-R11}:从栈中弹出R4-R11。LDMFD是“Load Multiple Full Descending”的缩写。
- MSR PSP, R0:更新PSP,指向下一个任务的栈顶。
- BX LR:这条指令最神奇。LR在异常入口时被硬件设置为一个特殊值(EXC_RETURN),BX LR会触发异常返回序列,硬件自动从栈中恢复R0-R3、R12、LR、PC、xPSR。
避坑指南:我曾经犯过一个低级错误——在PendSV里用了MSP而不是PSP。结果压栈压到了主栈里,任务栈纹丝不动。切换回来后,任务栈里的数据全是乱的。记住:任务上下文操作,一定要用PSP!
还有一个细节:为什么硬件自动保存的寄存器不需要我们手动处理?因为Cortex-M3/M4的异常入口和出口机制已经帮我们做了。异常发生时,硬件自动压栈R0-R3、R12、LR、PC、xPSP;异常返回时,硬件自动弹出这些寄存器。我们只需要处理R4-R11这些“漏网之鱼”。
嗯,到这里,任务切换的核心机制就讲完了。你可能会问:“那任务第一次创建时,栈里应该放什么?”这个问题问得好。任务第一次运行时,栈里必须模拟成“刚从异常返回”的状态。也就是说,我们要在任务栈里预先放好R0-R3、R12、LR、PC、xPSR这些寄存器的值,其中PC要指向任务的入口函数,PSP要指向栈顶。这样当任务第一次被PendSV切换进来时,BX LR一执行,CPU就自动跳转到任务入口了。
这部分内容,我会在下一章“任务创建与初始化”里详细展开。今天先把“换人”的流程吃透,后面就水到渠成了。