WINDOWS+PE权威指南读书笔记(15)

本文深入探讨了线程局部存储(TLS)技术,包括动态TLS和静态TLS的实现原理、API函数的使用,以及在Windows系统中的进程与线程创建过程。动态TLS通过TlsAlloc、TlsGetValue、TlsSetValue和TlsFree等函数进行管理,而静态TLS则在PE文件的“.tls”节预先声明。TLS技术有效地解决了多线程程序中全局变量的同步问题,简化了编程过程。
摘要由CSDN通过智能技术生成

目录

线程局部存储

Windows 进程与线程:

Windows 体系结构:

进程与线程创建:

进程环境块 PEB:

线程环境块TEB:

什么是线程局部存储:

动态线程局部存储:

动态TLS 实例:

获取索引TIsAlloc:

按索引取值 TlsGetValue:

按索引存储 TlsSetValue:

释放索引 TIlsFree:

静态线程局部存储:(.tls节)

TLS 定位:

TLS 目录结构 IMAGE_TLS_DIRECTORY32:

静态TLS 实例分析:(结合上面的TLS目录结构)

TLS 回调函数:

测试静态 TLS 下的线程存储初始化回调函数:

小结:


线程局部存储

本章介绍线程局部存储(Thread Local Storage ,TLS)技术,它实现了线程内局部变量的存储访问。在该技术下定义的变量能被同一个线程内部的各个函数所调用,同时,杜绝了其他线程对这些变量的访问。该技术解决了线程同步访问全局变量时带来的诸多问题,方便了程序员对多线程程序的设计。

Windows 进程与线程:

Windows 体系结构:

现代的操作系统都是基于分层设计思路设计的。总体上讲,Windows 体系结构包含用户模式部分和内核模式部分。

内核模式负责向用户模式提供可供操作的接口,这种模式下,各模块独立操作,通过良好的消息沟通机制进行彼此间的通信。

应用程序平时运行在用户模式下,当需要用到系统内核所提供的服务时,操作系统通过特殊的指令将指令地址指针 eip 转移到内核模式下执行,完成后再将控制权交还给用户模式下的代码。这种运作机制可以有效地保护 Windows 操作系统的核心代码,使其不受应用程序的错误所影响,具有良好的稳定性和可扩展性。

人们把 Windows 安全层次设置为Ring(环状) 结构,最外层环被命名为 Ring3,表示用户模式; 最里层环被命名为 Ring0,表示内核模式。Windows 体系结构如图 9-1 所示。

如图 9-1 所示,Windows 体系结构由用户模式和内核模式组成。最终由内核模式的硬件抽象层负责处理与硬件相关的数据通信(当然,有的驱动程序本身也具备硬件抽象层的功能,

所以图上设备驱动程序与硬件抽象层有重叠的部分)。

从上向下看,用户开发的应用程序 a.exe 和b.exe 调用了 Windows 子系统的动态链接库 ;其中大部分函数被转移到 ntdll.dll 中实现 , 有一些则直接进入内核模式的 Win32k.sys,从用户模式到内核模式的转换使用中断调用方式。

内核模式执行体 ntoskrnl.exe 中的导出函数在 ntdll.dll 中都有存根。如果需要与硬件进行通信。则通过调用内核模式下硬件驱动程序相关函数(或由硬件驱动程序转由硬件抽象层)实现。用户也可以直接使用自定义 DLL 动态链接库开发程序,自定义动态链接库通过调用 ntdll.dll 中的函数或转由 Windows 子系统动态链接库调用 ntdll.dll 中的函数,如图中 c.exe 和d.exe 所示。

通常情况下,用户在应用程序中会使用 Win32 API 调用相关函数,对大部分函数的调用最终都转到 ntdll.dll 中。ntdll.dll 是连接用户模式代码和内核模式系统服务的桥梁,对于内核模式中提供的每一个服务,在 ntdll.dll 中都有一个相应的存根函数。该存根没有代码的具体实现,只提供参数传递和跳转功能,大部分函数的运行都转到了内核模式的 ntoskrnl.exe 中进行。

进程与线程创建:

下面分两部分简单描述进程与线程的创建。先来讨论的是内核模式下进程与线程的创建,之后再讨论用户模式下进程与线程的创建。

  1. 内核模式下进程与线程的创建过程

内核模式下进程的创建:(从函数 NtCreateProcess 开始)

