3. 变量的存储布局

首先看下面的例子:

例 19.2. 研究变量的存储布局

#include <stdio.h>

const int A = 10;
int a = 20;
static int b = 30;
int c;

int main(void)
{
	static int a = 40;
	char b[] = "Hello world";
	register int c = 50;

	printf("Hello world %d\n", c);

	return 0;
}

我们在全局作用域和main函数的局部作用域各定义了一些变量,并且引入一些新的关键字conststaticregister来修饰变量,那么这些变量的存储空间是怎么分配的呢?我们编译之后用readelf命令看它的符号表,了解各变量的地址分布。注意在下面的清单中我把符号表按地址从低到高的顺序重新排列了,并且只截取我们关心的那几行。

$ gcc main.c -g
$ readelf -a a.out
...
    68: 08048540     4 OBJECT  GLOBAL DEFAULT   15 A
    69: 0804a018     4 OBJECT  GLOBAL DEFAULT   23 a
    52: 0804a01c     4 OBJECT  LOCAL  DEFAULT   23 b
    53: 0804a020     4 OBJECT  LOCAL  DEFAULT   23 a.1589
    81: 0804a02c     4 OBJECT  GLOBAL DEFAULT   24 c
...

变量A用const修饰,表示A是只读的,不可修改,它被分配的地址是0x8048540,从readelf的输出可以看到这个地址位于.rodata段:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
...
  [13] .text             PROGBITS        08048360 000360 0001bc 00  AX  0   0 16
...
  [15] .rodata           PROGBITS        08048538 000538 00001c 00   A  0   0  4
...
  [23] .data             PROGBITS        0804a010 001010 000014 00  WA  0   0  4
  [24] .bss              NOBITS          0804a024 001024 00000c 00  WA  0   0  4
...

它在文件中的地址是0x538~0x554,我们用hexdump命令看看这个段的内容:

