ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

《从零手写 700 行 RTOS 内核跑在 STM32 上,这 5 个坑比教科书讲得透》

2026/9/6 5:17:44 拓冰建站 浏览量
《从零手写 700 行 RTOS 内核跑在 STM32 上,这 5 个坑比教科书讲得透》 ---**仓库地址https://github.com/1919884213/TinyRTOS**MIT 协议欢迎拍砖用 FreeRTOS 写了一年多项目后我一直有个说不清的虚xTaskCreate 背后到底发生了什么面试被问上下文切换保存了哪些寄存器我能背八股但让我现场画栈帧我画不出来。于是花了段时间在 STM32F407VGT6 上从零手写了一个抢占式 RTOS 内核不调任何 RTOS 库从调度器到 PendSV 汇编全部手写约 700 行 C 一份汇编。最终实测规模 **FLASH 11 KB / RAM 12.6 KB**CI 每次 push 全量编译。功能上它当然不能打 FreeRTOS 一根手指但写的过程中踩的几个坑每一个都比教科书给我讲得清楚。这篇文章不列功能清单只讲这五件事——它们也是这个内核里我认为最值得看的五段代码。---## 坑一临界区用 __disable_irq()我赢了调度输了串口最早写临界区一行搞定c__disable_irq(); /* 世界清净了 */能跑直到我把 USART 接收中断挂上任务发现只要任务一进临界区传感器数据就开始丢帧。原因很直白__disable_irq() 是核弹关掉**所有**可屏蔽中断——内核只是不想被 SysTick 打断调度却顺手把用户外设也一起灭了。Cortex-M 其实给了个更体面的开关BASEPRI。它是一条中断优先级屏蔽线只拦截优先级数值小于等于它的中断。把内核的三个系统中断SysTick / PendSV / SVC压到屏蔽线以内用户中断照常响应c/* port.h */#define KERNEL_BASEPRI 0x80ustatic inline uint32_t enter_critical(void) {uint32_t b __get_BASEPRI(); /* 保存旧值支持嵌套恢复 */__set_BASEPRI(KERNEL_BASEPRI); /* 只屏蔽内核中断 */return b;}static inline void exit_critical(uint32_t b) { __set_BASEPRI(b); }这也是 FreeRTOS 在 Cortex-M 上 taskENTER_CRITICAL 的同款思路——但自己踩一遍才知道关中断和抬屏蔽线差在哪。顺带一提这个坑有个回旋镖临界区里千万别调 HAL_Delay()。它靠 SysTick 计数忙等而 SysTick 恰好被 BASEPRI 屏蔽着——直接死等。我第一次遇到进了临界区程序就僵住查了半小时才反应过来是 HAL 库函数在偷偷依赖被我自己屏蔽掉的 tick。## 坑二切换为什么必须挂起一个最低优先级的中断初学时的疑问os_schedule() 都算出下一个任务了直接改 PSP 不就切了为什么人人都绕一圈去置位 SCB-ICSR 的 PENDSVSET等 PendSV 中断来执行切换因为**你不能在中断处理到一半的时候换栈**。如果在 SysTick 回调里直接切上下文等于把一半的 os_tick() 现场留在了新任务的栈逻辑里重入、丢现场全来了。PendSV 的玩法是我只挂起一个切换请求latched它是可编程最低优先级15CPU 会把当前所有在飞的中断全部退出干净之后才进 PendSV 做真正的压栈—换栈—弹栈。切换点从随时可能发生收敛成一定发生在所有中断之后这就安全了。整个内核最值钱的三十行就是这份 switch.s核心只有对称的两步asmPendSV_Handler:MRS R0, PSPSTMDB R0!, {R4-R11} /* 软件补压 8 字存进旧任务 sp */...换 current_tcb-sp 到新任务...LDMIA R0!, {R4-R11} /* 弹出新任务现场 */MSR PSP, R0BX LR /* 异常返回硬件自动弹另外 8 字 */R4~R11 软件压xPSR/PC/LR/R12/R3~R0 硬件在进出异常时自动压——完整的 16 字栈帧。搞清这个分工任务切换到底切了什么这个问题就再也问不倒你了。## 坑三一个假唤醒 Bug败给两行代码的先后顺序这是整个项目我印象最深的一个 bug值得单独讲。任务阻塞到信号量上的时候要做两件事①把它从**就绪链表**摘下来②把它挂到信号量的**等待队列**上。两个操作都动 tcb-next 这个指针。我当时写成这样c/* ✗ 错误顺序先把 next 覆写去串等待队列 */current_tcb-next m-wait_head;m-wait_head current_tcb;ready_remove(current_tcb); /* 再去就绪链表摘除 */看起来没毛病——毕竟任务反正两个链表都要动。但 ready_remove 靠 t-next 修补前驱节点的指针此刻 next 已经指向等待队列的头节点于是就绪链表被接进了一根**别的链表的尾巴**。后果是调度器顺着就绪链表走会走到一个 TASK_BLOCKED 的任务上——**事件明明没发生它却被唤醒并切上 CPU**跑得一脸茫然。正确顺序必须先摘后挂内核里最终留了注释当路标c/* ★ 顺序关键先摘就绪用旧 next 修补就绪链表* 再覆写 next 去串等待队列。反了会把等待队列写进就绪链表 → 假唤醒 */ready_remove(current_tcb);current_tcb-next m-wait_head;m-wait_head current_tcb;这类 bug 没有任何报错现象是偶尔任务乱跑你甚至怀疑过编译器。链表复用指针的代价算是真实体会过了。## 坑四优先级继承——把优先级借给锁的持有者写信号量时顺手把 mutex 也写了然后读了优先级反转的经典案例火星探路者号决定把优先级继承做进去。机制比听起来简单低优先级任务 L 持锁干活高优先级任务 H 来抢锁被阻塞。此时如果中优先级任务 M 抢了 CPUL 迟迟跑不完H 就被 M 间接卡住——这就是反转。解法**H 等谁就把自己的优先级临时借给谁**让 L 以 H 的优先级抢先跑完、释放锁。c/* mutex.cMUTEX_INHERIT 宏控制开关 */if (current_tcb-prio owner-cur_prio) {if (current_tcb-prio m-prio)m-prio current_tcb-prio; /* 记录本锁继承到的最高优先级 */prio_change(owner, current_tcb-prio); /* 提升持有者就绪队列位置同步迁移 */SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; /* 悬起 PendSV马上让位给它 */}坑中坑在 prio_change这个内核里**正在运行的任务也在就绪链表里**改它的 cur_prio 必须按旧优先级先摘出来 → 改值 → 按新优先级重新插入顺序错一位就绪链表和位图就全乱了。而 ready_insert() 用的也是 cur_prio 不是 prio——入队永远按当前优先级排队这一个字段的区分撑起了继承的整套逻辑。释放锁时反向恢复先把持有者优先级还原回基础值再把锁**直接交接**给队首等待者不发生二次竞争。我还把继承逻辑做成编译宏 MUTEX_INHERITCMake 里一行开关注释掉重新编译就能在演示 4 里直观对比开/关继承下反转现象的区别——这比任何 PPT 都直观。## 坑五任务不能拆自己脚下的地板task_delete(NULL) 删除自己时如果直接把 TCB 标记空闲、槽位回收——可任务此刻正站在这块栈上执行删除函数的后半段呢等于拆自己脚下的地板。解法是两阶段回收删除时只标记 TASK_DYING 然后切换走idle 任务每轮巡检把 TASK_DYING 的任务真正清零归还槽位TASK_FREE 可被后续 task_create() 复用。FreeRTOS 里 vTaskDelete 也是这个自杀要人收尸的设计写一遍才真正理解它为什么必须这样。## 顺手一提的设计- **O(1) 调度**一个 uint32_t 就绪位图 31 - __builtin_clz() 反斜引导令定位最高就绪优先级不遍历链表32 级优先级天然封顶也天然解释了为什么位图只有一个字。- **零 malloc**TCB 是静态池MAX_TASKS 5栈编译期分配无碎片、确定性。RAM 里 10 KB 是 5 × 2 KB 任务栈——演示 printf 时把栈从 1 KB 抬到 2 KB 换来的。- **idle 用 __WFI()**全阻塞时 CPU 直接休眠等中断功耗演示顺手就有了。- **5 组可切换演示**改一个 DEMO_SELECT 宏就能验证延时轮转 / 信号量 / 消息队列 / 优先级继承 / 删除重建分别对应上面每个机制。已知边界我都写在 README 里外部删除只对 READY 任务安全阻塞任务还挂在等待链上要额外摘除、task_delay() 自带临界区别重复包、任务栈固定 2 KB——刻意留白的学习边界不是 bug是 TODO。## 和 FreeRTOS 对照着看| 概念 | FreeRTOS | TinyRTOS ||---|---|---|| 调度查找 | 优先级链表 | 就绪位图 CLZ || 上下文切换 | portASM.S (PendSV) | switch.s (PendSV) || 临界区 | taskENTER_CRITICAL (BASEPRI) | KERNEL_BASEPRI 同款思路 || 内存 | heap_1~5 可选 | 纯静态零 malloc || 优先级继承 | 内嵌在队列模块 | mutex.c 独立实现 |---## 最后源码https://github.com/1919884213/TinyRTOS MITclone 下来改 DEMO_SELECT 烧板子就能跑CI 每次 push 全量编译release 产物直接下载写内核不是目的把每个概念问到为什么非这样做不可才是。上面五个坑欢迎在 issue 里继续给我挖新坑。------