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又如何處理?這些問題留待以後再做分析吧!

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