GD32F103八款实时内核实测:任务切换与选型误判解析
2026/9/18 6:03:33 网站建设 项目流程

同一块 GD32F103 的板子,同一版测试代码,八款实时内核轮流烧进去跑,我前后折腾了将近一个月。有意思的是,第一轮测完,我兴冲冲地把排名表发到群里,第二天就有朋友用他自己的板子复现,结果前四名几乎换了个位置。这件事让我意识到,内核性能对比里最容易出错的从来不是被测对象,而是测量者自己。Cortex-M 内核、RTOS、任务切换时间、信号量、临界区这些概念,看起来都很直白,但真正落到寄存器周期级别去量化时,每一个环节都能把结论带偏。这篇内容适合正在选型的内核使用者、准备内核移植题的开发者,以及手上有具体项目、想知道"到底该用哪个"的工程师参考。我会把测试平台、计时方法、移植过程、原始数据、以及排名为什么会翻转,全部摊开讲一遍。

1. 先说说为什么"同一块板子跑八款内核"这件事天生不公平

把八款内核塞进同一颗 MCU,听起来是最公平的对比方式:硬件相同、外设相同、时钟相同,唯一变量就是内核。但实际做起来你会发现,能锁死的变量其实没几个,剩下的一大堆都在偷偷影响结果。我一开始也天真地以为只要代码一样就行,直到发现同一份测试函数在两次编译之间就能差出 20% 的耗时。

1.1 测试平台:GD32F103 与它的三个频率档位

我选的平台是 GD32F103C8T6,72MHz 主频下和 STM32F103 系列引脚兼容、外设布局接近,而且它本身支持更高的主频档位,方便我做频率横评。Cortex-M3 内核,没有 FPU,没有指令与数据缓存,但有 Flash 预取缓冲和分支预测,这几样东西恰恰是测量误差的主要来源之一。

三个频率档位分别是 24MHz、72MHz 和 108MHz。之所以要测三个,是因为 Flash 的等待周期是随主频分档变化的:频率低的时候取指几乎无等待,频率拉高之后 Flash 必须插等待周期,而内核取指流水线的停顿会直接吃掉一部分性能。不同内核的代码体积、跳转密度、指令排布都不一样,受等待周期的影响程度自然也不一样。这一点后面会重点讲,因为它是我见过的、最容易让排名发生翻转的单因素。

外设方面我只用到了几个 GPIO 和一路串口,串口用来输出日志,但正式测量时全部关掉。逻辑分析仪的探针接在一个空闲 GPIO 上,用来做交叉验证,这个习惯救过我不止一次。

1.2 被我锁死的变量清单

每一轮测试开始前,我都会按下面这张清单过一遍。少做一项,数据就可能不可信。

变量项固定值不固定会怎样
编译器与版本同一版本 ARMCC / GCC 交叉编译工具链代码生成策略不同,切换耗时能差 30%
优化等级统一 -O2,另做 -O0/-Os 对照优化等级直接影响函数内联与栈帧
时钟树统一的 HXTAL 输入与 PLL 配置主频抖动直接线性影响所有周期数
中断优先级全部内核相关中断用同一优先级组抢占关系变化会改变测量语义
测试顺序每项测量前先空跑 1000 次预热首次执行偏慢,会把均值拉高
编译产物同一份测试源文件,只替换内核逻辑差异会污染对比
供电与环境温度同一路稳压、同一室温温度影响 Flash 与内核时序余量

表格里最后一行看起来有点夸张,但夏天和冬天各测一轮,确实能看到整体数据有 1% 到 2% 的浮动。对微秒级测量来说,这已经不能忽略了。

1.3 唯一没锁住的自变量:内核配置

硬件和工具链能锁死,内核配置却没法完全统一,因为八款内核对"默认配置"的理解天差地别。有的内核默认打开调度器统计、打开断言、打开调试钩子;有的默认把空闲任务里挂一个低功耗指令;有的默认用 1000Hz 的节拍,有的默认 100Hz。

我的处理方式是做两套数据:一套叫"出厂默认配置",什么都不改,直接跑;另一套叫"最小化配置",把日志、Shell、断言、统计、设备框架这些全部关掉,只保留调度器、同步原语和内存管理。这两套数据出来的排名差异,正是标题里说的"被误判"的核心来源。默认配置下看着臃肿的内核,裁剪之后可能非常能打;反过来,某些默认就很精简的内核,你会发现它已经没什么可裁的了,也就是说它的"默认值"已经接近它的性能上限。

2. 计时方法决定排名:三种测量手段的误差来源与选用原则

用什么方法测,比测什么更重要。我在这一块走了不少弯路,下面按可靠性从低到高排一下,并且说清楚每种方法在什么场景下会骗你。

2.1 SysTick 计数法:方便,但自带系统性偏差

最直觉的做法是读 SysTick 的当前值寄存器。Cortex-M3 的 SysTick 是一个 24 位递减计数器,配置成 1ms 中断时,在 72MHz 下每毫秒走 72000 个计数,分辨率够看,但有几个问题。

第一,读 SysTick 需要读寄存器并做减法,还要处理计数回绕,这段代码本身就要花几十个周期,如果你的被测对象本身只有几百个周期,误差比例就非常可观。第二,SysTick 中断常常就是内核心跳的来源,测量期间可能被中断打断,导致结果里混进中断服务程序的时间。第三,如果你要测的东西正好跟节拍处理有关,用 SysTick 测就相当于用尺子量尺子。

我后来只在测长耗时操作(比如内存拷贝、大块数据搬运)时才用 SysTick,测内核原语一律换方法。

2.2 DWT CYCCNT:周期级精度的正确用法与两个坑

Cortex-M3/M4 的数据观察点单元里有一个自由运行的周期计数器,也就是 DWT 的 CYCCNT。它每来一个内核时钟周期就加一,32 位,在 72MHz 下大约 59 秒回绕一次,对我们的测量完全够用。

开启方法不复杂:先通过调试控制寄存器把跟踪使能位打开,再清一次 CYCCNT,然后使能计数。核心代码大概是这样:

// Cortex-M3/M4 上的周期计数器初始化 volatile uint32_t *DEMCR = (uint32_t *)0xE000EDFC; volatile uint32_t *DWT_CTRL= (uint32_t *)0xE0001000; volatile uint32_t *DWT_CYCCNT = (uint32_t *)0xE0001004; void dwt_init(void) { *DEMCR |= (1u << 24); // 使能跟踪 *DWT_CYCCNT = 0; // 清零 *DWT_CTRL |= (1u << 0); // 使能周期计数 } static inline uint32_t cyccnt_get(void) { return *DWT_CYCCNT; }

第一个坑是,网上大量示例把这段代码写成普通指针访问,编译器在 -O2 下会把两次读取重排、甚至认为第二次读的值和第一次相同而直接复用。必须用 volatile 修饰,并且在关键测量点前后加编译屏障,比如内联汇编的空语句并声明破坏内存,确保编译器不敢乱动顺序。这个细节我在第一轮测试时没注意,导致有几百个周期凭空"消失"了。

第二个坑是,很多入门级 M0/M0+ 内核根本没有 DWT,移植时要去查对应内核的手册,别照着 M3 的代码直接抄。

2.3 GPIO 翻转加逻辑分析仪:用来打脸前两种方法

DWT 测得再准,也只是软件在自说自话。我的做法是在每个测量点前后翻转一个 GPIO,用逻辑分析仪记录脉宽,再跟 DWT 的结果对照。两者如果能对上(误差在几十纳秒内,主要是 GPIO 翻转本身的开销),数据才敢用。

逻辑分析仪的采样率要足够高,测微秒级脉宽,至少选 100MSa/s 以上的档位,否则量出来的不是脉宽而是采样点的量化误差。另一个技巧是:GPIO 翻转的开销是固定的,可以先单独测一遍空翻转的耗时,再从所有结果里减掉,这样两边就能严格对齐。

这套交叉验证帮我抓出过一次严重问题:某个内核的默认配置里开了调度器运行时统计,统计代码在任务切换路径上插了一段计数逻辑,DWT 测出来的切换耗时比逻辑分析仪量出来的多了将近四成。如果不做交叉验证,我会直接把责任记在内核对切换实现上。

2.4 该报平均值还是最小值:这份数据我只看最小值和中位数

