LiteOS-M PendSV 阻塞修复:SVC + PendSV 标准启动流程与 FPU 布局陷阱(LiteOS-M 移植⑤·续篇)

系列定位:STM32MP157 M4 的 LiteOS-M 移植系列(Windows 纯 GCC + Makefile,不做 Keil)。
本篇是[第⑤篇「链接脚本与编译运行」]的续篇——第⑤篇结尾用「直接调用 led_task() 绕开 LOS_Start()」的临时方案让 LED 先闪起来,本篇把它升级为 SVC + PendSV 标准启动流程,真正恢复多任务调度。


摘要:本文解决第⑤篇遗留的调度器启动问题——LOS_Start() 卡死、任务从未被调度。核心修复是通过 svc 0 触发 SVC 异常,再以 bx lr(EXC_RETURN=0xFFFFFFFD)真正退出 Reset 上下文,进入 Thread 模式解锁 PendSV。同时修复了 FPU 宏丢失导致的 TaskContext 布局错位(68→204 字节),最终实现双任务独立闪烁。

0 一句话看懂:改了什么

第⑤篇留下一个尾巴:LOS_Start() 启动不了调度器,只能绕开它。本篇找到根因并修复,核心变化就一句话:

对比项修改前(第⑤篇临时方案)修改后(本篇正式方案)
main.c直接调用 led_task()LOS_TaskCreate ×2 + LOS_Start()
任务数1 个(忙等闪烁)2 个(LED0 + LED1)
延时for(volatile i...) 忙等LOS_TaskDelay(500/200)
HalStartToRunbx r6(普通跳转)cpsie i + svc 0(触发系统调用)
SVC 处理SVC_Handler = Default_Handler(空)新增 HalSVCHandlerbx lr 异常返回)
LOS_TaskDelay❌ 不可用(依赖 PendSV)✅ 正常工作
调度器❌ 未启动,单任务裸跑✅ 启动,多任务轮流调度
TaskContext 布局68 字节(FPU 宏丢失)204 字节(显式定义 FPU 宏)

读完全篇你会理解:为什么第⑤篇要绕开 LOS_Start,以及怎样用一次 SVC 异常返回把调度器真正拉起来——外加一个第⑤篇没提到的 FPU 布局坑。


1 修改前的现象:任务创建成功,但从未被调度

第⑤篇的临时方案之所以「绕开」,是因为标准写法根本跑不起来:

LOS_TaskCreate(&task_id, &task_init);  // 创建任务 → 成功
LOS_Start();                            // 启动调度器 → 卡死

任务创建成功,但从未被调度。系统掉进 HalSysExit 死循环(LOS_IntLock(); while(1){}),LED 不闪。

当时的临时对策(第⑤篇修复 3):

if (LOS_KernelInit() != LOS_OK) { while (1); }
led_task();   /* 直接调用,绕开 LOS_Start → HalStartToRun → bx r6 路径 */

任务能跑,但只是跑在 main 的调用栈上,没有经过调度器——LOS_TaskDelay 不能用(依赖 PendSV 上下文切换),只能用忙等循环替代。这不是 RTOS 该有的样子。


2 根因:Cortex-M 异常优先级陷阱

2.1 异常优先级层级

Cortex-M 用「数值越小优先级越高」:

异常优先级说明
Reset-3(固定)最高,不可改
NMI-2(固定)不可屏蔽
HardFault-1(固定)硬件故障
SVC0(可编程,复位默认)系统调用
SysTick0xF0(LiteOS 设为最低)系统节拍
PendSV0xF0(LiteOS 设为最低)上下文切换

在这里插入图片描述

2.2 bx r6 为什么不工作

看 LiteOS-M 原始的 HalStartToRunlos_dispatch.S):

HalStartToRun:
    ldr     r4, =OS_NVIC_SYSPRI2      @ SHPR3:设置 PendSV/SysTick 优先级
    ldr     r5, =OS_NVIC_PENDSV_PRI
    str     r5, [r4]

    mov     r0, #2                    @ CONTROL.SPSEL = 1(切到 PSP)
    msr     CONTROL, r0
    ...
    ldmfd   r12!, {R0-R7}             @ 手动恢复寄存器
    msr     psp, r12                  @ 设置 PSP
    cpsie   i                          @ 开中断
    bx      r6                         @ ← 普通跳转,不是异常返回!

关键在这一行 bx r6

  1. bx r6 是普通跳转,只是把 PC 改到任务入口,不会执行「异常返回」动作。
  2. main() 是从 Reset_Handlerbl main 调进来的,整条调用链(Reset → main → LOS_Start → HalStartToRun)都在 Reset 异常上下文里执行。
  3. 因为始终没有异常返回,CPU 一直停留在 Reset 异常上下文,异常优先级被压死在 -3
  4. PendSV 的优先级是 0xF0(数值 15),远低于 -3,永远无法抢占,调度器的上下文切换永远不会发生。

