操作系统进程的实现---中---05

x33g5p2x  于2022-07-13 转载在 其他  
字(6.9k)|赞(0)|评价(0)|浏览(397)

内核级线程实现

进程=资源+执行序列。

执行序列=线程。

进程需要进入内核执行,所以进程里面的执行序列其实就是一个内核级线程。

而所谓对资源的管理,其实主要指的是对内存资源的管理。

因为要实现进程,首先需要实现一个内核级线程,然后再是对内存的管理。

核心级线程的两套栈,核心是内核栈…

先来回顾一下内核级线程切换的两套栈:

  • 中断(磁盘读或者时钟中断)
  • 调用schedule完成切换
  • 调度算法获取下一个线程的TCB,然后调用switch_to进行切换
  • 完成内核栈的切换,即tcb切换
  • 第二级切换,通过iret指令,弹出用户态状态
  • 如果线程S和线程T属于不同的进程,还需要进行映射表切换

整个故事要从进入内核开始——某个中断开始…

  • 要进入内核,就需要经过中断,下面列举了一个最长使用的中断; fork()
    fork()的作用是创建进程,而创建进程,就需要创建执行序列和资源; 这里创建执行序列实际就是创建线程。

  • main函数执行,需要压栈,栈中保存当前函数执行结束后的返回地址,这里ret=exit ,表示main函数执行完毕后,程序就结束了。

  • 执行A函数时,同样需要压栈,保存A函数执行结束后的返回地址,这里ret=B,表示A函数执行结束后,会去执行B函数
    这里ret保存的实际上是cs和ip

  • A中调用fork函数,该函数首先将系统调用号保存到eax寄存器中,该中断号代表当前执行的是进程创建
    eax, ebx, ecx, edx, esi, edi, ebp, esp等都是X86 汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。如果用C语言来解释,可以把这些寄存器当作变量看待。
    这里eax中保存的系统调用号,会在system_call_table中发挥索引具体内核函数地址的作用

  • 然后调用INT 0X80中断

  • 执行INT 0x80中断的时候,还没有进入内核,在执行时,会将当前用户栈的SS和SP保存到内核栈中,还有cs和ip,当然还有相关寄存器,下面会将
    因为ip会自动加一,所以这里ret= mov res,%eax
    EFLAGS保存的是标志寄存器

标志寄存器主要是记录当前的程序状态

  • INT 0X80实际上会去调用system_call ,因此还需要将system_call地址压入栈中,一会进入内核中后,首先弹出system_call地址,然后去执行

操作系统接口和调用–02

切换五段论中的中断入口和中断出口

  • 首先,弹出栈顶元素,即system_call函数的地址,然后去执行该函数
  • 该函数首先保存当前会使用到的寄存器,即保护现场,也是保护了用户态切换时寄存器的状态
  • 然后,通过系统调用号,去system_call_table定位到某个具体的内核函数地址,然后执行
  • 这里具体定位到的是sys_fork函数

在执行sys_fork的时候,可能会引起切换,例如: 如果产生了阻塞或者时间片到期了
对于sys_read或者sys_write来说,会引起阻塞

引起切换,具体判断逻辑是什么,怎么进行切换的呢?

下面先来看看sys_fork执行完后的代码

  • 将当前线程PCB赋值给eax

  • 判断PCB的状态是否为0,在linux 0.11中,0是就绪状态,而非0是阻塞状态
    如果调用了相关sys_read和sys_write方法后,只需要将当前PCB状态设置为非0,表示进入阻塞状态接口

  • 如果当前PCB状态为非0,然后就调用reschedule函数进行调度

  • reschedule函数主要完成的是内核级线程的切换,即PCB切换,因为用户态状态在产生中断的时候,就已经保存到了内核栈中

  • 然后再通过counter来判断时间片是否到期,如果到期了,也需要进行切换

  • 当reschedule函数执行结束后,会去执行ret_from_sys_call函数,即iret返回

reschedule函数首先将ret_from_sys_call子程序标号入栈,然后去执行具体的_schedule函数,这里是一个c函数。

c函数执行结束后,会弹出栈顶元素,然后返回到栈顶元素地址处继续执行。

