第4章 中斷機制

 

 

我們編寫的程序在運行的時候,並不會一直佔據着 CPU 資源,比如你需要和外部設備做交互(讀寫磁盤數據、讀寫網絡接口等),那麼你就要主動放棄 CPU,當外部設備數據就緒後,就會通過中斷機制來通知 CPU 切換回你剛纔運行程序的上下文繼續往下執行。

另外,即使是 CPU 密集型運算的程序,系統也並不僅僅給一個進程來運行。爲了對系統中所有的進程公平起見,一般會通過時鐘中斷的機制,定期打斷當前在 CPU 中的進程,以便切換給其他進程以得到公平運行的機會。

可以這樣說,中斷響應的機制對於現代操作系統來講尤爲重要,該機制解放了 CPU,對提升系統的利用率起到了非常大的作用。

假如沒有中斷機制,那麼所有條件是否滿足的判斷都需要 CPU 進行輪詢忙等待,這樣就會增加系統的開銷,浪費 CPU 的資源。

本章通過以下幾個問題來分析操作系統的中斷機制:

1)爲什麼要引入中斷機制,x86 系統的中斷機制。

2)Linux 系統如何對中斷機制進行封裝和實現。

3)Linux 引入了軟中斷、tasklet、工作隊列等機制是怎樣的。

4)系統調用、時鐘中斷、信號處理機制等在 Linux 中是如何實現的。

4.1 x86 系統的中斷機制

先舉個例子來說明中斷機制帶來的好處。假如你的汽車出了問題,要去 4s 店維修,工作人員告訴你,今天就可以取車,但是什麼時間點不確定。沒有中斷機制的情況下,你就什麼事情都不能幹,在 4S 店刷刷手機,在無聊中乾等一天。有了中斷機制,工作人員就會說,你現在可以出去幹其他事情,等修好了會打電話通知你來取車;這樣你就有可能去看一場電影,或者乾點別的你想做的任何事情,直到電話響起後,你再確定何時去 4S 店取車。

操作系統中斷機制的好處就是讓 CPU 把某些需要輪詢等待的空閒時間釋放出來,化主動爲被動,可以把釋放出來的時間用於其他邏輯運算的工作,提升 CPU 的有效利用率。

我們平時工作接觸最多的操作系統就是 x86 架構的系統了,所以先分析一下 x86 下的中斷機制。

4.1.1 x86 中斷架構

x86 中斷架構如圖4-1所示。當我們想通知 CPU 發生中斷的時候,可以通過以下兩種方式來進行:

  • int 指令,比如用戶自己編寫的軟件,可以通過 int 指令來實現中斷 CPU 的目的。

  • 通過與 CPU 連接的相關芯片(例如 8259A)把外部中斷的信號傳遞給 CPU,例如 8259A 芯片連接了磁盤或者鍵盤,當這些外部設備就緒,需要通知 CPU 響應的時候,就會發生外部中斷。

圖4-1 x86 中斷機制

中斷信息註冊在內存中的中斷向量表中,通過中斷向量可以找到相應中斷處理的地址,當 CPU 響應相關中斷指令或者信號後,就會執行中斷處理程序。

4.1.2 x86 在保護模式下的中斷

現在我們已經瞭解了 x86 硬件的中斷機制和中斷向量的原理。不過,在 x86 的保護模式中,CPU 並非直接訪問內存中的中斷向量表地址,爲了安全,其訪問的是中斷門數據結構,門的數據也存放在內存中,類似於 GDT(全局描述表)或 IDT(中斷描述表)的描述符。中斷門的結構如圖4-2所示。

圖4-2 中斷門結構

在中斷門結構中,最核心的就是選擇子和偏移量。BYTE 3 和 BYTE 2 組成了16位的段選擇子,BYTE 7 和 BYTE 6 保存了偏移量的高16位,BYTE 1 和 BYTE 0 保存了偏移量的低16位,組合起來就是32位的段內偏移量。

另外代表屬性的 BYTE 5 和 BYTE 4 兩個字節中的 P、DPL、S、TYPE 等字段和3.2.2節中的 GDT 中的字段是一致的。

中斷門結構中最重要的是保存了程序段的選擇子和段內的偏移量,這樣就可以找到相應的中斷處理程序。門的機制就相當於一個關卡(Gate)做了一道安全檢測和攔截。

在訪問門描述符時要將描述符當作一個數據段來檢查訪問權限,要求指定門的選擇子的 RPL≤門描述符 DPL,同時當前代碼段 CPL≤門描述符 DPL,就如同訪問數據段一樣,要求訪問數據段的程序的 CPL≤待訪問的數據段的 DPL,同時選擇子的 RPL≤待訪問的數據段或堆棧段的 DPL。只有滿足了以上條件,CPU 纔會進一步從調用門描述符中讀取目標代碼段的選擇子和地址偏移,進行下一步的操作。

要了解 RPL、DPL、CPL 等概念,需要了解 X86 的保護模式。X86 的保護模式核心就是提供特權等級概念,以便對代碼和數據進行訪問的時候進行保護和控制。特權級分爲0,1,2,3四級,數字越小權限越高。

圖4-3是特權級在操作系統中應用。

圖4-3 特權等級

這些特權等級,通過三個符號來體現 CPL/RPL/DPL:

  • CPL(Current Privilege Level)是當前進程的權限級別,是當前正在執行的代碼所在的段的特權級,存在於 CS 寄存器的低兩位。(個人認爲可以看成是段描述符未加載入 CS 前,該段的 DPL,加載入 CS 後就存入 CS 的低兩位,所以叫 CPL,其值就等於原段 DPL 的值)。

  • RPL(Request Privilege Level)說明的是進程對段訪問的請求權限,是對於段選擇子而言的,每個段選擇子有自己的 RPL,是進程對段訪問的請求權限,有點像函數參數。而且 RPL 對每個段來說不是固定的,兩次訪問同一段時的 RPL 可以不同。RPL 可能會削弱 CPL 的作用,例如當前 CPL=0 的進程要訪問一個數據段,把段選擇符中的 RPL 設爲3,這樣雖然它對該段仍然只有特權爲3的訪問權限。

  • DPL(Descriptor Privilege Level)存儲在段描述符中,規定訪問該段的權限級別,每個段的 DPL 固定。當進程訪問一個段時,需要進程特權級檢查,一般要求 DPL>=max{CPL,RPL}。

