本文于 2007 年投稿于 ACM-SIGPLAN 会议1。
指针在代码编写过程中可能出现以下两种问题:
存在一条执行路径,指针未成功释放(内存泄漏),如下面代码中注释部分所表明的:
int foo()
{
int *p = malloc(4 * sizeof(int));
if (p == NULL)
return -1;
int *q = malloc(4 * sizeof(int));
if (q == NULL)
return -1; // 注意这里,q为NULL时p一定不为NULL,但是函数直接返回,导致p所指向的区域未释放
// some code to execute
free(p);
free(q);
return 0;
}
存在一条执行路径,指针被重复释放(未定义行为),如 free
一个空指针。
int *p = (int *)malloc(4 * sizeof(int));
int *q = p;
free(q);
q = p;
free(q);
最笨拙的方法是枚举每一条可能的路径,依次判断。但是这显然是不切实际的。因而本文的主要工作是提出一个能够发现未释放指针或重复释放指针的高效算法,并进行了代码实现,提示编写者具体可能的错误原因。即给定一个程序,找到其中可能存在的这种问题。
首先进行控制流图(Condition-Flow Graph,CFG)的约定:
此外,本文将问题进行了规约:定义 s o u r c e − s i n k [ n , m ] {\rm source-sink}[n,m] source−sink[n,m] 问题为从 s r c \rm src src 会流入到 [ n , m ] [n,m] [n,m] 个 s i n k \rm sink sink 的条件可满足性问题。对于未释放,则是 s o u r c e − s i n k [ 0 , 0 ] {\rm source-sink}[0,0] source−sink[0,0] 问题,而多次释放则是 s o u r c e − s i n k [ 2 , ∞ ] {\rm source-sink}[2,\infty] source−sink[2,∞] 问题,而合法性判断是 s o u r c e − s i n k [ 1 , 1 ] {\rm source-sink}[1,1] source−sink[1,1] 问题。
整体算法流程图如下所示:
在实现该算法的同时还需要调用:
编译原理中经典的数据流分析方法。下文中用 p d o m ( x , n , m ) pdom(x,n,m) pdom(x,n,m) 来描述变量 x x x 能不能从 CFG 上流图节点 n n n 值不发生改变的到节点 m m m。论文中的 S S S 仅为一个记忆化的集合,不做具体参数使用。 p d o m pdom pdom 的计算使用逆向数据流分析方法:
p d o m ( x , n , m ) = { t r u e , m = n f a l s e , n 没有出边(返回节点) ⋀ i ∈ I n p d o m ( x , i , m ) ∧ ¬ d e f i n e ( x , i ) , 其他情况 pdom(x,n,m)= \begin{cases} true, m=n\\ false,\text{$n$ 没有出边(返回节点)}\\ \bigwedge_{i \in I_n} pdom(x,i,m) \wedge ^\lnot{define}(x,i),\text{其他情况} \end{cases} pdom(x,n,m)=⎩ ⎨ ⎧true,m=nfalse,n 没有出边(返回节点)⋀i∈Inpdom(x,i,m)∧¬define(x,i),其他情况
其中 d e f i n e ( x , i ) define(x,i) define(x,i) 表示 i i i 节点没有进行对变量 x x x 的赋值操作。
在构建值流图之前首先需要介绍 free
函数的工作原理或特性。它释放传入参数给定的指针所指向的区域,也就是说它是针对内存区域而非指针的。例如下面的两个例子:
p1
和 p2
指针所指向的区域都被释放了。 int *p1 = malloc(4 * sizeof(int)), *p2 = malloc(6 * sizeof(int));
int *q = p1;
free(q);
q = p2;
free(q);
p
指针指向区域并未完全释放——p
指针所指向的区域仍有一个 int
大小的空间未释放。 int *p = malloc(4 * sizeof(int));
int *q = p + 1;
free(q);
基于以上两个特性,构建如下的节点:
赋值(运算)节点。针对 CFG 上每个形如 x = y x=y x=y 形式的赋值语句都对应一个 VFG 的节点。
内存区域节点。由于 free
是针对区域而非指针型变量,因而需要用一个单独的节点描述它是否有被释放的途径。该部分节点用 n r n_r nr 表示,可以使用这篇论文2中的方法快速描述代码中每个指针可能对应的内存区域集合。
这里还需要注意的是,由于指针存在加减法操作,因而这里需要额外使用一个偏移量来去衡量该内存地址的具体使用情况。
释放节点。每个 free
函数调用的节点都对应一个 VFG 上的汇(sink)点。
函数调用实参节点。由于进行函数调用,可以视为进行一次变量的值使用,记为 x @ n x_{@}n x@n。
函数调用形参节点。在被调用函数(callee)中该函数作为新变量使用,同时它对应于调用函数(caller)的一个变量。为避免函数多次调用导致边关系混乱,因而新建一个形参节点以解决图过于混乱的问题,记为 [ p ] [p] [p]。
函数返回节点。函数的返回值可能在 caller 中作为一个新变量继续存在,并涉及后续赋值和计算。
遍历 CFG,按如下规则建图(假设当前节点为 n n n):
malloc
函数返回的堆区域指针),调用内存区域查询函数返回对应的内存区域节点。该步骤中忽略了程序流图中的条件,即认为所有的边都是可走到的,在这种情况下先简单分析是否每个 malloc
函数都有至少一个 free
与之对应。
free
语句对应节点,记为 K K K。malloc
语句无对应 free
语句,直接报告内存泄漏。首先在 CFG 上进行条件分析。考虑每个分支节点 n n n 需要满足其分支出口唯一,即对于分支节点 n n n: switch e ( c 1 , n 1 ; c 2 , n 2 ; c k , n k ; n t ) \texttt{switch}_e(c_1,n_1;c_2,n_2;c_k,n_k;n_t) switche(c1,n1;c2,n2;ck,nk;nt),需要满足:
C n = [ ⋁ i ( e = c i ) n ] ∧ [ ⋀ i ≠ j ( e = c i ) n ∧ ( e = c j ) n ‾ ] C_n=\left [\bigvee_{i}(e=c_i)_n\right] \wedge \left[\bigwedge_{i \ne j} \overline{(e=c_i)_n \wedge (e=c_j)_n}\right] Cn=[i⋁(e=ci)n]∧ i=j⋀(e=ci)n∧(e=cj)n
即存在一条出路,且不存在一个条件同时满足两条出路。
定义函数 c g ( x , n , m ) cg(x,n,m) cg(x,n,m)(下简写成 c g u a r d ( n → m ) cguard(n \to m) cguard(n→m),其中程序点 n n n 为形如 x = e x=e x=e 的赋值语句)表示变量 x x x 从程序点 n n n 到 m m m 需要满足的输入条件集合,有如下递推:
c g ( x , n , m , E ) = { t r u e , 如果满足支配关系即 p d o m ( x , n , m ) c g ( x , n 1 , m , E ) , n 处无分支, n 1 为 n 语句的唯一后续语句 ⋁ i ∈ I n , x , E c o n d ( n , n i , E ) ∧ c g ( x , n i , m , E ∪ { ⟨ n , n i ⟩ } ) , 其他情况 cg(x,n,m,E)= \begin{cases} true,\texttt{如果满足支配关系即 $pdom(x,n,m)$}\\ cg(x,n_1,m,E),\texttt{$n$ 处无分支,$n_1$ 为 $n$ 语句的唯一后续语句}\\ \bigvee_{i \in I_{n,x,E}} cond(n,n_i,E) \wedge cg(x,n_i,m,E \cup \{\langle n,n_i\rangle\}),\texttt{其他情况} \end{cases} cg(x,n,m,E)=⎩ ⎨ ⎧true,如果满足支配关系即 pdom(x,n,m)cg(x,n1,m,E),n 处无分支,n1 为 n 语句的唯一后续语句⋁i∈In,x,Econd(n,ni,E)∧cg(x,ni,m,E∪{⟨n,ni⟩}),其他情况
其中 I n , x , E I_{n,x,E} In,x,E 表示满足后续节点不是形如 d e f i n e ( n i , x ) define(n_i,x) define(ni,x) 且 ⟨ n , n i ⟩ \langle n,n_i \rangle ⟨n,ni⟩ 不在 E E E 中的后续节点集合 { n i } \{n_i\} {ni}。 c o n d ( n , n i , E ) cond(n,n_i,E) cond(n,ni,E) 函数表示从 n n n 节点走到 n i n_i ni 节点所需要满足的条件,并且要求 n n n 节点后续不能有 E E E 中的边:
c o n d ( n , n i , E ) = { ( e = c i ) n , n 是一个分支节点,且 n 的后续集合不在 E 集合中 t r u e , 其他情况 cond(n,n_i,E)= \begin{cases} (e=c_i)_n,\texttt{$n$ 是一个分支节点,且 $n$ 的后续集合不在 $E$ 集合中}\\ true,\texttt{其他情况}\\ \end{cases} cond(n,ni,E)={(e=ci)n,n 是一个分支节点,且 n 的后续集合不在 E 集合中true,其他情况
这里 ( e = c i ) n (e=c_i)_n (e=ci)n 表示在程序分支判断点 n n n 处进行条件判断 e = c i e=c_i e=ci。
这里加入 E E E 集合(已遍历的边集合)作为参数是方便代码实现上的,由于流图中可能有环存在,因而不能重复遍历同一条边,通过在状态中多维护一个 E E E 集合可以有效防止重复遍历到同一条边。在 c o n d cond cond 中,显然要进入循环然后退出该循环需要同时满足在循环的出口判断中 e = c i e=c_i e=ci 和 e ≠ c i e \ne c_i e=ci,这显然是永假的。因而这里加入了边的限制条件以防止上述情况的出现。当然这里会存在一个小漏洞就是仅判断了循环入口点的条件,未判断出口点的。这里会对未释放问题的判定产生一定的影响,但是对多重释放不会。
接下来就是考虑利用这些条件在 VFG 上进行条件判断。同样定义 v g u a r d ( n , m ) = v g ( n , m ) vguard(n,m)=vg(n,m) vguard(n,m)=vg(n,m) 函数表示从 VFG 图上 n n n 节点走到 m m m 的约束条件集合,有:
v g ( n , m , E ) = { t r u e , n = m ⋁ n → n ′ ∈ E c g u a r d ( n → n ′ ) ∪ v g ( n ′ , m , E ∪ { n → n ′ } ) , 其他情况 vg(n,m,E)= \begin{cases} true,n=m\\ \bigvee_{n \to n' \in E} cguard(n \to n') \cup vg(n',m,E \cup \{n \to n'\}),\texttt{其他情况} \end{cases} vg(n,m,E)={true,n=m⋁n→n′∈Ecguard(n→n′)∪vg(n′,m,E∪{n→n′}),其他情况
和在 CFG 上的情况类似,这里只需要在 VFG 图上再加入 CFG 上的信息即可。
考虑对于一个确定的 s r c src src 节点和所有该指针的释放操作汇点 K K K,在 2.3 节中阐述的 R R R 集合上进行分析。定义 G k = v g u a r d ( s r c , k ) G_k=vguard(src,k) Gk=vguard(src,k), C C C 为 R R R 上所有的分支节点需要满足的 C n C_n Cn 的与集合。此时就满足了一个 SAT 问题的框架:
free
语句,进行多次释放操作,因而发生未定义行为(Undefined Behavior)。可以注意到上述的操作时间复杂度是比较高的,特别是对于有大量 malloc
语句存在的时候。因而本文中仅对操作数不超过阈值 30 30 30 的代码进行分析。
本文同时实现了代码,将所有的错误种类分成以下几类:
Cherem S, Princehouse L, Rugina R. Practical memory leak detection using guarded value-flow analysis[C]//Proceedings of the 28th ACM SIGPLAN Conference on Programming Language Design and Implementation. 2007: 480-491. ↩︎
Bjarne Steensgaard. Points-to analysis in almost linear time. In Proceedings of the ACM Symposium on the Principles of Programming Languages, St. Petersburg Beach, FL, January 1996. ↩︎