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
指令触发后,就会通过计算机硬件上的寄存器来找到此时的线程所对应的内核栈。然后,就会将刚才用户所执行的用户栈的ss
和sp
信息压入内核栈。同时,也会将刚才的pc
和cs
信息压入内核栈,即把刚才在用户态所执行的地方压入内核栈。内核栈通过指针把两个栈拉在一起。
与之相对的是执行IRET
指令时,就会将内核栈的数据弹出,又会退回到用户栈,即回到之前在用户态下所执行的地方。
随之程序的执行,执行到int 0x80
时,进入内核栈,将ss
、sp
、pc
和cs
信息压入内核栈,再压入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就会马上找到当前的内核栈,压入当前的ss
和sp
以及cs
和ip
(int 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
判断_current
的counter
是否等于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
存入到对应的寄存器当中。最后,再执行iret
将ss
和sp
以及cs
和ip
一起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)置给TR
,TR
会作为选择子而指向新的TSS描述符,从而指向新的TSS段。
缺点: 使用TSS(Task Structe Segment任务结构段)进行切换执行起来慢,用一条指令做的不能进行指令流水。因此,我们需要从基于TSS的切换而变到Kernel Stack切换。
(4)内核级线程切换的总结
实际上核心代码就三句:int 0x80
、ljmp
、iret
,再加一些其他代码将其包裹起来就完成了内核级线程的切换。
4、创建线程 / 进程 copy_process
linux0.11里面没有线程的概念,但进程和线程在实现上的区别主要在于是否变动映射表,而其余创建等操作是相同类似的,因此这里通过讲解创建进程copy_process
来解释创建线程。
fork()
继续往下走,就到了call _copy_process
语句,父进程复制自己而创建出一个和自己基本一样的子进程。而这个函数的参数就是之前压入栈中的线程信息。
get_free_page()
:找到mem_map
=0的那一页并且把这个地址返回,再对其进行强制类型转换,将其地址交给指针p
。此时这个块也是内核栈的地址,也就相当于此语句不仅申请了PCB的内存空间,还创建了内核栈的地址空间,这块地址用来做PCB
。
然后,初始化TSS。
esp0
和ss0
是内核栈。p
所指向的是PCB的初始地址,然后再加上4K(PAGE_SIZE)一页的大小,赋值给esp0
。将内核数据段0x10
赋值给ss0
。
ss
和esp
是用户栈。使用父进程的ss
和esp
作为用户栈,和父进程共用栈 。
将eip
置给TSS中,也就是当前父进程的IP位置,即执行fork()
语句的下一句话的地址。
其中让eax
置为0,是为了让if(!fork())
语句生效,从而让子进程执行这里面的程序,实现父子进程分离。
子进程用了父进程创建的壳子,然后在这个壳子里执行自己的程序。
子进程在执行exec
语句之前,父进程和子进程用的是同样的代码。而子进程一旦进入if(!fork())
中执行exec
后,子进程在未来从内核态退回到用户态时(中断返回时),子进程就会执行新的代码(ls
这个程序的entry
代码)。
其中中断返回时,实际上执行的是iret
指令,而该指令即就是找到存放ss
、sp
、EFLAGS
、cs
、ip
的栈,然后把栈里面存的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()
中将entry
(ls这个可执行文件的入口地址)置给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 实现去掉,写成一段基于堆栈切换的代码。
本次实验包括如下内容:
- 编写汇编程序
switch_to
: - 完成主体框架;
- 在主体框架下依次完成 PCB 切换、内核栈切换、LDT 切换等;
- 修改
fork()
,由于是基于内核栈的切换,所以进程需要创建出能完成内核栈切换的样子。 - 修改 PCB,即
task_struct
结构,增加相应的内容域,同时处理由于修改了task_struct
所造成的影响。 - 用修改后的 Linux 0.11 仍然可以启动、可以正常使用。
- (选做)分析实验 3 的日志体会修改前后系统运行的差别。
(1)修改schedule让其指向目标进程的PCB
1)GDT与LDT
1 段描述符表 GDT
相当于是GDT为全局共享区,LDT是一个或多个任务的私有区。
2 段选择符
- 一个系统中可以定义很多段,但同时只有
6个段
可供立即访问,若要访问其他段就需要加载这些段的选择符。 - 为每个段寄存器在配备一个“影子寄存器”(也称“描述符缓冲”),当描述符表中的描述符做过任何改动后,就立刻重新加载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)的切换。
内核栈的具体情况如下图所示:
在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
寄存器中保存的是下一个进程的PCB,current
是当前进程的PCB。如果两个进程相同,跳转到1f
位置处,后面的代码不用执行了,什么都不会发生。否则,执行切换PCB等后续操作。
虽然看起来完成了挺多的切换,但实际上每个部分都只有很简单的几条指令。完成 PCB 的切换可以采用下面两条指令,其中 ebx 是从参数中取出来的下一个进程的 PCB 指针,
! 切换PCB
movl %ebx,%eax
xchgl %eax,current ! 数据交换指令
经过这两条指令以后,eax
指向现在的当前进程,ebx
和全局变量 current
都指向下一个进程。
TSS 中的内核栈指针的重写可以用下面三条指令完成,其中宏 ESP0 = 4
,struct 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:ESP
,CS: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 表,而是直接用其隐藏部分中的基地址 0
和 100
累加直接得到 PC,增加了执行指令的效率。现在想必明白了为什么重新设置 fs=0x17
了吧?而且为什么要出现在切换完 LDT 之后?
(3)修改fork
开始修改 fork() 了,和书中论述的原理一致,就是要把进程的用户栈、用户程序和其内核栈通过压在内核栈中的 SS:ESP
,CS: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-基于内核栈切换的进程切换