Fundamentals 27 min read

Do You Really Understand std::string’s Yin‑Yang Layout Across DLL Boundaries?

The article dissects why a seemingly safe cross‑library call in C++ can crash, covering allocator mismatches, ABI differences for std::string, exception type_info duplication, and presents three practical mitigation strategies ranging from pure C interfaces to unified runtimes and custom deleters.

IT Services Circle
IT Services Circle
IT Services Circle
Do You Really Understand std::string’s Yin‑Yang Layout Across DLL Boundaries?

1. Why a seemingly safe cross‑library call crashes

Assume a host program loads a custom operator plugin with dlopen and calls a function that returns a std::vector<float>*. The plugin allocates the vector with new and the host deletes it with delete. On the developer's machine the program exits cleanly, but on a client machine it aborts with free(): invalid pointer or a corrupted heap.

#include <vector>
extern "C" std::vector<float>* make_weights(int n) {
    return new std::vector<float>(n, 1.0f);
}

// host
void* h = dlopen("./libmyop.so", RTLD_NOW);
auto make = (std::vector<float>*(*)(int))dlsym(h, "make_weights");
std::vector<float>* w = make(1);
delete w;

The crash is not caused by the delete statement itself; the real problem is that the allocator used on the plugin side is not the same as the one used on the host side.

2. delete across modules

Both the plugin and the host rely on the global operator new and operator delete provided by the C++ runtime. If the two modules link against different copies of the runtime (for example, a hidden memory‑pool allocator in the plugin or a statically linked CRT on Windows), the pointer handed to free no longer points to a chunk that the host allocator recognises, resulting in invalid pointer or heap corruption.

On Linux the default build links both sides to the same libstdc++.so, so the symbols _Znwm (operator new) and _ZdlPv (operator delete) resolve to the same implementation and the program works. Adding -fvisibility=hidden or using a private memory pool hides those symbols, causing each side to have its own allocator and the crash reappears.

On Windows each DLL can have its own CRT heap ( /MD shares the heap, /MT creates a private heap). Crossing DLL boundaries with mismatched CRTs always leads to heap corruption, regardless of the code being identical.

3. Passing STL containers across boundaries

Even when the allocator is the same, the layout of the container object itself can differ. std::string has two ABI versions: the modern C++11 ABI (size 32, small‑string optimisation) and the legacy COW ABI (size 8, a pointer to a reference‑counted buffer). Compiling the host with -D_GLIBCXX_USE_CXX11_ABI=0 while the plugin uses the default ABI makes the two std::string types incompatible. The host interprets the 32‑byte layout as an 8‑byte object, reads garbage as the length field, and either crashes or produces garbled output.

When both sides use the same ABI, the container still stores a pointer to heap memory allocated by the side that created it. If the host later destroys the container, its allocator<float>::deallocate runs in the host’s heap, freeing memory that was allocated by the plugin’s allocator – the same mismatch described in section 2.

4. Exceptions across boundaries

Throwing a C++ exception from a plugin compiled with RTLD_LOCAL creates a separate type_info object ( _ZTI9MyOpError) inside the plugin. The host’s catch clause compares the thrown object's type_info pointer with its own copy; because the pointers differ, the catch fails, the exception falls through to a catch‑all or triggers std::terminate.

Modern GCC compares type_info by name (a string) when the symbols are hidden, so the exception may still be caught, but this relies on the symbols being visible and the name comparison being enabled. Older GCC performed pointer comparison, which always fails in this scenario.

5. Mitigation strategies

5.1 Pure C interface with opaque handles

Expose only C‑compatible functions and use an opaque pointer (e.g., void*) as a handle. The plugin provides create and destroy functions that allocate and free the object inside the same module, and all errors are returned as integer codes.

#ifdef __cplusplus
extern "C" {
#endif

typedef struct MyOp MyOp;

MyOp* myop_create(int n_channels);
int   myop_forward(MyOp* op, const float* in, int len, float* out);
void  myop_destroy(MyOp* op);

#ifdef __cplusplus
}
#endif

This eliminates cross‑module allocator and exception issues, but sacrifices type safety, RAII, and requires careful lifetime management (the handle must not outlive the DLL).

5.2 Unified runtime (single ABI)

Force every module to link against the same shared C++ runtime ( libstdc++.so on Linux, ucrtbase.dll on Windows) and use the same _GLIBCXX_USE_CXX11_ABI setting. Load modules with RTLD_GLOBAL and export type_info symbols with default visibility. With a single allocator, a single std::string layout, and a single type_info instance, the three failure modes disappear.

This approach works well in a monorepo where the build environment is fully controlled, but it cannot be applied when third‑party closed‑source plugins are involved.

5.3 Transfer ownership with std::shared_ptr and a custom deleter

The allocating module creates a shared_ptr that stores a lambda deleter calling the module’s own destroy function. The receiving module treats the pointer as a normal shared_ptr and never needs to know how to free the object.

std::shared_ptr<MyOp> myop_make_shared(int n) {
    return std::shared_ptr<MyOp>(myop_create(n),
        [](MyOp* p){ myop_destroy(p); });
}

The downside is that the deleter lives in the allocating module; if the module is dlclose d while the shared_ptr still exists, the deleter will jump into unmapped memory and crash. It also implicitly requires the two modules to share the same shared_ptr ABI.

6. Summary

Root cause of all three crashes: a symbol that must be unique on both sides (allocator, ABI layout, or type_info) is duplicated.

Delete mismatch: different operator new/delete implementations.

STL container mismatch: different _GLIBCXX_USE_CXX11_ABI settings and allocator ownership.

Exception mismatch: separate type_info objects caused by RTLD_LOCAL and hidden visibility.

Choose a mitigation based on the deployment scenario:

C interface + opaque handle: safest for open plugin ecosystems; loses type safety and RAII.

Unified runtime: ideal for fully controlled monorepos; impossible with third‑party binaries.

Shared_ptr with custom deleter: keeps C++ ownership semantics but requires the allocating module to stay loaded.

Statement: This article is based on thorough review of authoritative sources and is presented in a neutral, fact‑based manner.
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Memory ManagementC++ABIDynamic Libraries
IT Services Circle
Written by

IT Services Circle

Delivering cutting-edge internet insights and practical learning resources. We're a passionate and principled IT media platform.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.