Zig para Sistemas Embarcados e IoT: Guia Prático

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

AspectoCRustZig
Cross-compilationToolchain separadarustup target addNativo, zero setup
MetaprogramaçãoMacros de preprocessadorproc macrosComptime
Segurança de memóriaManualBorrow checkerComptime + runtime checks
Tamanho do binárioMínimoModeradoMínimo
Ecossistema embeddedVasto (décadas)Crescendo (embedded-hal)MicroZig (ativo)
Interop com CNativoVia FFINativo 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:

  1. Instalar Zig e testar com o cheatsheet de comandos CLI
  2. Explorar os projetos de exemplo para referência
  3. 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.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.