A Aldeia Numaboa ancestral ainda está disponível para visitação. É a versão mais antiga da Aldeia que eu não quis simplesmente descartar depois de mais de 10 milhões de pageviews. Como diz a Sirley, nossa cozinheira e filósofa de plantão: "Misericórdia, ai que dó!"

Se você tiver curiosidade, o endereço é numaboa.net.br.

Leia mais...

Informática Numaboa - Tutoriais e Programação

Assembly e o Stack

Sab

25

Abr

2009


14:10

(15 votos, média 4.67 de 5) 


Iniciantes

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 smile). 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:

PUSH 2 PUSH [hWnd] PUSH ADDR STRING

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:

POP EAX POP EBX POP ECX

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:

PUSH EDI ;salva o manipulador de arquivo CALL CALCULA ;faz alguns cálculos (usando EDI) POP EDI ;recupera o manipulador de arquivo CALL CLOSE_FILEHANDLE ;fecha o manipulador contido em EDI

Uma outra alternativa é preservar o EDI dentro do procedimento CALCULA:

CALL CALCULA ;faz alguns cálculos (salva EDI) CALL CLOSE_FILEHANDLE ;fecha o manipulador contido em EDI CALCULA: PUSH EDI ;salva o manipulador de arquivo . . ;código usando EDI . POP EDI ;recupera o manipulador de arquivo RET

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:

PUSH EBP,EBX,EDI,ESI . . ;seu código vai aqui . POP ESI,EDI,EBX,EBP

É ó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:

PUSH [NRODE_WIDGETS] ;guardar número de widgets L2: CALL REPORT_WIDGET ;escrever detalhes do widget na tela DEC D[NRODE_WIDGETS] ;decrementar o número de widgets JNZ L2 ;continuar com o próximo enquanto não for zero POP [NRODE_WIDGETS] ;restaurar o número de widgets CALL WRITETO_FILE ;e gravar em arquivo

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:

MOV EAX,[NRODE_WIDGETS] MOV [COPIADE_NRODE_WIDGETS],EAX

Igualmente eficiente seria:

PUSH [NRODE_WIDGETS] POP [COPIADE_NRODE_WIDGETS]

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:

XOR EDX,EDX ;zera edx XOR ECX,ECX ;zera ecx (usado como contador) MOV EBX,10 ;ebx guarda sempre o valor 10 L2: DIV EBX ;divide edx:eax por 10 - quociente em eax, resto em edx PUSH EDX ;põe resultado na pilha INC ECX ;conta quantos foram feitos XOR EDX,EDX ;zera edx CMP EAX,EDX ;vê se há mais para ser feito JNZ L2 ;sim L3: ;agora reverter a ordem dos dígitos POP EAX ;pega o próximo da pilha ADD AL,48 ;converte para número ascii STOSB ;escreve número ascii no buffer LOOP L3 ;continua enquanto ecx for diferente de zero

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:

401020: MOV EAX,EDX 401022: CALL CALCULA_CUSTOS 401027: MOV [CUSTOS],EAX ;põe resultado da chamada na memória

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:

CALCULA_CUSTOS: ;um monte de código aqui RET ;retornar ao chamador

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:

CALCULA_CUSTOS: CALL CALCULA_CUSTOFIXO RET ;retorna ao chamador CALCULA_CUSTOFIXO: ;uma porção de código CALL GET_CUSTOVARIAVEL CALL AJUSTEPARA_DEPRECIACAO ADD ESP,4 ;tira o equilíbrio de ESP RET

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:

START: RET

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.

PUSH 1,[hButton] CALL EnableWindow ;habilitar botão

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:

WINAPI EnableWindow( HWND hWnd, BOOL bEnable );

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:

INVOKE EnableWindow, [hButton], 1

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.

mfx brokerкрышка для сковородыооо полигон киев отзывы церковькласскупить косметикаполигон ооо

Informações adicionais