Thread 本機儲存空間

ELF Thread 本機儲存空間 ABI (TLS) 是變數的儲存模型,可讓每個執行緒都有專屬全域變數副本。這個模型是用於實作 C++ 的 thread_local 儲存模型。在建立執行緒時,會從初始 TLS 映像檔取得變數的初始值。針對執行個體,TLS 變數適用於做為執行緒安全程式碼中的緩衝區,或每個執行緒簿保留。您也可以使用這種方式處理 C 樣式錯誤,例如 errno 或 dlerror。

TLS 變數與任何其他全域/靜態變數非常類似,實作時,初始資料會在 PT_TLS 區隔中提升。儘管傳輸層安全標準 (TLS) 變數可寫入,但 PT_TLS 區隔位於唯讀 PT_LOAD 區隔內。接著,這個片段會複製到每個執行緒在專屬可寫入位置的程序。PT_TLS 區段的複製位置受到區段的對齊方式影響,以確保會遵守 TLS 變數的對齊方式。

ABI

儘管實作細節較為複雜,編譯器、連結器和動態連結器仍必須遵循的實際介面其實相當簡單。編譯器和連結器必須發出程式碼和動態重新定位,這些都是使用 4 種存取模型的其中一種 (詳情請見下一節)。接著,動態連結器和執行緒實作作業必須進行所有設定,才能實際運作。不同的架構有不同的 ABI,但對於大筆劃,我們大部分都能討論,就像只有一個 ABI 一樣。本文件會假設有使用 x86-64 或 AArch64,並指出出現差異。

