C 语言全局变量初始化和符号重名的问题

全局变量是 C 语言语法和语义中一个很重要的知识点,首先它存在的意义可以从三个不同角度去理解:

  • 对于程序员来说,它是一个记录内容的 变量(variable)。
  • 对于编译/链接器来说,它是一个需要解析的 符号(symbol)。
  • 对于计算机来说,它可能是具有地址的一块 内存(memory)。

跨单元访问和持续生存周期这两个特点使得全局变量往往成为一段受攻击代码的突破口,了解这一点十分重要。

强弱符号学说

在 C 语言里,全局变量如果不初始化的话,默认为 0。int x = 0int x 的效果看起来是一样的。但其实这里面的差别很大,强烈建议大家所有的全局变量都要初始化,因为编译器在编译的时候针对这两种情况会产生两种符号放在目标文件(*.o)的符号表中,对于初始化的,叫 强符号;未初始化的,叫 弱符号

链接器在链接目标文件的时候,如果遇到两个重名符号,会有以下处理规则:

  1. 如果有多个重名的强符号,则报错(error);
  2. 如果有一个强符号,多个弱符号,则以强符号为准;
  3. 如果没有强符号,但有多个重名的弱符号,则「任选一个弱符号」or「先决议到 size 最大的那个,如果同样大小,则按照链接顺序选择第一个」。(具体要看编译器)

事实上,这种规则是 C 语言里的一个大坑,编译器对这种全局变量多重定义的「纵容」很可能会无故修改某个变量,导致程序产生不确定的行为。

例子 1

基于以上规则看下面的程序:(gcc 3.4.6)

1
2
3
4
5
6
7
8
9
10
// main.c
#include <stdio.h>
int x = 1;
void foo(void);
int main(int argc, char* argv[]) {
printf("x1: %d\n", x);
foo();
printf("x2: %d\n", x);
return 0;
}
1
2
3
4
5
// var.c
int x = 0;
void foo(void) {
x = 2;
}

因为两个文件里面的 x 都被初始化了,所以编译出来的两个目标文件里 x 都是强符号,链接的时候会报错:multiple definition of 'x',符合规则 1。

把 var.c 里面的 int x = 0; 改成 int x;,编译、链接无任何警告,运行结果为:

1
2
x: 1
x: 2

说明链接的时候以 main.c 中的 x 为准,foo 函数修改的是 main.c 中定义的 x,符合规则 2。

把 main.c 中的初始化也去掉,改成 int x;,编译、链接仍然很顺利,运行结果为:

1
2
x: 0      (全局变量初始化为 0)
x: 2

说明 main 函数和 foo 函数修改的是同一个 x,链接器自己选择了一个 x,符合规则3。

然而,在大部分情况下,我们不希望链接器为我们做决定。也许写 var.c 的人根本不知道 main.c 里面也有一个 x,foo 函数的本意也许并不是要修改 main.c 中的 x。因为这种问题引起的 bug 会很难查。

所以,我们要尽量把全局变量初始化,对于不想给别的文件引用的变量,也尽量用 static 修饰。除了链接时的表现不一样外,未初始化的符号在目标文件的 bss 段中,而初始化的符号在 data 段中。

例子 2

先看 t.hfoo.cmain.c 这三个文件:

1
2
3
4
5
/* t.h */
// #ifndef _H_
// #define _H_
int a;
// #endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* foo.c */
#include <stdio.h>
#include "t.h" // 包含t.h

struct {
char a; // 1 byte
int b; // 3 + 4 = 7 bytes
} 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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* main.c */
#include <stdio.h>
#include "t.h" // 再次包含t.h

int b; // 4 bytes
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;
}

程序解答:

这个程序里我们定义了四个全局变量,t.h 头文件定义了一个整型 a 且未初始化,main.c 里定义了两个整型 b 和 c 并且未初始化,foo.c 里定义了一个已初始化的结构体 b,还定义了一个函数指针变量 main。两个源文件里变量 b 和函数指针变量 main 被重复定义了(实际上可以看做代码段的地址)。但编译器并未报错(符合规则二),只给出一条警告:

/usr/bin/ld: Warning: size of symbol 'b' changed from 4 in main.o to 8 in foo.o

运行程序发现,main.c 打印中 b 大小是 4 个字节,而 foo.c 是 8 个字节,因为 sizeof 关键字是编译时决议的,而且两个源文件中对 b 类型定义不一样。但令人惊奇的是无论是在 main.c 还是 foo.c 中,a 和 b 都具有相同的地址,也就是说,b 被定义了两次,b 还是不同类型,但内存映像中只有一份拷贝。我们还看到:main.c 中 b 的值居然就是 foo.c 中结构体第一个成员变量 b.a 的值,这证实了前面的推断,即便存在多次定义(类型还可以不同),内存中只有一份初始化的拷贝。另外,在这里 c 是置身事外的一个独立变量

根据强弱符号学说,全局变量 a 和 b 存在重复定义。如果我们将 main.c 中的 b 初始化赋值,那么就存在两个强符号而违反了规则一,编译器报错。如果满足规则二,则仅仅提出警告,实际运行时决议的是 foo.c 中的强符号。而变量 a 都是弱符号,所以只选择一个,且由于类型大小相同,因按照目标文件链接时的顺序(这里假定没有`#ifndef _H_`,不然不会发生重复定义)。

参考

C 语言全局变量那些事儿