# 作業系統開發 vicLin8712 當硬體通電之後直至第一個任務被執行的過程,紀錄並學習如何將過程拆分與設計。 **Boot**: 通電後執行的初始化設定,第一部分紀錄直接加載 SBI,也就是自動初始化的過程。第二部分採用**手動**實作硬體相關初始化流程。 **核心初始化**: **載入排程器**: 參考材料: [Operating System in 1000 Lines](https://operating-system-in-1000-lines.vercel.app/en/)、[linmo OS](https://github.com/sysprog21/linmo) ## Boot ### 通電 當按下電源鍵後,下一步機器將會進行甚麼樣的行為運作? #### ROM 通電後,CPU將執行 ROM(Read Only Memory) 的程式碼,通常這是遷入在 CPU 內幾乎無法更改的。通常是由 CPU0 執行 ROM 的程式碼,並且其他 CPU 處於等待通知的狀態。 ROM 指的是硬體區塊的程式碼。在 IC 設計階段,工程師會將 ROM 編譯成的機器碼放入晶片內某個規劃的區塊,後續被晶片製造商刻入晶片之中。而當通電,CPU PC (point counter) 將自動指向此區塊的開頭,當然現代有些 SoC 支援可編輯的 ROM 機制。 ROM 的主要功能如下 (以 [AM335x](https://wiki.csie.ncku.edu.tw/embedded/rt-thread?revision=c0cb2c16e62e18a05c95eb712304350b03c96be6#AM335x%20ARM%20Cortex-A8%20Boot%20Sequence) 為例) * 設定堆疊指標 * 設定 Watchdog Timer 1 (計時器避免開機死當) * 系統時脈設定 * 掃描開機裝置 (FAT 12/16/32 partion) * 找到合法的 MLO(Main Loader) 檔 * 將此 MLO 檔儲存到 internal SRAM * 跳轉並執行 SRAM 內的 MLO 檔案 * MLO 載入 u-boot.img 至 DRAM * CPU 跳到 u-boot.img 執行後載入 Linux OS 關於上述名詞 * FAT /12/16/32 partion: File Allocation Table,檔案系統格式,其中 FAT32 為現今大多數 USB 規格, partion 則代表儲存裝置的[獨立邏輯磁區](https://zh.wikipedia.org/zh-tw/%E7%A1%AC%E7%9B%98%E5%88%86%E5%8C%BA) * MLO: 強制規定之檔案名稱,ROM 會搜尋設備中 MLO 檔案並將其載入 SRAM 執行,MLO 重要的任務為將 DRAM 初始化、並將設備中更完整的 `boot.img` 檔案放入 DRAM 執行。 :::warning 因 SRAM 容量有限,因此只能執行 MLO 檔將 DRAM 初始化後才可執行開機檔 ::: * SRAM: Static Random-Access Memory,此為揮發性記憶體,也就是說斷電後資料即消失。因其所需電子電路架構較大,因此此類型記憶體儲存量較小,常用於 L1 Cache. * DRAM: Dynamic Random-Access Memory,此為揮發性記憶體,程式、 img 檔、kernel 等都運行在 DRAM 上,同時因 DRAM 具有電容,物理特性會使其漏電,因此需要時常刷新,反觀 SRAM 無電容因此有電即可保持資料。 #### SBI (Supervisor Binary Interface) 在 RISC-V 架構下,CPU 執行完 ROM 後,會執行 SBI (韌體) 檔,其為 API 檔案用於提供接下來的 kernel boot 特殊權限 (s-mode) 存取硬體,所有的 Machine mode 皆透過 SBI interface 操作硬體。與 AM335x 不同的是這個架構下並沒有 MLO 檔。 :::info 僅需將 SBI 編譯成二進制,並將 QEMU flag BIOS 綁定即可使用,抑或是使用軟體對硬體實際控制,比如說透過 UART 寫入/讀取 CPU。之後依照 UART 與 inline assembly 的方式存取 registers。 ::: 透過 qemu 啟動 SBI 後,其輸出結果如下所示。下方設定的虛擬硬體資訊比如說 cpu 數、記憶體位址等由 qemu 與 openSBI 共同決定。 ```c Platform Name : riscv-virtio,qemu Platform Features : medeleg Platform HART Count : 1 // cpu 數 Platform IPI Device : aclint-mswi Platform Timer Device : aclint-mtimer @ 10000000Hz Platform Console Device : uart8250 Platform HSM Device : --- Platform PMU Device : --- Platform Reboot Device : sifive_test Platform Shutdown Device : sifive_test Firmware Base : 0x80000000 Firmware Size : 208 KB Runtime SBI Version : 1.0 Domain0 Name : root Domain0 Boot HART : 0 Domain0 HARTs : 0* Domain0 Region00 : 0x02000000-0x0200ffff (I) Domain0 Region01 : 0x80000000-0x8003ffff () Domain0 Region02 : 0x00000000-0xffffffff (R,W,X) Domain0 Next Address : 0x80200000 Domain0 Next Arg1 : 0x87e00000 Domain0 Next Mode : S-mode Domain0 SysReset : yes Boot HART ID : 0 Boot HART Domain : root Boot HART Priv Version : v1.12 Boot HART Base ISA : rv32imafdch Boot HART ISA Extensions : time,sstc Boot HART PMP Count : 16 Boot HART PMP Granularity : 4 Boot HART PMP Address Bits: 32 Boot HART MHPM Count : 16 Boot HART MIDELEG : 0x00001666 Boot HART MEDELEG : 0x00f0b509 ``` 當 kernel (S mode) 需要 machine (M mode) 的服務時,比如說設定 timer,重新開關機,則 kernel 需要呼叫由 SBI 所提供的 API 函式,對應的記憶體區塊則為上方表格之記憶體區塊。 ##### Kernel Linker Scripts (with SBI) SBI 預設 `Domain0 Region02 : 0x00000000-0xffffffff (R,W,X)` 這個資訊提供了 Domain0 涵蓋的記憶體範圍,`Region02` 代表著整個 root 所覆蓋的範圍。 那麼,核心要加載在哪個記憶體範圍? `Domain0 Next Address : 0x80200000` 此段資訊表明了下一個區段 (S-mode) 由此記憶體位址起始,也就是說,核心由此開始加載。參考對應的 linker scripts 如下 ```c ENTRY(boot) // 定義 symbol 'boot' 為進入點 SECTIONS { . = 0x80200000; // kernel 起始位址 .text :{ // 程式碼區段 KEEP(*(.text.boot)); // 宣告 keep 避免此開機段被 linker 優化消失 *(.text .text.*); } .rodata : ALIGN(4) { // 唯讀區段資料,4 byte 對齊 *(.rodata .rodata.*); } .data : ALIGN(4) { // R/W 資料 ex, int i = 12; *(.data .data.*); } .bss : ALIGN(4) { // R/W 資料,初始值為 0 ex, int i; __bss = .; *(.bss .bss.* .sbss .sbss.*); __bss_end = .; } . = ALIGN(4); // .當前記憶體以 4 byte 對齊 . += 128 * 1024; /* 當前記憶體後移 128KB 作為 stack 記憶體 */ __stack_top = .; // 定義 symbol '__stack_top' 為 stack pointer } ``` ##### Minumum kernel 上述已分配好 kernel 將所使用的記憶體區段,接下來將建立最小的 kernel.c 檔案。第一個問題是,kernel 需要甚麼? * 定義型別: 此為 32 bit RISC-V 架構,因此需先定義此架構下所使用的資料型別 ```c typedef unsigned char uint8_t; typedef unsigned int uint32_t; typedef uint32_t size_t; ``` * 宣告將使用由 linker scripts 所產生的 symbols ```c extern char __bss[], __bss_end[], __stack_top[]; ``` * 新增記憶體設置函數: `ld` 檔案標定資料位址,但卻沒有將其設置為 0 ,因此要有此函式將對應記憶體位址設置為 0 避免運作錯誤。 ```c void memset(void *buf, char c, size_t n){ uint8_t *p = (uint8_t *) buf; // 將 buf 存為 byte 以便存取 while(n--) // n 個 byte *p = c; // 每個 byte 值設為 c return buf; } ``` * 將預設 stack 清空函式 ```c stack_init(void){ memset(__bss, 0, (size_t)__bss_end - (size_t)__bss_top); for(;;); // 避免結束後跳回錯誤位址,暫時卡在這 } ``` * boot 函式 ```c __attribute__((section(".text.boot"))) // 強制放入 text.boot 區,避免編譯器放錯 __attribute__((naked)) // 確保由自己控制 stack 與暫存器 void boot(void){ __asm__ __volatile__( "mv sp, %[stack_top]\n" // Set the stack pointer "j stack_init\n" // Jump to the kernel main function : : [stack_top] "r" (__stack_top) // Pass the stack top address as %[stack_top] ); } ``` * 啟動 shell 檔 ```shell #!/bin/bash set -xue QEMU=qemu-system-riscv32 # Path to clang and compiler flags CC=/opt/homebrew/opt/llvm/bin/clang # Ubuntu users: use CC=clang CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fno-stack-protector -ffreestanding -nostdlib" # Build the kernel $CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \ kernel.c # Start QEMU $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \ -kernel kernel.elf ``` 至此可啟動簡易的 kernel,同時載入相關的 registers。 ### 硬體初始化設定 硬體初始化設定主要有三大面向: * 記憶體區塊綁定: 將 linked script 設定之 symbol 提供給暫存器,比如說 sp,gp。 * 硬體初始化設定: 一開始開機時 `mstatus`,`mie` 為 0,需注意的是若是在 S-mode 啟動需要做額外的 `mstatus` 設定確保進入 M-modoe,換句話說 `mstatus` 控制權限需要根據需求調整,而 `mie` 需要根據要開啟的中斷做設定,比如說先開啟 `mie.MEIE`,也就是允許外部中斷 (i.e.,UART),當然中斷總控 bits 依舊在 `mstatus` 中被關閉。 * 例外處理: 確保最後轉交給 `main` 函式正確。 ## Kernel Functions ### SBI spec (已捨棄) 前一段落的 minimum kernel 並沒有使用到 SBI 介面的 registers,那麼具體該如何建立 kernel 與 SBI 的連接呢?參考 [SBI spec.](https://github.com/riscv-non-isa/riscv-sbi-doc/releases/tag/v3.0) 所提供的暫存器操作規範進行設計。 #### 確保 kernel 可以管理 SBI 執行結果 參考 SBI spec. page 11. 其說明 SBI calling convention 定義 `a0` 為 error code,同時 `a1` 亦為保留暫存器,因此需要兩個記憶體來儲存此兩個暫存器值並於 kernel 判讀使用。 ```c struct sbiret { long error; // a0 register long value; // a1 register } ``` * 嘗試與 SBI 溝通 參考如下程式碼如何透過 SBI 與硬體層溝通 ```c struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4, long arg5, long fid, long eid) { register long a0 __asm__("a0") = arg0; register long a1 __asm__("a1") = arg1; register long a2 __asm__("a2") = arg2; register long a3 __asm__("a3") = arg3; register long a4 __asm__("a4") = arg4; register long a5 __asm__("a5") = arg5; register long a6 __asm__("a6") = fid; register long a7 __asm__("a7") = eid; __asm__ __volatile__("ecall" : "=r"(a0), "=r"(a1) : "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5), "r"(a6), "r"(a7) : "memory"); return (struct sbiret){.error = a0, .value = a1}; } ``` 根據 SBI 函式定義,其對應參數如上函式。定義 `long a0` 作為 C 語言的變數,透過 `__asm__` 綁定實體記憶體 `a0`,並將傳入的數值 `arg0` 儲存至此暫存器。其他暫存器亦同。 `__asm__ __volatile__` 為 C 語言支援 inline assembly 語法,首先進行了 `ecall` 指令,此指令會觸發 trap(陷入),會將 CPU 跳入高權限模式,接下來會根據暫存器中的值(先前已傳入 `arg0` 等...),呼叫對應的硬體操作,比如說讀取時間等。之後在 `trap handler` 處理完之後再跳回 `user mode` `"=r" (a0), "=r(a1)"` 為寫入操作,在 trap handler 結束之後,registers a0 a1 會儲存相對應的回傳值,透過 `=r(a0)` 的指令將實體 register a0 寫入 C 變數 a0 中, `r(a0)` 為讀取 a0 變數的數值,在 ecall 發生時將其寫入任意 register,但先前有綁定至實體 a0 register,故編譯器會依此綁定。另一個問題是,ecall 後有寫入寫出動作,順序不同會有影響嗎? #### 透過 SBI sepc 撰寫函式 以 chapter 4.1. 函式 `sbi_get_spec_version(void)` 為例, 參考 commit [7e15dc2](https://github.com/vicLin8712/hahaOS/commit/7e15dc2af8df51327724b501d0e18d75ecc3ec58) ,實作此函式呼叫先前的 `sbi_call` 方式達成,並在 `EID` 暫存器存入 `0x10`。其回傳數值 `value` 屬於 `long` (32 bits),須根據對應的 major 與 minor bit 位置定義分別取出,並利用 `putchar` 函式達到輸出在 console 的效果。 ### 行程與 context switch 當一個函式要執行,需先建立行程,當然必須對行程的結構進行定義。那麼問題是行程需要哪些元素才足以使用呢? * 行程 ID: 紀錄此行程編號 * 行程狀態: 紀錄此行程是否處於運作,是否需要被排程器挑選執行 * 行程堆疊地址: 紀錄此行程所處的記憶體位址,以便 CPU 讀取此行程資料 * 記憶體空間: 行程內所含資料空間 基於上述最基本的要求定義結構體如下 ```c struct process{ int pid; int state; vaddr_t sp; uint8_t stack[8192]; }; ``` 之後利用簡易的方式儲存 process 陣列 ```c struct process procs[PROCS_MAX]; ``` 有趣的是此陣列的記憶體儲存方式究竟如何,嘗試實驗列出觀察: ``` First process at 0x80200478 Second process at 0x80202484 ``` `0x80202484-0x80200478 = 0x0000200C`,`0x00002000` 代表 `stack [8192]` 大小,`0x0000000C` 代表 `4 byte(pid, state, sp) * 3`。 定義完 `process` 與儲存之陣列之後,接下來要如何建立新的 `process`? 新的 `process` 需要有甚麼樣的條件? * 在陣列之中的 `process` 需要可用: `state` 判斷為可用 * 將 registers 清空 * 將 function 地址引入 ra 地址,當 context switch 時,會將此 ra 地址存入 register,並透過 ret 跳至此函式運作! * PID 重新設定 * `state` 重新設定 * 將 stack pointer 位置存回此 proc 結構內,context switch 可呼叫 基於以上可以實作如下 ```c void *create_process (uint32_t pc) { struct process *proc = NULL; for (int i =0; i< PROCS_MAX; i++){ if(proc[i].state == PROC_UNUSED) { proc = &proc[i]; break; } } uint32_t *sp = (uint32_t *) &proc->stack[sizeof(proc->stack)]; *--sp = 0; // s11 *--sp = 0; // s10 *--sp = 0; // s9 *--sp = 0; // s8 *--sp = 0; // s7 *--sp = 0; // s6 *--sp = 0; // s5 *--sp = 0; // s4 *--sp = 0; // s3 *--sp = 0; // s2 *--sp = 0; // s1 *--sp = 0; // s0 *--sp = (uint32_t) pc; // ra // Initialize fields. proc->pid = i + 1; proc->state = PROC_RUNNABLE; proc->sp = (uint32_t) sp; return proc; } ``` 接下來可以建立挑選任務的排程器 `scheduler`,從 `procs` 陣列中挑選出 `state = RUNNABLE` 的行程並回傳。 ```c struct process *scheduler(){ for (int i = 0; i<PROCS_MAX; i++) { if (procs[i].state == PROC_RUNNABLE) { procs[i].state = PROC_UNUSED; return &procs[i]; } } return NULL; } ``` 並使用排程 `schedule` 執行挑選任務與 context switch: ```c void schedule(){ struct process *current = NULL; struct process *new_task; while (1){ new_task = scheduler(); if (new_task != NULL && new_task != current){ switch_context(&current->sp, &new_task->sp); } } } ``` 對應執行程式碼測試 ```c void task_A(){ printf("task_A executed"); } void task_B(){ printf("task_B executed"); } create_process((uint32_t) &task_A); create_process((uint32_t) &task_B); while (1) { schedule(); }; ``` :::warning context_switch 函式會將 `task_A` 地址寫入 `ra`,並且使用 `ret`,也就是說,當切入至 `task_A` 之後的 `ra` 依舊是 `task_A`,並無改動,這就造成 `task_A` 會一直重複執行。 ::: #### 如何切換任務?協作式排程器 參考 [linmo/task.c](https://github.com/sysprog21/linmo) 專案,可以設計一個結構體記錄 thread 所儲存的地址,同時記錄正在執行 thread 的地址。當我們需要更改任務 * 使用 `setjmp` 將當前任務保存 * 使用 `scheduler` 函式改動 `kcb` 結構體內要執行任務的位址 * 使用 `longjump` 切換至下個任務, ##### 定義 `jmp_buf` `jmp_buf` 用於儲存當前執行狀態資料,並儲存於型別 `jmp_buf`,如下: ```c /* Define buffer for task switching. * Memory layouts (14 x 32-bit words) * [0-11]: s0-s11 (callee-saved registers) * [12]: sp (stack pointer) * [13]: ra (return address) */ typedef uint32_t jmp_buf[14]; ``` ##### 定義函式 `setjmp(jmp_buf env)` 此函式用於儲存當前狀態下的 registers,並將其儲存至 `env` 變數中: ```c setjmp(jmp_buf env){ asm volatile ( "sw s0 0*4(%0)\n" "sw s1 1*4(%0)\n" ... "sw s11 11*4(%0)\n" "sw sp 12*4(%0)\n" "sw ra 13*4(%0)\n" "li a0 0" /* Makesure return value will be 0 */ : : "r"=(env) /* Put all stored value into this jmp_buf */ : "memory","a0") ); return 0; } ``` ##### 定義函式 `longjmp (jmp_buf env, int32_t val)` 還原 buffer `env` 至 registers 並確保 `setjmp` 傳入之 `val` 值為0。 ##### 新增結構體管理行程 ```c extern struct task procs[PROCS_MAX]; kcb_t kernel_state = { .tasks = &procs, .cur_task = NULL }; ``` ##### 新增行程結構體 ```c struct task{ uint32_t entry; int32_t pid; int32_t state; jmp_buf context; uint8_t stack[8192]; }; ``` ##### 新增行程建立函式 此函式將其他函式置入 `procs` 佇列中並設定行程狀態可執行 * 檢查 `procs` 哪個地址為空,可放置新行程,若無則 panic(後續可調整佇列為動態) * 將可放置新行程的地址進行初始化 * 設定 pid 由 `kcb` 決定 * 設定 `entry = &pc` * 設定相對應的 `jmp_buf` * 設定 `state = READY` ##### 修改排程器機制 `sched_select_next_task` 函式預期做到 * 找尋 `state = TASK_READY` 的 task * 更新 `kcb` 的 `cur_task`,指向新可執行程式。 ##### 修改排程函式 `sched` 函式預期做到 * 跳至 `cur_task` 執行 ##### 讓出 CPU `yield` 函式預期做到 * setjmp 儲存 registers * 更改 `cur_task` 狀態 * 呼叫 `sched_select_next_task` 改動下一個可執行程式 * 呼叫 `sched` 交還執行狀態。 ### Panic 實作 需要有機制當 kernel 函式運作出現非預期的情形,回報錯誤的機制並進行相對應的處理。此實作參考 [linmo](https://github.com/sysprog21/linmo) 專案。 實作分為三部分: * 硬體層 panic 控制 * 定義 panic 事件編碼 * 定義完整 panic API 提供 kernel 函式使用 #### 硬體層 panic 控制 (暫時只用 `wfi`) 參考 SBI spce.,欲使用 ` struct sbiret sbi_hart_stop(void)` 函式停止正在運行的 hart。29 頁` "sbi_hart_stop() must be called with supervisor-mode interrupts disabled."` 因此需先將 `S-Mode` 可中斷功能確保關閉。 要操作 `S-Mode` ,需要對 `CSR` 暫存器進行讀寫以改變 `Machine-Level` 的功能。相關的 `CSR` 名稱可參考 "risc-v previliged 2.2 CSR Listing"。先定義讀取 `CSR` 的巨集,讀取 `reg` 並回傳 `uint32_t`: ```C #define read_csr(reg) \ ({ \ uint32_t __tmp; \ __asm__ __volatile__("csrr %0, " #reg : "=r"(__tmp)); \ __tmp; \ }) ``` :::warning 上述使用 `read_csr` 方式存取 CSR 暫存器沒辦法在使用 OPENSBI 的情況下進入 `M-Mode`,也因此需要使用 OPENSBI 提供的 vendor extension 進行開發 ::: 使用 ` 4.5. Function: Get machine vendor ID (FID #4)` 獲取vendor 數值。得到結果為 0。 :::warning 暫時只用 wfi 處理 ::: `hal_panic` 實作如下 ```c void hal_panic(void) { while (1) __asm__ __volatile__("wfi"); } ``` 建立表格,並使用 `enum` 賦值。 ### timer 透過讀取硬體震盪器所產生的 ticks 作為計時基準。查看 RISC-V Priviledge 手冊所定義 ticks 相關暫存器進行計時器相關的操作。首先必須得了解 timer 存在的必要性。 當一個任務在執行的時候,勢必要有外部訊號通知 CPU 才能切換到排程器 scheduler , 再根據不同的算法挑選下一個任務,總不可能要 CPU 一邊運行此任務,同時計算此任務的剩餘時間做條件判斷,那會造成非常多沒必要的資源消耗 (軟體層面)。也因此需要硬體計時功能觸發中斷訊號。 第一個問題是外部訊號要如何被觸發,再來是觸發後 CPU 應當會跳到某個處理中斷的函式,也因此第二個問題是 CPU 如何知道要跳到哪個函式。 #### 計時觸發機制 獲取硬體時間,需查閱硬體設計規範。查閱 SiFive FE310-G000 Manual v3p2,Core Local Interrupt (CLINT) 章節,共有三種暫存器(Base `0x20000000`): ![image](https://hackmd.io/_uploads/rk6ko_L9ge.png) * msip: m (machine) s (software) ip,顧名思義即為軟體設置之中斷,常用於 IPI 通訊機制,對應至 mie/mip 暫存器 bit3 * mtimecmp: 比較時間用暫存器,與 mtime 比較,若小於則會觸發 mtip (machine time interrupt),mtip 暫存器對應至 mie/mip 暫存器 bit7 * mtime: 系統時間 當 `mtimecmp` > `mtime`,將觸發計時器中斷,跳到中斷處理。 ## 中斷處理 當 `mtimecmp` 滿足觸發條件後,下一步 CPU 會做甚麼事? CPU 原先在做某個任務,觸發後應當要跳到特殊的函式上處理硬體觸發事件,也就是 trap handler。查詢特權手冊,`mtvec` 即為儲存 trap handler 函式地址的暫存器。也就是說,在硬體初始化階段,就需要將 trap handler 函式地址寫入此暫存器,當中斷觸發後將會跳到此暫存器內所存的地址。 那麼 trap handler 又要如何管理硬體中斷觸發? 邏輯上來說,`stvec` 應當在初始化階段就寫入,因此,在 `_entry` 加入: ```c "la t0, _isr" /* Load _isr address*/ "csrw mtvec, t0" /* Store _isr address to stvec */ ``` 那麼每當觸發中斷,CPU 就會跳轉至 `_isr` (interrupt server routine) 函式。下一個問題是 `_isr` 需要做甚麼 當 trap 發生,跳入 `_isr` 後 , 硬體將會"自動"做以下的行為: * `mstatus.MIE` -> `mstatus.MPIE`: 會將進入 trap 之前的`mstatus.MIE` 位元存入 `mstatus.MPIE` 位元 (P for previous)。 * `mepc`: Machine Exception Program Counter,發生例外時的 PC。 * `mcause`: Cause,發生原因,可查詢 3.1. Machine-Level CSRs Table 14. :::warning `mie` 暫存器本身不會被改動 ::: 一個任務執行到一半被中斷後,接下來有兩種可能的結果,也就是排程器不改任務與更換執行任務。也因此 trap 之後須將"所有"暫存器存入當前任務 stack ,為的是若任務並沒有被排程器更換,中斷結束後可以繼續執行原先被打斷的任務。下一步是跳轉至高階 C 語言程式碼處理 trap (命名 `do_trap`),具體來說,`do_trap` 應當 (2.3.4. 交由排程器處理): 1. 判斷 exception: 須注意,`_isr` 呼叫 `do_trap` 前須將 `mcause` 傳入 `a0` (RISC-V calling convention) 用以判斷 exception。 2. 保存進入排程器前的 context 至 `tcb->jmp_buf`(函式 save): 當進入函式 `save` 之後,將進入前的狀態點存入當前任務的 `jmp_buf`。重點在於此時的 `ra` ,這數值對應到 `do_trap` 呼叫 `save` 的下一行指令地址以。保存完成後將 `a0` 設置為 0 ,用以甄別是第一次中斷保存,最後 `ret` 回 `save` 進入點。此時會判斷回傳值是否是 1 從而跳回 `_isr` 或是呼叫排程器。 :::warning `jmp_buf` `mstatus` 暫存器須注意將 MPIE 恢復至 MIE 位元,也就是說要恢復成 "進入 trap 前的" `mstatus` 狀態,如果直接儲存,會保存到關閉中斷的 `mstatus`。 ::: 3. 呼叫排程器: 挑選任務並放入 `kcb->cur_task` 4. 恢復進入排程器前的 context 並跳轉 (函式 restore): 此時恢復被挑選到的任務 `jmp_buf` 至暫存器中。這邊考慮兩個 case,也就是任務不變以及跳至新任務。 * 任務不變/跳回此任務: 在先前 2. 時已將進入 `save` 的狀態保存,因此 `restore` 會恢復此狀態,同時將 `a0` 設置為 1 並 `ret` ,此時跳回至判斷式所得到的數值為非 0,便會在 branch 上觸發 return 而非再次進入排程器。即便切換到不同的任務後再返回此任務,依舊會載入 `save` 的進入點並 ret 回 `_isr`,雖然任務切換過可能導致 `mepc` 改變,但已經在對應的 trap frame 儲存所有 registers,因此結束 trap 處理過程依舊可以順利恢復原先任務的狀態。 * 任務改變: load 入新任務 `jmp_buf` 並進行跳轉。 ```c void scheduler(void) { if (hal_context_save((tcb_t *)(kcb->cur_tcb)->context) != 0) return; sched_select_next_task(); hal_context_restore(((tcb_t *)(kcb->cur_tcb)->context), 1); } ``` ## library 實作所需lib,記憶體管理、I/O ### print 實作 `print` 功能,並支援 `%d` 整數,`%x` 十六進位,`%s` 字串三種類型輸出,定義 prototype 如下: ```c printf(const char *fmt, ...) ``` 後方 `...` 參數為可變參數,在此須透過編譯器語法將後方參數取出。先在 `type.h` 新增所需用到的 marco 以便在 `printf` 函式使用,gcc 提供相關內建的語法,可參考與之對應 C11 7.16,雖然此時還無法引用 C 函式庫: ```C #define va_list __builtin_va_list #define va_start __builtin_va_start #define va_end __builtin_va_end #define va_arg __builtin_va_arg ``` 首先要將後方 `...` 可變參數以 `va_list` 型別儲存: ```c va_list vargs; va_start(vargs, fmt); ``` `va_start` 函式會將`print`函式 `fmt` 參數後所有參數放入 `vargs` 以供後續讀取。 接下來以迴圈方式讀取 `fmt` 的內容,直到終止符號 `\0`。同時根據每個 `fmt` 的內容當遇到 `%` 開始判斷不同的例子,`%d` 變數為整數,`%s`變數為字元。 >commit [d72977d](https://github.com/vicLin8712/hahaOS/commit/d46b7fa268426a40973fc21ff74568ede25b4970) ### 初始記憶體分配 >參考 [記憶體管理](https://hackmd.io/wvy8FnZXSHqpxtkK_pbyFQ) 如何告訴 CPU 該從哪個地址開始分配記憶體? 透過 linker scripts 描述記憶體的位置,是否有相關限制? #### 支援 hexadecimal 輸出,驗證記憶體分配 先在 linker scripts 添加記憶體區塊: ```c . = ALIGN(4096); __free_ram = .; . += 64 * 1024 * 1024; /* 64 MB memory */ __free_ram_end = .; ``` 以 4096 byte 方式對齊,宣告後需添加 `extern` 以存取此 symbol: ```c extern char __free_ram[]; extern char __free_ram_end[]; ``` 接下實作分配 page 函式 [dea4cd3](https://github.com/vicLin8712/hahaOS/commit/dea4cd372a7db33806c83fe29533aee8e5a6b174) ## 排程器