iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 6
0

前情提要


花去一天來解決目前的所有問題,也好好整理了一下 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 的功能:展示程式檔頭


還記得嗎?我們昨天說明了 Header64FileHeader 結構的資訊落差問題。其中原本前者有而後者闕漏的部份,在 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
}

我們可以看到這個函式庫將那些資訊的知識直接利用,並且存成變數名稱為 ProgsProg 指標陣列;也就是說如果當初 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.goNewFile 函式之中找到這些區域被寫入時的過程。筆者這裡就將結果直接拿來用了。

實作

所以我們可以參考之前 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 格式檔案來探討這兩種檔頭:

  • 物件檔(Reclocatable,可重定位檔案)
    這些就是通常編譯專案時會出現的那些 .o 檔案,比方說之前編譯 Linux 時的這類檔案就很多。這些檔案因為不可執行,所以就沒有被作業系統映射到記憶體的問題,那也就不需要程式檔頭或是程式區塊;相對的,因為這類檔案存在的目的就是等著被連結、重定位,因此他們的區段都會被連結器整合起來與其他物件檔相同的區段放在一起。比方說 A.oB.o 都有自己的程式碼在 .text 區段裡面,那連結器就要閱讀各自的 .text 區段大小、內容等資訊,然後整合在一起。(當然,除了放在一起之外,程式要能動還需要重定位這個很重要的步驟。)
  • 動態物件檔(Dynamic Object,通常是共享函式庫)
    通常是函式庫的那些 .so 檔案。這些物件檔既有被連結的需求,也有被執行的需求,因此兩種檔頭都必須具備。
  • 可執行檔(Excutable,可執行檔)
    按照上面的思路,可執行檔應該也不太需要什麼區段檔頭,因為已經沒有連結需求了不是嗎?筆者對這個也還沒有肯定的答案,但是使用 readelf 工具程式觀察大部分的可執行檔都還是有區段檔頭。然而這裡可以作一個小實驗,由於輸出量繁多,就描述步驟不截終端機內容。
    • 首先,下載 dhex 這個二進位檔編輯器
    • 複製任何可執行檔一份,然後修改區段檔頭數量為 0
    • 還是可以執行!

小結


今天終於完成 -l-S 的說明與實作!希望各位讀者能夠感到不那麼無聊,這也就更有意義了。關於接下來的篇章,就暫且讓大家期待一下。各位讀者,我們明日再會!


上一篇
第五日:readelf 開發過程之疑難排解
下一篇
第七日:以組譯器 as 為下一個目標
系列文
與妖精共舞:在 RISC-V 架構上使用 GO 語言實作 binutils 工具包30

尚未有邦友留言

立即登入留言