
RTOS内核开发实战(4):Cortex-M3端口层,初始栈帧、PendSV与首任务启动
当链表、TCB、ready queue 和调度器基本站稳之后,RTOS 才真正来到一个分水岭:前面的代码都还在“决定谁该运行”,而 port 层要解决的是另一件更硬的问题,怎么让 CPU 真的切到那个任务去执行。
当前仓库里,这部分工作集中在 RTOS/portable/os_port_cortex_m3.c。
它做了三件核心事情:
- 按 Cortex-M 异常返回规则伪造任务初始栈帧。
- 使用 PendSV 作为上下文切换入口。
- 在首任务启动时,从 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;
}
这里的设计点很典型:
- 栈顶先按
OS_TASK_STACK_ALIGNMENT_BYTES对齐,保证满足 Cortex-M3 异常栈帧要求。 xPSR里的 T 位必须为 1,也就是0x01000000,否则 CPU 不会按 Thumb 状态执行任务入口。PC直接放任务入口地址,R0放任务参数。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
}
这段汇编的几个动作连起来看,就很清楚了:
- 先配置 PendSV 优先级。
- 关中断,避免 MSP 重置过程被打断。
- 把 MSP 恢复到链接脚本给出的
_estack,这样main -> os_kernel_start -> os_port_start_first_task这条启动调用链就不会继续占用中断栈。 - 触发 PendSV,请求走统一的上下文切换入口。
- 开中断,等待 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;
}
这一步非常关键。
它没有碰寄存器保存细节,而是专注处理调度器关心的语义:
- 普通切换时,把保存完
r4-r11之后的 PSP 写回旧任务 TCB。 - 读取调度器已经选好的
g_next_task。 - 通过
task_set_current()把next正式提交成current。 - 返回新任务的软件栈顶,交给汇编去恢复寄存器。
六、汇编入口真正做了哪些现场操作
再看汇编入口:
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 的标准上下文切换模型:
- 如果当前已经有任务在跑,就从
PSP取出线程栈,并手工保存r4-r11。 - 调用
os_port_switch_context(),把旧任务栈顶写回 TCB,并取得新任务栈顶。 - 从新任务栈恢复
r4-r11,把更新后的栈顶写回PSP。 - 把
CONTROL.SPSEL置 1,让 Thread mode 使用 PSP。 - 用
EXC_RETURN = 0xFFFFFFFD异常返回,CPU 自动恢复硬件保存的r0-r3/r12/lr/pc/xpsr。
为什么只手工保存 r4-r11?
因为 Cortex-M 在异常入口时已经自动帮你压了 r0-r3、r12、lr、pc 和 xpsr。RTOS 只需要补齐“硬件不帮你做的那一半”,这就是软件上下文和硬件上下文的分工。
七、这层实现现在已经解决了什么,还没解决什么
从当前代码状态看,port 层已经把最难啃的切换骨架搭起来了:
- 任务创建时能得到合法的初始栈帧。
- 首任务启动和普通任务切换共用 PendSV 主路径。
- 调度器和汇编切换逻辑之间有明确边界,靠
task_get_next()和task_set_current()协作。
但它也还停留在“最小可工作的切换链路”阶段。比如:
- 还没有接入 SysTick 去周期性调用
task_time_slice_tick()。 - 还没有中断临界区、嵌套锁计数等更完整的调度保护机制。
- 阻塞、超时唤醒、队列/信号量等待链还没真正接到 PendSV 触发路径上。
不过对于一个正在演进中的 RTOS 来说,这已经是非常关键的一步。因为只要“能正确切”这件事成立,后面再加信号量、消息队列、延时唤醒,本质上都只是让更多事件源能够安全地把 g_next_task 改对,然后请求一次 PendSV 而已。