引言
【上一篇】文章中介紹到 C/C++ 中一些基本類型變量在內存中的位置皮假,本來想把字符串也在上一篇中介紹的粘招,但測試發(fā)現(xiàn)字符串的情況比基本類型變量復雜的多,所以單獨寫一篇專門來介紹字符串拌喉。
這里說的字符串復雜是因為字符串實際就是 char 數(shù)組派诬,且經(jīng)常要和指針打交道盟萨,甚至不同編譯器還有不同的表現(xiàn),不是一句兩句能說清楚的兰伤。本文旨在介紹一些常見情況下字符串實際在內存中的位置(段内颗、棧和堆),讀取字符串的過程敦腔,和一些需要注意的問題均澳;本文編譯器使用 clang 和 gcc 兩種(默認使用 clang ),用以介紹不同編譯器的不同點会烙。
開發(fā)環(huán)境
- OS X El Captian (10.11.6)
- Apple LLVM 7.3.0 (clang-703.0.31)
- gcc 6.1.0
字符串在內存中的位置
字符串的初始化一般有兩種方法:
- 數(shù)組:
char str[] = "Hello";
- 指針:
char *str = "World";
這里考慮全局變量负懦、靜態(tài)局部變量和局部變量三種情況;對于全局變量和靜態(tài)局部變量柏腻,還考慮已初始化和未初始化的情況纸厉。
代碼:
#include <unistd.h>
char global_unin_t1[] = {};
char *global_unin_t2;
char global_t1[] = "Hello";
char *global_t2 = "World";
int main(int argc, const char *argv[]) {
static char local_stat_unin_t1[] = {};
static char *local_stat_unin_t2;
static char local_stat_t1[] = "Think";
static char *local_stat_t2 = "Free";
char local_t1[] = "Here";
char *local_t2 = "Comes";
sleep(-1);
return 0;
}
先看看幾個典型的段信息:
sectname | segname | addr | size | type |
---|---|---|---|---|
__cstring | __TEXT | 0x0000000100000f9e | 0x0000000000000016 | S_CSTRING_LITERALS |
__data | __DATA | 0x0000000100001018 | 0x0000000000000020 | S_REGULAR |
__common | __DATA | 0x0000000100001038 | 0x0000000000000010 | S_ZEROFILL |
__bss | __DATA | 0x0000000100001048 | 0x0000000000000010 | S_ZEROFILL |
再看看段內數(shù)據(jù):
__cstring:
addr start | size | data (0x) | data (ASCII) |
---|---|---|---|
0x100000fae | 6 | 43 6F 6D 65 73 00 | Comes |
0x100000fa4 | 5 | 46 72 65 65 00 | Free |
0x100000fa9 | 5 | 48 65 72 65 00 | Here |
0x100000f9e | 6 | 57 6F 72 6C 64 00 | World |
gcc 編譯 __cstring 段中僅有 "World"、"Free" 和 "Comes"五嫂,沒有 "Here"颗品。
__data:
addr start | size | data (0x) | data (ASCII) |
---|---|---|---|
0x100001018 | 6 | 48 65 6C 6C 6F 00 | Hello |
0x100001020 | 8 | 9E 0F 00 00 01 00 00 00 | - |
0x100001028 | 6 | 54 68 69 6E 6B 00 | Think |
0x100001030 | 8 | A4 0F 00 00 01 00 00 00 | - |
全局變量
變量名 | 內存地址 | 所屬段 | 值(十六進制) | 值(ASCII/0x) | 類型 |
---|---|---|---|---|---|
global_unin_t1 | 0x100001038 | __common | 00 | char [] | |
global_t1 | 0x100001018 | __data | 48 65 6C 6C 6F 00 | Hello | char [6] |
global_t2 | 0x100001020 | __data | 9E 0F 00 00 01 00 00 00 | 0x100000f9e | char * |
global_unin_t2 | 0x100001040 | __common | 00 00 00 00 00 00 00 00 | NULL | char * |
未初始化的全局變量放在 __common 段中肯尺,已初始化的放在 __data 段中。采用數(shù)組方式初始化的 global_t1 將本數(shù)組內的所有值都放在 __data 段中躯枢;而采用指針方式初始化的 global_t2 則僅將指針放在 __data 段中则吟,其指向 __cstring 段中的 "World"。
靜態(tài)變量
變量名 | 內存地址 | 所屬段 | 值(十六進制) | 值(ASCII/0x) | 類型 |
---|---|---|---|---|---|
local_stat_unin_t1 | 0x100001048 | __bss | 00 | char [] | |
local_stat_unin_t2 | 0x100001050 | __bss | 00 00 00 00 00 00 00 00 | NULL | char * |
local_stat_t1 | 0x100001028 | __data | 54 68 69 6E 6B 00 | Think | char [6] |
local_stat_t2 | 0x100001030 | __data | A4 0F 00 00 01 00 00 00 | 0x100000fa4 | char * |
未初始化的靜態(tài)局部變量在 __bss 段中锄蹂,已初始化的放在 __data 段中氓仲。采用數(shù)組方式初始化的 local_stat_t1 將本數(shù)組內的所有值都放在 __data 段中;而采用指針方式初始化的 local_stat_t2 則僅將指針放在 __data 段中得糜,其指向 __cstring 段中的 "Free"敬扛。
局部變量
分析局部變量我們直接看反匯編代碼,將 clang 和 gcc 分開分析朝抖。
clang
char local_t1[] = "Here";
0x100000f52 <+34>: movl 0x51(%rip), %edi ; "Here"
0x100000f58 <+40>: movl %edi, -0x15(%rbp)
0x100000f5b <+43>: movb 0x4c(%rip), %dl ; ""
0x100000f61 <+49>: movb %dl, -0x11(%rbp)
這里 rip 為指令指針寄存器啥箭,rbp 為幀指針(Frame Pointer)寄存器;mov 用來傳送數(shù)據(jù)治宣,movl 操作 32 位急侥,movb 操作 8 位。
rip 為下一指令地址即 0x100000f58侮邀,所以 0x51(%rip) 表示的就是 0x100000fa9坏怪,即 __cstring 段中的 "Here"。注意到 "Here" 實際共 5 byte绊茧,不能用 movx 指令一次移動完陕悬,所以分兩次移動,一次 4 byte按傅,一次 1 byte,這樣將 "Here" 放到 -0x11(%rbp) 開始的棧中胧卤。即 local_t1 將本數(shù)組內的所有值都放棧中唯绍。
char *local_t2 = "Comes";
0x100000f3d <+13>: leaq 0x6a(%rip), %rcx ; "Comes"
0x100000f64 <+52>: movq %rcx, -0x20(%rbp)
這里 rip 為指令指針寄存器,rbp 為幀指針(Frame Pointer)寄存器枝誊;lea 將地址指針寫入到寄存器况芒,leaq 操作 64 位。
0x6a(%rip) 表示的是 0x100000fae叶撒,即 __cstring 段中的 "Comes"绝骚,最終這個地址被放到 -0x20(%rbp) 開始的棧中。即 local_t2 將指向 "Comes" 的指針放在棧中祠够。
gcc
char local_t1[] = "Here";
0000000100000f57 movl $0x65726548, -0x10(%rbp) ## imm = 0x65726548
0000000100000f5e movb $0x0, -0xc(%rbp)
這里直接將 "Here "的二進制值 48 65 72 65 00 硬編碼到匯編中压汪,將其壓入棧中。和 clang 一樣古瓤,local_t1將本數(shù)組內的所有值都放棧中止剖。
char *local_t2 = "Comes";
0000000100000f62 leaq 0x3b(%rip), %rax ## literal pool for: "Comes"
0000000100000f69 movq %rax, -0x8(%rbp)
這里和 clang 一樣腺阳,不作過多解釋。local_t2 將指向 "Comes" 的指針放在棧中穿香。
先說結論:采用數(shù)組方式初始化的 local_t1 將本數(shù)組內的所有值都放在棧中亭引;而采用指針方式初始化的 local_t2 則僅將指針放在棧段中,其指向 __cstring 段中的 "Comes"皮获。再談談 clang 和 gcc 的區(qū)別焙蚓,主要就是在對以指針方式初始化的字符串上:clang 編譯時將字符串放在 __cstring 段中,運行時從 __cstring 段復制到棧中洒宝;gcc 則在編譯時直接將字符串放在代碼段中购公,運行時直接壓棧。兩者的處理方式各有優(yōu)缺點:clang 避免了將字符串常量放到代碼段中待德,若代碼中還有相同的字符串則可以合并為一個字符串君丁,但運行時有復制的步驟,存在效率問題将宪;gcc 直接將字符串放在代碼段中绘闷,沒有如上 clang 的好處,但運行時直接將值寫入棧中较坛,相對效率更高印蔗。
小結
可以看出,與基本類型變量不同的是丑勤,字符串變量用到了 __cstring 段用來保存字符串常量华嘹;但如果仔細分析,其實字符串同基本類型變量也沒有太大差別法竞,都遵循基本的規(guī)則耙厚。在對以數(shù)組方式初始化的局部變量的處理上,gcc 更常見岔霸,即將字符串以普通數(shù)組的方式編譯薛躬;但 clang 則采用了一種“融合”的方法,將字符串所特有的 __cstring 段也利用了起來呆细。
注意:這里沒有介紹字符串在堆中的情況型宝,因為在堆中的情況很簡單:malloc() 申請堆空間,返回空間指針作為字符串頭絮爷,按照常規(guī)方式讀寫或使用 string 庫操作趴酣。
不同位置讀取效率
這里僅考慮局部變量,分析兩種初始化方式帶來的讀取效率差異坑夯。因為gcc和clang處理方法基本一樣岖寞,所以這里只拿 clang 來舉例。
代碼:
#include <unistd.h>
int main(int argc, const char *argv[]) {
char str1[] = "Hello";
char *str2 = "World";
char str1_0 = str1[0];
char str1_1 = str1[1];
char str2_0 = str2[0];
char str2_1 = str2[1];
sleep(-1);
return 0;
}
數(shù)組方式
char str1_0 = str1[0];
char str1_1 = str1[1];
對應的匯編:
0x100000f4a <+58>: movb -0x16(%rbp), %r8b
0x100000f4e <+62>: movb %r8b, -0x21(%rbp)
0x100000f52 <+66>: movb -0x15(%rbp), %r8b
0x100000f56 <+70>: movb %r8b, -0x22(%rbp)
這段匯編代碼很容易(注意字符串數(shù)組就在棧里):
- 取棧中對應位置的數(shù)據(jù)渊涝,交給一個寄存器慎璧;
- 由寄存器將數(shù)據(jù)交給棧床嫌。
為什么要一個寄存器繞一下?因為棧在內存中胸私,不能直接內存到內存厌处,需要寄存器作為中間人。
指針方式
char str2_0 = str2[0];
char str2_1 = str2[1];
對應的匯編:
0x100000f5a <+74>: movq -0x20(%rbp), %rcx
0x100000f5e <+78>: movb (%rcx), %r8b
0x100000f61 <+81>: movb %r8b, -0x23(%rbp)
0x100000f65 <+85>: movq -0x20(%rbp), %rcx
0x100000f69 <+89>: movb 0x1(%rcx), %r8b
0x100000f6d <+93>: movb %r8b, -0x24(%rbp)
這段匯編代碼多個一個步驟(注意字符串在__cstring段中):
- 從棧中取指向字符串的指針岁疼,交給一個寄存器阔涉;
- 取指針所指地址對應位置數(shù)據(jù),交給一個寄存器捷绒;
- 由寄存器將數(shù)據(jù)交給棧瑰排。
為什么這里多繞一步?因為涉及到指針所指地址轉換為地址的問題暖侨。
小結
對比兩種方式可以發(fā)現(xiàn)椭住,讀取字符串時,數(shù)組方式只需要用到一個寄存器字逗,而指針方式需要用到兩個寄存器(需要先把指針讀到寄存器)京郑,讀取以數(shù)組方式初始化的字符串要比以指針方式初始化的字符串效率高。但以指針方式初始化的字符串在加載時就已經(jīng)在內存中葫掉,而以數(shù)組方式初始化的字符串還要進行壓棧的操作些举。
寫字符串時的危險
這里介紹字符串在棧中時,可能對程序造成的破壞俭厚。實際介紹的就是 C 中危險的指針操作户魏,不僅限于字符串,只要涉及到指針操作都可能造成下面說到的破壞挪挤。
例一
在棧中時叼丑,指向字符串的指針不僅能夠修改字符串本身,還能不經(jīng)意間修改其他變量的值扛门。
#include <stdio.h>
int main(int argc, const char *argv[]) {
int a = 1;
char str[] = "World";
int b = 2;
char *str_ptr = &str[0];
*(str_ptr + 6) = 'W';
*(str_ptr - 3) = 'o'; // align
printf("a=%d, b=%d, str=\"%s\"\n", a, b, str);
return 0;
}
輸出為:
a=87, b=1862270978, str="World"
簡單解釋下幢码,因為 a 先入棧,"World" 隨后入棧尖飞,b 最后入棧,此時指針 str_ptr 指向 'W'店雅。注意棧的增長方向是像內存地址更小的方向政基,str_ptr + 6 已經(jīng)超出字符串范圍,到達了 b 的內存區(qū)域闹啦,這時在修改b的值沮明;str_ptr - 3 修改的是 a 的最低一個 byte,所以 a 的值變?yōu)?'o'窍奋,即 87荐健,至于為什么不是 str_ptr - 1酱畅,是因為涉及到內存對齊的問題。
例二
C 中函數(shù)調用時的返回地址江场、保存的寄存器的值等也是在棧中纺酸,指針操作還能威脅到整個程序的順利運行。
void write_str(char *str) {
for (int i = 2; i < 40; i++) {
*(str + i) = 'd';
}
}
int main(int argc, const char *argv[]) {
char str[] = "World";
char *str_ptr = &str[0];
write_str(str);
return 0;
}
程序執(zhí)行址否,在 return 0 時會提示程序出錯 Thread 1: EXC_BAD_ACCESS (code=EXC_i386_GPLFT)餐蔬。
這里是因為操作字符串指針持續(xù)向棧底方向寫數(shù)據(jù),破壞了程序的活動記錄(Activate Record)佑附,造成程序出錯樊诺。
小結
指針操作一定要慎重!