Architecture
How cppgo works: the artefacts it produces, and one call from Go to C++.
Before this page, read The Go runtime and a native call.
The design below rests on four mechanisms, and each one is named here before it is used, because the rest of the page reuses the names.
- A trampoline is a handful of instructions of Go assembly that take the address of a compiled function and jump to it. It exists because hand-written assembly can do one thing ordinary Go code cannot: move the stack pointer, so that the callee finds the 16-byte-aligned stack the x86-64 calling convention requires.
NOSPLITis the assembler attribute that forbids the Go runtime from growing the goroutine’s stack at that point of the program. It is what makes moving the stack pointer in the trampoline legal: a stack that cannot move cannot invalidate the pointer arithmetic three instructions later.- A shim is one C++ file that cppgo generates and compiles beside your library. It holds small C++ functions that call the declarations a raw register call cannot express, and hand the result back in a form Go can hold.
- A carrier is which of the three routes across the boundary one declaration takes: the trampoline, a function in the shim, or a third route that lets C++ call back into Go.
A foreign call — a call from Go into machine code that the Go toolchain never compiled — is what all four serve. The design is what it is because the Go runtime cannot see one: it cannot move the goroutine’s stack in the middle of it, it cannot hand that goroutine’s processor to another goroutine while the call runs, and the only way back into Go is a callback. Without that model the choices on this page read as arbitrary.
This is the how-it-works page: it explains the mechanism behind the API. What you need in order to use cppgo is on the other pages — start with Bind your first C++ class, use the how-to guides for a task, and Reference to look something up.
cppgo is two parts that ship together: a runtime that Go programs link, and a generator that writes and builds the C++ side. Everything else in this page follows from that split and from the three artefacts one generation produces.
Two parts
The runtime is what a generated package imports: dynamic loading, symbol resolution, the calls into C++, the callback support, the error types. It needs no C++ compiler and no C++ parser.
The generator is the command: it parses the headers, selects the declarations, decides how each one is called, emits the Go and the C++, and builds the C++ side. It is the only part that needs libclang — the C library of the Clang compiler front end, which parses C++ the way the compiler would — and a C++ compiler.
A program built with a generated package needs neither. It needs the two shared
objects at run time — the two .so files that the dynamic loader opens — namely
the shim, and the library it links against.
Three artefacts
A binding is one C++ declaration turned into a callable Go function — a method of a bound class, a package function, a constructor — and it is the unit this page counts everything in.
| Artefact | Owner | What it is |
|---|---|---|
| the user’s library | you | your sources, your compiler flags, your build. cppgo never writes into it |
| the cppgo shim | cppgo | zz_cppgo_shim.cpp, generated from the same parse as the Go API and compiled by cppgo into libcppgo_<package>.so, linked against your library |
| the generated Go package | cppgo | zz_cppgo.gen.go and zz_cppgo_amd64.s: the API you call, the assembly trampolines — one per binding, except the reentrant carrier, plus the one that frees a copied standard-library result — and the code that loads the shim |
The edge the table draws is the contract: your build is untouched, and no generated code is in it. A header-only library has no shared object at all: the shim is the whole binary.
The generated files
your header + your compilation configuration
│
▼
cppgo gen ──► zz_cppgo.gen.go (the Go API)
│ zz_cppgo_amd64.s (one trampoline per binding that does not enter C++ through the runtime, plus the free trampoline)
│ cppgo_shim/*.cpp (the thunks)
│ libcppgo_<pkg>.so (the shim, compiled by cppgo)
▼
your Go program
| File | Contents |
|---|---|
zz_cppgo.gen.go | the Go API, a load function, and one declaration per trampoline |
zz_cppgo_amd64.s | one NOSPLIT assembly trampoline per binding, except the bindings on the reentrant carrier: a binding that passes a Go function to C++ and a template binding enter C++ through runtime.cgocall instead and have none. cppgoFree is the extra trampoline for the helper that frees a standard-library result the shim copied out. Each trampoline aligns the stack, makes the call, restores it |
cppgo_shim/zz_cppgo_shim.h | the extern "C" prototypes of the thunks (extern "C" gives a C++ function the C calling convention and an unmangled name, so a plain function pointer can reach it) |
cppgo_shim/zz_cppgo_shim.cpp | the thunks themselves, one per non-direct binding |
libcppgo_<package>.so | the shim, compiled by cppgo and linked against your library |
The first four are sources you commit with your project. The .so is a build
output. Nothing in this list is written into your library.
Both trampoline rules come from one place:
internal/gen/emit_asm.go
walks the bindings, skips the reentrant carrier, and writes cppgoFree after
the rest when the shim copies a standard-library result out for Go to free.
A thunk is one function in that shim which stands in for one C++ declaration: the trampoline’s register call arrives at it, it calls the real declaration as C++, and it returns a status instead of letting anything unwind. A binding that the direct call can express gets no thunk; every other one gets exactly one.
One call, step by step
Take mathx::Vector::at, from
examples/mathx/include/mathx/vector.hpp:
double at(std::size_t index) const;
It is not noexcept — it throws for an index other than 0 or 1 — so the
generator gives it the thunk carrier and emits the Go method At.
Go calls
At(2). The generated method checks the receiver, callscppgo.ProbeStack()(runtime reference), and forms a pointer to the local variable that will hold the result.The trampoline runs.
cppgoCall_79aeb346bd10is a NOSPLIT assembly function with no Go frame (zz_cppgo_amd64.s):TEXT ·cppgoCall_79aeb346bd10(SB), NOSPLIT, $0-36 MOVQ fn+0(FP), R12 MOVQ this+8(FP), DI MOVQ a1+16(FP), SI MOVQ out+24(FP), DX MOVQ SP, R13 ANDQ $~15, SP CALL R12 MOVQ R13, SP MOVL AX, ret+32(FP) RETLine by line:
TEXTdeclares the function, and$0-36says it has no local frame and 36 bytes of arguments and result.MOVQ fn+0(FP), R12loads the address of the C++ function into a register the callee must preserve.this+8(FP)intoDIanda1+16(FP)intoSIput the object pointer and the index in the first two argument registers of the C convention, andout+24(FP)intoDXputs the address of Go storage for the result in the third.MOVQ SP, R13saves the Go stack pointer andANDQ $~15, SProunds it down to a 16-byte boundary, which is what the System V convention requires at the entry of a callee.CALL R12makes the call,MOVQ R13, SPrestores the Go stack pointer, and only then doesMOVL AX, ret+32(FP)store the status the callee returned inAX.RETreturns.The order is the rule: every argument is read while
SPstill points into the Go frame, and the result is stored only afterSPis back, becauseFP— the pseudo-register the arguments are addressed through — is relative toSP.ProbeStackran first because the probe grows the goroutine stack, and growing it after taking a pointer into the caller’s frame would move that frame. The Go runtime and a native call derives each of those constraints.The thunk runs as C++. The address in
R12iscppgo_thunk_79aeb346bd10in the shim (zz_cppgo_shim.cpp): it clears the error message this thread recorded — athread_local, the C++ storage class that gives each thread its own copy — castsselfback toconst mathx::Vector *, and calls the real method insidetry/catch.The result comes back through Go storage. The thunk writes the
doublethrough the out-pointer the caller provided — the address of the Go variable step 1 made — and returns0or1.The generated method maps the status. Non-zero becomes a
*cppgo.CallError(runtime reference) reading the shim’s message; zero returns the value.
A direct binding is the same path with step 3 replaced by the C++ symbol
itself and no status: mathx::Vector::scale(double) is noexcept — the C++
keyword by which a function promises not to throw — so the call goes straight to
the function’s mangled symbol, _ZN5mathx6Vector5scaleEd, and there is nothing
to map back. A mangled symbol is the name the C++ compiler encodes into the
library’s symbol table, with the namespaces, the class and the parameter types
folded into it; The evidence line below reads one apart.
A reentrant binding differs at step 2: cppgo.CallReentrant
(runtime reference)
fills a block of memory with the call’s arguments and enters through
runtime.cgocall — the Go runtime’s own entry point for “this thread is inside
foreign code” — so that a callback can re-enter Go.
In the mathx example,
scale is the one direct binding: it is noexcept, not virtual (virtual — the
implementation a call reaches is chosen at run time from the object’s type,
instead of being fixed when the call is compiled), defined out of line (in
src/vector.cpp, not inside the class body), and the library exports its mangled
name. x() is a thunk even though it is a trivial accessor, because its body is
inline and the library exports no symbol for it. The rest can throw or must be
copied, so the shim carries them. The
carrier table has the full matrix and
the costs.
Exceptions across the boundary
A declaration that is not noexcept is carried by a thunk, and the thunk wraps
the call in try/catch — the call and the conversion of its arguments and
result, because constructing a std::string or a std::vector there can throw
std::bad_alloc too. This is the generated code for mathx::Vector::norm
(examples/mathx/cppgo_shim/zz_cppgo_shim.cpp):
extern "C" int32_t cppgo_thunk_49993cda6560(const void * self, double * cppgo_result) {
cppgo_error_message.clear();
try {
*cppgo_result = static_cast<const ::mathx::Vector *>(self)->norm();
return 0;
} catch (const std::exception &error) {
cppgo_error_message = error.what();
return 1;
} catch (...) {
cppgo_error_message = "unknown C++ exception";
return 1;
}
}
The mechanism in four sentences: each thunk clears a thread-local message on
entry; a caught exception copies what() into it; the shim exports it through
cppgo_last_error
(the function in the same shim);
the generated binding reads it after a non-zero status and returns it.
No exception can be supported without a C++ function above the call. A
thrown exception unwinds — it walks the frames above the throw, looking for a
catch — and it needs unwind information for every frame it crosses. A Go frame
has none, so an exception that reaches one terminates the process. A direct
binding is noexcept by construction, so it never has a handler; that is why a
throwing function is never assigned the direct carrier, even when its signature
would fit the registers.
The practical consequence for a user is on Report failures from C++ to Go: an API that throws and whose headers the shim cannot include cannot be bound safely, and the fix belongs in the library.
A status-code convention. Some libraries report failure as a 32-bit return
code instead of an exception. A declaration named with -status is generated as
a call whose status is the error channel: the C++ side returns the callee’s
own code to Go, and Go treats any non-zero value as the failure. The Go
signature drops the integer and keeps only the error. A -status entry that
names no declaration, or whose declaration returns a value rather than an
integer, is a generation error.
Report failures from C++ to Go
has the steps.
Values, copies, and the layout check
A record — C++’s word for a struct or class used as a bundle of fields — is
mirrorable when all its fields are public, its layout is the natural Go
layout, and no field is a bitfield (several fields packed into the bits of one
integer), a reference, a pointer, a union member (a union lets several members
share the same storage) or a non-mirrorable type. The record in the examples is
mathx::Point, in
examples/mathx/include/mathx/vector.hpp:
struct Point {
double x;
double y;
};
Its Go value is the C layout — the bytes of the Go struct are the bytes C++ sees — and the generated file asserts that at compile time:
// The C++ layout is proven at compile time: a wrong size, alignment or
// field offset does not compile.
var _ [16]byte = [unsafe.Sizeof(Point{})]byte{}
var _ [8]byte = [unsafe.Alignof(Point{})]byte{}
var _ [0]byte = [unsafe.Offsetof(Point{}.X)]byte{}
var _ [8]byte = [unsafe.Offsetof(Point{}.Y)]byte{}
Each line is a compile-time assertion: the array length is the measured value and the fixed number beside it is what C++ layout requires, so a mismatch is an array whose length is not a constant — a build error, not a run-time bug.
A record that is not mirrorable becomes an opaque value instead: Go storage the
C++ side owns, with Close, Clone and generated accessors.
By-value records are copied by the shim in both directions. mathx::midpoint,
for example, takes a 16-byte POD by value and returns one — a POD, for plain
old data, being a struct with no constructor, no virtual function and no private
members, whose bytes are its whole representation — so the thunk copies the
arguments into the call and the result back into Go storage.
The std:: ABI
The std:: in that heading is the C++ standard library’s namespace, and the
ABI — application binary interface — is how a compiled call and a compiled
value are laid out: which registers and which memory carry a call’s arguments,
and what bytes a value has in memory. The standard library’s types never appear
in Go for that second reason: their layout exists only in the shim, which is C++
and can legally construct them:
- a
std::stringparameter is copied into astd::stringin the shim, and a result is copied out into a Go string; - a
std::vector<T>is copied element by element in both directions; std::string_viewandstd::span— read-only views, a pointer and a length that own nothing — are borrowed as(pointer, length)and are valid only for the call;- the shim and your library must use the same standard library and the same
_GLIBCXX_USE_CXX11_ABI, the build macro that selects which of two incompatiblestd::stringlayouts the GNU standard library compiles. cppgo compiles the shim with the flags it discovered and reports the compiler it used; it does not inspect the library’s standard library, so keeping the two in step is a build decision. Recording the version and_GLIBCXX_USE_CXX11_ABIand refusing a mismatch is designed (Limits).
Templates: why two phases
A C++ template does not exist until it is instantiated: the compiler only produces
code for a template when some code names the concrete types it is used with.
std::vector<int> and std::vector<double> are two different types with two
different pieces of machine code, and which ones your program needs is a property
of the Go program, not of the header.
That is why templates take two phases. cppgo gen writes the generic Go API,
which compiles without any concrete type. cppgo build -scan <package> loads the
program with go/packages — the Go library that answers “what does this program
import and use” — reads the type arguments at every use of the generic API, and
for each specialisation (one template with one set of concrete types) writes the
explicit instantiation into the shim’s source:
template struct ::box::Box<int>;
plus one thunk per member the program uses, and then compiles the shim. A Go instantiation with no C++ counterpart does not reach a nil call: it fails when it is used, and the error names the line to add.
The user-facing steps are in Bind a C++ template library.
Generation: parse, selection, determinism
The parse. cppgo gen reads the headers through libclang, which only sees
the program the compiler would see when it is given the same include paths and
language standard. The configuration comes from compile_commands.json or
.clangd, the compilation database in which a C++ project records the exact
compiler command for each source file. cppgo rewrites a database’s relative
include paths against the directory its entry names, because libclang resolves a
relative path against cppgo’s own working directory, which is a different
directory: the parse then succeeds while seeing a different program.
The libclang floor. The generator needs libclang 20 or newer. It reads the
offset of base-class subobjects through clang_getOffsetOfBase, which libclang
19 does not export, so a build against 19 fails at load time with
undefined symbol: clang_getOffsetOfBase.
Selection. A header is the unit of selection: every public declaration in it,
plus the type closure of every bound signature — the types that signature
mentions, and the types those mention, transitively. -bind and -exclude narrow
the set, and an entry that names nothing is an error. A construct this build does
not support is reported with a message naming it and the fix — a declaration is
left out with a warning, while a template pattern the generic API cannot model
stops the run — which is what
Limits tabulates.
Determinism. The same inputs produce byte-identical Go and C++. Bindings are sorted by qualified name, and a thunk’s name is a stable hash of the qualified signature.
The report. One line per binding, then the compiler:
mathx::Vector::scale(double) noexcept direct _ZN5mathx6Vector5scaleEd noexcept, not virtual, register class; the library exports the symbol
mathx::Vector::norm() const thunk cppgo_thunk_49993cda6560 not noexcept: an exception would unwind into Go
mathx::midpoint(Point, Point) thunk cppgo_thunk_a66614728a62 a record passed or returned by value: the thunk copies it
cppgo: built libcppgo_mathx.so with /usr/bin/g++
The columns are the C++ declaration, its carrier, the symbol the call goes to and
the reason for that carrier — register class there saying that the values fit
the argument registers, so the call needs no memory on the way in. When
-library names a readable file, a direct binding is cross-checked against the
library’s dynamic symbol table — the list of symbols a shared object publishes to
the loader — and its generated comment ends , verified against libmathx.so. The
last line names the compiler that built the shim, which is the earliest warning
if it is not the one your library was built with.
The evidence line. Every binding carries one comment naming the C++ declaration, its mangled symbol and the carrier:
// C++: mathx::Vector::at(std::size_t) const, _ZNK5mathx6Vector2atEm (thunk cppgo_thunk_79aeb346bd10, not noexcept: an exception would unwind into Go), verified against libmathx.so
Read the mangled symbol as the signature: C++ has overloading and namespaces, so
the compiler encodes the parameter types and the qualifying names into the
symbol it emits. _ZN5mathx6Vector5scaleEd is mathx::Vector::scale(double).
Building the shim
The shim is one C++ translation unit — one .cpp file, compiled as a unit — built
by cppgo, not by your build. The command is assembled from the flags discovered
for the header (compile_commands.json or .clangd), the compiler named by
-cxx or found as g++/clang++, -fPIC -shared (-fPIC makes the code
usable from whatever address it is loaded at, -shared asks the linker for a
shared object), and your library as a link input:
- the library is passed as
-L<dir> -l:<file>, so the linker records its soname — the name written inside a shared object that the loader resolves at run time, normally a versioned file name likelibfoo.so.1— or its file name when it declares no soname, rather than a build path; - the shim is linked with
-Wl,-rpath,$ORIGIN, so the library is found beside the shim at run time.
A shim that does not compile is a generation error carrying the compiler’s diagnostic and the binding whose thunk it is in, never a symbol that fails at the first call.
Loading: the two objects
Loading happens through the dynamic loader — the system component that opens a shared object at run time and resolves the names in it — and the generated package loads your library first and the shim second. A shim symbol is therefore always resolvable; a missing symbol is a load error naming the shim and the advice to regenerate. A failure to find your library at this step is tolerated: the shim names it as a dependency, so loading the shim brings it in anyway.
The shim’s DT_NEEDED entry — the list of other shared objects a shared object
declares it needs, which the loader opens with it — names your library by its
soname (or, when the library declares none, by its file name), and cppgo links
the shim with -Wl,-rpath,$ORIGIN. $ORIGIN in a run path means “the directory
this object was loaded from”, so an object placed beside the shim is found
without any environment variable. The pairing is recorded in the shim and can
be read back rather than assumed:
$ readelf -d /opt/mytool/libcppgo_mytool.so | grep -E 'NEEDED|RUNPATH'
0x0000000000000001 (NEEDED) Shared library: [libmylib.so.1]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
(readelf -d prints a shared object’s dynamic section, the table the loader
reads; the two grep patterns select the dependency list and the run path.)
Your library is looked for in this order, and the first match wins:
CPPGO_LIBRARY_PATH(the environment);CppgoLibraryPath(the package variable);- the shim’s own directory, when the shim was opened by a path that has one and the library sits there;
- the recorded name, resolved by the dynamic loader’s normal search.
Points 1 and 2 are read when the package is opened; a later change is not
observed. Open("") — the load function the generated package exports — uses the
name the package was generated with, which the loader resolves through its own
search path — not the current directory.
The deployment steps that follow from this are in Ship a program that uses a binding.
Embedded objects
cppgo.OpenFS takes a file system of embedded bytes, extracts every .so in it
to a private cache, and loads one of them by name. Every .so is extracted, not
just the one asked for: the shim’s DT_NEEDED entry names your library, and the
loader can only find it if both are in one directory. Entries are flattened into
a single directory (base names kept), because that is the only layout in which
$ORIGIN sees a sibling. Two entries with the same base name are an error, and
other files are ignored.
os.UserCacheDir()/cppgo/<sha256 of the whole set>/<file name>
The digest covers every object of the set — name, size and SHA-256 of the contents — in file-name order. Two programs embedding the same bytes share one entry; a change to any byte of any object lands in a new directory.
- the cache root and every entry directory are
0700; every object is0600. A mode something else loosened is tightened on the next call; - an object is written to a temporary file in the same directory (
O_EXCL,0600, randomised name) and renamed into place, so no reader sees a partial object.O_EXCLmeans “create the file, or fail if it already exists”, which is what keeps two processes from writing the same name; - an existing file is reused only when its size and SHA-256 both match. A truncated or corrupted entry is replaced;
- a path that is not a private directory, or a symbolic link where an object
should be, is refused with
cppgo.ErrInsecureCache(runtime reference) instead of being used. Those are shapes another local user could have prepared in advance.
The shim must be linked with -Wl,-rpath,$ORIGIN for the sibling to be found.
cppgo passes that flag when it builds the shim; a user library that has its own
siblings has to pass it too.
The task, the override and what embedding does not buy are in Embed the C++ side in the Go binary.
Callbacks
A Go function handed to C++ becomes a callback slot: one entry in a
process-wide table of identical five-byte CALL instructions. C++ receives that
entry’s address and calls it like any C function pointer, and the CALL lands in
one shared assembly routine. The routine works out which slot it was entered
through — the slots sit a fixed distance apart, so the return address identifies
one — saves the six integer and eight floating-point argument registers, and
calls runtime.cgocallback, a copy of the Go runtime’s own entry for a callback
arriving from C. The runtime then runs the Go function on the goroutine that made
the original call, on the same operating-system thread (an M, in the
runtime’s vocabulary), with the C frames left where they are.
Three consequences are structural:
- the call that may invoke the callback must be made through the reentrant carrier, because the runtime accepts a callback only on a thread that is already inside a foreign call; on the direct trampoline the same callback kills the process;
- a callback carries at most six INTEGER and eight SSE arguments and one register result, because the routine reads the register file only — the fixed set of CPU registers the calling convention passes arguments in, and nothing on the stack;
- a panic inside the callback is captured, the result is zeroed, and the binding re-panics in Go when the foreign call returns. It never unwinds into C++.
Related
- Why the split exists: Why cppgo exists.
- Every carrier, construct and cost: Carriers and the construct table.
- What a call costs: Performance.
- What the mechanisms stop at: Limits.