PIN:用于体系结构研究的二进制插桩

标题:Binary instrumentation for architectural studies: PIN

日期:2014/12/16

作者:Mainak Chaudhuri

链接:https://www.youtube.com/watch?v=Bat6pB3UTwY

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:Gemini 根据口述生成了一些代码,仅供参考。(代码不方便提取,看下视频吧。)


在本次教程中,我们将了解什么是插桩(instrumentation)以及什么是动态二进制插桩(dynamic binary instrumentation)。然后,我们将看到 PIN 究竟是如何进行动态二进制插桩的,以及如何使用 PIN 进行体系结构研究。你们在做家庭作业时将会用到 PIN,所以请注意听,如果有任何问题,请随时打断我。

什么是插桩?

让我们从插桩开始。插桩是一种将额外代码插入到应用程序中,以观察、研究甚至改变其行为的技术。许多程序分析工具都使用插桩来进行性能剖析(performance profiling)、错误检测以及捕获和读取行为。

  • Valgrind 使用插桩进行内存错误分析。它可以检测到 mallocfree 的滥用。所谓滥用 malloc,我指的是你使用 malloc 分配了一块内存段,但在使用完毕后没有释放它,这会导致内存泄漏(memory leaks)。Valgrind 可以识别这些问题。所谓滥用 free,我指的是你试图释放一个未分配的内存段,这会导致段错误(segmentation fault)。它甚至可以检测到未初始化的变量和悬空指针(dangling pointers)。你们知道什么是悬空指针吗?

    学员:嗯,就是那些没有被初始化指向…

    悬空指针实际上指向一个应用程序尚未分配的内存区域。因此,对它们进行解引用(dereferencing)可能会导致段错误。好的。

  • Intel Inspector 也使用插桩进行线程错误分析。它可以检测多线程应用程序中的数据竞争(data races),甚至可以检测死锁(deadlocks)。

  • Intel Vtune Amplifier 同样使用插桩进行性能分析。它可以定位你应用程序中的热点(hotspots),甚至可以进行锁等待分析,为你提供锁竞争的时间信息。

我们将主要使用插桩来进行体系结构研究。我们会使用插桩工具进行处理器、分支预测器和缓存的模拟。你们将使用插桩来收集指令踪迹(instruction traces)。然后,这些指令踪迹将被输入到一些其他的基于阶段的模拟器或详细模拟器中。

好的。Intel 的软件开发模拟器(Software Development Emulator, SDE)也使用插桩来模拟新的 x86 指令。假设你想实现某个新的 x86 指令,并研究它能带来什么好处。你要做的就是在 SDE 中注册一个函数,这个函数实现了该新指令的功能行为。当 SDE 遇到这个新指令时,它不会尝试在硬件上执行它,而是会调用你注册的那个函数来模拟其行为。

插桩的方法

现在我们来看看插桩的方法。当我们进行插桩时,出现的两个主要问题是:代码要插在哪里? 以及 我们要插入什么代码? 这两个问题很大程度上与你试图解决的问题相关。

  • 如果你在做缓存模拟(cache simulation),那么在每条加载(load)/存储(store)指令执行时,你都希望查询你所开发的缓存模型。所以,在缓存模拟中,我们在加载/存储指令执行时插入代码,而插入的代码是用来查询缓存模型的。

  • 同样,在分支预测器性能研究中,我们希望在分支指令执行之前插入代码。这些代码会查询我们准备好的分支预测器模型,并告诉我们是分支命中(hit)还是未命中(miss)。

好的。接下来出现的问题是,我们实际上如何插入这些代码? 有多种方法可以实现。

1. 源代码插桩 (Source Code Instrumentation)

我们可以直接修改应用程序的源代码,在其中直接编写和添加代码。这被称为源代码插桩。你可能在测量某个应用程序代码块的执行时间时用过这种方法:你在这段代码之前添加代码来记录开始时间,在它结束时添加代码来记录结束时间,从而得到特定代码块消耗的时间。

这种方法并不被广泛使用,因为它有一些主要的缺点:

  • 需要源代码:既然是进行源代码插桩,你就必须拥有源代码。

  • 库文件问题:仅仅插桩应用程序的源代码是不够的。你还需要修改你所使用的第三方库的源代码。想想看,在缓存模拟中,我们试图在每条加载/存储指令之前插入代码来查询缓存模型。你使用的库函数可能也在执行加载/存储指令。因此,我们也需要在那之前插入查询缓存模型的代码。所以,仅仅插桩应用程序源代码是不够的,你必须插桩它使用的所有库。

  • 重新编译:这最终需要重新编译和重新链接整个应用程序。

