一、前言
本文主要是以context_switch為起點,分析了整個進程切換過程中的基本操作和基本的代碼框架,很多細節(jié),例如tlb的操作,cache的操作,鎖的操作等等會在其他專門的文檔中描述。進程切換包括體系結(jié)構(gòu)相關(guān)的代碼和系統(tǒng)結(jié)構(gòu)無關(guān)的代碼。第二、三、四分別描述了context_switch的代碼脈絡(luò),后面的章節(jié)是以ARM64為例子,講述了具體進程地址空間的切換過程和硬件上下文的切換過程。
二、context_switch代碼分析
在kernel/sched/core.c中有一個context_switch函數(shù),該函數(shù)用來完成具體的進程切換,代碼如下(本文主要描述進程切換的基本邏輯,因此部分代碼會有刪節(jié)):
static inline struct rq * context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next)------------------(1) { struct mm_struct *mm, *oldmm;
mm = next->mm; oldmm = prev->active_mm;-------------------(2)
if (!mm) {---------------------------(3) next->active_mm = oldmm; atomic_inc(&oldmm->mm_count); enter_lazy_tlb(oldmm, next);-----------------(4) } else switch_mm(oldmm, mm, next); ---------------(5)
if (!prev->mm) {------------------------(6) prev->active_mm = NULL; rq->prev_mm = oldmm; }
switch_to(prev, next, prev);------------------(7) barrier();
return finish_task_switch(prev); }
(1)一旦調(diào)度器算法確定了pre task和next task,那么就可以調(diào)用context_switch函數(shù)實際執(zhí)行進行切換的工作了,這里我們先看看參數(shù)傳遞情況:
rq:在多核系統(tǒng)中,進程切換總是發(fā)生在各個cpu core上,參數(shù)rq指向本次切換發(fā)生的那個cpu對應(yīng)的run queue prev:將要被剝奪執(zhí)行權(quán)利的那個進程 next:被選擇在該cpu上執(zhí)行的那個進程
(2)next是馬上就要被切入的進程(后面簡稱B進程),prev是馬上就要被剝奪執(zhí)行權(quán)利的進程(后面簡稱A進程)。mm變量指向B進程的地址空間描述符,oldmm變量指向A進程的當前正在使用的地址空間描述符(active_mm)。對于normal進程,其任務(wù)描述符(task_struct)的mm和active_mm相同,都是指向其進程地址空間。對于內(nèi)核線程而言,其task_struct的mm成員為NULL(內(nèi)核線程沒有進程地址空間),但是,內(nèi)核線程被調(diào)度執(zhí)行的時候,總是需要一個進程地址空間,而active_mm就是指向它借用的那個進程地址空間。
(3)mm為空的話,說明B進程是內(nèi)核線程,這時候,只能借用A進程當前正在使用的那個地址空間(prev->active_mm)。注意:這里不能借用A進程的地址空間(prev->mm),因為A進程也可能是一個內(nèi)核線程,不擁有自己的地址空間描述符。
(4)如果要切入的B進程是內(nèi)核線程,那么調(diào)用體系結(jié)構(gòu)相關(guān)的代碼enter_lazy_tlb,標識該cpu進入lazy tlb mode。那么什么是lazy tlb mode呢?如果要切入的進程實際上是內(nèi)核線程,那么我們也暫時不需要flush TLB,因為內(nèi)核線程不會訪問usersapce,所以那些無效的TLB entry也不會影響內(nèi)核線程的執(zhí)行。在這種情況下,為了性能,我們會進入lazy tlb mode。進程切換中和TLB相關(guān)的內(nèi)容我們會單獨在一篇文章中描述,這里就不再贅述了。
(5)如果要切入的B進程是內(nèi)核線程,那么由于是借用當前正在使用的地址空間,因此沒有必要調(diào)用switch_mm進行地址空間切換,只有要切入的B進程是一個普通進程的情況下(有自己的地址空間)才會調(diào)用switch_mm,真正執(zhí)行地址空間切換。
如果切入的是普通進程,那么這時候進程的地址空間已經(jīng)切換了,也就是說在A--->B進程的過程中,進程本身尚未切換,而進程的地址空間已經(jīng)切換到了B進程了。這樣會不會造成問題呢?還好,呵呵,這時候代碼執(zhí)行在kernel space,A和B進程的kernel space都是一樣一樣的啊,即便是切了進程地址空間,不過內(nèi)核空間實際上保持不變的。
(6)如果切出的A進程是內(nèi)核線程,那么其借用的那個地址空間(active_mm)已經(jīng)不需要繼續(xù)使用了(內(nèi)核線程A被掛起了,根本不需要地址空間了)。除此之外,我們這里還設(shè)定了run queue上一次使用的mm struct(rq->prev_mm)為oldmm。為何要這么做?先等一等,下面我們會統(tǒng)一描述。
(7)一次進程切換,表面上看起來涉及兩個進程,實際上涉及到了三個進程。switch_to是一個有魔力的符號,和一般的調(diào)用函數(shù)不同,當A進程在CPUa調(diào)用它切換到B進程的時候,switch_to一去不回,直到在某個cpu上(我們稱之CPUx)完成從X進程(就是last進程)到A進程切換的時候,switch_to返回到A進程的現(xiàn)場。這一點我們會在下一節(jié)詳細描述。switch_to完成了具體prev到next進程的切換,當switch_to返回的時候,說明A進程再次被調(diào)度執(zhí)行了。
三、switch_to為什么需要三個參數(shù)呢?
switch_to定義如下:
#define switch_to(prev, next, last) / do { / ((last) = __switch_to((prev), (next))); / } while (0)
一個switch_to將代碼分成兩段:
AAA
switch_to(prev, next, prev);
BBB
一次進程切換,涉及到了三個進程,prev和next是大家都熟悉的參數(shù)了,對于進程A(下圖中的右半圖片),如果它想要切換到B進程,那么: prev=A next=B
這時候,在A進程中調(diào)用 switch_to 完成A到B進程的切換。但是,當經(jīng)歷萬水千山,A進程又被重新調(diào)度的時候,我們又來到了switch_to返回的這一點(下圖中的左半圖片),這時候,我們是從哪一個進程切換到A呢?誰知道呢(在A進程調(diào)用switch_to 的時候是不知道的)?在A進程調(diào)用switch_to之后,cpu就執(zhí)行B進程了,后續(xù)B進程切到哪個進程呢?隨后又經(jīng)歷了怎樣的進程切換過程呢?當然,這一切對于A進程來說它并不關(guān)心,它唯一關(guān)心的是當切換回A進程的時候,該cpu上(也不一定是A調(diào)用switch_to切換到B進程的那個CPU)執(zhí)行的上一個task是誰?這就是第三個參數(shù)的含義(實際上這個參數(shù)的名字就是last,也基本說明了其含義)。也就是說,在AAA點上,prev是A進程,對應(yīng)的run queue是CPUa的run queue,而在BBB點上,A進程恢復執(zhí)行,last是X進程,對應(yīng)的run queue是CPUx的run queue。
四、在內(nèi)核線程切換過程中,內(nèi)存描述符的處理
我們上面已經(jīng)說過:如果切入內(nèi)核線程,那么其實進程地址空間實際上并沒有切換,該內(nèi)核線程只是借用了切出進程使用的那個地址空間(active_mm)。對于內(nèi)核中的實體,我們都會使用引用計數(shù)來根據(jù)一個數(shù)據(jù)對象,從而確保在沒有任何引用的情況下釋放該數(shù)據(jù)對象實體,對于內(nèi)存描述符亦然。因此,在context_switch中有代碼如下:
if (!mm) { next->active_mm = oldmm; atomic_inc(&oldmm->mm_count);-----增加引用計數(shù) enter_lazy_tlb(oldmm, next); }
既然是借用別人的內(nèi)存描述符(地址空間),那么調(diào)用atomic_inc是合理的,反正馬上就切入B進程了,在A進程中提前增加引用計數(shù)也OK的。話說有借有還,那么在內(nèi)核線程被切出的時候,就是歸還內(nèi)存描述符的時候了。
這里還有一個悖論,對于內(nèi)核線程而言,在運行的時候,它會借用其他進程的地址空間,因此,在整個內(nèi)核線程運行過程中,需要使用該地址空間(內(nèi)存描述符),因此對內(nèi)存描述符的增加和減少引用計數(shù)的操作只能在在內(nèi)核線程之外完成。假如一次切換是這樣的:…A--->B(內(nèi)核線程)--->C…,增加引用計數(shù)比較簡單,上面已經(jīng)說了,在A進程調(diào)用context_switch的時候完成。現(xiàn)在問題來了,如何在C中完成減少引用計數(shù)的操作呢?我們還是從代碼中尋找答案,如下(context_switch函數(shù)中,去掉了不相關(guān)的代碼):
if (!prev->mm) { prev->active_mm = NULL; rq->prev_mm = oldmm;---在rq->prev_mm上保存了上一次使用的mm struct }
借助其他進程內(nèi)存描述符的東風,內(nèi)核線程B歡快的運行,然而,快樂總是短暫的,也許是B自愿的,也許是強迫的,調(diào)度器最終會剝奪B的執(zhí)行,切入C進程。也就是說,B內(nèi)核線程調(diào)用switch_to(執(zhí)行了AAA段代碼),自己掛起,C粉墨登場,執(zhí)行BBB段的代碼。具體的代碼在finish_task_switch,如下:
static struct rq *finish_task_switch(struct task_struct *prev) { struct rq *rq = this_rq(); struct mm_struct *mm = rq->prev_mm;――――――――――――――――(1)
rq->prev_mm = NULL;
if (mm) mmdrop(mm);――――――――――――――――――――――――(2) }
(1)我們假設(shè)B是內(nèi)核線程,在進程A調(diào)用context_switch切換到B線程的時候,借用的地址空間被保存在CPU對應(yīng)的run queue中。在B切換到C之后,通過rq->prev_mm就可以得到借用的內(nèi)存描述符。
(2)已經(jīng)完成B到C的切換后,借用的地址空間可以返還了。因此在C進程中調(diào)用mmdrop來完成這一動作。很神奇,在A進程中為內(nèi)核線程B借用地址空間,但卻在C進程中釋放它。
五、ARM64的進程地址空間切換
對于ARM64這個cpu arch,每一個cpu core都有兩個寄存器來指示當前運行在該CPU core上的進程(線程)實體的地址空間。這兩個寄存器分別是ttbr0_el1(用戶地址空間)和ttbr1_el1(內(nèi)核地址空間)。由于所有的進程共享內(nèi)核地址空間,因此所謂地址空間切換也就是切換ttbr0_el1而已。地址空間聽起來很抽象,實際上就是內(nèi)存中的若干Translation table而已,每一個進程都有自己獨立的一組用于翻譯用戶空間虛擬地址的Translation table,這些信息保存在內(nèi)存描述符中,具體位于struct mm_struct中的pgd成員中。以pgd為起點,可以遍歷該內(nèi)存描述符的所有用戶地址空間的Translation table。具體代碼如下:
static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk)----------------(1) { unsigned int cpu = smp_processor_id();
if (prev == next)--------------------(2) return;
if (next == &init_mm) {-----------------(3) cpu_set_reserved_ttbr0(); return; }
check_and_switch_context(next, cpu); }
(1)prev是要切出的地址空間,next是要切入的地址空間,tsk是將要切入的進程。
(2)要切出的地址空間和要切入的地址空間是一個地址空間的話,那么切換地址空間也就沒有什么意義了。
(3)在ARM64中,地址空間的切換主要是切換ttbr0_el1,對于swapper進程的地址空間,其用戶空間沒有任何的mapping,而如果要切入的地址空間就是swapper進程的地址空間的時候,將(設(shè)定ttbr0_el1指向empty_zero_page)。
(4)check_and_switch_context中有很多TLB、ASID相關(guān)的操作,我們將會在另外的文檔中給出細致描述,這里就簡單略過,實際上,最終該函數(shù)會調(diào)用arch/arm64/mm/proc.S文件中的cpu_do_switch_mm將要切入進程的L0 Translation table物理地址(保存在內(nèi)存描述符的pgd成員)寫入ttbr0_el1。
六、ARM64的的進程切換
由于存在MMU,內(nèi)存中可以有多個task,并且由調(diào)度器依次調(diào)度到cpu core上實際執(zhí)行。系統(tǒng)有多少個cpu core就可以有多少個進程(線程)同時執(zhí)行。即便是對于一個特定的cpu core,調(diào)度器可以可以不斷的將控制權(quán)從一個task切換到另外一個task上。實際的context switch的動作也不復雜:就是將當前的上下文保存在內(nèi)存中,然后從內(nèi)存中恢復另外一個task的上下文。對于ARM64而言,context包括:
(1)通用寄存器
(2)浮點寄存器
(3)地址空間寄存器(ttbr0_el1和ttbr1_el1),上一節(jié)已經(jīng)描述
(4)其他寄存器(ASID、thread process ID register等)
__switch_to代碼(位于arch/arm64/kernel/process.c)如下:
struct task_struct *__switch_to(struct task_struct *prev, struct task_struct *next) { struct task_struct *last;
fpsimd_thread_switch(next);--------------(1) tls_thread_switch(next);----------------(2) hw_breakpoint_thread_switch(next);--和硬件跟蹤相關(guān) contextidr_thread_switch(next); --和硬件跟蹤相關(guān)
dsb(ish); last = cpu_switch_to(prev, next); ------------(3)
return last; }
(1)fp是float-point的意思,和浮點運算相關(guān)。simd是Single Instruction Multiple Data的意思,和多媒體以及信號處理相關(guān)。fpsimd_thread_switch其實就是把當前FPSIMD的狀態(tài)保存到了內(nèi)存中(task.thread.fpsimd_state),從要切入的next進程描述符中獲取FPSIMD狀態(tài),并加載到CPU上。
(2)概念同上,不過是處理tls(thread local storage)的切換。這里硬件寄存器涉及tpidr_el0和tpidrro_el0,涉及的內(nèi)存是task.thread.tp_value。具體的應(yīng)用場景是和線程庫相關(guān),具體大家可以自行學習了。
(3)具體的切換發(fā)生在arch/arm64/kernel/entry.S文件中的cpu_switch_to,代碼如下:
ENTRY(cpu_switch_to) -------------------(1) mov x10, #THREAD_CPU_CONTEXT ----------(2) add x8, x0, x10 --------------------(3) mov x9, sp stp x19, x20, [x8], #16----------------(4) stp x21, x22, [x8], #16 stp x23, x24, [x8], #16 stp x25, x26, [x8], #16 stp x27, x28, [x8], #16 stp x29, x9, [x8], #16 str lr, [x8] ---------A add x8, x1, x10 -------------------(5) ldp x19, x20, [x8], #16----------------(6) ldp x21, x22, [x8], #16 ldp x23, x24, [x8], #16 ldp x25, x26, [x8], #16 ldp x27, x28, [x8], #16 ldp x29, x9, [x8], #16 ldr lr, [x8] -------B mov sp, x9 -------C ret -------------------------(7) ENDPROC(cpu_switch_to)
(1)進入cpu_switch_to函數(shù)之前,x0,x1用做參數(shù)傳遞,x0是prev task,就是那個要掛起的task,x1是next task,就是馬上要切入的task。cpu_switch_to和其他的普通函數(shù)沒有什么不同,盡管會走遍萬水千山,但是最終還是會返回調(diào)用者函數(shù)__switch_to。
在進入細節(jié)之前,先想一想這個問題:cpu_switch_to要如何保存現(xiàn)場?要保存那些通用寄存器呢?其實上一小段描述已經(jīng)做了鋪陳:盡管有點怪異,本質(zhì)上cpu_switch_to仍然是一個普通函數(shù),需要符合ARM64標準過程調(diào)用文檔。在該文檔中規(guī)定,x19~x28是屬于callee-saved registers,也就是說,在__switch_to函數(shù)調(diào)用cpu_switch_to函數(shù)這個過程中,cpu_switch_to函數(shù)要保證x19~x28這些寄存器值是和調(diào)用cpu_switch_to函數(shù)之前一模一樣的。除此之外,pc、sp、fp當然也是必須是屬于現(xiàn)場的一部分的。
(2)得到THREAD_CPU_CONTEXT的偏移,保存在x10中
(3)x0是pre task的進程描述符,加上偏移之后就獲取了訪問cpu context內(nèi)存的指針(x8寄存器)。所有context的切換的原理都是一樣的,就是把當前cpu寄存器保存在內(nèi)存中,這里的內(nèi)存是在進程描述符中的 thread.cpu_context中。
(4)一旦定位到保存cpu context(各種通用寄存器)的內(nèi)存,那么使用stp保存硬件現(xiàn)場。這里x29就是fp(frame pointer),x9保存了stack pointer,lr是返回的PC值。到A代碼處,完成了pre task cpu context的保存動作。
(5)和步驟(3)類似,只不過是針對next task而言的。這時候x8指向了next task的cpu context。
(6)和步驟(4)類似,不同的是這里的操作是恢復next task的cpu context。執(zhí)行到代碼B處,所有的寄存器都已經(jīng)恢復,除了PC和SP,其中PC保存在了lr(x30)中,而sp保存在了x9中。在代碼C出恢復了sp值,這時候萬事俱備,只等PC操作了。
(7)ret指令其實就是把x30(lr)寄存器的值加載到PC,至此現(xiàn)場完全恢復到調(diào)用cpu_switch_to那一點上了。
參考文獻:
1、ARM標準過程調(diào)用文檔(IHI0056C_beta_aaelf64.pdf)
2、linux 4.4.6內(nèi)核源代碼
新聞熱點
疑難解答