ARTICLE DETAIL

资讯详情

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

STM32F103C8T6手写抢占式调度器:从硬件机制到任务切换实战

STM32F103C8T6手写抢占式调度器:从硬件机制到任务切换实战 抢占式调度器这个词很多做单片机的朋友第一次听到会觉得是操作系统内核才配拥有的东西离自己写裸机代码很远。但真把 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; // xPSRbit24 1 *(--sp) 0x12121212; // R12 *(--sp) 0x03030303; // R3 *(--sp) 0x02020202; // R2 *(--sp) 0x01010101; // R1 *(--sp) 0x00000000; // R0 return sp; }这里有个细节值得说LR 的值我填的是 0xFFFFFFFD而不是 0xFFFFFFF9。这两个值的区别在于异常返回时用哪个栈。0xFFFFFFF9 表示返回后使用 MSP0xFFFFFFFD 表示返回后使用 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 位整数的每一位表示一个任务是否就绪找任务时用 CLZCount 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 是 0PendSV 会跳过保存直接恢复任务就开始运行了。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 必须是 1PC 必须是奇数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.smd 表示中等容量F103C8T6 属于这一档。如果选成 hd大容量或 ld小容量中断向量表会对不上程序跑不起来。PendSV_Handler 这个名字在启动文件里已经定义好了弱符号我们在自己的 .s 文件里重新定义同名符号就能覆盖它。注意不要拼错PendSV_Handler 中间没有下划线分隔就是 PendSV 加下划线加 Handler。另外如果工程里同时包含了标准库的 stm32f10x_it.c里面可能也有 PendSV_Handler 的定义需要把它注释掉或者删掉否则会报重复定义错误。7.3 从裸机调度器到完整 RTOS 的扩展方向手写调度器跑通之后如果想继续深入可以往几个方向扩展。一是加入信号量或消息队列实现任务间通信。二是加入优先级继承机制解决优先级反转问题。三是加入内存管理支持动态创建和删除任务。不过对于大多数 F103C8T6 的项目来说一个能抢占、能延时、能互斥的调度器已经够用了。我自己的几个小项目就是用这套手写调度器跑的稳定运行了两年多没出过问题。关键是要把栈溢出检测和临界区保护做好这两点做到了可靠性就有保障。最后分享一个调试小技巧在 PendSV 处理程序里加一个全局计数器每次切换时加一。然后在主循环里定期打印这个计数器的值就能知道系统的切换频率。如果切换频率异常高说明有任务在频繁阻塞和唤醒可能需要优化任务设计。如果切换频率为零说明调度器没在工作赶紧查 PendSV 有没有被正确触发。这个计数器我每个项目都会加排查问题时特别有用。
返回列表