学员:它经常告诉你这是一个问题吗?那么详细地告诉你?如果是,为什么?

讲师:大多数商业应用程序,你都拿不到源代码。对吧?比如说,谁有微软 Word 的源代码?很多人都想插桩微软 Word。但这种方式行不通。最常用的应用程序很多都是这些 Windows 商业软件,我们没有它们的源代码。所以通过源代码插桩来分析它们是不现实的。

2. 静态二进制插桩 (Static Binary Instrumentation)

第二种技术是静态二进制插桩。它与源代码插桩类似,但我们不是修改源代码,而是直接修改应用程序的可执行二进制文件。它也面临类似的缺点:

  • 二进制格式:你需要了解二进制文件的格式,因为你要修改它。

  • 地址重定位:我们试图在二进制文件中插入一些额外的代码。这会导致什么呢?指令的地址可能会因为我们添加的额外代码而改变。所以,如果有一条分支指令,我们甚至需要修改它的分支目标地址。因此,仅仅添加额外代码是不够的,原始代码也需要被修改。

  • 代码发现(Code Discovery):我说的代码发现是指,我们使用的大多数应用程序都使用动态库。你使用的第三方库的代码并不在应用程序的二进制文件中。它是在应用程序加载时,在运行时被加载的。因此,当你进行静态二进制插桩时,你手头并没有第三方库的代码。所以在这里,你也必须去找到这些库的二进制文件并修改它们。因此,仅仅修改应用程序的二进制文件也是不够的。

3. 动态二进制插桩 (Dynamic Binary Instrumentation)

第三种方法,也是最常用的插桩方法,是动态二进制插桩。在动态二进制插桩中,你在运行时对应用程序进行插桩。当应用程序正在执行时,你向其中插入一些额外的代码,并让它与应用程序一起执行。

这类似于即时编译(Just-in-Time Compilation, JIT)。你们熟悉即时编译吗?知道哪个编译器是…?

学员:Java? Java。Java。

讲师:Java。

学员:然后我想改变二进制代码,然后那个二进制代码被编译,它会在哪里导入?Java?

讲师:那不只是整数,那是 Java 编译。是的,Java。当你编译一个 Java 程序时,它会生成字节码(bytecode)。类文件(class files)中包含了字节码。现在,当你用 Java 虚拟机运行这个 Java 程序时,你输入 java 和一个类名,java 就是 Java 虚拟机。当它开始执行程序时,它会从类文件中读取字节码,并为之生成对应的机器码(machine code)。然后,生成的机器码才是实际被执行的。所以它会持续读取字节码,生成机器码,然后执行。这个过程一直持续到应用程序结束。

动态二进制插桩与即时编译类似,不同之处在于,这里不是从字节码翻译成机器码,而是从机器码翻译成机器码。在进行这种从机器码到机器码的翻译时,你就有机会在应用程序中插入一些额外的代码。所以,当这个翻译过程进行时,它会添加额外的代码,然后生成的代码实际上包含了原始代码和你添加的额外代码,然后它会执行这个新生成的代码。

学员:所以你必须把机器码加到机器码里。你只能把它加到机器码里。

讲师:是的。我的意思是,最终运行的总是机器码。你会看到它是如何工作的。

动态二进制插桩的好处在于:

  • 无需重新编译和链接应用程序。

  • 因为一切都发生在运行时,你可以直接将你的插桩工具附加(attach)到一个正在运行的进程上。大多数你想要插桩的工具,比如 Web 服务器和数据库服务器,都是守护进程(daemon processes)。你可以直接附加到它们上面。

  • 代码发现变得更加容易。如果你的应用程序使用了一些动态加载的库,那么这些库的代码也会首先被加载到地址空间中。当它即将被执行时,插桩引擎可以在那个时候也向其中插入代码。所以,不像静态二进制插桩那样,我们必须显式地去寻找第三方库并添加代码,这里就不需要了。

  • 它甚至能处理自修改(self-modifying)应用程序。自修改应用程序在执行时会修改自己的源代码(应指代码段)。这也会被这种机制处理好。我们稍后会看到这是如何工作的。

