延遲繫結(lazy binding)
一個大型程式可能連結了許多共享函式庫,它們合起來匯出數千個函式——但在典型的一次執行裡只呼叫其中一小部分。在啟動時解析每一個那些位址,會為可能永遠用不到的函式拖慢程式的啟動。延遲繫結是把每個函式的位址查找延後到它第一次真正被呼叫的那一刻的最佳化,於是你只為用到的付費,而且分散著付而非一次付清。
把機制走一遍。每個外部函式得到一個 PLT 樁與一個 GOT 槽。一開始動態連結器把每個 GOT 槽設成指回一個呼叫解析器的特殊 PLT 項目。你第一次呼叫,譬如 foo() 時,PLT 樁透過 foo 的 GOT 槽跳躍,落在那段解析器程式碼裡;解析器在已載入的函式庫中查找 foo、把 foo 的真正位址寫進 GOT 槽、跳到 foo。關鍵在於那個副作用:GOT 槽現在持有 foo 的真正位址,所以「第二次」與之後所有對 foo 的呼叫都直接通過、不經解析器——成本恰好只付一次,在第一次使用時。這就是它叫「延遲」的原因:工作被推遲到無可避免才做。
它對大型程式的啟動時間有意義,但帶有真實的告誡。每個函式的第一次呼叫因為一次性解析而稍慢,這可能擾動對時間敏感的程式碼。更重要的是,延遲繫結要求 .got.plt 保持可寫,好讓解析器修補它,這削弱了強化。基於這個原因,注重安全的建構往往把延遲繫結「關掉」——在建構時用完整 RELRO,或設定環境變數 LD_BIND_NOW=1——於是每個符號在啟動時就被積極解析,GOT 可被鎖成唯讀。所以延遲繫結是速度對安全的取捨,不是無條件的勝利。
# 第一次呼叫 foo():PLT 樁 -> GOT 槽 -> 解析器寫入真正位址。 # 之後每次呼叫:PLT 樁 -> GOT 槽已持有 foo -> 直接跳。 $ LD_BIND_NOW=1 ./app # 停用延遲:在啟動時解析所有東西
第一次呼叫解析並把位址快取到 GOT;之後的呼叫直接進行。LD_BIND_NOW 為了強化而停用延遲。
延遲繫結讓 .got.plt 保持可寫,這是個強化弱點;完整 RELRO 或 LD_BIND_NOW 會積極解析所有東西,使 GOT 能變唯讀。每個延遲繫結函式的第一次呼叫比之後的呼叫稍慢。