如何編寫共享庫(一)- How To Write Shared Libraries 中文譯本

0. 譯者的話

原文是由 Ulrich Drepper 發佈於下面的鏈接中

https://www.akkadia.org/drepper/dsohowto.pdf

因爲需要製作一個c++的共享庫,譯者進行了很多檢索,發現目標都指向於這篇文章。由於這個方面系統性介紹的中文資料難覓蹤影,所以萌發了翻譯此文章的念頭。

0.1 關於作者

Ulrich Drepper

Ulrich Drepper 是“GNU C標準庫”項目glibc的首席開發者和負責人。自2017年4月起,他一直在Red Hat工作。

他是德國人。


以下爲正文

如何編寫共享庫

How To Write Shared Libraries
Ulrich Drepper
[email protected]
December 10, 2011

Copyright c 2002-2010, 2011 Ulrich Drepper
All rights reserved. No redistribution allowed.

摘要

今天,共享庫的應用已經很普及。開發者出於多種原因而使用它們,像編寫應用程序代碼一樣創建共享庫。然而,之所以編寫共享庫會是個問題,是因爲在許多平臺上,必須應用一些額外的技術才能生成合適的代碼。而生成優化的代碼則需要更多的知識。本文介紹了所需要的技術和需要了解的規則。此外,本文還介紹了應用程序二進制接口(ABI)穩定性的概念,並展示瞭如何管理ABI穩定性。

1. 前言

很長一段時間以來,程序員們把通用的代碼收集進庫中,這樣就可以重用代碼。這節省了開發時間並減少了錯誤,因爲重用的代碼只需要調試一次。在同時運行數十個或數百個進程的操作系統中,在鏈接時重用代碼只解決了部分問題。許多進程將使用它們從庫中導入的相同代碼段。利用現代操作系統中的內存管理系統,還可以在運行時共享代碼。在物理內存中只加載一份庫代碼,並通過虛擬內存在多個進程中重用這份代碼,這樣就可以實現在運行時共享代碼。這種庫稱爲共享庫。

這個概念並不是很新。 操作系統設計人員實現了對操作系統的擴展,這通過他們以前使用的基礎架構實現。操作系統的擴展過程對用戶是透明的。但是由用戶直接處理這部分工作會產生問題。

主要問題是二進制格式造成的。二進制格式是用於描述應用程序代碼的。僅僅提供內存轉儲就足夠了的日子已經早就過去了。多進程系統需要識別程序文件的不同部分,例如文本部分,數據部分和調試信息部分。爲了這個目的,在很早之前就引入了二進制格式。在Unix早期通常使用的是諸如a.out或COFF之類的格式。很顯然,這些早期的二進制格式的設計並未考慮共享庫。

1.1 一點歷史

最初用於Linux的二進制格式是a.out的變體。在引入共享庫時,某些設計決策必須要在a.out的限制之下使共享庫可以工作。主要的限制是在程序裝載時和裝載後都沒有重定位機制。共享庫必須直接以運行時在內存中的形式存放在磁盤中。這是構建和使用共享庫的一個主要限制:每個共享庫必須擁有固定的加載地址; 否則將無法生成不必重定位的共享庫。

固定的加載地址必須統一分配,分配地址時必須保證各個共享庫之間沒有重疊和衝突,還要保證未來共享庫容量增長之後也不發生衝突。因此,必須有一個分配授權中心來爲各個共享庫進行地址範圍分配,而這事本身就是一個大問題。還有更糟的情況:在一個擁有數百個DSO(動態共享對象)的Linux系統中,應用程序可用的地址空間和虛擬內存會嚴重碎片化。這將限制可動態分配的內存塊的大小,某些應用程序將無法避免遇到問題。甚至會發生分配授權中心用盡地址的情況,至少在32位機器上會出現這個情況。

