Pular para o conteúdo

Especificação §23

ABI

language-design.md §23 · 63 linhas · 6 min de leitura

A FFI de C (seção 17) resolve o lado consumo: você chama C. A ABI é o lado oposto, como outros falam com a tua linguagem em binário. Ela cobre duas coisas: (a) teus próprios módulos compilados se linkarem entre si, e (b) outras linguagens (C, e por extensão tudo que fala C) consumirem a tua como biblioteca pré-compilada. É o contrato de baixo nível: layout de tipos, nomes de símbolos, como chamadas acontecem.

A decisão que atravessa tudo é que a ABI é uma fronteira de dados, não de runtime. O que cruza são tipos concretos com layout definido; os conceitos do teu runtime (processos, channels, @mm) não atravessam crus, o runtime os opera por baixo. Isso espelha a FFI de C, e mantém a fronteira pequena e estável.

Layout: não-estável por padrão, @repr pra congelar

Seção intitulada “Layout: não-estável por padrão, @repr pra congelar”

O compilador otimiza o layout dos structs (reordena campos pra minimizar padding, seção 2), e por padrão esse layout é não-estável, o que tem um significado preciso: a ordenação é determinística (mesmo código, mesmo compilador, mesmas flags dão sempre o mesmo layout), mas não é uma promessa que cruza fronteiras que você não controla. Três coisas podem mudá-la: a versão do compilador (a v1.1 pode fazer packing melhor que a v1.0), as flags de otimização, ou uma mudança num tipo embarcado (um campo a mais lá no fundo muda o padding de tudo que o contém).

A consequência prática é tranquila: dentro de um único build, tudo linka (teus módulos foram compilados juntos, com a mesma regra). O que o não-estável proíbe é pegar uma lib compilada separadamente (outra versão, outro momento, ou pra ser consumida por C) e esperar que os layouts batam. Pra esse caso, e só pra ele, você usa @repr(c) (seção 17), que congela o layout campo a campo naquele tipo.

E isso é opt-in por tipo, de propósito: não há flag global --abi=stable. Um flag global congelaria o layout do programa inteiro pra ganhar estabilidade que você só precisa em alguns tipos (os que cruzam a fronteira), matando a otimização de reordenação em tudo. O @repr(c) é o inverso: você paga estabilidade só onde precisa (o struct que vai pra fora), e o resto do programa segue otimizado. Estável é a exceção marcada, não o default caro.

A interface do runtime: a única fronteira que é congelada

Seção intitulada “A interface do runtime: a única fronteira que é congelada”

A regra acima — instável por dentro, estabilidade como exceção marcada — tem uma contraparte, e é a interface de host contra a qual o runtime é escrito (seção 5). Ali o default se inverte: é ABI externa, e é congelada.

O motivo é pra que a fronteira serve. O layout de um struct é interno até você marcar o contrário, porque quase nenhum struct cruza. A interface de host é o oposto: ela existe só pra ser cruzada, e por código que não escrevemos e não podemos recompilar — um kernel, um alvo embarcado, o simulador de alguém. Uma coisa cujo propósito inteiro é ser implementada por outras pessoas não pode também ser livre pra mudar.

Então write(stream, buf, len) não ganha um parâmetro, e map(bytes) não passa a devolver um status. E a disciplina não é cortesia com quem implementa um host: o compilador emite chamadas contra essas mesmas assinaturas, então mudar uma quebraria a compatibilidade da Makoto com os programas dela própria. A restrição já existia; nomear a fronteira só a torna visível.

Deliberadamente não há campo de versão nem negociação de capacidade. Esses são mecanismos pra uma interface que muda, e pagar a cerimônia deles em toda implementação pra se segurar contra um evento que estamos nos comprometendo a não causar seria o mesmo erro do --abi=stable global: um custo em todo mundo por uma garantia de que ninguém precisa. E se o core algum dia tiver que quebrar terreno aqui, a resposta é a que a linguagem já dá em todo o resto (seção 22): o core anda devagar, e quando anda de forma incompatível, vem com o migrador.

A distinção vale ser dita com clareza, porque as duas vivem a uma linha de distância no mesmo programa:

  • ABI interna — seus structs, suas chamadas entre módulos de um mesmo build. Otimizada, reordenada, não prometida entre versões do compilador. Congelar é opt-in por tipo, com @repr(c).
  • ABI externa — a interface de host, e o que @extern/@repr(c) expõem pro C. Congelada, porque outra pessoa compila contra ela.

