搞懂你敲的代码是如何被计算机执行的

1,457 阅读13分钟

本文将对计算机执行代码的原理进行全面解析,深入讨论我们写的代码是如何被计算机执行的,涵盖机器码、字节码、AST抽象语法树、编译器、解释器、JIT、解释型语言、编译型语言等,最后再落实到V8的代码执行原理上,挨个名词捋,不信你看不懂。

机器码

人与机器是怎么沟通交互的呢,计算机又是怎么知道我们的意图的?是通过一连串指令(操作者的命令)来表达的,这个指令序列就称为程序。一个指令规定计算机CPU执行一个基本操作。一个程序规定计算机完成一个完整的任务。一种计算机所能识别的一组不同指令的集合,称为该种计算机的指令集合或指令系统。

机器码(机器语言指令)就是CPU能够认识和理解的有一定结构的用来表达指令的编码,我们知道CPU只能理解二进制,那么机器码就是某条指令在CPU上的一串二进制代码,这串代码包含:

  • 操作码,如+-*/等
  • 操作数
  • 操作结果存储地址
  • 下条指令的地址

机器码是可以被电脑CPU直接读取运行的机器指令,运行速度最快,但是非常晦涩难懂,也比较难编写,一般从业人员接触不到,类似计算机厂商会有直接应用。

