← Voltar aos artigosDecodificando instruções ARM à mão com o objdump (bit a bit)

Decodificando instruções ARM à mão com o objdump (bit a bit)

No artigo do GDB a gente prometeu: "abrimos o objdump do sum.elf e decodificamos as instruções ARM à mão, bit a bit". Pois é hoje. Todo mundo trata add r1, r1, r4 como mágica; depois deste texto você vai olhar para e0811004 e saber exatamente o que cada nibble significa. E adivinha: é exatamente a leitura que o decodificador do arm-jitter faz para saber o que emular.

O que o objdump mostra

Pegamos o sum.elf do artigo de carga e armazenamento (aquele que soma 3,7,1,9,4) e rodamos:

arm-none-eabi-objdump -d sum.elf

O trecho que importa (o laço) é este — e os bytes abaixo são reais:

00008010 <loop>:
    8010: e1520003  cmp   r2, r3
    8014: aa000003  bge   8028 <done>
    8018: e4904004  ldr   r4, [r0], #4
    801c: e0811004  add   r1, r1, r4
    8020: e2822001  add   r2, r2, #1
    8024: eaffff9  b     8010 <loop>

00008028 <done>:
    8028: e59f5010  ldr   r5, [pc, #16]
    802c: e5851000  str   r1, [r5]
    8030: e1a00001  mov   r0, r1
    8034: e3a07001  mov   r7, #1
    8038: ef000000  svc   0x00000000

Cada linha tem três colunas: o endereço (onde a instrução está na memória), os 4 bytes da instrução (um número de 32 bits, little-endian — leia da esquerda para a direita como o valor 0xe1520003) e o mnemônico que o desmontador inferiu. Nosso trabalho é fazer o caminho inverso: dos bytes de volta ao mnemônico.

Anatomia de uma instrução ARM

Em modo ARM (32-bit), toda instrução tem 32 bits. O pedaço mais importante é o topo: os 4 bits mais significativos (o primeiro nibble) são o campo cond — a condição de execução. 1110 (hex E) significa always (executa sempre). É por isso que quase toda instrução começa com e….

Logo abaixo, bits [27:26] dizem a "família". Para o nosso laço, três famílias aparecem:

  • 00 → instrução de processamento de dados (add, mov, cmp…).
  • 01 → load/store (ldr, str).
  • 10 → desvio (b, bl).

Para processamento de dados, o formato (do bit 31 ao 0) é este:

bits  31..28   cond        (condição de execução)
bits  27..26   00          (família: dados)
bit   25       I           (1 = operando é imediato; 0 = registrador+shift)
bits  24..21   opcode      (qual operação: ADD, MOV, CMP…)
bit   20       S           (1 = atualiza flags do CPSR)
bits  19..16   Rn          (registrador fonte 1)
bits  15..12   Rd          (registrador destino)
bits  11..0    operand2    (segundo operando)

Glossário: códigos de condição (campo cond)

O bge do nosso laço só desvia se o resultado foi "maior ou igual". Isso vive no cond. Tabela dos mais usados:

Códigos de condição ARM (bits 31..28)
Cond (bin)MnemônicoSignificado
1110ALSempre (default — por isso quase tudo começa com e).
0000EQEqual (flag Z = 1).
0001NENot equal (Z = 0).
1010GEGreater or equal (N == V). É o nosso bge.
1011LTLess than (N != V).
1100GTGreater (Z = 0 e N == V).
1101LELess or equal (Z = 1 ou N != V).
0010 / 0011CS / CCCarry set / clear (útil para unsigned).

Glossário: opcodes de processamento de dados

Os 4 bits do opcode (bits 24..21) determinam a operação:

Principais opcodes (bits 24..21)
Opcode (bin)MnemônicoOperação
0100ADDsoma
0010SUBsubtração
0000ANDE lógico
0001EOROU-exclusivo
1100ORROU lógico
1101MOVcópia de operando2 para Rd
1010CMPsubtrai e atualiza flags (Rd ignorado)
1110BICclear bit (AND com o complemento)

Decodificando na prática

mov r3, #5 = e3a03005

Dá para ler tudo sem adivinhação:

e3a03005
31     27 26 25   24 23 22 21   20   19..16  15..12  11............0
1110 [ 00 ]  1 [ 1  1  0  1 ]  0  [ 0000 ] [ 0011 ] [ 0000 0000 0101 ]
cond  00   I    MOV(1101)    S=0   Rn=0     Rd=r3    imm: rot=0, val=5
  • e = cond 1110 = AL (sempre).
  • 00 em [27:26] = família dados.
  • I = 1 (bit 25): o operando é um imediato.
  • 1101 = MOV.
  • S = 0: não mexe nas flags.
  • Rd = 3r3.
  • operando2 = 0x005: um valor de 8 bits com rotação (val=5, rot=0) → simplesmente 5. É assim que constantes pequenas cabem numa instrução só.

cmp r2, r3 = e1520003

O cmp é a chave do laço — ele é quem alimenta o bge.

e1520003
cond=1110 (AL) | 00 | I=0 | opcode=1010 (CMP) | S=1 | Rn=2 (r2) | Rd=ignorado | op2=reg r3
  • opcode 1010 = CMP; S = 1 força atualizar o CPSR.
  • Como o CMP só existe para comparar, o Rd é descartado — por isso o resultado não vai para lugar nenhum, só afeta as flags N, Z, C, V.
  • É exatamente o que vimos no artigo do GDB: após esse cmp, o bit Z do CPSR diz se r2 == r3.

add r1, r1, r4 = e0811004

O coração da soma acumulada:

e0811004
cond=1110 (AL) | 00 | I=0 | opcode=0100 (ADD) | S=0 | Rn=1 (r1) | Rd=1 (r1) | op2=reg r4
  • opcode 0100 = ADD; Rn = r1, Rd = r1, operando2 = r4.
  • Portanto: r1 = r1 + r4 — a soma acumulada cresce.

bge 8028 = aa000003

O desvio usa outra família (bits [27:26] = 10):

aa000003
cond=1010 (GE) | 10 (desvio) | L=0 (B, não BL) | offset = 0x000003
  • cond 1010 = GE (greater or equal) — só pula se o cmp anterior deu r2 >= r3.
  • O alvo é (PC + 8) + (offset << 2). Na instrução em 0x8014, PC+8 = 0x801C; 3 << 2 = 12 = 0xC; 0x801C + 0xC = 0x8028 = o rótulo done. Bateu!
  • Por isso existem dois add seguidos por um b no final: o laço só sai quando o bge decide pular.

ldr r4, [r0], #4 = e4904004

Família load/store (bits [27:26] = 01):

e4904004
cond=1110 (AL) | 01 (load/store) | [25:20]=modo de endereçamento
Rn=0 (r0) | Rd=4 (r4) | offset imediato = 4
  • Rn = r0 (o ponteiro), Rd = r4 (destino da leitura).
  • O formato [r0], #4 (vírgula depois do colchete) é pós-indexado com writeback: lê a palavra e só então soma 4 a r0. Os bits [25:20] codificam justo isso — é por isso que o ponteiro "anda sozinho" sem precisar de um add extra.
  • Curiosidade: o ldr r0, =nums do início virou e59f0034 (ldr r0, [pc, #52]) — como o endereço não cabe no imediato de 8 bits do mov, o assembler o guardou numa literal pool e fez leitura relativa ao PC. Daí a importância de separar "valor" de "endereço".

Glossário: comandos úteis

Para repetir em casa (a mesma toolchain do ambiente ARM):

Comandos para montar, ligar e desmontar
ComandoO que faz
arm-none-eabi-as -march=armv5te f.s -o f.oMonta o assembly em objeto.
arm-none-eabi-ld -static -o f.elf f.oLiga em um ELF estático (roda no arm-box).
arm-none-eabi-objdump -d f.elfDesmonta e mostra os bytes de cada instrução.
info registers cpsrNo GDB: mostra as flags que o cmp mudou.
x/4xw $r0No GDB: inspeciona 4 palavras a partir de r0.

O que o arm-jitter faz com esses bits

Quando o arm-box encontra e0811004, o decodificador do arm-jitter faz exatamente este processo: lê o cond, descarta se a condição não bate, olha os bits [27:26] para saber que é processamento de dados, decodifica o opcode e os registradores, e executa a operação — seja no interpretador ou no JIT. Entender a codificação é entender o emulador por dentro. E, como vimos no artigo do GDB, o mesmo código que você decodificou aqui é o que o step do GDB executa instrução a instrução.

Próximo passo

Até agora só vimos instruções de 32 bits fixas. O próximo texto quebra essa regra: o Thumb e o Thumb-2, onde as instruções têm 16 ou 32 bits e o decodificador precisa olhar o tamanho antes do opcode. É também o que o --arch=thumb2 do arm-box exercita com binários reais. Até lá — pegue qualquer .elf seu e tente decodificar um mov e um b sem olhar o mnemônico.