Conteúdos da cadeira

Assembly RISC-V

Registos, instruções base, acessos à memória, saltos e a convenção de chamada com a pilha.

Markdown

Perguntar sobre esta página

ChatGPTClaudePerplexityGeminiCopiar e abrir

Envia o link e pede à IA para ler a página. No Gemini, cola a pergunta copiada.

Ver pergunta para copiar
Nesta página

Em FSC programaste em LEGv8, um conjunto de instruções inventado para ensinar. Em AC programas em RISC-V, um conjunto real e aberto que equipa desde microcontroladores até supercomputadores. A boa notícia: as ideias são as mesmas, registos, memória endereçável ao byte, saltos e pilha, e só os nomes e os detalhes mudam. Esta página faz essa ponte.

Os registos que interessam

O RISC-V tem 32 registos inteiros, x0 a x31. Cada um tem um nome de função que deves usar no código:

NomeRegistosPara que serve
zerox0Vale sempre 0; escritas são ignoradas
rax1Endereço de regresso das chamadas
spx2Apontador da pilha
a0a7x10x17Argumentos das funções e valor de retorno (a0)
t0t6x5x7, x28x31Temporários (o chamado pode estragá-los)
s0s11x8, x9, x18x27Guardados (o chamado tem de os preservar)

Há duas diferenças grandes em relação ao LEGv8. A primeira: não há um registo de zero inventado, há o zero (x0), que vale sempre 0 e serve para inicializar registos e sintetizar instruções. A segunda: em vez de X30 como registo de ligação, há o ra, e em vez de pares dedicados há uma convenção explícita sobre que registos cada função pode estragar.

Os temporários t0t6 e os argumentos a0a7 são do chamador: se precisares do valor depois de uma chamada, guarda-o antes. Os guardados s0s11 são do chamado: uma função que os use tem de gravar o valor antigo na pilha e repô-lo antes de regressar. O s0 também faz de fp (frame pointer) quando a função precisa de uma referência estável à sua zona da pilha.

Contas, imediatos e memória

As operações entre registos têm o formato familiar de três operandos:

add  t0, t1, t2    # t0 = t1 + t2
sub  t0, t1, t2    # t0 = t1 - t2
and  t0, t1, t2    # e bit a bit
or   t0, t1, t2    # ou bit a bit
xor  t0, t1, t2    # ou exclusivo
slt  t0, t1, t2    # t0 = 1 se t1 < t2 (com sinal), senão 0

Com constantes usa-se a versão imediata, com um i no fim do nome:

addi t0, t1, 20    # t0 = t1 + 20
andi t0, t1, 0xFF  # t0 = t1 & 0xFF (só o byte baixo)
slli t0, t1, 2     # t0 = t1 << 2 (multiplica por 4)

Repara que não há um subi: para subtrair uma constante, somas o seu simétrico com addi. E ao contrário do LEGv8, os deslocamentos (slli, srli, srai) são instruções próprias, não um campo das instruções de soma.

Os acessos à memória usam lw e sw para palavras de 32 bits e ld e sd para doublewords de 64 bits:

lw   t0, 8(t1)     # t0 = memória[t1 + 8], palavra de 32 bits
sw   t0, 8(t1)     # memória[t1 + 8] = t0
ld   t0, 0(sp)     # t0 = memória[sp], 64 bits

Tal como no LEGv8, só as instruções de acesso tocam na memória; todo o resto trabalha entre registos. É a filosofia load-store dos RISC.

Saltos e decisões

Os saltos condicionais comparam dois registos e saltam se a condição se verificar:

beq  t0, zero, fim  # salta se t0 == 0
bne  t0, t1, ciclo  # salta se t0 != t1
blt  t0, t1, menor  # salta se t0 < t1 (com sinal)
bge  t0, t1, maior  # salta se t0 >= t1

Não há instrução de comparação separada nem flags: a comparação vive dentro do salto. Para condições compostas, combina com slt: calcula o resultado para um temporário e testa-o com beq ou bne contra zero.

Os saltos para procedimentos usam jal (jump and link) e jalr:

jal  ra, soma       # ra = PC + 4; PC = endereço de soma
jalr zero, 0(ra)    # PC = ra; regresso (equivale a ret)

jal ra, soma guarda em ra o endereço da instrução seguinte e salta. No fim da função, jalr zero, 0(ra) salta para esse endereço guardado. O ret que vês nos exemplos é só um nome simpático para esta segunda instrução.

Um exemplo completo: somar um vetor

Soma os 100 inteiros de 32 bits guardados a partir do endereço em a0 e devolve o total em a0:

soma_vetor:
    li    t0, 0        # t0 = soma acumulada
    li    t1, 0        # t1 = índice i
ciclo:
    bge   t1, a1, fim  # se i >= n (em a1), termina
    slli  t2, t1, 2    # t2 = i * 4 (cada inteiro ocupa 4 bytes)
    add   t2, a0, t2   # t2 = endereço de vetor[i]
    lw    t3, 0(t2)    # t3 = vetor[i]
    add   t0, t0, t3   # soma += vetor[i]
    addi  t1, t1, 1    # i++
    jal   zero, ciclo  # repete
fim:
    mv    a0, t0       # devolve a soma em a0
    jalr  zero, 0(ra)

Segue com valores pequenos para verificar: vetor [5, 7] em algum endereço base B, com n = 2. Na primeira volta, t2 = 0, lê-se memória[B] = 5, a soma fica 5. Na segunda, t2 = 4, lê-se memória[B+4] = 7, a soma fica 12. Na terceira, t1 = 2, o bge confirma e sai do ciclo com a0 = 12.

Duas instruções novas merecem atenção. li (load immediate) carrega uma constante qualquer num registo, o assembler trata de a decompor se ela não couber no imediato. mv a0, t0 copia um registo para outro e é na verdade addi a0, t0, 0. Vais ver muitas destas pseudo-instruções nos exemplos: são abreviaturas que o assembler traduz para instruções reais.

Chamadas e a pilha

Uma função que chama outra função tem um problema: a chamada interior escreve o seu próprio endereço de regresso em ra e apaga o da função atual. A solução é a pilha, que cresce para endereços mais baixos e se gere com sp:

maximo_3:
    addi  sp, sp, -16  # reserva 16 bytes (a pilha mantém-se alinhada a 16)
    sd    ra, 8(sp)    # guarda o endereço de regresso
    sd    s0, 0(sp)    # guarda s0, que vamos usar
    # ... corpo: chama maximo_2, usa s0 à vontade ...
    ld    s0, 0(sp)    # repõe s0
    ld    ra, 8(sp)    # repõe o regresso
    addi  sp, sp, 16   # liberta a zona
    jalr  zero, 0(ra)

A disciplina é rígida: reserva no início, guarda ra e todos os s que usares, repõe pela ordem inversa no fim. Uma função que não chama ninguém (leaf) e só usa temporários pode saltar a pilha toda. E lembra-te de que os argumentos vão em a0a7 e o resultado volta em a0: é assim que o chamador e o chamado combinam sem partilhar mais nada.

Corre este padrão no WebRISC-V ou no qtrvsim com uma função que chama outra e observa ra e sp a cada passo. Ver o endereço de regresso a ser guardado e reposto tira a magia toda ao mecanismo.

Para levar para a próxima página

Com o assembly assente, a pergunta passa a ser quanto tempo cada programa demora. A página sobre desempenho define o tempo de execução, o CPI e a lei de Amdahl, as três ferramentas com que vais comparar todas as técnicas desta cadeira.

Ver o ficheiro no GitHub

À tua maneira

Escolhe como preferes ler.

Aparência
Ajustar cores e largura
Cor de destaque do tema FEUP
Tipo de letra

Álgebra, lógica e uma ideia de cada vez.

As tuas escolhas ficam guardadas neste navegador.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.