📄 漫谈兼容内核之二十三关于tls.txt
字号:
if (LdrpTlsCount > 0)
{
LdrpTlsArray = RtlAllocateHeap(RtlGetProcessHeap(),0,
LdrpTlsCount * sizeof(TLS_DATA));
if (LdrpTlsArray == NULL)
{
return STATUS_NO_MEMORY;
}
ModuleListHead = &NtCurrentPeb()->Ldr->InLoadOrderModuleList;
Entry = ModuleListHead->Flink;
while (Entry != ModuleListHead)
{
Module =
CONTAINING_RECORD(Entry, LDR_MODULE, InLoadOrderModuleList);
if (Module->LoadCount == -1 && Module->TlsIndex >= 0)
{
TlsDirectory = (PIMAGE_TLS_DIRECTORY)
RtlImageDirectoryEntryToData(Module->BaseAddress, TRUE,
IMAGE_DIRECTORY_ENTRY_TLS, NULL);
assert(Module->TlsIndex < LdrpTlsCount);
TlsData = &LdrpTlsArray[Module->TlsIndex];
TlsData->StartAddressOfRawData = (PVOID)TlsDirectory->StartAddressOfRawData;
TlsData->TlsDataSize = TlsDirectory->EndAddressOfRawData -
TlsDirectory->StartAddressOfRawData;
TlsData->TlsZeroSize = TlsDirectory->SizeOfZeroFill;
if (TlsDirectory->AddressOfCallBacks)
TlsData->TlsAddressOfCallBacks = *TlsDirectory->AddressOfCallBacks;
else
TlsData->TlsAddressOfCallBacks = NULL;
TlsData->Module = Module;
/* FIXME: Is this region allways writable ? */
*(PULONG)TlsDirectory->AddressOfIndex = Module->TlsIndex;
CHECKPOINT1;
}
Entry = Entry->Flink;
}
}
return STATUS_SUCCESS;
}
可想而知,对于把所有.tls段合并在一起的静态TLS,每个线程都需要有个副本;但是线程是随时都可以创建的,所以必须把来自各模块映像.tls段的原始映像合并保存起来。然而各个.tls段既然分别存在于已被装入的模块映像中,而这些.tls段又不属于任何具体的线程,在运行不会被修改,就无需再单独存储一份合并以后的版本,但是需要有个合并的目录,以便需要时可以方便地找到这些原始的版本。这正是LdrpInitializeTlsForProccess()要做的。
所以,为实现静态TLS,每个进程的用户空间需要一个类似于目录的指针数组LdrpTlsArray[],其中的每个指针都指向一个TLS_DATA数据结构,而每个TLS_DATA数据结构则代表着一个模块的.tls段。
typedef struct _TLS_DATA
{
PVOID StartAddressOfRawData;
DWORD TlsDataSize;
DWORD TlsZeroSize;
PIMAGE_TLS_CALLBACK TlsAddressOfCallBacks;
PLDR_MODULE Module;
} TLS_DATA, *PTLS_DATA;
这个数据结构本身并不提供实际的TLS,其指针StartAddressOfRawData才指向实际的存储空间、即各个模块的.tls段。每个.tls段的开头可以有一些非0初值的TLS变量,TlsDataSize说明了这一部分的大小。其余则都是初值为0的TLS变量,字段TlsZeroSize说明了这一部分的大小。此外,含有.tls段的模块可能同时提供一些用于初始化或需要在特定条件下执行的“回调”函数,指针TlsAddressOfCallBacks指向一个用来提供有关函数指针的IMAGE_TLS_CALLBACK数据结构。至于指针Module,则只是说明这个.tls段来自哪一个模块,同时也便于从具体模块的数据结构中获取更多、更详尽的有关信息。例如,其中的FullDllName就提供了该模块的映像文件名。
上面的程序中根据.tls段的个数LdrpTlsCount分配了数组LdrpTlsArray[]所需的空间。注意LdrpTlsCount只反映了静态装入的.tls段的个数,而动态装入的模块到底会有多少是无法预测的。
然后扫描本进程的“已装入模块队列”、即PEB下面的InLoadOrderModuleList,为每个模块的.tls段(如果有的话)建立LdrpTlsArray[]中的目录项。注意此时所有静态装入的模块都已经在这个队列中,所以这是一次完全的扫描。另一方面,凡是带有.tls段、或者说.tls段的大小非0的模块,前面都已经为其分配了TLS索引号TlsIndex,所以在目录中的位置也已经确定。
从程序中可以看出,具体的原始TLS映像仍在各模块的内存映像中,但是现在有了一个统一的目录LdrpTlsArray[]。
再回到LdrPEStartup()的代码,下面的LdrpAttachProcess()主要是以常数DLL_PROCESS_ATTACH为参数调用各个DLL模块的DllMain(),与静态TLS的实现没有什么关系。而LdrpTlsCallback()则是以DLL_PROCESS_ATTACH为参数调用各模块的TLS回调函数(如果有的话),我们在这里就不深入下去了。
这些操作都是进程一级的,所以只是进程中的第一个线程才加以执行,以后创建的线程就不再执行这些操作了。
从LdrPEStartup()返回到__true_LdrInitializeThunk(),在完成了所有别的操作以后,对于静态TLS而言,就是线程一级的操作了,这是每个线程都要执行的,目的在于为当前线程复制一个本地的静态TLS副本。这是在LdrpAttachThread()里面完成的:
[__true_LdrInitializeThunk() > LdrpAttachThread()]
NTSTATUS
LdrpAttachThread (VOID)
{
PLIST_ENTRY ModuleListHead;
PLIST_ENTRY Entry;
PLDR_MODULE Module;
NTSTATUS Status;
DPRINT("LdrpAttachThread() called for %wZ\n",
&ExeModule->BaseDllName);
RtlEnterCriticalSection (NtCurrentPeb()->LoaderLock);
Status = LdrpInitializeTlsForThread();
if (NT_SUCCESS(Status))
{
ModuleListHead = &NtCurrentPeb()->Ldr->InInitializationOrderModuleList;
Entry = ModuleListHead->Flink;
while (Entry != ModuleListHead)
{
Module = CONTAINING_RECORD
(Entry, LDR_MODULE, InInitializationOrderModuleList);
if (Module->Flags & PROCESS_ATTACH_CALLED &&
!(Module->Flags & DONT_CALL_FOR_THREAD) &&
!(Module->Flags & UNLOAD_IN_PROGRESS))
{
TRACE_LDR("%wZ - Calling entry point at %x for thread attaching\n",
&Module->BaseDllName, Module->EntryPoint);
LdrpCallDllEntry(Module, DLL_THREAD_ATTACH, NULL);
}
Entry = Entry->Flink;
}
Entry = NtCurrentPeb()->Ldr->InLoadOrderModuleList.Flink;
Module = CONTAINING_RECORD(Entry, LDR_MODULE, InLoadOrderModuleList);
LdrpTlsCallback(Module, DLL_THREAD_ATTACH);
}
RtlLeaveCriticalSection (NtCurrentPeb()->LoaderLock);
DPRINT("LdrpAttachThread() done\n");
return Status;
}
显然,这里LdrpInitializeTlsForThread()的作用就是为当前线程复制一个“本地”的TLS副本:
[__true_LdrInitializeThunk() > LdrpAttachThread() > LdrpInitializeTlsForThread()]
static NTSTATUS
LdrpInitializeTlsForThread(VOID)
{
PVOID* TlsPointers;
PTLS_DATA TlsInfo;
PVOID TlsData;
ULONG i;
DPRINT("LdrpInitializeTlsForThread() called for %wZ\n", &ExeModule->BaseDllName);
if (LdrpTlsCount > 0)
{
TlsPointers = RtlAllocateHeap(RtlGetProcessHeap(),0,
LdrpTlsCount * sizeof(PVOID) + LdrpTlsSize);
if (TlsPointers == NULL)
{
DPRINT1("failed to allocate thread tls data\n");
return STATUS_NO_MEMORY;
}
TlsData = (PVOID)TlsPointers + LdrpTlsCount * sizeof(PVOID);
NtCurrentTeb()->ThreadLocalStoragePointer = TlsPointers;
TlsInfo = LdrpTlsArray;
for (i = 0; i < LdrpTlsCount; i++, TlsInfo++)
{
TRACE_LDR("Initialize tls data for %wZ\n", &TlsInfo->Module->BaseDllName);
TlsPointers[i] = TlsData;
if (TlsInfo->TlsDataSize)
{
memcpy(TlsData, TlsInfo->StartAddressOfRawData, TlsInfo->TlsDataSize);
TlsData += TlsInfo->TlsDataSize;
}
if (TlsInfo->TlsZeroSize)
{
memset(TlsData, 0, TlsInfo->TlsZeroSize);
TlsData += TlsInfo->TlsZeroSize;
}
}
}
DPRINT("LdrpInitializeTlsForThread() done\n");
return STATUS_SUCCESS;
}
注意这里分配的空间大小是LdrpTlsCount * sizeof(PVOID) + LdrpTlsSize,这是一个大小为LdrpTlsCount的指针数组加上合并以后的整个静态TLS的大小。然后就根据静态TLS目录把所有.tls段的原始副本收集汇总并复制到所分配的缓冲区中。这样,就为一个线程构建了一个合并的静态TLS副本。至于指针数组TlsPointers[ ]中的每个指针,则各自指向相应.tls段的数据在这个副本中的起点。
还要注意,指针TlsPointers填写在当前线程TEB的字段ThreadLocalStoragePointer中。所以,找到了一个线程的TEB,就可以根据这个指针找到其静态TLS副本。
由于每个线程在启动时都要执行__true_LdrInitializeThunk(),因而都要执行这个函数,所以就都会有各自的静态TLS副本。
回到LdrpAttachThread(),后面以DLL_THREAD_ATTACH为参数对各个模块调用LdrpCallDllEntry()和LdrpCallDllEntry(),这里就不看了。
那么,在应用程序中怎样访问静态TLS变量呢?如前所述,这需要编译工具和库程序的配合。可以想像,库程序应提供一个类似于__errno_location()那样的函数,但是以目标变量所在模块的索引号和目标变量在其.tls段内部的位移为参数。这样,只要先找到当前线程的TEB,就可以找到其静态TLS副本。然后,以具体TLS变量所在模块的索引号为下标可以通过其指针数组找到目标所在的.tls段副本,再用目标TLS变量在.tls段内的位移就可以找到其地址。
可见静态TLS其实一点不比动态TLS简单,反倒复杂得多。
早期的程序员也许觉得使用动态TLS需要在程序中通过TlsSetValue()、TlsGetValue()一类的函数才能访问TLS变量,不如使用静态TLS那样方便;但是现在大家都已经习惯了面向对象的程序设计,而面向对象的程序设计本来就得通过对象所提供的“方法(method)”进行访问,因此也就不感到不方便了。
作为一个例子,我们再看一下errno。本来,W32 API的界面并不需要使用errno,也不存在为此而需要使用TLS的问题;但是微软为了实现对POSIX子系统的支持而只好也来实现一个标准C程序库的界面,因此就也有了如何实现errno的问题,下面的代码仍取自ReactOS:
#define errno (*__PdxGetThreadErrNum())
显然,__PdxGetThreadErrNum()相当于__errno_location()。再看它是如何实现的:
int * __PdxGetThreadErrNum(void)
{
return &(((__PPDX_TDATA)
(NtCurrentTeb()->TlsSlots[__PdxGetProcessData()->TlsIndex]) )->ErrNum);
}
可见,这实际上是在用动态TLS来实现静态TLS,因为TEB中的TlsSlots[]是用于动态TLS的。
最后,表面上TLS的实现都是用户空间的事,似乎与内核无关;但是实际上内核为用户空间TLS的实现提供了基础。这是因为,无论是与TEB挂勾,还是与用户空间堆栈挂勾,还是使用段寄存器寻址,实际上都是建立在分段存储的基础上;但实际上正是内核在维持段描述表GDT或LDT中段描述项的切换,并使段寄存器FS或GS在CPU进入用户空间时持有相应的选择项。
此外,在系统调用NtSetInformationThread()中有两个选项是与TLS有关的,一个是ThreadZeroTlsCell,另一个是ThreadSetTlsArrayAddress。前者的作用据说是将一个(大概是静态)TLS变量清0。后者则是为“TLS数组”、应该是当事线程的静态TLS副本吧、指定一个地址。为此,在KTHREAD数据结构中还设了一个字段TlsArray,但是在ReactOS的代码中未见使用。
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -