Skip to content

libstdc++ string:从 COW 到 SSO 的 ABI 迁移

COW 时代(GCC 4.x,ABI v1)

libstdc++ 在 C++11 之前使用写时复制。string 对象本身只有 1 个指针(32 位系统下 4 字节!):

 string a            string b            string c
 ┌────────────┐      ┌────────────┐      ┌────────────┐
 │  _M_p  ●───┼──┐   │  _M_p  ●───┼──┐   │  _M_p  ●───┼──┐
 └────────────┘  │   └────────────┘  │   └────────────┘  │
                 │                   │                   │
                 ▼                   ▼                   │
  堆内存:  ┌───────────────────────────┐                 │
           │ _M_length    = 5          │                 │
           │ _M_capacity  = 15         │  共享块          │
           │ _M_refcount  = 3  ◄───────┼─────────────────┘
           ├───────────────────────────┤
           │ H  e  l  l  o  \0        │ ← _M_p 指向此处
           └───────────────────────────┘

COW 的致命问题(C++11 后不合规)

cpp
string a = "hello";
char& c = a[0];   // 获取引用
string b = a;      // COW:b 和 a 共享数据,refcount=2
c = 'H';           // 写入触发 lazy copy → b 的数据被影响
                   // 但 c 引用的是 a[0],COW 实现可能违反标准

全局空字符串实例

 ┌────────────────────────┐
 │ _M_length   = 0        │
 │ _M_capacity = 0        │
 │ _M_refcount = -1       │ ← 永不释放
 ├────────────────────────┤
 │ \0                     │
 └────────────────────────┘
 所有空 string 的 _M_p → 此全局实例

SSO 迁移(GCC 5.0,ABI v2)

  basic_string 对象 (32 字节)
  ┌───────────────────────────────────────────────────────────────────────┐
  │ 偏移   0 ──  7  │ _M_dataplus._M_p       (指向实际字符数据)          │
  │ 偏移   8 ── 15  │ _M_dataplus._M_alloc   (空 allocator, EBO 压缩)   │
  │ 偏移  16 ── 23  │ union: _M_string_length │ _M_local_buf[16] 前 8 字节│
  │ 偏移  24 ── 31  │ _M_allocated_capacity  │ _M_local_buf[16] 后 8 字节│
  └───────────────────────────────────────────────────────────────────────┘

  Short(≤15):_M_p 指向自身的 _M_local_buf
  Long(>15):_M_p 指向堆分配

  SSO 容量 = 16 - 1 = 15 字节  (比 libc++ 的 22 少 7 字节)
  sizeof = 32 字节              (比 libc++ 的 24 多 8 字节)

双 ABI 共存

  -D_GLIBCXX_USE_CXX11_ABI=0          默认 (=1)
  ┌──────────────────────────────┐    ┌────────────────────────────┐
  │ namespace std {              │    │ namespace std {            │
  │   inline namespace __cxx11 { │    │   // (无内联命名空间)      │
  │     class basic_string;      │    │   class basic_string;      │
  │   }                          │    │ }                          │
  │ }                            │    │                            │
  └──────────────────────────────┘    └────────────────────────────┘

  ABI v1: _ZNSt12basic_stringIcSt11char_traitsIcESaIcEE...
  ABI v2: _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE...
                   ^^^^^^^  命名空间注入 + [abi:cxx11] tag

abi_tag 属性将 ABI 标签编码到符号名中,使同一 .so 中可以同时存在两种 ABI 的 string 类型。

ABI 稳定的代价:libstdc++ 不得不保留次优实现。例如 std::list::size() 必须是 O(1)(ABI 锁定),即使这使得 splice 需要 O(n) 的计数更新。

用户 API

用户侧看到的是 std::string 的构造、拼接、修改与 data()/c_str() 访问;本文现有正文主要解释这些 API 背后的 COW→SSO ABI 迁移。

标准语义

C++11 对 std::basic_string 施加了多项语义约束,直接终结了 COW 实现的合规性:

标准要求条款COW 实现 (ABI v1) 的冲突
连续存储 (contiguous storage)[string.require]COW 共享块本身是连续的,但写入时 _M_mutate() 可能重新分配,导致旧指针悬空
operator[] 返回 reference / const_reference[string.access]COW 版 operator[] 非 const 重载需要触发 _M_unshare() 分离副本;若调用者持有之前的 const_reference,该引用指向的内存可能已变
data() 返回 const char* (C++11);C++17 增加非 const data()[string.accessors]COW 版 data() 可能返回共享缓冲区地址;非 const data() 要求可写连续内存,与共享语义矛盾
非变异操作不使引用/指针/迭代器失效[string.iterators]COW 的 operator[] constbegin() const 等可能触发 _M_unshare()(如果 rep 是共享的),间接导致内存重新分配,使已获引用失效
c_str()data() 返回相同指针[string.accessors]COW 实现中两者语义等价,但若 _M_unshare() 在两者之间触发,c_str() 返回的指针可能失效
cpp
// C++11 标准要求此代码在无 UB 的情况下始终正确
std::string a = "hello";
const char& r = a[0];      // 获取 const 引用
std::string b = a;          // 拷贝
assert(&r == &a[0]);        // 引用仍指向 a 的数据——COW 下若 r 触发了 unshare 则可能不成立

// C++17 增加的非 const data() 要求可写连续内存
std::string s = "test";
char* p = s.data();         // C++17 合法,p 指向可写内存
*p = 'T';                   // 必须不触发任何 copy-on-write

COW 实现在 C++11 标准草案阶段曾有专门的缺陷报告(LWG 2268 等),最终 C++11 明确禁止了引用语义与 COW 的共存。GCC 5.0(2015)发布时切换到 SSO+深拷贝模型,彻底满足这些要求。

对象布局

上文已经覆盖 COW 单指针布局、SSO 时代的 32 字节对象布局与双 ABI 符号;后续补一张 v1/v2 并排偏移图。

核心源码路径

libstdc++ string 实现散布在以下头文件中:

文件职责
include/bits/basic_string.h主模板 basic_string<CharT,Traits,Alloc> 定义、SSO 布局、所有内联成员函数
include/bits/basic_string.tccout-of-line 成员函数模板实现(_M_create_M_mutatereplacefind 系列等)
include/bits/allocator.hstd::allocator 模板;basic_string 通过 EBO 压缩空 allocator 以减小对象尺寸
include/bits/cow-string.h(已废弃)GCC 5 前的 COW 实现,包含 _Rep_M_refcount_M_is_shared() 等;ABI v1 路径仍引用此文件的部分类型
include/bits/c++config.hABI 分叉点:定义 _GLIBCXX_USE_CXX11_ABI 默认值(GCC 5+ 默认为 1),以及 namespace __cxx11inline namespace 注入

ABI v1/v2 分叉逻辑basic_string.h 根据 _GLIBCXX_USE_CXX11_ABI 宏展开为两条路径——

  • =1(默认):使用 SSO 布局,basic_string 定义在 inline namespace __cxx11 中,符号带 [abi:cxx11] tag
  • =0:使用旧 COW 布局,basic_string 定义在 std 直接命名空间中,符号为 _ZNSs... 格式

可通过 _GLIBCXX_USE_CXX11_ABI 宏检查当前 ABI 版本:

cpp
#include <bits/c++config.h>
static_assert(_GLIBCXX_USE_CXX11_ABI == 1, "需要 ABI v2");

核心类 / 函数

ABI v2(SSO 时代)核心成员

cpp
// basic_string 的实际数据成员(简化,去除 allocator traits 细节)
struct _Alloc_hider : allocator_type {   // EBO:空 allocator 不占空间
    pointer _M_p;                         // 指向实际字符数据
};

_Alloc_hider _M_dataplus;                // 偏移 0: 指针 + allocator (EBO)
size_type    _M_string_length;            // 偏移 8: 当前长度
union {
    char       _M_local_buf[16];          // SSO 缓冲区(容量 15 + 1 字节 '\0')
    size_type  _M_allocated_capacity;     // 堆模式下记录分配容量
};
成员偏移大小说明
_M_dataplus._M_p08 字节SSO 时指向 _M_local_buf;堆时指向 new char[]
_M_dataplus._M_alloc (EBO)0 字节默认 allocator<char> 是空类,EBO 压缩后不占空间
_M_string_length88 字节SSO 和堆模式共用,始终存储当前字符串长度
_M_local_buf[16]1616 字节SSO 缓冲区;与 _M_allocated_capacity 共享 union
_M_allocated_capacity168 字节堆模式下存储分配容量(union 的另一视图)

