【哈工大李治军】操作系统课程笔记5:多线程、用户线程和内核线程 +【实验 5】基于内核栈切换的进程切换

1、用户级线程

在这里插入图片描述
在多进程任务中,引出了一个问题:
能否只切换任务,而不切换映射表?

从而引出了线程这个概念。我们能否只是让执行指令发生变化,而内存不发生变化?
在这里插入图片描述
进程 = 资源 + 指令执行序列
资源指令执行分开,好处是只用变动PC的值,而不用改变映射表的值,内存不用跟着切换。既保留了并发的优点,也避免了进程切换代价,切换耗时更短,完成切换更快。
在这里插入图片描述
**加粗样式**

(1)引入yield

yield让当前线程从运行态进入就绪态,实现线程的切换,从而让线程可交替执行
在这里插入图片描述

(2)线程栈

在这里插入图片描述
当线程之间使用yield()的时候,由于两个线程共用一个栈,可能会出现当执行完两次yield()后,执行到ret指令时,根据从栈中弹出的返回地址(404),又跳回到上一个线程里,导致本来已经切换的线程(204),又回到了上一个线程(404)里。

为了解决这个问题,就将原来的共用栈分开成每个线程个有一个单独的栈
在这里插入图片描述
这就从一个栈到了两个栈。每个线程有自己对应的一个栈。在执行yield时,首先是要切换栈,切换到另一个线程中的栈,再去执行jmp跳转指令。

为了存放线程中对应栈的指针信息,就引入了TCB的概念,TCB(线程控制块)是一个全局的数据结构,大家都能看到。TCB和栈相互配合,就完成了线程之间的切换。

(3)yield中去掉jmp

在这里插入图片描述
但此时还有一个问题没有解决,当我们jmp以后,跳回到之前线程的栈中时,遇到ret指令后,弹出的是204又会回到函数中上一步的位置进行执行,从而出现多余的重复步骤。

因此,我们来分析一下204。首先,204是第一次调用yield()后压栈得到的,当我们第二次再调用yield()时,jmp指令会让我们直接跳到esp=1000的204中,而yield()ret指令则永远不会被执行到。但我们可以发现,当完成esp中值的转换时,此时已经到了另一个线程的栈里,如果不执行jmp 204,而是直接执行ret指令,也可以完成弹出204的效果。当再ret时,此时栈顶已经变成104,从而弹出的就是104地址,从而可按原规定的方式进行执行。

修改后,yiled()只需要完成栈的切换即可,不要再加jmp指令,而是通过}转换成的ret指令完成弹栈工作。

(4)总结

在这里插入图片描述
ThreadCreate的核心就是用程序做出三样东西
申请一段内存作为TCB、申请一段内存作为并在栈里面填入程序的初始地址,再让栈和TCB进行关联

void ThreadCreate(A) {
	TCB *tcb = malloc();
	*stack = malloc();
	*stack = A;
	tcb.esp = stack;
}

在这里插入图片描述
用户级线程是在用户态上切来切去,操作系统完全感知不到它的存在。

这里会导致一个问题,如果在某一个进程中的一个线程被阻塞,由于操作系统感知不到用户级线程,只能感知到所属的进程,则操作只会让这个进程变成阻塞状态,从而导致这个进程中的所有线程都会被阻塞住,从而使线程的并发性失效。如果系统中有别的进程,则操作系统就会切换另一个进程去执行。如果系统中没有别的进程,则CPU只能空转。

在这里插入图片描述
为了解决上述问题,便引入了核心级线程的概念。核心级线程的ThreadCreate是一个系统调用,要创建这个线程会进入到内核中创建,内核可以感知到该线程的存在。若该线程被阻塞了,操作系统可以切换到另一个核心级线程进行执行,而不至于让一个进程中的所有线程都被阻塞。因此,设置内核级线程可以增强线程的并发性。

为了与yield(用户主动释放)区分开来,用schedule(操作系统调度)来实现线程的切换,而shcedule完全不可见是由操作系统决定的。

2、内核级线程

在这里插入图片描述
多核(多个CPU共用一个MMU映射表)要想充分发挥作用必须要支持核心级线程。多进程(来回更改映射表)和用户线程(不能使用硬件)都不能发挥多核的优点,只有核心级线程才能发挥多核的优势。

在这里插入图片描述
内核级线程和用户级线程相对比:从一个栈到一栈。从两个栈到两栈。

内核级线程的核心:在内核态创建核心级线程,需要即能在用户态下的用户栈执行,也能够在内核态下的内核栈执行。一个TCB要关联一套栈(内核栈用户栈)。

在这里插入图片描述
进入内核的唯一方法是中断,只要进入内核时,就会进入内核栈

一旦INT指令触发后,就会通过计算机硬件上的寄存器来找到此时的线程所对应的内核栈。然后,就会将刚才用户所执行的用户栈sssp信息压入内核栈。同时,也会将刚才的pccs信息压入内核栈,即把刚才在用户态所执行的地方压入内核栈。内核栈通过指针把两个栈拉在一起。

与之相对的是执行IRET指令时,就会将内核栈的数据弹出,又会退回到用户栈,即回到之前在用户态下所执行的地方。

在这里插入图片描述
随之程序的执行,执行到int 0x80时,进入内核栈,将sssppccs信息压入内核栈,再压入1000,内核程序进入到2000时,开始执行sys_read()函数。
在这里插入图片描述
在这里插入图片描述
启动磁盘读,然后将自己阻塞后,就会引起调度线程的调度,找到下一个待执行的线程。cur是当前线程的TCB、next是下一个线程的TCB。

注: switch_to:仍然是通过TCB找到内核栈指针,然后通过ret切换到某个内核程序,最后再用CS:PC切到用户程序。

切换到线程T后,它在内核态执行完一小段后,就又会切换到用户态下执行对应的用户线程。
在这里插入图片描述
PC=500,CS=3000,???是含有iret的代码。

在这里插入图片描述
(1)中断入口(进入切换):由用户线程进入到内核线程,开启中断后,会将用户栈和用户程序的位置信息压入内核栈中;
(2)中断处理(引发切换):当所执行的内核线程触发中断或系统引发中断时,就通过调度策略,找到下一个要切换的内核级线程;
(3)schedule:找到下一个内核级线程的TCB后,调用switch_to
(4)switch_to(内核栈切换):使用switch_to,通过TCB找到内核栈的指针压入栈中,最后执行ret从栈顶取出地址,完成两个内核级线程的切换;
(5)中断出口(第二级切换):弹出内核栈中的数据,让当前内核栈切换回与之对应的用户栈,最后iret指令,就跳转到了用户线程进行执行。
(6)进程切换,就需要用到地址映射表,在后续的内存管理会讲解。
在这里插入图片描述ThreadCreate主要完成两个任务:
(1)完成用户栈内核栈的关联;
(2)完成TCB内核栈的关联。
在这里插入图片描述
简单来说,不同的线程间的切换主要使用TCB保存记录,实现切换;同一个线程在不同级别下(用户/内核级)的切换,主要是通过在栈中保存返回地址等信息来实现,也类似于 “共用一个栈” 。

3、内核级线程实现——线程切换

进程实际上由两部分组成,一部分是资源,另一部分是执行序列,而里面的执行序列实际上就是线程。进程又必须进入到内核,所以进程里面的执行序列就是内核级线程。所以,学会了内核级的代码代码实现,也就掌握了大部分的进程实现。再加上后面的资源管理部分,就在操作系统上实现进程。
在这里插入图片描述

(1)系统调用

在这里插入图片描述
首先,将用户程序压入用户栈中,其中A函数的返回地址就是B函数的初始地址。当在A()中执行时,遇到fork()后就会触发80中断,出现系统调用。

一旦触发80中断后,CPU就会马上找到当前的内核栈,压入当前的sssp以及csipint 0x80后一句代码的位置),即用户栈与程序执行的位置信息。然后进入到中断处理函数,将system_call压入内核栈中。

(2)中断入口

在这里插入图片描述
(1)中断入口

_system_call:
	push %ds .. %fs
	pushl %edx...
	call sys_fork
	pushl %eax

_system_call中依然是压栈,把刚才在用户态中的寄存器里的数据在和核心态中下记录下来。(当将来再用这些内容时,可以再弹出去)

记录完后,执行之前用户程序所需要的系统调用函数sys_fork。而在执行sys_fork时,可能会引起切换,因为当内核程序在内核中执行时,系统会通过判断一个事件,来引发切换,这也就是五段论中中间三段

(2)五段论中的中间三段

movl _current, %eax
cmpl $0, state(%eax)
jne reschedule
cmpl $0, counter(%eax)
je reschedule
ret_from_sys_call:

sys_fork执行完后,再往下执行时,会将_current(当前线程的PCB)置给eax,然后判断当前线程的state(实际上就是 state+eax = state+_current = state+PCB),实际上就是用cmpl判断当前线程PCB中的state是否等于0。如果等于0,则表示为运行态,不会执行jne指令进行调度;如果不等于0,表示为阻塞状态,就会通过jne指令(不相等则跳转,ZF=0时执行该语句)进行调度reschedule进行执行,reschedule会完成内核级线程的切换,即五段轮中的中间三步。

