📖 参考书籍:《程序员的自我修养——链接、装载与库》
很多同学写 C 语言,天天点 VS 的“本地 Windows 调试”,敲完代码一键运行。你有没有好奇过:我们写的人类看得懂的 C 语言文本,究竟是怎么变成 CPU 能识别的 0 和 1,最终跑起来的?
很多人误以为“点击运行”只是简单一步,实际上背后经历了两大环境:翻译环境和运行环境。
- 翻译环境:造轮子,把人写的
.c源代码翻译成机器指令,产出可执行程序。 - 运行环境:开汽车,把可执行程序加载进内存,真正跑起来。
通俗比喻:翻译环境相当于翻译官,把中文菜谱翻译成机器看得懂的手册;运行环境相当于厨师,拿着翻译好的手册动手做菜。
一、翻译环境的完整流水线
翻译环境又分为两大块:编译(预处理 → 编译 → 汇编)+ 链接。一个工程可以拥有许许多多 .c 源文件,每一个 .c 文件都会独立完成预处理、编译、汇编,生成目标文件。
- Windows:目标文件后缀是
.obj - Linux:目标文件后缀是
.o
⚠️ 重点:每个 .c 是单独编译的!A 文件编译的时候,并不知道 B 文件里面函数、变量的真实地址。最后交给链接器把全部目标文件和系统库拼装起来,生成最终的可执行程序。
阶段 1:预处理(预编译)——简单粗暴的文本替换
gcc 命令:
gcc -E test.c -o test.i,输出.i预处理文件。
预处理器只处理所有以 # 开头的指令,做纯文本处理,不做语法检查。主要干这几件事:
#include头文件展开:找到头文件,把头文件完整复制粘贴到当前位置,并递归处理嵌套 include。这就是为什么写
#include <stdio.h>之后,你就可以使用 printf。打开生成的.i文件,你会发现短短几行代码膨胀成上千行。- 宏定义
#define全部展开替换,并删掉 #define 本身。 - 处理条件编译:
#ifdef、#if、#else、#endif,保留符合条件的代码,删掉不满足条件的代码。 - 删除所有注释,单行
//和多行/* */全部抹除。 - 添加行号、文件名标记,方便后续报错调试。
- 保留
#pragma指令,留给编译器后续使用。
✅ 小技巧:遇到宏异常、头文件包含 bug,直接看 .i 预处理文件,看展开之后真实代码是什么,是调试神器。
注意:预处理结束之后依旧是文本文件,还没有变成机器码。
阶段 2:编译——C 语言转汇编语言
gcc 命令:
gcc -S test.i -o test.s,输出汇编文件.s。
这是编译器真正干活的阶段,分为词法分析、语法分析、语义分析、优化,最终输出汇编代码。
举个例子代码:
array[index] = (index+4)*(2+6);
- 词法分析:把连续的字符串切割成一个个记号:标识符、数字、运算符、括号。就像把一整段文章拆成一个个词语。
- 语法分析:根据 C 语法规则,生成一棵语法树,描述表达式的嵌套关系。如果少写分号、括号不匹配,这一步直接报语法错误。
- 语义分析:做静态语义检查,检查类型是否匹配、变量有没有声明。比如把 int 赋值给 char* 指针,这里就会报警告或报错,并给语法树上每个节点打上类型标记。
- 代码优化:把高级表达式等价转换成汇编助记符。
编译完成得到
.s汇编文件,是人可以读懂的文本,还不是二进制。
阶段 3:汇编——汇编转二进制目标文件
gcc 命令:
gcc -c test.s -o test.o,生成目标文件.o(Windows 是.obj)。
汇编器对照汇编指令和机器指令对照表,把每一条汇编语句翻译成对应的二进制机器指令。⚠️ 但是!.o 目标文件还不能直接运行!
为什么不能直接运行?一个源文件里面可能调用别的 .c 文件定义的函数、全局变量。编译本文件的时候,编译器根本不知道外部符号的内存地址,只能先做标记,留下一堆“待修补的缺口”,等待链接器来填坑。
目标文件内部除了机器指令,还包含符号表、重定位表:记录函数名、全局变量名,记录哪些地方的地址后续需要修补。
阶段 4:链接——拼接各个模块,修补地址(重中之重)
前面三步每个
.c单独干活;链接是唯一把全部模块汇总的步骤。
链接器输入:一堆 .o 目标文件 + 系统库文件(C 标准库);输出:可执行程序(.exe / a.out)。
链接两大核心工作:符号解析、重定位。
举个经典的多文件例子:test.c 里面写 extern int Add(int x,int y); 调用 Add 函数;真正 Add 函数的实现写在另一个文件 add.c 中。
- 符号解析:编译器编译
test.c的时候,只看到Add的声明,看不到实现,不知道 Add 函数到底在内存哪个位置。链接器遍历所有目标文件的符号表,找到Add符号真正的定义,把调用位置和这个符号绑定。 - 重定位(打补丁):找到真实地址之后,把目标文件机器码里面所有“预留的地址缺口”全部修改,替换成真正的内存地址。这个修补地址的动作,就叫重定位。
⚠️ 坑提醒:链接器不懂 C 语言语法!如果你 extern 声明的函数类型和真实定义不一致,编译器不会报错,链接只会按照符号名字匹配,运行直接崩溃。
链接除了处理我们自己写的 .o,还会链接系统库。比如我们天天用的 printf,函数实现在 C 标准库,链接器把 printf 的地址也修补进我们程序。
二、运行环境:可执行程序真正跑起来
翻译环境结束,我们得到可执行文件,但是它此时安安静静躺在硬盘上,还没有执行。运行环境负责让程序活起来。
程序执行大致流程:
- 加载入内存:操作系统把硬盘上的可执行程序,读取到进程的虚拟内存空间。
- 做好环境准备,调用 main 函数,正式开始执行我们写的业务代码。
- 运行过程中:
- 使用函数栈(stack):存放局部变量、函数返回地址;函数调用、返回全靠栈。
- 使用静态内存:static 修饰的变量,整个程序生命周期都存在。
- 程序终止:main 正常 return;或者程序异常崩溃退出。
区分记忆:
- 翻译环境:硬盘上操作,生成 exe,此时程序没有运行。
- 运行环境:载入内存,CPU 执行一条条机器指令。
三、实操:GCC 手动拆解完整流程(可以复制敲一遍)
新建简单 hello.c:
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
#1 预处理,生成hello.i
gcc -E hello.c -o hello.i
#2 编译,生成汇编hello.s
gcc -S hello.i -o hello.s
#3 汇编,生成目标文件hello.o
gcc -c hello.s -o hello.o
#4 链接,得到可执行程序
gcc hello.o -o hello
平时我们写
gcc hello.c -o hello,gcc 驱动程序帮我们一口气走完预处理、编译、汇编、链接全部流程。
四、初学者高频踩坑 & 易错点
- 编译报错 VS 链接报错要分清
- 编译报错:语法错误,少分号,类型不匹配,发生在预处理/编译阶段。
- 链接报错:
undefined reference to xxx,符号找不到。代表语法没问题,但是找不到函数/变量的实现。- 原因:漏加
.c文件参与编译;函数写错名字;忘记链接库。
- 原因:漏加
extern只是声明,不产生实体。定义才会分配内存;声明可以多次,定义只能一次。- 目标文件
.o不等于可执行文件,缺少外部符号地址信息,直接双击运行会报错。 - 每个
.c独立编译,互相看不见对方内容,全靠链接器做跨文件的符号匹配。
五、思维导图式总结
源代码(.c/.h)
↓预处理(#include、宏展开、删注释)
.i文本文件
↓编译(词法/语法/语义分析 →汇编)
.s汇编文件
↓汇编(汇编翻译二进制机器码)
.o / .obj目标文件(带符号表、重定位缺口,不可运行)
↓链接【符号解析 + 重定位,拼接库】
可执行程序 .exe
↓运行环境:加载进内存,执行main
程序运行输出结果
拓展阅读:如果想要深入理解目标文件格式、静态链接、动态链接,强烈推荐《程序员的自我修养——链接、装载与库》,这本书把编译链接底层讲得非常透彻。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/ZYyyyyyyyyxxxxxx/article/details/169561422



