在 Xcode 中按下 command + B
不出意外的话, 就会显示小锤子
小锤子下面是 Build Succeeded
Build
就是构建
其实构建是一个很复杂的过程
大致的分为 预处理
编译
汇编
链接
四个步骤
最近看了 程序员的自我修养
这本书和一些相关的博客
整理一下构建过程的细节
预编译
在构建的第一步是预编译
预编译做的事比较简单, 大概是:
- 将所有的
#define
删除, 并且展开所有宏定义 - 处理所有预编译指令, 比如
#if
#ifdef
#elif
#else
#endif
- 处理
#include
预编译指令, 将被包含的文件插入到该预编译指令位置 - 删除所有注释
/* */
//
- 添加行号和文件标识, 以便于编译时编译器产生调试用的行号信息以及用于编译时产生编译错误或警告时显示行号
- 保留
#pragma
指令, 编译器会用到他们
编译
编译过程就是将预处理完的文件进行一系列操作 : 词法分析
语法分析
语义分析
优化并生成汇编代码
这个过程是构建程序最核心的部分, 也是最复杂的部分.
-
词法分析
首先源代码被输入到扫描器, 扫描器进行词法分析, 运用一种类似有限状态机的算法将源代码分割成记号
例如这段代码array[index] = index + 4
会被分割成这样 :
记号 | 类型 |
---|---|
array | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
4 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
词法分析产生的记号一般分为以下几类 : 关键字
标识符
字面量(数字 字符串)
特殊符号(加号 等号)
,
与此同时, 扫描器还完成了其他工作 : 将标识符存放到符号表, 将数字字符串常量存放到文字表等.
-
语法分析
接下来语法分析器将对扫描器产生的记号进行语法分析, 从而产生语法树, 语法树是以表达式为节点的树 :
语法树
图中可以看出, 符号和数字是最小表达式, 他们不是有其他表达式组成的, 所以他们通常作为整个语法树的叶节点.
在语法分析的同事, 很多运算符号的优先级也被定义下来, 比如乘法优先级比加法要高, 还有一些符号有多重含义, 比如*
既可以代表乘法也可以代表对指针取内容, 语法分析阶段会对这些内容进行区分, 如果出现不合法的表达式, 编译器会报错. -
语义分析
语义分析由语义分析器完成, 语法分析仅是对表达式的语法层面的分析, 但是它并不了解这个语义是否真正有含义, 比如 C 语言里面两个指针做乘法运算是没有意义的, 但是这个语法却是合法的.
编译器能分析的是静态语义, 也就是能在编译期就确定的语义, 通常包括声明和类型的匹配, 类型的转换. 与之对应的是动态语义, 就是在运行期才能确定的语义, 比如在运行时将 0 作为除数是不合法的.
经过语义分析后, 语法树的表达式被标识了类型 :
语义分析后的语法树 -
代码优化
现在的编译器有着很多层的优化, 往往在源码级就会有一个优化过程, 由源码级优化器完成, 比如 (2+6) 这个表达式, 在编译器就可以被确定, 生成如下语法树 :
其实直接在语法树上做优化比较困难, 所以源码优化器往往把整个语法树转换成中间代码, 他是语法树的顺序表示, 已经非常接近目标代码, 但是他一般跟目标机器和运行时环境是无关的, 比如他不包含数据的尺寸, 变量地址和寄存器的名字等. 中间代码使编译器被分为前端和后端, 编译器前端负责生产与机器无关的中间代码, 编译器后端将中间代码转换成目标代码. 这样对于一些可以跨平台的编译器而言, 他们可以针对不同的平台使用一个前端数个后端.
-
目标代码生成
源代码优化器产生中间代码后的过程属于编译器后端, 主要包括代码生成器和目标代码优化器.
代码生成器将中间代码转换成目标机器代码, 这个过程十分依赖于目标机器, 因为不同的目标机器有着不同的字长, 寄存器, 整数数据类型和浮点数据类型等.
目标机器代码再由目标代码优化器进行优化, 比如选择合适的寻址方式, 使用位移来代替运算, 删除多余指令等.
链接
经过 扫描
语法分析
语义分析
源代码优化
目标代码生成
目标代码优化
这一系列操作, 源码被编译成了目标代码, 但是目标代码有一个问题, index 和 array 的地址还没有确定, 如果 index 和 array 和源代码在同一个编译单元, 那么编译器可以为他们分配空间, 如果定义在别的程序模块就没办法了.
现在程序的代码规模往往很大, 所以每个程序会被分为多个模块, 这样做的好处是每个模块之间相互依赖又相互独立, 而且模块可以单独开发编译测试, 便于重用. 但是随之而来的问题就是模块之间怎么通信, 模块之间的通信包括函数的调用和变量的访问, 函数的访问需要知道函数的地址, 变量的访问需要知道变量的地址.
-
模块拼装 --- 静态链接
我们把每个源代码模块独立的编译, 然后将他们组装起来, 这个组装的过程就叫链接, 连接过程包括了地址分配, 符号决议和重定位.
模块间的通信是地址的相互访问, 解决这个问题的方式就是模块间符号的引用, 模块中符号表分为已定义符号集合D,和一个未定义符合集合U, 未定的符号将引用其他模块中的符号.
在连接的过程中, 每个模块会去其他模块中寻找自己未定义的那些符号的定义, 这个过程就是符号决议.
在未找到符号符号之前, 模块先把这个未定义符号的地址置为 0, 当在其他模块中找到了该符号的定义的时候, 会重新给这个符号赋值地址, 这个过程就是重定位.
静态链接的进本过程和作用 : 比如在程序 main.c 模块中使用另一个模块 func.c 中的函数 foo(). 我们再 main.c 模块中调用 foo 的时候必须知道 foo 这个函数的地址, 但是由于每个模块是单独编译的, main.c 编译的时候并不知道 foo 函数的地址, 所以他暂时把这个指令搁置(地址置 0), 等到最后连接的时候由连接器将指令的目标地址修正, 如果没有连接器, 我们需要手动修正 foo 的地址, 而且每次编译后地址可能会改变. 连接器在连接的时候会根据所引用的符号 foo, 自动取相应的 func.c 模块查找 foo 的地址, 然后将 main.c 模块中所引用的 foo 指令进行重定位,