原文网址:http://www.nynaeve.net/?p=185
昨天,我大致说了下编译器和链接器如何合作来支持TLS,但是并没有讲当访问__declspec(thread)变量时具体底层是个什么样子,或者说是怎么来做到的。
在解释如何访问__declspec(thread)变量的内部工作原理之前,有必要了解下tlssup.c中的几个特殊变量。这些变量被_tls_used引用来创建TLS目录结构。
第一个感兴趣的变量是_tls_index,在线程局部存储解析机制中,几乎每次线程局部变量被访问时改变量都由编译器隐式引用,从而解析出当前线程局部变量的地址(存在不引用该变量的一个例外,后面说)。_tls_index也是tlssup.c中唯一一个采用默认存储类的变量(普通全局变量)。内部,该变量代表本模块(exe或dll等pe文件)的TLS索引。概念上TLS索引和由TlsAlloc分配的索引相似。但是它们并不兼容(即这两个TLS索引不能混用的,因为底层支撑机制不同),模块独有的TLS索引具有更多的支撑代码。稍后将会讲到这些,现在大家先稍等一下。
在tlssup.c文件中,_tls_start和_tls_end变量的定义如下:
/* Special symbols to mark start and end of ThreadLocal Storage area. */
#pragma data_seg(".tls")
#if defined (_M_IA64) || defined (_M_AMD64)
_CRTALLOC(".tls")
#endif /*defined (_M_IA64) || defined (_M_AMD64) */
char _tls_start = 0;
#pragma data_seg(".tls$ZZZ")
#if defined (_M_IA64) || defined (_M_AMD64)
_CRTALLOC(".tls$ZZZ")
#endif /*defined (_M_IA64) || defined (_M_AMD64) */
char _tls_end = 0;
#pragma data_seg()
这段代码创建了两个变量,并将它们放在.tls段的开头和结尾。编译器和链接器将会自动为所有的__declspec(thread)变量放置在默认段.tls段,在最终的PE文件中这些变量将会被放置在_tls_start和_tls_end中间。这两个变量用于告诉链接器TLS存储模板节的边界位置(首地址和结束地址)。映像文件的TLS目录存储了该信息。
现在我们知道了在语言层次上__declspec(thread)是如何来工作的,接下来有必要了解下编译器产生的访问__declspec(thread)变量的支持代码。幸运的是这些支持代码非常直观。考虑如下测试程序:
__declspec(thread) int threadedint = 0;
int __cdecl wmain(int ac,
wchar_t**av)
{
threadedint = 42;
return 0;
}
对于x64系统,编译器将产生如下代码:
mov ecx,DWORD PTR _tls_index
mov rax,QWORD PTR gs:58h
mov edx,OFFSET FLAT:threadedint
mov rax,QWORD PTR [rax+rcx*8]
mov DWORD PTR[rdx+rax], 42
想想前面有介绍gs段寄存器在x64系统中用于引用TEB的首地址。88(0x58)是TEB的ThreadLocalStoragePointer成员的偏移:
+0x058 ThreadLocalStoragePointer : Ptr64 Void
但是,如果我们在运行时查看代码将会是下面这个样子:
mov ecx,cs:_tls_index
mov rax,gs:58h
mov edx,4
mov rax,[rax+rcx*8]
mov dwordptr [rdx+rax], 2Ah ; 42
xor eax,eax
可以发现“threadedint”变量被解析成了一个小值(4)。回忆在单独编译时,mov edx,4指令对应mov edx,OFFSET FLAT:threadedint。
现在,4不是一个平坦地址(我们希望的是一个范围位于可执行文件使用范围的地址)发生了什么事情了?
ok,原来这里链接器玩了一个小把戏。当链接器解析对__declspec(thread)变量的引用时,将偏移假定为相对于.tls节的起始位置。如果检查PE文件中的.tls段,事情将变得更清晰:
0000000001007000 _tls segment para public 'DATA'use64
0000000001007000 assume cs:_tls
0000000001007000 ;org 1007000h
0000000001007000 _tls_start dd 0
0000000001007004 ; int threadedint
0000000001007004 ?threadedint@@3HA dd 0
0000000001007008 _tls_end dd 0
“threadedint”相对于.tls节起始位置的偏移确实是4。但是这些仍然没有解释编译器产生的指令如何访问线程局部变量。
这里诀窍就藏在接下来的三条指令当中:
mov ecx,cs:_tls_index
mov rax,gs:58h
mov rax,[rax+rcx*8]
这三条指令获取TEB中ThreadLocalStoragePointer的值并用_tls_index来索引其指向的空间。获得指针代表的地址在使用threadedint进行索引来合成一个完成的访问该线程所有threadedint变量的地址。
(其实可以这样认为:对于每个线程都有新分配了一块和.tls同样大小的内存,用ThreadLocalStoragePointer引用,这样该变量的值和偏移加起来就是变量的地址了)
采用C语言,编译器产生的代码将是下面的样子:
// This represents the ".tls" section
struct _MODULE_TLS_DATA
{
inttls_start;
intthreadedint;
inttls_end;
} MODULE_TLS_DATA, * PMODULE_TLS_DATA;
PTEB Teb;
PMODULE_TLS_DATA TlsData;
Teb =NtCurrentTeb();
TlsData = Teb->ThreadLocalStoragePointer[_tls_index ];
TlsData->threadedint = 42;
如果之前你使用过显式TLS,这里看起来是非常熟悉的。显式TLS典型范式就是在TLS槽中放置一个结构体的指针,然后在访问线程局部状态,每个线程的结构体实例都通过结构体指针进行访问。这里不同的地方是编译器和链接器合作(加载器)将你从显式进行这些操作中解脱出来;所有你需要做的就是使用__declspec(thread)声明一个变量,然后一切背后的事情就自然发生了。
从代码生成角度来看,隐式TLS变量的工作机制存在一条额外的曲线。你可能注意到示例中为X64版本中访问__declspec(thread)变量的代码;这是因为默认情况下,X86在构建exe文件时包含一个特殊的优化选项(/GA,Optimize for Windows Application,也许是有史以来编译器选项名字中最烂的一个),该优化假定_tls_index为0从而消除了对其的引用过程(这样加快了对线程局部变量的访问)。
该优化仅仅对进程的主模块起作用(一般是exe文件)。该假定成立的原因是加载器按照模块加载顺序为_tls_index指定序列值,而主模块将在第二个被加载,ntdll是第一个加载的模块(显然ntdll中不能使用__declspec(thread)变量,否则该模块将是0索引,即_tls_index值为0)。值得注意的是,在exe具有导出函数且使用了__declspec(thread)变量时,该优化将会导致应用程序随机崩溃。
以备参考,当/GA选项开启时,X86版编译生成如下指令:
mov eax,large fs:2Ch
mov ecx,[eax]
mov dwordptr [ecx+4], 2Ah ; 42
记得在X86系统中,fs的基地址引用TEB的首地址,ThreadLocalStoragePointer所在的偏移为0x2C。
注意这里并没有对_tls_index的引用;编译器假定使用0值。如果是X86平台下构建dll,该优化始终是关闭的,_tls_index将如之前那样来使用。
但是,__declspec(thread)变量背后的事情远不是编译器和链接器能搞定的。某某仍然需要为每个线程分配存储空间,这个某某就是加载器。更多关于加载器在这里所扮演的角色将在下次进行探讨。