Zig e MQTT: Cliente IoT e Pub/Sub para Sistemas Embarcados

Se você já publicou um tópico MQTT a partir de um microcontrolador, provavelmente usou uma biblioteca em C herdada, um wrapper de vendor ou um stub gerado para o seu RTOS. O que poucos percebem é que o Zig é uma das linguagens mais confortáveis para falar MQTT hoje: o protocolo é binário, compacto e previsível, e o Zig foi desenhado justamente para ler e montar bytes assim, sem alocação escondida e sem runtime. O resultado é um cliente MQTT que cabe em poucos quilobytes, compila para o seu alvo embarcado e você entende por inteiro.

Este guia mostra como usar MQTT com Zig de forma prática: entender o modelo pub/sub, escolher entre usar uma biblioteca C (como a libmosquitto) e escrever um cliente nativo em Zig, montar os pacotes CONNECT, PUBLISH e SUBSCRIBE na mão, garantir QoS, keepalive e last will, fechar a conexão com TLS e operar em produção. Ele complementa o material sobre IoT e embarcados em Zig, o firmware de ESP32 com FreeRTOS, os fundamentos de sockets TCP/UDP e a interoperabilidade com C. Para a parte de carreira, vale ligar com o guia de carreira em embedded com Zig e a página de vagas de Zig no Brasil.

Por que MQTT combina com Zig

O MQTT é um protocolo de mensageria leve sobre TCP criado para redes com banda limitada e dispositivos restritos. Em vez de o cliente falar diretamente com outro cliente, todo mundo publica e assina em um broker central usando tópicos hierárquicos — por exemplo fabrica/linha1/sensor/temperatura. Esse modelo pub/sub desacopla produtores e consumidores e lida bem com devices que desconectam o tempo todo.

As características que tornam o MQTT bom para embarcados são exatamente as que casam com a filosofia do Zig:

  • binário e compacto: o cabeçalho fixo tem 2 bytes; cada mensagem carrega pouco overhead, o que importa quando você roda em um enlace de rádio de baixa taxa;
  • sem mágica de runtime: não há coletor de lixo nem máquina virtual entre o seu código e o socket;
  • controle fino de memória: você aloca buffers do tamanho exato para os pacotes, sem suprir as dependências de uma biblioteca genérica;
  • compila para qualquer alvo: o mesmo cliente MQTT roda no seu notebook, em um Raspberry Pi e, com os cuidados de cross-compilação, direto no firmware.

Em suma, o MQTT resolve o problema de “como vários dispositivos conversam de forma resiliente”, e o Zig resolve o problema de “como fazer isso sem desperdiçar RAM e sem perder previsibilidade”.

Como o MQTT funciona por baixo dos panos

Antes de escrever código, vale fixar os conceitos que aparecem em qualquer cliente:

  • Broker: o servidor que recebe PUBLISH e replica para quem tem SUBSCRIBE casando com o tópico (Mosquitto, EMQX e HiveMQ são exemplos comuns).
  • Tópicos e wildcards: tópicos são separados por /. O + casa um nível (sensor/+/temp) e o # casa vários níveis a partir dali (fabrica/#).
  • QoS (Quality of Service): 0 é fire-and-forget (no máximo uma vez), 1 exige PUBACK (pelo menos uma vez, pode duplicar) e 2 faz um handshake de quatro passos (exatamente uma vez, mais caro).
  • Mensagem retida: o broker guarda a última mensagem publicada com a flag retain e a entrega para novos assinantes daquele tópico.
  • Last Will and Testament (LWT): uma mensagem que o broker publica em seu nome se a conexão cair sem um DISCONNECT limpo — útil para sinalizar que um device ficou offline.
  • Keepalive: a cada N segundos sem tráfego, cliente e broker trocam PINGREQ/PINGRESP; se um lado parar de responder, a conexão é considerada morta.

As portas padrão são 1883 (TCP puro) e 8883 (MQTT sobre TLS). Brokers também costumam expor WebSocket em 8083/8443 para aplicações web.

Duas abordagens: biblioteca C ou cliente nativo

