JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

與裝置對話:控制器、埠與暫存器

鍵盤、磁碟、網路卡、螢幕——CPU 從來不直接碰它們任何一個。它透過少少幾個特別的暫存器,跟一塊小小的中間人晶片,也就是控制器對話,而整個 I/O 系統就建立在這場對話之上。這篇導覽會把這場對話拆開來,並展示資料實際跨越鴻溝的三種方式。

CPU 從不碰到裝置本身

沿著這座階梯一路爬上來,我們一直跟著 CPU:看它執行行程、在行程間切換、調度記憶體、把分頁從磁碟錯入。但那每一個故事,都悄悄假設了一件我們從未打開來看的事:這台機器其實能夠跟外面的世界對話——讀取你按下的一個鍵、畫出一個像素、從碟片上拉一個區塊、送出一個封包。這一階講的,正是這場對話。而第一個令人意外的事實是:CPU 根本從不直接碰到鍵盤或磁碟。

在 CPU 與每個裝置之間,坐著一塊小小的專用晶片,叫做裝置控制器(device controller)——一個小小的專家,一面講著裝置自己的私房語言,另一面則提供一個乾淨、標準的電氣介面。把它想成某個部門的櫃檯接待員:你不會自己闖進倉庫,而是把一張單子交給櫃檯的接待員,由他去應付裡頭那一團亂。CPU、核心、還有你,永遠只跟接待員對話。所有這些控制器、它們掛上去的那些電線,以及它們背後的裝置,合起來就是我們所說的 I/O 硬體

為什麼要費事弄個中間人?因為各種裝置天差地別——一顆磁碟和一支麥克風,在實際搬移位元的方式上幾乎毫無共通之處——然而 CPU 想用同一種方式跟它們全部對話。控制器把這團混亂吸收掉。它處理裝置的類比時序、它古怪的訊號、它的脾氣,然後對 CPU 呈現出一組整整齊齊的信箱,叫做暫存器(register)。讀寫那些暫存器,「就是」CPU 跟裝置對話的方式。所以這篇導覽我們的整個任務,就是搞懂那些信箱:它們住在哪裡、裝著什麼,以及一道命令怎麼進去、一個結果怎麼出來。

四個小信箱:控制器的暫存器

把幾乎任何一塊控制器打開,你都會找到大致相同的四種暫存器,每一個都只是一小格 CPU 可以讀或寫的位元。資料輸入暫存器(data-in register)是 CPU 讀取裝置產生的位元組之處(一個鍵碼、你要的那個磁區)。資料輸出暫存器(data-out register)是 CPU 寫入裝置該消化的位元組之處(一個要印出的字元、一個要存的區塊)。狀態暫存器(status register)裝著一些旗標,CPU 讀它來問「你忙嗎?有資料在等嗎?有什麼壞掉了嗎?」而控制暫存器(control register)則是 CPU 寫入一道命令之處——「開始讀取」、「重置」、「啟用中斷」——好告訴裝置該做什麼。

   CPU / kernel                device controller            the device
  +-----------+              +-------------------+        +-----------+
  |  driver   |  --write-->  | [ control reg ]   | -----> | keyboard, |
  |  code     |              | [ status  reg ]   | <----- |  disk,    |
  |           |  <--read---  | [ data-in reg ]   |        |  NIC, ... |
  |           |  --write-->  | [ data-out reg ]  | -----> |           |
  +-----------+              +-------------------+        +-----------+
      ^  the CPU only ever pokes these 4 mailboxes;
         the controller does the messy real work on the right
整場 CPU 對裝置的對話,全部漏斗式地擠過控制器上少少幾個暫存器。驅動程式寫入命令、讀取狀態與資料;控制器再把那些翻譯成裝置真正的行為。

光靠這四個,一場完整的互動就已經可能。要送一個字元給印表機,CPU 可能會這樣做:在一個迴圈裡反覆讀狀態暫存器,直到「就緒」旗標被設起來,接著把字元寫進資料輸出暫存器,再把控制暫存器裡的「命令就緒」位元設起來,說一聲「上」。控制器接著在它工作期間把「就緒」旗標放掉,以它自己慢吞吞的步調印出那個字元,完成後再把「就緒」舉起來。四個信箱、一次簡單的握手,就驅動了一整台印表機。每一個裝置,不論多奇特,都是同一支舞的某種延伸。

暫存器住在哪?I/O 埠對上記憶體映射 I/O

現在來個真正的問題:那些暫存器,是攤在控制器晶片上一小格一小格的位元——那 CPU 的程式碼到底要怎麼稱呼它們、去讀去寫?答案有兩個,而大多數機器兩個都用。第一個,是一塊專門給裝置用的獨立位址空間,靠特別的指令來搆到。每個暫存器都拿到一個號碼,叫 I/O 埠,而 CPU 有專屬的「in」和「out」指令:大致是「讀埠 0x60」(經典的鍵盤資料埠)或「寫 0x3F8」。這些埠活在它們自己的小世界裡,跟你的程式所用的記憶體位址完全分開。

