扯淡:大白话聊聊编译那点事儿

96
作者 折腾范儿_味精
2017.03.01 19:27 字数 6255

notes:本篇其实是我自己的一篇读书笔记,在看了一些书和博客之后,想用大白话解释一下,然后加强自己的知识记忆,只是想分享一下

很多细节知识点,在大白话后可能讲的很糙,甚至掩盖了很多技术细节,如有不足,希望指正

我会把看到的相关博客,书籍,在文尾一一列出,其实还是看书好,更加系统

作为程序员的我们,每天写各种语言的各种代码,点一下IDE环境里的run,或者用一行命令一跑,一个程序就运行起来了。我们写好的那一行行代码,其实就是最普通的文本字符串,这些个文本字符串是怎么变成一个个漂亮的界面,一个个大数据量吞吐的服务器,一个个聪明的人工智能AI的?这里面其实经历了三个过程,编译,链接,装载(脚本语言会特殊一些,本文后面也会提及)

  • 编译:编译系统会读取我们写的文本字符串,去解读这里面代码所蕴含的意义,解读出来后会翻译成机器能看懂的汇编语言,我们管这个结果叫目标文件,这个过程叫编译

  • 链接:每个类,每个文件都会被编译成不同的目标文件,链接器把这每个目标文件穿起来,让他们之间能够相互调用,最后生成可执行文件,这个过程叫链接

  • 装载:把已经生成的可执行文件放到操作系统里,在系统专属的进程与内存控制下,找到机器可以识别的汇编代码入口,开始按着汇编去执行机器码
    ,并且能与操作系统级别的各种系统Api对接起来,这个过程叫装载

这一篇主要聊聊编译,但我会持续把大白话聊链接,装载坚持写完

编译步骤

怎么能让编译系统理解你写出来的一行行字符串,让机器去读懂你写的代码?这里面其实经历了很多步骤,我之前从antlr扯淡到一点点编译原理里提到过一点点,这里我们再扯一扯~

  • 预编译
  • 词法分析
  • 语法分析
  • 语义分析
  • 生成抽象语法树
  • 生成中间码
  • 目标代码生成
  • 目标代码优化

<!--more-->

预编译

在预编译的过程中,会处理源代码中的那些以#开头的预编译指令,比如#include,#define,#ifdef等,在编译开始前就先对原始的代码文件进行调整,经过了预编译之后,你写的代码其实已经改变了很多。不能说面目全非,但也变化很大,不再是你亲手写出来的样子了

  • #define大家都知道是宏定义,在预编译环节会扫描所有的宏,并将宏展开成真正的原始代码

  • #include大家知道是引用的意思,但是引用在预编译阶段是怎么操作的呢?其实这行预编译指令就是原封不动的把.h文件插入到写include的位置,既然是原封不动的copy插入,这里会涉及一些命名重定义问题,这就是为什么有时候写在.h里面的定义需要加static关键字。

  • #indef大家都知道是条件编译,通过上面讲的几种预编译的实际操作过程,也能猜到其实就是字符层面的删减,只有符合条件编译的情况,这里面的代码才会被保留,如果编译条件为false,在输入编译器之前,你写的那大段代码就已经被删掉丢弃了

  • /* */ & //删除注释,是滴,注释是对编译完全没用的,因此注释是不会参与编译的

  • #pragma是一种编译器指令,这种编译器指令会被保留,后续编译有用

  • 添加行号,给每行代码添加行号,万一编译报错,也方便追查

看到宏的操作我们可以理解到,为什么我们不推荐把一些业务中常用的常量用宏来表示,如果一个宏被修改,那么相当于所有用宏的地方,你写出的代码都变了,要重新编译

如果一个宏被写入了.h文件,这个.h文件又被include到各种地方,甚至写入了pch,那么一个宏修改,不管你用不用这个宏,受影响的所有文件都等同于你直接修改了代码,要重新编译

词法分析

词法分析其实是用一个扫描器逐行去扫描整个代码文件,通过一些算法(有限状态机算法)把你写的字符串分割成一个个的记号token,你写的关键字,标识符,字面量,运算符号等,都会被词法分析一一识别,分割,然后按顺序排列成一个个的标记。

  • 关键词:比如for while if static等会被词法分析识别成单词
  • 标识符:你代码起名的常量名字,变量名字等
  • 字面量:你在代码里写死的一些值,数字,字符等,比如@“1”
  • 特殊符号:+ - * /等等

让你的代码不再是一个char 一个char相互之间无关联的字符串,而变成了一个标记一个标记的标记流(你可以理解有独立意义的单词),每一个标记可能是个值,可能是个变量名字,可能是个运算符,可能是个语法单词。

