这个项目里我们定义了四个全局变量,t.h头文件定义了一个整型a,main.c里定义了两个整型b和c并且未初始化,foo.c里定义了一个初始化了的结构体,还定义了一个main的函数指针变量。由于C语言每个源文件单独编译,所以t.h分别包含了两次,所以int a就被定义了两次。两个源文件里变量b和函数指针变量main被重复定义了,实际上可以看做代码段的地址。但编译器并未报错,只给出一条警告:
/usr/bin/ld: Warning: size of symbol'b' changed from 4in main.o to 8 in foo.o
运行程序发现,main.c打印中b大小是4个字节,而foo.c是8个字节,因为sizeof关键字是编译时决议,而源文件中对b类型定义不一样。但令人惊奇的是无论是在main.c还是foo.c中,a和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最大的那个,如果同样大小,则按照链接顺序选择第一个。
像上面这个例子中,全局变量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,sizeof b, b.a, b.b, main);
}
/* main.c */
#include
intb;
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\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);
}
return0;
}
运行情况如下:
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
setb=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
(说明一点,运行情况是直接输出到stdout的打印,笔者曾经将./test输出重定向到log中,结果发现打印的执行序列不一致,所以采用默认输出。)
这是一个多进程环境,首先我们看到无论父进程还是子进程,main.c还是foo.c,全局变量b和c的地址仍然是一致的(当然只是个逻辑地址),而且对b的大小不同模块仍然有不同的决议。这里值得注意的是,我们在子进程中对变量b进行赋值动作,从此子进程本身包括foo()调用中,整型b以及结构体成员b.a的值都是1,而父进程中整型b和结构体成员b.a的值仍是2,但它们显示的逻辑地址仍是一致的。
个人认为可以这样解释,fork创建新进程时,子进程获得了父进程上下文“镜像”(自然包括全局变量),虚拟地址相同但属于不同的进程空间,而且此时真正映射的物理地址中只有一份拷贝,所以b的值是相同的(都是2)。随后子进程对b改写,触发了操作系统的写时拷贝(copy on write)机制,这时物理内存中才产生真正的两份拷贝,分别映射到不同进程空间的虚拟地址上,但虚拟地址的值本身仍然不变,这对于应用程序来说是透明的,具有隐瞒性。
还有一点值得注意,这个示例编译时没有出现第一个示例的警告,即对变量b的sizeof决议,笔者也不知道为什么,或许是GCC的一个bug?