我們仍然沒有涵蓋a.out共享庫的所有缺點。由於使用共享庫的應用程序在共享庫升級更新之後不必重新鏈接,因此入口點(即函數和變量地址)不得更改。只有當入口點與實際代碼分開時才能保證這一點,否則函數尺寸的限制將被硬編碼。用函數stub表來間接調用函數的實際實現是Linux上使用的解決方案。靜態鏈接器從特殊文件(文件擴展名爲.sa)獲取每個函數stub的地址。在運行時,使用以.so.X.Y.Z結尾的文件,它必須與.sa文件相對應。這又要求stub表中的已分配條目必須始終用於同一函數。必須仔細處理stub表的分配。引入新接口時就追加在stub表後面。stub表中的條目永遠都不能刪除。爲避免將舊的共享庫與鏈接到較新版本共享庫的程序一起使用,必須在應用程序中保留一些記錄:記錄.so.XYZ後綴名稱的X和Y部分,動態鏈接器確保最小要求得到滿足。

該方案的好處是最終的程序運行速度非常快。即使是第一次調用,在這樣的共享庫中調用函數也非常有效。它只能用兩個絕對跳轉來實現:第一個從用戶代碼到stub,第二個從stub到函數的實際代碼。這可能比任何其他共享庫的實現都要更快,但是換取到這個速度的代價實在太高:

  1. 需要分配授權中心來分配地址範圍;
  2. 有可能發生碰撞(地址重疊和衝突),帶來災難性後果;
  3. 地址碎片化更加嚴重。

由於所有這些原因以及更多原因,Linux在早期轉向使用ELF(可執行鏈接格式 Executable Linkage Format)作爲二進制格式。 ELF格式由添加了特定於處理器的擴展(psABI)的通用規範(gABI)定義。 事實證明,函數調用的攤銷成本幾乎與a.out相同,但限制不復存在。

1.2 轉向 ELF

對於程序員來說,轉換到ELF的主要優點是創建ELF共享庫或ELF-speak DSO變得非常容易。生成應用程序和DSO之間的唯一區別在於最終鏈接命令行。用一個選項(在GNU ld下爲--shared)告訴鏈接器要生成DSO而不是應用程序,不用選項用默認值就生成應用程序。實際上,DSO只不過是一種特殊的二進制文件; 不同之處在於它們沒有固定的加載地址,因此需要動態鏈接器來加載執行。 使用位置獨立可執行文件(PIE),差異會更大。

ELF與後面將描述的GNU Libtool的引入,導致程序員們廣泛採用DSO。正確使用DSO有助於節省大量資源。但是要得到任何好處必須遵循一些規則才行,而且必須遵循更多的規則才能獲得最佳結果。解釋這些規則將成爲本文很大一部分的主題。

使用DSO並非都是爲了節省資源。 如今,DSO也經常用作構建程序的一種方式。 程序的不同部分被放入單獨的DSO中。 這可能是一個非常強大的工具,尤其是在開發階段。不需要重新鏈接整個程序,只需重新鏈接已更新的DSO。這通常要快得多。

即使DSO未在其他程序中重複使用,一些項目也決定在部署階段保留許多單獨的DSO。 在許多情況下,這當然是一件有用的事情:DSO可以單獨更新,減少必須傳輸的數據量。 但DSO的數量必須保持在合理的水平。 但是,並非所有程序都要這樣做,我們稍後會看到爲什麼這可能引起問題。

在我們開始討論所有這些之前,需要對ELF及其實現有一些瞭解。

1.3 ELF是如何實施的?

處理靜態鏈接的應用程序非常簡單。內核知道靜態鏈接的應用程序的固定加載地址。加載過程簡單,使新建進程的二進制文件在適當的內存空間中可用,並將控制權轉移到應用程序的入口點。創建可執行文件時,其他所有內容都由靜態鏈接器完成。

相反,動態鏈接的二進制文件在從磁盤加載時不完整。因此內核不可能立即將控制權轉移到應用程序。在此之前,很顯然需要加載helper程序。這個helper程序就是動態鏈接器。動態鏈接器的任務是加載動態鏈接的應用程序所需的DSO(依賴項)並且執行重定位。然後控制權纔可以轉移到程序中。

但是,在大多數情況下,這不是動態鏈接器的最後一項任務。 ELF允許在符號被調用時才完成與符號關聯的重定位。這種“懶”重定位方案是可選的,下面討論的在啓動時立即執行重定位的優化也會影響“懶”重定位。所以我們在後面忽略了完成啓動之後的所有內容。

1.4 程序啓動:在內核中的情況