为了编写计算机能运行的程序,早期的程序设计均使用机器语言。程序员们将用0,1数字编成的程序代码打在纸带或卡片上(纸带或卡片就是程序存储介质(类比现在的闪存和内存),1打孔,0不打孔,再将程序通过纸带机或卡片机输入计算机,进行运算。这样的机器语言由纯粹的0和1构成,十分复杂,不方便阅读和修改,也容易产生错误。程序员们很快就发现了使用机器语言带來的麻烦,它们难于辨别和记忆,给整个产业的发展带来了障得,于是汇编语言以及其他高级语言产生了。

现在各种编程语言中,什么python,java等等语言,实际上都是为了简化一定编程场景所面临的问题,不断演变出来的程序语言。为的就是在某些场景下能够更加方便,快捷,高效的实现代码的编写,这些语言都离不开对应语言编译器(或解释器)的支持来把源代码变成目标代码,目标代码再通过各种形式最终转为机器码执行。

  • 如果源代码在操作系统上运行:目标代码就是“汇编代码”。再通过汇编和链接的过程形成可执行的机器码文件,然后通过加载器加载到操作系统执行。

    在同一语言处理系统中,编译器产生汇编语言而不是机器语言的好处是什么?

    因为汇编语言比较容易输出和调试,方便优化,汇编语言到机器语言由硬件实现即可,这样分层处理可以有效降低编译器编写复杂性,提高效率。

  • 如果源代码在虚拟机(解释器)上运行:目标代码就是“解释器可以理解的中间形式的代码”,比如字节码(中间代码)、AST语法树。

字节码

字节码(Byte-code)是一种包含执行程序、由一序列 op 代码/数据对组成的二进制文件。字节码是一种中间码,它比机器码更抽象。它经常被看作是包含一个执行程序的二进制文件。需要直译器转译后才能成为机器码,被CPU执行。

字节码也要转为机器码

我们知道JS是一种脚本语言,他运行在不同的平台,包括Windows、Linux、MacOS、iOS、Android 等。不管什么样的语言,最终都是要变成机器码的,而不同平台由于有不同的处理器架构以及对应的指令集,所以就需要有一个中间层来负责对应平台的指令转换成对应的机器码,使得脚本语言的开发者无需关心底层这些复杂的硬件和指令系统。这个中间层就是我们说的虚拟机,而在虚拟机上面执行的代码就是字节码

虚拟机是一种抽象化的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。虚拟机有自己完善的架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。虚拟机屏蔽了与具体操作系统平台相关的信息,使得程序只需生成在虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行

例如JavascriptCore内部的SquirrelFish、V8的Ignition(下文有说明),以及React Native专用的Hermes。

AST 抽象语法树

高级语言是开发者可以理解的语言,但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说,它们可以理解的就是 AST 了。所以无论你使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST。

抽象语法树(abstract syntax code,AST)是源代码的抽象语法结构的树状表示,编译器或者解释器后续的工作都需要依赖于 AST,而不是源代码。

AST 在很多项目中有着广泛的应用。其中最著名的一个项目是 Babel。Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转为 ES5 代码,这意味着你可以现在就用 ES6 编写程序,而不用担心现有环境是否支持 ES6。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

编译器和解释器

  • 解释器直接执行用编程语言编写的指令程序;编译器把源代码转换成(翻译)低级语言的程序。

    babel还有tsc也属于编译器,tsc是TypeScript的编译器。

  • 编译器生成一个独立的程序,而解释的程序总是需要解释器来运行。

  • 解释器比较容易让用户实现自己跨平台的代码,比如java,php等,同一套代码可以几乎在所有的操作系统上执行,而无需根据操作系统做修改;编译器的目的就是生成目标代码再由连接器生成可执行的机器码,这样的话需要根据不同的操作系统编译代码。

  • 解释器不会一次把整个程序转译出来,每次运行程序时都要先转成另一种语言再作运行,而且是逐行转译执行,因此解释器的程序整体运行速度比较缓慢。编译器一次性将所有源代码编译为一个可执行程序,一次编译可重复执行,但是首次启动花费的时间更多。

  • 解释器可以迅速开始工作并运行代码,不必完成整个编译阶段,就能开始运行代码了,这个特点很适用于JavaScript,对于 Web 开发者来说能够快速运行自己的代码(只有首次有优势)是很重要的。但是,当不止一次地运行相同代码时,解释器的缺点就出现了 。例如,在一个循环中,它将不得不 一次又一次的做出同样的 翻译 。

  • 编译器有更多的时间对代码进行分析和修改,以便代码能够运行的更快,这种修改被称为 优化(optimization)。解释器 作用在 运行时(runtime),所以在 翻译 时没有足够的时间来计算这些 优化 。

即时编译器 (JIT):两全其美

为了避免解释器的低效性,即遇到相同代码时还会重新翻译一遍,浏览器开始混合加入编译器。

它们将 监视器作为一个新部件 添加到浏览器引擎中。监视器 在代码开始运行时开启监控,并记录代码 运行的次数 和 使用到的类型,最开始在解释器中运行所有代码,如果同一行代码运行过几次,那么这段代码就被称为 warm ,如果运行了非常多次,那么这段代码就被称为 hot,即HotSpot热点代码。warm和hot的代码被分别放进基线编译器和优化编译器中进行优化并且编译,如果 监视器 发现代码执行中出现了 执行过的相同代码(且使用的变量类型相同),那么就会取出 已编译 的版本,而不再走解释器解释执行,这有利于加速代码运行。

它把字节码编译成可立即执行的指定平台的可执行代码(机器码)。其实字节码配合解释器和编译器是最近一段时间很火的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,我们把这种技术称为即时编译(JIT)。除了 V8 使用了“字节码 + JIT”技术之外,苹果的 SquirrelFish Extreme 和 Mozilla 的 SpiderMonkey 也都使用了该技术。

尽管JIT的性能仍不如静态类型的编译语言,但它已经在性能上带来了非常可观的收益,尤其是在代码运行时间长的情况下,一但获得warm或hot标识的编译过的代码,性能显着。

JIT 即把解释器解释代码过程中的热点代码编译成可立即执行的机器码,再次命中热点代码时不再重新解释,能够提高性能。

大家感兴趣的话强烈推荐阅读Tapir大佬的文章,讲的比较清楚即时编译器(JIT) 速成课

解释型语言和编译型语言

编译型语言

编译型语言要求使用编译器一次性将所有源代码编译为一个可执行程序,一次编译可重复执行。代表语言有C、C++、Golang、汇编等。

优点:

  • 编译型语言最大的优势之一就是其执行速度快
  • 编译型程序比解释型程序消耗的内存更少

缺点:

  • 编译型语言一般不能跨平台
  • 编译器比解释器要难写得多。
  • 编译器在调试程序时提供不了多少帮助——有多少次在你的C语言代码中遇到一个“空指针异常”时,需要花费好几个小时来明确错误到底在代码中的什么位置。
  • 可执行的编译型代码要比相同的解释型代码大许多。例如,C/C++的.exe文件要比同样功能的Java的.class文件大很多。
  • 编译型程序不支持代码中实现安全性——例如,一个编译型的程序可以访问内存的任何区域,并且可以对你的PC做它想做的任何事情(大部分病毒是使用编译型语言编写的),由于松散的安全性和平台依赖性,编译型语言不太适合开发因特网或者基于Web的应用。

编译型语言执行流程:

编译型语言执行流程

  • 这里二进制文件就是机器码文件

解释型语言

解释型语言是使用解释器一边执行一边转换,用到些源代码就转换哪些,不会生成可执行程序。代表语言有JavaScript、Python、PHP、Shell等。

优点:

  • 解释型语言可以跨平台
  • 解释型语言提供了极佳的调试支持。因为运行环境不仅指明了异常的性质,而且给出了异常发生位置具体的行号和函数调用顺序(著名的堆栈跟踪信息)。这样的便利是编译型语言所无法提供的。
  • 解释器比编译器容易实现
  • 解释型语言安全性比编译型语言高——这是互联网应用迫切需要的
  • 中间语言代码的大小比编译型可执行代码小很多

缺点:

  • 解释型语言存在一些严重的缺点。解释型应用占用更多的内存和CPU资源。这是由于,为了运行解释型语言编写的程序,相关的解释器必须首先运行。解释器是复杂的,智能的,大量消耗资源的程序并且它们会占用很多CPU周期和内存。
  • 解释器运行过程很耗费性能,所以会比编译型程序慢很多。
  • 解释器也会做很多代码优化,运行时安全性检查;这些额外的步骤占用了更多的资源并进一步降低了应用的运行速度。

解释型语言执行流程:

解释型语言执行流程

JS代码解析执行过程

js执行过程包含四个模块:

  • Parser:解析器,此处进行此法和语法分析,将JavaScript源码转换为AST抽象语法树并且生成执行上下文(执行上下文在事件循环的文章中有提到);
  • Ignition:这是一个虚拟机,运行着解释器(Interpreter),解释器会根据 AST 生成字节码,并解释执行字节码(字节码最终也会转为机器码执行),同时收集优化编译所需的信息(即上文提到的监听器监听代码运行次数);
  • TurboFan:编译器(JIT编译器Compiler),如果发现有热点代码(HotSpot), 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,直接执行机器码就省去了字节码“翻译”为机器码的过程,这样就大大提升了代码的执行效率。
  • Garbage Collector:垃圾回收模块,负责将程序不再需要的内存空间回收;

js执行过程

js执行过程

为什么要用字节码?

  1. 机器码占用内存空间很大,使用字节码可以节省更多内存空间给引擎做其他优化,如JIT等。

开始 V8 并没有字节码,而是直接将 AST 转换为机器码,早期的手机上(特别是 512M 内存)内存占用问题:消耗大量的内存来存放转换后的机器码,为了解决内存占用问题,引入字节码

机器码和字节码占用空间对比:

机器码和字节码占用空间对比

  1. 代码复杂度太高。上文提到过不同的CPU架构对应的指令集是完全不同的,而市面上CPU架构的种类又非常多,那么将AST转化为二进制代码编译器和解释器要针对不同的CPU架构编写代码,这个复杂程度及工作量可想而知,而对字节码进行编译可以大大的减少这个工作量。