學習linux的中斷異常是前公司所在部門組織的學習任務,參照《深入理解linux內核》,每人選擇一個章節進行系統性的深入學習,然后組織大家進行知識分享。這樣每個人花費時間認真學習一個章節,就可以獲取所有章節的知識,盡量用最少的時間達到最好的效果。當然如果不是自己盡心盡力去系統的學習,聽別人講解一般也就算入門級水平,知道某些概念和框架而已,但也可以節省大量時間了。實際執行過程中,畢竟大家不一定有充裕的時間學習,而且linux基礎因人而異,所以在我離職之前也只組織過幾次培訓,想起來還是蠻懷念那段時間的。當時選擇中斷和異常這一章,是因為我是從小的嵌入式實時系統轉到linux的,之前用的是uc/os,那時候就研究過uc/os的移植包括內核代碼,還自學並移植freertos。因為這兩個實時系統的移植工作主要是跟中斷異常相關的,個人對這方面就會更感興趣,想知道linux系統中復雜的中斷和異常是如何實現的。
一開始去看《深入理解linux內核》感覺真的是晦澀難懂,而且書本內容屬於平鋪直敘型,就是單純的介紹,並不側重於前后的邏輯性和思路引導。對於一個linux小白並且對邏輯需求又很高的人來說,認真的看完一段話其實只是在腦海中讀了一遍而已,大腦完全沒有去想這段文字的意思。但是畢竟時間可以改變一切,經歷了萬事開頭難的階段,以及其后一年多的時間斷斷續續的鞏固和深化理解,終於可以對linux的中斷框架總結一些東西。
本文主要從四個方面來講,中斷和異常向量表的初始化、進入中斷、中斷描述符、退出中斷。因為對細節需求很高,有一個小的環節不明白都會影響我對整個框架的理解,所以中后期的學習基本都是直接查閱代碼的,這樣可以看到每一個點的細節。所以文中會粘貼不少的源碼,我現在使用的內核源碼是linux4.20.5版本。學習過程中也拜讀了許多大牛的博客。
一、X86中斷硬件體系結構
X86中的中斷控制器涉及到的概念有8259中斷控制器(PIC----可編程中斷控制器)、Local APIC和I/O APIC(APIC------高級可編程中斷控制器),另外在PCI /PCIE中還存在MSI中斷。每個8259芯片支持8個中斷信號,采用2級級聯的方式可以支持15個中斷信號。APIC是為支持多核CPU引入的,Local APIC和I/O APIC分別在CPU和chipset上,I/O APIC通過總線將中斷信息分派給每顆CPU的LocalAPIC,LocalAPIC可以智能的決定是否接受總線上傳來的中斷信息,而且它還可以處理本地CPU中斷的pending、nesting、masking。MSI中斷依賴於中斷控制器實現。
不管是哪種中斷控制器,CPU都需要對其進行初始化。具體的初始化操作沒有詳細了解。
中斷必然要涉及到中斷向量表,在X86中有IVT和IDT兩種結構,IVT適用於最開始的實模式,他的每一項就是相應中端的中斷入口地址。IDT主要用於保護模式及之后的模式,IVT和IDT的基地址存儲在IDTR寄存器中,CPU通過該寄存器獲取中斷向量表的基地址進而實現中斷尋址。IDT的每一個表項是一個中斷描述符結構,中斷描述符由段尋址的地址+標志位組成,地址轉換+權限判斷,所以適用於保護模式。中斷描述符的結構如下所示。


