底层段寄存器的线程局部实际使用
Thread local real usage of the underlying segment registers
我读了很多文章和 S/O 回答说(在 linux x86_64 上)FS(或某些变体中的 GS)引用特定于线程的页面 table 条目,然后给出指向可共享数据中实际数据的指针数组。当交换线程时,所有的寄存器都被交换,线程基页因此改变。线程变量按名称访问,仅需 1 个额外的指针跃点,并且引用的值可以共享给其他线程。一切都很好,也很合理。
确实,如果你查看 __errno_location(void)
的代码,errno
背后的函数,你会发现类似的东西(这是来自 musl,但 gnu 并没有太大的不同):
static inline struct pthread *__pthread_self()
{
struct pthread *self;
__asm__ __volatile__ ("mov %%fs:0,%0" : "=r" (self) );
return self;
}
来自 glibc:
=> 0x7ffff6efb4c0 <__errno_location>: endbr64
0x7ffff6efb4c4 <__errno_location+4>: mov 0x6add(%rip),%rax # 0x7ffff6f01fa8
0x7ffff6efb4cb <__errno_location+11>: add %fs:0x0,%rax
0x7ffff6efb4d4 <__errno_location+20>: retq
所以我的预期是 FS 的实际值会因每个线程而改变。例如。在调试器下,gdb: info reg
或 p $fs
,我会看到 FS 的值在不同的线程中是不同的,但没有:ds、es、fs、gs 一直都是零。
在我自己的代码中,我写了类似下面的内容并得到了相同的结果 - FS 未更改但 TLV“有效”:
struct Segregs
{
unsigned short int cs, ss, ds, es, fs, gs;
friend std::ostream& operator << (std::ostream& str, const Segregs& sr)
{
str << "[cs:" << sr.cs << ",ss:" << sr.ss << ",ds:" << sr.ds
<< ",es:" << sr.es << ",fs:" << sr.fs << ",gs:" << sr.gs << "]";
return str;
}
};
Segregs GetSegRegs()
{
unsigned short int r_cs, r_ss, r_ds, r_es, r_fs, r_gs;
__asm__ __volatile__ ("mov %%cs,%0" : "=r" (r_cs) );
__asm__ __volatile__ ("mov %%ss,%0" : "=r" (r_ss) );
__asm__ __volatile__ ("mov %%ds,%0" : "=r" (r_ds) );
__asm__ __volatile__ ("mov %%es,%0" : "=r" (r_es) );
__asm__ __volatile__ ("mov %%fs,%0" : "=r" (r_fs) );
__asm__ __volatile__ ("mov %%gs,%0" : "=r" (r_gs) );
return {r_cs, r_ss, r_ds, r_es, r_fs, r_gs};
}
但是输出呢?
Main: Seg regs : [cs:51,ss:43,ds:0,es:0,fs:0,gs:0]
Main: tls @0x7ffff699307c=0
Main: static @0x96996c=0
Modified to 1234
Main: tls @0x7ffff699307c=1234
Main: static @0x96996c=1234
Async thread
[New Thread 0x7ffff695e700 (LWP 3335119)]
Thread: Seg regs : [cs:51,ss:43,ds:0,es:0,fs:0,gs:0]
Thread: tls @0x7ffff695e6fc=0
Thread: static @0x96996c=1234
所以实际上还有其他事情正在发生?发生了什么额外的诡计,为什么要增加并发症?
对于上下文,我正在尝试做一些“用叉子时髦”的事情,所以我想知道血淋淋的细节。
在 64 位模式下,16 位 FS 和 GS 段寄存器的实际内容通常是“空选择器”(0
),因为其他机制用于设置段基数64 位值。 (MSR 或 wrfsbase
)
与保护模式一样,CPU 中有单独的“FSBASE”和“GSBASE”寄存器,并且当您指定对指令的 FS 段覆盖时,FSBASE 的基地址寄存器被添加到操作数的有效地址以确定要访问的实际线性地址。
每个线程的内核上下文结构存储其 FSBASE 和 GSBASE 寄存器的副本,并在每次上下文切换时适当地重新加载它们。
所以实际发生的是每个线程都将其 FSBASE 寄存器设置为指向其自己的线程本地存储。 (根据 CPU 特性和 OS 设计,这可能只适用于特权代码,因此可能需要系统调用。)然后可以使用带有 FS 段覆盖的指令来访问对象如您所见,在线程本地存储块中具有给定的偏移量。
另一方面,在32位模式下,FS和GS中的值确实有更多的意义;它们是段选择器,用于索引内核维护的描述符table。描述符 table 包含实际的段信息,包括它的基地址,您可以使用系统调用要求内核修改它。每个线程都有自己的本地描述符 table,因此您不一定会在 FS 中看到不同线程的不同选择器,但来自不同线程的 FS-override 指令仍然会导致对不同线程的访问线性地址。
(或者 32 位内核可以写入 GDT 条目和 mov
寄存器中的常量到 fs
或 gs
以使其重新加载新写入的GDT 条目。因此它只需要每个逻辑核心一个 GDT 而不是每个进程一个 LDT。CPU 永远不会自行重新加载段描述符,尽管使用每个核心 GDT 条目仍会匹配当前任务,如果你有单独的 FS 和 GS 条目。所以 user-space 可能不会用 mov eax,gs
/ mov gs,eax
.)
无论如何,这实际上只是缺少一种方便的 MSR 或 wrfsbase
方法来设置段寄存器基数与 mov Sreg, r/m
触发 CPU 加载描述符分开。在保护模式或长模式下,段寄存器中的值确实需要有效(包括 null = 0
),将一些随机值移入其中可能会出错。
我读了很多文章和 S/O 回答说(在 linux x86_64 上)FS(或某些变体中的 GS)引用特定于线程的页面 table 条目,然后给出指向可共享数据中实际数据的指针数组。当交换线程时,所有的寄存器都被交换,线程基页因此改变。线程变量按名称访问,仅需 1 个额外的指针跃点,并且引用的值可以共享给其他线程。一切都很好,也很合理。
确实,如果你查看 __errno_location(void)
的代码,errno
背后的函数,你会发现类似的东西(这是来自 musl,但 gnu 并没有太大的不同):
static inline struct pthread *__pthread_self()
{
struct pthread *self;
__asm__ __volatile__ ("mov %%fs:0,%0" : "=r" (self) );
return self;
}
来自 glibc:
=> 0x7ffff6efb4c0 <__errno_location>: endbr64
0x7ffff6efb4c4 <__errno_location+4>: mov 0x6add(%rip),%rax # 0x7ffff6f01fa8
0x7ffff6efb4cb <__errno_location+11>: add %fs:0x0,%rax
0x7ffff6efb4d4 <__errno_location+20>: retq
所以我的预期是 FS 的实际值会因每个线程而改变。例如。在调试器下,gdb: info reg
或 p $fs
,我会看到 FS 的值在不同的线程中是不同的,但没有:ds、es、fs、gs 一直都是零。
在我自己的代码中,我写了类似下面的内容并得到了相同的结果 - FS 未更改但 TLV“有效”:
struct Segregs
{
unsigned short int cs, ss, ds, es, fs, gs;
friend std::ostream& operator << (std::ostream& str, const Segregs& sr)
{
str << "[cs:" << sr.cs << ",ss:" << sr.ss << ",ds:" << sr.ds
<< ",es:" << sr.es << ",fs:" << sr.fs << ",gs:" << sr.gs << "]";
return str;
}
};
Segregs GetSegRegs()
{
unsigned short int r_cs, r_ss, r_ds, r_es, r_fs, r_gs;
__asm__ __volatile__ ("mov %%cs,%0" : "=r" (r_cs) );
__asm__ __volatile__ ("mov %%ss,%0" : "=r" (r_ss) );
__asm__ __volatile__ ("mov %%ds,%0" : "=r" (r_ds) );
__asm__ __volatile__ ("mov %%es,%0" : "=r" (r_es) );
__asm__ __volatile__ ("mov %%fs,%0" : "=r" (r_fs) );
__asm__ __volatile__ ("mov %%gs,%0" : "=r" (r_gs) );
return {r_cs, r_ss, r_ds, r_es, r_fs, r_gs};
}
但是输出呢?
Main: Seg regs : [cs:51,ss:43,ds:0,es:0,fs:0,gs:0]
Main: tls @0x7ffff699307c=0
Main: static @0x96996c=0
Modified to 1234
Main: tls @0x7ffff699307c=1234
Main: static @0x96996c=1234
Async thread
[New Thread 0x7ffff695e700 (LWP 3335119)]
Thread: Seg regs : [cs:51,ss:43,ds:0,es:0,fs:0,gs:0]
Thread: tls @0x7ffff695e6fc=0
Thread: static @0x96996c=1234
所以实际上还有其他事情正在发生?发生了什么额外的诡计,为什么要增加并发症?
对于上下文,我正在尝试做一些“用叉子时髦”的事情,所以我想知道血淋淋的细节。
在 64 位模式下,16 位 FS 和 GS 段寄存器的实际内容通常是“空选择器”(0
),因为其他机制用于设置段基数64 位值。 (MSR 或 wrfsbase
)
与保护模式一样,CPU 中有单独的“FSBASE”和“GSBASE”寄存器,并且当您指定对指令的 FS 段覆盖时,FSBASE 的基地址寄存器被添加到操作数的有效地址以确定要访问的实际线性地址。
每个线程的内核上下文结构存储其 FSBASE 和 GSBASE 寄存器的副本,并在每次上下文切换时适当地重新加载它们。
所以实际发生的是每个线程都将其 FSBASE 寄存器设置为指向其自己的线程本地存储。 (根据 CPU 特性和 OS 设计,这可能只适用于特权代码,因此可能需要系统调用。)然后可以使用带有 FS 段覆盖的指令来访问对象如您所见,在线程本地存储块中具有给定的偏移量。
另一方面,在32位模式下,FS和GS中的值确实有更多的意义;它们是段选择器,用于索引内核维护的描述符table。描述符 table 包含实际的段信息,包括它的基地址,您可以使用系统调用要求内核修改它。每个线程都有自己的本地描述符 table,因此您不一定会在 FS 中看到不同线程的不同选择器,但来自不同线程的 FS-override 指令仍然会导致对不同线程的访问线性地址。
(或者 32 位内核可以写入 GDT 条目和 mov
寄存器中的常量到 fs
或 gs
以使其重新加载新写入的GDT 条目。因此它只需要每个逻辑核心一个 GDT 而不是每个进程一个 LDT。CPU 永远不会自行重新加载段描述符,尽管使用每个核心 GDT 条目仍会匹配当前任务,如果你有单独的 FS 和 GS 条目。所以 user-space 可能不会用 mov eax,gs
/ mov gs,eax
.)
无论如何,这实际上只是缺少一种方便的 MSR 或 wrfsbase
方法来设置段寄存器基数与 mov Sreg, r/m
触发 CPU 加载描述符分开。在保护模式或长模式下,段寄存器中的值确实需要有效(包括 null = 0
),将一些随机值移入其中可能会出错。