PIN: 一个动态二进制插桩框架

现在我们来谈谈 PIN。PIN 是一个动态二进制插桩引擎。它实现了动态二进制插桩。它是免费可用的,但不是开源的。你可以访问这个网站 pintool.org,下载适用于你操作系统的 PIN 工具包(PIN kit)。你会得到一个压缩包,解压它,无需安装。一旦解压,你就可以开始使用了。

它适用于 x86 32位和64位平台,以及所有主流操作系统:Windows, Linux, macOS。对 macOS 的支持相对较新,我不知道它工作得有多好。它可以插桩未经修改的真实应用程序,如数据库服务器、Web 服务器、Web 浏览器和多线程应用程序。只要你能在这些操作系统上运行一个应用程序,你就可以对它进行插桩。就是这样。

在 PIN 工具包中,你实际上会得到一个 PIN 插桩引擎的二进制文件。这个 PIN 插桩引擎暴露了一套丰富的 API。使用这些 API,你可以用 C 或 C++ 编写自己的插桩工具。这些插桩工具被称为 PIN 工具(Pintools)。PIN 工具包中提供了许多示例 PIN 工具,路径位于 source/tools/ 目录下,你应该去看看它们。

正如我之前所说,PIN 是一个动态二进制插桩引擎。它所做的是读取原始应用程序的代码,并为这个应用程序代码生成一些相应的代码。在做这件事的时候,它利用来自 PIN 工具的信息来修改这个生成过程。它会插入一些 PIN 工具告诉它要插入的额外代码。所以,PIN 工具会告诉 PIN 在哪里要插入额外代码,以及要插入什么代码。而 PIN 会为 PIN 工具实际执行这个操作。

PIN 使用 PIN 工具在运行时,在动态二进制编译期间对应用程序进行插桩。PIN 工具可以被看作是修改 PIN 代码生成过程的插件(plugins)

学员:这个工具是一个即时编译器吗?

讲师:是的,它类似于一个即时编译器。在即时编译器中,你为某些字节码生成机器码。而在这里,你为机器码生成机器码。

学员:这也会在进程执行时生成吗?

讲师:是的。它在运行时完成所有这些事情。当应用程序正在执行时,它会一边执行应用程序,一边在应用程序中添加一些额外的代码,并执行那些额外的代码。

PIN 工具由 插桩例程(instrumentation routines)分析例程(analysis routines) 组成。这些都是简单的函数。

  • 插桩例程:每当需要生成新代码时,PIN 就会调用插桩例程。每当 PIN 即将生成一些新代码时,它就会调用这些插桩例程。插桩例程会研究原始代码的静态属性,并决定是否以及在何处注入对分析例程的调用。它会遍历代码,决定是否要在这里注入一些代码,然后它会注入对分析例程的调用。这两个例程都包含在 PIN 工具中。一旦它决定了要插入对分析例程的调用,它就会请求 PIN 插入该调用。

  • 分析例程:分析例程定义了插桩代码将做什么。

一旦代码被生成,它会被存储在一个 代码缓存(code cache) 中并被重用。所以,如果你有一个执行多次迭代的 for 循环,那么在第一次迭代执行时,PIN 会为这次迭代生成代码。在为第一次迭代生成代码时,它也会对其进行插桩。当第一次迭代执行完毕后,对于第二次迭代,它不会再次调用插桩例程来生成代码,因为它已经将那段代码缓存在了代码缓存中。使用缓存来存储生成的代码是整个即时编译领域的一种优化,用以降低代码生成的成本。它确实能相当快地加速整个过程。

学员:澄清一下,这个缓存不是硬件结构吧?

讲师:是的,它是一个软件缓存。它是内存的一部分,用来存储…

学员:存储生成的代码。

讲师:只要生成的代码在代码缓存中,就不需要进行插桩。它可以直接执行那段代码。所以,对于一段已经被插桩的应用程序代码,如果生成的代码在其整个生命周期内都保留在代码缓存中,那么插桩例程就不会再为它被调用。大多数时候,代码缓存的大小足以缓存整个应用程序。所以插桩函数对于原始代码通常只会被调用一次。

学员:你可以配置代码缓存的大小吗?

讲师:可以的。

下面是一个示意图。这是原始的应用程序代码。我们正在从机器码翻译到机器码,所以生成的代码也会和它几乎相似,指令保持不变(寄存器分配可能会不同,但我这里用的是一样的)。我们插入了一个对分析函数的调用。

