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:
- carregam um programa ARM numa memória que você define;
- criam um núcleo ARM apontado para essa memória;
- 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ê implementaread8/16/32ewrite8/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, sendor15o PC, contador de programa), o registrador de statusCPSR, e a lógica de buscar, decodificar e executar instruções.SwiDispatcher— o que fazer quando o programa executaSVC, 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, 4Algumas leituras para quem não está acostumado com assembly ARM:
ldr r3, [r1], #4lê 4 bytes da memória no endereçor1e, depois, soma 4 ar1— andando para o próximo elemento.- O
sdesubspede que a subtração atualize as flags doCPSR(Nnegativo,Zzero,Ccarry,Voverflow). Obne("branch if not equal") só desvia seZestiver desligado, ou seja, enquanto o contador não chegou a zero. svc #0comr7 = 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
avira o índicea - BASE. Osread16/read32montam 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 oarm-jitterguarda 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/macOSpc=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 = 24O que o trace revela:
- A segunda coluna é o
InstructionKinddaDecodedInstruction: a categoria que o decodificador atribuiu à instrução (ADD,LOAD,SUB,BRANCH). Oadr r1, numsaparece comoADDporqueadrnão é uma instrução de verdade: o montador o traduz paraadd r1, pc, #28, "o endereço desta instrução mais um deslocamento". - O laço é visível nos endereços: depois de
0x00010018(obne), o PC volta para0x0001000C(oldr), er0cresce3 → 10 → 11 → 20 → 24enquantor2desce de 5 a 0. cpsrcomeça em0x000000D3. Os 5 bits baixos doCPSRdizem o modo do processador;0xD3=1101 0011dáI=1eF=1(interrupções IRQ e FIQ mascaradas) e modo10011= Supervisor, o modo privilegiado em que um ARM real sai do reset. Umnew ArmCore(...)nasce assim. (Oarm-boxtroca 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 primeirosubs. Na subtração, o ARM defineC = 1quando não houve empréstimo (borrow):5 - 1não precisou, entãoCliga e fica ligado o laço todo. 0x60000000na última linha = bits 30 e 29 =ZeC. Osubsque fez1 - 1 = 0ligou oZ, e é esseZque faz obneseguinte 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
| Termo | O que é |
|---|---|
| Emulador | software que executa programas de outra arquitetura reproduzindo o efeito de cada instrução |
| Hospedeiro | o programa (aqui, em Java) que cria e controla o núcleo emulado |
arm-jitter | biblioteca Java que decodifica e executa código ARM, por interpretação ou JIT |
arm-box | executor de linha de comando construído sobre o arm-jitter |
AddressSpace | interface do barramento de memória: read/write de 8/16/32 bits |
ArmCore | o processador emulado: registradores, CPSR, busca/decodificação/execução |
SwiDispatcher | política para a instrução SVC: tratar no hospedeiro ou desviar para o vetor de exceção |
SVC | supervisor call: instrução com que um programa pede um serviço ao sistema operacional (antigo SWI) |
| Vetor de exceção | endereço fixo (0x08 para SVC) para onde o processador desvia numa exceção |
| PC | program counter (r15): endereço da instrução sendo executada |
CPSR | current program status register: flags N Z C V, máscaras de interrupção e modo atual |
Flags N Z C V | negativo, 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 banco | registrador com uma cópia por modo, como o r13 (SP) |
| Trace listener | objeto notificado depois de cada instrução (afterInstruction) ou bloco |
DecodedInstruction / InstructionKind | instrução já decodificada / sua categoria (ADD, LOAD, BRANCH...) |
| SMC | self-modifying code: programa que escreve sobre o próprio código |
notifyWrite | aviso do AddressSpace de que um endereço mudou, para invalidar blocos em cache |
| Little-endian | ordem de bytes com o menos significativo no endereço mais baixo |
| Interpretador | executa uma instrução por vez, sem compilar |
| JIT | just-in-time compiler: compila blocos de instruções durante a execução |
| Bloco | sequência de instruções que termina num desvio; unidade do JIT |
IR / IrBlock | representação intermediária de um bloco, comum a todos os backends |
| Backend | quem executa a IR: bytecode JVM, interpretador de IR, ou ambos com checagem |
JitRuntime | o runtime de execução por blocos, criado por JitRuntimeFactory |
| BIOS | código de boot gravado no aparelho, que prepara o processador antes do programa |
configureExecutionState | define PC, modo, conjunto de instruções (ARM/Thumb) e máscaras IRQ/FIQ de uma vez |
Comandos
| Comando | O que faz |
|---|---|
mvn dependency:get -Dartifact=dev.vitorsilverio:arm-jitter:1.3.0 | baixa a biblioteca do Maven Central para ~/.m2/repository |
javac -cp arm-jitter-1.3.0.jar TraceSoma.java | compila o hospedeiro usando a biblioteca no classpath |
java -cp "arm-jitter-1.3.0.jar;." TraceSoma soma5.bin | roda o hospedeiro no Windows (classpath separado por ;) |
java -cp "arm-jitter-1.3.0.jar:." TraceSoma soma5.bin | o 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
- Depurando ARM com GDB no arm-box — a mesma observação passo a passo, mas pelo GDB.
- Fetch–decode–execute e o ArmCore.step() — o que acontece dentro de cada
step(). - O decoder do arm-jitter — como os bytes viram
DecodedInstructioneIrBlock. - Montando e ligando um executável ARM — como o
soma5.binfoi produzido a partir do.s.
Referências
- Arm Ltd. — ARM Architecture Reference Manual, ARMv7-A and ARMv7-R edition (ARM DDI 0406C). Fonte normativa para os modos do processador, os registradores em banco, o layout do
CPSR(flagsN Z C V, bitsI/F, campoM) e os vetores de exceção, incluindo o deSVC. - Free Software Foundation — Using as, seção "ARM Opcodes" (GNU Binutils). Descreve a pseudo-instrução
ADRe sua tradução para umADD/SUBrelativo ao PC. - Vítor Silvério — código-fonte do
arm-jitter(GitHub, licença BSD-3-Clause).AddressSpace,ArmCore,ArmTraceListenereJitRuntimeFactory; o README traz o exemplo mínimo de uso. dev.vitorsilverio:arm-jitter:1.3.0no Maven Central. Coordenadas e versões publicadas da biblioteca.- Oracle — The java Command, Java SE 25 & JDK 25. Opção
-cp/--class-pathe o separador;(Windows) ou:(demais sistemas). - Martin Korth — GBATEK, seção "GBA Interrupt Control". Valores iniciais de
SP_svc(03007FE0h) eSP_irq(03007FA0h) deixados pela BIOS do GBA; a seção "GBA Memory Map" do mesmo documento dá o início da ROM do cartucho em08000000h.
Trilha: Curso de Arquitetura ARM · Módulo 3, lição 4.