1
What is JNI ?
JNI (Java Native Interface)是JVM为上层java应用提供的调用Native模块的渠道。我们知道java语言属于托管语言,依赖于JVM虚拟机的解析执行,并非直接运行于操作系统中,而对于操作系统而言,虚拟机只是一个普通进程而已。
JNI机制对于JVM来说,可以说是一个很重要的机制,不仅仅为java代码的开发提供了调用native模块的机会,虚拟机自身的一些重要实现也依赖于JNI调用。这里作者随意查看了系统中的java进程的依赖库,可以发现一些重要模块通过JNI机制,也封装入了动态链接库中:
那么思考一个问题,为什么java进程要使用jni技术?这个问题和进程为什么需要使用动态链接库一个道理。主要有两个原因:
从软件开发的角度来说,一些模块放入动态链接库,便于java虚拟机的模块化开发与管理,使得虚拟机的实现不至于那么复杂臃肿,这一点符合软件工程的解耦原则。最近Java 9的发布,一个重要的特性就是模块化,虽然作者没有考究其实现机制idea导出可执行jar包,但猜测应该也离不开JNI的支持。
动态链接库有助于节省内存,可以使进程占用更少的内存资源。这里就需要说一下动态链接库的知识了(以Windows 动态链接库DLL来说),如果系统中两个进程都依赖于同一个动态链接库,那么系统内存中该动态链接库只会被加载一次(更准确的说是通过VirtualAlloc API分配一次内存空间),这样就大大提高了系统的内存的使用率。如果java中一些关键模块都是直接内联入虚拟机中实现的,那么相同的模块就会重复加载,造成内存浪费。当然PE与ELF等涉及的内容还很多,不是本文重点,想要深入学习的小伙伴,可以看《程序员的自我修养》一书。
在传统项目的java开发中,一般很少用到JNI技术,基本上java层就能作所有事情了,而JNI技术一般用在安全加固方面,如市面上很多apk都将自己的加解密函数,甚至协议包的封装,都放入了动态链接库中实现,提高了破解的难度。
2
JNI的使用与开发
JNI的开发分为两个部分,一个是java层对于调用接口的定义,另一重要部分是C/C++动态链接库对于接口的实现(这里的接口并非指java中的interface,实际上申明的还是class)。Java部分接口的申明需要使用native关键字接口声明,并且需要必须声明为静态方法:
之后,就可以使用javah工具根据声明类生成C/C++工程需要引用的函数定义头文件,命令如下:
javah会根据java接口定义的包名,类名,方法名按照jni的命名规则生成函数定义头文件,同时该函数会被声明为动态链接库的导出函数:
头文件生成后,就能在C/C++项目中直接引用该头文件,开发动态链接库了。
3
JNI实现之动态链接库
由于动态链接库使用C/C++进行开发,就不具备跨平台的特性了。在windows系统下,动态链接库是遵循PE文件格式的DLL文件,linux(android)系统下则是ELF格式的so文件。在Windows平台下动态链接库的编译,需要在编译时为编译器指定/DLL开关,也可以使用Visual Stuido直接创建一个DLL项目进行开发,动态链接库的项目属性配置如下:
具体的开发过程,本文就不讨论了,开发什么功能都是由项目需求决定的,这里作者列举一些动态链接库在开发过程中需要注意的问题:
1.Dll的平台版本问题。在32位操作系统中,32位进程(x86)只能使用不超过4GB的内存空间,并且高2GB的内存地址还是由操作系统内核所占用(默认1:1),因此4GB内存地址对于今天的大型系统来说往往是不够使用的。为了使进程可以使用超过4GB的内存空间,现代CPU都支持了64位操作系统,但操作系统一般会兼容之前的32位软件,64位版本的Windows系统就通过Wow64技术来对32位进程提供了运行的支持。但是对于动态链接库的开发来说,程序员还是要需要区分动态链接库的平台版本,因为64位进程无法加载32位动态链接库,所以必须确保java进程与jni动态链接库的平台一致。不过程序员无需对32位与64位的区分做额外的开发工作(除非要使用内联汇编),只需要设置编译器的编译平台类型支持即可,如Visual Studio下只需添加对应对应platform的支持即可。
2.DLL的动态链接与静态链接(LINK)。实际上我们开发的DLL就是由Java进程动态链接入进程内存空间的函数实现的模块,动态与静态链接的区别在于,目标代码(指令)是否直接包含在了可执行文件中,这一点与Java开发中只包含了接口,将jar包放入java进程加载目录中来实现库的调用,还是直接将库的jar包直接打包到了生成包中的区别相似。这里作者提出DLL的动态与静态链接问题的原因是,如果采用动态链接,那么在不同版本的windows系统下,Java进程可能会弹出c++运行时库(msvcrXXX.dll)缺失的问题(相当于Java的ClassNotFound异常),所以一劳永逸的做法就是直接使用静态链接的方式来生成DLL。但如果你十分确定Java的运行平台的运行时库版本,并且追求运行内存的最小化,那么这里可以采用动态链接的方式编译。
3.跨平台的开发问题。JNI动态链接库需要注意跨平台开发的问题,如果一套代码能支持多个平台,那将会是一件很愉快的事情。那我们如何做到呢?首先尽量避免使用依赖于平台特性的系统API,能使用C++标准运行时库的函数尽量使用运行时库来实现,如果必须使用特定平台的系统API,可以使用_WIN32宏来做代码区分编译。这里作者举一个例子,在jni_md.h中JNIEXPORT与JNIIMPORT等函数导出宏在不同的平台下具有不同的声明,但该声明没有做到跨平台编译的支持,因此作者在项目中将jni_md.h进行了改造,将linux与Windows平台函数导出的声明放在了一起,并结合使用_WIN32宏来区别平台的版本编译,如下:
4
虚拟机中JNI加载原理
在开发完成动态链接库之后并成功编译生成可执行文件后,就可以引入Java项目idea导出可执行jar包,通过JNI进行调用了。
熟悉C/C++开发的同学一定知道,在进程中使用动态链接库时,需要通过LoadLibrary() API将动态链接库加载到进程内存空间,才能使用,并通过函数指针结合函数地址来进行函数的调用。而在Java中则是由System.loadLibrary()静态方法来实现链接库的加载,完成加载后,便可以通过之前的声明的native接口来进行调用了。
这样看来,JNI加载的关键实现就在System.loadLibrary()这个函数中了,而该函数的实现也是一个native的掉用,是由JVM中由os::dll_load()方法实现,其实质同样是通过系统API来对动态链接库进行加载,只是不同的平台下调用的系统API不同罢了,但原理相似(顺便说一下,JVM源码的确是跨平台C++项目的开发典范):
所以,JNI的就是JVM为Java语言封装了进程调用动态链接库的系统API而已。在第二节中,我们说明了通过javah生成的C/C++头文件的函数命名规则,但读者还需要注意一个地方,那就是函数通过JNIEXPORT宏被声明为了导出函数,那么何所谓导出函数,为什么要声明为导出函数?
这里作者以Java程序员的角度来说一下什么是PE(ELF)文件的导出函数。导出函数就相当于Java包中的public方法,可以被其他java应用程序通过引用的方式进行调用。而在PE(ELF)文件中,声明为导出函数的函数名称与地址将会在编译时被填写在PE(ELF)文件的导出表中,而其他进程在引用动态链接库时只能调用导出表中的函数(除非使用hack手段,才能调用任意函数,任意地址),通过IDA下查看动态链接库的导出表如下:
可以发现,这些导出表中的函数名与通过javah工具生成的头文件中的函数名完全一致。最后一个需要探讨的问题就是关于native方法的调用,其实也是由JVM实现的,从进程的角度看,方法调用实际上就是CPU执行地址寄存器(ESI)在指令内存空间的一次跳跃(JMP),Java在调用native 方法时,java进程通过导出表获取到对应函数在内存中的地址,然后跳过去执行(注意导出表中的地址并不是函数的内存地址,是文件偏移地址,在被加载到内存后,还需要加上镜像基地址才能找到函数的内存地址)。当然寻找函数地址的方法,操作系统已经提供了现成的API,这里就不详解了,不同平台下具体实现如下图所示:
5
关于JNI的调试
JNI调试其实就是源码调试技术,但是由于调用者是Java进程,在Idea、eclipse下貌似没有办法直接从java的JNI接口步入C++实现函数进行调试(Android studio ndk可以调试,但相对于其他C++源码的调试器还是差了些),那么我们真的就无法进行调试了吗?当然不是,其实在使用VS编译生成二进制文件时,编译器会产生一个符号文件(*.pdb文件),只要调试器在调试该进程时,同时加载了这个pdb文件,那么调试过程中,调试器就会根据符号文件与源码进行匹配,从而实现源码调试。下面看一下使用IDEA + Visual Studio进行jni项目调试的具体操作步骤:
首先确保C/C++项目的编译配置中生成了pdb文件:
为了便于调试关闭编译器优化选项:
编译完成后将生成的xxx.dll文件文件放入jni加载路径,运行Java调用程序(最好先在java调用程序中下一个断点)。然后使用ProcessHacker工具找到JNI的调用进程的PID,注意这个PID一定要找对,系统有的Java进程并不是调用程序的java进程,Java PID的寻找也可使用jps命令:
打开visual studio,并且打开DLL源码项目,通过Debug -> Attach Process的方式进行调试:
在进程列表中找到PID与Java调用进程PID相同的Java进程,点击Attache:
在被调用函数中使用F9下断点,此时便可以放开IDEA中的断点,java进程在加载了DLL后,并运行至C++中设置的断点后,就会被断下,此时就可以进行源码调试了,如下所示:
C++的源码调试,一定要注意pdb文件与生成的动态链接库版本一致,否则Visual studio会报找不到符号文件的问题,当然并不是说没有符号文件你就不能进行二进制的调试了,你可以使用汇编调试,呵呵!
6
限时特惠:本站每日持续更新海量展厅资源,一年会员只需29.9元,全站资源免费下载
站长微信:zhanting688