在 GDB 中跨过线程局部变量时,使用 -fPIC 编译的程序崩溃
Program compiled with -fPIC crashes while stepping over thread-local variable in GDB
这是一个非常奇怪的问题,只有当程序使用-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
= 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
,Red Hat Enterprise 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
问题深藏在 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 个字节留下 space 用于 backend/assembler/linker 优化。实际上,您的编译器会为 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 , %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 , (%rax)
.loc 1 16 0
...
然后,汇编器 GAS 将包含函数调用 (!) 的代码放宽到只有两条指令。它们是:
- a
mov
具有 fs:
段覆盖,并且
- 一个
lea
,在最后的组装中。它们之间总共占用 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 [=12=]x10,%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 [=12=]x1,(%rax)
到目前为止,一切都已正确完成。现在问题开始了,因为 GAS 会为您的特定汇编代码生成 DWARF 调试信息。
在binutils-x.y.z/gas/read.c
中逐行解析时,函数void
read_a_source_file (char *name)
,GAS遇到.loc 1 15 0
,下一行开始的语句,运行 是 dwarf2dbg.c
中的处理程序 void dwarf2_directive_loc (int dummy ATTRIBUTE_UNUSED)
。不幸的是,处理程序不会无条件地为它当前正在构建的机器代码的 "fragment" (frag_now
) 中的当前偏移量发出调试信息。它可以通过调用 dwarf2_emit_insn(0)
来完成此操作,但 .loc
处理程序目前仅在连续看到多个 .loc
指令时才会这样做。相反,在我们的例子中,它会继续到下一行,而不会发出调试信息。
在下一行,它看到通用动态序列的 .byte 0x66
指令。尽管在 x86 汇编中表示 data16
指令前缀,但这本身并不是指令的一部分。 GAS 使用处理程序 cons_worker()
对其进行操作,片段大小从 12 字节增加到 13。
在下一行它看到一个真实的指令,leaq
,它通过调用映射到 gas/config/tc-i386.c
中的 void md_assemble (char *line)
的宏 assemble_one()
来解析=].在该函数的最后,调用 output_insn()
,它本身最终调用 dwarf2_emit_insn(0)
并导致最后发出调试信息。开始了一个新的行号语句 (LNS),它声称第 15 行开始于函数起始地址加上之前的片段大小,但是由于我们在这样做之前跳过了 .byte
语句,片段太大了 1 个字节,第 15 行第一条指令的计算偏移量因此偏移 1 个字节。
一段时间后,GAS 将全局动态序列放宽为以 mov fs:0x0, %rax
开头的最终指令序列。代码大小和所有偏移量保持不变,因为两个指令序列都是 16 字节。调试信息没变,还是报错
GDB 在读取行号语句时被告知 threadMain()
的序言与第 14 行相关联,在第 14 行上找到了它的签名,在第 15 行开始的地方结束。 GDB尽职尽责地在那个位置设置了一个断点,但不幸的是它太远了1个字节。
当运行没有断点时,程序运行正常运行,看到
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
。然后,正常的单步执行序列将涉及恢复指令的第一个字节,将程序计数器 eip
设置为该断点的地址,单步执行,重新插入断点,然后继续执行程序。
但是,当 GDB 将断点设置在错误地址 1 字节太远时,程序会看到
64 cc fs:int3
8b 04 25 00 00 00 00 <garbage>
这是一个奇怪但仍然有效的断点。这就是为什么您没有看到 SIGILL(非法指令)的原因。
现在,当 GDB 试图越过时,它恢复指令字节,将 PC 设置为断点的地址,这就是它现在看到的:
64 fs: # CPU DOESN'T SEE THIS!
48 8b 04 25 00 00 00 00 mov (0x0),%rax # <- CPU EXECUTES STARTING HERE!
# BOOM! SEGFAULT!
因为GDB重新开始执行一个字节太远,CPU不解码fs:
指令前缀字节,而是用默认段执行mov (0x0),%rax
,即ds:
(数据)。这会立即导致从地址 0(空指针)读取。 SIGSEGV 立即跟进。
所有归功于 基本上解决了这个问题。
保留的解决方案是对 cc1
、gcc
的实际 C 编译器进行二进制补丁,以发出 data16
而不是 .byte 0x66
。这导致 GAS 将前缀和指令组合作为一个单元进行解析,从而在调试信息中产生正确的偏移量。
这是一个非常奇怪的问题,只有当程序使用-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
= 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
,Red Hat Enterprise 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
问题深藏在 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 个字节留下 space 用于 backend/assembler/linker 优化。实际上,您的编译器会为 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 , %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 , (%rax)
.loc 1 16 0
...
然后,汇编器 GAS 将包含函数调用 (!) 的代码放宽到只有两条指令。它们是:
- a
mov
具有fs:
段覆盖,并且 - 一个
lea
,在最后的组装中。它们之间总共占用 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 [=12=]x10,%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 [=12=]x1,(%rax)
到目前为止,一切都已正确完成。现在问题开始了,因为 GAS 会为您的特定汇编代码生成 DWARF 调试信息。
在
binutils-x.y.z/gas/read.c
中逐行解析时,函数void read_a_source_file (char *name)
,GAS遇到.loc 1 15 0
,下一行开始的语句,运行 是dwarf2dbg.c
中的处理程序void dwarf2_directive_loc (int dummy ATTRIBUTE_UNUSED)
。不幸的是,处理程序不会无条件地为它当前正在构建的机器代码的 "fragment" (frag_now
) 中的当前偏移量发出调试信息。它可以通过调用dwarf2_emit_insn(0)
来完成此操作,但.loc
处理程序目前仅在连续看到多个.loc
指令时才会这样做。相反,在我们的例子中,它会继续到下一行,而不会发出调试信息。在下一行,它看到通用动态序列的
.byte 0x66
指令。尽管在 x86 汇编中表示data16
指令前缀,但这本身并不是指令的一部分。 GAS 使用处理程序cons_worker()
对其进行操作,片段大小从 12 字节增加到 13。在下一行它看到一个真实的指令,
leaq
,它通过调用映射到gas/config/tc-i386.c
中的void md_assemble (char *line)
的宏assemble_one()
来解析=].在该函数的最后,调用output_insn()
,它本身最终调用dwarf2_emit_insn(0)
并导致最后发出调试信息。开始了一个新的行号语句 (LNS),它声称第 15 行开始于函数起始地址加上之前的片段大小,但是由于我们在这样做之前跳过了.byte
语句,片段太大了 1 个字节,第 15 行第一条指令的计算偏移量因此偏移 1 个字节。一段时间后,GAS 将全局动态序列放宽为以
mov fs:0x0, %rax
开头的最终指令序列。代码大小和所有偏移量保持不变,因为两个指令序列都是 16 字节。调试信息没变,还是报错
GDB 在读取行号语句时被告知 threadMain()
的序言与第 14 行相关联,在第 14 行上找到了它的签名,在第 15 行开始的地方结束。 GDB尽职尽责地在那个位置设置了一个断点,但不幸的是它太远了1个字节。
当运行没有断点时,程序运行正常运行,看到
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
。然后,正常的单步执行序列将涉及恢复指令的第一个字节,将程序计数器 eip
设置为该断点的地址,单步执行,重新插入断点,然后继续执行程序。
但是,当 GDB 将断点设置在错误地址 1 字节太远时,程序会看到
64 cc fs:int3
8b 04 25 00 00 00 00 <garbage>
这是一个奇怪但仍然有效的断点。这就是为什么您没有看到 SIGILL(非法指令)的原因。
现在,当 GDB 试图越过时,它恢复指令字节,将 PC 设置为断点的地址,这就是它现在看到的:
64 fs: # CPU DOESN'T SEE THIS!
48 8b 04 25 00 00 00 00 mov (0x0),%rax # <- CPU EXECUTES STARTING HERE!
# BOOM! SEGFAULT!
因为GDB重新开始执行一个字节太远,CPU不解码fs:
指令前缀字节,而是用默认段执行mov (0x0),%rax
,即ds:
(数据)。这会立即导致从地址 0(空指针)读取。 SIGSEGV 立即跟进。
所有归功于
保留的解决方案是对 cc1
、gcc
的实际 C 编译器进行二进制补丁,以发出 data16
而不是 .byte 0x66
。这导致 GAS 将前缀和指令组合作为一个单元进行解析,从而在调试信息中产生正确的偏移量。