25 KiB
5 – Pilha de hardware e pilha de memória
- Objetivos
- O conceito de pilha (stack)
- Pilha de memória
- Pilha de hardware
- Registradores e a pilha de memória
- Convenções de chamadas de funções (System V AMD64 ABI)
- Convenções de chamadas de sistema
- Exercícios propostos
- Referências
Objetivos
- Entender o que é uma pilha.
- Diferenciar pilha de hardware da pilha de memória.
- Explorar a movimentação do ponteiro da pilha.
- Observar a dinâmica dos quadros de pilha.
- Observar como a pilha se altera com a execução de sub-rotinas.
- Utilizar a pilha para salvar e restaurar estados de registradores.
- Escrever funções simples que manipulam a pilha explicitamente.
O conceito de pilha (stack)
Pilhas são estruturas lineares de dados que seguem a política LIFO (Last In, First Out), ou seja, o último elemento inserido é o primeiro a ser removido.
┌──────────────┐ TOPO DA PILHA │ ELEMENTO N │ ├──────────────┤ │ ELEMENTO N-1 │ ├──────────────┤ │ ···· │ ├──────────────┤ │ ELEMENTO 3 │ ├──────────────┤ │ ELEMENTO 2 │ ├──────────────┤ │ ELEMENTO 1 │ └──────────────┘ BASE DA PILHA
Por analogia, podemos imaginar uma pilha de pratos: os pratos são colocados um por cima do outro e, quando precisamos de um prato no meio da pilha, todos que estiverem acima dele terão que ser retirados primeiro. Isso ilustra bem a principal característica da estrutura, que é o acesso imediato restrito ao elemento que estiver no topo da pilha.
Implementação da pilha como estrutura de dados
Uma pilha pode ser implementada de duas formas principais: utilizando uma área contígua de memória (como um vetor) ou uma estrutura que cresce conforme a necessidade (como uma sequência de elementos ligados uns aos outros).
- Como um vetor
- A pilha é representada por um arranjo fixo de elementos e um índice que identifica o elemento que estiver no topo. Essa abordagem é simples e eficiente, mas exige que o tamanho máximo da pilha seja definido previamente.
- Como uma lista encadeada
- Os elementos da pilha são criados conforme a necessidade, ligando cada novo elemento ao que já estiver ocupando a posição do topo. Assim, a pilha pode crescer ou diminuir dinamicamente, utilizando apenas o espaço de memória realmente necessário.
Aplicações típicas
As pilhas são amplamente utilizadas por serem simples e pela sua propriedade de oferecerem acesso aos dados na ordem inversa de sua inserção. Algumas aplicações comuns incluem:
- Avaliação de expressões em notação pós-fixada (notação polonesa reversa).
- Operações de desfazer e refazer em editores.
- Verificação do balanceamento de estruturas aninhadas (parêntesis, chaves e colchetes).
- Algoritmos de busca e retrocesso (backtracking), como para explorar labirintos ou solucionar problemas combinatórios por tentativa e erro.
- Controle de chamadas de funções durante a execução de um programa.
- Tratamento de interrupções de hardware e chamadas de funções e sub-rotinas.
Entre tantas outras. No entanto, as aplicações que nos interessam dizem respeito às duas últimas da lista, como veremos um pouco mais adiante.
Operações associadas às pilhas
De modo geral, as operações associadas a uma pilha são:
- Push: para inserir um novo elemento no topo da pilha;
- Pop: para acessar o elemento do topo da pilha e removê-lo;
- Peek: para acessar o elemento no topo da pilha sem removê-lo.
Operação PUSH:
TOPO DA PILHA (novo) ┌──────┐ TOPO DA PILHA │ 23 │ ┌──────┐ ├──────┤ │ 64 │ │ 64 │ ├──────┤ ├──────┤ │ 42 │ PUSH 23 │ 42 │ ├──────┤ ├──────┤ │ 16 │ │ 16 │ └──────┘ └──────┘ BASE DA PILHA BASE DA PILHA
Operação POP:
TOPO DA PILHA ┌──────┐ │ 23 │ TOPO DA PILHA (removido) ├──────┤ ┌──────┐ │ 64 │ A = ??? │ 64 │ A = 23 ├──────┤ ├──────┤ │ 42 │ POP A │ 42 │ ├──────┤ ├──────┤ │ 16 │ │ 16 │ └──────┘ └──────┘ BASE DA PILHA BASE DA PILHA
Operação PEEK:
TOPO DA PILHA TOPO DA PILHA (não alterado) ┌──────┐ ┌──────┐ │ 23 │ │ 23 │ ├──────┤ ├──────┤ │ 64 │ A = ??? │ 64 │ A = 23 ├──────┤ ├──────┤ │ 42 │ PEEK A │ 42 │ ├──────┤ ├──────┤ │ 16 │ │ 16 │ └──────┘ └──────┘ BASE DA PILHA BASE DA PILHA
Pilha de memória
No contexto de sistemas GNU/Linux, a pilha de memória é uma região especial da memória virtual associada a um processo que cresce dinamicamente no sentido dos endereços mais baixo. Essa estrutura é usada para armazenar dados temporários durante a execução de um programa, especialmente:
- Endereços de retorno de chamadas de função.
- Valores recebidos pelas funções como argumentos.
- Dados em variáveis locais de funções.
- Dados nos registradores da CPU (registros) que foram salvos durante chamadas e interrupções.
A pilha de memória é controlada pelo hardware e pelo sistema operacional, e seu
comportamento segue a política LIFO, como em toda estrutura de pilha: o último
valor inserido é o primeiro a ser removido. Além disso, em arquiteturas x86_64,
a pilha é acessada principalmente pelos registradores rsp
(stack pointer) e
rbp
(base pointer), e seu uso está profundamente ligado à organização de
chamadas de função e à convenção de chamadas da ABI do sistema.
Pilha de hardware
Além da pilha de memória, gerenciada pelo sistema e pelas convenções de chamadas de função, o processador possui um mecanismo chamado pilha de hardware. Trata-se de um recurso interno da CPU que suporta operações de empilhar (push) e desempilhar (pop) valores diretamente, facilitando o gerenciamento da pilha de memória no nível do hardware.
Em processadores Intel 64, as instruções push
e pop
operam sobre a pilha de
memória associada ao registrador rsp
(stack pointer), que contém o endereço
do dado que se encontra no topo da pilha.
Quando a instrução push
é executada, o processador decrementa o valor em rsp
(o endereço do dado no topo da pilha) e, em seguida, armazena o novo dado no
endereço resultante. Já a instrução pop
realiza o oposto: ela lê o valor no
endereço atualmente indicado em rsp
, copia esse valor para um registrador ou
para outro local na memória, e então incrementa o endereço em rsp
.
Resumindo:
- Instrução
push
: Decrementa do endereço emrsp
e insere um novo valor no topo da pilha (a pilha de memória cresce no sentido dos endereços mais baixos).
Exemplo:
push rax ; Copia o valor em rax para um novo
; elemento no topo da pilha.
- Instrução
pop
: Copia o dado no topo da pilha para um destino e incrementa o endereço emrsp
, o que faz com que o dado no endereço anterior seja ignorado (e eventualmente sobrescrito) em operações subsequentes.
Exemplo:
pop rax ; Copia para rax o valor que será
; removido do topo da pilha.
A implementação em hardware torna operações de pilha muito eficientes, e é uma das razões pelas quais a pilha de memória é utilizada para controle de fluxo, armazenamento temporário de dados, e manipulação de chamadas de função. Mas, embora a CPU tenha suporte para operações com a pilha, sua manipulação depende também do sistema operacional, da ABI e do compilador, que definem regras para uso, alinhamento e preservação de seus dados.
Registradores e a pilha de memória
Como vimos, registradores são pequenas unidades de armazenamento internas da CPU para guardar valores temporários. Eles são utilizados constantemente durante a execução de programas, pois permitem a manipulação de dados sem depender da memória principal (RAM), que é bem mais lenta.
Antes da execução de funções e sub-rotinas, é comum que o conteúdo de alguns desses registradores precise ser preservado para que, ao retomar o controle da sua execução, o programa encontre os mesmos valores que estavam presentes antes da chamada. Para isso, os valores dos registradores costumam ser salvos na pilha de memória e restaurados posteriormente.
A especificação de quais registradores precisam ser salvos depende do contexto e, principalmente, das convenções envolvidas, como veremos a seguir.
Convenções de chamadas de funções (System V AMD64 ABI)
Ao compilar programas em linguagens como C para sistemas Linux 64 bits, o código gerado segue uma convenção de chamadas definida pela ABI (Application Binary Interface) do sistema. Essa convenção especifica:
- Quais registradores são usados para passar argumentos.
- Qual registrador é usado para o valor de retorno.
- Quais registradores devem ser preservados entre chamadas de função.
- Como a pilha deve ser utilizada.
Ordem dos argumentos
Os primeiros seis argumentos de uma função são passados em registradores, na seguinte ordem:
Argumento | Registrador |
---|---|
arg1 |
RDI |
arg2 |
RSI |
arg3 |
RDX |
arg4 |
RCX |
arg5 |
R8 |
arg6 |
R9 |
Argumentos adicionais são passados na pilha.
Valor de retorno
O valor de retorno de uma função é armazenado em:
RAX
: para valores inteiros ou ponteiros.RAX + RDX
: para estruturas de até 128 bits.XMM0
(e seguintes): para valores em ponto flutuante.
Registradores preservados e não preservados
Durante uma chamada de função, há uma distinção entre os registradores que devem ser preservados e os que podem ser sobrescritos…
Preservados pela função chamada (callee-saved):
RBX
RBP
R12
aR15
RSP
também deve ser restaurado à posição original.
Não presenrvados (caller-saved):
RAX
RCX
RDX
RSI
RDI
R8
aR11
Os registradores não preservados podem ser modificados pela função chamada. Assim, se uma função chamadora deseja manter o valor de algum deles após a chamada, ela será responsável por salvá-los antes.
Exemplo em C
Arquivo: soma.c
#include <stdio.h>
int soma(int a, int b) {
return a + b;
}
int main() {
int x = 10;
int y = 20;
int r = soma(x, y);
printf("Soma: %d\n", r);
return 0;
}
Durante a execução de soma(x, y)
, os valores de x
e y
são passados via RDI
e RSI
, respectivamente. O resultado é retornado em RAX
. Se a função soma
quiser usar, por exemplo, RBX
, ela deverá salvá-lo na pilha no início da
função (com push rbx
) e restaurá-lo (com pop rbx
) antes de retornar.
Quadro de pilha (stack frame)
Em geral, cada função chamada cria seu próprio quadro de pilha, que inclui:
- Valores salvos de registradores preservados.
- Variáveis locais.
- Espaço para armazenar argumentos extras ou retorno de funções chamadas.
A base do quadro costuma ser registrada em RBP
, como podemos demonstrar
compilando o programa do exemplo e analisando o assembly desmontado pelo
GDB.
Compilação:
:~$ gcc -O0 -no-pie -g -o soma soma.c
Aqui, nós desabilitamos as otimizações (-O0
) e a geração de um executável
independente de posição (-no-pie
) para facilitar a análise dos endereços
absolutos das instruções.
Início do GDB:
:~$ gdb soma Reading symbols from soma...
Desmontagem da função soma
:
(gdb) disassemble soma Dump of assembler code for function soma: 0x0000000000401126 <+0>: push rbp 0x0000000000401127 <+1>: mov rbp,rsp 0x000000000040112a <+4>: mov DWORD PTR [rbp-0x4],edi 0x000000000040112d <+7>: mov DWORD PTR [rbp-0x8],esi 0x0000000000401130 <+10>: mov edx,DWORD PTR [rbp-0x4] 0x0000000000401133 <+13>: mov eax,DWORD PTR [rbp-0x8] 0x0000000000401136 <+16>: add eax,edx 0x0000000000401138 <+18>: pop rbp 0x0000000000401139 <+19>: ret End of assembler dump.
Logo nas duas primeiras instruções, nós podemos ver a inicialização do quadro de pilha:
0x0000000000401126 <+0>: push rbp 0x0000000000401127 <+1>: mov rbp,rsp
Onde o endereço da base da pilha (em rbp
) é inserido como um novo elemento no
topo da pilha. Em seguida, o novo endereço do topo da pilha (em rsp
) é
copiado (em rbp
) como o endereço da base da pilha, utilizado nas instruções
seguintes como referencial fixo para a inserção dos dados da função.
Antes do retorno (ret
), o endereço do topo da pilha é decrementado e o
resultado, salvo em rbp
, será o endereço que ele armazenava originalmente –
em outras palavras, o estado original de rbp
é restaurado.
0x0000000000401138 <+18>: pop rbp 0x0000000000401139 <+19>: ret
Repare também que, entre o início e a remoção do quadro de pilha, os
argumentos passados na chamada da função (registrados em edi
e esi
) são
copiados em endereços, respectivamente, 4 e 8 bytes mais baixos que o
endereço em rbp
:
0x000000000040112a <+4>: mov DWORD PTR [rbp-0x4],edi 0x000000000040112d <+7>: mov DWORD PTR [rbp-0x8],esi
Deste modo, os dados são inseridos na pilha sem que novos elementos sejam
empilhados, ou seja, para todos os efeitos, o endereço do topo da pilha ainda
será o registrado em rbp
.
Convenções de chamadas de sistema
Diferente das chamadas de função ,que utilizam a pilha de memória para armazenar argumentos e preservar o estado dos registradores, chamadas de sistema interagem diretamente com o kernel e seguem uma convenção distinta, mais enxuta e orientada ao uso de registradores.
Em sistemas Linux x86-64, a transição entre espaço de usuário e espaço do
kernel é feita por meio da instrução syscall
. Os argumentos são passados
exclusivamente por registradores:
Registrador | Conteúdo |
---|---|
rax |
Número da chamada de sistema |
rdi |
1º argumento |
rsi |
2º argumento |
rdx |
3º argumento |
r10 |
4º argumento |
r8 |
5º argumento |
r9 |
6º argumento |
rax |
Valor de retorno |
A pilha permanece intacta durante a chamada de sistema — ou seja, não há
necessidade de alocar um novo quadro de pilha nem de salvar/restaurar
registradores preservados, como ocorre nas convenções de chamadas entre
funções. Em vez disso, o kernel assume que os registradores não preservados
(rax
, rcx
e r11
) podem ser sobrescritos e o processo que realiza a chamada
é que deve se encarregar de salvar qualquer valor que deseje manter.
Essa característica destaca o papel dos registradores temporários: eles são
utilizados livremente pela chamada de sistema, e qualquer valor importante
deve ser salvo na pilha pelo chamador se necessário, o que geralmente é feito
com push <registrador>
, antes da chamada de sistema, e =pop <registrador> após
a chamada.
Exemplo em Assembly
Arquivo: salve.asm
section .rodata
msg db "Salve, simpatia!", 10
len equ $ - msg
section .text
global _start
_start:
mov rax, 1 ; syscall write
mov rdi, 1 ; stdout
mov rsi, msg
mov rdx, len
syscall
mov rax, 60 ; syscall exit
mov rdi, 0 ; código de saída 0
syscall
Com este exemplo, nós queremos demonstrar que, nas chamadas de sistema:
- Nenhum dado é empilhado.
- Nenhum registrador é preservado.
- O controle é transferido diretamente ao kernel por meio de
syscall
. - Após o retorno do kernel, o processo continua sua execução ou é finalizado.
Para isso, vamos montá-lo e abrir o executável com o GDB:
:~$ nasm -f elf64 -g salve.asm :~$ ld -o salve salve.o :~$ gdb salve Reading symbols from salve... (gdb)
Com o comando list
, nós descobrimos que a instrução que processará a chamada
de sistema write
(sycall
) está na linha 13:
(gdb) list 1 section .rodata 2 msg db "Salve, simpatia!", 10 3 len equ $ - msg 4 5 section .text 6 global _start 7 8 _start: 9 mov rax, 1 ; syscall write 10 mov rdi, 1 ; stdout (gdb) 11 mov rsi, msg 12 mov rdx, len 13 syscall 14 15 mov rax, 60 ; syscall exit 16 mov rdi, 0 ; código de saída 0 17 syscall
Sendo assim, ela será o nosso ponto de parada:
(gdb) break 13 Breakpoint 1 at 0x401019: file salve.asm, line 13.
Nosso objetivo é comparar o estado dos registradores antes e depois da execução do ponto de parada para determinar o que mudou. Então, vamos executar o programa e inspecionar o estado corrente dos registradores:
(gdb) run Starting program: /home/blau/git/pbn/curso/exemplos/05/salve Breakpoint 1, _start () at salve.asm:13 13 syscall (gdb) info registers rax 0x1 1 rbx 0x0 0 rcx 0x0 0 rdx 0x11 17 rsi 0x402000 4202496 rdi 0x1 1 rbp 0x0 0x0 rsp 0x7fffffffdfd0 0x7fffffffdfd0 r8 0x0 0 r9 0x0 0 r10 0x0 0 r11 0x0 0 r12 0x0 0 r13 0x0 0 r14 0x0 0 r15 0x0 0 rip 0x401019 0x401019 <_start+25> eflags 0x202 [ IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0 fs_base 0x0 0 gs_base 0x0 0
Note que, até aqui, nós utilizamos apenas os registradores…
RAX
: Identificação da chamada de sistemawrite
(0x1
);RDX
: Quantidade de bytes a serem impressos (0x11 = 17
);RSI
: O endereço do primeiro byte da mensagem (0x402000
);RDI
: O número do descritor de arquivos (0x1 = stdout
).
Todos os demais estão em seus estados originais, menos RIP
, o ponteiro de
instruções, que sempre é atualizado para indicar o endereço da próxima
instrução a ser executada. No caso, RIP
contém o endereço 0x401019
, que é
exatamente o endereço da instrução syscall
, no nosso ponto de parada.
Mas vejamos o que acontece quando a chamada de sistema é processada:
(gdb) next Salve, simpatia! 15 mov rax, 60 ; syscall exit (gdb) info registers rax 0x11 17 rbx 0x0 0 rcx 0x401004 4198404 rdx 0x11 17 rsi 0x402000 4202496 rdi 0x1 1 rbp 0x0 0x0 rsp 0x7fffffffdfd0 0x7fffffffdfd0 r8 0x0 0 r9 0x0 0 r10 0x0 0 r11 0x302 770 r12 0x0 0 r13 0x0 0 r14 0x0 0 r15 0x0 0 rip 0x40101b 0x40101b <_start+27> eflags 0x202 [ IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0 fs_base 0x0 0 gs_base 0x0 0
Observe que apenas três registradores foram alterados:
Registrador | Antes | Depois |
---|---|---|
RAX |
0x1 |
0x11 (17) |
RCX |
0x0 |
0x401004 |
R11 |
0x0 |
0x302 |
Causa da mudança em RAX:
O registrador RAX
é utilizado como retorno da chamada de sistema write
e
recebe a quantidade de bytes impressos (17).
Causa da mudança em RCX:
Quando uma chamada de sistema é processada, o endereço da instrução seguinte
(originalmente em RIP
) é salvo em RCX
e restaurado para RIP
antes do retorno
da chamada, mas isso não significa que, no espaço de usuário, após a linha de
syscall
, nós encontraremos em RCX
o mesmo endereço registrado em RIP
. Not
e que os valores em RCX
e RIP
são diferentes após a chamada de sistema:
(gdb) info registers rcx rip rcx 0x401004 4198404 rip 0x40101b 0x40101b <_start+27>
Acontece que, depois de restaurado o endereço em RIP
, o kernel está livre
para utilizar RCX
antes de retornar ao espaço de usuário. Por isso, em
chamadas de sistema, RCX
é considerado um registrador volátil, ou seja, seu
conteúdo pode ser alterado a qualquer momento pelos próprios mecanismos
internos da transição entre os modos de execução, sem qualquer garantia de
preservação.
Causa da mudança em R11:
Internamente, em uma chamada de sistema, R11
é utilizado para salvar e
restaurar o estado do registrador RFLAGS
(eflags
no GDB). Contudo, assim
como acontece com RCX
, após a restauração de RFLAGS
o kernel está livre
para utilizar R11
no processo de transição de volta ao espaço de usuário,
o que justifica a diferença encontrada nesses dois registradores:
(gdb) info registers r11 eflags r11 0x302 770 eflags 0x202 [ IF ]
Preservação de registradores com a pilha
Se for necessário, é possível salvar e restaurar os registradores RAX
, RCX
e
R11
para a execução de uma chamada de sistema. A técnica mais comum em
arquiteturas x86_64 envolve a utilização da pilha de memória:
push rax ; salva rax como elemento N da pilha
push rcx ; salva rcx como elemento N+1 da pilha
push r11 ; salva r11 como elemento N+2 da pilha (atual topo)
; código da chamada de sistema...
pop r11 ; restaura e remove elemento N+2 da pilha (atual topo)
pop rcx ; restaura e remove elemento N+1 da pilha
pop rax ; restaura e remove elemento N da pilha
É importante observar a ordem de salvamento e restauração, porque estamos lidando com uma pilha, e o elemento em seu topo sempre será o último que tiver sido empilhado.
Resumo comparativo com chamadas de funções
Finalmente, aqui está uma tabela comparando as principais diferenças entre as convenções de chamadas de funções e a ABI do kernel para chamadas de sistema:
Aspecto | Chamadas de função | Chamadas de sistema |
---|---|---|
Interface | Conveção System V ABI | ABI do kernel (syscall ABI) |
Usa a pilha de memória | Sim (quadro de pilha) | Não (usa registradores) |
Preserva registradores | Sim | Não (registradores temporários) |
Instrução de transição | call , ret |
syscall |
Participação do kernel | Não | Sim |
Exercícios propostos
…
Referências
- man 2 syscall
- OS Dev: Calling Conventions
- OS Dev: Stack