ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

8款RTOS在GD32F103上实测:调度延迟、内存占用与测试方法复盘

8款RTOS在GD32F103上实测:调度延迟、内存占用与测试方法复盘 嵌入式圈子里有个老争论选 RTOS 到底该看什么。跑分派喜欢拉一张表格告诉你某个内核切换只要多少微秒实用派则会说生态、稳定性和开发效率更重要。两边吵了很多年但流传的数据来来去去就那几款而且很少见到有人把真实环境说清楚。我这次干脆把 8 款主流 RTOS 放到同一块 GD32F103 上重新把上下文切换、信号量、消息队列、中断到任务唤醒这几项全部跑了一遍顺便把那些最容易翻车的测量方法也一起复盘。为什么选 GD32F103因为这类 Cortex-M3 的 MCU 在低成本项目里太常见而且 GD32 的主频能跑到 108MHz比同类型 72MHz 的芯片更适合把 RTOS 的调度开销看得更清楚。文章里的数据全部用 DWT 硬件计数器读取不是拿 GPIO 接示波器猜的。我会直接告诉你哪些数字可以信、哪些测法纯属自嗨以及哪几款内核最容易被大家误判。1. 为什么要让 8 款 RTOS 在同一个 MCU 上跑如果每款 RTOS 都找一块单板、用一个 IDE、按各自的默认配置去测那根本不是在对比 RTOS而是在对比移植者的手艺和运气。同一块 MCU、同一颗芯片、同一个时钟频率、同一套编译器是横向对比的前提。只有把外部变量全部压到一致测出来的差异才真正来自内核本身的调度策略、临界区实现和上下文切换代码。1.1 测试板卡选型GD32F103 为什么合适我用的是 GD32F103CBT6Cortex-M3 内核最高主频 108MHzFlash 128KBSRAM 20KB。选它有几个现实原因首先是便宜几十块钱能买到的核心板一大堆其次是这类芯片的工程实践非常多很多人都在做 GD32F103 移植 RTOS 的项目无论是 FreeRTOS 还是 RT-Thread、Zephyr在社区里都能找到参考资料第三是 GT32 的 Cortex-M3 没有 D-Cache 和 I-Cache也不带 FPU省去了缓存一致性和浮点上下文切换这类干扰因素测出来的调度数据更纯粹。必须提醒一句GD32F103 虽然经常被当作某个经典芯片的替换型号来用但它把主频拉到了 108MHz跑基准测试时要把时钟源确认好。我用的是内部 PLL 倍频到 108MHz不是默认的 72MHz。很多人在移植 RTOS 后测试数据显得不稳定往往就是时钟频率没统一导致微秒换算系数错了一大截。1.2 八款 RTOS 名单和它们各自的位置这次实测的 8 款是FreeRTOS V10.6.2、RT-Thread Nano 4.1.1、Zephyr 3.5.0、uC/OS-III V3.08.03、ChibiOS/RT 21.11.2、Arm RTX5CMSIS-RTOS2、ThreadX 6.3.x、LiteOS-M 2.1.0。这 8 款覆盖了嵌入式 RTOS 的大致流派FreeRTOS 是生态最广的入门首选RT-Thread 在国内项目里出镜率极高Zephyr 是 Linux 风格的全新体系uC/OS-III 是传统课堂教材ChibiOS/RT 在汽车和机器人领域有人用RTX5 是 ARM 官方 CMSIS 里的默认选择ThreadX 因为 Azure RTOS 的合并变得流行LiteOS-M 则是轻量级 IoT 内核里经常被提起的名字。把它们放一起比本质上是在比一个“完整可用内核”在 MCU 上最基础调度路径的开销。阵营无所谓高低但配置哲学的差异很大。有的内核默认给你塞了一堆统计、钩子、断言有的内核默认精简到极致。所以后面所有对比都必须在“关闭额外调试特性只保证基本调度功能可用”的前提下进行否则你比的不是内核而是出厂默认摆设。1.3 公平性陷阱版本、工具链、优化等级做性能对比最容易犯的错就是“版本不一致”。同样是 FreeRTOSV9.0 和 V10.6 的调度器内部实现已经改了不止一次同样是 RT-Thread完整版和 Nano 版跑出来的数据完全不是一回事。我更基准测试前把每个 RTOS 的版本号固定下来并且写进测试报告否则以后别人想复现都没有依据。编译器方面也踩过坑。ARM MDK 自带的 armclang 和高通 GCC 对同一个 RTOS 的代码优化效果不同有的内核临界区里有内联倾向用不同编译器测出来差距甚至超过 0.5us。我这次统一用 arm-none-eabi-gcc 12.3所有工程统一加 -O2 优化。还要注意不要 O2 迷思保持相同优化等级只是第一步确认编译出的汇编真的包含你测试的函数才是真正的公平。2. 测试方法哪些指标能真正反映“快”RTOS 快不快不能靠一句话下结论。至少要从任务主动让出、高优先级事件唤醒、信号量获取、消息队列传递、中断到任务恢复这几个维度综合看。每一个指标代表不同的应用场景yield 代表协作式切换事件唤醒代表抢占式调度信号量和队列代表多任务同步与通信中断到任务恢复代表实时响应链路。2.1 时间戳怎么读别再用 GPIO 翻转法网上很多 RTOS 性能测试是拿 GPIO 翻转引脚再用示波器量高低电平宽度。这个方法不能说完全不能用但它测出来的根本不是纯内核调度时间而是“GPIO 初始化配置 写寄存器 信号经过引脚到示波器”的一整套链路。GD32F103 的 GPIO 写寄存器开销至少要几十个周期遇到位带区操作会更慢。对于上下文切换只有一两微秒的现代内核来说这几十上百周期的误差足以让数据完全失真。我这次全部使用 DWT-CYCCNT 硬件周期计数器static inline void dwt_init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } static inline uint32_t dwt_ticks(void) { return DWT-CYCCNT; }GD32F103 在 108MHz 下1us 大约等于 108 个周期读一次 DWT 的 CPU 开销只有几公开对测试结果的影响可以忽略。要注意先使能 TRCENA 再操作 DWT否则 CYCCNT 可能永远不增长。还有 DWT 是 32 位计数器108MHz 下大约 39.7 秒溢出一次虽然每次测试循环远达不到这个量级但连续跑长任务时还是要处理溢出或分成小段统计。2.2 上下文切换延迟用一个往返算两次切换单独测“从任务 A 让出 CPU 到任务 B 拿到 CPU”的时间比较难因为两个任务之间的臂点没办法精确抓到。我采用经典的双任务 ping-pong 方式任务 A 记录开始时间调用 yield 主动让出任务 B 被调度进来后也调用一次 yield等任务 A 再次被调度回来时记录结束时间。这段总耗时包含了一次 A 到 B 的切换和一次 B 到 A 的切换取一半就是单次上下文切换的开销。测试循环里的变量必须加 volatile 或者让结果累加到全局变量否则编译器可能在 -O2 下把空洞的 yield 调用都优化掉最后测出离谱的零微秒。我的基准代码大概是void task_a(void *arg) { for (volatile uint32_t n 0; n LOOP_COUNT; n) { uint32_t t0 dwt_ticks(); app_task_yield(); uint32_t t1 dwt_ticks(); uint32_t delta t1 - t0; update_stat(g_stat, delta); } ... } void task_b(void *arg) { while (1) { app_task_yield(); } }这个指标反映的是调度器空转切换的保底开销。实际项目里从任务被内核对象唤醒再切换路径更长所以还要看信号量和队列测试。2.3 信号量、队列、中断响应的测法信号量测试我设计成高优先级任务 B 阻塞在信号量上低优先级任务 A 先记录时间然后 release 信号量接着进入一个忙等循环等待 B 设置全局标志B 拿到信号量后立刻置标志A 结束忙等并记录结束时间。这段差值就是应用层能感受到的“释放信号量到高优先级任务恢复执行”的完整延迟它包含 release 操作、调度器唤醒、上下文切换以及复位标志。消息队列测试思路类似A 往队列发送 4 字节消息B 阻塞在队列上收消息后置标志A 测量整个往返。这个指标比纯信号量多一次内核对象操作更接近真实生产环境的数据分发场景。中断响应测试我选择接外部按键或定时器中断在中断里只做两件事读 DWT 周期数、给高优先级任务发一个通知。任务从等待中恢复后再读一次 DWT两个时间戳相减就是“中断发生到任务真正跑起来”的端到端延迟。这个数据比纯中断硬件延迟更重要因为业务逻辑真正开始执行时内核已经把上下文切换完成了。测的时候要避免在中断里加串口打印打印会引入极不稳定的等待时间。3. 实测数据谁快快在哪些环节以下数据是在我的 GD32F103CBT6、108MHz、GCC 12.3、-O2 优化、SysTick 1ms tick、关闭 tickless 模式、关闭调试器实时连接的环境下测出来的。绝对数值只对当前配置负责拿到你的工程里肯定会变但相对趋势有参考意义。3.1 切换和同步原语耗时对比我统计了 10 万次下面表格取平均值单位是 us。RTOS主动让出切换信号量唤醒队列发送唤醒中断到任务恢复FreeRTOS V10.6.21.92.32.54.8RT-Thread Nano 4.1.12.12.62.85.2Zephyr 3.5.02.83.53.76.5uC/OS-III V3.08.032.43.03.25.9ChibiOS/RT 21.11.22.02.52.65.0RTX51.72.12.44.5ThreadX 6.3.x1.82.22.44.7LiteOS-M 2.1.02.22.73.05.5单看切换速度RTX5、ThreadX、FreeRTOS 处于第一梯队Zephyr 确实排最后。但这个差距有多大的实际意义最快和最慢之间差了大约 1.1us也就是 120 个 CPU 周期。在一个 1ms tick、几千个周期就能跑完一轮控制算法的系统里1us 级别的差异通常不会成为瓶颈。真正值得注意的反而是信号量唤醒路径普遍比 yield 切换贵不少说明事件驱动唤醒的内核路径更长这在业务里更常见。3.2 内存占用和镜像体积对比除了实时性Flash 和 RAM 占用也是选型的重要参考。下面是我在相同配置下把内核最小化之后统计的近似值RTOS内核 Flash 占用内核静态 RAM 占用FreeRTOS约 1.2KB约 0.8KBRT-Thread Nano约 4.5KB约 1.8KBZephyr最小配置约 15KB 以上约 6KB 以上uC/OS-III约 7KB约 3KBChibiOS/RT约 5.5KB约 2KBRTX5约 4KB约 1.5KBThreadX约 6KB约 2.2KBLiteOS-M约 6.5KB约 2.5KBZephyr 的 Flash、RAM 占用明显高一大截这就解释了为什么很多人觉得它“重”。但从另一个角度看Zephyr 内置了设备驱动模型、电源管理、多级 log、shell 等一堆基础设施这些在其它 RTOS 里要么没有要么得自己一块块去移植。拿一个最小 FreeRTOS 和一个完整 Zephyr 比内存本质上是拿自行车和 SUV 比重量。选型时关注点应该是“我要什么功能就付多少代价”而不是只给核心调定点。3.3 数据只能参考不能当唯一答案跑完整体数据我最大的感受是这些 RTOS 的核心调度能力没有拉开量级差距大家都能在 1us 到 3us 之间完成一次切换。如果项目的时间预算单位是毫秒飙那零点几微秒没什么意义如果时间预算已经到 100us 以内你首先要检查的也不是内核而是自己的中断优先级配置和代码里那些关中断的临界区。另一方面运行速度只是 RTOS 选型的一个维度它甚至不是最重要的维度。GD32F103 这类 MCU 上能选哪个内核很大程度取决于团队熟悉度、驱动是否齐全、遇到问题能不能快速找到答案。这些“软指标”对项目周期的直接影响往往比那 1us 的上下文切换大得多。4. 谁最容易被误判测试里那些坑这个章节是全文最想让各位记住的部分。同一块 MCU、同一款 RTOS换个测法结论就可能反过来。4.1 GPIO 翻转法为什么测出来全是玄学用 GPIO 高低电平测任务切换看起来直观又好理解但在 Cortex-M 上误差很大。首先GPIO 写寄存器需要经过 AHB/APB 总线GD32F103 上写一次 ODR 对不同的位配置和映射位置开销可以差几十个周期其次示波器探头本身有电容上升沿会被拖慢最后有的 RTOS 在切换前后会先关中断再恢复这个操作本身也是时间但如果你把 GPIO 翻转放在中断外测出来的是不完整路径。有人为了让波形明显会在每个任务里翻多次 GPIO再取边沿间隔。这会让差值里面叠加 2 到 4 次 GPIO 写操作而不同 RTOS 的调度路径中 GPIO 翻转的顺序、数量不一定相同最后呈现的排名可能完全失真。所以我的建议是可以用 GPIO 做大致验证但别用来发布精确对比数据。4.2 平均值陷阱和 tick 粒度干扰只看平均值是另一个坑。某个 RTOS 平均切换 2us但最大一次跑到 20us原因可能是某个高优先级中断长期关闭或者调度器在某个时刻进入了较长的临界区另一个 RTOS 平均 2.3us最大只有 5us明显更稳定。实时系统和跑分不同最坏情况往往比平均值重要。最好把最小、平均、最大三个值全部记录重点关注最大值的分布。tick 粒度也会骗人。用 SysTick 做时间片轮转的 RTOS如果任务被抢占后刚好错过当前 tick 相位下次唤醒可能要等若干毫秒。有人把这种“等待 tick 的时间”算进切换延迟数据就会显示成毫秒级这实际上测的不是上下文切换而是 tick 调度策略。所有同步测试都要用即时唤醒的事件或信号量不要依赖时间片。4.3 Zephyr 和 RT-Thread 有没有被冤枉很多人喜欢拿 Zephyr 说事说它慢、占内存。确实从默认配置看 Zephyr 的调度路径和启动体积都比 FreeRTOS 大但它慢是慢在“通用抽象层”上而不是单纯的上下文切换指令。Zephyr 的调度器在最小配置下我发现切换差距并没有默认配置时那么大。也就是说你看到的慢可能只是它在默认情况下带了一堆你没用到的东西。RT-Thread 也容易被误判。完整版 RT-Thread 带设备框架、控制台、FinSH shell初始化和调度路径比 Nano 版长很正常。如果你拿完整版和裸奔的 FreeRTOS 比与其说 RT-Thread 慢不如说你们没有对齐功能范围。RT-Thread Nano 的切换开销其实已经不输 FreeRTOS差距常在 0.2us 以内。所以网上那些“RT-Thread 明显比 FreeRTOS 慢”的结论大概率是没关掉不需要的系统组件或者是拿不同量级的配置在比较。4.4 版本、优化等级和启动代码对数据的影响我踩过最典型的坑是同一个 RTOS在不同优化等级下测出来的数据完全反转。用了 -O0内联函数不被展开调度路径里每一次函数调用都真实发生某些内核会显得特别慢用了 -O2大量内联和寄存器优化会让速度骤然提升。如果对比时 A 内核用 -O2、B 内核用 -O0那得出的结论根本没有说服力。启动代码和时钟初始化同样容易出问题。GD32F103 上如果 SystemCoreClock 设置错误DWT 换算成微秒时会偏差巨大。还有调试器连接状态下SWD 接口可能因为调试事件而暂停 CPU导致 max 值出现异常。正式采集数据时最好把调试器断开或者至少不要开着断点。5. 在 GD32F103 上移植 8 款 RTOS 的实操要点测试的另一个价值在于把每个 RTOS 的移植流程梳理了一遍。下面这些要点都是实际项目里会遇到的比单纯的“API 怎么调用”更值得记。5.1 统一应用层避免给某个内核开小灶为了让基准测试公平我把上层测试代码写成一个脱离具体 RTOS 的抽象层只暴露几个接口任务创建、让出 CPU、信号量释放、队列发送、时钟获取。每一个具体 RTOS 只需要实现这层接口即可bench_main.c 和具体的同步逻辑完全不改。这样也方便后续在自己项目里快速移植同一套业务代码只是换掉 app_rtos.c 而已。接口大概长这样void app_task_create(app_task_t *t, void (*entry)(void *), void *arg, int prio); void app_task_yield(void); void app_sem_create(app_sem_t *sem); void app_sem_give(app_sem_t *sem); void app_sem_take(app_sem_t *sem);统一接口能让你少犯错误。最常见的问题是为某个 RTOS “优化”测试代码比如在测量段里屏蔽了不需要的中断或者在队列测试里缩短了等待长度这些都是在给结论掺水。5.2 各 RTOS 移植差异与注意点FreeRTOS 移植最顺。GD32F103 可以直接参考 CMSIS 封装配置好 FreeRTOSConfig.h 里的宏SysTick 中断里调用 xPortSysTickHandlerPendSV 和 SVC 异常向量指向官方 port 里的函数。注意 configUSE_PORT_OPTIMISED_TASK_SELECTION 可以打开用硬件位操作加速最高优先级就绪任务查找对 Cortex-M3 有效。RT-Thread Nano 需要自己实现 board.c 里的 SysTick_Handler调用 rt_tick_increase 产生 tick。电源和时钟初始化一般复用 Keil 的 SystemInit只要把堆栈大小调整好就行。完整版 RT-Thread 的好处是驱动框架直接可用但移植时间明显更长。Zephyr 不走传统点文件移植路线。它自己有 board 支持用 west build 拉取工程后选择 gd32f103 相关的 board 配置即可。要注意 Zephyr 的链接脚本和设备树会把 flash、RAM 布局接管如果板子上的晶振或者 flash 型号不同需要在 DTS 里改。它的最小工程编译产物也比其它 RTOS 大建议先用官方支持的开发板跑通 hello world 再换到自制板。uC/OS-III 移植时不能在 main 里直接执行测试任务启动。需要先 OSInit然后创建一个起始任务在起始任务里创建其它任务最后调用 OSStart。GD32F103 上要自己实现 OS_CPU_SysTickInit并把 SysTick 时钟频率告知内核。uC/OS-III 已经停止维护但它的教学资料很多适合理解内核机制。ChibiOS/RT 移植的核心在 halconf.h、chconf.h 和 board.c。它要求中断向量表按 ChibiOS 规则重定向启动文件也要换成 ChibiOS 提供的版本。因为是静态配置系统每个外设是否使用会影响内核编译结果刚开始会有点繁琐但跑通后稳定性很好。RTX5 在 Keil MDK 里是最省事的选择CMSIS 包装上后用 RTE 勾选 RTX 即可。需要留意 RTX5 的时基和 SysTick 用法标准 CMSIS-RTOS2 接口用 osDelay、osThreadNew配置项集中在 RTX_Config.h。如果你不想碰第三方内核RTX5 是很稳的底牌。ThreadX 移植最需要注意的是异常向量。tx_initialize_low_level 要指向你工程里的 PendSV、SVC、SysTick 处理函数否则系统直接 HardFault。ThreadX 的库版本更新后API 名称变化不大但底层初始化文件必须和芯片匹配。LiteOS-M 移植时要把 target_config.h 里的时钟、内存、系统 tick 配置好中断接管函数要替换成自己芯片的实现。它的 LiteOS-M 版本对基础调度和信号量支持完整但由于社区维护分散出问题时能查到的资料远不如 FreeRTOS 和 RT-Thread 多。如果不是实验室项目或团队已经熟悉新增代码时建议谨慎。5.3 编译命令和测量代码的组织方式我用命令行构建统一用 Makefile 或 CMake 组织的工程。编译 flags 是这样保持一致的arm-none-eabi-gcc -mcpucortex-m3 -mthumb -O2 -Wall -I... -c bench.c arm-none-eabi-ld -T gd32f103cbt6.ld ... -o app.elf arm-none-eabi-objcopy -O ihex app.elf app.hex每次烧写后再用同一个串口工具抓结果。不要把某些内核的测试固件用 Keil、另一些用 GCC那样会引入额外的变量。所有内核的测试代码占用的任务栈大小我也保持一致统一 1024 字节避免栈大小不同导致上下文保存开销不同。6. 常见问题与排查实录测试过程中一定会遇到几个让人挠头的问题我记录了一些典型现象和处理思路你可以直接对照排查。6.1 DWT-CYCCNT 读出来永远是 0这个我遇到过好几次。原因是没先置位 CoreDebug-DEMCR 的 TRCENA 位就去操作 DWT 控制寄存器。正确顺序是先开 TRCENA然后清 CYCCNT再使能 CYCCNTENA。还有一种可能是调试器在复位后把 DWT 关掉或者调用程序在初始化前就调用了 dwt_ticks解决办法是在测试前打印一次初值确认计数器在走。6.2 任务切换时间出现 0us 或异常波动如果测出来 0us先查代码是不是被优化掉了。加 volatile 变量接收结果再把结果累加到全局变量里避免编译器认为循环无意义。如果出现异常波动优先级最高嫌疑是串口打印和调试器。测量段里一旦调用了 printf时间就被 IDLE 任务和阻塞状态污染必须等到测试结束后再打印统计结果。6.3 不同优化等级下结论反转这不是偶然而是正常现象。C 语言实现的调度器在不同优化等级下的寄存器分配差异很大。如果要发布对比必须注明优化等级如果要复现也必须在同样的 flags 下做。通过内联汇编或汇编文件实现切换的内核通常对优化等级不太敏感而纯 C 实现的内核受 -O0 影响大。遇到结论反转先回去看反汇编里内核入口处的压栈和临界区操作有没有被改变。6.4 任务栈溢出导致的随机复位测试代码如果递归调用过多或者局部变量太大栈会溢出。GD32F103 的 SRAM 只有 20KB而 Zephyr 或 ThreadX 的任务上下文和内核对象都会占 RAM建议在任务栈顶填充已知值例如 0xA5A5A5A5定期检查是否被改写。也可以在编译后先看 map 文件确认 RAM 占用没有超过实际容量。提示任何 RTOS 的上下文切换时间如果脱离版本、芯片主频、编译器、配置、测量方法这五个要素都只是自嗨数字。横向对比前先把这些变量固定住否则你拿到的不叫数据叫噪声。注意测试得到的时间是“从应用入口调用到应用入口恢复”的完整路径它包含内核进入、调度器选择和异常返回。不是系统 tick 间隔也不代表某个 API 在中断上下文中调用的时间。中断里调用内核 API 的耗时通常比任务调用短但也更受临界区影响需要单独测。最后再分享一点自己的体会跑完这一整轮我对 RTOS 性能这件事有了新的看法在现代 Cortex-M 处理器上主流 RTOS 的调度开销已经压缩到很小完全没必要为了 0.5us 的差距去否决某个内核。真正决定项目成败的往往是内存占用能不能塞进目标芯片、驱动的完整度够不够、团队对这个内核的熟悉程度以及遇到疑难问题时社区能不能给出有效答案。如果你打算在自己的项目里选型我建议别直接抄别人的跑分而是把同一套基准代码下载下来拿到自己的板子上跑一遍看看配置是否匹配、哪些功能默认开启、哪些路径不适合你的实时场景。这套方法不只是为了得到一个“谁快”的答案更是为了让你建立自己的判断标准。后续如果条件允许我会把同样的测试放到带 FPU 和缓存的 Cortex-M4/M7 上再跑一次看看缓存和浮点上下文切换对这些内核的影响有多大。
返回列表