线程本地存储TLS详解

博客的大部分原理转自:https://blog.csdn.net/zhangmiaoping23/article/details/44345341
本人自己把觉得自己需要的部分自己进行了综合。

TLS(Thread Local Storage)是为了多线程考虑其线程本身需要维持一些状态而设置的一种机制.
TLS在概念上并不复杂。常规设计是将所有对TLS的访问都通过TEB中的指针来进行间接访问,TEB操作系统定义的每个线程一份的数据结构,用于保存一些线程相关的信息。使用Tls可以用隐式或者显式两种方式
kernel32有一组可以显式使用TLS的函数:TlsGetValue、TlsSetValue、TlsAlloc、TlsFree。通过名字就很容易知道他们的具体功能是干嘛的。TlsAlloc用于为要维持的变量分配空间,TlsSetValue设置变量的值。其他两个也是类似如此。
由加载器、编译器和链接器来支持隐式使用线程局部存储,方法是在要维持状态的变量上加上_declspec(thread),用隐式可以很方便的不需要通过显式调用TlsAlloc函数,而是由加载器自动根据变量个数来分配空间。

TLS显式存储

从实现来看,显式TLS API是目前两类实现TLS方法中较简单的一种,因此这种方法很少涉及内部实现的可变部分。正如我上次提到的,显式TLSAPI主要是4个函数。其中最重要的两个是TlsGetValueTlsSetValue,分别负责设置和获取线程相关的数据。
其背后的核心机制是他们是使用dwTlsIndex为索引来访问TEB中两个数组的“dumb accessors”(内部使用2个数组来实现:TlsSlotsTlsExpansionSlots,这两个函数用于根据索引访问这两个数组)

先看下TlsAlloc和TlsGetValue原型
在这里插入图片描述
在这里插入图片描述

LPVOID TlsGetValue(
  DWORD dwTlsIndex
);

可以看出在显式使用这些API的的时候,是通过index来访问这些变量的,下面看下他们的源码

LPVOID __stdcall TlsGetValue(_In_ DWORD dwTlsIndex)
{
       PTEB Teb = NtCurrentTeb();//获取Teb
       Teb->LastErrorValue= 0;//可以看到我们调用GetLastError的时候是从TEB的LastErrorValue中获取 错误值 的
       if(dwTlsIndex < 64)
              //64个指针大小的空间
              return Teb->TlsSlots[dwTlsIndex];
       if(dwTlsIndex>= 1088){//440h
              //总共有1088个slot,超出就错误了 所以最多只支持1088个tls变量
              SetLastError(ERROR_INVALID_PARAMETER);
              return 0;
       }
       if(Teb->TlsExpansionSlots)//当 TlsIndex >= 64的时候 会去TlsExpansionSlots这个数组里去寻找值
              return Teb->TlsExpansionSlots[dwTlsIndex - 64];
       else
              return 0;

}

 

BOOL __stdcall TlsSetValue(_In_ DWORD dwTlsIndex, _In_ LPVOID lpTlsValue)
{
       PTEB Teb= NtCurrentTeb();//跟GetValue一样首先判断范围是否合理,假如 < 64 那么将TlsSlots对应index的值设置为我们的指针
       if( dwTlsIndex < 64 ){
              Teb->TlsSlots[dwTlsIndex]= lpTlsValue;
              return TRUE;
       }
       if(dwTlsIndex >= 1088){
              SetLastError(ERROR_INVALID_PARAMETER);
              return 0;
       }
       //处理扩展Slot的情况
       if( !Teb->TlsExpansionSlots ){
              //第一次进入需要为扩展Slot分配内存
              RtlAcquirePebLock();//进行加锁处理
              if(!Teb->TlsExpansionSlots){//先判断是否存在TlsExpansionSlots
                     LPVOIDTmp = RtlAllocateHeap(Teb->Peb->ProcessHeap, 8, 1024*sizeof(LPVOID));//若不存在 调用RtlAllocateHeap分出一块内存给TlsExpansionSlots使用
                     if(!Tmp){
                            //资源不足
                            RtlReleasePebLock();
                            SetLastError(0);
                            return FALSE;
                     }
                     Teb->TlsExpansionSlots= (PVOID*)Tmp;//分配的空间首地址给TlsExpansionSlots
              }
              RtlReleasePebLock();//释放锁
       }
       Teb->TlsExpansionSlots[dwTlsIndex- 64] = lpTlsValue;//将TlsExpansionSlots的值设置为我们的指针
       return TRUE;

}

