Pular para o conteúdo

Rust embarcado: microcontroladores, no_std e Embassy

Rust embarcado (embedded Rust) é escrever Rust para computadores pequenos que vivem dentro das coisas: um termostato, um teclado, um drone, um sensor que rega as plantas. Esses computadores se chamam microcontroladores. Um único chip tem o processador, algumas centenas de kilobytes de memória e os pinos que conversam com LEDs, botões e sensores. Normalmente não existe sistema operacional nenhum. O seu programa é a única coisa rodando, desde que a energia liga até ela desligar.

Parece assustador, mas também é muito divertido: você escreve algumas linhas e uma luz de verdade pisca na sua mesa. Esta página mostra o que é possível, qual placa e quais crates (pacotes) escolher e como colocar o seu primeiro programa em um chip. No caminho, você vai praticar o “jeito embarcado de pensar” com exemplos que rodam direto no navegador.

Por que o Rust combina tão bem com sistemas embarcados

Seção intitulada “Por que o Rust combina tão bem com sistemas embarcados”

Por décadas, o código embarcado foi escrito principalmente em C. O Rust traz algumas coisas que importam muito em um chip minúsculo:

  • Sem garbage collector. Nada pausa o seu programa para limpar a memória, então o tempo de resposta fica previsível. Isso é importante quando você precisa reagir a um sinal em microssegundos.
  • Segurança de memória sem runtime. As regras de ownership (posse) e de borrowing (empréstimo) são verificadas na compilação, então não custam nada no chip. Classes inteiras de falhas (ponteiros pendurados, data races entre uma interrupção e o seu laço principal) são pegas antes de você gravar qualquer coisa no chip.
  • Abstrações de custo zero. Uma HAL deixa você escrever led.set_high() em vez de mexer em endereços de memória, e isso compila para a mesma escrita única no registrador que você faria à mão.
  • O ownership modela o hardware. Um pino é um valor. Depois que ele é movido para um Output, ninguém mais consegue pegá-lo e usá-lo como entrada por engano. O compilador garante o que antes era só um comentário em um arquivo C.
  • Ótimas ferramentas. cargo, os targets do rustup e o probe-rs substituem muitas IDEs de fabricantes e muitos makefiles.

Sendo honesto sobre as arestas: os fabricantes de chips ainda lançam os seus SDKs e exemplos primeiro em C, então para alguns chips o suporte a Rust é mais fraco ou só da comunidade. Você ainda precisa ler datasheets. E alguns crates estão antes da versão 1.0 e mudam a API entre versões, então siga sempre os exemplos da versão que você usa.

  • Sensores e gadgets: uma estação meteorológica, um alarme de umidade do solo, um contador de passos.
  • Dispositivos USB: teclados personalizados, controles de videogame, controladores MIDI.
  • Dispositivos conectados (IoT): sensores com Wi-Fi ou Bluetooth em um ESP32 que mandam leituras para um servidor.
  • Robôs e motores: seguidores de linha, controle de motores de passo e servos, fitas de LED e displays.
  • Sistemas sérios: a Oxide Computer escreveu o Hubris, um pequeno sistema operacional para microcontroladores, inteiramente em Rust. A Espressif, a empresa por trás do ESP32, mantém o seu próprio suporte a Rust e publica o The Rust on ESP Book (em inglês).

Qualquer uma destas é uma boa primeira placa. Se não conseguir decidir, pegue um Raspberry Pi Pico: é barato, muito bem documentado, e o exemplo com Embassy mais abaixo foi escrito para ele.

Placa Chip Target do Rust Por que é legal Comece com
Raspberry Pi Pico / Pico W RP2040 (Arm Cortex-M0+) thumbv6m-none-eabi Barato, popular, excelente suporte no Embassy embassy-rp, ou rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350 (Arm Cortex-M33, ou RISC-V) thumbv8m.main-none-eabihf Mais novo e mais rápido, tem unidade de ponto flutuante embassy-rp (feature rp235xa), rp235x-hal
Placas ESP32-C3 / ESP32-C6 Chips RISC-V da Espressif riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf Wi-Fi e Bluetooth embutidos, suporte oficial a Rust esp-hal e a ferramenta esp-generate
STM32 Nucleo, “Black Pill” STM32 (por exemplo, STM32F4) thumbv7em-none-eabihf Família enorme de chips usada na indústria; as placas Nucleo têm um debug probe embutido embassy-stm32, stm32f4xx-hal
BBC micro:bit v2 nRF52833 (Arm Cortex-M4F) thumbv7em-none-eabihf Debug probe, LEDs, botões e sensores na própria placa microbit-v2, mais o livro Discovery

