C/C++编译与链接
《程序员的自我修养:链接、装载与库》阅读笔记
编译
编译可以被分为四个步骤:
- 预编译(Prepressing)
- 编译(Compilation)
- 汇编(Assembly)
- 链接(Linking)
预编译
gcc -E hello.c -o hello.i |
cpp hello.c > hello.i |
- 移除并展开所有
#define
- 处理所有条件预编译指令,如
#ifdef
- 递归处理
#include
指令,将被包含的文件拷贝到该指令位置 - 删除所有的注释
- 添加行号和文件名标识,便于产生调试和报错的行号信息
- 保留
#pragma
指令
编译
gcc -S hello.i -o hello.s |
- 词法分析:扫描器使用有限状态机将源代码的字符切分为一系列记号(Token)
- 语法分析:分析记号,得到语法树,树的节点是表达式
- 语义分析:分析所有的静态语义,为表达式标注类型
- 中间语言生成:优化一些代码,比如2+6优化成8
- 三地址码与P代码
- 目标代码的生成与优化
GCC可以将预编译和编译合成一个步骤
gcc -S hello.c -o hello.s |
cc1 hello.c |
汇编
gcc -c hello.s -o hello.o |
as hello.s -o hello.o |
链接
ld -static xxxx hello.o -xxxx |
链接的本质是替换指令中(对函数和变量)的地址引用
- 地址和空间分配
- 符号决议
- 重定位
main.c文件调用了func.c文件中的函数foo()
,所以需要知道foo()
的确切地址。但C++不同模块是单独编译的,因此编译main.c时不知道foo()
的地址
于是编译器先将foo()
的地址搁置,在链接的时候再逐个修正,填入foo()
真实的地址
这个地址修复的过程也叫做重定位(Relocation),每一个需要修正的地方叫重定位入口(Relocation Entry)
目标文件
编译器编译后生成的文件是目标文件(.obj
和.o
),结构跟可执行文件相同,只是还未链接
可执行文件、目标文件、动态库、静态库均采用相同的存储格式,在Windows下使用PE-COFF格式存储,在Linux下使用ELF格式存储
- Windows:PE-COFF(Probable Executable-Common File Format)
- Linux:ELF(Executable Linkable Format)
目标文件格式
由四个部分组成:
- 文件头(File Header):文件可否执行、是静态链接还是动态链接、链接入口、目标硬件、目标操作系统、段表(Section Table)
- 段表描述了文件中各个段的偏移位置和属性,用于找到代码段、数据段
- 代码段(.text section):编译得到的执行语句
- 数据段(.data section):已初始化的静态/全局变量
- .bss section:为未初始化的静态/全局变量预留位置,没有内容
- .rodata section:只读数据,比如用const修饰的变量、字符串常量
int global_inited_var = 84; |
objdump -h hello.o
查看目标文件的结构
hello.o: file format elf64-x86-64 |
objdump -s -d hello.o
将段内容以十六进制的形式打印出来,并将指令段反汇编
hello.o: file format elf64-x86-64 |
.data中54000000
转化到十进制是84,55000000
是85
大小端
注意十六进制的读法,0x54是最低位,后面三个0x00是更高的位,这种低位在前高位在后的字节序是小端序,有点反人类
为什么要区分代码块和数据块呢?
- 程序被装载后,数据区域是可读写的,而代码区域是只读的,将段进行分离,可以防止潜在的错误修改
- 数据集中存储,可以利用局部性原理提高缓存利用率
- 当系统同时运行多个同一文件时,可以共享只读的指令和数据,能大幅节省空间
readelf -h hello.o
查看elf文件头的详细信息
ELF Header: |
魔术(Magic)用于区分ELF文件类型(与Windows里用后缀名区分不同)
符号
符号表
如果目标文件B用到了目标文件A的函数foo()
,我们称目标文件A定义(Define)了函数foo()
,目标文件B引用(Reference)了函数foo()
我们称函数、变量为符号(Symbol),他们的名字被称为符号名(Symbol Name),符号名独一无二
编译过程中每个目标文件都有一个符号表,每个符号能找到一个对应的符号值,对于变量和函数,符号值就是他们的地址
符号值的类型有:
- 定义在本文件内的全局符号,比如
global_inited_var
,func
- 定义在其他文件内,但是被本文件引用的全局符号,叫外部符号(External Symbol),比如
printf
- 段名,是段的起始地址
- 局部符号,比如
static_inited_var
- 行号
readelf -s hello.o |
Name Mangling
为了防止符号名冲突,C语言会加上命名空间等方法修饰符号,C++会做符号改编(Name Mangling)
函数签名:由函数的名称、参数数量与类型、所在的类、所在的命名空间等组成
在C++进行编译链接时,会使用函数签名生成一个修饰后名称,使用这个修饰后名称作为符号名
C++允许使用函数重载,但是重载函数的参数不能完全相同
C++允许局部变量和全局变量重名,因为他们修饰后是两个不同的符号
C++的Name Mangling规则取决于编译器版本,没有统一和公开
extern “C”
C++的符号十分复杂,C的符号兼容性会更好,于是我们可以使用extern "C"
声明一个C符号,里面的符号不会被Name Mangling
|
强弱符号
强符号:C++默认函数和初始化的全局变量为强符号,如果多个目标文件拥有相同名字的强符号,链接时会报符号重定义的错误
弱符号:C++未初始化的全局变量是弱符号
extern int ext; // 不是强、弱符号,因为是外部文件定义的变量 |
规则:
- 不允许强符号重定义
- 弱符号会被强符号覆盖
- 若一个符号在所有文件中都是弱符号,则选择体积最大的符号去链接
强弱引用
强引用:在链接时,如果没有找到强引用的符号,会报符号未定义的错误
弱引用:在链接时,若符号未定义,会给一个默认值0
弱符号、弱引用对于库来说十分有用,可以被用户自己定义的强符号所覆盖,也可以使得程序功能被剪裁组合
静态链接
链接会将多个目标文件加工成一个可执行文件
段合并
相似段合并:将所有具有相同性质的段合并在一起,比如多个文件的.data段合在一个大的.data段中
两步链接
- 空间和地址分配
- 符号解析和重定位
C++链接步骤
- 重复代码消除,一个模板可能在多个编译单元中被实例化,且实例化成相同的代码,将这些重复代码消除可以提高缓存利用率和节约空间
- 函数级别链接(选择性开启):每一个函数都单独存储在一个段中,链接时按需添加到目标文件,可以减少包体,但是会降低编译链接速度
程序入口
Linux下程序的入口是_start
,这是Glibc库的一部分,会进行程序的初始化,比如全局函数的创建,然后再去main
函数执行
ELF有.init和.fini两个段,在这两个段中的代码会在mian
前后执行
ABI
两个代码想要链接,需要使用相同的目标文件格式。我们将符号修饰、变量内存布局、函数调用方法等和二进制可执行文件兼容性相关的内容称为ABI(Application Binary Interface)
对于C语言,通过下列内容判断二进制兼容性:
- 内置类型(int、char)的大小、字节序、对齐方式
- 组合类型(struct、array、union)的存储方式和内存分布
- 外部符号的解析方式,比如外部的
func
被解析为_func
- 函数调用方式,比如参数入栈顺序,返回值如何保持
- 堆栈分布方式
- 寄存器使用约定
对于C++还额外有:
- 继承相关的内存分布
- 指向成员函数的指针的内存分布,如何通过成员函数指针调用成员函数,如何传递this指针
- 如何调用虚函数,虚表的内容及分布
- 模板如何实例化
- Name Mangling
- 全局对象的构造与析构
- 异常
- RTTI
- inline
C++的ABI不稳定,于是DLL建议使用C风格
装载
可执行文件要装载到内存中才能被CPU执行
动态装入:许多情况下程序所需要的内存远超物理内存,为此我们会将一部分数据存储在磁盘里,内存中只保留最常用的部分。目前最常用的方法是页映射
进程的创建
- 创建一个独立的虚拟内存空间
- 分配页目录
- 虚拟内存区域(VMA)
- 段(Segment)
- 读取可执行文件的头,建立虚拟空间和可执行文件的映射关系
- 当程序发生缺页时,需要知道当前所需的页在可执行文件的哪一位置,这个位置信息存储在映射关系中
- 这是装载最核心的步骤,于是很多可执行文件也叫映像文件(Image)
- CPU指令寄存器设置为可执行文件的入口,程序运行
页错误
当CPU开始执行一段指令时,发现所在地址的页面是一个空页面,这就是页错误
- 此时操作系统会去读映射关系,找到当前页所在的VMA,计算出该页在可执行文件的位置
- 去物理内存中读这个页,并建立虚拟页和物理页的映射关系
- 回到先前的地址,继续执行指令
VMA
VMA除了可以映射段,还会映射堆(Stack)和栈(Heap)
VMA类型 | 权限 | 能否执行 |
---|---|---|
代码VMA | 只读 | 可执行 |
数据VMA | 读写 | 可执行 |
堆VMA | 读写 | 可执行 |
栈VMA | 读写 | 不可执行 |
段地址对齐
x86处理器默认页的大小为4096字节,于是物理地址和虚拟地址进行映射时,虚拟内存空间的大小应该为4096字节的整数倍
动态链接
静态链接浪费内存空间、磁盘空间、难以更新
动态链接将链接推迟到了运行(装载),能够实现库的复用,减少包体、提高缓存命中率
动态链接升级模块时,理论上只需要重新编译、替换动态库的模块,可以实现插件系统
Linux下动态库是动态共享对象(DSO),以.so
结尾
Windows下动态库是动态链接库(DLL),以.dll
结尾