兩個描述符的格式摘自 https://www.jianshu.com/p/54c1bf1b4aef
中斷描述符中,描述符段選擇子+偏移量共同實現了中斷處理程序的尋址,這個與X86的段尋址結構設計相關。中斷處理程序的地址=全局描述符表[段選擇子] + 偏移量。但是在linux中並沒有采用段式管理機制,所以全局描述符表中的段基址都被設置為0,其實偏移量就是最終的虛擬地址。其他還有一些bit,是用於設置CPU安全等級等相關操作的,與CPU架構設計息息相關,此處不再詳細介紹。
參見 https://www.2cto.com/kf/201702/561719.html
http://news.eeworld.com.cn/qrs/2015/0821/article_24256.html
二、中斷和異常向量表的初始化
linux的鏈接文件是/arch/x86/kernel/目錄下的vmlinux.lds文件,從該文件可以看一下內存分配。系統的入口地址,中斷向量表的定義等。從vmlinux.lds文件中看到,初始位置存放的是HEAD_TEXT段,也就是*(.head.text)段。從命名方式上看,這個段應該是跟makefile中指定的head-y中添加的內容相關,所以去head-y中定義的head_$(BITS).s中查找.head.text段的定義。
果然,在head_32/64.s文件開頭分別看到了下面的定義,其中__HEAD在/inlude/linux/init.h中定義為*(.head.text)。這里定義的就是入口函數 startup_32和startup_64.
head_32.s: __HEAD ENTRY(startup_32) movl pa(initial_stack),%ecx /* test KEEP_SEGMENTS flag to see if the bootloader is asking us to not reload segments */ testb $KEEP_SEGMENTS, BP_loadflags(%esi) jnz 2f head_64.s: .text __HEAD .code64 .globl startup_64 startup_64: UNWIND_HINT_EMPTY
跑題了,實際上中斷和異常向量表的初始化並不是在head-32/64.s中定義的,而是在entry_32/64.s中定義的。entry.s這個文件定義的都是跟中斷(中斷和異常的進入退出、中斷和異常處理入口)、fork退出(ret_from_fork)、任務調度(switch_to_asm)等相關的匯編代碼。
1、中斷處理函數定義
下面就看中斷處理函數在entry.s中的定義:
/* * Build the entry stubs with some assembler magic. * We pack 1 stub into every 8-byte block. */ .align 8 ENTRY(irq_entries_start) vector=FIRST_EXTERNAL_VECTOR .rept (FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR) pushl $(~vector+0x80) /* Note: always in signed byte range */ vector=vector+1 jmp common_interrupt .align 8 .endr END(irq_entries_start)
分析上面的代碼,真正定義的中斷處理函數有兩句話,內容如下:
pushl $(~vector + 0x80) jmp common_interrupt
使用偽代碼循環定義了所有外部中斷的中斷處理函數,循環次數即為外部中斷的個數(FIRST_SYSTEM_VECTOR -- FIRST_EXTERNAL_VECTOR)。因為所有的中斷處理函數的保存現場、跳轉到內核中斷處理函數的動作都是相同的,所以這部分工作是統一由common_interrupt實現的,只需要將當前的中斷向量號推入堆棧即可,類似於向后面的commin_interrupt傳入了一個參數,通知它當前處理的是哪一個中斷。
這段匯編代碼相當於定義了(FIRST_SYSTEM_VECTOR -- FIRST_EXTERNAL_VECTOR)個外部中斷處理函數。所有的外部中斷處理函數在內存中的存儲形式如下圖,這個圖在后續中斷向量表初始化過程中需要用到。

除了外部中斷處理函數,異常處理函數也在entry.s中定義,在x86中前32個中斷/異常向量編號服務於異常處理。截取部分異常處理代碼如下圖。異常處理的入口代碼會有不同的處理方式,所以都是分開定義的,其通用的保存現場、調用異常處理子函數、異常返回等操作定義在了common_exception函數中。common_exception函數中需要直接調用不同的異常處理子函數。所以在這部分每個異常單獨擁有的代碼中,需要將對應的異常處理子函數的地址傳遞給common_exception,當然還會根據具體的異常處理子函數的設計,看是否需要向其輸入參數。
ENTRY(coprocessor_error) ASM_CLAC pushl $0 pushl $do_coprocessor_error jmp common_exception END(coprocessor_error) ENTRY(device_not_available) ASM_CLAC pushl $-1 # mark this as an int pushl $do_device_not_available jmp common_exception END(device_not_available)
綜上所述,entry.s中定義了所有的異常向量處理函數和外部中斷處理函數。
談到中斷/異常向量表的初始化,還需要涉及到x86的中斷體系架構。這部分內容對於每個CPU都不同,ARM架構和X86都有自己獨特的體系結構,所以是在arch目錄下定義的。X86的中斷向量表稱為IDT(interrupt description table),每一個表項稱為中斷描述符,這連續存放的每個表項就對應相應編號的異常/中斷。像某些嵌入式系統的CPU,中斷向量表可能就是直接存放中斷處理程序的地址。對於復雜的X86架構,中斷描述符不僅需要指明中斷處理程序的入口地址,還包括一些權限說明。IDT表的首地址存放在IDTR寄存器中,對於CPU來說,中斷響應時會根據IDTR中的數據獲取IDT表的位置,然后去獲取對應的中斷描述符,進而根據中斷描述符中的地址跳轉執行。
中斷描述符中,描述符段選擇子+偏移量共同實現了中斷處理程序的尋址,這個與X86的段尋址結構設計相關。中斷處理程序的地址=全局描述符表[段選擇子] + 偏移量。但是在linux中並沒有采用段式管理機制,所以全局描述符表中的段基址都被設置為0,其實偏移量就是最終的虛擬地址。其他還有一些bit,是用於設置CPU安全等級等相關操作的,與CPU架構設計息息相關,此處不再詳細介紹。
2、中斷描述符表初始化
中斷處理程序都定義好了,接下來就是要根據中斷處理程序的首地址初始化中斷描述符表了。在start_kernel()中,看到了init_IRQ()函數,顧名思義就是初始化中斷相關的內容。
在init_IRQ()函數中調用了x86_init.irqs.intr_init(); x86_init可是一個很重要的結構體,這個結構體中包含了中斷、時鍾、頁表、資源等等一系列的初始化操作。該結構體在/arch/x86/kernel/x86_init.c中定義,intr_init被賦值為native_init_IRQ。
void __init native_init_IRQ(void)
{
/* Execute any quirks before the call gates are initialised: */
x86_init.irqs.pre_vector_init();
idt_setup_apic_and_irq_gates(); //組建中斷描述符表irq_desc.
lapic_assign_system_vectors();
if (!acpi_ioapic && !of_ioapic && nr_legacy_irqs())
setup_irq(2, &irq2);
irq_ctx_init(smp_processor_id());
}
在idt_setup_apic_and_irq_gates()函數中,可以看到這個函數初始化從中斷號0xec開始的中斷,中斷號0xec就是system_vector_start。這是X86的系統中斷,區別於外部中斷。
void __init idt_setup_apic_and_irq_gates(void)
{
int i = FIRST_EXTERNAL_VECTOR;
void *entry;
idt_setup_from_table(idt_table, apic_idts, ARRAY_SIZE(apic_idts), true); //這個函數初始化從中斷號0xec開始的中斷。
for_each_clear_bit_from(i, system_vectors, FIRST_SYSTEM_VECTOR) {
entry = irq_entries_start + 8 * (i - FIRST_EXTERNAL_VECTOR); //初始化外部中斷,看到了irq_entries_start
set_intr_gate(i, entry);
}
#ifdef CONFIG_X86_LOCAL_APIC
for_each_clear_bit_from(i, system_vectors, NR_VECTORS) {
set_bit(i, system_vectors);
set_intr_gate(i, spurious_interrupt);
}
#endif
}
二、中斷處理
1、通用中斷入口處理程序
common_interrupt的代碼實現如下。
/*
* the CPU automatically disables interrupts when executing an IRQ vector,
* so IRQ-flags tracing has to follow that: */ .p2align CONFIG_X86_L1_CACHE_SHIFT common_interrupt: ASM_CLAC addl $-0x80, (%esp) /* Adjust vector into the [-256, -1] range */ //修正堆棧中中斷向量號的格式 SAVE_ALL switch_stacks=1 //SAVE_ALL宏,保存現場 ENCODE_FRAME_POINTER TRACE_IRQS_OFF movl %esp, %eax //將堆棧中的數據(中斷向量號)放到EAX寄存器,因為X86架構通過EAX向子函數傳遞參數 call do_IRQ //調用do_IRQ,相當於C代碼中執行 do_IRQ(中斷向量號)。 jmp ret_from_intr //跳轉到中斷退出函數(包括linux中斷退出前的操作、恢復現場等操作) ENDPROC(common_interrupt)
2、保存現場
看一下SAVE_ALL的宏實現
.macro SAVE_ALL pt_regs_ax=%eax switch_stacks=0 cld //清除方向標志 PUSH_GS pushl %fs pushl %es pushl %ds pushl \pt_regs_ax pushl %ebp pushl %edi pushl %esi pushl %edx pushl %ecx pushl %ebx //至此,將段寄存器、通用寄存器的數值推入堆棧,防止原來的狀態被中斷處理過程污染 movl $(__USER_DS), %edx movl %edx, %ds movl %edx, %es movl $(__KERNEL_PERCPU), %edx movl %edx, %fs SET_KERNEL_GS %edx /* Switch to kernel stack if necessary */ .if \switch_stacks > 0 SWITCH_TO_KERNEL_STACK //是否切換到內核堆棧 .endif .endm
注意,由於中斷的不可預知性,CPU的硬件必須要對中斷機制提供硬件支持,硬件實現與X86的體系架構也是綁定的。首先肯定是中斷尋址(先找到中斷描述符),還要針對中斷描述符中的權限設置進行權限檢查,還要根據中斷描述符將CPU切換到RING0模式,如果發生了CPU模式的切換還需要切換堆棧(硬件首先從TSS結構中獲得新的堆棧指針,然后將原來的堆棧指針推入新堆棧,然后將SP修改為新的堆棧指針),然后需要將被中斷時的PC指針推入新堆棧,最后還要將中斷入口地址推入PC寄存器實現硬件跳轉。
所以進入中斷處理程序時,(用戶態進入內核態的情況)內核堆棧中已經包含原狀態的堆棧指針和PC指針。然后跳轉到中斷處理程序后,首先將當前的中斷號推入堆棧,然后通過SAVE_ALL宏將段寄存器、通用寄存器等都推入了堆棧中,完成了中斷現場的保護。SAVE_ALL執行完成后,內核堆棧中的數據分布如下圖。
3、x86中斷處理程序do_IRQ
do_IRQ函數也是x86架構的專用代碼,定義在/arch/x86/kernel/irq.c中。
/*
* do_IRQ handles all normal device IRQ's (the special
* SMP cross-CPU interrupts have their own specific
* handlers).
*/
__visible unsigned int __irq_entry do_IRQ(struct pt_regs *regs)
{
struct pt_regs *old_regs = set_irq_regs(regs);
struct irq_desc * desc;
/* high bit used in ret_from_ code */
unsigned vector = ~regs->orig_ax;
entering_irq();
/* entering_irq() tells RCU that we're not quiescent. Check it. */
RCU_LOCKDEP_WARN(!rcu_is_watching(), "IRQ failed to wake up RCU");
desc = __this_cpu_read(vector_irq[vector]); //根據中斷號,獲取linux的中斷描述符結構 每CPU變量
if (!handle_irq(desc, regs)) { //子函數handle_irq,在該函數中響應中斷
ack_APIC_irq(); //x86特有的中斷應答
if (desc != VECTOR_RETRIGGERED) {
pr_emerg_ratelimited("%s: %d.%d No irq handler for vector\n",
__func__, smp_processor_id(),
vector);
} else {
__this_cpu_write(vector_irq[vector], VECTOR_UNUSED);
}
}
exiting_irq();
set_irq_regs(old_regs);
return 1;
}
handle_irq也是x86的處理函數,其定義在/arch/x86/kernel/irq_32.c中。說明x86和x64的處理方式是不同的。一開始看代碼,看到generic_handle_irq_desc()函數就恍然大悟,就是在這里通過它直接調用desc->handle_irq函數呀。但是研究一下前面的代碼結構,只有if成立的時候才會執行這個分支,如果if不成立則直接退出。為什么?研究一下execute_on_irq_stack()函數就知道了,顧名思義,“使用中斷堆棧執行”,在這個函數中判斷如果當前不是中斷堆棧,需要切換到中斷堆棧,然后執行desc->handle_irq,最后恢復到原來的堆棧(內核棧?),此時返回1,則if分支不成立,無需再次執行中斷服務程序。user_mode(regs)這個判斷條件沒太想明白,如果是從用戶態響應中斷,那么就無需切換中斷棧直接在內核堆棧運行??是擔心內核態響應的話本來內核就會占用堆棧,容易導致內核堆棧溢出????
bool handle_irq(struct irq_desc *desc, struct pt_regs *regs)
{
int overflow = check_stack_overflow();
if (IS_ERR_OR_NULL(desc))
return false;
if (user_mode(regs) || !execute_on_irq_stack(overflow, desc)) { //excute_on_irq_stack():切換中斷棧、調用中斷處理函數、恢復內核堆棧。
if (unlikely(overflow))
print_stack_overflow();
generic_handle_irq_desc(desc); //linux通用中斷處理函數,直接調用desc->handle_irq.
}
return true;
}
execute_on_irq_stack()函數沒有必要研究那么細致,他的功能已經介紹了,因為涉及堆棧的切換,所以內部使用了內嵌匯編來實現。
static inline int execute_on_irq_stack(int overflow, struct irq_desc *desc)
{
struct irq_stack *curstk, *irqstk;
u32 *isp, *prev_esp, arg1;
curstk = (struct irq_stack *) current_stack();
irqstk = __this_cpu_read(hardirq_stack);
/*
* this is where we switch to the IRQ stack. However, if we are
* already using the IRQ stack (because we interrupted a hardirq
* handler) we can't do that and just have to keep using the
* current stack (which is the irq stack already after all)
*/
if (unlikely(curstk == irqstk))
return 0;
isp = (u32 *) ((char *)irqstk + sizeof(*irqstk));
/* Save the next esp at the bottom of the stack */
prev_esp = (u32 *)irqstk;
*prev_esp = current_stack_pointer; //當前堆棧存儲到新堆棧底部,為什么?
if (unlikely(overflow))
call_on_stack(print_stack_overflow, isp);
asm volatile("xchgl %%ebx,%%esp \n" //堆棧指針ESP與EBX交換,ESP推入EBX,EBX推入ESP
CALL_NOSPEC //該宏定義展開是一段匯編代碼,其中call *%[thunk_target]調用了中斷服務程序。
"movl %%ebx,%%esp \n" //恢復堆棧指針
: "=a" (arg1), "=b" (isp)
: "0" (desc), "1" (isp),
[thunk_target] "D" (desc->handle_irq)
: "memory", "cc", "ecx");
return 1;
}
三、linux通用中斷處理架構
linux通用的中斷處理結構是這樣的,對於每個中斷號,都對應一個中斷描述符結構irq_desc,一個中斷號對應的信息都在這個結構體中維護。中斷處理程序使用irqaction結構體來維護。對於共享中斷的情況,一個中斷信號可能由多個設備共享使用,所以使用action鏈表來維護所有設備的中斷處理函數。
在x86的中斷處理函數中,是調用了desc->handle_irq函數來處理中斷。因為多個中斷服務程序並不好維護,所以又封裝了一個handle_irq函數,來判斷當前是哪個設備的中斷,並執行中斷服務程序。
desc->handle_irq是在中斷初始化的過程中已經配置好的,不受設備驅動動態管控。關於desc->handle_irq的配置可以從內核的C入口程序,start_kernel開始看,看內核是在什么地方初始化中斷描述符。
start_kernel ---------> time_init() //在time_init函數中,只做了一件事兒,就是賦值late_time_init = x86_late_time_init;
----------> late_time_init() //定時器相關的初始化,並且調用x86_init.irqs.intr_mode_init();
--------->x86_init.irqs.intr_mode_init(); //其中intr_mode_init()被賦值為apic_intr_mode_init函數。
apic_intr_mode_init() ----->apic_bsp_setup() ------>setup_IO_APIC() ------>init_IO_APIC_traps() ------>
對每一個有效中斷執行legacy_pic->make_irq(irq); = make_8259A_irq(irq); --------> irq_set_chip_and_handler(irq, &i8259A_chip, handle_level_irq); 相當於將handle_level_irq()賦值給了desc的handle_irq函數。也就是說對每一個中斷,都采用handle_level_irq來處理中斷。
異常處理函數也是在這個文件中定義:
