歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux基礎 >> 關於Linux >> Linux進程上下文切換過程context_switch詳解--Linux進程的管理與調度(二十一)

Linux進程上下文切換過程context_switch詳解--Linux進程的管理與調度(二十一)

日期:2017/3/1 11:43:00   编辑:關於Linux

前面我們了解了linux進程調度器的設計思路和注意框架

周期調度器scheduler_tick通過linux定時器周期性的被激活, 進行程序調度

進程主動放棄CPU或者發生阻塞時, 則會調用主調度器schedule進行程序調度

在分析的過程中, 我們提到了內核搶占和用戶搶占的概念, 但是並沒有詳細講, 因此我們在這裡詳細分析一下子

CPU搶占分兩種情況, 用戶搶占, 內核搶占

其中內核搶占是在Linux2.5.4版本發布時加入, 同SMP(Symmetrical Multi-Processing, 對稱多處理器), 作為內核的可選配置。

1 前景回顧


1.1 Linux的調度器組成


2個調度器

可以用兩種方法來激活調度

一種是直接的, 比如進程打算睡眠或出於其他原因放棄CPU

另一種是通過周期性的機制, 以固定的頻率運行, 不時的檢測是否有必要

因此當前linux的調度程序由兩個調度器組成:主調度器周期性調度器(兩者又統稱為通用調度器(generic scheduler)核心調度器(core scheduler))

並且每個調度器包括兩個內容:調度框架(其實質就是兩個函數框架)及調度器類

6種調度策略

linux內核目前實現了6中調度策略(即調度算法), 用於對不同類型的進程進行調度, 或者支持某些特殊的功能

SCHED_NORMAL和SCHED_BATCH調度普通的非實時進程

SCHED_FIFO和SCHED_RR和SCHED_DEADLINE則采用不同的調度策略調度實時進程

SCHED_IDLE則在系統空閒時調用idle進程.

5個調度器類

而依據其調度策略的不同實現了5個調度器類, 一個調度器類可以用一種種或者多種調度策略調度某一類進程, 也可以用於特殊情況或者調度特殊功能的進程.

其所屬進程的優先級順序為

stop_sched_class -> dl_sched_class -> rt_sched_class -> fair_sched_class -> idle_sched_class

3個調度實體

調度器不限於調度進程, 還可以調度更大的實體, 比如實現組調度.

這種一般性要求調度器不直接操作進程, 而是處理可調度實體, 因此需要一個通用的數據結構描述這個調度實體,即seched_entity結構, 其實際上就代表了一個調度對象,可以為一個進程,也可以為一個進程組.

linux中針對當前可調度的實時和非實時進程, 定義了類型為seched_entity的3個調度實體

sched_dl_entity 采用EDF算法調度的實時調度實體

sched_rt_entity 采用Roound-Robin或者FIFO算法調度的實時調度實體

sched_entity 采用CFS算法調度的普通非實時進程的調度實體

1.2 調度工作


周期性調度器通過調用各個調度器類的task_tick函數完成周期性調度工作

如果當前進程是完全公平隊列中的進程, 則首先根據當前就緒隊列中的進程數算出一個延遲時間間隔,大概每個進程分配2ms時間,然後按照該進程在隊列中的總權重中占得比例,算出它該執行的時間X,如果該進程執行物理時間超過了X,則激發延遲調度;如果沒有超過X,但是紅黑樹就緒隊列中下一個進程優先級更高,即curr->vruntime-leftmost->vruntime > X,也將延遲調度

如果當前進程是實時調度類中的進程:則如果該進程是SCHED_RR,則遞減時間片[為HZ/10],到期,插入到隊列尾部,並激發延遲調度,如果是SCHED_FIFO,則什麼也不做,直到該進程執行完成

延遲調度**的真正調度過程在:schedule中實現,會按照調度類順序和優先級挑選出一個最高優先級的進程執行

而對於主調度器則直接關閉內核搶占後, 通過調用schedule來完成進程的調度

可見不管是周期性調度器還是主調度器, 內核中的許多地方, 如果要將CPU分配給與當前活動進程不同的另外一個進程(即搶占),都會直接或者調用調度函數, 包括schedule或者其子函數__schedule, 其中schedule在關閉內核搶占後調用__schedule完成了搶占.

而__schedule則執行了如下操作

__schedule如何完成內核搶占

完成一些必要的檢查, 並設置進程狀態, 處理進程所在的就緒隊列

調度全局的pick_next_task選擇搶占的進程

如果當前cpu上所有的進程都是cfs調度的普通非實時進程, 則直接用cfs調度, 如果無程序可調度則調度idle進程

否則從優先級最高的調度器類sched_class_highest(目前是stop_sched_class)開始依次遍歷所有調度器類的pick_next_task函數, 選擇最優的那個進程執行

context_switch完成進程上下文切換

即進程的搶占或者切換工作是由context_switch完成的

那麼我們今天就詳細講解一下context_switch完成進程上下文切換的原理

2 進程上下文


2.1 進程上下文的概念


操作系統管理很多進程的執行. 有些進程是來自各種程序、系統和應用程序的單獨進程,而某些進程來自被分解為很多進程的應用或程序。當一個進程從內核中移出,另一個進程成為活動的, 這些進程之間便發生了上下文切換. 操作系統必須記錄重啟進程和啟動新進程使之活動所需要的所有信息. 這些信息被稱作上下文, 它描述了進程的現有狀態, 進程上下文是可執行程序代碼是進程的重要組成部分, 實際上是進程執行活動全過程的靜態描述, 可以看作是用戶進程傳遞給內核的這些參數以及內核要保存的那一整套的變量和寄存器值和當時的環境等