/* 生成代码,仔细甄别 */

// 原始代码
...
cmp eax, ebx
jle target
...

// 生成的、被插桩后的代码
...
call analysis1    // 注入的调用
cmp eax, ebx      // 原始指令
jle target
...

这是原始代码,PIN 为它生成了这段代码。PIN 工具对这段代码进行了插桩,并注入了一个对 analysis1 函数的调用。所以,每次这条 cmp 指令即将被执行时,就在它之前,这个 analysis 函数也会被执行。因此,每一条被插桩的指令,每次那条指令被执行时,分析函数也会被执行。你明白了吗?

  • 插桩例程告诉你要插桩哪条指令以及在哪里插桩。你可以把它放在指令之前或之后,我们稍后会讨论这个。

  • 分析例程告诉你要做什么。这里的“做什么”就是注入对分析例程的调用,“在哪里”则由插桩例程决定。

所以,插桩意味着仅仅是插入调用。这些插桩函数对于每条指令大多数时候最多被调用一次。而分析函数则会在每次该指令被执行时被调用。

讲师评论:所以,你可以看到,在最理想的情况下,假设你插桩每一条指令,插桩例程对于每条指令应该只被调用一次。当然,前提是你需要有足够的空间,代码缓存等等。然而,针对特定指令的分析例程,则会在每次该指令执行时被调用。因为生成的代码中包含了一个对这个分析函数的调用,所以每次程序执行到这条代码路径时,这个分析函数都会被调用。

PIN 工具不仅包含插桩和分析例程,它还会向 PIN 注册应该调用哪个插桩例程。它注册一个回调(callback)到 PIN 应该使用的插桩例程。

除了注册插桩例程的回调,PIN 工具还可以为 通知事件(notification events) 注册回调。这些通知事件可以是:

  • 线程启动(thread start)、线程结束(thread end)

  • 派生(fork)一个子进程

  • 应用程序结束

  • 镜像(image)加载和卸载(这里镜像指的是一个被加载的二进制文件,比如动态库)

每当一个动态库的二进制文件被加载时,我们可以注册一个函数调用。这些事件通知通常用于初始化 PIN 工具的某些部分,以及在结束时进行清理工作。

你的 PIN 工具是一个 C/C++ 程序。你编写它,编译它,创建一个 .so 文件(共享库)。然后,你将这个 PIN 工具作为命令行参数提供给 PIN 引擎,在双破折号 -- 之后,你提供你想要插桩的应用程序二进制文件。

/* 生成代码,仔细甄别 */
# 示例命令
/path/to/pin -t /path/to/your_pintool.so -- /path/to/your_application

这些 PIN 工具,你要么可以使用 PIN 工具包中的示例工具,要么可以编写自己的。你也可以将 PIN 附加到一个已经运行的进程上。为此,你需要使用 -pid 命令行选项提供该进程的 PID。

学员:你会给我们一些 PIN 的例子,对吧?

讲师:是的。好的,接下来有三个例子。

示例 1:计算指令数 (inscount0)

我们来尝试写一个 PIN 工具,它用来计算执行的指令总数。这里的汇编代码是应用程序的代码。我们想做的是,在每条指令之前,插入代码来递增一个计数器 counter++

/* 生成代码,仔细甄别 */

// 目标逻辑
...
counter++;      // 插入的代码
add eax, 1;     // 原始指令
...
counter++;      // 插入的代码
cmp ebx, ecx;   // 原始指令
...

这样,每当一条指令即将执行时,就在它之前,计数器会被增加。通过这种方式,我们就可以记录执行的指令数量。

学员:在那个例子中,在大多数情况下,我们试图内联(inline)分析函数,对吧?

讲-师:是的。它插入了对分析例程的调用。在这里,我们的分析例程就是递增这个计数器。但是 PIN… 你编译这个 PIN 工具时,它也会进行编译器优化。在那里,它会看到这是一个简单的函数,所以很可能会将这些函数内联。

假设我们已经写好了这个 PIN 工具,我们想用它来插桩 ls 命令。

  • 这是 ls 通常的输出。

  • 当我们用 PIN 运行它时,像这样:pin -t inscount0.so -- ls,它会产生 ls 的输出,以及 PIN 工具的输出。

