☰
从零手写抢占式调度器:RTOS核心原理与实现
2026/10/13 5:28:15 网站建设 项目流程

前阵子我在调一个采集加显示的双任务项目,还是老一套前后台结构:main循环里刷显示,ADC中断里做采集。逻辑上一点问题没有,但显示刷新总是一卡一卡的,按键一多,画面直接掉帧。后来我咬咬牙,自己动手写了一个抢占式调度器,把两个任务彻底拆开,问题立刻消失。这篇文章就是我从零手写抢占式调度器的完整记录,包括核心原理、代码骨架和实测踩坑。内容适合正在学RTOS原理的嵌入式开发者,也适合对操作系统内核感兴趣的软件工程师。别被“手写一个抢占式调度器”这句话吓住,拆开看,骨架并不复杂,但每一步都有讲究。

1. 前后台模式的气不够,抢占到底在解决什么

1.1 一个延时就把整条主线卡死的场景

先说我最开始写的这个采集显示项目。main循环里做了这么几件事:读传感器、刷新屏幕、处理按键。按键处理必须消抖,于是常规操作就是按下检测到之后,delay个10毫秒再确认一次。就这么一个delay,放在原本流畅的循环里,显示刷新立刻停顿。更让人难受的是,如果主循环里有一段长时间的计算,比如某个滤波算法要算几个毫秒,那这段时间内所有中断以外的任务都得等着。

有人说,我有中断啊,ADC采集走中断不就行了。话是没错,但中断不是万能的。中断处理函数里能做的工作非常有限,不能做浮点运算,不能调用耗时函数,更不能阻塞等待。中断的本质是“打断当前执行”,而不是“安排一套任务轮流执行”。一旦项目里有两三个需要周期性执行且互相关联的任务,前后台模式很快就变成一团乱麻。

1.2 抢占式调度:不是更快,而是更可控

抢占式调度器的核心思想,是把CPU时间交给“优先级”来裁决。每个任务有自己的优先级,最高优先级的就绪任务永远先跑。当高优先级任务进入就绪状态时,它可以立刻打断正在运行的低优先级任务,把CPU抢过来。等它运行完毕或者进入阻塞状态,低优先级任务才回到CPU上继续执行。

这里的关键不是“运行得更快”,而是“响应时间可控”。任务A优先级高,只要它准备好,系统保证在下一个调度点(通常是一个系统节拍)内让它上CPU。最坏情况延迟是可计算的,这就叫实时性。而前后台模式下,任务A的响应时间取决于主循环当前跑到哪里,完全不可控。

1.3 自己写一个调度器,到底在写什么

把问题拆开,手写抢占式调度器只需要回答三个问题:系统里有哪些任务要跑?它们各自的优先级是多少?切走一个任务再切进另一个任务时,现场怎么保存和恢复?

第三点是新手最容易忽视的。表面上看,抢占调度器只是“选一个优先级最高的任务,然后跳过去执行”,但任务是被中断打断的还是主动让出CPU的,上下文状态完全不同。中断里保存现场是硬件完成的,主动切换则要软件处理一堆寄存器。等你把这块理清楚,整个调度器也就通了一半。

2. 地基:任务控制块、就绪表与“选谁跑”的算法

2.1 任务控制块里到底该放什么

所有RTOS里都有一个核心结构体,叫任务控制块,缩写TCB。它描述一个任务的“身份档案”。我最初的版本只放了五个字段,跑起来之后发现不够用,又加了几个。最终稳定是这个样子:

#define TASK_STATE_READY 0x01 #define TASK_STATE_BLOCKED 0x02 #define TASK_STATE_SLEEP 0x04 typedef struct tcb { uint32_t *sp; // 任务自己的栈指针,切换现场时用 uint8_t priority; // 优先级,数值越小优先级越高 uint8_t state; // 当前状态:就绪/阻塞/睡眠 void (*entry)(void *arg); // 任务入口函数 void *arg; // 传给入口函数的参数 uint32_t delay_left; // 剩余睡眠节拍数 uint16_t slice_cnt; // 时间片轮转计数器 struct tcb *next; // 链表节点 } tcb_t;

sp字段是整个TCB的灵魂。任务被切走时,它的寄存器现场会全部压入它自己的栈,sp就指向栈顶。任务被切回来时,从sp开始恢复现场,CPU就能接着之前的位置继续执行。想明白这个字段,上下文切换就理解了一半。

2.2 就绪表:链表方案与位图方案的取舍

就绪表是调度器的核心数据结构,它的任务是回答“当前哪些任务可以运行”。两种常见设计:链表和位图。

链表方案把就绪任务串成一个链表,插入删除灵活,任务数量无限;但查找最高优先级任务需要遍历链表,最坏情况遍历全部节点,时间不可控。位图方案用1个int(假设32位)表示32个优先级,第n位为1表示优先级n有就绪任务;查找最高优先级只需从最低位开始扫描,最多32次循环,时间基本恒定。代价是优先级数量被限制在32个以内。

我选的是位图方案,因为32个优先级对中小型项目完全够用,而且查找算法可以写得很干净。就绪表相关代码如下:

#define MAX_TASK_NUM 32 static tcb_t *g_task_tbl[MAX_TASK_NUM]; // 每个优先级至多一个任务 static volatile uint32_t g_ready_bits; // 就绪位图 void sched_add_task(tcb_t *tcb, uint8_t prio) { if (prio >= MAX_TASK_NUM) { return; } tcb->priority = prio; tcb->state = TASK_STATE_READY; g_task_tbl[prio] = tcb; g_ready_bits |= (1U << prio); }

这里约定优先级数值越小越紧急,所以位0是最高的优先级。这个约定贯穿整个调度器,后面所有等待、唤醒的逻辑都要符合它。

2.3 选下一个任务的函数,三行代码里有大学问

调度器最重要的函数,就是从就绪表里找最高优先级任务。我的实现很直接:

static int32_t find_highest_ready(void) { uint32_t bits = g_ready_bits; int32_t prio = 0; while ((bits & 0x1U) == 0U) { bits >>= 1U; prio++; } return prio; }

这段代码把g_ready_bits从低位往高位逐个检查,遇到第一个为1的位,就返回它的下标,也就是当前最高优先级。最多循环32次,在绝大多数场景下都是“瞬间”完成的。

我一度觉得这个函数太平凡了,甚至想过换成查表法。后来实测发现,调度器的性能瓶颈根本不在这里,而是在上下文切换和临界区开关上。查表优化纯属自我感动。这也是我写调度器得到的第一个教训:先跑起来,再谈优化,不要为了看起来很厉害而提前优化。

3. 心跳与节拍:系统定时器怎么把调度器叫醒

3.1 抢占需要外力,这个外力就是定时器中断

如果你把调度器当成一个“更强壮的switch-case”,永远做不出抢占。抢占的前提是:一个外部机制能强行打断当前任务的执行,然后把控制权交回调度器。实现这个外部机制的主角,是单片机里的系统节拍定时器,大家习惯叫SysTick。

这个定时器的配置非常简单,核心就两件事:设置重装载值,使能中断。以常见的主频72MHz内核时钟为例,要实现1ms一个节拍,重装载值就是:

// 72MHz / 1000Hz - 1 = 71999 #define SYSTICK_LOAD_VALUE 71999 void systick_init(void) { // 关闭定时器,清零计数器 // 写入重装载值 // 选择内核时钟作为时钟源 // 使能计数器,使能中断 }

配置逻辑并不复杂,但有几处细节容易忽略。第一,重装载值要减1,因为定时器是从这个值向下数到0,数到0本身也算一个时钟周期。第二,清零计数器这一步必须有,否则第一次中断的时间是随机的,调度器刚启动那一刻就会被打乱。

3.2 节拍周期怎么选,不是越短越好

系统节拍决定了调度器响应速度的下限。1ms节拍意味着高优先级任务最多等待1ms就能被调度;10ms节拍下,这个延迟就变成10ms。听起来节拍越短越好,但代价是中断唤醒CPU的频率成倍增加。以1kHz节拍为例,CPU每秒被中断1000次,每次中断都要执行扫描、调度、切换,这些开销会在高节拍下快速累积。

我个人的经验是:一般裸机项目从1ms节拍起步,如果任务对时间要求不苛刻,可以放慢到5ms甚至10ms,省出来的CPU时间留给业务逻辑。这里没有银弹,必须根据任务响应要求倒推节拍。另外,节拍寄存器配置完成后,验证方法是写一个全局计数器,在定时器中断里加一,再让一个GPIO翻转,用示波器看频率对不对。别相信眼睛,示波器才是最诚实的。

3.3 定时器中断里该做什么:标记请求,而不是立刻切换

系统节拍中断(tick中断)是调度器的心脏。它要完成三件事:更新系统节拍计数;扫描那些正在睡眠的任务,把到期者唤醒;请求一次上下文切换。用代码表达:

static volatile uint32_t g_tick; void systick_handler(void) { g_tick++; // 遍历所有任务,处理睡眠到期唤醒 for (int i = 0; i < MAX_TASK_NUM; i++) { tcb_t *t = g_task_tbl[i]; if (!t || t->state != TASK_STATE_SLEEP) { continue; } if (--t->delay_left == 0) { t->state = TASK_STATE_READY; g_ready_bits |= (1U << i); } } // 请求一次调度,但不在这里真正切换 request_schedule(); }

注意最后一步,我只“请求”调度,而没有在中断里直接执行切换。这是抢占式调度器极其关键的设计决策。如果在中断里直接改栈指针、弹恢复现场,相当于在别人铺到一半的地板上强行换砖,现场会支离破碎。更稳妥的做法是:标记一个调度请求,等中断返回前的一个固定时机,再去执行真正的上下文切换。这个时机在主流内核上叫可挂起系统服务请求,我后面会细说。

3.4 临界区保护:中断开关的粒度问题

调度器里到处都要操作共享数据(就绪表、任务链表、信号量计数),这些操作必须原子完成。最简单的保护办法就是关中断。我封装了两个函数:

static inline uint32_t critical_enter(void) { uint32_t mask = __get_PRIMASK(); // 保存当前中断开关状态 __disable_irq(); // 关中断 return mask; } static inline void critical_exit(uint32_t mask) { __set_PRIMASK(mask); // 恢复到进入之前的状态 }

这里有个新手必踩的坑:临界区退出时直接开中断,而不是恢复到进入前的状态。如果两个临界区嵌套,内层先退出,把中断开了,外层还没执行完,这时候来了中断,共享数据就又被破坏了。正确做法是上面这样,先保存旧状态,再关闭,退出时恢复旧状态,嵌套也能安全处理。

4. 上下文切换:一次寄存器级的全景搬家

4.1 任务切换到底在切什么

CPU内部有一组寄存器,谁在运行,就看这组寄存器里装的是谁的值。任务A暂停,得把A的所有寄存器现场保存到A的栈里;任务B恢复,得把B之前保存的现场从B的栈里搬回寄存器。整个过程就是一次寄存器级的全景搬家。

不同内核的寄存器数量不同,但处理策略类似。以主流M内核为例,异常发生时,硬件会自动压栈8个寄存器:xPSR、PC、LR、R12、R3到R0,它们被压入当前使用的栈。剩下R4到R11这8个寄存器,硬件不管,需要软件手动压栈。所以切换代码的核心就是:手动压栈R4-R11,更新栈指针,然后从新任务的栈里弹出R4-R11,剩余部分由硬件在异常返回时恢复。

4.2 为什么切换入口放在“可挂起系统服务请求”而不是定时器中断里

我前面提到,定时器中断只是发出调度请求,真正切换放在另一个地方。为什么不能直接在定时器中断里切?因为定时器中断可能正在打断另一个中断的执行。如果两个中断嵌套,在里层直接切换任务,外层中断返回时会恢复成一个不匹配的现场,系统瞬间崩溃。

所以主流的做法是:引入一个优先级最低的异常,专门用来做上下文切换。定时器中断里触发这个异常,如果当前有更高优先级的中断在跑,这个切换异常会一直挂起,直到所有中断都处理完毕才真正执行切换。这就保证了上下文切换永远发生在“所有中断都安静下来”的时机,现场足够干净。

我自己的实现也是照这个思路:

void request_schedule(void) { // 触发一次可挂起的系统服务请求异常 // 它会在所有中断处理完毕后执行 }

4.3 一段极简的切换核心,看懂它调度器就通了一半

既然切换异常是切换的入口,那核心切换代码就长下面这样(以常见架构的寄存器和指令为例,不同平台指令有差异,但思路完全一致):

; 进入切换异常后,硬件已经自动压栈了 xPSR/PC/LR/R12/R3-R0 ; 这里先把剩余的R4-R11压到当前任务的栈里 PUSH {R4-R11} ; 保存当前栈指针到当前任务的TCB LDR R0, =g_current_tcb LDR R1, [R0] ; R1 = 当前TCB指针 STR SP, [R1] ; TCB->sp = 当前SP ; 查找最高优先级就绪任务 BL find_highest_ready ; R0 = 新TCB指针 ; 更新全局当前任务指针 STR R0, [g_current_tcb] ; 把新任务的栈指针加载到SP LDR R1, [R0] ; R1 = 新TCB->sp LDR SP, [R1] ; 切到新任务的栈 ; 弹出R4-R11 POP {R4-R11} ; 返回,剩余寄存器由硬件恢复 BX LR

这段汇编里,PUSH和POP对应的是R4-R11的软件保存与恢复;硬件压栈的8个寄存器,在异常返回时由CPU自动从栈顶弹出。所以新任务的栈里必须预先放好这8个值,才能保证第一次切换时“硬件异常返回”能正确跳到任务入口。

4.4 任务栈初始化:给“第一次切换”埋好伏笔

新任务创建时,它的栈里并没有任何现场。我们要人为造一个“假现场”,让第一次切换看起来就像“这个任务之前已经被暂停过”一样。这就是任务栈初始化的由来:

#define STACK_SIZE 256 uint32_t task_stack[STACK_SIZE]; static void init_task_stack(tcb_t *tcb, void (*entry)(void *), void *arg) { uint32_t *sp = &task_stack[STACK_SIZE - 1]; *--sp = 0x01000000; // xPSR,必须使能Thumb位,否则跑飞 *--sp = (uint32_t)entry; // PC:任务入口地址 *--sp = 0xFFFFFFFD; // LR:异常返回后进入线程模式并使用进程栈 *--sp = 0x00000000; // R12 *--sp = 0x00000000; // R3 *--sp = 0x00000000; // R2 *--sp = 0x00000000; // R1 *--sp = 0x00000000; // R0 // 下面是软件恢复的R11-R4,第一次切换都会弹出,填0即可 for (int i = 0; i < 8; i++) { *--sp = 0x00000000; } tcb->sp = sp; }

这里每一个值都有讲究。PC填任务入口地址,这决定任务从哪里开始执行。xPSR的Thumb位必须置1,否则CPU不认为这是合法指令模式,第一次切换直接进错误异常。LR填0xFFFFFFFD,这是异常返回魔法值,告诉CPU返回后使用线程模式+任务自己的栈指针。第一次跑这个初始化逻辑时,我是对着反汇编一行一行确认的,这里错一个字节,系统就是起不来。

5. 代码跑通之后:四个让我头皮发麻的实测坑

5.1 第一坑:在中断里调用带锁的函数,程序直接死等

场景是这样的:我在一个外部中断的处理函数里,对一个信号量执行了“获取”操作。目标是想做“中断里发事件,任务里处理”。结果板子一跑起来,系统直接卡死,一点反应都没有。

当时排查了很久,在函数入口加打印,发现打印都出不来。冷静下来看代码才发现,信号量获取函数里有临界区保护,临界区把中断关了。如果信号量计数为0,当前任务会阻塞等待,而这个信号量只可能在另一个中断里被释放。中断已经被关死了,等一个永远不可能来的释放,这就是死锁。

这是把自己锁死的经典案例。我在这个坑里学到两条准则:第一,中断上下文里永远不要调用“获取型”操作,只做“释放型”操作,把事件记录下来就返回,处理逻辑放到任务里去;第二,带临界区的函数必须明确标注“仅任务上下文可用”,最好在命名上就区分开。

5.2 第二坑:第一次切换直接跑飞,反汇编看了半小时

调度器能编译通过,能创建任务,但一启动,第一个任务入口函数还没执行第一行C代码,就跳进了错误处理。我把反汇编一行行看过去,发现PC加载没有问题、栈顶地址没有问题,最后才注意到xPSR的初始值不对。

xPSR里有一个控制位用来区分指令集状态,主流M内核要求处于Thumb状态。我初始化时填的0x00000000,把这个位清掉了,等于告诉CPU“这里不是Thumb指令模式”,第一个跳转就失败。改成0x01000000后立竿见影。

这让我意识到,任务栈初始化不是随随便便往栈里塞几个值就完事,每一格都对应CPU手册里的一个真实行为。最好的排查办法不是干瞪眼,而是把新任务栈的内存dump出来,和CPU异常压栈布局一一对照,哪里对不上,问题就锁定在哪里。

5.3 第三坑:同优先级任务排队,后面的永远轮不到

我的位图方案里,每个优先级最多放一个任务,这对大多数场景够了。但有一次我把两个同等重要的工作放在了同一优先级,结果是只有第一个任务跑了,第二个任务的优先级位根本没置上,相当于永远不被调度。

后来我用链表把同一优先级的多个任务串起来,每个任务增加一个“下一个同优先级任务”指针,形成环形结构。调度函数选中该优先级后,再从环形队列的头节点开始,每次选中当前节点,然后把头指针挪到下一个节点,实现时间片轮转的基本效果。这个改动不大,但它让调度器从“单任务抢占”变成了“多任务抢占”,能力上了一个台阶。

5.4 第四坑:两个任务同时用串口,字符乱得没法看

两个任务都在向串口打印日志,结果输出里经常混入另一个任务的字符片段。一开始我以为是波特率问题,用示波器看了波形,完全正常。后来才明白,这是典型的资源竞争:task A打印一行字符串的过程中,时间片到了,task B抢占了CPU,也开始打印,两个任务的字符就叠在一起了。

解决方式是把串口看成共享资源,给它的打印函数套一个互斥量。在临界区里把打印缓冲区整体占用,打印完再释放。这套机制在调度器后期是必须的,信号量也是从这里开始加入我的调度器的。而且要注意,互斥量的“获取”操作不能放在中断里,否则又回到第一个坑了。

现象直接原因根因
死等无响应在中断里调用获取型操作临界区关闭中断,等待永远不来的释放
跑飞进错误处理任务第一次切换失败xPSR初始化缺Thumb位
同优先级任务不执行就绪表只支持单任务优先级模型缺少多任务队列
串口输出乱码两任务抢占打印共享资源未互斥保护

6. 补完计划:延时、时间片轮转与互斥量信号量

6.1 让任务学会睡觉:阻塞延时的实现

调度器跑起来后,第一个要补的功能就是任务的阻塞延时。裸机里的delay函数是死循环烧CPU,这在多任务环境下是灾难性的——一个任务延时,白白浪费整个CPU。正确的做法是让出CPU,进睡眠队列,等tick中断唤醒。

void task_sleep(uint32_t ticks) { uint32_t mask = critical_enter(); g_current_tcb->delay_left = ticks; g_current_tcb->state = TASK_STATE_SLEEP; g_ready_bits &= ~(1U << g_current_tcb->priority); // 从就绪表摘除 critical_exit(mask); request_schedule(); // 交出CPU }

对应的,tick中断里扫描所有睡眠任务,把delay_left减到0的恢复就绪。这套代码第一次跑通时,我在两个任务里轮流打印不同的字符,示波器上看GPIO波形,时序非常干净。那一刻我真切感受到,多任务调度的爽快感是前后台模式给不了的。

6.2 同优先级谁也不让谁:时间片轮转

纯粹的优先级抢占有一个副作用:如果系统里三个任务优先级相同,最低优先级的人永远饿不死,但同优先级里排后面的任务可能一直轮不到。解决办法就是时间片轮转,给每个同优先级任务分配固定时间片,达到阈值就把CPU让给下一个同优先级任务。

实现思路很清晰:tick中断里给当前任务的时间片计数器加一,达到阈值后,把该任务从同优先级环形队列的头位移到尾部,然后触发一次调度。这样每个同优先级任务都能拿到CPU时间,轮流执行。我把时间片设为5个节拍(5ms),实测下来切换开销占比很低,但任务间的响应均匀度明显提升。

6.3 资源互斥:从全局关中断到信号量

信号量是让任务安全访问共享资源的工具。一个最简版的信号量就两个操作:获取和释放。获取时如果计数为0,当前任务就挂到等待队列,让出CPU;释放时计数加一,如果有任务在等待,就唤醒一个。

typedef struct { uint32_t count; tcb_t *wait_head; // 等待队列 } sem_t; void sem_take(sem_t *s) { uint32_t mask = critical_enter(); while (s->count == 0) { // 当前任务挂到等待队列 // 从就绪表摘除 // 记录等待状态 critical_exit(mask); request_schedule(); // 让出CPU,等别人释放 mask = critical_enter(); // 被唤醒后重新进入临界区 } s->count--; critical_exit(mask); } void sem_give(sem_t *s) { uint32_t mask = critical_enter(); s->count++; // 如果等待队列非空,唤醒第一个任务到就绪表 critical_exit(mask); }

这段代码其实还隐含了一个问题:一个高优先级任务等一个低优先级任务占用的信号量时,中间来了一个中等优先级任务,低优先级上不了CPU,信号量就迟迟释放不了,高优任务被间接饿着。这就是优先级反转。实际产品中,我会建议直接采用支持优先级继承的互斥量方案,或者在设计任务优先级时尽量避免高优任务等待低优任务的资源。

写完这个调度器之后,我再去看各种内核源码,阅读障碍小了很多。最大的体会是,调度器不是靠某个天才算法撑起来的,而是靠无数细节堆出来的——临界区嵌套、栈初始化、异常返回魔法值、位图选定优先级,每一个背后都藏着一整页血泪。说句实在话,如果你也想练手,我建议先别急着读现成调度器的源码,从TCB开始自己写一个,跑通之后再回头读,理解完全不一样。那种亲手把“为什么”填上的感觉,是这个领域最上头的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询