← Voltar aos artigos

Primeiro programa em assembly ARM

Objetivo

Até aqui a trilha decodificou instruções ARM (à mão e via arm-jitter) e olhou código gerado pelo compilador. Nesta lição a via é inversa: escrever assembly ARM do zero, sem C, sem biblioteca — só instruções, um _start e o exit code como prova de que o resultado está certo. Duas funções pequenas bastam para isso: fib(n), que exercita laço e flags, e max(a, b), que mostra o ARM resolvendo um if sem desviar. No fim você vai ter montado (as), ligado (ld) e rodado os dois binários no arm-box.

O contrato: r0 e bx lr

A convenção de chamada (AAPCS) do ARM, em uma frase: argumentos entram em r0–r3, o retorno sai em r0, e bx lr volta. bl funcao grava o endereço da instrução seguinte em lr (r14) e pula; bx lr desvia de volta para lá. Isso é tudo que uma folha (função que não chama ninguém) precisa saber.

Para provar o resultado sem printf, os exemplos usam o exit code: mov r7, #1 + svc #0 é o exit(r0) do Linux. O arm-box propaga o r0 do guest como código de saída do processo.

fibonacci(n)

A sequência de Fibonacci é definida por F(0) = 0, F(1) = 1 e F(n) = F(n-1) + F(n-2) para n > 1 — cada termo é a soma dos dois anteriores (0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55...). A versão recursiva direta da definição existe, mas refaz o mesmo cálculo várias vezes (custo exponencial); a versão iterativa abaixo carrega só os dois últimos termos (a e b) e os atualiza n vezes num laço — custo linear, sem pilha de chamadas, exatamente o tipo de laço simples que serve para mostrar registrador, flag e desvio condicional em assembly puro.

fib.s (.elf) — versão iterativa, n em r0:

_start:
    mov     r0, #10            @ n = 10
    bl      fib
    mov     r7, #1            @ exit(r0)
    svc     #0

fib:                           @ r0 = n  ->  r0 = fib(n)
    cmp     r0, #1
    bxle    lr                @ n <= 1: r0 já é a resposta (0 ou 1)
    mov     r1, #0            @ a = fib(0)
    mov     r2, #1            @ b = fib(1)
    mov     r3, r0            @ contador = n
1:  add     r12, r1, r2       @ next = a + b
    mov     r1, r2            @ a = b
    mov     r2, r12           @ b = next
    subs    r3, r3, #1
    bne     1b               @ repete n vezes
    mov     r0, r1            @ resultado = a
    bx      lr

Montando, ligando e rodando:

arm-none-eabi-as -march=armv7-a -o fib.o fib.s
arm-none-eabi-ld -Ttext=0x10000 -o fib.elf fib.o
java -jar target/armbox.jar --arch=armv7a fib.elf ; echo $?
55

fib(10) = 55: o arm-box confirma que o laço está certo. Vale conferir o binário montado contra o fonte — é o objdump -d fib.elf, mostrando endereço, bytes da instrução e o mnemônico que o arm-none-eabi-as gerou a partir do que escrevemos:

00010010<fib>:
10010:e3500001cmpr0, #1
10014:d12fff1ebxlelr@ cond = LE
...
10030:e2533001subsr3, r3, #1
10034:1afffffabne10024

