作者:markshuang,騰訊 IEG 背景開發工程師
1 背景介紹
我們知道遊戲伺服器經常有小版本釋出,如果每次一點小的改動就重新開機,對于遊戲業務來說或多或少是有損服務的,如果是有狀态的程序影響更大,是以伺服器支援熱更能夠使得服務更加穩定、使用者體驗更好。
2 不同熱更方案
不同的伺服器有各自的熱更方式,比如 java 熱更(替換記憶體中已經加載好的 class 位元組碼)、内嵌 lua 熱更(通過 lua 提供的 require 機制強制重新整理已加載好的子產品)、C++熱更(例如通過修改現有函數最開始的指令 jmp 至新函數位址處,其中涉及的細節比較多、還有一種針對 so 的熱更方式修改程序記憶體中的 GOT 表,跳轉至新函數進而達到熱更這也正是本文要介紹的方法)。
3 原理介紹
C++程式在運作時有兩種方式加載動态連接配接庫:隐式連結和顯式連結。 (1) 隐式連結就是在編譯的時候使用-l 參數連結的動态庫,程序在開始執行時就将動态庫檔案映射到記憶體空間中。 (2) 顯示連結使用 libdl.so 庫的 API 接口在運作中加載和解除安裝動态庫,主要的 API 有 dlopen、dlclose、dlsym、dlerror。
動态修改 GOT 表達方式适用于上面描述中的第一種情況。
針對隐式連結 so 庫的函數調用和資料通路方式做了個總結:
(1) 子產品内部的非靜态函數調用 使用 plt 的方式通路
(2) 子產品内部的靜态函數調用 使用相對位址的方式通路
(3) 子產品間的函數通路 使用 plt 的方式通路
(4) 子產品内部的全局變量通路 使用 got 表的方式進行通路
(5) 子產品内部的靜态變量通路 使用相對位址的方式通路
(6) 子產品間的資料通路 使用 got 表的方式進行通路
針對其中函數調用的情況總結下來隻有子產品内部的靜态函數調用不會通過 plt 的方式去調用,另外兩種函數調用方式都适用于本文介紹的熱更方法。
先來分析下什麼是 PLT 和 GOT,通過一個最簡單的例子來看下。

