☰
Soot调用图与过程间控制流图构建机制解析
2026/10/29 11:42:01 网站建设 项目流程

简介:SootTest 项目围绕 Soot 静态分析框架的入门实践展开,面向具备 Java 基础、希望理解程序调用关系与跨方法控制流的学习者和开发者,重点解决如何调用 Soot API 生成调用图与过程间控制流程图(ICFG)的实操问题。压缩包共 23 个文件,大小约 12.27MB,包含 Java 源码、编译后的 class、依赖 jar,以及 dot 图形描述和 png 预览等类型;其中源码与 class 便于对照阅读,dot 与 png 可用于查看生成结果,整体结构适合按模块检索。已有 1443 人学习下载,说明该主题在 Java 静态分析学习中具有一定参考需求。通过示例可了解输入加载、Soot 配置、Scene 构建、UnitGraph 与 ICFG 生成、调用图构建及输出解析等环节,帮助读者搭建实验环境、观察方法间调用与返回关系,并为进一步的数据流分析和代码优化实践提供可复用的思路。

1. 调用图并不是“读一遍源码”就能得到的:Soot 把 Java 里被擦掉的调用可能性摊成一张图

在 IDE 里点一下调用层次结构,会弹出一棵树,让人误以为调用关系是源码里现成的事实。但 javac 编译之后,一个a.foo()只剩一条invokevirtual demo/A.foo:()V,常量池里记的是“声明类型 + 方法签名”,真正被执行的那个实现,编译器早就不知道了。所以用 Soot 生成调用图和过程间控制流图,本质上不是“画图”,而是承认信息在编译期被擦掉了,然后用一套保守的近似把可能性重新补回来。Java 生态里所有往上走的分析,包括污点追踪、空指针检测、死代码消除、程序切片,地基都是这两张图:调用图回答“谁可能调到谁”,过程间控制流图回答“参数从哪条路径流进被调方法、返回值又从哪条路径出来”。地基偏一寸,上层结论全歪,这也是为什么值得把它的机制拆开看。

2. 从栈式字节码到 Jimple:为什么要先做一次“三地址化”才能建图

2.1 栈式字节码为什么画不出像样的控制流

JVM 是栈机,操作数在操作数栈上隐式流动。一段iadd前面必然是两条 push,中间没有任何“变量名”,只有局部变量槽位索引和栈深度。控制流图要求“节点是语句、边是可能的下一条语句”,可字节码里没有天然的语句边界:一条 Java 语句往往展开成七八条指令,跳转目标还可能落在某条高级语句的中间。更麻烦的是 def-use 关系,字节码里只能靠栈模拟反推,遇到dup、swap、dup_x1这种操作,手写的遍历器极容易出错。

常见的做法是先把字节码“抬”成一种更接近三地址码的中间表示。Soot 的这张底牌叫 Jimple:所有操作数都显式化成局部变量,this显式化成一条赋值语句,每个 Unit 最多一个运算符。抬完之后,节点粒度对了,def-use 变成局部变量级别的显式引用,建图才有意义。关键在于这一步属于“保语义的重新编码”,并非“优化”:抬的过程中不能改变任何可观察行为,否则后面所有的边都不可信。

2.2 Jimple 的 Unit / Value / Box 三层结构与 identity 语句

一段源码void bar() { baz("hello"); }被抬成 Jimple 之后长这样:

public void bar(); { demo.Foo $r0; java.lang.String $r1; $r0 := @this: demo.Foo; // this 引用被显式化成一条 IdentityStmt $r1 = "hello"; // 常量进局部变量 virtualinvoke $r0.<demo.Foo: void baz(java.lang.String)>($r1); // 接收者显式 return; }

注意几个细节。$r0 := @this: demo.Foo是一条IdentityStmt,右边是ThisRef;方法的形参会用@parameter0、@parameter1这样同样形式的ParameterRef依次绑定。这条语句在过程间控制流图里极其重要,它就是一个方法的“入口节点”,ICFG 里getStartPointsOf(method)返回的正是这些 IdentityStmt。第二,调用语句把接收者写在了显式位置($r0.),这让“这个调用点的 receiver 是哪个 Value”变成一个可直接查询的事实,而不用再回栈里推。

支撑这一切的是三层结构。Body持有一个Chain<Unit>(实际类型是PatchingChain,特点是删除元素时不会让迭代器失效)、一份Locals列表和一份Traps列表;Unit是语句节点,Stmt是它的子接口,下面挂AssignStmt、InvokeStmt、IfStmt、GotoStmt、ReturnStmt、ThrowStmt、IdentityStmt、NopStmt这些形态;Value是操作数,分Local、Constant、Expr三支。最容易被忽略的是Box:ValueBox表示“一个值在语法树里占据的位置”,UnitBox表示“一条语句在链表中占据的位置”。有了 Box,改写时只替换盒子里的 Value,不用重建整棵语句树,也不用担心引用失效。

提示:判断一个 Unit 是不是调用点,用stmt.containsInvokeExpr()比instanceof InvokeStmt更可靠。因为调用可以出现在赋值右边($r2 = virtualinvoke ...)、可以出现在返回值里、可以出现在条件表达式里,InvokeStmt只覆盖“调用结果被丢弃”这一种。

2.3 加载、转换、建图:Soot 的 Pack 流水线

Soot 把整个处理过程切成一串“包”(Pack),按固定顺序执行:jb负责把每个方法的字节码抬成 Jimple Body(内部还细分局部变量拆分、聚合、无用局部变量消除、trap 收紧等子阶段),cg负责构建调用图,wjap做全程序变换,jop、jap做过程内与过程间优化,最后是tag和输出。理解这个顺序非常关键:调用图是在cg包里成形的,而cg依赖jb已经产出了 Body。所以如果你在jb之前就去Scene.v().getCallGraph(),拿到的只可能是空图或者不完整的图。

import soot.*; import soot.options.Options; import java.util.Collections; public class BuildGraph { public static void main(String[] args) { G.reset(); // 清空 Scene 全局状态,多次分析必须重置 Options.v().set_whole_program(true); // 打开整程序模式,Spark 等全程序分析的前提 Options.v().set_process_dir(Collections.singletonList("target/classes")); // 待分析目录 Options.v().set_allow_phantom_refs(true); // 缺失依赖只留幻影类,不直接抛异常 Options.v().set_output_format(Options.output_format_none); // 只要分析结果,不落盘 Scene.v().loadNecessaryClasses(); // 触发类加载与类型解析 Scene.v().setEntryPoints(EntryPoints.v().all()); // 入口点:默认取所有 main PackManager.v().runPacks(); // 依次执行 jb -> cg -> wjap -> jop ... } }

set_whole_program(true)之后,Soot 才愿意做跨类的分析;set_allow_phantom_refs(true)的意思是“遇到找不到的类,造一个只有名字没有 Body 的占位符”,这在分析只依赖部分第三方库时几乎是必开项,否则加载阶段就直接失败。loadNecessaryClasses()与runPacks()的分工也要分清:前者只负责把类读进来、建好类型层次,后者才真正跑变换。很多“图里什么都没有”的问题,根源就是只调了前者。

3. 调用图构建:CHA、RTA 与 Spark 分别在假设什么

3.1 虚方法分派的不确定性从哪里来

一个调用点能连出几条边,取决于两件事:接收者的声明类型和运行时可用的实现集合。invokevirtual A.foo()里,A是声明类型,实际对象可能是A,也可能是任意子类。字节码里没有任何字段记录“实际类型是什么”,所以静态分析只能给出一个集合。这个集合怎么算,就是不同算法的分歧所在。

极端例子:一个接口I声明了run(),你的工程里只有一个实现类,但类路径上如果还挂着一个包含两百个实现类的公共库,CHA 会把两百条边全部连上。此时调用图不是“错”,而是“保守地成立”——它没有漏掉任何可能,只是把不可能也算进去了。所有调用图算法的差异,本质是“用多少额外信息去收缩这个集合”。

3.2 精度阶梯:从 CHA 到 Spark 的四种取舍

算法边集合的判据精度代价需要整程序
CHA类型层次上所有可能重写该签名的方法最低极低,不需要 Body否
RTA限制在“被实例化过的类型”集合内做 CHA中中,需一次快速扫描是
VTA让类型信息沿变量传播后再收缩中高较高是
Spark基于指向分析算出每个 receiver 可能的对象集合最高最高是

CHA 的思路最直接:对每个invokevirtual A.foo(),遍历A的所有子类,凡是重写了foo的实现全部连边。它甚至不需要方法体,只要类型层次就够了,所以特别快,也特别粗。

RTA(快速类型分析)多了一步:先把“哪些类型被new过”扫一遍,得到一个实例化集合,再在这个集合内部做 CHA。这一步的收益非常明显——一个从没被实例化的类,它的方法不可能是任何虚调用的目标。代价是需要全程序视野,而且必须先确定入口与可达性,否则扫描不全。

VTA 更进一步,让类型信息在变量之间传播:$r0 = $r1这样的赋值会把可能类型从$r1传到$r0,于是 receiver 的候选集合可以更小。

Spark 是这套体系里最重的一档:它做的是子集约束的指向分析,给每个变量算出可能指向的对象集合,再据此连边。它支持 on-the-fly 建图(边建边传播,而不是先算完再连),也支持不同的传播器与集合实现。精度上去了,内存和时间也一起上去了,在本机跑一个中等规模的工程,几十分钟和几个 G 堆是常态。

3.3 取到 CallGraph:边、边的种类与可达方法

import soot.jimple.toolkits.callgraph.*; import java.util.Iterator; // 在 2.3 的流程之后:此时 cg 包已经跑完,Scene 上挂着真正的调用图 CallGraph cg = Scene.v().getCallGraph(); // 从可达方法出发遍历:cg 里存的是“可达方法集合 + 边集合”,不是哈希表 Iterator<SootMethod> mit = cg.getReachableMethods().iterator(); while (mit.hasNext()) { SootMethod caller = mit.next(); Iterator<Edge> eit = cg.edgesOutOf(caller); while (eit.hasNext()) { Edge e = eit.next(); SootMethod callee = e.tgt(); // 目标方法 SootMethod src = e.src(); // 源方法,与 caller 相同 Unit site = e.srcUnit(); // 调用点语句;THREAD/FINALIZE 这类合成边为 null Edge.Kind kind = e.kind(); // 边的语义类别,见下 } }

Edge.Kind是理解调用图的一把钥匙,它把“这条边为什么存在”编码进了类型:

  • STATIC:来自invokestatic,目标唯一,最没有歧义。
  • SPECIAL:来自invokespecial,覆盖构造器调用、super.xxx()、private 方法。
  • VIRTUAL与INTERFACE:来自虚调用与接口调用,这两类是精度问题的重灾区。
  • CLINIT:类初始化触发<clinit>,一次普通的方法调用可能额外带出一条边。
  • THREAD:Thread.start()到run()的合成边,srcUnit通常为空。
  • FINALIZE:对象生命周期相关的合成边。
  • PRIVILEGED:特权块相关的合成边。

后三种都是“字节码里根本没有对应指令”的边,工具替你补上了。这也是为什么统计边数时你会觉得比源码里的调用点多——多出来的部分是运行时机制,并非重复。

3.4 入口点:整程序假设的第一道闸门

整程序分析的隐含前提是“我有一个起点,从它出发能覆盖所有会被执行的代码”。Soot 默认的EntryPoints.v().all()大体是“所有public static void main”,如果你的目标是一个库、或者一个没有 main 的模块,那么可达方法集合会直接退化成空,调用图自然一片空白。这不是 bug,是它的世界观和你给它的输入不匹配。

常见的补救是显式指定入口:

// 把库对外暴露的 public 方法全部当作根,模拟“任何调用者都可能调它” List<SootMethod> roots = new ArrayList<>(); for (SootClass c : Scene.v().getApplicationClasses()) { for (SootMethod m : c.getMethods()) { if (m.isPublic() && !m.isAbstract() && !m.isConstructor()) { roots.add(m); } } } Scene.v().setEntryPoints(roots);

代价是精度进一步下降——根越多,可达集合越大。这属于解决方案中必需的代价,并非缺陷。另一条更精细的路子是按“业务入口”分批设根,跑多次分析再合并结果,代价是多次加载的耗时。

4. 过程间控制流图:把上千个 UnitGraph 缝成一张网

4.1 UnitGraph 家族:Brief、Exceptional、Complete 的差别

调用图只回答“可能调谁”,控制流图才能回答“数据怎么流”。在过程内,Soot 用UnitGraph表示:getHeads()给入口节点,getTails()给出口节点,getSuccsOf(unit)与getPredsOf(unit)给边。同一个 Body 可以构造出不同的图,区别在于把哪些“隐式控制流”算进来:

实现是否含 trap/异常边是否含 monitor 等隐式边典型用途
BriefUnitGraph否否只关心正常路径、追求速度
ExceptionalUnitGraph是部分绝大多数数据流分析的默认选择
CompleteUnitGraph是是(极端保守)需要严格 soundness、能忍爆炸
TrapUnitGraph只用 trap 信息否只需要知道 catch 结构
SootMethod m = Scene.v().getMethod("<demo.Foo: void bar()>"); Body body = m.retrieveActiveBody(); // 按需触发 jb 包,懒加载 UnitGraph g = new ExceptionalUnitGraph(body); for (Unit u : body.getUnits()) { // 注意:body.getUnits() 是 PatchingChain List<Unit> succs = g.getSuccsOf(u); for (Unit s : succs) { // 这里的 s 可能是一条普通的下一条语句,也可能是某个 catch 块的首句 } }

retrieveActiveBody()是懒加载的关键:如果你只对少数方法建图,Soot 就只为这几个方法跑jb,不必为整个工程付出转换成本。但反过来,如果你在一个循环里反复调用它,就要自己做一层Map<SootMethod, UnitGraph>缓存,否则每次都会重建。

4.2 trap 与异常边:最容易被忽略的那部分控制流

Body.getTraps()返回的Trap有四个关键字段:getBeginUnit()、getEndUnit()、getHandlerUnit()、getException()。语义是“在[begin, end)区间内抛出的该类型异常,控制流跳到 handler”。ExceptionalUnitGraph会把区间里每一条可能抛异常的语句都连一条边到 handler,于是你会看到图里突然多出一堆指向 catch 块的边。

这件事的重要性在于:所有基于控制流的数据流分析,都会把“从 try 块任意位置跳到 catch”当成一条合法路径。如果你用的是BriefUnitGraph,这些路径根本不存在,那么 catch 块里的变量在你的分析里就是“没有定义来源”,结论直接歪掉。反过来,如果你用的是CompleteUnitGraph,几乎每条语句都要连到 handler,图会膨胀到影响性能。

ExceptionalUnitGraph还提供了一个omitExceptingUnitEdges参数,用来砍掉那些“隐式可能抛异常”的语句产生的边(比如某些不会真的抛出声明异常的语句),换来图规模下降。开不开取决于你是要“不漏”还是要“跑得完”。我一般的做法是:先用ExceptionalUnitGraph跑通,若图规模失控再考虑裁剪,而不是一开始就图快。

4.3 ICFG 的接口语义与懒加载实现

过程间控制流图的接口(在soot.jimple.toolkits.ide.icfg包里,典型实现是JimpleBasedInterproceduralCFG)把这几个问题封装成方法:

import soot.jimple.toolkits.ide.icfg.JimpleBasedInterproceduralCFG; import heros.InterproceduralCFG; InterproceduralCFG<Unit, SootMethod> icfg = new JimpleBasedInterproceduralCFG(); Unit callSite = /* 某个含 invoke 表达式的 Unit */; Collection<SootMethod> callees = icfg.getCalleesOfCallAt(callSite); // 可能的被调方法 Collection<Unit> returns = icfg.getReturnSitesOfCallAt(callSite); // 返回后落到哪些 Unit Collection<Unit> starts = icfg.getStartPointsOf(callees.iterator().next()); // IdentityStmt Collection<SootMethod> callers = icfg.getCallersOf(callees.iterator().next()); boolean isCall = icfg.isCallStmt(callSite); Collection<Unit> allStarts = icfg.allNonCallStartNodes(); // 所有方法的入口节点

几个语义细节值得强调。getStartPointsOf返回的是 IdentityStmt 集合,而不是方法的第一条普通语句——这决定了“参数绑定”发生在哪一步,如果你的分析在入口节点上不做@this和@parameter0的初始化,后续所有数据流都是无源之水。getReturnSitesOfCallAt返回的是调用点的后继节点,也就是“返回之后继续执行哪”,并不包含被调方法内部的 return 语句,这两者经常被混淆。

JimpleBasedInterproceduralCFG内部维护一个方法到 UnitGraph 的缓存,并在需要时对该方法调用retrieveActiveBody()。这意味着它是懒加载的:你只遍历到哪,它才转换到哪。这也解释了为什么“只跑loadNecessaryClasses()然后直接用 ICFG”有时能工作、有时又拿不到 body——取决于你是否触发了对应方法的转换。它还会记录哪些方法没有 Body(抽象方法、native 方法、幻影类),在getCalleesOfCallAt里把这些目标过滤掉或者保留,取决于构造时的开关。

4.4 上下文敏感:ICFG 从一开始就是不敏感的

这是整套设计里最需要说清的一点:ICFG 对每个SootMethod只保留一份Body。不管这个方法被十个地方调用还是一百个地方调用,图里只有它的一份节点。于是getCallersOf(m)会把这个方法的所有调用点都返回,数据流从任意一个调用点流进同一份 Body,再流回任意一个返回点。

这个选择是刻意的:节点数从“上下文数 × 方法数”降到“方法数”,内存和时间都变成了可预测的量级。代价是精度损失,两个不同调用点传进来的实参在你的分析里可能混成一个。想更精确,就得在 ICFG 外面再套一层,比如按调用点复制方法体、或者在过程间数据流分析框架里做调用串(call-string)或访问路径的抽象。这两条路都会把状态空间重新放大,属于用精度换资源。

5. 避坑与排查:五处最容易让图“对不上”的地方

5.1 建图阶段:边太多、边太少、边重复

现象一:调用图里只有一个 main,或者干脆是空的。原因通常是两个叠在一起:整程序模式没开(set_whole_program(true)缺失),以及入口点没设。库代码没有public static void main,默认入口集合就是空的,可达方法自然为空。 解决:先确认Options.v().whole_program()返回 true,再显式Scene.v().setEntryPoints(...)。如果还不行,打印Scene.v().getApplicationClasses().size(),确认类到底有没有被加载进来——很多“空图”其实是-cp指错了目录。

现象二:图里冒出成片的java.lang.Object、java.io.Serializable相关边。原因是 CHA 对接口和抽象类的处理过于保守,把公共库里的所有实现都算了进来;另一种情况是你没开-pp(prepend classpath),JDK 基础类被当成幻影类,父类关系缺失,Soot 只能把它们当作根类型处理。 解决:补上-pp(或者显式把运行时类路径拼进-cp),并在遍历边时按包名做白名单过滤。更彻底的办法是换 Spark,让指向分析把不可能的 receiver 收缩掉。

现象三:同一对 caller/callee 出现多次,边数远大于手工统计。原因有三类。一是 on-the-fly 建图在传播过程中可能对同一个调用点重复解析,产生重复边;二是同一个 callee 从同一个 caller 的不同调用点进来,srcUnit不同但(src, tgt)相同;三是合成边(THREAD、FINALIZE、CLINIT)本来就对应不到任何源码调用点。 解决:去重时用(srcUnit, tgt, kind)三元组做键,而不是(src, tgt)。统计口径统一之后,数字就稳定了。

5.2 遍历与资源:栈溢出、内存与跑不完

现象四:遍历 ICFG 时抛StackOverflowError,或者递归进不去。原因是图里有环——递归调用、互递归、以及getCallersOf反向遍历时形成的环,都是天然的。用递归写遍历,深度很容易上千。 解决:改成显式栈,并维护visited集合。过程内的 visited 用Set<Unit>,过程间的 visited 用Set<SootMethod>,如果要做路径敏感的遍历,就退化到Set<Pair<SootMethod, Unit>>。

现象五:分析跑到几十分钟,内存一直涨到 OOM。原因是 Spark 默认会为整个类路径建模,而 ICFG 按需缓存 Body 之后不会主动释放,两者叠加能把堆吃干净。 解决:用set_process_dir只给应用类目录;开set_no_bodies_for_excluded(true)让被排除的类连 Body 都不生成;分析完一批方法后调用SootMethod.releaseBody()主动释放;实在不行就把分析按入口切成多批,每批G.reset()重来。

注意:G.reset()会清掉 Scene 上的所有全局状态,包括已经建好的调用图。如果你的流程跨阶段复用,别在中间随手调它。

6. 精度、可扩展与可信的近似:这套设计的取舍边界在哪里

整套体系建立在一个假设上:你能看到所有可能被加载的类。在这个封闭世界假设下,CHA 是 sound 的——它不会漏掉任何真实的调用边。但一旦代码里有反射、动态代理、ServiceLoader这类运行时才确定目标的机制,这个假设就破了,图上会直接缺边,而且是静默地缺。工程上常见的补救是维护一份白名单,手工把反射点对应的边补回去,属于典型的“用人力换 soundness”。

第二层取舍在精度与可扩展之间。CHA 到 Spark 是一条明显的代价曲线:边越少,分析越慢,内存越高。选哪一档,取决于你下游的分析对“假阳性”的容忍度——做污点追踪,多几条边只是多几个误报;做“这个方法绝对不可能被调用”的删除决策,少一条边就是线上事故。同一个调用图,在不同下游场景里的合格标准是不一样的。

第三层取舍在 ICFG 的上下文不敏感性上。它把内存从“路径数”压到“方法数”,代价是不同调用点的实参在分析里混流。这不是偷懒,而是把复杂度显式地推给调用方:需要精度就自己在外面套上下文抽象,不需要就用默认的便宜版本。

验证这块,我自己比较依赖“黄金样例法”:手写十几个类的玩具工程,每个类不超过三十行,覆盖虚调用、接口调用、try-catch、递归、内部类、lambda。先人工在纸上画出应有的边,再让工具输出 dot 图逐个对照。这比在真实工程里debug 高效得多,因为真实工程的边数上千,人眼根本核不动。

// 把 UnitGraph 导成 dot,配合黄金样例人眼核对 import soot.util.dot.DotGraph; UnitGraph g = new ExceptionalUnitGraph(body); DotGraph dot = new DotGraph("cfg-" + m.getName()); for (Unit u : body.getUnits()) { dot.drawNode(u.toString()); for (Unit s : g.getSuccsOf(u)) { dot.drawEdge(u.toString(), s.toString()); // 边方向:当前节点 -> 后继 } } dot.plot("out/" + m.getName() + ".dot");

从那以后,每接一个调用图相关的任务,我都会先拿那个玩具工程跑一遍,把 dot 图打印出来对着源码看,确认入口点、虚调用边、异常边的形状都符合预期,再放到真实工程上跑。这一步花二十分钟,能省掉后面几小时对着几千条边找“为什么这里多了一条”的时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询