TlsSolts在 TEB 的0xe10的位置,TlsExpansionSlots在0xf94的位置
在这里插入图片描述
在这里插入图片描述

TlsAlloc和TlsFree的实现正如你想象的那样:它们获得一个锁,查找未分配的Tls槽(如果找到就返回槽的索引,否则告诉调用者没有空余的槽了)。如果最初的64个槽用完了(TlsSlots用完)且TlsExpansionSlots指针为NULL,则TlsAlloc将会分配1024个TLS槽(每个槽为指针大小 == sizeof(PVIOD)),将这块内存清0,然后更新TlsExpansionSlots,使其引用这块内存。
在内部,TlsAlloc和TlsFree利用Rtl Bitmap来记录Tls槽的使用情况;bitmap中的每个位记录一个槽的使用情况(使用或未被使用)。这样既可以快速查找TLS槽的使用映射情况,同时节省了内存空间。

当一个线程正常退出,如果TlsExpansionSlots指针已经分配了内存,它将被释放。(当然,如果线程调用TerminateThread结束,该块内存就leak了。这也是无数为什么你要远离TerminateThread的原因之一)。

TLS隐式存储

编译器和链接器会搭配,将tls状态变量存放到.tls节中。在数据目录表中我们可以获取到它的RVA,但是首先我们先讲一下TLS目录的结构。
通过fs:[0x2c]可以获取到TLS的指针
在这里插入图片描述
TLS的结构

typedef struct _IMAGE_TLS_DIRECTORY32 {
    DWORD   StartAddressOfRawData;    //TLS初始化数据的起始地址
    DWORD   EndAddressOfRawData;      //TLS初始化数据的结束地址  两个正好定位一个范围,范围放初始化的值
    DWORD   AddressOfIndex;              //TLS 索引的位置 
    DWORD   AddressOfCallBacks;          //PIMAGE_TLS_CALLBACK函数指针数组的地址。这里回调函数类型为PIMAGE_TLS_CALLBACK,TLS目录指向一个以NULL结尾的callbacks数组(这些函数将按顺序调用)。
    DWORD   SizeOfZeroFill;         //填充0的个数
    DWORD 	Characteristic;				//保留,目前设置为0

} IMAGE_TLS_DIRECTORY32;

AddressOfCallBacks是线程建立和退出时的回调函数,包括主线程和其他线程。一个线程被建立或被销毁后,列表中的回调函数会被调用。需要注意的是,TLS数据初始化和TLS回调函数是在入口点之前执行(这个具体的实现我会在以后的博客中会讲)。

注意:IMAGE_TLS_DIRECTORY结构中的地址是虚拟地址,而不是RVA。这说明了假如程序不是运行在期望地址上时会被重定位

下面实战下:
在这里插入图片描述看下文件偏移
在这里插入图片描述

在这里插入图片描述StartAddressOfRawData地址是0x403000,EndAddressOfRawData是0x403200,说明TLS初始化数据的范围大小是0x200。AddressOfIndex的位置是0x402030,AddressOfCallBacks是0x402000。

编译器和加载器所做的工作

C运行时库中声明变量_tls_used的源代码位于tlssup.c文件中(与Visual Studio一起发布)。_tls_used标准的声明方式如下所示:

_CRTALLOC(".rdata$T")
const IMAGE_TLS_DIRECTORY _tls_used =
{
       (ULONG)(ULONG_PTR) &_tls_start, // start of tls data
       (ULONG)(ULONG_PTR) &_tls_end,  // end of tls data
       (ULONG)(ULONG_PTR) &_tls_index, // address of tls_index
       (ULONG)(ULONG_PTR)(&__xl_a+1), // pointer to call back array
       (ULONG) 0,                      //size of tls zero fill
       (ULONG) 0                       //characteristics
};

为了使用CRT提供的TLS回调支持,需要我们声明一个存放在以“.CRT$XLx“为名的节里面,这里x是一个位于A和Z之间的字母。例如,如下的代码片段:

  #pragma section(“.CRT$XLY”,long,read)
    extern “C” __declspec(allocate(.CRT$XLY”))
    PIMAGE_TLS_CALLBACK _xl_y = MyTlsCallback;

