简介:SootTest 是一份面向 Java 静态分析与编译优化学习者的实战示例资源,基于开源分析平台 Soot,演示如何从字节码或 Jimple 中间表示出发,构建方法调用图与过程间控制流程图(ICFG),适合已具备 Java 基础、希望深入理解程序调用关系与跨方法控制流的中高级开发者参考。压缩包共 23 个文件,约 12.27MB,以 class 与 java 源文件为主体,辅以 dot 图形输出、jar 依赖包、png 结果截图及 Eclipse 工程配置,完整保留了可运行的工程结构。资源已有 1447 人学习下载,说明其在 Soot 入门与图形化分析场景中具有一定参考价值。读者可从中获得调用图与 ICFG 的生成思路、Soot 场景构建与配置方式,以及 dot 输出结果的直观对照,便于迁移到自身的代码分析、性能优化或缺陷检测实践中。
1. 从一段“看不懂的 Java 老代码”说起
接手一个跑了七八年的 Java 服务,最头疼的不是业务逻辑,而是没人说得清方法之间到底谁调了谁。日志里一个异常抛出来,顺着堆栈往上翻,翻到第三层就断了——因为中间隔着一层反射、一层接口实现、一层回调。这时候光靠 IDE 的“Find Usages”已经不够用了,你需要的是把整个工程的调用关系画成一张图,再顺着控制流看清楚一个方法在什么条件下会走到哪个分支。
SootTest 这个项目干的就是这件事:它基于 Soot 这个 Java 字节码分析框架,把编译后的 class 文件读进来,生成方法级的调用图(Call Graph),再进一步生成过程间控制流程图(Interprocedural Control Flow Graph,ICFG)。前者回答“谁调用了谁”,后者回答“从入口到出口,控制流经过了哪些基本块、跨过了哪些方法边界”。适合谁?做代码审计的、做静态分析的、维护遗留系统想理清调用链的,以及想入门程序分析但被 Soot 文档劝退的 Java 开发者。下面按“能干什么 → 怎么跑 → 核心代码怎么读 → 怎么改”的顺序拆。
2. 项目定位与技术选型:为什么是 Soot 而不是自己写解析器
2.1 这个项目解决的具体问题
Java 源码经过 javac 编译后变成字节码,字节码里方法调用有四种指令:invokevirtual、invokespecial、invokestatic、invokeinterface,Java 7 之后还有invokedynamic。想从这些指令还原出“调用图”,你得处理继承、接口多态、虚方法分派——一个invokevirtual可能对应十几个实际被调用的目标方法。自己写解析器不是不行,但光是把 class 文件格式(常量池、方法表、属性表)解析对就要花掉大半时间,更别说后面还要做指针分析来确定多态调用的真实目标。
Soot 把这些脏活都干了。它内置了多种调用图构造算法(CHA、RTA、SPARK),能把字节码转成 Jimple 这种三地址码中间表示,再基于 Jimple 做控制流分析。SootTest 的价值不在于重新发明轮子,而在于把 Soot 的 API 串成一条能直接跑的流水线:加载 class → 构造调用图 → 遍历调用图 → 对每个方法生成 CFG → 拼接成 ICFG → 输出成可读格式。
2.2 技术栈与依赖关系
项目的技术栈很集中,核心就是 Java + Soot,构建工具常见的是 Maven 或 Gradle。Soot 本身是个纯 Java 库,通过 Maven 坐标引入即可,不需要额外安装什么运行时。下面是一个典型的依赖声明,版本号按你本地实际用的填,Soot 4.x 和 3.x 的 API 有差异,建议锁定一个主版本:
<dependency> <groupId>org.soot-oss</groupId> <artifactId>soot</artifactId> <version>4.4.1</version> </dependency>除了 Soot 本体,如果要把图输出成 DOT 格式再用 Graphviz 渲染,通常还会引入一个轻量的图结构库,或者干脆自己用字符串拼 DOT。SootTest 这类项目一般不会引入太重的图数据库,因为分析对象是单个工程的字节码,内存里存个Map<SootMethod, Set<SootMethod>>就够了。
提示:Soot 4.x 要求 JDK 11 及以上,如果你还在 JDK 8 上跑,要么降 Soot 到 3.3.x,要么先升级 JDK。这个版本错配是新手第一个翻车点。
2.3 目录结构长什么样
一个典型的 SootTest 工程目录大致如下,不同人实现会有出入,但核心文件跑不掉:
soot-test/ ├── pom.xml ├── src/main/java/ │ ├── Main.java # 入口,解析参数、启动分析 │ ├── CallGraphBuilder.java # 封装 Soot 调用图构造 │ ├── IcfgBuilder.java # 过程间 CFG 拼接 │ └── DotExporter.java # 图输出为 DOT └── src/test/java/ └── Sample.java # 被分析的示例类Main负责命令行参数和 Soot 的Options配置,CallGraphBuilder把 Soot 的CallGraph包一层方便遍历,IcfgBuilder处理跨方法的边,DotExporter只做序列化。这种拆分的好处是:你想换调用图算法,只动CallGraphBuilder;想换输出格式,只动DotExporter,互不牵连。
3. 跑起来:从 class 文件到第一张调用图
3.1 环境准备与 Soot 的 Options 配置
跑之前先把被分析的 class 准备好。最省事的办法是拿项目自己的target/classes目录,或者随便写个Sample.java用javac编译出一个 class。Soot 需要知道三件事:从哪读 class(-cp或setSootClassPath)、分析哪个类(-main-class或setMainClass)、用什么中间表示(setOutputFormat)。下面这段是初始化 Soot 的骨架代码:
import soot.*; import soot.options.Options; public class Main { public static void main(String[] args) { // 清空 Soot 的全局状态,避免多次分析互相污染 G.reset(); Options opts = Options.v(); // 指定被分析 class 所在目录 opts.set_soot_classpath("./target/classes"); // 指定入口类,调用图从这里开始扩展 opts.set_main_class("Sample"); // 用 Jimple 作为中间表示,后续 CFG 基于它构建 opts.set_output_format(Options.output_format_jimple); // 保留所有类,不做无用代码裁剪 opts.set_whole_program(true); opts.set_allow_phantom_refs(true); // 加载入口类及其依赖 Scene.v().loadNecessaryClasses(); Scene.v().setEntryPoints( Scene.v().getMainClass().getMethods() ); } }set_whole_program(true)是关键,它告诉 Soot 要做全程序分析,否则调用图只会包含入口类内部的方法,跨类的边全丢。set_allow_phantom_refs(true)允许引用不存在的类(比如 JDK 内部类没在 classpath 里),避免加载阶段直接抛异常。G.reset()这行很多人会漏,结果在同一个 JVM 里跑第二次分析时拿到上一次的残留状态,图里多出一堆莫名其妙的边——这个坑我踩过,排查了半天才发现是全局状态没清。
3.2 构造调用图:CHA、RTA 还是 SPARK
Soot 提供三种主要的调用图构造器,精度和开销递增:
| 算法 | 精度 | 适用场景 | 主要局限 |
|---|---|---|---|
| CHA | 最低 | 快速摸底、类层次简单 | 虚调用按声明类型全量展开,边多且假 |
| RTA | 中等 | 常规分析、平衡精度与速度 | 只考虑可达方法,反射仍丢失 |
| SPARK | 最高 | 需要精确指向关系 | 内存和耗时明显上升 |
构造调用图的代码很短,但选哪个算法决定了你后面看到的图是“毛坯”还是“精装”:
import soot.jimple.toolkits.callgraph.CallGraph; import soot.jimple.toolkits.callgraph.CHATransformer; import soot.jimple.spark.SparkTransformer; // 方式一:CHA,最快,适合先看个大概 CHATransformer.v().transform(); CallGraph cg = Scene.v().getCallGraph(); // 方式二:SPARK,精度高,适合做真实调用链分析 // Map<String, String> sparkOpts = new HashMap<>(); // sparkOpts.put("enabled", "true"); // sparkOpts.put("propagator", "worklist"); // SparkTransformer.v().transform("", sparkOpts); // CallGraph cg = Scene.v().getCallGraph();CallGraph对象的核心 API 是edgesInto和edgesOutOf,分别拿到指向某方法和从某方法出发的边。每条边是一个Edge,带src()、tgt()和kind()(调用类型)。遍历时注意:CallGraph是只读视图,别试图往里加边,要改图得在构造阶段通过 Soot 的Scene操作。
3.3 生成过程间控制流程图
调用图只告诉你方法之间的调用关系,不告诉你控制流在方法内部怎么走、跨方法时从哪个调用点进入、从哪个返回点出来。ICFG 就是把这些拼起来:每个方法的 CFG 是一个子图,调用点连到被调方法的入口,被调方法的出口连回调用点的下一条语句。
Soot 本身没有直接暴露“给我一个 ICFG”的一键 API,常见做法是自己遍历调用图,对每个SootMethod用BriefBlockGraph或ExceptionalBlockGraph生成方法内 CFG,再按调用边拼接。下面是一个简化的拼接逻辑:
import soot.toolkits.graph.BriefBlockGraph; import soot.toolkits.graph.Block; // 对调用图中的每个方法生成方法内 CFG for (SootMethod m : cg.getReachableMethods()) { if (!m.hasActiveBody()) continue; // 抽象方法或 native 方法跳过 Body body = m.getActiveBody(); BriefBlockGraph cfg = new BriefBlockGraph(body); // 遍历基本块,找调用语句 for (Block block : cfg) { for (Unit unit = block.getHead(); unit != null; unit = block.getSuccOf(unit)) { if (unit instanceof InvokeStmt || unit instanceof DefinitionStmt) { // 这里可以拿到调用点,后续与调用图的边做关联 // 实际拼接时,把调用点 block 的出口指向被调方法 CFG 的入口 } } } }BriefBlockGraph把方法体切成基本块,块内没有分支,块间有getSuccsOf/getPredsOf。拼接 ICFG 的难点在于:一个调用点可能对应多个被调目标(多态),所以调用点的后继不是一个块,而是一组块的入口;同时被调方法可能有多个返回点,每个返回点都要连回调用点的后继。这段逻辑写起来琐碎,但思路就是“调用点出边 → 被调入口,被调出口 → 调用点后继”,把这两类边补上,ICFG 就成形了。
注意:
hasActiveBody()一定要判断。接口方法、抽象方法、native 方法都没有方法体,直接getActiveBody()会抛异常。这个判断漏掉,分析稍微大一点的工程必崩。
4. 核心模块拆解:调用图遍历与 ICFG 拼接的关键实现
4.1 调用图的边类型与过滤策略
CallGraph里的边不是只有“方法调用”一种。Soot 的Kind枚举区分了VIRTUAL、SPECIAL、STATIC、INTERFACE、THREAD、FINALIZE等多种类型。做调用链分析时,THREAD和FINALIZE这类边往往是噪音——它们表示线程启动和对象终结,不是普通的方法调用。过滤策略直接影响图的可读性:
import soot.jimple.toolkits.callgraph.Edge; import soot.jimple.toolkits.callgraph.CallGraph; import java.util.Iterator; public void walkCallGraph(CallGraph cg) { Iterator<Edge> it = cg.iterator(); while (it.hasNext()) { Edge edge = it.next(); // 只保留普通方法调用边,过滤线程和终结器边 if (edge.kind() == Kind.THREAD || edge.kind() == Kind.FINALIZE) { continue; } SootMethod src = edge.src(); SootMethod tgt = edge.tgt(); System.out.println(src.getSignature() + " -> " + tgt.getSignature()); } }getSignature()返回的是完整签名,包含类名、方法名、参数类型和返回类型,比getName()信息全得多。做图输出时用签名做节点 ID,能避免不同类里同名方法被合并成一个节点——这是另一个常见错误,用方法名当 key,结果两个类都有toString(),图里直接串线。
4.2 方法内 CFG 的块结构读取
BriefBlockGraph的Block对象提供了getHead()、getTail()、getSuccsOf()、getPredsOf(),但块内语句的遍历要小心:getSuccOf(unit)在到达块尾时返回null,所以循环条件要判空。下面这段把每个块的头尾和语句数打出来,是调试 CFG 结构最直接的手段:
BriefBlockGraph cfg = new BriefBlockGraph(body); for (Block block : cfg) { Unit head = block.getHead(); Unit tail = block.getTail(); int stmtCount = 0; for (Unit u = head; u != null; u = block.getSuccOf(u)) { stmtCount++; if (u == tail) break; // 到达块尾,停止 } System.out.printf("Block head=%s tail=%s stmts=%d succs=%d%n", head, tail, stmtCount, block.getSuccsOf(tail).size()); }getSuccsOf(tail)拿到的是块尾的后继块列表,正常情况下一个块最多两个后继(条件分支),但异常处理会引入额外的边。如果你发现某个块的后继数量异常多,先检查是不是ExceptionalBlockGraph把 catch 块也连进来了。普通分析用BriefBlockGraph就够,它不展开异常边,图更干净。
4.3 跨方法边的拼接与去重
ICFG 拼接时最容易出的问题是重复边。同一个调用点,如果被调方法有多个重载或实现,调用图里会有多条边指向不同目标,拼接时每个目标都要连,但不能把同一个(调用点, 目标入口)对连两次。用一个Set<String>记录已处理的边 key 就能解决:
import java.util.HashSet; import java.util.Set; Set<String> edgeKeys = new HashSet<>(); for (Edge e : callGraph) { String key = e.src().getSignature() + "|" + e.tgt().getSignature(); if (!edgeKeys.add(key)) { continue; // 已处理过,跳过 } // 在这里执行 ICFG 跨方法边的拼接 connectCallSiteToCallee(e.src(), e.tgt()); }connectCallSiteToCallee的具体实现依赖你用什么图结构存 ICFG。如果只是输出 DOT,直接在字符串里拼"src_block" -> "tgt_block"即可;如果要支持路径查询,就得建一个邻接表。去重这步不做,DOT 文件里会出现大量平行边,Graphviz 渲染出来一团乱麻,根本没法看。
5. 避坑与排查:Soot 分析里最容易翻车的五件事
5.1 现象:调用图里只有入口类的方法,跨类边全丢
原因几乎总是set_whole_program(true)没设,或者set_soot_classpath只指向了单个 class 文件而不是目录。Soot 在非全程序模式下只分析入口类,依赖类的方法不会被加载进Scene,自然没有边。解决办法是把 classpath 设成target/classes这样的目录,并确认loadNecessaryClasses()在设置完 classpath 之后调用。顺序错了也白搭。
5.2 现象:分析到一半抛RuntimeException: no active body
前面提过,抽象方法、接口方法、native 方法没有方法体。遍历调用图时拿到一个SootMethod,先hasActiveBody()判断,再getActiveBody()。另外,如果方法体被 Soot 优化掉了(比如set_whole_program配合某些优化选项),也可能出现有方法但无 body 的情况,这时候检查Options里有没有开set_optimize之类的开关,分析阶段建议全关。
5.3 现象:同一个 JVM 里跑第二次分析,结果和第一次不一样
G.reset()没调。Soot 的Scene、Options都是全局单例,第一次分析完状态还在,第二次loadNecessaryClasses会把新类叠加到旧状态上。每次分析前G.reset()一行解决,别省。如果是在 Web 服务里做按需分析,更要注意每次请求前重置,否则并发下状态互相污染,出的图完全不可信。
5.4 现象:SPARK 分析大工程时 OOM 或跑几十分钟不出结果
SPARK 的指针分析复杂度高,工程一大内存直接爆。先确认 JVM 堆够不够(-Xmx4g起步),再考虑降级到 RTA。RTA 只分析可达方法,速度快一个数量级,精度损失在多数调用链场景下可以接受。如果非要用 SPARK,把propagator设成worklist,并限制分析范围到特定包,别全量扫。
5.5 现象:DOT 文件渲染出来节点重叠、边交叉成蜘蛛网
节点太多时 Graphviz 的默认布局撑不住。两个办法:一是按包或类做子图分组(subgraph cluster_xxx),让同类的节点聚在一起;二是先做过滤,只输出入度或出度超过阈值的关键节点,把叶子方法折叠掉。另外 DOT 里节点 ID 用签名会很长,渲染时用短 ID 加label属性显示全名,图会清爽很多。
6. 二次开发:把调用图接到你自己的分析流水线上
6.1 从“出图”到“出数据”的改造思路
SootTest 默认输出 DOT 给人看,但二次开发时你往往要的是结构化数据——比如把调用图导成 JSON 喂给前端做交互式浏览,或者把 ICFG 的边存进图数据库做路径查询。改造点很集中:把DotExporter换成一个JsonExporter,节点用{id, signature, className, methodName},边用{src, tgt, kind}。Soot 的SootMethod能直接拿到getDeclaringClass().getName()和getSubSignature(),拼 JSON 不难。
真正值得花时间的是给边加上调用点信息。Edge对象有srcUnit()方法,返回触发这次调用的具体语句(Jimple 的InvokeStmt或含调用的DefinitionStmt)。把这个Unit的字符串表示也塞进边的属性里,前端就能显示“第几行调用了谁”,排查问题时比纯方法级图有用得多。我一般会在导出时把srcUnit的行号也带上,unit.getJavaSourceStartLineNumber()能拿到源码行号(前提是编译时带了调试信息,javac -g)。
6.2 验证改造是否正确的三个检查点
改完导出逻辑,别急着接下游,先做三个自检。第一,拿一个只有三层调用的小样例,手工数一遍边数,和导出结果对得上;第二,找一个有接口多态的例子,确认invokeinterface的边指向了所有实现类的方法,而不是只指向接口声明;第三,把导出的 JSON 重新读回来构造成图,跑一次连通性检查,确认没有孤立节点(除非那个方法确实没被调用也没调用别人)。这三个检查过了,数据基本可信。
| 检查项 | 预期结果 | 失败时先看哪 |
|---|---|---|
| 小样例边数 | 与手工统计一致 | 是否漏了SPECIAL边(构造器调用) |
| 接口多态边 | 指向所有实现 | 调用图算法是否用了 CHA 而非 RTA/SPARK |
| 连通性 | 无异常孤立节点 | hasActiveBody过滤是否误杀 |
6.3 一个具体技巧:用Scene缓存加速批量分析
如果你要对多个 class 目录做分析,每次G.reset()后重新loadNecessaryClasses会很慢。一个折中办法是把公共依赖(JDK 类、第三方库)先加载进Scene,分析不同业务类时只增量加载业务 class,不重置全局状态。代价是要自己管理Scene的清理,容易出状态污染。我的习惯是:批量分析时按“依赖集合”分组,同一组依赖只加载一次,组间才G.reset()。这样既避免了全量重载的开销,又把污染范围控制在组内。
从那以后我每次接静态分析任务,都先把“分析范围”和“依赖边界”写清楚再动手,而不是上来就loadNecessaryClasses全量扫——范围没圈定,图再漂亮也是错的。希望帮到你。
本文还有配套的精品资源,点击获取