2026/10/14 16:53:00

QuarkTS移植实战:从裸机到协作式调度

QuarkTS移植实战:从裸机到协作式调度 1. 先想清楚为什么我会在项目里选择 QuarkTS 而不是继续裸奔大概在去年做某便携式监测设备的原型时我遇到一个很典型的嵌入式困境。产品本身是一块 Cortex-M3 内核的核心板外设不算少两路传感器要周期采集、一路串口要收发不定长命令、按键要防抖、指示灯要用不同闪烁频率表达工作状态。裸机阶段初期一切都很爽主循环里一个 switch 就能撑住局面。可是随着功能叠加标志位和 if 嵌套越来越多。今天加一个“收到电压数据后延时两秒再上报”明天加一个“按键长按五秒进入配置模式”主循环的状态分支从一个 switch 变成了两层 switch 加一堆全局变量。代码还能跑但每次改动我都得把所有可能的状态组合在脑子里过一遍改完心里完全没底。当时团队里有人建议直接上 FreeRTOS。我也认真评估过但总觉得为了这几个不复杂的周期性任务引入一整套抢占式调度体系有点重。任务栈要规划、优先级要设计、信号量要理解互斥和递归的区别这些都是成本。更重要的是我的业务逻辑并不需要那种“高优先级任务立刻打断低优先级任务”的实时保证。我需要的是手头这三五个任务能各自按周期跑起来别互相干扰代码结构能像状态机一样清晰可维护就足够了。后来权衡再三我选择了 QuarkTS。QuarkTS 是一个开源的嵌入式调度内核核心设计思路是事件驱动和协作式调度。和抢占式 RTOS 最大的区别在于QuarkTS 里的任务并不会被系统时钟中断随时打断而是每个任务执行完自己“这一轮”的逻辑后主动交还控制权调度器再根据周期或事件决定下一步去执行哪个任务。打个比方抢占式 RTOS 像餐厅后厨每个厨师有自己的灶台总厨按定时器吹哨换人而协作式调度更像一条流水线每个工位干完手头工序自动流转给下一个工位没人会在你干到一半时硬抢。QuarkTS 的移植范围非常集中对于一个小型项目来说工作量比完整 RTOS 的移植小得多。当然QuarkTS 不是万能的。它的生态和知名度远不如 FreeRTOS遇到问题能查到的资料也少。但正因为如此把 QuarkTS 从源码拉到自己的板子上跑通是一个特别好的“从零理解调度器”的过程。如果你和我一样写过一段时间裸机又不想直接跳进抢占式 RTOS 的深水区QuarkTS 是很合适的中间站。这篇文章分享的就是我在实际项目里从零移植 QuarkTS 的完整过程包括源码结构、最小移植三要素、任务编写方式以及我调试中踩过的坑。适合刚接触单片机调度、想给裸机代码找一套轻量级组织方案的同学。2. 移植前摸底QuarkTS 源码结构和“最小三件套”2.1 拿到源码后先别急着往工程里拖很多新手拿到一个开源内核第一步就是把所有 .c 文件全塞进工程然后编译接着报错几十条然后放弃。QuarkTS 的源码结构并不复杂从官方仓库拉下来后核心代码基本都在 src 目录下。你需要关心的文件大概包括 quarkts.c、quarkts.h、quarkts_port.h以及一些可选模块。quarkts.h 是主头文件API 定义基本都在这里quarkts_port.h 是平台相关层其他文件比如队列、内存池、链表属于按需启用的功能模块。我建议第一次移植时只保留最小集合quarkts.c、quarkts.h、quarkts_port.h再加一个内核配置文件。把其他模块先断开等跑通第一个 LED 任务之后再逐个把队列、信号量这些东西加回来。这样做的好处是一旦出了问题问题面只有一个“调度器本身”而不是“调度器加队列加内存池的复合体”。我自己第一次移植时就是全量编译结果一个头文件版本不对导致宏定义冲突排查了两个小时才发现是某个不用的模块引入的问题。从那以后凡是引进新组件我都坚持最小化裁剪。2.2 协作式调度内核的移植到底要“移植”什么这是整个移植过程最关键的认识。很多人一听到“移植操作系统”脑子里先想到的是汇编、中断向量表、任务栈这些东西。QuarkTS 在协作模式下要简单得多。因为我用的是默认的协作式调度所有任务共享同一条执行流任务之间不存在“保存现场、切换栈”的动作。任务切换不过是从一个函数返回再进入另一个函数。所以它不需要你为每个任务分配独立的栈空间这一下省掉了很多内存规划工作。那到底需要移植什么核心就三样时间基准、临界区保护、平台相关宏。时间基准指的是调度器需要一个单调递增的时钟计数器用来判断任务的周期到没到、延时结束没有。临界区保护用于中断与任务共享数据时防止竞争。平台相关宏则包括关中断、开中断、主动让出 CPU 之类的底层操作。这三件事对应到 Cortex-M 平台上基本就是 SysTick 定时器加几条关中断指令的事。这也是我觉得 QuarkTS 适合用来“第一次移植操作系统”的原因移植面窄机制清晰你能真正看到内核依赖平台的边界在哪里。2.3 用配置宏裁剪功能控制 ROM 占用QuarkTS 的功能模块大多可以通过头文件里的宏开关裁剪。比如我在项目里用到了信号量和事件标志但暂时用不到消息队列和内存池就把这两个模块的编译开关关掉。裁剪之后编译出来的代码体积小内核行为也更容易分析。看代码时也不会被一大片没启用的逻辑干扰。我个人的习惯是建一个自己的配置文件把用得到的模块开关列出来注释写清楚为什么开、为什么关。比如串口命令解析任务需要借助事件标志来通知“收到完一条完整命令”我就把事件标志模块打开。传感器采集任务之间需要互斥访问 I2C 总线我就把信号量打开。用不到的队列就关掉。每个开关都对应一个实际需求不会出现“为了保险全打开”这种无意义的资源浪费。配置宏具体叫什么名字不同版本可能有差异但思路是一致的打开内核源码头文件搜索 “USE_” 开头的内容就能看到功能裁剪的清单。3. 核心实操从裸机工程到第一个 QuarkTS 任务点亮 LED3.1 先把裸机工程跑通再谈操作系统移植系统有个特别容易踩的坑就是工程本身还没验证过就叠加了系统的复杂性。我当时的做法是先用裸机工程把核心板上最基本的点灯、按键、串口回环都跑通。这一步看起来多余实际上价值很大。它保证了后面所有的问题都出在“系统集成”层而不是外设初始化、时钟配置这些基础问题上。如果裸机工程都不稳定移植完 QuarkTS 后出了诡异问题你会分不清是系统导致的还是外设本身就有问题。我用的是一块 Cortex-M3 内核的核心板外设库和经典系列很接近。裸机工程里我先初始化了 SysTick让它产生 1 毫秒的中断初始化了一个 GPIO 口用来点灯串口配置成 115200 波特率。这些都是老生常谈的操作但我会刻意把 SysTick 的中断服务函数留一个空壳注释写着“这里后面要喂内核”。这样等会儿加入 QuarkTS 时改动点非常明确。3.2 把 QuarkTS 源码加进工程并建立移植层把上一步选出的最小文件复制到工程目录下然后在 IDE 的编译选项里包含对应头文件路径。这一步本身没什么技术含量但要注意编译器版本和 C99 支持的兼容性。QuarkTS 源码用到了较多 C99 特性如果你的工程默认标准是 C89编译时会报一堆变量声明位置不对之类的错误把编译器语言标准切到 C99 或 C11 就能解决。接下来最重要的一步是找到平台相关的移植点。把 quarkts_port.h 打开你会看到里面定义了一些需要平台实现的宏比如进入临界区、退出临界区、系统 tick 处理函数的挂接点。Cortex-M 平台下进入临界区最简单的方式就是关中断退出临界区就是开中断。用编译器提供的 __disable_irq() 和 __enable_irq() 就能搞定这两条指令最终会操作 PRIMASK 寄存器。更稳妥的做法是使用内核提供的通用接口但核心思想都一样在访问共享资源的短暂时间里把可屏蔽中断挡住。这里我要特别提醒一点移植层的宏一定要仔细看默认实现。有些版本自带了针对 GCC 和 ARM Compiler 的弱定义如果你的编译环境不在默认支持列表里它会编译失败或跳到一个空实现。遇到这种情况别慌你要做的不是去改内核主逻辑而是找到并发相关的平台钩子按你自己编译器的语法补一个实现即可。3.3 配置 SysTick把系统心跳喂给内核时间基准是调度器的命脉。QuarkTS 在协作模式下同样需要知道自己运行了多久这样才能判断一个周期任务的下一轮是否该被调度。Cortex-M 平台最方便的时间来源就是 SysTick。我的配置是 1 毫秒触发一次中断然后在中断服务函数里调用内核暴露的 tick 处理函数。这个 tick 处理函数在不同版本里可能叫 QK_Tick_Handler也可能叫别的名字你不需要死记硬背最好的办法是去源码目录里搜索 “tick” 关键字找到那个负责推进内核时钟的函数。注意一个细节SysTick 的中断优先级不要设得太高。我一开始图省事把它设成了最高优先级结果某次在临界区关中断期间一个外部中断触发了很长时间的逻辑SysTick 虽然被挡着但外部中断处理完之前系统心跳仍然有可能丢。协作式调度对 tick 精度不像抢占式 RTOS 那么敏感丢一两个 tick 通常只会让任务的周期出现几个毫秒的抖动。但如果 SysTick 优先级和外设中断一样高还会引发优先级反转的排查困扰。我后来的惯例是SysTick 优先级放在中等偏低位置保证外部中断响应优先tick 稍微晚一点不会引发灾难。在 SysTick 中断里喂完 tick 之后调度器的时间基础就建立起来了。这时候可以先写一个最简单的空任务看它能不能被创建、被调度、被周期唤起。如果这个流程跑通说明内核的移植层基本完成接下来的工作就是往里面填业务逻辑。3.4 编写第一个单 LED 闪烁任务任务函数是 QuarkTS 里最直接的组成单元。我用的是类似状态机风格的定义方式。先看一个最基本的 LED 闪烁任务长什么样static void Task_LED(void) { QK_TASK_BEGIN(); while (1) { QK_Task_Sleep(500); GPIO_ToggleBits(LED_GPIO_PORT, LED_GPIO_PIN); } QK_TASK_END(); }这个写法看着还是挺直观的任务启动后进入一个死循环每 500 毫秒翻转一次 LED。QK_Task_Sleep 不是让 CPU 傻等 500 毫秒它做的事情是告诉内核“我这个任务 500 毫秒之后再叫我”然后立刻返回。调度器继续去执行其他任务等时间到了再次进入这个函数继续执行 while 循环里的下一步。这就是协作式调度的精髓任务自己主动让出 CPU而不是被系统打断。main 函数里的骨架大致是这样int main(void) { /* 初始化时钟、GPIO、USART、SysTick ... */ Board_Init(); QK_Init(); QK_Task_Create(Task_LED, 0, QK_FLAG_PERIODIC, 0); QK_Run(); return 0; }QK_Init 负责初始化内核的数据结构QK_Task_Create 注册任务QK_Run 启动调度循环。QK_Run 通常是一个不会返回的死循环调度器的主循环就在这里面。整个应用代码的实际形态从“裸机 while(1) 写各种状态分支”变成了“在 main 的最后把控制权交给内核”。这里我提醒一个新手常见问题QK_Run 放在 main 里之后主循环后面不要再写任何业务代码。QK_Run 一般不返回你写在后面的代码永远不会执行。如果你看到的现象是任务没跑起来第一件事就是确认 QK_Init、任务创建、QK_Run 这三步是否都在顺序是否对。我调试过不少案例最后发现是任务创建语句放在 QK_Run 之后任务压根没注册进去。4. 协作式调度下延时、事件标志和临界区的使用细节4.1 任务里千万不能用阻塞式延时协作式调度最需要建立的新习惯就是放弃裸机编程里的阻塞延时。裸机时代我们用 HAL_Delay 或者自己写一个 for 循环等待没问题因为主循环被卡住就是整个系统在等待。但在 QuarkTS 里你的任务只是调度器流水线上的一道工序。如果你在某个任务里写了 while 等待一个引脚电平变化或者调用了库函数的阻塞延时整个系统的其他任务全部会跟着停摆。我当时就吃过这个亏。传感器采集任务里我顺手用了一个别人封装的“I2C 读传感器内部自带 10 毫秒等待”的库函数。程序跑起来发现 LED 灯闪烁明显变慢串口命令响应也变得迟钝。排查半天才意识到那个库函数在等待期间把 CPU 死死占住了调度器没机会去执行其他任务。解决方法是把这种内部等待改成基于 QuarkTS 调度的非阻塞写法或者把库函数的等待时间缩短让每次任务执行时间控制在可以接受的范围内。所以要建立一个习惯凡是“延时、等待某个条件成立”这种逻辑第一反应是用内核提供的睡眠或事件等待接口而不是用轮询死等。任务运行时间要尽量短执行完就交还控制权。4.2 用信号量和事件标志改造中断通知机制裸机工程里串口接收中断最常用的做法是置一个全局标志位主循环发现标志位变了就去处理数据。在 QuarkTS 里这种做法依然可行但更好的做法是使用内核提供的事件标志或信号量。事件标志的本质是一个可以被内核调度的“条件变量”串口中断里往标志里写值对应的任务在等待这个标志时不会占着 CPU而是让调度器去干别的事直到标志被触发。比如我的串口命令解析任务在裸机版里是一个 while(1) 循环检查一个全局变量 rx_ready。在 QuarkTS 版里改成了这样串口接收中断解析完一帧数据后调用内核的发送函数设置事件标志解析任务在一个循环里调用带超时的事件等待接口。如果标志没置位任务直接交还 CPU系统时间片全都留给传感器采集和 LED 任务。这个改造不仅让代码语义更清晰也让 CPU 占用率大幅下降。事件标志相关的具体函数名不同版本会有差异你自己在头文件里搜 signal 或 event 就能找到。但用法思路是通用的中断里置位任务里等待。等待时任务不占 CPU这是和裸机全局标志位最大的区别。4.3 临界区到底要不要用、什么时候用协作式调度下任务和任务之间天然不会同时执行因为同一时刻只有一个任务在跑。真正需要担心的是“任务”和“中断”的竞争。假设串口中断往缓冲区写数据任务正在从同一个缓冲区读数据写命令解析如果任务读到一半中断把数据改了解析结果就会出错。这种场景就必须加临界区保护。临界区的宏用起来很简单在访问共享数据前进入临界区访问完成后退出临界区。底层就是关中断和开中断。但使用时有几个经验值得记下来。第一临界区里只做最必要的操作比如拷贝几个数据、更新一个下标。不要在里面调用日志输出、延时、甚至复杂的浮点运算否则会拖长中断关闭时间影响实时性。第二临界区不要嵌套嵌套再嵌套尽量保持“进入-退出”的配对层次清晰。如果代码路径上中途 return 或跳转容易把临界区状态打乱。第三如果中断里也要操作同一块数据那中断里通常不需要再加关中断保护因为中断本身已经能打断任务只要任务侧用临界区把中断挡在外面就行。我后来给自己定了一个简单规则硬件外设的寄存器操作默认不保护因为它们要么由中断独占要么由任务独占只有多个执行上下文共享的内存变量才必须用临界区。5. 真实项目拆解传感器采集、串口命令解析和心跳灯共存5.1 任务划分与优先级设计思路某便携式监测设备的任务最终划分成四个传感器一每 100 毫秒采集一次传感器二每 500 毫秒采集一次串口命令解析由事件触发只有收到完整一行命令才运行LED 心跳灯每 1 秒翻转一次。四个任务互不阻塞每个任务的单次执行时间都很短。任务创建的时候QuarkTS 允许你指定周期还是单次执行。传感器采集这种周期性任务用周期模式串口解析这种由外部事件驱动的任务严格来说可以做成“非周期模式配合事件等待”这样它平时完全不消耗 CPU。优先级这个参数在协作模式下没有抢占式 RTOS 里那么关键但它会影响调度器选择下一个执行哪个就绪任务。我的习惯是把执行时间短、对及时性要求高的任务优先级放高一点把耗时较长的任务优先级放低一点。这样即便某轮全部任务都就绪短任务也能优先完成降低整体响应延迟。项目里还有一个容易忽视的点I2C 总线是两个传感器采集任务共用的外设。如果两个任务同时发起 I2C 传输总线时序会乱。我在两个采集任务之间加了一个信号量任何一方访问 I2C 前必须先获取信号量用完就释放。这也是 QuarkTS 这类内核里信号量最经典的应用场景保护同一硬件资源不会被多个执行流交错访问。5.2 任务执行时间实测与调度余量评估任务划分完之后我没有直接拍板说系统没问题而是用 GPIO 翻转的办法实际测量了每个任务的执行时间。方法很土但很有效在任务函数入口拉高一个备用 GPIO在任务函数返回前拉低用逻辑分析仪抓这个引脚的高电平脉宽。测出来的数据是传感器一采集任务约 1.2 毫秒传感器二约 2.1 毫秒它内部要多算一轮平均串口解析任务约 3.8 毫秒。这几个任务加起来在最坏情况下单轮总耗时约 7 毫秒左右。而最密集的传感器一任务周期是 100 毫秒所以调度器每 100 毫秒只需要拿出 7 毫秒不到的时间余量非常充足。这个测量结果让我对系统响应有了底后面加功能时也有了判断依据只要新任务的执行时间和周期比例不超过 30%系统都能稳定跑超过 50% 就要认真考虑合并任务或优化执行体了。如果某个任务的单次执行时间超过它自己的周期说明这个任务设计就不合理调度器必然会出现任务积压表现出来就是周期抖动。5.3 主循环里的 QK_Run 和空闲钩子主循环被 QK_Run 接管之后应用代码平时是插不进手的。如果你需要在系统空闲时做一些低优先级的事比如统计 CPU 占用率、清看门狗可以用空闲钩子。空闲钩子会在所有任务都不需要执行时被调度器调用。但注意在协作式模式下空闲钩子本质上仍然是主循环的一部分它的执行时间也会占用 CPU所以同样要控制得短。我一般不太建议把喂狗放在空闲钩子里因为一旦某个任务出现阻塞空闲钩子可能长时间得不到执行看门狗反而会误触。更稳妥的做法是让一个低优先级任务专门负责喂狗这个任务只要系统调度还活着就会周期执行。看门狗喂的是“调度器是否还在正常转”而不是“CPU 是不是真的一点事都没有”两者意义完全不同。6. 移植调试中的典型坑以及我的排查方法6.1 五种常见故障速查表移植过程中我把遇到的问题整理成了一张速查表后面再做类似项目时能直接对照排查。现象可能原因解决思路任务完全没有执行QK_Init 或 QK_Run 没调用或任务创建语句在 QK_Run 之后检查 main 函数流程确保初始化、注册、启动顺序正确只有第一个任务执行后面任务不跑某个任务内部有阻塞延时或 while 死等搜索所有 delay、while 等待改成内核睡眠或事件等待接口任务周期明显偏差SysTick 没配好或中断优先级异常导致丢 tick用逻辑分析仪对比实际翻转间隔和期望周期检查时钟配置偶发跑飞或异常报错数组越界、共享变量未加临界区保护检查所有在中断和任务之间共享的变量补临界区串口数据乱码printf 重定向过程中被中断打断或发送缓冲区冲突发送整段数据时加临界区或改成中断方式逐字节发送我遇到最多的其实是第二种。很多从裸机转过来的开发者会把原来的按键防抖代码直接搬进任务里内部一个 while 循环等按键释放。这一搬整个系统就卡住了。排查技巧是先搜代码里所有循环等待片段再逐个改成状态机方式。6.2 二分隔离法先确认内核没问题再加任务当你发现异常但找不到原因时我的建议是“不断减任务”。假如系统里有五个任务先把四个停掉只保留 LED 任务。如果 LED 任务都不正常说明问题出在最基础的 SysTick 或临界区配置上跟业务逻辑无关。如果 LED 正常再逐个把任务加回来每加一个跑一段时间观察。这种二分法在调试嵌入式系统时特别管用因为问题往往不是内核本身而是某个具体业务逻辑破坏了整条流水线。有一回我加完串口解析任务后系统每隔几秒就卡一次。用二分法排查最后定位到解析任务里一个字符串处理函数使用了动态内存分配分配失败后进入了一个错误处理分支这个分支里有一个大延时。这种情况光看代码很难发现因为正常运行时动态分配几乎不会失败。但用了减任务策略后每加回一个任务系统就变化一次问题很快就浮出水面。所以排查时要敢于删代码而不是把所有可疑点堆在一起反复看。6.3 版本差异和 API 变化别盲目抄老教程QuarkTS 的版本迭代过程中API 有过调整。我自己就遇到过照着老版本文档写任务创建结果新版源码里函数签名完全不同的情况。遇到这种问题最直接的办法是打开官方 example 对应平台的例程用它作为参照。官方例程会直接给出当前版本下任务创建、信号量使用、tick 挂接的标准写法。不要硬背任何版本的 API要学会查源码和例程。还有一点我在编译器优化等级上踩过一个小坑。刚开始工程默认开了 -O2 优化结果在一些边界条件下变量被优化掉行为变得很奇怪。嵌入式内核调试期间我建议先用 -O0 或不带优化跑通确认逻辑没问题后再逐步调高优化等级。如果优化后出现时序或行为异常优先怀疑编译器优化掉了你写的空的延时循环或者某个看似没有用的变量读取。这种情况下给关键变量加 volatile 修饰通常能解决。写在最后的个人体会移植 QuarkTS 这个项目做完之后我最大的感受是它让我第一次真正看清了一个调度器的边界在哪里。以前用 FreeRTOS 的时候我是把它当作一个黑盒子在用任务栈、优先级、调度切换很多概念都是背下来的。而移植 QuarkTS 的过程因为要亲自动手搞定 SysTick、临界区宏、平台钩子我才意识到所谓操作系统内核在最小形态下其实就是一段不断“查时间、查事件、调函数”的主循环。这种认知对我后来理解更复杂的 RTOS 帮助非常大。如果问我什么情况下适合用 QuarkTS我的建议是任务数量在你用裸机状态机已经写不下去、但又不是非得抢占式不可的中间地带。比如 3 到 10 个周期性或事件驱动的任务实时性要求不高资源又比较紧张QuarkTS 是一个挺好的选择。如果任务数量上了两位数或者有硬实时的强优先级要求那还是老老实实上 FreeRTOS 这类抢占式内核更稳妥。最后再分享一个小技巧移植过程中坚持写移植日志不用很正式记录每个改动点、每次出问题的现象和解决方式。这类日志在系统后期调试时价值非常大很多当时觉得莫名其妙的玄学问题回过头看都能在日志里找到蛛丝马迹。移植操作系统本身也是一场对工程习惯的考试严谨一点后面的路会顺很多。