1. 程式人生 > 其它 >Linux中斷--很好的一篇文章

Linux中斷--很好的一篇文章

引言

本文整理了 Linux 核心中斷的相關知識,其他 Linux 相關文章均收錄於貝貝貓的文章目錄

中斷

大家應該很清楚,系統在執行時可以處於兩種可能的狀態:核心態和使用者態。之前我們討論過的系統呼叫,就能使程序從使用者態切換到核心態去執行某些任務,當執行成功後再回到使用者程序中。大家可能還記得這是通過軟中斷來實現的,那麼中斷到底是什麼呢? 接下來我將介紹與中斷相關的一些知識。

硬中斷

通常中斷可以分為如下兩個類別:

  • 同步中斷和異常。這些由 CPU 自發地針對當前執行的程式產生的。異常可能因種種原因觸發: 由於執行時發生的程式設計錯誤(除0),或由於出現了異常的情況或條件,致使處理區需要外部幫助才能處理。前一種情況下,核心必須通知應用程式出現了異常,比如使用訊號機制,這樣應用程式才有機會輸出一些適當的錯誤資訊。但是,異常也不見得一定是程式內部導致的,比如缺頁異常,這時候就需要核心出面解決。
  • 非同步中斷。多數由裝置產生,可能發生在任何時間。不同與同步中斷,非同步中斷並不與特定的程序關聯。比如網絡卡通過傳送中斷通知核心有新的資料到來,因為資料可能隨時到來,所以當前 CPU 上執行的可能是和該網路資料無關的程序。為了避免損害該程序的執行時間,核心必須快速的完成資料的處理工作,使得 CPU 時間能夠返還給當前程序。但是處理網路資料並不簡單,要花費許多的時間,所以核心採取的辦法是將中斷分為兩半,前半段儘可能地快速完成(將網路資料快取在記憶體中,快取好了之後就將 CPU 返還給程序),後半段在不是那麼繁忙的時候再處理。

無論是上述的哪種中斷,都會涉及到中斷處理過程中的一個關鍵流程,那就是:如果在發生中斷時,當前 CPU 沒有處於核心態,則發起從使用者態到核心態的轉換。接下來,在核心中執行一個專門的中斷處理程式。

另外,我們知道中斷是可以被禁用的,比如在一些中斷處理程式中,可能通過禁用中斷的方式來達到資料臨界區的效果,但是,禁用中斷的時間過長的話,註定會影響到系統的效能,還有可能漏掉其他重要的中斷,所以中斷處理程式被劃分為兩個部分,關鍵性的任務會在前半段(禁用中斷時)處理,而不那麼重要的工作會在後半段非同步延期處理。這裡講這麼多就是為了讓大家明白中斷可能會被分階段處理,如果一箇中斷對應的工作很快就能完成,那麼一般都會以同步的方式處理,而如果一箇中斷的處理過程可能花費很長時間的話,可能就會分成兩段,前一段已同步的方式處理關鍵性的任務,後一段以非同步的形式處理次要任務。

那麼系統是怎麼將中斷與對應的處理程式掛鉤的呢?一個簡單的想法是:每個中斷都有一個編號,比如分配給一個網絡卡的中斷號是 m,分配給 SCSI 控制器的中斷號是 n,那麼核心即可區分兩個裝置,並在中斷髮生時對應地執行特定於裝置的操作。同樣的方案也可適應於異常,不同的異常指派了不同的編號。但遺憾的是,由於系統架構的原因,情況並不總是像描述的那樣簡單。在一些系統架構中,可用的中斷編號少得可憐,所以必須由幾個裝置共享一個編號,這個過程被稱為中斷共享。在 IA-32 處理器上,硬體中斷的最大數目通常是15,這個值可不怎麼大,此外,還要考慮到有些中斷編號已經永久性地分配給了標準的系統元件(鍵盤、定時器,等),這就限制了其他外設的中斷編號數。

實際上,外設並不會直接產生中斷,它們會有電路連線到中斷控制器,在需要傳送中斷時,外設會向中斷控制器放中斷請求(IRQ),隨後中斷控制器將中斷請求(IRQ)轉化為對應的中斷號,最終傳輸到 CPU 的中斷輸入中。當 CPU 得知發生中斷後,它將進一步的處理委託給一箇中斷處理程式,該程式可能會修復故障、提供專門的處理或者將事件通知使用者程序等。由於每個中斷和異常都有一個唯一的編號,核心使用了一個數組來維護中斷號到對應處理程式的對映關係,就如下圖所示。

