---
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/)