极术小姐姐 · 2023年02月01日

[调度 2] 浅度剖析 ARM mbed OS 上下文切换

0. 目录

1. 垫话

2. 前言

3. 系统调用

    3.1 用户侧接口定义

    3.2 系统调用处理函数

    3.3 用户侧接口调用

4. 上下文

    4.1 上下文初始化

    4.2 第一次上下文切换

    4.3 上下文切换

    4.4 其他上下文切换点位

    4.5 where is schedule()?

5. 总结

1. 垫话

从本文开始,正式进入对调度实现细节及底层的探讨。本文讨论 ARM mbed OS(RTX) 的上下文切换。解构调度器,按说不应该从“上下文切换”如此 meta 的细节入手。但从我个人角度来说,本文是一个回顾和总结,如我在《浅谈调度相关的元问题》一文所述,mbed OS 是一个支持分态的内核,其上下文切换实现的套路非常神似 linux,故而对 mbed OS 上下文切换的探讨有一定的推广意义。而且因为 ARM 没有历史包袱,相较于 x86 架构,其设计非常的 make sense,更加符合现代操作系统实现的直觉,尤其是 v7a。所以如果上下文切换本身是跳不过去的环节的话,基于 ARM 架构的 RTOS 去讲会更容易理解。

另外,ARM 的 v7m 架构隔离框架 uVisor,其底层适配的就是自家的 mbed OS。我对 ARM 代码的好感始自 uVisor,这是我读过最优秀的代码之一,有时间的话写一个 uVisor 解构系列文章。

如本文这类回顾总结,都是以前工作学习中曾研究过的,很可惜当时没有写文章记录的习惯,很多细节都遗忘了,所以也是一个从新学习的过程。

2. 前言

本文解剖 mbed OS(下文简称 mbed)上下文切换实现的细节。mbed 是一个分态设计的内核,本文主要聚焦 ARM v7m 架构上 mbed 相关实现细节。

本文会对原代码进行精简甚至魔改,本文代码皆选择 v7m gcc 编译器下的实现。

本文需要一些 ARM v7m 架构的基础背景知识,但本文不会展开讨论 v7m 架构细节,相关内容推荐《cortex M3 权威指南》(下文简称 《权威指南》)。

3. 系统调用

既然分态,系统调用是必要的。

3.1 用户侧接口定义

类似 linux,mbed 的系统调用也是通过一套模板宏来定义。我们看一个典型的实现:


/* 定义线程创建(ThreadNew)系统调用的用户侧接口
 * 注意此宏定义的是系统调用的用户侧接口
 * 与 linux 的 SYSCALL_DEFINEX 宏不同,后者定义的是内核侧接口
 */
SVC0_3 (ThreadNew, osThreadId_t, osThreadFunc_t, void *, const osThreadAttr_t *)

/* 辅助宏
 */
#define SVC0_3(f,t,t1,t2,t3)                                                   \
__attribute__((always_inline))                                                 \
__STATIC_INLINE t __svc##f (t1 a1, t2 a2, t3 a3) {                             \
  SVC_ArgR(0,a1);                                                              \
  SVC_ArgR(1,a2);                                                              \
  SVC_ArgR(2,a3);                                                              \
  SVC_ArgF(svcRtx##f);                                                         \
  SVC_Call0(SVC_In3, SVC_Out1, SVC_CL0);                                       \
  return (t) __r0;                                                             \
}

#define SVC_RegF "r12"

#define SVC_ArgN(n) \
register uint32_t __r##n __ASM("r"#n)

#define SVC_ArgR(n,a) \
register uint32_t __r##n __ASM("r"#n) = (uint32_t)a

#define SVC_ArgF(f) \
register uint32_t __rf   __ASM(SVC_RegF) = (uint32_t)f

#define SVC_In0 "r"(__rf)
#define SVC_In1 "r"(__rf),"r"(__r0)
#define SVC_In2 "r"(__rf),"r"(__r0),"r"(__r1)
#define SVC_In3 "r"(__rf),"r"(__r0),"r"(__r1),"r"(__r2)
#define SVC_In4 "r"(__rf),"r"(__r0),"r"(__r1),"r"(__r2),"r"(__r3)

