Glibc线程局部存储(TLS)的实现

  • 背景

在笔者分析glibc源码中的内存分配模块时,遇到了线程局部变量thread_arena,该变量是线程专有的局部变量。在glibc源码中,有相似的变量errno,也是线程专有的变量。尽管在glibc的其他头文件中,errno被定义为 (* __errno_location()),但线程专有的变量实现机制是相同的,笔者希望了解其实现的具体机制。

  • 调试代码

为了调试分析glibc对线程本地存储(Thread-Local-Storage,即TLS)实现的机制,笔者编写了如下代码(文件名为tls-test.c):

/*
 * Created by [email protected]
 *
 * Thread-Local-Storage test
 *
 * 2020/05/10
 */

#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <fcntl.h>
#include <unistd.h>
#include <pthread.h>

static int myPipe[2];
#define TLSTEST_BUFSIZ   8192
static __thread int datls __attribute__ ((tls_model ("initial-exec")));

static void handle_sigpipe(int signo)
{
	fprintf(stdout, "Received signal: %d, &datls: %p, &errno: %p\n",
		signo, &datls, &errno);
	fflush(stdout);
}

static void * datls_thread(void * what)
{
	int * errp;
	unsigned char * qdat, * rdat;
	void * retval = (void *) 0x20200510;

	datls = 0x1;
	errp = &errno;
	/* dump thread-local-storage variables */
	fprintf(stdout, "In [%s], errno: %p, datls: %p, %d, what: %p, %d\n",
		__FUNCTION__, errp, &datls, datls, what, *((int *) what));
	fflush(stdout);

	/* allocate memory */
	qdat = (unsigned char *) malloc(TLSTEST_BUFSIZ);
	if (qdat == NULL) {
		fputs("Thread out of memory!\n", stderr);
		fflush(stderr);
		return retval;
	}

	for (;;) {
		ssize_t rl0;
readAgain:
		rdat = NULL;
		rl0 = read(myPipe[0], (void *) &rdat, sizeof(rdat));
		if (rl0 < 0) {
			if (*errp == EINTR)
				goto readAgain;
			fprintf(stderr, "Error, failed to read(%d): %s\n",
				myPipe[0], strerror(*errp));
			fflush(stderr);
			break;
		}
		if (rl0 == 0) break;
		if (rl0 != sizeof(rdat)) {
			fprintf(stderr, "Error, partial read of %d: %ld\n",
				myPipe[0], (long) rl0);
			fflush(stderr);
			break;
		}
		fprintf(stdout, "Trying to free memory pointer: %p\n", errp);
		fflush(stdout);
		free(rdat);
	}
	free(qdat);
	return retval;
}

int main(int argc, char *argv[])
{
	void * rval;
	pthread_t threadID;
	unsigned char * pdat;
	int ret, * perr, idx;

	myPipe[0] = myPipe[1] = -1;

	if (signal(SIGPIPE, handle_sigpipe) == SIG_ERR) {
		fputs("Error, failed to register signal handler!\n", stderr);
		fflush(stderr);
		return 1;
	}

	/* allocate memory */
	pdat = (unsigned char *) malloc(TLSTEST_BUFSIZ);
	if (pdat == NULL) {
		fputs("Error, system out of memory!\n", stderr);
		fflush(stderr);
		return 1;
	}
	memset(pdat, 0, TLSTEST_BUFSIZ);

	datls = 0;
	perr = &errno;
	/* dump the address of errno */
	fprintf(stdout, "Location of errno: %p, value: %d; address of datls: %p\n",
		perr, *perr, &datls);
	fflush(stdout);

	/* create a pipe, blocked IO */
	ret = pipe2(myPipe, O_CLOEXEC);
	if (ret != 0) {
		fprintf(stderr, "Error, failed to create pipe: %s\n", strerror(*perr));
		fflush(stderr);
		free(pdat);
		return 2;
	}

	threadID = 0;
	/* create a thread */
	ret = pthread_create(&threadID, NULL, datls_thread, &datls);
	if (ret != 0) {
		fprintf(stderr, "Error, failed to create thread: %d\n", ret);
		fflush(stderr);
		close(myPipe[0]); myPipe[0] = -1;
		close(myPipe[1]); myPipe[1] = -1;
		return 3;
	}

	for (idx = 0; idx < 0x3; ++idx) {
		ssize_t rl1;
		unsigned char * datp;
		datp = (unsigned char *) malloc(TLSTEST_BUFSIZ);
		if (datp == NULL) {
			fputs("Error, out of memory!\n", stderr);
			fflush(stderr);
			break;
		}

		memset(datp, idx + 0x1, TLSTEST_BUFSIZ);
		rl1 = write(myPipe[1], (void *) &datp, sizeof(datp));
		if (rl1 != sizeof(datp)) {
			fprintf(stderr, "Error, write(%d) has failed: %s\n",
				myPipe[1], strerror(*perr));
			fflush(stderr);
			free(datp);
			break;
		}
		/* wait for signal */
		pause();
	}

	rval = NULL;
	close(myPipe[1]); myPipe[1] = -1;
	pthread_join(threadID, &rval);
	close(myPipe[0]); myPipe[0] = -1;
	free(pdat); pdat = NULL;
	fprintf(stdout, "Exit value from child thread: %p\n", rval);
	fflush(stdout);
	return 0;
}

