c/c++程序从原代码到二进制的可执行文件,分为预处理--编译--汇编--链接四个阶段。
一、整体流程概览
cpp
//一步完成
gcc -o hello hello.c
//分步完成
gcc -E hello.c -o hello.i //预处理
gcc -s hello.i -o hello.s //编译
gcc -c hello.s -o hello.o //汇编
gcc hello.o -o hello //链接
二、分阶段详解
阶段 1:预处理
-
输入 :
.c源代码文件 -
输出 :
.i预处理后的 C 文件 -
核心操作:
- 展开所有
#define宏定义,执行宏替换 - 递归展开所有
#include头文件(将头文件内容直接插入到当前文件) - 处理所有条件编译指令(
#if/#ifdef/#endif等) - 删除所有注释(单行
//和多行/* */) - 添加行号和文件名标识(用于编译错误和调试信息)
- 展开所有
-
面试高频考点:
#include <file.h>和#include "file.h"的区别:前者搜索系统头文件目录,后者先搜索当前目录,再搜索系统目录- 宏定义
#define和typedef的本质区别:宏是文本替换,typedef 是类型别名 - 宏的副作用:比如
#define MAX(a,b) ((a)>(b)?(a):(b)),调用MAX(i++,j)会导致 i 被自增两次
阶段 2:编译
-
输入 :
.i预处理后的 C 文件 -
输出 :
.s汇编语言文件 -
核心操作:将 C 代码翻译成汇编指令,是整个过程中最复杂的阶段,分为 6 个子步骤:
- 词法分析:将源代码拆分成一个个 token(关键字、标识符、常量、运算符等)
- 语法分析:根据语法规则生成抽象语法树(AST)
- 语义分析:检查语法树的语义正确性(类型检查、类型转换等)
- 中间代码生成:将语法树转换成与平台无关的中间代码(如三地址码)
- 代码优化:对中间代码进行优化(常量折叠、死代码消除、循环展开、内联等)
- 目标代码生成:将优化后的中间代码转换成特定平台的汇编指令
-
面试高频考点:
- 编译器优化的常见类型及作用
- 内联函数和宏的区别:内联函数时编译的时候,有类型检查,宏是预处理的时候展开,五类型检查
阶段 3:汇编(Assembly)
- 输入 :
.s汇编语言文件 - 输出 :
.o可重定位目标文件(ELF 格式,Linux 下) - 核心操作:将汇编指令逐条翻译成机器指令(二进制代码),生成目标文件。
阶段 4:链接(Linking)【字节跳动面试核心中的核心】
- 输入 :多个
.o目标文件 + 静态库 / 动态库 - 输出:可执行文件(ELF 格式)
- 核心作用 :
- 解决多个目标文件之间的符号引用问题(比如 A 文件调用 B 文件的函数)
- 进行重定位,将目标文件中的相对地址转换为最终的虚拟地址
- 合并相同类型的段(比如所有目标文件的
.text段合并成一个大的.text段)
链接分为静态链接 和动态链接两种,下面分别详解。
4.1 静态链接(Static Linking)
静态链接在编译链接阶段 完成,将所有需要的目标文件和静态库(.a文件)打包成一个独立的可执行文件。
4.1.1 步骤 1:符号解析(Symbol Resolution)
- 什么是符号:函数名、全局变量名、静态变量名
- 符号表:每个目标文件都有一个符号表,记录了该文件定义的符号和引用的外部符号
- 符号解析的目的:为每个外部符号引用找到对应的定义
强符号与弱符号规则
- 强符号:已初始化的全局变量、函数定义
- 弱符号 :未初始化的全局变量、用
__attribute__((weak))修饰的函数 / 变量 - 规则 :
- 不允许有多个同名的强符号(否则报
multiple definition错误) - 如果一个强符号和一个弱符号同名,选择强符号
- 如果有多个同名的弱符号,选择占用空间最大的那个
- 不允许有多个同名的强符号(否则报
4.1.2 步骤 2:重定位(Relocation)
- 为什么需要重定位:目标文件中的地址都是相对地址(相对于自身段的起始地址),不知道最终会被加载到内存的哪个位置
- 重定位的过程:
- 链接器为每个段分配最终的虚拟地址
- 遍历所有重定位条目,根据重定位类型修改指令中的地址
- 将修改后的指令写入最终的可执行文件
4.1.3 静态链接的优缺点
- 优点:
- 运行速度快,不需要在运行时进行链接
- 可执行文件独立,不依赖系统的库文件
- 缺点:
- 可执行文件体积大,包含了所有用到的库代码
- 内存浪费:多个进程运行同一个静态链接的程序,会在内存中加载多份相同的库代码
- 更新困难:库更新后,所有使用该库的程序都需要重新编译链接
4.2 动态链接(Dynamic Linking)
动态链接将链接过程推迟到程序运行时 进行,多个进程可以共享同一个动态库(.so文件)的代码段。
4.2.1 基本原理
- 编译链接时,链接器不将动态库的代码复制到可执行文件中,而是在可执行文件中记录动态库的名称和符号信息
- 程序运行时,由操作系统的动态链接器(
ld-linux.so)加载动态库,并完成符号解析和重定位
4.2.2 :PIC(位置无关代码)
- 什么是 PIC:编译动态库时必须使用
-fPIC选项生成位置无关代码,使得动态库的代码段可以被加载到内存的任意位置,而不需要修改代码本身 - 为什么需要 PIC:如果动态库不是 PIC,那么每个进程加载动态库时都需要对代码段进行重定位,导致代码段无法共享,失去了动态链接的意义
- PIC 的实现原理:通过 **GOT 表(全局偏移表)和PLT 表(过程链接表)** 实现
4.2.3 :GOT 表与 PLT 表及延迟绑定机制
这是字节跳动面试动态链接部分最高频的考点,必须能清晰描述整个流程。
-
GOT 表(Global Offset Table):
- 位于数据段,是一个数组,每个条目存储一个外部符号的绝对地址
- 编译时,所有对外部符号的引用都被转换为对 GOT 表中对应条目的引用
-
PLT 表(Procedure Linkage Table):
- 位于代码段,是一个数组,每个条目是一段小的汇编代码
- 用于实现延迟绑定(惰性绑定):只有当函数第一次被调用时,才会解析该函数的地址并写入 GOT 表
-
延迟绑定的完整流程(以调用
printf为例):- 程序第一次调用
printf时,跳转到 PLT [1](printf对应的 PLT 条目) - PLT [1] 跳转到 GOT [3](
printf对应的 GOT 条目) - 此时 GOT [3] 中存储的是 PLT [1] 的下一条指令的地址,所以又跳回 PLT [1]
- PLT [1] 将
printf的符号索引压入栈,然后跳转到 PLT [0] - PLT [0] 将 GOT [1](动态链接器的标识)压入栈,然后跳转到 GOT [2](动态链接器的
_dl_runtime_resolve函数地址) _dl_runtime_resolve函数解析printf的绝对地址,将其写入 GOT [3],然后跳转到printf函数执行- 程序第二次及以后调用
printf时,直接跳转到 GOT [3],此时 GOT [3] 中已经存储了printf的绝对地址,不需要再进行解析
- 程序第一次调用
4.2.4 动态链接的两种方式
- 加载时动态链接:程序启动时,由动态链接器自动加载所有需要的动态库并完成绑定
- 运行时动态链接 :程序运行过程中,通过
dlopen()/dlsym()/dlclose()函数手动加载和卸载动态库,适用于插件系统等场景
4.2.5 动态链接的优缺点
- 优点:
- 可执行文件体积小,只包含必要的代码
- 内存共享:多个进程可以共享同一个动态库的代码段,节省内存
- 更新方便:动态库更新后,不需要重新编译所有使用该库的程序
- 缺点:
- 运行速度稍慢,第一次调用函数时需要进行符号解析
- 可执行文件依赖系统的动态库,移植性较差