☰
C语言编译和链接:从 .c到 .exe,程序到底经历了什么?
2026/10/2 17:31:37 网站建设 项目流程

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 #pragma

1. 展开宏

例如:

#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.s

GCC 命令:

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分别是什么?

这个表格建议直接记下来:

文件含义
.cC语言源代码
.h头文件,通常包含声明、宏等
.i预处理后的文件
.s汇编代码文件
.oLinux等环境下的目标文件
.objWindows环境下常见的目标文件
.exeWindows环境下常见的可执行程序

完整流程:

.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 / .obj

5. 链接阶段做什么?

多个目标文件 + 链接库 ↓ 链接 ↓ 可执行程序

主要涉及:

地址和空间分配 符号决议 重定位

二十八、最容易混淆的几个概念

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询