#define SVC_Out0
#define SVC_Out1 "=r"(__r0)

#define SVC_CL0
#define SVC_CL1 "r1"
#define SVC_CL2 "r0","r1"

#ifndef   __STATIC_INLINE
  #define __STATIC_INLINE                        static inline
#endif

#define SVC_Call0(in, out, cl)                                                 \
  __ASM volatile ("svc 0" : out : in : cl)

#ifndef   __ASM
  #define __ASM                                  __asm
#endif

没必要搞清楚这组宏具体是怎么工作的,我们直接 gcc -E:

/* 以上 SVC0_3 (ThreadNew ....) 宏本质上定义了一个如下函数
 * 该函数名为 __svcThreadNew
 * 其三个入参,分别通过寄存器给到 r0、r1、r2
 * 并将目标函数 svcRtxThreadNew(系统调用内核侧接口)的地址放到 r12 中
 * 最后调用 svc 指令,注意这里传入的 svc 系统调用号一律是 0
 */
__attribute__((always_inline))
static inline osThreadId_t
__svcThreadNew (osThreadFunc_t a1, void * a2, const osThreadAttr_t * a3) {
    register uint32_t __r0 __asm("r0") = (uint32_t)a1;
    register uint32_t __r1 __asm("r1") = (uint32_t)a2;
    register uint32_t __r2 __asm("r2") = (uint32_t)a3;
    register uint32_t __rf __asm("r12") = (uint32_t)svcRtxThreadNew;
    __asm volatile ("svc 0" : "=r"(__r0) : "r"(__rf),"r"(__r0),"r"(__r1),"r"(__r2) : );
    return (osThreadId_t) __r0;
}

3.2 系统调用处理函数


/* 代码源文件:
 * rtos/TARGET_CORTEX/rtx5/RTX/Source/TOOLCHAIN_GCC/TARGET_RTOS_M4_M7/irq_cm4f.S
 */

