好奇的探索者,理性的思考者,踏实的行动者。
Table of Contents:
所谓声明(Declaration),就是告诉编译器我要使用这个变量或函数,你现在没有找到它的定义不要紧,请不要报错,稍后我会把定义补上。
1) 函数的声明
函数的定义有函数体,函数的声明没有函数体,编译器很容易区分定义和声明,所以对于函数声明来说,有没有 extern 都是一样的。
2) 变量的声明
变量和函数不同,编译器只能根据 extern 来区分,有 extern 才是声明,没有 extern 就是定义。
变量的定义有两种形式,你可以在定义的同时初始化,也可以不初始化:
datatype name = value; datatype name;
而变量的声明只有一种形式,就是使用 extern 关键字:
extern datatype name;
另外,变量也可以在声明的同时初始化,格式为: extern datatype name = value;
这种似是而非的方式是不被推荐的,有的编译器也会给出警告,我们不再深入讨论,也建议各位读者把定义和声明分开,尽量不要这样写。
从源代码生成可执行文件可以分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。
汇编的过程非常简单,仅仅是查表翻译,我们通常把它作为编译过程的一部分,不再单独提及。这样,源文件经过预处理、编译和链接就生成了可执行文件。
从文件结构上来讲,目标文件已经是二进制文件,它与可执行文件的组织形式非常类似,只是有些变量和函数的地址还未确定,程序不能执行。链接的一个重要作用就是找到这些变量和函数的地址。
编译是针对单个源文件的,有几个源文件就会生成几个目标文件。
现在PC平台上流行的可执行文件格式主要是 Windows 下的 PE(Portable Executable)和 Linux 下的 ELF(Executable Linkable Format),它们都是 COFF(Common File Format)格式的变种。
从广义上讲,目标文件与可执行文件的存储格式几乎是一样的,我们可以将它们看成是同一种类型的文件,在 Windows 下,将它们统称为 PE 文件,在 Linux 下,将它们统称为 ELF文件。
另外,动态链接库(DLL,Dynamic Linking Library)(Windows 下的.dll和 Linux 下的.so)和静态链接库(Static Linking Library)(Windows 下的.lib和 Linux 下的.a)也是按照可执行文件的格式存储的。
Section && Segment
站在文件结构的角度,可执行文件包含了众多的段(Section),每个段都有不同的作用;
站在加载和执行的角度,所有的段都是数据,操作系统只关心数据的权限,只要把相同权限的数据加载到同一个内存区域,程序就能正确执行。
常见的数据权限无外乎三种:
* 只读(例如 .rodata 只读数据段)、
* 读写(例如 .data 数据段)、
* 读取和执行(例如 .text 代码段)
我们将一块连续的、具有相同权限的数据称为一个 Section,一个 Segment 由多个权限相同的 Section 构成。
不巧的是,“Segment”也被翻译为“段”,但这里的段(Segment)是针对加载和执行的过程。
在目标文件中,段表(Section Table)用来描述各个 Section 的信息,包括它的名字、长度、在文件中的偏移、读写权限等,通过段表可以详细地了解目标文件的结构。
而在可执行文件中,段表被删除了,取代它的是程序头表(Program Header Table);
程序头表用来描述各个 Segment 的信息,包括它的类型、偏移、在进程虚拟地址空间中的起始地址、物理装载地址、长度、权限等。操作系统就是根据程序头表将可执行文件加载到内存,并为各个 Segment 分配内存空间、确定起止地址。
可执行文件不再关注具体的文件结构,而是关注程序的加载和执行过程。
由于可执行文件在加载时实际上是被映射的虚拟地址空间,所以可执行文件很多时候又被叫做映像文件(Image)。
编译器生成的是目标文件,而我们最终需要的是可执行文件,链接(Linking)的作用就是将多个目标文件合并成一个可执行文件
在链接过程中,链接器会将多个目标文件中的代码段、数据段、调试信息等合并成可执行文件中的一个段。段的合并仅仅是一个简单的叠加过程
数据是保存在内存中的,对于计算机硬件来说,必须知道它的地址才能使用。变量名、函数名等仅仅是地址的一种助记符,目的是在编程时更加方便地使用数据,当源文件被编译成可执行文件后,这些标识符都不存在了,它们被替换成了数据的地址。
编译器和链接器的一项重要任务就是将助记符替换成地址。
符号(Symbol)这个概念随着汇编语言的普及被广泛接受,它用来表示一个地址,这个地址可能是一段子程序(后来发展为函数)的起始地址,也可以是一个变量的地址。
现代软件的规模往往都很大,动辄数百万行代码,程序员需要把它们分散到成百上千个模块中。这些模块之间相互依赖又相互独立,原则上每个模块都可以单独开发、编译、测试,改变一个模块中的代码不需要编译整个程序。
在C语言中,一个模块可以认为是一个源文件(.c 文件)。
在程序被分隔成多个模块后,需要解决的一个重要问题是如何将这些模块组合成一个单一的可执行程序。在C语言中,模块之间的依赖关系主要有两种:一种是模块间的函数调用,另外一种是模块间的变量访问。
函数调用需要知道函数的首地址,变量访问需要知道变量的地址,所以这两种方式可以归结为一种,那就是模块间的符号引用。
这种通过符号将多个模块拼接为一个独立的程序的过程就叫做链接(Linking)。
链接(Linking)就是通过符号将各个模块组合成一个独立的程序的过程。
链接的主要内容就是把各个模块之间的相互引用部分处理好,使得各个模块能够正确地衔接。原理无非是找到符号的地址,或者把指令中使用到的地址加以修正。这个过程称为符号决议(Symbol Resolution)或者重定位(Relocation)。
有了链接器,我们可以直接调用其他模块中的函数而无需知道它们的地址,因为在链接的时候,链接器会根据符号 func 自动去 module.c 模块查找 func 的地址,然后将 main.c 模块中所有使用到 func 的指令重新修正,让它们的目标地址成为真正的 func() 函数的地址。
这种在程序运行之前确定符号地址的过程叫做静态链接(Static Linking);
如果需要等到程序运行期间再确定符号地址,就叫做动态链接(Dynamic Linking)。
在C语言中,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)
强符号之所以强,是因为它们拥有确切的数据,变量有值,函数有函数体;弱符号之所以弱,是因为它们还未被初始化,没有确切的数据。
链接器会按照如下的规则处理被多次定义的强符号和弱符号:
1) 不允许强符号被多次定义,也即不同的目标文件中不能有同名的强符号;如果有多个强符号,那么链接器会报符号重复定义错误。
2) 如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。
3) 如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。
在 GCC 中,可以通过__attribute__((weak))
来强制定义任何一个符号为弱符号。
需要注意的是,__attribute__((weak))
只对链接器有效,对编译器不起作用,编译器不区分强符号和弱符号,
只要在一个源文件中定义两个相同的符号,不管它们是强是弱,都会报“重复定义”错误。
弱符号对于库来说十分有用,我们在开发库时,可以将某些符号定义为弱符号,这样就能够被用户定义的强符号覆盖,从而使得程序可以使用自定义版本的函数,增加了很大的灵活性。
目前我们所看到的符号引用,在所有目标文件被链接成可执行文件时,它们的地址都要被找到,如果没有符号定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。
与之相对应的还有一种弱引用(Weak Reference),如果符号有定义,就使用它对应的地址,如果没有定义,也不报错。
链接器处理强引用和弱引用的过程几乎是一样的,只是对于未定义的弱引用,链接器不认为它是一个错误,一般默认其为 0(地址为 0),或者是一个特殊的值,以便程序代码能够识别。这特别适合做插件。
在变量声明或函数声明的前面加上__attribute__((weak))
就会使符号变为弱引用。
弱引用和强引用非常利于程序的模块化开发,我们可以将程序的扩展模块定义为弱引用,当我们将扩展模块和程序链接在一起时,程序就可以正常使用;如果我们去掉了某些模块,那么程序也可以正常链接,只是缺少了某些功能,这使得程序的功能更加容易裁剪和组合。
头文件可以包含如下的内容:
* 可以声明函数,但不可以定义函数。
* 可以声明变量,但不可以定义变量。
* 可以定义宏,包括带参的宏和不带参的宏。
* 结构体的定义、自定义数据类型一般也放在头文件中。
实际上我们一般不直接向用户提供目标文件,而是将多个相关的目标文件打包成一个静态链接库(Static Link Library),例如 Linux 下的 .a 和 Windows 下的 .lib。
打包静态库的过程很容易理解,就是将多个目标文件捆绑在一起形成一个新的文件,然后再加上一些索引,方便链接器找到,这和压缩文件的过程非常类似。
C语言在发布的时候已经将标准库打包到了静态库,并提供了相应的头文件,例如 stdio.h、stdlib.h、string.h 等。
在实际开发中,我们都是将头文件放在当前工程目录下,非常建议大家使用相对路径,这样即使后来改变了工程所在目录,也无需修改包含语句,因为源文件的相对位置没有改变。
#ifndef _INC_STDIO
#define _INC_STDIO
/* 头文件内容 */
#endif
那是一个被遗忘的年代,那时,编译器只认识.c(或.cpp)文件,而不知道.h是何物的年代。
那时的人们写了很多的.c(或.cpp)文件,渐渐地,人们发现在很多.c文件中的声明变量或函数原型是相同的,但他们却不得不一个字一个字地重复地将这些内容敲入每个.c文件。但更为恐怖的是,当其中一个声明有变更时,就需要检查所有的.c(或.cpp)文件,并修改其中的声明。
终于,有人再不能忍受这样的折磨,他(们)将重复的部分提取出来,放在一个新文件里,然后在需要的.c(或.cpp)文件中敲入#include XXXX
这样的语句。这样即使某个声明发生了变更,也再不需要到处寻找与修改了。
因为这个新文件,经常被放在.c(或.cpp)文件的头部,所以就给它起名叫做“头文件”。
一个.cc,或.cpp作为一个编译单元.生成.o
//变量是声明,并未实际分配地址,未产生实际目标代码,可以有多个重复声明的存在。
extern int x; //函数声明,未产生实际目标代码
void print(); 3 ; void print() {}; //定义,产生了实际目标代码。 int x; int x =
声明不产生实际的目标代码,它的作用是告诉编译器,我在该编译单元后面会有这个x变量或函数的定义。
否则编译器如果发现程序用到x,print,而前面没有声明会报错,不知道如何分配空间了。
如果有声明,而没有定义,那么链接的时候会报错未定义。
在c语言的早期,没有头文件,为了减少重复各种extern的变量和函数的声明,才加上了头文件
同一编译单元内部的重名符号在编译期就被阻止了,而不同编译单元之间的重名符号要到链接器才会被发现。
如果你在一个 source1.cc中
//source1.cc
int x;
int x;//出现两次 int x; int x;即两个x的定义,会编译报错,x重复定义。
如果你的
//source1.cc
int x;
//source2.cc
int x; g++ –o test source1.cc source2.cc
那么编译过程不会出错,在链接过程,由于目标代码中有两个全局域的x,会链接出错,x重定义。
不同的编程人员可能会写不同的模块,那么很容易出现这种情况,如何避免呢,namespace可以避免重名。
google编程规范鼓励使用不具名空间,没有名字的namespace,不具名空间只在本文件中可见。
//source1.cc
namespace {
int x;
}//source2.cc
namespace {
int x; }
OK,现在不会链接出错了因为两个x不重名了,当然对于这个简单的例子只在source1.cc中用不具名命名空间就可避免链接出错了。
//source1.cc
namespace {
int x;
}//source1.cc
static int x;
有什么区别呢,看上去效果一样,区别在于不具名空间的x仍然具有外链接,但是由于它是不具名的,所以别的单元没办法链接到,如果
namespace haha{
int x; }
则在别的单元可以用haha::x访问到它,static则因为是内部链接特性,所以无法链接到。可以用不具名命名空间替代static。
//head.h
int x;
//source1.cc
#include “head.h”
//source2.cc
#include “head.h”
头文件不被编译,.cc中的引用 include “ head.h”其实就是在预编译的时候将head.h中的内容插入到.cc中。
所以上面的例子如果
g++ –o test source1.cc source2.cc, 同样会链时发现重复定义的全局变量x。
因此变量定义,包括函数的定义不要写到头文件中,因为头文件很可能要被多个.cc引用。
那么如果我的head.h如下这么写呢,是否防止了x的链接时重定义出错呢?
//head.h
#ifndef _HEAD_H_
#define _HEAD_H_
int x;
#endif
//source1.cc
#include “head.h”
//source2.cc
#include “head.h”
现在是否g++ –o test source1.cc source2.cc就没有问题了呢,答案是否定的。
所有的头文件都是应该如上加头文件保护,但它的作用是防止头文件在同一编译单元被重复引用。
就是说防止可能的
//source1.cc
#include “head.h”
#include “head.h”
这种情况,当然我们不会主动写成上面的形式但是,下面的情况很可能发送
//source1.cc
#include “head.h”
#inlcude “a.h”
//a.h
#include “head.h”
这样就在不经意见产生了同一编译单元的头文件重复引用,于是soruc1.cc 就出现了两个int x;定义。
//类的声明类的声明和普通变量声明一样,不产生目标代码,可以在同一,以及多个编译单元重复声明。
class A;
class A {//类的定义只是告诉编译器,类的数据格式是如何的,实例化后对象该占多大空间。类的定义也不产生目标代码。 };
//source1.cc
class A;//类重复声明,OK
class A;
class A{
};
class A{
};
class A{
int x;//同一编译单元内,类重复定义,会编译时报错,因为编译器不知道在该编译单元,A a;的话要生产怎样的a. };
但是在不同编译单元内,类可以重复定义,因为类的定义未产生实际代码。但链接的时候会出问题。
//source1.cc
class A{
}
//source2.cc
class A{
int x;
}
< >
引用的是编译器的类库路径里面的头文件
" "
引用的是你程序目录的相对路径中的头文件,在程序目录的相对路径中找不到该头文件时会继续在类库路径里搜寻该头文件。
C++标准程序库中的所有标识符都被定义于一个名为std
的namespace中
C++ 头文件的现状:
1) 旧的 C++ 头文件,如 iostream.h、fstream.h 等将会继续被支持,尽管它们不在官方标准中。这些头文件的内容不在命名空间 std 中。
2) 新的 C++ 头文件,如 iostream、fstream 等包含的基本功能和对应的旧版头文件相似,但头文件的内容在命名空间 std 中。
3) 标准C头文件如 stdio.h、stdlib.h 等继续被支持。头文件的内容不在 std 中。
4) 具有C库功能的新C++头文件,如 cstdio、cstdlib 。提供的内容和相应的旧的C头文件相同,只是内容在 std 中。
不过现实情况和 C++ 标准所期望的有些不同,对于原来C语言的头文件,即使按照 C++ 的方式来使用,即#include <cstdio>
这种形式,那么符号可以位于命名空间 std 中,也可以位于全局范围中, 大部分编译器在实现时并没有严格遵循C++标准,它们对带std和不带std的都支持
c++标准为了和C区别开,也为了正确使用命名空间,规定头文件不使用后缀.h。
一 :<iostream>
和<iostream.h>
格式不一样,后缀为.h的头文件c++标准已经明确提出不支持了,早些的实现将标准库功能定义在全局空间里,声明在带.h后缀的头文件里,c++标准为了和C区别开,也为了正确使用命名空间,规定头文件不使用后缀.h
。 因此,
1)当使用<iostream.h>
时,相当于在c中调用库函数,使用的是全局命名空间,也就是早期的c++实现;
2)当使用<iostream>
的时候,该头文件没有定义全局命名空间,必须使用namespace std;这样才能正确使用cout。
一个是为了兼容以前的C++代码,一个是为了支持新的标准。命名空间std封装的是标准程序库的名称,标准程序库为了和以前的头文件区别,一般不加".h"
1、直接指定标识符。例如std::cout
2、使用using关键字。 using std::cout; using std::endl; using std::cin;
以上程序可以写成 cout << std::hex << 3.4 << endl
;
3、最方便的就是使用using namespace std
; 这样命名空间std内定义的所有标识符都有效。
很多教程中都是这样做的,将 std 直接声明在所有函数外部,这样虽然使用方便,但在中大型项目开发中是不被推荐的,这样做增加了命名冲突的风险,我推荐在函数内部声明 std。