目录
前言
程序
指令
程序计数器
指令执行大致过程
寻址方式
指令流水线
指令乱序
程序编译
小结
该章节中所参考或者部分转载的文章
进程
为什么会引入进程概念
系统资源
内核态和用户态
进程描述符
用户进程在用户空间中的表示
程序中函数栈的使用
内核堆栈和用户堆栈
PID
进程的状态
进程家族树
进程的创建
写时复制
该章节中所参考或者部分转载的文章
存储器管理
以linux内核与设计实现第三版为主线,网上资料和书籍为辅助,进行断续整理,参考文章放在后面。整理的目的,当然就是为了对整个计算机的体系提高理解程度,但是并不会抠细节。
我是从第三章开始看的,这里面说到了一个程序的概念,因为并不是很理解,就放在开头。
程序是什么,简单的来说,程序就是指令的集合,或者说是指令序列,除了指令当然也包括了相关的二进制数据。通过这个指令的序列,去告诉计算机要做什么,详细的执行步骤是什么。
不过这里需要进行一个说明,因为计算机语言可以分为机器语言,汇编以及其他高级语言。所以说,上面解释的概念指的就是机器语言,而高级语言出现只是为了方便我们人进行编写和阅读。
而每个计算机都有指令系统,包含了计算机能够做的所有基础操作。比如说,算术运算(加减),逻辑运算,数据传送,移位,条件转移等。而指令本质就是一组二进制的代码。
通过这组二级制信息(涉及到指令的格式),我们需要指出数据的来源,操作结果的去向以及所执行的操作是什么:
而为了压缩指令的长度,可以用一个程序计数器(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了,重复利用使得多条指令同时执行,大大提高效率。
说到指令流水线就想到指令乱序,指令乱序的目的就是为了进一步提高执行的效率,减少流水线阻塞。
流水线的阻塞有三种情况:
所以,如果像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。而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代码到底是如何编译成机器指令的
从之前的说明我们了解到,程序是一个静态的概念,而进程是一个动态的概念。那么为什么需要引入进程就比较有一个准备了。
简单来说,就是因为多道批处理系统的出现,使得程序的执行出现了新的特征,而原本的程序这个概念并不能很好的表达,所以就引入进程概念。而进程的特征有:
从上面可以看到,进程最基本的特征就是动态。
这里有一个并发和并行的概念。并发这里是一个宏观的概念,就是说在一个时间段内交替做多件事情。而并行则是可以同时做,指的是时间点。
而为什么要引入多道批处理系统:那是因为单道批处理,每次只能运行一个程序,资源利用率低,cpu并不能一直处于忙碌的状态,浪费了资源。多道批处理可以一次加载多个程序,这个阻塞了,我们可以调用另外一个继续运行,充分利用cpu。
但是多个程序因为共享cpu以及其他系统资源,所以就会产生各种问题需要去解决,比如说:如何调度进程,资源的争用等。
我们经常会看到:
进程是资源分配的最小单位
那么什么是系统资源,资源由谁来分配。资源的分配当然是由操作系统。而资源,就是各种各样的软硬件资源。归纳起来分为:处理器,存储器,IO设备以及文件。OS的主要功能就是对着四类资源进行有效的管理:其中处理器管理就是如何分配和管理处理器;存储器管理就是内存的分配和回收;IO设备管理就是负责IO设备的分配和操作;文件管理就是对文件存取,共享和保护。
说到这里我们就延伸一下,操作系统的作用有哪些,这里引用计算机操作系统里面说的三个点:
所以我们可以说:
操作系统对计算机中的资源进行抽象,屏蔽了具体的细节,向上提供各种的数据结构以及操作命令;同时为了协调多用户对系统共享资源的使用,还维护了各种数据结构来记录和查询各个资源的使用和进程运行的情况。
我们可以将操作系统管理的数据结构分为四类:内存表,设备表,文件表以及进程表(也就是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定位到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的作用:
PCB的常用三种组织方式:线性,链接,索引。其中后面两种都有将不同状态的进行分类,如运行,就绪,阻塞,空闲。而就绪还可以按照优先级进行一个排列。
这里就说到了内核栈和用户栈的概念。而这个概念又涉及到堆栈的作用。我们还得从用户进程在用户空间中的表示说起。
一般来说,进程使用的内存空间有默认如下分段:代码段,初始化数据段,未初始化数据段,栈,堆,其他。其他就忽略了。copy一张图过来,更加形象。
图中的unused是未使用的地方,也就是当空间不足时,堆栈可以进一步使用的方向。
代码段:位置放在堆栈的下边,可以防止堆栈溢出而被覆盖。可以看到代码段text是只读的,这样可以保证在执行的时候不会被修改。
初始化数据段:也称为数据段。包含全局变量和静态变量,在编译的时候就已经初始化。还可以细分为只读和可读写,常量就是只读的。
栈:栈很重要,没有栈就没有函数,就没有局部变量。栈为函数工作提供独立的工作空间,这个空间就是栈帧,栈中存储的是一个个栈帧,栈帧保存了一个函数调用所需要维护的信息,所以说函数所占空间就是栈帧所占用的空间:
堆:堆的作用就是为了弥补栈的不足,栈帧是操作系统管理分配的,在函数调用完成之后就会直接释放。所以如果需要在函数调用完成之后依然存在,就需要进行内存动态申请的。用动态是说,操作系统并不能预知你要用多少,而是我给你分配,然后你只能用这么多。
为了实验我们自己动手弄个:
#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 :
#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
}
1f: c9 leave
20: c3 ret
0000000000000021 :
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
return d;
4c: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] //将存储在栈帧中的值放入eax中,调用者会去取出
}
4f: c9 leave //还原,先将rbp中的值赋值给rsp,然后弹出之前压栈的调用者函数栈低,更新rbp
50: c3 ret 将栈顶的放回地址弹出程序计数器,然后继续往下执行
0000000000000051 :
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 //相当于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有什么关系?问题真的是越看越多。这里就留着坑吧,毕竟知识是可以相互关联的,后面如果有发现再来补充。
回到进程描述符继续往下。
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 */
}
进程的状态有基础五种:
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函数
这块概念在计算机操作系统里面有讲,直接整理即可。