【番外篇】纯手工 PSP 任务切换终极版:无 RTOS、无 HAL、无 SVC,最简双任务轮转
本文通过STM32H723ZGT6芯片和STM32CubeIDE环境,演示了基于Cortex-M7内核的双任务堆栈切换机制。实验完全脱离HAL库和RTOS,仅使用CMSIS-Core头文件,通过手写汇编实现任务上下文切换。文章详细介绍了工程配置步骤,包括关闭FPU、精简工程文件等关键设置,并提供了完整的代码实现。核心部分展示了如何初始化任务堆栈、定义任务函数,以及通过PendSV异常处理程序完成双
📌 本文是《STM32内核精讲》第五篇《双堆栈机制详解》的配套实战。
使用 STM32H723ZGT6 + STM32CubeIDE 1.19.0,仅靠 CMSIS‑Core 头文件,手写汇编完成两个任务的上下文切换。
一、实验目标与平台
目标:在两个独立堆栈中交替运行两个任务,每次切换由 PendSV 异常完成。调试器中能清晰看到 PSP 在两个栈顶之间跳变,同时 task1_cnt 和 task2_cnt 同步递增。
软硬件环境:
- 芯片:STM32H723ZGT6(Cortex‑M7,最高 550 MHz)
- IDE:STM32CubeIDE 1.19.0
- 依赖:仅使用 CMSIS‑Core 头文件
stm32h7xx.h(无 HAL、无 RTOS) - 浮点处理:工程强制关闭 FPU(
Floating-point unit = None)
二、工程创建:纯内核工程(快速版)
为节省篇幅,工程创建的核心目标是 只保留启动文件和 main.c,删除所有 HAL 文件。
- 新建 STM32 Project → 选择
STM32H723ZG,语言选 C,生成 Executable。

