Extreme Thinking
CPU中斷處理

2017-03-18


  從計算機系統內部看,中斷無時無刻不在,這篇博文就和大家一起探討中斷的原理,並以x86_64平台上的linux 3.10內核為例來分析底層實現細節。

什麼是中斷?

  中斷是一個系統過程,是計算系統中外部設備向CPU(或CPU之間)通知事件發生的一種機制。這種說法也許有些偏底層,可以從上層更直觀的角度來理解:想像你正面對你的個人電腦,當你按下一個鍵盤按鍵時,你就觸發了一個中斷,隨後屏幕上會出現你期望的字母;或者當你移動鼠標時,你也會觸會一個中斷,隨後光標會隨鼠標的移動而移動。嚴格意義上說,中斷只是一個系統過程(系統調用過程如果拋開其核心處理函數的執行,也是一個系統過程),是系統的一個部分,而不是一個完整系統,因為它不具備完整系統所應有的功能性能可靠性可擴展性安全性兼容性可維護性等各方面的屬性。比如,通過按動鍵盤,你以中斷的方式向計算系統發送命令,但真正執行命令並返回結果的是計算機系統而不是按鍵動作本身。

為什麼需要中斷?

  計算機是個“死腦筋”,從打開電源鍵開始,就算你不向她發送任何指令,她也會按設定的程序開始忙祿,大多時候都在執行一個叫做IDEL的無聊程序。如何讓她聽命於你呢?兩種方法,要么讓她隨時可被打斷,去做你想讓她做的事,隨後再去幹原來被打斷的事;要么讓她不停地一直問你想讓她做什麼,其它的事啥也不干,這樣你一發話,她可以立刻響應你的命令。第一種方式,便是中斷(interrupt),第二種方式叫輪詢(polling)。在大多數場景下,中斷都是一種更為高效的(從完成任務數來看)通知方式,因為計算機乾了更多有意義的事,而不是一直在“傻問”;輪詢時,如果你想讓計算機幹得活不是很多,那麼計算機大多數問詢得到結果都是“謝謝,我不需要你做什麼”,這就白白浪費了她的寶貴精力,但是每次你想讓計算機做事時,她總是先主動地詢問你,之後便立刻進入工作狀態了,這比下達命令之後才慢吞吞開始行動的中斷方式更為高效(從任務響應時間來看)。因此中斷和輪詢各有優點,各有各的適用場景:中斷適用大多數設備通知場景,而在處理時延敏感場景下(如高性能網絡轉發),輪詢表現得更好。

如何實現中斷?

  下面我們深入系統內部,更細緻地理解中斷過程。前期的博文介紹過intel i440fx體系的基本組成,這裡我們做些簡化,只看和中斷過程相關的幾個部分,並按中斷發生後的時序對中斷過程作一個概括性的描述:

  • 首先,我們可以看到在南橋芯片上集成了一個稱為IOAPIC的部件,它共有24根中斷引腳可以接收來自外部設備(如鍵盤、鼠標)的中斷請求;當外設觸發中斷請求後,IOAPIC芯片會根據設備驅動初始化時設定的內容(一個內存地址Address和一個數據Data)向總線發送中斷信息(將Data值寫入Address地址代表的內存),如圖中綠色線條所示,直觀地理解,這個Address指明了接收本次中斷的具體CPU,而Data代表中斷向量號(粗略地講,可以認為這是不同中斷相互區別的一個整數值,0~255)
  • 其次,對於PCI設備而言,有兩種中斷觸發方式:一種是通過intx中斷引腳觸發(最終通過IOAPIC發送中斷信號,如圖中虛線所示);另一種是MSI/MSI-X方式(Message Signal Interrupt),PCI設備通過驅動初始化時設定的內容直接向總線發送中斷,如圖中藍線所示,其發送原理類似IOAPIC,由於外部設備中斷過程並非本文討論的重點,有興趣的同學可以查閱PCI規範中相關內容來獲得更深入的理解。
  • 此外,CPU之間可以通過寫ICR寄存器發送IPI(Inter Processors Interrupt)中斷來進行核間通信,如圖中粉色線條所示。
  • 最後,每個CPU邏輯核都有一個稱為LAPIC的子部件,它負責接收總線上的中斷信息,當確認是發送給本地邏輯核時,便會引發本地CPU的中斷過程:CPU會對保存當前正在執行任務的狀態信息,之後會根據中斷信息找到具體的中斷處理邏輯函數;在完成中斷處理函數後,CPU便會恢復先前保存的任務狀態,繼續處理原先的作務。

CPU如何處理中斷?

  介紹完系統內部整體的中斷過程後,我們把焦點放到CPU上,更深入地從代碼級別看看它是如何處理中斷的。

1、硬件自動完成動作

  x86_64 CPU在中斷發生時會執行一系列硬件動作,完成執行上下文的切換,這些都是硬件自動完成的,不受軟件控制,如下圖所示:

  圖中上半部分錶示中斷發生前寄存器狀態(這里以用戶態上下文狀態舉例):

  • CS(代碼段寄存器)和rip(指令指針寄存器)指向了當前正在執行的用戶態指令的位置(綠色線條所示);
  • SS(堆棧段寄存器)和rsp(棧指針寄存器)指向了當前用戶態堆棧的棧項位置;
  • rflags(標誌寄存器)中的IF位為1,表示允許中斷發生;
  • TR(Task Register)任務寄存器指向當前任務的任務狀態段TSS(Task State Segment),其中的rsp0域指向了內核態棧頂位置(內核特權級為0);
  • IDTR(Interrupt Descriptor Table Register)指向了全局中斷描述符表(Interrupt Descriptor Table),表中共有256個中斷描述符(Interrupt Descriptor),每個描述符指向一個中斷處理函數入口,中斷描述符在表中的索引(下標)稱為中斷向量(Interrupt Vector)。

  中斷發生後(只能發生在指令邊界,不能打斷單條指令的執行),寄存器狀態將發生變化,如上圖下半部分所示:

  • 對於運行在用戶態的程序,中斷發生後需要切換到內核態執行中斷處理函數,出於安全的考慮,堆棧也需要切換到內核態(注意,每個進程在內核態都有一個獨立的棧空間,3.10內核中有16K大小,棧項指針保存在TSS);
  • 切換到內核態棧後,CPU自動將用戶態SS、rsp、rflags、CS、rip壓入棧中(從上到下,棧頂在下,棧底在上);
  • CPU根據中斷向量,取出中斷描述符表中對應的中斷描述符,將CS:rip指向中斷描述符中的函數入口地址;
  • 對於類型為Interrupt Gate的中斷描述符,rflags中的IF標置位將被清零,表示CPU此時開始不響應外部中斷。

  細心的同學可能會問如果程序正好在執行系統調用進入內核態,那中斷的硬件過程是怎樣的?除了不用進行棧切換外,其它的過程和上面的一樣,因為系統調用已經完成了棧切換的動作。

2、中斷描述符表

  硬件自動完成動作的最後是根據中斷描述表中的內容找到中斷處理函數入口,下面我們看看3.10內核裡的中斷描述符表的相關實現,其初始流程大致為start_kernel->init_IRQ- >native_init_IRQ,其核心片斷如下所示:

arch/x86/kernel/irqinit.c:

void __init native_init_IRQ(void)
{
    int i;
    
    ...

    /*
     * Cover the whole vector space, no vector can escape
     * us. (some of these will be overridden and become
     * 'special' SMP interrupts)
     */
    i = FIRST_EXTERNAL_VECTOR;
    for_each_clear_bit_from(i, used_vectors, NR_VECTORS) {
        /* IA32_SYSCALL_VECTOR could be used in trap_init already. */
        set_intr_gate(i, interrupt[i - FIRST_EXTERNAL_VECTOR]);
    }

    ...

}

  FIRST_EXTERNAL_VECTOR為32,NR_VECTOR為256,開頭的這段註釋的意思是說這裡會給從32到256的所有中斷向量註冊處理函數,從下面的代碼看出處理函數在全局數組interrupt中。那麼就有兩個問題:為什麼從32開始?為什麼一開始就能把中斷處理函數全部註冊好,此時驅動程序都沒初始化,具體的中斷處理邏輯難道不是在驅動代碼中實現的嗎?第一個問題比較好回答,其實0~31的向量是intel預留給異常使用的,這是CPU用來處理內部問題的一種方式,如除零、缺頁等等。第二個問題目前確實比較難回答,我們就帶著這個問題看看interrupt數組的定義吧:

arch/x86/kernel/entry_64.S:

/*
 * Build the entry stubs and pointer table with some assembler magic.
 * We pack 7 stubs into a single 32-byte chunk, which will fit in a
 * single cache line on all modern x86 implementations.
 */
    .section .init.rodata,"a"
ENTRY(interrupt)
    .section .entry.text
    .p2align 5
    .p2align CONFIG_X86_L1_CACHE_SHIFT
ENTRY(irq_entries_start)
vector=FIRST_EXTERNAL_VECTOR
.rept (NR_VECTORS-FIRST_EXTERNAL_VECTOR+6)/7
    .balign 32
    .rept 7
    .if vector < NR_VECTORS
1: pushq_cfi $(~vector+0x80) /* Note: always in signed byte range */
            .if ((vector-FIRST_EXTERNAL_VECTOR)%7) <> 6
    jmp 2f
            .endif
    .previous
    .quad 1b
    .section .entry.text
    vector=vector+1
    .endif
    .endr
2: jmp common_interrupt
.endr
END(irq_entries_start)

.previous
END(interrupt)
.previous

  這段彙編代碼確實比較晦澀,它把32到256的中斷按7個一組劃成一個個大組,每個大組的內存佔用空間大小在32個字節內,這樣這些組塊可以