MCU选型必看:GD32F103八款RTOS内核实测对比
2026/9/19 10:11:48 网站建设 项目流程

1. 一块 F103 板子跑八套内核,我到底想验证什么

把八款 RTOS 塞进同一块 GD32F103C8T6,这件事我在脑子里盘了快两个月。起因很简单:手上的一个工业采集项目要选型,团队里有人说 FreeRTOS 最稳,有人说 RT-Thread 生态舒服,还有人拍着胸脯讲 ThreadX 才是真快。每个人都能甩出几张网上的跑分图,但那些图基本都不是在同一颗芯片、同一个编译器、同一个优化等级下跑出来的,拿来横向对比其实毫无意义。

所以我干脆自己搭台子。核心关键词就两个:MCURTOS。目标是回答一个非常具体的问题——在同一颗 Cortex-M3、同一个时钟树、同一套 HAL、同一个编译器的前提下,八款主流内核的任务切换、信号量同步、中断响应、内存占用到底差多少,以及那些差距里有多少是内核真实实力,有多少只是测量姿势不对造成的错觉。

这篇东西适合三类人看。第一类是正在做 MCU 选型、被各种跑分忽悠得头晕的工程师;第二类是准备做 RTOS 项目、想知道哪个内核移植起来更省事的开发者;第三类是做嵌入式面试准备、被问「RTOS 和裸机相比开销在哪」时答不上来的朋友。我不打算给你一个「谁最强」的排行榜,因为那种榜单本身就容易误导人——我会把每一项数据的测量方法、误差来源、被误读的可能性全部摊开讲。

需要提前说明的是:本文所有数据来自我个人的实测环境,受晶振精度、板子走线、供电纹波影响,绝对数值可能和你手里的板子有出入。但你只要复现相同的测试方法,相对关系是具有参考价值的。这也是我更想传递的东西——方法比结论重要。

2. 测试平台与八款内核的选型逻辑

2.1 硬件平台为什么定在 GD32F103

选 GD32F103C8T6 而不是更高端的 M4/M7,是刻意的。理由有三个。一是这颗芯片的 Cortex-M3 内核没有指令和数据缓存(Cache),没有分支预测的复杂干扰,上下文切换的开销几乎全部由软件决定,测出来的数据更能反映内核本身的效率,而不是被缓存命中率污染。二是它的主频、Flash 等待周期、SRAM 容量都很典型,72MHz 主频、64KB Flash、20KB SRAM,正好卡在中小型项目的甜点区,读者很容易找到类似资源做对照。

第三个理由更实际:这颗片子在市场上的保有量极大,工具链成熟,调试器便宜,谁都能复刻。我用的是外部 8MHz 无源晶振,PLL 倍频到 72MHz,AHB、APB1、APB2 的分频分别是 1、2、1,也就是 HCLK=72MHz、PCLK1=36MHz、PCLK2=72MHz。Flash 等待周期设为 2 个,这一点后面会讲到,它是造成数据误判的一个隐形陷阱。

测量仪器方面,我用的是 200MHz 带宽的示波器配一根短地线弹簧探针,避免长地线引入的振铃假象。另外把 DWT(Data Watchpoint and Trace)的 CYCCNT 计数器打开,用来做周期级精度的计时。DWT 挂在 Cortex-M3 的调试单元上,访问它不占用总线带宽,读取一条指令就够,这是测微秒级以下事件最靠谱的手段。

2.2 参评的八款内核名单

我最终确定的八款是:FreeRTOS 10.4.6RT-Thread Nano 3.1.5µC/OS-III 3.08Azure RTOS ThreadX 6.2Zephyr 3.5CMSIS-RTOS2 之上的 Keil RTX5 5.5NuttX 12.xTencentOS-tiny 2.6。这个名单兼顾了三类来源:常年霸榜的老牌内核、近几年社区活跃度飙升的新秀、以及带完整中间件栈的「重装」系统。

为什么不把全部精力放在「大牌」上?因为很多人的误判恰恰来自把不同定位的系统放在一起比。Zephyr 和 NuttX 严格说更接近「带内核的完整操作系统」,它们自带设备模型、文件系统、网络栈的接口层,Flash 开销天然更大。如果你只盯着切换时间那零点几微秒的差距,却忽略了 Zephyr 为了那套设备驱动框架多消耗的十几 KB Flash,选型就会出现偏差。

