首页 / 资讯中心 / 文章详情

QuarkTS移植实战:Cortex-M单片机协作式调度内核迁移全记录

QuarkTS移植实战:Cortex-M单片机协作式调度内核迁移全记录 ★ FEATURED ARTICLE
如果你手头的单片机项目开始出现几十个状态、二十多个定时事件裸机主循环里堆满了标志位和嵌套的 switch-case那你大概率会和我一样在某天调试到深夜之后动了移植一个小型操作系统的念头。我最后选的是 QuarkTS一个用 C 编写、以协作式调度为核心的单片机系统内核。这篇文章不逐行分析它的源码而是完整记录我从零开始把它迁移到一颗 Cortex-M 内核 MCU 上的全过程——选型逻辑、内核原理、移植步骤、实测验证以及后来踩进去又爬出来的坑。1. 为什么是 QuarkTS裸机协程不够用之后的选型过程1.1 裸机主循环写到最后的样子先说清楚我为什么要折腾这件事。之前的项目是个带传感采集、按键处理、显示刷新和简单联网协议的小设备刚开始用裸机主循环写得很顺手while (1) { if (flag_10ms) { read_sensor(); } if (flag_50ms) { update_display(); } if (flag_100ms) { process_protocol(); } handle_key_event(); }等到功能越来越多标志位从 3 个变成 15 个每个 if 块里又套着若干子状态代码就慢慢失控了。有一次显示刷新和协议处理都要访问同一份缓冲区我在主循环的临界区里加了又删、删了又加改了两天。后来还出现一个很难复现的 bug按键事件触发了一次状态切换数据没来得及刷新下一帧直接用了旧指针。那段时间我意识到问题不在于代码写得不够小心而在于“裸机 标志位”这种模型已经不适合这个项目的复杂度了。1.2 为什么不考虑传统抢占式 RTOS普通认知里单片机要上系统第一反应是上传统抢占式 RTOS。我一开始也这么想但认真对比之后就犹豫了。传统抢占式 RTOS 的强项是硬实时和多优先级抢占适合电机控制、通信协议栈这类对响应时间极其敏感的场景。但我的应用大部分是数据采集和事件响应要求没那么苛刻。相反传统 RTOS 带来的问题却很实在任务多了之后优先级反转、临界区保护、内核对象初始化这些概念都要重新学团队维护成本高系统本身占用的 ROM/RAM 不小如果外设驱动还要相互独立整体资源开销会明显上升抢占式调度意味着一个任务可能在任何位置被切走所有共享资源的访问都要加锁排查并发问题比裸机还麻烦。所以我真正需要的不是“更厉害的抢占”而是“让代码组织更清晰”的调度模型。QuarkTS 的协作式调度正好切中了这个需求它不抢占任务主动让出 CPU所以临界区的处理更简单它支持时间片轮转能模拟多任务“同时运行”它还带状态机扩展和事件驱动 API处理复杂业务逻辑时比裸机 switch-case 舒服得多。1.3 QuarkTS 到底能带来什么QuarkTS 的定位我理解下来是“面向资源受限 MCU 的轻量级事件驱动任务框架”。它不是要替代传统实时内核而是用一套更轻的机制把裸机里最难维护的“调度 状态 通信”统一起来。比较吸引我的几个点协作式多任务调度核心代码很小对 Flash 不敏感静态内存设计任务栈和内核对象在编译期或显式配置不会出现堆碎片内置软件定时器、信号量、事件标志、消息队列任务间通信够用状态机扩展可以直接把原本用 switch-case 写的状态切换改成事件驱动的状态机任务全套 C 语言没有依赖特定编译器特性移植窗口很窄。我最后决定用它还有一个很实际的原因它允许我保留大部分裸机外设驱动只是把“调度逻辑”交给内核而不是像传统 RTOS 那样要求重写所有驱动。2. 先摸清内核脾气协作式调度、时间片与事件驱动的关系2.1 协作式调度模型任务不主动让出CPU 就一直属于它协作式调度的核心规则特别简单当前任务不主动让出调度器不会强制切换它。这意味着每个任务函数内部必须有让出点通常是一个延时或者事件等待。把这个类比成开会传统 RTOS 是主持人可以随时打断任何发言人QuarkTS 是发言人自己说完了才把话筒交回去。优点是不会发生任务间争抢临界区问题少缺点是如果有个任务写了个死循环且不延时整个系统就卡死。所以写 QuarkTS 任务有个铁律任务函数不能长时间占用 CPU循环体内必须有qOS_Sleep或qOS_Yield之类的让出动作。我刚开始移植时把一个原本裸机里的阻塞式传感器读取整个丢进任务里结果其他任务全部饿死。后来改为“启动读取 → 让出 → 等待完成标志”的事件驱动写法调度才恢复正常。2.2 时间片轮转多任务“同时运行”的样子纯粹协作式调度有个缺陷如果一个任务每次只跑很短就主动让出还行但如果几个任务都需要频繁执行且没有一个合适的天然让出点调度器就会显得“卡顿”。QuarkTS 给出的解法是时间片轮转。内核维护一个就绪任务链表当前任务运行超过预设的时间片长度后即使它没有主动让出系统节拍中断里也会把执行权切给下一个任务。这样从外部看多个任务像是在并行运行。时间片实现的细节和 tick 频率有直接关系。假设我把系统节拍配置成 1 kHz那么一个 tick 就是 1 ms如果时间片设为 5 个 tick那么一个任务最多连续运行 5 ms。这个值选多大需要权衡太小任务频繁切换上下文开销增加太大任务之间响应的间隔变长事件等待的延迟也跟着变大。我实际测试下来在 48 MHz 主频的 MCU 上时间片设 510 ms 比较合理既要让出点密集的事件型任务能快速响应又不至于让 CPU 全耗在切换上。2.3 事件驱动与状态机QuarkTS 的另一半任务调度只是 QuarkTS 的表面真正让项目代码变得容易维护的是它的事件驱动模型和状态机扩展。裸机里做状态切换最常见的是同一个函数里判断current_state再根据标志位跳转。状态一多这个函数就会膨胀而且状态切换的入口出口很难统一管理。QuarkTS 的思路是把状态切换本身建模成事件外设中断产生事件、放入队列、唤醒对应任务任务内部的状态机根究当前状态和接收事件决定下一步动作。我举个实际例子按键处理任务裸机逻辑要轮询 GPIO、处理消抖、再在 key_flag 里记录主循环再判断。QuarkTS 里我可以创建一个按键任务它等待一个“按键事件”进入队列消抖逻辑放在状态机里每次只处理一个事件处理完继续等待下一个。代码的可读性和变更难度改善很明显。3. 工程落地前三件事裁剪源码、确认工具链、检查中断资源3.1 源码目录与最小裁剪思路从开源仓拉下来 QuarkTS 源码后第一眼看上去文件不少但真正移植不需要全部编译。我用的版本核心文件大概就这几类QuarkTS.h/QuarkTS.c内核主体任务管理、调度、内核对象定义都在这里quarkts_os_config.h全局配置文件所有特性开关和参数都在这里移植层文件负责提供系统 tick、临界区接口通常是QuarkTS_port.h/QuarkTS_port.c一些可选组件文件状态机、软件定时器、事件框架等按需包含。我的建议是第一次移植先做“最小系统”只保留任务调度、延时、信号量或队列其中一个通信组件其他全关掉。这样万一跑不起来排查范围小很多。裁剪时注意看配置头文件里的宏开关我那次只打开了Q_USE_OS_TIMER和Q_USE_SIGNAL其余组件全部置 0。编译出来内核占用的 Flash 很小在动辄几十 KB 的 MCU 上完全无压力。3.2 构建环境与工程组织我平时习惯用命令行构建所以移植时选了 arm-none-eabi-gcc 加 CMake。如果用的是图形化 IDE逻辑一样只要把源码文件添加进工程、设置好头文件路径就行。给一个最小 CMakeLists 思路cmake_minimum_required(VERSION 3.16) project(quarkts_port_demo C) set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR cortex-m) add_executable(demo src/main.c src/quarkts_port.c src/QuarkTS.c ) target_include_directories(demo PRIVATE src include ) target_compile_options(demo PRIVATE -mcpucortex-m0plus -mthumb -Os -Wall )编译选项里的-Os建议保留Keil 里对应的是-OsizeQuarkTS 这种小型内核在优化体积时表现更好对运行逻辑没影响。还有一个新手容易忽略的点QuarkTS.c 里有些编译器相关的分支如果编译器不是 GCC需要到源码里确认对应宏是否被正确识别。我用 GCC 没遇到问题但看到一个针对其他编译器的分支宏说明作者对移植是有预期的。3.3 目标 MCU 的资源盘点移植前最好把目标板的中断和内存资源理清楚不然后面调到半夜发现资源不够就难受了。三个必查项系统节拍来源我用的 Cortex-M 内核优先选择 SysTick一个现成的 24 位递减定时器不用额外占用外设定时器。如果目标内核没有 SysTick则要选一个通用定时器能产生 1 kHz 左右的中断即可。节拍频率不一定要 1 kHz但换算延时时间比较方便我图省事选的就是 1 kHz。内存分配QuarkTS 的静态内存模型下任务栈是自己显式提供的。每个任务的栈大小取决于任务内部函数的嵌套层级、局部变量大小、调用 printf 时的格式化需求。我移植时给每个任务都先分了 512 字节后来实测发现日志任务因为snprintf格式化浮点数栈吃紧改成 768 字节才稳定。中断优先级分组临界区如果通过关中断实现中断优先级分组要提前统一。内核在临界区里会关中断如果某个外设中断的优先级比系统节拍还高在临界区里触发了会很麻烦这点后面展开说。4. 核心移植过程配置内核、编写移植层、启动流程4.1 配置文件的几个关键开关QuarkTS 的配置文件是所有移植工作的起点我按最小可运行系统来改主要关注这几个#define Q_TICK_FREQ_HZ 1000u // 系统节拍频率 #define Q_SCHED_MAX_NUMBER_TASKS 8u // 最大任务数 #define Q_USE_OS_TIMER 1u // 软件定时器 #define Q_USE_SIGNAL 1u // 信号量 #define Q_USE_TASK_QUEUE 1u // 消息队列 #define Q_USE_TIMESLICING 1u // 时间片轮转 #define Q_TIMESLICE 5u // 时间片长度单位 tick这里最容易踩坑的是Q_TICK_FREQ_HZ与延时函数的关系。内核所有延时的单位最终都会换算成 tick如果你把节拍频率设成 500 Hz但任务里qOS_Sleep(100)想表达 100 ms实际结果会完全不同。我一开始看文档不仔细改了频率没换算结果闪烁任务频率直接翻倍。Q_SCHED_MAX_NUMBER_TASKS也不是越大越好它会直接决定内核维护的静态任务表大小RAM 紧张时要按实际需求设置。4.2 移植层要实现的接口移植层是这次工作的核心负责把内核和具体 MCU 的中断、时钟、临界区对接。整体要实现的接口不多大概三类。第一是系统节拍。SysTick 中断里要调用内核的 tick 处理函数void SysTick_Handler(void) { qOS_System_Update(); // 通知内核一个 tick 到来 }如果不用 SysTick而是用通用定时器那就是在对应定时器的中断回调里调用同样的函数。这个函数会检查是否有任务延时到期、时间片是否用完、软件定时器是否需要触发。第二是临界区。内核在操作任务链表和内核对象时需要短暂关闭中断防止节拍中断插入引发数据竞争。Cortex-M 上最简单的实现是void qOS_EnterCritical(void) { __disable_irq(); } void qOS_ExitCritical(void) { __enable_irq(); }实际工程里我还给这两个函数加了一层嵌套保护用一个静态变量记录进入次数只有最外层退出时才真正开中断避免临界区嵌套时提前放开。这个细节是后来调试一个诡异 bug 时发现的因为外设中断里也用了临界区函数嵌套出了问题导致中断没有恢复。第三是任务切换相关。协作式调度下上下文切换不像抢占式那样依赖 PendSV 保存现场通常在启动时做一次初始化即可。我检查下来QuarkTS 的协作式模型对上下文的要求比传统 RTOS 简单很多只要保证任务函数的调用栈正常就行。4.3 系统启动流程和 main 函数整个启动流程比传统 RTOS 还简洁main 函数里大致是三步#include QuarkTS.h void LedTask(void *param); void LogTask(void *param); int main(void) { // 1. 硬件初始化 // Board_Init(); // 2. 内核初始化 qOS_Init(NULL); // 3. 创建任务 qOS_Add_Task(LedTask, led, 0, 512, NULL, 1); qOS_Add_Task(LogTask, log, 1, 768, NULL, 1); // 4. 启动调度器 qOS_Start(); return 0; }注意创建任务必须在qOS_Start()之前完成。调度器启动后不会再等你注册新任务这一点与传统 RTOS 一样。所有外设初始化既可以在内核启动前做也可以在第一个任务的入口做我习惯放在 main 开头这样任务函数里只关心业务逻辑看起来更清爽。任务函数本体要遵循协作式调度的规则不能 returnvoid LedTask(void *param) { (void)param; while (1) { toggle_led(); qOS_Sleep(200); } }这个任务每隔 200 ms 翻转一次 LED每次执行完马上延时让出 CPU正是协作式调度最经典的写法。5. 用两个任务验证调度节拍、信号量与队列的实测记录5.1 任务注册与最小编译我第一次跑通的验证代码很简单一个 LED 闪烁任务一个日志打印任务。void LedTask(void *param) { (void)param; static uint32_t counter 0; while (1) { set_led(counter % 2); counter; qOS_Sleep(200); } } void LogTask(void *param) { (void)param; while (1) { printf(tick: %lu\r\n, (unsigned long)qOS_Get_Tick()); qOS_Sleep(1000); } }烧录后现象符合预期LED 以 200 ms 间隔闪烁串口每秒打印一次 tick 计数。这说明两个任务都已经正常挂载到调度器上节拍中断也在正常工作。有一个容易忽略的细节qOS_Get_Tick()获取的是系统运行以来的 tick 数如果日志打印的间隔恰好和 LED 闪烁周期重叠说明任务的睡眠让出是精准的。如果数值出现漂移多半是节拍频率配置或临界区代码有问题。5.2 用队列验证任务间通信任务能跑起来只是第一步接下来我验证了内核对象——队列。我写了一个简单任务通信按键任务模拟产生事件日志任务消费事件。队列创建后大小固定为 4 个槽位。static QQueue_t keyQueue; static uint8_t queueStorage[4 * sizeof(uint32_t)]; void KeyScanTask(void *param) { (void)param; uint32_t event 0; while (1) { event; qOS_QueueSend(keyQueue, event, Q_NO_WAIT); qOS_Sleep(50); } } void ConsumerTask(void *param) { (void)param; uint32_t event; while (1) { if (qOS_QueueReceive(keyQueue, event, Q_WAIT_FOREVER) Q_TRUE) { printf(event: %lu\r\n, (unsigned long)event); } } }这个测试的关键是验证队列在“等待”状态下能否正确唤醒任务。消费者任务在没有事件时一直挂起不消耗 CPU直到生产者任务发出事件调度器才把它重新放入就绪队列。实测中串口打印频率严格跟随生产者任务 50 ms 一次的发送频率说明队列的内核唤醒逻辑正常。5.3 从波形和串口日志判断调度是否正常单纯用 printf 观察调度在一些时序敏感场景不够直观所以我还会用 GPIO 翻转加示波器看任务周期。方法是给每个任务加一个调试 GPIO任务每执行一次就翻转一次。示波器上能看到两条方波分别对应两个任务的执行周期。比如一个任务固定 100 ms 周期另一个固定 200 ms两者相位互不影响就说明时间片轮转和延时唤醒都没问题。如果方波周期出现抖动我第一个排查的是 SysTick 优先级和外设中断的交互第二个是临界区执行时间。临界区每次关中断的时间越长可能错过的 tick 就越多这也直接影响任务周期精度。6. 移植后复盘栈、中断优先级与配置项的坑6.1 SysTick 优先级设置不当导致的任务周期抖动移植初期我把 SysTick 中断优先级设得很高想着系统节拍越快越准。结果发现某个串口中断处理时间稍长时任务周期反而出现偶发抖动。原因不复杂串口中断在高优先级上运行SysTick 被串口中断打断节拍脉冲出现了间隙。更麻烦的是如果我在串口中断里调用了带临界区的函数而临界区关的正好又是 SysTick 中断那 SysTick 脉冲丢失得会更明显。正确的做法是把 SysTick 优先级设置成低于关键外设中断但高于普通任务代码。这样外设中断可以先抢跑处理完再补节拍。我调整之后波形稳定多了。6.2 任务栈空间的低估与高水位检查协作式调度下任务栈虽然不像抢占式那样需要保存大量上下文但任务内部函数的调用深度和局部变量仍可能撑爆栈。一次日志任务把snprintf的浮点格式串带进去了栈从 512 字节调到 768 字节才稳定。调试方法是在移植层里给每个任务初始化为固定模式比如全部填成 0xAA任务跑一段时间后查看尾部 0xAA 的剩余量就知道高水位大概在哪。这个技巧很土但在嵌入式里非常有效。经验值方面简单 IO 翻转任务 256 字节就够带标准库格式化输出的任务建议从 768 字节起网络协议栈相关任务起码 1024 字节起步但最终都要用实测高水位确认。6.3 配置项的隐藏坑延时单位、任务 ID 与返回值最后记录几个容易犯的低级错误。第一个是延时单位。qOS_Sleep()的参数在不同版本里可能“正好”都是毫秒但如果内核内部用的 tick 频率变了延时换算会出错。最稳妥的办法是用qOS_Sleep_Until()这类基于绝对 tick 的接口避免累积漂移。第二个是任务 ID 不能重复。我移植时为了省事两个任务都填了 0结果编译正常但运行时调度异常。原因是任务表按 ID 索引重复会导致后注册任务查不到。这种错误不会报编译错误排查起来尤其耗时间。第三个是任务函数不能返回。协作式任务如果函数返回会导致调度器跑去执行未知数据万一 return 到无效地址就得靠看门狗自救。所以每个任务函数最外层都应该是while(1)这是协作式开发约定俗成的规则。回头复盘这次移植最值钱的倒不是“成功跑起来了”而是理解了协作式调度和事件驱动模型带来的编程范式变化。QuarkTS 把任务、状态机、队列这些抽象全部集中在一个很小的内核里既没有学习传统 RTOS 那么多概念又把裸机里最难维护的部分重新组织了一遍。如果你手里的项目也卡在多状态、多事件的泥潭里不妨拿一块吃灰的开发板花一天时间把 QuarkTS 跑起来感受一下任务让出和事件唤醒到底和裸机标志位有什么不同。
阅读完成 · 觉得有帮助?
咨询建站