前言

上一篇C/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";

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

C代码:

#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;
}

先看看几个典型的段信息:

sectnamesegnameaddrsizetype
__cstring__TEXT0x0000000100000f9e0x0000000000000016S_CSTRING_LITERALS
__data__DATA0x00000001000010180x0000000000000020S_REGULAR
__common__DATA0x00000001000010380x0000000000000010S_ZEROFILL
__bss__DATA0x00000001000010480x0000000000000010S_ZEROFILL

再看看段内数据:

__cstring:

addr startsizedata (0x)data (ASCII)
0x100000f9e657 6F 72 6C 64 00World
0x100000fa4546 72 65 65 00Free
0x100000fa9548 65 72 65 00Here
0x100000fae643 6F 6D 65 73 00Comes

gcc编译__cstring段中仅有"World""Free""Comes",没有"Here"

__data:

addr startsizedata (0x)data (ASCII)
0x100001018648 65 6C 6C 6F 00Hello
0x10000102089E 0F 00 00 01 00 00 00-
0x100001028654 68 69 6E 6B 00Think
0x1000010308A4 0F 00 00 01 00 00 00-

全局变量

变量名内存地址所属段值(十六进制)值(ASCII/0x)类型
global_unin_t10x100001038__common00 char []
global_unin_t20x100001040__common00 00 00 00 00 00 00 00NULLchar *
global_t10x100001018__data48 65 6C 6C 6F 00Hellochar [6]
global_t20x100001020__data9E 0F 00 00 01 00 00 000x100000f9echar *

未初始化的全局变量放在__common段中,已初始化的放在__data段中。采用数组方式初始化的global_t1将本数组内的所有值都放在__data段中;而采用指针方式初始化的global_t2则仅将指针放在__data段中,其指向__cstring段中的"World"

静态变量

变量名内存地址所属段值(十六进制)值(ASCII/0x)类型
local_stat_unin_t10x100001048__bss00 char []
local_stat_unin_t20x100001050__bss00 00 00 00 00 00 00 00NULLchar *
local_stat_t10x100001028__data54 68 69 6E 6B 00Thinkchar [6]
local_stat_t20x100001030__dataA4 0F 00 00 01 00 00 000x100000fa4char *

未初始化的静态局部变量放在__bss段中,已初始化的放在__data段中。采用数组方式初始化的local_stat_t1将本数组内的所有值都放在__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用来传送数据,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";

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将本数组内的所有值都放栈中。

char *local_t2 = "Comes";

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来举例。

C代码:

#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;
}

数组方式

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)

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

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

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

指针方式

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段中):

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

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

小结

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

写字符串时的危险

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

例一

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

#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已经超出字符串范围,到达了b的内存区域,这时在修改b的值;str_ptr - 3修改的是a的最低一个byte,所以a的值变为'o',即87,至于为什么不是str_ptr - 1,是因为涉及到内存对齐的问题。

例二

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

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),造成程序出错。

小结

指针操作一定要慎重!

参考