news 2026/7/9 16:20:33

手写一个 RTOS

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写一个 RTOS

之前看 RTOS 的时候,我总觉得它有点黑盒:任务怎么切走,又怎么回来?为什么一个delay不会把 CPU 卡死?PendSVSVC这些异常名听起来也挺吓人。

这份代码刚好适合把这些东西拆开。它不是官方 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_queueovertime_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 的底层动作其实很克制:保存现场,换一个任务,恢复现场。剩下的复杂度,都是为了让这个动作在更多场景下安全、稳定、可控。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/9 16:19:26

3步解放网课时间:AutoUnipus智能答题工具的颠覆性体验

3步解放网课时间:AutoUnipus智能答题工具的颠覆性体验 【免费下载链接】AutoUnipus U校园脚本,支持全自动答题,百分百正确 2024最新版 项目地址: https://gitcode.com/gh_mirrors/au/AutoUnipus 你是否也曾面临这样的困境?深夜十一点,…

作者头像 李华
网站建设 2026/7/9 16:18:33

Milvus 源码学习系列 | 第 20 章:删除、TTL、可见性与 Compaction

上一章我们沿着 Search 请求一路进入 QueryNode 和 C++ segcore,重点看了单个 Segment 内部如何执行过滤、向量检索和结果构造。 在第 19 章结尾,我们留下了一个更隐蔽的问题: 如果一条数据已经被删除,或者已经因为 TTL 过期, 为什么 Search / Query 不会再看到它?直觉…

作者头像 李华
网站建设 2026/7/9 16:13:49

SpringMVC前后端分离跨域问题解决方案

问题因为最近在学习vue和springboot.用到了前后端分离.前端webpack打包运行的时候会启动nodejs的服务器占用8080端口,后端springboot自带tomcat启动占用1111端口(我自己设置的)...导致前端请求的ajax到后台会产生跨域问题...然后自己试了试发现有2种办法都可以解决.利用SpringM…

作者头像 李华
网站建设 2026/7/9 16:12:33

.NET数据可视化实战:如何用OxyPlot高效构建专业级图表

.NET数据可视化实战:如何用OxyPlot高效构建专业级图表 【免费下载链接】oxyplot A cross-platform plotting library for .NET 项目地址: https://gitcode.com/gh_mirrors/ox/oxyplot 在.NET开发中,数据可视化常常面临平台限制和功能单一的困境。…

作者头像 李华