链接器的世界/ 原理篇/ 17 篇
109 分钟阅读公开阅读

[链接器的世界-原理篇09] TLS:同一个变量,每个线程各有一份

本章要回答的是:同一个变量名,怎样在两个线程中得到两块不同的存储?理解主线只需掌握原理篇 02的节、符号和重定位,以及原理篇 06的文件映像与零填充。这里的线程指针是定位当前线程数据的基准,不是普通全局变量的地址;线程块是模板的一份实例,不是文件中的另一个节。

先沿着“文件里的 TLS 模板 → 每个线程的副本 → 一条访问指令”读到静态布局和 LE/IE 模型。动态加载会使模块数量和位置变化,DTV、GD/LD 与 TLSDESC 随后解释这种变化怎样影响定位;初读不需要先背四套指令序列。每次遇到偏移,先确认它相对于模板、模块还是线程指针。

一个进程可以有多个线程:它们在同一片进程地址空间里执行,各自保留当前执行位置、寄存器和调用栈。普通全局变量因而会被这些线程共同访问。但有些状态必须各存各的。上一章的异常处理就是一例:线程 A 正在处理的异常,不能被线程 B 当成自己的异常重抛。

把变量放进函数的局部栈帧,只能让它跟随那次函数调用;异常运行时却需要在同一线程的许多函数之间找到同一份状态。线程局部存储(Thread-Local Storage,TLS1)提供了这样的对象:源码里只有一个变量名,每个线程访问的却是自己的实例,实例随线程存续。C11 用 _Thread_local 声明它,C++11 用 thread_local,GCC2 更早提供的扩展写法是 __thread。

先观察这个区别。下面的 counter 初值是 42。主线程把它加到 43 后创建工作线程,工作线程把自己那份加 10,最后主线程再次读取。代码通过 POSIX 线程接口操作线程:pthread_create 启动一个执行 worker 的线程,pthread_join 等待它结束。等待完成后才打印最后一行,因此这里的输出次序是确定的。

#include <pthread.h>
#include <stdio.h>
static _Thread_local int counter = 42;
static void *worker(void *unused) {
(void)unused;
printf("worker starts: %d\n", counter);
counter += 10;
printf("worker changes: %d\n", counter);
return NULL;
}
int main(void) {
pthread_t thread;
++counter;
printf("main before: %d\n", counter);
if (pthread_create(&thread, NULL, worker, NULL) != 0) return 1;
if (pthread_join(thread, NULL) != 0) return 1;
printf("main after: %d\n", counter);
return 0;
}

在 Linux 中,把源码保存为 thread_identity.c 后运行。-std=c11 选择 C11 语言规则,-pthread 要求工具链启用构建 POSIX 线程程序所需的选项和库支持。

$ cc -std=c11 -O1 -pthread thread_identity.c -o thread_identity
$ ./thread_identity
main before: 43
worker starts: 42
worker changes: 52
main after: 43

新线程从 42 开始,并没有继承创建它时主线程里的 43;它写入 52,也没有改变主线程的值。同一处变量访问因此不能只对应一个进程内固定地址。运行时既要从某处取得每份实例的初值,也要让访问代码找到当前线程的实例。

下面用 ELF3 的初始化模板与 x86-64 的访问指令展开这条地址计算路径。AArch64 的对照用于说明另一种线程指针布局;两种布局都要把模块内位置转换为当前线程中的实际地址。

先看没有编译器帮忙的做法

在编译器支持这种关键字之前,POSIX 线程接口(pthreads)就提供了线程私有数据:先用 pthread_key_create 申请一个"键",然后每个线程用 pthread_setspecific(key, p) 存一个 void *,用 pthread_getspecific(key) 取回自己存的那个。

这套接口让库在运行时申请线程私有数据,不要求提前声明语言层面的 TLS 变量;代价是键和对象的生命周期需要显式管理。Ulrich Drepper 在后来成为事实标准的文档《ELF Handling For Thread-Local Storage》开头列了它的毛病:键要在运行时动态申请,不用了还要释放,既费事又容易出错;和动态加载的代码搭在一起时,问题就更大了(Drepper, tls.pdf, §1)。这里的动态加载指第 7 章讲过的 dlopen,即程序运行到中途再按需装入一个共享库。设想一个被 dlopen 进来的插件想要一个线程局部变量:谁来申请键,什么时候申请,插件卸载时谁来释放,已经在跑的线程怎么拿到它的那一份?每次访问还都是一次函数调用加一次查表。

语言层面的 TLS 让编译器知道哪些变量需要每个线程一份,链接器记录它们的存储需求,运行时负责建立实例。程序不再为每个变量显式申请键,但这些工作并没有消失。

沿着刚才的运行结果,可以逐步追踪这些工作:每个线程那一份的初始内容从哪里来;运行时怎么找到"当前线程的那一份";机器码里该填什么,才能在每个线程里都算出正确的地址。

模板:.tdata、.tbss 与 PT_TLS

先写一个小文件 tls.c,后面的访问模型都用它:

_Thread_local int counter = 42; /* 有初值 -> .tdata */
_Thread_local int scratch[256]; /* 无初值 -> .tbss */
extern _Thread_local int ext_var; /* 别的模块定义的 TLS */
int bump(void) { return ++counter; }
int *scratch_at(int i) { return &scratch[i]; }
int read_ext(void) { return ext_var; }

另有一个只有一行的 ext.c:_Thread_local int ext_var = 7;。先编译 tls.c,看节头和符号表:

$ clang -O1 -fPIC -c tls.c -o pic.o
$ readelf -SW pic.o | grep -E 'Nr|tdata|tbss'
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 4] .tdata PROGBITS 0000000000000000 000098 000004 00 WAT 0 0 4
[ 5] .tbss NOBITS 0000000000000000 0000a0 000400 00 WAT 0 0 16
$ readelf -sW pic.o | grep TLS
4: 0000000000000000 4 TLS GLOBAL DEFAULT 4 counter
7: 0000000000000000 1024 TLS GLOBAL DEFAULT 5 scratch
9: 0000000000000000 0 TLS GLOBAL DEFAULT UND ext_var

两个新的节。.tdata 对应 .data:放有初值的线程局部变量,这里是 4 字节的 counter,文件里存着它的初值 42。.tbss 对应 .bss:放初值为零的线程局部变量,这里是 1024 字节的 scratch,类型是 NOBITS,在文件里不占空间,只记一个大小。

区别在标志列:除了 W(SHF_WRITE)和 A(SHF_ALLOC,运行时要装入内存),还多了一个 T,即 SHF_TLS(0x400)。符号的类型也不是普通变量的 OBJECT,而是 TLS(STT_TLS)。连只声明为 extern、还没有定义的 ext_var 也是 STT_TLS,链接器据此知道对它的引用要按 TLS 的规则处理。

还有一个细节:counter 和 scratch 的值都是 0。普通符号的值是"在所在节里的偏移",链接后变成虚拟地址;TLS 符号的值最终表示"在本模块 TLS 块里的偏移",拿它没法直接访问内存。原因后面就会清楚。

这里的“模块”指最终加载的一个可执行文件或共享库,不是每个输入 .o 文件。tls.o 和 ext.o 链接进同一个可执行文件后,它们的 TLS 数据属于同一个模块、共用一份模板;如果把 ext.o 链接成单独的共享库,这个库就有自己的 TLS 模板。

链接时,链接器把输入文件的已初始化 TLS 节合并、零初始化 TLS 节合并,并让它们紧挨着:.tdata 在前,.tbss 在后。判定依据是 SHF_TLS 和节类型,节名不必恰好等于 .tdata 或 .tbss。然后为这一段生成一个专门的程序头 PT_TLS。第 5 章讲过,PT_LOAD 告诉加载器"把文件这一段映射到内存这个位置";PT_TLS 的含义不同,它描述的是一份模板:p_offset/p_vaddr 指向初始化映像(也就是 .tdata 的内容),p_filesz 是映像长度,p_memsz 是每个线程那一份的总大小,p_align 是这一份要求的对齐。

把 tls.c 和 ext.c 一起链接成 PIE4:

$ ld.lld -pie start.o pic.o ext.o -o gd2le
$ readelf -lW gd2le | grep -E 'Type|LOAD|TLS|DYNAMIC|RELRO'
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0002b4 0x0002b4 R 0x1000
LOAD 0x0002c0 0x00000000000012c0 0x00000000000012c0 0x000068 0x000068 R E 0x1000
LOAD 0x000330 0x0000000000002330 0x0000000000002330 0x000098 0x000cd0 RW 0x1000
TLS 0x000330 0x0000000000002330 0x0000000000002330 0x000008 0x000410 R 0x10
DYNAMIC 0x000338 0x0000000000002338 0x0000000000002338 0x000090 0x000090 RW 0x8
GNU_RELRO 0x000330 0x0000000000002330 0x0000000000002330 0x000098 0x000cd0 R 0x1
$ readelf -sW gd2le | grep TLS
6: 0000000000000000 4 TLS GLOBAL DEFAULT 7 counter
9: 0000000000000010 1024 TLS GLOBAL DEFAULT 8 scratch
11: 0000000000000004 4 TLS GLOBAL DEFAULT 7 ext_var

FileSiz 是 8:两个输入的 .tdata 合并后,counter 在偏移 0,ext_var 在偏移 4。MemSiz 0x410 由三部分组成:8 字节的 .tdata,补到 .tbss 要求的 16 字节对齐所留的 8 字节空隙,再加 0x400 字节的 .tbss,所以 scratch 的块内偏移是 0x10。注意链接后符号表里的 TLS 符号值仍然是块内偏移,而不是虚拟地址。

.tdata 同时被那个可写的 PT_LOAD 覆盖,初始化映像会随普通数据一起映射进内存;它还落在 PT_GNU_RELRO(第 7 章讲的"填完就锁上"的区域)里,因为程序从不直接写这份模板。每创建一个线程,运行时按 p_memsz 分配一块内存,把 p_filesz 字节的映像复制进去,其余清零。所有线程用的是同一份初始化映像,新线程不会继承父线程当时的值(Drepper §3.1)。

.tbss 在布局上还有个怪癖,看节头就能发现:

$ readelf -SW gd2le | grep -E 'tdata|tbss|dynamic'
[ 7] .tdata PROGBITS 0000000000002330 000330 000008 00 WAT 0 0 4
[ 8] .tbss NOBITS 0000000000002340 000338 000400 00 WAT 0 0 16
[ 9] .dynamic DYNAMIC 0000000000002338 000338 000090 10 WA 4 0 8

.tbss 的地址区间是 0x2340 到 0x2740,紧随其后的 .dynamic 却从 0x2338 开始,两者重叠。.bss 虽然不占文件空间,但要占进程的地址空间,后面的节只能排在它之后;.tbss 在进程地址空间里根本没有"自己的那份",链接器给它的地址只是用来算偏移的记账值,后面的普通节可以照常排在 .tdata 之后。这是布局阶段对 TLS 的特殊处理之一。

第一个问题有了答案:每个线程那一份的初始内容来自 PT_TLS 描述的模板。这一份在每个线程里放在哪里,又回到"当前是哪个线程"。

线程指针:每个线程手里的一把钥匙

CPU 执行指令时并不知道"线程"这个概念,它只认寄存器和内存。要让同一条指令在不同线程里访问不同地址,最直接的办法是给每个线程一个寄存器,里面放一个只属于它的指针,操作系统切换线程时连同这个寄存器一起切换。这个指针叫线程指针(thread pointer,TP)。

AArch64 为此准备了一个系统寄存器 TPIDR_EL0。系统寄存器是存放处理器状态和配置的专用寄存器,不能像通用寄存器那样直接参与运算,要用 mrs(读到通用寄存器)和 msr(从通用寄存器写入)两条指令访问;TPIDR_EL0 在用户态可读,mrs x8, TPIDR_EL0 就把线程指针读进 x8(AArch64 System V ABI)。

