linux内核与设计实现-进程(未完待续。。。)

目录

前言

程序

指令

程序计数器

指令执行大致过程

寻址方式

指令流水线

指令乱序

程序编译

小结

该章节中所参考或者部分转载的文章

进程

为什么会引入进程概念

系统资源

内核态和用户态

进程描述符

用户进程在用户空间中的表示

程序中函数栈的使用

内核堆栈和用户堆栈

PID

进程的状态

进程家族树

进程的创建

写时复制

该章节中所参考或者部分转载的文章

存储器管理


前言

以linux内核与设计实现第三版为主线,网上资料和书籍为辅助,进行断续整理,参考文章放在后面。整理的目的,当然就是为了对整个计算机的体系提高理解程度,但是并不会抠细节。

我是从第三章开始看的,这里面说到了一个程序的概念,因为并不是很理解,就放在开头。

程序

程序是什么,简单的来说,程序就是指令的集合,或者说是指令序列,除了指令当然也包括了相关的二进制数据。通过这个指令的序列,去告诉计算机要做什么,详细的执行步骤是什么。

不过这里需要进行一个说明,因为计算机语言可以分为机器语言,汇编以及其他高级语言。所以说,上面解释的概念指的就是机器语言,而高级语言出现只是为了方便我们人进行编写和阅读。

指令

而每个计算机都有指令系统,包含了计算机能够做的所有基础操作。比如说,算术运算(加减),逻辑运算,数据传送,移位,条件转移等。而指令本质就是一组二进制的代码。

通过这组二级制信息(涉及到指令的格式),我们需要指出数据的来源,操作结果的去向以及所执行的操作是什么

  1. 操作码:我要做什么操作
  2. 操作数的地址
  3. 操作结果的存储位置
  4. 下条指令的地址:执行程序时,大多数指令按顺序依次从主存中取出执行,只有在遇到转移指令时,程序的执行顺序才会改变。

程序计数器

而为了压缩指令的长度,可以用一个程序计数器(Program Counter,PC)存放指令地址。每执行一条指令,PC 的指令地址就自动 +1(设该指令只占一个主存单元),指出将要执行的下一条指令的地址。当遇到转移指令时,则用转移地址修改 PC 的内容。由于使用了 PC,指令中就不必明显地给出下一条将要执行指令的地址。

工作流程如图所示,我直接copy别人博客的图:

指令执行大致过程

图中的指令指针IP也就是我们熟悉的程序计数器PC,它里面中存储的地址传送(通过MAR和MDR进行传输)给存储器,而后取出指令存储到指令寄存器中IR中,然后通过指令译码得知操作是什么,通过地址计算(寻址)取出(访存)所需的操作数据,放到cpu中的寄存器中。进而发出信号进行执行操作。最后将PC+1,或者从指令中得到目标地址更新PC

寻址方式

前面图中地址计算的过程就是寻址。寻址分为立即寻址,寄存器寻址,直接寻址,寄存器间接寻址,寄存器相对寻址

(1) 立即寻址:操作数就在指令里面,无需访存;

(2) 寄存器寻址和寄存器间接寻址:操作数就在寄存器里面,通过寄存器的编码去寻址;间接寻址就是寄存器存储的是操作数的地址,要通过寄存器拿到地址去访存拿到数据

(3) 相对寻址:根据当前的PC地址加上指令中的形式地址形成有效地址;

(4) 基址寻址:有效地址是将cpu中的基址寄存器加上指令中的形式地址A。基址寄存器中的内容由OS决定,并且在执行的过程中不可变。这样在多道程序中,用户就可以只要关心自己的地址空间就行了,不必关心实际的地址。它的好处就是可以扩大寻址的范围,因为基址寄存器的位数可以大于形式地址A的位数;

(5) 变址寻址:有效地址是将CPU中变址寄存器IX的内容加上指令字中有效地址A。其中形式地址是作为一个基准地址,而变址寄存器中的地址是由用户设定,会根据情况发生变化。主要用于解决循环问题。比如一个几百万次的循环,我们只需将变址寄存器中的值自增1就可以访问了。

无论哪种寻址都有是为了更快,更高效的执行指令。所以我们可以说,程序是通过一个个基础的,具有一定顺序的指令组成,然后按照一定的顺序一一执行,如果要跳转那指令里面都有说明,计算机只要按照说明操作就行了。

指令流水线