進程的上下文信息包括, 指向可執行文件的指針, 棧, 內存(數據段和堆), 進程狀態, 優先級, 程序I/O的狀態, 授予權限, 調度信息, 審計信息, 有關資源的信息(文件描述符和讀/寫指針), 關事件和信號的信息, 寄存器組(棧指針, 指令計數器)等等, 諸如此類.

處理器總處於以下三種狀態之一

1. 內核態,運行於進程上下文,內核代表進程運行於內核空間;

2. 內核態,運行於中斷上下文,內核代表硬件運行於內核空間;

3. 用戶態,運行於用戶空間。

用戶空間的應用程序,通過系統調用,進入內核空間。這個時候用戶空間的進程要傳遞 很多變量、參數的值給內核,內核態運行的時候也要保存用戶進程的一些寄存器值、變量等。所謂的”進程上下文”

硬件通過觸發信號,導致內核調用中斷處理程序,進入內核空間。這個過程中,硬件的 一些變量和參數也要傳遞給內核,內核通過這些參數進行中斷處理。所謂的”中斷上下文”,其實也可以看作就是硬件傳遞過來的這些參數和內核需要保存的一些其他環境(主要是當前被打斷執行的進程環境)。

LINUX完全注釋中的一段話

當一個進程在執行時,CPU的所有寄存器中的值、進程的狀態以及堆棧中的內容被稱 為該進程的上下文。當內核需要切換到另一個進程時,它需要保存當前進程的 所有狀態,即保存當前進程的上下文,以便在再次執行該進程時,能夠必得到切換時的狀態執行下去。在LINUX中,當前進程上下文均保存在進程的任務數據結 構中。在發生中斷時,內核就在被中斷進程的上下文中,在內核態下執行中斷服務例程。但同時會保留所有需要用到的資源,以便中繼服務結束時能恢復被中斷進程 的執行.

2.2 上下文切換

進程被搶占CPU時候, 操作系統保存其上下文信息, 同時將新的活動進程的上下文信息加載進來, 這個過程其實就是上下文切換, 而當一個被搶占的進程再次成為活動的, 它可以恢復自己的上下文繼續從被搶占的位置開始執行.

上下文切換(有時也稱做進程切換任務切換)是指CPU從一個進程或線程切換到另一個進程或線程

稍微詳細描述一下,上下文切換可以認為是內核(操作系統的核心)在 CPU 上對於進程(包括線程)進行以下的活動:

掛起一個進程,將這個進程在 CPU 中的狀態(上下文)存儲於內存中的某處,

在內存中檢索下一個進程的上下文並將其在 CPU 的寄存器中恢復

跳轉到程序計數器所指向的位置(即跳轉到進程被中斷時的代碼行),以恢復該進程

因此上下文是指某一時間點CPU寄存器和程序計數器的內容, 廣義上還包括內存中進程的虛擬地址映射信息.

上下文切換只能發生在內核態中, 上下文切換通常是計算密集型的。也就是說,它需要相當可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統來說意味著消耗大量的 CPU 時間,事實上,可能是操作系統中時間消耗最大的操作。
Linux相比與其他操作系統(包括其他類 Unix 系統)有很多的優點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少.

3 context_switch進程上下文切換


linux中進程調度時, 內核在選擇新進程之後進行搶占時, 通過context_switch完成進程上下文切換.

注意 進程調度與搶占的區別

進程調度不一定發生搶占, 但是搶占時卻一定發生了調度

在進程發生調度時, 只有當前內核發生當前進程因為主動或者被動需要放棄CPU時, 內核才會選擇一個與當前活動進程不同的進程來搶占CPU

context_switch其實是一個分配器, 他會調用所需的特定體系結構的方法

調用switch_mm(), 把虛擬內存從一個進程映射切換到新進程中

switch_mm更換通過task_struct->mm描述的內存管理上下文, 該工作的細節取決於處理器, 主要包括加載頁表, 刷出地址轉換後備緩沖器(部分或者全部), 向內存管理單元(MMU)提供新的信息

調用switch_to(),從上一個進程的處理器狀態切換到新進程的處理器狀態。這包括保存、恢復棧信息和寄存器信息

switch_to切換處理器寄存器的呢內容和內核棧(虛擬地址空間的用戶部分已經通過switch_mm變更, 其中也包括了用戶狀態下的棧, 因此switch_to不需要變更用戶棧, 只需變更內核棧), 此段代碼嚴重依賴於體系結構, 且代碼通常都是用匯編語言編寫.

context_switch函數建立next進程的地址空間。進程描述符的active_mm字段指向進程所使用的內存描述符,而mm字段指向進程所擁有的內存描述符。對於一般的進程,這兩個字段有相同的地址,但是,內核線程沒有它自己的地址空間而且它的 mm字段總是被設置為 NULL

context_switch( )函數保證:如果next是一個內核線程, 它使用prev所使用的地址空間

由於不同架構下地址映射的機制有所區別, 而寄存器等信息弊病也是依賴於架構的, 因此switch_mm和switch_to兩個函數均是體系結構相關的

3.1 context_switch完全注釋


