数据流分析

最近在看Accurate Recovery of Functions in COTS Binaries,但是关于数据流分析没看懂,找到了这篇博客,感觉写的很好,加深了自己理解


引子

编译器后端会对前端生成的中间代码做很多优化,也就是在保证程序语义不变的前提下,提高程序执行的效率或减少代码size等优化目标。优化需要依靠代码分析给出的“指导信息”来相应地改进代码,而代码分析中最重要的就是数据流分析。另外数据流分析是程序静态分析的基础。所以掌握数据流分析对编译后端极为重要。

  • 何为数据流分析
  • 数据流抽象
  • 数据流分析模式
  • 基本块上的数据流模式

何为数据流分析

数据流分析指的是一组用来获取有关数据如何沿着程序执行路径流动的相关信息的技术《编译原理》

数据流分析的目的是提供一个过程(或一大段程序)如何操纵其数据的全局信息《高级编译器设计与实现》

从上面的表述中,我们可以看出数据流分析通过静态代码来“推断”程序动态执行的相关信息,数据流分析并不真正执行程序。虽然数据流分析和符号执行在某些方面比较相似,但还是两种完全不同的概念,更确切的说数据流分析是符号执行的基础。

数据流分析和符号执行从某些方面都很相似,例如符号执行有程序点(ProgramPoint)的概念,并且在当前程序点存储着程序运行到此刻的所有状态和值信息(一般情况下不会维护历史程序点的信息,开销太大)。数据流分析中也有程序点的概念,程序点存储着数据流信息。两者都是在CFG(Control Flow Graph)图的基础上,进行的分析。Clang的静态分析示意图如下所示,Clang会时刻维护符号执行当前的状态和内存信息。从这一点上看,符号执行和虚拟机更为相似。

这里写图片描述

但数据流分析和符号执行还是不同的,虽然都有程序点,但程序点存储的信息却是两个不同的概念。数据流分析中程序点存储的是数据流值,这些数据流值是和具体的数据流问题相关的,有可能是当前程序点的定值信息,也有可能是可用表达式信息,这些信息标识着该程序内含的一些属性。符号执行中程序点存储的是程序符号执行到此处的所有状态和值信息,这些信息和程序运行更为相关。

而且两者的分析方法也不同,符号执行是单次执行,而数据流分析大多采用迭代分析的框架,然后在迭代分析的过程中不断更新程序点的数据流信息,最终得到比精确解更小(更保守)的解。但为了进行更为激进的优化,要求数据流分析在保证保守的同时又尽可能是激进的。


数据流抽象

前面的文章中我们也提到过,程序的执行可以看作是程序状态的一系列转换。程序状态是由程序中所有变量的值以及运行时栈帧上的相关值组成。程序语句对应着转换函数,将前一个程序点的输入程序状态转换到下一个程序点的新的输出状态。

这里写图片描述

上图所示中的红点表示的就是程序点,数据流转换函数就是作用在程序点上的状态,并沿着程序路径一步步进行的。其实这个过程就是一个自动机,抽象出的自动机如下图所示。程序点代表自动机中的一个节点,程序语句或者说是转换函数代表自动机中的一条边。一般来说,一个程序有无穷多条可能的执行路径,执行路径的长度并没有上界(例如死循环)。程序分析可以推断出各个程序点的程序状态(有穷的特性集合),当然很少有哪种数据流分析会用到所有的数据流信息,一般只是提取出感兴趣的特性集合进行分析。

这里写图片描述

我们考虑的多数数据流分析问题关注的是各种程序对象(常数,变量,定值,表达式等)的集合,以及在过程内任意一点这些对象的什么集合是合法的有关判断。另外在数据流分析中,一般是会忽略掉路径条件判断的,也就是说默认所有路径都可达(这种近似是正确且有效的,现在我还没有找到忽略控制条件也能够保证数据流分析正确性的证明!),在程序分析中忽略掉程序控制条件,所以核心的部分就是状态数据如何变化了,也就是数据流分析。

我们虽然可以对过程的控制流图进行数据流分析,但通常更有效的做法是将它分解为局部数据流分析全局数据流分析,局部数据流分析针对每一个基本块进行,全局数据流分析针对控制流图进行分析,其实就是一个粒度问题。我们可以将同一个基本块内的各个语句的作用综合起来合成整一个基本块的作用。例如我们可以将上面的自动机改造为基于基本块的形式,如下图所示。

