1. 程式人生 > >XV6陷入,中斷和驅動程式

XV6陷入,中斷和驅動程式

陷入,中斷和驅動程式

執行程序時,cpu 一直處於一個大迴圈中:取指,更新 PC,執行,取指……。但有些情況下使用者程式需要進入核心,而不是執行下一條使用者指令。這些情況包括裝置訊號的發出、使用者程式的非法操作(例如引用一個找不到頁表項的虛擬地址)。處理這些情況面臨三大挑戰:1)核心必須使處理器能夠從使用者態轉換到核心態(並且再轉換回使用者態)2)核心和裝置必須協調好他們並行的活動。3)核心必須知道硬體介面的細節。解決這三個問題需要對硬體的深入理解和小心翼翼的程式設計,並且有可能導致難以理解的核心程式碼。這一章告訴你 xv6 是如何解決這些問題的。

系統呼叫,異常和中斷

正如我們上一章最後所見,使用者程式通過系統呼叫請求系統服務。術語exception指產生中斷的非法程式操作,例如除以0,嘗試訪問 PTE 不存在的記憶體等等。術語interrupt指硬體產生的希望引起作業系統注意的訊號,例如時鐘晶片可能每100毫秒產生一箇中斷,以此來實現分時。再舉一個例子,當硬碟讀完一個數據塊時,它會產生一箇中斷來提醒作業系統這個塊已經準備好被獲取了。

所有的中斷都由核心管理,而不是程序。因為在大多數情況下只有核心擁有處理中斷所需的特權和狀態。例如為了使程序響應時鐘中斷而在程序間實現時間分片,就必須在核心中執行這些操作,因為我們有可能強迫程序服從處理器的排程。

在所有三種情況下,作業系統的設計必須保證下面這些事情。系統必須儲存暫存器以備將來的狀態恢復。系統必須準備好在核心中執行,必須選擇一個核心開始執行的地方。核心必須能夠獲得關於這個事件的資訊,例如系統呼叫的引數。同時還必須保證安全性;系統必須保持使用者程序和系統程序的隔離。

為了達成這個目標作業系統必須知道硬體是如何處理系統呼叫、異常和中斷的。在大多數處理器中這三種事件都用同樣的硬體機制處理。比如說,在 x86 中,一個程式可以通過 int 指令產生一箇中斷來進行系統呼叫。同樣的,異常也會產生一箇中斷。因此,如果作業系統能夠處理中斷,那麼作業系統也可以處理系統呼叫以及異常。

我們的計劃是這樣的。中斷終止正常的處理器迴圈然後開始執行中斷處理程式中的程式碼。在開始中斷處理程式之前,處理器儲存暫存器,這樣在作業系統從中斷中返回時就可以恢復他們。切換到中斷服務程式面臨的問題是處理器需要在使用者模式和核心模式之間切換。

咱們說說術語:雖然官方的 x86 術語是中斷,xv6 都用陷入來代表他們,很大程度上是因為這個術語被 PDP11/40 使用,從而也是傳統的 Unix 術語。這一章交替使用陷入和中斷這兩個術語,但一定要記住陷入是由在 cpu 上執行的當前程序導致的,而中斷是由裝置導致的,可能與當前程序毫無關係。比如說,磁碟可能在接受了一個程序的資料塊之後發出一箇中斷,但是在中斷的時候可能執行的是其他程序。中斷的這一特性使得思考中斷的相關問題比陷入要難,因為中斷和其它活動是並行的。然而正如我們馬上就要討論的,他們都依賴相同的硬體機制在使用者模式和核心模式之間進行切換。

X86 的保護機制

x86 有四個特權級,從 0(特權最高)編號到 3(特權最低)。在實際使用中,大多數的作業系統都使用兩個特權級,0 和 3,他們被稱為核心模式和使用者模式。當前執行指令的特權級存在於 %cs 暫存器中的 CPL 域中。

在 x86 中,中斷處理程式的入口在中斷描述符表(IDT)中被定義。這個表有256個表項,每一個都提供了相應的 %cs 和 %eip。

一個程式要在 x86 上進行一個系統呼叫,它需要呼叫 int n 指令,這裡 n 就是 IDT 的索引。int 指令進行下面一些步驟:

  • 從 IDT 中獲得第 n 個描述符,n 就是 int 的引數。
  • 檢查 %cs 的域 CPL <= DPL,DPL 是描述符中記錄的特權級。
  • 如果目標段選擇符的 PL < CPL,就在 CPU 內部的暫存器中儲存 %esp 和 %ss 的值。
  • 從一個任務段描述符中載入 %ss 和 %esp。
  • 將 %ss 壓棧。
  • 將 %esp 壓棧。
  • 將 %eflags 壓棧。
  • 將 %cs 壓棧。
  • 將 %eip 壓棧。
  • 清除 %eflags 的一些位。
  • 設定 %cs 和 %eip 為描述符中的值。

int 指令是一個非常複雜的指令,可能有人會問是不是所有的這些操作都是必要的。檢查 CPL <= DPL 使得核心可以禁止一些特權級系統呼叫。例如,如果使用者成功執行了 int 指令,那麼 DPL 必須是 3。如果使用者程式沒有合適的特權級,那麼 int 指令就會觸發 int 13,這是一個通用保護錯誤。再舉一個例子,int 指令不能使用使用者棧來儲存值,因為使用者可能還沒有建立一個合適的棧,因此硬體會使用任務段中指定的棧(這個棧在核心模式中建立)。

圖 3-1 展示了一個 int 指令之後的棧的情況,注意這是發生了特權級轉換(即描述符中的特權級比 CPL 中的特權級低的時候)棧的情況。如果這條指令沒有導致特權級轉換,x86 就不會儲存 %ss 和 %esp。在任何一種情況下,%eip 都指向中斷描述符表中指定的地址,這個地址的第一條指令就是將要執行的下一條指令,也是 int n 的中斷處理程式的第一條指令。作業系統應該實現這些中斷處理程式,之後我們會看到 xv6 幹了些什麼。

作業系統可以使用 iret 指令來從一個 int 指令中返回。它從棧中彈出 int 指令儲存的值,然後通過恢復儲存的 %eip 的值來繼續使用者程式的執行。

程式碼:第一個系統呼叫

第一章的最後在 initcode.S 中呼叫了一個系統呼叫。讓我們再看一遍(7713)。這個程序將 exec 所需的引數壓棧,然後把系統呼叫號存在 %eax 中。這個系統呼叫號和 syscalls 陣列中的條目匹配,(syscall 是一個函式指標的陣列)(3350)。我們需要設法使得 int 指令將處理器的狀態從使用者模式切換到核心模式,呼叫適當的核心函式(例如在這裡是 sys_exec),並且使核心可以取出 sys_exec 的引數。接下來的幾個小節將描述 xv6 是如何做到這一點的,你會發現我們可以用同樣的程式碼來實現中斷和異常。

程式碼:彙編陷入處理程式

xv6 必須設定硬體在遇到 int 指令時進行一些特殊的操作,這些操作會使處理器產生一箇中斷。x86 允許 256 個不同的中斷。中斷 0-31 被定義為軟體異常,比如除 0 錯誤和訪問非法的記憶體頁。xv6 將中斷號 32-63 對映給硬體中斷,並且用 64 作為系統呼叫的中斷號。

Tvinit (3067) 在 main 中被呼叫,它設定了 idt 表中的 256 個表項。中斷 i 被位於 vectors[i] 的程式碼處理。每一箇中斷處理程式的入口點都是不同的,因為 x86 並未把中斷號傳遞給中斷處理程式,使用 256 個不同的處理程式是區分這 256 種情況的唯一辦法。

Tvinit 處理 T_SYSCALL,使用者系統會呼叫 trap,特別地:它通過傳遞第二個引數值為 1 來指定這是一個陷阱門。陷阱門不會清除 FL 位,這使得在處理系統呼叫的時候也接受其他中斷。

同時也設定系統呼叫門的許可權為 DPL_USER,這使得使用者程式可以通過 int 指令產生一個內陷。xv6 不允許程序用 int 來產生其他中斷(比如裝置中斷);如果它們這麼做了,就會丟擲通用保護異常,也就是發出 13 號中斷。

當特權級從使用者模式向核心模式轉換時,核心不能使用使用者的棧,因為它可能不是有效的。使用者程序可能是惡意的或者包含了一些錯誤,使得使用者的 %esp 指向一個不是使用者記憶體的地方。xv6 會使得在內陷發生的時候進行一個棧切換,棧切換的方法是讓硬體從一個任務段描述符中讀出新的棧選擇符和一個新的 %esp 的值。函式 switchuvm(1773)把使用者程序的核心棧頂地址存入任務段描述符中。

當內陷發生時,處理器會做下面一些事。如果處理器在使用者模式下執行,它會從任務段描述符中載入 %esp 和 %ss,把老的 %ss 和 %esp 壓入新的棧中。如果處理器在核心模式下執行,上面的事件就不會發生。處理器接下來會把 %eflags,%cs,%eip 壓棧。對於某些內陷來說,處理器會壓入一個錯誤字。而後,處理器從相應 IDT 表項中載入新的 %eip 和 %cs。

xv6 使用一個 perl 指令碼(2950)來產生 IDT 表項指向的中斷處理函式入口點。每一個入口都會壓入一個錯誤碼(如果 CPU 沒有壓入的話),壓入中斷號,然後跳轉到 alltraps。

Alltraps(3004)繼續儲存處理器的暫存器:它壓入 %ds, %es, %fs, %gs, 以及通用暫存器(3005-3010)。這麼做使得核心棧上壓入一個 trapframe(中斷幀) 結構體,這個結構體包含了中斷髮生時處理器的暫存器狀態(參見圖3-2)。處理器負責壓入 %ss,%esp,%eflags,%cs 和 %eip。處理器或者中斷入口會壓入一個錯誤碼,而alltraps負責壓入剩餘的。中斷幀包含了所有處理器從當前程序的核心態恢復到使用者態需要的資訊,所以處理器可以恰如中斷開始時那樣繼續執行。回顧一下第二章,userinit通過手動建立中斷幀來達到這個目標(參見圖1-3)。

考慮第一個系統呼叫,被儲存的 %eip 是 int 指令下一條指令的地址。%cs 是使用者程式碼段選擇符。%eflags 是執行 int 指令時的 eflags 暫存器,alltraps 同時也儲存 %eax,它存有系統呼叫號,核心在之後會使用到它。

現在使用者態的暫存器都儲存了,alltraps 可以完成對處理器的設定並開始執行核心的 C 程式碼。處理器在進入中斷處理程式之前設定選擇符 %cs 和 %ss;alltraps 設定 %ds 和 %es(3013-3015)。它設定 %fs 和 %gs 來指向 SEG_KCPU(每個 CPU 資料段選擇符)(3016-3018)。

一旦段設定好了,alltraps 就可以呼叫 C 中斷處理程式 trap 了。它壓入 %esp 作為 trap 的引數,%esp 指向剛在棧上建立好的中斷幀(3021)。然後它呼叫 trap(3022)。trap 返回後,alltraps 彈出棧上的引數(3023)然後執行標號為 trapret 處的程式碼。我們在第二章闡述第一個使用者程序的時候跟蹤分析了這段程式碼,在那裡第一個使用者程序通過執行 trapret 處的程式碼來退出到使用者空間。同樣地事情在這裡也發生:彈出中斷幀會恢復使用者模式下的暫存器,然後執行 iret 會跳回到使用者空間。