這裡大家肯定會有疑問,中斷號是怎麼共享的呢?實際上每個中斷號對應一箇中斷處理程式只是一個籠統的說法,當多個裝置共用同一個中斷號時,顯然一箇中斷號會對應一組處理程式。實際上,在我們安裝裝置的驅動時,就會將該裝置對應的中斷處理程式註冊到對應的中斷號上,換句話說,核心會為每個中斷號,維護一個處理程式連結串列(下圖 action),連結串列上的每個節點都對應了一個使用該中斷號的裝置。

對於每個裝置的 irqaction 除了會記錄其對應的處理程式地址外,還會記錄一個簡要的裝置名和裝置 id,裝置名主要用於顯示給人看/proc/irq/{Num}/{Name},而裝置 id 用來描述某一指定的裝置,這樣在解除安裝裝置驅動時,只要指出該裝置的中斷號以及該裝置的 id 就能將其中斷處理程式從上述連結串列中刪除。

struct irqaction {
    irq_handler_t handler; // 處理程式地址
    unsigned long flags;
    const char *name; // 裝置名
    void *dev_id;   // 裝置 id
    struct irqaction *next; // 使用該中斷號的下一個裝置
}

現在我們已經知道了如何動態的註冊和刪除中斷處理程式,接下來我們還要解釋一下當中斷髮生時,核心如何定位應該讓哪個中斷處理程式處理。因為 CPU 在中斷髮生時,只能拿到中斷號這一個引數,所以核心的處理方式非常粗暴:

    1. 它會先檢查當前是否該中斷是否被遮蔽
    2. 逐一呼叫註冊在該中斷號上的所有處理函式
    3. 每個中斷處理程式需要自己確定該中斷是不是自己的裝置發出的,一般有兩個方案:
      1. 比較近代的裝置上都會有一個裝置暫存器,記錄著該裝置剛才有沒有發出中斷訊號,如果暫存器為 1 則說明是自己發出的,那麼就將暫存器置為 0 然後開始處理
      2. 如果裝置沒有暫存器,則會檢查是否有裝置資料可用,有的話處理資料,沒有的話,則返回
  1. 每個中斷處理程式如果正確的處理的 IRQ 則返回 IRQ_HANDLED,否則如果不是有自己負責的話返回 IRQ_NONE
  2. 核心會逐一呼叫每個處理函式,無論是否有處理程式已經正確的處理

那麼,當核心開始處理中斷時都需要做什麼呢?下圖就簡要的描述了整個中斷處理的過程。

進入路徑的一個關鍵任務是,從使用者態棧切換到核心態棧。但是,只有這點還不夠。因為核心還要使用 CPU 資源執行其程式碼,進入路徑必須儲存使用者應用程式當前的暫存器狀態,以使在中斷活動結束後恢復。這與排程期間用於上下文切換的機制是相同的。在進入核心態時,只儲存部分暫存器的內容,因為核心並不會使用全部暫存器。舉例來說,核心程式碼中不使用浮點操作(只有整數計算),因而並不儲存浮點暫存器。隨後核心跳轉到與中斷號對應的中斷處理程式中執行特定的任務,在退出時,核心會檢查如下事項:

  • 排程器是否應該選擇一個新程序代替舊的程序。
  • 是否有訊號必須投遞到原程序。

從中斷返回之後,只有確認了這兩個問題,核心才能完成其常規任務,即還原暫存器集合、切換到使用者態棧、切換到適當的處理器狀態(如果原來是使用者態,就切換回使用者態)。

實現中斷處理程式時,也會遇到很多問題,比如在處理中斷期間,發生了其他中斷,儘管可以通過禁用中斷來防止這個問題,但是這又會引入別的問題,比如遺漏重要中斷。所以禁用中斷這個功能必須只能短時間使用。總結一下中斷處理程式要滿足如下幾個要求:

  1. 實現(特別是要禁用其他中斷時)要儘可能簡單,以支援快速處理
  2. 中斷處理程式也允許被其他中斷打斷,彼此還要互不干擾

儘管後一個問題我們可以通過精妙的設計方案來解決,但是前一個就很困難了。因為中斷處理程式的每個部分並不是同等重要,所以每個中斷處理程式都可以劃分為 3 個部分,它們具有不同的意義:

  1. 關鍵操作必須在操作發生後立即執行。否則,無法維持系統的穩定,在執行此類操作時,必須禁止其他中斷。
  2. 非關鍵操作也應該儘快執行,但是允許其他中斷搶佔
  3. 可延期處理的操作不是那麼重要,不必在中斷處理程式中實現。核心可以延遲處理這些工作,在時間充裕的時候進行。比如核心提供了 tasklet 機制,用於稍後執行可延期的操作。