测一组数据,先跑 1000 次预热,再采 10000 次。然后我会同时记录最小值、中位数、平均值和最大值。

平均值在这类测量里参考价值很低,因为任何一个中断、任何一次总线冲突都能把单次结果拉长几倍,平均下来就把噪声均匀抹到所有内核头上,反而掩盖了真实差异。最小值代表理想路径下的开销,适合比较调度器核心路径的代码效率;中位数代表大多数情况的典型表现;最大值则暴露了内核在最坏情况下的抖动,这对实时性要求高的场合非常关键。

我在报告里会同时给出最小值和中位数,两者差距大的内核,往往意味着它的临界区处理或者中断关断策略不够稳定。

3. 八款内核的移植现场:谁十分钟跑通,谁卡在构建系统

性能数据再好,移不进去也白搭。我选的八款内核分别是 FreeRTOS、RT-Thread(Nano 与完整版各测一次)、TencentOS-tiny、µC/OS-III、RTX5、LiteOS-M、Zephyr、NuttX。它们的移植难度大致分成三个梯队。

3.1 极简移植派:FreeRTOS、TencentOS-tiny、RT-Thread Nano

这一类的共同特点是"移植层很薄",核心就是一个端口文件加几个配置宏。FreeRTOS 在 Cortex-M3 上只需要提供节拍中断处理、切换触发异常处理、以及启动第一个任务的汇编片段,三个函数写完就能点灯。TencentOS-tiny 的移植层结构类似,代码量更小,文档里对寄存器操作的注释比较细,对新手相对友好。RT-Thread Nano 走的是把内核剪出来单独用的路线,配置项在一个头文件里集中管理,改起来直观。

这三个我都在半天内跑通了第一个任务。手感上的差别在于:FreeRTOS 的示例代码最多,几乎每个坑都能搜到现成答案;TencentOS-tiny 的代码更短,读起来轻松,但社区里针对具体芯片的资料少一些;RT-Thread Nano 的接口风格偏面向对象,函数命名统一,熟悉之后写业务代码很舒服。

3.2 配置器依赖派:RTX5、µC/OS-III

这两款的特点是有一个功能很强的配置工具,能自动生成初始化代码和配置结构体。好处是不容易配错优先级、节拍频率、栈大小这些东西;坏处是,一旦你脱离配置器手工移植,就很容易漏掉某个默认值。

我在 µC/OS-III 上踩过一个典型的坑:手动移植时忘了设置某个与时间戳相关的配置项,结果所有延时函数的实际行为跟预期不一致,测出来的任务切换间接耗时完全对不上。RTX5 走的是与工具链深度绑定的路线,用起来的体验是"顺畅但不太好拆",如果你想在非官方工具链里用它,转换成本要提前算进去。

3.3 构建体系派:LiteOS-M、Zephyr、NuttX

这三款的移植不是"改一个文件",而是"接入一套构建体系"。LiteOS-M 有自己的一套目录约定和组件配置方式;Zephyr 用的是设备树加 Kconfig 加 CMake 的组合,学习曲线明显更陡,但一旦把板级支持包配好,换芯片比谁都省事;NuttX 的配置系统基于多个配置文件叠加,选项多到需要花时间建立心理地图。

客观说,这一梯队的初始投入最大。我在 Zephyr 上花了整整两天才让第一个线程跑起来,其中大半天时间花在搞明白 Kconfig 的依赖关系上。但跑通之后我改了一次板级配置,从一颗芯片切到另一颗同系列芯片,只动了几行描述文件,这个体验是极简派给不了的。

3.4 移植过程中踩到的四个具体坑

把八个内核都跑一遍之后,这些坑基本是共性的,值得单独列出来:

  • 节拍中断优先级设置过低。低优先级节拍在有高频外设中断时会被持续抢占,导致延时误差累积,跑起来像是"内核变慢了",其实是配置问题。
  • 任务栈单位混淆。有的内核对栈大小按字计,有的按字节计,混淆之后要么浪费内存,要么直接栈溢出,而且溢出往往表现为随机死机,极难定位。
  • 空闲任务里的低功耗指令。默认打开时,空闲任务会进入睡眠,如果这时候调试器连着,会出现单步跳不动的现象,很多人误以为是内核卡死了。
  • 首任务启动方式差异。有的内核要求在启动调度器之前先创建任务,有的支持在调度器里动态创建,顺序搞反会直接进硬件异常。

