iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 4
0

前情提要


前兩日將 go 語言環境和 go-binutils 專案設定好,也建立了一些 go 語言相關概念與用法知識,今天就要開始來跨出認識 ELF 格式的第一步:''readelf'' 工具介紹與部份功能實作。

這裡預期讀者都有用過 ELF 格式的執行檔,只是對於其中結構不甚熟悉。看完本日鐵人文章之後,我們可以透過操作的方式,由淺入深地窺探 ELF 內部的奧秘之處。

今天開始的工具程式篇,都會先介紹原本的 GNU 實作,必要時展示其一部份的功能集合。如果可以的話,盡量停留在使用情境,而不深入挖掘原本的專案內容。然後,我們會在原先 go-binutils 專案中加入子集合的功能實作,畢竟筆者沒有那麼厲害能夠一天完成全功能的移植。

readelf 介紹


ELF 檔沒有什麼魔術,也和所有數位資料一樣是由一個一個 bit 組成的,之所以會有特殊的性質與功能,完全是因為它內部依循著一定的結構,並且欲使用 ELF 檔的系統都有能夠解讀 ELF 所屬結構的功能:影音多媒體檔案要有播放器、辦公室文件要有辦公室軟體等等,皆是一樣的道理。

readelf 這個工具程式,就是另外一種意義的 ELF 解讀器。舉個例子,我們都會期待一個正式的音樂檔解讀器配備有足夠的解碼能力,能夠將檔案的數位內容解放成悅耳的音樂;但也可以有一種解讀器,它同樣理解那數位音樂檔的內容,但是產出是該曲目對應的五線譜。回到 ELF 的例子,作業系統如 Linux 與 FreeBSD 都有核心內建的功能在負責 ELF 的解讀,並且引導程式進入記憶體開始執行;同樣地類比,readelf 不做實際的執行工作,而是透過自己對 ELF 格式的理解,產出人類可以閱讀的報表。

筆者這裡選擇實作的是 readelf 的三個參數:-h, -l, 以及 -S 這三個。本日會介紹 -h 參數的效果與意義,剩下兩組則留待明日解決。簡言之,-h 參數是要展示 ELF 檔案標頭。這又是什麼意思呢?

標頭、檔頭、header,之類的名詞都是用在表達一個概念:**如果所有的資料對電腦來說都是一視同仁的 0 與 1 的數位流,那麼它又該如何判斷所需處理的資料流的各種屬性呢?**所以,網路封包也會有 header,用來表示所接收到的東西是什麼樣的封包;不同的檔案也會有不同的 header,讓作業系統得以呼叫相應的解讀程式。可執行檔,尤其在這個系列中的 ELF,當然也不例外。而我們首先要實作的這個功能,就是用來檢驗放諸四海皆準的 ELF 檔的檔頭。

直接使用看看!

各位讀者可以在慣用的環境中安裝 binutils 軟體包之後試用這個選項:

$ readelf -h <隨意一個可執行檔>

筆者一律使用 Arch Linux 的 base 軟體包中的 ls 程式作為以下範例的目標檔案。

應該會得到類似這樣的結果:

$ readelf -h /bin/ls
ELF 檔頭:
魔術位元組:7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  類別:                              ELF64
  資料:                              2 的補數,小尾序(little endian)
  版本:                              1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  類型:                              DYN (共享物件檔案)
  系統架構:                          Advanced Micro Devices X86-64
  版本:                              0x1
  進入點位址:              0x5060
  程式標頭起點:            64 (檔案內之位元組)
  區段標頭起點:                     131960 (檔案內之位元組)
  旗標:             0x0
  此標頭的大小:       64 (位元組)
  程式標頭大小:       56 (位元組)
  Number of program headers:         9
  區段標頭大小:         64 (位元組)
  區段標頭數量:         27
  字串表索引區段標頭: 26

筆者預設的環境使用 zh_TW.utf-8 的語系設定,所以 readelf 很貼心的讀取了這個設定並顯示結果。但是誠如各位所見,這樣的輸出結果實在只能說是差強人意,排版實在太難看了!稍微轉換一下:

$ LC_ALL=C readelf -h /bin/ls
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x5060
  Start of program headers:          64 (bytes into file)
  Start of section headers:          131960 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         27
  Section header string table index: 26

果然改回電腦的母語英文之後,至少就整齊劃一,沒有顯示的問題了。

這裡只是先突顯一件事情:就算是 binutils 這樣重要的軟體,也會有不堪的跨語系支援。我們日後有機會再回來看 go 語言的多語系軟體支援,目前還是會聚焦在 ELF 檔的處理。

首行:魔術數字與關鍵性質

第一行的 Magic 代表的是檔頭中的檔頭,也就是任何一個 ELF 解讀器會最先讀取的 16 個位元,其用意是:「要叫我把這個檔案當作 ELF 解讀之前,至少先讓我確定這真的是個 ELF 再說!」按照位元次序,這些內容的意義依序是:

  • 0~3:這四個位元的內容是固定的魔術數字\x7fELF\x45就是E字元的 ASCII 編碼,以下類推。
  • 4:平台類別。目前只有兩個官方定義,0x02代表的是,這機器是 64 位元的機器。這個資訊之所以被放在魔術數字之後的首要位置,是因為這個資訊關係到讀取器如何解讀之後的部份。畢竟,這些與連結、函式、執行功能的檔案內含非常多記憶體位址型態的資訊,那些資訊會隨著機器位元數而變動;對於 32 和 64 位元的機器來說,這些讀取方式沒有一個常規可以通用。
  • 5:資料型態。這個資訊是要表明這個機器的資料存放政策是偏向大頭(Big Endian)或是小頭(Little Endian)。現在許多的機器都已經是小頭為主了,現在展示的 x86_64 當然也不例外。至於前面的 2 的補數,則已經幾乎是整數資料的存放標準,就不細講了。這個資訊也同樣非常重要,若是一個大頭的 CPU 想要讀取一個小頭系統專用的 ELF 檔案,而且它沒有事先取得資料型態的資訊,那麼它讀取到的內容將會是嚴重錯置的。
  • 6:ELF 格式的版本資訊。當初訂完第一版之後委員會就功成身退,事實也證明這個標準能夠適應後來變遷的軟硬體生態,且運作穩當。
  • 7:作業系統的 ABI。這裡可以看見這個檔案使用的是 System V 的 ABI,編碼為 \x00
  • 8:ABI 相關的補充資訊。Linux 通常不設定這個值。
  • ~15:空白的 padding。

ELF 檔頭:總覽

之前提到檔頭就是解讀資料流或資料塊的前置資訊,或者比較炫一點的說法:meta data,那為什麼這檔頭裡面還要分前 16 位元組和剩下的部份呢?為了尋求答案,我們先觀摩 go 語言的官方實作中,它們是如何看待這整個標頭檔;其中又有兩組標頭檔,型別分別是 Header32Header64 定義在 /lib/go/debug/elf/elf.go 之中:

type Header32 struct {
        Ident     [EI_NIDENT]byte /* 檔案格式識別 */
        Type      uint16          /* ELF 檔的種類 */
        Machine   uint16          /* 計算機架構 */
        Version   uint32          /* ELF 版本號碼 */
        Entry     uint32          /* 程式進入點 */
        Phoff     uint32          /* 程式標頭偏移量 */
        Shoff     uint32          /* 區段標頭偏移量 */
        Flags     uint32          /* 架構相關的標誌 */
        Ehsize    uint16          /* ELF 檔頭的位元組數 */
        Phentsize uint16          /* 程式標頭的大小 */
        Phnum     uint16          /* 程式標頭的個數 */
        Shentsize uint16          /* 區段標頭的大小 */
        Shnum     uint16          /* 區段標頭的個數 */
        Shstrndx  uint16          /* 存有區段名稱的區段的索引號碼 */
}

對於 Header64,相同的部份省略:

type Header64 struct {
	...
        Entry     uint64          /* Entry point. */
        Phoff     uint64          /* Program header file offset. */
        Shoff     uint64          /* Section header file offset. */
	...
}

筆者將原本程式碼中的註解轉換為中文了。

給習慣 C、C++ 甚至是 Java 的讀者一個提醒,go 的 struct 內部成員宣告時,型別會在成員名稱的後面!

這裡我們確認到,關鍵其實就在前一段展示前 16 byte 的 4, 5 項之中。首先,如果不知道這份 ELF 檔的平台是多少位元的系統,取得這整個檔頭就根本無法正確,也許前幾項都還可以,但是到了那些與位址、偏移量有關的成員項目時,一定會有讀取上的落差而產生錯誤;再者,就算理解了平台的位元數,若是誤判資料存放方式,而以 IBM 慣用的大頭讀取方式去讀 Intel 慣用的小頭資料存放,那麼也無法正確取得這些檔頭之內的多位元組成員資料。也就是說,那些曲折的檔頭定義方式,不是為了要迷惑後進,而是它為了泛用性而展示出的一種設計模式。

ELF 檔頭:剩下的部份

再來看看其他的檔案如何?那麼,就用 RISC-V 模擬環境中的 riscv64-unknown-linux-gnu-readelf 來查看 busybox 檔案吧!

# riscv64-unknown-linux-gnu-readelf -h /home/riscv/rootfs/bin/busybox
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class(1):                             ELF64
  Data(1):                              2's complement, little endian
  Version(1):                           1 (current)
  OS/ABI(1):                            UNIX - System V
  ABI Version(1):                       0
  Type(2):                              EXEC (Executable file)
  Machine(2):                           RISC-V
  Version(4):                           0x1
  Entry point address(8):               0x102f8
  Start of program headers(8):          64 (bytes into file)
  Start of section headers(8):          1657208 (bytes into file)
  Flags(2):                             0x5, RVC, double-float ABI
  Size of this header(2):               64 (bytes)
  Size of program headers(2):           56 (bytes)
  Number of program headers(2):         5
  Size of section headers(2):           64 (bytes)
  Number of section headers(2):         25
  Section header string table index(2): 24

筆者在每個項目的後面安插了該資料的原始大小(以 byte 為單位)給各位作對照。請參考前一段展示的標頭檔結構註解理解這些內容。

動手實作!

筆者首先在 readelf/readelf.go 檔案中多新增 encoding/json,讓 Run 方法的輸出結果為支援良好的 json 格式。然後,進行以下修改:

 func (reu *readelfUtil) Run(args map[string]interface{}) (string, error) {

+       jsonOut := "nothing"
+
+       if *args["h"].(*bool) {
+               raw, err := json.Marshal(reu.file.FileHeader)
+               if err != nil {
+                       return "", err
+               }
+
+               jsonOut = `{"File Header":` + string(raw) + `}`
+       }
        fmt.Println(*args["S"].(*bool))
-       fmt.Println(*args["h"].(*bool))
        fmt.Println(*args["l"].(*bool))

-       return "nothing yet", nil
+       return jsonOut, nil
 }

如果有 -h 參數存在的話,就將檔頭型別 FileHeader 的內容一股腦兒做成 json 格式,並且在之後回傳。如果只有這樣,這個程式一定還是一動也不動的。所以我們需要到 common/util.go 之中讓 Output 方法至少做一點事情,這裡先簡單的使用萬用的輸出方式:

 func Output(content string) {
-       return
+       fmt.Println(content)
 }

如此一來雖然還沒有像是 GNU 版本的那樣排版輸出,至少也能夠有一個 json 輸出了!立刻執行然後看看結果如何。

$ make
go build
$ mv go-binutils /tmp/readelf
$ /tmp/readelf -h /bin/ls
false
false
{"File Header":{"Class":2,"Data":1,"Version":1,"OSABI":0,"ABIVersion":0,"ByteOrder":{},"Type":3,"Machine":62,"Entry":20576}}
$ 

當然,各位讀者也可以使用 RISC-V 環境中的 riscv-go,並拿來檢驗 RISC-V 的其他 ELF 檔案。

小結


今日我們實作最入門的 readelf -h 用法,同時介紹許多 ELF 格式的相關知識。很明顯的,還有許多部份不夠完整,彷彿可以聽見各位在吶喊:

  • 跟前面的 Header64 結構相比,少這麼多項,都不用解釋嗎?
  • json 檔根本就不好看,難道你連排版輸出的誠意都沒有嗎?
  • 這個 json 內容,就算排版了,你輸出數字誰懂?
  • 你在 main.go 裡面直接把命令列最後一個參數當作要讀的檔案,這樣好嗎?

這些質疑都非常正確!而筆者正是預計接下來實作 -l 以及 -S 的過程中解決這些問題,收尾第一個 readelf 程式。各位讀者,我們明日再會!


上一篇
第三日:go 語言環境建置與 go-binutils 專案簡介
下一篇
第五日:readelf 開發過程之疑難排解
系列文
與妖精共舞:在 RISC-V 架構上使用 GO 語言實作 binutils 工具包30

尚未有邦友留言

立即登入留言