ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零手写700行RTOS内核:STM32上的任务调度与5个关键陷阱

从零手写700行RTOS内核:STM32上的任务调度与5个关键陷阱 前段时间给自己定了个目标不用 FreeRTOS不抄 uC/OS完全从零写一个能跑在 STM32 上的 RTOS 内核。断断续续折腾了两周最后调度器、延时、信号量、队列全加起来C 代码加汇编不到 700 行跑在 STM32F103 上能稳定切换三个任务串口日志不乱、延时准确、信号量同步也没问题。这篇文章就把整个过程最值钱的 5 个坑整理出来核心代码都会贴适合那些对 RTOS 理解还停留在“调用 API”层面、想真正搞懂调度器底层原理的人也适合准备把手写内核当面试练习的工程师。我不会花太多篇幅讲“什么是任务”这种基础直接上硬货但关键的原理我会顺手解释清楚。1. 700行内核的完整设计蓝图1.1 一个“最小可用”内核需要哪些模块很多人一提到 RTOS 就想到一堆组件任务管理、内存池、软件定时器、消息队列、互斥量、事件标志组、信号量、信号、CPU 利用率统计……但如果目标只是“能在裸机上跑多任务”最核心的部分其实只有两块任务管理和上下文切换决定哪个任务该跑、怎么从一个任务切到另一个任务。tick 时基提供一个稳定的时间基准让任务可以延时、让调度器可以周期性触发。在这个基础上再加两个非常常用的同步机制就是信号量和消息队列。信号量解决“任务之间谁等谁”的问题消息队列解决“任务之间怎么传数据”的问题。有了这四样一个嵌入式项目里 90% 的多任务需求都能覆盖了剩下那 10% 是互斥量优先级继承、软件定时器、事件组这些锦上添花的东西。我给自己定的限制是内核代码量控制在 700 行左右不依赖任何第三方库只用 ARM Cortex-M3 的 CMSIS 头文件编译不加优化也能稳定跑。写的过程中刻意砍掉了所有非必要功能比如互斥量只做信号量的变体软件定时器先不加内存管理直接用静态数组。这样做的最大好处是每一行代码都能解释清楚出了问题能一眼看穿。1.2 代码结构文件划分与数据流整个工程分成三类文件内核文件、移植文件、板级文件。内核文件完全跟芯片无关理论上换一个 Cortex-M 内核的芯片只需要改移植文件。我最后的文件结构大概是这样文件作用主要代码量kernel.h内核对外 API、数据类型、TCB 结构定义约 80 行kernel.c任务创建、调度器启动、延时列表维护约 250 行sem.c信号量实现约 100 行queue.c消息队列实现约 120 行port.c port.s上下文切换汇编、SysTick/PendSV 处理约 120 行main.c测试用例、板级初始化约 100 行数据流说起来很简单应用程序通过task_create创建任务通过task_delay主动让出 CPU通过sem_wait/sem_signal做同步SysTick 每 1ms 触发一次中断更新时基并检查延时任务如果发现有更该跑的任务就置位 PendSV 异常真正的任务切换在 PendSV 处理函数里完成。所有需要改任务状态的操作都在临界区保护下进行这是整个内核代码看起来最“绕”但其实最核心的逻辑。1.3 为什么不直接抄 FreeRTOS而要自己写一遍这可能是很多人想问的问题。FreeRTOS 很成熟CMSIS-RTOS 封装也好用直接调 API 不香吗为什么非要自己造轮子我的观点是造轮子的目的不是替代轮子而是理解轮子。如果你只是想要量产级的内核直接上 FreeRTOS 绝对是对的别自己写。但如果你的目标是搞懂“任务切换到底是怎么发生的”“信号量底层是怎么等待和唤醒的”那读十遍源码不如自己写一遍。很多细节比如初始任务栈帧怎么伪造、PendSV 为什么要放在最低优先级、临界区为什么不能简单地关中断这些在官方文档里都是一笔带过只有亲手踩过坑才会刻进脑子里。另外手写一个微型内核还有一个附加价值当你在一个资源很紧的 MCU 上做极简任务调度时未必需要完整版 RTOS一个 700 行的微型内核可能比 FreeRTOS 省一半 Flash。我这次跑在 STM32F103C8T6 上内核占用的 ROM 只有 4KB 出头RAM 占用主要是任务栈这个开销在很多老项目里是可接受的。2. 任务机制与上下文切换实现细节2.1 TCB 与任务栈布局任务控制块TCB是内核里最重要的数据结构每一个任务对应一个 TCB。我的设计里 TCB 长这样typedef struct tcb { uint32_t *sp; // 任务栈指针切换到该任务时从这里恢复现场 uint8_t prio; // 优先级数字越小优先级越高 uint8_t state; // 任务状态READY / DELAYED / BLOCKED uint32_t delay_ticks; // 延时剩余的 tick 数 void (*entry)(void *arg); // 任务入口函数 void *arg; // 入口函数参数 struct tcb *next; // 链表指针用于就绪链表或延时链表 uint32_t stack_magic; // 栈溢出检测标记 uint32_t *stack_start; // 栈底地址 uint32_t stack_size; // 栈大小字节 } tcb_t;任务栈我用的是静态数组#define TASK_STACK_SIZE 256 uint32_t task1_stack[TASK_STACK_SIZE] __attribute__((aligned(8))); uint32_t task2_stack[TASK_STACK_SIZE] __attribute__((aligned(8)));这里aligned(8)很重要坑 5 会详细讲。栈是向下增长的所以初始栈指针指向数组末尾也就是高地址。任务栈里除了任务的局部变量还会存放上下文切换时保存的寄存器现场。因为 Cortex-M3 进入异常时会由硬件自动压入 8 个寄存器的异常帧再加上我们手动压入的 R4-R11一个任务的完整上下文一共是 16 个字64 字节。2.2 创建任务时怎么“伪造”现场创建任务最核心的操作是把任务第一次运行时的状态伪造出来让调度器切到它的时候能像一个“刚刚被中断的任务”一样自然恢复。这里的关键是理解 Cortex-M3 的异常帧布局。Cortex-M3 进入异常时硬件会自动把 R0、R1、R2、R3、R12、LR、PC、xPSR 这 8 个寄存器按固定顺序压栈然后从异常返回时再原样弹出。如果我们希望任务第一次运行时从 entry 函数开始就要预先在任务栈里按同样的顺序放好这些值。我创建任务时的核心代码大致是这样int task_create(tcb_t *task, void (*entry)(void *), uint8_t prio, uint32_t *stack_top, uint32_t stack_size) { uint32_t *sp stack_top; // 栈指针按 8 字节对齐AAPCS 要求 sp (uint32_t *)(((uint32_t)sp) ~0x07u); // 硬件异常帧xPSR, PC, LR, R12, R3, R2, R1, R0 *--sp 0x01000000; // xPSRbit24 必须为 1表示 Thumb 状态 *--sp (uint32_t)entry; // PC任务入口 *--sp 0; // LR *--sp 0; // R12 *--sp 0; // R3 *--sp 0; // R2 *--sp 0; // R1 *--sp 0; // R0 // 手动保存寄存器R4 - R11 for (int i 0; i 8; i) { *--sp 0; } task-sp sp; task-prio prio; task-state TASK_READY; task-delay_ticks 0; // 加入就绪链表按优先级排序 insert_ready_task(task); return 0; }第一次上下文切换时我们从task-sp里弹回 R4-R11再把 PSP 指向异常帧起始位置从异常返回时硬件自动弹出 xPSR、PC 等寄存器任务就从 entry 开始跑起来了。这个“伪造现场”的思路是手写 RTOS 最核心的魔法搞懂它后面一切都顺了。2.3 PendSV 切换流程上下文切换我用的是 PendSV 异常这是 Cortex-M 处理器专门为操作系统切换上下文准备的异常有两个特点可挂起且优先级可编程到最低。为什么不能直接在函数里切换因为任务切换必须中断掉当前任务的正常流执行切换代码再让另一个任务从自己上次断掉的地方继续。如果我们在一个普通函数里切换这个函数本身就是当前任务的一部分栈可能都被局部变量占着切出去再切回来容易乱。用 PendSV 就干净了它是异常进入异常时硬件自动保存当前任务的现场退出异常时硬件自动恢复另一个任务的现场切换变得非常规整。我的 PendSV 处理函数是汇编写的PendSV_Handler: MRS r0, PSP ; 取当前任务栈指针 LDR r1, current_tcb LDR r1, [r1] ; r1 当前任务 TCB CBZ r1, PendSV_NoSave ; 首次调度时 current_tcb 为空不保存现场 STMDB r0!, {r4-r11} ; 手动压入 R4-R11硬件已压了异常帧 STR r0, [r1] ; 保存 sp 到当前 TCB PendSV_NoSave: BL schedule_next ; 选择下一个要运行的任务 LDR r1, next_tcb LDR r1, [r1] ; r1 新任务 TCB LDR r2, current_tcb STR r1, [r2] ; current_tcb next_tcb LDR r0, [r1] ; r0 新任务 TCB-sp LDMIA r0!, {r4-r11} ; 弹出新任务的 R4-R11 MSR PSP, r0 ; PSP 指向异常帧 BX LR ; 异常返回硬件弹出异常帧任务开始运行这段汇编的核心逻辑是“保存当前现场 - 选下一个任务 - 恢复新任务现场 - 退出异常”。looks 简单但里面至少藏了三个容易出错的地方第一次切换不能保存现场、R4-R11 和异常帧的顺序、以及 PSP 必须指向正确的位置。这几个点后面坑 1、坑 2 都会展开。2.4 SysTick 和调度触发时机SysTick 就是一个周期中断源我给它的中断周期设为 1ms作为系统时基。每次进入 SysTick 中断我要做三件事更新全局 tick 计数器。遍历延时链表看有没有任务延时到期到期就移到就绪链表。检查是否需要触发任务切换如果有更高优先级的任务因延时到期进入了就绪态则置位 PendSV 的挂起位。讲一下为什么 SysTick 不能直接做切换。因为 SysTick 中断本身也是一个异常它执行的时候当前任务现场已经被硬件压栈了理论上确实可以在这里切换。但问题在于SysTick 里调用的代码如果很长会拉高中断延迟影响其他中断响应。而且实际项目中很多中断里也会触发调度比如串口接收中断唤醒一个任务、如果在 SysTick 里直接切那中断里触发的调度就不好统一处理了。所以标准做法是SysTick 里只标记“需要调度”然后置位 PendSV等所有高优先级中断处理完后由最低优先级的 PendSV 统一执行切换。这样就能保证切换时系统处于最稳定的状态不会跟其他中断的现场打架。SysTick 中断处理里还有一个细节并不是每次 tick 都必须切换只有“更高优先级任务就绪”时才需要。为了简单我的第一个版本是每个 tick 都强制切换后来发现这会导致同优先级任务之间没有意义的乒乓球式切换白白浪费 CPU。改成只有高优先级任务就绪才切换之后整个系统安静了很多。3. 5个坑每一个都比教科书值钱3.1 坑1第一个任务启动总是 HardFault这是所有人写 RTOS 内核时第一个遇到的坎系统启动后自己写的scheduler_start()函数怎么都切不到第一个任务里一进 PendSV 就 HardFault或者任务入口函数根本执行不起来。我当时的实现是在scheduler_start()里把 PSP 设为 0然后置位 PendSV让 PendSV 执行切换逻辑。结果就是PendSV 里MRS r0, PSP取到的值是 0然后STMDB r0!, {r4-r11}直接把数据写进了 0 地址立刻触发总线错误。根因是第一次调度时当前任务并不存在我们没有现场需要保存。教科书里的伪代码往往只写了保存当前任务现场这一步没有专门交代第一次切换时的特殊情况。解决方法是加一个判断如果current_tcb为空就跳过保存现场这一步直接选下一个任务并恢复它的现场。就是上面汇编代码里的CBZ r1, PendSV_NoSave这一行。但这里还有第二个坑跳过保存之后r0 里仍然是 PSP 的值而 schedule_next 选好新任务后我们需要新任务 TCB 里的 sp。所以我的写法是 schedule_next 之后重新从 next_tcb 里加载 sp不能在 PendSV 入口处就把 PSP 存出去。有些简化版本的代码会用临时变量传 sp一个不留神就会拿错栈指针。给第一次写内核的朋友一个建议第一次切换不要想着一步到位。你可以先只创建一个任务把调度器启动逻辑单独走通验证能从 main 切到任务里执行再逐步加多任务。我为了排查第一个任务启动失败的问题整个过程至少花了半天最后是靠单步仿真看 PSP 寄存器才定位到的。3.2 坑2SysTick 和 PendSV 的优先级调不对就现场全丢第二个坑来得比第一个更隐蔽。任务能启动之后我欢快地写了两个任务一个闪灯、一个打印日志结果发现任务切换经常“卡死”或者串口打出乱码甚至偶尔 HardFault。查了很久才意识到问题出在中断优先级配置上。Cortex-M3 里 PendSV 和 SysTick 的优先级是各自独立配置的我一开始图省事给 SysTick 配了最高优先级PendSV 配了最低优先级。这样做的直接后果是SysTick 可以抢占 PendSV 执行。设想一个场景PendSV 正在执行任务切换已经把当前任务的 R4-R11 压栈、还没恢复新任务现场的时候SysTick 中断来了把它打断。SysTick 认为当前任务已经切完了但其实新任务的现场还没完全恢复。紧接着 SysTick 里发现又有任务要切换又置位 PendSV 挂起位等 PendSV 恢复执行时现场信息已经对不上了轻则切换混乱重则直接 HardFault。正确配置就两条PendSV 和 SysTick 必须配置为最低优先级。用户中断的优先级必须比它们高保证中断响应不被调度器拖慢。Cortex-M3 的 NVIC 优先级是 4 位取值范围 0-150 最高15 最低。我最后在port.c里明确写成NVIC_SetPriority(PendSV_IRQn, 0x0F); NVIC_SetPriority(SysTick_IRQn, 0x0F);这里还有一个细节当 PendSV 和 SysTick 同为最低优先级且同时 pending 时异常编号小的先执行。PendSV 异常编号是 14SysTick 是 15所以 SysTick 里置位 PendSV 后SysTick 退出PendSV 会立即执行。正因为这一点“SysTick 标记调度请求PendSV 执行切换”的机制才能成立。如果你在手写内核时发现任务切换总是一段时间后崩溃优先检查这两个中断的优先级。我后来养成了一个习惯新板子到手先写一个最小工程把 SysTick 和 PendSV 的优先级日志打出来确认寄存器里确实是 0x0F 再开始调内核。3.3 坑3临界区不是关个中断那么简单这个坑是我认为所有 5 个坑里最有“教材之外”价值的。最开始我实现临界区的方式非常简单粗暴就是关全局中断__disable_irq(); // 临界区代码 __enable_irq();第一版在单任务环境里跑得没问题后来信号量加入后就出现了邪门现象某个任务等信号量明明信号量已经被释放了但任务就是一直挂着醒不过来。甚至有时候系统跑几十分钟后突然死机。问题在哪两个地方。第一个是嵌套临界区会被提前打断。我在一个临界区里调用了sem_signal而sem_signal内部又有一个临界区。外层关了中断内层退出时执行__enable_irq()直接把中断开了——这时候外层临界区还没结束如果刚好来了一个中断把当前任务抢占可能导致现场不一致。这就是经典的“临界区嵌套被错误打开”问题。第二个是关全局中断连 SysTick 也关掉了。如果在一个比较长的临界区里关中断SysTick 中断不能执行整个系统的 tick 计数就会“丢拍”。临界区结束后所有任务的时间基准都已经漂移了。对于强实时要求的系统这种漂移是不能接受的。正确做法是用 BASEPRI 寄存器做临界区保护。BASEPRI 是一个可以屏蔽指定优先级以下所有中断的寄存器它不像 PRIMASK 那样把中断全部关掉而是只关低优先级的高优先级的中断依然能响应。我的实现uint32_t task_enter_critical(void) { uint32_t basepri __get_BASEPRI(); __set_BASEPRI(CRITICAL_PRIO); // 屏蔽优先级不高于 CRITICAL_PRIO 的中断 return basepri; } void task_exit_critical(uint32_t basepri) { __set_BASEPRI(basepri); }注意退出临界区不是把 BASEPRI 设置为 0而是要恢复进入之前的原值。这样才能正确处理嵌套临界区。CRITICAL_PRIO 我取的是 0x50也就是把优先级 5-15 的中断全部屏蔽保留 0-4 的紧急中断。SysTick 和 PendSV 的优先级是 15自然也被屏蔽但临界区通常只有几十条指令丢一到两个 tick 影响很小。这个坑给我的教训是操作系统里所有“全局变量被多个上下文访问”的地方都必须考虑并发。关中断只是最粗暴的手段真正稳妥的是“屏蔽足够多的中断但保留关键中断”加“保存和恢复原状态”。3.4 坑4信号量超时导致的重复挂接这个坑是我给信号量加超时参数时踩的整个过程非常痛苦。信号量的等待本来很简单任务调sem_wait(sem, timeout)如果信号量没资源就把当前任务挂到信号量自带的等待链表中然后触发调度。等另一个任务调用sem_signal时从链表头部唤醒一个任务。看起来没问题但一旦引入超时任务就同时处于两个链表里一个是信号量的等待链表一个是全局的延时链表。时间到了SysTick 要把任务从信号量等待链表里摘出来移到就绪链表信号量被释放时sem_signal也要把同一个任务从延时链表里摘出来移到就绪链表。如果两边不同步任务就可能被唤醒两次或者两段代码同时操作同一个任务的链表节点导致链表直接成环系统随机跑飞。我当时的表现是系统跑一段时间后任务列表里随机出现一个“幽灵任务”打印出来的任务结构体全是乱值调度器一遍历链表就 HardFault。根因就是我偷懒了。我只在sem_signal里加了唤醒逻辑没有在超时分支里写“从信号量等待链表移除”的操作反过来超时处理时也没有考虑任务可能已经被信号量唤醒的情况。两边修改的是同一块内存却没有一致的互斥机制。解决办法分两步第一所有对任务状态、等待链表、延时链表的修改都要在临界区里完成保证 SysTick、sem_signal、sem_wait 三方不会同时操作同一个 TCB。第二任务状态机要清晰。我增加了blocked_on字段用来记录任务到底阻塞在什么对象上。信号量超时处理时先检查blocked_on是不是自己的信号量 ID不是就说明任务已经被唤醒了直接跳过是才执行移除操作。同理sem_signal唤醒任务时也会把delay_ticks清零保证延时链表里的检查不会二次处理它。这个坑让我明白了一个道理RTOS 里最常见的 bug 不是“逻辑不对”而是“资源归属不明确”。一个任务同时被两个机制管理时必须有一个权威的状态标记否则各种并发修改都会互相踩踏。3.5 坑5栈对齐和 xPSR 的 T 位最后一个坑比较“静默”出错的时候非常难查。现象是任务本身能跑但偶尔用 sprintf 打印浮点数或者调用一些标准库函数时会 HardFault而且是完全随机的跟系统负载有关。查了很久最后定位到两个问题。第一个是任务栈的初始地址没有按 8 字节对齐。Cortex-M3 采用的是 AAPCS 调用约定要求栈指针在任何异常入口、任何函数调用边界处都是 8 字节对齐的。如果任务创建时栈顶地址只是 4 字节对齐任务运行后一旦调用浮点运算、64 位整数运算或某些标准库函数就会因为栈未对齐触发 UsageFault。解决方案有两个一个是任务栈数组声明时用__attribute__((aligned(8)))另一个是创建任务时对栈指针做一次sp ~0x07u强制对齐。我两个都做了因为静态数组不一定能保证你手工算偏移后还是 8 对齐。第二个是 xPSR 的 T 位。Cortex-M3 只支持 Thumb 指令集处理器必须处于 Thumb 状态才能执行代码。xPSR 的 bit24 是 T 位硬件在异常返回时会从栈中恢复 xPSR如果这个位的值为 0返回后处理器就认为当前处于 ARM 状态第一条指令就会触发错误。我第一版创建任务时只把 PC 指向入口函数xPSR 填了 0结果任务启动直接异常。正确值是0x01000000bit24 置 1 表示 Thumb 状态。这个值我在写初版时根本没在意直到看反汇编发现 PC 永远跳不到入口函数才醒悟。这两个问题都属于“编译器要求”范畴教科书里很少专门讲但它们会让一个看起来完全正常的内核随机崩溃。我的总结经验是新建一个任务时初始栈帧不仅仅是“把寄存器填上”那么简单还得满足编译器和硬件底层的约定。如果任务里涉及浮点运算还要考虑 FPU 的扩展帧这些都需要在创建任务时提前规划好。4. 信号量、队列与中断同步4.1 信号量等待与唤醒信号量是这个内核里最常用的同步原语。我实现的版本是最经典的计数信号量支持两个操作sem_wait获取资源sem_signal释放资源。int sem_init(sem_t *sem, uint32_t init_count); int sem_wait(sem_t *sem, uint32_t timeout); int sem_signal(sem_t *sem); int sem_signal_from_isr(sem_t *sem);sem_wait的逻辑是先进入临界区如果计数器大于 0直接减一返回否则把当前任务状态改为 BLOCKED记录阻塞对象挂到信号量的等待链表然后退出临界区并触发调度。sem_signal的逻辑是进入临界区如果等待链表为空计数器加一否则从等待链表头取出一个任务把它恢复到就绪态然后退出临界区并触发调度。这里有一个容易被忽略的设计决策信号量释放时到底是把资源给一个正在等待的任务还是把资源存进计数器让等待任务继续等两种做法都能工作但实时性不一样。我的做法是“释放时优先唤醒等待者”这样等待的任务可以立即恢复执行不会造成额外延迟。超时版本再多一个小逻辑等待任务在进入 BLOCKED 状态前把超时 tick 数记录到 TCB 的 delay_ticks 字段SysTick 会定期检查它。超时到点后任务如果仍然在等待链表里就会被强制唤醒并返回错误码SEM_ERR_TIMEOUT。这里就是坑 4 反复提到的双重链表问题处理时要格外小心。4.2 环形队列数据缓冲消息队列我实现了一个很精简的环形缓冲区版本核心数据结构typedef struct { uint8_t *buf; uint32_t size; uint32_t head; uint32_t tail; sem_t empty; // 空位信号量 sem_t filled; // 有数据信号量 } queue_t;队列的读写本质上就是两个信号量的组合写数据前sem_wait(q-empty)写完后sem_signal(q-filled)读数据前sem_wait(q-filled)读完后sem_signal(q-empty)。有了信号量队列的阻塞和唤醒逻辑几乎不需要单独处理代码非常简洁。环形缓冲区的实现也简单head 和 tail 递增后对 size 取模就行。需要注意的是队列的 head、tail 修改必须在临界区里完成因为可能有两个写者同时写队列。虽然信号量保证了同一时刻只有一个任务能进入写操作但中断里也可能调用queue_write_from_isr此时必须用临界区保护 head、tail 的修改。4.3 ISR 中使用 FromISR 系列 API 的正确姿势手写内核最容易“觉得自己写完了”的地方就是中断服务函数里的同步。裸机开发时你可以在中断里直接置一个标志位然后主循环查询。但有了 RTOS 之后中断里更合适的做法是直接唤醒一个任务让任务去处理耗时操作这样中断服务函数可以尽快退出。为此我专门实现了sem_signal_from_isr和queue_write_from_isr两个函数。它们的内部逻辑和普通版本几乎一样唯一区别是结尾不会调用阻塞式调度而是检查是否有更高优先级的任务被唤醒如果有就置位 PendSV 挂起位等中断全部退出后再切换。这里有个重要原则中断服务函数里不要调用可能阻塞的接口比如sem_wait、queue_read因为这些函数会把当前任务挂起但中断里并没有“当前任务”的概念挂起了谁都不知道。一旦写了轻则逻辑混乱重则直接 HardFault。我给自己的内核定了一个死规矩ISR 里只允许用 FromISR 后缀的接口普通接口一票否决。5. 在 STM32F103 上的移植与实测5.1 开发环境与工程配置我用的板子是经典的 STM32F103C8T6 蓝丸板64KB Flash、20KB RAM主频 72MHz开发环境是 Keil MDK 5编译器 AC5。整颗芯片的 Flash 占用情况是内核约 4.3KB测试代码约 2KB串口和 GPIO 驱动约 1KB完全无压力。SysTick 配置比较简单直接用 CMSIS 库函数SysTick_Config(SystemCoreClock / 1000); // 1ms 中断时钟树方面我使用外部 8MHz 晶振PLL 倍频到 72MHz。SystemCoreClock变量必须正确否则 SysTick 的配置会按错误频率走导致 tick 时间完全不对。我第一次在新板子上跑的时候忘了确认SystemCoreClock的值结果 1ms 延时实际变成了 1us 左右信号量超时几乎瞬间就返回错误排查了半小时才发现是时钟配置对不上。中断优先级分组也要注意。STM32F103 默认是 4 位抢占优先级、0 位子优先级这正好符合 Cortex-M3 的默认行为。如果你用的是带子优先级的设置那 NVIC_SetPriority 的具体数值映射会变PendSV 和 SysTick 的“最低优先级”也需要按实际分组重新计算。5.2 切换耗时到底有多快上下文切换耗时是 RTOS 性能的一个重要指标。我通过一个 GPIO 翻转来实测任务切换时在 PendSV 入口把 PIN 拉高在退出前拉低用逻辑分析仪测量高电平宽度。实测结果是从进入 PendSV 到完成切换整个过程大约 3.2us72MHz 下。这包括保存 R4-R11、运行 C 语言调度函数、恢复新任务的 R4-R11、以及异常返回时的硬件出栈操作。3.2us 是什么概念如果系统有 100 个任务每个任务的切换都是 3.2us那每秒切换 10 万次场景下也只占 32% CPU。对大多数嵌入式应用来说这个切换开销完全可以接受。如果你想进一步压榨性能可以把调度函数改成纯汇编或者用链表直接取出最高优先级任务能省掉约 0.5us但对 700 行教学内核来说意义不大。5.3 多任务压力测试验证内核稳定性不能只靠一个点灯程序。我写了一个相对严格的测试场景任务 A优先级 1每 10ms 打印一次日志同时翻转 LED1。任务 B优先级 2每 20ms 打印一次日志同时翻转 LED2。任务 C优先级 3用一个全局计数器累加计数器到 10000 时打印一次“Counter C reach 10000”。中断 ISR外部按键触发在中断里调用sem_signal_from_isr唤醒一个高优先级任务处理按键事件。这个场景串口输出必须是严格有规律的任务 A 和 B 的日志交替出现且 A 的频率是 B 的两倍按键事件发生后处理任务的日志要立即打印不能被低优先级任务拖住。实测跑了两天没有死机、没有乱序、没有 HardFault。这个结果基本说明调度器的核心理路是对的。5.4 GD32F103 移植差异网上经常有人问 GD32F103 能不能直接跑 STM32 的工程。我的经验是如果你的内核只依赖 Cortex-M3 的 CMSIS 接口移植非常容易几乎只改两个地方。第一是时钟初始化。GD32F103 的主频可以跑到 108MHz但默认很多开发板的 system_gd32f10x.c 可能会配到 108MHz。如果沿用 STM32F103 的 72MHz 配置需要检查 PLL 倍频系数否则 SysTick 会和实际主频不一致。第二是 Flash 等待周期。GD32F103 在 108MHz 下需要配置 Flash 等待周期为 272MHz 下是 1配置错了会随机死机。我的内核文件完全没改只改了工程里的system_gd32f10x.c和启动文件PendSV、SysTick、端口配置这些在 GD32 上照常工作。如果你打算把 STM32 内核代码往 GD32 上搬建议先把跑马灯裸机程序验证时钟对不对再挂内核否则会把时钟问题和内核问题混在一起排查起来非常痛苦。6. 调试排查崩溃之后怎么定位6.1 常见问题速查表我把调试过程中遇到的高频问题整理成了一个表每一行的信息量都很大建议收藏现象可能原因排查思路任务无法启动进 PendSV 就 HardFault首次切换时把 PSP0 当成现场保存了检查 PendSV 汇编里是否有 current_tcbNULL 的分支任务能启动但一段时间后跑飞PendSV 或 SysTick 优先级配置错误确认两者都为最低优先级 0x0F串口日志乱码或丢失多个任务同时打印互相抢占用互斥信号量保护串口这是典型临界区问题信号量超时立即返回SystemCoreClock 配置与实际主频不符打印 main clock 与 SysTick 实际中断间隔偶尔 HardFault 且随机任务栈溢出或初始栈未 8 字节对齐检查任务栈 magic number检查 sp 是否对齐任务唤醒后调度顺序不对就绪链表按优先级排序的逻辑有 bug打印 TCB 链表内容验证插入和取出方式printf 浮点数卡死栈指针非 8 字节对齐任务栈数组加 aligned(8)创建时强制对齐6.2 栈检查和 HardFault 定位我参考 FreeRTOS 的思路在每个任务栈的底部放了 magic number#define STACK_MAGIC 0xDEADBEEF task-stack_magic STACK_MAGIC;上下文切换时顺手检查一下当前任务的栈底 magic 是否被改写。如果被改写说明这个任务栈溢出了立刻打印出当前 TCB 和栈指针能帮你快速定位是哪个任务的栈开小了。HardFault 定位有一个非常好用的技巧HardFault 进入时通过异常栈帧里的 PC 和 LR 值配合 Map 文件就能定位到崩溃的代码行。我在 HardFault_Handler 里加了保存现场的逻辑把 LR、PC、PSP、MSP 打出来再用 Keil 的View - Disassembly Window输入 PC 的地址就能看到崩在哪条指令上。这个方法在处理随机 HardFault 时几乎救命。6.3 串口日志加锁和断点调试最后分享几个调试技巧。第一串口打印必须加锁。我有个任务 A 打印一半时任务 B 也调用打印函数串口输出就拼在一起了。我的方案是把串口 printf 包了一层互斥信号量打印期间其他任务无法进入。注意释放锁之后可能触发切换所以打印函数内部临界区不要包太长只锁串口硬件寄存器不锁格式化字符串避免拖慢系统。第二调试内核变量时不要在中断里打断点。Keil 在 SysTick 里打断点如果断点停在 SysTick 内部此时 PendSV 可能还没执行整个系统的时序就已经乱了。我更推荐用逻辑分析仪看 GPIO 翻转让内核状态可视化比如切换时翻转 PIN延时结束时翻转 PIN这样不用打断点也能判断系统运行节奏。第三如果遇到诡异的随机 bug优先怀疑不是算法逻辑而是内存布局。比如两个任务栈挨得太近一个溢出会破坏另一个的 TCB。用__attribute__((aligned(8)))保证栈对齐之后可以在栈区前后放置保护区域配合 RAM 的 Memory Map定位这类越界问题会快很多。这个 700 行的小内核写完之后我自己最大的收获是终于看懂了 FreeRTOS 那几百行汇编到底在干什么。以前看源码里那些 PendSV_Handler、xPortStartScheduler 都像天书现在再看就是老朋友了。如果你也想写一个自己的 RTOS建议从最小的调度器开始别一上来就做全套先把单任务启动打通再加多任务、延时、信号量、队列每加一个模块就单独验证一次很多坑其实都不会踩到。最后再说一个我个人的小习惯每写完一个内核模块就在测试文件里加一个对应的极端用例比如任务栈设成很小、延时设成 0、信号量超时设成 1 tick。这些边界条件平时不会触发但最能暴露设计上的隐患。我的内核最终能稳定跑两天不掉链子跟这些“无聊”的边界测试有直接关系。
返回列表