☰
STM32F103C8T6手写抢占式调度器:从硬件机制到任务切换实战
2026/10/7 15:49:02 网站建设 项目流程

抢占式调度器这个词,很多做单片机的朋友第一次听到会觉得是操作系统内核才配拥有的东西,离自己写裸机代码很远。但真把 Cortex-M3 的寄存器手册翻一遍就会发现,STM32F103C8T6 这颗芯片从硬件层面就给你留好了实现抢占式调度的全部零件:双堆栈指针、PendSV 异常、SysTick 定时器,三样凑齐就能搭出一个能跑、能切换、能抢占的迷你内核。我最早是在一块最小系统板上折腾这件事的,当时手上只有 Keil 和一根 USB 转串口线,连调试器都是借的,但就是在这种条件下把第一个任务切换跑通了,那种感觉比点亮 LED 爽得多。

这篇内容面向的是已经能写 STM32 标准库工程、会用 GPIO 和中断,但还没碰过 RTOS 内核实现的开发者。我会从硬件机制讲起,把 PSP、MSP、PendSV、SysTick 这几个关键词背后的原理拆开,然后一步步带你写出一个真正能抢占的调度器,最后把我在调试过程中踩过的坑和验证方法一并交代清楚。全程基于 STM32F103C8T6 和标准库,不依赖任何现成 RTOS,代码可以直接抄进你的工程里跑。

1. 为什么 STM32F103C8T6 天生适合手写调度器

1.1 Cortex-M3 的双堆栈设计解决了什么痛点

在没有操作系统概念的裸机程序里,所有代码共用一个栈指针,也就是主堆栈指针 MSP。函数调用、局部变量、中断现场全都压在这一个栈上。这种模式在单任务场景下没问题,但一旦你想让多个任务各自独立运行,麻烦就来了:任务 A 执行到一半被切走,它的局部变量和返回地址还留在栈上,任务 B 接着用同一个栈,两边的数据就会互相覆盖。

Cortex-M3 的设计者显然考虑过这个问题,所以给了两个物理上独立的栈指针:MSP 和 PSP。MSP 继续给中断和异常服务程序用,PSP 则专门留给任务代码用。每个任务拥有自己的 PSP 值,切换任务时只要把当前任务的 PSP 保存起来、把下一个任务的 PSP 恢复回去,栈上的现场就自动跟着切换了。这个机制是整个抢占式调度器的地基,没有它,你就得手动搬运栈数据,效率和可靠性都会大打折扣。

STM32F103C8T6 用的是 Cortex-M3 内核,完整支持这套双栈机制。相比之下,一些低端的 Cortex-M0 芯片虽然也有 MSP 和 PSP,但异常模型简化了不少,写起来限制更多。所以选 F103C8T6 来练手,硬件条件是够用的。

1.2 PendSV 为什么是任务切换的最佳载体

任务切换这件事,本质上是在某个时刻保存当前任务的上下文、恢复另一个任务的上下文。问题在于,这个时刻选在哪里最安全?

如果你在 SysTick 中断里直接做切换,会撞上一个经典问题:SysTick 的优先级可能比某些其他中断高,如果切换过程中又来了一个中断,而这个中断的优先级比 SysTick 还高,它就会打断切换过程,导致上下文处于半保存半恢复的状态,程序直接跑飞。这就是所谓的优先级反转和中断嵌套冲突。

PendSV 的设计初衷就是解决这个问题。它是一个可以挂起的异常,你可以通过写 NVIC 的挂起寄存器来触发它,但它不会立即执行,而是等到所有更高优先级的中断都处理完了才执行。更关键的是,你可以把 PendSV 的优先级设成最低,这样它永远不会打断其他中断,其他中断也永远不会打断它正在做的切换工作。SysTick 只负责定时触发 PendSV 挂起,真正的切换动作交给 PendSV 去做,职责分离,干净利落。

1.3 SysTick 在调度器里扮演的角色

SysTick 是 Cortex-M3 内置的一个 24 位递减计数器,STM32F103C8T6 上它通常被配置成 1ms 中断一次。在手写调度器里,SysTick 的作用非常单纯:提供一个时间基准,每次中断时检查有没有任务需要被唤醒或者有没有任务的时间片用完了,然后挂起 PendSV 请求切换。

它不直接参与上下文保存和恢复,只负责“发号施令”。这种设计的好处是 SysTick 中断服务程序可以写得极短,减少中断延迟,同时把复杂的切换逻辑隔离到 PendSV 里,方便调试。

