RTOS开发学习记录(9):移植

成元 2026-04-22 { 教程, 笔记 } [ RTOS ]

前言

前面几篇文章主要记录了 Yuan RTOS 的通用内核逻辑,比如任务调度、软件定时器和 IPC。

不过 RTOS 还有一部分代码和具体处理器、编译器强相关,比如关中断、恢复中断、任务栈初始化、第一次启动任务、任务上下文切换等。这些代码不太可能完全用一套 C 代码跑在所有平台上,因此需要单独抽出移植层。

这篇文章记录一下 Yuan RTOS 的移植思路,以及当前 Cortex-M3 + GCC 版本移植层大概做了什么。

完整代码请访问我的Github仓库,网址为:yuan-rtos

值得注意是本文的示例代码在你阅读时可能已经进行过修改了,具体实现以仓库中代码为主。

为什么需要移植层

RTOS 的通用逻辑可以尽量做到和平台无关。比如调度器只关心哪个任务应该运行,IPC 只关心任务如何阻塞和唤醒,软件定时器只关心 tick 计数和超时链表。

但是下面这些事情就很难平台无关:

这些都和 ISA、异常机制、ABI、编译器扩展有关,因此 Yuan RTOS 把它们放到了 include/portable.h 中作为接口,再由具体平台实现。

移植层接口

当前移植层接口主要如下:

yr_uint32_t yr_irq_disable(void);
void yr_irq_enable(yr_uint32_t disirq);

yr_uint8_t *yr_task_stack_init( void *entry,
                                void *exit,
                                void *param,
                                yr_uint8_t *stackaddr);

void yr_task_first_switch_to( yr_uint32_t to);
void yr_task_switch( yr_uint32_t from, yr_uint32_t to);

int yr_find_first_set(int value);

这些接口可以分成几类:

只要某个平台实现了这些接口,内核通用部分理论上就可以运行起来。

目录组织

当前建议把移植层文件放在:

libcpu/<ISA>/port_<compiler>.c
libcpu/<ISA>/port_<compiler>.s

例如当前 Cortex-M3 + GCC 的实现为:

libcpu/CM3/port_gcc.c
libcpu/CM3/port_gcc.s

这样做的好处是目录结构比较直观。看到 CM3 就知道是 Cortex-M3 相关代码,看到 gcc 就知道汇编语法和内建函数是按 GCC 来写的。

如果后续要支持别的平台,可以新增类似:

libcpu/RISCV/port_gcc.c
libcpu/RISCV/port_gcc.s

当然,具体目录名不一定非要这样,但最好能同时体现 ISA 和编译器。

中断开关

内核中很多地方需要进入临界区,例如调度器修改就绪队列、IPC 修改阻塞队列、定时器修改链表等。

当前 Cortex-M3 版本使用 PRIMASK 实现关中断和恢复中断:

@ yr_uint32_t yr_irq_disable(void)
yr_irq_disable:    
    mrs     r0, PRIMASK
    cpsid   i
    bx      lr

@ void yr_irq_enable(yr_uint32_t disirq)
yr_irq_enable:    
    msr     PRIMASK, r0
    bx      lr

yr_irq_disable() 不是简单关中断,而是先读取当前 PRIMASK 并返回。之后 yr_irq_enable() 再把原来的值写回去。

这样做的好处是支持嵌套临界区。比如外层本来已经关中断,内层再次调用 yr_irq_disable(),最后恢复时不会错误地把外层关掉的中断打开。

初始化任务栈

任务第一次运行时,不可能真的从某个“之前被切走的位置”恢复,因为它从来没有运行过。因此需要提前伪造一个上下文栈帧,让第一次切换到该任务时,看起来就像它刚刚从异常中返回一样。

当前 Cortex-M3 版本定义了一个上下文结构:

