Zig para Sistemas Embarcados e IoT: Guia Prático
Zig nasceu para programação de sistemas, e poucos domínios exigem tanto controle quanto sistemas embarcados. Microcontroladores têm kilobytes de RAM, clock limitado e nenhum sistema operacional para te salvar. É exatamente nesse cenário que Zig se destaca — oferecendo segurança de memória em comptime, cross-compilation trivial e zero overhead de runtime.
Neste guia, vamos explorar como usar Zig para desenvolvimento embarcado e IoT: desde a configuração do ambiente até drivers bare metal, passando por HAL, comunicação e deploy em microcontroladores reais.
Novo em Zig? Comece por O que é Zig e o guia de instalação.
Por Que Zig para Embarcados?
A escolha tradicional para embarcados é C, com alguns projetos usando C++ ou Rust. Zig traz vantagens únicas:
1. Cross-Compilation Nativa
Com Zig, compilar para ARM Cortex-M, RISC-V ou qualquer target é um único comando — sem instalar toolchains separadas:
# Compilar para ARM Cortex-M4 (STM32F4)
zig build -Dtarget=thumb-freestanding-eabi -Dcpu=cortex_m4
# Compilar para RISC-V 32-bit
zig build -Dtarget=riscv32-freestanding-none -Dcpu=generic_rv32
Compare com C, onde você precisa instalar arm-none-eabi-gcc, configurar o linker script manualmente e lutar com o CMake. O artigo sobre cross-compilation detalha todas as opções.
2. Comptime para Configuração de Hardware
O comptime do Zig é perfeito para embarcados. Configuração de periféricos, cálculo de registradores e geração de tabelas — tudo resolvido em tempo de compilação, sem custo em runtime:
const config = comptime blk: {
const clock_hz: u32 = 72_000_000; // 72 MHz
const baud_rate: u32 = 115_200;
const divisor = clock_hz / (16 * baud_rate);
break :blk .{
.brr = divisor,
.over8 = false,
};
};
fn initUart(regs: *volatile UartRegs) void {
regs.brr = config.brr; // Valor calculado em comptime
regs.cr1 = .{ .ue = true, .te = true, .re = true };
}
Zero custo em runtime. O compilador resolve tudo e gera apenas as instruções de escrita nos registradores.
3. Zero Runtime e Freestanding
Zig pode rodar em modo freestanding — sem libc, sem allocator de sistema, sem nada:
// build.zig — target freestanding
const target = b.resolveTargetQuery(.{
.cpu_arch = .thumb,
.os_tag = .freestanding,
.abi = .eabi,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 },
});
const exe = b.addExecutable(.{
.name = "firmware",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = .ReleaseSmall, // Otimizar para tamanho
});
// Desabilitar stack protector para bare metal
exe.root_module.stack_protector = false;
O binário resultante contém apenas o seu código — nenhuma dependência de SO. Para entender como o build system funciona em detalhe, veja nosso guia dedicado.
Configurando o Ambiente
Estrutura do Projeto
Um projeto embarcado em Zig segue uma estrutura clara:
firmware-projeto/
├── build.zig
├── build.zig.zon
├── src/
│ ├── main.zig # Entry point
│ ├── hal/
│ │ ├── gpio.zig # Abstração GPIO
│ │ ├── uart.zig # Abstração UART
│ │ ├── spi.zig # Abstração SPI
│ │ └── timer.zig # Abstração Timer
│ ├── drivers/
│ │ ├── led.zig # Driver de LED
│ │ └── sensor.zig # Driver de sensor
│ └── app/
│ └── logic.zig # Lógica da aplicação
├── linker/
│ └── stm32f401.ld # Linker script
└── openocd.cfg # Configuração do debugger
Linker Script
Para bare metal, você precisa de um linker script que descreve o mapa de memória:
// build.zig — configurar linker script customizado
exe.setLinkerScript(b.path("linker/stm32f401.ld"));
GPIO: Controlando Pinos
Vamos começar pelo exemplo mais básico em embarcados — piscar um LED:
const std = @import("std");
// Registradores mapeados em memória para STM32
const RCC_BASE: usize = 0x4002_3800;
const GPIOA_BASE: usize = 0x4002_0000;
const GpioRegs = packed struct {
moder: u32, // Mode register
otyper: u32, // Output type
ospeedr: u32, // Output speed
pupdr: u32, // Pull-up/pull-down
idr: u32, // Input data
odr: u32, // Output data
bsrr: u32, // Bit set/reset
lckr: u32, // Lock
afrl: u32, // Alternate function low
afrh: u32, // Alternate function high
};
fn getGpioA() *volatile GpioRegs {
return @ptrFromInt(GPIOA_BASE);
}
fn habilitarClockGpioA() void {
const rcc_ahb1enr: *volatile u32 = @ptrFromInt(RCC_BASE + 0x30);
rcc_ahb1enr.* |= (1 << 0); // Bit 0 = GPIOAEN
}
fn configurarPinoSaida(gpio: *volatile GpioRegs, pino: u5) void {
// Limpar bits do modo (2 bits por pino)
gpio.moder &= ~(@as(u32, 0b11) << (@as(u5, pino) * 2));
// Setar como saída (01)
gpio.moder |= (@as(u32, 0b01) << (@as(u5, pino) * 2));
}
fn togglePino(gpio: *volatile GpioRegs, pino: u5) void {
gpio.odr ^= (@as(u32, 1) << pino);
}
// Delay simples por loop
fn delay(count: u32) void {
var i: u32 = 0;
while (i < count) : (i += 1) {
asm volatile ("nop");
}
}
export fn _start() noreturn {
habilitarClockGpioA();
const gpio = getGpioA();
configurarPinoSaida(gpio, 5); // LED no pino PA5
while (true) {
togglePino(gpio, 5);
delay(1_000_000);
}
}
Repare no uso de packed struct para mapear registradores diretamente na memória — cada campo corresponde a um registrador real do hardware. Veja mais sobre tipos de ponteiros em Zig.
HAL: Hardware Abstraction Layer
Para projetos maiores, abstração é essencial. Vamos criar uma HAL genérica:
// src/hal/gpio.zig
pub fn Gpio(comptime config: GpioConfig) type {
return struct {
const Self = @This();
const regs: *volatile GpioRegs = @ptrFromInt(config.base_addr);
pub fn init() void {
// Habilitar clock via RCC
config.enableClock();
// Configurar modo
setMode(config.pin, config.mode);
}
pub fn write(valor: bool) void {
if (valor) {
regs.bsrr = @as(u32, 1) << config.pin;
} else {
regs.bsrr = @as(u32, 1) << (config.pin + 16);
}
}
pub fn read() bool {
return (regs.idr & (@as(u32, 1) << config.pin)) != 0;
}
pub fn toggle() void {
regs.odr ^= (@as(u32, 1) << config.pin);
}
fn setMode(pino: u5, modo: Mode) void {
const shift = @as(u5, pino) * 2;
regs.moder &= ~(@as(u32, 0b11) << shift);
regs.moder |= @intFromEnum(modo) << shift;
}
};
}
pub const GpioConfig = struct {
base_addr: usize,
pin: u5,
mode: Mode,
enableClock: *const fn () void,
};
pub const Mode = enum(u2) {
input = 0b00,
output = 0b01,
alternate = 0b10,
analog = 0b11,
};
O comptime gera um tipo especializado para cada combinação de porta/pino. Sem vtables, sem indireção, sem overhead — o compilador inline tudo. Veja como isso se compara à abordagem de C++ templates no artigo Zig vs C++.
Usando a HAL
const gpio = @import("hal/gpio.zig");
// LED no PA5 — tudo resolvido em comptime
const Led = gpio.Gpio(.{
.base_addr = 0x4002_0000,
.pin = 5,
.mode = .output,
.enableClock = habilitarClockGpioA,
});
// Botão no PC13
const Botao = gpio.Gpio(.{
.base_addr = 0x4002_0800,
.pin = 13,
.mode = .input,
.enableClock = habilitarClockGpioC,
});
export fn _start() noreturn {
Led.init();
Botao.init();
while (true) {
if (Botao.read()) {
Led.toggle();
}
delay(100_000);
}
}
UART: Comunicação Serial
Comunicação serial é fundamental em IoT. Veja como implementar UART:
// src/hal/uart.zig
pub fn Uart(comptime config: UartConfig) type {
return struct {
const regs: *volatile UartRegs = @ptrFromInt(config.base_addr);
pub fn init() void {
// Calcular baud rate divisor em comptime
const divisor = comptime config.clock_hz / (16 * config.baud_rate);
regs.brr = divisor;
regs.cr1 = .{
.ue = true, // UART enable
.te = true, // Transmitter enable
.re = true, // Receiver enable
};
}
pub fn write(byte: u8) void {
// Esperar transmit buffer vazio
while (regs.sr.txe == false) {}
regs.dr = byte;
}
pub fn read() u8 {
// Esperar dado recebido
while (regs.sr.rxne == false) {}
return @truncate(regs.dr);
}
pub fn print(msg: []const u8) void {
for (msg) |byte| {
write(byte);
}
}
};
}
pub const UartConfig = struct {
base_addr: usize,
clock_hz: u32,
baud_rate: u32,
};
Uso prático com sensor de temperatura:
const Serial = Uart(.{
.base_addr = 0x4001_1000, // USART1
.clock_hz = 72_000_000,
.baud_rate = 115_200,
});
fn enviarTemperatura(temp: i16) void {
var buf: [32]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Temp: {}°C\r\n", .{temp}) catch return;
Serial.print(msg);
}
MicroZig: O Ecossistema Embarcado
O projeto MicroZig é o framework oficial da comunidade para desenvolvimento embarcado. Ele fornece:
- HAL pronta para dezenas de microcontroladores (STM32, RP2040, ESP32, nRF)
- Drivers para periféricos comuns (I2C, SPI, ADC, PWM)
- Board support packages para placas populares
// Exemplo com MicroZig para Raspberry Pi Pico (RP2040)
const microzig = @import("microzig");
const rp2040 = microzig.hal;
pub fn main() !void {
// LED onboard do Pico está no GPIO 25
const led = rp2040.gpio.num(25);
led.setFunction(.sio);
led.setDirection(.out);
while (true) {
led.toggle();
rp2040.time.sleepMs(500);
}
}
Compare a simplicidade com o código C equivalente usando o SDK do Pico — são dezenas de linhas a menos. Veja mais sobre o ecossistema em Embedded HAL do Zig.
IoT: Conectando Dispositivos
Para aplicações IoT, o dispositivo precisa se comunicar. Aqui está um padrão para enviar dados via MQTT sobre Wi-Fi:
const Sensor = struct {
temperatura: f32,
umidade: f32,
timestamp: u64,
pub fn toJson(self: Sensor, buf: []u8) ![]const u8 {
return std.fmt.bufPrint(buf,
\\{{"temperatura":{d:.1},"umidade":{d:.1},"ts":{d}}}
, .{ self.temperatura, self.umidade, self.timestamp });
}
};
fn coletarEEnviar(uart: anytype) !void {
const leitura = Sensor{
.temperatura = lerSensorTemp(),
.umidade = lerSensorUmidade(),
.timestamp = getTimestamp(),
};
var buf: [256]u8 = undefined;
const payload = try leitura.toJson(&buf);
// Enviar via MQTT (usando módulo Wi-Fi ESP-AT)
try uart.print("AT+MQTTPUB=\"sensor/dados\",\"");
try uart.print(payload);
try uart.print("\"\r\n");
}
Esse padrão de serialização em buffer fixo é idiomatic em Zig — sem alocação dinâmica, perfeito para dispositivos com memória limitada. Veja o guia de gerenciamento de memória e as estratégias de alocação.
Otimização de Tamanho
Em embarcados, cada byte conta. Zig oferece controle fino:
// build.zig — otimizar para tamanho mínimo
exe.root_module.optimize = .ReleaseSmall;
exe.root_module.strip = true; // Remover símbolos de debug
exe.link_gc_sections = true; // Garbage collect seções não usadas
exe.root_module.single_threaded = true; // Desabilitar threading
// Resultado típico para blink:
// Debug: ~48 KB
// ReleaseSmall: ~800 bytes (!)
// ReleaseFast: ~2 KB
De 48 KB para 800 bytes — uma redução de 98%. Compare com projetos C equivalentes usando arm-none-eabi-gcc -Os, que tipicamente geram 1-2 KB para o mesmo firmware. Veja mais sobre release modes.
Debugging
Para debug em hardware real, Zig funciona com as ferramentas padrão:
# Iniciar OpenOCD
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
# Em outro terminal, iniciar GDB
arm-none-eabi-gdb -ex "target remote :3333" zig-out/bin/firmware
Como Zig gera DWARF debug info padrão, todas as ferramentas de debug existentes funcionam. Veja mais sobre ferramentas de debug do Zig.
Zig vs C vs Rust para Embarcados
| Aspecto | C | Rust | Zig |
|---|---|---|---|
| Cross-compilation | Toolchain separada | rustup target add | Nativo, zero setup |
| Metaprogramação | Macros de preprocessador | proc macros | Comptime |
| Segurança de memória | Manual | Borrow checker | Comptime + runtime checks |
| Tamanho do binário | Mínimo | Moderado | Mínimo |
| Ecossistema embedded | Vasto (décadas) | Crescendo (embedded-hal) | MicroZig (ativo) |
| Interop com C | Nativo | Via FFI | Nativo com @cImport |
Para quem vem de Rust, o ecossistema embedded-hal de Rust é mais maduro, mas o Zig oferece compilação mais rápida e binários menores. Veja o guia de Rust em português para comparar. Se quer explorar o mercado de trabalho para embarcados, confira Zig para carreira em embedded.
Conclusão
Zig é uma escolha excelente para sistemas embarcados e IoT. A combinação de cross-compilation trivial, comptime para configuração de hardware, zero runtime e binários mínimos faz do Zig uma alternativa real a C no mundo bare metal.
O ecossistema MicroZig está em crescimento acelerado, com suporte para as plataformas mais populares. E com o novo sistema async/await via std.Io — especialmente o modo stackless — Zig também brilha em aplicações IoT que precisam de concorrência com memória limitada.
Para começar, recomendamos:
- Instalar Zig e testar com o cheatsheet de comandos CLI
- Explorar os projetos de exemplo para referência
- Seguir o roadmap de desenvolvedor Zig para embarcados
Se o seu background é Go ou Python, saiba que a transição para Zig em embarcados é mais suave do que parece — comece pelo tutorial introdutório.