Procedimentos e pilha
Chamadas leaf e non-leaf, registos preservados, frames e passagem de argumentos.
Chamar uma rotina exige guardar um endereço de retorno e acordar onde seguem os argumentos e o resultado. A convenção de chamadas também distingue os registos que a rotina pode alterar dos que tem de repor, para que o chamador possa continuar a execução sem conhecer todas as instruções da rotina chamada.
Chamar e voltar
jal ra,rotina coloca o endereço da instrução seguinte em ra e muda o PC. ret equivale a jalr zero,0(ra), por isso volta sem criar um novo endereço de retorno.
Se a chamada estiver em 0x00400020, ra recebe 0x00400024. Uma rotina leaf não chama outras rotinas e pode usar esse ra diretamente para voltar, enquanto uma non-leaf faz outra chamada que sobrescreve ra e tem de preservar o retorno para o seu próprio chamador antes dessa chamada. Isto não contradiz ra ser caller-saved: é precisamente porque a chamada interior pode alterá-lo que a non-leaf guarda o valor de que ainda precisa.
Quem conserva o quê
| Registos | Regra |
|---|---|
| a0 a a7, t0 a t6, ra | Caller-saved: podem mudar numa chamada |
| s0 a s11, sp | Callee-saved: o chamado repõe o valor anterior |
| gp, tp | Reservados pela convenção, não usar como temporários |
Os primeiros oito argumentos inteiros seguem em a0 a a7; resultados inteiros usuais em a0 e a1. Mais argumentos podem seguir na pilha, segundo o contrato de chamada.
Se o chamador precisa de t0 depois da chamada, tem três opções: guardá-lo na sua própria pilha, recalculá-lo depois ou movê-lo para um registo s. A terceira só vale se esse s já estiver preservado no seu próprio frame. A obrigação mantém-se: esse s tem de voltar intacto ao seu próprio chamador se a rotina respeita a ABI. Se o chamado usa s0, guarda o s0 que recebeu e restaura-o antes de voltar. Um registo ser callee-saved não significa que já contenha um valor útil para a rotina: significa que o chamador o vai reencontrar intacto.
Frames e alinhamento
A pilha cresce para endereços menores. Reservar 16 bytes faz addi sp,sp,-16; libertar faz addi sp,sp,16. A ABI normal mantém sp alinhado a 16 bytes. Um frame pode reservar mais espaço do que os valores guardados para cumprir esse alinhamento.
Não escrevas abaixo de sp sem reservar o espaço. Guarda e recupera os valores nos mesmos offsets. Todos os caminhos de retorno têm de libertar o frame e restaurar o que foi preservado.
Exemplo non-leaf
Queremos . A rotina dobro é leaf e devolve . A rotina mais_um_dobro calcula x+1, chama dobro e devolve o resultado. Guarda ra porque a chamada interior o altera; guarda s0 porque o usa.
.text
.globl main
main:
andi sp, sp, -16
li s0, 99
li a0, 7
jal ra, mais_um_dobro
li a7, 1
ecall
li a0, 10
li a7, 11
ecall
mv a0, s0
li a7, 1
ecall
li a7, 10
ecall
mais_um_dobro:
addi sp, sp, -16
sw ra, 12(sp)
sw s0, 8(sp)
addi s0, a0, 1
mv a0, s0
jal ra, dobro
lw s0, 8(sp)
lw ra, 12(sp)
addi sp, sp, 16
ret
dobro:
slli a0, a0, 1
retExecuta o código para ver a saída e os gráficos.
Se é o sp recebido, reservar 16 bytes põe sp em . Os offsets são relativos ao novo sp:
| Endereço | Offset no frame | Conteúdo |
|---|---|---|
| Fora do frame | Limite superior; sp recebido | |
| 12(sp) | Word com ra recebido | |
| 8(sp) | Word com s0 recebido, 99 | |
| a | 0 a 7 | Espaço não usado |
O frame ocupa os 16 bytes de a . Descer sp reserva endereços; não guarda automaticamente os registos.
A rotina coloca 8 em s0 e passa esse valor em a0. A chamada a dobro modifica ra e produz o resultado em a0. O epílogo restaura s0 e ra sem alterar a0, liberta os 16 bytes e volta. Guardar ra depois do jal preservaria o retorno interior, não o retorno correto.
No exemplo executável, main alinha o sp inicial do simulador com andi sp,sp,-16. Isso é preparação do programa principal, não uma operação para uma rotina fazer ao sp do chamador. Uma rotina comum assume o alinhamento acordado e deve devolver exatamente o sp recebido.
Chamada auxiliar dentro de um ciclo
Para contar carateres alfanuméricos, a rotina percorre a string com o apontador em s0 e o contador em s1, ambos preservados no seu frame. Em cada iteração copia o byte para a0 com lbu, chama is_alnum e só depois avança s0 e soma o resultado de a0 a s1. Guardar o apontador em t0 falharia porque a chamada pode alterar t0 (caller-saved).
.data
texto: .asciz "A7"
.text
.globl main
main:
andi sp, sp, -16
la a0, texto
jal ra, conta_alnum
li a7, 1
ecall
li a7, 10
ecall
conta_alnum:
# a0: base da string asciz; devolve a contagem em a0
# s0: apontador, s1: contador; esta rotina preserva ambos para o chamador
addi sp, sp, -16
sw ra, 12(sp)
sw s0, 8(sp)
sw s1, 4(sp)
mv s0, a0
li s1, 0
ciclo:
lbu t0, 0(s0)
beq t0, zero, fim
mv a0, t0
jal ra, is_alnum
add s1, s1, a0
addi s0, s0, 1
j ciclo
fim:
mv a0, s1
lw s1, 4(sp)
lw s0, 8(sp)
lw ra, 12(sp)
addi sp, sp, 16
ret
is_alnum:
# a0: byte; devolve 1 se letra ou digito ASCII, 0 caso contrario
# so usa a0 e t1 (caller-saved); nao toca em s0/s1
li t1, 48
bltu a0, t1, nao
li t1, 58
bltu a0, t1, sim
li t1, 65
bltu a0, t1, nao
li t1, 91
bltu a0, t1, sim
li t1, 97
bltu a0, t1, nao
li t1, 123
bltu a0, t1, sim
nao:
li a0, 0
ret
sim:
li a0, 1
retExecuta o código para ver a saída e os gráficos.
O programa completo testa "A7" e imprime 2:
| Byte em s0 | a0 antes da auxiliar | a0 depois | s1 depois | Passo seguinte |
|---|---|---|---|---|
A | 65 | 1 | 1 | Avançar s0 um byte |
7 | 55 | 1 | 2 | Avançar s0 um byte |
00 | Não há chamada | 2, resultado da contagem | 2 | Restaurar o frame e voltar |
s0 e s1 sobrevivem a cada jal porque is_alnum respeita os registos callee-saved; esta implementação usa apenas a0 e t1. Por sua vez, conta_alnum guardou no seu frame os valores de s0 e s1 recebidos de main e repõe-nos no fim. Preservar o estado do ciclo e preservar o estado do chamador são obrigações em níveis diferentes.
Validar com saída antecipada
Contar alfanuméricos percorre todo o texto. Para saber se todos os bytes são alfanuméricos, basta encontrar um que não seja: nesse caso devolvemos 0 imediatamente. Só devolvemos 1 quando chegamos ao terminador sem encontrar um erro.
Adotamos um contrato explícito: a0 recebe o endereço de uma string ASCII terminada em zero, com memória válida até esse terminador. A auxiliar is_alnum recebe um byte em a0 e devolve exatamente 0 ou 1, respeitando a ABI. A string vazia devolve 1, pois não contém nenhum byte inválido. Se a aplicação exigir pelo menos um carácter, deve rejeitar o terminador inicial num teste separado.
Antes de cada leitura, s0 aponta para o próximo byte e todos os anteriores já passaram na auxiliar. Não precisamos de um contador. Precisamos de conservar s0 entre chamadas, preservar o s0 recebido e guardar o retorno exterior em ra.
# a0: string ASCII terminada em zero; resultado 0 ou 1 em a0
# is_alnum: byte em a0; devolve 0 ou 1; respeita a ABI
# String vazia e valida segundo o contrato desta rotina.
valida_alnum:
addi sp, sp, -16
sw ra, 12(sp)
sw s0, 8(sp)
mv s0, a0
valida_ciclo:
lbu a0, 0(s0)
beq a0, zero, valida_sim
jal ra, is_alnum
beq a0, zero, valida_fim
addi s0, s0, 1
j valida_ciclo
valida_sim:
li a0, 1
valida_fim:
lw s0, 8(sp)
lw ra, 12(sp)
addi sp, sp, 16
ret
O trecho define apenas a rotina; a auxiliar tem o contrato indicado acima. Em A7, chama-a com 65 e 55, depois encontra zero e devolve 1. Em A-7, chama-a com 65 e 45; a segunda chamada devolve 0 e o programa sai sem ler o 7. A string vazia termina sem chamadas à auxiliar.
Os ramos de sucesso e de erro passam pelo mesmo epílogo. Assim ambos restauram s0, ra e sp, sem substituir o resultado em a0. Guardar o apontador apenas em a0 perderia esse endereço na primeira chamada, porque a0 passa a conter o resultado da auxiliar.
Chamadas dentro de ciclos e recursão
Cada chamada recursiva necessita do seu próprio frame para os argumentos ou resultados parciais que ainda vai usar. Não guardes o estado de todas as chamadas numa única variável global. Define primeiro o caso base para que a recursão termine; depois verifica que cada caminho tem um epílogo correto.
Os ecall de impressão e saída pertencem ao ambiente RARS: a7=1 imprime o inteiro de a0, a7=11 imprime um carácter e a7=10 termina. Não substituem a convenção de chamadas entre rotinas e não são serviços portáveis para qualquer sistema operativo.
Para retomar a relação entre a pilha e sp, podes consultar Rodrigo Dias:
Exercícios
Por que a rotina não volta?
Uma rotina executa jal ra,auxiliar e depois ret, sem guardar o ra recebido. Explica para onde o ret salta e mostra como corrigir.
Pista
O jal interior substitui o retorno exterior.
Mais uma pista
Guarda ra antes do jal e restaura antes do ret.
Solução
O jal coloca em ra o endereço da instrução seguinte dentro da própria rotina. Após a auxiliar voltar, o ret usa esse mesmo endereço interior e não regressa ao chamador exterior. Reserva um frame alinhado, guarda ra, chama, restaura ra e liberta o frame antes de ret. Uma leaf que não chama outras rotinas não precisa desta preservação só por ser chamada.
Confere a tua resposta:
- A chamada interior sobrescreve ra.
- Guarda o retorno exterior antes do jal e restaura-o antes do ret.
Erros frequentes
Não guardes ra depois da chamada, porque já perdeu o valor exterior.
Valores vivos numa chamada
Uma rotina guarda um apontador em t0 e um contador em t1, chama uma auxiliar e continua a usá-los. A auxiliar cumpre a ABI mas modifica t0 e t1. Quem tem de corrigir o programa?
Pista
Os registos t são caller-saved, por isso a auxiliar pode alterá-los sem violar a ABI.
Solução
O chamador tem de guardar t0 e t1, ou colocar os valores em s0 e s1, preservando os antigos s0,s1 no seu próprio frame. A auxiliar pode modificar t0,t1 sem violar a ABI. A convenção não garante preservação só porque o nome sugere uma variável importante.
Confere a tua resposta:
- A correção cabe ao chamador.
- Preserva valores vivos ou usa registos s com preservação correta.
Erros frequentes
Não imponhas uma restrição extra à auxiliar sem a declarar no contrato.
Espaço e alinhamento
Uma non-leaf precisa de guardar ra,s0,s1, três words. O sp recebido está alinhado a 16. Propõe offsets e o menor tamanho do frame e mostra a libertação.
Pista
12 bytes de dados não obriga a frame de 12 bytes.
Mais uma pista
Arredonda a reserva para manter alinhamento de 16.
Solução
Reserva 16 com addi sp,sp,-16. Pode guardar ra em 12(sp), s0 em 8(sp), s1 em 4(sp). Restaura nesses mesmos offsets, faz addi sp,sp,16 e ret. O espaço em 0(sp) pode ficar livre. Com frame de 12, sp ficaria desalinhado na chamada interior.
Confere a tua resposta:
- Reserva mínima 16 bytes para manter alinhamento.
- Offsets distintos dentro do frame, restaurados antes de libertar exatamente 16 bytes.
Erros frequentes
Não escrevas os três valores antes de reservar o frame nem libertes um tamanho diferente.
Resultados parciais independentes
Uma rotina recursiva precisa do argumento original depois da chamada interior. Pode conservá-lo só em a0? Explica que estado pertence a cada frame.
Pista
a0 passa argumentos e recebe resultados.
Mais uma pista
A chamada interior pode alterar a0 e ra.
Solução
Não pode contar com a0 preservado. Cada frame guarda o argumento original ou um resultado parcial necessário, além de ra e de registos s usados. A chamada interior tem o seu próprio frame. Ao voltar, a rotina recupera os seus dados e combina-os com o resultado devolvido. O caso base deve evitar nova chamada e todos os caminhos repõem sp.
Confere a tua resposta:
- Cada frame guarda o estado necessário da sua chamada.
- A0 e ra podem mudar na chamada interior; inclui caso base e restauração.
Erros frequentes
Uma variável global única não conserva estados distintos de chamadas simultaneamente ativas.
Rejeitar sem perder o retorno
Escreve uma rotina RV32 que recebe em a0 o endereço de uma string ASCII terminada em zero, válida em memória até esse terminador. Deve devolver 1 se todos os bytes antes de zero forem alfanuméricos e 0 caso contrário. A string vazia devolve 1. Usa a auxiliar is_alnum, que recebe um byte em a0, devolve exatamente 0 ou 1 e pode alterar qualquer registo caller-saved, cumprindo a ABI. Não implementes a auxiliar.
Faz a rotina sair ao encontrar o primeiro byte inválido. Segue A7, A-7 e a string vazia, indicando quantas vezes chama a auxiliar e como repõe o estado do chamador.
Pista
O apontador tem de sobreviver à chamada, mas a0 muda de endereço para byte e depois para resultado. Decide onde guardar cada valor vivo.
Solução
Antes de cada leitura, todos os bytes anteriores passaram na auxiliar. Conservamos o apontador em s0 e guardamos o s0 recebido e ra num frame de 16 bytes. Uma solução é:
# a0: string ASCII terminada em zero; resultado 0 ou 1 em a0
# is_alnum: byte em a0; devolve 0 ou 1; respeita a ABI
# String vazia e valida segundo o contrato desta rotina.
valida_alnum:
addi sp, sp, -16
sw ra, 12(sp)
sw s0, 8(sp)
mv s0, a0
valida_ciclo:
lbu a0, 0(s0)
beq a0, zero, valida_sim
jal ra, is_alnum
beq a0, zero, valida_fim
addi s0, s0, 1
j valida_ciclo
valida_sim:
li a0, 1
valida_fim:
lw s0, 8(sp)
lw ra, 12(sp)
addi sp, sp, 16
retA7 faz duas chamadas, com 65 e 55, e devolve 1 ao encontrar o zero. A-7 faz duas chamadas, com 65 e 45; a segunda devolve 0 e a rotina volta sem ler o terceiro byte. Vazio não faz chamadas e devolve 1. Todos os caminhos passam por valida_fim, que repõe s0, ra e sp sem alterar o resultado em a0. Não precisamos de preservar o byte depois da chamada: só interessa o resultado devolvido.
Confere a tua resposta:
- Testei o zero antes da auxiliar e aceitei vazio segundo o contrato.
- Rejeitei no primeiro byte inválido, sem ler os restantes.
- Preservei apontador, ra e s0 recebido, e restaurei sp nos dois resultados.
Erros frequentes
Testar a0 depois de jal testa o resultado da auxiliar, não o byte original. Guardar o apontador num t sem o preservar permite que a auxiliar o destrua.
