视频链接
此篇文章参考资料1
此篇文章参考资料2
此篇文章参考资料3
此篇文章参考资料4
此篇文章参考资料5
此篇文章参考资料6
此篇文章参考资料7
此篇文章参考资料8

)


文章目录

前言

多进程图像是操作系统的核心图像,而进程切换则是多进程图像的核心


一、用户级线程,只切指令不切换资源,而且不进入内核,最简单的切换

1.为什么要从线程开始?

它是学习进程的基础

在这里插入图片描述



2.什么是线程?

线程就是能够交替执行的指令序列

线程切换是基础,把它搞懂后,再加上映射表的切换(映射表切换是内存管理部分),就是进程的切换

在这里插入图片描述





3.来看看线程切换的一个实例

在这里插入图片描述
以下文字转载自此
李老师使用了网页加载的例子展示了多线程的优势。当我们在浏览器的地址栏中输入一个网址的时候,需要从服务器下载网页数据,需要显示文本信息,需要处理图片(如解压缩),还需要显示图片等,这些需求都各自开一个线程,交替执行。特别是网速不好的时候,用户可以感觉到网页是一点点加载出来了,刚开始显示一个轮廓,而后细节越来越清晰。但如果不是多线程交替执行,数据下载完之后再显示,那么就会导致刚开始的时候网页一直卡顿了,用户似乎觉得自己断网了的感觉,不利于用户体验。
李老师举的这个例子中,使用多线程交替执行,合作往前推进。为啥不用多进程呢?如果用多进程,那么从服务器下载数据的进程把数据下载下来放在自己进程的缓冲区,而显示文本信息的进程还需要把这些数据读到自己的缓冲区,这既费时间又费空间!而多线程就没这个问题,多线程共享这些资源,当然还有前述所说的切换开销小等优势!

在这里插入图片描述
在这里插入图片描述
切换是OS能运转起来的发动机!!!!





如果是两个执行序列用的同一个栈的话,会出错

注意①:在call一个函数(也就是跳转)时,有两件事情要做:先将调用函数的下一行指令的地址压入栈中,再进行跳转
②:每个函数最后的 } 是ret返回的意思,它会弹栈

ret原理:先出栈,再将出栈的值放到CPU里的PC寄存器中。因为PC寄存器中永远放的是下一次执行指令的地址。

esp:CPU的物理寄存器,用来指向当前栈的位置(也就是相当于当前的栈)

在这里插入图片描述





从一个栈到两个栈

在这里插入图片描述
下方图片来源处

在这里插入图片描述
下方文字来源处
每个线程拥有一个栈时,线程切换时要先切换栈,通过Yield函数,根据图中yield函数体,可知是通过yield函数其实就是把当前CPU执行的线程的栈保存起来,并且取出需要执行的线程的栈指针,赋给寄存器开始执行
PCB是进程控制块
TCB是线程控制块
yield{
当前线程的栈指针 = 寄存器里的栈指针
寄存器里的栈指针 = 将要运行线程的栈指针
}



在这里插入图片描述
线程创建函数thread_create的核心是用程序做出线程的PCB、栈、保存在栈中的切换的PC(线程的初始地址)
根据图中函数体:
1.申请内存给TCB
2.申请内存给栈
2.把线程的初始地址 放在栈中
3.栈和TCB关联(把指向 初始地址 的 栈顶指针 赋给 TCB)

在这里插入图片描述

4.可以回答为什么要讲用户级线程了

因为用户级线程完全可以实现切换(这是最简单的切换),而且都是由用户主动做的,切换过程没有进入内核,操作系统感知不到

在这里插入图片描述
show的TCB在用户态,OS肯定感知不到

在这里插入图片描述





5.用户级线程总结

核心就是从一个栈切换到另一个栈,每个线程都有自己的栈,栈的PC指针放在TCB中,栈的切换完全由用户实现,所以全部在用户态,操作系统感知不到

内核级线程调度函数:schedule()
用户级线程调度函数:Yield()






二、内核级线程,其实内核级线程包含了用户级线程