x86-64 沿用了 i386 的做法,借用段寄存器。i386 上 Drepper 的理由是这个架构寄存器太少(§3.4.2),于是把线程指针间接地放在 %gs 的段基址里;x86-64 换成了 %fs(§3.4.6)。64 位模式下段寄存器的分段功能基本废弃,但 %fs 和 %gs 还保留着段基址:一条带 %fs: 前缀的访存指令,实际访问的是"%fs 基址 + 指令算出的地址"。Linux 上,主线程的 %fs 基址由 C 库通过 arch_prctl(ARCH_SET_FS, ...) 系统调用设置,新线程则在 clone 时带上 CLONE_SETTLS 标志、由内核一并设好,之后内核切换线程时保存和恢复它。

基址藏在 CPU 内部,一条普通的 mov 读不出它。GNU 系统上的约定是:线程指针所指的那个字,存的就是线程指针自己。于是 movq %fs:0, %rax 读的是"基址 + 0"处的字,恰好得到线程指针的值。这个约定 Drepper 在 §3.4.2 为 IA-32 的 GNU 变体写下,§3.4.6 说明 x86-64 与 IA-32 的 GNU 变体基本一致,§4.3.6 的代码序列直接用到了它。

线程指针指向的那块内存叫 TCB(Thread Control Block,线程控制块),是线程库为每个线程维护的描述结构。各线程那一份 TLS 数据就放在 TCB 旁边,放在哪一边有两种约定。

两种布局:TLS 在线程指针的哪一边

Drepper 文档定义了两种布局(§3,图 1、图 2),各架构二选一。

Variant I 是为 IA-64 新定的,当时没有兼容包袱。TCB 开头保存一张按模块查找 TLS 块的表的指针,这张表叫 DTV,下一节展开它的查找过程。可执行文件的 TLS 块排在 TCB 之后,后面依次是启动时就加载的各共享库的块,变量地址是线程指针加一个正偏移。AArch64 用的就是 Variant I:线程指针指向一个固定 16 字节的 TCB,前 8 字节是 DTV 指针,后 8 字节留给实现,可执行文件的 TLS 块从线程指针之后 16 字节(再按 p_align 补齐)开始(AArch64 System V ABI)。并非所有 Variant I 架构都让线程指针恰好指向 TCB:RISC-V5 的线程指针指向 TCB 之后、TLS 块的起点,PowerPC 则在此基础上再往后偏 0x7000 字节,好让有符号 16 位位移能覆盖更大的范围。

Variant II 用于 i386、x86-64、s390 等。线程指针同样指向 TCB,但 TLS 块放在 TCB 下方:可执行文件的块紧贴线程指针之下,启动时加载的共享库依次往更低的地址排,变量地址是线程指针减一个偏移。Drepper 在脚注里给的理由是兼容:这些架构上线程指针所指内存的布局由来已久,和 Variant I 不兼容。

先看本例使用的简单对齐情形:TLS 模板起点满足 p_align,没有需要保留的非零起始对齐余数。Variant II 下第 m 个模块的块离线程指针多远,可以按下面的公式计算(Drepper §3.4.2 与 §3.4.6)。一般 ELF 文件还可能需要考虑模板起点的对齐余数,不能仅凭这两行实现所有输入的布局。

tlsoffset_1 = round(tlssize_1, align_1)
tlsoffset_m+1 = round(tlsoffset_m + tlssize_m+1, align_m+1)

round(x, y) 把 x 向上取整到 y 的倍数,这里沿用 Drepper 的编号,把带有 TLS 的主可执行文件记为模块 1;公式描述启动时的静态 TLS 布局。代入上面的 gd2le:p_memsz 0x410,p_align 0x10,所以 tlsoffset_1 = 0x410,块占据 [TP − 0x410, TP)。三个变量相对线程指针的偏移是块内偏移减 0x410:counter 是 −0x410,ext_var 是 −0x40c,scratch 是 −0x400。

TLS 初始化映像、线程副本与 TP 相对偏移

图中的三行使用不同的坐标。第一行标的是文件偏移:input[0x330..0x338] 只有两个整数的初值,不含 1024 字节的 scratch。下面两行标的是各线程 TLS 块内的偏移:运行时复制这 8 字节,并把剩余 0x408 字节清零。scratch 从块内偏移 0x10 开始,所需的内存由运行时提供,不是从文件中读取出来的。

同一个 counter 在两个线程中都位于各自块的偏移 0,但两块内存的起点不同。x86-64 的访问代码以当前线程的 TP 为基准,所以地址写成 TP − 0x410;这个负数是从 TP 向低地址走的距离,不是负的文件偏移。两个线程执行同样的指令,会因为 TP 不同而读写不同地址。

本例中这些 TP 相对偏移在链接可执行文件时就能算出:块的大小和对齐已知,启动时共享库的 TLS 块排在它的低地址一侧,不会改变这几个变量相对于 TP 的位置。

共享库就没有这么好的运气。一个共享库被哪个程序加载、排在第几个、前面有多大的块,链接它时一概不知;如果是被 dlopen 进来的,更是在进程已经跑了很久、已经有很多线程之后才出现。编译器不能保证运行时已为它保留一个相对线程指针固定的块。某些运行时会预留静态 TLS 余量,稍后会看到这种兼容措施及其容量限制;通用访问路径仍需要在不能依赖这份余量时成立,因此还需要一张表。

DTV 与 __tls_get_addr:给动态加载留的路

这张表叫 DTV(Dynamic Thread Vector,动态线程向量)。每个线程有自己的一张 DTV,TCB 里有指针指向它。DTV 按模块编号索引:第 m 项指向"当前线程的模块 m 的 TLS 块"。模块编号由 ld.so 在加载模块时分配,Drepper §3.1 的模型把主可执行文件记为 1;真正的查找应使用加载器给出的编号,不能把所有输入模块的编号写死。

有了 DTV,任何一个 TLS 变量都能用一对数表示:模块编号,加上变量在该模块 TLS 块里的偏移。这就是 STT_TLS 符号的值只是块内偏移的原因。从这对数到地址,由运行时提供的函数 __tls_get_addr 完成。x86-64 上它只接受一个参数,指向这样一个结构(§3.4.6):

typedef struct {
unsigned long int ti_module; /* 模块编号 */
unsigned long int ti_offset; /* 块内偏移 */
} tls_index;
extern void *__tls_get_addr (tls_index *ti);

Drepper §3.1 用下面的伪代码说明按需分配的实现方式。这里改写成单参数形式;thread_id 表示当前线程,dtv 和 allocate_tls 是解释算法的记号,不是可直接链接的 C 库接口:

void *__tls_get_addr (tls_index *ti) {
char *block = dtv[thread_id][ti->ti_module];
if (block == UNALLOCATED_TLS_BLOCK)
block = dtv[thread_id][ti->ti_module] = allocate_tls (ti->ti_module);
return block + ti->ti_offset;
}

中间那个判断就是给 dlopen 留的路。新模块被 dlopen 进来时,ld.so 给它分配编号,但不必立刻为所有已有线程分配 TLS 块,DTV 里对应的项先填一个"未分配"的特殊值;哪个线程第一次访问这个模块的 TLS 变量,__tls_get_addr 就在那时按 PT_TLS 模板为它分配并初始化。DTV 本身也可能需要变长,所以它开头还有一个代数(generation)计数,用来发现"这张表比全局的模块列表旧了,需要扩容"(§3.2)。

按首次访问分配是一种运行时策略,不是 DTV 这个结构强制要求的行为。glibc 使用按需分配路径;musl6 则在 dlopen 和创建线程时预先准备所需的 TLS 与 DTV 存储,使后续 TLS 访问不再因为临时分配失败而出错。模块编号加块内偏移的寻址接口仍然成立,分配发生的时间却不同,不能把两种实现的步骤混成一条通用加载流程。musl 的设计说明专门解释了这个选择。

到这里,地址的算法有了两条路:要么"线程指针加减一个常数",要么"调 __tls_get_addr 查 DTV"。前者要求变量在静态 TLS 区域里取得固定偏移,通常由启动时布局满足;后者通过运行时查找,也允许编译器复用已经取得的地址。在这两者之间怎么选,就是 TLS 访问模型。

同一个变量的四种坐标

分析 TLS 重定位时,先确定一个数属于哪套坐标。ELF 文件的位置、模板内的位置、线程内的位移和实际地址可以同时出现在一份工具输出中,却不能互相替代。以 counter 为例:

数值所属坐标用途
0x330文件偏移找到模板中初值 42 的四个字节
0模块 TLS 块内偏移链接后 STT_TLS 的 st_value;供模块内寻址使用
−0x410TP 相对位移在本例 Variant II 布局中从 TP 找到 counter
TP − 0x410当前线程的实际地址运行时读写变量;随着线程的 TP 改变

块内偏移为 t、块起点到 TP 的距离为 D 时,TP 相对位移是 t − D。LE 把该位移写进指令字段;IE 把该位移放进 GOT 槽,再用当前 TP 访问。两者最终都计算 TP + (t − D)。GOT 槽自己的地址不是变量地址:找到槽位的 RIP 相对位移,与槽位里保存的 TP 相对位移,是两次不同的计算。

例如 counter 的位移是 −0x410。四字节有符号字段的小端编码为 f0 fb ff ff;若用八字节 GOT 槽保存,编码为 f0 fb ff ff ff ff ff ff。读取后仍按有符号位移参与地址计算。不能把这个位模式当作等待加上 ELF 加载基址的普通指针:整个映像的 load bias 描述进程中的映像位置,TP 描述当前线程的 TLS 位置,两者独立。

GD 和 LD 使用的模块块内偏移也不等于 TP 相对位移。它们先取得当前线程中相应模块的块起点,再加 t;模块的动态分配位置无需满足本例 LE 的固定距离 D。这正是不同 TLS 重定位不能仅凭字段宽度相同就交给普通地址补丁处理的原因。

四种访问模型

先从“哪些信息还没确定”理解模型,再看它们的名字。变量在所属模块里的偏移,可以在链接该模块时确定;但哪个模块提供定义,以及模块的块相对 TP 放在哪里,可能要等到加载时才知道。

地址计算方式代码中已经固定的部分运行时还需要取得的部分
LE:TP + 常数可执行文件中变量的 TP 相对偏移当前线程的 TP
IE:TP + GOT 中的偏移存放偏移的 GOT 项位置当前 TP,以及加载器填入的偏移
LD:本模块块起点 + 常数变量在本模块的块内偏移当前线程中本模块块的起点
GD:解析“模块 + 块内偏移”参数对所在的 GOT 项位置定义所属模块、块内偏移,以及当前线程中的块起点

这里的“常数”指链接相应产物时能够确定的值,编译 .o 时仍可能需要重定位。模型允许编译器作出的假设如下(Drepper §4):

  • General Dynamic(GD,通用动态):什么都不假设。代码可能在被 dlopen 的库里,变量可能在任何模块里。使用 __tls_get_addr 取得地址,编译器可复用求地址结果。
  • Local Dynamic(LD,局部动态):代码可能在任何模块,但变量定义在当前模块里(比如 static __thread,或可见性为 hidden 的变量)。调一次 __tls_get_addr 拿到本模块 TLS 块的起点,之后每个变量只是"起点 + 常数偏移"。
  • Initial Exec(IE,初始可执行):要求变量能分配在静态 TLS 区域,通常由启动时加载满足。运行中加载时能否借助预留余量,是运行时的额外限制,IE 代码无法自行补救。变量相对线程指针的偏移由 ld.so 布局时确定,存在 GOT7(第 7 章的全局偏移表)里,用时读出来。
  • Local Exec(LE,局部可执行):代码在可执行文件里,变量也定义在可执行文件里。偏移在链接时就能算出,直接作为立即数写进指令。

GD:两条指令,一次调用

-fPIC 编译出来的 bump:

$ llvm-objdump -d -r pic.o
pic.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <bump>:
0: 50 pushq %rax
1: 66 48 8d 3d 00 00 00 00 leaq (%rip), %rdi # 0x9 <bump+0x9>
0000000000000005: R_X86_64_TLSGD counter-0x4
9: 66 66 48 e8 00 00 00 00 callq 0x11 <bump+0x11>
000000000000000d: R_X86_64_PLT32 __tls_get_addr-0x4
11: 8b 08 movl (%rax), %ecx
13: ff c1 incl %ecx
15: 89 08 movl %ecx, (%rax)
17: 89 c8 movl %ecx, %eax
19: 59 popq %rcx
1a: c3 retq
1b: 0f 1f 44 00 00 nopl (%rax,%rax)
0000000000000020 <scratch_at>:
20: 53 pushq %rbx
21: 89 fb movl %edi, %ebx
23: 66 48 8d 3d 00 00 00 00 leaq (%rip), %rdi # 0x2b <scratch_at+0xb>
0000000000000027: R_X86_64_TLSGD scratch-0x4
2b: 66 66 48 e8 00 00 00 00 callq 0x33 <scratch_at+0x13>
000000000000002f: R_X86_64_PLT32 __tls_get_addr-0x4
33: 48 63 cb movslq %ebx, %rcx
36: 48 8d 04 88 leaq (%rax,%rcx,4), %rax
3a: 5b popq %rbx
3b: c3 retq
3c: 0f 1f 40 00 nopl (%rax)
0000000000000040 <read_ext>:
40: 50 pushq %rax
41: 66 48 8d 3d 00 00 00 00 leaq (%rip), %rdi # 0x49 <read_ext+0x9>
0000000000000045: R_X86_64_TLSGD ext_var-0x4
49: 66 66 48 e8 00 00 00 00 callq 0x51 <read_ext+0x11>
000000000000004d: R_X86_64_PLT32 __tls_get_addr-0x4
51: 8b 00 movl (%rax), %eax
53: 59 popq %rcx
54: c3 retq

写成汇编源码,这两条是 leaq counter@tlsgd(%rip), %rdi 和 call __tls_get_addr@PLT。@tlsgd、@PLT,以及后面会出现的 @gottpoff、@tpoff、@dtpoff、@tlsld,都是汇编器的操作符:写在符号后面,告诉汇编器这里该生成哪种重定位。counter@tlsgd 生成的就是 R_X86_64_TLSGD。

R_X86_64_TLSGD 要求链接器在 GOT 里分配两个连续的 8 字节 GOT 项,组成一个 tls_index,然后把这个结构相对当前 PC 的偏移填进 leaq;-0x4 加数的来历和第 4 章的 PC32 一样。这两个 GOT 项要等 ld.so 来填,所以链接器还要为它们各生成一条动态重定位:R_X86_64_DTPMOD64 填"定义 counter 的模块的编号",R_X86_64_DTPOFF64 填"counter 在该模块 TLS 块里的偏移"(Drepper §4.1.6)。call 时 %rdi 就是那个 tls_index 的地址,返回值 %rax 是当前线程的 counter 的地址,后面是普通的读、加一、写回。

奇怪的是那几个多出来的字节:leaq 前面有个 66,call 前面有 66 66 48(llvm-objdump 的反汇编不显示它们,只能从字节里看)。0x66 是操作数大小前缀,汇编里写作 data16;0x48 是 REX.W 前缀,写作 rex64。Drepper §4.1.6 规定 GD 序列必须带这些前缀,把整组凑成 16 字节(这里是 0x1 到 0x11),并且说明用前缀而不用空操作指令,是因为前缀对执行没有负面影响。凑成 16 字节的用处,到链接器改写时就会看到。

scratch_at 和 read_ext 用的是同样的序列。在 -fPIC 下,counter 和 scratch 虽然就定义在本文件里,但它们是默认可见性的全局符号,第 7 章讲过的符号插入允许别的模块提供同名定义来顶替它们,编译器不能假设引用一定落在本模块,只能用 GD。

LD:多个本地变量共用一次调用

换一个文件 ld.c,变量改成 static:

static _Thread_local int a = 1;
static _Thread_local int b;
int touch(int x) { a += x; b -= x; return a + b; }
$ clang -O1 -fPIC -c ld.c -o ld.o
$ llvm-objdump -d -r ld.o
ld.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <touch>:
0: 53 pushq %rbx
1: 89 fb movl %edi, %ebx
3: 48 8d 3d 00 00 00 00 leaq (%rip), %rdi # 0xa <touch+0xa>
0000000000000006: R_X86_64_TLSLD a-0x4
a: e8 00 00 00 00 callq 0xf <touch+0xf>
000000000000000b: R_X86_64_PLT32 __tls_get_addr-0x4
f: 8b 90 00 00 00 00 movl (%rax), %edx
0000000000000011: R_X86_64_DTPOFF32 a
15: 8d 34 1a leal (%rdx,%rbx), %esi
18: 89 b0 00 00 00 00 movl %esi, (%rax)
000000000000001a: R_X86_64_DTPOFF32 a
1e: 8b 88 00 00 00 00 movl (%rax), %ecx
0000000000000020: R_X86_64_DTPOFF32 b
24: 89 ce movl %ecx, %esi
26: 29 de subl %ebx, %esi
28: 89 b0 00 00 00 00 movl %esi, (%rax)
000000000000002a: R_X86_64_DTPOFF32 b
2e: 01 d1 addl %edx, %ecx
30: 89 c8 movl %ecx, %eax
32: 5b popq %rbx
33: c3 retq

(如果两个变量从来没被写过,clang 会直接把 a + b 折叠成常数 1,一条 TLS 访问都不留,所以这里让函数去改它们。)

R_X86_64_TLSLD(汇编里是 a@tlsld)也要求在 GOT 里放一个 tls_index,但它代表"本模块、偏移 0",即本模块 TLS 块的起点,只需要一条 DTPMOD64,偏移那一格链接器直接写 0。__tls_get_addr 返回块的起点,之后对 a、b 的每次访问都是 (%rax) 加一个 32 位位移,位移由 R_X86_64_DTPOFF32(a@dtpoff)填成变量在块内的偏移,链接时就能确定(Drepper §4.2.6)。这里的 leaq 和 call 前面没有前缀,一共 12 字节,后面同样会用到这个长度。

一个函数里访问多个本地 TLS 变量时,LD 的好处最明显。只访问一个变量时,Drepper 一方面说它在 x86-64 上对 GD 没有优势,一方面又补了一句:每多一个变量只多几条指令,不增加 GOT 项和动态重定位,所以即使只有一个变量,如果能省下运行时处理动态重定位的开销,也可能值得用 LD(§4.2.6)。GD 每个变量要两条动态重定位,LD 整个模块只要一条 DTPMOD64。

IE:从 GOT 读出偏移

