【README】
1.本文内容总结自 B站 《操作系统-哈工大李治军老师》,内容非常棒,墙裂推荐;
【说明】
- 本文中提到的父线程可以理解为父进程
- 因为进程包括运行资源和执行指令,又执行指令表示为线程,所以也可以说 进程包括 运行资源和线程;
- 在执行指令或代码层面,可以理解进程与线程类似(但线程切换成本更低,这是他们的很大区别);
2.进程包括资源与执行序列,其中执行序列用线程来实现,线程分为用户线程和内核线程;进程中的资源管理主要是内存管理;
3.线程在操作系统的代码实现: 通过内核级代码 加上 内存管理代码 ;
【补充】线程切换步骤:
- 1. 用户栈切换到内核栈;
- 2. 内核栈找到tcb(线程控制块);
- 3. Tcb切换到其他线程的tcb2;
- 4. 通过tcb2找到内核栈2;
- 5. 通过内核栈2切换到用户栈2;
【1】进入内核
1) fork:
- 是一个系统调用,用于创建进程,即创建资源和执行序列,其中创建执行序列就是创建线程;
2 )fork代码的工作:
- 工作1)线程怎么切换的;即 切换五段论 怎么具体实现的;
- 工作2)创建一个内核线程需要做哪些事情?
切换五段论参见 8.内核级线程(核心级线程)_PacosonSWJTU的博客-CSDN博客
3)具体代码
fork系统调用会展开为
mov %eax, __NR_fork ; // 把系统调用号 __NR_fork 送入 eax寄存器;
INT 0x80 ; // 触发80号中断;
Mov res, %eax;// 中断处理完成后执行的下一条指令;
例:__NR_write 是系统调用号,用来标识是哪种系统调用; |
4)Int0x80 中断指令发起后,cpu做以下事情
- 找到当前线程的内核栈;
- 压入当前线程的物理寄存器ss,sp到内核栈;
- 压入当前线程的物理寄存器cs,ip到内核栈;
5)Int80的中断处理函数是 system_call 系统调用;
- 在执行 system_fork(system_read或system_write)过程中, 碰到磁盘读写等情况,需要阻塞以等待,操作系统就会从当前内核线程 切换到 其他内核线程去执行;
【2】切换五段论
【2.1】切换五段论中的中断入口和出口
1)内核栈的内容:存储了用户态所有物理寄存器的值;
2)系统调用 _system_call 源码
_system_call: Push %ds..%fs // 把用户态的物理寄存器值压入到内核栈; Pushl %edx // 把用户态的物理寄存器值压入到内核栈; Call sys_fork // 通过系统调用号查询系统调用表,获取系统调用函数是 sys_fork ;系统调用如 sys_fork ,sys_write,sys_read 因读写磁盘可能导致当前线程阻塞,操作系统就会切换到其他内核线程执行; Pushl %eax // 把 eax寄存器值压入内核栈; |
Movl _current, %eax // _current 就是 tcb 线程控制块,送入eax寄存器; Cmpl $0,state(%eax) // 判断pcb.state是否等于0;(不等于0表示当前线程阻塞,0表示就绪) Jne reschedule // 不等于0,则重新执行线程调度算法reschedule(切换到其他内核线程与内核栈,或五段论的中间三段) Cmpl $0, counter(%eax) // pcb.counter 时间片是否等于0; je reschedule // 时间片等于0,即时间片用光了,也要切换到其他内核线程 ret_from_sys_call: // 中断返回(系统调用返回地址) |
3)reschedule 线程调度算法(五段论中的三段),切换到其他内核线程
Pushl $ret_from_sys_call // 把中断返回地址,或系统调用返回地址压入到内核栈;
Jmp _schedule // 调度函数
【补充】
- schedule 执行完成后,一定要执行 ret_from_sys_call 进行中断返回;
【2.2】切换五段论中的 schedule 和 中断出口
1)中断入口是 push;中断出口是 pop;
2)Ret_from_sys_call: 中断返回代码(系统调用返回函数代码);
Popl %eax // 返回值 Popl %ebx
Pop %fs
Iret // 内核栈所有寄存器值弹出到物理寄存器,实现线程切换;
3)void schedule: 线程切换调度
调用 switch_to(next)
void schedule(void)
{
Next = I; // 找到下一个线程的tcb
Switch_to(next); // 切换
}
【2.3】 切换五段论中的 switch_to(切换tss)
1)tss定义: task struct segment,任务结构段,用任务结构段进行切换;
tss 保存了线程的所有寄存器值;如下图所示。
2)基于tss的切换 变为 基于 kernel stack(内核栈);
tss 用一句长跳转指令 ljmp %0 完成切换,其中长跳转指的是跳转到其他段,但ljmp指令执行慢,所以需要转换到 内核栈切换;
- tss是一个段,GDT中存在段描述符项,指向tss段基址;如GDT的原TSS描述符指向原TSS;
3)TR寄存器
- 用 TR选择子(TR寄存器值) 从 GDT中找到 TSS描述符,进而找到TSS段;
- 长跳转指令 ljmp,指的是段与段之间的跳转,实际上是段寄存器值变化,即GDT中新TSS段描述符赋值给 TR寄存器,其中新TSS段描述符由 _TSS(n) 赋值,_TSS(n)中的n是上文中的next指针,是下一个线程的“段寄存器基址CS”;
4)TR寄存器存储的是当前cpu的任务段
- TR寄存器值指向了 GDT表的一个tss描述符,通过tss描述符可以定位到tss结构体的内存地址,tss结构体存储了所有物理寄存器的值(快照);
- 只要TR改变,则cpu载入的tss(任务结构段)也会跟着切换,因为cpu中物理寄存器被tss中逻辑寄存器值快照覆写了,即内核线程跟着切换;
【补充】ljmp 长跳转指令
- 步骤1)把cpu所有寄存器的内容送入TSS任务结构段中(拍寄存器快照),TSS任务结构段通过TR寄存器值查询GDT得到TSS描述符,TSS描述符映射到TSS任务结构段地址;
- 步骤2)把 _TSS(n) 赋值给 TR 寄存器(新的TSS描述符地址);
- 步骤3)通过TR选择子,寻址GDT得到新TSS描述符,进而映射得到新TSS;
- 步骤4)把 新TSS里的内容 送入对应的cpu寄存器(重写物理寄存器内容);
简单点:TSS是一个结构体,存储在内存,可以存储所有cpu物理寄存器的值;也可以把TSS结构体数据赋值到cpu物理寄存器;
5)cpu物理寄存器举例
EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP, EIP, EFLAGS, CR3 等; 其中 ESP被替换为TSS的对应值,完成栈切换; |
6)tss 是 pcb的一个子段,pcb的一个部分
- 或 tss 是 pcb结构体中的一个内嵌结构体元素;
- 根据pcb 找到tss,根据tss找到 并切换esp ;
【问题】
因为 ljmp %0 是个复杂的汇编命令,无法利用指令流水提高执行速度,执行速度慢;
所以把基于tss的线程切换模式 转为 基于 内核栈(kernel stack)的线程切换模式;
【基于tss的线程切换小结】共3句代码:
- 1) int 0x80
- 2) switch_to 中的 ljmp // 长跳转
- 3) iret
【3】fork创建线程
0)创建线程:sys_fork;
- 调用 call _copy_process ;
【3.1】 _copy_process 创建栈
1)call _copy_process:创建栈
int copy_process(int nr, long ebp, long edi, long esi, long gs, long none, ….)
// 各个寄存器值
2)在父线程创建子线程前,父线程会把用户态的物理寄存器值压入内核栈;如下。
- 有了内核栈,就可以创建出与父线程差不多的子线程了;内核栈元素就作为了 copy_process的方法参数。
补充:内核栈:
SS:SP // 栈底 |
EFLAGS |
ret=??1 // 赋值给 eip ;是父线程触发中断 int 0x80指令的下一句指令; |
ds,es,fs // |
edx, ecx, ebx |
??2 |
gs |
esi,edi |
ebp |
eax |
??4 |
3)copy_process 创建栈细节
p=(struct task_struct *) get_free_page(); | // 获得一页内存,是内核态代码;而malloc是用户态代码;这一页内存用来做pcb; | 申请内存 |
p->tss.esp0=PAGE_SIZE+(long)p; | esp0是内核栈; PAGE_SIZE=4k; P是这一页内存初始地址,基址; (示意图如上) | 创建内核栈 |
p->tss.ss0= 0x10; | 0x10 表示内核数据段; ss0是内核堆栈段; | |
p->tss.ss=ss & 0xffff | 父线程用户栈的ss | 创建用户栈 |
P->tss.esp=esp; | 父线程用户栈的esp |
【补充】
- 创建的新线程,其用户栈与父线程是同一个,但内核栈不同;
- 创建的新线程,有自己的 tcb,在内核中是分开的;
mem_map:把内存拆分为单位为4k的存储块,每个存储块叫做一页(mem_map中等于0的元素);
【总结】 copy_process 的细节:创建栈步骤:
- 创建tcb;
- 创建内核栈;
- 创建用户栈;
- 关联栈和tcb;
【3.2】copy_process 细节-执行前准备
1)具体步骤:
- 把内核栈的寄存器值,赋值给tss结构体;
- tss修改后,cpu载入tss覆写了物理寄存器,寄存器值修改,如cs,ip等,则下一条要执行的指令就修改了,达到线程切换的效果;
【小结】父线程创建子线程后切换到子线程的过程
- 1) 父线程 执行完 sys_fork 创建完子线程后;继续向下执行;
- 2) 执行到 cmpl $0, state(%eax); 又父线程状态阻塞,则进入 reschedule ;
- 3) 进入 reschedule,就会进入switch_to 切换到子线程;switch_to执行具体步骤如下:
- 父线程把物理寄存器值快照保存到原tss结构体,tss保存所有寄存器值;
- 用新tss描述符赋值给TR寄存器,从而TR寄存器指向新tss结构体;
- Cpu通过TR寄存器寻址到新的tss结构体,并把新tss载入到cpu寄存器,从而切换到子线程;其中eip是指令int 0x80的内存地址;又 eax等于0,执行 mov res, %eax,则res等于0; 即 子线程fork()函数返回0;
上述汇编代码执行过程截图如下:
2)补充:
- 对于父线程而言,eax寄存器值不等于0;
- 对于子线程而言, eax寄存器值等于0 ;
- 从而达到把父子线程分开的目的(fork-分叉);
(截图自操作系统接口)
代码描述:
- While循环中, fork()返回值等于0,则表示是子线程,执行 exec(…);
- fork() 返回值不等于0,则表示是父线程,执行 wait();
【小结2】 父线程创建子线程步骤
- 1)tss做好了,可以完成线程切换;
- 2)用户栈,子线程与父线程用同一个;
- 3)内核栈,为子线程新创建一个内核栈,父子线程各自用各自的内核栈;
- 4)关联父线程用户栈与子线程的内核栈;
【4】利用新建子线程运行业务代码
0)问题:
- 如何利用新建的子线程执行业务代码 ,如指针A 表示的是 业务代码的首地址;
2) main 函数代码:
if(!fork()) {
exec(cmd);
} else wait(0);
// !fork() 表示返回为0,即子线程时,执行exec(cmd); // 业务代码
3)exec 系统调用执行细节
- 3.1) fork后 ,在子线程进入内核态之前,父线程与子线程执行的代码是相同的;
- 3.2) 但子线程调用 exec,接着调用 sys_execve系统调用后,进入内核态;执行内核代码结束后,子线程从内核态返回,或从0x80中断返回时,执行 ls->entry 入口程序;而 ls->entry入口程序地址是由 iret指令 把子线程内核栈的eip元素值弹出覆写 ip物理寄存器,从而修改pc寄存器值得到的;
3)通过 exec 系统调用 进入内核态
_system_call:
Push %ds .. %fs
Pushl %eax
Call sys_execve
_sys_execve:
// 把 %esp + EIP 的值赋值给 eax寄存器,又EIP偏移量28;
Lea EIP(%esp), %eax;
// 把 eax寄存器值压栈,即把EIP的地址(即内核栈28号元素的地址)压栈;
// 作为 do_execve的方法参数;
Pushl %eax;
// 调用 do_execve方法
Call _do_execve
EIP = 0x1C // 寻址内核栈下标28的元素 (ret=??1)
代码解说:
// 程序入口地址 赋值给 eip;
// 完成了把 ls->entry 程序入口地址赋值给eip寄存器(逻辑上,非物理)的工作;
eip[0] = ex.a_entry;
// 线程tcb起始地址 赋值给SP堆栈指针寄存器;
eip[3]=p;
【总结】线程切换(非常重要*)
1)演示图
2)线程切换步骤:
- Step1)用户栈通过int0x80展开为系统调用 sys_call,调用系统调用,进入内核栈;
- Step2)通过内核栈的esp元素找到tcb;
- Step3) tcb通过 switch_to 切换到其他线程的tcb2,切换到其他线程的内核栈2;
- Step4)用 iret 把内核栈2的寄存器值弹出,切换到用户栈2;