跳到内容

Linux 应用程序调试技术/插桩库

来自维基教科书,开放的书籍,开放的世界

动态链接器允许拦截应用程序对任何共享库的任何函数调用。因此,插桩是一种强大的技术,可以调整性能,收集运行时统计信息或调试应用程序,而无需对该应用程序的代码进行插桩。

例如,我们可以使用插桩库来跟踪调用,包括参数值和返回值。

调用追踪

[编辑 | 编辑源代码]

请注意,以下部分代码是 32 位 x86 和 gcc 4.1/4.2 特定的。

代码插桩
[编辑 | 编辑源代码]

在库中,我们希望解决以下问题

  • 当函数/方法被调用和退出时。
  • 当函数被调用时,调用参数是什么。
  • 当函数退出时,返回值是什么。
  • 可选地,函数从哪里被调用。

第一个很容易:如果需要,编译器将插桩函数和方法,以便当函数/方法被调用时,会调用一个插桩函数,而当函数退出时,会调用一个类似的插桩函数

    void __cyg_profile_func_enter(void *func, void *callsite); 
    void __cyg_profile_func_exit(void *func, void *callsite);

这是通过使用以下标志编译代码来实现的-finstrument-functions上述两个函数可以用于收集覆盖率数据;或用于分析。我们将使用它们来打印函数调用的跟踪。此外,我们可以将这两个函数和支持代码隔离在自己的插桩库中。这个库可以在需要的时候加载,从而基本上不改变应用程序代码。

现在,当函数被调用时,我们可以获得调用的参数

    void __cyg_profile_func_enter( void *func, void *callsite )
    {
        char buf_func[CTRACE_BUF_LEN+1] = {0};
        char buf_file[CTRACE_BUF_LEN+1] = {0}; 
        char buf_args[ARG_BUF_LEN + 1] = {0}; 
        pthread_t self = (pthread_t)0;
        int *frame = NULL;
        int nargs = 0;
    
        self = pthread_self(); 
        frame = (int *)__builtin_frame_address(1); /*of the 'func'*/

        /*Which function*/
        libtrace_resolve (func, buf_func, CTRACE_BUF_LEN, NULL, 0);
        
        /*From where.  KO with optimizations. */
        libtrace_resolve (callsite, NULL, 0, buf_file, CTRACE_BUF_LEN);
        
        nargs = nchr(buf_func, ',') + 1; /*Last arg has no comma after*/ 
        nargs += is_cpp(buf_func);       /*'this'*/
        if (nargs > MAX_ARG_SHOW)
            nargs = MAX_ARG_SHOW;

        printf("T%p: %p %s %s [from %s]\n", 
               self, (int*)func, buf_func, 
               args(buf_args, ARG_BUF_LEN, nargs, frame), 
               buf_file);
    }

当函数退出时,我们得到返回值

    void __cyg_profile_func_exit( void *func, void *callsite )
    {
        long ret = 0L; 
        char buf_func[CTRACE_BUF_LEN+1] = {0};
        char buf_file[CTRACE_BUF_LEN+1] = {0}; 
        pthread_t self = (pthread_t)0;

        GET_EBX(ret); 
        self = pthread_self(); 

        /*Which function*/
        libtrace_resolve (func, buf_func, CTRACE_BUF_LEN, NULL, 0);
        
        printf("T%p: %p %s => %d\n", 
               self, (int*)func, buf_func, 
               ret);

        SET_EBX(ret); 
    }


由于这两个插桩函数都了解地址,而我们实际上希望跟踪对人类来说可读,因此我们还需要一种方法将符号地址解析为符号名称:这就是 libtrace_resolve() 所做的。

Binutils 和 libbfd
[编辑 | 编辑源代码]

首先,我们必须准备好符号信息。为了实现这一点,我们使用 -g 标志编译应用程序。然后,我们可以将地址映射到符号名称,这通常需要编写一些了解 ELF 格式的代码。

幸运的是,有一个 binutils 包,它包含一个执行此操作的库:libbfd;以及一个工具:addr2line。addr2line 是一个关于如何使用 libbfd 的很好的例子,我仅仅用它来包装 libbfd。结果就是 libtrace_resolve() 函数。

由于插桩函数被隔离在独立的模块中,因此我们通过环境变量(CTRACE_PROGRAM)告诉该模块插桩可执行文件的名称,我们在运行程序之前设置它。这对于正确初始化 libbfd 以搜索符号是必需的。

栈布局
[编辑 | 编辑源代码]

要解决第一个问题,这项工作是与体系结构无关的(实际上 libbfd 了解体系结构,但这些东西隐藏在它的 API 后面)。但是,要检索函数参数和返回值,我们必须查看堆栈,编写一些特定于体系结构的代码并利用一些 gcc 技巧。同样,我使用的编译器是 gcc 4.1 和 4.2;以后或以前的版本可能会有所不同。简而言之

  • x86 规定堆栈向下增长。
  • GCC 规定如何使用堆栈 - 下面描述了一个“典型”的堆栈。
  • 每个函数都有一个由 ebp(基指针)和 esp(堆栈指针)寄存器标记的堆栈帧。
  • 通常,我们希望 eax 寄存器包含返回值
                                \
               +------------+   |
               | arg 2      |   \
               +------------+    >- previous function's stack frame
               | arg 1      |   /
               +------------+   |
               | ret %eip   |   /
               +============+   
               | saved %ebp |   \
        %ebp-> +------------+   |
               |            |   |
               |   local    |   \
               | variables, |    >- current function's stack frame
               |    etc.    |   /
               |            |   |
               |            |   |
        %esp-> +------------+   /

在一个理想的世界里,编译器生成的代码将确保在插桩函数退出时:设置返回值,然后将 CPU 寄存器压入堆栈(以确保插桩函数不会影响它们),然后调用插桩函数,然后弹出寄存器。此代码序列将确保我们始终能够访问

return value in the instrumentation function. The code generated by the compiler is a bit different...

此外,在实践中,许多 gcc 的标志会影响堆栈布局和寄存器使用。最明显的标志是

  • -fomit-frame-pointer。此标志会影响找到参数的堆栈偏移。
  • 优化标志:-Ox;这些标志中的每一个都聚合了许多优化。这些标志不会影响堆栈,而且令人惊讶的是,无论优化级别如何,参数始终通过堆栈传递给函数。人们会期望一些参数通过寄存器传递 - 在这种情况下,获取这些参数将被证明是困难甚至不可能的。但是,这些标志确实使恢复返回值变得复杂。但是,在某些体系结构上,这些标志会吸取 -fomit-frame-pointer 优化。
  • 无论如何,请注意:您用来编译应用程序的其他标志可能会带来惊喜。
函数参数
[编辑 | 编辑源代码]

在我的编译器测试中,所有参数总是通过堆栈传递。因此,这是微不足道的事情,在一定程度上受 -fomit-frame-pointer 标志的影响 - 此标志会改变参数开始的偏移量。

函数有多少个参数,堆栈上有多少个参数?推断参数数量的一种方法是基于它的签名(对于 C++,注意隐藏的“this”参数),这是 __cyg_profile_func_enter() 中使用的技术。

一旦我们知道了参数在堆栈上的起始偏移量以及它们的数量,我们只需遍历堆栈即可检索它们的值

    char *args(char *buf, int len, int nargs, int *frame)
    {
        int i; 
        int offset;

        memset(buf, 0, len); 

        snprintf(buf, len, "("); 
        offset = 1; 
        for (i=0; i < nargs && offset < len; i++) {
            offset += snprintf(buf+offset, len-offset, "%d%s", 
                             *(frame+ARG_OFFET+i), 
                             i==nargs-1 ? " ...)" : ", ");
        }

        return buf; 
    }
函数返回值
[编辑 | 编辑源代码]

只有在使用 -O0 标志时,才能获得返回值。

让我们看看当这个方法

    class B {
        ...
        virtual int m1(int i, int j) {printf("B::m1()\n"); f1(i); return 20;}
        ...
    };

使用 -O0 插桩时

     080496a2 <_ZN1B2m1Eii>:
     80496a2:    55                       push   %ebp
     80496a3:    89 e5                    mov    %esp,%ebp
     80496a5:    53                       push   %ebx
     80496a6:    83 ec 24                 sub    $0x24,%esp
     80496a9:    8b 45 04                 mov    0x4(%ebp),%eax
     80496ac:    89 44 24 04              mov    %eax,0x4(%esp)
     80496b0:    c7 04 24 a2 96 04 08     movl   $0x80496a2,(%esp)
     80496b7:    e8 b0 f4 ff ff           call   8048b6c <__cyg_profile_func_enter@plt>
     80496bc:    c7 04 24 35 9c 04 08     movl   $0x8049c35,(%esp)
     80496c3:    e8 b4 f4 ff ff           call   8048b7c <puts@plt>
     80496c8:    8b 45 0c                 mov    0xc(%ebp),%eax
     80496cb:    89 04 24                 mov    %eax,(%esp)
     80496ce:    e8 9d f8 ff ff           call   8048f70 <_Z2f1i>