这里先来看一下中断返回,即执行完_schedule函数后,执行的ret_from_sys_call

  • 恢复现场,将保存的相关寄存器状态从栈中弹出
    此时esp指向的栈,已经不是原内核级线程对应的栈了,而是切换后的内核级线程对应的栈,所以这里弹栈,弹的也是切换后线程关联的栈,恢复的是切换后线程原先的状态

  • iret恢复用户栈和cp,ip相关状态
    大家注意思考: 此时eax等于多少?

再来看看执行调度的具体过程,即_schedule函数执行:

  • 通过相关调度算法,找出切换到哪个线程继续执行
  • switch_to完成具体切换,主要是完成对内存级线程PCB的切换

switch_to难点分析

参考:
Linux0.11内核–进程的调度schedule和switch_to解析
任务状态段TSS及TSS描述符、局部描述符表LDT及LDT描述符
Linux 0.11用tss切换,但也可以 用栈切换,因为tss中的信息可以 写到内核栈中

下面讲解的是基于TSS完成进程切换的过程

在一个多任务环境中,当发生了任务切换,需保护现场,因此每个任务的应当用一个额外的内存区域保存相关信息,即任务状态段(TSS);TSS格式固定,104个字节,处理器固件能识别TSS中元素,并在任务切换时读取其中信息。

各部分关系图如下:

TSS描述符格式:

TYPE中'B':忙,刚创建时应为0,任务开始执行,挂起时为1,由硬件管理,防止切换任务切到自己;TSS描述符DPL必须为0,只有CPL为0能调用;

  • 先来看看Linux 0.11中switch_to的完整源码实现
    switch_to() (sched.h 第173行)
/****************************************************************************/  
/* 功能:切换到任务号(即task[]数组下标)为n的任务                          */  
/* 参数:n 任务号                                                         */  
/* 返回:(无)                                                               */  
/****************************************************************************/  
// 整个宏定义利用ljmp指令跳转到TSS段选择符来实现任务切换  
// __tmp用来构造ljmp的操作数。该操作数由4字节偏移和2字节选择符组成。
// 当选择符是TSS选择符时,指令忽略4字节偏移。  
// __tmp.a存放的是偏移,__tmp.b的低2字节存放TSS选择符。高两字节为0。  
// ljmp跳转到TSS段选择符会造成任务切换到TSS选择符对应的进程。  
// ljmp指令格式是 ljmp 16位段选择符:32位偏移,但如果操作数在内存中,顺序正好相反。  
// %0   内存地址    __tmp.a的地址,用来放偏移  
// %1   内存地址    __tmp.b的地址,用来放TSS选择符  
// %2   edx         任务号为n的TSS选择符  
// %3   ecx         task[n]  
01   #define switch_to(n) { / 
02   struct (long a,b;} __tmp; / 
03   __asm__("cmpl %%ecx,current /n/t" / 
04   "je 1f/n/t" / 
05   "xchgl %%ecx, current/n/t" / 
06   "movw %%dx, %1/n/t" / 
07   "ljmp *%0/n/t" / 
08   "cmpl %%ecx, %2/n/t" / 
09   "jne 1f/n/t" / 
10   "clts/n" / 
11 "1:" / 
12 ::"m" (*&__tmp.a), "m" (*&__tmp.b), / 
13 "m" (last_task_used_math),"d" _TSS(n), "c" ((long) task[n])); / 
14 }

