C语言令人抓狂的一面——全局变量

混说Linux 2023-01-16 11:00
点击左上方蓝色“混说Linux”,选择“设为星标
第一时间看干货文章



 1

作为一名程序员,如果说沉迷一门编程语言算作一种乐趣的话,那么与此同时反过来去黑一门编程语言就是这种乐趣的升华。今天我们就来黑一把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 
#include "t.h"

struct {
   char a;
   int b;
} b = { 24 };

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 
#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 ;

struct {
    int a;
    int b;
} b = { 24 };

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)=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 

const struct {
    int a;
    int b;
} b = { 33 };

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)=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是一门很恐怖的语言!


文整理自网络,版权归原作者所有,如有侵权,请联系删除。





往期推荐

EEPROM 和 flash 这样讲,早就懂了!

STM32 单片机开发中的 RTOS

PID到底是个啥?给你讲个故事,通俗易懂

Linux 最强总结!

来都来了,点个 在看 再走吧~~~



混说Linux 百度研发工程师,分享Linux干货,和大家一起学习!
评论
  • 每日可见的315MHz和433MHz遥控模块,你能分清楚吗?众所周知,一套遥控设备主要由发射部分和接收部分组成,发射器可以将控制者的控制按键经过编码,调制到射频信号上面,然后经天线发射出无线信号。而接收器是将天线接收到的无线信号进行解码,从而得到与控制按键相对应的信号,然后再去控制相应的设备工作。当前,常见的遥控设备主要分为红外遥控与无线电遥控两大类,其主要区别为所采用的载波频率及其应用场景不一致。红外遥控设备所采用的射频信号频率一般为38kHz,通常应用在电视、投影仪等设备中;而无线电遥控设备
    华普微HOPERF 2025-01-06 15:29 125浏览
  • By Toradex 秦海1). 简介嵌入式平台设备基于Yocto Linux 在开发后期量产前期,为了安全以及提高启动速度等考虑,希望将 ARM 处理器平台的 Debug Console 输出关闭,本文就基于 NXP i.MX8MP ARM 处理器平台来演示相关流程。 本文所示例的平台来自于 Toradex Verdin i.MX8MP 嵌入式平台。  2. 准备a). Verdin i.MX8MP ARM核心版配合Dahlia载板并
    hai.qin_651820742 2025-01-07 14:52 44浏览
  •     为控制片内设备并且查询其工作状态,MCU内部总是有一组特殊功能寄存器(SFR,Special Function Register)。    使用Eclipse环境调试MCU程序时,可以利用 Peripheral Registers Viewer来查看SFR。这个小工具是怎样知道某个型号的MCU有怎样的寄存器定义呢?它使用一种描述性的文本文件——SVD文件。这个文件存储在下面红色字体的路径下。    例:南京沁恒  &n
    电子知识打边炉 2025-01-04 20:04 100浏览
  • 自动化已成为现代制造业的基石,而驱动隔离器作为关键组件,在提升效率、精度和可靠性方面起到了不可或缺的作用。随着工业技术不断革新,驱动隔离器正助力自动化生产设备适应新兴趋势,并推动行业未来的发展。本文将探讨自动化的核心趋势及驱动隔离器在其中的重要角色。自动化领域的新兴趋势智能工厂的崛起智能工厂已成为自动化生产的新标杆。通过结合物联网(IoT)、人工智能(AI)和机器学习(ML),智能工厂实现了实时监控和动态决策。驱动隔离器在其中至关重要,它确保了传感器、执行器和控制单元之间的信号完整性,同时提供高
    腾恩科技-彭工 2025-01-03 16:28 170浏览
  • 根据Global Info Research项目团队最新调研,预计2030年全球封闭式电机产值达到1425百万美元,2024-2030年期间年复合增长率CAGR为3.4%。 封闭式电机是一种电动机,其外壳设计为密闭结构,通常用于要求较高的防护等级的应用场合。封闭式电机可以有效防止外部灰尘、水分和其他污染物进入内部,从而保护电机的内部组件,延长其使用寿命。 环洋市场咨询机构出版的调研分析报告【全球封闭式电机行业总体规模、主要厂商及IPO上市调研报告,2025-2031】研究全球封闭式电机总体规
    GIRtina 2025-01-06 11:10 104浏览
  • 根据环洋市场咨询(Global Info Research)项目团队最新调研,预计2030年全球无人机锂电池产值达到2457百万美元,2024-2030年期间年复合增长率CAGR为9.6%。 无人机锂电池是无人机动力系统中存储并释放能量的部分。无人机使用的动力电池,大多数是锂聚合物电池,相较其他电池,锂聚合物电池具有较高的能量密度,较长寿命,同时也具有良好的放电特性和安全性。 全球无人机锂电池核心厂商有宁德新能源科技、欣旺达、鹏辉能源、深圳格瑞普和EaglePicher等,前五大厂商占有全球
    GIRtina 2025-01-07 11:02 68浏览
  • 大模型的赋能是指利用大型机器学习模型(如深度学习模型)来增强或改进各种应用和服务。这种技术在许多领域都显示出了巨大的潜力,包括但不限于以下几个方面: 1. 企业服务:大模型可以用于构建智能客服系统、知识库问答系统等,提升企业的服务质量和运营效率。 2. 教育服务:在教育领域,大模型被应用于个性化学习、智能辅导、作业批改等,帮助教师减轻工作负担,提高教学质量。 3. 工业智能化:大模型有助于解决工业领域的复杂性和不确定性问题,尽管在认知能力方面尚未完全具备专家级的复杂决策能力。 4. 消费
    丙丁先生 2025-01-07 09:25 80浏览
  • 在智能家居领域中,Wi-Fi、蓝牙、Zigbee、Thread与Z-Wave等无线通信协议是构建短距物联局域网的关键手段,它们常在实际应用中交叉运用,以满足智能家居生态系统多样化的功能需求。然而,这些协议之间并未遵循统一的互通标准,缺乏直接的互操作性,在进行组网时需要引入额外的网关作为“翻译桥梁”,极大地增加了系统的复杂性。 同时,Apple HomeKit、SamSung SmartThings、Amazon Alexa、Google Home等主流智能家居平台为了提升市占率与消费者
    华普微HOPERF 2025-01-06 17:23 141浏览
  • 本文介绍Linux系统更换开机logo方法教程,通用RK3566、RK3568、RK3588、RK3576等开发板,触觉智能RK3562开发板演示,搭载4核A53处理器,主频高达2.0GHz;内置独立1Tops算力NPU,可应用于物联网网关、平板电脑、智能家居、教育电子、工业显示与控制等行业。制作图片开机logo图片制作注意事项(1)图片必须为bmp格式;(2)图片大小不能大于4MB;(3)BMP位深最大是32,建议设置为8;(4)图片名称为logo.bmp和logo_kernel.bmp;开机
    Industio_触觉智能 2025-01-06 10:43 87浏览
  • 随着市场需求不断的变化,各行各业对CPU的要求越来越高,特别是近几年流行的 AIOT,为了有更好的用户体验,CPU的算力就要求更高了。今天为大家推荐由米尔基于瑞芯微RK3576处理器推出的MYC-LR3576核心板及开发板。关于RK3576处理器国产CPU,是这些年的骄傲,华为手机全国产化,国人一片呼声,再也不用卡脖子了。RK3576处理器,就是一款由国产是厂商瑞芯微,今年第二季推出的全新通用型的高性能SOC芯片,这款CPU到底有多么的高性能,下面看看它的几个特性:8核心6 TOPS超强算力双千
    米尔电子嵌入式 2025-01-03 17:04 55浏览
  • 彼得·德鲁克被誉为“现代管理学之父”,他的管理思想影响了无数企业和管理者。然而,关于他的书籍分类,一种流行的说法令人感到困惑:德鲁克一生写了39本书,其中15本是关于管理的,而其中“专门写工商企业或为企业管理者写的”只有两本——《为成果而管理》和《创新与企业家精神》。这样的表述广为流传,但深入探讨后却发现并不完全准确。让我们一起重新审视这一说法,解析其中的矛盾与根源,进而重新认识德鲁克的管理思想及其著作的真正价值。从《创新与企业家精神》看德鲁克的视角《创新与企业家精神》通常被认为是一本专为企业管
    优思学院 2025-01-06 12:03 116浏览
  • PLC组态方式主要有三种,每种都有其独特的特点和适用场景。下面来简单说说: 1. 硬件组态   定义:硬件组态指的是选择适合的PLC型号、I/O模块、通信模块等硬件组件,并按照实际需求进行连接和配置。    灵活性:这种方式允许用户根据项目需求自由搭配硬件组件,具有较高的灵活性。    成本:可能需要额外的硬件购买成本,适用于对系统性能和扩展性有较高要求的场合。 2. 软件组态   定义:软件组态主要是通过PLC
    丙丁先生 2025-01-06 09:23 85浏览
  • 村田是目前全球量产硅电容的领先企业,其在2016年收购了法国IPDiA头部硅电容器公司,并于2023年6月宣布投资约100亿日元将硅电容产能提升两倍。以下内容主要来自村田官网信息整理,村田高密度硅电容器采用半导体MOS工艺开发,并使用3D结构来大幅增加电极表面,因此在给定的占位面积内增加了静电容量。村田的硅技术以嵌入非结晶基板的单片结构为基础(单层MIM和多层MIM—MIM是指金属 / 绝缘体/ 金属) 村田硅电容采用先进3D拓扑结构在100um内,使开发的有效静电容量面积相当于80个
    知白 2025-01-07 15:02 75浏览
  • 这篇内容主要讨论三个基本问题,硅电容是什么,为什么要使用硅电容,如何正确使用硅电容?1.  硅电容是什么首先我们需要了解电容是什么?物理学上电容的概念指的是给定电位差下自由电荷的储藏量,记为C,单位是F,指的是容纳电荷的能力,C=εS/d=ε0εrS/4πkd(真空)=Q/U。百度百科上电容器的概念指的是两个相互靠近的导体,中间夹一层不导电的绝缘介质。通过观察电容本身的定义公式中可以看到,在各个变量中比较能够改变的就是εr,S和d,也就是介质的介电常数,金属板有效相对面积以及距离。当前
    知白 2025-01-06 12:04 170浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