目录
前言
Soot 是 McGill 大学的 Sable 研究小组自 1996 年开始开发的 Java 字节码分析工具,它提供了多种字节码分析和变换功能。通过它可以进行过程内和过程间的分析优化,以及程序流图的生成;还能通过图形化的方式输出,让用户对程序有个直观的了解。尤其是做单元测试的时候,可以很方便的通过这个生成控制流图然后进行测试用例的覆盖,显著提高效率。Soot 项目已经不再继续维护,其最高支持到 Java 9 版本。如果要在更新的项目中使用,请配置项目组最新开发和维护的 SootUp 项目(https://soot-oss.github.io/SootUp/)。
一、Soot 的下载和安装
1.1 在命令行中使用 Soot
Soot 项目在 Github 上的地址为:https://github.com/Sable/soot。目前来说,要使用 Soot 有三种途径,分别是命令行、添加到项目以及 Eclipse 插件(不推荐)。
可以选择使用 Github 上的 Release 或者 Git 克隆项目到本地,然后使用 Maven 或者 IDEA -maven 构建项目。
可以在这里(https://repo1.maven.org/maven2/org/soot-oss/soot/)下载最新的 soot jar 包,我下载的是 4.4.1 版本中的 sootclasses-trunk-jar-with-dependencies.jar 包。
这个包应该自带了 soot 所需要的所有依赖。下载完成后使用命令提示符进入 jar 文件所在的文件夹(我的是 D:\programing\sootTest),输入以下命令:
java -cp sootclasses-trunk-jar-with-dependencies.jar soot.Main
输出如下图:
在输入 -h 命令可以回显帮助信息:
java -cp sootclasses-trunk-jar-with-dependencies.jar soot.Main -h
1.2 在项目中使用 Soot
从 Github 上 Soot 项目的简介可知,Soot 一般配合 Maven 来进行部署,相关的 POM 文件依赖添加语句如下(在 <dependencies> 下追加 <dependency>):
<dependencies>
<dependency>
<groupId>ca.mcgill.sable</groupId>
<artifactId>soot</artifactId>
<version>4.4.1</version>
</dependency>
</dependencies>
二、使用 Soot 生成中间代码 (IR)
Soot 是 Java 优化框架,提供 4 种中间代码来分析和转换字节码。
Baf:精简的字节码表示,操作简单
Jimple:适用于优化的 3-Address 中间表示
Shimple:Jimple 的 SSA 变体
Grimple:适用于反编译和代码检查的 Jimple 汇总版本。
由于在命令行中调用 Soot 是最为简单的模式,所以后文均以在命令行中使用 Soot 为基准。
我的目标是将 Java 源文件转化为 Jimple 以发现程序编译中的问题和规律。因此本文的重点就在这里,我先在 soot.jar 所在的文件夹下新建了一个 Java 源文件 HelloWorld.java 如下图所示:
// HelloWorld.java
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello");
}
}
因为我使用的 Java 版本是 JDK1.8(Java 8),根据 Soot 提示,默认输入是 class 文件,所以我先用 javac 命令将 HelloWorld.java 编译为 HelloWorld.class。
javac HelloWorld.java
下面我们尝试将上面得到的 class 文件作为输入传给 soot 。
java -cp sootclasses-trunk-jar-with-dependencies.jar soot.Main -pp -cp . HelloWorld
得到的结果没有报错,但是也无事发生,这是因为 soot 需要通过 -f 属性指定输出的类型,这里我们将输出类型指定为 Jimple,查询文档之后得知要添加 -f J 以确定输出格式,最终的语句如下:
java -cp sootclasses-trunk-jar-with-dependencies.jar soot.Main -f J -pp -cp . HelloWorld
该命令在 soot 工具所在目录下生成了一个 sootOutput 文件夹,里面有一个 HelloWorld.jimple 文件,使用 Idea 编辑器打开这个文件,得到的内容如下,这就是一个最基本的 HelloWorld.java文件所形成的 Jimple 码。
public class HelloWorld extends java.lang.Object
{
public void <init>()
{
HelloWorld r0;
r0 := @this;
specialinvoke r0.<init>();
return;
}
public static void main(java.lang.String[])
{
java.io.PrintStream $r0;
java.lang.String[] r1;
r1 := @parameter0;
$r0 = java.lang.System.out;
$r0.println("hello");
return;
}
}
上文使用 sootclasses-trunk-jar-with-dependencies.jar 也可以改用 sootclasses-trunk.jar (只不过用这个 sootclasses-trunk.jar 必须配置好 classpath 环境变量),但是我不建议这么做。
三、使用 Soot 进行 Java 类插桩
(该小节摘录自官方教程的机器翻译内容)
我将首先展示一个例子。从这个示例中,您可以了解到如何使用 Soot 修改类文件。然后,我将解释在 Soot 中类、方法和语句的内部表示。
在本教程开始前,建议你先掌握 JVM 指令的相关知识。你还应该学习如何使用 Soot 添加局部变量和字段等。这里的类插桩只修改字节码文件,通过注入性能分析代码,来记录程序运行时的重要信息。
任务:计算在运行一个微小的基准测试 TestInvoke.java 时执行了多少条 InvokeStatic 指令。
class TestInvoke {
private static int calls=0;
public static void main(String[] args) {
for (int i=0; i<10; i++) {
foo();
}
System.out.println("我使用了 " + calls + " 个静态调用");
}
private static void foo(){
calls++;
bar();
}
private static void bar(){
calls++;
}
}
为了实现计数器,我编写了一个名为 MyCounter 的辅助类:
/* 计数器类 */
public class MyCounter {
/* 计数器变量存储,初始化为0 */
private static int c = 0;
/**
* 将计数器增加多少(howmany)
* @param howmany :计数器的增量。
*/
public static synchronized void increase(int howmany) {
c += howmany;
}
/**
* 报告计数器内容。
*/
public static synchronized void report() {
System.err.println("计数 : " + c);
}
}
现在,我要创建一个包装器类,在 Soot 中添加一个阶段,用于插入分析指令,然后调用 Soot.Main.main()。该驱动程序类的 main 方法将名为" jtp.instrumenter "的转换阶段添加到 Soot 的" jtp "包中。
PackManager 是对 Soot 注册的不同阶段的类的包装。当 MainDriver 调用 soot.Main.main 时, Soot 将从 PackManager 得知注册了一个新阶段,并且标志着一个新阶段的 internalTransform 方法会被 Soot 调用。
MainDriver.java :
/* 用法: java MainDriver [soot-options] appClass
*/
/* 导入必要的 soot 包 */
import soot.*;
public class MainDriver {
public static void main(String[] args) {
/* 检查参数 */
if (args.length == 0) {
System.err.println("用法: java MainDriver [options] classname");
System.exit(0);
}
/* 通过调用 Pack.add 方法添加一个阶段(phase)到 transformer 包 */
Pack jtp = PackManager.v().getPack("jtp");
jtp.add(new Transform("jtp.instrumenter",
new InvokeStaticInstrumenter()));
/* 把控制权交给 Soot 来处理所有选项,
* InvokeStaticInstrumenter.internalTransform 将被调用。
*/
soot.Main.main(args);
}
}
instrumenter(插桩)的实际实现扩展了一个抽象类 BodyTransformer。它实现了 internalTransform 方法,该方法采用方法体(指令)和一些选项。主要操作发生在该方法中。根据您的命令行选项,Soot 构建一个类列表(这也意味着方法列表),并通过传入每个方法的主体来调用InvokeStaticInstrumenter.internalTransform。
InvokeStaticInstrumenter.java :
/*
* InvokeStaticInstrumenter 在程序中的 INVOKESTATIC
* 字节码之前插入计数指令。插桩后的程序将
* 报告在一次运行中发生了多少静态调用。
*
* 目标:
* 在静态调用指令之前插入计数器指令。
* 在程序正常退出点之前报告计数器数值。
*
* 方法:
* 1. 创建一个计数器类,它有一个计数器字段,和
* 一个报告方法。
* 2. 获取每个方法体,遍历每个指令,并且
* 在 INVOKESTATIC 之前插入计数指令。
* 3. 调用计数器类的报告生成方法。
*
* 从这个例子中可以学到的东西:
* 1. 如何使用 Soot来测试 Java 类。
* 2. 如何在类中插入分析指令。
*/
/* InvokeStaticInstrumenter 扩展了抽象类 BodyTransformer,
* 并实现 internalTransform 方法。
*/
import soot.*;
import soot.jimple.*;
import soot.util.*;
import java.util.*;
public class InvokeStaticInstrumenter extends BodyTransformer{
/* 一些内部字段 */
static SootClass counterClass;
static SootMethod increaseCounter, reportCounter;
static {
counterClass = Scene.v().loadClassAndSupport("MyCounter");
increaseCounter = counterClass.getMethod("void increase(int)");
reportCounter = counterClass.getMethod("void report()");
}
/* InternalTransform 遍历方法体并将计数器指令插入
* 在 INVOKESTATIC 指令之前。
*/
protected void internalTransform(Body body, String phase, Map options) {
// 主体的方法
SootMethod method = body.getMethod();
// 调试
System.out.println("instrumenting method : " + method.getSignature());
// 将主体的单元作为一个链(单元链)
Chain units = body.getUnits();
// 获取单元的快照迭代器,因为我们将在
// 迭代链时对其进行变异。
//
Iterator stmtIt = units.snapshotIterator();
// 用于迭代每个语句的典型 while 循环
while (stmtIt.hasNext()) {
// 回溯一个声明
Stmt stmt = (Stmt)stmtIt.next();
// 语句有很多种类型,这里只是
// 对包含 InvokeStatic 的语句感兴趣
// 注意:有两种语句可能包含
// invoke 表达式:InvokeStmt 和 AssignStmt
if (!stmt.containsInvokeExpr()) {
continue;
}
// 取出 invoke (调用)表达式
InvokeExpr expr = (InvokeExpr)stmt.getInvokeExpr();
// 现在跳过非静态调用
if (! (expr instanceof StaticInvokeExpr)) {
continue;
}
// 现在我们到达真正的指令
// 调用 Chain.insertBefore() 在其之前插入指令
//
// 1. 首先,新建一个 invoke 表达式
InvokeExpr incExpr= Jimple.v().newStaticInvokeExpr(increaseCounter.makeRef(),
IntConstant.v(1));
// 2. 然后,构造一个 invoke 语句
Stmt incStmt = Jimple.v().newInvokeStmt(incExpr);
// 3. 向链中插入新语句
// (我们正在对单元链实施变异操作)。
units.insertBefore(incStmt, stmt);
}
// 不要忘记插入报告计数器的指令
// 这只发生在 main 方法的退出点之前。
// 1. 通过检查签名来检查这是否是 main 方法
String signature = method.getSubSignature();
boolean isMain = signature.equals("void main(java.lang.String[])");
// 2. 重新迭代主体以查找 return 语句
if (isMain) {
stmtIt = units.snapshotIterator();
while (stmtIt.hasNext()) {
Stmt stmt = (Stmt)stmtIt.next();
// 检查指令是否是带值/不带值的 return
if ((stmt instanceof ReturnStmt)
|| (stmt instanceof ReturnVoidStmt)) {
// 1. 构造 MyCounter.report() 的 invoke 表达式
InvokeExpr reportExpr= Jimple.v().newStaticInvokeExpr(reportCounter.makeRef());
// 2. 然后,构造一个 invoke 语句
Stmt reportStmt = Jimple.v().newInvokeStmt(reportExpr);
// 3. 向链中插入新语句
// (我们正在对单元链实施变异操作)。
units.insertBefore(reportStmt, stmt);
}
}
}
}
}
现在,在正式插桩(instrumentation)之前,需要测试一下 instrumenter :
[cochin] [621tutorial] java TestInvoke
我使用了 20 个静态调用
运行该 instrumenter :
[cochin] [621tutorial] java MainDriver TestInvoke
Soot started on Tue Feb 12 21:22:59 EST 2002
Transforming TestInvoke... instrumenting method : <TestInvoke: void <init>()>
instrumenting method : <TestInvoke: void main(java.lang.String[])>
instrumenting method : <TestInvoke: void foo()>
instrumenting method : <TestInvoke: void bar()>
instrumenting method : <TestInvoke: void <clinit>()>
Soot finished on Tue Feb 12 21:23:02 EST 2002
Soot has run for 0 min. 3 sec.
这会将转换后的 TestInvoke.class 放入 ./sootOutput 中。运行这个新转换的基准测试(注意你现在需要在你的类路径上放置 MyCounter.class 文件):
[cochin] [621tutorial] cd sootOutput
[cochin] [621tutorial] java TestInvoke
Exception in thread "main" java.lang.NoClassDefFoundError: MyCounter
at TestInvoke.main(TestInvoke.java)
[cochin] [621tutorial] cp ../MyCounter.class .
[cochin] [621tutorial] java TestInvoke
我使用了 20 个静态调用
计数 : 20
比较插桩前后的JIMPLE代码:
插桩前:
class TestInvoke extends java.lang.Object
{
......
public static void main(java.lang.String[] )
{
......
label0:
staticinvoke <TestInvoke: void foo()>();
i0 = i0 + 1;
......
return;
}
private static void foo()
{
......
staticinvoke <TestInvoke: void bar()>();
return;
}
private static void bar()
{
......
return;
}
......
}
插桩后:
class TestInvoke extends java.lang.Object
{
......
// 这是主方法
public static void main(java.lang.String[] )
{
......
label0:
// 这里插入了计数器静态方法
staticinvoke <MyCounter: void increase(int)>(1);
staticinvoke <TestInvoke: void foo()>();
i0 = i0 + 1;
......
// 这里插入了报告生成器静态方法
staticinvoke <MyCounter: void report()>();
return;
}
private static void foo()
{
......
// 这里插入了计数器静态方法
staticinvoke <MyCounter: void increase(int)>(1);
staticinvoke <TestInvoke: void bar()>();
return;
}
private static void bar()
{
......
return;
}
......
}
我们看到,在每个 staticinvoke 指令之前添加了对 MyCounter.increase(1) 的方法调用,并且在 main 方法的返回指令之前插入了对 MyCounter.report() 的调用。
关于这一部分更多的讲解可以看以下几篇文章:
2. Soot 使用记录 | Jckling's Blog;
四、使用 Soot 生成控制流图 (CFG)
Soot 利用 AST (抽象语法树)生成程序的控制流程关系。soot.tools.CFGViewer 分析类中的每个方法的控制流并生成 DOT 语言描述的控制流图。我们使用 Graphviz 工具中的 dot 命令将其转换成可视化图形。
任务:使用 soot.tools.CFGViewer 生成 Triangle.class 的控制流图
首先使用 javac 命令编译此源代码文件:
// Triangle.class
package Soot;
public class Test {
private double num = 5.0;
public double cal(int num, String type){
double temp=0;
if(type == "sum"){
for(int i = 0; i <= num; i++){
temp =temp + i;
}
}
else if(type == "average"){
for(int i = 0; i <= num; i++){
temp = temp + i;
}
temp = temp / (num -1);
}else{
System.out.println("Please enter the right type(sum or average)");
}
return temp;
}
}
运行 “sootclasses-trunk-jar-with-dependencies.jar” 时,输入文件 Triangle.class 文件的位置与sootclasses-trunk-jar-with-dependencies.jar 在同一目录下。
soot 生成控制流程关系时,有两种增量方式:语句划分和按基本块划分。
4.1 按语句划分的控制流程图
使用下面的命令执行生成按照语句划分的控制流程关系。
# 命令1:按语句划分
java -cp sootclasses-trunk-jar-with-dependencies.jar soot.tools.CFGViewer -cp . -pp Triangle
其中:
(1)“soot.tools.CFGViewer” 表示使用 soot 的控制流图绘制功能
(2)“-cp .” 表示 soot 指明类路径,“.” 表示类路径为当前路径。
(3)Soot 还必须指明 java.lang.Object,可以用 “-pp”,也可以添加 “rj.jar”。
(4)Triangle 指代 Triangle.class,Soot 默认输入 class 文件;当然,你可以用 "–src-prec" 指定输入文件类型。
4.2 按基本块划分的控制流程图
# 命令2:按基本块划分
java -cp sootclasses-trunk-jar-with-dependencies.jar soot.tools.CFGViewer -cp . -pp --graph=BriefBlockGraph Triangle
其中:“–graph=BriefBlockGraph” 表示按基本块划分。
使用基本块划分将使得最终生成的控制流程图分支更少,调理更清晰。但可能忽略一些块内细节的展示。我们在大多数情况下使用基本块划分模式。
五、Graphviz 工具的安装和使用
Graphviz 是开源图形可视化软件。图形可视化是一种将结构信息表示为抽象图形和网络图的方法。它在网络、生物信息学、软件工程、数据库和网页设计、机器学习以及其他技术领域的可视化界面中具有重要应用。
5.1 Graphviz 工具的安装
它的官网服务器是在国外,所以国内浏览会比较慢。
官网链接:Graphviz。
关于安装教程方面,我暂时也没时间截图重新整理一份。索性在 CSDN 找到两篇写的很好的教程,可以结合着看。
1. https://blog.csdn.net/qq_42294351/article/details/119754109
2. https://blog.csdn.net/qq_42257666/article/details/121688656
5.2 Graphviz 工具的使用
Graphviz 画图只需两步:
- 创建 .dot 文本文件, 在其中使用 DOT 语言描述图形;这里我们已经有了由 soot 生成的 DOT 文件,可以直接使用 Graphviz 生成可视化图像了。
- 使用命令将 dot 文本内容转换为图片:
dot Triangle.dot -T png -o Triangle.png
# -T指定输出类型, 可以指定jpg, gif, svg等
# -o 指定输出文件名, 不指定则输出到标准输出上
# 或者:
# dot -T png -o Triangle.png Triangle.dot
# dot -Tpng -o Triangle.png Triangle.dot
按语句划分的(太长了只能截图上传):
按基本块划分的:
经过人工优化代码以及修改 DOT 后生成的简化版:
【注】:因为一些原因,我不能展示完整的图像给大家。
关于 Graphviz 工具和它的 DOT 语法的使用细节可以看这篇文章:
- Graphviz 绘图 — Graphviz 笔记 (graphviz-note.readthedocs.io);
- DOT 用法 — Graphviz 笔记 (graphviz-note.readthedocs.io);
六、其他 CFG 图绘制方法
6.1 使用工具
Visustin 工具是一个老牌的多语言流程图生成工具。这款软件是商业化的,虽然他支持多种自然语言,但演示版仅允许免费使用 30 天,并且功能受到限制。
Visustin 演示版工具的图标:
支持的自然语言列表:
样例程序:
Visustin 工具生成的控制流图虽然相对准确,但是很不美观。我们可以截图保存并使用 Viso 工具重新画一个 CFG 图。
6.2 使用在线网站
有很多网站提供简单源代码的 CFG 图生成和编辑功能。比如 Code2Flow 网站(code2flow - online interactive code to flowchart converter)。虽然这些网站生成控制流图较为精美且操作简单,但是大多数都不完全免费。
本文发布于:2024.03.25,更新于:2024.03.25.