一.预编译阶段(预处理,gcc -E main.cpp main.i)
输入:.cpp源码;
输出:.i预处理后的文本文件,仍然是C/C++源码文本,不是机器码。
1.#define宏展开,删除#define定义
例子:#define PI 3.14,代码里所有PI全部替换成3.14;宏请按不原地展开,之后删掉#define这一行。
注意:只是纯文本替换,不做语法检查!写错宏,预处理阶段不会报错,等到后面编译阶段才报错。
2.处理条件预编译 #if / #ifdef / #ifndef / #endif
根据条件判断,直接删掉不满足条件的代码行。
例:#ifdef WIN32 如果不是Windows平台,这一段代码直接被删掉,不会参与后续编译。
3.处理#include头文件
把#include "xxx.h"对应的整个头文件内容,原封不动复制粘贴到当前代码这个位置。
头文件会递归处理:头文件里面还包含别的头文件,继续复制。
4.删除所有注释
//单行注释和/*块注释*/全部删掉,替换成空格。注释对程序没有任何作用,预处理直接清理掉。
5.添加行号,文件名标识
就是# 行号 “文件名”标记。后面编译报错,gdb调试的时候,才能告诉你main.cpp第25行出错。如果没有这个标记,编译器分不清代码来自哪个文件哪一行。
6.保留#pragma指令
#pragma是留给特定编译器的指令,不是标准C语法。预处理不会删掉,交给后面编译器处理。
例:#pragma once防止头文件重复包含。
预处理总结:纯文本操作,不做语法检查,只是增删替换。
二.编译阶段(gcc -S main.i -o main.s)
输入:预处理后的.i文件;输出:.s汇编代码文件(文本)
一共5步:词法分析->语法分析->语义分析->代码优化->汇总符号
1.词法分析
把连续代码切成一个个[单词token],把代码字符串切割:关键字if,变量名a,运算符+,数字10。
比如:int a = 1+2;拆成int a = 1 + 2 ;
识别非法字符,比如中文标点,这里就报错。
2.语法分析
按照C语法,生成语法树(抽象语法树AST),检查句子语法是否合法。
比如:int = 10; 变量名确实,语法错误,直接报编译错误。
3.语义分析
检查类型是否合法(重点!)
语法没问题,但类型不对在这里报错。
例如:int a = "abc"; 把字符串赋值给int,语法没问题,语义错误。
做类型检查,类型转换。
4.代码优化
对AST做优化,简化代码。
比如:int a = 1+2; 直接优化成a=3,不用运行时再计算。
5.汇总符号
收集所有符号,建立符号表。
符号:变量名,函数名。记录函数,变量在那定义,类型是什么。
编译阶段产出:汇编代码.s,是人类刻度的汇编指令文本。
三.汇编阶段(gcc -c main.s -o main.o)
输入:.s汇编代码;输出:目标文件.o(Windows叫.obj),二进制文件。
汇编器as,把汇编指令翻译成CPU能识别的二进制机器码。
1.将汇编指令翻译成二进制,划分各个section(段)
.test段:存放代码指令(函数二进制机器码);
.data段:已经初始化的全局变量;
.bss段:未初始化全局变量,不占用目标文件空间,只记录大小。
2.生成符号表
记录本.o里面的函数,变量符号。
***重点:此时只是单个目标文件,符号没有分配最终虚拟地址!!!
如果调用别的文件的函数,汇编阶段只标记[这个符号待解析],不知道它的真实地址。
举例:main.o调用func(),func在另一个test.o里面。汇编阶段不知道func在哪,先记下来,留给链接阶段处理。
四.链接阶段(link,gcc把多个.o合并成可执行文件)
输入:多个.o目标文件+静态库.a;输出:可执行文件a.out/main.exe
两大核心步骤:
1.合并段,合并符号表,符号解析,分配虚拟地址
(1)把所有.o里面同名section合并:所有.text合并成一个大代码段;所有.data合并成数据段。
(2)合并所有目标文件的符号表。
(3)符号解析:找到每一个符号唯一的定义。
比如:main.o引用func,链接器去其他.o里面找func的定义,找到func对应的符号。
(4)给每一个符号分配最终的虚拟地址。
2.符号重定位(最核心)
重定位:把代码里所有引用符号的地方,把原来临时占位的地址,替换成刚刚分配好的虚拟地址。
举个例子:
main函数调用func(),在main.o里面call指令的地址是占位符。
链接找到func的虚拟地址,修改call指令,填入func真正的虚拟地址。
如果链接找不到符号定义:报未定义引用undefined reference,这是链接期错误,不是编译错误!
链接的两种类型:
1.静态链接
多个目标文件.o打包归档在一起的文件,不是可执行程序。
链接阶段行为:链接器扫描你的.o目标文件,发现你调用了库里的函数,直接把被调用函数对应的那一段机器码,从静态库里面拷贝出来,合并到最终的可执行文件中。
特点:
(1)运行时不再依赖原静态库文件,库代码已经完整复制exe/a.out,拿到这个可执行文件就能直接跑,移植方便。
(2)可执行文件体积更大,因为包含了库的机器码。
(3)缺点:多个程序如果都用同一个静态库,每个程序都会单独复制一份库代码,磁盘上存在多份重复代码,浪费磁盘空间。
(4)库升级:如果静态库升级,必须重新编译链接整个项目,重新生成可执行程序,否则程序依旧使用旧版本库代码。
总结:静态链接在编译链接期,把所需要的库代码复制进可执行文件,运行不依赖库,程序体积大,多程序会重复保存库代码。
2.动态链接
动态库后缀:Linux .so,Windows .dll
链接阶段行为:
不会把库的机器码复制进可执行文件。链接器只做两件事:
1.检查你调用的函数在动态库里面是否存在,完成符号校验;
2.在可执行文件里,仅仅记录依赖哪个动态库,用到哪些函数,只留下一张“函数索引表”。
程序运行时行为:
操作系统加载可执行文件到内存之后,再根据记录的依赖信息,去磁盘找到对应的.so/.dll动态库,把动态库加载进内存,然后完成符号重定位,把函数调用绑定到动态库内函数的真实地址。
特点:
(1)磁盘上只保存一份动态库,多个应用程序可以共享内存里这一份动态库代码,节省磁盘和物理内存。
(2)可执行文件本身体积更小。
(3)库升级:替换新版本的.so/.dll文件即可,不需要重新编译链接你的程序,下次运行程序自动加载新版本库。
(4)缺点:运行环境必须存在对应的动态库文件,缺少库直接运行失败(经典报错:xxx.so not found),移植时需要连带部署动态库。
总结:动态链接编译时只记录库依赖,不复制代码;程序运行时操作系统才加载动态库,多程序共享同一份库,程序体积小,但运行环境必须有对应动态库。