Pular para o conteúdo

Especificação §17

FFI com C

language-design.md §17 · 151 linhas · 12 min de leitura

@cimport("header.h") traz o C pra dentro. Roda em comptime, invoca um compilador C embutido na toolchain, traduz o header inteiro (funções, structs, tipos, macros) e devolve um módulo. Você não redeclara protótipo nenhum: aponta pro .h e usa. É a abordagem do Zig (@cImport), pelo mesmo motivo. Ela abre o ecossistema C inteiro “de graça”: SDL2, Vulkan, SQLite e libcurl passam a só funcionar, porque o compilador lê o header oficial em vez de você transcrever assinaturas (e errar, virando UB). O custo é um compilador C dentro da toolchain, peso real, pago pela ergonomia.

Duas ressalvas. A primeira: @cimport não é comptime de usuário (seção 10), é uma fase do compilador, que lê o .h e roda o C embutido. O “comptime puro, sem IO” vale pro teu código, não pra esta fase. A segunda: como o compilador C e a libc/musl são shippados e locados com a toolchain (o nosso compilador é também um compilador C), o build fica determinístico dada a toolchain fixada, sem depender do compilador-C nem dos headers do sistema. Um @cimport de header do sistema, esse sim, ata o build ao ambiente, e fica registrado como dependência (como o mk.sum locka deps, seção 15).

O C tem bitfields (unsigned x : 3;), mas a regra “largura sub-byte na fronteira C é erro” (seção 14) vale aqui também: um struct C com bitfield, importado, vira um tipo opaco. Você segura o ponteiro e o passa de volta pro C, mas não lê nem escreve os campos pela linguagem (é o que o translate-c do Zig faz, demote-to-opaque). A promessa “de graça” tem essa ressalva: structs com bitfield vêm como handle opaco, não campo a campo. (Acessores gerados estilo bindgen, com getters e setters que mascaram os bits, ficam como extensão futura se houver demanda; por ora, opaco.)

Cada @cimport cria um submódulo sob o namespace-raiz c, a raiz de todo FFI, que surge com o primeiro @cimport. O nome do submódulo é o basename do header (sem path, sem extensão), automático, sem você declarar:

@cimport("stdio.h") // → c.stdio
@cimport("SDL2/SDL.h") // → c.SDL
@cimport("SDL2/SDL.h") as gfx // → c.gfx (renomeia o submódulo; 'as' p/ colisão ou clareza)
unsafe c.stdio.printf(fmt, x) // tudo qualificado: c.stdio.printf, c.SDL.SDL_Init, c.SDL.SDL_INIT_VIDEO...

Tudo do C fica sob c.: funções, tipos, constantes, structs, sem exceção. Nada de FFI no teu escopo global (a poluição do #include). O c. em cada uso marca “isto é C”, a fronteira fica visível em todo ponto de contato, e teu namespace fica limpo (FFI não polui quem não faz FFI, o teste do opt-in da seção 1). O as é a mesma regra do use ... as (seção 14), pra colisão de basename (dois foo.h em paths diferentes) ou clareza. E como @cimport é resolvido em compilação, cai no comptime e nos módulos que já existem (seção 15): é o use condicional, aplicado a C.

Tipos na fronteira: tudo qualificado, inclusive os fundamentais

Seção intitulada “Tipos na fronteira: tudo qualificado, inclusive os fundamentais”

Os tipos do C não são tipos da tua linguagem, são membros do módulo C, como c.stdio.printf é função dele. Não existe c_int/c_char solto; tudo vem qualificado de um @cimport. Isso vale até pros tipos fundamentais (int, char, size_t), por um motivo que é a premissa do FFI com C: esses tipos dependem da plataforma. Não existe “o int do C”, existe “o int sob um dado ABI”. E o eixo que decide de fato a largura é o psABI (plataforma + arquitetura), não o compilador: num mesmo ABI gcc e clang concordam (int==32, long==64 no System V AMD64), e é o ABI que difere — long é 64 no System V mas 32 no Windows x64 (LLP64), char puro é signed no x86 mas unsigned no ARM/RISC-V, e long double é x87 80-bit no System V mas IEEE quad-128 no AArch64. Então a grafia canônica nomeia o ABI:

@cimport("systemv") // → c.systemv (também: win64, aarch64_aapcs, riscv64, wasm32)
size: c.systemv.size_t = n // o size_t sob o psABI System V AMD64: tamanho/ABI corretos
count: c.systemv.int // 32-bit; c.win64.long seria 32, c.systemv.long é 64

Os nomes de compilador ficam como aliases: c.gcc.int / c.clang.int resolvem pro ABI do target pra qual aquele compilador está configurado, então c.gcc.long é 64 compilando pra Linux e 32 compilando pra Windows x64 — o compilador é o agente que carrega o ABI do target, e nomeá-lo é atalho pra “o int como o gcc o constrói pra este target”. (Como efeito colateral, a cross-compilation vira trocar o target, e os tamanhos seguem, coisa que um c.int fixo não conseguiria expressar.) É a regra única, sem exceção: todo símbolo C, inclusive os tipos-base, vem de um @cimport e mora sob c.<fonte>., onde <fonte> é um nome de ABI (independente do target) ou um nome de compilador/runtime/header (amarrado ao target de compilação).

Ponteiros e null. O T* do C vira *c.<...>.T; e como o C tem NULL e você tem Optional (seção 6), um ponteiro C que pode ser null chega como Optional[*c.gcc.char]: o null do C é o None do Optional, casado normalmente. Você não deref um Optional sem tratá-lo, então a possibilidade de null do C vira tratamento explícito do teu lado.

Structs e layout. Tuas structs são otimizadas por padrão: o compilador reordena campos pra minimizar padding. Isso é o oposto do layout C (campos na ordem escrita), então passar uma struct pro C exige @repr(c), que desliga o reordenamento e dá layout fiel ao C:

@repr(c)
decl Point { x: c.gcc.int; y: c.gcc.int } // ordem fiel, padding C: seguro pra cruzar a fronteira

A simetria vale do outro lado: structs que vêm do C (via @cimport) já chegam com layout C de origem, são @repr(c) automaticamente, você não marca nada. Só as tuas structs, indo pro C, precisam do marcador. (Como @repr(c) desliga a otimização, o default sem marcador continua sendo o caso comum rápido; o marcador aparece só na minoria que cruza a fronteira.)

O outro marcador é @repr(packed), o layout bit-packed estilo Zig (seção 14): a struct vira um único inteiro de suporte e cada campo ocupa sua largura exata de bits, então um u3 e um u5 dividem um byte. É pra flags de bits e registros de fio/hardware, não pra fronteira C (um campo bit-packed não tem ABI C, e não tem *T, já que não fica num byte). O layout de qualquer tipo é observável em tempo de compilação via mem.size_of[T](), mem.align_of[T]() e mem.offset_of[T]("campo") (o offset segue o reordenamento). São funções genéricas comuns do pacote mem escritas em Makoto sobre compiler.reflect (não builtins do compilador): size_of/align_of dobram pra uma constante mas continuam chamáveis em runtime, enquanto offset_of é uma comptime fn (ligue o resultado a um const), e offset_of de um campo sub-byte ou packed é erro de compilação, porque tal campo não tem offset de byte.

O C não conhece teu @mm. São três situações, todas reusando a seção 5.

Memória que o C te dá chega marcada @mm(c), um MemoryManager (seção 5) cuja implementação aponta pro free (o deallocator do C), não pro teu. Isso carrega a informação que o @mm(none) perderia: memória do C não se libera com o teu free, se libera com o do C, e o @mm(c) sabe disso. Pra trazer a memória pro teu mundo (parar de depender do C), usa @promote, que já significa “copia pra outro MM”:

data: *Record @mm(c) = c.sqlite3.get_record(...) // memória do C, sob @mm(c)
mine := data @promote(gc) // repatria: copia pro meu GC, me desliga do C

@promote de @mm(c) pro teu MM é literalmente “tira do C, traz pra mim”. O @mm(c) é só mais um MemoryManager, então @transfer e @promote lidam com ele sem nada novo.

Memória tua que vai pro C: se o objeto é não-móvel (@mm(arena)/none/c), cruza direto (já é estável). Mas um objeto @mm(gc) é o caso perigoso, porque o coletor pode mover ou liberar o objeto enquanto o C segura o ponteiro, e o C lê lixo. A regra é dura e única: passar um objeto @mm(gc) pro C exige @pin, e sem @pin é erro de compilação. O @pin fixa o objeto (o GC não move nem coleta enquanto o pin vive); @unpin solta:

@pin handle // fixa: o GC não toca enquanto pinado
unsafe c.SDL.SDL_AddEventWatch(cb, handle) // o C pode segurar o ponteiro com segurança
// ... mais tarde, quando o C terminou:
@unpin handle // solta: por SUA conta

Soltar o pin é responsabilidade tua, e esquecer é uma dívida, como vazar memória @mm(none). O compilador te obriga a colocar o @pin (senão não compila), mas não te segura a mão pra soltar: é território unsafe, e você assinou embaixo ao chamar C. Objeto não-móvel dispensa o @pin.

Toda chamada a C é uma caixa-preta que pode violar qualquer invariante teu, então chamar C é uma operação unsafe (seção 5). Função C é função unsafe nossa: você abate com assert ou assume na operação, ou agrupa num bloco unsafe:

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 pra um Result: o C pode devolver ponteiro inválido, código de erro, ou bytes não-UTF-8 que viram string. A fronteira de volta checa antes de você operar livre. Um c.stdio.fopen que pode devolver NULL vira Optional, e uma conversão de bytes vira Result (seção 16). Depois de validado, você opera com as garantias normais.

Variádicos (c.stdio.printf(fmt, ...)) são transparentes: o @cimport viu o ... no protótipo, então c.stdio.printf aceita N argumentos e você só os passa, sem anotar variadicidade (veio do header). Mas o variádico do C não checa tipos (errar é UB) e a assinatura não diz os tipos esperados, então os tipos dos argumentos são responsabilidade tua. É caso-B clássico do unsafe (seção 5): o juízo “os argumentos batem com o format string” é humano e inexprimível, abatido pelo assume ou pelo bloco unsafe:

unsafe c.stdio.printf("%d itens, %s", count, name) // VOCÊ garante que os tipos batem com o format

Callbacks: @callback, @extern e o spawn pela ponte

Seção intitulada “Callbacks: @callback, @extern e o spawn pela ponte”

Há dois sentidos de “C chama de volta”, e dois marcadores:

  • @callback marca uma função tua pra ser passada como callback a uma função C (c.stdlib.qsort(...)). Ela ganha ABI C pro C poder chamá-la.
  • @extern expõe uma função tua com ABI C pra ser uma lib que o C consome (o sentido “ser chamado pelo C”).
@callback fn compare(a: *c.gcc.void, b: *c.gcc.void) -> c.gcc.int { ... }
unsafe c.stdlib.qsort(arr, n, sz, compare)
@extern fn my_library_init() -> c.gcc.int { ... } // C externo pode linkar e chamar isto

O caso espinhoso fica dentro de um callback. O C chama teu callback de uma thread crua do C, sem teu scheduler, teu isolamento nem teu runtime de processos (seção 8). Se o callback faz spawn, ele não pode criar um processo ali. A solução é o runtime como middle-man: o spawn no callback não cria a thread localmente, vira uma solicitação ao teu runtime (“cria o processo worker(x) no mundo certo e me devolve o channel”), e o runtime, do lado de lá, cria o processo no lugar certo (teu scheduler, teu isolamento) e devolve o canal.

O mesmo princípio fecha o contrato do @callback: o corpo não suspende e não aloca. Alocação de heap local e io.* lá dentro são erro de compilação, porque não há @mm nem processo na thread crua do C pra sustentá-los (o @mm vem do processo, seção 5; a suspensão de io.* precisa de um processo pra suspender, seção 12). O que é permitido é falar com o runtime e os processos: mandar num channel, e o spawn-pela-ponte acima. Mesmo que a mensagem aloque, quem aloca é o runtime do outro lado, não o callback. A linha é essa: comunicação com runtime e processos é ponte permitida; alocação ou IO de heap local é proibido. Um @callback é uma ponte fina; qualquer coisa “real” (alocar, fazer IO, criar processo) atravessa pelo middle-man.

A sintaxe é idêntica dentro e fora: você escreve spawn worker(x) igual, e na fronteira o spawn é só roteado pelo runtime. E como essa rota pode falhar (runtime indisponível, recurso esgotado), ela usa a forma que a linguagem já tem, o catch do spawn (seção 3):

@callback fn on_event(ev: *c.SDL.SDL_Event) -> c.gcc.int {
spawn handle_event(ev) catch |e| { return c.SDL.SDL_FALSE } // spawn pela ponte; pode falhar
return c.SDL.SDL_TRUE
}

Um spawn que atravessa a fronteira é só um spawn com mais um jeito de falhar, e o catch cobre, venha a falha de onde vier. A thread crua do C nunca opera teu scheduler; ela solicita, e o runtime executa. (É o modelo da BEAM pra NIFs e do Go pra cgo: código estrangeiro não vira processo nem goroutine, ele se comunica com um, e isso já é o teu modelo de channels entre mundos isolados, seção 4.)

O compilador compila os .c junto (estilo Zig, e é o que faz sqlite.c “só funcionar”: o .c entra no build, não é um binário pré-compilado que você reza pra existir). Como ele descobre o que linkar segue a reprodutibilidade que governou strings e módulos: declaração explícita, não auto-discover.

Auto-discover (o compilador varre os @cimport e adivinha o -lSDL2 do ambiente) é mágico quando funciona e quebra reprodutibilidade quando não. Roda na tua máquina, falha na CI com outra versão da lib, e não tem onde registrar qual versão. É o problema que o mk.sum resolveu. Então deps C são declaradas em cdeps no mk.project (seção 15), como as deps Git: nome, versão, checksum:

mk.project
cdeps {
SDL2 2.30.1 sha256:...
sqlite3 3.45.0 sha256:...
}

O @cimport("SDL2/SDL.h") diz qual header; o cdeps diz qual SDL2, de onde, com que hash, a mesma separação “código aponta, manifesto governa versão e prova” do resto. O build vira reproduzível. A exceção são as system libs onipresentes (libc e cia): sempre presentes, não versionadas pelo teu projeto, dispensam declaração. O resto (SDL2, SQLite, Vulkan) é declarado.

A FFI deixa um programa Makoto chamar C; o interop de build deixa um módulo Makoto entrar num codebase C/C++ sem que esse codebase reescreva o build dele. Esse é o caminho de adoção: um time com uma árvore CMake ou Ninja existente quer um módulo em Makoto, incrementalmente, não um rewrite. Vale nos dois sentidos, e nenhum deles inventa um segundo build system — cada lado mantém o seu e chama o outro numa fronteira fina.

Um build Makoto chamando cmake/ninja é só o task runner (seção 22). Uma task declara o subprocesso que roda, então buildar uma dependência C que traz o próprio CMake, ou invocar o ninja de um projeto, é um [tasks.<nome>] com run = cmake --build build e cmake (ou ninja) na lista subprocess. O gate de subprocesso-declarado já governa isso; não há nada novo pra aprender, e a regra hermético-por-convenção (seção 22) continua valendo — uma ferramenta não declarada é recusada.

Um build cmake/ninja chamando Makoto é o mk export. Como o mk build --emit=lib já produz uma biblioteca estática C-callable mais um header gerado (seção 17, a fronteira de mão dupla), o export é cola fina que shella pra ele em tempo de build:

  • mk export cmake escreve um MakotoConfig.cmake — um módulo reusável que um projeto C/C++ dá include() (ou find_package(Makoto)), ganhando makoto_add_library(<target> <entry.mko>) e makoto_add_executable(...). A biblioteca é buildada pelo mk durante o build cmake e linka como qualquer outro alvo IMPORTED: target_link_libraries(oapp PRIVATE mymod). Aceita os próprios botões do build (RELEASE, TARGET <arch>, LIBC <nome>).
  • mk export ninja escreve um arquivo ninja com uma regra makoto_lib e uma aresta build pro entry do projeto lib<nome>.a + header, rodável sozinho (ninja -f makoto.ninja) ou subninja’do numa árvore maior.

O lado C compila com mk cc (o drop-in da seção 17, então linka contra o mesmo toolchain hermético com que a biblioteca foi buildada — aponte CMAKE_C_COMPILER pra ele), ou, pra um projeto que linka com o toolchain do host, a biblioteca é buildada LIBC system pra casar. De todo jeito a disciplina é a do design inteiro: Makoto dona do build Makoto (um compilador, reproduzível), o build estrangeiro dono do dele, e os dois se encontram numa fronteira declarada e versionável — o mesmo mk.project/mk.mod que governa um build puro-Makoto, agora alcançável de fora.