当前位置: 首页 > 科技观察

C语言疯狂的一面——全局变量

时间:2023-03-12 05:44:02 科技观察

我们知道,全局变量是C语言语法和语义中非常重要的一个知识点。首先,它的含义需要从三个不同的角度来理解:对于程序员来说,对于计算机来说,它是一个记录内容的变量(variable);对于编译器/链接器,它是一个需要解析的符号;对于计算机来说,它可能是一块带有地址的内存(内存)。其次是语法/语义:从作用域来看,带static关键字的全局变量的作用域只能限定在文件内,否则会outlink到整个模块和项目;从生命周期来看,它是静态的,贯穿于整个程序或模块的运行过程中(注意,正是跨单元访问和持续生命周期这两个特性,使得全局变量往往成为一个块的突破点被攻击的代码,理解这一点非常重要);从空间分配的角度来看,定义和初始化的全局变量在编译时在数据段(.data)分配空间,定义但未初始化的全局变量**临时定义**在.bss段,即编译时自动清除,但只声明.com的全局变量只能看做一个符号,存放在编译器的符号表中,在链接时重定向到相应地址后才会分配空间或运行时。我们将向您展示在编译/链接和运行程序时,非静态限定全局变量会发生哪些有趣的事情,顺便一窥C编译器/链接器的分析原理。以下示例对ANSIC和GNUC标准均有效。笔者的编译环境是Ubuntu下的GCC-4.4.3。第一个例子/*t.h*/#ifndef_H_#define_H_inta;#endif/*foo.c*/#include#include"t.h"struct{chara;intb;}b={2,4};intmain();voidfoo(){printf("foo:\t(&a)=0x%08x\n\t(&b)=0x%08x\n\tsizeof(b)=%d\n\tb.a=%d\n\tb.b=%d\n\tmain:0x%08x\n",&a,&b,sizeofb,b.a,b.b,main);}/*main.c*/#include#include"t.h"intb;intc;intmain(){foo();printf("main:\t(&a)=0x%08x\n\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsize(b)=%d\n\tb=%d\n\tc=%d\n",&a,&b,&c,sizeofb,b,c);return0;}Makefile如下:test:main.ofoo.ogcc-otestmain.ofoo.omain.o:main.cfoo.o:foo.cclean:rm*.otestRunning:foo:(&a)=0x0804a024(&b)=0x0804a014sizeof(b)=8b.a=2b.b=4main:0x080483e4main:(&a)=0x0804a024(&b)=0x0804a014(&c)=0x0804a028size(b)=4b=2c=0在这个项目中,我们定义了四个全局变量。t.h头文件定义了一个整数类型a。两个整数类型b和c在main.c中定义并且未初始化。在foo.c中定义了一个初始化结构体,同时也定义了一个主函数指针变量因为C语言的每个源文件都是单独编译的,t.h包含了两次,所以inta定义了两次。在两个源文件中,重复定义了变量b和函数指针变量main,实际上可以看成是代码段的地址。但是编译器并没有报错,只有警告:/usr/bin/ld:Warning:sizeofsymbol'b'changedfrom4inmain.oto8infoo.o运行程序发现main.c是打印b的大小是4个字节,而foo.c是8个字节,因为sizeof关键字是在编译时确定的,与源文件中b类型的定义不同。但是令人惊讶的是,无论是在main.c还是foo.c中,a和b都是同一个地址,也就是说a和b定义了两次,b还是不同的类型,只不过只有a复制。我们还看到main.c中b的值实际上是foo.c中结构体的第一个成员变量b.a的值,印证了之前的推断——**即使有多个定义,也只有一份在内存中初始化的副本。**这里c也是一个独立变量,不会影响。为什么会这样?这涉及C编译器对多个定义的全局符号的解析和链接。在编译期间,编译器将全局符号信息隐式编码到可重定位目标文件的符号表中。有一个**“强符号(strong)”和“弱符号(weak)”的概念**——前者指的是定义和初始化的变量,比如foo.c中的结构体b,后者指的是Most其中有未定义或已定义但未初始化的变量,如main.c中的整数b和c,两个源文件的头文件中均包含a。当多次定义一个符号时,GNU链接器(ld)使用以下规则来解析:不允许多次出现相同的强符号。如果有一个强符号和多个弱符号,则选择强符号。如果有多个weaksymbol,则先解析尺寸最大的,如果尺寸相同,则按链接顺序选择第一个。在上面的例子中,全局变量a和b有重复的定义。如果我们在main.c中对b进行初始化赋值,那么就有两个强符号违反了规则一,编译器就会报错。如果满足第二条规则,则只发出警告,而foo.c中的强符号实际上是在运行时解析的。变量a是一个弱符号,所以只选择一个(按照目标文件链接时的先后顺序)。其实这条规则是C语言的一个大坑。编译器对全局变量的多重定义的“纵容”,很可能会无缘无故地修改一个变量,导致程序出现未定义的行为。如果你还没有意识到事态的严重性,我再举一个例子。第二个例子/*foo.c*/#include;struct{inta;intb;}b={2,4};intmain();voidfoo(){printf("foo:\t(&b)=0x%08x\n\tsizeof(b)=%d\n\tb.a=%d\n\tb.b=%d\n\tmain:0x%08x\n",&b,sizeofb,b.a,b.b,main);}/*main.c*/#includeintb;intc;intmain(){if(0==fork()){sleep(1);b=1;printf("child:\tsleep(1)\n\t(&b):0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tsetb=%d\n\tc=%d\n",&b,&c,sizeofb,b,c);富();}else{foo();printf("父级:\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n\twaitchild...\n",&b,&c,sizeofb,b,c);等待(-1);printf("parent:\tchildover\n\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n",&b,&c,sizeofb,b,c);}return0;}运行情况如下:foo:(&b)=0x0804a020sizeof(b)=8b.a=2b.b=4main:0x080484c8parent:(&b)=0x0804a020(&c)=0x0804a034sizeof(b)=4b=2c=0waitchild...child:sleep(1)(&b):0x0804a020(&c)=0x0804a034sizeof(b)=4setb=1c=0foo:(&b)=0x0804a020sizeof(b)=8b.a=1b.b=4main:0x080484c8parent:childover(&b)=0x0804a020(&c)=0x0804a034sizeof(b)=4b=2c=0(注意运行状态直接输出到stdout,作者曾经将./test输出重定向到log,发现打印的是执行顺序不一致,所以使用默认输出)这是一个多进程环境。首先,我们看到不管是父进程还是子进程,main.c还是foo.c,全局变量b和c的地址还是一致的(当然只是一个逻辑地址),还有b.不同模块的不同分辨率。这里值得注意的是,我们在子进程中给变量b赋值。从子进程本身来看,包括foo()调用在内,整数b和结构成员b.a的值都是1,而父进程中整数b和结构成员b.a的值仍然是2,但是他们显示的逻辑地址还是一致的。我个人认为可以这样解释。fork创建新进程时,子进程获取父进程上下文的“镜像”(自然包括全局变量),虚拟地址相同但属于不同的进程空间,此时只有一个realmapped物理地址副本,所以b的值是相同的(都是2)。然后子进程重写b,触发了操作系统的**copyonwrite**机制。此时在物理内存中产生了两个真实的副本,分别映射到不同进程空间的虚拟地址。但虚拟地址本身的值保持不变,对应用程序是透明的,是隐蔽的。另外值得注意的是,编译本例时并没有出现第一个例子的警告,即变量b的sizeof解析。作者不知道为什么,也许是GCC的bug?第三个例子这个例子的代码和上一个一样,只是我们把foo.c做成了一个静态链接库libfoo.a来链接,这里只给出Makefile的变化。test:main.ofoo.oarrcslibfoo.afoo.ogcc-static-otestmain.olibfoo.amain.o:main.cfoo.o:foo.cclean:rm-f*.o测试操作As如下:foo:(&b)=0x080ca008sizeof(b)=8b.a=2b.b=4main:0x08048250parent:(&b)=0x080ca008(&c)=0x080cc084sizeof(b)=4b=2c=0waitchild...child:sleep(1)(&b):0x080ca008(&c)=0x080cc084sizeof(b)=4setb=1c=0foo:(&b)=0x080ca008sizeof(b)=8b.a=1b.b=4main:0x08048250parent:childover(&b)=0x080ca008(&c)=0x080cc084sizeof(b)=4b=2c=0和这个例子没什么区别,但是使用静态链接后,全局变量加载的地址为As结果,b和c的地址似乎相距更远。但是这次编译器对变量b给出了sizeofresolutionwarning。至此,可能有人会对上面的例子嗤之以鼻,认为这不过是罗列了C语言的一些特性而已,并不黑。有人认为,如果是这样的话,就必须对所有的全局变量进行静态限制,或者同时定义和初始化,这样才能剔除弱符号,这样才能在编译时检测到错误。只要用心,C语言还是很完美的~对于有这种想法的人,我只想说,夜深人静的时候请仔细听,你可能会听到九泉下DennisRichie的恶言笑声——不,与其说是嘲笑不如说是诅咒……第四个例子/*foo.c*/#includeconststruct{inta;intb;}b={3,3};intmain();voidfoo(){b.a=4;b.b=4;printf("foo:\t(&b)=0x%08x\n\tsizeof(b)=%d\n\tb.a=%d\n\tb.b=%d\n\tmain:0x%08x\n",&b,sizeofb,b.a,b.b,main);}/*t1.c*/#includeintb=1;intc=1;intmain(){intcount=5;while(count-->0){t2();富();printf("t1:\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n",&b,&c,b,b,c);睡觉(1);}return0;}/*t2.c*/#includeintb;intc;intt2(){printf("t2:\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n",&b,&c,sizeofb,b,c);返回0;}Makefile脚本:exportLD_LIBRARY_PATH:=.all:test./testtest:t1.ot2.ogcc-shared-fPIC-olibfoo.sofoo.cgcc-o测试t1.ot2.o-L。-lfoot1.o:t1.ct2.o:t2.c.PHONY:cleanclean:rm-f*.o*.sotest*执行结果:./testt2:(&b)=0x0804a01c(&c)=0x0804a020sizeof(b)=4b=1c=1foo:(&b)=0x0804a01csizeof(b)=8b.a=4b.b=4main:0x08048564t1:(&b)=0x0804a01c(&c)=0x0804a020sizeof(b)=4b=4c=4t2:(&b)=0x0804a01c(&c)=0x0804a020sizeof(b)=4b=4c=4foo:(&b)=0x0804a01csizeof(b)=8b.a=4b.b=4main:0x08048564t1:(&b)=0x0804a01c(&c)=0x0804a020sizeof(b)=4b=4c=4...其实前面几个例子只是开胃菜,真正的大坑终于出现了!而这次编译器既没有报错也没有给出警告,但是我们确实看到了重写了main()中的强符号b,旁边的c也是“躺枪”。眼尖的读者发现,这次foo.c是作为动态链接库运行时加载的。t1第一次调用t2时,libfoo.so还没有加载。一旦foo函数被调用,b就立刻被枪毙,c的地址实际上是和b相邻的。这使得c一起拍摄。但是,作者无法解释这种行为的原因。有一种说法是强符号的全局变量连续分布在数据段(对应的,弱符号暂存在.bss段或符号表),可能会反馈给GNU编译器开发组。另外,作者尝试在t1.c中b和c的定义前加上const限定符,编译器依然默认通过,但是程序在第一次调用foo()时触发了Segmentfault异常在main()中,导致崩溃,在foo.c中使用指针重写它是相同的。推测GCC对const常量所在地址启用了类似的操作系统写保护机制,但我不确定早期版本的GCC是否会允许在不导致程序崩溃的情况下重写const常量。至于全局变量的volatile关键字,自测好像没有作用。这个怎么样?看完最后一个例子是不是有点“不清楚”?C语言还是你心目中那个“清纯”、“干净”、“一贯”的女孩吗?可能她会在你不注意的时候偷偷给你戴上绿帽子,都是通过全局变量,尤其是在动态链接环境下,即使全部定义为强符号,编译器还是检测不到。而一些IT行业的“恐怖分子”经常**将恶意代码打包成全局变量,在root权限下注入易受攻击的操作序列,**就像著名的堆栈溢出攻击一样。有一天,当你看着一个有未定义行为的程序而无法定位原因时,请不要忘记里奇叔叔最深切的“问候”~也许有人会偷偷改变观念,把这归咎于编译器和链接器并认为它与语言无关,但我提醒你,是编译器/链接器的行为支撑了整个语言的语法和语义。你可以想想为什么C的小弟C++引入了**“命名空间(namespace)”**的概念,也可以用其他高级语言看看重新定义的全局变量能否通过编译。所以请永远牢记C是一门糟糕的语言!