STM32 RT-Thread 系统分析(3)-线程管理之线程切换(系统移植基础篇二)

    科技2025-07-29  20

    线程管理之线程切换

    前言基本信息前言说明 rt_hw_context_switch_to 函数关键代码分析还原MSP值复位序列LDR r0, =SCB_VTORLDR r0, [r0]startup_stm32f103xg.S文件内容一段截取如下:startup_stm32f103xg.S文件关于 .isr_vector内容map 文件关于.isr_vector内容map 文件关于_estack内容:总结 知识点LDR 伪指令 vs. ADR 伪指令16 位存储器数据传送指令 rt_hw_context_switch函数和rt_hw_context_switch_interrupt函数知识点简单ATPCS寄存器的使用规则:实现上下文切换详细介绍:线程之间的上下文切换,如下图表示:中断到线程的上下文切换可以用下图表示: 总结

    前言

    基本信息

    名称描述说明RT-Thread Studio 软件版本版本: 1.1.3RT-Thread 系统版本4.0.2STM32CubeIDE 软件版本1.4.0STM32芯片型号STM32F013VG

    前言说明

    接着面的文章内容: RT-Thread 系统分析(3)-线程管理之线程切换(系统移植基础篇一).的内容,接下来我们继续分析剩余的函数。

    函数和变量描述rt_base_t rt_hw_interrupt_disable(void);关闭全局中断void rt_hw_interrupt_enable(rt_base_t level);打开全局中断rt_uint8_t *rt_hw_stack_init(void *tentry, void *parameter, rt_uint8_t *stack_addr, void *texit);线程栈的初始化,内核在线程创建和线程初始化里面会调用这个函数void rt_hw_context_switch_to(rt_uint32 to);没有来源线程的上下文切换,在调度器启动第一个线程的时候调用,以及在 signal 里面会调用void rt_hw_context_switch(rt_uint32 from, rt_uint32 to);从 from 线程切换到 to 线程,用于线程和线程之间的切换void rt_hw_context_switch_interrupt(rt_uint32 from, rt_uint32 to);从 from 线程切换到 to 线程,用于中断里面进行切换的时候使用rt_uint32_t rt_thread_switch_interrupt_flag;表示需要在中断里进行切换的标志rt_uint32_t rt_interrupt_from_thread, rt_interrupt_to_thread;在线程进行上下文切换时候,用来保存 from 和 to 线程PendSV_Handler函数(在下一篇内容中)在 Cortex-M3 里,PendSV 中断处理函数是 PendSV_Handler()。在 PendSV_Handler() 里完成线程切换的实际工作

    rt_hw_context_switch_to 函数

    没有来源线程的上下文切换,在调度器启动第一个线程的时候调用,以及在 signal 里面会调用。

    /* * void rt_hw_context_switch_to(rt_uint32 to); * R0 --> to */ .global rt_hw_context_switch_to /*声明一个全局可调用变量*/ .type rt_hw_context_switch_to, %function/*将 rt_hw_context_switch_to 设置为函数类型*/ rt_hw_context_switch_to: LDR R1, =rt_interrupt_to_thread /*r1 的值是一个指针,该指针指向 rt_interrupt_to_thread 的 SP 成员*/ STR R0, [R1] /*将 r0 寄存器的值保存到 rt_interrupt_to_thread 变量里*/ /* set from thread to 0 设置 from 线程为空,表示不需要从保存 from 的上下文*/ LDR R1, =rt_interrupt_from_thread/*r1 的值是一个指针,该指针指rt_interrupt_from_thread 的 SP 成员*/ MOV R0, #0 /*将 立即数0 赋值给r0寄存器*/ STR R0, [R1] /*将 r0 寄存器的值保存到 rt_interrupt_from_thread 变量里*/ /* set interrupt flag to 1 设置标志为 1,表示需要切换,这个变量将在 PendSV 异常处理函数里切换的时被清零*/ LDR R1, =rt_thread_switch_interrupt_flag MOV R0, #1 /*将 立即数1 赋值给r0寄存器*/ STR R0, [R1] /*将 r0 寄存器的值保存到 rt_thread_switch_interrupt_flag 变量里*/ /* set the PendSV exception priority */ LDR R0, =SHPR3 /*加载SHPR3(系统优先寄存器)地址到R0*/ LDR R1, =PENDSV_PRI_LOWEST /*从PENDSV_PRI_LOWEST(PendSV优先级值(最低))地址处加载值到R1*/ LDR.W R2, [R0,#0] /* read 将R0寄存器处的地址偏移0个字节处的值加载到R2寄存器*/ ORR R1, R1, R2 /* modify R1值=R1值|R2值*/ STR R1, [R0] /* write-back 将R0的值写入SHPR3寄存器中 */ /*触发PendSV异常(导致上下文切换) trigger the PendSV exception (causes context switch) */ LDR R0, =ICSR /*将ICSR寄存器地址载入R0寄存器*/ LDR R1, =PENDSVSET_BIT /*PENDSVSET_BIT值为0x10000000 用于触发PendSV异常的值*/ STR R1, [R0] /*将PENDSVSET_BIT值写入到R0指向的ICSR寄存器处*/ /* restore MSP 还原MSP */ LDR r0, =SCB_VTOR /* 加载 SCB_VTOR(向量表偏移寄存器 )地址到r0*/ LDR r0, [r0] /*获取向量表的首地址值*/ LDR r0, [r0] /*获取MSP初始值到r0*/ NOP MSR msp, r0 /*写入 R0 的值到主堆栈中*/ /* enable interrupts at processor level 在处理器级别启用中断*/ CPSIE F /*开 异常*/ CPSIE I /*开 中断*/ /* never reach here! */

    说明:伪指令的使用请看下面知识点讲解,其中设置PendSV异常优先级,触发PendSV中断是一种固定的用法,即将固定的值写入ICSR寄存器即可触发PendSV异常,此时可以产生异常中断,CPU会自动进行压栈,此时可以进行上下文切换。

    关键代码分析

    还原MSP值

    /* restore MSP 还原MSP */ LDR r0, =SCB_VTOR /* 加载 SCB_VTOR(向量表偏移寄存器 )地址到r0*/ LDR r0, [r0] /*获取向量表的首地址值*/ LDR r0, [r0] /*获取MSP初始值到r0*/ NOP MSR msp, r0 /*写入 R0 的值到主堆栈中*/

    这一段的代码比较绕,蕴含的知识点比较复杂琐碎,下面开始专门讲解分析。

    复位序列

    在离开复位状态后, CM3 做的第一件事就是读取下列两个 32 位整数的值: z 从地址 0x0000,0000 处取出 MSP 的初始值。 z 从地址 0x0000,0004 处取出 PC 的初始值——这个值是复位向量, LSB 必须是 1。然后从这个值所对应的地址处取指。 图 3.17 复位序列 请注意,这与传统的 ARM 架构不同——其实也和绝大多数的其它单片机不同。传统的 ARM 架构总是从 0 地址开始执行第一条指令。它们的 0 地址处总是一条跳转指令。在 CM3 中, 0 地址处提供 MSP 的初始值,然后就是向量表(向量表在以后还可以被移至其它位置)。 向量表中的数值是 32 位的地址,而不是跳转指令。向量表的第一个条目指向复位后应执行 的第一条指令。 因为 CM3 使用的是向下生长的满栈,所以 MSP 的初始值必须是堆栈内存的末地址加 1。 举例来说,如果你的堆栈区域在 0x20007C00‐0x20007FFF 之间,那么 MSP 的初始值就必须是0x20008000。向量表跟随在 MSP 的初始值之后——也就是第 2 个表目。要注意因为 CM3 是在 Thumb态下执行,所以向量表中的每个数值都必须把 LSB 置 1(也就是奇数)。正是因为这个原因, 图 中使用 0x101 来表达地址 0x100。当 0x100 处的指令得到执行后,就正式开始了程序的执行。在此之前初始化 MSP 是必需的,因为可能第 1 条指令还没执行就会被 NMI 或是其它 fault 打断。 MSP 初始化好后就已经为它们的服务例程准备好了堆栈。

    LDR r0, =SCB_VTOR

    SCB_VTOR 的值在context_gcc.S文件内容的开头有定义:

    .equ SCB_VTOR, 0xE000ED08 /* Vector Table Offset Register 向量表偏移寄存器*/

    这是向量表偏移寄存器的地址,因此LDR r0, =SCB_VTOR 这段代码表示:加载SCB_VTOR 寄存器的地址到r0寄存器

    LDR r0, [r0]

    这段表示:加载指向SCB_VTOR 寄存器的地址处的值到r0寄存器,这个值是多少呢?是中断向量偏移地址的值。接下来我们想中断向量偏移地址的值是多少呢?请看:

    startup_stm32f103xg.S文件内容一段截取如下:

    这是定义了一个对象,名字是:g_pfnVectors ,而这个对象的第一个元素是_estack,这个_estack的值就是MSP (主堆栈指针)的初始值。原因是:

    因为 CM3 使用的是向下生长的满栈,所以 MSP 的初始值必须是堆栈内存的末地址加 1。举例来说,如果你的堆栈区域在 0x20007C00‐0x20007FFF 之间,那么 MSP 的初始值就必须是 0x20008000。

    startup_stm32f103xg.S文件关于 .isr_vector内容
    SECTIONS /*输出段SECTIONS用于将所有.o文件输出为统一的文件*/ { .text : /*可执行代码部分*/ { . = ALIGN(4); /*指示编译器将接下来的代码进行4字节对齐编译,也就是在分配地址时,以4的整数倍分配。*/ _stext = .; /*_stext 地址被定义为 4字节对齐后的地址计数器的值*/ KEEP(*(.isr_vector)) /* Startup code 启动码,所有isr_vector的section都连接到这个地址*/ 后面省略,具体可以查看文件内容,或者前面关于启动文件内容的讲解

    这段代码表示:代码段(text段)4字节对齐后所加载的第一个对象是.isr_vector,而.isr_vector的内容是栈顶地址值和中断向量表。

    map 文件关于.isr_vector内容
    .text 0x08000000 0xc3f8 0x08000000 . = ALIGN (0x4) 0x08000000 _stext = . *(.isr_vector) .isr_vector 0x08000000 0x1e4 ./libraries/CMSIS/Device/ST/STM32F1xx/Source/Templates/gcc/startup_stm32f103xg.o 0x08000000 g_pfnVectors 0x080001e4 . = ALIGN (0x4)

    从这里map映射文件可以验证前面的说法, .isr_vector是代码段第一个加载的对象,起始地址是0x08000000,而0x08000000地址指向的值第一个值就是MSP主堆栈指针值,就是_estack代表的值,也是MSP主堆栈指针初始值。

    map 文件关于_estack内容:
    .stack 0x20000598 0x400 load address 0x0800c998 0x20000598 . = ALIGN (0x4) 0x20000598 _sstack = . 0x20000998 . = (. + _system_stack_size) *fill* 0x20000598 0x400 0x20000998 . = ALIGN (0x4) 0x20000998 _estack = . 0x20000998 __bss_start = .

    可以看到map文件_estack的值为:0x20000998,那么是不是确实是栈顶地址值呢?从下面的程序编译出的**.bin文件**的内容可以看到,bin文件的开始前4个字节就是:0x20000998

    总结

    因此第一次的 LDR r0, [r0] 表示:加载中断向量表偏移地址的值 第二次 LDR r0, [r0] 表示: 加载 中断向量表偏移地址的值指向第一个条目的值,即栈顶地址值,也是MSP主堆栈指针初始值。

    从上面的分析和描述中我们可以了解以下结论:

    CM3 使用的是向下生长的满栈,所以 MSP 的初始值必须是堆栈内存的末地址加 1。这也是上面反复强调的关键点。 向量表跟随在 MSP 的初始值之后

    从加载的顺序可以看出CPU必须要先加载MSP的值才能够正常的执行Reset_Handler函数。

    知识点

    LDR 伪指令 vs. ADR 伪指令

    Both LDR 和 ADR 都有能力产生一个地址,但是语法和行为不同。对于 LDR,如果汇编器发现要产生立即数是一个程序地址,它会自动地把 LSB 置位,例如:

    LDR r0, =address1 ; R0= 0x4000 | 1 … address1 0x4000: MOV R0, R1

    在这个例子中,汇编器会认出 address1 是一个程序地址,所以自动置位 LSB。另一方面,如果汇编器发现要加载的是数据地址,则不会自作聪明,多机灵啊!看:

    LDR R0, =address1 ; R0= 0x4000 … address1 0x4000: DCD 0x0 ;0x4000 处记录的是一个数据

    ADR 指令则是“厚道人”,它决不会修改 LSB。例如:

    ADR r0, address1 ; R0= 0x4000。注意: 没有“=”号 … address1 0x4000: MOV R0, R1

    ADR 将如实地加载 0x4000。注意,语法略有不同,没有“=”号。 前面已经提到, LDR 通常是把要加载的数值预先定义,再使用一条 PC 相对加载指令来取出。而 ADR 则尝试对 PC 作算术加法或减法来取得立即数。因此 ADR 未必总能求出需要的立即数。其实顾名思义, ADR 是为了取出附近某条指令或者变量的地址,而 LDR 则是取出一个通用的 32 位整数。因为 ADR 更专一,所以得到了优化,故而它的代码效率常常比 LDR的要高。

    16 位存储器数据传送指令

    名字功能LDR从存储器中加载字到一个寄存器中LDRH从存储器中加载半字到一个寄存器中LDRB从存储器中加载字节到一个寄存器中LDRSH从存储器中加载半字,再经过带符号扩展后存储一个寄存器中LDRSB从存储器中加载字节,再经过带符号扩展后存储一个寄存器中STR把一个寄存器按字存储到存储器中STRH把一个寄存器存器的低半字存储到存储器中STRB把一个寄存器的低字节存储到存储器中LDMIA加载多个字,并且在加载后自增基址寄存器STMIA加载多个字,并且在加载后自增基址寄存器PUSH压入多个寄存器到栈中POP从栈中弹出多个值到寄存器中

    用于访问存储器的基础指令是“加载(Load)”和“存储(Store)”。加载指令 LDR 把存储器中的内容加载到寄存器中,存储指令 STR 则把寄存器的内容存储至存储器中,传送过程中数据类型也可以变通,最常使用的格式有: 表 4.14 常用的存储器访问指令

    示例功能描述LDRB Rd, [Rn, #offset]从地址 Rn+offset 处读取一个字节到 RdLDRH Rd, [Rn, #offset]从地址 Rn+offset 处读取一个半字到 RdLDR Rd, [Rn, #offset]从地址 Rn+offset 处读取一个字到 RdLDRD Rd1, Rd2, [Rn, #offset]从地址 Rn+offset 处读取一个双字(64 位整数)到 Rd1(低32 位)和 Rd2(高 32 位)中。STRB Rd, [Rn, #offset]把 Rd 中的低字节存储到地址 Rn+offset 处STRH Rd, [Rn, #offset]把 Rd 中的低半字存储到地址 Rn+offset 处STR Rd, [Rn, #offset]把 Rd 中的低字存储到地址 Rn+offset 处LDRD Rd1, Rd2, [Rn, #offset]把 Rd1(低 32 位)和 Rd2(高 32 位)表达的双字存储

    rt_hw_context_switch函数和rt_hw_context_switch_interrupt函数

    函数 rt_hw_context_switch() 和函数 rt_hw_context_switch_interrupt() 都有两个参数,分别是 from 线程和 to 线程。它们实现从 from 线程切换到 to 线程的功能。下图是具体的流程图: 函数源代码:

    /* * void rt_hw_context_switch(rt_uint32 from, rt_uint32 to); * R0 --> from * R1 --> to */ .global rt_hw_context_switch_interrupt /*定义全局可调用变量 rt_hw_context_switch_interrupt*/ .type rt_hw_context_switch_interrupt, %function /*将 rt_hw_context_switch_interrupt 设置为函数类型*/ .global rt_hw_context_switch /*定义全局可调用变量 rt_hw_context_switch*/ .type rt_hw_context_switch, %function /*将 rt_hw_context_switch 设置为函数类型*/ rt_hw_context_switch_interrupt: rt_hw_context_switch: /* 检查 rt_thread_switch_interrupt_flag 变量是否为 1 set rt_thread_switch_interrupt_flag to 1 */ LDR R2, =rt_thread_switch_interrupt_flag LDR R3, [R2] /*将 rt_thread_switch_interrupt_flag地址处的值加载到R3中*/ CMP R3, #1 /*如果变量为 1 就跳过更新 from 线程的内容*/ BEQ _reswitch /*当R3寄存器值为1时执行_reswitch*/ MOV R3, #1 /*R3值不为1执行,此时将R3寄存器值设置为1*/ STR R3, [R2] /*设置 rt_thread_switch_interrupt_flag 变量为 1*/ LDR R2, =rt_interrupt_from_thread /* set rt_interrupt_from_thread */ STR R0, [R2] /*将R0的值赋值给 rt_interrupt_from_thread*/ _reswitch: /*R3值为1 执行*/ LDR R2, =rt_interrupt_to_thread /*从参数 r1 里更新 rt_interrupt_to_thread 变量 set rt_interrupt_to_thread */ STR R1, [R2] /*将r1的值写入到rt_interrupt_to_thread对应的地址 */ LDR R0, =ICSR /*触发 PendSV 异常,将进入 PendSV 异常处理函数里完成上下文切换 trigger the PendSV exception (causes context switch) */ LDR R1, =PENDSVSET_BIT STR R1, [R0] BX LR

    说明:

    函数的输入参数:观察当前代码可以看出,在代码使用时,开始并没使用R0和R1寄存器,这是因为,rt_hw_context_switch函数是有输入参数的,ARM子函数定义中的参数放入寄存器的规则是遵循的是 ATPCS(ARM-Thumb Procedure Call Standard)。下面知识点简单介绍一下函数参数的存放规则。因此输入参数void rt_hw_context_switch(rt_uint32 from, rt_uint32 to)的对应关系是 R0 --> from 而R1 --> to。基于自动部分压栈和 PendSV 的特性,上下文切换可以实现地更加简洁,因此上面的切换线程的代码也是很简洁。

    知识点

    简单ATPCS寄存器的使用规则:

    子程序通过寄存器R0~R3来传递参数. 这时寄存器可以记作: A0~A3 , 被调用的子程序在返回前无需恢复寄存器R0~R3的内容.在子程序中,使用R4-R11来保存局部变量,这时寄存器R4-R11可以记作: V1-V8 .如果在子程序中使用到V1-V8的某些寄存器,子程序进入时必须保存这些寄存器的值,在返回前必须恢复这些寄存器的值,对于子程序中没有用到的寄存器则不必执行这些操作.在THUMB程序中,通常只能使用寄存器R4~R7来保存局部变量.寄存器R12用作子程序间scratch寄存器,记作ip; 在子程序的连接代码段中经常会有这种使用规则.寄存器R13用作数据栈指针,记做SP,在子程序中寄存器R13不能用做其他用途. 寄存器SP在进入子程序时的值和退出子程序时的值必须相等.寄存器R14用作连接寄存器,记作lr ; 它用于保存子程序的返回地址,如果在子程序中保存了返回地址,则R14可用作其它的用途.寄存器R15是程序计数器,记作PC ; 它不能用作其他用途.ATPCS中的各寄存器在ARM编译器和汇编器中都是预定义的.

    补充: ATPCS建议函数的形参不超过4个,如果形参个数少于或等于4,则形参由R0,R1,R2,R3四个寄存器进行传递;若形参个数大于4,大于4的部分必须通过堆栈进行传递

    子程序结果返回规则 1.结果为一个32位的整数时,可以通过寄存器R0返回. 2.结果为一个64位整数时,可以通过R0和R1返回,依此类推. 3.结果为一个浮点数时,可以通过浮点运算部件的寄存器f0,d0或者s0来返回. 4.结果为一个复合的浮点数时,可以通过寄存器f0-fN或者d0~dN来返回. 5.对于位数更多的结果,需要通过调用内存来传递. 举例: 输入参数通过r0-r3传递,多余的放入堆栈中;返回值放入r0,不够的话放入{r0,r1}或者{r0,r1,r2,r3},比如: int foo(int a, int b, int c, int d), 输入:r0 = a, r1 = b, r2 = c, r3 = d,返回:r0 = 类型为int的retvalue int *foo(char a, double b, int c, char d), 输入:r0 = a, r1用于对齐(double 要求8字节对齐), b = {r2, r3},c放在堆栈的sp[0]位置,d放在堆栈的sp[4]位置,这里的sp是指进入函数时的sp;返回:r0 = 类型为int *的retvalue

    实现上下文切换

    其实一开始就看这一段代码,我们可能是蒙的,因为为什么是这样的流程,这样的流程设计原因是什么? 原因就是:rt_hw_context_switch函数和rt_hw_context_switch_interrupt函数实现上下文切换的方式是很类似的,两个函数输入的参数(rt_uint32 from, rt_uint32 to)也相同,因此这两个函数写在了一起。

    详细介绍:

    在不同的 CPU 架构里,线程之间的上下文切换和中断到线程的上下文切换,上下文的寄存器部分可能是有差异的,也可能是一样的。在 Cortex-M 里面上下文切换都是统一使用 PendSV 异常来完成,切换部分并没有差异。但是为了能适应不同的 CPU 架构,RT-Thread 的 libcpu 抽象层还是需要实现三个线程切换相关的函数: 1) rt_hw_context_switch_to():没有来源线程,切换到目标线程,在调度器启动第一个线程的时候被调用。 2) rt_hw_context_switch():在线程环境下,从当前线程切换到目标线程。 3) rt_hw_context_switch_interrupt ():在中断环境下,从当前线程切换到目标线程。 在线程环境下进行切换和在中断环境进行切换是存在差异的。线程环境下,如果调用 rt_hw_context_switch() 函数,那么可以马上进行上下文切换;而在中断环境下,需要等待中断处理函数完成之后才能进行切换。

    由于这种差异,在 ARM9 等平台,rt_hw_context_switch() 和 rt_hw_context_switch_interrupt() 的实现并不一样。在中断处理程序里如果触发了线程的调度,调度函数里会调rt_hw_context_switch_interrupt() 触发上下文切换。中断处理程序里处理完中断事务之后,中断退出之前,检查 rt_thread_switch_interrupt_flag 变量,如果该变量的值为 1,就根据 rt_interrupt_from_thread 变量和 rt_interrupt_to_thread 变量,完成线程的上下文切换。

    在 Cortex-M 处理器架构里,基于自动部分压栈和 PendSV 的特性,上下文切换可以实现地更加简洁。

    线程之间的上下文切换,如下图表示:

    硬件在进入 PendSV 中断之前自动保存了 from 线程的 PSR、PC、LR、R12、R3-R0 寄存器,然后 PendSV 里保存 from 线程的 R11-R4 寄存器,以及恢复 to 线程的 R4-R11 寄存器,最后硬件在退出 PendSV 中断之后,自动恢复 to 线程的 R0~R3、R12、LR、PC、PSR 寄存器。

    中断到线程的上下文切换可以用下图表示:

    硬件在进入中断之前自动保存了 from 线程的 PSR、PC、LR、R12、R3-R0 寄存器,然后触发了 PendSV 异常。在 PendSV 异常处理函数里保存 from 线程的 R11~R4 寄存器,以及恢复 to 线程的 R4~R11 寄存器,最后硬件在退出 PendSV 中断之后,自动恢复 to 线程的 R0~R3、R12、PSR、PC、LR 寄存器。

    总结

    上面两篇文章已经把线程切换一些基本的函数分析完了,基本上了解了线程栈初始化和线程切换的内容,其中ARM子函数定义中的参数放入寄存器的规则是一个很复杂的内容,但是目前还没有遇到函数使用比较复杂的用法就不再深入研究这里面的用法了,后面还有一些与线程切换相关的函数,限于篇幅原因,放到第三篇中进行分析。

    Processed: 0.010, SQL: 8