歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux綜合 >> Linux內核 >> 深入理解Linux內核day03--中斷和異常

深入理解Linux內核day03--中斷和異常

日期:2017/3/1 12:03:03   编辑:Linux內核

中斷和異常

中斷(interrupt)通常被定義為一個事件,該事件改編處理器執行的指令順序。這樣的事件與CPU芯片內部外部硬件電路產生電信號相對應。
中斷通常分為同步中斷(synchronous)中斷和異步(asynchronous)中斷:
同步中斷是當指令執行時由CPU控制單元產生的,之所以稱為同步,是因為只有在一條指令終止執行後CPU才會發出中斷。
異步中斷是由其他硬件設備依照CPU時鐘信號隨機產生的。
中斷(異步中斷)是由間隔定時器和I/O設備產生的,例如,用戶的一次按鍵會引起一個中斷。
異常(同步中斷)是由程序的錯誤產生的,或者是由內核必須處理的異常條件產生。

中斷信號的作用

中斷信號提供了一種特殊的方式,使處理器轉而去運行正常控制流之外的代碼。
當一個中斷信號達到時,CPU必須停止它當前正在做的事情,並且切換到一個新的活動。為了做到這一點,就要在內核態堆棧保存程序計數器的當前值,並把與中斷類型相關的一個地址放進程序計數器。
中斷處理是由內核執行的最敏感的任務行為之一,因為它必須滿足下列約束:
當內核正打算去完成一些別的事情時,中斷隨時會到來。因此,內核的目標就是讓中斷盡可能快地處理完,盡其所能把更多的處理向後推遲。因此,內核相應中斷後需要進行的操作分為兩部分:關鍵而緊急的部分,內核立即執行;其余推遲部分,內核隨後執行。
因為中斷隨時會到來,所以內核正在處理其中一個中斷時,另一個中斷(不同設備)又發生了。應該盡可能多地運行這種情況的發生,因此這能維持更多的I/O設備處於忙狀態。因此,中斷處理程序必須編寫成使相應的內核控制路徑能以嵌套的方式執行。
盡管內核在處理前一個中斷時可以接受新的中斷,但在內核代碼中還是存在一些臨界區,中斷必須被禁止。

中斷和異常

Intel文檔吧中斷和異常分為以下幾種:
中斷:
可屏蔽中斷:I/O設備發出的所有中斷請求(IRQ)都產生可屏蔽中斷。可屏蔽中斷可以處於兩種狀態:屏蔽的或非屏蔽的;一個屏蔽的中斷只要還是屏蔽的,控制單元就忽略它。
非屏蔽中斷:只有幾個危急事件(硬件故障)才能引起非屏蔽中斷,非屏蔽中斷由CPU辨認。
異常:
處理探測異常:當CPU執行指令時探測到的一個反常條件所產生的異常。
故障(fault):通常可以糾正。
陷阱(trap):在陷阱指令執行後立即報告;內核把控制權返回給程序後就可以繼續它的執行而不失連貫性。陷阱主要用途是為了調試程序。
異常中止(abort):發生一個嚴重錯誤;異常中止用於報告嚴重錯誤,如硬件故障或系統表中無效的值或不一致的值。
編程異常(programmed exception):在編程者發出請求時發生。
每個中斷和異常是由0~255之間的一個數字標識。

IRQ和中斷

每個能夠發出中斷請求的硬件設備控制器都有一條名為IRQ(interrupt request)的輸出線。所有現有的IRQ線都與一個名為可編程中斷控制器的硬件電路的輸入引腳相連。可編程中斷控制器執行下列動作:
1、監視IRQ線,檢查產生的信號。如果有條或兩條以上的IRQ線上產生信號,就選擇引腳編號較小的IRQ線。
2、如果一個引發信號出現在IRQ線上:
a、把接收到的引發信號轉換成對應的向量。
b、把這個向量存放在中斷控制器的一個I/O端口,從而允許CPU通過數據總線讀取此向量。
c、把引發信號發送到處理器的INTR引腳,即產生一個中斷。
d、等待,直到CPU通過把這個中斷信號寫進可編程中斷控制器的一個I/O端口來確認它,當這種情況發生時,清INTR線。
3、返回第1步。
通過中斷控制器端口發布合適的指令,就可以修改IRQ和向量之間的映射。
可以有選擇地禁止每條IRQ線。可以通過PIC編程從而禁止IRQ。禁止的中斷是丟失不了的,它們一旦被激活,PIC就又把它們發送到CPU。這一特點被大多數中斷處理程序使用,因為這允許中斷處理程序依次的處理同一類型的IRQ。

高級可編程中斷控制器

來自外部硬件設備的中斷請求以兩種方式在可用CPU之間分發:
靜態分布:IRQ信號傳遞給重定向表相應項中所列出的本地APIC。中斷立即傳遞給一個特定的CPU,或一組CPU,或所有CPU。
動態分布:如果處理器正在執行最低優先級的進程,IRQ信號就傳遞給這種處理器的本地APIC。
處理器間中斷(簡稱IPI)是SMP體系結構至關重要的組成部分,並由Linux有效地用來在CPU之間交換信息。

異常
80x86微處理器發布了大約20種不同的異常。內核必須為每種異常提供一個專門的異常處理程序。對於某些異常,CPU控制單元在開始執行異常處理程序前會產生一個硬件出錯碼,並且壓入內核態堆棧。

中斷描述符表

中斷描述符表(Interrupt Descriptor Table,IDT)是一個系統表,它與每一個中斷或異常向量相聯系,每一個向量在表中有相應的中斷或異常處理程序的入口地址。內核在允許中斷發生前,必須適當地初始化IDT。
在允許中斷之前,必須用lidt匯編指令初始化idtr。
IDT包含三種類型的描述符:
任務門描述符、中斷們描述符、陷阱門描述符
Linux利用中斷門處理中斷,利用陷阱門處理異常。

中斷和異常處理程序的嵌套執行

每個中斷或異常都會引起一個內核控制路徑,或者說代表當前進程在內核態執行單獨的指令序列。
內核控制路徑可以任意嵌套另一個中斷處理程序可以被另一個中斷處理程序“中斷”,因此引起內核控制路徑的嵌套執行。
一個中斷處理程序既可以搶占其他的中斷處理程序,也可以搶占異常處理程序。相反異常處理程序從不搶占中斷處理程序。
在內核態能觸發的唯一異常就是缺頁異常。但是中斷處理程序從不執行可以導致缺頁(因此意味著進程切換)的操作。
基於以下兩個主要原因,Linux交錯執行內核控制路徑:
為了提高可編程中斷控制器和設備控制器的吞吐量。
為了實現一種沒有優先級的中斷模型。

初始化中斷描述符表

內核啟動中斷之前,必須把IDT表的初始地址裝到idtr寄存器,並初始化表中的每一個項。這項工作在初始化系統是完成。
int指令允許用戶態進程發出一個中斷信號,其值可以是0~255的任意一個向量。因此,為了防止用戶通過int指令模擬非法的中斷和異常,IDT的初始化必須非常小心。可以通過把中斷或陷阱門的描述符的DPL字段設置為0來實現。

中斷門、陷阱門即系統門

Linux使用和Intel稍微不同的細目分類和術語,把中斷描述符如下分類:
中斷門:用戶態的進程不能訪問的一個Intel中斷門(門的DPL字段為0)。所有的Linux中斷處理程序都是通過中斷門激活。並且全部限制在內核態。
系統門:用戶態的進程可以訪問的一個Intel陷阱門(門的DPL字段為3)。通過系統門來激活三個Linux異常處理程序,他們的向量是4,5及128.
系統中斷門:能夠被用戶態進程訪問的Intel中斷門(門的DPL字段為3)。與向量3相關的異常處理程序由系統中斷門激活。
陷阱門:用戶態的進程不能訪問的一個Intel陷阱門(門的DPL字段為0)。大部分Linux異常處理程序都通過陷阱門來激活。
任務門:不能被用戶態經常訪問的Intel任務門(門的DPL字段為0)。Linux對“Double fault”異常的處理程序是由陷阱門來激活的。
下列體系相關的函數用來在IDT中插入門:
set_intr_gate(n,addr):在IDT的第n個表項插入一個中斷門。DPL字段為0
set_trap_gate(n,addr):在IDT的第n個表項插入一個陷阱門。DPL字段為0
set_system_intr_gate(n,addr):在IDT的第n個表項插入一個中斷門。DPL字段為3
set_system_gate(n,addr):在IDT的第n個表項插入一個陷阱門。DPL字段為3
set_task_gate(n,addr):在IDT的第n個表項插入一個中斷門。DPL字段為3

IDT的初步初始化

當計算機還運行在實模式時,IDT被初始化並由BIOS例程使用,然而,一旦Linux接管,IDT就被移到RAM的另一個區域,並進行第二次初始化,應為Linux沒有任何BIOS例程。
IDT存放在idt_table表中,有256個表項。
內核初始化時Setup_idt()匯編用同一個中斷門(即指向ignore_int()中斷處理程序)來填充這256個idt_table。

異常處理

CPU產生的大部分異常都由Linux解釋為出錯條件。當其中一個異常發生時,內核就向引起異常的進程發送一個信號向它通知一個反常條件。
異常處理程序有一個標准的結構,由以下三部分組成:
1、在內核堆棧中保存大多數寄存器的內容。
2、用高級的C語言處理異常。
3、通過ret_from_exception()函數從異常處理程序中退出。
為了利用異常,必須對IDT進行適當的初始化,是的每個被確認的異常都由一個異常處理程序。trap_init()函數的工作就是將一些最終值(即處理異常函數)插入到IDT的非屏蔽中斷及異常表項中。

為異常處理程序保存寄存器的值

ENTRY(divide_error)
pushl $0 # no error code
pushl $do_divide_error
ALIGN
當異常發生是,如果控制單元沒有自動把一個硬件出錯代碼插入到棧中,相應的匯編語言片段會包含一條pushl $0指令,在棧中墊上一個控制,然後,把高級C函數的地址壓入棧中。

進入和離開異常處理程序

異常處理程序的C函數總是有do_前綴和處理程序名組成。大部分函數把硬件出錯碼和異常向量保存在當前進程的描述符中,然後,當前進程發送一個適當的信號。
異常處理程序剛一終止,當前進程就關注這個信號。該信號要麼由進程自己的信號處理程序來處理,要麼由內核來處理。

中斷處理

中斷處理依賴於中斷類型。
I/O中斷:某個I/O設備需要關注,相應的中斷處理程序需要查詢設備以確定適當的操作過程。
時鐘中斷:某種時鐘產生一個中斷;這種中斷告訴內核一個固定的時間間隔已經過去。
處理器間中斷:多處理器系統中一個CPU對另一個CPU發出一個中斷。
I/O中斷處理
一般而言,I/O中斷處理程序必須足夠靈活以給多個設備同時提供服務。
中斷程序的靈活性是以兩種不同的方式實現的:
IRQ共享:中斷處理程序執行多個中斷服務例程(ISR)。每個ISR是一個與單獨設備(共享IRQ線)的相關函數。
IRQ動態分配:一個IRQ線在可能的最後時刻才與一個設備驅動程序相關聯;
Linux把緊隨中斷要執行的操作分為三類:
緊急的、非緊急的、非緊急可延遲的

中斷向量

物理IRQ可以分配給32~238范圍內的任何向量。不過linux使用向量128實現系統調用。
為IRQ可配置設備選擇一條線有三種方式:
設置一個硬件跳接器(僅適用於舊式設備卡)
安裝設備時執行一個實用程序。
在系統是執行一個硬件協議。
內核必須在啟動中斷前發現IRQ與I/O設備之間的對應,IRQ號與I/O設備之間的對應是在初始化每個設備驅動程序是建立的。

IRQ在多處理器系統中的並發

Linux准訊對稱多處理器模型(SMP);這意味著,內核本質上對任何一個CPU都不應該偏愛。因而內核試圖以輪轉的方式把來自硬件設備的IRQ信號在所有CPU之間分發。因此,所有CPU服務於I/O中斷的執行時間片幾乎相同。

多中類型的內核棧

每個進程的thread_info描述符與thread_union結構中的內核棧緊鄰,而根據內核編譯時的選項不同,thread_union結構可能占一個或兩個頁框。
如果thread_union結構的大小為8KB,那麼當前進程的內核棧被用於所有類型的內核控制路徑:異常、中斷和可延遲的函數。
相反,如果thread_union結構的大小為4KB,內核就是用三種類型的內核棧:
異常棧,用於處理異常(包括系統調用),這個棧包含了每個進程的thread_union數據結構,因此對系統中的每個進程,內核使用不同的異常棧。
硬中斷請求棧,用於處理中斷,系統中的每個CPU都由一個硬中斷請求棧,並且每個棧占用一個獨立的頁框。
軟中斷請求棧,用於處理可延遲的函數(軟中斷或tasklet),系統中的每個CPU都由一個軟中斷請求棧,並且每個棧占用一個獨立的頁框。
所有的硬中斷請求存放在harding_stack數組中,而所有的軟中斷請求存放在softirq_stack數組中,每個數組元素都是跨越一個單獨頁框的irq_ctx類型的聯合體。

為中斷處理程序保存寄存器的值

當CPU接收到一個中斷時,就開始執行相應的中斷處理程序代碼,該代碼的地址存放在IDT的相應門中。
調用do_IRQ()函數執行與一個中斷相關的所有中斷服務例程。

中斷服務例程

一個中斷服務例程(ISR)實現一種特定設備的操作。當中斷處理程序必須執行IRQ時,它就調用handle_IRQ_event()函數。

處理器間中斷處理

處理器間中斷允許一個CPU向系統中的其他CPU發送中斷信號。
在多處理器系統中,Linux定義了下列三種處理器間中斷:
CALL_FUNCTION_VECTOR:發往所有的CPU(不包含發送者),強制這些CPU運行發送者傳遞過來的函數。相應的中斷處理程序叫做call_function_interrupt()。
RESCHEDULE_VECTOR:當一個CPU接收這種類型的中斷時,相應的處理程序(叫做reschedule_interrupt())限定自己來應答中斷。
INVALIDATE_TLB_VECTOR:發往所有的CPU(不包含發送者)強制它們的轉換後援緩沖器(TLB)變為無效。相應的處理程序(叫做invalidate_interrupt())刷新處理器的某些TLB表項。
由於下列的一組函數,使得產生處理器中斷(IPI)變成一件容易的事情:
send_IPI_all():發送一個IPI到所有的CPU(包括發送者)
send_IPI_allbutself():發送一個IPI到所有的CPU(不包括發送者)
send_IPI_self():發送一個IPI到發送者的CPU。
send_IPI_mask():發送一個IPI到掩碼指定的一組CPU。

軟中斷及tasklet

我們在前面“中斷處理”一節中提到,在由內核執行的幾個任務之間有些不是緊急的:在必要情況下它們可以延遲一段時間。
在Linux2.6迎接這種挑戰是通過兩種非緊迫、可中斷內核函數:所謂的可延遲函數(包括軟中斷和tasklets)和通過工作隊列來執行的函數。
軟中斷和tasklet由密切的關系,tasklet是在軟中斷之上實現的。
事實上,出現在內核代碼中的術語“軟中斷(softirq)”常常表示可延遲函數的所有種類。
軟中斷的分配是靜態地(即在編譯時定義),而tasklet的分配和初始化可以在運行時進行(例如:安裝一個內核模塊時)。軟中斷可以並發地運行在多個CPU上。
因此軟中斷是可重入函數而且必須明確地使用自旋鎖保護其數據結構。
tasklet不必擔心這些問題,因為內核對tasklet的執行進行了更加嚴格的控制。相同類型的tasklet總是被串行的執行,換而言之,不能再2個CPU上運行相同類型的tasklet。但是類型不同的可以再不同CPU上同事運行。
一般而言,可延遲函數上可以執行四種操作:
初始化(initialization):定義一個新的可延遲函數;這個操作通常在內核自身初始化或者加載模塊時進行。
激活(activation):標記一個可延遲函數為“掛起”(在可延遲函數的下一輪調度中執行)。激活可以再任何時候進行(即使正在處理中斷)。
屏蔽(masking):有選擇地屏蔽一個可延遲函數,這樣即使他被激活,內核也不執行它。
執行(execution):執行一個掛起的可延遲函數和同類型的其他所有掛起的可延遲函數;執行是在特定的時間進行。
激活和執行不知何故總是綁定在一起:由給定CPU激活的一個可延遲函數必須在同一個CPU上執行。

軟中斷

軟中斷使用的主要數據結構是softirq_vec數組,該數組包含類型為softirq_action的32個元素。一個軟中斷的優先級是相應的softirq_action元素在數組內的下標。

處理軟中斷

open_softirq()函數處理軟中斷的初始化。它使用三個參數:軟中斷下標、指向要執行的軟中斷函數的指針及指向可能由軟中斷函數使用的數據結構的指針。
void open_softirq(int nr, void (*action)(struct softirq_action*), void *data)
open_softirq()限制自己初始化softirq_vec數組中適當的元素。
raise_softirq()函數用來激活軟中斷,它接受軟中斷下標nr作為參數,執行下面的操作:
1、執行local_irq_save宏以保存eflags寄存器IF標志的狀態值並禁止本地CPU上的中斷。
2、把軟中斷標記為掛起狀態,這是通過設置本地CPU的軟中斷掩碼中與下標nr相關的位來實現的。
3、如果in_interrupt()產生為1的值,則跳轉到第5步。這種情況說明:要麼已經在中斷上下文中調用了raise_softirq(),要麼當前禁止了軟中斷。
4、否則,就在需要的時候去調用wakeup_softirqd()以喚醒本地CPU的ksoftirqd內核線程。
5、執行local_irq_restore宏,恢復在第1步保存的IF標志狀態。
do_softirq()函數:
如果在這樣的一個檢查點(local_softirq_pending()不為0)檢測到掛起的軟中斷,內核就調用do_softirq()來處理它們。
__do_softirq()函數:
__do_softirq()函數讀取本地CPU的軟中斷掩碼並執行與每個設置為相關的可延遲函數。
由於正在執行一個軟中斷函數是可能出現新掛起的軟中斷,所以為了保證可延遲函數的低延遲性,__do_softirq()一直運行到執行完所有掛起的軟中斷。

ksoftirqd內核線程

在最近的內核版本中,每個CPU都有自己的ksoftirqd/n內核線程。
每個ksoftirqd/n內核線程都運行ksoftirqd()函數。

tasklet

tasklet是I/O驅動程序中實現可延遲函數的首選方法。tasklet建立在兩個叫做HI_SOFTIRQ和TASKLET_SOFTIRQ的軟中斷之上。幾個tasklet可以與同一個軟中斷相關聯,每一個tasklet執行自己的函數。兩個軟中斷沒有真正的區別,只不過do_softirq()先執行HI_SOFTIRQ的tasklet,後執行TASKLET_SOFTIRQ的tasklet。

工作隊列