这里写图片描述


数据流分析模式

在数据流分析中,程序点一般和数据流值(data-flow value)关联起来,注意这个数据流值不是程序中变量的值。“这个值是在该点可能观察到的所有程序状态的集合的抽象表示”,这句话说起来有点绕口,每个数据流分析问题都有其对应的值域,每个程序点的数据流值都是该值域的子集。比如,到达定值的数据流值的域是程序的定值集合的所有子集的集合。某个数据流值是一个定值的集合,数据流分析的目的就是推导出所有程序点与其对应的到达定值的集合。

一个定值是对某个变量的赋值。可能沿着某条路径到达某个程序点的定值称为到达定值(reaching definition)。

我们把每个语句s之前和之后的数据流值分别记为IN[s]和OUT[s]。数据流问题就是对一组约束求解,得到所有IN[s]和OUT[s]的结果。

这里写图片描述

每个语句都约束了该语句之前程序点状态和之后程序点状态的关系,也就是说语句s限定了IN[s]和OUT[s]之间的关系。整个程序就是由无穷个这样的约束构成的。数据流问题(data-flow problem)就是对这一组约束求解,另外约束不仅有语义(传递函数)上的约束,更有基于控制流的约束。

传递函数

在一个语句之前和之后的数据流值受该语句语义的约束,也就是程序语句前后程序点的数据流值受该语句语义的约束,这种约束关系成为传递函数(transfer function)

传递函数有两种风格:数据流信息可能沿着执行路径向前传播,或者沿着程序路径逆向流动,相应的就有前向(forward)数据流问题后向(backward)数据流问题

Forward data-flow analysis, Information at a node is based on what happens earlier in the flow graph. 
Backward data-flow analysis, Information at a node is based on what happens later in the flow graph.

大部分人刚接触到后向数据流问题时会比较困惑,数据流值怎么会依赖于后面的数据流值信息呢。其实这是由于有些人还是对于数据流值的概念不是很理解,将数据流值简单的归结于变量的值,如果这么对比的话,就会出现矛盾

对于前向数据流问题,一个程序语句s的传递函数以语句前程序点的数据流值作为输入,并产生出语句之后程序点对应的新数据流值。例如到达定值就是前向数据流问题。

这里写图片描述

对于后向数据流问题,一个程序语句s的传递函数以语句后的程序点的数据流值作为输入,转变成语句之前程序点的新数据流值。例如活变量分析就是后向数据流问题。

这里写图片描述

控制流约束

第二组关于数据流值的约束是从控制流中得到的,基本块内都是顺序执行,没有控制流的约束。但是基本块之间有相应的控制流约束,例如一个基本块的最后一个语句和后继基本块的第一个语句之间的约束,这些约束比较复杂。

基本块上的数据流模式

前面我们已经提到过程序语句的约束分为两种,基于程序语句语义的约束和基于控制流的约束。基本块之间的约束都是基于控制流的约束,由于基本块内没有分支,所以我们可以基于整个基本块来描述基本块对于数据流值的约束,而不是基于程序语句(前面也提到过,我们可以使用局部数据流和全局数据流分析结合更加高效)。我们以基本块为最小单位来研究基本块上的数据流模式。

这里写图片描述

基本块的传递函数和基本块内程序语句所表示的传递函数之间的关系如上图所示。那么基本块之间的约束是如何的呢?如下图所示。

这里写图片描述

图中展示出来的是基本块之间的前向数据流问题的约束方程。后向数据流问题的方程如下图所示。

这里写图片描述

数据流分析就是根据这一组约束,得到一个满足这些约束的解。和线性算术方程不同,数据流方程通常没有唯一解。数据流分析的目标是寻找一个最“精确的”满足这两组约束(即控制流和传递函数)的解,当然这个解必须是保守的,能够保证我们根据这个解进行代码优化不会导致不安全的转换。

当然数据流分析,不是直接联立方程求解的,一般是通过一种迭代分析的方法来求解的。

这里写图片描述

到达定值
什么是到达定值
“到达定值”是最常见的和有用的数据流模式之一。编译器能够根据到达定值信息知道 x 在点 p 上的值是否为常量,而如果 x 在点 p 上被使用,则调试器可以指出x是否未经定值就被使用。

如果存在一条从紧随在定值 d 后面的程序点到达某一个程序点 p 的路径,并且在这条路径上 d 没有被“杀死”,我们就说定值 d 到达程序点 p 。如果在这条路径上有对变量x的其他定值,我们就说变量 x 的这个定值(定值 d )被”杀死”了。 《编译原理》

到达定值的示意图如下所示。

è¿éåå¾çæè¿°

注:上面这个图不严谨,p是程序点,应该紧挨着下面的矩形而不是表示矩形。图中的矩形表示的是一条语句。 
到达定值有以下用法:

创建use/def链
常量传播
循环不变量外提

è¿éåå¾çæè¿°
变量x的一个定值是(可能)将一个值赋给x的语句。过程参数、数组访问和间接引用都可以有别名,因此指出一个语句是否向特定程序变量x赋值并不是件很容易的事情。 《编译原理》

存在别名的情况下的需要作别名分析,如果为了提高分析效率而不介意损失一些分析精度的话,可以做保守估计,例如我们不知道当前语句对哪个变量赋值,我们就在此处针对每个变量产生一个定值。这是一种无奈的折中。此处我们不考虑别名情况。

到达定值的传递函数


首先我们做一些假设:

一个语句节点至多能够对一个变量定值
我们可以通过节点编号索引到该赋值语句
当然,在实际情况中一个语句节点有可能会对不止一个变量定值。下面我们定义一下 gen [n]函数和 kill [n]函数。

gen[n] :节点 n 产生的定值(假设一个语句节点至多一个定值) 
kill[n] :节点 n“杀死”的定值


上面的表格列举了一些程序语句的 gen 和 kill 传递函数形式。第一行的列举的“s: t = b op c”,产生了定值s并”杀死”了除定值s以外所有对变量t的定值。 
注意:定值是一个程序语句,对同一个变量可以存在多个不同的定值

我们也可以先计算出各个程序语句的 gen 和 kill 结果,然后综合基本块中的各个语句生成整个基本块的 gen 和 kill 集合。如下图所示,其中我们先默认各个基本块的起始和结束处所有定值都可以到达,下图程序中总共有7个定值,分别为d1, d2, d3, d4, d5, d6, d7。

经过第一次的传递函数作用,各个基本块到达定值集合的变化情况如图左所示。

到达定值的保守性


在前面介绍数据流分析时,曾经提到过数据流分析允许一定的不精确性。但是它们都是在“安全”或者说“保守”的方向上不精确。如下图所示:

è¿éåå¾çæè¿°

只要我们得到的解偏于保守的一方即可,然后再尽力的向精确的方向靠近,不同的应用“保守”的定义也不同。在 大部分到达定值的应用 中,在一个定值不可能到达某点的情况下假设其能够到达是保守的。如下图所示:

è¿éåå¾çæè¿°

因此在设计一个数据流模式的时候,我们必须知道这些信息将如何被使用,并保证我们做出的任何估算都是在“保守”或者说“安全”的方向上。每个模式和应用都要单独考虑。 《编译原理》

也就是说,不能套用同一个模式来判断“保守”或者“安全”的方向,在可用表达式中,“安全”的定义就和到达定值不同。如果可用表达式没有到达某个程序点,而得出的解表明到达了,则这是不安全的。

到达定值的传递方程以及控制流方程


到达定值对于单个语句的传递方程如下图所示,一个基本块内的依据就是按照这组方程建立起联系的。和单个语句一样,一个基本块也会生成一个定值集合,并杀死一个定值集合。

è¿éåå¾çæè¿°

根据基本块之间的控制流得到的约束集合,我们可以生成一个控制流方程。其实控制流方程的含义就是在路径交叉点进行数据流值的交汇,在到达定值中,交汇运算就是并集运算(∪)。

è¿éåå¾çæè¿°

对于到达定值来说,只要一个定值能够沿着至少一条路径到达某个程序点,就说这个定值到达该程序点。所以控制流方程的交汇运算时并集,但是对于其他一些数据流问题交汇运算时交集,例如可用表达式。

到达定值的迭代分析算法