4.2 Linux 對中斷的支持和實現

爲了更好地支持上層不同的業務場景,Linux 在硬件提供的中斷機制之上進行了封裝和擴展。下面通過圖4-4來介紹 Linux 內核對中斷機制的封裝。

圖4-4 Linux 內核中的中斷機制

Linux 在啓動階段會通過 set_intr_gate 來設置中斷門和中斷向量。開發者可以通過 request_irq 來給相應的中斷向量設置回調函數。當中斷髮生的時候,會觸發通用中斷程序 common_interrupt 並且調用 do_IRQ 來觸發相應中斷向量的回調函數。下面着重來分析 Linux 中斷機制中最關鍵的三個部分。

4.2.1 初始化 IRQ 中斷門

爲什麼 IRQ 中斷可以被響應呢?因爲在系統的初始化過程中調用了 init_IRQ,通過設置中斷門初始化了中斷向量(代碼詳見:/linux-4.15.8/arch/x86/kernel/irqinit.c):

void __init init_IRQ(void)
{
    int i;
    for (i = 0; i < nr_legacy_irqs(); i++)
    per_cpu(vector_irq, 0)[ISA_IRQ_VECTOR(i)] = irq_to_desc(i);
    x86_init.irqs.intr_init();
}

通過下面的 irqs 定義可以發現真正調用的是 native_init_IRQ 函數:

.irqs = {
.pre_vector_init          = init_ISA_irqs,
.intr_init                = native_init_IRQ,
.trap_init                = x86_init_noop,
},

在 native_init_IRQ 函數中,通過 set_intr_gate 設置中斷門並且初始化中斷向量,從0x20(FIRST_EXTERNAL_VECTOR)開始到256(NR_VECTORS):

void __init native_init_IRQ(void)
{
    int i;
    …
    for_each_clear_bit_from(i, used_vectors, first_system_vector) {
        set_intr_gate(i, irq_entries_start +
        8 * (i - FIRST_EXTERNAL_VECTOR));
    }
…
}

4.2.2 中斷響應流程

通過圖4-3可以看到,當中斷髮生的時候,會觸發中斷處理函數,從 irq_entries_start 處開始:

.align 8
ENTRY(irq_entries_start)
vector=FIRST_EXTERNAL_VECTOR
.rept (FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR)
pushl        $(~vector+0x80)
vector=vector+1
jmp        common_interrupt
.align        8
.endr
END(irq_entries_start)

上面函數的 .rept 這一行,會重複(FIRST_SYSTEM_VECTOR-FIRST_EXTERNAL_VECTOR)次,根據 native_init_IRQ 可以理解,當傳入的中斷號不同,則傳入的 vector 也不同。

最終會通過 common_interrupt 調用 do_IRQ 函數。

do_IRQ 在獲取中斷向量通過 handle_irq->generic_handle_irq_desc 調用了 irq 所對應的 irq_desc 結構的 handle_irq:

static inline void generic_handle_irq_desc(struct irq_desc *desc)
{
    desc->handle_irq(desc);
}

這些標準的回調函數都是 irq_flow_handler_t 類型:

typedef         void (*irq_flow_handler_t)(unsigned int irq,
struct irq_desc *desc);

目前的通用中斷子系統實現了以下這些標準流控回調函數,這些函數都定義在 kernel/irq/chip.c 中:

  • handle_simple_irq 用於簡易流控處理。

  • handle_level_irq 用於電平觸發中斷的流控處理。

  • handle_edge_irq 用於邊沿觸發中斷的流控處理。

  • handle_fasteoi_irq 用於需要響應 eoi 的中斷控制器。

  • handle_percpu_irq 用於只在單一 CPU 響應的中斷。

  • handle_nested_irq 用於處理使用線程的嵌套中斷。

最終會執行用戶註冊的中斷處理程序 action。以上幾種回調函數,最終都調用了 handle_irq_event。

handle_irq_event 最終調用執行了 handle_irq_event_percpu 函數:

irqreturn_t handle_irq_event_percpu(struct irq_desc *desc)
{
    irqreturn_t retval = IRQ_NONE;
    unsigned int flags = 0, irq = desc->irq_data.irq;
    struct irqaction *action = desc->action;
    while (action) {
    irqreturn_t res;

    …
    res = action->handler(irq, action->dev_id);        // 執行中斷的 action 註冊的 handler
    …
    retval |= res;
    action = action->next;                             // 獲取下一個 action 事件
    }
    …
return retval;
}

handle_irq_event_percpu 將中斷向量 desc 註冊的 action 鏈表中的 handler 挨個執行一遍。

4.2.3 中斷回調 handler 註冊過程

在掌握了 Linux 的中斷髮生和響應處理機制後,我們發現,中斷髮生的時候處理最終回調的是在中斷向量中註冊的 action 中的 handler。那麼 handler 是如何註冊上去的呢?

內核提供了 request_irq 函數進行註冊自定義 handler 的功能:

request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
const char *name, void *dev)

其調用鏈爲 request_irq->request_threaded_irq->__setup_irq,然後把構建出來的 action 插入到中斷向量的 action 鏈表中:

…
do {
    thread_mask |= old->thread_mask;
    old_ptr = &old->next;
    old = *old_ptr;
} while (old);
shared = 1;
}
…

4.3 Linux 加速中斷處理的機制

在中斷髮生之後,假如中斷處理的回調函數需要很長的時間,那麼會對系統性能造成很大的影響,比如中斷頻繁發生的情況下,後續的中斷就會得不到響應。Linux 爲了解決這個問題提供了幾個異步工具來加快中斷處理執行的過程,比如軟中斷、tasklet、工作隊列等。

4.3.1 軟中斷

軟中斷可以在不觸發硬件中斷機制的情況下,進行中斷業務流程的模擬實現,其原理是通過後臺異步線程+隊列的方式來模擬異步事件回調的機制。

軟中斷的註冊函數爲:

void open_softirq(int nr, void (*action)(struct softirq_action *))
{
softirq_vec[nr].action = action;
}

軟中斷的資源是有限的,內核目前只實現了10種類型的軟中斷,它們是:

enum
{
    HI_SOFTIRQ=0,
    TIMER_SOFTIRQ,
    NET_TX_SOFTIRQ,
    NET_RX_SOFTIRQ,
    BLOCK_SOFTIRQ,
    IRQ_POLL_SOFTIRQ,
    TASKLET_SOFTIRQ,
    SCHED_SOFTIRQ,
    HRTIMER_SOFTIRQ,        // 沒有使用,但有時候可以有一些工具依賴它
    RCU_SOFTIRQ,            // 最好的 RCU 總是放在最後的軟中斷

    NR_SOFTIRQS
};

注意 

內核的開發者們不建議我們擅自增加軟中斷的數量,如果需要新的軟中斷,儘可能把它們實現爲基於軟中斷的 tasklet 形式。

下面通過圖4-5來說明軟中斷的響應流程,在軟中斷實現中,每個 CPU 都維護了一個後臺響應的進程 ksoftirqd:

static __init int spawn_ksoftirqd(void)
{
    register_cpu_notifier(&cpu_nfb);
    …
    return 0;
}
early_initcall(spawn_ksoftirqd);

static struct smp_hotplug_thread softirq_threads    = {
    .store                        = &ksoftirqd,
    .thread_should_run            = ksoftirqd_should_run,
    .thread_fn= run_ksoftirqd,
    .thread_comm                  = "ksoftirqd/%u",
};

DECLARE_PER_CPU(struct task_struct *, ksoftirqd);

static inline struct task_struct *this_cpu_ksoftirqd(void)
{
    return this_cpu_read(ksoftirqd);
}

圖4-5 軟中斷響應流程

通過系統初始化時候的守護線程最終調用了 run_ksoftirqd,其核心實現爲 __do_softirq()函數:

static void run_ksoftirqd(unsigned int cpu)
{
    ..
    __do_softirq();
    …
}

asmlinkage __visible void __do_softirq(void)
{
    unsigned long end = jiffies + MAX_SOFTIRQ_TIME;
    unsigned long old_flags = current->flags;
    int max_restart = MAX_SOFTIRQ_RESTART;        // 啓動 ksoftirqd 之前,最大的處理 softirq 的次數,經驗值
    struct softirq_action *h;
    bool in_hardirq;
    __u32 pending;
    int softirq_bit;
    …
    // 取得當前被掛起的 softirq,同時這裏也解釋了爲什麼 Linux 內核最多支持32個 softirq,因爲 pending 只有32位
    pending = local_softirq_pending();
    account_irq_enter_time(current);
    __local_bh_disable_ip(_RET_IP_, SOFTIRQ_OFFSET);
    in_hardirq = lockdep_softirq_start();
    restart:
    set_softirq_pending(0); // 獲取 pending 的 softirq 之後,清空所有 pending 的 softirq 標誌
    local_irq_enable();
    h = softirq_vec;
    while ((softirq_bit = ffs(pending))) { // 從最低位開始,循環右移逐位處理 pending 的 softirq
        unsigned int vec_nr;
        int prev_count;
        h += softirq_bit - 1;
        vec_nr = h - softirq_vec;
        prev_count = preempt_count();
        kstat_incr_softirqs_this_cpu(vec_nr);
        trace_softirq_entry(vec_nr);
        h->action(h);        // 執行 softirq 的處理函數
        …
}
h++;
pending >>= softirq_bit;        // 循環右移
}
..
local_irq_disable();
pending = local_softirq_pending();
    if (pending) {
    if (time_before(jiffies, end) && !need_resched() &&
    --max_restart)        // 啓動 ksoftirqd 的閾值
    goto restart;
    wakeup_softirqd();        // 啓動 ksoftirqd 去處理 softirq,此時說明 pending 的 softirq 比較多,比較頻繁,上面的處理過程中,又不斷有 softirq 被 pending
    }
    …
}

在 __do_softirq 執行過程中只要在 pending 中的軟中斷標誌位被設置了,那麼就會調用該中斷的 action 函數。

最後,軟中斷處理函數註冊後,還需要將該軟中斷激活才能被執行,激活操作是通過 raise_softirq 函數來實現,它調用了 raise_softirq_irqoff 函數:

inline void raise_softirq_irqoff(unsigned int nr)
{
    __raise_softirq_irqoff(nr);
    if (!in_interrupt())
    // 假如不在中斷當中,那麼我們儘快喚醒 softirqd 進行下半部的處理
    wakeup_softirqd();
}
我們着重關注一下__raise_softirq_irqoff(nr):
void __raise_softirq_irqoff(unsigned int nr)
{
    trace_softirq_raise(nr);
    or_softirq_pending(1UL << nr);
}

#define or_softirq_pending(x)  (local_softirq_pending() |= (x))

__raise_softirq_irqoff 把我們的中斷號左移之後進行異或操作合併到 pending 標誌位中,便於 softirqd 線程進行處理。

4.3.2 tasklet

在分析軟中斷的時候,我們已經瞭解到內核開發者平時不建議我們使用軟中斷,最好使用 tasklet 機制,因爲其本身也是軟中斷的一部分。