Três pontos que não aparecem só lendo o fonte:

  • bxle lr é condicional, não um if escondido.BX aceita o mesmo sufixo de condição de qualquer instrução ARM (o campo cond que ocupa os 4 bits mais altos da palavra). Um nibble é meio byte — 4 bits, exatamente o tamanho de um dígito hexadecimal — então dá pra ler o campo cond direto no primeiro dígito hex da instrução, sem converter nada pra binário: em d12fff1e, o primeiro dígito, d, é o cond e codifica LE. Ou seja: essa linha só desvia quando a comparação anterior (cmp r0, #1) deu "menor ou igual". Quando n <= 1, ela substitui um if (n <= 1) return n; inteiro sem nenhum b (branch) de verdade.
  • O laço depende de subs, não de sub. É o S no fim que faz a subtração atualizar as flags (Z, N, C, V); é essa flag Z que o bne (branch se Z=0) lê logo depois para decidir se repete. Trocar por sub sem S quebra o laço — é exatamente o bug do exercício mais abaixo.
  • 1b no fonte é um rótulo numérico local: "o 1: mais próximo para trás" (por isso o disassembly mostra o endereço real, 10024, no lugar do 1b simbólico). Útil em laços curtos sem precisar inventar um nome só para eles.
  • r12 (ip) é o único registrador que a AAPCS reserva como scratch garantido — dá pra usar como temporário (aqui, para guardar a + b) sem se preocupar em salvar o valor antigo.

max(a, b) — sem um único desvio

Depois de um laço, o segundo exemplo é o oposto: uma função sem nenhum laço e sem nenhum desvio condicional, mesmo resolvendo um if. max(a, b) devolve o maior dos dois argumentos — o tipo de função tão simples que o compilador normalmente reduz a um cmp + jl + mov em x86. Em ARM dá pra fazer melhor, e o motivo é o mesmo do bxle acima: qualquer instrução pode ser condicional.

max.s (.elf):

_start:
    mov     r0, #17
    mov     r1, #42
    bl      max
    mov     r7, #1
    svc     #0

max:                           @ r0 = a, r1 = b  ->  r0 = maior
    cmp     r0, r1
    movlt   r0, r1            @ se a < b: r0 = b   (senão mantém a)
    bx      lr
java -jar target/armbox.jar --arch=armv7a max.elf ; echo $?
42

Desmontado:

00010014<max>:
10014:e1500001cmpr0, r1
10018:b1a00001movltr0, r1@ cond = LT
1001c:e12fff1ebxlr

Três instruções, e a segunda repete o mesmo truque do bxle de cima: em b1a00001, o primeiro dígito hex, b, é o campo cond e codifica LT. Ou seja, movlt r0, r1 só copia r1 pra r0 se a última cmp deu "menor" — nas outras vezes é um nop disfarçado, r0 fica com o valor que já tinha (a).

Em x86 isso seria um cmp + jl + mov + label — um desvio de verdade, que o preditor de desvios da CPU pode errar (e pagar o preço de esvaziar o pipeline). No ARM não existe desvio nenhum aqui: movlt é uma instrução comum de mover registrador, só que com um bit de condição embutido nela. Essa é a predicação da qual o artigo de flags fala, aqui pagando o aluguel.

O compilador faz o mesmo: no artigo de C, o -O2 transformou um if num par cmp/movXX sempre que o corpo cabia numa instrução.

Exercício

Quebre de propósito e veja o arm-box reportar:

  • Troque movlt por movgt em max — o programa passa a devolver o menor.
  • Em fib, esqueça o subs (deixe sub r3, r3, #1, sem o S): o bne passa a olhar uma flag velha e o laço nunca termina. O arm-box roda até o limite e você percebe o hang.
  • Passe n = 13 em fib: fib(13) = 233, ainda cabe em 8 bits. n = 14 dá 377, que satura o exit code em 377 & 0xff = 121 — um bug clássico de "resultado maior que o canal de saída".

Glossário

TermoO que é
Folha (leaf)função que não chama nenhuma outra; não precisa salvar lr
bl / bx lrchamar (grava retorno em lr) / voltar
Rótulo numérico local1: … 1b (back) / 1f (forward) — alvo anônimo reutilizável
Predicaçãoexecutar (ou não) uma instrução conforme as flags, sem desviar
ip (r12)registrador scratch da AAPCS

Comandos

ComandoO que faz
arm-none-eabi-as -march=... -o x.o x.smonta
arm-none-eabi-ld -Ttext=ADDR -o x.elf x.oliga com o código em ADDR
java -jar target/armbox.jar --arch=armv7a x.elf ; echo $?roda e mostra o exit code
java -jar target/armbox.jar --gdb=3333 x.elfroda sob GDB para inspecionar

Próximo passo

Você montou com as e ligou com ld sem pensar muito no -Ttext. O próximo artigo abre o processo de montagem e ligação: o que o linker faz, o linker script e o mapa de memória.

Trilha: Curso de Arquitetura ARM · Módulo 3, lição 1. Ver também: Carga e armazenamento no ARM, Sub-rotinas em ARM.