Why Minus Twelve?
A colleague sent me a decompiled length check in 2019 asking why it read twelve bytes backwards. Four years later the answer turned out to be statically linked Boost, welding a 2010 std::string layout permanently into the binary.
In 2019 a colleague sent me a note that ended with “help please =/”. He’d been picking at a crash in a 2010-era commercial Linux app, 32-bit, that wouldn’t run on anything modern, and he’d gotten deep enough to find something that made no sense to either of us.
It sat for four years. I picked it back up in 2023 on parental leave, mostly because it had been bugging me, and that’s when it finally came apart. This is that.
The note
He was looking at a Boost path constructor, decompiled, and it opened with this:
if ( !*(_DWORD *)(*(_DWORD *)str_path2 - 12) )
// allocate and throw a runtime_error
He’d checked it against the Boost source, so he knew what it meant: if the path
you handed it is empty, throw. Fine. Except it was throwing for ".", which is
one character.
His note, which I’ve kept for six years:
“why minus 12? for some reason
str_path2points to the inlined c-string and allstd::stringmetadata (length, capacity, allocator) is BEFORE that pointer. I created some minimal examples and I always got a memory layout of allocator, length, inline data for strings with length < 15.”
The whole problem is sitting right there in the open and neither of us saw it. He wrote minimal examples, compiled them, checked the layout, and got a different answer than the disassembly showed. His conclusion was that either Boost was doing something inscrutable or libstdc++ was being libstdc++, which, having read that header, is not an unreasonable place to land.
The obvious theory, which is wrong
The story you’ll hear about binaries like this, assuming you attend the kind of
parties where this comes up, is that libstdc++ removed the copy constructor
_ZNSsC1EjcRKSaIcE and replaced it with _ZNSsC2EjcRKSaIcE, so the imports
stopped resolving.
That’s not what happened. C1 and C2 aren’t versions of each other. The
Itanium C++ ABI §5.1.4.3 defines a family:
<ctor-dtor-name> ::= C1 # complete object constructor
::= C2 # base object constructor
::= C3 # complete object allocating constructor
::= D0 # deleting destructor
::= D1 # complete object destructor
::= D2 # base object destructor
Virtual inheritance splits construction in two… a complete object constructs
its virtual bases, the same class used as a base subobject doesn’t, because the
most-derived constructor already did. Hence two bodies. For a class with no
virtual bases, which std::string is, they’re identical, and the spec says so
in the next line: “Some of the symbols for constructor and destructor variants
are optional.”
I checked a shipping library rather than take that on faith (LOL). Debian trixie,
libstdc++.so.6.0.33, GCC 15:
00000000000f8a30 W _ZNSsC1EmcRKSaIcE@@GLIBCXX_3.4
00000000000f8a30 W _ZNSsC2EmcRKSaIcE@@GLIBCXX_3.4
Same address, both exported, both still tagged GLIBCXX_3.4, which is the label
from GCC 3.4 in 2004. 210 old copy-on-write string symbols are still exported by
a current libstdc++, all fifteen _Rep internals included, the refcount
machinery itself. libstdc++’s ABI policy lists “Deleting an exported symbol” as
prohibited, and promises binaries linked against an old release keep running
against new ones.
So I built a 32-bit program with -D_GLIBCXX_USE_CXX11_ABI=0, importing
_ZNSsC1EjcRKSaIcE@GLIBCXX_3.4 and three friends, and linked it against GCC
15’s libstdc++. Runs fine. The old ABI is still there and still serviced.
There’s a real thing buried in the folk version though. The C1 and C2 aliases don’t always ship together or carry the same version tag:
_ZNSsC1EOSs@@GLIBCXX_3.4.14 # GCC 4.5
_ZNSsC2EOSs@@GLIBCXX_3.4.15 # GCC 4.6
One function, two entry points, exported a release apart. Which variants an explicit instantiation emits has moved around across GCC versions, and that does produce link-time surprises. It doesn’t freeze a binary.
What the old string actually was
Copy-on-write std::string is one pointer. That’s the entire object. It points
at character data, and the bookkeeping sits immediately BEFORE that data:
┌──────────┬──────────┬──────────┬─────────────────────┐
_Rep → │ length │ capacity │ refcount │ "hello\0" │
└──────────┴──────────┴──────────┴─────────────────────┘
▲
│
std::string ─┘ (one pointer, that's it)
sizeof(std::string), measured:
| 32-bit | 64-bit | |
|---|---|---|
COW (_GLIBCXX_USE_CXX11_ABI=0) |
4 | 8 |
| SSO (default, GCC 5+) | 24 | 32 |
In 1998 that was a good design. Copying a string was a pointer copy and an increment, so passing by value cost nothing, which mattered a lot in a language that wouldn’t get move semantics for another thirteen years. Four bytes of refcount bought all of it.
Threads killed it. Every copy touches a shared refcount, so every copy is an
atomic, and by the mid-2000s that cost more than just copying the sixteen-odd
bytes most strings actually contain. Then C++11 finished it: operations on
distinct string objects mustn’t race, operator[] and begin() mustn’t
invalidate, c_str() must be O(1) and non-mutating. Copy-on-write can’t do
those, because a read through a shared buffer might have to unshare first. It
wasn’t just slow by then, it was non-conforming.
The replacement is a 24-or-32-byte struct: pointer, length, and a union of capacity with a small inline buffer. Up to 15 characters live inside the object. No allocation, no refcount, no sharing.
You can’t translate between those at a boundary. It isn’t a representation difference you can shim, it’s semantic… in one model a copy shares a buffer, in the other it copies. No glue makes a 4-byte handle and a 24-byte value the same thing.
GCC 5.1 shipped both and let _GLIBCXX_USE_CXX11_ABI pick. The new one lives in
an inline namespace, which is why you see std::__cxx11 in mangled names, and
that was the smart part… the break was engineered to fail at link time. Mix
the two and you get an unresolved symbol, not a corrupted struct at runtime. The
quieter drift of the 4.x era had nothing like that.
Minus twelve
Here’s the header, from libstdc++-v3/include/bits/basic_string.h, unchanged
from at least GCC 4.4.2 through 4.9.0:
struct _Rep_base
{
size_type _M_length;
size_type _M_capacity;
_Atomic_word _M_refcount;
};
and the data pointer comes from _M_refdata(), which is a very polite name for
reinterpret_cast<_CharT*>(this + 1). One whole _Rep past the header. Three
4-byte fields on a 32-bit target. Twelve. _M_length first, so it lands at
exactly data - 12.
The minus twelve isn’t inscrutable. It’s an ordinary inlined empty() check,
compiled in 2010, and it’s correct.
I confirmed it on a 32-bit COW build of std::string s("."):
COW len=1 sizeof=4 *(data-12)=1 *(data-8)=1 *(data-4)=-1
Length 1, capacity 1, refcount −1, the sentinel for a string that can’t be shared. Every field exactly where the disassembly reaches for it.
Same "." under the modern layout:
SSO len=1 sizeof=24 data-inside-object=1 *(data-12)=20
No header. Short string, so the characters live INSIDE the object at offset +8,
which puts data - 12 at offset −4. Four bytes before the string even begins,
in whatever happened to be sitting there. That run it was 20. Could be anything.
When it’s zero, a one-character path is empty and something throws.
Every symbol resolved. Control transferred correctly, arguments passed correctly. The program read twelve bytes back from a pointer, into memory that had stopped meaning what it used to mean, got a number, and believed it.
That’s also why hooking the one function and forwarding it got him past exactly one failure. Every string crossing that boundary carries the same disagreement, so there’s no single call to fix… which is most of why it stayed unsolved for four years, because it doesn’t present as an ABI problem. It presents as an unrelated bug, and then another one.
Where the static linking comes in
None of this traps a binary on its own. _GLIBCXX_USE_CXX11_ABI=0 gets you the
old string, leave it off and you get the new one. It’s a build flag.
Unless you can’t rebuild the thing that set it.
This binary statically links Boost. Three components, and it’s always these
three: filesystem, regex, chrono. Boost is mostly templates, so “linking” means
Boost’s instantiations were compiled against the libstdc++ of the day and
inlined straight into the executable. The code that builds a _Rep, bumps the
refcount, assumes a string is one pointer wide, is IN the binary.
The binary doesn’t use an ABI. It contains one.
There’s nothing to relink, because there’s no separate Boost to rebuild. Boost’s
own distribution has encoded the toolset into library filenames forever, the
-vc71 in libboost_regex-vc71-mt-d-x86-1_34.lib. Nobody puts the compiler
version in a filename because things are going well.
What I ended up shipping in 2023: carry the historical libstdc++.so.6.0.13
next to the program and rewrite every mangled _Z* GOT entry to point into it.
A PLT-hook swap at load. 6.0.13 is GCC 4.4.2, symbol version
GLIBCXX_3.4.13, exactly the vintage the rest of the story implies.
I hated it, and spent a long time looking for something cleaner before accepting
there isn’t one. But it works for a reason nothing else does: it IS the code
that implemented that ABI. Not a reimplementation. The original. Restore the
library that still keeps _M_length twelve bytes before the data and both
halves of the contract agree again.
Is it rare?
The COW→SSO break, no, not remotely. One of the best-documented compatibility
events in the toolchain’s history, and it broke a mountain of software. If
you’ve ever seen undefined reference to
'foo(std::__cxx11::basic_string...)', that’s this.
Whether the specific symbol churn around 6.0.13 was routine or unusual I couldn’t establish, and I’d rather say so than guess. The version spread on the C1/C2 aliases looks like ordinary release-to-release variation, but that’s me inferring from one symbol table, not reading a changelog.
The bill
In 2010 there was no std::filesystem, no std::regex, no std::chrono. All
three arrived later, regex and chrono in C++11 and filesystem in C++17, and all
three are more or less direct descendants of the Boost components this binary
uses. Reaching for Boost wasn’t a shortcut, it was the only answer available.
A successor product dropped Boost for the standard equivalents and the problem went away, for new builds. Everything already shipped stays exactly as frozen as it was.
The easy moral is don’t statically link C++. The one I actually took is about
time: a statically linked C++ library makes that library’s ABI a permanent
property of your binary, and every assumption its templates made about
std::string is welded in with no seam left to reopen.
And nothing tells you. The binary doesn’t announce which ABI it froze, the symbols resolve, the disassembly looks right because it IS right. What quietly stops being true is that the current version of the library, the one you’ll open when you go to check, describes your binary. Your reference drifts away from your artifact and no tool anywhere notices. You find out because a path of length one is empty.
Every place I’ve hit statically linked Boost since, I’ve pulled it out. Not because Boost is bad, but because I know what it costs and roughly when the invoice arrives. Much cheaper to decide that at the start than while you’re staring at a decompiled length check wondering why minus twelve.
Thoughts? Curious whether anyone’s hit the quieter 4.x-era version of this,
where there’s no __cxx11 marker to tip you off at all.
Sources: the Itanium C++ ABI
§5.1.4.3 on constructor and destructor variants, the libstdc++
ABI policy and
dual ABI docs,
the GCC 5 release notes, and the
copy-on-write string header itself,
basic_string.h at GCC 4.4.2,
cross-checked against 4.9.0. Boost library naming from their
getting started guide.
Symbol tables and sizeof figures measured against libstdc++.so.6.0.33 on
Debian trixie, not quoted.
August 2026