原文链接:http://www.ibm.com/developerworks/cn/java/j-jnidebug/
=====================================================================================================================================
对于某些应用程序,就是不可能使用纯 Java 语言的解决方案。有时需要 C/C++ 的原始性能;或者需要使用只有 C/C++ 接口的 API;或者需要访问操作系统调用(例如,Windows 注册表),它在 Java 类库中没有等价的调用。问题是目前没有调试器可以用于调试使用 Java 和 C 编程语言共同编写的应用程序。
本文中,我们将使用命令行工具来说明基本调试技术,然后讨论当不得不通过测试步骤来运行混合语言应用程序时遇到的问题。我们不讨论基本调试技术(有关 Java 调试的教程,请参阅 参考资料)。
注:本文中,我使用 Windows 2000 ,Java 调试器(JDB)和 GNU 调试器(GDB)作为主要调试器。这些技术可以轻松地适用于 UNIX/Linux 平台。 参考资料中包含有关个别调试器更高级用途的信息。
现在思考这样一个问题:从操作系统的角度如何看待 Java 虚拟机(JVM)。操作系统将 Java 虚拟机只是看作另一个应用程序,采用类文件形式的输入并且产生输出。
任何使用 Java 调试器的调试过程,从操作系统的角度来看,都是应用程序级的操作。而调试本机应用程序被认为是一个更具特权的操作,要停止整个应用程序,以便调试本机代码,这意味着 JVM 不能与 Java 调试器交互。
Java 本机接口(JNI)可以将本机程序与 Java 程序集成在一起,并且可以帮助解决这个调试的问题。有关 JNI 的详细信息,请阅读侧栏 “什么是 JNI”。
使用 JNI 构造应用程序大致有两种方法 ― 标准方法和 调用方法。每种方法反映了 JVM 是如何在一个进程中启动的。我们将详细研究这两种方法,然后使用这两种方法进行调试。
假设有一个过程,它担负着一些繁重的数学计算工作。您已经将这个过程编写成本机库,并且想要从 Java 应用程序调用它。这就是我所指的通过标准方法使用 JNI;它是 JNI 的关键所在。
对于这种方法,通过运行一个例如 Java myApplication
的命令来启动 JVM,在代码的某处,存在一个本机方法。
标准 JNI 方法样本代码
我已经准备了一些样本代码来演示标准 JNI 方法。代码文件可以从 参考资料中名为 std.zip 的压缩文件获得。下载部分包含了以下七个文件:
- jnitest.c
- JNITest.class
- jnitest.dll
- JNITest.h
- JNITest.java
- jnitest.o
- libtstdll.a
其中,Java 代码是 JNITest.java
。它包含装入本机库的 main()
方法,以及 instance()
和 static()
两个方法。它还声明了native()
方法 ― public native int native_doubleInt(int value)
。
C 代码是 jnitest.c
。它含有 native_doubleInt()
方法的 C 实现。当被调用时,这个方法就调用一个简单的 C 方法,并且回调到 Java 类中。
这个调用方法允许您只在需要 JVM 的时候才启动它。例如,想象在一个 Java 类库外面已经封装了一个本机 C 接口,然后想要编写一个使用这个接口的 C 应用程序。在这种情况下,主 C 应用程序将要在需要 JVM 的时候启动它。
对于这个方法,应用程序需要使用“调用 API(Invocation API)”,它允许您将 JVM 装入到任意本机应用程序中。实际上,所有 Java 程序都使用“调用 API”启动。 java.exe
的启动程序使用“调用 API”来启动 VM,然后运行 main 类,尽管这样做会产生许多命令处理以及设置的开销。关键问题是从您的角度来看,Java 程序是如何启动的。
调用 JNI 方法样本代码
我已经准备了一些样本代码来演示调用 JNI 方法。代码文件可以从 参考资料中名为 invocation.zip 的压缩文件获得。下载包含了以下五个文件:
- jvm.def
- libjvm.a
- main.c
- main.exe
- main.o
这个代码示例使用与标准样本相同的类。 main.c
是编译到可执行文件中的 C 代码。它首先执行的是创建带调试选项的 JVM。(始终记住要正确设置选项的个数。)
goTest()
方法调用进入到 Java 类的 static 方法中。(设置为 static 使 JNI 代码更简单。)紧接在 goTest()
方法的前面,有一个无限循环。这个循环用在这里是出于调试方面的原因,我将在后面进行说明。
现在让我们开始调试。设想需要启动 Java 程序,然后将 C 调试器与那个 Java 程序进程连接。有可能使用一些调试器,(比如 Visual C++)来直接运行 Java 可执行文件。使用这种方法,需要将您的 DLL 添加到附加的 DLL 列表中;否则,运行应用程序时,将无法设置断点。
Java 应用程序有一个可以动态装入 DLL 的 System.loadLibrary
调用。(注:本例演示了如何同时使用 JDB 和 GDB 进行调试。如果不希望使用 JDB,可以将 Java 设置为发生 System.loadLibrary
后停止等待用户的输入。)按照如下步骤进行操作:
- 在调试模式下运行 Java 程序。
- 在另一个窗口中,将 JDB 与 JVM 连接。
- 在行上设置一个断点。
- 将 GDB 与 JVM 连接。
- 使 JDB 让 JVM 继续运行。
- 通过 GDB 窗口释放 VM。
- 遍历本机代码。
让我们更仔细地检查这些步骤。
步骤 1.第一步是在调试模式下运行 Java 程序,并将 Java 调试器与它连接。在以下示例中,我使用“Java 平台调试器体系结构(Java Platform Debugger Architecture,JPDA)”,而不使用较早的调试 Java 界面。
清单 1. 使用 JPDA,在调试模式下运行 Java 程序
C:\_jni\std>java -Xdebug -Xnoagent -Djava.compiler=none -Xrunjdwp:transport=dt_socket,server=y,suspend=y JNITest Listening for transport dt_socket at address: 1060 |
命令 suspend=y
使得当 VM 一旦建立调试器可以与之连接的端口时,它就立即停止。
步骤 2. 在另一个窗口中,将 JDB 与 JVM 连接。JDB 需要访问源代码和类文件。这些可以通过使用 -sourcepath
和 -classpath
选项在 JDB 的命令行中指定。另外一个方法是从这些文件的目录中运行 JDB。
清单 2. 将 JDB 与 JVM 连接
C:\_jni\std>jdb -attach 1060 Initializing jdb... main[1] VM Started: No frames on the current call stack main[1] |
步骤 3. 然后我们可以在发生 System.loadLibrary
调用后在行上设置一个断点。
清单 3. 在行上设置断点
main[1] stop at JNITest:22 Deferring breakpoint JNITest:22. It will be set after the class is loaded. main[1] run > Set deferred breakpoint JNITest:22 Breakpoint hit: thread="main", JNITest.main(), line=22, bci=21 22 System.out.println(" ##### About to call native method "); main[1] |
请注意,在 JDB 中设置断点有两种方法。一种方法称为 stop in;另一种称为 stop at。 stop at用于在指定的行上停止; stop in用于在特定方法或类中的断点。
这时,JVM 已经停止执行,但从操作系统的角度来看,应用程序仍旧是正常运行。它将看见从 JDB 进程到 JVM 进程的连接。
步骤 4.现在可以将 GDB 与 JVM 进程连接。为此,需要知道进程的标识(PID)。可以使用任务管理器或者使用比如 listdlls(来自于 SysInternals.com )这样的工具来获得。 -nw -quiet
选项启动 GDB,此时的界面不是图形方式并且没有正在启动的版本信息。
清单 4. 将 GDB 与 JVM 进程连接
C:\_jni\std>gdb -nw -quiet (gdb) attach 1304 Attaching to process 1304 [Switching to thread 1304.0x2b8] (gdb) break jnitest.c:15 Breakpoint 1 at 0x100010cf: file jnitest.c, line 15. (gdb) |
步骤 5. 现在 GDB 在进程中设置了一个断点。此时需要使 JDB 让 JVM 继续运行。在 JDB 窗口中输入 cont
。(请注意这样看起来是不返回的 ― 这是因为已经使用 GDB 停止了进程。)实际所发生的是 JDB 已经设置了 JVM,使它可以按正常工作时那样运行。
步骤 6. 在 GDB 窗口中输入 cont
来“释放”VM。注意已经到达这个断点。
清单 5. 释放 VM
(gdb) cont Continuing. [Switching to thread 1304.0x550] Breakpoint 1, Java_JNITest_native_1doubleInt (pEnv=0x2346c8, obj=0x6fe20, value=42) at jnitest.c:15 15 printf(" ***** Entering C code\n"); (gdb) |
步骤 7. 现在可以按需要遍历本机代码。如果返回到 JDB 窗口并输入 stop at JNITest:26
,试图在应用程序第二次调用本机方法之前停止它,那么应用程序不会响应。需要首先让应用程序单步进入 GDB。
在 GDB 中单步跳过 JNI 调用是很困难的。可能需要输入 next
两次来单步跳过这个调用。
一个更好的方法是输入许多断点,它可能导致进程加速 ― 当使用 GDB 时,JNI 调用要花一些时间来完成。
使用这种方法,能够轻松地调试 C 代码。可以在调试器中运行应用程序。然而,为调试任何 Java 代码,需要设置 JVM 选项。当使用“调用 API”启动应用程序时,可以这样做。
注意以下示例中的选项与 清单 1中的选项相同。如果直接运行这个程序,将获得类似的输出。
清单 6. C 代码调用使一个数增至三倍的 Java 方法。
Listening for transport dt_socket at address: 1068 #### java |
本例中,我已经使用 C 代码来调用使一个数增至三倍的 Java 方法。我希望将 JDB 用于这个方法 ― 我们已经提供了可以与 JDB 连接的端口号。这种风格的调试中比较困难的部分是在 GDB 的控制下,允许 JDB 充分地与 JVM 交互。
一个简单的方法是产生一个永不退出的循环。可以利用这个循环使应用程序在运行时不执行任何逻辑,但允许 JVM 在与 JDB 连接的情况下工作。以下是执行的步骤:
- 将应用程序装入 GDB;设置一个断点。
- 在 GDB 中重复遍历循环。
- 在 GDB 中停止循环并继续调试。
- 在断点上查看 JDB 的停止。
让我们来更仔细地分析这些步骤。
步骤 1.将应用程序装入 GDB 中并且设置一个断点,这样可以遍历循环。请注意,虽然这个循环在任何程序中都是很糟糕的,但是它仅用于调试的目的。这个想法使 JVM 可以在与 JDB 连接的情况下工作。每次程序遍历循环时,JVM/JDB 就需要完成更多的工作。
步骤 2.在 GDB 中重复遍历循环,以在 Java 的“三倍数字”方法中设置断点。当 JDB 响应时,只需继续遍历循环。可以通过添加例如休眠等操作使它更复杂。
清单 7. 当 JDB 响应时,继续遍历循环
main[1] main[1] stop at JNITest:58 Deferring breakpoint JNITest:58. It will be set after the class is loaded. main[1] |
步骤 3. 在完成了步骤 2 之后,就可以在 GDB 中停止循环,继续进行调试。这里,我已经在 goTest()
方法的末端设置了一个断点,使所有的 JNI 调用都可以执行。
清单 8. 断点使所有的 JNI 调用执行
(gdb) print loop $1 = 0 (gdb) set loop=1 (gdb) next 42 goTest(pEnv); (gdb) step goTest (pEnv=0x3f4e58) at main.c:53 53 javaClass = (*pEnv)->FindClass(pEnv,"JNITest"); (gdb) list 48 49 jclass javaClass; 50 jmethodID mid; 51 jint result; 52 53 javaClass = (*pEnv)->FindClass(pEnv,"JNITest"); 54 55 mid = (*pEnv)->GetStaticMethodID(pEnv,javaClass,"java_tripleNumber",'(I)I"); 56 57 result = (*pEnv)->CallStaticIntMethod(pEnv,javaClass,mid,42) ; (gdb) break main.c:58 Breakpoint 2 at 0x401474: file main.c, line 58. (gdb) c Continuing. |
步骤 4.当上述代码运行时,可以看到 JDB 在断点上停止。
清单 9. JDB 在断点上停止
Set deferred breakpoint JNITest:58 Breakpoint hit: thread="main", JNITest.java_tripleNumber(), line=58, bci=0 58 System.out.println(" #### java "); main[1] |
JNI 引用的不透明度很高 ― 也就是说,不发布它们的结构。如果知道了对象的结构,就可以在内存中看到对象了。
在以下的摘录中,完成 Java 方法调用后,停止了一些 C 语言代码。 result
包含一个 jstring 引用。
清单 10. JNI jstring 引用
Breakpoint 3, goTest (pEnv=0x3f4e50) at main.c:60 60 } (gdb) print result $1 = 0x3fda44 |
请注意,内存变量( 0x00ad1ac8
)中的地址在下面的清单 11 中显示。如果要从那个位置开始打印内存,可以看到字符串的起点。GDB 的 Cygwin 分发版提供了图形前端(它有内存编辑窗口)— 这样更容易查看字符串。
清单 11. 查看字符串
(gdb) x 0x3fda44 0x3fda44: 0x00ad1ac8 (gdb) x/30s 0x00ad1ac8 0xad1ac8: "0021" 0xad1acc: "" 0xad1acd: "" 0xad1ace: "" 0xad1acf: "" 0xad1ad0: "032-" 0xad1ad4: "" 0xad1ad5: "" 0xad1ad6: "" 0xad1ad7: "" 0xad1ad8: "\022" 0xad1ada: "" 0xad1adb: "" 0xad1adc: "0" 0xad1ade: "" 0xad1adf: "" 0xad1ae0: "\022" 0xad1ae2: "" 0xad1ae3: "" 0xad1ae4: "*" 0xad1ae6: "" 0xad1ae7: "" 0xad1ae8: "C" 0xad1aea: "a" 0xad1aec: "t" 0xad1aee: " " 0xad1af0: "s" 0xad1af2: "a" 0xad1af4: "t" 0xad1af6: " " (gdb) |
标准 Java 分发版不带 GCC 库,所以编译有些复杂。对于上面的示例,通过下面的命令序列,我使用了 MinGW 工具箱:
- 基于
def
文件,生成 GCC 的库文件。用以下内容创建一个名为jvm.def
的文件。
EXPORTS JNI_CreateJavaVM@12
如果在 libjvm 文件中运行比如dumpbin
或者nm
这样的工具,则可以为其它的“调用 API”抽取引用。
- 创建一个应用程序可以链接的库文件。
dlltool -k --input-def jvm.def --dll jvm.dll --output-lib libjvm.a
- 编译 C 应用程序。
gcc -Ic:\_jdk\include -g -c main.c
- 将主应用程序与 JVM 链接在一起。
gcc -o main.exe main.o -L./ -ljvm
以下是使用 JNI 的一些技巧,可以用来避免一些常见问题:
- 始终检查 JNI 调用的返回值和异常。
- 在检查异常时,记住如果调用了
ExceptionDescribe()
,那么可能得到的描述过的异常是一段时间以前发生的,而不是最后一次调用的结果。
- 记住在任何线程终止前调用
threadDetach()
。如果执行调用失败,在垃圾收集器运行时,可能导致大问题。它将试图查找已经不存在的线程的堆栈帧。
- 和 JPDA 一起运行时,始终使用
java.compiler=NONE
。如果使用java.compiler=fred
,那么 JNI 将停止 JIT,但实际上它没有起作用。
如需了解更多内幕,建议参考 Sheng Liang 所著的 The Java Native Interface和 Sun 的 JNI 站点(请查看 参考资料进行链接)。如果想要阅读 Essential JNI一书(其中关于 JNI 基础的章节写得很好),请注意其 Java 2 函数是基于 JDK beta 版的,因此它们没有那样的形式。
切记,在享用强大的,但不是纯 Java 语言的应用程序的好处时,不要忘记如何使用 JNI 来实现一个完整、复杂的调试过程。
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文.
- 请参加有关本文的 论坛。
- 下载本文中使用的示例代码。 std.zip包含了标准 JNI 方法的文件, invocation.zip包含了调用 JNI 方法的文件。
- IBM Distributed Debugger上循序渐进的教程提供了一个分布式调试器的方案、本地和远程调试的示例以及如何将调试器与正在运行的虚拟机连接的指示信息。
- 在 jGuru JNI 论坛上,Davanum Srinivas 管理着一个正在进行的“JNI 所有问题”的讨论。
- Sun 的 JPDA 页面是一个非常详尽的来源,它提供了“Java 平台调试器体系结构”的概述、体系结构注意事项以及规范。该公司的 JNI 页面提供了到规范、JNI 增强以及教程的链接。
- 学习 GCC 及其相关工具的所有内容。
- Sysinternals 自由软件站点正致力于 Windows 开发,它有诸如 Process Explorer(基于 GUI 的实用程序,允许用户查看由进程打开和装入的 DLL 和句柄)和 ListDLLs(显示了进程装入的 DLL)等一些了不起的工具。
- MinGW是头文件和导入库的集合,它可以让开发人员使用 GCC 并且产生不依赖第三方 DLL 的本机 Windows32 程序。
- Cygwin是用于 Windows 的 UNIX 环境,它由一个 DLL 和一个移植的 UNIX 工具的集合组成。其中,DLL 作为 UNIX 仿真层来提供 UNIX API 功能;移植 UNIX 工具的集合可以提供类似 UNIX/Linux 的外观和界面风格。
- 有关使用 JPDA 的开放源码图形 Java 调试器的信息,请尝试 JSwat― 它是一个可扩展的独立调试器前端。
- Dev-C++是一个基于 GCC 工具的全功能 IDE,它能够使用 MinGW 或 Cygwin 编译器创建 Windows 程序或者基于控制台的 C/C++ 程序。
- 如果要认真使用 JNI,那么请阅读 Sheng Liang 的 The Java Native Interface (Addison-Wesley, 1999),该书包括了一些主题,比如编写本机方法、在 Java 语言和本机程序语言之间传递数据类型、在本机应用程序中嵌入 JVM,以及提高代码的效率和可靠性。
- 关于在 Windows 下的调试,请参考以下书籍:
- John Robbins 的 Debugging Applications 提供了一个整体性的方法来调试以及重新定义错误,该书包含了从用户界面问题和性能问题到难以理解的产品手册的所有内容。
- John Robbins 的 Debugging Applications 提供了一个整体性的方法来调试以及重新定义错误,该书包含了从用户界面问题和性能问题到难以理解的产品手册的所有内容。
- 在 developerWorks 上,Eric Allen 的 Diagnosing Java Code专栏提供了在代码导致问题以前修正代码的技巧。
- Laura Bennett 的 Java 调试教程(developerWorks,2001年 2 月)提供了 Java 调试概念详实的介绍。
- 可以在 developerWorks Java 专区上找到更多 Java 参考资料。