操作系统学习笔记5 | 用户级线程 && 内核级线程

在上一部分中,我们了解到操作系统实现多进程图像需要组织、切换、考虑进程之间的影响,组织就是用PCB的队列实现,用到了一些简单的数据结构知识。而本部分重点就是进程之间的切换。


参考资料:


1. 用户级线程

1.1 线程

我们聊的是进程切换,为什么引入了线程

  • 进程 = 资源 + 指令执行序列,资源包括内存映射表等等

    实现进程之间的切换是需要消耗资源的,当进程切换频繁,这种消耗就不能忽视了;

  • 是否可以资源不变而只切换指令执行序列呢?实现了多段程序交替执行,而且不需要切换资源这种消耗时间的操作,时间上还会更快;

    实质就是映射表不变而PC指针变

  • 由此引入线程的概念,Thread.

  • 进程 = 一个资源 + 多个指令执行序列(即多个线程)

    • 只切换指令执行序列,而不切换资源
    • 保留了并发的优点,避免了进程切换代价

本部分专注于线程的切换,而不关注资源的切换,而大的进程切换,需要结合内存管理来理解。

线程切换也是进程切换的一部分,学习线程的切换也就是在学习进程切换的一个部分。

可以理解为:进程切换=线程切换 + 内存映射表切换。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图1

操作系统学习笔记5 | 用户级线程 && 内核级线程插图2

1.2 线程设计的实用性

上面提出了一种轻量化进程的想法,下面分析这种想法是否实用。

  • 以浏览器为例,

    • 当前浏览器打开多个 Tab 页面是多进程,而加载同一个页面的不同资源是多线程,比如:js引擎线程;定时触发器线程;GUI渲染线程等等

    • 这样就比单线程顺序加载好很多;

    • 而且这些线程还要共享资源,比如接收数据的线程从网站上接收的数据还需要在显示文本、显示图片的线程上使用;所以没有必要进行地址分离,在同一套地址上处理更为方便;

1.3 浏览器多线程的设计理解

尝试简单实现这个浏览器多线程模型,借此理解操作系统的实现方式.

//下面代码,启动了多个线程,同时出发
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){...};

上面代码只讲了多个线程同时出发,下面还要实现线程之间的交替执行,才叫多线程,才能提高CPU利用率:

  • 需要在线程的执行函数中(如GetData())增加一些内容

    上文提到过的在读写磁盘时释放CPU需要内核级线程的支持,本部分先讲解用户级。用户级线程完全是用户态,不会进入内核。

  • 要想切换用户级线程,需要主动调用 yeild 来释放CPU

    可以完全不靠操作系统实现线程切换,也就是说线程切换可以手操切换,这也是多线程编程的理论基础。具体可见yeild源码

  • 比如,当GetData将文本下载完成后,调用yeild主动释放CPU,切换出去,进行文本的显示工作

    yield 礼让,让当前线程从运行态进入就绪态,重新与其他线程争夺进入运行态

操作系统学习笔记5 | 用户级线程 && 内核级线程插图3

1.4 Create && Yield 函数理解

  • Yield 函数

    释放CPU控制权,进行进程间的切换,

  • Create函数

    创建线程,就可以实现同时触发多段代码,Create就是要制造出第一次切换时应该的样子

1.4.1 Yield && 线程切换机制

举个实际程序例子:

操作系统学习笔记5 | 用户级线程 && 内核级线程插图4

  • 首先执行A,会跳到B执行,由于B执行完之后会返回A执行,所以要将B函数返回地址104 压栈

    这是线程内部的函数调用

  • B执行时调用了 Yield,于是跳到Yield 去执行,同样地,把返回地址压栈。

  • Yield 的核心操作就是修改PC指针;在该例子中指向了线程二地址为300的C函数,调用D函数,压栈返回地址304;

  • 执行D函数,又使用了 Yield,压栈404;而此处的Yield的跳转就是找下一个线程需要进行处的程序地址。如果从(2)跳回(3),则为204.

  • 返回左侧204处后进行到右大括号,会成为ret汇编指令,进行弹栈操作,弹出了404,

  • 这样,程序就出错了,因为线程由于函数调用的关系在栈中纠缠起来了

