Redescobrindo Assembly

Entendendo a “Stack” em sua forma mais primitiva (em Assembly)

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.

Em Resumo
  • O fato

    Saber assembly não é fundamental. Entretanto, é importante que todos saibamos que programas modernos, mesmo os escritos em linguagens mais sofisticadas, em sua execução utilizam uma implementação primitiva de Stack - na prática, uma área contínua de memória utilizada na "comunicação" que ocorre na chamada e no retorno das funções.
  • O insight

    Conhecer como a "stack" é utilizada, escrevendo algum código em assembly, ajuda a entender como a dinâmica de execução das aplicações modernas acontece. Eventualmente, ajuda a estruturar programas para que eles sejam mais eficientes.

Elemar Júnior

Microsoft Regional Director e Microsoft MVP. Atua, há mais de duas décadas, desenvolvendo software e negócios digitais de classe mundial. Teve o privilégio de ajudar a mudar a forma como o Brasil vende, projeta e produz móveis através de software. Hoje, seus interesses técnicos são arquiteturas escaláveis. bancos de dados e ferramentas de integração. Além disso, é fascinado por estratégia e organizações exponenciais.

Talvez você goste também

Carregando posts…

Mais posts da série Redescobrindo Assembly

1 comentário
  1. Sérgio Trovatti Uetanabaro

    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’

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *