O que é FFI e Por que Zig é Excelente para Interoperabilidade?
Foreign Function Interface (FFI) é o mecanismo que permite que uma linguagem de programação chame código escrito em outra linguagem. No mundo do desenvolvimento de sistemas, onde bibliotecas legadas em C e C++ dominam, uma boa FFI é essencial.
Zig se destaca particularmente nesta área porque:
- Compila C/C++ nativamente: O compilador Zig pode compilar código C/C++ junto com Zig
- Suporte built-in a C: Zig entende diretamente headers C e pode importá-los automaticamente
- ABI compatível: Zig segue as mesmas convenções de chamada que C
- Sem runtime pesado: Diferente de outras linguagens, Zig não adiciona overhead ao interagir com C
Neste guia, você aprenderá a integrar Zig com C e C++ de forma prática, desde exemplos simples até casos reais de produção.
Conceitos Fundamentais de FFI
Antes de mergulharmos no código, é importante entender alguns conceitos:
ABI (Application Binary Interface)
ABI define como funções são chamadas em nível de máquina: onde parâmetros são passados (registradores vs pilha), como valores são retornados, e como a pilha é gerenciada.
Zig usa a ABI C por padrão ao interagir com código externo, garantindo compatibilidade total.
extern e export
extern: Indica que uma função ou variável vem de fora (C ou outra linguagem)export: Torna uma função Zig visível para código externo
Mangling de Nomes
C++ “mangle” (embaralha) nomes de funções para suportar sobrecarga. Para interoperar com C++ de forma compatível com C, usamos extern "C".
Chamando C a partir de Zig
Vamos começar com o caso mais comum: usar uma biblioteca C existente a partir do Zig.
Exemplo 1: Usando a Biblioteca Padrão C
const std = @import("std");
// Declara funções da libc
extern "c" fn printf(format: [*c]const u8, ...) c_int;
extern "c" fn strlen(s: [*c]const u8) usize;
extern "c" fn malloc(size: usize) ?*anyopaque;
extern "c" fn free(ptr: ?*anyopaque) void;
pub fn main() void {
// Usando printf da libc
_ = printf("Olá de C!\n");
// Usando strlen
const msg = "Zig é incrível";
const len = strlen(msg);
std.debug.print("Tamanho: {d}\n", .{len});
}
Pontos importantes:
[*c]const u8é o tipo de ponteiro C para string (equivalente aconst char*)- O modificador
"c"na declaraçãoexternespecifica a convenção de chamada C - Tipos C são convertidos para equivalentes Zig
Exemplo 2: Criando um Wrapper Idiomático
A forma direta acima funciona, mas não é idiomática. Vamos criar um wrapper mais “Zig-like”:
const std = @import("std");
// Declarações externas (low-level)
extern "c" fn fopen(filename: [*c]const u8, mode: [*c]const u8) ?*FILE;
extern "c" fn fclose(file: ?*FILE) c_int;
extern "c" fn fprintf(file: ?*FILE, format: [*c]const u8, ...) c_int;
const FILE = opaque {};
// Wrapper idiomático em Zig
pub const FileMode = enum {
read,
write,
append,
};
pub const CFile = struct {
handle: ?*FILE,
pub fn open(path: []const u8, mode: FileMode) !CFile {
const mode_str = switch (mode) {
.read => "r",
.write => "w",
.append => "a",
};
// Converte []const u8 para [*c]const u8 (null-terminated)
const c_path = try std.heap.c_allocator.dupeZ(u8, path);
defer std.heap.c_allocator.free(c_path);
const handle = fopen(c_path.ptr, mode_str);
if (handle == null) return error.FileNotFound;
return CFile{ .handle = handle };
}
pub fn close(self: *CFile) void {
if (self.handle) |h| {
_ = fclose(h);
self.handle = null;
}
}
pub fn write(self: CFile, msg: []const u8) void {
if (self.handle) |h| {
const c_msg = std.heap.c_allocator.dupeZ(u8, msg) catch return;
defer std.heap.c_allocator.free(c_msg);
_ = fprintf(h, "%s", c_msg.ptr);
}
}
};
pub fn main() !void {
var file = try CFile.open("teste.txt", .write);
defer file.close();
file.write("Escrevendo via wrapper Zig!\n");
}
Vantagens desta abstração:
- Gerenciamento de memória automático (
defer) - Tipos seguros (
FileModeenum ao invés de strings) - Tratamento de erros Zig (
!CFile) - Fechamento automático com
defer
Compilando C junto com Zig
Uma das características únicas do Zig é que ele inclui um compilador C/C++. Isso significa que você pode compilar código C sem precisar do GCC ou Clang instalados.
Exemplo: Biblioteca C em um Projeto Zig
Estrutura do projeto:
meu-projeto/
├── build.zig
├── src/
│ └── main.zig
└── vendor/
└── minha-lib/
├── lib.c
└── lib.h
vendor/minha-lib/lib.h:
#ifndef MINHA_LIB_H
#define MINHA_LIB_H
typedef struct {
int x;
int y;
} Ponto;
Ponto criar_ponto(int x, int y);
int distancia(Ponto a, Ponto b);
#endif
vendor/minha-lib/lib.c:
#include "lib.h"
#include <math.h>
Ponto criar_ponto(int x, int y) {
Ponto p = {x, y};
return p;
}
int distancia(Ponto a, Ponto b) {
double dx = (double)(a.x - b.x);
double dy = (double)(a.y - b.y);
return (int)sqrt(dx * dx + dy * dy);
}
build.zig:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Compila a biblioteca C
const lib = b.addStaticLibrary(.{
.name = "minha-lib",
.target = target,
.optimize = optimize,
});
lib.addCSourceFiles(.{
.files = &.{
"vendor/minha-lib/lib.c",
},
.flags = &.{
"-std=c99",
"-Wall",
"-Wextra",
},
});
lib.linkLibC();
b.installArtifact(lib);
// Executável principal
const exe = b.addExecutable(.{
.name = "meu-app",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.linkLibrary(lib);
exe.linkLibC();
// Adiciona header path para @cImport
exe.addIncludePath(b.path("vendor/minha-lib"));
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Roda o app");
run_step.dependOn(&run_cmd.step);
}
src/main.zig:
const std = @import("std");
// Importa o header C automaticamente!
const c = @cImport({
@cInclude("lib.h");
});
pub fn main() void {
const p1 = c.criar_ponto(0, 0);
const p2 = c.criar_ponto(3, 4);
const dist = c.distancia(p1, p2);
std.debug.print("Distância entre ({d},{d}) e ({d},{d}): {d}\n", .{
p1.x, p1.y, p2.x, p2.y, dist
});
}
Para compilar e rodar:
zig build run
Saída:
Distância entre (0,0) e (3,4): 5
A Magia de @cImport
A função @cImport é extraordinária: ela analisa headers C em tempo de compilação e gera declarações Zig equivalentes. Isso significa:
- ✅ Sem bindings manuais
- ✅ Sem wrappers complexos
- ✅ Sempre sincronizado com o header
- ✅ Type-safe
Limitação: @cImport requer que o header C seja parseável. Headers muito complexos ou com macros complicadas podem precisar de ajustes.
Exportando Funções Zig para C
Agora vamos inverter: escrever código Zig que será chamado a partir de C.
Exemplo: Biblioteca Zig Usável por C
src/lib.zig:
const std = @import("std");
// Exporta função para C
export fn zig_soma(a: c_int, b: c_int) c_int {
return a + b;
}
// Exporta com nome diferente
export fn zig_fatorial(n: c_int) c_int {
if (n <= 1) return 1;
return n * zig_fatorial(n - 1);
}
// Exporta estrutura
pub const PontoZig = extern struct {
x: f64,
y: f64,
};
export fn zig_distancia(a: PontoZig, b: PontoZig) f64 {
const dx = a.x - b.x;
const dy = a.y - b.y;
return std.math.sqrt(dx * dx + dy * dy);
}
// Função que recebe callback C
export fn zig_processa_array(
arr: [*]const c_int,
len: usize,
callback: ?*const fn (c_int) callconv(.C) void
) void {
if (callback == null) return;
var i: usize = 0;
while (i < len) : (i += 1) {
callback.?(arr[i]);
}
}
build.zig (para biblioteca):
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Compila como biblioteca dinâmica
const lib = b.addSharedLibrary(.{
.name = "ziglib",
.root_source_file = b.path("src/lib.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(lib);
}
Para gerar o header C automaticamente:
Zig pode gerar um header C compatível com suas funções exportadas:
// Adicione ao build.zig:
const generate_c_header = b.addSystemCommand(&.{
"zig", "translate-c",
"--target", target.zigTriple(b.allocator) catch unreachable,
"src/lib.zig"
});
Ou você pode escrever manualmente:
ziglib.h:
#ifndef ZIGLIB_H
#define ZIGLIB_H
#ifdef __cplusplus
extern "C" {
#endif
int zig_soma(int a, int b);
int zig_fatorial(int n);
typedef struct {
double x;
double y;
} PontoZig;
double zig_distancia(PontoZig a, PontoZig b);
typedef void (*CallbackInt)(int);
void zig_processa_array(const int *arr, size_t len, CallbackInt callback);
#ifdef __cplusplus
}
#endif
#endif
main.c (usando a biblioteca Zig):
#include <stdio.h>
#include "ziglib.h"
void imprimir(int n) {
printf("Valor: %d\n", n);
}
int main() {
printf("3 + 4 = %d\n", zig_soma(3, 4));
printf("5! = %d\n", zig_fatorial(5));
PontoZig p1 = {0.0, 0.0};
PontoZig p2 = {3.0, 4.0};
printf("Distância: %.2f\n", zig_distancia(p1, p2));
int nums[] = {10, 20, 30};
zig_processa_array(nums, 3, imprimir);
return 0;
}
Compilando:
# Compila a biblioteca Zig
zig build
# Compila o programa C que usa a biblioteca
zig cc main.c -L./zig-out/lib -lziglib -o meu_app
# Roda
./meu_app
Integração com C++
A integração com C++ é mais complexa devido ao name mangling e features como classes, templates e exceções. A estratégia recomendada é usar C como intermediário.
Padrão de Interoperabilidade C++
minha-classe.hpp (C++):
#ifndef MINHA_CLASSE_HPP
#define MINHA_CLASSE_HPP
class MinhaClasse {
public:
MinhaClasse(int valor);
~MinhaClasse();
int getValor() const;
void setValor(int v);
int dobrar() const;
private:
int valor;
};
#endif
minha-classe.cpp (C++):
#include "minha-classe.hpp"
MinhaClasse::MinhaClasse(int v) : valor(v) {}
MinhaClasse::~MinhaClasse() = default;
int MinhaClasse::getValor() const { return valor; }
void MinhaClasse::setValor(int v) { valor = v; }
int MinhaClasse::dobrar() const { return valor * 2; }
wrapper-c.cpp (C wrapper):
#include "minha-classe.hpp"
extern "C" {
// Opaque pointer para esconder C++
typedef struct MinhaClasseHandle MinhaClasseHandle;
MinhaClasseHandle* minha_classe_criar(int valor) {
return reinterpret_cast<MinhaClasseHandle*>(new MinhaClasse(valor));
}
void minha_classe_destruir(MinhaClasseHandle* handle) {
delete reinterpret_cast<MinhaClasse*>(handle);
}
int minha_classe_get_valor(MinhaClasseHandle* handle) {
return reinterpret_cast<MinhaClasse*>(handle)->getValor();
}
void minha_classe_set_valor(MinhaClasseHandle* handle, int valor) {
reinterpret_cast<MinhaClasse*>(handle)->setValor(valor);
}
int minha_classe_dobrar(MinhaClasseHandle* handle) {
return reinterpret_cast<MinhaClasse*>(handle)->dobrar();
}
} // extern "C"
Usando em Zig:
const std = @import("std");
// Declarações C das funções wrapper
extern "c" fn minha_classe_criar(valor: c_int) ?*anyopaque;
extern "c" fn minha_classe_destruir(handle: ?*anyopaque) void;
extern "c" fn minha_classe_get_valor(handle: ?*anyopaque) c_int;
extern "c" fn minha_classe_set_valor(handle: ?*anyopaque, valor: c_int) void;
extern "c" fn minha_classe_dobrar(handle: ?*anyopaque) c_int;
// Wrapper idiomático Zig
pub const MinhaClasse = struct {
handle: ?*anyopaque,
pub fn init(valor: i32) MinhaClasse {
return .{
.handle = minha_classe_criar(valor),
};
}
pub fn deinit(self: *MinhaClasse) void {
if (self.handle) |h| {
minha_classe_destruir(h);
self.handle = null;
}
}
pub fn getValor(self: MinhaClasse) i32 {
if (self.handle) |h| {
return minha_classe_get_valor(h);
}
return 0;
}
pub fn setValor(self: MinhaClasse, valor: i32) void {
if (self.handle) |h| {
minha_classe_set_valor(h, valor);
}
}
pub fn dobrar(self: MinhaClasse) i32 {
if (self.handle) |h| {
return minha_classe_dobrar(h);
}
return 0;
}
};
pub fn main() void {
var obj = MinhaClasse.init(21);
defer obj.deinit();
std.debug.print("Valor: {d}\n", .{obj.getValor()});
std.debug.print("Dobro: {d}\n", .{obj.dobrar()});
obj.setValor(50);
std.debug.print("Novo valor: {d}\n", .{obj.getValor()});
}
build.zig para C++:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Compila o wrapper C++
const cxx_lib = b.addStaticLibrary(.{
.name = "cxx-wrapper",
.target = target,
.optimize = optimize,
});
cxx_lib.addCSourceFiles(.{
.files = &.{
"src/minha-classe.cpp",
"src/wrapper-c.cpp",
},
.flags = &.{
"-std=c++17",
"-Wall",
},
});
cxx_lib.linkLibCpp();
b.installArtifact(cxx_lib);
// Executável Zig
const exe = b.addExecutable(.{
.name = "app",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.linkLibrary(cxx_lib);
exe.linkLibCpp();
b.installArtifact(exe);
}
Por que este padrão funciona:
- Opaque pointer: Esconde detalhes de C++ do Zig
- C wrapper: Evita name mangling e incompatibilidades ABI
- RAII em Zig: Usamos
deferpara destruição automática - Type safety: O wrapper Zig fornece tipos seguros sobre C
Gerenciamento de Memória Através de Fronteiras
Um dos maiores desafios em FFI é o gerenciamento de memória. Regras essenciais:
Regras de Ouro
- Quem aloca, libera: Se C alocou memória, C deve liberar
- Não misture allocators: Use
std.heap.c_allocatorpara memória que será liberada por C - Cuidado com lifetimes: Ponteiros C não têm lifetime tracking
Exemplo Seguro: Passando Strings
const std = @import("std");
extern "c" fn processa_string(s: [*c]const u8) void;
// ❌ ERRADO: ponteiro pode ficar inválido
pub fn enviarMensagemErrada(msg: []const u8) void {
processa_string(msg.ptr); // msg pode não ser null-terminated!
}
// ✅ CORRETO: aloca memória C-compatível
pub fn enviarMensagemSegura(msg: []const u8) !void {
// dupeZ aloca e adiciona null terminator
const c_msg = try std.heap.c_allocator.dupeZ(u8, msg);
defer std.heap.c_allocator.free(c_msg); // Zig libera
processa_string(c_msg.ptr);
}
// ✅ CORRETO: C libera memória que C alocou
extern "c" fn cria_string() ?[*c]u8;
extern "c" fn libera_string(s: ?[*c]u8) void;
pub fn usarStringC() !void {
const c_str = cria_string() orelse return error.OutOfMemory;
defer libera_string(c_str); // C libera
// Converte para slice Zig
const len = std.mem.len(c_str);
const slice = c_str[0..len];
std.debug.print("String de C: {s}\n", .{slice});
}
Usando std.heap.c_allocator
Este allocator usa malloc/free da libc, garantindo compatibilidade:
const std = @import("std");
extern "c" fn recebe_buffer(buf: [*c]u8, len: usize) void;
pub fn exemploAlocacao() !void {
// Usa o allocator compatível com C
const allocator = std.heap.c_allocator;
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);
// Passa para C
recebe_buffer(buffer.ptr, buffer.len);
// C pode manter referência temporária, mas não deve guardar
// o ponteiro após a função retornar
}
Caso Real: Integrando com SQLite
Vamos ver um exemplo prático e útil: usando SQLite a partir do Zig.
Setup do Projeto
build.zig:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "sqlite-demo",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Link com SQLite
exe.linkSystemLibrary("sqlite3");
exe.linkLibC();
b.installArtifact(exe);
}
Código Zig:
const std = @import("std");
// Importa header SQLite
const c = @cImport({
@cInclude("sqlite3.h");
});
pub const SQLiteError = error{
OpenError,
ExecError,
PrepareError,
StepError,
};
pub const Database = struct {
db: ?*c.sqlite3,
pub fn open(path: []const u8) !Database {
const c_path = try std.heap.c_allocator.dupeZ(u8, path);
defer std.heap.c_allocator.free(c_path);
var db: ?*c.sqlite3 = null;
const rc = c.sqlite3_open(c_path.ptr, &db);
if (rc != c.SQLITE_OK or db == null) {
return error.OpenError;
}
return Database{ .db = db };
}
pub fn close(self: *Database) void {
if (self.db) |db| {
_ = c.sqlite3_close(db);
self.db = null;
}
}
pub fn execute(self: Database, sql: []const u8) !void {
const c_sql = try std.heap.c_allocator.dupeZ(u8, sql);
defer std.heap.c_allocator.free(c_sql);
const rc = c.sqlite3_exec(self.db, c_sql.ptr, null, null, null);
if (rc != c.SQLITE_OK) {
return error.ExecError;
}
}
pub fn query(self: Database, sql: []const u8) !ResultSet {
const c_sql = try std.heap.c_allocator.dupeZ(u8, sql);
defer std.heap.c_allocator.free(c_sql);
var stmt: ?*c.sqlite3_stmt = null;
const rc = c.sqlite3_prepare_v2(
self.db,
c_sql.ptr,
-1, // lê até null terminator
&stmt,
null
);
if (rc != c.SQLITE_OK or stmt == null) {
return error.PrepareError;
}
return ResultSet{ .stmt = stmt };
}
};
pub const ResultSet = struct {
stmt: ?*c.sqlite3_stmt,
pub fn deinit(self: *ResultSet) void {
if (self.stmt) |stmt| {
_ = c.sqlite3_finalize(stmt);
self.stmt = null;
}
}
pub fn next(self: ResultSet) !bool {
if (self.stmt) |stmt| {
const rc = c.sqlite3_step(stmt);
if (rc == c.SQLITE_ROW) return true;
if (rc == c.SQLITE_DONE) return false;
return error.StepError;
}
return false;
}
pub fn getInt(self: ResultSet, col: c_int) i32 {
if (self.stmt) |stmt| {
return c.sqlite3_column_int(stmt, col);
}
return 0;
}
pub fn getText(self: ResultSet, col: c_int) []const u8 {
if (self.stmt) |stmt| {
const ptr = c.sqlite3_column_text(stmt, col);
const len = c.sqlite3_column_bytes(stmt, col);
if (ptr == null or len == 0) return "";
return ptr[0..@intCast(len)];
}
return "";
}
};
pub fn main() !void {
// Cria/abre banco
var db = try Database.open("test.db");
defer db.close();
// Cria tabela
try db.execute(
\\CREATE TABLE IF NOT EXISTS users (
\\ id INTEGER PRIMARY KEY,
\\ name TEXT NOT NULL,
\\ age INTEGER
\\)
);
// Insere dados
try db.execute(
\\INSERT OR IGNORE INTO users (id, name, age)
\\VALUES (1, 'João', 30), (2, 'Maria', 25)
);
// Query
var result = try db.query("SELECT id, name, age FROM users");
defer result.deinit();
std.debug.print("Usuários:\n");
while (try result.next()) {
const id = result.getInt(0);
const name = result.getText(1);
const age = result.getInt(2);
std.debug.print(" {d}: {s} ({d} anos)\n", .{id, name, age});
}
}
Execução:
# Precisa do SQLite instalado
# Ubuntu/Debian: sudo apt-get install libsqlite3-dev
# macOS: brew install sqlite3
zig build run
Saída:
Usuários:
1: João (30 anos)
2: Maria (25 anos)
Dicas de Performance
1. Minimize Chamadas de Fronteira
Cada chamada entre Zig e C tem overhead. Agrupe operações:
// ❌ Ineficiente: múltiplas chamadas
extern "c" fn adiciona_item(item: Item) void;
extern "c" fn processa_lote() void;
for (items) |item| {
adiciona_item(item); // N chamadas
}
processa_lote();
// ✅ Eficiente: uma chamada com array
extern "c" fn processa_items(items: [*]const Item, count: usize) void;
processa_items(items.ptr, items.len); // 1 chamada
2. Use callconv(.C) Explicitamente
Para funções de callback passadas para C:
// Sempre use callconv(.C) para callbacks
const Callback = *const fn (data: *anyopaque) callconv(.C) void;
extern "c" fn registra_callback(cb: Callback) void;
3. Alinhamento e Padding
Estruturas compartilhadas devem ter alinhamento explícito:
pub const SharedStruct = extern struct {
// Use align para garantir compatibilidade
campo1: u32 align(4),
campo2: u64 align(8),
// padding automático para alinhamento C
};
4. Evite Alocações Frequentes
// ❌ Aloca em cada iteração
for (strings) |s| {
const c_str = try std.heap.c_allocator.dupeZ(u8, s);
defer std.heap.c_allocator.free(c_str);
processa(c_str);
}
// ✅ Reusa buffer
var buffer: [1024]u8 = undefined;
for (strings) |s| {
if (s.len >= buffer.len) continue;
@memcpy(buffer[0..s.len], s);
buffer[s.len] = 0; // null terminator
processa(&buffer);
}
Depuração de Problemas FFI
Problemas Comuns e Soluções
| Problema | Causa Provável | Solução |
|---|---|---|
| Segmentation fault | Ponteiro nulo ou inválido | Verifique null antes de usar; use orelse |
| Valores corrompidos | Incompatibilidade de tipos | Verifique sizes e alignments |
| Memory leak | Esqueceu defer ou free | Use defer sempre; track allocations |
| Link errors | Biblioteca não linkada | Adicione exe.linkSystemLibrary("nome") |
| Compile errors com @cImport | Header não encontrado | Adicione exe.addIncludePath(...) |
Ferramentas Úteis
# Verificar símbolos em bibliotecas
nm -D libminha.so
objdump -t libminha.a
# Verificar dependências
ldd meu_executavel
# Debug com GDB
gdb ./meu_app
(gdb) break minha_funcao
(gdb) run
(gdb) bt # backtrace
Resumo e Próximos Passos
Neste guia, você aprendeu:
- ✅ Fundamentos de FFI: ABI, extern, export, e convenções de chamada
- ✅ Chamar C de Zig: Usar
@cImport, declarar funções externas, criar wrappers - ✅ Exportar Zig para C: Compartilhar funções e estruturas com código C
- ✅ Integração C++: Padrão de wrapper C para interoperar com classes C++
- ✅ Gerenciamento de memória: Regras essenciais para segurança
- ✅ Exemplo real: Usando SQLite do Zig
Próximos Passos
Continue sua jornada Zig explorando estes tópicos relacionados:
- Zig Build System — Aprofunde-se em
build.zigpara projetos complexos - Gerenciamento de Memória em Zig — Entenda allocators em profundidade
- Comptime em Zig — Metaprogramação poderosa
- Zig para Programadores C — Migração completa de C para Zig
Recursos Adicionais
- Documentação Oficial Zig - FFI
- Zigtranslate-c — Como funciona @cImport
- Exemplos do Zig — Mais código de exemplo
Tem dúvidas sobre FFI em Zig? Entre na discussão nos comentários ou compartilhe seu projeto!