-
-
[原创]详细分析MSVC中std::string对象内存布局
-
发表于: 3天前 156
-
详细分析MSVC中std::string对象内存布局
std::string的内存布局没有统一标准,由各个C++标准库的实现者决定。目前主流的实现有两种截然不同的策略:
- libstdc++ (gcc):写时复制(COW)
- libc++ (clang)/MSVC:小字符串优化(SSO)
我们在此重点分析MSVC中std::string对象的内存布局。
MSVC 中 std::string 的短字符串优化 (SSO) 核心是:当字符串长度 ≤ 15 个字符(对于 char 类型)时,数据直接存储在 std::string 对象内部的栈上,从而避免了昂贵的堆内存分配。
STL源码分析
std::string的定义
我们跳转到std::string的定义处:

我们看到的using string = basic_string<char, char_traits<char>, allocator<char>>;是 std::string 的类型别名定义,真正的实现细节都隐藏在 basic_string 这个模板类里。
basic_string

basic_string 类本身只声明了一个数据成员 _Mypair,真正的状态存放在它内部的 _String_val 里:

- _Alty = _Rebind_alloc_t<_Alloc, _Elem>:把用户给的 _Alloc 重绑定到元素类型上的分配器。
- _Scary_val = _String_val<...>:实际存放数据的地方。
- _Compressed_pair 利用 EBCO(空基类优化):当分配器是空类型(无状态,如 std::allocator)时,它不占任何字节,被压缩进 pair 里,因此不增加对象大小。
在C++中,空基类优化(Empty Base Optimization,简称EBO) 是一项由C++标准允许、编译器执行的优化技术。它允许一个空的基类子对象在派生类中不占用任何内存空间。EBO的核心在于,C++标准不强制要求基类子对象也必须拥有独立的地址。这为编译器优化提供了空间。
_String_val
这是真正的容器:

对于_Container_base,当 _ITERATOR_DEBUG_LEVEL != 0 时它含一个 _Container_proxy* _Myproxy(迭代器调试用的代理指针);为 0 时是空基类,不占空间。
在_String_Val内部,有三个重要的成员:

以伪代码进行说明:
template <class _Val_types>
class _String_val : public _Container_base {
...
static constexpr size_type _BUF_SIZE = 16 / sizeof(value_type) < 1 ? 1 : 16 / sizeof(value_type);
static constexpr size_type _Alloc_mask = ...; // 堆分配容量向上取整的掩码 [0,15]
static constexpr size_type _Small_string_capacity = _BUF_SIZE - 1;
...
union _Bxty {
_CONSTEXPR20 _Bxty() noexcept : _Buf() {}
_CONSTEXPR20 ~_Bxty() noexcept {}
value_type _Buf[_BUF_SIZE]; // SSO 小缓冲区
pointer _Ptr; // 指向堆缓冲的指针(大模式)
char _Alias[_BUF_SIZE]; // TRANSITION, ABI 兼容保留
void _Switch_to_buf() noexcept { ... }
};
_Bxty _Bx;
size_type _Mysize = 0; // 当前长度(size),不含结尾 '\0'
size_type _Myres = 0; // 当前容量(capacity),不含结尾 '\0'
};
两种模式的判定
在_String_Val内部,有一个成员函数,用于判断使用SSO或者大模式:

- 小模式(SSO):_Myres == _Small_string_capacity(= _BUF_SIZE - 1),数据存在 _Bx._Buf 里,不分配堆内存。
- 大模式:_Myres > _Small_string_capacity,数据在 _Bx._Ptr 指向的堆缓冲里,实际分配 _Myres + 1 个元素(多出的是结尾 '\0')。
对象大小
basic_string<char> // 用户看到的类
└─ _Compressed_pair<_Alty, _Scary_val> _Mypair; // 唯一成员
├─ _Alty(分配器) // 被 EBCO 压掉,不占字节
└─ _String_val<char 信息> // 真正存状态的地方
├─ _Container_base(基类) // 迭代器调试代理指针(可选)
├─ _Bxty _Bx // union:SSO 缓冲 / 堆指针
├─ _Mysize // size
└─ _Myres // capacity
对 std::string(char,x64,_ITERATOR_DEBUG_LEVEL == 0):
_Compressed_pair<_Alty, _String_val> // allocator 被 EBCO 掉
└─ _String_val // 32 字节
├─ _Container_base // 空基类,0 字节
├─ _Bx union // max(_Buf[16]=16, _Ptr=8) = 16 字节
├─ _Mysize // 8 字节
└─ _Myres // 8 字节
即 sizeof(std::string) == 32(x64)/ 24(x86);sizeof(std::wstring) 同样为 32/24(_BUF_SIZE = 16/sizeof(wchar_t) = 8,union 取 _Ptr 的 8 字节对齐后仍是 16 字节)。注意这是在Release编译选项下的大小,如果是Debug,会增加8/4(x64/x86)字节。_ITERATOR_DEBUG_LEVEL == 0就是Release模式下才有的。
示例程序
#include <string>
#include <iostream>
void test1()
{
std::string str;
std::cout << "sizeof std::string object: " << sizeof(str) << std::endl;
str = "test string";
std::cout << str << std::endl;
}
void test2()
{
std::string str("This is a string longer than 16 bytes");
std::cout << str << std::endl;
}
int main(int argc, char* argv[])
{
test1();
test2();
return 0;
}
查看内存布局
这里以x64/Debug编译选项进行说明。
SSO模式
对于test1中的str,其长度小于16字节。它的地址是:

接下来对其赋值“test string”,查看内存:

- 红色部分共8字节,是指向迭代器调试代理(堆上分配),这个后面会再详细说说。
- 橙色部分共16字节,是SSO模式的缓冲区。
- 绿色部分共8字节,是当前字符串长度。
- 紫色部分共8字节,是当前缓冲区的长度。
大模式
在test2函数中,str中存储的字符串长度大于16字节,使用大模式保存。
str对象的地址是0x00000005E18FF998,初始化后:

查看其中指向堆的地址(橙色部分):

Debug与Release对std::string对象内存布局的影响
前文稍微提到了一点Debug和Release的影响,这里再展开说说。
Debug/Release 的 _ITERATOR_DEBUG_LEVEL 来源(yvals.h)
// B1ii. _HAS_ITERATOR_DEBUGGING 默认值
#ifdef _DEBUG
#define _HAS_ITERATOR_DEBUGGING 1 // Debug 构建
#else
#define _HAS_ITERATOR_DEBUGGING 0 // Release 构建
#endif
// B3. 推导 _ITERATOR_DEBUG_LEVEL
#if _HAS_ITERATOR_DEBUGGING
#define _ITERATOR_DEBUG_LEVEL 2 // Debug → 2
#elif _SECURE_SCL
#define _ITERATOR_DEBUG_LEVEL 1
#else
#define _ITERATOR_DEBUG_LEVEL 0 // Release → 0
#endif
Debug 默认 IDL=2,Release 默认 IDL=0(_DEBUG 由 /MDd、/MTd 等定义)。
为什么 Debug 会变大(xstring + xutility)
xstring 里有两处直接证明:
template <class _Val_types>
class _String_val : public _Container_base { ... }; // 基类是 _Container_base
// 计算 memcpy 优化时跳过的基类字节数:
template <class _Ty>
constexpr size_t _Size_after_ebco_v = is_empty_v<_Ty> ? 0 : sizeof(_Ty);
static constexpr size_t _Memcpy_val_offset = _Size_after_ebco_v<_Container_base>;
_Container_base 在 _ITERATOR_DEBUG_LEVEL != 0 时不再是空基类,而含有一个指针成员:
// xutility / __msvc_iter_core.hpp 系列
#if _ITERATOR_DEBUG_LEVEL != 0
class _Container_base {
_Container_proxy* _Myproxy = nullptr; // 指向迭代器调试代理(堆上分配)
...
};
#else
class _Container_base {}; // 空基类,被 EBCO 掉
#endif
is_empty_v<_Container_base> 从 true 变成 false,于是 sizeof(std::string) 增加一个指针的大小:x64 多 8 字节,x86 多 4 字节。
应当指出:所有标准容器的sizeof都受此影响,vector、list、map等的内部_Container_base/_Tree_node在Debug下同样带_Myproxy指针。
x64与x86对std::string对象内存布局的影响
这个没什么好说的,基本数据类型的大小不同。
逆向时如何识别std::string对象
这个可以参考:
里面提到了一个重要特征字符串:

在构造std::string对象时,校验长度部分会有一个"stirng too long"特征字符串,可以据此识别std::string对象。