一個程序的啓動是從內核中開始的,通常是在execve系統調用中。 當前正在執行的代碼被替換爲新程序。 這意味着內存地址空間的內容被包含新程序的文件內容替換。 僅僅通過簡單地映射(使用mmap)文件的內容是不行的。因爲ELF文件是結構化的,文件中通常至少有三種不同的區域:

  • 執行代碼,這個區域一般是不能寫入的
  • 運行時要更新的數據,這個區域一般是不能當作代碼來運行的
  • 運行時不需要的數據,因爲不需要所以在程序啓動時這個區域不會加載

現代操作系統和處理器可以保護存儲器區域是否允許讀取,寫入和執行,這種保護是對每個單獨的內存頁(注1)而言的。最好將儘可能多的頁面標記爲不可寫,因爲只讀頁面可以在使用相同應用程序或者DSO的進程之間共享。寫保護還有助於檢測和防止數據甚至代碼的無意或惡意修改。

注1:內存頁是操作系統的內存子系統所操作的最小實體。內存頁的尺寸大小因不同體系架構而不同,甚至是相同體系架構的不同操作系統也有可能有不同的內存頁尺寸大小。

爲了使內核能夠找到ELF文件結構中不同的區域(ELF段)及其訪問權限,ELF文件格式定義了一個表,其中僅包含此信息。每個可執行文件和DSO中必須存在所謂的ELF程序頭表(ELF Program Header table)。它由C類型Elf32_Phdr和Elf64_Phdr表示,其定義如圖1所示。

圖1:ELF程序頭表 C數據結構

typedef struct
{
    Elf32_Word p_type;
    Elf32_Off  p_offset;
    Elf32_Addr p_vaddr;
    Elf32_Addr p_paddr;
    Elf32_Word p_filesz;
    Elf32_Word p_memsz;
    Elf32_Word p_flags;
    Elf32_Word p_align;
} Elf32_Phdr;

typedef struct
{
    Elf64_Word p_type;
    Elf64_Word p_flags;
    Elf64_Off  p_offset;
    Elf64_Addr p_vaddr;
    Elf64_Addr p_paddr;
    Elf64_Xword p_filesz;
    Elf64_Xword p_memsz;
    Elf64_Xword p_align;
} Elf64_Phdr;

要找到程序頭數據結構,需要另一個數據結構——ELF頭。 ELF頭是唯一一個在偏移零點開始,在可執行文件中具有固定位置的數據結構。其C數據結構如圖2所示。e_phoff字段指定從文件開頭開始計算的程序頭表(ELF Program header table,即圖1的表)的起始位置。 e_phnum字段包含程序頭表中的條目數,e_phentsize字段包含每個條目的尺寸大小,這個值僅作爲二進制文件的運行時一致性檢查。

圖2:ELF頭 C數據結構

typedef struct {
        unsigned char   e_ident[EI_NIDENT];
        Elf32_Half      e_type;
        Elf32_Half      e_machine;
        Elf32_Word      e_version;
        Elf32_Addr      e_entry;
        Elf32_Off       e_phoff;
        Elf32_Off       e_shoff;
        Elf32_Word      e_flags;
        Elf32_Half      e_ehsize;
        Elf32_Half      e_phentsize;
        Elf32_Half      e_phnum;
        Elf32_Half      e_shentsize;
        Elf32_Half      e_shnum;
        Elf32_Half      e_shstrndx;
} Elf32_Ehdr;

typedef struct {
        unsigned char   e_ident[EI_NIDENT];
        Elf64_Half      e_type;
        Elf64_Half      e_machine;
        Elf64_Word      e_version;
        Elf64_Addr      e_entry;
        Elf64_Off       e_phoff;
        Elf64_Off       e_shoff;
        Elf64_Word      e_flags;
        Elf64_Half      e_ehsize;
        Elf64_Half      e_phentsize;
        Elf64_Half      e_phnum;
        Elf64_Half      e_shentsize;
        Elf64_Half      e_shnum;
        Elf64_Half      e_shstrndx;
} Elf64_Ehdr;

