Generate Go bindings for C libraries without CGo.
ffi-converter reads a C header file and generates a Go package that calls the
native library at runtime through libffi.
This means:
- No CGo required — your code compiles with
go build, no C compiler needed - Cross-compilation works — build for any platform from any platform
- Faster builds — no CGo compilation overhead
# Build the tool
go build .
# Generate bindings from a C header
./ffi-converter -header mylib.h -output ./mylib -package mylib -lib mylibpackage main
import "myproject/mylib"
func main() {
if err := mylib.Load("/path/to/libs"); err != nil {
panic(err)
}
result := mylib.SomeFunction(42)
}Load is safe for concurrent use and idempotent: a second successful call is a
no-op rather than a reload that leaks the first library handle.
| Flag | Required | Description |
|---|---|---|
-header |
Yes | Path to the C header file |
-output |
No | Output directory (default: current directory) |
-package |
No | Go package name (default: bindings) |
-lib |
No | Library name; mylib becomes libmylib.so / libmylib.dylib / mylib.dll (default: derived from the header filename) |
Anything the tool cannot represent faithfully in Go is skipped whole and
reported, never half-generated. Every skip is printed to stderr as a
warning: line naming the construct and the reason:
warning: line 1: skipped union "U": unions are not supported
warning: line 2: skipped struct "S": function pointer or parenthesised declarator is not supported
warning: line 4: skipped struct "M": multi-dimensional arrays are not supported
warning: skipped function "ld_fn": return type: C type "long double" has no Go representation
warning: skipped function "printf_like": variadic functions need one prepared call interface per call site
warning: C long is mapped to Go int64: correct on LP64 targets, wrong on Windows LLP64 and on 32-bit targets
Warnings do not fail the run; the tool still exits 0 and writes the bindings for everything it could translate. Read them. A missing wrapper is always explained by a warning — if a function you expected is absent from the output, the reason is on stderr. The tool exits non-zero only when the header cannot be read or the generated code cannot be produced at all.
Given testdata/calculator.h:
typedef struct {
double value;
int32_t precision;
uint8_t use_cache;
} CalcConfig;
typedef struct Calc_s* Calc;
CalcConfig calc_default_config(void);
Calc calc_create(CalcConfig config);
void calc_free(Calc calc);
double calc_add(Calc calc, double a, double b);
const char* calc_get_version(void);
int32_t calc_format(Calc calc, char* buf, size_t buf_size);the tool writes three files (see the committed sample in testdata/out/):
Opens the shared library with platform detection (.so, .dylib, .dll) and
prepares one libffi call interface per wrapped function.
Go structs matching the C structs, plus the matching libffi layout descriptors:
type CalcConfig struct {
Value float64
Precision int32
UseCache uint8
}
var FFITypeCalcConfig = ffi.NewType(
&ffi.TypeDouble,
&ffi.TypeSint32,
&ffi.TypeUint8,
)
type Calc uintptr // opaque handleGo functions that call into the native library:
func CalcDefaultConfig() CalcConfig
func CalcCreate(config CalcConfig) Calc
func CalcFree(calc Calc)
func CalcAdd(calc Calc, a float64, b float64) float64
func CalcGetVersion() string
func CalcFormat(calc Calc, buf []byte, bufSize uint) int32Note the string handling: const char* becomes a Go string, while a writable
char* output buffer becomes []byte.
- Primitive types:
int,char,short,long,long long,float,double,bool - Fixed-width and platform types:
int32_t,uint64_t,size_t,ssize_t,uintptr_t,ptrdiff_t - Typedefs, including chains, resolved to the type they alias
- Structs passed by value or by pointer, including one-dimensional array members
- Opaque handles (
typedef struct X_s* X) - Enums, with C value semantics (
{ RED = 1, GREEN, BLUE }is 1, 2, 3) and an underlying Go integer type wide enough for every member - Strings:
const char*parameters andchar*returns asstring, writablechar*/unsigned char*buffers as[]byte - Pointer parameters,
void*, and struct pointers - Out-parameters:
char**becomes**byte, any otherT**or deeper becomes*unsafe.Pointer— real Go pointers, so the call is safe for the garbage collector to move memory around extern "C" { ... }blocks, including the usual#ifdef __cpluspluswrapper that most C libraries put around their entire API- Array parameters, which decay to a pointer as they do in C: given
typedef unsigned char buf_t[16];, the typedef emits Gotype BufT [16]uint8whilevoid take(buf_t b)becomesfunc Take(b []byte) - Duplicate declarations: identical redeclarations (what a broken include guard produces) collapse to one wrapper; a genuinely conflicting one keeps the first and warns
Each of these produces a warning: line and the enclosing declaration is
dropped rather than mis-translated:
- Unions
- Function-pointer struct members, and functions returning function pointers
- Anonymous nested structs and unions inside a struct
- Bitfields
- Multi-dimensional and non-constant-sized arrays
long double— Go has no extended-precision float- Variadic functions — libffi needs one prepared call interface per call site
- Function definitions in the header (
static inline ...); only declarations are translated - File-scope variable declarations
- Callbacks, which still require manual setup with
ffi.Closure - Preprocessor macros beyond being tolerated in a declaration; the tool does not
run a preprocessor and does not follow
#include. Headers that build their declarations out of macros are the main casualty — zlib'sint foo OF((int a));idiom yields nothing, because only a preprocessor can tell whatOFexpands to.
long / unsigned long are supported but emit a portability warning: they
are mapped to 64-bit Go types, which is correct on LP64 targets and wrong on
Windows LLP64 and on 32-bit targets. size_t and friends have no such problem —
their libffi type is chosen at init from unsafe.Sizeof.
Go 1.25.6 or newer (see go.mod). The tool itself has no dependencies outside
the standard library.
- Linux/FreeBSD: install libffi (
apt install libffi8,dnf install libffi) - macOS: libffi is bundled
- Windows (AMD64): libffi is bundled
Add these to the go.mod of the project that consumes the bindings:
require (
github.com/jupiterrider/ffi v0.7.0
golang.org/x/sys v0.28.0
)
Do not downgrade github.com/jupiterrider/ffi below v0.5.1. Earlier releases
mirrored ffi_cif as a single 32-byte struct on every architecture, but the
aarch64 ABI defines FFI_EXTRA_CIF_FIELDS, making the real ffi_cif 40 bytes.
On arm64 ffi_prep_cif therefore wrote 8 bytes past the end of the Go
allocation, and once the neighbouring object was reused every later call
marshalled from corrupted state — float and double arguments arriving as zero,
plus intermittent SIGSEGVs. v0.7.0 is the pinned, verified version.
- Lex and parse — a hand-written tokenizer (
parser/lex.go) feeds a declarator parser (parser/decl.go); there is no regex matching of C syntax.extern "C"blocks are descended into, so a real-world header works: the systemsqlite3.hyields 117 wrappers. Anything unrepresentable is recorded inHeader.Diagnosticsinstead of being guessed at. - Resolve — typedef chains are followed and each C type is looked up in a single table that gives its Go type, its libffi type and its width.
- Plan, then emit — the generator decides every name, signature and call
shape up front, then writes the Go source and runs it through
go/format, so a generator bug becomes a hard error rather than a broken output file.
The generated code uses jupiterrider/ffi, which wraps libffi for Go.
For the internals — the type table, the FFI calling patterns, and how to add support for a new C type — see AGENTS.md.
Apache 2.0