天天看点

用户级线程和内核级线程--OS用户级线程内核级线程

用户级线程

进程 = 资源 + 指令

一个进程

mov ...  	mov[100], ax 	write ax
           

一个进程每执行一条指令,可能需要访问一个地址,这个地址就从映射表中获取,而每一个进程就有一个对应的映射表,主要是为了防止进程之间访问同一个内存地址而设置的,当我们从一个进程切换到另外一个进程的时候,就需要切换映射表,而切换映射表需要一些代价,因此我们引进线程。

一般而言:

进程 = 资源 + 指令

资源就是映射表,因为映射表对应的就是一段段的内存地址。

而线程就是希望将资源和指令执行给分开,实现一个资源 + 多个指令执行序列。

因此线程切换时,只需要改变PC值和一些寄存器的值,而不需要修改内存即映射表,这样线程就既保留了并发的优点,同时避免了进程切换的代价。

线程的切换,就是指令的切换

使用线程的例子

一个网页浏览器的打开

  • 一个线程用来从服务器接受数据‘
  • 一个线程用来显示文本
  • 一个线程用来处理图片(如解压缩)
  • 一个线程用来显示图片

为什么一个网页的打开需要使用线程?

如果使用进程来实现网页的打开

那么一个进程首先从服务器接收数据,接受完全部数据后再切换其他进程实现文本图片的显示,这样,其他进程的映射表就找不到第一个进程接受数据所在的地址,因此需要拷贝过去,效率低,另外,当一个进程在接受数据的时候,是将数据全部读取完成后再显示,用户交互性不好。

因此,我们才使用进程实现网页的打开

一个线程从服务器接受数据的时候,当接受完文本数据,可以切换指令到另外一个线程将文本显示出来,再重新回到线程接受数据,而其他线程显示数据也不需要另外拷贝数据,因为线程数据是共享的,因此线程在实现网页的打开要比使用进程优秀。

开始实现这个浏览器

void WebExplorer()
{
	char URL[] = "http://cms.hit.edu.cn";
	char buffer[1000];
	pthread_create(..., GetData, URL, buffer);
	pthread_create(..., show, buffer);
}
void GetData(char *URL, char *p)
{...}
void show(char *p)
{...}
           

线程切换的实现

线程切换的实现靠的是栈的切换

为什么要切换栈?

下面看两段代码

//线程1
100: A()
{
	B();
	104;
}
200: B();
{
	Yield();
	204;
}
//线程2
300: C()
{
	D();
	304;
}
400: D();
{
	Yield();
	404;
}
void Yield()
{
	找到300;
	jmp 300;
}
           

如果要实现进程的切换,那么就需要调用函数,根据C语言调用函数的实现,我们每调用一个函数

需要在栈中保存函数返回地址,那么从线程1到线程2的切换,我们将栈中的保存值写下来

104 // 栈底
204
304
404 // 栈顶
           

当我们在D()函数执行完之后,遇到}时会执行ret,也就是从栈中弹出一个地址出来,这时候

弹出来的地址为404, 这显然不是我们想要的,因为,我们是从线程1切换到线程2执行,执行

完之后,我们需要重新回到线程1来,但是现在,弹栈弹出来的是404,为什么会这样,因为我们

两个线程使用的是同一个栈,使用同一个栈无法实现线程的切换,因此,我们需要将栈分开,也就是

一个线程使用单独的一个栈,这就是实现线程切换的关键

因此,我们在TCB中设置一个栈结构,当我们需要切换线程时,使用Yield函数,能够实现栈的切换

void Yield()
{
	TCB2.esp = esp;
	esp = TCB.esp
}
           

这样就实现了线程的切换。

两个线程的样子:两个TCB,两个栈,切换的PC在栈中

ThreadCreate的核心就是用程序做出这三样东西

void ThreadCreate的核心就是用程序做出这三样东西
{
	TCB *tcb=malloc();
	*stack = malloc();
	*stack = A;
	tcb.esp = stack;
}
           

将所有东西组合在一起

void WebExplorer()
{
	ThreadCreate(GetData, URL, buffer);
	while(1)
		Yield();
}
void GetData(char *URL, char *p)
{
	连接URL;
	下载;
	Yield();
}
void ThreadCreate(func, argv1)
{
	申请栈;
	申请TCB,func等入栈;
	关联TCB与栈;
}
void Yield()
{
	压入现场;
	esp放在当前TCB;
	next();
	从下一个TCB中取出esp;
	弹栈切换线程;
}

gcc -0 explorer get.c yiled.c ...
           

内核级线程

核心级线程

用户级线程与核心级线程的区别

前面学习用户级线程时,我们知道,线程的切换用到的是栈,从一个栈到两个栈,每一个线程

