Pesquisa de site

Um tutorial prático para usar o GNU Project Debugger


O GNU Project Debugger é uma ferramenta poderosa para encontrar bugs em programas.

Se você é um programador e deseja colocar uma determinada funcionalidade em seu software, comece pensando em maneiras de implementá-la – como escrever um método, definir uma classe ou criar novos tipos de dados. Então você escreve a implementação em uma linguagem que o compilador ou intérprete possa entender. Mas e se o compilador ou intérprete não entender as instruções como você as tinha em mente, mesmo tendo certeza de que fez tudo certo? E se o software funcionar bem na maioria das vezes, mas causar bugs em determinadas circunstâncias? Nestes casos, você deve saber usar um depurador corretamente para encontrar a origem dos seus problemas.

O GNU Project Debugger (GDB) é uma ferramenta poderosa para encontrar bugs em programas. Ele ajuda a descobrir o motivo de um erro ou travamento, rastreando o que está acontecendo dentro do programa durante a execução.

Este artigo é um tutorial prático sobre o uso básico do GDB. Para acompanhar os exemplos, abra a linha de comando e clone este repositório:

git clone https://github.com/hANSIc99/core_dump_example.git

Atalhos

Cada comando no GDB pode ser abreviado. Por exemplo, info break, que mostra os pontos de interrupção definidos, pode ser abreviado para i break. Você pode ver essas abreviações em outros lugares, mas neste artigo escreverei o comando inteiro para que fique claro qual função é usada.

Parâmetros de linha de comando