在词法分析阶段,扫描器是并不清楚这一个个标记是什么意思的,他不需要知道for代表循环,int代表整形,他只需要知道for,int,是一个个独立的单词。

其实扫描器在经历过词法分析后,会简单的对这些token进行归类,符号,常量等可以简单处理的会放入不同的常量区符号区。

语法分析

语法分析就是真正的编译器在尝试读懂你写的代码了,经过了词法分析你得到的token流,会按顺序输入语法分析器,语法分析器会尝试解读,最终将我们希望表达的自然语义,构建成了一个逻辑上的计算机能识别,能执行,能遍历的结构--树状结构。也就是抽象语法树(Abstract Syntax Tree)

每一条语句都是一个表达式,复杂语句就是复杂表达式的组合,可以相互嵌套,语法分析会把token流中很明显的表达式token识别出来,识别出表达式的核心意图,识别出表达式的参数,同时语法分析器会按着自己内部的运算符优先级规则,去调整表达式的执行顺序。

  • 遇到了 = token,语法分析器会知道这是一个赋值表达式,左边的token是赋值对象的表达式 or token,右边是值的表达式 or token
  • 遇到了+ token,语法分析器会知道这是一个加法表达式
  • 按着语法分析器的内置规则,把一个又一个的表达式,按着语义去组合去嵌套,组合成一堆表达式的树状结构

如果此时我们写代码不严谨,哪里少了个括号,哪里赋值没写值就直接回车,这时候语法树都是无法生成的,就会直接编译报错。

大学的时候计算机有一门课程作业就是自己实现一个计算器,要求可以用字符串连续输入一串数学运算式,由我们自己用算法来处理一级运算符 + - 与二级运算符 * / 甚至还要处理括号,最终得出计算器的最终结果。

这个处理过程很像,我们会识别出一行字符串里,优先计算出 * / 二级运算符的结果,用结果再去计算 + - ,最后得出了一个树按着树去深度遍历,就能拿到计算结果

语义分析

我们已经初步得到了抽象语法树,这个抽象语法树每个节点都是一个表达式,但是这个表达式是否有意义,此时还并不确定,一个赋值表达式,我不能将一个数值赋值给一个对象指针,一个乘法表达式我不可能把一个地址指针与一个数值相乘。

语义分析就是进行这种静态分析,会遍历整个语法树,把每个节点的表达式都标识类型,并且验证是否合法,

抽象语法树

从语法分析开始,就提到生成抽象语法树(Abstract Syntax Tree),经过了语义分析,AST又变的更加完善,自此最终版的抽象语法树已经建立完成

生成中间码

不同硬件平台的汇编处理都是不一样的,这和硬件,CPU,总线的设计都有关系,一个AST如果想要在各个平台都能运行,那么就得生成很多个平台的汇编码。

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决

于是我们在AST与多个平台的汇编代码中间,抽象出了一个中间码(Intermediate Representation),他与语言无关(过了AST之后,就和语言无关了),与平台无关(他并没有直接生成汇编,在中间码的设计里打平了平台差异硬件差异)。

最后通过目标平台的汇编器,由中间码生成汇编

目标代码

计算机是通过汇编来执行操作的,机器能够听从并执行诸如,移动到内存某个地址位移多少个字节写入多少byte的数据,这种汇编指令,因此一个程序如果想让硬件机器能够执行,最终一定是通过这样的汇编指令来实现。

我们手头有了抽象语法树,他是一个树状的结构,每个节点都是一个表达式的描述,但这毕竟不是机器能读懂的汇编,因此我们需要遍历这个AST,把AST的每一个表达式,每一层逻辑,都先转化成上面提到的中间码,在根据不同的硬件平台,翻译成机器可以读懂的语言--汇编,最终翻译成的汇编语言文件,就是目标文件(以后的篇幅讲链接会重点介绍)

这一部分还可以细分为

  • 目标代码生成
  • 目标代码优化

编译前端-编译后端

我们介绍了七个环节,预编译词法分析语法分析语义分析抽象语法树中间码生成目标代码生成目标代码优化,我们把中间码生成当做一个分界线,前边的环节就叫编译前端,后面的环节叫做编译后端

这么区分有啥好处?这样的设计就保证了中间码这个东西,语言无关&平台无关。

编译前端

预编译词法分析语法分析语义分析抽象语法树,这些环节共同组成了编译前端,编译前端专门去处理语言专属的特性。不同的语言他的词法关键字,他的语法规则,语义分析的函数类型校验,都是不同的,甚至不是所有语言都有预编译这个环节,但每个语言可以开发一个属于自己语言的编译前端,只要生成统一的标准的中间码,就可以无缝对接给任意编译后端,这就是语言无关