typedef struct yr_context_t {
    yr_cpu_stack_t r4;
    yr_cpu_stack_t r5;
    yr_cpu_stack_t r6;
    yr_cpu_stack_t r7;
    yr_cpu_stack_t r8;
    yr_cpu_stack_t r9;
    yr_cpu_stack_t r10;
    yr_cpu_stack_t r11;

    yr_cpu_stack_t r0;
    yr_cpu_stack_t r1;
    yr_cpu_stack_t r2;
    yr_cpu_stack_t r3;
    yr_cpu_stack_t r12;
    yr_cpu_stack_t lr_r14;
    yr_cpu_stack_t pc_r15;
    yr_cpu_stack_t psr;
} yr_context_t;

前半部分是软件手动保存和恢复的 r4-r11,后半部分是 Cortex-M 异常返回时硬件会自动恢复的寄存器。

初始化任务栈时,先做 8 字节对齐,再预留一个 yr_context_t 大小的空间:

psp = (yr_uint8_t *)( ((yr_cpu_stack_t)psp) & ~((8) - 1) );  
psp -= sizeof(yr_context_t);
pstack = (yr_context_t *)psp;

随后设置几个关键寄存器:

pstack->r0 = (yr_cpu_stack_t)param;
pstack->psr = 0x01000000UL;
pstack->pc_r15  = (yr_cpu_stack_t)entry;
pstack->lr_r14  = (yr_cpu_stack_t)exit;

其中:

这也是为什么创建任务时要传入 entryparam 和任务栈地址。

启动第一个任务

调度器找到第一个任务后,会调用:

yr_task_first_switch_to( (yr_uint32_t)&next_task->sp);

注意这里传入的是任务栈指针变量的地址,而不是栈指针本身。这样汇编代码就可以根据这个地址取到任务当前保存的 SP。

当前 Cortex-M3 + GCC 版本使用 SVC 启动第一个任务:

yr_task_first_switch_to:
    ldr r1, =NVIC_SYSPRI2
    ldr r2, [r1]
    ldr r3, =NVIC_PENDSV_PRI
    orr r2, r2, r3
    str r2, [r1]
    cpsie i
    svc #0

这里先把 PendSV 配成最低优先级,然后打开中断,最后触发 SVC。

SVC Handler 中会把第一个任务的栈帧恢复到 PSP,并通过异常返回进入 Thread mode:

SVC_Handler:
    ldr r1, [r0]
    ldmia r1!, {r4-r11}
    msr psp, r1

    ldr r0, =0xFFFFFFFD
    bx  r0

0xFFFFFFFD 是 Cortex-M 的一种 EXC_RETURN 值,表示返回 Thread mode,并使用 PSP。

从这个时刻开始,任务代码就真正跑起来了。

任务切换

普通任务切换由:

void yr_task_switch( yr_uint32_t from, yr_uint32_t to);

完成。

这里 fromto 同样是任务栈指针变量的地址。

在 Cortex-M 上,实际上下文切换放在 PendSV 中完成。yr_task_switch() 主要做三件事:

ldr r2, =yr_prev_task_sp_p
str r0, [r2]

ldr r2, =yr_next_task_sp_p
str r1, [r2]

ldr r2, =NVIC_INT_CTRL
ldr r3, =NVIC_PENDSVSET
str r3, [r2]

真正的保存与恢复在 PendSV_Handler 中完成:

mrs     r1, psp
stmdb   r1!, {r4-r11}
ldr     r0, [r0]
str     r1, [r0]

ldr     r1, =yr_next_task_sp_p
ldr     r1, [r1]
ldr     r1, [r1]

ldmia   r1!, {r4-r11}
msr     psp, r1

旧任务的 r4-r11 被压入旧任务栈,新任务的 r4-r11 从新任务栈中恢复。至于 r0-r3r12lrpcxPSR,则利用 Cortex-M 异常进入和异常返回机制处理。

这也是 Cortex-M 做 RTOS 移植比较方便的地方。

