← Voltar aos artigos

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
  1. Decoder (ArmDecoder / ThumbDecoder) — separa os campos de uma instrução num record neutro.
  2. Lifter (StandardIrBlockLifter) — decodifica em sequência a partir de um startPc e traduz cada instrução para uma ou mais operações de IR, até um terminal (branch, SVC) ou um limite. O resultado é um IrBlock.
  3. Otimizador (ir.opt) — reescreve o bloco: dobra constantes, remove código morto, tira setFlags inútil.
  4. 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, #3

vira, 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, #5

Operaçõ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:

  1. 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.
  2. 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.
  3. Otimizar. Constant fold, DCE e flag merge são baratos de fazer numa lista linear de IrOp e caros (ou impossíveis) de fazer direto nos bytes ARM.
  4. 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 uma int. A mesma análise de liveness serve para DCE e para flag merge porque ambos leem o mesmo IrBlock.
  5. Um bug de decode se propaga; um bug de otimização fica isolado. Como o pipeline é decode → lift → IR → otimizar, um problema no ConstantFoldPass não exige suspeitar do ArmDecoder — o IrBlock de entrada já está correto e documentado pelo record. É 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

TermoO que é
Liftertraduz DecodedInstruction → IrOp
IrBlocklista imutável de IrOp para um intervalo [startPc, endPc)
IrOp.Alua operação ALU genérica da IR (opcode, dst, src1, src2, setFlags, cond)
Terminalinstrução que fecha um bloco: branch, BX, SVC
Constant foldtrocar op de operandos constantes por MOV #resultado
DCEremover operações cujo resultado nunca é lido
Flag mergeremover setFlags quando as flags morrem sem uso
Vivência (liveness)análise para trás de "este valor ainda vai ser lido?"

Comandos

ComandoO que faz
arm-none-eabi-objdump -d x.elfver os bytes que entram no decoder
java -jar target/armbox.jar --interp x.elfrodar pelo interpretador de IR (o oráculo)
java -jar target/armbox.jar --check x.elfJIT 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().