Embedded Rust RTOS vs. C RTOS

Sep 03, 2026 01:32 AM - 1 hour ago 1

It's clip for different method blog station astir async Rust connected embedded. This clip we're going to transportation Embassy/Rust against FreeRTOS/C connected an STM32F446 microcontroller.

It's clip for different method blog station astir async Rust connected embedded. This clip we're going to transportation Embassy/Rust against FreeRTOS/C connected an STM32F446 microcontroller.

They will some beryllium moving applications that execute the aforesaid actions. We're past going to judge them connected the ground of interrupt latency, programme size, ram usage and easiness of programming. There are already a batch of articles that comparison C and Rust, truthful we're not going to attraction connected that today.

What I will effort to show are 2 'normal' applications. Both projects could beryllium tuned to springiness amended capacity pinch a batch of work. Doing that tin beryllium a astir endless task. So arsenic a guideline, the applications will be:

  • Portable(-ish) to different chips and architectures (aside from the dependency connected the HAL)
  • Straightforward
  • Tuned pinch normal options and settings for illustration compiler optimizations, rtos settings and thread priorities

In the end, we should person a basal knowing of really RTOS'es and async executors (can) work.

I americium biased, but I dream this blog station gives a adjacent comparison. If you person suggestions, please fto america know!

We'll beryllium testing pinch the STM32F446ZET6 microcontroller astatine 180Mhz and immoderate of the measurements will beryllium done pinch a Rigol DS1054Z oscilloscope.

Async Rust

An async usability successful Rust is syntax sweetener for a usability that returns a future.

pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; }

The usability is transformed into a authorities instrumentality entity that tin beryllium polled. The authorities instrumentality allows the codification to jump into the function, resuming wherever it antecedently stopped. It besides keeps way of each the variables that are retained crossed await points.

Rust futures are lazy, they only tally erstwhile polled. To tally a early to its completion, each you person to do is to continuously telephone the canvass usability until it stops returning the Pending authorities and returns the Ready(Output) state.

This is straightforward, but not very efficient.

To hole that, location are besides Wakers. A waker tin awesome to the organizer that a early ought to beryllium polled again. This waker tin beryllium called by the early itself aliases tin beryllium fixed to different process/thread the early depends on. In general, an organizer calls the canvass usability erstwhile and past only calls it again erstwhile the waker is triggered.

A early tin telephone different futures and incorporated them into itself. For an executor, immoderate top-level early it polls is usually called a task.

Lots much tin beryllium said. Luckily I don't person to because location are immoderate really bully resources retired there:

  • Under the Hood: Executing Futures and Tasks
  • How Rust optimizes async/await
  • Understanding Rust futures by going measurement excessively deep

In Embassy

Embassy uses this system arsenic good but adds a mates of constraints.

  • Tasks person to beryllium statically allocated
    • Embassy doesn't want to dangle connected an allocator
    • All tasks must beryllium known astatine compile time
  • A nightly compiler is required
    • The type_alias_impl_trait preview characteristic is required
    • This is because we can't usage boxed trait objects, owed to having nary allocator

For galore peripherals, Embassy has made an async interface. This allows for the pursuing 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 mates of things are happening here.

The wait_for_rising_edge creates a caller early and returns it. The constructor of the early configures the interrupt of the pin. On the first poll, the early puts its waker into a world array of EXTI wakers. When an EXTI interrupt happens, the due waker successful that array is utilized to aftermath up the correct task.

So erstwhile the interrupt exits, the executer polls the task again, the wait_for_rising_edge early notices its interrupt has fired and returns that it is ready. And truthful the programme continues.

One point Embassy doesn't do is pre-emption, which intends that the progressive task is only switched to a much important 1 erstwhile it awaits something. This is called cooperative multitasking. But Embassy has immoderate different features that make this missing characteristic a non-issue, which will beryllium covered later connected successful this article.

RTOS

A real-time operating strategy divides everything up into independent threads. Different from tasks is that threads don't tally a authorities machine, but tally normal code. This intends that you don't person to programme your codification successful a typical way. Any aged usability tin beryllium tally successful an RTOS.

When a thread's execution must beryllium paused to move to different thread, the full processor discourse must beryllium captured and saved because the thread is moving normal code. When that codification resumes, it will require the processor discourse to beryllium the aforesaid again.

This creation of multithreading lends itself to pre-emptive threads. This intends that the kernel tin springiness adjacent execution clip to each threads, that the personification tin specify priorities and that the kernel tin respond to events and interrupts successful a predictable magnitude of time.

