C语言编译和链接:从.c到.exe,程序到底经历了什么?
很多刚开始学习 C 语言的小伙伴都会有一个疑问:
我们写的
.c文件,CPU 根本看不懂,那它到底是怎么变成可以运行的.exe程序的?答案就是:预处理 → 编译 → 汇编 → 链接 → 运行
本文详细梳理 C 语言程序从源代码到最终运行的完整过程,帮助你彻底搞懂编译、汇编、链接以及多文件之间是如何协同工作的。
文章目录
- C语言编译和链接:从 `.c` 到 `.exe`,程序到底经历了什么?
- 一、C语言程序的整体流程
- 二、翻译环境和运行环境
- 1. 翻译环境
- 2. 运行环境
- 三、第一阶段:预处理
- 四、预处理阶段主要做什么?
- 1. 展开宏
- 五、`#include` 到底是什么?
- 六、条件编译
- 七、删除注释
- 八、为什么要查看 `.i` 文件?
- 九、第二阶段:编译
- 十、编译阶段到底做什么?
- 十一、什么是词法分析?
- 十二、什么是语法分析?
- 十三、什么是语义分析?
- 十四、第三阶段:汇编
- 十五、为什么有了 `.o` 文件还不能直接运行?
- 十六、什么是链接?
- 十七、为什么 `test.c` 可以调用 `add.c` 中的函数?
- 十八、声明和定义一定要分清
- 声明
- 定义
- 十九、链接器如何找到 `Add`?
- 二十、什么是重定位?
- 二十一、链接主要做哪三件事情?
- ① 地址和空间分配
- ② 符号决议
- ③ 重定位
- 二十二、完整分析一个多文件程序
- 二十三、`.c`、`.i`、`.s`、`.o` 分别是什么?
- 二十四、GCC中的 `-E`、`-S`、`-c`
- `-E`
- `-S`
- `-c`
- 二十五、编译器和链接器不是一回事
- 编译器
- 汇编器
- 链接器
- 二十六、运行环境
- 1. 加载到内存
- 2. 开始执行
- 3. 使用运行时栈
- 4. 程序结束
- 二十七、整节课的核心知识点
- 1. C语言程序的翻译过程
- 2. 预处理主要处理什么?
- 3. 编译阶段做什么?
- 4. 汇编阶段做什么?
- 5. 链接阶段做什么?
- 二十八、最容易混淆的几个概念
- ① `#include` 不等于链接
- ② 声明不等于定义
- ③ 编译不等于链接
- ④ `.o` 文件不是最终程序
- 二十九、用一句话理解整个过程
- 三十、最后总结
一、C语言程序的整体流程
我们平时写的 C 语言代码,本质上只是源代码,计算机不能直接执行
一个 C 语言程序从源代码到最终运行,大致经历下面几个阶段:
C源代码 │ ▼ ① 预处理 │ ▼ .i文件 │ ▼ ② 编译 │ ▼ .s文件 │ ▼ ③ 汇编 │ ▼ .o / .obj │ │ ┌──────┴──────┐ │ │ 其他目标文件 链接库 │ │ └──────┬──────┘ ▼ ④ 链接 │ ▼ 可执行程序 │ ▼ 运行程序所以一定要记住:
C语言程序的翻译过程:预处理 → 编译 → 汇编 → 链接
课件中指出,翻译环境由编译和链接两个大的过程组成,而编译又可以进一步分成:
- 预处理
- 编译
- 汇编
二、翻译环境和运行环境
C语言中存在两个不同的环境:
1. 翻译环境
翻译环境负责:
把源代码转换成可执行的机器指令
也就是说,我们写:
intmain(){inta=10;return0;}计算机最终需要把它转换成机器能够执行的二进制指令
这个过程就是翻译环境
2. 运行环境
程序被翻译成最终的可执行程序之后,还需要真正运行
运行环境负责:
可执行程序 ↓ 加载到内存 ↓ 开始执行 ↓ 调用 main 函数 ↓ 执行程序代码 ↓ 程序结束所以可以简单理解:
翻译环境 = 把程序“造出来” 运行环境 = 把程序“跑起来”三、第一阶段:预处理
预处理也叫预编译
假设我们有一个文件:
test.c经过预处理之后:
test.c ↓ 预处理 ↓ test.i使用 GCC 可以通过下面的命令观察预处理结果:
gcc-Etest.c-otest.i四、预处理阶段主要做什么?
预处理主要处理以#开头的预处理指令
例如:
#include<stdio.h>#defineMAX100常见的预处理指令包括:
#include #define #if #ifdef #elif #else #endif #pragma1. 展开宏
例如:
#defineMAX100inta=MAX;经过预处理后,可以理解成:
inta=100;也就是说:
#define MAX 100 ↓ MAX ↓ 100预处理阶段会展开宏定义
五、#include到底是什么?
我们经常写:
#include<stdio.h>那么#include到底干了什么?
从预处理阶段来看,可以简单理解为:
把头文件的内容插入到
#include所在的位置
例如:
#include"test.h"intmain(){return0;}如果test.h中有:
intAdd(intx,inty);那么经过预处理后,可以理解为:
intAdd(intx,inty);intmain(){return0;}而且这个包含过程是可以递归进行的:
test.c ↓ #include test.h ↓ test.h ↓ #include xxx.h ↓ xxx.h所以:
#include可以简单理解为把头文件的内容包含进当前源文件
六、条件编译
预处理阶段还会处理条件编译
例如:
#ifdefDEBUGprintf("debug");#endif或者:
#if1printf("hello");#elseprintf("world");#endif相关指令包括:
#if #ifdef #elif #else #endif预处理器会根据条件决定哪些代码参与后续编译
七、删除注释
例如:
// 这是一个注释intmain(){/* 这也是注释 */return0;}预处理阶段会删除注释
所以:
源代码 ↓ 预处理 ↓ 注释被删除八、为什么要查看.i文件?
如果我们不知道:
- 宏到底有没有正确展开
- 头文件到底有没有正确包含
- 条件编译到底保留了哪些代码
可以生成.i文件进行查看:
gcc-Etest.c-otest.i然后打开:
test.i就可以看到预处理之后的结果
所以可以记住:
.i文件就是预处理之后的结果
九、第二阶段:编译
预处理完成之后:
test.c ↓ 预处理 ↓ test.i接下来进入:
test.i ↓ 编译 ↓ test.sGCC 命令:
gcc-Stest.i-otest.s十、编译阶段到底做什么?
编译阶段主要会进行:
词法分析 ↓ 语法分析 ↓ 语义分析 ↓ 优化 ↓ 生成汇编代码最终得到:
.s也就是汇编代码文件
十一、什么是词法分析?
假设我们有:
array[index]=(index+4)*(2+6);词法分析首先要做的事情就是:
把源代码中的字符划分成一个个有意义的记号(Token)
例如可以拆分成:
array [ index ] = ( index + 4 ) * ( 2 + 6 ) ;这些一个个的元素就是记号
所以:
词法分析可以理解为“把代码切成一个个单词”
十二、什么是语法分析?
词法分析完成之后,编译器知道:
array [ index ] = ...这些是什么。
但是还需要判断:
这些东西组合起来是否符合 C 语言的语法规则?
这就是语法分析
例如:
a+b*c编译器需要知道运算的结构,而不是简单地从左到右理解
可以表示成一棵语法树:
+ / \ a * / \ b c也就是:
a + (b * c)课件中指出,语法分析器会根据词法分析得到的记号进行分析,并生成语法树
所以:
语法分析就是分析代码的结构
十三、什么是语义分析?
语法正确,并不代表代码的含义一定正确
例如:
inta;a();从代码结构来看,形式上可能可以进行分析
但是:
a是一个int类型变量,不是函数
所以:
a();在语义上存在问题
这就是语义分析要解决的问题
语义分析主要涉及:
- 声明和类型是否匹配
- 类型转换
- 表达式含义是否合理
- 静态语义分析
所以可以用一句话区分:
词法:看“单词” 语法:看“结构” 语义:看“意义”这是非常重要的一个知识点
十四、第三阶段:汇编
编译阶段结束之后:
test.i ↓ 编译 ↓ test.s现在得到的是:
汇编代码
但是 CPU 最终需要执行的是机器指令
所以接下来需要进行:
汇编代码 ↓ 汇编器 ↓ 机器指令例如:
gcc-ctest.s-otest.o最终得到目标文件:
test.o在 Windows 环境下,目标文件通常使用:
.obj在 Linux 环境下通常使用:
.o所以:
.s ↓ 汇编 ↓ .o / .obj十五、为什么有了.o文件还不能直接运行?
这是这节课最重要的问题之一
假设我们的项目中有两个.c文件:
test.c add.c其中:
test.c使用了:
Add(a,b);但是Add函数真正的定义在:
add.c例如:
intAdd(intx,inty){returnx+y;}那么:
test.c和:
add.c是两个不同的源文件
它们会被分别编译
最终得到:
test.c → test.o add.c → add.o此时问题出现了:
test.o里面需要调用Add,但是Add在add.o里面
所以这两个目标文件需要被组合起来
这就是:链接
十六、什么是链接?
链接就是:
把多个目标文件、链接库等组合起来,最终生成可执行程序
例如:
test.o \ \ → 链接器 → test.exe / / add.o实际项目可能更加复杂:
main.o student.o teacher.o math.o + 标准库 + 第三方库 ↓ 链接器 ↓ 可执行程序所以:
链接主要解决多文件、多模块之间互相调用的问题
十七、为什么test.c可以调用add.c中的函数?
例如:
externintAdd(intx,inty);这里的extern表示:
Add 函数存在,但是它的定义不在当前源文件中
然后:
intmain(){intret=Add(10,20);}编译器在编译test.c的时候,知道:
Add 是一个函数 参数有两个 int 返回值是 int但是编译器并不知道:
Add 最终会被放到内存中的哪个地址
因为Add的定义在另一个文件:
add.c而test.c是单独编译的
所以这个问题要留给:
链接器
来解决。
十八、声明和定义一定要分清
这是学习 C 语言非常重要的概念
例如:
externintg_val;这是:
声明
意思是:
g_val 存在但是:
intg_val=2022;才是:
定义
也就是说:
声明: 告诉编译器“它存在” 定义: 真正创建这个东西函数也是一样
声明
externintAdd(intx,inty);定义
intAdd(intx,inty){returnx+y;}十九、链接器如何找到Add?
假设:
test.o需要:
Add而:
add.o提供:
Add那么链接器就会把它们对应起来:
test.o │ │ 需要 Add ▼ 链接器 ▲ │ 提供 Add │ add.o这里的:
Add g_val都可以称为符号
所以可以简单理解:
test.o: 我要 Add 我要 g_val add.o: 我有 Add 我有 g_val链接器:
Add ───────→ Add g_val ───────→ g_val这就是符号决议
二十、什么是重定位?
找到Add之后,还存在一个问题:
Add最终到底位于什么地址?
编译test.c的时候:
Add最终放在哪里还不知道
所以编译器只能先把相关位置留下来
等到链接阶段,链接器把整个程序的空间安排好之后:
Add → 某个具体地址然后再修改之前留下的位置
这个过程就叫:
重定位(Relocation)
课件中也指出,链接器找到其他模块中的Add和g_val后,会对引用它们的位置进行修正,这个地址修正过程就是重定位
二十一、链接主要做哪三件事情?
课件中给出了三个重要概念:
1. 地址和空间分配 2. 符号决议 3. 重定位可以简单理解成:
① 地址和空间分配
决定每个东西最终放在哪里
② 符号决议
找到你使用的符号到底对应哪个定义
例如:
Add ↓ 找到 add.o 中的 Add③ 重定位
把之前不知道的地址修改成最终确定的地址
二十二、完整分析一个多文件程序
假设我们有:
test.c add.c其中:
test.c调用:
Add(a,b);而:
add.c定义:
intAdd(intx,inty){returnx+y;}整个过程就是:
test.c │ ▼ 预处理 │ ▼ test.i │ ▼ 编译 │ ▼ test.s │ ▼ 汇编 │ ▼ test.o │ │ │ ▼ 链接 ▲ │ │ add.o ▲ │ 汇编 ▲ │ 编译 ▲ │ add.i ▲ │ 预处理 ▲ │ add.c最终:
test.o + add.o + 链接库 ↓ 链接器 ↓ 可执行程序二十三、.c、.i、.s、.o分别是什么?
这个表格建议直接记下来:
| 文件 | 含义 |
|---|---|
.c | C语言源代码 |
.h | 头文件,通常包含声明、宏等 |
.i | 预处理后的文件 |
.s | 汇编代码文件 |
.o | Linux等环境下的目标文件 |
.obj | Windows环境下常见的目标文件 |
.exe | Windows环境下常见的可执行程序 |
完整流程:
.c ↓ 预处理 ↓ .i ↓ 编译 ↓ .s ↓ 汇编 ↓ .o / .obj ↓ 链接 ↓ 可执行程序二十四、GCC中的-E、-S、-c
这三个参数非常重要
-E
gcc-Etest.c-otest.i作用:
只进行预处理
.c → .i-S
gcc-Stest.i-otest.s作用:
进行编译,生成汇编代码
.i → .s-c
gcc-ctest.s-otest.o作用:
进行汇编,生成目标文件
.s → .o可以直接记:
-E:预处理 -S:编译 -c:汇编二十五、编译器和链接器不是一回事
很多初学者容易把它们混在一起
实际上:
编译器
主要负责:
C代码 ↓ 汇编代码过程中会进行:
词法分析 语法分析 语义分析 优化汇编器
负责:
汇编代码 ↓ 机器指令链接器
负责:
多个目标文件 + 各种链接库 ↓ 可执行程序所以:
编译器 ≠ 链接器虽然我们平时可能只输入一条:
gcc test.c但实际上编译器驱动程序会帮我们完成多个阶段
二十六、运行环境
当链接完成以后,终于得到了可执行程序
例如:
test.exe接下来进入运行环境
1. 加载到内存
程序首先需要被加载到内存
可以简单理解为:
硬盘 ↓ 可执行程序 ↓ 内存在有操作系统的环境中,一般由操作系统负责程序的加载
2. 开始执行
程序加载完成后开始执行
接下来:
程序启动 ↓ 调用 main()3. 使用运行时栈
程序执行函数时,需要使用栈空间保存:
- 局部变量
- 返回地址
- 函数调用相关信息
例如:
intmain(){inta=10;return0;}这里的局部变量a就涉及函数运行时的存储空间
4. 程序结束
正常情况下:
return0;程序结束
当然,程序也可能因为其他原因异常终止
二十七、整节课的核心知识点
如果只记住下面这些内容,基本就抓住了本节课的主线
1. C语言程序的翻译过程
预处理 ↓ 编译 ↓ 汇编 ↓ 链接 ↓ 可执行程序2. 预处理主要处理什么?
#include #define #if #ifdef #elif #else #endif同时还会:
展开宏 处理条件编译 包含头文件 删除注释 添加相关标识信息3. 编译阶段做什么?
词法分析 ↓ 语法分析 ↓ 语义分析 ↓ 优化 ↓ 生成汇编代码4. 汇编阶段做什么?
汇编代码 ↓ 机器指令生成:
.o / .obj5. 链接阶段做什么?
多个目标文件 + 链接库 ↓ 链接 ↓ 可执行程序主要涉及:
地址和空间分配 符号决议 重定位二十八、最容易混淆的几个概念
①#include不等于链接
#include发生在:
预处理阶段可以简单理解为:
把头文件内容包含进来而链接发生在后面:
链接阶段主要解决:
不同目标文件之间的符号引用② 声明不等于定义
例如:
externintg_val;是声明
而:
intg_val=2022;是定义
③ 编译不等于链接
编译主要处理:
C代码 ↓ 汇编代码链接主要处理:
目标文件 + 库 ↓ 可执行程序④.o文件不是最终程序
.o是目标文件
它还可能存在:
符号没有最终确定 地址没有最终确定需要经过链接之后,才能得到最终的可执行程序
二十九、用一句话理解整个过程
如果让我把整节课压缩成一句话:
我们写的
.c文件首先经过预处理、编译和汇编,分别变成.i、.s和.o,然后链接器把多个目标文件和链接库组合起来,完成符号决议和重定位,最终生成可执行程序
然后程序进入运行环境:
可执行程序 ↓ 加载到内存 ↓ 调用 main ↓ 执行代码 ↓ 程序结束三十、最后总结
整个 C 语言程序的生命周期可以浓缩成下面这张图:
C源代码 │ ▼ 【预处理】 #include / #define 条件编译 / 删除注释 │ ▼ .i │ ▼ 【编译】 词法 / 语法 / 语义 + 优化 │ ▼ .s │ ▼ 【汇编】 汇编 → 机器码 │ ▼ .o/.obj │ │ ┌───────────┴───────────┐ │ │ 其他目标文件 链接库 │ │ └───────────┬───────────┘ ▼ 【链接】 地址和空间分配 符号决议 重定位 │ ▼ 可执行程序 │ ▼ 【运行】 │ ▼ 加载到内存 │ ▼ main() │ ▼ 执行代码 │ ▼ 程序结束最后记住这句口诀:
预处理看
#,编译看语法,汇编变机器码,链接找符号,重定位修地址。
搞懂这条主线之后,后面学习:
extern- 头文件
- 多文件编程
- 静态库
- 动态库
- 符号表
- ELF
- 链接错误
都会容易很多
如果这篇文章对你有帮助,欢迎点赞、收藏、关注!
C语言学习路漫漫,一起把基础打牢!