关键成员函数

函数作用
_M_is_local()判断 _M_dataplus._M_p == _M_local_buf,即是否处于 SSO 模式
_M_data()返回 _M_dataplus._M_p(实际字符数据指针)
_M_set_length(n)设置 _M_string_length = n 并在 data()[n] 写入 '\0'
_M_capacity()SSO 返回 15;堆返回 _M_allocated_capacity
_M_create(capacity, old_cap)分配 capacity+1 字节的堆内存,返回新指针
_M_dispose()SSO 时为无操作;堆时 _M_destroy() 释放堆内存
_M_mutate(pos, len1, s, len2)核心变异原语:若 SSO 或容量不足则创建新缓冲区,移动/拷贝字符
_M_leak_hard()ABI v2 中的遗留函数(COW 时代调用 _M_rep()->_M_set_leaked(),SSO 时代为 no-op)

ABI v1(COW 时代)历史成员

cpp
// COW 布局:string 对象只有 1 个指针
struct _Rep {                        // 堆上的元数据块
    size_type   _M_length;
    size_type   _M_capacity;
    _Atomic_word _M_refcount;        // 原子引用计数
    // 字符数据紧跟 _Rep 之后
};

char* _M_p;                          // 指向 _Rep 之后的字符数据
成员/函数说明
_Rep::_M_refcount原子引用计数;-1 表示永不释放(空字符串全局实例)
_Rep::_M_is_shared()_M_refcount > 0 时表示数据被多个 string 共享
_M_rep()_M_p 反推 _Rep* 地址(_M_p - sizeof(_Rep) 处)
_M_grab(alloc, alloc2)COW 核心:若 rep 未共享则直接复用,否则 _M_clone() 深拷贝
_M_leak()标记 rep 为 leaked(_M_refcount = -1),断开共享

关键算法

SSO / Long 模式切换

_M_is_local() 判定:
  _M_dataplus._M_p == (pointer)_M_local_buf ?
  ├─ true  → Short 模式:数据在栈上,容量 = 15
  └─ false → Long 模式:数据在堆上,容量 = _M_allocated_capacity

构造/赋值时的决策路径:

  1. size <= 15 → 直接写入 _M_local_buf_M_p 指向自身
  2. size > 15 → 调用 _M_create(size+1, 0) 堆分配,数据拷贝到堆缓冲区

容量增长策略

cpp
// _M_check_len(n) 的计算逻辑(简化)
size_type _M_check_len(size_type n) const {
    if (max_size() - size() < n)
        __throw_length_error("basic_string::_M_check_len");
    size_type new_size = size() + std::max(size(), n);  // max(2*current, current+needed)
    return std::min(new_size, max_size());               // 不超过 max_size()
}

增长因子为 2x(与 libc++ 一致),但上限受 max_size() 约束。实际分配字节数为 new_size + 1(含终止符 '\0')。

历史 COW 写时复制路径(ABI v1)

cpp
// 任何可能修改数据的操作前,检查共享状态
void _M_check_mutate() {
    if (_M_rep()->_M_is_shared())   // refcount > 0
        _M_mutate(0, 0, 0);         // 分离副本:分配新缓冲区,拷贝数据,refcount--
}

写时复制的调用链: operator[] (非 const) → _M_check_mutate()_M_is_shared() → 若共享则 _M_mutate()_M_clone() → 新 _Rep + 拷贝字符

空字符串优化:所有空 string 共享全局 _Rep_M_refcount = -1,永不释放),避免零长度字符串的任何堆分配。

ABI 符号选择

编译时 _GLIBCXX_USE_CXX11_ABI 决定符号名:

ABI 版本std::string 的 mangled 名内联命名空间
v1 (=0)_ZNSsC1Ev (构造函数)无,直接 std::basic_string
v2 (=1)_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEC1Evstd::__cxx11::basic_string

v2 符号中 __cxx11 段是 GCC abi_tag 属性自动注入的,无需手动编码。

ABI 约束

