编译器对代码块中变量的处理
问题
最近在学习函数调用过程时碰到了如下问题:
代码块(中间花括号)中定义的局部变量a在栈中是何时建立,何时销毁的?
1 #include <stdio.h> 2 3 int main(int argc, char *argv[]) 4 { 5 int a = 5; 6 int *p = NULL; 7 { 8 int a = 11; 9 p = &a; 10 } 11 /* printf("%d\n",a); */ 12 printf("%d\n",*p); 13 return 0; 14 }
分析
以上程序的输出结果是11。
看到网上的讨论,主要的观点有两种:
① 用push和pop指令,在进入代码块时分配,出代码块时销毁
② 在函数入口处,和函数体中所有的局部变量一起分配,其实现是直接将栈指针减去某一个值
针对以上两种观点,最好的验证手段就看看编译出来的汇编代码。
反汇编如下(平台是x86-64/gcc-4.7.3):
000000000040052c <main>: #include <stdio.h> int main(int argc, char *argv[]) { 40052c: 55 push %rbp 40052d: 48 89 e5 mov %rsp,%rbp 400530: 48 83 ec 20 sub $0x20,%rsp 400534: 89 7d ec mov %edi,-0x14(%rbp) 400537: 48 89 75 e0 mov %rsi,-0x20(%rbp) int a = 5; 40053b: c7 45 f4 05 00 00 00 movl $0x5,-0xc(%rbp) int *p = NULL; 400542: 48 c7 45 f8 00 00 00 movq $0x0,-0x8(%rbp) 400549: 00 { int a = 11; 40054a: c7 45 f0 0b 00 00 00 movl $0xb,-0x10(%rbp) p = &a; 400551: 48 8d 45 f0 lea -0x10(%rbp),%rax 400555: 48 89 45 f8 mov %rax,-0x8(%rbp) } /* printf("%d\n",a); */ printf("%d\n",*p); 400559: 48 8b 45 f8 mov -0x8(%rbp),%rax 40055d: 8b 00 mov (%rax),%eax 40055f: 89 c6 mov %eax,%esi 400561: bf 24 06 40 00 mov $0x400624,%edi 400566: b8 00 00 00 00 mov $0x0,%eax 40056b: e8 a0 fe ff ff callq 400410 <printf@plt> return 0; 400570: b8 00 00 00 00 mov $0x0,%eax } 400575: c9 leaveq 400576: c3 retq 400577: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1) 40057e: 00 00
汇编语句 sub $0x20,%rsp,说明变量的分配确实是将栈指针直接减去了0x20,而不是伴随着代码块在栈中分配与释放。
在上述语句为局部变量分配空间之后,对局部变量的初始化和访问,就可以用rbp寄存器加上偏移来定位了,如对a的初始化:movl $0xb,-0x10(%rbp)
也正因为,在出了代码块之后,变量还未被释放,所以还是可以用指针p访问到它的值并没有发生变化。(注意不能直接访问变量a,否则访问到的将是函数体中的局部变量,最终输出结果将是5)
有人或许会疑惑:局部变量的作用域就是就是包含它的最内层的花括号(most-closely nested rule),那用push和pop对代码块中的变量进行分配不是很符合思维习惯吗?
关于这个问题,需要明确变量属性中作用域和生存周期的区别。Block structure中的变量虽然作用域是界定它的花括号,但其生存周期却是与函数栈帧一致的。就好比函数中的static变量,其作用域同上,但其生存周期却是整个程序运行期。
这个问题深入下去,还涉及到编译原理相关知识。编译的过程大致可以分为分析(analysis)和综合(synthesis)。分析阶段很重要的一个任务就是提取出程序中的符号表(symbol table)。符号表一般以一个块结构为单位建立。编译器对各个符号表进行栈式的管理,当解析一个符号时,一层一层地往上查找,直到在某个符号表中找到它的声明或定义,它的作用域也就确定下来了。
至于生存周期则是代码综合的结果,也就是我们最终看到的汇编代码。关于为何将代码综合成这样子,是出于什么考虑,相比于push和pop的分配方式有什么优点,本人也不清楚,希望能有精通编译原理的少侠不吝见告。
总结
宋劲杉老师的《Linux C编程一站式学习》中有一段话很好的总结了C语言中的各种变量特性,记载在此,以备梳理。
http://learn.akae.cn/media/ch19s03.html
作用域(Scope)这个概念适用于所有标识符,而不仅仅是变量,C语言的作用域分为以下几类:
-
函数作用域(Function Scope),标识符在整个函数中都有效。只有语句标号属于函数作用域。标号在函数中不需要先声明后使用,在前面用一个
goto
语句也可以跳转到后面的某个标号,但仅限于同一个函数之中。 -
文件作用域(File Scope),标识符从它声明的位置开始直到这个程序文件[30]的末尾都有效。例如上例中
main
函数外面的A
、a
、b
、c
,还有main
也算,printf
其实是在stdio.h
中声明的,被包含到这个程序文件中了,所以也算文件作用域的。 -
块作用域(Block Scope),标识符位于一对{}括号中(函数体或语句块),从它声明的位置开始到右}括号之间有效。例如上例中
main
函数里的a
、b
、c
。此外,函数定义中的形参也算块作用域的,从声明的位置开始到函数末尾之间有效。 -
函数原型作用域(Function Prototype Scope),标识符出现在函数原型中,这个函数原型只是一个声明而不是定义(没有函数体),那么标识符从声明的位置开始到在这个原型末尾之间有效。例如
int foo(int a, int b);
中的a
和b
。
对属于同一命名空间(Name Space)的重名标识符,内层作用域的标识符将覆盖外层作用域的标识符,例如局部变量名在它的函数中将覆盖重名的全局变量。命名空间可分为以下几类:
-
语句标号单独属于一个命名空间。例如在函数中局部变量和语句标号可以重名,互不影响。由于使用标号的语法和使用其它标识符的语法都不一样,编译器不会把它和别的标识符弄混。
-
struct
,enum
和union
(下一节介绍union
)的类型Tag属于一个命名空间。由于Tag前面总是带struct
,enum
或union
关键字,所以编译器不会把它和别的标识符弄混。 -
struct
和union
的成员名属于一个命名空间。由于成员名总是通过.
或->
运算符来访问而不会单独使用,所以编译器不会把它和别的标识符弄混。 -
所有其它标识符,例如变量名、函数名、宏定义、
typedef
的类型名、enum
成员等等都属于同一个命名空间。如果有重名的话,宏定义覆盖所有其它标识符,因为它在预处理阶段而不是编译阶段处理,除了宏定义之外其它几类标识符按上面所说的规则处理,内层作用域覆盖外层作用域。
标识符的链接属性(Linkage)有三种:
-
外部链接(External Linkage),如果最终的可执行文件由多个程序文件链接而成,一个标识符在任意程序文件中即使声明多次也都代表同一个变量或函数,则这个标识符具有External Linkage。具有External Linkage的标识符编译后在符号表中是
GLOBAL
的符号。例如上例中main
函数外面的a
和c
,main
和printf
也算。 -
内部链接(Internal Linkage),如果一个标识符在某个程序文件中即使声明多次也都代表同一个变量或函数,则这个标识符具有Internal Linkage。例如上例中
main
函数外面的b
。如果有另一个foo.c
程序和main.c
链接在一起,在foo.c
中也声明一个static int b;
,则那个b
和这个b
不代表同一个变量。具有Internal Linkage的标识符编译后在符号表中是LOCAL
的符号,但main
函数里面那个a
不能算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
,在第 2.4 节 “sizeof运算符与typedef类型声明”讲过这个关键字,它并不是用来修饰变量的,而是定义一个类型名。在那一节也讲过,看typedef
声明怎么看呢,首先去掉typedef
把它看成变量声明,看这个变量是什么类型的,那么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
函数可以释放这种存储空间。