大前端开发者需要了解的基础编译原理和语言知识

https://static.surmon.me/nodepress/image/xWebAssembly.jpg.pagespeed.ic.g9C5CkOvtB.jpg

这是一篇转载自 fullstack.blog 的非常优秀的文章,值得深度阅读和扩展学习,共勉。

正文部分:

在我刚刚进入大学,从零开始学习 C 语言的时候,我就不断的从学长的口中听到一个又一个语言,比如 C++、Java、Python、JavaScript 这些大众的,也有 Lisp、Perl、Ruby 这些相对小众的。 一般来说,当程序员讨论一门语言的时候,默认的上下文经常是:“用 xxx 语言来完成 xxx 任务”。 所以一直困扰着的我的一个问题就是,为什么完成某个任务,一定要选择特定的语言,比如安卓开发是 Java,前端要用 JavaScript,iOS 开发使用 Objective-C 或者 Swift。 这些问题的答案非常复杂,有的是技术原因,有的是历史原因,有的会考虑成本,很难得出统一的结论,只能 case-by-case 的分析。 这篇文章并非专门解答上述问题,而是希望通过介绍一些通用的概念,帮助读者掌握分析问题的能力,如果这个概念在实际编程中用得到,我也会举一些具体的例子。

在阅读本文前,不妨思考一下这几个问题,如果没有头绪,建议看完文章以后再思考一遍。如果觉得答案显而易见,恭喜你,这篇文章并非为你准备的:

  1. 什么是编译器,它以什么为分界线,分为前端和后端?
  2. Java 是编译型语言还是解释型语言,Python 呢?
  3. C 语言的编译器也是 C 语言,那它怎么被编译的?
  4. 目标文件的格式是什么样的,段表、符号表、重定位表有什么作用?
  5. Swift 是静态语言,为什么还有运行时库?
  6. 什么是 ABI,ABI 不稳定有什么问题?
  7. 什么是 WebAssembly,为什么要推出这门技术,用 C++ 代替 JavaScript 可行么?
  8. JavaScript 和 DOM API 是什么关系,JavaScript 可以读写文件么?
  9. C++ 代码可以自动转换成 Java 代码么,任意两种语言是否可以互转?
  10. 为什么说 Python 是胶水语言,它可以用来开发 iOS/Android 么?

编译原理

就像数学是一个公理体系,从简单的公理就能推导出各种高阶公式一样,我们从最基本的 C 语言和编译说起。

              
  • 1
  • 2
  • 3
  • 4
