iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 8
0

前情提要


昨日確立了下一個階段目標,就是做好 RISC-V 專用二進位編碼指令之間的轉換函式庫,而最主要的應用就是組譯器 as。筆者也已經盡了努力尋找目前的 go 語言標準函式庫中可用的部份,但發現 go 對於編譯流程的設計與 GNU 太過不同,因此還是決定重新刻一個。

有鑑於 riscv-go 的開發沒有很完整,也許這個部份可以設法套進 go 的框架之中,之後往上游貢獻,讓 riscv 成為正式甚至第一級的支援項目。

一直重複說轉換函式庫相當重複,自此開始筆者將之稱為** rvgc 函式庫**,代表 riscv gc 指令集,也就是 imafdc 的縮寫,他們分別是基本整數、乘法、原子操作、單雙倍浮點數、壓縮指令集。

短期目標:簡單組譯器生成 add.o


延續昨日,我們的目標是讓 as 能夠以 add.s 組語檔案為輸入產生 add.o 檔,至少在功能上要能夠透過相同的步驟與 main.c 檔內的程式呼叫整合在一起。因此,這個短期目標分為以下三個部份來實作:

  1. 輸入:組合語言檔
    1. 理解非指令部份、解析結構
    2. 理解指令部份
  2. 組譯:根據 rvgc 函式庫找出對應的二進位編碼
  3. 輸出:物件檔
    1. 將前一階段結果格式化成區段
    2. 將各區段組合進入 ELF 格式
    3. 整理其餘 ELF 區域

輸入組合語言


關於這個,我們不能閉門造車,因此先參考 GNU 的 as 手冊,最主要是第三章定義語法、第五章講解 symbol 命名規則、以及第七章的組譯器選項(assembler directives)等等。其中,由於 GNU 計畫是過去許多傳統的積累,因此必須整合許多關於舊型二進位檔格式、許多現在已經沒有什麼市占率的架構的特殊需求、甚至是其他歷史上曾經有過一席之地的組譯器所支援的格式;我們這個系列則完全沒有這個限制,因為所有目標都很明確。

也就是說,整個專案架構的汎用性、可移植性並非筆者的第一考量。

目標

這是我們的目標程式,因此列在這裡參考:

.section .text
add:
        add a0, a1, a0
        ret
.global add
.end

現階段支援

在組譯器指令的部份,我們明顯應該支援的是 .section.global、以及 .end。這些指令的說明都在手冊第七章裡面。

  • .section:這指令後面會接一個識別字,代表該行以下的內容應該置放在以那個識別字為名的區段之中。手冊中特別強調這個指令只有在二進位檔格式支援任意名稱才可以使用。但顯然我們的 ELF 格式是有支援的。
  • .global:這個指令後面接的標籤,表示連結器可以拿那個標籤去給其他的物件檔使用。也就是說,如果獨缺這一行 .global add 的話,昨日列出的連結流程也是無法完成的,因為 main 函式將無法成功找到 add 函數與之連結。
  • .end:這個指令為整個組合語言檔案的最後一行,在這之後的內容都會被組譯器無視。

除了這些編譯器指令還有組合語言指令之外,只剩下標籤定義的格式,也就是像是 add: 這樣冒號結尾的敘述。至於標籤本身,GNU 所支援的語法是
以英文字或句點(.)、底線(_)開頭,後面可以包含英數字和非註解的符號的字串

有了這些資訊之後,我們就可以開始來寫解析輸入檔案的程式了!

組譯器需要的結構:asUtil

type elf64 struct {
        header   Header64
        sections []elf.Section64
}

type asUtil struct {
        src     *os.File
        objFile *os.File
        obj     *elf64
        raw     map[string][]string
}

其中,src 將用以代表我們要處理的輸入組語檔案,objFile 則是要輸出的物件檔;obj 是內部組譯處理之後放置檔頭的空間,這裡的配置是採取真實檔頭的配置,包含了一個 ELF 檔頭和多個區段檔頭希望可以直接以這個順序輸出到 objFile 中;raw 乍看之下與 readelf 的時候採取同樣的型別與名稱,其實這裡是 map 到字串陣列而非字元陣列,作為區段名稱(如 .text)到區段內容add 函數的 8 個位元組的機器碼)的對應。

之所以不繼續採取字元陣列的對應目標,是為了撰寫的方便性。對於 XXXTAB 型的區段,以現在的簡單例子來說,就是存放 symbol 與區段名稱的區段,它們新增內容時,一次以一個字串為單位新增會比較方便。之後要真正產出符合 ELF 規格的檔案時,轉換回純粹的記憶體結構或是字元陣列也都很方便。

初始化所有區段