不加 -fPIC 再编译一次。这个 clang 对 Linux 目标默认按 PIE 编译(-### 能看到它传给前端的是 -pic-level ... -pic-is-pie),所以下面是"代码会进可执行文件"的情形:

$ clang -O1 -c tls.c -o nopic.o
$ llvm-objdump -d -r nopic.o
nopic.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <bump>:
0: 64 8b 04 25 00 00 00 00 movl %fs:0x0, %eax
0000000000000004: R_X86_64_TPOFF32 counter
8: ff c0 incl %eax
a: 64 89 04 25 00 00 00 00 movl %eax, %fs:0x0
000000000000000e: R_X86_64_TPOFF32 counter
12: c3 retq
13: 66 66 66 66 2e 0f 1f 84 00 00 00 00 00 nopw %cs:(%rax,%rax)
0000000000000020 <scratch_at>:
20: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
29: 48 63 cf movslq %edi, %rcx
2c: 48 8d 04 88 leaq (%rax,%rcx,4), %rax
30: 48 05 00 00 00 00 addq $0x0, %rax
0000000000000032: R_X86_64_TPOFF32 scratch
36: c3 retq
37: 66 0f 1f 84 00 00 00 00 00 nopw (%rax,%rax)
0000000000000040 <read_ext>:
40: 48 8b 05 00 00 00 00 movq (%rip), %rax # 0x47 <read_ext+0x7>
0000000000000043: R_X86_64_GOTTPOFF ext_var-0x4
47: 64 8b 00 movl %fs:(%rax), %eax
4a: c3 retq

ext_var 定义在别的模块里,可能是某个共享库,编译器不知道它离线程指针多远。但这段代码会进可执行文件,可执行文件依赖的库都在启动时加载,所以 ext_var 的块在线程指针下方有一个固定位置。于是用 IE:movq ext_var@gottpoff(%rip), %rax 生成 R_X86_64_GOTTPOFF,要求链接器在 GOT 里分配一项,把这一项相对 PC 的偏移填进 movq;这个 GOT 项带一条动态重定位 R_X86_64_TPOFF64,由 ld.so 在启动时填入"ext_var 相对线程指针的偏移"。运行时 movq 把偏移读进 %rax,第二条 movl %fs:(%rax), %eax 以线程指针为基址访问,第一个字节 64 就是 %fs 段前缀。没有函数调用。

Drepper §4.3.6 给了两种等长的 IE 序列:一种是先 movq %fs:0, %rax 取线程指针,再 addq x@gottpoff(%rip), %rax 加上偏移,得到地址;另一种就是上面这种 movq x@gottpoff(%rip), %rax 加 %fs: 访存,直接取值。

LE:偏移写在指令里

同一个文件里的 bump:

0000000000000000 <bump>:
0: 64 8b 04 25 00 00 00 00 movl %fs:0x0, %eax
0000000000000004: R_X86_64_TPOFF32 counter
8: ff c0 incl %eax
a: 64 89 04 25 00 00 00 00 movl %eax, %fs:0x0
000000000000000e: R_X86_64_TPOFF32 counter
12: c3 retq

counter 定义在本文件,代码又在可执行文件里,它离线程指针的距离是链接时常数,连 GOT 都不需要。64 8b 04 25 是 %fs 前缀、mov 的操作码,加上两个描述寻址方式的字节:04 是 ModRM 字节(x86 指令里紧跟操作码、说明操作数是寄存器还是内存、用哪种寻址方式的那个字节),25 是它要求的附加寻址字节,两者合起来表示"无基址、只有 32 位位移"。位移由 R_X86_64_TPOFF32(汇编里是 counter@tpoff)填成相对线程指针的偏移。把 nopic.o 和 ext.o 链接成 PIE,就能看到填好的值:

$ ld.lld -pie start.o nopic.o ext.o -o ie2le
$ llvm-objdump -d ie2le
ie2le: file format elf64-x86-64
Disassembly of section .text:
00000000000012b0 <_start>:
12b0: c3 retq
12b1: cc int3
12b2: cc int3
12b3: cc int3
12b4: cc int3
12b5: cc int3
12b6: cc int3
12b7: cc int3
12b8: cc int3
12b9: cc int3
12ba: cc int3
12bb: cc int3
12bc: cc int3
12bd: cc int3
12be: cc int3
12bf: cc int3
00000000000012c0 <bump>:
12c0: 64 8b 04 25 f0 fb ff ff movl %fs:-0x410, %eax
12c8: ff c0 incl %eax
12ca: 64 89 04 25 f0 fb ff ff movl %eax, %fs:-0x410
12d2: c3 retq
12d3: 66 66 66 66 2e 0f 1f 84 00 00 00 00 00 nopw %cs:(%rax,%rax)
00000000000012e0 <scratch_at>:
12e0: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
12e9: 48 63 cf movslq %edi, %rcx
12ec: 48 8d 04 88 leaq (%rax,%rcx,4), %rax
12f0: 48 05 00 fc ff ff addq $-0x400, %rax # imm = 0xFC00
12f6: c3 retq
12f7: 66 0f 1f 84 00 00 00 00 00 nopw (%rax,%rax)
0000000000001300 <read_ext>:
1300: 48 c7 c0 f4 fb ff ff movq $-0x40c, %rax # imm = 0xFBF4
1307: 64 8b 00 movl %fs:(%rax), %eax
130a: c3 retq
130b: cc int3

f0 fb ff ff 就是 −0x410,和前面按公式算出的 counter 偏移一致。++counter 成了三条指令,和访问一个普通全局变量几乎一样快。scratch_at 要取地址,所以先 movq %fs:0, %rax 拿到线程指针本身,再 addq $scratch@tpoff, %rax,链接后是 addq $-0x400, %rax。

LE 的前提是"代码在可执行文件里"。如果把这样编译出来的目标文件拿去链接共享库,链接器会直接拒绝。两个链接器的报错如下:

$ ld.lld -shared nopic.o -o x.so
ld.lld: error: relocation R_X86_64_TPOFF32 against counter cannot be used with -shared
>>> defined in nopic.o
>>> referenced by tls.c
>>> nopic.o:(bump)
ld.lld: error: relocation R_X86_64_TPOFF32 against counter cannot be used with -shared
>>> defined in nopic.o
>>> referenced by tls.c
>>> nopic.o:(bump)
ld.lld: error: relocation R_X86_64_TPOFF32 against scratch cannot be used with -shared
>>> defined in nopic.o
>>> referenced by tls.c
>>> nopic.o:(scratch_at)
$ ld.bfd -shared nopic.o -o y.so
/usr/bin/x86_64-linux-gnu-ld.bfd: nopic.o: relocation R_X86_64_TPOFF32 against symbol `counter' can not be used when making a shared object; local-exec is incompatible with -shared
/usr/bin/x86_64-linux-gnu-ld.bfd: failed to set dynamic section sizes: bad value

原因和第 7 章非 PIC8 代码进共享库时的报错相同:TPOFF32 要求链接器把"相对线程指针的偏移"写成立即数,而共享库的块离线程指针多远,链接时无从知道。看到 TPOFF32 出现在这类报错里,就说明某个本该用 -fPIC 编译的目标文件混进了共享库。

对这里展示的指令序列,GD 经过求地址调用;LD 取得模块 TLS 块起点后,可以让后续多个访问共用这个结果;IE 需要读取 GOT 中的偏移;LE 把偏移直接放进指令。实际调用次数还取决于编译器是否复用或外提求地址结果,LE 也仍需要完成普通的地址计算和访存。能在链接时确定的信息越多,运行时需要补做的工作通常越少。

模型代码可以在变量可以在偏移何时确定运行时代价
GD任何模块,含 dlopen 的库任何模块运行时(__tls_get_addr)求地址调用,可被复用
LD任何模块同一模块块起点运行时,块内偏移链接时取得模块基址的调用,可供多个访问共用
IE能满足静态 TLS 约束的模块静态 TLS 区域中的模块ld.so 布局时(GOT)读取 GOT 偏移,再访问对象
LE可执行文件可执行文件链接时(立即数)直接使用已知偏移

编译器怎么选,以及 -ftls-model

编译器默认按它知道的情况选:-fPIC(可能进共享库)下,外部或可被插入的变量用 GD,本模块的用 LD;PIE 或非 PIC(一定进可执行文件)下,外部变量用 IE,本文件定义的用 LE。

程序员也可以用 -ftls-model=global-dynamic|local-dynamic|initial-exec|local-exec 或变量属性 __attribute__((tls_model("initial-exec"))) 指定模型。GCC 文档有一句限定:选择会被优化覆盖,对翻译单元(第 3 章讲过)之外不可见的符号,或者没有给 -fpic 时,编译器可能用更高效的模型(GCC Code Gen Options)。clang 的行为一致。实测在默认 PIE 下指定 -ftls-model=global-dynamic,输出和上面的 nopic.o 一字不差;反过来,在 -fPIC 下指定 -ftls-model=initial-exec,bump 就从 GD 序列变成了:

$ clang -O1 -fPIC -ftls-model=initial-exec -c tls.c -o ie.o
$ llvm-objdump -d -r ie.o
ie.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <bump>:
0: 48 8b 0d 00 00 00 00 movq (%rip), %rcx # 0x7 <bump+0x7>
0000000000000003: R_X86_64_GOTTPOFF counter-0x4
7: 64 8b 01 movl %fs:(%rcx), %eax
a: ff c0 incl %eax
c: 64 89 01 movl %eax, %fs:(%rcx)
f: c3 retq
0000000000000010 <scratch_at>:
10: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
19: 48 03 05 00 00 00 00 addq (%rip), %rax # 0x20 <scratch_at+0x10>
000000000000001c: R_X86_64_GOTTPOFF scratch-0x4
20: 48 63 cf movslq %edi, %rcx
23: 48 8d 04 88 leaq (%rax,%rcx,4), %rax
27: c3 retq
28: 0f 1f 84 00 00 00 00 00 nopl (%rax,%rax)
0000000000000030 <read_ext>:
30: 48 8b 05 00 00 00 00 movq (%rip), %rax # 0x37 <read_ext+0x7>
0000000000000033: R_X86_64_GOTTPOFF ext_var-0x4
37: 64 8b 00 movl %fs:(%rax), %eax
3a: c3 retq

这个选项只能把模型往更快、更受限的方向推,编译器不会因为它而用比自己能证明的更慢的模型。往快的方向推有代价:用 IE 编译的共享库放弃了"可以被 dlopen"这个保证,这一点留到本章最后。

链接器的模型降级

前面陆续出现的 TLS 重定位(定义见 x86-64 psABI 的 TLS 重定位表和 Drepper §4),按链接器怎么对待它们,可以分成两类。

一类是链接器当场就能算掉的。R_X86_64_TPOFF32 在可执行文件里填相对线程指针的偏移,R_X86_64_DTPOFF32 填变量在本模块块内的偏移,都是链接时常数,填完就没有了。R_X86_64_TLSGD、R_X86_64_TLSLD、R_X86_64_GOTTPOFF 填的是 GOT 项相对 PC 的偏移,这个偏移链接器也算得出来;但它们让链接器建出来的 GOT 项,内容往往只有运行时才知道。

另一类由链接器在输出中生成,留给 ld.so 执行。例如输入的 TLSGD 请求一组 GOT 参数,链接器为相应输出位置生成动态重定位,并非把输入的 TLSGD 原样搬过去。其中,R_X86_64_DTPMOD64 填模块编号,R_X86_64_DTPOFF64 填块内偏移,R_X86_64_TPOFF64 填相对线程指针的偏移。模块编号和模块之间的排列只有在进程启动或 dlopen 时才确定,这是第 7 章"有些地址链接时根本不知道"的 TLS 版本。把 pic.o 链接成共享库,看到的就是这一类:

$ ld.lld -shared pic.o -o libtls.so
$ readelf -rW libtls.so
Relocation section '.rela.dyn' at offset 0x3b8 contains 6 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000002658 0000000200000010 R_X86_64_DTPMOD64 0000000000000000 ext_var + 0
0000000000002660 0000000200000011 R_X86_64_DTPOFF64 0000000000000000 ext_var + 0
0000000000002638 0000000400000010 R_X86_64_DTPMOD64 0000000000000000 counter + 0
0000000000002640 0000000400000011 R_X86_64_DTPOFF64 0000000000000000 counter + 0
0000000000002648 0000000600000010 R_X86_64_DTPMOD64 0000000000000010 scratch + 0
0000000000002650 0000000600000011 R_X86_64_DTPOFF64 0000000000000010 scratch + 0
Relocation section '.rela.plt' at offset 0x448 contains 1 entry:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000003680 0000000100000007 R_X86_64_JUMP_SLOT 0000000000000000 __tls_get_addr + 0

三个变量各占两个相邻的 GOT 项(0x2638/0x2640 是 counter 的 tls_index),各带一对 DTPMOD64 + DTPOFF64;__tls_get_addr 本身是一个普通的外部函数,走第 7 章的 PLT9 和 JUMP_SLOT。同样的方式链接 ld.o,.rela.dyn 里只有一条不带符号的 DTPMOD64,这就是 LD 省下的那部分。

共享库里这些重定位只能留着。但编译器选模型时只看得到一个翻译单元:pic.o 的 bump 用 GD,是因为编译器不知道这个 .o 最后会进共享库还是可执行文件。如果它最后被链接进可执行文件(有些项目的静态库统一用 -fPIC 编译),链接器知道的就比编译器多得多:产物是可执行文件;counter 定义在这个可执行文件里,可执行文件总是符号查找的第一站,没人能顶替它;它在 TLS 块里的偏移也已经排好。这时还去调 __tls_get_addr 就纯属浪费。

于是链接器做和第 4 章 GOTPCRELX relaxation 同一类的事:不改变指令总长度,把慢的指令序列原地改写成快的。Drepper 第 5 章的标题是 Linker Optimizations,正文里也称之为 code relaxation,各家链接器的源码多叫 relaxation,中文里也常说"模型降级"。能做的改写构成一个层级(§5 的示意图):GD 可以降到 IE 或 LE,LD 可以降到 LE,IE 可以降到 LE。条件是:

  • 产物是可执行文件(包括 PIE)时,被引用的变量定义在可执行文件本身:GD→LE、LD→LE、IE→LE;定义在启动时加载的共享库里:GD→IE。
  • 产物是共享库时,GD 和 LD 都不能降级,因为这个库可能被 dlopen,它自己的块位置也不固定。

GD → LE

前面用 ld.lld 链接 gd2le 时,pic.o 和定义 ext_var 的 ext.o 都进了可执行文件。bump 那 16 字节的 GD 序列变成了:

$ llvm-objdump -d gd2le
gd2le: file format elf64-x86-64
Disassembly of section .text:
00000000000012c0 <_start>:
12c0: c3 retq
12c1: cc int3
12c2: cc int3
12c3: cc int3
12c4: cc int3
12c5: cc int3
12c6: cc int3
12c7: cc int3
12c8: cc int3
12c9: cc int3
12ca: cc int3
12cb: cc int3
12cc: cc int3
12cd: cc int3
12ce: cc int3
12cf: cc int3
00000000000012d0 <bump>:
12d0: 50 pushq %rax
12d1: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
12da: 48 8d 80 f0 fb ff ff leaq -0x410(%rax), %rax
12e1: 8b 08 movl (%rax), %ecx
12e3: ff c1 incl %ecx
12e5: 89 08 movl %ecx, (%rax)
12e7: 89 c8 movl %ecx, %eax
12e9: 59 popq %rcx
12ea: c3 retq
12eb: 0f 1f 44 00 00 nopl (%rax,%rax)
00000000000012f0 <scratch_at>:
12f0: 53 pushq %rbx
12f1: 89 fb movl %edi, %ebx
12f3: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
12fc: 48 8d 80 00 fc ff ff leaq -0x400(%rax), %rax
1303: 48 63 cb movslq %ebx, %rcx
1306: 48 8d 04 88 leaq (%rax,%rcx,4), %rax
130a: 5b popq %rbx
130b: c3 retq
130c: 0f 1f 40 00 nopl (%rax)
0000000000001310 <read_ext>:
1310: 50 pushq %rax
1311: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
131a: 48 8d 80 f4 fb ff ff leaq -0x40c(%rax), %rax
1321: 8b 00 movl (%rax), %eax
1323: 59 popq %rcx
1324: c3 retq
1325: cc int3
1326: cc int3
1327: cc int3

改写前是 66 48 8d 3d xx xx xx xx 加 66 66 48 e8 xx xx xx xx,改写后是 9 字节的 movq %fs:0, %rax 加 7 字节的 leaq -0x410(%rax), %rax,9 + 7 = 16,一个字节都不多,正是 Drepper §5.5 给出的目标序列。执行完 %rax 里正好是当前线程的 counter 的地址,和原来 call 返回时一样,所以后面的 movl (%rax), %ecx 不用动。scratch_at 和 read_ext 也被改写,立即数分别是 −0x400 和 −0x40c。readelf -r gd2le 输出 There are no relocations in this file.,节头里也没有 .got:tls_index 和那几条动态重定位一并取消了。

这里能看出链接器为什么对这类序列格外挑剔:它要识别的是一整组固定形状的指令,单看一条重定位不够。R_X86_64_TLSGD 后面必须紧跟 call __tls_get_addr,寄存器必须是 %rdi 和 %rax,前缀字节必须恰好在那里。编译器如果在两条指令中间插了别的东西,链接器就无从改写,所以 psABI10 连整段代码序列一起规定。

GD → IE

把 ext.c 单独链接成共享库 libext.so,再让可执行文件依赖它。counter 仍在可执行文件里,走 GD→LE;ext_var 来自共享库,偏移链接时未知,但模块一定在启动时加载,于是 read_ext 降到 IE:

$ ld.lld -shared -soname libext.so ext.o -o libext.so
$ ld.lld -pie start.o pic.o libext.so -o gd2ie
$ llvm-objdump -d gd2ie
gd2ie: file format elf64-x86-64
Disassembly of section .text:
0000000000001330 <_start>:
1330: c3 retq
1331: cc int3
1332: cc int3
1333: cc int3
1334: cc int3
1335: cc int3
1336: cc int3
1337: cc int3
1338: cc int3
1339: cc int3
133a: cc int3
133b: cc int3
133c: cc int3
133d: cc int3
133e: cc int3
133f: cc int3
0000000000001340 <bump>:
1340: 50 pushq %rax
1341: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
134a: 48 8d 80 f0 fb ff ff leaq -0x410(%rax), %rax
1351: 8b 08 movl (%rax), %ecx
1353: ff c1 incl %ecx
1355: 89 08 movl %ecx, (%rax)
1357: 89 c8 movl %ecx, %eax
1359: 59 popq %rcx
135a: c3 retq
135b: 0f 1f 44 00 00 nopl (%rax,%rax)
0000000000001360 <scratch_at>:
1360: 53 pushq %rbx
1361: 89 fb movl %edi, %ebx
1363: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
136c: 48 8d 80 00 fc ff ff leaq -0x400(%rax), %rax
1373: 48 63 cb movslq %ebx, %rcx
1376: 48 8d 04 88 leaq (%rax,%rcx,4), %rax
137a: 5b popq %rbx
137b: c3 retq
137c: 0f 1f 40 00 nopl (%rax)
0000000000001380 <read_ext>:
1380: 50 pushq %rax
1381: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
138a: 48 03 05 e7 10 00 00 addq 0x10e7(%rip), %rax # 0x2478 <ext_var+0x2478>
1391: 8b 00 movl (%rax), %eax
1393: 59 popq %rcx
1394: c3 retq
$ readelf -rW gd2ie
Relocation section '.rela.dyn' at offset 0x2a8 contains 1 entry:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000002478 0000000200000012 R_X86_64_TPOFF64 0000000000000000 ext_var + 0

同样 16 字节:9 字节取线程指针,7 字节的 addq 从 0x2478 的 GOT 项读出偏移加上去。GOT 从两格的 tls_index 换成一格,动态重定位从 DTPMOD64 + DTPOFF64 换成一条 TPOFF64,__tls_get_addr 的 JUMP_SLOT 也没有了。Drepper 在 §5.5 开头就是用这个转换来解释 GD 序列里那 4 字节前缀的:IE 序列比不加前缀的 GD 序列长 4 个字节。

LD → LE

ld.o 单独链接成 PIE,它的块只有 a、b 两个 4 字节变量,p_memsz 为 8:

$ ld.lld -pie start.o ld.o -o ld2le
$ llvm-objdump -d ld2le
ld2le: file format elf64-x86-64
Disassembly of section .text:
0000000000001290 <_start>:
1290: c3 retq
1291: cc int3
1292: cc int3
1293: cc int3
1294: cc int3
1295: cc int3
1296: cc int3
1297: cc int3
1298: cc int3
1299: cc int3
129a: cc int3
129b: cc int3
129c: cc int3
129d: cc int3
129e: cc int3
129f: cc int3
00000000000012a0 <touch>:
12a0: 53 pushq %rbx
12a1: 89 fb movl %edi, %ebx
12a3: 66 66 66 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
12af: 8b 90 f8 ff ff ff movl -0x8(%rax), %edx
12b5: 8d 34 1a leal (%rdx,%rbx), %esi
12b8: 89 b0 f8 ff ff ff movl %esi, -0x8(%rax)
12be: 8b 88 fc ff ff ff movl -0x4(%rax), %ecx
12c4: 89 ce movl %ecx, %esi
12c6: 29 de subl %ebx, %esi
12c8: 89 b0 fc ff ff ff movl %esi, -0x4(%rax)
12ce: 01 d1 addl %edx, %ecx
12d0: 89 c8 movl %ecx, %eax
12d2: 5b popq %rbx
12d3: c3 retq

原来 12 字节的 leaq + call 变成 movq %fs:0, %rax:本身 9 字节,前面垫三个无用的 0x66 凑满 12 字节(Drepper §5.5 写作 .word 0x6666; .byte 0x66)。之后 %rax 是线程指针而不是块起点,所以每一处 DTPOFF32 的位移也要从"块内偏移"改成"相对线程指针的偏移",即 Drepper 写的把 x1@dtpoff 换成 x1@tpoff。指令本身不变,a 的位移从 0 变成 −8,b 的从 4 变成 −4。

IE → LE

ie2le 里的 read_ext:

1300: 48 c7 c0 f4 fb ff ff movq $-0x40c, %rax # imm = 0xFBF4
1307: 64 8b 00 movl %fs:(%rax), %eax

改写前是 48 8b 05 xx xx xx xx(movq ext_var@gottpoff(%rip), %rax)。两边都是 7 字节:操作码从 8b(从内存读)换成 c7(装入立即数),ModRM 从 05(RIP 相对寻址)换成 c0(目标是寄存器 %rax),后面 4 字节从 GOT 项的 PC 相对偏移换成 ext_var 相对线程指针的偏移。−0x40c 就是前面算过的那个数:ext_var 在块内偏移 4,块顶在 TP − 0x410。后面那条 movl %fs:(%rax), %eax 照旧。

Drepper §5.5 只列出了 addq 形式的 IE→LE,即把 addq x@gottpoff(%rip), %rax 改成 leaq x@tpoff(%rax), %rax;§4.3.6 里另一种 movq 形式的 IE 序列,在 §5.5 没有对应条目。lld 的 relaxTlsIeToLe(lld/ELF/Arch/X86_64.cpp)两种都处理:addq 改成 leaq(%rsp 和 %r12 例外,改成 addq $imm,因为它们的 leaq 编码多一个字节,放不下),movq 改成 movq $imm。

TLSDESC:让那次调用本身便宜一点

对于不能改成固定偏移的通用动态访问,前面的 GD 序列仍需要通过 __tls_get_addr 求地址。编译器可以复用已经取得的地址,因此一次源码访问不一定对应一次调用;但保留下来的调用使用普通 C 调用约定,调用方要保护仍然需要的寄存器值,解析器也需要处理 DTV 和模块列表的更新。除了减少调用次数,还能让这次求地址本身便宜一点吗?

TLS 描述符(TLSDESC)就是这个思路。它由 Alexandre Oliva 和 Guido Araújo 在 2006 年 GCC 开发者峰会的论文里提出(Speeding Up Thread-Local Storage Access in Dynamic Libraries)。GOT 里放的不再是 tls_index,而是一个"函数指针 + 参数"的描述符,代码间接调用这个函数,得到的是变量相对线程指针的偏移。ld.so 根据模块的实际情况填入不同的函数:模块的块恰好落在静态区域时,函数只是把参数里预先算好的偏移原样返回,几条指令就结束;块是动态分配的,才走类似 __tls_get_addr 的慢路径。这个函数约定保存几乎所有寄存器,调用方不必为它腾寄存器。

x86-64 上默认配置的 GCC 和 clang 都不生成它,要加 -mtls-dialect=gnu2(GCC 也可以在构建编译器时用 --with-tls=gnu2 改掉默认值):

$ clang -O1 -fPIC -mtls-dialect=gnu2 -c tls.c -o desc.o
$ llvm-objdump -d -r desc.o
desc.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <bump>:
0: 50 pushq %rax
1: 48 8d 05 00 00 00 00 leaq (%rip), %rax # 0x8 <bump+0x8>
0000000000000004: R_X86_64_GOTPC32_TLSDESC counter-0x4
8: ff 10 callq *(%rax)
0000000000000008: R_X86_64_TLSDESC_CALL counter
a: 64 8b 08 movl %fs:(%rax), %ecx
d: ff c1 incl %ecx
f: 64 89 08 movl %ecx, %fs:(%rax)
12: 89 c8 movl %ecx, %eax
14: 59 popq %rcx
15: c3 retq
16: 66 2e 0f 1f 84 00 00 00 00 00 nopw %cs:(%rax,%rax)
0000000000000020 <scratch_at>:
20: 50 pushq %rax
21: 48 8d 05 00 00 00 00 leaq (%rip), %rax # 0x28 <scratch_at+0x8>
0000000000000024: R_X86_64_GOTPC32_TLSDESC scratch-0x4
28: ff 10 callq *(%rax)
0000000000000028: R_X86_64_TLSDESC_CALL scratch
2a: 64 48 03 04 25 00 00 00 00 addq %fs:0x0, %rax
33: 48 63 cf movslq %edi, %rcx
36: 48 8d 04 88 leaq (%rax,%rcx,4), %rax
3a: 59 popq %rcx
3b: c3 retq
3c: 0f 1f 40 00 nopl (%rax)
0000000000000040 <read_ext>:
40: 50 pushq %rax
41: 48 8d 05 00 00 00 00 leaq (%rip), %rax # 0x48 <read_ext+0x8>
0000000000000044: R_X86_64_GOTPC32_TLSDESC ext_var-0x4
48: ff 10 callq *(%rax)
0000000000000048: R_X86_64_TLSDESC_CALL ext_var
4a: 64 8b 00 movl %fs:(%rax), %eax
4d: 59 popq %rcx
4e: c3 retq
$ ld.lld -shared desc.o -o libdesc.so
$ readelf -rW libdesc.so | grep TLSDESC
0000000000002518 0000000100000024 R_X86_64_TLSDESC 0000000000000000 ext_var + 0
00000000000024f8 0000000300000024 R_X86_64_TLSDESC 0000000000000000 counter + 0
0000000000002508 0000000500000024 R_X86_64_TLSDESC 0000000000000010 scratch + 0

leaq 取描述符地址,call *(%rax) 调用描述符里的函数,返回的是偏移,所以下一条就是 %fs:(%rax)。每个变量只有一条 R_X86_64_TLSDESC 动态重定位,由 ld.so 填写描述符的两个字。

AArch64 上 GCC 和 clang 默认就用 TLSDESC:

$ clang --target=aarch64-unknown-linux-gnu -O1 -fPIC -c tls.c -o a64pic.o
$ llvm-objdump -d -r a64pic.o
a64pic.o: file format elf64-littleaarch64
Disassembly of section .text:
0000000000000000 <bump>:
0: a9bf7bfd stp x29, x30, [sp, #-0x10]!
4: 910003fd mov x29, sp
8: 90000000 adrp x0, 0x0 <bump>
0000000000000008: R_AARCH64_TLSDESC_ADR_PAGE21 counter
c: f9400001 ldr x1, [x0]
000000000000000c: R_AARCH64_TLSDESC_LD64_LO12 counter
10: 91000000 add x0, x0, #0x0
0000000000000010: R_AARCH64_TLSDESC_ADD_LO12 counter
14: d63f0020 blr x1
0000000000000014: R_AARCH64_TLSDESC_CALL counter
18: d53bd049 mrs x9, TPIDR_EL0
1c: aa0003e8 mov x8, x0
20: b860692a ldr w10, [x9, x0]
24: 11000540 add w0, w10, #0x1
28: b8286920 str w0, [x9, x8]
2c: a8c17bfd ldp x29, x30, [sp], #0x10
30: d65f03c0 ret
0000000000000034 <scratch_at>:
34: a9bf7bfd stp x29, x30, [sp, #-0x10]!
38: 910003fd mov x29, sp
3c: 2a0003e8 mov w8, w0
40: 90000000 adrp x0, 0x0 <bump>
0000000000000040: R_AARCH64_TLSDESC_ADR_PAGE21 scratch
44: f9400001 ldr x1, [x0]
0000000000000044: R_AARCH64_TLSDESC_LD64_LO12 scratch
48: 91000000 add x0, x0, #0x0
0000000000000048: R_AARCH64_TLSDESC_ADD_LO12 scratch
4c: d63f0020 blr x1
000000000000004c: R_AARCH64_TLSDESC_CALL scratch
50: d53bd049 mrs x9, TPIDR_EL0
54: 8b000129 add x9, x9, x0
58: 8b28c920 add x0, x9, w8, sxtw #2
5c: a8c17bfd ldp x29, x30, [sp], #0x10
60: d65f03c0 ret
0000000000000064 <read_ext>:
64: a9bf7bfd stp x29, x30, [sp, #-0x10]!
68: 910003fd mov x29, sp
6c: 90000000 adrp x0, 0x0 <bump>
000000000000006c: R_AARCH64_TLSDESC_ADR_PAGE21 ext_var
70: f9400001 ldr x1, [x0]
0000000000000070: R_AARCH64_TLSDESC_LD64_LO12 ext_var
74: 91000000 add x0, x0, #0x0
0000000000000074: R_AARCH64_TLSDESC_ADD_LO12 ext_var
78: d63f0020 blr x1
0000000000000078: R_AARCH64_TLSDESC_CALL ext_var
7c: d53bd048 mrs x8, TPIDR_EL0
80: b8606900 ldr w0, [x8, x0]
84: a8c17bfd ldp x29, x30, [sp], #0x10
88: d65f03c0 ret

adrp + ldr + add 是第 4 章讲过的页寻址:算出描述符地址放进 x0,把描述符里的函数指针读进 x1;blr x1 调用它,返回的偏移在 x0;mrs 读线程指针,ldr w10, [x9, x0] 用"线程指针 + 偏移"取值。AArch64 ABI11 要求这四条指令严格按规定的形状生成,中间不能插别的指令,寄存器也必须一致,就是为了让链接器能认出整组并改写(AArch64 System V ABI)。

链接器同样可以把 TLSDESC 降到 IE 或 LE。把它链接进 PIE:

$ ld.lld -pie a64start.o a64pic.o a64ext.o -o a64exe
$ llvm-objdump -d a64exe
a64exe: file format elf64-littleaarch64
Disassembly of section .text:
00000000000102dc <_start>:
102dc: d65f03c0 ret
00000000000102e0 <bump>:
102e0: a9bf7bfd stp x29, x30, [sp, #-0x10]!
102e4: 910003fd mov x29, sp
102e8: d2a00000 movz x0, #0x0, lsl #16
102ec: f2800200 movk x0, #0x10
102f0: d503201f nop
102f4: d503201f nop
102f8: d53bd049 mrs x9, TPIDR_EL0
102fc: aa0003e8 mov x8, x0
10300: b860692a ldr w10, [x9, x0]
10304: 11000540 add w0, w10, #0x1
10308: b8286920 str w0, [x9, x8]
1030c: a8c17bfd ldp x29, x30, [sp], #0x10
10310: d65f03c0 ret
0000000000010314 <scratch_at>:
10314: a9bf7bfd stp x29, x30, [sp, #-0x10]!
10318: 910003fd mov x29, sp
1031c: 2a0003e8 mov w8, w0
10320: d2a00000 movz x0, #0x0, lsl #16
10324: f2800300 movk x0, #0x18
10328: d503201f nop
1032c: d503201f nop
10330: d53bd049 mrs x9, TPIDR_EL0
10334: 8b000129 add x9, x9, x0
10338: 8b28c920 add x0, x9, w8, sxtw #2
1033c: a8c17bfd ldp x29, x30, [sp], #0x10
10340: d65f03c0 ret
0000000000010344 <read_ext>:
10344: a9bf7bfd stp x29, x30, [sp, #-0x10]!
10348: 910003fd mov x29, sp
1034c: d2a00000 movz x0, #0x0, lsl #16
10350: f2800280 movk x0, #0x14
10354: d503201f nop
10358: d503201f nop
1035c: d53bd048 mrs x8, TPIDR_EL0
10360: b8606900 ldr w0, [x8, x0]
10364: a8c17bfd ldp x29, x30, [sp], #0x10
10368: d65f03c0 ret

四条定长 4 字节的指令换成另外四条:movz 和 movk 分两次把 32 位偏移装进 x0(高 16 位为 0,低 16 位为 0x10),剩下两个位置填 nop。readelf -r 同样显示没有重定位。偏移 +0x10 正是 Variant I 的样子:16 字节 TCB 之后就是可执行文件的块,counter 在块内偏移 0,所以相对线程指针是正的 16。和 x86-64 的 −0x410 对照,两种布局的差别在这一个数上就看得见。

"cannot allocate memory in static TLS block"

现在回到讲 -ftls-model 时留下的那个代价。

IE 访问把变量的位置表示为 TP 加一个固定偏移。该偏移必须对进程中的每个线程都有效,因此相应模块的 TLS 块必须进入静态 TLS 区域。启动时加载的模块可以在分配线程存储之前一起参与布局;运行中才加载的模块,则需要运行时为它保留的位置。

这个要求来自寻址方式:已有代码可能已经把线程指针或 TLS 变量地址保存在寄存器、栈或其他对象中,运行时不能任意移动各线程的实例。通用动态访问可以查表得到另行分配的块,IE 指令却没有这种查找或回退路径。因此晚加载 IE 是否成功,取决于运行时能否在所有线程里提供同一个 TP-relative 位置,而不是 ELF 规定了一个固定的字节上限。

DF_STATIC_TLS 表示模块使用了要求静态 TLS 的访问方式。x86-64 的 IE 共享库还通过 R_X86_64_TPOFF64 请求加载器填写 TP-relative 偏移。前面的 ie.o 可以用来查看这些信息怎样写入 ELF:

$ ld.lld -shared ie.o -o libie.so
$ readelf -dW libie.so | grep FLAGS
0x000000000000001e (FLAGS) STATIC_TLS
$ ld.bfd -shared ie.o -o libie_bfd.so
$ readelf -dW libie_bfd.so | grep FLAGS
0x000000000000001e (FLAGS) STATIC_TLS

它的 .rela.dyn 里是三条 R_X86_64_TPOFF64,每个变量一条。Drepper 的文档明确说,这样的模块不能被动态加载(§3.1:"remember, such modules cannot be loaded dynamically")。dlopen 发生时,进程里可能已经有几十个线程,每个线程的静态 TLS 区域都已分配好,就在各自的线程指针旁边,没法挪动也没法变大;而新库的 IE 代码要求自己的块离线程指针有一个固定距离,并且所有线程里这个距离都一样。

glibc 在进程启动时预留一部分静态 TLS 空间。必需的 IE 分配与可选优化要分开理解:IE 没有回退路径,空间不足就不能完成装载;TLSDESC 若能取得静态位置,可以使用返回固定偏移的快路径,否则仍可走动态解析。余量由多个装载请求共享,对齐也会消耗空间。

glibc 的可调参数手册说明了这种启动期预留,以及每个线程需要承担的内存成本。一个历史版本的预算计算放在实现附录;理解上述机制不需要记住它的默认数字。

必需的静态 TLS 分配失败时,glibc 的 dlopen 会报告 cannot allocate memory in static TLS block。诊断应围绕分配要求展开:先用 LD_DEBUG=files 找出正在加载的库,再检查 STATIC_TLS 标志、R_X86_64_TPOFF64 等要求固定 TP 偏移的动态重定位,以及 PT_TLS 描述的大小和对齐。仅缺少标志不足以排除问题,加载器还会处理具体重定位。

解决办法也来自这个机制:改用 GD 或 TLSDESC,使访问不再强制依赖静态位置;让库成为启动依赖,使它在初次布局时就被计入;或者减小 TLS 需求。增大 optional_static_tls 可以增加预留空间,但每个线程都要承担这份额外存储。能否满足某个库,要按实际布局判断,不能把某次探测到的容量当成接口保证。

几个边角

静态链接(-static)时,整个程序只有一个模块、一个 TLS 块,偏移全在链接时确定,因此具备将受支持的访问序列松弛为 LE 的条件(Drepper §3.3);是否改写还取决于具体重定位、指令形式和链接器实现。在 Linux 上用 musl 工具链的 gcc -static 链接同样的 pic.o,read_ext 里就是 leaq -0x40c(%rax), %rax,readelf -r 显示没有重定位。这时没有 ld.so,主线程的 TLS 块由 C 库的启动代码建立(glibc 里是 csu/libc-tls.c 的 __libc_setup_tls),之后的线程由线程库照模板分配。

C++ 的 thread_local 可以有构造函数和析构函数,而 Drepper 文档 §1 还写着"C++ 程序里的线程局部变量不能要求静态构造"。今天的做法是编译器为这样的变量生成一个 TLS wrapper 函数(名字以 _ZTW 开头,放在 COMDAT12 组里),每次访问先经过它:第一次访问时运行构造函数,并通过 __cxa_thread_atexit 登记线程退出时要调用的析构函数。对链接器来说,这些只是普通函数和普通符号,TLS 的部分还是本章讲的那些。

不是所有平台都用 ELF TLS。macOS 的 Mach-O 用一套叫 TLV(thread-local variables)的机制,访问时经过一个描述符调用;没有原生 TLS 支持的平台可以用 -femulated-tls,编译器把每次访问变成对 __emutls_get_address 的调用,底下正是本章开头那套 pthread 键,链接器无从优化。

从访问模型到实现验证

布局建立模板,GOT 保存需要运行时补足的参数,松弛利用链接时新增的信息简化指令。这三部分必须共同满足同一份线程布局约定。

以链接 musl 的 libc.a、输出静态可执行文件为例,可以先实现一条明确的路径:合并 .tdata 和 .tbss、生成 PT_TLS,用前文线程指针布局的公式算出每个符号的 TPOFF 填进 TPOFF32,并用示例核对;遇到 GOTTPOFF 就做 IE→LE 改写,把 movq 换成装立即数。第 4 章的 GOTPCRELX 在静态链接里也是同样的处境,GOT 项里要放的地址链接时已知,可以把 movq x@GOTPCREL(%rip) 改成 leaq x(%rip),那一个 GOT 项就不用建了。

验证至少覆盖两类程序。第一类是多线程 C 程序,_Thread_local 变量定义在另一个文件里,通过 extern 引用。对本例工具链的默认非 PIC 编译方式,同一文件内的定义通常直接使用 LE,产生 TPOFF32,无法检验 IE→LE;换成 extern 之后,用 musl 工具链编译,llvm-objdump -dr 里就是 R_X86_64_GOTTPOFF,ld.lld 静态链接后改写成 movq $-0x4, %rax。每个线程改自己的那一份,再故意让一次系统调用失败、读 errno。errno 检验的是另一件事。errno 也不是 ELF TLS 变量:__errno_location 只有 movq %fs:0, %rax 和 addq $0x34, %rax,取的是线程描述结构里的一个字段(src/errno/__errno_location.c,结构定义在 src/internal/pthread_impl.h)。这个结构和主线程的 TLS 块,是启动代码 __init_tls 从程序头里找到 PT_TLS、按它的大小和对齐一起分配的(src/env/__init_tls.c),PT_TLS 写错,errno 也会跟着错。

第二个是第 8 章留下的静态链接 C++ 异常。Linux 上从源码构建的 musl 目标 GCC 15.2 所提供的 libstdc++.a 里,eh_globals.o 的 __cxa_get_globals 用 LD 序列访问一个 16 字节的 __thread 变量,即 R_X86_64_TLSLD、call __tls_get_addr 和 R_X86_64_DTPOFF32,可见这个静态库是按 PIC 编译的。支持这份库的静态链接路径需要识别这组序列并将其改写为 LE,或提供满足其调用约定的其他实现。另有两处细节。这个变量所在的节叫 .tbss._ZZN12_GLOBAL__N_1L10get_globalEvE6global,要按 .tbss. 前缀并进输出的 .tbss。对 __tls_get_addr 的引用会从 libc.a 里拉进 __tls_get_addr.o,LD→LE 改写之后已经没有指令调用它,但用一个只含 LD 序列的小程序实测,BFD 和 ld.lld 都照样拉进了这个成员:静态链接的输出里有 __tls_get_addr,对它的 call 一条也没有。这反映了归档提取与指令松弛的先后关系:先因未定义引用提取成员,后消除调用,并不会自动撤销此前的提取。是否进一步删除相关代码,还取决于节 GC13 等机制;归档提取策略和最终代码存活是两件事。这两类程序的编译、静态链接与运行都在 Linux 上完成,再和 ld.lld 的产物比较段、符号偏移和改写后的指令字节;输入与命令沿用本节前面给出的形式。

从线程的实例到机器的启动

TLS 使链接器和运行时的分工更加具体。链接器写下模板及访问规则,运行时为每个线程分配实例、设好线程指针,指令才会访问正确的数据。仅仅把 ELF 文件的字节放进内存,并不会自动完成后面这些工作。

普通应用可以依靠内核、动态链接器和 C 库建立这些条件。操作系统内核启动时却不能调用另一个已经运行的自身来准备环境;它要接受固件或引导程序交来的机器状态,再执行自己的启动代码。在 QEMU14 中,模拟器也承担了一部分装载工作。链接地址、实际装入的位置和接到控制权时的地址必须与这条启动路径一致,链接器脚本因此成了程序与机器启动方式之间的具体约定。

练习

答案对应上述 x86-64 Linux 环境的实测。按前述命令在独立临时目录中执行,打印 GCC、binutils、glibc 版本,并完成线程运行、偏移计算、静态 TLS 余量探测与三种修复方法的验收。AArch64 的题目只做静态指令对照,明确不把它算作本机运行结果。

观察

  1. 下面的 rt.c 让主线程和两个同时存活的线程各打印一次线程指针、一个栈上变量的地址、tag 的地址,以及四个 TLS 变量相对线程指针的差值。

    #include <pthread.h>
    #include <stdio.h>
    #include <stdint.h>
    _Thread_local char tag[6] = "hello";
    _Thread_local long seq = 1;
    _Thread_local int hits;
    _Thread_local _Alignas(32) char slab[40];
    static char *tp(void) {
    #if defined(__x86_64__)
    char *p; __asm__("movq %%fs:0, %0" : "=r"(p)); return p;
    #else
    return __builtin_thread_pointer(); /* 读 TPIDR_EL0 的内建函数 */
    #endif
    }
    static pthread_barrier_t bar;
    static void *show(void *name) {
    if (((char *)name)[0] == 't') pthread_barrier_wait(&bar);
    char *t = tp();
    int local;
    hits++;
    printf("%-4s tp=%p &local=%p &tag=%p tag%+ld seq%+ld hits%+ld slab%+ld hits=%d\n",
    (char *)name, (void *)t, (void *)&local, (void *)tag,
    (long)((intptr_t)tag - (intptr_t)t), (long)((intptr_t)&seq - (intptr_t)t),
    (long)((intptr_t)&hits - (intptr_t)t), (long)((intptr_t)slab - (intptr_t)t), hits);
    return 0;
    }
    int main(void) {
    pthread_t a, b;
    pthread_barrier_init(&bar, 0, 2);
    show("main");
    pthread_create(&a, 0, show, "t1");
    pthread_create(&b, 0, show, "t2");
    pthread_join(a, 0); pthread_join(b, 0);
    return 0;
    }

    在 x86-64 Linux 上用 gcc -O1 rt.c -o rt-x86 -pthread 编译,再直接运行 ./rt-x86。先写下预测:三行的 &tag 是否相同,四个差值是否相同,每个线程都执行了 hits++,打印出的 hits 各是几。再看 tp 和 &local 的距离,说说新线程的 TLS 块放在哪里,主线程的又在哪里。

手算与预测

  1. 把 rt.c 里的四个变量和四个小函数单独放进 q.c:

    char *tag_p(void) { return tag; }
    long get_seq(void) { return seq; }
    int get_hits(void){ return hits; }
    char *slab_p(void) { return slab; }

    用 clang -O1 -c q.c 编译,和 start.o 一起 ld.lld -pie 链接成 q-x86_64。readelf 给出:

    TLS 0x000340 0x0000000000002340 0x0000000000002340 0x000010 0x000068 R 0x20
    [ 7] .tdata PROGBITS 0000000000002340 000340 000010 00 WAT 0 0 8
    [ 8] .tbss NOBITS 0000000000002360 000350 000048 00 WAT 0 0 32
    5: 0000000000000000 6 TLS GLOBAL DEFAULT 7 tag
    7: 0000000000000008 8 TLS GLOBAL DEFAULT 7 seq
    9: 0000000000000020 4 TLS GLOBAL DEFAULT 8 hits
    11: 0000000000000040 40 TLS GLOBAL DEFAULT 8 slab

    (a) 列出变量清单,用 Variant II 的公式算出四个变量各自的 %fs:-N,再用 llvm-objdump -d q-x86_64 核对。(b) .tdata 只有 0x10 字节,hits 的 st_value 为什么是 0x20 而不是 0x10?(c) 运行时保证线程指针本身按 p_align 对齐。如果把 p_memsz 0x68 直接当成块的大小,哪个变量会出问题?

  2. 同一个 q.c 换成 --target=aarch64-unknown-linux-gnu,链接出的 PT_TLS 的大小和对齐与上题相同,四个 st_value 也相同。AArch64 是 Variant I,TCB 固定 16 字节,Drepper §3.4.1 给 IA-64 写的公式是 tlsoffset_1 = round(16, align_1),AArch64 照用。预测 tag_p 和 slab_p 里 add x0, x8, #imm 的立即数,再反汇编核对。

改坏

  1. 共享库 big.c 里只有一个强制 IE 模型的大数组,host.c 在运行时 dlopen 它:

    /* big.c */
    __attribute__((tls_model("initial-exec"))) __thread char buf[N];
    char *buf_at(int i) { return &buf[i]; }
    /* host.c */
    #include <dlfcn.h>
    #include <stdio.h>
    int main(int argc, char **argv) {
    void *h = dlopen(argv[1], RTLD_NOW);
    if (!h) { printf("dlopen: %s\n", dlerror()); return 1; }
    char *(*at)(int) = (char *(*)(int))dlsym(h, "buf_at");
    printf("ok, buf at %p\n", (void *)at(0));
    return 0;
    }

    在 x86-64 Linux 上先 gcc -O1 host.c -o host -ldl,再用 gcc -O1 -fPIC -shared -DN=... big.c -o libbigN.so 编出不同大小的库,逐个交给 ./host。(a) 预测 N 到多大时 dlopen 开始失败,再找出确切的边界。(b) 用 readelf -dW 看库的 FLAGS,再用 readelf -rW 查动态 TLS 重定位。这两个视图分别告诉我们什么,为什么排查不能只看其中一个?(c) 用三种办法让 N = 2048 的库能用。

写代码

本章示例用于核对 TLS 符号相对线程指针的偏移。

答案

练习一
$ ./rt-x86
main tp=0x75a7da1897c0 &local=0x7ffe12995c54 &tag=0x75a7da189768 tag-88 seq-96 hits-24 slab-64 hits=1
t2 tp=0x75a7d95fe6c0 &local=0x75a7d95fde04 &tag=0x75a7d95fe668 tag-88 seq-96 hits-24 slab-64 hits=1
t1 tp=0x75a7d9dff6c0 &local=0x75a7d9dfee04 &tag=0x75a7d9dff668 tag-88 seq-96 hits-24 slab-64 hits=1

t1 和 t2 的先后每次运行可能不同。三行的 &tag 各不相同,四个差值却完全一样:rt-x86 是 PIE,四个变量都定义在可执行文件里,编译器用的是 LE,差值在链接时就定了,写在指令里,所有线程共用。

三个 hits 都是 1。主线程先加过一次,新线程拿到的仍是按 PT_TLS 模板新建的一份,.tbss 部分清零,不继承父线程当时的值。

t1 的 tp 比 &local 高 0x8bc 字节,两者在同一块映射里:glibc 的 pthread_create 把新线程的线程描述结构和静态 TLS 放在这个线程自己的栈映射顶端(nptl/allocatestack.c)。主线程的栈是内核在 execve 时建的,在本次输出的 0x7ffe1299 一带;它的 TLS 块是 ld.so 启动时另外分配的,和栈不在一处。

练习二

(a)

p_memsz = 0x68
p_align = 0x20
tlsoffset_1 = round(0x68, 0x20) = 0x80 块占 [TP − 0x80, TP)
tag st_value = 0x00 0x00 − 0x80 = −0x80
seq st_value = 0x08 0x08 − 0x80 = −0x78
hits st_value = 0x20 0x20 − 0x80 = −0x60
slab st_value = 0x40 0x40 − 0x80 = −0x40
$ llvm-objdump -d q-x86_64
q-x86_64: file format elf64-x86-64
Disassembly of section .text:
00000000000012c0 <_start>:
12c0: c3 retq
12c1: cc int3
12c2: cc int3
12c3: cc int3
12c4: cc int3
12c5: cc int3
12c6: cc int3
12c7: cc int3
12c8: cc int3
12c9: cc int3
12ca: cc int3
12cb: cc int3
12cc: cc int3
12cd: cc int3
12ce: cc int3
12cf: cc int3
00000000000012d0 <tag_p>:
12d0: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
12d9: 48 8d 80 80 ff ff ff leaq -0x80(%rax), %rax
12e0: c3 retq
12e1: 66 66 66 66 66 66 2e 0f 1f 84 00 00 00 00 00 nopw %cs:(%rax,%rax)
00000000000012f0 <get_seq>:
12f0: 64 48 8b 04 25 88 ff ff ff movq %fs:-0x78, %rax
12f9: c3 retq
12fa: 66 0f 1f 44 00 00 nopw (%rax,%rax)
0000000000001300 <get_hits>:
1300: 64 8b 04 25 a0 ff ff ff movl %fs:-0x60, %eax
1308: c3 retq
1309: 0f 1f 80 00 00 00 00 nopl (%rax)
0000000000001310 <slab_p>:
1310: 64 48 8b 04 25 00 00 00 00 movq %fs:0x0, %rax
1319: 48 8d 80 c0 ff ff ff leaq -0x40(%rax), %rax
1320: c3 retq

(b) 输出节 .tbss 的对齐取自其中最严格的输入,即 slab 的 32(节头最后一列)。.tdata 结束在块内 0x10,.tbss 只能从 round(0x10, 0x20) = 0x20 开始,节头里 .tbss 的地址 0x2360 也正好是 0x2340 + 0x20。

(c) 块顶如果放在 TP − 0x68,slab 就落在 TP − 0x28,不是 32 的倍数,_Alignas(32) 被破坏。向上取整到 0x80 多出的 0x18 字节,就是为对齐垫的空隙。

运行对照就是练习一的原生 rt-x86。GCC 15.2 把 slab 排在 hits 前面,seq 的块内偏移为 0、tag 为 8、slab 为 0x20、hits 为 0x48;p_memsz 为 0x4c,所以 round(0x4c, 0x20) = 0x60。实际运行的每个线程都打印 tag-88 seq-96 hits-24 slab-64,即 −0x58、−0x60、−0x18、−0x40,与从 ELF 独立计算的结果一致。

练习三
tlsoffset_1 = round(16, 0x20) = 0x20
tag 0x20 + 0x00 = +0x20 seq 0x20 + 0x08 = +0x28
hits 0x20 + 0x20 = +0x40 slab 0x20 + 0x40 = +0x60
$ llvm-objdump -d q-aarch64
q-aarch64: file format elf64-littleaarch64
Disassembly of section .text:
00000000000102b4 <_start>:
102b4: d65f03c0 ret
00000000000102b8 <tag_p>:
102b8: d53bd048 mrs x8, TPIDR_EL0
102bc: 91400108 add x8, x8, #0x0, lsl #12 // =0x0
102c0: 91008100 add x0, x8, #0x20
102c4: d65f03c0 ret
00000000000102c8 <get_seq>:
102c8: d53bd048 mrs x8, TPIDR_EL0
102cc: 91400108 add x8, x8, #0x0, lsl #12 // =0x0
102d0: 9100a108 add x8, x8, #0x28
102d4: f9400100 ldr x0, [x8]
102d8: d65f03c0 ret
00000000000102dc <get_hits>:
102dc: d53bd048 mrs x8, TPIDR_EL0
102e0: 91400108 add x8, x8, #0x0, lsl #12 // =0x0
102e4: 91010108 add x8, x8, #0x40
102e8: b9400100 ldr w0, [x8]
102ec: d65f03c0 ret
00000000000102f0 <slab_p>:
102f0: d53bd048 mrs x8, TPIDR_EL0
102f4: 91400108 add x8, x8, #0x0, lsl #12 // =0x0
102f8: 91018100 add x0, x8, #0x60
102fc: d65f03c0 ret

容易写成 16 + st_value,得到 0x10 和 0x50。正文的 a64exe 里 p_align 是 4,不超过 16 时两种算法结果相同;这里 p_align 是 0x20,16 字节的 TCB 后面要再空 16 字节,块才能从 32 的倍数开始。两条 add 来自目标文件里的 R_AARCH64_TLSLE_ADD_TPREL_HI12 和 R_AARCH64_TLSLE_ADD_TPREL_LO12_NC:偏移拆成高 12 位(lsl #12)和低 12 位,这里偏移小于 4096,高位那条加的是 0。

这道题验证 AArch64 文件中的指令立即数与 ABI 公式一致;它没有启动 AArch64 程序。练习一是 x86-64 的 Variant II 运行结果,不能拿两种布局的正负偏移混作一次运行证据。

练习四

(a)

1024: ok, buf at 0x7e0661baf280
1664: ok, buf at 0x776979c67000
1665: dlopen: ./libbig1665.so: cannot allocate memory in static TLS block
2048: dlopen: ./libbig2048.so: cannot allocate memory in static TLS block

在 glibc 2.43、默认 tunable、单库新进程的条件下,边界是 1664 字节;1665 开始失败。这个数字取决于加载顺序、运行库、对齐与配置,脚本用新进程逐步搜索,不能把它当作所有 Linux 的固定上限。

(b) GNU ld 2.46 生成的 x86-64 库有 FLAGS STATIC_TLS,动态重定位里还有一条针对 buf 的 R_X86_64_TPOFF64:

0x000000000000001e (FLAGS) STATIC_TLS
0000000000003fd8 0000000600000012 R_X86_64_TPOFF64 0000000000000000 buf + 0

标志说明库要求静态 TLS,重定位则给出哪个符号需要由加载器填写相对线程指针的偏移。诊断时要同时查看两者:不同目标后端的标志处理可能不同,但处理 IE 动态重定位时仍需要一个实际的静态 TLS 位置。找不到位置,加载器就无法填出该指令所需的固定偏移。

(c) 三种办法,实测都能加载:

  • 去掉 tls_model 属性重新编译。x86-64 GCC 默认 PIC 代码使用 GD 序列,允许动态分配 TLS;本例 big_gd.c 中 N = 4096 的库直接加载成功。
  • GLIBC_TUNABLES=glibc.rtld.optional_static_tls=1024 ./host ./libbig2048.so。余量变成 1664 − 512 + 1024 = 2176,实测 2176 能加载,2177 失败。
  • 让库在启动时加载。直接 gcc host.c ./libbig2048.so -o host_dep 没用,readelf -d host_dep 里没有它的 NEEDED:Debian 的 gcc 默认给链接器传 --as-needed(gcc -dumpspecs 里是 %{!fsanitize=*:--as-needed}),host.c 不引用库里任何符号,它就被丢掉了。加上 -Wl,--no-as-needed 之后 N = 8192 也能用,启动时加载的模块的块在建静态区域时就算进去,不占余量。

ELF TLS 的设计来源

在 2000 年代初,编译器、链接器和运行时一起接手了这件事。最早的两份设计是 Intel 的 IA-64 处理器专用 ABI(2001 年 5 月)和 Sun 的一份 TLS 文档(0.68 版,2001 年 9 月,由 Mike Walker 撰写,描述 Sun 在 SPARC 和 IA-32 上的实现)。Drepper 在 2002 年 1 月 27 日写出第一版文档,把这两份整合成一份跨架构的规范;此后陆续并入 SH、Alpha、s390 等架构,x86-64 部分是 2002 年 10 月 21 日由 Jakub Jelínek 加入的;本文引用的是 2013 年 8 月的 0.21 版(见该文档首页与第 7 节修订记录)。这套机制叫 ELF TLS(Thread-Local Storage,线程局部存储)。程序员只要写 __thread int x;,剩下的全由工具链负责。

参考

实现示例:glibc 的静态 TLS 预留预算

glibc 2.36 的 _dl_tls_static_surplus_init 给出了一个具体的预留策略:每个线程的静态 TLS 区域带有额外余量(surplus),大小在进程启动时算定,之后不能再变(elf/dl-tls.c)。算法是 (nns − 1) × 144 + nns × 144 + optional_static_tls,再加一个凑数的常量:nns 是可调参数(tunable,ld.so 启动时从环境变量 GLIBC_TUNABLES 读入的运行时设置)glibc.rtld.nns,即支持的 dlmopen 命名空间个数,默认 4;两个 144 分别是为每个额外命名空间里 libc.so 自己的 IE TLS、以及其他运行时库(如 libgomp)的 IE TLS 预留的;glibc.rtld.optional_static_tls 默认 512。源码注释写明,这个常量的作用是让默认参数下的总量恰好等于历史值 1664 字节。

这 1664 字节里有两种用法。optional_static_tls 那 512 字节是留给"能放进静态区就放"的优化的,典型就是前文的 TLSDESC:放得进,描述符函数直接返回常数偏移;放不进,就退回动态分配,功能不受影响(glibc 手册:Dynamic Linking Tunables)。带 DF_STATIC_TLS 的库则是必需的分配,没有退路,elf/dl-reloc.c 里的 _dl_try_allocate_static_tls 对这种请求检查的是全部剩余余量,不受 512 字节的限制(elf/dl-reloc.c)。

附录:术语与工具

  1. TLS — TLS(Thread-Local Storage)让不同线程拥有同一变量的独立实例。链接器描述初始化模板并处理寻址模型,运行时负责为线程建立实例。这里不是网络协议 Transport Layer Security。 官方文档。 ↩

  2. GCC — GCC(GNU Compiler Collection)是一组语言编译器。命令 gcc 是驱动入口,会组织编译、汇编和链接;在终端调用它,并不意味着后续工作都在同一个进程里完成。 官方文档。 ↩

  3. ELF — ELF(Executable and Linkable Format)规定目标文件、可执行文件与共享对象的结构。通用规则见 gABI,架构相关的调用约定和重定位规则见对应 psABI。 官方文档。 ↩

  4. PIE — PIE(position-independent executable)是可以在不同加载基址运行的可执行文件。生成 PIE 需要编译与链接选项配合;static-PIE 还需要自身的启动路径完成必要重定位。 官方文档。 ↩

  5. RISC-V — RISC-V 是开放的指令集架构。本系列主线是在原生 x86-64 Linux 上构建链接器;RV64 用于架构对照与内核案例。相关例子的编码、寄存器约定和重定位规则见 RISC-V psABI,不能直接套用 x86-64 的规则。 ↩

  6. musl — musl 是 Linux 的一种 C 标准库实现,提供 printf 等库函数及运行时支持。本系列在需要分析或链接较小的静态运行库时使用它;普通 Linux 服务器不一定预装 musl。 官方文档。 ↩

  7. GOT — GOT(Global Offset Table)保存供代码间接访问的地址或相关偏移。它让部分地址修补集中到数据表中;表项的具体用途由重定位类型与 ABI 决定。 官方文档。 ↩

  8. PIC — PIC(position-independent code)使用适合位置变化的寻址方式。它常用于共享库;具体通过 PC 相对寻址还是表项间接访问,取决于架构与符号绑定。 官方文档。 ↩

  9. PLT — PLT(Procedure Linkage Table)是一组函数调用跳板,常与 GOT 和动态符号绑定配合。PLT 存放指令,不能简单理解成另一张纯地址表。 官方文档。 ↩

  10. psABI — psABI(processor-specific ABI)是特定处理器架构的二进制接口约定。不同架构可以共用 ELF 文件结构,同时拥有不同的指令、调用约定和重定位公式。 官方文档。 ↩

  11. ABI — ABI(Application Binary Interface)规定二进制组件如何协作,包括调用约定、数据布局和文件格式等。它约束编译结果之间的交接,比源码层面的 API 更靠近机器。 官方文档。 ↩

  12. COMDAT — COMDAT 让工具链表示可供择一保留的重复定义组。ELF 通过 section group 与签名表达相关关系;选择副本时,组内关联内容需要一致处理。 官方文档。 ↩

  13. GC — 本文 GC 指 section garbage collection,即链接器从入口和其他根出发保留可达节、删除无用节。它发生在构建阶段,与运行时堆内存的垃圾回收不同。 官方文档。 ↩

  14. QEMU — QEMU 可模拟处理器和系统。用户态模式运行另一架构的用户程序,系统模式则连同机器设备一起模拟;本系列 xv6 实验使用后者启动完整内核。 官方文档。 ↩