注释:这是一个嵌入式汇编宏,作用是从当前任务切换到任务n,在进程调度程序中被调用。

  • 第2行定义了一个__tmp结构,包含2个long类型整数a和b
  • 第3行将task[n]与current比较,其中task[n]是要切换到的任务,current是当前任务;
  • 第4行说明,如果要切换到的任务是当前任务,则跳到标号1,即结束,什么也不做,否则继续执行下面的代码。
  • 第5行交换两个操作数的值,相当于C代码的:current = task[n] ,ecx = 被切换出去的任务(原任务)
  • 第6行将新任务的TSS选择符赋值给 __tmp.b;
  • 第7行是理解任务切换机制的关键。长跳转至 * &tmp,造成任务的切换。AT&T语法的ljmp相当于Intel语法的 jmp far SECTION : OFFSET,在这里就是将(IP)<-__tmp.a,(CS)<-__tmp.b,它的绝对地址之前加星号(“*”)。当段间指令jmp所含指针的选择符指示一个可用任务状态段的TSS描述符时,将造成任务切换。那么CPU怎么识别描述符是TSS描述符而不是其他描述符呢?这是因为所有描述符(一个描述符是64位)中都有4位用来指示该描述符的类型,如描述符类型值是9或11都表示该描述符是TSS描述符。好了,CPU得到TSS描述符后,就会将其加载到任务寄存器TR中,然后根据TSS描述符的信息(主要是基址)找到任务的tss内容(包括所有的寄存器信息,如eip),根据其内容就可以开始新任务的运行。我们暂且把这个恢复所有寄存器状态的过程称为恢复寄存器现场。
    第7行简单来说:
    首先,TR保存的值,还是先前线程的TSS选择符,因此CPU会首先会根据当前TR中保存的值,定位到先前线程的TSS,然后将当前相关寄存器状态,全部保存到该TSS中。

"d" _TSS(n)将新任务的TSS选择符放入到TR中,然后CPU根据TR中的值,去GDT表中找到对应的TSS描述符,然后根据描述符,定位到新任务的TSS,然后将对应TSS中保存的寄存器状态,全部恢复到当前CPU上

  • 第8~10行是判断原任务上次是否使用过协处理器,若是,则清除寄存器CR0的TS标志。

第2个难点是:在第7行执行后,完成任务切换(即切换到新的任务里执行);当任务切换回来后才会继续执行第8行!下面详解其原因。

既然任务切换时CPU会恢复寄存器现场,那么它当然也会保存寄存器现场了。这些寄存器现场都会被写入原任务的tss结构里,值得注意的是,EIP会指向引起任务切换指令(第7行)的下一条指令(第8行),所以,很明显,当原任务有朝一日再次被调度运行时,它将从EIP所指的地方(第8行)开始运行。

另一个故事ThreadCreate就顺了…

回到下面这幅图,我们上面已经讲完了,sys_fork创建线程前需要做啥,创建完线程后要做啥,但是就是没讲,具体内核级线程创建的过程,即sys_fork系统函数执行的过程,下面来具体聊聊:

下面进入sys_fork函数具体执行过程:

_sys_fork函数中具体会去调用copy_process函数完成内核线程的创建,而该函数中需要的所有参数值,都来源于栈中,已经压入栈中的参数是在创建线程前,放入的相关寄存器和用户栈状态

  • ret保存的是eip,而这里保存的eip是执行int 0x80时,压入栈中的,eip是int 0x80下一条指令,即mov res,%eax

copy_process的细节:创建栈

它会用当前进程的一个副本来创建新进程并分配pid,但不会实际启动这个新进程。它会复制寄存器中的值、所有与进程环境相关的部分,每个clone标志。新进程的实际启动由调用者来完成。

  • 首先通过get_free_page()函数,申请一页内存,用来初始化当前线程对应的PCB
    这里get_free_page()实际会去mem_map中获取一个空闲页,mem_map在mem_init…中被初始化好
    不能使用malloc分配内存,是因为malloc是用户态函数,而这里需要调用内核态分配内存的函数

  • linux 0.11中线程的切换,是靠tss完成的,因此这里创建内核级线程时,最重要的就是,初始化该内核级线程对应tss的初始化值,这样一会切换到该线程执行时,只需要将该新创建线程的tss中保存的寄存器状态进行恢复即可。

首先,新创建的内核级线程的内核栈使用的是上面申请的PCB内存中的一部分:

对于申请的空闲页来说,下面是PCB内存,上面是内核栈

tss.ss0指向的是内核数据段

对于用户栈来说,其使用的就是父进程的用户栈空间,下面ss和esp参数,就是函数从栈中获取的实参值

最后还有一点需要说明,因为这里使用tss来完成内核级线程的切换,而不是内核栈的方式,因此不需要将eip压入两个栈中。因为tss中已经保存了相关寄存器的值

copy_process的细节:执行前准备

上面申请内存空间,创建TCB,创建内核栈和用户栈,关联栈和TCB后,下面会做什么事情呢?

  • 首先,将当前父进程的eip和cs放在tss中,说明子进程一会如果执行的话,会从父进程在中断进入内核态时,压入栈中的eip和cs处开始执行
  • 然后eax设置为了0,这一点很重要
  • 因为linux 0.11只支持进程切换,因此下面还需要切换内存,这里暂时不做讨论

子进程创建完后被调度

  • 上面父进程通过sys_fork创建了一个子进程,子进程除了内核栈和父进程不一样之外,用户栈和eip,cs都和父进程一样

  • sys_fork执行完后,假设,此时父进程时间片到期了,如果进行进程调度,此时就切换到了刚创建完毕的子进程去执行
  • 那么,在进行进程切换时,子进程会将自己的tss中保存的寄存器状态,全部扣到当前CPU中,注意,此时子进程的eip和cs就是一开始父进程中断进入时压入栈中的

  • 如果,大家还记得上面这幅图的话,就知道,此时子进程拿到的eip等于mov res,%eax这条指令位置,而eax的值为0,所以此时res=0

  • 而这里res就是fork函数要返回的结果,对于子进程来说,此时res=0,而如果是父进程执行完中断,返回后,此时res是不等于0的

那么fork函数返回0和非0的作用在哪里体现呢?

第三个故事: 如何执行我们想要的代码?

  • 父进程创建完子进程后,进行进程切换,而正好切换到创建完的子进程的话,此时子进程fork返回值为0,因此会进入if条件语句去执行exec(cmd)命令
  • 而对于父进程来说,如果中断返回以后,则不会进入if条件判断

因此,父进程再创建完子进程,并且子进程第一次运行时,其实执行的就是父进程的代码,只不过,在进入exec后,此时子进程就会去执行和父进程不一样的代码了,相当于一把叉子,分界点就在上面的if判断处.

  • 父进程会去执行自己的shell程序,不断接受用户输入的命令
  • 如果用户输入命令,要去执行 hello.exe程序
  • 那么首先会去创建一个子进程,然后子进程去执行hello.exe程序
  • 下面,来看看这个执行的具体过程是什么样子的

结构: 子进程进入A,父进程等待…

exec是会去进行系统调用,然后通过中断进入内核,再经过一通操作后,再返回到用户态执行hello.exe可执行文件
执行hello.exe可执行文件,会设计到对文件的操作,磁盘操作,因此必须要进入内核才行

进入内核态靠的是中断,中断返回靠的是iret,那么exec在进入内核前,需要压入栈中的eip设置为hello.exe程序的位置,这样中断返回后,才能直接去执行hello.exe程序

可以看到,在进行具体系统调用sys_execve时,首先会将EIP(%esp)内容压栈,这里EIP=0x1C,那么EIP+esp指向的就是+28的地址处,即ret=??1,这里ret就是存放的中断返回后,将会赋值给eip寄存器的值。

终于可以让A执行了…

  • eip[0]指向的就是+28地址处,即存放中断返回后eip的地方,这里将ex.a_entry赋值给了eip[0],ex.a_entry是可执行程序入口地址,即我们上面说的hello.exe程序入口地址处

至于hello.ex可执行文件的入口地址是如何找到的,首先需要从磁盘读取出这个文件,然后通过其文件头中定义的信息,就可以找到该文件的入口地址处

然后,通过iret中断返回后,eip会被设置为hello.exe程序的地址,因此子进程就直接去执行该hello程序了

小结

Linux 0.11的TSS方式:

  • 对于TSS方式完成内核线程的切换而言,主要通过一条长跳转指令,通过将CPU状态拍到老进程的TSS上,而将新进程的TSS拍到当前CPU上,就完成了内核栈的切换,这样的缺点在于切换代价比较大
  • 由于线程切换靠的是TSS,因此在内核级线程创建的时候,需要将当前线程的TSS状态都初始化好

通过内核栈完成切换

  • 如果是通过内核栈完成内核级线程的切换,在具体切换时,只需要切换TCB即可,因为在进入中断的时候,就已经将当前各种寄存器的状态压入了内核栈中,相当于用内核栈保存CPU当前状态,从而替换了TSS

相关文章