O micro:bit é a placa usada pelo Embedded Rust Discovery book (em inglês), um curso oficial, gratuito e passo a passo para iniciantes. O ESP32 original e o ESP32-S3 usam núcleos Xtensa em vez de RISC-V; para eles, a ferramenta espup da Espressif instala uma toolchain especial do Rust. O LED do Pico W está ligado ao chip de Wi-Fi, e não a um pino normal, então ele precisa de um blinky diferente (o Embassy tem um exemplo wifi_blinky para ele).

Um programa Rust normal usa a biblioteca padrão (std). A std espera um sistema operacional: arquivos, threads, um heap, um terminal para o println!. Um microcontrolador não tem nada disso. Por isso, os programas embarcados começam com duas linhas:

#![no_std] // não liga a biblioteca padrão, só a `core`
#![no_main] // não usa o ponto de entrada habitual `fn main()`
  • #![no_std] significa que você só tem a core (em inglês): inteiros, Option, Result, iteradores, slices, traits, formatação. Nada de Vec, String, Box, arquivos ou threads. (Com um alocador, você pode voltar a usar o Vec e companhia pelo crate alloc, mas muitos projetos nunca precisam disso.)
  • #![no_main] é necessário porque, em um chip, não existe sistema operacional para chamar o main. Um crate de runtime como o cortex-m-rt prepara a memória depois do reset e então pula para a sua função, que você marca com um atributo como #[cortex_m_rt::entry] ou, com Embassy, #[embassy_executor::main].
  • Você também precisa dizer o que acontece em um panic, porque não há terminal para imprimir. Crates como panic-probe ou panic-halt cuidam disso; você acrescenta um com use panic_probe as _;.

Sem Vec, como guardar uma lista? O crate heapless (em inglês) oferece Vec, String e filas com capacidade fixa, escolhida na compilação. Eles moram na pilha ou em um static, então não há heap nem surpresas de falta de memória. Um buffer cheio simplesmente devolve o seu valor:

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// Espaço para exatamente 4 leituras. A memória faz parte da variável:
// sem heap, sem alocador, e o tamanho é conhecido na compilação.
let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] {
match readings.push(value) {
Ok(()) => println!("stored {value}"),
Err(rejected) => println!("buffer full, dropped {rejected}"),
}
}
// Uma String com capacidade fixa de 16 bytes
let mut line: String<16> = String::new();
write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap();
println!("{line} (capacity {})", line.capacity());
}

O heapless também funciona em um PC, então você pode experimentar com cargo add [email protected] e cargo run (ele não está no Playground). As mensagens dizem que cada valor foi guardado (“stored”), que o buffer encheu e o último valor foi descartado, e mostram a média (avg):

stored 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=219 (capacity 16)

Os crates de Rust embarcado se empilham uns sobre os outros. Na maior parte do tempo você vai trabalhar no nível da HAL ou do framework, mas ajuda saber o que tem embaixo:

Camada O que é Exemplos
PAC (Peripheral Access Crate) Gerado a partir do arquivo de descrição dos registradores do chip. Uma função mais ou menos segura para cada registrador e campo. Bem baixo nível. rp2040-pac, stm32f4, esp32c3
HAL (Hardware Abstraction Layer, camada de abstração de hardware) Tipos amigáveis para uma família de chips: Output, Uart, I2c, timers. Construída sobre um PAC. rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32
Traits do embedded-hal Traits compartilhadas, como OutputPin, I2c, SpiDevice e DelayNs. As HALs as implementam; os drivers as usam. A versão 1.0 é estável. embedded-hal, embedded-hal-async
BSP (Board Support Package, pacote de suporte à placa) Uma HAL mais nomes para os pinos e as peças de uma placa específica (“o LED”, “o botão A”). rp-pico, microbit-v2
RTIC Um framework para apps guiados por interrupções. As tarefas ficam ligadas a interrupções e prioridades, e o compilador verifica os dados compartilhados na hora do build. rtic
Embassy Um framework async: um executor, timers, rede e USB, além das suas próprias HALs. Você escreve tarefas async fn e faz .await em eventos do hardware. embassy-executor, embassy-time, embassy-rp

