前段时间给自己定了个目标:不用 FreeRTOS,不抄 uC/OS,完全从零写一个能跑在 STM32 上的 RTOS 内核。断断续续折腾了两周,最后调度器、延时、信号量、队列全加起来,C 代码加汇编不到 700 行,跑在 STM32F103 上能稳定切换三个任务,串口日志不乱、延时准确、信号量同步也没问题。这篇文章就把整个过程最值钱的 5 个坑整理出来,核心代码都会贴,适合那些对 RTOS 理解还停留在“调用 API”层面、想真正搞懂调度器底层原理的人,也适合准备把手写内核当面试练习的工程师。我不会花太多篇幅讲“什么是任务”这种基础,直接上硬货,但关键的原理我会顺手解释清楚。
1. 700行内核的完整设计蓝图
1.1 一个“最小可用”内核需要哪些模块
很多人一提到 RTOS 就想到一堆组件:任务管理、内存池、软件定时器、消息队列、互斥量、事件标志组、信号量、信号、CPU 利用率统计……但如果目标只是“能在裸机上跑多任务”,最核心的部分其实只有两块:
- 任务管理和上下文切换:决定哪个任务该跑、怎么从一个任务切到另一个任务。
- tick 时基:提供一个稳定的时间基准,让任务可以延时、让调度器可以周期性触发。
在这个基础上再加两个非常常用的同步机制,就是信号量和消息队列。信号量解决“任务之间谁等谁”的问题,消息队列解决“任务之间怎么传数据”的问题。有了这四样,一个嵌入式项目里 90% 的多任务需求都能覆盖了,剩下那 10% 是互斥量优先级继承、软件定时器、事件组这些锦上添花的东西。
我给自己定的限制是:内核代码量控制在 700 行左右,不依赖任何第三方库,只用 ARM Cortex-M3 的 CMSIS 头文件,编译不加优化也能稳定跑。写的过程中刻意砍掉了所有非必要功能,比如互斥量只做信号量的变体,软件定时器先不加,内存管理直接用静态数组。这样做的最大好处是每一行代码都能解释清楚,出了问题能一眼看穿。
1.2 代码结构:文件划分与数据流
整个工程分成三类文件:内核文件、移植文件、板级文件。内核文件完全跟芯片无关,理论上换一个 Cortex-M 内核的芯片,只需要改移植文件。
我最后的文件结构大概是这样:
| 文件 | 作用 | 主要代码量 |
|---|---|---|
| kernel.h | 内核对外 API、数据类型、TCB 结构定义 | 约 80 行 |
| kernel.c | 任务创建、调度器启动、延时列表维护 | 约 250 行 |
| sem.c | 信号量实现 | 约 100 行 |
| queue.c | 消息队列实现 | 约 120 行 |
| port.c + port.s | 上下文切换汇编、SysTick/PendSV 处理 | 约 120 行 |
| main.c | 测试用例、板级初始化 | 约 100 行 |
数据流说起来很简单:应用程序通过task_create创建任务,通过task_delay主动让出 CPU,通过sem_wait/sem_signal做同步;SysTick 每 1ms 触发一次中断,更新时基并检查延时任务;如果发现有更该跑的任务,就置位 PendSV 异常,真正的任务切换在 PendSV 处理函数里完成。所有需要改任务状态的操作都在临界区保护下进行,这是整个内核代码看起来最“绕”但其实最核心的逻辑。
1.3 为什么不直接抄 FreeRTOS,而要自己写一遍
这可能是很多人想问的问题。FreeRTOS 很成熟,CMSIS-RTOS 封装也好用,直接调 API 不香吗?为什么非要自己造轮子?
我的观点是:造轮子的目的不是替代轮子,而是理解轮子。如果你只是想要量产级的内核,直接上 FreeRTOS 绝对是对的,别自己写。但如果你的目标是搞懂“任务切换到底是怎么发生的”“信号量底层是怎么等待和唤醒的”,那读十遍源码不如自己写一遍。很多细节,比如初始任务栈帧怎么伪造、PendSV 为什么要放在最低优先级、临界区为什么不能简单地关中断,这些在官方文档里都是一笔带过,只有亲手踩过坑才会刻进脑子里。
另外,手写一个微型内核还有一个附加价值:当你在一个资源很紧的 MCU 上做极简任务调度时,未必需要完整版 RTOS,一个 700 行的微型内核可能比 FreeRTOS 省一半 Flash。我这次跑在 STM32F103C8T6 上,内核占用的 ROM 只有 4KB 出头,RAM 占用主要是任务栈,这个开销在很多老项目里是可接受的。
2. 任务机制与上下文切换实现细节
2.1 TCB 与任务栈布局
任务控制块(TCB)是内核里最重要的数据结构,每一个任务对应一个 TCB。我的设计里 TCB 长这样:
typedef struct tcb { uint32_t *sp; // 任务栈指针,切换到该任务时从这里恢复现场 uint8_t prio; // 优先级,数字越小优先级越高 uint8_t state; // 任务状态:READY / DELAYED / BLOCKED uint32_t delay_ticks; // 延时剩余的 tick 数 void (*entry)(void *arg); // 任务入口函数 void *arg; // 入口函数参数 struct tcb *next; // 链表指针,用于就绪链表或延时链表 uint32_t stack_magic; // 栈溢出检测标记 uint32_t *stack_start; // 栈底地址 uint32_t stack_size; // 栈大小(字节) } tcb_t;任务栈我用的是静态数组:
#define TASK_STACK_SIZE 256 uint32_t task1_stack[TASK_STACK_SIZE] __attribute__((aligned(8))); uint32_t task2_stack[TASK_STACK_SIZE] __attribute__((aligned(8)));这里aligned(8)很重要,坑 5 会详细讲。栈是向下增长的,所以初始栈指针指向数组末尾,也就是高地址。任务栈里除了任务的局部变量,还会存放上下文切换时保存的寄存器现场。因为 Cortex-M3 进入异常时会由硬件自动压入 8 个寄存器的异常帧,再加上我们手动压入的 R4-R11,一个任务的完整上下文一共是 16 个字(64 字节)。
2.2 创建任务时怎么“伪造”现场
创建任务最核心的操作是:把任务第一次运行时的状态伪造出来,让调度器切到它的时候能像一个“刚刚被中断的任务”一样自然恢复。这里的关键是理解 Cortex-M3 的异常帧布局。
Cortex-M3 进入异常时,硬件会自动把 R0、R1、R2、R3、R12、LR、PC、xPSR 这 8 个寄存器按固定顺序压栈,然后从异常返回时再原样弹出。如果我们希望任务第一次运行时从 entry 函数开始,就要预先在任务栈里按同样的顺序放好这些值。
我创建任务时的核心代码大致是这样:
int task_create(tcb_t *task, void (*entry)(void *), uint8_t prio, uint32_t *stack_top, uint32_t stack_size) { uint32_t *sp = stack_top; // 栈指针按 8 字节对齐,AAPCS 要求 sp = (uint32_t *)(((uint32_t)sp) & ~0x07u); // 硬件异常帧:xPSR, PC, LR, R12, R3, R2, R1, R0 *--sp = 0x01000000; // xPSR,bit24 必须为 1,表示 Thumb 状态 *--sp = (uint32_t)entry; // PC,任务入口 *--sp = 0; // LR *--sp = 0; // R12 *--sp = 0; // R3 *--sp = 0; // R2 *--sp = 0; // R1 *--sp = 0; // R0 // 手动保存寄存器:R4 - R11 for (int i = 0; i < 8; i++) { *--sp = 0; } task->sp = sp; task->prio = prio; task->state = TASK_READY; task->delay_ticks = 0; // 加入就绪链表,按优先级排序 insert_ready_task(task); return 0; }第一次上下文切换时,我们从task->sp里弹回 R4-R11,再把 PSP 指向异常帧起始位置,从异常返回时硬件自动弹出 xPSR、PC 等寄存器,任务就从 entry 开始跑起来了。这个“伪造现场”的思路是手写 RTOS 最核心的魔法,搞懂它,后面一切都顺了。
2.3 PendSV 切换流程
上下文切换我用的是 PendSV 异常,这是 Cortex-M 处理器专门为操作系统切换上下文准备的异常,有两个特点:可挂起,且优先级可编程到最低。
为什么不能直接在函数里切换?因为任务切换必须中断掉当前任务的正常流,执行切换代码,再让另一个任务从自己上次断掉的地方继续。如果我们在一个普通函数里切换,这个函数本身就是当前任务的一部分,栈可能都被局部变量占着,切出去再切回来容易乱。用 PendSV 就干净了:它是异常,进入异常时硬件自动保存当前任务的现场,退出异常时硬件自动恢复另一个任务的现场,切换变得非常规整。
我的 PendSV 处理函数是汇编写的:
PendSV_Handler: MRS r0, PSP ; 取当前任务栈指针 LDR r1, =current_tcb LDR r1, [r1] ; r1 = 当前任务 TCB CBZ r1, PendSV_NoSave ; 首次调度时 current_tcb 为空,不保存现场 STMDB r0!, {r4-r11} ; 手动压入 R4-R11(硬件已压了异常帧) STR r0, [r1] ; 保存 sp 到当前 TCB PendSV_NoSave: BL schedule_next ; 选择下一个要运行的任务 LDR r1, =next_tcb LDR r1, [r1] ; r1 = 新任务 TCB LDR r2, =current_tcb STR r1, [r2] ; current_tcb = next_tcb LDR r0, [r1] ; r0 = 新任务 TCB->sp LDMIA r0!, {r4-r11} ; 弹出新任务的 R4-R11 MSR PSP, r0 ; PSP 指向异常帧 BX LR ; 异常返回,硬件弹出异常帧,任务开始运行这段汇编的核心逻辑是“保存当前现场 -> 选下一个任务 -> 恢复新任务现场 -> 退出异常”。looks 简单,但里面至少藏了三个容易出错的地方:第一次切换不能保存现场、R4-R11 和异常帧的顺序、以及 PSP 必须指向正确的位置。这几个点后面坑 1、坑 2 都会展开。
2.4 SysTick 和调度触发时机
SysTick 就是一个周期中断源,我给它的中断周期设为 1ms,作为系统时基。每次进入 SysTick 中断,我要做三件事:
- 更新全局 tick 计数器。
- 遍历延时链表,看有没有任务延时到期,到期就移到就绪链表。
- 检查是否需要触发任务切换:如果有更高优先级的任务因延时到期进入了就绪态,则置位 PendSV 的挂起位。
讲一下为什么 SysTick 不能直接做切换。因为 SysTick 中断本身也是一个异常,它执行的时候,当前任务现场已经被硬件压栈了,理论上确实可以在这里切换。但问题在于:SysTick 里调用的代码如果很长,会拉高中断延迟,影响其他中断响应。而且实际项目中,很多中断里也会触发调度(比如串口接收中断唤醒一个任务)、如果在 SysTick 里直接切,那中断里触发的调度就不好统一处理了。所以标准做法是:SysTick 里只标记“需要调度”,然后置位 PendSV,等所有高优先级中断处理完后,由最低优先级的 PendSV 统一执行切换。这样就能保证切换时系统处于最稳定的状态,不会跟其他中断的现场打架。
SysTick 中断处理里还有一个细节:并不是每次 tick 都必须切换,只有“更高优先级任务就绪”时才需要。为了简单,我的第一个版本是每个 tick 都强制切换,后来发现这会导致同优先级任务之间没有意义的乒乓球式切换,白白浪费 CPU。改成只有高优先级任务就绪才切换之后,整个系统安静了很多。
3. 5个坑,每一个都比教科书值钱
3.1 坑1:第一个任务启动总是 HardFault
这是所有人写 RTOS 内核时第一个遇到的坎:系统启动后,自己写的scheduler_start()函数怎么都切不到第一个任务里,一进 PendSV 就 HardFault,或者任务入口函数根本执行不起来。
我当时的实现是:在scheduler_start()里把 PSP 设为 0,然后置位 PendSV,让 PendSV 执行切换逻辑。结果就是:PendSV 里MRS r0, PSP取到的值是 0,然后STMDB r0!, {r4-r11}直接把数据写进了 0 地址,立刻触发总线错误。
根因是:第一次调度时当前任务并不存在,我们没有现场需要保存。教科书里的伪代码往往只写了保存当前任务现场这一步,没有专门交代第一次切换时的特殊情况。解决方法是加一个判断:如果current_tcb为空,就跳过保存现场这一步,直接选下一个任务并恢复它的现场。就是上面汇编代码里的CBZ r1, PendSV_NoSave这一行。
但这里还有第二个坑:跳过保存之后,r0 里仍然是 PSP 的值,而 schedule_next 选好新任务后,我们需要新任务 TCB 里的 sp。所以我的写法是 schedule_next 之后重新从 next_tcb 里加载 sp,不能在 PendSV 入口处就把 PSP 存出去。有些简化版本的代码会用临时变量传 sp,一个不留神就会拿错栈指针。
给第一次写内核的朋友一个建议:第一次切换不要想着一步到位。你可以先只创建一个任务,把调度器启动逻辑单独走通,验证能从 main 切到任务里执行,再逐步加多任务。我为了排查第一个任务启动失败的问题,整个过程至少花了半天,最后是靠单步仿真看 PSP 寄存器才定位到的。
3.2 坑2:SysTick 和 PendSV 的优先级,调不对就现场全丢
第二个坑来得比第一个更隐蔽。任务能启动之后,我欢快地写了两个任务,一个闪灯、一个打印日志,结果发现任务切换经常“卡死”,或者串口打出乱码,甚至偶尔 HardFault。查了很久才意识到问题出在中断优先级配置上。
Cortex-M3 里 PendSV 和 SysTick 的优先级是各自独立配置的,我一开始图省事,给 SysTick 配了最高优先级,PendSV 配了最低优先级。这样做的直接后果是:SysTick 可以抢占 PendSV 执行。设想一个场景:PendSV 正在执行任务切换,已经把当前任务的 R4-R11 压栈、还没恢复新任务现场的时候,SysTick 中断来了,把它打断。SysTick 认为当前任务已经切完了,但其实新任务的现场还没完全恢复。紧接着 SysTick 里发现又有任务要切换,又置位 PendSV 挂起位,等 PendSV 恢复执行时,现场信息已经对不上了,轻则切换混乱,重则直接 HardFault。
正确配置就两条:
- PendSV 和 SysTick 必须配置为最低优先级。
- 用户中断的优先级必须比它们高,保证中断响应不被调度器拖慢。
Cortex-M3 的 NVIC 优先级是 4 位,取值范围 0-15,0 最高,15 最低。我最后在port.c里明确写成:
NVIC_SetPriority(PendSV_IRQn, 0x0F); NVIC_SetPriority(SysTick_IRQn, 0x0F);这里还有一个细节:当 PendSV 和 SysTick 同为最低优先级且同时 pending 时,异常编号小的先执行。PendSV 异常编号是 14,SysTick 是 15,所以 SysTick 里置位 PendSV 后,SysTick 退出,PendSV 会立即执行。正因为这一点,“SysTick 标记调度请求,PendSV 执行切换”的机制才能成立。
如果你在手写内核时发现任务切换总是一段时间后崩溃,优先检查这两个中断的优先级。我后来养成了一个习惯:新板子到手,先写一个最小工程把 SysTick 和 PendSV 的优先级日志打出来,确认寄存器里确实是 0x0F 再开始调内核。
3.3 坑3:临界区不是关个中断那么简单
这个坑是我认为所有 5 个坑里最有“教材之外”价值的。最开始我实现临界区的方式非常简单粗暴,就是关全局中断:
__disable_irq(); // 临界区代码 __enable_irq();第一版在单任务环境里跑得没问题,后来信号量加入后,就出现了邪门现象:某个任务等信号量,明明信号量已经被释放了,但任务就是一直挂着醒不过来。甚至有时候系统跑几十分钟后突然死机。
问题在哪?两个地方。
第一个是嵌套临界区会被提前打断。我在一个临界区里调用了sem_signal,而sem_signal内部又有一个临界区。外层关了中断,内层退出时执行__enable_irq(),直接把中断开了——这时候外层临界区还没结束,如果刚好来了一个中断把当前任务抢占,可能导致现场不一致。这就是经典的“临界区嵌套被错误打开”问题。
第二个是关全局中断连 SysTick 也关掉了。如果在一个比较长的临界区里关中断,SysTick 中断不能执行,整个系统的 tick 计数就会“丢拍”。临界区结束后,所有任务的时间基准都已经漂移了。对于强实时要求的系统,这种漂移是不能接受的。
正确做法是用 BASEPRI 寄存器做临界区保护。BASEPRI 是一个可以屏蔽指定优先级以下所有中断的寄存器,它不像 PRIMASK 那样把中断全部关掉,而是只关低优先级的,高优先级的中断依然能响应。我的实现:
uint32_t task_enter_critical(void) { uint32_t basepri = __get_BASEPRI(); __set_BASEPRI(CRITICAL_PRIO); // 屏蔽优先级不高于 CRITICAL_PRIO 的中断 return basepri; } void task_exit_critical(uint32_t basepri) { __set_BASEPRI(basepri); }注意:退出临界区不是把 BASEPRI 设置为 0,而是要恢复进入之前的原值。这样才能正确处理嵌套临界区。CRITICAL_PRIO 我取的是 0x50,也就是把优先级 5-15 的中断全部屏蔽,保留 0-4 的紧急中断。SysTick 和 PendSV 的优先级是 15,自然也被屏蔽,但临界区通常只有几十条指令,丢一到两个 tick 影响很小。
这个坑给我的教训是:操作系统里所有“全局变量被多个上下文访问”的地方都必须考虑并发。关中断只是最粗暴的手段,真正稳妥的是“屏蔽足够多的中断但保留关键中断”加“保存和恢复原状态”。
3.4 坑4:信号量超时导致的重复挂接
这个坑是我给信号量加超时参数时踩的,整个过程非常痛苦。信号量的等待本来很简单:任务调sem_wait(sem, timeout),如果信号量没资源,就把当前任务挂到信号量自带的等待链表中,然后触发调度。等另一个任务调用sem_signal时,从链表头部唤醒一个任务。
看起来没问题,但一旦引入超时,任务就同时处于两个链表里:一个是信号量的等待链表,一个是全局的延时链表。时间到了,SysTick 要把任务从信号量等待链表里摘出来,移到就绪链表;信号量被释放时,sem_signal也要把同一个任务从延时链表里摘出来,移到就绪链表。如果两边不同步,任务就可能被唤醒两次,或者两段代码同时操作同一个任务的链表节点,导致链表直接成环,系统随机跑飞。
我当时的表现是:系统跑一段时间后,任务列表里随机出现一个“幽灵任务”,打印出来的任务结构体全是乱值,调度器一遍历链表就 HardFault。
根因就是我偷懒了。我只在sem_signal里加了唤醒逻辑,没有在超时分支里写“从信号量等待链表移除”的操作;反过来,超时处理时也没有考虑任务可能已经被信号量唤醒的情况。两边修改的是同一块内存,却没有一致的互斥机制。
解决办法分两步:
第一,所有对任务状态、等待链表、延时链表的修改都要在临界区里完成,保证 SysTick、sem_signal、sem_wait 三方不会同时操作同一个 TCB。
第二,任务状态机要清晰。我增加了blocked_on字段,用来记录任务到底阻塞在什么对象上。信号量超时处理时,先检查blocked_on是不是自己的信号量 ID,不是就说明任务已经被唤醒了,直接跳过;是才执行移除操作。同理,sem_signal唤醒任务时也会把delay_ticks清零,保证延时链表里的检查不会二次处理它。
这个坑让我明白了一个道理:RTOS 里最常见的 bug 不是“逻辑不对”,而是“资源归属不明确”。一个任务同时被两个机制管理时,必须有一个权威的状态标记,否则各种并发修改都会互相踩踏。
3.5 坑5:栈对齐和 xPSR 的 T 位
最后一个坑比较“静默”,出错的时候非常难查。现象是:任务本身能跑,但偶尔用 sprintf 打印浮点数或者调用一些标准库函数时会 HardFault,而且是完全随机的,跟系统负载有关。
查了很久,最后定位到两个问题。
第一个是任务栈的初始地址没有按 8 字节对齐。Cortex-M3 采用的是 AAPCS 调用约定,要求栈指针在任何异常入口、任何函数调用边界处都是 8 字节对齐的。如果任务创建时栈顶地址只是 4 字节对齐,任务运行后一旦调用浮点运算、64 位整数运算或某些标准库函数,就会因为栈未对齐触发 UsageFault。解决方案有两个:一个是任务栈数组声明时用__attribute__((aligned(8))),另一个是创建任务时对栈指针做一次sp &= ~0x07u强制对齐。我两个都做了,因为静态数组不一定能保证你手工算偏移后还是 8 对齐。
第二个是 xPSR 的 T 位。Cortex-M3 只支持 Thumb 指令集,处理器必须处于 Thumb 状态才能执行代码。xPSR 的 bit24 是 T 位,硬件在异常返回时会从栈中恢复 xPSR,如果这个位的值为 0,返回后处理器就认为当前处于 ARM 状态,第一条指令就会触发错误。我第一版创建任务时只把 PC 指向入口函数,xPSR 填了 0,结果任务启动直接异常。正确值是0x01000000,bit24 置 1 表示 Thumb 状态。这个值我在写初版时根本没在意,直到看反汇编发现 PC 永远跳不到入口函数才醒悟。
这两个问题都属于“编译器要求”范畴,教科书里很少专门讲,但它们会让一个看起来完全正常的内核随机崩溃。我的总结经验是:新建一个任务时,初始栈帧不仅仅是“把寄存器填上”那么简单,还得满足编译器和硬件底层的约定。如果任务里涉及浮点运算,还要考虑 FPU 的扩展帧,这些都需要在创建任务时提前规划好。
4. 信号量、队列与中断同步
4.1 信号量:等待与唤醒
信号量是这个内核里最常用的同步原语。我实现的版本是最经典的计数信号量,支持两个操作:sem_wait获取资源,sem_signal释放资源。
int sem_init(sem_t *sem, uint32_t init_count); int sem_wait(sem_t *sem, uint32_t timeout); int sem_signal(sem_t *sem); int sem_signal_from_isr(sem_t *sem);sem_wait的逻辑是:先进入临界区,如果计数器大于 0,直接减一返回;否则把当前任务状态改为 BLOCKED,记录阻塞对象,挂到信号量的等待链表,然后退出临界区并触发调度。sem_signal的逻辑是:进入临界区,如果等待链表为空,计数器加一;否则从等待链表头取出一个任务,把它恢复到就绪态,然后退出临界区并触发调度。
这里有一个容易被忽略的设计决策:信号量释放时,到底是把资源给一个正在等待的任务,还是把资源存进计数器,让等待任务继续等?两种做法都能工作,但实时性不一样。我的做法是“释放时优先唤醒等待者”,这样等待的任务可以立即恢复执行,不会造成额外延迟。
超时版本再多一个小逻辑:等待任务在进入 BLOCKED 状态前,把超时 tick 数记录到 TCB 的 delay_ticks 字段,SysTick 会定期检查它。超时到点后,任务如果仍然在等待链表里,就会被强制唤醒并返回错误码SEM_ERR_TIMEOUT。这里就是坑 4 反复提到的双重链表问题,处理时要格外小心。
4.2 环形队列:数据缓冲
消息队列我实现了一个很精简的环形缓冲区版本,核心数据结构:
typedef struct { uint8_t *buf; uint32_t size; uint32_t head; uint32_t tail; sem_t empty; // 空位信号量 sem_t filled; // 有数据信号量 } queue_t;队列的读写本质上就是两个信号量的组合:写数据前sem_wait(&q->empty),写完后sem_signal(&q->filled);读数据前sem_wait(&q->filled),读完后sem_signal(&q->empty)。有了信号量,队列的阻塞和唤醒逻辑几乎不需要单独处理,代码非常简洁。环形缓冲区的实现也简单,head 和 tail 递增后对 size 取模就行。
需要注意的是,队列的 head、tail 修改必须在临界区里完成,因为可能有两个写者同时写队列。虽然信号量保证了同一时刻只有一个任务能进入写操作,但中断里也可能调用queue_write_from_isr,此时必须用临界区保护 head、tail 的修改。
4.3 ISR 中使用 FromISR 系列 API 的正确姿势
手写内核最容易“觉得自己写完了”的地方就是中断服务函数里的同步。裸机开发时,你可以在中断里直接置一个标志位,然后主循环查询。但有了 RTOS 之后,中断里更合适的做法是直接唤醒一个任务,让任务去处理耗时操作,这样中断服务函数可以尽快退出。
为此我专门实现了sem_signal_from_isr和queue_write_from_isr两个函数。它们的内部逻辑和普通版本几乎一样,唯一区别是结尾不会调用阻塞式调度,而是检查是否有更高优先级的任务被唤醒,如果有就置位 PendSV 挂起位,等中断全部退出后再切换。
这里有个重要原则:中断服务函数里不要调用可能阻塞的接口,比如sem_wait、queue_read,因为这些函数会把当前任务挂起,但中断里并没有“当前任务”的概念,挂起了谁都不知道。一旦写了,轻则逻辑混乱,重则直接 HardFault。我给自己的内核定了一个死规矩:ISR 里只允许用 FromISR 后缀的接口,普通接口一票否决。
5. 在 STM32F103 上的移植与实测
5.1 开发环境与工程配置
我用的板子是经典的 STM32F103C8T6 蓝丸板,64KB Flash、20KB RAM,主频 72MHz,开发环境是 Keil MDK 5,编译器 AC5。整颗芯片的 Flash 占用情况是:内核约 4.3KB,测试代码约 2KB,串口和 GPIO 驱动约 1KB,完全无压力。
SysTick 配置比较简单,直接用 CMSIS 库函数:
SysTick_Config(SystemCoreClock / 1000); // 1ms 中断时钟树方面,我使用外部 8MHz 晶振,PLL 倍频到 72MHz。SystemCoreClock变量必须正确,否则 SysTick 的配置会按错误频率走,导致 tick 时间完全不对。我第一次在新板子上跑的时候,忘了确认SystemCoreClock的值,结果 1ms 延时实际变成了 1us 左右,信号量超时几乎瞬间就返回错误,排查了半小时才发现是时钟配置对不上。
中断优先级分组也要注意。STM32F103 默认是 4 位抢占优先级、0 位子优先级,这正好符合 Cortex-M3 的默认行为。如果你用的是带子优先级的设置,那 NVIC_SetPriority 的具体数值映射会变,PendSV 和 SysTick 的“最低优先级”也需要按实际分组重新计算。
5.2 切换耗时到底有多快
上下文切换耗时是 RTOS 性能的一个重要指标。我通过一个 GPIO 翻转来实测:任务切换时在 PendSV 入口把 PIN 拉高,在退出前拉低,用逻辑分析仪测量高电平宽度。
实测结果是:从进入 PendSV 到完成切换,整个过程大约 3.2us(72MHz 下)。这包括保存 R4-R11、运行 C 语言调度函数、恢复新任务的 R4-R11、以及异常返回时的硬件出栈操作。
3.2us 是什么概念?如果系统有 100 个任务,每个任务的切换都是 3.2us,那每秒切换 10 万次场景下也只占 32% CPU。对大多数嵌入式应用来说,这个切换开销完全可以接受。如果你想进一步压榨性能,可以把调度函数改成纯汇编或者用链表直接取出最高优先级任务,能省掉约 0.5us,但对 700 行教学内核来说意义不大。
5.3 多任务压力测试
验证内核稳定性不能只靠一个点灯程序。我写了一个相对严格的测试场景:
- 任务 A:优先级 1,每 10ms 打印一次日志,同时翻转 LED1。
- 任务 B:优先级 2,每 20ms 打印一次日志,同时翻转 LED2。
- 任务 C:优先级 3,用一个全局计数器累加,计数器到 10000 时打印一次“Counter C reach 10000”。
- 中断 ISR:外部按键触发,在中断里调用
sem_signal_from_isr唤醒一个高优先级任务处理按键事件。
这个场景串口输出必须是严格有规律的:任务 A 和 B 的日志交替出现,且 A 的频率是 B 的两倍;按键事件发生后,处理任务的日志要立即打印,不能被低优先级任务拖住。实测跑了两天,没有死机、没有乱序、没有 HardFault。这个结果基本说明调度器的核心理路是对的。
5.4 GD32F103 移植差异
网上经常有人问 GD32F103 能不能直接跑 STM32 的工程。我的经验是:如果你的内核只依赖 Cortex-M3 的 CMSIS 接口,移植非常容易,几乎只改两个地方。
第一是时钟初始化。GD32F103 的主频可以跑到 108MHz,但默认很多开发板的 system_gd32f10x.c 可能会配到 108MHz。如果沿用 STM32F103 的 72MHz 配置,需要检查 PLL 倍频系数,否则 SysTick 会和实际主频不一致。第二是 Flash 等待周期。GD32F103 在 108MHz 下需要配置 Flash 等待周期为 2,72MHz 下是 1,配置错了会随机死机。
我的内核文件完全没改,只改了工程里的system_gd32f10x.c和启动文件,PendSV、SysTick、端口配置这些在 GD32 上照常工作。如果你打算把 STM32 内核代码往 GD32 上搬,建议先把跑马灯裸机程序验证时钟对不对,再挂内核,否则会把时钟问题和内核问题混在一起,排查起来非常痛苦。
6. 调试排查:崩溃之后怎么定位
6.1 常见问题速查表
我把调试过程中遇到的高频问题整理成了一个表,每一行的信息量都很大,建议收藏:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 任务无法启动,进 PendSV 就 HardFault | 首次切换时把 PSP=0 当成现场保存了 | 检查 PendSV 汇编里是否有 current_tcb==NULL 的分支 |
| 任务能启动但一段时间后跑飞 | PendSV 或 SysTick 优先级配置错误 | 确认两者都为最低优先级 0x0F |
| 串口日志乱码或丢失 | 多个任务同时打印,互相抢占 | 用互斥信号量保护串口;这是典型临界区问题 |
| 信号量超时立即返回 | SystemCoreClock 配置与实际主频不符 | 打印 main clock 与 SysTick 实际中断间隔 |
| 偶尔 HardFault 且随机 | 任务栈溢出或初始栈未 8 字节对齐 | 检查任务栈 magic number,检查 sp 是否对齐 |
| 任务唤醒后调度顺序不对 | 就绪链表按优先级排序的逻辑有 bug | 打印 TCB 链表内容,验证插入和取出方式 |
| printf 浮点数卡死 | 栈指针非 8 字节对齐 | 任务栈数组加 aligned(8),创建时强制对齐 |
6.2 栈检查和 HardFault 定位
我参考 FreeRTOS 的思路,在每个任务栈的底部放了 magic number:
#define STACK_MAGIC 0xDEADBEEF task->stack_magic = STACK_MAGIC;上下文切换时顺手检查一下当前任务的栈底 magic 是否被改写。如果被改写,说明这个任务栈溢出了,立刻打印出当前 TCB 和栈指针,能帮你快速定位是哪个任务的栈开小了。
HardFault 定位有一个非常好用的技巧:HardFault 进入时,通过异常栈帧里的 PC 和 LR 值,配合 Map 文件就能定位到崩溃的代码行。我在 HardFault_Handler 里加了保存现场的逻辑,把 LR、PC、PSP、MSP 打出来,再用 Keil 的View -> Disassembly Window输入 PC 的地址,就能看到崩在哪条指令上。这个方法在处理随机 HardFault 时几乎救命。
6.3 串口日志加锁和断点调试
最后分享几个调试技巧。
第一,串口打印必须加锁。我有个任务 A 打印一半时任务 B 也调用打印函数,串口输出就拼在一起了。我的方案是把串口 printf 包了一层互斥信号量,打印期间其他任务无法进入。注意释放锁之后可能触发切换,所以打印函数内部临界区不要包太长,只锁串口硬件寄存器,不锁格式化字符串,避免拖慢系统。
第二,调试内核变量时不要在中断里打断点。Keil 在 SysTick 里打断点,如果断点停在 SysTick 内部,此时 PendSV 可能还没执行,整个系统的时序就已经乱了。我更推荐用逻辑分析仪看 GPIO 翻转让内核状态可视化,比如切换时翻转 PIN,延时结束时翻转 PIN,这样不用打断点也能判断系统运行节奏。
第三,如果遇到诡异的随机 bug,优先怀疑不是算法逻辑,而是内存布局。比如两个任务栈挨得太近,一个溢出会破坏另一个的 TCB。用__attribute__((aligned(8)))保证栈对齐之后,可以在栈区前后放置保护区域,配合 RAM 的 Memory Map,定位这类越界问题会快很多。
这个 700 行的小内核写完之后,我自己最大的收获是终于看懂了 FreeRTOS 那几百行汇编到底在干什么。以前看源码里那些 PendSV_Handler、xPortStartScheduler 都像天书,现在再看就是老朋友了。如果你也想写一个自己的 RTOS,建议从最小的调度器开始,别一上来就做全套,先把单任务启动打通,再加多任务、延时、信号量、队列,每加一个模块就单独验证一次,很多坑其实都不会踩到。最后再说一个我个人的小习惯:每写完一个内核模块,就在测试文件里加一个对应的极端用例,比如任务栈设成很小、延时设成 0、信号量超时设成 1 tick。这些边界条件平时不会触发,但最能暴露设计上的隐患。我的内核最终能稳定跑两天不掉链子,跟这些“无聊”的边界测试有直接关系。