Pular para o conteúdo

Especificação §18

Inline assembly

language-design.md §18 · 120 linhas · 6 min de leitura

Inline assembly é a extensão low-level da FFI (seção 17): assim como o @cimport traz C pra dentro, o assembly traz instruções de máquina cruas, pra quem precisa descer ao metal (um syscall, um rdtsc, uma instrução SIMD que o compilador não emite, uma barreira de memória). É a fronteira mais baixa que a linguagem oferece, usada por uma minoria, e como toda fronteira pra fora das garantias, é unsafe: o juízo “este assembly está correto” é inexprimível pro compilador, é o caso-B humano do assume (seção 5).

Há duas formas, que compartilham os mesmos decoradores: a função-assembly (a função inteira é asm, com params e retorno ligados a registradores) e o bloco inline (instruções no meio de código normal). A regra pra escolher é simples. Se você quer um valor de asm, nomeado e reutilizável, use a função; se quer rodar asm no meio de código normal, mexendo em variáveis que já existem, use o bloco.

O corpo da função é assembly, e os params e o retorno se ligam a registradores via @asm(in, …)/@asm(out, …) colocados no próprio param (o binding fica junto do que ele liga):

@asm(x86, intel)
unsafe fn add10(value: u32 @asm(in, rax)) -> u32 @asm(out, rbx) {
mov rbx, rax
add rbx, 10
}
// uso: forma-valor, como qualquer função:
result := unsafe add10(x) assume "x cabe em u32 e rbx não é usado pela ABI aqui"

@asm(x86, intel) decora a função com arquitetura e dialeto (e opções, abaixo). value: u32 @asm(in, rax) liga o param ao rax; -> u32 @asm(out, rbx) diz que o retorno é o rbx. O mesmo @asm carrega dois papéis sem ambiguidade: na função ele leva (arquitetura, dialeto); num param ou local ele leva (in|out|clobber, registrador), e a posição mais o primeiro argumento distinguem. Os clobbers são implícitos aqui: a função é uma fronteira de chamada, então a ABI já define quais registradores ela suja, e você não lista nada. A função retorna valor (result := add10(x)), é nomeada, reutilizável, testável, e com inlining tem custo zero. É a forma pra “asm que produz um valor”.

Pra rodar asm no meio de uma função normal, use o bloco. Os decoradores ficam empilhados acima do unsafe {} (decorador sempre em cima do que decora), e o bloco contém só instruções:

var lo: u32
var hi: u32
@asm(x86, intel)
@asm(out, rax) lo
@asm(out, rdx) hi
@asm(clobber, "memory")
unsafe {
rdtsc // põe o timestamp em EDX:EAX
}
// 'lo' e 'hi' têm o valor agora

O cabeçalho liga locals em escopo a registradores (@asm(out, rax) lo), e o unsafe {} (que já é escopo-expressão, seção 5) carrega as instruções. São três diferenças em relação à função, cada uma por um motivo:

  1. Binding por cabeçalho, não por param: o bloco não tem assinatura onde colocar o binding, então os @asm(in, …)/@asm(out, …) ficam acima, ligando locals.
  2. Clobbers explícitos (@asm(clobber, …)), e aqui é correção, não verbosidade: o bloco está no meio de código que usa registradores, então ele tem que declarar o que suja (registradores, "memory"), senão o compilador assume que estão intactos e gera código errado. A função não precisa porque a ABI cobre; o bloco precisa porque protege o código ao redor.
  3. É statement, não expressão: o bloco escreve nos locals de saída (@asm(out, rax) lo escreve em lo), não avalia pra um valor. Quem quer asm como valor usa a função-assembly; o bloco cobre o “asm no meio do código”. (Não há forma-expressão: ela seria uma função-assembly anônima, duplicando a função sem poder reusar nem nomear, e o result := no topo, separado do unsafe {} lá embaixo por três decoradores, lê pior.)

Cada binding nomeia um registrador específico (rax) ou a classe xreg (o compilador escolhe um registrador livre, e otimiza):

@asm(x86, intel)
unsafe fn double(value: u32 @asm(in, xreg)) -> u32 @asm(out, xreg) {
add value, value // refere pelo NOME: você não sabe qual registrador é
}

A diferença no corpo: com registrador explícito você escreve o registrador cru (rax) ou o nome do operando, e ambos resolvem, porque você sabe qual é. Com xreg você tem que usar o nome simbólico (value), porque o registrador é escolhido pelo compilador e você não tem como nomeá-lo. Explícito é pra quando a instrução exige um registrador específico (muitos syscalls); xreg é pra quando você só precisa de “um registrador” e quer deixar o alocador otimizar.

O dialeto vive no segundo argumento do @asm, e é por arquitetura (cada máquina tem a sua sintaxe):

  • x86: intel ou att (a mesma instrução, grafias diferentes, ambas parseadas pelo backend).
  • ARM e RISC-V: os dialetos nativos de cada uma.
  • WASM: as duas sintaxes do WebAssembly text (folded/S-expression e linear/stack), aceitas no mesmo bloco sem toggle, porque a forma folded é só açúcar da linear (o parser de WAT expande uma na outra).
@asm(x86, att) // mesma adição, dialeto AT&T
unsafe fn add10(value: u32 @asm(in, rax)) -> u32 @asm(out, rbx) {
movl %eax, %ebx
addl $10, %ebx
}

WASM é uma máquina de pilha, não tem registradores. Então todo o modelo de binding por registrador (@asm(in, rax)) não se aplica: não há rax. Mas isso é menos trabalho, não mais, porque o WASM já é mais alto-nível, com params e locals tipados nativamente. Uma função-assembly WASM liga pelos próprios params (sem @asm(in/out)); um bloco WASM liga por locals:

@asm(wasm, wasm) // sem @asm(in/out): WASM já tem params/result tipados
unsafe fn add(a: u32, b: u32) -> u32 {
local.get a
local.get b
i32.add
}

A assimetria é parte do design: binding por registrador no x86, ARM e RISC-V, e binding nativo por param ou local no WASM. O @asm tem semântica de operando dependente da arquitetura: nas máquinas de registrador você liga registradores, na máquina de pilha os params já são os operandos.

Como asm é específico de arquitetura, você escreve uma versão por arquitetura que suporta e o compilador escolhe a do alvo, via o pacote arch da stdlib (constantes comptime: .x86_64, .arm, .riscv, .wasm e afins) com o comptime match/comptime if que já existe:

use arch
fn timestamp() -> u64 {
comptime if arch.current == .x86_64 {
return unsafe rdtsc_x86() assume "rdtsc disponível neste alvo"
}
return portable_clock() // outro alvo: caminho normal, sem asm
}

O prefixo comptime é o que faz a seleção resolver em tempo de compilação: o if inteiro é avaliado pelo compilador, um braço é escolhido e SÓ esse braço é emitido (eliminação do braço morto). É o mesmo prefixo que comptime match e comptime loop usam, e é o que um if de runtime não conseguiria — um if de runtime ainda emitiria os dois braços, então o @asm de outra arquitetura no braço não-tomado chegaria ao codegen. A seleção roda em comptime (o arch.current é constante de compilação), então o binário do alvo só contém o caminho da sua arquitetura. E mismatch de arquitetura é erro de compilação: compilar um bloco @asm(x86, ...) pra um alvo ARM ou WASM não compila. Você precisa fornecer o bloco da arquitetura alvo, sem fallback mágico (é a natureza do asm; um mov rax não existe em ARM). É a mesma rigidez do Rust e do Zig: sem o asm da arch, não compila.

Além do inline, assembly também pode morar em arquivos próprios (.s / .asm / .extasm), pra trechos grandes (uma rotina cripto inteira, um hot path extenso) que poluiriam o código se inline. Os arquivos seguem os mesmos dialetos por arquitetura, e são linkados pelo build como o .c do FFI.

O assembly é emitido pelo LLVM por enquanto, escolha pragmática que viabiliza os múltiplos alvos (incluindo WASM) e o cross-compile sem reescrever um assembler por arquitetura. Um backend próprio está nos planos (o caminho que o Zig trilhou: LLVM primeiro, backends nativos depois), mas é trabalho futuro.

E o que não entra é uma sintaxe de assembly normalizada própria (o que o Go fez com Plan9, uma única gramática cross-arch). Com suporte aos N dialetos reais (Intel, AT&T, ARM, RISC-V, WASM), inventar mais uma sintaxe unificada é trabalho sem retorno: você escreve no dialeto nativo de cada arquitetura, que é o que as ferramentas e a documentação de cada ISA já usam.