當取用巢狀函式的位址時,GCC 會在執行時期建立一個跳板。這個跳板放置在堆疊上,這意味著堆疊必須是可執行的。這是一個問題,因為不可執行的堆疊是一項重要的安全功能。一段時間以來,GCC 提供了將跳板放置在堆疊上的選項,這比較好,但仍不理想,因為配置的成本較高,而且在使用 longjmp 時跳板可能會洩漏。先前,我指出了如何透過新的寬指標類型來避免這種情況,並討論了 GCC 的初步補丁,該補丁實現了實現此目標所需的一些核心功能。
首先,GCC 16 已發佈。在 GCC 16 中,保證不存取父函式變數的巢狀函式將不需要跳板。實際上,這在優化時已經確保,但現在即使不優化也已確保並記錄在案。這也意味著您可以安全地從父函式返回此類函式,並且在嘗試返回存取局部上下文的函式時會收到警告(至少在編譯器明顯的情況下),如下例所示(Godbolt 範例)
typedef int cb_f(int); cb_f *foo(int x) { int worker(int y) { return x + y; // 擷取 } return worker; }
cb_f *bar(int _x) { static int x; x = _x; int worker(int y) { return x + y; // 無擷取 } return worker; }
不存取父函式變數的巢狀函式仍然可以存取靜態變數、命名常數或類型(如果不是變數修改)。這對於編寫回呼函式很有用(Godbolt 範例)。
typedef int cb_f(void *, int y); int process(cb_f *cb, void *data) { return cb(data, 5); }
int foo(int x) { struct { int x; } data = { .x = x }; int worker(void *_data, int y) { typeof(data) *data = _data; return data->x + y; }
return process(worker, &data); }
儘管如此,巢狀函式的真正威力在於能夠直接存取父函式的變數。此外,上面的程式碼看起來像是我們透過手動添加一些樣板程式碼來模擬此類函式——編譯器當然應該能夠在這方面提供幫助。這就是我的第二個更新的內容。
最近,我的補丁的一個版本已合併到 GCC 的開發分支中,該分支實現了兩個新的內建函式,可以與現有的內建函式 __builtin_call_with_static_chain 一起使用,以編寫此範例(Godbolt 範例),而無需跳板。
事實上,由於沒有跳板,GCC 在底層所做的就是自動將其轉換為上述範例!與手動編寫的版本唯一的區別是資料指標是透過特殊暫存器傳遞的。此暫存器被許多其他程式語言用於傳遞靜態鏈,因此 ABI 已為此用途保留。
即使沒有適當的語言支援,我們仍然可以透過定義一個通用寬指標類型並透過巨集包裝內建函式來使此範例更漂亮。(Godbolt 範例)
#define wide(T) struct wide_##T { typeof(T) *code; void *chain; }
typedef int cb_f(int y); int baz(wide(cb_f) p, int x) { return CALL(p, (x)); }
int foo(int k) { int bar(int x) { return k + x; }
return baz(CLOSURE(cb_f, bar), 2 * k); }
編譯器將會愉快地將其優化為單一的組合語言指令,該指令實現乘以三的操作。
foo:
lea eax, [rdi+rdi*2]
ret
展望
雖然有其他編譯器支援巢狀函式,但此功能並不是真正可移植的。未來,我將討論如何在 clang 和 GCC 中使用巢狀函式,以及如何透過一個技巧,即使在舊版本的 GCC 上也能避免產生跳板。