编译后端

目标代码生成目标代码优化,这些环节共同组成了编译后端,其实编译后端还会有本文没有深入讲的链接环节(将目标文件串联成可执行文件),编译后端专门负责处理各个平台的差异,根据不同平台,编译后端进行不同的汇编代码生成,无论是什么语言生成的标准中间码,只要编译后端支持的硬件平台,通过编译后端都能直接生成对应的目标文件,编译后端不支持的硬件平台?扩展编译器,让编译器支持一下咯╮(╯_╰)╭,这就是平台无关

编译工具

GCC

GCC(GNU Compiler Collection,GNU编译器套装),既然是编译器套装,那么其实GCC内部包含了编译前端与编译后端所有模块。

GCC的编译前端部分原本只支持C,后来很快就扩展支持了C++,再到后来,GCC也扩展支持了很多包括FortranPascalObjective-CJava

GCC的编译后端也是很强大的,移植各个平台都支持,包括x86、mips、Alpha、ARM、AVR、IA-64、SPARC、PowerPC等30多种平台

GCC虽然在被广泛的使用,但目前也面临了危机,后起之秀CLang/LLVM,大有全面赶超GCC之势头。

Clang/LLVM

先说LLVM吧,以下内容摘抄自百科

LLVM 命名最早源自于底层虚拟机(Low Level Virtual Machine)的缩写,由于命名带来的混乱,目前LLVM就是该项目的全称。LLVM 核心库提供了与编译器相关的支持,可以作为多种语言编译器的后台来使用。能够进行程序语言的编译期优化、链接优化、在线编译优化、代码生成。LLVM的项目是一个模块化和可重复使用的编译器和工具技术的集合

扯点历史原因,最早苹果也是用GCC进行一整套的编译链接的,但据说苹果对Objective-C语言打算加入很多新特性,但GCC开发者并不是很买账,一度导致苹果用的GCC版本与GCC主版本的分支割裂。随着2005年Chris大神加入苹果,苹果决定彻底放弃GCC作为编译后端,采用了全新的,高效的、模块化的、协议更放松的LLVM作为编译后端,苹果处于一个GCC编译前端/LLVM编译后端的状态。

并且GCC/LLVM的使用起来依然无法满足苹果的需求,甚至在苹果的GCC分支版本扩展都无法满足苹果的要求,于是苹果干脆从零去开发一个编译前端Clang,目的就是干掉越来越用的不顺手的GCC

于是形成了苹果现在的Clang/LLVM的编译前端+编译后端的编译体系。

有一种ClangPlugin的插件开发模式

可以支持在Clang编译出AST的时候,开发辅助插件去干预或者处理Clang生成的AST

比如一些OCLint这种,OC编码规范静态检查

比如直接把OC的AST转化成JS代码甚至类JSPatch代码(想到了什么?滴滴的DynamicCocoa)

一些关于ClangPlugin开发的介绍文章

一些开源社区很火的Clang转JS的项目

LLVM的发展

随着LLVM的发展,他模块化、高效的设计收到越来越多的组织青睐,不只是苹果,越来越多的语言,都开始选择用LLVM当做编译后端

如果你要开发一种新的编程语言,在词法语法解析完成后,你要做什么,肯定是生成中间代码,然后优化,最后编译成目标机器码。但是llvm 的中间代码不仅效率高而且可读性很好。那我们就直接拿LLVM过来用就好了,按照你的AST语法树,利用llvm给你的操作IR的接口,生成等价的IR中间码,生成IR了,之后所有的事情就交给llvm吧。

编译器发展故事一则:

看到了一条微博上面讲了一个故事

五十年代美国女程序员 grace hopper 发明第一个 compiler 之后,遭到顽固抵触,很多程序员情愿费时费力的把程序用人工翻译成机器代码,也不愿用她的发明。早期的机器代码就是像 A4 83 E7 C5 这类如看天书般的东西,使用 compiler 编译器后工作效率提高几十倍。但是一直到五十年代中期很多程序猿对编译器仍然强烈抵触。

当时程序猿的主流观点是,“让一个机械的进程,去完成编译高效代码这样一个伟大的工作,显然是个愚蠢和傲慢的白日梦”.

今天许多科学家和工程师,看轻 Ai 和自动驾驶技术部署实施的速度,是不是在犯同样的错误?

先不说后面对人工智能的评论,在计算机的远古时代,程序员们还在人工去写机器码,这简直是一个不敢想象的可怕的事情,而现在编译器已经发展到,我们程序员完全不需要掌握如此底层的知识就能让各种各样的程序运行起来,改变我们的世界。

虚拟机体系

