阿尔萨斯 原理探究 仿写arthas-watch

前言

在没完成watch功能之前,一直以为watch应该是最好实现的,但是写了快两天才写完!!主要是涉及到的知识点比较多。期间被两个问题卡着:

  1. 如何获取到方法的入参类型与值?

  2. 如何获取到方法的返回结果的类型与值?

还没看到阿尔萨斯是如何处理这两个问题的,不过在我写的iarthas里,我是通过字节码中的局部变量与操作数栈来解决第一个问题的。第二个问题我是通过字节码指令记录最后一次访问局部变量,因此获取返回结果的局部变量中的索引位置。现在没看懂没事,后文会详细的介绍。先来看一下我实现的watch方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
4
1
-------------------
监控当前方法:doAdd
入参:
参数类型-- int@1
参数类型-- String@abc
参数类型-- long@11
参数类型-- Lagent/Job;@agent.Job@7adf9f5f
参数类型-- Lagent/Main;@agent.Main@85ede7b
参数类型-- double@0.11
出参:
参数类型-- int@4
4
1
-------------------

可以看到一开始方法只是简单的打印出测试输出4 ,1 ,通过 Attach 到目标主机,对目标类的方法进行retransformer,加强了原方法,输出了监控方法的入参、出参的类型与值。github

结合watch与trace应该可以完成Doom中核心的功能:流量数据采集!!

局部变量与操作数栈

我们知道,Java 代码是在线程内部执行的。每个线程都有自己的执行栈,栈由帧组成。每个帧表示一个方法调用:每次调用一个方法时,会将一个新帧压入当前线程的执行栈。当方法返回时,会将这个帧从执行栈中弹出。

每一帧包括两部分:一个局部变量部分和一个操作数栈部分。局部变量部分包含可根据索引以随机顺序访问的变量。由名字可以看出,操作数栈部分是一个栈,其中包含了供字节代码指令用作操作数的值。用下面例子来说明:

1
2
3
public void add(int x,int y){
int result = x+y;
}

调用add方法时会创建一个帧,一开始会为该帧的局部变量与操作数栈进行初始化,一般局部变量会将当前 this 押入到第0位置,再将两个方法参数 x , y 分别压入到第1和第2的位置,那么初始化好的局部变量就是:【this , x , y】,操作数栈还是为空:【】

add方法对应的字节码操作为如下:

1
2
3
4
ILOAD 1 	//1
ILOAD 2 //2
IADD //3
ISTORE 3 //4

当执行1,2两个指令的时候,ILOAD 1 会将局部变量的1号位置中的值压入到操作数栈中,ILOAD 2会将局部变量2号位置中的值压入操作数栈中,操作数栈对应的是【x , y】。

IADD会将当前对操作数栈进行出栈的操作,出栈两个,并执行ADD,将结果再压入到操作数栈中,操作数栈现在保存了【x+y】。

ISTORE 3 会对操作数栈进行出栈操作,并将值存储到局部变量的3号位置,最后局部变量对应的是:【this , x , y , x+y】,操作数栈为空:【】。

需要注意局部变量部分和操作数栈部分中的每个槽(slot)可以保存除 long 和 double 变量之外的任意 Java 值。long 和 double 变量需要两个槽。如果忽略这点,在实现 watch 过程中会采坑的。

获取方法的入参类型与值

假设我要对以下方法进行 watch ,那么第一步我需要知道该方法所有入参的类型先!!我们可以通过 ASM 中visitMethod 来获取方法的描述符

1
2
3
public int doAdd(int x,String s,long l ,Job j,Main n,double d) throws Exception{
return x+1;
}

ClassVisitor.visitMethod方法:

1
2
3
4
5
6
7
8
9
10
11
/**
* 可以根据Desc获取入参与出参的类型,desc的值可以参考下面
* void m(int i, float f) 的Description:(IF)V
* int[] m(int i, String s) 的Description:(ILjava/lang/String;)[I
* int m(Object o) 的Description:(Ljava/lang/Object;)I
* Object m(int[] i) 的Description:([I)Ljava/lang/Object;
*/
@Override
public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) {

}

一些 Java 类型的类型描述符:

你想输入的替代文字

因此我们可以获取到doAdd的方法描述符:(ILjava/lang/String;JLagent/Job;Lagent/Main;D)I 对应起来就是(int ,String ,agent.Job , agent.Main ,double),最后一个I表示返回类型为int。

回想一下前面讲到的局部变量和操作数栈,那么在调用 doAdd(int x,String s,long l ,Job j,Main n,double d) 方法时局部变量会初始化为:【this , x , s , l , j , n , d】,那么要打印出这个值,我们只需要把要打印的值的下标 LOAD 到操作数栈就可以了!!比如我要打印参数int x的值,那么只要得到 x 的下标1.

