Intel Pin 本体是一个动态二进制插桩框架,但是其内部集成的 Intel XED 库接口非常适合做指令统计,更重要的是……已经有现成的工具包含在官方示例里面了。
本文简单记录一下 Intel Pin 的概念和用法,仅作为备忘录。
NOTES:
- 静态分析的话,可以使用 LLVM-MCA。
- 动态分析但是面向性能剖析的话,perf 就够了。
- 我本来想着这种观测用途首先就得找 eBPF/bcc 工具集,结果往里一看,没有一个工具是关于指令的。然后我又找了
valgrind --tools=lackey
,卡🐔没法用。看来我对工具的认知还不够。 - Intel Pin 是闭源的,不要管什么 JIT 原理实现,能用就行;另外这里只做统计不做分析。
基本概念
以下是对 Pin User Guide 的拙劣解释,不保证理解无误。推荐读者亲自看用户指南,或者找有安全背景的人(我不是!)写的文章。
$ pin -t <pintool.so> -- <target_binary>
Intel Pin 可以视为一个 JIT 编译器,它会接收两个组件:一是需要被插桩的二进制文件(目标程序),二是 pintool。Intel Pin 根据目标程序在同一虚拟地址空间内将原始指令编译成新的指令,而 pintool 则注册插桩例程和提供分析例程,前者决定插入点,后者用于插入点执行用户定义函数。也就是说,pintool 是我们用户要实现的工具,用它来指导 Intel Pin 的代码生成过程。
pintool 示例:计算指令总数(图源)
Intel Pin 为了提高执行效率,将插桩的粒度分为多种对象,从大到小:
- IMG/image:一个二进制文件。
- SEC/section:一个 ELF section。
- RTN/routine:一个函数。
- TRACE/trace:连续的指令序列,单入口多出口。(动态构建的执行路径,用于优化性能。)
- BBL/basic block:连续的指令序列,单入口单出口。(内部不存在跳转。)
- INS/instruction:单条指令。
对于指令级的插桩,其插入点(IPOINT)除了不仅有前后之分(BEFORE/AFTER,后者只用于直落场景),还有控制流之分(TAKEN_BRANCH,只用于非直落场景),直落(fall through)指的是控制流不会拐弯,也就是要排除掉 jmp/call/ret 这类指令,像 jcc 这种指令就是 AFTER 和 TAKEN_BRANCH 都可以插入,但是运行时互斥;对于更粗粒度的插桩,还有「不管什么位置总之插入就行」的选项(ANYWHERE),以便 JIT 优化。
IPOINT 文档还是抽象,我构造了一个半成品测试程序,感兴趣可以试试。
一些节选特性,详情需要看官方指南:
- 代码缓存:trace 可理解为多个 BBL 动态创建,提供代码缓存优化。也就是说,如果其中有同一个 BBL 是在 trace 里面反复跳转,那么能节省 JIT 开销。
- 共享上下文:由于是共享的地址空间,pintool 可以与原有的目标二进制程序进行共享内存通信;不仅如此,连文件描述符等系统提供的资源也是共享的。
- 线程安全:pintool 的分析例程如果是运行在多线程环境中,那么访问全局变量就需要避免数据竞争,此时必须要使用 Intel PIN 提供的接口(锁原语等)。
- 探针模式:除了 JIT 模式以外,Intel Pin 还支持 probe 模式。官方说辞是这个跑得快但是接口会更少。我的理解是后者只是给特定函数(RTN)入口换了个 trampoline 跳板。
- 更多的插入语义:常规的插入接口是 insert-call,但是接口文档还提供了 insert-if-call 和 insert-then-call 等更加复杂的语义。为什么不在 insert-call 层面上自己做 if-else 判断呢?文档的意思是有更好的内联优化。
所以基本概念大概这样?再复杂点我也不会。
示例使用
# 位于 ./source/tools/SimpleExamples,我们使用 catmix 来分析 ls 程序
# 别忘了编译
../../../pin -t obj-intel64/catmix.so -- /bin/ls && cat catmix.out
Intel Pin 内置的 catmix 示例已经基本符合我的工具需求,先来一个 demo。
#
# static counts
#
#num category count-unpredicated count-predicated
#
2 ADOX_ADCX 0 3
3 AES 0 12636
4 AMX_TILE 0 196
6 AVX 818 0
7 AVX2 2844 11862
8 AVX2GATHER 0 2
9 AVX512 866 0
13 AVX512_VBMI 0 7227
14 AVX512_VP2INTERSECT 0 531
16 BINARY 59325 0
17 BITBYTE 1176 520
19 BMI1 537 1766
20 BMI2 146 1
21 BROADCAST 57 0
22 CALL 18901 0
23 CET 20 5433
25 CLFLUSHOPT 0 6573
28 CMOV 0 2383
30 COND_BR 55168 56
32 CONVERT 558 0
33 DATAXFER 166068 0
34 DECIMAL 0 20845
38 FLAGOP 3 0
44 HRESET 0 19963
46 INTERRUPT 0 529
48 IOSTRINGOP 0 5
51 KMASK 791 0
54 LOGICAL 49049 0
55 LOGICAL_FP 32 0
56 LZCNT 27 0
57 MISC 26814 0
62 NOP 3327 0
#
# dynamic counts
#
#num category count-unpredicated count-predicated
#
3 AES 0 24011
6 AVX 2879 0
7 AVX2 8549 29049
13 AVX512_VBMI 0 9552
14 AVX512_VP2INTERSECT 0 31
16 BINARY 128776 0
17 BITBYTE 1361 89
19 BMI1 2409 5883
20 BMI2 316 0
21 BROADCAST 455 0
22 CALL 9557 0
23 CET 0 17601
25 CLFLUSHOPT 0 6503
28 CMOV 0 2255
30 COND_BR 119821 8
32 CONVERT 74 0
33 DATAXFER 225624 0
34 DECIMAL 0 13874
44 HRESET 0 17370
48 IOSTRINGOP 0 1
54 LOGICAL 99891 0
57 MISC 25563 0
62 NOP 524 0
# eof
实现这些指令统计需要依赖 Intel XED 库,这也是 catmix.cpp 的实现方式。为了精简,这里的结果去掉了第三列(使用 INS_InsertPredicatedCall 的报告)。
源码在上方,接下来做点简要解析。
Intel XED
// 输入
UINT16 INS_GetStatsIndex(INS ins)
{
// 关键是 INS_Category
if (INS_IsPredicated(ins))
return MAX_INDEX + INS_Category(ins);
else
return INS_Category(ins);
}
// 输出
VOID DumpStats(ofstream& out, STATS& stats, const string& title)
{
out << "#\n"
"# "
<< title
<< "\n"
"#\n"
"#num category count-unpredicated count-predicated count-predicated-true\n"
"#\n";
for (UINT32 i = 0; i < MAX_INDEX; i++)
{
if (stats.unpredicated[i] == 0 && stats.predicated[i] == 0) continue;
// 关键是 CATEGORY_StringShort
out << decstr(i, 3) << " " << ljstr(CATEGORY_StringShort(i), 15)
<< decstr(stats.unpredicated[i], 12)
<< decstr(stats.predicated[i], 12)
<< decstr(stats.predicated_true[i], 12)
<< endl;
}
}
XED 就是 X86 Encoder/Decoder,不仅能编码(生成指令),还能解码(解析指令)。工具里面用的它的解码能力:说白了就是调接口 INS_Category()
。
category 是比指令更高层的抽象,这就是我之前特别想要的需求,我想看到的是程序的某些例程在运行一段时间后不同类别的指令数占比,而不是每一种指令的占比。比如 ja 和 jc,类似的指令总不应该分成两个项单独列出来吧,所以用 COND_BR
作为一个高层类别是合理的。其实这种事情实质就是查表,只是工作量有点大。
更多接口
# 稍微对工具做了定制
# $dynamic-counts
#
# opcode count
#
3000 *total 576158
3001 *mem-atomic 76
3002 *stack-read 52148
3003 *stack-write 46084
3004 *iprel-read 10675
3005 *iprel-write 1029
3007 *mem-read-1 28931
3008 *mem-read-2 3091
3010 *mem-read-4 22536
3014 *mem-read-8 81995
3022 *mem-read-16 2548
3038 *mem-read-32 4412
3527 *mem-write-1 1796
3528 *mem-write-2 400
3530 *mem-write-4 6017
3534 *mem-write-8 50163
3542 *mem-write-16 2409
3558 *mem-write-32 410
除此以外,官方示例中还有一个 insmix.cpp 实现,也是用来了解访存模式的好工具。总之就是拿一堆 INS_IsMemoryRead()
类似接口做的。
静态分析
VOID Image(IMG img, VOID* v)
{
// 从 IMG 下探到 SEC -> RTN -> INS
for (SEC sec = IMG_SecHead(img); SEC_Valid(sec); sec = SEC_Next(sec))
{
for (RTN rtn = SEC_RtnHead(sec); RTN_Valid(rtn); rtn = RTN_Next(rtn))
{
RTN_Open(rtn);
for (INS ins = RTN_InsHead(rtn); INS_Valid(ins); ins = INS_Next(ins))
{
// 静态地获取 INS 信息
if (INS_IsPredicated(ins))
GlobalStatsStatic.predicated[INS_Category(ins)]++;
else
GlobalStatsStatic.unpredicated[INS_Category(ins)]++;
}
RTN_Close(rtn);
}
}
if (KnobProfileStaticOnly.Value())
{
Fini(0, 0);
exit(0);
}
}
int main() {
// ...
IMG_AddInstrumentFunction(Image, 0);
// ...
}
前面的示例结果(# static counts
)和源码其实也展示了 Intel Pin 的静态分析(统计)能力。通过 IMAGE 下探到 SEC、RTN,就可以静态地获取到所有指令的元数据。知道这种方式的写法,自己也可以做一个静态 pintool 了。
动态分析
前面已经有了 XED 给的接口,在静态分析那里也看到了也是调用一下的事情,那么动态分析的一种可行的办法就是 INS_AddInstrumentFunction 直接运行时把每条指令算出来。
VOID Trace(TRACE trace, VOID* v)
{
for (BBL bbl = /*...*/)
{
// 只在 BBL 的入口插桩,降低分析例程的执行开销
INS_InsertCall(BBL_InsHead(bbl), /* ... */);
}
}
int main(int argc, CHAR* argv[])
{
// ...
TRACE_AddInstrumentFunction(Trace, 0);
PIN_AddFiniFunction(Fini, 0);
// ...
// Never returns
PIN_StartProgram();
return 0;
}
不过示例的做法更加精明一些:作者只对 BBL 入口进行插桩,从而把分析例程的执行开销从指令量级降低到 BBL 量级。这也是官网文档提到的:插桩例程只会执行一次,但是分析例程会执行 N 次,因此要想办法降低分析例程的开销。
const UINT32 MAX_INDEX = 64;
typedef UINT64 COUNTER;
/* zero initialized */
typedef struct
{
COUNTER unpredicated[MAX_INDEX];
COUNTER predicated[MAX_INDEX];
COUNTER predicated_true[MAX_INDEX];
} STATS;
STATS GlobalStatsStatic;
STATS GlobalStatsDynamic;
class BBLSTATS
{
public:
BBLSTATS(UINT16* stats) : _stats(stats), _counter(0) {};
const UINT16* _stats;
COUNTER _counter;
};
list< const BBLSTATS* > statsList;
VOID Trace(TRACE trace, VOID* v)
{
// ...
for (BBL bbl = TRACE_BblHead(trace); BBL_Valid(bbl); bbl = BBL_Next(bbl))
{
// Summarize the stats for the bbl in a 0 terminated list
// This is done at instrumentation time
// 注意是 instrumentation time 而不是 analysis time
UINT16* stats = new UINT16[BBL_NumIns(bbl) + 1];
INT32 index = 0;
// 将这个 BBL 的批量(指令)静态结果记录到 stats 中
for (INS ins = BBL_InsHead(bbl); INS_Valid(ins); ins = INS_Next(ins))
{
// ...
stats[index++] = INS_GetStatsIndex(ins);
}
stats[index] = 0;
// Insert instrumentation to count the number of times the bbl is executed
BBLSTATS* bblstats = new BBLSTATS(stats);
INS_InsertCall(BBL_InsHead(bbl), IPOINT_BEFORE, AFUNPTR(docount), IARG_PTR, &(bblstats->_counter), IARG_END);
// Remember the counter and stats so we can compute a summary at the end
statsList.push_back(bblstats);
}
}
// 用于 Fini 的辅助函数
VOID ComputeGlobalStats()
{
// We have the count for each bbl and its stats, compute the summary
for (list< const BBLSTATS* >::iterator bi = statsList.begin(); bi != statsList.end(); bi++)
{
for (const UINT16* stats = (*bi)->_stats; *stats; stats++)
{
GlobalStatsDynamic.unpredicated[*stats] += (*bi)->_counter;
}
}
}
VOID Fini(int, VOID* v)
{
// 静态统计
DumpStats(*out, GlobalStatsStatic, "static counts");
*out << endl;
ComputeGlobalStats();
// 动态统计
DumpStats(*out, GlobalStatsDynamic, "dynamic counts");
*out << "# eof" << endl;
out->close();
}
具体来说就是,先是静态统计了每个 BBL 的指令信息 bblstats
,其中使用两个字段:
- 一个数组,长度为
BBL_NumIns(bbl) + 1
,记录每条指令应归属的 (un)predicated[] 下标。 - 一个计数器,就是 BBL 触发的次数。
控制流全部执行完了再走 Fini
插桩例程,聚合计算 category 类别的次数,注意这里是无分支地计算了 predicated[] 和 unpredicated[],因为它们布局是连续的。
我个人有另外一个实现设想,这里可利用 BBL 的性质:只要控制流能进入 BBL 的入口,它的性质保证了内部所有的指令均会被执行。可以尝试将 bblstats
的描述改为包含一个计数器和一个长度固定的 stat[MAX_CATEGORY]
数组,静态遍历块内指令时更新 stat[INS_Category(ins)]++
。也就是说不仅可以让分析例程降低到 BBL 粒度,还能在聚合计算时改进为 unpredicated[i] += bblstats.stat[i] * bblstats.counter。其实复杂度和原有实现是相同的,但是访存会更有局部性,也易于 SoA 改造。
有问题吗?
其实心里是有点质疑这个工具的质量,我看一眼示例就认为有两个问题:
- 为什么会有 AVX512 类别?我的主机根本就不支持该指令啊,哪里长出来的?
- 为什么没有 UNCOND_BR 类别?这是 jmp 指令的抽象,怎么连这都抓不到?
# jojo.s
.section .text
.global _start
_start:
mov $1234567, %rcx
jump_loop:
dec %rcx
jnz jump_loop
mov $60, %rax # syscall number for exit
xor %rdi, %rdi # exit status 0
syscall
# 记住这个名字,后面还会用
# gcc jojo.s -nostdlib -static -o jojo
# pin -t catmix.so -- ./jojo
#
# dynamic counts
#
#num category count-unpredicated count-predicated
#
16 BINARY 1234567 0
30 COND_BR 1234567 0
33 DATAXFER 2 0
54 LOGICAL 1 0
# eof
问题一我先是认为可能是 C库/ld.so 引入的乱七八糟的指令,至少写了一个足够简单的汇编,加载后确实没有胡来的指令;但这能证明一个常规的用了 C 库的二进制文件的统计是正确的吗?继续问题二的讨论,你会心中有数。
题外话,看到 catmix.cpp 的 -no_shared_libs 选项吗?使用它会直接 core dumped。
# 仍然是 pin -t obj-intel64/catmix.so -- /bin/ls && cat catmix.out
#
# dynamic counts
#
#num category count-unpredicated count-predicated
#
6 AVX 2903 0
7 AVX2 8637 0
16 BINARY 140626 0
17 BITBYTE 1361 0
19 BMI1 2433 0
20 BMI2 318 0
21 BROADCAST 455 0
22 CALL 9643 0
28 CMOV 0 2126
30 COND_BR 126124 0
32 CONVERT 74 0
33 DATAXFER 235297 0
54 LOGICAL 104085 0
57 MISC 27162 0
62 NOP 544 0
67 POP 24322 0
71 PUSH 29364 0
77 RET 9638 0
78 ROTATE 31 0
81 SEMAPHORE 91 0
83 SETCC 7083 0
87 SHIFT 17703 0
89 SSE 6498 0
90 STRINGOP 0 2175
92 SYSCALL 156 0
94 SYSTEM 8 0
98 UNCOND_BR 14076 0 # 问题二解决了😋
108 WIDENOP 17658 0
112 XSAVE 1 0
# eof
问题二的根因出现在源码上。
// catmix.cpp 关键一行
const UINT32 MAX_INDEX = 64;
这个 catmix.cpp 太老旧了,硬编码导致跟不上时代。其实简单地把 MAX_INDEX
数值改为 XED_CATEGORY_LAST
就能覆盖所有的类别。
其实问题二也在影响问题一。如果你拿原 catmix 去观测 jojo
二进制,你会发现还多了一次 CMOV 的统计,其实这是静态分析越界到动态分析了。
所以还有没有问题三?这我就不清楚了。
结尾
不知道为啥,这个工具存在很多年了,我在网上没找到太多信息,可能是人不在安全圈子的原因吧。
总之这只是一次 Intel Pin 的折腾笔记,我目前使用 pintool 统计了一些程序的运行时信息,至于如何分析,这又是另一个难题了。
闭源软件我是真的没什么好总结的,官网文档写得不错,就这样吧。