A mágica está na linha do meio. Um driver de sensor de temperatura escrito com a trait I2c do embedded-hal funciona em um Pico, em um ESP32 e em um STM32 sem mudanças, porque cada HAL implementa a mesma trait. Você mesmo vai escrever um driver assim mais abaixo.

O seu PC tem um processador de desktop x86 ou Arm; o Pico tem um Cortex-M0+. Então você faz compilação cruzada: compila em uma máquina, para outra. O rustup baixa a biblioteca core já compilada para o chip:

Terminal window
rustup target add thumbv6m-none-eabi # RP2040 (Pico)
rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)
rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4

Para copiar o programa para o chip e ver os logs, você precisa do probe-rs e de um debug probe (sonda de depuração): um pequeno dispositivo USB que se conecta aos pinos de depuração do chip. As placas micro:bit e STM32 Nucleo já têm um embutido. Para um Pico, use um Raspberry Pi Debug Probe, ou um segundo Pico gravado com o firmware gratuito debugprobe. Instale o probe-rs com o script instalador (veja a página de instalação, em inglês, para o Windows) ou com o cargo:

Terminal window
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
# ou, mais devagar, a partir do código-fonte:
cargo install probe-rs-tools --locked
probe-rs list # mostra os probes que ele encontra

Ainda não tem um probe? Segure o botão BOOTSEL do Pico enquanto o conecta, e ele aparece como um pendrive. Ferramentas como picotool ou elf2uf2-rs conseguem copiar o seu programa para ele desse jeito. Mas, sem um probe, você não vê os logs do defmt.

Em um ESP32-C3 ou C6, normalmente você nem precisa de um probe: o chip tem USB embutido, e a ferramenta espflash grava o programa pelo cabo USB normal. Rode cargo install esp-generate espflash --locked, depois esp-generate, escolha o seu chip e as opções, e cargo run --release compila, grava e mostra os logs.

O “blinky” é o “Olá, mundo!” dos sistemas embarcados: acende o LED, espera, apaga, espera, para sempre. Este é o exemplo oficial de blinky do Embassy para o RP2040 (com comentários acrescentados). Ele precisa de uma placa de verdade e não roda no Playground.

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // manda os logs do defmt para o probe
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // em um panic: imprime a mensagem pelo probe e para
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// Toma posse de todos os periféricos do chip, exatamente uma vez
let p = embassy_rp::init(Default::default());
// O LED do Pico fica no pino 25. PIN_25 é movido para `led`.
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
info!("led on!");
led.set_high();
Timer::after_millis(500).await;
info!("led off!");
led.set_low();
Timer::after_millis(500).await;
}
}

Algumas coisas para reparar:

  • O main é uma async fn. Timer::after_millis(500).await não fica girando em um laço ocupado; o executor coloca o chip para dormir até o timer disparar. Com mais tarefas (criadas pelo Spawner), as outras rodam enquanto esta espera. Se async/await é novidade para você, a ideia é a mesma do Tokio em um PC, só que sem sistema operacional.
  • p.PIN_25 é movido para dentro de Output::new. Tente usar p.PIN_25 uma segunda vez e o compilador impede: é o ownership aplicado ao hardware.
  • use defmt_rtt as _; e use panic_probe as _; importam crates só pelo que eles configuram (o canal de logs e o tratador de panic), e não por algum nome.

O jeito mais rápido de ver o LED piscar é usar a própria pasta de exemplos oficial: clone o repositório do Embassy e, em examples/rp, rode cargo run --bin blinky --release. Para o seu próprio projeto, as dependências vêm do Cargo.toml desse exemplo:

[package]
name = "blinky"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-executor = { version = "0.10.0", features = ["defmt"] }
embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.0"
[profile.release]
debug = true # mantém as informações de debug para o probe-rs; elas não vão para o chip

Mais dois arquivos dizem ao cargo o que compilar e como rodar. O .cargo/config.toml define o target e faz o cargo run gravar o chip pelo probe-rs:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "debug"

