资讯动态

指针分析实战:Andersen约束求解与差异传播算法解析

发布时间:2026/10/17 5:32:02 来源:尧图企业网站定制
上一篇咱们聊完了指针分析的基本概念什么是“可能指向”、为什么要做这种偏保守的近似、以及别名关系对编译优化和程序理解有多大的影响。这次直接上硬货把“约束”这个东西彻底讲透——怎么从一份C语言代码一步步翻译成指针约束怎么把约束求解成具体的指向集合以及当你真的想在工程里落地一个指针分析器时那些论文里不会写清楚的坑。如果你是第一次接触这个方向建议先把上一篇里“指向集points-to set”和“可能指向”这两个基础概念过一遍。这篇的核心关键词是Andersen风格约束、Steensgaard风格约束、约束图的构建、工作列表求解、差异传播、流敏感与上下文敏感的取舍。读完你可以亲手写出一个能跑的迷你指针分析器也能在真实工具链里听懂别人讨论的是哪一层问题。1. 从“可能指向”到“约束求解”先理清两个经典算法1.1 为什么Andersen是默认起点指针分析本质上回答一个问题某个指针变量在程序运行到任意位置时可能指向哪些内存对象。听起来简单但程序里指针赋值、函数调用、结构体字段、数组下标混在一起直接穷举所有运行时状态是不可能的。所以所有实用的算法都走同一条路把问题抽象成约束再求约束的最小不动点。这里的基础约束系统来自两个经典算法。第一个是Andersen指针分析国内论文里常叫“Andersen风格分析”或者“子集约束分析”。它的核心规则极其简单如果语句是q p那么p可能指向的一切对象q也都可能指向。用数学语言说就是pts(p) ⊆ pts(q)这个“⊆”就是子集约束的名字来源。它不要求两个集合相等只要求“q 至少包含 p 的全部可能对象”。这个保守的方向非常关键——指针分析追求的是不漏报宁可多算一些不可能的对象也不能漏掉任何一个实际可能发生的指向因为漏报会直接导致编译器做出错误的优化决策或者漏洞分析工具漏掉一条真实路径。由于只加子集关系不合并相等集合Andersen分析能达到非常高的精度代价是复杂度高最坏情况是O(n³)。n是程序中指针相关节点的数量。实际项目里n动辄几十万所以你需要靠后续的工程优化扛过去这个我们放在第3章细说。1.2 Steensgaard的同一性约束另一个经典算法是Steensgaard分析它把规则改得更激进遇到q p直接合并两个集合。pts(p) pts(q)也就是说只要两个变量有过直接或间接的赋值关系它们的指向信息就彻底融为一体。这个叫“同一性约束”。你马上能看出来这个算法的精度比Andersen差了一大截。但它有一个绝妙的好处可以用并查集Union-Find实现时间复杂度几乎是线性的接近O(n·α(n))其中α(n)是反阿克曼函数实际中就是一个很小的常数。所以处理几十万行代码时Steensgaard是秒出的而Andersen可能需要跑几分钟甚至更久。工程上怎么选我个人的经验是当你要先快速拿到一版粗糙的指针信息、跑通整个编译流程时Steensgaard打头阵等到了优化或分析阶段需要更精确的结果再切到Andersen或者更高级的流敏感分析。很多工业级编译器的早期流程其实就是用Steensgaard先排除掉一大批无关别名。1.3 两个算法的取舍对照特性Andersen分析Steensgaard分析约束类型子集约束⊆同一性约束精度高区分不同赋值来源低合并所有相关变量复杂度O(n³) 最坏近线性典型场景优化前精确别名分析、静态检测快速粗筛、大规模程序预处理数据流方向流不敏感但逐变量传播按等价类合并后查并查集生活化类比一下Andersen像你维护一个完整的朋友名单名单上每个人单独记录认识谁Steensgaard像你只维护“圈子”一旦两个人认识整个圈子合并成一个群群里谁认识谁根本不区分。前者的名单能回答“张三认识李四吗”这种精确问题后者只能说“李四在张三所在的某个圈子里”。现在有了约束的两种基本形态下一步的关键问题是代码里的每种语句到底怎么变成这些约束。2. 约束生成把C语言一句句翻译成指针约束2.1 四种基础语句的约束规则约束生成是所有指针分析工具的入口。你要做的很简单遍历中间表示AST、LLVM IR或者三地址码把所有跟指针有关的语句翻译成约束。无论你面对的是什么语言最终跳不出下面四个基本模式。代码模式约束形式含义p xAddressOf{x} ⊆ pts(p)p 一定包含分配点 xp qAssignpts(q) ⊆ pts(p)p 继承 q 的全部分配点*p qStore对任意 o ∈ pts(p)pts(q) ⊆ pts(o)p 指向的每个对象都被 q 的指向集更新p *qLoad对任意 o ∈ pts(q)pts(o) ⊆ pts(p)p 继承 q 指向对象的内容注意Store和Load这两条是“间接”的它们要等pts(p)或者pts(q)算出来之后才能继续展开。这也就是为什么你不能只扫一遍代码就得到结果必须进入一个迭代求解的过程。还有一个容易被忽略的“第五种模式”函数调用。严格说函数调用不是指针语句但在真实程序中它才是精度杀手。处理方式是把实参到形参的赋值、返回值到调用点的赋值当成普通赋值约束加进去。比如调用foo(a, b)函数签名是void foo(int *x, int *y)你就要生成pts(a) ⊆ pts(x)、{b} ⊆ pts(y)然后再把函数体内的约束合并进来。如果参数是指针的指针还得逐层展开。这里有一个大坑直接这样生成约束会导致上下文不敏感不同调用点的参数全部混在一起精度急剧下降。第4章我们再讨论怎么用克隆区分调用上下文。2.2 一个完整的约束推导示例光说规则太抽象我们拿一段具体的代码手推一遍。假设要分析的函数如下void demo() { int x, y; int *p, *q, **r; p x; // 语句1 q p; // 语句2 r p; // 语句3 *r y; // 语句4 }注意这段代码里p最后到底指向谁如果按程序语义来推理p先指向x然后r存的是p的地址*r y相当于p y所以最终p指向x和y两个对象。如果分析器只告诉你p指向x那它就是漏报了。现在按约束规则逐步生成语句1生成{x} ⊆ pts(p)语句2生成pts(p) ⊆ pts(q)语句3生成{p} ⊆ pts(r)语句4生成对于任意o ∈ pts(r)都有{y} ⊆ pts(o)。因为pts(r) {p}所以其实就是{y} ⊆ pts(p)最终得到pts(p) {x, y} pts(q) {x, y} pts(r) {p}这个例子我建议你亲手在纸上画一遍。它能说明三件事第一Store规则确实能把新对象引入到p的集合里间接赋值是精度分析的重灾区第二q的集合完全是被动地跟着p变化的赋值语句只是传播不创造新对象第三整个推导可以完全脱离“运行时状态”来做纯粹是集合之间的保守逼近。2.3 结构体、数组与字段敏感的补充真实世界里你不可能绕开结构体。p s.field这种代码怎么处理两种做法第一种是最粗糙的“字段不敏感”直接忽略.field把它当成s处理。好处是省内存坏处是一旦两个字段被不同的指针引用全部混在一起精度惨不忍睹。第二种是“字段敏感”为结构体的每个字段创建一个独立的抽象对象。处理方式是把对象编号做成“基对象字段偏移”的二元组比如obj(s, 1)代表s.field1。约束规则不变只是对象粒度变了。这也是现代分析器默认的做法代价是对象数量膨胀好几倍你需要配套做压缩和按需分析。数组的处理思路和结构体类似可以选择把整个数组当成一个对象也可以按下标分段segment建模。工程上最常见的还是“整个数组一个对象”因为数组下标在静态分析里几乎无法精确推导分段反而引入大量误报。3. 约束求解工作列表与差异传播约束生成完了代码就变成一张“约束图”。接下来是核心环节求解。我们要把这个图上的信息不断传播直到所有集合都不再变化也就是到达不动点。3.1 基础工作列表算法最直观的求解方式就是维护一个工作列表worklist里面存着“新产生、还没传播出去的对象”。伪代码如下Python风格def solve(nodes, new_constraints, assign_edges, store_edges, load_edges): pts {n: set() for n in nodes} worklist [] # 处理 p obj直接把 obj 加入 pts(p) for (p, obj) in new_constraints: if obj not in pts[p]: pts[p].add(obj) worklist.append((p, obj)) while worklist: var, obj worklist.pop() # 赋值边pts(var) 里新增的 obj 要传给所有 q 满足 var - q for q in assign_edges[var]: if obj not in pts[q]: pts[q].add(obj) worklist.append((q, obj)) # 存储边var 可能指向 obj那么 obj 的 pts 要收到所有通过 *var 写进来的对象 # 这里需要结合 store_edges[var] 里记录的目的对象 for target in store_targets[var]: # target 是 obj 或 obj 的字段等 if ...: pts[target].add(...) # 加载边类似这个朴素的算法一定能终止因为每个集合的元素都来自有限的对象集合程序里所有分配点每传播一次至少往某个集合里增加一个新元素而集合元素总量有上限。但它的性能不够好一条新增信息可能沿着赋值图传播很远如果每次都把整个集合从源头重新推一遍复杂度直接就奔着O(n³)去了。这里给新手一个建议先不要急着优化把朴素版本写出来、跑通验证再考虑改进。因为朴素版本的逻辑最容易对照论文和测试用例排查问题。优化是第二步的事情。3.2 差异传播到底优化了什么“差异传播”的名字听起来高级核心思想就一句话只传播新增的那个对象不要重复传播整个集合。当pts(p)从{x}变成{x, y}时需要往q传播的只有y而不是再传一遍x。这就是上面伪代码里工作列表存的是(变量, 对象)二元组的原因。这个简单的改动能把复杂度从O(n³)降到一个更可控的水平。实测下来在同样的测试集上差异传播版本往往比全量传播版本快一个数量级。更进一步的优化是用位图bitmap来表示集合。每个对象编号对应位图里的一个bit并集操作直接变成按位或。现代指针分析器几乎都这么做因为除了速度快位图还能大幅减少内存占用——千万级对象集合同样只需要几MB的位图。3.3 处理循环依赖与迭代顺序还有一类麻烦循环。赋值关系有环比如p x; q p; p q;传播会绕着环走一圈又一圈吗不会。因为集合是单调递增的每次更新都只是加入新元素环上的元素发完了就自然停机。这也是为什么指针分析总是不动点算法的原因。迭代顺序对性能影响很大。如果赋值图的边按程序执行顺序来排传播路径更短收敛更快。工程里常用“逆后序”遍历或者先跑一遍Steensgaard得到粗略的等价类再按这个图来引导Andersen的传播顺序。我在自己的实现里实测过引导顺序优化能让大规模测试快20%-30%非常可观。4. 精度进阶流敏感与上下文敏感4.1 流不敏感的代价前面讲的Andersen和Steensgaard都是“流不敏感”的它们完全忽略语句的执行顺序把所有赋值关系当成一个集合同时发生。这带来一个致命的问题int *p x; p y;这段代码里p在任何一次实际执行中都只指向一个对象但流不敏感分析会告诉你pts(p) {x, y}。这就是误报。对一个基于指针分析的告警工具来说误报率可能直接上升到无法接受的程度。流敏感分析把程序按控制流切分成多个程序点每个点都维护一份独立的指向集合。代价是内存和计算开销成倍增长。所以工业界很少全量做流敏感通常的做法是“稀疏流敏感”只在少数关键变量或关键路径上做细粒度分析其他地方仍然流不敏感。4.2 上下文敏感区分不同调用点如果说流敏感性处理的是“时间顺序”上下文敏感性处理的就是“调用栈上的区别”。看这个例子void set(int **a, int *v) { *a v; } int main() { int x, y; int *px x, *py y; int *rx; if (cond) { set(rx, px); // 调用点1 } else { set(rx, py); // 调用点2 } }如果上下文不敏感地合并两个调用点分析器会认为rx可能同时指向x和y——这在合并视角下是“对的”但如果你想知道每条路径上rx只指向各自的对象就需要分开处理。克隆法把每次调用点的局部约束复制一份不同调用点的形参各自独立精度立刻上去但代码量和内存消耗也线性膨胀。工业界常用的折中是“1-CFA”只区分最近一层的调用点再往外的调用上下文全部合并。也有“对象敏感”的做法区分点是接收对象本身适合面向对象语言。关键的原则是不是上下文敏感就一定好而是它带来的精度收益是否值得开销。你在做一个真实分析器时应该先用上下文不敏感跑通再用采样热点函数逐步加敏感度。4.3 按需分析和逐步降级策略我的实战建议是构建一个可配置的分析管线第一层Steensgaard粗筛第二层Andersen流不敏感第三层热点函数局部流敏感上下文敏感。分析结果缓存起来谁需要更精确的结果就申请更高层的分析。这种“按需”策略能最大化性价比。你不需要对所有函数都做流敏感实际程序中90%的别名关系用Andersen就能说清楚。5. 实战实现一个迷你指针分析器的实施要点5.1 准备中间表示与分配点如果要处理C/C强烈建议基于LLVM IR而不是AST。原因很简单LLVM IR已经处理了类型转换、结构体展开和函数的显式调用关系指针操作只剩load/store/alloca/gep这几种指令。你要做的第一步是遍历模块收集三类信息分配点alloca栈对象、malloc/calloc堆对象、global变量、bitcast产生的常量地址。指针变量每个alloca产生的SSA值以及每个寄存器值。指令关系store、load、bitcast、getelementptr、函数调用传递。分配点的编号要单独做一张表因为后面所有集合里的元素都是分配点编号。这里有个小技巧直接用一个递增计数器给对象编号不要用指针或字符串当key性能差一个量级。5.2 约束收集与图构建的实现顺序建议按这个流程写第一遍扫描搜集所有分配点建立对象编号表。第二遍扫描遍历所有指令生成三类约束直接约束store X, Y相当于Y X、load、alloca。地址约束store p, obj、调用时实参是obj。间接约束函数调用产生的形参实参关系先记录成“待处理边”。把约束转成邻接表形式的约束图赋值边、存储边、加载边、调用边。初始化工作列表把所有直接地址约束产生的(变量, 对象)对压入。迭代求解直到工作列表为空。每一步都用独立的函数封装方便后面单独测试。我在第一次实现时就因为把扫描和求解混在一起出了bug后根本分不清是约束生成的问题还是传播逻辑的问题。分开之后排查时间直线下降。5.3 验证正确性构造断言式测试指针分析的正确性最怕“看着对、实际上漏报”。强烈建议你在项目里内置一个小的测试框架用断言直接检查每个变量的指向集合// 测试用例 1 int a, b; int *p a; int *q p; // 断言 pts(p) {a} // 断言 pts(q) {a}我通常会把测试分成三类第一类是单条语句的规则测试验证四个基本约束第二类是多条语句组合的传播测试包括二级指针和结构体第三类是负测试——故意构造一个漏报场景比如循环里反复赋值断言结果里必须覆盖到所有实际分配点。负测试比正测试重要得多因为指针分析是过逼近的漏报是硬伤误报反而可以在后续过滤。5.4 工具与库的选择如果只做玩具项目自己从零写完全可行。但真要做工程级分析建议直接用现成框架LLVM自带-analyze -aa-eval可以输出别名查询信息适合做基准验证。工业界有成熟的指针分析库如SVF基于LLVM实现了Andersen、流敏感、上下文敏感和字段敏感。你可以在SVF的基础上做二次开发。学术界常用的基准测试集包含SPEC CPU程序、linux内核的抽象用例等拿这些跑一遍才能暴露扩展性问题。我见过不少人花大量时间自己造轮子结果性能和精度都远不如SVF默认参数。除非你要做一些非常规的DSL分析否则先站在巨人的肩膀上。6. 常见问题与排查技巧实录6.1 大规模程序上内存爆炸怎么办这是Andersen分析最经典的问题。分配点几十万约束边几百万朴素位图都要几百MB内存。我的经验排序是先做变量合并把必须指向同一个对象的变量合并成一个节点再做稀疏压缩去掉没有指针操作的纯数据容器最后用“按需分配”只展开真正需要的函数。还有一个大招是用BDD二叉决策图来表示集合能在牺牲较少精度的前提下把内存压缩到原来的1/10但BDD的调参很看运气不建议新手一上来就碰。6.2 函数指针让调用图不准C语言通过函数指针调用导致你无法在分析前预知调用关系而形成“先有鸡还是先有蛋”的循环。解法是迭代式修复第一轮先假设所有同类型的函数都是可能目标得到一个粗糙调用图再基于这个调用图做指针分析看哪些函数实际上不可能被指针引用从候选集中删掉重复迭代直到调用图不再变化。这个思路从理论到实践都很成熟唯一的坑是要记得设置最大迭代次数防止某些变态代码在候选集中反复横跳。6.3 99%耗时都花在少数大函数上不要试图对所有函数一视同仁。指针分析的传播耗时高度集中通常10%的函数贡献了90%的约束边。对这些函数单独降级比如只做Steensgaard粗轮廓而普通函数用Andersen精确分析整体精度只会损失一点点性能却能提升数倍。我在处理一份几百万行代码时这个策略直接让全量分析从跑不完变成一分钟内出结果。6.4 常见问题速查表症状可能原因对应解法内存占用持续上涨分配点膨胀、集合全部用HashSet改用bitmap、做对象合并分析结果精度过低字段不敏感且上下文不敏感对热点函数开字段敏感或1-CFA结果漏掉真实指向调用图迭代始终不收敛检查函数指针候选集合是否被错误剔除求解始终不结束间接约束循环里更新集合但未加入新元素检查worklist是否每次优先push新对象两个变量永远被合并Steensgaard哈希约束确认当前分析器确实是Andersen而非Steensgaard其实做指针分析最大的经验教训就一条先用小例子反复验证正确性再谈性能和复杂优化。很多人一上来就追着论文实现了各种高级变体结果基础约束生成错了后面全白搭。以我个人的体会来说调试指针分析最有效的办法是给自己写一个“最小复现脚本”突出一段包含二级指针和函数调用的20行以内代码打印每一步迭代中每个变量的集合变化。当你亲眼看到一条信息如何从地址约束流入工作列表、又沿着赋值边传播出去这个算法整个就活了。这个内容后续还可以往流敏感和过程间分析两个方向扩展——比方说给同一份约束求解器加上CFG驱动的传播顺序就能得到一版基础的流敏感分析器进阶路线其实很清晰。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