TLS ABI 會使用下列幾個字詞:

  • 執行緒指標:這是每個執行緒中的不重複位址,通常儲存在註冊資料庫中。執行緒本機變數位於執行緒指標之間偏移。執行緒指標將會縮寫,並在本文件中做為 $tp 使用。$tp__builtin_thread_pointer() 在 AArch64 上傳回的內容。在 AArch64 上,$tp 是由名為 TPIDR_EL0 的特殊暫存器提供,可透過 mrs <reg>, TPIDR_EL0 存取。在 x86_64 上,系統會使用 fs.base 區隔集,並可透過 %fs: 存取,並且可從 %fs:0rdfsbase 指示載入。
  • TLS 區隔:這是每個模組中的資料圖片,由各模組的 PT_TLS 程式標頭指定。並非所有模組都有 PT_TLS 程式標頭,因此並非每個模組都有 TLS 區隔。每個模組最多有一個傳輸層安全標準 (TLS) 片段,且最多對應一個 PT_TLS 程式標頭。
  • 靜態傳輸層安全標準 (TLS) 集:這是程式啟動時動態連結器已知的模組總數。這個元件包含主要的執行檔以及由 DT_NEEDED 間接提及的每個程式庫。需要設為靜態傳輸層安全標準 (TLS) 的模組,已在其動態資料表中的 DT_FLAGS 項目上設定 DF_STATIC_TLS (以 PT_DYNAMIC 區段表示)。
  • TLS 區域:每個執行緒專屬的連續記憶體區域。「$tp」會指向這個區域的某個時間點。其中包含 Static TLS 集內每個模組的傳輸層安全標準 (TLS) 區段,以及一些實作私人資料 (有時稱為 TCB (執行緒控制區塊)。在 AArch64 上,從 $tp 開始的 16 位元組保留空間有時也稱為 TCB。在本文件中,我們會將這個聊天室稱為「ABI TCB」。
  • TLS 封鎖:個別執行緒的 TLS 區隔副本。每個執行緒的每個傳輸層安全標準 (TLS) 區段都有一個 TLS 區塊。
  • 模組 ID:除了主執行檔的模組 ID (一律為 1) 以外,模組 ID 並非靜態資訊。其他模組的模組 ID 是由動態連接器選擇。只是每個模組的唯一非零 ID。理論上,它可以是模組專屬的任何 64 位元值,例如雜湊或其他項目。實際上,這只是動態連結器維護的簡單計數器
  • 主要執行檔:這是包含起始位址的模組。會以特殊方式處理,屬於其中一個存取模式。模組 ID 一律為 1。這是唯一可以透過下述的本機執行模型,使用 $tp 的固定偏移量的模組。

為遵循 ABI 規定,所有存取模式都必須支援。

存取模型

ABI 指定了 4 種存取模型:

  • global-dynamic
  • local-dynamic
  • initial-exec
  • local-exec

這些值可用於 -ftls-model=...__attribute__((tls_model("...")))

採用的模型與以下項目相關:

  1. 哪個模組執行存取權:
    1. 主要的執行檔
    2. 靜態 TLS 組合中的模組
    3. 啟動後載入的模組,例如 dlopen
  2. 存取中變數的模組定義如下:
    1. 在同一模組中 (例如 local-*)
    2. 在其他模組 (例如 global-*)
  • global-dynamic 可從任何位置使用,適用於任何變數。
  • local-dynamic 可用於任何模組,適用於該模組中定義的任何變數。
  • initial-exec 可供任何模組用於靜態傳輸層安全標準 (TLS) 組合中定義的任何變數。
  • local-exec 可供主要執行檔中定義的變數使用。
全球動態

全域動態是最常見的存取格式,也最慢。任何執行緒本機全域變數都應透過此方法存取。如果動態程式庫存取在其他模組中定義的符號 (請參閱 Initial Exec 中的例外狀況),則必須使用此存取模型。執行檔中定義的符號不需要使用這個存取模式。主要的執行檔也能避免使用這個存取模式。這是使用 -fPIC 編譯時的預設存取模式,是共用程式庫的常態。

這個存取模型的運作方式是呼叫動態連結器中定義的函式。有兩種方式可以呼叫函式:透過 TLSDESC 或 __tls_get_addr

如果是 __tls_get_addr,系統會傳遞與這個符號相關聯的 GOT 項目組合。具體來說,它會把指標傳送到第一個項目,緊接在第二個項目之後。如果是特定符號 S,第一個項目 (註明 GOT_S[0]) 必須包含定義 S 的模組模組 ID。第二個項目 (以 GOT_S[1] 表示) 必須包含傳輸層安全標準 (TLS) 區塊中的偏移值,與相關模組的 PT_TLS 區段中符號的偏移相同。接著,系統會使用 __tls_get_addr(GOT_S) 計算 S 的指標。我們稍後會討論 __tls_get_addr 的實作方式。

TLSDESC 是 global-dynamic 存取 (和 local-dynamic) 的替代 ABI,其中第一個 GOT 位置包含函式指標。GOT第二項包含一些動態連接器定義的輔助資料。如此一來,動態連結器就能根據情況選擇要呼叫的函式。

在這兩種情況下,您對這些函式的呼叫都必須由特定的程式碼序列和一組特定重新定位進行實作。如此可讓連結器辨識這些存取作業,並可能因此放寬對 local-dynamic 存取模式的存取限制。

(注意:以下段落包含編譯器如何維持 ABI 末端的詳細資料。如果不在乎,請略過這個段落)。

為了讓編譯器發送程式碼給這個存取模型,系統必須向 __tls_get_addr (由動態連結器定義) 發出呼叫,以及對符號名稱的參照。具體來說,編譯器會發送給 GOT 本身 __tls_get_addr(GOT_S) 所需的額外重新配置程式碼。然後連結器會在產生 GOT 時發出兩個動態重定位。x86_64 中的 R_X86_64_DTPMODR_X86_64_DTPOFF。在 AArch64 上為 R_AARCH64_DTPMODR_AARCH64_DTPOFF。無論模組是否依據該名稱定義符號,這些重新定位都會參照該符號。

本地動態

本機動態值與 Global Dynamic 相同,但用於本機符號。可做為這個模組 TLS 區塊的單一 global-dynamic 存取權。然後,由於模組中定義的每個變數都與 TLS 區塊的固定偏移值相同,因此編譯器可以將多個 global-dynamic 呼叫最佳化為單一呼叫。每當變數是本機/靜態或隱藏的瀏覽權限,編譯器就會放寬 local-dynamicglobal-dynamic 存取權。連結器有時也可以放寬 local-dynamicglobal-dynamic 存取要求。

以下舉例說明編譯器可能會如何輸出此存取模型的程式碼:

static thread_local char buf[buf_cap];
static thread_local size_t buf_size = 0;
while(*str && buf_size < buf_cap) {
  buf[buf_size++] = *str++;
}

可能會調降為

// GOT_module[0] is the module ID of this module
// GOT_module[1] is just 0
// <X> denotes the offset of X in this module's TLS block
tls = __tls_get_addr(GOT_module)
while(*str && *(size_t*)(tls+<buf_size>) < buf_cap) {
  (char*)(tls+<buf>)[*(size_t*)(tls+<buf_size>)++] = *str++;
}

如果這個程式碼使用全域動態,則必須至少進行 2 次呼叫,一個才能取得 buf 的指標,另一個則取得 buf_size 的指標。

初始執行

每當編譯器知道要存取的符號的模組都會在初始執行檔中載入,而不是透過 dlopen 開啟,就可以使用這個存取模型。通常只有在主要執行檔存取具有預設瀏覽權限的全域符號時,才會使用這個存取模型。這是因為編譯執行檔是編譯器得知產生的任何程式碼都會位於初始可執行檔集中的時間。如果編譯 DSO 以讓執行緒本機存取權使用這個模型,就無法使用 dlopen 安全地開啟 DSO。對效能至關重要的應用程式來說,這是可接受的做法,如果您知道二進位檔永遠不會以強制使用者權限開啟 (例如 libc 的話),以這種方式編譯/連結的模組會有 DF_STATIC_TLS 旗標。

在沒有 -fPIC 的情況下進行編譯時,預設會執行初始 Exec。

編譯器不會針對這個存取模型呼叫 __tls_get_addr,也能發送程式碼。具體做法是使用單一 GOT 項目,為 s 表示 GOT_s 符號,而編譯器會發出重新定位,以確保兩者

extern thread_local int a;
extern thread_local int b;
int main() {
  return a + b;
}

較低

int main() {
  return *(int*)($tp + GOT[a]) + *(int*)($tp + GOT[b]);
}

請注意,在 x86 架構上,GOT[s] 實際上會解析為負值。

本機執行

這是最快的存取模式,只有在符號位於第一個 TLS 區塊 (也就是主要執行檔的 TLS 區塊) 時才能使用。在實務上,只有主要執行檔可使用這種存取模式,因為任何共用程式庫無法 (通常也不需要) 知道是否正在存取主要執行檔中的資料。連結器會將 initial-exec 放寬為 local-exec。由於編譯器不知道目前的轉譯單元是否會連結至主要執行檔或共用程式庫,因此如果沒有透過 -ftls-model__attribute__((tls_model("..."))) 提供明確指示,編譯器就無法執行這項操作。

這種偏移方式的精確細節,會從架構變更為其他架構,

範例程式碼:

static thread_local int a;
static thread_local int b;

int main() {
  return a + b;
}

會變成

int main() {
  return (int*)($tp+TPOFF_a) + (int*)($tp+TPOFF_b));
}

