← Voltar aos artigos

Fetch–decode–execute e o ArmCore.step()

O ciclo

Todo núcleo, do ARM7TDMI ao Cortex-A78, repete a mesma engrenagem:

  1. Fetch — lê a instrução no endereço apontado pelo PC.
  2. Decode — quebra os bits em campos: qual operação, quais registradores, qual imediato.
  3. Execute — faz a conta, o acesso à memória ou o desvio.
  4. Avança — PC += tamanho da instrução (4 em ARM, 2 em Thumb), a menos que a execução tenha escrito no PC (um branch).

Repete. Um programa é só esse laço rodando alguns bilhões de vezes por segundo.

O pipeline e o "PC adiantado"

O ARM7TDMI (ARMv4T, o núcleo do GBA) tem um pipeline de três estágios: enquanto a instrução N executa, N+1 está sendo decodificada e N+2 está sendo buscada.

ciclo:      1        2        3        4        5
N        [fetch]  [decode] [execute]
N+1               [fetch]  [decode] [execute]
N+2                        [fetch]  [decode] [execute]

Consequência que vaza para o programador: o PC (que vive no estágio de fetch) está sempre duas instruções à frente da que executa. Ler R15 em estado ARM devolve endereço_da_instrução + 8; em Thumb, + 4. Não é bug, é contrato de arquitetura — código que faz add r0, pc, #x conta com isso.

Provando no arm-box

O programa pcoffset.s (.elf):

_start:
    mov     r0, pc             @ r0 = (endereço deste mov) + 8
    mov     r1, pc             @ r1 = (endereço deste mov) + 8
    sub     r2, r1, r0         @ r2 = 4
    mov     r0, #0
    mov     r7, #1
    svc     #0
00010000<_start>:
10000:e1a0000fmovr0, pc
10004:e1a0100fmovr1, pc
10008:e0412000subr2, r1, r0

Sob o stub GDB (java -jar target/armbox.jar --arch=armv7a --gdb=3333 pcoffset.elf):

(gdb) p/x $pc
$1 = 0x10000                       @ PC = a instrução em _start

(gdb) stepi                        @ executa  mov r0, pc  (em 0x10000)
(gdb) p/x $pc
$2 = 0x10004                       @ PC agora aponta para a PRÓXIMA instrução
(gdb) p/x $r0
$3 = 0x10008                       @ mas r0 recebeu 0x10000 + 8

(gdb) stepi                        @ executa  mov r1, pc  (em 0x10004)
(gdb) p/x $r1
$5 = 0x1000c                       @ 0x10004 + 8

(gdb) stepi                        @ sub r2, r1, r0
(gdb) p/x $r2
$6 = 0x4                           @ 0x1000c - 0x10008

O arm-boxnão tem pipeline — ele executa uma instrução por inteiro e só então a próxima. Mas o arm-jitter modela o valor arquitetural de R15: quando uma instrução usa o PC como operando, o núcleo entrega endereço + 8, exatamente como o silício com pipeline entregaria. É a diferença entre emular o mecanismo (o pipeline) e emular o comportamento observável (o valor do registrador) — um emulador de ISA só precisa do segundo.

Onde o ciclo mora: ArmCore.step()

No arm-jitter, um passo do interpretador é o método step(), que delega para executeSingleInstruction(). É o ciclo, linha por linha:

private SingleInstructionExecution executeSingleInstruction() {
    synchronizeModeFromCpsr();                       // (0) modo em dia com o CPSR
    int pc = programCounter();                        // (1) FETCH: de onde
    InstructionSet instructionSet = currentInstructionSet();   // ARM ou THUMB?
    traceListener.beforeInstruction(this, pc, instructionSet); // gancho de observação

    if (sleepState != CpuSleepState.RUNNING) {        // WFI/HALT: queima 1 ciclo e sai
        addCycles(1);
        return /* no-op */;
    }
    if (servicePendingIrq()) {                        // IRQ pendente + bit I limpo?
        addCycles(1);                                 //   entra no vetor 0x18 e sai
        return /* exception taken */;
    }

    ArmInterpreter.StepResult result;
    try {
        result = interpreter.stepWithResult(this);    // (2)+(3) DECODE + EXECUTE
    } catch (MemoryTranslationException fault) {
        enterMemoryAbort(pc, fault);                  // falta de página → data/prefetch abort
        return /* abort taken */;
    }

    traceListener.afterInstruction(this, result.instruction());
    return new SingleInstructionExecution(result.instruction(), result.internalCycles());
}

O ciclo "puro" são três linhas: pegar o pc, chamar interpreter.stepWithResult(this), reportar. Todo o resto é a contabilidade que um núcleo real faz implicitamente e que um emulador precisa fazer à mão:

Passo do códigoO que o hardware faz "de graça"
synchronizeModeFromCpsr()o banco de registradores visível segue os bits de modo do CPSR
sleepStateum WFI/WFE realmente para o clock do núcleo
servicePendingIrq()a lógica de interrupção samplea a linha de IRQ entre instruções
catch MemoryTranslationExceptiona MMU aborta o acesso e levanta a exceção no ponto exato
addCycles(...)o contador de ciclos anda sozinho

step() devolve a DecodedInstruction que acabou de rodar; step(int n) é só um for chamando step(). É o caminho frio: sem JIT, sem cache de bloco, uma instrução de cada vez. É de propósito — esse caminho é o oráculo de semântica (invariante G1 do projeto): todo backend compilado tem que produzir exatamente o mesmo estado que ele.

O interpretador é a referência

interpreter.stepWithResult(this) (classe ArmInterpreter) faz o decode (via ArmDecoder, assunto do artigo de anatomia) e o execute: aplica a semântica da DecodedInstruction sobre o ArmCore — escreve Rd, atualiza NZCV se S, mexe no PC se for branch. Nada de otimização, nada de tradução: é a definição executável do que cada instrução significa.

O JIT (assunto do módulo 4) entra por outro método — runBlock(JitRuntime) — que traduz um bloco inteiro de uma vez para bytecode JVM. Mais rápido, mas atômico: os ganchos beforeInstruction/afterInstructionnão disparam dentro de um bloco compilado. Por isso a depuração passo a passo do arm-box sempre usa step(), nunca o JIT — fidelidade instrução a instrução.

Observando de fora: ArmTraceListener

Para inspecionar sem acoplar o núcleo a logging, existe a interface ArmTraceListener:

public interface ArmTraceListener {
    default void beforeInstruction(ArmCore core, int pc, InstructionSet set) {}
    default void afterInstruction(ArmCore core, DecodedInstruction instruction) {}
    default void beforeBlock(ArmCore core, int pc, InstructionSet set) {}
    default void afterBlock(ArmCore core, int startPc, InstructionSet set, int cycles) {}
    default void onMemoryAbort(ArmCore core, int instructionAddress, MemoryTranslationException fault) {}
}

core.setTraceListener(...) e você recebe um callback com o pc antes e a DecodedInstruction depois de cada passo — a base para escrever seu próprio tracer de registradores (é o que o artigo de API Java do módulo 3 faz).

Glossário

TermoO que é
Fetch–decode–executeo ciclo de três passos que todo núcleo repete
Pipelineestágios sobrepostos; no ARM7 são 3 (fetch/decode/execute)
PC +8 / +4valor arquitetural de R15: instrução + 8 (ARM) ou + 4 (Thumb)
Caminho frioexecução pelo interpretador, uma instrução por vez, sem JIT
Oráculo (G1)o interpretador como referência de semântica para todos os backends
ArmTraceListenergancho de observação por instrução / por bloco

Comandos

ComandoO que faz
stepi / si(GDB) executa uma instrução de máquina
p/x $pc(GDB) o PC atual (aponta para a próxima instrução após um stepi)
x/3i $pc(GDB) desmonta as 3 próximas instruções a partir do PC
info registers(GDB) todos os registradores + cpsr

Próximo passo

Fecha o módulo 1. O módulo 2 mergulha no decode: compilar C para binário, decodificar ARM e Thumb à mão, e abrir o ArmDecoder do arm-jitter por dentro.

Trilha: Curso de Arquitetura ARM · Módulo 1, lição 4. Ver também: Anatomia de uma instrução ARM, Depurando ARM com GDB no arm-box.