做这个事情的起因其实是我在看一块开发板的参考手册时发现里面自带的那个所谓“系统”其实就是一个裸机轮询所谓多任务不过是前台后台的变种。当时我就想能不能自己动手在底层写一个抢占式调度器不是调库不是用现成的RTOS而是从零开始把任务切换、中断管理、调度算法这些东西一笔一划地写出来。这篇文章就是我当时落地过程的完整记录包含了设计思路、关键结构体的定义、汇编切换的细节、调度算法的选择还有我调试时踩进去的那些坑。如果你正在学操作系统原理觉得“线程切换”这个概念太抽象或者你手上有个裸机项目想把简单任务调度做进固件里又或者你就是纯粹想搞清楚所谓“抢占”这两个字在底层到底是怎么实现的——这篇文章应该能帮你省下不少时间。1. 整体设计思路与方案选型1.1 为什么非要自己写而不是直接上RTOS我知道很多人会问这个问题。现成的RTOS很多功能完整、文档齐全内核几K到几十K都有。我当时的想法很简单用现成的RTOS你永远只会在“创建任务、设置优先级、调用API”这个层面操作内核到底怎么把CPU从任务A切换到了任务B你心里是没有那个画面的。手写调度器最大的价值不是“造轮子”而是把《操作系统原理》里那几页纸变成你能亲眼看到、亲手调试的几百行代码。中断一来CPU怎么保存现场调度器怎么挑中下一个任务挑中之后怎么恢复现场、让那个任务从上次停下的地方继续跑——这些过程一旦自己写过就再也忘不掉了。而且在某些场景下比如资源极少的小单片机中断频率极高、要求纳秒级响应的场景一个按需裁剪的自研内核往往比移植一个通用RTOS更可控。1.2 抢占式调度模型的核心框架先说清楚“抢占式”这三个字。通俗地说就是只要有一个更高优先级的任务处于就绪状态当前正在运行的低优先级任务就必须立刻让出CPU不用等它自己主动交出来。要实现这个机制硬件上需要一条关键的“鞭子”——一个能周期性产生中断的定时器比如系统节拍定时器。每次定时器中断一进来内核就趁机检查一遍当前任务的优先级还是最高的吗如果不是就抢。整个调度器的核心框架可以拆成四块任务控制块TCB每个任务的所有状态都放在一个结构体里这是内核管理任务的最小单位。就绪队列用于追踪哪些任务当前是“可以运行”的状态。上下文切换保存当前任务的寄存器现场把CPU交给下一个任务。调度算法在就绪任务中选出“下一个应该运行谁”。这四块就像一个公司的四个部门TCB是员工档案就绪队列是待命名单上下文切换是交接仪式调度算法是排班经理。1.3 平台与工具选型写调度器离不开具体的硬件载体。我选用的是ARM Cortex-M3内核的开发板主要原因是ARM的NVIC中断控制器天然支持抢占优先级硬件上就帮我们实现了“中断嵌套”Cortex-M3内核有一个非常方便的硬件特性PendSV异常专门用来做上下文切换不用手动处理中断返回地址的压栈细节开发板便宜调试器也方便。软件方面我用的是GCC交叉编译工具链配合OpenOCD和GDB做在线调试。整份代码纯粹是C语言加上一小段汇编没用任何平台厂商的库函数保证调度器的逻辑干干净净便于移植。我在代码目录里建了三个核心文件kernel.h、kernel.c、switch.S。分工非常明确头文件放结构体和API声明C文件放调度逻辑和定时器回调汇编文件放寄存器保存与恢复的上下文切换代码。2. 任务控制块设计与就绪队列管理2.1 任务控制块的数据结构任务控制块是整个调度器的“定海神针”。我设计的TCB结构体长这样typedef struct tcb { uint32_t stack_ptr; // 栈顶指针 uint32_t stack_size; // 栈总大小 uint8_t priority; // 任务优先级 uint8_t state; // 任务状态 char name[8]; // 任务名称 void (*entry)(void *arg); // 任务入口函数 void *arg; // 入口参数 struct tcb *next; // 用于链表挂接 } tcb_t;stack_ptr是每个任务私有栈当前的位置它指向的是“上一次任务被切换出去时栈指针停在哪里”。这个字段决定了一个任务被重新调度时CPU从哪个地址继续取数。这里我要特别强调一下priority。在我这个调度器里数值越小优先级越高因为后面选任务用的是位扫描或线性比较数值小的容易排前面而且这个习惯和Cortex-M的中断优先级编号逻辑保持一致不容易记混。2.2 就绪队列组织链表还是位图就绪队列最简单的做法是链表。每个优先级下面挂一串任务调度时从优先级最高的那一级链表里取队头。但实际做过一轮后我觉得如果你的系统任务数不多优先级等级也不多那么位图法 固定优先级列表更香。我实现的是一个多级就绪链表组用一个32位的整型来当位图#define MAX_PRIORITY 8 // 只用了8个优先级等级 static tcb_t *ready_queue[MAX_PRIORITY]; // 每级就绪链表头 static uint32_t ready_bitmap 0; // 哪些级别非空入队的时候把任务按优先级挂到对应链表同时把对应位图位置1void add_ready(tcb_t *task) { task-state READY; if (ready_queue[task-priority] NULL) { ready_queue[task-priority] task; task-next NULL; ready_bitmap | (1U task-priority); } else { tcb_t *head ready_queue[task-priority]; task-next head; ready_queue[task-priority] task; } }出队的时候用一条指令就能找出当前最高非空优先级uint32_t highest __builtin_clz(ready_bitmap); // 数前导零 tcbc_t *next ready_queue[31 - highest];__builtin_clz在GCC里会被编译成单片机的一条CLZ指令速度极快比循环遍历链表判断优先级快得多。这一块算是我整个项目里最早做的几处优化之一。2.3 任务状态流转任务的状态一共就三种运行中RUNNING、就绪READY、阻塞BLOCKED。我这里的调度器为了保持精简没有实现真正的延时阻塞队列而是用一个简单的“睡眠计数器”模拟阻塞static volatile uint32_t sleep_tick[MAX_TASKS];任务调用task_sleep(ms)时把自己的sleep_tick设为要等待的节拍数状态置为BLOCKED然后主动让出CPU。每个系统节拍中断里内核把所有sleep_tick 0的任务减一减到0了就把任务重新放回就绪队列。这里有个细节值得记录一下状态切换必须在关中断的环境下做。为什么因为如果一个人在中断里被置为READY同时一个人在外面被置为BLOCKED两个人同时操作同一个任务就是数据竞争。我整个内核里关中断的力度控制得很保守只在操作链表和状态字段的时候关一离开马上开避免影响系统实时性。3. 上下文切换调度器的灵魂3.1 理解Cortex-M3的压栈机制上下文切换是手写调度器最核心、也是初学者最容易卡壳的地方。在Cortex-M3上这一块相对友好因为硬件替你完成了一部分工作。当异常比如SysTick中断发生时CPU会自动把xPSR、PC、LR、R12、R3~R0这8个寄存器压入当前任务的栈中。然后进入异常处理函数这时你只需要在异常处理函数里再手动压入剩余的4个通用寄存器R4~R11这样这个任务的整个上下文——一共16个寄存器——就完整保存了。关键点在于每个任务都有自己独立的栈空间。所以每当任务被切换出去发生压栈的位置都是在它自己的栈里每当任务被选进来出栈恢复的也是它自己栈里的东西。我画了个简单的数据流图这里用文字描述一下任务A运行 → SysTick中断 → A的剩余寄存器压入A栈 → 调度器选中B → 弹B栈 → 从B的断点继续执行。这个流程每1毫秒可能发生好几次而你要保证几十个任务跑下来谁的现场都不能乱。3.2 触发上下文切换的两种方式在Cortex-M3上触发切换业界标准做法是利用PendSV异常而不是直接在SysTick中断里切换。为什么多此一举因为如果直接在中断里做切换中断返回时需要用一堆技巧去改LR链接寄存器和异常返回栈帧很容易搞错。而PendSV是“挂起”到所有中断处理完毕之后才执行的优先级可以设为最低这样就不影响其他中断的响应。我的实现里有两个切换入口系统节拍中断SysTick Handler主要处理时间片轮转和超时唤醒然后置位PendSV任务主动让出task_yield直接触发PendSV。为了区分这两种情况下的返回行为我在切换函数里做了个很小的状态标记。3.3 汇编实现上下文切换这是整个项目最精妙的几十行汇编也是我花时间最长的地方。核心代码如下.syntax unified .cpu cortex-m3 .thumb .global os_switch_context .global os_svc_init .extern current_tcb .extern next_tcb .extern os_sched_run .thumb_func os_switch_context: MRS R0, PSP 获取当前任务的栈指针 SUBS R0, R0, #32 在栈上留出8个寄存器位置 STMIA R0!, {R4-R11} 手动保存R4~R11 LDR R1, current_tcb STR R0, [R1] 更新当前任务TCB中的栈指针 调用C语言调度函数它会将 next_tcb 指向新的任务 PUSH {LR} BL os_sched_run POP {LR} 从 next_tcb 获得新任务的栈指针 LDR R1, next_tcb LDR R0, [R1] LDR R2, [R0] 栈指针是第一个字段 MSR PSP, R2 恢复新任务的寄存器 LDMIA R2!, {R4-R11} ADDS R2, R2, #32 跳过硬件自动恢复的区域 MSR PSP, R2 BX LR这段汇编的逻辑是切换前的现场全部压在旧任务的栈里切换后的现场从新任务的栈里弹出来。中间的os_sched_run函数负责寻找“下一个该跑谁”然后把全局变量next_tcb赋值好。有一个特别容易栽跟头的地方PSP进程栈指针的恢复时机。我在第一版代码里把MSR PSP, R2放在了恢复R4~R11之后结果每次切换回来任务的数据全花屏GDB看寄存器发现R4~R11的值是对的但栈指针错了一拍。后来我意识到只要PSP被更新了紧接着的“异常返回”就会从新栈里自动弹那8个硬件寄存器的内容。如果PSP没设对整个恢复过程全部白搭。3.4 任务第一次启动伪造一个“初始现场”新任务从来没有被切换出去过它的栈里自然没有所谓的“上次保存的现场”。所以要在创建任务的时候往它的栈里“伪造”一份现场。我在任务创建函数里做了一件看起来很像魔术的事void task_create(tcb_t *task, uint8_t prio, void (*entry)(void *), void *arg) { task-priority prio; task-entry entry; task-arg arg; task-state READY; uint32_t *sp (uint32_t *)((uint8_t *)task_stack_buffer task-stack_size); *(--sp) 0x01000000; // xPSRThumb模式使能 *(--sp) (uint32_t)entry; // PC任务入口地址 *(--sp) (uint32_t)task_exit; // LR任务退出后的去处 *(--sp) 0; // R12 *(--sp) 0; // R3 *(--sp) 0; // R2 *(--sp) 0; // R1 *(--sp) (uint32_t)arg; // R0参数 // 再留出R4~R11的位置初值清零 for (int i 0; i 8; i) *(--sp) 0; task-stack_ptr (uint32_t)sp; add_ready(task); }这里有个细节必须讲清楚这个伪造的栈布局必须和“硬件异常压栈”的布局完全一致。也就是说栈顶往下第一个8个寄存器的顺序必须是xPSR、PC、LR、R12、R3、R2、R1、R0然后再是R4~R11。凡是顺序搞错的任务一运行就疯跑PC指到哪去都不知道。xPSR那个0x01000000是为了让CPU知道这是一个Thumb指令代码。Cortex-M只有Thumb模式这个位如果不置1进异常返回时直接进HardFault。4. 调度算法设计与实现4.1 优先级抢占调度我的调度算法是最简单也最符合教科书描述的总是选择就绪态中优先级最高的那一个。但有一种特殊情况需要考虑假设任务A优先级最高而任务B和任务C已经阻塞在延时上此时就绪队列里只有A那调度器就要决定如果没有别的就绪任务还是继续跑A吗我的处理很简单os_sched_run里如果就绪队列只有一项那就原地不动恢复到当前任务自己——相当于一次“自切换”没有任何实际影响但代码流程保持统一。抢占的触发点是定时器中断。不管当前跑的是哪个任务每一次定时器中断进来我都要重新扫描一遍就绪队列看有没有比当前任务更高优先级的任务在等。这个“重新扫描”就是抢占发生的全部秘密。4.2 时间片轮转的取舍我在当初设计的时候纠结过要不要实现时间片轮转Round Robin。轮转模式指的是同一优先级下多个任务轮流跑每个任务分配固定的时间片。最终我的做法是先在框架里留了轮转的接口但默认关闭。原因有两条大多数实时小系统里任务数量少优先级划分清晰靠抢占已经能很好工作轮转需要额外记录“上次跑到哪个任务了”调度器的状态变量会变多调试复杂度提升。我留下了一个宏开关CONFIG_ROUND_ROBIN把它打开后调度器在同级队列中会做简单的先进先出轮转。这个开关在后续扩展中是很有价值的一个入口。4.3 多级就绪队列和位图的实现细节位图法的高效性依赖两个关键假设优先级等级有限如8或32级且每个优先级下面允许挂多个任务。这两个假设在小型嵌入式系统里完全成立。我的双键表指位图和链表配合实现里有一个我踩坑才学会的教训出队时要从位图里找到最高优先级但取任务时不能随意删除链表里的节点。正确做法是从该优先级的链表头取出任务如果取的是中间节点就必须把前后节点接上再清除位图的对应位。否则下一次调度这个优先级明明没任务了位图还是置1扫描就会一直跳到这里取一个空指针。我第一版代码里偷懒只从链表头取任务结果出现了假死机。当时的表象是两个同优先级任务轮流跑跑久了系统就卡住了GDB看一看发现就绪队列链上有个任务节点的next指回了自己——循环链表了。这就是删除节点时指针没理清导致的。tcb_t *sched_remove_highest(void) { if (ready_bitmap 0) return NULL; uint8_t prio 31 - __builtin_clz(ready_bitmap); tcb_t *task ready_queue[prio]; ready_queue[prio] task-next; if (ready_queue[prio] NULL) { ready_bitmap ~(1U prio); } task-next NULL; return task; }这样就把链表头和位图维护成一致的了。5. 定时器与系统节拍机制5.1 让调度器有“心跳”没有节拍抢占式调度就是一句空话。我在项目里把SysTick定时器的中断周期设为1ms。SysTick是Cortex-M内核自带的简单定时器配置起来几乎没有成本。void systick_init(void) { SysTick-LOAD (SystemCoreClock / 1000) - 1; // 1ms 中断 SysTick-VAL 0; SysTick-CTRL SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk; }这个1ms被称为一个tick是调度器的时间粒度。所有任务的延时都是以tick为最小单位来计算的。5.2 基于心跳的延时实现我在2.3节提到了睡眠计数器这里展开细说。每个TCB里有个sleep_tick字段当任务调用task_sleep时并不是让CPU真的空转而是在函数里做这几件事记录要睡眠的tick数把任务状态改为BLOCKED调用调度器主动让出CPU。之后这个任务就消失在就绪队列里了。SysTick中断每次会对所有BLOCKED的任务做一次sleep_tick--一旦减到0就把它重新放回就绪队列。这样实现的睡眠机制CPU不会停车等待而是继续跑其他任务效率比裸机里的delay_loop高出一大截。5.3 时间戳与运行统计的扩展点调度器写到这里我开始意识到一个很实用的功能既然SysTick中断每次都在跑我完全可以利用它实现一个系统运行时钟。我在内核里维护了一个64位的全局计数器static volatile uint64_t tick_counter; uint64_t system_ticks(void) { return tick_counter; }后来我还根据每个任务在调度时的状态在TCB里加了两个字段runtime_total用于统计每个任务的累计运行时间。方法很简单调度器选中某个任务时记录下来当前tick数切换走的时候把差值累加进这个任务的运行时间。这一块做出来后我调试优先级问题时方便极了每个任务到底吃了多少CPU一眼就能看出来。6. 临界区与互斥保护6.1 为什么不能用“关全局中断”一关了之内核里修改任务状态、操作就绪队列这些都属于临界区代码。最简单的保护方式就是关中断进入临界区前__disable_irq()退出后__enable_irq()。但这样做有一个非常大的隐患如果在关中断期间进入了一个耗时较长的循环那么系统对中断的响应就完全被堵死了。对实时系统而言这等于自杀。我采取的方式是克制的临界区保护uint32_t primask_val __get_PRIMASK(); __disable_irq(); // 仅仅在这几行代码里做链表操作 __set_PRIMASK(primask_val);注意这里用__set_PRIMASK(primask_val)恢复而不是直接__enable_irq()是因为如果进入临界区之前中断本来就是关着的恢复现场时直接开中断会把之前的状态搞乱。6.2 中断里的调度器入口SysTick中断处理函数是我的调度器另一只脚。它的C代码看起来精简但顺序很关键void SysTick_Handler(void) { tick_counter; update_sleeping_tasks(); if (need_resched) { SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; // 触发PendSV } }我在SysTick中断里只做最少的事推进时间、唤醒延时结束的任务、标记有没有必要切换任务。真正耗费精力的上下文切换被推迟到PendSV里做这样SysTick中断能快速返回其他中断也几乎感受不到调度器的存在。我在调试nested interrupt时发现一个很重要的细节在PendSV触发之前可以嵌套中断但一旦PendSV开始执行就不允许再被普通中断打断。为此我在PendSV入口处设置了临界区保护同时在PendSV里禁用了部分中断源确保切换过程是原子的。6.3 优先级反转问题的简化处理说到这里就不得不提一下优先级反转。这是抢占式调度器最经典的痛点高优先级任务在等待一个低优先级任务占用的资源时会被中等优先级任务插队导致高优先级任务空等。我的小内核里没实现优先级继承。我采取的方式是让互斥量如果后续加的话只能被同一个优先级级别的任务访问或者使用锁持有者优先级临时提升。就当前这个阶段我其实主要靠临界区保护让共享数据的访问都在极短时间内完成从而把反转窗口压缩到最小。如果后续你需要处理更复杂的互斥关系可以考虑在每个互斥量上挂一个“持有者优先级提升”字段在获取互斥量时把持有者优先级临时提到等待者最高优先级。这个机制就是优先级继承实现难度不大但效果非常明显。7. 实测与调试那些踩进去又爬出来的坑7.1 调试环境和工具链我用的工具链是ARM GCC配合OpenOCD和GDB连接开发板。硬件调试最痛苦的地方莫过于“看不到内存变化”所以我很依赖GDB的watchpoint。在调试上下文切换时我给current_tcb-stack_ptr设了一个硬件观察点每当栈指针被更新就停下来这样我能清楚地看到切换发生的那一瞬间发生了什么。还有一个小技巧把日志输出重定向到UART然后在关键位置打印一个字符比如切换时打S中断时打I。用示波器看UART波形能够看出任务切换的节律是否稳定。这个方法看着土但在验证调度周期时特别好使。7.2 问题一第一次上板任务A跑完后任务B不跑现象描述任务A正常跑但主循环里调用task_yield切换后任务B只跑了一次之后系统就死住了。排查过程GDB看就绪队列发现任务B确实还在队列里状态也是READY位图对应位也是1。看起来一切正常。但我用GDB单步到切换函数发现next_tcb指向的地址和current_tcb一样。原因找到了任务B的栈指针被错误地设置成了任务A的栈地址。具体来说是我在task_create时用了一个错误的栈数组基址——所有任务都指向同一个缓冲区第二个任务创建时把第一个任务的栈顶覆盖了。这个教训很简单每个任务必须有独立的栈空间而且这个空间得在任务存在期间一直保持有效绝对不能是局部数组。7.3 问题二跑一会后任务数据全被破坏现象描述系统能跑但跑几秒钟后某任务里的一个全局变量值突然变成一个离谱的数。排查过程我用GDB在变量变化的位置设置条件断点发现变化点不是在业务代码里而是在上下文切换的汇编代码里。当时我有个宏定义错了把任务栈大小设成了64字节结果任务每次调用子函数时栈溢出把相邻任务或内核数据全压坏了。这个问题的典型特征是“随机性”如果某任务只在特定调用路径上会溢出就会在某个特定时序下突然炸掉。解决方式是把栈空间拉大同时在任务创建时对栈区填一个固定的模式字节如0xAA运行一段时间后检查栈区域有没有被改写。这个方法比看GDB更高效能自动识别出栈深度告急的任务。7.4 问题四进HardFaultR0寄存器奇奇怪怪现象描述任务切换几次后进HardFaultGDB看R0的值像是一个野指针。排查过程我用GDB查看HardFault发生时的栈帧发现PC跳到了一个不存在的地址。这种问题十有八九是任务入口地址写错或者寄存器保存恢复不对。后来查了很长时间发现是任务函数的指针用了函数指针转换时地址被截断了。在Cortex-M3上函数地址通常带bit0位为1表示Thumb模式。我如果直接把函数地址强制转成uint32_t就丢了那个位。解决方式很简单就是取地址时保留最低位或者直接用uint32_t)(uintptr_t)entry的方式转换。8. 后续扩展方向与个人心得调度器跑稳定之后我给它陆续加了几个功能支持任务间通过“邮箱”传递消息一个任务往邮箱里塞数据另一个任务阻塞等待邮箱数据支持动态创建和销毁任务支持任务优先级动态调整。这些都建立在TCB链表管理成熟的基础上。内核代码从刚开始的三百行涨到了八百行左右但核心调度思路没有变。如果要说我在这个过程里最深的一点体会那就是写调度器的难点不在于实现而在于明白“中断发生时系统的状态是什么”。只要把“当前任务、就绪队列、栈指针”这三个顶点之间的关系在脑子里建立了清晰图示那么无论是调度算法还是上下文切换都不会再有什么神秘感。最后再分享一个小技巧如果你也被某个诡异的调度Bug折磨到崩溃不要一上来就翻代码。先把任务数量减到两个再把SysTick定到10ms让自己能看清节奏最后加一个UART打印在每个任务切换点打一个标记。这种“降维调试法”我百试百灵。调度器这东西看起来高深真正从头写一遍后你会发现它无非就是那么几件事选谁、换谁、保护谁。剩下的事都是工程细节。