==>  80496d3:    bb 14 00 00 00           mov    $0x14,%ebx
     80496d8:    8b 45 04                 mov    0x4(%ebp),%eax
     80496db:    89 44 24 04              mov    %eax,0x4(%esp)
     80496df:    c7 04 24 a2 96 04 08     movl   $0x80496a2,(%esp)
==>  80496e6:    e8 81 f5 ff ff           call   8048c6c <__cyg_profile_func_exit@plt>
     80496eb:    89 5d f8                 mov    %ebx,0xfffffff8(%ebp)
==>  80496ee:    eb 27                    jmp    8049717 <_ZN1B2m1Eii+0x75>
     80496f0:    89 45 f4                 mov    %eax,0xfffffff4(%ebp)
     80496f3:    8b 5d f4                 mov    0xfffffff4(%ebp),%ebx
     80496f6:    8b 45 04                 mov    0x4(%ebp),%eax
     80496f9:    89 44 24 04              mov    %eax,0x4(%esp)
     80496fd:    c7 04 24 a2 96 04 08     movl   $0x80496a2,(%esp)
     8049704:    e8 63 f5 ff ff           call   8048c6c <__cyg_profile_func_exit@plt>
     8049709:    89 5d f4                 mov    %ebx,0xfffffff4(%ebp)
     804970c:    8b 45 f4                 mov    0xfffffff4(%ebp),%eax
     804970f:    89 04 24                 mov    %eax,(%esp)
     8049712:    e8 15 f5 ff ff           call   8048c2c <_Unwind_Resume@plt>

==>  8049717:    8b 45 f8                 mov    0xfffffff8(%ebp),%eax
     804971a:    83 c4 24                 add    $0x24,%esp
     804971d:    5b                       pop    %ebx
     804971e:    5d                       pop    %ebp
     804971f:    c3                       ret

请注意,返回值是如何被移动到 ebx 寄存器中的 - 这有点出乎意料,因为传统上,eax 寄存器用于返回值 - 然后调用插桩函数。这对于检索返回值很好,但为了避免 ebx 寄存器在插桩函数中被覆盖,我们在进入函数时保存它,并在退出时恢复它。

当编译使用一定程度的优化(-O1...3;这里显示的是 -O2)时,代码会发生变化

    080498c0 <_ZN1B2m1Eii>:
     80498c0:    55                       push   %ebp
     80498c1:    89 e5                    mov    %esp,%ebp
     80498c3:    53                       push   %ebx
     80498c4:    83 ec 14                 sub    $0x14,%esp
     80498c7:    8b 45 04                 mov    0x4(%ebp),%eax
     80498ca:    c7 04 24 c0 98 04 08     movl   $0x80498c0,(%esp)
     80498d1:    89 44 24 04              mov    %eax,0x4(%esp)
     80498d5:    e8 12 f4 ff ff           call   8048cec <__cyg_profile_func_enter@plt>
     80498da:    c7 04 24 2d 9c 04 08     movl   $0x8049c2d,(%esp)
     80498e1:    e8 16 f4 ff ff           call   8048cfc <puts@plt>

     80498e6:    8b 45 0c                 mov    0xc(%ebp),%eax
     80498e9:    89 04 24                 mov    %eax,(%esp)
     80498ec:    e8 af f7 ff ff           call   80490a0 <_Z2f1i>
     80498f1:    8b 45 04                 mov    0x4(%ebp),%eax
     80498f4:    c7 04 24 c0 98 04 08     movl   $0x80498c0,(%esp)
     80498fb:    89 44 24 04              mov    %eax,0x4(%esp)
==>  80498ff:    e8 88 f3 ff ff           call   8048c8c <__cyg_profile_func_exit@plt>
     8049904:    83 c4 14                 add    $0x14,%esp
==>  8049907:    b8 14 00 00 00           mov    $0x14,%eax
     804990c:    5b                       pop    %ebx
     804990d:    5d                       pop    %ebp
==>  804990e:    c3                       ret    

     804990f:    89 c3                    mov    %eax,%ebx
     8049911:    8b 45 04                 mov    0x4(%ebp),%eax
     8049914:    c7 04 24 c0 98 04 08     movl   $0x80498c0,(%esp)
     804991b:    89 44 24 04              mov    %eax,0x4(%esp)
     804991f:    e8 68 f3 ff ff           call   8048c8c <__cyg_profile_func_exit@plt>
     8049924:    89 1c 24                 mov    %ebx,(%esp)
     8049927:    e8 f0 f3 ff ff           call   8048d1c <_Unwind_Resume@plt>
     804992c:    90                       nop    
     804992d:    90                       nop    
     804992e:    90                       nop    
     804992f:    90                       nop

请注意,插桩函数是如何先被调用,然后 eax 寄存器才被返回值设置的。因此,如果我们绝对需要返回值,我们必须使用 -O0 编译。

示例输出
[编辑 | 编辑源代码]

最后,以下是结果。在 shell 提示符处键入

$ export CTRACE_PROGRAM=./cpptraced
$ LD_PRELOAD=./libctrace.so ./cpptraced
T0xb7c0f6c0: 0x8048d34 main (0 ...) [from ]
./cpptraced: main(argc=1)
T0xb7c0ebb0: 0x80492d8 thread1(void*) (1 ...) [from ]
T0xb7c0ebb0: 0x80498b2 D (134605416 ...) [from cpptraced.cpp:91]
T0xb7c0ebb0: 0x8049630 B (134605416 ...) [from cpptraced.cpp:66]
B::B()
T0xb7c0ebb0: 0x8049630 B => -1209622540 [from ]
D::D(int=-1210829552)
T0xb7c0ebb0: 0x80498b2 D => -1209622540 [from ]
Hello World! It's me, thread #1!
./cpptraced: done.
T0xb7c0f6c0: 0x8048d34 main => -1212090144 [from ]
T0xb740dbb0: 0x8049000 thread2(void*) (2 ...) [from ]
T0xb740dbb0: 0x80498b2 D (134605432 ...) [from cpptraced.cpp:137]
T0xb740dbb0: 0x8049630 B (134605432 ...) [from cpptraced.cpp:66]
B::B()
T0xb740dbb0: 0x8049630 B => -1209622540 [from ]
D::D(int=-1210829568)
T0xb740dbb0: 0x80498b2 D => -1209622540 [from ]
Hello World! It's me, thread #2!
T#2!
T0xb6c0cbb0: 0x8049166 thread3(void*) (3 ...) [from ]
T0xb6c0cbb0: 0x80498b2 D (134613288 ...) [from cpptraced.cpp:157]
T0xb6c0cbb0: 0x8049630 B (134613288 ...) [from cpptraced.cpp:66]
B::B()
T0xb6c0cbb0: 0x8049630 B => -1209622540 [from ]
D::D(int=0)
T0xb6c0cbb0: 0x80498b2 D => -1209622540 [from ]
Hello World! It's me, thread #3!
T#1!
T0xb7c0ebb0: 0x80490dc wrap_strerror_r (134525680 ...) [from cpptraced.cpp:105]
T0xb7c0ebb0: 0x80490dc wrap_strerror_r => -1210887643 [from ]
T#1+M2 (Success)
T0xb740dbb0: 0x80495a0 D::m1(int, int) (134605432, 3, 4 ...) [from cpptraced.cpp:141]
D::m1()
T0xb740dbb0: 0x8049522 B::m2(int) (134605432, 14 ...) [from cpptraced.cpp:69]
B::m2()
T0xb740dbb0: 0x8048f70 f1 (14 ...) [from cpptraced.cpp:55]
f1 14
T0xb740dbb0: 0x8048ee0 f2(int) (74 ...) [from cpptraced.cpp:44]
f2 74
T0xb740dbb0: 0x8048e5e f3 (144 ...) [from cpptraced.cpp:36]
f3 144
T0xb740dbb0: 0x8048e5e f3 => 80 [from ]
T0xb740dbb0: 0x8048ee0 f2(int) => 70 [from ]
T0xb740dbb0: 0x8048f70 f1 => 60 [from ]
T0xb740dbb0: 0x8049522 B::m2(int) => 21 [from ]
T0xb740dbb0: 0x80495a0 D::m1(int, int) => 30 [from ]
T#2!
T#3!

请注意,当函数被内联时,libbfd 如何无法解析某些地址。

华夏公益教科书