C/C++编译模型
C++ 语言的三大约束:与 C 兼容、零开销(zero overhead)原则、值语义。
C语言编译模型
In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for [Stroustrup, 1994]. And further: What you do use, you couldn’t hand code any better.
1 |
|
C++ 编译器必须能理解头文件 sys/socket.h 中 struct sockaddr 的定义,生成与 C 编译器完全相同的 layout(包括采用相同的对齐 (alignment) 算法),遵循 C 语言的函数调用约定(参数传递、返回值传递、栈帧管理等等)。
笼统地说把 .cc 文件编译为可执行文件,指的的是 preprocessor/compiler/assembler/linker 这四个步骤。
- 预编译,预编译的时候做一些简单的文本替换,比如宏替换,而不进行语法的检查;
- 编译,在编译阶段,编译器将检查一些语法错误,但是,如果使用的函数事先没有定义这种情况,不再这一阶段检查,编译后,得到 .s 文件;
- 汇编,将 C/C++ 代码变为汇编代码,得到 .o 或者 .obj 文件;
- 链接,将所用到的外部文件链接在一起,在这一阶段,就会检查使用的函数有没有定义,链接过后,形成可执行文件 .exe。
C++ 没有模块机制,不能像其他现代编程语言那样用 import 或 using 来引入当前源文件用到的库,而必须用 include 头文件的方式来机械地将库的接口声明以文本替换的方式载入,再重新 parse 一遍。这也带来了一些隐患,部分原因是因为头文件包含具有传递性,引入不必要的依赖;另一个原因是头文件是在编译时使用,动态库文件是在运行时使用,二者的时间差可能带来不匹配,导致二进制兼容性方面的问题。
隐式函数声明(implicit declaration of function)
代码在使用前文未定义的函数时,编译器不需要也不检查函数原型:既不检查参数个数,也不检查参数类型与返回值类型。编译器认为未声明的函数都返回 int,并且能接受任意个数的 int 型参数。而且早期的 C 语言甚至不严格区分指针和 int,而是认为两者可以相互赋值转换。
1 | int main() # 这个程序没有引用任何头文件 |
如果 C 程序用到了某个没有定义的函数(可能错误拼写了函数名),实际造成的是链接错误 (undefined reference),而非编译错误。
1 | int main() |
#include 完成文件内容替换,#define 只支持定义宏常量,不支持定义宏函数。早期的头文件里只放 struct 定义、外部变量的声明、宏常量。
C 语言是按单遍编译 (one pass) 来设计:
- C 语言要求结构体必须先定义,才能访问其成员,否则编译器不知道结构体成员的类型和偏移量,无法立刻生成目标代码。
- 局部变量也必须先定义再使用,如果把定义放在后面,编译器在第一次看到一个局部变量时并不知道它的类型和在 stack 中的位置,无法立刻生成代码,只能报错退出。
- 为了方便编译器分配 stack 空间,C 语言要求局部变量只能在语句块的开始处定义。
- 对于外部变量,编译器只需要知道它的类型和名字,不需要知道它的地址,因此需要先声明后使用。在生成的目标代码中,外部变量的地址是个空白,留给链接器填上。
- 当编译器看到一个函数调用时,按隐式函数规则,编译器可以立刻生成调用函数的汇编代码(函数参数入栈、调用、获取返回值),这里唯一尚不能确定的是函数的实际地址,编译器可以留下一个空白给链接器去填。
C++编译模型
单遍编译
编译器只能根据目前看到的代码做出决策,读到后面的代码也不会影响前面做出的决定。
- 名字查找
C++ 的名字有类型名、函数名、变量名、typedef 名、template 名等。如果不知道 Foo、T、a 这三个名字代表什么,编译器就无法进行语法分析。有如下三种可能:1
Foo<T> a; # Foo、T、a 这三个名字都不是 macro
1、Foo 是个 templateclass Foo;T 是 type,那么这句话以 T 为模板类型参数类型具现化了 Foo 类型,并定义了变量 a。
2、Foo 是个 templateclass Foo;T 是 const int 变量,那么这句话以 T 为非类型模板参数具现化了 Foo 类型,并定义了变量 a。
3、Foo、T、a 都是 int,这句话没有任何意义。
C++ 编译器的符号表至少要保存目前已看到的每个名字的含义,包括 class 的成员定义、已声明的变量、已知的函数原型等,才能正确解析源代码。如果考虑 template,编译 template 的难度是很大的。编译器还要正确处理作用域嵌套引发的名字的含义变化:内层作用域中的名字有可能遮住 (shadow) 外层作用域中的名字。建议用 g++ 的 -Wshadow 选项来编译代码。(muduo 的代码都是 -Wall -Wextra -Werror -Wconversion -Wshadow 编译的)。
- 函数重载决议
当 C++ 编译器读到一个函数调用语句时,它必须(也只能)从目前已看到的同名函数中选出最佳函数。如果交换两个 namespace 级的函数定义在源代码中的位置,有可能改变程序的行为。如果在重构代码的时候把 void bar() 的定义挪到 void foo(char) 之后,程序的输出就不一样了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19void foo(int)
{
printf("foo(int);\n");
}
void bar()
{
foo('a'); // 调用 foo(int)
}
void foo(char)
{
printf("foo(char);\n");
}
int main()
{
bar();
}
前向声明
使用前向声明可以减少编译期依赖。
如果代码里调用了 foo(),C++ 编译期 parse 此处函数调用时,需要生成函数调用的目标代码。为了完成语法检查并生成调用函数的目标代码,编译期需要知道函数的参数个数和类型以及函数的返回值类型,它并不需要知道函数体的实现(除非要做 inline 展开)。因此通常把函数原型放到头文件里。
1 | // in foo.h |
编译上述代码不会出错,编译器会认为 foo 有两个重载。但是链接整个程序会报错:找不到 void foo(int) 的定义。这是 C++ 的一种典型缺陷,即一样东西区分声明和定义,代码放到不同的文件中,这就有可能出现不一致。
编译器通常能查出参数列表不同,但不一定能查出返回类型不同,也可能参数类型相同,但是顺序调换了。
1 | draw(int height, int width),定义的时候写成 draw(int width, int height) |
其他语言似乎没有这个问题。Java 不需要使用函数原型,一个成员函数的参数列表只需要在代码里出现一次。Java 编译器也不受单遍编译的约束,调整成员函数的顺序不会影响代码语义。Java 没有头文件包含机制,而是基于 package 的模块化机制。
class 的前向声明
对于 class Foo,以下几种使用不需要看见其完整定义:
- 定义或声明 Foo* 和 Foo&,包括用于函数参数、返回类型、局部变量、类成员变量等等。这是因为 C++ 的内存模型是 flat 的,Foo 的定义无法改变 Foo 的指针或引用的含义。
- 声明一个以 Foo 为参数或返回类型的函数,Foo bar() 或 void bar(Foo f),代码里调用这个函数就需要知道 Foo 的定义,因为编译器需要使用 Foo 的拷贝构造函数和析构函数,因此至少要看到它们的声明(虽然构造函数没有参数,但是有可能位于 private 区)。
不能重载 &&、||、,(逗号)、一元 operator& (取地址操作符),重载 operator&,这个 class 就不能用前向声明了。
1 | class Foo; // 前向声明 |
C++ 比 C 链接模型增加了两项内容:
- 函数重载,需要类型安全的链接,即 name mangling
- vague linkage,即同一个符号有多份互不冲突的定义。
C 语言一个符号在程序中只能有一处定义,否则就会重复定义。C++ 编译器在处理单个源文件的时候并不知道某些符号是否应该在本编译单元定义。只能在每个目标文件生成一份弱定义,而依赖链接器去选择一份作为最终的定义,这就是 vague linkage。
C++链接
函数重载
C++ 编译器普遍采用名字改编 (name mangling),为每个重载函数生成独一无二的名字,这样在链接的时候就能找到正确的重载版本。
1 | // foo.cc |
普通 non-template 函数的 mangled name 不包含返回类型。返回类型不参与函数重载。如果一个源文件用到了重载函数,但它看到的函数原型声明的返回类型是错的(违反了 ODR),链接器无法捕捉这样的错误。
1 | // main.cc |
inline函数
如果编译器无法 inline 展开的话,每个编译单元都会生成 inline 函数的目标代码,然后链接器会从多份实现中任选一份保留,其余的则丢弃(vague linkage)。如果编译器能够展开 inline 函数,那就不必单独为之生成目标代码了(除非使用函数指针指向它)。
如何判断一个 C++ 可执行文件是 debug build 还是 release build?即如何判断一个可执行文件是 -O0 编译还是 -O2 编译?一个方法就是看 class template 的短成员函数有没有被 inline 展开。
1 | // vec.cc |
编译器自动生成 class 析构函数也是 inline 函数,有时候要故意 out-line,防止代码膨胀或出现编译错误。
1 | // printer.h |
1 |
|
模板
C++ 模板包括函数模板和类模板,与链接相关的话题包括:
- 函数定义,包括具现化后的函数模板、类模板的成员函数、类模板的静态成员函数等。
- 变量定义,包括函数模板的静态数据变量、类模板的静态数据成员、类模板的全局对象等。
模板编译链接的不同之处在于,以上具有 external linkage 的对象通常会在多个编译单元被定义。链接器必须进行重复代码消除,才能正确生成可执行文件。
template 和 inline 函数会不会导致代码膨胀?
1 | template<int Size> |
编译器会为每一个用到的类模板成员函数具现化一份实体。
1 | ubuntu@VM-0-9-ubuntu:~$ g++ buffer.cc |
如果想限制模板的具现化,比如限制 Buffer 只能有64、256、1024、4096这几个长度,除了可以用 static_assert 来制造编译期错误,还可以用只声明、不定义的办法来制造链接错误。C++ 教材中指出模板的定义要放到头文件中,否则会有编译错误,其实是链接错误。
1 | template<typename T> |
其实是可以在头文件里只放声明的,前提是你要知道模板会有哪些具现化类型,并事先显示(或隐式)具现化出来。
1 | template<typename T> |
对于 private 成员函数模板,也不用在头文件中给出定义,因为用户代码不能调用它,也就无法随意具现化它,所以不会造成链接错误。
1 | class PrintRequest |
PrintRequest 和 ScanRequest 有一些共同的成员,但是没有共同的基类。写一个 Printer class,能同时处理这两种请求,为了避免代码重复,用一个函数模板来解析 request 的公共部分。
1 | class PrintRequest; |
decodeRequest 是模板,但不必把实现暴露在头文件中,因为只有 onRequest 会调用它。可以把这个成员函数模板的实现放到源文件中。这样的好处之一是 Printer 的用户看不到 decodeRequest 函数模板的定义,可以加快编译速度。
1 |
|
C++11 新增了 extern template 特性,可以阻止隐式模板具现化。g++ 很早就支持这个特性,g++ 的 C++ 标准库就使用了这个办法,使得使用 std::string 和 std::iostream 的代码不受代码膨胀之苦。
1 |
|
虚函数
虚函数的动态调用(动态绑定、运行期决议)是通过虚函数表 (vtable) 进行的,每个多态 class 都应该有一份 vtable。定义或继承了虚函数的对象中会有一个指向 vtable 的指针,即 vptr。在构造和析构对象的时候,编译器生成的代码会修改这个 vptr 成员。有时看到的链接错误不是找不到某个虚函数的定义,而是找不到虚函数表的定义。
1 | class Base |
头文件使用原则
为了使用某个 struct 或者某个库函数而包含了一个头文件,那么这个头文件中定义的其他名字 (struct、函数、宏) 也被引入当前编译单元。
头文件的害处
- 传递性。头文件可以再包含其他头文件。一方面造成编译缓慢;另一方面,任何一个头文件改动一点点代码都会需要重新编译所有直接或间接包含它的源文件。
- 顺序性。通常的做法是把头文件分为几类,然后分别按顺序包含这几类头文件,相同类的头文件按文件名的字母排序。一般避免每次在 #include 列表的末尾添加新的头文件。
- 差异性。内容差异造成不同源文件看到的头文件不一致,时间差异造成头文件与库文件内容不一致。如果两个源文件编译时的宏定义选项不一致,可能造成二进制代码不兼容,整个程序应该用统一的编译选项。
现代编程语言使用模块化的做法:
- 对于解释性语言,import 的时候直接对对应模块的源文件解析 (parse) 一遍(不再是简单地把源文件包含进来)。
- 对于编译型语言,编译出来的目标文件(如 Java .class 文件)里直接包含了足够的元数据,import 的时候只需要读目标文件的内容,不需要读源文件。
头文件的使用规则
- 将文件间的编译依赖降至最小。
- 将定义式之间的依赖关系降至最小。避免循环依赖。
- 让 class 名字、头文件名字、源文件名字直接相关。这样方便源代码的定位。
- 令头文件自给自足。为了验证 TcpServer.h 的自足性(self-contained),TcpServer.cc 第一个包含的头文件是它。
- 总是在头文件内写内部 #include guard(护套),不要在源文件写外部护套。这是因为现在的预处理对这种通用做法有特别的优化,GNU cpp 在第二次 #include 同一个文件时甚至不会去读这个文件,而是直接跳过。
- #include guard 用的宏的名字应该包含文件的路径全名(从版本管理器的角度),必要的话还要加上项目名称(如果每个项目有自己的代码仓库)。
- 如果编写程序库,公开的头文件应该表达模块的接口,必要的时候可以把实现细节放到内部头文件中。
一个查找头文件包含的小技巧,比如有一个程序只包含了,但是却能使用 std::string,可以在当前目录创建一个 string 文件,然后制造编译错误。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int main()
{
std::string s = "muduo";
}
ubuntu@VM-0-9-ubuntu:~$ cat > string # 创建一个只有一行内容的 string 文件
^C
ubuntu@VM-0-9-ubuntu:~$ g++ -M -I . hello.cc # 用 g++ 查出包含路径,原来是 locale_classes.h
In file included from /usr/include/c++/7/bits/locale_classes.h:40:0,
from /usr/include/c++/7/bits/ios_base.h:41,
from /usr/include/c++/7/ios:42,
from /usr/include/c++/7/ostream:38,
from /usr/include/c++/7/iostream:39,
from hello.cc:1:
./string:1:2: error:
^~~~~
In file included from /usr/include/c++/7/stdexcept:39:0,
from /usr/include/c++/7/system_error:41,
from /usr/include/c++/7/bits/ios_base.h:46,
from /usr/include/c++/7/ios:42,
from /usr/include/c++/7/ostream:38,
from /usr/include/c++/7/iostream:39,
from hello.cc:1:
./string:1:2: error:
^~~~~
工程项目中库文件的组织原则
改动程序本身或它依赖的库之后应该重新测试,否则测试通过的版本和实际运行的版本根本就是两个东西。对于脚本语言,除了库之外,解释器的版本(Python2.5/2.6/2.7)也会影响程序的行为,因此有 Python virtualenv 和 Ruby rbenv 这样的工具,允许一台机器同时安装多个解释器版本。
还有一种依赖是外部进程的依赖,例如 app 程序依赖某些数据源(运行在别的机器上的进程),会在运行的时候通过某种网络协议从这些数据源定期或不定期读取数据。数据源可能会升级,其行为也可能变化。
另外一个需要考虑的是 C++ 标准库(libstdc++) 的版本与 C 标准库(glibc)的版本。C++ 标准库的版本跟 C++ 编译器直接关联,C 标准库的版本跟 Linux 操作系统的版本直接相关。Linux 的共享库(shared library)比 Windows 的动态链接库在 C++ 编程方面要好用得多,对应用程序来说基本可算是透明的,和使用静态库无区别。主要体现在:
- 一致的内存管理。Linux 动态库与应用程序共享一个 heap,因此动态库分配的内存可以交给应用程序去释放,反之亦然。
- 一致的初始化。动态库里的静态对象(全局对象、namespace 级的对象等等)的初始化和程序其他地方的静态对象一样,不用特别区分对象的位置。
- 在动态库的接口中可以放心地使用 class、STL、boost(如果版本相同)。
- 没有 dllimport/dllexport 的累赘。直接 include 头文件就能使用。
- DLL HELL 的问题也小得多,因为 Linux 允许多个版本的动态库并存,而且每个符号可以有多个版本。
DLL HELL 指的是安装新的软件的时候更新了某个公用的 DLL,破坏了其他已有软件的功能。
一个 C++ 库的发布方式有三种:动态库(.so)、静态库(.a)、源码库(.cc)。
动态库 | 静态库 | 源码库 | |
---|---|---|---|
库的发布方式 | 头文件 + .so 文件 | 头文件 + .a 文件 | 头文件 + .cc 文件 |
程序编译时间 | 短 | 短 | 长 |
查询依赖 | ldd 查询 | 编译期信息 | 编译期信息 |
部署 | 可执行文件 + 动态库 | 单一可执行文件 | 单一可执行文件 |
主要时间差 | 编译时 <==> 运行时 | 编译库 <==> 编译应用程序 | 无 |
这里动态库只包括编译时就链接动态库的那种常规用法,不包括运行期动态加载(dlopen())的用法。
如果要在多台 Linux 机器上运行程序,先要把程序部署到那些机器上。如果程序只依赖操作系统本身提供的库(包括可以通过 package 管理软件安装的第三方库),那么只要把可执行文件拷贝到目标机器上就能运行。这是静态库和源码库在分布式环境下突出优点之一。
传统的观点,动态库比静态库节省磁盘空间和内存空间,并且具备动态更新的能力(hot fix bug)。
动态库的不足
在发布动态库的 bug fix 之前无法做到充分测试所有受影响的应用程序。动态库的使用面宅,只有两三个程序用到它,测试的成本较低,那么它作为动态库的优势就不明显。一个动态库的使用面宽,有几十个程序用到它,动态库的优势明显,测试和更新的成本也相应很高。有一种做法是把动态库的更新先发布到 QA 环境,正常运行一段时间之后再发布到生产环境。这么做也有另外的问题:在测试下一版 app1.1 的时候,该用 QA 环境的动态库版本还是用生产环境的动态库版本?
静态库的不足
静态库相比动态库的主要有几点好处:
- 依赖管理在编译期决定,不用担心日后它用的库会变。调试 core dump 不会遇到库更新导致 debug 符号失效的情况。
- 运行速度可能更快,因为没有 PLT(过程查找表),函数的调用的开销更小。
- 发布方便,只要把单个可执行文件拷贝到模板机器上。
静态库的作者把源文件编译成 .a 库文件,连同头文件一起打包发布。应用程序的作者用库的头文件编译自己的代码,并链接到 .a 库文件,得到可执行文件。这里有一个编译的时间差:编译库文件比编译可执行文件要早,这就可能造成编译应用程序时看到的头文件与编译静态库时不一样。应用程序在使用静态库的时候必须要采用完全相同的开发环境(更底层的库、编译器版本、编译器选项)。
静态库把库之间的版本依赖完全放到编译期,可能会遇到的情况:
- 迫使升级高版本。
- 重复链接。
- 版本冲突。
源码编译
每个应用程序自己选择要用到的库,并自行编译为单个可执行文件。彻底避免头文件与库文件之间的时间差,确保整个项目的源文件采用相同的编译选项,也不用为库的版本搭配操心。但是编译时间会长。
最好能和源码版本工具配合,让应用程序只需指定用哪个库,build 工具能自动帮我们 check out 库的源码。这样库的作者只需要维护少数几个 branch,发布库的时候不需要把头文件和库文件打包供人下载,只要 push 到特定的 branch 即可。这个 build 工具最好还能解析库的 Makefile(或等价的 build script),自动帮我们解决库的传递性依赖,就像 Apache Ivy。接近这一点的有 Chromjum 的 gyp 和腾讯的 typhoon-blade,其他 Scons、CMake、Premake、Waf 等等工具仍然是以库的思路来搭建项目。
- 本文标题:C/C++编译模型
- 本文作者:beyondhxl
- 本文链接:https://www.beyondhxl.com/post/2829de4.html
- 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!