在實現處理程式例程時,必須要注意些要點。這些會極大地影響系統的效能和穩定性。中斷處理程式在所謂的中斷上下文(interrupt context)中執行。核心程式碼有時在常規上下文執行,有時在中斷上下文執行。為區分這兩種不同情況並據此設計程式碼,核心提供了 in_interrupt 函式,用於指明當前是否在處理中斷。中斷上下文與普通上下文的不同之處主要有如下 3 點。

  1. 中斷是非同步執行的。換句話說,它們可以在任何時間發生。因而從使用者空間來看,處理程式並不是在一個明確定義的環境中執行。所以在這種環境下,禁止訪問使用者空間,特別是與使用者空間地址之間來回複製記憶體資料的行為。例如,對網路驅動程式來說,不能將接收的資料直接轉發到等待的應用程式。畢竟,核心無法確定等待資料的應用程式此時是否在執行(事實上,這種可能性很低)
  2. 中斷上下文中不能呼叫排程器。因為中斷上下文具有最高執行優先順序,這是核心無法排程別的程序來執行。它只能以主動返回的方式結束自己的處理過程。
  3. 處理程式例程不能進入睡眠狀態。因為程序睡眠後,中斷處理程式只能永遠等待下去。因為中斷處理程式沒有排程實體,所以不可能被再次排程。當然,只確保處理程式的直接程式碼不進入睡眠狀態是不夠的。其中呼叫的所有其他函式都不能進入睡眠狀態。對此進行的檢查並不簡單,必須非常謹慎。

至此,中斷的主要知識就已經串完了,我們接下來還要介紹一下軟中斷,它是從軟體層面觸發中斷的途徑。介紹完軟中斷,我們才會開始介紹中斷後半段的處理方式,比如 tasklet。

軟中斷

軟中斷的意義是使核心可以延期執行任務,因為它的運作方式和上述的中斷類似,但完全是從軟體實現的,所以稱為軟中斷。核心藉助軟中斷來獲知異常情況的發生,而該情況將在稍後有專門的處理程式解決。

軟中斷是相對稀缺的資源,因為各個軟中斷都有一個唯一的編號,所以使用其必須謹慎,不能由各種裝置驅動程式和核心元件隨意使用,預設情況下,系統上只能使用 32 個軟中斷,但這個沒什麼,因為基於軟中斷核心還衍生出了許多其他其他延期執行機制,比如 tasklet、工作佇列和核心定時器。我們稍後會介紹它們。

只有中樞的核心程式碼才使用軟中斷,軟中斷只用於少數場景,如下就是其中相對重要的場景。其中兩個用來實現 tasklet (HI_SOFTIRQ,TASKLET_SOFTIRQ),兩個用於網路的傳送和接受(NET_TX_SOFTIRQ,NET_RX_SOFTIRQ,這兩個是構建軟中斷機制的最主要原因),一個用於塊層,實現非同步請求完成(BLOCK_SOFTIRQ),一個用於排程器(SCHED_SOFTIRQ),以實現 SMP 系統上週期性的負載均衡。在啟用高解析度定時器時,還需要一個軟中斷(HRTIMER_SOFTIRQ)。

enum
{
    HI_SOFTIRQ=0TIMER_SOFTIRQNET_TX_SOFTIRQNET_RX_SOFTIRQBLOCK_SOFTIRQTASKLET_SOFTIRQSCHED_SOFTIRQ#ifdef CONFIG_HIGH_RES_TIMERS
    HRTIMER_SOFTIRQ#endif
};

軟中斷的編號形成了個優先順序,雖然這並不影響各個處理程式例程執行的頻率或它們相當於其他系統活動的優先順序,但影響了多個軟中斷同時處理時執行的次序。

我們可以通過 raise_softirq(int nr) 發起一個軟中斷(類似普通中斷),軟中斷的編號通過引數指定。每個 CPU 都有一個位圖 irg_stat,其中每一位代表了一箇中斷號,raise_softirq 會函式設定各 CPU 變數 irg_stat 對應的位元位。該函式會將對應的軟中斷標記為 1,但是該中斷的處理程式並不會立即執行。通過使用特定於處理器的點陣圖,核心才能確保幾個軟中斷(甚至是相同的)可以同時在不同的 CPU 上執行。