Por fim, copie o memory.x (onde ficam a flash e a RAM no chip) e o build.rs (que passa os scripts do linker) da mesma pasta examples/rp. Depois conecte o seu probe e rode:

Terminal window
cargo run --release

O cargo compila para thumbv6m-none-eabi, o probe-rs grava o chip, o LED começa a piscar e as mensagens led on! / led off! (“LED aceso!” / “LED apagado!”) aparecem no seu terminal, com horário.

Um microcontrolador não tem tela, e formatar texto com core::fmt ocupa muito espaço na flash. O defmt (“deferred formatting”, formatação adiada) resolve as duas coisas. As strings de formatação ficam guardadas no seu PC, e não no chip; o chip só manda um ID minúsculo mais os valores brutos pelo probe, e o probe-rs os transforma de volta em texto legível.

use defmt::{info, warn, Format};
#[derive(Format)] // como #[derive(Debug)], mas para o defmt
pub struct Reading {
pub sensor_id: u8,
pub milli_celsius: i32,
}
pub fn report(r: &Reading) {
info!("new reading: {}", r);
if r.milli_celsius > 60_000 {
// `=u8` e `=i32` dizem ao defmt o tipo exato, o que economiza ainda mais espaço
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

Os níveis são trace!, debug!, info!, warn! e error!, iguais aos do crate log. A configuração DEFMT_LOG no .cargo/config.toml escolhe quais níveis são compilados; tudo o que fica abaixo dela custa zero bytes.

Por baixo de toda HAL, controlar o hardware significa escrever números em registradores: endereços de memória especiais em que cada bit liga ou desliga algo no chip. Em um chip típico, um registrador de saída GPIO de 32 bits tem um bit por pino. Aqui, um u32 comum faz o papel desse registrador, para você praticar os quatro movimentos que todo desenvolvedor de sistemas embarcados usa. Os textos impressos ficaram em inglês porque estão alinhados à mão com espaços (“LED on” é “LED aceso”, “toggled” é “invertido”, “pin 3 off” é “pino 3 desligado”):

const LED: u32 = 25; // o LED da placa do Pico fica no pino 25
fn main() {
// Faz de conta que este u32 é o registrador de saída GPIO do chip.
// O bit 0 controla o pino 0, o bit 1 controla o pino 1, e assim por diante.
let mut gpio_out: u32 = 0b1000; // o pino 3 já está ligado
// Ligar um bit: OR com uma máscara
gpio_out |= 1 << LED;
println!("LED on: {gpio_out:#034b}");
// Ler um bit: AND com uma máscara e depois comparar com zero
let led_is_on = (gpio_out & (1 << LED)) != 0;
let pin2_is_on = (gpio_out & (1 << 2)) != 0;
println!("LED on? {led_is_on}, pin 2 on? {pin2_is_on}");
// Inverter um bit: XOR com uma máscara
gpio_out ^= 1 << LED;
println!("toggled: {gpio_out:#034b}");
// Desligar um bit: AND com a máscara invertida
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("pin 3 off: {gpio_out:#034b}");
println!("as hex: {gpio_out:#010x}");
}

Saída:

LED on: 0b00000010000000000000000000001000
LED on? true, pin 2 on? false
toggled: 0b00000000000000000000000000001000
pin 3 off: 0b00000010000000000000000000000000
as hex: 0x02000000
▶ Experimente você mesmo

Em Rust, ! aplicado a um inteiro inverte todos os bits (o C usa ~ para isso). {:#034b} imprime o valor em binário, com o prefixo 0b e preenchido até 32 dígitos, para você ver o bit 25 aparecer e sumir. Veja Operadores em Rust para a lista completa de operadores bit a bit.

Muitos registradores juntam vários campos em um só número: alguns bits para uma configuração de velocidade, um bit para “ativado”. Para mudar um campo, você faz um read-modify-write (ler, modificar, escrever): limpa os bits do campo e depois faz OR com o novo valor, sem tocar em nenhum outro bit:

// Um registrador de controle de UART inventado:
// bit 0 = ativar
// bits 4..=6 = configuração de velocidade (um número de 3 bits, de 0 a 7)
const ENABLE: u32 = 1 << 0;
const SPEED_SHIFT: u32 = 4;
const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 {
(reg & SPEED_MASK) >> SPEED_SHIFT
}
fn write_speed(reg: u32, speed: u32) -> u32 {
// Limpa o campo antigo e depois coloca o novo valor.
// Todos os outros bits ficam exatamente como estavam.
(reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)
}
fn main() {
let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT);
println!("start: {ctrl:#010b}, speed = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5);
println!("speed 5: {ctrl:#010b}, speed = {}", read_speed(ctrl));
// 9 não cabe em 3 bits. A máscara corta o valor em vez de
// deixá-lo vazar para os bits vizinhos.
ctrl = write_speed(ctrl, 9);
println!("speed 9?: {ctrl:#010b}, speed = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0;
println!("still enabled: {still_enabled}");
}

Saída:

start: 0b00100001, speed = 2
speed 5: 0b01010001, speed = 5
speed 9?: 0b00010001, speed = 1
still enabled: true
▶ Experimente você mesmo

Repare que o 9 virou 1 em silêncio. É exatamente o tipo de bug de que os PACs e as HALs protegem você: eles dão a cada campo um método com nome próprio (muitas vezes com valores nomeados também), então você raramente escreve máscaras à mão. No hardware de verdade, o registrador é um endereço de memória fixo, escrito com core::ptr::write_volatile dentro de código unsafe; o PAC faz essa parte por você.

O melhor hábito embarcado que você pode aprender: escreva a sua lógica usando traits, e não um chip específico. Assim o mesmo código roda na placa com a HAL de verdade e no seu PC com peças falsas (“mocks”) em um teste normal. Aqui, StatusLed só precisa de “algo que eu consiga ligar e desligar” e de “algo que consiga esperar”:

use std::cell::RefCell;
use std::rc::Rc;
// As duas habilidades de que um driver de pisca-pisca precisa. Elas imitam as
// traits OutputPin e DelayNs do embedded-hal 1.0, simplificadas para rodar no Playground.
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// O driver não sabe nada sobre nenhum chip. Ele só usa as traits.
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// Pisca `times` vezes: aceso por `on_ms`, depois apagado por `off_ms`.
fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) {
for _ in 0..times {
self.pin.set_high();
self.delay.delay_ms(on_ms);
self.pin.set_low();
self.delay.delay_ms(off_ms);
}
}
}
// --- Peças falsas para testar no PC ---
// As duas escrevem no mesmo log compartilhado, então dá para ver a ordem.
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("liga".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("desliga".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("espera {ms}"));
}
}
fn main() {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let pin = MockPin { log: Rc::clone(&log) };
let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay };
led.blink(2, 100, 400);
let events = log.borrow();
println!("{}", events.join(", "));
assert_eq!(events.len(), 8);
assert_eq!(events[0], "liga");
assert_eq!(events[1], "espera 100");
println!("todas as verificações passaram");
}

