之前看 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 的底层动作其实很克制:保存现场,换一个任务,恢复现场。剩下的复杂度,都是为了让这个动作在更多场景下安全、稳定、可控。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