多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

同板实测8款RTOS:任务切换、中断延迟与内存占用对比

同板实测8款RTOS:任务切换、中断延迟与内存占用对比 MCU 选型会上最容易被问倒的一句话大概就是那句“FreeRTOS 和 RT-Thread 到底谁快”。三年前我第一次认真回答这个问题时掏出的是一张从网上抄来的跑分表结果被同事一句“你这数据是哪个板子哪个主频”问得哑口无言。后来自己动手测过几轮才发现网上流传的 RTOS 性能对比同一款内核在不同文章里的数字能差三倍以上很多时候差异根本不来自 RTOS 本身而来自 Flash 等待周期、编译优化等级、tick 频率、甚至有没有挂调试器。所以这次我干脆把条件全部钉死在同一块 MCU 上把 8 款主流 RTOS 挨个跑了一遍重点不只是“谁快”更是“谁的数字最容易被人看歪”。本文会完整给出测试方法、原始数据区间、配置代码、移植步骤和排查表面向正在做 MCU 选型的嵌入式工程师也适合刚接触 RTOS、想知道“该信哪张表”的朋友。所有数据都标明测试条件你可以按同样的方法在自己的板子上复现。1. 为什么我又做了一遍 RTOS 跑分1.1 网上那些表格坑在哪里先说我为什么会怀疑公开数据。同一款 FreeRTOS V10.4某篇中文博客写上下文切换 2.1 µs另一篇英文实测写 0.8 µs差了 2.6 倍。这两篇文章我都不认为作者在造假他们只是用了完全不同的尺子。差异主要来自五个地方每一个都能单独把结果拉开 30% 以上。第一个是主频和 Flash 等待周期。同样是 Cortex-M372 MHz 和 48 MHz 差 50%这个好理解但很多人会忽略 Flash 等待周期——STM32F103 在 72 MHz 下需要 2 个等待周期而 GD32F103 在 72 MHz 下官方推荐是 0 等待周期。如果你把 ST 的SystemInit直接搬到 GD32 上不改FLASH_SetLatency性能就白丢一截而这个损失会平摊到每一次取指上跑分自然难看。第二个是编译优化等级。-O0和-O2在上下文切换这种小函数密集的代码路径上差距可以到 40%。有些跑分表用的是 CubeIDE 默认的-O0Debug 配置有些用的是-Os横向比毫无意义。第三个是内核开关。FreeRTOS 的configGENERATE_RUN_TIME_STATS、RT-Thread 的RT_USING_OVERFLOW_CHECK和RT_USING_HOOK、µC/OS-III 的OS_CFG_STAT_TASK_EN这些功能默认打开会明显拉长切换路径。µC/OS-III 尤其典型默认配置里统计任务和 CPU 使用率统计是开着的很多人第一次跑出来觉得“这老古董怎么这么慢”其实关掉就正常了。第四个是堆和内存分配策略。用了动态堆FreeRTOS 的 heap_4、RT-Thread 的 TLSF和全部静态分配任务创建路径的差异很大如果你测的是“任务创建耗时”而不是“任务切换耗时”这个影响是决定性的。第五个也是最容易被忽略的测的时候有没有挂调试器有没有开 ITM/SWO 打印。挂着调试器单步、或者开着 SWO 连续输出CPU 会被周期性打断P99 直接翻倍。提示如果你只想要一个结论那就是——任何不带测试条件说明的 RTOS 跑分表都只能当参考不能当选型依据。所以这次我把能钉死的变量全部钉死只在 RTOS 之间变化其余全部一致。1.2 参赛名单8 款正式 2 款场外对照8 款正式参赛的 RTOS 选型不是随便凑的我按“实际项目里真有人用”这个标准来挑覆盖了精简内核、完整框架、商业血统、国产内核几条线。编号RTOS版本血统选它的理由1FreeRTOSV10.4.6 内核版亚马逊MCU 领域事实标准必须测2RT-Thread Nano3.1.5国产只搬内核和完整版形成对照3RT-Thread 完整版4.1.1国产含设备框架、msh、DFS4µC/OS-III3.05商业经典抢占式内核代码风格严谨5Zephyr3.2 minimal英特尔/Linux 基金会配置项极其丰富最能体现“默认值陷阱”6TencentOS-tiny2.4.6国产主打极小体积7LiteOS-MOpenHarmony 3.1 内核国产端侧生态里出现频率高8ThreadX6.1Azure RTOS微软公认切换开销极低场外对照还有两款ChibiOS/RT 20.3和NuttX 12。ChibiOS 因为移植层需要改的东西比较多我只在最小工程上跑了切换和内存两项NuttX 则比较尴尬——它的默认配置在这块只有 20 KB SRAM 的板子上压根跑不起来我只统计了 Flash 占用没参与排名。这个“跑不起来”本身就是一个很有价值的信息后面第 4 章会展开。2. 测量方案不想被自己骗就得先把尺子校准2.1 硬件与统一口径测试硬件是一块 GD32F103C8T6 最小系统板Cortex-M3 内核主频 72 MHzHSE 8 MHz 经 PLL 倍频Flash 64 KBSRAM 20 KBFlash 等待周期设为 0预取缓冲开启。板子上只焊了电源、复位、晶振和一根 SWD 排针没有 LCD、没有外部 Flash、没有 USB尽量减少干扰源。测量引脚用 PB0接逻辑分析仪做交叉验证。统一口径这部分我列成表格方便你照抄项目统一设定说明编译器arm-none-eabi-gcc 10.3全部同一版本优化等级-O2 -g0不用-Os避免内联策略差异架构参数-mcpucortex-m3 -mthumb -mfloat-abisoftM3 无 FPUtick 频率1000 Hz统一且都在计时测量中屏蔽 tick 干扰堆策略全部静态分配排除 heap 算法差异任务数3空闲 定时器 测试任务数量固定调试接口SWD 连接但不在测量时读取测量期间只供电串口输出测量前关闭测完统一打印避免 UART 中断扰动优先位数4 bit 全抢占不用子优先级分组这里有两个细节值得单独拎出来说。第一用-g0而不是-g3。调试信息本身不影响代码但-g3会让某些编译器调整内联决策虽然理论上不该发生我在实测里确实见过 3% 左右的波动索性关掉。第二优先位数只用 4 位抢占、不用子优先级。这是个经典坑很多人初始化时调NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)把 4 个优先级位拆成 2 位抢占 2 位子优先级然后 FreeRTOS 的configMAX_SYSCALL_INTERRUPT_PRIORITY就按 4 位抢占去算。结果是中断能打断内核临界区出现各种玄学死机或者“偶尔一次切换特别慢”。我在测之前统一改成NVIC_PriorityGroup_4让所有 4 位都是抢占优先级。2.2 GPIO 打点 vs DWT 周期计数测切换延迟有两种主流方法我都用了互相校验。GPIO 打点法任务 A 在切换前把引脚拉高任务 B 落地后把引脚拉低用逻辑分析仪量脉冲宽度。优点是绝对真实不受 CPU 时钟理解偏差影响缺点是引脚翻转本身有开销而且分辨率受限于分析仪采样率。这里有个小技巧别用GPIO_SetBits()直接用 BSRR 寄存器/* 快得多的打点方式省掉 HAL 库的函数调用和参数检查 */ #define MEAS_HIGH() (GPIOB-BSRR GPIO_Pin_0) #define MEAS_LOW() (GPIOB-BRR GPIO_Pin_0)我实测过GPIO_SetBits()到BSRR直写在 72 MHz 下差 20 多个周期也就是 0.3 µs 左右——这已经和切换延迟本身一个量级了用错方法等于在给自己制造误差。DWT 周期计数法用 Cortex-M3 内核自带的 DWT 单元的 CYCCNT 计数器精度是单周期不需要外部仪器。初始化代码很短static inline void dwt_init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; /* 打开跟踪单元时钟 */ DWT-LAR 0xC5ACCE55; /* M7 需要解锁M3 写了无害 */ DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; /* 启动周期计数 */ } #define DWT_CYCLE() (DWT-CYCCNT)注意 CYCCNT 是 32 位72 MHz 下大约 59.6 秒回绕一次。我的单轮测量不超过 100 ms不会溢出但你的测试循环如果跑得久记得自己处理回绕。测量循环大概长这样核心思路是“低优先级任务发信号高优先级任务被唤醒执行再返回”的往返时间#define SAMPLE_CNT 2000 static uint32_t g_samples[SAMPLE_CNT]; static volatile uint32_t g_wake_cycle; /* 高优先级任务被唤醒的瞬间打点 */ void task_hi(void *arg) { for (;;) { osSemaphoreAcquire(sem_go, osWaitForever); g_wake_cycle DWT_CYCLE(); osSemaphoreRelease(sem_back); } } /* 低优先级任务负责发起和记录 */ void task_lo(void *arg) { for (uint32_t i 0; i SAMPLE_CNT; i) { uint32_t t0 DWT_CYCLE(); osSemaphoreRelease(sem_go); osSemaphoreAcquire(sem_back, osWaitForever); g_samples[i] g_wake_cycle - t0; /* 端到端切换延迟单位 cycle */ } report_and_halt(); }要说清楚一点这个方法测到的不是纯粹的 PendSV 汇编耗时而是包含信号量操作在内的端到端延迟。纯切换耗时很难单独剥离而且对实际项目也没意义——你真正关心的是“我发一个信号量对方多久开始跑”。所以我从一开始就把口径定为端到端。换算公式很简单比如记录到 92 个周期切换时间(µs) 周期数 / 主频(MHz) 92 / 72 ≈ 1.28 µs注意DWT 计数器在低功耗模式下会停如果你的测试涉及__WFI()或者停止模式这个方法就失效了得回到 GPIO 打点 逻辑分析仪。2.3 为什么我只看中位数和 P99这是我觉得整篇文章最值得强调的一条方法论。很多跑分表给的是“平均切换时间”这是最容易被误导的统计量。因为切换延迟的分布是右偏的——大部分样本集中在 1.3 µs 左右偶尔被 SysTick 中断或者 Flash 取指冲突撞一下蹦到 5 µs。平均值会被这些长尾拉高同时又掩盖掉长尾的存在。你看到“平均 1.5 µs”可能实际情况是 99% 的样本 1.2 µs、1% 的样本 12 µs——对实时系统来说那 1% 才是要命的。所以我每款 RTOS 都记录 2000 个样本统计四个数字最小值、中位数、P99、最大值。中位数代表典型性能P99 代表抖动最大值用来发现异常路径。我还在测量前把 SysTick 优先级调到最低尽量减少它对短窗口测量的干扰。另外测量循环跑的时候串口全关只在最后一次性把统计结果打出来。如果你的测量代码里有任何printf那测的就不是 RTOS是串口驱动。3. 三组实测数据3.1 任务切换端到端延迟的全排名先给完整排名。单位是微秒2000 个样本条件如第 2 章所述。排名RTOS最小值中位数P99最大抖动来源1ThreadX 6.10.830.921.15无明显抖动2ChibiOS/RT 20.3场外0.911.041.22无明显抖动3FreeRTOS 10.4.61.111.281.60Flash 取指4RT-Thread Nano 3.1.51.241.422.10钩子函数5LiteOS-M1.311.553.40临界区较长6TencentOS-tiny 2.4.61.381.632.05位图查找7µC/OS-III关统计1.621.882.30优先级表遍历8Zephyr 3.2裁剪后1.701.912.45跟踪代码9RT-Thread 4.1.1 完整版1.952.353.90框架层开销有几个结论和直觉不太一样。ThreadX 拿第一不意外但差距没有传闻中那么大。网上有种说法是 ThreadX 比 FreeRTOS 快一倍以上我实测下来中位数领先约 28%已经很可观但没到翻倍。原因在于 FreeRTOS 在 Cortex-M3 上有configUSE_PORT_OPTIMISED_TASK_SELECTION这个开关打开后用CLZ指令做就绪表查找这个优化非常关键。如果你的 FreeRTOS 实测比我这慢很多先去检查这个宏。RT-Thread 完整版排最后但这个结论必须加限定词。它的切换路径里多了调度器钩子、线程栈溢出检查、以及rt_thread_self()之类的框架调用。把RT_USING_HOOK和RT_USING_OVERFLOW_CHECK关掉再测中位数能压到 1.72 µs 左右直接进前四。所以“RT-Thread 内核慢”这个说法是错的“RT-Thread 默认配置慢”才是准确的。LiteOS-M 的中位数不差但 P99 明显偏高。3.4 µs 的 P99 是这批里最差的。我查了一下它的部分内存操作和链表遍历是在关中断状态下做的临界区比同类内核长这使得它更容易被中断或者 Flash 取指撞上。平均快、抖动大这对硬实时场景是个隐患。µC/OS-III 的名次其实可以更好。这里测的是关掉统计之后的数字。默认配置下OS_CFG_STAT_TASK_EN 1中位数会掉到 3.1 µs 附近因为空闲任务在跑 CPU 使用率统计一直在抢总线。所以如果你看到有人说“µC/OS-III 慢”大概率是在默认配置下测的。3.2 中断响应与最长临界区切换延迟之外还有一个更贴近实时性本质的指标最长关中断时间。中断响应最坏情况等于“关中断剩余的时长 硬件入栈 取向量”硬件那部分大家都是 12 个周期Cortex-M3 自动入栈 8 个寄存器区别全在临界区。我用 GPIO 打点包住了每款 RTOS 里最长的一段临界区测出来的结果如下RTOS临界区屏蔽方式最长临界区µs中断入口实测延迟µsChibiOS/RTBASEPRI0.750.28ThreadXBASEPRI0.860.31FreeRTOSBASEPRI1.100.33TencentOS-tinyPRIMASK1.400.42RT-Thread NanoPRIMASK1.600.47Zephyr裁剪后BASEPRI1.250.36Zephyr默认BASEPRI2.000.55µC/OS-IIIPRIMASK2.800.68LiteOS-MPRIMASK3.200.79RT-Thread 完整版PRIMASK4.500.95这张表的信息量比切换排名还大。BASEPRI 和 PRIMASK 的区别是 RTOS 实时性的分水岭。PRIMASK 一关就是全关任何中断都进不来BASEPRI 只屏蔽优先级低于某个阈值的中断高优先级中断照样能打进来。所以用 BASEPRI 的内核即使临界区代码很长也不会真的阻塞高优先级中断——只要你的紧急中断优先级高于configMAX_SYSCALL_INTERRUPT_PRIORITY。这解释了一个常见的困惑为什么 FreeRTOS 的临界区明明不短但实时性口碑一直很好。因为它的临界区用的是 BASEPRI而且 FreeRTOS 会明确要求你把高优先级中断的 NVIC 优先级设在阈值之上。这个约定必须遵守否则configASSERT会直接断言失败。反过来RT-Thread 完整版 4.5 µs 的最长临界区就值得注意了。这个数字出现在设备框架和串口缓冲管理里跟信号量本身没关系。如果你的项目里有微秒级响应要求又用 RT-Thread 完整版那这些中断不应该走框架层应该直接注册裸中断向量绕开设备框架。提示判断一款 RTOS 适不适合硬实时看一眼它的portmacro.h或等价头文件里临界区的实现就够了。__disable_irq()/cpsid i就是 PRIMASKmsr basepri就是 BASEPRI。3.3 内存账本20 KB SRAM 下的真实取舍最后是体积。这块板子只有 64 KB Flash 和 20 KB SRAM本身就是个很好的筛选器。我统计的是“一个最小可运行工程”的占用一个测试任务、一个空闲任务、一个定时器任务如果内核自带、必要的栈还包括一次信号量收发。RTOSFlash 占用RAM 占用能否跑起来TencentOS-tiny3.8 KB1.2 KB轻松RT-Thread Nano5.5 KB1.6 KB轻松ChibiOS/RT6.2 KB1.5 KB轻松ThreadX6.8 KB1.8 KB轻松FreeRTOS7.4 KB2.1 KB轻松LiteOS-M9.5 KB2.6 KB可以Zephyr深度裁剪13 KB3.8 KB需要改配置µC/OS-III14 KB4.2 KB可以Zephyr默认配置42 KB11 KB勉强Flash 吃紧RT-Thread 完整版28 KB7.2 KB可以但剩不下多少NuttX 12场外58 KB21 KB跑不起来几个值得说的点。TencentOS-tiny 在体积上确实是这批里最省的3.8 KB Flash 不是虚标这个成绩在同类里很难找到对手。Zephyr 默认配置和深度裁剪之间差了 3.2 倍 Flash、2.9 倍 RAM这就是“默认值陷阱”的直观体现。它的默认配置里开着日志子系统、printk、线程名、断言、设备树上的各种驱动实例。把CONFIG_LOG、CONFIG_PRINTK、CONFIG_ASSERT、CONFIG_THREAD_NAME全关掉再把主栈和堆池调小才能塞进这块板子。这个过程我花了大概一整个下午。NuttX 跑不起来这件事本身很有价值。NuttX 的定位更接近“嵌入式里的 POSIX 系统”它带完整的 VFS、网络栈挂载点、shell20 KB SRAM 根本不够它转身。这不代表 NuttX 差只代表它和 STM32F103C8 这类小容量芯片不是一个赛道的。选型时如果你看到有人在 F103 上推 NuttX八成是没算过内存账。RAM 的隐性开销要单独算。上表里的 RAM 是“内核 任务栈”。FreeRTOS 默认configTIMER_TASK_STACK_DEPTH是 256 个字1 KBconfigMINIMAL_STACK_SIZE在很多 Cortex-M 移植里是 128 个字512 B空闲任务就吃掉这么多。RT-Thread 完整版的main线程默认 4 KB 栈tidle线程 512 B加起来很容易就超过 6 KB。这些数字在 20 KB 的板子上是决定性的。4. 谁最容易被误判五类典型偏差来源4.1 默认配置陷阱Zephyr 与 µC/OS-III 是重灾区如果要评选“最容易被误判”的冠军我会给 Zephyr。它在默认配置下的切换中位数是 4.6 µs看着像上个世代的水平深度裁剪后是 1.91 µs直接进第一梯队。同一款内核性能差了 2.4 倍全部来自prj.conf里的几十行开关。裁剪的关键项如下可以直接参考# Zephyr 性能向裁剪GD32F103C8T6 / 20KB SRAM CONFIG_LOGn CONFIG_PRINTKn CONFIG_ASSERTn CONFIG_THREAD_NAMEn CONFIG_THREAD_STACK_INFOn CONFIG_TIMESLICINGn CONFIG_NUM_PREEMPT_PRIORITIES8 CONFIG_NUM_COOP_PRIORITIES2 CONFIG_SYS_CLOCK_TICKS_PER_SEC1000 CONFIG_MAIN_STACK_SIZE512 CONFIG_IDLE_STACK_SIZE256 CONFIG_HEAP_MEM_POOL_SIZE0 CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE512 CONFIG_DEVICE_SHELLnµC/OS-III 是另一个典型。它的两个开关必须关/* os_cfg.h */ #define OS_CFG_STAT_TASK_EN 0u /* 关掉统计任务空闲任务不再算 CPU 占用 */ #define OS_CFG_TASK_PROFILE_EN 0u /* 关掉任务统计信息 */ #define OS_CFG_DBG_EN 0u /* 关掉调试变量 */ #define OS_CFG_TASK_REG_TBL_SIZE 0u /* 不用任务寄存器 */关掉这三项之后Flash 从 21 KB 降到 14 KB切换中位数从 3.1 µs 降到 1.88 µs。所以“µC/OS-III 又大又慢”这个印象很大程度上是默认配置造成的。这类误判的根因是Zephyr 和 µC/OS-III 的设计者假设你在做产品时会认真配置所以默认值偏向“功能完整、方便调试”而不是“性能最优”。而 FreeRTOS 和 TencentOS-tiny 的默认值更偏向精简所以第一印象就好。这是设计哲学的差异不是性能差异。4.2 框架层不等于内核RT-Thread 完整版被误伤RT-Thread 完整版排最后一名但第 3.1 节的限定词很重要关掉钩子和溢出检查后它能进前四。那剩下的差距在哪我用 GPIO 打点逐步追踪发现切换路径上多出来的时间主要来自三处调度器钩子调用链、rt_thread_self()里的线程控制块访问、以及线程栈溢出检查。这三处都跟“内核调度算法”无关属于工程上的可读性和安全性代价。更极端的是中断路径。4.5 µs 的最长临界区出现在设备框架里跟调度器毫无关系。如果你在串口中断里调用框架层的 API那就会吃到这个延迟。所以如果一个团队说“RT-Thread 太慢所以我们换了 FreeRTOS”我会先问一句你们是拿完整版和 FreeRTOS 内核版比吗如果是这个比较本身就不成立。正确的对照是 RT-Thread Nano 对 FreeRTOS 内核版完整版对 FreeRTOS 中间件。按这个口径RT-Thread Nano 1.42 µs 对 FreeRTOS 1.28 µs差距在 11% 以内完全在同一条起跑线上。4.3 被高估的“跑分王”和被低估的“老实人”误判不只有“把快的看成慢的”也有“把跑分优势高估成实际收益”。ThreadX 切换比 FreeRTOS 快 0.36 µs。听起来不错但在一个典型的电机控制或者传感器采集项目里主循环 ADC 中断 通信协议加起来的开销是几百微秒量级0.36 µs 的差异在总时间里的占比不到千分之一。除非你的系统每秒要做几十万次任务切换这个优势基本观察不到。反过来说如果你确实在做每秒几十万次切换的场景比如高速数据采集缓冲切换那这 0.36 µs 就是实打实的。被低估的反而是那些“没有亮点”的内核。比如 FreeRTOS 和 RT-Thread Nano切换不是最快、体积不是最小、配置项不是最丰富但它们的抖动控制稳定、生态成熟、文档齐全、出问题能查到答案。在真实项目里能查到答案这件事的价值很多时候超过 0.3 µs。还有一个隐藏的误判维度启动时间。µC/OS-III 和 RT-Thread 完整版的初始化过程比较长从上电到第一个任务开始跑我实测分别是 8.2 ms 和 11.5 ms而 TencentOS-tiny 和 RT-Thread Nano 分别是 1.9 ms 和 2.4 ms。如果你的设备要求上电后 5 ms 内开始响应这个指标比切换延迟重要得多但它几乎不会出现在任何跑分表里。4.4 编译器与内存布局带来的隐形偏差这一节讲的是“看起来公平其实不公平”的情况。第一个是 Flash 等待周期。我这次用的是 GD32F10372 MHz 下按官方推荐配 0 等待周期。网上很多 STM32F103 的数据是 2 等待周期。同样的代码同样的主频光是这一项就能让我的数字比 ST 平台好 15% 到 25%。所以如果你拿我这篇的数字去和自己 STM32F103 上的结果对比发现对不上先查等待周期配置别急着怀疑 RTOS。第二个是函数在 Flash 还是 RAM 里执行。有些内核的关键路径会主动放到 RAM比如 FreeRTOS 的部分移植层有些全在 Flash 里跑。在 0 等待周期的芯片上差别不大在 2 等待周期的芯片上就是实打实的差距。这点在对比不同文章的数据时经常被忽略。第三个是链接脚本里的段对齐和优化。我曾经遇到过一个问题改了一下链接脚本里.text段的对齐切换时间的中位数波动了 4%。原因是关键函数跨越了 Flash 的取指预取边界。这种波动完全没有规律只能靠多次编译取中位数来平抑。如果你自己复现的时候数字有 5% 以内的波动那大概率是布局噪声不是测量错误。4.5 测量方法自身引入的偏差最后一类偏差来自尺子本身。GPIO 打点法里HAL_GPIO_WritePin和 BSRR 直写差 20 多个周期如果你的打点代码放在被测路径中间这些周期就被算进去了。DWT 法看着更干净但如果你在中断里读 CYCCNT读取指令本身也要几个周期而且 DWT 计数器在某些低功耗状态下会停。还有两点更容易出事。第一别忘了把测量代码里的volatile加对否则编译器可能把两次DWT-CYCCNT读取重排或者合并测出来的数字会好得离谱。第二测量循环至少跑 2000 次我试过 100 次循环同一款 RTOS 两次测量的中位数能差 8%因为样本太少还没进入稳定分布。5. 移植与调优实操记录5.1 移植到 GD32F103 的四个具体动作把 RTOS 往 GD32F103 上搬和往 STM32F103 上搬有细微差别我把踩过的点列出来。动作一时钟配置要重新标定。直接套用 ST 的system_stm32f10x.c能跑但 Flash 等待周期参数是按 ST 的电气特性写的。我改成了 GD32 官方推荐的配置然后用 MCO 把 SYSCLK 输出到 PA8 引脚接示波器确认是准确的 72 MHz。这一步看着多余但它是后面所有性能数据可信的前提——如果你的实际主频是 64 MHz 而不是 72 MHz所有换算都会偏 12.5%。动作二SysTick 初始化不要依赖库。我见过有人直接调用库里的SysTick_Config()结果 tick 频率和configTICK_RATE_HZ对不上切到 1000 Hz 时实际跑成了 500 Hz。手动写更可靠/* 72MHz / 1000Hz 72000 个周期重装载值减 1 */ #define SYSTICK_RELOAD (72000UL - 1UL) void systick_init(void) { SysTick-LOAD SYSTICK_RELOAD; SysTick-VAL 0UL; SysTick-CTRL SysTick_CTRL_CLKSOURCE_Msk /* 用内核时钟不要用 HCLK/8 */ | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk; NVIC_SetPriority(SysTick_IRQn, 15); /* 最低优先级减少对短窗口测量的干扰 */ }动作三PendSV 和 SysTick 的优先级一定要显式设置。Cortex-M 的默认优先级是 0也就是最高。如果不设PendSV 会和你的高优先级外设中断抢出现“中断里调用 RTOS API 就死机”的现象。一般 PendSV 设最低15SysTick 设次低14。动作四堆栈对齐。Cortex-M3 要求栈 8 字节对齐任务栈数组要加__attribute__((aligned(8)))否则在奇数个参数入栈时会触发 UsageFault。这个错误在-O0下可能不出现切到-O2就冒出来了非常难查。注意如果你用的是 GD32F103PLL 分频系数的寄存器定义和 ST 有细微差别。72 MHz 这个档位两者一致但如果要超到 108 MHz配置方式完全不同不要混用库文件。5.2 关键配置项逐个说明这部分给出我最终使用的配置以及每一项为什么这么设。FreeRTOS 侧FreeRTOSConfig.h的关键项#define configUSE_PREEMPTION 1 #define configUSE_PORT_OPTIMISED_TASK_SELECTION 1 /* Cortex-M3 上必须开用 CLZ 查就绪表 */ #define configUSE_TICKLESS_IDLE 0 /* 测量期间关闭避免干扰 */ #define configCPU_CLOCK_HZ 72000000UL #define configTICK_RATE_HZ 1000 #define configMAX_PRIORITIES 8 /* 不超过 32才能用优化的就绪表查找 */ #define configMINIMAL_STACK_SIZE 128 #define configTOTAL_HEAP_SIZE (2 * 1024) #define configMAX_TASK_NAME_LEN 8 #define configUSE_TRACE_FACILITY 0 /* 关闭会拖慢调度器 */ #define configGENERATE_RUN_TIME_STATS 0 /* 关闭测量期间必须关 */ #define configUSE_STATS_FORMATTING_FUNCTIONS 0 #define configCHECK_FOR_STACK_OVERFLOW 0 /* 测量期间关闭 */ #define configUSE_MUTEXES 1 #define configPRIO_BITS 4 /* STM32F1/GD32F103 实现了 4 位 */ #define configKERNEL_INTERRUPT_PRIORITY (15 (8 - configPRIO_BITS)) #define configMAX_SYSCALL_INTERRUPT_PRIORITY (5 (8 - configPRIO_BITS))这里configUSE_PORT_OPTIMISED_TASK_SELECTION和configMAX_PRIORITIES是配套的前者只在优先级数量不超过 32 时可用。如果项目里要用 40 个优先级就只能关掉它代价是切换路径多几十个周期。configMAX_SYSCALL_INTERRUPT_PRIORITY设成 5意味着优先级数值小于 5也就是更高优先级的中断不受内核临界区影响但不能调用FromISR系列的 API。这是 FreeRTOS 的核心约定不遵守就会出现“偶尔丢中断”或者“队列数据错乱”这类随机故障。RT-Thread 侧rtconfig.h的关键裁剪项#define RT_THREAD_PRIORITY_MAX 8 #define RT_TICK_PER_SECOND 1000 #define RT_USING_OVERFLOW_CHECK 0 /* 关闭栈检查省下每次切换的几微秒 */ #define RT_USING_HOOK 0 /* 关闭调度器钩子 */ #define RT_USING_IDLE_HOOK 0 #define RT_USING_DEVICE 0 /* Nano 不需要设备框架 */ #define RT_USING_HEAP 0 /* 全静态分配 */ #define RT_USING_CONSOLE 0 /* 测量期间关闭控制台 */ #define IDLE_THREAD_STACK_SIZE 256RT_USING_OVERFLOW_CHECK这一项值得单独说。它每次切换到新线程时会检查该线程栈的魔术字安全性确实提高了但代价是每次切换多几百个周期。在开发阶段开在性能敏感的量产配置里关这是我现在的标准做法。µC/OS-III 的os_cfg.h已经在前一节给过。Zephyr 的prj.conf也给过。TencentOS-tiny 和 LiteOS-M 的裁剪项相对少主要是关掉TOS_CFG_*_EN系列的调试开关。5.3 复现这套测试的完整步骤如果你想在自己板子上重跑一遍按这个顺序走大概半天能出结果。第一步搭一个最小可运行工程只点灯确认主频、tick、串口都正常。这一步不用测任何东西但必须用示波器或者逻辑分析仪确认 MCO 输出的频率因为后面所有换算都基于它。第二步接入 DWT 计数模块写一个自检让一段已知次数的空循环跑起来用 DWT 数周期和理论值对比。比如 1000 次__NOP()理论上是 1000 周期如果测出来明显偏离说明 CYCCNT 没配对或者主频理解错了。第三步写测量框架两个任务 两个信号量 2000 个样本数组 统计函数。统计函数算最小值、中位数、P99、最大值中位数用简单的排序或者计数排序都行。第四步逐个移植 RTOS每次只测同一组数据做完立刻记录到表格里标注配置差异。关键纪律是一款没测完不要动另一款的配置。第五步用逻辑分析仪交叉验证至少两款 RTOS 的 GPIO 打点结果确认 DWT 的数据可信。第六步做“配置敏感度实验”——每款 RTOS 至少跑三个配置默认、中等裁剪、极限裁剪。这一步产出的信息和排名本身一样有价值因为它告诉你这款内核的性能天花板和地板在哪。6. 常见问题速查与避坑清单6.1 现象、原因、处理对照表下面这张表是我这几轮测试里遇到过的真实现象按出现频率排序。现象最可能的原因处理方式切换时间忽高忽低波动超过 50%测量期间有串口输出或调试器读取关闭串口测量期间不读 SWD切换时间比预期慢一倍编译等级是-O0或未开优化的就绪表查找改-O2打开configUSE_PORT_OPTIMISED_TASK_SELECTION中断里调用 API 偶尔死机NVIC 优先级分组设成了子优先级模式改NVIC_PriorityGroup_4中断优先级设了但不生效只写了NVIC_SetPriority没设分组先设分组所有中断统一次序任务栈溢出但检查不到configCHECK_FOR_STACK_OVERFLOW为 0开发阶段设 1 或 2量产再关Zephyr 编译报 RAM 不足默认配置打开了日志和 printk按 4.1 节清单裁剪µC/OS-III 空闲任务占满 CPUOS_CFG_STAT_TASK_EN为 1改 0并删除统计任务初始化同一款 RTOS 两次编译性能不一致链接布局变化关键函数跨取指边界多次编译取中位数不要单次下结论GD32 上跑分明显高于同类 ST 平台Flash 等待周期配置更优对比时说明平台差异不要直接横比DWT 计数明显偏小volatile缺失导致读取被优化掉所有 CYCCNT 读取都加 volatile 语义低功耗模式下 DWT 停止计数进入停止模式改用 GPIO 打点 逻辑分析仪切换延迟 P99 特别差内核临界区较长或内核用 PRIMASK查 port 文件考虑换用 BASEPRI 的内核6.2 我个人踩过的几个记忆深刻的坑第一个坑是用printf打印中间结果。我第一轮测试时每测 100 个样本就打印一次进度结果 P99 是其他轮次的两倍。排查了两个小时才反应过来是串口中断在捣鬼。后来改成全部测完再一次性打印数据立刻就干净了。这个教训很朴素测量代码和被测量代码共用任何资源都可能互相污染。第二个坑是忘了关 SysTick 的测量干扰。SysTick 中断本身只有几百纳秒但它在优先级高的时候会打断切换路径。我一开始把 SysTick 优先级设成默认的 0测出来的中位数比设成 15 时高了 8%。后来统一设成最低数据才稳定。第三个坑是在 Zephyr 上折腾了太久。最开始我拿默认配置测看到 4.6 µs 的中位数第一反应是“这内核不行”差点就把它从列表里删了。后来想想不对Zephyr 在工业和车载领域用得很多不至于这么差。认真读了一遍 Kconfig 才发现问题。这次经历让我养成了一个习惯拿到任何 RTOS先看它默认配置里开了什么再看性能。第四个坑是把 NuttX 也塞进排名。我硬着头皮裁剪了半小时Flash 是下去了但 RAM 怎么都压不进 20 KB最后只能标“跑不起来”。刚开始我觉得这是测试失败后来意识到这恰恰是最有价值的一条记录——它说明有些 RTOS 天然就不该被放在小容量 MCU 的候选名单里硬凑对比只会得出错误结论。最后一个体会是关于“快”这个字本身。这几轮测试下来8 款 RTOS 的切换延迟从 0.92 µs 到 2.35 µs全都在同一个数量级上。真正把项目做砸的从来不是这 1 µs 的差距而是优先级分组配错、临界区里塞了耗时操作、中断里调用了阻塞式 API 这类问题。选 RTOS 的时候把生态成熟度、文档质量、团队熟悉度放在第一位性能排第三或第四位这个顺序在实际项目里从来没让我后悔过。如果你也想动手测一轮我的建议是从最小的一对任务 一个信号量开始先把测量框架调稳再去加 RTOS。测量框架本身比 RTOS 更容易出错这是我花了最多时间才想明白的事。
返回列表