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
"
"je 1f
"
"movw %%dx,%1
"
"xchgl %%ecx,current
"
"ljmp *%0
"
"cmpl %%ecx,last_task_used_math
"
"jne 1f
"
"clts
"
"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:这么多寻址方式真的很头疼..