高级语言是如何运行的

您所在的位置:网站首页 电脑运行c语言程序的方法 高级语言是如何运行的

高级语言是如何运行的

2024-07-08 13:24| 来源: 网络整理| 查看: 265

《高级语言是如何运行的——语言的运行方式》源站链接,阅读体验更佳~ 在《计算机是如何读懂高级语言的——编译过程简述》一文中,我们介绍了机器是如何读懂我们的高级语言的代码的,那么我们的代码又是如何在机器上得以运行的呢?

高级语言的运行方式有两大类,一类是直接把我们的高级语言的代码翻译为机器码,由机器直接运行,采用这种方式运行的语言我们称之为编译运行的语言;另一种就是再为我们的高级语言专门写一个程序,这个程序的作用就是解释执行我们的高级语言的代码;采用这种方式运行的语言我们称之为解释执行的语言。

根据运行方式的不同,高级程序设计语言大致可以分为编译运行和解释运行两种,而实际上也有非常多的语言是介于这两种方式之间的,比如说Java。

我们上一节的时候已经简要描述了高级语言代码的编译过程,我们现在再来回顾一下:

编译过程简图

对于编译器前端的三个过程,无论是编译运行的语言还是解释运行的语言,都是无法避免的,我们的高级代码要顺利执行,首先要做的就是让机器能够理解我们的代码。

我们再仔细推敲一下就不难发现,其实中端所述的两个过程——生成中间代码和对中间代码的结构进行优化也是必要的,因为不论是生成编译运行所需的目标代码还是直接对高级代码进行解释执行,基于处理过的中间代码显然对机器更加友好,性能也更好。

编译运行

编译运行的语言的特点是将某种高级程序设计语言的源代码直接转换成特定平台(操作系统)中可以识别的可执行文件,从而让代码得以运行。

以C语言为例,编译运行的语言从编写源代码到执行代码大致需要经历如下的几个步骤:

编写好源代码之后使用特定平台上针对C语言的编译器对源代码进行编译。编译又可以分为词法分析,语法分析和语义分析三个步骤,其中的每一个步骤都会产生一下代码的元数据信息,这些我们在《计算机是如何读懂高级语言的——编译过程简述》一文中已经做过简单的介绍了,这里就不详细介绍了。

在编译的过程中编译器会对一些基本的语法和语义错误进行检测,直到源代码可以正常编译。编译完成之后每一个源文件都会产生一个目标文件。目标文件中的代码称为目标代码,一般来说编译型的语言不会直接编译成机器码,而是编译成汇编代码。

编译产生的每一个目标文件都有一个文件头,里面存储了这个目标文件的一些元数据信息,供下一个阶段使用。

对编译生成的目标文件进行静态链接操作,生成可执行文件。目标文件之间可能存在相互引用,这些引用的信息都存在了每个目标文件的文件头中,而连接所做的工作就是根据这些元数据信息把编译所得的所有的目标文件连接成一个文件,再与编译器所提供的函数库相连接形成一个整体,从而生成一个可供特点平台直接执行的可执行文件。

加载运行,在有了可执行文件之后,就可以把可执行文件加载到内存中运行了,当然在加载到内存中运行之前还要经过动态链接和汇编等步骤。

编译运行的语言在加载到内存中的时候就已经是一堆完整的机器码了,对所有指令和数据的解析在运行时都是完整的,包括我们在运行时要动态申请的内存空间的首地址要存储在哪一个内存空间或寄存器中这在程序加载到内存之中的时候都是已经完全可以确定的。

基于这样的特点,我们想要在代码中实现运行时对元数据的获取和分析(反射机制)就变得比较麻烦:编译器必须在编译时就把运行时可能需要的元数据信息编译到数据中,而且还要负责组织这些元数据,并且提供访问这些元数据的函数,这样我们才能在源代码的层次获取运行时的元数据并进行逻辑的开发。而且程序开始运行之后其内存的管理要自己负责,一旦处理不好就会发生内存溢出。

但是,世事无绝对,也不是所有的编译运行的语言都没有提供反射和自动内存管理的功能,具有反射机制和垃圾回收机制的编译运行语言的典型是Golang,我对这门语言的内在机制了解的不是特别多,只是停留在能简单使用的层次,以后可能会花一些精力去深入学习。

综上所述,编译运行的语言可以获得较高的执行效率,但是灵活性有所欠缺。

解释执行

解释型的语言是指使用专门的解释器对源程序逐行解释并立即执行的语言。我们上文中也已经提到过,不论是编译运行的语言还是解释运行的语言,编译器的前端所对应的三个阶段都是不可避免的,而且为解释器生成更易于解释执行的中间代码也是有必要的。

所以我们在接下来讨论解释运行的语言的时候假定的都是解释器所解释的是经过处理之后的中间代码。而且中间代码的表现形式一般都是对机器更加友好的二进制形式的代码,我们一般称之为字节码,所以我们在下文进行讨论的时候就直接使用字节码这个术语了。

解释执行的语言的运行要依托于一个虚拟的运行环境,我们称其为解释器或者是虚拟机,下文统一使用虚拟机这个术语。虚拟机,顾名思义即使虚拟的机器,它从操作系统平台申请了一些硬件资源,并且在解释执行代码的时候管理这些资源,而代码的执行上下文也都被虚拟机管理着。

这个时候我们的代码在运行中的所有副作用对于虚拟机来说都是可控的,而且程序的元数据信息相对来说更加丰富也更加完整,运行时的可控性也更高,可以进行一些更加复杂的运行时校验,同时提供反射和自动内存管理的能力相对来说更加容易。

虚拟机解释执行字节码有两种实现思路:分别是直接解释执行和编译为本地代码后执行,下面我们假设使用C语言编写一个虚拟机来简单描述以下这两种方式。

通过C语言解释执行

将字节码翻译成对应的可以运行的机器码,一种可行的方法是,使用C程序,将字节码的每一条指令,都逐行的解释成C程序。当解释执行字节码的程序——虚拟机程序被编译后,字节码所对应的C程序被一起编译成本地机器码,于是虚拟机在解释执行字节码的时候,自然就会执行C程序所对应的本地机器码。

我直接这么干巴巴的说,你可能不太明白,下面我们通过一个简单的例子来感受一下:

计算两个整数之和。如果我们直接使用C语言进行编码,代码如下:

#include int add(int a, int b); int main() { int a = 5; int b = 3; int r = add(a, b); printf("%d + %d = %d", a, b, r); return 0; } int add(int a, int b) { return a + b; }

假设现在我们发明了某种脚本语言,这门语言的编译器会产生一种中间代码;它的解释器是使用C语言编写的,它以行为单位读入中间代码并解释执行。

假设该语言的中间语言中有一条指令来实现两个正数相加,这条指令的助记符是iadd,其对应的数字唯一编号为0x01,通过C语言直接解释执行这条指令的代码大致如下:

#include // 解释执行操作码的函数 int run(int operation_code, void* operands); // iadd操作码对应的函数实现 int iadd(int, int); // 我们这里只是写了一个main函数来进行简单的测试 int main() { // 这里模拟了读出的指令为iadd,a和b是这条指令的操作数 int a = 5; int b = 6; int operands[] = {a, b}; int code = 0x01; int res = run(code, operands); printf("iadd res is %d", res); } int run(int operation_code, void* operands) { switch (operation_code) { case 0x01: // 整数相加指令,把指针类型转为int* int* operand_ptr = operands; // iadd指令会有两个操作数 return iadd(operand_ptr[0], operand_ptr[1]); default: return -1; } } int iadd(int a, int b) { return a + b; }

上面测试的运行结果如下:

image-20200920132038371

