Entendendo os depuradores
Entendendo os conceitos principais sobre um depurador e como eles funcionam.
Depuradores (debuggers) são ferramentas que atuam se conectando (attaching) em processos para controlar e monitorar a execução dos mesmos. Isso é possível por meio de recursos que o próprio sistema operacional provém, no caso do Linux por meio da syscall ptrace.
O processo que se conecta é chamado de tracer e o processo conectado é chamado de tracee. Essa conexão é chamada de attach e é feita em uma thread individual do processo. Quando o depurador faz attach em um processo ele na verdade está fazendo attach na thread principal do processo.

Processos

As threads são tarefas individuais em um processo. Cada thread de um processo executa um código diferente de maneira concorrente em relação as outras threads do mesmo processo.
Um processo é basicamente a imagem de um programa em execução. Uma parte do sistema operacional conhecida como loader (ou dynamic linker) é a responsável por ler o arquivo executável, mapear seus códigos e dados na memória, carregar dependências (bibliotecas) resolvendo seus símbolos e iniciar a execução da thread principal do processo no código que está no endereço do entry point do executável. Onde entry point se trata de um endereço armazenado dentro do arquivo executável e é o endereço onde a thread principal inicia a execução.
O depurador tem acesso a mem√≥ria de um processo e pode controlar a execu√ß√£o das threads do processo. Ele tamb√©m tem acesso a outras informa√ß√Ķes sobre o processo, como o valor dos registradores em uma thread por exemplo.

Context switch

Do ponto de vista de cada thread de um processo ela tem exclusividade na execução de código no processador e no acesso a seus recursos. Inclusive em Assembly usamos registradores do processador diretamente sem nos preocuparmos com outras threads (do mesmo processo ou de outros) usando os mesmos registradores "ao mesmo tempo".
Cada n√ļcleo (core) do processador t√™m um conjunto individual de registradores, mas √© comum em um sistema operacional moderno diversas tarefas estarem concorrendo para executar em um mesmo n√ļcleo.
Uma parte do sistema operacional chamada de scheduler √© respons√°vel por gerenciar quando e qual tarefa ser√° executada em um determinado n√ļcleo do processador. Isso √© chamado de escalonamento de processos (scheduling) e quando o scheduler suspende a execu√ß√£o de uma tarefa para executar outra isso √© chamado de troca de contexto ou troca de tarefa (context switch ou task switch).
Quando há a troca de contexto o scheduler se encarrega de salvar na memória RAM o estado atual do processo, e isso inclui o valor dos registradores. Quando a tarefa volta a ser executada o estado é restaurado do ponto onde ele parou, e isso inclui restaurar o valor de seus registradores.
√Č assim que cada thread tem valores distintos em seus registradores. √Č assim tamb√©m que depuradores s√£o capazes de ler e modificar o valor de registradores em uma determinada thread do processo, o sistema operacional d√° a capacidade de acessar esses valores no contexto da tarefa e permite fazer a modifica√ß√£o. Quando o scheduler executar a tarefa o valor dos registradores ser√£o atualizados com o valor armazenado no contexto.
Processadores Intel mais modernos t√™m uma tecnologia chamada Hyper-Threading. Essa tecnologia permite que um mesmo n√ļcleo atue como se fosse dois permitindo que duas threads sejam executadas paralelamente no mesmo n√ļcleo.
Cada "parte" independente √© chamada de processador l√≥gico (logical processor) e cada processador l√≥gico no n√ļcleo tem seu conjunto individual de registradores. Com exce√ß√£o de alguns registradores "obscuros" que s√£o compartilhados pelos processadores l√≥gicos do n√ļcleo. Esses registradores n√£o foram abordados no livro, mas caso esteja curioso pesquise por Model-specific register (MSR) e MTRRs. Apenas alguns MSR s√£o compartilhados pelos processadores l√≥gicos.

Sinais

