修改JVM源码,方便执行流断点
一、 说明一下
1.只是基于我对jvm的熟悉程度,不知道有没有更直接的方法,如果哪位大神有请赐教
2.ubuntu16.4,x86_64,clion调试jdk1.8+
3.以monitorenter字节码为例,做下微调
二、前置条件
1.会汇编基础,不需要深基本指令和32,64位寄存器
2.会C稍微熟练,最起码的函数申明和使用要会
3.对jvm的执行流了解
…只是断点以上就行–附加…
4.想要理解原理,得了解指针、内存及内存偏移
三、我遇到的问题
1.使用GDB断点时在类比较复杂字节码比较多的情况下就比较蛋疼。需要通过字节码指令一个个找过去在GDB中要输入无数的si、ni、b *xxxx、c等执行命令,有时候还要做一些偏移计算才行。还要通过i r 、p /x 、x /xg等命令时刻关注进入的断点是不是你想要的信息,并且一旦错过就得蛋疼的重启。
2.使用-XX:+PrintInterpreter打印出字节码执行流地址直接GDB跳转。好是好就是有个问题java字节码中out vtos和in vtos的组合是必现的,在这种组合下有些字节码的入口地址并不是打印出来的地址
举例:
main方法中有字节码
..........................
7: astore_1
8: aload_1
..........................
通过控制台打印aload_1 “43 aload_1 [0x00007f3b987d5420, 0x00007f3b987d5480]“,0x00007f3b987d5420是aload_1的首地址,使用GDB时一般就直接命令b *0x00007f3b987d5420 ,然后c过去了,然后就没有然后,要重启了。因为这个地址并不是astore_1和aload_1两个字节码组合后的aload_1首地址在这里下断根本不会跳入这个断点。
看下汇编及地址:
/**这是首地址**/ 0x00007f3b987d5420 push %rax
0x00007f3b987d5421 jmpq 0x7f3b987d5450
..........................................................................
/** 实际组合的首地址**/ 0x00007f3b987d5450 mov -0x8(%r14),%rax
0x00007f3b987d5454 movzbl 0x1(%r13),%ebx
..........................................................................
0x00007f3b987d547f int3
/**这是尾地址**/ 0x00007f3b987d5480 movabs 0x84cccccccc000000,%al
为什么不能在0x00007f3b987d5420位置下断,在“四、修改源码前的一些必须点“的3.2说明。
所以我花了两个小时微调了代码,让ide可以直接断点
四、修改源码前的一些必须点
1.先搞一个demo
// An highlighted block
public class Test {
public int exp() throws InterruptedException {
synchronized(this) {
exp();//死循环,这里不是重点,不用在意
}
}
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
test.exp();
}
}
2.javap -verbose
// An highlighted block
public int exp() throws java.lang.InterruptedException;
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
...............省略.....................
public static void main(java.lang.String[]) throws java.lang.InterruptedException;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #7 // class Test
3: dup
4: invokespecial #8 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #2 // Method exp:()I
.................省略......................
}
3.必要的字节码执行过程
根据java字节码对这些字节码的堆栈及寄存器分析。还有一些额外知识补充
3.1 字节码new
在_new字节码的实现中会将这个对象所在的堆或者TLAB的地址放入rax寄存器,然后由dup字节码先将rax压栈(这里指的是os的堆栈)后在将栈顶的数据取出在压入,以做到复制栈顶并压栈的操作,先看下_new的部分汇编:
截图部分只是tlab的分配,如果是首次创建会走slow_case,通过InterpreterRuntime::_new创建,但是结果都是放到rax寄存器中
3.2 字节码dup
字节码是否对os堆栈进行压入取决上一个字节码的outToState和当前字节码的inTostate(如何判定和有什么用可以跳转到 (https://www.processon.com/view/link/62d7f0ac07912923e888d919)流程图右下半侧 )。简单的说就是outToState和inTostate是在字节码执行流前对栈必要操作
1.当前字节码的inTostate不是vtos的需要先pop rax。
2.上一个字节码的outTostate不是vtos但是当前字节码的inTostate是vtos就要push rax。
3.当前字节码的inTostate和上一个字节码的outToState都不是vtos就要先pop在push
4.两则都是vtos的不需要对栈做前置操作
_new字节码的outTostate是atos,而dup的inTostate是vtos,所以在_dup字节码执行前要先push rax也就是当前类的oop压栈。
dup汇编 | 堆栈 |
---|---|
![]() | ![]() |
3.3 字节码invokespecial
这个展开的话有点复杂,从ConstatPool、ConstatPoolCache、ConstatPoolCacheEntry到klass再到oop,从元空间到堆再到GC都有涉及。
在这个dome中只要知道这个字节码就是new Test()的init,会跳转到构造方法的栈中将Klass和ConstatPool链接,将nonStatic的变量初始化、赋值等操作,最后执行_return字节码返回调用者方法的栈
3.4 字节码astore_1
astore_1:将引用类型或returnAddress类型值存入局部变量1
这里回到main方法的执行栈帧。为什么这里用astore_1,因为在局部变量表中参数先压栈,局部变量后压栈,非static方法的有一个隐式的this*参数第一个压栈,而static方法没有。main是static但是有一个args入参所以局部变量表第一位是这个入参。
main方法的栈和栈帧以及局部变量表的指向
局部变量表寄存器r14的状态
3.5 字节码aload_1
aload_1:从局部变量1中装载引用类型值,它的outTostate为atos,所以这里是将局部变量表的第二个变量放入rax在下一个字节码中push rax
汇编:
3.6 字节码invokevirtual
复杂,功能和invokespecial挺像字节码执行完后的栈和寄存器
局部变量表
4.转入exp()方法
4.1 字节码aload_0
取局部变量表的第一个变量。从方法上看,这是一个非static方法有一个隐式入参this指针,所以读取的就是当前类的oop地址。
汇编是 mov rax,r14;
4.2 字节码dup
上个字节码aload_0的outTostate是atos,dup的inTostate是vtos,所以push rax ;然后复制栈顶并压栈 mov rax,[rsp]; push rax;
4.3 字节码monitorenter
直接看汇编
pop %rax
cmp (%rax),%rax
push %rax
mov 0x8(%rax),%eax
............................省略...................................
第一步就是将栈顶弹出到rax,在上个字节码dup执行完时栈顶的数据是oop,这里pop后rax就是指向oop的指针有了这个指针就还有啥取不到
oop长这样
五、断点代码实现
1.第一次修改
既已获取到oop那么,根据oop的数据结构可以整理出代码的构思及雏形。由于是ubunt所以使用AT&T
1.1 首先不能影响原先逻辑,那就要保存现场,而我这只需要一个标识可以让我断点所以只要一个寄存器就行了,那保存现场就直接push rax
1.2 x86_64下rax需要加+8才能拿到Klass*, 之所以用eax是因为默认开启了指针压缩,八字节数据中除了压缩后的四字节对象指针外可能还有nonStatic的类变量数据存在(看不懂先了解下大小端的存储和读取)
mov 0x8(%rax),eax;
1.3 压缩的指针decode,rax得完整的指针(只适用Xmx参数小于等于4G,原因等我把元空间的文章写出来)
shl 0x3,rax;
1.4 这里可以分支一下,如果只是想要klass信息的rax就是.想要拿到类名就需要做内存偏移,klass类中Symbol* _name属性是存当前类的类名,偏移是24,汇编就是
mov 0x18(rax),rax;
1.5 代码的对称性,既然上面保存了现场这里就需要恢复现场,pop rax
完整汇编
push rax;
mov 0x8(%rax),eax;
shl 3,rax;
mov 0x18(rax),rax;
pop rax
//转换成jvm的代码
push(rax);
movl(rax,Address(rax, 0x8));
shlptr(rax,0x3);
movptr(rax,Address(rax, 0x18));
//这里留一个代码插入点
pop(rax);
1.6 ide设置断点必须在源码上,汇编只能通过GDB调试(vs确实好用,可惜没有ubuntu版)。那就要一个函数来接收rax,我模仿了jvm的宏在
//在interpreterRuntime.hpp申明一个函数
static void breakpoint(JavaThread* thread, Symbol* cname /** klass *k **/);
//在interpreterRuntime.cpp中实现
IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::breakpoint(JavaThread* thread, Symbol* cname))
if(strcmp(cname->as_class_string() ,"Test") == 0 ){
int isBreakpoint = 1;
}
IRT_END
//interpreterRuntime.hpp没有引入string.h strcmp用不了,所以要自己加一下
#include<string.h>//放上面去
//cname->as_class_string()这个函数也是自己定义的,因为Symbol的as_C_string()函数在调用时经常取不到当前线程的arean区域而报出段错误,暂时不知道啥原因,所以我就直接申请os内存了
//在symbol.hpp申明
char* as_class_string() const;
//在symbol.cpp实现
char* Symbol::as_class_string() const {
int len = utf8_length();
char* str = (char*)malloc(len+1);
return as_C_string(str, len + 1);
}
1.6 函数搞定后将breakpoint函数插入到"代码插入点"。完整的代码:
push(rax);
movl(rax,Address(rax, 0x8));
shlptr(rax,0x3);
movptr(rax,Address(rax, 0x18));
call_VM(noreg, CAST_FROM_FN_PTR(address, InterpreterRuntime::breakpoint), rax);
pop(rax);
call_VM jvm自己实现了保存、恢复现场
1.7 进入断点
六、代码改进
我是一个javaer,所以魔数这玩意有点不太喜欢。
/** movl(rax,Address(rax, 0x8)); **/movl(reg,Address(reg, wordSize));
/** shlptr(rax,0x3); **/shlptr(reg,LogMinObjAlignmentInBytes);
并且像_name在kalss中是private的,假如我要改成public以方便在其他函数中调用就要在其他位置重新申明,这样会造成offset的变化所以在这里可以改进一下
// klass.hpp中实现一个静态函数
static ByteSize cname_offset() { return in_ByteSize(offset_of(Klass, _name)); }
//这样 movptr(rax,Address(rax, 0x18));这段就可以改成
movptr(reg,Address(reg, Klass::cname_offset()));
最后在封装一下方便其他地方用
//在interp_masm_x86_64.hpp中申明函数
void breakpoint(Register reg);
//在interp_masm_x86_64.cpp中实现函数
void InterpreterMacroAssembler::breakpoint(Register reg){
push(reg);
movl(reg,Address(reg, wordSize));
shlptr(reg,LogMinObjAlignmentInBytes);
movptr(reg,Address(reg, Klass::cname_offset()));
call_VM(noreg, CAST_FROM_FN_PTR(address, InterpreterRuntime::breakpoint), reg);
pop(reg);
}
最后的最后
void TemplateTable::monitorenter() {
transition(atos, vtos);
// check for NULL object
__ null_check(rax);
__ breakpoint(rax);//这里调用
.......................省略部分............................
}
上面的是静态的写死类名为Test,每次换个类就要重新编译比较麻烦,想动态参数断点我的想法是:
在RUNTIME_FLAGS宏申明自己的宏,然后在启动参数中增加自己的参数就可以将strcmp(cname->as_class_string() ,“Test”)中的Test改成动态的了,例如:
在RUNTIME_FLAGS中加入一个MyFlagTest
在启动参数中加入了
这样就动态不需要重新编译了,也可以在在启动参数写入多个值,用标识符号分割,然后循环下比对,我懒得实现了
结语
我个人的想法是在试着改jvm源码,而不是只想搞一个方便断点的工具