上面的代码及其简陋,而且’虚拟机’的实现也没有考虑为程序管理上下文等功能,但是其完全可以体现出直接解释执行的含义:对于每一条字节码指令,都对应着一个C函数,当虚拟机读取到一条指令的时候会调用对应的C语言函数,在虚拟机管理的硬件资源中组织程序的上下文,实现程序的副作用。

这样的实现方式非常直观易懂,但是其缺陷也是相当明显——效率极其低下。使用这种方式实现虚拟机我们不仅要考虑被解析的语言的上下文,同时还要考虑宿主语言的上下文,对于机器来说,其实它是在同时维护两种语言的上下文,效率能高才怪。

使用这种实现方式的虚拟机的典型就是Java诞生之初的Classic VM,其实现原理非常简单,而且没有采用准确式内存管理机制,内存管理必须基于句柄(handle),这在内存管理的时候多了一次查询的开销,进一步降低了效率。Java语言运行慢的不良印象也是在那个时候产生的。

不过在Java 1.3版本之后,Classic VM就被 HotSpot VM取代了,HotSpot VM不仅采用了准确式内存管理,而且在解释字节码的时候采用了直接编译为机器码的方式,这使得Java语言的运行效率得到了很大的提升,下面我们就来简单介绍一下直接把字节码翻译为机器码并运行的基本原理。

直接翻译为机器码

在介绍这种方式之前,我们有几个概念要先搞清楚:

指令和数据是同构的

在《指令也是数据?浅谈计算机体系结构》一文中我们不止一次强调这个概念,冯·诺依曼体系结构有一个非常明显的特点——它一视同仁地对待指令和数据,它规定了指令和数据统一用二进制进行编码,而且指令和数据是存储在同一个存储器中(同一个内存空间中)的,借着将指令当成一种特别类型的静态数据,一台存储程序型电脑可轻易改变其程序,并在程控下改变其运算内容。

这是我们接下来要介绍的虚拟机执行字节码指令的基本原理。

CS:IP

这里我们先解释一下什么是CS和IP。这是物理CPU内部的两个寄存器。对于一个CPU而言,这两个寄存器的地位是无可替代的,因为CPU从内存中取指令就是依靠这两个寄存器,CS:IP永远指向下一条要执行的指令的内存地址。

我们在代码中进行分支和循环的流程控制以及对函数(子程序)的调用其实都是通过修改CS:IP的指向来实现的。

搞明白了这两点,把字节码直接翻译成机器码并执行的做法也就不言自明了。只是我们如果通过C语言来实现这个过程的话,怎么实现把代码跳转到我们编译好的字节码呢?答案也很明显——函数,只不过我们这里要利用的是指向函数的指针。

我们修改一下上面的例子,把执行字节码的方式修改成翻译成机器码然后直接执行的方式,代码大致如下:

#include #include // 编译函数,用来把一段字节码编译成机器码,并返回编译好的机器码的入口地址 void* compile(char* code); // 我们这里只是写了一个main函数来进行简单的测试 int main() { // 模拟读到的字节码 char code[] = "iadd 2, 3"; // 对字节码进行编译,假设编译出来的内容是 "\x55\x89\xe5\x8b\45\x0c\8b\x55\x08\x01\x0b\x5d\xc3" void* machine_code= compile(code); // 声明一个指向函数的指针,并把它指向我们编译好的字节码的入口地址 void (*func)() = machine_code; // 通过函数调用跳转到字节码编译好的机器码进行执行 func(); //(* func)() // 这种写法也是可以的,这是一个历史遗留问题,有兴趣的可以自行了解一下。 free(machine_code); // 执行完成之后,释放字节码编译好的机器码的内存。 } void* compile(char* code) { // 分配1k的内存用来存储编译生成的机器码 void* machine_code = malloc(1024*8); 把code编译生成的机器码放到刚分配的内存中 // 这里是伪代码 return machine_code; }

可以看到这个实现过程完全依赖我们上面所提到的两个概念——指令和数据是同构的、重定向CS:IP。这个代码不太方便在我的机器上进行测试,所以就不提供运行示例了。

