Fetch–decode–execute e o ArmCore.step()
O ciclo
Todo núcleo, do ARM7TDMI ao Cortex-A78, repete a mesma engrenagem:
- Fetch — lê a instrução no endereço apontado pelo PC.
- Decode — quebra os bits em campos: qual operação, quais registradores, qual imediato.
- Execute — faz a conta, o acesso à memória ou o desvio.
- 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 #0Sob 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 - 0x10008O 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ódigo | O que o hardware faz "de graça" |
|---|---|
synchronizeModeFromCpsr() | o banco de registradores visível segue os bits de modo do CPSR |
sleepState | um 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 MemoryTranslationException | a 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
| Termo | O que é |
|---|---|
| Fetch–decode–execute | o ciclo de três passos que todo núcleo repete |
| Pipeline | estágios sobrepostos; no ARM7 são 3 (fetch/decode/execute) |
| PC +8 / +4 | valor arquitetural de R15: instrução + 8 (ARM) ou + 4 (Thumb) |
| Caminho frio | execuçã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 |
ArmTraceListener | gancho de observação por instrução / por bloco |
Comandos
| Comando | O 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.