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 cgo tool runs your C compiler over the preamble, parses the DWARF debug info to discover every C type and constant, rewrites C.foo into _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 through runtime.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:

go
// 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:

go
// 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:

FileCompiled byWhat it holds
.cgo1.gocmd/compileYour file with import "C" removed and every C.xxx rewritten to _Cfunc_xxx or _Ctype_xxx
_cgo_gotypes.gocmd/compileGo definitions of every C type, plus the Go wrapper functions
.cgo2.cgcc/clangThe C-side glue that unpacks the frame
_cgo_export.c / _cgo_export.hgcc/clangC-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:

go
// 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:

go
// 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

go
// 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:

go
// 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”:

go
// 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.

go
// 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:

  1. The uintptr trick. The frame address is converted to uintptr so 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.
  2. //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:
go
// 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:

go
// 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"
  1. _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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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 stack

So a cgo call is not a function call. It is:

  1. entersyscall() — tell the scheduler this thread is in foreign code and may block. The P is released so other goroutines run.
  2. 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.
  3. Call the C wrapper, which calls the actual C function.
  4. Switch back, exitsyscall() — possibly blocking until the runtime can give this goroutine a P again.

The shape of one call, end to end:

text
   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
   <- returns

The Go half:

go
// 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:

asm
// 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	nosave

So 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:

go
// 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:

go
// 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 bool

The cgocheck2 implementation is in src/runtime/cgocheck.go, and hooks every pointer write:

go
// 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:nowritebarrier

The 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.struct in 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 GoString and keep it, or let C hold a []byte. You must copy.
  • Static analysis stops at the boundary. go vet and 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 documentation
  • src/cmd/cgo/main.go — the driver, and cPrefix
  • src/cmd/cgo/ast.go — saveRef, saveCall, saveExport
  • src/cmd/cgo/gcc.go — gccDefines, loadDebug, Translate
  • src/cmd/cgo/out.go — writeDefs, writeDefsFunc, writeExports
  • src/runtime/cgocall.go — cgocall, cgoCheckPointer
  • src/runtime/asm_amd64.s — asmcgocall, the stack switch to m->g0
  • src/runtime/cgo/asm_amd64.s — crosscall2, the C-to-Go adapter
  • src/runtime/cgo/callbacks.go — crosscall2 export and _cgo_panic
  • src/runtime/cgo/cgo.go — the runtime/cgo package itself
  • src/runtime/cgocheck.go — the cgocheck2 pointer-write checker

GoInternalsCgoFfiRuntime