C语言编译和链接:从 .c到 .exe,程序到底经历了什么?

C语言编译和链接:从 .c 到 .exe,程序到底经历了什么?

很多刚开始学习 C 语言的小伙伴都会有一个疑问:

我们写的 .c 文件,CPU 根本看不懂,那它到底是怎么变成可以运行的 .exe 程序的?

答案就是:预处理 → 编译 → 汇编 → 链接 → 运行

本文详细梳理 C 语言程序从源代码到最终运行的完整过程,帮助你彻底搞懂编译、汇编、链接以及多文件之间是如何协同工作的。


文章目录

一、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语言学习路漫漫,一起把基础打牢!

相关推荐
keep intensify1 小时前
C++20线程池
c++·算法·c++20
菜鸟~noob2331 小时前
【太空电子战】午夜锤行动中的电磁静默区:可逆干扰的实战验证【含matlab代码】
linux·开发语言·matlab·电子战
汉克老师1 小时前
GESP2026年9月认证C++一级( 第三部分编程题(2、棋盘上的奖赏))精讲
c++·gesp·小学生·学c++编程
大侠归来1 小时前
C 与 Go 语言对比:从设计哲学到工程实践
java·c语言·golang
努力努力再努力wz1 小时前
【CUDA 入门系列】核函数与线程模型:一文理清 Grid、Block、dim3、内置变量与全局索引
c++·架构
ShineWinsu1 小时前
对于Redis:ZSet类型的解析
数据库·c++·redis·缓存·面试·有序集合·zset
奋发向前wcx1 小时前
CSP-J复赛模拟赛2 王晨旭补题 2026.10.3
算法
All for pursuit.2 小时前
【回溯-5】78.子集
数据结构·c++·算法·leetcode
|_⊙2 小时前
理清字节序
c语言