项目地址:https://github.com/leoduu/mimiRTOS
专栏地址:mimiOS
在 RTOS 中,线程作为调度实体,其核心抽象可归纳为三点:拥有独立的栈空间、能够保存与恢复 CPU 上下文,并作为调度实体被内核调度。本章的目标是完成线程栈及初始上下文的准备工作,并通过首次上下文切换切入第一个线程,使其开始执行。
开始前确保你的开发板可用,编译调试环境没有问题噢~
手动切换线程栈
设计一个任务 A,每一秒翻转一次 LED 灯,并进行串口打印计数。
void threadA_entry(void)
{
int cnt = 0;
char str[18] = {0};
while (1) {
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
sprintf(str, "count:%d\n", cnt++);
HAL_UART_Transmit(&huart2, (uint8_t *)str, strlen(str), HAL_MAX_DELAY);
HAL_Delay(1000);
}
}
这段代码在裸机里也能跑,让它变成“线程”,差的就是一套切换的机制。第一步,我们需要切换栈指针,让这段代码能够运行在独立的栈上。
切换栈指针
Cortex-M 有两个栈指针:MSP 和 PSP,RTOS 给每个线程分配自己的栈,PSP 可以指向不同线程的栈,中断则继续用 MSP,互不干扰。
系统复位后栈指针默认为 MSP,要切到 PSP,我们可以修改 CONTROL 寄存器,将 SPSEL 置为 1:
| 位 | 功能 |
|---|---|
| [0] nPRIV | 0:特权级线程模式;1:非特权级 |
| [1] SPSEL | 0:MSP(默认);1:PSP |
根据 ARMv7-M 架构手册要求:修改 CONTROL 寄存器必须紧跟 ISB 刷新流水线。否则可能会出现 bug:SP 切换到了 PSP,但下一条指令还在继续使用 MSP。
ARMv7-M Architecture Reference Manual, B1.4.4 “Software must use an ISB barrier instruction to ensure a write to the CONTROL register takes effect before the next instruction is executed.” 实际操作
Cortex-M 的栈是从高地址往低地址增长的,我们指定栈顶作为栈的起始地址。
C 语言不能直接修改 PSP 和 CONTROL 寄存器,需要通过汇编指令。好消息是 ARM 提供的 GCC 头文件已经把对应汇编指令封装好了,可以直接调用。
uint8_t threadA_stack[STACK_SIZE] __attribute__((aligned(8)));
uint32_t *threadA_stack_top = (uint32_t *)(threadA_stack + STACK_SIZE);
void start_first_thread(void)
{
__set_PSP((uint32_t)threadA_stack_top); // 设置PSP地址
__set_CONTROL(0x02); // 切换到PSP
__ISB(); // 刷新流水线
threadA_entry(); // 直接调线程函数
}
编译烧录运行,开发板出现 LED 灯闪烁,串口打出 count:0、count:1、count:2…… ,一切顺利。
如何证明真的切换了
在 threadA_entry() 中调用 HAL_Delay() 那一行打上断点,进入调试模式,运行至断点位置。观察左下角的寄存器信息,可以看到当前 SP 的值跟 PSP 相等,CONTROL 寄存器 SPSEL 位也已经变成 1 了。
添加图片注释,不超过 140 字(可选)
添加图片注释,不超过 140 字(可选)
把栈内容打印出来,可以看到局部变量 str 落在线程的栈上:
添加图片注释,不超过 140 字(可选)
通过异常启动
Zephyr、RTEMS 等启动第一个线程,走的是设置 PSP 然后跳转到线程入口函数的路径。FreeRTOS、RT-Thread、ThreadX 等则利用 ARM 异常返回机制,选择 PSP 出栈,硬件自动从 PSP 恢复寄存器,处理器跳到指定的线程入口函数运行。
作为学习,我们也来使用异常来完成第一个线程的启动。
SVC 异常
SVC(Supervisor Call)指令是专门给用户程序调用内核服务的接口,异常触发简单直接,一行汇编即可。下面是利用 SVC 异常进入第一个线程的流程:
添加图片注释,不超过 140 字(可选)
准备异常栈帧
异常返回出栈的数据块称为栈帧,按照固定的布局排列,我们要在线程 A 的栈上提前伪造好一套栈帧,将线程初始信息填进去。
| 偏移 | 寄存器 | 内容 |
|---|---|---|
| 0 | R0 | 线程函数的参数 |
| 4 | R1 | 0 |
| 8 | R2 | 0 |
| 12 | R3 | 0 |
| 16 | R12 | 0 |
| 20 | LR | 线程退出返回的地址(现在不会退出,先填 0) |
| 24 | PC | 线程入口函数地址 |
| 28 | xPSR | 0x01000000,Thumb 位必须为 1 |
xPSR 的第 24 位是 Thumb 位,Cortex-M 只支持 Thumb 状态,置 0 时 CPU 会尝试切到 ARM 状态,直接触发 UsageFault。
控制异常返回路径
进入异常时,硬件把 LR 替换成一个特殊值 EXC_RETURN,它记录了异常发生前的处理器状态。修改该值,我们还可以控制异常返回时出栈的指针,以及返回后的模式。我们的目标是用 PSP 出栈,所以要把 bit[2] 置 1,其他位保持不变。
| 位 | 含义 |
|---|---|
| 31: 5 | 全为 1 |
| 4 | 0=扩展栈帧(含浮点,26 字);1=标准栈帧(8 字) |
| 3 | 0=返回后进入 Handler 模式; 1=返回后进入线程模式 |
| 2 | 0=使用 MSP 出栈; 1=使用 PSP 出栈 |
| 1 | 保留,值为 0 |
| 0 | 0=返回 ARM 状态; 1=返回 Thumb 状态 |
触发异常
从栈顶往下取 8 个字,按照入栈顺序准备好栈帧,然后触发 SVC 异常:
void start_first_thread(void)
{
threadA_stack_top -= 8; // 压栈了8个字,栈顶向下偏移
threadA_stack_top[0] = 10; // R0 = 入口函数参数
threadA_stack_top[1] = 0; // R1
threadA_stack_top[2] = 0; // R2
threadA_stack_top[3] = 0; // R3
threadA_stack_top[4] = 0; // R12
threadA_stack_top[5] = 0; // LR = 线程退出时返回的地址
threadA_stack_top[6] = (uint32_t)threadA_entry; // PC = 入口函数
threadA_stack_top[7] = 0x01000000; // Thumb 位必须置 1
__asm volatile ("svc 0"); // 触发SVC异常
}
在 stm32l4xx_it.c 文件里编写 SVC 异常处理函数:
void SVC_Handler(void)
{
__asm volatile (
"ldr r0, =threadA_stack_top \n" // r0 = &threadA_stack_top
"ldr r1, [r0] \n" // r1 = threadA_stack_top (异常帧地址)
"msr psp, r1 \n" // PSP 赋值
"orr lr, lr, #0x04 \n" // 使用 PSP 出栈
"bx lr \n"
::: "r0", "r1"
);
}
线程入口函数 threadA_entry 接收参数:
void threadA_entry(void *param)
{
int cnt = (int)param;
...
}
重置 MSP
切换到 PSP 之后,MSP 的职责变成了异常处理的中断栈,复位函数到触发 SVC 异常之间, MSP 上记录的调用栈和数据后续不再使用,这段栈空间可以回收,把 MSP 重置回初始值。
在 Cortex-M 中,异常向量表第一个字存放的就是 MSP 初始值:
void start_first_thread(void)
{
...
uint32_t mstack = *(uint32_t *)SCB->VTOR;
__set_MSP(mstack); // msp 复位
__ISB();
__asm volatile("svc #0"); // 触发 SVC
}
上机验证
编译烧录运行,开发板的 LED 正常闪烁,串口打印从 count:10 开始。同样的,在线程 A 内部打上断点进行调试,可以观察到:
- CONTROL 寄存器 SPSEL 的值为 1;
- SP 的值等于 PSP;
- 刚进入 threadA_entry 时,PSP 指向线程 A 的栈顶,打印栈顶往低地址 8 个字的数值,也就是伪造的栈帧,和当前寄存器的值一一对应。
不同 RTOS 启动第一个线程的流程
FreeRTOS
FreeRTOS 的启动代码思路也是一样的,调度器开始运行,开启第一个线程时,重置 MSP 寄存器,然后触发 SVC 异常。异常函数里设置 PSP(最高优先级的线程栈),将 lr (r14) 的 bit3(线程模式),bit2(PSP 出栈)和 bit0(Thumb 位)置 1,异常返回跳到线程入口。
static void prvPortStartFirstTask( void )
{
__asm volatile(
" ldr r0, =0xE000ED08 \n" /* Use the NVIC offset register to locate the stack. */
" ldr r0, [r0] \n"
" ldr r0, [r0] \n"
" msr msp, r0 \n" /* Set the msp back to the start of the stack. */
" cpsie i \n" /* Globally enable interrupts. */
" cpsie f \n"
" dsb \n"
" isb \n"
" svc 0 \n" /* System call to start first task. */
" nop \n"
);
}
void vPortSVCHandler( void )
{
__asm volatile (
" ldr r3, pxCurrentTCBConst2 \n" /* Restore the context. */
" ldr r1, [r3] \n" /* Use pxCurrentTCBConst to get the pxCurrentTCB address. */
" ldr r0, [r1] \n" /* The first item in pxCurrentTCB is the task top of stack. */
" ldmia r0!, {r4-r11} \n" /* Pop the registers that are not automatically saved on exception entry and the critical nesting count. */
" msr psp, r0 \n" /* Restore the task stack pointer. */
" isb \n"
" mov r0, #0 \n"
" msr basepri, r0 \n"
" orr r14, #0xd \n"
" bx r14 \n"
" \n"
" .align 4 \n"
"pxCurrentTCBConst2: .word pxCurrentTCB \n"
);
}
RT-Thread
RT-Thread 使用的是 PendSV,通过修改 ICSR 悬起 PendSV,开启中断后自动跳转到 PendSV 异常,第一次切换和后续切换线程共用一套代码,第一次会跳过保存当前线程上下文:
void rt_system_scheduler_start(void)
{
to_thread = _scheduler_get_highest_priority_thread(&highest_ready_priority);
rt_hw_context_switch_to((rt_uintptr_t)&to_thread->sp);
}
libcpu/arm/cortex-m3/context_gcc.S
rt_hw_context_switch_to:
...
/* trigger the PendSV exception (causes context switch) */
LDR R0, =ICSR
LDR R1, =PENDSVSET_BIT
STR R1, [R0]
/* restore MSP */
LDR r0, =SCB_VTOR
LDR r0, [r0]
LDR r0, [r0]
NOP
MSR msp, r0
PendSV_Handler:
...
LDR R0, =rt_interrupt_from_thread
LDR R1, [R0]
CBZ R1, switch_to_thread /* skip register save at the first time */
...
switch_to_thread:
LDR R1, =rt_interrupt_to_thread
LDR R1, [R1]
LDR R1, [R1] /* load thread stack pointer */
LDMFD R1!, {R4 - R11} /* pop R4 - R11 register */
MSR PSP, R1 /* update stack pointer */
pendsv_exit:
/* restore interrupt */
MSR PRIMASK, R2
ORR LR, LR, #0x04
BX LR
Zephyr
跟前面不一样,Zephyr 在系统初始化早期就使用 PSP,完成一系列初始化后,跳转到 main 线程的入口函数,后续线程切换则使用 PendSV,整体流程稍稍复杂。
在 cortex-m 架构的复位函数,可以看到 MSP 指向了主线程栈 z_main_stack,PSP 指向了中断栈 z_interrupt_stacks,然后将 CONTROL.SPSEL 置 1,SP 切换到 PSP。
arch/arm/core/cortex_m/reset.S
SECTION_SUBSEC_FUNC(TEXT,_reset_section,z_arm_reset)
SECTION_SUBSEC_FUNC(TEXT,_reset_section,__start)
ldr r0, =z_main_stack + CONFIG_MAIN_STACK_SIZE
msr msp, r0
/* lock interrupts: will get unlocked when switch to main task */
movs.n r0, #_EXC_IRQ_DEFAULT_PRIO
msr BASEPRI, r0
/* Set PSP and use it to boot without using MSP */
ldr r0, =z_interrupt_stacks
ldr r1, =CONFIG_ISR_STACK_SIZE + MPU_GUARD_ALIGN_AND_SIZE
adds r0, r0, r1
msr PSP, r0
mrs r0, CONTROL
movs r1, #(2 | CONTROL_ARM_PAC_MASK | CONTROL_ARM_BTI_MASK)
orrs r0, r1 /* CONTROL_SPSEL_Msk */
msr CONTROL, r0
isb
bl z_prep_c
这里是故意“交叉”设置,MSP 先借用 z_main_stack 完成 z_interrupt_stacks 初始化。例如开启 CONFIG_INIT_STACKS 后,系统会调用 arch_early_memset,用 0xaa 填充满 z_interrupt_stacks,以便观察栈的最大使用量,此时使用 z_main_stack 进行。
SP 切换到 PSP 后,系统跳转到 z_prep_c 进行 C 代码初始化,随后 MSP 重新设置为 z_interrupt_stacks,MSP 恢复为中断栈:
z_arm_reset
├─ MSP = 主线程栈顶 ← 第一次设置(临时占位)
├─ PSP = 中断栈顶 ← 第一次设置(借用)
├─ SPSEL=1 ← 用 PSP 跑 C
│
├─ z_arm_interrupt_stack_setup()
│ MSP = 中断栈顶 ← 第二次设置(MSP 归位)
│
└─ arch_switch_to_main_thread(prepare_multithreading())
PSP = 主线程栈顶 ← 第二次设置(PSP 归位)
arch/arm/include/cortex_m/stack.h
static ALWAYS_INLINE void z_arm_interrupt_stack_setup(void)
{
uint32_t msp = (uint32_t)(K_KERNEL_STACK_BUFFER(z_interrupt_stacks[0])) +
K_KERNEL_STACK_SIZEOF(z_interrupt_stacks[0]);
__set_MSP(msp);
}
切换到主线程时,PSP 重新设置为 z_main_stack,此前一直使用 z_interrupt_stacks,后续进入中断,系统从 z_interrupt_stacks 栈顶重新往下使用,直接覆盖掉初始化留下的残骸。
最后手动设置相关寄存器,系统跳转到主线程入口函数 bg_thread_main,完成第一次切换:
arch/arm/core/cortex_m/thread.c
void arch_switch_to_main_thread(struct k_thread *main_thread, char *stack_ptr, k_thread_entry_t _main)
{
__asm__ volatile("mov r4, %0\n" /* force _main to be stored in a register */
"msr PSP, %1\n" /* __set_PSP(stack_ptr) */
"ldr r3, =arch_irq_unlock_outlined\n"
"blx r3\n"
"mov r0, r4\n" /* z_thread_entry(_main, NULL, NULL, NULL) */
"movs r1, #0\n"
"movs r2, #0\n"
"movs r3, #0\n"
"ldr r4, =z_thread_entry\n"
/* We don’t intend to return, so there is no need to link. */
"bx r4\n"
:
: "r"(_main), "r"(stack_ptr)
: "r0", "r1", "r2", "r3", "r4", "ip", "lr", "memory");
CODE_UNREACHABLE;
}