現在我們討論的是發生在使用者模式下的中斷,但是中斷也可能發生在核心模式下。在那種情況下硬體不需要進行棧轉換,也不需要儲存棧指標或棧的段選擇符;除此之外的別的步驟都和發生在使用者模式下的中斷一樣,執行的 xv6 中斷處理程式的程式碼也是一樣的。而 iret 會恢復了一個核心模式下的 %cs,處理器也會繼續在核心模式下執行。

程式碼:C 中斷處理程式

我們在上一節中看到每一個處理程式會建立一箇中斷幀然後呼叫 C 函式 trap。trap(3101)檢視硬體中斷號 tf->trapno 來判斷自己為什麼被呼叫以及應該做些什麼。如果中斷是 T_SYSCALL,trap 呼叫系統呼叫處理程式 syscall。我們會在第五章再來討論這裡的兩個 cp->killed 檢查。

當檢查完是否是系統呼叫,trap 會繼續檢查是否是硬體中斷(我們會在下面討論)。中斷可能來自硬體裝置的正常中斷,也可能來自異常的、未預料到的硬體中斷。

如果中斷不是一個系統呼叫也不是一個硬體主動引發的中斷,trap 就認為它是一個發生中斷前的一段程式碼中的錯誤行為導致的中斷(如除零錯誤)。如果產生中斷的程式碼來自使用者程式,xv6 就列印錯誤細節並且設定 cp->killed 使之待會被清除掉。我們會在第五章看看 xv6 是怎樣進行清除的。

如果是核心程式正在執行,那就出現了一個核心錯誤:trap 列印錯誤細節並且呼叫 panic。

程式碼:系統呼叫

對於系統呼叫,trap 呼叫 syscall(3375)。syscall 從中斷幀中讀出系統呼叫號,中斷幀也包括被儲存的 %eax,以及到系統呼叫函式表的索引。對第一個系統呼叫而言,%eax 儲存的是 SYS_exec(3207),並且 syscall 會呼叫第 SYS_exec 個系統呼叫函式表的表項,相應地也就呼叫了 sys_exec。

syscall 在 %eax 儲存系統呼叫函式的返回值。當 trap 返回使用者空間時,它會從 cp->tf 中載入其值到暫存器中。因此,當 exec 返回時,它會返回系統呼叫處理函式返回的返回值(3381)。系統呼叫按照慣例會在發生錯誤的時候返回一個小於 0 的數,成功執行時返回正數。如果系統呼叫號是非法的,syscall 會列印錯誤並且返回 -1。

之後的章節會講解系統呼叫的實現。這一章關心的是系統呼叫的機制。還有一點點的機制沒有說到:如何獲得系統呼叫的引數。工具函式 argint、argptr 和 argstr 獲得第 n 個系統呼叫引數,他們分別用於獲取整數,指標和字串起始地址。argint 利用使用者空間的 %esp 暫存器定位第 n 個引數:%esp 指向系統呼叫結束後的返回地址。引數就恰好在 %esp 之上(%esp+4)。因此第 n 個引數就在 %esp+4+4*n。

argint 呼叫 fetchint 從使用者記憶體地址讀取值到 *ip。fetchint 可以簡單地將這個地址直接轉換成一個指標,因為使用者和核心共享同一個頁表,但是核心必須檢驗這個指標的確指向的是使用者記憶體空間的一部分。核心已經設定好了頁表來保證本程序無法訪問它的私有地址以外的記憶體:如果一個使用者嘗試讀或者寫高於(包含)p->sz的地址,處理器會產生一個段中斷,這個中斷會殺死此程序,正如我們之前所見。但是現在,我們在核心態中執行,使用者提供的任何地址都是有權訪問的,因此必須要檢查這個地址是在 p->sz 之下的。

argptr 和 argint 的目標是相似的:它解析第 n 個系統呼叫引數。argptr 呼叫 argint 來把第 n 個引數當做是整數來獲取,然後把這個整數看做指標,檢查它的確指向的是使用者地址空間。注意 argptr 的原始碼中有兩次檢查。首先,使用者的棧指標在獲取引數的時候被檢查。然後這個獲取到得引數作為使用者指標又經過了一次檢查。