/* 生成代码,仔细甄别 */

# 正常运行 ls
file1.txt  file2.txt  another_dir

# 使用 PIN 工具运行 ls
$ pin -t inscount0.so -- ls
file1.txt  file2.txt  another_dir
Count: 123456  # PIN 工具的输出

它会告诉我们 ls 命令执行的指令数量。

现在让我们看看 inscount 这个 PIN 工具的代码。它是一个简单的 C++ 应用程序。这是 PIN 工具包中 source/tools/SimpleExamples/ 文件夹下的一个文件,代码就取自那里。

/* 生成代码,仔细甄别 */

#include "pin.H"
#include <iostream>

// 用于保存指令计数的全局变量
UINT64 icount = 0;

// 分析例程:每次调用时将计数器加一
VOID docount() { 
    icount++; 
}
    
// 插桩例程:在每条指令前插入对 docount 的调用
VOID Instruction(INS ins, VOID *v) {
    // 在'ins'指令之前插入一个对函数'docount'的调用。
    // 不需要参数,所以参数列表为空。
    INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_END);
}

// Fini 函数:在应用程序退出时调用,打印总数
VOID Fini(INT32 code, VOID *v) {
    std::cerr << "Count: " << icount << std::endl;
}

// Pintool 的主函数
int main(int argc, char * argv[]) {
    // 初始化 PIN
    PIN_Init(argc, argv);

    // 注册 Instruction 函数以在每条指令上被调用
    INS_AddInstrumentFunction(Instruction, 0);

    // 注册 Fini 函数以在应用程序结束时被调用
    PIN_AddFiniFunction(Fini, 0);
    
    // 启动程序,这个调用永不返回
    PIN_StartProgram();
    
    return 0; // 永远不会执行到这里
}

让我们来分析一下这段代码:

  • 头文件:我们包含了 pin.H 头文件,因为要使用 PIN 的 API 函数。

  • 全局计数器:我们定义了一个64位整型 icount,并初始化为0。

  • docount() 函数:这是分析例程。每当它被调用时,就将计数器加一。

  • Instruction() 函数:这是插桩例程。它接受一些参数,并调用 INS_InsertCall 函数。这是 PIN 的一个 API 函数。它的第三个参数是指向 docount 函数的指针。

  • Fini() 函数:这个函数只是将 icount 的值打印到标准错误流(stderr)。

  • main() 函数:这是你 PIN 工具的入口点。在 main 函数中,我们有四个 PIN API 调用:

    1. PIN_Init():这个函数会初始化 PIN 的运行时系统。它接受命令行参数,所以你也可以向 PIN 工具传递一些命令行参数。

    2. INS_AddInstrumentFunction():这个函数接受一个指向 Instruction 函数的指针。通过这个调用,PIN 工具向 PIN 注册了当需要生成代码时应该调用的插桩例程。它告诉 PIN,每当要为一条指令生成代码时,在生成代码之前,应该先调用 Instruction 函数。

    3. PIN_AddFiniFunction():这个函数接受一个指向 Fini 函数的指针。我们稍后会详细讲。

    4. PIN_StartProgram():这个函数启动应用程序并开始插桩过程。

学员:你可以覆盖 Fini 函数吗?

讲师:不,你不能。你的 PIN 工具总是会执行这个主函数,这是它执行的第一个函数。你不能移动它。你不能修改这些函数的任何一个。是的。

Instruction() 函数中,我们调用了 INS_InsertCall()。我们想在每条指令之前递增计数器。所以我们说,在这条指令之前 (IPOINT_BEFORE),我们请求 PIN 插入一个对 docount 函数的调用。

PIN_AddFiniFunction() 函数注册了一个回调。当应用程序即将退出时,它会调用 Fini 函数。这是应用程序结束事件的回调。这正是我们想要的——当应用程序即将退出时,打印出已执行的指令数量。

学员:如果异常退出呢?比如 exit(-1)

讲师:是的,那种情况下这个函数也会被调用。你可以用这个函数做任何你想在程序终止时做的事情。这里我们只是计数,所以我们打印计数值。你可能为你的 PIN 工具分配了一些内存,你可以在应用程序结束时释放它。所以这些事件函数通常用于初始化和最后的清理工作。

直到 PIN_StartProgram() 被调用之前,应用程序和插桩都没有真正开始。这个函数实际上永远不会返回return 0; 只是为了让编译器满意。它内部实现了一个长跳转(long jump)。当这个函数执行时,它启动应用程序并开始插桩过程。

