靜態(tài)庫(kù)系列(九):動(dòng)態(tài)庫(kù)如何進(jìn)入進(jìn)程地址空間:從磁盤 .so 到共享內(nèi)存映射)
文章目錄一、動(dòng)態(tài)庫(kù)為什么比靜態(tài)庫(kù)更常用二、動(dòng)態(tài)庫(kù)也是文件三、動(dòng)態(tài)庫(kù)加載的整體流程四、從磁盤 .so 到物理內(nèi)存五、從物理內(nèi)存到進(jìn)程虛擬地址空間六、多個(gè)進(jìn)程如何共享同一個(gè)動(dòng)態(tài)庫(kù)七、共享的是代碼不是什么都共享八、為什么動(dòng)態(tài)庫(kù)加載地址不固定九、使用 /proc 查看動(dòng)態(tài)庫(kù)映射十、使用 pmap 查看進(jìn)程映射十一、動(dòng)態(tài)鏈接和靜態(tài)鏈接再對(duì)比一次十二、為什么動(dòng)態(tài)庫(kù)需要后續(xù)重定位十三、總結(jié)前面我們已經(jīng)知道動(dòng)態(tài)庫(kù).so不是在編譯鏈接階段完整拷貝進(jìn)可執(zhí)行程序而是在程序運(yùn)行時(shí)被動(dòng)態(tài)鏈接器加載。那么問(wèn)題來(lái)了磁盤上的libxxx.so到底是怎么進(jìn)入進(jìn)程地址空間的多個(gè)程序都依賴同一個(gè)動(dòng)態(tài)庫(kù)時(shí)系統(tǒng)真的會(huì)給每個(gè)進(jìn)程都復(fù)制一份庫(kù)代碼嗎為什么說(shuō)動(dòng)態(tài)庫(kù)可以節(jié)省內(nèi)存這篇文章就圍繞“動(dòng)態(tài)庫(kù)如何映射到進(jìn)程地址空間”展開(kāi)。一、動(dòng)態(tài)庫(kù)為什么比靜態(tài)庫(kù)更常用靜態(tài)鏈接會(huì)把用到的庫(kù)代碼合并進(jìn)可執(zhí)行文件。這樣程序可以獨(dú)立運(yùn)行但也帶來(lái)兩個(gè)問(wèn)題可執(zhí)行文件變大。多個(gè)程序使用同一個(gè)庫(kù)時(shí)會(huì)重復(fù)保存同一份代碼。假設(shè)系統(tǒng)里有 100 個(gè)程序都用到了 C 標(biāo)準(zhǔn)庫(kù)。如果每個(gè)程序都把 libc 的代碼靜態(tài)鏈接進(jìn)去磁盤和內(nèi)存都會(huì)浪費(fèi)大量空間。動(dòng)態(tài)庫(kù)的思路是把公共代碼單獨(dú)放在 .so 文件中 程序運(yùn)行時(shí)再加載 多個(gè)進(jìn)程盡量共享同一份庫(kù)代碼。這就是動(dòng)態(tài)庫(kù)廣泛使用的重要原因。二、動(dòng)態(tài)庫(kù)也是文件先明確一點(diǎn)動(dòng)態(tài)庫(kù)不是內(nèi)存里憑空出現(xiàn)的東西它首先是磁盤上的一個(gè)普通文件。例如ls-l/lib64/libc.so.6file/lib64/libc.so.6你會(huì)發(fā)現(xiàn)它是一個(gè) ELF shared object。所以加載動(dòng)態(tài)庫(kù)的第一步仍然是文件操作找到 .so 文件 打開(kāi) .so 文件 讀取 ELF 信息 把需要的 segment 映射進(jìn)內(nèi)存只不過(guò)這個(gè)過(guò)程通常由動(dòng)態(tài)鏈接器和內(nèi)核協(xié)作完成程序員平時(shí)不直接感知。三、動(dòng)態(tài)庫(kù)加載的整體流程一個(gè)動(dòng)態(tài)鏈接程序啟動(dòng)時(shí)大致流程如下1. 內(nèi)核加載主程序 ELF 2. 發(fā)現(xiàn)主程序需要?jiǎng)討B(tài)鏈接器 3. 動(dòng)態(tài)鏈接器讀取主程序依賴的 .so 列表 4. 找到每個(gè) .so 文件 5. 將 .so 的代碼段、數(shù)據(jù)段等映射到進(jìn)程地址空間 6. 完成必要的符號(hào)解析和重定位 7. 進(jìn)入程序 main 函數(shù)如果第 4 步找不到庫(kù)就會(huì)出現(xiàn)我們前一篇講的libxxx.so not found如果能找到庫(kù)下一步就是把庫(kù)映射到進(jìn)程地址空間中。四、從磁盤 .so 到物理內(nèi)存當(dāng)程序依賴libmyc.so時(shí)動(dòng)態(tài)鏈接器會(huì)找到磁盤上的庫(kù)文件。然后系統(tǒng)會(huì)把庫(kù)中需要加載的 segment 放入物理內(nèi)存尤其是代碼段.text對(duì)應(yīng)的內(nèi)容。可以先粗略理解為磁盤 libmyc.so - 加載到物理內(nèi)存中的若干頁(yè)現(xiàn)代系統(tǒng)很多時(shí)候還會(huì)用文件映射、按需分頁(yè)等機(jī)制并不是一開(kāi)始就把整個(gè)庫(kù)所有內(nèi)容都讀入內(nèi)存。但從理解動(dòng)態(tài)庫(kù)共享的角度我們先抓住核心庫(kù)文件的內(nèi)容最終要對(duì)應(yīng)到物理內(nèi)存頁(yè)。五、從物理內(nèi)存到進(jìn)程虛擬地址空間進(jìn)程不能直接使用物理地址。每個(gè)進(jìn)程看到的是自己的虛擬地址空間。所以動(dòng)態(tài)庫(kù)加載后還需要在當(dāng)前進(jìn)程的虛擬地址空間中劃出一段區(qū)域用來(lái)映射這份庫(kù)??梢岳斫鉃檫M(jìn)程虛擬地址空間中的一段共享區(qū) - 通過(guò)頁(yè)表 - 映射到物理內(nèi)存中的 libmyc.so 代碼頁(yè)例如進(jìn)程 A 中l(wèi)ibmyc.so可能映射到0x7f1000000000 ~ 0x7f1000010000這只是進(jìn)程 A 自己看到的虛擬地址范圍。真正的物理內(nèi)存在哪里進(jìn)程并不直接關(guān)心。CPU 會(huì)通過(guò)頁(yè)表完成轉(zhuǎn)換。六、多個(gè)進(jìn)程如何共享同一個(gè)動(dòng)態(tài)庫(kù)假設(shè)進(jìn)程 A 和進(jìn)程 B 都依賴libmyc.so。進(jìn)程 A 啟動(dòng)時(shí)磁盤 libmyc.so - 物理內(nèi)存代碼頁(yè) 進(jìn)程 A 的虛擬共享區(qū) - 映射到這些物理頁(yè)進(jìn)程 B 啟動(dòng)時(shí)如果系統(tǒng)發(fā)現(xiàn)這份庫(kù)的代碼頁(yè)已經(jīng)在物理內(nèi)存中就不必再加載一份完整代碼。它只需要給進(jìn)程 B 分配自己的虛擬共享區(qū) 讓進(jìn)程 B 的頁(yè)表也映射到同一批物理代碼頁(yè)于是形成進(jìn)程 A 虛擬地址 0x7f1000000000 - 同一份物理庫(kù)代碼 進(jìn)程 B 虛擬地址 0x7f2000000000 - 同一份物理庫(kù)代碼注意兩個(gè)進(jìn)程的虛擬地址可以不同 但它們可以映射到同一份物理內(nèi)存。這就是動(dòng)態(tài)庫(kù)節(jié)省內(nèi)存的關(guān)鍵。七、共享的是代碼不是什么都共享動(dòng)態(tài)庫(kù)中并不是所有內(nèi)容都能隨便共享。一般來(lái)說(shuō)只讀代碼段可以共享 只讀數(shù)據(jù)可以共享 可寫(xiě)數(shù)據(jù)通常不能直接在進(jìn)程間共享。原因很簡(jiǎn)單如果動(dòng)態(tài)庫(kù)中的全局變量被多個(gè)進(jìn)程共享那進(jìn)程 A 修改變量會(huì)影響進(jìn)程 B這顯然不符合普通進(jìn)程隔離原則。所以動(dòng)態(tài)庫(kù)的可寫(xiě)數(shù)據(jù)部分通常會(huì)為每個(gè)進(jìn)程提供獨(dú)立映射或者通過(guò)寫(xiě)時(shí)拷貝等機(jī)制保證進(jìn)程隔離。動(dòng)態(tài)庫(kù)節(jié)省內(nèi)存主要依賴代碼段只讀可以被多個(gè)進(jìn)程共享。這也是為什么動(dòng)態(tài)庫(kù)代碼要盡量做到位置無(wú)關(guān)不能隨便修改代碼段本身。八、為什么動(dòng)態(tài)庫(kù)加載地址不固定每個(gè)進(jìn)程都有自己的虛擬地址空間。不同進(jìn)程加載了不同的程序、不同的庫(kù)地址空間中空閑區(qū)域也不同。所以動(dòng)態(tài)鏈接器不能假設(shè)所有進(jìn)程都把libmyc.so放到同一個(gè)虛擬地址。它通常會(huì)在當(dāng)前進(jìn)程地址空間中選擇一段合適的空閑區(qū)域把動(dòng)態(tài)庫(kù)映射進(jìn)去。這就帶來(lái)一個(gè)關(guān)鍵問(wèn)題動(dòng)態(tài)庫(kù)如果被加載到任意地址里面的函數(shù)調(diào)用和全局變量訪問(wèn)怎么保證正確這就是下一篇 GOT/PIC 要解決的問(wèn)題。九、使用 /proc 查看動(dòng)態(tài)庫(kù)映射Linux 提供了/proc/[pid]/maps可以查看進(jìn)程地址空間映射。先運(yùn)行一個(gè)程序讓它保持一段時(shí)間./main如果程序很快退出可以在代碼中加sleep(100)。然后查看pidof maincat/proc/進(jìn)程PID/maps你會(huì)看到類似7f... r-xp ... /lib64/libc.so.6 7f... r--p ... /lib64/libc.so.6 7f... rw-p ... /lib64/libc.so.6這些行說(shuō)明libc.so.6的不同部分被映射到了進(jìn)程虛擬地址空間中并且權(quán)限不同r-xp可讀可執(zhí)行通常對(duì)應(yīng)代碼段 r--p只讀 rw-p可讀可寫(xiě)通常對(duì)應(yīng)數(shù)據(jù)段這能非常直觀地看到動(dòng)態(tài)庫(kù)確實(shí)進(jìn)入了進(jìn)程地址空間。十、使用 pmap 查看進(jìn)程映射也可以使用pmap 進(jìn)程PID它會(huì)以更簡(jiǎn)潔的形式展示進(jìn)程內(nèi)存映射。例如pmap$(pidof main)可以看到主程序、堆、棧、動(dòng)態(tài)庫(kù)等區(qū)域。學(xué)習(xí)動(dòng)態(tài)庫(kù)時(shí)/proc/[pid]/maps是非常有價(jià)值的觀察工具。十一、動(dòng)態(tài)鏈接和靜態(tài)鏈接再對(duì)比一次靜態(tài)鏈接庫(kù)代碼在鏈接階段進(jìn)入可執(zhí)行文件 程序運(yùn)行時(shí)不再找?guī)?多個(gè)程序各自包含一份庫(kù)代碼。動(dòng)態(tài)鏈接可執(zhí)行文件記錄依賴 程序運(yùn)行時(shí)加載 .so 多個(gè)進(jìn)程可以共享同一份庫(kù)代碼物理頁(yè)。動(dòng)態(tài)鏈接本質(zhì)上把一部分鏈接工作推遲到了程序加載和運(yùn)行階段。這帶來(lái)靈活性也帶來(lái)復(fù)雜性。十二、為什么動(dòng)態(tài)庫(kù)需要后續(xù)重定位動(dòng)態(tài)庫(kù)被映射到進(jìn)程地址空間后它的加載地址才真正確定。但庫(kù)代碼中可能需要訪問(wèn)庫(kù)內(nèi)函數(shù) 庫(kù)內(nèi)全局變量 其他動(dòng)態(tài)庫(kù)函數(shù) 主程序中的符號(hào)有些地址必須在加載后才能知道。所以動(dòng)態(tài)鏈接器還要做符號(hào)解析和重定位讓這些引用指向正確位置。問(wèn)題是代碼段通常是只讀且共享的不能每個(gè)進(jìn)程都直接修改代碼段里的地址。因此動(dòng)態(tài)鏈接需要一種更巧妙的機(jī)制把可能變化的地址放到可寫(xiě)表中代碼通過(guò)查表跳轉(zhuǎn)。這張表就是 GOT全局偏移表。十三、總結(jié)動(dòng)態(tài)庫(kù)加載不是簡(jiǎn)單地“把.so拷貝進(jìn)進(jìn)程”。更準(zhǔn)確的理解是動(dòng)態(tài)鏈接器找到 .so 內(nèi)核把 .so 的 segment 映射進(jìn)進(jìn)程虛擬地址空間 多個(gè)進(jìn)程可以通過(guò)各自頁(yè)表映射到同一份物理代碼頁(yè) 動(dòng)態(tài)鏈接器再完成必要的符號(hào)解析和重定位動(dòng)態(tài)庫(kù)節(jié)省內(nèi)存的關(guān)鍵在于只讀代碼段可以共享。但動(dòng)態(tài)庫(kù)加載地址不固定這就要求動(dòng)態(tài)庫(kù)不能把絕對(duì)地址寫(xiě)死在代碼里。下一篇我們繼續(xù)講 GOT 與 PIC看動(dòng)態(tài)庫(kù)如何做到“加載到哪里都能運(yùn)行”。