argstr 是最後一個用於獲取系統呼叫引數的函式。它將第 n 個系統呼叫引數解析為指標。它確保這個指標是一個 NUL 結尾的字串並且整個完整的字串都在使用者地址空間中。

系統呼叫的實現(例如,sysproc.c 和 sysfile.c)僅僅是封裝而已:他們用 argint,argptr 和 argstr 來解析引數,然後呼叫真正的實現。在第二章,sys_exec 利用這些函式來獲取引數。

程式碼:中斷

主機板上的裝置可以產生中斷,xv6 必須配置硬體來處理這些中斷。沒有硬體的支援 xv6 不可能正常使用起來:使用者不能夠用鍵盤輸入,沒有一個能夠儲存資料的檔案系統等等。幸運的是,新增一些簡單裝置的中斷並不會增加太多額外的複雜性。正如我們將會見到的,中斷可以使用與系統呼叫和異常處理相同的程式碼。

中斷和系統呼叫相似,除了它可以在任何時候產生。主機板上的硬體能夠在需要的時候向 CPU 發出訊號(例如使用者在鍵盤上輸入了一個字元)。我們得對裝置程式設計來產生一箇中斷,然後令 CPU 接受它們的中斷。

我們來看一看分時硬體和時鐘中斷。我們希望分時硬體大約以每秒 100 次的速度產生一箇中斷,這樣核心就可以對程序進行時鐘分片。100 次每秒的速度足以提供良好的互動效能並且同時不會使處理器進入不斷的中斷處理中。

像 x86 處理器一樣,PC 主機板也在進步,並且提供中斷的方式也在進步。早期的主機板有一個簡單的可程式設計中斷控制器(被稱作 PIC),你可以在 picirq.c 中找到管理它的程式碼。

隨著多核處理器主機板的出現,需要一種新的處理中斷的方式,因為每一顆 CPU 都需要一箇中斷控制器來處理髮送給它的中斷,而且也得有一個方法來分發中斷。這一方式包括兩個部分:第一個部分是在 I/O 系統中的(IO APIC,ioapic.c),另一部分是關聯在每一個處理器上的(區域性 APIC,lapic.c)。xv6 是為搭載多核處理器的主機板設計的,每一個處理器都需要程式設計接受中斷。

為了在單核處理器上也能夠正常執行,xv6 也為 PIC 程式設計(6932)。每一個 PIC 可以處理最多 8 箇中斷(裝置)並且將他們接到處理器的中斷引腳上。為了支援多於八個硬體,PIC 可以進行級聯,典型的主機板至少有兩集級聯。使用 inb 和 outb 指令,xv6 配置主 PIC 產生 IRQ 0 到 7,從 PIC 產生 IRQ 8 到 16。最初 xv6 配置 PIC 遮蔽所有中斷。timer.c 中的程式碼設定時鐘 1 並且使能 PIC 上相應的中斷(7574)。這樣的說法忽略了編寫 PIC 的一些細節。這些 PIC(也包括 IOAPIC 和 LAPIC)的細節對本書來說並不重要,但是感興趣的讀者可以參考 xv6 原始碼引用的各裝置的手冊。

在多核處理器上,xv6 必須編寫 IOAPIC 和每一個處理器的 LAPIC。IO APIC 維護了一張表,處理器可以通過記憶體對映 I/O 寫這個表的表項,而非使用 inb 和 outb 指令。在初始化的過程中,xv6 將第 0 號中斷對映到 IRQ 0,以此類推,然後把它們都遮蔽掉。不同的裝置自己開啟自己的中斷,並且同時指定哪一個處理器接受這個中斷。舉例來說,xv6 將鍵盤中斷分發到處理器 0(7516)。將磁碟中斷分發到編號最大的處理器,你們將在下面看到。

時鐘晶片是在 LAPIC 中的,所以每一個處理器可以獨立地接收時鐘中斷。xv6 在 lapicinit(6651)中設定它。關鍵的一行程式碼是 timer(6664)中的程式碼,這行程式碼告訴 LAPIC 週期性地在 IRQ_TIMER(也就是 IRQ 0) 產生中斷。第 6693 行開啟 CPU 的 LAPIC 的中斷,這使得 LAPIC 能夠將中斷傳遞給本地處理器。

處理器可以通過設定 eflags 暫存器中的 IF 位來控制自己是否想要收到中斷。指令 cli 通過清除 IF 位來遮蔽中斷,而 sti 又開啟一箇中斷。xv6 在啟動主 cpu(8412)和其他 cpu(1126)時遮蔽中斷。每個處理器的排程器開啟中斷(2464)。為了控制一些特殊的程式碼片段不被中斷,xv6 在進入這些程式碼片段之前關中斷(例如 switchuvm(1773))。

xv6 在 idtinit(1265)中設定時鐘中斷觸發中斷向量 32(xv6 使用它來處理 IRQ 0)。中斷向量 32 和中斷向量 64(用於實現系統呼叫)的唯一區別就是 32 是一箇中斷門,而 64 是一個陷阱門。中斷門會清除 IF,所以被中斷的處理器在處理當前中斷的時候不會接受其他中斷。從這兒開始直到 trap 為止,中斷執行和系統呼叫或異常處理相同的程式碼——建立中斷幀。

當因時鐘中斷而呼叫 trap 時,trap 只完成兩個任務:遞增時鐘變數的值(3063),並且呼叫 wakeup。我們將在第 5 章看到後者可能會使得中斷返回到一個不同的程序。

驅動程式

驅動程式是作業系統中用於管理某個裝置的程式碼:它提供裝置相關的中斷處理程式,操縱裝置完成操作,操縱裝置產生中斷,等等。驅動程式可能會非常難寫,因為它和它管理的裝置同時在併發地執行著。另外,驅動程式必須要理解裝置的介面(例如,哪一個 I/O 埠是做什麼的),而裝置的介面又有可能非常複雜並且文件稀缺。

xv6 的硬碟驅動程式給我們提供了一個良好的例子。磁碟驅動程式從磁碟上拷出和拷入資料。磁碟硬體一般將磁碟上的資料表示為一系列的 512 位元組的塊(亦稱扇區):扇區 0 是最初的 512 位元組,扇區 1 是下一個,以此類推。為了表示磁碟扇區,作業系統也有一個數據結構與之對應。這個結構中儲存的資料往往和磁碟上的不同步:可能還沒有從磁碟中讀出(磁碟正在讀資料但是還沒有完全讀出),或者它可能已經被更新但還沒有寫出到磁碟。磁碟驅動程式必須保證 xv6 的其他部分不會因為不同步的問題而產生錯誤。

程式碼:磁碟驅動程式

通過 IDE 裝置可以訪問連線到 PC 標準 IDE 控制器上的磁碟。IDE 現在不如 SCSI 和 SATA 流行,但是它的介面比較簡單使得我們可以專注於驅動程式的整體結構而不是硬體的某個特別部分的細節。

磁碟驅動程式用結構體 buf(稱為緩衝區)(3500)來表示一個磁碟扇區。每一個緩衝區表示磁碟裝置上的一個扇區。域 dev 和 sector 給出了裝置號和扇區號,域 data 是該磁碟扇區資料的記憶體中的拷貝。

域 flags 記錄了記憶體和磁碟的聯絡:B_VALID 位代表資料已經被讀入,B_DIRTY 位代表資料需要被寫出。B_BUSY 位是一個鎖;它代表某個程序正在使用這個緩衝區,其他程序必須等待。當一個緩衝區的 B_BUSY 位被設定,我們稱這個緩衝區被鎖住。

核心在啟動時通過呼叫 main(1234)中的 ideinit(3851)初始化磁碟驅動程式。ideinit 呼叫 picenable 和 ioapicenable 來開啟 IDE_IRQ 中斷(3856-3857)。呼叫 picenable 開啟單處理器的中斷;ioapicenable 開啟多處理器的中斷,但只是開啟最後一個 CPU 的中斷(ncpu-1):在一個雙處理器系統上,CPU 1 專門處理磁碟中斷。

接下來,ideinit 檢查磁碟硬體。它最初呼叫 idewait(3858)來等待磁碟接受命令。PC 主機板通過 I/O 埠 0x1f7 來表示磁碟硬體的狀態位。idewait(3833)獲取狀態位,直到 busy 位(IDE_BSY)被清除,以及 ready 位(IDE_DRDY)被設定。

現在磁碟控制器已經就緒,ideinit 可以檢查有多少磁碟。它假設磁碟 0 是存在的,因為啟動載入器和核心都是從磁碟 0 載入的,但它必須檢查磁碟 1。它通過寫 I/O 埠 0x1f6 來選擇磁碟 1 然後等待一段時間,獲取狀態位來檢視磁碟是否就緒(3860-3867)。如果不就緒,ideinit 認為磁碟不存在。

ideinit 之後,就只能通過塊高速緩衝(buffer cache)呼叫 iderw,iderw 根據標誌位更新一個鎖住的緩衝區。如果 B_DIRTY 被設定,iderw 將緩衝區的內容寫到磁碟;如果 B_VALID 沒有被設定,iderw 從磁碟中讀出資料到緩衝區。

磁碟訪問耗時在毫秒級,對於處理器來說是很漫長的。引導載入器發出磁碟讀命令並反覆讀磁碟狀態位直到資料就緒。這種輪詢或者忙等待的方法對於引導載入器來說是可以接受的,因為沒有更好的事兒可做。但是在作業系統中,更有效的方法是讓其他程序佔有 CPU 並且在磁碟操作完成時接受一箇中斷。iderw 採用的就是後一種方法,維護一個等待中的磁碟請求佇列,然後用中斷來指明哪一個請求已經完成。雖然 iderw 維護了一個請求的佇列,簡單的 IDE 磁碟控制器每次只能處理一個操作。磁碟驅動程式的原則是:它已將隊首的緩衝區送至磁碟硬體;其他的只是在等待他們被處理。

iderw(3954)將緩衝區 b 送到佇列的末尾(3967-3971)。如果這個緩衝區在隊首,iderw 通過 idestart 將它送到磁碟上(3924-3926);在其他情況下,一個緩衝區被開始處理當且僅當它前面的緩衝區被處理完畢。

idestart 發出關於緩衝區所在裝置和扇區的讀或者寫操作,根據標誌位的情況不同。如果操作是一個寫操作,idestart 必須提供資料(3889)而在寫出到磁碟完成後會發出一箇中斷。如果操作是一個讀操作,則發出一個代表資料就緒的中斷,然後中斷處理程式會讀出資料。注意 iderw 有一些關於 IDE 裝置的細節,並且在幾個特殊的埠進行讀寫。如果任何一個 outb 語句錯誤了,IDE 就會做一些我們意料之外的事。保證這些細節正確也是寫裝置驅動程式的一大挑戰。

iderw 已經將請求新增到了佇列中,並且會在必要的時候開始處理,iderw 還必須等待結果。就像我們之前討論的,輪詢並不是有效的利用 CPU 的辦法。相反,iderw 睡眠,等待中斷處理程式在操作完成時更新緩衝區的標誌位(3978-3979)。當這個程序睡眠時,xv6 會排程其他程序來保持 CPU 處於工作狀態。

最終,磁碟會完成自己的操作並且觸發一箇中斷。trap 會呼叫 ideintr 來處理它(3124)。ideintr(3902)查詢佇列中的第一個緩衝區,看正在發生什麼操作。如果該緩衝區正在被讀入並且磁碟控制器有資料在等待,ideintr 就會呼叫 insl 將資料讀入緩衝區(3915-3917)。現在緩衝區已經就緒了:ideintr 設定 B_VALID,清除 B_DIRTY,喚醒任何一個睡眠在這個緩衝區上的程序(3919-3922)。最終,ideintr 將下一個等待中的緩衝區傳遞給磁碟(3924-3926)。

