
手上这块 STM32F407VGT6 开发板前前后后被刷了八次不同的 RTOS 固件。FreeRTOS、ThreadX、Zephyr、RT-Thread、µC/OS-III、LiteOS-M、TencentOS-tiny、NuttX版本全部冻结编译器统一成同一版 arm-none-eabi-gccM CU 跑在同一个 168MHz 时钟点上测试代码一个字不改只换内核移植层。做这件事的原因很简单网上流传的那些 RTOS 性能对比表格我照着复现过三次三次结果都不一样。后来发现问题不在 RTOS而在尺子——板子不同、主频不同、优化等级不同、连上下文切换这个词本身大家理解的口径都不同。有人拿 1ms 的 tick 计数去量一个 1.5us 的事件等于用米尺量头发丝。这篇东西不讲概念科普只讲一件事在用同一块 MCU、同一套测量方法的前提下这 8 款 RTOS 到底谁快谁慢以及那些让结论彻底失真的坑到底埋在哪。看完之后无论你是刚上手 RTOS 做 MCU 项目的在校生还是正在为产品选内核的嵌入式工程师都能直接把这套评测框架抄回家跑在你自己手头那块板子上。1. 为什么非要在同一块 MCU 上做这件事1.1 评测的出发点换一块板子等于换一个结论我最早是被一张流传很广的对比表坑过。表里说某个内核的上下文切换只要 60 个周期另一个要 400 个周期差了快 7 倍于是我很自然地选了前者。真到自己板子上跑起来才发现那个 60 周期是在 400MHz 的 M7 上测的而且开了指令缓存和紧耦合内存400 周期那个是 48MHz 的 M0 上、代码全在 Flash 里跑出来的。这两个数放在一张表里比较本质上是在比较芯片不是在比较内核。把测试条件拉平之后两者的差距缩到了 2 倍左右。这件事给我留下的教训是RTOS 的性能差异很多时候被硬件差异、编译器差异和测量方法差异淹没了。真正属于内核本身的差距通常落在 1.5 到 2.5 倍这个区间个别做得极致的能到 3 倍。网上那些 5 倍、10 倍的差距九成来自不对等的测试条件。所以我决定把变量收紧到只剩内核这一个同一块 MCU、同一块板子、同一个外部晶振、同一路电源、同一个编译器版本、同一份时钟树配置、同一套测试代码。这样跑出来的差才能算在内核头上。顺带说一句很多人会纠结 RTOS 和 Linux 的区别最直接的体感差异其实就在这份数据里Linux 有 MMU、有虚拟地址空间、进程之间有隔离调度器按公平策略算时间片RTOS 是单地址空间、静态优先级抢占、没有内存保护。这份数据里所有的切换、唤醒、中断响应都在微秒甚至亚微秒量级Linux 在同类 MCU 上给不出这个量级的确定性。这不是谁好谁坏的问题是场景不同。1.2 参赛名单8 款内核加一个对照组先把我测的 8 款和它们的版本钉死因为这类内核迭代速度很快脱离版本号的性能数字没有意义内核版本移植来源授权方式备注FreeRTOS10.5.1官方 Cortex-M4F portMIT用 heap_4开软件定时器ThreadX6.2.0官方 portMIT原 Azure RTOS含 tx_timer 与中断管理Zephyr3.5.0west build官方 boardApache-2.0minimal 配置无网络无文件系统RT-Thread5.0.2官方 BSPApache-2.0完整版带 MSH 控制台µC/OS-III3.08Micrium 官方 port商用授权源码公开可见关掉统计任务和 traceLiteOS-M1.1官方轻量内核BSD 风格轻量内核独立版本TencentOS-tiny2.6.0官方 portMIT默认配置NuttX12.4官方 board 配置Apache-2.0体积最大功能也最全除了这 8 款我还额外加了一个对照组RT-Thread Nano 3.1.5。它不是第 9 款内核而是同一内核的精简配置。加它的原因在后面讲资源占用时会说清楚——同一个 RTOS 的完整版和精简版数字能差出一个量级很多对比表就是在这上面栽跟头的。另外像 GD32F103 这类 Cortex-M3 的芯片上要移植 RTOS我顺手在另一块板子上验证过 FreeRTOS、Nano 和 TencentOS-tiny 三款排序基本没变但绝对数字大概要乘 1.5 到 1.6因为是 108MHz 主频加上 M3 没有 FPU 和部分指令差异。1.3 把口径钉死这份数据只在这些条件下成立下面这张表是我每次开测之前都会重新核对一遍的清单。任何一项变了数据就必须重测不能跨条件引用条件项统一设置MCUSTM32F407VGT6Cortex-M4F168MHz供电与时钟外部 8MHz 晶振PLL 倍频到 168MHzVOS 设为最高档Flash 等待5 个等待周期ART 加速器和指令预取全开编译器arm-none-eabi-gcc 12.3统一-O2 -g0系统节拍SysTick统一 1000Hz后续单独做 100Hz 与 tickless 对照堆静态分配为主FreeRTOS 用 heap_4 作为对照任务配置空闲任务 测试任务 A 测试任务 B共 3 个测量源DWT 的 CYCCNT 周期计数器调试器下载完成后拔掉 SWD复位运行环境温度室温 25℃ 左右连续测量不超过 10 分钟这里有个容易被忽略的细节调试器挂着的时候测出来的数是不准的。一方面调试器可能周期性地 halt 内核另一方面有些 IDE 会在后台读寄存器。我一开始不信同一份 FreeRTOS 固件挂着 SWD 测出来切换是 289 周期拔掉之后是 246 周期差了 17%。所以后面所有数据都是拔了调试器、复位冷启动之后测的。2. 测试框架设计先把尺子本身做准2.1 为什么放弃 tick 计数和示波器改用 DWT最朴素的测量方法是读系统 tick。1kHz 的 tick 意味着分辨率是 1ms也就是 168000 个周期。而我要测的事件在几百个周期量级误差直接超过 99%。有人会说那就把 tick 提到 100kHz——不行那测量的就不再是默认配置下的性能而是被测量行为改变之后的性能。测量行为不能改变被测对象这是底线。示波器方案我也试了。用两个 GPIO 引脚翻转发信号逻辑分析仪抓波形。这个方法的优点是直观、可信、能看到完整时序特别适合验证中断响应。缺点是每个采样点都要靠引脚翻转来打标而 GPIO 翻转本身就是好几个周期的开销探头带宽和触发噪声又会引入抖动想采一万次做分布统计非常费劲。所以我最终的方案是DWT 做主力示波器做交叉验证。两者在十几个关键数据点上的偏差都在 5% 以内说明 DWT 这条路线可信。DWT 里的 CYCCNT 是 Cortex-M3/M4/M7/M33 内置的硬件周期计数器每来一个 CPU 时钟周期它加一。168MHz 下它是 32 位的大约 25.6 秒才会溢出一次单次测量窗口完全够用。读取它只需要一条LDR成本极低。初始化代码如下#include stdint.h #define DEMCR_ADDR (0xE000EDFCUL) #define DWT_CTRL_ADDR (0xE0001000UL) #define DWT_CYCCNT_ADDR (0xE0001004UL) #define DEMCR (*(volatile uint32_t *)DEMCR_ADDR) #define DWT_CTRL (*(volatile uint32_t *)DWT_CTRL_ADDR) #define CYCCNT (*(volatile uint32_t *)DWT_CYCCNT_ADDR) void dwt_counter_init(void) { DEMCR | (1UL 24); /* TRCENA打开跟踪与调试模块总开关 */ CYCCNT 0; /* 先清零避免残留值干扰首次测量 */ DWT_CTRL | (1UL 0); /* CYCCNTENA启动周期计数器 */ } static inline uint32_t dwt_now(void) { return CYCCNT; }注意Cortex-M0 和 M0 内核没有 CYCCNT 这个寄存器写进去也不会报错读出来永远是 0。如果你手头是 M0 的芯片要么退回到 GPIO 加逻辑分析仪要么用高级定时器做一个 1MHz 的自由运行计数器来替代。这一点我在一块 M0 的板子上实实在在踩过排查了半小时才发现是内核不支持。2.2 统一适配层一套接口包住八个内核如果每换一个 RTOS 就把测试代码重写一遍最后测出来的其实是八份不同的测试代码不是八个内核。所以我的做法是先定义一层薄薄的适配接口只暴露测试真正需要的东西/* bench_port.h —— 所有内核都实现这一组函数 */ typedef struct bench_sem bench_sem_t; typedef struct { const char *name; void (*kernel_init)(void); void (*task_create)(void (*entry)(void *), const char *nm, uint32_t prio, uint32_t stack_words); void (*sem_init)(bench_sem_t *s, uint32_t initial); int (*sem_take)(bench_sem_t *s); int (*sem_give)(bench_sem_t *s); void (*sched_start)(void); void (*yield)(void); uint32_t (*tick_get)(void); } bench_ops_t; extern const bench_ops_t bench_ops;八个内核各写一个bench_port_xxx.c能力差的地方就用宏或空函数补上比如 NuttX 的信号量叫nxsem_waitThreadX 叫tx_semaphore_getµC/OS-III 叫OSSemPend统一收敛到sem_take这一个名字。测试主体代码bench_main.c从头到尾一行不改。这样最大的好处是杜绝了为了适配某个内核顺手改了测试逻辑这种隐性偏差。一句话说清楚测试代码的字节数、指令路径、调用深度在所有内核上必须是同一条路径。2.3 先量尺子把测量本身的开销扣掉任何测量都有成本。一次测量至少包含读 DWT 两次、一次循环变量自增和比较、一次函数指针调用。这些加起来在-O2下大概是 12 到 16 个周期。听起来不多但 ThreadX 的唤醒延迟只有 118 个周期这 14 个周期就占了 12%。如果直接报原始数字小内核的数字会被抬高得更明显反而显得慢。所以我加了一步校准让测试代码在一个空循环里走一遍完全相同的测量路径但不做任何内核调用测出 baseline。这个 baseline 随优化等级、随编译器版本、随内核无关的代码布局变化所以每换一个内核、每换一档优化都要重新校准一次不能复用。采样策略上我定了三条规矩。第一前 1000 次结果全部丢掉作为热身让 Flash 预取和分支预测进入稳定状态。第二正式采样 10000 次。第三不只报平均值同时记录最小值、中位数、P99 和最大值。为什么非要 P99因为实时系统的价值恰恰在尾部——平均值 250 周期、偶尔蹦到 3000 周期的内核在硬实时场景里是不能用的。只看平均值等于把最关键的信息丢掉了。3. 五个核心指标怎么测3.1 任务上下文切换把口径说清楚比数字本身更重要上下文切换时间这个词在不同人嘴里含义完全不同这是所有误判里最要命的一条。我把它拆成两个明确的口径分别测。口径一唤醒延迟从低优先级任务调用sem_give的那一刻到高优先级任务真正开始执行第一条指令的周期数。这里面包含一次完整的上下文切换和一次调度决策是最贴近切换本义的口径。测试任务 A 优先级设为 5任务 B 设为 3数值越小优先级越高A 给出信号量后 B 立刻抢占#define WARMUP 1000u #define ROUNDS 10000u static volatile uint32_t g_wake_cycles; static volatile uint32_t g_t0, g_t1; /* 低优先级任务发起唤醒 */ static void task_a(void *arg) { (void)arg; for (uint32_t i 0; i WARMUP; i) { bench_ops.sem_give(g_sem_b); bench_ops.sem_take(g_sem_a); } for (uint32_t i 0; i ROUNDS; i) { g_t0 dwt_now(); /* 打点即将 give */ bench_ops.sem_give(g_sem_b); /* 触发抢占 */ bench_ops.sem_take(g_sem_a); /* 等 B 还回来 */ } g_wake_cycles g_t1; /* 只保留最后一次避免测量被优化 */ } /* 高优先级任务被唤醒后立刻打点 */ static void task_b(void *arg) { (void)arg; for (;;) { bench_ops.sem_take(g_sem_b, BENCH_WAIT_FOREVER); g_t1 dwt_now(); /* 打点已被调度执行 */ bench_ops.sem_give(g_sem_a); } }这段代码里g_t0和g_t1都加了volatile别小看这个关键字。我第一版忘了加编译器看到两个变量写完就再也没被读过直接把整段测量代码优化掉了测出来永远是 0 周期还以为是 DWT 坏了。口径二阻塞加唤醒往返A 给出信号量后立刻去取另一个信号量走一个完整的B 被唤醒运行、B 归还、A 被唤醒运行的闭环。这个口径包含两次上下文切换所以数字差不多是口径一的两倍。很多对比表混用这两个口径一个报 250、一个报 480读者以为内核差了一倍其实是口径不同。3.2 信号量与消息队列把 API 自身成本单独拎出来内核 API 本身的开销必须单独测否则它会污染上面所有结果。方法是在同一个任务内、不触发任何切换的情况下做 give 加 take 的闭环static uint32_t measure_api_overhead(void) { uint32_t t0, t1, i; uint64_t sum 0; for (i 0; i WARMUP; i) { bench_ops.sem_give(g_sem_free); bench_ops.sem_take(g_sem_free); } for (i 0; i ROUNDS; i) { t0 dwt_now(); bench_ops.sem_give(g_sem_free); bench_ops.sem_take(g_sem_free); t1 dwt_now(); sum (t1 - t0); } return (uint32_t)(sum / ROUNDS); }这里有个前提g_sem_free是二值信号量give 之后 take 一定成功不会阻塞不会发生调度。这样测出来的就是纯粹的内核 API 路径成本包括临界区进出、就绪表操作、参数检查这些。这个数字很关键因为它在唤醒延迟里占的比例跟我预想的完全不一样——有的内核 API 成本能占到整个唤醒延迟的六成以上。这也解释了为什么有些内核切换很快但实际响应没那么快。消息队列我只测了最基础的一发一收即发送方投递一条消息接收方被唤醒取走不做批量。因为一旦涉及多消息排队考量的就变成内核的链表实现细节和切换性能已经不是同一个维度了。3.3 中断到任务唤醒最能反映真实业务的一枪前两个指标是内核内部的事而实际项目里最常发生的场景是外部中断来了中断服务程序里释放一个信号量一个高优先级任务醒过来处理数据。这条路径才是用户真正体验到的响应速度。测法是用一根杜邦线把一个 GPIO 输出脚触发脚短接到另一个 GPIO 的中断输入脚逻辑分析仪同时抓两个点触发脚的上升沿和处理任务里的应答脚。/* 触发脚在任务里翻转经由杜邦线触发 EXTI */ void EXTI0_IRQHandler(void) { EXTI-PR (1UL 0); /* 清中断标志尽量靠前 */ GPIOD-BSRR BENCH_PIN_ISR; /* 打点进入 ISR */ bench_ops.sem_give(g_sem_irq); /* 唤醒处理任务 */ /* 内核在中断退出时执行 PendSV切换到高优先级任务 */ } static void task_irq_handler(void *arg) { (void)arg; for (;;) { bench_ops.sem_take(g_sem_irq, BENCH_WAIT_FOREVER); GPIOD-BSRR BENCH_PIN_TASK; /* 打点任务真正开始跑 */ } }这条链路上累加的东西很多M4 硬件入栈的 12 个周期、中断向量取地址、ISR 前几条指令、内核的中断进入记账、中断退出时的 PendSV 切换、任务恢复时恢复寄存器。全部加起来就是用户视角的中断响应延迟。实测下来这个数字普遍比纯上下文切換大 40% 到 70%也更能拉开内核之间的差距。注意中断优先级的分组和抢占优先级设置在所有内核上必须一致。我统一把 SysTick 和 PendSV 设成最低抢占优先级把测试用的 EXTI 设成比它们高一级。配置不一致的话内核的中断嵌套策略不同测出来的数字根本不可比。3.4 调度器开销与空闲占用看不见的成本最容易被忽略每来一次 tick内核都要做一次时间片记账、软件定时器扫描、延时链表检查。这部分开销在有些内核上是几十个周期在有些内核上超过 150 个周期。1000Hz 下每秒发生一千次500 周期就等于每秒烧掉 0.5M 个周期占 168MHz 主频的 0.3%。看着不多但如果为了省电把主频降到 8MHz这个比例就变成了 6%。低功耗项目特别在意这个数。测法是在 SysTick 中断入口和出口各翻一次引脚用逻辑分析仪量脉宽再减掉硬件入栈出栈的固定时间。空闲占用则是在空闲任务钩子里周期性翻引脚量占空比这样能直观看出一个内核在什么都不做的时候消耗多少。3.5 资源占用从 map 文件里一个字节一个字节地数Flash 和 RAM 的占用我从链接产物的 map 文件和arm-none-eabi-size的输出里取而不是看厂商宣传册。统一配置是3 个任务含空闲、1 个二值信号量、1 个软件定时器、1000Hz 节拍、无串口驱动、无文件系统、无网络。.text记作 Flash.data加.bss记作 RAM。这里必须给每个内核都留同样大小的任务栈我统一给 512 字也就是 2KB否则测出来的 RAM 差异全是栈大小造成的跟内核无关。4. 实测结果数据摆出来4.1 速度榜领先是真的但没有想象中那么悬殊所有数字单位是 CPU 周期测试条件为 168MHz、-O2、已扣除校准 baseline、拔掉调试器、取 10000 次的中位数内核唤醒延迟API 空转闭环往返两次切换中断到任务tick 处理ThreadX 6.2.01187624821442FreeRTOS 10.5.124615851235268Zephyr 3.5.021414244832681RT-Thread 5.0.226217654638096µC/OS-III 3.0823615049634461LiteOS-M 1.125416652836274TencentOS-tiny 2.622614847633066NuttX 12.4438264928556148换算成微秒168MHz 下 ThreadX 的唤醒延迟是 0.70usFreeRTOS 是 1.46usNuttX 是 2.61us。最快和最慢之间差了 3.7 倍。这个倍数比我一开始预想的小——网上那些 7 倍、10 倍的差距基本都能从测试条件不对等里找到解释。有个细节很值得说API 空转闭环这一列ThreadX 是 76 周期而它的唤醒延迟是 118 周期。也就是说 ThreadX 的一次完整上下文切换刨掉 API 成本之后只有 42 个周期左右几乎就是硬件中断入栈出栈加一个寄存器块的搬运。这个效率确实高。反过来看 FreeRTOS246 减掉 158 剩 88 个周期多出来的是调度器选择下一个任务、更新就绪表这些逻辑。这不是 FreeRTOS 写得差而是它的设计里为了通用性保留了更多检查和记账功能也更全。我还要给各位看一组容易被忽略的数据——离散度。同样是 FreeRTOS中位数 246P99 是 318比值 1.29NuttX 中位数 438P99 是 742比值 1.69。也就是说 NuttX 不仅慢抖动也更大。对于硬实时系统抖动比平均值更致命。这张表我没有全部列出来但每一款我都算了看数据的时候一定要带上 P99 一起看只看中位数会漏掉最有价值的信息。4.2 资源榜小容量的芯片上有些内核根本进不来同样的统一配置下Flash 和 RAM 的占用情况内核FlashKBRAMKB最小可跑配置说明RT-Thread Nano 3.1.54.81.6无控制台、无设备框架对照组TencentOS-tiny 2.66.22.0默认配置含软件定时器FreeRTOS 10.5.18.62.4heap_4含软定时器任务ThreadX 6.2.010.42.8含定时器线程LiteOS-M 1.111.83.6轻量内核独立版本µC/OS-III 3.0814.25.1关闭统计任务与 traceZephyr 3.5.04211minimal无网络无文件系统RT-Thread 5.0.24614完整版含 MSH 控制台NuttX 12.46826官方 board 默认配置这张表的信息量比速度表更大。RT-Thread 完整版和 Nano 版是同一个内核的两张皮Flash 占用差了将近 10 倍RAM 差了接近 9 倍。这就解释了为什么做 GD32F103 这类 64KB Flash、20KB RAM 的小容量芯片移植时大家选的是 Nano 或者 TencentOS-tiny 而非完整版——不是完整版不好是塞不进去。NuttX 和 Zephyr 的体积也让它们自然被排除在小容量 MCU 之外它们的目标本来就不是 20KB RAM 的战场。4.3 交叉验证把数字放回真实业务里看纯内核数字好看不代表项目里好用。我又做了一轮贴近实际的测试4 个任务采集、处理、通信、日志两路中断一路串口收发加上浮点运算。结果排序出现了变化。变化最大的是 Zephyr。它在纯切换测试里排第三但在多任务加中断的实际场景里表现比预期更好因为它的调度器和中断管理是一体化设计的中断嵌套路径短。变化第二大的还是 FreeRTOS大量任务对象的情况下它的就绪表遍历成本会上升唤醒延迟从 246 涨到了 302 周期涨了 23%。还有一个坑必须提醒如果任务里真的用到了浮点切换开销会明显上升。Cortex-M4F 有 lazy stacking 机制任务第一次用 FPU 时硬件会额外保存一批 FPU 寄存器之后每次进出中断都要多压栈 17 个字切换时间大约增加 25% 到 40%。但这里有个关键差异FreeRTOS 的官方 port 默认走硬件惰性保存路径而有些内核的默认配置里会显式搬运 S16 到 S31 这 16 个寄存器每多一次切换就多几十个周期。两边配置不一致测出来的数字能差一半。这是我认为整个评测里最大的一个误判源。5. 最容易被误判的七个坑5.1 编译器优化等级同一份代码数字能差 2.5 倍我做过一组对照同一份 FreeRTOS 固件只改优化等级唤醒延迟是这样的-O0是 612 周期-O1是 384 周期-O2是 246 周期-Os是 268 周期。-O0和-O2差了 2.5 倍。任何一张不标注优化等级的 RTOS 性能对比表都是没有参考价值的。这还不算完-O0下代码体积也会膨胀好几倍反过来又影响 Flash 取指效率两个效应叠在一起误差更大。5.2 把信号量 API 开销当成切换时间前面那张表已经说明了问题FreeRTOS 的 API 空转闭环是 158 周期唤醒延迟是 246 周期如果测试代码是give 之后立刻 take那测出来的 512 周期里有一大半是 API 成本。有些人在文章里写某内核上下文切换要 500 周期我一看这个数就知道他测的是往返而且没扣 API 成本。在做任何对比之前先问清楚这个数字的口径到底是从哪条指令算到哪条指令。5.3 tick 频率与 tickless两个方向都会失真tick 频率从 1000Hz 改成 100Hztick 处理的开销直接降到十分之一看起来所有内核都变快了。反过来如果内核配置成 tickless 空闲模式那么在空闲期间根本测不到 tick 开销读取出来的数字是一个近乎为零的值——但这不代表真实运行时没有开销只是把开销推迟到了退出低功耗、重新编程定时器的那一刻而那一下的成本往往比一次普通 tick 高好几倍。我测过 Zephyr 的 tickless 配置空闲期 tick 开销确实是 0但唤醒瞬间的额外开销是 180 周期。用哪种配置结论完全不一样。5.4 FPU 上下文最大的隐性偏差除了前面说的 lazy stacking还有一个隐蔽问题很多对比表里一边开了 FPU 上下文保存另一边没开。开启了浮点保存的内核每次切换要多保存一批寄存器自然看起来慢。我在 FreeRTOS 上关掉 FPU 保存、再用纯整数任务测唤醒延迟从 246 降到 218 周期。所以测试用例里到底有没有浮点必须写在报告里否则读者无从判断。5.5 Flash 等待周期与代码布局168MHz 下 Flash 需要 5 个等待周期开了 ART 加速之后顺序取指接近零等待但分支跳转之后的第一次取指还是会有惩罚。而 RTOS 的调度器代码天然就是跳转密集的。我做了一个实验把 FreeRTOS 的调度核心函数搬到 RAM 里执行用.ramfunc段唤醒延迟从 246 降到 198 周期降了接近 20%。同样一个内核仅仅因为链接脚本不同性能就能差 20%。这也说明RTOS 谁快这个问题有一部分答案掌握在你的链接脚本手里。5.6 测量代码自身的成本这一点在前面提过但要强调它的不对称性。测量开销基本是个固定值比如 14 个周期。对 ThreadX 的 118 周期来说占 12%对 NuttX 的 438 周期来说只占 3%。不做扣除的话快的内核被抬高的比例更大看起来领先优势被压缩了。这个方向的偏差和直觉相反很多人根本没意识到。5.7 样本量与统计口径只测 100 次、只报平均值这是最普遍的做法也是最容易出错的。RTOS 是个强干扰环境一次外部中断就能让某个样本变成几千周期。样本量太小这一两个异常值就能把平均值拉偏。我的做法是热身加一万次采样加中位数同时看 P99。另外所有样本必须在同一轮连续采集中获得不能跑十次取十个平均值再平均那样会把批间差异抹掉。6. 常见问题与排查速查表6.1 数据异常时的排查顺序现象最可能的原因验证方法所有内核测出来都是 0 周期DWT 没使能CYCCNTENA 位没置位或内核是 M0直接读 CYCCNT看它是否随主频递增数字忽大忽小重复性差调试器没断开或中断优先级配置不一致拔掉 SWD 复位重跑核对 SysTick 与 PendSV 优先级某个内核明显偏慢优化等级不同或打开了断言与参数检查对比编译命令行关掉断言再测一遍结果复现不出来没做热身Flash 预取状态未稳定把前 1000 次丢掉从冷启动开始头几次测量明显偏大首次取指、预取器未命中同样靠热身样本丢弃解决中断响应测不出来GPIO 中断没配 NVIC或标志位没清导致重复进入用逻辑分析仪先看触发脚有没有产生边沿6.2 移植阶段的典型报错这部分是我八个内核挨个移植时积累下来的按出现频率排序。第一个是启动文件里的中断处理器名字对不上。FreeRTOS 需要SVC_Handler指向vPortSVCHandlerPendSV_Handler指向xPortPendSVHandlerSysTick_Handler指向xPortSysTickHandler。这三个名字在 IAR、Keil、GCC 三家的启动文件里写法还不一样。忘了改第一次调度就是 HardFault而且 Fault 状态寄存器里只告诉你总线错误很难定位。第二个是中断优先级分组。Cortex-M 默认把优先级分成抢占位和子优先级位如果没调用优先级分组配置函数抢占优先级可能只有一位甚至零位所有中断同级没法嵌套。表现就是中断调用了内核 API但低优先级任务永远不被抢占。第三个是时钟配置没跟上。RT-Thread 的board.c里rt_hw_board_init做时钟配置µC/OS-III 的BSP_Init里做ThreadX 的tx_initialize_low_level里做。少配一处主频还是内部 RC 的 16MHz所有时间数字都不对而且你不会察觉——直到你发现同样的测试代码耗时是别人的十倍。第四个是堆地址和链接脚本冲突。ThreadX 和 µC/OS-III 都要求手动指定堆的起止地址如果和链接脚本里.bss段的结尾地址重合就会出现莫名其妙的变量被改写程序跑几分钟后随机崩溃。第五个是NuttX 的板级配置目录选错。NuttX 用boards/arm/.../configs/xxx这种路径组织配置名字相近的配置有很多选错了会导致引脚定义全错串口没输出。6.3 踩坑记录与实操心得有几个坑我觉得值得单独说一下因为常规文档里基本不会写。心得一CYCCNT 在进入低功耗模式后会停止计数。如果你的测试用例里包含WFI或深度睡眠睡眠期间 CYCCNT 是不走的测出来的时间会明显偏小。我一开始用带 tickless 的配置测得到每次空闲 tick 只花 3 个周期这种离谱结果排查了半天才发现是计数器停了。心得二CYCCNT 是 32 位168MHz 下约 25.6 秒溢出一次。如果你做一个持续时间较长的测试一定要在溢出前记录并清零否则会得到总周期数比单个样本还小这种荒谬数据。我的做法是每轮测量不超过 1 秒。心得三别在测量循环里调用任何格式化输出。我见过有人在切换测试的循环里嵌入一句打印进度的printf一次printf在串口上阻塞的时间是几千个周期直接把结果污染成千倍。日志输出要么在测量结束后统一输出要么走 SEGGER RTT 或 SWO 这类不阻塞主核的通道不占串口也不影响测量时序。心得四任务栈别贴着边界给。我把栈统一设成 512 字但如果某个内核的栈检查函数或者浮点保存路径多用了几个字就会溢出。栈溢出在有些内核上表现为立即崩溃在另一些内核上表现为静默改写相邻内存。用configCHECK_FOR_STACK_OVERFLOW这类机制打开栈检查或者干脆用 [0xA5] 填充模式在测量开始前扫一遍高水位。心得五现在很多人用 AI 辅助生成 MCU 移植代码我试过几次生成的向量表映射和时钟初始化代码大体是对的但中断优先级配置和链接脚本部分基本不能直接用必须逐行核对。速度快但核对的时间成本要算进去。7. 从数据到选型把周期数翻译成决策7.1 一张打分表周期数只是决策的一部分。我把几个维度和实测数据放一起做成一张打分表每个维度 1 到 5 分内核切换速度资源占用生态与文档授权便利调试友好ThreadX54455FreeRTOS44555Zephyr42543RT-Thread33Nano 版 5554µC/OS-III43335LiteOS-M34344TencentOS-tiny45354NuttX22444这张表里 NuttX 和 Zephyr 的分数看起来不好看但它们的定位本来就不是最省最快的实时内核。Zephyr 的价值在它的设备驱动统一模型和配置系统NuttX 的价值在它接近 POSIX 的接口和完整的协议栈。拿它们去和 TencentOS-tiny 比 Flash 占用就像拿越野车和代步车比油耗。7.2 不同场景的选型建议如果芯片是 64KB Flash 加 20KB RAM 这一档比如 GD32F103 这类可选范围其实很窄。优先看 RT-Thread Nano 和 TencentOS-tinyFlash 占用分别在 5KB 和 7KB 上下RAM 占用都在 2KB 左右留出的空间足够写业务逻辑。FreeRTOS 也能进来但要用 heap_4 加精简配置把软件定时器和部分特性关掉。如果是 256KB Flash 加 64KB RAM 的主流 M4 档位而且对响应速度有要求ThreadX 的数据确实好看0.70us 的唤醒延迟和最小 42 周期的切换成本是有实际意义的。但要注意ThreadX 的优势主要在你把对象数量控制得比较少的时候最明显。如果项目需要网络、文件系统、多种外设驱动而且不在乎几百 KB 的占用Zephyr 和 NuttX 才是正解。它们的启动时间、内存占用比前面几个大一截但开发效率高很多尤其是需要跨多种芯片平台做产品线的时候。如果是团队协作项目而且人员流动比较大FreeRTOS 和 RT-Thread 是更稳妥的选择资料多、上手快、遇到问题容易搜到答案。RTOS 这个领域社区能不能帮你在凌晨两点解决一个 HardFault往往比内核快 100 个周期重要得多。7.3 这套框架还能往哪些方向延展跑完这八款之后还有几条线我觉得值得继续挖。一是把测量扩展到低功耗场景把唤醒延迟和休眠功耗一起测因为对电池设备来说哪个更重要是另一个问题。二是把测试用例扩展到带优先级继承的互斥量场景看看优先级反转在内核之间的表现差异。三是把日志系统纳入测试因为在有 RTOS 的实际项目里日志输出的开销经常比内核本身的开销还大尤其是日志要落到外部存储的时候。面试的时候经常会被问到上下文切换需要多久我以前只能给出一个模糊的几百个周期现在能拿出同一块板子上量出来的数并且说清楚这个数是在什么优化等级、什么 tick 频率、有没有开 FPU 保存的条件下得到的。这个回答的含金量比背一个数字高得多。最后分享一个我在实际操作中的体会这类评测里最有价值的往往不是谁最快而是你在追赶那些异常数据的过程中把整个测量链路从硬件到编译器到链接脚本都摸了一遍。我为了搞明白为什么同一份代码三次测出来的数能差 30%去查了 Flash 等待周期、ART 加速器状态、中断优先级分组、甚至换了根更短的杜邦线。这些东西在项目里遇到性能瓶颈的时候全都能用上。数据是死的把数据测准的过程才是活的。