前兩日將 go 語言環境和 go-binutils 專案設定好,也建立了一些 go 語言相關概念與用法知識,今天就要開始來跨出認識 ELF 格式的第一步:''readelf'' 工具介紹與部份功能實作。
這裡預期讀者都有用過 ELF 格式的執行檔,只是對於其中結構不甚熟悉。看完本日鐵人文章之後,我們可以透過操作的方式,由淺入深地窺探 ELF 內部的奧秘之處。
今天開始的工具程式篇,都會先介紹原本的 GNU 實作,必要時展示其一部份的功能集合。如果可以的話,盡量停留在使用情境,而不深入挖掘原本的專案內容。然後,我們會在原先 go-binutils 專案中加入子集合的功能實作,畢竟筆者沒有那麼厲害能夠一天完成全功能的移植。
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 再說!」按照位元次序,這些內容的意義依序是:
\x7fELF
。\x45
就是E
字元的 ASCII 編碼,以下類推。0x02
代表的是,這機器是 64 位元的機器。這個資訊之所以被放在魔術數字之後的首要位置,是因為這個資訊關係到讀取器如何解讀之後的部份。畢竟,這些與連結、函式、執行功能的檔案內含非常多記憶體位址型態的資訊,那些資訊會隨著機器位元數而變動;對於 32 和 64 位元的機器來說,這些讀取方式沒有一個常規可以通用。2 的補數
,則已經幾乎是整數資料的存放標準,就不細講了。這個資訊也同樣非常重要,若是一個大頭的 CPU 想要讀取一個小頭系統專用的 ELF 檔案,而且它沒有事先取得資料型態的資訊,那麼它讀取到的內容將會是嚴重錯置的。\x00
。之前提到檔頭就是解讀資料流或資料塊的前置資訊,或者比較炫一點的說法:meta data,那為什麼這檔頭裡面還要分前 16 位元組和剩下的部份呢?為了尋求答案,我們先觀摩 go 語言的官方實作中,它們是如何看待這整個標頭檔;其中又有兩組標頭檔,型別分別是 Header32
和 Header64
定義在 /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 慣用的小頭資料存放,那麼也無法正確取得這些檔頭之內的多位元組成員資料。也就是說,那些曲折的檔頭定義方式,不是為了要迷惑後進,而是它為了泛用性而展示出的一種設計模式。
再來看看其他的檔案如何?那麼,就用 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
結構相比,少這麼多項,都不用解釋嗎?這些質疑都非常正確!而筆者正是預計接下來實作 -l
以及 -S
的過程中解決這些問題,收尾第一個 readelf 程式。各位讀者,我們明日再會!