天天看點

關于Linux線程的線程棧以及TLS

a.本文描述Linux NPTL的線程棧簡要實作以及線程本地存儲的原理,實驗環境中Linux核心版本為2.6.32,glibc版本是2.12.1,Linux發行版為ubuntu,硬體平台為x86的32位系統。

b.對于Linux NPTL線程,有很多話題。本文挑選了原則上是每線程私有的位址空間來讨論,分别是線程棧和TLS。原則山私有并不是真的私有,因為大家都知道線程的特點就是共享位址空間,原則私有空間就是一般而言通過正常手段其它線程不會觸及這些空間的資料。

雖然Linux将線程和程序不加區分的統一到了task_struct,但是對待其位址空間的stack還是有些差別的。對于Linux程序或者說主線程,其stack是在fork的時候生成的,實際上就是複制了父親的stack空間位址,然後寫時拷貝(cow)以及動态增長,這可從sys_fork調用do_fork的參數中看出來:

何謂動态增長呢?可以看到子程序初始的size為0,然後由于複制了父親的sp以及稍後在dup_mm中複制的所有vma,是以子程序stack的flags仍然包含:

這就說針對帶有這個flags的vma(stack也在一個vma中!)可以動态增加其大小了,這可從do_page_fault中看到:

很清晰。

        然而對于主線程生成的子線程而言,其stack将不再是這樣的了,而是事先固定下來的,使用mmap系統調用,它不帶有VM_STACK_FLAGS    标記(估計以後的核心會支援!)。這個可以從glibc的nptl/allocatestack.c中的allocate_stack函數中看到:

此調用中的size參數的擷取很是複雜,你可以手工傳入stack的大小,也可以使用預設的,一般而言就是預設的。這些都不重要,重要的是,這種stack不能動态增長,一旦用盡就沒了,這是和生成程序的fork不同的地方。在glibc中通過mmap得到了stack之後,底層将調用sys_clone系統調用:

是以,對于子線程的stack,它其實是在程序的位址空間中map出來的一塊記憶體區域,原則上是線程私有的,但是同一個程序的所有線程生成的時候淺拷貝生成者的task_struct的很多字段,其中包括所有的vma,如果願意,其它線程也還是可以通路到的,于是一定要注意。

Linux的glibc使用GS寄存器來通路TLS,也就是說,GS寄存器訓示的段指向本線程的TEB(Windows的術語),也就是TLS,這麼做有個好處,那就是可以高效的通路TLS裡面存儲的資訊而不用一次次的調用系統調用,當然使用系統調用的方式也是可以的。之是以可以這麼做,是因為Intel對各個寄存器的作用的規範規定的比較松散,是以你可以拿GS,FS等段寄存器來做幾乎任何事,當然也就可以做TLS直接通路了,最終glibc線上程啟動的時候首先将GS寄存器指向GDT的第6個段,完全使用段機制來支援針對TLS的尋址通路,後續的通路TLS資訊就和通路使用者态的資訊一樣高效了。

        線上程啟動的時候,可以通過sys_set_thread_area來設定該線程的TLS資訊,所有的資訊都得glibc來提供:

fill_ldt設定GDT中第6個段描述符的基址和段限以及DPL等資訊,這些資訊都是從sys_set_thread_area系統調用的u_info參數中得來的。本質上,最終GDT的第6個段中描述的資訊其實就是一塊記憶體,這塊記憶體用于存儲TLS節,這塊記憶體其實也是使用brk,mmap之類調用在主線程的堆空間申請的,隻是後來調用sys_set_thread_area将其設定成了本線程的私有空間罷了,主線程或者其它線程如果願意,也是可以通過其它手段通路到這塊空間的。

        明白了大緻原理之後,我們來看一下一切是如何關聯起來的。首先看一下Linux核心關于GDT的段定義,如下圖所示:

我們發現是第六個段用于記錄TLS資料,我了證明一下,寫一個最簡單的程式,用gdb看一下GS寄存器的值,到此我們已經知道GS寄存器表示的段描述子指向的段記錄TLS資料,如下圖所示:

可以看到紅色圈住的部分,GS的值是0x33,這個0x33如何解釋呢?見下圖分解:

