高级语言是如何运行的——语言的运行方式

⾼级语⾔是如何运⾏的——语⾔的运⾏⽅式
上⼀篇⽂章中,我们介绍了机器是如何读懂我们的⾼级语⾔的代码的,那么我们的代码⼜是如何在机器上得以运⾏的呢?
⾼级语⾔的运⾏⽅式有两⼤类,⼀类是直接把我们的⾼级语⾔的代码翻译为机器码,由机器直接运⾏,采⽤这种⽅式运⾏的语⾔我们称之为编译运⾏的语⾔;另⼀种就是再为我们的⾼级语⾔专门写⼀个程序,这个程序的作⽤就是解释执⾏我们的⾼级语⾔的代码;采⽤这种⽅式运⾏的语⾔我们称之为解释执⾏的语⾔。
根据运⾏⽅式的不同,⾼级程序设计语⾔⼤致可以分为编译运⾏和解释运⾏两种,⽽实际上也有⾮常多的语⾔是介于这两种⽅式之间的,⽐如说Java。
我们上⼀节的时候已经简要描述了⾼级语⾔代码的编译过程,我们现在再来回顾⼀下:
对于编译器前端的三个过程,⽆论是编译运⾏的语⾔还是解释运⾏的语⾔,都是⽆法避免的,我们的⾼级代码要顺利执⾏,⾸先要做的就是让机器能够理解我们的代码。
我们再仔细推敲⼀下就不难发现,其实中端所述的两个过程——⽣成中间代码和对中间代码的结构进⾏优化也是必要的,因为不论是⽣成编译运⾏所需的⽬标代码还是直接对⾼级代码进⾏解释执⾏,基于处理过的中间代码显然对机器更加友好,性能也更好。
编译运⾏
编译运⾏的语⾔的特点是将某种⾼级程序设计语⾔的源代码直接转换成特定平台(操作系统)中可以识别的可执⾏⽂件,从⽽让代码得以运⾏。
以C语⾔为例,编译运⾏的语⾔从编写源代码到执⾏代码⼤致需要经历如下的⼏个步骤:
1. 编写好源代码之后使⽤特定平台上针对C语⾔的编译器对源代码进⾏编译。编译⼜可以分为词法分析,语法分析和语义分析三个步骤,
其中的每⼀个步骤都会产⽣⼀下代码的元数据信息,这些我们在⼀⽂中已经做过简单的介绍了,这⾥就不详细介绍了。
在编译的过程中编译器会对⼀些基本的语法和语义错误进⾏检测,直到源代码可以正常编译。编译完成之后每⼀个源⽂件都会产⽣⼀个⽬标⽂件。⽬标⽂件中的代码称为⽬标代码,⼀般来说编译型的语⾔不会直接编译成机器码,⽽是编译成汇编代码。
编译产⽣的每⼀个⽬标⽂件都有⼀个⽂件头,⾥⾯存储了这个⽬标⽂件的⼀些元数据信息,供下⼀个阶段使⽤。
2. 对编译⽣成的⽬标⽂件进⾏静态链接操作,⽣成可执⾏⽂件。⽬标⽂件之间可能存在相互引⽤,这些引⽤的信息都存在了每个⽬标⽂
件的⽂件头中,⽽连接所做的⼯作就是根据这些元数据信息把编译所得的所有的⽬标⽂件连接成⼀个⽂件,再与编译器所提供的函数库相连接形成⼀个整体,从⽽⽣成⼀个可供特点平台直接执⾏的可执⾏⽂件。
3. 加载运⾏,在有了可执⾏⽂件之后,就可以把可执⾏⽂件加载到内存中运⾏了,当然在加载到内存中运⾏之前还要经过动态链接和汇
编等步骤。
编译运⾏的语⾔在加载到内存中的时候就已经是⼀堆完整的机器码了,对所有指令和数据的解析在运
⾏时都是完整的,包括我们在运⾏时要动态申请的内存空间的⾸地址要存储在哪⼀个内存空间或寄存器中这在程序加载到内存之中的时候都是已经完全可以确定的。
基于这样的特点,我们想要在代码中实现运⾏时对元数据的获取和分析(反射机制)就变得⽐较⿇烦:编译器必须在编译时就把运⾏时可能需要的元数据信息编译到数据中,⽽且还要负责组织这些元数据,并且提供访问这些元数据的函数,这样我们才能在源代码的层次获取运⾏时的元数据并进⾏逻辑的开发。⽽且程序开始运⾏之后其内存的管理要⾃⼰负责,⼀旦处理不好就会发⽣内存溢出。
但是,世事⽆绝对,也不是所有的编译运⾏的语⾔都没有提供反射和⾃动内存管理的功能,具有反射机制和垃圾回收机制的编译运⾏语⾔的典型是Golang,我对这门语⾔的内在机制了解的不是特别多,只是停留在能简单使⽤的层次,以后可能会花⼀些精⼒去深⼊学习。综上所述,编译运⾏的语⾔可以获得较⾼的执⾏效率,但是灵活性有所⽋缺。
解释执⾏
解释型的语⾔是指使⽤专门的解释器对源程序逐⾏解释并⽴即执⾏的语⾔。我们上⽂中也已经提到过,不论是编译运⾏的语⾔还是解释运⾏的语⾔,编译器的前端所对应的三个阶段都是不可避免的,⽽且为解释器⽣成更易于解释执⾏的中间代码也是有必要的。
所以我们在接下来讨论解释运⾏的语⾔的时候假定的都是解释器所解释的是经过处理之后的中间代码。⽽且中间代码的表现形式⼀般都是对机器更加友好的⼆进制形式的代码,我们⼀般称之为字节码,所以我们在下⽂进⾏讨论的时候就直接使⽤字节码这个术语了。
解释执⾏的语⾔的运⾏要依托于⼀个虚拟的运⾏环境,我们称其为解释器或者是虚拟机,下⽂统⼀使⽤虚拟机这个术语。虚拟机,顾名思义即使虚拟的机器,它从操作系统平台申请了⼀些硬件资源,并且在解释执⾏代码的时候管理这些资源,⽽代码的执⾏上下⽂也都被虚拟机管理着。
这个时候我们的代码在运⾏中的所有副作⽤对于虚拟机来说都是可控的,⽽且程序的元数据信息相对来说更加丰富也更加完整,运⾏时的可控性也更⾼,可以进⾏⼀些更加复杂的运⾏时校验,同时提供反射和⾃动内存管理的能⼒相对来说更加容易。
虚拟机解释执⾏字节码有两种实现思路:分别是直接解释执⾏和编译为本地代码后执⾏,下⾯我们假设使⽤C语⾔编写⼀个虚拟机来简单描述以下这两种⽅式。
通过C 语⾔解释执⾏
将字节码翻译成对应的可以运⾏的机器码,⼀种可⾏的⽅法是,使⽤C程序,将字节码的每⼀条指令,都逐⾏的解释成C程序。当解释执⾏字节码的程序——虚拟机程序被编译后,字节码所对应的C程
序被⼀起编译成本地机器码,于是虚拟机在解释执⾏字节码的时候,⾃然就会执⾏C程序所对应的本地机器码。
我直接这么⼲巴巴的说,你可能不太明⽩,下⾯我们通过⼀个简单的例⼦来感受⼀下:
计算两个整数之和。如果我们直接使⽤C语⾔进⾏编码,代码如下:
假设现在我们发明了某种脚本语⾔,这门语⾔的编译器会产⽣⼀种中间代码;它的解释器是使⽤C语⾔编写的,它以⾏为单位读⼊中间代码并解释执⾏。
假设该语⾔的中间语⾔中有⼀条指令来实现两个正数相加,这条指令的助记符是iadd,其对应的数字唯⼀编号为0x01,通过C语⾔直接解释执⾏这条指令的代码⼤致如下:#include  <stdio.h>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 ;}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
上⾯测试的运⾏结果如下:
上⾯的代码及其简陋,⽽且’虚拟机’的实现也没有考虑为程序管理上下⽂等功能,但是其完全可以体现出直接解释执⾏的含义:对于每⼀条字节码指令,都对应着⼀个C函数,当虚拟机读取到⼀条指令的时候会调⽤对应的C语⾔函数,在虚拟机管理的硬件资源中组织程序的上下⽂,实现程序的副作⽤。
这样的实现⽅式⾮常直观易懂,但是其缺陷也是相当明显——效率极其低下。使⽤这种⽅式实现虚拟机我们不仅要考虑被解析的语⾔的上下⽂,同时还要考虑宿主语⾔的上下⽂,对于机器来说,其实它是在同时维护两种语⾔的上下⽂,效率能⾼才怪。
使⽤这种实现⽅式的虚拟机的典型就是Java诞⽣之初的Classic VM,其实现原理⾮常简单,⽽且没有采⽤准确式内存管理机制,内存管理必须基于句柄(handle),这在内存管理的时候多了⼀次查询的开销,进⼀步降低了效率。Java语⾔运⾏慢的不良印象也是在那个时候产⽣的。
不过在Java 1.3版本之后,Classic VM就被 HotSpot VM取代了,HotSpot VM不仅采⽤了准确式内存
管理,⽽且在解释字节码的时候采⽤了直接编译为机器码的⽅式,这使得Java语⾔的运⾏效率得到了很⼤的提升,下⾯我们就来简单介绍⼀下直接把字节码翻译为机器码并运⾏的基本原理。
直接翻译为机器码
在介绍这种⽅式之前,我们有⼏个概念要先搞清楚:#include  <stdio.h>// 解释执⾏操作码的函数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 ;}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
指令和数据是同构的
在⼀⽂中我们不⽌⼀次强调这个概念,冯·诺依曼体系结构有⼀个⾮常明显的特点——它⼀视同仁地对
待指令和数据,它规定了指令和数据统⼀⽤⼆进制进⾏编码,⽽且指令和数据是存储在同⼀个存储器中(同⼀个内存空间中)的,借着将指令当成⼀种特别类型的静态数据,⼀台存储程序型电脑可轻易改变其程序,并在程控下改变其运算内容。这是我们接下来要介绍的虚拟机执⾏字节码指令的基本原理。
CS:IP
这⾥我们先解释⼀下什么是CS和IP。这是物理CPU内部的两个寄存器。对于⼀个CPU⽽⾔,这两个寄存器的地位是⽆可替代的,因为CPU从内存中取指令就是依靠这两个寄存器,CS:IP永远指向下⼀条要执⾏的指令的内存地址。
我们在代码中进⾏分⽀和循环的流程控制以及对函数(⼦程序)的调⽤其实都是通过修改CS:IP的指向来实现的。
搞明⽩了这两点,把字节码直接翻译成机器码并执⾏的做法也就不⾔⾃明了。只是我们如果通过C语⾔来实现这个过程的话,怎么实现把代码跳转到我们编译好的字节码呢?答案也很明显——函数,只不过我们这⾥要利⽤的是指向函数的指针。
我们修改⼀下上⾯的例⼦,把执⾏字节码的⽅式修改成翻译成机器码然后直接执⾏的⽅式,代码⼤致如下:
可以看到这个实现过程完全依赖我们上⾯所提到的两个概念——指令和数据是同构的、重定向CS:IP。这个代码不太⽅便在我的机器上进⾏测试,所以就不提供运⾏⽰例了。
我们在把机器码编译成字节码的时候要负责参数的准备、返回值的准备等内容,⽽其在其中要负责所有的副作⽤,所以⽣成的机器码所代表的函数不⽤接收参数,也不⽤有返回值。
从这⾥我们就可以看出,在我们把字节码直接编译成机器码的时候,只需要考虑字节码程序的上下⽂的维护和副作⽤就可以了,⽽不⽤考虑宿主语⾔的上下⽂的组织,这就为代码的执⾏节省了很⼤的开⽀。所以采⽤这种⽅式解释执⾏字节码性能上会有很⼤的提升。
代码转换但是由于要考虑字节码代码上下⽂的组织,所以其相⽐于编译运⾏的语⾔,⽣成的机器码可能会多出⼀个甚⾄多个数量级,在性能上还是和直接编译运⾏的语⾔有⼀定的差距。例如如下对两个数求和的C语⾔代码:#include  <stdio.h>#include  <stdlib.h>// 编译函数,⽤来把⼀段字节码编译成机器码,并返回编译好的机器码的⼊⼝地址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 ;}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
对其进⾏编译之后⽣成的机器码⼤致如下(使⽤汇编助记符):
这是我从CLang IDE的debug状态下拷贝出来的,原图如下:可以看到⾥⾯⼀共就包含6条机器指令,如果去掉pushl %ebp 等⼊栈、出栈的辅助性指令,则只需要下⾯三条机器指令:
⽽如果使⽤字节码编译为机器指令,指令的数量可能会多出⼏个数量级,这⾥以Java为例:
这段Java代码编译后⽣成的字节码⽂件如下:上⾯的字节码只包含了⽅法add 的字节码,⽽每⼀条字节码最终都会产⽣⼀⼤堆的机器指令,机器指令的数量远超C语⾔编译后的机器指令的数量。
由此可见,由于字节码本⾝不能被CPU直接执⾏,为了能够被CPU执⾏,字节码在完成同⼀个功能的时候,需要准备更多便于⾃我管理的上下⽂环境,最后才能执⾏⽬标机器指令。由于其要直接编译成机器码执⾏,为其维护上下⽂的任务也就落到了⽣成的机器指令⾝上,因此字节码就⽣成了更多的机器码。
JIT 和AOT
知道了可以把字节码解释成机器码然后在执⾏的基本原理之后,我们就看到了其中的⽆限可能,⽐如我们可以动态⽣成代码了,依赖这⼀点有望实现真正的⼈⼯智能,这么扯可能有点远了,我们还是在语⾔运⾏这个层⾯来谈⼀下两个⾮常热的话题:即时编译和提前编译。int  iadd (int  a , int  b ) {  return  a + b ;}
1
2
3 pushl  %ebp  movl  %esp, %ebp  movl  0x8(%ebp), %eax  addl  0xc(%ebp), %eax  popl  %ebp  retl
1
2
3
4
5
6 movl  %esp, %ebp  movl  0x8(%ebp), %eax  addl  0xc(%ebp), %eax
1
2
3class  A {  public  int  add (int  a , int  b ) {    return  a + b ;  }}
1
2
3
4
50: iload_11: iload_22: iadd 3: ireturn
1
2
3
4

本文发布于:2024-09-22 21:18:50,感谢您对本站的认可!

本文链接:https://www.17tex.com/tex/4/377674.html

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。

标签:编译   字节   代码
留言与评论(共有 0 条评论)
   
验证码:
Copyright ©2019-2024 Comsenz Inc.Powered by © 易纺专利技术学习网 豫ICP备2022007602号 豫公网安备41160202000603 站长QQ:729038198 关于我们 投诉建议