context_switch定義在kernel/sched/core.c#L2711, 如下所示

/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
           struct task_struct *next)
{
    struct mm_struct *mm, *oldmm;

    /*  完成進程切換的准備工作  */
    prepare_task_switch(rq, prev, next);

    mm = next->mm;
    oldmm = prev->active_mm;
    /*
     * For paravirt, this is coupled with an exit in switch_to to
     * combine the page table reload and the switch backend into
     * one hypercall.
     */
    arch_start_context_switch(prev);

    /*  如果next是內核線程,則線程使用prev所使用的地址空間
     *  schedule( )函數把該線程設置為懶惰TLB模式
     *  內核線程並不擁有自己的頁表集(task_struct->mm = NULL)
     *  它使用一個普通進程的頁表集
     *  不過,沒有必要使一個用戶態線性地址對應的TLB表項無效
     *  因為內核線程不訪問用戶態地址空間。
    */
    if (!mm)        /*  內核線程無虛擬地址空間, mm = NULL*/
    {
        /*  內核線程的active_mm為上一個進程的mm
         *  注意此時如果prev也是內核線程,
         *  則oldmm為NULL, 即next->active_mm也為NULL  */
        next->active_mm = oldmm;
        /*  增加mm的引用計數  */
        atomic_inc(&oldmm->mm_count);
        /*  通知底層體系結構不需要切換虛擬地址空間的用戶部分
         *  這種加速上下文切換的技術稱為惰性TBL  */
        enter_lazy_tlb(oldmm, next);
    }
    else            /*  不是內核線程, 則需要切切換虛擬地址空間  */
        switch_mm(oldmm, mm, next);

    /*  如果prev是內核線程或正在退出的進程
     *  就重新設置prev->active_mm
     *  然後把指向prev內存描述符的指針保存到運行隊列的prev_mm字段中
     */
    if (!prev->mm)
    {
        /*  將prev的active_mm賦值和為空  */
        prev->active_mm = NULL;
        /*  更新運行隊列的prev_mm成員  */
        rq->prev_mm = oldmm;
    }
    /*
     * Since the runqueue lock will be released by the next
     * task (which is an invalid locking op but in the case
     * of the scheduler it's an obvious special-case), so we
     * do an early lockdep release here:
     */
    lockdep_unpin_lock(&rq->lock);
    spin_release(&rq->lock.dep_map, 1, _THIS_IP_);

    /* Here we just switch the register state and the stack. 
     * 切換進程的執行環境, 包括堆棧和寄存器
     * 同時返回上一個執行的程序
     * 相當於prev = witch_to(prev, next)  */
    switch_to(prev, next, prev);

    /*  switch_to之後的代碼只有在
     *  當前進程再次被選擇運行(恢復執行)時才會運行
     *  而此時當前進程恢復執行時的上一個進程可能跟參數傳入時的prev不同
     *  甚至可能是系統中任意一個隨機的進程
     *  因此switch_to通過第三個參數將此進程返回
     */


    /*  路障同步, 一般用編譯器指令實現
     *  確保了switch_to和finish_task_switch的執行順序
     *  不會因為任何可能的優化而改變  */
    barrier();  

    /*  進程切換之後的處理工作  */
    return finish_task_switch(prev);
}
````


##3.2   prepare_arch_switch切換前的准備工作
-------

在進程切換之前, 首先執行調用每個體系結構都必須定義的prepare_task_switch掛鉤, 這使得內核執行特定於體系結構的代碼, 為切換做事先准備. 大多數支持的體系結構都不需要該選項


```c
struct mm_struct *mm, *oldmm;

prepare_task_switch(rq, prev, next);    /*  完成進程切換的准備工作  */

prepare_task_switch函數定義在kernel/sched/core.c, line 2558, 如下所示

/**
 * prepare_task_switch - prepare to switch tasks
 * @rq: the runqueue preparing to switch
 * @prev: the current task that is being switched out
 * @next: the task we are going to switch to.
 *
 * This is called with the rq lock held and interrupts off. It must
 * be paired with a subsequent finish_task_switch after the context
 * switch.
 *
 * prepare_task_switch sets up locking and calls architecture specific
 * hooks.
 */
static inline void
prepare_task_switch(struct rq *rq, struct task_struct *prev,
            struct task_struct *next)
{
    sched_info_switch(rq, prev, next);
    perf_event_task_sched_out(prev, next);
    fire_sched_out_preempt_notifiers(prev, next);
    prepare_lock_switch(rq, next);
    prepare_arch_switch(next);
}
````


##3.3   next是內核線程時的處理
-------

由於用戶空間進程的寄存器內容在進入核心態時保存在內核棧中, 在上下文切換期間無需顯式操作. 而因為每個進程首先都是從核心態開始執行(在調度期間控制權傳遞給新進程), 在返回用戶空間時, 會使用內核棧上保存的值自動恢復寄存器數據.


另外需要注意, 內核線程沒有自身的用戶空間上下文, 其task_struct->mm為NULL, 參見[Linux內核線程kernel thread詳解--Linux進程的管理與調度(十)](http://blog.csdn.net/gatieme/article/details/51589205#t3), 從當前進程"借來"的地址空間記錄在active_mm中


```c
/*  如果next是內核線程,則線程使用prev所使用的地址空間
 *  schedule( )函數把該線程設置為懶惰TLB模式
 *  內核線程並不擁有自己的頁表集(task_struct->mm = NULL)
 *  它使用一個普通進程的頁表集
 *  不過,沒有必要使一個用戶態線性地址對應的TLB表項無效
 *  因為內核線程不訪問用戶態地址空間。
*/
if (!mm)        /*  內核線程無虛擬地址空間, mm = NULL*/
{
    /*  內核線程的active_mm為上一個進程的mm
     *  注意此時如果prev也是內核線程,
     *  則oldmm為NULL, 即next->active_mm也為NULL  */
    next->active_mm = oldmm;
    /*  增加mm的引用計數  */
    atomic_inc(&oldmm->mm_count);
    /*  通知底層體系結構不需要切換虛擬地址空間的用戶部分
     *  這種加速上下文切換的技術稱為惰性TBL  */
    enter_lazy_tlb(oldmm, next);
}
else            /*  不是內核線程, 則需要切切換虛擬地址空間  */
    switch_mm(oldmm, mm, next);