第二個答案更優雅,如今也佔了主流:記憶體映射 I/O。硬體不另開一塊埠空間,而是把每個控制器暫存器接線到一個普通的實體記憶體位址上。讀取位址 0xFEC00000 並不會碰到任何 RAM 晶片——它讀的是某個控制器的狀態暫存器;往那裡寫,戳的是它的控制暫存器。妙就妙在不需要任何特殊指令:CPU 本來就用來存取記憶體的那些普通載入(load)與儲存(store)指令,對裝置也一樣管用。驅動程式可以把一個控制暫存器當成一個變數來對待。這也是為什麼它能如此自然地,融進你兩階之前認識的位址翻譯機構裡。

真正搬動資料的三種方式

知道了怎麼讀寫一個控制器的暫存器之後,還有一個棘手的問題:CPU 要怎麼跟一個比它慢上百萬倍的裝置協調?CPU 一秒鐘執行十億條以上的指令;印表機都還在暖機。在整個這一階裡,你會遇到三種應付這件事的策略,一個比一個聰明——而這篇導覽接下來,就是把這三種快速逛一遍,好讓後面幾篇導覽有地方可以接。

第一種、也最簡單,是程式化 I/O 搭配輪詢(polling):CPU 坐在一個緊湊的迴圈裡,一遍又一遍讀狀態暫存器,問著「好了沒?好了沒?」,直到旗標翻過來,然後傳一個位元組,然後再為下一個位元組繞回去。它管用,而且簡單到不行,但對一個慢速裝置而言它是場災難——CPU 忙碌等待(busy-wait),燒掉好幾百萬個週期,卻只是在問。這就像站在微波爐前,整整三分鐘隔著玻璃盯著看,明明那時間你可以去洗碗。對一個幾乎總是就緒的裝置這還行;其他情況就是毀滅性的浪費。

解藥是別再問,改讓裝置拍拍你的肩膀。用中斷驅動 I/O 時,CPU 發出命令後就跑去執行別的行程;當裝置終於做完,它的控制器舉起一個中斷——一個電氣門鈴——CPU 就放下手邊的事,跳到一小段服務該裝置的處理常式,然後再回到它原本在跑的東西。不再忙碌等待了;CPU 在空檔裡做有用的工作。這就是幾乎所有真實 I/O 背後的機制,而這一階的第三篇導覽會鉅細靡遺地解剖這個門鈴:那個說明是誰按了鈴的向量、那個來應門的處理常式,以及它分成快速的上半部與延後的下半部。

但中斷驅動 I/O 裡仍藏著一筆代價:對一個又快又大量的裝置(像磁碟)來說,CPU 得親自把每一個位元組都透過資料暫存器搬一遍——每一塊都要一次中斷加一次複製,一個檔案就要上千次。所以第三招,把這份苦力交給另一塊專門的複製晶片。用直接記憶體存取(DMA)時,CPU 告訴 DMA 控制器「把 4 KB 從磁碟控制器搬到這個 RAM 位址」,然後就整個走人。DMA 引擎自己把一整塊直接挪進記憶體,最後才舉起唯一一個中斷,說「完成了」。CPU 從那種一個位元組一個位元組的苦工裡被解放出來;第二篇導覽會把輪詢、中斷、DMA 三者面對面地拿來比較。

軟體那一面:驅動程式,與一個適用於萬物的介面

這一切戳暫存器、處理中斷的活兒,都是裝置專屬的、瑣碎的,正是那種你絕不想讓它散落在整個核心各處的東西。所以作業系統把它隔離在一個裝置驅動程式(device driver)裡:一塊程式碼,每種控制器一份,它對那個特定裝置的暫存器與脾氣瞭若指掌,並向上層暴露一小組標準的操作——開啟、讀、寫、關閉。在驅動程式之上,坐著一個裝置無關 I/O 層,它只講那套標準詞彙,於是核心其餘部分可以發出 read(fd, buf, n),卻完全不必知道「fd」到底是磁碟、鍵盤,還是一個網路通訊端。

這跟你沿著整座階梯一路看到的,是同一招抽象手法——那個讓一個檔案、一條管線、一個裝置全都看起來像同一串位元組的檔案描述符,正是這一層在運作。為了讓這個統一介面好處理,核心把裝置歸成幾個大家族。一個區塊裝置(像磁碟)以固定大小、可定址、可在其中尋找位置的區塊來搬資料;一個字元裝置(像鍵盤或序列埠)則一次一個、依序地遞送一串位元組,不能尋找位置。兩個家族、少少幾個標準操作,忽然之間,成千上萬種天差地別的小玩意,全都套進同一組系統呼叫裡。

於是一個位元組從你按下一個鍵開始的旅程,如今首尾都看得清清楚楚:你按下一個鍵,鍵盤控制器把鍵碼鎖進它的資料暫存器、舉起一個中斷;CPU 跳進鍵盤驅動程式,它讀取資料暫存器、把那個位元組往上透過裝置無關層遞交;最終它從你程式裡一個 read(fd, buf, n) 呼叫浮出水面。底層是硬體、中間是一層薄薄的驅動程式架起鴻溝、頂端是一個統一介面——這一階接下來,不過就是依序把這每一個接縫各自放大來看罷了。