位图查找

调度器中使用了优先级位图,需要快速找到最低位的 1。这个能力也放在移植层:

int yr_find_first_set(int value)
{
    return __builtin_ffs(value);
}

当前 GCC 版本直接使用 __builtin_ffs()。如果换成其它编译器或其它 ISA,可以改成对应的内建函数或汇编指令。

这一点在任务调度那篇文章中已经提到过:O(1) 调度不只是因为用了位图,还依赖于能快速找到位图中最低位的 1。

提供系统 tick

除了实现 portable.h 中的接口,还需要给 RTOS 提供一个周期性的系统 tick。

当前配置中默认 tick 频率为:

#define YR_TICK_RATE_HZ (1000)

在 STM32 的 BSP 中,一般可以在 SysTick_Handler() 中调用:

void SysTick_Handler(void)
{
    HAL_IncTick();
    yr_tick_update();
}

yr_tick_update() 会递增系统 tick,处理当前任务时间片,并检查软件定时器超时。

因此移植到新平台时,一定要确认 tick 中断频率和 YR_TICK_RATE_HZ 一致。否则任务延时、周期延时、IPC 超时都会不准确。

接入构建系统

当前根目录 CMakeLists.txt 会收集 src/libcpu/ 下的源文件:

file(GLOB_RECURSE YUAN_RTOS_SOURCES CONFIGURE_DEPENDS
    "${CMAKE_CURRENT_SOURCE_DIR}/src/*.c"
    "${CMAKE_CURRENT_SOURCE_DIR}/src/*.s"
    "${CMAKE_CURRENT_SOURCE_DIR}/src/*.S"
    "${CMAKE_CURRENT_SOURCE_DIR}/libcpu/*.c"
    "${CMAKE_CURRENT_SOURCE_DIR}/libcpu/*.s"
    "${CMAKE_CURRENT_SOURCE_DIR}/libcpu/*.S"
)

这对当前项目来说比较方便,因为新加的 libcpu/<ISA>/port_<compiler>.c.s 会自动被加入构建。

不过如果后续同时存在多个平台的移植层,就不能简单地把 libcpu/ 下所有文件都编进去。否则可能会出现多个平台都定义了 yr_task_switch()SVC_Handler()PendSV_Handler() 等符号。

后续更稳妥的做法是通过 CMake 选项选择目标平台,只编译对应的移植层。

移植时的检查清单

移植到一个新平台时,可以按下面的顺序检查:

  1. 基础类型是否正确,例如 yr_uint32_t 是否为 32 位
  2. yr_irq_disable() 是否能保存原中断状态并关闭中断
  3. yr_irq_enable() 是否能恢复原中断状态,而不是简单打开中断
  4. 任务栈是否满足目标 ABI 的对齐要求
  5. 初始栈帧是否能让任务第一次运行时进入 entry(param)
  6. 任务返回时是否能进入 exit
  7. 首任务启动后是否不再返回 main
  8. 普通任务切换是否能正确保存和恢复上下文
  9. tick 中断频率是否和 YR_TICK_RATE_HZ 一致
  10. yr_find_first_set() 是否符合返回值约定,没有置位时返回 0

这些问题中,最容易出错的一般是栈帧布局和异常返回。它们一旦写错,常见表现就是一启动调度就 HardFault,或者任务跑一会儿后栈被破坏。

总结

Yuan RTOS 的移植工作主要分成两部分:

当前 Cortex-M3 + GCC 版本利用了 Cortex-M 的 SVC、PendSV、PSP/MSP 和异常自动压栈机制,因此任务切换实现相对清晰。

如果移植到其它架构,最关键的仍然是两点:第一,要能构造任务第一次运行所需的初始上下文;第二,要能在任务切换时保存旧任务上下文并恢复新任务上下文。只要这两点可靠,调度器、定时器和 IPC 这些通用代码就有机会正常运行起来。