插桩点 (Instrumentation Points)

如果你还记得,在 INS_InsertCall 函数中,第二个参数是插桩点。

/* 生成代码,仔细甄别 */
INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_END);

插桩点指的是一个相对于指令的位置。让我们看一个 jle (小于等于则跳转) 指令的例子。

对于这条指令,有三个可能的位置:

  1. 指令之前 (IPOINT_BEFORE):在我们的例子中,我们使用了 IPOINT_BEFORE。它会在 jle 指令对应的代码生成之前,在这里插入对 docount 函数的调用。

  2. 指令之后(顺序执行) (IPOINT_AFTER):因为这是一条分支指令,它有一条“顺序执行边”(fall-through edge),即分支不跳转时执行的下一条指令。我们可以用 IPOINT_AFTER 在这条路径上插入代码。

  3. 指令之后(跳转) (IPOINT_TAKEN_BRANCH):它还有一条“跳转边”(taken edge),即分支跳转时执行的目标指令。我们可以用 IPOINT_TAKEN_BRANCH 在这条路径上插入代码。

如果我们在之前的例子中指定了 IPOINT_AFTER,那么 counter++ 就会被放在顺序执行的路径上。

学员:这只对分支指令是个问题吧?

讲师:是的。在一个分支之后,可能会有两种可能性,取决于分支走向何方。

这两个插桩点(IPOINT_AFTERIPOINT_TAKEN_BRANCH)对于某些指令可能并未定义。

  • 如果这是一条无条件跳转指令,那么它没有顺序执行边,因为它总是会跳转。所以 IPOINT_AFTER 对于无条件分支是无效的,只有 IPOINT_TAKEN_BRANCH 有效。

  • 如果这不是一条分支指令,比如 cmp 指令,那么它没有跳转边,只有顺序执行边。

IPOINT_BEFORE 对于绝大多数指令都是始终定义的。

学员:在之前的例子里,如果我不用 IPOINT_BEFORE,而是指定 IPOINT_AFTER,会怎么样?

讲师:它会正常编译。但是,它将无法统计跳转了的分支。是的。对于条件分支,如果分支被采纳(跳转了),那么紧随其后的 counter++ 就不会被执行。所以,你将不得不修改你的插桩代码,同时在 IPOENT_AFTERIPOINT_TAKEN_BRANCH 两个位置都进行插桩。

学员:那种情况下,对于非分支指令,会不会重复计数?

讲师:不会。因为另一部分(跳转边)不存在。

大多数时候我们会使用 IPOINT_BEFORE,但对于分支预测器研究,我们可能也需要使用另外两个。例如,如果你想知道对于一个给定的分支,它有多少次走了顺序执行路径,有多少次走了跳转路径,那么这两个插桩点就非常重要。

示例 2:打印指令踪迹 (itrace)

现在看另一个例子。我们想打印一个指令踪迹,我指的是被执行指令的指令地址。我们将插入一个对 printIp 函数的调用,并向它提供一个输入参数 ip(指令指针)。在这里,我们需要向分析例程传递一个输入参数。

讲师澄清:在 PIN 的世界里,ip 指的是指令指针(instruction pointer),和程序计数器(program counter, PC)是同一个东西。所以它只是打印每条指令的指令指针。

这个工具的用法类似:

/* 生成代码,仔细甄别 */
pin -t itrace.so -- ls

这个工具不会在标准输出或标准错误终端上输出任何东西。它会把踪迹写入一个名为 itrace.out 的文件。当你运行它时,它会创建这个文件,下面我展示了 itrace.out 文件的一部分内容,它会以十六进制格式打印指令地址。

0x7f1234567890
0x7f1234567891
0x7f1234567894
0x7f123456789a
...

学员:第一条指令是1个字节吗?从 9091

讲师:是的。第二条就很大了,到 94。这可能是一条跳转指令。x86 指令不是定长的,它们是可变长度指令。

这是 itrace 的代码。

/* 生成代码,仔细甄别 */
#include "pin.H"
#include <iostream>
#include <fstream>

// 输出文件流
std::ofstream TraceFile;

// 分析例程:打印指令指针
VOID printIp(VOID *ip) {
    TraceFile << ip << std::endl;
}

// 插桩例程
VOID Instruction(INS ins, VOID *v) {
    // 插入一个对 printIp 的调用,并将指令指针作为参数传递
    INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)printIp, IARG_INST_PTR, IARG_END);
}

// Fini 函数
VOID Fini(INT32 code, VOID *v) {
    TraceFile.close();
}

// Pintool 主函数
int main(int argc, char *argv[]) {
    PIN_Init(argc, argv);
    TraceFile.open("itrace.out");

    INS_AddInstrumentFunction(Instruction, 0);
    PIN_AddFiniFunction(Fini, 0);

    PIN_StartProgram();
    return 0;
}

代码分析:

  • 分析例程 printIp 现在接受一个 VOID* 类型的参数 ip,并将其打印到文件。

  • main 函数中,我们在程序开始时创建并打开 itrace.out 文件。

  • Fini 函数中,我们关闭这个文件。

  • 插桩例程 Instruction 发生了变化。INS_InsertCall 函数现在有了更多参数。我们传递了 IARG_INST_PTR。这告诉 PIN,当注入对 printIp 函数的调用时,请将当前指令的指令指针作为参数传递给 printIp 函数。IARG_INST_PTR 是由 PIN API 定义的,它会给出当前正在插桩的指令的地址。

学员:IARG_INST_PTR 是一个全局环境变量吗?

讲师:不,它是一个宏定义。你不能在 main 函数里访问它。

基本上,PIN 提供了一个 API 函数 INS_Address,如果你把指令引用作为输入传给它,它就会给你地址。IARG_INST_PTR 是一个宏,它实际上被转换成了类似 IARG_UINT32, INS_Address(ins) 的形式。

分析函数可以有任意数量的参数。在 INS_InsertCall 中,我们指定了所有想传递的参数。

  • 前三个参数通常是固定的:指令引用、插桩点、分析函数指针。

  • 在分析函数指针之后,我们可以指定任意数量的参数,它们将被传递给分析函数。

  • IARG_END 是一个宏,它标志着 INS_InsertCall 参数列表的结束。

学员:如果我想传递一个文件指针,我需要把它设为全局变量吗?

讲师:是的,可以。如果你在这里定义了文件指针,它对这个函数就是可见的。你可以这样做。

传递给分析例程的参数示例

我们可以传递各种类型的参数:

  • 整数值IARG_UINT32, 6。这个宏需要跟一个值。它会将值 6 作为一个32位整数传递给分析函数。

  • 指令指针IARG_INST_PTR

  • 寄存器值IARG_REG_VALUE, reg_name。PIN 会传递那个寄存器的值。

  • 分支目标地址IARG_BRANCH_TARGET_ADDR

  • 内存地址:我们稍后会看到如何使用它们的例子,例如 IARG_MEMORYREAD_EA (有效地址)。

还有很多很多,你可以传递任意数量的参数。

示例 3:打印内存踪迹 (pinatrace)

好了,最后一个例子。到目前为止,我们一直在插桩每一条指令。但如果你想做缓存模拟,或者打印内存踪迹,我们只想在每条加载/存储指令之前插入代码,而不是在算术逻辑单元(ALU)指令之前。

首先,什么是内存踪迹?它记录了类似这样的信息:

/* 生成代码,仔细甄别 */

Instruction Address | R/W | Memory Address
-------------------------------------------
0x...               |  R  | 0x...
0x...               |  W  | 0x...

它表示某条指令从某个内存地址进行了读(R)操作,或者向某个内存地址进行了写(W)操作。

我们想收集这样的内存踪迹。虽然我们也可以通过插桩每一条指令来实现,但那样效率很低。大约有三分之二的指令是非内存操作指令,我们不想插桩它们。我们只想插桩那些加载/存储指令。

PIN 工具 pinatrace 就是做这个的,它打印内存踪迹。它也在 PIN 工具包里,这是它的代码…

讲师:我们是不是该休息一下了?你想下次再继续吗?

我已经在课程网站上放了一个 PIN 工具的链接,你们可以下载并开始尝试使用它。PIN 工具的官网是 pintool.org,课程网站上也有链接。网站上有一份手册,是一篇很好的教程,所有这些例子都取自那份手册。你们应该去读一下那份教程。

好的,我们下次再完成这部分内容。这对你们来说是一次全面的教程,一旦我们完成,我们就可以结束第一部分了。谢谢。