1. 程式人生 > 其它 >寫個作業系統吧!

寫個作業系統吧!

寫個作業系統吧!

參考書籍:

  • 《作業系統真相還原》
  • 《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指令呼叫,只能通過calljmp

    指令呼叫。

    可以安裝在全域性描述符表GDT和區域性描述符表LDT中。


中斷向量表和中斷描述符表的區別:

中斷向量表:真實模式

存在於低端記憶體中,即0x0 ~ 0x3ff,大小為1024個位元組,每個中斷向量大小為4個位元組,因此中斷向量表可以容納256箇中斷向量

中斷描述符表:保護模式

中斷描述符表暫存器(IDTR),該暫存器分為兩部分,0~15位是表界限,16~47位為IDT的基地址,最多容納8192個描述符。


中斷處理過程:

  1. CPU根據中斷向量號定位到中斷描述符。
  2. CPU進行特權級檢查
  3. 執行中斷處理程式

兩個中斷有關的命令

  • cli:關中斷,把eflags暫存器的IF位設定為0。
  • sti:開中斷,把eflags暫存器的IF位設定為1。

中斷處理過程中棧的變化:


中斷錯誤碼:

引數解釋:

  • EVT:用來指明中斷源是否來自外部裝置,1是,0否。
  • IDT:表示選擇子是否指向中斷描述符表,1是,0否。
  • TI:表示是選擇子使用GDT還是LDT,1LDT,0GDT。

中斷控制器8259A

構造:

外部裝置發起中斷到CPU處理中斷的流程:

  1. 外設發起中斷,該中斷訊號被送入到8259A的某個IRQ介面
  2. 8259A收到該訊號後,根據IMR暫存器判斷該訊號是否被遮蔽。(1遮蔽,0放行)
  3. 8259A將IRQ介面對應的該IRR暫存器的bit置為1。
  4. 優先仲裁器PRIRR暫存器中挑選一個優先順序最大的中斷(即IRQ的位置越小,優先順序越大),通過INT介面傳送INTR訊號CPU
  5. CPU收到該訊號後,通過INTA介面回覆一個INTA訊號,表示CPU準備完成可以接收中斷向量號。
  6. 8259A收到INTA訊號後,將對應的IRR暫存器上的位設定為0。
  7. CPU再次傳送INTA訊號8259A8259A傳送\(起始向量號 + IRQ介面號 = 該裝置的中斷向量號\)CPU
  8. CPU根據該中斷向量號找到對應中斷程式入口,執行對應的中斷處理程式。
  9. 當中斷處理程式結束後,如果8259AEOI通知(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_listthread_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函式的步驟

  1. 先通過running_thread獲取當前執行緒。
  2. 檢查執行緒是否棧溢位,即檢視struct task_struct*結構體的stack_magic屬性是否被篡改。
  3. 將執行緒執行的總時間片數加一。
  4. timer.c中的全域性變數ticks加一,表示從作業系統核心載入到現在所執行過的時間片數。
  5. 判斷執行緒的可用時間片ticks是否為0,如果為0,則表示時間片用完,進行排程,即呼叫thread.c檔案中的schedule函式。否則,將可用時間片減一,結束中斷。(結束中斷後,回返回當前執行緒之前正在執行的函式,並恢復其上下文(暫存器))

說了這麼多,排程的所有關鍵點都在schedule函式上。

schedule函式的步驟:

  1. 通過running_thread函式獲取當前執行的執行緒cur
  2. 判斷當前執行緒是否處於TASK_RUNNING狀態,如果是,則表明該執行緒是因為時間片用完,則進行執行緒排程的,那麼將cur放入就緒佇列thread_ready_list,重置該執行緒的可用時間片cur->ticks = cur->priority,設定執行緒的狀態為TASK_READY
  3. 從就緒佇列thread_ready_list的隊頭pop出一個執行緒,更新執行緒狀態為TASK_RUNNING
  4. 呼叫process_activate函式,判斷當前PCB是使用者程序還是核心執行緒?如果是使用者程序,則呼叫page_dir_activate函式修改cr3暫存器,即修改頁目錄表的起始地址為使用者虛擬地址空間。否則修改頁目錄表的起始地址為0x100000,即為核心虛擬地址空間。(因為在排程時,前一個PCB有可能是使用者程序,所有也需要更新cr3暫存器)。如果是使用者程序,則需要修改tssesp0值,即修改0特權級棧為核心棧,即(uint32_t *)((uint32_t)pthread + PG_SIZE)
  5. 最後呼叫switch_to函式,該函式位於switch.s檔案中,儲存當前執行緒上下文,即將暫存器的值壓入執行緒棧中,同時恢復即將排程的執行緒的上下文,最後通過ret指令,獲取棧中的值,跳轉到kernel_thread函式中去執行。

補充:

當跳轉到kernel_thread函式後,會先開啟中斷,在呼叫執行緒棧中的thread_stack::function函式。從而實現從一個指令流跳轉到另一個指令流。

程序建立

程序相比於執行緒,多出了一個3特權級棧虛擬地址空間

步驟:

  1. 通過process_execute函式傳入呼叫的函式指標(void*)和程序名稱,在從核心記憶體池中申請一個頁面作為核心棧,進行執行緒相關的初始化,即:init_thread -> (create_user_vaddr_bitmap) -> init_stack
  2. create_user_vaddr_bitmap是初始化程序的虛擬地址空間的bitmap,將虛擬地址空間的起始地址設定為0x8048000,同時在核心記憶體池中分配出幾個頁來作為bitmap
  3. 建立使用者程序的頁目錄表,先向核心記憶體池申請一個頁面來作為頁目錄表,同時將核心的頁目錄表的第768項到1023項都複製到使用者程序的頁目錄表中。
  4. 關閉中斷。
  5. 將該PCB加入thread_all_listthread_ready_list佇列
  6. 開啟中斷。

程序排程

程序的排程相比於執行緒的不同之處,即程序需要在排程後,需要初始化使用者程序的上下文,即恢復暫存器原先的值,並且將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函式的主要流程:

  1. 獲取當前PCB,並設定PCB的中斷棧intr_stack結構。主要就是把棧中的eip值改為使用者給的啟動函式,同時修改棧中csss的值為使用者態下的段選擇子,修改esp指標為3特權級棧(到這裡才實際分配使用者棧空間)。
  2. 修改esp暫存器的值為PCB中斷棧的起始地址,通過jmp指令跳轉到kernel.s檔案的intr_exit函式中。
  3. intr_exit這個函式主要就是恢復esp暫存器指向的棧中值到gs、fs、es、ds暫存器中,同時通過iretd指令退出中斷模式(欺騙CPU,來跳轉到3特權級棧
  4. 由於eip暫存器指向使用者給定的啟動函式,那麼程序就從使用者給的函式開始執行。

系統呼叫

中斷髮生後,處理器從低特權進入高特權,它會把ss3、esp3、eflag、cs、eip暫存器依次壓入棧中,共20位元組。

系統呼叫流程

在使用者程序中匯入syscall.h標頭檔案。

裡面有對應的系統呼叫函式,具體實現在syscall.c檔案中。

write系統呼叫為例:

  1. 使用者呼叫write函式,傳入一個對應的字串引數,這是函式內部會呼叫對應的巨集_syscall1
  2. _syscall1巨集會傳入系統呼叫子功能號和引數,子功能號對應的就是SYSCALL_NR列舉(列舉會被轉化為整數),_syscall1巨集的功能就是,呼叫asm volatile巨集定義(C語言內聯彙編),把引數和子功能號壓入棧中,併發起中斷,即int $0x80指令,再將中斷處理後的結果從eax暫存器取出來放入到ret_val變數中,並返回。
  3. 0x80中斷的具體實現在kernel.s檔案,該中斷處理函式在interrupt.c檔案的完成註冊,即在中斷描述符表中加入該函式的中斷描述符。具體路徑:idt_init() -> idt_desc_init() -> make_idt_desc(&idt[0x80], IDT_DESC_DPL3, syscall_hanlder)
  4. 現在看下syscall_handler函式,該函式的主要作用就是儲存當前執行緒上下文,然後從棧中取出esp3指標,即使用者棧指標,因為系統呼叫是在使用者態下呼叫的,當CPU特權級發生變化時,CPU會負責將esp3等暫存器的值壓入棧中,然後從棧中獲取子功能號和系統呼叫引數,回撥syscall.c檔案中定義的全域性陣列syscall_table,該陣列每一個元素都是對應子功能號(子功能號對應陣列下標)的系統呼叫處理函式,當回撥返回後,再將eax暫存器的值壓入棧中,呼叫intr_exit函式退出中斷。

記憶體管理

分配記憶體的步驟:

  1. 在虛擬記憶體池中分配虛擬地址,相關函式是vaddr_get,此函式會操作核心的虛擬記憶體點陣圖kernel_vaddr.vaddr_bitmap或使用者虛擬記憶體點陣圖pcb->user_program_vaddr.vaddr_bitmap
  2. 在實體記憶體池中分配實體地址,相關函式是palloc,此函式會操作核心的實體記憶體池點陣圖kernel_pool->pool_bitmap或使用者實體記憶體池點陣圖user_pool->pool_bitmap
  3. 在頁表中完成虛擬地址到實體地址的對映,相關函式是page_table_add

以上三個步驟封裝在malloc_page函式中。

釋放記憶體的步驟:

  1. 在實體記憶體池中釋放物理頁地址,相關函式是pfree
  2. 在頁表中去掉虛擬地址的對映,原理是將頁表項的P位設定為0(即表示對應的資料不在記憶體中),相關函式是page_table_pte_remove
  3. 在虛擬記憶體池中釋放虛擬地址,相關函式是vaddr_remove,操作的點陣圖同vaddr_get函式。

以上三個步驟封裝在mfree_page函式中。