2 线程切换

10 阅读10分钟

项目地址:​https://github.com/leoduu/mimiRTOS

专栏地址:​mimiOS

上一章​启动第一个线程中,我们通过跳转和 SVC 异常两种方式,完成了第一个线程的启动。这一章我们来完成两个线程并发运行:一个控制 LED 闪烁,一个控制串口输出。

首先优化一下项目结构,将内核,开发板,CPU 架构,app 和测试代码整理一下:

code/
  ├── bsp/
  │    ├── nucleo-l452/     ← CubeMX 工程目录
  │    └── board.h
  ├── kernel/
  │    ├── include/
  │    ├── src/             ← 内核代码
  │    └── utils/           ← 工具
  ├── ports/                ← cpu适配代码
  │    ├── cortex-m/
  │    └── portable.h       ← 接口
  ├── app/                  ← 用户线程
  ├── test/                 ← 测试代码
  └── platformio.ini

线程控制块

我们定义一个数据结构来存放线程信息,记录线程的栈和线程入口信息:

// Thread Control Block
typedef struct {
    mimi_node   node;        // 侵入式链表节点

	void        *sp;         // 线程当前栈指针(唯一会变的字段)
    uint32_t    stack_size;  // 栈大小
    void        *entry;      // 线程入口函数
    void        *param;      // 入口参数
} mimi_tcb;

使用侵入式链表将多个 TCB 串联,考虑到以下几点:

  1. 切换线程时,只需要移动到下一个节点;
  2. 一个 node 支持挂载到不同类型的队列,例如线程队列、互斥锁等待队列等;
  3. 设置多个 node,可以同时挂载到多个队列,例如互斥锁等待队列和定时器超时队列。

链表是双向环形链表,方便链表尾部插入和前驱操作:

struct list_node {
    struct list_node *prev;
    struct list_node *next;
};
typedef struct list_node mimi_node;

知道了 node 地址,怎么拿到 TCB 地址?借鉴 Linux 内核的写法,根据 node 的地址和结构体偏移进行计算即可:

#define container_of(ptr, type, member) ({                      \
    void *__mptr = (void *)(ptr);                               \
    static_assert(__same_type(*(ptr), ((type *)0)->member) ||   \
            __same_type(*(ptr), void),                          \
            "pointer type mismatch in container_of()");         \
    ((type *)(__mptr - offsetof(type, member))); })

static_assert 可以编译时检查 ptr 的类型和指定的 type->member 是否匹配,编译阶段提前发现 bug。

链表单元测试

链表是整个内核的基石,补充单元测试,在 platformio.ini 新增一个运行环境:

[env:unit]
platform = native
test_build_src = yes
build_flags =
    -Itest
    -Ikernel/include
    -Ikernel/utils
    -Ilibcpu
build_src_filter =
    +<test/unit/*>
    +<kernel/utils/list.c>

执行测试命令 pio test -e unit,全部通过,非常美观的绿色

添加图片注释,不超过 140 字(可选)

线程切换

通过 PendSV 切换线程

PendSV(可挂起的系统调用)是一种可以延迟执行的异常,写入 ICSR 寄存器的 PENDSVSET 标志位进行挂起,等条件合适了再执行。 SVC 异常如果因为优先级不够导致指令执行后没有得到响应,就会触发 HardFault:

ARMv7-M Architecture Reference Manual, B1.5.4 When the current execution priority is less than HardFault, the processor escalates the exception priority to HardFault in the following cases: When the group priority of a pending synchronous fault or Supervisor Call is lower than or equal to the currently executing priority, inhibiting normal preemption.

把 PendSV 异常设置为最低优先级,配合可挂起的特性,异常可以在用户线程或者中断中安全触发,触发时不会抢占任何正在处理的异常,等到所有高优先级异常全部结束后才执行,保证在异常返回前再进行线程切换。

因此,启动第一个线程改为 PendSV 完成,和切换线程能够复用同一套代码。SVC 留作系统调用,让非特权用户线程能访问受 MPU 保护的资源。

上下文切换

线程运行时,寄存器里装的是它此刻的运行现场(PC 指向的代码,SP 指向栈地址,LR 保存返回地址,R0-R12 存着数据,xPSR 存着状态标记),这套现场就是上下文。切换线程,本质就是把当前线程的上下文保存下来,再恢复下一个线程的上下文。

上下文保存在线程自己的栈上,进入异常时硬件自动将 R0-R3、R12、LR、PC 和 xPSR 压栈,剩下的 R4-R11 通过软件手动保存。完成后把更新后的 PSP 写回 mimi_tcb.sp,恢复上下文时就从这里读起。

添加图片注释,不超过 140 字(可选)

分步解析

上下文切换的逻辑跟芯片架构强相关,应该放在 ports 目录下,代码需要操作指定的寄存器,并且使用特殊指令,我们使用汇编代码来实现上下文切换。

上下文切换需要知道当前寄存器现场保存到哪里,下一个现场需要从哪里加载,我们定义两个全局变量 mimi_curr_thread 和 mimi_next_thread,记录需要切换的线程,传递给 PendSV 中断的汇编文件使用。

  1. 我们把 ICSR(中断控制和状态寄存器)第 28 位 PENDSVSET 置 1,挂起 PendSV 异常:
    SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;

2. 进入异常处理函数 PendSV_Handler,第一步先关中断:

    CPSID   I                       // disable interrupt

3. 首次切换时 mimi_curr_thread 还是 NULL,跳过保存上下文:

    LDR     R3, =mimi_curr_thread
    LDR     R1, [R3]                // current thread tcb
    CBZ     R1, switch_to_next      // if null, skip saving

4. 手动保存当前线程上下文到 PSP 上,然后更新到 mimi_curr_thread 的 sp。根据 sp 在 tcb 里面的偏移,str 加上 8 字节偏移寻址:

    MRS     R0, PSP
    STMDB   R0!, {R4 - R11}         // save registers
    STR     R0,  [R1, #8]           // save sp back to tcb->sp

5. 接下来加载下一个线程上下文,拿到 tcb->sp,手动恢复 R4-R11,最后将 PSP 寄存器更新为下一个线程的栈 :

switch_to_next:
    LDR     R12, =mimi_next_thread
    LDR     R12, [R12]              // next thread tcb
    LDR     R1,  [R12, #8]          // pick up tcb->sp
    LDMIA   R1!, {R4 - R11}         // restore R4-R11
    MSR     PSP, R1                 // update PSP

6. 更新记录当前线程的变量

    STR     R12, [R3]    // mimi_curr_thread = mimi_next_thread

7. 恢复中断,异常返回,硬件从新的 PSP 出栈:

    CPSIE   I                       // enable interrupt
    ORR     LR, LR, #0x04           // select PSP as thread stack point
    BX      LR                      // return to next thread

栈操作指令

上面汇编代码中用到了 STMDB 和 LDMIA,这两条指令可以将 push 和 pop 操作打包到一条指令:

我们先来看下 Cortex-M 的栈模型,满递减(Full descending stack)模式,特性如下:

  • PUSH 操作:先减小 SP,再把数据存到 SP 指向的地址
  • POP 操作:从 SP 指向的地址读出数据,再增加 SP

添加图片注释,不超过 140 字(可选)

STMDB(Store Multiple Decrease Before):先减地址,再存数据。

STMDB R0!, {R4 - R11}
  1. R0 先减一个字,R0 -= 4
  2. 把寄存器的值存到 R0 指向的地址,也就是 SP 的地址
  3. 重复直到 R11 存完
  4. ! 表示完成后更新 R0 的值

LDMIA(Load Multiple Increase After):先取数据,再增地址。跟 STMDB 相反。

LDMIA R1!, {R4 - R11}
  1. 从 [R1] 加载数据到寄存器
  2. R1 增加一个字,R1 += 4
  3. 重复直到 R11 加载完成
  4. 更新 R1 的值

系统初始化

重定向 main

作为一个 RTOS,需要接管整个系统的运行,自然包括系统开始流程。在 startup_stm32l452xx.s 文件里复位函数最后,改为跳转到 mimiOS 的 main 函数,然后完成各个模块的初始化。原本的 main 函数,放到 bsp 的初始化函数 mimi_board_init() 完成:

/* Call the application's entry point.*/
- bl  main
==============
+ bl  mimi_main

芯片初始化

原来 main 函数的芯片初始化以及 HAL 库初始化,放到开发板的初始化完成:

void mimi_board_init(void)
{
    main();
    NVIC_SetPriority(PendSV_IRQn, 0x0F);        // lowest
}

自动初始化

相信大家总会遇到初始化的烦恼,应用初始化需要调用 xxx_init(),得找个地方加上头文件,然后再找个地方显式调用,侵入式修改公共模块,非常丑陋,那有没有优雅的解决方案呢?有的兄弟有的。

我们可以将这些初始化函数按照一定顺序存放到指定的内存段,在 OS 初始化阶段遍历内存段,调用初始化函数,达到自动初始化的效果。

在 export.h 可以看到一组宏,在函数的附近调用 MIMI_INIT_xxx_EXPORT 宏,会生成一个全局变量,存放该初始化函数的地址,并且该变量会被指定存放到 .mimi_init_fn.x 内存段上。

typedef int (*init_fn_t)(void);

#define INIT_EXPORT(fn, level)                      \
    mimi_used const init_fn_t __mimi_init_##fn      \
        __attribute__((section(".mimi_init_fn." level))) = fn

#define MIMI_INIT_DEVICE_EXPORT(fn)          INIT_EXPORT(fn, "0")
#define MIMI_INIT_APP_EXPORT(fn)             INIT_EXPORT(fn, "1")

我们在链接文件 STM32L452XX_FLASH.ld 新增一段 flash 区域:.mimi_data,使用 SORT 按照字典序从小到大排列。在内存段开始和结尾的地方加上一个标签记录地址。

.mimi_data :
  {
    . = ALIGN(8);
    __mimi_init_start = .;
    KEEP (*(SORT(.mimi_init_fn*)))
    __mimi_init_end = .;
    . = .;

  } >FLASH

读取 start 和 end 标签的地址然后遍历,拿到的就是各个模块的初始化函数地址。

void mimi_component_init(void)
{
    extern const size_t __mimi_init_start;
    extern const size_t __mimi_init_end;
    volatile const init_fn_t *fn_ptr;

    for (fn_ptr = (init_fn_t *)&__mimi_init_start;
         fn_ptr < (init_fn_t *)&__mimi_init_end; fn_ptr++) {
        (*fn_ptr)();
    }
}

线程初始化

调用 mimi_thread_init() 初始化线程,线程 TCB 的 node 节点挂载到 mimi_thread_list,旋转链表就可以找到下一个线程。

栈帧初始化

上一章讲到,线程第一次运行时,需要在栈上提前准备好异常栈帧,将寄存器初始化,这段逻辑是跟芯片架构相关的,代码放到 ports/cortex-m/ 目录。需要注意栈地址必须要 8 字节对齐,赋值前进行一次向下取整。

void *mimi_stack_init(void *entry, void *param, void *stack, uint32_t size)
{
    uint8_t *stack_addr;
    stack_addr = (uint8_t *)stack + size - sizeof(mimi_stack_frame);
    stack_addr = (uint8_t *)mimi_align_down((uint32_t)stack_addr, 8);
    mimi_stack_frame *stack_frame = (mimi_stack_frame *)stack_addr;

    for (int i = 0; i < sizeof(mimi_stack_frame) / sizeof(uint32_t); i++) {
        ((uint32_t *)stack_frame)[i] = 0;
    }
    stack_frame->r0 = (uint32_t)param;
    stack_frame->pc = (uint32_t)entry;
    stack_frame->psr = (uint32_t)0x01000000UL;  // set Thumb flag bit

    return (void *)stack_addr;
}

其他配置

PlatformIO 配置文件添加规则,过滤掉 CubeMX 生成的中断函数文件,避免 PendSV_Handler 重复定义:

build_src_filter =
	-<bsp/nucleo-l452/Core/Src/stm32l4xx_it.c>

HAL 库默认依赖 SysTick 中断进行计时,比如 HAL_Delay() 函数,我们补上 SysTick_Handler 实现:

// bsp\nucleo-l452\board.c
void SysTick_Handler(void) {
    HAL_IncTick();
}

用户线程

核心切换机制我们已经清楚了,下面写两个线程来验证:一个点灯,一个串口打印。线程完成操作后各自等待 500ms,调用 mimi_thread_yield() 触发线程切换,两个线程交替运行。

void led_thread_entry(void *param)
{
    while (1) {
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        HAL_Delay(500);
        mimi_thread_yield();
    }
}

void uart_thread_entry(void *param)
{
    int32_t cnt = 0;
    while (1) {
        printf("count:%ld\n", cnt++);
        HAL_Delay(500);
        mimi_thread_yield();
    }
}

void app_init(void)
{
    mimi_thread_init(&led_thread,
                    led_thread_stack, STACK_SIZE,
                    led_thread_entry, NULL);

    mimi_thread_init(&uart_thread,
                    uart_thread_stack, STACK_SIZE,
                    uart_thread_entry, NULL);
}
MIMI_INIT_APP_EXPORT(app_init);

有了自动初始化,我们不需要再找个地方调用 app_init() 了!编译烧录,上机运行:LED 闪烁,串口每秒打印。在 PendSV 中断函数打上断点进行调试,可以观察到 PSP 的值在 led_thread.sp 和 uart_thread.sp 之间切换。