假设每个控制流图都有两个空的基本块,代表了控制流图的ENTRY节点和EXIT节点。由于没有定值到达这个图的开始,所以基本块ENTRY的传递函数是一个简单的返回空集Ø的常函数,即OUT[ENTRY] = Ø.

到达定值问题使用下面方程的定义: 
OUT[ENTRY] = Ø 
且对于所有的不等于ENTRY的基本块B,有

OUT[B] = gen(B) U ( IN[B] - kill(B) ) 
IN[B] = U OUT[P] ,其中P是B的一个前驱基本块

我们可以使用下面的算法来求这个方程组的解。这个算法来自《编译原理》中到达定值部分。

到达定值算法: 
输入:一个流图,其中每个基本块 B 的 kill(B) 集和 gen(B) 集都已经计算出来了。 
输出:到达流图中各个基本块 B 的入口点和出口点的定值的集合,即 IN[B] 和 OUT[B] 。 
方法:我们使用迭代的方法来求解。一开始,我们“估计”对于所有基本块 B 都有 OUT[B] = Ø,并逐步逼近想要的 IN 和 OUT 值。因为我们必须不停地迭代直到各个 IN 值(因此各个 OUT 值也)收敛,所以我们使用一个 bool 变量 change 来记录每次扫描各基本块时是否有 OUT 值发生改变。

è¿éåå¾çæè¿°

从算法中我们可以明确看到,数据流值是从前驱 P 到 IN[B] 然后再流向 OUT[B] 这样一个从前向后不断传播的。然后从Ø 不断扩大直到越过精确解到达“保守解”。

迭代算法不断从空向到达定值结果越来越多的方向靠近,最终会跨越精确解到达保守解的部分,主要因为两个原因导致一定会越过精确解: 
(1)不考虑路径条件,假设所有路径都可达;这样某些定值最终会到达他们本来到达不了的地方 
(2)存在别名时,给无法确认的“别名”赋值时,给所有变量添加一个定值(注意此处并没有kill掉所有的定值,因为添加所有可能的定值,删除肯定被kill掉的定值,这样才能保证“保守”)

这个算法还有可以改进的地方,其中一个就是精心安排迭代分析时基本块的顺序,基本按照CFG从入口ENTRY到EXIT的顺序。在《迭代数据流分析中的逆后序(Reverse Postorder)》中我们会提到,对于前向数据流问题来说,逆后序是最高效的方式。如果当前基本块的到达定值结果发生了改变,那么就把其所有后继基本块加入待迭代的工作列表 WorkList 。

另外到达定值使用了一种位向量的结构,来表示到达定值集合。即每个程序点的到达定值使用一个位向量表示,例如该程序有7个定值,那么位向量为7位,初始向量“0000 000”表示此时定值为空,如果第3号定值到达了当前程序点,那么位向量为“0010 000”。

活跃变量分析


活跃变量分析是一个后向数据流分析问题,因为当前变量x是否在未来的某个地方被用到,只能通过从后面节点的信息中获知。

A variable is live at a particular point in the program if its value at that point will be used in the future(dead, otherwise) 
To compute liveness at a given point, we need to look into the future

活跃变量的重要用途之一是为基本块进行寄存器分配。计算机技术中有很多类似于寄存器分配的场景,也就是有限的资源去满足无限的需求,例如cache有限,但是欲放入cache的数据却又很多;另或者主存空间有限,而磁盘中欲放如主存的数据有很多等。所以这时候就需要某种资源共享,保证不冲突,并且采用某种算法来求得把资源分配给哪些数据更合理。例如LRU换页算法,或者cache的空间局部性原理等。当然这些问题都不是程序员需要深入考虑的,底层软件或工具软件会在背地里完成。

Register Allocation

A program contains an unbounded number of variables
Must execute on a machine with a bound number of registers
Two variables can use the same register if they are never in use at the same time(i.e, never simultaneously live).
所以寄存器分配需要活变量的信息来决定两个变量能否使用同一个寄存器,另外在所有寄存器都被占用时,如果我们还需要申请一个寄存器的话,那么应该考虑使用一个存放了已死亡的值的寄存器,因为这个值我们不需要保存到内存,无需register spill。

活跃变量的数据流方程


我们给出两个定义:

def(or definition)
use
def[v] = 定义变量v的所有CFG节点集合 
def[n] = 节点n定义的变量集合

use[v] = 使用变量v的CFG节点集合 
use[n] = 在节点n使用的变量集合

计算活跃性的规则:

(1)产生活跃性

è¿éåå¾çæè¿°

(2)活跃性如何越过程序语句节点之间的边

è¿éåå¾çæè¿°

(3)活跃性如何越过程序语句节点

è¿éåå¾çæè¿°

我们列出活跃变量的数据流方程如下所示,注意此处我们将语义约束和控制约束同时写出来了(因为我们现在是以单个程序语句为图节点,而不是单个基本块)

in [n] = use [n] U ( out [n] - def [n] )
out [n] = U in [s] , 其中s是节点n的后继节点
从这两个方程我们可以看出,对于活跃变量分析来说数据流是从后向前传播的。我们这里解释一下为什么要从out[n]中删除def[n],

è¿éåå¾çæè¿°

下面给出活跃变量分析的算法:

 è¿éåå¾çæè¿°
注:此处CFG是以程序语句为单个节点构建的

当然这个算法没有考虑到CFG图中节点的顺序,效率比较低,我们将CFG图中的节点反序,用来求解。改进算法如下:

è¿éåå¾çæè¿°

我们也可以基本块为单位来就行活跃变量的分析,但是我们得首先根据基本块中程序语句的传递函数合并成为基本块的传递函数。定义如下:

def [B] 是指如下变量的集合,这个变量在B中的定值(即被明确地)先于任何对它们的使用
use [B]是指如下变量的集合,它们的值可能在B中先于任何对它们的定值被使用
注意:上述我们标注的黑色部分,在def[B]中需要被明确定值,而在use中条件弱化,只需要可能就行了。类似于到达定值,这样做是为了保守性。在活跃变量中,假设变量活跃到程序结束是没有问题的,只是会损失些可以优化的点(例如寄存器分配时两个变量的活跃区间相互重叠的概率就会变得很大),但是如果将变量的活跃期缩短的话,有可能就将该寄存器挪做他用,这样就会导致程序错误。所以在活跃变量分析中,将活跃变量尽可能的向前传播是有利于偏向保守的。但是不能一味的偏于保守,否则得到的信息就没有任何价值,在保证保守的同时,尽可能的向精确解靠拢(所以杀死被明确赋值的变量)

所以我们在杀死变量时(即在基本块内明确定义,在 def[B] 中)必须明确规定,但是尽可能地向前传播(也就是如果可能在use[B] 中,直接加入就好)。如下图所示,我们所求得的结果必须能够保证在保守解部分,并尽力向精确解靠近。为了保证保守性,需要做到如下两点:

忽略路径分支条件,保证所有路径都可达
只要有可能是活的,就向其中加入该变量。只有在该活跃变量被明确杀死时(例如被明确赋值),才删除

è¿éåå¾çæè¿°
如果以基本块为单位,那么得到的数据流方程如下图所示:

è¿éåå¾çæè¿°

第一个方程描述了边界条件,即在程序出口处没有变量是活跃的。 
第二个方程说明一个变量要在进入一个基本块时活跃,必须满足两个条件中的一个:要么它在基本块内被重新定值之前就被使用;要么它在基本块的出口处活跃且在基本块内没有对它进行重新定值。 
第三个方程说明一个变量在一个基本块的出口处活跃当前仅当它在该基本块的某个后继入口处活跃。

和到达定值相同,活跃变量不需要在后继基本块入口都活跃,只要在其中一个基本块入口活跃即可。但是活跃变量是后向数据流模式。在各个数据流模式中,我们都沿着路径传播信息,有的数据流问题,要求对应性质需要在所有路径上都成立,而有的数据流只需要存在一个满足该性质的路径即可。

基于基本块的活跃变量分析算法: 
输入:一个流图,其中每个基本块的use和def已经计算出来了。 
输出:该流图中的各个基本块B的入口和出口处的活跃变量集合,即 IN[ B ] 和 OUT[ B ]。

è¿éåå¾çæè¿°

该算法得到的具有最小活跃变量(亦即尽量向精确解靠近)的集合。

猜你喜欢

转载自blog.csdn.net/z2664836046/article/details/88742210