在 AArch64 TPOFF_a == max(16, p_align) + <a> 上,p_align 是主要執行檔 PT_TLS 區段的 p_align 欄位,而 <a> 則是從主要執行檔的 TLS 區段開頭的 a 偏移值。

x86_64 TPOFF_a == -<a> 上,<a> 是從主要執行檔傳輸層安全標準 (TLS) 區段結束a 偏移。

連結器知道任何指定 XTPOFF_X,並填入這個值。

實作

本節會說明 Fuchsia 實際導入的實作方式。這能確保此處廣泛的筆劃在不同 libc 實作中大不相同,包括 musl 和 glibc。

實際的實作項目會加入一些細節。稱呼所謂的「DTV」(動態執行緒向量) (在本文件中註明的 dtv),這會依模組 ID 為 TLS 封鎖建立索引。下圖顯示初始執行集的呈現方式。在 Fuchsia 的實作中,我們實際上是在執行緒描述元結構體中和 ABI TCB (下方註明的 tcb) 儲存大量中繼資訊。在實作中,我們會使用這個空間的前 8 個位元組 指向 DTV。在第一個 tcb 指向 dtv (如下圖所示),但在 dlopen 之後,這個值可能會變更。

arm64:

*------------------------------------------------------------------------------*
| thread | tcb | X | tls1 | ... | tlsN | ... | tls_cnt | dtv[1] | ... | dtv[N] |
*------------------------------------------------------------------------------*
^         ^         ^             ^            ^
td        tp      dtv[1]       dtv[n+1]       dtv

此處的 X 設有 min(16, tls_align) - 16 大小,其中 tls_align 是靜態 TLS 集內所有載入的 TLS 區段最大對齊。由於靜態連結器會解析 TPOFF_* 值,因此由靜態連結器設定。設定這個邊框間距後,如果必要,$tp 會與主要執行檔的 PT_TLS 區隔的 p_align 值對齊,則 tls1 - $tp 會是 max(16, p_align)。確保 ABI TCB 始終至少有 16 位元組空間 (上圖中註明的 tcb)。

x86:

*-----------------------------------------------------------------------------*
| tls_cnt | dtv[1] | ... | dtv[N] | ... | tlsN | ... | tls1 | tcb |  thread   |
*-----------------------------------------------------------------------------*
^                                       ^             ^       ^
dtv                                  dtv[n+1]       dtv[1]  tp/td

此處的 td 代表「執行緒描述元指標」。在這兩種實作方式中,這個點都會指向執行緒描述元。在這些圖表中,一個不明顯的一點是,在兩種情況下,tcb 實際上是執行緒描述元結構的成員,但在 AArch64 上,這是最後一個成員,在 x86_64 則是第一個成員。

藍綠色

這張圖片說明初始執行檔會發生什麼事,但並未說明 dlopen 情況發生了什麼事。呼叫 __tls_get_addr 時,系統會先檢查 tls_cnt 是否讓模組 ID (由 GOT_s[0] 提供) 是否位於 dtv 中。如果是,只會查詢 dtv[GOT_s[0]] + GOT_s[1],但若是沒有更複雜的結果。請參閱 dynlink.c 中的 __tls_get_new 實作。

簡單來說,我們已針對呼叫 dlopen 時為較大的 dtv 分配到足夠大的空間。它是不變的系統,可讓足夠空間始終存在已分配的位置。較大的空間會設為正確的 dtv。接著,tcb 已設為指向這個較大的新 dtv。由於 tls_cnt 相當龐大,未來存取時將使用更簡單的程式碼路徑。