而为了提高CPU执行指令的效率,使用了指令流水线的技术。它将一条指令分为多个不同的阶段:取值(IF),译码(ID),访存(MEM),执行(EX),写回(WB)5个阶段

想象一下工厂的流水线,第一个工人在完成某个件事情后并不会等待后面的工人完成在继续下一个操作,而是一件接着一件。指令流水线和这个类似,当第一条指令完成IF后,第二条指令就可以开始IF了,重复利用使得多条指令同时执行,大大提高效率。

指令乱序

说到指令流水线就想到指令乱序,指令乱序的目的就是为了进一步提高执行的效率,减少流水线阻塞。

流水线的阻塞有三种情况:

  1. 结构相关:资源冲突,如都要使用某个部件。
  2. 数据相关:后一个指令需要前一个指令的执行结果。
  3. 控制相关:涉及到跳转,分支指令。

所以,如果像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。而CPU的乱序,作为优化的一种手段,则试图通过指令重排将这样的两条指令拉开距离, 以至于后一条指令进入CPU的时候,前一条指令结果已经得到了,那么也就不再需要阻塞等待了。这里的意思是将不相关的指令插入相关指令的中间,以达到减少阻塞时间的目的

指令乱序虽然是一种优化手段,但是也会带来一些问题,比如说某个变量虽然看起来是前后无关的,但是会和其他线程的操作有着隐含的前后关系,这样就会出现线程安全的问题。

而为了解决这类问题,在JVM中提供了内存屏障来防止乱序。而除了乱序,它同时也有一个功能那就是保证缓存的一致性。这样就说到了缓存一致性协议。

程序编译

联系到java,JVM本身也是一个虚拟的计算机,它里面也定义了基础的指令操作,也就是字节码指令,这个我们在IDEA编译之后就可以发现。而class本身也是一个二级制的文件,JVM通过C++去识别这个class里面的指令,然后通过PC寄存器来模拟计算机中的程序计数器,来记录指令执行的位置。而每个线程都有自己的PC寄存器。

不过不管怎么样,最终,JVM本身的指令也会被翻译成机器指令,通过计算机来执行。而这个翻译就涉及到了编译。编译的原因就是,因为计算机并不识别JVM中定义的字节码指令。

而java是如果变成机器语音,详情可以看Java代码到底是如何编译成机器指令的,这里进行部分的转载操作:

 

里面他将.java文件编译成.class文件称为前端编译;将.class文件翻译成机器指令称为后端编译

在后端编译中,JVM会一条条执行指令字节码指令,其实也就是一条条将字节码指令通过解释器翻译成机器码指令执行。这样一边翻译一边执行速度当然会比可执行的二进制程序要慢,所以就引入了JIT技术。

JIT技术会将频繁执行的热点代码翻译成机器码进行缓存使用

当 JVM 执行代码时,它并不立即开始编译代码。首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。第二个原因是最优化,当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化

小结

所以我们通常说的程序,也就是高级语言写成的代码,只是一个二级制文本而已,本身并不具备动起来来的能力。而要让计算机识别我们写的代码,势必要进行翻译,翻译成基本的cpu指令。而且还要将这些指令和代码中所涉及的数据加载到内存中,从起始指令开始,让CPU去按照特定顺序去取出-执行-取出-执行。CPU通过识别指令,利用各种寄存器,对内存或者是磁盘等里面的数据进行操作,最终完成一个个指令,从而让整个程序"动起来"。

但是实际上,我们在平时交流的时候,好像并没有这么严格的规定,知道即可。

该章节中所参考或者部分转载的文章

程序执行的过程 - 一文看懂计算机执行程序的过程

基址寻址与变址寻址

Java代码到底是如何编译成机器指令的

进程

从之前的说明我们了解到,程序是一个静态的概念,而进程是一个动态的概念。那么为什么需要引入进程就比较有一个准备了。

为什么会引入进程概念

