ADRIANOLAUREANO← Artigos
Compiladores LLVM Funções Rust

Como uma Chamada de Função Funciona por Dentro:
Da Stack ao LLVM

Nos artigos anteriores compilamos expressões e variáveis para código nativo. Mas uma linguagem sem funções é apenas uma calculadora com memória. Neste artigo a Pulso ganha funções com parâmetros e retorno — e vamos ver como a mesma mecânica de frames e endereço de retorno que existia na nossa VM e na micro-cpu se materializa em LLVM IR e, finalmente, em silício.

ARTIGO 13 · PARÂMETROS, FRAMES E RETORNO

5movimentos

2stacks

1endereço de retorno

ANATOMIA DE UMA CHAMADA · DESCER, ISOLAR, RETORNAR
CÓDIGOmain()soma(2, dobro(3))
CALL ↓
↑ RET
FRAME 03 · TOPOdobrox = 3 · retorno → soma
FRAME 02somaa = 2 · b = ?
FRAME 01mainretorno → sistema

VOCABULÁRIO VISUAL

Antes de avançar, conheça as peças deste capítulo.

01Funçãobloco nomeado que recebe e devolve valores
02Frameestado temporário de uma chamada
03Call stackpilha que organiza chamadas ativas
04Retornovalor e ponto para onde voltar
05ABIregras de registradores e alinhamento
06Recursãofunção chamando uma nova instância de si
Você não precisa memorizar agora. Este mapa existe para consultar sempre que um termo reaparecer.
01 /
Não Começaremos do Zero
A AST e o LLVM já existem. Falta dar corpo às funções.

No artigo “Construindo um compilador nativo do zero com LLVM”, nosso compilador LLVM sabia lidar com:

  • variáveis locais (alloca + store/load)
  • expressões aritméticas (fadd, fsub, etc.)
  • desvios condicionais (br com blocos básicos)
  • chamadas externas como printf

O que ainda não tínhamos era a capacidade de definir nossas próprias funções, passar argumentos para elas e receber valores de volta. Sem isso, cada programa Pulso era um único bloco main. Vamos corrigir isso agora.

A ideia que precisa ficar

Uma função é apenas um bloco de código nomeado que recebe valores pela stack (ou em registradores) e devolve um valor para quem chamou. A Pulso já sabia fazer isso na VM (via Call e Return). O LLVM faz o mesmo — só que o hardware executa diretamente.

O QUE ACONTECE EM SOMA(3, 4)Uma única linha esconde cinco movimentosA linguagem mostra a intenção; VM, compilador e CPU organizam o caminho de ida e volta.
01Avaliarcalcula argumentos3 · 4
02Prepararposiciona valoresarg0 · arg1
03Chamarsalva o retornocall @soma
04Executarcria o framea + b
05Retornardevolve ao chamadorret 7

Leitura técnica: quem define registradores, alinhamento e preservação de valores é a convenção de chamada da plataforma, também chamada de ABI.

02 /
A Nova Sintaxe da Pulso
O que muda na linguagem para suportar funções.

Adicionamos três construções à gramática da Pulso:

fn dobro(x) {
    retorne x * 2;
}

fn soma(a, b) {
    retorne a + b;
}

fn main() {
    var x = soma(3, 4);
    var y = dobro(x);
    mostre(y);   // imprime 14
}

Regras simples:

  • Toda função começa com fn, seguida do nome e dos parâmetros entre parênteses.
  • O corpo é um bloco entre chaves.
  • retorne devolve um valor para quem chamou. Se omitido, a função retorna 0.0.
  • main continua sendo o ponto de entrada do programa.
  • Funções devem ser declaradas antes de serem chamadas? Não. Nosso compilador fará duas passadas: uma para descobrir os nomes, outra para gerar o corpo.
A ideia que precisa ficar

A sintaxe é deliberadamente minimalista. Não temos tipos explícitos nos parâmetros porque a Pulso é dinamicamente tipada (tudo é double por enquanto). Não temos sobrecarga nem genéricos. O foco é entender a mecânica da chamada.

03 /
A AST Estendida
Três novos nós na árvore.

O parser agora entrega uma AST com três novos tipos de nó:

pub enum Stmt {
    Var(String, Box<Expr>),
    Print(Box<Expr>),
    If { cond: Box<Expr>, then_branch: Vec<Stmt>, else_branch: Vec<Stmt> },
    // NOVO:
    Function {
        name: String,
        params: Vec<String>,
        body: Vec<Stmt>,
    },
    Return(Option<Box<Expr>>),
}

pub enum Expr {
    Number(f64),
    StringLiteral(String),
    Boolean(bool),
    Variable(String),
    Binary { op: String, left: Box<Expr>, right: Box<Expr> },
    // NOVO:
    Call { name: String, args: Vec<Expr> },
}

Note que Function é um Stmt de nível superior — ele não aparece dentro de outras funções (ainda). Call é um Expr porque uma chamada de função produz um valor: soma(3, 4) vale 7.

Programa
├── Function "dobro" ["x"]
│   └── Return
│       └── Binary "*"
│           ├── Variable "x"
│           └── Number 2.0
├── Function "soma" ["a", "b"]
│   └── Return
│       └── Binary "+"
│           ├── Variable "a"
│           └── Variable "b"
└── Function "main" []
    ├── Var "x"
    │   └── Call "soma"
    │       ├── Number 3.0
    │       └── Number 4.0
    ├── Var "y"
    │   └── Call "dobro"
    │       └── Variable "x"
    └── Print
        └── Variable "y"
AST do programa exemplo. Cada função é uma sub-árvore independente.
04 /
Revisitando a VM: Frames e Endereço de Retorno
Como a mini-vm fazia uma chamada de função.
FOTOGRAFIAS DA STACKCada chamada ganha um espaço temporárioUm frame reúne parâmetros, variáveis locais e o endereço usado para voltar.
ANTESmaincontinua executando
DURANTEsomaa = 3 · b = 4retorno → mainmain fica abaixo
DEPOISmainrecebe resultado = 7

Detalhe importante: “destruir o frame” significa mover o topo lógico da stack. Os bytes podem continuar ali por algum tempo, mas já não pertencem à função encerrada.

Na mini-vm do artigo “Construindo um compilador e uma máquina virtual do zero em Rust”, quando encontrávamos um opcode Call, o que acontecia por baixo?

  1. Empilhar os argumentos na stack de valores (o chamador fazia isso antes).
  2. Criar um novo frame na stack de chamadas, guardando:
    • o endereço de retorno (onde o ip deve voltar quando a função terminar)
    • o endereço base das variáveis locais da função chamada
  3. Atualizar o ip para o primeiro opcode da função.
  4. Executar o corpo normalmente, até encontrar Return.
  5. Desempilhar o frame, restaurar o ip para o endereço de retorno, e colocar o valor de retorno no topo da stack.

Essa dança é invisível para o programador Pulso, mas é o coração de toda chamada de função em qualquer sistema computacional.

EstadoAção da VMO que muda na stack
Antes do CallEmpilha argumentos[... args ...]
Durante CallCria frame, salva ip[... args ...] + [frame]
No corpoExecuta instruçõesLocais alocadas no frame
No ReturnCalcula valor, limpa frameFrame removido, valor no topo
Depois do CallContinua em quem chamou[... resultado ...]
A ideia que precisa ficar

Call e Return são operações de stack. A VM fazia isso explicitamente com Vec<Frame>. A CPU real faz com a stack de hardware (rsp no x86-64). O LLVM abstrai isso em call e ret, mas por baixo a mesma dança acontece.

05 /
Revisitando a Micro-CPU: Call e Return no Silício
O hardware não tem frames. Tem endereços e uma stack.

Nossa micro-cpu do artigo “Como uma CPU funciona por dentro” não implementou Call e Return, mas vimos que ela tinha um Jump. Call nada mais é do que um Jump que guarda o endereço de onde veio.

Em uma CPU real (x86-64, por exemplo):

; Chamador empilha argumentos (convenção de chamada)
push 3.0          ; argumento b
push 4.0          ; argumento a
call soma         ; empilha endereço de retorno e salta
; quando 'soma' retornar, o resultado estará em xmm0

; Dentro de 'soma':
soma:
    push rbp          ; salva frame anterior
    mov rbp, rsp      ; novo frame base
    ; ... corpo da função ...
    pop rbp           ; restaura frame anterior
    ret               ; desempilha endereço de retorno e salta

Note o trio sagrado:

  • call = push endereço de retorno + jmp para função
  • ret = pop endereço de retorno + jmp para ele
  • rbp/rsp = registradores que delimitam o frame atual na stack
A ideia que precisa ficar

A stack de hardware é a mesma ideia da nossa VM. A diferença é que a CPU tem registradores dedicados (rsp, rbp) e instruções específicas (call, ret) para tornar isso extremamente rápido — uma operação de um ciclo de clock.

06 /
LLVM IR: Funções, Parâmetros e Retorno
Como o LLVM representa o que a CPU faz em hardware.

Vamos ver como o programa exemplo se parece em LLVM IR:

define double @dobro(double %x) {
entry:
  %mul = fmul double %x, 2.0
  ret double %mul
}

define double @soma(double %a, double %b) {
entry:
  %add = fadd double %a, %b
  ret double %add
}

define i32 @main() {
entry:
  ; x = soma(3.0, 4.0)
  %call_soma = call double @soma(double 3.0, double 4.0)

  ; y = dobro(x)
  %call_dobro = call double @dobro(double %call_soma)

  ; mostre(y) → printf
  %fmt = call ptr @printf(ptr @fmt_num, double %call_dobro)

  ret i32 0
}

Compare os três mundos:

Nossa VM (PBC1)Micro-CPU (assembly)LLVM IR
Call <função> call endereço call tipo @nome(args)
Return ret ret tipo valor
Argumentos na stack Registradores / stack (ABI) Parâmetros nomeados (%a, %b)
Endereço de retorno no frame Empilhado por call Gerenciado automaticamente

LLVM IR abstrai a stack. Não vemos push e pop explícitos porque o LLVM sabe, de acordo com a ABI (Application Binary Interface) do sistema operacional, se os argumentos devem ir em registradores (x86-64 System V usa %xmm0, %xmm1 para floats) ou na stack. Nosso trabalho é apenas declarar os parâmetros e usá-los.

A ideia que precisa ficar

define cria uma função. call a invoca. ret devolve o controle. O LLVM traduz isso para o par call/ret da CPU real, que por sua vez manipula a stack de hardware. São três níveis de abstração do mesmo conceito.

07 /
Compilando Funções com Inkwell
Duas passadas: declarar, depois implementar.

O desafio ao compilar funções é a referência futura: main pode chamar soma, mas no AST main pode aparecer antes de soma no vetor de statements. O LLVM exige que uma função seja declarada antes de ser chamada.

Por isso usamos duas passadas:

  1. Passada 1 — Declaração: Percorremos todas as funções e criamos seus protótipos no módulo LLVM (add_function). Guardamos os ponteiros em um HashMap.
  2. Passada 2 — Implementação: Percorremos novamente, agora gerando o corpo (bloco entry, alocação de parâmetros, compilação dos statements).

Isso também permite recursão: uma função pode se chamar porque seu próprio nome já está no HashMap quando estamos compilando seu corpo.

O HashMap de funções

pub struct CodeGen<'ctx> {
    context: &'ctx Context,
    module: Module<'ctx>,
    builder: Builder<'ctx>,
    variables: HashMap<String, PointerValue<'ctx>>,
    // NOVO: funções declaradas no módulo
    functions: HashMap<String, FunctionValue<'ctx>>,
}

Passada 1: Declarar protótipos

fn declare_functions(&mut self, stmts: &[Stmt]) {
    let f64_type = self.context.f64_type();

    for stmt in stmts {
        if let Stmt::Function { name, params, .. } = stmt {
            // Tipo da função: (double, double, ...) -> double
            let param_types: Vec<BasicMetadataTypeEnum> =
                params.iter().map(|_| f64_type.into()).collect();

            let fn_type = f64_type.fn_type(&param_types, false);
            let fn_val = self.module.add_function(name, fn_type, None);
            self.functions.insert(name.clone(), fn_val);
        }
    }
}

Passada 2: Compilar corpos

