伯樂線上補充:
《Linux Insides》是 0xAX 童鞋在編寫的一本將 Linux 核心的書。之前伯樂線上已摘譯其中幾篇:《Linux 核心資料結構:雙向連結串列》、《Linux 核心資料結構:Radix 樹》、《Linux 核心記憶體管理(1)》。
簡介
這是《Linux Insides》新章節的第一部分。我們已經學習了本書的前幾個章節。從最開始核心的初始化過程到最終第一個init程式的啟動。這其中包含很多核心子系統相關的初始化過程。但是,我們並未深入探討這些子系統的實現細節。本章中,我們將嘗試理解各種核心子系統是如何工作和實現的。正如本章標題,第一個研究的子系統是中斷。
什麼是中斷?
在本書的許多章節中,我們都遇到中斷一詞,甚至碰到過一些中斷處理例程。本節將從理論部分開始,即:
- 什麼是中斷?
- 什麼是中斷處理程式?
然後將繼續深入探討中斷的實現細節,以及Linux核心是如何處理中斷的。
那麼,首先中斷是什麼?中斷是軟體或硬體在需要CPU關注時發出的一個事件。例如,按下鍵盤上的按鍵,我們期望發生什麼?作業系統和計算機在按下鍵後應該進行哪些操作?為簡化問題,假設每個外設均有一條連線至CPU的中斷線。裝置可以通過中斷線向CPU傳送中斷訊號。但是,中斷訊號並非直接傳送至CPU。在傳統計算機中,由PIC晶片負責序列處理來自多個裝置的多重中斷請求。在現代計算機中,由稱為APIC的高階可程式設計中斷控制器負責。APIC由兩個獨立裝置組成:
- 本地高階中斷控制器(LAPIC)
- I/O高階中斷控制器(IOAPIC)
第一個,LAPIC位於每一個CPU核心內。LAPIC負責處理CPU相關的中斷配置。通常用於管理來自APIC定時器、溫度感測器和其他所有本地連線的I/O裝置的中斷。
第二個,IOAPIC提供多處理器中斷管理。它將外部中斷分配給各個CPU核心。更多關於LAPIC和IOAPIC的內容將在本章後續內容中討論。通常,中斷的發生是隨機的。一旦中斷觸發,作業系統必須立即處理。但是處理中斷意味著什麼?當中斷觸發時,作業系統必須執行以下步驟:
- 核心必須停止當前正在執行的進行;(取代當前任務)
- 核心必須匹配中斷處理程式並轉移控制;(執行中斷處理程式)
- 在中斷處理程式執行完成後,被中斷程式能夠恢復執行;
當然,處理中斷的過程涉及很多複雜操作。但是以上三個步驟是這一過程的基本框架。
每一箇中斷處理的地址都存放在固定位置,稱為中斷描述符表或者IDT。處理器使用唯一的數值來標識中斷或異常型別,該數值被稱為中斷向量。中斷向量是IDT表中的一個序號。中斷向量的數量是有限的,從0到255。在Linux核心原始碼中對中斷向量範圍進行了如下檢查:
1 |
BUG_ON((unsigned)n > 0xFF); |
你可以在Linux核心原始碼中斷建立相關部分找到該檢查(例如arch/x86/include/asm/desc.h中的set_intr_gate、void set_system_intr_gate)。0到31這32個初始向量保留給處理器使用,用於處理體系定義的異常和中斷。你可以在Linux核心初始化程式第二部分找到這些向量的描述表——初始中斷和異常處理。32-255號向量是提供給使用者自定義的中斷,未保留給處理器使用。這些中斷通常分配給外圍I/O裝置,允許這些裝置向處理器傳送中斷請求。
下面討論中斷的型別。廣義上講,中斷可以分為兩大類:
- 外設或硬體產生的中斷;
- 軟體產生的中斷。
第一個,外部中斷通過LAPIC或連線至LAPIC的處理器引腳傳輸至處理器。第二個,軟體中斷是由於處理器自身異常(有時使用架構相關的特殊指令)造成。常見例子是除零異常。另一個例子是使用syscall指令退出程式。
正如前面提到的,中斷可以在任何時候因程式碼和CPU失控引發。另一方面,異常和程式執行是同步的,可以分為3類:
- 錯誤
- 陷阱
- 終止
錯誤是在引起錯誤的指令(可以恢復)執行之前發出的“異常”。如果恢復,被中斷的程式可以恢復。
下一個,陷阱(trap)是在引起陷阱的指令執行之後發出的異常。陷阱和錯誤一樣,也允許被中斷的程式恢復。
最後,終止(abort)是不報告引起異常具體指令的一種異常,不允許被中斷程式恢復。
從前面部分,我們知道中斷可分為可遮蔽型(naskable)和非遮蔽型。可遮蔽中斷是可以使用指令遮蔽的中斷,x86_64下可使用sti和cli指令。在Linux核心原始碼中可以找到它們:
1 2 3 4 |
static inline void native_irq_disable(void) { asm volatile("cli": : :"memory"); } |
和
1 2 3 4 |
static inline void native_irq_enable(void) { asm volatile("sti": : :"memory"); } |
這兩條指令修改了中斷暫存器中的IF標誌位。sti指令將IF標誌位置位,cli指令將該標誌位復位。非遮蔽中斷必須響應。通常,所有硬體錯誤都會被對映成非遮蔽中斷。
如果多個異常或中斷同時觸發,處理器會按照預定義優先順序進行處理。我們可以從下表中判斷最高到最低優先順序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
+----------------------------------------------------------------+ | | | | 優先順序 | 描述 | | | | +--------------+-------------------------------------------------+ | | 硬體復位和機器檢查 | | 1 | - RESET | | | - 機器檢查 | +--------------+-------------------------------------------------+ | | 在任務切換上陷入 | | 2 | - 在TSS中的T標誌設定 | | | | +--------------+-------------------------------------------------+ | | 外部硬體干涉 | | | - FLUSH | | 3 | - STOPCLK | | | - SMI | | | - INIT | +--------------+-------------------------------------------------+ | | 在上一條指令上陷入 | | 4 | - 斷點 | | | - 除錯陷入異常 | +--------------+-------------------------------------------------+ | 5 | 非可遮蔽中斷 | +--------------+-------------------------------------------------+ | 6 | 可遮蔽硬體中斷 | +--------------+-------------------------------------------------+ | 7 | 程式碼斷點故障 | +--------------+-------------------------------------------------+ | 8 | 取下一條指令故障 | | | 程式碼段界限違反 | | | 碼段頁故障 | +--------------+-------------------------------------------------+ | | 解碼下一條指令故障 | | | 指令長度 > 15位元組 | | 9 | 無效操作碼 | | | 協處理器不可用 | | | | +--------------+-------------------------------------------------+ | 10 | 執行指令故障 | | | 溢位 | | | 邊界出錯 | | | 無效TSS | | | 段不存在 | | | 堆疊故障 | | | 通用保護 | | | 資料頁故障 | | | 對齊檢查 | | | x87 FPU 浮點異常 | | | SIMD 浮點異常 | | | 虛擬化異常 | +--------------+-------------------------------------------------+ |
既然對各種中斷和異常有了初步瞭解,現在可以深入到更加實用的部分。從中斷描述符表的描述開始。正如前面所提到的,IDT中存放了中斷和異常處理的入口點。IDT在結構上與Kernel啟動第二部分的全域性描述符表類似。但肯定有些許區別。與描述符不同,IDT表項稱為門(gate)。它包含下列門中的一種:
- 中斷門
- 任務門
- 陷阱門
在x86架構中。x86_64下只能引用長模式(long mode)中斷門和陷阱門。和全域性描述符表相同的是中斷描述符表是一個門陣列,在x86上為8位元組門陣列,x86_64上為16位元組門陣列。回想核心啟動過程第二部分,全域性描述符表必須包含空描述符作為其第一個元素。和全域性描述符表不同的是,中斷描述符表可包含一個門,但並非強制要求。例如,你也許記得在過渡到保護模式的最初階段,中斷描述符表載入過NULL門:
1 2 3 4 5 6 7 8 |
/* * Set up the IDT */ static void setup_idt(void) { static const struct gdt_ptr null_idt = {0, 0}; asm volatile("lidtl %0" : : "m" (null_idt)); } |
在arch/x86/boot/pm.c檔案中。中斷描述符表可以駐留線上性地址空間的任何地方,並且其基址在x86上必須和8位元組邊界對齊,在x86_64上必須和16位元組邊界對齊。IDT的基地址存放在指定暫存器——IDTR。在x86相容處理器上有兩條指令可以修改IDTR暫存器:
- LIDT
- SIDT
第一條指令LIDT用於載入IDT的基址,即指定運算元到IDTR。第二條指令SIDT用於讀取並儲存IDTR的內容到指定運算元。IDTR暫存器在x86上是48位,包含以下資訊:
1 2 3 4 5 6 |
+-----------------------------------+----------------------+ | | | | Base address of the IDT | Limit of the IDT | | | | +-----------------------------------+----------------------+ 47 16 15 0 |
分析setup_idt的實現,我們定義了一個null_idt並使用lidt指令將其載入到IDTR暫存器中。需要注意的是,null_idt包含的gdt_ptr型別定義如下:
1 2 3 4 |
struct gdt_ptr { u16 len; u32 ptr; } __attribute__((packed)); |
這裡我們可以看到結構體的定義如圖所示,內部由兩位元組和四位元組(總共48位)的兩個域組成。下面我們來分析一下IDT表項的結構。IDT表項結構體在x86_64中是一個稱為門的16位元組陣列。其結構如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
127 96 +-------------------------------------------------------------------------------+ | | | Reserved | | | +-------------------------------------------------------------------------------- 95 64 +-------------------------------------------------------------------------------+ | | | Offset 63..32 | | | +-------------------------------------------------------------------------------+ 63 48 47 46 44 42 39 34 32 +-------------------------------------------------------------------------------+ | | | D | | | | | | | | Offset 31..16 | P | P | 0 |Type |0 0 0 | 0 | 0 | IST | | | | L | | | | | | | -------------------------------------------------------------------------------+ 31 16 15 0 +-------------------------------------------------------------------------------+ | | | | Segment Selector | Offset 15..0 | | | | +-------------------------------------------------------------------------------+ |
為了構成IDT表中的一個索引值,處理器將異常或中斷向量號乘以16。處理器處理異常和中斷,就像遇到呼叫指令執行程式呼叫一樣。處理器使用唯一的中斷或異常數值或向量值作為查詢相應中斷描述符表的表項的編號。下面我們具體分析一下IDT表項。
正如我們看到的,圖表中的IDT表項由以下域組成:
- 0-15位 —— 相對於段選擇器的偏移地址,處理器將段選擇器作為中斷處理入口的基地址;
- 16-31位 —— 中斷處理入口所在段選擇基地址;
- IST —— x86_64中新的特殊機制,稍後介紹;
- DPL —— 描述符特權級;
- P —— 段存在標誌;
- 48-63位 —— 中斷處理基地址第二部分;
- 64-95位 —— 中斷處理基地址第三部分;
- 96-127位 —— CPU保留位
最後一個Type域描述了IDT表項的型別。中斷處理有三種型別:
- 中斷門
- 陷阱門
- 任務門
IST或中斷棧表是x86_64中採用的新機制。它是傳統堆疊切換機制的一種替換。傳統x86體系為中斷響應提供自動切換棧幀機制。IST是x86棧切換模式的演化版本。當該機制開啟並用於特定中斷(我們很快能看到)相關IDT表項的所有中斷時,該機制會無條件切換棧。從這一點可以看出,IST並非對所有中斷都必要。一些中斷可以沿用傳統棧切換模式。IST機制為包含程式資訊的特殊結構體——任務狀態段或TSS提供多至7個IST指標。TSS用於Linux核心中斷或異常執行期間的棧切換。每一個指標均由IDT中的中斷門引用。
中斷描述符表由gate_desc結構體陣列表示:
1 |
extern gate_desc idt_table[]; |
gate_desc定義如下:
1 2 3 4 5 6 7 8 9 |
#ifdef CONFIG_X86_64 ... ... ... typedef struct gate_struct64 gate_desc; ... ... ... #endif |
gate_struct64定義如下:
1 2 3 4 5 6 7 8 |
struct gate_struct64 { u16 offset_low; u16 segment; unsigned ist : 3, zero0 : 5, type : 5, dpl : 2, p : 1; u16 offset_middle; u32 offset_high; u32 zero1; } __attribute__((packed)); |
x86_64架構下,每一個活動執行緒在Linux核心中都擁有一個很大的棧。棧尺寸定義為THREAD_SIZE,定義如下:
1 2 3 4 5 6 7 |
#define PAGE_SHIFT 12 #define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT) ... ... ... #define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER) #define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER) |
PAGE_SIZE為4096位元組,THREAD_SIZE_ORDER取決於KASAN_STACK_ORDER。如上所示,KASAN_STACK取決於CONFIG_KASAN核心配置引數,定義如下:
1 2 3 4 5 |
#ifdef CONFIG_KASAN #define KASAN_STACK_ORDER 1 #else #define KASAN_STACK_ORDER 0 #endif |
KASan是執行時記憶體偵錯程式。那麼…,如果未配置CONFIG_KASAN,THREAD_SIZE為16384位元組,如果在核心中配置該選項時是32768位元組。只要一個執行緒處於活動或者殘留狀態,這些棧就包含有用資料。當執行緒在使用者空間時,除棧底的thread_info結構體外(該結構體的詳細資訊,可參見Linux核心初始化過程的第四部分),核心棧是空的。活動執行緒或殭屍執行緒不是唯一擁有棧的執行緒。還存在與每一個可用CPU相關的特殊棧。當核心在該CPU上執行時,這些棧就有用。當使用者空間程式在CPU上執行時,這些棧不包含任何有用資訊。每一個CPU都包含一些專門的cpu特有棧。首先是用於外部硬體中斷的中斷棧。它的尺寸取決於下面的定義:
1 2 |
#define IRQ_STACK_ORDER (2 + KASAN_STACK_ORDER) #define IRQ_STACK_SIZE (PAGE_SIZE << IRQ_STACK_ORDER) |
或者16384位元組。cpu特有中斷棧在x86_64中由Linux核心中的irq_stack_union聯合體表示:
1 2 3 4 5 6 7 8 |
union irq_stack_union { char irq_stack[IRQ_STACK_SIZE]; struct { char gs_base[40]; unsigned long stack_canary; }; }; |
irq_stack第一個域是一個16KB的陣列。irq_stack_union還包含有兩個域的結構體:
- gs_base —— gs暫存器總是指向irqstack聯合體的底部。在x86_64上,cpu特有區域和stack canary(更多關於cpu特有變數的內容,可以閱讀專欄部分)共享gs暫存器。所有cpu特有符號都是都是基於零索引,gs指向cpu特有區域的基地址。分段記憶體模型在長模式下不可用,但我們可以設定MSR暫存器的fs和gs兩個段暫存器的基地址,這些暫存器仍可作為地址暫存器。如果你還記得Linux核心初始化過程的第一部分,你可能記得我們設定了gs暫存器:
1 2 3 4 |
movl $MSR_GS_BASE,%ecx movl initial_gs(%rip),%eax movl initial_gs+4(%rip),%edx wrmsr |
這裡的 initial_gs 指向irq_stack_union:
1 2 |
GLOBAL(initial_gs) .quad INIT_PER_CPU_VAR(irq_stack_union) |
- stack_canary —— 中斷棧的stack canary是一個棧保護器,用於檢測棧沒有被覆蓋。注意gs_base是一個40位元組的陣列。GCC要求stack canary相對gs基地址的偏移是固定,並且其值在x86_64上必須是40,在x86上必須是20。
irq_stack_union是cpu特有區域的第一個資料塊,在System.map中定義如下:
1 2 3 4 5 6 7 |
0000000000000000 D __per_cpu_start 0000000000000000 D irq_stack_union 0000000000004000 d exception_stacks 0000000000009000 D gdt_page ... ... ... |
在程式碼中定義如下:
1 |
DECLARE_PER_CPU_FIRST(union irq_stack_union, irq_stack_union) __visible; |
接下來,分析一下irq_stack_union的初始化。除irq_stack_union定義外,arch/x86/include/asm/processor.h中還定義瞭如下cpu特有變數:
1 2 |
DECLARE_PER_CPU(char *, irq_stack_ptr); DECLARE_PER_CPU(unsigned int, irq_count); |
首先是irq_stack_ptr。從變數名可知,很明顯這是一個指向棧頂的指標。第二個,irq_count用於檢查CPU是否處於中斷棧。irq_stack_ptr的初始化位於arch/x86/kernel/setup_percpu.c檔案中的setup_per_cpu_areas函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
void __init setup_per_cpu_areas(void) { ... ... #ifdef CONFIG_X86_64 for_each_possible_cpu(cpu) { ... ... ... per_cpu(irq_stack_ptr, cpu) = per_cpu(irq_stack_union.irq_stack, cpu) + IRQ_STACK_SIZE - 64; ... ... ... #endif ... ... } |
該函式逐個遍歷所有CPU並設定irq_stack_ptr。結果相當於將棧頂減去64。為何是64?如果你記得,在init/main.c中的start_kernel函式開始時呼叫boot_init_stack_canary函式設定了stack canary:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
static __always_inline void boot_init_stack_canary(void) { u64 canary; ... ... ... #ifdef CONFIG_X86_64 BUILD_BUG_ON(offsetof(union irq_stack_union, stack_canary) != 40); #endif // // getting canary value here // this_cpu_write(irq_stack_union.stack_canary, canary); ... ... ... } |
注意canary是64位。這是為何需要將中斷棧的大小減去64,從而避免stack canary值重疊。irq_stack_union.gs_base在load_percpu_segment函式中初始化,該函式定義於arch/x86/kernel/cpu/common.c檔案:
更多內容可參見wrmsl指令。
1 2 3 4 5 6 7 8 |
void load_percpu_segment(int cpu) { ... ... ... loadsegment(gs, 0); wrmsrl(MSR_GS_BASE, (unsigned long)per_cpu(irq_stack_union.gs_base, cpu)); } |
並且據我們所知,gs暫存器指向中斷棧的底部:
1 2 3 4 5 6 7 |
movl $MSR_GS_BASE,%ecx movl initial_gs(%rip),%eax movl initial_gs+4(%rip),%edx wrmsr GLOBAL(initial_gs) .quad INIT_PER_CPU_VAR(irq_stack_union) |
這裡的wrmsr指令用於將edx:eax中的資料載入到由ecx暫存器指向的MSR暫存器。這裡的MSR暫存器是MSR_GS_BASE,該暫存器包含gs暫存器指向的記憶體段的基地址。edx:eax指向initial_gs的地址,該地址是irq_stack_union的基地址。
我們知道,x86_64具備中斷棧表或IST功能。該特性為非遮蔽中斷、雙故障異常等事件提供了切換到新棧的功能。每個cpu最多可達7個IST表項。其中一些如下:
- DOUBLEFAULT_STACK
- NMI_STACK
- DEBUG_STACK
- MCE_STACK
或
1 2 3 4 |
#define DOUBLEFAULT_STACK 1 #define NMI_STACK 2 #define DEBUG_STACK 3 #define MCE_STACK 4 |
所有使用IST切換到新棧的中斷門描述符使用set_intr_gate_ist函式初始化。例如:
1 2 3 4 5 |
set_intr_gate_ist(X86_TRAP_NMI, &nmi, NMI_STACK); ... ... ... set_intr_gate_ist(X86_TRAP_DF, &double_fault, DOUBLEFAULT_STACK); |
其中&nmi和&double_fault是給定中斷處理的入口地址:
1 2 |
asmlinkage void nmi(void); asmlinkage void double_fault(void); |
定義在arch/x86/kernel/entry_64.S檔案中
1 2 3 4 5 6 7 8 9 |
idtentry double_fault do_double_fault has_error_code=1 paranoid=2 ... ... ... ENTRY(nmi) ... ... ... END(nmi) |
當一箇中斷或異常發生時,新ss選擇器強制置空,且ss選擇器的rpl域為新cpl。原來的ss、rsp、register flag、cs、rip被推入新棧。在64位模式下,推入的棧幀尺寸固定在8位元組,所以我們得到的棧如下:
1 2 3 4 5 6 7 8 9 10 |
+---------------+ | | | SS | 40 | RSP | 32 | RFLAGS | 24 | CS | 16 | RIP | 8 | Error code | 0 | | +---------------+ |
如果中斷門中的IST域不為零,我們將IST指標讀到rsp中。如果中斷向量值有一個與其相關的錯誤碼,我們將錯誤碼推入棧中。如果中斷向量值沒有錯誤碼,我們繼續執行,並將偽錯誤碼推入棧中。這樣做是為了確保棧的相容性。接下來,將門描述符中的段選擇載入到CS寄存器,並通過檢查第21位,即全域性描述符表的L位,來驗證目的碼段是64位模式程式碼段。最後,將門描述符中的offset域載入到rip中,這是中斷處理的入口點。然後中斷處理開始執行。完成中斷處理後,必須使用iret指令將控制權交還被中斷程式。iret指令無條件彈出棧指標(ss:rsp)恢復被中斷程式的棧,不取決於cpl的改變。
就這些。
總結
關於Linux核心中斷與處理的第一部分就到這裡。內容涉及一些理論只是以及與中斷和異常相關的初始化。接下來的部分,我們將繼續深入探討中斷和中斷處理 —— 深入更加實際的方面。
如果你有任何問題或建議,給我留言或在twitter上聯絡我。