嵌入式 Rust RTOS 与 C RTOS 的对比
摘要
这篇博客文章对比了在STM32F446微控制器上Embassy (异步 Rust)和FreeRTOS (C)的性能和易用性,重点关注中断延迟、内存使用和编程简洁性。
暂无内容
查看缓存全文
缓存时间: 2026/09/02 20:51
# 异步 Rust 与实时操作系统(RTOS)大对决!—— Tweede golf 来源:https://tweedegolf.nl/en/blog/65/async-rust-vs-rtos-showdown/ **又一篇关于嵌入式异步 Rust 的技术博客文章来了。这次,我们将在 STM32F446 微控制器上对比 Embassy/Rust 和 FreeRTOS/C。** 这次我们要让它们运行执行相同操作的应用程序。然后,我们将根据中断延迟、程序大小、RAM 使用量和编程便捷性来评判它们。已有很多文章对比过 C 和 Rust,因此今天我们不在此展开。我将尝试展示两个“普通”的应用程序。两个项目都可以通过大量调优来获得更好的性能,但这几乎是一项无止境的工作。因此,作为指导原则,这些应用程序将: - 具备可移植性(在一定程度上)到其他芯片和架构(除了对 HAL 的依赖)- 简单直接 - 使用常规选项和设置进行调优,例如编译器优化、RTOS 设置和线程优先级。 最终,我们应该能对 RTOS 和异步执行器(如何)工作有一个基本的理解。我虽有偏见,但希望这篇博文能提供公平的对比。如果您有建议,请告诉我们! 我们将使用运行在 180MHz 的 STM32F446ZET6 微控制器进行测试,部分测量将使用 Rigol DS1054Z 示波器完成。 ## 异步 Rust Rust 中的异步函数是返回一个 Future 的函数的语法糖。 ``` pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Output>; } ``` 函数被转换成一个可以被轮询(polled)的状态机对象。状态机允许代码跳入函数,并从之前停止的地方恢复。它还跟踪所有在 await 点之间保留的变量。Rust futures 是惰性的(lazy),只在被轮询时运行。要让一个 future 运行完成,你只需要持续调用 `poll` 函数,直到它停止返回 `Pending` 状态并返回 `Ready(Output)` 状态。这很简单直接,但效率不高。为了解决这个问题,还有 `Waker`。唤醒器(Waker)可以通知执行器(executor)应该再次轮询某个 future。这个唤醒器可以由 future 自身调用,也可以传递给 future 依赖的另一个进程/线程。通常,执行器调用一次 `poll` 函数,然后只在唤醒器被触发时再次调用它。一个 future 可以调用其他 futures 并将它们纳入自身。对于执行器来说,它轮询的任何顶层 future 通常被称为一个 `task`(任务)。 还有很多可以说。幸运的是我不必说了,因为网上有一些非常好的资源: - 深入底层:执行 Futures 与 Tasks (https://rust-lang.github.io/async-book/02_execution/01_chapter.html) - Rust 如何优化 async/await (https://tmandry.gitlab.io/blog/posts/optimizing-await-1/) - 过度深入理解 Rust futures (https://fasterthanli.me/articles/understanding-rust-futures-by-going-way-too-deep) ### 在 Embassy 中 Embassy 也使用了这种机制,但增加了一些约束。 - 任务必须是静态分配的 - Embassy 不希望依赖分配器 - 所有任务必须在编译时确定 - 需要 nightly 编译器 - 需要 `type_alias_impl_trait` 预览功能 - 这是因为没有分配器,我们无法使用装箱(boxed)trait 对象。 对于许多外设,Embassy 提供了异步接口。这允许以下代码: ```rust #[embassy::task] async fn my_task(mut button: ExtiInput<'static, PC13>) { loop { button.wait_for_rising_edge().await; info!("Pressed!"); button.wait_for_falling_edge().await; info!("Released!"); } } ``` 这里发生了一些事情。`wait_for_rising_edge` 创建一个新的 future 并返回它。future 的构造函数配置了引脚的中断。在第一次轮询时,future 将其唤醒器放入一个全局的 EXTI 唤醒器数组中。当 EXTI 中断发生时,该数组中相应的唤醒器被用来唤醒正确的任务。因此,当中断退出时,执行器再次轮询任务,`wait_for_rising_edge` future 注意到它的中断已触发,并返回就绪(Ready)状态。于是程序继续。 Embassy 不做的一件事是抢占(pre-emption),这意味着活动任务只有在它 await 某物时才会切换到更重要的任务。这被称为协作式多任务(cooperative multitasking)。但 Embassy 有一些其他特性使这个缺失的功能不再是问题,这些将在本文后面介绍。 ## 实时操作系统(RTOS) 实时操作系统将所有内容划分为独立的线程。与任务不同的是,线程不运行状态机,而是运行普通代码。这意味着你不需要以特殊方式编写代码。任何旧函数都可以在 RTOS 中运行。当一个线程的执行必须暂停以切换到另一个线程时,必须捕获并保存整个处理器上下文,因为线程正在运行普通代码。当该代码恢复时,它将要求处理器上下文恢复相同。这种多线程设计适合于抢占式线程。这意味着内核可以为所有线程提供公平的执行时间,用户可以指定优先级,并且内核可以在可预测的时间量内响应事件和中断。 这个描述甚至没有触及 RTOS 的表面。如果你想更好地理解,这里有一些文章: - 如何构建实时操作系统(RTOS)(https://medium.com/@dheeptuck/building-a-real-time-operating-system-rtos-ground-up-a70640c64e93) - FreeRTOS 内核开发者文档 (https://www.freertos.org/features.html) ## 让对决开始吧! 既然我们对这两种模型有了一点了解,我们将通过实现相同的程序来对比它们。 ### 程序 我们无法构建一个完全现实的程序,因为那将花费太长时间。但让我们尝试拥有一些不太简单的东西。有几点我们需要能够宣称接近现实: - 多个任务 - 任务间数据共享 - 响应中断 因此,我们的程序将执行以下三个(字面意义上的)任务: - 每 200ms 闪烁一次 LED,持续 100ms - 在循环中使用执行器的延迟函数 - 如果用户按钮被按下,LED 不能点亮 - 这从另一个线程通信(我们自己不检查寄存器) - 跟踪用户按钮状态 - 设置 GPIO 中断以便我们可以检测信号变化 - 在共享的(原子)布尔值中通信按钮是高电平还是低电平 - 当按钮状态改变时,将字符串放入消息队列,文本为 `Button is <0/1> (N)\n`,其中 `<0/1>` 在按钮低电平时为 0,高电平时为 1,`N` 是触发次数 - 将消息队列内容打印到串口 - 等待消息队列包含字符串 - 将其打印到串口 ### 我们测量什么 这场对决可以基于这些东西来决出胜负: #### 性能 按钮 GPIO 中断花费多长时间? - 当中断触发时,我们将设置一个引脚为高电平 - 当中断结束时,我们将设置引脚为低电平 - 中间的时间由示波器测量 按钮线程(或任务)在再次等待之前花费多长时间? - 当线程停止等待时,我们将设置一个引脚为高电平 - 当线程再次开始等待时,我们将设置引脚为低电平 - 中间的时间由示波器测量 #### 中断(处理)延迟 按钮 GPIO 中断开始到按钮线程恢复之间的时间是多少? - 中断引脚上升沿到线程引脚上升沿之间的时间由示波器测量 #### 程序大小 由 arm-none-eabi-size 报告的 .text 段 #### 静态内存使用量 由 arm-none-eabi-size 报告的 .data + .bss 段 - 所有任务和线程都是静态分配的 我们只关注静态内存使用量,因为动态内存使用量难以测量。一个静态分配大量内存的程序可能比一个类似的非静态分配程序使用更少的栈内存。然而,由于 RTOS 可能在这方面遇到困难,我认为这是一个相关的比较指标。 #### 编程便捷性 非常主观,我知道 重申一下开头,我们不是在寻找最优化的解决方案。目标是拥有一个相对普通的程序。 ### 预期 我真的不知道该期待什么,只知道 RTOS 是为了真正优化性能和延迟而设计的。所以基于此,我的预测是: #### 性能 RTOS 将直接在线程中设置标志,这可能比必须找到一个异步唤醒器并触发它更快。除了代码如何恢复和挂起的方式外,按钮线程在两种实现之间没有太大区别。我希望它们花费的时间相似。 #### 中断(处理)延迟 RTOS 可能针对此进行了更多优化。Embassy 无法抢占正在运行的任务,因此在这方面进行大量优化的价值较小。 #### 程序大小 Rust 程序通常由于更昂贵的格式化和编译器插入的运行时检查而略大。由于程序的其余部分基本相同,我预计 C 实现将使用更少的闪存。 #### 静态内存使用量 由于 Rust 编译器生成的 futures 只存储在 await 点之间保留的变量,并且不必完全分配完整的栈大小,Rust 实现应该获胜。 #### 编程便捷性 忽略“Rust vs C”的一面,我认为异步模型会更容易使用。在网络世界中,async/await 已经战胜了线程,因此在这里可能也是如此。 ## 让我们看看代码 代码仓库在这里:github (https://github.com/tweedegolf/async-rtos-showdown)C 项目是使用 STMCube 1.8 制作的,Rust 项目是一个标准的 cargo 二进制项目。 ### 让按钮中断被注意到 我们不会在中断中处理所有事情,我们只是通知执行器中断已经发生。对于 Rust,我们不需要做任何事情,因为这正是 Embassy 已经做的。在 C 中,我们需要自己创建一个用于中断的函数并通知线程: ```c void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == USER_Btn_Pin) { osThreadFlagsSet(buttonWaiterHandle, 1); } } ``` ### 闪烁 LED 我们将使用每个执行器的普通延迟函数来等待所需的时间。要确定按钮是否被按下,我们有一个原子布尔值需要读取。在 C 中,该布尔值存储在全局变量中,因为任务是全局创建的,并且从 void 指针参数获取它不太好。 #### Rust ```rust #[embassy::task] async fn blink_led(mut led: Output<'static, PB0>, button_high: &'static AtomicBool) { loop { Timer::after(Duration::from_millis(100)).await; if !button_high.load(Ordering::SeqCst) { led.set_high().unwrap(); } Timer::after(Duration::from_millis(100)).await; led.set_low().unwrap(); } } ``` 在 Rust 中,我们需要为任务函数添加注解,以便它可以被静态分配。LED 也作为参数传递,因为外设是使用 Rust 的所有权模型建模的。 #### C C 中的原子类型是 C11 规范的可选部分,幸运的是我们的编译器实现了它们。这使得使用原子类型变得更加舒适。 ```c void StartBlinkLedTask(void *argument) { for (;;) { osDelay(100); if (atomic_load(&buttonPressed) == GPIO_PIN_RESET) { HAL_GPIO_WritePin(LD1_GPIO_Port, LD1_Pin, GPIO_PIN_SET); } osDelay(100); HAL_GPIO_WritePin(LD1_GPIO_Port, LD1_Pin, GPIO_PIN_RESET); } } ``` ### 将消息队列写入串口 我们发送给写任务的消息基本上是字符串。Rust 实现使用 `ArrayVec` 库来访问一个良好的栈分配 `ArrayString` 类型。在 C 中,我们没有那么多便利,所以我为它做了一个简单的类型: ```c typedef struct { char data[32]; } UartMessage; ``` 消息队列的容量为 8 条消息。线程/任务将等待新消息出现,然后将其打印到 uart。 #### Rust Rust 实现非常直接: ```rust #[embassy::task] async fn uart_writer( mut usart: Uart<'static, USART3, DMA1_CH3>, mut receiver: Receiver<'static, Noop, ArrayString<32>, 8>, ) { loop { let message = receiver.recv().await.unwrap(); usart.write(message.as_bytes()).await.unwrap(); } } ``` #### C 在 C 中,我们需要做更多的内存和大小管理: ```c void StartUartWriter(void *argument) { for (;;) { UartMessage message; CheckStatus( osMessageQueueGet(uartQueueHandle, &message, NULL, osWaitForever) ); size_t messageLength = strnlen(message.data, sizeof(message.data)); CheckStatus( HAL_UART_Transmit(&huart3, (uint8_t*)&message.data, (uint16_t)messageLength, 1000) ); } } ``` ### 等待按钮 按钮逻辑分为几个部分。首先,将按钮引脚配置为在上升沿产生中断。然后等待中断。为了测量,`button_processed` 引脚在等待行的前后也会被置高和置低。等待结束后,触发计数增加,按钮按下变量被置高,并格式化一条消息发送到消息队列。然后,将中断设置为下降沿,重复此过程。 敏锐的读者可能注意到按钮没有去抖(debouncing),这绝对是个问题。但我觉得如果我在这里加入延迟,会破坏我们将要进行的测量。所以没有做去抖处理,这使得 `button_pressed` 变量的状态有点不可靠。 #### Rust 总之,这是 Rust 代码: ```rust #[embassy::task] async fn button_waiter( mut button: ExtiInput<'static, PC13>, button_pressed: &'static AtomicBool, sender: Sender<'static, Noop, ArrayString<32>, 8>, mut button_processed: Output<'static, PG1>, ) { let mut trigger_count = 0; loop { button_processed.set_low().unwrap(); button.wait_for_rising_edge().await; button_processed.set_high().unwrap(); trigger_count += 1; button_pressed.store(true, Ordering::SeqCst); if sender.send(format_message(trigger_count, true)).await.is_err() { panic!("SendError"); } button_processed.set_low().unwrap(); button.wait_for_falling_edge().await; button_processed.set_high().unwrap(); trigger_count += 1; button_pressed.store(false, Ordering::SeqCst); if sender.send(format_message(trigger_count, false)).await.is_err() { panic!("SendError"); } } } ``` 我发现对 `sender.send()` 结果进行 unwrap 会导致不合理的大格式化代码(大小方面),而它并没有显示任何相关信息。所以现在它只做一个简单的 panic。 #### C 在 C 代码中,我们需要自己更改引脚中断方向。 ```c void StartButtonWaiterTask(void *argument) { int triggerCount = 0; UartMessage message; /* 无限循环 */ for (;;) { // 仅响应上升沿 EXTI->RTSR |= USER_Btn_Pin; EXTI->FTSR &= ~USER_Btn_Pin; HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_RESET); osThreadFlagsWait(1, osFlagsWaitAny, osWaitForever); HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_SET); triggerCount++; // 设置按钮按下变量 atomic_store(&buttonPressed, true); message = FormatMessage(triggerCount, true); CheckStatus( osMessageQueuePut(uartQueueHandle, &message, 0, osWaitForever) ); // 仅响应下降沿 EXTI->RTSR |= USER_Btn_Pin; EXTI->FTSR &= ~USER_Btn_Pin; HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_RESET); osThreadFlagsWait(1, osFlagsWaitAny, osWaitForever); HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_SET); triggerCount++; // 设置按钮按下变量 atomic_store(&buttonPressed, false); message = FormatMessage(triggerCount, false); CheckStatus( osMessageQueuePut(uartQueueHandle, &message, 0, osWaitForever) ); } } ``` ```rust ```
相似文章
Rust异步与ARM通用定时器
一篇技术博客文章,探讨了在ARM架构上使用ARM通用定时器进行Rust异步编程,比较了定时器外设,并讨论了Embassy和RTIC等框架。
Rust语言的性能
本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。
我们如何(及为何)将生产环境的C++前端基础设施重写为Rust
NearlyFreeSpeech.NET 将其生产环境的C++前端基础设施(nfsncore)重写为Rust,该系统负责所有传入请求的路由、缓存和访问控制。迁移的动机是Rust的安全性保证、性能、生态系统优势以及老化的C++代码库的局限性。
The Rust on ESP Book
《The Rust on ESP Book》是一本全面的指南,用于在 Espressif 产品上进行嵌入式 Rust 开发,涵盖项目生成、工具链和软件栈结构。
UFerris:面向Rust嵌入式初学者的多功能学习板
uFerris是一款多功能开源学习板,专为Rust嵌入式初学者设计,通过Seeed XIAO接口支持多种MCU,并配套《Simplified Embedded Rust》书籍。