Instruções básicas de assembly RISC-V
1. Objetivo
Aprender as famílias centrais de instruções do RV32I e praticar com um pequeno programa escrito à mão.
2. Famílias de instruções
- Aritmética:
add,addi,sub - Loads/stores:
lw,sw,lb,sb - Branches:
beq,bne,blt,bge - Jumps:
jal,jalr
3. Exemplo: somar dois números e armazenar o resultado
Arquivo: src/basic_add.s
| |
4. Linker script mínimo (e por que isso importa)
Quando você roda assembly bare-metal, não há sistema operacional nem runtime C para decidir onde o seu código e dados vivem na memória. O C Runtime (CRT) é o código que roda antes do seu main() e depois que ele termina.
Ele prepara o ambiente de execução de um programa C: pilha, argumentos, variáveis globais, bibliotecas, e só então chama main().
Nota curta sobre CRT (o que é e o que não é):
- CRT não é libc. É uma camada mínima de inicialização/finalização, normalmente dividida em objetos como
crt1.o,crti.oecrtn.o. - O kernel não chama
main(). O ponto de entrada é_start; o CRT fornece_starte depois chamamain()para você. - Sem CRT não há startup automático. Se você omite o CRT (como em bare-metal), precisa implementar
_startpor conta própria.
Sem CRT (C Runtime):
- Não há
main()automático - Você precisa fornecer
_start - Você fala diretamente com o kernel via syscalls (System Calls)
O linker precisa de instruções explícitas sobre:
- Ponto de entrada: onde a execução começa (
_start). - Endereço de memória: onde a imagem deve ser colocada (a máquina
virtdo QEMU espera RAM em0x80000000). - Layout de seções: onde
.text,.rodata,.datae.bssficam.
4.1. Crie um linker script mínimo:
Arquivo: src/minimal_link.ld
| |
4.2. O linker script
Estas são as instruções para o Linker. Pense no Linker como um construtor que pega pilhas de matéria-prima (as linhas compiladas do seu código) e as organiza em um prédio finalizado (o binário executável).
ENTRY(_start)- A porta de entrada: diz ao linker: “Quando o computador carregar este programa, a primeira instrução a executar está no rótulo
_start.” - Sem isso, o processador não saberia onde começar.
- A porta de entrada: diz ao linker: “Quando o computador carregar este programa, a primeira instrução a executar está no rótulo
SECTIONS { ... }- O blueprint: define o mapa de memória. Diz ao linker exatamente onde colocar cada parte do seu programa na RAM.
. = 0x80000000;- O contador de localização atual (
.): pense no ponto.como um cursor apontando para um endereço de memória. 0x80000000: essas linhas colocam o cursor nesse endereço específico.- Por que esse número? Em muitos sistemas RISC-V (como a máquina
virtdo QEMU), a RAM física começa em0x80000000. Endereços menores costumam ser registradores de hardware ou flash. Esta linha diz: “Comece a colocar nosso programa no início da RAM.”
- O contador de localização atual (
.text : { *(.text .text.*) }- A seção de código:
.texté onde seu código executável (instruções assembly) vive. { *(.text) }: significa “Pegue a seção.textde todos os arquivos de entrada (*) e coloque aqui.”- Como o cursor acabou de ser colocado em
0x80000000, o código vai primeiro.
- A seção de código:
.rodata : { *(.rodata .rodata.*) }- Dados somente leitura: constantes, como literais de string (“Hello World”) que não devem mudar.
- Elas ficam imediatamente após o código.
.data : { *(.data .data.*) }- Dados inicializados: variáveis globais com valor inicial (ex.:
int score = 10;). - Elas ficam depois de dados somente leitura.
- Dados inicializados: variáveis globais com valor inicial (ex.:
.bss : { *(.bss .bss.* COMMON) }- Dados não inicializados:
bsssignifica “Block Started by Symbol” (termo histórico). Idealmente guarda variáveis globais sem valor ainda (ex.:int buffer[100];). - O linker não grava zeros no arquivo para economizar espaço; ele apenas marca que esse espaço precisa ser reservado na RAM.
- Dados não inicializados:
Por que a ordem importa
Ao criar um linker script para assembly bare-metal, sempre coloque .text primeiro! A CPU salta cegamente para o endereço base (0x80000000). Se você colocar dados (como .rodata) primeiro, a CPU vai tentar “executar” bytes de dados como instruções, causando crash imediato. Embora loaders mais inteligentes (como o QEMU com -kernel) possam lidar com isso lendo o entry point do ELF, payloads binários crus em hardware real falharão.
4.3. Build e execução no QEMU
| |
O terminal vai travar por causa do loop infinito no final do programa. Isso é esperado. Para verificar o resultado, abra outro terminal e use gdb-multiarch para inspecionar a memória como explicado na próxima seção.
5. Conectar com gdb-multiarch
Para depurar, inicie o QEMU com o stub do GDB habilitado e pausado no reset:
| |
Em outro terminal, conecte-se manualmente usando gdb-multiarch e siga cada passo abaixo:
| |
Se preferir um comando único, você pode rodar:
| |
Agora você pode inspecionar o resultado:
| |
A saída deve mostrar 0x0000002a, que é 42 em decimal (40 + 2).
| |
- Para sair do GDB, digite
quitouq. - Para parar o QEMU, use
Ctrl+a xno terminal dele.
6. Exercícios
- Altere as constantes para calcular
7 + 13e verifique na memória. - Substitua
addporsube observe o novo resultado. - Adicione um loop que incrementa
g_resultcinco vezes.
7. Resumo
- Instruções RV32I são pequenas e ortogonais.
- Um programa mínimo pode rodar sem libc ao definir
_start. - QEMU + GDB permite inspecionar resultados diretamente na memória.