驅動是操作系統中用於管理特定設備的代碼:驅動控制設備硬件,通知硬件執行操作,處理中斷,與等待該設備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。