🔗 课程链接:李樾老师和谭天老师的:
目录
2. Virtual Calls 的关键步骤:method dispatch 的基本概念
5.2.5 Class Hierarchy Analysis (CHA)
5.2.6 Call Graph 的构造(via CHA)⭐
5.3 过程间控制流图 (Interprocedural Control-Flow Graph, ICFG)
5.4 过程间数据流分析(Interprocedural Data-Flow Analysis)
第五章 过程间分析
5.1 为什么需要过程间分析
1. 过程内分析intraprocedural:
- 不处理方法调用,
- 面对方法调用进行最保守的假设,以进行safe-approximation
- 过于保守的假设容易导致 source of imprecision(不精确)
如下图所示,在常量传播分析中,变量 n 调用了方法ten(),虽然该方法返回值是常量,但是过程内调用采用最保守的假设,会认为这个值可能是不固定的,讲n=NAC
2. 过程间分析interprocedural:
- 处理方法调用
- 沿过程间控制流边传播数据流信息(调用边call edges 和 返回边return edges )
- 重要的方式:调用图
如下图所示:
5.2 Call Graph
5.2.1 调用图的概念
调用图是程序中的调用关系的表示。从本质上讲,调用图是一组从调用点到其目标方法(callees)的调用边(call edges)。
如下图所示,foo函数中,调用到了ten和addOne函数,调用图如右图所示。
5.2.2 调用图的应用
调用图是非常重要的程序信息
- 所有过程间分析的基础
- 程序优化、理解、debugging、测试等等
5.2.3 针对面向对象语言的调用图的构造方法
构造调用图的算法,这四种方法,越往下精度越高,越往上速度越快。
- class hierarchy analysis (CHA)
- Rapid type analysis (RTA)
- Variable type analyss (VTA)
- Pointer analysis(k-CFA)
在这一章中会介绍第一种方法CHA,下一章会介绍最后一种Pointer analysis
5.2.4 预备知识
1. Java中的方法调用
Java中的调用主要分为三大类,对应了四种指令,(Java8开始还引入了invokedynamic,主要用于实现动态类型语言的,在这里我们不进行讨论)
- invokestatic
- 调用的目标方法是static methods (静态方法)。所以没有reciever object
- 目标个数只有一个,在编译期可以确定。
后两种调用的都是instance(实例)方法:
- invokespecial
- 有三个用途:调用构造函数、调用私有的实例方法、调用父类的实例方法,
- 它的目标个数也是只有1个,在编译期可以确定。
- invokeinterface和invokevirtual
- 调用其他的实例方法,
- 因为有多态的存在,所以可能调用不同的方法,因此目标方法可能大于一个,具体调用的方法要在运行时才能确定。
因为前两类处理起来相对比较简单,所以我们过程间分析的关键是对于第三种Virtual call的分析。
2. Virtual Calls 的关键步骤:method dispatch 的基本概念
在程序运行中,有一个virtual call,具体调用的方法是动态调用的时候的需要去解的,求解动态的时候具体目标方法的过程叫做method dispatch,这个过程中涉及到两个要素:
- 接收对象的类型(例如说函数指向的具体对象的类型): c
- call site这一点上的方法签名method signature : m
通过签名 signature,我们可以唯一地确定一个具体的方法,具体组成如下:
- signature有三个部分组成:class type + method name + descriptor
- descriptor 又有两部分组成:return type + parameter types
3. 实现 Method Dispatch 的具体过程
接下来,我们就可以定义一个函数Dispatch(c, m),它模拟了动态时method dispatch的过程。他有两个参数 c 和 m,这两个参数就是前边所提到的method dispatch的两个要素。
具体的过程如下所示:
如果class c 里包含一个和m有着相同名字和descriptor的非抽象方法m’(因为dispatch要找的是一个具体的能被调用的方法,所以必须是非抽象),那么就直接返回m’,我们就认为dispatch找到了目标函数。如果c 中没有满足条件的方法,那么我们就去c的父类里面找,重复这个过程直到找到为止。
由此可以看出,在方法签名中,起决定性作用的就是名字和discriptor。
4. Dispatch: An Example
如下图所示,对Dispatch(B, A.foo()) 是在B里找foo方法,B里没有,就会去其父类A中找,所以是A.foo(),而第二个Dispatch(C, A.foo())是在C里找foo方法,C里又,所以是C.foo()
通过这个例子其实能大致看出CHA的作用,就是一个程序中可能有很多个foo,这个算法就是为了找到这里使用的foo函数是在哪儿定义i的,找到了之后可以根据具体找到的那个方法的返回值来确定此处的数据,从而提高数据流分析的准确性。
5.2.5 Class Hierarchy Analysis (CHA)
1. 基本概念
这个方法可以用来解call graph,需要整个程序中的class层次的信息(例如父类、子类信息),根据call site的receiver variable(接收变量)的declared type(声明类型)来解析virtual call(虚拟调用)。
如下图所示,这里的call site 为a点,其声明类型为A,所以CHA就会根据A的方式来算它的目标方法。
具体思想:假设变量a可以指向A类以及所有A类的子类的对象,CHA去解目标方法的过程就是去查询A类的继承结构class hierarchy。
CHA论文出处:
*Jeffrey Dean, David Grove, Craig Chambers, "Optimization of Object-Oriented Programs Using Static Class Hierarchy Analysis". ECOOP 1995.
2. CHA的具体实现过程
这里,我们定义了一个方法Resolve(cs),该方法通过CHA解析调用点(call site, cs)的可能目标方法(possible target methods)。
算法的总体情况如下,传入的参数为调用点cs;初始化T为空,存放目标方法;用m保存cs点处的方法签名。
算法的核心为红色框框圈起来的部分,前边预备知识中提到,函数调用有三种类型,算法里的三个if分支也是分别对应3中调用情况。接下来,就针对每种情况来解决调用问题:
① static call 静态调用
如下图,对于static call 而言,目标方法就是写在call site中的方法,如图所示,直接用类C调用方法而不需要定义对象c,因此很明显调用的就是当前类的方法,所以可以直接加到集合T中。
② special call 特殊调用
special call 需要处理三种情况:父类方法、构造函数、私有方法。
这里先以父类方法为例,如图所示,C类继承B类,C类里有一个super调用,这个foo()虽然在当前类有定义,但是实际使用的是父类B的foo(),因此需要使用Dispatch函数(因为B类不一定就有这个foo方法,B继承自A,foo方法不一定时B 的,所以需要再用dispatch),其中的foo()的签名m由编译器返回信息可以知道是B的,那么获取foo()返回值的c也指向B,也就相当于在父类中寻找了。
另外两种special call 的方法:私有方法和构造函数,如下图所示,对于这两种方法,目标方法其实于static call一样,就是写在签名里的m,但是因为父类方法的调用是比较特殊的,所以为了能够处理这三种情况,需要用一下dispatch。
例如调用私有函数this.bar(),此时编译器返回的信息能算法知道此时m指向的仍是C,那么Dispatch(cm,m)得到的仍然是这个私有方法C.bar()。
因为Dispatch返回的目标方法是唯一的,这也就是为什么之前说special call目标个数也是唯一的。
③ virtual call
对virtual call 的处理方式,也是CHA与其他算法的不同之处,除了上述提到的情况之外,其他的情况都用virtual call 来处理,
- 首先会取出cs位置上的接收变量的声明类型(declared type),这里是C,
- 接下来会对 C 及其所有的直接子类及其子类的子类(这里定义为c'),调用Dispatch(c', m),并将所有的结果都加到T中。
因为除了super会将m的类类型指定为父类以外,别的都是当前类,所以该算法会对此方法做一个Dispatch(c,m)并将c的所有子集以及子集的子集全都做一次Dispatch(c’,m)。直观来看,可以分为两步,第一步是对本身做一次Dispatch,看看当前类中是否有foo(),没有的话就到父类中递归地找;第二步是在当前类地所有子集中找到所有的foo(),然后将这些foo同第一步找到的foo全都加入T中。
3. CHA: An Example🌰
如下图所示,有4个类,其中A有foo方法,C、D都复写了foo方法
Q: 这里有3个call site,如下图所示,求每个call site的目标方法
答案:
1. Resolve(c.foo()) = {C.foo()}// 这里是virtual call,所以会把C及其子类进行dispatch,又因为C没有子节点,所以只需要对C进行dispatch(dispatch的作用是遍历这个class 看看有没有对应函数,没有的话再遍历其父类),C有foo,不需要遍历其父节点,所以答案为{C.foo()}
2. Resolve(a.foo()) = {A.foo(), C.foo(), D.foo()}
// 同理,会对A及其子类进行dispatch,也就是对A B C D 进行dispatch,对A dispatch的结果就是A,对B dispatch的时候,B没有foo,所以会找其父类,结果也是A,再对B 的子类 C D 分别进行dispatch,结果加入CD,所以答案为 A C D
3. Resolve(b.foo()) = {A.foo(), C.foo(), D.foo()}
// 注意,这里会先对B, C, D分别进行 dispatch,对B dispatch的时候,B 没有foo,所以会找到其父类A,A有foo()函数,所以结果加入A,在对C D 进行dispatch,同理,所以答案也是 A C D
Q:假如第3个情况中, B b = new B() 呢?
答案:
4. Resolve(b.foo()) = {A.foo(), C.foo(), D.foo()}
// 注意,按照算法,CHA只考虑变量的声明类型,对于这个情况,哪怕写了B b = new B(),这里还是会根据其声明类型B来解目标方法,所以答案仍是A C D
这也是CHA的一个问题,C.foo() 和D.foo()对于这个情况来说是虚假的目标方法(spurious call targets)。CHA只考虑声明类型,这样就会导致精度的下降。
4. CHA的特征
- 优势:fast 快
- Only consider the declared type of receiver variableat the call-site, and its inheritance hierarchy 只考虑在调用点上接收变量的声明类型及其继承层次结构。
- Ignore data- and control-flow information. 忽略数据和控制流信息
- 缺点:imprecise 不精确
- Easily introduce spurious target methods 容易导致假的目标方法(解决这个问题,会在后续的几节课中,用别的方法解决)
CHA最常用的场景就是IDE中,如下图所示,在idea,可以提醒目标方法。
IDEA->导航(navigate)->方法层次结构
5.2.6 Call Graph 的构造(via CHA)⭐
上边讲的都是为单个函数某个程序点构造调用关系的方法,接下来看一下为整个程序构造调用图的步骤
1. 步骤
- 从入口方法开始(一般是Amin方法)
- 对每个可到达的方法m,通过CHA算法 Resolve(cs) 为m中每个调用点cs求解目标方法
- 每遇到一个新方法就重复上述步骤,直到没有新方法被发现
如下图所示,从main出发构造调用图,但是可能会有一些方法也是不可达的
2. 算法
算法名字叫BuildCallGraph,传入的参数为程序的入口,具体算法如下图所示:
3. 举个栗子🌰
过程还是很简单的,这里就不再赘述,大概就是从main开始,对call site 进行resolve分析,找到目标方法放入WL中,并加上调用边。然后循环再对WL中的方法进行分析,直到WL为空。
5.3 过程间控制流图 (Interprocedural Control-Flow Graph, ICFG)
5.3.1 ICFG与CFG对比
- CFG表示单个方法的结构
- ICFG表示整个程序的结构
- 有了ICFG,我们就可以做过程间的分析
- 一个ICFG是由程序中各个方法的CFG所构成,并需要加上两种额外的边:
- 调用边 (Call edges) : 从调用点call sites 连接到 其目标方法callees的入口entry nodes
- 返回边 (Return edges): 从return 语句 连接到 紧跟着的调用点call sites的下一个语句(i.e., 返回点return sites)
-
ICFG = CFGs + call & return edges
-
连接这两种边的信息来自call graph
-
5.3.1 ICFG: An Example🌰
如上图所示,ICFG就是CFG加上两种边,但是这里有一个问题,为什么还要保留黄色部分的边呢?这个边(从call site 到return site的边)叫做call-to-return edge,这个边可以用来在ICFG中传播本地数据流(就是函数内部的数据流)。
如果没有这条边,我们需要跨越其他方法来传播本地数据流,这是非常低效的。(如下图所示,其实这个图上的x,a ,b 等数据信息时会在5.4.2中介绍到的)
5.4 过程间数据流分析(Interprocedural Data-Flow Analysis)
5.4.1 过程间与过程内对比
过程间的数据流分析就是对有方法调用的整个程序,基于这个程序的ICFG进行数据流分析。
如上图所示,对比过程内和过程间,在程序表述上,过程内主要是关注每个CFG,而过程间需要加上调用和返回边,在转换函数中,过程间还需要加上两种边的转换函数edge transfer:
- Call edge transfer: 将数据流从call node 传送到被调用节点callee (沿着call edges,传递参数值)
- Return edge transfer: 将数据流从callee上的return node传送到return site(沿着return edges,传递返回值)
对于过程间调用的node transfer:
- node transfer:与之前提到的传递函数基本一样,但是多了一个性质:
对于每个调用节点call node (例如b=addOne(a)),会将等式左侧的数值kill掉,然后在下一步中由返回边传递函数重新赋值。这个操作可以在返回值与原值不同时防止数据冲突。
5.4.2 举个例子🌰
如下图,显示了如何对左边的程序进行过程间的常量传播分析,用黄色节点表示IN数据流,蓝色表示OUT数据流。
例如,对于 b= ten() 节点,OUT处会把b=7删掉,因为如果不删除,等return流分析完之后b=10就会与b=7冲突,当成merge操作,将b定义为NAC,导致精度下降。
当然这个例子是比较简单的,没有复杂的control merge操作,但是也可以很好的理解 call edge 与 return edge 上需要进行的操作,也可以很好地理解call-to-return edge 的作用。
其他的写的很好的博主的文章:
【软件分析/静态程序分析学习笔记】6.过程间分析(Interprocedural Analysis)_童年梦的博客-CSDN博客