Saída:

liga, espera 100, desliga, espera 400, liga, espera 100, desliga, espera 400
todas as verificações passaram
▶ Experimente você mesmo

Os dois mocks compartilham um único log por meio de Rc<RefCell<...>> (veja Ponteiros inteligentes), então você consegue verificar a ordem exata dos eventos. Em um projeto de verdade, as linhas com assert_eq! vão para uma função #[test] e rodam com cargo test no seu PC, sem precisar de placa (veja Testes em Rust e Testes avançados).

Na placa, você escreveria o mesmo driver com as traits de verdade do embedded-hal 1.0. As únicas mudanças: os métodos do pino devolvem um Result (um pino em um chip expansor de E/S pode falhar), então você repassa os erros com ?, e o código não precisa da std em nada:

#![no_std]
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> {
pub fn new(pin: P, delay: D) -> Self {
StatusLed { pin, delay }
}
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> {
for _ in 0..times {
self.pin.set_high()?;
self.delay.delay_ms(on_ms);
self.pin.set_low()?;
self.delay.delay_ms(off_ms);
}
Ok(())
}
}

O Output do Embassy implementa OutputPin, e embassy_time::Delay implementa DelayNs, então StatusLed::new(led, embassy_time::Delay) funciona no Pico. Para testes no PC com essas traits de verdade, o crate embedded-hal-mock (em inglês) oferece pinos e barramentos I2C e SPI falsos já prontos.

