花去一天來解決目前的所有問題,也好好整理了一下 go-binutil 這個野心很大卻又過度幼稚的專案。今日是讓我們兌現諾言的時候了,來完成 readelf 的階段性任務吧,我們先看檔頭就好。此時筆者與各位讀者一樣,像個剛認得字母的孩子在逛圖書館,readelf 就是我們的目錄,而我們正透過這目錄理解其中的結構邏輯,如此才能更興奮、更期待地翻閱書本真正的內容...
開始之前請各位有興趣的讀者先跟著準備我們這次要研究的目標檔案,也就是第三日最一開始的 Hello World 程式。不只是準備而已,它還必須是 RISC-V 版本。為了省去查找指令的麻煩,這裡複製一次:
# GOARCH=riscv GOOS=linux /riscv-go/bin/go build /root/main.go
# ./main
bash: /tmp/main: cannot execute binary file: Exec format error
# cp ./main /home/riscv/rootfs/
# cd /riscv-linux && make ARCH=riscv
...(核心編譯輸出資訊)
# cd /riscv-tools/riscv-pk/build && make
...(bbl 編譯輸出資訊)
# /home/riscv/bin/spike /riscv-tools/riscv-pk/build/bbl
...(Linux 開機資訊)
總之是指定環境變數、然後使用編好的 go 檔編出一個 main 可執行檔之後,確認他不是 Host (x86) 可執行的檔案。之後,將這個檔案放置到虛擬環境中的根目錄資料夾下,然後重新建置整份的 bbl 檔案。然後使用 spike 執行這個新的映像檔。如果成功進入 shell 的話,就可以繼續操作:
...(過去的 Linux 開機資訊)
[ 1.380000] Freeing unused kernel memory: 344324K
[ 1.380000] This architecture does not have kernel memory protection.
/ # /main
/main
Hello, World!
看似簡單,但也是費了一番功夫才能夠做到呢!總之,我們接下來就拿這個 main
執行檔當作例子,繼續看接下來的兩組檔頭資訊。
-l
的功能:展示程式檔頭還記得嗎?我們昨天說明了 Header64
與 FileHeader
結構的資訊落差問題。其中原本前者有而後者闕漏的部份,在 file.go
的讀檔程式片段是這樣子的:
// Read ELF file header
var phoff int64
var phentsize, phnum int
var shoff int64
var shentsize, shnum, shstrndx int
shstrndx = -1
這裡顯然分為兩組數值。若是與 GNU 的工具對照,則可以發現:
# riscv64-unknown-linux-gnu-readelf -h /home/riscv/rootfs/main
...
Machine: RISC-V
Version: 0x1
Entry point address: 0x70c50
Start of program headers: 64 (bytes into file)
Start of section headers: 456 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 7
Size of section headers: 64 (bytes)
Number of section headers: 23
Section header string table index: 3
ph
開頭的三個變數,即是分別代表程式檔頭起始偏移量(phoff
,program header offset,表示從檔案一開始到程式檔頭的偏移;型別是 64 位元整數,因為記憶體映射的關係,在 64 位元上有此需求)、每個程式檔頭的大小(phentsize
,program header entry size,是一個以 byte 數為單位的量)、以及這個 ELF 檔中含有的程式檔頭數量(phnum
)。
也就是說,從一個 ELF 檔案閱讀器的角度來思考的話,有了這三個資訊之後,就能夠取得總數為 phnum
個、每個 phentsize
個位元組、從 phoff
開始算起的程式檔頭內容了。相關的程式碼,讓我們再參考一下 debug/elf
是怎麼作的。一樣在 file.go
之中:
type File struct {
FileHeader
Sections []*Section
Progs []*Prog
}
我們可以看到這個函式庫將那些資訊的知識直接利用,並且存成變數名稱為 Progs
的 Prog
指標陣列;也就是說如果當初 phnum
的量是 7 的話,這裡我們就可以存取 f.Progs[0]
到 f.Progs[6],他們會是對應的 Prog 結構(位在 elf.go
之中):
type ProgHeader struct {
Type ProgType // 本區域的型別
Flags ProgFlag // 本區域的使用權限(讀、寫、執行)
Off uint64 // 本區域相對於檔案起始的偏移量
Vaddr uint64 // 本區域在虛擬記憶體中的位址
Paddr uint64 // (通常不用)
Filesz uint64 // 本區域在檔案中的大小
Memsz uint64 // 本區域在記憶體中的大小
Align uint64 // 本區域在記憶體中的對齊
}
type Prog struct {
ProgHeader // 程式檔頭資訊
io.ReaderAt
sr *io.SectionReader // 用來讀取區域內容的私有變數,日後再詳談
}
有興趣的讀者可以在
file.go
的NewFile
函式之中找到這些區域被寫入時的過程。筆者這裡就將結果直接拿來用了。
所以我們可以參考之前 ELF 檔頭的實作,但是不一樣的是,ELF 檔頭時只有雙欄的簡單屬性數值列表,這裡卻因為有多個程式檔頭的關係,成為多欄的資料表。因此我們在建立 json 字元陣列的時候,必須要手動把取得的 Prog
型別 json 使用逗號串連起來,然後頭尾再包上中括號,使之符合 json 文法規範。值得注意的是,我們這次還多用了正規表示式函式庫,目的就是為了最後能夠把多餘的逗號取代掉。
+ if *args["l"].(*bool) {
+ str := "]" // 後端加蓋
+ for _, p := range reu.file.Progs { // _ 是 go 的語法,代表忽略的意思
+ raw, err := json.Marshal(p) // 這裡忽略的是迴圈 index
+ if err != nil {
+ return err
+ }
+
+ str = "," + string(raw) + str // 將 json 檔串連起來
+ }
+ re, _ := regexp.Compile("^,") // 首次見面的 regex 用法!
+ str = re.ReplaceAllString(str, "[") // 前端加蓋
+
+ reu.raw["l"] = []byte(str)
+ }
這個組裝好的 reu.raw["l"]
在輸出時會被這樣解讀:
+ if *args["l"].(*bool) {
+ var output []elf.Prog // 當作某個結構的陣列來解讀
+ json.Unmarshal(reu.raw["l"], &output)
+ // 圖表欄位格式,\t 字元為分隔符
+ fmt.Fprintln(w, "Program Header:\t\t\t\t\t\t\t")
+ fmt.Fprintln(w, "Number\tType\tFlags\tOffset\tAddress\tFile Size\tMemory Size\tAlignment")
+ // 把每個程式檔頭印出來
+ for i, p := range output {
+ fmt.Fprintf(w, "%d\t%s\t%s\t%x\t%x\t%d\t%d\t%x\n",
+ i, p.Type.GoString(), p.Flags.GoString(),
+ p.Off, p.Vaddr, p.Filesz, p.Memsz, p.Align)
+ }
+ fmt.Fprintln(w)
+ w.Flush()
+ }
這樣就完成了程式檔頭的部份啦!和 GNU 版本有很多不一樣的部份,這是原本就打算這麼作的,畢竟許多資訊給了使用者只是徒然眼花撩亂,實際上未必有什麼幫助。
程式檔頭到底是什麼呢?Program 是一種特殊的文件,能夠被作業系統核可,能夠被讀取進入記憶體,並且作為指令執行的東西。程式檔頭因此強調程式的執行期需求,比方說,Filesz
參數讓 ELF 讀取器或是作業系統的執行引擎能夠真正找到那些指令或資料所在的內容;更清楚的則像是Flags
參數,讓作業系統將之載入到記憶體處理分頁時,能夠明確的知道,這個程式區域是可讀可執行的,因此應該是一些指令;那個程式區域可以讀寫但不可執行,應該是代表某些資料片段,等等的資訊。
-S
的功能:展示區段檔頭只要引用類似的技術,就能夠實作完這個部份。Run
函式的新增部份為:
+ if *args["S"].(*bool) {
+ str := "]"
+ for _, p := range reu.file.Sections {
+ raw, err := json.Marshal(p)
+ if err != nil {
+ return err
+ }
+
+ str = "," + string(raw) + str
+ }
+ re, _ := regexp.Compile("^,")
+ str = re.ReplaceAllString(str, "[")
+
+ reu.raw["S"] = []byte(str)
+ }
而 Output
函式則是
+ if *args["S"].(*bool) {
+ var output []elf.SectionHeader
+ json.Unmarshal(reu.raw["S"], &output)
+
+ fmt.Fprintln(w, "Section Header:\t\t\t\t\t\t\t\t\t")
+ fmt.Fprintln(w, "Number\tName\tType\tFlags\tAddress\tOffset\tSize\tLink\tInfo\tAlignment")
+ for i, s := range output {
+ fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%x\t%x\t%d\t%d\t%d\t0x%x\n",
+ i, s.Name, s.Type.GoString(), s.Flags.GoString(),
+ s.Addr, s.Offset, s.Size, s.Link, s.Info, s.Addralign)
+ }
+ fmt.Fprintln(w)
+ w.Flush()
+ }
是的,大部分的程式碼片段都很類似。筆者之前費盡思量,才決定 Run
函式的輸出格式以 map 物件儲存,令索引型別為字串是因為我們將會需要依照參數的內容去取用執行結果,而這個結果是以字元陣列的方式儲存的 json 內容。過程中曾經考慮過把這個輸出當作回傳值經過 main
函式再傳進歸屬在 common 函式庫裡的 Output
,但是發現 go 語言的 json 加解碼不支援巢狀結構!現在暫時的解決方案是把這個資料內部化,卻故意繞遠路:執行、打包 json、解碼 json、輸出,這是為了將來的融合性考量。
基於重複功能的程式碼很多,之後會考慮將之整合起來成為函式。
目前的成果類似這樣:
Section Header:
Number Name Type Flags Address Offset Size Link Info Alignment
0 .shstrtab elf.SHT_STRTAB 0x0 0 709a 246 0 0 0x1
1 .comment elf.SHT_PROGBITS elf.SHF_MERGE+elf.SHF_STRINGS 0 7080 26 0 0 0x1
2 .bss elf.SHT_NOBITS elf.SHF_WRITE+elf.SHF_ALLOC 207080 7080 448 0 0 0x20
3 .data elf.SHT_PROGBITS elf.SHF_WRITE+elf.SHF_ALLOC 207000 7000 128 0 0 0x20
4 .got elf.SHT_PROGBITS elf.SHF_WRITE+elf.SHF_ALLOC 206e38 6e38 456 0 0 0x8
5 .dynamic elf.SHT_DYNAMIC elf.SHF_WRITE+elf.SHF_ALLOC 206c78 6c78 448 6 0 0x8
6 .data.rel.ro elf.SHT_PROGBITS elf.SHF_WRITE+elf.SHF_ALLOC 206bc0 6bc0 184 0 0 0x20
7 .fini_array elf.SHT_FINI_ARRAY elf.SHF_WRITE+elf.SHF_ALLOC 206bb8 6bb8 8 0 0 0x8
8 .init_array elf.SHT_INIT_ARRAY elf.SHF_WRITE+elf.SHF_ALLOC 206bb0 6bb0 8 0 0 0x8
9 .eh_frame elf.SHT_PROGBITS elf.SHF_ALLOC 59a8 59a8 3180 0 0 0x8
10 .eh_frame_hdr elf.SHT_PROGBITS elf.SHF_ALLOC 5750 5750 596 0 0 0x4
11 .rodata elf.SHT_PROGBITS elf.SHF_ALLOC 4be0 4be0 2927 0 0 0x20
12 .fini elf.SHT_PROGBITS elf.SHF_ALLOC+elf.SHF_EXECINSTR 4bbc 4bbc 9 0 0 0x4
13 .text elf.SHT_PROGBITS elf.SHF_ALLOC+elf.SHF_EXECINSTR 1410 1410 14249 0 0 0x10
14 .plt.got elf.SHT_PROGBITS elf.SHF_ALLOC+elf.SHF_EXECINSTR 1400 1400 8 0 0 0x8
15 .plt elf.SHT_PROGBITS elf.SHF_ALLOC+elf.SHF_EXECINSTR 13f0 13f0 16 0 0 0x10
16 .init elf.SHT_PROGBITS elf.SHF_ALLOC+elf.SHF_EXECINSTR 13d8 13d8 23 0 0 0x4
17 .rela.dyn elf.SHT_RELA elf.SHF_ALLOC c88 c88 1872 5 0 0x8
18 .gnu.version_r elf.SHT_GNU_VERNEED elf.SHF_ALLOC c28 c28 96 6 1 0x8
19 .gnu.version elf.SHT_GNU_VERSYM elf.SHF_ALLOC ba8 ba8 126 5 0 0x2
20 .dynstr elf.SHT_STRTAB elf.SHF_ALLOC 8f8 8f8 688 0 0 0x1
21 .dynsym elf.SHT_DYNSYM elf.SHF_ALLOC 310 310 1512 6 1 0x8
22 .gnu.hash elf.SHT_GNU_HASH elf.SHF_ALLOC 298 298 116 5 0 0x8
23 .note.gnu.build-id elf.SHT_NOTE elf.SHF_ALLOC 274 274 36 0 0 0x4
24 .note.ABI-tag elf.SHT_NOTE elf.SHF_ALLOC 254 254 32 0 0 0x4
25 .interp elf.SHT_PROGBITS elf.SHF_ALLOC 238 238 28 0 0 0x1
26 elf.SHT_NULL 0x0 0 0 0 0 0 0x0
還算是蠻壯觀的吧!雖然很多資訊的可讀性還差強人意,但這就留待日後再調整了。
如果說程式檔頭是給執行引擎看的資訊,那麼說區段檔頭就是給連結器(linker)看的資訊也不為過。有修過系統程式的讀者應該會覺得 .text
、.bss
、.data
這些標籤有點眼熟,事實上,它們就都是區段的名稱。因為通常會有很多區段被連結在一起,其中有些區段雖然彼此之間沒什麼必然的相關性,但是可能因為同為可讀可執行的屬性(如建構子程式 .init.text
和一般程式 .text
),而被整合在同一個程式區域之中,交由同一個程式檔頭管理它們的共通屬性。
我們接著就三種最常見的 ELF 格式檔案來探討這兩種檔頭:
.o
檔案,比方說之前編譯 Linux 時的這類檔案就很多。這些檔案因為不可執行,所以就沒有被作業系統映射到記憶體的問題,那也就不需要程式檔頭或是程式區塊;相對的,因為這類檔案存在的目的就是等著被連結、重定位,因此他們的區段都會被連結器整合起來與其他物件檔相同的區段放在一起。比方說 A.o
和 B.o
都有自己的程式碼在 .text
區段裡面,那連結器就要閱讀各自的 .text
區段大小、內容等資訊,然後整合在一起。(當然,除了放在一起之外,程式要能動還需要重定位這個很重要的步驟。).so
檔案。這些物件檔既有被連結的需求,也有被執行的需求,因此兩種檔頭都必須具備。dhex
這個二進位檔編輯器今天終於完成 -l
與 -S
的說明與實作!希望各位讀者能夠感到不那麼無聊,這也就更有意義了。關於接下來的篇章,就暫且讓大家期待一下。各位讀者,我們明日再會!