本篇主题本身就是 ABI;后续这里补齐 __cxx11 命名空间、abi_tag_GLIBCXX_USE_CXX11_ABI 与混用二进制边界的约束清单。

异常安全

SSO 时代(ABI v2)的异常安全

操作保证机制
reserve()强保证_M_create() 分配新缓冲区,拷贝数据到新缓冲区,再释放旧缓冲区;若分配抛异常,原数据不变
append(const char* s, size_type n)强保证通过 _M_check_len() 计算所需容量;若超出当前容量,先分配新缓冲区再写入;分配失败时原 string 不变
replace(pos, len, s, n)强保证(长度不变时为 no-throw)若替换后长度不变且不触发扩容,直接原地修改(no-throw);否则通过 _M_mutate() 分离新缓冲区
operator=(const basic_string&)基本保证赋值前先 _M_dispose() 旧缓冲区,再拷贝新内容;若拷贝抛异常,string 处于合法但未指定状态
operator=(basic_string&&)no-throw移动语义:交换指针和长度,_M_p 置为 _M_local_buf(空 SSO),源 string 为空
clear()no-throw_M_set_length(0),不释放内存

COW 时代(ABI v1)的异常安全差异

COW 的 _M_mutate()(分离副本)存在微妙的异常安全问题:

cpp
// COW 的 append 路径(简化)
void append(const char* s, size_type n) {
    _M_mutate(size(), n, s, n);  // 步骤 1: 若 rep 共享则 clone,然后扩容
    // 步骤 2: 写入新字符
    // 问题:_M_mutate 内部的 clone 失败时,原 rep 的 refcount 已递减
    // 若 clone 抛异常,原 string 可能指向已释放的 rep
}

