C语言全局变量那些事儿

C语言全局变量那些事儿

(感谢网友 @我的上铺叫路遥 投稿)

作为一名程序员,如果说沉迷一门编程语言算作一种乐趣的话,那么与此同时反过来去黑一门编程语言就是这种乐趣的升华。今天我们就来黑一把C语言,好好展示一下这门经典语言令人抓狂的一面。

我们知道,全局变量是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头文件定义了一个整型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 4 in 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 <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>

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

(说明一点,运行情况是直接输出到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?

第三个例子

这个例子代码同上一个一致,只不过我们将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)=0x080ca008
	sizeof(b)=8
	b.a=2
	b.b=4
	main:0x08048250
parent:	(&b)=0x080ca008
	(&c)=0x080cc084
	sizeof(b)=4
	b=2
	c=0
	wait child...
child:	sleep(1)
	(&b):0x080ca008
	(&c)=0x080cc084
	sizeof(b)=4
	set b=1
	c=0
foo:	(&b)=0x080ca008
	sizeof(b)=8
	b.a=1
	b.b=4
	main:0x08048250
parent:	child over
	(&b)=0x080ca008
	(&c)=0x080cc084
	sizeof(b)=4
	b=2
	c=0

从这个例子看不出有啥差别,只不过使用静态链接后,全局变量加载的地址有所改变,b和c的地址之间似乎相隔更远了些。不过这次编译器倒是给出了变量b的sizeof决议警告。

到此为止,有些人可能会对上面的例子嗤之以鼻,觉得这不过是列举了C语言的某些特性而已,算不上黑。有些人认为既然如此,对于一切全局变量要么用static限死,要么定义同时初始化,杜绝弱符号,以便在编译时报错检测出来。只要小心地使用,C语言还是很完美的嘛~对于抱这样想法的人,我只想说,请你在夜深人静的时候竖起耳朵仔细聆听,你很可能听到Dennis Richie在九泉之下邪恶的笑声——不,与其说是嘲笑,不如说是诅咒……

第四个例子

/* 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>

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
t2:	(&b)=0x0804a01c
	(&c)=0x0804a020
	sizeof(b)=4
	b=4
	c=4
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被改写了,而且一旁的c也“躺枪”了。眼尖的读者发现,这次foo.c是作为动态链接库运行时加载的,当t1第一次调用t2时,libfoo.so还未加载,一旦调用了foo函数,b立马中弹,而且c的地址居然还相邻着b,这使得c一同中弹了。不过笔者有些无法解释这种行为的原因,有种说法是强符号的全局变量在数据段中是连续分布的(相应地弱符号暂存在.bss段或者符号表里),或许可以上报GNU的编译器开发小组。

另外笔者尝试过将t1.c中的b和c定义前面加上const限定词,编译器仍然默认通过,但程序在main()中第一次调用foo()时触发了Segment fault异常导致奔溃,在foo.c里使用指针改写它也一样。推断这是GCC对const常量所在地址启用了类似操作系统写保护机制,但我无法确定早期版本的GCC是否会让这个const常量被改写而程序不会奔溃。

至于volatile关键词之于全局变量,自测似乎没有影响。

怎么样?看了最后一个例子是否有点“不明觉厉”呢?C语言在你心目中是否还是当初那个“纯洁”、“干净”、“行为一致”的姑娘呢?也许趁着你不注意的时候她会偷偷给你戴顶绿帽,这一切都是通过全局变量,特别在动态链接的环境下,就算全部定义成强符号仍然无法为编译器所察觉。而一些IT界“恐怖分子”也经常将恶意代码包装成全局变量注入到root权限下存在漏洞的操作序列中,就像著名的栈溢出攻击那样。某一天当你傻傻地看着一个程序出现未定义的行为却无法定位原因的时候,请不要忘记Richie大爷那来自九泉之下最深沉的“问候”~

或许有些人会偷换概念,把这一切归咎于编译器和链接器身上,认为这同语言无关,但我要提醒你,正是编译/链接器的行为支撑了整个语言的语法和语义。你可以反过来思考一下为何C的胞弟C++推出“命名空间(namespace)”的概念,或者你可以使用其它高级语言,对于重定义的全局变量是否能通过编译这一关。

所以请时刻谨记,C是一门很恐怖的语言!

P.S.题外话写在最后。我无意挑起语言之争,只是就事论事地去“黑(hack)一门语言而已,而且要黑就要黑得有理有力有层次,还要带点娱乐精神。其实黑一门语言并非什么尖端复杂的技术,个人觉得起码要做到两点:

  • 亲自动手写测试程序。动手写测试程序是开发人员必备的基础技能,只有现成的代码才能让人心服口服,那些只会停留在口头上的争论只能算作cheap hack。
  • 测试程序不能依赖于不成熟的代码。软件开发99%以上的bug都是基于不合格(substandard)开发人员导致,这并不能怪罪于语言以及编译器本身。使用诸如#define TRUE FALSE或者#define NULL 1之类的trick来黑C语言只能证明此人很有娱乐精神而不是真正的”hack value”,拿老北京梨园行当里的一句话——“那是下三滥的玩意儿”。

(全文完)


关注CoolShell微信公众账号和微信小程序

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

——=== 访问 酷壳404页面 寻找遗失儿童。 ===——
好烂啊有点差凑合看看还不错很精彩 (28 人打了分,平均分: 3.82 )
Loading...

C语言全局变量那些事儿》的相关评论

  1. 第四个例子云风建议用dlopen方式加载,可实际测试仍然失败了,不知道我的sample写的对不对,不管怎样代码贴一下,只改动了t1.c和Makefile

    /* t1.c */
    #include <stdio.h>
    #include <dlfcn.h>
    
    int b = 1;
    int c = 1;
    
    int main()
    {
    	int count = 5;
    	void (*foo)(void);
    	void *handle;
    	char *err;
    
    	handle = dlopen("libfoo.so", RTLD_LAZY);
    	dlerror();
    	*(void **)(&foo) = dlsym(handle, "foo");
    	if ((err = dlerror()) != NULL)
    	{
    		printf("Error:%s\n", err);
    		return 0;
    	}
    	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;
    }
    
    /* Makefile */
    export LD_LIBRARY_PATH:=.
    
    all: test
    	./test
    
    test: t1.o t2.o
    	gcc -shared -fPIC -o libfoo.so foo.c
    	gcc -rdynamic -o test t1.c t2.c -ldl
    
    t1.o: t1.c
    t2.o: t2.c
    
    .PHONY:clean
    clean:
    	rm -f *.o *.so test*
    
  2. 大家可以在http://www.oldlinux.org/oldlinux/viewthread.php?tid=14790&extra=page%3D1中看没有转义字符代码的版本。

  3. Leo :
    大家可以在http://www.oldlinux.org/oldlinux/viewthread.php?tid=14790&extra=page%3D1中看没有转义字符代码的版本。

    竟然在这个论坛发的。。

  4. 第四个例子……大致理解是:动态链接时,libfoo.so 里的符号要被并入 test 的全局符号表。因为 Linux 在合并全局符号表时有符号「先入为主」原则,所以 libfoo.so 里的 b 符号直接使用 test 中已有的 b 的地址。所以修改 b.a 相当于修改 b,修改 b.b 就是修改 &b.a 往后 sizeof(int) 字节的 int,也就是 c……

  5. 最后一个例子,我在main里加入了多进程,如果父进程先调用foo(),然后,子进程调用t2()就可以看到修改后的值。这个可以理解。
    但是,在子进程中先调用foo(),然后父进程再调用t2()就看不到修改了,按道理说,这时候libfoo.so已经加载进内存且在内存中仅存在一份拷贝,这部分内存应该在父子进程间共享的呀~为什么父进程看不到子进程的修改呢?

  6. foo.c中b是const类型,foo()里面却修改了b,我的gcc4.6.3编译同不过。楼主是不是写错了啊。

  7. 楼主要黑 “C 语音”,其实黑的是 “C 语音编程”,并非所有的“C 语音编程”问题都是语言本身的问题,因为语言、语言实现、编译链接和程序执行虽然可以看成一体,但是仔细分析一下就是不同的部分。

    正是 C 语音自身规范只规定了一个集合,而其余的部分皆备看成是未定义行为,而其结果则由编译器自行选择处理方式。

    想黑 C 的话就可以从这些未定义行为开始,呵呵。

  8. leckie :
    我感觉一旦到了链接阶段,类型就已经不管用了。链接阶段只看名字。

    是否有用取决于编译器和链接器共享多少信息,可惜 C 语音的编译器和链接器历史上是分在不同的套件里面,而且能传递的信息比较有限,而有些优化需要更多的信息,于是 LLVM 就选择了在抽象语法之上把编译和链接都实现以下。

  9. 总体感觉作者说明问题比较“天马行空”,信息杂而且多,呵呵;当技术文章来的话也比较头痛。

    不过 C 语音本身就跟编译器、链接器以及操作系统结合过于紧密,一下子说明白也比较难。

  10. 不好意思的确写错了,我写了很多测试,项目管理上有些混乱@leckie

    谢谢,您这方面果然是个行家~@Timothy Qiu

    zz :
    最后一个例子,我在main里加入了多进程,如果父进程先调用foo(),然后,子进程调用t2()就可以看到修改后的值。这个可以理解。
    子进程仍然看不到修改啊,fork以后只要被改写就产生copy on write,此时不同进程空间的虚拟地址映射到各自独立的物理地址上去了,进程间怎么可能串号呢?否则怎么如何叫保护模式?

  11. 这种错误很容易检查和避免,你的GCC连警告参数都没加,还能怪人家不报警?你这种黑有什么意思呢。就算编译器不给警告,还有各种clint不是,还有编程规范可以限制不是?利器在手,看你怎么用了,你非要用来抹脖子,那也没办法呀。早就看透了语言之争,哪个有效的程序员不掌握几门语言?该用什么语言就用什么语言,想用什么语言就用什么语言。

  12. 另外,你只知道黑,你有没有分析过为什么c语言标准容忍这样的漏洞?有没有想过怎么通过”hack”的方式去避免这种漏洞的发生?

  13. 没有命名空间的语言恐怕或多或少会有类似的问题。但命名空间对C来说太昂贵了。

    说到底,无非就是防止名字冲突么。养成好的编码习惯不就好了。

  14. 第二个例子因为stdout默认是line buffered,在fork()前执行setbuf(stdout, NULL);再重定向到log就可以获得和终端一样的输出.
    而且我这warning正常,gcc version 4.7.2 20130108 [gcc-4_7-branch revision 195012] (SUSE Linux)

  15. sunyuanchao :
    第二个例子因为stdout默认是line buffered,在fork()前执行setbuf(stdout, NULL);再重定向到log就可以获得和终端一样的输出.
    而且我这warning正常,gcc version 4.7.2 20130108 [gcc-4_7-branch revision 195012] (SUSE Linux)

    查了下,写错了,应该是setvbuf(stdout, NULL, _IOLBF, 0);
    setbuf(stdout, NULL);也可以就是设置成了unbuffered.

  16. 通过debug了解了,stdout重定向到外部文件时,默认采取fully buffer策略,即将打印完全缓存,直到进程结束后才sync到文件,这也是为何log中所有child打印在前而parent打印在后。设为line buffer或者unbuffer模式即可同屏幕一模一样了。看来我需要一本libc的字典。@sunyuanchao

  17. 难道我错了吗?

    /* t.h */
    #ifndef _H_
    #define _H_
    int a;
    #endif

    会有两次定义吗? 不是通过符号被选择编译了吗? 编译main.c时,符号_H_被定义,在编译foo.c的时候这个地方不编译啊,怎么会定义两次int a;

  18. 编译器向汇编器输出强、弱全局符号,链接器再将.o文件和静态库从左至右进行符号解析,这可以解释前面三个例子。第四个是动态链接器执行重定位。《 深入理解操作系统 》 链接章节里对这些作了说明。

  19. 作为一个出现了快40年的语言,我觉得没办法去评判它设计的好坏,因为当他诞生之初计算机领域完全不是这个样的。

  20. 第四个例子中,感觉”当t1第一次调用t2时,libfoo.so还未加载”这个说法不太对。elf格式的可执行文件应该是在加载的时候就加载并mapping了libfoo.so吧

  21. 一直觉得c的可控性强,没想到在声明方面居然存在这么大的歧义。
    h文件是反复被使用的,在h里声明变量,最好能确定变量的定义会在哪个c文件

  22. 动态库这个确实很容易被hack。
    如@Timothy Qiu说的,注意把编译器提供的编译选项合理利用起来应该是可以避免的。

  23. 有一个方法是不是能改良一点这种问题?
    struct type_global_filename{
    int a ,b , c;
    }global_filename;

    因为文件名一般不会重名,这样用 struct 模拟名字空间,做个封闭私有的全局变量名。
    当然,你可以加 static。

  24. 浩哥 这篇不太理解啊,首先我在 AS5,GCC 4.1.2下是无法编译的,错误如下
    1:const sturct的定义以后不可以再赋值: 错误:向只读变量 ‘b’ 赋值
    2:最重要的是,全局变量重定义就已经报错 “ multiple definition of `b’”

HansomeDragon进行回复 取消回复

您的电子邮箱地址不会被公开。