使用-fPIC编译的程序在GDB中逐步执行线程局部变量时崩溃

时间:2015-10-30 06:24:22

标签: c linux gcc gdb pthreads

这是一个非常奇怪的问题,只有在使用-fPIC选项编译程序时才会出现。

使用gdb我能够打印线程局部变量但是踩到它们会导致崩溃。

thread.c

#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>

#define MAX_NUMBER_OF_THREADS 2

struct mystruct {
    int   x;
    int   y;
};

__thread struct mystruct obj;

void* threadMain(void *args) {
    obj.x = 1;
    obj.y = 2;

    printf("obj.x = %d\n", obj.x);
    printf("obj.y = %d\n", obj.y);

    return NULL;
}

int main(int argc, char *arg[]) {
    pthread_t tid[MAX_NUMBER_OF_THREADS];
    int i = 0;

    for(i = 0; i < MAX_NUMBER_OF_THREADS; i++) {
        pthread_create(&tid[i], NULL, threadMain, NULL);
    }

    for(i = 0; i < MAX_NUMBER_OF_THREADS; i++) {
        pthread_join(tid[i], NULL);
    }

    return 0;
}

使用以下代码编译它:gcc -g -lpthread thread.c -o thread -fPIC

然后在调试时:gdb ./thread

(gdb) b threadMain 
Breakpoint 1 at 0x4006a5: file thread.c, line 15.
(gdb) r
Starting program: /junk/test/thread 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
[New Thread 0x7ffff7fc7700 (LWP 31297)]
[Switching to Thread 0x7ffff7fc7700 (LWP 31297)]

Breakpoint 1, threadMain (args=0x0) at thread.c:15
15      obj.x = 1;
(gdb) p obj.x
$1 = 0
(gdb) n

Program received signal SIGSEGV, Segmentation fault.
threadMain (args=0x0) at thread.c:15
15      obj.x = 1;

虽然如果我在没有-fPIC的情况下编译它,那么这个问题就不会发生。

在任何人问我为什么使用-fPIC之前,这只是一个简化的测试用例。我们有一个巨大的组件,它编译成一个so文件,然后插入另一个组件。因此,fPIC是必要的。

因为没有功能影响,只有调试几乎不可能。

平台信息:Linux 2.6.32-431.el6.x86_64 #1 SMP Sun Nov 10 22:19:54 EST 2013 x86_64 x86_64 x86_64 GNU/Linux,红帽企业Linux服务器版本6.5(圣地亚哥)

以下内容也可重现

Linux 3.13.0-66-generic #108-Ubuntu SMP Wed Oct 7 15:20:27 
GNU gdb (Ubuntu 7.7.1-0ubuntu5~14.04.2) 7.7.1
gcc (Ubuntu 4.8.4-2ubuntu1~14.04) 4.8.4

1 个答案:

答案 0 :(得分:4)

问题在于GAS,GNU汇编程序以及它如何生成DWARF调试信息。

编译器GCC负责为位置无关的线程本地访问生成特定的指令序列,文档ELF Handling for Thread-Local Storage,第22页,第4.1.6节: x86-64通用动态TLS模型。这个顺序是:

0x00 .byte 0x66
0x01 leaq  x@tlsgd(%rip),%rdi
0x08 .word 0x6666
0x0a rex64
0x0b call __tls_get_addr@plt

,并且是它的方式,因为它占用的16个字节为后端/汇编器/链接器优化留出空间。实际上,您的编译器会为threadMain()生成以下汇编程序:

threadMain:
.LFB2:
        .file 1 "thread.c"
        .loc 1 14 0
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        subq    $16, %rsp
        movq    %rdi, -8(%rbp)
        .loc 1 15 0
        .byte   0x66
        leaq    obj@tlsgd(%rip), %rdi
        .value  0x6666
        rex64
        call    __tls_get_addr@PLT
        movl    $1, (%rax)
        .loc 1 16 0
        ...

汇编程序GAS然后放松这个代码,它包含一个函数调用(!),只有两个指令。这些是:

  1. mov fs: - 段覆盖,
  2. a lea
  3. ,在最后的集会中。它们总共占用16个字节,这说明了为什么通用动态模型指令序列被设计为需要16个字节。

    (gdb) disas/r threadMain                                                                                                                                                                                         
    Dump of assembler code for function threadMain:                                                                                                                                                                  
       0x00000000004007f0 <+0>:     55      push   %rbp                                                                                                                                                              
       0x00000000004007f1 <+1>:     48 89 e5        mov    %rsp,%rbp                                                                                                                                                 
       0x00000000004007f4 <+4>:     48 83 ec 10     sub    $0x10,%rsp                                                                                                                                                
       0x00000000004007f8 <+8>:     48 89 7d f8     mov    %rdi,-0x8(%rbp)                                                                                                                                           
       0x00000000004007fc <+12>:    64 48 8b 04 25 00 00 00 00      mov    %fs:0x0,%rax
       0x0000000000400805 <+21>:    48 8d 80 f8 ff ff ff    lea    -0x8(%rax),%rax
       0x000000000040080c <+28>:    c7 00 01 00 00 00       movl   $0x1,(%rax)
    

    到目前为止,一切都已正确完成。现在问题开始了,因为GAS为您的特定汇编程序代码生成DWARF调试信息。

    1. binutils-x.y.z/gas/read.c,函数void read_a_source_file (char *name)中逐行解析时,GAS遇到.loc 1 15 0,开始下一行的语句,并运行处理程序{{1在void dwarf2_directive_loc (int dummy ATTRIBUTE_UNUSED)中。不幸的是,处理程序不会无条件地为当前正在构建的机器代码的“片段”(dwarf2dbg.c)内的当前偏移量发出调试信息。它可以通过调用frag_now来完成此操作,但dwarf2_emit_insn(0)处理程序当前只有在连续看到多个.loc指令时才这样做。相反,在我们的情况下,它继续到下一行,使调试信息不​​再发送。

    2. 在下一行,它会看到General Dynamic序列的.loc指令。尽管在x86程序集中表示.byte 0x66指令前缀,但这本身并不是指令的一部分。 GAS使用处理程序data16对其进行操作,并且片段的大小从12个字节增加到13个。

    3. 在下一行,它会看到一条真正的指令cons_worker(),通过调用映射到leaq中的assemble_one()的宏void md_assemble (char *line)来解析该指令。在该函数的最后,调用gas/config/tc-i386.c,它本身最终调用output_insn()并导致最后发出调试信息。一个新的行号声明(LNS)开始声称第15行开始于function-start-address加上之前的片段大小,但由于我们在执行此操作之前通过了dwarf2_emit_insn(0)语句,因此片段大于1字节因此,第15行第一条指令的计算偏移量为1字节。

    4. 一段时间后,GAS将全局动态序列放宽到以.byte开头的最终指令序列。代码大小和所有偏移保持不变,因为两个指令序列都是16个字节。调试信息没有改变,但仍然是错误的。

    5. GDB在读取行号声明时,被告知mov fs:0x0, %rax的序言与第14行开始的第14行相关联。 GDB在该位置尽职尽责地设置了一个断点,但不幸的是它太远了1个字节。

      当没有断点运行时,程序正常运行,并看到

      threadMain()

      。正确放置断点将涉及用64 48 8b 04 25 00 00 00 00 mov %fs:0x0,%rax (操作码int3)保存和替换指令的第一个字节,留下

      0xcc

      。然后,正常的步进序列将涉及恢复指令的第一个字节,将程序计数器cc int3 48 8b 04 25 00 00 00 00 mov (0x0),%rax 设置为该断点的地址,单步执行,重新插入断点,然后继续执行程序。

      但是,当GDB将断点设置在错误的地址1字节太远时,程序会看到

      eip

      这是一个奇怪但仍然有效的断点。这就是为什么你没有看到SIGILL(非法指令)。

      现在,当GDB尝试跳过时,它会恢复指令字节,将PC设置为断点的地址,这就是它现在所看到的:

      64 cc                           fs:int3
      8b 04 25 00 00 00 00            <garbage>
      

      因为GDB重新执行了一个字节太远的执行,所以CPU不解码64 fs: # CPU DOESN'T SEE THIS! 48 8b 04 25 00 00 00 00 mov (0x0),%rax # <- CPU EXECUTES STARTING HERE! # BOOM! SEGFAULT! 指令前缀字节,而是使用默认段fs:执行mov (0x0),%rax(数据) 。这立即导致从地址0读取空指针。 SIGSEGV紧随其后。

      Mark Plotnick给予{{3}}所有应得的信用,实质上就是这个。

      保留的解决方案是二进制补丁ds:cc1的实际C编译器,以发出gcc而不是data16。这导致GAS将前缀和指令组合解析为单个单元,从而在调试信息中产生正确的偏移量。