SVC_Handler:
    /* 在 SVC 处理函数入口处,通过 EXC_RETURN(LR) 判断发起 SVC 调用的代码使用的是 MSP 还是 PSP
     * 《权威指南》9.1、9.6。
     * if LR & 0x04: R0 = PSP else: R0 = MSP
     */
    TST      LR,#0x04  // Determine return stack from EXC_RETURN bit 2
    ITE      EQ
    MRSEQ    R0,MSP    // Get MSP if return stack is MSP
    MRSNE    R0,PSP    // Get PSP if return stack is PSP

    /* 1. 从发起 SVC 代码的栈上取出 PC
     * 2. 从 PC 中萃取出 SVC 调用号(《权威指南》7.6)
     * 3. if svc_no != 0: goto SVC_User
     *
     * 如本文 3.1 所述,mbed 的系统调用用户侧一律使用 0 系统调用号
     * 对于非 0 系统调用,mbed 是用来留给用户自己去实现的,也就是 SVC_User
     * SVC_User 中的逻辑就是去一个用户自定义的系统调用表中取对应下标函数,并执行
     * mbed 默认的 SVC_User 系统调用表是空的(用户自定义),这里直接忽略
     */
    LDR      R1,[R0,#24]  // Load saved PC from stack
    LDRB     R1,[R1,#-2]  // Load SVC number
    CBNZ     R1,SVC_User  // Branch if not SVC 0

    /* 1. 将 SP、EXC_RETURN 保存到系统调用处理函数栈上
     * 2. 从 SVC 调用栈上取出 R0-R3(函数参数)及 R12(目标系统调用内核侧)
     * 3. 执行目标系统调用的内核侧函数,ThreadNew 对应的是 svcRtxThreadNew
     * 4. 恢复 SP、EXC_RETURN
     * 5. 将返回值保存到栈上
     */
    PUSH     {R0,LR}    // Save SP and EXC_RETURN
    LDM      R0,{R0-R3,R12}  // Load function parameters and address from stack
    BLX      R12      // Call service function
    POP      {R12,LR}    // Restore SP and EXC_RETURN
    STM      R12,{R0-R1}  // Store function return values

    /* 从系统调用返回(《权威指南》9.2)
     */
    BX       LR        // Exit from handler

3.3 用户侧接口调用


osThreadId_t osThreadNew (osThreadFunc_t func, void *argument, const osThreadAttr_t *attr) {
    osThreadId_t thread_id;
    
    /* 调用 osThreadNew 的是应用程序代码,也就是用户态代码
     * 虽然 v7m 有其自己的处理器模式(《权威指南》3.3),但我们沿用用户态、内核态的说法
     * 可以看出,如果当前在 IRQ 中,则不允许创建线程,直接返回错误
     * 否则,执行本文 3.1 节所定义的用户侧接口 __svcThreadNew
     * 最终通过 SVC_Handler 调用到内核态代码 svcRtxThreadNew
     */
    if (IsIrqMode() || IsIrqMasked()) {
        EvrRtxThreadError(NULL, (int32_t)osErrorISR);
        thread_id = NULL;
    } else {
      thread_id = __svcThreadNew(func, argument, attr);
    }
    return thread_id;
}

4. 上下文

4.1 上下文初始化

线程上下文的初始化,在线程创建的内核侧接口,也就是 svcRtxThreadNew 中:


static osThreadId_t svcRtxThreadNew (osThreadFunc_t func, void *argument, const osThreadAttr_t *attr) {
  ... ...
    
    /* 这是对线程栈的初始化
     * 配合源代码中的原注释不难理解,就是对一些寄存器做初始化
     * 这里关注对 ptr[15],也就是 xPSR 的初始化
     */
    ptr = (uint32_t *)thread->sp;
    for (n = 0U; n != 13U; n++) {
      ptr[n] = 0U;                      // R4..R11, R0..R3, R12
    }
    ptr[13] = (uint32_t)osThreadExit;   // LR
    ptr[14] = (uint32_t)func;           // PC
    ptr[15] = xPSR_InitVal(
                (bool_t)((osRtxConfig.flags & osRtxConfigPrivilegedMode) != 0U))
              );                        // xPSR
    ptr[8]  = (uint32_t)argument;       // R0

    ... ...
}

/* 此函数根据线程执行是在特权级模式下,还是非特权级模式下,来初始化 SPSR
 * 如果系统配置(OS_PRIVILEGE_MODE 配置项)线程执行在特权级模式下,则 SPSR 为 CPSR_MODE_SYSTEM
 * 否则为 CPSR_MODE_USER
 * 线程执行在特权级模式下,则为不分态模式;否则为分态模式
 */
__STATIC_INLINE uint32_t xPSR_InitVal (bool_t privileged) {
    uint32_t psr;
    
    if (privileged)
      psr = CPSR_MODE_SYSTEM;
    else
      psr = CPSR_MODE_USER;
    
    return psr;
}

4.2 第一次上下文切换

在本文 3.2 节对系统调用处理函数的解析中,我们有意忽略了一个极其重要的点。因为在 3.2 节中,我们聚焦的是系统调用的实现。本节中,我们补充被忽略的事实:mbed 会在系统调用处理函数,调用完对应目标内核侧接口后,在返回用户态之前,做一次上下文切换。

mbed 内核的第一次上下文切换,也即将代码从内核代码,切入到第一个用户态程序中,是通过触发一次系统调用来实现的。没想到吧,大多数单特权级 RTOS 下不是这么实现的。

触发第一次系统调用的代码是 osKernelStart:


/// Start the RTOS Kernel scheduler.
osStatus_t osKernelStart (void) {
    osStatus_t status;

    /* 调用 __svcKernelStart,内核侧是 svcRtxKernelStart
     * 有人可能会问,osKernelStart 函数的执行肯定是在内核代码中
     * 怎么内核代码里面也可以发起系统调用呢?
     * 这是因为 v7m 的处理器模式设计与我们通常概念下的用户态、内核态所有不同
     * v7m 下具体的处理器模式,请参考 《权威指南》3.3,此处不必纠结
     */
    if (IsIrqMode() || IsIrqMasked()) {
        EvrRtxKernelError((int32_t)osErrorISR);
        status = osErrorISR;
    } else {
        /* 第一次上下文切换即将发生
         */
        status = __svcKernelStart();
    }
    return status;
}

static osStatus_t svcRtxKernelStart (void) {
    .. ...
    
    /* 使能 tick
     */
    // Enable RTOS Tick
    OS_Tick_Enable();

    /* 从调度队列中选中优先级最高的任务
     */
    // Switch to Ready Thread with highest Priority
    thread = osRtxThreadListGet(&osRtxInfo.thread.ready);

    /* 告诉内核,应该调度了
     */
    osRtxThreadSwitch(thread);

    /* 此处与 v7m 处理器模式有关,《权威指南》3.3
     * 对 CONTROL 寄存器进行设置
     * 如果不分态(也就是应用运行在特权级模式下),则将应用运行的 CPU 设置为特权模式
     * 否则设置为非特权模式
     */
    if ((osRtxConfig.flags & osRtxConfigPrivilegedMode) != 0U)
        // Privileged Thread mode & PSP
        __set_CONTROL(0x02U);
    else
        // Unprivileged Thread mode & PSP
        __set_CONTROL(0x03U);

    return osOK;
}

/* 可以看到,mbed 内核的调度函数
 * 并没有实际发起上下文切换
 * 只是设定系统当前 “需要进行调度” 的 hint
 * 真正的上下文切换要等到 SVC 处理函数返回用户态的时候
 */
void osRtxThreadSwitch (os_thread_t *thread) {
    thread->state = osRtxThreadRunning;
    osRtxInfo.thread.run.next = thread;
}

4.3 上下文切换

3.2 节中,SVC 处理函数在调用完内核侧接口后就通过 BX LR 返回了,这是我们为了突出系统调用相关流程而故意进行了简化。实际上完整的 SVC 处理函数如下:


SVC_Handler:
    TST      LR,#0x04    // Determine return stack from EXC_RETURN bit 2
    ITE      EQ
    MRSEQ    R0,MSP      // Get MSP if return stack is MSP
    MRSNE    R0,PSP      // Get PSP if return stack is PSP

    LDR      R1,[R0,#24]  // Load saved PC from stack
    LDRB     R1,[R1,#-2]  // Load SVC number
    CBNZ     R1,SVC_User  // Branch if not SVC 0

    PUSH     {R0,LR}    // Save SP and EXC_RETURN
    LDM      R0,{R0-R3,R12}  // Load function parameters and address from stack
    BLX      R12      // Call service function
    /* 第 11 行 PUSH 了调用者的栈 R0(SP)
     * 这一行将调用者的栈 R0 POP 到 R12(SP) 中
     */
    POP      {R12,LR}    // Restore SP and EXC_RETURN
    STM      R12,{R0-R1}  // Store function return values

/* 以上代码同本文 3.2 节
 * 以下代码在系统调用的返回路径上,处理上下文切换
 */
SVC_Context: 
    /* R1 = curr, R2 = next
     * 如果 next(下一个要调度的线程)等于 curr(当前运行的线程),则无需上下文切换
     * if next == curr: return
     */
    LDR      R3,=osRtxInfo+I_T_RUN_OFS // Load address of osRtxInfo.run
    LDM      R3,{R1,R2}    // Load osRtxInfo.thread.run: curr & next
    CMP      R1,R2      // Check if thread switch is required
    IT       EQ
    BXEQ     LR        // Exit when threads are the same

    /* 如果 curr != NULL,则需要保存 curr 的上下文
     * curr 为 NULL 的场景,有两类情况:
     * 1. curr(正在运行的)线程被删除
     * 2. 第一次上下文切换时,也就是 svcRtxKernelStart 时
     *
     * if curr != NULL: goto SVC_ContextSave
     */
    CBNZ     R1,SVC_ContextSave  // Branch if running thread is not deleted
    TST      LR,#0x10      // Check if extended stack frame
    BNE      SVC_ContextSwitch

/* 第 17 行获取调用者(也即当前正在运行的线程)的栈,保存在 R12 中
 * SVC_ContextSave 将当前上下文保存到以 R12 为 SP 的栈上
 */
SVC_ContextSave:
    STMDB    R12!,{R4-R11}    // Save R4..R11

    STR      R12,[R1,#TCB_SP_OFS]  // Store SP
    STRB     LR, [R1,#TCB_SF_OFS]  // Store stack frame information

/* curr = next
 */
SVC_ContextSwitch:
    STR      R2,[R3]        // osRtxInfo.thread.run: curr = next

/* R2 为 next,next 指向一个 osRtxThread_t
 * R1 = (osRtxThread_t *)next->stack_frame(EXC_RETURN[7..0],《权威指南》9.6)
 * R0 = (osRtxThread_t *)next->sp
 * 从 R0(SP) 中恢复上下文,并切换入 next 线程
 */
SVC_ContextRestore:
    LDRB     R1,[R2,#TCB_SF_OFS]  // Load stack frame information
    LDR      R0,[R2,#TCB_SP_OFS]  // Load SP
    ORR      LR,R1,#0xFFFFFF00    // Set EXC_RETURN

    LDMIA    R0!,{R4-R11}           // Restore R4..R11
    MSR      PSP,R0                 // Set PSP

/* 从 SVC 处理函数返回
 */
SVC_Exit:
    BX       LR                     // Exit from handler

4.4 其他上下文切换点位

不仅系统调用返回用户态的点位上会做上下文切换,其他上下文切换点位有:

/* 调用 SVC_Context 的都是可能会做上下文切换的点位
 * 这里很典型的,PendSV 以及 Tick 中断处理函数中,也会做上下文切换
 */
PendSV_Handler:
    PUSH     {R0,LR}                // Save EXC_RETURN
    BL       osRtxPendSV_Handler  // Call osRtxPendSV_Handler
    POP      {R0,LR}                // Restore EXC_RETURN
    MRS      R12,PSP
    B        SVC_Context

SysTick_Handler:
    PUSH     {R0,LR}                // Save EXC_RETURN
    BL       osRtxTick_Handler      // Call osRtxTick_Handler
    POP      {R0,LR}                // Restore EXC_RETURN
    MRS      R12,PSP
    B        SVC_Context

4.5 where is schedule()?

mbed 代码通篇没有一个命名类似 schedule 的函数,那 schedule() 对等位的逻辑是啥?

通过上文的分析,osRtxThreadSwitch 就是指示内核调度到目标线程(osRtxThreadSwitch 函数的入参),实际上,mbed 相关代码在调用此函数之前都会先通过 osRtxThreadListGet 从就绪队列上获取下一个需要被调度的线程(或其他类似逻辑)。类似:


// Switch to Ready Thread with highest Priority
thread = osRtxThreadListGet(&osRtxInfo.thread.ready);

osRtxThreadSwitch(thread);

换句话说,mbed 并未实现一个明确的 schedule 函数,而是在各逻辑执行 osRtxThreadSwitch 之前,自行从就绪队列上选择下一个要投入运行的线程,并传给 osRtxThreadSwitch 函数。这一套组合拳,就相当于是 schedule 函数。

5. 总结

mbed 是支持分态的(应用程序运行在非特权级,内核运行在特权级),最典型的上下文切换点位是系统调用返回用户态时。另外 PendSV、tick 这两个中断的处理函数中也会做上下文切换。内核中的 osRtxThreadSwitch 只是给内核一个“需要进行上下文切换了”的 hint,真正的上下文切换,要留待系统调用或中断处理函数返回用户态之前的上下文切换点位。该设计与 linux 是神似的。

作者: 戴胜冬
文章来源:窗有老梅

推荐阅读

欢迎大家点赞留言,更多Arm技术文章动态请关注极术社区嵌入式客栈专栏欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
2834
内容数
164
分享一些在嵌入式应用开发方面的浅见,广交朋友
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息