哈工大李治军老师操作系统笔记【10】:内核级线程实现(Learning OS Concepts By Coding Them !)

2023-05-16

文章目录

  • 0 回顾
  • 1 实现
    • 1.1 int 0x80 fork(中断入口)
    • 1.2 进入核心态
    • 1.3 system_call(中断切换中间三段)
    • 1.4 中断出口
    • 1.3 switch_to
    • 1.4 ThreadCreate
    • 1.5 copy_process
  • 2 总结

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
  • 综上,就是三句代码,intljmpiret

(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 总结

加油

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

哈工大李治军老师操作系统笔记【10】:内核级线程实现(Learning OS Concepts By Coding Them !) 的相关文章

随机推荐