找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4607

积分

0

好友

604

主题
发表于 5 天前 | 查看: 25| 回复: 0

今天我们来聊聊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 amain.c定义了未初始化的int bint cfoo.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.csizeof(b)是4字节,而foo.c中是8字节,因为sizeof是编译时决议的,两个文件对b的类型定义不同。但令人惊讶的是,无论在哪个文件中,ab的地址都是相同的。这意味着它们虽然被定义了两次(b还是不同类型),但在内存映像中只有一份拷贝。

此外,main.cb的值竟然是foo.c中结构体第一个成员b.a的值。这印证了前面的推断:即便存在多次定义,内存中也只保留一份初始化的拷贝。变量c则是一个独立的个体。

为什么会这样?这涉及到C编译器对多重定义全局符号的解析与链接器的链接规则。

编译时,编译器将全局符号信息编码在目标文件的符号表中。这里引入 “强符号(strong)”“弱符号(weak)” 的概念:

  • 强符号:已定义并初始化的变量(如foo.c中的结构体b)。
  • 弱符号:未定义,或定义但未初始化的变量(如main.c中的bc,以及两个文件中的a)。

GNU链接器(ld)处理多重定义符号时遵循以下规则:

  1. 不允许出现多个相同的强符号。
  2. 如果有一个强符号和多个弱符号,则选择强符号。
  3. 如果都是弱符号,则优先选择size最大的那个;若size相同,则按链接顺序选择第一个。

在上面的例子中,ab都存在重复定义。a都是弱符号,所以只选一个。bmain.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,全局变量bc的地址(逻辑地址)仍然一致,且不同模块对bsizeof决议依然不同。

值得注意的是,子进程中对整型变量b赋值后,子进程自身(包括调用的foo()函数)中,整型b和结构体成员b.a的值都变成了1,而父进程中它们的值仍是2,但显示的地址相同。

可以这样解释:fork()创建子进程时,子进程复制了父进程的上下文“镜像”(包括全局变量),虚拟地址相同但属于不同的进程空间。此时物理内存中只有一份拷贝,所以b的初始值相同(都是2)。随后子进程对b进行写操作,触发了操作系统的 写时拷贝(Copy on Write) 机制,物理内存中才产生真正的两份拷贝,分别映射到两个进程空间,但虚拟地址值不变。

这个例子编译时没有出现第一个例子中关于bsizeof决议警告,原因不明。

第三个例子:静态链接库

代码与第二个例子相同,仅修改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

运行结果与第二个例子类似,只是全局变量的加载地址发生了变化,且这次编译器给出了关于变量bsizeof决议警告。

第四个例子:动态链接库——真正的“大坑”

看到这里,或许有人觉得这些不过是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()函数,bc立即被修改。一种可能的解释是,强符号的全局变量在数据段中是连续分布的,当动态库中的代码通过结构体视角(8字节)去修改同一地址时,实际上覆盖了主程序中两个整型变量(4字节+4字节)的空间。

更诡异的是,即便将t1.c中的bcconst修饰,编译器依然通过,但程序会在首次调用foo()时因段错误(Segment Fault)而崩溃。这可能是现代GCC对const常量所在内存启用了写保护机制。至于volatile关键字,测试表明它对全局变量的此类行为没有影响。

总结与思考

怎么样?最后一个例子是否让你感到后怕?C语言在你心中是否还是那个“行为一致”的纯洁形象?

在动态链接环境下,即使你将所有全局变量都定义为强符号(初始化),也无法完全避免这种诡异的交叉改写。这也是一些安全攻击的潜在载体,恶意代码可能通过全局变量注入到存在漏洞的进程中。

你或许会将责任归咎于编译器和链接器,但别忘了,正是编译/链接器的具体行为支撑了这门语言的语法和语义。反观C++引入了命名空间(namespace),或是其他现代语言,通常都不会允许重定义的全局变量通过编译。

因此,请务必谨慎使用C语言的全局变量,尤其是在大型项目或涉及动态链接库时。理解其背后的链接模型和内存布局,是写出稳健C程序的关键一步。更多关于编译、链接器原理的深度讨论,欢迎在云栈社区交流。




上一篇:C语言内联函数原理与应用:如何用inline优化函数调用开销
下一篇:C语言指针完全指南:从基础概念到复杂应用解析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 20:12 , Processed in 0.734320 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表