What cgo is

cgo is the part of the Go toolchain + runtime that enables in-process calls across the C ABI boundary. It is not “a library” and it is not “an ABI.” It is a compilation/link pipeline plus a runtime transition that makes two things simultaneously true:

  1. the machine-level call obeys the platform C ABI (SysV/Win64/…): argument/return registers, stack alignment, callee-saved registers, varargs rules, etc.
  2. the call does not violate Go runtime invariants: goroutine stack management, scheduling while the call blocks, and GC visibility constraints.

If you remove (2), you can still produce an instruction stream that “calls C,” but you will eventually hit correctness failures that are specific to Go’s execution model.

What cgo does at build time (and why you still #include "foo.h")

The comment block immediately preceding import "C" is treated as C preamble text and passed through the real C toolchain (preprocessor + compiler). So yes, you include headers in the normal way:

package main
 
/*
#cgo CFLAGS: -I./c
#cgo LDFLAGS: -L./c -lfoo
#include "foo.h"
*/
import "C"
 
func main() {
    C.foo_init()
}

The reason cgo exists even though you can include headers is that “including a header” only tells the C compiler what a declaration means. Go still needs a correct, target-specific mapping for that declaration: sizes (size_t), alignment, struct layout, calling convention, and the exact symbol/link flags. cgo therefore generates glue artifacts used during the build: Go declarations for C.* names and C/assembly stubs that bridge the Go calling world to the C ABI calling world, then it compiles the C parts into ordinary .o objects and links them together with the Go objects into the final executable or shared library.

![important] cgo relies on the platform C compiler because C headers are not “just syntax.” Macros, conditional compilation, and platform typedefs change the meaning of the API; the only reliable “truth” is what the configured C compiler says for the current target.

The core runtime problem: “conventional” stack discipline vs goroutine stacks

A “conventional” C stack discipline means: code runs on an OS thread stack whose address range is stable for the lifetime of the thread. Frames are created/removed by adjusting the stack pointer, but the backing memory mapping does not get relocated by the language runtime. When C takes the address of a stack local (or uses alloca), that address remains valid until the function returns and that frame is popped. C libraries also commonly assume their own unwind metadata corresponds to a conventional, monotonic stack pointer evolution.

A Go goroutine stack is different in a way that matters to foreign code: it is dynamically grown by relocation. When a goroutine needs more stack space, the runtime allocates a new, larger stack region and copies the live frames over, then fixes up Go-managed pointers so execution continues on the new stack. This works because Go controls compiled Go code and knows how to rewrite its own stack references safely.

The technical problem with running arbitrary C on a goroutine stack is that C can create “stack pointers that escape Go’s view,” and those do not get rewritten when the goroutine stack is moved. Typical examples include taking the address of a local and storing it in global/static memory, storing it in a heap object that Go cannot interpret, passing it through opaque callbacks, or using C mechanisms like setjmp/longjmp that snapshot/restore stack context in ways Go cannot legally transform. If the goroutine stack is grown while such an address is in circulation, that address now points into the old stack region and becomes a dangling pointer.

So the “growth problem” is not “more bytes”; it is stack relocation while foreign code may hold raw addresses into the stack.

What cgo does at runtime when Go calls C

When you write C.foo(...), the call is routed through a runtime gate (conceptually runtime.cgocall) that does two concrete things.

First, it executes the foreign call on the OS thread’s system stack (often referred to as the runtime’s g0 stack). This is a fixed, thread-associated stack region used for runtime work and for foreign calls, and it is not relocated the way goroutine stacks can be. That ensures that any stack addresses created by the C code have the conventional stability properties C expects for the duration of that call.

Second, it marks the current goroutine as being inside a foreign call so the runtime can schedule and GC correctly. In particular, the runtime treats the period spent in C similarly to a blocking syscall: the OS thread running the call might block in C for an unbounded amount of time, so the runtime must be free to run other goroutines on other OS threads.

This is why cgo explanations inevitably mention “threads”: not because the ABI needs threads, but because the Go runtime must preserve global progress while one thread is in foreign code.

Calling convention bridging: cgo is also an ABI adapter, not just a stack trick

Even on the same OS/arch, Go’s internal calling convention is not identical to the platform C ABI. (For example, Go has had its own ABI details historically, and even with register-based ABIs, the exact register assignment, frame layout, and metadata conventions do not simply match “C.”)

cgo therefore arranges that the transition into C happens through stubs that obey the platform C ABI exactly. Conceptually, you can think of the call as:

  • Go code prepares arguments in a Go-native way.
  • a generated/assembly bridge reshapes them into the platform C ABI layout and issues the actual call instruction to the C symbol (which, on ELF + dynamic linking, may still resolve via the usual loader indirection, but that’s not the interesting part).
  • on return, the bridge reshapes the result back into Go form.

The important point is that “calling C correctly” is not just “use the right registers”; it is “enter C as if you were compiled by a C compiler on this platform,” including stack alignment and red-zone/shadow-space constraints where applicable.

GC and pointer rules: what cgo forbids, and the exact reason

Once the call mechanics are correct, the main remaining hazard is GC reachability. If C stores a pointer into Go heap memory somewhere the Go GC cannot see, then from the GC’s perspective the pointed-to object may become unreachable and be reclaimed. Today Go’s GC is non-moving, so the classic failure mode is premature reclamation (use-after-free), not relocation; the rules are also designed to keep the runtime compatible with more aggressive GC strategies in the future.

This is why “C may not keep Go pointers” is not a stylistic preference: it is a statement about what the GC can soundly reason about. The robust pattern is to pass borrowed pointers only for the duration of the call, and for long-lived references pass handles (integers) that are tracked on the Go side (e.g., via runtime/cgo.Handle) rather than raw Go pointers stored in C memory.

A related sharp edge is liveness: Go’s optimizer may prove that a Go object is dead earlier than you expect. When you pass a pointer into C, you sometimes must use runtime.KeepAlive(x) after the cgo call to force “x is live until here,” so the GC does not reclaim it while C is still using it during the call.

C calling back into Go

cgo can also expose Go functions to C (via generated trampolines) so that C code can call into Go through a C ABI symbol. The runtime must then “legalize” that entry: it establishes Go runtime state on that OS thread before executing Go code, and it enforces the same scheduling/GC constraints as any other entry into the runtime.

Callback designs are where you must be explicit about reentrancy and blocking behavior. A C callback that re-enters Go while the program is already in a foreign-call region can deadlock or starve progress if the callback contract and runtime expectations don’t match.

How to inspect what cgo actually generated

cgo is easiest to understand by examining the concrete intermediates it produces and the final linked artifact. The Go toolchain can preserve the temporary build directory that contains the generated Go/C sources, compiled objects, and the exact compiler/linker invocations used.

Build while retaining the work directory and printing the executed commands:

go build -work -x

The output includes a path like WORK=/tmp/go-build... (or a platform-equivalent directory). In that directory you will find cgo-generated sources (commonly files named like _cgo_gotypes.go, _cgo_export.c, _cgo_main.c, plus package-specific generated files) and the compiled .o files produced by the platform C compiler. The generated Go code shows how C.foo(...) is expressed as a call through a cgo wrapper; the generated C code shows the C-side stubs/trampolines used for symbol linkage and callbacks (if present).

Once you have the final binary, validate the boundary using the same artifact-level tools you’d use for any ABI/linking question:

nm -a ./yourbin | grep -i cgo
readelf -Ws ./yourbin | grep foo            # ELF: symbol table entries
readelf -rW ./yourbin | grep -E 'JUMP_SLOT|GLOB_DAT'   # ELF: dynamic relocations (if dynamically linked)
objdump -d ./yourbin | grep -n '@plt'       # ELF: PLT-mediated call sites (if applicable)

This inspection loop answers, with no guesswork, which wrapper symbols exist, which C objects were compiled/linked, whether the call site is direct or dynamically bound, and where the Go↔C transition stubs live in the final artifact.