
1. 从一个最小系统板说起为什么要自己写调度器手头这块 STM32F103C8T6 最小系统板估计是很多人入门嵌入式时买的第一块板子。几十块钱72MHz 主频64KB Flash20KB SRAM资源不算富裕但足够折腾。我当初拿它跑裸机程序前后台架构写久了就发现一个问题主循环里塞的任务一多稍微有个延时或者阻塞操作整个系统的响应就变得一塌糊涂。按键要等、串口要等、OLED 刷新也要等用户体验极差。这时候自然会想到上 RTOS。FreeRTOS、RT-Thread 这些确实成熟但说实话对于只想搞清楚任务切换到底是怎么回事的人来说直接上现成 RTOS 反而容易停留在 API 调用层面底层机制始终隔着一层纱。所以我就动了念头干脆自己手写一个抢占式调度器把 Cortex-M3 内核的 PSP、PendSV、SysTick 这几个关键机制彻底吃透。这个项目做的事情很明确在 STM32F103C8T6 上不依赖任何现成 RTOS从零实现一个支持多任务、支持优先级抢占、支持任务延时和阻塞的调度器。核心用到的是 Cortex-M3 内核提供的双堆栈机制MSP/PSP、PendSV 异常做上下文切换、SysTick 定时器做时间片基准。做完之后你会发现所谓任务切换本质上就是保存一组寄存器、恢复另一组寄存器再配合栈指针的切换没有想象中那么神秘。适合谁来参考我觉得有三类人比较合适一是学过 STM32 裸机开发、想深入理解 RTOS 原理的二是面试被问到任务切换怎么实现的答不上来的三是想给自己项目加个轻量级调度但嫌 RTOS 太重的。前提是你得会用 Keil 建工程、会点汇编基础、看得懂寄存器手册。如果这些还不熟建议先把标准库工程模板搭起来跑通点灯再说。2. 整体设计思路为什么是 PSP PendSV SysTick 这套组合2.1 双堆栈机制MSP 和 PSP 的分工Cortex-M3 内核有两个栈指针主栈指针 MSP 和进程栈指针 PSP。复位后默认使用 MSP所有异常处理程序包括中断都跑在 MSP 上。而我们的任务代码应该跑在 PSP 上。为什么要这样分道理很简单如果任务和中断共用同一个栈那么当任务栈用得很深的时候一个中断进来压栈就可能把任务栈撑爆。分开之后中断永远用 MSP任务永远用 PSP两者互不干扰。中断里就算嵌套再多层也只消耗主栈空间任务栈的大小可以精确控制。切换栈指针靠的是 CONTROL 寄存器的 bit1。写 1 就用 PSP写 0 就用 MSP。注意这个操作在特权模式下才能做而且切换后最好跟一个 ISB 指令保证流水线同步。我一开始没加 ISB偶尔会出现莫名其妙的跑飞后来加上就稳了。2.2 PendSV为什么选它做上下文切换Cortex-M3 有一堆异常为什么偏偏选 PendSV 来做任务切换这是有讲究的。PendSV 是可挂起的系统调用异常它的优先级可以设成最低。设成最低的好处是如果当前正在处理其他中断PendSV 会被推迟直到所有高优先级中断处理完才执行。这样就避免了在中断嵌套中间做任务切换导致的混乱。举个例子SysTick 中断触发本来想切任务但此时恰好有个串口中断也在处理。如果直接在 SysTick 里切任务那串口中断的现场就可能被破坏。而用 PendSVSysTick 里只负责把 PendSV 挂起写 NVIC 的 STIR 寄存器或者 ICSR 的 PENDSVSET 位等所有中断都处理完了PendSV 才真正执行切换。这个设计非常优雅。2.3 SysTick时间基准从哪来SysTick 是内核自带的一个 24 位递减计数器专门用来产生周期性的时间基准。我们用它来驱动调度器的心跳。STM32F103C8T6 主频 72MHz如果我想让 SysTick 每 1ms 中断一次重装载值就是 72000 - 1 71999。SysTick 中断里做两件事一是给系统 tick 计数加一二是判断当前任务的时间片是否用完如果用完就触发一次调度挂起 PendSV。注意 SysTick 中断本身的优先级要设得比 PendSV 高否则 PendSV 可能被自己阻塞。2.4 任务控制块的设计每个任务需要一个身份证记录它的栈指针、状态、优先级、延时计数等信息。我定义了一个 tcb_t 结构体typedef struct tcb { uint32_t *sp; // 任务栈指针切换时的关键 uint32_t state; // 任务状态就绪/运行/阻塞/挂起 uint32_t priority; // 优先级数字越小优先级越高 uint32_t delay; // 延时剩余 tick 数 void *stack_base; // 栈底用于释放 struct tcb *next; // 就绪链表指针 } tcb_t;这里 sp 字段是核心中的核心。任务切换时我们保存的就是这个 sp恢复任务时我们加载的也是这个 sp。它指向任务栈中保存寄存器现场的位置。2.5 就绪链表与调度策略我用一个单向链表把所有就绪任务串起来调度器每次从链表头取任务。为了支持优先级抢占插入新任务或者任务状态变化时会按优先级排序插入。这样链表头永远是目前最高优先级的就绪任务。抢占的触发点有两个一是 SysTick 时间片到期二是高优先级任务从阻塞中唤醒。前者在 SysTick 中断里判断后者在 SysTick 里遍历延时链表时判断。只要发现更高优先级的任务就绪就挂起 PendSV 触发切换。3. 核心细节拆解栈帧、汇编与寄存器那些事3.1 任务栈的初始化伪造一个现场新建任务的时候任务还没跑过栈里是空的。但切换机制要求栈里必须有一组看起来像刚被中断打断的寄存器现场。所以我们要手动往栈里填数据这个过程叫伪造现场。Cortex-M3 在异常入口会自动压栈 8 个寄存器xPSR、PC、LR、R12、R3、R2、R1、R0。注意压栈顺序是 R0 在最低地址栈顶xPSR 在最高地址。所以初始化时我们要按这个顺序从高到低填uint32_t *init_task_stack(void *stack_top, void (*task_entry)(void)) { uint32_t *sp (uint32_t *)stack_top; // 从高地址往低地址填 *(--sp) 0x01000000; // xPSRbit24 置 1 表示 Thumb 状态 *(--sp) (uint32_t)task_entry; // PC任务入口地址 *(--sp) 0xFFFFFFFD; // LR异常返回用 PSP 的标记 *(--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; } return sp; }这里有个坑xPSR 的 bit24 必须置 1表示 Thumb 状态。Cortex-M3 只支持 Thumb 指令集如果这一位是 0异常返回时会触发 HardFault。我第一次写的时候忘了这一位调试了半天才发现。LR 填 0xFFFFFFFD 也是有讲究的。这个值告诉异常返回机制返回后使用 PSP并且进入线程模式。0xFFFFFFF9 是返回后用 MSP0xFFFFFFFD 是返回后用 PSP。我们要的是后者。3.2 PendSV 汇编上下文切换的心脏PendSV 处理程序是整个调度器最核心的部分必须用汇编写。逻辑分两段保存当前任务现场恢复下一个任务现场。PendSV_Handler: ; 关中断防止切换过程被打断 CPSID I ; 读取当前 PSP 到 R0 MRS R0, PSP ; 如果 R0 为 0说明是第一次切换跳过保存 CBZ R0, PendSV_NoSave ; 手动保存 R4-R11 到任务栈 STMDB R0!, {R4-R11} ; 把更新后的栈指针存回当前 TCB LDR R1, current_tcb LDR R1, [R1] STR R0, [R1] PendSV_NoSave: ; 调用 C 函数选择下一个任务 PUSH {LR} BL select_next_task POP {LR} ; 取出下一个任务的栈指针 LDR R0, current_tcb LDR R0, [R0] LDR R0, [R0] ; 手动恢复 R4-R11 LDMIA R0!, {R4-R11} ; 更新 PSP MSR PSP, R0 ; 开中断 CPSIE I ; 异常返回硬件自动恢复剩余 8 个寄存器 BX LR这段代码有几个关键点。第一CPSID I 关中断是必须的否则切换过程中来个中断栈指针就乱了。第二CBZ R0 判断是为了处理第一次切换的情况——第一个任务启动时PSP 还是 0没有现场需要保存。第三STMDB R0! 的感叹号表示基址寄存器更新保存完 R4-R11 后 R0 会自动减去 32。第四最后的 BX LR 不是普通返回而是触发异常返回序列硬件会根据 LR 的值决定用哪个栈、恢复到哪个模式。3.3 为什么只手动保存 R4-R11细心的读者可能发现了硬件自动压栈只压了 8 个寄存器R4-R11 是我们手动压的。为什么不全手动或者全自动这是 ARM 的设计约定。R0-R3、R12、LR、PC、xPSR 这 8 个叫调用者保存寄存器异常入口硬件自动压栈。R4-R11 叫被调用者保存寄存器硬件不管需要软件自己保存。这样设计的好处是如果异常处理程序不用到 R4-R11就不用压栈省时间。但任务切换必须保存全部现场所以 R4-R11 得我们手动来。3.4 临界区保护关中断还是关调度调度器里有不少操作需要保护比如链表插入、任务状态修改。保护方式有两种关中断和关调度。关中断用 CPSID I / CPSIE I简单粗暴但会影响中断响应。关调度是设一个标志位调度器看到标志就不切换但中断照常响应。我选择的是关中断因为操作都很短几个指令就完事对中断延迟影响可以忽略。而且关中断实现简单不容易出错。不过要注意关中断和开中断必须配对。我见过有人在中途 return 忘了开中断结果系统直接卡死。建议用宏包起来#define ENTER_CRITICAL() __disable_irq() #define EXIT_CRITICAL() __enable_irq()3.5 任务延时与阻塞的实现任务延时不是简单的死等而是把任务从就绪链表摘下来挂到延时链表上然后触发调度切到别的任务。SysTick 每次中断遍历延时链表把到期的任务重新挂回就绪链表。void task_delay(uint32_t ticks) { ENTER_CRITICAL(); current_tcb-delay ticks; current_tcb-state TASK_BLOCKED; // 从就绪链表移除 remove_from_ready_list(current_tcb); EXIT_CRITICAL(); // 触发调度 trigger_schedule(); }这里 trigger_schedule 就是往 PendSV 的 SET 位写 1让 PendSV 尽快执行。注意不能在临界区里触发否则 PendSV 被关中断挡住要等开中断才执行。4. 实操过程从建工程到跑通三个任务4.1 工程搭建与基础配置我用的是 Keil MDK4标准库版本 3.5.0。新建工程选 STM32F103C8然后手动添加标准库文件。需要的外设有GPIO点灯用、RCC时钟配置、USART打印调试信息。SysTick 和 PendSV 是内核的不用额外加库。时钟配置成 72MHz这个在 system_stm32f10x.c 里改一下宏定义就行。注意 STM32F103C8T6 的外部晶振一般是 8MHzPLL 倍频到 72MHz。如果你用的是最小系统板板载晶振通常是 8MHz直接按标准配置来。启动文件选 startup_stm32f10x_md.smd 表示中等容量。这个文件里定义了中断向量表我们要把 PendSV_Handler 和 SysTick_Handler 替换成自己的实现。可以在 C 文件里重定义同名函数链接器会自动覆盖启动文件里的弱定义。4.2 SysTick 初始化与中断服务SysTick 配置很直接四个寄存器CTRL、LOAD、VAL、CALIB。我们只用前三个。void systick_init(uint32_t ticks_per_sec) { uint32_t reload (SystemCoreClock / ticks_per_sec) - 1; SysTick-LOAD reload; SysTick-VAL 0; SysTick-CTRL (1 2) | // 时钟源用处理器时钟 (1 1) | // 中断使能 (1 0); // 使能 SysTick }SystemCoreClock 是 72000000ticks_per_sec 传 1000reload 就是 71999。注意 LOAD 寄存器是 24 位的最大值 0xFFFFFF71999 完全没问题。SysTick 中断服务程序里做三件事tick 计数加一、处理延时链表、判断是否需要调度。void SysTick_Handler(void) { system_tick; // 处理延时任务 tcb_t *t delay_list; while (t) { if (t-delay 0) { t-delay--; if (t-delay 0) { // 延时到期挂回就绪链表 add_to_ready_list(t); } } t t-next; } // 时间片轮转同优先级 if (--current_tcb-time_slice 0) { current_tcb-time_slice TIME_SLICE; // 挂起 PendSV SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; } }这里有个细节SCB-ICSR 的 PENDSVSET 位写 1 就挂起 PendSV。不要用 NVIC_SetPendingIRQ那个是给外部中断用的。4.3 任务创建与启动第一个任务任务创建就是分配栈空间、初始化栈帧、填 TCB、插入就绪链表。栈空间我一般给 128 字512 字节对于简单任务够用了。如果任务里用了 printf 或者大数组得适当加大。tcb_t *task_create(const char *name, void (*entry)(void), uint32_t priority, uint32_t *stack, uint32_t stack_size) { tcb_t *tcb (tcb_t *)malloc(sizeof(tcb_t)); tcb-stack_base stack; tcb-sp init_task_stack(stack stack_size, entry); tcb-priority priority; tcb-state TASK_READY; tcb-delay 0; tcb-time_slice TIME_SLICE; add_to_ready_list(tcb); return tcb; }启动第一个任务比较特殊。此时还没有当前任务需要手动把第一个任务的栈指针加载到 PSP然后切到 PSP 模式再触发异常返回。void scheduler_start(void) { // 取就绪链表头作为第一个任务 current_tcb ready_list; // 设置 PSP 为第一个任务的栈指针 __set_PSP((uint32_t)current_tcb-sp); // 切到使用 PSP __set_CONTROL(0x02); __ISB(); // 触发 SVC 或者直接构造异常返回 // 这里用最简单的方式手动触发 PendSV SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; __enable_irq(); // 等待 PendSV 执行 while (1); }实际上更常见的做法是用 SVC 异常来启动第一个任务但为了简单我直接触发 PendSV。注意 PendSV 里会判断 PSP 是否为 0第一次切换时 PSP 是我们刚设的值不为 0所以会走保存流程——但此时保存的是启动代码的现场其实没意义。更干净的做法是在 PendSV 里加个标志位第一次跳过保存。我图省事没加实测也能跑就是浪费了 32 字节栈空间。4.4 三个测试任务与现象验证我建了三个任务来验证调度器任务 A优先级 1每 500ms 翻转一次 LED1任务 B优先级 2每 200ms 翻转一次 LED2任务 C优先级 3每 100ms 通过串口打印一次 tick 值预期现象LED2 闪烁比 LED1 快串口每 100ms 输出一行。如果优先级抢占正常任务 C 应该能准时执行不会被 A 或 B 阻塞。实测下来LED 闪烁频率符合预期串口输出也很稳定。用逻辑分析仪抓 LED 引脚周期误差在 1ms 以内说明 SysTick 时间基准是准的。4.5 关键参数计算与选择依据SysTick 重装载值的计算前面说过了这里补充一下任务栈大小的估算。一个任务栈需要保存8 个硬件自动压栈寄存器32 字节 8 个手动压栈寄存器32 字节 任务函数自身的局部变量和调用深度。简单任务只有局部变量、没有大数组、没有深递归128 字够了。如果任务里调用了 printfprintf 本身可能用几百字节栈那就得给 256 字以上。我一般先用 256 字跑起来后用栈填充法把栈填成 0xAA跑一段时间看最深用到哪来精确测量再调整。时间片我设的是 10 个 tick也就是 10ms。这个值看需求如果任务都是短任务可以设小一点提高响应如果任务计算量大设大一点减少切换开销。5. 常见问题与排查技巧实录5.1 HardFault 排查从 LR 和栈帧找线索写调度器最容易遇到的就是 HardFault。一旦跑飞先别慌HardFault 处理程序里能拿到关键信息。进入 HardFault 时LR 的值能告诉我们是从哪来的0xFFFFFFF9 表示从 MSP 来中断里出错0xFFFFFFFD 表示从 PSP 来任务里出错。如果是任务里出错就去当前任务的栈里找压栈的 PC 值那个地址就是出错指令的位置。void HardFault_Handler(void) { __asm volatile ( TST LR, #4\n ITE EQ\n MRSEQ R0, MSP\n MRSNE R0, PSP\n B hardfault_report\n ); }hardfault_report 里把 R0 指向的栈帧打印出来重点看 PC 和 xPSR。PC 指向出错指令xPSR 的 bit24 如果是 0说明 Thumb 位没设对。我踩过的坑任务入口函数地址忘了加 1。Cortex-M3 的函数指针最低位必须是 1表示 Thumb 状态。如果直接填函数地址异常返回时会因为状态不对触发 HardFault。解决办法是初始化栈帧时给 PC 填(uint32_t)entry | 1。5.2 任务切换后跑飞检查 PSP 和 CONTROL如果 HardFault 发生在第一次任务切换后重点检查两个地方PSP 是否正确加载CONTROL 寄存器是否切到了 PSP 模式。可以在 PendSV 里加个断点单步看 PSP 的值。正常情况下PSP 应该指向任务栈中保存 R4 的位置。如果 PSP 是 0 或者指向奇怪的地方说明栈指针计算错了。另一个常见问题是 CONTROL 寄存器没切。如果忘了__set_CONTROL(0x02)任务代码还是跑在 MSP 上那任务栈根本没用上切换也就无从谈起。5.3 串口打印乱码或卡死串口打印在调度器里是个敏感操作。如果多个任务同时用 printf输出会交错。解决办法是给串口加个互斥锁或者干脆只在一个任务里打印。更隐蔽的问题是 printf 重入。标准库的 printf 不是线程安全的如果任务 A 正在 printf 时被切走任务 B 也调 printf内部缓冲区就乱了。我一般用自己写的简易打印函数或者给 printf 加临界区保护。5.4 常见问题速查表现象可能原因排查方法解决办法上电就 HardFault栈帧 xPSR 的 Thumb 位没设看 HardFault 栈帧的 xPSR初始化时 xPSR 填 0x01000000第一个任务跑飞PC 地址没加 1看栈帧 PC 值入口地址或上 0x01切换后卡死PSP 没正确加载PendSV 里看 PSP检查 TCB 的 sp 字段中断里跑飞中断用了 PSP看 LR 值确保中断用 MSP任务延时不准SysTick 重装载值错算 SystemCoreClock/1000-1确认主频配置正确优先级不生效就绪链表没排序打印链表顺序插入时按优先级排序串口输出乱printf 重入多任务同时打印加互斥或单任务打印5.5 几个独家避坑技巧第一PendSV 的优先级一定要设成最低。在 NVIC 里配置NVIC_SetPriority(PendSV_IRQn, 0xFF);0xFF 是最低优先级。如果设高了可能在中断嵌套中间切换现场就乱了。第二SysTick 的优先级要高于 PendSV 但低于关键外设中断。我一般设成 0xFE比 PendSV 高一级比串口中断低。第三任务栈最好 8 字节对齐。Cortex-M3 的栈操作按字4 字节对齐但某些指令如 LDRD要求 8 字节对齐。栈顶地址按 8 字节对齐能避免潜在问题。第四调试时可以在 PendSV 里翻转一个空闲 GPIO用示波器看切换频率。切换太频繁说明时间片太小切换太少说明任务响应慢根据波形调整参数很直观。第五任务删除要小心。如果删除的任务正在被调度或者它的栈还被引用直接 free 会出问题。安全做法是先标记删除等调度器切走后再释放。6. 调度器的扩展方向与性能优化6.1 支持信号量和互斥锁当前调度器只有任务调度没有任务间同步机制。实际项目里经常需要信号量来协调任务。实现思路是在 TCB 里加一个等待链表信号量释放时把等待任务唤醒。信号量的核心是一个计数器和等待队列。take 操作如果计数大于 0 就减一返回否则把当前任务挂到等待队列并阻塞。give 操作如果等待队列非空就唤醒一个任务否则计数加一。typedef struct { int count; tcb_t *wait_list; } sem_t; void sem_take(sem_t *sem) { ENTER_CRITICAL(); if (sem-count 0) { sem-count--; EXIT_CRITICAL(); return; } // 阻塞当前任务 current_tcb-state TASK_BLOCKED; add_to_wait_list(sem-wait_list, current_tcb); EXIT_CRITICAL(); trigger_schedule(); }互斥锁比信号量多一个优先级继承机制防止优先级反转。这个实现起来复杂一些但原理不难当高优先级任务等待低优先级任务持有的锁时临时把低优先级任务的优先级提到和高优先级一样等释放锁后再恢复。6.2 优先级反转与解决方案优先级反转是个经典问题。假设低优先级任务 L 持有锁高优先级任务 H 来抢锁被阻塞中优先级任务 M 就绪。此时 M 会抢占 L导致 L 迟迟不释放锁H 也就一直等。结果 M 比 H 先执行优先级完全反了。解决办法就是优先级继承H 等锁时把 L 的优先级临时提到 H 的级别这样 M 就抢不过 L 了L 能尽快释放锁。释放后 L 恢复原优先级。实现上需要在 TCB 里加一个 base_priority 字段记录原始优先级加锁时检查是否需要提升解锁时恢复。6.3 内存占用与切换开销实测在 STM32F103C8T6 上实测调度器核心代码PendSV SysTick 链表操作编译后大约 1.5KB Flash。每个任务的 TCB 占 24 字节128 字栈占 512 字节。三个任务总共约 1.6KB RAM对于 20KB SRAM 来说很宽裕。切换开销方面用 GPIO 翻转法测量一次完整切换保存 选择 恢复大约 2-3 微秒在 72MHz 下约 150-200 个时钟周期。这个开销对于大多数应用可以接受。如果切换太频繁影响性能可以适当加大时间片。6.4 后续可以怎么扩展这个调度器目前是最小可用版本后续可以往几个方向扩展。一是加软件定时器让任务能注册回调函数在指定 tick 后执行。二是加事件标志组支持任务等待多个事件。三是加内存池管理替代 malloc 避免碎片。四是加低功耗支持空闲时进 Sleep 模式SysTick 唤醒。我个人觉得把这个调度器吃透之后再去看 FreeRTOS 的源码会顺畅很多。很多概念TCB、就绪链表、PendSV 切换都是相通的只是 FreeRTOS 做得更完善、更健壮。自己动手写一遍那些原本模糊的机制就变得清晰了。最后分享一个小技巧调试调度器时可以在每个任务的入口和出口翻转不同的 GPIO用逻辑分析仪同时抓多个通道任务的执行时序一目了然。比串口打印直观得多也不影响实时性。