int main(void) { int a = strlen("Hello world"); // 字符串的长度是 11 return 0; }

相关的介绍编译过程的文章很多,读者应该都非常熟悉了,整个流程包括预处理、词法分析、语法分析、生成中间代码,生成目标代码,汇编,链接等。 已有的文章大多分析了每一步的逻辑,但很少谈实现思路,我会尽量用简单的语言来描述每一步的实现思路,相信这样有助于加深记忆。 由于主要谈的概念和思路,难免会有一些不够准确的抽象,读者学会抓重点就行。

预处理是一个独立的模块,它放在最后介绍,我们先看词法分析。

词法分析

最先登场的是编译器,它负责前五个步骤,也就是说编译器的输入是源代码,输出是中间代码。

编译器不能像人一样,一眼就看明白源代码的内容,它只能比较傻的逐个单词分析。 词法分析要做的就是把源代码分割开,形成若干个单词。这个过程并不像想象的那么简单。 比如举几个例子:

1.int t表示一个整数,而intt只是一个变量名。 2.int a()表示一个函数而非整数aint a ()也是一个函数。 3.a =没有具体价值,它可以是一个赋值语句,还可以是a == 1的前缀,表示一个判断。

词法分析的主要难点在于,前缀无法决定一个完整字符串的含义,通常需要看完整句以后才知道每个单词的具体含义。 同时,C 语言的语法也不简单,各种关键字,括号,逗号,语法等等都会给词法分析的实现增加难度。

词法分析的主要实现原理是状态机,它逐个读取字符,然后根据读到的字符的特点转换状态。比如这是 GCC 的词法分析状态机?(引用自《编译系统透视》):

https://static.surmon.me/17-11-15/4889038.jpg

如果自己实现的话,思路也不难。外面包一个循环,然后各种switch...case就完事了。词法分析应该算是最简单的一节。

语法分析

经过词法分析以后,编译器已经知道了每个单词,但这些单词组合起来表示的语法还不清楚。一个简单的思路是模板匹配,比如有这样的语句:

              
  • 1
int a = 10;

它其实表示了这么一种通用的语法格式:

类型 变量名 = 常量;

所以int a = 10; 当然可以匹配上这种模式。 同理,它不可能匹配类型 函数名(参数); 这种函数定义模式,因为两者结构不一致,等号无法被匹配。

语法分析比词法分析更复杂,因为所有 C 语言支持的语法特性都必须被语法分析器正确的匹配,这个难度比纯新手学习 C 语言语法难上很多倍。不过这个属于业务复杂性,无论采用哪种解决方案都不可避免,因为语法规则的数量就是这么多。

在匹配模式的时候,另一个问题在于上述的名词,比如 类型、参数,很难界定。 比如int是类型,long long也是类型,unsigned long long也是类型。 (int a)可以是参数,(int a, int b)也是参数,(unsigned long long a, long long double b, int *p)看起来能把人逼疯。

下面举一个简单的例子来解释int a = 10是如何被解析的,总的思路是归纳与分解。 我们把一个复杂的式子分割成若干部分,然后分析各个部分,这样可以简化复杂度。 对于int a = 10来说,他是一个声明,声明由两部分组成,分别是声明说明符和初始声明符列表。

声明 声明说明符 初始声明符列表
int a = 10 int a = 10
int fun(int a) int fun(int a)
int array[5] int array[5]

声明说明符比较简单,它其实是若干个类型的串联:

声明说明符 = 类型 + 类型的数组(长度可以为 0)

而且我们知道若干个类型连在一起又变成了声明说明符,所以上述等式等价于:

声明说明符 = 类型 + 声明说明符(可选)

再严谨一些,声明说明符还可以包括const这样的限定说明符,inline这样的函数说明符,和_Alignas这样的对齐说明符。 借用书中的公式,它的完整表达如下:

https://static.surmon.me/17-11-15/35690095.jpg

这才仅仅是声明语句中最简单的声明说明符,仅仅是几个类型和关键字的组合而已。后面的初始声明符列表的解析更复杂。如果有能力做完这些解析,恭喜你,成功的解析了声明语句。你会发现什么定义语句啦,调用语句啦,正妩媚的向你招手╮(╯▽╰)╭。

成功解析语法以后,我们会得到抽象语法树(AST: Abstract Syntax Tree)。 以这段代码为例:

              
  • 1
  • 2
  • 3
  • 4
  • 5
int fun(int a, int b) { int c = 0; c = a + b; return c; }

它的语法树如下:

https://static.surmon.me/17-11-15/96647133.jpg

语法树将字符串格式的源代码转化为树状的数据结构,更容易被计算机理解和处理。 但它距离中间代码还有一定的距离。

生成中间代码

以 GCC 为例,生成中间代码可以分为三个步骤:

  1. 语法树转高端 gimple
  2. 高端 gimple 转低端 gimple
  3. 低端 gimple 经过 cfa 转 ssa 再转中间代码

简单的介绍一下每一步都做了什么。

语法树转高端 gimple

这一步主要是处理寄存器和栈,比如c = a + b并没有直接的汇编代码和它对应,一般来说需要把a + b的结果保存到寄存器中,然后再把寄存器赋值给 c。所以这一步如果用 C 语言来表示其实是:

              
  • 1
  • 2
int temp = a + b; // temp 其实是寄存器 c = temp;

另外,调用一个新的函数时会进入到函数自己的栈,建栈的操作也需要在 gimple 中声明。

高端 gimple 转低端 gimple

这一步主要是把变量定义,语句执行和返回语句区分存储。比如:

              
  • 1
  • 2
  • 3
int a = 1; a++; int b = 1;

会被处理成:

              
  • 1
  • 2
  • 3
int a = 1; int b = 1; a++;

这样做的好处是很容易计算一个函数到底需要多少栈空间。

此外,return语句会被统一处理,放在函数的末尾,比如:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
if (1 > 0) { return 1; } else { return 0; }

会被处理成:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
if (1 > 0) { goto a; } else { goto b; } a: return 1; b: return 0;

低端 gimple 经过 cfa 转 ssa 再转中间代码

这一步主要是进行各种优化,添加版本号等,我不太了解,对于普通开发者来说也没有学习的必要。

中间代码的意义

其实中间代码可以被省略,抽象语法树可以直接转化为目标代码(汇编代码)。 然而,不同的 CPU 的汇编语法并不一致,比如 AT&T与Intel汇编风格比较 这篇文章所提到的,Intel 架构和 AT&T 架构的汇编码中,源操作数和目标操作数位置恰好相反。 Intel 架构下操作数和立即数没有前缀但 AT&T 有。 因此一种比较高效的做法是先生成语言无关,CPU 也无关的中间代码,然后再生成对应各个 CPU 的汇编代码。

生成中间代码是非常重要的一步,一方面它和语言无关,也和 CPU 与具体实现无关。 可以理解为中间代码是一种非常抽象,又非常普适的代码。 它客观中立的描述了代码要做的事情,如果用中文、英文来分别表示 C 和 Java 的话,中间码某种意义上可以被理解为世界语。

另一方面,中间代码是编译器前端和后端的分界线。 编译器前端负责把源码转换成中间代码,编译器后端负责把中间代码转换成汇编代码。

LLVM IR 是一种中间代码,它长成这样:

              
  • 1
  • 2
  • 3
  • 4
define i32 @square_unsigned(i32 %a) { %1 = mul i32 %a, %a ret i32 %1 }

生成目标代码

目标代码也可以叫做汇编代码。 由于中间代码已经非常接近于实际的汇编代码,它几乎可以直接被转化。 主要的工作量在于兼容各种 CPU 以及填写模板。 在最终生成的汇编代码中,不仅有汇编命令,也有一些对文件的说明。 比如:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
.file "test.c" # 文件名称 .global m # 全局变量 m .data # 数据段声明 .align 4 # 4 字节对齐 .type m, @objc .size m, 4 m: .long 10 # m 的值是 10 .text .global main .type main, @function main: pushl %ebp movl %esp, %ebp ...

汇编

汇编器会接收汇编代码,将它转换成二进制的机器码,生成目标文件(后缀是 .o),机器码可以直接被 CPU 识别并执行。 从目标代码可以猜出来,最终的目标文件(机器码)也是分段的,这主要有以下三个原因:

  1. 分段可以将数据和代码区分开。其中代码只读,数据可写,方便权限管理,避免指令被改写,提高安全性。
  2. 现代 CPU 一般有自己的数据缓存和指令缓存,区分存储有助于提高缓存命中率。
  3. 当多个进程同时运行时,他们的指令可以被共享,这样能节省内存。

段分离我们并不遥远,比如命令行中的 objcopy 可以自行添加自定义的段名,C 语言的__attribute((section(段名)))__可以把变量定义在某个特定名称的段中。

对于一个目标文件来说,文件的最开头(也叫作 ELF 头)记录了目标文件的基本信息,程序入口地址,以及段表的位置,相当于是对文件的整体描述。 接下来的重点是段表,它记录了每个段的段名,长度,偏移量。 比较常用的段有:

  • .strtab段: 字符串长度不定,分开存放浪费空间(因为需要内存对齐),因此可以统一放到字符串表(也就是.strtab段)中进行管理。字符串之间用\0分割,所以凡是引用字符串的地方用一个数字就可以代表。

  • .symtab: 表示符号表。符号表统一管理所有符号,比如变量名,函数名。符号表可以理解为一个表格,每行都有符号名(数字)、符号类型和符号值(存储地址)

  • .rel段: 它表示一系列重定位表。 这个表主要在链接时用到,下面会详细解释。

链接

在一个目标文件中,不可能所有变量和函数都定义在文件内部。 比如strlen函数就是一个被调用的外部函数,此时就需要把main.o这个目标文件和包含了strlen函数实现的目标文件链接起来。 我们知道函数调用对应到汇编其实是jump指令,后面写上被调用函数的地址,但在生成main.o的过程中,strlen()函数的地址并不知道,所以只能先用 0 来代替,直到最后链接时,才会修改成真实的地址。

链接器就是靠着重定位表来知道哪些地方需要被重定位的。 每个可能存在重定位的段都会有对应的重定位表。 在链接阶段,链接器会根据重定位表中,需要重定位的内容,去别的目标文件中找到地址并进行重定位。

有时候我们还会听到动态链接这个名词,它表示重定位发生在运行时而非编译后。 动态链接可以节省内存,但也会带来加载的性能问题,这里不详细解释,感兴趣的读者可以阅读《程序员的自我修养》这本书。

预处理

最后简单描述一下预处理。 预处理主要是处理一些宏定义,比如#define#include#if等。 预处理的实现有很多种,有的编译器会在词法分析前先进行预处理,替换掉所有#开头的宏,而有的编译器则是在词法分析的过程中进行预处理。 当分析到#开头的单词时才进行替换。 虽然先预处理再词法分析比较符合直觉,但在实际使用中,GCC 使用的却是一边词法分析,一边预处理的方案。

编译 VS 解释

总结一下,对于 C 语言来说,从源码到运行结果大致上需要经历编译、汇编和链接三个步骤。 编译器接收源代码,输出目标代码(也就是汇编代码),汇编器接收汇编代码,输出由机器码组成的目标文件(二进制格式,.o 后缀),最后链接器将各个目标文件链接起来,执行重定位,最终生成可执行文件。

编译器以中间代码为界限,又可以分前端和后端。 比如 clang 就是一个前端工具,而 LLVM 则负责后端处理。 另一个知名工具 GCC(GNU Compile Collection)则是一个套装,包揽了前后端的所有任务。 前端主要负责预处理、词法分析、语法分析,最终生成语言无关的中间代码。 后端主要负责目标代码的生成和优化。

关于编译原理的基础知识虽然枯燥,但掌握这些知识有助于我们理解一些有用的,但不太容易理解的概念。 接下来,我们简单看一下别的语言是如何运行的。

Java

在 Java 代码的执行过程中,可以简单分为编译和执行两步。 Java 的编译器首先会把.java格式的源码编译成.class格式的字节码。 字节码对应到 C 语言的编译体系中就是中间码,Java 虚拟机执行这些中间码得到最终结果。

回忆一下上文对中间码的解释,一方面它与语言无关,仅仅描述客观事实。 另一方面它和目标代码的差距并不大,已经包括了对寄存器和栈的处理,仅仅是抽象了 CPU 架构而已,只要把它具体化成各个平台下的目标代码,就可以交给汇编器了。

解释型语言

一般来说我们也把解释型语言叫做脚本语言,比如 Python、Ruby、JavaScript 等等。 这类语言的特点是,不需要编译,直接由解释器执行。 换言之,运行流程变成了:

源代码 -> 解释器 -> 运行结果

需要注意的是,这里的解释器只是一个黑盒,它的实现方式可以是多种多样的。 举个例子,它的实现可以非常类似于 Java 的执行过程。 解释器里面可以包含一个编译器和虚拟机,编译器把源码转化成 AST 或者字节码(中间代码)然后交给虚拟机执行,比如 Ruby 1.9 以后版本的官方实现就是这个思路。

至于虚拟机,它并不是什么黑科技,它的内部可以编译执行,也可以解释执行。 如果是编译执行,那么它会把字节码编译成当前 CPU 下的机器码然后统一执行。 如果是解释执行,它会逐条翻译字节码。

有意思的是,如果虚拟机是编译执行的,那么这套流程和 C 语言几乎一样,都满足下面这个流程:

源代码 -> 中间代码 -> 目标代码 -> 运行结果

下面是重点!!!

下面是重点!!!

下面是重点!!!

因此,解释型语言和编译型语言的根本区别在于,对于用户来说,到底是直接从源码开始执行,还是从中间代码开始执行。 以 C 语言为例,所有的可执行程序都是二进制文件。 而对于传统意义的 Python 或者 JavaScript,用户并没有拿到中间代码,他们直接从源码开始执行。 从这个角度来看, Java 不可能是解释型语言,虽然 Java 虚拟机会解释字节码,但是对于用户来说,他们是从编译好的.class文件开始执行,而非源代码。

实际上,在 x86 这种复杂架构下,二进制的机器码也不能被硬件直接执行,CPU 会把它翻译成更底层的指令。 从这个角度来说,我们眼中的硬件其实也是一个虚拟机,执行了一些“抽象”指令,但我相信不会有人认为 C 语言是解释型语言。 因此,有没有虚拟机,虚拟机是不是解释执行,会不会生成中间代码,这些都不重要,重要的是如果从中间代码开始执行,而且 AST 已经事先生成好,那就是编译型的语言。

如果更本质一点看问题,根本就不存在解释型语言或者编译型语言这种说法。 已经有人证明,如果一门语言是可以解释的,必然可以开发出这门语言的编译器。 反过来说,如果一门语言是可编译的,我只要把它的编译器放到解释器里,把编译推迟到运行时,这么语言就可以是解释型的。 事实上,早有人开发出了 C 语言的解释器:

C 源代码 -> C 语言解释器(运行时编译、汇编、链接) -> 运行结果

我相信这一点很容易理解,规范和实现是两套分离的体系。 我们平常说的 C 语言的语法,实际上是一套规范。 理论上来说每个人都可以写出自己的编译器来实现 C 语言,只要你的编译器能够正确运行,最终的输出结果正确即可。 而编译型和解释型说的其实是语言的实现方案,是提前编译以获得最大的性能提高,还是运行时去解析以获得灵活性,往往取决于语言的应用场景。 所以说一门语言是编译型还是解释型的,这会非常可笑。 一个标准怎么可能会有固定的实现呢?之所以给大家留下了 C 语言是编译型语言,Python 是解释型语言的印象,往往是因为这门语言的应用场景决定了它是主流实现是编译型还是解释型。

自举

不知道有没有人思考过,C 语言的编译器是如何实现的?实际上它还是用 C 语言实现的。 这种自己能编译自己的神奇能力被称为自举(Bootstrap)。

乍一看,自举是不可能的。 因为 C 语言编译器,比如 GCC,要想运行起来,必定需要 GCC 的编译器将它编译成二进制的机器码。 然而 GCC 的编译器又如何编译呢……

解决问题的关键在于打破这个循环,我们可以先用一个比 C 语言低级的语言来实现一个 C 语言编译器。 这件事是可能做到的,因为这个低级语言必然会比 C 语言简单,比如我们可以直接用汇编代码来写 C 语言的编译器。 由于越低级的语言越简单,但表达能力越弱,所以用汇编来写可能太复杂。 这种情况下我们可以先用一个比 C 语言低级但比汇编高级的语言来实现 C 语言的编译器,同时用汇编来实现这门语言的编译器。 总之就是不断用低级语言来写高级语言的编译器,虽然语言越低级,它的表达能力越弱,但是它要解析的语言也在不断变简单,所以这件事是可以做到的。

有了低级语言写好的 C 语言编译器以后,这个编译器是二进制格式的。 此时就可以删掉所有的低级语言,只留一个二进制格式的 C 语言编译器,接下来我们就可以用 C 语言写编译器,再用这个二进制格式的编译器去编译 C 语言实现的 C 语言编译器了,于是完成了自举。

以上逻辑描述起来比较绕,但我想多读几遍应该可以理解。 如果实在不理解也没关系,我们只要明白 C 语言可以自举是因为它可以编译成二进制机器码,只要用低级语言生成这个机器码,就不再需要低级语言了,因为机器码可以直接被 CPU 执行。

从这个角度来看,解释型语言是不可能自举的。 以 Python 为例,自举要求它能用 Python 语言写出来 Python 的解释器,然而这个解释器如何运行呢,最终还是需要一个解释器。 而解释器体系下, Python 都是从源码经过解释器执行,又不能留下什么可以直接被硬件执行的二进制形式的解释器文件,自然是没办法自举的。 然而,就像前面说的,Python 完全可以实现一个编译器,这种情况下它就是可以自举的。

所以一门语言能不能自举,主要取决于它的实现形式能否被编译并留下二进制格式的可执行文件。

运行时

本文的读者如果是使用 Objective-C 的 iOS 开发者,想必都有过在面试时被 runtime 支配的恐惧。 然而,runtime 并非是 Objective-C 的专利,绝大多数语言都有这个概念。 所以有人说 Objective-C 具有动态性是因为它有 runtime,这种说法并不准确,我觉得要把 Objective-C 的 runtime 和一般意义的运行时库区分开,认识到它仅仅是运行时库的一个组成部分,同时还是要深入到方法调用的层面来谈。

运行时库的基本概念

以 C 语言为例,有非常多的操作最终都依赖于 glibc 这个动态链接库。 包括但不限于字符串处理(strlenstrcpy)、信号处理、socket、线程、IO、动态内存分配(malloc)等等。 这一点很好理解,如果回忆一下之前编译器的工作原理,我们会发现它仅仅是处理了语言的语法,比如变量定义,函数声明和调用等等。 至于语言的功能, 比如内存管理,內建的类型,一些必要功能的实现等等。 如果要对运行时库进行分类,大概有两类。 一种是语言自身功能的实现,比如一些內建类型,内置的函数;另一种则是语言无关的基础功能,比如文件 IO,socket 等等。

由于每个程序都依赖于运行时库,这些库一般都是动态链接的,比如 C 语言的 (g)libc。 这样一来,运行时库可以存储在操作系统中,节省内存占用空间和应用程序大小。

对于 Java 语言来说,它的垃圾回收功能,文件 IO 等都是在虚拟机中实现,并提供给 Java 层调用。 从这个角度来看,虚拟机/解释器也可以被看做语言的运行时环境(库)。

swift 运行时库

经过这样的解释,相信 swift 的运行时库就很容易理解了。 一方面,swift 是绝对的静态语言,另一方面,swift 毫无疑问的带有自己的运行时库。 举个最简单的例子,如果阅读 swift 源码就会发现某些类型,比如字符串(String),或者数组,再或者某些函数(print)都是用 swift 实现的,这些都是 swift 运行时库的一部分。 按理说,运行时库应该内置于操作系统中并且和应用程序动态链接,然而坑爹的 Swift 在本文写作之时依然没有稳定 ABI,导致每个程序都必须自带运行时库,这也就是为什么目前 swift 开发的 app 普遍会增加几 Mb 包大小的原因。

说到 ABI,它其实就是一个编译后的 API。 简单来说,API 是描述了在应用程序级别,模块之间的调用约定。 比如某个模块想要调用另一个模块的功能,就必须根据被调用模块提供的 API 来调用,因为 API 中规定了方法名、参数和返回结果的类型。 而当源码被编译成二进制文件后,它们之间的调用也存在一些规则和约定。

比如模块 A 有两个整数 a 和 b,它们的内存布局如下:

模块 A
初始地址
a
b

这时候别的模块调用 A 模块的 b 变量,可以通过初始地址加偏移量的方式进行。

如果后来模块 A 新增了一个整数 c,它的内存布局可能会变成:

模块 A
初始地址
c
a
b

如果调用方还是使用相同的偏移量,可以想见,这次拿到的就是变量 a 了。 因此,每当模块 A 有更新,所有依赖于模块 A 的模块都必须重新编译才能正确工作。 如果这里的模块 A 是 swift 的运行时库,它内置于操作系统并与其他模块(应用程序)动态链接会怎么样呢?结果就是每次更新系统后,所有的 app 都无法打开。 显然这是无法接受的。

当然,ABI 稳定还包括其他的一些要求,比如调用和被调用者遵守相同的调用约定(参数和返回值如何传递)等。

JavaScript 那些事

我们继续刚才有关运行时的话题,先从 JavaScript 的运行时聊起,再介绍 JavaScript 的相关知识。

本文于 2017/11/16 上午 发布在 Code 分类下,当前已被围观 544 次

相关标签:计算机学习Web开发

永久地址:https://surmon.me/article/77

版权声明:自由转载-署名-非商业性使用  |  Creative Commons BY-NC 3.0 CN
  • sushant
    SushantWindows 10Chrome | 62IN#537

    Loved your blog, even more loved this article. I have to translate it through google translate first but it's worth it.

  • Surmon
    SurmonMac OS XChrome | 64CN - 厦门市#538

    回复 #537 @sushant

    Thank you.