之前的 readelf 的 Init 函式只有初始化所欲讀取的檔案,這裡我們卻可以名符其實的在這裡作檔頭內容的初始化。首先是 ELF 檔頭:

        reu.obj.header.Ident[0] = '\x7f'
        reu.obj.header.Ident[1] = 'E'
        reu.obj.header.Ident[2] = 'L'
        reu.obj.header.Ident[3] = 'F'
        reu.obj.header.Ident[4] = elf.ELFCLASS64
        reu.obj.header.Ident[5] = elf.ELFDATA2LSB
        reu.obj.header.Ident[6] = elf.EV_CURRENT
        reu.obj.header.Type = elf.ET_REL
        reu.obj.header.Machine = elf.EM_RISCV
        reu.obj.header.Version = elf.EV_CURRENT
        reu.obj.header.Ehsize = 64

        reu.obj.header.Shentsize = 64
        reu.obj.header.Shnum = 0
        reu.obj.header.Shstrndx = 0

當然,這內容參考自 readelf 的結果,我們預期經由這個 as 產生出來的物件檔都可以共用這些性質。程式檔頭不存在於物件檔中,所以再來就是區段檔頭了。add.s 組合語言檔案之中分明只有指定一個 .text區段,最後卻產生了 6 個區段出來。因此,我們初始化時,乾脆直接先把這些區段當作簡配款的基本配備,直接把它們生出來。這裡我們設計一個 addSection 函數,希望日後擴充性夠好。事實上,有許多區段的名稱都是沿襲多年來的慣例(比方說 .text 就一定是程式碼區域),所以我們之後會有一個內建區段名稱表,將這些慣用區段的屬性都在這時候設好,但這時候我們先手動設置;其他的屬性如區段實際大小等等的數值,必須等到處理完整個檔案才會知道,因此就隨著處理過程更新即可。最直接的例子是區段檔頭個數reu.obj.header.Shnum 這個值,這裡也將被我們直接拿來當作 index 使用。

筆者也有點叛逆心理,總覺得不想放慣例裡一定要有的 NULL 區段,且看之後會發生什麼事。

我們這裡令 Shstrndx 變數成為第 0 個區段,這麼一來就可以在每次呼叫 addSection 時將每個區段的名稱加到 .shstrtab 區段去。這個區段的組成是純文字,每一個區段名稱之間以空白當作間隔,對齊是 1。所以總結初始化流程就是:基本 ELF 檔頭設置、初始化 .shstrtab 所需空間(使用名為 slice 的 go 語言機制)、依序將預設的 6 種基本區段加入。也就是

        reu.raw[".shstrtab"] = make([]string, 6)
        addSection(".shstrtab")
        addSection(".strtab")
        addSection(".symtab")
        addSection(".data")
        addSection(".bss")
        addSection(".text")

其中 6 這個魔術數字當然就是目前支援的內建區段總數,這裡作的事情是將他初始化成可用的可變長度字串陣列、且其初始大小為 6。然後,addSection 的內容是

func (reu *asUtil) addSection(sec string) {
        if reu.obj.header.Shnum < 6 {
                reu.raw[".shstrtab"][reu.obj.header.Shnum] = sec
        } else {
                append(reu.raw[".shstrtab"], sec)
        }

        reu.obj.sections[reu.obj.header.Shnum].Name = currentOffsetShStr
        switch sec {
        case ".shstrtab":
                reu.obj.sections[reu.obj.header.Shnum].Type = elf.SHT_STRTAB
                reu.obj.sections[reu.obj.header.Shnum].Addralign = 0x1
        case ".strtab":
                reu.obj.sections[reu.obj.header.Shnum].Type = elf.SHT_STRTAB
                reu.obj.sections[reu.obj.header.Shnum].Addralign = 0x1
        case ".symtab":
                reu.obj.sections[reu.obj.header.Shnum].Type = elf.SHT_SYMTAB
                reu.obj.sections[reu.obj.header.Shnum].Addralign = 0x8
        case ".bss":
                reu.obj.sections[reu.obj.header.Shnum].Type = elf.SHT_NOBITS
                reu.obj.sections[reu.obj.header.Shnum].Flags = elf.SHF_ALLOC | elf.SHF_WRITE
                reu.obj.sections[reu.obj.header.Shnum].Addralign = 0x1
        case ".data":
                reu.obj.sections[reu.obj.header.Shnum].Type = elf.SHT_PROGBITS
                reu.obj.sections[reu.obj.header.Shnum].Flags = elf.SHF_ALLOC | elf.SHF_WRITE
                reu.obj.sections[reu.obj.header.Shnum].Addralign = 0x1
        case ".text":
                reu.obj.sections[reu.obj.header.Shnum].Type = elf.SHT_PROGBITS
                reu.obj.sections[reu.obj.header.Shnum].Flags = elf.SHF_ALLOC | elf.SHF_EXECINSTR
                reu.obj.sections[reu.obj.header.Shnum].Addralign = 0x4
        }

	currentOffsetShStr += len(sec) + 1
        reu.obj.header.Shnum += 1
}