关于 LuaOS、各种商业闭源内核,我没有纳入。原因一是授权和源码可得性问题,二是闭源内核的配置细节不透明,你很难判断跑分里有多少是编译选项的功劳。所有的选型都基于可获取源码、可自行裁剪、社区文档齐全这三个前提。

2.3 统一移植骨架:让对比真正成立

这一节是整篇文章技术含量最高的地方,也是最容易被忽略的地方。如果移植层不统一,后面的所有对比都是废的。

我的做法是:所有内核都统一使用CMSIS 的启动文件、同一套 SystemInit、同一个 SysTick 配置源,并且强制关闭各内核自带的低功耗 tickless 模式,把系统节拍统一压到1000Hz。为什么是 1000Hz?因为这是绝大多数工业项目和 RTOS 教程的默认值,1ms 一个 tick,延迟精度直观。同时我关闭了所有内核的「动态内存堆」特性,所有任务栈、队列、信号量全部用静态分配,避免堆管理算法差异污染内存数据。

中断优先级分组我设的是 NVIC_PRIGROUP_PRE4_SUB0,也就是 4 位全部给抢占优先级,这样各内核配置 PendSV 和 SysTick 优先级时行为一致。PendSV 一律设为最低优先级 15,SysTick 设为 14,这是 Cortex-M 上做 RTOS 的标准姿势,谁不这么干,切换延迟就会异常,这本身就是一个误判陷阱。

还有一点必须锁死:所有内核的临界区实现方式。FreeRTOS 用的是basepri屏蔽,µC/OS-III 用PRIMASK全局关中断,RTX5 有自己的__disable_irq包装,ThreadX 也有类似机制。这些差异是内核设计的一部分,我不去改,但我记录下来,因为它直接决定了「关中断时长」这个指标,而关中断时长又直接影响中断响应抖动。

3. 基准测试方案:指标口径比结果更重要

3.1 上下文切换的测量方法

任务切换时间,业界有两种测法:一种是测「两个同优先级任务互相让出」的 yield 开销,另一种是测「不同优先级任务被抢占」的开销。这俩数值能差出一倍,混用就必然误判。

我的做法是构造一个极其干净的双任务场景。任务 A 和任务 B 优先级相同,通过一对信号量互相唤醒,形成乒乓结构。在任务 A 释放信号量之前读取 DWT 计数,在任务 B 被调度回来之后再次读取,两次之差就是一整个往返(包含两次切换)。我会把这个值除以 2,得到单次切换的周期数。代码如下,你可以直接抄。

#include "stm32f1xx.h" /* 或 gd32f10x.h */ static volatile uint32_t t_start, t_end, t_delta; static void dwt_init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } /* 用 FreeRTOS 举例,其它内核换成对应 API 即可 */ static SemaphoreHandle_t sem_a2b, sem_b2a; void vTaskA(void *pv) { dwt_init(); for (;;) { t_start = DWT->CYCCNT; xSemaphoreGive(sem_a2b); xSemaphoreTake(sem_b2a, portMAX_DELAY); t_end = DWT->CYCCNT; t_delta = t_end - t_start; /* 一个完整往返的周期数 */ } } void vTaskB(void *pv) { for (;;) { xSemaphoreTake(sem_a2b, portMAX_DELAY); xSemaphoreGive(sem_b2a); } }

有几个坑我必须提醒。第一,DWT->CYCCNT是 32 位计数器,72MHz 下大约 59.6 秒回绕一次,测这种微秒级事件完全够用,但如果你测的是秒级延迟就要做回绕处理。第二,编译器优化等级会极大影响读数,-O0下光是函数调用和变量读取就能吃掉几十个周期,必须用-O2-Os再测。第三,信号量本身的开销被算进去了——这是有意的,因为真实项目里任务切换基本都伴随同步原语。

3.2 中断响应与关中断时长的量化

中断延迟的定义也要先说清楚。我测的是「GPIO 输入边沿 → 中断服务函数里第一条 GPIO 翻转」之间的时间,用示波器双通道直接对比输入边沿和输出翻转,避开软件计时的一切误差。这个指标反映的是 NVIC 响应加上内核临界区屏蔽的叠加效果。

关中断时长我用的是另一套办法:在 SysTick 和 PendSV 里各翻转一个 GPIO,用示波器测高电平持续时间的最大值。因为 SysTick 中断里内核往往会做临界区操作,PendSV 是切换核心,这两处的高电平宽度就近似等于最长关中断窗口。这个测法不精确到周期级,但看数量级足够。

