手写一个 RTOS
之前看 RTOS 的时候我总觉得它有点黑盒任务怎么切走又怎么回来为什么一个delay不会把 CPU 卡死PendSV、SVC这些异常名听起来也挺吓人。这份代码刚好适合把这些东西拆开。它不是官方 FreeRTOS 的移植版而是一个跑在 STM32F103C8 上的小 RTOSC 写大部分逻辑少量汇编负责上下文切换。功能不多但主线都有了任务创建、优先级调度、任务延时、任务删除、内存池还有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切换前会被塞回就绪队列尾部。这已经是一个小型抢占式调度器的样子了。代码没有做得很复杂但骨架是对的。真正切任务的是 PendSVRTOS 里最硬的部分在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 r140xFFFFFFFD的意思是返回线程模式并且使用 PSP。返回之后硬件继续从任务栈里恢复剩下那几个寄存器最后跳到任务函数。第一步就这么迈出去了。delay 为什么不会卡住 CPUtask_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 的底层动作其实很克制保存现场换一个任务恢复现场。剩下的复杂度都是为了让这个动作在更多场景下安全、稳定、可控。