任务如何切换---可能没你想的那么简单
任务切换本质是保存当前任务的上下文,然后恢复另一个任务的上下文。但是要在一颗芯片上很好的实现这一点其实并没有看上去那么简单。它其实设计到非常多的方面,需要对MCU内核特性,内联汇编,编译器特性等多方面都有深刻理解才能做好。下面开始任务切换进行分析。
另外需要说明的是,本文章基于cortex-m系列芯片进行讨论。
在哪里做任务切换动作
首先任务切换,其实就是抢走现有执行任务对CPU的执行权,那一个正常运行的程序要抢走它的CPU执行权,显然得靠中断。那么一颗mcu有非常多个中断,用哪个中断呢?这个中断不是谁都行,这个中断需要能够随时软件触发,因为实时操作系统随时都可能有切换任务需求,实际在芯片发展这么多年,设计芯片内核时早就针对RTOS做了优化,在在cortex-m系列中,PendSV(Pendable Service Call可悬起系统调用) 就是专门为RTOS任务切换设计的,可以随时软件触发。
那么PendSV中断如何触发呢,其实非常简单。只需将NVIC的ICSR寄存器中的PENDSVSET位(0xE000ED04 寄存器的第28位)置1, 就可以触发 PendSV 中断。
下面是在stm32f1(cortex-m3) 里面,在freeRTOS代码中触发PendSV中断的代码。
/* Scheduler utilities. */
#define portYIELD() \
{ \
/* Set a PendSV to request a context switch. */ \
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
\
/* Barriers are normally not required but do ensure the code is completely \
* within the specified behaviour for the architecture. */ \
__asm volatile ( "dsb" ::: "memory" ); \
__asm volatile ( "isb" ); \
}
#define portNVIC_INT_CTRL_REG ( *( ( volatile uint32_t * ) 0xe000ed04 ) )
#define portNVIC_PENDSVSET_BIT ( 1UL << 28UL )
PendSV中断如何实现任务切换
现场包括什么
任务切换到本质是保存当前任务的现场,以及恢复另一个任务的现场,那么什么是现场呢,没错就是你到的哪些通用寄存器以及SP、LR、PC这几个寄存器,这个非常好理解,R0~R12可能存储了中间变量,SP存储了栈地址,LR存储了函数执行后的返回地址,PC存储正在执行的地址。(另外如果内核中有浮点寄存器,其实要包保存浮点寄存器,如在Cortex-M7就有浮点寄存器,但本文只讨论最基本的,不把它搞的太复杂)除此之外还有一个xPSP寄存器

xPSR 就是(Program Status Registers 程序状态寄存器) 它存储了它运算结果的条件标志位,以及所在的中断号等信息。比如我们在写代码时,经常会做判断if(xx == yy) 这个时候的**计算结果并非存储于R0~R12中,而是存储于xPSR 的标志位中


下面举一个例子或许就明白了,这个给大家推荐一个非常好的网站 https://godbolt.org/ 网站名称叫 Compiler Explorer(编译器探索器),它可以实时查看 C/C++ 代码编译后的汇编代码,同时支持选择内核架构和编译器,这里我用它做一个实验就可以看到xPSR的作用。
如下图,当判断 if(a == b) 时,实际的汇编代码是
cmp r0, r1
这个比较操作会影响处的 Z 标志位(Zero Flag)。
- 如果
r0和r1相等,Z 标志位会被设置为 1。 - 如果
r0和r1不相等,Z 标志位会被设置为 0。
接着一句代码就是:
bne .LBB0_2
bne 的完整意思是:“如果不相等,则跳转” (Branch if Not Equal)。它实际就是去判断Z未是否为0来作出跳转的决定。所以保存xPSR无疑是现场之一

进入异常(包括中断)时硬件自动压栈
从前面的讨论中,我们知道现场包括了哪些寄存器,那么此时我们进入到PendSV中断函数时,是否是应该将手动将这些保存到栈中呢?其实不然,因为在cortex-m 系列中,当进入中断服务程序(Interrupt Service Routine)前,硬件会自动保存R0-R3、R12、R14(LR)以及 PSR。 这几个寄存器到当前栈中。详细可参考ARM官方文档《ARMv7-M Architecture Reference Manual》中 B1.5.6节“Exception entry behavior” 的描述。文档下载地址 https://developer.arm.com/documentation/ddi0403/latest/