进程必须在内核中,没有用户级进程,切换进程就是切换内核级进程

并发:多个事件在同一时间间隔内发生(处理多个任务,但不一定同事执行)

并行:多个事件在同一时刻发生(同时处理多个任务)

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





进入内核的唯一方法就是中断
在这里插入图片描述
SS:栈顶的段地址
SP:栈顶的偏移地址
SS:SP用来指向栈顶元素
CS:表示当前代码段
PC:用户态要执行的下一条指令的地址




S线程,看下图,从100处执行用户程序,A()函数调用B()函数,104入栈,B()函数调用read()函数,204入栈,read()函数执行int 0x80中断,内核栈压入此时用户态的SS/SP/EFLAGS/IP/CS等(此时用户态的PC=304),然后进入内核程序,执行system_call处代码,调用sys_read函数,把1000压入内核栈,进入sys_read函数内执行。
在这里插入图片描述
在这里插入图片描述
???:S线程内核态要执行的下一条指令的地址

以下文字来源处
S线程在内核执行sys_read函数的时候,启动磁盘读,进行I/O操作,于是乎内核就进行调度switch_to(cur, next);(cur是S线程的TCB,next是T线程的TCB),把S线程使用的核分配给T线程,于是乎就要完成S线程切换到T线程,需要把S线程应该执行的下一个指令地址压入S线程的内核栈,并把S线程的现场保存到S线程的TCB中,然后使用T线程的TCB恢复T线程的现场,当然此时esp指向T线程的栈顶,而遇到switc_to函数的右大括号}时就会弹栈,弹出的就是T线程之前执行到的地址,于是乎T线程接着之前执行到的地方继续执行。而此时T线程在内核态执行一些代码后,势必还是会回到T线程的用户态的,那么会遇到IRET,返回到T线程的用户态,怎么返回到T线程的用户态呢?T线程内核栈中弹出之前压入的T线程的用户态对应的CS/IP/SP/SS等信息,恢复到T线程的用户态执行。

TCB保存栈指针,切换栈, 栈指针指向栈,栈恢复现场。
内核栈的必要性:内核也要进行函数线程调用
内核栈和用户栈关联:势必要返回用户态,所以后面的代码势必包含IRET






在这里插入图片描述
所以首先要进入内核,通过中断进入内核,进入到内核之后,一但需要切换,
找到TCB,TCB切换,相应的内核栈切换,内核栈切换时用户栈也跟着切换,然后IRET返回到用户态

在这里插入图片描述





内核线程switch_to的五段论

在这里插入图片描述



以下文字出处

1.进入中断,五参数压栈;
2.进行中断处理,可能会阻塞引起调度;
3.若要调度切换,先找到下一线程的TCB;
4.根据TCB,switch_to完成内核栈的切换;
5.通过iret从内核栈返回用户栈。
这里要注意,中断出口这里已经经过了前面的switch_to,中断的iret已经不是原先的中断返回了,是切换后的新中断的执行返回!!!这样返回以后就来到了引发该新中断的用户态代码来执行

下方图片出处
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述









三、 内核级线程的实现

Linux0.11中只有内核级进程

内核级进程实现=内核级线程实现+资源分配

在这里插入图片描述

1.根据切换的五段论,我们从中断开始

fork函数会创建子进程,而我们切换(当父进程阻塞时)就是切换到子进程,而不是切换到其他进程

main()
{
A();
B();
}
A();
{
fork();
}

首先进入A函数,把B函数的起始地址入栈,在A中执行的时候遇到fork()这个创建进程的系统调用,引起中断,其中内核栈中的ret就是中断返回的地址res,而res=eax。
在这里插入图片描述
不过需要注意的是父进程和子进程的eax是不一样的,这个后面会说到。
因为父进程进入内核后总得返回,如果引起了切换,切换到子进程,然后子进程又是父进程创建的,那么我返回到用户态的时候还是执行父进程的代码吗?
肯定不是,所以从中断返回后,父进程执行mov res,%eax,子进程也执行mov res,%eax。res是fork函数的返回值,2但两者的eax是不一样的,这正好分开了父、子进程。

