☰
从零移植QuarkTS实时内核到ARM Cortex-M单片机
2026/10/15 16:13:53 网站建设 项目流程

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 之类。

配置节拍中断的步骤:

  1. 计算定时器重装载值。假设系统时钟 72MHz,想要 1kHz 节拍,那定时器每 1ms 中断一次。重装载值 = 时钟频率 / 节拍频率 - 1 = 72000000 / 1000 - 1 = 71999。
  2. 配置定时器为向上计数、自动重装载模式。
  3. 使能定时器更新中断,配置中断优先级。
  4. 在中断服务程序里调用内核节拍处理函数,并清除中断标志。

中断优先级这里有个关键点:节拍中断的优先级,以及 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 翻转一次 LED,task2 每秒打印一次,两个任务互不干扰,说明调度基本正常。如果只有一个任务在跑,或者时序明显不对,就要查节拍配置和优先级设置。

4.5 节拍精度的实测与校准

移植完成后,我习惯实测一下节拍精度。方法很简单:创建一个任务,延时 1000 个节拍(理论 1 秒),在延时前后翻转一个 IO,用示波器或者逻辑分析仪测实际周期。如果偏差在几个百分点以内,说明节拍配置没问题。如果偏差很大,检查时钟频率和重装载值算对没有。

还有一种情况是节拍“看起来对,但累积误差大”。这通常是节拍中断处理时间过长,或者中断被其他高优先级中断频繁打断导致的。解决办法是精简节拍中断里的代码,或者提高节拍中断的优先级。

5. 常见问题与排查技巧实录

移植过程中遇到的问题,很多是共性的。我把踩过的坑整理成一张速查表,方便对照排查。

现象可能原因排查方向
编译报未定义符号源文件没加全或路径没配检查工程文件列表和 include 路径
一启动就死机栈方向配反、栈太小确认栈生长方向,加大任务栈
任务不切换PendSV 没触发或优先级不对检查 yield 函数和 PendSV 优先级
延时不准节拍频率或时钟频率错核对重装载值计算
偶发数据错乱临界区没保护或嵌套处理错检查关开中断的保存恢复逻辑
只有最高优先级任务跑低优先级任务没机会检查是否有阻塞让出机制
中断里调用内核函数崩溃在中断里用了非中断安全 API改用中断安全版本或置标志

除了表格里的,再分享几个独家经验。

第一个坑:忘了设置 PendSV 优先级。如果 PendSV 优先级不是最低,它可能在某个外设中断处理到一半时插进去做上下文切换,导致外设状态错乱。表现是外设偶尔工作不正常,很难联想到调度器。移植时第一件事就是把 PendSV 优先级设成最低。

第二个坑:任务栈里放了太大的局部变量。有人在任务函数里定义一个几百字节的数组,栈直接溢出,把相邻任务的数据踩了。表现是另一个无关任务莫名其妙崩溃。排查这种问题,可以先把所有任务栈加大一倍,如果问题消失,基本就是栈溢出。定位到具体任务后,把大数组改成静态或全局变量。

第三个坑:在中断服务程序里调用会阻塞的内核函数。比如在串口接收中断里调用任务延时,这是不允许的,因为中断上下文没有任务控制块。正确做法是在中断里发一个事件标志或往队列里塞数据,让任务去处理。

第四个坑:节拍中断里忘了清标志。有些定时器需要手动清除更新中断标志,忘了清就会反复进中断,系统看起来像卡死。这个错误很隐蔽,因为中断确实在进,但主循环永远得不到执行。

提示:移植初期,建议打开内核的断言功能。很多内核在参数非法、状态异常时会触发断言,能帮你快速定位问题。等系统稳定后再关掉断言以节省资源。

6. 移植完成后的优化与扩展方向

系统能跑起来只是第一步,接下来还有不少可以打磨的地方。

裁剪内核功能。把项目里用不到的事件、队列、状态机模块关掉,能省下可观的 Flash 和 RAM。我一般会在功能开发完成后,回头审视一遍配置,把确实没用的模块关掉,重新编译看尺寸变化。

优化上下文切换。如果对切换速度有要求,可以检查汇编部分有没有冗余的压栈弹栈。有些移植示例为了通用性保存了所有寄存器,实际可以按需精简。不过这块优化空间有限,除非切换极其频繁,否则收益不大。

加入低功耗支持。很多单片机项目对功耗敏感。可以在系统空闲任务里让 CPU 进入睡眠模式,等节拍中断或其他中断唤醒。这样在任务都阻塞时,CPU 不空转,功耗能降不少。实现方式是在空闲任务里调用芯片的睡眠指令。

扩展调试手段。可以加一个统计任务,定期打印各任务的栈使用量、CPU 占用率。CPU 占用率的算法是:记录空闲任务运行的时间比例,用 1 减去它就是占用率。这些数据对优化任务划分很有帮助。

多平台复用移植层。如果项目要在多个芯片平台上跑,可以把移植层按平台分目录,用条件编译选择。这样同一套应用代码不用改,换个平台只换移植层。这个思路在需要跨平台的产品里很实用。

我个人在实际操作中的体会是,移植操作系统这件事,难点不在写代码,而在理清边界和排查问题。内核本身是成熟的,你要做的只是把它和硬件对接好。对接的每一个环节——时钟、中断、栈、优先级——都可能出问题,而且问题的表现往往和原因隔得很远。所以移植时一定要有耐心,一个环节一个环节验证,不要一次改一堆东西然后一起调试。先把节拍跑准,再把任务调起来,最后加功能,这个顺序能帮你省下大量排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询