
几个月前我被手上一台 32 位单片机搞得焦头烂额一个温控仪功能并不复杂就是按键面板、一路温度传感器、一组继电器输出外加数码管显示。结果裸机主循环里塞满了 debounce 标志、采样定时标志、串口协议状态机光全局标志就定义了十几个。直到我认真试了 QuarkTS 这个轻型调度内核才算是把单片机软件从“一堆 if 的迷宫”里解放出来。这篇文章不是讲 Kernel 源码解析也不是官方文档翻译而是我自己从零开始、在一颗普通 MCU 上完成移植的完整记录和踩坑总结。给单片机“移植操作系统”这个词听起来吓人但实际上 QuarkTS 并不要求你去改内核源码它更强调“配置 接好两个硬件依赖”。真正需要你亲自动手的只有系统 tick 源、临界区保护再加上任务划分。下面我把整个过程拆开讲。1. 为什么我不建议一上来就上 FreeRTOSQuarkTS 的定位差别1.1 裸机主循环到了什么阶段会撑不住我见过不少人的单片机项目是这样一个套路主循环里写一个巨大的 while(1)每隔 10ms 扫描一次按键每隔 50ms 刷新一次数码管每隔 200ms 读一次传感器中间再穿插串口接收解析。最初功能少这种写法没什么问题一旦业务变多你会发现每个功能模块都要“见缝插针”地挤进这个大循环修改一个地方的延时另一处的响应时间就跟着漂。更难受的是中断里攒了一堆事件标志主循环里到处查标志。查着查着代码就变成一团毛线。这个阶段你不是缺一个功能而是缺一套“如何组织多个并发业务”的框架。往小了说这是软件结构问题往大了说你需要一个能在小型 MCU 上跑起来的多任务环境。但这里有个陷阱多数人第一反应是上实时操作系统比如抢占式调度的那一类。1.2 协作式调度与抢占式调度的一字之差QuarkTS 本身是协作式调度内核和常见的抢占式 RTOS 有本质区别。抢占式内核会在任意时刻打断一个任务切换给另一个高优先级任务这需要完整的上下文切换、优先级反转保护、互斥量等机制。而协作式调度器依赖每个任务主动让出 CPU内核只负责维护“该轮到谁跑”不需要在任意位置保存和恢复寄存器现场所以代码量和 RAM 占用都能压得非常低。我用一个表把差别讲清楚对比维度抢占式 RTOS协作式调度QuarkTS 这一类切换时机任何时刻由调度器决定任务主动让出yield或等待事件上下文切换开销较大需要保存完整寄存器组小执行流相对可预测共享资源保护需要互斥量/信号量/禁中断天然少很多竞争配合临界区即可任务最长运行时间由时间片/优先级约束靠任务自律一个任务死循环其余全卡适用 MCU中高端RAM 几十 KB 以上小 RAM、定时器资源少、业务为事件驱动关键的“一字之差”就在这里抢占式靠强制协作式靠自觉。对大多数工业传感器、小家电控制、仪器仪表这类项目来说业务天然是“轮询 事件响应”协作式不仅能满足需求还省掉了大量同步机制。你不需要写互斥锁不需要担心优先级反转写代码的心智负担小很多。1.3 到底什么时候才算“从零开始移植”很多官方移植教程都假设你有一块厂商的开发板直接用现成工程配置。而我这次说的“从零开始”是指你手头只有一颗裸 MCU、一块自绘 PCB没有现成模板所有东西从芯片启动文件开始搭。这种场景下QuarkTS 反而是很友好的选择它不绑定某个特定内核不要求你一定用 SysTick也不要求你有 RTOS 专用的 heap 管理只要你把 tick 和临界区接好剩下的就是业务任务划分。如果你的项目满足下面几条我觉得可以放心往下走任务数量在几个到十几个之间不需要复杂的优先级抢占大部分工作是周期性轮询或者等待事件后短时间处理RAM 资源紧张不希望在底层机制上耗掉太多空间你希望代码易读、易调试不想处理太多异步打断。2. 硬件相关的只有两条腿系统 tick 与临界区2.1 tick 是调度器的心跳但不是时间片在传统 RTOS 里tick 是时间片轮转的基础一个 tick 之后调度器可能强行切换任务。QuarkTS 的 tick 不是这个角色它主要用来驱动软件定时器、超时判断、时间戳等待。换句话说tick 是心跳负责“记时间”不是负责“抢 CPU”。这意味着 tick 周期选择不用太激进。我见过有人为了“实时性”把 tick 设到 1kHz、甚至 10kHz结果任务都在等着让出中断却把 CPU 消耗了大半。一般来说 1kHz1ms 一次已经比较稳如果你的业务对超时不敏感用 100Hz10ms也行。我的建议是先用 1kHz理由很简单QuarkTS 里的事件等待、延时函数、软件定时器大多以 tick 为单位1ms 粒度对大多数传感交互足够了而且 32 位 MCU 处理一次 tick 中断的耗时通常在几微秒以内。2.2 SysTick 配置与重装载值计算Cortex-M 内核自带 SysTick这是最省事的 tick 源。它本质上是一个 24 位递减计数器减到 0 时触发中断并自动重装。配置的核心是算重装载值重装载值 SysTick 输入时钟频率 / 目标 tick 频率 - 1如果你用内核时钟做 SysTick 时钟源假设 MCU 主频是 72MHz目标 1ms tick那么重装载值 72_000_000 / 1_000 - 1 71999用 CMSIS 接口一句话就能完成初始化volatile uint32_t g_tick_count 0; void BSP_Tick_Init(void) { SysTick_Config(SystemCoreClock / 1000); /* 1ms 一次中断 */ NVIC_SetPriority(SysTick_IRQn, 0xFF); /* 优先级放到最低 */ }SysTick_Config 是标准 CMSIS 接口它会自动设置重装载值并开中断。SysTick 优先级我建议给到最低原因后面“踩坑日志”里专门说。如果芯片没有 SysTick那就随便挑一个通用定时器的更新中断用同样的思路配置溢出周期 1ms中断里做同一件事。2.3 临界区调度器对中断的“短暂禁令”临界区用于保护共享数据。当任务正在修改一个结构体、一个变量时突然来了中断中断里又改了同样的数据那么任务恢复后拿到的数据可能是半新半旧的。QuarkTS 在操作系统内部会用临界区保护自己的任务链表你写业务代码时凡是涉及任务与中断共享的变量也应该自己加临界区。Cortex-M 上最简单可靠的临界区就是开关全局中断#define ENTER_CRITICAL() __disable_irq() #define EXIT_CRITICAL() __enable_irq()__disable_irq 实际操作的是 PRIMASK 寄存器也就是把除 NMI 和 Fault 之外的所有中断都屏蔽掉。它执行时间极短对实时性影响很小。关键是临界区代码必须短不要在里面做延时、打印、或者等待外设。临界区是给“改一个变量、插入一个节点”这种微操作用的不是给你跑业务逻辑用的。至于 QuarkTS 自身怎么调用临界区不同小版本的宏名可能不同。你下载源码后在配置头文件里都会找到类似“临界区实现方式”的地方把我上面这两个宏填进去即可。如果你用的 MCU 不是 Cortex-M比如是老 8 位单片机那就用它的全局中断开关汇编指令原理完全一样。3. 按需裁剪让内核为这台单片机“瘦身”3.1 配置头文件要回答的三个问题拿到 QuarkTS 源码后别急着往工程里拖先打开配置头文件。它就像一张问卷问你这台单片机准备承担多少业务。核心问题大概有三个最多同时存在多少个任务这里说的是“已创建的任务”不是“可能运行的任务”按峰值算。需要用多少个软件定时器、消息队列、事件标志这些资源每种都要预先分配内存宁少勿多。要不要开启某些高级特性比如粘性调度、任务状态机辅助等用不上就关闭。我自己的习惯是先把业务盘一遍。比如一个温控项目用到的任务可能就是按键扫描、LED 刷新、传感器采集、控制运算、串口协议处理一共五六个。软件定时器可能要用一两个做超时看门。消息队列一个放传感器数据。事件标志两三个做唤醒。那就按这个量配置不要一上来拷贝一个大工程的全功能配置否则一个 32KB RAM 的芯片很容易被各种池子吃掉一大半。3.2 任务栈给每个任务划一块“私有内存”协作式调度下任务函数本质是普通函数的持续调用过程函数里的局部变量、函数调用链上的临时量都放在栈上。QuarkTS 要求你为每个任务准备一段独立的栈空间典型做法是静态数组#define TASK_STACK_SIZE 256 /* 单位word */ static uint32_t task_led_stack[TASK_STACK_SIZE]; static uint32_t task_key_stack[TASK_STACK_SIZE];这里的 256 是“字”不是“字节”。在 32 位 MCU 上256 字等于 1KB RAM 空间。为什么不用动态分配嵌入式系统动态分配容易产生碎片也增加不确定性静态数组简单、可预测、还能在调试器里直接查看。如果你手头 RAM 紧张可以精简任务里的局部变量然后逐步下降栈大小直到逼近临界值。注意如果任务里调用了 printf、浮点格式化这类工具栈需求会猛涨单这种调用就可能吃掉几百字节。所以我一般在协议解析任务里禁止浮点格式化宁可手动转字符串。关于栈大小是否够我强烈建议做一个“填坑测试”在创建任务前用特定填充字节比如 0xAA把整块任务栈刷一遍运行一段时间后检查栈尾还剩多少 0xAA 没被覆盖。这个技巧简单但极其有用能直观告诉你每个任务真实峰值栈用量是多少。3.3 裁剪后如何验证 ROM 和 RAM 预算配置完编译一次编译器会输出 code 和 data 的占用统计。以我那个项目为例QuarkTS 内核自身 ROM 开销大概在几 KB 量级RAM 开销大头是你给任务、队列、定时器分配的静态数组而不是内核本身。如果你发现静态数据比预期大很多别急着怀疑内核回去看自己是不是任务栈开得太大、消息池塞了太多条。我见过有人把每个任务栈都开了 4KB美其名曰“保险”结果一个本来 16KB RAM 的芯片直接爆掉。正确的姿势是先按业务估算一个保守值跑起来之后用前面说的填充法实测再反复压缩到安全边界以内。嵌入式开发里“内存是挤出来的”一点都不夸张。4. 第一个任务跑起来从 main 到调度循环的完整骨架4.1 main 函数的初始化顺序任务栈、内核配置都准备好以后main 函数里的顺序就成了关键。我的模板大致是这样#include quarkts.h #include bsp.h /* 任务函数声明 */ static void Task_Led(void *arg); static void Task_Key(void *arg); /* 任务栈 */ static uint32_t stack_led[128]; static uint32_t stack_key[256]; int main(void) { BSP_Init(); /* 时钟、GPIO、串口等基础外设 */ /* 1. 内核初始化接口具体以你的版本头文件为准 */ QK_Init(); /* 2. 创建任务 */ QK_TaskCreate(Task_Led, QK_PRIO_LOW, stack_led, sizeof(stack_led), NULL); QK_TaskCreate(Task_Key, QK_PRIO_HIGH, stack_key, sizeof(stack_key), NULL); /* 3. 启动调度器。启动后 main 通常不再返回 */ QK_SchedulerStart(); while (1) { /* 如果调度器返回说明配置有问题 */ } }一句话说明先初始化底层外设再初始化内核然后创建任务最后启动调度。顺序不要反。如果你在内核初始化之前就去调用延时有系统函数或者在外设还没跑起来前就创建依赖该外设的任务大概率会遇到奇怪的初始化失败。另外调度器一旦启动main 函数就被“接管”了后面不要再想着返回编译器和调试器都不喜欢这个假设。4.2 任务函数的生命周期与“永不返回”原则刚才代码里任务函数 Task_Led 和 Task_Key 都是我自己写的。它们的签名类似普通 C 函数都有一个 void* 参数但行为上有几条硬性规则任务函数应当在 for(;;) 里无限循环不要在末尾 return循环体内不能长时间占用 CPU要主动让出或者等待事件一个任务的任务函数入口会被调用一旦函数退出这个任务就彻底结束了这种情况要视为异常。协作式调度下任务函数就像合租室友里的一群人每个人用完厕所要主动出来否则其他人永远等不到。你在任务里写 while(1) 等待一个外部电平变化却不主动让出那等价于把整个系统都暂停了。4.3 一个 LED 闪烁 按键轮询的最小例子下面给一个最小但完整的例子你可以直接抄到自己的工程里改一改static void Task_Led(void *arg) { (void)arg; for (;;) { LED_Toggle(); QK_DelayMs(500); /* 固定延时 500ms内部会主动让出 */ } } static void Task_Key(void *arg) { (void)arg; for (;;) { if (Key_Scan() KEY_PRESSED) { Buzzer_On(); } else { Buzzer_Off(); } QK_Yield(); /* 主动让出 CPU */ } }注意看 Task_Led 里的 QK_DelayMs它不是裸机里的 delay 空循环而是在等待超时期间把 CPU 让给其他任务这正是协作式调度的魅力。Task_Key 每轮扫描一次后主动让出好让 LED 任务有执行机会。两个任务就这么互相配合谁也不把 CPU 独吞。接口名仍然以你下载版本的发布头文件为准但逻辑结构就是这样。4.4 第一个任务的验证方法跑起来之后第一件事不是写一堆日志而是先在调试器里看两个任务栈指针是否正常推进、调度器切了没有。我的土办法是在两个任务里各翻转一个 GPIO然后用示波器观察波形如果 LED 任务里的 GPIO 以 500ms 周期翻转说明延时任务工作正常按键任务里的 GPIO 高频翻转说明任务循环没有被卡死两个波形互不阻塞说明调度器切换成功。这个方法比 printf 可靠因为 printf 本身可能拖慢任务、引入不可预期的时序。等波形稳定了再考虑串口日志输出。5. 任务不是各干各的事件标志和轻量队列怎么用5.1 事件标志解决“等条件”而不是“查条件”裸机里判断条件最常见的写法是全局标志位比如if (adc_done_flag key_pressed_flag) { ... }任务少的时候行得通任务一多你就要考虑“谁在等这个条件”“条件满足后该醒谁”。QuarkTS 这类内核通常给你事件标志任务可以阻塞在某些位上等内核来通知。事件标志适合表达“某个事件发生了”这种逻辑比如传感器转换完成定时时间到串口收到完整一帧按键按下。用事件标志之后任务不再反复轮询而是“睡”在一个或多个位上条件满足后内核会自动把它唤醒。多人合租的场景里这就是“别人告诉你水开了”而不是“你一直盯着水壶”。代码语义更清楚CPU 利用率也更高。5.2 轻量队列传递数据事件标志只能表达“事情发生了”表达不了“发生了的具体内容”。比如温控项目里一个任务负责采样 ADC另一个任务负责控温采样值从哪传过去共享变量是一种方案但交叉访问容易出问题更好的方案是轻量消息队列。生产者任务把 ADC 结果塞进队列消费任务等待队列非空后取走/* 配置头文件里预留消息队列并指定一条队列尺寸 */ static void Task_Collect(void *arg) { for (;;) { uint16_t raw ADC_GetValue(); QK_QueueSend(adc_queue, raw, sizeof(raw)); QK_DelayMs(10); } } static void Task_Control(void *arg) { for (;;) { uint16_t raw; if (QK_QueueReceive(adc_queue, raw, QK_WAIT_FOREVER) QK_OK) { /* 根据 raw 计算 PWM 输出 */ Control_Temperature(raw); } } }队列的好处是解耦生产者和消费者各自按自己的节奏工作中间多出的数据由队列缓冲。队列深度别开太大我是根据生产周期和消费速度算的。比如生产任务每 10ms 发一条消费任务处理一条要 30ms那么队列至少要有三条左右的缓冲我一般再留 50% 余量开五条既安全又不浪费 RAM。5.3 软件定时器的合理姿势QuarkTS 一般还提供软件定时器这跟硬件定时器是两码事它适合“固定时间到了要做什么”的场景。比如设备需要每隔 2 秒做一次自检用软件定时器比在任务里延时更清晰。但要注意回调函数里不要做耗时操作否则同样会拖住调度器。我的习惯是定时器回调里只置事件标志真正的检查逻辑放到对应任务里去执行保证“通知”和“干活”分开。6. 我在实际移植过程中踩过的五个坑6.1 坑一tick 中断优先级设得比临界区还高最早我图省事把 SysTick 优先级设得很高想着“时间最重要”。结果很诡异任务切来切去偶尔死机而且不是必现。找了一下午发现原因系统临界区关闭全局中断后高优先级 SysTick 依然可以打入而 tick 处理函数里又试图修改内核的任务链表。临界区想保护的正是这份链表的一致性结果反被打断链表改到一半被改第二次系统当然崩溃。解决的规范做法是把 SysTick 优先级设到最低同时保证 ISR 里不调用任何内核操作接口只做最轻量的计时通知。如果你必须在高优先级中断里发数据也不要直接操作队列而是先放到一个 volatile 标记里由普通任务去“消费”。6.2 坑二在协作任务里用裸机 delay 磨时间新上手最容易犯的错就是把原来裸机里的软件延时函数原封不动搬进任务for (i 0; i 10000; i); /* 这就是个炸弹 */在裸机里这行代码只是让主循环慢一点在协作式调度器里它会让整个系统冻结到这个循环结束。因为协作式调度靠任务主动让出你一个空循环占住 CPU其他任务包括 tick 处理函数之外的业务全都等着。正确做法是把延时换成 QuarkTS 提供的延时等待接口或者干脆把“等待一段时间”设计成事件/定时器触发而不是死等。6.3 坑三栈溢出表现为各种“不可能”某天我发现一个任务运行一会儿后另一个毫不相干的变量被改成了 0x00就像闹鬼。调试器看变量值变了看代码路径又完全没写过这个变量。最后排查无果试着把疑似任务栈扩大一倍现象消失。这就是栈溢出的典型表现任务栈不够用函数局部变量压栈时越界把相邻的全局变量或另一个任务的栈踩掉了。从此以后我给自己定了一条规矩每个任务第一次跑通之后都要做“填坑测试”。初始化时把任务栈全部填成 0xAA正常运行几小时后检查栈尾字节是否还是 0xAA如果变了说明这任务运行过一段较深的调用链栈不够。这个办法虽然土但在小内存 MCU 上是真能救命。6.4 坑四编译器优化改变了共享变量的行为早期我有个共享变量从中断里更新主任务里普通读取。开调试模式正常一开 Level 2 优化任务那边看到的数据就明显“滞后”。原因很简单编译器认为那个变量在任务循环里没人改就把读取操作优化成只从寄存器拿值了。中断虽然改了内存里的值任务这边却还在用缓存的旧值。解决方式就两个字volatile。凡是中断和任务共享的变量一律用 volatile 修饰或者干脆通过队列/事件标志传递让编译器别做过度优化。还有一个小技巧别指望“把优化等级降低”就当修复了那只代表你掩盖了代码里的数据依赖问题。6.5 坑五分不清“阻塞等待”与“让出 CPU”事件标志等待、队列接收这类操作内部一般是阻塞等待也就是任务会被挂起让出 CPU直到事件到达。这是好的。但如果我在一个任务里写了这样的代码while (GPIO_ReadPin(PORT, PIN) HIGH) { /* 等待某个外部信号拉低 */ }这就变成了“死磕”外部信号不来这个任务永远占用 CPU其他任务全部停工。这不是内核的问题是协作式开发的纪律问题。凡是等待外部事件都要通过事件标志、队列或者延时轮询来让出 CPU绝不能用一个不加让出的 while 循环直接等。我的心得是写任务函数之前先在纸上把每个任务画成“事件流”。任务醒来、干活、让出、等什么、醒来之后干什么一步步画清楚再写代码坑就能避掉一大半。QuarkTS 不是万能的调度器它要求开发人员有清晰的协作意识但一旦适应了这套事件驱动的写法开发效率真的比在裸机里放大一堆标志高太多了。如果你也在考虑把某个小项目往 QuarkTS 上迁建议先从最精简的配置开始一个 LED 任务、一个按键任务、一个串口任务跑通之后再逐步加队列、软件定时器。第一版别追求功能齐全先把“让出 CPU”和“事件驱动”的感觉找到后面就顺了。