LK 博客
RTOS内核开发实战(4):Cortex-M3端口层,初始栈帧、PendSV与首任务启动
嵌入式
约 1 分钟阅读 0 赞 1 条评论 鸿蒙黑体

RTOS内核开发实战(4):Cortex-M3端口层,初始栈帧、PendSV与首任务启动

Yukikaze
Yukikaze @Yukikaze
累计点赞 0 登录后每个账号只能点一次
内容长度 0 正文词元数
正文
目录会跟随阅读位置移动。
阅读进度

当链表、TCB、ready queue 和调度器基本站稳之后,RTOS 才真正来到一个分水岭:前面的代码都还在“决定谁该运行”,而 port 层要解决的是另一件更硬的问题,怎么让 CPU 真的切到那个任务去执行。

当前仓库里,这部分工作集中在 RTOS/portable/os_port_cortex_m3.c

它做了三件核心事情:

  1. 按 Cortex-M 异常返回规则伪造任务初始栈帧。
  2. 使用 PendSV 作为上下文切换入口。
  3. 在首任务启动时,从 MSP 过渡到 PSP,并把 g_next_task 正式切成 g_current_task

先看这一层真正要解决什么

这篇文章关注的不是调度器“选谁跑”,而是 CPU “怎么切过去”。

所以后面的每一段,其实都围绕两个关键词展开:栈帧和异常返回。

一、为什么 Cortex-M3 的任务启动要先“伪造一个异常现场”

在 Cortex-M 上,新任务不是像普通 C 函数那样直接 call 进去的。更常见的做法是先把任务栈伪造成“像刚从异常返回一样”,然后让 CPU 走一次异常返回路径,自动把寄存器恢复到这个任务上下文。

当前实现就是这么做的:

uint32_t *os_port_task_stack_init(uint32_t *stack_base, uint32_t stack_size, task_entry_t entry, void *param)
{
    uintptr_t  top_address = 0U;
    uint32_t  *sp          = NULL;

    if ((stack_base == NULL) || (entry == NULL) || (stack_size < OS_TASK_MIN_STACK_DEPTH))
    {
        return NULL;
    }

    top_address = (uintptr_t)(stack_base + stack_size);
    top_address &= ~OS_PORT_STACK_ALIGN_MASK;
    sp = (uint32_t *)top_address;

    *(--sp) = OS_PORT_INITIAL_XPSR;
    *(--sp) = ((uint32_t)entry & 0xFFFFFFFEUL);
    *(--sp) = (uint32_t)os_port_task_exit_error;
    *(--sp) = 0x12121212UL;
    *(--sp) = 0x03030303UL;
    *(--sp) = 0x02020202UL;
    *(--sp) = 0x01010101UL;
    *(--sp) = (uint32_t)param;

    *(--sp) = 0x11111111UL;
    *(--sp) = 0x10101010UL;
    *(--sp) = 0x09090909UL;
    *(--sp) = 0x08080808UL;
    *(--sp) = 0x07070707UL;
    *(--sp) = 0x06060606UL;
    *(--sp) = 0x05050505UL;
    *(--sp) = 0x04040404UL;

    return sp;
}

这里的设计点很典型:

  1. 栈顶先按 OS_TASK_STACK_ALIGNMENT_BYTES 对齐,保证满足 Cortex-M3 异常栈帧要求。
  2. xPSR 里的 T 位必须为 1,也就是 0x01000000,否则 CPU 不会按 Thumb 状态执行任务入口。
  3. PC 直接放任务入口地址,R0 放任务参数。
  4. LR 不是返回到创建者,而是指向 os_port_task_exit_error(),防止任务函数意外 return 之后直接跑飞。

这段代码其实说明了 RTOS 的一个底层真相:所谓“创建任务”,本质上就是提前把它未来要恢复的寄存器布局铺好。

二、为什么上下文切换入口选 PendSV

PendSV 在 Cortex-M 系列里几乎就是 RTOS 的标准切换入口,原因很简单:它可以被设置成最低优先级。

这样一来,真正紧急的中断不会被任务切换打断,而任务切换总是在“该处理的中断基本处理完了”之后再发生。

当前代码里先把 PendSV 优先级压到最低:

static OS_PORT_USED void os_port_configure_pendsv_priority(void)
{
    uint32_t priority = (1UL << __NVIC_PRIO_BITS) - 1UL;

    NVIC_SetPriority(PendSV_IRQn, priority);
}

真正请求切换时,只需要置位 PENDSVSET

void os_port_trigger_pendsv(void)
{
    SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;
    __DSB();
    __ISB();
}

这里加 __DSB()__ISB() 不是形式主义,而是为了保证挂起请求和后续指令执行顺序被处理器正确观察到。

对系统级代码来说,这种细节不能省。

三、首任务启动为什么要先恢复 MSP,再通过 PendSV 进入任务

os_kernel_start() 已经能让调度器选出首任务,但真正切进去的是 os_port_start_first_task()

OS_PORT_NAKED void os_port_start_first_task(void)
{
#if defined(__GNUC__) && (defined(__arm__) || defined(__thumb__))
    __asm volatile(
        "bl os_port_configure_pendsv_priority \n"
        "cpsid i                            \n"
        "ldr r0, =_estack                  \n"
        "msr msp, r0                       \n"
        "bl os_port_trigger_pendsv         \n"
        "cpsie i                           \n"
        "1:                                \n"
        "b 1b                              \n"
    );
#else
    while (1)
    {
    }
#endif
}

