linux內核分析——中斷與異常


      學習linux的中斷異常是前公司所在部門組織的學習任務,參照《深入理解linux內核》,每人選擇一個章節進行系統性的深入學習,然后組織大家進行知識分享。這樣每個人花費時間認真學習一個章節,就可以獲取所有章節的知識,盡量用最少的時間達到最好的效果。當然如果不是自己盡心盡力去系統的學習,聽別人講解一般也就算入門級水平,知道某些概念和框架而已,但也可以節省大量時間了。實際執行過程中,畢竟大家不一定有充裕的時間學習,而且linux基礎因人而異,所以在我離職之前也只組織過幾次培訓,想起來還是蠻懷念那段時間的。當時選擇中斷和異常這一章,是因為我是從小的嵌入式實時系統轉到linux的,之前用的是uc/os,那時候就研究過uc/os的移植包括內核代碼,還自學並移植freertos。因為這兩個實時系統的移植工作主要是跟中斷異常相關的,個人對這方面就會更感興趣,想知道linux系統中復雜的中斷和異常是如何實現的。

        一開始去看《深入理解linux內核》感覺真的是晦澀難懂,而且書本內容屬於平鋪直敘型,就是單純的介紹,並不側重於前后的邏輯性和思路引導。對於一個linux小白並且對邏輯需求又很高的人來說,認真的看完一段話其實只是在腦海中讀了一遍而已,大腦完全沒有去想這段文字的意思。但是畢竟時間可以改變一切,經歷了萬事開頭難的階段,以及其后一年多的時間斷斷續續的鞏固和深化理解,終於可以對linux的中斷框架總結一些東西。

        本文主要從四個方面來講,中斷和異常向量表的初始化、進入中斷、中斷描述符、退出中斷。因為對細節需求很高,有一個小的環節不明白都會影響我對整個框架的理解,所以中后期的學習基本都是直接查閱代碼的,這樣可以看到每一個點的細節。所以文中會粘貼不少的源碼,我現在使用的內核源碼是linux4.20.5版本。學習過程中也拜讀了許多大牛的博客。

       一、X86中斷硬件體系結構

       X86中的中斷控制器涉及到的概念有8259中斷控制器(PIC----可編程中斷控制器Local APICI/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來處理中斷。

 

 

 

 

 

 

 

 

 

 

 

異常處理函數也是在這個文件中定義:

 

        

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM