到底怎麼跟一個裝置說話
上一篇導覽把中央處理器和一個裝置留在那裡,隔著像 PCIe 這樣的互連互相對望,原則上能交換位元組。可是處理器只會做寥寥幾件事——抓一條指令、讀一個暫存器、載入與儲存記憶體。它沒有「從那顆磁碟讀一個磁區」這種與生俱來的動詞。那麼軟體要怎麼去命令一個它從沒被教過的硬體?訣竅,就是讓每一個裝置假裝自己是記憶體。
每一個裝置都掛在一塊叫做裝置控制器(device controller)的小晶片上——一個小小的專家,真正懂得怎麼轉動馬達、閃爍 LED,或把位元推上一條線。控制器對外露出寥寥幾個暫存器:一個命令暫存器、一個狀態暫存器、一個資料暫存器,有時還有一對位址與計數。靠記憶體映射輸入輸出(memory-mapped I/O),作業系統把那些控制器暫存器映射進普通的實體位址。如今一個對位址 0x4000_0010 的尋常 `store`,根本不是寫進 RAM——它是給磁碟控制器的一道命令,而對鄰近位址的一個 `load` 則讀回它的狀態。CPU 繼續用它僅有的那幾個動詞;位址匯流排則默默把它們導向一個裝置,而不是記憶體。
輪詢:中央處理器站著乾等
驅動一個裝置最簡單的方式,是輪詢(polling),也叫忙等。中央處理器把一道命令寫進控制器的命令暫存器,然後坐在一個緊湊的迴圈裡,一遍又一遍地讀狀態暫存器——好了沒?好了沒?好了沒?——直到一個就緒位元翻轉。想像你站在微波爐前盯著計時器一秒一秒倒數,在它「叮」之前什麼別的都做不了。輪詢簡單到不能再簡單,而且對一個快速事件能用最低的延遲察覺到,這正是為什麼它在某些要求它的地方一直活著。
可是對一個慢吞吞的裝置,輪詢是一場災難。回想本節第一篇導覽說過輸入輸出有多慢:一顆磁碟要花毫秒才送出一個磁區,而一顆現代 CPU 在一毫秒裡能跑數十億個週期。如果處理器整段時間都在一個狀態迴圈裡空轉,它就把那數十億個週期燒在反覆讀同一個「還沒好」的位元上,做了零有用的工作,白白把晶片烤熱。每一輪迴圈本身就是一個完整的指令週期——抓那個載入、執行它、抓那個分支、跳過去——重複幾百萬次,只為了得知一個位元的消息。輪詢花掉的是 CPU 最寶貴的資源,它的週期,換來的卻幾乎什麼都不是。
中斷:別來找我們,我們會找你
修正之道,是把這段關係翻轉過來。與其讓 CPU 反覆去問裝置,不如讓裝置在自己就緒時告訴 CPU。這就是中斷(interrupt):裝置控制器拉高一條訊號線,硬體於是強迫 CPU 暫停手上正在做的事,跳到一段特別的處理常式,把裝置料理好,然後回到被中斷的程式、從它離開的那一處原封不動地接續下去。如今 CPU 可以發出磁碟命令、轉去跑別的工作——另一個程式、作業系統其餘的部分——只有在資料真正抵達時才被拉回來。那台做好就「叮」一聲、你趁機去洗碗的微波爐,正是整個想法的一張圖。
- 中央處理器把讀取命令與目標磁區寫進磁碟控制器的暫存器,然後回去跑別的程式。它不等。
- 幾毫秒後,控制器把資料備妥,拉起它的中斷線。CPU 做完當下這條指令,便自動把自己的位置存起來——至少是程式計數器——並切換到核心的中斷處理常式。
- 處理常式讀狀態暫存器,把剛抵達的位元組從資料暫存器複製進記憶體,清掉裝置的就緒旗標,並把那個等待中的程式重新標記為可執行。
- 處理常式回返;CPU 還原存起來的位置,接續被中斷的程式,彷彿什麼都沒發生。那段昂貴的等待沒花掉 CPU 一分一毫——它整段時間都在別處忙著。
對於緩慢而偶發的事件,中斷是一個巨大的勝利,但它並不免費。每一次中斷都強迫一次情境切換——存起又還原暫存器、跳進核心再跳回來——這要花上數百到數千個週期的額外開銷。對一個很少觸發事件的裝置(一次按鍵、一次磁碟尋軌完成),那點開銷比起它省下的等待微不足道。可是對一個傾瀉資料如洪流的裝置,每一個位元組一次中斷,會把 CPU 淹死在處理常式的開銷裡。這正是我們最後一個機制要補上的缺口,也正是為什麼高速率的裝置有時會刻意退回輪詢:當事件幾乎連綿不斷時,忙等其實勝過為每一個事件都付中斷開銷。
DMA:把粗活整個交辦出去
中斷解決了等待的問題,但一個更微妙的浪費還在。即使有了中斷,當一個磁區抵達時,CPU 仍得把每一個位元組從控制器的資料暫存器複製進記憶體,一次一個載入-儲存。用那種方式搬一個 4 KB 的區塊,CPU 就要執行成千上萬條指令,純粹在剷位元組——是有用的工作,但卻是處理器大材小用的無腦工作。直接記憶體存取(direct memory access,DMA)把 CPU 從複製裡整個拿掉。一個小小的 DMA 引擎,通常就內建在裝置控制器本身裡頭,被交給一個記憶體位址和一個位元組計數,便沿著輸入輸出路徑、直接在裝置與主記憶體之間搬運資料,每個位元組都不需要 CPU 插手。
如今分工美得很。CPU 設定一次傳輸——「把磁碟上的 4096 個位元組放到記憶體位址 X」——便回去做真正的工作。DMA 引擎自己做那逐位元組的苦工,只在爭奪記憶體匯流排週期時短暫地與 CPU 角力。而當整段傳輸完成時,控制器拉起單獨一次中斷,說「你的區塊已經在記憶體裡了」。一道命令出去,一次中斷回來,中間那成千上萬次位元組複製,沒花掉處理器一分一毫。DMA 加上中斷,正是今天每一顆真實的磁碟、每一張網路卡、每一顆 GPU 搬運大批資料的方式。
Reading 4 KB from disk -- cost to the CPU
Polling CPU spins ~millions of cycles reading
the status bit; ZERO other work done.
Interrupts CPU issues command, runs other work,
(no DMA) takes 1 interrupt -- then still copies
~4096 bytes by hand (thousands of loads).
Interrupts CPU issues command + DMA setup, runs
+ DMA other work, takes 1 interrupt at the end.
DMA engine moves all 4096 bytes itself.
CPU's bulk-copy cost: ~zero.
The trend: each rung hands more of the grunt work to
hardware, freeing the CPU to do what only it can do.在三者之間怎麼選
三者沒有誰單純是「最好」——每一種都在不同的處境裡勝出,而一個真實系統會同時三種都用。輪詢在事件迫在眉睫、你承受不起一次中斷情境切換的延遲時是對的,或在事件來得太快、處理常式開銷會喧賓奪主時是對的。中斷對緩慢或零星的事件是對的,這時讓 CPU 去做別的工作,輕易就抵還了每個事件的開銷。DMA 在負載龐大時永遠是對的,免去 CPU 淪為一個被美化的複製迴圈。一次現代的磁碟讀取把它們合在一起:作業系統設定一次 DMA 傳輸,CPU 去跑別的執行緒,當引擎結束時一次孤零零的中斷便觸發。
而這裡就是整節一直繞著打轉的那句老實話。即使中斷與 DMA 都使出渾身解數,裝置依然以毫秒之慢、對著 CPU 的奈秒之快——這些機制把等待藏起來,卻沒有把它廢除。對一個要讀大量資料的程式,是磁碟或網路、而不是處理器,定下了步調。這正是為什麼輸入輸出如此頻繁地成為真正的瓶頸,也正是為什麼下一篇導覽要轉向儲存裝置本身——會轉的硬碟,與固態硬碟——去弄清楚那些失落的毫秒究竟跑到哪裡去了。