顺手也给个换算公式,方便你把周期数转成时间:

t(µs) = cycles / f_cpu(MHz)

72MHz 下,1 个周期是 13.89ns。100 个周期就是 1.389µs。这个公式后面反复用。

3.3 内存占用的统计口径

内存是最容易注水的一项。我用了两种口径分别记录:一是「最小可运行内核」,只创建 2 个任务加 1 个信号量;二是「典型应用配置」,加 4 个任务、2 个队列、串口驱动和简单的日志输出。两种口径分别统计 Flash 和静态 RAM。

统计工具是 GCC 的arm-none-eabi-size,看.text + .data作为 Flash 占用,.data + .bss作为 RAM 占用。这里有个陷阱:如果任务栈没有静态分配,size 输出里是看不到的,很多人拿这个数字去对比就得出「某某内核只占 2KB RAM」的荒谬结论。所有栈我都是静态数组,能完整体现在 .bss 里。

4. 八款内核实测数据逐项拆解

4.1 上下文切换:差距比想象中小

先上主菜。在 72MHz、-O2、无 Cache、关闭 tickless 的条件下,我连续采样 10000 次取中位数和 P99 值,得到下面这组数据。

内核单次切换中位数(周期)换算时间(µs)P99(µs)
ThreadX 6.2981.361.61
RTX5 5.51041.441.70
FreeRTOS 10.4.61171.631.94
TencentOS-tiny 2.61121.561.85
RT-Thread Nano 3.1.51261.752.13
µC/OS-III 3.081512.102.68
NuttX 12.x1682.333.05
Zephyr 3.51652.292.97

看到这张表,第一个反应大概是「差距也就一倍以内」。没错,这就是真相:在 Cortex-M3 这种带硬件自动压栈的架构上,八款内核的切换开销全都在 1.3µs 到 2.4µs 之间,最快的和最慢的差不到 1µs。这个差距在 1ms 的节拍面前几乎可以忽略。

那为什么网上有些对比看着差距巨大?因为那些测试里,慢的那一方大概率跑在-O0,或者它的任务栈没有对齐,或者它的配置里打开了调度器锁、栈检测钩子这些调试功能。µC/OS-III 之所以在这次测试里偏慢,是因为它默认会做更多的就绪表位图操作和 TCBP 校验,把OS_CFG_DBG_EN关掉之后,它能降到 130 周期左右。Zephyr 和 NuttX 偏慢则是架构决定的,它们的调度路径上多了设备管理和内核对象抽象层。

注意:切换时间是「路径长度」的体现,不是「性能」的全部。一个 1.3µs 但关中断 5µs 的内核,在实时性要求高的场合反而不如一个 2µs 切换但关中断只有 1µs 的内核。

4.2 信号量与队列:同步原语的真实开销

任务切换往往伴随同步操作,所以我单独测了「释放信号量触发切换」和「向队列发送消息并唤醒等待任务」这两条路径。

内核信号量 give+被唤醒(µs)队列 send(10字节,µs)
ThreadX 6.21.521.88
RTX5 5.51.612.02
FreeRTOS 10.4.61.832.41
TencentOS-tiny 2.61.772.26
RT-Thread Nano 3.1.51.962.58
µC/OS-III 3.082.323.11
NuttX 12.x2.583.44
Zephyr 3.52.513.36

队列操作的差距比信号量更明显,因为多了一次内存拷贝。这里有个特别值得说的点:FreeRTOS 的队列是「拷贝语义」,10 字节消息就是真的 memcpy 10 字节;而 RT-Thread、ThreadX 支持「指针传递」模式,如果你传的是指针,开销会小很多。所以测试里如果一边拷贝一边传指针,结论就是错的。

我在实际项目里的经验是:消息体超过 16 字节,就别用拷贝队列了,改传指针配内存池。这一条对任何内核都成立。

4.3 Flash 与 RAM 占用:差距能到十倍

这一项的数据分化最大,也是选型时最应该看的一张表。注意这是「最小可运行内核」口径,不含应用代码。

内核Flash(最小)RAM(最小)Flash(典型)RAM(典型)
RT-Thread Nano5.6 KB0.9 KB9.2 KB3.1 KB
FreeRTOS6.4 KB1.0 KB10.8 KB3.6 KB
TencentOS-tiny6.1 KB1.1 KB11.4 KB3.9 KB
RTX5 5.58.3 KB1.4 KB13.6 KB4.4 KB
ThreadX 6.29.7 KB1.6 KB15.2 KB5.0 KB
µC/OS-III12.4 KB2.3 KB19.8 KB6.7 KB
Zephyr 3.526.8 KB4.6 KB48.5 KB12.3 KB
NuttX 12.x31.2 KB5.8 KB55.7 KB14.1 KB

RT-Thread Nano 在最小配置下最省,这个结果和它的设计目标一致——Nano 版本就是奔着「极简可裁剪」去的。而 Zephyr 和 NuttX 的占用是数量级上的差异,因为它们的「最小可运行」里已经包含了设备模型、启动时各子系统的初始化。这不是缺点,是定位不同。

这里有个我踩过的坑:µC/OS-III 的 RAM 占用里,有一大块是固定开销的 TCBP 指针表和就绪表,跟任务数关系不大。如果你的系统只有 2 个任务,用 µC/OS-III 其实不划算;但如果任务数上到 20 个,它的相对优势反而出来了,因为它的位图调度是 O(1) 复杂度,任务越多越划算。所以我一直强调,脱离任务规模谈内存占用没有意义。

4.4 中断延迟与关中断窗口

最后一组数据,用示波器测的 GPIO 边沿到 ISR 翻转,以及 SysTick/PendSV 里的最长临界区宽度。

内核中断延迟(周期)最长关中断(周期)关中断时间(µs)
ThreadX 6.224380.53
RTX5 5.522420.58
FreeRTOS 10.4.626560.78
TencentOS-tiny 2.625610.85
RT-Thread Nano 3.1.527680.94
µC/OS-III 3.0825941.31
Zephyr 3.529761.06
NuttX 12.x30881.22

中断延迟本身都很接近,因为前 12 个周期是 NVIC 硬件固定的压栈动作,软件部分差异不大。真正拉开差距的是关中断窗口。ThreadX 和 RTX5 在这项上表现最好,因为它们的临界区设计得更克制,用basepri屏蔽的优先级更低,允许更多中断穿过。µC/OS-III 用PRIMASK全关,窗口自然最长。

这个指标在什么场景下致命?做电机 FOC 控制、做高速 PWM 捕获、做 CAN 总线的场合,1µs 的关中断抖动就可能导致采样点偏移。这种时候,切换快 0.3µs 毫无意义,关中断短才是命根子。

5. 最容易踩的三个误判陷阱

5.1 编译优化等级:一夜之间能从倒数变第一

这是所有误判里最离谱的一个。同一个 FreeRTOS 工程,-O0测出来的切换时间是 218 周期,-O2测出来是 117 周期,差了 86%。如果你看的跑分图里,A 内核用了-O2,B 内核用了-O0,那这张图等于废纸。

更隐蔽的是-Os-O2的差别。-Os为了省空间会牺牲一部分内联,在调度路径这种热函数上,-O2通常快 5% 到 12%。但如果你把-O2用在 Flash 只有 32KB 的片子上,可能一个内核直接装不下,那就只能退回-Os,这时候排行榜又要变。

我给你的判断标准很简单:看别人的跑分,第一件事问清楚编译器、优化等级、内联设置。答不上来的,数据直接丢掉。自己做对比,就把优化等级写进测试报告的标题里,逼自己不忘。

5.2 滴答频率与延迟精度:1000Hz 不是万能药

很多人测任务延迟用vTaskDelay(1),然后抱怨「怎么实际延迟了 1.8ms」。这不是内核的问题,是 tick 精度的本质。1ms 的节拍意味着延迟分辨率就是 1ms,加上任务唤醒到实际执行还要排一次调度,抖动叠加起来 1.8ms 完全正常。

解决办法有两类。一类是把 tick 提到 10000Hz,延迟精度到 0.1ms,但代价是每秒多 9000 次中断,CPU 空转开销上去了,低功耗设计基本告吹。另一类是用 tickless 模式,空闲时停止节拍,靠下一个到期时间点唤醒,兼顾精度和功耗,但 tickless 的移植在不同内核上成熟度差异极大,有些内核的 tickless 实现有已知的唤醒漂移问题。

我在项目里的做法是:控制环路用硬件定时器直接触发中断,不依赖 RTOS 的软件延迟;只有非实时的人机交互、日志上报这类逻辑才用vTaskDelay,1ms 误差无所谓。把 RTOS 的延迟精度用在对的地方,比纠结哪个内核延迟少 0.2ms 有价值得多。

