sexta-feira, 16 de novembro de 2007

Depurando programas em C com o GDB

Bom dia pessoALL.

Eu resolvi falar sobre depuração hoje pois era uma coisa da qual eu sentia falta quando estava tentando aprender a programar em C sem usar uma IDE de desenvolvimento, como o CodeGear C++ Builder, que vem com um monte de ferramentas de depuração, que desde a época que eu programava mais ativamente em Delphi, sempre usei para facilitar minha tarefa de programar. Eu aprendi cedo a usar as várias técnicas de depuração como breakpoints, avaliação de expressões e variáveis, exame da pilha de ativação e as vezes até me arriscava a tentar entender um pouco do depurador assembler que vem com as IDEs da Borland. Mas, como disse, no linux, sem as IDEs para me ajudar, eu me sentia desamparado ... Até descobrir o GDB.

Por que utilizar um depurador?

Sabe, isso pode parecer bobagem, mas eu já ouvi de alguns programadores que afirmam não precisar usar um depurador. Eles simplesmente não criam bugs. Das duas, uma: ou eles nunca colocaram seu código em teste real ou eles realmente são talentosos como dizem. No entanto, para a maioria de nós, programadores, nossos códigos tendem a ter bugs. Poderíamos, claro, imprimir em tela ou lançar diálogos com informações daquela passagem no código, ou poderíamos usar um depurador. Eu acho que a segunda opção é melhor pois nos leva a escrever código limpo que é muito mais fácil de receber manutenção.

Quando estamos programando, podemos encontrar bugs de diversas formas. Os mais básicos são os erros sintáticos, semânticos ou de estrutura, que levam o próprio compilador a gerar uma mensagem de erro acerca do código imperfeito. No entanto, mesmo que tenhamos um código que esteja sintaticamente, muitas vezes o erro pode estar inserido na logica do código, e uma leitura superficial do código não ajuda na resolução do problema. E nestes casos, o que é melhor ?? Uma estrutura complexa de testes e mensagens para mostrar por onde o código está passando, ou um depurador que permite vc fazer isto sem alterar o código original? É aí que está a diferença entre os programadores medianos e os bons programadores.

A minha idéia com este texto é passar o básico do básico do GDB, já que também eu estou aprendendo a fuçar nesse depurador, já que ele é de linha de comando, e não como eu aprendi, com uma ide "água com açúcar".


Invocando o depurador GDB

Antes de chamar o GDB, certifique-se de que de que vc compilou o programa (e todos os seus módulos, assim como durante a linkedição) com a flag "-g". Caso contrário, somente os Deuses poderão depurar seu código. Vamos então compilar o programa me_depure.c listado abaixo, e então chamar a execução do GDB para debugá-lo:
/* me_depure.c */
#include <stdio.h>

/* imprime uma dada string e um número em um formato predeterminado. */
void print_string(int num, char* string)
{
printf("String '%d' - '%s'\n", num, string);
}

int main(int argc, char* argv[])
{
int i;

/* checagem de argumentos de linha de comando */
if (argc < 2) { /* 2 argumentos - 1 para o nome do programa (argv[0] e um para o parâmetro. */ printf("Modo de uso: %s [ ...]\n", argv[0]);
exit(1);
}

/* repete para todas as strings, e imprime uma por uma */
for (argc--,argv++,i=1 ; argc > 0; argc--,argv++,i++) {
print_string(i, argv[0]); /* chamada de função */
}

printf("Numero total de strings: %d\n", i);

return 0;
}

Agora vamos compilar nosso arquivo:
gcc -g me_depure.c -o me_depure
gdb me_depure

Nota: lembre-se de executar o gdb do mesmo path que o arquivo foi compilado, pois de outra forma o gdb não encontrará o arquivo fonte e, portanto, não será capaz de mostrar que estamos no código em um determinado ponto. É possível solicitar o gdb para pesquisar arquivos fontes extras em algum outro path depois de iniciado, mas, por enquanto, é mais fácil iniciá-lo do path onde está o nosso fonte.

Executando o programa de dentro do depurador

Assim que iniciamos o depurador, podemos executar o programa usando o comando "run". Se o programa requer parâmetros de linha de comando (como o nosso código faz), nós podemos passar estes parâmetros para aplicação através do comando "run" do gdb, como segue:
run "O Well Come GDB" "Nao come nao !!!"

Nota: Usamos aspas de marcação para informar que "O Well Come" é um único parâmetro, e não uma lista de comandos (o depurador e o próprio shell convencionam que espaço em branco é separador de parâmetros).

Criando Breakpoints

O problema com a execução simples do programa é que continua rodando até que o mesmo termine, quando nós provavelmente teremos passado pela fonte do erro. Para isto, são introduzidas interrupções no código. As interrupções me permitem que, ao chegar em determinado ponto da execução da aplicação, a execução seja suspensa imediatamente antes da linha marcada. Nós podemos setar breakpoints de duas maneiras:

1.) Especificar uma determinada linha do código
break me_depure.c:10

Este comando irá inserir um breakpoint na linha que checa os argumentos de linha de comando em nosso programa.

