OS進程/線程切換
1.基本概念
1.1 進程
進程:運行中的程序,同一個程序可以運行出多個進程,其不同之處表現在PCB中
PCB:用來記錄進程信息的數據結構,類似於當前CPU的快照加上一些進程本身的數據
CPU切換進程from->to:需要將當前運行着的進程from的PCB保存下來,然后將to的PCB更新到CPU中
進程=資源+指令執行序列:進程切換時必須同時切換指令執行序列和內存資源映射表
資源:內存資源,即每個進程都有屬於自己的內存空間(在內存管理部分還會提到)
指令執行序列:即進程的指令集
1.2 並發與並行
並發:指的是多個程序(多進程,多指令)可以同時運行的現象,實際上並不是真正的同時運行,單核CPU切換上下文進程就是經典的並發
並行:多核CPU同時運行多個進程,屬於真正的同時運行
1.3 線程
線程:同一進程的多個線程共享內存資源,所以線程切換時不用切換內存資源映射表(與進程的不同之處),但是仍需切換指令序列。值得注意的是,雖然一個進程內的線程共享內存資源,但是每個線程仍然有屬於自己的線程棧,共享的是內存映射表。
用戶態線程的切換:每個線程都有一個存在在用戶態的TCB(功能類似與進程的PCB)來保存棧幀等數據
當調用Yield()時(此處可以發現,用戶態線程的切換是線程主動的),線程從from切換到to,CPU會將from的運行數據存在from的TCB中,然后將to的TCB更新到CPU中,完成切換
用戶態線程的切換中,OS是感覺不到這種線程切換的(對OS來說就是同一個進程里原來的指令序列發生了一些改變),所以一旦進程中一個線程被阻塞,OS就會發現這整個進程被阻塞了,導致切換整個進程(原進程就卡了)
例如:有的瀏覽器加載頁面時卡了,是由於網卡較慢導致網卡線程阻塞,但是OS把整個瀏覽器進程給阻塞了
內核級線程的切換:每個線程都有對應的兩個棧:用戶棧+內核棧,TCB由內核管理,內核負責切換線程
2.用戶級線程
2.1 用戶級線程切換
我們先來考慮如何切換,再推出怎么創建以及初始化
由上述概念可知,每個進程執行時會有一套自己的內存映射表,即我們所謂的資源,當執行多進程時切換要切換這套內存映射表,即所謂的資源切換
但是如果在這個進程中創建線程,共用一套資源,那么進行線程切換時,只要切換pc指針和棧指針esp即可,這樣便省去了許多資源切換的操作
即資源不變但切換指令序列
例如,一個網頁瀏覽器,需要有多個線程:一個線程用來從服務器接收數據 一個線程用來處理圖片(如解壓縮) 一個線程用來顯示文本 一個線程用來顯示圖片
但是這些線程完全可以共用一套資源,即:接收數據放在某地址處,顯示時要讀, 所有的文本、圖片都顯示在一個屏幕上
那么這個瀏覽器的實現可能是這樣的
void WebExplorer(){
char URL[]="http://www.baidu.com"
char buffer[1000];
// 開啟一個線程用來接收數據
pthread_create(...,GetData,URL,buffer);
// 開啟一個線程用來顯示數據
pthread_create(...,Show,buffer);
}
// 這里就是對應的兩個不同線程代碼
// 用來獲取數據的線程代碼
void GetData(char *URL, char, *p){
...
Yield();// 運行到一半交出CPU運行權
...
};
void Show(char *p){
...
Yield();// 運行到一半交出CPU運行權
...
}
// Yield函數用來進行線程之間的切換
void Yield(){}
下面我們舉例說明一下Yield的實現
首先定義兩段線程程序,程序段中 如100:
表示該代碼的地址
// 線程1
100:A(){
B(); // 進入函數B,將返回地址壓棧,此時棧幀為 104(esp)|
104:
}
200:B(){
Yield1(); // 進入函數Yield1,將返回地址壓棧,此時棧幀為104|204(esp)|
204:
}
// 線程2
300:C(){
D(); // 進入函數D,將返回地址壓棧,此時棧幀為 304|
304:
}
400:D(){
Yield2(); // 進入函數Yield2,將返回地址壓棧,此時棧幀為304|404|
404:
}
那么我們要如何讓Yield實現切換線程的功能呢
void Yield1(){
TCB1.esp=esp;
esp=TCB2.esp
}
以第一個調度函數為例,當我們進入該函數時,線程1的棧幀為 104|204(esp)|
,esp
寄存器指向的就是204地址,
含義是從Yield1返回后線程1要執行的指令地址為204,因為要切換到線程2,esp
寄存器肯定要跟着變過去,
所以我們將線程1的esp
保存在TCB1中,然后將TCP2.esp
交給esp,
這樣指令就切換過去了
當我們需要從線程2返回時,也類似的寫一個Yield2函數,將線程2的esp
存下來,將線程2的TCB1.esp
交給esp
寄存器,這樣CPU又切換回去了,從線程1繼續執行下去
2.2 用戶級線程的創建及初始化
由於了解了用戶級線程的切換過程,那么可以推測出用戶級線程的創建過程
void ThreadCreate(A){
TCB *tcp = malloc();
*stack = malloc();// 1.在用戶空間申請資源:申請棧空間,tcb塊
*stack = A;
tcb.esp = stack; // 2.初始化線程對應的tcb:關聯tcb和棧
}
3.內核級線程/進程
每個內核級線程具有兩個棧,一個是用戶棧,一個是內核棧,在切換線程前,用戶程序的信息用用戶棧維護,當切換線程時,
就切到了內核棧里,將該線程的信息存在TCB中,然后由CPU切換到另一個線程中。
上圖是用戶棧和內核棧綁定的圖,當用戶棧切到內核棧時(比如INT指令觸發中斷),就會進入到內核棧里,
內核棧的SS:SP
存的是用戶態的 esp
,再往下,內核棧就存了用戶棧在切入時的CS:PC
寄存器,當觸發IRET從內核棧返回用戶棧時,就會將這些數據恢復到各寄存器上
從用戶態的角度看,當它觸發一個中斷時,就會被暫時掛起,CPU去處理一些工作后,又返回來接着往下執行,用戶態程序是感覺不到CPU干了啥的(它已經被掛起來了)
3.1 內核中的切換
我們現在已經知道了內核級線程切換時,用戶態的狀態已經用戶棧和內核棧的綁定,現在可以重點關注內核中是如何實現進程/線程切換的
PS:由於進程的切換也是在內核態進行的,所以對於指令角度而言,它和內核級線程的切換是類似的,
只是進程的切換還涉及到內存資源的映射,所以先不考慮線程的切換過程,我們的重點是切換的各個階段過程
內核的切換可以分為五個步驟,下面以系統調用fork
為例,我們先寫一段代碼
100:main(){
A();
200:
}
300:A(){
fork();// 此時的用戶棧為 200|400(esp)|
400:
}
顯然,當我們運行到fork()時,用戶棧為 200|400|,esp
指針指向的是400,接下去fork會觸發系統調用
3.1.1 中斷入口
從用戶態->內核態(觸發中斷),這個步驟傳給內核用戶態的棧信息
fork展開后的指令執行大致如下
mov %eax,__NR_fork
INT 0x80
mov res,%eax
如果明白系統調用原理的話,這代碼是很容易理解的:首先將系統調號賦值給eax
,然后觸發中斷,中斷返回后將返回結果交給res
(注意這個返回結果,我們之后還會進行分析)
值得關注的是此時內核棧的信息為何?
SS:SP // 這里就是用戶棧的esp指針位置
EFLAGS
ret=?? // 這里就是中斷結束返回后用戶棧下一條指令的位置即CS:PC,對應到該例,下一條指令就是 mov res,%eax
system_call // 這里已經進入了系統調用
3.1.2 中斷處理(引發切換)
內核態線程被阻塞(時鍾中斷或讀數據):進入調度算法,引發切換
我們來看中斷處理程序_system_call
_system_call:
# 將cpu寄存器狀態壓棧保存
push %ds
...
push %fs
pushl %edx
...
# 調用系統函數,其本質是copy_process,建立一個新的進程,同時把返回值存在eax中
call sys_fork
# 函數返回后,eax壓棧,這個eax要在返回時交給res,父進程的eax(子進程pid)在父進程棧里,而子進程的eax(0)在子進程棧里。后面要修改eax的值了
pushl %eax
...
# 將當前進程交給eax
movl _current, %eax
# 判斷當前進程是否阻塞,即state!=0
cmlp $0, state(%eax)
# 如果state!=0,執行reschedule
jne reschedule
# 判斷當前進程的時間片是否為0(counter為時間片)
cmpl $0,counter(%eax)
# counter==0,執行reschedule
je reschedule
# 從系統調用中返回
ret_from_sys_call
這一個函數其實就是調用了sys_fork
,同樣是在system_call.s
里,我們查看其代碼
.align 2
sys_fork:
call find_empty_process
testl %eax,%eax
js 1f
# 這里將父進程的寄存器壓棧,因為之后會改動
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call copy_process # 跳轉到copy_process()函數
addl $20,%esp
1: ret
可以看到fork()函數的核心就是調用了copy_process(),這是個子進程的創建函數,簡而言之,子進程會完全復制父進程的所有信息,詳細情況在下面子進程的創建中。
3.1.3 CPU調度
調度函數schedule
找到next
,引發switch_to
在第二步中,我們可以觀察到,當前進程被調度時,會調用一個reschedule
過程
reschedule:
# 將第五步那個返回函數壓棧,表示CPU再次調度回這個程序時,就執行ret_from_sys_call
pushl $ret_from_sys_call
# 去執行調度程序
jmp _schedule
下面我們來看調度函數schedule
的其中一段
void schedule(void){
...
next=i; // 找到下一個要調度的進程
...
switch_to(next);// 切換到下一個進程
}
該調度函數之后還會詳細分析,此處只要了解這兩句關鍵即可
3.1.4 內核棧切換
switch_to
函數內切換內核棧
此時我們已經通過第三步的調度函數schedule
進入switch_to
中,內核棧切換的重點就在這個函數里
我們看詳細信息
#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,current\n\t" \
"je 1f\n\t" \
"movw %%dx,%1\n\t" \
"xchgl %%ecx,current\n\t" \
"ljmp *%0\n\t" \
"cmpl %%ecx,last_task_used_math\n\t" \
"jne 1f\n\t" \
"clts\n" \
"1:" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \
}
上面是linux0.11
原版的switch_to函數,就是一段宏定義,使用長跳轉ljmp
指令將整個TSS(CPU快照)賦值給CPU,
從而達到替換全部的寄存器(當然也包括那些cs,esp
寄存器,然而這不屬於內核棧切換,於是我們重寫這個函數
我們將其作為一個系統調用來寫,系統調用都寫在system_call.s
里,看匯編有困難的看末尾的尋址方式
/** switch_to()
* 由於要對內核棧做精細的操作,所以要用匯編代碼來寫切換函數。
*/
.align 2
switch_to:
# 因為該匯編函數要在c語言中調用,所以要先在匯編中處理棧幀
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebc
pushl %eax
# 先得到目標進程的pcb,然后進行判斷
# 如果目標進程的pcb(存放在ebp寄存器中) 等於當前進程的pcb => 不需要進行切換,直接退出函數調用
# 如果目標進程的pcb(存放在ebp寄存器中) 不等於當前進程的pcb => 需要進行切換,直接跳到下面去執行
movl 8(%ebp),%ebx
cmpl %ebx,current
je 1f
/** 執行到此處,就要進行真正的基於堆棧的進程切換了 */
# PCB的切換。起始時ebx保存了指向目標進程的指針,current指向了當前進程,第一條指令執行完畢,
使得eax也指向目標進程,然后第二條指令,也就是將eax的值和current的值進行了交換,最終使得eax指向了當前進程,current就指向了目標進程(當前狀態就發生了轉移)
movl %ebx,%eax
xchgl %eax,current
# TSS中內核棧指針的重寫中斷處理時需要尋找當前進程的內核棧,否則就不能從用戶棧切到內核棧(中斷處理沒法完成),內核棧的尋找是借助當前進程TSS中存放的信息來完成的,(當然,當前進程的TSS還是通過TR寄存器在GDT全局描述符表中找到的)。
# 雖然此時不使用TSS進行進程切換了,但是Intel的中斷處理機制還是要保持。所以每個進程仍然需要一個TSS,操作系統需要有一個當前TSS。這里采用的方案是讓所有進程共用一個TSS(這里使用0號進程的TSS),因此需要定義一個全局指針變量tss(放在system_call.s中)來執行0號進程的TSS:struct tss_struct * tss = &(init_task.task.tss)
movl tss,%ecx
# 這句啥意思?這里讓ebx寄存器指向下一個進程的PCB,加上4096后,即為一個進程分配一頁4KB(4*1024)的空間,棧頂即為內核棧的指針,棧底即為進程的PCB起始地址
addl $4096,%ebx
# 將修改后的ebx更新到tss中去,ecx存了tss的開始地址,加上偏移量就是要找的位置
# 此時唯一的tss的目的就是:在中斷處理時,能夠找到當前進程的內核棧的位置。在內核棧指針重寫指令中有宏定義ESP0,所以在上面需要提前定義好 ESP0 = 4,(定義為4是因為TSS中內核棧指針ESP0就放在偏移為4的地方)並且需要將: blocked=(33*16) => blocked=(33*16+4)
movl %ebx,ESP0(%ecx)
# 切換內核棧
# 將esp的值,保存到當前進程pcb的eax寄存器中(保存當前進程執行信息)
movl %esp,KERNEL_STACK(%eax)
# 獲取目標進程的pcb放入ebx寄存器中
movl 8(%ebp),%ebx
# 將ebx寄存器中的信息,也就是目標進程的信息,esp中
movl KERNEL_STACK(%ebx),%esp
# LDT的切換
# 前兩條語句的作用(切換LDT):
# 取出參數LDT(next)
# 完成對LDTR寄存器的修改
movl 12(%ebp),%ecx
lldt %cx
# 然后就是對PC指針(即CS:IP)的切換:后兩條語句的含有就是重寫設置段寄存器FS的值為0x17,這其實在系統調用那里也涉及到了:FS的作用:通過FS操作系統才能訪問進程的用戶態內存,這里LDT切換完成意味着切換到了新的用戶態地址空間,所以也需要重置FS
movl $0x17,%ecx
mov %cx,%fs
movl $0x17,%ecx
mov %cx,%fs
cmpl %eax,last_task_used_math
jne 1f
clts
# 在到子進程的內核棧開始工作了,接下來做的四次彈棧以及ret處理使用的都是子進程內核棧中的東西
1:
popl %eax # 注意,第一個值是給eax的哦!
popl %ebx
popl %ecx
popl %ebp
ret
3.1.5 中斷出口
從切換過來的內核棧IRET至用戶態
我們看第三步reschedule中的那個ret_from_sys_call
ret_from_sys_call:
# 這些是第一次ret,就是把壓棧的寄存器信息恢復,如果是父進程的就壓回父進程去,如果是子進程的就壓回子進程去
popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
# 這是第二次返回 返回到int 0x80后的mov res,%eax去執行
iret
至於為什么子進程的res=0,當然是因為eax=0
,那為什么eax=0
?因為子進程在初始化PCB時,即在函數copy_process
中把最后個地址的值賦值為0,這個值在彈出是恰好給了eax
為什么父進程的res!=0?因為父進程在switch_to時,eax
得到了子進程的pid
,一直沒有修改,所以返回的時候自然也返回了這個值,因此父進程里res的值是子進程的pid
(這里尚有疑惑,如果有錯誤歡迎大家指出)
3.2 子進程的創建和初始化
接下來看copy_process(),原來的linux0.11
是根據TSS切換的,現在我們將其改為用內核棧切換。顯然子進程完全拷貝父進程的狀態
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
// 這里的參數已經在棧上(之前壓棧了好多寄存器)
{
struct task_struct *p;
int i;
struct file *f;
p = (struct task_struct *) get_free_page();//用來完成申請一頁內存空間作為子進程的PCB
...
/** 很容易看出來下面的部分就是基於tss進程切換機制時的代碼,所以將此片段要注釋掉
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
...
*/
/** 然后這里要加上基於堆棧切換的代碼(對frok的修改其實就是對子進程內核棧的初始化 */
long * krnstack = (long *)(PAGE_SIZE+(long)p);//p指針加上頁面大小就是子進程的內核棧位置,所以這句話就是krnstack指針指向子進程的內核棧
//初始化內核棧(krnstack)中的內容:
//下面的五句話可以完成對書上那個圖(4.22)所示的關聯效果(父子進程共有同一內存、堆棧和數據代碼塊)
/*
而且很容易可以看到,ss,esp,elags,cs,eip這些參數來自調用該函數的進程的內核棧中,
也就是父進程的內核棧,所以下面的指令就是將父進程內核棧的前五個內容拷貝到了子進程的內核棧中
*/
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;
*(--krnstack) = (long) first_return_kernel;//處理switch_to返回的位置
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;
//把switch_to中要的東西存進去
p->kernelstack = krnstack;
...
我們將子進程在棧中的信息畫出來就是
ss
sp # 用戶棧中的棧幀地址
EFLAGS
cs
ip # 用戶棧中下一條指令地址
# 以上為從內核態切換到用戶態要彈出的寄存器內容,即iret彈出的內容
ds
es
fs
gs
ebi
edi
edx # 一些通用寄存器壓棧
first_return_from_kernel函數地址 # 第一次返回的地址壓棧
ebp
ecx
ebx
0 # 彈出給eax的,用於fork的返回值
3.3 內核棧切換的總流程
至此我們可以把所有的流程過一遍了
理一理思路
通過內核棧來切換進程的流程
用戶態fork觸發int80中斷
進入_system_call
1.一些通用寄存器入棧保存(ds,es,fs)
2.call _sys_fork
進入sys_fork
1.通用寄存器壓棧(gs,esi,edi,edx)
2. copy_process
建立一個新的進程,同時把返回值交給eax
3.eax
壓棧,這個eax
要交給res
,父進程的eax
(子進程pid
)在父進程棧里,而子進程的eax(0)
在子進程棧里。后面要修改eax
的值了
4.父進程被阻塞時執行reschedule
:先把ret_from_sys_call
壓棧,然后call _schedule
進入schedule
1.時間片切換給新建的子進程
2.switch_to
進入switch_to
1.保存當前進程棧幀
2.各種通用寄存器壓棧
3.切換pcb
,重寫TSS
(重定位tss
),切換內核棧,切換LDT
4.通用寄存器恢復,要格外注意這里,pop
出來的所有寄存器都是應該從子進程內核里彈出來的,所以在`copy_process時,棧頂應該有這幾個對應的通用寄存器
到此為止已經進入子進程啦!
下面就是ret_from_sys_call
:
5.通用寄存器恢復,和switch_to
的第4步一樣,這里恢復的通用寄存器也是子進程內核里探出來的,所以copy_process
時,棧里要有對應的通用寄存器
6.著名的iret
實現從內核到用戶的返回,同時res=eax
1.如果是子進程返回,那么被壓在內核棧里的eax=0
2.如果是父進程返回,那么被壓在內核棧里的eax=子進程pid
到此為止,fork調用結束啦!
附:AT&T下的尋址方式
由於匯編語言有兩種..兩種不同的尋址方式,本系列博客中用的是AT&T的格式,由於涉及大量的尋址,所以列出一些常見的尋址方式
# 直接尋址:eax去ADDRESS這個地址找就好
movl ADDRESS, %eax
# 立即數尋址:ebx=2
movl $2, %ebx
# 寄存器尋址: eax=ebx
movl $ebx, %eax
# 間接尋址:ebx去eax里面存的那個值代表的地址找
movl (%eax), %ebx
# 索引尋址(變址尋址):從0xFFFF0000地址開始,加上%eax * 4作為索引的最終地址
movl 0xFFFF0000(,%eax,4), %ebx
# 基址尋址:以eax寄存器里的數值作為基址,加上4得到最終地址
movl 4(%eax), %ebx
PS:這么多尋址方式真的很頭疼..