How cgo Works in Go: The Two Compilers and a Bit of Magic
cgo is not an FFI layer bolted onto the Go compiler: it is a separate program that runs the C compiler to discover types, rewrites your Go source, and generates a matching set of C wrappers that hand arguments across in a struct.
TL;DR: cgo is two compilers and a translation layer. The
cgotool runs your C compiler over the preamble, parses the DWARF debug info to discover every C type and constant, rewritesC.foointo_Cfunc_foo, and generates a matching C function that packs the arguments into a struct and calls the real C function. Every call then goes throughruntime.cgocall, which switches the goroutine onto the system stack before the C code runs.
The import "C" line is the most magical-looking thing in Go. It is not a
real import: there is no package called C on disk.
What actually happens is that cgo — a standalone program at src/cmd/cgo
— is invoked as a build step. It reads your .go file, finds the
import "C" and the comment above it (the preamble), and does something
no other part of the Go toolchain does: it runs your C compiler.
This is how it all works, based on reading the Go 1.28 development tree
(commit c0be4674).
The preamble is the source of truth
The rule is short: the comment immediately preceding import "C" is the
preamble. Anything you declare in it becomes visible to Go as C.name. cgo
compiles it and parses the DWARF:
// src/cmd/cgo/gcc.go:1805
func (p *Package) gccCmd(ofile string) []string {
c := append(gccBaseCmd,
"-w", // no warnings
"-Wno-error", // warnings are not errors
"-o"+ofile, // write object to tmp
"-gdwarf-2", // generate DWARF v2 debugging symbols
"-c", // do not link
"-xc", // input language is C
)
if p.GccIsClang {
c = append(c,Note -gdwarf-2. cgo is not parsing the C source; it is asking the
compiler for the meaning of every identifier, machine-readably.
Constants are handled separately: cgo compiles them into the object file
and reads their values back out of the data segment. So C.O_RDONLY is not
a magic number baked into Go: it is the value the C compiler put in the
object file.
The file pipeline
Once cgo knows what C.foo means, it generates a family of files:
// src/cmd/cgo/doc.go:723
Given the input Go files x.go and y.go, cgo generates these source
files:
x.cgo1.go # for gc (cmd/compile)
y.cgo1.go # for gc
_cgo_gotypes.go # for gc
_cgo_import.go # for gc (if -dynout _cgo_import.go)
x.cgo2.c # for gcc
y.cgo2.c # for gcc
_cgo_defun.c # for gcc (if -gccgo)Two compilers, two sets of outputs:
| File | Compiled by | What it holds |
|---|---|---|
.cgo1.go | cmd/compile | Your file with import "C" removed and every C.xxx rewritten to _Cfunc_xxx or _Ctype_xxx |
_cgo_gotypes.go | cmd/compile | Go definitions of every C type, plus the Go wrapper functions |
.cgo2.c | gcc/clang | The C-side glue that unpacks the frame |
_cgo_export.c / _cgo_export.h | gcc/clang | C-callable wrappers for Go functions marked //export |
The Go compiler never sees the C source; the C compiler never sees your Go code. They meet at the boundary of a struct.
The symbol rewriting
The name mangling has two halves. In your source C.puts becomes
_Cfunc_puts:
// src/cmd/cgo/gcc.go:806
func (p *Package) mangleName(n *Name) {
// When using gccgo variables have to be
// exported so that they become global symbols
// that the C code can refer to.
prefix := "_C"
if *gccgo && n.IsVar() {
prefix = "C"
}
n.Mangle = prefix + n.Kind + "_" + n.Go
}In the C world the wrapper needs a symbol name that cannot collide with anything else, so cgo prefixes it with a hash of the package:
// src/cmd/cgo/main.go:395
// We already put _cgo_ at the beginning, so the main
// concern is other cgo wrappers for the same functions.
// Use the beginning of the 16 bytes hash of the input to disambiguate.
h := hash.New32()and
// src/cmd/cgo/out.go:563
cname := fmt.Sprintf("_cgo%s%s", cPrefix, n.Mangle)The cPrefix is the first six bytes of that hash, formatted as hex:
// src/cmd/cgo/main.go:451
cPrefix = fmt.Sprintf("_%x", h.Sum(nil)[0:6])So the C symbol for C.puts ends up as _cgo_be59f0f25121_Cfunc_puts —
deterministic across builds but unique per package.
The Go side declares that symbol with a //go:cgo_import_static directive,
telling the linker “this is a C symbol that lives in the gcc-compiled
object file”:
// src/cmd/cgo/out.go:632
// Wrapper calls into gcc, passing a pointer to the argument frame.
fmt.Fprintf(fgo2, "//go:cgo_import_static %s\n", cname)
fmt.Fprintf(fgo2, "//go:linkname __cgofn_%s %s\n", cname, cname)
fmt.Fprintf(fgo2, "var __cgofn_%s byte\n", cname)
fmt.Fprintf(fgo2, "var %s = unsafe.Pointer(&__cgofn_%s)\n", cname, cname)The Go side of a call
The generated Go wrapper does one thing: build a frame and hand it to
runtime.cgocall.
// src/cmd/cgo/out.go:647
fmt.Fprint(fgo2, "\n")
fmt.Fprint(fgo2, "//go:cgo_unsafe_args\n")
conf.Fprint(fgo2, fset, d)
fmt.Fprint(fgo2, " {\n")
// NOTE: Using uintptr to hide from escape analysis.
arg := "0"
if len(paramnames) > 0 {
arg = "uintptr(unsafe.Pointer(&p0))"
} else if !void {
arg = "uintptr(unsafe.Pointer(&r1))"
}Three things to notice:
- The
uintptrtrick. The frame address is converted touintptrso escape analysis and the GC don’t think the frame escapes to the heap — a lie told to the compiler for the sake of the GC’s stack scanning. //go:cgo_unsafe_args. This pragma tells the compiler that a pointer to one argument is a pointer to all of them, so it must disable the write barrier’s per-argument checks. The lexer turns it into a flag:
// src/cmd/compile/internal/noder/lex.go:67
case "go:cgo_unsafe_args":
return ir.CgoUnsafeArgs | ir.NoCheckPtr // implies NoCheckPtr (see #34968)and the inliner refuses to inline such functions:
// src/cmd/compile/internal/inline/inl.go:374
// If marked "go:cgo_unsafe_args", don't inline, since the function
// makes assumptions about its argument frame layout.
if fn.Pragma&ir.CgoUnsafeArgs != 0 {
reason = "marked go:cgo_unsafe_args"_Cgo_no_callback. With#cgo nocallback, the wrapper tells the runtime in advance that the C code will not call back into Go.
The C side of a call
The other half is generated C:
// src/cmd/cgo/doc.go:767
The hexadecimal number is a hash of cgo's input, chosen to be
deterministic yet unlikely to collide with other uses. The actual
function _cgo_be59f0f25121_Cfunc_puts is implemented in a C source
file compiled by gcc, the file x.cgo2.c:
void
_cgo_be59f0f25121_Cfunc_puts(void *v)
{
struct {
char* p0;
int r;
char __pad12[4];There it is: the arguments cross the boundary in a struct, by pointer.
Not in registers, not on a stack — in a struct both sides agree on the
layout of. It is __attribute__((packed)) with explicit padding
(char __pad12[4]), because the two compilers must agree on the byte
offsets exactly:
// src/cmd/cgo/out.go:995
argField := func(typ ast.Expr, namePat string, args ...any) {
name := fmt.Sprintf(namePat, args...)
t := p.cgoType(typ)
if off%t.Align != 0 {
pad := t.Align - off%t.Align
fmt.Fprintf(&ctype, "\t\tchar __pad%d[%d];\n", npad, pad)
off += pad
npad++
}
fmt.Fprintf(&ctype, "\t\t%s %s;\n", t.C, name)//export: C calling Go
The other direction works the same way, mirrored. A Go function marked
//export gets a C-callable wrapper:
// src/cmd/cgo/doc.go:311
# C references to Go
Go functions can be exported for use by C code in the following way:
//export MyFunction
func MyFunction(arg1, arg2 int, arg3 string) int64 {...}
//export MyFunction2
func MyFunction2(arg1, arg2 int, arg3 string) (int64, *C.char) {...}Note the struct return type for multiple return values. Note also the preamble restriction: because it is copied into two C output files, it must contain only declarations, never definitions, or the link fails on duplicate symbols.
The generated C wrapper for an exported function builds the same kind of
frame struct and calls crosscall2:
// src/cmd/cgo/out.go:1112
if gccResult != "void" && (len(fntype.Results.List) > 1 || len(fntype.Results.List[0].Names) > 1) {
fmt.Fprintf(fgcc, "\t%s r;\n", gccResult)
}
if fn.Recv != nil {
fmt.Fprintf(fgcc, "\t_cgo_a.recv = recv;\n")
}
forFieldList(fntype.Params,
func(i int, aname string, atype ast.Expr) {
fmt.Fprintf(fgcc, "\t_cgo_a.p%d = %s;\n", i, genParamName(aname, i))
})
fmt.Fprintf(fgcc, "\t_cgo_tsan_release();\n")
fmt.Fprintf(fgcc, "\tcrosscall2(_cgoexp%s_%s, &_cgo_a, %d, _cgo_ctxt);\n", cPrefix, exp.ExpName, off)crosscall2 is the adapter from the C calling convention to the Go one —
hand-written assembly on amd64, saving the registers the two conventions
disagree about.
The cost of a call
Now the part that matters for performance. The runtime’s own header comment describes the dance:
// src/runtime/cgocall.go:5
// Cgo call and callback support.
//
// To call into the C function f from Go, the cgo-generated code calls
// runtime.cgocall(_cgo_Cfunc_f, frame), where _cgo_Cfunc_f is a
// gcc-compiled function written by cgo.
//
// runtime.cgocall (below) calls entersyscall so as not to block
// other goroutines or the garbage collector, and then calls
// runtime.asmcgocall(_cgo_Cfunc_f, frame).
//
// runtime.asmcgocall (in asm_$GOARCH.s) switches to the m->g0 stackSo a cgo call is not a function call. It is:
entersyscall()— tell the scheduler this thread is in foreign code and may block. The P is released so other goroutines run.asmcgocall— switch from the goroutine’s stack to the system stack (m->g0), because Go’s goroutine stacks are small and movable when they grow, and C code holds pointers to its own stack frames. The system stack never moves.- Call the C wrapper, which calls the actual C function.
- Switch back,
exitsyscall()— possibly blocking until the runtime can give this goroutine a P again.
The shape of one call, end to end:
Go goroutine (stack S) Go runtime C world
------------------------ ----------- --------
_Cfunc_puts(p0)
-> runtime.cgocall(fn, &frame)
entersyscall() -------> release the P, other
goroutines run
asmcgocall() -------> switch to m->g0
(system stack)
call fn(frame)
-> _cgo_..._Cfunc_puts
a->r = puts(a->p0)
asmcgocall() <------- switch back to stack S
exitsyscall() <------- wait for a P again
<- returnsThe Go half:
// src/runtime/cgocall.go:132
//go:linkname cgocall
//go:nosplit
func cgocall(fn, arg unsafe.Pointer) int32 {
if !iscgo && GOOS != "solaris" && GOOS != "illumos" && GOOS != "windows" {
throw("cgocall unavailable")
}
if fn == nil {
throw("cgocall nil")
}And the assembly that does the stack switch, on amd64:
// src/runtime/asm_amd64.s:904
// func asmcgocall(fn, arg unsafe.Pointer) int32
// Call fn(arg) on the scheduler stack,
// aligned appropriately for the gcc ABI.
// See cgocall.go for more details.
TEXT ·asmcgocall(SB),NOSPLIT,$0-20
// Figure out if we need to switch to m->g0 stack.
// We get called to create new OS threads too, and those
// come in on the m->g0 stack already. Or we might already
// be on the m->gsignal stack.
get_tls(CX)
MOVQ g(CX), DI
CMPQ DI, $0
JEQ nosave
MOVQ g_m(DI), R8
MOVQ m_gsignal(R8), SI
CMPQ DI, SI
JEQ nosaveSo the cost of a cgo call is a scheduler hand-off, a stack switch, and a scheduler hand-back — a few hundred nanoseconds, versus a couple of nanoseconds for a direct Go call.
The reverse direction is more involved: the runtime switches back onto
the goroutine stack, runs Go code that may grow and move the stack, then
switches back again — runtime.cgocallbackg is where that starts.
The pointer rules, and how they’re enforced
The rules themselves are short and absolute:
// src/cmd/cgo/doc.go:383
Go code may pass a Go pointer to C provided the memory to which it
points does not contain any Go pointers to memory that is unpinned. When
passing a pointer to a field in a struct, the Go memory in question is
the memory occupied by the field, not the entire struct. When passing a
pointer to an element in an array or slice, the Go memory in question is
the entire array or the entire backing array of the slice.
C code may keep a copy of a Go pointer only as long as the memory it
points to is pinned.
C code may not keep a copy of a Go pointer after the call returns,
unless the memory it points to is pinned with [runtime.Pinner] and the
Pinner is not unpinned while the Go pointer is stored in C memory.
This implies that C code may not keep a copy of a string, slice,
channel, and so forth, because they cannot be pinned with
[runtime.Pinner].You cannot store a Go pointer in C memory. The reason is the garbage collector: it moves objects and frees them, and if C memory holds a pointer to Go memory the GC does not know about, it will eventually free memory C is still using. Unless the memory is pinned.
The rules are enforced at runtime. The default is GODEBUG=cgocheck=1, a
cheap check; the thorough one is a build-time experiment:
// src/internal/goexperiment/flags.go:90
// CgoCheck2 enables an expensive cgo rule checker.
// When this experiment is enabled, cgo rule checks occur regardless
// of the GODEBUG=cgocheck setting provided at runtime.
CgoCheck2 boolThe cgocheck2 implementation is in src/runtime/cgocheck.go, and hooks
every pointer write:
// src/runtime/cgocheck.go:17
// cgoCheckPtrWrite is called whenever a pointer is stored into memory.
// It throws if the program is storing an unpinned Go pointer into non-Go
// memory.
//
// This is called from generated code when GOEXPERIMENT=cgocheck2 is enabled.
//
//go:nosplit
//go:nowritebarrierThe rule encoded there is the rule in the docs: a Go pointer may be written to C memory only if it is pinned.
Trade-offs
What cgo buys you:
- Access to everything. Any C library, any version, any ABI — the wrapper comes from the DWARF.
- Zero-copy interop. A
*C.structin C memory can be read from Go without copying, if it holds no Go pointers. - Incremental adoption. Call an existing C codebase as-is.
What it costs:
- Two compilers, two ABIs, two linkers. The build is slower, the binary is bigger, and you need a C toolchain installed.
- Crossing the boundary is expensive. A cgo call is two orders of magnitude more expensive than a direct Go call. Batch your calls.
- The pointer rules are a real constraint. You cannot hand C a
GoStringand keep it, or let C hold a[]byte. You must copy. - Static analysis stops at the boundary.
go vetand escape analysis give up at a C call.
Caveats
A cgo call is not cheap. If the C function is trivial — say, strlen
on a short string — the cgo overhead can dominate by two orders of
magnitude. Batch the calls.
//exported functions must be in a file that imports “C”, and the
exported name must match the function name exactly: //export Foo on a
function called foo is an error.
C code cannot keep a Go pointer. Use runtime/cgo.Handle or copy.
cgocheck=0 turns the checks off, and the runtime will not save you:
you get a crash that looks like random memory corruption in native code.
GOEXPERIMENT=cgocheck2 is a build-time experiment, not a GODEBUG.
It instruments every pointer write, so it costs real time. Worth one build
in CI.
Callbacks from C into Go are the most fragile part. A C library that
calls back on a thread the runtime did not create needs
runtime/cgo.Handle or an //exported function that makes its own
goroutine.
C.malloc returns memory the GC does not scan. Use C.CBytes.
Wrapping up
The one thing to remember: cgo is a source-to-source translator, not an
FFI layer. cmd/cgo uses gcc to discover the C types, then writes a Go
wrapper (which marshals into a frame and calls runtime.cgocall) and a C
wrapper (which unpacks the frame and calls your function).
The part people get wrong: they think the expensive part is the C call. It is the goroutine leaving its stack.
What to go do about it: count the crossings. Calling C once per
network packet is fine; calling C once per struct field is a design
problem, and the fix isn’t unsafe, it’s runtime.Pinner.
Further reading
src/cmd/cgo/doc.go— the tool’s own documentationsrc/cmd/cgo/main.go— the driver, andcPrefixsrc/cmd/cgo/ast.go—saveRef,saveCall,saveExportsrc/cmd/cgo/gcc.go—gccDefines,loadDebug,Translatesrc/cmd/cgo/out.go—writeDefs,writeDefsFunc,writeExportssrc/runtime/cgocall.go—cgocall,cgoCheckPointersrc/runtime/asm_amd64.s—asmcgocall, the stack switch tom->g0src/runtime/cgo/asm_amd64.s—crosscall2, the C-to-Go adaptersrc/runtime/cgo/callbacks.go—crosscall2export and_cgo_panicsrc/runtime/cgo/cgo.go— theruntime/cgopackage itselfsrc/runtime/cgocheck.go— thecgocheck2pointer-write checker