2. 任务控制块与栈帧的布局设计

2.1 任务控制块里到底要存什么

任务控制块(TCB)是调度器管理任务的数据结构。对于手写调度器来说,不需要搞得太复杂,但有几个字段是必须的。

第一个是栈指针。每个任务在创建时分配一块独立的内存作为它的栈空间,初始化完成后,栈顶指针(注意是栈向下增长,所以是最高地址)要存进 TCB。切换任务时,从这个字段读出 PSP 的新值。

第二个是任务状态。最简单的状态有机就绪、运行、阻塞三种。就绪表示可以被调度,运行表示当前正在占用 CPU,阻塞表示在等待某个事件(比如延时到期)。调度器每次选任务时只从就绪队列里挑。

第三个是延时计数器。当任务调用延时函数时,把延时长度写进这个字段,然后把自己标记为阻塞。SysTick 每次中断时遍历所有阻塞任务,把计数器减一,减到零就恢复成就绪状态。

第四个是任务栈的起始地址和大小。这个字段主要用于调试和栈溢出检测,实际切换时用不到,但强烈建议保留,后面排查问题时会感谢自己。

typedef struct tcb { uint32_t *stack_ptr; // 当前栈顶指针 uint8_t state; // 任务状态 uint32_t delay_ticks; // 延时计数 uint32_t *stack_base; // 栈起始地址 uint32_t stack_size; // 栈大小 char name[8]; // 任务名,调试用 } tcb_t;

2.2 初始栈帧的构造过程

一个新任务刚创建时,它的栈是空的,但调度器第一次切换到它时,PendSV 会按照硬件规定的格式从栈里弹出寄存器。所以我们必须提前在任务的栈空间里“伪造”一个现场,让 PendSV 以为这个任务之前被正常切走过。

Cortex-M3 在进入异常时,硬件会自动把 8 个寄存器压入当前栈:xPSR、PC、LR、R12、R3、R2、R1、R0。退出异常时,硬件又会自动把这 8 个弹回去。所以我们在初始化任务栈时,需要按照这个顺序把这 8 个值放到栈顶,其中 PC 要指向任务的入口函数,xPSR 的 bit24 要置 1(表示 Thumb 状态)。

除此之外,为了配合 PendSV 里手动保存 R4-R11 的逻辑,我们还需要在硬件自动保存的 8 个寄存器下面再预留 8 个位置给 R4-R11。这样整个初始栈帧就是 16 个字。

uint32_t *init_task_stack(uint32_t *stack_top, void (*task_entry)(void)) { uint32_t *sp = stack_top; // 预留 R4-R11 的位置,初始值无所谓 *(--sp) = 0x04040404; // R4 *(--sp) = 0x05050505; // R5 *(--sp) = 0x06060606; // R6 *(--sp) = 0x07070707; // R7 *(--sp) = 0x08080808; // R8 *(--sp) = 0x09090909; // R9 *(--sp) = 0x10101010; // R10 *(--sp) = 0x11111111; // R11 // 硬件自动保存的 8 个寄存器 *(--sp) = 0xFFFFFFFD; // LR,异常返回用 PSP *(--sp) = (uint32_t)task_entry; // PC,任务入口 *(--sp) = 0x01000000; // xPSR,bit24 = 1 *(--sp) = 0x12121212; // R12 *(--sp) = 0x03030303; // R3 *(--sp) = 0x02020202; // R2 *(--sp) = 0x01010101; // R1 *(--sp) = 0x00000000; // R0 return sp; }

这里有个细节值得说:LR 的值我填的是 0xFFFFFFFD,而不是 0xFFFFFFF9。这两个值的区别在于异常返回时用哪个栈。0xFFFFFFF9 表示返回后使用 MSP,0xFFFFFFFD 表示返回后使用 PSP。因为任务代码要跑在 PSP 上,所以必须填 0xFFFFFFFD。这个值填错的话,任务第一次运行时会用 MSP,然后中断一来整个栈就乱了。

2.3 栈空间分配的大小怎么定

栈大小给多少合适?这个问题没有标准答案,取决于任务里用了多少局部变量、调用层次有多深、有没有用递归或大数组。

我的经验是,对于 F103C8T6 这种只有 20KB RAM 的芯片,每个任务先给 128 字(512 字节)起步。如果任务里有 printf 或者浮点运算,加到 256 字。创建完任务后,可以在栈底填一个魔术数字(比如 0xDEADBEEF),运行一段时间后检查这个数字有没有被覆盖,就能判断栈有没有溢出。

#define STACK_FILL_PATTERN 0xDEADBEEF void fill_stack_pattern(uint32_t *stack_base, uint32_t size) { for (uint32_t i = 0; i < size; i++) { stack_base[i] = STACK_FILL_PATTERN; } } uint32_t check_stack_usage(uint32_t *stack_base, uint32_t size) { uint32_t used = 0; for (uint32_t i = 0; i < size; i++) { if (stack_base[i] != STACK_FILL_PATTERN) { used = size - i; break; } } return used * 4; // 返回字节数 }

这个方法简单但极其有效,我在实际项目里靠它抓到过好几次栈溢出,比等到程序跑飞再回头查要省事得多。

3. PendSV 汇编切换代码的逐行拆解

3.1 保存现场时为什么只手动压 R4-R11

前面提到,Cortex-M3 进入异常时硬件自动压栈 8 个寄存器,退出时自动弹栈。这 8 个是调用者保存寄存器(caller-saved),硬件帮你管了。但 R4-R11 是被调用者保存寄存器(callee-saved),硬件不管,需要软件自己保存。

所以在 PendSV 处理程序里,我们要手动把 R4-R11 压入当前任务的栈,切换栈指针,再从新任务的栈里弹出 R4-R11。这样配合硬件的自动压栈弹栈,整个上下文就完整保存和恢复了。

这个设计的好处是切换效率高:只需要手动处理 8 个寄存器,另外 8 个由硬件并行完成,比全部手动压栈快得多。

3.2 PendSV 处理程序的完整汇编实现

下面这段汇编是调度器的心脏,我把它放在一个单独的 .s 文件里,用 extern 声明 C 语言里定义的全局变量。

.global PendSV_Handler .extern current_tcb .extern next_tcb PendSV_Handler: // 1. 关闭中断,防止切换过程被打断 CPSID I // 2. 读取当前 PSP,此时硬件已经自动压了 8 个寄存器 MRS R0, PSP // 3. 如果 PSP 为 0,说明是第一次切换,跳过保存 CBZ R0, PendSV_restore // 4. 手动压入 R4-R11 STMDB R0!, {R4-R11} // 5. 把更新后的栈指针存回当前 TCB LDR R1, =current_tcb LDR R1, [R1] STR R0, [R1] // TCB 的第一个字段就是 stack_ptr PendSV_restore: // 6. 取出下一个任务的 TCB LDR R0, =next_tcb LDR R0, [R0] // 7. 从 TCB 读出栈指针 LDR R0, [R0] // 8. 手动弹出 R4-R11 LDMIA R0!, {R4-R11} // 9. 更新 PSP MSR PSP, R0 // 10. 开中断 CPSIE I // 11. 异常返回,硬件自动弹出剩余 8 个寄存器 BX LR

逐行解释几个关键点。第 3 行的 CBZ 判断是为了处理第一次切换的情况:系统启动后还没有任何任务运行过,PSP 是 0,这时候不需要保存现场,直接跳到恢复流程。第 5 行把栈指针存回 TCB,注意 TCB 结构体的第一个字段必须是 stack_ptr,这样汇编里用偏移 0 就能访问到,不用算偏移量。第 11 行的 BX LR 是异常返回的标准写法,LR 里存的是 0xFFFFFFFD,硬件看到这个值就知道要返回线程模式并使用 PSP。

3.3 切换时机的选择与触发逻辑

PendSV 不会自己触发,需要软件去挂起它。挂起操作就是往 NVIC 的 ICSR 寄存器(地址 0xE000ED04)的 bit28 写 1。

#define NVIC_INT_CTRL (*((volatile uint32_t *)0xE000ED04)) #define PENDSVSET_BIT (1 << 28) void trigger_task_switch(void) { NVIC_INT_CTRL |= PENDSVSET_BIT; }

这个函数可以在任何地方调用,但最常用的地方是 SysTick 中断服务程序里。SysTick 每次中断时,先处理延时计数,然后判断是否需要切换任务,需要的话就挂起 PendSV。

void SysTick_Handler(void) { // 遍历所有任务,递减延时计数 for (int i = 0; i < MAX_TASKS; i++) { if (task_table[i].state == TASK_BLOCKED && task_table[i].delay_ticks > 0) { task_table[i].delay_ticks--; if (task_table[i].delay_ticks == 0) { task_table[i].state = TASK_READY; } } } // 时间片轮转:当前任务时间片用完就切换 if (--current_tcb->time_slice == 0) { current_tcb->time_slice = TIME_SLICE_DEFAULT; trigger_task_switch(); } }

注意 SysTick 中断服务程序里不要直接做切换,只挂起 PendSV 就行。因为 SysTick 的优先级可能比某些中断高,直接切换会有嵌套风险。挂起 PendSV 后,等所有中断处理完,PendSV 才会执行,这时候环境最干净。

4. 调度算法的实现与任务状态管理

4.1 就绪队列的组织方式

最简单的就绪队列就是一个数组,每个元素指向一个 TCB。调度器每次从数组里找一个状态为就绪的任务。这种方式实现简单,但任务多了之后查找效率低。

稍微好一点的做法是用位图。用一个 32 位整数的每一位表示一个任务是否就绪,找任务时用 CLZ(Count Leading Zeros)指令快速定位最高优先级的就绪任务。Cortex-M3 有 CLZ 指令,一条指令就能算出前导零个数,效率很高。

uint32_t ready_bitmap = 0; int find_next_task(void) { if (ready_bitmap == 0) { return -1; // 没有就绪任务 } // CLZ 返回前导零个数,31 减去它就是最高位的位置 return 31 - __builtin_clz(ready_bitmap); }

这个位图方案最多支持 32 个任务,对于 F103C8T6 来说完全够用。如果任务数超过 32,可以用多个 32 位整数组成位图数组,但一般手写调度器不会做到那么大。

4.2 任务创建函数的完整实现

任务创建要做几件事:分配 TCB、分配栈空间、初始化栈帧、把任务加入就绪队列。

#define MAX_TASKS 8 #define STACK_SIZE 128 tcb_t task_table[MAX_TASKS]; static uint32_t task_stacks[MAX_TASKS][STACK_SIZE]; static int task_count = 0; int create_task(void (*entry)(void), const char *name) { if (task_count >= MAX_TASKS) { return -1; } int id = task_count++; tcb_t *tcb = &task_table[id]; // 初始化栈空间 uint32_t *stack_top = &task_stacks[id][STACK_SIZE]; fill_stack_pattern(task_stacks[id], STACK_SIZE); tcb->stack_ptr = init_task_stack(stack_top, entry); tcb->stack_base = task_stacks[id]; tcb->stack_size = STACK_SIZE; tcb->state = TASK_READY; tcb->delay_ticks = 0; tcb->time_slice = TIME_SLICE_DEFAULT; // 复制任务名 for (int i = 0; i < 7 && name[i]; i++) { tcb->name[i] = name[i]; } tcb->name[7] = '\0'; // 加入就绪位图 ready_bitmap |= (1 << id); return id; }

这里有个容易忽略的点:栈空间的分配。我用的是静态数组 task_stacks[MAX_TASKS][STACK_SIZE],这样不需要动态内存分配,避免堆碎片问题。对于资源紧张的 F103C8T6 来说,静态分配是更稳妥的选择。8 个任务各 128 字,总共 4KB,加上 TCB 本身的开销,RAM 占用在可接受范围内。

4.3 延时与阻塞状态的转换逻辑

任务延时是调度器最常用的功能之一。实现思路是:任务调用 delay 函数时,把自己的状态改成阻塞,记录延时长度,然后主动触发一次任务切换。SysTick 中断里递减延时计数,减到零时把任务恢复成就绪。

void task_delay(uint32_t ticks) { if (ticks == 0) return; // 关闭中断,保护状态修改的原子性 __disable_irq(); current_tcb->delay_ticks = ticks; current_tcb->state = TASK_BLOCKED; ready_bitmap &= ~(1 << current_task_id); // 触发切换,让出 CPU trigger_task_switch(); __enable_irq(); }

这里必须关中断,因为修改任务状态和位图不是原子操作,如果中途来了 SysTick 中断,可能会看到不一致的状态。关中断的时间很短,只有几条指令,不会影响系统实时性。

还有一个细节:如果所有任务都在阻塞状态,ready_bitmap 会变成 0,调度器找不到可运行的任务。这时候应该让 CPU 进入空闲状态,可以跑一个空闲任务,或者直接执行 WFI 指令等中断。

void idle_task(void) { while (1) { __WFI(); // 等待中断,降低功耗 } }

创建任务时先创建一个空闲任务,保证任何时候都至少有一个就绪任务,调度器就不会找不到任务可跑。

5. 启动流程与第一次任务切换的调试

5.1 从复位到第一个任务运行的完整链路

系统上电后,先执行启动文件里的复位处理程序,初始化时钟和内存,然后跳到 main 函数。在 main 函数里,我们需要做几件事:初始化 SysTick、设置 PendSV 和 SysTick 的优先级、创建任务、启动调度器。

int main(void) { // 时钟初始化,F103C8T6 通常配到 72MHz SystemInit(); // 配置 SysTick 为 1ms 中断 if (SysTick_Config(SystemCoreClock / 1000)) { while (1); } // 设置优先级:PendSV 最低,SysTick 次低 NVIC_SetPriority(PendSV_IRQn, 0xFF); NVIC_SetPriority(SysTick_IRQn, 0xFE); // 创建任务 create_task(task1, "task1"); create_task(task2, "task2"); create_task(idle_task, "idle"); // 启动调度器 start_scheduler(); while (1); }

start_scheduler 函数负责选出第一个要运行的任务,设置 current_tcb 和 next_tcb,然后触发 PendSV。第一次触发时 PSP 是 0,PendSV 会跳过保存直接恢复,任务就开始运行了。

void start_scheduler(void) { int first = find_next_task(); if (first < 0) return; current_task_id = first; current_tcb = &task_table[first]; next_tcb = current_tcb; current_tcb->state = TASK_RUNNING; // 触发第一次切换 trigger_task_switch(); // 开中断,让 PendSV 执行 __enable_irq(); // 正常情况下不会执行到这里 while (1); }

5.2 用调试器验证 PSP 和栈内容

第一次跑调度器时,最可能出问题的地方就是栈帧初始化。我的建议是先用调试器单步跟踪,在 PendSV 处理程序里下断点,观察 PSP 的值和栈内容。

具体操作:在 Keil 里打开寄存器窗口,找到 PSP 寄存器。第一次进入 PendSV 时,PSP 应该是 0(因为还没有任务运行过)。恢复流程执行完后,PSP 应该指向第一个任务栈的某个位置。然后单步执行 BX LR,如果一切正常,程序会跳到任务入口函数开始执行。

如果跳过去之后跑飞了,大概率是栈帧里 PC 或 xPSR 的值不对。检查 init_task_stack 函数里填的值,特别是 xPSR 的 bit24 必须是 1,PC 必须是奇数(Thumb 指令地址最低位为 1)。函数指针本身通常就是奇数,但如果你做了地址运算,可能会丢掉这个位。

5.3 常见跑飞问题的排查顺序

任务切换跑飞是新手最常遇到的问题,我总结了一个排查顺序,按这个顺序查基本能定位到原因。

现象可能原因排查方法
第一次切换就跑飞栈帧 PC 或 xPSR 填错检查 init_task_stack 里的值
切换几次后跑飞栈溢出覆盖了 TCB用栈填充模式检查栈使用量
中断一来就跑飞PendSV 优先级不是最低检查 NVIC_SetPriority 调用
任务里调用函数后跑飞LR 值填错确认填的是 0xFFFFFFFD
延时后不恢复SysTick 没使能或计数逻辑错在 SysTick_Handler 里下断点

这个表是我在实际调试中一点点攒出来的,每次遇到新问题就加一行。现在基本上看到现象就能猜到原因,省了很多时间。

6. 抢占行为的验证与性能实测

6.1 怎么证明调度器真的在抢占

写完调度器后,怎么验证它确实在抢占?最直接的方法是用 GPIO 翻转加示波器观察。

创建两个任务,一个高优先级任务在延时后翻转 GPIO,一个低优先级任务在循环里翻转另一个 GPIO。如果调度器工作正常,高优先级任务延时到期时应该立即抢占低优先级任务,示波器上能看到高优先级任务的 GPIO 波形不受低优先级任务影响,延时精度很高。

void high_prio_task(void) { while (1) { GPIO_SetBits(GPIOA, GPIO_Pin_0); task_delay(10); GPIO_ResetBits(GPIOA, GPIO_Pin_0); task_delay(10); } } void low_prio_task(void) { while (1) { GPIO_SetBits(GPIOA, GPIO_Pin_1); for (volatile int i = 0; i < 1000; i++); GPIO_ResetBits(GPIOA, GPIO_Pin_1); for (volatile int i = 0; i < 1000; i++); } }

如果高优先级任务的波形周期稳定在 20ms,说明抢占正常。如果波形被拉长或者抖动很大,说明抢占没生效,可能是 PendSV 优先级设置有问题,或者 SysTick 中断里没有正确触发切换。

6.2 切换开销的测量方法

任务切换开销是衡量调度器性能的重要指标。测量方法是在 PendSV 处理程序的开头和结尾各翻转一个 GPIO,用示波器量脉冲宽度。

在 72MHz 的 F103C8T6 上,我实测的切换开销大约在 2 到 3 微秒之间,包括硬件自动压栈弹栈和软件手动压栈弹栈的时间。这个开销对于大多数应用来说完全可以接受,1ms 的 SysTick 周期里切换一次只占 0.3% 的 CPU 时间。

如果想进一步优化,可以把 PendSV 处理程序放到 RAM 里执行,避免 Flash 等待周期。F103C8T6 的 Flash 在 72MHz 下需要 2 个等待周期,放到 RAM 里能省一点时间。不过对于手写调度器来说,这点优化意义不大,除非你的切换频率非常高。

6.3 多任务并发时的资源竞争处理

多个任务同时访问共享资源时,需要做互斥保护。最简单的办法是关中断,但关中断会影响系统实时性,只适合保护很短的临界区。

void critical_section_enter(void) { __disable_irq(); } void critical_section_exit(void) { __enable_irq(); }

如果临界区比较长,或者需要在临界区里调用可能阻塞的函数,关中断就不合适了。这时候可以用调度器锁:进入临界区时禁止任务切换,退出时允许切换。实现方式是设置一个全局标志,PendSV 处理程序里检查这个标志,如果被锁住就延迟切换。

volatile uint8_t scheduler_locked = 0; void scheduler_lock(void) { __disable_irq(); scheduler_locked++; __enable_irq(); } void scheduler_unlock(void) { __disable_irq(); if (scheduler_locked > 0) { scheduler_locked--; } __enable_irq(); }

在 PendSV 处理程序开头加上判断:如果 scheduler_locked 不为零,就清除 PendSV 挂起位直接返回,等解锁后再重新触发切换。这样既能保护临界区,又不会长时间关中断。

7. 从最小系统板到实际项目的移植经验

7.1 最小系统板上的硬件资源约束

STM32F103C8T6 最小系统板只有 20KB RAM 和 64KB Flash,资源相当紧张。手写调度器本身占用的资源不多,代码量在 2KB 左右,RAM 占用主要是任务栈和 TCB。8 个任务各 128 字栈,加上 TCB 和其他全局变量,总共大约 5KB RAM,还剩 15KB 给应用程序用。

如果任务里要用 printf 调试,注意 printf 本身会占用不少栈空间,建议把用了 printf 的任务栈加大到 256 字。另外 printf 的重定向需要实现 fputc 函数,把输出定向到串口。

int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

7.2 在 Keil4 下建立工程模板的注意事项

Keil4 建立 STM32F103C8T6 标准库工程时,有几个地方容易出错。启动文件要选 startup_stm32f10x_md.s,md 表示中等容量,F103C8T6 属于这一档。如果选成 hd(大容量)或 ld(小容量),中断向量表会对不上,程序跑不起来。

PendSV_Handler 这个名字在启动文件里已经定义好了弱符号,我们在自己的 .s 文件里重新定义同名符号就能覆盖它。注意不要拼错,PendSV_Handler 中间没有下划线分隔,就是 PendSV 加下划线加 Handler。

另外,如果工程里同时包含了标准库的 stm32f10x_it.c,里面可能也有 PendSV_Handler 的定义,需要把它注释掉或者删掉,否则会报重复定义错误。

7.3 从裸机调度器到完整 RTOS 的扩展方向

手写调度器跑通之后,如果想继续深入,可以往几个方向扩展。一是加入信号量或消息队列,实现任务间通信。二是加入优先级继承机制,解决优先级反转问题。三是加入内存管理,支持动态创建和删除任务。

不过对于大多数 F103C8T6 的项目来说,一个能抢占、能延时、能互斥的调度器已经够用了。我自己的几个小项目就是用这套手写调度器跑的,稳定运行了两年多没出过问题。关键是要把栈溢出检测和临界区保护做好,这两点做到了,可靠性就有保障。

最后分享一个调试小技巧:在 PendSV 处理程序里加一个全局计数器,每次切换时加一。然后在主循环里定期打印这个计数器的值,就能知道系统的切换频率。如果切换频率异常高,说明有任务在频繁阻塞和唤醒,可能需要优化任务设计。如果切换频率为零,说明调度器没在工作,赶紧查 PendSV 有没有被正确触发。这个计数器我每个项目都会加,排查问题时特别有用。

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

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

立即咨询