<< Home

Chapter 27: Interoperability and ABI Concerns

C++ interfaces often meet other languages, older binaries, and platform level APIs. Working across these boundaries requires stable linkage, predictable data layout, careful exception policy, and a clear strategy for foreign function interfaces. This chapter explains how C++ cooperates with C, how name mangling affects linkage, how exceptions behave across boundaries, and how to design safe data layouts and portable FFI layers.

Extern "C" and linking with C

C++ normally decorates symbol names to encode types and calling conventions. C does not. extern "C" suppresses that decoration for declared symbols so C and C++ can link together correctly. This is the cornerstone of almost all cross language system interfaces.

Declaring C style interfaces

Wrap C compatible declarations in an extern "C" block. Function signatures must use C compatible types and avoid templates, references, and classes with non trivial behavior.

extern "C" {
  int add_ints(int a, int b);
}

C code can now link against the produced object file. In headers intended for both languages, guard the block with macros so C sees plain declarations and C++ applies the linkage specifier.

#ifdef __cplusplus
extern "C" {
#endif

int add_ints(int a, int b);

#ifdef __cplusplus
}
#endif

Sharing structures with C

Shared structures must contain only C compatible elements. Avoid templates, constructors, destructors, or non standard layout types. Keep alignment predictable by sticking to fundamental types.

struct point {
  double x;
  double y;
};
💡 When passing buffers across boundaries, prefer pointer plus size pairs. This avoids hidden assumptions and keeps memory ownership explicit.

Calling C from C++

You can freely include C headers in C++ code. Many system libraries follow this pattern. When mixing languages in a build, compile C files as C, not as C++.

#include <math.h>
double r = sqrt(2.0);

Name mangling and ABI stability

C++ needs mangled names to encode overloaded functions, namespaces, and types. Compilers differ in their mangling rules, and the language standard does not specify them. As a result, binary interfaces are fragile across compiler families and sometimes across versions of the same compiler.

How mangling works

The compiler builds symbol names that describe the full signature. This enables overloaded resolution at link time. Tools such as nm or objdump show these long strings when inspecting binaries.

// Unmangled form
int compute(int a, double b);

// Mangled form (varies by compiler)
// _Z7computeid

ABI boundaries and stable surfaces

Keep binary interfaces stable by exposing minimal C style entry points or by using dedicated ABI stable layers such as COM like interfaces or C wrappers. Avoid exposing templated or inline heavy functions in a public binary ABI. Prefer header only libraries when the whole interface is inline and template based, so no binary surface exists.

⚠️ Changing a class layout or inline behavior can silently break existing binaries. Recompile all dependents whenever public headers change.

Vendor ABIs and cross compiler issues

Different compilers on the same platform may share a common ABI only for fundamental types. Complex C++ types such as std::string or std::vector often differ. Avoid passing such types across shared library boundaries unless all parties agree on the exact compiler and standard library version.

Exceptions across boundaries

Exceptions must not cross ABI boundaries where the other side cannot catch them. Throwing an exception into foreign code leads to undefined behavior because the runtime model does not match and destructors may not run.

Sealing boundaries

Wrap calls near the boundary to catch any exceptions and translate them into error codes or status values. This keeps foreign callers safe and preserves predictable control flow.

extern "C" int do_work_c(int x, int* out) {
  try {
    *out = do_work_cpp(x);
    return 0;
  } catch (...) {
    return -1;
  }
}

Error reporting models

Combine simple error codes with detailed logs or structured results. For richer detail, write small plain old data structures that cross the boundary cleanly, then interpret them on the C++ side.

Long jumps and foreign runtimes

Languages such as C or Fortran may use setjmp and longjmp. These constructs bypass C++ destructors if they cross into C++ controlled scope. Restrict their use to pure C frames or wrap them with strict guards.

Data layout, padding, and alignment

Interoperability depends on consistent memory layout. Standard layout types have predictable field order and no surprising padding at the start, although compilers may insert padding between fields to satisfy alignment requirements. You must design shared structures so every participant agrees on layout and alignment.

Standard layout rules

A standard layout type has no virtual functions, no virtual base classes, and no multiple inheritance with tricky ordering. Its members follow defined order, and the first member starts at offset zero. This makes it suitable for cross language sharing.

struct header {
  uint32_t magic;
  uint16_t version;
  uint16_t flags;
};

Padding and alignment costs

Compilers align fields so that accesses stay efficient. Insert padding manually only when necessary. Aligning fields by decreasing size often reduces padding.

struct mixed {
  uint64_t a;
  uint32_t b;
  uint16_t c;
  uint8_t  d;
  // … compiler may insert padding at the end
};
💡 Use static_assert(sizeof(T) == expected) to confirm layout stability where ABI matters. This reveals silent padding changes after edits.

Alignment attributes and portability

Attributes such as alignas request specific alignment. Use them sparingly. Over aligned types may conflict with foreign runtimes or serialization formats. Validate alignment on each supported platform.

struct alignas(32) buffer {
  std::byte data[128];
};

FFI strategies and portability

Foreign function interfaces connect C++ with higher level languages such as Python, Rust, Java, or C#. A portable strategy keeps the C++ core in a clean C layer, then wraps that layer in the target language’s binding system.

The C shim model

Keep your C++ logic internal and expose thin extern "C" functions. Bindings in other languages call those functions and pass plain data. This reduces dependency on C++ ABI details.

extern "C" double dot_product(const double* a, const double* b, int n);

Automated binding tools

Many ecosystems contain tools that generate bindings from C headers or metadata. Examples include SWIG, ctypes, cffi, JNI, and P/Invoke. Each tool has its own calling convention assumptions. Use stable C interfaces as input to avoid chasing C++ ABI changes.

Memory ownership conventions

Define ownership precisely. If C++ allocates, provide a corresponding free function. If a foreign language allocates, confirm that C++ keeps only borrowed pointers. Mixing ownership rules invites leaks or double frees.

extern "C" char* make_string_copy(const char* s);  // caller frees
extern "C" void  free_string_copy(char* p);

Marshalling complex data

For more complex shapes such as trees or graphs, marshal data through plain arrays or flat buffers. Avoid sending deep object graphs directly. Many languages convert such buffers into their internal structures quickly and reliably.

⚠️ Do not expose STL containers or C++ only types to other languages. Their layout and exception behavior are not portable, and versions differ across standard libraries.

Testing boundaries

Cross language tests catch assumptions early. Validate basic round trips for primitive types, pointers, arrays, and error cases. On each platform, automate a full cycle of build, link, run, and memory analysis so boundary mismatches surface immediately.