在内核中,一个进程的创建是从函数 NtCreateProcess 开始的。该函数位于文件 ntoskmlexe中,该文件位于 %windir%\system32。它对用户传进的部分参数进行简单处理,然后交给函数 NtCreateProcessEx。

NtCreateProcessEx函数的原型如下:

该函数检查句柄 ProcessHandle 是否可写,然后将创建工作交给了函数,PspCreateProcess 系统中所有进程的创建工作均是由该函数负责完成的。

其创建进程的步骤大致如下:

步骤1 调用函数 ObCreateObject 创建 Windows 执行体内核对象 EPROCESS,该对象为新进程的进程对象,该对象归系统所有,并由系统统一管理。

步骤2 调用函数 ObReferenceObjectByHandle 获取内存区对象的指针 SectionHandle。

步骤3 根据传入的端口参数内容初始化新进程对象的相应字段。

步骤4 如果父进程不为空,则创建一个全新的地址空间。

步骤5 调用函数 KeInitializeProcess 初始化新进程对象的基本优先级、Affinity、进程页表目录和超空间的页帧号。

步骤6 调用函数 PspInitializeProcessSecurity 从父进程复制一个令牌初始化新进程的安全属性,并设置进程优先级。

步骤7 调用函数 MmInitializeProcessAddressSpace 初始化新进程的地址空间,并将映像映射到地址空间,同时加载 Ntdll.dll 和系统范围的国家语言支持 (NLS) 表。

步骤8 调用函数 ExCreateHandle 在 CID 句柄表中创建一个进程 ID 项。CID 是客户身份号(Client ID),一般由进程号和线程号两部分组成,用于识别某个进程。

步骤9 审计本次进程创建行为,并构造PEB,将新进程加入全局进程链表 PsActiveProcessHead 中 。

步骤10 调用函数 ObInsertObject 将新进程对象插入当前进程的句柄表中。

步骤11 设置进程基本优先级、访问权限、创建时间。

通过以上步骤,进程对象创建完成。但进程是有 “惰性” 的,离开了线程,进程的空间只是一个固定在内存中的死的空间而已; 直到它的第一个线程被创建和初始化之后,进程中的代码才能被真正地运作起来。

内核模式下线程的创建:(从函数 NtCreateThread 开始)

与进程的创建类似,内核中线程的创建是从函数 NtCreateThread 开始的。该函数对用户传递来的参数进行简单检查,并复制 InitialTeb 到局部变量 CaptureInitialTeb 中 ; 处理完参数后,交给函数 PspCreateThread,该函数是真正创建线程的函数。

PspCreateThread的原型如下:

内核中创建线程的步骤大致如下:

步骤1 根据进程句柄获得进程对象并将其放到局部变量中。

步骤2 调用函数 ObCreateObject 创建线程对象 ETHREAD,并初始化。

步骤3 获取进程的 RundownProtect 锁,以免线程创建过程中该进程被当掉。

步骤4 创建线程环境块 (TEB),并用 InitTeb 函数进行初始化; 然后,利用 ThreadContext 中的程序指针 eip 设置线程的启动地址 StartAddress 字段,并且将 ThreadContext 的 eax 寄存器设置到线程的 Win32StartAddress 字段。完成这些操作后,调用 KeInitThread 函数初始化新线程的一些属性。

步骤5 锁住进程,并将进程的活动线程数量加 1,然后调用函数 ObInsertObject 把新线程加入到进程的线程链表中。

步骤6 调用函数 KeStartThread,新线程即可运行。

  1. 用户模式下进程与线程的创建过程

用户模式下进程的创建:

在用户层,创建一个进程通常使用 kernel32.dll 中的函数 CreateProcess 来完成。该函数一且返回成功,新的进程和进程中的第一个线程就建立起来了。

从用户层的角度看进程创建的大致步骤如下:

步骤1 调用函数 CreateProcessW 打开指定的可执行映像文件。

步骤2 调用 ntdll.dll 中的存根函数 NtCreateProcessEx,该函数利用处理器的陷阱机制切换到内核模式下。

在内核模式下,系统服务分发函数 KiSystemService 获得 CPU 控制权,它利用当前线程指定的系统服务表,调用执行体层的函数 NtCreateProcessEx,开始内核过程的进程创建。执行体层的进程对象建立以后,进程的地址空间完成初始化,EPROCESS 中的进程环境块(PEB) 也已完成初始化。(进程一建立就完成了初始化)

