← Voltar aos artigos

Usando o arm-jitter direto pela API Java

Para executar um programa escrito para um processador ARM num computador comum (x86), é preciso um emulador: um software que lê as instruções ARM uma a uma e reproduz o efeito de cada uma. Nesta trilha, esse papel vem sendo feito pelo arm-box, um programa de linha de comando que recebe um executável ARM (.elf), monta a memória, executa e devolve o resultado. É prático, mas é uma caixa fechada: o mapa de memória, o que acontece numa chamada de sistema e o momento de parar são decisões dele, não suas.

Por baixo do arm-box está o arm-jitter, a biblioteca Java que de fato decodifica e executa as instruções ARM. Ela pode ser usada sem o arm-box. É assim que você escreveria o seu próprio emulador (de um console, de uma placa), um teste automatizado que confere os registradores depois de cada instrução, ou um script que observa um binário rodando.

Neste artigo você vai escrever esse programa hospedeiro mínimo — umas 40 linhas de Java que:

  1. carregam um programa ARM numa memória que você define;
  2. criam um núcleo ARM apontado para essa memória;
  3. executam o programa instrução por instrução, imprimindo registradores e flags a cada passo.

No fim você vai saber ler o trace resultante, trocar o interpretador por um compilador JIT e preparar o processador para começar num estado arbitrário — os três usos mais comuns da biblioteca.

O que você precisa: um JDK 25 ou mais novo (a biblioteca é compilada para Java 25), o arm-jitter-1.3.0.jar (como baixar está logo abaixo) e os dois arquivos de exemplo linkados na seção "O programa ARM". Não é preciso toolchain ARM nem saber escrever assembly: o binário já vem montado, e o código dele é explicado linha por linha.

Uma biblioteca, não um programa

O arm-jitter não tem main: ele não roda sozinho. Ele é um core (o núcleo do processador emulado) que um programa hospedeiro — o seu — cria e controla. As peças mínimas são três:

  • AddressSpace — a interface do barramento de memória. Toda leitura ou escrita que o processador emulado faz passa por ela. Você implementa read8/16/32 e write8/16/32 (ler/escrever 1, 2 ou 4 bytes) e decide o que existe em cada endereço.
  • ArmCore — o processador em si: os 16 registradores (r0–r15, sendo r15 o PC, contador de programa), o registrador de status CPSR, e a lógica de buscar, decodificar e executar instruções.
  • SwiDispatcher — o que fazer quando o programa executa SVC, a instrução de chamada ao sistema operacional. SwiDispatcher.empty() significa "nenhum tratamento especial": o processador faz o que um ARM real faria e desvia para o vetor de exceção (um endereço fixo baixo, onde o sistema operacional instalaria seu tratador).

A biblioteca está no Maven Central. Num projeto Maven, a dependência é:

<dependency>
    <groupId>dev.vitorsilverio</groupId>
    <artifactId>arm-jitter</artifactId>
    <version>1.3.0</version>
</dependency>

Sem Maven no projeto, basta o jar. mvn dependency:get -Dartifact=dev.vitorsilverio:arm-jitter:1.3.0 o baixa para ~/.m2/repository/dev/vitorsilverio/arm-jitter/1.3.0/arm-jitter-1.3.0.jar; copie-o para a pasta do exemplo.

O programa ARM

O programa emulado soma os números 3, 7, 1, 9, 4 e deixa o resultado (24) no registrador r0. O fonte é soma5.s:

_start:
    adr     r1, nums          @ r1 = endereço do vetor
    mov     r2, #5            @ r2 = contador (5 elementos)
    mov     r0, #0            @ r0 = acumulador
1:  ldr     r3, [r1], #4      @ r3 = *r1; depois r1 += 4
    add     r0, r0, r3        @ r0 += r3
    subs    r2, r2, #1        @ r2 -= 1, atualizando as flags
    bne     1b                @ volta ao rótulo 1 se o resultado não foi zero
    mov     r7, #1            @ número da chamada "exit"
    svc     #0                @ chama o sistema operacional
nums:
    .word   3, 7, 1, 9, 4