那麼軟中斷在什麼時候執行呢?

  1. 當前面的硬體中斷處理程式執行結束後,會檢查當前 CPU 是否有待決的軟中斷,有的話則會按照次序處理所有的待決軟中斷,每處理一個軟中斷之前,就會將其對應的位元位置零,處理完所有軟中斷的過程,我們稱之為一輪迴圈
  • 一輪迴圈處理結束後,核心還會再檢查是否有新的軟中斷到來(通過點陣圖),如果有的話,一併處理了,這就會出現第二輪迴圈,第三輪迴圈
  • 但是軟中斷不會無休止的重複下去,當處理的輪數超過 MAX_SOFTIRQ_RESTART(通常是 10) 時,就會喚醒軟中斷守護執行緒(每個 CPU 都有一個),然後退出
  1. 軟中斷守護執行緒負責在軟中斷過多時,以一個排程實體的形式(和其他程序一樣可以被排程),幫著處理軟中斷請求,在這個守護執行緒中會重複的檢測是否有待決的軟中斷請求
  • 如果沒有軟中斷請求了,則會進入睡眠狀態,等待下次被喚醒
  • 如果有請求,則會呼叫對應的軟中斷處理程式

這裡大家可能有一個疑問,我們在前面介紹系統呼叫時也說了它是通過中斷實現的,那麼在前面的軟中斷列表中怎麼沒有對應的軟中斷呢?實際上,系統呼叫使用到的中斷屬於 "軟體觸發的硬中斷" 而不是這裡所說的軟中斷,因為系統呼叫過程是要同步處理的,不能使用非同步的軟中斷方式實現。在我的 linux 中執行cat /proc/interrupts會列印所有註冊的硬中斷,仔細觀察之後,你會發現其中包含一個名為 'CAL' 的中斷,它就是系統呼叫所對應的中斷號。這是通過執行機器指令觸發的,所以我才說它是軟體觸發的硬中斷。

cat /proc/interrupts
           CPU0
0:        181   IO-APIC-edge      timer
...
CAL:          0   Function call interrupts
...

中斷後半段

雖然軟中斷是將操作推遲到未來時刻執行的最有效方法,但軟中斷的中斷號有限,而且該延期機制處理起來非常複雜。因為多個處理器可以同時且獨立地處理軟中斷,所以同一個軟中斷的處理程式例程可以在幾個 CPU 上同時執行,這就要求軟中斷處理程式的設計必須是可重入並且執行緒安全的,臨界區必須用自旋鎖保護。此外,在軟中斷還不能進入睡眠,因為軟中斷的其中一部分是在硬中斷處理結束之後進行的,這時候軟中斷執行函式沒有排程實體,所以不能進入睡眠。

既然軟中斷這麼多限制,那開發裝置驅動程式(以及其他一般的核心程式碼)的同學豈不是很痛苦,實際上核心基於軟中斷建立了很多上層非同步處理機制。

tasklet

tasklet 是一種延期執行工作的機制,其實現基於軟中斷,但它們更易於使用,因而更適合於裝置驅動程式(以及其他一般性的核心程式碼)。

在核心中每個 tasklet 都有與之對應的一個物件表示,核心以連結串列的形式管理所有的 tasklet(next 欄位),而且每個 tasklet 都有兩個狀態,這兩個狀態通過 state 欄位的不同位表示,其中一個代表該 tasklet 是否註冊到核心,成為一個排程實體(TASKLET_STATE_SCHED),另一個代表該 tasklet 是否正在執行(TASKLET_STATE_RUN)。通過 TASKLET_STATE_RUN 我們可以使一個 tasklet 只在一個 CPU 上執行。此外 count 欄位大於 0 表示該 tasklet 被忽略。

struct tasklet_struct
{
    struct tasklet_struct *next;
    unsigned long state;
    atomic_t count;
    void (*func)(unsigned long);
    unsigned long data;
};

當我們註冊 tasklet 時,如果發現 TASKLET_STATE_SCHED 已經被置為 1,則說明該 tasklet 已經註冊了,就不會重複註冊。那麼 tasklet 在什麼時候執行呢? tasklet 的執行被關聯到 TASKLET_SOFTIRQ 軟中斷。因而,在呼叫 raise_softirq(TASKLET_SOFTIRQ) 時,tasklet 就會在合適的時機執行。執行過程是這樣的:

  1. 檢查 tasklet 的 TASKLET_STATE_RUN 是否被置為 1,是的話則說明其他 CPU 正在執行它,那麼當前 CPU 就跳過它
  2. 檢查其是否被禁用(count 是否大於零)
  3. 將 TASKLET_STATE_RUN 置為 1
  4. 呼叫 tasklet 的 func

