1. 为什么要自己动手写一个抢占式调度器1.1 从“用RTOS”到“懂RTOS”的分水岭很多做单片机的朋友第一款芯片就是 stm32f103c8t6第一套工程模板是标准库第一个“操作系统”是直接拿 FreeRTOS 的 demo 改吧改吧跑起来。能用吗能用。但一旦任务切换出现异常、栈溢出、中断里调用阻塞 API 导致死机基本就只能靠“重启大法”和“删代码二分法”来排查。我自己早期调 stm32f103c8t6 freertos v9.0.0 的工程时就遇到过任务跑飞、HardFault 定位到 PendSV 里的情况查了两天才发现是任务栈给小了。问题的根子在于你只是“调用”了调度器但没有“拥有”它。抢占式调度器Preemptive Scheduler是 RTOS 的心脏它的核心机制其实并不复杂——SysTick 定时器产生中断在中断里判断是否需要切换任务需要切换就挂起PendSV异常在 PendSV 里完成真正的上下文切换任务各自使用自己的PSP进程栈指针。这套机制在 Cortex-M3 内核上是被硬件原生支持的stm32f103c8t6 正是 Cortex-M3 内核天生适合拿来练手。手写一个抢占式调度器能让你彻底搞明白几件事任务栈是怎么初始化的、上下文切换时哪些寄存器必须保存、为什么 PendSV 的优先级要设成最低、SysTick 和 PendSV 是怎么配合的。这些知识一旦打通你再看 FreeRTOS 的 port.c会发现全是老朋友。1.2 这个调度器能做什么适合谁看我们要实现的是一个极简但完整的抢占式调度器功能包括支持多个任务、每个任务独立栈空间、基于 SysTick 的时间片轮转、任务可以主动让出 CPU、上下文切换由 PendSV 完成。代码量控制在几百行以内全部基于 stm32f103c8t6 标准库工程不依赖任何现成 RTOS。适合的读者已经能用 stm32f103c8t6 点灯、串口打印会新建基于标准库开发的 stm32f103c8t6 工程模板但对 RTOS 内部机制一知半解想从零搞懂任务切换原理的人。如果你连 stm32f103c8t6 原理图和引脚功能都还没摸清建议先把 GPIO、SysTick 定时器产生中断这些基础打牢再来看。注意本文所有代码基于 Cortex-M3 架构stm32f103c8t6 完全适用。如果你用的是 M0 内核比如 STM32F030指令集有差异需要自行调整汇编部分。2. 核心机制拆解SysTick、PendSV 与 PSP 的三重奏2.1 Cortex-M3 的寄存器分组与双栈指针Cortex-M3 有两组栈指针MSP主栈指针和PSP进程栈指针。复位后默认使用 MSP中断服务程序包括 SysTick、PendSV都跑在 MSP 上而用户任务跑在 PSP 上。这种设计的妙处在于中断处理不会污染任务栈任务栈可以开得很小中断嵌套再深也不怕。R0-R3、R12、LR、PC、xPSR 这 8 个寄存器在异常入口和出口时由硬件自动压栈/出栈这叫“硬件压栈”。而 R4-R11 这 8 个寄存器硬件不管需要我们在 PendSV 里手动保存和恢复这叫“软件压栈”。理解这一点是理解整个上下文切换的关键。2.2 为什么 PendSV 的优先级必须最低PendSV可挂起的系统服务异常是专门为上下文切换设计的。它的优先级必须设成最低数值最大原因很简单如果 PendSV 优先级比 SysTick 高那么 SysTick 中断触发 PendSV 后PendSV 会立刻抢占 SysTick导致在中断嵌套中做任务切换逻辑会变得极其复杂。设成最低后PendSV 会等到所有其他中断都处理完才执行保证切换发生在“安全时刻”。在 stm32f103c8t6 上我们用标准库的NVIC_SetPriority来设置#define NVIC_PENDSV_PRI 0xFFFF0000 // 最低优先级 #define NVIC_SYSTICK_PRI 0x00000000 // 最高优先级 NVIC_SetPriority(PendSV_IRQn, 0xFF); NVIC_SetPriority(SysTick_IRQn, 0x00);2.3 SysTick 定时器产生中断的节奏控制SysTick 是 Cortex-M3 内核自带的 24 位递减计数器stm32f103c8t6 的主频通常是 72MHz。我们要让它每 1ms 产生一次中断重装载值这样算Reload 72000000 / 1000 - 1 71999在 SysTick 中断里我们做两件事一是给系统 tick 计数加一二是触发 PendSV 异常通过写ICSR寄存器的PENDSVSET位。注意不要在 SysTick 里直接做上下文切换只挂起 PendSV 就行。void SysTick_Handler(void) { tick_count; // 触发PendSV请求任务切换 SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; }2.4 任务栈的初始化布局每个任务创建时我们要在它的栈顶伪造一个“异常压栈帧”这样第一次切换到该任务时PendSV 出栈后就能直接跳到任务函数入口。栈的布局从高地址到低地址依次是偏移内容说明高地址xPSR必须设置 bit241Thumb 状态PC任务函数入口地址LR任务返回地址设为 0 表示任务结束R12初始值 0R3-R0初始值 0低地址R11-R4软件压栈部分初始值 0初始化完成后把 PSP 指向这个伪造帧的底部任务第一次被调度时就能“无缝”启动。3. 从零实现代码逐行拆解与实操3.1 工程准备与文件结构先新建一个基于标准库的 stm32f103c8t6 工程模板Keil4 或 Keil5 都行。工程里需要包含标准库的启动文件startup_stm32f10x_md.s注意这个文件里已经定义了PendSV_Handler和SysTick_Handler的弱符号我们要在自己的 C 文件里重写它们。文件结构建议这样组织main.c创建任务、启动调度器scheduler.c调度器核心实现scheduler.h任务控制块定义、API 声明stm32f10x_it.c中断服务函数SysTick_Handler提示如果你在 Keil4 下建立工程记得在 Options for Target - C/C 里把scheduler.c加入编译并且确保启动文件里的PendSV_Handler弱定义不会冲突。3.2 任务控制块与全局变量定义任务控制块TCB是整个调度器的数据结构核心我们只需要保存栈指针和任务状态typedef struct { uint32_t *stack_ptr; // 当前栈顶指针 uint32_t state; // 任务状态就绪/运行/阻塞 uint32_t priority; // 优先级本文用时间片轮转暂不启用 } tcb_t; #define MAX_TASKS 4 #define TASK_READY 0 #define TASK_RUN 1 tcb_t tasks[MAX_TASKS]; uint32_t task_count 0; uint32_t current_task 0; volatile uint32_t tick_count 0;每个任务的栈用一个独立的数组大小根据任务复杂度定简单任务 128 字512 字节够用复杂任务建议 256 字以上#define STACK_SIZE 128 uint32_t task0_stack[STACK_SIZE]; uint32_t task1_stack[STACK_SIZE];3.3 任务创建函数与栈帧伪造创建任务时核心工作是在栈顶伪造异常压栈帧。这里有个细节容易踩坑栈是向下增长的所以我们要从栈数组的最高地址开始往下填。int task_create(void (*task_func)(void), uint32_t *stack, uint32_t stack_size) { if (task_count MAX_TASKS) return -1; uint32_t *top stack[stack_size - 1]; top (uint32_t *)((uint32_t)top 0xFFFFFFF8); // 8字节对齐 // 伪造硬件压栈帧 *(--top) 0x01000000; // xPSR, Thumb位必须置1 *(--top) (uint32_t)task_func; // PC *(--top) 0xFFFFFFFD; // LR, 返回后使用PSP *(--top) 0x12121212; // R12 *(--top) 0x03030303; // R3 *(--top) 0x02020202; // R2 *(--top) 0x01010101; // R1 *(--top) 0x00000000; // R0 // 伪造软件压栈帧R4-R11 for (int i 0; i 8; i) { *(--top) 0; } tasks[task_count].stack_ptr top; tasks[task_count].state TASK_READY; task_count; return 0; }注意xPSR 的 bit24 必须置 1否则出栈后 CPU 进入 ARM 状态而 Cortex-M3 只支持 Thumb 指令会直接 HardFault。这个坑我踩过现象是第一次切换任务就死机调试器显示 PC 跑到了奇怪的地方。3.4 PendSV 汇编实现上下文切换的核心这是整个调度器最硬核的部分必须用汇编写。我们把它放在scheduler.c里用__asm内联汇编或者单独的.s文件都行。这里用内联汇编展示__asm void PendSV_Handler(void) { IMPORT current_task IMPORT tasks // 保存当前任务的R4-R11到PSP MRS R0, PSP CBZ R0, PendSV_NoSave // 第一次切换时PSP为0跳过保存 STMDB R0!, {R4-R11} // 更新当前任务的栈指针 LDR R1, current_task LDR R2, [R1] LDR R3, tasks LSL R2, R2, #3 // 每个tcb_t占8字节 ADD R3, R3, R2 STR R0, [R3] // 保存stack_ptr PendSV_NoSave // 切换到下一个任务 LDR R1, current_task LDR R2, [R1] ADD R2, R2, #1 LDR R3, task_count LDR R3, [R3] CMP R2, R3 BLT PendSV_NoWrap MOV R2, #0 PendSV_NoWrap STR R2, [R1] // 取出新任务的栈指针 LDR R3, tasks LSL R2, R2, #3 ADD R3, R3, R2 LDR R0, [R3] // 恢复新任务的R4-R11 LDMIA R0!, {R4-R11} MSR PSP, R0 // 设置LR为0xFFFFFFFD返回后使用PSP ORR LR, LR, #0x04 BX LR }这段代码的逻辑是先判断 PSP 是否为 0第一次切换不为 0 就手动压栈 R4-R11 并更新 TCB 里的栈指针然后轮转到下一个任务从新任务的 TCB 取出栈指针出栈 R4-R11更新 PSP最后通过修改 LR 让异常返回时使用 PSP。3.5 启动第一个任务调度器启动函数要做三件事初始化 SysTick、设置 PendSV 和 SysTick 优先级、触发一次 PendSV 让第一个任务跑起来。void scheduler_start(void) { // 配置SysTick1ms中断一次 if (SysTick_Config(SystemCoreClock / 1000)) { while (1); } // 设置优先级 NVIC_SetPriority(PendSV_IRQn, 0xFF); NVIC_SetPriority(SysTick_IRQn, 0x00); // 触发PendSV启动第一个任务 SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; // 开中断 __enable_irq(); while (1); }提示SysTick_Config函数会自动使能 SysTick 中断并设置重装载值但它默认优先级是 0我们需要手动改。另外启动第一个任务时PSP 还是 0PendSV 里的CBZ R0, PendSV_NoSave会跳过保存直接进入切换逻辑这样第一个任务就能正常启动。3.6 任务主动让出 CPU 的实现有时候任务不需要跑满整个时间片可以主动让出 CPU。实现很简单就是手动触发一次 PendSVvoid task_yield(void) { SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; __DSB(); __ISB(); }__DSB()和__ISB()是数据同步和指令同步屏障确保 PendSV 挂起操作立即生效。不加这两个屏障在某些编译器优化级别下可能会出问题。4. 实测验证与常见问题排查4.1 用两个闪灯任务验证切换写两个任务各自翻转不同的 GPIO延时用忙等或者简单的循环void task_led1(void) { while (1) { GPIO_SetBits(GPIOB, GPIO_Pin_0); for (volatile int i 0; i 500000; i); GPIO_ResetBits(GPIOB, GPIO_Pin_0); for (volatile int i 0; i 500000; i); } } void task_led2(void) { while (1) { GPIO_SetBits(GPIOB, GPIO_Pin_1); for (volatile int i 0; i 300000; i); GPIO_ResetBits(GPIOB, GPIO_Pin_1); for (volatile int i 0; i 300000; i); } }如果两个灯以不同频率闪烁说明时间片轮转正常工作。如果只有一个灯亮或者两个灯都不亮就要按下面的排查表来查。4.2 常见问题速查表现象可能原因排查方法第一次切换就 HardFaultxPSR 的 Thumb 位没置 1检查栈帧初始化代码任务跑飞PC 指向奇怪地址栈溢出任务栈太小增大 STACK_SIZE或在栈顶填魔数检测两个任务交替但频率不对SysTick 重装载值算错确认 SystemCoreClock 是否为 72000000PendSV 不触发ICSR 寄存器操作被优化加volatile或内存屏障中断里调用 task_yield 死机中断中 PSP 和 MSP 混用中断里不要主动切换任务编译报 PendSV_Handler 重复定义启动文件弱符号冲突确认自己的定义覆盖了弱符号4.3 栈溢出检测的实用技巧任务栈溢出是新手最容易踩的坑。一个简单有效的办法是在创建任务时把整个栈填成固定魔数比如0xDEADBEEF运行一段时间后检查栈底附近的魔数是否被改写// 创建时填充 for (int i 0; i stack_size; i) { stack[i] 0xDEADBEEF; } // 检查时 int check_stack(uint32_t *stack, uint32_t stack_size) { for (int i 0; i 16; i) { if (stack[i] ! 0xDEADBEEF) return -1; // 溢出 } return 0; }注意stm32f103c8t6 的 RAM 只有 20KB任务栈不要开太大。两个任务各 128 字512 字节加上主栈总共不到 2KB完全够用。如果你开了 DMA 通道或者用了较大的全局数组要留意 RAM 余量。4.4 关于 stm32f103c8t6 加密与项目密码锁的延伸有些朋友做 stm32f103c8t6 项目密码锁这类产品时会关心代码加密和读保护。这跟调度器本身没关系但既然提到了就顺带说一句stm32f103c8t6 支持通过选项字节设置读保护级别在量产时可以用工具开启。不过读保护一旦开启调试接口会被禁用调试前记得先解除否则芯片可能被锁死。这个操作有风险建议在充分测试后再做。4.5 从时间片轮转到优先级调度的扩展思路本文实现的是最简单的时间片轮转每个任务轮流跑 1ms。实际项目中往往需要优先级调度扩展思路是在 PendSV 里不简单地current_task 1而是遍历所有就绪任务选出优先级最高的那个。TCB 里已经预留了priority字段你只需要改 PendSV 里的选择逻辑。再进一步还可以加入阻塞延时、信号量等机制那就是一个迷你 RTOS 了。我个人在实际操作中的体会是手写调度器最大的价值不在于替代 FreeRTOS而在于让你在调试 FreeRTOS 问题时心里有底。当你自己写过 PendSV 汇编、自己算过 SysTick 重装载值、自己被栈溢出坑过之后再看那些 RTOS 的报错信息基本能一眼定位到问题所在。最后分享一个小技巧调试上下文切换时可以在 PendSV 入口和出口各翻转一个空闲 GPIO用示波器看波形切换耗时一目了然比单步调试高效得多。