上面的代码中,笔者创建了一个子线程,线程函数为datls_thread,在主线程和子线程中均输出了errno及datls变量的地址。

  • 编译并运行

编译上面的代码得到可执行文件tls-test,放置于嵌入式ARM设备上,注意需要修改其链接的动态库,必须都是调试版本的动态库:

笔者更新了LinuxARM.tar.xz但未发布,有需要的可以发邮件至笔者的邮箱索取。运行的结果如下:

计算可知,代码中定义的TLS变量datls与errno的地址相隔0x0C,即12个字节。标注为黄色的地址与标注为绿色的地址不相同,分别为主线程和子线程访问得到的TLS变量地址。

  • 调试线程变量的访问机制

线程本地存储的基地址是如何得到的?反汇编可以得到答案,是通过访问TPIDRURO寄存器得到的,调试结果如下:

如上图可知,TPIDRURO寄存器的内容为0xb6ffbf40,该地址即为TLS存储空间的基地址。变量datls和errno的偏移量分别为0x8个字节和0x14个字节。

  • 线程本地存储的Linux内核支持

通过查找ARM参考手册得知,用于存储TLS基地址的寄存器TPIDRURO在用户态是只读的:

那么可以推论,LINUX内核应该提供修改此寄存器的接口,如系统调用。经过查找可知,内核确实提供了相关的系统调用:

此外,Linux内核只在切换(用户态)任务时为新的任务加载相应的TLS基地址,反汇编内核的任务切换函数__switch_to可以印证这一点。同样的,glibc中可以找到调用此系统调用的相关代码:

即然如此,那么我们就来调试一下glibc对此系统调用的使用。首先,使用catch syscall来跟踪ARM_set_tls系统调用;其次,我们也对创建datls_thread线程的系统调用clone加一个跟踪断点:

当ARM_set_tls系统调用被触发之前,其第一个参数(由r0寄存器指定)为0xb6ffbf20,即为主线程的TLS基地址。不过,在创建子线程时,就不会通过该系统调用来指定子结程的TLS基地址了,这也是笔者跟踪clone系统调用的原因:

如上面的调试结果,当以clone系统调用创建子线程时,clone的第6个参数指定的新线程的TLS基地址,相应的glibc源码如下图:

Clone系统调用的clone_flags增加了CLONE_SETTLS选项,表明其参数中指定的新线程的TLS 基地址。这里需要注意的是,创建的子线程TLS存储空间各个变量的偏移量是相同的;也就是说,创建新的线程时,应当为新的线程分配与主线程相同大小的TLS存储空间。

 

  • 为主线程创建TLS存储空间的过程

通过上面的调试结果可知,主线程与子线程配置TLS基地址的机制是不同的。但TLS的空间大小是相同的。Glibc是如何计算TLS空间大小的呢?下面需要接着调试分析。在调试之前,我们可以查看tls-test及其依赖的动态库文件所有的TLS 数据大小:

使用readelf命令行工具,可以得知tls-test可执行文件中存在4字节大小的TLS变量;而libc动态库中则存在0x48字节大小的TLS变量。二者相加TLS 变量共占大小有76字节,这一点需要牢记,共76字节。分析glibc源码可知,计算TLS分配大小的函数为_dl_determine_tlsoffset,这样就可以加断点调试了:

调试结果显示,offset变量为84字节,比之前的76字节多出了8个字节,这是怎么回事呢?原来多出来的这8个字节,是TLS头部的一个结构体:

