Pangram verdict · v3.3
We believe that this entire text is human-written.
AI likelihood · overall
HumanArticle text · 1,576 words · 1 segments analyzed
It's time for another technical blog post about async Rust on embedded. This time we're going to pitch Embassy/Rust against FreeRTOS/C on an STM32F446 microcontroller.It's time for another technical blog post about async Rust on embedded. This time we're going to pitch Embassy/Rust against FreeRTOS/C on an STM32F446 microcontroller.They will both be running applications that perform the same actions. We're then going to judge them on the basis of interrupt latency, program size, ram usage and ease of programming. There are already a lot of articles that compare C and Rust, so we're not going to focus on that today.What I will try to show are two 'normal' applications. Both projects could be tuned to give better performance with a lot of work. Doing that can be a nearly endless task. So as a guideline, the applications will be:Portable(-ish) to other chips and architectures (aside from the dependency on the HAL)StraightforwardTuned with normal options and settings like compiler optimizations, rtos settings and thread prioritiesIn the end, we should have a basic understanding of how RTOS'es and async executors (can) work.I am biased, but I hope this blog post gives a fair comparison. If you have suggestions, please let us know!We'll be testing with the STM32F446ZET6 microcontroller at 180Mhz and some of the measurements will be done with a Rigol DS1054Z oscilloscope.Async RustAn async function in Rust is syntax sugar for a function that returns a future.pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } The function is transformed into a state machine object that can be polled. The state machine allows the code to jump into the function, resuming where it previously stopped. It also keeps track of all the variables that are retained across await points.Rust futures are lazy, they only run when polled. To run a future to its completion, all you have to do is to continuously call the poll function until it stops returning the Pending state and returns the Ready(Output) state.This is straightforward, but not very efficient.To fix that, there are also Wakers. A waker can signal to the executor that a future ought to be polled again. This waker can be called by the future itself or can be given to another process/thread the future depends on. In general, an executor calls the poll function once and then only calls it again when the waker is triggered.A future can call other futures and incorporate them into itself. For an executor, any top-level future it polls is usually called a task.Lots more can be said. Luckily I don't have to because there are some really good resources out there:Under the Hood: Executing Futures and TasksHow Rust optimizes async/awaitUnderstanding Rust futures by going way too deepIn EmbassyEmbassy uses this mechanism as well but adds a couple of constraints.Tasks have to be statically allocated Embassy doesn't want to depend on an allocatorAll tasks must be known at compile timeA nightly compiler is required The type_alias_impl_trait preview feature is requiredThis is because we can't use boxed trait objects, due to having no allocatorFor many peripherals, Embassy has made an async interface. This allows for the following code:#[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!"); } } A couple of things are happening here.The wait_for_rising_edge creates a new future and returns it. The constructor of the future configures the interrupt of the pin. On the first poll, the future puts its waker into a global array of EXTI wakers. When an EXTI interrupt happens, the appropriate waker in that array is used to wake up the right task.So when the interrupt exits, the executer polls the task again, the wait_for_rising_edge future notices its interrupt has fired and returns that it is ready. And so the program continues.One thing Embassy doesn't do is pre-emption, which means that the active task is only switched to a more important one when it awaits something. This is called cooperative multitasking. But Embassy has some other features that make this missing feature a non-issue, which will be covered later on in this article.RTOSA real-time operating system divides everything up into independent threads. Different from tasks is that threads don't run a state machine, but run normal code. This means that you don't have to program your code in a special way. Any old function can be run in an RTOS.When a thread's execution must be paused to switch to another thread, the entire processor context must be captured and saved because the thread is running normal code. When that code resumes, it will require the processor context to be the same again.This design of multithreading lends itself to pre-emptive threads. This means that the kernel can give fair execution time to all threads, that the user can specify priorities and that the kernel can respond to events and interrupts in a predictable amount of time.This description doesn't even scratch the surface of an RTOS. To get a better understanding, here are some articles if you're interested:How to build a Real-Time Operating SystemFreeRTOS Kernel Developer DocsLet the showdown begin!Now that we know a bit about the two models, we're going to pitch them against each other by implementing the same program in both.The programWe can't build a fully realistic program because that would just take too long to build. But let's try to have something that is not too simple.There are a couple of things we need to be able to claim to be approaching realism:Multiple tasksData sharing between tasksResponding to interruptsSo, what our program will do is the following three (literal) tasks:Blink an LED every 200ms for 100ms Be in a loop and use the delay function of the executorIf the user button is pressed, the led mustn't be turned on This is communicated from another thread (no checking the register ourselves)Keep track of the user button Set up a gpio interrupt so we can detect a signal changeCommunicate in a shared (atomic) boolean whether the button is high or lowWhen the button state changes, put a string on the message queue with the text Button is <0/1> (N)\n where <0/1> is 0 if the button is low and 1 if the button is high and N is the number of triggersPrint the message queue to serial Wait for the message queue to contain a stringPrint it to serialWhat we're measuringThis showdown can be won on the basis of these things:PerformanceHow long does the button gpio interrupt take?When the interrupt fires, we will set a pin highWhen the interrupt ends, we will set the pin lowThe time in between is measured by an oscilloscopeHow long does the button thread take until it waits again?When the thread stops waiting, we will set a pin highWhen the thread starts waiting again, we will set the pin lowThe time in between is measured by an oscilloscopeInterrupt (processing) latencyWhat is the time between the start of the button gpio interrupt and the button thread resuming?The time between the rise of the interrupt pin and the rise of the thread pin is measured by an oscilloscopeProgram size.text section as reported by arm-none-eabi-sizeStatic memory usage.data + .bss section as reported by arm-none-eabi-sizeAll tasks and threads are statically allocatedWe're only looking at static memory usage because dynamic memory usage is difficult to measure. A program that statically allocates a lot of memory will likely use less stack memory than a similar program that doesn't. However, since RTOS'es can struggle with this, I think it's a relevant metric to compare.Ease of programmingVery subjective, I knowTo reiterate from the start, we're not looking for the most optimized solution. The goal is to have a relatively normal program.ExpectationsI don't really know what to expect except that an RTOS is made to really optimize performance and latency. So based on that, here are my predictions:PerformanceThe RTOS will set a flag in the thread directly, this is probably faster than having to find an async waker and triggering it.Aside from how the code is resumed and suspended, there's not much difference for the button thread between the two implementations. I expect they will take a similar amount of time.Interrupt (processing) latencyThe RTOS will probably be more optimized for this. Embassy can't pre-empt running tasks, so it's less worthwhile to optimize this a lot.Program sizeRust programs are usually a bit bigger due to more expensive formatting and compiler inserted runtime checks. Since the rest of the program is essentially the same, I expect the C implementation to use less flash memory.Static memory usageBecause Rust's compiler-generated futures only store the variables that are held across an await point and doesn't have to fully allocate a full-stack size, the Rust implementation should win.Ease of programmingIgnoring the 'Rust vs C' side, I think the async model will be nicer to work with. In the web world async/await has already won from threads, so that will probably be the case here as well.Let's look at the codeThe repository can be found here: github The C project is made in STMCube 1.8 and the Rust project is a standard cargo binary.Getting the button interrupt noticedWe're not going to process everything in the interrupt, we're just notifying the executor that the interrupt has happened.For Rust, we don't need to do anything because this is exactly what Embassy already does.In C we need to create a function for the interrupt ourselves and notify the thread:void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == USER_Btn_Pin) { osThreadFlagsSet(buttonWaiterHandle, 1); } }