2.进入中断处理函数system__call

在这里插入图片描述

//文件位置: ~/oslab/linux-0.11/kernel/system_call.s
system_call:
	cmpl $nr_system_calls-1,%eax
	ja bad_sys_call
	push %ds
	push %es
	push %fs
	pushl %edx
	pushl %ecx		# push %ebx,%ecx,%edx as parameters
	pushl %ebx		# to the system call
	movl $0x10,%edx		# set up ds,es to kernel space
	mov %dx,%ds
	mov %dx,%es
	movl $0x17,%edx		# fs points to local data space
	mov %dx,%fs
	/ / 上面这些都是用来保存用户态中信息的
	call sys_call_table(,%eax,4) # call sys_fork 
	/ / 这在上节的系统调用中讲过,实际上的中断处理函数就是通过查sys_call_table去处理
	pushl %eax
	//还有部分代码未展示

可以看到前面都是一些push指令,为什么要这么做呢?
因为我们此时刚进入内核,CPU中的寄存器保存了用户态中的信息,所以将这些内容压栈到内核栈中,用以保护现场

在sys_fork执行过程中可能需要切换到另外一个线程,它是如何切换的?其实也就是通过判断

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

current指的是当前进程的PCB(或者说是当前线程的TCB,在这里线程和进程都一样,因为Linux0.11只有内核级进程),把它赋给eax
判断条件1

cmpl $0,state(%eax)
// state(%eax)是一段汇编代码,实际上是state加上eax
jne reschedule

判断当前进程是否阻塞,如果阻塞(state非0),那么就调度(reschedule),然后切换

判断条件2

cmpl $0,counter(%eax)
/ / 注意counter(%eax)不是counter=eax
je reschedule

判断当前进程的时间片(counter是时间片的意思,每个进程都被分配了一个时间片)是不是用完了(counter=0),用完了的话也要切换

怎么调度的?

reschedule:
pushl $ret_from_sys_call
jmp _schedule

进入到reschedule函数中,首先把返回函数的地址入栈,然后进入到真正实现调度和切换的函数schedule中。为什么要先把$ret_from_sys_call入栈呢?
不要忘了我们到内核栈中走一圈后还是得返回到用户态中去的,切换完内核栈后当然得返回到用户态中去执行新的代码


捋一捋这个过程,就是我们先通过中断进入到内核中,OK,先把它用户态里的内容保存下来,然后执行中断处理函数sys_fork(),执行过程中判断它的状态是不是阻塞了或者说它的时间片是不是用完了,如果阻塞或者时间片用完了的话,我们就要切换到其他进程,切换完后,通过中断出口返回(这个返回就是从内核态到用户态)




3.现在到了schedule和中断出口

在这里插入图片描述
schedule分为两部分:首先找到下一个进程,然后切换

void schedule(void){ 
next=i; / / 找到下一个进程
switch_to(next);/ / 这就是真正切换的函数了
 }

执行完这个线程之后就会转到ret_from_sys_call执行,首先将进入内核时存储的寄存器的值弹栈,也就是将当前状态的寄存器值恢复到执行本线程的状态。接着利用iret转到用户态,接着执行。

ret_from_sys_call:
popl %eax 
pop %fs ...
iret//重要

在ret_frome_sys_call程序段中做的事情主要是弹栈。不过值得注意的是,是先切换完栈后再返回,所以这里的弹栈应该是对另一个线程的内核栈进行弹栈。内核栈中的SS, SP指向用户栈,CS, PC指向下一句程序执行的位置,切换后的内核栈和用户栈联系在一起,接着利用iret退出中断转到用户态,在用户态继续执行另一个线程的程序。





4.switch_to具体怎么实现的呢


在linux-0.11中是根据tss来切换的。TSS全称是Task State Segment,是TCB的一个子段。
Intel 架构不仅提供了 TSS 来实现任务切换,而且只要一条指令就能完成这样的切换,即 ljmp 指令。
在这里插入图片描述