例如,跟踪一段 CreateProcess 代码,可以看到这种陷阱机制:

以上代码是函数 Ntdll.ZwCreateSection 的调用过程,前面是一系列的函数参数入栈操作,最后通过 call 指令调用函数。进入到函数内部看,函数 Ntdll.ZwCreateSection 是一段代理代码,通过对 eax 赋值后,为 edx 指定偏移地址,即可实施 call ntdllL.KiFastSystemCall 调用。如下所示;

其中 eax 存放系统服务号。从以上反汇编代码中可以看到,系统最终通过指令SYSENTER (Windows XP 系统) 或int 2E (Windows 2000 系统) 进入内核态执行相应程序。

步骤3 PEB 创建完成后,意味着进程的创建暂时告一段落。这时候还必须为进程创建第一个线程,通过调用函数 NtCreateThread 构造一个可供第一个线程运行的环境,如堆的大小、初始线程栈的大小等。

这些值的默认初始值来自于 PE 文件头部相应的字段,ntdll.dll 中的该函数依旧将任务交由执行层的 NtCreateThread 来完成。执行体层的线程对象 ETHREAD 建立以后,线程的ID、TEB 等也就完成了初始化。

步骤4 进程的第一个线程的启动函数是 kernel32.dll 中的 BaseProcessStart 函数 (其他线程则是调用 BaseThreadStart 函数)。此时的线程是被挂起的,要等到进程完全初始化完成后才真正开始。

注意:至此,用户模式下的进程创建实际上才刚刚开始,因为最终的进程要交给 Windows 子系统来运行,并由子系统维护进程的状态、进程的消息等信息。

步骤5 kernel32.dll 向 Windows 子系统发送一个消息,该消息包含了刚建立的进程和线程的相关信息 , Windows 子系统 csrss.exe 接收到此消息后,开始在子系统内部建立进程环境 ;最后,以显示应用程序启动光标作为进程环境创建结束。(也就是说应用程序的启动光标出现时windows下的进程创建就结束了)

步骤6 当 Windows 子系统已经知道并登记了新进程和线程后,先前挂起的初始线程被允许恢复执行。

用户模式下线程的创建:

在内核中,新线程的启动例程是 KiThreadStartup 函数,从 WRK 中可以获取到该函数的代码:

KiThreadStartup 函数首先将中断请求级别 (Interrupt ReQuest Level,IRQL) 降低到 APC_LEVEL,然后进入内核模式调用系统初始的线程函数 PspUserThreadStartup。函数 PspUserThreadStartup 通知缓存管理器预取可执行映像文件的页面,即该进程上一次启动的前 10s 内引用到的页面。读入后,将一个用户模式 APC 插入线程的用户 APC 队列中,此 APC 例程指向 ntdll.dll 的 LdrInitializeThunk 函数,然后函数 PspUserThreadStartup 返回 。

步骤7 当函数 KiThreadStartup 返回到用户模式时,由 PspUserThreadStartup 插入的 APC 也已被交付,于是函数 Ntdll.LdrinitializeThunk 被调用,该函数是 PE 映像加载器的初始化函数。

完成初始化工作以后,加载 PE 映像中任何必要的 DLL,并调用这些 DLL 的入口函数。最后,当 LdrInitializeThunk 返回到用户模式 APC 分发器时,该线程开始在用户模式下执行 ; 然后,它调用用户栈中的线程启动函数。至此,进程与线程全部建立完成,开始执行用户空间中的代码。

进程环境块 PEB:

操作系统会为每个进程设置一个数据结构,用来记录进程的相关信息。在 NT 中,该结构可以从进程空间的 FS:[0x30] 处找到。

PEB 描述的信息主要包括 : 进程状态、进程堆、PE 映像信息等:

其中有一个很重要的字段 Ldr,该字段指向的结构中记录了进程加载进内存的所有模块的基地址,通过 Ldr 指向的三个链表就可以找到 kernel32.dll 的基地址 (为什么要找它的基地址? 请阅读第 11 章动态加载技术)。

以下是进程环境块PEB的完整定义:(在操作系统中是PCB)

其中,偏移 +0040h 处为指向一个 RTL_BITMAP 数据结构的指针,该数据结构定义如下:

显然,其目的是要提供一个缓冲区,而真正的缓冲区在 Buffer 所指的地方。通常情况下,该值为PEB 中的 TlsBitmapBits[2],但是有需要时也不排斥采用别的缓冲区,两个 32 位长的字只能提供 64 个标志位。

线程环境块TEB:

以下是线程环境块的完整定义;

偏移 +0E10h 处的字段 TlsSlots[] 是个无类型的指针数组(在后面还会介绍到,这个指针数组称为TLS 存储槽),其大小为 40h 字节。也就是说,一个线程同时存在的动态 TLS 不能超过 64 项。

如果某一项动态 TLS 数据的大小不超过 4 个字节 (PVOID 数据类型占用 4 个字节),那么直接就可以存储在这个数组中,作为这个数组的一个元素。如果是存储大于 4 个字节的数据,由于单个存储槽中无法存放大于 4 个字节的数据,那就必须为之动态分配存储缓冲区,而把动态申请获取到的缓冲区的地址存储在这个数组中。通过这样的方式就可以大大扩充动态 TLS 的容量了。

字段 ThreadLocalStoragePointer 用于静态 TLS,操作系统会根据静态 TLS 目录把所有 .tls 段的原始副本收集汇总,并复制到所分配的缓冲区中。这样,就为一个线程构建了一个合并后的静态 TLS 副本,字段 ThreadLocalStoragePointer 即指向该副本的起始位置。(就是把分开的 .tls 段内容复制到一个缓冲区中,称之为合并)

什么是线程局部存储:

线程局部存储 (Thread Local Storage,TLS) 很好地解决了多线程程序设计中变量的同步问题。例如,编写一个从网络上下载文件的程序,既可以将其设置为单线程,也可以设计为多线程同时下载。尽管每个线程下载的内容不同,但每个线程下载代码逻辑是相同的,这就是多线程程序设计的普遍特点。

程序员通过使用TLS 机制,可以实现让程序拥有全局变量,但在不同的线程里却对应有不同的值。也就是说,进程中的所有线程都可以拥有全局变量,但这些变量其实是特定对某个线程才有意义的。

下载线程具有对文件写人的功能,写人时每个线程必须知道写入文件的偏移及字节数。这两个变量对不同的线程其值是不一样的。在这种情况下,把每一个线程所使用的文件偏移和字节数储存在 TLS 中,将会变得十分方便。当线程需要写文件时,它会从 TLS 中获取到本线程要写和文件的偏移和字节数。

TLS 机制最重要的地方在于 : 用来向目标文件写人内容的代码在所有线程中都是一样的,而从 TLS 中取出的文件偏移和字节数却各不相同。

实现 TLS 比较常见的办法是在进程中建立一个全局表,通过线程的 ID 去查询相应的数据结构,因为每个线程的 ID 是不一样的,所以查到的数据也自然就不一样了。

为了帮助读者理解上面一段话,笔者写了一个模拟下载写和的代码:

以上只是简单模拟了多线程可能的设计,注意,这里没有用到 TLS 技术。如果将这个程序用到实际的环境中,下载会出现数据同步问题。主程序通过循环建立的 1000 个线程访问到了同一全局变量: 文件偏移和写入字节。这是不符合多线程设计要求的。举例当某个线程调用函数 calcDownloadPara 设置该线程要写入文件的偏移和大小(这两个变量是全局变量)刚完成时,另外一个线程正在调用函数 DWToFile 准备下载写入,那么另外那个线程曾经执行过的影响了两个全局变量值的 calcDownloadPara 函数调用就是失效的。

要想让线程同步,开发者必须在以上代码中加入很多附加代码对变量实施约束(如使用代码免重入机制,使用变量锁等),这样才能保证程序设计足够合理。如果将上述代码中用到的全局变量设置为由TLS 存储,一切问题迎刃而解。程序员最受益的地方是不需要加入任何同步代码,即可满足程序功能设计的要求,所有同步操作由操作系统在内部自行完成,这就是TLS 技术。

TLS 技术分为两种:

口 动态线程局部存储技术

口 静态线程局部存储技术

TLS 技术的分类主要依据是线程局部存储的数据所用空间在程序运行期,操作系统完成的是动态申请还是静态分配。动态线程局部存储通过四个 Win32 API 函数实现对线程局部数据的存储 ,而静态线程局部存储则通过预先在 PE 文件中声明数据存储空间,由 PE 加载器加载该 PE 进入内存后为其预留存储空间实现。

动态线程局部存储:

你可以放弃使用TLS,因为你对自己设计的程序有比较全面的把握。你清楚自己设计的进程里有总共有多少个线程,每个线程都使用了哪些数据结构,内存空间的申请、释放都在你的掌控之下,全局变量的访问全部采用了同步技术,那是没有问题的。

如果你是一个 DLL 的开发者,你无法确定调用这个 DLL 的宿主程序里到底都多少个线程,每个线程的数据是如何定义的,这时,是你使用动态线程局部存储技术的最佳时机。

动态 TLS 存在以下四个 API 函数:

口TlsAlloc

口TlsGetvalue

口TlsSetValue

口TlsFree

应用程序或 DLL 在合适的时候会调用这四个函数,通过索引对进程中每个线程的存储区进行统一操作。它们位于动态链接库文件 kernel32.dll 中。

动态TLS 实例:

首先看以下这段代码,该实例完成了对线程运行时间的显示。详细见代码清单 9-1:

主程序首先调用函数 TlsAlloc 向进程申请一个索引,以便为每个线程预留保存全局变量 hTlsIndex 不同值的空间(因为每个线程中该索引所在的空间都会清空并保留备用)。

接下来在一个循环里使用 CreateThread 函数连续创建了 4 个相同代码的线程_tFun (117~127 行)。

每个线程执行相同的步骤如下:

步骤1 执行函数 _initTime,初始化每个线程各自存储在TLS 存储槽里的全局变量 hTlsIndex 对应的不同值的存储区域(该存储区域由操作系统维护,并由相同的索引指向)。初始化用的值取自 API 函数 GetTickCount,往 TLS 存储槽里存储数据时使用函数 TlsSetValue 。

步骤2 通过循环模拟耗时操作(90~96 行)。

步骤3 调用函数 _getLostTime 得到每个线程从开始执行到结束的时间。方法是首先通过函数 GetTickCount 获得当前时间,然后将 TLS 存储槽中存储的上一次时间通过函数 TlsGetValue 取出(注意不能直接使用全局变量),然后将当前时间与取出的时间相减即可得到线程运行时间。

注意:

线程存储的时间值使用了 TLS 槽,而不是通常看到的线程内的局部变量。线程的TLS 槽起的作用和线程的局部变量一样,但两者有根本的区别。

运行结果如图:

代码清单 9-2 是没有使用线程局部变量的代码:

在没有TLS 机制的多线程程序设计中,与每个线程有关的具有相同意义但不同值的变量必须被设置为局部变量(加黑部分)。如果多个线程使用同一个全局变量,则程序必须面对多个线程存取该变量时的同步问题。

从这个角度理解,TLS 又变成了一种参数传递机制。在 Win32 编程中,有些系统回调函数并没有准备足够的参数为我们传递数据。这些回调函数包括如 WindowProc、TimerProc 等,TLS 可以将函数用到的数据绑定到系统的当前线程中。就像为线程穿了一件衣服,线程到哪里,被绑定的数据就到哪里。无论我们在多线程设计中将线程设计得多复杂,调用了多少个函数,只要用到每个线程的私有变量,就可以到 TLS 存储槽中存取。

运行结果如图:

图 9-2 是线程本地存储机制的示意图:

如图 9-2 所示,在一个进程内部,存储着一个进程标志位,默认情况下它是一个双字(位于PEB 的 TlsBitmapBits 字段中),共 64 个位 (bit)。每个位可以是 0 或者 1,分别代表未使用和已使用。

每个线程有一个双字的数组(数组中的每个单元为一个TLS 存储槽,你可以把它理解为书橱上的一个抽屉) ;该数组的个数也是 64 个。线程数组的索引号对应着进程标志位双字的位索引,如图中所标识的进程的第 3 位对应每个线程的第 3 个双字(下标都从 0 开始)。

这就意味着,找到进程标志位的索引,也就找到每个线程的数组中相同索引的双字了,这个查找过程可以通过动态 TLS 的系列函数 TIsXXX 来完成。所有的线程中,该位置都是一个双字,由于存储在不同的内存中,每个线程对应的该位置的值可以不相同。

这个双字可以是一个值,也可以是一个指针,该指针还可以指向一个内存中的数据结构。于是,每个线程对应的该索引处的定义就变得五花八门了。

无论多线程程序的线程代码中使用了什么样的数据结构,用户在使用动态 TLS 函数存储的全局变量仅仅是一个索引而已。该索引位于进程中,对所有的线程可见, 并且这个值对于所有的线程都是相同的,操作这个索引就代表操作了所有线程的相同的索引。

操作这个进程索引就代表操作了所有线程的相同的索引,这句话可以这么理解 : 进程通过索引号告诉每个线程,你该在自己的这个位置做什么。

对应四个函数的比较通俗的解释如下:

口TlsAlloc 进程说,我要在你们(指线程)的空间里找个还没有用的位置。于是获取了索引号。

口TlsSetvalue 进程说,请分别在所有线程你们空间的某个索引处存储某个数据。

口TlsGetValue 进程说,把指定索引处的值都取出来吧,于是所有的线程都这么做了。

口TlsFree 进程说,我现在把这个空间收回了。

注意:这个值对每个线程来说可能是不一样的。比如,例子中尽管每个线程都执行了相同的操作 GetTickCount(),但由于每个线程执行该函数的时间不相同,所获取的值也不同。于是,所有的线程都那么做了,值被存储到了每个线程自己空间的相同索引处。

获取索引TIsAlloc:

函数功能:分配一个线程局部存储 (ITLS) 索引。该进程的任何线程都可以使用该索引来存储和检取线程中的值。

函数原型:

DWORD TlsAlloc(

void

)

参数: 无。

返回值: 若函数成功,则返回值为一个TLS 索引。失败则返回 TLS_ OUT_OF_INDEX,其十六进制值为0FFFFFFFFh

尽管在示例中只用了一个索引,但需要说明的是,微软保证每个进程最少有 64 个(常量符号为TLS_MINIMUM_AVAILABLE) 索引可供使用 .

如果程序需要,系统还会为线程提供更多的索引。一旦索引获取成功,该索引对应的进程标志位即被设置为1,表示已被使用。同时,每个线程的该索引对应的双字值也被声明为占用。

如果索引越过进程边界,该索引即视为无效。一个 DLL 不能假定在一个进程中分配的索引在另一个进程中依然有效。当一个 DLL 被附加到一个宿主进程时,它使用 TisAlloc 分配一个TLS 索引。然后,DLL 会分配一些动态存储单元,并调用函数 TlsSetValue 向该索引对应的双字数组相应位置写入分配的动态存储单元地址。TLS 索引存储在 DLL 的全局或静态变量中 。

有了这个索引,我们就可以通过它来取得、设置数据。注意,这些数据只对当前线程可见。当函数得到一个可用的索引值后,还会遍历进程中的每个线程,并将对应的 TLS 存储槽全部清 0。所以,函数返回以后,所有的操作就基本完成了。

按索引取值 TlsGetValue:

函数功能 : 检取调用线程的 TLS 存储槽的值。对于每个TLS 索引,进程中的每个线程都有它自己的槽。

函数原型:

LPVOID TlsGetValue(

DWORD dwTlsIndex //TLS 索引

)

参数:dwTlsImndex,由 TlsAlloc 分配的索引。

返回值: 若函数成功,则返回调用线程的 TLS 存储槽中的值; 失败则返回 0。

注意:由于存放在TLS 存储槽中值可以为0,在这种情况下,需要通过函数 GetLastError 的返回值来判断。如果为 NO_ERROR,则表示取到了值,且值是0; 否则表示函数调用失败。

按索引存储 TlsSetValue:

函数功能: 存储指定值倒调用线程的指定索引处。

函数原型:

BOOL TlsSetValue(

DWORD dwTlsIndex, //TLS 索引

LPVOID lpPTlsValue // 要设置的值

)

参数: dwTlsIndex,由 TlsAlloc 分配的索引。

LpTIlsValue,存储指定值到线程的 TLS 存储槽。

返回值: 若函数成功,则返回值不为0,失败则返回 0。

为了达到提高运行速度的目的,TlsSetvalue 和 TlsGetValue 两个函数执行最小的参数验证和错误检查。所以,如果你没有通过函数 TlsAlloc 得到一个合法的索引值,自行指定的索引在以上两个函数中同样可用。

释放索引 TIlsFree:

函数功能: 释放调用线程局部存储(TLS)索引。

函数原型:

BOOL TlsFree(

DWORD dwTlsIndaex //TLS 索引

)

参数: dwTlsIndex,由 TlsAlloc 分配的索引。

返回值: 若函数成功,返回值不为 0,失败则返回 0。