一句话:只要 Reset 异常保持活跃(没做异常返回),PendSV 就永远排队等不到执行机会。

另一个细节:msr CONTROL, #2 在 Handler 模式下写 SPSEL无效的(SPSEL 只能在 Thread 模式生效),这也侧面印证了「原实现没真正退出 Handler 模式」。


3 修改后:SVC + PendSV 标准启动流程

这是 Cortex-M 上所有 RTOS 启动第一个任务的标准做法(FreeRTOS 同款)。第⑤篇 5.2.9 结尾列的「后续恢复步骤」三步,本篇逐一落地:

  1. HalStartToRun 不再手动恢复寄存器 + 普通跳转,而是设置好优先级后,直接 svc 0 触发系统调用。
  2. HalSVCHandler(SVC 异常处理函数)里,把第一个任务的上下文从 PSP 弹出来,然后 bx lrEXC_RETURN = 0xFFFFFFFD)做真正的异常返回
  3. CPU 由此退出 Reset 上下文、进入 Thread 模式(优先级 0),PendSV 随之解锁。

整个时序如下:

在这里插入图片描述

EXC_RETURN 说明

EXC_RETURN 是异常返回时放在 LR 里的特殊值,高 28 位全为 1,低 4 位编码返回目标:

EXC_RETURN含义
0xFFFFFFF1返回 Handler 模式 + MSP
0xFFFFFFF9返回 Thread 模式 + MSP
0xFFFFFFFD返回 Thread 模式 + PSP(基本帧,无 FPU 硬件栈操作)
0xFFFFFFED返回 Thread 模式 + PSP(扩展帧,含 FPU)

这里选择 0xFFFFFFFD:因为我们在 HalSVCHandler 里已经手动恢复了 FPU 寄存器(D8-D15),不需要硬件在异常返回时再处理 FPU 帧,所以用 basic frame 即可。


4 代码改动:逐文件「改前 → 改后」

4.1 los_dispatch.SHalStartToRun 重写

改前bx r6 普通跳转):

    ldmfd   r12!, {R0-R7}
    msr     psp, r12
    cpsie   i
    bx      r6               @ ← 普通跳转,卡在 Reset 上下文

改后svc 0 触发系统调用):

HalStartToRun:
    ldr     r4, =OS_NVIC_SYSPRI2          @ SHPR3 (0xE000ED20)
    ldr     r5, =OS_NVIC_PENDSV_PRI        @ 0xF0F00000
    str     r5, [r4]

    cpsie   i                              @ 开中断
    svc     0                              @ 触发 SVCall
    b       .                              @ 安全网:不可达

4.2 los_dispatch.S:新增 HalSVCHandler

改前:SVC 向量指向 HalExcSvcCall(异常处理,不是任务启动)。

改后:新增专门的 SVC 处理函数:

HalSVCHandler:
    ldr     r0, =g_losTask
    ldr     r0, [r0]                       @ r0 = g_losTask.runTask
    ldr     r0, [r0]                        @ r0 = runTask->stackPointer

    ldr     r1, =OS_FPU_CPACR              @ 判断 FPU 是否使能
    ldr     r1, [r1]
    and     r1, r1, #OS_FPU_CPACR_ENABLE
    cmp     r1, #OS_FPU_CPACR_ENABLE
    bne     .LSVC_RestoreCore

    vldmia  r0!, {d8-d15}                  @ FPU:恢复 S16-S31 (64 字节)

.LSVC_RestoreCore:
    ldmia   r0!, {r4-r11}                  @ 恢复 R4-R11 (32 字节)
    adds    r0, r0, #4                     @ 跳过 uwPriMask
    msr     psp, r0                        @ PSP ← 硬件帧起始
    mov     r1, #2
    msr     CONTROL, r1                    @ SPSEL=1, FPCA=0
    isb
    mvn     lr, #2                         @ lr = ~2 = 0xFFFFFFFD
    bx      lr                             @ 异常返回 → 第一个任务启动!

为什么用 mvn lr, #2?因为 0xFFFFFFFD = ~0x00000002MVN 用小立即数即可编码,比 ldr lr, =0xFFFFFFFD 更省一次字面量池访问。

4.3 向量表路由:SVC 指向新 handler

/* los_interrupt.c:g_hwiForm 里的 SVC 向量 */
g_hwiForm[SVCall_IRQn + OS_SYS_VECTOR_CNT] = HalSVCHandler;  // 改前:HalExcSvcCall
/* startup_stm32mp15xx.s:弱别名兜底 */
  .weak      SVC_Handler
  .thumb_set SVC_Handler,HalSVCHandler     // 改前:Default_Handler

