📌 本文是《STM32内核精讲》第五篇《双堆栈机制详解》的配套实战
使用 STM32H723ZGT6 + STM32CubeIDE 1.19.0,仅靠 CMSIS‑Core 头文件,手写汇编完成两个任务的上下文切换。

一、实验目标与平台

目标:在两个独立堆栈中交替运行两个任务,每次切换由 PendSV 异常完成。调试器中能清晰看到 PSP 在两个栈顶之间跳变,同时 task1_cnttask2_cnt 同步递增。

软硬件环境

  • 芯片:STM32H723ZGT6(Cortex‑M7,最高 550 MHz)
  • IDE:STM32CubeIDE 1.19.0
  • 依赖:仅使用 CMSIS‑Core 头文件 stm32h7xx.h(无 HAL、无 RTOS)
  • 浮点处理工程强制关闭 FPUFloating-point unit = None

二、工程创建:纯内核工程(快速版)

为节省篇幅,工程创建的核心目标是 只保留启动文件和 main.c,删除所有 HAL 文件

  1. 新建 STM32 Project → 选择 STM32H723ZG,语言选 C,生成 Executable。

在这里插入图片描述

  1. 弹出固件库配置页时,仅勾选第一项 Add necessary library files as reference,其他不选。
    在这里插入图片描述

  2. 接受许可协议,在弹出的 MPU 弹窗中选 No

  3. 进入 .iocProject Manager → Code Generator取消勾选 Generate peripheral initialization ...,然后 GENERATE CODE
    在这里插入图片描述

  4. 删除自动生成的以下文件/文件夹:

    • Core/Inc/stm32h7xx_hal_conf.hstm32h7xx_it.h
    • Core/Src/stm32h7xx_hal_msp.cstm32h7xx_it.csystem_stm32h7xx.c
    • Drivers/ 整个文件夹
      在这里插入图片描述
  5. 工程属性 → C/C++ Build → Settings → MCU Settings,将 Floating-point unit 设为 NoneFloating-point ABI 选择 Software implementation
    在这里插入图片描述

  6. 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_RETURNinit_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 调试配置(重要)

为避免某些板子上的看门狗干扰,务必在调试配置中加入以下两项:

  1. Run → Debug Configurations → 选中你的配置 → Debugger 标签:
    • Suspend watchdog counters while halted = Yes
    • (可选)Command line options 中添加 -ignore 0 来忽略 WWDG 中断
  2. 拔掉 USB 线等待 10 秒再插上,确保看门狗硬件复位。

5.3 设置断点与观察

  1. 三个核心断点(在行号左侧双击):

    • task1for (volatile int i = 0; i < 200000; i++);
    • task2 的同一位置
    • PendSV_Handler 的第一句 "MRS r0, psp"
  2. 关注窗口

    • Registers 视图:展开 Core 组,盯住 PSP 值。
    • Expressions 视图:添加 task1_cnttask2_cnttask1_sptask2_sp
  3. 运行观察

    • F8 运行。
    • 程序会交替停在 task1 forPendSVtask2 forPendSV… 之间。
    • PSP 值:第一次停在 PendSV 时,PSP 指向 task1_sp 左右;第二次指向 task2_sp;之后循环。
    • 计数器task1_cnttask2_cnt 同步增长。每次进入任务 for 循环断点,对应的 cnt 变量就会 +1,你可以用 “步进 + 观察变量” 来验证两个任务是否真正在交替执行。

5.4 成功标志

  • 断点在三个位置间轮转。
  • task1_cnttask2_cnt 的值在每次暂停时均有所增加(可手动记录两次暂停间的差值)。
  • PSP 地址在 task1_sptask2_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_cnttask2_cnt,你可以彻底量化任务的执行情况。

当你在调试器中亲眼看到 PSP 在两个地址之间跳动,两个计数器交替递增时,你对“操作系统任务切换”的理解将不再停留在书本,而是化作你亲手操控的硅片行为。

关键词:#STM32H723, #PSP, #MSP, #PendSV, #双任务, #裸机, #Cortex‑M7, #STM32CubeIDE

原创不易,创作花费大量时间和精力💦,如果本文对你有帮助,欢迎
点赞👍、收藏⭐、关注➕,有任何问题,评论区留言,我会一一回复!你的支持,就是我持续更新的动力~
本文所使用的工程文件已上传至配套资源中,如有需要可自行下载。也可关注博主后留言获取

📢 关于作者与更多内容

我是 BackCatK Chen,长期关注嵌入式底层、国产半导体与 AI 算力芯片。

如果你对芯片架构、行业趋势感兴趣,欢迎关注我的公众号,获取更多宏观技术观察。

在这里插入图片描述

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