Funções, convenção de chamada e stack frames
TL;DR
- Você vai aprender como chamadas de função são implementadas no RV32 usando o ABI (Application Binary Interface):
jal,jalr, o registradorra(Return Address) e osp(Stack Pointer). - Vai aprender a reconhecer prólogos/epílogos de função, entender por que registradores são salvos/restaurados, e mapear offsets de pilha para variáveis locais/argumentos em C.
- Vai praticar depuração de pilha com GDB: inspecionar frames, backtraces e memória ao redor de
sp.
1. As duas instruções que definem chamadas
jal (Jump And Link)
- Salva
pc+4emra(x1) - Salta para um alvo
jalr (Jump And Link Register)
- Usada para chamadas indiretas e retornos
- Um retorno é comumente:
| |
Forma pseudo-instrução:
| |
2. Funções leaf vs non-leaf
Função leaf: não chama outras funções
- muitas vezes não precisa salvar
ra - às vezes nem toca a pilha
- muitas vezes não precisa salvar
Função non-leaf: chama outras funções
- precisa preservar o endereço de retorno entre chamadas
- normalmente salva
rana pilha
3. Um stack frame canônico
Um prólogo/epílogo RV32 típico (não universal) é assim:
| |
O que isso faz
- Reserva 16 bytes de espaço na pilha
- Salva registradores callee-saved usados pela função (geralmente
s0) - Salva
rase necessário - Opcionalmente configura
s0como frame pointer
-fomit-frame-pointer) em builds otimizadas, o que dificulta o unwind da pilha.4. Na prática: forçar um stack frame claro
Crie:
| |
Compile com flags que ajudam na leitura:
| |
Disassemble:
| |
O que localizar
- O prólogo/epílogo de
outereinner - Onde as variáveis locais vivem (offsets na pilha)
- Onde os argumentos são colocados (registradores, e às vezes pilha se são muitos)
5. Depure a pilha com GDB (comandos práticos)
Rode com o stub do GDB:
| |
Em outro terminal:
| |
Agora inspecione:
(gdb) info registers sp ra s0 a0 a1
(gdb) bt
(gdb) info frame
(gdb) x/32wx $sp
(gdb) x/16i $pc
Interpretando x/32wx $sp
Você está fazendo dump de 32 words de memória a partir do stack pointer atual.
rasalvo normalmente aparece como um endereço de códigos0salvo pode aparecer- locais podem aparecer como padrões próximos aos regs salvos
$sp e $ra, dê um step, e depois faça dump de novo. Você verá o novo frame sendo construído.6. Corrupção de pilha: como acontece
Causas comuns:
- escrever além do fim de um array local
- tamanho errado em
memcpy - tratar um ponteiro como um tipo maior do que ele é
- protótipos de função inconsistentes (especialmente com funções variádicas)
- assembly manual que esquece de restaurar registradores callee-saved
Bug prático: overwrite off-by-one
Crie:
| |
Compile e rode:
| |
Agora depure e observe o canary:
| |
(gdb) b victim
(gdb) c
(gdb) p/x canary
(gdb) next
(gdb) p/x canary
7. Argumentos além de a0..a7
O RISC-V usa a0..a7 para os primeiros 8 argumentos inteiros/ponteiros.
Argumentos extras são passados na pilha.
Você pode demonstrar isso escrevendo uma função com 10 argumentos e inspecionando como o call site os prepara.
Exercícios
- Em
stack_demo, identifique quais registradores carregam parâmetros parainner. - Em
stack_demo, determine o offset na pilha (a partir despous0) delocal1elocal2, correlacionando disassembly com dumps de memória do GDB. - Crie uma função com 9 parâmetros inteiros e encontre onde o parâmetro #9 vive.
- No exemplo de overflow, mude o loop para
i < 16e confirme que o canary permanece intacto.
Como testar suas respostas
- Verifique offsets imprimindo endereços (
&local1) em C e comparando com$spno GDB. - Use
objdump -d -M numeric,no-aliasespara confirmar que stores/loads batem com os offsets que você acha.
Resumo
Você agora entende como chamadas de função no RV32 funcionam: ra, sp, prólogos/epílogos e estrutura de stack frame, e praticou depurar estado de pilha diretamente.
A seguir: linker scripts e mapas de memória, vamos controlar onde código/dados vivem, gerar arquivos .map e explicar por que imagens de firmware costumam começar em endereços como 0x80000000.