也就是进入到PendSv 的中断函数 xPortPendSVHandler 函数时,任务栈也就是PSP的内容有如下的变化
高地址
| | <--- 一开始SP 初始指向这里(例如 0x20001000)
| R0 | <--- 0x20000FFC
| R1 |
| R2 |
| R3 |
| R12 |
| LR |
| PC |
| xPSR | <--- 准备进入到中断函数前会自动将不封寄存器压栈。SP 现在指向这里(0x20000FE0)
低地址
也就是这些个寄存我们无需再保存,我们 只需要保存其他的几个 R4~R11和PSP就可以了,那么有小伙伴可能好奇了,为啥不直接也把R4R11也保存了呢,其实这个和ARM的AAPCS(Arm Architecture Procedure Calling Standard ,Arm架构程序调用标准) 有关r4r11属于被调用者保存 (Callee-saved)寄存器 ,相对于平常程序来说,ISR即使被调用者,要保存也是在ISR函数内部保存这些。
PendSV中断函数实现
如下是PendSV中断函数 xPortPendSVHandler 内容, 由内联汇编实现,需要注意的是内联汇编是由编译器处理的,所以不同的编译器,内联汇编的写法可能不同。下面的代码是gcc/clang的写法,armcc的写法有所不同。
void xPortPendSVHandler( void )
{
/* This is a naked function. */
__asm volatile
(
" mrs r0, psp \n" // 将进程堆栈指针(PSP)的值读取到r0寄存器
" isb \n" // 指令同步隔离,确保前面的指令执行完成
" \n"
" ldr r3, pxCurrentTCBConst \n" // 加载当前任务控制块(TCB)的地址到r3 r3=&pxCurrentTCB
" ldr r2, [r3] \n" // 加载当前TCB的值到r2 r2=pxCurrentTCB
" \n"
" stmdb r0!, {r4-r11} \n" // 将r4-r11寄存器的值保存到任务栈中,并更新栈指针
" str r0, [r2] \n" // 因为r2=pxCurrentTCB,所以[r2]就是pxCurrentTCB 指向地址的内容的第一个地址,所以r0=pxTopOfStack
" \n"
" stmdb sp!, {r3, r14} \n" // 将r3和lr压入msp,r3是当前任务的pxCurrentTCBConst地址,lr是返回地址,之所以保存这两个寄存器是因为在vTaskSwitchContext函数中需要用到,不保存就被覆盖了。
" mov r0, %0 \n" // 将configMAX_SYSCALL_INTERRUPT_PRIORITY加载到r0
" msr basepri, r0 \n" // 设置BASEPRI寄存器,configMAX_SYSCALL_INTERRUPT_PRIORITY,也就是屏蔽优先级低于等于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断。
" bl vTaskSwitchContext \n" // 调用任务切换函数,选择下一个要运行的任务,并保存到pxCurrentTCB中
" mov r0, #0 \n" // 将0加载到r0
" msr basepri, r0 \n" // 恢复BASEPRI设置为0,允许所有中断
" ldmia sp!, {r3, r14} \n" // 从主栈恢复r3和lr的值,也就是
" \n"
" ldr r1, [r3] \n" // 加载新的当前TCB的地址到r1 也就是r1=pxCurrentTCB
" ldr r0, [r1] \n" // 加载新任务的栈指针到r0 也就是r0=pxCurrentTCB->pxTopOfStack
" ldmia r0!, {r4-r11} \n" // 从pxTopOfStack也就是任务栈栈顶恢复r4-r11寄存器的值
" msr psp, r0 \n" // 更新PSP为新任务的栈指针
" isb \n" // 指令同步隔离,确保前面的指令执行完成
" bx r14 \n" // 返回,通过lr跳转回任务代码
" \n"
" .align 4 \n" // 4字节对齐
"pxCurrentTCBConst: .word pxCurrentTCB \n" // 相当于const uint32_t pxCurrentTCBConst = (uint32_t)&pxCurrentTCB;
::"i" ( configMAX_SYSCALL_INTERRUPT_PRIORITY ) // 将configMAX_SYSCALL_INTERRUPT_PRIORITY作为立即数传入
);
}
可以看到它做了以下几个动作
- 保存R4~R11到PSP栈中,将PSP保存个到当前任务的结构体的pxTopOfStack成员中。
- 保存当前任务 pxCurrentTCB的指针,以及LR寄存器的值到 MSP
- 屏蔽中断
- 找到并将最新任务,并将它赋值给pxCurrentTCB
- 解除中断屏蔽
- 从MSP恢复当前任务 pxCurrentTCB的指针,以及LR寄存器的值
- 从新任务的 pxTopOfStack 也就是任务栈栈顶恢复r4-r11寄存器的值,同时自减
- 将pxTopOfStack自减后的值赋值给PSP
- 通过 BX LR 退出新的寄存器,返回到新的任务
但是如果仔细阅读代码,还是有几点疑问,
1、如mov r0, %0 的%0啥意思,::"i" ( configMAX_SYSCALL_INTERRUPT_PRIORITY啥意思。这些其实都是内联汇编的语法,有兴趣可以查查内联汇编。
2、有一点值细说的是 bx r14,它关乎到一个概念,就是EXC_RETURN
EXC_RETURN
如果仔细看,可能细心的小伙伴会发现这样一个问题,xPortPendSVHandler 中R14也就是LR寄存器一直保存着跳到中断时保存的值,返回时还是用这个值,那岂不是跳回了原先的任务的地址了? 其实不然,因为LR寄存器的作用在平时函数调用过程和进入到中断服务程序的过程使用是不一样的。
在平时函数调用时用LR来保存返回地址,所以,这个时候bx r14 确实是直接跳到r14所在地址,。但是在进入中断时,则不是这样,在进入中断时,它的值和PC等八个寄存器会保存到当前使用的栈中(MSP/PSP),LR寄存器会自动被设置为EXC_RETURN的值,这值本质上相当保存了跳转到中断服务前的工作模式(handler/thread)以及实际使用的栈寄存器(MSP/PSP)的信息。它的值通常如下,如果你查看stm32的 内核头文件,如core_cm3.h 就会看到类似下面的定义,不同的值代表了跳转到中断服务前的状态,所以跳转后会根据这个值来恢复到对应的工作模式,并找到对应的栈。并从栈中弹出PC等八个寄存器。之后就从PC地址开始执行了。
* EXC_RETURN_HANDLER (0xFFFFFFF1UL) /* return to Handler mode, uses MSP after return*/
* EXC_RETURN_THREAD_MSP (0xFFFFFFF9UL) /* return to Thread mode, uses MSP after return*/
* EXC_RETURN_THREAD_PSP (0xFFFFFFFDUL) /* return to Thread mode, uses PSP after return*/
为什么 xPortPendSVHandler要是nake 函数
另外还有一点可能不少人遗漏的,就是可以看到xPortPendSVHandler被 __attribute__( ( naked ) ) 修饰,也就是它是个nake 函数
void xPortPendSVHandler( void ) __attribute__( ( naked ) );//nake 函数
nake(裸的),这裸的意思就是编译器不会自动生成函数前序(prologue)和后序(epilogue)代码。我们平时写代码,并不需要考虑压栈,弹栈,返回这些。因为这些是编译器处理的,这些就包含前序(prologue)和后序(epilogue)代码。这些代码会会修改我们的寄存器,很可能不是我们预期的,所以为了保证我们预期的结果,我们使用naked函数,自己手动管理这些寄存器。
举一个例子 在网站 https://godbolt.org/ 上可以实现做不同编译器,下面是我做的一个非常简单的代码,可以看到它在进入前会先分配栈空间,结束会做调转

代码汇编详细解释
add:
push {r11, lr} // 前序:将调用者的帧指针(r11)和返回地址(lr)压入堆栈,进行保存
mov r11, sp // 前序:设置当前函数的帧指针,让r11指向当前的栈顶
sub sp, sp, #12 // 前序:在堆栈上为局部变量分配12字节的栈空间
str r0, [r11, #-4] // 将参数a (在r0中) 存入堆栈,位置是r11-4
str r1, [sp, #4] // 将参数b (在r1中) 存入堆栈,位置是sp+4
ldr r0, [r11, #-4] // 从堆栈中加载参数a到r0寄存器
ldr r1, [sp, #4] // 从堆栈中加载参数b到r1寄存器
add r0, r0, r1 // 核心计算:执行 a + b,结果存放在r0
str r0, [sp] // 将结果c (在r0中) 存入堆栈的顶部
ldr r0, [sp] // 从堆栈顶部加载结果c到r0,准备作为返回值
mov sp, r11 // 后序:恢复栈指针到函数初始位置,释放局部变量空间
pop {r11, pc} // 后序:恢复调用者的帧指针(r11),并将返回地址弹入程序计数器(pc)以完成返回
很明显的它会修改我们的栈指针,我们当我们需要精细管理栈和寄存器时,就需要nake,
如下图是xPortPendSVHandler nake 和nake的反汇编对比,它多生了一个BX lr导致我们有两个 BX lr

所以,可以看到,要想写一个好的任务切换功能,其实需要知道的知识点要很多,需要知道进入中断时,硬件自动保存的寄存器,需要知道nake函数自己完全掌握栈和寄存器,需要知道内联汇编预防、EXC_RETURN机制,和AAPCS这些隐藏的点。