Conhecer assembly, geralmente, não é fundamental para o dia a dia de um programador. Entretanto, entender como assembly funciona ajuda a valorizar determinadas características de linguagens de programação de nível mais alto e, até mesmo, deixar mais confortável cenários mais complexos de depuração ou otimização.
Um dos conceitos centrais de programação em assembly que ajudam a entender o comportamento do código, em uma linguagem como Java ou C#, é a stack.
O que é a Stack?
Trata-se de uma área contínua de memória que nossos programas utilizam para manipular dados, principalmente primitivos, e é intensamente utilizada na “comunicação” durante chamadas e retornos de funções.
Os dados são “empilhados” na stack usando, em assembly, a instrução push
e são desempilhados usando a instrução pop
. O endereço de memória correspondente ao “topo” da stack é mantindo no registrador esp
.
O topo da pilha “cresce negativamente”, de high-memory para low-memory. Assim, sempre que um valor é empilhado na stack, o valor de esp
é decrescido. De maneira análoga, sempre que um valor é “desempilhado” da stack, o valor de esp
é acrescido.
Qual a relação entre a Stack e a execução de funções?
De maneira geral, sempre que uma função é chamada, os dados necessários para sua execução são dispostos na stack obedecendo uma determinada convenção. Voltando ao código do post anterior, podemos perceber no “header” da função addInAsm
a convenção que deveria ser respeitada (cdecl).
#include <iostream> extern "C" int addInAsm(int a, int b); int main() { int a = 2; int b = 3; int result = addInAsm(a, b); std::cout << a << " + " << b << " = " << result << std::endl; return 0; }
O código em assembly que escrevemos utiliza “empiricamente” o que está acordado na convenção para acessar os dados.
.model flat, c .code addInAsm proc ; Initialize a stack frame pointer push ebp mov ebp, esp ; load the paramaters mov eax, [ebp + 8] ; eax = a mov ecx, [ebp + 12] ; ecx = b ; add eax, ecx ; restore the stack frame pop ebp ret addInAsm endp end
Segundo as convenções, a stack foi atualizada, na chamada, para conter, os parâmetros da função e o endereço de retorno para quando a função encerrar.
Perceba que códigos em assembly não empregam sistemas sofisticados de tipos. Ou seja, não há abstrações com relação a valores em memória – tudo são bytes que ganham significado conforme a intenção do código e são acessados através dos deslocamentos impostos pelo “tamanho” de cada dado.
O endereço de retorno é capturado pela instrução ret
, diretamente da stack, para saber onde está a próxima instrução da função chamadora (main
) a ser procesada.
Qual a relação entre a Stack e a parâmetros “byref”?
Quando passamos parâmetros “por referência”, mandamos na Stack, no lugar dos valores, os endereços de memória correspondendo as variáveis que desejamos atualizar.
#include <iostream> extern "C" void addMul(int a, int b, int* sum, int* prod); int main() { int a = 2; int b = 5; int sum = 0; int prod = 0; addMul(a, b, &sum, &prod); std::cout << "The sum of " << a << " and " << b << " is " << sum << " and the product is " << prod << std::endl; }
No exemplo, os parâmetros prod
e sum
são passados como referência, mas a estrutura na stack é praticamente inalterada.
addMul proc push ebp mov ebp,esp push edx push ecx push eax mov ecx,[ebp+8] ;ecx = 'a' mov eax, ecx ;eax = 'a' mov edx,[ebp+12] ;edx = 'b' imul ecx,edx ;edx = 'a' * 'b' mov ebx,[ebp+20] ;ebx = 'prod' mov [ebx],ecx ;save product add eax, edx ;eax = 'a' + 'b' mov ebx,[ebp+16] ;ebx = 'sum' mov [ebx],eax ;save sum pop eax pop ecx pop edx pop ebp ret addMul endp end
A mudança na stack fica apenas nas duas novas posições necessárias para acomodar os novos valores.
No código em assembly, repare que utilizemos os “endereços” contidos nos parâmetros e não seus valores diretamente (como fazemos para as variáveis a
e b
).
O que acontece se a memória destinada para a Stack for esgotada?
O resultado depende do ambiente operacional onde estamos trabalhando. Em C#, por exemplo, uma StackOverflowException irá ser disparada e o programa se encerrará.
Importante indicar que o “esgotamento” da Stack geralmente é causado por execuções recursivas em demasia. Afinal, a stack é utilizada para fazer “o caminho de volta” na execução de diversas funções.
Eventualmente, podemos escrever código que ajuda o compilador a entender que “não será necessário” voltar para a função quando houver um retorno.
int factorial(int n, int b = 1) { if (n == 0) { return b; } return factorial(n - 1, b * n); }
Alguns compiladores conseguem identificar esses cenários e não criar um registro na stack para cada chamada.
Concluindo
A stack é um dos conceitos fundamentais para execução de programas de computador em qualquer ambiente moderno. Em seu formato mais “bruto” trata-se apenas de um espaço contínuo de memória que é atualizado seguindo algumas convenções muito simples.
O conhecimento sobre como a Stack é manipulada, em seu estado mais fundamental, ajuda programadores a apreciar o bom trabalho dos compiladores e entender oportunidades de otimização.
Bem didático esse artigo mas tem uma observação:
imul ecx,edx ;edx = ‘a’ * ‘b’
ECX receberia o acumulado, não EDX: ecx = ‘a’ * ‘b’