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