资讯动态

从零移植QuarkTS实时内核到ARM Cortex-M单片机

发布时间:2026/10/15 16:14:01 来源:尧图企业网站定制
1. 为什么要在单片机上跑一个小型操作系统很多人第一次接触单片机开发都是从裸机程序开始的。一个while(1)大循环里面塞上按键扫描、数码管刷新、串口收发能跑就行。但项目稍微复杂一点比如同时要处理按键、屏幕刷新、串口协议解析、定时任务裸机轮询的弊端就暴露出来了响应不及时、代码耦合严重、加一个功能就要动全身。这时候引入一个轻量级的实时操作系统RTOS就成了很自然的选择。QuarkTS 就是这样一个面向资源受限设备的小型操作系统内核。它的定位非常清晰占用资源少、代码量小、可裁剪性强适合那些 RAM 只有几 KB 到几十 KB 的单片机。和市面上一些主流的嵌入式实时内核相比QuarkTS 的调度器实现相对简洁源码可读性高对于想搞清楚“操作系统到底是怎么调度任务”的开发者来说是一个很好的学习样本。同时它也能胜任实际项目支持任务优先级调度、延时、事件标志、队列、状态机等常用机制。这篇文章要聊的就是怎么从零开始把 QuarkTS 移植到自己的单片机平台上。我会以一款常见的 ARM Cortex-M 内核单片机为例把移植过程中涉及的目录结构、底层接口、时钟配置、任务创建、编译调试这些环节全部拆开讲清楚。不管你是刚学完裸机、想进阶 RTOS 的新手还是做过其他内核移植、想换个轻量方案的老手都能从里面找到可以直接抄作业的步骤和踩坑经验。需要提前说明的是不同内核的单片机在寄存器层面差异很大但移植 QuarkTS 的核心思路是通用的准备好一个能跑的裸机工程把内核源码加进来实现几个必须的底层钩子函数配置好系统节拍然后创建任务验证调度。下面我按这个主线一步步展开。2. 移植前的整体设计与方案选型2.1 为什么选 QuarkTS 而不是其他内核在动手之前先想清楚选型逻辑比盲目开干重要得多。嵌入式实时内核这个领域可选项其实不少有偏工业级的、有偏教学级的、有主打认证的。QuarkTS 吸引我的地方主要有三点。第一是代码规模。它的核心调度部分源码量不大编译出来的代码尺寸对 Flash 紧张的单片机比较友好。我实测在一个 64KB Flash 的芯片上把内核基本功能全开占用也就几 KB 的量级留给应用的空间很充裕。第二是可读性。很多内核为了追求极致的调度效率用了大量宏和内联汇编读起来门槛很高。QuarkTS 的调度逻辑用 C 语言写得比较直白任务控制块的结构、就绪队列的维护、节拍处理流程顺着源码读一遍基本能理清。这对想深入理解 RTOS 原理的人来说价值很大。第三是可裁剪。它通过配置文件把各种功能模块开关化用不到的事件、队列、状态机可以直接关掉不参与编译。这种设计让内核能适配从极简到相对复杂的各种场景。当然选型也要看短板。QuarkTS 的生态和文档丰富度肯定比不上那些大厂维护的内核遇到冷门问题可能需要自己读源码解决。但对于中小型项目和学习者来说这个代价是可以接受的。2.2 移植工作的边界在哪里很多人对“移植操作系统”这件事有误解以为要把整个内核改一遍。其实不是。移植的本质是让内核能在你的硬件上正确运行需要你提供的只是硬件相关的部分内核本身的调度算法、数据结构是不用动的。具体来说移植工作可以拆成这么几块工程集成把内核源码目录加入工程配置头文件搜索路径让编译器能找到它。底层接口实现内核需要知道怎么关中断、开中断、触发一次任务切换、获取当前栈指针这些和 CPU 架构强相关必须由移植者提供。系统节拍配置内核靠一个周期性的定时中断来推进时间这个节拍源要接到单片机的某个定时器上。内存与栈管理给每个任务分配独立的栈空间内核需要知道栈的生长方向和初始化方式。验证创建几个任务观察调度是否符合预期。把这五块想清楚移植就有了清晰的路线图不会做到一半不知道下一步干嘛。2.3 目录结构怎么规划工程目录乱后期维护就是灾难。我习惯在移植前先把目录规划好一个推荐的结构是这样的project/ ├── app/ 应用代码任务实现放这里 ├── bsp/ 板级支持LED、串口等驱动 ├── kernel/ QuarkTS 内核源码 │ ├── src/ 内核 .c 文件 │ ├── include/ 内核 .h 文件 │ └── port/ 移植层和 CPU 相关的代码 ├── config/ 内核配置文件 ├── startup/ 启动文件、链接脚本 └── drivers/ 芯片厂商提供的外设库把移植层单独放在kernel/port/下面好处是内核源码保持原样不动将来升级内核版本时只需要替换src和include自己的移植代码不受影响。这个习惯我强烈建议养成否则内核一升级你改过的源码全得重新对一遍非常痛苦。3. 核心细节解析与实操要点3.1 内核配置文件怎么改QuarkTS 通常有一个类似qt_config.h的配置文件里面是一堆宏开关。移植第一步就是把它改成适合你项目的状态。常见的配置项有这么几类配置项类别典型宏作用建议功能开关事件、队列、状态机相关宏决定哪些模块参与编译按需开启不用就关任务数量最大任务数宏决定任务控制块数组大小按实际任务数留余量优先级范围优先级数量宏决定就绪队列的层级够用即可过多浪费 RAM节拍频率每秒节拍数宏决定时间片粒度一般 100 到 1000栈生长方向栈方向宏告诉内核栈是向上还是向下生长必须和 CPU 一致这里重点说节拍频率。它决定了系统最小时间单位。比如设成 1000那一个节拍就是 1 毫秒任务延时 10 个节拍就是 10 毫秒。频率越高时间精度越好但节拍中断越频繁CPU 花在调度上的开销越大。我一般先用 1000 起步如果发现中断负担重再降到 100 或 200。对于大多数控制类应用1 毫秒的粒度完全够用。栈生长方向这个配置特别容易出错。ARM Cortex-M 内核的栈是向下生长的也就是从高地址往低地址压栈。如果配置反了任务一运行栈就乱了表现是随机的死机或数据错乱非常难查。移植时一定要先确认清楚自己芯片的栈方向。3.2 底层接口函数逐个实现内核要跑起来必须由移植者提供几个关键函数。不同版本的内核函数名可能略有差异但功能是固定的。下面按功能讲。关中断和开中断。内核在操作就绪队列、任务控制块这些共享数据时必须保证原子性不能被中断打断。所以需要提供一对函数进入临界区时关中断退出时开中断。在 Cortex-M 上通常用操作PRIMASK寄存器的方式实现/* 关中断返回之前的中断状态 */ static inline uint32_t port_enter_critical(void) { uint32_t primask __get_PRIMASK(); __disable_irq(); return primask; } /* 恢复之前的中断状态 */ static inline void port_exit_critical(uint32_t primask) { __set_PRIMASK(primask); }注意这里不是简单地开中断而是恢复之前的状态。因为临界区可能嵌套如果内层退出时无条件开中断外层临界区就被破坏了。用保存和恢复的方式才能正确处理嵌套。这个细节很多新手会忽略导致偶发的数据竞争。触发任务切换。当有更高优先级任务就绪或者当前任务主动让出 CPU 时内核需要立刻切换。在 Cortex-M 上一般通过触发PendSV异常来实现。为什么用 PendSV 而不是直接在中断里切换因为 PendSV 的优先级可以设成最低这样它会在所有其他中断处理完之后才执行避免在中断嵌套中间做上下文切换导致混乱。触发方式就是往PendSV的挂起寄存器写一位static inline void port_yield(void) { /* 挂起 PendSV 异常等当前中断处理完再切换 */ SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; }上下文切换本体。这是移植里唯一需要写汇编的地方。PendSV 的中断服务程序里要完成当前任务的寄存器保存和下一个任务的寄存器恢复。Cortex-M 硬件会自动保存一部分寄存器R0-R3、R12、LR、PC、xPSR软件需要手动保存剩下的 R4-R11。流程大致是读当前任务控制块指针把 R4-R11 压到当前任务栈保存栈指针到任务控制块然后调用内核的调度函数选出下一个任务从新任务控制块取出栈指针弹出 R4-R11更新任务控制块指针最后异常返回。这段汇编建议直接参考内核自带的移植示例改改寄存器名和栈偏移就行不要自己从零写容易出错。获取和设置栈指针。内核创建任务时需要初始化任务的栈把栈顶填上初始的寄存器值让任务第一次被调度时能正确“返回”到任务入口函数。这需要移植层提供获取当前栈指针、设置栈指针的函数以及一个初始化任务栈的函数。初始化任务栈时要按 CPU 的压栈格式在栈里预先放好 PC指向任务入口、LR指向任务退出处理、以及各通用寄存器的初值。3.3 系统节拍定时器怎么接系统节拍是内核的心跳。它由单片机的一个定时器周期性产生中断中断里调用内核的节拍处理函数内核据此更新延时计数、检查超时、决定是否触发调度。选哪个定时器原则是选一个不和其他功能冲突、且精度足够的通用定时器。比如用 SysTick 是最省事的因为它是 Cortex-M 内核自带的配置简单不占用外设资源。但如果项目里已经有别的代码用了 SysTick那就换一个通用定时器比如 TIM2 之类。配置节拍中断的步骤计算定时器重装载值。假设系统时钟 72MHz想要 1kHz 节拍那定时器每 1ms 中断一次。重装载值 时钟频率 / 节拍频率 - 1 72000000 / 1000 - 1 71999。配置定时器为向上计数、自动重装载模式。使能定时器更新中断配置中断优先级。在中断服务程序里调用内核节拍处理函数并清除中断标志。中断优先级这里有个关键点节拍中断的优先级以及 PendSV 的优先级要合理安排。一般把 PendSV 设成最低优先级节拍中断设成中等优先级其他外设中断按需设置。这样保证上下文切换不会打断其他中断同时节拍能及时响应。注意节拍中断服务程序里只做最必要的事就是调用内核的节拍处理函数不要在里面写耗时逻辑。否则会拖慢整个系统的时间基准。4. 实操过程与核心环节实现4.1 准备一个能跑的裸机工程移植的前提是有一个稳定的裸机工程。这个工程要满足几个条件能正常编译下载、时钟配置正确、至少有一个能用的定时器、有串口能打印调试信息。我建议先用一个点灯程序验证工程没问题再开始移植。如果裸机工程本身就有毛病移植过程中出了问题你根本分不清是内核的锅还是工程的锅。时钟配置尤其重要因为节拍定时器的重装载值依赖系统时钟频率。如果时钟配错了节拍就不准任务延时全部偏掉。我一般会在时钟初始化后用串口打印一下实际的主频确认和预期一致。4.2 把内核源码加入工程把内核的src和include目录复制到工程里然后在 IDE 或 Makefile 里添加源文件、配置头文件搜索路径。这一步看起来简单但有几个坑源文件要全部加入编译漏掉一个就会报未定义符号。头文件路径要包含内核的 include 目录和你的 config 目录否则找不到配置头文件。移植层的文件也要加入编译包括那个汇编文件。有些 IDE 默认不编译.s文件需要手动设置。加完之后先编译一次这时候大概率会报错因为移植层的函数还没实现。报错是正常的根据报错信息一个个补函数就行。4.3 实现移植层并初始化内核按前面讲的把关中断、开中断、触发切换、上下文切换、栈初始化这些函数实现好。然后写一个内核初始化函数在里面做几件事void kernel_init(void) { /* 初始化内核内部数据结构 */ qt_init(); /* 配置系统节拍定时器 */ tick_timer_init(1000); /* 1kHz */ /* 初始化移植层比如设置 PendSV 优先级 */ port_init(); }port_init里主要设置 PendSV 和 SysTick如果用的话的优先级。Cortex-M 的优先级寄存器是每 8 位对应一个异常设置时要注意移位。4.4 创建任务并验证调度内核初始化完就可以创建任务了。创建任务一般需要指定任务函数、任务名、栈空间、栈大小、优先级、参数。栈空间可以静态分配一个数组也可以从堆里动态分配。对于资源紧张的单片机我推荐静态分配避免堆碎片。/* 静态分配两个任务的栈 */ static uint32_t task1_stack[128]; static uint32_t task2_stack[128]; void task1(void *arg) { while (1) { led_toggle(LED1); qt_task_delay(500); /* 延时 500 个节拍 */ } } void task2(void *arg) { while (1) { uart_send_string(task2 running\r\n); qt_task_delay(1000); } } void app_start(void) { qt_task_create(task1, task1, task1_stack, 128, 1, NULL); qt_task_create(task2, task2, task2_stack, 128, 2, NULL); /* 启动调度器从此不返回 */ qt_start(); }栈大小怎么定128 个 32 位字就是 512 字节。对于简单的点灯、打印任务够用。但如果任务里调用了层层嵌套的函数、用了较大的局部数组栈就要加大。栈溢出是 RTOS 最常见的崩溃原因初期可以给大一点稳定后再逐步缩小。有些内核提供栈使用量检测功能移植完可以打开看看每个任务实际用了多少栈心里有数。验证调度的现象如果 task1 每 500ms 翻转一次 LEDtask2 每秒打印一次两个任务互不干扰说明调度基本正常。如果只有一个任务在跑或者时序明显不对就要查节拍配置和优先级设置。4.5 节拍精度的实测与校准移植完成后我习惯实测一下节拍精度。方法很简单创建一个任务延时 1000 个节拍理论 1 秒在延时前后翻转一个 IO用示波器或者逻辑分析仪测实际周期。如果偏差在几个百分点以内说明节拍配置没问题。如果偏差很大检查时钟频率和重装载值算对没有。还有一种情况是节拍“看起来对但累积误差大”。这通常是节拍中断处理时间过长或者中断被其他高优先级中断频繁打断导致的。解决办法是精简节拍中断里的代码或者提高节拍中断的优先级。5. 常见问题与排查技巧实录移植过程中遇到的问题很多是共性的。我把踩过的坑整理成一张速查表方便对照排查。现象可能原因排查方向编译报未定义符号源文件没加全或路径没配检查工程文件列表和 include 路径一启动就死机栈方向配反、栈太小确认栈生长方向加大任务栈任务不切换PendSV 没触发或优先级不对检查 yield 函数和 PendSV 优先级延时不准节拍频率或时钟频率错核对重装载值计算偶发数据错乱临界区没保护或嵌套处理错检查关开中断的保存恢复逻辑只有最高优先级任务跑低优先级任务没机会检查是否有阻塞让出机制中断里调用内核函数崩溃在中断里用了非中断安全 API改用中断安全版本或置标志除了表格里的再分享几个独家经验。第一个坑忘了设置 PendSV 优先级。如果 PendSV 优先级不是最低它可能在某个外设中断处理到一半时插进去做上下文切换导致外设状态错乱。表现是外设偶尔工作不正常很难联想到调度器。移植时第一件事就是把 PendSV 优先级设成最低。第二个坑任务栈里放了太大的局部变量。有人在任务函数里定义一个几百字节的数组栈直接溢出把相邻任务的数据踩了。表现是另一个无关任务莫名其妙崩溃。排查这种问题可以先把所有任务栈加大一倍如果问题消失基本就是栈溢出。定位到具体任务后把大数组改成静态或全局变量。第三个坑在中断服务程序里调用会阻塞的内核函数。比如在串口接收中断里调用任务延时这是不允许的因为中断上下文没有任务控制块。正确做法是在中断里发一个事件标志或往队列里塞数据让任务去处理。第四个坑节拍中断里忘了清标志。有些定时器需要手动清除更新中断标志忘了清就会反复进中断系统看起来像卡死。这个错误很隐蔽因为中断确实在进但主循环永远得不到执行。提示移植初期建议打开内核的断言功能。很多内核在参数非法、状态异常时会触发断言能帮你快速定位问题。等系统稳定后再关掉断言以节省资源。6. 移植完成后的优化与扩展方向系统能跑起来只是第一步接下来还有不少可以打磨的地方。裁剪内核功能。把项目里用不到的事件、队列、状态机模块关掉能省下可观的 Flash 和 RAM。我一般会在功能开发完成后回头审视一遍配置把确实没用的模块关掉重新编译看尺寸变化。优化上下文切换。如果对切换速度有要求可以检查汇编部分有没有冗余的压栈弹栈。有些移植示例为了通用性保存了所有寄存器实际可以按需精简。不过这块优化空间有限除非切换极其频繁否则收益不大。加入低功耗支持。很多单片机项目对功耗敏感。可以在系统空闲任务里让 CPU 进入睡眠模式等节拍中断或其他中断唤醒。这样在任务都阻塞时CPU 不空转功耗能降不少。实现方式是在空闲任务里调用芯片的睡眠指令。扩展调试手段。可以加一个统计任务定期打印各任务的栈使用量、CPU 占用率。CPU 占用率的算法是记录空闲任务运行的时间比例用 1 减去它就是占用率。这些数据对优化任务划分很有帮助。多平台复用移植层。如果项目要在多个芯片平台上跑可以把移植层按平台分目录用条件编译选择。这样同一套应用代码不用改换个平台只换移植层。这个思路在需要跨平台的产品里很实用。我个人在实际操作中的体会是移植操作系统这件事难点不在写代码而在理清边界和排查问题。内核本身是成熟的你要做的只是把它和硬件对接好。对接的每一个环节——时钟、中断、栈、优先级——都可能出问题而且问题的表现往往和原因隔得很远。所以移植时一定要有耐心一个环节一个环节验证不要一次改一堆东西然后一起调试。先把节拍跑准再把任务调起来最后加功能这个顺序能帮你省下大量排查时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