Muitos chips pequenos, como o Cortex-M0+ do RP2040, não têm unidade de ponto flutuante. Floats continuam funcionando, mas em software: mais lentos, e deixam o seu programa maior. Por isso, o código embarcado costuma usar números de ponto fixo: inteiros em uma unidade menor. Em vez de 25.25 graus, você guarda 25250 milésimos de grau.

Um sensor de temperatura do tipo TMP102 manda dois bytes. Os 12 bits de cima são um número com sinal, em passos de 1/16 °C:

// Os 12 bits de cima são a temperatura em passos de 1/16 °C (0,0625 °C),
// como um número com sinal (complemento de dois).
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 mantém o sinal. `>> 4` em um número com sinal também mantém.
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62,5 m°C, então multiplica por 625 e divide por 10.
raw as i32 * 625 / 10
}
// Imprime 25250 como "25.250" sem nunca encostar em um float.
fn format_milli(m: i32) -> String {
let sign = if m < 0 { "-" } else { "" };
let m = m.abs();
format!("{sign}{}.{:03}", m / 1000, m % 1000)
}
fn main() {
let samples = [
[0x19, 0x40], // uma sala quentinha
[0x00, 0x10], // o menor passo acima de zero
[0xFF, 0x80], // um pouco abaixo de zero
[0xE7, 0x00], // um freezer
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

Saída:

[19, 40] -> 25250 m°C = 25.250 °C
[00, 10] -> 62 m°C = 0.062 °C
[FF, 80] -> -500 m°C = -0.500 °C
[E7, 00] -> -25000 m°C = -25.000 °C
▶ Experimente você mesmo

Três detalhes fazem isto funcionar. i16::from_be_bytes lê os dois bytes como um único número com sinal, com o byte mais significativo primeiro (“big-endian”), que é como a maioria dos sensores manda os dados. >> 4 em um inteiro com sinal mantém o sinal, então as temperaturas negativas saem certas. E format_milli trata o sinal separadamente: -500 / 1000 dá 0 na matemática de inteiros, então, sem esse passo, -0.5 °C seria impresso como 0.500. (O programa usa ponto como separador decimal, como é o padrão em código.)

A divisão de inteiros também trunca: 62,5 m°C virou 62. Meio milésimo de grau não faz diferença para um sensor de ambiente, mas sempre se pergunte quanta precisão uma unidade oferece. E fique de olho na faixa de valores: multiplicar uma leitura grande por um fator grande pode estourar um i32 (veja Overflow de inteiros). No PC, String funciona bem; no chip, você imprimiria com o defmt ou escreveria em uma heapless::String.

Usar coisas da std em um programa no_std. println!, Vec e String vêm da std, então simplesmente não estão lá:

error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("hello");
| ^^^^^^^

O compilador diz que não encontrou a macro println neste escopo. Use defmt::info! para os logs e heapless (ou o crate alloc com um alocador) para as coleções.

Esquecer o tratador de panic. Um binário no_std precisa dizer o que um panic faz. Sem um crate como o panic-probe, você recebe:

error: `#[panic_handler]` function required, but not found

Ou seja, falta uma função #[panic_handler]. Acrescente panic-probe (ou panic-halt) ao Cargo.toml e use panic_probe as _; ao main.rs.

Sobrescrever um registrador inteiro. reg = 1 << 3 liga o pino 3 e desliga todos os outros. Para mudar um bit só, use reg |= 1 << 3. Para campos, faça o read-modify-write mostrado acima.

Seguir um tutorial antigo. Os crates embarcados mudam rápido, e posts de blog de alguns anos atrás muitas vezes usam o embedded-hal 0.2 ou APIs antigas do Embassy. Comece pela pasta de exemplos da versão do crate de que você realmente depende.

Escreva toggle(reg: u32, pin: u32) -> u32, que inverte um bit, e pins_on(reg: u32) -> Vec<u32>, que lista todos os pinos cujo bit é 1. Comece com os pinos 0, 5 e 25 ligados, imprima a lista e quantos pinos estão ligados, depois inverta os pinos 5 e 7 e imprima de novo. Dica: teste cada pino de 0 a 31 com uma máscara; o u32 tem um método count_ones().

Ver solução
fn toggle(reg: u32, pin: u32) -> u32 {
reg ^ (1 << pin)
}
fn pins_on(reg: u32) -> Vec<u32> {
(0..32).filter(|pin| reg & (1 << pin) != 0).collect()
}
fn main() {
let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25);
println!("ligados: {:?} ({} pinos)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("ligados: {:?} ({} pinos)", pins_on(reg), reg.count_ones());
}

Saída:

ligados: [0, 5, 25] (3 pinos)
ligados: [0, 7, 25] (3 pinos)
▶ Experimente você mesmo

O XOR inverte um bit, seja qual for o valor antigo, então inverter o pino 5 o desligou e inverter o pino 7 o ligou. count_ones() conta os bits 1 para você, então não é preciso um laço para essa parte.

Um ADC de 12 bits dá leituras de 0 a 4095 para 0 a 3300 mV. A bateria está ligada por um divisor de tensão que corta a tensão pela metade, então a tensão real é o dobro da medida. Escreva battery_mv(raw: u16) -> i32 e battery_percent(mv: i32) -> i32, em que 3000 mV é 0% e 4200 mV é 100%, nunca abaixo de 0 nem acima de 100. Experimente as leituras brutas 2606, 2482, 2234 e 1861. Dica: multiplique antes de dividir, para não perder precisão, e dê uma olhada no clamp.

Ver solução
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// ADC de 12 bits (0..=4095) medindo 0..=3300 mV, atrás de um divisor que corta a tensão pela metade
raw as i32 * 3300 * 2 / 4095
}
fn battery_percent(mv: i32) -> i32 {
let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV);
percent.clamp(0, 100)
}
fn main() {
for raw in [2606, 2482, 2234, 1861] {
let mv = battery_mv(raw);
println!("leitura {raw} -> {mv} mV -> {}%", battery_percent(mv));
}
}