主要就是下面这两句话

 ljmp %o\n\t/ / 长跳转指令,先把当前CPU中的所有寄存器拷贝到当前TR所指向的TSS段中,上一个任务的现场被保存下来,这是跳转之前的准备工作
 / / 然后开始跳转了,把新的选择符置给TR,实际上就是TR变成新的值了
 / / TR一变,那么就会找到新的TSS描述符,描述符是用来指向段的指针
 / / 也就是用TR找到描述符,用描述符找到TSS
"d"(_TSS(n))/ / 将线程二的tss段中的值赋给cpu的寄存器
/ / 所以TR变,CPU的内容也会跟着变,因为TSS里面保存了当前CPU的内容段

所以过程并不复杂,要跳转的时候把选择符置给TR,TR找到新的TSS,把新的TSS扣给CPU就完事了(当然扣之前还要给原来的内容保存下来)
其实就和看电视一样,如果从节目一切换到节目二,就要先将节目一切换前的一幕存在脑海里面,以便于下次再看这个节目的时候能“不间断”的看。
切换就讲完了,然后就返回到用户态去执行新的代码。





5.来看看fork真正的中断处理函数,sys_fork()创建子进程

在这里插入图片描述
核心就是call_copy_process函数,这个函数有很多参数

int copy_process(int nr,long ebp,
long edi,long esi,long gs,long 
none,long ebx,long ecx,long edx, long 
fs,long es,long ds,long eip,long 
cs,long eflags,long esp,long ss)
/ / 这些参数都是父进程在用户态执行的时候的样子,
我们知道,父进程进入内核态的时候他会把CPU中寄存器的值压栈(用以保存用户态的信息)
/ / 所以这些参数其实都来自父进程内核栈中
/ /C函数中,越在后面的参数就越早压入栈中
/ / 所以位置越靠前的参数,越靠近栈底。
esp>ss:sp esp j就是标记栈的
eip>ret=??1 。eip就是int 0x80指令执行完后的下一句代码

有了这些参数就能知道父进程长什么样子了。在_copy_process 基本能做出和父进程一样的叉子(进程)父进程和子进程只有一个地方的小差别。

在这里插入图片描述



6.具体剖析一下copy_process函数

在这里插入图片描述
我们这里是使用TSS切换的,所以不需要填写两个stack;
在这里插入图片描述
下面这个task_struct就是PCB,上面是内核栈。(所以PCB内核栈都做好了)

1:p=(struct task_struct *)get_free_page();
/ /申请一页内存空间创建PCB 
/ / 注意绝对不能用malloc()因为malloc是用户态代码,现在可不是用户态

2:p->tss.esp0 = PAGE_SIZE + (long) p;/ / PAGE_SIZE=4K,p是刚才申请的那一页内存空间的初始地址
/ / esp0指的是内核栈
/ / 如上图。esp0=初始地址p再加上4k(这个4k就是偏移量)
/ / 所以此时esp0指向的就是内核栈栈顶
p->tss.ss0 = 0x10;
/ /创建内核栈,0x10是内核数据段

3:p->tss.ss = ss & 0xffff;
p->tss.esp = esp;
/ /创建用户栈(和父进程共用栈)
/ / ss和esp都来自与copy_process参数,而参数从父进程内核栈传来
/ / esp就是用户栈,esp0和esp具体指向内核栈还是用户栈是已经硬性规定好的
/ / 这不就说明子进程的用户栈=父进程的用户栈吗

①:1、2、 3部分的代码完成了PCB、内核栈、用户栈的创建以及栈与PCB的关联
而2、3部分的代码则是设置好了TSS

②:子进程与父进程用户栈一样的,不过内核栈不一样





在这里插入图片描述
到此,子进程就创建好了,父进程调用fork,可能因为某些原因阻塞,所以要切换,切换到子进程,子进程就把已经初始化好的TSS扣给CPU就完事。
切换好后,返回到用户态,但要执行什么代码呢??(注意:子进程的eax不等于父进程的eax,eax会置给res,res就是fork的返回值)






7.第三个故事: 如何执行我们想要的代码?根据返回值不同,各走各的路

