C/C++、Java由源码到机器码的过程(编译原理)
软件开发的环境需要什么?一个IDE,一个OS,一个硬件设备,没错,这个实质是软件进展的三个层集。在很久很久以前(几十年),软件就是直接开发在硬件设备上的,用纸带有无孔标识二进制位,此时的开发语言是机器码,软件直接对接硬件设备;后来很不方便,尤其不方便复用,然后,有了汇编,有了简单的编译环境,然后逐渐发展成为OS内核;时代会进步,软件要处理越来越多复杂的场景,然后有了高级语言:C等,为了更加高效友好的开发,有了最初期的IDE,2000年左右的程序员,应该用记得有一个Turbo C,这个也是我的入门IDE,这个至少要比记事本写代码方便了一些。软件的规模越来越大,行业分工越来越细,开发的效率也越发的重要,以Android为例,从Eclipse到google官方所推的Android Studio,IDE的功能越来越强大,这个解放了程序员,但也控制了程序员,有谁还会知道,写在这些IDE中的代码行,为什么可以执行在手机上?中间有什么过程,是什么模块在控制这些过程?是完全由IDE实现的吗?我相信能答出这个问题的人是少数。古人说过,要知其然,还要知其所以然,只会使用IDE去写固定的功能模块,只是一个普通码农,要想用好一个工具(包括IDE及语言),还要了解其实质,要明白,我们写好的代码,是怎么运行到硬件环境上的。这个话题很大,包括编译原理、OS结构、硬件驱动、各种语言等。大不是不去了解的理由,技术的提交需要反复的打磨,好,先来揭个盖子,看一下C/C++及Java由代码到机器码的过程,有了机器码之后,再后面的运行细节,离软件开发离得较远,和开发优质软件平台(除OS外)关系也不太大。
由源码到机器码,C/C++与Java的实现并不相同,为什么要放在一起呢,三个原因吧:(一)从语言代来讲,C为二代面向过程语言、C++为三代面向对象语言,Java为参考C++所设计的三代面向对象语言,其本身是有传承的,语言会传承,编译运行环境同样是传承的,对比着看,可以看出优化方向;(二)我目前主要是在做Android开发的相关方面,这个是与自身最切身相关的语言,Android的内核是C/C++环境、应用层和framework层是Java环境,对这两门语言有迫切的项目需求。(三)个人认为,C/C++、Java作为静态语言,其应用范围、语言特性,编译运行原理非常有代表性,尤其是Java在做跨平台,之后JVM上可以运行其他的动态语言,可以说Java的编译运行可以代表一部分希望跨平台的动态语言,比如Kotlin。
先来看一下C/C++的源码到机器语言过程发生了什么,分为四个大步骤:预编译、编译、汇编、链接,在C/C++中统称为编译。
(一)预编译所处理的过程包括:
a.展开宏定义#define
b.处理条件预编译指令#if等
c.处理#include
d.删除注释
e.为Debug及日志填加行号
f.保留#pragma
(二)编译所处理的过程包括:
a.词法分析:使用扫描器将源码分隔为一系列的记号(Token),即源码中的不可分隔项,较为容易理解,比如下面的:
int index = (2 + 8) * c 会被折分为 :int index = ( 2 + 8 ) * c 10个token,左右半括号各为一个
b.语法分析
将a中分出来的Token,映射为语法树
c.语义分析
在b中语法树的基础上,分析是否有错误语义,编译器所能分析的语义为静态语义,包括:声明是否正确、类型是否匹配、类型的转换是否符合要求
d.经过前面三个过程后,将源代码转换为中间语言,可以理解为将c中通过的语法树通过一定的规则拍平,变为类假于目标代码的结构,为什么要引入中间语言呢,目标语言很多时候是硬件相关的,而中间语言与硬件无关。
e.生成目标代码并进行相应优化
目标代码的文件组织格式为.o文件,其内部的格式与具体设备有很大关系,比如在Android中,对arm7和x86CPU要编译出不同的so文件,此处同理,不同的硬件环境生成不同的目标代码。目标代码的优化也是针对不同情况有不同处理,在此不再展开,感兴趣的同学可以参考编译原理相关书籍。
(三)汇编:汇编的目的是把汇编语言转为机器语言,基本是一条转一条,没啥特殊的
(四)链接:链接是要解决目标文件之间的互相依赖关系,当a文件中的aa方法中调用了b文件的bb方法时,在汇编完成后,a文件的bb方法并没有准确的内存地址,链接后会转换为虚拟地址,虚拟地址可以依据一定的规则转换为实际地址,即可以运行时找到该方法。链接过程包括:地址和空间分配、符号决议和重定位,之后的文档中可以再总结下。
Java语言从源代码到机器码的过程要比C/C++复杂,Java追求的是一次编译,多次运行的跨平台特性,因而在分层上更为彻底。可以分为编译期和运行期两个周期,编译期的输入为源代码.java文件、输出为字节码.class文件、运行期输入为字节码.class文件,输出为机器码。为何这么做可以跨平台呢,JVM定义了严格的.class文件的格式,Java文件需要严格按照定义编译为.class文件,然后可以拿到各个平台各个版本的虚拟机上运行。如果其他的语言编译完毕后也遵循.class文件格式,也可以在JVM上和Java一样运行。
先来看Java的编译期,有以下几个过程:
(一)生成语法树及符号表的过程:
a.首先进行词法分析,将源码的字符流分解为token的集合,token的含义可以参考上面所提到的,即不可折分的个体
b.语法分析,根据token集合构建抽象语法树
c.生成符号表:符号表是一个类key-value结构的集合,记录符号的类型、结构、定义,支持增加与删除
(二)处理Java语言中的注解,修正(一)中生成的语法树
(三)语义分析,与C/C++的语义分析类似,进行一些语义正确性检测,具体包括:
a.标注检查:类型声明及赋值是否合适
b.常量折叠:将 1 + 2 直接用3替代
c.上下文的语法检测,如变量使用前是否赋值等
d.解语法糖,为了提升开发效率,Java提供了许多的语法糖,比如:泛型、变长参数、自动装箱等,在此过程中,会将这些语法糖转为JVM所规定的格式。具体可以查看Java虚拟机中相关语法编译期的处理
(四)生成字节码,生成字节码阶段主要是两个事情:
a.按照抽象语法树及符号表生成相应的字节码
b.针对Java语言的特点,进行些必要的填充,比如:字符串的+转为StringBuilder等
经过上述四个过程,Java中的编译期即进行完毕了,可以获取到.class文件,如果对.class文件格式感兴趣,可以看我之前的文章:https://blog.csdn.net/kcstrong/article/details/79460262
继续分析下Java的运行期,先看下运行期有什么特点。首先,运行期离Java语言的使用者,离得较远,运行期处理的是字节码到机器码之间的转换,Java语言的使用者不需要了解这些细节,也可以进行高效的开发。理解这些细节,以我当前的体验,对于开发者来讲,主要是增长知识面,对开发的影响也不大。那运行期和什么样的开发者关系较大呢,没错,就是虚拟机开发人员或者需要优化虚拟机的人员,比如,服务器的运维人员。
Java运行期的工作并非是固定的,最基本的,也是最简单的,当然,也可以理解为保底的,是使用解释器将字节码转换为机器码,该原理相当的清晰明确,有以下步骤:
a.按一定的规则寻找相关的类,首先,根据环境变量找到java类库的位置,然后根据双亲委托模式找到准确的类位置
b.然后就很简单了,JVM字节码可以直接逐条解释,只是JVM是基本于栈的指令集,条数较多,解释较为耗时。
另一种Java运行期的处理为JIT,是一种Java动态编译方法,分为两种:Client Compiler和Server Compiler
Client Compiler较为简单快速,其过程用下面一幅图来说明:
Server Compiler是一个经过充分优化的高级编译器,包括但不限于:无用代码擦除、基本块重排序等,其技术极为复杂,感兴趣的同学可以查阅专门的资料研究。选用上述那种Compiler进行处理,要视环境的需要而定。
JIT并非完全取代解释器,只是在需要优化的高频方法或高频模块(比如多次for循环)才会介入,其他时候仍采用的是解释器的处理方式。
针对Java的源码到机器码的处理,上面提到了两种编译运行方式分别为:编译为字节码+解释器、编译为字节码+JIT+解析器。目前还是另一种方式,为AOT,一种静态编译方式,在编译期,即将源码编译为机器码,其好处也是显然的,运行器无JVM处理时延,效率最高,但缺点也很明显:(1)牺牲了Java的跨平台特性,机器码一定是平台相关的(2)编译算法复杂度相当高,JIT在运行期,通过运行时数据收集,制定是否编译机器码,但AOT在编译期就要收集到这些。
好,总结一下,总共提到的Java的源码到机器码的处理为三种:
a.编译为字节码+解释器
b.编译为字节码+JIT+解析器
c.AOT直接编译为机器码
Java目前应用范围要大于C/C++,采用那种方式,要看那些场景更为合适,比如,Android采用的变异后的b方式(编译后的输出为dex文件,运行前要将dex文件转化为oat格式,运行时要处理的格式为oat格式,运行时的指令集为基于寄存器的)
从上面对C/C++及Java编译运行的分析,可以看出,编译的基本原理及流程是相近的,然后加入了后期的优化,尤其是Java,为改善编译及运行效率(JIT、AOT、字节码等),做了大量的工作,至Android虚拟机(当前文中没细说),又根据平台特点,在Java的基础上做了大量的改进。