XV6學習(8)中斷和設備驅動
驅動是作業系統中用於管理特定設備的程式碼:驅動控制設備硬體,通知硬體執行操作,處理中斷,與等待該設備IO的進程進行交互。
當設備需要與作業系統進行交互時,就會產生中斷(陷阱的一種),之後內核的陷阱處理程式碼就會識別中斷設備並調用對應的驅動處理程式。在XV6這一步發生在trap.c
的devintr
中。
大部分設備驅動在兩個上下文中執行程式碼:頂層部分運行在進程的內核執行緒中,底層部分在中斷處理時執行。頂層部分通過系統調用如read
和write
來調用,這一部分程式碼會請求硬體開始一個操作的執行(如請求硬碟讀取塊);之後就會進入等待狀態等待操作的完成。當設備完成操作後,就會觸發一個中斷,驅動的中斷處理程式,即底層部分就會判斷完成的操作,喚醒對應的正在等待的進程,之後通知硬體執行下一個操作。
程式碼:控制台輸入
控制台的驅動程式console.c
是一個驅動結構的簡單抽象。控制台驅動通過UART(Universal asynchronous receiver-transmitter,通用非同步收發傳輸器)串口讀取用戶輸入的字元。驅動程式一次會累積一行的輸入,並處理特定的字元如退格和ctrl-u。用戶進程通過read
系統調用來獲取一行輸入。
驅動調用的UART硬體是由QEMU模擬的16550晶片,一個16550晶片可以管理一條連接到終端或其他電腦的RS232串列鏈路。在QEMU中,其連接到鍵盤和顯示器。
UART硬體可以看作一組映射到記憶體中的控制暫存器,對硬體的控制可以直接通過load
和store
特定記憶體來完成。UART記憶體映射地址開始於0x10000000
或UART0
(定義於memlayout.h
)。每個控制暫存器的大小為1byte,偏移量定義於uart.c
。
XV6main
函數中的consoleinit
對UART設備進行初始化,設置UART設備每接收一個位元組的輸入就產生一個接收中斷,每當完成一個位元組輸出的發送時就產生一個傳輸完成中斷。
XV6 shell通過init.c
中打開的文件描述符對控制台進行讀取。read
系統調用將會調用consoleread
函數,該函數等待輸入的到達(通過中斷)並保持在cons.buf
中,拷貝其到用戶空間,當一整行接收完成後返回到用戶進程中。如果沒有一整行輸入到達,read進程就會在sleep
調用中等待。
當用戶輸入一個字元,UART設備就會產生一個中斷,激活XV6的陷阱處理程式。陷阱處理程式將會調用devintr
,讀取scause
判斷是否為外部設備產生的中斷。之後通過PLIC(平台級中斷控制器)判斷中斷設備,如果是UART設備,就會調用uartintr
函數。
uartinit
從UART設備中讀取所有輸入字元,並將其交給consoleintr
處理,此函數不會等待字元的輸入,因為未來的輸入會產生新的中斷。consoleintr
將輸入保持在buffer中直到一整行到達,同時對一些特殊符號進行處理。當一整行到達後,就會喚醒一個正在等待的consoleread
。
當consoleread
被喚醒時,buffer中就保存了完整的一行輸入,此時就可以將其拷貝到用戶空間並返回。
程式碼:控制台輸出
write
系統調用對控制台的寫入最終會調用uartputc
函數,設備會維護輸出緩衝uart_tx_buf
,因此寫進程不需要等待UART完成發送。uartputc
將字元加入緩衝區後,調用uartstart
函數開始傳輸之後返回,該函數唯一的等待情況是緩衝區已滿。
每當UART發送一個位元組後,就會產生一次中斷。uartintr
函數會調用uartstart
函數判斷傳輸是否完成,未完成就開始傳輸下一個緩衝的字元。因此,當進程寫入多個字元時,第一個位元組會通過uartputc
調用uartstart
進行傳輸,之後的位元組將會被uartintr
調用的uartstart
進行傳輸。
對於設備活動和進程活動,常用的解耦方式是通過緩衝和中斷。控制台驅動可以處理輸入即使沒有進程在等待讀取,一個隨後到來的read
會讀取到輸入。類似的,進程可以進行輸出而不需要等待設備響應。解耦可以允許進程並行執行設備IO從而提高性能,尤其是當設備速度很慢或需要立即進行響應(如輸入一個字元)。這種思想也被稱作I/O並行。
驅動中的並行
在consoleread
和consoleintr
中會調用acquire
函數。這些調用會申請一個鎖,用於在並行訪問中保護驅動的數據結構。在這裡有三種並行風險:兩個不同CPU上的進程同時調用consoleread
;當CPU在執行consoleread
函數時硬體觸發了一個中斷;當consoleread
在執行時,硬體在其他CPU上觸發了一個中斷。
在並行中需要關注的另一個點是一個進程可能會等待設備的輸入,但是中斷訊號在另一個進程運行時產生,因此中斷處理程式是必須上下文無關的(不允許考慮中斷時的進程或程式碼)。例如中斷處理程式不能安全地在當前進程地頁表上調用copyout
函數。中斷處理程式應該僅執行上下文無關的工作(如拷貝輸入到緩衝區),之後喚醒頂層部分來處理剩餘工作。
定時器中斷
XV6通過定時器中斷來維護時鐘以及進行進程切換;在usertrap
和kerneltrap
中的yield
函數會執行進程切換。定時器中斷會由RISC-V CPU內部的時鐘硬體產生。XV6對此時鐘硬體進行編程以定期中斷每個CPU。
RISC-V要求定時器中斷必須在機器模式下執行,而不是在監管模式下執行。RISC-V的機器模式在無分頁環境下執行,並且具有一系列單獨的控制暫存器,因此在機器模式下運行普通的 xv6 內核程式碼是不切實際的。所有XV6的定時器中斷處理程式是和陷阱機制完全分開的。
start.c
中的程式碼執行於機器模式中,main
函數之前,在timerinit
函數中對定時器中斷進行了設置:對CLINT硬體編程使其在一定時間後產生一次中斷;設置scratch區域(類似於trapframe),幫助定時器中斷處理程式保存暫存器和CLINT暫存器的地址。最後函數會設置mtvec
為timervec
函數地址並開啟定時器中斷。
定時器中斷會在任何時候發生,內核在執行關鍵操作時也無法禁用定時器中斷。因此定時器中斷處理程式必須保證不會干擾被中斷的內核程式碼執行。處理程式最基本的策略就是產生一個軟體中斷之後立即返回。產生的軟體中斷就可以通過通用的陷阱機制進行處理,並且可以進行關閉。軟體中斷的處理程式在devintr
函數中。
機器模式的時鐘中斷向量為timervec
,該函數保存了三個暫存器在start
函數準備的scratch
區域中,通知CLINT下一個中斷的時刻,通過csrw sip, a1
(a1
為2)指令觸發一個軟體中斷,最後恢復暫存器並返回。
真實作業系統
XV6運行設備和時鐘中斷在內核執行時產生。定時器中斷在中斷處理程式中強制執行緒切換,即使是在內核態執行中。這個功能可以使得內核執行緒公平地獲取CPU時間片,尤其是當內核執行緒耗費大量時間進行計算而不返回用戶態。但是,這使得內核程式碼需要考慮到其可能會被暫停並在一段時間後再另一個CPU上恢復,而這給XV6帶來了一定的複雜性。如果設備中斷和定時器中斷只在用戶程式碼執行時運行觸發,內核可以變得更加簡單。
在許多作業系統中,驅動程式的程式碼量遠遠大於內核本身。要支援所有設備在電腦上運行是十分繁雜的工作:有大量設備需要支援,設備有很多特性,設備間的協議十分複雜並且缺少文檔。
UART設備通過讀取控制暫存器一次接收一個位元組數據,這種模式稱為程式I/O(programmed I/O),因為數據移動由軟體驅動。這種方式十分簡單但是在高速設備上是十分緩慢的。高速設備通常通過DMA方式來進行數據傳輸。DMA設備硬體可以直接對記憶體進行讀寫,現代硬碟和網路設備就是通過這種方式進行的。DMA設備驅動會在記憶體中準備數據,之後通過一次控制暫存器的寫入告訴設備對準備好的數據進行處理。
當設備需要在無法預知但不太頻繁的時間上需要進行處理時,中斷是有效的。但是中斷有很大的CPU開銷。因此高速設備會使用一些技巧來減少中斷次數。一個技巧就是對一整批的輸入或輸出請求發起一次中斷。另一個是驅動完全禁用中斷,轉為定時查詢設備是否需要處理,這種技術被稱為輪詢(polling)。如果設備操作執行非常頻繁,那麼輪詢是有意義的,反之如果設備大部分時間都是空閑的,那麼輪詢會浪費CPU時間。一些驅動會根據設備負載自動切換輪詢和中斷。
UART驅動先拷貝輸入數據到內核緩衝區,之後再拷貝到用戶空間。這在低數據傳輸率的情況下是有效的,但是對於高速設備,兩次拷貝會顯著地降低性能。一些作業系統可以直接將數據在用戶態緩衝區和設備硬體之間移動,通常是通過DMA。