fun 函數是定義在 lib1.so 裡面的函數
可以看到調用 fun 的時候并不是直接通過函數位址跳轉而是采用了 [email protected] 的方式,這裡的 plt 就是上文講的 plt,那為什麼要用這個呢?因為程式在連結動态庫的時候并不知道程序啟動加載連結庫之後函數的位址在哪裡,是以上面代碼裡面的 fun 函數位址是沒法确定和填充的,那麼什麼時候可以确定函數的位址呢?程序運作加載完動态庫之後可以找到函數位址,于是就把真正解析函數位址的時機留到了運作時去處理。沒錯這個 plt 就是用來幹這件事的,plt 通過一段代碼指令來完成動态庫函數的運作時重定位,但是這樣的話還面臨兩個問題:
1 調用函數的地方在代碼段,目前的作業系統中預設情況下代碼段沒有可寫屬性,不過資料段是可以修改的
2 程序 mmap 動态庫到記憶體裡面使用的是 private 方式(從下圖代碼中可以看出),如果修改了代碼段裡面的内容,那麼就會觸發寫時複制機制,這些代碼段内容就無法做到系統内所有程序共享。
是以 fun 函數位址不能回寫到代碼段,而應該寫到資料段,還記得上面提到的 GOT 嗎?沒錯 GOT 就是在資料段的,GOT 裡面會記錄 fun 的位址,繼續剛剛函數運作的流程往下看。
[email protected] 第一條指令就是 jmp 到 [email protected],這個地方記錄的的是記錄 fun 函數位址的 got 表項,不過第一次調用的時候 fun 在 GOT 裡面的表項還沒初始化,是以跳轉到 f[email protected] 後面的指令,後面的指令會真正解析 fun 函數位址,如果每次調用都去解析一遍會比較浪費,是以在第一次解析成功之後會覆寫 got 表項,那麼再次調到這裡就不用再走後面的解析流程了,0x601018 這個位址是 fun 的 got 表項位址,目前記錄的是 plt 後面的指令位址,下圖中在正确解析之後覆寫 0x601018 内容更新為 fun 真實的位址。
等我們再次調用這個函數就直接 jmp 到 fun 函數了。畫了個調用流程圖:
可以看出來調用完之後 GOT 表項裡面就是真實的 fun 函數位址,這個在資料段是可寫的,熱更的時候把這個表項位址覆寫為新的函數位址即可。
4 資料繼承問題
下面是程序在虛拟記憶體中的布局圖:
熱更新之後對于資料繼承問題我把資料拆分成幾種類型依次說下: (1) 全局變量 -Wl,-export-dynamic 選項可以把原程序資料導出。 (2) 靜态變量 這個随着庫加載而确定位址的,而且 linux 有 ASLR 機制每次啟動都不一樣有随機性,但是離庫 mmap 的首位址偏移是固定的,是以使用 offset 方式通路。 (3) 局部非靜态變量 這個生命周期和函數調用棧幀一緻,函數内部維護堆棧平衡即可,通路權限也隻僅限函數調用期間的函數内部通路,是以不需要處理。
5 設計與實作
void fun()->void fun_v_1()
為了通路各種類型的資料和函數我特意定義了全局變量、靜态變量、局部靜态變量
(1) 首先把 1.c 編譯成 lib1.so
gcc -fPIC -shared -o lib1.so 1.c
(2) a.out 隐式連結 lib1.so,并且調用 fun 函數
gcc main.c -L./ -l1 -g -Wl,-rpath ./ -Wl,-export-dynamic -ldl./a.out
(3)熱更 fun 函數,先定義新函數 fun_v_1 原型和 fun 一樣,編譯生成 lib2.so
(4) a.out 捕獲 SIGUSR1 信号,在這個函數裡面熱更新 fun 函數,指派 GOT 表
(5)運作效果列印了變量位址可以發現熱更前後全局變量和靜态變量位址都是一緻的,熱更之後再次調用 fun 進入到函數 fun_v_1 了
6 與 textcode jmp 熱更方案對比
這種做法是通過 ptrace attach 到程序,先儲存目前的寄存器資訊,然後修改 rip 位址跳轉到 dl_open 函數,dl_open 函數加載 so 的時候會先執行 init 段代碼,然後在 init 裡面找到原函數位址修改最開始的指令為 jmp 到新的熱更函數,完成之後恢複之前儲存的寄存器,detach 程序,後面再次調用原函數就會跳轉到新函數進而完成熱更,下面對比兩種方案的優缺點。
(1) 性能方面 可以看到修改 GOT 表之後性能沒有任何損失和之前的調用流程完全一樣,textcode jmp 方式多了一次 jmp,另外現代 CPU 會預取指令,jmp 的話會使得預取到的指令失效 (2) 适用場景 修改 GOT 表方式隻能應用于隐式加載 so 的場景,textcode jmp 适用的場景更廣 (3) 便捷與安全性方面 修改 GOT 表方式修改的是具有可寫屬性的資料段,對程序沒有影響,textcode jmp 修改了原本沒有可寫屬性的代碼段需要 attach 到原程序從過程上來說更為兇殘(^^)一點
7 總結與後續
綜上可知修改 GOT 表确實可以熱更 so,但是也有一些限制隻能熱更使用 GOT 跳轉的函數,對于其他函數還是要通過其他的比如入侵式的修改函數指令方式熱更,從性能上來說熱更前後性能一緻,沒有多餘的指令,但是如果用在多線程裡面需要注意一點,第一次調用解析函數覆寫之前熱更函數,會出現解析完之後覆寫回去的問題如下圖中 A 線程第一次調用這個函數在即将解析成功之前(嚴格來說執行到第 3 步之後第七步之前),B 線程熱更,等 A 線程解析完成傳回的時候會覆寫回去,導緻熱更失效。
以上就是對 GOT 熱更 so 的探讨,後期的話準備進一步工程化,規範一些流程和操作,使得可以友善的在項目中去使用。