需要如此奇怪的节名是因为TLS回调指针需要进行内存排序的原因。为了理解这种特殊声明的作用,需要首先明白编译器和链接器是如何组织PE文件中的数据的。
PE文件中,除了头部数据,其它均是分不同节存储的,节就是具有相同属性(也保护属性)集合的内存区域。关键字__declspec(allocate(“section-name”))告诉编译器(这里应该是链接器,原文有错,下同,但仍然按原文翻译)在最终PE文件中其作用域内的内容放在指定的节内。编译器额外支持将相似名字的节合并为一个大节的功能。该功能通过使用节名前缀+$+任意字符串 的形式来激活。编译器将合并具有相同节名前缀的节一个大节

编译器对于相似节采用字典顺序进行合并(对 后 的 字 符 串 进 行 排 序 ) 。 这 意 味 着 在 内 存 中 , 位 于 节 “ . C R T 后的字符串进行排序)。这意味着在内存中,位于节“.CRT .CRTXLB”中的变量将在位于节“.CRT X L A ” 中 变 量 位 置 的 后 面 , 但 是 在 位 于 节 “ . C R T XLA”中变量位置的后面,但是在位于节“.CRT XLA.CRTXLZ”中的变量的前面。C运行时库利用编译器的这一特性来创建一个以NULL结尾的TLS回调数组(将节“.CRT X L Z ” 中 放 置 一 个 N U L L 指 针 ) 。 因 此 为 了 保 证 声 明 的 函 数 指 针 位 于 T L S 回 调 数 组 内 部 , 必 须 将 它 放 在 节 “ . C R T XLZ”中放置一个NULL指针)。因此为了保证声明的函数指针位于TLS回调数组内部,必须将它放在节“.CRT XLZNULLTLS.CRTXLx”中。

编译器和链接器将会自动为所有的__declspec(thread)变量放置在默认段.tls段,在最终的PE文件中这些变量将会被放置在_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_start是被放到了节最开始的位置,而_tls_end被放置在了.tls$ZZZ,即最后一个Section,这样在合并为同一个大区的时候,_tls_start和_tls_end就正好表示了开始和结束的地址(因为TLS存储局部变量是存放在连续地址上的)。
现在我们知道了在语言层次上__declspec(thread)是如何来工作的,接下来有必要了解下编译器产生的访问__declspec(thread)变量的支持代码。幸运的是这些支持代码非常直观。考虑如下测试程序:

 __declspec(thread) int threadedint = 0;

int __cdecl main(int ac,wchar_t**av)
{
	threadedint = 42;
	return 0;
}

编译产生的x64汇编代码如下:

 mov ecx,DWORD PTR _tls_index

mov rax,QWORD PTR gs:58h//ThreadLocalStoragePointer的位置

mov edx,OFFSET FLAT:threadedint

mov rax,QWORD PTR [rax+rcx*8]//ThreadLocalStoragePointer[index]

mov DWORD PTR[rdx+rax], 42

但是,如果我们在运行时查看代码将会是下面这个样子:

mov     ecx,cs:_tls_index

mov     rax,gs:58h

mov     edx,4

mov     rax,[rax+rcx*8]

mov     dword ptr [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,所以使用_declsprc(thread)声明的变量的时候,使用变量的地址解析成tls变量的偏移了,那么它又是怎么使用这个偏移的呢?
这里诀窍就藏在接下来的三条指令当中:

mov     ecx,cs:_tls_index

mov     rax,gs:58h

mov     rax,[rax+rcx*8]

这三条指令获取TEB中ThreadLocalStoragePointer的值并用_tls_index来索引其指向的空间。获得指针代表的地址在使用threadedint进行索引来合成一个完成的访问该线程所有threadedint变量的地址。
(其实可以这样认为:对于每个线程都有新分配了一块和.tls同样大小的内存,用ThreadLocalStoragePointer引用,这样该变量的值和偏移加起来就是变量的地址了。其实就是ThreadLocalStoragePointer[_tls_index]指向的是这个线程的tls变量的存储地址,加上某个tls变量的偏移就是指向本变量所拥有的空间了)。
采用C描述,编译器产生的代码将是下面的样子,用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变量的工作机制存在一条额外的曲线。你可能注意到示例中为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)变量背后的事情远不是编译器和链接器能搞定的。某某仍然需要为每个线程分配存储空间,这个某某就是加载器。

加载器所做的工作