2.) Especificar o nome de uma função
break main

Isto irá criar um breakpoint quando a aplicação for iniciada (pois a função "main" é executada automaticamente no começo de qualquer programa C e C++).

Executando o programa Passo-à-passo

Agora, vamos ver o que acontece quando executamos o gdb, então digite:
break main
run "O Well Come GDB" "Nao come nao !!!"

Então, o depurador irá retornar uma saída parecida com essa:
Starting program: /home/rolim/gdb_src/me_depure "O Well Come GDB" "Nao come nao !!!"
warning: Unable to find dynamic linker breakpoint function.
warning: GDB will be unable to debug shared library initializers
warning: and track explicitly loaded dynamic code.

Breakpoint 1, main (argc=3, argv=0xbffd1f84) at me_depure.c:15
15 if (argc < 2) { /* 2 argumentos - 1 para o nome do programa (argv[0] e um para o parâmetro. */


Nota: Nem sempre estes warnings aparecerão para você. Estas só servem para mostrar o quanto meu sistema está "bem" configurado e acertado. Em todo caso, estes warnings não são relevantes para nosso código atual, pois não pretendemos depurar bibliotecas compartilhadas.

Agora, queremos executar nosso programa passo-à-passo. Há duas opções para isso:

"next": Faz com que o depurador execute a linha atual e pare novamente no próximo comando, mostrando o próximo comando a ser executado.
"step": Faz com que o depurador execute a linha atual e, se a linha for uma chamada de função, pare no começo do código da função. Isto é útil para depurar código dentro de funções.

Agora é hora de experimentar com estas opções em nosso programa sendo depurado, e ver como funciona. Também é útil para ler a ajuda depuradores, usando o comando "help break" e "help breakpoints" para aprender como setar outros tipos de breakpoints, como eles são criados, como deletar breakpoints e como aplicar regras condicinais em breakpoints (fazer com que o breakpoint somente seja ativado se uma determinada expressão seja avaliada como "true").

Imprimindo variáveis

Sem ter condições de analisar variáveis durante a execução do programa, toda a idéia de utilizar um depurador é perdida. Você pode imprimir o conteúdo de uma variável com o comando:
print i

E você verá uma mensagem como:
$1 = 0

O que significa que "i" contém o número "0".
Note que isto requer "I" de estar em campo, ou você receberá uma mensagem como:
No symbol "i" in current context.

Por exemplo, se você para dentro da função print_string e tenta imprimir o valor da variável i, vc recebe esta mensagem. Você também pode tentar imprimir expressões mais complexas, como "i*2" ou "argv[3]" ou "argv[argc]", e por aí vai. De fato, você pode usar também typecasts, chamar funções encontradas no programa, e qualquer coisa que sua mente criativa puder imaginar (bem, quase). Mais uma vez, este é um bom momento para testar estas idéias.

Examinando a Pilha de Ativação

Depois que nós entramos em um ponto de parada e examinamos algumas variáveis, é interessante as vezes sabermos em que ponto da execução nós estamos parados. Isto é, aquilo que está para ser executado agora, que função chamou qual, e por aí vai. Isso pode ser feito usando o comando "where". No GDB, ao digitar o comando where, vc tem algo do tipo:
#0 print_string (num=1, string=0xbffd391c "O Well Come GDB") at me_depure.c:7
#1 0x08048433 in main (argc=2, argv=0xbffd1f88) at me_depure.c:22

Isso significa que a função sendo executada é print_string, no arquivo me_depure.c, linha 7. A função que a chamou é main. Nós podemos ver também os argumentos que cada função recebeu. Se há outras funções na cadeia de chamadas, nós podemos vê-las listadas em ordem. Esta ordem de chamadas de cada uma das funções é o que é chamada de pilha de ativação, ou Call Stack em inglês, já que ele mostra a estrutura da pilha de ativação neste ponto do ciclo de vida do programa.

Assim como nós podemos ver o conteúdo das variáveis da função corrente, podemos também ver o conteúdo de funções locais da função chamadora, ou de qualquer outra função que esteja na pilha. Por exemplo, se quizermos ver o conteúdo da variável "i" na função main, nós usamos estes comandos:
frame 1
print i

O comando frame diz para o depurador para mudar para o frame da pilha de ativação indicado (0 é o frame onde a execução está atualmente). A partir desse comando, qualquer comando print dado irá usar as informações no contexto do frame indicado. Claro que, se acionarmos "step" ou "next", o programa irá continuar a partir do topo da pilha, não do frame que nós estamos visualizando. Em todo o caso, o depurador não pode desfazer todas as chamadas e continuar dali.

Conectando-se a um processo já em execução

As vezes pode acontecer de que nós precisemos depurar um programa que não possa ser ativado via linha de comando. Isso ocorre, por exemplo, quando o programa foi iniciado a partir de algum Daemon e estamos com muita preguiça de torná-lo executável via linha de comando. Ou talvez o programa demore muito tempo para executar seu código de inicialização, e iniciar o mesmo com um depurador anexado pode tornar a execução deste código ainda mais lento. Existem também outras razões, mas acho que você entendeu a idéia. Para estes casos, o depurador pode ser lançado dessa forma:
gdb me_depure 9561

Aqui nós assumimos que me_depure é o nome do programa executado, e que 9561 é o pid de nosso processo que queremos depurar.

O que acontece é que o GDB, na primeira tentativa procura por um núcleo de nome 9561 (veremos o que arquivos de núcleo são no próximo tópico), e quando ele não encontra, ele assume que o número informado é o identificador único de processos, ou pid, e tenta se conectar a ele. Se aqui o processo executa o mesmo programa informado para o gdb (não pode ser uma cópia do arquivo. Tem que ser exatamente o mesmo arquivo que está sendo informado para o gdb o que está sendo executado), ele irá se conectar ao programa, pausar sua execução e então irá deixar que nós continuemos sua execução de dentro do depurador, como se tivessemos iniciado ele dentro do mesmo. Chamando o comando where, nós podemos saber exatamente em que parte do código a execução está parada, e nós podemos continuar dali. Caso nós saiamos do depurador, ele irá se desconectar do processo e o mesmo irá continuar sua execução a partir do ponto onde nós paramos no depurador.

Depurando uma aplicação que está travada

Um dos problemas relacionados à depuração de programas, é o que fazer com a Lei de Murphy: "A program will crash when least expected", ou na tradução direta, "O programa irá travar quando menos esperarmos". Esta frase significa que mesmo depois que seu programa já estejam pronto, em produção, ele irá travar uma hora. E os bugs não precisam ser necessariamente fáceis de reproduzir. Por sorte, ainda há salvação para nós, com a ajuda dos arquivos de núcleo.

Um arquivo de núcleo (do inglês "core file") contém a imagem da memória e do processo corrente, e (assumindo que o programa dentro do processo foi compilado com informações de depuração) sua pilha de ativação, conteúdo das variáveis, e por aí vai. Um programa é normalmente configurado para gerar um arquivos de núcleo contendo a imagem de seu processo na memória quando ele travou devido a sinais como SEGV ou BUS. Desde que o shell não seja configurado para limitar o tamanho desse arquivo, nós vamos encontrar este arquivo no diretório de trabalho do processo (ou no diretório de onde ele foi iniciado, ou o último diretório setado via chamada da api "chdir".

Assim que conseguirmos este arquivo, nós podemos examinar este arquivo com o seguinte comando:
gdb /caminho/do/me_depure core

Assumindo que nosso programa foi iniciado a partir desse diretório e que o arquivo de núcleo foi gerado no mesmo diretório. Se não for, nós precisamos informar o caminho para o arquivo. Quando o prompt do depurador aparecer (assumindo que o arquivo foi gerado corretamente), nós podemos lançar comandos como "print", "where" e "frame x". Comandos que implicam em execução ("next", "step" e invocação de chamadas de função) não podem ser efetuadas por não estarmos ligados a um processo, mas investigando o arquivo de núcleo.

Nota: Se nosso programa travou devido um erro de acesso à endereço inválido de memória, isto implicará que a memória da aplicação estará provavelmente corrompida, e consequentemente que o arquivo de núcleo também estará, e conterá conteúdos inválidos de memória, frames inválidos na pilha de ativação, entre outras coisas. No entanto, nós podemos estudar o conteúdo do arquivo de núcleo como um passado, entre vários passados prováveis. Isto faz com que a análise destes arquivos seja bastante similar à teoria quântica. Quase. Nada é impossível, tão menos improvável.

Obtendo mais informação sobre a depuração.

Agora é a hora de brincar com o gdb e seus programas. Eu sugeriria você digitar help dentro do depurador para aprender mais sobre seus comandos, especialmente o comando "dir", que nos permite depurar programas cujo código fonte está espalhado em diversos diretórios.

Se você achar que o gdb é muito limitado, você pode tentar algum depurador gráfico. Sugestões são o "xxgdb", que é um depurador gráfico que roda baseado no gdb. Você também pode tentar o "ddd". Uma vantagem do xxgdb é que ele permite que você inspecione o conteúdo de ponteiros, listas ligadas e outras estruturas de dados complexas. Ele pode não estar instalado na sua distribuição, e será necessário instalá-lo de alguma forma, dependendo da distribuição utilizada.

Considerações Finais

Mais uma vez, estou eu aqui esclarecendo detalhes do processo de criação de aplicativos, desta vez falando do processo de depuração, que é tão menosprezado pelos programadores menos experientes, as vezes por desconhecerem seu poder, as vezes por ignorância. No entanto, espero ter mostrado o quanto pode ser importante para o desenvolvimento o uso destas ferramentas, que nos ajudam bastante a escrever código de qualidade e sem erros.

Para a confecção deste post, eu usei várias fontes, entre elas a Wikipédia e o material do curso de C da UFMG, disponível em seu site. Agradeço também ao meu amigo Diego "Homer Simpson", que me ajudou na tarefa de tradução de algumas partes do material que eu usei como referência para este post.