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