这篇文章介绍SystemTap在用户层的实现原理。对文档进行了部分摘抄翻译,原文易懂,建议阅读原文:How SystemTap Userspace Probes Work。另外介绍另一篇文档:动态追踪技术漫谈
在给Bitcoin增加SystemTap时,对它的实现感到很疑惑,还有这会增加多少开销。 实际上,我想知道的是:
- 使用SystemTap的可执行程序会增加什么指令?
- 使用stap跟踪进程时实际上会发生什么?
- 不跟踪程序时开销有多少?
- 在跟踪程序时开销有多少?
SystemTap的wiki上有一些解释 userspace probe implementation, 但是不够详细。
增加了SystemTap探点的代码
这个例子中会看一下Bitcoin的函数IsInitialBlockDownload()
。源码修改后是这样的:
bool IsInitialBlockDownload()
{
// Once this function has returned false, it must remain false.
static std::atomic<bool> latchToFalse{false};
// Optimization: pre-test latch before taking the lock.
if (latchToFalse.load(std::memory_order_relaxed))
return false;
LOCK(cs_main);
if (latchToFalse.load(std::memory_order_relaxed))
return false;
if (fImporting || fReindex)
return true;
if (chainActive.Tip() == nullptr)
return true;
if (chainActive.Tip()->nChainWork < nMinimumChainWork)
return true;
if (chainActive.Tip()->GetBlockTime() < (GetTime() - nMaxTipAge))
return true;
LogPrintf("Leaving InitialBlockDownload (latching to false)\n");
latchToFalse.store(true, std::memory_order_relaxed);
if (PROBE_FINISH_IBD_ENABLED()) { // 关注这两句
PROBE_FINISH_IBD();
}
return false;
}
跟探测相关的是PROBE_FINISH_IBD_ENABLED()
和PROBE_FINISH_IBD()
。这些宏定义是作为构建程序的一部分由SystemTap自动生成的。
生成的x86汇编
下面是GDB disas反汇编IsInitialBlockDownload()
生成的代码,附带源码:
$ gdb ./src/bitcoind
(gdb) disas /s IsInitialBlockDownload
1170 if (PROBE_FINISH_IBD_ENABLED()) {
0x00000000001a2954 <+420>: cmpw $0x0,0x4f6724(%rip) # 0x699080 <bitcoin_finish_ibd_semaphore>
0x00000000001a295c <+428>: je 0x1a2831 <IsInitialBlockDownload()+129>
1171 PROBE_FINISH_IBD();
0x00000000001a2962 <+434>: nop
0x00000000001a2963 <+435>: jmpq 0x1a2831 <IsInitialBlockDownload()+129>
GDB disas默认生成的是AT&T语法。x86指令可以有变长操作数长度,AT&T语法在指令中用长度后缀注释。在这个例子中,cmpw中的w后缀表示”word”(16位)操作数。jmpq表示跳转指令有一个”quad”(64位)操作数。”if”语句编译成cmp指令,检查内存地址0x699080上的数值是否等于0。这里的注释说实际上在ELF文件中0x699080是bitcoin_finish_ibd_semaphore
。如果这个内存地址的值是0,je指令直接跳出”if”语句。否则指令会走到nop指令,nop是一个字节的指令,什么都不做。在nop指令之后是一个无条件跳转的jmp。注意两个跳转(je和jmpq)都跳转到了相同的地址0x1a2831。这个地址是return false语句。GCC通常会生成与源码顺序不一致的x86代码,这是为了优化,所以不能简单的自动向下流转到return语句。
这个代码看起来有点小怪,因为这个控制流程是这样的:
- 检查这个值:
bitcoin_finish_ibd_semaphore
- 如果不是0,执行一个no-op指令,然后跳转到return false语句。
- 如果是0,立即跳转到return false语句。
为什么要特定条件下执行no-op指令,并且实际上什么都不会做?如果对类似于GDB的调试器工作原理比较熟悉,就可能已经猜到了。
观察SystemTap跟踪时的内存
首先看一下运行时的程序disas的输出。我启动了一个bitcoind进程,进程号是4077。当执行disas时,看起来有点不一样:
$ gdb -p 4077
(gdb) disas /s IsInitialBlockDownload
1170 if (PROBE_FINISH_IBD_ENABLED()) {
0x000056352db8d954 <+420>: cmpw $0x0,0x4f6724(%rip) # 0x56352e084080 <bitcoin_finish_ibd_semaphore>
0x000056352db8d95c <+428>: je 0x56352db8d831 <IsInitialBlockDownload()+129>
1171 PROBE_FINISH_IBD();
0x000056352db8d962 <+434>: nop
0x000056352db8d963 <+435>: jmpq 0x56352db8d831 <IsInitialBlockDownload()+129>
这些指令看起来跟之前一样,除了内存地址。内存地址因为ASLR的原因改变了,但是整个流程还是不变。还是看bitcoin_finish_ibd_semaphore
:
(gdb) x/hx &bitcoin_finish_ibd_semaphore
0x56352e084080 <bitcoin_finish_ibd_semaphore>: 0x0000
这里显示是0x0000,这样我们就知道cmpw语句总会跳过nop。
关联SystemTap脚本
我现在准备关联一个SystemTap脚本,探测前面的内容。这是脚本内容:
# This probe is run when stap initially attaches.
probe begin {
println("attached to bitcoind process...")
}
# This probe is run when our IBD probe is triggered.
probe process("./src/bitcoind").mark("finish_ibd") {
println("IBD finished")
}
begin可以告诉我们SystemTap什么时候初始化结束,这个时候就真正的attach了。用这个脚本执行:
# Run our stap script and attach to process 4077.
$ stap -x 4077 demo.stp
attached to bitcoind process...
再次在同一个地址执行disas:
(gdb) disas /s IsInitialBlockDownload
1170 if (PROBE_FINISH_IBD_ENABLED()) {
0x000056352db8d954 <+420>: cmpw $0x0,0x4f6724(%rip) # 0x56352e084080 <bitcoin_finish_ibd_semaphore>
0x000056352db8d95c <+428>: je 0x56352db8d831 <IsInitialBlockDownload()+129>
1171 PROBE_FINISH_IBD();
0x000056352db8d962 <+434>: int3
0x000056352db8d963 <+435>: jmpq 0x56352db8d831 <IsInitialBlockDownload()+129>
事情变得不一样了。nop指令变成了int3。这个就是”trap”指令。工作原理是:当进程执行这个指令时,会在内核产生一个中断。内核可以使用任何手段处理它。正常情况下会产生一个SIGTRAP信号给进程。GDB这样的调试器就使用这样的机制实现断点。SystemTap有些不一样,稍后解释。bitcoin_finish_ibd_semaphore
发生了什么?
(gdb) x/hx &bitcoin_finish_ibd_semaphore
0x56352e084080 <bitcoin_finish_ibd_semaphore>: 0x0001
变成了0x0001,之前是0x0000。这就意味着当程序跑到cmpw指令时,就会执行”if”语句,然后在内核中产生中断。
SystemTap中断怎么工作的
执行一个SystemTap脚本时,会发生这些事情:
- stap命令将.stp代码转换成Linux内核模块C代码。
- C代码编译成一个本地内核模块(.ko文件)。
- 内核模块注册感兴趣的trap事件,探测要求的探测点,在内核上下文中作为本地x86代码执行。
这种实现让SystemTap非常快。但是因为脚本运行在内核态,所以可能会导致内核的不稳定,因此需要执行stap的用户具有权限。
其它的探测点会发生什么
我执行了一些其它的测试,发现了更多的工作原理。当SystemTap运行一个脚本时,它会观察你想要探测的那个点,只有你想要监控的探测点会改变。比如,我在另外一个函数CCoinsViewCache::FetchCoin
中增加了一个探测点。即使IsInitialBlockDownload()
已经被SystemTap修改,这个函数还是没有变化:
(gdb) disas /s CCoinsViewCache::FetchCoin
49 if (PROBE_CACHE_MISS_ENABLED() && m_enable_probing) {
0x000056352dc2bea8 <+328>: cmpb $0x0,0x80(%rbx)
0x000056352dc2beaf <+335>: je 0x56352dc2bdd6 <CCoinsViewCache::FetchCoin(COutPoint const&) const+118>
49 if (PROBE_CACHE_MISS_ENABLED() && m_enable_probing) {
0x000056352dc2bdc8 <+104>: cmpw $0x0,0x4582ae(%rip) # 0x56352e08407e <bitcoin_cache_miss_semaphore>
0x000056352dc2bdd0 <+112>: jne 0x56352dc2bea8 <CCoinsViewCache::FetchCoin(COutPoint const&) const+328>
50 PROBE_CACHE_MISS();
0x000056352dc2beb5 <+341>: nop
0x000056352dc2beb6 <+342>: jmpq 0x56352dc2bdd6 <CCoinsViewCache::FetchCoin(COutPoint const&) const+118>
这个代码看起来有点不一样,因为还要检查一个成员变量,不过基本流程还是一样的,而且”if”语句都是一样的。正如你所看到的,这里还是有一个nop指令,并没有变化。跟预期相符,bitcoin_cache_miss_semaphore
的值是0:
(gdb) x/hx &bitcoin_cache_miss_semaphore
0x56352e08407e <bitcoin_cache_miss_semaphore>: 0x0000
SystemTap detach的时候发生什么?
如果用原先的stap从进程上detach下来,IsInitialBlockDownload
函数就会恢复到原来的样子,bitcoin_finish_ibd_semaphore
这个值又变成了0.当semaphore值变成0时,对应的int3指令也会变回nop。
这个检查会增加多少开销?
当考虑到SystemTap探测的开销时,需要考虑两种场景:没有被跟踪的进程和已经跟踪的进程。
当进程没有被跟踪时,每个探测增加的开销就是一个cmp指令和一个jmp指令。因为这两个指令总是同时出现,现代处理器把它们融合在了一起。在一个现代处理器上(比如Intel Skylake),这个组合会消耗0.5到2指令周期。总结:没有跟踪的进程开销几乎是0。
当进程被跟踪时,每个探测点增加的开销是一个cmp指令和上下文切换。具体的上下文开销取决于内核版本和CPU。根据我发现的最好的资源(从2010年开始)显示,开销在2到50微秒之间,取决于CPU类型和其它的一些细节。探测点的代码可能会很复杂,SystemTap语言允许执行循环,调用其它函数,hash map等等),所以这个开销可能会很大。SystemTap考虑到了这点,会监测探测会执行多少时间。如果执行太长时间,stap脚本会自动终止。这个可以通过stap命令行选项调整,比如有一个复杂的探测逻辑并且不在乎消耗。在任何事件中,我认为这个开销对跟踪的事件来说都不是那么重要,因为只会在调试一个程序的时候才会跟踪。不会占据主要时间。