今天我们来聊聊C语言里一个既基础又“坑”人的话题——全局变量。对于程序员来说,爱上并使用一门语言是乐趣,而深入挖掘它的“阴暗面”则让这份乐趣变得更为深刻。
理解全局变量,我们需要从三个维度来看:
- 对程序员而言,它是一个记录内容的变量(variable)。
- 对编译/链接器而言,它是一个需要解析的符号(symbol)。
- 对计算机而言,它是一块具有地址的内存(memory)。
在语法和语义上:
- 作用域:用
static修饰的全局变量作用域仅限于当前文件;否则,它将对整个模块或项目可见。
- 生存期:它是静态的,贯穿整个程序或模块的运行周期(请注意,正是这种跨单元访问和长生存期的特性,常使全局变量成为代码安全的薄弱点)。
- 空间分配:定义且初始化的全局变量在编译时于
.data段分配空间;定义但未初始化的全局变量属于 “暂定定义(tentative definition)” ,位于.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
这个项目定义了四个全局变量。头文件t.h定义了int a,main.c定义了未初始化的int b和int c,foo.c定义了一个已初始化的结构体变量b和一个函数指针main。
由于每个.c文件独立编译,t.h被包含了两次,因此int a被定义了两次。变量b和函数指针main在两个源文件中也被重复定义了。然而编译器并未报错,只给出一条警告:
/usr/bin/ld: Warning: size of symbol ‘b’ changed from 4 in main.o to 8 in foo.o
运行程序发现,main.c中sizeof(b)是4字节,而foo.c中是8字节,因为sizeof是编译时决议的,两个文件对b的类型定义不同。但令人惊讶的是,无论在哪个文件中,a和b的地址都是相同的。这意味着它们虽然被定义了两次(b还是不同类型),但在内存映像中只有一份拷贝。
此外,main.c中b的值竟然是foo.c中结构体第一个成员b.a的值。这印证了前面的推断:即便存在多次定义,内存中也只保留一份初始化的拷贝。变量c则是一个独立的个体。
为什么会这样?这涉及到C编译器对多重定义全局符号的解析与链接器的链接规则。
编译时,编译器将全局符号信息编码在目标文件的符号表中。这里引入 “强符号(strong)” 和 “弱符号(weak)” 的概念:
- 强符号:已定义并初始化的变量(如
foo.c中的结构体b)。
- 弱符号:未定义,或定义但未初始化的变量(如
main.c中的b和c,以及两个文件中的a)。
GNU链接器(ld)处理多重定义符号时遵循以下规则:
- 不允许出现多个相同的强符号。
- 如果有一个强符号和多个弱符号,则选择强符号。
- 如果都是弱符号,则优先选择size最大的那个;若size相同,则按链接顺序选择第一个。
在上面的例子中,a和b都存在重复定义。a都是弱符号,所以只选一个。b在main.c中是弱符号,在foo.c中是强符号,根据规则二,最终链接的是foo.c中的强符号(结构体),因此编译器仅给出警告。
这种规则其实是C语言里的一大隐患,编译器对全局变量多重定义的“宽容”,很可能会导致某个变量被意外修改,引发程序的不确定行为。
第二个例子:多进程环境下的写时拷贝
foo.c
#include <stdio.h>
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 <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
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)=0x0804a020
sizeof(b)=8
b.a=2
b.b=4
main:0x080484c8
parent: (&b)=0x0804a020
(&c)=0x0804a034
sizeof(b)=4
b=2
c=0
wait child…
child: sleep(1)
(&b):0x0804a020
(&c)=0x0804a034
sizeof(b)=4
set b=1
c=0
foo: (&b)=0x0804a020
sizeof(b)=8
b.a=1
b.b=4
main:0x080484c8
parent: child over
(&b)=0x0804a020
(&c)=0x0804a034
sizeof(b)=4
b=2
c=0
这是一个多进程环境。可以看到,无论父进程、子进程、main.c还是foo.c,全局变量b和c的地址(逻辑地址)仍然一致,且不同模块对b的sizeof决议依然不同。
值得注意的是,子进程中对整型变量b赋值后,子进程自身(包括调用的foo()函数)中,整型b和结构体成员b.a的值都变成了1,而父进程中它们的值仍是2,但显示的地址相同。
可以这样解释:fork()创建子进程时,子进程复制了父进程的上下文“镜像”(包括全局变量),虚拟地址相同但属于不同的进程空间。此时物理内存中只有一份拷贝,所以b的初始值相同(都是2)。随后子进程对b进行写操作,触发了操作系统的 写时拷贝(Copy on Write) 机制,物理内存中才产生真正的两份拷贝,分别映射到两个进程空间,但虚拟地址值不变。
这个例子编译时没有出现第一个例子中关于b的sizeof决议警告,原因不明。
第三个例子:静态链接库
代码与第二个例子相同,仅修改Makefile,将foo.c编译为静态库链接。
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
运行结果与第二个例子类似,只是全局变量的加载地址发生了变化,且这次编译器给出了关于变量b的sizeof决议警告。
第四个例子:动态链接库——真正的“大坑”
看到这里,或许有人觉得这些不过是C语言的特性展示,只要谨慎使用——比如用static限定,或者定义时一律初始化以杜绝弱符号——就能在编译时发现问题,C语言依然“完美”。
如果你这么想,那就太天真了。真正令人防不胜防的陷阱,往往藏在动态链接库里。
foo.c
#include <stdio.h>
const 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 <stdio.h>
#include <unistd.h>
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 <stdio.h>
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)=0x0804a020
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)=0x0804a020
sizeof(b)=4
b=4
c=4
…
这次,编译器既没报错也没警告,但我们眼睁睁看到main()中作为强符号的b(值为1)被改写了,并且紧邻的变量c也一同遭殃(值从1变成了4)。
当t1第一次调用t2时,libfoo.so尚未加载。一旦调用foo()函数,b和c立即被修改。一种可能的解释是,强符号的全局变量在数据段中是连续分布的,当动态库中的代码通过结构体视角(8字节)去修改同一地址时,实际上覆盖了主程序中两个整型变量(4字节+4字节)的空间。
更诡异的是,即便将t1.c中的b和c用const修饰,编译器依然通过,但程序会在首次调用foo()时因段错误(Segment Fault)而崩溃。这可能是现代GCC对const常量所在内存启用了写保护机制。至于volatile关键字,测试表明它对全局变量的此类行为没有影响。
总结与思考
怎么样?最后一个例子是否让你感到后怕?C语言在你心中是否还是那个“行为一致”的纯洁形象?
在动态链接环境下,即使你将所有全局变量都定义为强符号(初始化),也无法完全避免这种诡异的交叉改写。这也是一些安全攻击的潜在载体,恶意代码可能通过全局变量注入到存在漏洞的进程中。
你或许会将责任归咎于编译器和链接器,但别忘了,正是编译/链接器的具体行为支撑了这门语言的语法和语义。反观C++引入了命名空间(namespace),或是其他现代语言,通常都不会允许重定义的全局变量通过编译。
因此,请务必谨慎使用C语言的全局变量,尤其是在大型项目或涉及动态链接库时。理解其背后的链接模型和内存布局,是写出稳健C程序的关键一步。更多关于编译、链接器原理的深度讨论,欢迎在云栈社区交流。