同时注释掉 stm32mp1xx_it.c 里的 C 版 SVC_Handler(与 PendSV、SysTick 同处理)。

4.4 main.c:从「绕开」到「标准多任务」

改前(第⑤篇临时方案):

static void led_task(void)   /* 单任务 + 忙等 */
{
    while (1) {
        LED0(0); LED1(1);
        for (volatile UINT32 i = 0; i < 4000000; i++) { __NOP(); }
        LED0(1); LED1(0);
        for (volatile UINT32 i = 0; i < 4000000; i++) { __NOP(); }
    }
}

int main(void)
{
    ...
    if (LOS_KernelInit() != LOS_OK) { while (1); }
    led_task();    /* 直接调用,绕开 LOS_Start */
    while (1);
}

改后(标准多任务):

static VOID *led0_task(UINT32 arg)   /* 红灯:500ms 亮灭,优先级 2 */
{
    (void)arg;
    while (1) { LED0(0); LOS_TaskDelay(500); LED0(1); LOS_TaskDelay(500); }
    return NULL;
}

static VOID *led1_task(UINT32 arg)   /* 绿灯:200ms 亮灭,优先级 3 */
{
    (void)arg;
    while (1) { LED1(0); LOS_TaskDelay(200); LED1(1); LOS_TaskDelay(200); }
    return NULL;
}

int main(void)
{
    ...
    if (LOS_KernelInit() != LOS_OK) { while (1); }

    TSK_INIT_PARAM_S task_init = {0};
    task_init.pfnTaskEntry = (TSK_ENTRY_FUNC)led0_task;
    task_init.usTaskPrio   = 2;
    task_init.uwStackSize  = 0x400;
    if (LOS_TaskCreate(&g_led0_task_id, &task_init) != LOS_OK) { while (1); }

    task_init.pfnTaskEntry = (TSK_ENTRY_FUNC)led1_task;
    task_init.usTaskPrio   = 3;
    if (LOS_TaskCreate(&g_led1_task_id, &task_init) != LOS_OK) { while (1); }

    LOS_Start();   /* 启动调度器,永不返回 */
    while (1);
}

变化:从「1 个忙等任务」变成「2 个 LOS_TaskDelay 任务」。两个任务不同优先级、不同周期(500ms vs 200ms),本身就是「调度器真的在切换」的最直观证据。


5 额外的坑:FPU 布局不一致 → UsageFault(第⑤篇没遇到)

做完上面的改动,烧录 + GDB 实测,又暴露了一个更隐蔽的坑——这是第⑤篇临时方案(不经过 HalStartToRunbx r6 路径)没有触发的。

5.1 现象

断点 HalSVCHandler 命中时一切正常(xpsr 低 9 位 = 11 = SVC),但单步跨过 bx lr 后,CPU 没进任务,而是掉进了 HalExcUsageFault(IPSR=6)。

5.2 根因:编译时布局 ≠ 运行时状态

关键在于 TaskContext 结构体的布局由编译时宏决定,而上下文切换汇编用运行时寄存器检测,两者在 FPU 上不一致:

  1. 编译时:LiteOS 内核 arch 层的头文件(los_arch_context.hlos_arch_interrupt.h)只 include 了 los_config.h / los_compiler.h没有 include CMSIS 的 core_cm4.h。因此 __FPU_PRESENT__FPU_USED 在内核编译单元里是未定义的,TaskContext 被编译成 68 字节的非 FPU 布局。证据:
$ objdump -d build/m4_liteos.elf | grep -A 5 "<HalTskStackInit>:"
1000a308: 3b44   subs r3, #68    @ sizeof(TaskContext) = 68 字节(非 FPU)
  1. 运行时:HAL 层的 SystemInit() 通过 stm32mp1xx_hal.h 引入 core_cm4.h__FPU_USED=1,于是使能了 CPACR(FPU)。而汇编 HalSVCHandler / HalPendSV 在运行时检测 CPACR=0xF00000,走了 FPU 路径vldmia {d8-d15},多偏移 64 字节)。

  2. 错位HalSVCHandler 按 FPU 布局算出 PSP = context + 100,但 TaskContext 实际只有 68 字节、硬件帧在 context + 36。PSP 偏了 64 字节,指向栈外 → 异常返回弹出垃圾 PC/xPSR → UsageFault

在这里插入图片描述

5.3 修复

los_config.h 的 include 之后显式定义这两个宏:

#ifndef __FPU_PRESENT
#define __FPU_PRESENT             1
#endif
#ifndef __FPU_USED
#define __FPU_USED                1
#endif

