Carbon's C++ Interoperability: An Architecture Note on Incremental Migration
Carbon's bidirectional C++ interoperability via extern "C++" linkage enables incremental migration without wrappers. This architecture note covers the minimal design, trust boundaries, CI verification steps, failure modes, and conditions that would force a redesign.
21 Feb 2026, 11:16 UTC

Requirements: Why Interoperability Matters for Migration
Large C++ codebases rarely support a big-bang rewrite. Teams need to replace modules one at a time while the rest of the system keeps running. Carbon's design addresses this by making C++ interoperability a first-class feature, not an afterthought. The requirement is straightforward: call C++ functions from Carbon and Carbon functions from C++ without hand-written wrappers, preserving ABI compatibility and exception semantics across the boundary.
Smallest Suitable Design: The extern "C++" Linkage
Carbon achieves bidirectional interop through a single language mechanism: extern "C++" linkage specifications. This mirrors C++'s own extern "C" but targets the C++ mangling and calling convention directly. The Carbon compiler emits thunks—small adapter functions—that reconcile differences in name mangling, parameter passing, and exception handling.
// Carbon side: importing a C++ function
package Geometry api;
extern "C++" fn CalculateArea(width: f64, height: f64) -> f64;
// Carbon side: exporting a function for C++
extern "C++" fn ScaleFactor() -> f64 {
return 1.5;
}
The extern "C++" declaration tells the Carbon compiler to use the Itanium C++ ABI (on Linux/macOS) or the Microsoft C++ ABI (on Windows) for that symbol. No separate IDL, no glue code, no runtime reflection.
Trust and Data Boundaries
The interop layer trusts that both sides honor the same ABI contract. This creates implicit trust boundaries:
- Layout compatibility: Carbon structs marked
extern "C++"must match the C++ class layout exactly—including base classes, virtual tables, and bit-field packing. - Exception safety: Carbon uses a zero-cost exception model compatible with Itanium/MSVC. Throwing across the boundary works, but only if both compilers agree on the exception object layout and personality routines.
- Ownership semantics: Raw pointers passed across the boundary carry no lifetime guarantees. Carbon's ownership model (borrow/own) does not extend into C++; you must document and enforce lifetimes manually.
Violating any of these assumptions produces undefined behavior that typically manifests as silent corruption or crashes in unrelated code paths.
Operational Checks: Verifying the Boundary
Before committing to incremental migration, run these checks in your CI pipeline:
- ABI smoke test: Compile a minimal C++ library and a Carbon executable that calls into it. Verify the linker resolves all symbols and the executable runs.
# C++ library (libgeometry.cpp) extern "C++" double CalculateArea(double w, double h) { return w * h; } # Carbon main (main.carbon) package Main api; extern "C++" fn CalculateArea(width: f64, height: f64) -> f64; fn Main() -> i32 { let area = CalculateArea(3.0, 4.0); assert(area == 12.0); return 0; } # Build commands (Linux, Carbon toolchain v0.1+) clang++ -std=c++20 -fPIC -shared -o libgeometry.so libgeometry.cpp carbon build --lib-path=. --link=geometry main.carbon ./main - Exception round-trip: Throw a C++ exception, catch it in Carbon, re-throw, and catch in C++. Confirm the exception type and message survive.
// C++ #include <stdexcept> extern "C++" void ThrowError() { throw std::runtime_error("boom"); } // Carbon extern "C++" fn ThrowError(); fn Test() { try ThrowError(); catch (e: std::runtime_error) { Print(e.what()); // should print "boom" throw e; // re-throw same object } } - Layout sanity: Use
static_assert(sizeof(CarbonStruct) == sizeof(CppStruct))on both sides for every shared type. Automate this with a shared header parsed by both compilers.
Failure Modes
| Symptom | Likely Cause | Mitigation |
|---|---|---|
| Linker error: undefined reference to mangled name | Missing extern "C++" on Carbon side or mismatched namespace/class qualification | Verify the exact mangled name with nm -C (Linux) or dumpbin /symbols (Windows) |
| Crash on function entry/exit | Calling convention mismatch (e.g., Carbon using register passing, C++ expecting stack) | Ensure both compilers target the same ABI version; avoid varargs across the boundary |
| Exception caught as unknown type / terminate() | Personality routine mismatch or exception object layout difference | Use standard exception types (std::exception derivatives); avoid custom exception hierarchies across the boundary |
| Silent data corruption in struct fields | Padding/alignment differences, bit-field ordering, or virtual base layout | Add static_assert for field offsets; prefer POD types for shared data |
| Template instantiation missing | C++ template used in Carbon extern "C++" signature without explicit instantiation | Provide explicit instantiation definitions in a C++ TU; wrap templates in non-template C++ functions |
Conditions That Would Change the Design
The current interop model assumes a stable C++ ABI and a Carbon compiler that emits compatible thunks. The design would need revision if:
- Carbon adopts a different exception model (e.g., result-based error handling as default). The thunk layer would need to translate between models, adding overhead and complexity.
- C++ modules (C++20) become the primary distribution mechanism. Header-based
extern "C++"declarations may not map cleanly to module interfaces; Carbon would need module-aware import syntax. - ABI breaks in major compiler versions (e.g., MSVC changing vtable layout). Carbon would need versioned ABI targets, similar to
-fabi-versionin GCC. - Carbon introduces garbage collection or moving GC. Raw pointer passing across the boundary would become unsafe without pinning or handle APIs.
Practical Migration Pattern
Start with a leaf module—one that has no C++ dependents. Replace its implementation with Carbon while keeping the C++ header as the contract. Build both the original C++ and new Carbon versions in CI; run identical tests against both. Only after parity is verified, switch the build to link the Carbon version. Repeat module by module.
This pattern works because the interop layer is symmetric: the C++ callers don't know (and don't need to know) that the callee is now Carbon. The risk stays localized to the module boundary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.