Pilha
A stack, como o nome diz, é uma pilha. Pode-se imaginar como uma pilha de pratos ou uma pilha de caixas. As operações básicas de uma pilha são empilhar, desempilhar e checar seu topo. Para entender a stack, é importante saber essas duas primeiras operações.
Quando empilha-se algo em uma pilha, coloca-se o novo elemento em cima. Caso vá desempilhar, retira-se o elemento mais ao topo.
Agora vamos ver como isso se aplica na memória stack:
Na memória stack, há dois registradores que realizam operações nela. Eles são o RSP
(ou ESP para sistemas de 32-bits) e o RBP
(ou EBP para sistemas de 32-bits).
O RSP é o stack pointer e sua função é guardar o endereço do topo da pilha.
O RBP é o base pointer e sua função é guardar o endereço da base da pilha.
Com esses dois registradores, é possível saber onde a pilha começa e onde ela termina.
Outro registrador importante de se ter conhecimento é o RIP
(ou EIP). Quando estamos executando um programa, ele está carregado na memória RAM. Para saber qual endereço devemos executar, utiliza-se o RIP, o instruction pointer.
Quando uma função é chamada, é possivel ver no assembly da função que a chamou que há a seguinte linha de código:
Nesse caso, é o endereço dessa função na memória RAM. O endereço pode ser representado pelo rótulo (nome) da função em assembly e é o que iremos encontrar na maioria dos casos caso olharmos o assembly do código no GDB.
Essa linha faz duas ações:
É necessário guardar o RIP atual na stack, pois ao chamar uma função, estamos no meio da execução de uma outra função. Quando acabarmos de executar essa nova função, queremos saber exatamente onde estavamos para continuar o que estavamos fazendo.
Considerando o desenho da stack acima, caso executarmos o call
, a stack ficará assim:
Depois disso, se olharmos as primeiras duas linhas do assembly da nova função, veremos as seguintes linhas:
Quando chamamos uma função, criamos uma "nova stack" em cima da nossa stack. Para isso, o endereço do topo atual (RSP) é atribuido a nova base (RBP). Porém, se apenas fizermos a atribuição, perderemos a referência de onde estava a base da stack anterior quando formos voltar a função anterior. Por isso, antes de atribuir, empilhamos o endereço da base atual (RBP) na stack.
Visualmente, ocorrerá o seguinte:
Ao fazer a atribuição:
Para finalizar, caso a nova função utilize variáveis locais, será alocado o número de bytes necessário para essas variáveis, criando um espaço chamado stack frame.
Para fins ilustrativos, considere uma função que utilize 16 bytes para variáveis locais, ou seja, 0x10 bytes em hexadecimal. Seguido do push e do mov (atribuição), haverá a seguinte linha:
Como a stack cresce "para baixo", ou seja, do maior endereço para o menor endereço, ao subtrair um valor do stack pointer, estamos deixando o RSP mais "alto" na pilha.
Portanto, essa linha teria o seguinte efeito:
Agora que está alocado, podemos atribuir valores a essas variáveis locais. Para fins didáticos, vamos imaginar que essa stack está em um sistema de 64-bits e queremos colocar nesses 16 bytes um int
(4 bytes) e uma string
de 4 caracteres (incluindo o '\0'). No caso da string, será necessário criar um ponteiro char*
(8 bytes para sistemas 64-bits) e o espaço dos 4 caracteres, totalizando 12 bytes para a string.
As variáveis locais dessa função serão:
Vamos considerar também que RBP = 0x7fffffffffff1000 e RSP = 0x7fffffffffff0ff0.
O código em assembly para essas atribuições é:
Ao redesenhar a stack:
A região marcada por 0x????????
é o lixo de memória que está na região da string, já que ainda nao escrevemos nada nela. Perceba que o ponteiro char* está escrito de trás para frente. Isso é porque a maioria das arquiteturas hoje em dia seguem o modelo little-endian, em que se escreve os bytes do byte menos significativo para o mais significativo. Por enquanto não precisam se preocupar com isso, veremos novamente mais para frente.
Por fim, vamos encerrar essa nova função.
No fim de uma função, haverá as seguintes linhas:
A linha leave
executa as seguintes operações:
A linha ret
desempilha o RIP anterior para RIP.
Portanto, visualmente:
O conteúdo da stack permanece como lixo de memória após o retorno da função.
E assim, encerramos a chamada da nova função.
Last updated