C和Cpp编译链接过程
以下介绍中有2个约定:
- 下面介绍中使用的编译器为
gcc/g++
,所有执行命令中gcc可以与g++互换 - gcc/g++编译命令允许跳过中间文件的生成,而直接得到所需的输出文件。但为了逻辑连贯,默认每一阶段将以上一阶段的输出文件作为源文件,后续不在赘述
整体介绍
C和Cpp代码编译链接的整体过程如下:
狭义上来说,编译
就是指上图中从预处理文件到汇编语言文件的这个阶段,但是广义上来说,编译是链接之前的所有阶段。每个人的叫法并不相同,需要具体看待。不过下面的分析中,为了更详细的介绍,所以遵循狭义上的概念
预编译
作用
预编译基本上完成了对源程序的“替代”工作。经过预处理后的代码与源代码相同,且依然可以被我们读懂,只是内容上有些修修剪剪
- 删除注释
- 展开头文件,即在
#include
位置上将对应的头文件代码展开 - 宏替换,即将
#define
宏对应的代码直接替换 - 条件编译,即对
#ifndef
、#define
、#endif
、#if
、#else
等条件进行判断检查。我们经常看到头文件会用#ifndef
这一系列包围起来以防止重复引用,就是在这一步生效 - 特殊符号处理,例如在源程序中出现的
__LINE__
标识将被解释为当前行号(十进制)
示例
为了简单的测试,写3个文件。其中,extern "C"
的介绍详见后续符号表部分,当前不用在意
- 头文件
test.h
#ifdef __cplusplus
extern "C" {
#endif
int Multiply(int, int);
#ifdef __cplusplus
}
#endif
int Minus(int, int);
- 源文件
test.cpp
#include "test.h"
int Minus(int a, int b)
{
return (a - b);
}
int Multiply(int a, int b)
{
return (a * b);
}
- main函数所在文件
main.cpp
#include "test.h"
#define ADD(a, b) (b > 0 ? a : 0)
int main()
{
// This is a test
int b;
#if 0
b = 2;
#else
b = 4;
#endif
int c = ADD(__LINE__, b);
int d = Minus(__LINE__, b);
int f = Multiply(__LINE__, b);
// return
return 0;
}
- 执行
gcc -E main.cpp -o main.i
或者cpp main.cpp > main.i
都可以生成预编译文件。得到的内容如下:
# 1 "main.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "main.cpp"
# 1 "test.h" 1
extern "C" {
int Multiply(int, int);
}
int Minus(int, int);
# 2 "main.cpp" 2
int main()
{
int b;
b = 4;
int c = (b > 0 ? 14 : 0);
int d = Minus(15, b);
int f = Multiply(16, b);
return 0;
}
编译
编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码(当前一般为汇编代码)。这一部分的内容较深了,不做详细介绍。
稍微引申一点,词法分析和语法分析也不只有编译器才会关注。事实上,对于涉及到语言转换、优化一类的都会使用,例如数据库中的sql解析就有用到
照样以预编译处所说的代码为例,可以执行gcc -S main.i -o main.s
将预编译的代码生成为汇编代码。此时的汇编代码想要查看需要通过其它软件,这里也不再详细介绍了
汇编
汇编过程实际上指把汇编语言代码翻译成机器指令的过程。可以执行gcc -c main.s -o main.o
实现这一过程,.o
文件也称为obj
文件、object
文件或者目标文件。目标文件大体分为数据段、代码段、符号表三个部分,这也是经常能看到的一些词眼,这里借用彻底理解链接器:二,符号决议中的一幅图来表示:
符号表
此处并不对数据段和代码段多做展开,详细可以参考刚刚的链接中的这篇文章,写的挺不错。另外符号表的查看可以采用nm
命令,具体可以参见Linux nm 命令使用及符号含义这篇文章。此处执行nm main.o
结果如下
0000000000000000 T main
U Multiply
U _Z5Minusii
换成nm -C main.o
命令的结果如下
0000000000000000 T main
U Multiply
U Minus(int, int)
- 可以看到C与C++符号表的不同之处,即C++的函数符号会进行编码,最后出来的无法直观阅读理解,而C的函数符号就是函数名。这也是C和C++代码在相互调用时需要对函数声明加上
extern "C"
的作用 - 此时
Multiply
和Minus
两个函数都定义在test.cpp
文件中,所以从main.o
看来,其符号表的这个两个函数就处于未定义状态
链接
由汇编生成的文件并不能马上执行,其中还有一些没有解决的问题。例如,某个源文件中的函数可能引用了另一个源文件(其中的变量、函数等);在程序中可能调用了某个库文件中的函数。所有的这些问题,都需要经链接程序的处理才能解决。链接器的详细介绍推荐参看英文版的维基百科),中文版写的并不好,而且有疏漏。
链接程序的作用就是将有关的文件彼此相连接,使其成为一个整体,这个整体分为三种:
- 可执行文件,即可以被操作系统运行的程序
- 库文件。当前有两种,静态库文件和动态库文件
- 目标文件,即生成的仍然是一个
.o
文件
链接方式
根据链接方式的不同,链接处理可分为两种:静态链接和动态链接。下面是这两种方式的简单介绍,更详细的可以参考资料彻底理解链接器:三,库与可执行文件,这篇文章写的确实挺好,我自问弗如
静态链接
在这种方式下,代码将从所在的静态链接库被拷贝到可执行程序中。简而言之,最终生成的可执行文件包含了所有的静态库代码,以后运行就跟它们没关系了
动态链接
在这种方式下,代码仍然是放在动态链接库中的,此时链接程序只是记录下了这些库文件的少量信息。在可执行文件运行的时候,还需要跑到这些库文件中找到对应的代码才行。简而言之,可执行文件和这些动态库绑在一块儿了,以后运行离了它们不行
示例
仍然以之前的程序为例,执行gcc test.o main.o -o main
得到可执行文件main
。对链接程序生成的上述3种结果,可以通过ldd
查看对应的链接库调用关系,执行ldd main
得到结果如下:
linux-vdso.so.1 (0x00007ffd245fc000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fb759af2000)
libm.so.6 => /lib64/libm.so.6 (0x00007fb759770000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fb759558000)
libc.so.6 => /lib64/libc.so.6 (0x00007fb759196000)
/lib64/ld-linux-x86-64.so.2 (0x00007fb759e87000)
此时可执行文件main
相对简单,所以依赖的全都是系统库文件。修改编译命令,将test.cpp
生成一个动态库文件,如下:
gcc -shared test.cpp -o libtest.so
gcc main.cpp -o main -L. -ltest
先修改配置下库文件搜索路径,执行export LD_LIBRARY_PATH=$(pwd):$LD_LIBRARY_PATH
,然后再次查看链接关系如下:
linux-vdso.so.1 (0x00007fff3a7a0000)
libtest.so => /home/vscode/RemoteWorking/test/libtest.so (0x00007fe158806000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fe158471000)
libm.so.6 => /lib64/libm.so.6 (0x00007fe1580ef000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fe157ed7000)
libc.so.6 => /lib64/libc.so.6 (0x00007fe157b15000)
/lib64/ld-linux-x86-64.so.2 (0x00007fe158a08000)