1 启动第一个线程

7 阅读10分钟

项目地址:​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] nPRIV0:特权级线程模式;1:非特权级
[1] SPSEL0: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 的栈上提前伪造好一套栈帧,将线程初始信息填进去。

偏移寄存器内容
0R0线程函数的参数
4R10
8R20
12R30
16R120
20LR线程退出返回的地址(现在不会退出,先填 0)
24PC线程入口函数地址
28xPSR0x01000000,Thumb 位必须为 1

xPSR 的第 24 位是 Thumb 位,Cortex-M 只支持 Thumb 状态,置 0 时 CPU 会尝试切到 ARM 状态,直接触发 UsageFault。

控制异常返回路径

进入异常时,硬件把 LR 替换成一个特殊值 EXC_RETURN,它记录了异常发生前的处理器状态。修改该值,我们还可以控制异常返回时出栈的指针,以及返回后的模式。我们的目标是用 PSP 出栈,所以要把 bit[2] 置 1,其他位保持不变。

位含义
31: 5全为 1
40=扩展栈帧(含浮点,26 字);1=标准栈帧(8 字)
30=返回后进入 Handler 模式; 1=返回后进入线程模式
20=使用 MSP 出栈; 1=使用 PSP 出栈
1保留,值为 0
00=返回 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 内部打上断点进行调试,可以观察到:

  1. CONTROL 寄存器 SPSEL 的值为 1;
  2. SP 的值等于 PSP;
  3. 刚进入 threadA_entry 时,PSP 指向线程 A 的栈顶,打印栈顶往低地址 8 个字的数值,也就是伪造的栈帧,和当前寄存器的值一一对应。

不同 RTOS 启动第一个线程的流程

FreeRTOS

FreeRTOS 的启动代码思路也是一样的,调度器开始运行,开启第一个线程时,重置 MSP 寄存器,然后触发 SVC 异常。异常函数里设置 PSP(最高优先级的线程栈),将 lr (r14) 的 bit3(线程模式),bit2(PSP 出栈)和 bit0(Thumb 位)置 1,异常返回跳到线程入口。

​portable/GCC/ARM_CM3/port.c

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 异常,第一次切换和后续切换线程共用一套代码,第一次会跳过保存当前线程上下文:

​src/scheduler.c

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;
}