特别提醒:函数 TlsFree 并不释放任何线程中与 TLS 相关联的动态存储单元。因为这些存储空间是由线程自己申请的,需要线程自行维护这些存储空间。

TlsFree 函数会先检验你交给它的索引值是否的确被配置过。如果是,它将进程标志位对应的索引位设置为0,即声明未使用。然后,TlsFree 遍历进程中的每一个线程,把 0 放到刚刚被释放的索引所对应的 TLS 存储槽上。

TLS 存储槽实际对应于线程环境块中的 TlsSlots 字段。

静态线程局部存储:(.tls节)

静态线程局部存储是操作系统提供的另外一种线程与数据绑定的技术。它与动态 TLS 的区别在于,通过静态线程局部存储指定的数据无需使用专门的 API 函数,所以在易用性上会更好一些。

静态线程局部存储预先将变量定义在 PE 文件内部,一般使用“.tls”节存储,对相关 API 的调用由操作系统来完成。这种方式的优点就是从高级语言程序员的角度来看更简单了。这种实现方式使得 TLS 数据的定义与初始化就像程序中使用普通的静态变量那样。

在 Visual C++ 中,对静态 TLS 变量的定义不需要像动态线程局部存储一样调用相关的Windows API 函数,只需要做如下声明即可:

_declspec (thread) int tlSsFlag = 1;

为了支持这种编程模式,PE 的 “.tls” 节会包含以下信息:

口 初始化数据

口 用于每个线程初始化和终止的回调函数

口 TLS 索引

注意:通过静态方式定义的 TLS 数据对象只能用于静态加载的映像文件。这使得在 DLL 中使用静态 TLS 数据并不可靠,除非你能确定这个DLL ,以及静态链接到这个DLL 的其他 DLL 永远不会被动态加载,如通过调用 LoadLibrary 这个 API 函数实施的动态加载那样。

可执行代码访问静态 TLS 数据一般需要经过以下几个步骤:

步骤1 在链接的时候,链接器设置 TLS 目录中的 AddressOfIndex 字段。这个字段指向一个位置,在这个位置保存程序用到的 TLS 索引。

微软运行时库为了处理方便定义了一个 TLS 目录的内存映像,并命名为“_tls_used”(该名称适应于 Intel x86 平台)。微软链接器查找这个内存映像,并直接使用其中的数据来创建PE 中的 TLS 目录。

步骤2 当创建线程时,加载器通过将线程环境块 (TEB) 的地址放入 FS 寄存器来传递线程的 TLS 数组地址。距 TEB 开头 0x2C 的位置处的字段 ThreadLocalStoragePointer 指向TLS 数组。这是特定于 Intel x86 平台的。

步骤 3 加载器将 TLS 索引值保存到 AddressOfIndex 字段指向的位置处。

步骤4 可执行代码获取 TLS 索引以及TLS 数组的位置。

步骤5 可执行代码将索引乘以4,并将该值作为这个数组内的偏移地址来使用。通过以上方法获取给定程序和模块的TLS 数据区的地址。每个线程拥有它自己的TLS 数据区,但这对于程序是透明的,它并不需要知道是怎样为单个线程分配数据的。

步骤6 单个的TLS 数据对象都位于 TLS 数据区的某个固定偏移处,因此可以用这种方式访问。

说明:TLS 数组即系统为每个线程维护的一个地址数组。这个数组中的每个地址即为前面提到的 TLS 存储槽,它指出了程序中给定模块的 TLS 数据区的位置; TLS 索引指出了是这个数组的哪个元素。

TLS 定位:

线程局部存储数据为数据目录中注册的数据类型之一,其描述信息处于数据目录的第 10 个目录项中:

使用PEDump 小工具获取 chapter9\tls1.exe 的数据目录表内容如下:

加黑部分即为线程局部存储数据目录信息。通过以上字节码得到如下信息:

口 线程局部存储数据所在地址 RVA=0x000003010

口 线程局部存储数据大小 =00000018h

使用 PEInfo 小工具获取该文件所有节的相关信息,内容如下:

根据 RVA 与 FOA 的换算关系,可以得到:

线程局部存储数据所在文件的偏移地址为 0x00000810。

TLS 目录结构 IMAGE_TLS_DIRECTORY32:

线程局部存储数据以数据结构 IMAGE_TLS_DIRECTORY32 开始。该结构的详细定义如下:

IMAGE_TLS_DIRECTORY32. StartAddressOfRawData字段:

+0000h,双字。表示 TLS 模板的起始地址。这个模板是一块数据,用于对 TLS 数据进行初始化。每当创建线程时,系统都要复制所有这些数据,因此这些数据一定不能出错。

注意:这个地址并不是一个RVA,而是一个VA。所以,在 .reloc 节中应当有一个相应的基址重定位信息是用来说明这个地址。

IMAGE_TLS_DIRECTORY32. EndAddressOfRawData字段:

+0004h,双字。表示 TLS 模板的结束地址。TLS 的最后一个字节的地址,不包括用于填充的 0。

IMAGE_TLS_DIRECTORY32. AddressOflndex字段:

+0008h,双字。用于保存 TLS 索引的位置,索引的具体值由加载器确定。这个位置在普通的数据节中,因此,可以给它取一个容易理解的符号名,便于在程序中访问。

IMAGE_TLS_DIRECTORY32. AddressOfCallBacks字段:

+000Ch,双字。这是一个指针,它指向由 TLS 回调函数组成的数组。这个数组是以 NULL 结尾的,因此,如果设有回调函数的话,这个字段指向的位置处应该是 4 个字节的0。

IMAGE_TLS_DIRECTORY32. SizeOfZeroFil字段:

+0010h,双字。TLS 模板中除了由 StartAddressOfRawData 和 EndAddressOfRawData 字段组成的已初始化数据界限之外的大小(以字节计)。TLS 模板的大小应该与映像文件中 TLS 数据的大小一致。用 0 填充的数据就是已初始化的非零数据后面的那些数据。

IMAGE_TLS_DIRECTORY32. Characteristics字段:

+0014h,双字。保留未用。

静态TLS 实例分析:(结合上面的TLS目录结构)

接下来看一个实例。通过前面定位文件 chapter9\tls.exe 的线程局部存储数据得到如下字节码:

>>28 30 40 00 - 2C 30 40 00

该实例中 TLS 模板的 VA 起止。指向文件偏移 0x00000828 到 0x0000082c。此位置为4字节的 0。

>>30 30 40 00

索引的地址,指向文件偏移 0x00000830,此处为一个双字的 0。

>>34 30 40 00

回调函数数组地址所在处。指向文件偏移 0x00000834。该处取出的值为, 0x00401008。通过分析,该实例中并不包含 TLS 变量数据。

TLS 回调函数:

程序可以通过 PE 文件的方式提供一个或多个 TLS 回调函数,用以支持对 TLS 数据进行附加的初始化和终止操作,这种操作类似于面向对象程序设计中的构造函数和析构函数。

尽管回调函数通常不超过一个,但还是将其作为一个数组来实现,以便在需要时可以另外添加更多回调函数。如果回调函数超过一个,它们将会按照其地址在数组中出现的顺序被依次调用,一个双字的空指针表示这个数组的结尾。如果程序没有提供回调函数,则该列表可以为空,这时,这个数组就只有一个元素,即结尾的双字的 0。

回调函数的原型与DLL 人口点函数参数相同:

参数解释:

1) Reserved: 预留,为0。

2) Reason: 调用该回调函数的时机。具体值见表 9-1。

3) DllIHandle: DLL 的句柄。

测试静态 TLS 下的线程存储初始化回调函数:

接下来将编写一个程序,用来测试静态 TLS 下的线程存储初始化回调函数。通过使用TLS 回调函数,开发者可以实现在主程序运行前运行一段自定义代码。详细见代码清单 9-3。

按照程序的正常流程分析,tls.asm 直接执行了 ExitProcess 函数退出。运行时也确实没有看到有任何的显示:

接下来,我们对该程序的 PE 文件进行简单的修改:

运行 tls1.exe 后就会出现 HelloWorldPE 的对话框了。因为在代码 20 一 23 行通过数据结构构造了静态 TLS 数据。 在数据结构中定义了 TLS 的回调函数,指向代码清单 9-3 中47 一 57 行的代码。

小结:

本章主要从动态和静态两个方面全面分析了线程局部存储技术在 PE 中的使用。TLS 是Win32 引入的多线程中共享变量的优秀特质,用户可以直接通过系统提供的 API 函数动态管理这些变量,也可以通过 PE 文件头部预先静态声明这些变量,程序员可以在程序中透明地使用它们。TLS 机制大大方便了多线程程序的设计。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

沐一 · 林

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值