-
弹出固件库配置页时,仅勾选第一项
Add necessary library files as reference,其他不选。
-
接受许可协议,在弹出的 MPU 弹窗中选 No。
-
进入
.ioc的Project Manager → Code Generator,取消勾选Generate peripheral initialization ...,然后GENERATE CODE。
-
删除自动生成的以下文件/文件夹:
Core/Inc/stm32h7xx_hal_conf.h、stm32h7xx_it.hCore/Src/stm32h7xx_hal_msp.c、stm32h7xx_it.c、system_stm32h7xx.cDrivers/整个文件夹
-
工程属性 →
C/C++ Build → Settings → MCU Settings,将Floating-point unit设为 None,Floating-point ABI选择 Software implementation。
-
在
main.c中全选、删除,粘贴本文最终的完整代码。
三、完整代码(直接可用)
#include "stm32h7xx.h"
// 启动文件强制要求
void SystemInit(void) {}
void ExitRun0Mode(void) {}
// 异常兜底(方便定位问题)
void HardFault_Handler(void) { while(1); }
void MemManage_Handler(void) { while(1); }
void BusFault_Handler(void) { while(1); }
void UsageFault_Handler(void){ while(1); }
void NMI_Handler(void) { while(1); }
void SysTick_Handler(void) { while(1); }
// ###########################################
// 任务配置
// ###########################################
#define TASK_STACK_SIZE 128
// 强制8字节对齐(M7 要求)
uint32_t task1_stack[TASK_STACK_SIZE] __attribute__((aligned(8)));
uint32_t task2_stack[TASK_STACK_SIZE] __attribute__((aligned(8)));
// 任务栈顶指针(保存当前的 PSP 值)
uint32_t *task1_sp;
uint32_t *task2_sp;
// 当前任务ID(1/2)
volatile uint32_t current_task = 1;
// 调试计数器:分别统计两个任务的执行次数
volatile uint32_t task1_cnt = 0;
volatile uint32_t task2_cnt = 0;
// 任务函数声明
void task1(void);
void task2(void);
// ###########################################
// 任务栈初始化(ARM 官方标准写法)
// ###########################################
uint32_t* init_task_stack(uint32_t *stack, void (*task)(void))
{
// 栈顶 = 数组首地址 + 大小(满递减堆栈)
uint32_t *sp = stack + TASK_STACK_SIZE;
// 1. 硬件自动入栈的寄存器(xPSR, PC, LR, R12, R3-R0)
*(--sp) = 0x01000000; // xPSR (T=1, Thumb 模式)
*(--sp) = (uint32_t)task; // PC = 任务入口地址
*(--sp) = 0xFFFFFFFD; // LR = EXC_RETURN(返回线程模式 + PSP)
*(--sp) = 0; // R12
*(--sp) = 0; // R3
*(--sp) = 0; // R2
*(--sp) = 0; // R1
*(--sp) = 0; // R0
// 2. 软件手动保存的寄存器(R4-R11)
// 第一次 PendSV 切换时不再需要填充垃圾数据,
// 这里提前预留位置,保证栈结构完整。
*(--sp) = 0; // R11
*(--sp) = 0; // R10
*(--sp) = 0; // R9
*(--sp) = 0; // R8
*(--sp) = 0; // R7
*(--sp) = 0; // R6
*(--sp) = 0; // R5
*(--sp) = 0; // R4
return sp; // 返回栈帧顶部(指向 R4 的位置)
}
// ###########################################
// 任务函数
// ###########################################
void task1(void)
{
while(1)
{
task1_cnt++; // 任务1计数器自增
for(volatile int i = 0; i < 200000; i++); // 模拟工作
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 请求 PendSV 进行任务切换
}
}
void task2(void)
{
while(1)
{
task2_cnt++;
for(volatile int i = 0; i < 200000; i++);
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
}
// ###########################################
// PendSV 异常处理(ARM 官方标准上下文切换)
// ###########################################
__attribute__((naked)) void PendSV_Handler(void)
{
__asm volatile (
"MRS r0, psp \n" // 读出当前 PSP
"STMDB r0!, {r4-r11} \n" // 将 R4-R11 压入当前任务栈
// 根据 current_task 保存 PSP 到对应指针
"LDR r1, =current_task \n"
"LDR r2, [r1] \n"
"CMP r2, #1 \n"
"BEQ save1 \n"
"LDR r2, =task2_sp \n"
"STR r0, [r2] \n"
"B next \n"
"save1: \n"
"LDR r2, =task1_sp \n"
"STR r0, [r2] \n"
"next: \n"
// 切换任务 ID(1↔2)
"LDR r1, =current_task \n"
"LDR r2, [r1] \n"
"EOR r2, #3 \n" // 1^3=2, 2^3=1
"STR r2, [r1] \n"
// 根据新 ID 加载下一个任务的 PSP
"CMP r2, #1 \n"
"BEQ load1 \n"
"LDR r2, =task2_sp \n"
"LDR r0, [r2] \n"
"B restore \n"
"load1: \n"
"LDR r2, =task1_sp \n"
"LDR r0, [r2] \n"
"restore: \n"
"LDMIA r0!, {r4-r11} \n" // 从新任务栈弹出 R4-R11
"MSR psp, r0 \n" // 更新 PSP
"BX lr \n" // 异常返回
);
}
// ###########################################
// main 函数:启动任务1
// ###########################################
int main(void)
{
// 1. 初始化任务栈,获得两个栈顶指针
task1_sp = init_task_stack(task1_stack, task1);
task2_sp = init_task_stack(task2_stack, task2);
// 2. 将 PendSV 设置为最低优先级,避免打断其他中断
NVIC_SetPriority(PendSV_IRQn, 0xFF);
// 3. 将 PSP 指向任务1的栈顶
__set_PSP((uint32_t)task1_sp);
// 4. 切换到线程模式 + 使用 PSP(CONTROL = 0x2)
__set_CONTROL(0x2);
__ISB(); // 指令同步屏障
// 5. 开启全局中断(PendSV 才能响应)
__enable_irq();
// 6. 直接调用任务1函数 —— 此时 CPU 已经使用 PSP,
// 所有函数调用、局部变量都将使用任务1的栈。
task1();
// 不会执行到这里
while(1);
}
四、核心设计解读
4.1 任务栈初始化:补齐 R4‑R11,告别首次切换隐患
任务栈初始化:完整压入 16 个寄存器
init_task_stack 在栈帧中同时压入了硬件自动保存的 8 个寄存器(xPSR、PC、LR、R12、R3‑R0)以及软件手动保存的 8 个寄存器(R4‑R11),初始值均设为 0。这样构造出来的栈帧与 PendSV 切换时的保存/恢复结构完全一致:
第一次任务运行时,PSP 已指向栈帧底部(R4 位置),任务函数内的任何操作都不会破坏栈结构。
首次触发 PendSV 时,STMDB {r4‑r11} 会在栈顶继续压入当前的 R4‑R11,栈帧格式与初始化时预留的位置完全匹配,无需特殊处理。
这种初始化方式与 ARM 官方推荐的上下文切换栈帧标准对齐,保证了后续每次 PendSV 切换的稳定性和可移植性。
4.2 启动方式:直接跳转,摒弃 SVC 异常
启动第一个任务时,我们没有使用过去版本中的 svc 0 触发异常,而是:
__set_PSP((uint32_t)task1_sp);
__set_CONTROL(0x2); // SPSEL=1, 使用 PSP
__ISB();
__enable_irq();
task1(); // 直接调用任务函数
原理:
- 在设置
CONTROL.SPSEL=1后,当前线程模式的栈指针立刻切换为 PSP。 task1()是一条普通的 C 函数调用,但此时栈指针已经是 PSP,指向task1_stack的顶部。所以task1内部的局部变量、函数调用全部使用 PSP 栈。- 关键点:
task1并不会“返回”到main,因为它是一个死循环,一直在内运行。
这种方式比 SVC 更简单,也更贴近“手工替换 RTOS 调度器”的原理:你只需要在进入第一个任务前把 PSP 指向任务栈,然后用 CONTROL 寄存器切换到 PSP 即可。
⚠️ 注意:
EXC_RETURN在init_task_stack中已经设为0xFFFFFFFD,这个值是为 PendSV 返回 准备的。第一次进入task1是我们手动调用,并非异常返回,所以这里的EXC_RETURN暂时没用到——但之后 PendSV 切换时会用到,因此必须保持。
4.3 PendSV 上下文切换(汇编分析)
MRS r0, psp ; 读取当前 PSP
STMDB r0!, {r4-r11} ; 保存 R4-R11
; 根据 current_task 保存 PSP 到 task1_sp 或 task2_sp
; 切换 current_task
; 加载新任务的 PSP
LDMIA r0!, {r4-r11} ; 恢复新任务的 R4-R11
MSR psp, r0 ; 更新 PSP
BX lr ; 异常返回
- 硬件在进入 PendSV 时,已经将 R0‑R3, R12, LR, PC, xPSR 压入了进入异常前使用的栈(即被中断任务的 PSP 栈)。
STMDB把剩余的 R4‑R11 继续压入同一个任务栈,实现了完整现场保存。- 切换时修改变量
current_task和对应的task1_sp/task2_sp。 LDMIA从新任务栈恢复 R4‑R11,然后MSR psp, r0让 PSP 指向剩下的栈帧。BX lr触发硬件自动弹出 R0‑R3, R12, LR, PC, xPSR,程序即跳转到新任务的断点处(通常是for循环内部或SCB->ICSR操作处)。
五、编译、烧录与调试
5.1 编译
确保工程已禁用 FPU(Properties → C/C++ Build → Settings → MCU Settings),Floating-point unit = None。
点击 Project → Build All,应 0 errors, 0 warnings。
5.2 调试配置(重要)
为避免某些板子上的看门狗干扰,务必在调试配置中加入以下两项:
Run → Debug Configurations→ 选中你的配置 →Debugger标签:Suspend watchdog counters while halted= Yes- (可选)
Command line options中添加-ignore 0来忽略 WWDG 中断
- 拔掉 USB 线等待 10 秒再插上,确保看门狗硬件复位。
5.3 设置断点与观察
-
三个核心断点(在行号左侧双击):
task1的for (volatile int i = 0; i < 200000; i++);task2的同一位置PendSV_Handler的第一句"MRS r0, psp"
-
关注窗口:
- Registers 视图:展开
Core组,盯住 PSP 值。 - Expressions 视图:添加
task1_cnt、task2_cnt、task1_sp、task2_sp。
- Registers 视图:展开
-
运行观察:
- 按
F8运行。 - 程序会交替停在
task1 for、PendSV、task2 for、PendSV… 之间。 - PSP 值:第一次停在 PendSV 时,PSP 指向
task1_sp左右;第二次指向task2_sp;之后循环。 - 计数器:
task1_cnt和task2_cnt同步增长。每次进入任务for循环断点,对应的cnt变量就会 +1,你可以用 “步进 + 观察变量” 来验证两个任务是否真正在交替执行。
- 按
5.4 成功标志
- 断点在三个位置间轮转。
task1_cnt与task2_cnt的值在每次暂停时均有所增加(可手动记录两次暂停间的差值)。- PSP 地址在
task1_sp和task2_sp之间来回跳变。 - 没有进入
HardFault_Handler或其他死循环。

六、常见问题与解决方案
| 现象 | 原因 | 解决 |
|---|---|---|
程序停在 WWDG_IRQHandler |
看门狗中断未屏蔽 | 在调试配置中设 Suspend watchdog while halted = Yes,或在代码开头添加 NVIC_DisableIRQ(WWDG_IRQn); |
| PSP 从未变化 | 全局中断未开 | 确认 main 中有 __enable_irq(); |
| 进入 HardFault | 栈未 8 字节对齐,或 FPU 未关闭 | 确认数组带 aligned(8),工程 FPU = None |
| 首次切换后跑飞 | 未预留 R4‑R11 栈帧 | 使用本文的 init_task_stack,已包含 R4‑R11 |
七、总结
这份代码是整个系列中最“直白”的双任务切换实现:
- 不依赖 SVC,不依赖 HAL,只有一个
main.c和一个启动文件。 - 任务栈初始化按照 ARM 标准压入完整的 16 个寄存器,保证 PendSV 切换时栈帧结构正确。
- 通过两个计数器
task1_cnt和task2_cnt,你可以彻底量化任务的执行情况。
当你在调试器中亲眼看到 PSP 在两个地址之间跳动,两个计数器交替递增时,你对“操作系统任务切换”的理解将不再停留在书本,而是化作你亲手操控的硅片行为。
关键词:#STM32H723, #PSP, #MSP, #PendSV, #双任务, #裸机, #Cortex‑M7, #STM32CubeIDE
原创不易,创作花费大量时间和精力💦,如果本文对你有帮助,欢迎
点赞👍、收藏⭐、关注➕,有任何问题,评论区留言,我会一一回复!你的支持,就是我持续更新的动力~
本文所使用的工程文件已上传至配套资源中,如有需要可自行下载。也可关注博主后留言获取
📢 关于作者与更多内容
我是 BackCatK Chen,长期关注嵌入式底层、国产半导体与 AI 算力芯片。
如果你对芯片架构、行业趋势感兴趣,欢迎关注我的公众号,获取更多宏观技术观察。

更多推荐




所有评论(0)