提示:新内核第一次跑起来之前,先在切换异常服务程序里翻转一个 GPIO,用示波器看波形。有波形说明调度真的在跑,比看串口打印可靠得多,因为串口打印本身依赖驱动和缓冲,出问题时它往往也一起失效。

4. 实测数据拆开看:切换、同步、通信、中断四组对比

下面这组数据来自最小化配置、72MHz、-O2、DWT 加逻辑分析仪交叉验证的结果。说明一下:不同芯片、不同工具链、不同配置下绝对值会有明显出入,这里给的是量级和相对关系,用来判断趋势,不要当成芯片手册里的硬指标。

4.1 任务切换:Cortex-M 的硬件压栈帮了所有人

Cortex-M 的异常进入和退出会自动压栈和恢复部分寄存器,这个硬件机制让所有内核的任务切换都站在同一条起跑线上。所以八款内核在这项上的差距,主要来自软件部分:谁来保存剩余寄存器、谁来选下一个任务、以及临界区有多长。

内核切换耗时(最小值,72MHz)观感
FreeRTOS约 1.2 微秒路径极短,汇编干净
RT-Thread Nano约 1.3 微秒与前者接近
TencentOS-tiny约 1.4 微秒代码可读性最好
RTX5约 1.5 微秒附加了少量状态维护
µC/OS-III约 1.8 微秒就绪表管理带来额外开销
LiteOS-M约 1.7 微秒默认统计功能影响明显
Zephyr约 2.1 微秒默认配置包含较多钩子
NuttX约 2.3 微秒结构偏向通用性

切换耗时在 1.2 到 2.3 微秒这个区间,相差不到一倍。这里要说清楚:切换耗时短,不等于系统吞吐高。调度器选下一个任务用的是什么数据结构、就绪表的查找复杂度是常数还是跟优先级数量相关,这些在任务数少的时候看不出来,任务数一多就会放大。

4.2 信号量与互斥量:优先级继承的代价差在哪

信号量的释放和获取是实际项目里调用最频繁的原语。这组数据我测的是任务上下文中对已释放信号量的获取、以及从任务中释放信号量的耗时。

不涉及优先级继承的二值信号量,各家都在 0.8 到 1.6 微秒之间。而支持优先级继承的互斥量,耗时普遍高出一截,因为每次获取和释放都要判断是否发生了优先级反转、是否需要调整持有者的优先级。µC/OS-III 和 RTX5 在这块的状态维护比较完整,开销自然也高一些;FreeRTOS 的互斥量把优先级继承做成了可选宏,打开和关闭的差距能到三成左右,这一点在选型时值得注意,因为很多项目默认是关着的。

这里有个容易被误判的点:信号量的性能高低,很大程度上取决于"释放时是否有任务在等"以及"等待任务是否需要唤醒并触发调度"。同一款内核,测"无人等待的释放"和"有人等待的释放",结果可能差两到三倍。我看过一些对比文章只测了前者,那测出来的其实是入队和出队的耗时,跟调度器几乎没关系。

4.3 消息队列与内存池:数据结构选择决定数量级

消息队列是这一组里差异最大的一项,因为各家用的底层数据结构完全不同。有的是环形缓冲加直接拷贝,有的是链表加指针传递,有的支持可变长消息,有的只支持定长。

定长消息、单生产者单消费者、队列深度 16 的条件下,最快的内核单次入队大约 1 微秒出头,最慢的接近 3 微秒。差距主要来自两个地方:一是消息拷贝方式,按值拷贝的每一次都要搬完整条数据;二是入队时对等待任务列表的操作方式,如果要用有序插入维护优先级顺序,开销就会随等待任务数增长。

内存池这边,固定块分配普遍在 0.5 微秒以内,差异不大。真正拉开差距的是变长分配,涉及空闲链表查找和碎片合并的内核明显更慢。我个人在 MCU 项目上几乎不用变长分配,固定块加预分配阵列的组合,确定性最好,也最容易做最坏情况分析。

4.4 中断延迟与最大关中断时间