Você tem dois caminhos, e a escolha depende do seu projeto:

  1. Usar a libmosquitto (ou Paho MQTT C) via FFI — é a opção mais segura para produção. A libmosquitto é madura, trata reconexão, QoS 2 e TLS. Em Zig você a importa com @cImport e linka no build.zig, como mostrado no guia de interoperabilidade com C. O custo é a dependência externa e o acoplamento à API em C.
  2. Escrever um cliente MQTT nativo em Zig — ideal para embarcados restritos, para quem quer eliminar dependências e para entender o protocolo. Funciona perfeitamente para QoS 0 e 1 (a esmagadora maioria dos casos de telemetria). É o que vamos detalhar a seguir.

A regra prática: use a libmosquitto quando precisar de QoS 2, sessões persistentes complexas ou TLS com mTLS corporativo; escreva o cliente em Zig quando quiser um binário mínimo, controle total e um footprint previsível em flash.

O detalhe que define o protocolo: o remaining length

Todo pacote MQTT começa com um byte de tipo/flags seguido de um campo chamado Remaining Length, que diz quantos bytes vêm depois. Esse campo é um inteiro de comprimento variável (1 a 4 bytes, 7 bits úteis por byte, com o bit mais alto indicando continuação). É a parte mais fácil de errar — e onde o Zig brilha:

// Codifica o "remaining length" do MQTT (1 a 4 bytes).
fn encodeRemainingLength(out: *[4]u8, len: u32) usize {
    var written: usize = 0;
    var x: u32 = len;
    while (true) {
        var encoded_byte: u8 = @intCast(x % 128);
        x = x / 128;
        if (x > 0) encoded_byte |= 0x80; // continuation bit
        out[written] = encoded_byte;
        written += 1;
        if (x == 0) break;
    }
    return written;
}

Sabendo disso, o resto é só montar o payload na ordem certa. Essa função, sozinha, já elimina o atrito de 90% dos tutoriais de MQTT em outras linguagens.

Conectando a um broker com um cliente nativo

Abaixo está o esqueleto de um cliente MQTT 3.1.1 em Zig que conecta a um broker local (por exemplo, um mosquitto rodando em 127.0.0.1:1883), autentica, assina um tópico e fica lendo mensagens publicadas pelo broker. As APIs de std.net mudam entre versões do Zig; trate o trecho como a estrutura canônica e ajuste os nomes da sua versão (0.14+ / 0.15+).

const std = @import("std");
const net = std.net;

// Monta um pacote CONNECT do MQTT 3.1.1 (clean session, keepalive 60s).
fn buildConnect(buf: []u8, client_id: []const u8) []u8 {
    // Variable header: protocol name "MQTT", level 4, flags, keepalive.
    var vh: [10]u8 = .{
        0x00, 0x04, 'M', 'Q', 'T', 'T', // protocol name
        0x04,                            // protocol level (3.1.1)
        0x02,                            // clean session flag
        0x00, 60,                        // keepalive = 60s (big endian)
    };
    var pos: usize = 0;
    var rl: [4]u8 = undefined;
    const rl_len = encodeRemainingLength(&rl, vh.len + 2 + client_id.len);
    buf[pos] = 0x10; // CONNECT
    pos += 1;
    @memcpy(buf[pos .. pos + rl_len], &rl);
    pos += rl_len;
    @memcpy(buf[pos .. pos + vh.len], &vh);
    pos += vh.len;
    // Payload: client ID com prefixo de comprimento (u16 big endian).
    buf[pos] = 0x00;
    buf[pos + 1] = @intCast(client_id.len);
    pos += 2;
    @memcpy(buf[pos .. pos + client_id.len], client_id);
    pos += client_id.len;
    return buf[0..pos];
}

pub fn main() !void {
    const addr = try net.Address.parseIp("127.0.0.1", 1883);
    var stream = try net.tcpConnectToAddress(addr);
    defer stream.close();

    var buf: [256]u8 = undefined;
    const connect = buildConnect(&buf, "zig-client-01");
    try stream.writeAll(connect);

    // Lê o CONNACK (4 bytes: 0x20 0x02 0x00 <return code>).
    var connack: [4]u8 = undefined;
    _ = try stream.read(&connack);
    std.debug.assert(connack[3] == 0x00); // 0x00 = aceito

    // SUBSCRIBE no tópico "sensor/#" com QoS 0.
    const subscribe = [_]u8{
        0x82, 0x08,                  // SUBSCRIBE, remaining length 8
        0x00, 0x01,                  // packet identifier = 1
        0x00, 0x07, 's', 'e', 'n', 's', 'o', 'r', '/', '#',
        0x00, // requested QoS 0
    };
    try stream.writeAll(&subscribe);

    // Loop de leitura: imprime payloads de PUBLISH recebidos.
    var inbox: [512]u8 = undefined;
    while (true) {
        const n = try stream.read(&inbox);
        if (n == 0) break;
        // PUBLISH: byte 0 = 0x30 (QoS0). Ignora cabeçalho e tópico aqui.
        if (n > 0 and (inbox[0] & 0xF0) == 0x30) {
            const topic_len: usize =
                (@as(usize, inbox[2]) << 8) | inbox[3];
            const payload = inbox[4 + topic_len .. n];
            std.debug.print("recebido: {s}\n", .{payload});
        }
    }
}

Esse exemplo é deliberadamente minimal: ele não decodifica o SUBACK, não implementa reconexão nem keepalive. Mas ele é o esqueleto real — a partir dele, cada peça de robustez é um acréscimo local e testável, exatamente como nos padrões de concorrência e rede em Zig.

Publicando telemetria

Para publicar, monte um PUBLISH. O byte de tipo carrega flags: bit 3 é DUP, bits 1–2 são o QoS e o bit 0 é retain. Para QoS 0 sem retain, o byte é 0x30:

fn buildPublish(buf: []u8, topic: []const u8, payload: []const u8) []u8 {
    var pos: usize = 0;
    var rl: [4]u8 = undefined;
    const remaining = 2 + topic.len + payload.len;
    const rl_len = encodeRemainingLength(&rl, @intCast(remaining));
    buf[pos] = 0x30; // PUBLISH, QoS0, no retain
    pos += 1;
    @memcpy(buf[pos .. pos + rl_len], &rl);
    pos += rl_len;
    buf[pos] = @intCast((topic.len >> 8) & 0xFF);
    buf[pos + 1] = @intCast(topic.len & 0xFF);
    pos += 2;
    @memcpy(buf[pos .. pos + topic.len], topic);
    pos += topic.len;
    @memcpy(buf[pos .. pos + payload.len], payload);
    pos += payload.len;
    return buf[0..pos];
}

O padrão que emerge é sempre o mesmo: calcular o remaining length, copiar o cabeçalho, copiar os comprimentos em big endian, copiar os dados. Depois do terceiro pacote, você escreve MQTT sem consultar a especificação.

QoS, keepalive e reconexão

Para telemetria, QoS 0 basta: perde-se uma leitura ocasional e tudo bem. Para comandos (abrir uma válvula, acionar um atuador), suba para QoS 1, que exige um PUBACK do broker e reenvio em caso de timeout. Em Zig, o reenvio é trivial: mantenha o pacote em um buffer, dispare um timer com std.time e reenvie se o PUBACK não vier — o padrão de timeout e retry é o mesmo usado no guia de circuit breaker, timeout e retry.

O keepalive evita que firewalls e NATs derrubem conexões ociosas: a cada keepalive segundos sem enviar nada, mande um PINGREQ (0xC0 0x00) e espere um PINGRESP (0xD0 0x00). Se o broker não responder dentro do tempo razoável, feche o socket e reconecte. Reconexão com backoff exponencial, limite de tentativas e estado salvo em flash é o que separa um protótipo de um device que sobrevive a quedas de energia e de rede — recomendações detalhadas estão no material sobre sistemas de tempo real.

Last Will e mensagens retidas

Configure o Last Will no CONNECT (na seção de variável header/payload) para que o broker publique, por exemplo, dispositivos/zig-client-01/status = offline se a sua conexão cair abruptamente. Combine com uma mensagem retida que você mesmo publica ao reconectar (.../status = online, com a flag retain): novos assinantes saberão instantaneamente o estado atual de cada device, sem precisar esperar a próxima publicação.

TLS para MQTT

Rodar telemetria em texto puro na internet pública é um risco. Use a porta 8883 com TLS. Em embarcados com recursos, a libmosquitto cuida do handshake; em um cliente nativo, você encadeia o socket em std.crypto.tls (quando disponível no seu alvo) ou terceiriza para a biblioteca do sistema. Os cuidados com cadeia de certificados, validação de hostname e mTLS são os mesmos do guia de TLS, HTTPS e mTLS em produção.

Backpressure, fila local e observabilidade

Devices de campo produzem dados em rajadas. Se o broker estiver lento, não bloqueie a coleta: acumule em uma fila local em memória (ou em flash, se volátil) e drene quando o socket respirar. Esse padrão de fila de workers com descarte controlado é o do guia de filas e workers em background. E, como em qualquer sistema distribuído, exponha métricas: conexões estabelecidas, mensagens publicadas, backoff atual, reconexões — integrando com o que já existe em observabilidade e Prometheus.

Quando NÃO usar MQTT

O MQTT não é sempre a resposta. Considere alternativas quando:

  • o cliente é um navegador ou app que já fala HTTP/WebSocket — SSE e Websockets costumam bastar (veja Server-Sent Events em Zig);
  • você precisa de mensageria entre serviços de backend comordering forte, partitions e replay — NATS, Kafka ou filas AMQP são mais adequados;
  • o tráfego é consulta/resposta simples de baixa frequência — um HTTP REST enxuto é mais simples de operar.

O MQTT brilha no meio termo: muitos devices, mensagens pequenas e frequentes, tolerância a reconexão e baixo consumo de b banda.

Checklist de produção

Antes de levar um cliente MQTT em Zig para campo, confirme que:

  • o client_id é único por device (senão o broker derruba a conexão anterior);
  • o keepalive está configurado e há PINGREQ/PINGRESP efetivos;
  • há reconexão com backoff e limite de tentativas;
  • o Last Will e a mensagem retida de status estão definidos;
  • o TLS está habilitado em 8883 com validação de certificado (ou mTLS, se exigido);
  • há fila/backpressure para rajadas e métricas exportadas;
  • os buffers têm tamanho fixo conhecido, sem alocação no hot path;
  • o QoS escolhido corresponde à criticidade de cada tópico (telemetria = 0, comando = 1).

Perguntas frequentes

Preciso de uma biblioteca para falar MQTT em Zig? Não. O protocolo é simples o suficiente para um cliente nativo com algumas centenas de linhas, como mostrado aqui. Para QoS 2 e TLS corporativo, porém, a libmosquitto via FFI poupa tempo.

Qual broker usar em desenvolvimento? O Mosquitto é o padrão de fato. Rode docker run -p 1883:1883 eclipse-mosquitto e aponte seu cliente Zig para 127.0.0.1:1883.

MQTT 5 ou 3.1.1? Muitos dispositivos embarcados ainda usam 3.1.1 (level 4), que é o deste guia. O MQTT 5 adiciona shared subscriptions, reason codes e properties, mas aumenta a complexidade do encoder.

Qual o tamanho típico de um cliente MQTT em Zig? Um cliente QoS 0/1 com reconexão e keepalive costuma ficar entre 4 e 8 KB de binário, dependendo do alvo — um dos motivos pelo qual o Zig é atrativo para firmware.

Próximos passos

Para aprofundar, leia sobre IoT e embarcados em Zig, firmware de ESP32 com FreeRTOS, os fundamentos de rede com sockets TCP/UDP e a interoperabilidade com C para o caminho da libmosquitto. Para comparar com outra linguagem de sistemas forte em embarcados — onde o cenário MQTT costuma exigir bindar uma biblioteca C via crate extern — veja como o tema aparece em Rust para embarcados: a diferença é que o Rust depende de bindings (bindgen/cc) para a pilha de rede embarcada, enquanto o Zig compila o mesmo código C junto no build e fala o protocolo diretamente, sem camada extra de FFI para o caso nativo.

Continue aprendendo Zig

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