在Linux2.6中引入了工作隊列,用來代替任務隊列。它們允許內核函數(非常像可延遲函數)被激活,而且稍後由一種叫做工作者線程的特殊內核線程來執行。
盡管可延遲函數和工作隊列非常相似,但是它們的區別還是很大的。主要區別在於:可延遲函數運行的中斷上下文中,而工作則隊列中的函數運行在進程上下文。執行可阻塞函數的我一方式是在進程上下文中運行。
工作隊列的數據結構
與工作隊列相關的主要數據結構是名為workqueue_struct的描述符,它包含一個有NR_CPUS個元素的數組,NR_CPUS是系統中CPU的最大數量。每個元素都是cpu_workqueue_struct類型的描述符。
struct cpu_workqueue_struct {

spinlock_t lock; //保護該數據的自旋鎖

long remove_sequence; /* Least-recently added (next to run) */
long insert_sequence; /* Next to add */

struct list_head worklist; //掛起鏈表的頭結點
wait_queue_head_t more_work;
wait_queue_head_t work_done;

struct workqueue_struct *wq;//指向workqueue_struct結構的指針,其中包含該描述符
task_t *thread;//指向結構中工作者線程的進程描述符指針

int run_depth; /* Detect run_workqueue() recursion depth 當前執行深度*/
} ____cacheline_aligned;
工作者函數
create_workqueue("foo")函數接受一個字符串作為參數,返回新創建工作隊列的workqueue_struct描述符的地址。該函數還創建n個工作者線程(n是當前系統中有效運行的cpu的個數),並根據傳遞給函數的字符串為工作者線程命名,如:foo/0,foo/1等等。
queue_work()把函數插入工作隊列,它接受wq和work兩個指針。wq指向workqueue_struct描述符,work指向work_struct描述符。
queue_work()主要執行下面的步驟:
1、檢查要插入的函數是否已經在工作隊列中(work->pending字段等於1),如果是就結束。
2、把work_struct描述符加到工作隊列鏈表中,然後把work->pending置1。
3、如果工作者線程在本地CPU的cpu_workqueue_struct描述符的more_work等待隊列中睡眠,該函數喚醒這個線程。
queue_delayed_work()函數和queue_work()幾乎相同,只是queue_delayed_work()函數多接受一個以系統滴答數來表示時間延遲參數,它用於確保掛起函數在執行前的等待時間盡可能短。
每個工作者線程在worker_thread()函數內部不斷地執行循環操作,因而,線程的絕大多數時間裡處於睡眠狀態並等待某些工作被插入隊列。工作隊列一旦被喚醒就調用run_workqueue()函數,該函數從工作者隊列鏈表中刪除所有的work_struct描述符並執行相應的掛起函數。

預定義工作隊列

在絕大多數情況下,為了運行一個函數而創建整個工作者線程的開始太大。因此,內核引入叫做events的預定義工作隊列,所有的內核開發者都可以隨意使用它。
預定義工作隊列值是一個包括不同內核層函數和I/O驅動程序的標准工作隊列,他的workqueue_struct描述符存放在keventd_wq數組中。
預定義工作隊列支持的函數
預定義工作隊列函數 等價的標准工作隊列函數
schedule_work(w) queue_work(keventd_wq,w)
schedule_delayed_work(w,d) queue_delayed_work(keventd_wq,w,d) (在任何CPU上)
schedule_delayed_work_on(cpu,w,d) queue_delayed_work(keventd_wq,w,d) (在某個CPU上)
flush_schedule_work() flush_workqueue(keventd_wq)

從中斷和異常返回

盡管終止階段的主要目的很清楚,即恢復摸個程序的執行。但是我們還需要考慮到以下幾個問題:
內核控制路徑並發執行數量:如果只有一個那麼CPU就必須切換到用戶態。
掛起進程的切換請求:如果有任何請求,內核就必須執行進程調度;否則,把控制權還給當前進程。
掛起的信號:如果一個信號發送到當前進程,就必須處理它。
單步執行模式:如果調試程序正在跟蹤當前進程的執行,就必須在進程切換回到用戶態之前恢復單步執行。
Copyright © Linux教程網 All Rights Reserved