ELF 线程本地存储 ABI (TLS) 是一种变量存储模型,它允许每个线程拥有全局变量的唯一副本。此模型用于实现 C++ 的 thread_local
存储模型。在创建线程时,系统将根据初始 TLS 映像为变量提供初始值。例如,TLS 变量可以用作线程安全代码中的缓冲区,或按线程保存记录。errno 或 dlerror 等 C 样式错误也可以使用这种方法处理。
TLS 变量与任何其他全局/静态变量非常相似。在实现过程中,其初始数据归在 PT_TLS
区段中。尽管 TLS 变量可写入,但 PT_TLS
段位于只读 PT_LOAD
段内。然后,系统会将此段复制到每个线程的进程中位于唯一的可写位置。PT_TLS
路段的复制位置受路段对齐影响,以确保遵循 TLS 变量的对齐方式。
ABI
虽然实现细节更为复杂,但编译器、链接器和动态链接器必须遵循的实际接口非常简单。编译器和链接器必须发出使用 4 种访问模型之一(详见下一部分)的代码和动态重定位。然后,动态链接器和线程实现必须完成所有设置,才能使其实际发挥作用。不同的架构具有不同的 ABI,但它们在大体上都足够相似,我们可以在大部分架构上说它们,就像只有一个 ABI 一样。本文档将假设使用的是 x86-64 或 AArch64,并指出两者的差异。
TLS ABI 使用以下几个术语:
- 线程指针:这是每个线程中的唯一地址,通常存储在寄存器中。线程局部变量位于距离线程指针的偏移量处。在本文档中,Thread Pointer 将采用缩写形式并用作
$tp
。$tp
是__builtin_thread_pointer()
在 AArch64 上返回的内容。在 AArch64 上,$tp
由名为TPIDR_EL0
的特殊寄存器提供,可以使用mrs <reg>, TPIDR_EL0
访问该寄存器。在x86_64
上,使用了fs.base
段基数,并且可通过%fs:
进行访问,并且可通过%fs:0
或rdfsbase
指令加载。 - TLS 分段:这是每个模块中的数据图像,由每个模块中的
PT_TLS
程序头文件指定。并非所有模块都有PT_TLS
程序头文件,因此并非所有模块都有 TLS 段。每个模块最多有一个 TLS 区段,并且最多有一个PT_TLS
程序标头。 - 静态 TLS 集合:这是程序启动时动态链接器已知的模块总数。它包含主可执行文件和
DT_NEEDED
以传递方式提及的每个库。对于需要位于静态 TLS 集的模块,系统会在其动态表(由PT_DYNAMIC
段给定)中的DT_FLAGS
条目上设置DF_STATIC_TLS
。 - TLS 区域:这是每个线程独有的连续内存区域。
$tp
将指向此区域中的某个点。它包含静态 TLS 集中每个模块的 TLS 段,以及一些实现专用数据,这些数据有时称为 TCB(线程控制块)。在 AArch64 上,以$tp
开头的 16 字节预留空间有时也称为 TCB。在本文档中,我们将此空间称为“ABI TCB”。 - TLS 分块:这是单个线程的 TLS 分段副本。每个线程的每个 TLS 段都有一个 TLS 块。
- 模块 ID:除了主可执行文件的模块 ID(始终为 1)之外,模块 ID 并非静态已知的模块 ID。其他模块的模块 ID 由动态链接器选择。它只是每个模块的唯一非零 ID。从理论上讲,它可以是对模块具有唯一性的任何非零 64 位值,例如哈希或其他内容。实际上,它只是动态链接器维护的一个简单计数器。
- 主可执行文件:这是包含起始地址的模块。在一种访问模型中,也会以特殊方式处理它。模块 ID 始终为 1。这是唯一一个可以通过下文所述的 Local Exec 模型使用来自
$tp
的固定偏移量的模块。
为了符合 ABI,必须支持所有访问模型。
访问模型
ABI 指定了 4 种访问模型:
global-dynamic
local-dynamic
initial-exec
local-exec
这些是可用于 -ftls-model=...
和 __attribute__((tls_model("...")))
的值。
使用哪种模型涉及以下方面:
- 执行访问的模块:
- 主可执行文件
- 静态 TLS 集内的模块
- 启动后加载的模块,例如由
dlopen
加载
- 要访问的变量是在哪个模块中定义的:
- 在同一模块中(即
local-*
) - 在其他模块(即
global-*
)中
- 在同一模块中(即
global-dynamic
可在任何位置用于任何变量。local-dynamic
可供任何模块用于同一模块中定义的任何变量。initial-exec
可供任何模块用于静态 TLS 集中定义的任何变量。local-exec
:可供主可执行文件用于主可执行文件中定义的变量。
全局动态
全局动态是最通用的访问格式。它的运行速度也最慢。
任何线程局部全局变量都应可通过此方法访问。如果动态库访问另一个模块中定义的符号,必须使用此访问模式(请参阅初始执行部分中的异常)。可执行文件中定义的符号不需要使用此访问模式。主可执行文件也可以避免使用此访问模型。这是使用 -fPIC
进行编译时的默认访问模型,与共享库的常规做法相同。
此访问模型通过调用动态链接器中定义的函数来运作。调用函数的方式有两种,即通过 TLSDESC 或通过 __tls_get_addr
调用。
如果是 __tls_get_addr
,则会收到与此符号关联的一对 GOT
条目。具体而言,系统会将指针传递给第一个条目,将第二个条目紧随其后。对于给定的 S
符号,第一个条目(以 GOT_S[0]
表示)必须包含定义了 S
的模块的模块 ID。第二个条目(表示为 GOT_S[1]
)必须包含“TLS 分块”中的偏移量,与关联模块的 PT_TLS
段中符号的偏移量相同。然后,使用 __tls_get_addr(GOT_S)
计算指向 S
的指针。我们稍后会讨论 __tls_get_addr
的实现。
TLSDESC 是 global-dynamic
访问(和 local-dynamic
)的替代 ABI,其中使用了一对不同的 GOT
槽,其中第一个 GOT
槽位包含一个函数指针。第二个变量包含一些动态链接器定义的辅助数据。这样,动态链接器就可以根据具体情况选择调用哪个函数。
在这两种情况下,对这些函数的调用都必须通过特定的代码序列和一组特定的重定位来实现。这样,链接器就可以识别这些访问,并可能将其放宽为 local-dynamic
访问模型。
(注意:以下段落详细介绍了编译器如何保持 ABI 的末尾。如果您对此不关心,请跳过此段。)
为了让编译器发出此访问模型的代码,需要针对 __tls_get_addr
(由动态链接器定义)和对符号名称的引用发出调用。具体而言,编译器会发出 __tls_get_addr(GOT_S)
的代码(注意 GOT 本身所需的额外重定位)。然后,链接器在生成 GOT 时会发出两个动态重定位。在 x86_64
上,这些是 R_X86_64_DTPMOD
和 R_X86_64_DTPOFF
。在 AArch64 上,它们为 R_AARCH64_DTPMOD
和 R_AARCH64_DTPOFF
。无论模块是否按该名称定义符号,这些重定位都会引用该符号。
本地动态
局部动态与全局动态相同,但适用于局部符号。可以将其视为对此模块的 TLS 块的单次 global-dynamic
访问。然后,由于模块中定义的每个变量都位于相对于 TLS 块的固定偏移量,因此编译器可以将多个 global-dynamic
调用优化为一个。只要变量是本地/静态变量或具有隐藏可见性,编译器就会放宽对 local-dynamic
访问的 global-dynamic
访问。链接器有时也可以放宽对 local-dynamic
的某些 global-dynamic
访问。
以下示例展示了编译器可如何针对这种访问模型发出代码:
static thread_local char buf[buf_cap];
static thread_local size_t buf_size = 0;
while(*str && buf_size < buf_cap) {
buf[buf_size++] = *str++;
}
降至
// GOT_module[0] is the module ID of this module
// GOT_module[1] is just 0
// <X> denotes the offset of X in this module's TLS block
tls = __tls_get_addr(GOT_module)
while(*str && *(size_t*)(tls+<buf_size>) < buf_cap) {
(char*)(tls+<buf>)[*(size_t*)(tls+<buf_size>)++] = *str++;
}
如果此代码使用了全局动态,则必须至少进行 2 次调用,一次用于获取 buf 的指针,另一次用于获取 buf_size
的指针。
初始执行
只要编译器知道在其中定义要访问的符号的模块将在初始可执行文件集中加载,而不是使用 dlopen
打开,就可以使用此访问模型。此访问模式通常仅在主可执行文件正在访问具有默认可见性的全局符号时使用。这是因为只有在编译可执行文件后,编译器才能知道生成的任何代码都将位于初始可执行文件集中。如果编译 DSO 以使线程本地访问使用此模型,则无法使用 dlopen
安全打开 DSO。这在性能关键型应用中是可以接受的,如果您知道二进制文件永远不会被 dlopen 解开,例如在 libc 的情况下。以这种方式编译/链接的模块会设置其 DF_STATIC_TLS
标志。
如果在不使用 -fPIC
的情况下进行编译,则默认执行的是初始执行。
编译器甚至无需为此访问模型调用 __tls_get_addr
即可发出代码。它使用单个 GOT 条目来实现此目的,对于符号 s
,我们将用 GOT_s
表示该条目,编译器会针对该条目发出重定位,以确保
extern thread_local int a;
extern thread_local int b;
int main() {
return a + b;
}
会降低到如下值:
int main() {
return *(int*)($tp + GOT[a]) + *(int*)($tp + GOT[b]);
}
请注意,在 x86 架构上,GOT[s]
实际上会解析为负值。
本地执行
这是最快的访问模式,仅当符号位于第一个 TLS 块(即主可执行文件的 TLS 块)中时才能使用。实际上,只有主可执行文件可以使用此访问模式,因为任何共享库都无法(也通常不需要)知道自己是否正在从主可执行文件访问某些内容。链接器会将 initial-exec
放宽为 local-exec
。如果没有通过 -ftls-model
或 __attribute__((tls_model("...")))
的明确指令,编译器就无法执行此操作,因为编译器无法知道当前转换单元将链接到主可执行文件还是共享库。
有关如何计算此偏移量的精确细节会因架构而异。
示例代码:
static thread_local int a;
static thread_local int b;
int main() {
return a + b;
}
会降至
int main() {
return (int*)($tp+TPOFF_a) + (int*)($tp+TPOFF_b));
}
在 AArch64 上,TPOFF_a == max(16, p_align) + <a>
其中 p_align
正好是主可执行文件的 PT_TLS
段的 p_align
字段,<a>
是 a
相对于主可执行文件的 TLS 段开头位置的偏移量。
在 x86_64
TPOFF_a == -<a>
上,其中 <a>
是 a
相对于主可执行文件的 TLS 段结束的偏移量。
链接器知道任何给定 X
的 TPOFF_X
是什么并填充此值。
实现
本部分介绍了在 Fuchsia 上实现的实现。这表明,在不同的 libc 实现(包括 musl 和 glibc)中,这里的大致描边非常相似。
上述所有代码的实际实现中会有一些更多详细信息。也就是所谓的“DTV”(动态线程矢量),在本文档中,用 dtv
表示,它会根据模块 ID 将 TLS 块编入索引。下图展示了初始可执行文件集。在 Fuchsia 的实现中,我们实际上将大量元信息连同 ABI TCB(在下文中表示为 tcb
)存储在一个线程描述符结构体中。在实现中,我们使用此空间的前 8 个字节指向 DTV。首先,tcb
指向 dtv
,如下图所示,但在 dlopen 之后,可能会发生变化。
arm64:
*------------------------------------------------------------------------------*
| thread | tcb | X | tls1 | ... | tlsN | ... | tls_cnt | dtv[1] | ... | dtv[N] |
*------------------------------------------------------------------------------*
^ ^ ^ ^ ^
td tp dtv[1] dtv[n+1] dtv
此处 X
的大小为 min(16, tls_align) - 16
,其中 tls_align
是从静态 TLS 集中加载的所有 TLS 段的最大对齐值。这由静态链接器设置,因为静态链接器会解析 TPOFF_*
值。此内边距经过设置,因此如果 $tp
与主可执行文件的 PT_TLS
段的 p_align
值需要对齐,则 tls1 - $tp
将为 max(16, p_align)
。这样可以确保 ABI TCB(上图中表示为 tcb
)始终至少有 16 字节的空间。
x86:
*-----------------------------------------------------------------------------*
| tls_cnt | dtv[1] | ... | dtv[N] | ... | tlsN | ... | tls1 | tcb | thread |
*-----------------------------------------------------------------------------*
^ ^ ^ ^
dtv dtv[n+1] dtv[1] tp/td
此处的 td
表示“线程描述符指针”。在这两种实现中,这都指向线程描述符。在这些图表中,有一点并未显而易见:在这两种情况下,tcb
实际上都是线程描述符结构体的成员,但在 AArch64 上,它是最后一个成员,而在 x86_64
上,它是第一个成员。
dlopen
下图说明了初始可执行文件会发生的情况,但没有解释 dlopen
会发生的情况。调用 __tls_get_addr
时,它会先检查 tls_cnt
是否使模块 ID(由 GOT_s[0]
提供)是否在 dtv
内。如果是,则只是查找 dtv[GOT_s[0]] + GOT_s[1]
,但如果不是更复杂的情况,就会发生。请参阅 dynlink.c 中的 __tls_get_new
实现。
简而言之,在调用 dlopen
时,系统已为更大的 dtv
分配了一个足够大的空间。它是系统的一个变体,即在已分配的位置始终具有足够的空间。然后将较大的空间设置为适当的 dtv
。然后,将 tcb
设置为指向这个更大的新 dtv
。然后,以后的访问将使用更简单的代码路径,因为 tls_cnt
将足够大。