1
2
3
4
5
6
7
8
@Override
public void visitCode() {
//省略....
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(iType.getLoadOpcode(), index);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(" + iType.getPrintDesc() + ")V", false);
//省略.....
}

在上一节中我们提到long和double是会占用两个solt的,因此你要打印参数long l时,它的下标是3,但是agent.Job的下标就是5了,因为3,4都被long占用了,因此LOAD 的时候需要计算好参数的下标。 我用index来表示每个参数在局部变量中的下标位置。

另外一点也要注意的是:int类型对应的是ILOAD, double类型对应的DLOAD,即每个参数类型都要有对应的opcodes :

ILOAD, LLOAD, FLOAD, DLOAD 和 ALOAD 指令读取一个局部变量,并将它的值压到操作数栈中。它们的参数是必须读取的局部变量的索引 i。ILOAD 用于加载一个 boolean、byte、char、short或int局部变量。LLOAD、FLOAD和DLOAD分别用于加载long、float或double 值。(LLOAD 和 DLOAD 实际加载两个槽 i 和 i+1)。最后,ALOAD 用于加载任意非基元值,即对 象和数组引用。与之对应,ISTORE、LSTORE、FSTORE、DSTORE 和 ASTORE 指令从操作数栈中弹出一个值,并将它存储在由其索引 i 指定的局部变量中。

具体实现可以在代码上看。

获取方法返回结果类型与值

获取方法返回结果当时被卡了很久!!最后突然想到在执行Return的时候,它会将要返回的值从局部变量中LOAD出来到操作数栈,那么我们将最后操作局部变量visitVarInsn方法中的下标记录下来,这个下标就是返回值在局部变量的下标了!!

methodVisitor.visitLineNumber(18, label10);
methodVisitor.visitVarInsn(ILOAD, 11);
methodVisitor.visitInsn(IRETURN);

下标11就是返回值在局部变量中下标位置,因为return是最好一个操作,所以用lastVar来记录即可:

1
2
3
4
5
@Override
public void visitVarInsn(int opcode, int var) {
lastVar = var;
super.visitVarInsn(opcode,var);
}

把局部变量LOAD出来打印一下即可!!

1
2
3
4
5
6
7
8
9
10
@Override
public void visitInsn(int opcode) {
if (opcode <= Opcodes.RETURN && opcode >= Opcodes.IRETURN) {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(parOutITypes.getLoadOpcode(), lastVar);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(" + parOutITypes.getPrintDesc() + ")V", false);

}
super.visitInsn(opcode);
}

核心代码

WatchMethodVisitor 算是watch功能的核心,完整代码可以参考github

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class WatchMethodVisitor extends MethodVisitor{

public String name ;
public ArrayList<IType> parInputITypes; //存放入参的类型Type
public int lastVar ; // 存放最后一个变量出栈的位置,即为返回值的下标!!
public IType parOutITypes; //存放输出类型
public WatchMethodVisitor(MethodVisitor mv, String n,ArrayList<IType> ITypes,IType outITypes) {
super(Opcodes.ASM5, mv);
name = n;
parInputITypes = ITypes;
parOutITypes = outITypes;
}

@Override
public void visitCode() {
//
// 输出当前方法名
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("监控当前方法:"+name + "\n"+" 入参:");
mv.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

// 输出 入参的变量类型和与值
if(parInputITypes !=null && parInputITypes.size()>0) {
int index = 1;
for (int i = 0; i < parInputITypes.size(); i++) {
IType iType = parInputITypes.get(i);
//打印变量类型
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn(" 参数类型-- "+iType.getClassType()+"@");
mv.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "print", "(Ljava/lang/String;)V", false);
//输出变量值
/**
* 根据类型,String 返回 - ALOAD ,int 返回 - ILOAD
* 根据类型来决定当前变量栈中的索引位置index
*/
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(iType.getLoadOpcode(), index);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(" + iType.getPrintDesc() + ")V", false);
index += iType.classTypeByteSize;
}
}
super.visitCode();
}


@Override
public void visitInsn(int opcode) {
if (opcode <= Opcodes.RETURN && opcode >= Opcodes.IRETURN) {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn(" 出参:");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn(" 参数类型-- "+parOutITypes.getClassType()+"@");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "print", "(Ljava/lang/String;)V", false);

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(parOutITypes.getLoadOpcode(), lastVar);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(" + parOutITypes.getPrintDesc() + ")V", false);

}
super.visitInsn(opcode);
}


@Override
public void visitVarInsn(int opcode, int var) {
lastVar = var;
super.visitVarInsn(opcode,var);
}


}

思考

结合前面的trace 与 watch功能,应该能够记录一次调用链路中完整的数据流量,那么回放功能可以参考tt的实现方式。