2026/10/19 6:00:57

裸机MCU轻量级任务调度器:用pend/post替代RTOS

裸机MCU轻量级任务调度器:用pend/post替代RTOS 去年帮人做一个小采集设备MCU 资源紧张几十K Flash、几K RAM运行几个简单任务。我第一反应是塞一个 RTOS 进去可评估下来又放弃任务不多、交互简单RTOS 的队列、互斥量、事件组一大半用不上白白占掉宝贵资源。最后我写了一个裸机前后台调度器核心就四件事TaskSchedule 放进 SysTick_Handler 周期性调度TaskHandle 放在 main 的 while(1) 里逐个处理任务任务之间用 pend 和 post 传信号再用 TaskTest 做验证。这套东西在无 OS 场景下非常好用而且代码量小、思路清晰。今天把它整理成一篇能照着写、照着跑的完整记录顺便把我在实际项目中踩过的坑也一起讲清楚。1. 为什么裸机也需要轻量级任务调度1.1 前后台模型的真正痛点很多人觉得裸机就是“主循环里轮询 定时中断里翻标志”这个说法没毛病但只适合任务特别简单的场景。一旦模块多起来主循环会变成一团浆糊键盘要扫描、屏幕要刷新、串口要解析、传感器要采集每个模块都“稍微等一会儿”或者“隔一段时间处理一次”代码很快就没法看了。最典型的痛点就是阻塞。一个模块在 while 里等串口数据其他所有模块都得跟着停。哪怕你聪明一点把等待改成非阻塞查询也只是把“阻塞”从代码里挪到了逻辑里主循环依然要不停判断每个模块“现在有没有事”。这种时候我需要的不是完整 RTOS而是一个轻量级“任务调度”的骨架让每个功能模块变成一个任务任务之间不互相死等谁有事件谁就运行谁没事件谁就让出 CPU。用术语说这就是典型的前后台协同——中断负责“准备好事情”主循环负责“把准备好的事情做掉”。1.2 用轮询事件等待替代RTOS的必要取舍RTOS 确实强大但对很多小项目是“杀鸡用牛刀”。它带来的任务切换上下文保存、调度器复杂逻辑、各种内核对象在某些 8 位机、16 位机上根本跑不动即使跑得动也占了几K RAM。而我的需求其实非常简单有几个任务函数希望它们能“按条件执行”任务之间能互相通知一下仅此而已。于是我做了三个明确取舍不用抢占式调度用协作式。任务函数必须自己主动让出 CPU不能抱着 CPU 不放。不做复杂优先级最多按注册顺序轮流扫描。有事件就执行没事件就跳过。用最简单的“事件计数 状态标记”实现 pend 和 post不搞内核对象。说白了全局变量加一个调度循环十几年前的单片机也能跑。这个取舍换来的是极低的资源占用和极高的可控性。你完全知道每个任务在什么时候被调用不像用 RTOS 那样需要担心优先级反转、临界区嵌套这些麻烦。2. 任务表、状态机与事件信号量的设计2.1 极简任务控制块一个结构体就够了做调度的第一步是定义“任务”到底是什么。我不打算用链表因为那对单片机不友好还容易内存碎片。我直接用静态数组每个任务对应一个任务控制块。结构体只需要四个字段任务函数指针、参数、运行状态、事件计数器。如果有延时需求再加一个延时计数。#define MAX_TASKS 8 #define TASK_WAIT 0 #define TASK_READY 1 typedef struct { void (*func)(void *arg); // 任务函数 void *arg; // 传给任务函数的参数 uint8_t state; // 状态等待/就绪 uint8_t sem; // 事件计数post加1pend消费 uint16_t delay; // 延时节拍计数可选 } Task_t;你可能会问为什么用数组而不是链表数组的优点是分配固定没有动态内存查询速度快代码里一个 for 循环就能扫完。缺点是任务数量有个上线但实际项目里 8 个任务足够用不够就调 MAX_TASKS。这种设计非常适合裸机每个任务占据静态内存所有状态一目了然。2.2 任务状态迁移与事件计数模型我的调度器里任务只有两种状态等待TASK_WAIT和就绪TASK_READY。没有“阻塞”“挂起”“睡眠”这么多花哨说法因为裸机场景下不需要。任务注册后默认进入等待状态等待某人给它发事件。调用 TaskPost 后事件计数加一任务变为就绪状态。主循环扫描到就绪任务调用其函数。任务函数内部调用 TaskPend消费一个事件计数如果没有事件就自动回到等待状态。整个模型非常像“信箱”post 是往信箱里投一封信pend 是从信箱里取信任务函数就是“收到信之后要办的事”。如果信箱里没有信任务就继续睡觉不占用 CPU。状态迁移可以用一张表说明操作原状态事件计数新状态说明TaskPostTASK_WAIT0 - 1TASK_READY有事件了任务可被调度TaskPendTASK_READY1 - 0TASK_WAIT消费事件若没有剩余则等待TaskPendTASK_READY0TASK_WAIT没事件继续等待TaskDelayTASK_READY不影响TASK_WAIT进入延时等待延时到期TASK_WAIT不影响TASK_READY可被调度这个模型简单到不需要画流程图用一个 uint8_t 状态变量和一个 uint8_t 计数变量就能完成。我故意没有用位图或者链表就是为了让新手也能一眼看明白。2.3 为什么“中断里只投递、主循环里只消费”是铁律这里的核心设计原则是中断服务函数只负责“把事情标记好”绝对不真正执行任务逻辑。因为中断上下文里做复杂事情一是容易打乱时间预算二是很容易与主循环共享数据产生不可预估的竞态。举个例子你在串口接收中断里解析一帧协议解析函数还没跑完另一个更高优先级中断又插进来那代码就乱套了。所以我的划分非常清楚TaskSchedule 放在 SysTick_Handler 里它只做“节拍维护、状态刷新”比如把延时到期的任务置为就绪把有事件的任务标记为就绪。TaskHandle 放在 main 的 while(1) 里它才是真正的“老板”扫描任务表找到就绪任务并调用其函数。任务内部需要通信的时候通过 TaskPost 和 TaskPend 操作事件计数不直接操作别的任务内部数据。这么做的最大好处是主循环可以被打断但任务函数始终只在主循环里执行中断里只是改几个变量。只要这几个变量的修改是原子的或者做了临界区保护整个调度就是安全的。3. TaskSchedule 为什么必须在 SysTick 里跑3.1 中断里只做“状态准备”不做“任务执行”SysTick 是 Cortex-M 内核自带的 24 位递减计数器最适合做系统心跳。我把 TaskSchedule 放在 SysTick_Handler 中等于给整个系统提供了一个固定节拍。比如系统主频 72MHz配置 SysTick 1ms 中断一次那么 TaskSchedule 每秒会被调用 1000 次。TaskSchedule 的职责有两条扫描所有任务如果有事件就绪的把任务状态置为 TASK_READY。对开启了延时的任务每个节拍把 delay 减一减到 0 时把任务置为 TASK_READY。它不做任何“处理数据”的动作只做“状态切换”。这就像闹钟到了时间开始响铃但是起床穿衣服是后面再做的事。我把这个思想写进代码里void TaskSchedule(void) { uint32_t pm EnterCritical(); for (uint8_t i 0; i task_count; i) { // 延时任务每 tick 减一减到 0 就绪 if (tasks[i].state TASK_WAIT tasks[i].delay 0) { tasks[i].delay--; if (tasks[i].delay 0) { tasks[i].state TASK_READY; } } // 有事件的任务无论之前状态如何直接置为就绪 if (tasks[i].sem 0) { tasks[i].state TASK_READY; } } ExitCritical(pm); }这个函数非常短在中断里跑完不会占用太多时间。很多人会习惯性地中断里关中断我一开始也觉得没必要后来发现如果主循环正在操作任务表比如 TaskCreate而中断里同时扫描任务表数组会被改乱。所以我在 TaskSchedule 里也加了临界区确保扫描过程中任务表不会被主循环修改。3.2 主循环 TaskHandle 的消费逻辑TaskHandle 放在 main 的 while(1) 里它做的事情很简单扫描任务表找到第一个 TASK_READY 的任务调用它的函数然后继续扫描下一个。等所有就绪任务都执行完如果没有任何任务处于就绪状态就让 CPU 进入低功耗模式。void TaskHandle(void) { while (1) { uint32_t pm EnterCritical(); uint8_t has_ready 0; for (uint8_t i 0; i task_count; i) { if (tasks[i].state TASK_READY) { has_ready 1; tasks[i].state TASK_WAIT; // 先清除就绪状态防止重复调度 void (*func)(void *) tasks[i].func; void *arg tasks[i].arg; ExitCritical(pm); // 锁解除后再运行任务函数 func(arg); pm EnterCritical(); } } ExitCritical(pm); // 没有任务需要运行进入低功耗等待中断唤醒 if (!has_ready) { __WFI(); } } }这里有一个关键点调用任务函数之前必须先释放临界区。否则任务函数里如果调用 TaskPend 或 TaskPost会发现关着中断无法执行直接死锁。我一开始没有注意到这个问题任务函数里调用 TaskPend 时总是卡死排查了好半天才发现临界区嵌套问题。所以现在养成习惯临界区只保护“任务表状态读取和清标志”不保护“任务函数本体”。3.3 临界区与原子性别让中断和主循环打架在裸机上谈任务调度最绕不开的就是共享变量保护。任务表本身就是一个全局数组SysTick 中断和主循环都在访问它如果没有保护可能出现这种情况主循环读到任务的 state 是 TASK_READY正要清标志SysTick 中断插进来把另一个任务的 state 也改成了 TASK_READY主循环恢复后继续跑却发现状态已经不是你读到的那个了。我的解决办法很简单所有修改任务表的操作包括 TaskCreate、TaskPost、TaskPend、TaskSchedule都要用 EnterCritical 和 ExitCritical 包起来。这两个函数在 Cortex-M 上一般就是关中断和恢复中断static uint32_t EnterCritical(void) { uint32_t primask __get_PRIMASK(); __disable_irq(); return primask; } static void ExitCritical(uint32_t primask) { __set_PRIMASK(primask); }有人会觉得这种保护太重但实测下来这些操作都很短关中断的时间只有几微秒对系统影响可以忽略。真正重要的是不要在关闭中断的情况下调用复杂函数、打印日志、或者做耗时的数据处理。4. pend/post机制解析事件信号怎么从ISR交到任务手里4.1 post是“发布/置位”pend是“等待/消费”项目标题里专门提到了 pend 和 post这是整个调度的通信基础。我用两个函数实现TaskPost(id)向某个任务发一个事件。即使调用时任务还没准备好事件也会被记录在 sem 里等任务被调度时消费。TaskPend(id)任务自己调用尝试消费一个事件。如果 sem 大于 0消费成功立即返回 0如果 sem 等于 0任务进入等待状态返回 -1。int TaskPost(uint8_t id) { if (id task_count) return -1; uint32_t pm EnterCritical(); if (tasks[id].sem 255) { tasks[id].sem; } tasks[id].state TASK_READY; ExitCritical(pm); return 0; } int TaskPend(uint8_t id) { if (id task_count) return -1; uint32_t pm EnterCritical(); if (tasks[id].sem 0) { tasks[id].sem--; tasks[id].state (tasks[id].sem 0) ? TASK_READY : TASK_WAIT; ExitCritical(pm); return 0; } else { tasks[id].state TASK_WAIT; ExitCritical(pm); return -1; } }这两个接口就是“事件信号量”的最小实现。为什么叫信号量因为 sem 可以累加。如果你连续 post 了三次pend 就要连续消费三次这适合“事件发生了三次就要处理三次”的场景。如果只是“事件设置标志”可以用二进制信号量当你只想要一个任务被唤醒一次时post 多次也只算一次。但我这里选择了计数信号量因为功能更通用。4.2 中断上下文调用 post 的安全写法和注意事项TaskPost 最迷人的一点是它可以安全地在中断里调用。比如串口接收到一帧完整数据后在接收中断里调用 TaskPost(串口处理任务ID)让主循环里的串口任务去处理数据。这样中断只负责“通知”不负责“处理”把处理逻辑放回主循环避免在中断里做耗时解析。但在中断里调用 TaskPost 有一个必须注意的点临界区保护不能少。因为如果主循环正在 TaskPend 里修改任务表中断里又调用 TaskPost 修改同一个 sem结果就会错乱。所以 TaskPost 内部一定要有 EnterCritical/ExitCritical。好在 Cortex-M 的关开中断指令非常快而且 TaskPost 函数体很短这一点中断延迟可以接受。还有一点你不能在中断里调用 TaskPend。因为 pend 是任务主动“等待”的动作中断服务函数不是一个任务它没有“让出 CPU”的概念。中断里只应该产生事件不应该消费事件。如果你在中断里调用了 TaskPend你的整个调度模型就崩了。4.3 为什么 pend 不能阻塞协作式调度的纪律看到这里你可能会问TaskPend 里 sem 等于 0 时直接返回 -1这不就是非阻塞吗那如果任务想要“等待某个事件直到它发生”该怎么办答案很简单你不需要阻塞因为调度器已经替你想好了。当任务处于 TASK_WAIT 状态时TaskHandle 根本不会执行它。只有外部通过 TaskPost 发来事件或者 SysTick 的 TaskSchedule 把它的延时减到 0任务才会变成 TASK_READY主循环才会去调用它。所以任务函数的标准写法是“事件驱动式”的void TaskTest(void *arg) { if (TaskPend(TASK_TEST_ID) 0) { // 说明收到了事件做真正想做的事 HandleTestEvent(); } // 否则什么都不做函数立即返回把 CPU 让给别人 }这种写法很多新手不习惯觉得任务函数怎么像个普通函数一样跑来跑去的但这就是协作式调度的纪律任务函数绝对不能在里面写 while(1) 等外部条件也不能用 delay 循环死等。你要把“等待”交给调度器用 TaskPend 或者 TaskDelay 来表达这样多个任务才能在同一个主循环里轮流正常运行。5. 一个可跑通的调度DemoTaskTest实战5.1 硬件初始化与任务注册先说测试平台。我用的是一个常见的 Cortex-M0 内核 MCU板载 LEDSysTick 配成 1ms 中断。整个工程只需要三个源文件main.c、task.c、task.h。main 函数开头要做的事情非常清楚初始化系统时钟、配置 GPIO、配置 SysTick、注册测试任务、进入 TaskHandle。在注册任务时我会把任务 ID 存到一个全局变量里方便后面 post。#define TASK_LED_ID 0 #define TASK_KEY_ID 1 int main(void) { SystemClock_Config(); GPIO_Init(); SysTick_Config(SystemCoreClock / 1000); // 1ms 中断 TaskCreate(TaskTest, NULL); // 注册测试任务 TaskCreate(TaskKeyScan, NULL); // 注册另一个模拟按键任务 TaskHandle(); // 永不走出的调度循环 return 0; }注意TaskCreate 返回的是任务 ID。如果你有多个任务后续 TaskPost 时需要使用这个 ID。最好在创建后立刻用一个全局变量保存不要在代码里硬编码不然任务注册顺序一变ID 就对不上了。5.2 TaskTest 和另一个任务协同工作现在设计 TaskTest 的完整逻辑。我希望它像一个小实验一个“按键任务”每隔一段时间用 post 发事件TaskTest 收到事件后翻转 LED。这样就能直观看到调度器是否正常工作。uint8_t g_ledTaskId; uint8_t g_keyTaskId; void TaskTest(void *arg) { // 收到来自按键周期任务的事件翻转 LED if (TaskPend(g_ledTaskId) 0) { GPIO_Toggle(LED_PIN); } } void TaskKeyScan(void *arg) { // 模拟周期按键扫描每 50ms 发一次事件给 TaskTest TaskDelay(g_keyTaskId, 50); TaskPost(g_ledTaskId); }这里有个很有意思的地方TaskKeyScan 并不真的去读引脚我只是要模拟一张“周期产生事件”的任务。它通过 TaskDelay 把自己挂起 50ms延时到期后 SysTick 里的 TaskSchedule 把它变成就绪主循环执行它时马上 post 一个事件给 LED 任务。这样两个任务就完成了“周期通知”的闭环。TaskTest 则是标准的“收到事件才干活”它被 TaskPost 唤醒后调用 TaskPend 消费事件翻转 LED然后函数返回继续睡觉。整个过程没有阻塞没有忙等。5.3 验证方法怎么知道调度器真的在跑写代码的时候最怕的是看起来挺对跑起来却没动静。我总结了三层验证方法从简单到复杂看 LED 是否周期性翻转。如果频率对说明 TaskKeyScan 的延时调度和 TaskPost 唤醒都正常。在调度器里加计数器。比如在 TaskHandle 中定义一个全局变量 g_scheduleCount每次调度加一通过调试器观察它是否以稳定速率递增。是故意制造异常。比如把 TaskTest 里的 TaskPend 注释掉任务会变成“一注册就永久等待”你会发现 LED 永远不闪然后就知道是事件链路断了。我实测下来1ms 的 SysTick50ms 的任务延时系统跑得非常稳。GPIO 翻转的波形在示波器上看很整齐没有毛刺也没有漏事件。6. 常见坑、排查技巧与后续扩展方向6.1 我踩过的几个典型问题这套代码看起来短但实际调试时也踩过不少坑最典型的三个任务函数里忘了调用 TaskPend结果任务被调度一次后就永远“睡了”。因为 TaskHandle 在调用任务前把 state 清成 TASK_WAIT任务自己不清不明事件一到任务变就绪函数执行完再没有 pend于是不会再被调度。TaskHandle 里忘了先关中断再清 state导致 SysTick 中断在清 state 和取函数指针之间插入任务表状态被改乱。后来我强制所有任务表操作都要跨临界区问题才消失。SysTick 中断里调用了一个带阻塞的代码比如串口打印导致中断过长主循环任务卡顿。这个其实不是调度器的锅而是中断设计的问题。后来我把所有耗时打印移到任务里中断里只做状态更新。6.2 快速定位调度异常的方法遇到调度异常我有一套排查顺序先确认 SysTick 中断有没有正常触发。在中断里加一个空变量翻转用调试器看变量是否变化。再确认任务表有没有正确注册。打印 task_count 或者调试看数组内容。接着确认事件通不通。在一个任务里手动调用 TaskPost 另一个任务看能否唤醒。最后才怀疑临界区问题。临界区问题一般表现为“程序跑飞”或“随机卡死”很难抓最好从一开始就做好封装。如果需要更细致的调试我在任务结构体里加了一个“最近运行次数”字段每次 TaskHandle 调度到该任务就加一通过观察计数能立刻知道哪个任务在跑、哪个任务一直没跑。6.3 想支持软定时器/优先级/延时休眠怎么加这个基础调度器是可扩展的我就用过几个方向软定时器在任务结构体里增加一个“计时周期”由 TaskSchedule 每个 tick 自动递减而不是依赖任务自己调 TaskDelay。这样就能提供纯软件定时器能力。优先级把任务表改成按优先级排序TaskHandle 扫描时从高优先级开始每次只执行一个就绪的最高优先级任务。但要注意协作式调度下优先级只决定“哪个任务先被检查”不能决定“谁抢占谁”。低功耗休眠TaskHandle 里已经用了 __WFI()。你可以根据闲时判定进入更深层次的 STOP 或者 SLEEP 模式但需要把唤醒源中断配好。只要任务控制块设计得有弹性给一个字段加几行逻辑就能扩展不需要推倒重来。这套前后台调度器我已经在两个小项目里实际用过一次是温湿度采集器一次是简易键盘矩阵控制板。都没有用 RTOS但代码很清晰任务之间用 post/pend 通信几乎没有遇到难缠的 bug。我个人体会是裸机项目里调度器不求大而全抓住“事件驱动”和“不让任务阻塞”这两个核心就已经解决绝大多数问题了。如果你手头也有一个小资源单片机任务比较多的活儿不妨先试试这个轻量方案。