从源代码到可执⾏程序:四个步骤与详解
"hello world"可以说是所有程序员闭着眼睛都能写出来的代码:
#include <stdio.h>
int main()
{
printf("hello world\n");
return0;
}
编译运⾏⼀⽓呵成。⽽每当有⼈问起:从源码到可执⾏程序有哪些步骤,⼤多数程序员⾯对这个问题也能脱⼝⽽出:预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。
不过很多⼈不了解其中都做了哪些处理,今天就带⼤家来好好聊⼀聊。
预编译
处理的第⼀步,是将源码⽂件.c和头⽂件.h编译成⼀个.i⽂件。
源程序是指什么程序
预编译过程主要是做了以下⼀些⼯作:
将所有的#define删除,展开所有的宏定义
处理条件编译指令,⽐如#ifdef、#ifndef、#if、#endif等等
处理#include指令,将头⽂件插⼊到该指令的位置。这个过程是递归的,也就是说被包含的头⽂件还可能包含其他头⽂件
删除所有注释
添加⾏号和⽂件名标识,⽐如# 2 "main2.c" 2 ,以便于编译产⽣错误或者警告的时候能够显⽰⾏号以及编译器产⽣调试⽤的⾏号信息
保留所有的#pragma编译器指令,因为编译器要使⽤它们。#pragma的作⽤是设定编译器的状态或者是指⽰编译器完成⼀些特定的动作
编译
编译过程就是将预处理完的⽂件进⾏词法分析、语法分析、语义分析和优化后产⽣相应的汇编代码⽂件。
词法分析
词法分析主要使⽤词法分析器(也叫扫描器),将源代码的字符序列分割成⼀系列的符号(Token)。⽐如如下⼀段程序:
int array = (index + 4) * 2;
经过扫描以后,产⽣11个记号:
int        关键字
array      标识符
=        赋值操作符
(        左⼩括号
index      标识符
+        加号
4        数字
)        右⼩括号
*        乘号
2        数字
;        语句结束
语法分析产⽣的记号⼀般可以分为:关键字,标识符,字⾯量(包括数字和字符串等)和特殊符号(加号减号等)。在识别记号的同时,扫描器也完成其他⼯作,⽐如讲标识符存放到符号表,讲数字字符串常量存放到⽂字表,以备后⾯的步骤使⽤。
语法分析
接下来语法分析器将对由扫描器产⽣的记号进⾏语法分析,从⽽产⽣语法树。整个分析过程采⽤了上下⽂⽆关语法的分析⼿段。
语义分析
这个阶段由语义分析器来完成。语法分析仅仅完成了对表达式的语法层⾯的分析,他并不了解这个语句是不是真的有意义。⽐如两个指针相乘是没有意义的,但是在语法上是合法的。编译器可以分析的语义是静态语义,即在编译器就可以确定的语义;与之对应的是动态语义,即在运⾏期才可以确定的语义。
静态语义通常包括声明和类型的匹配,类型的转换。⽐如⼀个浮点型表达式赋值给整形表达式的时候,语义分析会完成浮点型到整形的转换。动态语义使之运⾏期出现的语义相关问题,⽐如除数是0的时候会报运⾏期语义错误。
源代码优化
现代编译器有很多层的优化,往往在源代码级别会有⼀个优化过程。源代码优化器会在源码级别进⾏优化,⽐如⼀⾏代码:
array[index] = (index + 4) * (2 + 6);
在这⾏代码中,(2+6)这个表达式就可以被优化掉,因为他的值在编译器就可以确定。
在进⾏了语法分析和语义分析阶段的⼯作之后,有的编译程序将源程序变成⼀种内部表⽰形式,这种内
部表⽰形式叫做中间语⾔或中间表⽰或中间代码。所谓“中间代码”是⼀种结构简单、含义明确的记号系统,这种记号系统复杂性介于源程序语⾔和机器语⾔之间,容易将它翻译成⽬标代码。
中间代码使得编译器可以分为前端和后端,前端负责产⽣机器⽆关的中间代码,编译器后端将中间代码转换成⽬标机器代码。这样对于⼀些跨平台的编程语⾔,他们可以针对不同平台使⽤同⼀个前端和针对不同平台的数个后端。
⽬标代码⽣成与优化
源码级优化器产⽣中间代码标志着下⾯的过程都属于编辑器的后端。编译器后端主要包括代码⽣成器和⽬标代码优化器。代码⽣成器将中间代码转换成⽬标机器代码,然后⽬标代码优化器进⾏代码优化,⽐如选择合适的寻址⽅式、⾷⽤为宜来代替乘法运算,删除多余的指令。
经过扫描、词法分析、语法分析、语义分析、源代码优化、代码⽣成和⽬标代码优化,源代码终于被编译成了⽬标代码。但是现在还有⼀个问题:⽬标代码中有的变量定义在其他模块,我们该怎么办?
事实上,定义在其他模块的变量和函数在最终运⾏时的绝对地址都要在链接的时候才能确定。所以现代编译器可以将⼀个源代码⽂件编译成⼀个未链接的⽬标⽂件,然后由链接器最终将这些⽬标⽂件链接起来形成可执⾏⽂件。
链接
暂时挖个坑