C/C++中的重复定义bug
标签(空格分隔):c/c++
使用不当很容易出现重定义的bug
可以重复声明,不可以重复定义
在.h头文件中,变量如果没有初始化就是声明;初始化了就是定义。所以很多代码里面,都把变量的声明放在.h文件中当作全局变量使用,这是可以的,但如果这样使用的话是不能进行初始化的。一旦对.h中声明的变量进行了初始化也就相当于进行了定义,就可能出现重复定义的bug。或许可行的解决方案
#ifndef _HEADERNAME_H #define _HEADERNAME_H ...//(头文件内容) #endif
该解决方案针对的是一个.c文件中存在多个
#include *.h
,且这些.h文件同时又包含很多其他的头文件,造成重复包含。其他的重复包含
情况一
[main.c]#include <stdio.h> #include <stdlib.h> #include "fun.h" // int x; //见蓝色文字说明 int main() { fun(0); g_val; system("pause"); return 0; }
[fun.c]
#include <stdio.h> #include <stdlib.h> #include "fun.h" void fun(int x) { printf("iuahysiouayspi"); }
[fun.h]
int g_val; void fun(int x);
可以正常执行,不会报错,通过
nm
命令列出目标文件(.o文件)的符号清单如下:
可以看出,只是声明但是没有定义的g_val
,在两个目标文件中都位于C段,即common段,表示未初始化数据段,该数据段的数据对应的空间只有在链接过程才会分配,所以不会有重定义的报错(相当于多次声明,但不是多次定义)。不要使用这种方式声明全局变量!!!
有一点需要说明的是如果我们在main.c文件中的main函数上面声明一个全局变量int x;
通过 nm 命令可以发现,x在ELF中的位置和头文件中声明g_val的位置规律是一致的,即如果未初始化就在common段,如果初始化了就在.bss/.data段(由是否初始化为0决定)。情况二
[main.c]#include <stdio.h> #include <stdlib.h> #include "fun.h" int main() { fun(0); g_val; system("pause"); return 0; }
[fun.c]
#include <stdio.h> #include <stdlib.h> #include "fun.h" void fun(int x) { printf("iuahysiouayspi"); }
[fun.h]
int g_val = 0; // or int g_val = 1; void fun(int x);
见下图,会报错,g_val重定义。
【初始化为0】
【初始化为1】
可以看到,如果全局变量初始化为0,在两个目标文件中g_val都位于B段,即.bss段。如果全局变量初始化为1,在两个目标文件中g_val都位于D段,即.data段。很明显,两种情况下都会出现重复定义的问题(这也对应了不能出现两个强符号的规则)。情况三
[main.c]#include <stdio.h> #include <stdlib.h> #include "fun.h" int g_val = 0; int main() { fun(0); system("pause"); return 0; }
[fun.c]
#include <stdio.h> #include <stdlib.h> #include "fun.h" void fun(int x) { printf("iuahysiouayspi"); }
[fun.h]
extern int g_val; void fun(int x);
最好的全局变量的使用方法:在相应的.c文件中定义全局变量,在对应的.h文件中使用extern关键字。
因为对fun.c而言,extern int g_val
只是声明,而不是定义,且因为extern关键字,导致在fun.o文件中没有g_val符号的存在(在unix下使用U来标记这种情况的,U表示该符号在当前文件中未定义,即符号的定义在别的文件中)。而在main.o中,g_val被定义(且被初始化为0),在g_val在main.o目标文件的.bss段。此时,两个可执行文件之间不会出现全局变量的重定义。【再次说明】
#ifndef #define ... #endif
这些宏的作用只是在编译之前就“决定”了代码是否会参与编译以及后面的链接过程。#define
宏的使用见define和const的对比。
【补充·其他情况】
[main.c]#include <stdio.h> #include <stdlib.h> #include "fun.h" g_val = 0; int main() { fun(0); system("pause"); return 0; }
[fun.c]
#include <stdio.h> #include <stdlib.h> #include "fun.h" void fun(int x) { printf("iuahysiouayspi"); }
[fun.h]
int g_val; void fun(int x);
会报错,但是和重定义没有关系,报错如下:
error: ‘g_val’ does not name a type g_val = 0; ^
原因是在头文件中声明的全局变量,其实相当于在函数之外声明了全局变量(预处理过程,include内容被替换为相应代码),C/C++规定不能在函数外对变量赋值,但是可以初始化。这与很多编译器链接之前可以单独编译的特性没关系…。
上述说明在c++中就不行,c++作为一门面向对象的混合型语言,虽然形式上述的代码是可以的,但是最好不要出现面向过程的写法(再次我们仅仅是为了说明语言的差异)。如果还是上述代码,我们使用g++编译器编译会发现,不管我们是否初始化,在头文件中或者在.c文件中都算做定义,即变量都会分配在ELF的.bss/.data段,因为c++已经规定只有使用extern关键字且没有初始化的形式叫做声明,比如
extern int a;
,其余的都叫做既声明又定义,比如int a;
。
来自 coolshell 的记录:一个例子
全局变量是C语言语法和语义中一个很重要的知识点
首先它的存在意义需要从三个不同角度去理解:对于程序员来说,它是一个记录内容的变量(variable);对于编译/链接器来说,它是一个需要解析的符号(symbol);对于计算机来说,它可能是具有地址的一块内存(memory)。
其次是语法/语义:从作用域上看,带static关键字的全局变量范围只能限定在文件里,否则会外联到整个模块和项目中;从生存期来看,它是静态的,贯穿整个程序或模块运行期间(注意,正是跨单元访问和持续生存周期这两个特点使得全局变量往往成为一段受攻击代码的突破口,了解这一点十分重要);从空间分配上看,定义且初始化的全局变量在编译时在数据段(.data)分配空间,定义但未初始化的全局变量暂存(tentative definition)在.bss段,编译时自动清零,而仅仅是声明的全局变量只能算个符号,寄存在编译器的符号表内,不会分配空间,直到链接或者运行时再重定向到相应的地址上。
一个例子
[ 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; }
运行情况:
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)使用以下规则决议:
(1) 不允许出现多个相同强符号。
(2) 如果有一个强符号和多个弱符号,则选择强符号。
(3) 如果有多个弱符号,那么先决议到size最大的那个,如果同样大小,则按照链接顺序选择第一个。像上面这个例子中,全局变量a和b存在重复定义。如果我们将main.c中的b初始化赋值,那么就存在两个强符号而违反了规则一,编译器报错。如果满足规则二,则仅仅提出警告,实际运行时决议的是foo.c中的强符号。而变量a都是弱符号,所以只选择一个(按照目标文件链接时的顺序)。