針對這些內建的區段,我們設了三種能夠立刻決定的屬性。

  • Type:這裡有四種。PROGBITS 是程式碼和可讀寫資料,也就是那些程式裡面有寫出來的部份NOBITS 代表未初始化資料,需要動態配置記憶體空間,但是檔案中不佔空間。剩下就是兩種對照表格式。
  • Flags:這是關於區段性質的描述。ALLOC 就是需要配置記憶體給這個區段,EXECINSTR 表示區段內含有機器碼指令,WRITE 表示該區段內含有可寫資料。
  • Addralign:這是關於區段的位元對齊量。以 RISC-V 指令來說,每一次的寫入都應該至少對齊到 4 bytes,所以 .text 是 0x4;其他存放字元為主的區段就相當於無視對齊,因此設定為 0x1。

筆者是在我們的 readelf 設計中取得這些內容。當初使用 GoString 型別還可以直接複製貼上!真的是意料之外的好處,雖然未來還是應該把它們轉換為人類可讀的樣子才對。

讀取組合語言檔案

然後我們需要一個類似 readline 的功能,讓我們能夠使用 go 語言以行為單位處理組合語言檔案。筆者使用的是 bufio 函式庫中的 Readline 函式,完全符合這裡的需求。

因此,as 的 Run 函數框架如下,

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

        r := bufio.NewReaderSize(reu.src, 1024)
        line, _, err := r.ReadLine()
        for err == nil {
                sa := strings.Split(string(line), " ")

                if sa[0][0] == '.' {
                        dire(sa)
                } else if sa[0][len(sa[0])-1] == ':' {
                        label(sa[0][0 : len(sa[0])-1])
                } else {
                        inst(sa)
                }

                line, _, err = r.ReadLine()
        }

        if err != io.EOF {
                return err
        }

        reu.src.Close()
        return nil
}

對於每一行組語檔案,先以空白字元作為分隔符之後,使用多重條件式分開組譯器選項(dire)、標籤定義(label)以及指令(inst)處理。

dire函數必須計算組譯器選項的效果,然後把結果逐步存回暫存的 ELF 格式中,關於檔頭的資訊需要更新內建變數 header,而區段相關的更新就必須在 raw 之中了。之前因為 readelf 工具只需要讀取內容(大部分的 binutils 工具皆是如此),所以沒有需要這麼迂迴地處理。總之,我們目前支援三個項目:.section,在處理這個選項時,我們必須新增區段檔頭,並且配置對應的記憶體供區段使用;.global 會指派全域屬性給特定標籤,這是我們尚未提及的區段性質,將在後面一併解析;.end 標誌整份程式碼的結束。

組譯器選項處理

這一段在 dire 函數中,由前段描述的 Run 函數呼叫。其中特別需要注意的是 .section 的處理。由於我們已經把 .text 放在預設標頭之中,所以目前的版本中還不需要作錯誤判斷之外的行動,未來應該要在註解的部份新增判斷既有區段以及加入的相關邏輯。.global 的部份,會呼叫一個能夠設定標籤屬性的函數。

func (reu *asUtil) dire(d []string) (bool, error) {
        switch d[0] {
        case ".section":
                if len(d) != 2 {
                        return false, errors.New("Syntax error: section not specified!")
                }
                for _, sec := range internalSection {
                        if d[1] == sec {
                                return false, errors.New("Syntax error: not allowed section " + d[1])
                        }
                }
                //addSection(d[1])

        case ".global":
                if len(d) != 2 {
                        return false, errors.New("Syntax error: label not specified!")
                }
                setLabelType(d[1])

        case ".end":
                return true, nil
        }
        return false, nil
}

小結


又是進度的延宕!筆者感到萬分抱歉,但是鐵人賽若不是這樣氣喘吁吁,好像也會少了一種感覺;筆者原先的規劃還是太過輕忽,以為一個簡單的組語和物件檔應該沒有什麼困難,但是考察諸般參數設定仍然是花了不少時間。今日我們完成了掃描檔案並且將內容解析成接近真實 ELF 的格式,之後就可以繼續往可連結的 add.o 進行了。


上一篇
第七日:以組譯器 as 為下一個目標
下一篇
第九日:as 輸出物件檔之基礎架構實作
系列文
與妖精共舞:在 RISC-V 架構上使用 GO 語言實作 binutils 工具包30

尚未有邦友留言

立即登入留言