用户空间的SystemTap探测是怎么工作的

这篇文章介绍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命令行选项调整,比如有一个复杂的探测逻辑并且不在乎消耗。在任何事件中,我认为这个开销对跟踪的事件来说都不是那么重要,因为只会在调试一个程序的时候才会跟踪。不会占据主要时间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值