用户级线程
进程 = 资源 + 指令
一个进程
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
总结一下
内核线程切换就是、
- 引起中断进入内核
- 发生阻塞,需要切换线程,首先修改esp所指内容,也就是内核栈切换
-
中断出口,因为进入其他内核栈,实际运行的线程的程序主要在用户态中,因此需要调用
中断出口,返回用户态指向用户态线程程序。
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

linux-0.11中的切换主要是用TSS实现的,主要思路就是
- 将CPU中的内容即eax,ebx等等数据,根据TR所指向的段,保存到里边
- 然后将新的选择子赋值给当前的TR
- 当前的TR再从新的段中找到eax,edx等内容赋值到cpu中运行,其中就包括了esp的赋值,也就是内核栈的切换也在这里完成了。
linux 0.11用tss切换,但也可以用栈切换,因为tss中的信息也可以写到内核栈中。
另外故事ThreadCreate就顺了
创建一个新的进程的时候,使用函数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)
总结一下, 子进程要跳到自己代码处需要做上面?
- 调用exec中断进入操作系统内核
- 调用_sys_execve, 将子进程要执行代码的地址赋值给eax
- 调用_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)
总结一下, 子进程要跳到自己代码处需要做上面?
- 调用exec中断进入操作系统内核
- 调用_sys_execve, 将子进程要执行代码的地址赋值给eax
- 调用_do_execve, 将入口地址赋值给eip[0], 将SS:SP赋值给eip[3], 也就是将一个栈赋值给eip;