Você pode anexar o GDB a cada executável. Navegue até o repositório que você clonou e compile-o executando make. Agora você deve ter um executável chamado coredump. (Veja meu artigo sobre Criando e depurando arquivos de despejo do Linux para obter mais informações.

Para anexar o GDB ao executável, digite: gdb coredump.

Sua saída deve ficar assim:

(Stephan Avenwedde, CC BY-SA 4.0)

Diz que nenhum símbolo de depuração foi encontrado.

As informações de depuração fazem parte do arquivo objeto (o executável) e incluem tipos de dados, assinaturas de funções e o relacionamento entre o código-fonte e o código de operação. Neste ponto, você tem duas opções:

  • Continue depurando a montagem (consulte "Depurar sem símbolos" abaixo)
  • Compilar com informações de depuração usando as informações da próxima seção

Compilar com informações de depuração

Para incluir informações de depuração no arquivo binário, é necessário recompilá-lo. Abra o Makefile e remova a hashtag (#) da linha 9:

CFLAGS =-Wall -Werror -std=c++11 -g

A opção g diz ao compilador para incluir as informações de depuração. Execute make clean seguido de make e invoque o GDB novamente. Você deve obter esta saída e começar a depurar o código:

(Stephan Avenwedde, CC BY-SA 4.0)

As informações adicionais de depuração aumentarão o tamanho do executável. Nesse caso, aumenta o executável em 2,5 vezes (de 26.088 bytes para 65.480 bytes).

Inicie o programa com a opção -c1 digitando run -c1. O programa será iniciado e travará quando atingir o State_4:

(Stephan Avenwedde, CC BY-SA 4.0)

Você pode recuperar informações adicionais sobre o programa. O comando info source fornece informações sobre o arquivo atual:

(Stephan Avenwedde, CC BY-SA 4.0)

  • 101 linhas
  • Linguagem: C++
  • Compilador (versão, ajuste, arquitetura, sinalizador de depuração, padrão de linguagem)
  • Formato de depuração: DWARF 2
  • Nenhuma informação de macro de pré-processador disponível (quando compiladas com GCC, as macros estão disponíveis apenas quando compiladas com a flag -g3).

O comando info shared imprime uma lista de bibliotecas dinâmicas com seus endereços no espaço de endereço virtual que foi carregado na inicialização para que o programa seja executado:

(Stephan Avenwedde, CC BY-SA 4.0)

Se você quiser aprender sobre manipulação de bibliotecas no Linux, consulte meu artigo Como lidar com bibliotecas dinâmicas e estáticas no Linux.

Depure o programa

Você deve ter notado que pode iniciar o programa dentro do GDB com o comando run. O comando run aceita argumentos de linha de comando como você usaria para iniciar o programa a partir do console. A opção -c1 fará com que o programa trave no estágio 4. Para executar o programa desde o início, você não precisa sair do GDB; simplesmente use o comando run novamente. Sem a opção -c1, o programa executa um loop infinito. Você teria que pará-lo com Ctrl+C.

(Stephan Avenwedde, CC BY-SA 4.0)

Você também pode executar um programa passo a passo. Em C/C++, o ponto de entrada é a função main. Use o comando list main para abrir a parte do código fonte que mostra a função main:

(Stephan Avenwedde, CC BY-SA 4.0)

A função main está na linha 33, então adicione um ponto de interrupção digitando break 33:

(Stephan Avenwedde, CC BY-SA 4.0)

Execute o programa digitando run. Como esperado, o programa para na função main. Digite layout src para mostrar o código fonte em paralelo:

(Stephan Avenwedde, CC BY-SA 4.0)

Agora você está no modo de interface de usuário de texto (TUI) do GDB. Use as teclas de seta para cima e para baixo para percorrer o código-fonte.

GDB destaca a linha a ser executada. Digitando next (n), você pode executar os comandos linha por linha. GBD executa o último comando se você não especificar um novo. Para percorrer o código, basta pressionar a tecla Enter.

De vez em quando, você notará que a saída do TUI fica um pouco corrompida:

(Stephan Avenwedde, CC BY-SA 4.0)

Se isso acontecer, pressione Ctrl+L para redefinir a tela.

Use Ctrl+X+A para entrar e sair do modo TUI à vontade. Você pode encontrar outras combinações de teclas no manual.

Para sair do GDB, simplesmente digite quit.

Pontos de controle

O coração deste programa de exemplo consiste em uma máquina de estados rodando em um loop infinito. A variável n_state é uma enumeração simples que determina o estado atual:

while(true){
	switch(n_state){
	case State_1:
		std::cout << "State_1 reached" << std::flush;
		n_state = State_2;
		break;
	case State_2:
		std::cout << "State_2 reached" << std::flush;
		n_state = State_3;
		break;
	
	(.....)
	
	}
}

Você deseja parar o programa quando n_state estiver definido como o valor State_5. Para fazer isso, pare o programa na função main e defina um watchpoint para n_state:

watch n_state == State_5

Definir watchpoints com o nome da variável funciona apenas se a variável desejada estiver disponível no contexto atual.

Ao continuar a execução do programa digitando continue, você deverá obter uma saída como:

(Stephan Avenwedde, CC BY-SA 4.0)

Se você continuar a execução, o GDB irá parar quando a expressão do watchpoint for avaliada como false:

(Stephan Avenwedde, CC BY-SA 4.0)

Você pode especificar pontos de controle para alterações gerais de valores, valores específicos e acesso de leitura ou gravação.

Alterando pontos de interrupção e pontos de controle

Digite info watchpoints para imprimir uma lista de watchpoints definidos anteriormente:

(Stephan Avenwedde, CC BY-SA 4.0)

Excluir pontos de interrupção e pontos de controle

Como você pode ver, os pontos de controle são números. Para excluir um ponto de controle específico, digite delete seguido do número do ponto de controle. Por exemplo, meu ponto de controle tem o número 2; para remover este ponto de controle, digite delete 2.

Cuidado: Se você usar delete sem especificar um número, todos os pontos de controle e pontos de interrupção serão excluídos.

O mesmo se aplica aos pontos de interrupção. Na captura de tela abaixo, adicionei vários pontos de interrupção e imprimi uma lista deles digitando info breakpoint:

(Stephan Avenwedde, CC BY-SA 4.0)

Para remover um único ponto de interrupção, digite delete seguido de seu número. Alternativamente, você pode remover um ponto de interrupção especificando seu número de linha. Por exemplo, o comando clear 78 removerá o ponto de interrupção número 7, que está definido na linha 78.

Desabilitar ou habilitar pontos de interrupção e pontos de controle

Em vez de remover um ponto de interrupção ou ponto de controle, você pode desativá-lo digitando disable seguido de seu número. A seguir, os pontos de interrupção 3 e 4 estão desabilitados e marcados com um sinal de menos na janela de código:

(Stephan Avenwedde, CC BY-SA 4.0)

Também é possível modificar uma série de pontos de interrupção ou pontos de controle digitando algo como disable 2 - 4. Se quiser reativar os pontos, digite enable seguido dos seus números.

Pontos de interrupção condicionais

Primeiro, remova todos os pontos de interrupção e de controle digitando delete. Você ainda deseja que o programa pare na função main, mas em vez de especificar um número de linha, adicione um ponto de interrupção nomeando a função diretamente. Digite break main para adicionar um ponto de interrupção na função main.

Digite run para iniciar a execução desde o início e o programa irá parar na função main.

A função main inclui a variável n_state_3_count, que é incrementada quando a máquina de estado atinge o estado 3.

Para adicionar um ponto de interrupção condicional com base no valor do tipo n_state_3_count:

break 54 if n_state_3_count == 3

(Stephan Avenwedde, CC BY-SA 4.0)

Continue a execução. O programa executará a máquina de estados três vezes antes de parar na linha 54. Para verificar o valor de n_state_3_count, digite:

print n_state_3_count

(Stephan Avenwedde, CC BY-SA 4.0)

Tornar os pontos de interrupção condicionais

Também é possível condicionar um ponto de interrupção existente. Remova o ponto de interrupção adicionado recentemente com clear 54 e adicione um ponto de interrupção simples digitando break 54. Você pode tornar esse ponto de interrupção condicional digitando:

condition 3 n_state_3_count == 9

O 3 refere-se ao número do ponto de interrupção.

(Stephan Avenwedde, CC BY-SA 4.0)

Defina pontos de interrupção em outros arquivos de origem

Se você tiver um programa que consiste em vários arquivos de origem, poderá definir pontos de interrupção especificando o nome do arquivo antes do número da linha, por exemplo, break main.cpp:54.

Pontos de captura

Além de pontos de interrupção e pontos de controle, você também pode definir pontos de captura. Os pontos de captura se aplicam a eventos do programa, como realizar syscalls, carregar bibliotecas compartilhadas ou gerar exceções.

Para capturar o syscall write, que é usado para gravar em STDOUT, digite:

catch syscall write

(Stephan Avenwedde, CC BY-SA 4.0)

Cada vez que o programa grava na saída do console, o GDB interrompe a execução.

No manual, você pode encontrar um capítulo inteiro cobrindo pontos de interrupção, observação e pontos de captura.

Avaliar e manipular símbolos

A impressão dos valores das variáveis é feita com o comando print. A sintaxe geral é print . O valor de uma variável pode ser modificado digitando:

set variable <variable-name> <new-value>.

Na captura de tela abaixo, dei à variável n_state_3_count o valor 123.

(Stephan Avenwedde, CC BY-SA 4.0)

A expressão /x imprime o valor em hexadecimal; com o operador &, você pode imprimir o endereço dentro do espaço de endereço virtual.

Se você não tiver certeza do tipo de dados de um determinado símbolo, poderá encontrá-lo com whatis:

(Stephan Avenwedde, CC BY-SA 4.0)

Se você quiser listar todas as variáveis que estão disponíveis no escopo da função main, digite info scope main:

(Stephan Avenwedde, CC BY-SA 4.0)

Os valores DW_OP_fbreg referem-se ao deslocamento da pilha com base na sub-rotina atual.

Alternativamente, se você já estiver dentro de uma função e quiser listar todas as variáveis no quadro de pilha atual, você pode usar info locais:

(Stephan Avenwedde, CC BY-SA 4.0)

Verifique o manual para saber mais sobre como examinar símbolos.

Anexar a um processo em execução

O comando gdb attachment permite anexar a um processo já em execução especificando o ID do processo (PID). Felizmente, o programa coredump imprime seu PID atual na tela, então você não precisa encontrá-lo manualmente com ps ou top.

Inicie uma instância do aplicativo coredump:

./coredump

(Stephan Avenwedde, CC BY-SA 4.0)

O sistema operacional fornece o PID 2849. Abra uma janela de console separada, vá para o diretório de origem do aplicativo coredump e anexe o GDB:

gdb attach 2849

(Stephan Avenwedde, CC BY-SA 4.0)

O GDB interrompe imediatamente a execução quando você o anexa. Digite layout src e backtrace para examinar a pilha de chamadas:

(Stephan Avenwedde, CC BY-SA 4.0)

A saída mostra o processo interrompido durante a execução da função std::this_thread::sleep_for<...>(...) que foi chamada na linha 92 de main.cpp.

Assim que você sair do GDB, o processo continuará em execução.

Você pode encontrar mais informações sobre como anexar a um processo em execução no manual do GDB.

Percorra a pilha

Retorne ao programa usando up duas vezes para subir na pilha até main.cpp:

(Stephan Avenwedde, CC BY-SA 4.0)

Normalmente, o compilador criará uma sub-rotina para cada função ou método. Cada sub-rotina tem seu próprio quadro de pilha, portanto, mover-se para cima no quadro de pilha significa mover-se para cima na pilha de chamadas.

Você pode descobrir mais sobre avaliação de pilha no manual.

Especifique os arquivos de origem

Ao anexar a um processo já em execução, o GDB procurará os arquivos de origem no diretório de trabalho atual. Alternativamente, você pode especificar os diretórios de origem manualmente com o comando directory.

Avaliar arquivos de despejo

Leia Criando e depurando arquivos de despejo do Linux para obter informações sobre este tópico.

DR:

  1. Presumo que você esteja trabalhando com uma versão recente do Fedora
  2. Invoque coredump com a opção c1: coredump -c1

    (Stephan Avenwedde, CC BY-SA 4.0)

  3. Carregue o dumpfile mais recente com GDB: coredumpctl debug
  4. Abra o modo TUI e digite layout src

(Stephan Avenwedde, CC BY-SA 4.0)

A saída de backtrace mostra que a falha ocorreu a cinco frames de pilha de main.cpp. Digite para ir diretamente para a linha de código com defeito em main.cpp:

(Stephan Avenwedde, CC BY-SA 4.0)

Uma olhada no código-fonte mostra que o programa tentou liberar um ponteiro que não foi retornado por uma função de gerenciamento de memória. Isso resulta em comportamento indefinido e causa o SIGABRT.

Depurar sem símbolos

Se não houver fontes disponíveis, as coisas ficam muito difíceis. Tive minha primeira experiência com isso ao tentar resolver desafios de engenharia reversa. Também é útil ter algum conhecimento de linguagem assembly.

Veja como funciona com este exemplo.

Vá para o diretório fonte, abra o Makefile e edite a linha 9 assim:

CFLAGS =-Wall -Werror -std=c++11 #-g

Para recompilar o programa, execute make clean seguido de make e inicie o GDB. O programa não possui mais símbolos de depuração para orientar o código-fonte.

(Stephan Avenwedde, CC BY-SA 4.0)

O comando info file revela as áreas de memória e o ponto de entrada do binário:

(Stephan Avenwedde, CC BY-SA 4.0)

O ponto de entrada corresponde ao início da área .text, que contém o código de operação real. Para adicionar um ponto de interrupção no ponto de entrada, digite break *0x401110 e inicie a execução digitando run:

(Stephan Avenwedde, CC BY-SA 4.0)

Para configurar um ponto de interrupção em um determinado endereço, especifique-o com o operador de desreferenciação *.

Escolha o sabor do desmontador

Antes de se aprofundar na montagem, você pode escolher qual tipo de montagem usar. O padrão do GDB é AT&T, mas prefiro a sintaxe Intel. Altere com:

set disassembly-flavor intel

(Stephan Avenwedde, CC BY-SA 4.0)

Agora abra a montagem e registre a janela digitando layout asm e layout reg. Agora você deve ver uma saída como esta:

(Stephan Avenwedde, CC BY-SA 4.0)

Salvar arquivos de configuração

Embora você já tenha inserido muitos comandos, você ainda não iniciou a depuração. Se você estiver depurando intensamente um aplicativo ou tentando resolver um desafio de engenharia reversa, pode ser útil salvar suas configurações específicas do GDB em um arquivo.

O arquivo de configuração gdbinit no repositório GitHub deste projeto contém os comandos usados recentemente:

set disassembly-flavor intel
set write on
break *0x401110
run -c2
layout asm
layout reg

O comando set write on permite modificar o binário durante a execução.

Saia do GDB e abra-o novamente com o arquivo de configuração: gdb -x gdbinit coredump.

Leia as instruções

Com a opção c2 aplicada, o programa irá travar. O programa para na função de entrada, então você deve escrever continue para prosseguir com a execução:

(Stephan Avenwedde, CC BY-SA 4.0)

A instrução idiv realiza uma divisão inteira com o dividendo no registro RAX e o divisor especificado como argumento. O quociente é carregado no registro RAX e o restante é carregado no RDX.

Na visão geral do registro, você pode ver que o RAX contém 5, então você precisa descobrir qual valor está armazenado na pilha na posição RBP-0x4.

Ler memória

Para ler o conteúdo da memória bruta, você deve especificar mais alguns parâmetros do que para ler símbolos. Ao rolar um pouco para cima na saída do assembly, você pode ver a divisão da pilha:

(Stephan Avenwedde, CC BY-SA 4.0)

Você está mais interessado no valor de rbp-0x4 porque esta é a posição onde o argumento para idiv é armazenado. Na captura de tela, você pode ver que a próxima variável está localizada em rbp-0x8, então a variável em rbp-0x4 tem 4 bytes de largura.

No GDB, você pode usar o comando x para examinar qualquer conteúdo da memória:

x/ < parâmetro opcional n f u > < endereço de memória addr >

Parâmetros opcionais:

  • n: A contagem de repetições (padrão: 1) refere-se ao tamanho da unidade
  • f: especificador de formato, como em printf
  • u: tamanho da unidade

    • b: bytes
    • h: meias palavras (2 bytes)
    • w: palavra (4 bytes)(padrão)
    • g: palavra gigante (8 bytes)

Para imprimir o valor em rbp-0x4, digite x/u $rbp-4:

(Stephan Avenwedde, CC BY-SA 4.0)

Se você mantiver esse padrão em mente, será fácil examinar a memória. Verifique a seção de exame de memória no manual.

Manipule a montagem

A exceção aritmética aconteceu na sub-rotina zeroDivide(). Ao rolar um pouco para cima com a tecla de seta para cima, você encontrará este padrão:

0x401211 <_Z10zeroDividev>              push   rbp
0x401212 <_Z10zeroDividev+1>            mov    rbp,rsp  

Isso é chamado de prólogo da função:

  1. O ponteiro base (rbp) da função de chamada é armazenado na pilha
  2. O valor do ponteiro de pilha (rsp) é carregado no ponteiro base (rbp)

Ignore esta sub-rotina completamente. Você pode verificar a pilha de chamadas com backtrace. Você está apenas um quadro de pilha à frente da sua função main, então você pode voltar para main com um único up:

(Stephan Avenwedde, CC BY-SA 4.0)

Na sua função main, você pode encontrar este padrão:

0x401431 <main+497>     cmp    BYTE PTR [rbp-0x12],0x0
0x401435 <main+501>     je     0x40145f <main+543>
0x401437 <main+503>     call   0x401211<_Z10zeroDividev>

A sub-rotina zeroDivide() é inserida somente quando jump equal (je) é avaliado como true. Você pode facilmente substituir isso por uma instrução jump-not-equal (jne), que possui o opcode 0x75 (desde que você esteja em uma arquitetura x86/64; os opcodes são diferente em outras arquiteturas). Reinicie o programa digitando run. Quando o programa parar na função de entrada, manipule o opcode digitando:

set *(unsigned char*)0x401435 = 0x75

Por fim, digite continuar. O programa irá pular a sub-rotina zeroDivide() e não travará mais.

Conclusão

Você pode encontrar o GDB funcionando em segundo plano em muitos ambientes de desenvolvimento integrado (IDEs), incluindo Qt Creator e a extensão Native Debug para VSCodium.

(Stephan Avenwedde, CC BY-SA 4.0)

É útil saber como aproveitar a funcionalidade do GDB. Normalmente, nem todas as funções do GDB podem ser usadas no IDE, então você se beneficia por ter experiência no uso do GDB na linha de comando.

Artigos relacionados: