https://github.com/cfenollosa/os-tutorial/tree/master https://wiki.osdev.org/Expanded_Main_Page
[x]tab補全 [x]命令歷史記錄(上下鍵瀏覽) [x]CLEAR:清空螢幕 [x]TIME:顯示系統時間 [x]ECHO:回顯文字 [x]CALC:簡單計算器 [x]分頁機制(Paging) [x]實作 kfree()(釋放記憶體) []設計檔案系統結構(FAT12/簡化版) [x]Calling Global Constructors [x]printf相關函式 [x]多工處理(Multitasking) - PCB、Context Switch、Round-Robin Scheduler []SSP進階優化:多執行緒與 TLS []System Calls (int 0x80) []User Mode (Ring 3)
time:已修正為顯示台灣時區
當電腦開啟時,bios不知道如何載入os,這是boot sector的任務,boot sector一定要放在標準位置,位置在第一個磁碟的磁區(cylinder 0, head 0, sector 0),整個boot sector有512bytes大
確保disk可以bootable,bios檢查boot sector的第511 bytes和512 bytes是0xAA55
下面是最簡單的boot sector
e9 fd ff 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
[ 29 more lines with sixteen zero-bytes each ]
00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa基本上都是0,在後面的16-bits value是0xAA55(要注意是little-endian,x86) 第一列中的3個bytes是代表無限jump迴圈
看boot_sect.asm代碼
編譯指令
nasm -f bin boot_sect.asm -o boot_sect.bin
用qemu模擬
qemu-system-x86_64 boot_sect.bin 讓boot sector印出文字
改進我們的無限迴圈的boot sector可以印出東西顯示在螢幕上,將要發出interrupt信號做到這件事
把"Hello"的每一字元給放到register(al,lower part of ax),還有0x0e放入到ah(the higher part of x),最後使用0x10去發起中斷顯示字元在螢幕上
ah存入0x0e是傳送中斷訊號到螢幕,在tty mode讓al中的內容顯示到螢幕上
查看boot_sect_hello.asm
編譯指令
nasm -f bin boot_sect_hello.asm -o boot_sect_hello.bin執行
qemu-system-x86_64 boot_sect_hello.bin learn where the boot sector is stored
直接說bios一定都放在0x7c00位置上
說法來源參考
https://gist.github.com/letoh/2790559
https://zhuanlan.zhihu.com/p/655209631
將x顯在螢幕上,嘗試4種不同的方法實現
查看boot_sect_memory.asm
先將定義X作為資料,用label指定記憶體位置,不用知道實際的記憶體位置,也能存取到x
the_secret:
db "X"嘗試用不同方式存取the_secret
- mov al, the_secret
- mov al, [the_secret]
- mov al, the_secret + 0x7C00
- mov al, 2d + 0x7C00,2d代表實際"X"的位置
編譯指令
nasm -f bin boot_sect_memory.asm -o boot_sect_memory.bin執行
qemu-system-x86_64 boot_sect_memory.bin應該會看到字串像1[2¢3X4X
為甚麼X的地址位移量是0x2d呢?用xxd工具來檢查
使用 xxd 工具檢查編譯後的二進制文件:
xxd -l 0x30 boot_sect_memory.bin輸出結果:
00000000: b40e b031 cd10 b02d cd10 b032 cd10 a02d ...1...-...2...-
00000010: 00cd 10b0 33cd 10bb 2d00 81c3 007c 8a07 ....3...-....|..
00000020: cd10 b034 cd10 a02d 7ccd 10eb fe58 0000 ...4...-|....X..
可以看到 "X" 字元(0x58)出現在偏移量 0x2d 的位置。
使用 nasm -l 生成列表文件可以更清楚地看到:
nasm -f bin boot_sect_memory.asm -o boot_sect_memory.bin -l boot_sect_memory.lst從列表文件中可以看到:
jmp $指令在 0x0000002B 位置(2 bytes: EBFE)the_secret:標籤緊接在後面,位於 0x0000002D 位置X字元(0x58)就存儲在這個位置
所以位移量 0x2d 是從 boot sector 開始(0x0000)到 the_secret 標籤的距離,也就是所有前面的指令代碼(包括 4 種打印方法的代碼和 jmp $ 指令)的總長度。
現在,assemblers可以定義global offset作為每個記憶體的base address 加上org指令
[org 0x7c00]
重跑一次之前程式,之前的第二方法就可以正確執行,也會影響之前的指令
運用bp register 保存stack的base address,還有sp保存stack的top address
還有stack是由上而下的生長的,所以通常sp都是用減的來代表新top
這邊直接看boot_sect_stack.asm程式碼看stack運作
編譯指令
nasm -f bin boot_sect_stack.asm -o boot_sect_stack.bin執行
qemu-system-x86_64 boot_sect_stack.bin 這節要學習寫control structures,functional calling,full strings usage
作為在準備進入disk和kernel的前置概念
定義string就像一長串的characters集合,後面在加一個null byte來當字串結尾
mystring:
db 'Hello,World',0到目前的程式有使用'jmp $'當作無限迴圈
assembler跳到之前的指令結果 範例:
cmp ax,4 ; if ax=4
je ax_is_four ; do something when ax result equal to 4
jmp else ;else do another thing
jmp endif ; finally ,resume the normal flow
ax_is_four:
.....
jmp endif
else:
.....
jmp endif ;為了完整性這邊也跳到endif,其實`else:`執行完,就會往下執行
endif:calling a function is just a jump to a label 呼叫函式就像跳到label一樣
有兩個步驟傳入參數
- the programmer knows theys share a specific register or memory address
- write a bit more code and make function calls generic and without side effects
第一步,使用al(actually ax)作為傳入參數
mov al,'X'
jmp print
endprint:
...
print:
mov ah,0x0e ; tty mode
int 0x10 ;assume the 'al' already has the character
jmp endprint可以看到這方法很容意變成雜亂的程式碼,現在的print函式只有返回endprint,如果是其他函式想要呼叫,將會浪費程式碼資源
正確的解法是提供兩個改進點
- 儲存返回位址
- 儲存現在的register,這樣在函式中修改,不會修改到原本的register的數值
為了可以儲存return address,cpu將會幫忙取代jmp去呼叫函式,使用call和ret
為了儲存register data,也有stack特殊指令,pusha和popa,可以把全部register的數值自動保存和恢復
依照功能分開程式碼,並且印入到main檔,可以增加可讀性
%include "file.asm"編譯指令
nasm -f bin boot_sect_main.asm -o boot_sect_main.bin不用把boot_sect_print.asm給一起編譯進去
執行
qemu-system-x86_64 boot_sect_main.bin
下個目標是要讀取磁碟,所以需要一些方法確保讀取到正確資料,查看boot_sect_print_hex.asm
學習如何使用 16 位元真實模式分段來定址記憶體
之前使用 [org] 做過分段
分段意味著您可以為所有引用的資料指定一個偏移量
使用 cs、ds、ss、es 分別對應程式碼、資料、堆疊、額外段
警告:這些暫存器會被 CPU 隱式使用,因此所有記憶體存取都會被 ds 偏移
計算真實位址的公式為 segment * 16 + address(即 segment << 4 + address)。例如,如果 ds 是 0x4d,那麼 [0x20] 實際上指的是 0x4d * 16 + 0x20 = 0x4d0 + 0x20 = 0x4f0
注意:不能直接使用 mov 將立即數值載入這些暫存器,必須先使用通用暫存器作為中介
編譯指令
nasm -f bin boot_sect_segmentation.asm -o boot_sect_segmentation.bin執行指令
qemu-system-x86_64 boot_sect_segmentation.bin讓啟動扇區從磁碟載入資料以便啟動核心
作業系統無法完全放入啟動扇區的 512 字節中,需要從磁碟讀取資料來執行核心
不需要處理磁碟旋轉盤片的開關等硬體細節
可以直接呼叫 BIOS 常式。要這樣做,將 ah 設為 0x02(讀取扇區功能),並設定其他暫存器(包含所需的磁柱、磁頭和扇區參數),然後觸發 int 0x13
更多 INT 13h 詳細資訊:https://stanislavs.org/helppc/int_13-2.html
這次會使用進位標誌(carry flag),它是 EFLAGS 暫存器中的一個標誌位,當運算發生進位或借位時會被設定
mov ax, 0xFFFF
add ax, 1 ; ax = 0x0000 and carry = 1進位標誌不能直接存取,但會被其他指令作為控制位元使用,例如 jc(如果進位標誌被設定則跳轉)
BIOS 會在 al 中設定實際讀取的扇區數,務必將其與預期的扇區數進行比較
編譯指令
nasm -f bin boot_sect_disk_main.asm -o boot_sect_disk_main.bin執行
qemu-system-x86_64 boot_sect_disk_main.bin //自動是視為軟盤
qemu-system-x86_64 -fda boot_sect_disk_main.bin //直接用floopy disk開啟在 32 位元保護模式下在螢幕上列印
32 位元模式可以使用 32 位元暫存器和記憶體定址、保護記憶體、虛擬記憶體,但會失去 BIOS 中斷並需要編寫 GDT
編寫在 32 位元模式下運作的列印字串迴圈,不需要 BIOS 中斷
直接操作 VGA 視訊記憶體,而不是呼叫 int 0x10
VGA 記憶體位於位址 0xb8000,具有文字模式以避免直接操作像素
存取 80x25 網格上特定字元的公式:
0xb8000 + 2 * (row * 80 + col)
每個字元使用 2 字節(一個用於 ASCII,另一個用於顏色)
分段操作方式被左移以定址額外一層的間接定址
在 32 位元模式下,分段運作方式不同,偏移量是 GDT 中段描述符(Segment Descriptor, SD)的索引。這個描述符定義了基底位址(32 位元)、大小(20 位元)以及一些標誌,例如唯讀、權限等
編寫 GDT 的簡單方法是定義兩個段,一個用於程式碼,另一個用於資料,這些段可以重疊,這意味著沒有記憶體保護,但足以啟動系統
第一個 GDT 條目必須是空描述符(null descriptor,全為 0),以確保程式設計師不會在管理位址時犯錯
CPU 無法直接載入 GDT 位址,它需要一個稱為「GDT 描述符」的中繼結構,包含實際 GDT 的大小(16 位元)和位址(32 位元)。它透過 lgdt 指令載入
注意:參考 os-dev.pdf 檢查段標誌
進入 32 位元保護模式並測試之前的程式碼
進入 32 位元模式的步驟:
- 禁用中斷
- 載入 GDT
- 設定 CPU 控制暫存器
cr0中的一個位元 - 透過執行一個精心設計的遠跳轉來清除 CPU 管線
- 更新所有段暫存器(ds,ss,es,fs,gs)
- 更新堆疊
- 呼叫一個已知的標籤,該標籤包含第一個有用的 32 位元程式碼
在檔案 32bit-switch.asm 中建立此流程
進入 32 位元模式後,會呼叫 BEGIN_PM,這是實際有用程式碼的進入點,可以查看 32bit-main.asm
compile instruction
nasm -f bin 32bit-main.asm -o 32bit-main.binusage
qemu-system-x86_64 32bit-main.bin建立開發環境以建置您的核心
一旦跳轉到使用高階語言開發時,需要一個交叉編譯器,這裡使用 C
首先安裝所需的套件:
- gmp
- mpfr
- libmpc
- gcc
需要 gcc 來建置交叉編譯器
export CC=/usr/local/bin/gcc
export LD=/usr/local/bin/gcc需要建置 binutils 和交叉編譯的 gcc,會將它們放入 /usr/local/i386elfgcc
export PREFIX="/usr/local/i386elfgcc"
export TARGET=i386-elf
export PATH="$PREFIX/bin:$PATH"執行下面命令,但要有root權限才能執行
備註:建議下載更新的binutils.tar.gz,會比較好,這os還是用2.24
mkdir /tmp/src
cd /tmp/src
curl -O http://ftp.gnu.org/gnu/binutils/binutils-2.24.tar.gz # If the link 404's, look for a more recent version
tar xf binutils-2.24.tar.gz
mkdir binutils-build
cd binutils-build
../binutils-2.24/configure --target=$TARGET --enable-interwork --enable-multilib --disable-nls --disable-werror --prefix=$PREFIX 2>&1 | tee configure.log一樣,要有root權限才能執行
cd /tmp/src
curl -O https://ftp.gnu.org/gnu/gcc/gcc-4.9.1/gcc-4.9.1.tar.bz2
tar xf gcc-4.9.1.tar.bz2
mkdir gcc-build
cd gcc-build
../gcc-4.9.1/configure --target=$TARGET --prefix="$PREFIX" --disable-nls --disable-libssp --enable-languages=c --without-headers
make all-gcc
make all-target-libgcc
make install-gcc
make install-target-libgcc 安裝完後應該要有全部的GNU binutils和編譯器在/usr/local/i386elfgcc/bin
學習如何用 C 語言撰寫低階程式碼,就像我們用組合語言做的一樣
查看 C 編譯器如何編譯我們的程式碼,並與組合語言產生的機器碼進行比較
開始撰寫一個簡單的程式,包含一個函數 function.c
要編譯系統獨立的程式碼,需要 -ffreestanding 旗標
-ffreestanding:非常重要,讓編譯器知道它正在建置核心而不是使用者空間程式。需要實作自己的 memset、memcpy、memcmp 和 memmove 函數
compile instruction
i386-elf-gcc -ffreestanding -c function.c -o function.o用obdjump檢查機器碼
i386-elf-objdump -d function.o要產生二進制檔案,使用連結器,重要的部分是學習高階語言如何呼叫函數標籤
函數在記憶體中會被放置在什麼偏移量?
我們實際上不知道。例如,我們會將偏移量設為 0x0,並使用 binary 格式,該格式會產生不包含任何標籤和元資料的機器碼
i386-elf-ld -o function.bin -Ttext 0x0 --oformat binary function.o連結時可能會出現警告,可以忽略它
使用 xxd 檢查 function.o 和 function.bin 這兩個檔案
可以看到 .bin 檔案是純機器碼
.o 檔案包含許多除錯資訊和標籤
檢查機器碼
ndisasm -b 32 function.bin小型程式,包含以下特性:
- 區域變數
localvars.c - 函數呼叫
functioncalls.c - 指標
pointers.c
然後編譯並反組譯它們,檢查產生的機器碼
i386-elf-gcc -ffreestanding -c localvars.c -o localvars.o
i386-elf-gcc -ffreestanding -c functioncalls.c -o functioncalls.o
i386-elf-gcc -ffreestanding -c pointers.c -o pointers.o用i386-elf-objdump 檢查
i386-elf-objdump -d localvars.o
i386-elf-objdump -d functioncalls.o
i386-elf-objdump -d pointers.oi386-elf-ld -o localvars.bin -Ttext 0x0 --oformat binary localvars.o
i386-elf-ld -o functioncalls.bin -Ttext 0x0 --oformat binary functioncalls.o
i386-elf-ld -o pointers.bin -Ttext 0x0 --oformat binary pointers.o用xxd檢查
xxd localvars.o
xxd localvars.bin
xxd functioncalls.o
xxd functioncalls.bin
xxd pointers.o
xxd pointers.binndisasm -b 32 localvars.bin
ndisasm -b 32 functioncalls.bin
ndisasm -b 32 pointers.binwhy does the disassemblement of pointers.c not resemble what you would expect?
當你執行 ndisasm -b 32 pointers.bin 時,ndisasm 會傻傻地把整個檔案的所有位元組都當作 CPU 指令來翻譯。
編譯後的結構大致如下: 程式碼區段 (.text):包含 mov 指令,用來把字串的「記憶體位址」存到變數裡。 資料區段 (.rodata):緊接著程式碼之後,存放著實際的 "Hello" 字串(0x48, 0x65...)。 問題在於: ndisasm 讀到最後面的 "Hello" 時,它試圖把這些 ASCII 字元(0x48, 0x65...)解釋成組合語言指令,結果就變成了一堆奇怪、無意義的指令
Where is the ASCII 0x48656c6c6f for "Hello"?
00000000 55 push ebp ; 儲存 stack base
00000001 89E5 mov ebp,esp ; 設定新的 stack frame
00000003 83EC10 sub esp,byte +0x10 ; 預留 16 bytes 的局部變數空間
00000006 C745FC0F000000 mov dword [ebp-0x4],0xf ; 關鍵點!
0000000D C9 leave ; 清除 stack frame
0000000E C3 ret ; 返回關鍵點:注意第 54 行 mov dword [ebp-0x4],0xf。 這是把數值 0x0f 存入局部變數(也就是指標變數 string)。 0x0f 指向哪裡?指向檔案偏移量為 15 (十進位) 的位置。
緊接著 ret (0x0E) 之後,就是偏移量 0x0F。這裡正是編譯器放置字串常數 "Hello" 的地方。 但因為 ndisasm 只是盲目地翻譯,它把 "Hello" 的 ASCII 碼翻譯成了指令: 0x0F (offset): 48 -> ASCII 'H'。反組譯成 dec eax。 0x10 (offset): 65 -> ASCII 'e'。反組譯成 gs (前綴)。 0x11 (offset): 6C -> ASCII 'l'。反組譯成 insb。 0x12 (offset): 6C -> ASCII 'l'。反組譯成 insb。 0x13 (offset): 6F -> ASCII 'o'。反組譯成 outsd。 後面跟著的 00 (第 61 行 add [eax],al) 就是 C 語言字串結尾的 Null Terminator (\0)。
反組譯器很盡責地把「Hello」這幾個字翻譯成了「將暫存器減一」、「輸入字串」等毫無邏輯的 CPU 指令。這證明了馮·諾伊曼架構 (Von Neumann architecture) 的一個核心特性:記憶體中的資料和指令沒有區別,全看 CPU(或反組譯器)如何解讀它。
建立一個簡單的核心(kernel)以及一個能夠啟動該核心的開機磁區(boot sector)
一個 16-bit boot sector(組語) 載入並跳轉到一個 C kernel,而該 kernel 只做一件事——在螢幕左上角印出一個 X。 同時也會包含你特別提到的 dummy function,用來強迫 kernel 入口不是位於 0x0,而是明確的 main() 標籤。
compile instruction
/usr/local/i386elfgcc/bin/i386-elf-gcc -ffreestanding -c kernel.c -o kernel.o例程程式碼為 kernel_entry.asm。將學習如何在組合語言中使用 [extern] 宣告。此檔案不會編譯成二進位檔(binary),而是產生 ELF 格式,並與 kernel.o 進行連結。
compile instruction
nasm kernel_entry.asm -f elf -o kernel_entry.o連結器(linker)是一個非常有用的工具。
它可以將多個目的檔(object files)連結成單一的核心二進位檔(kernel binary),並解析各種標籤(label)的參照。
核心不會被放置在記憶體的 0x0位址,而是放在 0x1000。開機磁區(boot sector)也必須知道這個位址。
compile instruction
/usr/local/i386elfgcc/bin/i386-elf-ld -o kernel.bin -Ttext 0x1000 kernel_entry.o kernel.o --oformat binarybootsector.asm,並檢視其程式碼
compile instruction
nasm bootsect.asm -f bin -o bootsect.bin為開機磁區(bootsector)與核心(kernel)各自使用兩個獨立的檔案, 再透過連結(link)將它們合併成單一檔案。
concatenate instruction
cat bootsect.bin kernel.bin > os-image.bin如果發生磁碟載入錯誤,可能需要在 QEMU 中加入軟碟參數(floppy = 0x0,硬碟 = 0x80)。
qemu-system-i386 -fda os-image.bin //from floopy 不能使用 qemu-system-i386 -hda os-image.bin 從硬碟啟動作業系統,因為 os-image.bin 的容量太小。
會看到四則訊息:
- "Started in 16-bit Real Mode"
- "Loading kernel into memory"
- (左上角)"Landed in 32-bit Protected Mode"
- (左上角,覆蓋先前訊息)"X"
學習如何使用 GDB 偵錯核心(kernel)。
已經執行了自己的核心,但功能非常簡單,只會印出一個 'X'。
install gdb use to debug
which gdb
/usr/bin/gdblet gdb position setting variable Makefile:
GDB = /usr/bin/gdb使用 make debug。會建立 kernel.elf,這是一個物件檔(不是二進位檔),包含核心中所有產生的符號。
編譯時必須加上 -g 旗標。可以用 xxd 檢查,會看到一些字串,但檢查物件檔中字串的正確方法是 strings kernel.elf。
QEMU 可以與 GDB 配合使用,使用 make debug:
- 在
kernel.c:main()設定斷點:b main - 執行作業系統:
continue - 執行兩個步驟:
next再next,會看到指令已經設定 'X',但螢幕上尚未顯示字元 - 查看影片記憶體內容:
print *video_memory,會看到 "L",來自 "Landed in 32-bit Protected Mode" - 確認
video_memory指向正確位址:print video_memory next,讓 'X' 顯示在螢幕上- 再次確認:
print video_memory,並觀察 QEMU 螢幕
學習如何使用 VGA 顯示卡的資料埠(data ports)。
檢查對應螢幕游標位置的 I/O 埠。
- 檢查port
0x3D4,設定值為14以取得游標位置的高位元組 - 同一埠設定值為
15以取得低位元組
因為還無法直接在螢幕上印出變數,所以使用 GDB 來檢查,並在特定程式行設置斷點。
- 在
kernel.c:21設置斷點,使用print指令來檢查變數
在螢幕上寫入字串。
可以在螢幕上輸出文字。
查看 drivers/screen.h,會看到定義了一些 VGA 顯示卡驅動的常數,以及三個函式:
- 一個用來清除螢幕
- 另外兩個用來寫入字串
- 最後一個著名的
kprint,用於核心輸出(kernel print)
也可以查看 drivers/screen.c,這是 drivers/screen.h 的實作檔案。
其中有兩個 I/O 埠存取例程:set_cursor_offset() 與 set_cursor_offset()。
直接操作影像記憶體,使用 print_char() 函式。
kprint_at 可能會被呼叫時傳入 -1 作為 col 或 row 的值,這表示字串將會從目前游標位置開始印出。
會設定三個變數:col、row 和 offset。
函式會逐一遍歷字元指標 char*,並以當前座標呼叫 print_char()。
print_char 會回傳下一個游標位置的 offset,並在下一個迴圈中重複使用。
kprint 基本上是 kprint_at 的封裝(wrapper)。
像 kprint_at 一樣,print_char 也允許 col / row 為 -1,此時會從硬體取得游標位置,使用 ports.c。
print_char 也會處理換行(newline),將游標 offset 重設到下一行的第 0 欄。
VGA 的每個儲存格佔用 兩個位元組,一個用於字元,另一個用於顏色與背景。
新的kernel已經可以印出字串。
具有正確的字元定位,能跨越多行並處理換行(\n)。
如果嘗試寫入螢幕範圍之外的字元?這部分將在下一節解決。
當文字到達螢幕底部時捲動螢幕
請參閱 drivers/screen.c,並注意到 print_char 的底部有一個新區段(約在第 101 行),它會檢查目前的偏移量(offset)是否超過螢幕大小,並進行文字捲動。
捲動是由 memory_copy 處理的,它是標準 memcpy 的簡化版本,但為了避免名稱衝突而取了不同的名字。實作請參閱 libc/mem.c(原為 kernel/util.c)。
為了幫助視覺化捲動,我們還實作了一個將整數轉換為文字的函數 int_to_ascii。這是標準函數 itoa 的快速實作。
請注意,兩位數以上的整數在目前的實作中已經可以正常顯示(先前版本曾有反向列印的問題)。
可在 kernel/kernel.c 的第 14 行設置斷點。
設置中斷描述表 (Interrupt Descriptor Table, IDT) 以處理 CPU 中斷。
我們在 cpu/ 目錄下整合機器相關的程式碼,並使用明確定義大小的資料型別,這有助於將底層位元組結構與一般的字元或整數解耦。雖然早期曾使用自定義的 u8, u16, u32 (原定義於 cpu/types.h),但目前已全面改用 <stdint.h> 標準型別(如 uint8_t, uint32_t)以符規範。
相關的開機程式碼 (boot code) 則是 x86 特有的,目前仍保留在 boot/ 目錄中。
中斷是核心必須處理的核心要點之一。我們需要儘快建立此機制,以便能夠接收鍵盤輸入。
其他中斷範例包括:除以零 (division by zero)、越界 (out of bounds)、無效指令 (invalid opcodes)、分頁錯誤 (page faults) 等。
中斷是透過一個「向量表」(vector) 來處理的,其條目與 GDT 類似,但在中斷機制中稱為 IDT (中斷描述表)。我們將使用 C 語言來實作。
cpu/idt.h 定義了 IDT 條目的儲存方式 idt_gate_t(必須定義 256 個條目,如果為空,CPU 可能會發生崩潰/Panic)以及供 CPU 載入的實體 IDT 結構 idt_register_t。後者僅包含記憶體位址與大小,與 GDT 暫存器類似。
我們定義了一些變數,以便從組合語言 (assembler) 存取這些資料結構。
cpu/idt.c 將每個結構填入對應的處理程序 (handler)。你可以看到這涉及設定結構值並呼叫 lidt 組合語言指令。
每當 CPU 偵測到中斷(通常是致命錯誤)時,就會執行中斷服務常式 (Interrupt Service Routines, ISR)。
我們將編寫最精簡的處理代碼:印出一條錯誤訊息並停止 CPU。
在 cpu/isr.h 中定義了 32 個 ISR,它們被宣告為 extern,因為它們將在組合語言檔案 cpu/interrupt.asm 中實作。
在進入組合語言程式碼之前,請先查看 cpu/isr.c。你可以看到這裡定義了一個函式來一次安裝所有 ISR 並載入 IDT、一份錯誤訊息列表,以及顯示資訊的高階處理程序 (high level handler)。你可以根據需求自訂 isr_handler 以印出或執行任何操作。
接著是低階部分,它將每個 idt_gate 與其對應的低階和高階處理程序連結起來。打開 cpu/interrupt.asm,我們定義了一段通用的低階 ISR 程式碼,主要負責儲存/還原狀態並呼叫 C 語言代碼,以及在 cpu/isr.h 中引用的實際 ISR 組合語言函式。
registers_t結構是interrupt.asm中推入堆疊的所有暫存器的表現形式。
現在需要在我們的 Makefile 中引用 cpu/interrupt.asm,並讓核心安裝 ISR 並啟動其中一個進行測試。
請注意,目前有些中斷觸發後 CPU 並不會停止(halt)。
當 CPU 啟動時,可程式化中斷控制器 (PIC) 預設將 IRQ 0-7 映射到 INT 0x8-0xF,將 IRQ 8-15 映射到 INT 0x70-0x77。
由於我們已經編寫了 ISR 0-31 來處理 CPU 異常,因此標準做法是將 IRQ 重新映射到 ISR 32-47。
我們透過 I/O 埠與 PIC 通訊:主 (Master) PIC 的命令埠為 0x20,資料埠為 0x21;從 (Slave) PIC 的命令埠為 0xA0,資料埠為 0xA1。
重新映射 PIC 的代碼較為特殊且包含一些掩碼 (masks),詳情可以參考 OSDev Wiki,或者查看 cpu/isr.c:在設定完異常處理的 IDT 門 (gates) 之後,緊接著就是 IRQ 的 IDT 門設定。
跳轉到組合語言部分,在 interrupt.asm 中,第一個任務是為 C 代碼中使用的 IRQ 符號新增全域定義(請見 global 語句的末尾)。
接著在 interrupt.asm 的底部新增 IRQ 處理程序,注意到它們會跳轉到一個新的通用 stub:irq_common_stub。
然後建立這個 irq_common_stub,它與 ISR 的版本非常相似。它位於 interrupt.asm 的頂部,並宣告了一個新的 [extern irq_handler]。
回到 C 程式碼,在 isr.c 中編寫 irq_handler():它負責向 PIC 發送中斷結束訊號 (EOI),並呼叫儲存在 interrupt_handlers 陣列(定義於檔案頂部)中的處理程序。相關結構定義在 isr.h 中,我們還使用了一個簡單的函式來註冊中斷處理程序。
現在我們可以定義第一個 IRQ 處理程序了。
本次 kernel.c 不需要任何變更。
基本概念:
-
CPU 計時器(CPU Timer):是主機板或 CPU 內部的硬體組件,能以固定頻率產生訊號。
-
鍵盤中斷(Keyboard Interrupts):中斷是硬體用來告知 CPU 發生了緊急情況,需立即停止當前任務並優先處理另一項任務的機制。
-
掃描碼(Scancode):這是鍵盤硬體傳送到電腦的原始數據。
實作首批 IRQ 處理程序:CPU 計時器與鍵盤。
計時器的設定非常簡單。首先在 cpu/timer.h 宣告 init_timer() 並在 cpu/timer.c 實作。這主要涉及計算時鐘頻率並將位元組發送到對應的埠口。
接著修正 libc/string.c(原為 kernel/utils.c)中的 int_to_ascii(),使其能按正確順序印出數字。為此我們需要實作 reverse() 與 strlen()。
回到 kernel/kernel.c 執行兩件事:重新啟用中斷(在 irq_install 中執行)並初始化計時器中斷。
執行 make run 即可看到時鐘跳動(若處理程序中有印出訊息)。
鍵盤設定非常簡單,但有一個缺點:PIC 傳送的不是按鍵的 ASCII 碼,而是按鍵(key-press)與放開(key-up)事件的掃描碼(scancode),因此我們需要進行轉換。
請參閱 drivers/keyboard.c,其中包含兩個函式:回呼函式(callback)與設定中斷回呼的初始化函式。同時也建立了包含相關定義的 keyboard.h。
keyboard.c 中有一張長表用於將掃描碼轉換為 ASCII 碼。目前我們僅實作了美式鍵盤(US keyboard)的一個簡單子集,詳細資訊請參閱 鍵盤掃描碼參考資料。
先整理程式碼,再解析使用者輸入。
首先稍微清理一下程式碼,嘗試將各個程式模組放在最合理且易於預期的位置。這是一個很好的練習,能讓我們覺察程式碼何時開始過度增長,並調整架構以適應當前與未來的需求。
我們很快就會需要更多處理字串及其他功能的工具函式,在標準的作業系統中,這被稱為 C 函式庫或 libc。
現在我們將原本的 utils.c 拆分為 mem.c 與 string.c(位於 libc/ 目錄下),並附帶各自的標頭檔。
其次,我們建立了一個新的函式 irq_install(),讓核心只需呼叫一次即可初始化所有 IRQ。相對應地,初始化異常處理的函式為 isr_install(),兩者皆位於 isr.c。在此階段,我們會停用 timer_callback() 中的 kprint() 訊息,以避免時鐘跳動訊息填滿螢幕。
目前 cpu/ 與 drivers/ 之間的劃分尚不完全明確,日後會再優化。目前的變動是將 drivers/ports.* 移入 cpu/,因為埠口操作顯然屬於 CPU 相關代碼。boot/ 同樣也屬於 CPU 相關代碼,但除非未來要支援其他硬體架構,否則暫不更動。
Makefile 中的 CFLAGS 增加了更多編譯旗標。這是因為我們開始撰寫高階函式,不希望編譯器在處理宣告時引入任何外部連結。我們也加入了將警告視為錯誤的設定,因為指標轉型上的微小失誤往往是後續嚴重錯誤的根源,這也促使我們修正了程式碼中一些不嚴謹的指標宣告。
最後,我們在 libc/function.h 中加入了一個宏 (macro),用於消除編譯器針對「未使用參數」產生的警告錯誤。
如何存取鍵盤輸入的字元:
當按鍵被按下時,回呼函式(callback)會透過 keyboard.c 開頭定義的新陣列(如 sc_ascii)獲取對應的 ASCII 碼。
隨後,回呼函式會將該字元追加到緩衝區 key_buffer 中。
該字元也會同步顯示在螢幕上。
當使用者按下回車鍵(Enter)時,系統會呼叫核心函式 user_input(key_buffer) 來處理輸入內容。
keyboard.c 處理退格鍵(Backspace)的方式是刪除 key_buffer 中的最後一個字元,並透過重新顯示輸入行來更新螢幕(這涉及呼叫 screen.c:kprint_backspace())。我們也對 print_char() 進行了微調,使其在遇到退格符號(0x08)時不會增加偏移量(offset)。
鍵盤回呼函式(callback)會檢查換行符號,並呼叫核心告知使用者已完成了輸入。我們最後一個 libc 函式是 strcmp(),用於比較輸入字串。如果使用者輸入 end,則停止 CPU 運行。
實作記憶體分配器。
在 libc/mem.c 中加入核心記憶體分配器。其實作方式為一個指向可用記憶體的簡單指標,該指標會隨著分配不斷增長。
kmalloc() 函式可用於請求對齊的分頁(aligned page),並且它也會回傳用於後續用途的實體位址。
修改 kernel.c,保留所有 Shell 相關程式碼,僅加入對 kmalloc() 的測試。確認第一頁是從 0x10000 開始(此處在 mem.c 中為硬編碼),隨後的 kmalloc() 呼叫會產生新的位址,且該位址與前一個位址對齊 4096 位元組(或 0x1000)。
注意:在 libc/string.c 中新增了 hex_to_ascii() 函式,用於印出十六進位數字。
簡單修正:為了語言一致性,將 types.h 重新命名為 type.h。
修正雜項問題
OSDev Wiki 有一個頁面 James Molloy's Tutorial Known Bugs。由於我們遵循了該教學(從中斷到 malloc),因此需要確保修正以下問題:
在編譯 .o 檔案(包括 kernel_entry.o、kernel.bin 和 os-image.bin 相關物件)時,加入 -ffreestanding 旗標。
先前我們透過 -nostdlib 停用了 libgcc(注意不是 libc),但在連結時沒有重新啟用它,這會變得很棘手。因此我們在目前版本中調整了參數,確保正確連結必要的獨立程式庫。
同時也向 gcc 傳遞了 -nostdinc。
修改 kernel/kernel.c,將 main() 改名為 kernel_main(),因為 gcc 將 main 視為特殊關鍵字,我們不應直接操作它。
相應地修改 boot/kernel_entry.asm 以指向新名稱。
修正 i386-elf-ld: warning: cannot find entry symbol _start; defaulting to 0000000000001000 警告訊息:在 boot/kernel_entry.asm 中加入 global _start; 並定義 _start: 標籤。
定義非標準資料型別(如 u32 等)並非好主意,因為 C99 引入了標準的固定寬度資料型別(如 uint32_t)。
我們改為引入 <stdint.h>,這在 -ffreestanding 模式下依然有效(但需要環境支援),使用標準型別取代自定義型別,並刪除 type.h。
同時刪除不必要的 __asm__ 和 __volatile__ 關鍵字(除非確實需要)。
由於 kmalloc 使用大小參數,應使用正確的資料型別 size_t 取代 u32int_t(或 uint32_t)。所有相關參數都應統一使用 size_t。
實作缺失的 mem* 系列函式(如 memory_copy、memory_set)。
cli 指令是多餘的,因為如果 IDT 條目(idt_gate_t)的標誌設定正確,中斷會在進入處理程序時自動禁用。
sti 也是多餘的,因為 iret 指令會從堆疊中彈出其儲存的 EFLAGS 值,其中包含了中斷是否開啟的位元。換句話說,中斷處理程序會自動恢復到中斷發生前的狀態(無論之前是否啟用了中斷)。
在 cpu/isr.h 中,struct registers_t 有多處問題。首先,原本的 esp 被重新命名為 useless,因為該值反映的是當前的堆疊上下文,而非被中斷時的狀態。因此我們將原有的 useresp 重新命名為 esp。
根據 OSDev Wiki 的建議,在 cpu/interrupt.asm 呼叫 isr_handler 之前加入 cld 指令。
cpu/interrupt.asm 的另一個重要修正:通用的 stub 會在堆疊上建立 struct registers_t 的實體並呼叫 C 處理程序。但這違反了 ABI 規範,因為堆疊空間屬於被呼叫函式,它們可以隨意更改其值。我們必須以指標(pointer)的形式傳遞該結構。
實作方法:
- 修改
cpu/isr.h和cpu/isr.c,將registers_t r改為registers_t *t。 - 將結構欄位的存取方式從
.改為->。 - 在
cpu/interrupt.asm呼叫isr_handler和irq_handler之前加入push esp(推入結構位址)。 - 呼叫結束後記得
pop eax以清理該指標。
目前所有的回呼函式(如計時器和鍵盤)也都需要修改為使用 registers_t 指標。
原本教學是到El Capitan,查了才知道這是mac的電腦版本,才需要在更新原本的cross-compiler。基本上跟之前的教學沒啥兩樣,算是最後的章節了
實作實時時鐘 (Real Time Clock, RTC) 驅動,主要透過 CMOS I/O 埠存取硬體時間資訊。
- Index Port (0x70): 用於指定要存取的暫存器索引。
- Data Port (0x71): 用於讀取或寫入指定暫存器的資料。
注意: 在存取 Port 0x70 時,最高位元 (bit 7) 是 NMI Disable 位元。將其設為 1 會暫時停用不可遮蔽中斷 (NMI),許多開源系統在存取 CMOS 時會設定此位元以確保操作原子性。
RTC 提供暫存器來儲存秒、分、時、日、月、年等其他資訊:
0x00: 秒0x02: 分0x04: 時0x07: 日0x08: 月0x09: 年0x0A (Status Register A):- Bit 7 (UIP): Update In Progress。若此位元為 1,表示 RTC 正在更新時間,此時讀取的值可能無效,驅動程式必須等待其變為 0。
0x0B (Status Register B): 有四種模式- binary 模式 或 BCD 模式
- 12 小時制模式 或 24 小時制模式 有些format bit在register B中無法改變,所以必須處理這四種可能。並且別試著改變register B中的值,必須先讀取register B中的值,找到你要的格式,再進行處理。
- 在register B中,bit 1(value=2)是代表24小時制
- 在register B中,bit 2(value=4)是代表binary模式
binary mode就是預期的正常時間,假設時間是1:59:48 AM,hours的值會是1,minutes的值是59=0x3b,seconds的值是48=0x30
在BCD mode中,每一對16進位的byte將被修改成顯示十進位的數字,所以1:59:48 AM 會有hours是1,minutes的值是0x59=89,seconds的值是0x48=72,為了轉換成二進位制,需要以下公式來轉換。binary =((bcd/16)*10)+(bcd & 0xf)。優化公式版本binary = ( (bcd & 0xF0) >> 1) + ( (bcd & 0xF0) >> 3) + (bcd & 0xf)。
12小時制轉換24小時有點麻煩,如果hour是pm,那0x80 bit將被設為1,所以必須遮罩掉,然後午夜12,1 am是1,注意午夜不是0,是12,這是需要處理12小時制到24小時制的特殊情況(設置12為0)
- 安全讀取機制 - 二次比對法 (Double Read):
- 僅檢查
0x0A的 UIP 位元是不夠的,因為讀取多個暫存器期間可能剛好發生進位。 - 正確做法: 先檢查 UIP 為 0,讀取所有時間暫存器,接著再次檢查並讀取。若兩次讀取的結果完全相同,才視為有效資料。若不相同則重複此過程。
- 僅檢查
- 週期性中斷 (可選): 若需要高頻率計時,可以設定 Register B 的 PIE 位元,RTC 會觸發 IRQ 8 中斷。中斷頻率透過 Register A 的低 4 位元 (Rate Selection) 控制。
- 優化bcd to binary
PIT (Programmable Interval Timer) 是早期 IBM PC 中負責系統計時、硬體中斷觸發與音訊產生的核心組件。
基準頻率:約 1.193182 MHz (由 14.31818 MHz 基準時脈 12 分頻而來)。 組成架構:包含 1 個振盪器、1 個預分頻器及 3 個獨立的 16 位元分頻器(通道)。 計數能力:16 位元計數器,範圍 0~65535。其中 0 代表 65536。 精確度:誤差約為每日 +/- 1.73 秒。
通道 輸出連接 用途說明 Channel 0 IRQ 0 系統心跳:產生最高優先權中斷,用於作業系統排程。預設頻率 18.2 Hz。 Channel 1 DRAM 控制器 記憶體重新整理:現代電腦中已由專用硬體取代,通常已廢棄。 Channel 2 PC 喇叭 音訊產生:唯一可由軟體透過 I/O 埠 0x61 控制 Gate 與讀取輸出的通道。
I/O 埠 用途 說明 0x40 通道 0 資料埠 讀取/設定 Channel 0 計數值。 0x41 通道 1 資料埠 讀取/設定 Channel 1 計數值 (現代環境少用)。 0x42 通道 2 資料埠 讀取/設定 Channel 2 計數值。 0x43 模式/命令暫存器 唯寫。用於設定存取模式、運作模式及鎖存指令。
位元 用途 常用設定值 7-6 選擇通道 00=Ch0, 10=Ch2 5-4 存取模式 00=鎖存(Latch), 11=先傳低位再傳高位(Lobyte/Hibyte) 3-1 運作模式 010=Mode 2 (速率產生器), 011=Mode 3 (方波產生器),還有其他模式 0 BCD/二進位 0=16位元二進位 (只用16位元二進位)
Mode 2 (Rate Generator):產生極短的低電位脈衝。適合高精度的系統時鐘計時。 Mode 3 (Square Wave):產生 50% 佔空比方波。PC 喇叭發聲的標準模式。
控制 PC 喇叭發聲需要同時操作 PIT Channel 2 (0x42) 與 系統控制埠 B (0x61)。
要讓喇叭發出特定頻率的聲音,必須將 Channel 2 設定為 Mode 3 (方波產生器)。
- 輸出的控制位元組為
0xB6(1011 0110):10: 選擇 Channel 211: 先傳低位再傳高位 (Lobyte/Hibyte)011: Mode 3 (方波)0: 二進位計數
- 計算除數 (Divisor) =
1193180 / 頻率 (Hz)。 - 操作埠口:
- 將
0xB6寫入0x43。 - 將除數低位寫入
0x42。 - 將除數高位寫入
0x42。
- 將
PC 喇叭的物理連通受 System Control Port B (0x61) 控制:
- Bit 0: 開啟後,PIT Channel 2 的輸出才會送到喇叭。
- Bit 1: 開啟後,喇叭發聲器才會啟動。
程式操作:
- 播放聲音:讀取
0x61的當前值,將 Bit 0 和 Bit 1 設為 1 (即value | 0x03) 後寫回。 - 停止發聲:讀取
0x61的當前值,將 Bit 0 和 Bit 1 設為 0 (即value & ~0x03) 後寫回。
在讀取 16 位設計數器時,為避免讀取過程中計數值發生變化導致錯誤,應使用 鎖存指令 (Latch Command):
- 寫入
0x43:Bits 5-4 設為00(Latch),Bits 7-6 選擇通道。 - 讀取對應通道資料埠:連續讀取兩次 (低位、高位)。
https://wiki.osdev.org/Programmable_Interval_Timer
核心目的是防止攻擊者透過溢位局部變數(通常是陣列),來覆蓋函數在堆疊上的回傳位址(Return Address)。如果回傳位址被篡改,攻擊者就可以控制程式跳轉到惡意代碼(如 Shellcode)
SSP 僅能「偵測」堆疊緩衝區溢位,而非「防止」其發生。
- 放置金絲雀(Guard Value):當函數開始執行時,編譯器會在堆疊上的「局部變數」與「回傳位址」之間插入一個隨機的數值,這個值被稱為 Canary(金絲雀)。
- 執行函數:函數正常執行其邏輯。
- 檢查金絲雀:在函數準備 ret(回傳)之前,編譯器會插入一段代碼,檢查堆疊上的那個 Canary 值是否仍與原始值一致。
- 觸發報警:
- 如果值未變,說明沒有發生溢位(或者溢位沒碰到 Canary),函數正常回傳。
- 如果值改變了,說明發生了堆疊溢位(Stack Smashing),此時程式會立即跳轉到一個錯誤處理函數(通常是 __stack_chk_fail),終止程式或讓核心當機(Panic)。
- -fstack-protector:使用超過 8 位元組的 char 陣列,或是動態分配的記憶體(使用 malloc 等)
- -fstack-protector-all:強制為所有函數加上保護和檢查canary值
- -fstack-protector-strong:在stack-protector基礎上,加入本地數組或是union內含陣列、 若 local 變數位址用來賦值或者當作函式參數、以 register 類型宣告的 local 變數。
- -fstack-protector-explicit:只對以 attribute((stack_protect)) 宣告的 function 加入以及檢查 canary 值
e.g. attribute((stack_protect)) void test() {}
- fno-stack-protector:禁用 stack protector
- 全域變數:
__stack_chk_guard
- 定義一個全域變數:
uintptr_t __stack_chk_guard,用於儲存金絲雀值。 - 初始化:在核心初始化早期(例如 kmain),你應該賦予它一個隨機值
- 安全性注意:如果這個值永遠是固定的(例如 0x00000000),攻擊者只要猜到這個值,就可- 以在溢位時填入相同的數值來繞過檢測。
- 報警函數:__stack_chk_fail
- 當檢測到金絲雀被改變時,編譯器產生的代碼會呼叫這個函數。
- 原型:void __stack_chk_fail(void);
- 實作內容:在核心環境中,這通常會觸發一個 Kernel Panic。因為堆疊已經毀損,系統已處於不安全狀態,繼續執行可能會導致更嚴重的災難。
- 注意:這個函數絕對不能回傳(應該標記為 [[noreturn]] 或 attribute((noreturn))),且不應再嘗試使用複雜的堆疊操作。
在更進階的系統中(例如支援多執行緒的核心),每個執行緒(Thread)可能需要不同的 Canary 值,以防止某個執行緒的洩漏導致全域 Canary 被破解。這通常透過 TLS (Thread Local Storage) 實作,編譯器會根據目標架構(如 x86 的 gs 或 fs 暫存器)來讀取 Canary。
https://wiki.osdev.org/Stack_Smashing_Protector https://szlin.me/2017/12/09/stack-buffer-overflow-stack-canaries/
實作核心級多工處理,讓作業系統可以同時執行多個任務,並透過 Timer 中斷進行搶佔式排程 (Preemptive Scheduling)。
多工處理的本質是讓 CPU 在多個任務之間快速切換,造成「同時執行」的錯覺。要實現這個功能,需要以下幾個關鍵組件:
- Process Control Block (PCB): 儲存每個任務的狀態資訊
- Context Switch: 保存當前任務狀態,恢復下一個任務狀態
- Scheduler: 決定下一個要執行的任務
- TSS (Task State Segment): x86 硬體支援的任務狀態結構
- Timer Interrupt: 提供搶佔式排程的時機
PCB 是作業系統用來追蹤每個任務資訊的資料結構。每個任務都有一個對應的 PCB。
// 定義於 cpu/task.h
typedef enum {
TASK_READY, // 就緒:等待 CPU 時間
TASK_RUNNING, // 執行中:目前正在 CPU 上執行
TASK_BLOCKED, // 阻塞:等待 I/O 或其他事件(未來使用)
TASK_TERMINATED // 終止:任務已結束
} task_state_t;// 定義於 cpu/task.h
typedef struct pcb {
uint32_t pid; // 進程識別碼 (Process ID)
task_state_t state; // 當前狀態
uint32_t esp; // 保存的堆疊指標
uint32_t kernel_stack; // 核心堆疊基底位址
uint32_t kernel_stack_top; // 核心堆疊頂端
void (*entry_point)(void); // 任務進入點函數
struct pcb *next; // 鏈結串列指標(用於排程)
} pcb_t;欄位說明:
pid: 每個任務的唯一識別碼state: 追蹤任務當前的執行狀態esp: Context Switch 時保存的堆疊指標,指向保存的暫存器kernel_stack: 由kmalloc()分配的堆疊記憶體基底kernel_stack_top: 堆疊頂端(高位址,因為堆疊向下生長)entry_point: 任務開始執行的函數位址next: 用於建立任務的鏈結串列
Context Switch 是多工處理的核心機制。當 Scheduler 決定切換任務時,必須:
- 保存當前任務的 CPU 暫存器到其堆疊
- 保存當前堆疊指標到 PCB
- 載入新任務的堆疊指標
- 從新任務的堆疊恢復 CPU 暫存器
- 返回到新任務的執行位置
在 cdecl 呼叫慣例中:
- Caller-saved registers:
EAX,ECX,EDX- 呼叫者負責保存 - Callee-saved registers:
EBX,ESI,EDI,EBP- 被呼叫者負責保存
Context Switch 只需要保存 callee-saved registers,因為 caller-saved registers 已經被編譯器處理。
; 定義於 cpu/context_switch.asm
; void context_switch(uint32_t *old_esp, uint32_t new_esp)
;
; 參數:
; old_esp: 指向舊任務 PCB 中 esp 欄位的指標
; new_esp: 新任務保存的堆疊指標值
global context_switch
context_switch:
; === 保存當前任務的上下文 ===
; 保存 callee-saved registers
push ebp
push ebx
push esi
push edi
pushf ; 保存 EFLAGS(包含中斷旗標)
; 取得 old_esp 指標並保存當前 ESP
; 堆疊配置:5 次 push (20 bytes) + return address (4 bytes) = 24 bytes
mov eax, [esp + 24] ; eax = old_esp 指標
mov [eax], esp ; *old_esp = 當前 ESP
; === 切換到新任務 ===
; 載入新的堆疊指標
mov eax, [esp + 28] ; eax = new_esp
mov esp, eax ; ESP = new_esp
; === 恢復新任務的上下文 ===
popf ; 恢復 EFLAGS
pop edi
pop esi
pop ebx
pop ebp
ret ; 返回到新任務(從新堆疊彈出 EIP)當 context_switch 保存完畢後,堆疊配置如下:
高位址
+------------------+
| Return Addr | <- 由 call 指令推入
+------------------+
| EBP | <- push ebp
+------------------+
| EBX | <- push ebx
+------------------+
| ESI | <- push esi
+------------------+
| EDI | <- push edi
+------------------+
| EFLAGS | <- pushf
+------------------+ <- ESP (保存到 PCB)
低位址
創建新任務時,必須設置堆疊使其看起來像是被 context_switch 中斷的樣子:
// 定義於 cpu/task.c - task_create() 函數
uint32_t *sp = (uint32_t*)stack_top;
// Return address - context_switch 的 ret 會跳到這裡
*--sp = (uint32_t)entry; // 任務進入點
// 保存的暫存器(將被 context_switch 彈出)
*--sp = 0; // EBP
*--sp = 0; // EBX
*--sp = 0; // ESI
*--sp = 0; // EDI
*--sp = 0x202; // EFLAGS: IF=1 (中斷啟用), 保留位元 1
task->esp = (uint32_t)sp;排程器負責決定哪個任務應該獲得 CPU 時間。本實作使用 Round-Robin (輪詢) 演算法。
Round-Robin 是最簡單的排程演算法:
- 所有就緒任務排成一個佇列
- 每個任務獲得相等的時間片 (Time Slice)
- 時間片用完後,切換到下一個任務
- 到達佇列尾端時,回到開頭
// 定義於 cpu/scheduler.c
static pcb_t *current_task = 0; // 目前執行的任務
static pcb_t *ready_queue_head = 0; // 就緒佇列頭
static pcb_t *ready_queue_tail = 0; // 就緒佇列尾
static uint32_t task_count = 0; // 任務總數
static int scheduler_enabled = 0; // 排程器開關// 定義於 cpu/scheduler.c
void schedule(void) {
if (!scheduler_enabled) return;
if (task_count <= 1) return;
if (!current_task) return;
pcb_t *old_task = current_task;
pcb_t *next_task = 0;
// Round-Robin: 找下一個 READY 任務
pcb_t *candidate = old_task->next;
if (!candidate) {
candidate = ready_queue_head; // 回繞到佇列開頭
}
// 搜尋 READY 狀態的任務
pcb_t *start = candidate;
do {
if (candidate->state == TASK_READY) {
next_task = candidate;
break;
}
candidate = candidate->next;
if (!candidate) {
candidate = ready_queue_head;
}
} while (candidate != start);
// 沒有其他任務可切換
if (!next_task || next_task == old_task) return;
// 更新狀態
if (old_task->state == TASK_RUNNING) {
old_task->state = TASK_READY;
}
next_task->state = TASK_RUNNING;
current_task = next_task;
// 執行上下文切換
context_switch(&old_task->esp, next_task->esp);
}搶佔式排程透過 Timer 中斷實現:
// 修改於 cpu/timer.c
#include "scheduler.h"
static void timer_callback(registers_t *regs){
tick++;
// 呼叫排程器進行搶佔式多工
scheduler_timer_handler(regs);
}每次 Timer 中斷(預設每秒 50 次)都會觸發排程器檢查是否需要切換任務。
TSS 是 x86 架構中用於硬體任務切換的資料結構。雖然現代作業系統通常使用軟體任務切換,但 TSS 仍然是必要的,原因如下:
- 特權級切換: 當從 Ring 3 (User Mode) 切換到 Ring 0 (Kernel Mode) 時,CPU 需要知道要使用哪個核心堆疊
- ESP0 欄位: TSS 中的
esp0告訴 CPU 核心模式的堆疊指標
// 定義於 cpu/tss.h
typedef struct {
uint32_t prev_tss; // 前一個 TSS 鏈結(軟體切換不使用)
uint32_t esp0; // Ring 0 堆疊指標 ← 最重要的欄位
uint32_t ss0; // Ring 0 堆疊段選擇器
uint32_t esp1; // Ring 1 堆疊指標(未使用)
uint32_t ss1; // Ring 1 堆疊段
uint32_t esp2; // Ring 2 堆疊指標(未使用)
uint32_t ss2; // Ring 2 堆疊段
uint32_t cr3; // 頁目錄基底暫存器
uint32_t eip;
uint32_t eflags;
uint32_t eax, ecx, edx, ebx;
uint32_t esp, ebp, esi, edi;
uint32_t es, cs, ss, ds, fs, gs;
uint32_t ldt; // LDT 選擇器
uint16_t trap; // 任務切換陷阱位元
uint16_t iomap_base; // I/O 權限位圖偏移
} __attribute__((packed)) tss_entry_t;// 定義於 cpu/tss.c
tss_entry_t tss_entry;
void tss_init(uint32_t kernel_ss, uint32_t kernel_esp) {
uint32_t base = (uint32_t)&tss_entry;
uint32_t limit = sizeof(tss_entry_t) - 1;
// 清除 TSS
memory_set((uint8_t*)&tss_entry, 0, sizeof(tss_entry_t));
// 設定核心堆疊(用於特權級切換)
tss_entry.ss0 = kernel_ss; // 0x10 (核心資料段)
tss_entry.esp0 = kernel_esp; // 0x90000
// 設定核心段選擇器
tss_entry.cs = 0x08; // 核心程式碼段
tss_entry.ss = kernel_ss;
tss_entry.ds = kernel_ss;
tss_entry.es = kernel_ss;
tss_entry.fs = kernel_ss;
tss_entry.gs = kernel_ss;
// I/O 位圖偏移設為結構大小(表示無 I/O 權限)
tss_entry.iomap_base = sizeof(tss_entry_t);
// 在 GDT 中安裝 TSS 描述符
gdt_set_gate(3, base, limit, 0x89, 0x00);
}TSS 必須透過 ltr (Load Task Register) 指令載入:
; 定義於 cpu/context_switch.asm
global tss_flush
tss_flush:
mov ax, 0x18 ; TSS 選擇器 (GDT entry 3 * 8 = 0x18)
ltr ax ; 載入任務暫存器
ret為了支援 TSS,需要在 GDT 中新增一個 TSS 描述符。
// 定義於 cpu/gdt.h
typedef struct {
uint16_t limit_low; // Limit bits 0-15
uint16_t base_low; // Base bits 0-15
uint8_t base_middle; // Base bits 16-23
uint8_t access; // 存取權限位元組
uint8_t granularity; // 標誌 + Limit bits 16-19
uint8_t base_high; // Base bits 24-31
} __attribute__((packed)) gdt_entry_t;
typedef struct {
uint16_t limit; // GDT 大小 - 1
uint32_t base; // GDT 線性基底位址
} __attribute__((packed)) gdt_ptr_t;// 定義於 cpu/gdt.c
#define GDT_ENTRIES 4
static gdt_entry_t gdt[GDT_ENTRIES];
static gdt_ptr_t gdt_ptr;
void gdt_init(void) {
gdt_ptr.limit = (sizeof(gdt_entry_t) * GDT_ENTRIES) - 1;
gdt_ptr.base = (uint32_t)&gdt;
// Entry 0: Null 描述符(必須)
gdt_set_gate(0, 0, 0, 0, 0);
// Entry 1: 程式碼段 (選擇器 0x08)
// Access: P=1, DPL=0, S=1, Type=0xA = 0x9A
// Granularity: G=1, D=1 = 0xCF
gdt_set_gate(1, 0, 0xFFFFF, 0x9A, 0xCF);
// Entry 2: 資料段 (選擇器 0x10)
// Access: P=1, DPL=0, S=1, Type=0x2 = 0x92
gdt_set_gate(2, 0, 0xFFFFF, 0x92, 0xCF);
// Entry 3: TSS (選擇器 0x18)
// 由 tss_init() 填入
gdt_set_gate(3, 0, 0, 0, 0);
// 載入新的 GDT
gdt_flush((uint32_t)&gdt_ptr);
}// 定義於 cpu/gdt.c
void gdt_set_gate(int num, uint32_t base, uint32_t limit,
uint8_t access, uint8_t gran) {
// 設定基底位址
gdt[num].base_low = base & 0xFFFF;
gdt[num].base_middle = (base >> 16) & 0xFF;
gdt[num].base_high = (base >> 24) & 0xFF;
// 設定限制
gdt[num].limit_low = limit & 0xFFFF;
gdt[num].granularity = (limit >> 16) & 0x0F;
// 設定標誌和存取權限
gdt[num].granularity |= (gran & 0xF0);
gdt[num].access = access;
}; 定義於 cpu/context_switch.asm
global gdt_flush
gdt_flush:
mov eax, [esp + 4] ; 取得 gdt_ptr 指標
lgdt [eax] ; 載入 GDT
; 重新載入段暫存器
mov ax, 0x10 ; 資料段選擇器 (entry 2)
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
; 遠跳轉以重新載入 CS
jmp 0x08:.flush_done ; 程式碼段選擇器 (entry 1)
.flush_done:
retTSS 描述符與普通段描述符略有不同:
位元 欄位 TSS 的值
─────────────────────────────────────
7-6 選擇通道 與一般段相同
5-4 存取模式 與一般段相同
3-0 Type 0x9 = 可用的 32-bit TSS
0xB = 忙碌的 32-bit TSS
Access Byte for TSS:
- Bit 7: Present (P) = 1
- Bits 6-5: DPL = 00 (Ring 0)
- Bit 4: S = 0 (系統段)
- Bits 3-0: Type = 1001 (可用的 32-bit TSS)
完整的 Access Byte = 0x89
// 定義於 kernel/kernel.c
void kernel_main(){
// ... 其他初始化 ...
irq_install();
// 1. 初始化核心 GDT(取代開機時的 GDT,加入 TSS entry)
gdt_init();
// 2. 初始化 TSS(核心資料段 0x10,核心堆疊 0x90000)
tss_init(0x10, 0x90000);
tss_flush();
// 3. 初始化多工系統
task_init();
kprint("Multitasking: OK\n");
kprint("> ");
}// 定義於 cpu/task.c
void task_init(void) {
// 清除任務池
memory_set((uint8_t*)task_pool, 0, sizeof(task_pool));
// 初始化排程器
scheduler_init();
// 創建 "main" 任務代表當前核心執行
pcb_t *main_task = &task_pool[0];
main_task->pid = 0;
main_task->state = TASK_RUNNING;
main_task->kernel_stack = 0; // 使用開機堆疊
main_task->esp = 0; // 第一次切換時保存
scheduler_add_task(main_task);
}// 定義於 cpu/task.c
pcb_t* task_create(void (*entry)(void)) {
// 1. 在任務池中找空位
pcb_t *task = find_free_slot();
if (!task) return 0;
// 2. 分配核心堆疊 (4KB, 頁對齊)
uint32_t stack_base = kmalloc(KERNEL_STACK_SIZE, 1, 0);
if (!stack_base) return 0;
uint32_t stack_top = stack_base + KERNEL_STACK_SIZE;
// 3. 初始化 PCB
task->pid = next_pid++;
task->state = TASK_READY;
task->kernel_stack = stack_base;
task->kernel_stack_top = stack_top;
task->entry_point = entry;
// 4. 設置初始堆疊框架
uint32_t *sp = (uint32_t*)stack_top;
*--sp = (uint32_t)entry; // Return address
*--sp = 0; // EBP
*--sp = 0; // EBX
*--sp = 0; // ESI
*--sp = 0; // EDI
*--sp = 0x202; // EFLAGS (IF=1)
task->esp = (uint32_t)sp;
// 5. 加入排程器
scheduler_add_task(task);
return task;
}// 定義於 cpu/task.c
void task_exit(void) {
pcb_t *current = scheduler_current();
current->state = TASK_TERMINATED;
// 釋放堆疊
if (current->kernel_stack) {
kfree((void*)current->kernel_stack);
}
// 強制切換到下一個任務
schedule();
// 永遠不會到達這裡
while(1) { asm volatile("hlt"); }
}// 定義於 kernel/kernel.c
// 測試任務 A
void test_task_a(void) {
while(1) {
kprint("A");
for(volatile int i = 0; i < 500000; i++);
}
}
// 測試任務 B
void test_task_b(void) {
while(1) {
kprint("B");
for(volatile int i = 0; i < 500000; i++);
}
}
// Shell 指令處理
if (strcmp(input, "multitask") == 0) {
kprint("Starting multitasking test...\n");
task_create(test_task_a);
task_create(test_task_b);
scheduler_enable();
kprint("Scheduler enabled! You should see ABABAB...\n");
}執行 multitask 指令後,應該看到交替輸出的 ABABABABAB...,證明兩個任務正在被排程器切換。
┌─────────────────────────────────────┐
│ Timer IRQ (IRQ0) │
│ 每秒觸發約 50 次 │
└─────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ timer_callback() │
│ cpu/timer.c │
└─────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ scheduler_timer_handler() │
│ cpu/scheduler.c │
└─────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ schedule() │
│ 選擇下一個 READY 任務 │
│ (Round-Robin 演算法) │
└─────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ context_switch() │
│ cpu/context_switch.asm │
│ │
│ 1. 保存當前暫存器到堆疊 │
│ 2. 保存 ESP 到舊任務 PCB │
│ 3. 載入新任務的 ESP │
│ 4. 從新堆疊恢復暫存器 │
│ 5. ret 跳到新任務 │
└─────────────────────────────────────┘
| 檔案 | 說明 |
|---|---|
cpu/task.h |
PCB 結構定義、任務狀態枚舉、API 宣告 |
cpu/task.c |
任務管理實作:init, create, exit |
cpu/scheduler.h |
排程器 API 宣告 |
cpu/scheduler.c |
Round-Robin 排程器實作 |
cpu/tss.h |
TSS 結構定義 |
cpu/tss.c |
TSS 初始化、GDT TSS entry 設定 |
cpu/gdt.h |
GDT 結構定義 |
cpu/gdt.c |
核心 GDT 初始化(取代開機 GDT) |
cpu/context_switch.asm |
Context switch、tss_flush、gdt_flush 組語 |
cpu/timer.c |
Timer 中斷處理,呼叫 scheduler |
kernel/kernel.c |
初始化流程、測試任務 |
-
堆疊對齊: x86 ABI 要求堆疊 16 位元組對齊,使用
kmalloc()的頁對齊選項確保這點。 -
中斷安全: Context switch 期間不要手動操作中斷旗標。
pushf/popf會自動保存和恢復 EFLAGS。 -
Main 任務: 初始核心執行會變成 Task 0,使用開機時設定的堆疊,不可釋放。
-
EOI 順序: 確保在 IRQ handler 中先發送 EOI 給 PIC,再進行 context switch。目前的
irq_handler()已經正確處理這點。 -
單一位址空間: 目前所有任務共享同一個頁表,沒有記憶體隔離。未來需要為每個任務配置獨立的頁目錄。
-
優先權排程 (Priority Scheduling): 為任務加入優先權欄位,高優先權任務獲得更多 CPU 時間。
-
多級回饋佇列 (MLFQ): 結合多個優先權佇列,動態調整任務優先權。
-
User Mode (Ring 3):
- 為每個任務設定獨立的頁表
- 實作系統呼叫 (int 0x80)
- TSS 的 esp0 用於特權級切換
-
同步原語:
- Mutex (互斥鎖)
- Semaphore (信號量)
- Spinlock (自旋鎖)
-
行程間通訊 (IPC):
- 管道 (Pipes)
- 共享記憶體
- 訊息佇列
- OSDev Wiki - Context Switching
- OSDev Wiki - Kernel Multitasking
- OSDev Wiki - TSS
- OSDev Wiki - GDT Tutorial
- OSDev Wiki - Scheduling Algorithms
- Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 3A: System Programming Guide - Chapter 7: Task Management
- James Molloy's Kernel Development Tutorial - Multitasking
- BrokenThorn Entertainment - OS Development Series