c++编译链接模型精要

  c++ 语言的三大约束是:与 c 兼容、零开销原则、值语义。与 C 兼容除了语法兼容,更重要的是兼容 C 语言的编译模型与运行模型,现代操作系统暴露出的原生接口往往是 C 语言描述的。从.c 文件到.o 文件需要经过 预处理、编译(代码转换)、汇编、链接
.i 表示经过预处理的文件
.s 表示汇编语言源代码文件
.S 表示经过预编译的汇编语言源代码文件
.o 表示编译后的目标文件

  C++ 至今也没有引入模块的概念,因此不能用import,需要用include头文件的方式机械地将库的接口声明以文本替换的方式载入,再重新解析。但是头文件包含具有传递性,引入不必要的依赖;头文件是在编译的时候使用的,动态库文件是在运行时使用,二者时间差可能带来不匹配,导致二进制兼容方面的问题。

  C++ 为了兼容C语言,付出了很大的代价,例如要兼容C 语言的隐式类型转换规则,这让C++ 函数重载决议规则变得很复杂。

  C++ class 定义最后面 } 后面的“;” ,这个符号是为了和 C语言的struct 结构语法兼容,因为C允许在函数返回类型出定义新的struct 类型,因此分号是必要的。

1、C语言的编译模型及其成因

  1969年ken Thompson 用汇编语言在一台闲置的PDP-7 小型机上写出了UNIX 的史前版本,由于PDP-7的字长是18位,不能按照字(word)寻址,不支持我们现在常见的8位寻址。假如C语言诞生在PDP-7上,计算机硬软件的发展史恐怕要改写。1971年,贝尔实验室为了排版实验室的专利申请购买一台新的计算机,这台机器是16位字长的。1972年C语言加入了预处理,具备了编写大型程序的能力。次年,C语言基本定型,主要新特性是支持结构体。1973年,ken Thompson 和 Dennis Ritchie 两个人合力用C语言中心编写了Unix 内核,完成了高级语言编写操作系统的伟大创举。

(1)、 为什么C语言需要预处理

  由于早期的计算机的内存空间很小,没办法在内存里完整地表示单个源文件的抽象语法树,更不能把整个程序(有多个源文件组成)放到内存中,已完成交叉引用。由于内存的限制,编译器必须要能分别编译多个源文件,生成多个目标文件,再设法将这些目标文件链接成一个可执行文件。

  同一时期Niklaus Wirth 设计的Pascal 语言可以定义函数和结构体,也支持指针,但是它所有的程序位于同一个文件中,这大大限制了Pascal 在系统编程方面的应用。如果Pascal 克服了这个缺点,那么我们今天很可能要把begin 和 end 直接映射到键盘上。哈哈哈哈

  为了能在尽量减少内存使用的情况下实现分离编译,C语言采用了“隐式函数声明” 的做法。代码在使用前文未定义的函数时,编译器不需要也不检查函数的原型:既不检查参数个数,也不检查参数类型以及返回值类型 。编译器认为为声明的函数都返回int , 并且能接受任意个数的 int 型参数。而且早期的C语言并不区分指针和 int ,认为二者可以相互赋值转换。在C++程序员看来,这是毫无安全保障的做法,但是C语言也就是如此地相信程序员。

  实际上,C语言使用某个没有定义的函数,那么实际造成的是链接错误,而非编译错误。其实,有了隐式函数声明,我们已经能分别编译多个源文件,然后将它们链接为可执行的文件。那么为什么还需要头文件和预处理呢?
头文件和预处理的最大的好处是:将公共信息做成头文件,可以减少无畏的错误,提高质量的代码。最早的预处理只有两项功能,#include 和 #define。#include 完成文件内容替换,#define只支持宏定义常量,不支持宏定义函数。

(2)、C语言的编译模型

  由于不能将整个源文件的语法树保存在内存中,C语言其实是按照“单遍编译” 来设计的。单遍编译指的是从头到尾扫描一遍源码,一边解析代码,一边即刻生成目标代码。在单遍编译时。编译器只能看到目前已经解析过的代码,不到之后的代码,而且过眼即忘。

  • C语言要求结构体必须先定义,才能访问其成员,否则编译器不知道结构体成员的类型和偏移量,就无法即刻生成目标代码。
  • 局部变量也必须先定义再使用,因为如果定义放在后面,编译器再第一次看到一个局部变量时候,并不知道它的类型和在stack中的位置,也就无法立刻生成代码,只能报错退出。
  • 为了方便编译器分配stack空间,C语言要求局部变量只能在语句块的开始处定义。
  • 对于外部变量,编译器只需要知道它的类型和名字,不需要知道它的地址,因此需要先声明后使用。外部变量的地址是空白的,留给链接器去补起来。
  • 当编译器看到一个函数调用时,按隐式函数声明规则,编译器可以立刻生成调用函数的汇编代码(函数参数入栈,调用,获取返回值),唯一不确定的是被调用函数的地址,编译器可以留下一个空白给链接器去填补。

  对c 编译器;来说,只需要记住 struct 结构的成员和偏移,外部变量的类型就足以一边解析源代码,一边生成目标代码。外部符号的解析交给链接器去完成。(c 函数不支持嵌套定义,因为嵌套定义需要递归解析,内存消耗大),由于不能嵌套,整个c 语言的命名空间是平坦的,函数和struct 都处于全局命名空间。

2、C++的编译模型

