您现在的位置是:网站首页>技术百科技术百科
C语言趣谈:令人抓狂的全局变量
小大寒2024-01-01[技术百科]博学多闻
C语言趣谈:令人抓狂的全局变量本文通过趣味解析C语言中的全局变量,揭示了其在语法、语义和编译链接过程中的复杂性及潜在问题。全局变量既是程序运行的核心数据载体,也因生命周期贯穿始终和作用域不明确等特性,成为潜在错误源。通过示例和符号解析规则,文章展示了多模块、多进程环境下全局变量的地址一致性、写时拷贝机制及不同定义间的冲突,提醒程序员谨慎使用全局变量以避免不确定行为和安全隐患。
C语言趣谈:令人抓狂的全局变量
作为一名程序员,如果说沉迷于某门编程语言是一种乐趣,那么调侃或“黑”某门语言,或许就是乐趣的进一步升华。今天,我们来聊聊C语言,展示一下这门经典语言让人“抓狂”的一面。
全局变量的多面性
全局变量是C语言语法和语义中的重要知识点。它的存在意义可以从以下三个角度理解:
- 对程序员而言,它是一个记录数据的变量(variable)。
- 对编译器/链接器来说,它是需要解析的符号(symbol)。
- 对计算机而言,它可能是一块具有地址的内存(memory)。
此外,从语法和语义角度来看:
- 在作用域上,带
static
关键字的全局变量只在文件内部有效,否则会被外部模块访问。 - 在生命周期上,全局变量是静态的,贯穿整个程序或模块的运行周期。正是因为其跨单元访问和持续生命周期的特性,全局变量往往成为潜在的攻击目标。
- 在空间分配上:
- 已初始化的全局变量会在编译时分配到
.data
段。 - 未初始化的全局变量(暂存变量)会分配到
.bss
段,并在编译时自动清零。 - 仅声明的全局变量作为符号存在于编译器符号表中,不会实际分配空间,直到链接或运行时才重定向到对应地址。
- 已初始化的全局变量会在编译时分配到
全局变量的趣味解析
接下来,我们通过一个例子来展示非static
限定的全局变量在编译、链接以及运行时的一些有趣行为,同时对C编译器和链接器的解析原理略窥一二。以下示例适用于ANSI C和GNU C标准,笔者的编译环境为Ubuntu下的GCC-4.4.3。
代码示例
/* t.h */ #ifndef _H_ #define _H_ int a; #endif /* foo.c */ #include <stdio.h> #include "t.h" struct { char a; int b; } b = { 2, 4 }; int main(); void foo() { 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, sizeof b, b.a, b.b, main); } /* main.c */ #include <stdio.h> #include "t.h" int b; int c; int main() { 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, sizeof b, b, c); return 0; }
Makefile
test: main.o foo.o gcc -o test main.o foo.o main.o: main.c foo.o: foo.c clean: rm *.o test
运行结果
foo: (&a)=0x0804a024 (&b)=0x0804a014 sizeof(b)=8 b.a=2 b.b=4 main:0x080483e4 main: (&a)=0x0804a024 (&b)=0x0804a014 (&c)=0x0804a028 size(b)=4 b=2 c=0
符号解析规则
在编译阶段,编译器会将全局符号信息存储于可重定位目标文件的符号表中。以下是GNU链接器的符号解析规则:
- 不允许多个强符号(strong symbol)同时存在。
- 如果有一个强符号和多个弱符号,选择强符号。
- 如果仅有多个弱符号,则选择尺寸最大的,若尺寸相同,则按链接顺序选择第一个。
通过上述规则,可以解释为何重复定义的变量a
和b
在内存中只有一份初始化的拷贝。
实际上,C语言中的这种规则是一个巨大的坑。编译器对于全局变量多重定义的“宽容”,可能会在某些情况下无意修改某个变量,进而导致程序的不确定行为。为了说明问题的严重性,我们再来看一个例子。
第二个例子
/* foo.c */ #include; struct { int a; int b; } b = { 2, 4 }; int main(); void foo() { printf("foo:\t(&b)=0x%08x\n\tsizeof(b)=%d\n\tb.a=%d\n\tb.b=%d\n\tmain:0x%08x\n", &b, sizeof b, b.a, b.b, main); } /* main.c */ #include int b; int c; int main() { 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\tset b=%d\n\tc=%d\n", &b, &c, sizeof b, b, c); foo(); } else { foo(); printf("parent:\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n\twait child...\n", &b, &c, sizeof b, b, c); wait(-1); printf("parent:\tchild over\n\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n", &b, &c, sizeof b, b, c); } return 0; }
运行结果如下:
foo: (&b)=0x090584c8 sizeof(b)=8 b.a=2 b.b=4 main:0x090484c8 parent: (&b)=0x090584c8 (&c)=0x0804a034 sizeof(b)=4 b=2 c=0 wait child... child: sleep(1) (&b):0x090584c8 (&c)=0x0804a034 sizeof(b)=4 set b=1 c=0 foo: (&b)=0x090584c8 sizeof(b)=8 b.a=1 b.b=4 main:0x090484c8 parent: child over (&b)=0x090584c8 (&c)=0x0804a034 sizeof(b)=4 b=2 c=0
需要说明的是,上述运行结果是直接打印到标准输出的。笔者曾尝试将 ./test
输出重定向到日志文件,但发现打印的执行顺序不一致,因此选择默认输出。
在这个多进程环境中,无论是父进程还是子进程,无论是 main.c
还是 foo.c
,全局变量 b
和 c
的地址始终保持一致(这里的地址指的是逻辑地址)。但值得注意的是,不同模块对变量 b
的大小会得出不同的结论。
子进程中修改了变量 b
的值,导致子进程内以及 foo()
调用中,整型 b
和结构体成员 b.a
的值都变成了1,而父进程中这两个值仍然为2,但它们的逻辑地址仍然一致。
这是由于在 fork()
创建子进程时,子进程会继承父进程的上下文“镜像”(包括全局变量)。虽然虚拟地址相同,但它们映射到不同的进程空间中。初始时,物理内存中只有一份拷贝,因此 b
的值相同。随后,子进程对 b
的写操作触发了操作系统的写时拷贝(Copy on Write)机制,此时物理内存中才真正产生两份拷贝,但虚拟地址并未改变,这对应用程序是透明的。
第三个例子
这个例子与上一个类似,但将 foo.c
编译为一个静态库 libfoo.a
。以下是 Makefile
的改动:
test: main.o foo.o ar rcs libfoo.a foo.o gcc -static -o test main.o libfoo.a main.o: main.c foo.o: foo.c clean: rm -f *.o test
运行结果如下:
foo: (&b)=0x030ca008 sizeof(b)=8 b.a=2 b.b=4 main:0x02048250 parent: (&b)=0x030ca008 (&c)=0x020bc084 sizeof(b)=4 b=2 c=0 wait child... child: sleep(1) (&b):0x030ca008 (&c)=0x020bc084 sizeof(b)=4 set b=1 c=0 foo: (&b)=0x030ca008 sizeof(b)=8 b.a=1 b.b=4 main:0x02048250 parent: child over (&b)=0x030ca008 (&c)=0x020bc084 sizeof(b)=4 b=2 c=0
从运行结果来看,与上一个例子没有明显区别。但静态链接后,全局变量的加载地址发生了一些变化,b
和 c
的地址间距似乎更大了。此外,这次编译器对变量 b
的 sizeof
决议发出了警告。
到这里为止,可能有人对上面的例子嗤之以鼻,认为不过是列举了C语言的一些特性罢了,谈不上什么批评。有些人可能会认为,只要严格限制全局变量,要么用static
修饰以限制作用域,要么在定义时进行初始化以杜绝弱符号,从而在编译阶段就能发现问题。那么,只要谨慎使用,C语言依然是完美的嘛!
对于抱有这种想法的人,我只能说,请在夜深人静时竖起耳朵仔细聆听,你或许能听到Dennis Ritchie在九泉之下发出的邪恶笑声——不如说是嘲笑,更像是一种诅咒……
第四个例子
/* foo.c */ #includeconst struct { int a; int b; } b = { 3, 3 }; int main(); void foo() { 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, sizeof b, b.a, b.b, main); } /* t1.c */ #include int b = 1; int c = 1; int main() { int count = 5; while (count-- > 0) { t2(); foo(); printf("t1:\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n", &b, &c, sizeof b, b, c); sleep(1); } return 0; } /* t2.c */ #include int b; int c; int t2() { printf("t2:\t(&b)=0x%08x\n\t(&c)=0x%08x\n\tsizeof(b)=%d\n\tb=%d\n\tc=%d\n", &b, &c, sizeof b, b, c); return 0; }
Makefile脚本:
export LD_LIBRARY_PATH:=. all: test ./test test: t1.o t2.o gcc -shared -fPIC -o libfoo.so foo.c gcc -o test t1.o t2.o -L. -lfoo t1.o: t1.c t2.o: t2.c .PHONY: clean clean: rm -f *.o *.so test*
执行结果:
./test t2: (&b)=0x0804a01c (&c)=0x090584c8 sizeof(b)=4 b=1 c=1 foo: (&b)=0x0804a01c sizeof(b)=8 b.a=4 b.b=4 main:0x08048564 t1: (&b)=0x0804a01c (&c)=0x090584c8 sizeof(b)=4 b=4 c=4 ...
其实前几个例子只是小试牛刀,真正的大问题终于出现了!这次编译器既没有报错也没有警告,但我们却亲眼看到,作为main()
函数中强符号的b
被改写,甚至连旁边的c
也未能幸免。
有心的读者可能注意到,这次foo.c
是作为动态链接库在运行时加载的。当t1
第一次调用t2
时,libfoo.so
尚未加载。一旦调用了foo
函数,b
立刻被改写,甚至c
的地址还紧挨着b
,因此也被波及。不过笔者对这种行为的具体原因尚无法解释。
另外,笔者尝试在t1.c
中的b
和c
定义前加上const
限定符,结果程序在第一次调用foo()
时触发了Segmentation fault
异常。推测这是因为GCC对const
常量的地址启用了写保护机制。
最后,再次提醒大家,C语言是一门很恐怖的语言!
总结
- 亲自动手写测试程序。只有通过实际代码验证,才能让人信服。
- 不要依赖不成熟的代码。测试应基于标准行为,而非依赖有意设计的“漏洞”。
阅读完毕,很棒哦!
上一篇:分布式场景下的稳定性保障
下一篇:IO模型浅谈