前言
前面几篇文章主要记录了 Yuan RTOS 的通用内核逻辑,比如任务调度、软件定时器和 IPC。
不过 RTOS 还有一部分代码和具体处理器、编译器强相关,比如关中断、恢复中断、任务栈初始化、第一次启动任务、任务上下文切换等。这些代码不太可能完全用一套 C 代码跑在所有平台上,因此需要单独抽出移植层。
这篇文章记录一下 Yuan RTOS 的移植思路,以及当前 Cortex-M3 + GCC 版本移植层大概做了什么。
完整代码请访问我的Github仓库,网址为:yuan-rtos。
值得注意是本文的示例代码在你阅读时可能已经进行过修改了,具体实现以仓库中代码为主。
为什么需要移植层
RTOS 的通用逻辑可以尽量做到和平台无关。比如调度器只关心哪个任务应该运行,IPC 只关心任务如何阻塞和唤醒,软件定时器只关心 tick 计数和超时链表。
但是下面这些事情就很难平台无关:
- 如何关闭中断并保存原中断状态
- 如何恢复中断状态
- 任务首次运行前,栈帧应该长什么样
- 如何启动第一个任务
- 如何保存旧任务上下文
- 如何恢复新任务上下文
- 如何快速查找位图中最低位的 1
这些都和 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);
这些接口可以分成几类:
- 中断控制:
yr_irq_disable()和yr_irq_enable() - 初始栈帧构造:
yr_task_stack_init() - 任务启动与切换:
yr_task_first_switch_to()和yr_task_switch() - 位图查找:
yr_find_first_set()
只要某个平台实现了这些接口,内核通用部分理论上就可以运行起来。
目录组织
当前建议把移植层文件放在:
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;
其中:
r0:任务入口函数的第一个参数psr:设置 Thumb 位pc_r15:任务入口函数地址lr_r14:任务返回后的出口函数
这也是为什么创建任务时要传入 entry、param 和任务栈地址。
启动第一个任务
调度器找到第一个任务后,会调用:
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);
完成。
这里 from 和 to 同样是任务栈指针变量的地址。
在 Cortex-M 上,实际上下文切换放在 PendSV 中完成。yr_task_switch() 主要做三件事:
- 保存旧任务 SP 指针变量地址
- 保存新任务 SP 指针变量地址
- 触发 PendSV
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-r3、r12、lr、pc、xPSR,则利用 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 选项选择目标平台,只编译对应的移植层。
移植时的检查清单
移植到一个新平台时,可以按下面的顺序检查:
- 基础类型是否正确,例如
yr_uint32_t是否为 32 位 yr_irq_disable()是否能保存原中断状态并关闭中断yr_irq_enable()是否能恢复原中断状态,而不是简单打开中断- 任务栈是否满足目标 ABI 的对齐要求
- 初始栈帧是否能让任务第一次运行时进入
entry(param) - 任务返回时是否能进入
exit - 首任务启动后是否不再返回
main - 普通任务切换是否能正确保存和恢复上下文
- tick 中断频率是否和
YR_TICK_RATE_HZ一致 yr_find_first_set()是否符合返回值约定,没有置位时返回 0
这些问题中,最容易出错的一般是栈帧布局和异常返回。它们一旦写错,常见表现就是一启动调度就 HardFault,或者任务跑一会儿后栈被破坏。
总结
Yuan RTOS 的移植工作主要分成两部分:
- 实现
include/portable.h中规定的平台相关接口 - 提供符合
YR_TICK_RATE_HZ的系统 tick
当前 Cortex-M3 + GCC 版本利用了 Cortex-M 的 SVC、PendSV、PSP/MSP 和异常自动压栈机制,因此任务切换实现相对清晰。
如果移植到其它架构,最关键的仍然是两点:第一,要能构造任务第一次运行所需的初始上下文;第二,要能在任务切换时保存旧任务上下文并恢复新任务上下文。只要这两点可靠,调度器、定时器和 IPC 这些通用代码就有机会正常运行起来。