那么Java呢?Java是这么操作的么?大家都知道Java有虚拟机JVM。

那么JavaScript呢?大家都知道脚本都是输入脚本引擎去run的,js有jscore or V8引擎。

他们还是遵循一样的流程吗?

Java

我们上面提到过,其实GCC后来也支持编译java,也就是说我们写的java代码也是要经过编译前端的全部流程,只不过到中间码这一步产生了分歧

  • C系的编译语言,生成中间码后,最终目标是生成汇编,也就是可执行文件
  • Java的代码经过编译前端后,会生成一种和中间码类似概念的字节码(ByteCode)

我们在前面建立过一个认知,机器能够识别能够执行的代码,是汇编那种的可执行文件,中间码还是字节码这种东西,我们的程序认识能够识别,能够遍历,但是机器是不认识的。

所以Java需要一个Java Runtime Envirnment,这里面就有JVM,Java运行环境就是可以识别这种字节码的运行环境,如果一个设备,内部安装好了Java Runtime Envirnment,他不需要通过汇编来执行代码,Java运行环境可以直接将字节码输入,然后在java自己的虚拟机JVM里来运行。

所以Java是这样一个编译流程

  • 词法分析
  • 语法分析
  • 生成AST
  • 生成字节码(这个东西其实对应的就类似LLVM中的IR中间码)

这就完成了Java程序的编译过程,我们就得到了字节码这个结果,就是jar包里面的内容。

Java的运行流程

  • 目标设备必须具备 Java Runtime Envirnment
  • 通过Java运行环境来执行字节码

JavaScript脚本语言

都说js是脚本语言,不需要编译,是完全解释执行的,真的是这样吗?

js也是需要进行编译的,但是使用的不同引擎,可能内部的执行流程完全不一样,拿JavaScriptCore来举例。

js代码会直接在运行的时候输入给JSCore,JSCore也会进行如下的步骤

  • 预处理
  • 词法分析
  • 语法分析
  • 生成语法树
  • 生成字节码
  • 用LLInt(Low Level Interpreter 解释器)执行字节码
  • 更低级别的JIT执行(好像还有2种,在运行负担变大的时候会用更厉害的JIT去执行)

看了这些怎么感觉和Java那个流程差不多啊?为什么这玩意叫解释性语言?

我觉得这里面有一个本质性区别,就是同一个字节码到底在什么时候生成?

JAVA的机制是,一次编译生成后,字节码可以每次运行的时候直接使用

JavaScript的机制是,每次运行的时候,再进行编译生成字节码,然后执行,下次运行,又要重新编译生成字节码,重新执行。

Web Assembly

令人激动的时候来了,web上的js每次运行都得实时编译运行,就不能像Java那样直接下发编好的字节码,一次编译,N次执行呢?

这个脑洞就是目前炙手可热的Web Assembly,WebAssembly是一种新的字节码格式。它的缩写是".wasm", .wasm 为文件名后缀,是一种新的底层安全的二进制语法。它被定义为“精简、加载时间短的格式和执行模型”

各大浏览器厂商纷纷跟进,也就是说,直接在浏览器里请求编译好的wasm字节码,就可以直接执行,不必再每次运行的时候像javascriptcore一样,每次都要编译一次。

根据我们本文建立的编译前端的认知,我们其实可以把任何语言(比如C++)经过词法/语法/语法树后生成wasm格式的字节码,换句话说,用C++开发web也不是不可能~

其实在Web里运行C/C++也并不是一定只有最新的WebAssembly,那个是最新的高运行效率的一套新标准

通过词法分析语法分析语法树字节码解释器的JavaScriptCore工作流程,我们可以重新设计

把面向JS语法的词法分析语法分析,替换成C的语法

这样不就实现了一个C/C++的虚拟机了

已经有开源项目这么做了

Github JSCPP项目

C-SMILE 一套支持C/C++ JS JAVA四种语言的scripting language

除此之外,这种自己定制一套字节码,自己实现一套可以执行字节码的虚拟机,让你想到了什么?腾讯的OCS

参考文献

强烈推荐阅读文献
《程序员的个人修养-装载,链接与库》

相关Link:

编译器(GNU & GCC & clang & llvm)

gcc编译器---前端和后端

计算机体系-编译体系漫游

llvm之旅第三站 - 认识LLVM IR

Kaileidoscope: LLVM Tutorial Chinese version(中文版)

LLVM和GCC的区别

LLVM相比于JVM,有哪些技术优势?

JavaScript引擎深度解析--基础篇(一)字节码生成及语法树的构建详情分析

虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩

我自己的相关文章Link:

技术爆炸

从antlr扯淡到一点点编译原理

技术感悟