问题
C++ 中的 std::string
类相比起 C 中的字符串,使用起来非常方便,编译器会根据字符串长短自动分配内存;不像 C 里,需要确定的知道字符串有多长,然后分配相应的堆或者栈空间。 但是 C++ 能做到这样,肯定是有人替你负重前行。本文接下来探究 C++ 中不同长度的字符串在内存中是如何存储的。
短字符串
测试输出
首先写下基本测试代码:
#include <iostream>
using namespace std;
int main(){
long bottom = -1;
string word = "dev";
cout << "> Current string content: " << word << endl;
cout << " &bottom: " << &bottom << endl;
cout << " &word: " << &word << endl;
char &c1 = word[0];
cout << "&word[0]: " << (void *)&c1 << endl;
auto size = (char *)&bottom - (char *)&word;
cout << " sizeof word: " << size << endl;
}
执行结果如下:
> Current string content: dev
&bottom: 0x16eee7698
&word: 0x16eee7680
&word[0]: 0x16eee7680
sizeof word: 24
上述代码声明并初始化了两个变量:
- bottom:声明这个变量主要是为了获得其地址,作为基准地址就可以确认编译器给
word
对象分配的栈空间大小。因为接下来的word
肯定是紧贴着它的内存空间的; - word:一个 string 对象,也就是接下来我们主要研究的对象。初始值是字符串
"dev"
。
接下来的代码中,我们做了这些事:
- 输出了这个字符串;
- 输出了
bottom
和word
的地址,并根据地址计算出编译器给word
预留出了 24 个字节的栈空间; - 输出了字符串中第一个字符的地址,从目前的结果看,这个地址也就是
word
的起始地址。因此,目前可以判断,字符串就是存储在所分配的 24 个字节空间中的。
这样就引入了两个疑问:
- 总共 24 个字节,目前字符串内容是
"dev"
的话,只有4 个字节是被使用的,那么其他 20 个字节里有什么? - 如果字符串超出了 24 个字节怎么办?
针对第一个问题,我们以字长为单位,输出这 24 个字节的全部内容;
long *ptr = (long *)&word;
cout << " 0. " << std::hex << *ptr << endl;
cout << " 1. " << std::hex << *(ptr + 1) << endl;
cout << " 2. " << std::hex << *(ptr + 2) << endl;
输出结果如下:
0. 766564
1. 0
2. 300000000000000
内存布局
先画个图,把目前已知的关于栈空间的内容分布画出来。
短字符串内存分布
上图中,一个格子代表一个字节;栈的增长方向(地址的降低方向)是向图片下方。
根据输出,关于 word
对象有如下需要关注的地方:
- 前 8 个字节内容是 0x766564,对照一下 ASCII 表,可以确认这就是字符串
"dev"
,其他的字节全部是 0;另外我们还能确认当前机器是小端字节序; - 中间 8 个字节全部是 0;
- 最后 8 个字节是 0x0300000000000000,其中最高位字节 0x03,这个是什么?
一个巧合,当前存储的字符串长度刚好是 3,有没有可能这就是字符串的长度?很好验证,赋值字符串换一个不同长度的再执行上述代码,可以确认,这个字节中存储的就是字符串的长度。并且只有 24 个字节的空间,所以按照这样的存储方式,字符串最长只能是 22。
长字符串
测试输出
这是前面提出的两个问题之一:从目前的布局来看,只能存储最多 22 个字符(预留一个字节 \0
最为字符串终止,一个字节存储字符串长度);那么问题来了,如果 word
的内容超过了 24 个字节,那内存是怎么样的? 于是,我们重新把 word
赋值为一个长于 24 字节的字符串(此处是 25),看看结果如何。
cout << string(40, '-') << endl;
word = string(size + 1, 'C');
cout << " &word: " << &word << endl;
char &c2 = word[0];
cout << "&word[0]: " << (void *)&c2 << endl;
cout << " 0. " << std::hex << *pword << endl;
cout << " 1. " << std::hex << *(pword + 1) << endl;
cout << " 2. " << std::hex << *(pword + 2) << endl;
执行结果如下:
----------------------------------------
&word: 0x16eee7680
&word[0]: 0x600000730000
0. 600000730000
1. 19
2. 8000000000000020
可以观察到:
word
对象本身的地址没有变;word
对象中所存储字符串的第一个字符地址变了,C/C++ 规范要求字符串肯定是占据连续的空间,可以肯定整个字符串被移到了其他的地方;
内存布局
同样可以把此时的内存布局画出来:
长字符串内存分布
图中展示了一些即将要提到的细节。另外图中地址的相对大小令我有些疑惑,按我当前学习到的虚拟内存布局模型,堆的地址应该比栈更小才对,但是测试下来地址却如图中所示,莫非字符串不是分配在堆内存中?不过可以确定的是,string 对象的地址(&word)跟其包含的字符串是分开存储的。
观察此时 word
对象的 24 个字节的内容:
- 前 8 字节内容改了,而且刚好是的字符串第一个字符的地址,可以确认这里存储的是一个指针(
char **
),指向真正存储了字符串的空间。 - 中间 8 个字节
0x19
(25)是什么呢?同样可以猜测并验证,这就是字符串的长度。 - 最后 8 个字节,
0x8000000000000020
,这个下面详述。
掩码
通过前面的代码测试,我们明确了字符串可能存储在栈,也可能存储在堆;这就带来了不确定性,那么编译器怎么进行区分呢?
使用掩码确认字符串的真正位置
回想长字符串栈空间中最后 8 个字节 0x8000000000000020
,其最高位字节0x80
,最高比特位是 1;跟短字符串的情形不一样,这里肯定不再是字符串的长度了。短字符串情形下,这个字节存储的内容最大是 22,最高比特位是 0。
此时我们可以推测一下,编译器就是用这个 0x80
字节的最高位来区分:一个字符串到底是原地存储在 24 字节的栈中,还是存储在另外开辟的堆空间中。顺便可以关注一下,这个字节是在距离 &word
起始地址偏移 23 字节的位置,记住这个位置,等下会有用。
堆大小
还有个问题,0x8000000000000020
这里的最低位字节 0x20
(32)是什么(前面图中已经剧透)?凭目前的代码及输出结果,还无法推测。我在这个 Stackoverflow 回答里看到了线索:
Two common implementations are storing 3 pointers (begin of the allocated region and data, end of data, end of allocated region) or a pointer (begin of allocated region and data) and two integers (number of characters in the string and number of allocated bytes).
当字符串长度超过 22 时,无法存储在栈空间中,需要在堆内动态分配内存存储字符串,而前述的 0x20
,就是分配的堆大小。同样可以通过修改更长的字符串验证,当字符串长度增长时,分配的堆空间也会增长,而且因为对齐的限制,通常会超过字符串实际长度。
汇编验证
前面提到的 0x80
字节的作用只是猜测,接下来执行调试验证一下。要验证的问题很明确,当字符串既可能存储在函数栈,也可能存储在动态堆里时,编译器是如何确定到底在哪里的?
为此,我们写了个函数,如方法名所示,获取字符串中的第一个字符,要实现这个功能,汇编代码的执行肯定要涉及上述逻辑。接下来的调试只需要将断点设置在该函数即可:
char get_first_char(string *str) {
return (*str)[0];
}
函数 get_first_char
的汇编代码如下:
0x100005bd8 <+0>: sub sp, sp, #0x20
0x100005bdc <+4>: stp x29, x30, [sp, #0x10]
0x100005be0 <+8>: add x29, sp, #0x10
0x100005be4 <+12>: str x0, [sp, #0x8]
0x100005be8 <+16>: ldr x0, [sp, #0x8]
0x100005bec <+20>: mov x1, #0x0
0x100005bf0 <+24>: bl 0x100005b74 ; std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::operator[](unsigned long)
0x100005bf4 <+28>: ldrsb w0, [x0]
0x100005bf8 <+32>: ldp x29, x30, [sp, #0x10]
0x100005bfc <+36>: add sp, sp, #0x20
0x100005c00 <+40>: ret
ARM 格式的汇编代码不太熟,花了一个小时入门之后勉强看懂一点, bl 是函数调用指令,所以继续跟进调用这一行打断点。
0x100007708 <+24>: bl 0x100006908 ; std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::__is_long() const
0x10000770c <+28>: tbz w0, #0x0, 0x100007724 ; <+52>
0x100007710 <+32>: b 0x100007714 ; <+36>
0x100007714 <+36>: ldr x0, [sp, #0x10]
0x100007718 <+40>: bl 0x100006960 ; std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::__get_long_pointer()
0x10000771c <+44>: str x0, [sp, #0x8]
0x100007720 <+48>: b 0x100007734 ; <+68>
0x100007724 <+52>: ldr x0, [sp, #0x10]
0x100007728 <+56>: bl 0x1000069c0 ; std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::__get_short_pointer()
经过若干次函数调用跳转后,在汇编代码的注释里发现了可能的线索;观察上方汇编代码的注释,第 1 行似乎是调用了一个名为 __is_long
的函数。而后面又分别有 __get_long_pointer
和 __get_short_pointer
的函数,从命名上,似乎符合我们前面的猜想: 即根据某个特定比特位,判断字符串指针应该从栈里取还是堆里取。那么继续跟进去这个 __is_long
函数。
0x100006908 <+0>: sub sp, sp, #0x20
0x10000690c <+4>: stp x29, x30, [sp, #0x10]
0x100006910 <+8>: add x29, sp, #0x10
0x100006914 <+12>: str x0, [sp, #0x8]
0x100006918 <+16>: ldr x0, [sp, #0x8]
0x10000691c <+20>: bl 0x100006a20 ; std::__1::__compressed_pair<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::__rep, std::__1::allocator<char> >::first() const
0x100006920 <+24>: ldrb w8, [x0, #0x17]
0x100006924 <+28>: ands x8, x8, #0x80
0x100006928 <+32>: cset w8, ne
0x10000692c <+36>: and w0, w8, #0x1
0x100006930 <+40>: ldp x29, x30, [sp, #0x10]
0x100006934 <+44>: add sp, sp, #0x20
0x100006938 <+48>: ret
看看 __is_long
部分的汇编代码,我感觉可以宣告破案了。上面汇编代码的第 7、8 两行,有一个熟悉的十六进制数字:0x17,也就是 23,这就是前面提到的 0x80 字节的偏移量。这两行的逻辑是:
- 取出 x1 寄存器中内存地址偏移 23(#0x17)个字节的字节,放到 x8 寄存器中;
- 将该值与 0x80 进行与运算并将结果返回;
代码验证
上面的汇编代码,其实大部分我都看不懂,主要是注释给了我提醒,那么实现 string 类的代码里,是不是应该有注释里提到的这些方法? 跟随 IDE (我使用的是 CLion)的提示,点击进入 string 的类定义里:
typedef basic_string<char, char_traits<char>, allocator<char> > string;
继续进入 basic_string 的定义,可以找到如下的方法:
pointer __get_pointer() _NOEXCEPT
{return __is_long() ? __get_long_pointer() : __get_short_pointer();}
_LIBCPP_INLINE_VISIBILITY
const_pointer __get_pointer() const _NOEXCEPT
{return __is_long() ? __get_long_pointer() : __get_short_pointer();}
另外还定义了两个静态常量作为掩码。
#ifdef _LIBCPP_BIG_ENDIAN
static const size_type __short_mask = 0x01;
static const size_type __long_mask = 0x1ul;
#else // _LIBCPP_BIG_ENDIAN
static const size_type __short_mask = 0x80;
static const size_type __long_mask = ~(size_type(~0) >> 1);
#endif // _LIBCPP_BIG_ENDIAN
这部分基本上印证了汇编代码中体现的逻辑。C++ Primer 我才看到第三章,所以 basic_string
中其他的代码我根本看不懂。代码部分就说到这里吧。
结论
到目前为止,可以得出 string 对象到底如何存储字符串的结论:
std::string 的内存分布
- 编译器会为每一个 string 对象分配 24 个字节的栈空间;
- 如果要存储的字符串长度(strlen 而非 sizeof)小于等于 22,则直接存储在函数栈中;最后一个字节(地址最高处)存储字符串的实际长度;
- 如果要存储的字符串长度超过 22,会另外分配堆内存空间存储字符串本身,栈内的 24 个字节空间另有他用:
- 将字符串在堆中的地址存储在 24 个字节的前 8 个字节;
- 中间 8 个字节存储字符串的长度;
- 最后 8 个字节:地址最高的字节固定为 0x80,剩余字节存储分配的堆空间大小。
注意:以上的测试基于我当前使用的编译器,根据 StackOverflow 的一些回答,其他的编译器可能有不同的存储策略,本文暂不涉及。
文中涉及到的完整代码请参考 GitHub Gist。
文章原始地址:C++ 中 std::string 的内存布局