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.elfO 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:
| Cond (bin) | Mnemônico | Significado |
|---|---|---|
1110 | AL | Sempre (default — por isso quase tudo começa com e). |
0000 | EQ | Equal (flag Z = 1). |
0001 | NE | Not equal (Z = 0). |
1010 | GE | Greater or equal (N == V). É o nosso bge. |
1011 | LT | Less than (N != V). |
1100 | GT | Greater (Z = 0 e N == V). |
1101 | LE | Less or equal (Z = 1 ou N != V). |
0010 / 0011 | CS / CC | Carry set / clear (útil para unsigned). |
Glossário: opcodes de processamento de dados
Os 4 bits do opcode (bits 24..21) determinam a operação:
| Opcode (bin) | Mnemônico | Operação |
|---|---|---|
0100 | ADD | soma |
0010 | SUB | subtração |
0000 | AND | E lógico |
0001 | EOR | OU-exclusivo |
1100 | ORR | OU lógico |
1101 | MOV | cópia de operando2 para Rd |
1010 | CMP | subtrai e só atualiza flags (Rd ignorado) |
1110 | BIC | clear 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=5e= cond1110= AL (sempre).00em [27:26] = família dados.I = 1(bit 25): o operando é um imediato.1101= MOV.S = 0: não mexe nas flags.Rd = 3→r3.- operando2 =
0x005: um valor de 8 bits com rotação (val=5,rot=0) → simplesmente5. É 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 r3opcode 1010= CMP;S = 1forç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 flagsN, Z, C, V. - É exatamente o que vimos no artigo do GDB: após esse
cmp, o bitZdo CPSR diz ser2 == 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 r4opcode 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 = 0x000003cond 1010= GE (greater or equal) — só pula se ocmpanterior deur2 >= r3.- O alvo é
(PC + 8) + (offset << 2). Na instrução em0x8014,PC+8 = 0x801C;3 << 2 = 12 = 0xC;0x801C + 0xC = 0x8028= o rótulodone. Bateu! - Por isso existem dois
addseguidos por umbno final: o laço só sai quando obgedecide 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 = 4Rn = 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 ar0. Os bits[25:20]codificam justo isso — é por isso que o ponteiro "anda sozinho" sem precisar de umaddextra. - Curiosidade: o
ldr r0, =numsdo início viroue59f0034(ldr r0, [pc, #52]) — como o endereço não cabe no imediato de 8 bits domov, 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):
| Comando | O que faz |
|---|---|
arm-none-eabi-as -march=armv5te f.s -o f.o | Monta o assembly em objeto. |
arm-none-eabi-ld -static -o f.elf f.o | Liga em um ELF estático (roda no arm-box). |
arm-none-eabi-objdump -d f.elf | Desmonta e mostra os bytes de cada instrução. |
info registers cpsr | No GDB: mostra as flags que o cmp mudou. |
x/4xw $r0 | No 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.