如此一来,offset的最终大小为0x4 + 0x48 + 0x8 = 84字节了。当写入TLS相关的全局变量GL(dl_tls_static_size)中时,又增加了一个TLS_STATIC_SURPLUS,该值为1664字节,并将结果16字节对齐,最后得到GL(dl_tls_static_size)大小为1760字节,这就是分配给主线程的TLS存储空间大小。该(1760)值最终会被写入到定义于nptl-initl.c中的__static_tls_size变量,下图中的_dl_get_tls_static_info(…)第一个参数即为变量__static_tls_size的地址:

这里插入一些题外话。为什么我输入了CTRL-C之后将内存监视断点2禁用(上图黄框所示)?是因为内存监视断点严重地影响了软件的运行速度,可以笔者使用的安卓手机不支持硬件实现的内存断点。这一点在实际工作中也需要注意,如果设备不支持硬件实现的内存监视断点而使用之,很可能得不偿失。之后,__static_tls_size变量会写入1760:

接下来创建新的线程,会使用到这个__static_tls_size变量,用以分配子线程的栈空间和TLS存储空间。

  • 为新的线程创建TLS存储空间的过程

Glibc为新创建的线程分配栈空间和其他相关信息所需的空间时,没有使用到malloc/calloc等libc函数。它使用mmap分配了匿名空间,用于新线程的栈空间和TLS存储空间;而且仅调用了一次mmap系统调用。下面的调试结果可以印证这一点:

由于Linux内核支持两个mmap系统调用,笔者都对其进行跟踪。如上图,断点8即为mmap系统调用反回时,r0寄存器即为内核为应用分配的匿名空间:

上图中的1216为线程结构体struct pthread的大小。经过一番计算,我们初步得到了线程结构体struct pthread的指针pd为0xb6ec5460,下面就是检验真理的时刻了。在clone系统调用上加了断点,可以看到传入的TLS基地址为0xb6ec5920:

也就是说,线程的struct pthread结构体之后紧接着就是TLS存储空间的基地址。由上图的调试结果可知,我们的计算是无误的。最后,我不明白新线程的栈顶为何也要减动1216个字节(即struct pthread的大小)?在ARMv7平台上,C/C++函数栈都是向下生长的,这一点需要注意。

 

  • 动态链接器修改errno线程局部变量移量的过程

上面提到tls-test可执行文件中的TLS变量的大小总共为4字节;而且TLS 变量之后紧接着是libc-2.25.so动态库的TLS变量。如果我们在tls-test.c中再建一个线程局部变量,那么errno之类的变量在TLS 中的偏移量就需要增加了。这一点是如何实现的?也就是说,glibc是如何动态地修改这些线程局部变量的偏移量的?

在解答这个问题之前,首先让我们查看一下glibc是如何获取errno的地址的吧。Glibc的头文件把errno定义为:

在PC机上反汇编__errno_location函数,并手动计算errno相对于TLS基地址的偏移量:

可以断定,在动态库libc-2.25.so的加载过程中,这个0x8被修改为0x14了。下面我们对init_tls函数加上断点,得到libc-2.25.so动态库加载到内存中的起止地址:

我们可以看到,针对libc.so.d动态库的Syms为NO,我们就不能找到__errno_location函数的地址了。必须通过gdb的find命令来找这个函数的地址:

上面的调试过程是为了反汇编已加载的libc.so动态库中的__errno_location函数,并手动计算errno变量在TLS中的偏移地址。计算结果仍然是8字节。下面就要对这个内存地址加上内存监视断点了,看看是在哪里将这个8字节的偏移量修改为0x14个字节:

笔者之前提到内存监视断点很耗时(缺少硬件支持),这里运行了约三分钟后,内存断点被触发,可以看到是哪里修改了errno线程局部变量在TLS存储空间中的偏移量。修改之后,偏移量为20字节(即0x14),这个结果正是我们预期的。这是动态链接器的重定向操作,很高级的动态链接过程。笔者在这里就不再深入分析了。

  • 其他

这个调试的过程是很冗长的,笔者至今仍有一些疑问。比如说,当tls-test运行了一段时间后,它加载了一个新的动态库,动态库中也存在TLS变量,那么这些新的TLS 变量的偏移量是如何确定的?新的动态库的TLS变量所需的存储空间过大,现有TLS空间存储不下,glibc又如何处理?这些问题留待以后再做分析吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章