以下内容主要基于Valgrind: A Framework for Heavyweight Dynamic Binary Instrumentation 中的信息。
第1部分
为什么当您查看 IR 时,即使程序集很小,程序也会增长这么多。
至少有两个原因:
虽然 GCC 为例程生成的机器代码main()可能很小,但二进制中的所有代码都由 Valgrind 转换,包括动态链接库中的代码。
Valgrind 是一个动态二进制仪器 (DBI) 框架,在某些方面类似于DynamoRIO和PIN。它被实现为一个进程虚拟机(PVM),它将二进制文件加载到自己的虚拟内存空间中,并执行其代码的转换和插装版本:
Valgrind 使用动态二进制重新编译,类似于许多其他 DBI 框架。通过valgrind --tool=<toolname>在命令前添加(加上任何 Valgrind 或工具选项)来调用Valgrind 工具。命名工具启动,将客户端程序加载到同一进程中,然后(重新)编译客户端的机器代码,一次一个小代码块,以即时、执行驱动的方式。核心将代码块分解为中间表示 (IR),由工具插件使用分析代码对其进行检测,然后由核心将其转换回机器代码。生成的翻译存储在代码缓存中,以便在必要时重新运行。Valgrind 的核心大部分时间都用于制作、查找和运行翻译。没有运行客户端的原始代码。
正确处理的代码包括:正常的可执行代码、动态链接库、共享库和动态生成的代码。[1]
在 Valgrind 使用的中间表示 (IR) 中,二进制中的机器代码指令所具有的每个效果都由 IR 操作明确表示。这意味着具有副作用的 CISC 指令将由多个 IR 操作表示。
Valgrind 使用反汇编和重新合成 (D&R):机器代码被转换为 IR,其中每条指令都变成一个或多个 IR 操作。这个 IR 被检测(通过添加更多的 IR),然后转换回机器代码。所有原始代码对客户状态的影响(例如条件代码设置)都必须在 IR 中明确表示,因为原始客户端指令被丢弃,最终代码完全由 IR 生成。
IR 有一些类似于 RISC 的特性:它是加载/存储,每个原始操作只做一件事(许多 CISC 指令被分解为多个操作),并且当扁平化时,所有操作仅对临时和文字进行操作。尽管如此,支持所有不同大小的标准整数、FP 和 SIMD 运算需要 200 多个原始算术/逻辑运算。
测试二进制文件的指令集架构在原始帖子中没有明确说明,假设为 x86。x86 是CISC ISA 的一个例子,这意味着 IR 操作的数量>>原始机器代码指令的数量。这是由于 x86 指令在副作用和作为执行单个指令的结果执行的操作方面的复杂性。
当我执行时valgrind --tool=lackey --trace-mem=yes test,test从使用 GCC 的原始帖子中的示例 C 代码创建的 ELF32 二进制文件在哪里,这些是结果(被截断,加上指向将在随后讨论的行的箭头):
.
.
.
I 04cec101,3
I 04cec104,3
I 04cec107,2
==11736==
==11736== Counted 0 calls to main() <---------- (1)
==11736==
==11736== Jccs:
==11736== total: 44,420
==11736== taken: 21,288 ( 47%)
==11736==
==11736== Executed:
==11736== SBs entered: 44,083
==11736== SBs completed: 30,750
==11736== guest instrs: 211,953
==11736== IRStmts: 1,304,900
==11736==
==11736== Ratios:
==11736== guest instrs : SB entered = 48 : 10
==11736== IRStmts : SB entered = 296 : 10
==11736== IRStmts : guest instr = 61 : 10 <---------- (2)
==11736==
==11736== Exit code: 0
正如我们在(2)中看到的,IR 语句明显多于机器代码指令,这符合将 CISC 指令翻译成 Valgrind 的 IR 的预期。
(1) 与问题的第二部分有关
以下是生成多个 IR 语句的单个 x86 指令示例:
0x24F27C: addl %ebx,%eax <---------- x86 instruction + operands
4: ------ IMark(0x24F27C, 2) ------
5: PUT(60) = 0x24F27C:I32 # put %eip
6: t3 = GET:I32(0) # get %eax
7: t2 = GET:I32(12) # get %ebx
8: t1 = Add32(t3,t2) # addl
9: PUT(32) = 0x3:I32 # put eflags val1
10: PUT(36) = t3 # put eflags val2
11: PUT(40) = t2 # put eflags val3
12: PUT(44) = 0x0:I32 # put eflags val4
13: PUT(0) = t1 # put %eax
第2部分
如何在我编写的代码中隔离代表指令的 IR?
由于 Valgrind 如何转换机器代码(反汇编 + 重新合成),这似乎是不可能的。
我们在 (1) 中观察到main(),当 Valgrind 检测测试二进制文件时,调用了 0 次。由于main()什么都不做,它有可能在机器代码 -> IL -> 检测 IR -> 机器代码翻译过程中被优化掉。
翻译过程实际上包括 8 个阶段,其中
所有阶段都由核心执行,除了由工具执行的仪器。标有“*”的阶段是特定于体系结构的。
阶段 1. 反汇编*:机器码 → 树 IR。反汇编器将机器代码转换为(未优化的)树 IR。每条指令都独立地分解为一个或多个语句。这些语句完全更新内存中受影响的访客寄存器:访客寄存器从 ThreadState 中提取到临时寄存器中,对其进行操作,然后写回。
阶段 2. 优化 1:树 IR → 平面 IR。第一个优化阶段使 IR 变平并进行了几个优化:冗余 get 和 put 消除(从 ThreadState 中移除不必要的来宾寄存器复制)、复制和常量传播、常量折叠、死代码移除、公共子表达式消除、甚至对块内循环进行简单的循环展开。
阶段 3. 检测:平坦 IR → 平坦 IR。然后将代码块传递给该工具,该工具可以对其进行任意转换。在这一点上将 IR 变平是很重要的,因为它使检测更容易,特别是对于阴影值工具。
第 4 阶段。 优化 2:平坦 IR → 平坦 IR。第二个更简单的优化过程执行常量折叠和死代码删除。
阶段 5. 树构建:平面 IR → 树 IR。树生成器将平面 IR 转换回树 IR,为指令选择做准备。分配给只使用一次的临时表的表达式通常会被代入临时表的使用点,并删除分配。生成的代码可能以与原始代码不同的顺序执行加载,但加载永远不会经过存储
阶段 6. 指令选择*:树 IR → 指令列表。指令选择器将树 IR 转换为使用虚拟寄存器的指令列表(除了那些硬连线以使用特定寄存器的指令;这些在 x86 和 AMD64 上很常见)。指令选择器使用简单的、贪婪的、自顶向下的树匹配算法。
阶段7.寄存器分配:指令列表→指令列表。线性扫描寄存器分配器 [26] 用主机寄存器替换虚拟寄存器,必要时插入溢出。始终保留一个通用主机寄存器以指向 ThreadState。
阶段 8. 汇编*:指令列表 → 机器代码。最后的组装阶段只是对选定的指令进行适当的编码,并将它们写入内存块。
在优化和可能的任意转换之后,关于lackey由 GCC 为main().
补充资源:
https://fosdem.org/2017/schedule/event/valgrind_vex_future/
https://fosdem.org/2017/schedule/event/valgrind_vex_future/attachments/slides/1842/export/events/attachments/valgrind_vex_future/slides/1842/valgrind_vex_future.pdf
https://github.com/trailofbits/libvex/blob/master/VEX/pub/libvex_ir.h
https://arxiv.org/pdf/0810.0372.pdf
http://www.ittc.ku.edu/~kulkarni/teaching/EECS768/slides/chapter3.pdf
https://docs.angr.io/docs/ir.html
1. Nicholas Nethercote 和 Julian Seward。Valgrind:重量级动态二进制检测框架。在过程中。2007 年 6 月 ACM SIGPLAN 2007 编程语言设计与实现 (PLDI) 会议。