因為 tasklet 本質上也是在軟中斷的處理程式中進行的,所以它並不能睡眠或者阻塞,但是它能保證同一時刻某個 tasklet 只會在一個 CPU 上執行,這就有天生的執行緒安全保障。

除了普通的 tasklet 之外,核心還提供了另一種 tasklet,它具有更高的優先順序,除此之外,它們兩個完全相同。高優先順序的 tasklet 通過 HI_SOFTIRQ 軟中斷觸發而不是 TASKLET_SOFTIRQ,這兩種 tasklet 在不同的連結串列中維護。這裡的高優先順序是指軟中斷的處理程式 HI_SOFTIRQ 比其他軟中斷處理程式更先執行,因為它排在軟中斷號的第一位。很多音效卡驅動以及高速網絡卡都是依賴高優先順序 tasklet 實現的。

等待佇列

我們已經知道 tasklet 不能解決睡眠和阻塞的問題,那麼當裝置驅動要等待某一特定事件發生的時候,有什麼辦法嗎?我們可以通過等待佇列來完成這個需求。既然要睡眠和阻塞,勢必須要一個排程實體,換句話說,等待佇列中的項不再是一個簡單的處理函式,而是一個類似於後臺程序一樣的存在。

struct wait_queue_t {
        unsigned int flags; // 當 flags 為 WQ_FLAG_EXCLUSIVE 時,表示該事件可能是獨佔的,喚醒一個程序後就返回
        void *private; // 大部分情況下指向程序物件 task_struct
        wait_queue_func_t func; // 呼叫該函式喚醒等待程序
        struct list_head task_list; // 連結串列實現需要
};

等待佇列的使用分為如下兩部分。

  1. 為使當前程序在一個等待佇列中睡眠,需要呼叫 wait_event 函式。程序進入睡眠,將控制權釋放給排程器。核心通常會在向塊裝置發出傳輸資料的請求後,呼叫該函式。因為傳輸不會立即發生,而在此期間又沒有其他事情可做,所以程序可以睡眠,將 CPU 時間讓給系統中的其他程序。
  2. 就上面的例子而言,塊裝置的資料到達後,必須呼叫 wake_up 函式來喚醒等待佇列中的睡眠程序。在使用 wait_event 使程序睡眠之後,必須確保在核心中另一處有一個對應的 wake_up 呼叫。

wait_event 是一個巨集,它接收兩個引數,第一個是等待佇列物件 wait_queue_t,第二個是判斷事件是否到來的 bool 表示式。這個巨集的實現也很簡單,就是先將當前程序加入到等待佇列的 task_struct 連結串列中,然後迴圈地通過第二個引數確認是否事件已經到來,如果來了則跳出迴圈,否則繼續睡眠。

wake_up 函式也很簡單,第一個是等待佇列連結串列的第一個物件 wait_queue_head_t,第二個引數 mode 指定程序的狀態(TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE),第三個引數 nr_exclusive控制喚醒該佇列上的幾個程序,如果是 1 則表明是獨佔的事件,只喚醒其中一個,如果是 0 則會喚醒該佇列中的所有程序。

工作佇列

工作佇列是將操作延時執行的另一個手段。它和等待佇列一樣是通過守護程序實現,在使用者上下文執行,所以可以睡眠任意長的時間。它非常像一個"執行緒池",在建立的時候我們需要指定執行緒名,同時也可以指定是單個執行緒,還是每個 CPU 上建立一個對應的執行緒。

struct workqueue_struct *__create_workqueue(const char *nameint singlethread)

建立好工作佇列後,我們可以向其中註冊任務,每個工作任務的結構如下。註冊後的任務會維護在一個連結串列中,按照順序依次執行。

struct work_struct {
    atomic_long_t data; // 和本工作項相關的資料,例如工作函式可以將一些中間內容或者結果儲存在 data 中
    struct list_head entry; // 連結串列實現需要
    work_func_t func; // 函式指標,其中一個函式引數指向了本 work_struct 物件,使函式內可以訪問到 data 屬性
}

而且,在註冊工作內容時,我們還可以指定延時任務,它會在一個指定延遲後開始執行。當建立延時任務時,核心會建立一個定時器,它將在 delay jiffies 之後超時,隨後相關的處理程式會將 delayed_work 內部的 work_struct 物件加入到工作佇列的連結串列中,剩下的工作就和普通任務完全一樣了。

int fastcall queue_delayed_work(struct workqueue_struct *wqstruct delayed_work *dworkunsigned long delay)

struct delayed_work {
    struct work_struct work;
    struct timer_list timer;
}

參考:--->

https://zhuanlan.zhihu.com/p/94788008