寫個作業系統吧!
寫個作業系統吧!
參考書籍:
- 《作業系統真相還原》
- 《x86組合語言:從真實模式到保護模式》
中斷
中斷的分類:
-
外部中斷:外部裝置提供的中斷訊號
- INTR(INTeRrupt):可被遮蔽的中斷,例如:網絡卡接收到資料等。
- NMI(Non Maskable Interrupted):無法被遮蔽的中斷,例如:電源掉電等。
-
內部中斷:
-
軟中斷:由軟體發起,即執行了對應的中斷指令。
int 8位立即數
:根據中斷向量表或中斷描述符表呼叫對應的中斷處理程式。int3
:斷點調式命令,觸發中斷向量號3。into
:中斷溢位指令,觸發中斷向量號4。bound
:檢查陣列索引越界指令,觸發中斷向量號5。ud2
:未定義指令,觸發中斷向量號6。
-
異常:指令執行期間CPU內部發生錯誤引起的,無法被遮蔽。
按照異常的嚴重程度,可分為以下三種:
- Fault,故障:可被修復的錯誤型別。
- Trap,陷阱:通常用在除錯中。
- Abort,終止:一旦出現則無法修復,作業系統會將該程式抹掉。
-
作業系統對中斷的處理方式:
把中斷處理程式劃分為上半部和下半部。
上半部:處理一些緊急且重要的事情。(會關閉中斷,處理完成後再開啟中斷)
下半部:處理一些重要但不緊急的事情,由作業系統在適合的時候執行。(允許中斷髮生)
IDT中斷描述符表
保護模式下用於儲存中斷處理程式入口的表。
真實模式下儲存中斷處理程式入口的表稱為中斷向量表(IVT)
。
門:
-
任務門
與任務狀態段(TSS)配合使用,是Intel在硬體一級提供的任務切換機制。
任務門可以存在全域性描述符表GDT、區域性描述符表LDT、中斷描述符表IDT中。
-
中斷門
包含中斷處理程式所在段的段選擇子和段內偏移地址。通過此方式進入中斷後,標誌暫存器eflags中的IF位自動設定為0,也就是自動關閉中斷,避免中斷巢狀。
中斷門只允許存在於中斷描述符表IDT中。
-
陷阱門
與中斷門一樣,但是通過此方式進入中斷,不會自動關閉中斷。
-
呼叫門
提供給使用者進入特權0級的方式,其DPL為3。
呼叫門記錄了例程的地址,不能通過
int
指令呼叫,只能通過call
和jmp
可以安裝在全域性描述符表GDT和區域性描述符表LDT中。
中斷向量表和中斷描述符表的區別:
中斷向量表:真實模式
存在於低端記憶體中,即
0x0 ~ 0x3ff
,大小為1024個位元組,每個中斷向量大小為4個位元組,因此中斷向量表可以容納256箇中斷向量
中斷描述符表:保護模式
中斷描述符表暫存器(IDTR),該暫存器分為兩部分,
0~15
位是表界限,16~47
位為IDT的基地址,最多容納8192個描述符。
中斷處理過程:
- CPU根據中斷向量號定位到中斷描述符。
- CPU進行特權級檢查
- 執行中斷處理程式
兩個中斷有關的命令
cli
:關中斷,把eflags暫存器的IF位設定為0。sti
:開中斷,把eflags暫存器的IF位設定為1。
中斷處理過程中棧的變化:
中斷錯誤碼:
引數解釋:
- EVT:用來指明中斷源是否來自外部裝置,1是,0否。
- IDT:表示選擇子是否指向中斷描述符表,1是,0否。
- TI:表示是選擇子使用GDT還是LDT,1LDT,0GDT。
中斷控制器8259A
構造:
外部裝置發起中斷到CPU處理中斷的流程:
- 外設發起中斷,該中斷訊號被送入到
8259A
的某個IRQ介面
。 8259A
收到該訊號後,根據IMR暫存器
判斷該訊號是否被遮蔽。(1遮蔽,0放行)8259A
將IRQ介面對應的該IRR暫存器
的bit置為1。優先仲裁器PR
從IRR暫存器
中挑選一個優先順序最大的中斷(即IRQ的位置越小,優先順序越大),通過INT介面
傳送INTR訊號
給CPU
。CPU
收到該訊號後,通過INTA介面
回覆一個INTA訊號
,表示CPU
準備完成可以接收中斷向量號。8259A
收到INTA訊號
後,將對應的IRR暫存器
上的位設定為0。CPU
再次傳送INTA訊號
給8259A
,8259A
傳送\(起始向量號 + IRQ介面號 = 該裝置的中斷向量號\)給CPU
。CPU
根據該中斷向量號
找到對應中斷程式入口,執行對應的中斷處理程式。- 當中斷處理程式結束後,如果
8259A
的EOI通知(End Of Interrupt)
設定為非自動模式,則中斷處理程式需要在結束處向8259A
傳送EOI通知
,8259A
收到EOI通知
將ISR暫存器
中對應的bit設定為0。
如果
EOI通知
設定為自動模式,則8259A
會在收到第二次INTA訊號
後就自動將對應的ISR暫存器
的bit設定為0。
可程式設計的計數器/定時器8253
8253提供了三個計數器,分別對應埠0x40 ~ 0x42
(16位)。
20計數器在計時到期後就會發出時鐘中斷訊號,中斷代理8259A就可以收到。
程序和執行緒的執行機制
執行緒建立
由核心進行管理,不具備屬於自己的虛擬地址空間,執行緒建立時,通過
get_kernel_pages
函式向核心記憶體池申請一頁的記憶體空間作為PCB。注意:執行緒棧處於該記憶體頁的頂端,執行緒相關資訊處於記憶體頁的低端。
執行緒建立時通過
thread_start
函式建立,該函式主要的職責是從核心記憶體池中申請1頁的記憶體空間,作為PCB,即struct task_struct*
結構體。再初始化執行緒相關資訊,如:執行緒名稱、優先順序、執行緒狀態等,再初始化執行緒的核心棧,即將分配到的記憶體頁的頂端作為棧的起始位置。thread->self_kstack = (uint32_t*) ((uint32_t) thread + PG_SIZE)
。然後,再把執行緒的PCB塊納入thread_ready_list
和thread_all_list
佇列進行管理。
執行緒排程
執行緒的排程完全依賴於時鐘中斷函式。具體程式碼在
timer.c
檔案中。在
init.c
中會暴露出一個init_all
函式,作為main.c
檔案中的第一個函式呼叫。該函式負責初始化所有核心模組,也包括定時器的初始化,即timer_init
函式。這個函式是在timer.c
檔案中。
timer_init
函式主要設定了PIT8253
定時器的定時週期,即以一定的頻率向CPU發出中斷訊號,再將intr_timer_handler
中斷處理函式註冊到中斷描述符表中,對應的中斷向量號為0x20
(註冊函式:register_handler
),即interrupt.c
檔案中的idt_table
陣列。當CPU收到一箇中斷訊號時,就會呼叫
kernel.s
檔案中的intr_entry_table
陣列,而該陣列中的中斷處理函式,都是使用同一個模板,具體邏輯就是:儲存當前中斷上下文(相關暫存器),再呼叫interrupt.c
檔案中註冊好的idt_table
陣列。再說回
intr_timer_handler
函式,該函式的主要邏輯如下:
intr_timer_handler
函式的步驟
- 先通過
running_thread
獲取當前執行緒。- 檢查執行緒是否棧溢位,即檢視
struct task_struct*
結構體的stack_magic
屬性是否被篡改。- 將執行緒執行的總時間片數加一。
- 將
timer.c
中的全域性變數ticks
加一,表示從作業系統核心載入到現在所執行過的時間片數。- 判斷執行緒的可用時間片
ticks
是否為0,如果為0,則表示時間片用完,進行排程,即呼叫thread.c
檔案中的schedule
函式。否則,將可用時間片減一,結束中斷。(結束中斷後,回返回當前執行緒之前正在執行的函式,並恢復其上下文(暫存器))說了這麼多,排程的所有關鍵點都在
schedule
函式上。
schedule
函式的步驟:
- 通過
running_thread
函式獲取當前執行的執行緒cur
。- 判斷當前執行緒是否處於
TASK_RUNNING
狀態,如果是,則表明該執行緒是因為時間片用完,則進行執行緒排程的,那麼將cur
放入就緒佇列thread_ready_list
,重置該執行緒的可用時間片cur->ticks = cur->priority
,設定執行緒的狀態為TASK_READY
。- 從就緒佇列
thread_ready_list
的隊頭pop
出一個執行緒,更新執行緒狀態為TASK_RUNNING
。- 呼叫
process_activate
函式,判斷當前PCB是使用者程序還是核心執行緒?如果是使用者程序,則呼叫page_dir_activate
函式修改cr3
暫存器,即修改頁目錄表的起始地址為使用者虛擬地址空間。否則修改頁目錄表的起始地址為0x100000
,即為核心虛擬地址空間。(因為在排程時,前一個PCB有可能是使用者程序,所有也需要更新cr3
暫存器)。如果是使用者程序,則需要修改tss
的esp0
值,即修改0特權級棧
為核心棧,即(uint32_t *)((uint32_t)pthread + PG_SIZE)
。- 最後呼叫
switch_to
函式,該函式位於switch.s
檔案中,儲存當前執行緒上下文,即將暫存器的值壓入執行緒棧中,同時恢復即將排程的執行緒的上下文,最後通過ret
指令,獲取棧中的值,跳轉到kernel_thread
函式中去執行。補充:
當跳轉到
kernel_thread
函式後,會先開啟中斷,在呼叫執行緒棧中的thread_stack::function
函式。從而實現從一個指令流跳轉到另一個指令流。
程序建立
程序相比於執行緒,多出了一個
3特權級棧
和虛擬地址空間
。步驟:
- 通過
process_execute
函式傳入呼叫的函式指標(void*)
和程序名稱,在從核心記憶體池中申請一個頁面作為核心棧,進行執行緒相關的初始化,即:init_thread -> (create_user_vaddr_bitmap) -> init_stack
。create_user_vaddr_bitmap
是初始化程序的虛擬地址空間的bitmap
,將虛擬地址空間的起始地址設定為0x8048000
,同時在核心記憶體池中分配出幾個頁來作為bitmap
。- 建立使用者程序的頁目錄表,先向核心記憶體池申請一個頁面來作為頁目錄表,同時將核心的頁目錄表的第
768
項到1023
項都複製到使用者程序的頁目錄表中。- 關閉中斷。
- 將該PCB加入
thread_all_list
和thread_ready_list
佇列- 開啟中斷。
程序排程
程序的排程相比於執行緒的不同之處,即程序需要在排程後,需要初始化使用者程序的上下文,即恢復暫存器原先的值,並且將
esp
修改為3特權級棧
的地址值。執行緒排程就不需要,通過
switch_to
函式的ret
指令跳轉到kernel_thread
函式中執行給定的thread_func
函式即可。程序排程,則同樣通過
switch_to
函式的ret
指令跳轉到kernel_thread
函式中執行給定的thread_func
函式,但是這個thread_func
函式,在process_execute
函式裡呼叫init_stack
時,已經指定為start_process
函式。
start_process
函式的主要流程:
- 獲取當前PCB,並設定PCB的中斷棧
intr_stack
結構。主要就是把棧中的eip
值改為使用者給的啟動函式,同時修改棧中cs
和ss
的值為使用者態下的段選擇子,修改esp
指標為3特權級棧
(到這裡才實際分配使用者棧空間)。- 修改
esp
暫存器的值為PCB中斷棧的起始地址,通過jmp
指令跳轉到kernel.s
檔案的intr_exit
函式中。intr_exit
這個函式主要就是恢復esp
暫存器指向的棧中值到gs、fs、es、ds
暫存器中,同時通過iretd
指令退出中斷模式(欺騙CPU,來跳轉到3特權級棧
)- 由於
eip
暫存器指向使用者給定的啟動函式,那麼程序就從使用者給的函式開始執行。
系統呼叫
中斷髮生後,處理器從低特權進入高特權,它會把
ss3、esp3、eflag、cs、eip
暫存器依次壓入棧中,共20位元組。
系統呼叫流程
在使用者程序中匯入
syscall.h
標頭檔案。裡面有對應的系統呼叫函式,具體實現在
syscall.c
檔案中。以
write
系統呼叫為例:
- 使用者呼叫
write
函式,傳入一個對應的字串引數,這是函式內部會呼叫對應的巨集_syscall1
。_syscall1
巨集會傳入系統呼叫子功能號和引數,子功能號對應的就是SYSCALL_NR
列舉(列舉會被轉化為整數),_syscall1
巨集的功能就是,呼叫asm volatile
巨集定義(C語言內聯彙編),把引數和子功能號壓入棧中,併發起中斷,即int $0x80
指令,再將中斷處理後的結果從eax
暫存器取出來放入到ret_val
變數中,並返回。0x80
中斷的具體實現在kernel.s
檔案,該中斷處理函式在interrupt.c
檔案的完成註冊,即在中斷描述符表中加入該函式的中斷描述符。具體路徑:idt_init() -> idt_desc_init() -> make_idt_desc(&idt[0x80], IDT_DESC_DPL3, syscall_hanlder)
。- 現在看下
syscall_handler
函式,該函式的主要作用就是儲存當前執行緒上下文,然後從棧中取出esp3
指標,即使用者棧指標,因為系統呼叫是在使用者態下呼叫的,當CPU特權級發生變化時,CPU會負責將esp3等暫存器的值壓入棧中,然後從棧中獲取子功能號和系統呼叫引數,回撥syscall.c
檔案中定義的全域性陣列syscall_table
,該陣列每一個元素都是對應子功能號(子功能號對應陣列下標)的系統呼叫處理函式,當回撥返回後,再將eax暫存器的值壓入棧中,呼叫intr_exit
函式退出中斷。
記憶體管理
分配記憶體的步驟:
- 在虛擬記憶體池中分配虛擬地址,相關函式是
vaddr_get
,此函式會操作核心的虛擬記憶體點陣圖kernel_vaddr.vaddr_bitmap
或使用者虛擬記憶體點陣圖pcb->user_program_vaddr.vaddr_bitmap
。 - 在實體記憶體池中分配實體地址,相關函式是
palloc
,此函式會操作核心的實體記憶體池點陣圖kernel_pool->pool_bitmap
或使用者實體記憶體池點陣圖user_pool->pool_bitmap
。 - 在頁表中完成虛擬地址到實體地址的對映,相關函式是
page_table_add
。
以上三個步驟封裝在
malloc_page
函式中。
釋放記憶體的步驟:
- 在實體記憶體池中釋放物理頁地址,相關函式是
pfree
。 - 在頁表中去掉虛擬地址的對映,原理是將頁表項的P位設定為0(即表示對應的資料不在記憶體中),相關函式是
page_table_pte_remove
。 - 在虛擬記憶體池中釋放虛擬地址,相關函式是
vaddr_remove
,操作的點陣圖同vaddr_get
函式。
以上三個步驟封裝在
mfree_page
函式中。