http://www.ibm.com/developerworks/cn/linux/l-cn-prcss-hotupgrd/
爲了實現 Linux 系統進程熱升級,本文提供了一種底層的實現方法,即在不重啓進程的條件下,升級進程的共享庫模塊。
背景
用戶總是希望服務進程能保持穩定。如果可以 7*24 小時的工作,那就永遠不要重啓它。但是,軟件產品的功能總是在不斷的豐富。當用戶發現一些新的功能正是他所需要的,他也許會主動要求進行一次升級。而當嚴重的安全問題出現時,用戶就不得不接受強制的升級了。
不停機升級,也被稱爲熱升級。通常實現熱升級,需要用戶部署兩套業務系統。至少,被升級的關鍵模塊是兩塊以上的。這一般是通過硬件方式支持的。由此而產生的成本壓力,不是每個用戶都可以接受的。
對於小型業務系統,頻繁的升級總是不可避免的。如果升級過程中,業務進程不用重啓,那麼,升級將不再是一個令用戶煩惱的事情了。
動態鏈接的共享庫
Linux 環境中的應用依賴相當數量的共享庫。通常,爲了達到軟件模塊化的目的,開發人員會把邏輯上緊密相關的功能集中在一起,編譯到共享庫中。這樣做,既有利於代碼的管理,也便於模塊的複用。同時,共享庫的方式也有利於應用升級。許多時候,僅僅更新數個共享庫就可以完成整個應用的升級,降低了升級時的開銷。
如果應用支持手工觸發重新裝載共享庫,就不需要重啓。但如果應用正巧並不支持,那麼,更換共享庫後仍需要重啓應用。本文提供了一種方法可以在應用保持運行狀態下,替換共享庫。在替換過程中,應用被無縫的切換到新的共享庫中。整個過程,應用(進程)無需重啓。
基本過程
完成不重啓的升級,需要一系列的複雜步驟。一個獨立升級程序 U 來負責觸發目標應用(進程 T )掛載新的共享庫 L 。假設 U , T , L 是它們的名字。基本步驟如下:
- 升級程序 U 要找到進程 T 的 dlopen 函數的入口地址。
- 升級進程 U 執行 attach 系統調用,貼附到進程 T 上。向進程 T 的堆棧裏壓入新的共享庫 L 的名字,再把 dlopen 函數的入口地址賦值給 PC 寄存器。
- 讓目標進程 T 繼續運行。由於 PC 寄存器保存的是 dlopen 函數的入口地址,這樣,在目標進程 T 空間裏,dlopen 函數被調用。新的共享庫 L 被目標進程 T 裝載。
- 新的共享庫 L 在被裝載時,利用 dlsym 函數在目標進程 T 中找到被替換函數的地址。設置被替換函數的代碼空間爲可寫狀態。
- 將彙編指令 0xCC 和 0xC3 寫入被替換函數入口。0xCC 是彙編 INT 3 的指令碼。0xC3 是彙編 RET 的指令碼(注: 0xC3 是 64 位系統的指令碼)。顯然,由於 INT 3 的存在,當目標進程 T 調用這個被替換函數時,就會觸發一次 SIGTRAP 信號。
- 新的共享庫 L 在被裝載時,調用 sigaction 函數,接管 SIGTRAP 信號。在信號處理函數中,調用用於代替被替換函數的新函數。
- 至此,新的共享庫 L 的函數替代了目標進程 T 中原先使用的舊函數。每當目標進程 T 試圖調用被替換函數時,都會觸發 SIGTRAP 信號。然後,信號處理函數調用新的函數。這個過程將一直存在於進程 T 的整個生存週期中。
從上面的步驟可以得知,本方法適用於共享庫的升級。通過替換舊的共享庫中函數,實現升級。在上面的步驟的實施前,可以先對文件系統中共享庫進行替換。這樣,在無鏠升級後,當目標進程 T 有機會進行重啓,再度啓動的應用將直接加載新的共享庫,而不再需要上面的複雜升級過程了。
本方法在底層對進程的內存數據進行了修改。由於不同體系,不同位數的 CPU ,指令碼,寄存器,以及函數調用的棧幀結構都是不同的,因此,不同的硬件條件,升級程序將會有所差別。但是,基本原理是相同的。下面,分別詳細介紹 x86 和 ARM 版本的實現細節。
基於 x86 的實現
本節根據前一節的基本步驟所述的內容,展示在 x86_64 CPU 體系上的實現步驟和關鍵代碼,並對代碼給予詳細的說明。本章所列出的步驟將更爲詳細。
得到 dlopen 函數在目標進程 T 的地址。
假設升級程序 U 已經得到目標進程 T 的 PID。PID 爲 t_pid。
注:我們的目標進程 T 是 ELF 格式的程序。在 glibc 中,完成共享庫加載的函數是 __libc_dlopen_mode。詳情可參見 glibc 的相關資料和代碼。
清單 1. 得到 dlopen 函數地址
snprintf(path, sizeof(path), "/proc/%d/maps", my_pid); if ((f = fopen(path, "r")) == NULL) return -1; for (;;) { Read a line form maps file Look for a line with “r-xp” and libc- substring If found { addr = the first field of line; break; } } fclose(f); dlopen_entry = dlsym(NULL, "__libc_dlopen_mode"); if (!dlopen_mode) { printf("Unable to locate dlopen address.\n"); return -1; } dlopen_offset = dlopen_entry – addr; /* calc offset */ t_libc = begin of libc of target process T; /* get from maps file of target T */ if (!t_libc) { printf("Unable to locate begin of target's libc.\n"); return -1; } dlopen_entry = t_libc + dlopen_offset;
升級程序 U 在啓動後,調用 getpid 系統調用,得到自己的 PID (變量 my_pid ),進而確定 proc 目錄下的 maps 文件的路徑。打開 maps 文件,該文件描述了不同的 section 在進程空間裏的分配情況。形式如下:
2b779cdbf000-2b779cdc1000 r-xp 00000000 08:01 1446923 /lib64/libc-2.5.so
文件由多行組成。每行則由多個字段組成。字段間用空格分隔。
第一列描述了 section 的起始和結束地址:2b779cdbf000-2b779cdc1000。
第二列描述了 section 的權限: r-xp 。每個縮寫字符的含義爲 :
r=read,w=write,x=execute,s=shared,p=private(copy on write) 。
最後一列描述了被映射文件的文件名: /lib64/libc-2.5.so 。
升級程序 U 在 maps 文件中查找權限字段爲“ r-xp ”和最後字段爲“ libc-* ”的行。找到後,取出第一字段,存入 addr 變量中。
調用 dlsym 函數得到 __libc_dlopen_mode 函數在進程空間的入口地址。將其減入 addr ,得到與 __libc_dlopen_mode 函數在 libc 中的偏移量。
圖 1. 偏移量
這個偏移量在不同的進程空間裏是相同的。因爲,不同的進程加載的是相同的 libc 庫。所以,打開目標進程 T 的 maps 文件。採用相同的方法得到 libc 在目標進程 T 的起始地址。這個起始地址加上偏移量,升級程序 U 就得到了 __libc_dlopen_mode 函數在目標進程 T 的入口地址。
Attach 目標進程 T ,備份現場數據。
清單 2. attach 目標進程
struct my_user_regs { unsigned long r15; unsigned long r14; unsigned long r13; unsigned long r12; unsigned long rbp; unsigned long rbx; unsigned long r11; unsigned long r10; unsigned long r9; unsigned long r8; unsigned long rax; unsigned long rcx; unsigned long rdx; unsigned long rsi; unsigned long rdi; unsigned long orig_rax; unsigned long rip; unsigned long cs; unsigned long eflags; unsigned long rsp; unsigned long ss; unsigned long fs_base; unsigned long gs_base; unsigned long ds; unsigned long es; unsigned long fs; unsigned long gs; }; char sbuf1[512], sbuf2[512]; struct my_user_regs regs, saved_regs, aregs; if (ptrace(PTRACE_ATTACH, t_pid, NULL, NULL) < 0) return -1; waitpid(t_pid, &status, 0); ptrace(PTRACE_GETREGS, t_pid, NULL, ®s); peek_text(t_pid, regs.rsp + 512, sbuf1, sizeof(sbuf1)); peek_text(t_pid, regs.rsp, sbuf2, sizeof(sbuf2));
調用 ptrace 函數 attach 到目標進程。成功後,獲取寄存器組。根據棧寄存器 rsp ,備份棧內共計 1024 字節的數據。這些工作都是爲了最後恢復現場做準備。
注: peek_text 函數是自定義的。它對 ptrace(PTRACE_PEEKTEXT … ) 做了封裝,以支持多字節的數據塊的讀取。系統調用 ptrace(PTRACE_PEEKTEXT … ) 調用一次只能讀取一個字。函數 peek_text 根據入參指明的長度,多次調用 ptrace 讀取多個字節。後文將提到的 poke_text 是對 ptrace(PTRACE_POKETEST … ) 的封裝,以支持寫入多字節的數據塊。
在目標進程 T 的堆棧裏準備好 dlopen 函數的數據,觸發目標進程 T 執行 dlopen 函數。
清單 3. 觸發目標進程 T 執行 dlopen 函數
z=0; strcpy(filename_new_so, “/usr/lib/libnew.so”); poke_text(t_pid, regs.rsp, (char *)&z, sizeof(z)); poke_text(t_pid, regs.rsp + 512, filename_new_so, strlen(filename_new_so) + 1); memcpy(&saved_regs, ®s, sizeof(regs)); regs.rdi = regs.rsp + 512; regs.rsi = RTLD_NOW|RTLD_GLOBAL|RTLD_NODELETE; regs.rip = dlopen_entry + 2; ptrace(PTRACE_SETREGS, t_pid, NULL, ®s); ptrace(PTRACE_CONT, t_pid, NULL, NULL); waitpid(t_pid, &status, 0);
首先,將 0 壓棧,這個數據將成爲從 dlopen 函數退出時的返回地址。這個非法地址將觸發一個異常。這使得升級程序 U 可以在目標進程調用完 dlopen 函數後,重新獲得對它的控制。
保存文件名的變量 filename_new_so 是在升級程序 U 的進程空間中,所以,需要把它放入目標進程 T 的堆棧裏。regs.rsp + 512 開始的空間已經備份過,可以把文件名存放在這裏。
然後,爲 dlopen 函數準備入參。dlopen 函數的函數聲明是
void *dlopen(const char *filename, int flag)
在 64 位 CPU 中,函數參數的傳遞是使用寄存器。因此,在這裏, rdi 寄存器保存了文件名的地址。它對應入參 filename 。寄存器 rsi 保存了標誌,對應入參 flag 。
注:在 32 位 CPU 中,函數參數是通常棧空間完成。與上面的示例是完全不同的。
最後,將指令執行地址寄存器 rip 設定爲 dlopen 函數的入口地址,調用 ptrace 函數將控制權交回給目標進程。
由於在上一步中,預置了非法的返回地址 0, SIGSEGV 信號將會發生。升級程序 U 將再次獲得控制權。在本步驟執行結束後,新的共享庫 L 將被目標進程 T 加載。用戶可以通過執行
$cat /proc/t_pid/maps
查看新的共享庫 L 是否已經被加載。
升級程序 U 恢復現場
當新的共享庫被加載後,升級程序 U 必須恢復目標進程 T 至 attach 前的時刻。
清單 4. 恢復目標進程的現場
ptrace(PTRACE_SETREGS, t_pid, 0, &saved_regs); poke_text(t_pid, saved_regs.rsp + 512, sbuf1, sizeof(sbuf1)); poke_text(t_pid, saved_regs.rsp, sbuf2, sizeof(sbuf2)); ptrace(PTRACE_DETACH, t_pid, NULL, NULL);
在第一次執行 ptrace 進行 attach 後,升級程序 U 就備份了目標進程 T 的堆棧空間和寄存器。在新的共享庫 L 加載成功後,升級程序 U 將目標進程 T 的堆棧和寄存器恢復到 attach 前的狀態。
升級程序 U 的任務到這裏就完成了。爲了替代目標進程 T 中的函數,新加載的共享庫 L 需要執行一系列特定的步驟。下面的各節描述了新的共享庫 L 裏的實現細節。
將 INT 3 和 RET 指令寫入要替代的函數
假設要替換的函數聲明爲:void old_func(void)。
該函數無入參和返回值。這裏是爲了簡化問題,便於說明基本原理。帶有入參和返回值會使處理代碼更爲複雜。
清單 5. 寫入 INT3 和 RET 指令
void _init() { unsigned char *aligned = NULL; struct sigaction sa; unsigned char * entrys [32] = {0, 0}; void *handle=dlopen(NULL, RTLD_LAZY); if (handle == NULL) return ; if ((entrys[0] = dlsym(handle, "old_func")) == NULL){ return; } memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = sigtrap; sa.sa_flags = SA_RESTART|SA_SIGINFO; sigaction(SIGTRAP, &sa, NULL); aligned = (unsigned char *)(((size_t)hooks[0]) & ~4095); if (mprotect(aligned, 4096, PROT_READ|PROT_WRITE|PROT_EXEC) != 0) { return; } entrys [0][0] = 0xcc; /* int 3 */ entrys [0][1] = 0xc3; /* 64bit ret instruction */ }
代碼清單 4 描述的是新的共享庫的代碼。
函數 _init 將在共享庫被加載時隱式執行。值得注意的是,函數 _init 是在目標進程 T 的空間中運行。
首先,它調用入參爲 NULL 的 dlopen 函數得到全局符號句柄。依靠全局符號句柄,再調用 dlsym 函數獲得 old_func 函數的入口地址。
然後,設置信號 SIGTRAP 的處理函數爲自已的 sigtrap 函數。
最後,它將 old_func 函數的內存空間修改爲可讀寫執行模式。將 old_func 函數的第一個指令設置爲 0xCC ;第二個指令設置爲 0xC3 。 0xCC 是彙編指令 INT 3 的指令碼。 0xC3 是彙編指令 RET 的指令碼。由於被替換函數 old_func 是一個無入參,無返回值的函數,所以,在修改這個函數時,無需堆棧處理。但在實際應用中,如果函數有入參和返回值,就不可以直接使用 RET 指令,而是需要對堆棧進行精確的處理,保證目標進程 T 的堆棧的正確。
注:代碼段是可讀,可執行,但不可寫的。所以,爲了寫入新的指令,必須將代碼段設爲可寫模式。
在 sigtrap 信號處理函數裏,調用 new_func 函數。
在上一步中,函數 _init 對信號 SIGTRAP 設置了處理函數。本節介紹這個處理函數的細節。該函數的實現代碼屬於新的共享庫 L 。
清單 6. sigtrap 函數
void new_func(void) { printf(">> this is new function\n"); return ; } static void sigtrap(int x, siginfo_t *si, void *vp) { new_func(); return; }
信號處理函數異常簡單,僅僅是調用新的函數 new_func 。這個函數正是用於替換函數 old_func 的。
到此,舊的函數 old_func 就完全被替代了。每當目標進程 T 調用 old_func 函數時,由於 old_func 函數第一個指令爲 INT 3 ,這將觸發一個 SIGTRAP 信號。導致 sigtap 信號處理函數被調用。在信號處理函數內部,用來替代 old_func 的 new_func 函數被調用。從 sigtrap 函數返回後,由於第二個指令是 RET ,目標進程 T 對 old_func 的調用完成。對於目標進程 T 來說,雖然它調用的是 old_func 函數,但實際得到執行的卻是 new_func 。它根本無法查覺到 old_func 函數已經被替換成了 new_func 函數。
值得一提的是,在升級程序 U 執行熱升級任務之前,可以先對磁盤上的共享庫文件升級覆蓋。在新的共享庫文件中, old_func 函數已經被去除, new_func 函數已經編譯在程序中。這樣,當目標進程 T 重啓後, new_func 函數將經由正常的啓動途徑被加載,而無需上面的複雜機制。下面的示意圖可以幫助我們更好的理解新舊共享庫,函數之間的關係:
圖 2. 升級後,目標進程 T 內部調用關係
基於 ARM 的實現
本文所述方法也適用於 ARM 體系。但是,一些與 CPU 有關的地方,則有所不同。本節詳細說明不同之處。其餘部分完全相同。
第一個不同之處是“觸發目標進程 T 執行 dlopen 函數”。而獲取 dlopen 函數的方法與 x86 相同。
清單 7. 觸發目標進程 T 執行 dlopen 函數(ARM)
peek_text(t_pid, regs.ARM_sp + 512, sbuf1, sizeof(sbuf1)); peek_text(t_pid, regs.ARM_sp, sbuf2, sizeof(sbuf2)); strcpy(filename_new_so, “/usr/lib/libnew.so”); poke_text(t_pid, regs.ARM_sp + 512, filename_new_so, strlen(filename_new_so) + 1); memcpy(&saved_regs, ®s, sizeof(regs)); regs.ARM_r0 = regs.ARM_sp + 512; regs.ARM_r1 = RTLD_NOW|RTLD_GLOBAL|RTLD_NODELETE; regs.ARM_lr = 0; regs.ARM_pc = (size_t)dlopen_entry; ptrace(PTRACE_SETREGS, t_pid, NULL, &callso_regs); ptrace(PTRACE_CONT, t_pid, NULL, NULL); waitpid(ph->pid, &status, 0);
同樣的,首先備份棧內數據。 ARM 的棧寄存器是 sp 。代碼中記爲 ARM_sp 。準備 dlopen 函數的入參的步驟與 x86 有很大的不同。這是因爲 x86 使用棧來傳遞參數,而 ARM 則使用 R0~R3 寄存器來傳遞參數。如果參數個數大於 4 ,再使用棧空間。因此,這裏, ARM_r0 寄存器指向新的共享庫的文件名。 ARM_R1 寄存器保存了標誌。 ARM 的函數返回地址是保存在 lr 寄存中的,爲了觸發異常,而使升級程序 U 在加載了新的共享庫後,重新得到控制權,在這裏,我們爲 lr 寄存器設置了無效的返回值 0 。這與 x86 中的向棧內壓入值爲 0 的變量 z 是一樣的目的。最後,爲 pc 寄存器設置 dlopen 函數的入口地址。
第二處不同是向被替換函數寫入的指令不同。
在 x86 裏,我們使用 INT 3 來發出 SIGTRAP 信號。然後在信號函數裏調用新的函數,以達到替換的目的。但是,利用 ARM 指令來實現 SIGTRAP 信號的觸發,較爲繁瑣。故改用跳轉指令。代碼如下所示。
清單 8. 寫入無條件轉移指令
void _init() { unsigned char *aligned = NULL; struct sigaction sa; unsigned char * entrys [32] = {0, 0}; void *handle=dlopen(NULL, RTLD_LAZY); if (handle == NULL) return ; if ((entrys[0] = dlsym(handle, "old_func")) == NULL){ return; } aligned = (unsigned char *)(((size_t)hooks[0]) & ~4095); if (mprotect(aligned, 4096, PROT_READ|PROT_WRITE|PROT_EXEC) != 0) { return; } entrys [0][0] = 0xe59ff008;; /* ldr pc, [pc, #8] */ entrys [0][1] = (int)new_func; /* data */ entrys [0][2] = (int)new_func; entrys [0][3] = (int)new_func; entrys [0][4] = (int)new_func; }
ARM 裏的無條件跳轉指令有 B 、 BL 、 BX 、。但是它們都有 32MB 跳轉範圍的限制。ARM 可以通過直接修改 PC 寄存器,實現 4GB 空間的無條件跳轉。在向 PC 寄存器存入地址時,不能直接使用 MOV 指令存入絕對地址,像下面的指令:
mov pc, #40200000;
是無法通過編譯的。因此,我們在這裏使用 ldr 指令,在指令後面的內存空間裏存放跳轉地址。entrys[0][1]~[0][3] 是用於填充空間,並無實際意義。 ldr 指令實際是從 entrys[0][4] 中取出地址。這個地址正是新的函數的入口地址。
當目標進程 T 調用 old_func 函數時,該函數的入口是一條跳轉到 new_func 函數的指令。函數 new_func 被調用,而函數 old_func 就被繞過。函數 new_func 的入參和返回值 old_func 保持一致,實現了無縫升級。下面的示意圖可以幫助我們更好的理解:
圖 3. 升級後,目標進程 T 內部調用關係
總結
沿着本文所述方法的思路,可以進一步擴展支持更爲廣泛的目標進程。比如,本文利用 dlsym 來定位舊函數的入口地址。但 dlsym 無法定位非共享庫的函數。這時,就需要對進程的映射文件(外存設備上的 ELF 格式文件)進行解析,計算出在內存空間的的地址。
另外,在 x86 版本中,我們使用了 INT 3 的方法。其實,我們也可以使用 JMP 指令,採用 ARM 版本的方法來實現替換。而且,看起來這種方法更爲完美。
總之,進程熱升級可以很好的提高系統設備的可靠性和安全性。每當有 hotfix 補丁時,如果用戶不希望設備關機升級,產品的開發商可以利用本文的方法對設備升級,避免因爲不能停機的緣故,而無法打上安全補丁,而使產品帶着漏洞運行。
參考資料
學習
- 參考"程序員雜誌 104 期"的一篇文章:楊廣翔,2008. ELF 格式可執行程序的代碼嵌入技術。
- 如果希望瞭解鏈接和裝載方面的信息,請參考: John R. Levine, October 1999. Linkers and Loaders, Morgan Kaufmann
- 杜春蕾,2003,《ARM 體系結構與編程》,清華大學出版社
- 在 developerWorks Linux 專區 尋找爲 Linux 開發人員(包括 Linux 新手入門)準備的更多參考資料,查閱我們 最受歡迎的文章和教程。
- 在 developerWorks 上查閱所有 Linux 技巧 和 Linux 教程。
- 隨時關注 developerWorks 技術活動和網絡廣播。