为什么要显式定义?因为 Makefile 用了 -mfloat-abi=hard -mfpu=fpv4-sp-d16,FPU 本就是真实使用的,TaskContext 本就应该用 204 字节的 FPU 布局。而内核 arch 层缺 CMSIS 头导致宏丢失,才让布局悄悄缩水成 68 字节。

5.4 修复后的验证

$ objdump -d build/m4_liteos.elf | grep -A 5 "<HalTskStackInit>:"
1000a308: 3bcc   subs r3, #204   @ sizeof(TaskContext) = 204 字节(FPU 布局)✓

6 验证结果

验证项结果
编译链接0 error
反汇编 HalSVCHandler末尾 mvn lr, #2 + bx lr(EXC_RETURN=0xFFFFFFFD),确认异常返回
GDB 硬件帧8 字与 HalTskStackInit 初始化一字不差
GDB sibx lr进入 OsTaskEntry,不再掉 UsageFault
HalPendSV 断点反复命中,多任务调度打通
LED 最终效果LED0(红)500ms、LED1(绿)200ms 各自独立闪烁

对比第⑤篇:当时 LED 是忙等循环驱动的单任务闪烁,节奏由 for 循环次数决定;现在换成 LOS_TaskDelay500ms/200ms 是准时的调度延时,且两个任务真正在轮流切换。

(详细的编译、烧录、GDB 调试步骤见工程 README.md。)


7 总结:临时方案 vs 正式方案的差距

对比项第⑤篇临时方案本篇正式方案
跳转方式bx r6 普通跳转svc 0 + bx lr 异常返回
结束状态Reset 上下文 (pri -3)Thread 模式 (pri 0)
PendSV永久阻塞正常触发
调度器未启动正常工作
LOS_TaskDelay❌ 忙等替代✅ 正常工作
任务数1 个2 个独立切换
FPU 布局(未走该路径,未暴露)修复 68→204 字节

核心教训

  1. 在 Cortex-M 上启动第一个任务,必须通过一次异常返回来退出 Reset 异常上下文,否则 PendSV(以及所有可编程优先级的异常)都会被优先级 -3 的 Reset 压住。SVC 是为此而生的——同步异常、优先级默认最高(0),可先于任何 pending IRQ 被响应,安全完成「从 Reset 上下文到 Thread 模式」的切换。

  2. 上下文结构布局(编译时宏)与上下文切换汇编(运行时寄存器检测)必须严格一致。RTOS 里这两处最容易因「宏丢失」而悄悄错位,且只在运行时以 UsageFault/HardFault 的形式爆发。

这也是 FreeRTOS、RT-Thread 等所有主流 RTOS 在 Cortex-M 上启动第一个任务时,不约而同采用 SVC(或等价机制)的根本原因。

📌 重要后期勘误(代码复盘最终修正)
本文实现的 SVC + 异常返回启动方案,是 Cortex‑M 主流 RTOS 的标准通用启动方式,并非临时补救方案。该方案专门解决 HalStartToRun 在 Handler 异常模式执行 的场景:此时 CPU 滞留 Reset 异常上下文,普通 bx r6 跳转无法切换堆栈与运行模式,PendSV 会被高优先级异常永久阻塞,必须依靠 SVC 异常返回完成模式切换与栈帧恢复。
而 LiteOS‑M 原生默认流程(Reset_Handler → main (Thread 模式) → LOS_Start → HalStartToRun)全程运行在 Thread 模式下。在该合法前置条件下,CPU 可直接修改 CONTROL、PSP 寄存器,通过普通跳转即可正常拉起首个任务,无需 SVC 参与。
两种实现均符合 ARM 架构规范:
SVC 方案:通用性极强,兼容任意调用上下文,是 FreeRTOS、RT‑Thread 等商用 RTOS 的统一标准;
直接跳转方案:轻量化精简实现,依赖「必须在 Thread 模式启动调度器」的内核约束。
无论使用哪种启动方式,FPU 编译宏、C 语言 TaskContext 结构体大小、汇编栈偏移三者必须严格对齐,否则会出现栈布局错乱,稳定触发 UsageFault 异常。
—源代码下载链接:https://download.csdn.net/download/xiao089412/93270656
github 下载地址:https://gitee.com/tstcoder/stm32mp157-liteos-m/tree/v2.0-svc

系列目录
链接脚本与编译运行(临时方案绕开调度器)
⑤·续篇 本篇:SVC + PendSV 标准启动流程,恢复完整多任务调度

欢迎评论区交流移植经验,把复杂的讲简单,持续更新中。


标签#STM32MP157 #LiteOS-M #GCC #Makefile #RTOS #PendSV #SVC #Cortex-M #嵌入式

Logo

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

更多推荐