Em geral, o hardware dá algum suporte à manutenção dessa
pilha.
No caso da máquina Pentium, um registrador específico,
esp, é dedicado ao endereço do topo
da pilha.
Por motivos históricos, a pilha cresce em direção aos endereços
mais baixos de memória.
Ou seja, para alocar espaço para um endereço, devemos subtrair
4 de esp (lembre-se que um endereço ocupa 4 bytes!)
e para desalocar devemos somar 4 a esp.
Por exemplo:
-----------> crescem os endereços de memória
-----------------------------------------------
| | | | | | | | | | | | | | |
-----------------------------------------------
^
|
esp
empilhar: pushl %eax # suponha que o valor de eax e' a13f45c6
resultado:
-----------> crescem os endereços de memória
-----------------------------------------------
| | | | | | | | |c6|45|3f|a1| | |
-----------------------------------------------
^
|
esp
desempilhar: popl %eax
resultado:
-----------> crescem os endereços de memória
-----------------------------------------------
| | | | | | | | | | | | | | |
-----------------------------------------------
^
|
esp
A operação pushl %eax é análoga à sequência
subl $4, %esp movl %eax, (%esp)e a operação
popl %eax à sequência
movl (%esp), %eax addl $4, %esp
Ao chamarmos um procedimento, precisamos passar dados e controle de uma parte do código para outra. A maior parte das arquiteturas têm instruções que facilitam algumas dessas tarefas, e o restante delas tem que ser programado explicitamente, usando a pilha.
No IA32: intruções call e ret facilitam
a transferência de controle.
A instrução call f transfere o controle para f,
antes armazenando o endereço de retorno (endereço da instrução
seguinte a call f), para que o controle possa
retornar para este endereço ao final da execução da função.
A instrução ret faz com que o controle retorne
para o último endereço de retorno armazenado.
Mas onde call pode armazenar esse endereço?
call f armazena o endereço da próxima instrução
na pilha de execução e faz um desvio para o endereço
associado a f.
ret desempilha um endereço da pilha e desvia
para o endereço desempilhado.
esp).
Um outro uso da pilha é salvar registradores durante chamadas de funções.
Cada função utiliza os registradores (eax, ebx, ecx, edx, ...) para armazenar valores de variáveis e temporários. Mas ao fazer uma chamada, esses mesmos registradores são usados pela nova função!
Para evitar que os valores anteriores se percam, podemos armazená-los na pilha. Mas quem deve armazená-los? Quem chama (caller) ou quem é chamada (callee)?
Deve existir uma convenção em cada sistema, de maneira que não se armazenem valores duas vezes e nem se deixe de armazená-los...
No Linux, caller deve armazenar eax, ecxe edx.
Quando uma função P chama uma função Q, Q pode ficar a vontade
para destruir os valores nesses registradores.
Por outro lado, os registradores ebx, esi e
edi devem ser armazenados pelo callee (Q, nesse caso).
(No windows: caller deve armazenar eax, ebx, ecx e edx.)
Uma forma simples de passar parâmetros para funções é colocá-los em registradores. No entanto, quando o número de parâmetros é grande essa técnica não é adequada...
Uma outra técnica, usada em C, é passar os parâmetros colocando-os na pilha!
A convenção adotada em C é empilhar os parâmetros na ordem inversa em que aparecem na declaração da função (da direita para a esquerda). Ou seja, dada a declaração:
int boba (int tam, int nums[]);no momento da ativação de boba teremos a seguinte situação:
-------------- | | -------------- | | -------------- | end.ret | <- esp -------------- | tam | -------------- | nums (end) | -------------- | | -------------- ... -------------- | | -------------- | | <- ebp ; base do registro de ativação anterior --------------
nesse instante, poderíamos dizer que o primeiro parâmetro está no endereço
(esp) + 4.
No entanto, ao longo da execução da função o valor de esp pode
variar.
Por isso, utiliza-se um outro registrador, ebp, para apontar
para a base da pilha durante toda a execução de uma função.
(A porção da pilha referente a cada função é chamada de registro de ativação,
como veremos depois. Assim,
Para isso, adota-se a seguinte convenção (convenção do compilador C e outros). O código tipicamente gerado por um compilador para um procedimento começa com:
pushl %ebp ; salva o endereço do registro de ativação anterior
movl %esp, %ebp
e termina com:
movl %ebp, %esp
popl %ebp ; restaura o endereço do registro de ativação anterior
ou seja, em cada instante temos na pilha de execução:
| | --------- | | --------- | | --- <- esp (valor qualquer se tivermos empilhado coisas durante a execução da fc corrente) --------- | | | | --------- | | | |--- registro de ativação --------- | |ebp ant| <- ebp --------- | |end.ret| | --------- --- | | ---------
Assim, para acessar o valor do primeiro parâmetro (tam), o código da rotina deverá acessar a posição de memória 8(%ebp). O segundo parâmetro estará em 12(%ebp), e, se tivermos mais parâmetros, assim por diante.
Obs: Cada parâmetro é colocado em quatro bytes, mesmo que seu tipo ocupe menos do que isso.