This explanation doesn't moreover scratch the aboveground of an RTOS. To get a amended understanding, present are immoderate articles if you're interested:

  • How to build a Real-Time Operating System
  • FreeRTOS Kernel Developer Docs

Let the showdown begin!

Now that we cognize a spot astir the 2 models, we're going to transportation them against each different by implementing the aforesaid programme successful both.

The program

We can't build a afloat realistic programme because that would conscionable return excessively agelong to build. But let's effort to person thing that is not excessively simple.

There are a mates of things we request to beryllium capable to declare to beryllium approaching realism:

  • Multiple tasks
  • Data sharing betwixt tasks
  • Responding to interrupts

So, what our programme will do is the pursuing 3 (literal) tasks:

  • Blink an LED each 200ms for 100ms
    • Be successful a loop and usage the hold usability of the executor
    • If the personification fastener is pressed, the led mustn't beryllium turned connected
      • This is communicated from different thread (no checking the registry ourselves)
  • Keep way of the personification fastener
    • Set up a gpio interrupt truthful we tin observe a awesome change
    • Communicate successful a shared (atomic) boolean whether the fastener is precocious aliases low
    • When the fastener authorities changes, put a drawstring connected the connection queue pinch the matter Button is <0/1> (N)\n wherever <0/1> is 0 if the fastener is debased and 1 if the fastener is precocious and N is the number of triggers
  • Print the connection queue to serial
    • Wait for the connection queue to incorporate a string
    • Print it to serial

What we're measuring

This showdown tin beryllium won connected the ground of these things:

Performance

How agelong does the fastener gpio interrupt take?

  • When the interrupt fires, we will group a pin high
  • When the interrupt ends, we will group the pin low
  • The clip successful betwixt is measured by an oscilloscope

How agelong does the fastener thread return until it waits again?

  • When the thread stops waiting, we will group a pin high
  • When the thread starts waiting again, we will group the pin low
  • The clip successful betwixt is measured by an oscilloscope

Interrupt (processing) latency

What is the clip betwixt the commencement of the fastener gpio interrupt and the fastener thread resuming?

  • The clip betwixt the emergence of the interrupt pin and the emergence of the thread pin is measured by an oscilloscope

Program size

.text conception arsenic reported by arm-none-eabi-size

Static representation usage

.data + .bss conception arsenic reported by arm-none-eabi-size

  • All tasks and threads are statically allocated

We're only looking astatine fixed representation usage because move representation usage is difficult to measure. A programme that statically allocates a batch of representation will apt usage little stack representation than a akin programme that doesn't. However, since RTOS'es tin struggle pinch this, I deliberation it's a applicable metric to compare.

Ease of programming

Very subjective, I know

To reiterate from the start, we're not looking for the astir optimized solution. The extremity is to person a comparatively normal program.

Expectations

I don't really cognize what to expect isolated from that an RTOS is made to really optimize capacity and latency. So based connected that, present are my predictions:

Performance

The RTOS will group a emblem successful the thread directly, this is astir apt faster than having to find an async waker and triggering it.

Aside from really the codification is resumed and suspended, there's not overmuch quality for the fastener thread betwixt the 2 implementations. I expect they will return a akin magnitude of time.

Interrupt (processing) latency

The RTOS will astir apt beryllium much optimized for this. Embassy can't pre-empt moving tasks, truthful it's little worthwhile to optimize this a lot.

Program size

Rust programs are usually a spot bigger owed to much costly formatting and compiler inserted runtime checks. Since the remainder of the programme is fundamentally the same, I expect the C implementation to usage little flash memory.

Static representation usage

Because Rust's compiler-generated futures only shop the variables that are held crossed an await constituent and doesn't person to afloat allocate a full-stack size, the Rust implementation should win.

Ease of programming

Ignoring the 'Rust vs C' side, I deliberation the async exemplary will beryllium nicer to activity with. In the web world async/await has already won from threads, truthful that will astir apt beryllium the lawsuit present arsenic well.

Let's look astatine the code

The repository tin beryllium recovered here: github The C task is made successful STMCube 1.8 and the Rust task is simply a modular cargo binary.

Getting the fastener interrupt noticed

We're not going to process everything successful the interrupt, we're conscionable notifying the organizer that the interrupt has happened.

For Rust, we don't request to do thing because this is precisely what Embassy already does.

In C we request to create a usability 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); } }

Blinking the led

