Hardware e sistemas operacionais · parte 16 de 27
Pilha e chamada de função: onde a CPU guarda o caminho de volta
Como a CPU guarda o endereço de retorno e as variáveis locais na pilha, com quadros medidos em arm64, a convenção de chamada em x86-64 e arm64 e erros comuns.
intermediárioPublicada em · 34 min de leitura
Pilha e chamada de função: onde a CPU guarda o caminho de volta
Imagine que você está resolvendo uma pergunta numa prova e, no meio, encontra “veja o exercício 4”. Você põe um bilhete na página atual, escrito “volte aqui depois”, e vai ao exercício 4. Lá, o exercício manda ver a tabela do apêndice, e você cola outro bilhete por cima do primeiro antes de sair. Quando termina a tabela, arranca o bilhete de cima e volta. Quando termina o exercício 4, arranca o bilhete seguinte e volta à página original. Os bilhetes formam uma pilha, e o último colado é sempre o primeiro a sair. A CPU faz algo parecido com cada chamada de função: guarda num lugar combinado o endereço de volta, e as contas da etapa, que chamamos de variáveis locais, ficam anotadas no mesmo papel. A analogia tem um limite claro: o seu bilhete cabe num pedaço de papel, e a pilha da CPU é uma faixa fixa da memória, que um dia acaba.
Esta aula responde a uma pergunta: como a CPU guarda o endereço de retorno e as variáveis locais de uma função? É a última aula da fase 3. A aula Ciclo de instrução e unidade de controle mostrou o contador de programa (PC) avançando de 4 em 4 e um desvio trocando o PC pelo alvo. Uma chamada de função é um desvio que promete voltar, e para voltar a CPU precisa lembrar de onde saiu. A aula Assembly na prática mostrou o prólogo e o epílogo de uma função sem otimização e deixou para cá o que eles fazem com a memória. A aula ISA, CISC e RISC apresentou os registradores e a ideia de instrução, e a aula ULA e registradores mostrou que a conta de um endereço também passa pela ULA. Quando a pilha for lida como parte da memória, a aula Hierarquia de memória e localidade explica por que o acesso repetido à mesma faixa é barato.
Um termo aparece antes da seção em que é explicado. Pilha, em inglês stack, é uma região da memória usada como fila de última entrada, primeira saída: o item colocado por último é o primeiro a ser retirado. A definição completa vem na seção 1.
Mapa da aula
| Seção | Pergunta que responde |
|---|---|
| 1. O que é a pilha e a chamada de função | O que a CPU faz quando uma função chama outra, e o que é um quadro? |
| 2. Para que serve | Qual problema existiria sem uma pilha? |
| 3. Exemplos em contextos diferentes | Onde a pilha aparece fora do assembly? |
| 4. Como funciona | Quais registradores apontam para a pilha, o que call e bl guardam, como é um quadro em x86-64 e em arm64, e como a recursão cresce? |
| 5. Detalhes e bordas | O que é chamada de cauda, o que acontece quando a pilha acaba, e o que muda com threads e segurança? |
| 6. Erros comuns e boas práticas | Quais enganos aparecem quando alguém lê uma pilha pela primeira vez? |
| 7. O programa que confere | Quais comandos reproduzem os números desta aula? |
| 8. Exercícios | Como praticar, com as respostas? |
| 9. Glossário | Quais termos a aula usa? |
1. O que é a pilha e a chamada de função
Uma função é um trecho de código que outro trecho chama e que, ao terminar, devolve o controle para quem chamou. A CPU não sabe o que é função. Ela só conhece instruções. Em x86-64, a chamada é a instrução call, e o retorno é a instrução ret. Em arm64, a chamada é bl e o retorno é ret. Quem dá o significado de função é a convenção que o compilador segue, e a convenção é o assunto da seção 4.6.
Uma chamada faz duas coisas ao mesmo tempo. Ela desvia o fluxo para o início da função, o que já sabemos fazer pelo PC da aula 3.3, e ela precisa deixar guardado o endereço da instrução seguinte à chamada, para que o ret saiba para onde voltar. O lugar combinado para esse endereço, e para as variáveis que a função cria, é a pilha. A pilha é uma faixa da memória RAM reservada ao programa, e a CPU a usa com um registrador que aponta sempre para o último item colocado. Em x86-64 esse registrador é o rsp. Em arm64 é o sp.
Cada chamada ativa ocupa um pedaço dessa faixa, que se chama quadro (em inglês, stack frame). O quadro guarda o endereço de retorno, um valor salvo que a função precisa devolver ao chamador, e as variáveis locais da função. Quando a função termina, o quadro é desfeito, e o espaço volta a ser usado pela próxima chamada. Por isso a pilha obedece à regra de última entrada, primeira saída: a chamada mais recente é a primeira a terminar.
Leia a figura de cima para baixo. Cada seta rotulada com call é uma chamada, e a caixa de baixo é a mais nova. O rsp, à direita, aponta para ela. Por isso a pilha cresce para baixo: o quadro novo ocupa endereços menores que o de quem chamou.
Siga as mensagens de cima para baixo. Cada call grava na pilha o endereço da instrução seguinte e só então desvia, e cada ret tira esse endereço de volta e devolve o controle. As setas tracejadas são retornos, e as contínuas são chamadas. Os endereços são os do executável linkado, e a seção 4.2 mostra de onde eles saem.
Há uma diferença entre as duas arquiteturas que a aula vai mostrar em detalhe. No x86-64, a instrução call escreve o endereço de retorno na própria pilha. No arm64, a instrução bl não escreve na pilha. Ela grava o endereço de retorno no registrador x30, chamado de link register, e a função que vai chamar outra funções precisa salvar esse registrador na pilha por conta própria. A função que não chama ninguém nem precisa fazer isso.
2. Para que serve
Sem pilha, cada função precisaria de um lugar fixo na memória para guardar o próprio endereço de retorno e as próprias variáveis. Isso funciona enquanto cada função existe em um único exemplar e nunca chama a si mesma. Quando a mesma função é chamada de dois lugares diferentes, ao mesmo tempo, o segundo uso sobrescreve o endereço e os valores do primeiro, e o primeiro não consegue mais voltar. O mesmo acontece com a recursão, que é uma função chamando a própria versão.
A pilha resolve isso dando a cada chamada o seu próprio pedaço. A chamada de dentro usa um quadro novo, abaixo do quadro de quem chamou, e não mexe no que está acima. Quando ela termina, o quadro some e o de quem chamou volta a ser o topo, com os valores exatamente como estavam. A ordem de última entrada, primeira saída, combina com essa estrutura, porque chamadas aninhadas terminam sempre na ordem inversa do início.
Esse arranjo também é o que permite threads. Cada thread executa uma sequência de chamadas própria, e por isso precisa de uma pilha própria. A aula de threads da fase 7 volta a esse ponto, e aqui basta saber que duas threads compartilham a memória do processo, mas não compartilham a pilha.
3. Exemplos em contextos diferentes
O primeiro exemplo é a recursão de um fatorial. A função fatorial(n) chama fatorial(n - 1) antes de terminar a conta, e cada nível espera o resultado do nível de baixo. Para calcular fatorial(4), a CPU precisa de quatro quadros vivos ao mesmo tempo. Quem já viu um erro de “pilha de recursão estourada” sabe o que acontece quando o número de quadros cresce sem parar.
O segundo exemplo é o caminho de volta que um depurador mostra. Quando uma exceção é lançada, o programa imprime a lista de funções ativas, e cada linha dessa lista é um quadro. O exemplo abaixo tem três funções, e a exceção nasce na mais interna. A saída mostra a cadeia inteira, da função de topo até a que falhou.
def troca_ficha(valor):
return 10 / valor
def calcula_total(valores):
return sum(troca_ficha(v) for v in valores)
def processa_pedido():
return calcula_total([2, 0])
processa_pedido()
Traceback (most recent call last):
File "traceback.py", line 10, in <module>
processa_pedido()
File "traceback.py", line 8, in processa_pedido
return calcula_total([2, 0])
^^^^^^^^^^^^^^^^^^^^^
File "traceback.py", line 5, in calcula_total
return sum(troca_ficha(v) for v in valores)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "traceback.py", line 5, in <genexpr>
return sum(troca_ficha(v) for v in valores)
^^^^^^^^^^^^^^
File "traceback.py", line 2, in troca_ficha
return 10 / valor
~~~^~~~~~~
ZeroDivisionError: division by zero
saida=1
Leia a saída de baixo para cima. A última linha do bloco é a função que dividiu por zero. A linha acima dela é a função que a chamou, e assim por diante até o módulo principal. Cada bloco de duas linhas é um quadro, e a lista inteira é a pilha de chamadas no momento da falha. O caminho do arquivo é o do próprio exemplo, traceback.py.
O terceiro exemplo é uma função folha, que é aquela que não chama nenhuma outra. Uma função dessas não precisa guardar endereço de retorno para ninguém, e em arm64 o compilador pode deixar de criar quadro. A seção 4.5 mostra o caso real, e a seção 5.1 mostra que o otimizador ainda pode eliminar a própria chamada.
4. Como funciona
Nesta seção a pilha é desmontada peça por peça. Primeiro vêm os registradores que apontam para ela, depois o que a instrução de chamada faz, depois a forma de um quadro nas duas arquiteturas, e por fim a recursão e a convenção que diz quem leva qual valor.
Os exemplos desta seção usam um arquivo de funções pequenas e um programa que imprime endereços. As funções estão em quadro.c, e o programa que imprime os endereços da recursão está em run.c. Os dois compilam com a mesma ferramenta, o clang da Apple, versão 21.0.0 neste Mac, e com o alvo de cada arquitetura.
// Funções sem printf: a listagem do desmontador fica curta e completa.
__attribute__((noinline))
long dobro(long x) {
long y = x * 2;
return y;
}
long soma_dobro(long a, long b) {
long t = dobro(a);
long u = dobro(b);
return t + u;
}
long fatorial(long n) {
if (n <= 1) return 1;
return n * fatorial(n - 1);
}
long dobro(long x);
long soma_dobro(long a, long b);
long fatorial(long n);
int main(void) { return (int)(soma_dobro(3, 4) + fatorial(3)); }
4.1 Os registradores que apontam para a pilha
O registrador de pilha é o ponteiro do topo. Em x86-64 é o rsp, e a convenção do ABI diz que ele aponta para o byte de endereço mais baixo que faz parte da pilha em uso. A pilha cresce para baixo: um item novo ocupa um endereço menor que o item anterior. Um push diminui o rsp de 8 bytes e grava o valor, e um pop lê o valor e aumenta o rsp de 8 bytes.
Em arm64 o registrador é o sp, que tem a mesma função mas não é um registrador de uso geral. O ABI do arm64 também exige que o sp esteja alinhado a 16 bytes sempre que houver acesso à memória por ele. Além do ponteiro de pilha, cada arquitetura tem um registrador de quadro, que aponta para uma posição fixa dentro do quadro atual e facilita o acesso às variáveis locais. Em x86-64 é o rbp, e em arm64 é o x29.
O programa de endereços confirma o sentido do crescimento. Ele imprime o endereço de uma variável local em cada nível da recursão, e a diferença entre dois níveis seguidos é o tamanho do quadro. No arm64 deste Mac, a diferença é 0x40, ou 64 bytes, como o trecho abaixo mostra. O endereço absoluto muda a cada execução por causa da randomização do endereço de carga, e por isso a aula compara só as diferenças.
#include <stdio.h>
// Cada chamada recursiva cria um quadro novo: a variável local muda de endereço a cada nível.
long fatorial(long n) {
long local = n;
printf("n=%ld &local=%p\n", n, (void *)&local);
if (n <= 1) return 1;
return n * fatorial(n - 1);
}
int main(void) {
printf("resultado=%ld\n", fatorial(4));
return 0;
}
n=4 &local=0x16b0d2558
n=3 &local=0x16b0d2518
n=2 &local=0x16b0d24d8
n=1 &local=0x16b0d2498
resultado=24
saida=0
4.2 O que call e bl fazem
Em x86-64, a instrução call faz duas escritas. Primeiro subtrai 8 do rsp e grava nesse endereço a instrução seguinte à chamada. Depois troca o PC pelo alvo da função. A instrução ret faz o inverso: lê o valor no topo da pilha, põe esse valor no PC e soma 8 ao rsp.
O executável linkado de quadro.c torna visível o alvo real de cada chamada. A listagem abaixo é a função soma_dobro, extraída do objdump com um filtro que mantém só o trecho dela. O filtro não editou nenhuma instrução.
0000000100000360 <_soma_dobro>:
100000360: 55 pushq %rbp
100000361: 48 89 e5 movq %rsp, %rbp
100000364: 48 83 ec 20 subq $0x20, %rsp
100000368: 48 89 7d f8 movq %rdi, -0x8(%rbp)
10000036c: 48 89 75 f0 movq %rsi, -0x10(%rbp)
100000370: 48 8b 7d f8 movq -0x8(%rbp), %rdi
100000374: e8 c7 ff ff ff callq 0x100000340 <_dobro>
100000379: 48 89 45 e8 movq %rax, -0x18(%rbp)
10000037d: 48 8b 7d f0 movq -0x10(%rbp), %rdi
100000381: e8 ba ff ff ff callq 0x100000340 <_dobro>
100000386: 48 89 45 e0 movq %rax, -0x20(%rbp)
10000038a: 48 8b 45 e8 movq -0x18(%rbp), %rax
10000038e: 48 03 45 e0 addq -0x20(%rbp), %rax
100000392: 48 83 c4 20 addq $0x20, %rsp
100000396: 5d popq %rbp
100000397: c3 retq
100000398: 0f 1f 84 00 00 00 00 00 nopl (%rax,%rax)
Na linha de call dobro, o byte e8 é o código da instrução, e os quatro bytes seguintes são o deslocamento, em complemento de dois. O deslocamento é somado ao endereço da instrução seguinte, que é o endereço de retorno. A conta foi conferida num programa isolado, que recebe os bytes copiados da listagem e aplica a regra. A saída confere o alvo com o que o objdump mostrou.
x86 call em 0x100000374: retorno empilhado = 0x100000379, rel32 = -57, alvo = 0x100000340
x86 call em 0x1000003cd: retorno = 0x1000003d2, rel32 = -50, alvo = 0x1000003a0
arm64 bl em 0x100000360: imm26 = -14, deslocamento = -56, alvo = 0x100000328, endereço de retorno em x30 = 0x100000364
Em arm64, a instrução bl não escreve na pilha. Ela grava em x30 o endereço da instrução seguinte e desvia para o alvo. O alvo é o endereço da própria instrução mais o campo imm26 multiplicado por 4. A conta da saída acima confere o alvo da instrução bl de dobro. A instrução ret, sem operando, usa o x30 por padrão. A página de RET do espelho de Stanford, que não é oficial, diz “Defaults to X30 if absent”, e a especificação AAPCS64 diz na seção 6.5 que bl transfere o endereço de retorno para o link register.
4.3 Um quadro em x86-64 e em arm64 lado a lado
A figura abaixo põe o quadro de soma_dobro nas duas arquiteturas, com os mesmos valores nos mesmos papéis. A função guarda dois argumentos, a e b, e dois resultados intermediários, t e u, e chama dobro duas vezes. Os endereços são os da listagem da seção 4.2 e os da listagem do arm64 logo abaixo.
Leia cada coluna de cima para baixo, que é do endereço mais alto para o mais baixo. Em azul ficam os valores que a própria CPU precisa para voltar: o endereço de retorno e o registrador de quadro salvo. Em verde ficam as variáveis que a função escreveu. No x86-64, o rbp fica no meio do quadro e as variáveis aparecem com deslocamentos negativos a partir dele, com a e b vindo dos registradores de argumento. No arm64 o quadro começa com o par x29 e x30 salvo por stp, e as variáveis ficam em deslocamentos positivos a partir do sp. A figura mostra que as duas arquiteturas guardam as mesmas informações em lugares diferentes.
A listagem do arm64 confirma a posição de cada variável, e a figura foi montada a partir dela.
0000000100000348 <_soma_dobro>:
100000348: d100c3ff sub sp, sp, #0x30
10000034c: a9027bfd stp x29, x30, [sp, #0x20]
100000350: 910083fd add x29, sp, #0x20
100000354: f81f83a0 stur x0, [x29, #-0x8]
100000358: f9000be1 str x1, [sp, #0x10]
10000035c: f85f83a0 ldur x0, [x29, #-0x8]
100000360: 97fffff2 bl 0x100000328 <_dobro>
100000364: f90007e0 str x0, [sp, #0x8]
100000368: f9400be0 ldr x0, [sp, #0x10]
10000036c: 97ffffef bl 0x100000328 <_dobro>
100000370: f90003e0 str x0, [sp]
100000374: f94007e8 ldr x8, [sp, #0x8]
100000378: f94003e9 ldr x9, [sp]
10000037c: 8b090100 add x0, x8, x9
100000380: a9427bfd ldp x29, x30, [sp, #0x20]
100000384: 9100c3ff add sp, sp, #0x30
100000388: d65f03c0 ret
O prólogo do arm64 faz sub sp, sp, #0x30 e salva o par x29 e x30 com stp. O epílogo faz o caminho inverso com ldp e add, e termina com ret. Em x86-64 o prólogo é push rbp, mov rsp para rbp e sub 0x20 do rsp, e o epílogo é add 0x20, pop rbp e ret. Os dois prólogos reservam espaço para as quatro variáveis e para o alinhamento. Em x86-64 o total do quadro de soma_dobro é 0x20 mais o rbp salvo e o endereço de retorno, e no arm64 são 0x30 bytes, com o endereço de retorno dentro deles.
4.4 A recursão: um quadro por nível
A recursão é a chamada que cria a maior pilha antes de voltar. Em run.c, a função fatorial imprime o endereço de uma variável local, chama a si mesma e espera o resultado. A saída da seção 4.1 mostrou os quatro níveis, com a diferença de 0x40 entre cada um. A figura abaixo desenha a pilha no momento em que o nível mais fundo está ativo.
Siga a figura de cima para baixo. O quadro de fatorial(4) é o mais antigo e fica no endereço mais alto. Cada chamada desce 64 bytes, e o sp, à direita, aponta para o quadro de fatorial(1), que está ativo. Os rótulos entre os quadros dizem o que a chamada faz: subtrair 0x40 do sp. Quando fatorial(1) devolve 1, o sp sobe 64 bytes, e o nível de cima volta a ser o ativo. A conta fatorial(4) vale 24, como a saída mostrou, porque cada nível multiplica o próprio n pelo resultado do nível de baixo.
Esse crescimento linear é o que faz a recursão ser cara em memória. Cada nível de fatorial custa 64 bytes no arm64 deste Mac, e a pilha tem um limite. A seção 5.2 mede esse limite. No x86-64, o cálculo pela listagem de fatorial mostra outra conta: o endereço de retorno (8 bytes), o rbp salvo (8 bytes) e 0x20 bytes de variáveis e alinhamento somam 48 bytes por nível. Esse número é uma conta feita a partir da listagem, e não foi medido, porque o binário x86-64 não roda neste Mac.
4.5 Variáveis locais: onde elas moram
Uma variável local é uma variável declarada dentro de uma função. Sem otimização, o compilador a põe no quadro, e cada uso vira uma leitura ou uma escrita na pilha. Com otimização, o compilador pode mantê-la num registrador durante toda a função, e então ela nem aparece na memória. A decisão é do compilador, e o programador só a vê no assembly.
A função dobro de quadro.c mostra os dois casos. Sem otimização, o arm64 guarda o argumento na pilha, lê de volta, faz a conta e grava o resultado de novo na pilha antes de devolvê-lo. Com otimização, a mesma função é uma instrução de deslocamento seguida de ret, sem quadro nenhum. A figura abaixo põe lado a lado as duas formas.
Leia a coluna da esquerda de cima para baixo, com cada passo gravando ou lendo a pilha em verde. A conta, em vermelho, acontece no registrador x8. A coluna da direita tem a versão otimizada, em que a própria conta ocorre no registrador x0 e a função termina sem tocar na pilha. Os dois caminhos devolvem o mesmo valor, e a diferença está em quantas vezes a memória é usada.
A listagem do -O2 abaixo confirma o caso sem quadro. Ela vem do mesmo arquivo tail.c, que aparece na seção 5.1.
__attribute__((noinline)) long dobro(long x) { return x * 2; }
__attribute__((noinline)) long so_repassa(long x) { return dobro(x); }
__attribute__((noinline)) long dobro_mais_um(long x) { return dobro(x) + 1; }
long dobro(long x);
long so_repassa(long x);
long dobro_mais_um(long x);
int main(void) { return (int)(so_repassa(2) + dobro_mais_um(3)); }
0000000100000328 <_dobro>:
100000328: d37ff800 lsl x0, x0, #1
10000032c: d65f03c0 ret
0000000100000330 <_so_repassa>:
100000330: 17fffffe b 0x100000328 <_dobro>
0000000100000334 <_dobro_mais_um>:
100000334: a9bf7bfd stp x29, x30, [sp, #-0x10]!
100000338: 910003fd mov x29, sp
10000033c: 97fffffb bl 0x100000328 <_dobro>
100000340: 91000400 add x0, x0, #0x1
100000344: a8c17bfd ldp x29, x30, [sp], #0x10
100000348: d65f03c0 ret
No -O2 o dobro do arm64 tem duas instruções, lsl e ret, e não cria quadro. No x86-64 o mesmo código ainda tem push do rbp e mov do rsp, porque o clang para macOS manteve o registro de quadro nesta compilação. A aula 3.4 tinha usado a opção -fomit-frame-pointer para encurtar a listagem. Aqui essa opção não foi usada, e a diferença aparece no prólogo.
4.6 A convenção de chamada: quem leva qual valor
O endereço de retorno é só uma parte do acordo. Para que o código chamado e o código que chama se entendam, eles precisam concordar em qual registrador leva cada argumento, em qual leva o resultado, e quais registradores a função chamada deve devolver intactos. Esse acordo é a convenção de chamada, e ela vem do ABI da plataforma (application binary interface, a interface binária entre o código compilado e o sistema).
O ABI do x86-64 de System V, na versão 1.0 de 12 de março de 2025, diz que o primeiro argumento vai em rdi, o segundo em rsi, o terceiro em rdx, o quarto em rcx, e os seguintes em r8 e r9. O valor de retorno vai em rax. Os registradores rbx, rbp e de r12 a r15 pertencem ao chamador, e a função chamada precisa devolvê-los com o mesmo valor. A mesma fonte diz que o rsp deve estar alinhado a 16 bytes imediatamente antes da instrução call, e que os 128 bytes abaixo do rsp formam a zona vermelha, que uma função folha pode usar sem ajustar o rsp.
O ABI do arm64 da AAPCS64, na versão de abril de 2025 do repositório da Arm, diz que os registradores de x0 a x7 carregam os argumentos e os resultados, e que x8 leva o endereço de um resultado grande. Os registradores de x19 a x28 são preservados pela função chamada, e x29 é o frame pointer, x30 o link register. A mesma especificação diz que a pilha cresce do endereço alto para o baixo, em modo full-descending, e que SP mod 16 deve ser zero em todo acesso.
| Assunto | x86-64 (System V) | arm64 (AAPCS64) |
|---|---|---|
| Primeiro argumento | rdi | x0 |
| Segundo argumento | rsi | x1 |
| Valor de retorno | rax | x0 |
| Endereço de retorno | empilhado pelo call | x30, gravado pelo bl |
| Registrador de quadro | rbp, opcional | x29 |
| Preservados pela função chamada | rbx, rbp, r12 a r15 | x19 a x28, x29 |
| Alinhamento da pilha | 16 bytes antes do call | 16 bytes em todo acesso |
A tabela resume o que foi conferido nas duas especificações. A Apple publica variações próprias do ABI do arm64, por exemplo sobre o registrador x18 e sobre argumentos variádicos. A página oficial da Apple não trouxe o texto nesta sessão, então esta aula não afirma nenhum detalhe dessa variação.
4.7 O que a CPU faz em cada chamada
A aula 3.3 mostrou o ciclo de instrução com as etapas IF, ID, EX, MEM e WB, mas o modelo daquela aula não tinha call nem ret. Essas duas instruções seguem o mesmo ciclo, e a figura abaixo mostra a sequência de uma chamada e de um retorno em x86-64. As mensagens são as da execução de call dobro, com os endereços da listagem linkada.
Leia a figura de cima para baixo. A busca do call coloca o PC no endereço da instrução. Na execução, a ULA subtrai 8 do rsp. Na etapa de memória, o endereço de retorno é gravado em [rsp], e na escrita o PC recebe o alvo de dobro. O ret faz a leitura da memória no topo da pilha, troca o PC pelo valor lido e soma 8 ao rsp. A parte que a aula 3.3 não tinha é a escrita e a leitura do endereço de retorno, que usam a memória no mesmo papel que a instrução lw usava, mas agora com o rsp como endereço.
5. Detalhes e bordas
5.1 Chamada de cauda
Uma chamada de cauda é uma chamada que acontece como última ação da função. A função so_repassa de tail.c só chama dobro e devolve o resultado. Com -O2, o compilador pode trocar o call por um salto, porque o endereço de retorno que a função so_repassa empilharia seria o mesmo que o chamador já tem. A listagem da seção 4.5 mostra isso: no arm64, so_repassa vira um b para dobro, e no x86-64 o call vira um jmp depois de o quadro ser desfeito.
O efeito é que a pilha não cresce nessa chamada, e dobro devolve o resultado direto para quem chamou so_repassa. Já dobro_mais_um precisa somar 1 depois que dobro retorna, então ela continua com um call normal. A figura abaixo compara as duas situações.
A linha de cima mostra dobro_mais_um: o chamador faz uma chamada que se desdobra em outra, e a volta passa por dobro_mais_um para somar 1. A linha de baixo mostra so_repassa, que só repassa. Como o salto não empilha nada, dobro volta direto para o chamador original, e nenhum quadro intermediário fica vivo. A diferença está no que sobra na pilha durante a chamada.
Essa otimização depende do compilador e do nível de otimização. Não conferi nesta aula o texto da norma da linguagem C para dizer se ela é obrigatória, e a aula não afirma que todo compilador a faz.
5.2 Quando a pilha acaba
Uma recursão sem caso de parada, ou com um caso de parada que nunca é atingido, cria quadros até o limite da pilha. Quando não há mais espaço na região reservada, o próximo acesso cai fora dela e o sistema encerra o processo com um sinal de falha de segmentação, SIGSEGV. A figura abaixo desenha essa decisão em cada chamada.
O programa deep.c é uma recursão sem caso de parada, com um vetor local em cada quadro, e imprime uma linha por nível no erro padrão.
#include <stdio.h>
// Cada nível cria um quadro com um vetor local. A recursão não tem caso de parada:
// o programa só termina quando a pilha acaba.
long descer(long n) {
volatile long pad[2];
pad[0] = n;
fprintf(stderr, "nivel=%ld &pad=%p\n", n, (void *)&pad[0]);
return descer(n + 1) + pad[0];
}
int main(void) {
descer(1);
return 0;
}
O tamanho do quadro dele pode ser lido nas linhas impressas, e o número de linhas diz até que nível a execução chegou. Ele foi rodado com o limite de pilha padrão deste Mac e com um limite maior, usando o comando ulimit do shell para mudar só a execução.
8176
saida=139
104535
nivel=1 &pad=0x16d68a558
nivel=2 &pad=0x16d68a508
nivel=3 &pad=0x16d68a4b8
nivel=104535 &pad=0x16ce90a78
bash: line 1: 23910 Segmentation fault: 11 ./deep_arm 2> niveis8.txt
Com 8176 KB de limite, a execução chegou a algo perto de 104534 níveis, com variação de poucas unidades entre execuções, e a saída do shell foi 139, que é 128 mais o número 11 do sinal SIGSEGV. A diferença de endereço entre dois níveis seguidos é 0x50, ou 80 bytes, e esse é o tamanho do quadro de descer neste build. A conta simples, 8176 KB vezes 1024 bytes dividido por 80 bytes, dá 104652. A medida ficou cerca de 118 níveis abaixo disso, o que equivale a cerca de 9,5 KB que o processo já usava antes da primeira chamada. Essa parte não foi investigada, e a aula não diz de onde vêm esses 9,6 KB.
saida=139
209597
nivel=209597 &pad=0x16e544a98
bash: line 1: 23917 Segmentation fault: 11 ./deep_arm 2> niveis16.txt
Com 16384 KB de limite, a execução chegou a cerca de 209600 níveis, quase o dobro do primeiro caso. A contagem varia em poucas unidades de uma execução para outra, por isso a aula não dá um número exato. A proporção confirma que a profundidade máxima cresce com o limite e cai com o tamanho do quadro. Em outras palavras, o número de níveis que cabem na pilha é a faixa dividida pelo quadro, e não o número de linhas do código.
5.3 Threads e outras pilhas
Cada thread tem a sua pilha, e o tamanho dela pode ser diferente do da thread principal. O ulimit deste shell só altera a thread principal do processo que ele inicia, e não diz o tamanho das outras threads. O tamanho padrão de pilha de thread não foi medido nesta aula, e a fase 7 da trilha trata do assunto quando falar de threads.
5.4 Segurança: o endereço de retorno fica na memória
O endereço de retorno e as variáveis locais estão na mesma região da memória. Uma escrita que passa do fim de uma variável local pode alcançar o endereço de retorno e trocá-lo por outro. Esse é o princípio de uma classe de ataques conhecida como estouro de buffer na pilha. Os compiladores modernos adotam defesas para isso, como valores de controle, chamados de canários, gravados antes do endereço de retorno e conferidos antes do ret. Não conferi essas defesas na listagem desta aula, e a aula 9.4 trata do tema com mais profundidade.
6. Erros comuns e boas práticas
O primeiro erro é confundir a pilha com o heap, a região onde malloc reserva memória. A pilha é desfeita sozinha quando a função retorna, e o heap só é liberado quando o programa libera o bloco. Quem guarda um endereço de variável local e o usa depois de a função terminar está lendo um quadro que já foi desfeito, e isso leva ao erro do segundo item.
O segundo erro é devolver o endereço de uma variável local. O compilador avisa, e o aviso é o melhor momento para ler com atenção.
A figura mostra a sequência que causa o erro. A função cria x no quadro, devolve o endereço dele e o rsp volta ao quadro do chamador. Quando o chamador lê *p, o espaço já pode conter outro dado, e o C não garante o valor que estava ali.
O arquivo aviso.c tem dois casos, um de endereço de variável local e outro de variável que pode não receber valor. A saída do clang com os avisos está abaixo.
long *endereco_local(void) {
long x = 5;
return &x;
}
long valor_nao_iniciado(int ok) {
long x;
if (ok) x = 1;
return x;
}
aviso.c:3:13: warning: address of stack memory associated with local variable 'x' returned [-Wreturn-stack-address]
3 | return &x;
| ^
aviso.c:7:9: warning: variable 'x' is used uninitialized whenever 'if' condition is false [-Wsometimes-uninitialized]
7 | if (ok) x = 1;
| ^~
aviso.c:8:12: note: uninitialized use occurs here
8 | return x;
| ^
aviso.c:7:5: note: remove the 'if' if its condition is always true
7 | if (ok) x = 1;
| ^~~~~~~
aviso.c:6:11: note: initialize the variable 'x' to silence this warning
6 | long x;
| ^
| = 0
2 warnings generated.
O terceiro erro é achar que uma variável local começa com zero. O C não garante o valor de uma variável não inicializada, e o aviso de uso não inicializado existe porque o valor pode ser qualquer coisa. Inicialize sempre a variável na declaração, e o compilador deixa de avisar.
O quarto erro é tratar o quadro de -O0 como se fosse o que roda em produção. Como a seção 4.5 mostrou, o -O2 pode eliminar o quadro de uma função folha e manter registradores no lugar da pilha. Antes de tirar conclusões sobre o uso de memória, confira o nível de otimização da compilação que você está lendo.
O quinto erro é esquecer que no arm64 a função que chama outra precisa salvar o x30. A função dobro, que não chama ninguém, não salva o x30 e não precisa. A função soma_dobro salva o par x29 e x30 no prólogo, e um desenvolvedor que escreve assembly à mão e esquece esse passo faz o ret voltar para o lugar errado.
A boa prática geral é tratar a pilha como um recurso finito. Recursões com muitos níveis, vetores grandes declarados como variáveis locais e chamadas profundas consomem a faixa, e o limite depende do sistema e da thread. Quando a profundidade é previsível e grande, uma versão iterativa ou um vetor no heap costuma ser a escolha mais segura.
7. O programa que confere
O programa desta aula foi rodado neste Mac, um Apple M1 Max, com o clang da Apple versão 21.0.0 e o Python isolado (python3 -I). A execução nativa só é possível para arm64, e a listagem do x86-64 foi conferida só pelo desmontador. Os comandos abaixo reproduzem as saídas que aparecem nas seções anteriores.
Os arquivos de funções e de programa foram os mesmos da seção 4 e da seção 5. O programa de conferência de alvos é o conferencia.py abaixo, que lê os bytes da listagem do objdump e aplica a regra de cada arquitetura.
# Confere, a partir dos bytes do objdump, o endereço de retorno do call x86 e o alvo do bl e do call.
def sext(valor, bits):
return valor - (1 << bits) if valor & (1 << (bits - 1)) else valor
# x86-64: call rel32 (e8 cd) em 0x100000374; a instrução ocupa 5 bytes.
endereco = 0x100000374
rel = sext(int.from_bytes(bytes([0xc7, 0xff, 0xff, 0xff]), "little"), 32)
retorno = endereco + 5
print(f"x86 call em {endereco:#x}: retorno empilhado = {retorno:#x}, rel32 = {rel}, alvo = {retorno + rel:#x}")
# x86-64: call de fatorial em 0x1000003cd, bytes e8 ce ff ff ff
endereco = 0x1000003cd
rel = sext(int.from_bytes(bytes([0xce, 0xff, 0xff, 0xff]), "little"), 32)
print(f"x86 call em {endereco:#x}: retorno = {endereco + 5:#x}, rel32 = {rel}, alvo = {endereco + 5 + rel:#x}")
# ARM64: bl em 0x100000360, palavra 0x97fffff2; imm26 nos 26 bits baixos, deslocamento = imm26 * 4
palavra = 0x97fffff2
imm26 = sext(palavra & 0x3FFFFFF, 26)
endereco = 0x100000360
print(f"arm64 bl em {endereco:#x}: imm26 = {imm26}, deslocamento = {imm26 * 4}, alvo = {endereco + imm26 * 4:#x}, endereço de retorno em x30 = {endereco + 4:#x}")
A listagem do x86-64 abaixo foi gerada com o clang para o alvo x86_64-apple-macos13. O executável não roda neste Mac, e a saída do sistema para a tentativa aparece logo depois.
bash: ./quadro_x86: Bad CPU type in executable
saida=126
Os comandos que geraram as saídas são estes, em ordem: a compilação e o objdump dos dois alvos com quadro.c e main_quadro.c; a execução de run_arm; a conferência de alvos; a leitura do limite de pilha e as duas execuções de deep_arm; e a compilação com -O2 de tail.c. Todas as saídas foram copiadas da execução, e os filtros aplicados com awk mantêm só o trecho da função pedida.
8. Exercícios
Exercício 1. Na listagem do arm64 de soma_dobro, a instrução stp x29, x30, [sp, #0x20] grava o par no quadro. Em que endereço, relativo ao sp, fica o x30 salvo?
Resposta: o stp grava x29 no endereço sp+0x20 e x30 no endereço seguinte, sp+0x28. O x30 salvo fica em sp+0x28, que é o endereço de retorno da função.
Exercício 2. O limite de pilha deste Mac é 8176 KB, e o quadro de descer mede 80 bytes. Quantos níveis caberiam se a pilha inteira fosse usada sem nenhum custo extra?
Resposta: 8176 vezes 1024 dividido por 80 dá 104652 níveis. A execução medida chegou a algo entre 104533 e 104535 níveis, uma diferença de cerca de 118 níveis, ou cerca de 9,5 KB, que o processo usa antes da primeira chamada. A conta ideal é um limite superior, e a medida é a que vale.
Exercício 3. Por que o dobro do arm64 em -O2 não tem prólogo, e o so_repassa do x86-64 em -O2 ainda tem push de rbp?
Resposta: o dobro é uma função folha simples, e o compilador mantém o valor em x0 sem precisar de quadro. O so_repassa do x86-64 mantém o registro de quadro porque o clang para macOS, nesta compilação, o manteve. A seção 4.5 mostrou as duas listagens, e a conclusão vale para este compilador e estes alvos.
Exercício 4. Um programa devolve o endereço de uma variável local. O compilador avisa, e o programa roda sem erro na primeira execução. Por que o aviso importa mesmo assim?
Resposta: o quadro da função é desfeito no retorno, e o endereço passa a apontar para uma região que pode ser reutilizada pela próxima chamada. A primeira execução pode dar o valor certo por acaso, e uma execução posterior pode dar outro valor. O aviso aponta exatamente essa situação.
Exercício 5 (sem resposta fechada). Escreva uma função em C que chame outra duas vezes e tenha três variáveis locais. Compile para arm64 com -O0 e para x86-64 com -O0, e meça a diferença de tamanho do quadro com a listagem. Diga quais variáveis ficam no quadro e quais o compilador colocou em registradores. A resposta depende do código que você escrever.
9. Glossário
Pilha: região da memória usada para os quadros das chamadas, com acesso de última entrada, primeira saída.
Quadro: pedaço da pilha ocupado por uma chamada ativa, com o endereço de retorno, valores salvos e variáveis locais.
Endereço de retorno: endereço da instrução seguinte à chamada, para onde o ret devolve o controle.
rsp e sp: registradores que apontam para o topo da pilha, em x86-64 e em arm64.
rbp e x29: registradores de quadro, que apontam para uma posição fixa do quadro atual.
x30 (link register): registrador do arm64 que recebe o endereço de retorno da instrução bl.
Convenção de chamada: acordo entre quem chama e quem é chamado sobre os registradores de argumentos, de retorno e de preservação.
Função folha: função que não chama outra função.
Chamada de cauda: chamada feita como última ação, que pode virar um salto e não empilhar um novo retorno.
Zona vermelha: os 128 bytes abaixo do rsp no ABI do x86-64, que uma função folha pode usar sem ajustar o rsp.
SIGSEGV: sinal de falha de segmentação, enviado quando o programa acessa memória que não pode acessar, como fora da pilha.
O que não foi confirmado ou medido
Não foi medido nenhum tempo de chamada, de retorno ou de acesso à pilha. As contas de bytes por nível no x86-64 foram feitas pela listagem, porque o binário não roda neste Mac. O tamanho de 48 bytes por nível de fatorial no x86-64 é uma conta, e não uma medição.
A diferença de 9,6 KB entre a conta ideal e a medida da pilha profunda não foi investigada. A aula não diz de onde ela vem e não a atribui a nenhuma parte do sistema.
A variação do ABI da Apple para o arm64, incluindo o registrador x18 e os argumentos variádicos, não foi conferida, porque a página oficial não trouxe o texto nesta sessão. A aula não afirma detalhes dessa variação.
A obrigatoriedade da chamada de cauda pela linguagem C não foi conferida no texto da norma. A aula descreve o que o clang fez nesta compilação, e não o que a norma exige.
As defesas de pilha, como os canários, não foram conferidas em listagem. A aula apenas as cita e remete para a aula 9.4.
Fontes
Consultadas em 1 de novembro de 2026.
- Arm Limited, Procedure Call Standard for the Arm 64-bit Architecture (AArch64), versão 2025Q1 com data de emissão de 7 de abril de 2025, publicada no release 2025Q4 do repositório ARM-software/abi-aa. Seções usadas: 6.1.1 sobre o papel dos registradores, 6.4.5 sobre a pilha full-descending e o alinhamento de 16 bytes, 6.4.6 sobre o registro de quadro e 6.5 sobre bl e o link register. Arquivo consultado: https://github.com/ARM-software/abi-aa/releases/download/2025Q4/aapcs64.pdf
- System V Application Binary Interface, AMD64 Architecture Processor Supplement, versão 1.0 de 12 de março de 2025, mantido no repositório x86-psABIs do GitLab. Seções usadas: 3.2.1 sobre a preservação dos registradores e a zona vermelha, 3.2.2 sobre o quadro da pilha e o alinhamento, e a tabela 3.4 de uso dos registradores. Arquivo consultado: https://gitlab.com/x86-psABIs/x86-64-ABI/-/jobs/artifacts/master/raw/x86-64-ABI/abi.pdf?job=build
- Espelho não oficial da página de RET do arm64, mantido na Universidade de Stanford por Zyedidia: https://www.scs.stanford.edu/~zyedidia/arm64/ret.html. Usado apenas para a frase de que o registrador padrão de RET é o X30. A fonte primária é o manual DDI 0487 da Arm, que não foi alcançado nesta sessão.
- Ferramentas usadas: Apple clang 21.0.0 (clang-2100.3.34.2) com os alvos x86_64-apple-macos13 e arm64-apple-macos13, objdump da LLVM do macOS, Python 3.12 no modo isolado (python3 -I) e o interpretador Python para o traceback.
Relacionados
- Trilha de Hardware
- Ciclo de instrução e unidade de controle
- Assembly na prática
- ISA, CISC e RISC
- ULA e registradores
- Hierarquia de memória e localidade
- CPU