
做单片机开发的尤其裸机写多了的朋友肯定有体会需求稍微复杂一点main()函数里的while(1)就开始变得臃肿。今天要处理按键明天要扫数码管后天还要跑一个控制算法所有事情都堆在一个大循环里互相抢时间片。我自己的做法是从一个很小的项目开始把 QuarkTS 这套基于状态机的事件驱动协作式调度器搬进了一颗 Cortex-M3 内核的单片机。这篇文章就把整个移植过程、运行模型、踩过的坑完整写出来权当给准备入手的你一份实操地图。QuarkTS 是一个纯 C 实现的开源实时任务调度库它做的事情非常克制把任务看成状态机用事件驱动的方式按优先级和轮转策略协同调度不抢占、不依赖复杂 RTOS 内核。正因为如此它对 Flash 和 RAM 的消耗都很小移植成本也低很适合 RAM 只有几十 KB 的单片机。如果你正在裸机和轻量级 RTOS 之间纠结或者想给老项目加一个简单但可靠的任务框架这篇文章会比较适合你。我假设你手上有块 ARM Cortex-M 内核的开发板懂最基础的 GPIO、定时器中断和串口。下面我会从运行模型讲到工程落地再列出我实际调试时遇到的典型问题。这里不涉及很深的汇编知识耐心点一个周末基本能跑通。1. 为什么要把协作式调度器搬进单片机1.1 裸机大循环的三个硬伤写裸机程序的经典结构是这样的初始化外设然后在while(1)里反复轮询各种标志位。刚开始很爽简单直接但在任务变多之后问题会逐渐暴露出来。第一个硬伤是时间片被抢。假设你有三个功能按键扫描、LED 渐变、串口数据处理。其中一个功能偶尔会比较耗时比如串口要解析一帧变长的 Modbus 报文那你就会发现按键响应突然变慢了LED 渐变也不流畅了。所有函数的执行时间都被偷偷加长而你又很难通过示波器看到底是谁拖慢了谁。第二个硬伤是状态散落在全局变量里。按键消抖要记时间戳串口收数据要记当前帧长度菜单界面要记当前页码这些状态全都堆成全局变量。代码一多你自己都分不清某个变量是哪个模块在改。我见过不少裸机项目出 bug 之后排查半天最后发现是多个函数在同时修改一个标志位。第三个硬伤是需求一变就要大改。今天产品经理说要在显示界面加一个动画效果你可能就得把主循环里的延时逻辑全部调整一遍。每次改动都牵一发动全身测试又要重来一轮。原因很简单裸机程序里时间和执行顺序是耦合在一起的你没法把某个功能单独拆出来重排。1.2 QuarkTS 的定位介于裸机和抢占式 RTOS 之间QuarkTS 做的事情本质上就是把这堆全局状态和时间调度收编到一个统一的框架里。它让每个功能模块变成一个任务每个任务内部再拆成若干状态内核按照规则轮流给任务执行机会。关键点在于它是协作式的也就是每个任务自己运行完一小段逻辑后主动让出 CPU内核才把执行权交给下一个任务。这和 FreeRTOS 那种抢占式调度有本质区别。抢占式调度里一个高优先级任务随时可以打断正在运行的低优先级任务协作式调度里任务之间是商量着来的。我用一个生活化的类比。抢占式 RTOS 像一个有多个收银员的超市哪个柜台前排队的人优先级高店长就随时调人去支援协作式调度则像一个只有一个厨师的厨房每道菜按顺序做厨师自己决定切完这拨菜再处理下一道菜。这个设计带来一个明显好处不需要复杂的中断切换逻辑也不需要给每个任务分配很大的私有栈RAM 占用非常低。对一块只有 16KB SRAM 的单片机来说这几乎是唯一能上操作系统的路径。下面这个表可以帮你看清楚差别对比维度裸机大循环QuarkTS 协作式调度FreeRTOS 抢占式调度任务切换方式手动轮询任务主动让出中断强制切换资源占用最低很低较高学习曲线无门槛需要理解状态机需要理解内核机制实时性不可控可预测但依赖任务自觉强实时适合芯片任意小内存 MCU 友好一般要求内存稍充裕所以 QuarkTS 并不是要替代 FreeRTOS它解决的场景很明确裸机代码已经失控但又不想为了一个小项目引入完整 RTOS 的情况。1.3 为什么偏偏是状态机QuarkTS 的任务接口直接面向状态机设计这不是拍脑袋决定的。协作式调度最大的敌人是任务里出现长阻塞。一个任务如果在延时 1 秒的过程中什么都不做整个系统等于停摆 1 秒。但如果把任务拆成状态机一个普通延时操作就可以变成记录当前时间然后主动让出 CPU等内核到时间了再回来执行下一段代码。从工程角度讲状态机还让每个任务的行为变得非常可控。你不再需要用sleep去硬等一个事件而是可以明确表达我在等按键事件在等串口帧事件在等定时器到时事件。写出来的代码几乎就是一张活的状态迁移表出问题的概率比一堆if-else小得多。2. 移植前先搞懂 QuarkTS 的运行模型2.1 唯一需要的硬件资源系统 tick嵌入式操作系统几乎都有一个心跳专业说法叫 tick。QuarkTS 对硬件的要求极低一个周期稳定的定时器中断就够了一般是 1ms 或者 10ms。Cortex-M 内核自带的 SysTick 就是专门干这个的不需要占用任何通用定时器。初始化 tick 的时候一个重要参数是中断频率。频率越高时间分辨率越精细但中断开销也越大。我做功耗敏感的项目时习惯用 10ms 的 tick做需要较精细延时的项目用 1ms 也完全没问题。QuarkTS 里的软定时器、任务延时精度都取决于这个 tick 周期。下面是一个基于寄存器操作的 SysTick 初始化示例我用 CMSIS 标准库来写这样在不同厂商的 Cortex-M 芯片上都能通用#include core_cm3.h // 或 core_cm4.h / core_cm0.h取决于内核 void SystemTick_Init(uint32_t freq_hz) { SysTick_Config(SystemCoreClock / freq_hz); NVIC_SetPriority(SysTick_IRQn, 3); // 中断优先级设低一点别跟硬实时外设抢 }然后在 SysTick 中断服务函数里调用 QuarkTS 的时间更新接口。具体函数名跟你下载的 QuarkTS 版本有关我这边用的版本是void SysTick_Handler(void) { QTS_SysTick_Update(); // 通知内核一个 tick 过去了 }注意这个中断处理函数里的代码要尽量短不要在中断里做任何业务处理这是铁律。2.2 任务是状态机调度器是裁判QuarkTS 里每个任务在创建后都有自己的状态寄存器和一个事件队列。任务函数每次被调度时本质上就是被问一句当前这个事件你在哪个状态准备怎么处理 任务函数内部用switch (me-State)判断当前状态处理完逻辑之后要么把me-State改成下一个状态要么返回等待下一个事件。这个模型的优势在于任务函数每次执行的时间都非常短。它只在做处理当前状态这件事而不是在跑一个完整的大循环。内核可以频繁给每个任务分配执行机会从外部看所有任务就像在同时运行一样。事件驱动是什么意思任务不是被周期性强制运行的而是有事件来了才被唤醒。当然你也可以给任务设置周期性的时间事件实现类似裸机里轮询的效果。这样 CPU 在任务没有事件时可以直接休眠把功耗降下来。2.3 内核配置项拆解QuarkTS 的初始化需要一个配置结构体我理解它本质上就是告诉内核我给你准备了多少资源你按这个上限去运行。 最常见的几个配置项大致如下字段名会随版本变化但思路不会变配置项作用我的建议值任务池大小最多允许创建多少个任务按需求数量再加 2 个余量事件队列深度每个任务最多缓存几个待处理事件48 就够用优先级级数支持多少个不同的优先级8 级足够别贪多时钟周期字段记录当前 tick 计数值初始化时清零即可配置原则很简单够用就行别把任务池开得太大。任务池意味着内存占用开得越大浪费越多。我在一块 64KB RAM 的板子上开了 8 个任务池一个事件队列深度为 4整体内核 RAM 占用大概在 3KB 左右。2.4 优先级和调度时机QuarkTS 的调度策略不是单一的全抢占而是基于优先级和轮转的组合。高优先级的任务在事件到达时可以优先获得执行但同一优先级内的任务之间是轮流执行的。这种设计比裸机的大循环灵活又比全抢占式内核的调度开销低。这里有个容易误解的地方在协作式内核里高优先级任务也不是立即打断低优先级任务的它要等低优先级任务执行完当前这一段代码、主动让出 CPU 后才能被调度。所以协作式内核的实时性和任务代码长度直接相关你的任务处理函数里绝对不能出现长阻塞。3. 一步步把 QuarkTS 跑起来3.1 源码下载与工程目录组织QuarkTS 的源码可以从官方开源仓库直接下载。解压后你会看到 include 和 source 两个目录里面有quarkts.h和对应的.c文件。它没有复杂的第三方依赖也不需要移植层文件这是很多人第一次接触时会惊讶的地方。我的目录组织习惯是这样的project/ ├── app/ │ ├── main.c │ ├── bsp.c │ └── tasks.c ├── third_party/ │ ├── quarkts/ │ │ ├── include/ │ │ └── source/ │ └── ...其他第三方库 ├──芯片厂商SDK/ └── Makefile 或 工程文件把 QuarkTS 放在独立的第三方库目录里不要跟业务代码混在一起方便以后升级版本。在编译工程时把quarkts.c加进源文件列表把头文件路径指到include目录即可。3.2 创建基础工程并初始化 tick我在某款 Cortex-M3 内核的板子上做移植时工程是手工搭的没用自动生成工具。初始化顺序很重要先配置时钟再初始化 GPIO 和串口最后配置 SysTick。串口初始化完成后建议立刻输出一行启动日志这样后面定位问题会方便很多。别小看这一行日志QuarkTS 跑起来之后任务调度是否正常、系统是否活着很多时候靠它判断。代码框架大概是int main(void) { SystemClock_Config(); BSP_GPIO_Init(); BSP_UART_Init(); SystemTick_Init(1000); // 1kHz tick QuarkTS_App_Init(); // 初始化内核和任务 QuarkTS_App_Start(); // 启动调度永不返回 return 0; }3.3 初始化内核和用户数据QuarkTS 的启动流程分两步初始化系统配置、启动调度器。我写的自定义封装函数如下#include quarkts.h QTSSystemConfig_t sys_cfg; QTS_User_t app_user; void QuarkTS_App_Init(void) { QTS_System_Init(sys_cfg, app_user); // 根据实际情况调整任务池/队列深度等字段 // sys_cfg.TaskPoolSize 8; // sys_cfg.EventQueueDepth 4; }这里注意一个细节配置结构体初始化之后最好趁着还没启动调度器立刻创建任务。因为调度器一旦启动就不再适合动态修改这些静态配置了。3.4 定义第一个状态机任务下面是一个 LED 闪烁任务的标准写法。任务函数签名和事件指针是 QuarkTS 规定的你需要按照这个格式来写void Task_LED(QTS_Task_t *me, QTS_Event_t *event) { switch (me-State) { case 0: BSP_LED_On(); QTS_Time_Delay(me, 500); // 让出 CPU 500ms me-State 1; break; case 1: BSP_LED_Off(); QTS_Time_Delay(me, 500); me-State 0; break; default: me-State 0; break; } }这种写法最核心的地方是QTS_Time_Delay不是裸机里的死等到点而是登记一个 500ms 后的时间事件然后立刻把 CPU 让出去。任务在延时期间不占用任何执行时间。创建任务的代码放在调度器启动之前void QuarkTS_App_Init(void) { QTS_System_Init(sys_cfg, app_user); QTS_Task_Create(sys_cfg, task_led, LED, Task_LED, 1, 0); QTS_Task_Create(sys_cfg, task_uart, UART, Task_UART, 2, 0); }我习惯给任务起名字例如LED、UART。调试时可以在任务里输出自己的名字配合串口日志观察调度顺序比看变量省事多了。3.5 启动调度器启动调度器就一行代码但有几个注意事项void QuarkTS_App_Start(void) { QTS_System_Start(app_user); }启动之后main()就不会返回了CPU 控制权完全交给内核。在启动调度器之前千万不要调用任何和延时、事件相关的 API因为这些 API 依赖内核运行上下文。你可以把它理解为裁判还没进场球员不能自己开始比赛。3.6 用逻辑分析仪验证调度是否生效移植完成后第一件事不是写复杂业务而是验证调度器真的在工作。我习惯在任务里翻转一个 GPIO用逻辑分析仪看波形。比如两个任务分别每 100ms 和 250ms 翻转一次 GPIO波形应该呈现稳定的周期。如果波形乱跳或者某个任务消失了说明配置有问题。这种验证方法虽然土但非常直接比看调试器的任务列表靠谱。4. 事件机制与任务间通信如何写出像样的业务逻辑4.1 事件标志让任务只在有意义的时候醒来QuarkTS 的任务可以接收事件。事件可以是时间事件也可以是别的任务发来的标志事件。这个机制非常像中断里的标志位但比裸机里的全局标志位规范得多。我用一个按钮点亮 LED 的例子来说明。裸机写法是轮询 GPIOQuarkTS 的写法是专门开一个任务扫描按键消抖之后发出一个按键按下事件LED 任务平时处于等待事件状态收到事件就翻转 LED没事件时干脆不占 CPU。事件标志的意义在于任务不会再被无谓地周期性唤醒。这让 CPU 有更多时间休眠在做低功耗设备时这个差异非常明显。4.2 状态机任务的标准写法多个事件同时到达时任务需要按事件类型分支处理。常见写法是先判断事件类型再根据状态决定具体动作void Task_Menu(QTS_Task_t *me, QTS_Event_t *event) { if (event-EventID EV_BTN_PRESS) { switch (me-State) { case MENU_MAIN: me-State MENU_SETTING; break; case MENU_SETTING: me-State MENU_MAIN; break; } } }这里有个实操心得状态编号尽量用枚举定义不要用裸数字。如果状态多了裸数字会让你在阅读代码时崩溃。每个状态在前一个状态退出时最好执行一段统一的状态进入动作逻辑比如刷新界面、清标志位这样状态迁移会很有条理。4.3 消息队列把数据从一个任务搬到另一个任务串口接收、传感器数据采集这类任务很容易产生一堆数据要交给另一个任务的需求。QuarkTS 提供了消息队列能力允许你把数据块作为消息发给目标任务。实际使用中我的习惯是中断里只负责收数据、判断帧头帧尾把完整的数据帧放到队列里而真正解析数据的动作放在任务上下文中。这样中断函数非常短不会阻塞其他外设的中断响应也能避免在中断里做复杂逻辑导致溢出。队列深度要按最坏情况来评估不能只算平均速率。如果一段时间内有大量数据涌入队列满了我通常的策略是直接丢弃旧数据或者新数据并记录一个丢包计数这样调试时能知道协议栈是不是出了问题。4.4 协作式下的临界区其实很简单协作式调度最让我省心的一点是任务之间根本没有互相抢占所以不需要复杂的互斥锁。两个任务不会同时运行天然避免了大部分资源竞争问题。唯一需要小心的还是中断。如果一个变量既会被中断修改又会被任务读取我建议在读写时临时关中断或者用volatile修饰。裸机里怎么做QuarkTS 里基本照旧。千万不要把 FreeRTOS 里那一套互斥锁思维硬套过来。4.5 周期任务和软件定时器QuarkTS 支持创建周期性时间事件这个功能对周期采集、周期上报非常有用。你可以把一个任务设置成每 100ms 执行一次而不需要一个专门的延时任务去反复唤醒自己。我在做传感器采集时就是这样用的采集任务以周期事件方式运行每 20ms 读一次传感器数据解析任务则等待采集完成事件。这样整个模块像流水线一样每个环节都只在自己需要的时候激活。5. 移植实战中最容易踩的坑5.1 任务里写了阻塞大循环整个系统卡死这是协作式调度最经典的问题。我见过不少刚开始用 QuarkTS 的朋友习惯性在任务里写while(1) { // 等待某个标志 }这种写法在裸机里没什么问题但在 QuarkTS 里等于把整个系统锁死了。因为任务不让出 CPU调度器永远没有机会运行其他任务。正确做法是把等待变成状态事件。任务要么主动查询一次标志就返回要么等待着某个事件而不是死等。记住这个原则协作式任务中绝对不允许出现长时间的忙等循环。5.2 SysTick 中断优先级配置不当导致数据丢失我之前踩过一个很隐蔽的坑把 SysTick 优先级设得和某个高速外设中断一样高结果每次 tick 中断和串口中断竞争导致串口数据偶发丢失。排查了很久最后关掉调度器才发现是优先级配置的问题。经验是内核 tick 一般放在中等偏低的优先级就行。真正需要严格实时响应的外设中断优先级应该高于 SysTick而所有普通业务任务哪怕优先级再高在协作式模型里也不会打断中断所以中断优先级的安排要优先保护那些数据易丢失的外设。5.3 编译优化等级引发的神秘问题有段时间我的任务函数在-O0下一切正常开到-O2后竟然跑飞了。后来定位到是一个用于中断和任务共享的变量没有加volatile修饰编译器把它优化成只在寄存器里操作导致两边看到的不是同一个值。排查共享变量时volatile要按需使用该加的加不该加的别乱加。每个共享变量都加volatile会让代码看起来很臃肿也会让编译器放弃合理的优化性能下降不少。5.4 栈溢出和内存对齐问题QuarkTS 的任务栈可以自己分配但如果数组声明得太大又要放进任务句柄里一个不留神就撞到别的变量。我的建议是每个任务栈大小根据实际函数嵌套深度来估算不要默认给一个 1KB 这种大数字。另外Cortex-M 内核要求部分数据对齐访问配置结构体传递错误地址时容易出现 HardFault排查时优先检查指针是否对齐。5.5 API 使用时机不对导致行为异常QuarkTS 的部分 API 只能在任务上下文中调用不能在中断里调用。这个和 FreeRTOS 的限制是类似的。我一开始在串口中断里直接调用了事件发送接口结果调度器数据被破坏系统随机死机。查文档时留意每个 API 的使用范围基本都能避开这个坑。如果确实需要在中断里通知任务就用相应的事件触发接口或者只设置标志位由任务退出中断后主动查询。下面是一份快速排查表遇到问题可以对照着检查现象可能原因处理办法任务不执行没有让出 CPU / 事件没触发检查任务里是否有忙等检查事件发送周期性任务乱跳tick 周期配置错误用逻辑分析仪量 GPIO 翻转周期随机 HardFault栈溢出或指针未对齐减小任务栈并监控查指针优化后行为变化共享变量缺少 volatile排查跨中断/任务的全局变量串口数据丢失SysTick 中断优先级过高调整中断优先级分配6. 资源占用、性能实测与我的选型建议6.1 多小的芯片能跑起来我实测在一颗 64KB Flash、8KB SRAM 的 Cortex-M0 核单片机上QuarkTS 内核占用的 Flash 大约 4KB 左右RAM 占用取决于任务池和事件队列配置通常在 2KB4KB 之间。这个数据会随版本和配置变化但量级不会差太远。对比一下如果你手头只有 8KB SRAM跑完整版 FreeRTOS 会有点紧但跑 QuarkTS 基本轻松。它把轻量这件事做到了极致。6.2 什么场景适合用 QuarkTS我个人的判断标准是这样如果项目只有两三个功能模块互相之间耦合不深那裸机完全够用没必要上任何调度器。如果功能模块已经到五六个而且涉及状态切换、定时轮询、数据分发QuarkTS 的价值就很明显。如果你的需求里有硬实时要求比如电机控制中的电流环那还是优先考虑全抢占式 RTOS或者干脆把关键算法放在中断里执行不要把这种流量放到任务里。6.3 一点个人体会移植 QuarkTS 说到底是理解时间和状态的关系。裸机是一个大循环问所有模块该你了吗QuarkTS 是每个模块自己准备好后告诉调度器我该出场了。这种思维转变一开始有点不习惯但一旦熟悉你会发现代码结构清晰很多很多以前靠全局标志位凑出来的功能都能用状态机自然地表达。最后再分享一个小技巧如果你第一次移植不要同时上太多功能先弄两个 LED 任务、一个按键任务让三个任务协作跑起来再去加串口、传感器这些外设。基础轮子转起来之后后面加任务只是十分钟的事。祝你的板子一次点亮跑得顺畅。