軟中斷專門實現了 TASKLET_SOFTIRQ,其 action 爲:

static void tasklet_action(struct softirq_action *a)
{
struct tasklet_struct *list;

local_irq_disable();
list = __this_cpu_read(tasklet_vec.head);
__this_cpu_write(tasklet_vec.head, NULL);
__this_cpu_write(tasklet_vec.tail, this_cpu_ptr(&tasklet_vec.head));
local_irq_enable();

    while (list) {
    struct tasklet_struct *t = list;

    list = list->next;

        if (tasklet_trylock(t)) {
            if (!atomic_read(&t->count)) {
            if (!test_and_clear_bit(TASKLET_STATE_SCHED,
            &t->state))
            BUG();
            t->func(t->data);
            tasklet_unlock(t);
            continue;
            }
        tasklet_unlock(t);
        }

    local_irq_disable();
    t->next = NULL;
    *__this_cpu_read(tasklet_vec.tail) = t;
    __this_cpu_write(tasklet_vec.tail, &(t->next));
    __raise_softirq_irqoff(TASKLET_SOFTIRQ);
    local_irq_enable();
    }
}

從 tasklet_action 實現可以發現,只要 tasklet_vec 向量隊列中有 tasklet 存在,那麼就拿出來執行 func 函數。

tasklet 的結構如下:

struct tasklet_struct
{
    struct tasklet_struct *next;
    unsigned long state;
    atomic_t count;
    void (*func)(unsigned long);
    unsigned long data;
};

並且可以通過下面的宏來定義:

#define DECLARE_TASKLET(name, func, data)
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(0), func, data }

我們可以通過調用 tasklet_schedule 把 tasklet 提交到 tasklet 隊列中:

static inline void tasklet_schedule(struct tasklet_struct *t)
{
    if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
    __tasklet_schedule(t);
}

其實執行的是 __tasklet_schedule 函數:

void __tasklet_schedule(struct tasklet_struct *t)
{
    unsigned long flags;
    local_irq_save(flags);
    t->next = NULL;
    *__this_cpu_read(tasklet_vec.tail) = t;
    __this_cpu_write(tasklet_vec.tail, &(t->next));
    raise_softirq_irqoff(TASKLET_SOFTIRQ);
    local_irq_restore(flags);
}

可以發現 __tasklet_schedule 把 tasklet 放到了 tasklet 向量隊列的隊尾,並且最後通知 softirqd 線程處理 tasklet 信號。

因爲 tasklet 機制就是基於 TASKLET_SOFTIRQ 的軟中斷來實現的,所以只要掌握了軟中斷機制,tasklet 也就很好理解了。

4.3.3 工作隊列

工作隊列類似於應用層代碼中線程池概念,我們把 work 提交到隊列,然後由相關的線程從隊列中提取 work 並且執行,其原理如圖4-6所示。

當我們創建一個工作隊列後(wq),它爲每個 CPU 都分配了一個工作隊列池(pool_workqueue 結構的 pool),同時會創建一個工作線程 work_thread 和該池中的 worklist 掛鉤,來處理提交上來的 work。

可以通過 alloc_workqueue 宏來分配一個工作隊列:

#define alloc_workqueue(fmt, flags, max_active, args...)
__alloc_workqueue_key((fmt), (flags), (max_active),
NULL, NULL, ##args)

其參數如下:

  • name 是 wq 的名稱,並且會被當作相應救援線程的名稱。

  • flag 和 max_active 用來控制到底有多少工作項被分配了執行資源,被調度和執行。

Flag 的幾種類型說明:

  • WQ_UNBOUND:unbound 隊列中的工作項會被相應的工作者線程池處理,這些線程池管理的工作者不會和任何制定的 CPU 綁定。這樣的話,該隊列的行爲就相當於是提供了一個簡單的執行上下文,而不會做併發管理。未綁定的線程池只要有可能就會試圖啓動工作項的執行。未綁定的隊列犧牲了 CPU 的相關性,但是有以下用處。

  • WQ_FREEZABLE:凍結隊列因爲參與了系統的掛起操作流程,在該隊列上的工作項會被排出(drained),直到被解凍之前都不會執行新的工作項。

  • WQ_MEM_RECLAIM:所有有可能運行在內存回收流程中的工作隊列都需要設置該標記。這樣能夠確保即使在內存壓力比較大的情況下都能有至少一個執行上下文能夠運行。

  • WQ_HIGHPRI:highpri 隊列中的工作項會由指定的 CPU 上的工作者線程池來處理。highpri 工作者線程池中的線程具有較高的 nice 值(用 top 或 ps 查看的進程 %nice 指標)。

  • WQ_CPU_INTENSIVE:CPU 密集型工作隊列中的工作項並不會對併發級別產生貢獻。換句話說,可以運行的 CPU 密集型工作項不會阻止相同工作者線程池上的工作項的運行。對於綁定的並且希望獨佔 CPU 週期的工作項,這個 flag 是有用的,因爲它們的運行可以被系統進程調度程序調節。雖然 CPU 密集型的工作項不對併發級別做出貢獻,但是它們的執行仍然會被併發管理所調節,因爲可以運行的那些非 CPU 密集型工作項可以延遲 CPU 密集型工作項的運行。這個標記對於 unbound 工作隊列沒有什麼意義。

圖4-6 工作隊列原理示意圖

從 alloc_workqueue 宏代碼發現創建工作隊列最終調用了 __alloc_workqueue_key 方法:

struct workqueue_struct *__alloc_workqueue_key(const char *fmt,
unsigned int flags,
int max_active,
struct lock_class_key *key,
const char *lock_name, ...)
{
    size_t tbl_size = 0;
    va_list args;
    struct workqueue_struct *wq;
    struct pool_workqueue *pwq;
    …
    if (flags & WQ_UNBOUND)
    tbl_size = nr_node_ids * sizeof(wq->numa_pwq_tbl[0]);        // 等待隊列的結構體空間大小,一
                                                                // 共 nr_node_ids 個
    wq = kzalloc(sizeof(*wq) + tbl_size, GFP_KERNEL);        // 爲 workqueue 申請內存空間
    if (!wq)
    return NULL;
    if (flags & WQ_UNBOUND) {
    wq->unbound_attrs = alloc_workqueue_attrs(GFP_KERNEL);
    if (!wq->unbound_attrs)
    goto err_free_wq;
}
va_start(args, lock_name);
vsnprintf(wq->name, sizeof(wq->name), fmt, args);        // 格式化工作隊列的 name
va_end(args);
max_active = max_active ?: WQ_DFL_ACTIVE;
max_active = wq_clamp_max_active(max_active, flags, wq->name);
// init wq 下面操作初始化工作隊列中的列表
wq->flags = flags;
wq->saved_max_active = max_active;
mutex_init(&wq->mutex);
atomic_set(&wq->nr_pwqs_to_flush, 0);
INIT_LIST_HEAD(&wq->pwqs);
INIT_LIST_HEAD(&wq->flusher_queue);
INIT_LIST_HEAD(&wq->flusher_overflow);
INIT_LIST_HEAD(&wq->maydays);
lockdep_init_map(&wq->lockdep_map, lock_name, key, 0);
INIT_LIST_HEAD(&wq->list);
if (alloc_and_link_pwqs(wq) < 0)
goto err_free_wq;
if (flags & WQ_MEM_RECLAIM) { // WQ_MEM_RECLAIM 場景,在內存緊張的時候,分配一個救援線程來處理 work
    struct worker *rescuer;
    rescuer = alloc_worker(NUMA_NO_NODE);
    if (!rescuer)
    goto err_destroy;
    rescuer->rescue_wq = wq;
    rescuer->task = kthread_create(rescuer_thread, rescuer, "%s",
    wq->name);
    if (IS_ERR(rescuer->task)) {
        kfree(rescuer);
        goto err_destroy;
    }
    wq->rescuer = rescuer;
    kthread_bind_mask(rescuer->task, cpu_possible_mask);
    wake_up_process(rescuer->task);
}
…
mutex_lock(&wq_pool_mutex);
mutex_lock(&wq->mutex);
for_each_pwq(pwq, wq)
pwq_adjust_max_active(pwq);        // 遍歷調整每個 pwq 的 max_active 大小
mutex_unlock(&wq->mutex);
list_add_tail_rcu(&wq->list, &workqueues);
mutex_unlock(&wq_pool_mutex);
return wq;
…
}

上面的代碼主要初始化了 workqueue 的結構體,申請內存空間,並且初始化 pool_work-queue 的 max_active,假如是 WQ_MEM_RECLAIM 場景會創建一個救援線程。

接着我們分析任務的提交,先來看提交給指定 CPU 的 workqueue 隊列,任務會通過 queue_work_on 函數提交,在任務提交後,都會執行 __queue_work 方法:

static void __queue_work(int cpu, struct workqueue_struct *wq,
struct work_struct *work)
{
    struct pool_workqueue *pwq;
    struct worker_pool *last_pool;
    struct list_head *worklist;
    unsigned int work_flags;
    unsigned int req_cpu = cpu;
    …
    retry:
    if (req_cpu == WORK_CPU_UNBOUND)  // 在不需要綁定 CPU 的場景下,找一個未被 work 綁定的 CPU
    cpu = wq_select_unbound_cpu(raw_smp_processor_id());

    // 除非 work 在其他地方執行,否則使用 pwq
    if (!(wq->flags & WQ_UNBOUND))
    pwq = per_cpu_ptr(wq->cpu_pwqs, cpu);
    else
    pwq = unbound_pwq_by_node(wq, cpu_to_node(cpu));

    // 假如該 work 之前已經在其他的 pool 中了,那麼會在那邊繼續運行,pool 保證不能重入
    last_pool = get_work_pool(work);
    if (last_pool && last_pool != pwq->pool) {
    struct worker *worker;
    spin_lock(&last_pool->lock);
    worker = find_worker_executing_work(last_pool, work);
    if (worker && worker->current_pwq->wq == wq) {
    pwq = worker->current_pwq;
    …
    insert_work(pwq, work, worklist, work_flags);        // 最後把 work 插入到工作隊列中
    spin_unlock(&pwq->pool->lock);
}

最後我們開每個 pool 都會創建一個 work_thread 來進行後臺隊列處理:

static struct worker *create_worker(struct worker_pool *pool)
{
struct worker *worker = NULL;
int id = -1;
char id_buf[16];
…
worker = alloc_worker(pool->node);        // 從內存中分配 work 空間
…
worker->pool = pool;
worker->id = id;
…
worker->task = kthread_create_on_node(worker_thread, worker, pool->node,
    "kworker/%s", id_buf);                // 創建一個 work 線程
…
set_user_nice(worker->task, pool->attrs->nice);
kthread_bind_mask(worker->task, pool->attrs->cpumask);
worker_attach_to_pool(worker, pool);        // 把 worker 掛到 pool 隊列中
spin_lock_irq(&pool->lock);
worker->pool->nr_workers++;
worker_enter_idle(worker);
wake_up_process(worker->task);                // 喚醒新創建的 work 線程
spin_unlock_irq(&pool->lock);
return worker;
…
}

create_worker 創建的內核進程會在後臺一直工作,處理提交的 worker。

4.4 系統調用

系統調用(syscall)是 Linux 系統中非常重要的概念,操作系統的核心功能,如進程、內存、I/O、文件系統等,都是以系統調用的方式提供給用戶態程序的。常見的 Linux 系統調用分類可以參考:https://www.ibm.com/developerworks/cn/linux/kernel/syscall/part1/appendix.html

系統調用的實現方式也和中斷機制有關,在前面介紹 trap_init 方法的時候,其中有一行代碼:

set_system_trap_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);

該行註冊了系統調用的中斷向量:

#define IA32_SYSCALL_VECTOR                0x80

可以發現系統調用的中斷向量號爲0x80,我們接着看中斷處理函數的實現:

ENTRY(entry_INT80_32)
ASM_CLAC
pushl        %eax                       // pt_regs->orig_ax 把系統調用號保存到 pt_regs->org_ax 中
SAVE_ALL pt_regs_ax=$-ENOSYS            // 在中斷髮生前夕,要把所有相關寄存器的內容都保存在堆棧中,這是通過 SAVE_ALL 宏完成的
movl        %esp, %eax                  // 把 esp 棧頂放入到 eax 中,因爲 eax 一般都是作爲函數調用參數的
call do_syscall_32_irqs_on

在32位系統中,最終調用了 do_syscall_32_irqs_on 函數:

__always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
    struct thread_info *ti = pt_regs_to_thread_info(regs);
    unsigned int nr = (unsigned int)regs->orig_ax; // 獲取系統調用號
    …
    if (likely(nr < IA32_NR_syscalls)) {
        regs->ax = ia32_sys_call_table[nr](                // 執行系統調用的過程
        (unsigned int)regs->bx, (unsigned int)regs->cx,
        (unsigned int)regs->dx, (unsigned int)regs->si,
        (unsigned int)regs->di, (unsigned int)regs->bp);
    }
…
}

該函數通過 nr 系統調用號找到了系統調用表中的響應系統調用函數,並且壓入 regs 棧中的數據,然後執行以下代碼:

__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {
    [0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
    #include <asm/syscalls_32.h>
};

其中 sys_call_table 數組的初始化使用 GCC 的擴展語法,語句[0...__NR_syscall_max]=&sys_ni_syscall 將數組內容全部初始化爲未實現版本,然後包含 asm/syscalls_32.h 當中逐項初始化的內容進行初始化。

asm/syscalls_32.h 爲編譯期間生成的一個頭文件,該內容由 /include/uapi/asm-generic/unistd.h 等頭文件共同生成,其內容如下:

#include <asm/bitsperlong.h>
…
#define __NR3264_lseek 62
__SC_3264(__NR3264_lseek, sys_llseek, sys_lseek)
#define __NR_read 63
__SYSCALL(__NR_read, sys_read)
#define __NR_write 64
__SYSCALL(__NR_write, sys_write)
#define __NR_readv 65
__SC_COMP(__NR_readv, sys_readv, compat_sys_readv)
#define __NR_writev 66
__SC_COMP(__NR_writev, sys_writev, compat_sys_writev)
…
#endif

最後總結系統調用機制如圖4-7所示,用戶態程序通過0x80號中斷進行系統調用,具體調用哪個系統調用函數由 eax 中的參數決定,然後根據系統調用號查找 sys_call_table 中的具體函數,並且嵌入內核,執行該函數調用。

圖4-7 系統調用機制

4.5 時鐘中斷

時鐘中斷對操作系統來講是個很重要的概念,特別是像 Linux 這樣的分時操作系統,CPU 不能被某幾個進程獨佔,所以需要通過對時間片的劃分進行切換(參見1.3.2節)。所以,時鐘中斷是進程實現被動切換的機制。

要產生時鐘中斷,必須通過硬件來完成,一般情況下,CPU 都會通過中斷控制器連接 8259A 這樣的芯片,來產生頻率恆定的時鐘中斷。

跟系統調用一樣,時鐘中斷也是在系統初始化的時候進行初始化的:

main(){
    …
    time_init();
    …
    if (late_time_init)
    late_time_init();
}

void __init time_init(void)
{
    late_time_init = x86_late_time_init;
}

static __init void x86_late_time_init(void)
{
    x86_init.timers.timer_init();
    tsc_init();// 初始化時鐘頻率
}

在上面代碼中我們發現時鐘中斷初始化最終調用的方法爲:

x86_init.timers.timer_init()和tsc_init()

tsc_init()是用來初始化硬件相關的時鐘頻率,我們不再展開,這裏主要來分析一下 x86_init.timers.timer_init()

.timers = {
    .setup_percpu_clockev        = setup_boot_APIC_clock,
    .timer_init                  = hpet_time_init,
    .wallclock_init              = x86_init_noop,
},

在 x86_init 中 timer 的 timer_init 方法爲 hpet_time_init,其調用了 setup_default_timer_irq:

void __init setup_default_timer_irq(void)
{
    if (!nr_legacy_irqs())
    return;
    setup_irq(0, &irq0);
}

最後發現時鐘中斷的註冊爲 IRQ 中斷0,irqaction 爲 irq0:

static struct irqaction irq0  = {
    .handler = timer_interrupt,
    .flags = IRQF_NOBALANCING | IRQF_IRQPOLL | IRQF_TIMER,
    .name = "timer"
};

從上面結構體定義發現時鐘中斷函數爲 timer_interrupt:

static irqreturn_t timer_interrupt(int irq, void *dev_id)
{
    global_clock_event->event_handler(global_clock_event);
    return IRQ_HANDLED;
}

至於其具體的 event_handler 的實現,就不在這裏闡述了,讀者有興趣可以自行研究源代碼。

4.6 信號處理機制

在 Linux 中,有一種非常常用的機制,用於通知進程觸發響應的事件,這就是信號(signal)處理機制。

Linux 把它包裝爲系統調用給用戶態程序進行使用,信號回調註冊可以通過以下兩個系統調用來完成。

1)sigaction 系統調用:

COMPAT_SYSCALL_DEFINE3(sigaction, int, sig,
const struct compat_old_sigaction __user *, act,
struct compat_old_sigaction __user *, oact)
{
    struct k_sigaction new_ka, old_ka;
    int ret;
    …
    ret = do_sigaction(sig, act ? &new_ka : NULL, oact ? &old_ka : NULL);
    …
    return ret;
}

2)signal 系統調用:

SYSCALL_DEFINE2(signal, int, sig, __sighandler_t, handler)
{
    struct k_sigaction new_sa, old_sa;
    int ret;
    new_sa.sa.sa_handler = handler;
    new_sa.sa.sa_flags = SA_ONESHOT | SA_NOMASK;
    sigemptyset(&new_sa.sa.sa_mask);
    ret = do_sigaction(sig, &new_sa, &old_sa);
    return ret ? ret : (unsigned long)old_sa.sa.sa_handler;
}

不管是 signal 還是 sigaction,最終都調用了 do_sigaction 方法,最終返回舊的 action 的 handler:

int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
{
    struct task_struct *p = current, *t;
    struct k_sigaction *k;
    sigset_t mask;

    if (!valid_signal(sig) || sig < 1 || (act && sig_kernel_only(sig)))
    return -EINVAL;
    k = &p->sighand->action[sig-1];
    spin_lock_irq(&p->sighand->siglock);
    if (oact)
    *oact = *k;        // 保存舊的 action

    if (act) {
        sigdelsetmask(&act->sa.sa_mask,
        sigmask(SIGKILL) | sigmask(SIGSTOP));
        *k = *act;                // 設置新的 action
        // 對兩種 handler 的特殊處理,忽略 SIG_IGN 和 SIG_DFL 信號
        if (sig_handler_ignored(sig_handler(p, sig), sig)) {
            sigemptyset(&mask);
            sigaddset(&mask, sig);
            flush_sigqueue_mask(&mask, &p->signal->shared_pending);
            for_each_thread(p, t)
            flush_sigqueue_mask(&mask, &t->pending);
        }
    }
    spin_unlock_irq(&p->sighand->siglock);
    return 0;
}

do_sigaction 的實現很簡單,先保存舊的 action,用於系統調用返回,然後設置新的 action。

收到信號的進程對各種信號有不同的處理方法,可以分爲三類:

  • 類似於中斷的處理程序,對於需要處理的信號,進程可以指定處理函數,由該函數來處理。

  • 忽略某個信號,對該信號不做任何處理,就象未發生過一樣。

  • 對該信號的處理保留系統的默認值,這種默認操作,對大部分信號的默認操作是使得進程終止。進程通過系統調用 signal 來指定進程對某個信號的處理行爲。

系統的默認行爲如下所示,signal 不同,action 的分配也不同:

*        +--------------------+------------------+
*        |  POSIX signal      |  default action  |
*        +--------------------+------------------+
*        |  SIGHUP            |  terminate       |
*        |  SIGINT            |  terminate       |
*        |  SIGQUIT           |  coredump        |
*        |  SIGILL            |  coredump        |
*        |  SIGTRAP           |  coredump        |
*        |  SIGABRT/SIGIOT    |  coredump        |
*        |  SIGBUS            |  coredump        |
*        |  SIGFPE            |  coredump        |
*        |  SIGKILL           |  terminate(+)    |
*        |  SIGUSR1           |  terminate       |
*        |  SIGSEGV           |  coredump        |
*        |  SIGUSR2           |  terminate       |
*        |  SIGPIPE           |  terminate       |
*        |  SIGALRM           |  terminate       |
*        |  SIGTERM           |  terminate       |
*        |  SIGCHLD           |  ignore          |
*        |  SIGCONT           |  ignore(*)       |
*        |  SIGSTOP           |  stop(*)(+)      |
*        |  SIGTSTP           |  stop(*)         |
*        |  SIGTTIN           |  stop(*)         |
*        |  SIGTTOU           |  stop(*)         |
*        |  SIGURG            |  ignore          |
*        |  SIGXCPU           |  coredump        |
*        |  SIGXFSZ           |  coredump        |
*        |  SIGVTALRM         |  terminate       |
*        |  SIGPROF           |  terminate       |
*        |  SIGPOLL/SIGIO     |  terminate       |
*        |  SIGSYS/SIGUNUSED  |  coredump        |
*        |  SIGSTKFLT         |  terminate       |
*        |  SIGWINCH          |  ignore          |
*        |  SIGPWR            |  terminate       |
*        |  SIGRTMIN-SIGRTMAX |  terminate       |
*        +--------------------+------------------+
*        |  non-POSIX signal  |  default action  |
*        +--------------------+------------------+
*        |  SIGEMT            |  coredump        |
*        +--------------------+------------------+

既然已經可以針對相應信號進行註冊 action,那麼當信號發生的時候,如何來響應信號呢?

Linux 是通過在進程切換的時候,作爲鉤子點進行統一處理的。

每次進程調度切換之後,都會執行 ret_from_work 函數:

ENTRY(ret_from_fork)
pushl        %eax
call        schedule_tail
GET_THREAD_INFO(%ebp)
popl        %eax
pushl        $0x0202                // 重置內核 eflags
popfl

// 當我們執行完 fork,我們同樣會跟蹤子進程返回的系統調用
movl    %esp, %eax
call    syscall_return_slowpath
jmp     restore_all
END(ret_from_fork)

其中 syscall_return_slowpath 的調用鏈路爲:

if (cached_flags & _TIF_SIGPENDING)
do_signal(regs);

最終 do_signal 調用 handle_signal 對信號進行了處理:

void do_signal(struct pt_regs *regs)
{
    struct ksignal ksig;
    if (get_signal(&ksig)) {
    handle_signal(&ksig, regs);
    return;
    }
…
}

注意 

我們可以讓一個進程產生 coredump 文件用於調試。首先可以通過 ulimit-c unlimited 命令不限制 corefile 文件的大小,然後通過 kill-3 pid 來通知進程調用 coredump。

