1. 程式人生 > >Linux進程切換(1) 基本框架

Linux進程切換(1) 基本框架

兩個 nor pid text _id 現在 然而 器) 相關

一、前言

本文主要是以context_switch為起點,分析了整個進程切換過程中的基本操作和基本的代碼框架,很多細節,例如tlb的操作,cache的操作,鎖的操作等等會在其他專門的文檔中描述。進程切換包括體系結構相關的代碼和系統結構無關的代碼。第二、三、四分別描述了context_switch的代碼脈絡,後面的章節是以ARM64為例子,講述了具體進程地址空間的切換過程和硬件上下文的切換過程。

二、context_switch代碼分析

在kernel/sched/core.c中有一個context_switch函數,該函數用來完成具體的進程切換,代碼如下(本文主要描述進程切換的基本邏輯,因此部分代碼會有刪節):

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)一旦調度器算法確定了pre task和next task,那麽就可以調用context_switch函數實際執行進行切換的工作了,這裏我們先看看參數傳遞情況:

rq:在多核系統中,進程切換總是發生在各個cpu core上,參數rq指向本次切換發生的那個cpu對應的run queue
prev:將要被剝奪執行權利的那個進程
next:被選擇在該cpu上執行的那個進程

(2)next是馬上就要被切入的進程(後面簡稱B進程),prev是馬上就要被剝奪執行權利的進程(後面簡稱A進程)。mm變量指向B進程的地址空間描述符,oldmm變量指向A進程的當前正在使用的地址空間描述符(active_mm)。對於normal進程,其任務描述符(task_struct)的mm和active_mm相同,都是指向其進程地址空間。對於內核線程而言,其task_struct的mm成員為NULL(內核線程沒有進程地址空間),但是,內核線程被調度執行的時候,總是需要一個進程地址空間,而active_mm就是指向它借用的那個進程地址空間。

(3)mm為空的話,說明B進程是內核線程,這時候,只能借用A進程當前正在使用的那個地址空間(prev->active_mm)。註意:這裏不能借用A進程的地址空間(prev->mm),因為A進程也可能是一個內核線程,不擁有自己的地址空間描述符。

(4)如果要切入的B進程是內核線程,那麽調用體系結構相關的代碼enter_lazy_tlb,標識該cpu進入lazy tlb mode。那麽什麽是lazy tlb mode呢?如果要切入的進程實際上是內核線程,那麽我們也暫時不需要flush TLB,因為內核線程不會訪問usersapce,所以那些無效的TLB entry也不會影響內核線程的執行。在這種情況下,為了性能,我們會進入lazy tlb mode。進程切換中和TLB相關的內容我們會單獨在一篇文章中描述,這裏就不再贅述了。

(5)如果要切入的B進程是內核線程,那麽由於是借用當前正在使用的地址空間,因此沒有必要調用switch_mm進行地址空間切換,只有要切入的B進程是一個普通進程的情況下(有自己的地址空間)才會調用switch_mm,真正執行地址空間切換。

如果切入的是普通進程,那麽這時候進程的地址空間已經切換了,也就是說在A--->B進程的過程中,進程本身尚未切換,而進程的地址空間已經切換到了B進程了。這樣會不會造成問題呢?還好,呵呵,這時候代碼執行在kernel space,A和B進程的kernel space都是一樣一樣的啊,即便是切了進程地址空間,不過內核空間實際上保持不變的。

(6)如果切出的A進程是內核線程,那麽其借用的那個地址空間(active_mm)已經不需要繼續使用了(內核線程A被掛起了,根本不需要地址空間了)。除此之外,我們這裏還設定了run queue上一次使用的mm struct(rq->prev_mm)為oldmm。為何要這麽做?先等一等,下面我們會統一描述。

(7)一次進程切換,表面上看起來涉及兩個進程,實際上涉及到了三個進程。switch_to是一個有魔力的符號,和一般的調用函數不同,當A進程在CPUa調用它切換到B進程的時候,switch_to一去不回,直到在某個cpu上(我們稱之CPUx)完成從X進程(就是last進程)到A進程切換的時候,switch_to返回到A進程的現場。這一點我們會在下一節詳細描述。switch_to完成了具體prev到next進程的切換,當switch_to返回的時候,說明A進程再次被調度執行了。