简单来说,就是因为多道批处理系统的出现,使得程序的执行出现了新的特征,而原本的程序这个概念并不能很好的表达,所以就引入进程概念。而进程的特征有:

  1. 动态性:他表示程序的一次执行过程,是动态产生和消亡,有活动状态,也有阻塞状态等。
  2. 并发性:因为多道批处理系统可以让多个程序交替执行,看起来就像是同时执行一样,所以具有并发性。
  3. 独立性:在未引入线程之前,进程是可以独立运行和调度的基本单位。另外也是资源分配的基本单位
  4. 异步性:因为是操作系统去进行调度,进行交替执行,所以你不知道它什么时候会被调度,什么时候有调出阻塞,所以是不可预知和难以再现的。
  5. 结构性:进程具有一定的结构。由程序段,数据段和PCB等组成。程序就是cpu指令,让计算机知道要做什么,数据则是程序操作的对象,PCB则是一个注册记录,便于操作系统管理和控制进程。

从上面可以看到,进程最基本的特征就是动态

这里有一个并发和并行的概念。并发这里是一个宏观的概念,就是说在一个时间段内交替做多件事情。而并行则是可以同时做,指的是时间点。

为什么要引入多道批处理系统:那是因为单道批处理,每次只能运行一个程序,资源利用率低,cpu并不能一直处于忙碌的状态,浪费了资源。多道批处理可以一次加载多个程序,这个阻塞了,我们可以调用另外一个继续运行,充分利用cpu。

但是多个程序因为共享cpu以及其他系统资源,所以就会产生各种问题需要去解决,比如说:如何调度进程,资源的争用等。

系统资源

我们经常会看到:

进程是资源分配的最小单位

那么什么是系统资源,资源由谁来分配。资源的分配当然是由操作系统。而资源,就是各种各样的软硬件资源。归纳起来分为:处理器,存储器,IO设备以及文件OS的主要功能就是对着四类资源进行有效的管理:其中处理器管理就是如何分配和管理处理器;存储器管理就是内存的分配和回收;IO设备管理就是负责IO设备的分配和操作;文件管理就是对文件存取,共享和保护。

说到这里我们就延伸一下,操作系统的作用有哪些,这里引用计算机操作系统里面说的三个点:

  1. 操作系统为用户提供操作系统资源的接口。使得用户可以通过系统调用的方式进行使用,进而方便,快捷操作计算机硬件和运行自己的程序;
  2. 操作系统可以有效管理和调度系统资源
  3. 操作系统实现了对计算机资源的抽象。和第一点意思感觉有点类似;
    1. 首先一台完全无软件的计算机,我们称为裸机。而我们要使用就需要对硬件的接口有充分的了解,那多麻烦。所以在上面覆盖了一层IO设备管理软件,它屏蔽了物理接口的具体细节,将其抽象为数据结构和IO操作的命令,我们只要通过命令操作就行了,无需关心细节,这是第一个层次的抽象;
    2. 而后由于内存并不会永久保存数据,所以就需要以文件到形式存放到磁盘上。但是如果我们自己去管理,那不是得要事无巨细,要知道存放位置,文件属性,好要保护数据不被破坏等等。所以为了方便,我们在上面覆盖了第二层文件管理的软件。第二层抽象。
    3. 如果我们不想用命令而是用鼠标,我们可以在文件管理软件上面再加一层窗口管理的软件

所以我们可以说:

操作系统对计算机中的资源进行抽象,屏蔽了具体的细节,向上提供各种的数据结构以及操作命令;同时为了协调多用户对系统共享资源的使用,还维护了各种数据结构来记录和查询各个资源的使用和进程运行的情况。

 我们可以将操作系统管理的数据结构分为四类:内存表,设备表,文件表以及进程表(也就是PCB)

内核态和用户态

内存上区分了内核空间和用户空间

内核空间装载的就是操作系统这个程序,用户空间装载的是用户自定义程序。所以进入内核态,就是在执行操作系统的代码;进入用户态就是在执行用户自己的代码。区分的原因,当然是为了便于管理,防止用户进程误操作或者恶意破坏系统,提高安全性和运行的稳定性。你操作你的数据,我操作我的数据,分离开,如果用户进程崩溃了,那我内核也不会受到影响。

进程描述符

进程在用户空间中的表示就不研究了,直接看看进程描述符,或者说是进程控制块PCB。进程是一个我们抽象出来的概念,用来描述运行中的程序,但是计算机并不知道。所以PCB就是为了可以让计算机去管理和控制进程的一个结构体。所以我们也可以认为PCB在进程在,PCB亡进程亡

PCB是被操作系统放在一个叫做任务队列(task list,一个双向循环链表)的地方。链表中的每一项都是task_struct的结构体,这个结构体我们可以在/usr/src/kernels/3.10.0-862.el7.x86_64/include/linux/sched.h中找到。截个pdf里面的图,好看一点。

但是有一个问题,那就是如何快速找到找到PCB,因为无论是进程的状态的转换,进程的切换还是内核态和用户态的转换都是会用到PCB。而在寄存器较少的x86体系结构中,我们可以通过内核栈指针找到,从而避免使用专门的寄存器。下图copy自https://blog.csdn.net/gatieme/article/details/51577479,图中箭头方向就是栈增长的方向。

thread_info

这里我们只要知道可以通过放在栈指针就可以轻松定位到thread_info定位到PCB(因为每次使用完内核栈之后都是空的,可以通过偏移计算),也可以通过PCB定位到内核栈和thread_info。而且在这种情况下,内核并不会给task_struct分配内存,而是分配一个内核栈,然后将内核栈的一部分给PCB使用。结构体的信息放在后面顺序说明。

//include/linux/sched.h    
union thread_union {
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];
};
struct task_struct {
    void *stack; //指向内核栈
    //...
}

//include/asm-arm/thread_info.h
#define THREAD_SIZE             8192
struct thread_info {
    struct task_struct      *task;          /* main task structure */
    //...
};

总一下PCB的作用:

  1. 作为独立运行基本单位的标志:有了PCB你就可以取得OS服务的权力,如打开文件,申请读写IO设备。当进程创建一个新进程就建立PCB,当进程结束的时候又会回收PCB。就像是身份证,每人都有一个,当过世之后这个身份证也会随之消亡。
  2.  可以保存现场信息:因为在多道程序的情况下,就会出现进程的切换,有切换就有信息的保存。这些信息比如说:寄存器,程序计数器,堆栈的情况。
  3. 进程控制信息:包括程序和数据的地址,这样我们才知道其储存位置;资源清单,所需的资源是什么,以及已经分配了什么;进程同步和通信机制,如信号量等。
  4. 进程的调度信息:进程状态;进程优先级;进程调度其他信息,这个和调度的算法有关,如等待时间,已执行时间等。事件,进程由运行状态变为阻塞的原因。

PCB的常用三种组织方式:线性,链接,索引。其中后面两种都有将不同状态的进行分类,如运行,就绪,阻塞,空闲。而就绪还可以按照优先级进行一个排列。

用户进程在用户空间中的表示

这里就说到了内核栈和用户栈的概念。而这个概念又涉及到堆栈的作用。我们还得从用户进程在用户空间中的表示说起。

一般来说,进程使用的内存空间有默认如下分段:代码段,初始化数据段,未初始化数据段,栈,堆,其他。其他就忽略了。copy一张图过来,更加形象。

图中的unused是未使用的地方,也就是当空间不足时,堆栈可以进一步使用的方向。

代码段位置放在堆栈的下边,可以防止堆栈溢出而被覆盖。可以看到代码段text是只读的,这样可以保证在执行的时候不会被修改。

初始化数据段:也称为数据段。包含全局变量和静态变量,在编译的时候就已经初始化。还可以细分为只读和可读写,常量就是只读的。

:栈很重要,没有栈就没有函数,就没有局部变量。栈为函数工作提供独立的工作空间,这个空间就是栈帧,栈中存储的是一个个栈帧,栈帧保存了一个函数调用所需要维护的信息,所以说函数所占空间就是栈帧所占用的空间:

  1. 函数的返回地址和传输的实参:返回地址指的是调用该函数的地址。
  2. 局部变量:函数所使用的临时变量。

:堆的作用就是为了弥补栈的不足,栈帧是操作系统管理分配的,在函数调用完成之后就会直接释放。所以如果需要在函数调用完成之后依然存在,就需要进行内存动态申请的。用动态是说,操作系统并不能预知你要用多少,而是我给你分配,然后你只能用这么多。

程序中函数栈的使用

为了实验我们自己动手弄个:

#include"stdio.h"

void printD(int d){
  printf("%d\n", d);
}

int add(int a, int b, int c){
  int d = a+b+c;
  printD(d);
  return d;
}

void main(){
  int a=1, b=2, c=3;
  int d = add(a,b,c);
}

 将其编译后查看汇编:

gcc -g -c test_func_stack.c
objdump -d -M intel -S test_func_stack.o

输出如下:

Disassembly of section .text:

0000000000000000 <printD>:
#include"stdio.h"

void printD(int d){
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	48 83 ec 10          	sub    rsp,0x10                
   8:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
  printf("%d\n", d);
   b:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
   e:	89 c6                	mov    esi,eax
  10:	bf 00 00 00 00       	mov    edi,0x0
  15:	b8 00 00 00 00       	mov    eax,0x0
  1a:	e8 00 00 00 00       	call   1f <printD+0x1f>
}
  1f:	c9                   	leave  
  20:	c3                   	ret    

0000000000000021 <add>:

int add(int a, int b, int c){
  21:	55                   	push   rbp                      //调用函数的栈帧底部地址压入栈中
  22:	48 89 e5             	mov    rbp,rsp                  //将栈顶地址放到rbp中,表示新的栈帧底部
  25:	48 83 ec 20          	sub    rsp,0x20                 //因为栈是高位向低位增长,所以要分配临时空间就要减
  29:	89 7d ec             	mov    DWORD PTR [rbp-0x14],edi //将三个参数值存储到分配的临时空间中
  2c:	89 75 e8             	mov    DWORD PTR [rbp-0x18],esi
  2f:	89 55 e4             	mov    DWORD PTR [rbp-0x1c],edx
  int d = a+b+c;
  32:	8b 45 e8             	mov    eax,DWORD PTR [rbp-0x18]
  35:	8b 55 ec             	mov    edx,DWORD PTR [rbp-0x14]
  38:	01 c2                	add    edx,eax                  //进行计算
  3a:	8b 45 e4             	mov    eax,DWORD PTR [rbp-0x1c]
  3d:	01 d0                	add    eax,edx                  //计算最终结果在eax中
  3f:	89 45 fc             	mov    DWORD PTR [rbp-0x4],eax  //最后也是放入栈帧中
  printD(d);
  42:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  45:	89 c7                	mov    edi,eax                  //需要传输给下一个函数,先放入寄存器中
  47:	e8 00 00 00 00       	call   4c <add+0x2b>
  return d;
  4c:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]  //将存储在栈帧中的值放入eax中,调用者会去取出
}
  4f:	c9                   	leave                           //还原,先将rbp中的值赋值给rsp,然后弹出之前压栈的调用者函数栈低,更新rbp
  50:	c3                   	ret                             将栈顶的放回地址弹出程序计数器,然后继续往下执行

0000000000000051 <main>:

void main(){
  51:	55                   	push   rbp
  52:	48 89 e5             	mov    rbp,rsp
  55:	48 83 ec 10          	sub    rsp,0x10
  int a=1, b=2, c=3;
  59:	c7 45 fc 01 00 00 00 	mov    DWORD PTR [rbp-0x4],0x1
  60:	c7 45 f8 02 00 00 00 	mov    DWORD PTR [rbp-0x8],0x2
  67:	c7 45 f4 03 00 00 00 	mov    DWORD PTR [rbp-0xc],0x3
  int d = add(a,b,c);
  6e:	8b 55 f4             	mov    edx,DWORD PTR [rbp-0xc]
  71:	8b 4d f8             	mov    ecx,DWORD PTR [rbp-0x8]
  74:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  77:	89 ce                	mov    esi,ecx
  79:	89 c7                	mov    edi,eax
  7b:	e8 00 00 00 00       	call   80 <main+0x2f>          //相当于push和jump,先将放回地址压入栈中,然后跳到调用函数的起始位置。
  80:	89 45 f0             	mov    DWORD PTR [rbp-0x10],eax
}
  83:	c9                   	leave  
  84:	c3                   	ret

首先我们得要知道RSP和RBP是做什么的:

RSP:栈指针寄存器(reextended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。

RBP:基址指针寄存器(reextended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

然后分析上面过程,发现用一个栈结构非常漂亮完成这个操作。先将返回地址压栈,然后是上一个栈的栈底;然后就是分配一个临时空间,这空间是固定值说明是编译器就确定了,用于存储运行时候所需的变量(包括参数和临时变量),可以称其为局部变量表;完成操作后就将返回值放到寄存器中,然后利用之前压栈保存的数据进行恢复,跳转到之前的位置继续执行。还有一点就是rbp和rsp之间的其实可以看成一个栈帧

JVM中也有方法栈,而且每个线程都有一个,和这个实现类似:调用方法会压栈,方法返回的时候弹出释放空间。至于线程在内存空间中是怎么个存在,线程的栈又是什么管理的后面再探究吧。

内核堆栈和用户堆栈

我们现在知道了堆栈的作用,自然可以知道内核堆栈和用户堆栈的作用。内核堆栈就是为了执行内核程序,用户堆栈就是为了执行用户程序。而在创建进程的时候就给进程分配好了用户堆栈和内核堆栈,会随着内核态和用户态而切换使用这两种堆栈执行指令

那么,是不是很疑惑,如果一开始就给定了用户堆栈的指针,那么要进行系统调用的时候,那内核栈的指针又在哪里保存?我在linux内核栈与用户栈中找到介绍,是在TSS任务状态段中保存。那么TSS段又是什么,和进程控制块PCB有什么关系?问题真的是越看越多。这里就留着坑吧,毕竟知识是可以相互关联的,后面如果有发现再来补充

回到进程描述符继续往下。

PID

struct task_struct {
    //...
    pid_t pid; //进程的PID
}

pid是一个int类型,这个可以自己找一下,要找很多个文件。而为了和老版本兼容,所以将PID最大值默认设置为32768,短整型的最大值。当然如果需要调整我们可以调整/proc/sys/kernel/pid_max;pid,顾名思义就是一个标识。

进程的状态

struct task_struct {
    volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
}

  进程的状态有基础五种:

  1. TASK_RUNNING(运行):进程就绪或者正在执行。在用户态中只有这个状态;
  2. TASK_INTERRUPTIBLE(可中断)和TASK_UNTERRUPTIABLE(不可中断):都是属于阻塞状态,处于阻塞状态的进程都是因为需要等待某个条件达成,可能是数据是否准备就绪,可能是其他。两种状态的区别在是否会被中断。可中断就是可以被提前唤醒。
  3. __TASK_TRACED(进程被跟踪):例如通过ptrace进行调试
  4. __TRASK_STOPPED(停止):进程停止,停止的进程不能重新再运行。

 

进程家族树

unix系统中的进程之间存在一个明显的继承关系,一个很直观的就是执行ps -ef命令的时候:

是不是发现PPID为0,1,2的比较多,而且很多不是这三个的最后父父进程也是1。 所以我们可以说:每个进程都有父进程,所有的后面创建的进程都是PID为1的init进程的后代。而1的父进程是0,2的父进程也是0,说明0是始祖。拥有同个父进程的我们称为兄弟

但是这里并没有看到init,是因为在CentOS中用Systemed取代了init。而init顾名思义就是一个用于初始化的用户级进程,这样就理解了为什么它是最大的,而后可以看到0号进程有启动了2号,看名字应该是管理内核线程的,所以后面进程都是以2为父进程也可以理解了。

进程之间的关系都存放在结构体中:

struct task_struct __rcu *parent;
struct list_head children;
struct list_head tasks;

 我们可以通过几个指针轻松找到其他进程,而想要遍历系统进程只需要用tasks即可,本就是一个双向循环链表。我们可以在:/usr/src/kernels/3.10.0-862.el7.x86_64/include/linux/types.h中找到这个结构体节点的定义:

struct list_head{
    struct list_head *next, *prev;
};

 所以如果我们想要遍历只需要next,然后判断是否到遍历起始位置就行了。

说到这里我们可以顺便研究一下实现,之前都是理论,还没见到真货。真货除了定义操作什么的在/usr/src/kernels/3.10.0-862.el7.x86_64/include/linux/list.h中。为了节约时间,我找到一篇博文对照源码看。

进程的创建

这里为了和pdf中所述内容符合,我需要去下一个源码,之前我是看的CentOs的,看的有点难受。我下载的是Linux2.6.26的版本。

其中创建操作do_fork在kernel/fork.c中找到,但是真正的操作是copy_process执行。参考博文:linux源码解析-copy_process函数

第一步:dup_task_struct(current),创建并复制父进程的PCB,共享父进程的内核栈,只读共享

static struct task_struct *dup_task_struct(struct task_struct *orig)
{
        struct task_struct *tsk;
        struct thread_info *ti;
        
        prepare_to_copy(orig);

        tsk = alloc_task_struct(); //创建新的PCB
        ti = alloc_thread_info(tsk); //创建thread_info
        err = arch_dup_task_struct(tsk, orig); //完全拷贝
        
        tsk->stack = ti;
        setup_thread_stack(tsk, orig);
        return tsk;
out:
        //释放分配内存
}

//include/linux/sched.h
//获取thread_info
#define task_thread_info(task)  ((struct thread_info *)(task)->stack)
static inline void setup_thread_stack(struct task_struct *p, struct task_struct *org)
{
        *task_thread_info(p) = *task_thread_info(org); //拷贝thread_info,相当于拷贝内核栈
        task_thread_info(p)->task = p; //设置PCB
}

第二步:下面这个是对用户进程所能打开进程资源的限制判断,而判断里面包含了一个signal-->rlimt[...]的获取。然后进程一个统计数量递增。

//---copy_process
    if (atomic_read(&p->user->processes) >=
            p->signal->rlim[RLIMIT_NPROC].rlim_cur) {
        if (!capable(CAP_SYS_ADMIN) && !capable(CAP_SYS_RESOURCE) &&
            p->user != current->nsproxy->user_ns->root_user)
                goto bad_fork_free;
    }

    atomic_inc(&p->user->__count);
    atomic_inc(&p->user->processes);
    //...
//---

//include/linux/resource.h
struct rlimit {
        unsigned long   rlim_cur; //软限制
        unsigned long   rlim_max; //硬限制
};

这个rlimit就是为了限制资源而设计的一个数据结构,软硬限制我还没找到介绍。不过我去查找这个rlim[RLIMIT_NPROC],找到了初始化的地方,也是在fork.c里面,而且是在初始化init_task的时候赋值的,所以也可以看出init进程是后面fork进程的父进程,因为有一个继承的关系

//kernel/fork.c
void __init fork_init(unsigned long mempages){
    //...
    max_threads = mempages / (8 * THREAD_SIZE / PAGE_SIZE);
    if(max_threads < 20)
        max_threads = 20;
        
    init_task.signal->rlim[RLIMIT_NPROC].rlim_cur = max_threads/2;
    init_task.signal->rlim[RLIMIT_NPROC].rlim_max = max_threads/2;
    init_task.signal->rlim[RLIMIT_SIGPENDING] =
    init_task.signal->rlim[RLIMIT_NPROC];
}

注意到上面的最大线程数量计算,不太理解这块计算的含义,只是了解一点就是会随着内存的变化而变化。而且限制了一个进程不能拥有一半以上的进程数量。

第三步:为了将自己和父进程区分,对一些统计信息进行初始化,这个时候并未修改那些继承的信息,跳过;

第四步:分配pid;

pid = alloc_pid(task_active_pid_ns(p));
p->pid = pid_nr(pid);

 第四步:回到do_fork里面,准备就绪,放入就绪队列中准备调度wake_up_new_task,而后返回pid;

细节有很多都跳过了,有很多看的不是太懂需要花时间研究。

第二个就是exec函数负责装载可执行文件到内存中。

写时复制

这里有一个写时复制的概念,意思就是,需要的时候再来操作,延迟操作的时间,在此之前共享一份内存,提高性能。这样做时因为,以前fork实现是将父进程所有的资源复制一份给新的子进程,费时费力,有时候甚至有很大一部分资源是不会用到的,而且这样会影响进程的创建开销。

举个平常例子,比如说写文件会有缓存的使用,使用缓存的原因就是为了不必每次都要进行一次i o,只会在需要的时候(OS认为需要的时候),比如说关闭文件,比如说显示调用flush。平时文件编辑的时候,如果突然断电,是不是数据就丢失了?

还有一个问题就是:调度顺序问题,如果是父进程先运行,对内存某一段进行操作导致写时复制,不免浪费表情,因为后面exec还会装载可执行文件,到那个时候自然会进行内存分配。需要注意,exec并不是创建一个新的进程,而是用一个新的程序替换了当前进程的几个段

所以总的来说,fork的开销就是复制父进程的页表以及创建PCB。这里又出现一个概念:页表,这个放在存储器管理方式中。

该章节中所参考或者部分转载的文章

文献:浅析进程概念

PCB与进程分配资源

浅谈程序的内存布局

一篇文章了解C语言函数调用栈——程序员进阶必备

linux内核栈与用户栈

进程管理—进程描述符(task_struct)

Linux内核中经典链表 list_head 常见使用方法解析

Linux进程内核栈与thread_info结构详解--Linux进程的管理与调度(九)

linux源码解析-copy_process函数

存储器管理

这块概念在计算机操作系统里面有讲,直接整理即可。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值