具体来说,COW 的 _M_mutate() 执行以下步骤:

  1. 检查 _M_is_shared();若共享,调用 _M_clone() 创建新 _Rep(可能抛 std::bad_alloc
  2. _M_clone() 成功后,旧 rep 的 _M_refcount 递减
  3. _M_refcount 降为 0,旧 rep 被释放
  4. _M_p 指向新 rep 的字符区域

如果步骤 1 的 _M_clone() 抛异常,此时旧 rep 的 refcount 已经递减(从 2 变为 1),但 _M_p 仍指向旧 rep。这意味着 string 对象仍有效,但共享关系已被破坏——其他持有旧 rep 的 string 将看到 refcount 少 1。这是 基本保证(而非强保证),因为 string 的内部状态已发生不可逆改变。

iterator / reference invalidation

SSO 时代(ABI v2)失效规则

操作迭代器/指针/引用是否失效原因
SSO → 堆转换(如 append 导致长度 > 15)全部失效_M_p_M_local_buf 切换到堆分配的新地址,所有指针不再有效
堆内扩容(如 append 导致超出当前容量)全部失效_M_create() 分配新缓冲区,释放旧缓冲区
堆内原地修改(长度不变,如 operator[] 写入)不失效数据仍在同一堆缓冲区
shrink_to_fit()可能全部失效堆模式下可能重新分配更小缓冲区;SSO 模式下为 no-op(不失效)
clear()不失效_M_set_length(0),容量和缓冲区不变
reserve(n)n > capacity()全部失效分配新缓冲区,释放旧缓冲区
swap()全部失效交换指针和长度,原引用指向另一 string 的数据
erase()不失效(但指向被删元素的引用无效)字符原地移动,长度缩短,缓冲区不变

COW 时代(ABI v1)的额外陷阱

COW 实现中,非变异操作也可能导致失效

cpp
std::string a = "hello";
std::string b = a;           // COW:a 和 b 共享同一 rep,refcount=2
const char* p = a.c_str();   // 获取 a 的数据指针
char c = b[0];               // b 的 operator[] const 触发 _M_check_mutate()
                             // 若 _M_is_shared() 返回 true,b 会 clone 一个新 rep
                             // a 的 refcount 降为 1,但 p 仍然有效——这一轮没问题

// 真正的陷阱:
std::string c = a;
char& r = a[0];              // 获取非 const 引用 → 触发 _M_mutate(),a 可能 clone
// 若 clone 发生,r 引用的是旧 rep 的数据,可能已悬空

关键区别:

  • SSO 时代:只有触发容量变化的操作才失效——失效边界清晰可预测
  • COW 时代:任何非 const 访问都可能触发 clone——即使 begin() 非 const 版本也可能失效迭代器
  • COW 的 c_str() 在 GCC 4.x 中有一次著名的缓存失效 bug:operator[] const 会破坏 c_str() 返回的缓冲区

标准规范(§[string.iterators])

标准要求:

  • 非变异操作(begin() const、end() const、data() const、operator[] const、c_str()不得使迭代器、引用、指针失效
  • 变异操作仅在导致重新分配时使迭代器失效

COW 实现无法满足第一条——这是 C++11 迫使 ABI 变更的核心原因之一。

性能模型

SSO 性能特征

参数libstdc++ (GCC)libc++ (Clang)影响
sizeof(string)32 字节24 字节libstdc++ 的 union { _M_local_buf[16]; _M_allocated_capacity; } 比 libc++ 的 __short_ 联合体多占 8 字节
SSO 容量15 字节22 字节libc++ 将 size_type(8 字节)与指针(8 字节)重叠,腾出更多内联空间
SSO 触发阈值len <= 15len <= 22约 85% 的现实字符串 ≤ 15 字节(路径、邮件地址、标识符等),两者均覆盖大部分场景
缓存行占用32 字节 = 半个 cache line(64B)24 字节 < 半个 cache line32 字节的 string 数组每两个对象恰好占满一个 cache line;24 字节则有 16 字节碎片

复制性能权衡

COW 时代(ABI v1):
  复制 string a → b:
    1. 拷贝 _M_p(1 个指针)              ← 极快
    2. _M_refcount.fetch_add(1)            ← 原子操作,~10-50ns
    3. 写入时才 _M_clone()(深拷贝)       ← 延迟到第一次修改
  问题:每次写入都有 _M_is_shared() 检查分支;多线程下原子操作有 cache-line bouncing

SSO 时代(ABI v2):
  复制 string a → b:
    若 SSO:memcpy 16 字节(_M_local_buf) ← 等价于 2 次 8 字节 load+store
    若堆:malloc + memcpy(size)            ← 每次复制都是深拷贝
  收益:无原子操作、无共享检查分支、无 cache-line bouncing

对于短字符串(≤ 15 字节),SSO 的深拷贝实际上比 COW 更快——memcpy 16 字节比 fetch_add 原子操作更快。

ABI 兼容成本

双 ABI 共存的代价:

  1. 二进制体积:每个 basic_string 成员函数都有两份符号(v1 + v2),增加约 10-15% 的 string 相关代码体积
  2. 链接时间:符号表更大,链接器需要处理更多符号
  3. 模板实例化:若翻译单元使用不同 ABI 宏,同一 basic_string<T> 实例化会产生两套不同的代码
  4. 不允许混用:v1 的 std::string 和 v2 的 std::__cxx11::basic_string 是不同类型——传递到对方的函数会链接失败(而非静默 UB)

与 MSVC STL 对比

MSVC 的 std::string(VS 2015+):

  • SSO 容量:15 字节(_BUF_SIZE = 16
  • 对象大小:32 字节(与 libstdc++ 相同)
  • 无 COW 历史包袱(MSVC 从未实现 COW)
  • 无 ABI 双版本问题(MSVC 的 ABI 由编译器版本号锁定,如 _MSC_VER

libstdc++ vs libc++ vs MSVC

正文已给出 SSO 容量和对象大小的部分差异;后续在这里补齐三家 string 布局、增长策略、调试模式与 ABI 策略对照。

最小复现代码

cpp
#include <string>

int main() {
  std::string s = "hello";
  s += " world";
  return static_cast<int>(s.size());
}

编译 / 反汇编 / benchmark 证据

验证 SSO / Long 分界

cpp
// sso_boundary.cpp — 验证 15 字节 SSO 阈值
#include <cstdio>
#include <string>
#include <cstring>

int main() {
    // 15 字节:SSO
    std::string short_str("0123456789abcde");  // len = 15
    printf("short: data=%p local=%p is_local=%d cap=%zu\n",
           (void*)short_str.data(), (void*)&short_str,
           short_str.data() == (const char*)&short_str,
           short_str.capacity());

    // 16 字节:堆分配
    std::string long_str("0123456789abcdef");   // len = 16
    printf("long:  data=%p local=%p is_local=%d cap=%zu\n",
           (void*)long_str.data(), (void*)&long_str,
           long_str.data() == (const char*)&long_str,
           long_str.capacity());
}

编译运行:

bash
g++ -std=c++20 -O2 sso_boundary.cpp -o sso_boundary && ./sso_boundary
# 预期输出:
# short: data=0x7fff... local=0x7fff... is_local=1 cap=15
# long:  data=0x555...   local=0x7fff... is_local=0 cap=31

双 ABI 符号名验证

bash
# 编译同一个测试程序,分别用 v1 和 v2 ABI
g++ -std=c++20 -O2 -D_GLIBCXX_USE_CXX11_ABI=1 string_test.cpp -o v2
g++ -std=c++20 -O2 -D_GLIBCXX_USE_CXX11_ABI=0 string_test.cpp -o v1

# 查看 v2 符号:包含 __cxx11 命名空间
objdump -t v2 | grep basic_string | head -5
# 预期:_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE...
#               ^^^^^^^ ABI tag

# 查看 v1 符号:直接在 std 命名空间
objdump -t v1 | grep basic_string | head -5
# 预期:_ZNSs... (简写) 或 _ZNSt12basic_stringIcSt11char_traitsIcESaIcEE...
#                            无 __cxx11

对象大小与布局验证

bash
# 编译包含两种 ABI 的程序,比较对象大小
cat > string_size.cpp << 'EOF'
#include <cstdio>
#include <string>
struct WithString { std::string s; int x; };
int main() {
    printf("sizeof(std::string)  = %zu\n", sizeof(std::string));
    printf("sizeof(WithString)   = %zu\n", sizeof(WithString));
    printf("SSO capacity         = %zu\n", std::string().capacity());
}
EOF
g++ -std=c++20 -O2 string_size.cpp -o string_size && ./string_size
# 预期(libstdc++ ABI v2, x86-64):
# sizeof(std::string)  = 32
# sizeof(WithString)   = 40  (32 + 4 + 4 padding)
# SSO capacity         = 15

append 热路径反汇编

bash
# 查看 append 的内联路径(SSO 模式下的快速路径)
cat > append_bench.cpp << 'EOF'
#include <string>
#include <benchmark/benchmark.h>
static void BM_AppendSSO(benchmark::State& state) {
    for (auto _ : state) {
        std::string s = "hello";
        s += " world";          // 5+6=11, 仍在 SSO 内
        benchmark::DoNotOptimize(s);
    }
}
static void BM_AppendHeap(benchmark::State& state) {
    for (auto _ : state) {
        std::string s = "hello world! this is a long string";
        s += " and more data";  // 触发堆分配
        benchmark::DoNotOptimize(s);
    }
}
BENCHMARK(BM_AppendSSO);
BENCHMARK(BM_AppendHeap);
BENCHMARK_MAIN();
EOF
# 编译并运行 benchmark
g++ -std=c++20 -O2 -lbenchmark append_bench.cpp -o append_bench && ./append_bench

反汇编查看 SSO 快速路径:

bash
g++ -std=c++20 -O2 -S -masm=intel append_bench.cpp -o append_bench.s
# 搜索 SSO append 的关键指令:应看到 memcpy/mov 操作而非 _M_create 调用
grep -A 20 'append' append_bench.s | head -30

典型 benchmark 结果(参考值,x86-64 GCC 13 -O2)

场景耗时(ns/op)说明
SSO 构造 + 拷贝(11 字节)~5-8纯 memcpy 32 字节
堆构造 + 拷贝(64 字节)~20-30含 malloc + memcpy
s += "abc" SSO 内~8-12原地追加,无分配
s += "abc" 触发扩容~40-60含 realloc/新分配 + 拷贝
COW 构造 + 拷贝(ABI v1,64 字节)~15-25仅拷贝指针 + 原子操作
COW s += "abc"(共享状态)~30-50含 clone + 追加

SSO 在短字符串场景下比 COW 快 2-3 倍;长字符串 COW 的复制更便宜,但写入时的 clone 开销在热路径中可能抵消收益。

cpplings 练习入口

基于 MIT 许可发布