这段汇编的几个动作连起来看,就很清楚了:

  1. 先配置 PendSV 优先级。
  2. 关中断,避免 MSP 重置过程被打断。
  3. 把 MSP 恢复到链接脚本给出的 _estack,这样 main -> os_kernel_start -> os_port_start_first_task 这条启动调用链就不会继续占用中断栈。
  4. 触发 PendSV,请求走统一的上下文切换入口。
  5. 开中断,等待 PendSV 在正确的栈环境里接管。

也就是说,首任务启动并不是一套“特殊分支逻辑”,而是被纳入到同一条 PendSV 切换链路里。

这样设计的好处很明显:首任务和普通任务切换共享一条入口,维护成本更低,也更不容易出现两套上下文切换逻辑逐渐漂移的问题。

四、PendSV 切换链路怎么分工

当前 PendSV_Handler() 的思路是“汇编做寄存器现场搬运,C 函数做调度状态提交”。

五、C 辅助函数负责什么

先看 C 辅助函数:

static OS_PORT_USED uint32_t *os_port_switch_context(uint32_t *stack_pointer)
{
    tcb_t *current_task = NULL;
    tcb_t *next_task = NULL;
    os_status_t status = OS_STATUS_OK;

    current_task = task_get_current();
    if (current_task != NULL)
    {
        current_task->sp = stack_pointer;
    }

    next_task = task_get_next();
    if (next_task == NULL)
    {
        return (current_task != NULL) ? current_task->sp : NULL;
    }

    status = task_set_current(next_task);
    if (status != OS_STATUS_OK)
    {
        return (current_task != NULL) ? current_task->sp : NULL;
    }

    return next_task->sp;
}

这一步非常关键。

它没有碰寄存器保存细节,而是专注处理调度器关心的语义:

  1. 普通切换时,把保存完 r4-r11 之后的 PSP 写回旧任务 TCB。
  2. 读取调度器已经选好的 g_next_task
  3. 通过 task_set_current()next 正式提交成 current
  4. 返回新任务的软件栈顶,交给汇编去恢复寄存器。

六、汇编入口真正做了哪些现场操作

再看汇编入口:

OS_PORT_NAKED void PendSV_Handler(void)
{
#if defined(__GNUC__) && (defined(__arm__) || defined(__thumb__))
    __asm volatile(
        "bl task_get_current                \n"
        "cbz r0, 1f                         \n"
        "mrs r0, psp                        \n"
        "stmdb r0!, {r4-r11}                \n"
        "b 2f                               \n"
        "1:                                 \n"
        "movs r0, #0                        \n"
        "2:                                 \n"
        "bl os_port_switch_context          \n"
        "cbz r0, 3f                         \n"
        "ldmia r0!, {r4-r11}                \n"
        "msr psp, r0                        \n"
        "movs r0, #2                        \n"
        "msr control, r0                    \n"
        "isb                                \n"
        "ldr lr, =0xFFFFFFFD                \n"
        "bx lr                              \n"
        "3:                                 \n"
        "b 3b                               \n"
    );
#else
    while (1)
    {
    }
#endif
}

这段代码背后对应的是 Cortex-M 的标准上下文切换模型:

  1. 如果当前已经有任务在跑,就从 PSP 取出线程栈,并手工保存 r4-r11
  2. 调用 os_port_switch_context(),把旧任务栈顶写回 TCB,并取得新任务栈顶。
  3. 从新任务栈恢复 r4-r11,把更新后的栈顶写回 PSP
  4. CONTROL.SPSEL 置 1,让 Thread mode 使用 PSP。
  5. EXC_RETURN = 0xFFFFFFFD 异常返回,CPU 自动恢复硬件保存的 r0-r3/r12/lr/pc/xpsr

为什么只手工保存 r4-r11

因为 Cortex-M 在异常入口时已经自动帮你压了 r0-r3r12lrpcxpsr。RTOS 只需要补齐“硬件不帮你做的那一半”,这就是软件上下文和硬件上下文的分工。

七、这层实现现在已经解决了什么,还没解决什么

从当前代码状态看,port 层已经把最难啃的切换骨架搭起来了:

  1. 任务创建时能得到合法的初始栈帧。
  2. 首任务启动和普通任务切换共用 PendSV 主路径。
  3. 调度器和汇编切换逻辑之间有明确边界,靠 task_get_next()task_set_current() 协作。

但它也还停留在“最小可工作的切换链路”阶段。比如:

  1. 还没有接入 SysTick 去周期性调用 task_time_slice_tick()
  2. 还没有中断临界区、嵌套锁计数等更完整的调度保护机制。
  3. 阻塞、超时唤醒、队列/信号量等待链还没真正接到 PendSV 触发路径上。

不过对于一个正在演进中的 RTOS 来说,这已经是非常关键的一步。因为只要“能正确切”这件事成立,后面再加信号量、消息队列、延时唤醒,本质上都只是让更多事件源能够安全地把 g_next_task 改对,然后请求一次 PendSV 而已。

作者名片

Yukikaze
Yukikaze
@Yukikaze

私にできることなら何でもするから

评论区
文章作者和管理员都可以管理这里的评论。
1 条评论
登录后即可参与评论。 去登录
111111
程佳豪 @111111 2026-04-01 16:44
这家伙在说什么呢?