三、switch_to為什麽需要三個參數呢?

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是大家都熟悉的參數了,對於進程A(下圖中的右半圖片),如果它想要切換到B進程,那麽:
prev=A
next=B
技術分享圖片

這時候,在A進程中調用 switch_to 完成A到B進程的切換。但是,當經歷萬水千山,A進程又被重新調度的時候,我們又來到了switch_to返回的這一點(下圖中的左半圖片),這時候,我們是從哪一個進程切換到A呢?誰知道呢(在A進程調用switch_to 的時候是不知道的)?在A進程調用switch_to之後,cpu就執行B進程了,後續B進程切到哪個進程呢?隨後又經歷了怎樣的進程切換過程呢?當然,這一切對於A進程來說它並不關心,它唯一關心的是當切換回A進程的時候,該cpu上(也不一定是A調用switch_to切換到B進程的那個CPU)執行的上一個task是誰?這就是第三個參數的含義(實際上這個參數的名字就是last,也基本說明了其含義)。也就是說,在AAA點上,prev是A進程,對應的run queue是CPUa的run queue,而在BBB點上,A進程恢復執行,last是X進程,對應的run queue是CPUx的run queue。

四、在內核線程切換過程中,內存描述符的處理

我們上面已經說過:如果切入內核線程,那麽其實進程地址空間實際上並沒有切換,該內核線程只是借用了切出進程使用的那個地址空間(active_mm)。對於內核中的實體,我們都會使用引用計數來根據一個數據對象,從而確保在沒有任何引用的情況下釋放該數據對象實體,對於內存描述符亦然。因此,在context_switch中有代碼如下:

if (!mm) {
next->active_mm = oldmm;
atomic_inc(&oldmm->mm_count);-----增加引用計數
enter_lazy_tlb(oldmm, next);
}

既然是借用別人的內存描述符(地址空間),那麽調用atomic_inc是合理的,反正馬上就切入B進程了,在A進程中提前增加引用計數也OK的。話說有借有還,那麽在內核線程被切出的時候,就是歸還內存描述符的時候了。

這裏還有一個悖論,對於內核線程而言,在運行的時候,它會借用其他進程的地址空間,因此,在整個內核線程運行過程中,需要使用該地址空間(內存描述符),因此對內存描述符的增加和減少引用計數的操作只能在在內核線程之外完成。假如一次切換是這樣的:…A--->B(內核線程)--->C…,增加引用計數比較簡單,上面已經說了,在A進程調用context_switch的時候完成。現在問題來了,如何在C中完成減少引用計數的操作呢?我們還是從代碼中尋找答案,如下(context_switch函數中,去掉了不相關的代碼):

if (!prev->mm) {
prev->active_mm = NULL;
rq->prev_mm = oldmm;---在rq->prev_mm上保存了上一次使用的mm struct
}

借助其他進程內存描述符的東風,內核線程B歡快的運行,然而,快樂總是短暫的,也許是B自願的,也許是強迫的,調度器最終會剝奪B的執行,切入C進程。也就是說,B內核線程調用switch_to(執行了AAA段代碼),自己掛起,C粉墨登場,執行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)我們假設B是內核線程,在進程A調用context_switch切換到B線程的時候,借用的地址空間被保存在CPU對應的run queue中。在B切換到C之後,通過rq->prev_mm就可以得到借用的內存描述符。

(2)已經完成B到C的切換後,借用的地址空間可以返還了。因此在C進程中調用mmdrop來完成這一動作。很神奇,在A進程中為內核線程B借用地址空間,但卻在C進程中釋放它。

五、ARM64的進程地址空間切換

對於ARM64這個cpu arch,每一個cpu core都有兩個寄存器來指示當前運行在該CPU core上的進程(線程)實體的地址空間。這兩個寄存器分別是ttbr0_el1(用戶地址空間)和ttbr1_el1(內核地址空間)。由於所有的進程共享內核地址空間,因此所謂地址空間切換也就是切換ttbr0_el1而已。地址空間聽起來很抽象,實際上就是內存中的若幹Translation table而已,每一個進程都有自己獨立的一組用於翻譯用戶空間虛擬地址的Translation table,這些信息保存在內存描述符中,具體位於struct mm_struct中的pgd成員中。以pgd為起點,可以遍歷該內存描述符的所有用戶地址空間的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進程的地址空間的時候,將(設定ttbr0_el1指向empty_zero_page)。

