Como o decoder do arm-jitter funciona
O caminho de um byte
No artigo de fetch–decode–execute a gente viu o laço ArmCore.step(). Por dentro, os bytes passam por quatro estágios:
memória decoder lifter otimizador backend
[bytes] → DecodedInstruction → IrBlock (List<IrOp>) → IrBlock' → interpretador
ou JIT- Decoder (
ArmDecoder/ThumbDecoder) — separa os campos de uma instrução numrecordneutro. - Lifter (
StandardIrBlockLifter) — decodifica em sequência a partir de umstartPce traduz cada instrução para uma ou mais operações de IR, até um terminal (branch,SVC) ou um limite. O resultado é umIrBlock. - Otimizador (
ir.opt) — reescreve o bloco: dobra constantes, remove código morto, tirasetFlagsinútil. - Backend — o interpretador (
IrBlockExecutor) ou o JIT (AsmBlockCompiler) executa o bloco otimizado. Assunto do módulo 4.
Estágio 1: DecodedInstruction
ArmDecoder.decode(memory, address) faz o que você fez no papel no artigo de anatomia: lê raw = memory.fetch32(address & ~3), tira condition = decodeCondition(raw >>> 28), e a partir do opcode preenche um record:
public record DecodedInstruction(
int address, int raw,
InstructionSet instructionSet,
Condition condition,
InstructionKind kind,
int destinationRegister, // Rd, ou -1
int sourceRegister, // Rn, ou -1
int secondSourceRegister, // Rm, ou -1
int immediate, // já expandido
boolean immediateOperand, // bit I
boolean setFlags, // bit S
/* link, accessSizeBytes, writeback, postIndexed, ... */) { }Para 0xe0810002 (add r0, r1, r2): kind = DATA_PROCESSING/ADD, destinationRegister = 0, sourceRegister = 1, secondSourceRegister = 2, immediateOperand = false, setFlags = false, condition = AL.
É um formato neutro: uma instrução Thumb decodificada preenche o mesmo record. Do estágio 2 em diante, ninguém pergunta se veio de ARM ou Thumb.
Estágio 2: elevação para IR
O lifter traduz cada DecodedInstruction para operações de IR. Representação intermediária (IR) é o termo geral de compiladores para uma estrutura de dados que fica entre o código-fonte (ou, aqui, os bytes ARM) e o código final: nem a sintaxe de origem, nem o alvo — algo mais fácil de analisar e transformar que os dois. O arm-jitter usa a sua própria, sem relação com LLVM IR, GCC GIMPLE ou bytecode JVM, mas o papel é o mesmo dessas: um conjunto de ~70 records selados (IrOp.Alu, IrOp.Load, IrOp.Branch, IrOp.MultipleTransfer, IrOp.Swi, IrOp.MemoryBarrier, ...). Para add r0, r1, r2:
record Alu(
IrOpCode opcode, // ADD
int dst, // 0
int src1, // 1
int src1ValueOverride, // -1 (nenhum valor constante conhecido)
IrOperand src2, // Register(2)
boolean setFlags, // false
Condition condition // AL
) implements IrOp {}IrOperand também é selado: Register(index, valueOverride), Immediate(value, carryOutKnown, carryOut) (o imediato já expandido, com o carry do barrel shifter quando conhecido), ou ShiftedRegister(index, shiftType, amount, ...) — o barrel shifter da anatomia vira um operando de primeira classe.
O tripé por instrução
O lifter não emite só a operação semântica. Para cada instrução ele adiciona trêsIrOp na ordem:
Fetch(address, sizeBytes) // o custo de buscar a instrução na memória
Cycle(count) // ciclos internos
<a operação em si> // Alu, Load, Branch, ...Fetch e Cyclenunca ganham guard condicional (disciplina interna G4): mesmo que a instrução seja addne, o processador ainda gastou o fetch e o ciclo. Só a operação semântica carrega a Condition.
O IrBlock resultante é imutável, associado ao intervalo [startPc, endPc), e é o que entra no cache de blocos do JIT.
Estágio 3: o otimizador
O pipeline padrão para ARM7TDMI é StandardIrOptimizer.gba():
new ConstantFoldPass()
.then(new DeadCodeEliminationPass())
.then(new FlagMergePass());Constant fold
Uma operação ALU cujos dois operandos são conhecidos em tempo de compilação vira um MOV dst, #resultado. Exemplo — o bloco:
mov r0, #2
add r0, r0, #3vira, depois do fold, mov r0, #2 seguido de mov r0, #5. Condições para dobrar: condição AL, setFlags = false (dobrar flags exigiria simular o barrel shifter inteiro), dst != 15 (o PC tem semântica especial), src2 imediato, e opcode que não dependa de carry (ADC/SBC/RSC) nem seja teste puro (CMP/CMN).
Dead code elimination
Agora o primeiro mov r0, #2 escreve um valor que nunca é lido antes de r0 ser reescrito. A DCE, com análise de vivência de registradores para trás (live_in(i) = use(i) | (live_out(i) & ~def(i))), remove operações IrOp.Alu com setFlags = false e dst != 15 cujo resultado é morto. As duas linhas colapsam em:
mov r0, #5Operações com efeito colateral (memória, CPSR, PC, exceções) nunca são candidatas — só ALU pura.
Flag merge
O subs do laço -O2 do artigo de C atualiza NZCV. Se a próxima instrução que toca flags for um movs r0, #0antes de qualquer desvio condicional, aquele NZCV nunca foi lido. O FlagMergePass faz vivência de flags para trás, por flag (N, Z, C, V separados — um shift escreve N/Z/C mas não V; um MOV/AND escreve só N/Z), e tira o setFlags das operações cujos flags morrem sem uso. O subs vira sub — o JIT não precisa mais emitir o cálculo de flags.
Por que uma IR, afinal
Cinco razões, e as três primeiras aparecem no módulo 4:
- Desacoplar decode de execução. O decoder não sabe se o resultado vai ser interpretado, compilado para bytecode JVM ou para Truffle. Ele só produz
IrOp. - Um formato, três backends. Interpretador, JIT ASM e Truffle consomem o mesmo
IrBlock. O interpretador é o oráculo de semântica (G1): todo backend compilado passa por um harness de equivalência contra ele. - Otimizar. Constant fold, DCE e flag merge são baratos de fazer numa lista linear de
IrOpe caros (ou impossíveis) de fazer direto nos bytes ARM. - Analisar sem reimplementar o decoder. Vivência de registradores, vivência de flags, detecção de terminal — tudo isso opera sobre
records Java com campos nomeados, não sobre bits crus de umaint. A mesma análise de liveness serve para DCE e para flag merge porque ambos leem o mesmoIrBlock. - Um bug de decode se propaga; um bug de otimização fica isolado. Como o pipeline é
decode → lift → IR → otimizar, um problema noConstantFoldPassnão exige suspeitar doArmDecoder— oIrBlockde entrada já está correto e documentado pelorecord. É a mesma separação de responsabilidades que qualquer IR de compilador busca: cada estágio do pipeline testa e evolui isolado dos outros.
Glossário
| Termo | O que é |
|---|---|
| Lifter | traduz DecodedInstruction → IrOp |
IrBlock | lista imutável de IrOp para um intervalo [startPc, endPc) |
IrOp.Alu | a operação ALU genérica da IR (opcode, dst, src1, src2, setFlags, cond) |
| Terminal | instrução que fecha um bloco: branch, BX, SVC |
| Constant fold | trocar op de operandos constantes por MOV #resultado |
| DCE | remover operações cujo resultado nunca é lido |
| Flag merge | remover setFlags quando as flags morrem sem uso |
| Vivência (liveness) | análise para trás de "este valor ainda vai ser lido?" |
Comandos
| Comando | O que faz |
|---|---|
arm-none-eabi-objdump -d x.elf | ver os bytes que entram no decoder |
java -jar target/armbox.jar --interp x.elf | rodar pelo interpretador de IR (o oráculo) |
java -jar target/armbox.jar --check x.elf | JIT e interpretador em paralelo, aborta na 1ª divergência |
Próximo passo
Fecha o módulo 2. O módulo 3 sai da teoria: escrever, montar e rodar seu próprio assembly no arm-box e pela API Java do arm-jitter.
Trilha: Curso de Arquitetura ARM · Módulo 2, lição 5. Ver também: Anatomia de uma instrução ARM, Fetch–decode–execute e o ArmCore.step().