Compile or Interpret ?
Last updated
Last updated
经常听到的一句话是,编译和解释,然后又有人把它们用具体的语言来理解,比如 C 语言,是编译型语言,而 JavaScript 是解释型语言。它们的区别在于,最后变成机器指令的时机不同,我们最早说的 编译,意味在执行之前,它已经变成了机器指令,而 解释,直到执行它之前,它还是一些代码(字符串)。
但是,最后它们都会变成机器代码,所以我们不需要做这些区分,解释 和 编译 只是变成机器指令的时机不同。
不过,之所以会不一样,确实是因为语言的特点所决定的!
在 C 中,我们所有的变量,都有类型声明,这意味这变量的空间,在我们声明的那一刻就已经确定了,它的结构是不会改变的。
所以我们可以使用 sizeof 这样的 宏~ 下面这张图更生动解释了这一点,来源会在最后给出。
而现在目前的大多数语言,都支持不声明具体类型,直接使用。
考虑上面的情况,一个变量可以同时是多种类型,那么它的空间就是不确定的,这个情况还比较容易处理,直接一次给 所有类型的空间都分配出来就好了。
这种上面函数的调用,则比较复杂了,在做语句检查的时候, obj obj2 obj3 可以说是一个一个类似的结构,但是实际的大小却绝对的不同。
可能大家不知道,我们访问结构体成员的方式,其实就是 地址的加减 实现的,在 C语言下,所有的成员访问,都是确定的。而对于 JS 这一种,却难以处理。
所以,才有了 解释,这一操作,因为代码有着非常多的不确定因素,但是,并不代表直接静态的汇编不能够处理,回想我们在 C++ 所学的 template 这个关键字,其实仍然可以实现的,只是 需要在所有函数的调用语句之前,增加一个类型检查,如果出现了新的类型,那么就根据模版函数,实现一个新的函数。
在C/C++ 编译是指翻译为汇编代码,这里把这俩个过程合并,其实后面的 汇编,在英文有组装意思,本质是因为 C/C++ 是一个个文件分开编译的,所有有了这一个说法。
实际上就是说,上面的代码会导致有三个构造函数,当然这是经过处理的,对于上层的编写代码的人员来说还是透明的,其实就是类似于 函数名的重载,只是这个重载是由编译器来实现罢了。
不仅如此,对于所有的赋值也要检查,因为一个变量可以同时是多种类型,Anyway,事先编译也是可以实现的。
这里的加载方式,指的就是利用 dlopen 加载, 至于我们链接过程指定的外部库,其实是一样的,本质就是 dlopen
考虑一下,如果我们把一块代码,事先编译成了机器指令,比如我们使用的一些函数的动态库,其实就是说,我们要动态加载进一个代码块,然后执行它。
所以现在的脚本语言,其实都渐渐地开始支持静态类型的声明,就是为了加快脚本的执行速度
动态库,相信大家都不陌生,链接动态库的过程其实就是处理代码对外部的函数,变量的引用,把它们改成正确的值。
这是一个典型的例子,printf 就是一个外部函数,当我们执行这个程序的时候,printf 的实际地址其实并没有确认,程序有一个专门的部件(dynamic linker),负责处理这些外部函数的引用,本质就是把 printf 所在的动态库(其实就是二进制指令)加载进内存,然后改变调用的地址,让 CPU 顺利的转到 printf 所在的代码块执行。
其实这里就是一个二进制的处理过程,事先俩个模块都已经是二进制代码了,专门的部件需要加载进内存,然后跳转执行。
了解 dlopen 的人肯定不会陌生,加载一个外部的机器指令,需要处理的是函数,变量的引用,这些信息都要在头部用一些结构表示,这些结构必须得存在,因为二进制代码非常不好阅读。
所有的动态加载,无论有什么快捷的方法,本质都必须通过这些表格指示来解决引用,看多了就会发现实现的方法大同小异。
比如上面这个代码,最后编译成二进制之后,必然头部有类似的结构
函数名
偏移
状态
printf
0x38
UNDEFINED
这样就表示了,程序在 0x38 偏移的地方的那条指令,有一个函数的引用没有解决,同样的,当你编译一个程序作为动态库的使用的时候,必然它的头部也有类似的结构。
函数名
偏移
状态
printf
0x64
DYNAMIC
这就意味着,在所说的偏移处就是一个函数开始的地方,它的名字为 printf。表指明了代码的地址。
现在,我们又要有一个概念,如果事先编译成了二进制指令,事后执行,那么必须要有相关的表格来记录这些未处理的引用,原因是因为 二进制指令 不能提供有效的信息。
动态库的链接其实就是繁琐的处理表头这些结构的过程,让指令可以正确的执行,无论是 dlopen 又或者是 链接 的时候指明了 动态库的库名,都有一个共同点,就是操作系统来掌管了代码加载的过程,我们只需要利用相关的接口就好了。
把目标代码编译成库的形式加载,如果库里面有对外部函数的引用,可以使用 -fPIC 这个选项,那么就可以实现这个需求
解释执行的最大的一个好处就在于(本人看来),更好的做代码优化,对于小模块代码非常友好,原因之后我会解释。考虑 JS 在浏览器的使用,大部分都是一段不太长代码,而且很多时候存在异步的操作,还会更新相关的代码,如果每一次代码来了,都马上编译,然后调用上一小节的方法,dlopen,那么会是非常浪费时间的,大部分的时间都在 生成上面 提到的表头的数据,然后交给动态加载的模块来处理。
这里提到的动态加载,均是指利用操作系统的接口实现的接口,因为它是固定的操作,所以我们需要把代码的编译成接口规定的结构,如果仅仅只有10行的代码,却要做这以上的操作,数量一多的话,效率就不高了。
更重要的是,对于 X86 来说,段控制是以页为单位的,dlopen 会把库加载进新的一个页内,并把它设立为只读,可执行,那么每次动态加载一次代码(大小不足一页),都会耗费一个页的内存大小。
大部分人认为的解释执行,就是一条语句一条语句的翻译,如果是这样,那是非常浪费时间的,就好像 DEBUG 一样,在我的理解,解释执行和编译执行是没有区别的,我不排除早期有这样处理的程序,当时如果你是编写者,你会这么处理么?
我认为的解释执行,非常简单,就是编写者自己处理 函数和数据的引用,凡是实现了这个功能,那么我们就认为它是解释执行的,即,没有使用操作系统提供的 dlopen 等接口,而是自己完成了 从代码 -> 机器指令这个过程,同时自己放入自己控制的代码段,解决外部函数和数据的引用。
大部分解释型语言,在翻译成机器指令,是一块一块(比如一个函数+函数所依赖的函数 )的代码来编译成机器指令,同时,解决一些外部函数的引用,接着复制进代码段,然后跳转到目标代码执行,具体这个块的大小,都是有考量的,并不是一杠子打死,就是每次翻译一句,更别说现在很多脚本语言,其实就有整块代码全部翻译的,为了加快执行的速度。
所以,解释型语言,真正的做的事情,就是根据自己的需求,把若干长度的代码翻译成机器指令,加载执行。
我每每思考这些解释型语言的实现,就觉得无非俩种: 1. 编译成动态库,然后动态加载 2. 用现有的代码模拟脚本语言,也就是一大堆的 switch case 来实现
会这么认为,因为我知道一点,内存不是可以随意执行的,所有的段(若干个连续页)都有权限,读写执行(r-w-x),在最初接触程序的时候,我们就知道它大概有这么几个段,代码段,数据段,堆,桟,其中代码段就是只读的,然后在学习单片机的时候呢,又接触了51架构,里面的哈佛结构干脆直接把代码段独立到一个储存器,变成只读的,所以我的影响中,操作系统应该不可能提供让一个段即是可写又是可执行的,这个接口。虽然对于内核来说,只是更改几个标志位的事情,但是容易使程序被恶意控制。
原来,真的是我孤陋寡闻了,所有类型的操作系统都支持一点,就是执行在堆上的指令,并且可读可写可执行,在 *nix 下,提供了 mprotect() 这个系统调用,更改某个内存段的权限,当然不能是最开始的代码段,但是可以为堆。Windows 应该也提供了相关的 API
当然,直接调用 mprotect 可能会被 SELinux 这类的安全服务给拒绝并报告给用户,因为这属于恶意操作。更恰当的做法是利用 mmap(),获取一块可执行可写的内存,该函数会返回具体的起始地址。
Windows 则是提供了,
我相信, 所有的脚本编译器(解释器),都有这么关键的一步,只有如此,才能得到一个可写的代码段,这是所有的基础!我的疑惑也终于解开。
这篇算是一个引子,关键得理解,解释执行 实现的关键,是拥有一块可写可执行的一块区域。