我们在把机器码编译成字节码的时候要负责参数的准备、返回值的准备等内容,而其在其中要负责所有的副作用,所以生成的机器码所代表的函数不用接收参数,也不用有返回值。

从这里我们就可以看出,在我们把字节码直接编译成机器码的时候,只需要考虑字节码程序的上下文的维护和副作用就可以了,而不用考虑宿主语言的上下文的组织,这就为代码的执行节省了很大的开支。所以采用这种方式解释执行字节码性能上会有很大的提升。

但是由于要考虑字节码代码上下文的组织,所以其相比于编译运行的语言,生成的机器码可能会多出一个甚至多个数量级,在性能上还是和直接编译运行的语言有一定的差距。例如如下对两个数求和的C语言代码:

int iadd(int a, int b) { return a + b; }

对其进行编译之后生成的机器码大致如下(使用汇编助记符):

pushl %ebp movl %esp, %ebp movl 0x8(%ebp), %eax addl 0xc(%ebp), %eax popl %ebp retl

这是我从CLang IDE的debug状态下拷贝出来的,原图如下:

image-20200921001025813

可以看到里面一共就包含6条机器指令,如果去掉pushl %ebp等入栈、出栈的辅助性指令,则只需要下面三条机器指令:

movl %esp, %ebp movl 0x8(%ebp), %eax addl 0xc(%ebp), %eax

而如果使用字节码编译为机器指令,指令的数量可能会多出几个数量级,这里以Java为例:

class A { public int add(int a, int b) { return a + b; } }

这段Java代码编译后生成的字节码文件如下:

0: iload_1 1: iload_2 2: iadd 3: ireturn

上面的字节码只包含了方法add的字节码,而每一条字节码最终都会产生一大堆的机器指令,机器指令的数量远超C语言编译后的机器指令的数量。

由此可见,由于字节码本身不能被CPU直接执行,为了能够被CPU执行,字节码在完成同一个功能的时候,需要准备更多便于自我管理的上下文环境,最后才能执行目标机器指令。由于其要直接编译成机器码执行,为其维护上下文的任务也就落到了生成的机器指令身上,因此字节码就生成了更多的机器码。

JIT和AOT

知道了可以把字节码解释成机器码然后在执行的基本原理之后,我们就看到了其中的无限可能,比如我们可以动态生成代码了,依赖这一点有望实现真正的人工智能,这么扯可能有点远了,我们还是在语言运行这个层面来谈一下两个非常热的话题:即时编译和提前编译。

即时编译(JIT)的意思就是,虚拟机可以通过热点探测探测到执行次数比较多或者是内部有效循环次数较多的函数,以函数为单位把其字节码编译生成的机器码缓存下来,下一次再调用这个函数的是时候,我们就不用再编译了,而且针对特定的场景可以进行编译优化,这样调教之后的热点代码的运行效率可能都会超过编译运行的语言。

不过JIT需要经过一段时间的预热,当热点代码都被探测的差不多的时候,才能达到最优的运行效率。

提前编译(AOT)的意思是,既然我们的代码可以在运行的时候动态编译成机器码再运行,那么当然也可以在静态阶段直接编译成机器码了,这样我们不是就可以直接省去JIT的预热过程了吗。Java虚拟机就有这样的尝试,但是目前效果并不理想,因为要实现在静态阶段就编译成机器码,那么就要求提前编译的部分完全静态可知,其依赖的部分也都必须是静态可知的,这使得程序丧失了很多的动态特性(就连动态的类型加载都不能支持了)。

现在针对解释执行的优化手段越来越先进,硬件也越来越发达,可能在不久之后我们就完全不用在意解释执行所带来的额外开销了。

编译运行 VS 解释运行

刚开始出现的高级语言大都采用编译运行的运行方式,编译运行的运行方式最主要的优点就是代码的运行效率高,能最大程度的发挥机器的性能。

而随着硬件水平的不断发展,硬件的成本已经降低了很多,现在的代码编写风格也都越来越大开大合,所以现在解释运行的语言尤其是脚本语言变得越来越流行了。

