以下面C程序为例:
#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函数的局部作用域各定义了一些变量,并且引入一些新的关键字const, static, register来修饰变量,那么这些变量的存储空间是怎么分配的呢?我们编译之后用readelf命令看它的符号表,了解各变量的地址分布。下面的清单中原作者把符号表按地址从低到高重新排列了,并且只截取了我们关心的那几行:
变量A用const修饰,表示A是只读的,不可修改,它被分配的地址是0x8048540,从readelf的输出可以看到这个地址位于.rodata段:
它在文件中的地址是0x538~0x554,我们用hexdump命令看这个段的内容:
其中0x540地址处的0a 00 00 00 就是变量A,我们还看到程序中的字符串字面值"Hello world %d\n"分配在.rodata段的末尾,字符串的字面值是只读的,相当于在全局作用域定义了一个const数组:
程序加载运行时,.rodata段和.text段通常合并到一个Segment中,操作系统将这个Segment只读保护起来,防止意外改写。这一点从readelf的输出也可以看出来:
注意,像A这种const变量在定义时必须初始化。因为只有初始化才有机会给它一个值,一旦定义之后就不能再改写了,即不能再赋值。
从上面readelf的输出可以看到.data段从地址0x804a010开始,长度是0x14,也就是到地址0x804a024结束。在.data段中有三个变量,a,b和a.1589。
a是一个GLOBAL符号,而b被static关键字修饰了,导致它成为一个LOCAL的符号,所以static在这里的作用是声明b这个符号为LOCAL的,不被链接器处理,如果把多个目标文件链接在一起,LOCAL的符号只能在某一个目标文件中定义和使用,而不能定义在一个目标文件中却在另一个目标文件中使用。一个函数定义前面也可以用static修饰,表示这个函数名符号是LOCAL的。
还有一个a.1589是什么呢?它是main函数中的static int a。函数中的static变量不同于局部变量,它并不是在调用函数时分配在函数返回时释放,而是像全局变量一样静态分配,所以用"static"这个词。另一方面,函数中的static变量的作用域和局部变量一样只在函数中起作用,比如main函数中的a这个变量名只在main函数中起作用,所以编译器给它的符号加了一个后缀以便和全局变量a以及其他函数的变量a区分开。
.bss段从地址0x804a024开始,长度为0xc,也就是到地址0x804a030结束。变量c位于这个段。从上面的readelf输出可以看到.data和.bss在加载时合并到一个Segment中,这个Segment是可读写的。.bss段和.data段的不同之处在于.bss段在文件中不占存储空间,在加载时这个段用0填充。所以全局变量如果不初始化则初值为0,也分配在.bss段。
现在还剩下函数中的b和c这两个变量没有分析。函数的参数和局部变量是分配在栈上的,b是数组也一样,也是分配在栈上的,我们看main函数的反汇编代码:
可见,给b初始化用的这个字符串"Hello world"并没有分配在.rodata段,而是直接写在指令里了,通过三条movl指令把12个字节写到栈上,这就是b的存储空间,如下图所示:
虽然栈是从高地址向低地址增长的,但数组总是从低地址向高地址排列的,按从低地址到高地址的顺序依次是b[0]、b[1]、b[2]......
数组元素b[n]的地址 = 数组的基地址(b做右值就表示这个基地址) + n x 每个元素的字节数,当n=0时,元素b[0]就是数组的基地址,因此数组下标要从0开始而不是从1开始。变量c并没有在栈上分配存储空间,而是直接存在eax寄存器里,后面调用printf也是直接从eax寄存器里取出c的值当参数压栈,这就是register关键字的作用,指示编译器尽可能分配一个寄存器来存储这个变量。调用printf时对于"Hello world %d\n"这个参数压栈的是它在.rodata段中的首地址,而不是把整个字符串压栈。所以字符串在使用时可以看做数组名,如果做右值则表示数组首元素的地址。
我们用全局变量和局部变量这两个概念主要是从作用域上区分的,现在看来用着两个概念给变量区分太笼统了,需要进一步细分。我们总计一下相关的C语法:
作用域这个概念使用于所有标识符,而不仅仅是变量,C语言的作用域分为一下几类:
- 函数作用域,标识符在整个函数中都有效。只有语句标号属于函数作用域。标号在函数中不需要先声明后使用,在前面用一个goto语句也可以跳转到后面的某个标号,但仅限于同一个函数之中。
- 文件作用域, 标识符从它声明的位置开始知道这个程序文件的末尾都有效。例如上例中main函数外面的A、a、b、c还有main也算,printf其实是在stdio.h中声明的被包含到这个程序文件中了,所以也算文件作用域的。
- 块作用域, 标识符位于一对{}括号中(函数体或语句块),从它声明的位置开始到右}括号之间有效。例如上例中main函数里的a、b、c,此外,函数定义中的形参也算块作用域的,从声明的位置开始到函数末尾之间有效。
- 函数原型作用域, 标识符出现在函数原型中,这个函数原型只是一个声明而不是定义(没有函数体),那么标识符从声明的位置开始到在这个原型末尾之间有效。例如int foo(int a, int b);中的a和b。
对属于同一命名空间的重名标识符,内层作用域会覆盖外层作用域的标识符。命名空间可分为以下几类:
- 语句标号单独属于一个命名空间。例如在函数中局部变量和语句标号可以重名,互不影响。由于使用标号的语法和使用其它标识符的语法都不一样,编译器不会把它和别的标识符弄混。
- struct, enum和union的类型Tag属于一个命名空间。由于Tag前面总是带struct, enum, union关键字,所以编译器不会把它和别的标识符弄混。
- strcut和union的成员名属于一个命名空间。由于成员名总是通过.或->运算符来访问而不会单独使用,所以编译器不会把ta和别的标识符弄混。
- 所有其他标识符,例如变量名、函数名、宏定义、typedef的类型名、enum成员等等都属于同一个命名空间,如果有重名的话,宏定义覆盖所有其他标识符,因为它在预处理阶段而不是编译阶段处理,除了宏定义之外其它几类标识符按上面所说的规则处理,内层作用域覆盖外层作用域。
标识符的链接属性有三种:
- 外部链接(External Linkage), 如果最终的可执行文件由多个程序文件链接而成,一个标识符在任意程序文件中即使声明多次也都代表同一个变量或函数,则这个标识符具有External Linkage。具有External的标识符编译后在符号表中是GLOBAL的符号。例如上例中main函数外面的a和c,main和printf也算。
- 内部链接(Internal Linkage),如果一个标识符在某个程序文件中即使声明多次也都代表同一个变量或函数,则这个标识符具有Internal Linkage。例如上例中main函数外面的b。具有Internal Linkage的标识符编译后在符号表中是LOCAL的符号,但main函数里面那个b不能算Internal Linkage,因为即使在同一个程序文件中,在不同的函数中声明多次,也不代表同一个变量。
- 无链接(No Linkage) 。除以上情况之外的标识符都属于No Linkage的,例如函数的局部变量以及不表示变量和函数的其他标识符。
存储类修饰符(Storage Class Specifier)有以下几种关键字,可以修饰变量或函数声明:
- static ,用它修饰的变量的存储空间是静态分配的,用它修饰的文件作用域的变量或函数具有Internal Linkage。
- auto ,用它修饰的变量在函数调用时自动在栈上分配存储空间,函数返回时自动释放,例如上例中 main 函数里的 b 其实就是用 auto 修饰的,只不过 auto 可以省略不写, auto 不能修饰文件作用域的变量。
- register ,编译器对于用 register 修饰的变量会尽可能分配一个专门的寄存器来存储,但如果实在分配不开寄存器,编译器就把它当 auto 变量处理了, register 不能修饰文件作用域的变量。现在一般编译器的优化都做得很好了,它自己会想办法有效地利用CPU的寄存器,所以现在 register 关键字也用得比较少了。
- extern ,上面讲过,链接属性是根据一个标识符多次声明时是不是代表同一个变量或函数来分类的, extern 关键字就用于多次声明同一个标识符,下一章再详细介绍它的用法。
- typedef ,它并不是用来修饰变量的,而是定义一个类型名。typedef 在语法结构中出现的位置和是面几个关键字一样,也是修饰变量定义的,所以从语法(而不是语义)的角度把它和前面几个关键字归类到一起。
上面介绍的 const 关键字不是一个Storage Class Specifier,虽然看起来它也修饰一个变量声明,但是在以后介绍的更复杂的声明中 const 在语法结构中允许出现的位置和Storage Class Specifier是不完全相同的。 const 和以后要介绍的 restrict 和 volatile 关键字属于同一类语法元素,称为类型限定符(Type Qualifier)。
变量的生存期(Storage Duration,或者Lifetime)分为以下几类:
- 静态生存期(Static Storage Duration),具有外部或内部链接属性,或者被 static 修饰的变量,在程序开始执行时分配和初始化一次,此后便一直存在直到程序结束。这种变量通常位于 .rodata , .data 或 .bss 段,例如上例中 main 函数外的 A , a , b , c ,以及 main 函数里的 a 。
- 自动生存期(Automatic Storage Duration),链接属性为无链接并且没有被 static 修饰的变量,这种变量在进入块作用域时在栈上或寄存器中分配,在退出块作用域时释放。例如上例中 main 函数里的 b 和 c 。
- 动态分配生存期(Allocated Storage Duration),以后会讲到调用 malloc 函数在进程的堆空间中分配内存,调用 free 函数可以释放这种存储空间。