(1)、单遍编译

  c++ 也继承了单遍编译(从头到尾扫描源代码,一边解析一边生成目标代码)。但是单遍编译非常影响名字查找以及函数重载。c++编译器的符号表至少要保存目前已看到的每个名字的含义,包括class 的成员定义、已声明的变量、已知的函数原型等,才能正确解析源代码。还没有考虑template,编译template 的难度超乎想象。编译器还要正确处理作用域嵌套引发的名字的含义的变化:内层作用域中的名字可能会遮住(shadow)外层作用域中的名字。 建议用 g++ 的 -Wshadow 选项来编译代码 (推荐用 g++ -Wextra -Werror -Wconversion -Wshadow 编译 )

  c++ 必须在内存中保存函数级的语法树,才能正确实施返回值优化(RVO),否则遇到return 的时候编译器无法判断被返回的这个对象是不是那个可以被优化的对象。c++ 编译器并不能像C 那样做到单遍编译,只是为了兼容C 才称为单遍编译。

(2)、前向声明

  几乎每份 C++ 编码规范都会建议尽量使用前向声明来减少编译期 的依赖。对于函数的原型声明和函数体定义而言,不一致表现在参数列表和返回值类型上,编译器通常可以查出参数列表的不同,但不一定能查出返回类型不同;也可能会出现参数的类型相同,但是顺序反了,编译器无法查出此类错误,因为原型声明中的变量名是无用的。使用前向声明可以减少include,并且避免将内部class 的定义暴露给用户代码。但是前向声明是有限制的,如果class 中重载了 && 、||、, 这三个操作符,就不能使用前向声明。

3、C++ 链接

   越基础的库放在后面,这样内存消耗比较小。如果先处理基础库,那么链接器不知道哪些符号后面会被使用,因此只能将每个符号都记住。如果先处理应用库,那么只需要记住目前尚未定义的符号就行了。

  • 函数重载,需要类型安全的链接,即name mangling。
  • vague linkage ,即同一个符号有多份互不冲突的定义。

   C++ 编译器在处理单个源文件的时候并不知道某些符号是否应该在本编译单元定义。为了保险起见,只能为每个目标文件生成一份弱定义,而依赖链接器去选择一份作为最终的定义,这就是 vague linkage。不这么做的话,会出现未定义符号的错误,因为链接器通常不会聪明到反过来调用编译器去生成未定义的符号。为了让这种机制能正常运行,C++ 要求代码满足2定义原则(ODR),否则代码的行为是随机的。

  • 函数重载
      为了实现函数重载,C++ 编译器采用名字改编(name mangling),为每个重载函数生成独一无二的名字,这样在链接的时候就能找到正确的函数重载版本。注意,普通的非模板函数的name mangling 不包含返回类型,因为返回类型不参与函数重载。(返回值类型不同的是函数重写)

这样存在一个问题:如果一个源文件用到了重载函数,但是错误地将返回值类型写错了,链接器不能捕捉这样的错误。

  • inline 函数
       由于 inline 函数的关系,C++ 源代码里调用一个函数并不意味这生成的目标代码里也会做一次真正的函数调用。现在的编译器聪明到可以判断一个函数是否适合inline,因此inline 关键字在源文件中往往不是必需的。在头文件中还是需要inline 的,为了防止链接器报怨重复定义。现在的C++ 编译器采用重复代码消除的办法来避免重复定义。也就是说,如果编译器无法inline 展开的话,每个编译单元都会生成inline 函数的目标代码,然后链接器会从多份实现中任选一份保留,其余的则丢弃( vague linkage)。如果编译器能够展开inline 函数,那么就不用单独为之生成目标代码(除非使用函数指针指向它)。

传统的C++ 教程中告诉我们,要想编译器能inline 一个函数,那么这个函数体必须在当前编译单元可见。因此我们常常将公共inline 函数放到头文件中。现在有了 link time code generation,编译器不需要看到inline 函数的定义,inline 展开可以留给链接器去做。(编译器如何处理inline 函数中的static 变量?

4、工程中使用头文件的使用规则

  一旦为了使用某个struct 或者 某个库函数而包含一个头文件,那么这个头文件中定义的其他名字也被引入当前编译单元,可能会引起错误。

(1)、头文件的害处
  • 传递性。头文件可以再包含其他头文件。
  • 顺序性。一个源文件可以包含多个头文件,如果头文件内容组织不当,会造成程序的语义个头文件包含顺序有关,也跟是否包含某一个头文件有关。
  • 差异性。内容差异造成不同源文件看到的头文件不一致,时间差异造成头文件与库文件内容不一致。

相比于C++ ,现在不少语言支持模块化,模块化的做法主要有两种:

  • 对于解释型语言,import 的时候直接把对应某块的源文件解析一遍(不再是简单地把源文件包含进来);
  • 对于编译型语言,编译出来的目标文件里直接包含了足够的元数据,import 的时候只需要读取目标文件的内容,不需要读取源文件。

模块化做法的好处是避免了声明与定义不一致的问题,因为在这些语言中,声明和定义一体的。同时import 也不会引入不想要的名字,大大简化了名字查找的负担,也不用担心import 的顺序不同造成代码功能变化。

(2)、头文件的使用规则
  • 将文件间的编译依赖降至最小;
  • 将定义式之间的依赖关系降至最小;
  • 让 class 名字、头文件名字、源文件名字直接相关;
  • 令头文件自给自足;
  • 总是在头文件内写内部 #include guard(护套),不要在源文件写外部护套;
  • #include guard用的宏名字应该包含文件的路径全名;
  • 如果编写库,需要公开的头文件应该表达模块的接口。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值