關於 ELF 文件想知道的事

1.起源

1.1 高級語言 vs 彙編語言 vs 機器語言

平時寫代碼的過程中,都知道高級語言需要編譯成機器語言才能被執行,但是原因是什麼呢?

你是否對既定的事實也抱有好奇的態度。很多時候,它是什麼遠沒有它爲什麼重要。

在這裏插入圖片描述

語言本身其實是一種溝通的工具:

  • 高級語言是寫給人看的,通過閱讀高級語言(go、c、c++ 等)編寫的源碼,可以方便的理解一段程序的處理邏輯。
  • 彙編語言是機器語言便於記憶的書寫格式,可理解爲助記符。
  • 機器語言是用二進制代碼表示的計算機能直接識別和執行的一種機器指指令系統令的集合。

你現在看到的和所寫的其實是一代又一代人的智慧結晶。想象一下現在讓你再回到基於機器指令寫代碼的年代,直接寫最右邊 code.o 的代碼,你會不會抓狂,反正我是頭大了……

在這裏插入圖片描述

1.2 機器語言和 ELF 文件

並非 ELF 文件需要我們,而是我們需要 ELF 文件。既然寫好的高級語言需要編譯成機器語言才能夠被機器運行,那我們可以合理的推測 ELF 文件中必然包含翻譯過後的機器語言。

注:ELF 是 the Executable and Linkable Format 的縮寫。

問:除了被翻譯後的機器語言,ELF 文件還需要包括什麼呢?或者由你設計 ELF 文件你會如何設計的?

答:既然機器語言是 ELF 文件最細粒度的組成部分,那麼我們能否對其進行抽象,將具有相同屬性的機器語言進行聚合。 所以一個完整的 ELF 文件其實包括以下幾個部分。

  • ELF 文件 Header —— 用來標識自己
  • 按照 Segment 規則分類的機器語言 —— 供系統運行 ELF 文件時使用的(由 Program Header Table 進行描述)
  • 按照 Section 規則分類的機器語言 —— 供編譯器鏈接時使用的(由 Section Header Table 進行描述)
    在這裏插入圖片描述

糾結如我,畫圖的時候就在想是不是所有 ELF 文件的佈局都像上圖所示,ELF header 和 Program Header Table 在文件頭,Section Header Table 在文件尾?查看了幾個編譯後的 ELF 文件有些是符合上述規則,有些則不然。所以結論其實是:(ps 英文翻譯水平實在不咋地,截圖貌似解釋的會更清楚一些

在這裏插入圖片描述

2. 編譯(從 section 到 segment)

2.1 爲什麼定義兩種不同視角的概念

因爲 ELF 文件是一類文件的簡稱,所以抽象出來的是更加通用的能力而非針對某個具體類型的定製化能力。ELF 包括以下四類:

  • 如果是可執行文件,則必須包含 Program Header Table,用於在程序運行時創建內存中的程序鏡像
  • 如果是鏈接時被使用的文件,則必須包含 Section Header Table,用於在鏈接時合併相似的 section

在這裏插入圖片描述

2.2 section 和 segment 是什麼

2.2.1 section

其實吧,我覺得 section 簡單理解就是將源碼編譯以後的指令、數據等按照某種類型、屬性特徵進行劃分存儲的方式。比如:

  • bss section
    • 類型 :SHT_NOBITS 變量存儲的值不會佔用文件磁盤空間
    • 屬性:SHF_ALLOC + SHF_WRITE 運行時需要分配存儲空間且具有寫權限
  • data section
    • 類型:SHT_PROGBITS 該 section 保存被進程定義的數據,其意義和格式由進程解釋
    • 屬性:SHF_ALLOC + SHF_WRITE 運行時需要分配存儲空間且具有寫權限
  • text section
    • 類型:SHT_PROGBITS 該 section 保存被進程定義的數據,其意義和格式由進程解釋
    • 屬性:SHF_ALLOC + SHF_EXECINSTR 運行時需要分配存儲空間且具有執行權限

類型解釋英文參考:

SHT_NOBITS:A section of this type occupies no space in the file but otherwise resemebles SHT_PROGBITS. Although this section contains no bytes, the sh_offset member contains the conceptual file offset.

SHT_PROGBITS:The sections holds information defined by the program, whose format and meaning are determined solely by the program.

在這裏插入圖片描述

2.2.2 segment

一個項目包括多個源文件,從宏觀上看,編譯、鏈接是將多個源碼文件編譯後的 *.o 鏈接成一個 .out 可執行文件的過程;從微觀上看,編譯其實生成 sections 數組的過程,鏈接其實就是合併多個 sections 數組並將其映射成 segments 的過程,具體 sections 的合併過程如下圖所示。

思考:爲什麼做多個 .o 文件的相同屬性做合併產生 .out 可執行文件,而不是連續拼接多個 .o 爲一個 .out 可執行文件

在這裏插入圖片描述

**爲什麼需要 segments 映射 **

  • 因爲程序需要被解釋爲進程才能夠被利用,而程序被解釋爲進程的過程就是將磁盤上的程序加載到內存,所以操作系統加載程序就是按照 segment header table 內容進行存儲鏡像加載的。(ps 其實有點像說明書

sections 到 segments 的映射

  • 猜測 sections 到 segments 的映射是在鏈接器的內部是有一定規則實現的,但是我暫時沒有找到規律點也木有查到相關資料,此處留一個疑問吧,一個具體的映射實例如下圖所示:

在這裏插入圖片描述

3. 鏈接以後我便成爲了你

segment 其實是一個邏輯的概念,本質就是一個映射關係的數據結構,記錄了 segment 和 section 的關係。而鏈接過後 section 便以 segment 的屬性被使用,也就是鏈接以後我(section)便成爲了你(segment)。

注:此處強行文藝了一波,其實 section 和 segment 的映射關係就是 1:1 或者 n : 1 或者 0:1(ps 嗯,就是 0,比如上圖的 00 這裏雖然有 segment 但是沒有實際的 section,僅做保留和佔位

3.1 鏈接發展歷史

存儲空間和 CPU 是限制計算機運行程序數量和速度的兩類瓶頸因素。而鏈接的發展就是圍着這如何充分利用存儲空間這一核心點進行的

3.1.1 靜態鏈接 + 靜態裝入

靜態鏈接、靜態裝入的做法是將所有目標文件鏈接成一個可執行文件,隨後在創建進程時將該可執行文件全部加載到內存。

在這裏插入圖片描述

注:數據其實包括了數據和指令兩個部分……

上圖的策略的缺點:

  • 浪費磁盤存儲空間
  • 浪費內存存儲空間

注:A、B 程序即使依賴了一個相同的第三方庫 S,這個 S 也需要在磁盤和內存中各存儲一份

3.1.2 靜態鏈接 + 動態裝入

靜態鏈接、動態裝入的做法是將所有目標文件鏈接成一個可執行文件,但是在創建進程的時候採用了動態裝入的策略,即一個函數只有當它被調用時,其所在的模塊纔會被裝入內存。

在這裏插入圖片描述

上圖的策略的缺點:

  • 仍舊浪費了磁盤存儲空間
  • 部分節省了內存的存儲空間

注:對於動態裝入的策略,對於那些沒有被使用到的代碼是永遠不會被裝入內存的。

問:什麼是沒有被使用的,沒有被使用不就應該在鏈接的時候不會被鏈接進來纔對?

答:此處應該說的不是模塊,應該是異常分支的路徑,比如一段代碼裏有大量的錯誤處理函數,其實這部分錯誤處理原則上來說是不會被加載到內存中的。

3.1.3 動態鏈接 + 動態裝入

動態鏈接、動態裝入的做法

  • 動態鏈接:是對程序依賴的動態庫鏈接時,不鏈接真正執行的指令,而是用 Stub(樁)的策略,在程序真正運行時纔會去翻譯 Stub(樁)爲真正的指令(ps 這個思想在很多程序語言的設計上也有,比如 golang 的 Interface、rpc 通信中的 mock client
  • 動態裝入:仍舊是按需加載指令或數據到內存中

在這裏插入圖片描述

此處,假設 add 這個模塊爲進程 1,進程 2 都依賴的一個共享庫,這樣在其實在對於賴 add 這個模塊的程序編譯的時候,僅編譯 Stub(樁)到 ELF 文件中。而運行時則採用:

  • 先在內存中查找是否有其他程序依賴了這個共享庫,如有,則直接翻譯對應指令;如無,則進行下一步查找
  • 從磁盤上加載共享庫到內存中使用

上面策略的優點:

  • 最大限度的節省了內存和磁盤空間
  • 採用共享庫的策略,但是需要額外指定共享庫的存儲路徑

3.2 動態鏈接本質

如下圖所示進程 1 和進程 2 採用了動態鏈接的策略,使用了相同的共享庫,但是這個庫被兩個進程定位不同的地址上,在進程 1 中,庫從 9k 開始,在進程 2 中,庫從 17k 開始。

由於庫是共享的,所以

  • 不能採用寫時複製的策略,爲兩個進程分別一份庫的數據到內存,這違反了共享庫節省存儲空間的策略。

  • 不能採用裝載的時候的重定位策略,將庫重定位到進程 1 中 9k 對應的地址空間中,這會導致進程 2 無法完成動態加載的過程

一個更通用的解決是:在編譯共享庫的時候,用一個特殊的編譯選項告知編譯器,不要使用絕對地址的指令。而是,只能使用相對地址。即,使用向前 (向後)跳轉 N 個字節的指令。無論共享庫被加載虛擬地址空間中的什麼位置。這種指令都是可以正確執行的。

在這裏插入圖片描述

所以,動態鏈接的核心就是共享庫,而共享庫其實就是如何編譯出與地址無關的指令。( ps 在代碼中不要使用絕對地址,而是使用相對偏移量的代碼

碎碎念

寫在最後面,因爲最近花了很多力氣去看 ELF 相關的東西,但是好像實際工作中使用它的場景很少。所以最近就一直在思考是不是花同樣的力氣去看其他的知識會更好,不過最近偶然看到《允許自己虛度時光》裏的一段話突然就想通了。「不必對每件事情都期待結果,享受滿足好奇的過程就好。」

原文如下:

我慢慢明白了爲什麼我不快樂,因爲我總是期待一個結果。看一本書期待它讓我變得深刻,喫飯游泳期待它讓我一斤斤瘦下來,發一條短信期待它被回覆,對人好期待它迴應也好,寫一個故事說一個心情期待它被關注被安慰,參加一個活動期待換來充實豐富的經歷。這些預設的期待如果實現了,長舒一口氣。如果沒實現呢?自怨自艾。可是小時候也是同一個我,用一個下午的時間看螞蟻搬家,等石頭開花,小時候不期待結果,小時候哭笑都不打折。

參考資料

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