````
qizhongenter_lazy_tlb通知底層體系結構不需要切換虛擬地址空間的用戶空間部分, 這種加速上下文切換的技術稱之為惰性TLB

##3.4   switch_mm切換進程虛擬地址空間
-------

###3.4.1    switch_mm函數
-------

switch_mm主要完成了進程prev到next虛擬地址空間的映射, 由於內核虛擬地址空間是不許呀切換的, 因此切換的主要是用戶態的虛擬地址空間

這個是一個體系結構相關的函數, 其實現在對應體系結構下的[arch/對應體系結構/include/asm/mmu_context.h](http://lxr.free-electrons.com/ident?v=4.6;i=switch_mm)文件中, 我們下面列出了幾個常見體系結構的實現

| 體系結構 | switch_mm實現 |
| ------- |:-------:|
| x86 | [arch/x86/include/asm/mmu_context.h, line 118](http://lxr.free-electrons.com/source/arch/x86/include/asm/mmu_context.h?v=4.6) |
| arm | [arch/arm/include/asm/mmu_context.h, line 126](http://lxr.free-electrons.com/source/arch/arm/include/asm/mmu_context.h?v=4.6#L126) |
| arm64 | [arch/arm64/include/asm/mmu_context.h, line 183](http://lxr.free-electrons.com/source/arch/arm64/include/asm/mmu_context.h?v=4.6#L183)

其主要工作就是切換了進程的CR3

###3.4.2  CPU-CR0~CR4寄存器
-------

控制寄存器(CR0~CR3)用於控制和確定處理器的操作模式以及當前執行任務的特性


| 控制寄存器 | 描述 |
| ------- |:-------:|
| CR0 | 含有控制處理器操作模式和狀態的系統控制標志 |
| CR1 | 保留不用, 未定義的控制寄存器,供將來的處理器使用 |
| CR3 | 含有頁目錄表物理內存基地址,因此該寄存器也被稱為頁目錄基地址寄存器PDBR(Page-Directory Base address Register), 保存頁目錄表的物理地址,頁目錄表總是放在以4K字節為單位的存儲器邊界上,因此,它的地址的低12位總為0,不起作用,即使寫上內容,也不會被理會 |
| CR4 | 在Pentium系列(包括486的後期版本)處理器中才實現,它處理的事務包括諸如何時啟用虛擬8086模式等 |

### 3.4.3   保護模式下的GDT、LDT和IDT
-------

保護模式下三個重要的系統表——GDT、LDT和IDT

這三個表是在內存中由操作系統或系統程序員所建,並不是固化在哪裡,所以從理論上是可以被讀寫的。

這三個表都是描述符表. 描述符表是由若干個描述符組成, 每個描述符占用8個字節的內存空間, 每個描述符表內最多可以有8129個描述符. 描述符是描述一個段的大小,地址及各種狀態的。

描述符表有三種,分別為**全局描述符表GDT**、**局部描述符表LDT**和**中斷描述符表IDT** 


| 描述符表 | 描述 |
| ------- |:-------:|
| 全局描述符表GDT | 全局描述符表在系統中只能有一個,且可以被每一個任務所共享.
任何描述符都可以放在GDT中,但中斷門和陷阱門放在GDT中是不會起作用的. 能被多個任務共享的內存區就是通過GDT完成的 |
| 局部描述符表LDT | 局部描述符表在系統中可以有多個,通常情況下是與任務的數量保持對等,但任務可以沒有局部描述符表.

任務間不相干的部分也是通過LDT實現的.這裡涉及到地址映射的問題.

和GDT一樣,中斷門和陷阱門放在LDT中是不會起作用的. |
| 中斷描述符表IDT | 和GDT一樣,中斷描述符表在系統最多只能有一個,中斷描述符表內可以存放256個描述符,分別對應256個中斷.因為每個描述符占用8個字節,所以IDT的長度可達2K.

中斷描述符表中可以有任務門、中斷門、陷阱門三個門描述符,其它的描述符在中斷描述符表中無意義 |

**段選擇子**

在保護模式下,段寄存器的內容已不是段值,而稱其為選擇子.
該選擇子指示描述符在上面這三個表中的位置,所以說選擇子即是索引值。
當我們把段選擇子裝入寄存器時不僅使該寄存器值,同時CPU將該選擇子所對應的GDT或LDT中的描述符裝入了不可見部分。
這樣只要我們不進行代碼切換(不重新裝入新的選擇子)CPU就不會對不可見部分存儲的描述符進行更新,可以直接進行訪問,加快了訪問速度。
一旦寄存器被重新賦值,不可見部分也將被重新賦值。

**關於選擇子的值是否連續**

關於選擇子的值,我認為不一定要連續。
但是每個描述符的起始地址相對於第一個描述符(即空描述符)的首地址的偏移必須是8的倍數,即二進制最後三位為0。這樣通過全局描述符表寄存器GDTR找到全局描述符表的首地址後,使用段選擇子的高13位索引到正確的描述符表項(段選擇子的高13位左移3位加上GDTR的值即為段選擇子指定的段描述符的邏輯首地址)

也就是說在兩個段選擇符之間可以填充能被8整除個字節值。當然,如果有選擇子指向了這些填充的字節,一般會出錯,除非你有意填充一些恰當的數值,呵呵。

**關於為什麼LDT要放在GDT中 -LDT中的描述符和GDT中的描述符**

除了選擇子的bit3一個為0一個為1用於區分該描述符是在GDT中還是在LDT中外,描述符本身的結構完全一樣。
開始我考慮既然是這樣,為什麼要將LDT放在GDT中而不是像GDT那樣找一個GDTR寄存器呢?

後來終於明白了原因——很簡單,
GDT表只有一個,是固定的;而LDT表每個任務就可以有一個,因此有多個,並且由於任務的個數在不斷變化其數量也在不斷變化。

如果只有一個LDTR寄存器顯然不能滿足多個LDT的要求。因此INTEL的做法是把它放在放在GDT中。

##3.5   prev是內核線程時的處理
-------

如果前一個進程prev四內核線程(即prev->mm為NULL), 則其active_mm指針必須重置為NULL, 已斷開其於之前借用的地址空間的聯系, 而當prev重新被調度的時候, 此時它成為next會在前面[next是內核線程時的處理](未填寫網址)處重新用`next->active_mm = oldmm;`賦值, 這個我們剛講過



```c
/*  如果prev是內核線程或正在退出的進程
 *  就重新設置prev->active_mm
 *  然後把指向prev內存描述符的指針保存到運行隊列的prev_mm字段中
 */
if (!prev->mm)
{
    /*  將prev的active_mm賦值和為空  */
    prev->active_mm = NULL;
    /*  更新運行隊列的prev_mm成員  */
    rq->prev_mm = oldmm;
}

3.4.4 switch_mm函數注釋


下面我們提取了x86架構下的switch_mm函數, 其定義在arch/x86/include/asm/mmu_context.h, line 118

//  http://lxr.free-electrons.com/source/arch/x86/include/asm/mmu_context.h?v=4.6
static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next,
                 struct task_struct *tsk)
{
    unsigned cpu = smp_processor_id();


    /*  確保prev和next不是同一進程  */
    if (likely(prev != next))
    {
#ifdef CONFIG_SMP
        /*  刷新cpu地址轉換後備緩沖器TLB  */
        this_cpu_write(cpu_tlbstate.state, TLBSTATE_OK);
        this_cpu_write(cpu_tlbstate.active_mm, next);
#endif
        /*  設置當前進程的mm->cpu_vm_mask表示其占用cpu  */
        cpumask_set_cpu(cpu, mm_cpumask(next));

        /*
         * Re-load page tables.
         *
         * This logic has an ordering constraint:
         *
         *  CPU 0: Write to a PTE for 'next'
         *  CPU 0: load bit 1 in mm_cpumask.  if nonzero, send IPI.
         *  CPU 1: set bit 1 in next's mm_cpumask
         *  CPU 1: load from the PTE that CPU 0 writes (implicit)
         *
         * We need to prevent an outcome in which CPU 1 observes
         * the new PTE value and CPU 0 observes bit 1 clear in
         * mm_cpumask.  (If that occurs, then the IPI will never
         * be sent, and CPU 0's TLB will contain a stale entry.)
         *
         * The bad outcome can occur if either CPU's load is
         * reordered before that CPU's store, so both CPUs must
         * execute full barriers to prevent this from happening.
         *
         * Thus, switch_mm needs a full barrier between the
         * store to mm_cpumask and any operation that could load
         * from next->pgd.  TLB fills are special and can happen
         * due to instruction fetches or for no reason at all,
         * and neither LOCK nor MFENCE orders them.
         * Fortunately, load_cr3() is serializing and gives the
         * ordering guarantee we need.
         * 
         * 將新進程的pgd頁目錄表填寫到cpu的cr3寄存器中
         */
        load_cr3(next->pgd);

        trace_tlb_flush(TLB_FLUSH_ON_TASK_SWITCH, TLB_FLUSH_ALL);

        /* Stop flush ipis for the previous mm 
         * 除prev的cpu_vm_mask,表示prev放棄使用cpu  */
        cpumask_clear_cpu(cpu, mm_cpumask(prev));

        /* Load per-mm CR4 state
         */
        load_mm_cr4(next);

#ifdef CONFIG_MODIFY_LDT_SYSCALL
        /*
         * Load the LDT, if the LDT is different.
         *
         * It's possible that prev->context.ldt doesn't match
         * the LDT register.  This can happen if leave_mm(prev)
         * was called and then modify_ldt changed
         * prev->context.ldt but suppressed an IPI to this CPU.
         * In this case, prev->context.ldt != NULL, because we
         * never set context.ldt to NULL while the mm still
         * exists.  That means that next->context.ldt !=
         * prev->context.ldt, because mms never share an LDT.
         *
         * 
         */
        if (unlikely(prev->context.ldt != next->context.ldt))
            load_mm_ldt(next);
#endif
    }
#ifdef CONFIG_SMP
    else
    {
        this_cpu_write(cpu_tlbstate.state, TLBSTATE_OK);
        BUG_ON(this_cpu_read(cpu_tlbstate.active_mm) != next);

        if (!cpumask_test_cpu(cpu, mm_cpumask(next)))
        {
            /*
             * On established mms, the mm_cpumask is only changed
             * from irq context, from ptep_clear_flush() while in
             * lazy tlb mode, and here. Irqs are blocked during
             * schedule, protecting us from simultaneous changes.
             */
            cpumask_set_cpu(cpu, mm_cpumask(next));

            /*
             * We were in lazy tlb mode and leave_mm disabled
             * tlb flush IPI delivery. We must reload CR3
             * to make sure to use no freed page tables.
             *
             * As above, load_cr3() is serializing and orders TLB
             * fills with respect to the mm_cpumask write.
             */
            load_cr3(next->pgd);
            trace_tlb_flush(TLB_FLUSH_ON_TASK_SWITCH, TLB_FLUSH_ALL);
            load_mm_cr4(next);
            load_mm_ldt(next);
        }
    }
#endif
}

3.6 switch_to完成進程切換


3.6.1 switch_to函數


最後用switch_to完成了進程的切換, 該函數切換了寄存器狀態和棧, 新進程在該調用後開始執行, 而switch_to之後的代碼只有在當前進程下一次被選擇運行時才會執行

執行環境的切換是在switch_to()中完成的, switch_to完成最終的進程切換,它保存原進程的所有寄存器信息,恢復新進程的所有寄存器信息,並執行新的進程

該函數往往通過宏來實現, 其原型聲明如下

/*
 * Saving eflags is important. It switches not only IOPL between tasks,
 * it also protects other tasks from NT leaking through sysenter etc.
*/
#define switch_to(prev, next, last)
體系結構 switch_to實現 x86 arch/x86/include/asm/switch_to.h中兩種實現

定義CONFIG_X86_32宏

未定義CONFIG_X86_32宏 arm arch/arm/include/asm/switch_to.h, line 25 通用 include/asm-generic/switch_to.h, line 25

內核在switch_to中執行如下操作

進程切換, 即esp的切換, 由於從esp可以找到進程的描述符

硬件上下文切換, 設置ip寄存器的值, 並jmp到__switch_to函數

堆棧的切換, 即ebp的切換, ebp是棧底指針, 它確定了當前用戶空間屬於哪個進程

__switch_to函數

體系結構 __switch_to實現 x86 arch/x86/kernel/process_32.c, line 242 x86_64 arch/x86/kernel/process_64.c, line 277 arm64 arch/arm64/kernel/process.c, line 329

3.6.2 為什麼switch_to需要3個參數


調度過程可能選擇了一個新的進程, 而清理工作則是針對此前的活動進程, 請注意, 這不是發起上下文切換的那個進程, 而是系統中隨機的某個其他進程, 內核必須想辦法使得進程能夠與context_switch例程通信, 這就可以通過switch_to宏實現. 因此switch_to函數通過3個參數提供2個變量.

在新進程被選中時, 底層的進程切換冽程必須將此前執行的進程提供給context_switch, 由於控制流會回到陔函數的中間, 這無法用普通的函數返回值來做到, 因此提供了3個參數的宏

我們考慮這個樣一個例子, 假定多個進程A, B, C…在系統上運行, 在某個時間點, 內核決定從進程A切換到進程B, 此時prev = A, next = B, 即執行了switch_to(A, B), 而後當被搶占的進程A再次被選擇執行的時候, 系統可能進行了多次進程切換/搶占(至少會經歷一次即再次從B到A),假設A再次被選擇執行時時當前活動進程是C, 即此時prev = C. next = A.

在每個switch_to被調用的時候, prev和next指針位於各個進程的內核棧中, prev指向了當前運行的進程, 而next指向了將要運行的下一個進程, 那麼為了執行從prev到next的切換, switcth_to使用前兩個參數prev和next就夠了.

在進程A被選中再次執行的時候, 會出現一個問題, 此時控制權即將回到A, switch_to函數返回, 內核開始執行switch_to之後的點, 此時內核棧准確的恢復到切換之前的狀態, 即進程A上次被切換出去時的狀態, prev = A, next = B. 此時, 內核無法知道實際上在進程A之前運行的是進程C.

因此, 在新進程被選中執行時, 內核恢復到進程被切換出去的點繼續執行, 此時內核只知道誰之前將新進程搶占了, 但是卻不知道新進程再次執行是搶占了誰, 因此底層的進程切換機制必須將此前執行的進程(即新進程搶占的那個進程)提供給context_switch. 由於控制流會回到函數的該中間, 因此無法通過普通函數的返回值來完成. 因此使用了一個3個參數, 但是邏輯效果是相同的, 仿佛是switch_to是帶有兩個參數的函數, 而且返回了一個指向此前運行的進程的指針.

switch_to(prev, next, last);

prev = last = switch_to(prev, next);

其中返回的prev值並不是做參數的prev值, 而是prev被再次調度的時候搶占掉的那個進程last.

在上個例子中, 進程A提供給switch_to的參數是prev = A, next = B, 然後控制權從A交給了B, 但是恢復執行的時候是通過prev = C, next = A完成了再次調度, 而後內核恢復了進程A被切換之前的內核棧信息, 即prev = A, next = B. 內核為了通知調度機制A搶占了C的處理器, 就通過last參數傳遞回來, prev = last = C.

內核實現該行為特性的方式依賴於底層的體系結構, 但內核顯然可以通過考慮兩個進程的內核棧來重建所需要的信息

3.6.3 switch_to函數注釋


switch_mm()進行用戶空間的切換, 更確切地說, 是切換地址轉換表(pgd), 由於pgd包括內核虛擬地址空間和用戶虛擬地址空間地址映射, linux內核把進程的整個虛擬地址空間分成兩個部分, 一部分是內核虛擬地址空間, 另外一部分是內核虛擬地址空間, 各個進程的虛擬地址空間各不相同, 但是卻共用了同樣的內核地址空間, 這樣在進程切換的時候, 就只需要切換虛擬地址空間的用戶空間部分.

每個進程都有其自身的頁目錄表pgd

進程本身尚未切換, 而存儲管理機制的頁目錄指針cr3卻已經切換了,這樣不會造成問題嗎?不會的,因為這個時候CPU在系統空間運行,而所有進程的頁目錄表中與系統空間對應的目錄項都指向相同的頁表,所以,不管切換到哪一個進程的頁目錄表都一樣,受影響的只是用戶空間,系統空間的映射則永遠不變

我們下面來分析一下子, x86_32位下的switch_to函數, 其定義在arch/x86/include/asm/switch_to.h, line 27

先對flags寄存器和ebp壓入舊進程內核棧,並將確定舊進程恢復執行的下一跳地址,並將舊進程ip,esp保存到task_struct->thread_info中,這樣舊進程保存完畢;然後用新進程的thread_info->esp恢復新進程的內核堆棧,用thread->info的ip恢復新進程地址執行。
關鍵點:內核寄存器[eflags、ebp保存到內核棧;內核棧esp地址、ip地址保存到thread_info中,task_struct在生命期中始終是全局的,所以肯定能根據該結構恢復出其所有執行場景來]

/*
 * Saving eflags is important. It switches not only IOPL between tasks,
 * it also protects other tasks from NT leaking through sysenter etc.
 */
#define switch_to(prev, next, last)                                     \
do {                                                                    \
        /*                                                              \
         * Context-switching clobbers all registers, so we clobber      \
         * them explicitly, via unused output variables.                \
         * (EAX and EBP is not listed because EBP is saved/restored     \
         * explicitly for wchan access and EAX is the return value of   \
         * __switch_to())                                               \
         */                                                             \
        unsigned long ebx, ecx, edx, esi, edi;                          \
                                                                        \
        asm volatile("pushfl\n\t" /* save flags 保存就的ebp、和flags寄存器到舊進程的內核棧中*/   \
                     "pushl %%ebp\n\t"          /* save    EBP   */     \
                     "movl %%esp,%[prev_sp]\n\t"        /* save ESP  將舊進程esp保存到thread_info結構中 */ \
                     "movl %[next_sp],%%esp\n\t"        /* restore ESP 用新進程esp填寫esp寄存器,此時內核棧已切換  */ \
                     "movl $1f,%[prev_ip]\n\t"  /* save EIP 將該進程恢復執行時的下條地址保存到舊進程的thread中*/     \
                     "pushl %[next_ip]\n\t"     /* restore EIP 將新進程的ip值壓入到新進程的內核棧中 */     \
                     __switch_canary                                    \
                     "jmp __switch_to\n"        /* regparm call  */     \
                     "1:\t"                                             \
                     "popl %%ebp\n\t"           /* restore EBP 該進程執行,恢復ebp寄存器*/     \
                     "popfl\n"                  /* restore flags  恢復flags寄存器*/     \
                                                                        \
                     /* output parameters */                            \
                     : [prev_sp] "=m" (prev->thread.sp),                \
                       [prev_ip] "=m" (prev->thread.ip),                \
                       "=a" (last),                                     \
                                                                        \
                       /* clobbered output registers: */                \
                       "=b" (ebx), "=c" (ecx), "=d" (edx),              \
                       "=S" (esi), "=D" (edi)                           \
                                                                        \
                       __switch_canary_oparam                           \
                                                                        \
                       /* input parameters: */                          \
                     : [next_sp]  "m" (next->thread.sp),                \
                       [next_ip]  "m" (next->thread.ip),                \
                                                                        \
                       /* regparm parameters for __switch_to(): */      \
                       [prev]     "a" (prev),                           \
                       [next]     "d" (next)                            \
                                                                        \
                       __switch_canary_iparam                           \
                                                                        \
                     : /* reloaded segment registers */                 \
                        "memory");                                      \
} while (0)

3.7 barrier路障同步


witch_to完成了進程的切換, 新進程在該調用後開始執行, 而switch_to之後的代碼只有在當前進程下一次被選擇運行時才會執行.

/*  switch_to之後的代碼只有在
 *  當前進程再次被選擇運行(恢復執行)時才會運行
 *  而此時當前進程恢復執行時的上一個進程可能跟參數傳入時的prev不同
 *  甚至可能是系統中任意一個隨機的進程
 *  因此switch_to通過第三個參數將此進程返回
*/


/*  路障同步, 一般用編譯器指令實現
 *  確保了switch_to和finish_task_switch的執行順序
 *  不會因為任何可能的優化而改變  */
barrier();

/*  進程切換之後的處理工作  */
return finish_task_switch(prev);

而為了程序編譯後指令的執行順序不會因為編譯器的優化而改變, 因此內核提供了路障同步barrier來保證程序的執行順序.

barrier往往通過編譯器指令來實現, 內核中多處都實現了barrier, 形式如下

// http://lxr.free-electrons.com/source/include/linux/compiler-gcc.h?v=4.6#L15
/* Copied from linux/compiler-gcc.h since we can't include it directly 
 * 采用內斂匯編實現
 *  __asm__用於指示編譯器在此插入匯編語句
 *  __volatile__用於告訴編譯器,嚴禁將此處的匯編語句與其它的語句重組合優化。
 *  即:原原本本按原來的樣子處理這這裡的匯編。
 *  memory強制gcc編譯器假設RAM所有內存單元均被匯編指令修改,這樣cpu中的registers和cache中已緩存的內存單元中的數據將作廢。cpu將不得不在需要的時候重新讀取內存中的數據。這就阻止了cpu又將registers,cache中的數據用於去優化指令,而避免去訪問內存。
 *  "":::表示這是個空指令。barrier()不用在此插入一條串行化匯編指令。在後文將討論什麼叫串行化指令。
*/
#define barrier() __asm__ __volatile__("": : :"memory")

3.8 finish_task_switch完成清理工作


finish_task_switch完成一些清理工作, 使得能夠正確的釋放鎖, 但我們不會詳細討論這些. 他會向各個體系結構提供了另一個掛鉤上下切換過程的可能性, 當然這只在少數計算機上需要.

前面我們諒解switch_to函數的3個參數時, 講到
注:A進程切換到B, A被切換, 而當A再次被選擇執行, C再次切換到A,此時A執行,但是系統為了告知調度器A再次執行前的進程是C, 通過switch_to的last參數返回的prev指向C,在A調度時候需要把調用A的進程的信息清除掉

由於從C切換到A時候, A內核棧中保存的實際上是A切換出時的狀態信息, 即prev=A, next=B,但是在A執行時, 其位於context_switch上下文中, 該函數的last參數返回的prev應該是切換到A的進程C, A負責對C進程信息進行切換後處理,比如,如果切換到A後,A發現C進程已經處於TASK_DEAD狀態,則將釋放C進程的TASK_STRUCT結構

函數定義在kernel/sched/core.c, line 2715中, 如下所示

/**
 * finish_task_switch - clean up after a task-switch
 * @prev: the thread we just switched away from.
 *
 * finish_task_switch must be called after the context switch, paired
 * with a prepare_task_switch call before the context switch.
 * finish_task_switch will reconcile locking set up by prepare_task_switch,
 * and do any other architecture-specific cleanup actions.
 *
 * Note that we may have delayed dropping an mm in context_switch(). If
 * so, we finish that here outside of the runqueue lock. (Doing it
 * with the lock held can cause deadlocks; see schedule() for
 * details.)
 *
 * The context switch have flipped the stack from under us and restored the
 * local variables which were saved when this task called schedule() in the
 * past. prev == current is still correct but we need to recalculate this_rq
 * because prev may have moved to another CPU.
 */
static struct rq *finish_task_switch(struct task_struct *prev)
        __releases(rq->lock)
{
        struct rq *rq = this_rq();
        struct mm_struct *mm = rq->prev_mm;
        long prev_state;

        /*
         * The previous task will have left us with a preempt_count of 2
         * because it left us after:
         *
         *      schedule()
         *        preempt_disable();                    // 1
         *        __schedule()
         *          raw_spin_lock_irq(&rq->lock)        // 2
         *
         * Also, see FORK_PREEMPT_COUNT.
         */
        if (WARN_ONCE(preempt_count() != 2*PREEMPT_DISABLE_OFFSET,
                      "corrupted preempt_count: %s/%d/0x%x\n",
                      current->comm, current->pid, preempt_count()))
                preempt_count_set(FORK_PREEMPT_COUNT);

        rq->prev_mm = NULL;

        /*
         * A task struct has one reference for the use as "current".
         * If a task dies, then it sets TASK_DEAD in tsk->state and calls
         * schedule one last time. The schedule call will never return, and
         * the scheduled task must drop that reference.
         *
         * We must observe prev->state before clearing prev->on_cpu (in
         * finish_lock_switch), otherwise a concurrent wakeup can get prev
         * running on another CPU and we could rave with its RUNNING -> DEAD
         * transition, resulting in a double drop.
         */
        prev_state = prev->state;
        vtime_task_switch(prev);
        perf_event_task_sched_in(prev, current);
        finish_lock_switch(rq, prev);
        finish_arch_post_lock_switch();

        fire_sched_in_preempt_notifiers(current);
        if (mm)
                mmdrop(mm);
        if (unlikely(prev_state == TASK_DEAD))  /*  如果上一個進程已經終止,釋放其task_struct 結構  */
        {
                if (prev->sched_class->task_dead)
                        prev->sched_class->task_dead(prev);

                /*
                 * Remove function-return probe instances associated with this
                 * task and put them back on the free list.
                 */
                kprobe_flush_task(prev);
                put_task_struct(prev);
        }

        tick_nohz_task_switch();
        return rq;
}
Copyright © Linux教程網 All Rights Reserved