最後我們來簡單介紹 kill 命令的實現,它是通過發送信號的系統調用來實現的,這樣的系統調用有很多,最終都會調用 __send_signal()函數:

staticint __send_signal(int sig,struct siginfo *info, struct task_struct *t,
int group, int from_ancestor_ns)
{
    struct sigpending *pending;
    struct sigqueue *q;
    int override_rlimit;
    ……
    // 找到需要掛起的隊列
    pending = group ? &t->signal->shared_pending : &t->pending;
    ……
    // 分配隊列項結構
    q = __sigqueue_alloc(t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
    override_rlimit);
    if (q) {          // 如果分配成功,將該結構添加到掛起隊列,並進行初始化
        list_add_tail(&q->list, &pending->list);
        switch ((unsigned long) info) {
            case (unsigned long) SEND_SIG_NOINFO:
            q->info.si_signo = sig;
            q->info.si_errno = 0;
            q->info.si_code = SI_USER;
            q->info.si_pid = task_tgid_nr_ns(current,
            task_active_pid_ns(t));
            q->info.si_uid = current_uid();
            break;
            case (unsigned long) SEND_SIG_PRIV:
            q->info.si_signo = sig;
            q->info.si_errno = 0;
            q->info.si_code = SI_KERNEL;
            q->info.si_pid = 0;
            q->info.si_uid = 0;
            break;
            default:
            copy_siginfo(&q->info, info);
            if (from_ancestor_ns)
            q->info.si_pid = 0;
            break;
        }
            } else if (!is_si_special(info)) {
            if (sig >= SIGRTMIN && info->si_code != SI_USER)
            return -EAGAIN;
        }

    out_set:
    signalfd_notify(t, sig);                  // 喚醒 action 中的等待隊列
    sigaddset(&pending->signal, sig);  // 設置信號 ID 位掩碼,即上面所說的那個位圖
    complete_signal(sig, t, group);           // 試着喚醒執行該信號的進程
    return 0;
}

發送信號,即將該信號鏈接到制定進程的信號掛起隊列上,最後試着喚醒執行該信號的進程,這樣我們就能理解爲什麼信號的響應都是在執行 ret_from_work 的時候進行。

4.7 Nginx 信號處理機制

很多軟件在啓動過程中都會註冊默認信號處理函數。例如 Nginx,在 main 函數啓動的時候就通過 ngx_init_signals 函數進行了信號處理機制初始化:

int ngx_cdecl
main(int argc, char *const *argv)
{
…
if (ngx_init_signals(cycle->log) != NGX_OK) {
        return 1;
    }
…

其中 ngx_init_signals 函數就是將默認的信號處理回調函數註冊到相應的信號:

ngx_int_t
ngx_init_signals(ngx_log_t *log)
{
    ngx_signal_t      *sig;
    struct sigaction   sa;
    for (sig = signals; sig->signo != 0; sig++) {
        ngx_memzero(&sa, sizeof(struct sigaction));
        if (sig->handler) {
            sa.sa_sigaction = sig->handler;
            sa.sa_flags = SA_SIGINFO;
        } else {
            sa.sa_handler = SIG_IGN;
        }
        sigemptyset(&sa.sa_mask);
    …
    return NGX_OK;
}

這些信號相應的默認處理函數也在全局做了定義:

ngx_signal_t  signals[] = {
    { ngx_signal_value(NGX_RECONFIGURE_SIGNAL),
        "SIG" ngx_value(NGX_RECONFIGURE_SIGNAL),
        "reload",
        ngx_signal_handler },
    { ngx_signal_value(NGX_REOPEN_SIGNAL),
        "SIG" ngx_value(NGX_REOPEN_SIGNAL),
        "reopen",
        ngx_signal_handler },
    { ngx_signal_value(NGX_NOACCEPT_SIGNAL),
        "SIG" ngx_value(NGX_NOACCEPT_SIGNAL),
        "",
        ngx_signal_handler },
    { ngx_signal_value(NGX_TERMINATE_SIGNAL),
        "SIG" ngx_value(NGX_TERMINATE_SIGNAL),
        "stop",
        ngx_signal_handler },
    { ngx_signal_value(NGX_SHUTDOWN_SIGNAL),
        "SIG" ngx_value(NGX_SHUTDOWN_SIGNAL),
        "quit",
        ngx_signal_handler },
    { ngx_signal_value(NGX_CHANGEBIN_SIGNAL),
        "SIG" ngx_value(NGX_CHANGEBIN_SIGNAL),
        "",
        ngx_signal_handler },
    { SIGALRM, "SIGALRM", "", ngx_signal_handler },
    { SIGINT, "SIGINT", "", ngx_signal_handler },
    { SIGIO, "SIGIO", "", ngx_signal_handler },
    { SIGCHLD, "SIGCHLD", "", ngx_signal_handler },
    { SIGSYS, "SIGSYS, SIG_IGN", "", NULL },
    { SIGPIPE, "SIGPIPE, SIG_IGN", "", NULL },
    { 0, NULL, "", NULL }
};

4.8 本章小結

可以這樣認爲,除了信號處理機制是結合進程切換過程的實現外,本章提到的其他幾種機制,都是結合中斷機制來展開的。只要使用操作系統,幾乎在所有的地方都會用到中斷機制,不管寫什麼程序,都要結合系統調用來展開。

很多軟件在啓動過程中都會註冊默認信號處理函數,例如 Nginx 等,其他軟件也有類似的處理,大家有興趣也可以自己去閱讀其他軟件源代碼。

本章的內容是承上啓下的,因爲進程的切換、信號處理等都需要通過中斷機制來實現。另外,和操作系統相關的 I/O 也和中斷機制有很大的關係,有了中斷機制,才能建立更加高效的 I/O 模型,提升系統響應效率。下一章將介紹 I/O 機制。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章