Abstract
过程间分析需要基于程序的Call Graph
来完成。由于CHA Analysis
过度追求速度造成误报——而false positive
对于常量传播这样的must
分析来说是不可接受的。因此指针分析在POPL'1980
被第一次提出Interprocedural Data Flow Analysis in the Presence of Pointers, Procedure Variables, and Label Variables。
Pointer Analysis computes an over-approximation of the set of which memory-locations(objects) a pointer can point to.
指针分析除了可以构建Call Graph
等程序基本信息之外,还可以用于编译优化,程序鲁棒性和安全等各个领域,是最基础的静态分析算法之一——比如流行的别名分析Alias Analysis
就是从指针分析中派生出来的:Alias information can be derived from points-to relations.在指针分析的输出指向关系中是否有两个指针可以指向同一个对象。
Introduction
指针分析是一种may
分析,输入时整个程序,分析结果的输出是类似于map的一系列指向关系Points-to relations
。
KeyFactors
作为一项比较复杂的技术,指针分析框架的精度和速度取决于四个很主要的因素:
Factor | Problem | Choice |
---|---|---|
Heap abtraction | How to model heap memory? | Allocation-site/Storeless |
Context sensitivity | How to model calling contexts? | Context-sensitive/Context-insensitive |
Flow sensitivity | How to model control flow? | Flow-sensitive/Flow-insensitive |
Analysis scope | Which parts of program should be analyzed? | Whole-program/Demand-driven |
Heap Abstraction
如何抽象堆操作,比如循环和递归会在动态运行时创建很多的堆对象,如何将这些unbounded
的操作进一步简化抽象成finite
的静态分析对象。
学术界有两个流派来完成堆抽象:Store based model
和Storeless model
。本节课介绍第一个流派下面的Allocation sites
方法来对所有堆对象完成建模——One abstract object per allocation site
。即每一个分配点new
的堆对象都抽象到一个object
中。
Context Sensitivity
在函数调用的时候会产生上下文context
。一个method
被调用多次的话如何对每一次的上下文进行抽象。关于上下文抽象也有两种流派:
Context-sensitive
: 对每一次调用都区别对待,具体调用具体分析Context-insensitive
: 对一个method
的所有调用都merge到一起,一次性处理
上下文敏感是提高静态分析效率非常有效的工具之一,是这门课需要讨论的重点。
Flow Sensitivity
如何对控制流抽象,也有两种流派:
Flow-sensitive
: 确保程序的执行顺序execution order
,维护每一个程序点的points-to relations
表Flow-insensitive
:忽略执行顺序,维护整个程序的指向表
目前学术界还没有直接研究表明flow sensitivity
相比于flow insensitivity
对Java
这样的OOP语言会有多少优势。
Analysis Scope
在进行指针分析的时候,如何选取需要分析的程序段?
- Whole-program
- Demand-driven
是直接分析整套程序还是以需求为驱动进行特定程序片段的分析。实际上如果需求很多的话,单独分析的复杂度和直接进行整套程序分析的复杂度不相上下。
Concerned Statements
上述的Key Factors
是具有全局性质的-即针对整个指针算法框架,那在实现上-即针对Java
编程语言的具体指针分析框架来说,更细粒度的Concerned Statements
会是什么呢?
Pointers in Java
首先需要总结一下Java
中所有可以用作指针的声明。
Local variable:x
Static field: C.f
Instance field: x.f
Array element: array[i]
共有4中具体的指针类型——static field
是一种全局变量。数组元素在分析时可以忽略掉索引,看成是可以指向任何数组元素field的指针类型array.arr
,因此也被看成是一种Instance field
。综上来看,针对Java语言的指针分析主要处理Local variable
和instance field
两种指针类型。
Pointer-Affecting Statements
明确了指针分析研究对象Pointers
的具体类型之后,继续思考全程序的指针分析如何更具体的完成实现?就语句本身来说例如if/for/break
这样的控制流并不会对指针分析的结果造成影,我们需要关注真正的pointer-affecting statements如下:
New: x = new T()
Assign: x = y
Store: x.f = y
Load: y = x.f
Call: r = x.k(a, ...)
重点关注virtual calls
Foundations
Rules
Domains and Notations
指针分析是一种以指向关系作为传递信息的数据流分析。所以其理论也需要引入域和某些符号解释:
- Variable: x, y ∊ V
- Fields: f,g ∊ F
- Objects: oi,oj ∊ O (Allocation sites)
- Instance fields: oi.f, oj.g ∊ O⨉F
- Pointers: Pointer = V⋃(O⨉F)
其中Variable表示程序中的所有变量指针类型。Object和Fields分别指的是程序中分配堆对象的创建点和抽象堆对象中的域——对象和域组合起来的Instance Fields也表示程序中一种指针。Pointers指的是程序中所有的variable
和instance field
——V ⋃ (O x F)
。
最后引入指向关系pt
(其本身就是一种映射, 代表指针可能指向的对象的集合):
Points-to relations:
pt: Pointer ⟶ 𝓟(O)
𝓟(O)
表示对象O的powerset
pt(p)
表示指针p的指向关系集合
理解上,指针指向对象——指针p的值域就是V ⋃ (O x F)
,对象o的值域就是𝓟(allocation sites)
。p⟶o ≡ o∊pt(p)
即指针p指向对象o等价于对象o属于指针p的指向关系集合。
在此基础上,引入如何处理Pointer-Affecting Statements
的形式化Rules:
分数上面是前提premise
而下面是结论conclusion
。
New:
New
规则中的oi 表示一个Allocation site
在这一点创建出来的对象,那么指针分析如何处理一个New
语句——即执行完这条语句后让x指向oi即oi ∊ pt(x)。Assign: 如果oi属于y的指向集,那在执行完这条语句后oi则需要加入到x的指向集中。
Store: 将新对象保存到该对象的一个域f——如果x指向oi而y指向oj,那执行完这条语句之后oi.f需要指向oj。
Load: 将某个对象的域f加载到新对象中——如果x指向oi而oi的域f指向oj,那执行完后将oj加入到y的指针集当中。
How to Implement Pointer Analysis
在确定好Domain和Rules之后开始明确如何实现全程序的指针分析算法。
Essentially,pointer analysis is to propagate points-to information among pointers(variables & fields).
在两种指针(variable
和fields
)之间根据Pointer-Affecting Statements
建立和传递指向关系points-to
。从另一个角度来说指针分析就是在解决指针之间的inclusion constraints
即对于x = y
语句指针x一定包含指针y的所有指向信息。而关键的是当pt(x)
更新时,这些改变也需要传递给x的其他相关指针。
而在实现上,我们用Graph
来连接相关联的指针,当pt(x)
更新时,将更新的数据流(数据流分析根据Domain不同而不同,指向关系也是一种数据流信息)传递给x
的后继元素,这个图就是Pointer Flow Graph
。
Pointer Flow Graph(PFG)
一个程序的PFG
是一种表示points-to
信息如何在pointers
之间流动的有向图,图中的节点代表了V ⋃ (O ⨉ F)
——变量或者抽象对象(因为PFG是静态分析建立的,所以是抽象的堆对象)的域,边代表了对象在指针之间的流动关系Pointer ⨉ Pointer
——比如x⟶y
代表了指针x的指向对象集合可能(指针分析是may
分析)会流动到y的指向集合。
PFG中的节点就是程序中的各种指针,而边是根据指针分析的Rules建立的。
有了PFG,指针分析问题就可以看成是在PFG上求其传递闭包的问题,比如PFG中a点到b点是可达的,那a中的数据流信息就可以直接流到b节点上。
综上,指针分析的思路主要有两部分:创建PFG——>在PFG上传递指向信息。而难点在于这两者是互相依赖的。为什么创建PFG也会依赖指向信息呢?
因为在处理instance fields
的指向关系的时候,前提是需要知道这些field
的object
的指向信息——由allocation sites
更新得到。因此PFG
本身也是随着指针分析的过程动态更新的。
Algorithm
本节具体实现数据流分析算法,首先是使用到的数据结构:
S
: 输入程序的语句集合WL
: work list存储了需要处理的指向关系pair,其中每一项<n,𝓟(O)>*
意味着在𝓟(O)
中的信息需要传递给n
的指向关系集合pt(n)
中,用PFG
: 有向图,表示成为边的集合,其中s⟶t
表示s
的指向关系流向t
的指向关系Hybrid-Set
: 用来存在pt(s)
,即当元素小于16时用HashSet
大于16时用BitVector
Main Algorithm
1 | Solve(S) { |
AddEdge
首先每一条New
语句生成一条指向关系完成对WL
的初始化。这套算法的输入语句S
是流不敏感insensitive
的,即处理过程不会关注语句顺序。
AddEdge
函数主要是通过处理Assign
语句构建PFG
。每一条s⟶t
都对应着图上一条边,根据指针分析的inclusion constraints
原理将<t, pt(s)>
加入WL
中实现把指针s
的指向集合加到指针t
中。
Propagate
在构建完最基础的的PFG
之后,算法进入While
循环开始迭代处理WL
中的指向关系。取出一项<n, pts>
,过滤掉指针s
指向关系中指针n
已经存在的抽象对象即𝚫=pts-pt(n)——主要是为了避免更多的冗余操作已达到Differential Propagation
然后开始Propagate
函数,这个函数是真正修改了指针的指向关系即完成数据流的传递,换句话讲指针分析的过程就是在迭代过程中不断向pts
中加入新的数据流。而进入到函数内的𝚫是真正需要传递给n
的指向集合,接着又根据inclusion constraints
需要把指针n
刚更新过的指向集合加到其在PFG
上的后继中,故遍历整个图找到所有的后继节点s
并把<s, pts>
加到WL
中等待下一轮更新。
Differential Propagation
查分传播是为了避免处理冗余的指针信息。这是因为在PFG
上任意点的指向集合信息pt(n)
一定已经传播到了其后继元素,没有必要在传播一次。在整个算法中一共有两处查分传播,分别是在𝚫=pts-pt(n)
语句以及处理Store/Load
语句的代码片段foreach O_i ∊ 𝚫 do
。
首先Store/Load
语句映射到PFG
上的操作正好相反:
Store:x.f=y
oi.f ⟵ yLoad: y=x.f
y⟵ oi.f
关键是需要知道指针x
指向的抽象对象o_i
然后继续操作。而foreach o_i∊𝚫 do
正好清除掉指针x
以前的指向信息而保留了这次迭代中新加入的抽象对象。
就Store/Load
语句本身而言也是通过AddEdge
的方式来更新PFG
的,但这里要注意的是因为有可能两个变量会指向相同的抽象对象oi,即虽然oi对于x来说是新加入的指向信息,但是有可能在前几次迭代中有z.f=y
中的指针z
也指向抽象对象oi,即因为指针oi.f中oi是一个对象——有可能会被多个指针指向,因此这里的AddEdge
操作不能保证一定会引入新的PFG
边。
Fixed Point
算法符合不动点原理——WL
的功能就是向pt
中添加信息,而核心pt(n) ⋃ =pts
之后如果直接检查合并前后pt
集合是否有变化也以判断达到不动点。
How To Deal With Method Calls
全程序的指针分析算法还需要处理方法调用。和所有过程间分析一样,Inter-procedural pointer analysis
也需要建立call graph
。不过相比于CHA analysis
仅仅通过receiver object
的声明类型来构建调用图不同,指针分析还使用指向信息points-to relations
来使得call graph
的构建更加精确。比如void foo(A a) {... b = a.bar();...}
中points-to relations
和Call Graph
都是在指针分析的过程中动态更新的,比如想要建立pt(a)
需要知道函数foo
的调用边,而如果想要给a.bar()
建立调用边则也需要pt(a)
,这种方法被称为on-the-fly call graph construction
。
CHA
: 基于a
声明类型解析call targets
pointer analysis
: 基于pt(a)
解析call targets
Rule: Call
首先引入指针分析处理方法调用Rules的形式化描述:
指针分析器在遇到了r = x.k(a1, ...)
调用语句的时候,肯定会模拟编译器的运行时行为并解析:
- oi∊pt(x),m=Dispatch(oi,k) ⟶ oi∊pt(mthis),其中x是
caller
变量 - ou∊pt(aj) ⟶ ou∊pt(mpj),其中aj是形参
- ov∊pt(mret)⟶ov∊pt(mr),其中mret是返回值
为了解释的更清楚,下面对规则进行了进一步的解释:
- 根据
pt(x)
得到被调用方法k
的receiver object
即oi,然后调用Dispatch(O_i, k)
解析出target method
即m
- 将目标函数
m
的this
指针指向变量x
实际指向的对象即oi - 将形参
m_pj
的指针指向实参变量指向的对象集合pt(aj)
,并将形参变量和对应实参变量之间加上PFG
边(a1⟶m_pj) - 将调用语句的
LHS
指向目标函数m
的返回值变量指向的对象结婚m_ret
,并将这两个变量之间加上PFG
的边(r⟵mret)
主要需要关注的是receiver object
,target method
,arguments
和return value
。
而这里对形参和实参之间,返回值和左值之间连上PFG
的边而没有给目标方法和调用者之间加边的原因是因为caller
变量x
有可能有多个指向对象pt(x)
,而对于Dispatch
函数来说,只要receiver object
确定那target method
也是确定的。如果我们需要保证OOP语言中所有函数的this
指针都指向唯一正确的receiver object
即oi,前提就是保证不能让其他虚假的pt(x)
信息直接流入this
指针——如果贸然给x
和目标方法的this
指针之间加上PFG
只会导致引入虚假spurious
信息。
Algorithm
在过程间指针分析中,调用图的构建和指向关系的构建都是同步动态更新的,引入reachable
可达的概念:即从main
函数开始,将真正调用到的函数依次纳入可达方法集合中,所有的分析只从这些集合中完成。首先介绍算法中使用到的数据结构:
S
: 可达语句reachable statements
的集合S_m
: 函数m
中的语句集合RM
: 可达函数集合CG
: 调用边Call Graph
集合
1 | Solve(m_entry) { |
AddReachable
这个函数的功能就是将被调用的方法加入RM
,并将对应的语句加入到S
中。AddReachable
函数在整个算法一共两个地方被调用——一个是初始化将main
函数首先加入,其次就是在每一次有新的Call Graph edge
被创建的时候。函数本身还需要处理当前Reachable Method
中的New/Assign
语句。这是因为Load/Store/Call
语句的分析都是根据指向信息的变化(比如x.f=y
如果x
又指向了一个新对象)来更新处理的,但是New/Assign
不需要任何信息就可以独立完成分析。这也是Differential
的目的。
ProcessCall
该函数处理方法调用的Rules
。整个函数执行的前提就是该函数的receiver object
即x
有新的指向关系oi,j,k...被加入到𝚫时,这两个参数被传入到函数中开始执行。
首先使用Dispatch
函数解析出来target method
即m
变量。通过把<mthis,{oi}>加入到WL
中来完成将目标函数m
的this
指针指向变量x
实际指向对象即oi。
接下来添加Call Graph
,这里之所以要有if
语句判断该条调用边存在性即Differential
,是因为有可能之前有和oi相同类型的对象oj已经触发了ProcessCall
函数执行并加上调用边——因为整个While
循环继续执行的前提就是pt(x)
被更新,那更新前后的oi,j,k...完全有可能是同一个类型。
添加完调用边之后,最后添加参数和返回值。整个算法的输出是Points-to relations
和Call Graph
。至此上下文不敏感的全程序指针分析算法就介绍完了。
Context Sensitivity
上下文敏感是提升指针分析精度(尤其是针对Java
语言的指针分析)最有效的技术,也是指针分析领域近年来研究的热点。
Introduction
在程序动态运行的过程中,一个method
可能会在不同的context
下被调用多次。而在不同的上下文中method
的指针可能会指向不同的object
。Context Insensitivity .aka. C.I.
导致这些不同上下文的objects
通过返回值都被传递到程序的其他分支中进而产生虚假数据流信息。比如程序中:
1 | x = id(n1); |
因为函数id
被调用两次,故形式参数n
被传入两条PFG
边——分别是实参n1
和n2
,而这两个指针对象又分别指向不同的oi和oj,导致返回值n
的指向关系集合pt(n)
里增加了oi和oj。而n
和x
,y
又各自连着PFG
故导致pt(x)/pt(y)
的指向关系也是oi和oj——如果是过程间的常量传播则最终分析的结果就是x/y = NAC
,很明显丧失了精度。
Context sensitivity models calling contextx by distinguishing different data flows of different contextx to improve precision.
下面讨论开展上下文敏感的指针分析算法所需要考虑的几个方面:
Call-site sensitivity
Context sensitivity .aka. C.S.
用来区别不同上下文的方式中最常见的是call-site sensitivity
——每一个上下文都抽象成本次方法调用的动态运行时调用栈即一系列call-sites
组成的列表(随着调用层次深入c
的长度会增加,区别于用l
行号代表当前调用点),比如上面一段代码中的id
方法共有两个context
分别是[1]
和[2]
。从实现的角度来看,Cloning-Based Context Sensitivity
是实现C.S.
最直接的方式——同一个method
的多次调用都根据各自的context
区分成不同的Cloning-Based
版本,method
中的调用也随着各自的Cloning-Based
区分出来——one clone per context
。
Context-Sensitive Heap
从更细粒度的角度看,OOP是heap-intensive
的语言——所有的对象都是建立在堆区by new
。在实际设计中,上下文敏感的指针分析算法还需要考虑到堆敏感的问题——这是因为所有的语句其实都有自己的作用域,OOP的作用域就是函数。创建新对象的new
语句就是一个allocation site
,而当new
语句出现在一个函数中恰巧该函数又被多次调用的时候,同一个allocation site
会在不同的上下文背景下建立各自的对象,所以同一个方法中的堆对象也需要根据不同的heap context
来进行区分。最直接的方式就是直接继承其所在method
的上下文并从更加细粒度的角度处理指针分析算法:
Rules
下面引入上下文敏感指针分析的Rules
:
Domains and Notations
在上下文敏感分析中,程序中的所有元素(变量,方法,对象)都加上了上下文。首先引入上下文的集合C
,
- c,c',c'' ∊ C
而程序中的方法,变量和对象都和context
做了product
:
- Context-sensitive methods: c:m ∊ C x M
- Context-sensitive variables: c:x, c':y ∊ C x V
- Context-sensitive objects: c:oi, c':oj ∊ C x O
- Fields: f, g ∊ F
- Instance fields: c:oi.f, c':oj.g ∊ C x O x F
最后,引入这些程序中的两种上下文敏感的指针类型:
- Context-sensitive pointers: CSPointer = (C x V) ⋃ (C x O x F)
以及指向关系集合:
- Points-to relations: pt: CSPointer ⟶ 𝓟(C x O)
Regular Rules
这些规则和之前针对Java
语言的数据流分析框架规则基本一样,只是元素都加上了不同的上下文以区分,重点需要关注这些语句中的指针变量和堆对象是否都在同一个context
中:
new
语句中x
和分配的堆对象oi同属于一个上下文,那是因为指针变量的初始化堆内存一定是在一个作用域中。assign
语句中会发现只有x = y
是在一个context
中,而其两个指针变量共同指向的堆内存部分pt(y)
却共同在另一个context
中这是因为有参数传递的可能。Store/Load
语句中x
和y
在同一个上下文中但是它们的指向对象却分别在不同的context
下。
Call Rule
上下文敏感的规则中最重要的就是Call
规则,它主导并传递了不同的数据流使得精度提升成为可能。
假设用c
代表当前所在方法也就是调用者Caller
的上下文,而用ct代表被调用者Callee
的上下文。首先得到指针变量x
的receiver object
即oi,该堆对象有可能是在不同的上下文中因此和其指针的上下文不同。其次使用oi 和方法签名k
作为参数,Dispatch
出真正的target method
。最后使用和call-site
相关的信息:call-site
,当前上下文c
以及所指向的堆对象c':o_i
作为参数使用函数Select
来取得该target method
的上下文ct。需要注意的就是oi,j,k... = pt(x)<中的oi,j,k...和x
两者之间的上下文是没有直接关系的,所以严格讲这两个的c
都应该是不同的。
这时候基本上所有Cloning-Based
的程序元素都取得了,就和上下文不敏感指针分析处理Call
的规则基本一致。
Algorithm
上下文敏感的算法和上下文不敏感的指针分析算法整体上框架是一样的。构建上下文敏感的指针流图并通过PFG with C.S.传递指向信息。只是这些PFG的节点和边为了更高的精度被加上了新的约束——上下文信息,从而变成了context-sensitive variable/abstract object
的指针类型。
Node: CSPointer = (C x V) ⋃ (C x O x F)
Edges:CSPointer x CSPointer
PFG中的每一个节点都被上下文c
区分开,每一条边都有了更细粒度的解释:
x = y
: c:x ⟵ c:yx.f = y
: c':oi.f ⟵ c:yy = x.f
: c:y ⟵ c':oi.f
下面就是上下文敏感的算法:
1 | Solve(m_entry) { |
Variants
上述算法中引入了一个新的函数Select(c, l, c':o_i)
——在caller
函数中利用相关信息获取当前callee
的上下文。该信息包括调用者上下文caller context
,调用点call-ste
,堆上下文敏感的对象receiver object with heap context
。
介绍三个版本的Select
函数。
Call-site sensitivity
该版本的context
由一系列call-site
(在代码中由行号来表示)组成的列表。callee-context
是在每次调用点将当前的行号append到caller-context
上——本质上是在模拟调用栈:
- Select(c, l, ...) = [l', ..., l'', l], where c = [l', ..., l'']
这个列表的长度可以规定,故又称为k-CFA
。而这种抽象在递归程序中会导致出现无数个callee-context
,又比如在实际工程项目中调用栈很深的情况下上下文过于冗长:
1 | void main() { //context:[] |
k-Limiting Context
通过对上下文的长度进行了限制保证了不会过度增长。一般情况下长度≤3。而且method context
和heap context
使用不同的长度保证不会互相有影响。
Object sensitivity
该版本的context
是由一系列抽象对象abstract objects
(由其allocations site
表示)组成的。
- Select(,,c':oi) = [oj,...,ok,oi] where c'=[oj,...,ok]
这里一个有意思的点就是不管是Object
还是Call-site
都有自己的局限性:
1 | class A{ |
则使用1-call-site
敏感来完成时,会发现因为set
中套用了doSet
函数而导致c:b1
和c':b2
都指向了c'':doSet_this
函数,从而引入虚假信息。而使用1-object
则因为doSet
函数分别在两个堆对象下所以更精确。
下面的代码下:
1 | class C{ |
使用1-object
的时候因为这里两次调用id
函数都是在c
对象下的因此导致c:n1
和c':n2
都指向c_this
函数从而引入虚假信息,而1-call-site
则因为是两个call-site
因此更加精确。
故本质上很多时候指针分析本质和语言的性质有非常密切的关系。
Type sensitivity
该版本的context
使用一系列InType
组成,不深入了。
至此指针分析的学习结束。