文章目录
0 回顾
- 内核级线程的切换过程对用户程序来说是透明的
- 内核级线程的实现,实际上主要看两套栈的切换
- 线程1,一旦进入到内核当中,就要使用内核栈,内核栈和用户栈是要通过int指令的CPU解释自动拉在一起的,形成一套
- 在内核中执行一件事,可能要调度到另外一个线程实现,该怎么做?
- 首先TCB(线程控制块)进行切换,TCB切换好了,那么内核栈就切换好了
- 在内核栈运行一段时间,处理一些收尾工作之后,然后再通过
iret
指令,用户栈就切过来了,PC指针也切过去了 - 整个过程就是,从用户栈到内核栈,再从内核栈到用户栈
1 实现
1.1 int 0x80 fork(中断入口)
- 从fork系统调用(创建进程系统调用)(创建资源也要创建线程,fork对应线程)开始讲,为什么讲fork系统调用
- 一方面,因为fok是创建进程的系统调用,包括创建资源和指令序列(线程),也就是说能看到创建线程的过程(创建成能够切换的样子)
- 另一方面,也能看到进程/线程调度与切换的过程(五段论)
- 从main开始,通过fork()进入int0x80中断,五段论中的第一段,中断入口
实现过程
- 怎么进入内核?从中断开始
- 首先调用main,将返回地址exit压入用户栈,因为中断完成后肯定要返回
- 然后main函数中,首先调用A,将返回地址B压入用户栈,记住内核栈的样子
- 接下来进入A函数,就是一个fork函数的调用,fork函数展开成汇编代码
-
- 首先将_NR_fork(系统调用),即fork系统调用的编号放入eax寄存器
-
- 然后就是int 0x80中断指令,开始做进入内核态的准备工作(也可以认为已经进入内核态了吧?)
-
-
- 内核栈中压入SS:SP,执行的时候指向用户栈
-
-
-
- 内核栈中压入EFLAGS
-
-
-
- 内核栈中压入用户程序的返回地址,即int中断指令的下一条
-
-
-
- int 0x80对应的是系统调用,开始执行_system_call的代码
-
1.2 进入核心态
- 走到此时,仍然是把用户态的一堆东西记录下来
- 进行压栈push,此时的寄存器是用户态的(刚进入内核态)
-
前面的东西都是用户态的,所以需要把用户态的东西在内核态中保存起来
-
将来返回的时候还需要用到
-
按照老师的意思,这里严格来说仍然处于用户态,在做进入内核态的准备工
-
首先会将当前用户态的寄存器等数据放入内核栈中(保存现场),
push%ds…%fs,pushl%edx
等等,要把用户态的信息保存,因为以后还要返回
1.3 system_call(中断切换中间三段)
切换有五段论,第一段是中断入口,实际上就是建立内核栈与用户栈之间的关联
之后进入内核,在内核中会执行,在执行的时候会发现,某些进程/线程可以不执行,比如说在内核当中发现某进程是启动磁盘读,那可以不让你读,这个时候就会引发切换,所以说在内核中的时候会判断一个事件来引发切换,判断这个事件来引发切换,这就是内核级线程五段论之间的中间三段,在sys_fork执行过程当中,会发现不要执行,但是sys_read之类发现磁盘已经读写了,必须等待。
- 调用sys_fork(可能会引起切换),真正进入内核态
sys_fork
在执行的过程中被打断调度切换的可能性比较小(但也是有可能的)(因为可能判断这个操作不要执行,比如读操作),sys_read和sys_wite的可能性比较大,因为已经启动磁盘读写了,必须等待- 老师跳过了sys_fork的功能性代码(即创建进程的过程,这个在本视频的后半段再讲),直接来到其最后判断是否需要调度/切换的代码
- 在sys_fork的功能性代码执行完后,会判断当前线程(进程)的状态是否阻塞,当前线程的时间片是否已经用完,任何一个满足,就跳转到reschedule
- 上图中current指向的就是当前进程的PCB,PCB中的state非0则表示阻塞,就要进行调度,跳转到reschedule
- 这个中间的切换就是由schedule引发的,这个schedule引发就会完成内核栈的切换,中断出口的时候,再调用
iret
完成用户栈到内核栈的切换 - 这个非0(
cmp $0, state(%eax)
中的0,state非0意味着阻塞,阻塞了得重新调用)是在什么时候设置的?应该是在sys_fork、sys_write等这些系统调用的功能性代码内部设置的 - 下图中counter指的是时间片,等于0则表示时间片已经用完,也要跳转到reschedule(中间切换,跳出去会跳回来,即中断返回)
- 疑问:时间片是否用完为什么会在系统调用的代码判断?难道不是在时钟中断的代码里判断吗?
- reschedule中
-
- 首先将
ret_from_sys_call
这个地址压入栈中
- 首先将
-
- 然后调用
c
函数schedule
,在schedulel
函数运行到最后的右括号时,就会从栈中取出返回地址ret_from_sys_call
- 然后调用
-
- 这样,下次调度回当前线程的时候,就会运行到
schedule
函数的右括号,就会回到ret_from_sys_call
标号的位置
- 这样,下次调度回当前线程的时候,就会运行到
- 讲
reschedule
的时候,老师说schedule
执行完后,会回到ret_from_sys_call
,感觉这里讲得不是很精细,schedule
执行完后应该会切换到另一个线程(另一个线程在之前陷入内核时不一定是系统调用吧?),除非调度的结果仍然是当前线
程,或者另一个线程也是通过系统调用陷入内核的,才会马上回到当前的这个ret_from_sys_call
标号的位置
1.4 中断出口
- 中断入口是一堆的push,自动push,int指令push
- 中断返回是一堆pop,按照先进后出的顺序一个个弹出去
- 这里的一堆
pop
是哪里来的?是ret_from_sys_call
这里来的,所以这个代码一定要执行 - 五段论中的最后一段,中断出口,
ret_from_sys_call
,和之前_system_call的指令对应,按照倒序pop
恢复寄存器等现场信息,最后是iret指令,iret
之后,肯定就是切换到下一个内核级线程(下一个进程) - 我理解的
ret_from_sys_call
这部分代码,是在下次调度到这个线程的时候才会执行,或者可以把这里讲的ret_from_sys_cal
看成是属于切换后的线程的(这个线程当初也是通过某个系统调用陷入内核的) - 再次提到实验4,做完实验4,就真正明白了整个过程
schedule
引发的东西:找下一个内核级线程,即为调度- 调度完就要切换,
next
是调度后的下一个内核级线程的TCB - 下面主要关注
switch_to
切换
1.3 switch_to
- 完成切换使用
switch_to
,怎么做到的呢? switch_to
在linux0.11的TSS实现方案- TSS,Task Struct Segment,任务结构段,用这种方法进行切换,可以认为保存了所有寄存器的信息,也可以认为是TCB(PCB)的一部分信息,TSS的详细介绍在实验4中有
- Iinux0.11中是基于TSS实现的进程切换,实验4就是要将其改成前面讲的那种内核栈的切换
- TSS切换的实现比较简单,但是效率较低
- 现代操作系统实际上只需要使用一句指令,
ljmp %0\n\t
即可进行切换 - 通过以上内容,可以大致了解到任务切换的流程,
switch_to
中关键是ljmp %0
ljmp
是长跳转指令,实际上是跳转segment,每个段都要有个段描述符
- TSS是一个段,必然有一个描述符(指向段的指针),有描述符就有选择子
- 选择子是TR,TR是操作系统固有的一个寄存器,和cs一样是固有的,TR就是用来找到这个段的,所以用TR这个选择子找到描述符之后再找到这个段,这就是找到了TSS段
- 段的跳转,实际上就是段寄存器的变化
- 所以说,接下来就是再找一个段寄存器
- 新的TR,就是TSS(n),就是下一个进程对应的cs
- 综上
下图中,ljmp指令是长跳转指令,即TSS段的跳转切换,使用TR寄存器+GDT表,在上一张图中画了,TR是TSS段的选择符,拿这个选择符去查GDT表,找到对应的TSS描述符,这个TSS描述符指向了TSS的内容。下图中,n表示调度选择的下一个进程编号,那么TSS(n)就是下一个进程对应的cs,指向下一个进程的TSS后,就把其中的数据赋给寄存器等,恢复现场
- 捋一捋长挑转指令的整个过程
- 把当前CPU的所有寄存器信息(快照,保存现场)放在当前TR对应的TSS
- TSS(n)作为ljmp的一个操作数
- 将新的TR对应的TSS中的信息赋给CPU的寄存器等(恢复现场)
- 我的理解,大家看看:谁执行,谁就把持这12个寄存器内的值,程序的执行点pc=cs:ip出(这条指令执行),A切换B时A的12个寄存器快照在A-TCB中,B把之前自己的快照放入12个寄存器中pc更新
- TSS的切换实际上就完成了之前井的栈里保存的信息的切换,在上一张图中可以看到EBP、ESP、EIP等,TSS可以认为是TCB(PCB)中的一个子段,保存了之前讲的栈里保存的信息
- 之前讲的方案是先切换TCB,TCB的ESP指向了栈,栈里保存了返回地址CS、IP等信息
- 这里TSS的方案中,TSS中直接包含了IP
- 综上,就是三句代码,
int
,ljmp
,iret
(1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
(2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
- 这种方案因为TSS保存了很多东西,所以切换很慢(保存现场和恢复现场的过程很慢),只用ljmp一条指令完成,这条指令就会做得很复杂,一条指令也不能通过指令流水来优化,不能充分利用CPU的硬件加速
1.4 ThreadCreate
- fork实际上是linux中创建子进程的一种方式,创建子进程,每个进程有一个主线程,那么实际上也就是创建了一个新的线程
- 所以可以通过sys_fork来看CreateThread
- sys_fork中调用了_copy_process,如下图,需要一大堆参数,这些参数已经被提前放到当前这个父进程的内核栈里面了(call _copy_process指令之前的那一堆push、pushl指令),调用时就会倒序从栈里取出参数(为什么需要这么多参数?别忘了fork是按照父进程copy出一个子进程,所以这些参数基本上都是来自父进程的,子进程按照这些参数来创建)
-
- 栈里的eax对应第一个参数int nr
-
- 栈里的ebp对应第二个参数long ebp
-
- 栈里的ret=??1对应参数long eip,也就是int 0x80的下一条指令的地址作为返回地址
-
- 栈里的EFLAGS对应参数long eflags
-
- 栈里的SP对应倒数第二个参数long esb
-
- 栈里的SS对应最后一个参数long ss
- 注意,下图右侧将内核栈分成了左右两部分
-
- 右部分的最底端就是_sys_fork的第一条push指令放入的gs
-
- 右部分的最顶端
??4
就是call _copy_process
的时候的PC,对应指令addl $20, %esp
- 右部分的最顶端
-
- 左部分的最顶端
??2
就是call _sys_fork
的时候的PC,翻到上面的图可以看到,对应的指令是pushl %eax
- 左部分的最顶端
-
- 左部分的内容是从进入
int 0x80
中断到call _sys_fork
之前就准备好的,右部分的内容是进入_sys_fork
后才准备的参数
- 左部分的内容是从进入
1.5 copy_process
copy_process的细节:创建子进程的栈
- 用户栈、内核栈是TSS的一部分(或者说是关联),TSS是TCB(PCB)的一部分
- 下面的代码
-
- 首先调用get_free_page()分配一页内存,并且类型转化为task_struct(可以理解为PCB,其中包含了一个子结构TSS):即申请内存空间+创建TCB(PCB)
-
-
- 这里并不是把一整页都转化为了task_struct类型,看后面的示意图,是把前面的一部分作为task_struct(包含TSS),后面剩下的部分存储其它数据,就是内核栈,并将内核栈的地址关联到前面的task_struct(中的TSS)中
-
-
-
- 为什么不用malloc?老师说在内核中就调用内核中专用的函数,而不用用户态的malloc
-
-
-
- 之前在系统初始化的时候,提到了mem_map,它把内存以4k为单位进行划分,就是一页,从mem_map中找到等于0的页就是空闲页
-
-
- 然后创建内核栈和用户栈,关联到TSS,也就是将栈和TCB关联起来了
-
-
- p->tss.esp0是内核栈的栈顶指针,设置为PAGE_SIZE(4k)+p,即这一页的最后一个地址
-
-
-
-
- 疑问:按照下面的示意图,内核栈是往低地址空间扩展的吗?
-
-
-
- p->tss.ss0是内核栈的段地址,0x10是内核数据段
-
- p->tss.esp和p->tss.ss是用户栈,这里由参数ss和esp指定,也就是说,和父进程共享用户栈(疑问:为什么?)
copy_process的细节:执行前准备
- 按照linux0.11按照TSS的切换方案,此时不需要填写用户栈和内核栈,只需要把TSS中的相关信息设置好,就相当于之前那种方案中设置了用户栈和内核栈
-
- 意思是,在之前的方案中,这些寄存器等现场信息是保存在栈里的,在TSS的方案中,这些信息全部保存在了TSS中
- 所以接下来讲解的就是初始化TSS的过程
- 如下图,大部分的信息直接来自于调用copy_process时传入的参数
-
- p->tss.eip=传入的eip,也就是说返回用户态后,执行的第一条指令地址(int 0x80的下一条指令)和父进程一致
-
- p->tss.cs同理
-
- p->tss.eax置为0,eax保存的是返回值(最前面的第一张图),也就是说,子进程调用fork()的返回值是0
-
-
- 父进程调用fork()的返回值是1(为什么是1?老师说他就不讲了,可以去看父进程相关的源代码),所以通常会用fork()的返回值区分是父进程还是子进程来编写代码
-
-
- p->tss.ecs=ecs,这个老师没讲
-
- p->tss.ldt,这个是内存相关的,现在不讲
- 注意,copy_process执行完后,只是把另一个线程/进程创建为了可以切换的样子,并没有切换过去,接下来还是会回到父进程call sys_fork后面的指令,即前面提到的判断当前进程是否阻塞,时间片是否用完,如果是,就会执行reschedule,进行调度切换
- 以shell中调用fork为例来讲解,如何让新进程执行我们指定的代码
- shell中,使用fork创建子进程,然后用exec(cmd)系统调用,来使子进程执行shell中输入的命令
- 提到了4个大实验中的第一个,创建内核级线程,就要用到这里讲的内容
- 详细讲解exec这个系统调用
- exec内部调用的是sys_execve
- 子进程在sys_execve之前执行的就是fork的代码,如视频前面所述,fork的代码准备好了子进程的内核栈,大部分信息来自父进程
- 随后子进程执行sys_execve,找到了命令对应的可执行文件,从可执行文件中取出必要的信息(例如可执行文件的入口地址,即第一条指令的地址),用这些信息修改了内核栈中用于IRET的参数,即返回地址,使得从sys_execve返回(IRET)后,转而去执行输入的命令,之后执行的指令和父进程就完全没关系了
- 下图中就有_sys_execve的汇编代码,首先计算得到栈中EIP的地址(0x1C是28,esp+28就是ret=??1的位置,也就是返回用户程序的地址),压入栈中,作为_do_execve的参数,然后call _do_execve
- do_execve的代码如上图
- 将栈中EIP修改为ex.a_entry,即可执行程序的入口地址
- 同时修改了即eip[3]即SP为当前申请的页内存
2 总结
加油