☰
从零手写STM32抢占式调度器:深入SysTick、PendSV与PSP机制
2026/10/28 1:10:13 网站建设 项目流程

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必须设置 bit24=1(Thumb 状态)
PC任务函数入口地址
LR任务返回地址,设为 0 表示任务结束
R12初始值 0
R3-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) { // 配置SysTick,1ms中断一次 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 还是 0,PendSV 里的CBZ R0, PendSV_NoSave会跳过保存,直接进入切换逻辑,这样第一个任务就能正常启动。

3.6 任务主动让出 CPU 的实现

有时候任务不需要跑满整个时间片,可以主动让出 CPU。实现很简单,就是手动触发一次 PendSV:

void 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 是否为 72000000
PendSV 不触发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,用示波器看波形,切换耗时一目了然,比单步调试高效得多。

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

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

立即咨询