Como um debugger interrompe um programa? Construindo breakpoints do zero em Rust
Um ponto vermelho aparece ao lado da linha. O programa começa, corre por milhões de instruções e congela exatamente ali. Quem mandou a CPU parar?
42 soma += item;
43● mostre(soma);
44 retorne soma;RIP 0x4011a7
RSP 0x7fff…
CC → instrução originalVOCABULÁRIO VISUAL
As peças que tornam a pausa observável.
O executável é carregado pelo sistema como vimos em Como um computador executa um programa por dentro. O Vigia é um processo; o programa observado, ou debuggee, é outro. O kernel autoriza o primeiro a observar eventos e alterar o estado pausado do segundo.
VIGIA ── pedidos ptrace ──▶ KERNEL ── controla ──▶ DEBUGGEE │ │ │ CLI e breakpoints permissões e sinais executa na CPU
O Vigia cria um filho com fork. No filho, PTRACE_TRACEME pede rastreamento e execve troca o código pelo executável-alvo. O kernel o pausa e notifica o pai. Só então instalamos breakpoints.
match unsafe { fork()? } {
ForkResult::Child => {
ptrace::traceme()?;
execv(&program, &argv)?;
}
ForkResult::Parent { child } => session.wait_initial_stop(child)?,
}O loop classifica Stopped, PtraceEvent, Exited e Signaled. Um sinal inesperado não é engolido: o usuário decide se deve entregá-lo ao processo.
A tabela de símbolos ELF relaciona calcular a um valor. Em executável não-PIE didático, esse valor já é virtual. Em PIE, ele é relativo à base escolhida pelo loader. O Vigia lê /proc/PID/maps, identifica o mapeamento do executável e calcula endereço_runtime = base + valor_relativo.
Os fixtures do primeiro artigo são compilados com símbolos, sem PIE e sem otimização. O Vigia também detecta PIE e mostra o cálculo, sem recomendar desativar ASLR globalmente.
CC.No x86_64, INT 3 foi projetada para depuração e ocupa um byte, 0xCC. ptrace lê e escreve uma palavra de máquina. Portanto preservamos os outros sete bytes.
let word = ptrace::read(pid, address as AddressType)? as u64;
let original = (word & 0xff) as u8;
let patched = (word & !0xff) | 0xcc;
ptrace::write(pid, address as AddressType, patched as i64)?;palavra original: 55 48 89 E5 48 83 EC 10
alterar byte 0: CC 48 89 E5 48 83 EC 10
▲ os outros bytes permanecemAo executar INT 3, a CPU avança o RIP e gera trap. O endereço real do breakpoint é RIP - 1. Se apenas continuarmos, pularemos a instrução original.
1 instalar CC 2 CPU executa CC → SIGTRAP → RIP já aponta depois 3 restaurar byte original e recuar RIP em 1 4 single-step executa somente a instrução restaurada 5 reinstalar CC e continuar
O gerenciador guarda endereço, byte original, estado e contador de acertos. Breakpoints múltiplos são independentes; inserir duas vezes no mesmo endereço incrementa referência, não perde o byte original.
$ vigia ./fixtures/chamadas
(vigia) break calcular
breakpoint 1 em 0x401156 <calcular>
(vigia) run
parado: breakpoint 1 · RIP=0x401156
(vigia) continue
processo terminou com código 0quit remove breakpoints, envia SIGTERM ao filho, espera um prazo curto e usa SIGKILL somente se necessário. O Vigia não anexa automaticamente a processos externos e não contorna Yama ou permissões.
Fixtures conhecidos comprovam breakpoint por endereço e símbolo, dois breakpoints simultâneos, repetição num loop, entrega de sinal e limpeza ao sair. Linux x86_64 é exigido. GDB e LLDB tratam threads, arquiteturas, formatos, remotos e muitos eventos que não cabem aqui.
Fontes: ptrace(2), signal(7) e ELF specification.
PROJETO COMPLETO · ESTADO EXATO DESTE CAPÍTULO
Baixe o Vigia 0.1.
Pacote exclusivo com código Rust, fixture em C, opções de compilação, testes e limites documentados para Linux x86_64. O Vigia controla apenas filhos criados por ele.