再用cmpl判断_currentcounter是否等于0,如果等于0,则执行je指令(相等则跳转)进行调度,而counter所记录的实际上就是时间片,等于0表示时间片已经用光。

上述两个跳转指令,分别用于判断状态时间片,来决定是否去reschedule中执行schedule函数。如果当前的状态不为运行态,则进行调度。如果当前线程的时间片已经用光,则也进行调度。

(3)中断出口

reschedule:
	pushl $ret_from_sys_call
	jmp _schedule

进入到reschedule后,首先会把切换前的线程的返回地址存入到栈中,然后再去执行shcedule。一旦切换完成(schedule执行完后),接下来就又会跳回到这个函数的下一条语句ret_from_sys_call,执行中断返回。

(3)中断出口

在这里插入图片描述
reschedule.s

reschedule:
	pushl $ret_from_sys_call
	jmp _schedule

存入返回地址,进行线程调度

schedule.c

void schedule(void) {
	next = i;
	switch_to(next);
}

线程调度

ret_from_sys_call.s

ret_from_sys_call:
	popl %eax // 返回值 popl %ebx ...
	pop %fs ...
	iret // 重要

跳转到返回地址后,执行该语句。中断出口,切换到刚才调度的内核线程。

按照之前push的逆序顺序进行pop存入到对应的寄存器当中。最后,再执行iretsssp以及csip一起pop出去后,就会切换到的就是下一个核心线程(之前调度的线程)。

(3)switch_to

在这里插入图片描述
其中TR是当前CPU对应的任务段的描述符所存在于GDT表中的地址位置,类似于CS
参数n是下一个线程对应的TR
TSS包含CPU所有寄存器的状态信息(就像拍了一个快照一样);
TSS描述符是指向TSS段的指针。
TR用于找到TSS段
在这里插入图片描述
在这里插入图片描述

记录切换前的信息
在ljmp指令(长跳转,会传输64位的数据量信息)之前,先将当前CPU的所有寄存器中的值存放到当前TR所指向的段中,从而将切换前的线程的所有信息记录下来。

将新的线程信息覆盖给CPU
然后,让TSS指向GDT表中新的TSS描述符,从而得到指向新的TSS段的指针,将TSS(n)置给TRTR会作为选择子而指向新的TSS描述符,从而指向新的TSS段。

缺点: 使用TSS(Task Structe Segment任务结构段)进行切换执行起来慢,用一条指令做的不能进行指令流水。因此,我们需要从基于TSS的切换而变到Kernel Stack切换。

(4)内核级线程切换的总结

实际上核心代码就三句:int 0x80ljmpiret,再加一些其他代码将其包裹起来就完成了内核级线程的切换

4、创建线程 / 进程 copy_process

linux0.11里面没有线程的概念,但进程和线程在实现上的区别主要在于是否变动映射表,而其余创建等操作是相同类似的,因此这里通过讲解创建进程copy_process来解释创建线程
在这里插入图片描述
fork()继续往下走,就到了call _copy_process语句,父进程复制自己而创建出一个和自己基本一样的子进程。而这个函数的参数就是之前压入栈中的线程信息
在这里插入图片描述
get_free_page():找到mem_map=0的那一页并且把这个地址返回,再对其进行强制类型转换,将其地址交给指针p。此时这个块也是内核栈的地址,也就相当于此语句不仅申请了PCB的内存空间还创建了内核栈的地址空间,这块地址用来做PCB

然后,初始化TSS
esp0ss0是内核栈。p所指向的是PCB的初始地址,然后再加上4K(PAGE_SIZE)一页的大小,赋值给esp0。将内核数据段0x10赋值给ss0

ssesp是用户栈。使用父进程的ssesp作为用户栈,和父进程共用栈 。
在这里插入图片描述
eip置给TSS中,也就是当前父进程的IP位置,即执行fork()语句的下一句话的地址。

其中让eax置为0,是为了让if(!fork())语句生效,从而让子进程执行这里面的程序,实现父子进程分离。
在这里插入图片描述
在这里插入图片描述
子进程用了父进程创建的壳子,然后在这个壳子里执行自己的程序。

在这里插入图片描述
子进程在执行exec语句之前,父进程和子进程用的是同样的代码。而子进程一旦进入if(!fork())中执行exec后,子进程在未来从内核态退回到用户态时(中断返回时),子进程就会执行新的代码(ls这个程序的entry代码)。

其中中断返回时,实际上执行的是iret指令,而该指令即就是找到存放ssspEFLAGScsip的栈,然后把栈里面存的ip置给真正的寄存器eip。当pc在执行时,就会按照寄存器eip里的值进行执行。

因此,为了能让子进程切换到用户态时执行新的代码, 只需要修改栈中的ip值,从而改变pc的执行位置即可。

lea EIP(%esp), %eax
pushl %eax

EIP是偏移,%esp是当前栈指针。此句话的意思是EIP + %esp 置给 %eax,即0x1C=28,%esp加上28以后正好就在ret这个地方,而这个地方对应着eip的地址。然后,再将eax压栈。当执行中断返回时,pc就会按照新的eip中的值继续执行,从而跳到新的代码中去。
在这里插入图片描述
获取eip的地址,然后在do_execve()中将entryls这个可执行文件的入口地址)置给eip。将sp置给eip[3]。从而让进程从内核态退回到用户态时,PC指针会跳到与父进程不同代码的地方进行执行。目标执行的代码是ls读文件命令

5、总结

在这里插入图片描述
用户栈内核栈,内核栈再找到TCB,使用switch_to完成TCB的切换,从而完成内核栈之间的切换,最后用iret完成从内核栈到用户栈的切换

[实验 5]:基于内核栈切换的进程切换

现在的 Linux 0.11 采用 TSS(后面会有详细论述)和一条指令就能完成任务切换,虽然简单,但这指令的执行时间却很长,在实现任务切换时大概需要 200 多个时钟周期。

而通过堆栈实现任务切换可能要更快,而且采用堆栈的切换还可以使用指令流水的并行优化技术,同时又使得 CPU 的设计变得简单。所以无论是 Linux 还是 Windows,进程/线程的切换都没有使用 Intel 提供的这种 TSS 切换手段,而都是通过堆栈实现的。

实验目标
本次实践项目就是将 Linux 0.11 中采用的 TSS 切换部分去掉,取而代之的是基于堆栈的切换程序。具体的说,就是将 Linux 0.11 中的 switch_to 实现去掉,写成一段基于堆栈切换的代码。

本次实验包括如下内容:

  1. 编写汇编程序 switch_to
  2. 完成主体框架;
  3. 在主体框架下依次完成 PCB 切换、内核栈切换、LDT 切换等;
  4. 修改 fork(),由于是基于内核栈的切换,所以进程需要创建出能完成内核栈切换的样子。
  5. 修改 PCB,即 task_struct 结构,增加相应的内容域,同时处理由于修改了 task_struct 所造成的影响。
  6. 用修改后的 Linux 0.11 仍然可以启动、可以正常使用。
  7. (选做)分析实验 3 的日志体会修改前后系统运行的差别。

(1)修改schedule让其指向目标进程的PCB

在这里插入图片描述
**加粗样式**
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1)GDT与LDT

GDT与LDT
在这里插入图片描述

1 段描述符表 GDT

在这里插入图片描述
在这里插入图片描述
相当于是GDT为全局共享区,LDT是一个或多个任务的私有区。
在这里插入图片描述

2 段选择符

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  1. 一个系统中可以定义很多段,但同时只有6个段可供立即访问,若要访问其他段就需要加载这些段的选择符
  2. 为每个段寄存器在配备一个“影子寄存器”(也称“描述符缓冲”),当描述符表中的描述符做过任何改动后,就立刻重新加载6个段寄存器,并将描述符表中的相应段信息重新加载到影子寄存器中

2)TSS、TR与任务门描述符

1 TSS

在这里插入图片描述
在这里插入图片描述
对于TSS段描述符并没有程序给出读写该描述符的能力。因此,需要使用映射到内存相同位置的数据段描述符(别名描述符)来对其进行操作。

2 TR

在这里插入图片描述

3 任务门描述符

在这里插入图片描述

3)修改schedule()

在这里插入图片描述
在这里插入图片描述
修改为:

if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
    c = (*p)->counter, next = i, pnext = *p;

//.......
// pnext: 指向目标进程的PCB的指针;next为下一个进程的数组位置,也是GDT表中的n
switch_to(pnext, _LDT(next));

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
如果找到counter大于0且有就绪任务,或者没有一个可运行的任务可以调用,则跳出循环;否则,就是任务的counter都等于0或没有绪任务可以调用,则重新更新权重,再重新选择出next
在这里插入图片描述
同时,需要在kernel/sche.c中添加声明

extern long switch_to(struct task_struct *p, unsigned long address);

在这里插入图片描述
再在这个.c文件的schedule()中添加

struct task_struct* pnext = &(init_task.task);

在这里插入图片描述
注意:这个要添加到这个schedule()函数里面,有的博客里写在外面,会导致开机后不断重启。

(2)实现switch_to

随后要添加新的switch_to(),因此要在include/linux/sched.h中,把原函数注释掉。
在这里插入图片描述

在这里插入图片描述
实现 switch_to 是本次实践项目中最重要的一部分。

由于要对内核栈进行精细的操作,所以需要用汇编代码来完成函数 switch_to 的编写。

这个函数依次主要完成如下功能:由于是 C 语言调用汇编,所以需要首先在汇编中处理栈帧,即处理 ebp 寄存器;接下来要取出表示下一个进程 PCB 的参数,并和 current 做一个比较,如果等于 current,则什么也不用做;如果不等于 current,就开始进程切换,依次完成 PCB 的切换TSS 中的内核栈指针的重写内核栈的切换LDT 的切换以及 PC 指针(即 CS:EIP)的切换

函数栈EIP、EBP、ESP寄存器的作用(转)

内核栈的具体情况如下图所示:
在这里插入图片描述
kernel/system_call.s中添加switch_to()

.align 2
switch_to:
    pushl %ebp
    movl %esp,%ebp
    pushl %ecx
    pushl %ebx
    pushl %eax
    movl 8(%ebp),%ebx			! 将ebp指针+8指向的数据传递给了ebx寄存器
    cmpl %ebx,current			! 比较如果为同一个进程则跳转到1f,否则继续往下执行
    je 1f
! 切换PCB
    movl %ebx,%eax					! 将pnext置给eax
	xchgl %eax,current				! 交换数据,将current置给eax,penxt置给current
! TSS中的内核栈指针的重写
	movl tss,%ecx					! 将0号进程的tss信息置给ecx
	addl $4096,%ebx					! 让ebx指向下一个进程的PCB
	movl %ebx,ESP0(%ecx)			! 让指向下一个进程的PCB指针信息传递给0号进程tss里的ESP0
! 切换内核栈
	movl %esp,KERNEL_STACK(%eax)	! 将当前的内核栈的栈顶指针保存到切换前的进程PCB中
	! 再取一下 ebx,因为前面修改过 ebx 的值
	movl 8(%ebp),%ebx				! 将下一个进程的PCB位置置给ebx
	movl KERNEL_STACK(%ebx),%esp	! 从下一个进程的PCB中取出内核栈的顶指针信息置给esp
! 切换LDT
    mov 12(%ebp), %ecx				! 将下一个进程的LDT置给ecx。下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间实现了分离。
    lldt %cx						! 修改 LDTR 寄存器
    movl $0x17,%ecx
    mov %cx,%fs						! 重新取fs,以后会通过 fs 访问进程的用户态内存。
! 和后面的 clts 配合来处理协处理器,由于和主题关系不大,此处不做论述
    cmpl %eax,last_task_used_math
    jne 1f
    clts

1:    
	popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
	ret

无注释版

.align 2
switch_to:
	pushl %ebp
	movl %esp,%ebp
	pushl %ecx
	pushl %ebx
	pushl %eax
	movl 8(%ebp),%ebx
	cmpl %ebx,current
	je 1f

	movl %ebx,%eax
	xchgl %eax,current

	movl tss,%ecx
	addl $4096,%ebx
	movl %ebx,ESP0(%ecx)

	movl %esp,KERNEL_STACK(%eax)
	movl 8(%ebp),%ebx
	movl KERNEL_STACK(%ebx),%esp
	
	mov 12(%ebp),%ecx
	lldt %cx
	movl $0x17,%ecx
	mov %cx,%fs
    
	cmpl %eax,last_task_used_math
	jne 1f
	clts

1:    
	popl %eax
	popl %ebx
	popl %ecx
	popl %ebp
	ret

在这里插入图片描述
先执行switch_to里的前几行指令

    pushl %ebp
    movl %esp,%ebp
    pushl %ecx
    pushl %ebx
    pushl %eax
    movl 8(%ebp),%ebx	! 将ebp+8指向的数据传递给了ebx

由于栈是由高地址向低地址,因此ebp+8指向的是pnext,将得到下图所示栈的情况:
在这里插入图片描述
如果调度的是当前进程PCB,则不进行后续改变。如果调度的使其进程的PCB,则进行进程切换。

	cmpl %ebx,current
    je 1f

ebx寄存器中保存的是下一个进程的PCBcurrent当前进程的PCB。如果两个进程相同,跳转到1f位置处,后面的代码不用执行了,什么都不会发生。否则,执行切换PCB等后续操作。

虽然看起来完成了挺多的切换,但实际上每个部分都只有很简单的几条指令。完成 PCB 的切换可以采用下面两条指令,其中 ebx 是从参数中取出来的下一个进程的 PCB 指针,