Algumas leituras para quem não está acostumado com assembly ARM:

  • ldr r3, [r1], #4 lê 4 bytes da memória no endereço r1 e, depois, soma 4 a r1 — andando para o próximo elemento.
  • O s de subs pede que a subtração atualize as flags do CPSR (N negativo, Z zero, C carry, V overflow). O bne ("branch if not equal") só desvia se Z estiver desligado, ou seja, enquanto o contador não chegou a zero.
  • svc #0 com r7 = 1 é a forma de pedir "encerre o programa" a um Linux ARM.

O que a biblioteca vai receber é soma5.bin: esses 56 bytes já montados, sem cabeçalho nenhum (14 instruções/palavras de 4 bytes), pensados para morar a partir do endereço 0x00010000.

O hospedeiro em Java

TraceSoma.java carrega soma5.bin numa RAM que começa em 0x00010000, instala um trace listener (um objeto avisado depois de cada instrução executada) e dá 23 passos:

static final int BASE = 0x00010000;

byte[] image = Files.readAllBytes(Path.of(args[0]));

AddressSpace ram = new AddressSpace() {
    @Override public int read8(int a)  { return image[a - BASE] & 0xFF; }
    @Override public int read16(int a) { return read8(a) | (read8(a + 1) << 8); }
    @Override public int read32(int a) { return read16(a) | (read16(a + 2) << 16); }
    @Override public void write8(int a, int v)  { image[a - BASE] = (byte) v; notifyWrite(a); }
    @Override public void write16(int a, int v) { write8(a, v); write8(a + 1, v >> 8); }
    @Override public void write32(int a, int v) { write16(a, v); write16(a + 2, v >> 16); }
};

ArmCore core = new ArmCore(ram, SwiDispatcher.empty());
core.setProgramCounter(BASE);

core.setTraceListener(new ArmTraceListener() {
    @Override public void afterInstruction(ArmCore c, DecodedInstruction insn) {
        System.out.printf("pc=%08X  %-8s r0=%2d r2=%d cpsr=%08X%n",
                insn.address(), insn.kind(),
                c.register(0), c.register(2), c.cpsr().get());
    }
});

for (int i = 0; i < 23; i++) {
    core.step();
}
System.out.println("--- r0 final = " + core.register(0));

Parte por parte:

  • A memória é só o array de bytes do arquivo. O endereço ARM a vira o índice a - BASE. Os read16/read32 montam o valor em little-endian (byte menos significativo primeiro), que é a ordem usada pelo ARM nesse modo.
  • notifyWrite(a) avisa a biblioteca que um endereço mudou. Isso importa porque o arm-jitter guarda em cache blocos de instruções já decodificados; se o programa escrever sobre o próprio código (self-modifying code, SMC), o bloco antigo precisa ser descartado. Este programa nunca faz isso, mas é boa prática chamar.
  • new ArmCore(ram, SwiDispatcher.empty()) cria o processador ligado a essa memória; setProgramCounter(BASE) aponta o PC para a primeira instrução.
  • core.step() executa exatamente uma instrução. A cada uma, o listener imprime o endereço, o tipo da instrução e os registradores que interessam.

Por que 23 passos? São 3 instruções de preparação mais 5 voltas de 4 instruções no laço. O passo 24 seria o mov r7, #1 e o 25, o svc. Com SwiDispatcher.empty(), o svc desvia para o vetor de exceção em 0x00000008 — um endereço que não existe na nossa RAM. A leitura dá índice negativo no array e o Java lança ArrayIndexOutOfBoundsException. Parar antes é a forma mais simples de evitar isso. (O arm-box resolve com um SwiDispatcher que trata o exit e devolve r0 como exit code.)

Compilar e rodar

javac -cp arm-jitter-1.3.0.jar TraceSoma.java
java  -cp "arm-jitter-1.3.0.jar;." TraceSoma soma5.bin      # ':' em vez de ';' no Linux/macOS
pc=00010000  ADD      r0= 0 r2=0 cpsr=000000D3
pc=00010004  MOV      r0= 0 r2=5 cpsr=000000D3
pc=00010008  MOV      r0= 0 r2=5 cpsr=000000D3
pc=0001000C  LOAD     r0= 0 r2=5 cpsr=000000D3
pc=00010010  ADD      r0= 3 r2=5 cpsr=000000D3
pc=00010014  SUB      r0= 3 r2=4 cpsr=200000D3
pc=00010018  BRANCH   r0= 3 r2=4 cpsr=200000D3
pc=0001000C  LOAD     r0= 3 r2=4 cpsr=200000D3
pc=00010010  ADD      r0=10 r2=4 cpsr=200000D3
   ...
