Zig para Sistemas Embarcados: IoT e Microcontroladores

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:

ArquiteturaExemplos de HardwareAlvo Zig
ARM Cortex-M0/M0+STM32F0, nRF51, RP2040thumb-freestanding
ARM Cortex-M3STM32F1, LPC1768thumb-freestanding
ARM Cortex-M4/M4FSTM32F4, nRF52, SAM4thumb-freestanding
ARM Cortex-M7STM32H7, STM32F7thumb-freestanding
ARM Cortex-A (Linux)Raspberry Pi, BeagleBoneaarch64-linux
RISC-VESP32-C3, SiFive, GD32Vriscv32-freestanding
AVRArduino, ATmega328Pavr-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

AspectoCRustZig
Curva de aprendizadoBaixaAlta (borrow checker)Média
Segurança de memóriaNenhumaGarantida (compile-time)Verificações opcionais
Alocações ocultasPossíveis (malloc)Possíveis (Box, Vec)Nenhuma
Ecossistema embeddedImensoCrescendo (Embassy)Crescendo (MicroZig)
Interop com HAL CNativaVia FFI (bindgen)Via @cImport (direto)
Compilação cruzadaComplexaMelhor que CTrivial
MetaprogramaçãoMacros do preprocessadorMacros proceduraisComptime
Tamanho do binárioMuito pequenoPequeno (com esforço)Muito pequeno
FreestandingNativoRequer no_stdNativo

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

  1. Comece com o Raspberry Pi Pico: O RP2040 tem excelente suporte no MicroZig e é barato.
  2. Use ReleaseSmall: Para firmware, tamanho do binário é mais importante que velocidade.
  3. Aproveite @cImport: Use HAL e CMSIS headers do fabricante diretamente.
  4. Debugging com GDB: Zig gera informações de debug compatíveis com GDB. Use arm-none-eabi-gdb com OpenOCD.
  5. Evite std em freestanding: A maioria da standard library não está disponível em freestanding. Use std.mem e std.fmt que funcionam sem allocator.
  6. 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.

Leia Também

Continue aprendendo Zig

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