Informática Numaboa - Tutoriais e Programação
Assembly e o uso avançado da pilha (stack)
Dom 21 Jun 2009 16:16 |
- Detalhes
- Categoria: Assembly Numaboa (antigo oiciliS)
- Atualização: Segunda, 22 Junho 2009 14:44
- Autor: vovó Vicki
- Acessos: 9832
Este tutorial trata de aspectos mais avançados da pilha. O texto base para este assunto você encontra no tutorial Assembly e o Stack.
A pilha está num espaço virtual de memória
O valor em ESP é um endereço virtual. Se, por exemplo, no início for 64FE3Ch, não estará se referindo a um endereço de memória existente na memória física real. Para obter o endereço físico da memória, o sistema precisa converter (ou "mapear") 64FE3Ch de acordo com seus próprios registros internos. Por exemplo, este endereço pode muito bem corresponder a 2FE3Ch na memória física real. Portanto, um endereço virtual é apenas uma representação conveniente de uma posição na memória. Costuma-se dizer que cada aplicação roda no seu próprio espaço virtual de endereços. Na teoria, toda a extensão de endereços de 32 bits (zero a 4 Gb) está disponível para cada uma das aplicações. Na prática a coisa muda de figura, mas continua sendo verdade que cada aplicação que esteja rodando no sistema pode usar a mesma extensão de endereços virtuais. Não ocorrem conflitos por que o sistema sabe o tempo todo qual aplicação está endereçando memória. Portanto, pode indicar às aplicações o local correto na memória física. Deste modo, é possível que várias aplicações apresentem simultaneamente o mesmo valor em ESP porém cada um destes valores esteja apontando para um local diferente da memória física.
Conteúdo inicial da pilha
Quando é carregado, o sistema operacional Windows aloca uma área de pilha específica para a linha (thread) principal. O próprio sistema faz uso deste thread e da sua área de pilha antes de chamar o endereço de entrada do programa. Você pode ver isto no debugger. Inicie seu programa, deixe chegar no endereço de entrada e observe o valor de ESP. Agora abra uma janela de inspeção para o valor de ESP. Talvez você imagine estar na base da área de memória, só que não é este o caso. Se você rolar a janela de inspeção para a base da memória (role para o maior endereço) você verá que já houve muita atividade na pilha durante a preparação do sistema para chamar o endereço de entrada do programa. É interessante que o último valor da pilha, antes da aplicação ser chamada, é um endereço de retorno na Kernel32.dll. Isto indica que uma função da Kernel32.dll chamou a aplicação. Devido à existência deste endereço de retorno é possível usar um simples RET para terminar o processo, ao invés de chamar ExitProcess. É claro que isto só funciona se a pilha estiver em equilíbrio de modo que a execução do código continue na função chamadora da Kernel32.dll.
Um pouco mais adiante podemos ver na pilha o nome do arquivo da aplicação e, mais adiante ainda, podemos observar o endereço do manipulador de exceções que o próprio sistema alocou para o thread principal da aplicação. Todas estas coisas mostram que a área de pilha da aplicação (assim como seu thread) é utilizada pelo sistema para preparar a chamada a esta aplicação.
Espaço inicial da pilha
No Windows, quando alguma memória é reservada para o uso de uma aplicação, uma certa quantidade de endereços virtuais são alocados pelo sistema. Esta alocação preserva estes endereços para que a aplicação possa utilizá-los. Se a aplicação precisar de mais memória, os mesmos endereços não podem ser reutilizados. Nenhuma memória física é utilizada enquanto a memória não tiver sido consignada. Neste ponto, os endereços virtuais que foram alocados são mapeados para a área ou áreas da memória física que estejam disponíveis para o sistema.
Obviamente, para que este processo funcione, o sistema precisa saber do tamanho máximo de memória contígua que pode ser consignada. Esta passa a ser a extensão de endereços alocados.
O mesmo se aplica quando alguma memória é reservada para o uso da pilha. No início de uma aplicação, o sistema precisa saber quanta memória alocará para a pilha e quanto deverá consignar na primeira instância. Estas duas quantidades estão referenciadas no arquivo PE em +48h e +4Ch no cabeçalho opcional. Como veremos abaixo, referem-se não somente ao thread principal da aplicação, mas também a novos threads criados pela aplicação.
A maioria dos linkers utilizam, respectivamente, 1Mb e 4 Kb (o tamanho normal de página) para estes valores. Com o GoLink você pode alterar estes valores default usando, respectivamente, /stacksize e /stackinit (veja o manual do GoLink para saber como usá-los).
Ampliando a pilha em tempo de execução
O sistema percebe quando uma aplicação está tentando ler ou escrever além da área consignada para a pilha usando manipulação de exceção. Considerando que a tentativa ocorra dentro da área permitida da pilha, mais memória será consignada de acordo com a necessidade. Mesmo que ocorra uma tentativa de aumentar a pilha além da área alocada, o sistema NT (mas não o Win9x) tentará alocar mais memória, o que só não ocorre se os endereços virtuais requeridos tenham sido alocados para outras áreas de memória.
Área de uso permitido
64D000h | ||
Página 4K indisponível |
64E000h | |
---|---|---|
Página 4K disponível |
64F000h | |
ESP (64FE3Ch) está aqui → | Página 4K disponível |
650000h |
A pilha não é considerada apropriada para manter grandes quantidades de dados e este enfoque é reforçado pelo Windows através do seu mecanismo de exceção. No Win9x, a área de pilha usável permitida situa-se entre o ESP corrente e o limite da próxima página mais o tamanho da página. Por exemplo, se ESP for 64FE3Ch, então o limite da próxima página será 64F000h e o tamanho da página extra (que geralmente é fixada em 4K pelo sistema) nos leva para 64E000h.
Desta forma, se ESP for 64FE3Ch, a instrução
causará uma exceção por que o ponto atual da pilha que está sendo endereçado é 64DFFCh, uma área não disponível por que ainda não foi consignada pelo sistema.
Também não é possível contornar o problema movendo ESP. No Win9x, o sistema permite que o ESP seja movido apenas até ao limite da próxima página + o tamanho da página menos quatro bytes. Por exemplo, se ESP for 64FE3Ch, só será permitida uma única instrução para mover ESP em 1E38h (em decimal isto corresponde a 7836 bytes). Isto significa que a instrução
faz com que ESP se torne 64E004h e isto é permitido. Já a instrução
causará uma exceção. A diferença de 4 bytes na posição que dispara a exceção sugere que existem dois tipos de proteção.
Pelo acima exposto pode parecer que o tamanho dos dados que podem ser colocados na pilha esteja limitado a 4K, porém isto não é verdade. Existem duas maneiras de se evitar estas exceções e, desta forma, usar a pilha para uma quantidade maior de dados. A primeira forma é mover e usar o ESP incrementalmente. Isto assegurará que o sistema consigne memória progressivamente, como desejado. O seguinte código cria com segurança uma área de 40K bytes na pilha:
Aqui se obriga o sistema a consignar 10 blocos de 4K de memória de pilha. O ESP acaba ficando no topo desta área de pilha. Este processo não é particularmente rápido por que o sistema precisa consignar memória dez vezes. Um método mais rápido é instruir o sistema a consignar uma quantidade maior que a usual de memória para a pilha quando a aplicação for carregada. Com o GoLink é possível fazer isto usando /stackinit. Por exemplo,
garantirá que 40K de memória sejam consignados para a pilha no início. Você vai poder mover o ESP com segurança usando a instrução
e terá um espaço de 40K de memória para brincar.
Usando a pilha para manter um fluxo de dados
Com as devidas precauções, a pilha pode ser usada para armazenar um fluxo razoável de dados. Os pontos que devem ser lembrados são:
- Sempre restaure o equilíbrio de ESP quando tiver terminado a operação com a pilha.
- Nunca escreva para [ESP], a não ser que você tenha subtraído pelo menos 4 bytes do valor original de ESP, por que estes contém o endereço de retorno do procedimento.
Nunca escreva para [ESP+n], a não ser que um número suficiente de bytes tenha sido subtraído de ESP para evitar que outros dados importantes sejam sobrescritos. - Se você não mover o ESP para o topo da área de dados então será preciso escrever os dados na direção inversa, isto é, para endereços progressivamente decrescentes. Isto pode ser feito de vários modos, o mais conveniente sendo provavelmente ativando a flag de direção usando STD e depois usando instruções MOV. Por exemplo:
MOV ECX,8000 MOV EDI,ESP SUB EDI,4 STD ; ativa a flag de direção REP MOVSD ; move dwords de ECX de [ESI] para [EDI] CLD ; limpa a flag de direção Este código escreve 8.000 dwords na pilha. Observe como SUB EDI,4 evita a escrita sobre [ESP], o qual contém o endereço de retorno do procedimento. Não há problema com o aumento da memória por que a escrita é incremental e o sistema apropriadamente cria novas áreas de memória de 4K à medida que se tornam necessárias. - Se você mover ESP para o topo da área de dados, será preciso tomar as precauções citadas no tópico "Área de uso permitido". Respeitando as premissas será possível escrever na direção normal.
A pilha em aplicações multi-thread
Cada thread do seu aplicativo possui seus próprios registradores e pilha. Isto quer dizer que, quando o sistema delegar tempo de processamento ao thread, ele entrará no contexto de registradores deste thread. O contexto contém todos os valores dos registradores existentes no momento em que, da última vez, o tempo de processamento foi tirado do thread. Como os registradores incluem o ESP, seu valor também será corretamente trocado de modo que a área de memória física correta será usada pelo thread como sua pilha. O resultado é que thread pode se apoiar no fato de que pode usar sua pilha como uma área particular da memória que recebe interferências de outros threads. Você pode observar isto no debugger. Será possível ver que o ESP sempre muda substancialmente quando a execução troca de thread.
Quando um thread é iniciado, sua área de pilha é alocada. Como exemplo prático, verificou-se que o thread principal de um aplicativo rodava a partir de 64FE3Ch (para baixo) e, quando um novo thread era feito, sua pilha rodava a partir de 75FF9Ch (para baixo). Num outro teste, quando seis threads novos foram feitos, suas pilhas foram iniciadas respectivamente em 19DEF9Ch, 1AFFF9Ch, 1C1FF9Ch, 1D3FF9Ch, 1E5FF9Ch e 1F7FF9Ch. Aqui você pode notar que o sistema está separando o endereço virtual de cada área de pilha com 128Kb a mais do que o default de 1Mb. Provavelmente isto tenha ocorrido para abrir espaço para o uso da pilha pelo sistema e também alguma folga. Alterando a alocação do tamanho da pilha para 200000h (2Mb) através do uso de /stacksize e depois criando seis threads novos teve como resultado a separação das áreas de pilha com 128Kb a mais que os 2 Mb.
Moldura da pilha e dados locais
Uma moldura de pilha é uma área particular da pilha que contém um endereço de retorno de uma função e dados usados por esta função, sem o risco de sobre-escrita porque o valor de ESP foi decrementado. Os dados mantidos numa moldura da pilha são denominados "dados locais". Isto porque são usados apenas dentro da referida moldura e não está previsto que sejam endereçados pelo programa de forma geral. Vejamos este exemplo simples:
e
Aqui a moldura da pilha é criada usando a instrução SUB ESP,20h. Isto diminui o valor de ESP em 32 bytes, criando espaço na pilha para 8 dwords. Agora, como o ESP foi mudado, qualquer coisa que ocorrer na PROCEDURE2 nunca vai sobre-escrever estes 8 dwords. Vamos conferir isto visualmente imaginando que ESP contenha 64FE38h no início da PROCEDURE1:
64FE08h | contém valor em ECX inserido pela PROCEDURE2 | |
64FE0Ch | contém valor em EBX inserido pela PROCEDURE2 | |
64FE10h | contém valor em EAX inserido pela PROCEDURE2 | |
ESP aqui no início da PROCEDURE2→ | 64FE14h | contém o endereço de retorno da PROCEDURE2 |
Moldura de pilha da PROCEDURE1 | 64FE18h a 64FE34h |
8 dwords para dados locais |
---|---|---|
64FE38h | contém o endereço de retorno da PROCEDURE1 |
Endereçando dados locais
Observação: isto é automatizado no GoAsm usando FRAME..ENDF e no MASM usando PROC..ENDP.
Uma vez que ESP aponta para o topo da área de dados locais, é possível endereçar os dados usando ESP. Assim, no exemplo acima, o primeiro dado local dword estaria disponível em [ESP] imediatamente após o SUB ESP,20h. Porém, usando ESP para gerenciar os dados locais na pilha pode ser complicado por que ESP mudará a cada CALL ou PUSH dentro do procedimento. Por esta razão costuma-se usar o registrador EBP e não o ESP. Atribui-se um valor ao EBP logo no início da moldura de pilha, na base dos dados locais, e o valor não é alterado enquanto a execução não abandonar a moldura. Desta forma temos certeza de que os dados locais podem sempre ser endereçados usando um deslocamento (offset) de EBP. Agora, o código para uma moldura de pilha típica passa a ter a seguinte aparência:
Aqui mudamos o ponteiro da pilha em 12 bytes. No ponto "X", a pilha em relação a EBP tem o seguinte aspecto:
ebp-10h | o próximo push entrará aqui | |
ESP aqui no ponto "X"→ | ebp-0Ch | contém espaço para dado local |
---|---|---|
ebp-8h | contém espaço para dado local | |
ebp-4h | contém espaço para dado local | |
ebp | contém valor original de ebp | |
ebp+4h | contém o endereço de retorno de MolduraPilhaTipica |
Agora, por todo a moldura de pilha, seja o que for que acontecer a ESP, os dados locais estarão acessíveis em [EBP-4H], [EBP-8h] e [EBP-0Ch].
Observe como o equilíbrio de ESP é restaurado automaticamente pelo uso de MOV ESP,EBP pouco antes de retornar ao chamador.
Não é obrigatório usar EBP para este fim, qualquer registrador é adequado. Acontece que o EBP é tradicionalmente usado para este fim e seu código será mais facilmente entendido por outros programadores.
Acessando parâmetros através da pilha
Já vimos como passar parâmetros para outros procedimentos usando a pilha. Agora vamos analisar como usar parâmetros passados para procedimentos no seu próprio código. Basicamente, estes parâmetros estão mais em baixo na pilha para que não sejam sobre-escritos em circunstâncias normais. Por esta razão não há necessidade nenhuma de salvá-los ou resgatá-los. Depois de entrar num procedimento, ESP estará apontando para o endereço de retorno deste procedimento (inserido pelo CALL). Por isto, os parâmetros estarão em [ESP+4h], [ESP+8h], [ESP+0Ch] e assim por diante, dependendo de quantos parâmetros existirem. Mas pode ser difícil localizar onde exatamente estejam os parâmetros usando ESP por que o valor deste registrador mudará no próximo PUSH ou CALL. Mais uma vez o EBP pode ser usado para apontar os parâmetros.
Se o código prólogo for
quando ESP for passado para EBP, ele terá 4 bytes a menos do que tinha no início da chamada (devido ao primeiro PUSH EBP). Portanto, os parâmetros agora podem ser acessados usando [EBP+8h], [EBP+0Ch], [EBP+10h] e assim por diante, dependendo do número de parâmeros existentes.
A pilha e procedimentos callback do Windows
As duas técnicas utilizadas (criando espaço para dados locais e endereçando parâmetros) são requeridas em procedimentos de callback do Windows. O procedimento callback mais comum em programas Windows é o procedimento janela. É para este procedimento que o Windows envia "mensagens" e o Windows espera uma resposta correta. O que acontece neste caso é que o Windows chama o procedimento janela usando o thread do próprio programa. Isto geralmente ocorre enquanto o programa estiver num loop de mensagem esperando um retorno da API GetMessage ou então executando a API DispatchMessage.
Por sorte você pode usar FRAME..ENDF no GoAsm para obter os parâmetros enviados por janelas e também endereçá-los por nome. Você também pode criar com facilidade áreas de dados locais endereçáveis por nome. E você pode preservar registradores e restaurar o equilíbrio da pilha automaticamente. Veja o manual do GoAsm para uma descrição completa.
Fonte
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 2).