5.3 Flash 等待周期与主频:换块板子结论就翻

这个陷阱最隐蔽。我在 GD32F103 上把主频从 72MHz 拉到 108MHz 复测了一遍,结果有些内核的相对排名变了。

原因在 Flash 等待周期。72MHz 时是 2 个等待周期,108MHz 时要加到 3 个甚至更多,取指延迟的比例变化对不同内核的影响不一样。代码密度高、分支少的内核,受取指等待影响小;调度路径上函数调用多、跳转多的内核,受影响就大。RT-Thread 和 Zephyr 在 108MHz 下的相对表现就比 72MHz 时差了一截。

还有一个变量是 SRAM 里的执行。如果你的调度器关键代码被搬到 SRAM 里跑(用__attribute__((section(".ramfunc")))),取指没有等待周期,切换时间能再降 15% 到 25%。这是一个实打实的优化手段,但很多人根本不知道可以这么干。

提示:如果你的切换延迟预算卡得很紧,优先考虑把 PendSV 处理函数和调度器核心函数搬进 SRAM,收益比换内核更明显。

6. 实战排查与移植避坑速查

6.1 常见问题速查表

现象大概率原因排查动作
任务切换时间异常大(>10µs)PendSV/SysTick 优先级配置错误检查是否设成最低优先级
系统跑一会儿就死任务栈溢出开栈检测钩子或填 0xAA 图案观察
中断里调用 RTOS API 后卡死用了非 FromISR 版本换带 FromISR 后缀的接口
延迟比设定值大一个 ticktick 精度不足提高节拍频率或改用硬件定时器
移植后串口打不出字栈大小不够或重定向未实现加大栈并检查 _write 重定向
Flash 一下多出十几 KB打开了调试/统计功能关掉 configUSE_TRACE_FACILITY 等
切换抖动忽大忽小供电纹波导致主频漂移示波器看 VDD,加去耦电容

这张表里的每一条我都真金白银踩过。尤其是第一条,我见过太多人把 PendSV 设成高优先级,结果每次切换都打断别的中断,测出来的数字惨不忍睹,然后到处说这个内核慢。这不是内核的锅。

6.2 移植时的几个实操心得

关于栈大小的确定,教科书上只会告诉你「估个值」。我的经验做法是:先用一个明显偏大的值跑起来,然后在任务栈底部预填充 0xAA 图案,跑满 24 小时业务压力测试后,统计被覆盖到的最深位置,再留 30% 余量。这个方法比任何静态分析都靠谱,因为实际调用深度往往在你意料之外的回调里。

关于日志存储,这是 MCU 项目里很实际的需求。如果 RTOS 开了 tickless,日志时间戳就只能靠 RTC 或者一个独立的高频计数器,用 tick 计数会得到跳跃的时间轴。我在采集项目里用的是「DWT 计数 + 每帧记录基准」的方案,既能算相对时间又省 RAM,比挂一个小文件系统轻得多。

关于 AI 辅助编写 RTOS 代码这件事,我的态度是:用它生成移植骨架、生成 API 调用示例是划算的,但绝不要让它替你决定优先级分配和栈大小。这两件事强依赖你的硬件时序和应用逻辑,AI 看不到你的示波器波形。我试过让它生成一个三任务的优先级方案,逻辑上自洽,但完全没考虑我的串口中断实际耗时 40µs,按那个方案跑必然丢数据。

最后说一个调试技巧。当你不确定性能问题出在内核还是应用时,先关掉所有应用任务,只留一个空转任务和一个翻转 GPIO 的任务,测这个最小系统的切换时间。如果这个值正常,说明问题在你的应用任务设计上,比如某个任务关中断太久、某个临界区范围过大;如果这个值就异常,那才是内核配置或者硬件平台的问题。这个二分法能帮你省下大量无谓的怀疑时间。

我个人在这八次移植里最大的体会是:选 RTOS 别只看跑分,先看你的任务数量、中断频率、内存预算、团队熟悉度这四个维度,四项都能对上,那个内核就是对的。至于那零点几微秒的切换差距,等你真的被 1µs 卡住的时候,你早就不会在 RTOS 层面找答案了,你会去改硬件、改架构、改算法。内核只是工具,别让它成了信仰。

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

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

立即咨询