第四章 任务切换与上下文: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就自动跳转到任务入口了。

这部分内容,我会在下一章“任务创建与初始化”里详细展开。今天先把“换人”的流程吃透,后面就水到渠成了。