實際情況

想要完美的支援所有的裝置需要投入大量的工作,這是因為各種各樣的裝置有各種各樣的特性,裝置和驅動之間的協議有時會很複雜。在很多作業系統當中,各種驅動合起來的程式碼數量要比系統核心的數量更多。

實際的裝置驅動遠比這一章的磁碟驅動要複雜的多,但是他們的基本思想是一樣的:裝置通常比 CPU 慢,所以硬體必須使用中斷來提醒系統它的狀態發生了改變。現代磁碟控制器一般在同一時間接受多個未完成的磁碟請求,甚至重排這些請求使得磁碟使用可以得到更高的效率。當磁碟沒有這項功能時,作業系統經常負責重排請求佇列。

很多作業系統可以驅動固態硬碟,因為固態硬碟提供了更快的資料訪問速度。雖然固態硬碟和傳統的機械硬碟的工作機制很不一樣,但是這兩種裝置都使用了基於塊的介面,在固態硬碟上讀寫塊仍然要比在記憶體在讀寫成本高的多。

其他硬體和磁碟非常的相似:網路裝置緩衝區儲存包,音訊裝置緩衝區儲存音訊取樣,視訊記憶體儲存影象資料和指令序列。高頻寬的裝置,如硬碟,顯示卡和網絡卡在驅動中同樣都使用直接記憶體訪問(Direct memory access, DMA)而不是直接用 I/O(insl, outsl)。DMA 允許磁碟控制器或者其他控制器直接訪問實體記憶體。驅動給予裝置快取資料區域的實體地址,可以讓裝置直接地從主存中讀取或者寫入,一旦複製完成就發出中斷。使用 DMA 意味著 CPU 不直接參與傳輸,這樣做可以提高CPU工作效率,並且 CPU Cache 開銷也更小。

在這一章中的絕大多數裝置使用了 I/O 指令來進行程式設計,但這都是針對老裝置的了。而所有現代裝置都使用記憶體對映 I/O (Memory-mapped I/O)來進行程式設計。

有些裝置動態地在輪詢模式和中斷模式之間切換,因為使用中斷的成本很高,但是在驅動去處理一個事件之前,使用輪詢會導致延遲。舉個例子,對於一個收到大量包的網路裝置來說,可能會從中斷模式到輪詢模式之間切換,因為它知道會到來更多的包被處理,使用輪詢會降低處理它們的成本。一旦沒有更多的包需要處理了,驅動可能就會切換回中斷模式,使得當有新的包到來的時候能夠被立刻通知。

IDE 硬碟的驅動靜態的傳送中斷到一個特定的處理器上。有些驅動使用了複雜的演算法來發送中斷,使得處理負載均衡,並且達到良好的區域性性。例如,一個網路驅動程式可能為一個網路連線的包向處理這個連線的處理器傳送一箇中斷,而來自其他連線的包的中斷髮送給另外的處理器。這種分配方式很複雜;例如,如果有某些網路連線的活動時間很短,但是其他的網路連線卻很長,這時候作業系統就要保持所有的處理器都工作來獲得一個高的吞吐量。

使用者在讀一個檔案的時候,這個檔案的資料將會被拷貝兩次。第一次是由驅動從硬碟拷貝到核心記憶體,之後通過 read 系統呼叫,從核心記憶體拷貝到使用者記憶體。同理當在網路上傳送資料的時候,資料也是被拷貝了兩次:先是從使用者記憶體到核心空間,然後是從核心空間拷貝到網路裝置。對於很多程式來說低延遲是相當重要的(比如說 Web 伺服器服務一個靜態頁面),作業系統使用了一些特別的程式碼來避免這種多次拷貝。一個在真實世界中的例子就是緩衝區大小通常是符合記憶體頁大小的,這使得只讀的資料拷貝可以直接通過分頁對映到程序的地址空間,而不用任何的複製。