Informática Numaboa - Tutoriais e Programação
Assembly e o Stack
Sab 25 Abr 2009 14:10 |
- Detalhes
- Categoria: Assembly Numaboa (antigo oiciliS)
- Atualização: Segunda, 27 Abril 2009 23:34
- Autor: vovó Vicki
- Acessos: 21218
Todos programas fazem uso intensivo da pilha em tempo de execução. Quando se programa usando uma linguagem de alto nível, este aspecto passa batido e a gente nem toma conhecimento do assunto. Um programador assembly, no entanto, precisa ficar esperto porque a pilha é uma das ferramentas mais importantes que ele tem à sua disposição. Saber trabalhar com a pilha é uma enorme vantagem, apesar de não ser indispensável. Em todo caso, sempre é bom ter uma noçãozinha da coisa.
Características e vantagens da pilha
A pilha é basicamente uma área de dwords (área de dados de 32 bits) existente na memória em tempo de execução, na qual o aplicativo pode armazenar dados temporariamente. Possui certas características e vantagens reais em relação a outros tipos de armazenamento na memória (seção de dados e áreas de memória em tempo de execução). São elas:
- O processador é muito veloz no acesso à pilha, tanto para escrever quanto para ler, por que é otimizado para esta tarefa.
- As instruções muito simples de PUSH e POP podem ser usadas para escrever e ler na pilha. Estas instruções são muito compactas, possuindo apenas um byte quando usam registradores ou cinco bytes quando usam marcadores (labels) de memória ou ponteiros para endereços de memória.
- No Windows, a pilha é ampliada em blocos de 4Kb em tempo de execução. Isto evita desperdício de memória.
A pilha pode ser usada para:
- Preservar valores de registradores em funções (exemplo)
- Preservar dados da memória (exemplo)
- Transferir dados sem usar registradores (exemplo)
- Reverter a ordem de dados (exemplo)
- Chamar outras funções e depois retornar (exemplo)
- Passar parâmetros para funções (exemplo)
Registrador ESP, o ponteiro da pilha
O registrador ESP (acrônimo de "extended stack pointer") contém o topo da pilha. Este é o ponto usado pelas instruções que utilizam a pilha (PUSH, POP, CALL e RET). Adiante falaremos mais sobre o assunto.
Normalmente o programador faz o registrador EBP (acrônimo de "extended base pointer") apontar para um determinado lugar da pilha para que seus dados possam ser lidos ou escritos usando um endereçamento com base indexada. Por exemplo, na instrução MOV EAX,[EBP+8h], o registrador EBP é usado como um índice para uma área da pilha e esta instrução irá transferir da pilha para o registrador EAX um dword situado 8 bytes adiante. A origem do uso do registrador EBP associado à pilha é da época dos sistemas de 16 bits, que tinham toda aquela complicação com segmentos e outros que tais. Nos sistemas de 32 bits não é necessário manter esta associação e o registrador EBP pode ser utilizado como um registrador de uso geral. Apenas por hábito ele continua sendo usado para endereçar determinadas áreas da pilha, principalmente para acessar parâmetros passados para funções e rotinas de callback e para endereçar dados locais.
Armazenando e retirando dados da pilha
A pilha pode ser imaginada como uma pilha de pratos. Isto funciona na base de "último a entrar, primeiro a sair". O último prato colocado na pilha usando uma instrução PUSH será o primeiro a ser retirado com uma instrução POP (se não for assim, a pilha cai ). O ponteiro da pilha em ESP sempre aponta para este prato no topo.
Voltando ao computador. Suponha que o valor de ESP seja 64FE3Ch e que você tenha as seguintes instruções no seu código fonte:
Após estas três instruções, ESP estaria com o valor 64FE30h (12 bytes ou 3 dwords a menos) e a pilha teria o seguinte aspecto:
ESP está aqui -> 64FE30h endereço de STRING 64FE34h valor de hWnd 64FE38h número 2 64FE3Ch
Observe que cada instrução PUSH diminui o valor de ESP em 4 bytes.
Observe também que, uma vez que ESP aponta para o último dword PUSHado para a pilha, o próximo PUSH vai escrever em ESP-4h. Isto é feito pelo processador, que reduz o ESP em quatro e depois escreve o dword no endereço que ESP contém.
Agora vamos ver como se comporta o POP. Usando os mesmos valores da pilha, usaremos as seguintes instruções:
Despois destas três instruções a pilha terá o seguinte aspecto:
64FE30h endereço de STRING -> EAX 64FE34h valor de hWnd -> EBX 64FE38h número 2 -> ECX ESP está aqui -> 64FE3Ch
A primeira coisa a ser observada é que, após estas três instruções, o ESP está de volta em 64FE3Ch. Isto significa que o equilíbrio de ESP foi restaurado. Este é um conceito muito importante (veja logo abaixo).
O registrador EAX agora contém o endereço de STRING, o EBX contém o valor de hWnd e o ECX contém o número 2. Percebe-se que os dados armazenados na pilha foram retirados pelo POP na ordem inversa em que foram colocados.
Observe também que os dados da pilha continuam presentes! Isto acontece por que a instrução POP não escreve na pilha. Ela apenas lê os dados da pilha e os transfere para a segunda parte da instrução (chamada de "operando").
Preservando valores de registradores em funções
Programas escritos em Assembly são rápidos porque usam os registradores exaustivamente, só que isto muitas vezes exige que os valores dos registradores sejam preservados para uso futuro. Por exemplo, imagine que um manipulador de arquivo (handle) esteja em EDI e que, após alguns cálculos com a ajuda do EDI, você tenha que fechar o manipulador. Para preservá-lo pode-se fazer o seguinte:
Uma outra alternativa é preservar o EDI dentro do procedimento CALCULA:
Outra razão para um registrador ser preservado é quando uma função em particular é chamada externamente (por outra função no mesmo programa, por outro programa ou pelo sistema). Na maioria dos casos deve-se garantir que EBP, EBX, EDI e ESI sejam preservados. Programas em C ou Delphi que chamam rotinas em Assembly e procedimentos callback chamados pelo próprio Windows com certeza exigem esta preservação. Um exemplo de procedimento callback é um procedimento de uma janela que é usada pelo sistema para passar informações para uma janela de um aplicativo. Nestas circunstâncias é necessário garantir os valores dos registradores usando, por exemplo:
É óbvio que, se estes registradores não forem modificados pelo código, alguns PUSH e POP não são necessários. Mesmo assim, é uma boa prática garantir a preservação dos seus valores - o seguro morreu de velho. Note que os POP estão na ordem inversa dos PUSH - isto é para respeitar o "último a entrar, primeiro a sair" da pilha. Observe também que os registradores estão em ordem alfabética. É um pequeno truque para não esquecer nenhum deles.
Caso você esteja trabalhando com o GoAsm, a declaração USES preserva e restaura automaticamente todos os registradores.
Preservando dados da memória
Da mesma forma que é possível preservadar valores de registradores usando a pilha, pode-se também preservar dados da memória. Suponha que você tenha calculado cuidadosamente o número de widgets e quer escrever os detalhes dos widgets na tela além de gravá-los em arquivo. Você pode usar o seguinte código:
Transferindo dados sem usar registradores
Suponha que você queira transferir o número de widgets para um outro marcador (label) de memória. Você poderia usar:
Igualmente eficiente seria:
Como esta segunda opção não faz uso do registrador EAX, este registrador não perderia seu valor e poderia ser utilizado para outra finalidade.
Revertendo a ordem de dados
Você pode tirar vantagem da característica "último a entrar, primeiro a sair" da pilha para inverter a ordem de dados. Um exemplo muito prático é escrever na tela um valor decimal. Neste exemplo, EAX contém o valor que deve ser escrito e EDI contém a posição de memória do buffer que abrigará a string com os algarismos:
Vamos analisar este código. Imagine que o valor em EAX seja 123 decimal. A primeira divisão por dez põe 12 em EAX e 3 em EDX. 3 é colocado na pilha. A segunda divisão por dez põe 1 em EAX e 2 em EDX. 2 é colocado na pilha. A terceira divisão por dez põe zero em EAX e 1 em EDX. 1 é colocado na pilha. O resultado de CMP EAX,EDX então é zero e a execução do código é desviada para o marcador L3. ECX está com 3 porque contou o número de dígitos. Agora cada um deles é retirado da pilha e adicionado a 48. Para 1, 2 e 3 obtemos respectivamente 49, 50 e 51. Estes valores são transferidos para o buffer e correspondem aos caracteres ascii "1", "2", e "3". Como foram colocados na pilha na ordem inversa (321) e foram retirados novamente na ordem inversa (123), já estão na sequência desejada e prontos para, mais tarde, serem escritos na tela.
Como CALL e RET usam a pilha
A instrução CALL é muito usada em programação. É utilizada para desviar a execução para um procedimento (ou "função") em particular. Quando o procedimento termina, a execução continua logo após a linha da chamada. Chamando procedimentos ajuda a manter o código fonte limpo e mais fácil de entender. Por exemplo:
Não há dúvida de que o procedimento CALCULA_CUSTOS deve realizar um trabalho extenso, porém, neste ponto do código, não há a necessidade de se preocupar com isso. Usando calls também ajuda a manter a modularidade do código, ou seja, o procedimento CALCULA_CUSTOS também pode ser usado por outros programas. Se quiser, pode considerá-lo como um "objeto". A programação orientada a objeto é basicamente isto.
Como é que o processador sabe onde continuar o processamento depois de uma chamada? Muito simples: ele coloca o endereço de retorno na pilha!
Vamos dar uma olhada na pilha no momento em que acontece uma chamada. Imagine que o valor de ESP seja 64FE3Ch e que o código fonte seja o mostrado acima. Após a primeira instrução, é claro que ESP ainda está em 64FE3Ch e a pilha não foi modificada por que ela não é afetada pela instrução MOV. Mas, quando a instrução CALL CALCULA_CUSTOS é executada, o processador PUSHa para a pilha o endereço de retorno 401027h. Bem, no procedimento CALCULA_CUSTOS existe uma instrução RET (retornar ao chamador), por exemplo:
A instrução RET causa um POP para EIP. Em outras palavras, seja o que for que estiver em [ESP] é atribuído a EIP (o ponteiro de instruções) e depois ESP (o ponteiro da pilha) é incrementado em 4 bytes.
Vamos observar o que acontece com a pilha antes, durante e depois destas instruções. Note como o equilíbrio do ESP é restaurado:
Antes da Chamada Durante a Chamada Depois da Chamada 64FE30h 64FE30h 64FE30h 64FE34h 64FE34h 64FE34h 64FE38h ESP -> 64FE38h 401027h 64FE38h 401027h ESP -> 64FE3Ch 64FE3Ch ESP -> 64FE3Ch
A importância do equilíbrio da pilha
Vimos como um procedimento pode ser chamado e o endereço de retorno é mantido na pilha. Acontece que, com frequência, procedimentos chamam outros procedimentos que chamam outros procedimentos... e assim por diante. Podemos ter, por exemplo:
Neste exemplo, a tarefa é dividida em vários componentes. Imagine que o procedimento CALCULA_CUSTOFIXO adicione 4 a ESP por engano. Se isto acontecer, quando a instrução RET for executada, o ponteiro de instruções EIP estará carregado com um valor errado e o programa vai dar pau.
Enquanto um procedimento estiver sendo executado é comum que o ESP seja deslocado (por exemplo, quando é preciso abrir um espaço na pilha), mas é de vital importância assegurar que o equilíbrio da pilha seja restaurado assim que o procedimento chegar no fim.
O equilíbrio da pilha também é importante ao retornar para o Windows, mesmo num programinha minúsculo. O aplicativo Windows mais simples possível, que não faz absolutamente nada, é o seguinte:
onde START é a entrada do aplicativo. Na realidade, o Windows normalmente chama seu aplicativo através da Kernel32.dll, de modo que um simples RET termina o programa alegremente sem maiores problemas por que esta e outras DLLs da API cuidam que a pilha se mantenha equilibrada.
Usando a pilha para passar parâmetros
As APIs do Windows esperam receber parâmetros através da pilha. Portanto, quando chamamos uma API, é necessário PUSHar os parâmetros necessários para que estes possam ser resgatados pela API.
Inicialmente colocamos o valor 1 (flag ENABLE, habilitar) na pilha, seguido pelo manipulador da janela que quermos habilitar. O Windows usa a convenção de chamada padrão "C" para suas APIs de modo que, ao retornar da API, a pilha estará novamente em equilíbrio. A convenção também significa que EBP, EBX, ESI e EDI sempre são restaurados pela API.
Outro aspecto da convenção é que os parâmetros são sempre PUSHados da direita para a esquerda (ou do último para o primeiro). As especificações para a função EnableWindow no Windows Software Development Kit são:
Para traduzir para Assembly, é preciso ler do fim para o começo. A coisa fica um pouco mais fácil se usarmos a instrução INVOKE ao invés de CALL. Neste caso, a ordem dos parâmetros é a mesma do SDK:
Finalmentes
UFA!!! Foi muito pra cabeça? Espero que não. Entender o funcionamento da pilha, a meu ver, é essencial para produzir programas de qualidade.
Não posso deixar de agradecer Jeremy Gordon pelos seus excelentes artigos sobre a pilha. O presente texto é (praticamente) apenas a tradução de Understand the stack (part 1) do referido autor.