(4)check_and_switch_context中有很多TLB、ASID相關的操作,我們將會在另外的文檔中給出細致描述,這裏就簡單略過,實際上,最終該函數會調用arch/arm64/mm/proc.S文件中的cpu_do_switch_mm將要切入進程的L0 Translation table物理地址(保存在內存描述符的pgd成員)寫入ttbr0_el1。

六、ARM64的的進程切換

由於存在MMU,內存中可以有多個task,並且由調度器依次調度到cpu core上實際執行。系統有多少個cpu core就可以有多少個進程(線程)同時執行。即便是對於一個特定的cpu core,調度器可以可以不斷的將控制權從一個task切換到另外一個task上。實際的context switch的動作也不復雜:就是將當前的上下文保存在內存中,然後從內存中恢復另外一個task的上下文。對於ARM64而言,context包括:

(1)通用寄存器

(2)浮點寄存器

(3)地址空間寄存器(ttbr0_el1和ttbr1_el1),上一節已經描述

(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);--和硬件跟蹤相關
contextidr_thread_switch(next); --和硬件跟蹤相關

dsb(ish);
last = cpu_switch_to(prev, next); ------------(3)

return last;
}

(1)fp是float-point的意思,和浮點運算相關。simd是Single Instruction Multiple Data的意思,和多媒體以及信號處理相關。fpsimd_thread_switch其實就是把當前FPSIMD的狀態保存到了內存中(task.thread.fpsimd_state),從要切入的next進程描述符中獲取FPSIMD狀態,並加載到CPU上。

(2)概念同上,不過是處理tls(thread local storage)的切換。這裏硬件寄存器涉及tpidr_el0和tpidrro_el0,涉及的內存是task.thread.tp_value。具體的應用場景是和線程庫相關,具體大家可以自行學習了。

(3)具體的切換發生在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函數之前,x0,x1用做參數傳遞,x0是prev task,就是那個要掛起的task,x1是next task,就是馬上要切入的task。cpu_switch_to和其他的普通函數沒有什麽不同,盡管會走遍萬水千山,但是最終還是會返回調用者函數__switch_to。

在進入細節之前,先想一想這個問題:cpu_switch_to要如何保存現場?要保存那些通用寄存器呢?其實上一小段描述已經做了鋪陳:盡管有點怪異,本質上cpu_switch_to仍然是一個普通函數,需要符合ARM64標準過程調用文檔。在該文檔中規定,x19~x28是屬於callee-saved registers,也就是說,在__switch_to函數調用cpu_switch_to函數這個過程中,cpu_switch_to函數要保證x19~x28這些寄存器值是和調用cpu_switch_to函數之前一模一樣的。除此之外,pc、sp、fp當然也是必須是屬於現場的一部分的。

(2)得到THREAD_CPU_CONTEXT的偏移,保存在x10中

(3)x0是pre task的進程描述符,加上偏移之後就獲取了訪問cpu context內存的指針(x8寄存器)。所有context的切換的原理都是一樣的,就是把當前cpu寄存器保存在內存中,這裏的內存是在進程描述符中的 thread.cpu_context中。

(4)一旦定位到保存cpu context(各種通用寄存器)的內存,那麽使用stp保存硬件現場。這裏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。執行到代碼B處,所有的寄存器都已經恢復,除了PC和SP,其中PC保存在了lr(x30)中,而sp保存在了x9中。在代碼C出恢復了sp值,這時候萬事俱備,只等PC操作了。

(7)ret指令其實就是把x30(lr)寄存器的值加載到PC,至此現場完全恢復到調用cpu_switch_to那一點上了。

參考文獻:

1、ARM標準過程調用文檔(IHI0056C_beta_aaelf64.pdf)

2、Linux 4.4.6內核源代碼

Linux進程切換(1) 基本框架