Funções viram símbolos no binário, e símbolos não podem colidir (dois foo em módulos diferentes, ou List[int] vs List[string], precisam de nomes distintos). Isso exige mangling: codificar módulo e tipos no nome. Mas o mangling só importa em um lugar:

  • Símbolos internos (teus módulos chamando teus módulos): mangled, e ninguém os digita, porque o compilador gera e resolve, máquina falando com máquina. O nome pode ser o que for.
  • Símbolos na fronteira (@extern, seção 17): nome cru, sem mangling, porque o C precisa escrever extern void minha_api(); e achar o símbolo. O @extern desliga o mangling na fronteira por definição. E o @extern não aceita parâmetro genérico: um símbolo C é concreto e pré-compilado, e o C não instancia genérico, então uma @extern fn f[T] é rejeitada (genéricos atravessam como código a monomorfizar, não como símbolo binário).

Como o símbolo interno nunca é digitado à mão, o estilo do mangling é uma opção de build de otimização, no mesmo balde que tamanho, velocidade e debug, com o default em otimizado:

  • optimized (default): mangling compacto, símbolos menores, binário menor.
  • readable: mangling legível (algo como _auto_core_int_fmt em vez de _ZN4core3fmt...), que torna stack traces, profiling e debugging legíveis (você lê o símbolo e entende, sem decodificar). Combina com o profiler e o observer (seções 19 e 22).

Nenhum dos dois afeta a fronteira; lá o @extern é sempre cru. É uma escolha de qualidade de vida das ferramentas, não de arquitetura.

Generics: monomorfização em comptime, não cruzam pré-compilados

Seção intitulada “Generics: monomorfização em comptime, não cruzam pré-compilados”

Genéricos não cruzam a fronteira de ABI como binário pré-compilado, e a razão é a natureza da monomorfização. Um List[T] só vira código quando você escreve List[int]: o compilador monomorfiza em comptime (seção 10), gerando o código específico daquele tipo, no teu build. Não dá pra pré-compilar List[T] numa lib, porque o T não existe até o uso.

Isso não é limitação, é o que mantém os layouts consistentes: o List[int] da tua lib e o List[int] do programa que a consome são o mesmo layout, porque ambos são monomorfizados no mesmo build, pela mesma regra determinística. O genérico viaja como o que ele é (uma forma a instanciar), e é instanciado onde é usado, dos dois lados igual. Então só tipos concretos (@repr quando cruzam a fronteira) atravessam como binário; genéricos atravessam como código a monomorfizar.

Quando uma função chama outra, a calling convention decide onde os argumentos ficam (registradores ou stack), onde o retorno fica, e quem salva registradores. Por enquanto, a linguagem herda a convenção da plataforma via LLVM (System V e afins), a mesma que o C usa. A vantagem é direta: interop com C é grátis (teus símbolos já falam a língua do C), e não há duas convenções pra o compilador manter e converter. O @extern (seção 17) já é a fronteira, e ela coincide com a convenção interna, então não há conversão.

Uma convenção própria interna (mais argumentos em registrador, menos salvamentos, otimizada pro teu código chamando o teu) traria ganho, mas incremental, ao custo de complexidade real (manter duas convenções, converter na fronteira). Então fica pra depois, junto do backend próprio (ver Notas de implementação), a mesma lógica do “LLVM agora, próprio depois” do resto.

Conceitos de runtime na fronteira: o runtime é o middle-man

Seção intitulada “Conceitos de runtime na fronteira: o runtime é o middle-man”

O que acontece quando algo externo precisa interagir com um processo ou channel teu (seções 3 e 4)? Eles não atravessam crus, são conceitos do teu runtime, e C não sabe o que é um processo. Em vez disso, o runtime é o middle-man: a ABI expõe o mínimo pra mandar e receber dados, e o teu runtime, do teu lado, cria o processo, atrela o channel e faz a transmissão. O externo nunca “segura um channel”, ele troca bytes com o teu runtime, que opera o channel por baixo.

Isso é idêntico ao que a FFI de C já faz (o callback que dá spawn é roteado pelo runtime, seção 17), e ao princípio do @mm(c) (quando você recebe algo do C, vem o free junto pra gerenciar a memória daquele objeto). A simetria fecha a fronteira: dados atravessam; conceitos de runtime não, porque o runtime os opera por baixo, dos dois lados.