您现在的位置是:网站首页>技术百科技术百科

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)同时存在。
  • 如果有一个强符号和多个弱符号,选择强符号。
  • 如果仅有多个弱符号,则选择尺寸最大的,若尺寸相同,则按链接顺序选择第一个。

通过上述规则,可以解释为何重复定义的变量ab在内存中只有一份初始化的拷贝。

实际上,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,全局变量 bc 的地址始终保持一致(这里的地址指的是逻辑地址)。但值得注意的是,不同模块对变量 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
    

从运行结果来看,与上一个例子没有明显区别。但静态链接后,全局变量的加载地址发生了一些变化,bc 的地址间距似乎更大了。此外,这次编译器对变量 bsizeof 决议发出了警告。

到这里为止,可能有人对上面的例子嗤之以鼻,认为不过是列举了C语言的一些特性罢了,谈不上什么批评。有些人可能会认为,只要严格限制全局变量,要么用static修饰以限制作用域,要么在定义时进行初始化以杜绝弱符号,从而在编译阶段就能发现问题。那么,只要谨慎使用,C语言依然是完美的嘛!

对于抱有这种想法的人,我只能说,请在夜深人静时竖起耳朵仔细聆听,你或许能听到Dennis Ritchie在九泉之下发出的邪恶笑声——不如说是嘲笑,更像是一种诅咒……

第四个例子
/* foo.c */
#include 

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 

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中的bc定义前加上const限定符,结果程序在第一次调用foo()时触发了Segmentation fault异常。推测这是因为GCC对const常量的地址启用了写保护机制。

最后,再次提醒大家,C语言是一门很恐怖的语言!

总结
  • 亲自动手写测试程序。只有通过实际代码验证,才能让人信服。
  • 不要依赖不成熟的代码。测试应基于标准行为,而非依赖有意设计的“漏洞”。

阅读完毕,很棒哦!

文章评论

站点信息

  • 网站地址:www.xiaodahan.com
  • 我的QQ: 3306916637