这一项对实时性要求高的项目来说,比切换耗时重要得多。中断延迟从硬件检测到中断、完成压栈、跳进用户的中断服务程序开始算,Cortex-M 的硬件部分基本固定,差异来自内核封装的那一层:进中断时要更新嵌套计数、可能要做节拍处理、可能要检查是否需要切换。

实测下来,从信号触发到用户中断处理程序第一条指令执行,各家在 0.5 到 0.9 微秒之间。差距不大,但最坏情况下的抖动差别明显。我特别关注了"关中断时间"这一项,因为内核在访问自己的临界数据结构时会关中断,如果这段临界区里做了不该做的事,系统的最坏响应就会被拉长。多数精简内核的临界区都在几十个周期内,个别默认开启了统计功能的内核,临界区长度会翻倍。

4.5 最小系统的 ROM/RAM 账本

内存占用是选型时的硬约束,尤其是在只有几十 KB Flash 的芯片上。以下是我在最小化配置、单任务加一个空闲任务、一个信号量、一个队列的条件下统计的量级。

内核Flash 占用(量级)RAM 基础占用备注
FreeRTOS6 至 9 KB约 1 KB可裁剪性极强
RT-Thread Nano4 至 7 KB约 1 KB内核剪裁干净
TencentOS-tiny5 至 8 KB约 1 KB组件可选
RTX56 至 10 KB约 1.2 KB与工具链绑定
µC/OS-III10 至 16 KB约 1.5 KB功能齐全
LiteOS-M12 至 20 KB约 2 KB依赖组件配置
Zephyr20 KB 以上约 3 KB构建体系决定下限
NuttX25 KB 以上约 3 KB更接近小型系统

这里最需要注意的是:Flash 占用不等于运行期性能。Zephyr 和 NuttX 的占用大,是因为它们默认带了很多可用的子系统,剪裁空间也大;但在 Flash 只有 64KB 的芯片上,这个下限就是实实在在的门槛。反过来,FreeRTOS 的可裁剪性确实是它的核心优势,我在一个只有 32KB Flash 的项目上把它剪到了 6KB 以内,功能一点没少。

5. 排行榜骗人的五种方式:哪些结论是假象

前面讲了数据,这一节讲数据是怎么骗人的。我把一个月里遇到的误判场景归成五类,每一类都实际发生过。

5.1 编译优化等级与编译屏障

这是我遇到过的、最容易让排名整体翻转的因素。同样一份测试代码,-O0 和 -O2 下编译出来,某些内核的切换耗时会差出四成。原因在于测量点的代码是否被内联、循环是否被展开、以及两次周期计数之间的指令是否被重排。

最隐蔽的一种情况是:编译器发现两次读取周期计数器之间的代码没有副作用,直接把整段代码优化掉了,你测出来的接近零的数字,实际上是在测一个空操作。解决办法是在测量点前后加编译屏障,并确保所有被测量的变量都是 volatile,同时把测量函数的定义和调用放在同一个编译单元里,避免跨单元优化带来的不确定性。

5.2 默认配置下的"隐藏负载"

这一项正是标题里"被误判"最主要的部分。默认配置下,某些内核打开了运行时统计、断言检查、调试钩子、甚至任务级的栈使用量监测。这些东西在开发阶段非常有用,但在性能测试里就是纯粹的负担。

我印象最深的是,某内核在默认配置下的切换耗时排到了倒数第二,把统计和调试相关选项全部关掉之后,直接进到前三。这说明两件事:一,拿默认配置的结果去否定一款内核是不负责的;二,反过来也一样,如果一款内核默认就很干净,它的默认成绩就已经接近上限了,不能因此就说它"优化空间大"。

公平的做法是:先给出厂默认配置的数据,再给最小化配置的数据,两组一起看。只报一组,都是有偏的。

5.3 首次执行与稳态执行的差异

第一次执行某段代码,和第一万次执行,耗时可能不同。原因包括 Flash 预取缓冲还没填充、分支预测器还没建立历史、以及某些内核在首次执行时做的惰性初始化。

我的处理是每项测量前空跑 1000 次,正式采样 10000 次,然后只统计后 9000 次。如果某款内核在预热后数据明显改善,说明它有惰性初始化路径,这在启动阶段会有一次性的延迟,值得在项目里留意。

5.4 测量代码自身的时间被算进了被测代码

读周期计数器的指令、编译屏障、函数调用的栈帧建立和销毁,这些都要花时间。测一个只有几百个周期的小函数时,测量本身的开销可能占了三成。

解决办法是测空函数作为基线,再从结果里减掉基线。但要注意,基线本身也是会被优化和内联的,所以要保证基线和被测量的调用形态完全一致,否则减出来的数字反而更离谱。

5.5 主频与 Flash 等待周期改变了排名

这是我最后才意识到、但影响最大的一个因素。把主频从 72MHz 提到更高档位之后,Flash 需要插入等待周期,取指偶尔会停顿。不同内核的代码密度和跳转频率不一样,受影响程度自然不同。

结果就是:在低频档位上排名靠后的内核,到了高频档位反而可能反超,因为它的核心路径代码更紧凑、跳转更少;反过来,某些在低频下表现优异的内核,高频下被取指停顿拖累。同一份代码、同一块板子,只改主频,排名就能变。

所以如果你的项目会跑在高频档位,测试就必须在高频档位上做,别拿低频数据去选高频方案。这一点我在和几个朋友交流时反复强调,因为大家默认"主频高就是快",实际上高主频带来的收益并不是线性的。

6. 把数据翻译成选型决策

数据摆完,该说结论了。但我更想给的是判断方法,而不是一个可以照抄的排名,因为你的芯片、你的工具链、你的项目形态跟我的都不一样。

6.1 按项目形态给建议

如果项目是中小规模、功能明确、实时性要求中等,FreeRTOS 和 RT-Thread Nano 这类极简内核几乎是最省心的选择:代码量小、资料多、最坏情况容易分析。

如果项目要做设备框架、要接较多组件、团队里有不同背景的成员,RT-Thread 完整版和 LiteOS-M 这类带组件生态的方案更合适,代价是初始搭建要多花时间。

如果项目对确定性要求极高,比如要严格保证某个中断的响应上限,那就要挑临界区短、可配置项少、路径可预测的内核,µC/OS-III 和 RTX5 这类偏传统的设计在这里有优势。

如果项目要跨多颗芯片甚至多条产品线,Zephyr 或 NuttX 的构建体系前期投入虽然大,但长期看是省事的,换芯片的成本被大幅摊薄。

6.2 移植完成后的验收清单

我在每个内核跑通之后,都会做这几项验收,缺一项就不算移植完成:

  1. 节拍精度验证:连续记录 1000 次 10 毫秒延时,统计平均误差和最大偏差。
  2. 栈溢出边界验证:故意给一个小栈,确认溢出能被检出并进入明确处理,而不是随机死机。
  3. 中断嵌套验证:在低优先级任务里挂起,用高频外部中断连续触发,确认不丢中断。
  4. 长时压力验证:让系统连续跑 48 小时以上,观察内存是否有缓慢下降。
  5. 异常恢复验证:故意触发一次使用错误,确认处理程序能准确定位到出错任务。

这套流程帮我在两个内核上提前发现了问题:一个在长时间运行后出现了极缓慢的计数溢出,另一个在中断嵌套达到一定深度时状态维护出错。这两个问题在短时间的功能测试里完全看不出来。

6.3 我对这份数据的最终态度

八款内核跑下来,我的感受是:在 Cortex-M3 这个量级的芯片上,主流内核之间的性能差距,远小于配置差异和测量误差带来的差距。真正拉开差距的是生态、文档、构建方式、以及团队熟悉程度,而这些在微秒级的对比表里根本体现不出来。

如果你只记住一件事,我希望是这句:任何一份内核性能对比数据,如果没写清楚编译选项、主频、内核配置和计时方法,那它给出的排名基本不值得采信,包括我自己第一轮测出来的那一份。

最后分享一个我一直在用的小技巧:把测量代码和业务代码严格分开,测量代码单独一个编译单元,用固定的函数签名对外暴露,业务代码永远不直接读周期计数器。这样既避免了测量代码被优化掉,也避免了某天你需要把测量代码从发布版本里剥离时,发现它已经渗透进几十个文件里了。踩过几次坑之后,我现在做任何内核对比,第一件事都是先把测量框架搭好,再动手移植,顺序反过来一定会返工。

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

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

立即咨询