不同的段由程序頭條目表示,p_type字段中具有PT_LOAD值。 p_offset和p_filesz字段指定段開始的文件位置以及段的長度。 p_vaddr和p_memsz字段指定段在進程的虛擬地址空間中的位置以及內存區域的大小。 p_vaddr字段本身的值不一定是最終加載地址。DSO可以在虛擬地址空間中的任意位置加載,但是段的相對地址很重要。對於預鏈接的DSO,p_vaddr字段的實際值是有意義的:它指定了DSO被其它程序預先鏈接的地址。但即使這樣,也並不意味着動態鏈接器在必要時不能忽略此信息。

文件的大小可能小於內存中佔用的地址空間。 內存區域的第一個p_filesz字節是從文件中段的數據讀取並初始化的,大出來的內存空間用零初始化。 這可以用於處理BSS段(注2),未初始化變量的段,根據C標準用零初始化。 以這種方式處理未初始化的變量具有以下優點:可以減小文件大小,因爲不必存儲初始化值,不必將數據從盤複製到存儲器,並且OS通過mmap接口提供的存儲器已經初始化爲零。

注2:BSS段僅包含NUL字節。因此,它們不必在文件中表示。加載器只需知道大小,它就可以分配足夠的內存並用NUL填充它

最後,p_flags告訴內核內存頁面使用什麼權限。該字段是位圖,其中定義了下表中給出的位。標誌直接映射到mmap可以理解的標誌。

p_flags mmap標誌 描述
PF_X 1 PROT_EXEC 執行權限
PF_W 2 PROT_WRITE 寫權限
PF_R 4 PROT_READ 讀權限

在使用適當的權限和指定的地址映射所有PT_LOAD段之後,或者在爲沒有固定加載地址的動態對象自由地分配地址之後,就可以開始下一階段。去設置動態鏈接的可執行文件本身的虛擬地址空間。但二進制文件並不完整。內核必須讓動態鏈接器完成剩下的工作,爲此動態鏈接器必須以與可執行文件本身相同的方式加載(即,在程序頭中查找可加載的段)。不同之處在於動態鏈接器本身必須是完整的,並且應該可以自由重定位。

由哪個二進制代碼具體實現動態鏈接器在內核中沒有硬編碼(內核沒有限制由誰來做動態鏈接器)。相反,應用程序的程序頭包含帶有PT_INTERP標記的條目。此條目的p_offset字段包含一個以NUL結束的字符串的偏移量,該字符串指定動態鏈接器的文件名。對這個文件的唯一要求是它的加載地址不會與可能與它一起運行的可執行文件的加載地址衝突。通常,這意味着動態鏈接器不能有固定的加載地址,可以在任何內存位置加載; 這就不會造成地址衝突。

一旦動態鏈接器也被映射到待啓動進程的內存中,我們就可以啓動動態鏈接器。請注意,不是把控制權轉移到應用程序的入口點。此時還只有動態鏈接器準備好了可以運行。要啓動動態鏈接器,還需要執行一個步驟。必須以某種方式告知動態鏈接器,讓它可以找到應用程序的位置以及應用程序完成後必須將控制權轉移到何處。爲了這個目的使用了一種結構化的方式——內核在新進程的堆棧上放置一組標記-值對。此輔助向量(標記-值對組)除了前面提到的兩個東西(應用程序位置、程序結束後的控制權)之外還包含幾個值,這些值可以讓動態鏈接器避免掉多個系統調用。 elf.h頭文件定義了許多帶有AT_前綴的常量。這些是輔助向量中條目的標記。

在設置輔助向量之後,內核最終準備好在用戶模式下將控制轉移到動態鏈接器。入口點在動態鏈接器的ELF頭的e_entry字段中定義。

1.5 動態鏈接器中的程序啓動過程

程序啓動的第2階段發生在動態鏈接器中,包括以下任務:

  • 檢測並且加載依賴項
  • 對應用程序和所有依賴項進行重定位
  • 以正確的順序初始化應用程序和各依賴項

在下文中,我們只更詳細地討論重定位處理。對於其他兩點,提高性能的方式很明顯:減少依賴項的數量。 每個參與對象只需初始化一次,但必須進行一些拓撲排序。 識別和加載過程也隨着依賴項的數量而擴展; 在大多數(所有?)實現中,這不會是線性擴展。

