手写一个 RTOS
之前看 RTOS 的时候,我总觉得它有点黑盒:任务怎么切走,又怎么回来?为什么一个 delay 不会把 CPU 卡死?PendSV、SVC 这些异常名听起来也挺吓人。
这份代码刚好适合把这些东西拆开。它不是官方 FreeRTOS 的移植版,而是一个跑在 STM32F103C8 上的小 RTOS:C 写大部分逻辑,少量汇编负责上下文切换。功能不多,但主线都有了:任务创建、优先级调度、任务延时、任务删除、内存池,还有 SysTick + SVC + PendSV 这一套 Cortex-M 上常见的调度组合。
我按代码实际跑起来的顺序讲,不从概念堆起。
工程github链接:ZiYu-Coder/-RTOS: 了解RTOS的任务创建,切换,删除,SVC ,PendSV ,寄存器操作,上下文
这个工程在做什么
目录里主要有四块:
Start STM32 启动文件、异常向量表、系统初始化 Library STM32F10x 标准外设库 User main.c、串口初始化、测试任务 RTOS 自己写的内核
真正有意思的是 RTOS 目录:
config.h tick 频率、优先级数量、内存池大小 memory.c 简单内存池 task.c 任务创建、就绪队列、延时队列、任务切换 rtos.c RTOS 初始化、SysTick、SVC、PendSV、临界区
main.c 里做的事很少:
USER_USART1_INIT(); rtos_init(); test1_tcb = create_task(test1, 10, 300, NULL); test2_tcb = create_task(test2, 10, 500, NULL); start_scheduler();
串口初始化之后,先初始化内核,再创建任务,最后启动调度器。启动调度器之后,main 基本就不再回来了,CPU 开始在几个任务之间来回切。
任务不是函数,是一段能恢复的现场
应用里的任务函数看起来很普通:
void test1(void *arg)
{
int counter = 0;
while (1)
{
printf("test1:%d\r\n", counter++);
task_delay(500);
}
}
如果裸机里直接调用它,程序会永远卡在这个 while (1)。RTOS 不这么调用任务。它会给每个任务分配一块独立的栈,然后用 TCB 记住这块栈当前停在哪里。
这个工程里的 TCB 很朴素:
typedef struct task_control_block
{
void *stack_top;
void *stack_start;
uint32_t stack_size;
uint32_t priority;
enum task_state state;
} TCB;
stack_top 是最关键的字段。任务切走时,把 CPU 寄存器压到这个任务自己的栈里,然后更新 stack_top。任务切回来时,再从 stack_top 把寄存器恢复出来。
所以任务的本质不是“一个函数正在运行”,而是“一份以后还能继续恢复的 CPU 现场”。
create_task 最巧的地方
create_task() 不只是申请内存、填结构体。它还会提前在任务栈里摆好一份假的异常现场。
Cortex-M3 进入异常时,硬件会自动保存这些寄存器:
r0 r1 r2 r3 r12 LR PC xPSR
但 r4-r11 要软件自己保存。所以代码里定义了一个完整的栈帧:
typedef struct stack_frame
{
uint32_t r4;
uint32_t r5;
uint32_t r6;
uint32_t r7;
uint32_t r8;
uint32_t r9;
uint32_t r10;
uint32_t r11;
uint32_t r0;
uint32_t r1;
uint32_t r2;
uint32_t r3;
uint32_t r12;
uint32_t LR;
uint32_t PC;
uint32_t xPSR;
} stack_frame;
创建任务时,代码把 PC 填成任务函数地址,把 r0 填成参数,把 xPSR 的 Thumb 位设好:
stack->r0 = (uint32_t)arg; stack->PC = (uint32_t)task_name & ((uint32_t)0xfffffffeUL); stack->xPSR = 0x01000000;
这样第一次调度这个任务时,CPU 会以为自己只是从异常里返回。返回之后,PC 指向任务函数,于是任务就开始跑了。
这招挺漂亮。它没有真的“调用”任务函数,而是伪造出一副“这个任务之前已经运行过,现在要恢复”的样子。
调度器怎么挑任务
就绪任务放在 ready_queue 里:
tcb_node ready_queue[MAX_PRIORITY];
它是一个数组,每个优先级对应一条链表。任务变成就绪态时,会插到自己优先级链表的尾部。调度时,task_switch() 从高优先级往低优先级扫,找到第一个非空链表,取出队头任务运行。
这个策略很容易理解:高优先级任务先跑;同优先级任务按链表顺序轮流跑;当前任务如果还是 RUNNING,切换前会被塞回就绪队列尾部。
这已经是一个小型抢占式调度器的样子了。代码没有做得很复杂,但骨架是对的。
真正切任务的是 PendSV
RTOS 里最硬的部分在 PendSV_Handler()。
很多 RTOS 都喜欢用 PendSV 做任务切换,因为它可以被软件挂起,而且优先级通常设得很低。这样 SysTick 或任务 API 只需要“请求一次切换”,真正的切换可以等其他中断处理完再做。
这个工程里的 PendSV_Handler() 大概是这个流程:
读当前 PSP 保存 r4-r11 到当前任务栈 把新的栈顶写回 current_tcb 调用 task_switch 选出下一个任务 从新任务栈恢复 r4-r11 更新 PSP 异常返回
代码里只手动保存 r4-r11,不是忘了别的寄存器,而是 Cortex-M3 在进入异常时已经自动保存了 r0-r3、r12、LR、PC、xPSR。
这就是上下文切换最朴素的样子:保存旧现场,换一个 TCB,恢复新现场。
第一个任务靠 SVC 启动
普通任务切换可以用 PendSV,但第一次启动调度器时还没有“旧任务”可保存,所以代码用了 SVC。
start_scheduler() 里最后执行:
svc 0
进入 SVC_Handler() 后,它从 current_tcb 找到第一个任务的栈顶,恢复 r4-r11,设置 PSP,然后用特殊的异常返回值回到线程模式:
ldr r3, = current_tcb
ldr r1, [r3]
ldr r0, [r1]
ldmia r0!, {r4-r11}
msr psp, r0
orr r14, #0xFFFFFFFD
bx r14
0xFFFFFFFD 的意思是:返回线程模式,并且使用 PSP。返回之后,硬件继续从任务栈里恢复剩下那几个寄存器,最后跳到任务函数。
第一步就这么迈出去了。
delay 为什么不会卡住 CPU
task_delay(500) 不是简单循环等 500ms。它会把当前任务放进延时队列,然后触发一次任务切换:
计算 wake_time 插入延时队列 当前任务改成 BLOCKED 挂起 PendSV
系统时间来自 SysTick。配置里写的是:
#define configSYSTICK_CLOCK_HZ 72000000 #define configTICK_RATE_HZ 1000
也就是 1ms 进一次 SysTick_Handler()。每次 tick 到来,time_counter++,然后检查延时队列。如果某个任务到时间了,就把它重新放回就绪队列。
这个设计比裸机 delay 好很多。任务睡觉的时候,CPU 可以去跑别的任务;时间到了,再把它叫回来。
代码里还有一个处理 tick 溢出的设计:delay_queue 和 overtime_queue 两条链表。time_counter 溢出回 0 时,两条队列交换。这个细节挺认真,说明作者不是只写了一个能跑 demo 的死循环。
内存池够简单,也够说明问题
任务栈来自 memory.c 里的静态内存池:
uint8_t memory[memory_size];
memory_size 配在 config.h 里,现在是 2048 字节。空闲块用双向链表维护,申请时用 best-fit 找一块最合适的内存:
遍历空闲链表 找能放下请求大小、并且最小的块 如果刚好一样大,就整块取走 如果更大,就切一段出来
释放时会尝试和前后相邻的空闲块合并,减少碎片。
这不是工业级内存管理,但对这个 RTOS 来说够用了:任务可以动态创建,也可以删除后释放栈空间。
main 里的 demo 跑出了什么
main.c 创建了两个任务:
test1_tcb = create_task(test1, 10, 300, NULL); test2_tcb = create_task(test2, 10, 500, NULL);
test1 每 500ms 打印一次。test2 每 1000ms 打印一次,而且计数到 10 的时候删除 test1,计数到 20 的时候创建 test3。
这其实是在演示四件事:多任务交替运行、task_delay() 让任务进入阻塞态、任务动态删除、系统运行后再创建新任务。
用串口打印看起来很简单,但对一个手写 RTOS 来说,这已经把主要链路都跑通了。
代码里几个小坑
这份代码很适合学习,但也有几个地方真要跑长期任务的话需要修一下。
task_switch() 里从 MAX_PRIORITY 开始访问 ready_queue[index],但数组定义是:
tcb_node ready_queue[MAX_PRIORITY];
如果 MAX_PRIORITY 是 20,合法下标其实是 0-19。从 20 开始扫会越界。
还有 SysTick_Handler() 里,延时节点 free(head) 之后又用了 tmp = tmp->next。这属于释放后继续访问,比较危险。更稳的写法是删除节点后重新从 delay_queue->next 取下一个。
另外,优先级参数没有范围检查,传错了也可能访问越界。内核里还混用了自定义内存池和 C 库 malloc/free,做实验没问题,想写得更干净的话最好统一一下。
写完之后再看 RTOS
这份工程最大的价值,不是功能多,而是它把 RTOS 的骨架露出来了。
任务不是玄学,就是一块栈加一个 TCB。调度不是玄学,就是从就绪队列里挑下一个任务。上下文切换也不是玄学,就是保存寄存器、换栈、恢复寄存器。
真正让人一下子想明白的是这条链路:
main 创建任务 SVC 启动第一个任务 SysTick 提供系统节拍 task_delay 把任务挂起 PendSV 完成上下文切换 到期任务重新回到就绪队列
理解了这条线,再回头看 FreeRTOS 这种成熟系统,很多原本看起来很吓人的宏、链表和汇编,就会慢慢变成熟人。
一个 RTOS 的底层动作其实很克制:保存现场,换一个任务,恢复现场。剩下的复杂度,都是为了让这个动作在更多场景下安全、稳定、可控。
更多推荐

所有评论(0)