Embedded Rust: no_std e microcontroladores

1. Introdução ao Embedded Rust

Rust é uma linguagem que promete segurança de memória sem garbage collector, mas em sistemas embarcados típicos — microcontroladores com poucos KB de RAM e sem sistema operacional — não temos acesso ao runtime completo da biblioteca padrão (std). É aqui que entra o no_std.

O atributo #![no_std] diz ao compilador para usar apenas o crate core, que contém tipos, traits e funções independentes de alocação dinâmica e de um sistema operacional. Sem std, não há Vec, String, HashMap, File, ou threads do SO. Em compensação, ganhamos previsibilidade, tamanho reduzido de binário e controle total sobre o hardware.

No ecossistema Embedded Rust, trabalhamos com três camadas principais:
- PACs (Peripheral Access Crates): gerados a partir de SVD (System View Description), expõem registros de hardware de forma crua e unsafe.
- HALs (Hardware Abstraction Layers): constroem sobre PACs com APIs seguras e ergonômicas (ex: stm32f4xx-hal, rp2040-hal).
- BSPs (Board Support Packages): integram HALs para placas específicas (ex: nucleo-f401re, raspberry-pi-pico).

2. Configuração do Ambiente e Primeiro Projeto

Para começar, instale o target para Cortex-M3 (exemplo comum):

rustup target add thumbv7m-none-eabi

Crie um projeto library:

cargo init --lib meu_projeto
cd meu_projeto

No arquivo .cargo/config.toml, configure o target e o linker:

[build]
target = "thumbv7m-none-eabi"

[target.thumbv7m-none-eabi]
rustflags = ["-C", "link-arg=-Tlink.x"]

Crie um arquivo memory.x para definir as regiões de memória do microcontrolador (ex: STM32F103):

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 64K
  RAM   : ORIGIN = 0x20000000, LENGTH = 20K
}

Adicione as dependências no Cargo.toml:

[dependencies]
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5"
panic-halt = "0.2"

3. O Atributo #![no_std] e o Entry Point

No src/lib.rs, escrevemos o programa mínimo:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _; // trata panics com loop infinito

#[entry]
fn main() -> ! {
    loop {}
}

Explicação:
- #![no_std]: remove a biblioteca padrão.
- #![no_main]: desabilita o entry point padrão do Rust.
- #[entry]: macro do cortex-m-rt que gera o vetor de reset adequado.
- panic_halt: implementa #[panic_handler] com um loop infinito.

Sem panic_handler, o compilador reclama. Podemos usar alternativas como panic_abort (chama abort()) ou panic_semihosting (envia mensagem via depurador).

Exemplo prático: piscar LED (conceitual, sem HAL ainda)

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;

// Endereço do registo de saída do GPIOA (exemplo STM32F1)
const GPIOA_ODR: *mut u32 = 0x4001_080C as *mut u32;

#[entry]
fn main() -> ! {
    // Configuração omitida (RCC, MODER, etc.)
    loop {
        unsafe {
            *GPIOA_ODR ^= 1 << 5; // toggle bit 5 (PA5)
        }
        // delay aproximado
        for _ in 0..1_000_000 {}
    }
}

4. Acesso a Periféricos com PACs e HALs

Trabalhar com registros brutos é propenso a erros. Usamos PACs e HALs. Exemplo com stm32f1xx-hal:

[dependencies.stm32f1xx-hal]
features = ["stm32f103", "rt"]
version = "0.10"
#![no_std]
#![no_main]

use panic_halt as _;
use stm32f1xx_hal::{
    pac,
    prelude::*,
    timer::Timer,
};
use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let mut rcc = dp.RCC.constrain();
    let mut flash = dp.FLASH.constrain();
    let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);

    let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);

    let mut timer = Timer::syst(dp.SYST, 1.hz(), &mut rcc.clocks);

    loop {
        led.toggle();
        timer.wait();
    }
}

Aqui:
- Peripherals::take() acessa os registros de forma segura (uma única vez).
- constrain() configura clocks e pinos.
- Timer::syst usa o SysTick para gerar delays precisos.
- led.toggle() alterna o pino PC13 (LED da Blue Pill).

5. Interrupções e Concorrência sem OS

Interrupções são essenciais em sistemas embarcados. Com cortex-m-rt, usamos #[interrupt]:

use cortex_m::peripheral::NVIC;
use cortex_m_rt::entry;

// Recurso compartilhado
static COUNTER: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);

#[interrupt]
fn TIM2() {
    COUNTER.fetch_add(1, core::sync::atomic::Ordering::SeqCst);
}

#[entry]
fn main() -> ! {
    // Configurar TIM2 para gerar interrupção a cada 1s (omitido)
    unsafe {
        NVIC::unmask(pac::Interrupt::TIM2);
    }
    loop {
        // Pode ler COUNTER com critical_section
        let val = cortex_m::interrupt::free(|_| COUNTER.load(core::sync::atomic::Ordering::SeqCst));
        // ...
    }
}

Para dados maiores que AtomicU32, usamos Mutex do crate cortex-m:

use cortex_m::interrupt::Mutex;
use core::cell::RefCell;

static SHARED: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

#[interrupt]
fn TIM2() {
    cortex_m::interrupt::free(|cs| {
        *SHARED.borrow(cs).borrow_mut() += 1;
    });
}

6. Alocação Dinâmica e Vec sem std

Às vezes precisamos de Vec ou String. Para isso, precisamos de um allocador global:

[dependencies]
alloc-cortex-m = "0.4"
#![no_std]
#![no_main]
#![feature(alloc_error_handler)]

extern crate alloc;
use alloc::vec::Vec;
use alloc_cortex_m::CortexMHeap;

#[global_allocator]
static ALLOCATOR: CortexMHeap = CortexMHeap::empty();

#[entry]
fn main() -> ! {
    unsafe { ALLOCATOR.init(cortex_m_rt::heap_start() as usize, 1024); } // 1KB heap

    let mut v = Vec::new();
    v.push(42);
    // ...
    loop {}
}

Limitações importantes:
- Heap pequeno e fixo.
- Fragmentação é um problema real.
- HashMap não está disponível (precisa de std ou crate específico).

Alternativas recomendadas:
- heapless: coleções de tamanho fixo (sem alocação dinâmica).
- tinyvec: Vec baseado em array estático.

7. Comunicação Serial e Debugging

Depurar sem println! é desafiador. Usamos UART ou RTT.

Exemplo com UART (STM32F1):

use stm32f1xx_hal::serial::{Config, Serial};
use nb::block;

let serial = Serial::usart1(
    dp.USART1,
    (gpioc.pc10, gpioc.pc11), // TX, RX
    Config::default().baudrate(115200.bps()),
    &mut rcc.apb2,
);
let (mut tx, _rx) = serial.split();

block!(tx.write(b'H')).ok();
block!(tx.write(b'i')).ok();

Debugging com RTT (Real-Time Transfer):

[dependencies]
rtt-target = { version = "0.4", features = ["cortex-m"] }
use rtt_target::{rtt_init_print, rprintln};

#[entry]
fn main() -> ! {
    rtt_init_print!();
    rprintln!("Hello from RTT!");
    loop {}
}

Para monitorar: probe-rs run --chip STM32F103C8 target/thumbv7m-none-eabi/debug/meu_projeto

Ferramentas úteis:
- probe-rs: debugger moderno com suporte a RTT.
- openocd + itmdump: para semihosting.
- screen /dev/ttyUSB0 115200: para UART.

8. Próximos Passos e Boas Práticas

  • Gerenciamento de energia: use asm::wfi() (wait for interrupt) para reduzir consumo.
  • Simulação: QEMU com cortex-m-semihosting permite testar sem hardware.
  • Segurança: minimize unsafe; prefira HALs e PACs testados.
  • RTIC (Real-Time Interrupt-driven Concurrency): framework para concorrência determinística em sistemas de tempo real.

Boas práticas finais:
- Sempre defina um panic_handler explícito.
- Use critical_section para acesso a recursos compartilhados.
- Prefira heapless a alocação dinâmica sempre que possível.
- Documente o layout de memória (memory.x) e o consumo de stack.

Referências