重定位過程通常(注3)是動態鏈接器工作中花銷最大的部分。 這是一個漸近至少爲O(R + nr)的過程,其中R是相對重定位的數量,r是命名重定位的數量,n是參與DSO的數量(加上主可執行文件)。 ELF哈希表函數和修改符號查找功能的各種ELF擴展的開銷可能會將因子增加到O(R + rn log s),其中s是符號的數量。 這應該表明,爲了提高性能,儘可能減少重新定位和符號的數量是很重要的。 在解釋了重定位過程後,我們將對實際數字進行一些估算。

注3:這裏,我們忽略了pre-linking(預鏈接)支持在許多情況下可以有效地減少甚至消除重定位開銷。

1.5.1 重定位的過程

在此上下文中的重定位是指將應用程序和作爲依賴項加載的DSO調整爲它們自己和所有其他加載地址。 有兩種依賴關係:

  • 對知道是在自己的對象中的依賴關係的定位,這與特定符號無關,因爲鏈接器知道對象中相對位置的定位。
    請注意,應用程序沒有相對重定位,因爲代碼的加載地址在鏈接時就已經知道,因此靜態鏈接器能夠執行重定位。

  • 基於符號的依賴關係。 定義的引用通常是在與定義不同的對象中(有時也不一定是這樣)。

相對重定位的實現很容易。鏈接器可以在程序鏈接時計算對象文件中目標的偏移量。對於此偏移量,動態鏈接器只需添加對象的加載地址,並將結果存儲在一個由重定位指明的地方。在運行時,動態鏈接器必須僅花費非常小且恆定的時間,這個時間不會隨着DSO數量增加而增加。

基於符號的重定位要複雜得多。 ELF符號解析過程設計得非常強大,因此它可以處理許多不同的問題。但是,所有這些強大的功能都增加了複雜性和運行時的開銷。以下描述的讀者可能會質疑引起這個過程的決策。我們不能在這裏爭論; 讀者可參考ELF的討論。事實上,符號重定位是一個代價高昂的過程,DSO參與的越多或DSO中定義的符號越多,符號查找所需的時間就越長。

任何重定位的結果都將和引用一起存儲在對象中的某個位置。 理想情況下,這個位置通常位於數據段中。 如果用戶,編譯器或鏈接器重定位錯誤地生成代碼,則可能會修改掉文本或只讀段。 如果按照ELF規範的要求標記了對象動態段(dynamic section)的DT_FLAGS條目中的DF_TEXTREL(或舊二進制文件中存在DT_TEXTREL標誌),動態鏈接器將正確地處理此問題。 但結果是修改後的頁面無法與使用同一對象的其他進程共享。 修改過程本身也很慢,因爲內核必須重新組織相當多的內存管理數據結構。

1.5.2 符號重定位

動態鏈接器必須對在運行時使用的符號,以及同一對象中在鏈接時未知的所有符號執行重定位作爲引用。由於在某些體系結構上生成代碼的方式,可以延遲一些重定位的處理,直到實際需要使用到有問題的引用;在許多體系結構中對函數的調用都是如此。在使用對象之前,所有其他類型的重定位必須處理完成。我們將忽略延遲重定位處理,因爲這只是一種延遲工作的方法,重定位最終必須完成,因此我們將其納入我們的開銷分析。通過將環境變量LD_BIND_NOW設置爲非空值來使在使用對象之前執行所有重定位。通過向鏈接器命令行添加-z now選項,可以爲單個對象禁用延遲重定位。鏈接器將在動態段的DT_FLAGS條目中設置DF_BIND_NOW標誌以標記DSO禁用延遲重定位。但是,如果不重新鏈接DSO或編輯二進制文件,則無法撤消此設置,因此只有在真正需要時才應使用此選項。

從一開始就爲每個加載對象中的每個符號的重定位進行實際的查找。請注意,在不同對象中可以有許多對相同符號的引用。對於每個對象,查找的結果可以是不同的,因此除了爲每個對象中的符號查找結果進行緩存之外沒有捷徑可走,因爲不止一個重定位引用會指向相同的符號。以下步驟中提到的查找範圍是已加載對象的子集的有序列表,對於每個對象本身可以是不同的。查找範圍的計算方式非常複雜,並且在這裏並不重要,感興趣的讀者可以看ELF規範和第1.5.4節。重要的是,範圍的長度通常直接取決於加載的對象的數量。這是減少加載對象數量的另一個因素,即提高性能。

 

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