! 切换PCB
movl %ebx,%eax
xchgl %eax,current		! 数据交换指令

经过这两条指令以后,eax 指向现在的当前进程ebx 和全局变量 current 都指向下一个进程

TSS 中的内核栈指针的重写可以用下面三条指令完成,其中宏 ESP0 = 4struct tss_struct *tss = &(init_task.task.tss); 也是定义了一个全局变量,和 current 类似,用来指向那一段 0 号进程的 TSS 内存

kernel/system_call.s文件中添加

ESP0 = 4

TSS的基本格式中偏移量为4的位置上是ESP0(特权级为0的栈段对应的顶指针)
任务状态段TSS及TSS描述符、局部描述符表LDT及LDT描述符
在这里插入图片描述

kernel/sched.c文件中添加

struct tss_struct *tss = &(init_task.task.tss);

表示0号进程的tss的全局变量
在这里插入图片描述
前面已经详细论述过,在中断的时候,要找到内核栈位置,并将用户态下的 SS:ESPCS:EIP 以及 EFLAGS 这五个寄存器压到内核栈中,这是沟通用户栈(用户态)和内核栈(内核态)的关键桥梁,而找到内核栈位置就依靠 TR 指向的当前 TSS

现在虽然不使用 TSS 进行任务切换了,但是 Intel 的这态中断处理机制还要保持,所以仍然需要有一个当前 TSS,这个 TSS 就是我们定义的那个全局变量 tss,即 0 号进程的 tss,所有进程都共用这个 tss,任务切换时不再发生变化。

! TSS中的内核栈指针的重写
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)	! ecx + 4 = ebx

ebx本来是指向下一个进程,执行完指令addl $4096,%ebx后,ebx指向下一个进程的PCB。为什么偏移量是4096?4096 = 4KB。在linux0.11中,一个进程的内核栈和该进程的PCB段是放在一块大小为4KB的内存段中的,其中该内存段的高地址开始是内核栈,低地址开始是PCB段。

然后,movl %ebx, ESP0(%ecx) 实现将指向下一个进程的PCB指针 传递给0 号进程tss 的记录栈顶指针位置处。

完成内核栈的切换也非常简单,和我们前面给出的论述完全一致。

#切换内核栈
    movl %esp,KERNEL_STACK(%eax) 
    movl 8(%ebp),%ebx
    movl KERNEL_STACK(%ebx),%esp

将寄存器 esp(内核栈使用到当前情况时的栈顶位置)的值保存到当前 PCB 中,再从下一个 PCB 中的对应位置上取出保存的内核栈栈顶放入 esp 寄存器,这样处理完以后,再使用内核栈时使用的就是下一个进程的内核栈了。

由于现在的 Linux 0.11 的 PCB 定义中没有保存内核栈指针这个域(kernelstack),所以需要加上,而宏 KERNEL_STACK 就是你加的那个位置,当然将 kernelstack 域加在 task_struct 中的哪个位置都可以,但是在某些汇编文件中(主要是在 kernal/system_call.s 中)有些关于操作这个结构一些汇编硬编码,所以一旦增加了 kernelstack,这些硬编码需要跟着修改,由于第一个位置,即 long state 出现的汇编硬编码很多,所以 kernelstack 千万不要放置在 task_struct 中的第一个位置

include/linux/sched.h中task_struct的定义里添加

// 在 include/linux/sched.h 中
struct task_struct {
    long state;
    long counter;
    long priority;
    long kernelstack;
//......

在这里插入图片描述
当放在其他位置时,修改 kernal/system_call.s 中的那些硬编码就可以了,在其中添加

KERNEL_STACK = 12

在这里插入图片描述
由于这里将 PCB 结构体的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化,需要将原来的 #define INIT_TASK { 0,15,15, 0,{{},},0,... 修改为 #define INIT_TASK { 0,15,15,PAGE_SIZE+(long)&init_task, 0,{{},},0,...,即在 PCB 的第四项中增加关于内核栈栈指针的初始化

include/linux/sched.h修改

/* linux/sched.h */
#define INIT_TASK \
/* state etc */ { 0,15,15,PAGE_SIZE+(long)&init_task,\
/* signals */   0,{{},},0, \
......
}

在这里插入图片描述
再下一个切换就是 LDT 的切换了。

! 切换LDT
    mov 12(%ebp), %ecx
    lldt %cx			! 覆盖
    movl $0x17,%ecx
    mov %cx,%fs

指令 movl 12(%ebp),%ecx (ebp + 12)负责取出对应 LDT(next)的那个参数,指令 lldt %cx 负责修改 LDTR 寄存器(加载局部描述符并置为%cx),一旦完成了修改,下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间实现了分离

程序运行到这里,我们通过一系列的操作,让操作系统切换了PCB、切换了TSS指针以及内核栈。现在esp指针已经是指向了下一个进程的内核栈了,但是下一个进程的内核栈还是空空如也的:
在这里插入图片描述
最后一个切换是关于 PC 的切换,和前面论述的一致,依靠的就是 switch_to 的最后一句指令 ret,虽然简单,但背后发生的事却很多:schedule() 函数的最后调用了这个 switch_to 函数,所以这句指令 ret 就返回到下一个进程(目标进程)的 schedule() 函数的末尾,遇到的是},继续 ret 回到调用的 schedule() 地方,是在中断处理中调用的,所以回到了中断处理中,就到了中断返回的地址,再调用 iret 就到了目标进程的用户态程序去执行,和书中论述的内核态线程切换的五段论是完全一致的。

