Como o depurador GDB e outras ferramentas usam informações de quadro de chamada para determinar as chamadas de função ativas
Obtenha a chamada de função ativa do seu depurador.
Em meu artigo anterior, mostrei como o debuginfo é usado para mapear entre o ponteiro de instrução (IP) atual e a função ou linha que o contém. Essa informação é valiosa para mostrar qual código o processador está executando no momento. No entanto, ter mais contexto para as chamadas que levam à função e linha atuais que estão sendo executadas também é extremamente útil.
Por exemplo, suponha que uma função em uma biblioteca tenha acesso ilegal à memória devido a um ponteiro nulo sendo passado como parâmetro para a função. Basta olhar para a função e a linha atuais para mostrar que a falha foi acionada por tentativa de acesso por meio de um ponteiro nulo. No entanto, o que você realmente deseja saber é o contexto completo das chamadas de função ativas que levam ao acesso ao ponteiro nulo, para que você possa determinar como esse ponteiro nulo foi inicialmente passado para a função da biblioteca. Essas informações de contexto são fornecidas por um backtrace e permitem determinar quais funções podem ser responsáveis pelo parâmetro falso.
Uma coisa é certa: determinar as chamadas de função atualmente ativas não é uma operação trivial.
Registros de ativação de função
As linguagens de programação modernas possuem variáveis locais e permitem recursão onde uma função pode chamar a si mesma. Além disso, programas simultâneos possuem vários threads que podem ter a mesma função em execução ao mesmo tempo. As variáveis locais não podem ser armazenadas em locais globais nestas situações. As localizações das variáveis locais devem ser exclusivas para cada invocação da função. Veja como funciona:
- O compilador produz um registro de ativação de função cada vez que uma função é chamada para armazenar variáveis locais em um local exclusivo.
- Para maior eficiência, a pilha do processador é usada para armazenar os registros de ativação da função.
- Um novo registro de ativação de função é criado no topo da pilha do processador para a função quando ela é chamada.
- Se essa função chamar outra função, um novo registro de ativação de função será colocado acima do registro de ativação de função existente.
- Cada vez que há um retorno de uma função, seu registro de ativação de função é removido da pilha.
A criação do registro de ativação da função é criada pelo código da função denominado prólogo. A remoção do registro de ativação da função é feita pelo epílogo da função. O corpo da função pode fazer uso da memória reservada na pilha para valores temporários e variáveis locais.
Os registros de ativação de função podem ter tamanho variável. Para algumas funções, não há necessidade de espaço para armazenar variáveis locais. Idealmente, o registro de ativação da função só precisa armazenar o endereço de retorno da função que chamou esta função. Para outras funções, pode ser necessário um espaço significativo para armazenar estruturas de dados locais para a função, além do endereço de retorno. Essa variação nos tamanhos dos quadros faz com que os compiladores usem ponteiros de quadro para rastrear o início do quadro de ativação da função. Agora, o código do prólogo da função tem a tarefa adicional de armazenar o ponteiro do quadro antigo antes de criar um novo ponteiro do quadro para a função atual, e o epílogo deve restaurar o valor do ponteiro do quadro antigo.
Da forma como o registro de ativação da função é apresentado, o endereço de retorno e o ponteiro do quadro antigo da função de chamada são deslocamentos constantes do ponteiro do quadro atual. Com o ponteiro do quadro antigo, o quadro de ativação da próxima função na pilha pode ser localizado. Este processo é repetido até que todos os registros de ativação da função tenham sido examinados.
Complicações de otimização
Existem algumas desvantagens em ter ponteiros de quadro explícitos no código. Em alguns processadores, existem relativamente poucos registros disponíveis. Ter um ponteiro de quadro explícito faz com que mais operações de memória sejam usadas. O código resultante é mais lento porque o ponteiro do quadro deve estar em um dos registradores. Ter ponteiros de quadro explícitos pode restringir o código que o compilador pode gerar, porque o compilador não pode misturar o código do prólogo e do epílogo da função com o corpo da função.
O objetivo do compilador é gerar código rápido sempre que possível, portanto, os compiladores normalmente omitem ponteiros de quadro do código gerado. Manter os ponteiros do quadro pode reduzir significativamente o desempenho, conforme mostrado pelo benchmarking da Phoronix. A desvantagem de omitir ponteiros de quadro é que encontrar o quadro de ativação e o endereço de retorno da função de chamada anterior não são mais simples deslocamentos do ponteiro de quadro.
Informações do quadro de chamada
Para auxiliar na geração de backtraces de função, o compilador inclui DWARF Call Frame Information (CFI) para reconstruir ponteiros de quadro e encontrar endereços de retorno. Essas informações suplementares são armazenadas na seção .eh_frame
da execução. Ao contrário do debuginfo tradicional para informações de função e localização de linha, a seção .eh_frame
está no executável mesmo quando o executável é gerado sem informações de depuração ou quando as informações de depuração foram removidas do arquivo. As informações do quadro de chamada são essenciais para a operação de construções de linguagem como throw-catch em C++.
O CFI possui uma entrada de descrição de quadro (FDE) para cada função. Como uma de suas etapas, o processo de geração de backtrace encontra o FDE apropriado para o quadro de ativação atual que está sendo examinado. Pense no FDE como uma tabela, com cada linha representando uma ou mais instruções, com estas colunas:
- Endereço de quadro canônico (CFA), o local para o qual o ponteiro do quadro apontaria
- O endereço de retorno
- Informações sobre outros registros
A codificação do FDE foi projetada para minimizar a quantidade de espaço necessária. O FDE descreve as alterações entre as linhas em vez de especificar completamente cada linha. Para compactar ainda mais os dados, as informações iniciais comuns a vários FDEs são fatoradas e colocadas em Entradas de Informações Comuns (CIE). Isso torna o FDE mais compacto, mas também requer mais trabalho para calcular o CFA real e encontrar a localização do endereço de retorno. A ferramenta deve iniciar do estado não inicializado. Ele percorre as entradas no CIE para obter o estado inicial na entrada da função, depois processa o FDE começando na primeira entrada do FDE e processa as operações até chegar à linha que cobre o ponteiro de instrução que está sendo analisado no momento. .
Exemplo de uso de informações de quadro de chamada
Comece com um exemplo simples com uma função que converte Fahrenheit em Celsius. Funções embutidas não possuem entradas no CFI, então o __attribute__((noinline))
para a função f2c
garante que o compilador mantenha f2c
como um função real.
#include <stdio.h>
int __attribute__ ((noinline)) f2c(int f)
{
int c;
printf("converting\n");
c = (f-32.0) * 5.0 /9.0;
return c;
}
int main (int argc, char *argv[])
{
int f;
scanf("%d", &f);
printf ("%d Fahrenheit = %d Celsius\n",
f, f2c(f));
return 0;
}
Compile o código com:
$ gcc -O2 -g -o f2c f2c.c
O .eh_frame
está lá conforme esperado:
$ eu-readelf -S f2c |grep eh_frame
[17] .eh_frame_hdr PROGBITS 0000000000402058 00002058 00000034 0 A 0 0 4
[18] .eh_frame PROGBITS 0000000000402090 00002090 000000a0 0 A 0 0 8
Podemos obter as informações financeiras em formato legível por humanos com:
$ readelf --debug-dump=frames f2c > f2c.cfi
Gere um arquivo de desmontagem do binário f2c
para que você possa procurar os endereços das funções f2c
e main
:
$ objdump -d f2c > f2c.dis
Encontre as seguintes linhas em f2c.dis
para ver o início de f2c
e main
:
0000000000401060 <main>:
0000000000401190 <f2c>:
Em muitos casos, todas as funções no binário usam o mesmo CIE para definir as condições iniciais antes que a primeira instrução de uma função seja executada. Neste exemplo, tanto f2c
quanto main
usam o seguinte CIE:
00000000 0000000000000014 00000000 CIE
Version: 1
Augmentation: "zR"
Code alignment factor: 1
Data alignment factor: -8
Return address column: 16
Augmentation data: 1b
DW_CFA_def_cfa: r7 (rsp) ofs 8
DW_CFA_offset: r16 (rip) at cfa-8
DW_CFA_nop
DW_CFA_nop
Neste exemplo, não se preocupe com as entradas de dados de Aumento ou Aumento. Como os processadores x86_64 possuem instruções de comprimento variável de 1 a 15 bytes de tamanho, o “fator de alinhamento de código” é definido como 1. Em um processador que possui apenas 32 bits (instruções de 4 bytes), isso seria definido como 4 e permitiria codificação mais compacta de quantos bytes uma linha de informações de estado se aplica. De forma semelhante, existe o “fator de alinhamento de dados” para tornar mais compactos os ajustes de localização do CFA. No x86_64, os slots de pilha têm 8 bytes de tamanho.
A coluna na tabela virtual que contém o endereço de retorno é 16. Isso é usado nas instruções no final do CIE. Existem quatro instruções DW_CFA
. A primeira instrução, DW_CFA_def_cfa
descreve como calcular o Canonical Frame Address (CFA) para o qual um ponteiro de quadro apontaria se o código tivesse um ponteiro de quadro. Neste caso, o CFA é calculado a partir de r7 (rsp)
e CFA=rsp+8
.
A segunda instrução DW_CFA_offset
define onde obter o endereço de retorno CFA-8
. Neste caso, o endereço de retorno é atualmente apontado pelo ponteiro de pilha (rsp+8)-8
. O CFA começa logo acima do endereço de retorno na pilha.
O DW_CFA_nop
no final do CIE é preenchido para manter o alinhamento nas informações DWARF. O FDE também pode ter preenchimento no final do alinhamento.
Encontre o FDE para main
em f2c.cfi
, que cobre a função main
de 0x40160
até, mas não incluindo, 0x401097
:
00000084 0000000000000014 00000088 FDE cie=00000000 pc=0000000000401060..0000000000401097
DW_CFA_advance_loc: 4 to 0000000000401064
DW_CFA_def_cfa_offset: 32
DW_CFA_advance_loc: 50 to 0000000000401096
DW_CFA_def_cfa_offset: 8
DW_CFA_nop
Antes de executar a primeira instrução da função, o CIE descreve o estado do quadro de chamada. Porém, à medida que o processador executa instruções na função, os detalhes mudam. Primeiro, as instruções DW_CFA_advance_loc
e DW_CFA_def_cfa_offset
correspondem à primeira instrução em main
em 401060
. Isso ajusta o ponteiro da pilha para baixo em 0x18
(24 bytes). O CFA não mudou de localização, mas o ponteiro da pilha mudou, então o cálculo correto para CFA em 401064
é rsp+32
. Essa é a extensão da instrução do prólogo neste código. Aqui estão as primeiras instruções em main
:
0000000000401060 <main>:
401060: 48 83 ec 18 sub $0x18,%rsp
401064: bf 1b 20 40 00 mov $0x40201b,%edi
O DW_CFA_advance_loc
faz com que a linha atual se aplique aos próximos 50 bytes de código na função, até 401096
. O CFA está em rsp+32
até que a instrução de ajuste de pilha em 401092
conclua a execução. O DW_CFA_def_cfa_offset
atualiza os cálculos do CFA para os mesmos da entrada na função. Isso é esperado, porque a próxima instrução em 401096
é a instrução de retorno (ret
) e retira o valor de retorno da pilha.
401090: 31 c0 xor %eax,%eax
401092: 48 83 c4 18 add $0x18,%rsp
401096: c3 ret
Este FDE para a função f2c
usa o mesmo CIE que a função main
e cobre o intervalo de 0x41190
a 0x4011c3
:
00000068 0000000000000018 0000006c FDE cie=00000000 pc=0000000000401190..00000000004011c3
DW_CFA_advance_loc: 1 to 0000000000401191
DW_CFA_def_cfa_offset: 16
DW_CFA_offset: r3 (rbx) at cfa-16
DW_CFA_advance_loc: 29 to 00000000004011ae
DW_CFA_def_cfa_offset: 8
DW_CFA_nop
DW_CFA_nop
DW_CFA_nop
A saída objdump
para a função f2c
no binário:
0000000000401190 <f2c>:
401190: 53 push %rbx
401191: 89 fb mov %edi,%ebx
401193: bf 10 20 40 00 mov $0x402010,%edi
401198: e8 93 fe ff ff call 401030 <puts@plt>
40119d: 66 0f ef c0 pxor %xmm0,%xmm0
4011a1: f2 0f 2a c3 cvtsi2sd %ebx,%xmm0
4011a5: f2 0f 5c 05 93 0e 00 subsd 0xe93(%rip),%xmm0 # 402040 <__dso_handle+0x38>
4011ac: 00
4011ad: 5b pop %rbx
4011ae: f2 0f 59 05 92 0e 00 mulsd 0xe92(%rip),%xmm0 # 402048 <__dso_handle+0x40>
4011b5: 00
4011b6: f2 0f 5e 05 92 0e 00 divsd 0xe92(%rip),%xmm0 # 402050 <__dso_handle+0x48>
4011bd: 00
4011be: f2 0f 2c c0 cvttsd2si %xmm0,%eax
4011c2: c3 ret
No FDE para f2c
, há uma instrução de byte único no início da função com o DW_CFA_advance_loc
. Após a operação avançada, existem duas operações adicionais. Um DW_CFA_def_cfa_offset
altera o CFA para %rsp+16
e um DW_CFA_offset
indica que o valor inicial em %rbx
é agora em CFA-16
(o topo da pilha).
Olhando para este código de desmontagem do fc2
, você pode ver que um push
é usado para salvar %rbx
na pilha. Uma das vantagens de omitir o ponteiro de quadro na geração do código é que instruções compactas como push
e pop
podem ser usadas para armazenar e recuperar valores da pilha. Neste caso, %rbx
é salvo porque %rbx
é usado para passar argumentos para a função printf
(na verdade convertido em um puts
), mas o valor inicial de f
passado para a função precisa ser salvo para o cálculo posterior. O DW_CFA_advance_loc
29 bytes para 4011ae
mostra a próxima mudança de estado logo após pop %rbx
, que recupera o valor original de %rbx
. O DW_CFA_def_cfa_offset
observa que o pop mudou o CFA para %rsp+8
.
GDB usando as informações do quadro de chamada
Ter as informações CFI permite que o GNU Debugger (GDB) e outras ferramentas gerem backtraces precisos. Sem informações financeiras, o GDB teria dificuldade em encontrar o endereço do remetente. Você pode ver o GDB fazendo uso dessas informações se definir um ponto de interrupção na linha 7 de f2c.c
. GDB coloca o ponto de interrupção antes que o pop %rbx
na função f2c
seja concluído e o valor de retorno não esteja no topo da pilha.
O GDB é capaz de desenrolar a pilha e, como bônus, também é capaz de buscar o argumento f
que estava atualmente salvo na pilha:
$ gdb f2c
[...]
(gdb) break f2c.c:7
Breakpoint 1 at 0x40119d: file f2c.c, line 7.
(gdb) run
Starting program: /home/wcohen/present/202207youarehere/f2c
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
98
converting
Breakpoint 1, f2c (f=98) at f2c.c:8
8 return c;
(gdb) where
#0 f2c (f=98) at f2c.c:8
#1 0x000000000040107e in main (argc=<optimized out>, argv=<optimized out>)
at f2c.c:15
Informações do quadro de chamada
As informações do quadro de chamada DWARF fornecem uma maneira flexível para um compilador incluir informações para desenrolar com precisão a pilha. Isto torna possível determinar as chamadas de função atualmente ativas. Forneci uma breve introdução neste artigo, mas para obter mais detalhes sobre como o DWARF implementa esse mecanismo, consulte a especificação DWARF.