ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

QuarkTS轻量级调度器移植实战:告别裸机While(1)的多任务实现

QuarkTS轻量级调度器移植实战:告别裸机While(1)的多任务实现 裸机 while(1) 写多了你会慢慢感觉到一种“越写越拧巴”的别扭LED 要闪、按键要扫描、屏幕要刷新、串口还要收发全怼在一个大循环里加一个功能就要改一片状态逻辑改着改着就把自己绕进去了。想直接上 FreeRTOS 吧又觉得项目就巴掌大配置任务栈、调度器、内存堆光铺路就得折腾半天。如果你正好卡在这个尴尬点上那我建议你认真看看 QuarkTS 这个轻量级调度器。QuarkTS 的定位很有意思官方把它叫做“面向嵌入式系统的开源调度器”而不是完整操作系统。它没有复杂的内存管理不需要你在工程里塞一堆驱动框架核心就是一个任务调度内核加上事件标志、消息队列、软件定时器这些常用的任务间同步原语。移植到一个新的单片机上核心工作量往往只有三件事配置好系统节拍定时器、实现好临界区、再把定时器中断服务函数接到内核的 tick 入口。对资源紧张的小 MCU 来说这东西比 FreeRTOS 轻太多了。这篇文章就围绕这个标题展开从我实际移植的流程出发把 QuarkTS 到底解决什么问题、底层接口要动哪里、具体每步怎么配、以及那些网上文档里不会写的坑一次性拆开讲清楚。适合正在从裸机往多任务过渡的开发者也适合手上芯片资源有限、塞不下完整 RTOS 的嵌入式从业者参考。1. 移植前先想明白QuarkTS 到底是解决哪个问题的1.1 裸机大循环真正做不好的三件事裸机程序的核心结构就是“初始化 超级循环”这个结构本身没有问题很多简单产品跑得也很稳。但等你手里的功能模块变多之后三个问题会越来越明显。第一所有模块共享同一个执行流。一个任务里写了阻塞延时其他所有任务全部跟着等待。比如串口发送函数如果用了轮询方式等待发送完成标志在一个 9600 波特率的链路上发一串日志对面整个 LED 闪烁周期都会被拖乱。这是裸机程序里最典型的“一损俱损”。第二时间精度完全依赖主循环的循环体长度。你用delay_ms(20)做软件定时看起来是 20ms但实际执行时间等于“20ms 延时 当前循环里所有任务执行时间之和”。今天加一个函数没事明天再加一个传感器读取按键消抖的时间窗口就漂了很多玄学故障就是这么来的。第三中断和主循环之间的数据交接没有规范。中断里置标志位、主循环里轮询清标志这套做法本身没问题但一旦涉及的共享变量多了变量有没有加volatile、清标志和读数据之间的先后顺序合不合理全凭个人经验。同一个团队里两个人写出来的裸机代码风格可能天差地别。QuarkTS 解决的正是这三点。它把每一个独立的功能封装成“任务”每个任务有自己独立的上下文由调度器决定“谁在什么时候运行”。时间触发任务严格按照配置的周期去执行不受其他任务的影响事件触发任务则由中断或其他任务通过事件标志唤醒数据交接走标准的队列原语。写起来干净后期维护也好做。1.2 它和 FreeRTOS 的本质差异决定了它的适用边界刚开始接触 QuarkTS 的人最容易问的一个问题是这玩意儿和 FreeRTOS 有什么区别区别还挺大我列个表说明。维度裸机超级循环QuarkTSFreeRTOS内核体积无很小核心代码约几千行较大完整功能更多动态内存不用默认可不使用任务控制块静态分配常用堆分配也可静态任务模型无任务概念时间触发/事件触发/周期任务抢占式/协作式任务调度方式顺序轮询固定优先级 可选时间触发的综合调度优先级抢占 时间片轮转队列/信号量/事件自己写内置用法简单内置功能完整学习曲线无较低中等偏上适用场景逻辑极简的小产品中小型多任务、资源受限 MCU复杂系统、强实时、多外设QuarkTS 没有内存保护、没有设备驱动框架、没有用户态和内核态的概念。它更准确的叫法其实是一个“调度核心”目标是在尽量小的资源开销下把多任务调度这件事做扎实。你在 QuarkTS 里不会看到线程、信号量、互斥锁满天飞的那套完整 RTOS 语义取而代之的是事件触发、时间触发、队列、信号量这些足够日常用的原语。所以适用边界也就清晰了如果你的单片机只有几 KB 内存外设模块不超过十个希望快速实现多任务又不愿意被完整 RTOS 的配置细节淹没QuarkTS 是很好的中间选择。如果项目涉及复杂的文件系统、网络协议栈、多级优先级嵌套那还是老老实实上 FreeRTOS 或更完整的 RTOS别在调度器这一层省事。2. 移植前要看清的本质QuarkTS 到底要求你提供哪些底层能力2.1 硬件依赖也就三样东西移植 QuarkTS 不需要特定的单片机型号它本身就设计成可以跑到任意有 C 编译器的 MCU 上。从实际角度讲硬件上有三样东西是必需的。第一一个稳定的周期定时器中断。这是 QuarkTS 的“心跳”所有时间触发任务、超时判断、队列阻塞都依赖这个 tick。我习惯把系统 tick 配成 1ms这个粒度对大多数传感器读取、按键扫描、LED 控制都够用。如果你有对时间精度要求特别高的模块比如飞控的角速度积分或者音频采样那 1ms 就不够了需要把 tick 周期调短到 100us 级别但代价是调度器本身的切换开销占比会变大任务切换频繁时 CPU 占用率会明显升高。第二开关中断的能力。QuarkTS 内部很多操作需要原子性比如就绪队列的插入和移除必须先关中断再操作操作完再恢复。在 Cortex-M 内核上一般通过改写 PRIMASK 或 BASEPRI 寄存器实现。第三一个足够大的定时器计数器用来给任务提供周期计时。通常 MCU 自带的 SysTick 或者通用定时器就够了QuarkTS 本身不需要外部硬件辅助。选平台的时候我的建议是第一步先别管性能挑一块你手上最熟悉的开发板把串口打印和定时器中断都验证过的那块板子拿来移植。移植内核最怕的是“底层有问题但不知道问题在哪”你在熟悉的环境里做可以把变量压缩到最小。2.2 QuarkTS 的“移植层”到底只有哪几个函数打开 QuarkTS 源码目录你会看到它把平台相关的部分单独隔离出来了。常见结构是一个quarkts.c和一个quarkts.h作为内核主体另外留了几个由用户实现的接口或者叫移植回调。不同小版本的接口名称会有些出入但本质就四类一是 tick 中断服务入口。定时器中断触发后你需要在这个中断服务函数里调用内核的 tick 处理函数告诉内核“一个时间片过去了”。不同版本的 QuarkTS 这个函数叫法可能是qOS_Tick()也可能是qTick()具体以你下载到的头文件为准。这个函数内部要做就绪队列更新、任务切换检查所以你在中断服务函数里除了调用它尽量不要干别的耗时的活。二是临界区进入和退出。QuarkTS 在修改就绪队列、任务状态时需要短暂地关掉中断保证操作过程中不会被 tick 打断。你通常只需要写两组宏分别是进入临界区和退出临界区然后在quarkts.h的移植配置区里把它映射好。三是可选的任务切换钩子。在 Cortex-M 这类需要显式保存寄存器上下文的内核上任务切换通常借助 PendSV 异常来完成。QuarkTS 本身自带了一些通用切换代码但不同编译器、不同优化等级下你可能需要微调一下内联汇编或者栈对齐方式。四是挂起和解挂调度器的能力。当任务进入一些长时间阻塞的等待状态时调度器可能要临时把中断里的切换动作挂起来避免反复做无意义的上下文切换。这个功能有的版本会开放给用户有的版本内部自己处理移植时留意一下宏开关即可。2.3 以某 Cortex-M 内核芯片为例底层映射怎么做我这里用一块 Cortex-M0 内核的国产开发板作为例子说明。Cortex-M0 没有 BASEPRI 寄存器临界区只能靠 PRIMASK 全关中断来实现代码很简单/* port.c 示例 */ #include quarkts.h #include core_cm0.h void port_SysTick_Init(void) { /* 配置 SysTick 为 1ms 中断时钟源和处理频率请按实际计算 */ SysTick_Config(SystemCoreClock / 1000); } void SysTick_Handler(void) { /* 进入内核 tick 处理 */ qOS_Tick(); }临界区的宏可以这样映射/* port.h 示例 */ #define QK_CRIT_ENTER() __disable_irq() #define QK_CRIT_EXIT() __enable_irq()内核配置区里把这个宏关联进去即可。流程上就三步初始化定时器、定义 tick 中断、实现临界区。后面再把内核初始化、配置结构体填好QuarkTS 就能在你的单片机上跑起来了。这里多说一句Cortex-M3 和 M4 内核的同学临界区建议用 BASEPRI 方式而不是 PRIMASK 全关中断因为 BASEPRI 可以只屏蔽低于某个优先级的中断让真正紧急的中断在临界区期间也能响应。这是后面调实时性时非常关键的一点。3. 实操过程从空工程到双任务跑起来3.1 把源码组织进工程的小建议QuarkTS 源码可以从代码托管平台下载找到发布版本的压缩包。拿到手以后不要直接把整个目录全部塞进工程而是只复制真正用到的文件。我的习惯工程结构是这样my_quarkts_demo/ ├── core/ │ ├── quarkts.c │ ├── quarkts.h │ └── qassert.h ├── port/ │ ├── port.c │ └── port.h ├── app/ │ ├── main.c │ └── app_task.c └── mcu/ └── (你的芯片启动文件、外设库等)这样区分的好处是core目录是内核源码永远不去改它port目录放平台相关代码app目录是业务逻辑。以后内核版本升级时直接替换core目录里的文件就行自己的业务代码不受影响。我见过有人把 QuarkTS 的源码和自己的业务逻辑混在一个文件里改最后升级源码时痛苦不堪不建议这样做。3.2 系统配置结构体这里填错最容易出怪问题QuarkTS 启动之前一般要求填充一个全局配置结构体告诉内核你打算创建多少个任务、多少个队列、多少个优先级等级。不同版本的配置项名称会有差异但核心参数逃不出下面这些。配置项推荐值说明最大任务数视业务而定建议 8~16一次性把内核可能用到的任务池开够优先级等级数8 到 16不是越高越好等级越多调度链表越复杂最大事件组数业务需要每个事件组可以包含多个事件位最大消息队列数业务需要通常给 4~8 个就够tick 周期1ms具体以定时器配置为准是否启用抢占是需要实时响应就开逻辑极简可以关配置项设置偏小了运行时会直接断言失败或者任务创建失败设置偏大了RAM 会白占。你说不清楚到底要多少的时候先用一个大点的值跑通功能再逐步调小观察内存占用。这个习惯能省掉很多调试时间。配置结构体的填充代码大致长这样qOS_Cfg_t app_qos_cfg; void app_qos_config_init(void) { app_qos_cfg.task_pool_size 16; app_qos_cfg.priority_levels 8; app_qos_cfg.use_preemption true; /* 其他成员按你的头和实际需求补全 */ }配置完成以后调用内核初始化然后启动调度器。顺序一定要是初始化、配置、然后qOS_Run()进入调度循环qOS_Run()之后你的入口函数就不需要返回了。3.3 创建第一个任务LED 闪烁验证 tick 链路我用一个最经典的 LED 闪烁任务来验证整个移植链路是否通畅。第一版功能不求多只求能证明“内核收到 tick、任务被调度、任务能周期运行”。#include quarkts.h #include port.h qTCB_t tcb_led; qTCB_t tcb_printf; void task_led_run(qEvent_t evt, qTCB_t *tcb) { static uint8_t level 0; level !level; GPIO_WritePin(LED_PIN, level); } void task_printf_run(qEvent_t evt, qTCB_t *tcb) { /* 这里假设每 500ms 打印一次 */ printf(tick alive, led level toggled\n); } int main(void) { /* 1. 先初始化基础硬件包括 tick 定时器 */ board_init(); port_SysTick_Init(); /* 2. 初始化内核 */ qOS_Init(); app_qos_config_init(); qOS_Config(app_qos_cfg); /* 3. 创建任务 参数里的周期值在不同版本里含义略有区别 原则上是“间隔多少个 tick 执行一次”。 优先级数字越小优先级越高。 */ qTask_Create(task_led_run, 5, 20, tcb_led); qTask_Create(task_printf_run, 6, 500, tcb_printf); /* 4. 启动调度器不再返回 */ qOS_Run(); return 0; }上面代码里我用的是语义化的 QuarkTS 风格 API具体函数名和参数顺序要对照你下载到的quarkts.h去看。第一次调试千万不要管那么多先把 LED 任务建出来把周期设为 20 个 tick也就是用 1ms tick 算大概 20ms 闪烁一次。烧录以后观察 LED 是不是以可辨认的周期翻转。这一步跑通说明 tick 中断、内核调度、任务上下文切换这一整条链路已经打通了。这里我想强调一个容易误解的点QuarkTS 的时间触发任务它的“周期”通常是指任务被置为就绪状态的时间间隔而不是从任务开始执行到下次执行之间的间隔。也就是说如果你在一个周期任务里执行了很长时间比如超过了一个周期那任务的执行起点会继续按原始节拍移动表现为“本次还没跑完下一次就绪时间又快到了”。这与裸机delay的语义有本质区别移植几周后你调业务逻辑时这个概念必须刻在脑子里。3.4 用串口任务验证多任务下互不拖累第二个任务我经常会做成周期打印任务每 500ms 打印一行字符。它的作用是验证两个任务能并行跑并且一个任务执行耗时不会污染另一个任务的周期。调试时有个实用技巧在 LED 任务和打印任务里各放一个计数器每执行一次加一然后用调试器查看两个计数器的数值比例。如果你配置的周期比是 1:25那么最终计数器比例应该非常接近 1:25。如果比例明显不对说明 tick 配置或者任务周期参数有偏差回头检查配置结构体。串口打印任务在内核刚移植成功时非常有价值但后面调正式业务时要注意不要在任务函数里频繁调用printf因为重定向到串口的打印往往是阻塞式的一个 115200 波特率下打印几十个字符就要耗费好几毫秒严重时会把时间触发任务的节奏拖乱。我的习惯是任务里只置一个标记主循环或专门的打印任务再统一输出。4. 从双任务到真实应用事件、队列、软定时器是怎么用的4.1 事件标志中断里唤醒任务的标准姿势QuarkTS 的典型事件触发场景是这样的外部按键按下触发 GPIO 中断中断服务函数里不适合做复杂处理于是置一个事件标志通知某个任务去执行按键消抖和逻辑处理。事件标志的使用思路和裸机里的“标志位”很像区别在于它是内核管理的对象可以对任务进行精准唤醒而不是靠主循环轮询。示意如下qEvent_t key_event; void EXTI_IRQHandler(void) { /* 清除中断标志等常规操作 */ qEvt_Signal(key_event); } void task_key_run(qEvent_t evt, qTCB_t *tcb) { while (1) { /* 等待事件阻塞在这里 */ qEvt_Wait(key_event, QWAIT_FOREVER); do_key_scan_and_handle(); } }从裸机过来的朋友请注意事件等待任务里的while (1)结构和 QuarkTS 时间触发任务不同。事件触发任务如果从函数返回通常会被内核认为任务结束或者重新挂起具体行为取决于版本。你要养成习惯事件触发任务写成“等待事件→处理→再等待”的循环结构这个结构在裸机里很别扭但在事件驱动模型里非常自然。4.2 消息队列任务间数据传递的正确做法事件标志适合传递“发生了没有”这种信息但如果要传递“发生了什么数据”就不够了。比如串口接收中断把一帧数据收进来交给处理任务解析就需要消息队列。qQueue_t uart_rx_queue; void UART_RX_IRQHandler(void) { uint8_t byte UART_ReadByte(); /* 队列满时返回失败这里做简单丢弃处理 */ qQueue_Send(uart_rx_queue, byte, sizeof(byte)); } void task_uart_process(qEvent_t evt, qTCB_t *tcb) { uint8_t data; while (1) { if (qQueue_Receive(uart_rx_queue, data, sizeof(data), QWAIT_FOREVER)) { handle_rx_frame(data); } } }中断里调用qQueue_Send时要注意一点如果队列满这个函数的行为取决于版本和配置有的直接返回失败有的可能会在中断里做等待。等待这个动作在中断上下文里是大忌会直接把系统卡住。所以无论内核怎么处理你的业务代码里都应该在发送前检查队列是否有空间或者做好“满则丢弃”的策略。4.3 软件定时器比任务周期更灵活的控制手段有些场景不适合用周期任务比如“延时 300ms 后再做某个动作”“超时 200ms 没收到数据就算通信失败”。这类一次性或单次触发的定时需求QuarkTS 提供了软件定时器原语。软件定时器的底层仍然是 tick 计数但使用上比创建一个完整任务轻量得多。典型的创建方式是在初始化阶段注册一个定时器回调函数启动时设置超时 tick 数。到了时间后内核回调你的函数你可以在里面置事件标志、发队列、或者直接执行短逻辑。注意回调函数是在内核上下文里执行的不能阻塞不能做耗时操作比较重的操作应该在回调里“通知任务去做”。5. 移植过程中最常翻车的点问题排查与调优经验5.1 任务完全不跑十有八九是 tick 链路断了移植 QuarkTS 之后最打击人的现象就是程序跑起来LED 不闪串口不打印一切看起来都没反应。查这一步问题要按链路顺序来。先确认定时器中断有没有真正触发。在中断服务函数里加一个 IO 翻转用示波器看波形如果 IO 不翻转说明定时器配置有问题内核喊破嗓子也没用。确认中断触发之后再确认中断服务函数里有没有调用 QuarkTS 的 tick 处理函数。有些用户移植时把中断服务函数名字写错了比如平台库要求的名字和编译器启动文件里的名字不一致中断根本没挂上。另外常见的一个坑是定时器中断里忘了清除中断标志。SysTick 一般不需要手动清标志但通用定时器基本都要写清除寄存器。标志没清中断会反复进入QuarkTS 内部状态被连续推进任务表现就会异常比如周期明显变短或者优先级错乱。5.2 抢占调度不生效先查优先级配置再查临界区长度如果你配置的任务已经有高低优先级之分但高优先级任务并没有抢占低优先级任务先从两个方向排查。第一确认内核的抢占调度开关已经打开。有些版本默认是协作式调度高优先级任务必须等低优先级任务主动让出 CPU 才有机会运行此时任务的执行顺序还是“排队”的感知不到抢占。第二确认 tick 中断可以打断当前任务。抢占的前提是 tick 中断在高优先级任务就绪后能打断正在运行的低优先级任务。如果你的低优先级任务里有一段比较长的临界区而且临界区里把中断关了那这段临界区里发生的任何高优先级任务就绪事件都要等临界区退出后才能被响应。这本质是优先级被临界区延后了不是内核的 bug但容易误判。排查时可以设计一个测试低优先级任务里做个 10ms 的忙等高优先级任务每 2ms 翻转一次 IO。如果 IO 波形在这 10ms 内出现中断缺失说明有临界区过长或者中断被屏蔽的问题。逐段注释业务代码很快就能定位到是哪一段关过中断。5.3 时间触发任务抖动先怀疑打印和模块执行时间时间触发任务最理想的运行情况是“每 20ms 在任何情况下都准点启动”但很多因素会造成抖动。最常见的是任务内部执行时间太长导致本次还没跑完下一次 tick 就绪标志已经来了。这时候任务会表现为“连续执行”“周期错乱”看起来就像系统控制不住自己。解决思路有两个方向。一是给任务内耗时的模块做拆分把重活拆给多个任务级联处理二是适当调大周期任务的周期值给任务留足执行余量保证在一个周期内完成任务且有空闲余量。还有一个容易忽视的因素任务里的调试打印。调试阶段用串口打印特别爽但一个printf可能占用几十万个时钟周期把本来美如画的时间触发表完全打乱。我的习惯是调实时性的时候把打印任务单独做成低优先级任务或者干脆用调试器在线看变量别让打印干扰时间敏感任务。5.4 跑一段时间随机死机查栈溢出和内存越界内核本身资源开销很小但如果业务任务函数里使用了较大的局部数组或者调用了递归函数任务栈就很容易爆掉。QuarkTS 的任务栈内存可以来源于静态数组也可以来源于配置好的内存池栈溢出不一定马上崩溃往往是某次临界区嵌套或者长中断之后突然复位排查起来很头疼。我的排查顺序是这样的先看编译器的链接 MAP 文件确认 QuarkTS 的任务栈区域分配在哪个段以及总 RAM 占用率是多少。再在任务函数入口放一个栈高水位标记周期性检查任务栈剩余空间。如果发现某个任务栈剩余空间一直在下降说明这个任务的局部变量太大或者调用链太深需要加大栈配置或者优化代码结构。另外要特别注意不要在任务函数里动态分配大块内存不要在中断里调用可能分配内存的函数。QuarkTS 默认不依赖 malloc 的轻量特征一旦你自己开了动态内存就可能引入堆碎片问题表现为“跑几天才崩一次”这种问题比栈溢出更隐蔽。5.5 一张自检清单移植前逐项打钩检查项做法tick 周期是否满足业务精度用定时器实测别依赖理论计算定时器中断标志是否清除检查芯片手册确认清标志寄存器中断服务函数名是否匹配对比启动文件别写错名字临界区是否嵌套安全确认退出时能正确恢复原状态优先级数量是否覆盖所有任务只多不少避免运行期断言失败时间触发任务是否有足够执行余量周期要大于任务最坏执行时间任务内是否用了阻塞式 printf实时性敏感场景下严禁栈高水位是否监控上线前至少做 72 小时压力测试如果你遇到的问题不在上面列表里还有一个笨但有效的办法把业务任务一个个注释掉只剩 LED 心跳任务看问题是否复现。如果复现问题在内核移植配置层面如果不复现了问题在某一个业务任务里。这种二分排除法在嵌入式排障中永远不过时。移植 QuarkTS 这件事本质上是在一个已经足够好用的裸机循环之上增加了一层“调度维度”。一旦这条链路打通你会发现写多任务 Demo 的速度明显变快很多以前靠状态机硬啃的逻辑现在拆成几个事件触发任务就清清楚楚了。我个人实际体会是不要一上来就追求把所有业务都塞进 QuarkTS先花半天把双任务和串口打印链路跑通再逐步把按键、显示、通信模块一个一个迁进来。每一步都有可观测的现象每迁完一个模块就打包验证一次这个节奏比一次性大改造稳妥得多。后续如果想把 CPU 占用率、任务调度延迟这些指标做可视化也可以考虑接一个轻量级调试工具但那是移植成功之后的故事了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表