Async Rust vs RTOS showdown!

Het is tijd voor een nieuwe technische blogpost over async Rust in embedded systemen. Deze keer zetten we Embassy/Rust af tegen FreeRTOS/C op een STM32F446-microcontroller.

Beide systemen draaien applicaties die dezelfde acties uitvoeren. We beoordelen ze op basis van interrupt-latentie, programmagrootte, RAM-gebruik en het gemak van programmeren. Er zijn al veel artikelen die C en Rust vergelijken, dus daar ligt vandaag niet de focus op.

Ik probeer twee 'normale' applicaties te tonen. Beide projecten zouden met veel werk verder geoptimaliseerd kunnen worden voor betere prestaties, maar dat is een eindeloze taak. Daarom hanteren we de volgende richtlijnen voor de applicaties:

  • Ze zijn (grotendeels) overdraagbaar naar andere chips en architecturen (op de afhankelijkheid van de HAL na).
  • Ze zijn rechttoe rechtaan geschreven.
  • Ze zijn getuned met normale opties en instellingen, zoals compiler-optimalisaties, RTOS-instellingen en thread-prioriteiten.

Het doel is om een basisbegrip te krijgen van hoe RTOS'en en async executors (kunnen) werken. Hoewel ik bevooroordeeld ben, hoop ik dat deze blogpost een eerlijke vergelijking biedt.

We testen met de STM32F446ZET6-microcontroller op 180 MHz en enkele metingen worden uitgevoerd met een Rigol DS1054Z-oscilloscoop.

Async Rust

Een async-functie in Rust is syntax sugar voor een functie die een future teruggeeft.

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

De functie wordt getransformeerd in een state machine-object dat gepolled kan worden. De state machine maakt het mogelijk om in de functie te springen en te hervatten waar deze eerder stopte. Ook houdt het alle variabelen bij die behouden moeten blijven over await-punten heen.

Rust futures zijn 'lui' (lazy); ze draaien alleen wanneer ze gepolled worden. Om een future tot voltooiing te laten draaien, moet de poll-functie continu worden aangeroepen totdat deze stopt met het teruggeven van de status Pending en Ready(Output) teruggeeft.

Dit is eenvoudig, maar niet erg efficiënt. Om dit op te lossen, zijn er Wakers. Een waker kan het signaal aan de executor geven dat een future opnieuw gepolled moet worden. Deze waker kan worden aangeroepen door de future zelf, of worden doorgegeven aan een ander proces of thread waar de future van afhankelijk is. In het algemeen roept een executor de poll-functie één keer aan en pas opnieuw wanneer de waker wordt geactiveerd.

Een future kan andere futures aanroepen en deze in zichzelf opnemen. Voor een executor wordt elke top-level future die gepolled wordt, meestal een task genoemd.

Voor wie dieper in de materie wil duiken, zijn dit uitstekende bronnen:

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

In Embassy

Embassy maakt gebruik van dit mechanisme, maar voegt een paar beperkingen toe:

  • Tasks moeten statisch worden gealloceerd (Embassy wil niet afhankelijk zijn van een allocator).
  • Alle tasks moeten bekend zijn bij het compileren.
  • Er is een nightly compiler vereist.
  • De typealiasimpl_trait preview-feature is vereist.

Dit is noodzakelijk omdat we geen boxed trait objects kunnen gebruiken vanwege het ontbreken van een allocator. Voor veel randapparatuur (peripherals) heeft Embassy een async-interface gemaakt, wat de volgende code mogelijk maakt:

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

Wat hier gebeurt: waitforrising_edge creëert een nieuwe future en geeft deze terug. De constructor van de future configureert de interrupt van de pin. Bij de eerste poll plaatst de future zijn waker in een globale array van EXTI-wakers. Wanneer een EXTI-interrupt optreedt, wordt de juiste waker in die array gebruikt om de juiste task te wekken.

Zodra de interrupt eindigt, pollt de executor de task opnieuw. De waitforrising_edge-future merkt dat de interrupt is afgegaan en geeft terug dat deze klaar is. Vervolgens gaat het programma verder.

Eén ding dat Embassy niet doet, is pre-emptie. Dit betekent dat een actieve task alleen wordt gewisseld voor een belangrijkere task wanneer deze iets await. Dit wordt coöperatieve multitasking genoemd. Embassy heeft echter andere functies die dit nadeel compenseren, wat later in dit artikel wordt behandeld.

RTOS