Os sinais é um mecanismo de comunicação entre processos (Inter-Process Communication - IPC). Existem determinados sinais em cada sistema operacional e quando um sinal é enviado para um processo ele é temporariamente suspenso e um tratador (handler) do sinal é executado.
A maioria dos sinais podem ter o tratador personalizado pelo programador mas alguns t√™m um tratador padr√£o e n√£o podem ser alterados. √Č o caso por exemplo no Linux do sinal SIGKILL, que √© o sinal enviado para um processo quando voc√™ tenta for√ßar a finaliza√ß√£o dele (com o comando kill -9 por exemplo). O tratador desse sinal √© exclusivamente controlado pelo sistema operacional e o processo n√£o √© capaz de personalizar ele.
Exemplo de personalização do tratador de um sinal:
main.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
‚Äč
void termination(int signum)
{
puts("Goodbye!");
_Exit(EXIT_SUCCESS);
}
‚Äč
int main(void)
{
struct sigaction action = {
.sa_handler = termination,
};
‚Äč
sigaction(SIGTERM, &action, NULL);
int pid = getpid();
‚Äč
printf("My PID: %d\nPlease, send-me SIGTERM... ", pid);
while (1)
{
getchar();
}
‚Äč
return 0;
}
‚Äč
Experimente compilar e executar esse programa. No Linux você pode enviar o sinal SIGTERM para o processo com o comando kill, como em:
$ kill 155541
‚Äč
# Onde 155541 seria o PID do processo.
O sinal SIGTERM seria o jeito "educado" de finalizar um processo. Por√©m como pode ser observado √© poss√≠vel que o processo personalize o tratador desse sinal, que por padr√£o finaliza o programa. Nesse c√≥digo de exemplo se removermos a chamada para a fun√ß√£o _Exit() o processo n√£o ir√° mais finalizar ao receber SIGTERM. √Č por isso que existe o sinal mais "invasivo" SIGKILL que foi feito para ser usado quando o processo n√£o est√° mais respondendo.
Um processo que está sendo depurado (o tracee) para toda vez que recebe um sinal e o depurador toma o controle da execução. Exceto no caso de SIGKILL que funciona normalmente sem a intervenção do depurador.

Exce√ß√Ķes

Quando um processo dispara uma exce√ß√£o, um tratador (handler) configurado pelo sistema operacional envia um sinal para o processo tratar aquela exce√ß√£o. Depuradores s√£o capazes de identificar (e ignorar) exce√ß√Ķes intervindo no processo de handling desse sinal.

Depuradores

Agora que j√° entendemos um pouco sobre processos vai ficar mais f√°cil entender como depuradores funcionam. Afinal de contas depuradores depuram processos.
ūüôā
Depuradores têm a capacidade de controlar a execução das threads de um processo, tratar os sinais enviados para o processo, acessar sua memória e ver/editar dados relacionados ao contexto de cada thread (como os registradores, por exemplo). Todo esse poder é dado para os usuários do depurador por meio de alguns recursos que serão descritos abaixo.

Breakpoint

Um ponto de parada (breakpoint) é um ponto no código onde a execução do programa será interrompida e o depurador irá manter o programa em pausa para que o usuário possa controlar a execução em seguida.
Os breakpoints são implementados na prática (na arquitetura x86-64) como uma instrução int3 que dispara a exceção #BP. Quando um depurador insere um breakpoint em um determinado ponto do código ele está simplesmente modificando o primeiro byte da instrução para o byte 0xCC, que é o byte da instrução int3. Quando a exceção é disparada o sinal SIGTRAP é enviado para o processo e o depurador se encarrega de dar o controle da execução para o usuário. Quando o usuário continua a execução o depurador restaura o byte original da instrução, executa ela e coloca o byte 0xCC novamente.
Em arquiteturas que não têm uma exceção específica para disparar breakpoints os depuradores substituem a instrução por alguma outra instrução que disparará alguma exceção. Como uma instrução de divisão ilegal por exemplo.
Podemos comprovar isso com o seguinte código:
#include <stdio.h>
#include <signal.h>
‚Äč
void breakpoint(int signum)
{
puts("Breakpoint!");
}
‚Äč
int main(void)
{
struct sigaction action = {
.sa_handler = breakpoint,
};
‚Äč
sigaction(SIGTRAP, &action, NULL);
‚Äč
asm("int3");
puts("...");
‚Äč
return 0;
}
Ao executar a instrução int3 inserida com inline Assembly na linha 17, o processo recebe o sinal SIGTRAP e nosso tratador é executado. Experimente comentar a chamada para sigaction na linha 15 para ver o resultado do tratador padrão.

Hardware/Software breakpoint

O termo software breakpoint é usado para se referir a um breakpoint que é definido e configurado por software (o depurador), como o que já foi descrito acima. Por exemplo breakpoints podem ter uma condição de parada e isso é implementado pelo próprio depurador. Ele faz o tratamento do breakpoint normalmente mas antes verifica a condição, se a condição não for atendida ele continua a execução do código como se o breakpoint nunca tivesse acontecido.
Já o termo hardware breakpoint é usado para se referir a um breakpoint que é suportado pelo próprio processador. A arquitetura x86-64 tem 8 registradores de depuração (debug registers) onde 4 deles podem ser usados para indicar breakpoints.
Os registradores DR0, DR1, DR2 e DR3 armazenam o endereço onde irá ocorrer o breakpoint. Já o registrador DR7 habilita ou desabilita esses breakpoints e configura uma condição para eles. Onde a condição determina em qual ocasião o breakpoint será disparado, como por exemplo ao ler/escrever naquele endereço ou ao executar a instrução no endereço.
Quando a condição do breakpoint é atendida o processador dispara uma exceção #BP.
Os debug registers não podem ser lidos/modificados sem privilégios de kernel. Rodando sobre um sistema operacional um processo comum não é capaz de manipulá-los diretamente.
Esse mesmo recurso (com at√© mais recursos ainda) poderia ser implementado pelo depurador com um software breakpoint. Por exemplo caso o depurador queira que um breakpoint seja disparado ao ler/escrever em um determinado endere√ßo o depurador pode simplesmente modificar as permiss√Ķes de acesso daquele endere√ßo e, quando o processo fosse acessar os dados naquele endere√ßo, uma exce√ß√£o #GP seria disparada e o depurador poderia retomar o controle da execu√ß√£o.