问题出在哪呢?

  • 我们想要实现的是,单个线程中就做该线程中的事情,因而204返回后应当弹出104,但是弹出了404.
  • 本质问题:两个线程共用了一个栈
  • 我们需要将不同线程的栈分开:如下图所示:

操作系统学习笔记5 | 用户级线程 && 内核级线程插图5

这个跟进程不能共享内存需要地址映射的原因相似,因而处理时采取的操作也类似:

  • 每个线程拥有自己的栈,但切换线程也需要切换栈,需要结构来存储栈的情况;

  • 在线程跳转前,用 TCB 存储栈情况,

    TCB:Tread Control Block

    ESP(Extended Stack Pointer):扩展栈指针寄存器

  • 比如线程1切换到线程2时,将左侧的栈放入TCB1(具体操作是esp的当前值1000放入TCB,保存了当前指令地址),切换回来时,就从TCB1中恢复(具体操作为TCB中的esp重新赋值给esp,使其指向原先指令地址)。

    线程2 转移到 线程1 同理;

    操作系统学习笔记5 | 用户级线程 && 内核级线程插图6

    栈切换完成后,(栈只存储了函数调用的返回的地址等信息),还需要PC的切换,即进行上图的jmp 204,到达204处向下执行,到达ret指令,要弹栈返回;

    结果,弹栈返回204,重新回到了204处。乱套了。

  • 虽然这样保证了函数调用只在一个栈中跳,但是还是出现了问题。

    我们想一下204是怎么来的?

    • 在调用 Yield() 时,将 204 压入栈中,那就应当在 Yield 返回时弹,而 Yield 返回处实在上图的右括号处,而右括号前面的jmp指令导致其永远不会返回,也就永远弹不出204.

    • 所以把 jmp 204 去掉。

      即 Yield 只需要切换栈,不需要进行PC的转换

      函数调用的压栈机制已经做了这件事。

1.4.2 Create

前面已经提到过了,Create 即做出切换所需的样子:

三样:栈 + TCB + 存放在栈中的返回地址

执行过程:

  • 申请内存分配TCB
  • 申请内存来分配栈
  • 栈顶压入线程的初始地址
  • TCB中保存栈顶指针
  • Create返回的时候,从栈里弹出的地址就是线程的初始地址

具体见下面代码:

操作系统学习笔记5 | 用户级线程 && 内核级线程插图7

1.5 梳理整合

上面Create 和 Yield 都明白后,多线程的情况也就基本实现了。可以把上面的浏览器代码和多线程组装在一起:

  • 用一个主程序,开启各个线程,每个线程设计自己的函数进行自己的工作;

  • 在浏览器的各线程中调用 Yield ,使 CPU 进行线程切换。

  • 而通过Yield函数弹栈,实现PC的切换;

  • 将这些函数统一编译在一起,就得到了浏览器

操作系统学习笔记5 | 用户级线程 && 内核级线程插图8

1.6 Yield 是用户程序

为什么这一部分是用户级线程呢?

  • 上述举的例子是浏览器的例子,我们的实现(Yield)也没有涉及太多操作系统的内容, Yield 是一个用户程序层面的函数,完全没有进入内核。

    用户级线程是内核级线程切换的子部分,而且这个子部分是可以单独使用,与操作系统的联系并不紧密;理解起来较为简单;

    Yeild 中包含next(),它是用来线程调度的,即选择下一个占用CPU的线程

  • 缺点:

    • 以上述浏览器为例,执行GetData时,由于时从网上下载数据,需要进行网卡IO,网卡是计算机硬件,要想访问硬件必须通过操作系统

    • 而网卡IO会慢一些,会引发进程阻塞,进而执行其他进程

    • 重点来了,由于内核看不到同进程的其他线程,OS 就切换到了其他进程,达不到上述线程切换的效果

      即使CPU只有当前浏览器一个进程,OS 也会空转,因为用户级线程对操作系统不可见。

而核心级线程 ThreadCreate 是系统调用,创建线程时,会进入内核;TCB也在内核中;此时各个线程对于操作系统是可见的,当 GetData 停滞,操作系统就会切换到其他线程(如Show)

内核级线程的并发性更好。

当然,对于内核级线程而言,此时 原先的 Yield 成为内核级的程序 Schedule,对于用户就不可访问了。

2. 内核级线程

Kernel Threads.

回到我们提出线程的初心,是想分而治之地实现进程切换,线程切换即进程切换中的指令流切换,而在用户级的线程无法实现进程切换的全部特征(因为进程是在内核中的,用户级线程无法深入内核)。

我们可以通过用户级线程理解线程的相关特点,而前文所说的:进程切换 = 线程切换 + 资源切换(内存管理)中的线程,实际上应当是 内核级线程。

而实际上,用户级线程也是内核级线程的一部分。

这部分就主要来看如何切换内核级线程

2.1 为什么会有内核级线程

核心级线程的优点和必要性原因有很多,我们只挑其中一个讲解。

  • 当前PC机多核居多,如果操作系统不支持核心级线程,那么计算机多核的设计是无效的。

  • 下图左侧为多处理器,右侧为多核;

    多处理器架构,每个处理器有自己的缓存、内存映射(MMU)
    单个处理器多核架构,只有一套缓存+内存映射,有多个运算部件

    操作系统学习笔记5 | 用户级线程 && 内核级线程插图9

  • 两者的区别是多处理器的每个CPU都有自己的一套缓存和MMU(内存映射,Memory Management Unit);后者多核中多个CPU使用同一套缓存和映射,多个执行序列使用同一套资源和映射,所以多核实际上就是多线程。

  • 而如果我们要使用多核,也即我们要将多个程序分配到多核架构的多个CPU上,这时我们的操作需要面向硬件、分配到物理设备CPU上;需要穿过内核,所以这时线程需要是内核级线程

  • 小总结,为什么进程不能满足需求呢,以及内核级线程为什么是必要的?

    如果没有线程,只有多进程,那么 MMU 内存映射在多进程切换的时候就必须跟着切换,共享的Cache、MMU 就会失去意义,变得很麻烦;

    充分发挥多核架构的效果是并行。

    并行和并发:

    • 并行是同时触发、同时执行,相当于多个人分别同时来做多件事;
    • 并发是同时出发交替执行,只有一套资源;相当于一个人干干这个又干干那个;
    • 不要把多核、多处理器的架构概念和并行、并发捆绑在一起。两种概念的判定标准并不一样。

    如果没有内核级线程,只有用户级线程,那么操作系统内核就无法感知这些线程,也就无法把这些线程分配到多个核上,多核就失去了意义

下图展示的就是,用户程序级别的函数只有通过内核,才能分配到底层的核中合理使用。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图10

2.2 内核级线程实现原理

内核级线程和用户级线程的区别:

  • 用户级线程两个线程之间分用两个栈
  • 内核级线程两个线程之间分用两套栈;

一套栈是指每个内核级线程有一个用户栈和一个内核栈;进行两个内核级线程的切换就需要切换的两个线程的栈,即两套栈。

关于用户栈和内核栈:

  • 我们平时使用的是在用户级别的程序,所以还是需要用户栈;
  • 而进行内核级线程切换需要进入内核,并且内核中相应也有函数调用,所以需要内核栈。

什么时候会出现内核栈呢?

  • 进入内核时;具体点说,进入内核的唯一方式就是 中断

  • 在用户态执行时就使用用户栈,一有对应中断,操作系统通过硬件会找到内核栈并启用

    如何找到内核栈下部分 3 会细说。

收到中断,内核态启用后需要压栈,需要在内核栈中先依次压入源SS、源SP、EFLAGS、源PC、源CS等内容

操作系统学习笔记5 | 用户级线程 && 内核级线程插图11

