在笔者分析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
#include
#include
#include
#include
#include
#include
#include
#include
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个字节。
通过查找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的空间大小是相同的。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存储空间。
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++函数栈都是向下生长的,这一点需要注意。
上面提到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又如何处理?这些问题留待以后再做分析吧!