Saída:

leitura 2606 -> 4200 mV -> 100%
leitura 2482 -> 4000 mV -> 83%
leitura 2234 -> 3600 mV -> 50%
leitura 1861 -> 2999 mV -> 0%
▶ Experimente você mesmo

Dividir por 4095 por último mantém a precisão: raw / 4095 * 6600 daria 0 para toda leitura abaixo de 4095. O maior produto, 4095 * 6600, fica em torno de 27 milhões, o que cabe tranquilamente em um i32.

  • O Rust embarcado roda em microcontroladores sem sistema operacional. Sem garbage collector, com segurança verificada na compilação e abstrações de custo zero, o Rust combina muito bem com isso.
  • #![no_std] deixa você só com a core; #![no_main] permite que um crate de runtime chame a sua função de entrada; você precisa escolher um tratador de panic. O heapless oferece coleções de capacidade fixa.
  • As camadas: PAC (registradores), HAL (uma família de chips), traits do embedded-hal (compartilhadas), BSP (uma placa) e frameworks como RTIC e Embassy por cima.
  • Adicione o target do chip com rustup target add, depois grave o programa e veja os logs com o probe-rs e um debug probe (ou com o espflash em um ESP32). O defmt deixa os logs minúsculos.
  • Pratique no seu PC: máscaras de bits para registradores, drivers escritos com traits e peças falsas, e inteiros de ponto fixo em vez de floats.

Teste o que aprendeu

1. O que #![no_std] faz?

2. Qual camada permite que um único driver de sensor funcione em um Pico, em um ESP32 e em um STM32 sem mudanças?

3. Como ligar o bit 3 de reg sem mudar nenhum outro bit?

4. Por que o código embarcado muitas vezes guarda 25,25 °C como 25250 milésimos de grau?

Pontuação: 0 / 4

  • The Embedded Rust Book (em inglês): a introdução oficial a no_std, periféricos, registradores e ao ecossistema embarcado.
  • Discovery (micro:bit v2) (em inglês): um curso gratuito para iniciantes que leva você de um LED piscando até sensores, passo a passo, em um BBC micro:bit.
  • The Embassy Book (em inglês): como começar com o Embassy, como funcionam o executor e as HALs dele e como rodar os exemplos oficiais.
  • The Rust on ESP Book (em inglês): o guia da própria Espressif sobre esp-hal, esp-generate, espflash e as toolchains do ESP32.
  • Instalação do probe-rs (em inglês): como instalar o probe-rs em todos os sistemas operacionais e configurar o seu debug probe.