问题在于 GAS、GNU 汇编器的内部,以及它如何生成 DWARF 调试信息。
编译器 GCC 负责为位置无关的线程本地访问生成特定的指令序列,这在文档中进行了记录线程本地存储的 ELF 处理 http://www.akkadia.org/drepper/tls.pdf,第 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 将这段包含函数调用 (!) 的代码放宽为只有两条指令。这些都是:
- a
mov
有一个fs:
-段覆盖,以及
- a
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 $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 调试信息时,问题就开始了。
逐行解析时binutils-x.y.z/gas/read.c
, 功能void
read_a_source_file (char *name)
, 气体遭遇.loc 1 15 0
,开始下一行并运行处理程序的语句void dwarf2_directive_loc (int dummy ATTRIBUTE_UNUSED)
in dwarf2dbg.c
。不幸的是,处理程序不会无条件地发出“片段”内当前偏移量的调试信息(frag_now
)当前正在构建的机器代码。它可以通过调用来完成此操作dwarf2_emit_insn(0)
,但是.loc
处理程序当前仅在看到多个时才会这样做.loc
连续指示。相反,在我们的例子中,它继续到下一行,而未发送调试信息。
在下一行中,它看到.byte 0x66
通用动态序列的指令。尽管代表了指令,但它本身并不是指令的一部分data16
x86 汇编中的指令前缀。 GAS 与 handler 一起作用于它cons_worker()
,片段大小从 12 字节增加到 13 字节。
在下一行它看到一个真正的指令,leaq
,通过调用宏来解析assemble_one()
映射到void md_assemble (char *line)
in gas/config/tc-i386.c
。在该函数的最后,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 紧随其后。
所有应得的学分马克·普洛特尼克 https://stackoverflow.com/questions/33429912/program-compiled-with-fpic-crashes-while-stepping-over-thread-local-variable-in/33557963#comment54798247_33429912基本上解决了这个问题。
保留的解决方案是二进制补丁cc1
, gcc
的实际 C 编译器,发出data16
代替.byte 0x66
。这导致 GAS 将前缀和指令组合解析为单个单元,从而在调试信息中产生正确的偏移量。