源SS和源SP是指向用户栈的指针,也就是说内核栈中存放了指向用户栈的指针;源PC和源CS是用户栈中的返回地址;(这点非常重要!

SS:存放栈顶段地址,SP:存放栈顶偏移地址

EFLAGS 是标志寄存器,用于存放一些标志位,保存当前运行状态;比如进位,溢出,奇偶等等,详见:X86标志寄存器EFLAGS详解

从内核态返回用户态的时候(IRET中断返回指令),会从内核栈弹出这些信息,根据这些信息就可以恢复到用户栈;

2.3 内核级线程原理举例

通过一个例子来看看通过中断进入内核级线程,过程是什么样的。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图12

  • A调用B,B中不再调用yield,调用read()库函数,展开为一段含 int 0x80 的指令

  • 触发中断,进入内核,启动内核栈

  • 内核栈按次序保存了

    • SS:SP,指向用户栈的指针
    • EFLAGS,标志位
    • 304(PC),返回用户程序的偏移地址
    • CS,返回用户程序的段基址
    • 1000是调用 sys_read 时压入栈中的PC值,
  • 系统调用返回(IRET)的时候,就会根据 SS:SP 切换回用户栈,根据 CS:PC 切换到用户程序的指令;

那么如何实现内核级线程切换呢?

  • sys_read 启动磁盘读,将自己变成阻塞,操作系统去找下一个线程并切换

    sys_read(){
        //找到next;
        switch_to(cur,next);
        // next 和 cur 分别是下一个和当前的 TCB
    }
    
  • 这里 TCB 和用户级线程相似,依然是要保存当前栈指针,通过下一个内核级线程的 TCB 找到内核栈指针,然后通过 ret 切换到某个内核程序,再通过上面提过的 cs:pc 切换到用户程序。

    操作系统学习笔记5 | 用户级线程 && 内核级线程插图13

    更详细地说:

    用当前的 S 线程的 TCB,把当前S的栈顶地址 esp 存下来;存下来之后,进入到 next 下一个线程 T;

    这时要把 T 线程的 TCB 中的栈顶地址拿出来赋给esp寄存器,这样才能把T线程的栈利用起来;

    进入 T 线程后,在内核中的工作往往只是任务的一部分,我们还需要回到 T 线程的用户级去进行,所以借助 T 线程的栈中的 PC 和 CS 能够链接到 T 线程的用户态代码,接着去执行它;

    而利用 ss:sp 就可以链接到线程 T 的用户栈;

如何实现 线程T 栈的切换(即拿到内核栈向用户态切换)?

  • 答:包含 iret 中断返回 的代码。
  • 上图问号处的具体填写,如下图:
  • PC:CS 是500,是函数 C 的开始地址
  • ????是 包含 iret 中断返回的 代码地址

操作系统学习笔记5 | 用户级线程 && 内核级线程插图14

2.4 内核线程 switch_to 的五段论

这里对 2.3 中的内容再做一次梳理,归纳整理为五个部分。

  1. 中断入口:由 线程A 的 用户态代码进入内核,才能完成内核栈与用户栈的联系

  2. 中断处理:引发线程切换,找到下一个线程的TCB

    这里引发线程切换的原因有很多,比如操作系统的分时系统,当时钟中断时,也会引出 schedule。

  3. 根据 找到的TCB ,调用 switch_to;

  4. 内核栈切换

  5. 中断出口:使用 iret 内核栈返回到线程 B 的用户栈和用户态代码

    下部分会再详细讲解这部分的代码。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图15

2.5 ThreadCreate 的实现原理

跟用户级线程一样,讲完线程切换的机制,就要说一说线程的初始化与创建机制(1.4.2的 Create)。

回忆上文 内核栈线程切换 所需的结构:

  • 用户栈 + 内核栈 + TCB

将它们做成可以如上文切换的样子就可以;

  • 申请一段内存作为 TCB;

  • 申请一段内存作为 内核栈;

  • 初始化内核栈;

  • 申请一段用户段内存作为用户栈;

  • 将内核栈指针指向用户栈;

  • 设置函数地址;

    此处CS一定是0F,至于为什么,在内存管理处会提及,也可以参见《Linux 源码剖析》。

  • TCB关联内核栈;

操作系统学习笔记5 | 用户级线程 && 内核级线程插图16

2.6 简单总结

用户级线程、核心级线程的对比,用户级线程和核心级线程搭配的效果最好;

操作系统学习笔记5 | 用户级线程 && 内核级线程插图17

  • 代价:用户级线程完全不需要进入内核,也就不需要额外的内核数据结构,用户想起多少个用户级线程都行,就像浏览器想开多少个标签都行,如果用内核级线程来启动浏览器标签,那么启动多了之后就卡了;

  • 用户灵活性:用户级线程可以由用户利用 Yield 自己实现调度,自己决定什么时候切换,而核心级线程的调度与切换是在内核中提前写好的。

3. 内核级线程实现

这部分来讲内核级线程的具体代码实现。

linux0.11 不支持内核级线程,但是进程和内核级线程非常像,只是没有资源切换。大实验就是在0.11内核上实现内核级线程;

内核级线程的切换过程对用户程序来说是透明的,用户可以看见的只是用户栈、用户级线程的切换。

  • 具体实现过程如下图所示,

    切换的原理就是上面第二部分的五段论,两套栈之间的切换

操作系统学习笔记5 | 用户级线程 && 内核级线程插图18

  • 用户栈1通过 int 中断进入内核
  • 内核栈1 -> TCB1
  • TCB切换,即找到下一个 TCB2
  • TCB2 -> 内核栈2
  • 完成切换后通过 iret 返回用户态2

3.1 中断入口:进入内核

3.1.1 int 0x80

这一部分应当与上文2.3结合着看。

前面提到过,进入内核靠的是中断,引起中断的原因有很多:系统调用、时钟中断、键盘中断等;我们比较熟悉的是系统调用引起的中断,比如 fork() 这个系统调用。

fork,创建系统线程调用;从这里进去内核就可以看到创建线程、切换线程两个过程。

代码:

main(){
    A();
    B();
}
A(){
    fork();
}
  • 调用main,将返回地址exit压入用户栈

  • 然后main函数中,首先调用A,将返回地址B压入用户栈

    A 的返回地址就是 B的初始地址,所以是 ret=B;

  • 接下来进入A函数,调用 fork 函数,fork函数展开成汇编代码:

    # 首先将__NR_fork(即系统调用 fork 的编号)放入 eax 寄存器
    mov %eax,__NR_fork
    # 然后就是int 0x80中断指令,开始做进入内核态的准备工作
    INT 0X80
    mov res,%eax
    

    现在用户栈为下图中间的模样:

  • 一旦执行 INT 指令,开始做进入内核的准备工作;

    • CPU 就找到 当前内核栈;

    • 在内核栈中压入 指向当前用户栈的 SS:SP

    • 在内核栈中压入EFLAGS

    • cs和ip:压入用户程序的返回地址,即 int 中断指令的下一条;

    • int 0x80 对应的是系统调用,开始执行_system_call的代码

操作系统学习笔记5 | 用户级线程 && 内核级线程插图19

3.1.2 _system_call

前面几讲讲过,int0x80是由 system_call 函数来处理的。

这里需要复习第二讲,操作系统接口。当时以 whoami为例讲解了上层用户程序 到 sys_write 级别函数的工作机制。

下面来看看 system_call 做了什么:

  • 3.1.1 中,我们依然处于用户态,需要中断进入内核来实现上面所说的压栈工作,这与前文提到的压栈并不矛盾,只是更深入解析其实现过程;

  • 将当前用户态的寄存器等数据放入内核栈中(保存现场),用于将来切换回该线程后弹栈返回该线程的用户层:

    void sched_init(){
    	set_system_gate(0x80,&system_call);
    }
    
    _system_call:
    	push %ds..%fs
    	push1 %edx...
    	call sys_fork
    	push1 %eax
    	#这一句在3.4中又提到了
    

    调用 sys_call_table 表 中的 sys_fork进入内核来具体地处理创建线程这件事;

    具体sys_fork的功能性代码,即创建进程的过程,这个在3.4 fork时再讲,

到这里就是五段论中的第一段:从用户栈到内核栈的关联建立;接下来需要在内核中进行中间三段。

3.2 中间三段:进行切换

这部分就是五段论中的中间三段

泛化一点讲,进行 sys_fork 级别的功能代码时,可能就会因为需要等待磁盘、键盘等设备响应而引起系统的阻塞,或者遇到系统的时钟阻塞,进而需要进行线程切换

上文的例子中,sys_fork 的功能代码本身并不会引起阻塞(要想阻塞只会是类似于系统的时钟阻塞),所以在代码 call sys_fork 后会继续向下执行,会有如下判断程序:

mov1 _current,%eax
cmpl $0,state(%eax)
jne reschedule
cmpl $0,counter(%eax)
je reschedule
ret_from_sys_call:

注意这里是AT&T汇编,和 intel 汇编(王爽汇编)刚好反着的。

state 此前存放在PCB中:

current是当前的PCB结构体的地址。state是该结构体的一个成员,如果当前 state≠0,表示当前进程阻塞,就进行 系统重新调度(schedule),实现线程切换:reschedule;至于下面又出现了 cmpl $0,counter(%eax),并跟着一句 je;意思是判断操作系统的时间片是否用光,用光了也需要切换。

切换完后,就是五段论的第五段中断出口 iret :ret_from_sys_call。见 3.3 中断返回。

reschedule 的汇编代码:

reschedule:
	push1 $ret_from_sys_call
	jmp _schedule

可见放入地址后,调用 _schedule 这个C函数;当C函数返回,就会从栈中弹出,执行 上文的 ret_from_sys_call. 返回到用户层。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图20

操作系统学习笔记5 | 用户级线程 && 内核级线程插图21

那么 schedule 引发了什么:

  1. 调度 next,依据一定的规则找到下一个线程/进程以及PCB,这个规则后面专门用一篇笔记来学习。

  2. switch_to,linux0.11目前不是用内核栈切换的,而是通过TSS,tasks struct segment进行切换的,实验四是要将其变成基于 内核栈Kernel Thread 的切换。

    前者实现原理简单,但效率较低;后者是现代操作系统普遍使用的方法。

3.2.1 TSS 切换

intel CPU架构已经实现了这种方式,代码只需要 ljmp一句即可;下面看看这种跳转方式:

操作系统学习笔记5 | 用户级线程 && 内核级线程插图22

  • ljmp指令是长跳转指令,即TSS段的跳转切换,使用TR寄存器+GDT表,如上图所示:

    TSS在内存中,104个字节保存着所有的寄存器的值

  • TR是TSS段的选择符(选择子),拿这个选择符TR去查GDT表,找到对应的TSS描述符,这个TSS描述符指向了TSS段

    这一点跟前几讲中查找GDT表的方式一样。

    TR-> GDT表中的TSS的描述符-> TSS段

  • 上面调度算法选择下一个进程后:

    1. 原来的TSS段是通过一个TR来查找的(类似于一个指针存贮着任务信息的地址)

    2. 当进行核心线切换的时候,会将TR指向第二个线程描述符,也就是通过调度算法找到的线程n,其对应的TSS(n)

    3. 第2步中,将TR切换到新的线程段,所以将此时的线程信息填入到CPU中

      这里的 TSS(n) 中的 n 就是 GDT 表的索引

参考资料:《Linux内核完全解析》第五章

具体代码如下,是一段内嵌汇编,具体语义不细说了,就是上面的过程。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图23
操作系统学习笔记5 | 用户级线程 && 内核级线程插图24

分析:

  • 与 内核栈的对比:TSS记录了所有的信息,而内核栈只记录必要的信息,并且利用函数调用栈的特性少记信息。
  • 由于TSS保存了很多东西,所以切换很慢(保存现场和恢复现场的过程很慢),并且只用ljmp一条指令完成,也不能通过指令流水来优化,不能充分利用CPU的流水线加速

3.2.2 内核栈切换

这部分在实验四中,具体实现在实验中体会。

  • TSS方案:核心就是3条指令:int、switch_to中的ljmp、iret,再加上其它的代码,总共加起来20、30句;

  • 改成栈的方案,也不会超过100句。

3.3 中断返回:返回用户态

前面3.2中3.2.1 之前的部分讲解到,ret_from_sys_call会实现返回用户层。

ret_from_sys_call:
	pop1 %eax
	pop %fs... ###一堆pop弹栈,弹出用户的东西
	iret ###最核心代码,返回到用户层

3.4 ThreadCreate | fork

3.4.1 fork 过程 && copy_process参数表

前文3.1.2中讲到了 sys_fork 的功能代码,下面继续接着这里向更深处讲解:

fork实际上是 linux 中创建子进程的一种方式,创建子进程,所以可以通过 fork 这个系统调用看看 ThreadCreate 的过程。

fork实际上要做的是,把原来的进程一分为二,一条为调用fork的父进程,另一条是父进程调用fork函数copy出来的子进程。

形状上像一个叉子。

sys_fork的功能代码如下图:

  • push语句,将当前进程的参数压入父进程的内核栈

  • 调用了 copy_process 这个C函数,将上述参数传递给子进程;

  • 调用 copy_process 时就会倒序从栈里取出参数,按照这些参数来创建子进程;

    copy_process 的参数表与栈的对应情况:

    (注意这里栈被分为左右两部分:

    • 栈里的eax对应第一个参数int nr

    • 栈里的ebp对应第二个参数long ebp

    • ......

    • 栈里的ret= ??1 对应参数 long eip,也就是int 0x80的下一条指令的地址作为返回地址,

      这里可见3.1.1 int0x80,也就是上面所说的B函数。

    • 右部分的最底端就是 _sys_fork的第一条push指令放入的 gs

    • 右部分的最顶端 ??4 就是 call _copy_process的时候的PC,对应指令 addl $20, %esp

    • 左部分的最顶端 ??2 就是 call _sys_fork 的时候的PC,翻到上面的图可以看到,对应的指令是 pushl %eax

    左部分的内容是从进入 int 0x80 中断到 call _sys_fork之前就准备好的,右部分的内容是进入 sys_fork 后才准备的参数

  • 这样子进程就跟父进程基本一样;(只有一个地方有差别)

操作系统学习笔记5 | 用户级线程 && 内核级线程插图25

3.4.2 copy_proces 的细节

回忆一下前文2.5中的创建进程需要做的事情:

  • 申请内存空间
  • 创建TCB
  • 创建内核栈和用户栈
  • 填写两个栈
  • 关联栈和TCB

copy_process 要做的事情是:

  • 申请内存创建TCB

    • get_free_page();获得一页内存;

      而不是 malloc(用户态代码),这一点后续内存管理会讲;

    • 页:系统初始化时,mem_map 将内存打成4k为单位的单元格;这里的 PAGE_SIZE 就是 4k。

    • 通过强制类型转换作为PCB

    • p是页的初始地址

  • 初始化TSS,创建内核栈和用户栈:

    注意,这里是3.2中TSS的切换方案,没有使用内核栈和用户栈,只是说相当于设置用户栈和内核栈

    这里esp0表示内核栈;esp表示用户栈;

    • esp0指向页的顶端地址

    • 0x10 表示内核数据段和内核堆栈段(一个段)

    • 这样如下图所示,一页内存中底下是PCB,上面是内核栈;这样内核栈和PCB就设置好了;

    • --------------------下面是设置用户栈---------------------------

    • ss 和 esp 是上文 3.4.1 中的 copy_process 中的参数表的内容;由父进程传递给当前正要创建的子进程;

    • 这说明:父进程和子进程用的是相同的用户栈

      当然,内核栈是分开的,用户栈可以共用。

    • 并且这句话也关联了栈和TCB

      至此,除了填写两个栈没有做,其他四步都做了

    • 而填写栈不需要,因为基于内核栈切换中,主要填写eip,而TSS切换中eip已经存入TSS,不需要填写栈,只需要用TSS初始化一下即可。

      eip是int指令执行后的下一句话,本文例子中的B函数

操作系统学习笔记5 | 用户级线程 && 内核级线程插图26

接下来初始化TSS,即使用copy_process传入的参数来初始化子进程的TSS。

  • p->tss.eip =父进程传入的 eip,也就是说返回用户态后,执行的第一条指令地址(int 0x80的下一条指令)和父进程保持一致

  • p->tss.cs 同理

  • p->tss.eax 置为0,eax保存的是返回值,见上文3.1.1中 的汇编代码最后一句;

看下面我对于整个过程的梳理

  • 剩下的部分与内存管理 有关。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图27

nr不是地址,而是task数组中空闲task_struct的标号!

下面梳理一下整个父子进程的过程:

  1. 在A函数中运行,遇到了fork,也就是父进程执行如下汇编代码(3.1.1~3.2的例子,以及其对应的汇编)

    # 首先将__NR_fork(即系统调用 fork 的编号)放入 eax 寄存器
    mov %eax,__NR_fork
    # 然后就是int 0x80中断指令,开始做进入内核态的准备工作
    INT 0X80
    mov res,%eax
    
  2. fork 执行完毕,新建了一个子进程,此时还没有执行子进程。则int指令结束,返回,接下来会执行 3.1.2 中的 _system_call

  3. 虽说fork的功能代码不会引发阻塞,但是我们假设其引发了阻塞(比如系统的时间片用完了,需要切换),于是system_call检测到阻塞,的调度下一个进程后,进行 switch_to

  4. switch_to 切换到子进程,子进程进行3.2.1 中的TSS切换,由于前文提过的父子进程的eip相同,则子进程开始就工作上面汇编代码的最后一句 mov res,%eax,这里子进程的eax已经被赋值为0

操作系统学习笔记4中的4.1 中提到过if(!fork()){...}的经典语句。

子进程调用fork()的返回值是0,父进程调用fork()的返回值是1,所以通常会用fork()的返回值区分是父进程还是子进程来编写代码。

达成的效果就是:

  • 子进程执行 if 语句段内的语句;父进程跳过不执行;
  • 形成了一个叉子(fork)状的分叉

3.5 如何执行我们的代码实际举例

上述过程已经回扣了例子,可能还是抽象,用操作系统学习笔记4中 终端shell 的例子再说明一下。

下图可见,子进程通过 if语句结构,在父进程的壳子里(shell终端),执行自己的功能,比如 ls (子进程)在终端(父进程)里,但是执行自己显示目录的功能。

简单看看 exec 的系统调用讲解(此时就是子进程了):

  • exec 内部调用的是 sys_execve;

  • 子进程在sys_execve之前执行的就是 fork 的代码(与父进程相同),fork 的代码准备好了子进程的内核栈,大部分信息来自父进程

  • 随后子进程执行 _sys_execve,找到了命令功能性代码(实际上编译完成后是可执行文件),从可执行文件中取出必要的信息

    例如可执行文件的入口地址,即第一条指令的地址

    用这些信息修改了内核栈中用于 IRET 的参数,即返回地址,使得从sys_execve返回(IRET)后,就转而去执行输入的命令,之后执行的指令和父进程就完全没关系

sys_execve(最后这里没太懂原理,涉及了编译)

  • 下图2中就有 _sys_execve 的汇编代码

    首先计算得到 栈中EIP的地址

    0x1C是28,esp+28就是 ret=??1 的位置,也就是返回用户程序的地址

    压入栈中,作为 do_execve的参数,然后 call _do_execve

  • do_execve的代码如下2图

    将栈中EIP修改为ex.a_entry,即可执行程序的入口地址

    同时修改了即eip[3]即 SP为当前申请的页内存

    ex.a_entry 是可执行程序入口地址,在编译时产生可执行文件时写入。

操作系统学习笔记5 | 用户级线程 && 内核级线程插图28

操作系统学习笔记5 | 用户级线程 && 内核级线程插图29

4. 总结

  • 理解 switch_to 对应的栈切换,将自己变成计算机,能够执行整个过程
  • ThreadCreate的目的就是初始化一套栈。

文章来源于互联网:操作系统学习笔记5 | 用户级线程 && 内核级线程

THE END
分享
二维码