c++的字符串類std::string能否存儲二進制字符以及字符’\0’?


要解決這個問題,我們首先要了解c++的std::string的存儲結構。
(注意不同的平台下C++規範對std::string的實現不完全一致,例如sizeof(std::string)在linux x64 gcc-4.4下的輸出是8,而在mac gcc 4.2下的輸出是24; 這篇文章以Linux x64 gcc Red Hat 4.4.4為運行環境。)

首先檢查std::string類的實例大小, 即一個std::string對象佔用空間大小。

#include <stdio.h>
#include <string>

int main(int argc, char * argv[])
{
    std::string ss("1234567890");

    printf("sizeof=[%d]\n", sizeof(ss));
    printf("size()=[%d]\n", ss.size());
    printf("data  =[%s]\n", ss.data());

    return 0;
}

運行結果如下:

sizeof=[8]
size()=[10]
data  =[1234567890]

我們可以看到sizeof(ss)的輸出大小為固定8字節,和string的內容無關,不管內容字符串有多少長度,這個大小都正好是一個地址長度,這說明std::string實例只有一個成員變量即指向字符串內容的指針,而並沒有別的成員變量來記錄實際字符串長度了。其類成員內存分配模型如下:


1.jpg

總結起來std::string的成員只有一個指向字符串值的指針。

再看函數size()的輸出,正好是字符串內容的長度10個字符,所以size()返回就是10,這個size()函數類似於C語言里返回char *類型數據的長度,即strlen()的返回值(??? 先這麼理解)。

下面我們用程序來驗證這個問題,即std::string只有一個指針成員變量,這個指針正好指向字符串內容的內存地址。

int main(int argc, char * argv[])
{
    std::string ss("1234567890");
    void * pv = (void *)&ss;
    char * ps = *((char **)pv);

    printf("&ss=[%p]\n", pv);
    printf("*(ss)=[%p]\n", ps);
    printf("&data=[%p]\n", ss.data());
    printf("data=[%s]\n", ss.data());
    return 0;
}

輸出結果如下:

&ss=[0x7fffc8d43ff0]
*(ss)=[0x1ba8028]
&data=[0x1ba8028]
data=[1234567890]

可以看到ss對象的地址是0x7fffc8d43ff0,這個地址上存儲的值是0x1ba8028,這個值和data()的值是一樣的,也就是說明ss的唯一成員變量就是一個地址,這個地址是一個指向字符串內容的指針。
至此我們已經了解的std::string對象的存儲模式。


接下來我們再討論std::string能否存儲二進制字符以及’\0’字符的問題。還是通過一個例子說明。

#include <stdio.h>
#include <string.h>
#include <string>

int main(int argc, char * argv[])
{
    std::string ss = std::string("12") + '\0' + "34" + '\11' + "56" + '\255' + "78";

    printf("strlen=[%d]\n", strlen(ss.data()));
    printf("data  =[%s]\n", ss.data());
    printf("sizeof=[%d]\n", sizeof(ss));
    printf("size()=[%d]\n", ss.size());

    return 0;
}

依據前面的經驗,我們可以很快得出:strlen輸出應該是2,data輸出應該是”12″,sizeof輸出應該是8,可是size()輸出應該是多少呢?有兩種可能:
a). 輸出2,即和strlen一樣,因為data的第三個字符為’\0’。
b). 輸出11,因為總的字符長度為11。
如果a)是正確的,那麼相當於剩下的”34″, “\255”, “56”,以及”78″都找不到了,無法引用了,是個嚴重的memory leak問題;而如果b)是正確的,那麼這個size=11是如何計算出來的呢,儘管在”78″之後有一個’\0’字符, 從’1’開始到”78″之後的’\0’長度正好是11,現在的問題是在”12″和”34″之後也有一個’\0’字符,std::string如何得知字符串內容已經結束了呢?

先看上述代碼的實際運行結果:

strlen=[2]
data  =[12]
sizeof=[8]
size()=[11]

我們看到size()的實際輸出值是11,可見第二種可能性是正確的,所以memory leak的問題是不存在的,那麼剩下的問題是size()如何得出正確的值。


通過前面分析我們已經知道兩點,1.這個size肯定是需要記錄下來的,存在某一個地方;2.類std::string的實例大小是8,即一個指針大小,而這個指針正好確實是指向了字符串內容的地址;貌似沒有地方存儲這個size大小的值了。

做過應用程序內存分配庫函數API的同學估計已經猜到了,std::string可能會把這個size存在什麼地方了:),另外如果學習過C++ new數組操作的童鞋估計也猜到了,例如char * ch = new char[50],c++會在ch地址的前面位置存儲這個長度50 。
下面我們再給出一個例子來驗證這個猜測。

#include <stdio.h>
#include <string>

int main(int argc, char * argv[]) {
    std::string ss = "1234567890";
    void * pv = (void *)&ss;
    char * ps = *((char **)pv);

    printf("pv=%p\n", pv);
    printf("ps=%p\n", ps);

    size_t len = ss.size();

    return 0;
}

用GDB單步調試