fn compile_function(&mut self, name: &str, params: &[String], body: &[Stmt])
    -> Result<(), String>
{
    let fn_val = *self.functions.get(name)
        .ok_or_else(|| format!("função {} não declarada", name))?;

    // Cria bloco entry
    let entry = self.context.append_basic_block(fn_val, "entry");
    self.builder.position_at_end(entry);

    // Limpa variáveis do escopo anterior e mapeia parâmetros
    self.variables.clear();

    for (i, param_name) in params.iter().enumerate() {
        // Pega o parâmetro LLVM
        let param = fn_val.get_nth_param(i as u32)
            .ok_or("parâmetro não encontrado")?
            .into_float_value();

        // Aloca espaço na stack e armazena o parâmetro
        // (isso permite que o parâmetro seja tratado como variável mutável)
        let alloca = self.builder.build_alloca(
            self.context.f64_type(), param_name
        ).map_err(|e| e.to_string())?;

        self.builder.build_store(alloca, param)
            .map_err(|e| e.to_string())?;

        self.variables.insert(param_name.clone(), alloca);
    }

    // Compila o corpo
    for stmt in body {
        self.compile_stmt(stmt)?;
    }

    // Se a função não tiver retorno explícito, retorna 0.0
    if self.builder.get_insert_block().unwrap().get_terminator().is_none() {
        self.builder.build_return(
            Some(&self.context.f64_type().const_float(0.0))
        ).map_err(|e| e.to_string())?;
    }

    Ok(())
}

Por que alocamos os parâmetros na stack? Em LLVM IR, parâmetros são valores SSA (imutáveis por natureza). Se quisermos permitir que uma função Pulso faça var a = a + 1 (redefinir um parâmetro), precisamos de um endereço de memória. Alocar na stack com alloca e fazer store do parâmetro é o padrão LLVM para "promover parâmetros a variáveis locais".

A ideia que precisa ficar

Duas passadas resolvem o problema da galinha e do ovo. Na primeira, anunciamos que a função existe. Na segunda, usamos essa existência para gerar chamadas e corpos. É o mesmo princípio do patch_jump da Cadência, só que em escala de funções inteiras.

08 /
Programa Completo: Três Funções e um Executável
Do AST ao ./programa em um único fluxo.

Vamos ver o código completo que compila nosso exemplo. A estrutura do projeto:

pulso-funcoes/
├── Cargo.toml
└── src/
    ├── main.rs
    └── codegen.rs
src/codegen.rs — gerador LLVM (trechos novos)
fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), String> {
    match stmt {
        // ... Var, Print, If como antes ...

        Stmt::Return(expr) => {
            let val = match expr {
                Some(e) => self.compile_expr(e)?,
                None => self.context.f64_type().const_float(0.0),
            };
            self.builder.build_return(Some(&val))
                .map_err(|e| e.to_string())?;
        }

        Stmt::Function { .. } => {
            // Funções são compiladas na passada 2, não aqui
        }
    }
    Ok(())
}

fn compile_expr(&self, expr: &Expr) -> Result<FloatValue<'ctx>, String> {
    match expr {
        // ... Number, Variable, Binary como antes ...

        Expr::Call { name, args } => {
            let func = self.functions.get(name)
                .ok_or_else(|| format!("função '{}' não encontrada", name))?;

            let mut compiled_args: Vec<BasicMetadataValueEnum> = vec![];
            for arg in args {
                compiled_args.push(self.compile_expr(arg)?.into());
            }

            let call = self.builder.build_call(
                *func, &compiled_args, "calltmp"
            ).map_err(|e| e.to_string())?;

            Ok(call.try_as_basic_value()
                .left()
                .unwrap()
                .into_float_value())
        }
    }
}
src/main.rs — orquestrando a compilação
use inkwell::context::Context;
use std::process::Command;

mod codegen;
use codegen::{CodeGen, Expr, Stmt};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // AST do programa:
    // fn dobro(x) { retorne x * 2; }
    // fn soma(a, b) { retorne a + b; }
    // fn main() { var x = soma(3, 4); var y = dobro(x); mostre(y); }
    let program = vec![
        Stmt::Function {
            name: "dobro".to_string(),
            params: vec!["x".to_string()],
            body: vec![
                Stmt::Return(Some(Box::new(Expr::Binary {
                    op: "*".to_string(),
                    left: Box::new(Expr::Variable("x".to_string())),
                    right: Box::new(Expr::Number(2.0)),
                }))),
            ],
        },
        Stmt::Function {
            name: "soma".to_string(),
            params: vec!["a".to_string(), "b".to_string()],
            body: vec![
                Stmt::Return(Some(Box::new(Expr::Binary {
                    op: "+".to_string(),
                    left: Box::new(Expr::Variable("a".to_string())),
                    right: Box::new(Expr::Variable("b".to_string())),
                }))),
            ],
        },
        Stmt::Function {
            name: "main".to_string(),
            params: vec![],
            body: vec![
                Stmt::Var(
                    "x".to_string(),
                    Box::new(Expr::Call {
                        name: "soma".to_string(),
                        args: vec![Expr::Number(3.0), Expr::Number(4.0)],
                    }),
                ),
                Stmt::Var(
                    "y".to_string(),
                    Box::new(Expr::Call {
                        name: "dobro".to_string(),
                        args: vec![Expr::Variable("x".to_string())],
                    }),
                ),
                Stmt::Print(Box::new(Expr::Variable("y".to_string()))),
            ],
        },
    ];

    let context = Context::create();
    let mut codegen = CodeGen::new(&context, "pulso_funcoes");

    // Declara printf e as funções do programa
    codegen.declare_externals();
    codegen.declare_functions(&program);

    // Compila cada função
    for stmt in &program {
        if let Stmt::Function { name, params, body } = stmt {
            codegen.compile_function(name, params, body)?;
        }
    }

    // Mostra o IR gerado
    println!("=== LLVM IR gerado ===");
    codegen.print_ir();

    // Emite .o e linka
    codegen.emit_object("programa.o")?;
    let status = Command::new("clang")
        .args(&["programa.o", "-o", "programa", "-lm"])
        .status()?;

    if status.success() {
        println!("\n=== Executável gerado. Rodando... ===");
        Command::new("./programa").status()?;
    }

    Ok(())
}

Compilando e executando

$ cargo run
=== LLVM IR gerado ===
define double @dobro(double %x) {
entry:
  %x1 = alloca double, align 8
  store double %x, ptr %x1, align 8
  %x2 = load double, ptr %x1, align 8
  %multmp = fmul double %x2, 2.000000e+00
  ret double %multmp
}

define double @soma(double %a, double %b) {
entry:
  %a1 = alloca double, align 8
  store double %a, ptr %a1, align 8
  %b2 = alloca double, align 8
  store double %b, ptr %b2, align 8
  %a3 = load double, ptr %a1, align 8
  %b4 = load double, ptr %b2, align 8
  %addtmp = fadd double %a3, %b4
  ret double %addtmp
}

define i32 @main() {
entry:
  %x = alloca double, align 8
  %calltmp = call double @soma(double 3.000000e+00, double 4.000000e+00)
  store double %calltmp, ptr %x, align 8
  %y = alloca double, align 8
  %x1 = load double, ptr %x, align 8
  %calltmp2 = call double @dobro(double %x1)
  store double %calltmp2, ptr %y, align 8
  %y3 = load double, ptr %y, align 8
  %printf_call = call i32 (ptr, ...) @printf(ptr @fmt, double %y3)
  ret i32 0
}

=== Executável gerado. Rodando...
14

O número 14 foi calculado por três funções nativas. Nenhuma VM interpretou bytecode. O processador executou @soma, depois @dobro, depois @printf — tudo em código de máquina real, com frames de stack reais gerenciados pelo LLVM de acordo com a ABI do sistema.

O que agora funciona
  • Declaração de funções com múltiplos parâmetros
  • Retorno de valores via retorne
  • Chamada de funções como expressões (var x = soma(3, 4))
  • Encadeamento de chamadas (dobro(soma(3, 4)))
  • Duas passadas de compilação (declaração + implementação)
  • Cada função vira um define LLVM IR independente
09 /
Como Provar que Nossas Funções Funcionam
Resultado correto não basta: precisamos testar frames, argumentos, retorno e recursão.

Funções introduzem estado temporário e desvios de controle. Por isso um único programa que imprime 14 não cobre os erros mais perigosos. O mesmo compilador precisa preservar a ordem dos argumentos, isolar variáveis locais de chamadas diferentes e restaurar o chamador depois de cada retorno.

Matriz mínima de testes
  • Zero argumentos: prova que o ponto de entrada e o retorno vazio funcionam.
  • Vários argumentos: detecta inversões entre a e b.
  • Chamadas aninhadas: dobro(soma(3, 4)) exige que resultados virem novos argumentos.
  • Recursão: fatorial cria vários frames da mesma função sem misturar seus valores locais.
  • Erro de aridade: chamar soma(1) deve falhar na compilação, antes de chegar ao LLVM.
  • Caminho sem retorno: toda função precisa terminar em ret; o verificador do LLVM deve confirmar isso.

A recursão é o teste de estresse conceitual

Em fat(4), cada chamada guarda um n diferente e um endereço de retorno diferente. O frame de fat(1) termina primeiro; depois os resultados voltam na ordem inversa: 1, 2, 6 e 24. Visualizar essa descida e subida transforma “recursão” de truque abstrato em movimentação concreta da stack.

Limite que abre o próximo artigo

Os frames resolvem valores que vivem durante uma chamada. Eles não resolvem uma função devolvida que ainda precisa de uma variável do frame já encerrado. É exatamente aí que entram as closures e o heap do próximo artigo.

09 /
A Próxima Ponte
Agora que funções existem, podemos fazer elas capturarem o mundo ao redor.
09Pulsolinguagem com lexer, parser e AST
10Cadência + mini-vmcompilador e máquina virtual de bytecode
11micro-cpucomo uma CPU real executa instruções
12LLVMgerando código nativo a partir da AST
13Funçõesparâmetros, retorno e chamadas nativas ← você está aqui
14Closuresquando a stack não basta: captura de variáveis
O que falta para closures

As funções que criamos são estáticas: seus parâmetros vivem apenas durante a chamada. Uma closure é uma função que lembra do ambiente onde foi criada — mesmo depois que esse ambiente deixou de existir na stack. Isso exige que variáveis capturadas vivam no heap, não na stack. É o próximo salto: do frame de chamada para a alocação dinâmica de memória.

10 /
Perguntas Frequentes
O que costuma ficar depois do primeiro call nativo.
Por que precisamos de duas passadas? Não dá pra compilar de uma vez?

Se main aparece antes de soma no AST, e tentássemos gerar o corpo de main imediatamente, o LLVM ainda não saberia que @soma existe. A primeira passada cria os protótipos (só o nome e os tipos), permitindo que qualquer função referencie qualquer outra. A segunda passada preenche os corpos. Isso também habilita recursão.

Os parâmetros são passados por valor ou por referência?

Por valor. Em LLVM IR, call copia os argumentos para os parâmetros da função chamada. Como usamos double (64 bits), a cópia é barata. Se tivéssemos structs grandes, a ABI poderia passar por referência implicitamente. Para a Pulso atual, tudo é por valor.

E se eu quiser retornar mais de um valor?

LLVM IR permite retornar structs, mas a Pulso ainda não tem tipos compostos. Uma alternativa comum em linguagens simples é retornar um array ou usar parâmetros de saída (ponteiros). Isso será coberto quando introduzirmos structs na série.

Recursão funciona?

Sim. Como declaramos o protótipo da função antes de compilar seu corpo, ela já existe no HashMap functions quando o gerador de código processa seu próprio corpo. Uma chamada recursiva como fn fat(n) { se n <= 1 { retorne 1; } retorne n * fat(n-1); } funciona sem alterações no compilador.

O LLVM não otimiza as alloca dos parâmetros?

Otimiza. A passada mem2reg do LLVM promove automaticamente alloca que só têm um store e um load para registradores SSA. O código de máquina final não faz loads e stores desnecessários. Por isso podemos gerar IR "ingênuo" e confiar no LLVM para limpar.

E funções anônimas (lambdas)?

Elas são o próximo passo — e exigem closures. Uma função anônima sem captura de variáveis externas é tecnicamente só um ponteiro de função. Mas o poder real vem quando ela captura o ambiente: aí precisamos de heap, structs de captura e, eventualmente, garbage collection. É o artigo “Como closures funcionam por dentro: quando a stack não basta”.

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.

Baixar código final deste artigo (.zip)Rust · Pulso · Cadência · micro-CPU · LLVM IR

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.