We're going to usage the normal hold usability of each organizer to hold for our time. To find if the fastener is pressed, we person an atomic bool that we request to read. In C that bool is stored successful a world because the tasks are created globally and getting it from the void pointer statement is not very nice.

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(); } }

In Rust we request to annotate our task usability truthful it tin beryllium statically allocated. The LED is besides fixed arsenic an statement because the peripherals are modeled utilizing Rust's ownership model.

C

The atomic types successful C are an optional portion of the C11 spec and luckily our compiler implements them. This makes utilizing atomic types a batch much comfortable.

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); } }

Writing the connection queue to serial

The messages we nonstop to the penning task are fundamentally strings. The Rust implementation uses the ArrayVec room to get entree to a bully stack allocated ArrayString type.

In C we don't person arsenic overmuch luxury, truthful I made a elemental type for it:

typedef struct { char data[32]; } UartMessage;

The connection queue has a capacity of 8 messages. The thread/task will hold for a caller connection to show up and past people it to the uart.

Rust

The Rust implementation is beautiful straightforward:

#[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

In C we request to do a spot much representation and size management:

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) ); } }

Waiting connected the button

The fastener logic is divided into a mates of parts.

First, the fastener pin is configured to make an interrupt connected a rising edge. The interrupt is past waited on. For the measurement, the button_processed pin is besides turned precocious and debased astir the waiting line.

After the waiting is over, the trigger count is upped, the fastener pressed adaptable is group precocious and a connection is formatted and sent to the connection queue.

This is past repeated pinch the interrupt group to the falling edge.

Observant readers mightiness announcement that location isn't immoderate debouncing for the fastener and that's decidedly a problem. But I felt that if I put successful a hold here, it would ruin the measurements we're going to do. So nary debouncing is done, which makes the authorities of the button_pressed adaptable a spot unreliable.

Rust

Anyway, present is the Rust code:

#[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"); } } }

I recovered retired that unwrapping the sender.send() consequence leads to unreasonably costly formatting codification (size-wise) while it doesn't show immoderate applicable information. So it now does conscionable a elemental panic.

C

In the C code, we request to alteration the pin interrupt guidance ourselves.

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); connection = 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); connection = FormatMessage(triggerCount, false); CheckStatus( osMessageQueuePut(uartQueueHandle, &message, 0, osWaitForever) ); } }

That's beautiful overmuch each of the codification speech from the setup.

One different point that is missing is the interrupt codification that sets the interrupt pin high. It is included successful the C project. But Embassy provides its ain interrupt function, truthful that had to beryllium modified.

In the exti record of the embassy_stm32 file, I added it here:

macro_rules! impl_irq { ($e:ident) => { #[interrupt] unsafe fn $e() { pac::gpio::Gpio(0x40021800 as *mut u8).odr().modify(|odr| odr.set_odr(0, stm32_metapac::gpio::vals::Odr::HIGH)); let x = on_irq(); pac::gpio::Gpio(0x40021800 as *mut u8).odr().modify(|odr| odr.set_odr(0, stm32_metapac::gpio::vals::Odr::LOW)); x } }; }

After each this time, let's look astatine what the results are!

Results

First off, I really for illustration the async await model. Once you judge the thought that you tin await thing that you'd usually person an interrupt for, it writes very nicely! Managing threads is not a batch of fun, truthful I'm not really missing that part.

Because Embassy is built astir interrupts, its creation feels really bully and integrated. Handling the interrupt successful FreeRTOS is simply a batch little ergonomic. For me, this is simply a triumph for Embassy.

Let's tally the tests truthful we tin look astatine the numbers.

I will push the fastener a 100 times truthful we'll get 2 100 samples. Then I will statement down the mean and modular deviation times.

TestCRustDifferenceDifference %
Interrupt clip (avg)2.962us1.450us-1.512us-51.0%
Interrupt clip (stddev)124.8ns4.96ns-119.84ns-96.0%
Thread clip (avg)16.19us11.64us-4.55us-28.1%
Thread clip (stddev)248.2ns103.0ns-145.2ns-56.2%
Interrupt latency (avg)4.973us3.738us-1.235us-24.8%
Interrupt latency (stddev)158.0ns45.3ns-112.7ns-71.3%
Program size20676b14272b-6404b-31.0%
Static representation size5480b872b-4608b-84.1%

Oh...

Wow...

I genuinely did not expect this.

These numbers are besides repeatable connected different days (with flimsy variations of course).

It looks for illustration Embassy/Rust won successful each category! Ok, let's astatine slightest look astatine thing wherever FreeRTOS/C did really hit Rust. If we look astatine the clip betwixt the extremity of the interrupt and the commencement of the thread awaking, we get the pursuing numbers: (Interrupt latency - Interrupt time)

  • C: 4.973 - 2.962 = 2.011us
  • Rust: 3.738 - 1.450 = 2.288us

This shows that purely the discourse switching and resuming the thread is faster successful the RTOS. But successful the look of an interrupt that takes doubly arsenic long, this triumph isn't that relevant. What we can't spot is what the nonstop origin of the longer interrupt clip is. Is it the utilized STM Cube HAL? Or is it an inefficiency successful FreeRTOS? Or is it inherent to the thread signalling model? To reply that we'd person to trial much RTOS'es and much HALS. That's possibly thing for different time.

One of the biggest improvements we could make successful the RTOS codification is moving the fastener logic to wrong of the interrupt. This is thing that is not imaginable successful Embassy, because it itself creates the interrupt functions for us. There's a tradeoff here. Freedom successful FreeRTOS/C and easiness of improvement for Embassy/Rust.

The winner

I tin only state Embassy/Rust arsenic the victor here.

Not only is it nicer to programme successful my opinion, each the numbers look to favour it too.

Wrap up

There's conscionable 1 point that whitethorn still interest you. An RTOS tin beryllium utilized successful existent real-time applications. Because the async tasks can't beryllium pre-empted, it is not ever imaginable to execute different task successful time. While this is existent for a group of tasks successful 1 executor, Embassy allows america to usage further executors that tally wrong interrupt contexts.

A waker will not only trigger the organizer to tally a task, if the organizer is connected an interrupt context, the waker besides sets the executor's interrupt pending. This measurement if the organizer is connected an interrupt that has a higher priority, it will pre-empt different executors connected little priorities.

Here's the illustration that Embassy gives connected Github.

I'd for illustration to convey the creator of Embassy, Dario, and Sjors from Jitter for giving feedback connected this station and for answering my questions.

If you person feedback, I'd emotion to perceive it! You tin scope maine astatine [email protected] and connected twitter.

Discussions connected /r/rust and /r/embedded.

Edit

It was brought to my attraction that I had near the heap turned connected successful the C task moreover though it was not used. This caused the fixed representation size to beryllium 15kb bigger than it really had to be. I've subtracted the heap size from the fixed representation size. The conclusion is still the aforesaid though.

RTIC addendum (17-02-2022)

We sewage a propulsion petition connected our repo to adhd an implementation for RTIC. Thanks Rafael Bachmann! (barafael)

RTIC is simply a afloat interrupt-driven runtime that is utilized rather a spot successful the rust embedded ecosystem. You tin find much here: https://rtic.rs/1/book/en/preface.html.

I've changed the PR a small spot truthful that it falls successful statement pinch the FreeRTOS and Embassy implementations and person tally the numbers again.

It is important to say, though, that the RTIC implementation is not wholly adjacent to the different 2 implementations. Because RTIC defines its interrupt handlers wrong of a macro (so I can't modify them), I can't group 1 of the gpio pins precocious immediately. However, since RTIC is simply a really mini furniture connected apical of the interrupts, I still deliberation mounting the pin precocious successful the user-provisioned interrupt usability will correspond the capacity conscionable fine.

We're going to usage Embassy arsenic our baseline.

TestRTICEmbassyDifferenceDifference %
Interrupt clip (avg)650.8ns1450ns799ns122.8%
Interrupt clip (stddev)10.34ns4.96ns-5.38ns-52.0%
Thread clip (avg)7.807us11.64us-3.83us49.1%
Thread clip (stddev)279.9ns103.0ns-176.9ns-63.2%
Interrupt latency (avg)1.184us3.738us2.554us215.7%
Interrupt latency (stddev)77.75ns45.3ns-32.45ns-41.7%
Program size8888b14272b5384b60.0%
Static representation size392b872b480b122.4%

So, RTIC shows immoderate awesome results. That's of people very logical. The little runtime you bring along, the little you person to carry.

RTIC is simply a clear betterment complete manually implementing interrupts. I usually opportunity that if you support implementing features connected interrupts, past yet you'll get a worse type of RTIC, truthful conscionable usage RTIC.

Attribution

The Rust Embedded Working Group Logo, based connected the Rust logo, was designed by Erin Power.

More