--- tags: System Software, 作業系統 --- # 在 QEMU 系統模擬器上執行 bare metal C code ## 引言 這是一個 Hello world,我想是一個再簡單不過的程式。 ```cpp // hello.c #include<stdio.h> int main(){ printf("Hello World!\n"); return 0; } ``` 假如我們希望編譯成將其 riscv image 並載入到 QEMU 系統模擬器中,結果會如何呢? ``` $ riscv64-unknown-elf-gcc hello.c \ -march=rv64g \ -static \ -o hello $ qemu-system-riscv64 -nographic -machine virt \ -cpu rv64 -bios none -kernel hello ``` 很顯然的,Hello world 沒辦法顯示出來。這樣的結果很理所當然,因為 `printf` 是來自標準函式庫,而後者一般情境中都是建立在作業系統之上的。在還未載入 kernel image 的系統模擬器中,即使我們藉由 `-static` 讓函式庫中的函式被靜態的複製到執行檔中,沒有作業系統也就意味著 `printf` 最終會呼叫的 `write` 系統呼叫沒有辦法被處理。 標準函式庫作為廣泛的使用目的,有許多的函式需要依賴於足夠完整的作業系統才合理。不過假設我們只需要印出字串的功能,是否可以設計一個與標準函式庫相似的介面的函式庫,並在該函式庫底下直接操作輸出裝置(例如 UART)來取代之呢? 答案是肯定的。 ## C Runtime 對初學 C 應用程式的撰寫者來說,可能都會認為 `main` 是整個程式的入口點,但反問幾個問題: 1. C 程式如果真是從 `main` 開始執行,那參數的 `argv[]` 內容究竟是從何而來呢? 2. 最後 `main` 回傳數值時,又是怎麼傳遞給作業系統使其得知程式執行結果? 究竟 `main` 是不是執行 C 程式的入口點呢? 引用 [你所不知道的 C 語言: 執行階段程式庫 (CRT)](https://hackmd.io/@sysprog/c-runtime?type=view#%E5%8B%95%E6%A9%9F): > 要解答上述疑惑,我們就需要理解就 C Run-Time Library (執行階段程式庫)。C 語言開發後 (1973 年),貝爾實驗室的 Dennis Ritchie 和 Brian Kernighan 就用 C 重寫了絕大多數 UNIX 系統函式,並把其中最常用的部分獨立出來,逐漸演化我們熟知的 `<stdio.h>` 和 `<stdlib.h>` 等標頭檔,而 C run-time library 也是如此成形。隨著 C 語言的廣泛流通,各個 C 編譯器的生產商/個體/團體都遵循老的傳統,在不同平台上都有相對應的 Standard Library,但大部分實現都是與各個平台有關的。為了縮減不同 C 編譯器間的落差,ANSI C 詳細規定 C 語言各個要素的具體含義和編譯器實作要求。 實際上,程式的入口點是由連結器(linker)所決定的。以 gcc 為例,根據 [The Entry Point](https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_node/ld_24.html) 文件: > ENTRY is only one of several ways of choosing the entry point. You may indicate it in any of the following ways (shown in descending order of priority: methods higher in the list override methods lower down). > * the `-e` entry command-line option; > * the ENTRY(symbol) command in a linker control script; > * the value of the symbol start, if present; > * the address of the first byte of the .text section, if present; > * The address 0. 在未使用 `-e` 參數的前提下,入口點基本是由 linker scripts 的 `ENTRY(symbol)` 決定。我們可以透過 `gcc hello.c -Wl,--verbose` 或者 `ld --verbose` 來看到 linker scripts,其中的內容中可以見到 `ENTRY(_start)` 這一段。換句話說,當我們透過 `gcc hello.c` 將程式編譯,程式其實是規範從 `_start` symbol 開始執行的! 於是標準函式庫的 CRT 可以定義該 symbol,並協助應用程式的撰寫者在執行 main 以前的初始化,再從 `_start` 中去呼叫 `main`,而當我們從 `main` 返回,CRT 也要負責處理 main 的回傳值以及正確的終止程式。 ## lib-rv64qemu [riscv-probe](https://github.com/michaeljclark/riscv-probe) 專案的 libfemto 部份提供了部份可以運行於 bare metal 環境之上的標準函式庫實作,而這裡我們借用其概念實作 [lib-rvqemu](https://github.com/RinHizakura/lib-rvqemu/tree/minimal),後者將 libfemto 簡化,將其必要的部份抽離出來(鎖定在使用的機器是 QEMU 的 64 bits `riscv-virt` machine 上),以下將對程式碼進行說明。 ### linker scripts [`linker.ld`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/linker_scripts/machine/qemu-virt/linker.ld) 擔任了把我們的函式庫與 `hello.c` 結合起來的角色,關於 linker 的寫法可以參照以下 reference,這裡我們暫時不仔細說明,不過可以看到其中的關鍵是我們將入口點設置為 `_start` > * [一個有註解的 linker scripts]( https://github.com/sgmarz/osblog/blob/master/risc_v/src/lds/virt.lds) > * [Linker Script初探 - GNU Linker Ld手冊略讀](http://wen00072.github.io/blog/2014/03/14/study-on-the-linker-script/) > * [Linker scripts](https://sourceware.org/binutils/docs/ld/Scripts.html) ### `crt.s` 作為入口點的 `_start` 在哪裡呢? 這個 symbol 位於 [`crt.s`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/arch/riscv/machine/qemu-virt/crt.s) 由於我們現在位於 bare metal 的環境上,在開始撰寫 C 語言之前,首先要將必要的環境進行初始化,步驟包含了: 1. `hart` / hardware thread 在 RISCV 中是指執行程式環境的基本單位,我們可以先簡單理解為一個 cpu core(注意這個理解並不完全正確)。因此對於此段 assembly,是為了避免多核心的複雜性,我們實際上只會啟動一個 core,其他則會陷入一個迴圈的休眠中。 ```asm # For the hart that id != 0, hanging in a infinite for loop csrr t0, mhartid bnez t0, 3f ... 3: wfi j 3b ``` 2. 為了使 bss section 的記憶體狀態符合期待(bss 擺放未初始化或者初始為 0 的變數) ```asm # .bss section is reset to be zero la a0, _bss_start la a1, _bss_end bgeu a0, a1, 2f 1: sd zero, (a0) addi a0, a0, 8 bltu a0, a1, 1b ``` 3. 設置 C 語言需要的 stack pointer,然後跳轉至 `lib_main` ``` 2: la sp, _stack_end j lib_main ``` ### `machine_setup` `lib_main` 首先執行 [`machine_setup`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/arch/riscv/machine/qemu-virt/setup.c#L21),將硬體裝置進行初始化,這裡涉及 console(模擬輸入與輸出) 和 poweroff(模擬 main 的 return) 兩個裝置的註冊。介面上,[`device.h`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/include/arch/riscv/device.h) 將裝置抽象成一個 struct,並且 struct 的成員為各種方法的 function。因此每個方法只要定義其方法的詳細實作,再透過 [`register_console`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/arch/riscv/device.c#L6) / [ `register_poweroff`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/arch/riscv/device.c#L16) 註冊到這個通用的介面上即可 在 console 裝置上,我們使用 [uart16550](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/drivers/uart16550.c)。而 poweroff 則藉由 [sifive_test](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/drivers/sifive_test.c) 這個在 QEMU 中,為了方便對模擬器進行測試並且可以將模擬器關閉的 [memory mapping I/O](https://github.com/qemu/qemu/blob/master/hw/riscv/virt.c#L48) 達成。 ### `lib_main` ```cpp int main(int argc, char **argv); void lib_main() { machine_setup(); char *argv[] = {"dummy", NULL}; int ret = main(1, argv); exit(ret); while (1) ; } ``` 在完成 `machine_setup` 後,我們就可以準備切換到 `hello.c` 的 `main` 之中了! 為了滿足與一般常見的 `main` 一致的介面,我們將 `argc` 設置為 1 並將傳遞一個目前不預期被使用的 `argv`。此外,由於 `main` 理論上要被定義在整個函式庫之外,可以注意到我們加上一個 [prototype](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/arch/riscv/init.c#L7) 來避免編譯器的警告。 最後,當從 `main` 返回之後,我們將 `main` 的回傳值傳送至 [`exit`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/stdlib/exit.c),後者的實作實際上則沒有進行處理 :laughing:,只是簡單地對 QEMU 的 poweroff 裝置傳送表示關閉模擬器的數值,以結束模擬器的運行。 ### `printf` 我們已經大致釐清了從模擬器啟動到關閉的流程,包含了其如何與 `hello.c` 的 `main` 連結在一起。不過最關鍵的問題其實是,我們如何實現 `main` 之中對標準函式庫的 `printf` 之呼叫呢? 首先,讓我們關注一下函式庫中的 `putchar` 是如何被實現的: ```cpp int putchar(int ch) { return console_dev->putchar(ch); } ``` 沒有什麼特別之處!我們只是直接由 [`uart16550_putchar`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/drivers/uart16550.c#L46) 將 char 進行輸出。 ```cpp int printf(const char *format, ...) { /* warning: if the actual format string is larger than 256 characters, * we'll meet the buffer overflow problem */ char buf[256]; va_list arg; va_start(arg, format); int len = vsprintf(buf, format, arg); va_end(arg); for (int i = 0; i < len; i++) putchar(buf[i]); return len; } ``` 而如上是我們對 `printf` 的實現,大致是透過 `va_start` / `va_end` 等 [`stdarg.h`](https://www.cplusplus.com/reference/cstdarg/) 中提供的一系列參數處理方法進行解析。[`vsprintf`](https://github.com/RinHizakura/lib-rv64qemu/blob/minimal/lib/stdio/vsprintf.c#L8) 會進行主要的字串格式解析。待 `vsprintf` 將產生的字串填入到 `buf` 後,我們只要逐一印出即可。 ### 小結 在回顧之前,也許會有人發現我們並沒有實作 `va_start` 或者定義 `uint64_t`,但為什麼可以直接使用它們呢? 明明 linker 選項中已經使用了 `-nostdlib` ? 這是因為 `-nostdlib` 只是避免 linker 將 symbol 連結到我們系統中的標準函式庫,但對於 macro 的展開或者 identifier 的定義,只要最終不涉及標準函式庫的 symbol,我們就可以直接借用系統上的標準函式庫之定義。以 `va_start` 而言,它在我的機器中被展開成 `__builtin_va_start`,因此參數的解析是在編譯器實作的,可以不依賴於標準函式庫。換句話說,如果編譯器不支援內建的 `__builtin_va_start` 情況下,`va_start` 可能被展開成某個系統標準函式庫的 symbol,此時則我們需要自己實作之。 但總歸來說,我們成功將 `hello.c` 編譯成可以在 bare metal 環境上載入並得到預期輸出的 image! 當然了,至今為止的實作具有缺陷或者並不足以支持標準函式庫的所有函式,不過顯然現在你可以為自己改進與實現他們! ## Reference * [你所不知道的 C 語言: 執行階段程式庫 (CRT)](https://hackmd.io/@sysprog/c-runtime?type=view#%E5%8B%95%E6%A9%9F) * [riscv-probe](https://github.com/michaeljclark/riscv-probe) * [riscv64-in-qemu](https://github.com/rtfb/riscv64-in-qemu) * [Adding a custom peripheral to QEMU RISC-V machine emulation & interacting with it using bare-metal C code](https://embeddedinn.xyz/articles/tutorial/Adding-a-custom-peripheral-to-QEMU/)