Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號

jmpcall發表於2020-09-18
1. 中斷本質:儲存當前執行現場+觸發指令跳轉

    記得剛學習C語言時,只要找個包含if語句的程式,然後透過理解整個程式執行到這條語句時,發生了什麼,自然就明白if語句的作用了。同樣,為了理解"中斷"的含義,我特別建議站在可以看見整個系統的角度,去看出現中斷時,整個系統這個"大程式"是如何執行的。

    時鐘中斷(硬體觸發,對於軟體是被動的)、異常(軟體缺頁、除0bug等情況無意觸發)、陷阱(軟體顯式執行int指令觸發)出現時,都會穿過一道"門",跳轉到核心在"門"中設定的指令地址處執行,所以它們本質上和執行jmp、call、rte等跳轉指令一樣,都是打斷"大程式"的順序執行,跳轉到指定的指令處執行,只不過在跳轉前,CPU硬體層還會做一些額外的操作

    中斷、異常、陷阱相互之間,只在兩點上稍有區別(根據本篇筆記稍後的內容,可以明白為什麼需要這些區別):

    ① 緊接著中斷的發生,硬體層是否關閉該型別的中斷;

    ② 穿過"門"的時候,CPL/RPL許可權檢查的邏輯。

    中斷型別(中斷/異常/陷阱)的區別,在於觸發的形式不同,而"門"型別(中斷門/陷阱門/呼叫門/任務門)的區別,在於穿過"門"時,硬體層執行的動作不同(主要有DPL檢查邏輯、壓棧內容、返回指令的位置)。Linux幾乎只使用了中斷門和陷阱門,外設觸發的是中斷門,CPU本身的異常和int指令,觸發的都是陷阱門:
Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號

2. 外設通用中斷(使用中斷門)

  • 中斷過程分析
        Linux核心筆記008已經介紹過,i386的系統結構支援256箇中斷向量,0~19號"門",必須按照CPU的硬體規範進行設定,其餘的"門"由核心自行使用。其中,Linux核心選擇使用80號"門"實現系統呼叫功能,用於提供給應用層程式,透過執行int指令,切換到核心態,而將從0x20號開始的其它223個"門",設計成了通用中斷通道,用於提供給外設使用。有了外設通用中斷通道,中斷控制器監測到某個外設的某個動作時,向CPU傳送中斷訊號後,CPU硬體層的邏輯,會自動且原子的執行一組指令,進行棧的切換(如果執行級別改變)和部分暫存器的壓棧操作(見下圖),並穿過相應的"門",跳轉到IRQ0xXX_interrupt程式碼處執行(見以下程式碼片段[code1]、[code2]、[code3])。
Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號
[code1]arch/i386/kernel/i8259.c,36~51:
BUILD_COMMON_IRQ()    // 展開得到:common_interrupt程式碼塊定義(見[code3])

// ③ 展開得到16個程式碼塊的定義:IRQ0x00_interrupt~IRQ0x0f_interrupt(見[code2])
#define BI(x,y) \
	BUILD_IRQ(x##y)

// ② 展開得到:BUILD_IRQ(0x00)~BUILD_RIQ(0x0f)
#define BUILD_16_IRQS(x) \
	BI(x,0) BI(x,1) BI(x,2) BI(x,3) \
	BI(x,4) BI(x,5) BI(x,6) BI(x,7) \
	BI(x,8) BI(x,9) BI(x,a) BI(x,b) \
	BI(x,c) BI(x,d) BI(x,e) BI(x,f)

// ① 展開得到:BI(0x0,0)~BI(0x,f)
/*
 * ISA PIC or low IO-APIC triggered (INTA-cycle or APIC) interrupts:
 * (these are usually mapped to vectors 0x20-0x2f)
 */
BUILD_16_IRQS(0x0)
[code2]include/asm-i386/hw_irq.h,172~178:
// 所有的IRQ0xXX_interrupt,都將(xx-256)壓入棧中,然後跳轉到common_interrupt處執行
#define BUILD_IRQ(nr) \
asmlinkage void IRQ_NAME(nr); \
__asm__( \
"\n"__ALIGN_STR"\n" \
SYMBOL_NAME_STR(IRQ) #nr "_interrupt:\n\t" \
	"pushl $"#nr"-256\n\t" \
	"jmp common_interrupt");
[code3]include/asm-i386/hw_irq.h,152~160:
/*
 * ① SAVE_ALL:向棧中壓入一個struct pt_regs結構資料
 * ② pushl $ret_from_intr,向棧中壓入ret_from_intr指令地址
 * ③ jmp到do_IRQ()函式,注意不是call,所以步驟②壓入的指令地址,對於do_IRQ()函式來說,是返回地址 !!
 *   do_IRQ()函式原型:asmlinkage unsigned int do_IRQ(struct pt_regs regs),注意引數型別是一個完整的結構,而不是指標,所以正好是步驟①壓入引數 !!
*/
#define BUILD_COMMON_IRQ() \
asmlinkage void call_do_IRQ(void); \
__asm__( \
	"\n" __ALIGN_STR"\n" \
	"common_interrupt:\n\t" \
	SAVE_ALL \
	"pushl $ret_from_intr\n\t" \
	SYMBOL_NAME_STR(call_do_IRQ)":\n\t" \
	"jmp "SYMBOL_NAME_STR(do_IRQ));

    根據[code1]、[code2]、[code3]三處程式碼,可歸納中斷髮生時的跳轉過程:

IRQ0x00_interrupt           // 程式碼塊
  |- jmp common_interrupt   // 程式碼塊
       |- jmp do_IRQ()      // 函式
    其中,SAVE_ALL執行過後,棧中的內容如下圖所示,同時由於希望do_IRQ()函式返回到ret_from_intr指令處,而不是"jmp $do_IRQ"的下一條指令,所以common_interrupt是將$ret_from_intr壓棧,透過jmp指令"呼叫"do_IRQ()函式,並且在返回地址上面構造的是do_IRQ()函式的引數。

Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號

    進入do_IRQ()函式後,根據IRQ0xXX_interrupt向棧的ORIG_EAX位置壓入的通用中斷號,並遍歷執行irq_desc[regs.orig_eax].action連結串列中的函式:

do_IRQ()
 |- irq = regs.orig_eax & 0xff
 |- spin_lock(&desc->lock)
 |- IRQ_INPROGRESS
 |-  for(;;)
 |    |- IRQ_PENDING
 |    |- handle_IRQ_event()
 |- do_softirq()
    ① spin_lock(&desc->lock)的作用
        中斷門與陷阱門硬體特性的區別是,CPU穿過中斷門時,會自動關閉中斷(將EFLAGS暫存器中的"I"標誌位清零,在該標誌位重新置1之前,硬體層不再響應任何中斷源傳送的中斷訊號),而穿過陷阱門時,則不會。do_IRQ()函式,正是透過中斷門執行到的,雖然當前CPU不會再次透過中斷門執行進來,但其它CPU仍然可以,所以加鎖保證多核之間相互干擾。
    ② IRQ_INPROGRESS、IRQ_PENDING標誌的作用
        根據do_IRQ()的原始碼可以看出,並不是整個函式都是加鎖的,在handle_IRQ_event()呼叫期間,就是unlock的,因為遍歷執行irq_desc[regs.orig_eax].action連結串列中的函式,可能需要花費很長的時間,所以在呼叫之前unlock,可以避免其它CPU也跟著一起白白的等待。但這並不表示handle_IRQ_event()可以由多個核同時執行,相反,核心最終就是要保證handle_IRQ_event()不會被多核同時執行,只是為了避免鎖加的太粗暴而已。
        為了保護handle_IRQ_event(),加了鎖,卻又為了減小核與核之間的競爭,反而又將handle_IRQ_event()放在鎖的範圍之外,總而言之,是不是感覺白白加了個廢鎖?
        其實,這裡是將中斷巢狀轉化成了一個迴圈,或者說是將handle_IRQ_event()的執行"序列化"  。已經有一個CPU正在執行handle_IRQ_event()時,會設定一下IRQ_INPROGRESS標誌,其它CPU此時進入do_IRQ()執行時,發現設定了IRQ_INPROGRESS標誌,就不會也進入handle_IRQ_event(),而是設定一下IRQ_PENDING標誌就返回了,這樣,執行handle_IRQ_event()的CPU,會在for(;;)迴圈中,再次進入handle_IRQ_event()執行。
        在handle_IRQ_event()執行期間,可能有多個核進入do_IRQ()函式設定IRQ_PENDING標誌,也有可能某一個核設定多次,最終都只會再呼叫一次handle_IRQ_event()函式,比如網路卡接收了3個報文,進入do_IRQ()三次,但最終可能只呼叫handle_IRQ_event()一次,同時處理了快取中的3個接收報文。

     Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號

    ③ handle_IRQ_event()函式開中斷執行

        EFLAGS暫存器並不能精確控制每道"門"的開關,只能透過一個"I"標誌位,整體開啟/關閉所有"門",所以從CPU穿過某道中斷門到再次將"I"標誌位置1期間,任何中斷源傳送的中斷訊號,都會丟失,雖然中斷源沒有收到CPU的響應訊號,一般會再次傳送,Linux核心還是透過軟體層的設計,儘量緩解中斷訊號的丟失。其實,不同irq_desc[X].action連結串列中的函式,既然是處理不同的外設中斷,所以訪問的資源一般是相互獨立的,很少會出現競爭的情況,所以Linux核心將是否允許重入handle_IRQ_event()(即正在該函式內部執行時接收到中斷訊號,又要重新從IRQ0xXX_interrupt開始,執行到該函式),留給action開發者選擇。

        比如,如果開發者可以保證,irq_desc[0x00].action連結串列上的函式,與其它action連結串列上的函式,不存在資源競爭,那麼,就可以透過設定actions->flags的SA_INTERRUPT標誌,讓handle_IRQ_event()函式在入口處執行sti指令,快速恢復當前CPU的中斷功能。這樣,由於do_IRQ()在呼叫handle_IRQ_event()前,執行了unlock,所以再次進入do_IRQ(),不會發生死鎖;另外,由於沒有資源競爭,所以交叉執行也不會有任何問題(irq_desc[0x00].action未執行完 -> 執行irq_desc[0x01].action -> 根據中斷時儲存的現場,恢復執行irq_desc[0x00].action)。

      Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號

        還有另外一種場景:CPU正在執行0號通用中斷的處理函式,這時又產生了0號通用中斷。其實,這就跟多CPU同時執行同一中斷通道的場景相同, handle_IRQ_event()會被IRQ_INPROGRESS、IRQ_PENDING"序列化"執行,所以也沒有問題:

      Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號

        可以看出,選擇中斷門和使用"序列化"設計,對外設中斷進行管理,大大減小了action開發者的負擔,相應也減少了產生bug的根源。

  • Bottom Half
        do_IRQ()執行完handle_IRQ_event(),還會在清空IRQ_INPROGRESS標誌和unlock之後,呼叫do_softirq()函式(比Linux-2.4.0版本老一些的核心中,呼叫的是do_bottom_half()函式)
     Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號
        相對於SA_INTERRUPT標誌,do_softirq()是用於提供給通用中斷使用者,更精細化的選擇"開中斷"執行的範圍。
        這塊邏輯比較繞,直接看圖吧:
Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號
        初始化:
softirq_init()
 |- tasklet_init()  // 初始化bh_task_vec[32],func成員都指向bh_action()
 |- open_softirq()  // 初始化softirq_vec[32]
     |- softirq_vec[HI_SOFTIRQ].action = tasklet_hi_action()      // HI_SOFTIRQ用於相容老的bottom hafl機制
     |- softirq_vec[TASKLET_SOFTIRQ].action = tasklet_action()    // TASKLET_SOFTIRQ用於新擴充套件的bottom hafl機制
	 |- irq_stat[所有CPU].__softirq_mask的HI_SOFTIRQ、TASKLET_SOFTIRQ位置1,從而每次執行do_softirq()時,就會呼叫tasklet_hi_action()、tasklet_action()

sched_init()        // 別的模組也會根據需要註冊其它bh函式
 |- init_bh(TIMER_BH, timer_bh)
 |- init_bh(TQUEUE_BH, tqueue_bh)
 |- init_bh(IMMEDIATE_BH, immediate_bh)
        這時,再假設CPU0接收到某個中斷訊號,順著整個中斷的響應過程過一遍,就會發現:
        這套設計,是為了將耗時並且不要求在"關中斷"條件下執行的操作,從handle_IRQ_event()->action中"支開",action完成少量必須在"關中斷"條件下執行的操作後,然後只要透過標記"通知"一下do_softirq()後續需要做什麼,就可以快速恢復中斷功能了。
        其中,HI_SOFTIRQ、TASKLET_SOFTIRQ,是在老版本bottom half機制上又擴充套件的一層邏輯,HI_SOFTIRQ在呼叫到最終的bh_base[X]()之前,必須先經過bh_action()函式,bh_action()函式中做了很嚴格的保護操作,使得多核之間的競爭和中斷丟換更多,同時也對bh函式的實現要求更低,TASKLET_SOFTIRQ相反,核心或驅動開發者,可以根據實際需要和對核心全域性瞭解的程度進行選擇。
    Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號
  • 訊號

        中斷/異常:硬體對核心的中斷;
        訊號:核心對應用程式的中斷。

3. 缺頁異常(使用陷阱門,DPL為0)

    跟IRQ0xXX_interrupt型別,直接看程式碼。

arch/i386/kernel/entry.S,410~412:

ENTRY(page_fault)
	pushl $ SYMBOL_NAME(do_page_fault)
	jmp error_code
arch/i386/kernel/entry.S,295~321:
error_code:
	pushl %ds
	pushl %eax
	xorl %eax,%eax
	pushl %ebp
	pushl %edi
	pushl %esi
	pushl %edx
	decl %eax			# eax = -1
	pushl %ecx
	pushl %ebx
	cld
	movl %es,%ecx
	movl ORIG_EAX(%esp), %esi	# get the error code(硬體自動壓入)
	movl ES(%esp), %edi		# get the function address(執行page_fault時壓入)
	movl %eax, ORIG_EAX(%esp)
	movl %ecx, ES(%esp)
	movl %esp,%edx
	pushl %esi			# push the error code(do_page_fault()的error_code引數)
	pushl %edx			# push the pt_regs pointer(do_page_fault()的regs引數)
	movl $(__KERNEL_DS),%edx
	movl %edx,%ds
	movl %edx,%es
	GET_CURRENT(%ebx)
	call *%edi                      # 呼叫do_page_fault()
	addl $8,%esp
	jmp ret_from_exception
    error_code和common_interrupt類似,也是一份公用程式碼,CPU發生各種異常時,最終都會執行到這裡。但是,對比這裡的pushl指令和SAVE_ALL的程式碼,就會發現最開始少了一條"pushl %es"指令,那是因為缺頁異常時,硬體除了將"EFLAGS->CS->EIP"自動壓棧,還會壓入一個導致缺頁異常的錯誤碼(也是棧的這個位置叫ORIG_EAX的原因),核心為了讓中斷、異常的最終處理函式,可以統一使用pt_regs結構,所以還是按照pt_regs結構壓棧,最後再用"movl %es,%ecx"和"movl %ecx, ES(%esp)"兩條指令,將es暫存器的值,存入棧的ES位置處。
Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號
    但是,有些異常沒有更加詳細的錯誤碼,相應的,CPU也不會向棧中多壓個值,為了仍然可以使用error_code處的程式碼,異常入口處,會向棧中補壓一個值,比如:
ENTRY(coprocessor_error)
	pushl $0
	pushl $ SYMBOL_NAME(do_coprocessor_error)
	jmp error_code
arch/i386/mm/fault.c,106:

void do_page_fault(struct pt_regs *regs, unsigned long error_code)

4. 時鐘中斷(使用中斷門)
    時鐘中斷使用的是0號通用中斷門:
time_init()  // arch/i386/kernel/time.c, 626~706
 |- setup_irq(0, &irq0)  // 向irq_desc[0]註冊action

// arch/i386/kernel/time.c, 547
static struct irqaction irq0 = { timer_interrupt, SA_INTERRUPT, 0, "timer", NULL, NULL};
    對於整個系統這個"大程式"來說,如果沒有時鐘中斷進行強制"跳轉",任何一個地方死迴圈(包括核心無法預測的應用程式),就會導致整個系統不工作,相反,就始終有回到核心程式碼,排程其它部分執行的機會。

5. 系統呼叫(使用陷阱門,DPL為3)

    跟CPU異常一樣,系統呼叫也是用陷阱門實現:

static void __init set_trap_gate(unsigned int n, void *addr)
{
	_set_gate(idt_table+n,15,0,addr);    // 15: D:1,type:111(陷阱門),DPL: 0
}

static void __init set_system_gate(unsigned int n, void *addr)
{
	_set_gate(idt_table+n,15,3,addr);    // 15: D:1,type:111(陷阱門),DPL: 3
}
    外設中斷和CPU異常時,硬體會忽略DPL檢查,所以異常處理程式對應的"門",DPL設定為0,是用於防止程式在使用者態穿過該"門",而系統呼叫正是提供給應用程式呼叫核心的介面,所以DPL設定為3。
    按照書上sethostname()系統呼叫的例子,過一遍:
// int sethostname(cost char *name, size_t len);
00000000 <sethostname>:
0: 89 da                mov %ebx,%edx
2: 8b 4c 24 08          mov 0x8(%esp,1),%ecx  # len 引數
6: 8b 5c 24 04          mov 0x4(%esp,1),%ebx  # name引數
a: b8 4a 00 00 00       mov $0x4a,%eax        # sethostname()函式對應的系統呼叫號
f: cd 80                int $0x80
11: 89 d3               mov %edx,%ebx
13: 3d 01 f0 ff ff      cmp $0xfffff001,%eax       # eax暫存器為核心介面的返回值,負數表示出錯
18: 0f 83 fc ff ff ff   jae 1a <sethostname+0x1a>  # 重定位後,為__syscall_error()函式地址(將exa絕對值儲存到errno,並將eax修改為-1,表示向上層程式返回-1)
1e: c3                  ret
    esp暫存器指向sethostname()函式的棧頂,沿著地址的增加,分別為返回地址、name引數、len引數,但由於系統呼叫會導致CPU執行級別變化,所以核心介面使用的是切換後的棧,所以必須複製到暫存器中傳給核心介面。
    然後,CPU透過80號中斷向量,"跳轉"到system_call程式碼處:
ENTRY(system_call)
	pushl %eax			# save orig_eax,將eax暫存器中的系統呼叫號,壓入系統棧的ORIG_EAX位置(終於看到這個名稱的來歷,外設中斷時儲存中斷號,異常時儲存錯誤碼)
	SAVE_ALL                        # SAVE_ALL最後壓入棧中的ecx、ebx,正好為long sys_sethostname(char *name, int len)的引數,跟外設中斷和異常的處理函式不同,引數不再是struct pt_regs結構
	GET_CURRENT(%ebx)               # 將當前程式的task_struct管理結構的地址,儲存到ebx暫存器(第四章)
	cmpl $(NR_syscalls),%eax
	jae badsys
	testb $0x02,tsk_ptrace(%ebx)	# PT_TRACESYS,如果當前程式被strace除錯工具跟蹤,跳轉到tracesys()執行(暫不關心)
	jne tracesys
	call *SYMBOL_NAME(sys_call_table)(,%eax,4)    # 跳轉到系統呼叫號對應的函式執行,即sys_sethostname()
	movl %eax,EAX(%esp)		# save the return value
ENTRY(ret_from_sys_call)
#ifdef CONFIG_SMP
	movl processor(%ebx),%eax
	shll $CONFIG_X86_L1_CACHE_SHIFT,%eax
	movl SYMBOL_NAME(irq_stat)(,%eax),%ecx		# softirq_active
	testl SYMBOL_NAME(irq_stat)+4(,%eax),%ecx	# softirq_mask
#else
	movl SYMBOL_NAME(irq_stat),%ecx		# softirq_active
	testl SYMBOL_NAME(irq_stat)+4,%ecx	# softirq_mask
#endif
	jne   handle_softirq

################# 以下部分,暫時瞭解即可 #################
ret_with_reschedule:
	cmpl $0,need_resched(%ebx)
	jne reschedule                  # 程式排程(第四章)
	cmpl $0,sigpending(%ebx)
	jne signal_return               # 訊號(第六章)
restore_all:
	RESTORE_ALL

	ALIGN
signal_return:
	sti				# we can get here from an interrupt handler
	testl $(VM_MASK),EFLAGS(%esp)
	movl %esp,%eax
	jne v86_signal_return
	xorl %edx,%edx
	call SYMBOL_NAME(do_signal)    # 執行應用程式中的訊號處理函式
	jmp restore_all
    從system_call入口,最終是進入了sys_sethostname()函式:
sys_sethostname()
 |- copy_from_user()  // 將主機名修改到核心空間,從而所有程式可以看到新的主機名
     |- ..
         |- __copy_user_zeroing()  // 彙編程式碼,建議仔細品一品
    __copy_user_zeroing()程式碼分析:
Linux核心筆記009 - 中斷、異常、陷阱、Bottom half、訊號
    sys_sethostname()的name指標是從使用者態傳過來的,為了確保這個指標沒有指飛,老版本核心是根據當前程式的mm_struct(記錄已使用的虛擬區間,見Linux核心筆記005)進行檢查,但是存在bug的程式碼相比於正常的程式碼,往往很少很少,換句話說,對於絕大多數這種情況,都是白白的做一次低效的檢查。所以,新版本的核心取消了對name指標的合法性檢查,直接使用,如果真的遇到錯誤指標,肯定會觸發缺頁異常進入do_page_fault()函式,就是說對這種情況的處理,可以移到do_page_fault()函式中實現:
// do_page_fault()函式片段
no_context:
	/* Are we prepared to handle this kernel fault?  */
	if ((fixup = search_exception_table(regs->eip)) != 0) {    // 在"異常表"中,查詢導致異常的那條指令的地址(__copy_user_zeroing()的後面部分程式碼,就是向該表中加入"出錯指令地址-修復地址"對應關係)
		regs->eip = fixup;    // 如果找到了,修改異常程式的eip,讓它跳轉到修復地址執行(否則回到原指令,又會觸發缺頁異常)
		return;
	}
        ...
do_sigbus:
	...

	/* Kernel mode? Handle exceptions or die */
	if (!(error_code & 4))    // 在核心態發生的缺頁異常
		goto no_context;
	return;
    這樣,再回到__copy_user_zeroing()看黑色字型的註釋,就很容易理解了,gcc會將程式中.section屬性指定的內容,新增到elf編譯檔案中的相應段中,執行時,由ld載入到記憶體作為"異常表"。除了__copy_user_zeroing()函式,SAVE_ALL中和iret指令也可能因為同樣的原因,發生缺頁異常,書中都已經解釋了原因,筆記中就不一一搬過來了。

相關文章