Depuração e Profiling em Zig: Tracy, Valgrind e Perf
Escrever código correto é apenas metade do trabalho — encontrar bugs e otimizar performance são habilidades fundamentais para qualquer desenvolvedor de sistemas. O Zig oferece ferramentas nativas poderosas para depuração, como stack traces detalhados e o GeneralPurposeAllocator com detecção de leaks. Além disso, por gerar binários nativos compatíveis com DWARF, o Zig funciona perfeitamente com ferramentas consagradas como Tracy, Valgrind e perf.
Neste artigo, vamos explorar técnicas práticas de depuração e profiling para programas Zig.
Modos de Build e Seu Impacto
Antes de depurar, é essencial entender os modos de release do Zig. Cada modo afeta o nível de informação disponível para depuração:
const std = @import("std");
pub fn main() !void {
// Em Debug, verificações de segurança estão ativas:
// - Bounds checking em slices
// - Detecção de undefined behavior
// - Stack traces completos
var buffer: [5]u8 = .{ 1, 2, 3, 4, 5 };
const slice = buffer[0..3];
// Em ReleaseSafe, mantém verificações com otimização
// Em ReleaseFast, remove verificações para máxima performance
// Em ReleaseSmall, otimiza para menor binário
std.debug.print("Slice: {any}\n", .{slice});
}
| Modo | Otimização | Safety Checks | Debug Info | Uso |
|---|---|---|---|---|
| Debug | Nenhuma | ✅ Sim | ✅ Completa | Desenvolvimento |
| ReleaseSafe | Total | ✅ Sim | ✅ Completa | Produção segura |
| ReleaseFast | Total | ❌ Não | ❌ Mínima | Máxima performance |
| ReleaseSmall | Tamanho | ❌ Não | ❌ Mínima | Embarcados |
Para depuração, sempre compile em modo Debug ou ReleaseSafe:
zig build -Doptimize=Debug
zig build -Doptimize=ReleaseSafe
Depuração com GDB e LLDB
O Zig gera informações DWARF completas em modo Debug, permitindo depuração com GDB ou LLDB sem configuração adicional.
Usando GDB
# Compilar com informações de debug
zig build-exe src/main.zig
# Iniciar GDB
gdb ./main
Comandos essenciais do GDB com Zig:
# Definir breakpoint em função
(gdb) break main
(gdb) break src/main.zig:42
# Executar até o breakpoint
(gdb) run
# Inspecionar variáveis
(gdb) print minha_variavel
(gdb) print slice.ptr[0..slice.len]
# Avançar linha por linha
(gdb) next
(gdb) step
# Ver stack trace
(gdb) backtrace
# Continuar execução
(gdb) continue
Inspecionando slices e structs
O Zig usa representações internas para slices e structs. No GDB, um slice aparece como uma struct com .ptr e .len:
const std = @import("std");
const Ponto = struct {
x: f64,
y: f64,
pub fn distancia(self: Ponto, outro: Ponto) f64 {
const dx = self.x - outro.x;
const dy = self.y - outro.y;
return @sqrt(dx * dx + dy * dy);
}
};
pub fn main() !void {
const pontos = [_]Ponto{
.{ .x = 0, .y = 0 },
.{ .x = 3, .y = 4 },
.{ .x = 6, .y = 8 },
};
// Breakpoint aqui para inspecionar
for (pontos, 0..) |ponto, i| {
std.debug.print("Ponto {}: ({d}, {d})\n", .{ i, ponto.x, ponto.y });
}
}
No GDB, inspecione com:
(gdb) print pontos[0]
$1 = {x = 0, y = 0}
(gdb) print pontos[1].x
$2 = 3
Detecção de Memory Leaks com GeneralPurposeAllocator
O GeneralPurposeAllocator (GPA) do Zig é uma das melhores ferramentas nativas para detectar vazamentos de memória. Ao contrário de ferramentas externas, ele está integrado diretamente na stdlib:
const std = @import("std");
pub fn main() !void {
// GPA detecta leaks, double-free e use-after-free
var gpa = std.heap.GeneralPurposeAllocator(.{
.stack_trace_frames = 8, // Frames no stack trace
.enable_memory_limit = true, // Limitar memória total
}){};
defer {
const status = gpa.deinit();
if (status == .leak) {
std.debug.print("⚠️ Memory leak detectado!\n", .{});
}
}
const allocator = gpa.allocator();
// Alocação normal — sem leak
const dados = try allocator.alloc(u8, 1024);
defer allocator.free(dados);
// Simulando um leak (sem free)
const leak = try allocator.alloc(u8, 256);
_ = leak; // ← Nunca liberado!
std.debug.print("Programa executado com sucesso.\n", .{});
}
Ao rodar esse programa em modo Debug, o GPA imprime:
Programa executado com sucesso.
⚠️ Memory leak detectado!
error: memory address 0x7f... leaked:
src/main.zig:22:41: main
O stack trace aponta exatamente a linha da alocação que não foi liberada.
Profiling com perf (Linux)
O perf é a ferramenta de profiling padrão no Linux. Como o Zig gera binários nativos, funciona sem configuração:
# Compilar com ReleaseSafe para ter símbolos + otimização
zig build -Doptimize=ReleaseSafe
# Gravar perfil de execução
perf record -g ./zig-out/bin/meu-programa
# Visualizar relatório interativo
perf report
# Gerar flame graph (requer FlameGraph tools)
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
Exemplo prático: identificando hotspot
const std = @import("std");
// Função intencionalmente lenta para demonstração
fn processarDados(dados: []const u8) u64 {
var soma: u64 = 0;
for (dados) |byte| {
// Simulando processamento pesado
var hash: u64 = byte;
for (0..1000) |_| {
hash = hash *% 6364136223846793005 +% 1442695040888963407;
}
soma +%= hash;
}
return soma;
}
fn validarDados(dados: []const u8) bool {
for (dados) |byte| {
if (byte == 0) return false;
}
return true;
}
pub fn main() !void {
var prng = std.Random.DefaultPrng.init(42);
var buffer: [10_000]u8 = undefined;
prng.fill(&buffer);
if (validarDados(&buffer)) {
const resultado = processarDados(&buffer);
std.debug.print("Resultado: {}\n", .{resultado});
}
}
O perf report mostraria que processarDados consome ~99% do tempo, enquanto validarDados é negligível. Flame graphs tornam isso visualmente óbvio.
Instrumentação com Tracy
O Tracy é um profiler em tempo real que oferece visualizações detalhadas com timeline, flame charts e estatísticas de alocação. A integração com Zig é feita via a biblioteca zig-tracy.
Configurando Tracy no build.zig.zon
.dependencies = .{
.tracy = .{
.url = "https://github.com/nektro/zig-tracy/archive/refs/tags/v0.11.1.tar.gz",
.hash = "1220...",
},
},
Configurando no build.zig
const tracy_dep = b.dependency("tracy", .{
.target = target,
.optimize = optimize,
.enable = b.option(bool, "tracy", "Habilitar Tracy profiler") orelse false,
});
exe.root_module.addImport("tracy", tracy_dep.module("tracy"));
Instrumentando o código
const std = @import("std");
const tracy = @import("tracy");
fn processarLote(dados: []const u8) u64 {
// Marca esta região no profiler
const zone = tracy.trace(@src());
defer zone.end();
var resultado: u64 = 0;
for (dados) |byte| {
resultado +%= @as(u64, byte) *% 31;
}
return resultado;
}
fn pipeline(allocator: std.mem.Allocator, entrada: []const u8) ![]u8 {
const zone = tracy.trace(@src());
defer zone.end();
zone.setName("Pipeline Principal");
// Marcar alocações para Tracy rastrear
const buffer = try allocator.alloc(u8, entrada.len * 2);
tracy.allocN(buffer.ptr, buffer.len, "buffer-pipeline");
for (entrada, 0..) |byte, i| {
buffer[i * 2] = byte;
buffer[i * 2 + 1] = byte ^ 0xFF;
}
return buffer;
}
pub fn main() !void {
// Frame mark para Tracy
tracy.frameMark();
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const dados = "Zig profiling com Tracy funciona muito bem!";
const resultado = try pipeline(allocator, dados);
defer allocator.free(resultado);
const soma = processarLote(resultado);
std.debug.print("Soma: {}\n", .{soma});
tracy.frameMark();
}
Para compilar com Tracy habilitado:
zig build -Dtracy=true
Depois, abra o Tracy Profiler GUI e conecte ao programa em execução para visualizar a timeline em tempo real.
Análise de Memória com Valgrind
O Valgrind é essencial para detectar problemas de memória que escapam do GPA, como acessos inválidos em código interoperando com C:
# Compilar em modo Debug
zig build-exe src/main.zig
# Rodar com Valgrind Memcheck
valgrind --leak-check=full --track-origins=yes ./main
# Para profiling de cache (performance)
valgrind --tool=cachegrind ./main
# Para profiling de chamadas
valgrind --tool=callgrind ./main
Exemplo: detectando acesso inválido
const std = @import("std");
const c = @cImport({
@cInclude("stdlib.h");
@cInclude("string.h");
});
pub fn main() void {
// Alocação via libc (não rastreada pelo GPA)
const ptr: [*]u8 = @ptrCast(c.malloc(100) orelse {
std.debug.print("Falha na alocação\n", .{});
return;
});
// Uso normal
_ = c.memset(ptr, 0, 100);
// Liberar memória
c.free(ptr);
// BUG: use-after-free — Valgrind detecta isso!
// ptr[0] = 42; // ← Descomente para testar
}
O Valgrind reporta com precisão a linha do acesso inválido, o ponto onde a memória foi liberada e onde foi alocada originalmente.
Nota: Valgrind funciona apenas em Linux (x86_64 e arm64). Para macOS, considere o AddressSanitizer ou o Instruments da Apple. Para mais detalhes sobre alocação de memória em Zig, confira nosso artigo dedicado.
Técnicas de Debug Nativas do Zig
Além de ferramentas externas, o Zig oferece recursos nativos poderosos:
std.debug.print para inspeção rápida
const std = @import("std");
fn fibonacci(n: u32) u64 {
std.debug.print("fibonacci({}) chamado\n", .{n});
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
pub fn main() void {
const resultado = fibonacci(10);
std.debug.print("Resultado: {}\n", .{resultado});
}
@breakpoint() para debug interativo
fn funcaoCritica(valor: i32) i32 {
if (valor < 0) {
// Pausa a execução — GDB/LLDB assume controle
@breakpoint();
}
return valor * 2;
}
Stack traces automáticos
Em modo Debug, o Zig gera stack traces automáticos para erros não tratados e unreachable:
fn processar(dados: ?[]const u8) !void {
const conteudo = dados orelse return error.DadosNulos;
// ...
_ = conteudo;
}
pub fn main() !void {
try processar(null);
// Imprime stack trace completo com arquivo e linha
}
Comparativo: Quando Usar Cada Ferramenta
| Ferramenta | Melhor para | Plataforma | Overhead |
|---|---|---|---|
| GPA (Zig) | Memory leaks, double-free | Todas | Baixo |
| GDB/LLDB | Breakpoints, inspeção | Todas | Nenhum |
| perf | CPU profiling, flame graphs | Linux | Mínimo |
| Tracy | Profiling em tempo real | Linux/Windows | Baixo |
| Valgrind | Análise profunda de memória | Linux | Alto (~20x) |
| Cachegrind | Cache misses, branch prediction | Linux | Alto |
Conclusão
Depurar e perfilar programas Zig é uma experiência produtiva graças à combinação de ferramentas nativas (GPA, stack traces, @breakpoint()) com ferramentas do ecossistema C/C++ (GDB, perf, Tracy, Valgrind). O fato de o Zig gerar binários nativos com DWARF completo significa que toda ferramenta que funciona com C funciona com Zig — sem adaptadores ou plugins.
Para aprofundar, explore nossos artigos sobre gerenciamento de memória, testes em Zig e error handling. Se você vem de linguagens como Rust ou Go, vai notar que o Zig dá mais controle manual sobre o processo de depuração — e isso é intencional. Em linguagens de sistemas, visibilidade total sobre memória e performance não é luxo, é necessidade.