中斷:無法保持禮貌的硬體
想像 CPU 正穩穩地跑著某個執行緒的程式碼,一條接一條地擷取並執行指令。PCI 匯流排上一張網路卡剛收到一個封包。它無法呼叫一個函式——它根本不知道你在跑什麼程式碼。它改為在一條實體線上拉高電壓,一個中斷請求(interrupt request,IRQ),這條線通到處理器的中斷控制器。CPU 把當前這條指令做完,然後停下它正在跑的執行緒、存下剛好夠回來用的狀態,再跳到一個固定的核心常式。這是你先前認識的那個家族的第三位成員——和陷阱與例外並列,中斷是非同步的那一個:它來自外部,發生在沒有任何指令要求它的時刻。
CPU 怎麼知道要跳到哪裡?每個中斷來源都有一個編號——它的向量——而處理器用那個編號當索引,去查一張核心在開機時設好的表,即中斷描述符表(在 x86 上;ARM 有等價物)。第 N 號項目存著「當第 N 號中斷觸發時要執行的常式」的位址。所以硬體路徑是機械化而快速的:線拉高、控制器送出一個向量、CPU 索引那張表、控制權落進核心程式碼。這整件事都不牽涉系統呼叫——沒有人開口要。被中斷的那個執行緒沒做錯任何事;它只是在錯誤的奈秒待在錯誤的地方,而一旦中斷處理完,它就會從它離開的地方原封不動地被恢復。
註冊一個處理常式,以及它執行的那個怪地方
驅動程式不會親手寫進描述符表。它改在自己的 probe 期間——上一篇裡驅動程式模型把程式碼綁定到一個裝置的那一刻——請求核心把它自己的一個函式接到該裝置的 IRQ 線上。那就是 IRQ 註冊,用像 request_irq(irq, my_handler, flags, name, dev) 這樣的呼叫完成。從那一刻起,每當裝置把它的線拉高,核心就會呼叫 my_handler()。你註冊的那個函式就是中斷處理常式,常被稱為上半部(top half):它是立刻執行的程式碼,直接回應硬體,在其他任何東西輪到之前。一如在核心裡的慣例,這個呼叫回傳一個錯誤碼,而你要檢查它——如果這條 IRQ 無法被宣告,這個裝置就沒用,probe 必須失敗並回退。
現在來到讓中斷程式碼感覺異樣的部分。上半部不像一般執行緒那樣執行。它執行在中斷情境(interrupt context)裡——而關於中斷情境最核心、最承重的事實是:它背後沒有行程。處理常式借用了它碰巧落到的那顆 CPU;它沒有自己的執行緒、沒有 PID、背後也沒有使用者空間可以把資料複製進去或複製出來。兩條硬規則直接從這裡掉出來。第一,處理常式絕不能睡眠:它不能阻塞、不能在一個互斥鎖上等待、不能呼叫任何可能讓執行緒睡著的東西,因為根本沒有執行緒可以讓它睡——在中斷情境裡睡眠是個核心臭蟲,系統會抓到並大聲抗議。第二,它絕不能呼叫進使用者空間,因為沒有一個「當前的使用者行程」讓它能去碰它的記憶體。
為什麼「快」很重要,以及上半部/下半部的切分
處理常式必須快,還有一個比「別睡」更深的理由。當一個中斷處理常式執行時,那條中斷線——還常常包括其他線——是被遮蔽(masked)的:在這一個返回之前,CPU 不會接受同一種的另一個中斷。你在上半部裡花掉的每一微秒,都是下一個封包、下一次按鍵、下一顆磁碟完成在那裡乾等的一微秒,會抬高系統的中斷延遲。更糟的是,一個中斷所隱含的工作常常確實很慢——喚醒等這份資料的執行緒、走一遍協定堆疊、配置緩衝區、把一千位元組搬來搬去。你不能在中斷被遮蔽、又沒有執行緒可睡的情況下做那些事。所以核心把回應切成兩半。
這正是這一級名稱的核心:上半部與下半部。上半部就是中斷處理常式本身——它執行在中斷情境裡,做那真正等不了的最低限度(向裝置應答,好讓它別再喊;抓走那幾個硬體即將覆寫掉的狀態位元組;記下有工作待辦),然後快速返回。下半部是被延後的剩餘部分——那慢的、可以睡眠的工作——被排程到稍晚、在中斷重新啟用後、離開要徑去執行。寫一個中斷處理常式的藝術,大半是「決定什麼才真正屬於上半部(幾乎沒有東西),把其餘一切往下推」的紀律。
device raises IRQ line -> CPU masks line, jumps to top half
|
TOP HALF (interrupt context, no sleeping, line masked):
- ack the device (stop it asserting the IRQ)
- read volatile status the hardware will clobber
- schedule the bottom half -> return FAST
|
... interrupts re-enabled, system runs on ...
|
BOTTOM HALF (later, sleepable context, line unmasked):
- wake waiting threads, run protocol code, copy buffers,
kmalloc, take a mutex -- all the slow work that may sleep三種口味的下半部,以及該挑哪一種
「排程下半部」不是單一機制,而是一個小家族,而選擇取決於一個問題:被延後的工作需不需要睡眠?最輕的選項是 softirq 及其較友善的包裝 tasklet:它們仍執行在類似中斷的情境裡、不能睡眠,但在上半部返回、中斷重新啟用後才執行,所以它們不再擋住新的中斷。它們用於「慢到不能在線被遮蔽時做、但又從不需要等待」的工作。較重的選項是工作佇列(work queue):它在一個真正的核心執行緒裡、在行程情境中執行你延後的函式,那裡它可以睡眠——取一個互斥鎖、配置可能會阻塞的記憶體、做任何一般核心執行緒能做的事。代價是切到那個執行緒的一次情境切換,所以啟動上要稍慢一點。
注意這如何對應到前面幾級的概念,而不需要任何新魔法。下半部只是被延後的工作,和「你排入佇列、之後才跑的一個回呼」是同一個想法;tasklet 是排在同一顆 CPU 上的一個小任務;工作佇列在底層就是一個由排程器管理的核心執行緒,從一張清單上拉出工作來做——一個生產者/消費者模式,上半部生產、工作執行緒消費。你已經懂得所有零件了。新的只有那個限制:上半部是一個「平常那種能睡眠、能上鎖、能碰使用者記憶體的自由被收走」的地方,而下半部是那份自由被交還的地方。
用正確的方式與中斷分享資料
這裡有個微妙的陷阱,而且是個真陷阱。你驅動程式的一般程式碼——它的 read()/write() 函式,跑在某個行程的情境裡——常常需要和中斷處理常式分享一個變數或一塊緩衝區:一個計數器、一圈收到的位元組、一個旗標。但中斷可以在任何一瞬間觸發,甚至就在你一般程式碼正更新那個共享結構的中途。那是最棘手的一種競爭條件,因為那個中斷不是另一個你排程出來的執行緒——它毫無預警地搶佔你、就在更新到一半時,留下一個寫到一半的結構。你無法用推理把它消掉;你必須上鎖。
但你在這裡不能用一個普通的、會睡眠的互斥鎖,理由很精確。假設你的一般程式碼持有一個互斥鎖,而中斷觸發、它的處理常式試圖取同一個互斥鎖。處理常式不能睡著去等它——而且就算它能,持有那個鎖的程式碼已經在這同一顆 CPU 上被中斷並凍結了,所以它永遠無法釋放它。那是一個對你自己的、瞬間發生且無法解開的死結。核心的答案是自旋鎖,搭配在持鎖期間於本地 CPU 上關閉那個中斷(即 spin_lock_irqsave() 慣用法)。自旋鎖從不睡眠——它忙碌等待——所以它在中斷情境裡是合法的;而在本地關閉那個中斷則保證處理常式無法觸發、無法試圖再去取你正持有的那個鎖。
在一台多核機器上還有一道皺褶,這也是為什麼中斷側的資料常常是每 CPU 的(per-CPU)。在本地關閉中斷只能擋住這顆 CPU 上的處理常式;同一個中斷可能同時在另一顆核心上觸發。純自旋鎖仍然涵蓋這點——第二顆核心會自旋直到第一顆釋放——但在設計允許時,一個更整潔的模式是給每顆 CPU 它自己私有的資料副本,這樣核心之間根本不會爭用。我們在這裡只碰到表面;同步那一級深入講過這些鎖為什麼那樣表現,而那整套機制——自旋鎖、上鎖順序、每 CPU 狀態——正是讓「中斷分享的資料」不在你底下被破壞的東西。誠實的總結:任何一個上半部和你一般程式碼都會碰的位元組,每一次都需要一個刻意的計畫。
退一步,以及前面的路
- 裝置把它的 IRQ 線拉高;CPU 把當前指令做完、遮蔽那條線、存下最少的狀態,再索引描述符表找到你註冊的處理常式。
- 上半部在中斷情境裡執行:向裝置應答、抓走它即將覆寫的揮發性狀態、排程下半部——然後快速返回,絕不睡眠、絕不碰使用者空間。
- 中斷重新啟用;稍後下半部執行——若工作不能睡眠就用 tasklet,能睡眠就用工作佇列(執行緒情境)——做真正的協定、緩衝區與喚醒工作。
- 處理常式與一般驅動程式碼之間分享的任何資料,都用自旋鎖加本地關中斷來守護、或保持為每 CPU 的,絕不用會睡眠的互斥鎖。
退開來看,這個形狀既簡單又人性:以最小可能的程度立即回應,然後在一個比較平靜、一般規則又重新適用的地方做大部分的工作。那一個本能——把待在中斷情境裡的時間最小化——驅動了驅動程式作者圍繞中斷做的幾乎每一個設計決定。這和整門課一再回到的價值相同:對限制誠實,並把程式碼安排成讓那個危險、被鎖死的時刻盡可能地短。
結尾還有一根刺,是接下來幾篇要面對的。中斷很美好,直到它們多到太多。一張重載下的網路卡可以為每一個封包都拉一次中斷——每秒數百萬次——而在那個速率下,光是「接受中斷」這件事本身的成本就把整台機器淹沒,這個失效模式叫做中斷風暴。解法是在重載下從中斷切回輪詢:照一個排程去問裝置「有東西嗎?」,而不是每次都被戳一下。Linux 把兩者混合的東西是 NAPI,而權衡輪詢對中斷是這一級的收尾問題。不過在那之前,先來面對我們一直延後的事——驅動程式究竟怎麼搬那些位元組,透過記憶體映射 I/O與 DMA——那是下一篇。