eax=0就是子进程;eax≠0就是父进程

父进程因为阻塞切换到了用户态,但两者共用一个用户栈,从内核态返回到用户态后子进程执行什么?还是原来的内容吗?

执行exec,他会替换掉子进程

在这里插入图片描述

int main(int argc, char * argv[])
{ while(1) { scanf(%s”, cmd);
if(!fork()) {exec(cmd);} wait(0); }
/ / 这里有个条件判断语句,根据fork的返回值
/ / 我们知道子进程与父进程的eax是不一样的,eax代表的就是fork函数的返回值
/ / if(!fork)所以当返回值为0时,也就是子进程返回后,它执行exec(cmd)
/ / exec是个系统调用
/ / 执行exec后,子进程又要进内核一段折腾,折腾完后返回到用户态
/ / 第二次进内核返回后,子进程内容会被替换掉,开始执行新代码

捋一下,其实就是子进程用了父进程创建的壳子去干自己的事情(子进程的eax=res=0)





8.开始讲讲exec这个系统调用

参考博客1
参考博客2

①:在exec()函数执行工作前,父进程与子进程实际上在干同一件事。exec就是用来替换子进程内容的
②:子进程执行exec又要进内核。进了肯定会iret返回,返回后要执行新代码,所以我们的重点之一不就是在这个iret上动手脚吗?
③:让子进程iret返回后跳到新的代码去执行,也就是ret置给真正要执行的eip(也就是PC)

在这里插入图片描述

④:所以关键就是找到要执行的代码的地址,赋给ret,将来iret弹出这个ret,赋给PC,我们不就达到去执行新代码的目的了吗


在这里插入图片描述

_sys_execve:
lea EIP(%esp),%eax
/ / esp是当前栈指针,值是0。如上图,就在esp+0x1c的上面
/ / 这句话的意思是把esp,eip加起来赋给eax,eax进行压栈,
EIP=0x1c 十进制是28,正好跳到28,也就是ret=???(eip)最后会指向PC。
pushl %eax
call _do_execve
EIP = 0x1C

pushl %eax是因为do_execve也是要有参数的,实际上压进去的参数是esp,eip。

在这里插入图片描述
eip[0]=ex.a_entry,ex.a_entry是可执行程序入口地址,把它置给eip
而eip[3] 指向SS:SP
ex.a_entry哪里来的?可以读文件 ls是一个可执行命令 ls hello ,hello 是一个可执行程序,这个程序在磁盘上有,从磁盘上把这个程序读进来,就有一个文件头,这个文件头就有a_entry,a_entry是编译的是时候作为可执行文件写进去的。实际上是连接做为可执行文件程序写入的,写进去就放给ex.a_entry。在读文件读出来置给内核栈中,置好内核栈后,在中断返回iret时候就弹回去了,就执行hello的第一句话。

总结一下

exec也即execve系统调用 将程序装到新建的进程壳子中
具体就是先调用execve,触发软终端,调用系统调用_sys_execve,再调用c函数_do_execve,在_do_execve中将内核栈做成新进程的样子,主要是
1)改动内核栈中返回地址的内容即图中ret内容,将返回地址置为要调用的程序入口
2)改动内核栈中用户栈地址内容即图中SS:SP内容,将用户栈地址置为新分配的用户栈空间这样在iret返回的时候,会返回到新程序入口去执行,并且跳转到对应的新分配的用户栈,这样就将新的程序,新的用户栈,新的程序地址和新的PCB关联起来了,以后保存到tss也是新的用户栈了,新的内核栈在fork中就已经保存到对应的tss中了

其中最重要的几句代码

eip[0]=ex.a_entry        
/ /ex.a_entry是可执行程序入口地址,把它置给eip
p = create_table;       / /为新进程分配用户栈空间
eip[3] = p;             / /将新的用户栈空间放到SS:SP


exec要让子进程执行另一个程序,那么它的用户态的ss:sp和内核栈返回用户栈的iret当然都要变,这个iret就是内核态返回用户态的入口

在这里插入图片描述

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