1:    
	popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
	ret

这里还有一个地方需要格外注意,那就是 switch_to 代码中在切换完 LDT 后的两句,即:

! 切换 LDT 之后
movl $0x17,%ecx
mov %cx,%fs

这两句代码的含义是重新取一下段寄存器 fs 的值,这两句话必须要加、也必须要出现在切换完 LDT 之后,这是因为在实践项目 2 中曾经看到过 fs 的作用——通过 fs 访问进程的用户态内存,LDT 切换完成就意味着切换了分配给进程的用户态内存地址空间,所以前一个 fs 指向的是上一个进程的用户态内存,而现在需要执行下一个进程的用户态内存,所以就需要用这两条指令来重取 fs

不过,细心的读者可能会发现:fs 是一个选择子,即 fs 是一个指向描述符表项的指针,这个描述符才是指向实际的用户态内存的指针,所以上一个进程和下一个进程的 fs 实际上都是 0x17,真正找到不同的用户态内存是因为两个进程查的 LDT 表不一样,所以这样重置一下 fs=0x17 有用吗,有什么用?

要回答这个问题就需要对段寄存器有更深刻的认识,实际上段寄存器包含两个部分:显式部分隐式部分,如下图给出实例所示,就是那个著名的 jmpi 0, 8,虽然我们的指令是让 cs=8,但在执行这条指令时,会在段表(GDT)中找到 8 对应的那个描述符表项,取出基地址段限长,除了完成和 eip 的累加算出 PC 以外,还会将取出的基地址和段限长放在 cs的隐藏部分,即图中的基地址 0段限长 7FF。为什么要这样做?

下次执行 jmp 100 时,由于 cs 没有改过,仍然是 8,所以可以不再去查 GDT 表,而是直接用其隐藏部分中的基地址 0100 累加直接得到 PC,增加了执行指令的效率。现在想必明白了为什么重新设置 fs=0x17 了吧?而且为什么要出现在切换完 LDT 之后?
在这里插入图片描述

(3)修改fork

开始修改 fork() 了,和书中论述的原理一致,就是要把进程的用户栈、用户程序和其内核栈通过压在内核栈中的 SS:ESPCS:IP 关联在一起。

另外,由于 fork() 这个叉子的含义就是要让父子进程共用同一个代码、数据和堆栈,现在虽然是使用内核栈完成任务切换,但 fork() 的基本含义不会发生变化。

将上面两段描述联立在一起,修改 fork() 的核心工作就是要形成如下图所示的子进程内核栈结构。
在这里插入图片描述
kernel/fork.c中修改copy_process()tss部分注释掉

/*
        p->tss.back_link = 0;
        p->tss.esp0 = PAGE_SIZE + (long) p;
        p->tss.ss0 = 0x10;
        ...
        p->tss.ldt = _LDT(nr);
        p->tss.trace_bitmap = 0x80000000;
*/

在这里插入图片描述
添加代码

	/* 用来完成申请一页内存空间作为子进程的PCB */
	p = (struct task_struct *) get_free_page();
	...
	/** 然后这里要加上基于堆栈切换的代码(对frok的修改其实就是对子进程内核栈的初始化 */
	long *krnstack;
	/* p指针加上页面大小就是子进程的内核栈位置,所以这句话就是krnstack指针指向子进程的内核栈 */
	krnstack = (long)(PAGE_SIZE +(long)p);
	
	*(--krnstack) = ss & 0xffff;
	*(--krnstack) = esp;
	*(--krnstack) = eflags;
	*(--krnstack) = cs & 0xffff;
	*(--krnstack) = eip;
	 
	*(--krnstack) = ds & 0xffff;
	*(--krnstack) = es & 0xffff;
	*(--krnstack) = fs & 0xffff;
	*(--krnstack) = gs & 0xffff;
	*(--krnstack) = esi;
	*(--krnstack) = edi;
	*(--krnstack) = edx;
	 
	*(--krnstack) = (long)first_return_from_kernel;
	*(--krnstack) = ebp;
	*(--krnstack) = ecx;
	*(--krnstack) = ebx;
	*(--krnstack) = 0;	/* 相当于eax=0 */

	p->kernelstack = krnstack;
	 ......

