A Pilha de Execução do x86-64: call e ret
Objetivos de Aprendizagem
- Explicar o que
pushepopfazem com%rspe com a memória e dizer, em consequência, em que direção a pilha cresce. - Explicar exatamente o que
callfaz (empilhar um endereço de retorno e saltar, nessa ordem) e dizer com precisão qual endereço é empilhado. - Explicar exatamente o que
retfaz (desempilhar um valor e saltar para ele) e por que isso só funciona corretamente se a pilha estiver exatamente comocalla deixou. - Rastrear, instrução por instrução, uma curta sequência de um
callseguido, em algum momento, de umret, mostrando%rspe o conteúdo da pilha em cada passo. - Ligar esse mecanismo concreto, no nível das instruções, diretamente ao comportamento abstrato LIFO da pilha de chamadas já visto em
the-stack-and-automatic-storage.
Contexto e Motivação
the-stack-and-automatic-storage descreveu o comportamento da pilha de chamadas (um frame empilhado por chamada, desempilhado por retorno, disciplina LIFO) inteiramente em termos do que acontece conceitualmente com variáveis locais e chamadas de função. Este conceito fornece as instruções de máquina exatas que tornam esse comportamento real: push, pop, call e ret, as instruções específicas do x86-64 que manipulam o registrador stack pointer (%rsp) e a memória para a qual ele aponta, momento a momento, conforme um programa de fato executa.
Este também é o conceito em que a pilha, como região do espaço de endereçamento do processo (the-process-address-space), e %rsp, um registrador visto de forma genérica em x86-64-registers-and-data-movement, se encontram pela primeira vez com um propósito específico e dedicado: %rsp não é só mais um registrador de uso geral disponível para aritmética arbitrária. Por convenção arquitetural, ele sempre guarda o endereço do topo atual da pilha, e push, pop, call e ret o leem e o atualizam como parte central do seu comportamento.
O guia de x86-64 do CS107 documenta push, pop, call e ret como as instruções específicas que este conceito cobre, exatamente nos termos usados aqui: call empilhando %rip (o ponteiro de instrução, que guarda o endereço da próxima instrução) e saltando, ret desempilhando esse endereço de volta para %rip. O CS:APP trata essas mesmas quatro instruções como o fundamento mecânico a partir do qual toda a disciplina de chamada de procedimentos (passagem de parâmetros, variáveis locais, retorno de um resultado) é construída.
Teoria Central
%rsp: o stack pointer, por convenção
%rsp é um registrador comum de 64 bits, mas a convenção de chamada do x86-64 e suas instruções push/pop/call/ret o tratam como guardando um valor específico e com significado: o endereço do topo atual da pilha. the-process-address-space já estabeleceu que a pilha cresce em direção aos endereços mais baixos, o que significa que "empilhar" algo na pilha diminui %rsp, e "desempilhar" algo aumenta %rsp, o inverso do que "push" e "pop" poderiam sugerir.
push e pop: as operações primitivas de pilha
textpushq %rax ; equivalente a: subq $8, %rsp ; movq %rax, (%rsp) ; (decrementa %rsp em 8 e depois guarda %rax no novo topo) popq %rax ; equivalente a: movq (%rsp), %rax ; addq $8, %rsp ; (carrega o valor do topo atual em %rax e depois incrementa %rsp em 8)
push primeiro abre espaço (movendo %rsp para um endereço mais baixo) e depois escreve o valor ali; pop primeiro lê o valor do topo atual e depois recupera esse espaço (movendo %rsp de volta para um endereço mais alto). Essas duas instruções são a realização mecânica exata da linguagem de "empilhar um frame / desempilhar um frame" que the-stack-and-automatic-storage já usou conceitualmente; aqui, aplicadas a um único valor de 8 bytes por vez, em vez de um frame inteiro.
call: empilha o endereço de retorno e depois salta
textcall someFunction ; equivalente a: pushq %rip_next ; jmp someFunction ; em que %rip_next é o endereço da instrução ; imediatamente DEPOIS desta instrução call
call faz duas coisas, exatamente nesta ordem: empilha o endereço da instrução imediatamente seguinte ao próprio call (este é o "endereço de retorno": onde a execução deve continuar quando a função chamada terminar) e então salta para o endereço da função de destino. Essa única instrução é inteiramente responsável pelas duas metades de "chamar uma função": lembrar para onde voltar e de fato transferir o controle.
ret: desempilha o endereço de retorno e salta para ele
textret ; equivalente a: popq %rip ; (desempilha o valor do topo atual da pilha para o ponteiro de instrução)
ret desempilha o valor que estiver no topo da pilha naquele momento e salta para ele, tratando esse valor como o endereço em que a execução deve continuar. Crucialmente, ret não verifica de forma independente se o valor que desempilha é de fato o endereço de retorno que call empilhou antes: ela confia totalmente na pilha, e essa é exatamente a suposição que stack-smashing-and-buffer-overflows já mostrou poder ser violada por uma escrita fora dos limites que sobrescreve exatamente esse valor antes de ret sequer executar.
Por que isso só funciona se a pilha estiver exatamente como call a deixou
call e ret formam um par casado, e sua correção depende inteiramente de o stack pointer estar exatamente no estado em que call o deixou quando ret executar. Se a função chamada empilhar três valores com push, mas só desempilhar dois deles antes do seu ret, o valor que ret vai desempilhar será um desses três valores que sobraram, e não o endereço de retorno real, e o programa vai saltar para um endereço lixo. É precisamente por isso que stack-frames-prologue-and-epilogue, o próximo conceito, não é contabilidade opcional: é a disciplina que garante que todo valor que uma função empilha internamente é desempilhado de novo antes de o ret dessa função executar.
Exemplos Resolvidos
Exemplo 1: rastreando %rsp numa chamada e num retorno simples
text; antes: %rsp = 0x7ffe1000 call myFunction ; empilha o endereço de retorno (0x7ffe... , a próxima instrução aqui) ; %rsp vira 0x7ffe0ff8 (diminuído em 8) ; salta para myFunction myFunction: pushq %rbx ; %rsp vira 0x7ffe0ff0 (diminuído em 8 de novo) ; ... corpo de myFunction ... popq %rbx ; %rsp vira 0x7ffe0ff8 (de volta a onde estava depois do call) ret ; desempilha o endereço de retorno de 0x7ffe0ff8 ; %rsp vira 0x7ffe1000 (de volta ao valor original) ; salta de volta para a instrução logo depois do call original
Quando ret executa, %rsp já voltou exatamente ao valor que tinha logo depois de call empilhar o endereço de retorno, porque myFunction desempilhou %rbx antes de retornar, desfazendo seu único push interno. Esse equilíbrio exato (todo push casado com um pop, restaurando %rsp antes do ret) é o que torna correta a confiança cega de ret no valor do topo da pilha.
Exemplo 2: o que acontece quando um push não é casado com um pop
textmyBuggyFunction: pushq %rbx ; %rsp diminui em 8 ; ... esqueceu de desempilhar %rbx antes de retornar ... ret ; desempilha o valor que estiver no topo agora, ; mas esse é o valor salvo de %rbx, e NÃO o endereço de retorno real!
ret não tem como saber que o valor no topo da pilha é na verdade o valor salvo de %rbx, e não um endereço de retorno genuíno: ela o desempilha e salta para ele incondicionalmente. O programa muito provavelmente vai travar (ou, pior, parecer continuar rodando enquanto executa quaisquer instruções lixo que por acaso morem naquele "endereço"), porque o push sem par deslocou todo acesso posterior à pilha em 8 bytes em relação ao que o código esperava.
Exemplo 3: chamadas aninhadas, e a ordem LIFO em que os endereços de retorno voltam
textmain: call first ; empilha o endereço de retorno de main; salta para first first: call second ; empilha o endereço de retorno de first; salta para second second: ; ... corpo ... ret ; desempilha o endereço de retorno de first; salta de volta para first first: ; (continuando logo depois de "call second") ret ; desempilha o endereço de retorno de main; salta de volta para main
Dois endereços de retorno se acumulam na pilha, na ordem em que as chamadas foram feitas: o de main (empilhado primeiro, então fica mais embaixo na pilha, mais longe do topo) e o de first (empilhado em segundo, ficando no topo). As duas instruções ret os desempilham exatamente na ordem inversa (primeiro o endereço de retorno de first, depois o de main), que é precisamente a disciplina LIFO que the-stack-and-automatic-storage já estabeleceu conceitualmente, agora mostrada como a consequência direta e mecânica de push/call sempre acrescentarem no topo e pop/ret sempre removerem do topo.
Equívocos Comuns e Armadilhas
- "
pushepopfazem a pilha crescer em direção aos endereços mais altos, já que 'push' soa como acrescentar algo em cima." A pilha cresce em direção aos endereços mais baixos no x86-64:pushdiminui%rsp,popo aumenta. O sentido cotidiano de "push" (acrescentar no topo) é preservado; o que merece atenção deliberada é só qual direção é "para cima" (em direção aos endereços mais baixos, e não aos mais altos). - "
callsó salta para a função de destino; o endereço de retorno é acompanhado de algum outro jeito." O própriocallempilha o endereço de retorno como parte da sua definição, na mesma instrução que faz o salto. Não existe mecanismo de contabilidade separado; a pilha é onde o endereço de retorno mora, sempre. - "
retde algum modo verifica se o endereço para o qual vai saltar é um endereço de retorno legítimo." Ela não faz verificação nenhuma: desempilha o valor que estiver no topo da pilha e salta para ele incondicionalmente, confiando totalmente que a pilha está no estado em que umcallcasado (e os pares equilibrados depush/popde cada função) a teriam deixado. Essa confiança incondicional é exatamente o questack-smashing-and-buffer-overflowsexplora. - "Um
pushsem par dentro de uma função é um bug menor, já que o valor só fica largado na pilha." Ele desloca%rspde forma permanente em relação ao que o resto da função (e seuret) espera, o que significa queretdesempilha o valor errado por completo. Não é um problema menor, e sim uma corrupção exatamente do mecanismo do qual este conceito depende.
Resumo
push e pop são as operações primitivas que movem %rsp (em direção aos endereços mais baixos no push, mais altos no pop) enquanto escrevem ou leem um valor no topo atual da pilha. call é definida como empilhar o endereço da instrução imediatamente seguinte a ela (o endereço de retorno) e então saltar para o destino; ret é definida como desempilhar o valor que estiver no topo da pilha e saltar para ele, sem absolutamente nenhuma verificação de que esse valor seja de fato um endereço de retorno legítimo. Esse par de instruções só se comporta corretamente quando todo valor que uma função empilha internamente é desempilhado de novo antes de o seu próprio ret executar, restaurando %rsp exatamente ao estado em que call o deixou: exatamente a disciplina que stack-frames-prologue-and-epilogue formaliza a seguir, e exatamente a suposição que um ataque de stack smashing, visto antes nesta disciplina, é construído para violar.