拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于STM32F103C8T6从零手写抢占式调度器:PSP、PendSV与SysTick实战

基于STM32F103C8T6从零手写抢占式调度器:PSP、PendSV与SysTick实战

1. 从一个最小系统板说起:为什么要自己写调度器

手头这块 STM32F103C8T6 最小系统板,估计是很多人入门嵌入式时买的第一块板子。几十块钱,72MHz 主频,64KB Flash,20KB 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 里切任务,那串口中断的现场就可能被破坏。而用 PendSV,SysTick 里只负责把 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; // xPSR,bit24 置 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 是返回后用 MSP,0xFFFFFFFD 是返回后用 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 的外部晶振一般是 8MHz,PLL 倍频到 72MHz。如果你用的是最小系统板,板载晶振通常是 8MHz,直接按标准配置来。

启动文件选 startup_stm32f10x_md.s,md 表示中等容量。这个文件里定义了中断向量表,我们要把 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 是 72000000,ticks_per_sec 传 1000,reload 就是 71999。注意 LOAD 寄存器是 24 位的,最大值 0xFFFFFF,71999 完全没问题。

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 字够了。如果任务里调用了 printf,printf 本身可能用几百字节栈,那就得给 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,用逻辑分析仪同时抓多个通道,任务的执行时序一目了然。比串口打印直观得多,也不影响实时性。

返回列表