使用自己的一个栈,而到了用了核心级线程,意味着线程既要在用户级线程上切换,还要到内核

上切换,因此,需要用到的是用户栈和内核栈,也就是从两个栈到两套栈

用户态线程切换是,TCB切换,然后再切换用户栈

核心级线程切换是,TCB切换,然后切换两套栈,用户栈也要跟着内核栈一起切换

用户栈和内核栈之间的关联

从用户态到内核态,主要就是靠中断实现,当中断出现时,内核栈就会保留用户栈的地址(SS, SP)

还有用户态执行到何处的指令即PC和CS值,这就是一套栈的实现。

下面看一个例子:

100: A()
{
	B();
	104:
}
200: B()
{
	read();
	204:
}
300: read()
{
	int 0x80;
	304:
}
system_call:
	call sys_read;
1000:
2000: sys_read()
{}

	用户栈
	104
	204

	内核栈(线程S)
	SS: SP   // SS指向用户栈的首地址,SP指向指令执行的位置
	EFLAGS
	304   // 调用中断后保存的返回地址
	cs   // cs指向 100,中断返回地址;
	1000
           

当进入磁盘读时,就会引起阻塞,此时开始进程的切换

switch_to

sys_read()
{
	next;
	switch_to(cur, next);  //找到下一个线程,cur,next就是当前线程和下一个线程的TCB
}
//switch_to: 仍然是通过TCB找到内核栈指针;然后通过ret切换到某个内核程序;
最后再用CS:PC切到用户程序

		线程S		线程T
		SS: SP		EFLAGS
		EFLAGS		PC = ???
		304			CS =  ???
		cs			????  <--esp
		1000
 -->esp ??? 

//切换线程就是将当前的esp赋给cur.esp,然后将next.esp赋给当前的esp;
//PC和CS是什么?其实线程T主要是代码在用户态中,进入内核态主要是为了
进行线程的切换,因此PC和CS实际上就是线程T的用户态代码所在的地址。
为了能够从线程T重新回到用户态线程,esp所指向的代码处应含有iret代码,也就是
中断返回代码。
           

内核线程switch_to的五段论

中断入口:(进入切换)
push ds; ... pusha;
mov ds, 内核段号; ...
call 中断
??:

中断处理:(引发切换)
启动磁盘读或时钟中断;
schedule();
}//ret

schedule: next=..;
call switch_to;
}//ret

switch_to:(内核栈切换)
TCB[cur].esp = %esp;
%esp = TCB[next].esp;
ret

中断出口:(第二级切换)
popa; ...; pop ds;
iret
           

总结一下

内核线程切换就是、

  1. 引起中断进入内核
  2. 发生阻塞,需要切换线程,首先修改esp所指内容,也就是内核栈切换
  3. 中断出口,因为进入其他内核栈,实际运行的线程的程序主要在用户态中,因此需要调用

    中断出口,返回用户态指向用户态线程程序。

ThreadCreate!做出那个样子

void ThreadCreate(...)
{
	TCB tcb = get_free_page();
	*krlstack = ...;
	*userstack传入;
	填写两个stack;
	tcb.esp = krlstack;
	tcb.状态 = 就绪;
	tcb入队;
}
           

#内核级线程实现

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

main()
{
	A();
	B();
}
A()
{
	fork();   --->系统调用引起中断, 
}
遇到fork;
	mov %eax, __NR_fork
	int 0x80
	mov res, %eax
引起中断后,就会调用_system_call
void sched_init(void)
{
	set_system_gate(0x80, &system_call);
}
_system_call初始化时,就会将中断处理设置好,将用户线程的内容压入内核栈中。
_system_call:
	push %ds..%fs 
	pushl %edx...
	call sys_fork									  内核栈
	push %eax  // 将线程的信息保留在内核栈中			 SS: SP
	mov1 _current, %eax								   EFLAGS
	cmpl $0, state(%eax)  //判断状态是否阻塞			 ret = ??1 //调用int 0x80中断后面的位置
	jne reschedule	//如果状态阻塞,就切换线程			  ds
	cmpl $0, counter(%eax)  //如果时间片用光了			  es
	je reschedule  //切换线程							 fs
	ret_from_sys_call:  //中断返回						 edx
														ecx
	reschedule:											ebx
		pushl $ret_from_sys_call						??2
		jmp _schedule
	// 这段代码就是将ret_from_sys_call压入栈中,然后调用 _schedule函数
	当函数遇到右括号时,就会从栈中弹出一个地址,此时就会执行中断返回

void schedule(void)
{
	next = i;
	switch_to(next); //下一个核心级线程的TCB
}
ret_from_sys_call:
	pop %eax //返回值 	popl %ebx  ... 			就是将前面压入栈中的东西弹出来
	pop %fs ...
	iret //重要, 返回到int 0x80后面执行, mov res, %eax
           