上次说了编译器和链接器为访问__declspec(thread)扩展类变量所使用的生成代码的机制。尽管此时它们已经为隐式TLS布置了舞台,但为了使整体能够工作,仍然需要加载器这个组件来提供必需的运行时支持。

具体的,加载器将负责为每个模块分配TLS索引值,为每个线程的TEB中的ThreadLocalStoragePointer分配内存空间。此外加载器还需要为每个模块分配TLS存储空间。

  1. 进程初始化阶段,为变量_tls_index分配索引值,确定每个模块所需的TLS空间内存的大小,然后调用TLS和DLL初始化函数(同一模块,先调用TLS初始化函数后调用DllMain初始化函数)(这里说的我会在我以后的博客详细说明,这篇文章并没有代码讲解)。
  2. 线程初始化阶段,为每一个使用了TLS的模块分配TLS内存并初始化,根据使用TLS的模块数目为当前线程分配ThreadLocalStoragePointer数组,然后将各个模块的TLS内存和ThreadLocalStoragePointer数组中的对应项相关联。然后为当前线程调用TLS初始化函数DLLMain初始化函数
  3. 在线程终止的时候,调用TLS初始化函数DLLMain函数(根据参数确定是线程终止),释放当前线程中每个模块对应的TLS内存,然后释放ThreadLocalStoragePointer数组
  4. 在进程终止时,也调用TLS和DLlmain初始化函数(可以把它们看成dwReason对应的不同值,我们在处理dwReason的时候不是会进行switch,分别在线程开始,结束,进程开始,结束时的对应处理函数吗,这里也就是类似的结果,不过每次是先进行TLS的初始化,然后是DllMain)。

除了进程初始化以外,其它大部分操作都非常直观。进程初始化主要是由ntdll中的LdrpInitializeTlsLdrpAllocateTls两个例程来完成的。
当所有静态连接的dll文件被载入之后,所有其它初始化例程被调用之前,LdrpInitializeTls被调用(说明优先级比较高,是关键的部分)。基本上,该函数要遍历所有加载模块,为每一个具有有效TLS目录的模块统计出它使用的TLS内存的大小。对每一个使用了TLS的模块,会分配一个数据结构来记录该模块所使用的TLS内存大小并为其分配的索引号(_tls_used)。(早在Xp系统中,LDR_DATA_TABLE_ENTRY结构中的TlsIndex域貌似就没有使用了。而在WINME系统中将该值误用为模块的TLS索引,因此假定该值为-1在WINME系统中是不可靠的)

使用了TLS的模块在调用LdrpInitializeProcess(即LdrPEStartup)的过程中将被标记为始终位于内存当中(这种模块的LoadCount值为0xFFFF)。实际中,这个不是什么问题,因为这种模块必须是静态链接的或是被主模块隐式依赖,不可能中途退场。

在函数LdrpInitializeTls为模块分类了TLS索引之后,将调用LdrpAllocateTls为初始线程初始化TLS值

这时,进程继续初始化,最后每个模块的TLS初始化和DLLmain初始化函数会被调用。(注意应用程序主模块可以有多个TLS回调函数,但是没有DLLmain函数)

一个有意思的事情是同一个DLL模块的TLS初始化函数始终在DLL初始化函数之前调用。(这个过程按顺序进行,例如先A.dll的TLS初始化,A.dll的DLLmain初始化,B.dll的TLS初始化,B.dll的Dllmain初始化,以此类推)。这意味着在TLS初始化函数中要慎重使用CRT的函数((as the C runtime is initialized before the user’s DllMain routineis called, by the actual DLL initializer entrypoint, such that the CRT will notbe initialized when a TLS initializer for the module is invoked).)。这将非常危险,因为全局数据还没有被创建;除非导入被跳过,否则模块将处于一个完全未初始化的状态。

另一个值得一提的有关加载器对TLS支持的方面是PE文件格式标准中,IMAGE_TLS_DIRECTORY结构中的SizeOfZeroFill域并没有被链接器和加载器使用。这意味着在现实中,所有TLS模板数据都将初始化,TLS内存块的大小不像PE文件格式标准所陈述的的那样包含域SizeOfZeroFill

一些软件滥用TLS回调来用于反调试的目的(通过创建一个TLS回调项来在入口函数获得执行权之前执行代码),虽然可以,但是实际中这点将非常明显,因为大部分PE文件都不会使用TLS回调。

  • 6
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值