pc=00010010  ADD      r0=24 r2=1 cpsr=200000D3
pc=00010014  SUB      r0=24 r2=0 cpsr=600000D3
pc=00010018  BRANCH   r0=24 r2=0 cpsr=600000D3
--- r0 final = 24

O que o trace revela:

  • A segunda coluna é o InstructionKind da DecodedInstruction: a categoria que o decodificador atribuiu à instrução (ADD, LOAD, SUB, BRANCH). O adr r1, nums aparece como ADD porque adr não é uma instrução de verdade: o montador o traduz para add r1, pc, #28, "o endereço desta instrução mais um deslocamento".
  • O laço é visível nos endereços: depois de 0x00010018 (o bne), o PC volta para 0x0001000C (o ldr), e r0 cresce 3 → 10 → 11 → 20 → 24 enquanto r2 desce de 5 a 0.
  • cpsr começa em 0x000000D3. Os 5 bits baixos do CPSR dizem o modo do processador; 0xD3 = 1101 0011 dá I=1 e F=1 (interrupções IRQ e FIQ mascaradas) e modo 10011 = Supervisor, o modo privilegiado em que um ARM real sai do reset. Um new ArmCore(...) nasce assim. (O arm-box troca para o modo System, 0xDF, antes de rodar; aqui não trocamos, e para este programa não faz diferença.)
  • O bit C (bit 29, 0x20000000) liga depois do primeiro subs. Na subtração, o ARM define C = 1 quando não houve empréstimo (borrow): 5 - 1 não precisou, então C liga e fica ligado o laço todo.
  • 0x60000000 na última linha = bits 30 e 29 = Z e C. O subs que fez 1 - 1 = 0 ligou o Z, e é esse Z que faz o bne seguinte não desviar — o laço termina.

Trocando o backend

core.step() usa o interpretador: decodifica e executa uma instrução por vez. É lento, mas perfeito para depurar e observar. Para velocidade, o arm-jitter tem um JIT (just-in-time compiler): ele agrupa instruções em blocos (sequências que terminam num desvio), traduz cada bloco para uma representação intermediária (IR) e compila essa IR para bytecode da JVM, que a própria JVM depois otimiza.

// bytecode JVM, tiered (tier frio interpretado + tier quente compilado):
JitRuntime runtime = JitRuntimeFactory.armThumb(1024, 3);
long cycles = core.runBlocks(runtime, 256);   // executa até 256 blocos

// interpretador de IR puro — o oráculo de testes:
JitRuntime oracle = JitRuntimeFactory.interpretedArmThumb(1024, 3);

// os dois em paralelo, aborta na primeira divergência:
JitRuntime check = JitRuntimeFactory.divergenceCheckingArmThumb(1024, 3, ArmArchitecture.ARMV5TE);

Os três consomem o mesmo bloco de IR (IrBlock) produzido pelo decodificador; só muda quem o executa — por isso se chamam backends. O de divergência roda cada bloco pelos dois caminhos e para na primeira diferença, o que é útil para caçar bugs no compilador.

Uma consequência prática: sob runBlocks, o afterInstruction do trace não dispara, porque um bloco compilado executa de uma vez, sem pontos de parada entre as instruções. Para observar a execução nesse modo existem beforeBlock/afterBlock, chamados a cada bloco.

Começando num estado arbitrário

Num aparelho real, o processador sai do reset, roda o código de boot (a BIOS ou bootloader) e só então salta para o programa principal, já num modo e com pilhas configurados. Um emulador de console costuma pular essa etapa e começar direto no estado em que a BIOS deixaria o processador.

Isso exige cuidado porque o ARM tem registradores em banco: alguns registradores, como o r13 (o ponteiro de pilha, SP), têm uma cópia separada para cada modo. Ao entrar em modo IRQ, o processador passa a enxergar o r13 do IRQ, e não o do Supervisor. Por isso é preciso preencher a pilha de cada modo e trocar PC e CPSR juntos, para o banco certo ficar ativo:

core.setBankedRegister(CpuMode.SUPERVISOR, 13, 0x03007FE0);  // SP do modo Supervisor
core.setBankedRegister(CpuMode.IRQ, 13, 0x03007FA0);         // SP do modo IRQ
core.configureExecutionState(
        0x08000000, CpuMode.SYSTEM, InstructionSet.ARM,
        /* irqDisabled = */ false, /* fiqDisabled = */ true);

Os endereços acima são os de um Game Boy Advance (pilhas na RAM interna em 0x03007Fxx, cartucho a partir de 0x08000000); num outro aparelho, você usaria o mapa de memória dele.

Glossário

TermoO que é
Emuladorsoftware que executa programas de outra arquitetura reproduzindo o efeito de cada instrução
Hospedeiroo programa (aqui, em Java) que cria e controla o núcleo emulado
arm-jitterbiblioteca Java que decodifica e executa código ARM, por interpretação ou JIT
arm-boxexecutor de linha de comando construído sobre o arm-jitter
AddressSpaceinterface do barramento de memória: read/write de 8/16/32 bits
ArmCoreo processador emulado: registradores, CPSR, busca/decodificação/execução
SwiDispatcherpolítica para a instrução SVC: tratar no hospedeiro ou desviar para o vetor de exceção
SVCsupervisor call: instrução com que um programa pede um serviço ao sistema operacional (antigo SWI)
Vetor de exceçãoendereço fixo (0x08 para SVC) para onde o processador desvia numa exceção
PCprogram counter (r15): endereço da instrução sendo executada
CPSRcurrent program status register: flags N Z C V, máscaras de interrupção e modo atual
Flags N Z C Vnegativo, zero, carry (na subtração: "não houve empréstimo"), overflow
Modo (Supervisor, System, IRQ...)nível de privilégio e contexto do processador; cada um pode ter registradores próprios
Registrador em bancoregistrador com uma cópia por modo, como o r13 (SP)
Trace listenerobjeto notificado depois de cada instrução (afterInstruction) ou bloco
DecodedInstruction / InstructionKindinstrução já decodificada / sua categoria (ADD, LOAD, BRANCH...)
SMCself-modifying code: programa que escreve sobre o próprio código
notifyWriteaviso do AddressSpace de que um endereço mudou, para invalidar blocos em cache
Little-endianordem de bytes com o menos significativo no endereço mais baixo
Interpretadorexecuta uma instrução por vez, sem compilar
JITjust-in-time compiler: compila blocos de instruções durante a execução
Blocosequência de instruções que termina num desvio; unidade do JIT
IR / IrBlockrepresentação intermediária de um bloco, comum a todos os backends
Backendquem executa a IR: bytecode JVM, interpretador de IR, ou ambos com checagem
JitRuntimeo runtime de execução por blocos, criado por JitRuntimeFactory
BIOScódigo de boot gravado no aparelho, que prepara o processador antes do programa
configureExecutionStatedefine PC, modo, conjunto de instruções (ARM/Thumb) e máscaras IRQ/FIQ de uma vez

Comandos

ComandoO que faz
mvn dependency:get -Dartifact=dev.vitorsilverio:arm-jitter:1.3.0baixa a biblioteca do Maven Central para ~/.m2/repository
javac -cp arm-jitter-1.3.0.jar TraceSoma.javacompila o hospedeiro usando a biblioteca no classpath
java -cp "arm-jitter-1.3.0.jar;." TraceSoma soma5.binroda o hospedeiro no Windows (classpath separado por ;)
java -cp "arm-jitter-1.3.0.jar:." TraceSoma soma5.bino mesmo no Linux/macOS (separador :)

Conclusão

Um emulador ARM embutível precisa de pouco: uma implementação de AddressSpace que diz o que existe em cada endereço, um ArmCore apontado para ela e um laço que chama step(). Com isso você controla tudo que o arm-box decidia por você — onde fica a memória, quando parar, o que acontece num SVC — e ganha visibilidade total: cada instrução, cada registrador, cada flag.

O trace também mostrou, na prática, a mecânica de um laço em ARM: o subs atualiza as flags, e o bne só lê o Z. Quando a velocidade importar, o mesmo núcleo roda sob um JitRuntime, trocando a observação instrução a instrução pela execução em blocos compilados. E, para emular um aparelho específico, setBankedRegister e configureExecutionState colocam o processador no estado em que o código de boot o deixaria.

Para ir além

Referências

Trilha: Curso de Arquitetura ARM · Módulo 3, lição 4.