编程语言一般分为编译期、运行期
有的编程语言是编译期与运行期合并在一起的,比如Python、PHP…即解释型语言
有的则是编译期与运行期分开的,比如C、C++、Java…即编译型语言
今天咱们深入聊聊编译期的C语言
运行期的C语言,我在我之前的课程中已经详细讲过,感兴趣的自己去看视频
编译期,即编译器工作的期间
一般编译器,都是基于《编译原理》实现的
但是C语言的编译器的实现,在《编译原理》基础上做了拓展,如图
接下来详细讲讲C语言编译器的各个阶段
预处理
在真正编译C语言程序之前,需要对C语言程序进行预处理,预处理是由集成在编译器中的预处理器完成的
从《编译原理》的角度,词法分析是编译器的第一个阶段。但是从C语言编译器的角度,预处理是第一个阶段
预处理阶段主要完成四件事:删除注释、宏展开、文件包含、条件编译
如果你要做实验论证,gcc -E即可实现,比如
举个例子吧:删除注释、宏展开,比如代码
预处理以后,注释没有了,宏展开了。C语言的预处理器,就是简单的宏替换
你会发现,预处理器会在开头多生成点东西,这些东西是什么呢?有什么用呢?
这些看似神秘的 # 开头的行,实际上叫做行控制信息(line control directives),是 GCC 预处理器在输出中自动插入的特殊指令,是方便后续编译器、调试器、诊断工具理解源码位置的信息
词法分析
经过预处理后,得到的就是完整的C语言程序了,就可以开始编译了。词法分析器启动…
词法分析器的职责是:输入源程序,输出token
来看看上面的程序生成的token
如何查看C语言程序生成的token呢?
你可能想问:为什么用clang,而不是用gcc?因为gcc作为老牌的编译器,不支持这个功能
来看看词法分析器生成token的过程
如果你想透彻理解词法分析的底层原理,你可以使用词法分析工具flex生成词法分析器,去实战。或者自己从零写一个词法分析器,体会将程序的点点滴滴转成token的过程,你的困惑就解开了…(这部分内容,我做的课程手写编程语言中有教,感兴趣的可以咨询班主任jvm-anan)
语法分析
拿到源程序对应的token,就可以去做语法分析了。语法分析器启动…
语法分析器的职责是:输入token,输出抽象语法树AST。后面的阶段,都是围绕AST进行的
换个程序,来看看经过语法分析生成的抽象语法树
对应的AST
如何查看呢?两种方式
其实gcc也可以,就是生成的文件太多,看起来不直观
会生成这些文件
来看看token生成AST的过程,比如print语句
如果你想透彻理解语法分析的底层原理,你可以使用语法分析工具bison生成语法分析器,去实战。或者自己从零写一个语法分析器,体会将token转成AST的过程,你的困惑就解开了…(这部分内容,我做的课程手写编程语言中有教,感兴趣的可以咨询班主任jvm-anan)
语义分析
语义分析器的职责是:输入AST,输出AST + 符号表
比如程序生成的AST长这样
语义分析器会遍历AST,生成符号表。这个无法查看,你可以大概理解成是这样
然后是在AST表中加上注解
语义分析是一种什么感觉呢?就像拿着一棵语法树边走边做笔记,遇到变量声明就记下来(符号表),遇到变量使用就去查阅笔记,发现有问题就报错
语义分析具体做哪些事情呢?我们所知的如:类型检查、类型转换、生命周期检查、控制流检查、访问权限…完整的如图
| 类别 | 子任务 |
|---|---|
| 符号绑定 | 名称绑定 (Name Binding) 名字唯一性检查 (Duplicate Declaration Check) |
| 符号表管理 | 作用域嵌套管理 (Scope Management) 作用域隐藏检测 (Shadowing Detection) |
| 类型系统 | 类型检查 (Type Checking) 类型推导 (Type Inference, 在某些初始化表达式中) 兼容性检查 (Type Compatibility Checking) |
| 类型转换 | 隐式类型转换检查 (Implicit Conversion) 强制类型转换检查 (Explicit Cast Checking) 常量转换合法性检查 |
| 存储类别分析 | static, extern, auto, register, thread_local |
| 链接属性分析 | 内部链接、外部链接、可见性 (Linkage & Visibility Analysis) |
| 生命周期分析 | 生命周期合法性 (Object Lifetime Analysis) 未初始化变量使用检测 |
| 函数分析 | 函数原型一致性检查 (Function Prototype Matching) 返回值类型检查 |
| 表达式分析 | 表达式合法性检查 (e.g. lvalue/rvalue合法性) 算子适用性检查 (Operator Applicability) |
| 控制流合法性 | return合法性 break/continue合法性 goto合法性(禁止跳转到未初始化变量作用域内) |
| 常量表达式分析 | 常量折叠 (Constant Folding) 编译期常量表达式合法性 (Constant Expression Validity, e.g. array size, switch case) |
| 标签与跳转分析 | goto合法性 标签重复定义检测 |
| 结构体与联合体分析 | 成员合法性检查 (Duplicate Member Check) 嵌套结构体合法性 |
| 枚举分析 | 枚举值合法性 枚举常量范围分析 |
| 数组分析 | 数组维度合法性 数组初始化合法性 |
| 指针分析 | 指针类型一致性 不合法解引用检测 |
| 语言扩展检查 | 特殊属性语义检查(如:attribute、aligned、packed) |
| 内置与关键字检查 | 内置函数合法性(如 __builtin_*) 关键字非法使用检测 |
| 兼容性与标准限制 | 语言标准兼容性检查(如 C89、C99、C11、C17 差异处理) |
语义分析是编译器前端中逻辑最复杂、实现难度最高、语言标准依赖最强、对整体编译正确性最关键的阶段。我在我的课程手写编程语言中,做了初步的语义分析,我觉得让大家touch到那个感觉即可
中间代码生成
万事俱备,可以生成中间代码了。比如程序
生成的中间代码长这样
如何查看的呢?
gcc -fdump-tree-all 1.c
前面说了,这样干会生成很多文件,后缀名是.gimple的才是
C语言的中间代码(IR)称为:GIMPLE, 三地址码形式
优化
在编译之前,还要做一件事:优化。我们使用gcc -O配置优化级别,就是在这个阶段完成的
gcc一共提供了5个优化
| 优化等级 | 大致做了哪些 GIMPLE 优化 |
|---|---|
-O0 | 几乎不做 GIMPLE 优化 |
-O1 | 基础常量传播、死代码消除 |
-O2 | 启动大部分 GIMPLE Pass,常用优化都做了 |
-O3 | 激进优化(循环展开、向量化等) |
-Ofast | 极限优化(关闭部分标准兼容性保障) |
所有的优化任务
| 类别 | 子任务 | 简要说明 |
|---|---|---|
| 控制流优化 | CFG简化 (Control Flow Simplification) | 合并跳转块、消除不可达代码 |
| 死代码消除 | DCE (Dead Code Elimination) | 消除无用语句 |
| 常量传播 | CCP (Conditional Constant Propagation) | 将已知常量沿控制流传播 |
| 常量折叠 | Constant Folding | 编译期计算表达式结果 |
| 循环优化 | Loop Unrolling, Loop Invariant Code Motion (LICM) | 循环展开、移动不变代码出循环 |
| 归纳变量优化 | Induction Variable Simplification | 简化循环计数器 |
| 变量合并 | Scalar Replacement of Aggregates (SRA) | 把 struct 变量拆成普通变量 |
| 别名分析 | Alias Analysis | 变量间内存相关性分析 |
| 逃逸分析 | Escape Analysis | 分析是否能栈分配对象 |
| SSA优化 | SSA Coalescing / Phi Elimination | 精简 SSA 形式、消除冗余 Phi 函数 |
| 全局值优化 | GVN (Global Value Numbering) | 消除全局公共子表达式 |
| 冗余消除 | PRE (Partial Redundancy Elimination) | 消除部分可重用子表达式 |
| 循环展开 | Loop Unrolling | 展开循环以便减少控制依赖 |
| 循环剥离 | Loop Peeling | 分离出循环前几次迭代 |
| 循环分裂 | Loop Splitting | 根据条件分裂不同路径循环 |
| 跳转线程化 | Jump Threading | 在多个跳转链中合并路径 |
| 纯函数优化 | Pure/Const Function Propagation | 纯函数调用结果缓存 |
| 内联 | Function Inlining | 将小函数内联展开 |
| 尾调用优化 | Tail Call Optimization | 改写尾递归为跳转形式 |
| 空间优化 | Stack Slot Reuse | 重用局部栈空间 |
| 代码移动 | Store Motion | 将冗余存储延后或提前 |
| 安全检查 | 未定义行为提前检查 | 比如整除零检测 |
举个例子帮助大家理解编译优化,比如常量折叠,优化前
优化后
看到这,是不是有一种悟的感觉了…
代码生成
代码生成阶段是由代码生成器完成的。代码生成器的职责是:将优化后的中间表示(IR)转换为目标平台的汇编代码
比如代码
生成汇编代码
如果你想查看C语言程序生成的汇编程序,运行时查看反汇编即可,编译时呢?这样查看
这就是C语言代码生成的all
汇编
这个阶段就是将汇编代码编译成机器码
注意,这时候还不是可执行文件,是目标文件.o
如何得到目标文件呢?gcc -c test.s -o test.o
理解这个阶段非常重要,因为我们写很多底层程序,比如操作系统,需要使用汇编+C语言,就是将汇编程序编译成目标文件,C语言程序编译成目标文件,然后链接成可执行文件,才得到真正的操作系统代码
链接
万事俱备,只欠可执行文件了。链接器启动…
执行链接操作:gcc test.o -o test即可生成可执行文件
如果有多个中间文件,可以将多个中间文件链接:gcc test.o 1.o 2.o -o test
注意,gcc完成链接工作,背后其实是调用链接器ld实现的
以上就是C语言程序编译期的全部,你学废了吗?
后续还会更新更多超硬核文章,感兴趣的话可以关注**【硬核子牙】**微信公众号