预处理
主要是做一些代码文本的替换工作。(该替换是一个递归逐层展开的过程。)
- 将所有的#define删除,并展开所有的宏定义
- 处理所有的条件预编译指令,如:#if #ifdef #elif #else #endif
- 处理#include预编译指令,将被包含的文件插进到该指令的位置,这个过程是递归的
- 删除所有的注释//与/* */
- 添加行号与文件名标识,以便产生调试用的行号信息以及编译错误或警告时能够显示行号
- 保留所有的#pragma编译器指令,因为编译器需要使用它们
编译
把预处理完的文件进行一系列词法分析(lex)、语法分析(yacc)、语义分析及优化后生成汇编代码,这个过程是程序构建的核心部分。
汇编
汇编代码->机器指令
链接
这里讲的链接,严格说应该叫静态链接。多个目标文件、库->最终的可执行文件(拼合的过程)。 可执行文件分类:
- linux的ELF文件 -- bin、a、so
- windows的PE文件 -- exe、lib、dll
- mac os x的ELF文件 -- bin、a、dylib
PE文件与ELF文件都是COFF文件的变种
静态库本质上就是包含一堆中间目标文件的压缩包,就像zip等文件一样,里面的各个中间文件包含的外部符号地址是没有被链接器修正的。
符号(Symbol) -- 链接的接口
每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。 在链接中,将函数和变量统称为符号,函数名或变量名就是符号名,函数或变量的地址就是符号值。 每一个目标文件都有一个符号表,符号有以下几种:
- 定义在本目标文件的全局符号,可被其他目标文件引用 如:全局变量,全局函数
- 在本目标文件中引用的全局符号,却没有定义在本目标文件 -- 外部符号(External Symbol) 如:extern变量,printf等库函数,其他目标文件中定义的函数
- 段名,这种符号由编译器产生,其值为该段的起始地址 如:目标文件的.text、.data等
- 局部符号,内部可见 如:static变量 链接过程中,比较关心的是上面的第一类与第二类。
符号修饰(Name Decoration)
符号修饰实际就是对变量或函数进行重命名的过程,影响命名的因素有:
- 语言的不同,修饰规则有差别 如:foo函数,在C语言中会被修饰成_foo,在Fortran语言中会被修饰成_foo_
- 面向对象语言(如:C++)引入的特性 如:类、继承、虚机制、重载、命名空间(namespace)等
函数签名(Function Signature)
函数签名用于识别不同的函数,包括函数名、它的参数类型及个数、所在的类和命名空间、调用约定类型及其他信息.
弱引用与强引用
对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,须被正确决议,如果没有找到该符号的定义,编译器就会报符号为定义的错误,这种被称为强引用; 与之对应还有一种弱引用,在处理弱引用时,即使该符号未被定义,链接器也不会报错,默认其为0或一个特殊的值。 GCC可以通过"attribute((weakref))"来声明一个外部函数的引用为弱引用。 这种弱符号和弱引用对于库来说十分有用,库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数; 或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用; 如果去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。
总结
对于链接器来说,整个链接过程,就是将多个输入目标文件合成一个可执行二进制文件。 现代链接器,基本都是采用两步链接的方法:
空间与地址分配 扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表中。 这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度和位置,并建立映射关系。
符号解析与重定位 使用上面第一步中收集的所有信息,读取输入文件中段的数据、重定位信息(有一个重定位表Relocation Table),并且进行符号解析与重定位、调整代码中的地址(外部符号)等。
常见编译器
gcc / g++
GNU开发的一种程序语言编译器。 它是根据GNU通用公共许可证(GPL)和GNU较小通用公共许可证(LGPL)发布的一组免费软件。 GNU和Linux系统的官方编译器,也是用于编译和创建其他UNIX操作系统的主要编译器。
gcc编译流程
hello.c --> hello.i --> hello.s --> hello.o --> hello.out
预处理, C 编译器对各种预处理命令进行处理,包括头文件包含、宏定义的扩展、条件编译的选择等;
编译,将预处理得到的源代码文件,进行"翻译转换",产生出机器语言的目标程序,得到机器语言的汇编文件;
汇编,将汇编代码翻译成了机器码,但是还不可以运行;
链接,处理可重定位文件,把各种符号引用和符号定义转换成为可执行文件中的合适信息,通常是虚拟地址。
gcc 命令
1. 预处理
gcc -E hello.c –o hello.i对文件在中的头文件, 宏进行展开
2. 编译
gcc -S hello.i得到对应机器的汇编文件
3. 汇编
gcc -c hello.s汇编代码到机器码
4. 链接
gcc hello.o将各个机器码进行链接
动态链接和静态链接:
动态链接使用动态链接库进行链接,生成的程序在执行的时候需要加载所需的动态库才能运行。动态链接生成的程序小巧,但是必须依赖动态库,否则无法执行。
Linux 下的动态链接库实际是共享目标文件(shared object),一般是.so 文件,作用类似于 Windows 下的.dll 文件。
静态链接使用静态库进行链接,生成的程序包含程序运行所需要的全部库,可以直接运行,不过体积较大。
Linux 下静态库是汇编产生的.o 文件的集合,一般以.a 文件形式出现。
gcc 默认是动态链接,加上-static 参数则采用静态链接。
gcc hello.o -static -o hello_static-save-temps 保存所有编译过程中产生的文件 gcc/clang -save-temps *.c
clang / clang++
LLVM包含一系列模块化的编译器组件和工具链。 它可以在编译,运行时和空闲时间优化程序语言和链接,并生成代码。LLVM可以作为多种语言的编译器的背景。 Clang是一种C,C ++,Objective-C或Objective-C ++编译器,它基于LLVM用C ++编译,并根据Apache 2.0许可发行。Clang主要用于提供优于GCC的性能。
cl.exe
cl.exe是Microsoft C/C++编译器, 只能在支持Microsoft Visual Studio 的操作系统中运行
字节对齐
内存对齐:编译器将程序中的每个"数据单元"安排在字的整数倍的地址指向的内存之中
对齐定义
现代计算机中,内存空间按照字节划分,理论上可以从任何起始地址访问任意类型的变量。 但实际中在访问特定类型变量时经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序一个接一个地存放,这就是对齐。
对齐模数 内存对齐中指定的对齐数值K成为对齐模数(Alignment Modulus). 当一种类型S的对齐模数与另一种类型T的对齐模数的比值是大于1的整数,我们就称类型S的对齐要求比T强(严格),而称T比S弱(宽松).
内存对齐的原则:
结构体变量的首地址能够被其最宽基本类型成员大小与对齐基数中的较小者所整除; 结构体每个成员相对于结构体首地址的偏移量 (offset) 都是该成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在成员之间加上填充字节 (internal padding); 结构体的总大小为结构体最宽基本类型成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在最末一个成员之后加上填充字节 (trailing padding)。
Why ?
不同硬件平台对存储空间的处理上存在很大的不同。 某些平台对特定类型的数据只能从特定地址开始存取,而不允许其在内存中任意存放。 例如Motorola 68000 处理器不允许16位的字存放在奇地址,否则会触发异常,因此在这种架构下编程必须保证字节对齐。
但最常见的情况是,如果不按照平台要求对数据存放进行对齐,会带来存取效率上的损失。 比如32位的Intel处理器通过总线访问(包括读和写)内存数据。 每个总线周期从偶地址开始访问32位内存数据,内存数据以字节为单位存放。 如果一个32位的数据没有存放在4字节整除的内存地址处,那么处理器就需要2个总线周期对其进行访问,显然访问效率下降很多。
因此,通过合理的内存对齐可以提高访问效率。为使CPU能够对数据进行快速访问,数据的起始地址应具有"对齐"特性。 比如4字节数据的起始地址应位于4字节边界上,即起始地址能够被4整除。
此外,合理利用字节对齐还可以有效地节省存储空间。 但要注意,在32位机中使用1字节或2字节对齐,反而会降低变量访问速度。 因此需要考虑处理器类型。还应考虑编译器的类型。在VC/C++和GNU GCC中都是默认是4字节对齐。
综上, 进行内存对齐的原因:(主要是硬件设备方面的问题)
- 某些硬件设备只能存取对齐数据,存取非对齐的数据可能会引发异常;
- 某些硬件设备不能保证在存取非对齐数据的时候的操作是原子操作;
- 相比于存取对齐的数据,存取非对齐的数据需要花费更多的时间;
- 某些处理器虽然支持非对齐数据的访问,但会引发对齐陷阱(alignment trap);
- 某些硬件设备只支持简单数据指令非对齐存取,不支持复杂数据指令的非对齐存取。
内存对齐的优点:
- 便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;
- 提高内存的访问效率,因为 CPU 在读取内存时,是一块一块的读取。
数据类型对应字节数
32位编译器
char :1个字节 char*(即指针变量,只与地址寻址范围有关): 4个字节(32位的寻址空间是2^32, 即32个bit,也就是4个字节。同理64位编译器) short int : 2个字节 int: 4个字节 unsigned int : 4个字节 float: 4个字节 double: 8个字节 long: 4个字节 long long: 8个字节 unsigned long: 4个字节
64位编译器
char :1个字节 char*(即指针变量): 8个字节 short int : 2个字节 int: 4个字节 unsigned int : 4个字节 float: 4个字节 double: 8个字节 long: 8个字节 long long: 8个字节 unsigned long: 8个字节
补充
64位处理器不代表一次访存"只能"读取64位数据。因为它可以一次取出一大块数据拆开放进多个64位(或者更宽的扩展)寄存器里。以ARMv8 AARCH64指令集为例,有可以可以一次读取64bit pair或者128bit的访存指令(其实还有读128bit pair指令)。具体实现的时候,如果地址是16字节对齐的话,是可以一次从内存取出128bit数据的。类似地,x86的SSE扩展可以一次读出16字节一点都不奇怪,出于性能角度考虑要求此类指令对于16字节对齐一点也很正常。
看处理器微结构上古处理器l1d 结构比较简单,8B不对齐可能会有多次访问的问题后来复杂一些,基本上只要保证64B对齐就可以一次访问,既访问地址+访问长度不要超过一个cache line,这个原则一直维持到现在后来进一步增强,虽然64B不对齐,但是性能损失也可以接受,如果你的算法使用64B非对齐可以大幅改进性能,往往你就可以用了
实例:
/*
说明:程序是在 64 位编译器下测试的
*/
#include <iostream>
using namespace std;
struct A
{
short var; // 2 字节
int var1; // 8 字节 (内存对齐原则:填充 2 个字节) 2 (short) + 2 (填充) + 4 (int)= 8
long var2; // 12 字节 8 + 4 (long) = 12
char var3; // 16 字节 (内存对齐原则:填充 3 个字节)12 + 1 (char) + 3 (填充) = 16
string s; // 48 字节 16 + 32 (string) = 48
};
int main()
{
short var;
int var1;
long var2;
char var3;
string s;
A ex1;
cout << sizeof(var) << endl; // 2 short
cout << sizeof(var1) << endl; // 4 int
cout << sizeof(var2) << endl; // 8 long
cout << sizeof(var3) << endl; // 1 char
cout << sizeof(s) << endl; // 32 string
cout << sizeof(ex1) << endl; // 56 struct
return 0;
}