Linux 進程熱升級(共享庫的動態替換)

http://www.ibm.com/developerworks/cn/linux/l-cn-prcss-hotupgrd/

爲了實現 Linux 系統進程熱升級,本文提供了一種底層的實現方法,即在不重啓進程的條件下,升級進程的共享庫模塊。

背景

用戶總是希望服務進程能保持穩定。如果可以 7*24 小時的工作,那就永遠不要重啓它。但是,軟件產品的功能總是在不斷的豐富。當用戶發現一些新的功能正是他所需要的,他也許會主動要求進行一次升級。而當嚴重的安全問題出現時,用戶就不得不接受強制的升級了。

不停機升級,也被稱爲熱升級。通常實現熱升級,需要用戶部署兩套業務系統。至少,被升級的關鍵模塊是兩塊以上的。這一般是通過硬件方式支持的。由此而產生的成本壓力,不是每個用戶都可以接受的。

對於小型業務系統,頻繁的升級總是不可避免的。如果升級過程中,業務進程不用重啓,那麼,升級將不再是一個令用戶煩惱的事情了。

動態鏈接的共享庫

Linux 環境中的應用依賴相當數量的共享庫。通常,爲了達到軟件模塊化的目的,開發人員會把邏輯上緊密相關的功能集中在一起,編譯到共享庫中。這樣做,既有利於代碼的管理,也便於模塊的複用。同時,共享庫的方式也有利於應用升級。許多時候,僅僅更新數個共享庫就可以完成整個應用的升級,降低了升級時的開銷。

如果應用支持手工觸發重新裝載共享庫,就不需要重啓。但如果應用正巧並不支持,那麼,更換共享庫後仍需要重啓應用。本文提供了一種方法可以在應用保持運行狀態下,替換共享庫。在替換過程中,應用被無縫的切換到新的共享庫中。整個過程,應用(進程)無需重啓。

基本過程

完成不重啓的升級,需要一系列的複雜步驟。一個獨立升級程序 U 來負責觸發目標應用(進程 T )掛載新的共享庫 L 。假設 U , T , L 是它們的名字。基本步驟如下:

  1. 升級程序 U 要找到進程 T 的 dlopen 函數的入口地址。
  2. 升級進程 U 執行 attach 系統調用,貼附到進程 T 上。向進程 T 的堆棧裏壓入新的共享庫 L 的名字,再把 dlopen 函數的入口地址賦值給 PC 寄存器。
  3. 讓目標進程 T 繼續運行。由於 PC 寄存器保存的是 dlopen 函數的入口地址,這樣,在目標進程 T 空間裏,dlopen 函數被調用。新的共享庫 L 被目標進程 T 裝載。
  4. 新的共享庫 L 在被裝載時,利用 dlsym 函數在目標進程 T 中找到被替換函數的地址。設置被替換函數的代碼空間爲可寫狀態。
  5. 將彙編指令 0xCC 和 0xC3 寫入被替換函數入口。0xCC 是彙編 INT 3 的指令碼。0xC3 是彙編 RET 的指令碼(注: 0xC3 是 64 位系統的指令碼)。顯然,由於 INT 3 的存在,當目標進程 T 調用這個被替換函數時,就會觸發一次 SIGTRAP 信號。
  6. 新的共享庫 L 在被裝載時,調用 sigaction 函數,接管 SIGTRAP 信號。在信號處理函數中,調用用於代替被替換函數的新函數。
  7. 至此,新的共享庫 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. 偏移量
圖 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, &regs); 

 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, &regs, 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, &regs); 
 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 內部調用關係
圖 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, &regs, 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 內部調用關係
圖 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 技術活動網絡廣播

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章