(gdb) b _ZNKSs4sizeEv
Breakpoint 1 at 0x400688
(gdb) r
pv=0x7fffffffe030
ps=0x601028
(gdb) disassemble
Dump of assembler code for function _ZNKSs4sizeEv:
=> 0x000000388f49c050 <+0>:     mov    (%rdi),%rax
   0x000000388f49c053 <+3>:     mov    -0x18(%rax),%rax
   0x000000388f49c057 <+7>:     retq   
End of assembler dump.
(gdb) info register rdi
rdi            0x7fffffffe030   140737488347184
(gdb) si
(gdb) info register rax
rax            0x601028 6295592
(gdb) si
(gdb) info register rax
rax            0xa      10
(gdb) disassemble
Dump of assembler code for function _ZNKSs4sizeEv:
   0x000000388f49c050 <+0>:     mov    (%rdi),%rax
   0x000000388f49c053 <+3>:     mov    -0x18(%rax),%rax
=> 0x000000388f49c057 <+7>:     retq   
End of assembler dump.
(gdb) x/64 0x601028-32 
0x601008:       49      0       10      0
0x601018:       10      0       0       0
0x601028:       875770417       943142453       12345   0
0x601038:       135121  0       0       0
0x601048:       0       0       0       0
0x601058:       0       0       0       0
0x601068:       0       0       0       0
0x601078:       0       0       0       0
0x601088:       0       0       0       0
0x601098:       0       0       0       0
0x6010a8:       0       0       0       0
0x6010b8:       0       0       0       0
0x6010c8:       0       0       0       0
0x6010d8:       0       0       0       0
0x6010e8:       0       0       0       0
0x6010f8:       0       0       0       0

單步來分析這些指令的含義

(gdb) b _ZNKSs4sizeEv

設置程序斷點std::string::size(),這個是mangle的函數名。

(gdb) r
pv=0x7fffffffe030
ps=0x601028

執行到斷點的時候,程序中的兩個print語句已經執行完成,我們記住這兩個值,下面會用到。

(gdb) disassemble
Dump of assembler code for function _ZNKSs4sizeEv:
=> 0x000000388f49c050 <+0>: mov (%rdi),%rax
0x000000388f49c053 <+3>: mov -0x18(%rax),%rax
0x000000388f49c057 <+7>: retq
End of assembler dump.

反彙編std::string::size()代碼,我們可以看到它只有三條指令。

(gdb) info register rdi
rdi 0x7fffffffe030 140737488347184

查看rdi寄存器的值,我們看到是0x7fffffffe030,這和前面打印出來的pv的值是一樣的,也就是說%rdi存儲的是ss對象的地址。
在之前介紹x64函數傳參規範的時候,我們知道函數的第一個參數使用%rdi傳遞的,有人可能會問了size()沒有參數啊,其實C++的實例函數都是默認把this指針作為函數的第一個參數;std::string::size()可理解成C代碼的size(std::string * ss);

(gdb) si
(gdb) info register rax
rax 0x601028 6295592

執行完指令mov (%rdi),%rax,把(%rdi)的值load到%rax寄存器;我們看到此時%rax寄存器的值和前面打印出來的ps的值是一樣的,就是ss的內容字符串的地址。

(gdb) si
(gdb) info register rax
rax 0xa 10
(gdb) disassemble
Dump of assembler code for function _ZNKSs4sizeEv:
0x000000388f49c050 <+0>: mov (%rdi),%rax
0x000000388f49c053 <+3>: mov -0x18(%rax),%rax
=> 0x000000388f49c057 <+7>: retq

執行完指令mov -0x18(%rax),%rax,把-0x18(%rax)的值load到%rax寄存器,我們可以看到此時%rax的值就是字符串的長度。再把字符串內容地址前後64字節內容打出來看看:

(gdb) x/64 0x601028-32 
0x601008:       49      0       10      0
0x601018:       10      0       0       0
0x601028:       875770417       943142453       12345   0
0x601038:       135121  0       0       0
0x601048:       0       0       0       0
0x601058:       0       0       0       0
0x601068:       0       0       0       0
0x601078:       0       0       0       0
0x601088:       0       0       0       0
0x601098:       0       0       0       0
0x6010a8:       0       0       0       0
0x6010b8:       0       0       0       0
0x6010c8:       0       0       0       0
0x6010d8:       0       0       0       0
0x6010e8:       0       0       0       0
0x6010f8:       0       0       0       0

據此我們可以推測std::string對象使用字符串內容地址的前面0x18開始存儲的是size的值,也就是字符串地址前面的第24字節開始的8字節長度存儲size的值;類字符串buffer內存分配模型如下:


2.jpg

最後通過幾個例子驗證一下:

#include <stdio.h>
#include <string>

void foo(const std::string & ss) {
    char * ps = *((char **)&ss);
    printf("size=%d,*(ps - 0x18)=%d\n", ss.size(), *((long *)(ps - 24)));
}

int main(int argc, char * argv[])
{
    std::string ss("");
    foo(ss);

    ss = "1";
    foo(ss);

    ss = std::string("12") + '\0' + "34" + '\11' + "56" + '\255' + "78";
    foo(ss);

    return 0;
}

運行結果

size=0,*(ps - 0x18)=0
size=1,*(ps - 0x18)=1
size=11,*(ps - 0x18)=11