這就證明了确實是GS指向的段來表示TLS資料了,在glibc中,初始化的時候會将GS寄存器指向第六個段:

既然如此,我們是不是可以直接通過GS寄存器來通路TLS資料呢?答案當然是肯定的,glibc其實就是這麼做的,無非經過封裝,使用更加友善了。但是如果想明白其是以然,還是自己折騰一下比較妥當,我的環境是ubuntu glibc-2.12.1,值得注意的是,每一個glibc的版本的TLS header都可能不一樣,一定要對照自己調試的那個版本的源碼來看,否則一定會發瘋的。我将上面的那個test_gs.c修改了一下,成為下面的代碼:

這個代碼的含義在于,我可以通過GS寄存器通路到TLS變量,為了友善,我就沒有寫代碼,而是通過gdb來證明,其實通過寫代碼取出TLS變量和通過gdb檢視記憶體的方式效果是一樣的,個人認為通過調試的方法對于了解還更好些。

        當調試的時候,在取出GS之後,我們得到了TLS的位址,然後根據該版本的TLS結構體分析哪裡存儲的是TLS變量,然後檢視TLS位址附近的記憶體,證明那裡确實存着一個TLS變量,這可以通過比較位址得出結論。當然在實際操作之前,我們首先看一下glibc-2.12.1版本的TLS資料結構,如下圖所示:

注意,由于我們并無意深度hack TLS,是以僅僅知道在何處能取到變量即可,是以我們隻需要知道一些字段的大小就可以了,暫且不必了解其含義與設計思想。

        我們發現,應該是從第35*4個位元組開始就是TLS變量的區域了,是不是這樣呢?我們來看一下調試結果,注意我們要把斷點設定在asm之後,這樣才能打出b的值,當然你也可以調整上述代碼,把asm内嵌彙編放在代碼最前面也是可以的。gdb指令就不多說了,都是些簡單的,如下展示出結果:

結果很明了了。最終還有一個小問題,那就是關于線程切換的問題。

        對于Windows而言,線程的TEB幾乎是固定的,而對于Linux,它同樣也是這樣子,隻需要得到GS寄存器,就能得到目前線程的TCB,換句話說,GS始終是不變化的,始終是0x33,始終指向GDT的第6個段,變化的是GDT的第6個段的内容,每當程序或者線程切換的時候,第6個段的内容都需要重新加載,載入将要運作線程的TLS info中的資訊,這是在切換時switch_to宏中完成的:

每個task_struct都有thread_struct,而該線程TLS的中繼資料資訊就儲存在thread_struct結構體的tls_array數組中:

除了我們使用pthread的API在運作時建立的TLS變量之外,還有一部分TLS稱為靜态TLS變量,這些TLS元素是在編譯期間預先生成的,常見的有:

1.自定義_thread修飾符修飾的變量;

2.一些庫級别預定義的變量,比如errno

那麼這些變量存儲在哪裡呢?設計者很明智的将其放在了動态TLS臨接的空間内,就是GS寄存器訓示的位址下面,其實要是我設計也會這麼設計的,你也一樣。這樣設計的好處在于可以很友善對不管是動态TLS變量還是靜态TLS變量的通路,并且對于動态TLS的管理也很友善。

        這些資料處于“initialized data section”,然而在連結或者線程初始化的時候被動态重定向到了靜态TLS空間内,在我的實驗環境中,如果我定義了一個變量:

_thread int test = 123;

那麼調試顯示的結果,它處于GS寄存器訓示tls段位址的緊接着下方4個位元組的偏移處,而errno處于_thread變量下方14*4位元組的位置。具體這些空間到底怎麼安排的,可以看glibc的dl-reloc.c,dl-tls.c等檔案,然而本人認為這沒有什麼意義,由于這涉及到很多關于編譯,連結,重定向,ELF等知識,如果不想深度優先的迷失在這裡面的化,了解原理也就夠了,本人真的是沒有時間再寫了,回到家就要看孩子,購物,做家務....。最後給出一幅圖,重定向後總的示意圖如下:

 本文轉自 dog250 51CTO部落格,原文連結:http://blog.51cto.com/dog250/1268946

繼續閱讀