Een Real-Time Operating System (RTOS) verdeelt alles in onafhankelijke threads. In tegenstelling tot tasks draaien threads geen state machine, maar normale code. Dit betekent dat je je code niet op een speciale manier hoeft te programmeren; elke willekeurige functie kan in een RTOS worden uitgevoerd.

Wanneer de uitvoering van een thread moet worden gepauzeerd om over te schakelen naar een andere thread, moet de volledige processorcontext worden vastgelegd en opgeslagen. Wanneer de code wordt hervat, moet de processorcontext weer identiek zijn.

Dit ontwerp van multithreading leent zich voor pre-emptieve threads. Dit betekent dat de kernel een eerlijke executietijd kan geven aan alle threads, dat de gebruiker prioriteiten kan opgeven en dat de kernel op gebeurtenissen en interrupts kan reageren binnen een voorspelbare tijd.

Voor een beter begrip van RTOS'en zijn dit interessante bronnen:

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

De strijd begint!

Nu we de twee modellen kennen, gaan we ze tegen elkaar afzetten door hetzelfde programma in beide te implementeren.

Het programma

Om een realistisch beeld te krijgen zonder dat de bouw te lang duurt, implementeren we een programma met de volgende kenmerken:

  • Meerdere tasks.
  • Gegevensdeling tussen tasks.
  • Reageren op interrupts.

Het programma voert de volgende drie taken uit:

  1. Een LED laten knipperen: Elke 200ms knippert de LED gedurende 100ms. Er wordt gebruikgemaakt van de delay-functie van de executor. Als de gebruikersknop is ingedrukt, mag de LED niet aan gaan. Dit wordt gecommuniceerd vanuit een andere thread (geen directe register-check).
  2. De gebruikersknop bewaken: Er wordt een GPIO-interrupt ingesteld om signaalwijzigingen te detecteren. Via een gedeelde (atomische) boolean wordt gecommuniceerd of de knop hoog of laag is. Bij een statuswijziging wordt een string op de message queue geplaatst met de tekst Button is <0/1> (N)\n, waarbij <0/1> de status is en N het aantal triggers.
  3. De message queue naar serieel printen: De task wacht tot de message queue een string bevat en print deze vervolgens naar de seriële poort.

Wat we meten

De winnaar wordt bepaald op basis van:

1. Performance

  • Hoe lang duurt de button GPIO-interrupt? (Gemeten met een oscilloscoop: pin hoog bij start, laag bij einde).
  • Hoe lang duurt de button-thread voordat deze weer gaat wachten? (Gemeten met een oscilloscoop: pin hoog bij hervatting, laag bij start van wachten).

2. Interrupt (processing) latentie

  • Wat is de tijd tussen het begin van de button GPIO-interrupt en het moment dat de button-thread wordt hervat?

3. Programmagrootte

  • De .text sectie zoals gerapporteerd door arm-none-eabi-size.

4. Statisch geheugengebruik

  • De .data + .bss sectie zoals gerapporteerd door arm-none-eabi-size. Alleen statisch geheugen wordt gemeten omdat dynamisch geheugen lastig te meten is.

5. Gemak van programmeren

  • Subjectieve beoordeling.

Verwachtingen

Omdat een RTOS specifiek is gemaakt voor het optimaliseren van performance en latentie, zijn dit mijn voorspellingen:

  • Performance: Het RTOS zal waarschijnlijk sneller een flag in de thread zetten dan dat Rust een async waker moet vinden en triggeren.
  • Interrupt-latentie: Het RTOS zal hier waarschijnlijk geoptimaliseerder zijn. Embassy kan draaiende tasks niet pre-empen, waardoor optimalisatie hier minder prioriteit heeft.
  • Programmagrootte: Rust-programma's zijn vaak iets groter door uitgebreidere formatting en runtime checks. Ik verwacht dat de C-implementatie minder flashgeheugen gebruikt.
  • Statisch geheugengebruik: Rust-futures slaan alleen variabelen op die over een await-punt heen behouden moeten blijven en hebben geen volledige stack-allocatie nodig. Hier zou Rust moeten winnen.
  • Gemak van programmeren: Ongeacht 'Rust vs C', denk ik dat het async-model prettiger is om mee te werken.

De code

De repository is te vinden op GitHub. Het C-project is gemaakt in STM32Cube 1.8 en het Rust-project is een standaard cargo binary.

De button-interrupt detecteren

In Rust hoeven we niets te doen, omdat Embassy dit standaard afhandelt. In C moeten we zelf een functie voor de interrupt maken om de thread te notificeren:

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    if (GPIO_Pin == USER_Btn_Pin) {
        osThreadFlagsSet(buttonWaiterHandle, 1);
    }
}

De LED laten knipperen

Beide gebruiken de standaard delay-functie. In C wordt de atomische boolean in een globale variabele opgeslagen.

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

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

De message queue naar serieel schrijven

Rust gebruikt de ArrayVec library voor een stack-gealloceerde ArrayString. In C is hiervoor een simpel type gemaakt:

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

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:

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

Wachten op de knop

De logica is als volgt: configureer de pin voor een rising edge interrupt, wacht, verhoog de trigger-count, zet de status op 'hoog' en stuur een bericht naar de queue. Herhaal dit voor de falling edge. (Let op: er is geen debouncing toegepast om de metingen niet te vervuilen).

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

C:

void StartButtonWaiterTask(void *argument) {
    int triggerCount = 0;
    UartMessage message;
    for (;;) {
        // Reactie op rising edges
        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));

        // Reactie op falling edges
        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));
    }
}

Resultaten

Het async/await-model is zeer prettig; het idee dat je kunt awaiten op iets waar je normaal een interrupt voor zou gebruiken, maakt de code zeer leesbaar. Embassy voelt goed geïntegreerd aan, terwijl het afhandelen van interrupts in FreeRTOS minder ergonomisch is.

Hieronder volgen de metingen (gemiddelde van 200 samples):

TestCRustVerschilVerschil %
Interrupt tijd (gem.)2.962us1.450us-1.512us-51.0%
Interrupt tijd (stddev)124.8ns4.96ns-119.84ns-96.0%
Thread tijd (gem.)16.19us11.64us-4.55us-28.1%
Thread tijd (stddev)248.2ns103.0ns-145.2ns-56.2%
Interrupt latentie (gem.)4.973us3.738us-1.235us-24.8%
Interrupt latentie (stddev)158.0ns45.3ns-112.7ns-71.3%
Programmagrootte20676b14272b-6404b-31.0%
Statisch geheugen5480b872b-4608b-84.1%

Analyse: Ik had dit niet verwacht, maar Embassy/Rust wint in elke categorie. Als we kijken naar de tijd tussen het einde van de interrupt en het ontwaken van de thread (Interrupt latentie - Interrupt tijd), zien we dat het RTOS wel sneller is in de context-switch:

  • C: $4.973 - 2.962 = 2.011\mu s$
  • Rust: $3.738 - 1.450 = 2.288\mu s$

Echter, omdat de interrupt in C twee keer zo lang duurt, is deze kleine winst niet relevant. De oorzaak van de langere interrupt-tijd in C (STM Cube HAL, FreeRTOS of het signaleringsmodel) zou verder onderzoek vereisen.

De winnaar

Ik kan Embassy/Rust uitroepen tot winnaar. Niet alleen is het prettiger om in te programmeren, maar alle cijfers zijn in het voordeel van Rust.

Afronding

Een punt van zorg bij async is dat tasks niet gepreëmpteerd kunnen worden, wat problematisch kan zijn voor harde real-time eisen. Embassy lost dit echter op door toe te staan dat er extra executors draaien binnen interrupt-contexten. Als een executor in een interrupt-context met een hogere prioriteit draait, zal deze andere executors met een lagere prioriteit pre-empen.

***

RTIC-addendum (17-02-2022)

Er is een implementatie toegevoegd voor RTIC, een volledig interrupt-gestuurde runtime die veel wordt gebruikt in het Rust embedded ecosysteem.

Omdat RTIC een zeer dunne laag bovenop de interrupts is, zijn de resultaten indrukwekkend. We gebruiken Embassy als baseline:

TestRTICEmbassyVerschilVerschil %
Interrupt tijd (gem.)650.8ns1450ns799ns122.8%
Interrupt tijd (stddev)10.34ns4.96ns-5.38ns-52.0%
Thread tijd (gem.)7.807us11.64us-3.83us49.1%
Thread tijd (stddev)279.9ns103.0ns-176.9ns-63.2%
Interrupt latentie (gem.)1.184us3.738us2.554us215.7%
Interrupt latentie (stddev)77.75ns45.3ns-32.45ns-41.7%
Programmagrootte8888b14272b5384b60.0%
Statisch geheugen392b872b480b122.4%

RTIC laat zien dat hoe minder runtime je meebrengt, hoe minder 'bagage' je hebt. Het is een duidelijke verbetering over het handmatig implementeren van interrupts.