$ hexdump -C a.out
...
00000530  5c fe ff ff 59 5b c9 c3  03 00 00 00 01 00 02 00  |\...Y[..........|
00000540  0a 00 00 00 48 65 6c 6c  6f 20 77 6f 72 6c 64 20  |....Hello world |
00000550  25 64 0a 00 00 00 00 00  00 00 00 00 00 00 00 00  |%d..............|
...

其中0x540地址处的0a 00 00 00就是变量A。我们还看到程序中的字符串字面值"Hello world %d\n"分配在.rodata段的末尾,在第 4 节 “字符串”说过字符串字面值是只读的,相当于在全局作用域定义了一个const数组:

const char helloworld[] = {'H', 'e', 'l', 'l', 'o', ' ',
		 	'w', 'o', 'r', 'l', 'd', ' ', '%', 'd', '\n', '\0'};

程序加载运行时,.rodata段和.text段通常合并到一个Segment中,操作系统将这个Segment的页面只读保护起来,防止意外的改写。这一点从readelf的输出也可以看出来:

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame 
   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag 
   06     
   07     .ctors .dtors .jcr .dynamic .got 

注意,像A这种const变量在定义时必须初始化。因为只有初始化时才有机会给它一个值,一旦定义之后就不能再改写了,也就是不能再赋值了。

从上面readelf的输出可以看到.data段从地址0x804a010开始,长度是0x14,也就是到地址0x804a024结束。在.data段中有三个变量,aba.1589

a是一个GLOBAL的符号,而bstatic关键字修饰了,导致它成为一个LOCAL的符号,所以static在这里的作用是声明b这个符号为LOCAL的,不被链接器处理,在下一章我们会看到,如果把多个目标文件链接在一起,LOCAL的符号只能在某一个目标文件中定义和使用,而不能定义在一个目标文件中却在另一个目标文件中使用。一个函数定义前面也可以用static修饰,表示这个函数名符号是LOCAL的。

还有一个a.1589是什么呢?它就是main函数中的static int a。函数中的static变量不同于以前我们讲的局部变量,它并不是在调用函数时分配,在函数返回时释放,而是像全局变量一样静态分配,所以用“static”(静态)这个词。另一方面,函数中的static变量的作用域和以前讲的局部变量一样,只在函数中起作用,比如main函数中的a这个变量名只在main函数中起作用,在别的函数中说变量a就不是指它了,所以编译器给它的符号名加了一个后缀,变成a.1589,以便和全局变量a以及其它函数的变量a区分开。

.bss段从地址0x804a024开始(紧挨着.data段),长度为0xc,也就是到地址0x804a030结束。变量c位于这个段。从上面的readelf输出可以看到,.data.bss在加载时合并到一个Segment中,这个Segment是可读可写的。.bss段和.data段的不同之处在于,.bss段在文件中不占存储空间,在加载时这个段用0填充。所以我们在第 4 节 “全局变量、局部变量和作用域”讲过,全局变量如果不初始化则初值为0,同理可以推断,static变量(不管是函数里的还是函数外的)如果不初始化则初值也是0,也分配在.bss段。

现在还剩下函数中的bc这两个变量没有分析。上一节我们讲过函数的参数和局部变量是分配在栈上的,b是数组也一样,也是分配在栈上的,我们看main函数的反汇编代码:

$ objdump -dS a.out
...
        char b[]="Hello world";
 8048430:       c7 45 ec 48 65 6c 6c    movl   $0x6c6c6548,-0x14(%ebp)
 8048437:       c7 45 f0 6f 20 77 6f    movl   $0x6f77206f,-0x10(%ebp)
 804843e:       c7 45 f4 72 6c 64 00    movl   $0x646c72,-0xc(%ebp)
        register int c = 50;
 8048445:       b8 32 00 00 00          mov    $0x32,%eax

        printf("Hello world %d\n", c);
 804844a:       89 44 24 04             mov    %eax,0x4(%esp)
 804844e:       c7 04 24 44 85 04 08    movl   $0x8048544,(%esp)
 8048455:       e8 e6 fe ff ff          call   8048340 <printf@plt>
...

可见,给b初始化用的这个字符串"Hello world"并没有分配在.rodata段,而是直接写在指令里了,通过三条movl指令把12个字节写到栈上,这就是b的存储空间,如下图所示。

图 19.4. 数组的存储布局

数组的存储布局

注意,虽然栈是从高地址向低地址增长的,但数组总是从低地址向高地址排列的,按从低地址到高地址的顺序依次是b[0]b[1]b[2]……这样,

数组元素b[n]的地址 = 数组的基地址(b做右值就表示这个基地址) + n × 每个元素的字节数

当n=0时,元素b[0]的地址就是数组的基地址,因此数组下标要从0开始而不是从1开始。

变量c并没有在栈上分配存储空间,而是直接存在eax寄存器里,后面调用printf也是直接从eax寄存器里取出c的值当参数压栈,这就是register关键字的作用,指示编译器尽可能分配一个寄存器来存储这个变量。我们还看到调用printf时对于"Hello world %d\n"这个参数压栈的是它在.rodata段中的首地址,而不是把整个字符串压栈,所以在第 4 节 “字符串”中说过,字符串在使用时可以看作数组名,如果做右值则表示数组首元素的地址(或者说指向数组首元素的指针),我们以后讲指针还要继续讨论这个问题。

以前我们用“全局变量”和“局部变量”这两个概念,主要是从作用域上区分的,现在看来用这两个概念给变量分类太笼统了,需要进一步细分。我们总结一下相关的C语法。

作用域(Scope)这个概念适用于所有标识符,而不仅仅是变量,C语言的作用域分为以下几类:

对属于同一命名空间(Name Space)的重名标识符,内层作用域的标识符将覆盖外层作用域的标识符,例如局部变量名在它的函数中将覆盖重名的全局变量。命名空间可分为以下几类:

标识符的链接属性(Linkage)有三种:

存储类修饰符(Storage Class Specifier)有以下几种关键字,可以修饰变量或函数声明:

注意,上面介绍的const关键字不是一个Storage Class Specifier,虽然看起来它也修饰一个变量声明,但是在以后介绍的更复杂的声明中const在语法结构中允许出现的位置和Storage Class Specifier是不完全相同的。const和以后要介绍的restrictvolatile关键字属于同一类语法元素,称为类型限定符(Type Qualifier)

变量的生存期(Storage Duration,或者Lifetime)分为以下几类:



[30] 为了容易阅读,这里我用了“程序文件”这个不严格的叫法。如果有文件a.c包含了b.hc.h,那么我所说的“程序文件”指的是经过预处理把b.hc.ha.c中展开之后生成的代码,在C标准中称为编译单元(Translation Unit)。每个编译单元可以分别编译成一个.o目标文件,最后这些目标文件用链接器链接到一起,成为一个可执行文件。C标准中大量使用一些非常不通俗的名词,除了编译单元之外,还有编译器叫Translator,变量叫Object,本书不会采用这些名词,因为我不是在写C标准。