0%

C 转载系列(四)变量与内存(下篇)

引言

【上一篇】文章中介绍到 C/C++ 中一些基本类型变量在内存中的位置,本来想把字符串也在上一篇中介绍的,但测试发现字符串的情况比基本类型变量复杂的多,所以单独写一篇专门来介绍字符串。
这里说的字符串复杂是因为字符串实际就是 char 数组,且经常要和指针打交道,甚至不同编译器还有不同的表现,不是一句两句能说清楚的。本文旨在介绍一些常见情况下字符串实际在内存中的位置(段、栈和堆),读取字符串的过程,和一些需要注意的问题;本文编译器使用 clang 和 gcc 两种(默认使用 clang ),用以介绍不同编译器的不同点。

开发环境

  • OS X El Captian (10.11.6)
  • Apple LLVM 7.3.0 (clang-703.0.31)
  • gcc 6.1.0

字符串在内存中的位置

字符串的初始化一般有两种方法:

  • 数组:char str[] = "Hello";
  • 指针:char *str = "World";

这里考虑全局变量、静态局部变量和局部变量三种情况;对于全局变量和静态局部变量,还考虑已初始化和未初始化的情况。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#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

再看看段内数据:

__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 段中。采用数组方式初始化的 global_t1 将本数组内的所有值都放在 __data 段中;而采用指针方式初始化的 global_t2 则仅将指针放在 __data 段中,其指向 __cstring 段中的 “World”。

静态变量

变量名 内存地址 所属段 值(十六进制) 值(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 *

未初始化的静态局部变量在 __bss 段中,已初始化的放在 __data 段中。采用数组方式初始化的 local_stat_t1 将本数组内的所有值都放在 __data 段中;而采用指针方式初始化的 local_stat_t2 则仅将指针放在 __data 段中,其指向 __cstring 段中的 “Free”。

局部变量

分析局部变量我们直接看反汇编代码,将 clang 和 gcc 分开分析。

clang

char local_t1[] = "Here";

1
2
3
4
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 用来传送数据,movl 操作 32 位,movb 操作 8 位。

rip 为下一指令地址即 0x100000f58,所以 0x51(%rip) 表示的就是 0x100000fa9,即 __cstring 段中的 “Here”。注意到 “Here” 实际共 5 byte,不能用 movx 指令一次移动完,所以分两次移动,一次 4 byte,一次 1 byte,这样将 “Here” 放到 -0x11(%rbp) 开始的栈中。即 local_t1 将本数组内的所有值都放栈中。

char *local_t2 = "Comes";

1
2
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";

1
2
0000000100000f57           movl       $0x65726548, -0x10(%rbp) ## imm = 0x65726548
0000000100000f5e movb $0x0, -0xc(%rbp)

这里直接将 “Here “的二进制值 48 65 72 65 00 硬编码到汇编中,将其压入栈中。和 clang 一样,local_t1将本数组内的所有值都放栈中。

char *local_t2 = "Comes";

1
2
0000000100000f62           leaq       0x3b(%rip), %rax        ## literal pool for: "Comes"
0000000100000f69 movq %rax, -0x8(%rbp)

这里和 clang 一样,不作过多解释。local_t2 将指向 “Comes” 的指针放在栈中。

先说结论:采用数组方式初始化的 local_t1 将本数组内的所有值都放在栈中;而采用指针方式初始化的 local_t2 则仅将指针放在栈段中,其指向 __cstring 段中的 “Comes”。再谈谈 clang 和 gcc 的区别,主要就是在对以指针方式初始化的字符串上:clang 编译时将字符串放在 __cstring 段中,运行时从 __cstring 段复制到栈中;gcc 则在编译时直接将字符串放在代码段中,运行时直接压栈。两者的处理方式各有优缺点:clang 避免了将字符串常量放到代码段中,若代码中还有相同的字符串则可以合并为一个字符串,但运行时有复制的步骤,存在效率问题;gcc 直接将字符串放在代码段中,没有如上 clang 的好处,但运行时直接将值写入栈中,相对效率更高。

小结

可以看出,与基本类型变量不同的是,字符串变量用到了 __cstring 段用来保存字符串常量;但如果仔细分析,其实字符串同基本类型变量也没有太大差别,都遵循基本的规则。在对以数组方式初始化的局部变量的处理上,gcc 更常见,即将字符串以普通数组的方式编译;但 clang 则采用了一种“融合”的方法,将字符串所特有的 __cstring 段也利用了起来。
注意:这里没有介绍字符串在堆中的情况,因为在堆中的情况很简单:malloc() 申请堆空间,返回空间指针作为字符串头,按照常规方式读写或使用 string 库操作。

不同位置读取效率

这里仅考虑局部变量,分析两种初始化方式带来的读取效率差异。因为gcc和clang处理方法基本一样,所以这里只拿 clang 来举例。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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;
}

数组方式

1
2
char str1_0 = str1[0];
char str1_1 = str1[1];

对应的汇编:

1
2
3
4
0x100000f4a <+58>:  movb   -0x16(%rbp), %r8b
0x100000f4e <+62>: movb %r8b, -0x21(%rbp)
0x100000f52 <+66>: movb -0x15(%rbp), %r8b
0x100000f56 <+70>: movb %r8b, -0x22(%rbp)

这段汇编代码很容易(注意字符串数组就在栈里):

  1. 取栈中对应位置的数据,交给一个寄存器;
  2. 由寄存器将数据交给栈。

为什么要一个寄存器绕一下?因为栈在内存中,不能直接内存到内存,需要寄存器作为中间人。

指针方式

1
2
char str2_0 = str2[0];
char str2_1 = str2[1];

对应的汇编:

1
2
3
4
5
6
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段中):

  1. 从栈中取指向字符串的指针,交给一个寄存器;
  2. 取指针所指地址对应位置数据,交给一个寄存器;
  3. 由寄存器将数据交给栈。

为什么这里多绕一步?因为涉及到指针所指地址转换为地址的问题。

小结

对比两种方式可以发现,读取字符串时,数组方式只需要用到一个寄存器,而指针方式需要用到两个寄存器(需要先把指针读到寄存器),读取以数组方式初始化的字符串要比以指针方式初始化的字符串效率高。但以指针方式初始化的字符串在加载时就已经在内存中,而以数组方式初始化的字符串还要进行压栈的操作。

写字符串时的危险

这里介绍字符串在栈中时,可能对程序造成的破坏。实际介绍的就是 C 中危险的指针操作,不仅限于字符串,只要涉及到指针操作都可能造成下面说到的破坏。

例一

在栈中时,指向字符串的指针不仅能够修改字符串本身,还能不经意间修改其他变量的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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;
}

输出为:

1
a=87, b=1862270978, str="World"

简单解释下,因为 a 先入栈,”World” 随后入栈,b 最后入栈,此时指针 str_ptr 指向 ‘W’。注意栈的增长方向是像内存地址更小的方向,str_ptr + 6 已经超出字符串范围,到达了 b 的内存区域,这时在修改b的值;str_ptr - 3 修改的是 a 的最低一个 byte,所以 a 的值变为 ‘o’,即 87,至于为什么不是 str_ptr - 1,是因为涉及到内存对齐的问题。

例二

C 中函数调用时的返回地址、保存的寄存器的值等也是在栈中,指针操作还能威胁到整个程序的顺利运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

程序执行,在 return 0 时会提示程序出错 Thread 1: EXC_BAD_ACCESS (code=EXC_i386_GPLFT)。

这里是因为操作字符串指针持续向栈底方向写数据,破坏了程序的活动记录(Activate Record),造成程序出错。

小结

指针操作一定要慎重!


参考