ELF Thread Local Storage ABI (TLS) 是一种存储模型,用于存储
允许每个线程拥有全局变量的唯一副本。此模型
用于实现 C++ 的 thread_local
存储模型。在创建线程时,
变量将使用初始 TLS 映像中的初始值。TLS
变量在线程安全代码中可用作缓冲区
会话记录。也可以处理 errno 或 dlerror 等 C 样式错误
。
TLS 变量与任何其他全局/静态变量非常相似。已实施
其初始数据最终在 PT_TLS
细分中。PT_TLS
细分
位于只读 PT_LOAD
段内(尽管 TLS 变量可写入)。
然后,此细分会复制到每个线程的
可写入位置。将 PT_TLS
细分复制到的位置会受到影响
由分段的对齐方式决定,以确保 TLS 变量的对齐方式
尊重。
ABI
编译器、链接器和动态链接器必须遵循的实际接口 尽管实施的细节较为复杂, 复杂。编译器和链接器必须发出 使用 4 种访问模式之一(如下一部分所述)。动态 然后,链接器和线程实现必须完成所有设置, 效果。不同的架构具有不同的 ABI,但它们是相似的 足够宽泛的表达方式,我们大多就能像 只需要一个 ABI本文档假定使用 x86-64 或 AArch64 并且会在出现差异时指出这一点
TLS ABI 使用几个术语:
- 线程指针:这是每个线程中的唯一地址,通常存储为
放在寄存器中线程局部变量位于线程指针的偏移量处。
在本文档中,线程指针将缩写为
$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 集中设置DF_STATIC_TLS
动态表中的DT_FLAGS
条目(由PT_DYNAMIC
细分指定)。 - TLS 区域:这是每个 IP 地址专属的连续内存区域
线程。
$tp
将指向此区域中的某个点。它包含 静态 TLS 集中每个模块的 TLS 段 实现私有数据,有时也称为 TCB(线程 控制区块)。在 AArch64 上,以$tp
开头的 16 字节预留空间为 有时也称为 TCB我们将该空间称为“ABI TCB” 。 - TLS 分块:这是各个线程的 TLS 段副本。还有 每个线程的每个 TLS 段对应一个 TLS 块。
- 模块 ID:除了主模块 ID,模块 ID 不是静态已知的 可执行文件的模块 ID(始终为 1)。其他模块的模块 ID 为 由动态链接器选择的网址它只是每个服务器中 模块。从理论上讲,它可以是 例如哈希或其他内容实际上,它只是一个简单的计数器 由动态链接器维护的代码
- 主可执行文件:这是包含起始地址的模块。它
访问模型也会以特殊方式进行处理。它始终
其模块 ID 为 1。这是唯一可以使用固定偏移量的模块
通过下文所述的 Local Exec 模型从
$tp
检索这些 API。
为了符合 ABI,必须支持所有访问模型。
访问模式
ABI 指定了 4 种访问模型:
global-dynamic
local-dynamic
initial-exec
local-exec
这些值可用于 -ftls-model=...
和
__attribute__((tls_model("...")))
所用的模型与以下方面相关:
- 执行访问权限的模块:
<ph type="x-smartling-placeholder">
- </ph>
- 主可执行文件
- 静态 TLS 集中的模块
- 在启动后加载的模块,例如上传者:
dlopen
- 要访问的变量是在哪个模块中定义的:
<ph type="x-smartling-placeholder">
- </ph>
- 在同一模块内(即
local-*
) - 在其他模块中(即
global-*
)
- 在同一模块内(即
global-dynamic
可在任何位置用于任何变量。local-dynamic
可由任何模块使用,用于在该模块中定义的任何变量 同一个模块中。initial-exec
可供任何模块用于静态变量中定义的任何变量 已设置 TLS。local-exec
可由主可执行文件用于 main 可执行文件。
全球动态
全局动态是最常用的访问格式。也是最慢的。
任何线程局部全局变量都应可通过此方法访问。这个
如果动态库访问在
另一个模块(请参阅“初始执行”部分中的例外情况)。已定义的符号
则不需要使用此访问模型。主可执行文件可以是
也不应使用这种访问模式执行上述操作时,这是默认的访问模式,
使用 -fPIC
进行编译是共享库的标准。
此访问模式通过调用动态链接器中定义的一个函数来发挥作用。
调用函数的方式有两种:通过 TLSDESC 或通过
__tls_get_addr
。
对于 __tls_get_addr
,系统会向其传递一对 GOT
条目
与此符号相关联的字词。具体来说,它会将指针传递到第一个
第二个条目紧跟在它之后对于给定的符号 S
,第一个
条目(表示为 GOT_S[0]
)必须包含以下模块的模块 ID:
已定义 S
。第二个条目(表示为 GOT_S[1]
)必须包含到
TLS 分块,与 PT_TLS
段中符号的偏移量相同
相关联然后,使用S
__tls_get_addr(GOT_S)
。__tls_get_addr
的实现将
稍后会讨论的。
TLSDESC 是访问 global-dynamic
(以及 local-dynamic
)的替代 ABI
其中,使用另一对 GOT
槽,其中第一个 GOT
槽
包含一个函数指针。第二个则包含已定义的
辅助数据。这样,动态链接器就可以选择
视情况调用。
在这两种情况下,对这些函数的调用都必须由特定的
代码序列和一组特定的 reloc 代码。这样,链接器就能识别
并将这些访问权限放宽为 local-dynamic
访问模式。
(注意:以下段落详细介绍了编译器如何支持 ABI 结尾。如果您无关紧要,请跳过此段。)
为了让编译器发出此访问模型的代码,需要发出一个调用
__tls_get_addr
(由动态链接器定义)和对
符号名称。具体而言,编译器会为(提示
GOT 本身需要迁居其他地点)__tls_get_addr(GOT_S)
。通过
然后链接器在生成 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,以使线程局部访问使用此模型,然后再使用 DSO
无法使用 dlopen
安全地打开。这在性能方面是可以接受的
而如果您知道二进制文件永远无法
dlopen-ed 处理,例如使用 libc。以这种方式编译/链接的模块
其 DF_STATIC_TLS
标志集。
在不使用 -fPIC
的情况下进行编译时,初始 Exec 为默认执行。
编译器会发出代码,甚至不会调用 __tls_get_addr
进行这种访问
模型。它使用单个 GOT 条目来实现,我们将用 GOT_s
表示符号
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
是
p_align
字段,<a>
是PT_TLS
从主可执行文件的 TLS 段开头的 a
的偏移量。
在 x86_64
TPOFF_a == -<a>
上,其中 <a>
是 a
相对于 end 的偏移量
位于主可执行文件的 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)
。这可以确保始终留有至少 16 字节的空间
(在上图中以 tcb
表示)。
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
案例中会发生什么。调用 __tls_get_addr
时
首先检查 tls_cnt
是否与模块 ID(由 GOT_s[0]
提供)一致
) 在 dtv
内。如果是,则查询 dtv[GOT_s[0]] + GOT_s[1]
但如果不是更复杂一些请参阅
dynlink.c 中的 __tls_get_new
。
简而言之,已经为更大的 dtv
分配了足够大的空间
调用 dlopen
。有足够的空间,
将始终存在于已分配的某个位置系统会将较大的空间设置为
是适当的 dtv
。然后,将 tcb
设置为指向这个更大的新 dtv
。未来
然后,访问将使用更简单的代码路径,因为 tls_cnt
将非常大
。