Sistemas embarcados são um dos domínios onde a zig lang mais brilha. A linguagem Zig foi projetada desde o início para ser uma alternativa moderna ao C, e em nenhum lugar isso é mais evidente do que na programação de microcontroladores. Sem dependência de standard library, sem alocações ocultas, com cross-compilation embutida e controle total sobre o hardware, Zig é uma escolha natural para embedded development.
Por Que Zig para Embedded
Programadores de sistemas embarcados tradicionalmente usam C (e às vezes C++) como linguagem principal. Zig oferece vantagens significativas sobre ambas:
- Sem dependência de standard library: Zig pode compilar para alvos
freestanding, sem nenhuma dependência de sistema operacional ou biblioteca padrão. - Sem alocações ocultas: Diferente de C++, Zig não faz alocações de memória implícitas. Toda alocação é explícita e controlada pelo programador, como detalhado no tutorial de gerenciamento de memória em Zig.
- Cross-compilation trivial: Compile para ARM, RISC-V, AVR e outros alvos com uma simples flag de compilação.
- Segurança sem custo: Verificações de bounds checking, integer overflow e alinhamento de memória podem ser ativadas em debug e removidas em release.
- Interoperabilidade C perfeita: Use headers C de fabricantes (HAL, CMSIS) diretamente via
@cImport. - Comptime: Gere registradores, tabelas de vetores de interrupção e configurações de periféricos em tempo de compilação.
Alvos Embarcados Suportados
Zig, através do backend LLVM, suporta uma ampla gama de arquiteturas embarcadas:
| Arquitetura | Exemplos de Hardware | Alvo Zig |
|---|---|---|
| ARM Cortex-M0/M0+ | STM32F0, nRF51, RP2040 | thumb-freestanding |
| ARM Cortex-M3 | STM32F1, LPC1768 | thumb-freestanding |
| ARM Cortex-M4/M4F | STM32F4, nRF52, SAM4 | thumb-freestanding |
| ARM Cortex-M7 | STM32H7, STM32F7 | thumb-freestanding |
| ARM Cortex-A (Linux) | Raspberry Pi, BeagleBone | aarch64-linux |
| RISC-V | ESP32-C3, SiFive, GD32V | riscv32-freestanding |
| AVR | Arduino, ATmega328P | avr-freestanding |
Configurando para Bare-Metal
Para programação bare-metal (sem sistema operacional), precisamos usar o target freestanding e configurar o linker script corretamente.
build.zig para ARM Cortex-M4
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .thumb,
.os_tag = .freestanding,
.abi = .eabi,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 },
});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "firmware",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Linker script específico do hardware
exe.setLinkerScript(b.path("stm32f407.ld"));
// Desabilitar stack protector (não disponível em bare-metal)
exe.root_module.stack_protector = false;
b.installArtifact(exe);
// Step para gerar binário .bin para flash
const bin = b.addObjCopy(exe.getEmittedBin(), .{
.format = .bin,
});
const install_bin = b.addInstallBinFile(bin.getOutput(), "firmware.bin");
const flash_step = b.step("bin", "Gerar binário para flash");
flash_step.dependOn(&install_bin.step);
}
Linker Script
O linker script define o layout da memória do microcontrolador:
/* stm32f407.ld */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
KEEP(*(.isr_vector))
*(.text*)
*(.rodata*)
} > FLASH
.data :
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT > FLASH
.bss :
{
_sbss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > RAM
_stack_top = ORIGIN(RAM) + LENGTH(RAM);
}
Bare-Metal Hello World: Piscando um LED
O “Hello World” dos sistemas embarcados é piscar um LED. Vamos implementar isso para um STM32F407 (ARM Cortex-M4).
// src/main.zig
// Registradores do STM32F407 (Memory-Mapped I/O)
const RCC_BASE = 0x40023800;
const GPIOD_BASE = 0x40020C00;
// Registrador de habilitação do clock para GPIO
const RCC_AHB1ENR: *volatile u32 = @ptrFromInt(RCC_BASE + 0x30);
// Registradores do GPIOD
const GPIOD_MODER: *volatile u32 = @ptrFromInt(GPIOD_BASE + 0x00);
const GPIOD_ODR: *volatile u32 = @ptrFromInt(GPIOD_BASE + 0x14);
// Função de delay simples (busy wait)
fn delay(count: u32) void {
var i: u32 = 0;
while (i < count) : (i += 1) {
asm volatile ("nop");
}
}
// Vetor de reset (entry point)
export fn _reset() callconv(.c) noreturn {
// 1. Habilitar clock do GPIOD
RCC_AHB1ENR.* |= (1 << 3); // Bit 3 = GPIODEN
// 2. Configurar PD12 como saída (LED verde na placa Discovery)
// MODER12[1:0] = 01 (General purpose output)
GPIOD_MODER.* &= ~(@as(u32, 0x3) << 24); // Limpar bits
GPIOD_MODER.* |= (@as(u32, 0x1) << 24); // Setar como saída
// 3. Loop infinito piscando o LED
while (true) {
// Ligar LED (setar bit 12)
GPIOD_ODR.* |= (1 << 12);
delay(1_000_000);
// Desligar LED (limpar bit 12)
GPIOD_ODR.* &= ~(@as(u32, 1) << 12);
delay(1_000_000);
}
}
// Tabela de vetores de interrupção
export const vector_table linksection(".isr_vector") = [_]?*const fn () callconv(.c) void{
@ptrFromInt(@as(u32, 0x20020000)), // Stack pointer inicial
@ptrCast(&_reset), // Reset handler
};
Para compilar e gravar no microcontrolador:
# Compilar
zig build -Doptimize=ReleaseSmall
# Gerar binário
zig build bin
# Gravar via OpenOCD
openocd -f board/stm32f4discovery.cfg \
-c "program zig-out/bin/firmware.bin verify reset exit 0x08000000"
# Ou via st-flash
st-flash write zig-out/bin/firmware.bin 0x08000000
Memory-Mapped I/O em Zig
Em sistemas embarcados, periféricos são acessados através de endereços de memória. Zig oferece ferramentas poderosas para isso.
Abstração Tipada de Registradores
fn Register(comptime addr: u32) type {
return struct {
const ptr: *volatile u32 = @ptrFromInt(addr);
pub inline fn read() u32 {
return ptr.*;
}
pub inline fn write(val: u32) void {
ptr.* = val;
}
pub inline fn setBit(comptime bit: u5) void {
ptr.* |= (@as(u32, 1) << bit);
}
pub inline fn clearBit(comptime bit: u5) void {
ptr.* &= ~(@as(u32, 1) << bit);
}
pub inline fn toggleBit(comptime bit: u5) void {
ptr.* ^= (@as(u32, 1) << bit);
}
pub inline fn readBit(comptime bit: u5) bool {
return (ptr.* & (@as(u32, 1) << bit)) != 0;
}
pub inline fn modifyBits(comptime mask: u32, val: u32) void {
ptr.* = (ptr.* & ~mask) | (val & mask);
}
};
}
// Uso: muito mais legível e seguro que manipulação direta
const RCC_AHB1ENR = Register(0x40023830);
const GPIOD_MODER = Register(0x40020C00);
const GPIOD_ODR = Register(0x40020C14);
fn configurarLed() void {
RCC_AHB1ENR.setBit(3); // Habilitar clock GPIOD
GPIOD_MODER.modifyBits(0x03000000, 0x01000000); // PD12 como saída
}
fn ligarLed() void {
GPIOD_ODR.setBit(12);
}
fn desligarLed() void {
GPIOD_ODR.clearBit(12);
}
Packed Structs para Registradores
Zig suporta packed structs que mapeiam exatamente para layouts de bits em registradores:
const GpioModer = packed struct(u32) {
moder0: u2 = 0,
moder1: u2 = 0,
moder2: u2 = 0,
moder3: u2 = 0,
moder4: u2 = 0,
moder5: u2 = 0,
moder6: u2 = 0,
moder7: u2 = 0,
moder8: u2 = 0,
moder9: u2 = 0,
moder10: u2 = 0,
moder11: u2 = 0,
moder12: u2 = 0,
moder13: u2 = 0,
moder14: u2 = 0,
moder15: u2 = 0,
};
const gpiod_moder: *volatile GpioModer = @ptrFromInt(0x40020C00);
fn configurarPinos() void {
var moder = gpiod_moder.*;
moder.moder12 = 0b01; // Saída
moder.moder13 = 0b01; // Saída
moder.moder14 = 0b01; // Saída
moder.moder15 = 0b01; // Saída
gpiod_moder.* = moder;
}
Essa abordagem é muito mais segura e legível do que manipulação manual de bits, e o compilador gera código idêntico ao C equivalente.
Tratamento de Interrupções
Interrupções são fundamentais em sistemas embarcados. Veja como configurar interrupções em Zig:
const std = @import("std");
// Handler do SysTick Timer
var tick_counter: u32 = 0;
export fn SysTick_Handler() callconv(.c) void {
tick_counter +%= 1;
}
// Registradores do SysTick
const SYST_CSR: *volatile u32 = @ptrFromInt(0xE000E010);
const SYST_RVR: *volatile u32 = @ptrFromInt(0xE000E014);
fn configurarSysTick(ticks: u24) void {
SYST_RVR.* = @as(u32, ticks) - 1;
SYST_CSR.* = 0x07; // Enable, interrupt, use processor clock
}
fn obterTicks() u32 {
return @atomicLoad(u32, &tick_counter, .monotonic);
}
fn delayMs(ms: u32) void {
const inicio = obterTicks();
while (obterTicks() - inicio < ms) {
asm volatile ("wfi"); // Wait For Interrupt (economia de energia)
}
}
// Tabela de vetores expandida
export const vector_table linksection(".isr_vector") = blk: {
var table = [_]?*const fn () callconv(.c) void{null} ** 256;
table[0] = @ptrFromInt(@as(u32, 0x20020000)); // Stack pointer
table[1] = @ptrCast(&_reset); // Reset
table[15] = @ptrCast(&SysTick_Handler); // SysTick
break :blk table;
};
Note o uso de @atomicLoad para ler a variável compartilhada entre a interrupção e o código principal, e wfi (Wait For Interrupt) para economizar energia enquanto espera.
UART: Comunicação Serial
A comunicação serial é essencial para depuração em sistemas embarcados:
// Registradores USART2 do STM32F407
const USART2_BASE = 0x40004400;
const USART2_SR: *volatile u32 = @ptrFromInt(USART2_BASE + 0x00);
const USART2_DR: *volatile u32 = @ptrFromInt(USART2_BASE + 0x04);
const USART2_BRR: *volatile u32 = @ptrFromInt(USART2_BASE + 0x08);
const USART2_CR1: *volatile u32 = @ptrFromInt(USART2_BASE + 0x0C);
fn uartInit(baud_rate: u32) void {
// Assumindo clock APB1 de 42 MHz
const clock = 42_000_000;
USART2_BRR.* = clock / baud_rate;
// Habilitar USART: UE | TE | RE
USART2_CR1.* = (1 << 13) | (1 << 3) | (1 << 2);
}
fn uartEnviarByte(byte: u8) void {
// Esperar até TXE (transmit data register empty)
while ((USART2_SR.* & (1 << 7)) == 0) {}
USART2_DR.* = @as(u32, byte);
}
fn uartEnviarString(str: []const u8) void {
for (str) |byte| {
uartEnviarByte(byte);
}
}
fn uartReceberByte() u8 {
// Esperar até RXNE (read data register not empty)
while ((USART2_SR.* & (1 << 5)) == 0) {}
return @truncate(USART2_DR.*);
}
// Implementar Writer para integrar com std.fmt
const UartWriter = struct {
pub fn write(_: @This(), bytes: []const u8) error{}!usize {
for (bytes) |b| {
uartEnviarByte(b);
}
return bytes.len;
}
};
fn uartPrint(comptime fmt: []const u8, args: anytype) void {
const writer = UartWriter{};
std.fmt.format(writer, fmt, args) catch {};
}
Comparação: Zig vs C vs Rust para Embedded
| Aspecto | C | Rust | Zig |
|---|---|---|---|
| Curva de aprendizado | Baixa | Alta (borrow checker) | Média |
| Segurança de memória | Nenhuma | Garantida (compile-time) | Verificações opcionais |
| Alocações ocultas | Possíveis (malloc) | Possíveis (Box, Vec) | Nenhuma |
| Ecossistema embedded | Imenso | Crescendo (Embassy) | Crescendo (MicroZig) |
| Interop com HAL C | Nativa | Via FFI (bindgen) | Via @cImport (direto) |
| Compilação cruzada | Complexa | Melhor que C | Trivial |
| Metaprogramação | Macros do preprocessador | Macros procedurais | Comptime |
| Tamanho do binário | Muito pequeno | Pequeno (com esforço) | Muito pequeno |
| Freestanding | Nativo | Requer no_std | Nativo |
Vantagens do Zig sobre C
- Verificações de overflow, bounds checking e null em debug.
- Sem comportamento indefinido acidental.
- Comptime substitui macros de preprocessador de forma segura.
- Build system integrado, sem necessidade de Make/CMake.
Vantagens do Zig sobre Rust
- Interoperabilidade C sem overhead (sem bindgen, sem unsafe blocks para FFI).
- Modelo mental mais simples (sem borrow checker, lifetimes ou traits).
- Compilação mais rápida.
- Mais fácil para quem vem de C.
MicroZig: O Framework Embedded para Zig
MicroZig é o principal framework para desenvolvimento embarcado em Zig. Ele fornece:
- Abstrações de hardware: Drivers para GPIO, UART, SPI, I2C, timers e mais.
- Suporte a múltiplos chips: STM32, nRF, RP2040, ESP32, GD32, AVR.
- Build system integrado: Configuração automática de linker scripts e targets.
- Modelo de camadas: HAL (Hardware Abstraction Layer), board support packages e drivers de periféricos.
Exemplo com MicroZig para RP2040 (Raspberry Pi Pico)
const microzig = @import("microzig");
const rp2040 = microzig.hal;
const led_pin = rp2040.gpio.num(25); // LED onboard do Pi Pico
pub fn main() !void {
led_pin.set_function(.sio);
led_pin.set_direction(.out);
while (true) {
led_pin.toggle();
rp2040.time.sleep_ms(500);
}
}
Configuração do build.zig com MicroZig
const std = @import("std");
const microzig = @import("microzig");
pub fn build(b: *std.Build) void {
const firmware = microzig.addFirmware(b, .{
.name = "meu-firmware",
.target = .{ .preferred = .rp2040 },
.root_source_file = b.path("src/main.zig"),
.optimize = .ReleaseSmall,
});
microzig.installFirmware(b, firmware);
}
GPIO Avançado: Leitura de Sensores
Exemplo de leitura de um sensor digital (botão) e controle de LED:
// Abstrações usando comptime para configuração de pinos
fn GpioPin(comptime port_base: u32, comptime pin: u5) type {
const moder: *volatile u32 = @ptrFromInt(port_base + 0x00);
const idr: *volatile u32 = @ptrFromInt(port_base + 0x10);
const odr: *volatile u32 = @ptrFromInt(port_base + 0x14);
const bit_mask: u32 = @as(u32, 1) << pin;
return struct {
pub fn configSaida() void {
var val = moder.*;
val &= ~(@as(u32, 0x3) << (pin * 2));
val |= (@as(u32, 0x1) << (pin * 2));
moder.* = val;
}
pub fn configEntrada() void {
var val = moder.*;
val &= ~(@as(u32, 0x3) << (pin * 2));
moder.* = val;
}
pub fn ligar() void {
odr.* |= bit_mask;
}
pub fn desligar() void {
odr.* &= ~bit_mask;
}
pub fn toggle() void {
odr.* ^= bit_mask;
}
pub fn ler() bool {
return (idr.* & bit_mask) != 0;
}
};
}
// Definição dos pinos (STM32F407 Discovery)
const LED_VERDE = GpioPin(0x40020C00, 12); // PD12
const LED_LARANJA = GpioPin(0x40020C00, 13); // PD13
const LED_VERMELHO = GpioPin(0x40020C00, 14); // PD14
const LED_AZUL = GpioPin(0x40020C00, 15); // PD15
const BOTAO = GpioPin(0x40020000, 0); // PA0
export fn _reset() callconv(.c) noreturn {
// Habilitar clocks (GPIOA e GPIOD)
const RCC_AHB1ENR: *volatile u32 = @ptrFromInt(0x40023830);
RCC_AHB1ENR.* |= (1 << 0) | (1 << 3);
// Configurar pinos
LED_VERDE.configSaida();
LED_LARANJA.configSaida();
LED_VERMELHO.configSaida();
LED_AZUL.configSaida();
BOTAO.configEntrada();
while (true) {
if (BOTAO.ler()) {
LED_VERDE.ligar();
LED_LARANJA.ligar();
LED_VERMELHO.ligar();
LED_AZUL.ligar();
} else {
LED_VERDE.desligar();
LED_LARANJA.desligar();
LED_VERMELHO.desligar();
LED_AZUL.desligar();
}
}
}
O uso de comptime aqui gera código zero-overhead: todas as abstrações são resolvidas em tempo de compilação, resultando em código de máquina idêntico à manipulação direta de registradores em C.
Dicas para Desenvolvimento Embedded com Zig
- Comece com o Raspberry Pi Pico: O RP2040 tem excelente suporte no MicroZig e é barato.
- Use
ReleaseSmall: Para firmware, tamanho do binário é mais importante que velocidade. - Aproveite
@cImport: Use HAL e CMSIS headers do fabricante diretamente. - Debugging com GDB: Zig gera informações de debug compatíveis com GDB. Use
arm-none-eabi-gdbcom OpenOCD. - Evite std em freestanding: A maioria da standard library não está disponível em freestanding. Use
std.memestd.fmtque funcionam sem allocator. - Teste no desktop primeiro: Escreva lógica de negócio em módulos testáveis no desktop e depois integre com o hardware.
# Debug via GDB + OpenOCD
openocd -f board/stm32f4discovery.cfg &
arm-none-eabi-gdb zig-out/bin/firmware
(gdb) target remote :3333
(gdb) monitor reset halt
(gdb) load
(gdb) break main
(gdb) continue
Conclusão
Zig está emergindo como uma alternativa real ao C para sistemas embarcados. Com segurança superior, cross-compilation trivial, metaprogramação via comptime e o ecossistema crescente do MicroZig, Zig oferece um caminho moderno para programação de microcontroladores sem sacrificar o controle de baixo nível que embedded exige. Se você trabalha com IoT, firmware ou qualquer tipo de sistema embarcado, Zig merece sua atenção.