Pular para o conteúdo

O livro · 13

FFI e baixo nível

book.md · 180 linhas · 5 min de leitura

Objetivo: chamar C com @cimport, descer a assembly inline quando preciso, e entender por que a fronteira insegura é contida, não espalhada.

@cimport("header.h") roda em comptime, invoca um compilador C embutido, traduz o header inteiro e devolve um módulo sob o namespace-raiz c. Você não redeclara protótipo nenhum:

@cimport("stdio.h") // → c.stdio
@cimport("SDL2/SDL.h") as gfx // → c.gfx (renomeia o submódulo)
unsafe c.stdio.printf("%d\n", count) // tudo qualificado sob 'c.'

Tudo do C fica sob c.: o ponto grita “isto é C” em todo contato, e seu namespace fica limpo (FFI não polui quem não faz FFI). Os tipos fundamentais vêm de um runtime importado, porque são dependentes de compilador/plataforma:

@cimport("gcc") // → c.gcc
size: c.gcc.size_t = n // o size_t COMO O GCC o define

O NULL do C vira Optional[*c.gcc.char] na fronteira (capítulo 01: ausência é Optional); structs suas indo pro C precisam de @repr(c) (layout fiel). Deps C são declaradas em cdeps no mk.project (o .c compila junto).

A toolchain traz o próprio compilador C E a própria biblioteca C (musl), do jeito que o Zig faz, então um build nunca depende de qual libc está instalada na máquina. --libc=musl linka seu programa estaticamente contra essa musl embarcada, produzindo um binário que não depende de nada:

Janela do terminal
mk build --libc=musl server.mko // binário totalmente estático; o `ldd` diz "not a dynamic executable"

O padrão (--libc=system) linka a biblioteca C do host dinamicamente, como de costume. A musl também é importável como runtime, @cimport("musl") c.musl.*, carregando a ABI da própria musl do mesmo jeito que c.gcc/c.clang carregam as suas; importá-la seleciona o build hermético automaticamente:

@cimport("musl")
n: i32 = unsafe c.musl.abs(-7) assume "puro" // linka a musl embarcada, estático, sem libc do sistema

A mesma toolchain embarcada é exposta direto como mk cc, um substituto de cc no espírito do zig cc. Ele encaminha os argumentos ao compilador mas linka contra a musl embarcada, então compila C em qualquer máquina e emite um binário estático, sem precisar da libc do sistema:

Janela do terminal
mk cc server.c -o server // um binário C totalmente estático
mk cc -c util.c -o util.o // compila uma unidade de tradução, drop-in como CC num Makefile

Isso o torna usável como o CC de um projeto C existente, pra adoção incremental ao lado do Makoto. C++ também compila (mk cc foo.cpp); linkar um programa C++ contra a biblioteca padrão de C++ precisa de um runtime C++ que a toolchain ainda não empacota.

A fronteira é de mão dupla. Uma função marcada @extern é exportada com seu nome C cru, então código C pode chamá-la. mk build --emit=lib compila um arquivo Makoto (sem exigir main) numa biblioteca estática mais um header C gerado:

@extern fn triple(x: i32) -> i32 { return x * 3 }
Janela do terminal
mk build --emit=lib mathlib.mko // -> libmathlib.a + libmathlib.h

O header declara cada export (envolto em extern "C", então C++ também chama) e makoto_init, que o chamador roda uma vez no início pra armar o runtime do Makoto antes de chamar qualquer export que aloca:

#include "libmathlib.h"
int main(void) { makoto_init(); return triple(14); } // linka libmathlib.a; retorna 42

Como a convenção de chamada já é a do C (System V), um export é um símbolo C puro, sem shim. É assim que uma codebase C ou C++ adota Makoto incrementalmente: escreve um módulo em Makoto, linka como biblioteca, chama.

@cimport de um header C++ (.hpp/.hh/.hxx) parseia como C++: funções resolvem pros seus símbolos Itanium-mangled, então o Makoto chama uma função livre C++ (em namespace ou não) e passa structs POD do mesmo jeito que chama C:

@cimport("mathpp.hpp") // namespace mathpp { int add(int, int); }
n := unsafe c.mathpp.add(20, 22) assume "c++"

Um header cuja extensão não revela a linguagem (um .h que na verdade é C++, como algumas bibliotecas distribuem) recebe um override explícito de linguagem como segundo argumento opcional, "c" ou "c++":

@cimport("legacy.h", "c++") // um .h que é realmente C++
@cimport("weird.hpp", "c") // força C puro num .hpp

As partes duras de C++ (templates, dispatch virtual, exceptions, RAII, a STL) são alcançadas do jeito que toda linguagem as alcança: o compilador C++ é dono da semântica C++, e o Makoto chama um ponto de entrada C-ABI. Você escreve um shim fino extern "C" em C++ e compila com mk cc; dentro dele você instancia templates, chama métodos virtuais, faz try/catch e usa std:: à vontade:

// bridge.cpp — o mk cc compila isto; o compilador C++ cuida do C++.
extern "C" int total(const int* xs, int n) {
std::vector<int> v(xs, xs + n); // STL, RAII
return std::accumulate(v.begin(), v.end(), 0);
}

O mk cc e o mk build --libc=musl linkam o runtime C++ automaticamente: a libstdc++ do host num build de sistema, a libc++ embarcada (alvo musl) num hermético, então um programa Makoto que chama C++ ainda vira um único binário totalmente estático. Uma função C++ sobrecarregada (dois símbolos, um nome) é um erro de compilação na fronteira em vez de uma escolha silenciosa — dê à sobrecarga que você quer o seu próprio shim extern "C".

Toda chamada a C é uma caixa-preta que pode violar qualquer invariante, então é uma operação unsafe, abatida com assert/assume ou agrupada num bloco:

n := unsafe c.unistd.read(fd, buf, len) assume "fd válido e buf comporta len bytes"
unsafe {
c.SDL.SDL_Init(c.SDL.SDL_INIT_VIDEO)
win := c.SDL.SDL_CreateWindow(...)
}

E o retorno do C valida na borda: ponteiro que pode ser NULL vira Optional, bytes que viram string passam por Result. Depois de validado, você opera com as garantias normais.

unsafe não é uma região onde tudo é permitido e silencioso (modelo Rust). É uma obrigação de tratamento: cada operação perigosa exige um lidador (assert/assume) ou é repassada (a função vira unsafe fn). Qualquer função que cumpra as pré-condições absorve o perigo e expõe interface segura por cima. Por isso unsafe vive em ilhas pequenas com fronteiras seguras, e não num call stack inteiro pintado.

A extensão mais baixa da FFI: instruções de máquina cruas, para um syscall, um rdtsc, uma SIMD que o compilador não emite. O binding de registrador é via @asm:

@asm(x86, intel)
unsafe fn add10(value: u32 @asm(in, rax)) -> u32 @asm(out, rbx) {
mov rbx, rax
add rbx, 10
}

Seleção por arquitetura é comptime (via o pacote arch); mismatch de arch é erro de compilação (não há fallback mágico, porque um mov rax não existe em ARM).

use arch
fn timestamp() -> u64 {
comptime if arch.current == .x86_64 {
return unsafe rdtsc_x86() assume "rdtsc disponível neste alvo"
}
return portable_clock()
}

Próximo: 14 · Extensão de UI