Execução passo a passo

Depuradores n√£o s√£o apenas capazes de executar o software e esperar por um breakpoint para retomar o controle. Eles podem tamb√©m executar apenas uma instru√ß√£o da thread por vez e permanecer controlando a execu√ß√£o. Isso √© chamado de execu√ß√£o passo a passo (step by step), onde o "passo" √© uma √ļnica instru√ß√£o. O usu√°rio do depurador pode clicar em um bot√£o ou executar um comando e apenas uma instru√ß√£o do processo ser√° executada, e o usu√°rio pode ver o resultado da instru√ß√£o e optar pelo que fazer em seguida.
Isso é implementado na arquitetura x86-64 usando a trap flag (TF) no registrador EFLAGS. Quando a TF está ligada cada instrução executada dispara uma exceção #BP, permitindo assim que o depurador retome o controle após executar uma instrução.
Existe tamb√©m o conceito de step over que √© quando o depurador executa apenas "uma instru√ß√£o" por√©m passando todas as instru√ß√Ķes da rotina chamada pelo CALL. O que ele faz na pr√°tica √© definir um breakpoint tempor√°rio para a instru√ß√£o seguinte ao CALL, como na ilustra√ß√£o:
mov rdi, 5 ; Última instrução executada
--> call anything ; CALL que iremos "passar por cima"
test rax, rax ; Instrução onde o breakpoint será definido
Se o depurador estiver parado no CALL e executamos um step over, o depurador coloca o breakpoint temporário na instrução TEST e então irá executar o processo. Quando o breakpoint na instrução TEST for alcançado ele será removido e o controle será dado para o usuário.
Repare no "defeito" desse mecanismo. O step over só funciona apropriadamente se a instrução seguinte ao CALL realmente for executada, senão o processo continuará a execução normalmente. Experimente rodar o seguinte código em um depurador:
testing.asm
bits 64
default rel
‚Äč
SYS_EXIT equ 60
‚Äč
section .text
‚Äč
global _start
_start:
call oops
nop
xor rdi, rdi
mov rax, SYS_EXIT
syscall
‚Äč
oops:
add qword [rsp], 1
ret
Compile com:
$ nasm testing.asm -o testing.o -felf64
$ ld testing.o -o testing
Ao dar um step over na chamada call oops um comportamento inesperado ocorre, o programa irá finalizar sem parar após o retorno da chamada. Isso é demonstrado na imagem abaixo com o depurador GDB:
Saída do depurador GDB

Informa√ß√Ķes de depura√ß√£o do execut√°vel

Muitos depuradores voltados para desenvolvedores leem informa√ß√Ķes de depura√ß√£o √† respeito do execut√°vel produzidas pelo pr√≥prio compilador. O compilador pode, por exemplo, dar informa√ß√Ķes para que o depurador seja capaz de identificar de qual arquivo e linha do c√≥digo-fonte uma instru√ß√£o pertence.
√Č assim que funcionam os depuradores que exibem o c√≥digo-fonte (ao inv√©s de apenas as instru√ß√Ķes em Assembly) enquanto executam o processo.
No caso do GCC ele armazena essas informa√ß√Ķes dentro do pr√≥prio execut√°vel na tabela de s√≠mbolos. J√° o compilador da Microsoft, usado no Visual Studio, atualmente gera um arquivo .pdb contendo todas as informa√ß√Ķes de depura√ß√£o.
Vale ressaltar aqui que o GCC (e qualquer outro compilador) não armazena o código-fonte do projeto dentro do executável. Ele meramente armazena o endereço do arquivo lá.
√Č comum tamb√©m que depuradores apresentem algum erro ao n√£o encontrar o arquivo-fonte indicado no endere√ßo armazenado nas informa√ß√Ķes de depura√ß√£o. Isso acontece quando ele tenta apresentar uma linha de c√≥digo naquele arquivo mas o mesmo n√£o foi encontrado na sua m√°quina.
Export as PDF
Copy link
On this page
Processos
Context switch
Sinais
Exce√ß√Ķes
Depuradores
Breakpoint
Hardware/Software breakpoint
Execução passo a passo
Informa√ß√Ķes de depura√ß√£o do execut√°vel