0 前言
RTOS中最重要的一个概念就是线程,线程的按需切换能够满足RTOS的实时性要求,同时能将复杂的需求分解成一个个线程执行减轻我们开发负担。
本文从栈的角度出发,详细介绍RTOS线程切换的过程和原理。
注:本文参考的RTOS是RT-Tthread。
1 初始化线程
对于裸机来说,我们大可不必关心栈的内容。对于RTOS来说,每个线程都有自己独立的栈区,用来保存R0-R15寄存器、形参、局部变量等内容,在正式开始线程调度前需要初始化线程栈。
初始化线程栈的操作实际上就是将栈空间内的数据赋一些初值,初始化完成后的栈空间内容如下:
上述操作完成后,会将栈顶的值赋给线程控制块的*SP(线程堆栈指针)。可以很容易发现,假设栈底地址为BSADDR,则SP=BSADDR-64。这里要注意,如果线程函数有2个形参,则第一个形参传入R0、第二个形参传入R1(形参的第一、第二顺序为从左往右)。
至于为什么线程栈要这么分布,这里有一个相关知识点:
我们切换线程前都会触发PendSV异常,然后CPU会按照下图规则根据PSP(进程堆栈指针的值)将xPSR, PC, LR, R12以及R3-R0保存进线程栈(入栈),出栈时操作相反。假设PSP的值是N,则入栈的操作如下:
其实初始化线程栈就像构造了一个虚假的现场,然后让CPU去恢复它。
2 第一次切换线程
RTOS第一次切换线程的时候会从就绪链表中挑选出优先级最高的线程执行,由于是第一个执行的线程因此不需要保存上文,只需要切换下文即可。第一次切换线程可以分为2个部分展开,首先是开启第一次线程切换,然后是在PendSV异常服务函数内进行下文切换。
2.1 开启第一次线程切换
以RT-Thread为例,开启第一次线程切换函数如下:
rt_hw_context_switch_to PROC; 导出rt_hw_context_switch_to,让其具有全局属性,可以在C文件调用EXPORT rt_hw_context_switch_to; 设置rt_interrupt_to_thread的值LDR r1, =rt_interrupt_to_thread ;将rt_interrupt_to_thread的地址加载到r1STR r0, [r1] ;将r0的值存储到rt_interrupt_to_thread; 设置rt_interrupt_from_thread的值为0,表示启动第一次线程切换LDR r1, =rt_interrupt_from_thread ;将rt_interrupt_from_thread的地址加载到r1MOV r0, #0x0 ;配置r0等于0STR r0, [r1] ;将r0的值存储到rt_interrupt_from_thread; 设置中断标志位rt_thread_switch_interrupt_flag的值为1LDR r1, =rt_thread_switch_interrupt_flag ;将rt_thread_switch_interrupt_flag的地址加载到r1MOV r0, #1 ;配置r0等于1STR r0, [r1] ;将r0的值存储到rt_thread_switch_interrupt_flag; 设置 PendSV 异常的优先级LDR r0, =NVIC_SYSPRI2LDR r1, =NVIC_PENDSV_PRILDR.W r2, [r0,#0x00] ; 读ORR r1,r1,r2 ; 改STR r1, [r0] ; 写; 触发 PendSV 异常 (产生上下文切换)LDR r0, =NVIC_INT_CTRLLDR r1, =NVIC_PENDSVSETSTR r1, [r0]; 开中断CPSIE FCPSIE I; 永远不会到达这里ENDP
该函数的操作流程如下:
(1)设置rt_interrupt_to_thread的值为第一个执行线程的线程控制块SP的值。
(2)设置rt_interrupt_from_thread的值为0,表明这是第一次线程切换,不需要保存上文。
(3)设置rt_thread_switch_interrupt_flag值为1,告知上下文切换服务函数这是一个有效的切换线程请求。
(4)设置PendSV的异常优先级为最低(避免打断其它中断),触发PendSV异常,开全局中断。
2.2 上下文切换
上下文切换的异常服务函数是用汇编写的,以RT-Thread为例,其实现上下文切换的函数如下:
PendSV_Handler PROCEXPORT PendSV_Handler; 失能中断,为了保护上下文切换不被中断MRS r2, PRIMASKCPSID I; 获取中断标志位,看看是否为0LDR r0, =rt_thread_switch_interrupt_flag ; 加载rt_thread_switch_interrupt_flag的地址到r0LDR r1, [r0] ; 加载rt_thread_switch_interrupt_flag的值到r1CBZ r1, pendsv_exit ; 判断r1是否为0,为0则跳转到pendsv_exit; r1不为0则清0MOV r1, #0x00STR r1, [r0] ; 将r1的值存储到rt_thread_switch_interrupt_flag,即清0; 判断rt_interrupt_from_thread的值是否为0LDR r0, =rt_interrupt_from_thread ; 加载rt_interrupt_from_thread的地址到r0LDR r1, [r0] ; 加载rt_interrupt_from_thread的值到r1CBZ r1, switch_to_thread ; 判断r1是否为0,为0则跳转到switch_to_thread; 第一次线程切换时rt_interrupt_from_thread肯定为0,则跳转到switch_to_thread; ========================== 上文保存 ==============================; 当进入PendSVC Handler时,上一个线程运行的环境即:; xPSR,PC(线程入口地址),R14,R12,R3,R2,R1,R0(线程的形参); 这些CPU寄存器的值会自动保存到线程的栈中,剩下的r4~r11需要手动保存MRS r1, psp ; 获取线程栈指针到r1STMFD r1!, {r4 - r11} ;将CPU寄存器r4~r11的值存储到r1指向的地址(每操作一次地址将递增一次)LDR r0, [r0] ; 加载r0指向值到r0,即r0=rt_interrupt_from_threadSTR r1, [r0] ; 将r1的值存储到r0,即更新线程栈sp; ========================== 下文切换 ==============================
switch_to_threadLDR r1, =rt_interrupt_to_thread ; 加载rt_interrupt_to_thread的地址到r1; rt_interrupt_to_thread是一个全局变量,里面存的是线程栈指针SP的指针LDR r1, [r1] ; 加载rt_interrupt_to_thread的值到r1,即sp指针的指针LDR r1, [r1] ; 加载rt_interrupt_to_thread的值到r1,即spLDMFD r1!, {r4 - r11} ;将线程栈指针r1(操作之前先递减)指向的内容加载到CPU寄存器r4~r11MSR psp, r1 ;将线程栈指针更新到PSPpendsv_exit; 恢复中断MSR PRIMASK, r2ORR lr, lr, #0x04 ; 确保异常返回使用的堆栈指针是PSP,即LR寄存器的位2要为1BX lr ; 异常返回,这个时候任务堆栈中的剩下内容将会自动加载到xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参); 同时PSP的值也将更新,即指向任务堆栈的栈顶。在ARMC3中,堆是由高地址向低地址生长的。; PendSV_Handler 子程序结束ENDP ALIGN 4END
该函数的操作流程如下:
(1)失能全局中断,避免切换上下文过程被打断。
(2)获取中断标志位,查看此次异常是否是由线程切换函数发起。
(3)检查rt_interrupt_from_thread 的值。如果是0则无需进行上文保存直接去切换下文;如果非0则先去保存上文再去切换下文。由于是第一次切换线程,这里rt_interrupt_from_thread 的值为0,直接去切换下文。
(4)通过2次指针操作获取前面初始化线程栈的SP的值,也就是BSADDR-64:
(5)将保存在线程栈的R4-R11的数据加载到CPU对应的R4-R11寄存器。同时R1的值设置为SP+32=BSADDR-32,最后将R1的值更新到PSP。相关语句如下:
LDMFD指令功能是弹出栈中的多个数据,采用事后递增方式,先弹出数据,再将SP指针增大。
(6)在上下文切换完成后,恢复中断。
(7)确保异常返回使用的堆栈指针是PSP,也就是要保证LR寄存器的bit2为1:
(8)最后异常返回,这时CPU会自动进行出栈操作,也就是将xPSR, PC, LR, R12以及R3-R0出栈,此时PSP指针的值为SP+32-32=BASDDR。
3 线程切换
3.1 产生上下文切换
在有多个线程运行的情况下,就会有线程的切换操作。在RT-Thread中用于产生上下文切换的函数如下:
rt_hw_context_switch PROCEXPORT rt_hw_context_switch; 设置中断标志位rt_thread_switch_interrupt_flag为1 LDR r2, =rt_thread_switch_interrupt_flag ; 加载rt_thread_switch_interrupt_flag的地址到r2LDR r3, [r2] ; 加载rt_thread_switch_interrupt_flag的值到r3CMP r3, #1 ; r3与1比较,相等则执行BEQ指令,否则不执行BEQ _reswitchMOV r3, #1 ; 设置r3的值为1STR r3, [r2] ; 将r3的值存储到rt_thread_switch_interrupt_flag,即置1; 设置rt_interrupt_from_thread的值LDR r2, =rt_interrupt_from_thread ; 加载rt_interrupt_from_thread的地址到r2STR r0, [r2] ; 存储r0的值到rt_interrupt_from_thread,即上一个线程栈指针sp的指针_reswitch; 设置rt_interrupt_to_thread的值LDR r2, =rt_interrupt_to_thread ; 加载rt_interrupt_from_thread的地址到r2STR r1, [r2] ; 存储r1的值到rt_interrupt_from_thread,即下一个线程栈指针sp的指针; 触发PendSV异常,实现上下文切换LDR r0, =NVIC_INT_CTRL LDR r1, =NVIC_PENDSVSETSTR r1, [r0]; 子程序返回BX LR; 子程序结束ENDP
该函数的操作流程如下:
(1)设置rt_interrupt_from_thread的值为1,相关语句如下:
(2)保存上一个线程栈的SP指针到rt_interrupt_from_thread,相关语句如下:
(3)保存需要切换的下一个线程的SP指针到rt_interrupt_to_thread,相关语句如下:
(4)触发PendSV异常,进行上下文切换,相关语句如下:
3.2 进行上下文切换
以RT-Thread为例,其实现上下文切换的函数如下:
PendSV_Handler PROCEXPORT PendSV_Handler; 失能中断,为了保护上下文切换不被中断MRS r2, PRIMASKCPSID I; 获取中断标志位,看看是否为0LDR r0, =rt_thread_switch_interrupt_flag ; 加载rt_thread_switch_interrupt_flag的地址到r0LDR r1, [r0] ; 加载rt_thread_switch_interrupt_flag的值到r1CBZ r1, pendsv_exit ; 判断r1是否为0,为0则跳转到pendsv_exit; r1不为0则清0MOV r1, #0x00STR r1, [r0] ; 将r1的值存储到rt_thread_switch_interrupt_flag,即清0; 判断rt_interrupt_from_thread的值是否为0LDR r0, =rt_interrupt_from_thread ; 加载rt_interrupt_from_thread的地址到r0LDR r1, [r0] ; 加载rt_interrupt_from_thread的值到r1CBZ r1, switch_to_thread ; 判断r1是否为0,为0则跳转到switch_to_thread; 第一次线程切换时rt_interrupt_from_thread肯定为0,则跳转到switch_to_thread; ========================== 上文保存 ==============================; 当进入PendSVC Handler时,上一个线程运行的环境即:; xPSR,PC(线程入口地址),R14,R12,R3,R2,R1,R0(线程的形参); 这些CPU寄存器的值会自动保存到线程的栈中,剩下的r4~r11需要手动保存MRS r1, psp ; 获取线程栈指针到r1STMFD r1!, {r4 - r11} ;将CPU寄存器r4~r11的值存储到r1指向的地址(每操作一次地址将递增一次)LDR r0, [r0] ; 加载r0指向值到r0,即r0=rt_interrupt_from_threadSTR r1, [r0] ; 将r1的值存储到r0,即更新线程栈sp; ========================== 下文切换 ==============================
switch_to_threadLDR r1, =rt_interrupt_to_thread ; 加载rt_interrupt_to_thread的地址到r1; rt_interrupt_to_thread是一个全局变量,里面存的是线程栈指针SP的指针LDR r1, [r1] ; 加载rt_interrupt_to_thread的值到r1,即sp指针的指针LDR r1, [r1] ; 加载rt_interrupt_to_thread的值到r1,即spLDMFD r1!, {r4 - r11} ;将线程栈指针r1(操作之前先递减)指向的内容加载到CPU寄存器r4~r11MSR psp, r1 ;将线程栈指针更新到PSPpendsv_exit; 恢复中断MSR PRIMASK, r2ORR lr, lr, #0x04 ; 确保异常返回使用的堆栈指针是PSP,即LR寄存器的位2要为1BX lr ; 异常返回,这个时候任务堆栈中的剩下内容将会自动加载到xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参); 同时PSP的值也将更新,即指向任务堆栈的栈顶。在ARMC3中,堆是由高地址向低地址生长的。; PendSV_Handler 子程序结束ENDP ALIGN 4END
该函数的操作流程如下:
(1)失能全局中断,避免切换上下文过程被打断。
(2)获取中断标志位,查看此次异常是否是由线程切换函数发起。
(3)检查rt_interrupt_from_thread 的值。如果是0则无需进行上文保存直接去切换下文;如果非0则先去保存上文再去切换下文。
上文保存:
(4)将上一个线程的PSP到R1(这里要注意,不是直接拿保存在线程控制块栈指针),由于CPU已经自动将xPSR, PC, LR, R12以及R3-R0入栈,我们只需要手动把CPU寄存器R4-R11的数据保存到线程栈内即可完成上文的保存,最后将更新后的栈指针赋给线程控制块的SP。相关语句如下:
STMFD指令是向栈内压入多个数据,采用事先递减的方式。
下文切换:
(5)通过2次指针操作获取下一个需要运行线程的线程控制块保存的SP的值:
(5)将保存在线程栈的R4-R11的数据加载到CPU对应的R4-R11寄存器。同时R1的值设置为SP+32,最后将R1的值更新到PSP。相关语句如下:
LDMFD指令功能是弹出栈中的多个数据,采用事后递增方式。
(6)在上下文切换完成后,恢复中断。
(7)确保异常返回使用的堆栈指针是PSP,也就是要保证LR寄存器的bit2为1:
(8)最后异常返回,这时CPU会自动进行出栈操作,也就是将xPSR, PC, LR, R12以及R3-R0出栈,此时PSP=SP+64。