在 GCC 與 Clang 中使用巢狀函式而不依賴可執行堆疊
在 C 語言的 巢狀函式(nested function)常被拿來實作回呼(callback)時,GCC 17 與 Clang 會自動在堆疊上產生 trampoline(跳板程式碼),並以 可執行堆疊(executable stack)呼叫。雖然接受可執行堆疊的安全風險已被弱化,但在需要支援較舊版 GCC 時,仍希望避免此需求。本文說明一種不依賴可執行堆疊的「hack」做法,並解析其原理與限制。
1. GCC 產生 trampoline 的機制
以下簡化程式展示了巢狀函式的典型寫法(取自原文的 Godbolt 範例):
typedef int cb_f(int y);
int baz(cb_f p, int x) { return p(x); }
int foo(int k) {
int bar(int x) { return k + x; }
return baz(bar, 2 * k);
}
編譯後的 foo 會在堆疊上配置 24 位元組的 trampoline,內容類似:
movq $bar.0, %r11 ; 函式位址
movq $frame, %r10 ; 靜態鏈(static chain)指向捕獲變數結構
jmp *%r11 ; 跳至實際程式碼
trampoline 同時儲存 靜態鏈(父函式的環境指標)與 程式碼位址,然後透過 call *%rax 直接呼叫。這就是為什麼需要可執行堆疊:trampoline 本身必須能被 CPU 執行。
2. 從 trampoline 取出資訊,直接呼叫本地函式
若不想讓 CPU 執行堆疊上的程式碼,我們可以讀取 trampoline 內的兩個常數,分別得到:
code:指向巢狀函式本身的程式碼位址chain:指向靜態鏈的指標
原文示範了使用 peek 與 array_slice 巨集取得這兩個指標(以 x86_64 大記憶體模型為例):
unsigned char (*tramp)[24] = (void*)bar;
void *code = peek(uint64_t, &array_slice(tramp, 2, 10));
void *chain = peek(uint64_t, &array_slice(tramp, 12, 20));
取得之後,利用 GCC 內建函式 __builtin_call_with_static_chain 即可直接呼叫:
__builtin_call_with_static_chain(((typeof(bar)*)code)(arg), chain);
此呼叫方式等價於原本的 trampoline,但 不再需要可執行堆疊,因為實際執行的程式碼位於常規的可執行段落。
3. 為何這是舊版 GCC 的備援方案
GCC 17 正式引入了 __builtin_call_static_chain 與 __builtin_call_code_address 兩個新內建,直接提供 code 與 chain。在尚未支援這兩個內建的舊版 GCC(如 GCC 12、13),仍可透過 手動解析 trampoline 的方式取得相同資訊,達到相同效果。
缺點:
-
仍會產生 trampoline:編譯器仍會在堆疊上放置 trampoline,只是我們不使用它執行。
-
間接呼叫無法被去虛擬化(devirtualize):編譯器無法在編譯期確定呼叫目標,可能影響效能。
因此,此技巧適合作為 兼容性備援,而非長期最佳化手段。
4. 未來展望與讀者建議
- 隨著 GCC 17 及更新版的普及,開發者可直接改用官方提供的
__builtin_call_static_chain系列,省去手動解析的繁瑣與潛在錯誤。 - 若必須支援舊版編譯器,建議將上述 hack 包裝成可重用的宏或函式庫,並在編譯時偵測 GCC 版本自動切換。
- 安全導向的程式碼基礎仍應盡量避免依賴可執行堆疊,尤其在嵌入式或高安全性環境中。
結論:透過讀取 trampoline 內的程式碼位址與靜態鏈,並搭配 __builtin_call_with_static_chain,開發者可以在不啟用可執行堆疊的前提下,讓 GCC 舊版也能支援巢狀函式的回呼機制。此方法雖有一定限制,但為跨版本相容提供了實用的過渡方案。
