资讯动态

手写抢占式调度器:裸机多任务实时性解决方案

发布时间:2026/10/14 19:51:53 来源:尧图企业网站定制
如果你也在裸机上写过多任务大概率遇到过类似的窘境主循环里塞了按键扫描、显示刷新、通信协议处理再加一个功能就要重新调整轮询顺序某个任务一旦卡住整个系统都得陪着等。我最早是在某个仪表类项目上被这种局面逼到墙角才下决心手写一个抢占式调度器。写完那一刻才真正明白任务调度不是一个高深莫测的黑盒而是由任务控制块、栈指针、异常入口这几个非常朴素的概念拼出来的。这篇文章会把我的设计思路、完整实现、调试验证和踩坑记录都整理出来适合正在裸机上做多任务、或者想真正搞懂调度器底层原理的开发者参考。1. 为什么裸机项目需要一个抢占式调度器1.1 轮询主循环的狼狈之处很多单片机的入门项目都是从超级循环开始的主循环里按顺序调用各个模块的处理函数。一套流程跑下来模块越多单轮循环的时间就越长而某个处理函数里一旦有阻塞等待后面的模块就只能干等。看起来是同时在跑多个功能实际上还是一个串行执行流只不过切换频率很快给人并行的错觉。这种写法最大的问题不是性能而是实时性不可控。比如按键扫描放在循环前面显示刷新放在后面按键响应会很快有一天你把一个耗时的加密计算插到中间按键扫描就被拖到几百毫秒之后才执行。表面上看是代码结构问题本质上是没有任何机制来保证关键任务必须在规定时间内得到处理。抢占式调度器解决的就是这个问题它给每个任务分配优先级高优先级任务一旦就绪低优先级任务就得让位而不是看它在代码里排在第几位。轮询循环在极简单的项目里完全够用但一旦任务超过三四个任务之间有明确的轻重缓急手写一个抢占式调度器就比靠人肉维护循环顺序可靠得多。我个人的经验是当主循环里函数调用超过五个并且开始出现这个模块必须每毫秒刷一次那个模块慢一点没关系的需求时就值得上调度器了。1.2 协作式与抢占式的分水岭在决定写抢占式之前有必要先分清协作式和抢占式的区别。协作式调度器里任务主动让出 CPU调度器才切换任务抢占式调度器里任务不管自己愿不愿意只要时间片到了或者更高优先级任务就绪就会被强制切换。对比维度协作式调度抢占式调度切换时机任务主动让出或调用延时/等待函数时钟节拍触发高优先级抢占时间片耗尽实时性依赖任务自觉阻塞会拖累全局高优先级任务能及时打断低优先级任务代码耦合每个任务都要注意什么时候让出任务只需要关注自己的逻辑调度由系统管理实现难度较低上下文切换不频繁较高需要处理中断现场保护和恢复典型场景状态机、简单协议栈多路传感器采集、电机控制、通信与显示并存协作式看起来很省事但它在某个任务忘了让出或者某个任务在等待外设时自旋的情况下整个系统照样会卡死。抢占式虽然实现难度高但实时性有下限保障时钟中断一到系统就有了重新分配 CPU 的机会。这也是我在实际项目中最终选择抢占式的原因宁可在写调度器时多花点心思也不愿意上线之后被一个阻塞的任务拖垮全盘。1.3 自己动手写的三个理由可能有人会问市面上现成的 RTOS 一抓一大把为什么要手写我在刚冒出这个念头时也被朋友问过同样的问题。理由其实很朴素第一有些项目根本塞不下一个完整 RTOS。资源极度受限的 MCU 上ROM 和 RAM 都按字节计算一个裁剪到极限的商用内核可能也要占掉不少资源而手写一个精简内核可以做到只占用几个函数和一块任务栈。第二交付项目时经常需要向团队讲清楚系统在异常情况下会怎么切换任务如果内核是借来的排查调度问题就只能在黑盒外面猜内核是自己写的每一行行为都在掌控里。第三从学习角度来说把上下文切换、就绪表、优先级抢占这些机制亲手实现一遍再看任何一款 RTOS 的代码理解速度都会快很多。手写调度器不是要和成熟内核比功能而是用最小的实现换取最大的掌控力。我后面给出的这套实现核心代码不超过五百行跑在带 Cortex-M 内核的 MCU 上足以支撑一个多任务的仪表控制项目。2. 抢占式调度器的核心机制拆解2.1 任务控制块调度器眼中的任务身份证调度器要管理多个任务总得有个东西记录每个任务的运行状态这就是任务控制块简称 TCB。TCB 在概念上就像人事档案记录了这个任务的栈指针、优先级、剩余时间片、状态以及它放在哪块栈空间里。每次切换任务调度器做的第一件事就是去查当前任务的档案把现场信息存进去切换目标任务时再从目标任务的档案里把现场信息取出来还原。我当时设计的 TCB 结构非常简单#define STACK_SIZE 256 #define MAX_PRIO 32 typedef void (*task_entry_t)(void *arg); typedef struct { uint32_t stack[STACK_SIZE]; // 任务自己的栈空间 uint32_t *sp; // 栈指针任务被切走时保存 uint8_t prio; // 优先级0 最高 uint8_t state; // 任务状态 uint16_t slice; // 时间片长度(节拍数) uint16_t remain; // 剩余节拍数 } TCB; #define TASK_READY 0x01 #define TASK_DELAY 0x02把栈数组直接放在 TCB 里是为了省事实际项目里任务栈一般放在单独的静态数组里把数组首地址作为参数传给创建函数。这个取舍后面讲栈溢出时还会再提你至少要知道栈空间不是凭空出现的每个任务多了这份开销RAM 预算就要重新算。任务状态我只留了 READY 和 DELAY 两个因为一套调度器只要能把可运行和不可运行区分开就已经能搭出基本框架了。至于挂起、等待信号量这些状态都是在 READY 和非 READY 的基础上扩展出来的。2.2 上下文切换保护现场与恢复现场上下文切换是调度器的心脏。所谓上下文就是任务运行时用到的那一组寄存器。任务 A 执行到一半被切走它的寄存器值必须完整保存下来下一次再切回到任务 A这些寄存器值要原样恢复A 才会觉得自己只是被暂停了一下完全感知不到自己曾经被换出去过。在带硬件浮点单元的 Cortex-M 内核上异常入口会自动保存一部分寄存器R0-R3、R12、LR、PC、xPSR这部分由硬件代劳。但 R4-R11 这八个通用寄存器硬件不会管必须由切换代码手工保存。所以一次典型的上下文切换包括保存当前任务的 sp 到它的 TCB从目标任务的 TCB 里取出 sp恢复目标任务需要的寄存器通过异常返回机制跳回目标任务的执行点这个流程我第一次写的时候总觉得很神秘实际就是把一组寄存器压进栈、再从另一块栈里弹出来再更新 PSP。只要栈帧布局和切换代码约定一致就不会出错。后面第 3 章我会把完整的栈帧布局一步步画出来新手照着实现也不会乱。2.3 就绪表用一条指令选出下一个任务有了 TCB 和上下文切换调度器还需要回答一个问题下一个该跑哪个任务最简单的办法是遍历所有任务挑出优先级最高且状态为 READY 的那个。任务数量少的时候完全没问题但每次切换都要做一次循环时间复杂度是 O(n)任务一多或者切换频繁时这笔开销看着不大积累起来很可观。我在实现里用的是位图就绪表。思路是准备一个 32 位的变量 ready_mask每一位对应当前优先级是否就绪。因为这里暂定一个优先级只放一个任务所以 ready_mask 的 bit 位置就能唯一确定一个任务。选择最高优先级就绪任务时用一条 CLZ 指令数出最高位的 0 的个数就能直接得到优先级编号时间复杂度降到了 O(1)。static uint32_t ready_mask; static TCB *task_table[MAX_PRIO]; static inline void set_ready(TCB *tcb) { ready_mask | 1u (31 - tcb-prio); } static inline void clear_ready(TCB *tcb) { ready_mask ~(1u (31 - tcb-prio)); } static TCB *schedule_next(void) { if (current_tcb current_tcb-state TASK_READY) { set_ready(current_tcb); } if (ready_mask 0) { return NULL; // 理论上到这里应该切空闲任务 } uint32_t top_prio 31 - __CLZ(ready_mask); TCB *next task_table[top_prio]; clear_ready(next); return next; }这里有个细节值得说明我把优先级 0 定义为最高优先级对应到 ready_mask 的 bit31。CLZ 统计从最高位开始连续 0 的个数如果 bit31 是 1CLZ 结果是 0用 31 减一下正好得到 0也就是最高优先级。这种映射关系写的时候容易搞反我第一次就被坑过切出来的任务总是错的。2.4 为什么抢占要拆成 SysTick 和 PendSV 两段既然要有时间片抢占就必须有个周期性的敲钟机制来提醒调度器检查时间片这个角色在 Cortex-M 上由 SysTick 定时器担任。当某个任务的时间片耗尽SysTick 中断触发但此时并不能直接在 SysTick 里做任务切换。原因在于 SysTick 本身是一个中断它在执行时并不清楚当时 CPU 是不是正处在另一个中断的处理流程里。如果在 SysTick 里直接切换任务中断嵌套现场的保存和恢复就会变得极其复杂稍有不慎就把正在处理的中断上下文搞丢了。标准解法是引入 PendSV 这个可挂起的系统调用它被设计成优先级最低的异常并且可以由软件主动置位触发只要系统里有其他中断在执行PendSV 就会一直等着等所有中断处理完毕才真正执行任务切换。用生活化的类比来说SysTick 像闹钟到点就响PendSV 像下班前的交接班流程必须等手头所有急事都处理完才能开始交接。任务切换不是越快越好而是要等所有人把手头的事干完再换人这样才不会踩乱现场。SysTick 中断里只做两件事递减当前任务的剩余时间片发现时间片用完了就置位 PendSV 请求切换。实际切换动作全部放到 PendSV 里完成。3. 从零实现一个可运行的调度器3.1 任务栈的初始化布局写调度器的第一个难点不是切换代码本身而是把新任务第一次运行时的寄存器状态伪造出来。一个新创建的任务没有历史现场调度器切换到它时必须假装它是被中断打断后恢复现场这样才能用统一的异常返回流程启动它。我用的 Cortex-M 在进入 PendSV 异常时硬件会自动从进程栈 PSP 中压入 xPSR、PC、LR、R12、R3、R2、R1、R0切换代码再手工压入 R4-R11。反过来恢复现场时先手工弹出 R4-R11再执行异常返回指令由硬件弹出那八个寄存器同时把 PC 装载为任务的入口地址。初始化任务栈时就要把栈里按这个顺序预先填好。代码是这样写的static uint32_t *task_stack_init(TCB *tcb, task_entry_t entry, void *arg) { uint32_t *sp tcb-stack[STACK_SIZE]; // 先预留异常自动保存的寄存器帧(高地址方向) sp - 8; sp[0] 0x01000000; // xPSRbit24 必须为 1使用 Thumb 指令 sp[1] (uint32_t)entry; // PC第一次运行的任务入口 sp[2] (uint32_t)task_exit; // 任务如果 return就跳到这个函数 sp[3] 0; // R12 sp[4] (uint32_t)arg; // R3也可以让入口参数走 R0 sp[5] 0; sp[6] 0; sp[7] 0; // R2 R1 R0 // 再预留手工保存的 R4-R11 区域(低地址方向) sp - 8; for (int i 0; i 8; i) { sp[i] 0; // R4-R11 初始值 } return sp; // 第一次调度时切换代码从这个地址开始弹出 R4-R11 }这里最容易踩的坑是 xPSR 的 Thumb 位。ARM 内核一般强制使用 Thumb-2 指令集如果初始化栈帧时没有把 xPSR 的 bit24 置 1第一次切换到新任务时会触发硬件 fault表现就是任务一启动就进 HardFault。这个问题我在第一版代码里遇到过后来排查时打开调试器看 PC 值才发现是 xPSR 没有正确设置。task_exit 是一个兜底函数正常任务应该是个死循环但如果有人不小心把任务函数写成了会 return 的结构就需要一个地方接住它否则 PC 会跳到栈上的未知地址。我把它定义成一个死循环static void task_exit(void *arg) { (void)arg; while (1) { // 兜底任务异常返回后停在这里 } }3.2 任务创建与启动调度TCB 和栈初始化准备好之后任务创建函数就很薄了主要工作是登记任务、分配栈帧、把它放进就绪表。void task_create(TCB *tcb, task_entry_t entry, void *arg, uint8_t prio, uint16_t slice) { tcb-prio prio; tcb-state TASK_READY; tcb-slice slice; tcb-remain slice; tcb-sp task_stack_init(tcb, entry, arg); task_table[prio] tcb; set_ready(tcb); }启动调度器时系统还没有一个正在运行的任务所以第一次切换是特殊场景。我的做法是把 current_tcb 置空手动触发一次 PendSV让调度逻辑从就绪表里选出最高优先级任务然后走正常的上下文切换流程启动它void scheduler_start(void) { current_tcb NULL; // 触发 PendSV执行第一次切换 SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; }第一次切换和后续切换共用了同一套 PendSV 入口区别只在 schedule_next 里如果 current_tcb 是空指针就不把当前任务放回就绪表直接从就绪表取第一个目标任务。这个空指针判断虽然只有一行但漏掉它时调度器启动就会乱套新任务还没跑起来就被当成旧任务保存现场了。3.3 PendSV 切换现场的汇编实现这一节是整个调度器里最硬的部分。我用的核心是 Cortex-M3/M4 内核切换代码用内嵌汇编写在 naked 函数里确保编译器不往函数头尾插入任何指令。完整实现如下__attribute__((naked)) void PendSV_Handler(void) { __asm volatile( CPSID I\n // 关中断保护现场切换过程 MRS R0, PSP\n // 拿到当前任务的进程栈指针 STMDB R0!, {R4-R11}\n // 把需要手工保存的寄存器压栈 LDR R1, current_tcb\n LDR R1, [R1]\n STR R0, [R1, #0]\n // 保存 sp 到当前 TCB 的第一个字段 BL schedule_next\n // 选择下一个任务返回值在 R0 LDR R1, current_tcb\n LDR R1, [R1]\n LDR R0, [R1, #0]\n // 取出目标任务的 sp LDMIA R0!, {R4-R11}\n // 恢复 R4-R11 MSR PSP, R0\n // 更新 PSP指向目标任务栈中的异常帧 CPSIE I\n // 恢复中断 BX LR\n // 异常返回硬件自动弹出剩余寄存器 ); }这段代码有两个地方值得细看。第一个是LDR R1, current_tcb\n这种写法它依赖汇编器的字面量池来装载 C 全局变量的地址如果你用的不是 ARM 编译器而是别的工具链可能需要换成current_tcb的 GOT 定位方式。第二个是 STMDB 和 LDMIA 的组合STMDB 把 R4-R11 压到 PSP 指向的低地址LDMIA 恢复时又是从低地址开始弹正好一一对应。值得提醒的是schedule_next 的返回值 R0 在上面的代码里其实被绕过了。我在汇编里重新从 current_tcb 里取了 sp所以 BL schedule_next 的返回值没有直接使用。这也行但如果你想让 schedule_next 直接返回目标任务指针后续记得不要把 R0 弄丢。3.4 时钟节拍与时间片轮转SysTick 配置成 1ms 触发一次在中断优先级上让它的优先级高于 PendSV。SysTick 里做的事情很克制把当前任务的 remain 减一减到 0 就认为时间片耗尽置位 PendSV 请求切换。void SysTick_Handler(void) { if (current_tcb current_tcb-state TASK_READY) { current_tcb-remain--; if (current_tcb-remain 0) { current_tcb-remain current_tcb-slice; // 当前任务仍然就绪但时间片用完让调度器重新选择 request_schedule(); } } } static void request_schedule(void) { SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; }任务主动让出 CPU 的接口也很简单同样走 PendSVvoid task_yield(void) { __disable_irq(); request_schedule(); __enable_irq(); }main 函数里的使用示例TCB tcb_led; TCB tcb_print; void led_task(void *arg) { while (1) { // 翻转LED } } void print_task(void *arg) { while (1) { // 输出调试信息 } } int main(void) { SysTick_Config(SystemCoreClock / 1000); // 1ms 节拍 task_create(tcb_led, led_task, NULL, 1, 5); // 优先级15ms 时间片 task_create(tcb_print, print_task, NULL, 2, 2); // 优先级22ms 时间片 scheduler_start(); while (1) { } }这个调度器的工作方式可以总结成一句话SysTick 负责催更PendSV 负责换人。当两个就绪任务的优先级相同时间片轮转让它们交替运行当高优先级任务就绪它会在下一个节拍抢占 CPU。这套机制凑齐了抢占式调度的最基本要素优先级、时间片、上下文切换、节拍驱动。4. 调试中踩过的坑与排查技巧4.1 栈溢出是头号杀手手写调度器之后我遇到的第一个大坑就是任务栈溢出。任务函数里随便调用一个格式化输出函数再碰上一个稍微复杂点的调用链栈空间就可能不够用。Cortex-M 上任务栈是从高地址向下生长的我在调试时发现某个任务跑着跑着就进 HardFault查看现场才知道是写到栈底之外去了把相邻 TCB 的内容覆盖得一塌糊涂。我的处理办法分两步第一步是给每个任务栈加上水印填充模式初始化时把整块任务栈填成固定的 0xA5 模式任务运行一段时间后再扫描栈空间看 0xA5 被破坏的最高水位据此估算实际栈用量。第二步是在任务切换时检查当前任务 sp 是否越过了栈底边界一旦越界就强制让系统进入错误状态同时把故障任务编号打印出来。这个检查在正式发布时可以关掉但开发调试阶段一定不要省。实际经验是带有通信协议栈的优先级高任务栈大小最好从 512 字节起步只做简单位操作和状态机的任务256 字节往往就够。宁可一开始给大测出水印后再慢慢削减也不要一上来就精打细算否则排查栈溢出比调功能还要费时间。4.2 临界区的粒度控制调度器里凡是访问共享资源的代码比如操作 ready_mask、修改任务状态都需要关中断保护。我见过很多人在写这类内核时习惯开一个全局大锁把所有内核操作都放在关中断区间里执行省心是省心但代价是中断响应延迟被拉长到难以接受的地步。我的经验是把临界区拆到最小就绪表位运算就那两三行关中断保护这两三行就够了时间片更新在 SysTick 里做本身就在中断里不需要再额外关。等到后面加消息队列、同步原语时再仔细斟酌每个临界区的范围。如果发现某个临界区超过了十几条指令就要回头看看有没有必要把它拆短。另外一个容易忽视的问题是关中断的嵌套。如果某段代码在中断已经关闭的情况下再次调用__disable_irq()之后可能因为层级不对导致中断永远关不上或者提前打开。我在实现里用一个嵌套计数记录关中断的层级只有计数归零时才真正打开中断这个细节建议在第一批代码里就做进去。4.3 优先级设计与饥饿问题抢占式调度有个天生的毛病叫优先级饥饿最高优先级的任务永远就绪低优先级任务可能永远跑不到。我的调试项目里第一次出现这个现象时现象很直观——两个低优先级任务完全不动系统时间片全部被高优先级任务吃掉。解决饥饿不是剥夺高优先级的权利而是保证高优先级任务足够克制。高优先级任务应该只做紧急且短小的工作一旦做完立刻进入阻塞或延时状态把 CPU 让给低优先级任务。如果某个高优先级任务的逻辑本身就需要大量 CPU 时间那就该重新审视它的优先级是不是定得太高了。优先级设计建议从应用需求倒推硬实时任务周期采样、电机环给最高优先级中等实时任务通信、按键给中间后台任务显示刷新、日志给最低。同一个优先级尽量少放多个任务因为时间片轮转本身会引入不可避免的切换开销优先级档位够用就行不要搞出二三十个档位。4.4 快速定位问题的检查清单手写调度器出问题时往往是多个因素叠加不是单一原因。我把几次排查经历整理成一张检查清单按顺序排查能省下大量时间。问题现象可能的根因快速排查方法第一个任务起不来栈帧初始化错误调试器查看新任务 sp 指向的栈内容是否按预期排列任务跑着跑着进 HardFault任务栈溢出或指针越界打开栈水印检查看破坏的水位线高优先级任务抢不到 CPUPendSV 优先级设置不正确检查 NVIC 里 PendSV 是否被设置为最低优先级任务切换偶尔乱序临界区保护不足就绪表被同时改写临时关掉 SysTick只靠 yield 切换观察是否复现系统偶尔卡死看门狗复位某任务关闭中断后没有恢复在所有__enable_irq()上打断点查嵌套计数这张表不是标准答案但它代表了排查调度问题时的基本思路先怀疑栈再查中断优先级最后看共享数据保护。多数调度器 bug 最终都落在这三块。5. 从调度器到小型 RTOS还能扩展什么5.1 阻塞机制缺失时的调度器局限前面实现的调度器有一个明显短板只要有高优先级任务就绪低优先级任务就只能靠高优先级任务主动让出时间片来获得一点 CPU 时间。没有阻塞机制的话低优先级任务实际上会饿死。要让低优先级任务有喘息空间就得给任务加一个延时阻塞接口。最简单的延时队列可以在 TCB 里放一个 delay_ticks 字段SysTick 里遍历所有状态为 DELAY 的任务把剩余延时递减减到 0 就恢复 READY 并放回就绪表。任务调用 task_delay 时把自己从就绪表移除状态改成 DELAY然后再触发一次重调度void task_delay(uint32_t ms) { __disable_irq(); clear_ready(current_tcb); current_tcb-state TASK_DELAY; current_tcb-delay_ticks ms; request_schedule(); __enable_irq(); }有了这个接口任务之间的协作方式立刻丰富起来高优先级任务每 10ms 读一次传感器其余时间阻塞低优先级任务在后台持续刷新屏幕不用再担心被饿死。我建议在第一批功能里就把 delay 加上没有它调度器只能算一个花架子。5.2 信号量、消息队列与优先级继承再往后扩展就是信号量和消息队列。信号量的核心是让任务可以等待资源等待期间任务从就绪表移除信号量释放时再唤醒对应任务。消息队列本质上是一块共享内存加上前后的临界区保护发送方写入数据时唤醒等待接收的高优先级任务。这里有一个隐藏的大坑叫优先级反转低优先级任务持有高优先级任务需要的资源结果低优先级任务反而把高优先级任务的执行拖住了。简单场景下可以通过关中断缩短保护区间来缓解但更通用的方案是优先级继承当高优先级任务等待低优先级任务持有的资源时临时把低优先级任务的优先级提升到和自己一样高低优先级任务一旦释放资源优先级立即恢复。这套机制在小型调度器里实现起来并不轻松但理解它的存在对不同调度器设计之间的取舍会理解更到位。5.3 手写调度器适合哪些项目不适合哪些项目做了这套调度器之后我有了一个比较清晰的边界它适合资源受限、逻辑结构清晰、实时要求可以量化的嵌入式项目典型如温控器、遥控器、电池管理、小型电机控制。这类项目任务数量不多优先级模型固定调度器的代码和 RAM 开销完全可以接受而且出了问题能自己定位到底层。但如果项目要求复杂的文件系统、网络协议栈、多核并发或者团队需要快速交付而自己维护内核的时间成本很高那就老老实实用成熟 RTOS不要为了情怀手写内核。手写调度器的价值在于学习原理和掌控灰度而不是用在一个已经需要丰富资源的系统里去替代成熟内核。自己写的调度器就像自己组装的机械键盘手感可能很爽但你不会拿它去写一部几十万字的长篇小说工具用对了场景才有意义。我在做完这套调度器后最大的收获不是我会写 RTOS 了而是以后遇到任何关于任务切换、优先级、临界区的问题脑子里都能浮现出从任务栈到寄存器再到就绪表的一条完整链路。调试时我甚至能靠看反汇编指令估计出一次任务切换要花多少周期这种底层的掌控感是直接用现成内核很难获得的。如果你正被裸机多任务的轮询顺序折磨不妨抽一个周末照着上面的思路自己写一个最小的抢占式调度器。不用追求功能全面跑起两个任务、看着它们按优先级和时间片交替运行那一刻你对调度的理解会发生质的改变。后续再有中断优先级、临界区、优先级反转这些概念冒出来时你也会知道它们各自站在调度器这条链路的哪个位置上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