Como uma CPU Funciona por Dentro:
Da Nossa VM ao Silício
A máquina virtual que construímos no artigo anterior, sobre a Cadência e a mini-vm, não é um acidente de design. Ela imita, em escala reduzida, a mesma anatomia que existe dentro de todo processador físico. Neste artigo vamos descer um andar e entender o que muda — e o que permanece idêntico — quando a máquina deixa de ser um programa Rust e vira silício.
VOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
No artigo “Construindo um compilador e uma máquina virtual do zero em Rust”, em que construímos a Cadência e a mini-vm, criamos uma máquina virtual de software. Ela possui:
- um Program Counter (
ip) que aponta para a próxima instrução - uma stack onde operandos vivem temporariamente
- uma Constant Pool com valores imutáveis
- globals e locals para variáveis
- frames para chamadas de função
- um loop que busca, decodifica e executa opcodes
Tudo isso vive dentro de um programa Rust. Mas esses mesmos conceitos também existem dentro de um processador físico. A diferença não é de ideia; é de material.
Nossa VM é uma CPU desenhada em software. Quando entendemos o que ela copiou do hardware, ganhamos o vocabulário necessário para o próximo passo: pedir a uma ferramenta como o LLVM que traduza nossa linguagem para código que um processador real execute diretamente.
mostre(2 + 3)CONST · ADD · PRINTfetch · decode · executeregistradores · ALU · RAMLeitura técnica: o bytecode não chega diretamente ao silício. A CPU executa as instruções nativas da mini-VM, e a mini-VM dá significado aos opcodes da Pulso.
| Nossa VM (software) | CPU Real (hardware) | O que faz |
|---|---|---|
ip | PC (Program Counter) | guarda o endereço da próxima instrução |
OpCode | Opcode (número bruto) | identifica qual operação executar |
stack | Stack (região da RAM) | pilha de operandos temporários |
globals | Memória RAM | espaço de dados do programa |
frames | Stack de chamadas | endereços de retorno e variáveis locais |
read_u8() | Busca (Fetch) | lê o próximo byte da memória |
match opcode | Decodificação (Decode) | descobre qual operação o número representa |
stack.push() | Execução (Execute) | altera registradores ou memória |
program.pbc | Executável (ELF, PE, Mach-O) | arquivo com instruções e dados |
A tabela não é analogia. É correspondência. Cada conceito que inventamos para a mini-vm já existia em alguma CPU real décadas antes.
No artigo anterior, nossa VM repetia este loop:
loop {
let byte = self.read_u8()?; // FETCH
let opcode = OpCode::try_from(byte)?; // DECODE
match opcode { /* ... */ } // EXECUTE
}
Uma CPU real faz exatamente a mesma coisa, só que em hardware:
Fetch — o PC aponta para a memória e a unidade de controle lê o próximo opcode.
Decode — circuitos decodificam o número e escolhem qual operação elétrica ativar.
Execute — a ALU ou unidade de carga escreve o resultado em registradores ou RAM.
Atualizar PC — o contador avança (ou recebe um salto) e o ciclo recomeça.
O ciclo fetch-decode-execute é o coração de qualquer processador. Nossa VM o escreveu em Rust; uma CPU o implementa em portas lógicas e fios.
Exemplo: em ADD R2, R0, R1, a busca lê a instrução; a decodificação identifica soma e operandos; a ALU soma R0 e R1; o resultado vai para R2.
Na mini-vm, quando queríamos somar dois números, empilhávamos ambos na stack e depois executávamos Add. Esse modelo é chamado stack machine.
CPUs reais, como x86 e ARM, usam o modelo register machine: valores ficam em registradores, pequenas gavetas de memória dentro do próprio processador.
Por que registradores?
A stack ocupa uma região da memória do processo e seus dados normalmente passam pelas caches antes de chegar à RAM. Registradores ficam dentro do núcleo e oferecem o acesso mais direto aos operandos. A latência exata varia conforme a microarquitetura, o nível de cache e as dependências entre instruções, mas a regra permanece: o compilador tenta manter no processador os valores usados com maior frequência.
Comparando as duas formas de somar
// Nossa VM (stack machine)
Constant 0 // empilha 2
Constant 1 // empilha 3
Add // retira dois, empilha 5
// CPU x86 (register machine)
mov eax, 2 // coloca 2 no registrador eax
add eax, 3 // soma 3 ao que está em eax
; resultado: eax agora vale 5
Nossa VM não precisava dizer onde guardar o resultado. A stack implicitamente colocava o resultado no topo. A CPU precisa nomear o destino: eax.
Implementação 01 — Criando uma CPU minimalista em Rust
micro-cpu/src/lib.rs
pub struct Cpu {
pub regs: [u64; 8], // 8 registradores de 64 bits
pub pc: usize, // Program Counter
pub ram: Vec<u8>, // memória principal
pub running: bool, // a CPU está ativa?
}
pub enum Instruction {
Load { dst: u8, addr: u16 },
Store { src: u8, addr: u16 },
Add { dst: u8, a: u8, b: u8 },
Sub { dst: u8, a: u8, b: u8 },
Jump { addr: u16 },
Halt,
}
01 — [u64; 8]: Oito registradores. Números reais de 64 bits, como os registradores de propósito geral do x86-64.
02 — pc: usize: Mesmo papel do ip da nossa VM: aponta para a próxima instrução na memória.
03 — ram: Vec<u8>: Nosso modelo didático da memória principal. Na VM, tínhamos code, constants, globals e stack separados. Aqui tudo vive em um único vetor de bytes; numa máquina real, caches, memória virtual e proteção de páginas acrescentam outras camadas entre a CPU e a RAM física.
04 — Instruction enum: Cada variante carrega os operandos necessários. Add precisa de três registradores: destino, operando A e operando B.
Nossa mini-vm organizava a memória em regiões com propósitos definidos: Constant Pool, Globals, Locais do frame, Stack de valores, Stack de frames.
Na CPU real, essas regiões são apenas convenções de software. O hardware vê apenas uma sequência de bytes numerados de 0 até o limite da RAM.
Endereço Conteúdo (na nossa micro-cpu)
0x0000 Load 0, 0x0100 // código do programa
0x0004 Load 1, 0x0108
0x0008 Add 2, 0, 1
0x000C Store 2, 0x0110
0x0010 Halt
...
0x0100 42 // dados (constantes/variáveis)
0x0108 8
0x0110 0 // espaço para resultado
O mesmo vetor ram guarda código e dados. A CPU não sabe a diferença. Quem decide o que é código é o pc; quem decide o que é dado são as instruções que leem e escrevem endereços.
No formato PBC1 que criamos, separamos constantes de opcodes de funções. Era um contrato binário legível. Na memória de uma CPU real, essa separação é uma decisão do compilador, não do hardware. O programa compilado decide: "a partir do endereço 0x1000 começam as constantes; a partir de 0x2000 começa o código."
Programa: calcular 42 + 8 e guardar o resultado.
let programa = vec![
// Load 0, 0x0100 → regs[0] = ram[0x0100] (42)
0x01, 0x00, 0x00, 0x01,
// Load 1, 0x0108 → regs[1] = ram[0x0108] (8)
0x01, 0x01, 0x08, 0x01,
// Add 2, 0, 1 → regs[2] = regs[0] + regs[1]
0x03, 0x02, 0x00, 0x01,
// Store 2, 0x0110 → ram[0x0110] = regs[2]
0x02, 0x02, 0x10, 0x01,
// Halt
0x06,
];
Fotografias do estado da CPU a cada instrução:
| Passo | Instrução | PC | regs[0] | regs[1] | regs[2] | ram[0x0110] |
|---|---|---|---|---|---|---|
| inicial | — | 0x0000 | 0 | 0 | 0 | 0 |
| 1 | Load 0, 0x0100 | 0x0004 | 42 | 0 | 0 | 0 |
| 2 | Load 1, 0x0108 | 0x0008 | 42 | 8 | 0 | 0 |
| 3 | Add 2, 0, 1 | 0x000C | 42 | 8 | 50 | 0 |
| 4 | Store 2, 0x0110 | 0x0010 | 42 | 8 | 50 | 50 |
| 5 | Halt | — | 42 | 8 | 50 | 50 |
A tabela é o trace da CPU. Cada linha mostra o estado completo após a execução. Note que pc avança em passos fixos de 4 bytes — exceto quando um salto ocorre.
Implementação 02 — O loop da micro-CPU
micro-cpu/src/lib.rs
impl Cpu {
pub fn step(&mut self) -> Result<(), String> {
// FETCH: ler o opcode no endereço apontado por pc
let opcode = self.ram.get(self.pc).copied()
.ok_or("pc fora da memória")?;
self.pc += 1;
// DECODE + EXECUTE
match opcode {
0x01 => { // Load
let dst = self.read_u8()? as usize;
let addr = self.read_u16()? as usize;
let value = self.read_ram_u64(addr)?;
self.regs[dst] = value;
}
0x02 => { // Store
let src = self.read_u8()? as usize;
let addr = self.read_u16()? as usize;
self.write_ram_u64(addr, self.regs[src]);
}
0x03 => { // Add
let dst = self.read_u8()? as usize;
let a = self.read_u8()? as usize;
let b = self.read_u8()? as usize;
self.regs[dst] = self.regs[a].wrapping_add(self.regs[b]);
}
0x04 => { // Sub
let dst = self.read_u8()? as usize;
let a = self.read_u8()? as usize;
let b = self.read_u8()? as usize;
self.regs[dst] = self.regs[a].wrapping_sub(self.regs[b]);
}
0x05 => { // Jump
let addr = self.read_u16()? as usize;
self.pc = addr;
}
0x06 => { // Halt
self.running = false;
}
other => return Err(format!("opcode desconhecido: {other}")),
}
Ok(())
}
pub fn run(&mut self) -> Result<(), String> {
self.running = true;
while self.running {
self.step()?;
}
Ok(())
}
}
01 — self.pc avança byte a byte. Cada instrução consome opcode + operandos.
02 — wrapping_add. Em CPUs reais, a adição não falha por overflow — descarta os bits que não cabem.
03 — running: bool. A CPU para quando encontra Halt. Até lá, o loop é infinito.
- Registradores de 64 bits
- Load e Store entre registradores e RAM
- Add e Sub com três operandos
- Jump incondicional
- Halt
Nossa mini-vm era uma stack machine por uma razão didática: cada instrução fica pequena. Add não precisa dizer onde estão os operandos; eles estão implicitamente no topo da pilha.
CPUs reais são register machines porque registradores são extremamente rápidos. Um programa compilado para registradores executa de 10 a 100 vezes mais rápido que o mesmo programa em uma stack machine pura.
Mas e se quiséssemos emular uma stack na CPU real?
Podemos. Basta escolher um registrador para ser o ponteiro de stack (rsp no x86-64) e usar instruções que empilham e desempilham:
push rax // empilha o valor de rax
pop rbx // desempilha para rbx
A stack não desapareceu. Ela foi promovida a uma convenção implementada com registradores e memória. Nossa VM usava Vec<Value>; a CPU real usa ram[sp] onde sp é um registrador especial.
| Nosso VM | x86-64 | ARM64 | O que faz |
|---|---|---|---|
Constant | mov rax, 42 | mov x0, #42 | carrega valor imediato |
Add | add rax, rbx | add x0, x1, x2 | soma |
LoadGlobal | mov rax, [mem] | ldr x0, [x1] | lê da memória |
StoreGlobal | mov [mem], rax | str x0, [x1] | escreve na memória |
Jump | jmp endereco | b endereco | salto incondicional |
JumpIfFalse | jz endereco | cbz x0, endereco | salto condicional |
Call | call funcao | bl funcao | chama função |
Return | ret | ret | retorna |
A semântica é a mesma. A sintaxe muda. Cada arquitetura escolheu nomes e formatos diferentes, mas todas precisam das mesmas operações fundamentais.
Tamanho de instruções
Nosso bytecode PBC1 tinha instruções de tamanho variável. CPUs reais também variam:
- x86-64: tamanho variável (1 a 15 bytes). Herança histórica.
- ARM64: tamanho fixo (4 bytes). Mais fácil de decodificar em hardware.
- RISC-V: tamanho fixo (4 bytes base, 2 bytes compressão). Design aberto.
Nossa micro-cpu usava tamanho fixo implícito. É mais parecido com ARM/RISC-V do que com x86.
Nossa VM executava passo a passo: buscava um opcode, decodificava, executava, e só então buscava o próximo. CPUs reais modernas não esperam.
Ciclo 1: I1 — Fetch
Ciclo 2: I1 — Decode | I2 — Fetch
Ciclo 3: I1 — Execute | I2 — Decode | I3 — Fetch
Ciclo 4: I1 — Write | I2 — Execute | I3 — Decode | I4 — Fetch
Cada linha horizontal é uma instrução. Cada coluna é um ciclo de clock. Quatro instruções em execução simultânea — como uma linha de montagem.
Isso é um pipeline. A CPU divide o trabalho em estágios independentes e processa várias instruções simultaneamente. Quando gerarmos código nativo, precisaremos saber que a CPU não lê nosso programa na ordem exata — ela reordena internamente para manter o pipeline cheio.
No artigo anterior, compilamos Pulso para PBC1. Para transformar Pulso em um executável nativo, precisaríamos:
- Traduzir nossos opcodes para instruções x86/ARM reais
- Mapear nossa stack para registradores e a stack da CPU
- Gerar um arquivo executável no formato ELF, PE ou Mach-O
- Resolver endereços de funções e variáveis
Fazer isso manualmente para cada arquitetura seria um projeto de anos. É aí que entra o LLVM.
Recebe código intermediário (LLVM IR)
Otimiza — remove redundâncias, aloca registradores
Gera código de máquina — x86, ARM, RISC-V
Produz executável — ELF, PE, Mach-O
LLVM é uma fábrica de compiladores. Ele não sabe sobre Pulso, mas entende LLVM IR — uma linguagem intermediária parecida com assembly, porém independente de arquitetura. Nosso trabalho será traduzir a AST da Pulso para LLVM IR. O LLVM faz o resto.
Porque LLVM IR fala em termos de CPU real: registradores, endereços de memória, tipos de máquina. Se não soubéssemos o que é um Program Counter, um registrador ou uma instrução de máquina, LLVM IR pareceria magia. Agora sabemos que cada instrução LLVM IR eventualmente vira um fetch-decode-execute em silício.
micro-cpu/
├── Cargo.toml
└── src/
├── lib.rs
└── main.rs
Cargo.toml
[package]
name = "micro-cpu"
version = "0.1.0"
edition = "2021"
src/lib.rs (completo)
pub struct Cpu {
pub regs: [u64; 8],
pub pc: usize,
pub ram: Vec<u8>,
pub running: bool,
}
impl Cpu {
pub fn new(ram: Vec<u8>) -> Self {
Self { regs: [0; 8], pc: 0, ram, running: false }
}
fn read_u8(&mut self) -> Result<u8, String> {
let v = self.ram.get(self.pc).copied()
.ok_or("fim inesperado da memória")?;
self.pc += 1;
Ok(v)
}
fn read_u16(&mut self) -> Result<u16, String> {
let lo = self.read_u8()? as u16;
let hi = self.read_u8()? as u16;
Ok((hi << 8) | lo)
}
fn read_ram_u64(&self, addr: usize) -> Result<u64, String> {
let bytes = self.ram.get(addr..addr + 8)
.ok_or("endereço de leitura inválido")?;
let mut buf = [0u8; 8];
buf.copy_from_slice(bytes);
Ok(u64::from_le_bytes(buf))
}
fn write_ram_u64(&mut self, addr: usize, value: u64) {
let bytes = value.to_le_bytes();
self.ram[addr..addr + 8].copy_from_slice(&bytes);
}
pub fn step(&mut self) -> Result<(), String> {
let opcode = self.read_u8()?;
match opcode {
0x01 => {
let dst = self.read_u8()? as usize;
let addr = self.read_u16()? as usize;
self.regs[dst] = self.read_ram_u64(addr)?;
}
0x02 => {
let src = self.read_u8()? as usize;
let addr = self.read_u16()? as usize;
self.write_ram_u64(addr, self.regs[src]);
}
0x03 => {
let dst = self.read_u8()? as usize;
let a = self.read_u8()? as usize;
let b = self.read_u8()? as usize;
self.regs[dst] = self.regs[a].wrapping_add(self.regs[b]);
}
0x04 => {
let dst = self.read_u8()? as usize;
let a = self.read_u8()? as usize;
let b = self.read_u8()? as usize;
self.regs[dst] = self.regs[a].wrapping_sub(self.regs[b]);
}
0x05 => {
let addr = self.read_u16()? as usize;
self.pc = addr;
}
0x06 => { self.running = false; }
other => return Err(format!("opcode {other} desconhecido")),
}
Ok(())
}
pub fn run(&mut self) -> Result<(), String> {
self.running = true;
while self.running { self.step()?; }
Ok(())
}
}
src/main.rs
use micro_cpu::Cpu;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut ram = vec![0u8; 0x0200];
ram[0x0100..0x0108].copy_from_slice(&42u64.to_le_bytes());
ram[0x0108..0x0110].copy_from_slice(&8u64.to_le_bytes());
let programa: Vec<u8> = vec![
0x01, 0x00, 0x00, 0x01,
0x01, 0x01, 0x08, 0x01,
0x03, 0x02, 0x00, 0x01,
0x02, 0x02, 0x10, 0x01,
0x06,
];
ram[..programa.len()].copy_from_slice(&programa);
let mut cpu = Cpu::new(ram);
cpu.run()?;
let resultado = u64::from_le_bytes(
cpu.ram[0x0110..0x0118].try_into()?
);
println!("Resultado na RAM[0x0110]: {}", resultado);
println!("Registradores: {:?}", cpu.regs);
Ok(())
}
O download desta sequência contém a micro-cpu como um projeto Rust independente. Ela não é uma maquete que apenas imprime um resultado pronto: cada byte do programa é buscado na RAM, decodificado e executado pelo mesmo loop estudado nas seções anteriores.
- Troque os valores gravados em
0x0100e0x0108e acompanhe quais registradores mudam. - Substitua
AddporSube explique por quewrapping_subevita pânico em underflow. - Insira um opcode inexistente e observe o erro antes de adicionar sua decodificação.
- Crie
JumpIfZero: leia um registrador, altere o PC e monte o primeiroifda CPU. - Adicione uma stack e então implemente
CalleReturn; esse experimento prepara diretamente o artigo “Como uma chamada de função funciona por dentro”.
O teste mais importante não verifica somente o número 50
Um bom teste fotografa o estado da máquina: o PC deve terminar depois de Halt, R0 deve conter 42, R1 deve conter 8, R2 deve conter 50 e os oito bytes em 0x0110 precisam representar 50 em little-endian. Assim testamos controle, registradores e memória separadamente.
Agora sabemos o destino final: registradores e instruções reais. O próximo mini livro volta à AST da Pulso e constrói a ponte que faltava entre essa árvore e a CPU: o LLVM.
“Construindo um compilador e uma máquina virtual do zero em Rust”: Pulso → AST → Cadência → PBC1 → mini-vm (a VM executa em software)
Este artigo: CPU real → registradores → RAM → fetch-decode-execute (o hardware executa diretamente)
Próximo artigo: Pulso → AST → LLVM IR → código nativo (LLVM traduz para a linguagem do silício)
A nossa VM era lenta porque usava stack?
Não necessariamente "lenta" para fins didáticos. Stack machines são mais simples de implementar, mas em hardware puro são menos eficientes que register machines. A JVM, por exemplo, é uma stack machine sofisticada que compensa com JIT.
Por que não usamos registradores na VM?
Porque a stack torna cada instrução auto-contida. Add não precisa dizer onde estão os operandos. Isso simplifica o compilador e deixa o bytecode compacto. É uma troca deliberada: simplicidade didática vs. performance máxima.
A micro-cpu que construímos é realista?
É uma versão didática. CPUs reais têm dezenas de registradores, caches, execução fora de ordem, unidades especializadas e proteção de memória. O ciclo fetch-decode-execute e o uso de registradores continuam reconhecíveis, mas a implementação física é muito mais paralela e sofisticada que nosso loop em Rust.
Por que precisamos saber disso para usar LLVM?
Porque LLVM IR expõe conceitos de máquina: tipos de tamanho fixo, blocos básicos, instruções que operam em valores virtuais (que viram registradores). Entender registradores e memória torna LLVM IR legível em vez de opaco.
Little-endian ou big-endian?
Nossa micro-cpu usou little-endian para dados (igual x86 e ARM). Os endereços de 16 bits no código usamos big-endian arbitrariamente para mostrar que a ordem é uma convenção — o importante é que quem grava e quem lê concordem.
O que vem depois?
No próximo artigo, vamos conectar a AST da Pulso ao LLVM. Em vez de gerar bytecode para nossa VM, geraremos LLVM IR. O LLVM transformará esse IR em código de máquina real — primeiro para x86-64, depois para ARM64 — produzindo um arquivo executável que roda diretamente no sistema operacional, sem precisar da mini-vm.
PROJETO COMPLETO · CÓDIGO DO CAPÍTULO
Continue exatamente de onde paramos.
O pacote reúne a linguagem Pulso, o compilador Cadência, a máquina virtual, a micro-CPU e os exemplos de LLVM IR usados nesta sequência. O README indica a ordem dos estágios e os comandos para executar cada experiência.