1、背景
本文章主要说明 rtthread 内核线程是如何切换的,初学者刚从裸机开发接触 RTOS 时难免会有些不适应,明白这部分原理之后就会对 RTOS 有更深的理解。在学习内核线程切换原理之前需要有以下基础知识铺垫。本文以 arm 公司的 Cortex-M3
内核为例。
2、基础知识
- CM3 拥有通用寄存器 R0-R15 以及一些特殊功能寄存器(中断屏蔽寄存器等等)
- R0-R12 都是通用寄存器,用来临时存储程序运行时产生的数据
- R13 这个寄存器存储堆栈指针,在 CM3 内核中一共有两个堆栈指针(MSP、PSP),于是 CM3 支持两个堆栈。在启动文件中定义的那个栈空间属于主栈,还有一个在我们创建线程时的栈属于线程栈。这两个栈空间不是同一个空间。
主堆栈指针(MSP),这是默认的堆栈指针,在裸机开发中只是用这一个指针,由 OS 内核、中断服务程序以及所有需要特权访问的应用程序代码使用。
进程堆栈指针(PSP),用于常规的应用程序代码,比如线程。
- R14 也叫做连接寄存器LR,在调用子程序时存储返回地址
- R15 也叫做程序计数器 (PC,program counter),因为 CM3 内部使用了指令流水线,PC 中存放的是当前指令的地址+4,也就是下一条指令的地址。
- 栈空间的定义 : 向下生长的栈。也就是说每次执行一个 push(压栈)命令,栈指针向下减小一个单元,每次执行pop命令,栈指针增加一个单元。如下图所示
3、代码分析
3.1 内核寄存器结构体定义
struct exception_stack_frame
{
rt_uint32_t r0;
rt_uint32_t r1;
rt_uint32_t r2;
rt_uint32_t r3;
rt_uint32_t r12;
rt_uint32_t lr;
rt_uint32_t pc;
rt_uint32_t psr;
};
struct stack_frame
{
/* r4 ~ r11 register */
rt_uint32_t r4;
rt_uint32_t r5;
rt_uint32_t r6;
rt_uint32_t r7;
rt_uint32_t r8;
rt_uint32_t r9;
rt_uint32_t r10;
rt_uint32_t r11;
struct exception_stack_frame exception_stack_frame;
};
struct exception_info
{
rt_uint32_t exc_return;
struct stack_frame stack_frame;
};
3.2 初始化线程栈
rt_uint8_t *rt_hw_stack_init(void *tentry, //线程函数入口地址
void *parameter,//线程函数参数
rt_uint8_t *stack_addr,//栈地址
void *texit)//线程退出时的函数地址
{
struct stack_frame *stack_frame;
rt_uint8_t *stk;
unsigned long i;
stk = stack_addr + sizeof(rt_uint32_t);//栈地址 + 4 个字节
stk = (rt_uint8_t *)RT_ALIGN_DOWN((rt_uint32_t)stk, 8);//向下8个字节对齐
stk -= sizeof(struct stack_frame);//偏移16个字(16*4个字节)
stack_frame = (struct stack_frame *)stk;//强制转换为 struct stack_frame 类型
/* init all register */
for (i = 0; i < sizeof(struct stack_frame) / sizeof(rt_uint32_t); i ++)
{
((rt_uint32_t *)stack_frame)[i] = 0xdeadbeef;//初始化这16个字的空间为 0xdeadbeef
}
/* 初始化高8个字的内存空间 */
stack_frame->exception_stack_frame.r0 = (unsigned long)parameter; /* r0 : argument */
stack_frame->exception_stack_frame.r1 = 0; /* r1 */
stack_frame->exception_stack_frame.r2 = 0; /* r2 */
stack_frame->exception_stack_frame.r3 = 0; /* r3 */
stack_frame->exception_stack_frame.r12 = 0; /* r12 */
stack_frame->exception_stack_frame.lr = (unsigned long)texit; /* lr */
stack_frame->exception_stack_frame.pc = (unsigned long)tentry; /* entry point, pc */
stack_frame->exception_stack_frame.psr = 0x01000000L; /* PSR */
#if USE_FPU
stack_frame->flag = 0;
#endif /* USE_FPU */
/* return task's current stack address */
return stk;
}
stack_addr
这个参数为当前线程栈的结束地址,也就是最高的地址。为什么是最高地址?原因是上面说过的栈空间的定义。struct stack_frame
这个结构体的定义可不是胡乱定义的,里面是有顺序要求的。stk -= sizeof(struct stack_frame);//偏移16个字(16*4个字节)
为何偏移这么多字节,因为这16个字的空间的每个地址要按照结构体成员变量的地址去存放,即 psr 要放到这个栈的最高地址,r4 在最低的地址。如图所示,此图出自野火。
3.3 执行线程切换
阅读这段代码之前得知道,cm3 内核执行中断或异常时,r0、r1、r2、r3、r12、lr、pc、psr,这些寄存器是自动压栈的。
rt_hw_context_switch PROC
EXPORT rt_hw_context_switch ;导出函数,此操作能够让C侧代码调用,C侧的第一个参数为当前线程栈sp的指针,第二个
;为将要执行的线程栈 sp 的指针
; set rt_thread_switch_interrupt_flag to 1
LDR r2, =rt_thread_switch_interrupt_flag;中断标志位 L2 = &rt_thread_switch_interrupt_flag
LDR r3, [r2];r3 = *r2也就是 r3 = rt_thread_switch_interrupt_flag
CMP r3, #1 ;判断rt_thread_switch_interrupt_flag 与 1是否相等
BEQ _reswitch ;相等跳转 _reswitch,当第2次执行线程切换时,rt_thread_switch_interrupt_flag被pendsv置0
;既然是第二次,所以当前线程具有上文所以要把sp存到rt_interrupt_from_thread,直接跳转_reswitch
;表示的是第一次切换线程,因为没有上文,所以直接跳到 _reswitch
MOV r3, #1 ;不等则置1
STR r3, [r2] ;rt_thread_switch_interrupt_flag = 1
LDR r2, =rt_interrupt_from_thread ; set rt_interrupt_from_thread
STR r0, [r2] ;rt_interrupt_from_thread = r0,&sp,当前线程sp的地址
_reswitch
LDR r2, =rt_interrupt_to_thread ; set rt_interrupt_to_thread
STR r1, [r2] ;rt_interrupt_to_thread = r1,&sp,将要只要的线程的sp的地址
;触发 pendsv 中断,线程切换的核心
LDR r0, =NVIC_INT_CTRL ; trigger the PendSV exception (causes context switch)
LDR r1, =NVIC_PENDSVSET
STR r1, [r0]
BX LR
ENDP
; r0 --> switch from thread stack
; r1 --> switch to thread stack
; psr, pc, lr, r12, r3, r2, r1, r0 are pushed into [from] stack
PendSV_Handler PROC
EXPORT PendSV_Handler
; 关闭所有中断以保护这一过程不被打断
MRS r2, PRIMASK
CPSID I
; rt_thread_switch_interrupt_flag 为 1时才继续接下来的操作,为0则跳转 pendsv_exit
LDR r0, =rt_thread_switch_interrupt_flag
LDR r1, [r0]
CBZ r1, pendsv_exit ; pendsv already handled
; 清楚中断标志位
MOV r1, #0x00
STR r1, [r0]
;判断 rt_interrupt_from_thread 是否为0,即是否是第一次切换线程,是0则跳转至switch_to_thread
LDR r0, =rt_interrupt_from_thread
LDR r1, [r0]
CBZ r1, switch_to_thread ; skip register save at the first time
MRS r1, psp ; 获取当前线程栈指针到r1中
STMFD r1!, {r4 - r11} ; 将r4 - r11寄存器中的值压入当前栈空间中
LDR r0, [r0]
STR r1, [r0] ; 把当前线程栈指针记录到 rt_interrupt_from_thread 中,即当前栈指针 sp 中
switch_to_thread
LDR r1, =rt_interrupt_to_thread;获取将要执行的栈的sp的地址
LDR r1, [r1]
LDR r1, [r1]
LDMFD r1!, {r4 - r11} ; 从将要执行的栈中弹出这个线程中的寄存器r4-r11
MSR psp, r1 ; 并把要执行的线程的栈指针给到 psp
pendsv_exit
; 恢复中断
MSR PRIMASK, r2
;由于cm3 内核发生中断时,堆栈指针使用的是msp,因此退出中断时,确保使用psp指针,实际操作就是对,lr寄存的位3进行置1就控制 ; 退出中断后使用psp中断
ORR lr, lr, #0x04
BX lr ;退出中断时使用psp指针
ENDP
- 通过解读 pendsv 中断代码我们知道,在进入 pendsv 中断前,r0、r1、r2、r3、r12、lr、pc、psr 这些寄存器已经自动压入了当前栈中。
- 当 pendsv 中断退出时,新的将要执行的线程的中断上下文(r0、r1、r2、r3、r12、lr、pc、ps)会自动的从这个线程栈中弹出,程序计数器 PC 就得到了这个将要执行的线程的pc值,这个线程中用到的其他寄存器的值也从这个新的线程栈中得到了(一部分手动pop,一部分自动pop)。
问题点一:我可以通过这个线程栈指针访问到R0~R15的值吗?
- 答案是肯定的,因为我们传入的 sp 地址就指向了线程栈地址的偏移16个字处,而内核压栈时,先自动压入 r0、r1、r2、r3、r12、lr、pc、psr 这8个字的空间,按照顺序压,先压psr,然后我们手动压 r4 - r11 ,也是按照顺序压,先压r11。此时这16个字的空间就被填满了,这也是为什么线程栈结构体中的成员变量的顺序不是随便填的(个人理解)。
问题点二:当我进入hard_fault 异常时,我能否获取到当前线程栈指针,从而拿到 pc 指针来判断程序出错的位置?
- 答案是可以的,rt-thread 已经帮我们重写了 hard_fault 服务程序