今天的終端機終於會出現核心自己印出的文字:
Hello Kernel!
sum=42 hex=2a
OpenSBI 的開機資訊和這兩行文字會出現在同一個視窗,但來源不同。
前者由韌體輸出,後者則從我們的 kernel_main() 開始,經由一個自己實作的字元輸出函式送出去。
《Operating System Concepts》第 10 版第 2.1 節,把 I/O 操作列為作業系統提供的服務。
程式需要輸出資料,但不應每次都重新理解裝置控制器如何運作,介面的價值就是把這些細節集中處理。
今天先在核心內建立這種分工:格式化函式決定要印哪些字元,字元輸出函式再呼叫韌體。
目前還沒有使用者程式,所以這不是 U-mode 的系統呼叫 API,而是核心自己的 Console 功能。
《OS in 1,000 Lines》的〈Hello World!〉利用 SBI(Supervisor Binary Interface)輸出字元。
我們沿用 Day 01 固定下載的 OpenSBI 韌體,使用它支援的 Legacy Console Putchar,擴充編號為 0x01。
Legacy 表示這是舊介面,換成其他韌體時要重新確認支援情況,不能假定所有 SBI 實作都提供它。
以下操作都在 30-days-os-kernel/tiny-kernel/,接續 Day 04。
新增 console.c:
#include "kernel.h"
#include <stdarg.h>
int putchar(int ch) {
register unsigned int a0 __asm__("a0") = (unsigned char) ch;
register unsigned int a7 __asm__("a7") = 1;
__asm__ __volatile__("ecall"
: "+r"(a0)
: "r"(a7)
: "memory");
return ch;
}
static int print_string(const char *s) {
int count = 0;
if (!s)
s = "(null)";
while (*s) {
putchar((unsigned char) *s++);
count++;
}
return count;
}
static int print_unsigned(unsigned int value, unsigned int base) {
const char digits[] = "0123456789abcdef";
char buffer[32];
unsigned int n = 0;
do {
buffer[n++] = digits[value % base];
value /= base;
} while (value);
int count = (int) n;
while (n)
putchar(buffer[--n]);
return count;
}
int printf(const char *format, ...) {
va_list args;
va_start(args, format);
int count = 0;
while (*format) {
if (*format != '%') {
putchar((unsigned char) *format++);
count++;
continue;
}
format++;
if (!*format) {
putchar('%');
count++;
break;
}
switch (*format) {
case 's':
count += print_string(va_arg(args, const char *));
break;
case 'u':
count += print_unsigned(va_arg(args, unsigned int), 10);
break;
case 'x':
count += print_unsigned(va_arg(args, unsigned int), 16);
break;
case '%':
putchar('%');
count++;
break;
default:
putchar('%');
putchar((unsigned char) *format);
count += 2;
break;
}
format++;
}
va_end(args);
return count;
}
putchar() 把字元放到 a0,把服務編號放到 a7,再執行 ecall。
這個 wrapper 只用於本次的 Legacy Putchar,不是通用的 SBI v0.2 呼叫封裝。
此處也尚未把韌體回傳的錯誤轉成完整的 I/O 錯誤處理。
ecall 會觸發環境呼叫例外,在目前的設定下由 M-mode 的 OpenSBI 處理,再返回 S-mode 核心。
字元最後經過韌體的裝置驅動與 QEMU 模擬的序列埠,呈現在主機終端機。
上面的 printf() 是本系列自己的最小版本,只支援 %s、%u、%x 與 %%。%u 與 %x 接收 unsigned int,字串則用 %s,不支援浮點數、欄寬或 %p。
stdarg.h 提供可變參數的工具,並不依賴主機的 printf()。
數字轉字串則是普通的整數運算,先反向保存餘數,再倒著輸出。
使用 do ... while 是為了確保輸入為零時,也會印出一個 0。
這份程式以 %u 為主,讓後續計數器與位址的型別保持清楚。
列印 RV32 位址時會明確轉成 unsigned int,到 RV64 階段不能直接沿用這種轉型。
在 kernel.h 末尾加入:
int putchar(int ch);
int printf(const char *format, ...);
Makefile 的 SOURCES 更新為:
SOURCES := kernel.c asm_probe.S boot.S console.c
保留 Day 04 的 kernel.c 其他內容,將 kernel_main() 取代為:
void kernel_main(void) {
probe_result = asm_probe(17, 25);
printf("Hello %s!\n", "Kernel");
printf("sum=%u hex=%x\n", probe_result, probe_result);
printf("zero=%u percent=%%\n", 0u);
printf("stack top=0x%x\n", (unsigned int) __stack_top);
for (;;)
__asm__ __volatile__("wfi");
}
wfi 是 Wait For Interrupt,允許處理器等待事件。
它放在迴圈中,是因為返回執行或被實作成近似 no-op 時,核心仍應停留在這個等待位置。
這不是排程器,也沒有建立可被喚醒的行程。
在 tiny-kernel/ 執行:
make
make run
預期在 OpenSBI 訊息後看到:
Hello Kernel!
sum=42 hex=2a
zero=0 percent=%
stack top=0x...
最後一行的位址依本次建置而定,應與 make symbols 裡的 __stack_top 相符。
這份輸出同時檢查字串、十進位、十六進位、零值與百分比符號,避免只用一句 Hello World 就以為所有格式都正常。
接著可以把 asm_probe(17, 25) 改成 asm_probe(100, 23),重新編譯後檢查 sum=123 hex=7b。
這能確認畫面上的數字真的來自本次運算,完成後可恢復原本的 42 範例。
如果只看到 OpenSBI,先用 Day 03 的 GDB 方法停在 putchar(),確認核心是否走到輸出函式。
若停在 ecall 周圍,檢查 a7 是否為 1,以及載入的韌體是否和 Day 01 相同。
如果字串正常但數字不對,先檢查格式和參數型別。
例如 %u 必須配合無號整數,不能拿它直接讀取指標或字串。
目前不支援的格式也不會自動得到標準函式庫的行為。
今天新增 console.c,修改 kernel.h、kernel.c 與 Makefile,建議 commit 訊息為:
day05: add kernel console
Day 06 會把這條輸出路徑用在失敗分析,讓核心遇到不能繼續的狀況時,能留下檔名、行號與原因。