切换五段论中的switch_to

用户级线程和内核级线程--OS用户级线程内核级线程

linux-0.11中的切换主要是用TSS实现的,主要思路就是

  1. 将CPU中的内容即eax,ebx等等数据,根据TR所指向的段,保存到里边
  2. 然后将新的选择子赋值给当前的TR
  3. 当前的TR再从新的段中找到eax,edx等内容赋值到cpu中运行,其中就包括了esp的赋值,也就是内核栈的切换也在这里完成了。

linux 0.11用tss切换,但也可以用栈切换,因为tss中的信息也可以写到内核栈中。

另外故事ThreadCreate就顺了

用户级线程和内核级线程--OS用户级线程内核级线程

创建一个新的进程的时候,使用函数copy_process, 其中的参数都是从栈中取出,也就是从父进程中

拷贝而来

copy_process的细节:创建栈

p = (struct task_struct *)get_free_page();
//申请内核空间
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;  //0x10是内核数据段
//创建内核栈
p->tss.ss = ss & 0xffff;
p->tss.esp = esp;
//创建用户栈(和父进程共用栈), esp 就是从父进程中的栈中传递过来的参数
           

上面的代码实现了1、申请内存空间,2、创建TCB,3、创建内核栈和用户栈,4、关联两个栈和TCB

copy_process的细节:执行前准备

p->tss.eip = eip;
p->tss.cs = cs & 0xffff;
//将执行地址cs:eip放在tss中
p->tss.eax = 0;
p->tss.ecx = ecx;
//执行时的寄存器也放进去了
...
p->state = TASK_RUNNING;
           

将上面所有的代码捋一下

在前面的fork中断中,假如进程遇到阻塞了,这时就会调用reschedule,调用了reschedule,就会

调用switch_to(next), 在switch_to(next)中,我们会进行tss切换,也就是切换进程,由于子进程

是从父进程得到的eip,因此,子进程与执行父进程后面的代码,也就是mov res %eax, 而子进程的eax

被置为0,在此处,我们就会看到进程切换的一段经典代码

if(!fork()){}

,此时进程就切换到子进程

运行了。

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

int main()
{
	while(1)
		scanf("%s", cmd);
	if(!fork())
	{
		exec(cmd)l
	}
	wait(0);
}
// 切换进程时
p->tss.eip = eip;
p->tss.cs = cs;
p->tss.eax = 0;

if(!fork()){exec(cmd);}
_system_call:
	push %ds .. %fs
	pushl %edx..
	call sys_execve

exec为系统调用,当子进程为进入exec时,子进程与父进程执行相同的代码,当子进程进入exec并且退出来后
就要去执行自己的代码了。

那么子进程是如何从中断返回后跳到执行自己代码的地方呢?

中断返回时会执行`iret`,而`iret`做了上面,`iret`在内核栈中找到eip并置给真正的eip;
因此,我们需要做的就是找到子进程要执行的代码的PC地址,并赋值给内核栈中的eip,而当子进程
执行`iret`指令后,就会跳转到自己的代码处执行了。

_sys_execve:
	lea EIP(%esp), %eax  将当前的栈指针加上EIP赋值给eax
	pushl %eax
	call _do_execve
EIP = 0x1C

int do_execve(*eip, ...)
{
	p += change_ldt(...;
	eip[0] = ex.a_entry
	eip[3] = p;)
}
struct exec{
	unsigned long a_magic;
	unsigned a_entry; //入口
};
eip[0] = esp + 0x1C;
eip[3] = esp + 0x1C + 0x0C = esp + 0x28(正好是SP)
           

总结一下, 子进程要跳到自己代码处需要做上面?

  1. 调用exec中断进入操作系统内核
  2. 调用_sys_execve, 将子进程要执行代码的地址赋值给eax
  3. 调用_do_execve, 将入口地址赋值给eip[0], 将SS:SP赋值给eip[3], 也就是将一个栈赋值给eip;
    l %eax
     		call _do_execve
     	EIP = 0x1C
    
     int do_execve(*eip, ...)
     {
     	p += change_ldt(...;
     	eip[0] = ex.a_entry
     	eip[3] = p;)
     }
     struct exec{
     	unsigned long a_magic;
     	unsigned a_entry; //入口
     };
     eip[0] = esp + 0x1C;
     eip[3] = esp + 0x1C + 0x0C = esp + 0x28(正好是SP)
               

总结一下, 子进程要跳到自己代码处需要做上面?

  1. 调用exec中断进入操作系统内核
  2. 调用_sys_execve, 将子进程要执行代码的地址赋值给eax
  3. 调用_do_execve, 将入口地址赋值给eip[0], 将SS:SP赋值给eip[3], 也就是将一个栈赋值给eip;

继续阅读