Rust em Ambientes Embarcados e no_std
Rust embarcado é Rust regular rodando em um microcontrolador sem um sistema operacional por baixo.
Esse único fato remodela quase tudo sobre como um programa é escrito, porque a biblioteca padrão
foi projetada assumindo que um SO está lá para pegar o programa quando ele cai. Esta página constrói
o modelo mental em que o restante da seção embedded-rust se baseia: o que no_std realmente remove,
por que embedded-hal existe como um contrato em vez de uma biblioteca, e como as duas ideias se
encaixam para fazer um crate de driver rodar sem modificações em uma dúzia de chips diferentes.
Resumo
no_stdremove as partes dependentes do sistema operacional da biblioteca padrão do Rust, enquantoembedded-halfornece um conjunto compartilhado de traits para que o código do driver possa ser escrito uma vez e reutilizado em diferentes chips.- Por que Importa: Microcontroladores não têm um SO para fornecer threads, alocação de heap ou descritores de arquivo, então o código que assume essas coisas simplesmente falha ao linkar, não apenas falha em tempo de execução.
- Conceitos Chave: core, alloc, std, panic handler, peripheral access crate (PAC), hardware abstraction layer (HAL), embedded-hal traits.
- Quando Usar: Microcontroladores bare-metal (Cortex-M, MCUs RISC-V), kernels customizados, bootloaders e qualquer alvo onde
stdnão pode linkar porque não há um SO por baixo dele. - Limitações / Trade-offs: Você perde
std::collections::HashMap,std::thread, acesso ao sistema de arquivos e o mecanismo padrão de pânico-unwind, e assume responsabilidade manual pelo layout de memória e comportamento de pânico. - Tópicos Relacionados: programação com restrição de memória, compilação cruzada, camadas de abstração de hardware, sistemas de tempo real.
Fundamentos
A biblioteca padrão do Rust é na verdade três camadas empilhadas umas sobre as outras, e no_std
remove apenas a de cima. A camada inferior é core, um subconjunto da biblioteca padrão que não
tem dependências de sistema operacional. Ela fornece tipos primitivos, Option e Result,
iteradores, slices e os traits core::fmt por trás de write!. core está sempre disponível,
mesmo no menor microcontrolador, porque nenhum de seus componentes precisa de um alocador, threads
ou um sistema de arquivos.
A camada intermediária é alloc, que adiciona tipos baseados em heap como Vec e String.
alloc não precisa de um SO completo, mas precisa que alguém forneça um alocador global: um
pedaço de código que sabe como extrair memória utilizável da RAM. Em um desktop, std fornece
esse alocador automaticamente chamando o SO. Em um microcontrolador, nada faz isso por você, então
alloc permanece indisponível até que você conecte um crate como linked_list_allocator ou
embedded-alloc e o registre com #[global_allocator].
A camada superior é o próprio std, que adiciona tudo que assume a presença de um sistema
operacional: threads, sockets TCP, o sistema de arquivos, variáveis de ambiente e um manipulador
de pânico padrão que desempilha a pilha e imprime no stderr. Escrever #![no_std] na raiz do crate
diz ao compilador "não linke a camada std", o que é uma declaração sobre qual biblioteca será
linkada, não uma declaração sobre quais recursos da linguagem estão disponíveis. Propriedade,
empréstimo, traits, genéricos, closures, async/await e macros funcionam exatamente da mesma forma
em código no_std; o que muda é o que você pode chamar, não o que você pode escrever.
Uma analogia útil: std é um apartamento totalmente mobiliado (cama, cozinha, água corrente
fornecidos), enquanto core é um cômodo vazio com paredes de carga. no_std não tira sua
capacidade de construir móveis, apenas significa que ninguém os entregou para você. Tudo que você
precisa, você traz explicitamente, e tudo que você traz é visível em seu Cargo.toml e em suas
importações, em vez de estar escondido atrás de um padrão.
#![no_std]
#![no_main]
use panic_halt as _;
#[no_mangle]
pub extern "C" fn main() -> ! {
// Não há SO para retornar controle, então main nunca retorna.
loop {}
}Esse trecho é o menor programa no_std possível. #![no_main] é tão importante quanto
#![no_std] aqui: programas Rust normais assumem que o SO invoca fn main() e lida com o valor
de retorno, mas um microcontrolador inicializa diretamente em seu código a partir de um vetor de
reset, então o ponto de entrada do runtime (geralmente fornecido pelo crate cortex-m-rt) é
diferente de um programa hospedado.
Mecânicas e Interações
Uma vez que std está fora de questão, o compilador precisa de duas coisas de você que normalmente
obteria de graça: um manipulador de pânico e, na maioria dos alvos reais, um layout de memória. Um
manipulador de pânico é uma função marcada com #[panic_handler] que o compilador chama sempre que
ocorre um pânico, já que não há maquinaria de desempilhamento do SO ou processo para sair. Crates como
panic-halt (fica em loop infinito), panic-reset (reinicia o chip) e panic-probe (envia um
relatório por um probe de depuração, depois para) empacotam escolhas comuns para que você raramente
escreva um do zero, mas o ponto é que o comportamento de pânico agora é uma dependência explícita do
crate em vez de um detalhe implícito do runtime.
O layout de memória é a outra peça. Em um desktop, o loader do SO decide onde o código e os dados
vivem na memória virtual. Um microcontrolador não tem loader, então um script de linker (convencionalmente
memory.x) tem que declarar exatamente onde o flash começa, qual seu tamanho, onde a RAM começa e
qual seu tamanho. O crate cortex-m-rt lê este script para posicionar a tabela de vetores, a seção
.text e a pilha corretamente. Se você errar os tamanhos, obterá um erro de link no caso amigável,
ou corrupção silenciosa de memória no caso hostil, porque nada em tempo de execução está verificando
se a pegada do seu programa realmente cabe no chip.
É aqui que o embedded-hal entra, e vale a pena ser preciso sobre que tipo de coisa ele é.
embedded-hal não é um runtime, não é um driver e não é um crate que você pode "executar". É um
conjunto de traits: OutputPin, InputPin, I2c, SpiDevice, DelayNs, e alguns outros, cada
um descrevendo uma capacidade que uma peça de hardware pode ter sem dizer nada sobre qual chip a
fornece. Um crate HAL específico de chip, como stm32f4xx-hal ou embassy-nrf, implementa esses
traits para seus próprios tipos de periféricos concretos. Um crate de driver, digamos para um sensor
LSM6DS, então escreve sua lógica contra os limites de trait (SPI: SpiDevice) em vez de contra
um chip concreto, para que o mesmo código de driver compile sem modificações, quer ele acabe
conectado a um STM32, um nRF52 ou um RP2040.
use embedded_hal::digital::OutputPin;
// Genérico sobre o tipo de pino GPIO de QUALQUER chip que implementa OutputPin.
pub fn blink<P: OutputPin>(led: &mut P, times: u32) -> Result<(), P::Error> {
for _ in 0..times {
led.set_high()?; // compila para o que quer que o set_high do HAL concreto faça
led.set_low()?;
}
Ok(())
}A natureza livre de trait-object e monomorfizada dessa função genérica é importante especificamente
para alvos embarcados. Como blink é genérico em vez de receber dyn OutputPin, o compilador gera
uma cópia especializada da função para cada tipo de pino concreto com o qual ela é chamada, sem custo
de despacho em tempo de execução. O despacho dinâmico através de uma vtable é tecnicamente legal em
no_std, mas o código embarcado o evita por convenção porque chamadas indiretas tornam o tempo
menos previsível e adicionam uma indireção de ponteiro em chips onde cada ciclo a 48-168MHz é visível
no orçamento de tempo do datasheet.
Dois crates valem a distinção aqui. O PAC (peripheral access crate) é gerado automaticamente a partir
do arquivo de descrição de registradores do fornecedor do chip e expõe leituras e escritas de registradores
brutos com quase nenhuma rede de segurança além de offsets de bits corretos. O crate HAL envolve o PAC
em APIs seguras e ergonômicas, impõe invariantes como "você só pode ter a posse de um periférico uma vez",
e implementa os traits embedded-hal por cima. embedded-hal fica um nível acima de ambos: ele nunca
toca nos registradores diretamente, apenas define a forma que os wrappers seguros de um HAL devem ter.
Considerações Avançadas e Aplicações
A maior complicação prática neste ecossistema tem sido a transição de embedded-hal 0.2 para 1.0.
Os traits 0.2 usavam assinaturas não bloqueantes baseadas no crate nb que retornavam um erro
"would block" (bloquearia) que o chamador tinha que tentar novamente; 1.0 mudou para traits
simplesmente bloqueantes com um crate separado embedded-hal-async para variantes assíncronas
usadas por executores como Embassy. Como o objetivo do embedded-hal é que muitos crates
independentes dependam dele, uma divisão de versão significa que alguns crates de driver e alguns
HALs de chip podem ainda estar na versão 0.2 enquanto outros migraram para 1.0, e misturá-los em um
projeto pode exigir um adaptador shim. Este é um custo de coordenação do modelo de portabilidade baseado
em traits, não uma falha exclusiva do Rust: qualquer ecossistema de interface compartilhada paga um
imposto de migração quando a própria interface precisa mudar.
| Abordagem | Força | Fraqueza | Melhor Encaixe |
|---|---|---|---|
Apenas core, sem alocador | Memória totalmente determinística, sem risco de fragmentação | Sem Vec/String, deve dimensionar tudo estaticamente | Loops de controle de tempo real difíceis, firmware de segurança crítica |
core + alloc com alocador global | Tipos de coleção familiares, prototipagem mais rápida | A alocação pode falhar ou fragmentar sem um OOM killer para fallback | Firmware com uso intensivo de dados e RAM generosa (pilhas BLE, parsers de protocolo) |
| Chamadas diretas de HAL específico de chip | Mais simples para uma única placa fixa | O código do driver não é portátil para outro chip | Produtos únicos que nunca terão como alvo um segundo chip |
Código genérico sobre traits embedded-hal | O crate do driver compila contra qualquer HAL compatível | Parâmetros de tipo genéricos extras e limites de trait para raciocinar | Crates de driver de sensor/atuador reutilizáveis publicados para a comunidade |
A segurança de memória em no_std merece uma ressalva honesta: o verificador de propriedade e empréstimo
do Rust ainda se aplicam, e eles ainda previnem corridas de dados e uso após liberação em código seguro.
O que eles não podem fazer é prevenir o mau uso de hardware, como escrever em um registrador que tem
efeitos colaterais físicos (iniciar uma transferência DMA, desabilitar um clock que o depurador precisa)
mesmo que a escrita em si seja segura em termos de memória do ponto de vista do compilador. Essa lacuna é
exatamente por que os PACs marcam a maioria dos acessos a registradores como seguros, mas os crates HAL
adicionam invariantes de nível de tipo extras, como o padrão singleton Peripherals::take(), por cima
para empurrar mais dessa correção em nível de hardware de volta para o sistema de tipos.
O outro lugar onde no_std mostra sua ausência em forma de SO é a concorrência. Sem um escalonador de SO,
não há std::thread::spawn e nenhum mutex em nível de SO, então o estado compartilhado entre um manipulador
de interrupção e o loop principal precisa ser protegido com critical-section (desabilitando brevemente
as interrupções) ou primitivas lock-free, e firmware assíncrono precisa de um executor embarcado como
Embassy em vez de Tokio. Isso não é uma limitação da linguagem; é a consequência direta de não haver um
escalonador por baixo do programa para coordenar o acesso concorrente para você.
Equívocos Comuns
- "
no_stdsignifica que estou escrevendo uma versão reduzida ou restrita do Rust" - a linguagem é idêntica; apenas a biblioteca linkada muda, então genéricos, traits, closures e até async/await funcionam exatamente como no Rust hospedado. - "
embedded-halé um HAL, comostm32f4xx-hal" - é a direção oposta:embedded-haldefine os traits, e crates específicos de chip comostm32f4xx-halsão os HALs que os implementam. - "Adicionar
allocme devolve tudo questdtinha" -allocapenas adiciona coleções baseadas em heap; você ainda não obtém threads, sockets ou um sistema de arquivos, porque eles precisam de um SO, não apenas de um heap. - "Um pânico em embarcado se comporta como um pânico em desktop" - não há desempilhamento e impressão padrão; você escolhe o comportamento de pânico explicitamente escolhendo um crate
#[panic_handler], e a escolha padrão é muitas vezes ficar em loop infinito ou reiniciar o chip. - "Código genérico é mais lento por causa da abstração" - traits de
embedded-halsão quase sempre usados como genéricos estáticos, então o compilador os monomorfiza e inlines, deixando nenhum custo de tempo de execução em comparação com chamar o HAL concreto diretamente. - "Você precisa de
unsafeem todo lugar na programação Rust embarcada" - a maioria dos acessos a registradores é envolvida de forma segura pelas camadas PAC e HAL;unsafeé tipicamente confinado a um pequeno número de locais bem compreendidos, como inicialização de alocador ou configuração de vetor de interrupção bruto.
FAQs
Qual é a diferença real entre core, alloc e std?
core: primitivas independentes de SO, sempre disponíveis (tipos, traits, iteradores,core::fmt)alloc: coleções baseadas em heap (Vec,String,Box), precisa de um alocador global registradostd: tudo dependente de SO (threads, sistema de arquivos, sockets, desempilhamento de pânico padrão)
O `#![no_std]` muda quais recursos da linguagem posso usar?
Não. Traits, genéricos, closures, async/await e macros são recursos da linguagem, não recursos da biblioteca, então todos funcionam da mesma forma. O que desaparecem são tipos e funções específicos que vivem no crate std e assumem um SO por baixo deles.
Por que não posso usar std::collections::HashMap em um microcontrolador?
HashMap vive em std, não em core ou alloc, em parte porque seu hasher padrão usa aleatoriedade fornecida pelo SO para resistência a DoS. Em no_std você usa alloc::collections::BTreeMap ou um mapa de capacidade fixa de heapless em vez disso.
Como um programa sequer começa sem um SO para chamar main()?
Um vetor de reset na memória do microcontrolador aponta para uma rotina de inicialização, tipicamente fornecida pelo crate cortex-m-rt, que configura o ponteiro de pilha e o estado inicial da memória e então chama seu #[entry] ou extern "C" fn main. #![no_main] diz ao rustc para não gerar o ponto de entrada normal hospedado, já que essa rotina de inicialização o substitui.
O que realmente acontece quando um programa no_std entra em pânico?
O compilador chama qualquer função que você marcou com #[panic_handler]. Não há padrão, então você deve fornecer um, comumente dependendo de panic-halt (loop infinito), panic-reset (reiniciar) ou panic-probe (relatar via probe de depuração, depois parar).
Qual é a diferença entre um crate PAC e um crate HAL?
Um peripheral access crate (PAC) é gerado a partir das descrições de registradores do fornecedor do chip e expõe acesso bruto a registradores. Um crate HAL envolve o PAC em APIs mais seguras e de nível superior e implementa os traits embedded-hal por cima, então a maioria do código de aplicação e driver deve ter como alvo o HAL, não o PAC diretamente.
Quando devo evitar genéricos embedded-hal e apenas chamar o HAL do chip diretamente?
Quando você está construindo firmware para exatamente uma placa que nunca terá como alvo um segundo chip, os parâmetros de tipo genéricos extras e limites de trait adicionam complexidade sem um benefício de portabilidade. Use a genericidade embedded-hal especificamente quando estiver escrevendo um crate de driver destinado a ser reutilizado em vários chips.
Por que existe tanto embedded-hal quanto embedded-hal-async?
embedded-hal 1.0 define métodos de trait bloqueantes. embedded-hal-async define equivalentes async para uso com executores cooperativos como Embassy, já que não há um escalonador de thread do SO para bloquear em um alvo bare-metal.
O que é a migração de 0.2 para 1.0 na verdade?
embedded-hal 0.2 usava assinaturas de trait não bloqueantes baseadas em nb (retry). 1.0 mudou para assinaturas de método puramente bloqueantes e dividiu o suporte assíncrono em seu próprio crate. Alguns crates de driver e HALs de chip mais antigos não migraram completamente, o que pode exigir shims de compatibilidade ao misturar dependências.
O código no_std pode usar threads?
Não no sentido de std::thread, já que não há escalonador de SO. A concorrência em bare metal vem de manipuladores de interrupção mais estado compartilhado protegido por critical-section, ou de um executor assíncrono cooperativo como Embassy rodando múltiplas tarefas em uma única thread de hardware.
O código unsafe é inevitável na programação no_std?
Algum unsafe é tipicamente necessário (inicialização de alocador, vetores de interrupção brutos, às vezes padrões singleton de periféricos), mas a lógica de aplicação e driver escrita contra APIs HAL e embedded-hal seguras permanece quase inteiramente em Rust seguro.
Relacionados
- Fundamentos Embarcados - configuração prática da toolchain: alvos,
memory.x, e um primeiro crateno_std - Programação no_std - receitas de trabalho para
core,alloc, manipuladores de pânico e alocadores globais - embedded-hal - tabelas de traits concretas e padrões de driver construídos sobre os conceitos aqui
- Memória em Embarcados - alocação estática e padrões de coleção
heapless
Versões da Stack: Esta página é conceitual e não está vinculada a uma versão específica da stack, embora os exemplos assumam Rust 1.97.0 (edição 2024) e assinaturas de trait
embedded-hal1.x.