在这里插入图片描述
不难想象,对 fork() 的修改就是对子进程的内核栈的初始化,在 fork() 的核心实现 copy_process 中,p = (struct task_struct *) get_free_page();用来完成申请一页内存作为子进程的 PCB,而 p 指针加上页面大小就是子进程的内核栈位置,所以语句 krnstack = (long *) (PAGE_SIZE + (long) p); 就可以找到子进程的内核栈位置,接下来就是初始化 krnstack 中的内容了。

*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;

这五条语句就完成了父子进程图中的那个重要的关联,因为其中 ss,esp 等内容都是 copy_proces() 函数的参数,这些参数来自调用 copy_proces() 的进程的内核栈中,就是父进程的内核栈中,所以上面给出的指令不就是将父进程内核栈中的前五个内容拷贝到子进程的内核栈中,图中所示的关联不也就是一个拷贝吗?

接下来的工作就需要和 switch_to 接在一起考虑了,故事从哪里开始呢?回顾一下前面给出来的 switch_to,应该从 “切换内核栈” 完事的那个地方开始,现在到子进程的内核栈开始工作了,接下来做的四次弹栈以及 ret 处理使用的都是子进程内核栈中的东西,

1: popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
ret

为了能够顺利完成这些弹栈工作,子进程的内核栈中应该有这些内容,所以需要对 krnstack 进行初始化:

*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
// 这里的 0 最有意思。
*(--krnstack) = 0;

现在到了 ret 指令了,这条指令要从内核栈中弹出一个 32 位数作为 EIP 跳去执行,所以需要弄一个函数地址(仍然是一段汇编程序,所以这个地址是这段汇编程序开始处的标号)并将其初始化到栈中。我们弄的一个名为 first_return_from_kernel 的汇编标号,然后可以用语句 *(--krnstack) = (long) first_return_from_kernel; 将这个地址初始化到子进程的内核栈中,现在执行 ret 以后就会跳转到 first_return_from_kernel 去执行了。

想一想 first_return_from_kernel 要完成什么工作?PCB 切换完成、内核栈切换完成、LDT 切换完成,接下来应该那个“内核级线程切换五段论”中的最后一段切换了,即完成用户栈和用户代码的切换,依靠的核心指令就是 iret,当然在切换之前应该回复一下执行现场,主要就是 eax,ebx,ecx,edx,esi,edi,gs,fs,es,ds 等寄存器的恢复.

kernel/system_call.s中添加

.align 2
first_return_from_kernel:
	popl %edx
	popl %edi
	popl %esi
	pop %gs
	pop %fs
	pop %es
	pop %ds
	iret

在这里插入图片描述
下面给出了 first_return_from_kernel 的核心代码,当然 edx 等寄存器的值也应该先初始化到子进程内核栈,即 krnstack 中。

popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret

最后别忘了将存放在 PCB 中的内核栈指针修改到初始化完成时内核栈的栈顶,即:

p->kernelstack = stack;

最后,注意由于switch_to()first_return_from_kernel都是在(kernel/system_call.s)中实现的,要想在(kernel/sched.c)和(kernel/fork.c)中调用它们,就必须在system_call.s中将这两个标号声明为全局的,同时在引用到它们的.c文件中声明它们是一个外部变量。

kernel/system_call.s中添加

.globl switch_to, first_return_from_kernel

在这里插入图片描述

最后,在kernel/fork.c中添加外部声明

extern long first_return_kernel(void);

在这里插入图片描述

(4)编译测试

make all

发现出现no father found错误

通过查阅后,发现这里需要将kernel/system_call.s

singal 改成16、
sigaction 改成20、
blocked 改成(33*16+4)

在这里插入图片描述
改完之后,再次编译,进入linux0.11
在这里插入图片描述
终于成功了!

参考资料:
哈工大-操作系统-HitOSlab-李治军-实验4-基于内核栈切换的进程切换

哈工大操作系统实验四——基于内核栈切换的进程切换(极其详细)

哈工大操作系统实验5 基于内核栈的进程切换 实验报错:no father found

  • 6
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

辰阳星宇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值