解释运行的缺点非常明显:

运行效率低不具备直接和硬件交互的能力,像编写硬件驱动程序这样的任务还是得交给C语言这样的比较底层的语言。…

其优点也同样明显

解释执行往往可以获得跨平台特性,可以实现"Write once, Run anywhere"的理想。

因为编译运行的语言的跨平台特性是通过编译器实现的,但是不同的平台下的API可能是不一样的,所以我们的程序要实现跨平台可能要依靠条件编译(C语言中条件编译可以通过预处理指令来实现)等比较复杂的技术。这就好比我们要出国谈业务,必须得自己懂得对应国家的语言一样。

而解释执行的语言通过定义良好的中间表示(中间代码),然后在不同的平台下提供对这同一种中间表示解释执行的虚拟机,这就好比我们出国谈业务,只需要懂英文,然后去当地找一个也懂英文的土著当翻译就可以了。

解释执行的语言往往具有更强的表达能力。

因为有虚拟机的存在,虚拟机会负责一部分上下文的维护工作,同样的一句代码,如果通过解释执行的方式运行,虚拟机就可以为我们做一些幕后的工作。所以同样的代码能做的可能更多。

提供反射和自动内存管理更加简单,因为程序的上下文在虚拟机的管理之下,而虚拟机本身也是一个程序,我们可以对其逻辑进行定制。

其实可以对比的地方还有很多,这里我们就不过多罗列了。

脚本语言

上文提到,脚本语言越来越受欢迎了,但是很多人可能只是知道脚本语言,但是却并不知道脚本语言具有什么样的特征,以及如何去区分一门语言是不是一门脚本语言。

脚本语言无一例外都是解释运行的,但是未必所有解释运行的语言都是脚本语言,比如Java和C#都是半解释执行的,但是它们都不是脚本语言。那么,脚本语言和非脚本语言有什么区别呢?

脚本语言和非脚本语言最本质的区别就是程序的入口:

脚本语言的入口往往是一个文件,我们可以直接在一个源文件中书写可运行的语句和表达式。非脚本语言的入口往往是一个函数,而且大部分语言都受到C语言的影响,入口函数的名称为main。

基于脚本语言的入口可以是一个文件这个特点,脚本语言一般都会提供一个命令行窗口,让你输入一条一条的语句,马上解释执行它,并得到输出结果。

比如 Node.js、Python 等都提供了这样的界面。这个输入、执行、打印的循环过程就叫做 REPL(Read-Eval-Print Loop)。你可以在 REPL 中迅速试验各种语句,REPL 即时反馈的特征会让你乐趣无穷。所以,即使是非常资深的程序员,也会经常用 REPL 来验证自己的一些思路,它相当于一个语言的 PlayGround(游戏场)。

就像我们《计算机是如何读懂高级语言的——编译过程简述》一文中在浏览器中调试我们的正则表达式一样,浏览器的Console就是JavaScript的一个REPL。

当然,一些编译运行的语言也可以提供PlayGround,但是其背后还是需要调用语言的编译器编译为目标代码,同时把我们在PlayGround中编写的代码封装为一个函数,然后PlayGround需要为我们自动生成入口函数,然后在入口函数中调用我们的代码,其本质上就是在语言的编译器之上封装的一个另类的解释器。

总结

这篇文章中,我们接单介绍了高级语言的运行方式,高级语言的运行方式可以分为编译运行和解释执行两种,而解释执行又有两种实现思路——直接解释执行和翻译为机器码后运行,我们对这两种方式的基本原理做了简单的介绍。

到现在为止,我们已经对高级语言代码的从编写到运行的整个生命周期都有了大概的了解,对高级语言建立一个语言无关的整体的认知对我们学习更多们语言是大有裨益的。

当然我介绍的这些东西并不全面,所以下一篇文章会谈一谈其他的一些高级语言的特性作为补充。

感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论([email protected])。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3