Linux之进程概念

  • Post author:
  • Post category:linux



目录


一、冯诺依曼操作系统


二、操作系统(Operator System)


前言


概念


操作系统是什么?


OS为什么存在?


OS是怎么实现的呢?


什么叫做管理?


操作系统如何管理进程呢?


如何为程序员提供各种功能呢?


系统调用接口 vs C语言库函数 vs C++语言库函数 vs Python vs Java?


三、进程


基本概念


为什么要有PCB?


PCB是什么?


进程是什么?如何查看


进程和程序的区别?


总结


四、PCB的内部构成


标识符


return 0;的意义


任务状态


优先级


内存指针


记账信息


如何理解上下文?


查看进程的第二种方式


五、通过系统调用创建进程-fork初识


如何理解fork创建子进程?


总结


fork的返回值


如何理解有两个返回值?


如何理解fork两个返回值的设置?


fork之后父与子谁先运行?


六、进程状态


linux中的几种状态


R—运行状态  一定正在占有cpu吗?


S/D状态


状态的验证


R状态


S状态


Z状态


僵尸进程危害


七、进程优先级


查看进程优先级


为什么会有优先级?


nice值为何是一个相对比较小的范围呢?


八、环境变量


基本概念


常见环境变量


查看环境变量方法


和环境变量相关的命令


测试PATH


./本质就是帮系统确认对应的程序在哪里。那么为何系统的命令就不用带路径呢?


如果我也想我的myproc执行的时候不带./该怎么办呢?


测试HOME


为什么不同的用户在登录时起始的目录不一样呢?


本地变量


命令行参数


环境变量的组织方式


命令行参数


使用命令行参数有什么用呢?


如何通过代码获取环境变量


环境变量通常具有全局属性


十、程序地址空间


程序地址空间回顾


上面那个我们以前叫做C/C++程序地址空间。那这个是内存吗?


验证程序地址空间分布


进程地址空间是什么?


OS是如何做到的呢?


地址空间和物理内存在什么关系呢?


为什么要搞得这复杂呢?进程直接访问物理内存不就行了吗?为什么还要搞张表?


如果直接让你的进程访问物理内存,这里会引发什么问题呢?


常量区的代码为什么不能被修改呢?


为什么要有地址空间?


解释程序父子进程全局变量发生写时拷贝后地址为什么仍然一样?


小问题:str与p的地址是一样的吗?


一、冯诺依曼操作系统


截至目前,我们所认识的计算机,都是有一个个的硬件组件组成


  • 输入设备:包括键盘


    ,


    鼠标,扫描仪


    , 写板,

    磁盘,网卡,显卡,话筒,摄像头



  • 中央处理器


    (CPU):含有运算器和控制器等,

    所谓的cpu也就是芯片:这个芯片具有运算能力。

    运算器:算术运算和逻辑运算;    控制器:读取指令,分析指令,执行指令,响应某种中断信号,执行某种中断方法。


  • 输出设备:显示器,打印机,

    磁盘,网卡,显卡,音响等
  • 输入和输出设备不是独立的两套设备


关于冯诺依曼,必须强调几点:


  • 这里的存储器指的是内存

  • 不考虑缓存情况,这里的


    CPU


    能且只能对内存进行读写,不能访问外设


    (


    输入或输出设备


    )

  • 外设


    (


    输入或输出设备


    )


    要输入或者输出数据,也只能写入内存或者从内存中读取。

  • 一句话,所有设备都只能直接和内存打交道。


这里详细讨论下存储器:

不同的存储介质存储效率是不一样的。凡是离cpu越近的存储设备,它的效率越高,但是单价成本越高。 eg:寄存器的加个就特别贵

这里我们首先引入一个概念,木桶原理:木桶原理是一个木桶能装多少水,并不取决于最长那块木板,而取决于最短的那块木板,想装得更满,必须补最短的木板。


cpu理论上可以直接从输入设备读数据到cpu里,然后在cpu内把结果计算完打印在输出设备上,直接摒弃存储器,但是确没人这么做,根本原因就是外设(输入设备)和cpu的效率相差太大了

,输入设备的效率是毫秒或者秒级别的,cpu则是纳秒级别的,如果我们不要存储器,直接就是外设输入到cpu再到输入设备,肯定能跑,但是整个体系的效率,最终并不是由cpu决定,而是由输入输出决定。换句话说,跑个程序别人可能一瞬间就完了,但是你却要几毫秒甚至是几秒,因而整个计算机的效率就变的特别的低了。其中毫秒级别的设备典型的是磁盘,秒级别的设备典型的是网卡。所以这样的方式一定会让计算机的效率变的特别的低。

所以当代计算机就引入一个设备叫做存储器(内存)

,它的特点是比输入输出(外设)快一到两个数量级(这个只是个大概并不能一概而论),比cpu的运行效率低,存储器本身也具有存储数据的能力,所以外设输入进来的数据不要读到cpu里,而是直接放到存储器里面,等到cpu闲的时候,存储器再把数据读到cpu内,cpu在内部计算,计算完成后再把结果写到存储器中,然后在再把数据定期的刷新到输出设备上。

有了内存,cpu就不需要在和外设打交道了,而是和内存打交道,所以这样的话,我们就可以看出内存的价值,在没有任何软件优化的情况下,整个硬件体系结构的效率就有存储器决定,因为任何外设把数据都要交个内存,而数据计算的时候都要经过内存读到cpu内进行计算。


内存是体系结构的核心设备

—所有的外设要有数据交互的时候必须把数据先给内存,然后cpu再从内存读取数据,读完数据在把数据写回到内存,然后再由内存刷新到外设。其中我们输入设备交给存储器的过程我们叫做input,把数据从内存刷新到输出设备上的过程叫做output,我们把input+output简称为IO。

eg:我们的输入设备可能是磁盘,输出设备也可能是磁盘。典型的就是我们把我们的数据从磁盘读进到内存做数据处理加工在写回到磁盘,这就叫体系下结构内部的磁盘IO;

如果我们从网卡里读上数据,读到内存当中,经过cpu计算在转到输出设备在转到网卡里,这叫做网络IO ;

这里的IO是一个宏观的概念,包括网络IO和本地IO。区分是本地还是网络完全取决于你的输入和输出设备分别是谁。



对冯诺依曼的理解,不能停留在概念上,要深入到对软件数据流理解上.


冯诺依曼的数据流向是怎么个流向法呢?

比如你在成都,你的朋友在吉林,你在你的笔记本上通过QQ发消息给你朋友。请站在纯硬件的角度上解释数据流向的问题。

你的计算机是一组冯诺依曼,对方的计算机也是一组冯诺依曼。当你在输入的时候你是通过键盘输入的,键盘输入的数据在硬件上一定交给存储器(内存),qq软件就在我们的内存中,然后数据要发出去,数据发的时候一定要计算(我们会添加一些网络相关的内容,要添加就要拷贝,就需要cpu一个字节一个字节的拷贝),cpu就从内存中读取数据,在cpu内做出计算(什么是计算?eg你发一个消息如果是明文传送就可能会被不法分子截取,所以要对发送的消息内容进行加密,加密逻辑就需要cpu帮助你计算。)处理完后写回到存储器,再把数据经过输出设备,最典型的就是网卡,网卡最终把你的数据发送到网络当中,网络经过各种转发发送到你朋友的设备中去,你朋友的笔记本收到这条数据一定是网卡这个设备先拿到,此时这里的网卡充当的就是输入设备,拿到的数据先读到内存,你朋友的qq同样启动着,qq启动就在内存,所以你的qq程序把网卡数据读到qq里面其实就是读到了内存里面,然后进行解密等等,然后在写回内存,然后进行刷新,此时在刷新到你朋友的显示器上。


总结:

任何外设,在数据层面,基本优先和内存打交道。CPU在数据层面上也直接和内存打交道。如上就是典型的冯诺依曼结构,我们不管软件如何写,操作系统如何做,本质上都脱离不开这样的硬件特性。操作系统,cou,各种各样的软硬件都是围绕着冯诺依曼展开的,而操作系统本身无非就是加载到内存当中的一款软件。操作系统的工作就是提前把各种外设的数据提前缓存到存储器当中,然后尽快的让cpu读取。其中数据提前缓存,数据刷新到外设这样的工作基本是由软件逻辑控制。



二、操作系统




(Operator System)


前言

必须是启动的操作系统才有意义。没启动的时候操作系统在磁盘或者外设当中。启动的本质是让软件数据与代码加载到内存当中。换句话说,只有把操作系统加载到内存中,操作系统才有意义。



概念



任何计算机系统都包含一个基本的程序集合,称为操作系统


(OS)


。笼统的理解,操作系统包括:

  • 内核(进程管理,内存管理,文件管理,驱动管理)

  • 其他程序(例如函数库,


    shell


    程序等等)

操作系统是什么?

OS是一款软件,专门针对软硬件资源进行管理工作的软件。

eg:银行当中桌椅板凳,仓库,各种电脑,宿舍都叫做银行的硬件。银行具有某种能力的员工我们可以称之为银行的软件。银行的软硬件都在,即可以实现帮助我们进行存款,取款,贷款的任务。银行除此之外还拥有一个管理团队,负责银行的管理工作。这样的话管理团队,执行团队和银行的硬件资源协调起来,才能形成一个系统帮我们完成某种核心工作。这个管理团队就可以类比成操作系统。


通过管理好软硬件资源,给用户提供稳定的,高效的,安全的运行环境。就叫做操作系统。

OS为什么存在?

对下:管理好软硬件资源(方式)。对上:给用户提供稳定的,高效的,安全的运行环(目的)。


通过管理好软硬件资源,给用户提供稳定的,高效的,安全的运行环境

OS是怎么实现的呢?



操作系统核心理念–管理


。现实生活中的管理一定要有两件事情去

1.0决策2.0执行

eg1:今天吃包子是个决策,去吃了叫做执行。生活中纯粹的决策或者执行是完全不存在的。

eg2:通过学校管理进行解释(这里将决策和执行分离开,并且进行模型简化)

做决策的人:就是校长。

执行的人:辅导员。

管理者:校长。

被管理者:学生。

1.0管理者和被管理者并不直接打交道。

2.0如何管理你呢? 对你做出各种决策,决策是要有依据的(依据你的属性数据 )

3.0管理者和被管理者并不直接打交道–你的数据又是怎么被校长知道的呢?–辅导员

管理者和被管理者并不直接打交道–校长的决策的执行由辅导员执行。

总结:以上都是我们在解释管理这个概念。类比到计算机上,以下是计算机的体系结构(宏观),微观上是冯诺依曼。

os就是管理者,它进行管理就必须对硬件进行管理,就要拿到硬件的各种属性数据,做各种决策也必须得让硬件执行,可os并不直接和硬件打交道,就需要有各种各样的驱动程序,计算机中每一种硬件都几乎配有一种驱动程序(eg:电脑插入鼠标,显示插入一个驱动程序),os不仅要对硬件管理,同样也要对软件进行管理,最经典的软件管理就是四大块,进程,内存,文件,驱动。目前os已经管理好了软硬件资源,目的就是对上提供一个良好的运行环境

管理就是对数据管理: 对软硬件属性数据进行管理,知道数据后进行决策。决策全部是有我们的数据所决定的。


站在校长的角度重新认识数据:


1.如何聚合同一个学生的数据呢?

我们可以通过一个结构体

struct stu
{
  char name;
  char sex;
   ...
  //学生的所有属性
}

如果这个学校只有5个学生的话,校长就可以通过定义5个结构变量struct stu zhangsan={…},lisi={…},…这样看他们的数据。但是如果如果有50000个学生,就需要定义50000个结构变量,并且他们之间没有任何关系,这样对于校长管理的成本就太高了。


2. 如何将多个学生的聚合数据产生关联呢?

在struct stu 添加这些链接属性
struct stu *next;   struct str *prev;
struct stu *left;   struct str *right;

然后将所有的学生信息转换成一个双链表或者二叉树等等组织起来

接下来对学生的管理工作,变成了对数据结构的增删查改。

eg:校长可以将学生的学分信息设置成最小堆或者最大堆,这样就可以对成绩好的同学进行奖励。再如:假设将学生的信息转换成一个链表,就可以通过遍历找到某个学生的信息,如果想开除这个学生,就直接删除这个节点,如过学校要新增一个学生,就直接增加一个节点。可以基于这样的结构编写各种各样的算法。

什么叫做管理?

1.先描述被管理对象    2.再组织-将被管理对象使用特定的数据结构组织起来

所以数据结构是操作系统的核心技术。关于操作系统的管理就是先描述再组织。


管理的概念:先描述,再组织,可以转换对目标的管理,转化为对数据的管理。

对普通用户:为用户提供良好的运行环境

对程序员:为程序员提供各种基本功能

操作系统如何管理进程呢?

先描述,再组织。描述进程的结构体-PCB(进程控制块)。为什么要有PCB?上述。

如何为程序员提供各种功能呢?

OS不信任任何用户,类比现实:银行,银行是不会让我们自己去钱库取钱的,而是提供了银行柜台(窗口)的方式操作。OS则是通过系统调用接口,OS为保证自身的安全性和完整性,不会让用户不会让用户直接去访问它内部的各个功能模块,而是必须对外提供各种接口,让上层用户通过调用系统调用的方式来完成功能的调用,

系统调用接口,叫做OS提供的接口,说白了就是函数

,操作系统提供的接口就是函数调用,Linux是C语言写的,这里就是C函数。换言之可以理解成就是操作系统提供出来的各种C语言对应的接口,然后经过这样的C语言函数调用就可以完成某种函数。

系统调用接口 vs C语言库函数 vs C++语言库函数 vs Python vs Java?

系统调用接口比较复杂一些,就有一些厉害的人,对系统调用接口进行软件封装!软件封装:有可能是以第三方库的形式呈现的,也有可能是以语言的方式呈现的(C标准库,C++标准库)。


什么意思呢?

有很多系统调用接口的成本太高了,有人就对其做了写基本封装作为第三方库(除了语言之外新安装的库成为第三方库,比如STL)。有了这样方式的呈现,人们在实际编程的时候并不直接使用系统调用接口,或多或少直接采用语言的方案进行变成。就像printf,这是C语言为我们提供的接口,再往下他就一定要使用操作系统的一些接口,完成对应他打印的一些工作,所以我们的系统调用和我们现在使用的主流的一些语言和第三方库他们两个是叫做上下层关系!

C语言库函数 ,C++语言库函数 ,Python ,Java这些都是上层内容。系统调用接口是下层内容。

不是所有的C/C++接口都会使用系统调用,像访问硬件的操作都跟系统有关系,但是像他们提供的数学库就没有调用系统调用接口。C,C++,java,python这样的上层语言如果涉及到对硬件的操作例如网络,读取,磁盘读取,文件IO,还有显示器打印等涉及到硬件的操作最终一定在语言内部调用了系统调用接口,也不是所有的系统调用都被封装了,不同语言支持力度是不一样的。

三、进程



基本概念



  • 课本概念:程序的一个执行实例,正在执行的程序等

  • 内核观点:担当分配系统资源(CPU时间,内存)的实体。

进程:加载到内存的程序,叫做进程(书本)。所有的程序运行时必须得加载到内存中。

系统中可不可能存在大量的进程?可能,操作系统要不要管理进程呢?必须、如何管理进程呢?先描述,在组织。

任何进程在形成之时,操作系统要为该进程创建PCB(进程控制块),这个PCB对应的概念就是先描述的过程(它将来在操作系统内部一定是个struct,struct内部包含的是进程的所有属性)。

为什么要有PCB?

答案就是要管理进程必须得先描述再组织,在描述的时候就得用结构体描述进程的相关属性,所以就有了PCB。

PCB是什么?

OS上面,PCB是一个进程控制块,在语言上就是一个结构体类型。

在linux系统中,PCB就叫做struct task_struct { //进程的所有属性} 类比shell和bash ,媒婆和王婆。

shell就是所有命令行的统称,Bash是一个具体的命令行,媒婆是这个做媒婆角色的总称,王婆是一个具体的媒婆, PCB是操作系统上一个总领的概念,而在具体的linux系统上它的进程控制块就叫做struct task_struct。

进程是什么?如何查看

我们用一个myproc.c为例。

写好后执行该程序。并单击会话,复制该会话

这样我们就可以同时操作,下面是查看进程的指令

把左边的任务ctrl+c,进程就退出来了。

在命令行运行一个程序就是创建一个进程;在手机上打开一个app,在系统层面上就是创建了一个进程;其在windows中打开一个软件,也是创建一个进程。

其实我们无时无刻都在创建进程,曾经我们所有的启动程序的过程,本质就是在系统上面创建进程!!!

eg:你在vs上写了份代码,要让这个代码运行起来,就是让你的代码变成进程,才能运行起来。所以以后程序运行起来就叫做进程!

进程和程序的区别?


当你把程序编译好的时候没有运行,他在磁盘上就是一个文件,程序本质上就是文件 ,叫做可执行文件或者二进制文件仅此而已。

操作系统要运行这个程序,那么在磁盘上的这个程序一定要加载到内存。操作系统要对加载进来的文件管理,一定是先描述,再组织。

可执行程序的文件必须也被加载到内存,操作系统要创建这个进程的时候除了在内存里要有这个可执行文件的代码和数据也就是磁盘上的这个文件加载到内存之外,操作系统还要在系统内部为你这个进程创建一个task_struct。

task_struct这个数据结构和加载到内存中可执行程序统称为进程。

eg:如果你在某个大学内,你就能说你是这个学校的学生吗?答案肯定不是,你如何证明你是你们学校的学生呢?除了证明你自身在学校里面,还得保证教学管理系统中包含了你的一部分属性。校长曾经针对你这个人先描述再组织过。

进程=程序文件内容+相关的数据结构(与进程相关的数据结构eg:task_struct[由操作系统帮助我们自动创建,包含了进程内部的所有的属性信息])。换句话说就是:你磁盘上有一个程序,进来加载到内存里,操作系统就给这个程序创建了一个task_struct;又来个进程,也加载到系统了,把自己代码和数据加载到操作系统内部,操作系统就也给它创建了一个PCB,所以进程就叫做程序文件和操作系统为了维护这个进程所创建的数据结构。

这个task_struct可以想象成list的node节点,包含了进程内部的所有的属性信息(也是数据)。

假设系统里存在5个进程叫做进程1~5,这里的框就是磁盘上的一个个可执行程序加载到内存,操作系统为了管理这5个进程必须得先描述,在组织。如何描述呢?操作系统立马给每个进程都创建了一个叫做task_struct结构体描述的是每个进程的相关属性 。PCB里就包括了进程的所有属性,同样包括代码和数据。同样还可以包括PCB* next和PCB* prev,我们可以把他们用链表信息把他们的结构连接起来。这样就完成了先描述再组织的,描述使用PCB描述这个进程的相关属性信息,链接就可以把所有的进程组织起来。将来我们操作系统想找某个进程就是找PCB的过程,找到这个链表的头部遍历这张链表。结束进程就是把某个PCB节点干掉。

所以以后操作系统找进程并不是找对应的进程加载到内存中的代码和数据而是找这个进程的PCB.假设这有个cpu,cpu说操作系统啊,我闲着呢,能不能给我几个进程,操作系统说好啊,cpu维护了一个运行队列,此时指向NULL,操作系统给CPU进程就通过把task_struct这个结构体变量链接到cpu的队列当中,task_struct依旧指向代码和数据。我们只需要让进程控制块排队,cpu就可以找到这个进程,找到这个进程这个进程的所有属性我们就能知道,同时这个进程对应的代码也能找到,所以cpu就能跑你的代码。

所以有了进程控制块,所有的进程管理任务与进程对应的程序毫无关系,与进程对应的内核(OS)创建的该进程的PCB强相关。

eg:你要找工作,面试官面试完你了,面试官一共面试了10个人,决定录取某个人的时候,肯定不是把你们10个人都叫过来当场宣布要录取某个人,当面试官面试的时候每个人的面试得分就都记录下来了,面试官对面试数据里进行排序,在前面的优先考虑,所以当你一旦面试完后,录不录取你就和你这个人没关系了,而是由你当时的面试数据所决策的。

同样的当一个进程把他的代码数据加载到内存中后那么此时操作系统已经不在关心你的代码和数据了,OS关心的是你这个进程对应属性数据也就是PCB该如何维护起来,维护起来后如果要进行进程状态的相关设置话,只要找到进程PCB就能控制你的这个进程,这叫做对进程的管理转成OS对进程数据的管理也即先描述再组织。

总结

  • 进程=程序+操作系统维护进程的相关数据结构。
  • 程序本身有自己的代码和数据,一旦加载到OS内部,此时OS就会为这个进程创建相关的数据结构。

四、PCB的内部构成


task_struct里面有什么?



task_ struct




内容分类


  • 标示符


    :


    描述本进程的唯一标示符,用来区别其他进程。

    叫做Pid

  • 状态


    :


    任务状态,退出代码,退出信号等。

  • 优先级


    :


    相对于其他进程的优先级。

  • 程序计数器


    :


    程序中即将被执行的下一条指令的地址。

  • 内存指针


    :


    包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针


  • 上下文数据



    :


    进程执行时处理器的寄存器中的数据


    [


    休学例子,要加图


    CPU


    ,寄存器


    ]




  • I





    O


    状态信息


    :


    包括显示的


    I/O


    请求


    ,


    分配给进程的


    I





    O


    设备和被进程使用的文件列表。

  • 记账信息


    :


    可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。

  • 其他信息

标识符

pid的查看,getpid函数,返回值是认为是无符号的整数。

以这段代码为例:

输入此条命令查看进程


杀死进程kill -9 +进程pid

getppid 获取自己的父亲进程的pid

我们发现这个11646的进程就是bash.


在命令行上运行的命令,基本上父进程都是bash.


return 0;的意义

这个返回值叫做进程退出时的退出码,返回的时候说是返回给系统了,更准确的说,这个数据最后被父进程拿到了,也就是返回到了对应的系统,通过系统让父进程拿到。

ls报错后退出码就变成了2

当我们在进行main函数执行完后return 100的时候,C语言编译运行后变成进程,return 100就是直接写入到进程控制块中,OS就知道了退出码,然后就可以让其他进程读取。

任务状态

一个进程可能处于某种状态,运行状态,等待状态,死亡状态,阻塞状态…OS根据进程不同的状态做不同的事情。eg:生活中你会处于学习状态,睡觉状态,游戏状态…人本身也会根据自身状态衍生后续内容。

优先级

像生活中的排队,决定先后问题,与权限的差别,权限决定的是能或者不能的问题,能不能读写删,优先级则是你已经能了,只不过是先后顺序的问题。比如:你没买票进不去进去这是权限的问题,你已经买了票进行排队你先进还是后进这就是优先级的问题。

内存指针

通过PCB中的内存指针帮我们找到代码中的内存和数据。

记账信息

OS内有一个模块叫做调度模块,实际上是一个算法:较为均衡的调度某个进程(让每个进程都能公平的获得cpu资源,让进程被执行)。比如:飞机的航线只有一到两条,通过地面的调度让多架飞机公平的使用航线。我们如何保证公平呢?就通过记账信息。

如何理解上下文?

cpu年内部包括各种临时存储单元,他们大小不一,功能各不相同,这些临时存储单元叫做寄存器。保存的是当前正在运行的进程的临时数据,EIP就是临时指针,ESP栈顶,EDP栈底,EAX,EBX,ECX,EDX四个通用寄存器,还有状态寄存器等等。

每个进程都有自己的PCB,cpu维护自己的一个run_queue。内存指针帮我们找对应的代码和数据。cpu要运行的时候找到这些进程PCB又通过内存指针帮我们找对应的代码和数据,加载到cpu内就可以跑了。那么进程就相当于被cpu选中放到cpu中运行,但是进程的代码可能不是很短的时间就能运行完的。比如第一个进程运行跑10秒,cpu给了10秒,站在用户的角度只有这一个进程在运行,其他的进程都不运行,这就是不公平的,所以OS规定规定每个进程单次运行的时间片:比如说每个进程需要的时间都不同,os是不会按他们各自所需要的时间进行分配的,而是给一个时间片比如这个时间片是10ms,第一个进程跑了10ms后,os就不让第一个进程跑了,立马把第一个进程从cpu剥离下来放在队列的尾部在重新排队,然后再让下一个进程跑10ms,如果进程在10ms的时间内结束了那最好,如果没结束,10ms后就停止,进程再去重新排队。这就叫做时间片,


时间片是一个进程单次运行的时间的最长数叫做时间范围。


好处:比如我有4个进程,在40ms以内每个进程的代码都得以推进,如果在10秒之内在用户看来好像这四个进程都同时运行,在单cpu情况下,用户感受到的多个进程在同时运行,本质是通过cpu的快速切换完成的。 eg:任务管理器。

进程在运行期间是有切换的!进程可能存在大量的临时数据,暂时在cpu的寄存器中保存,当前进程1在运行cpu中的这一套寄存器就是进程1的,当进程1倍剥离下来后,这个cpu的寄存器也应该给别人让出来。可是cpu只有一套。

此时进程A正在运行,在cpu内放了一堆临时变量,当他运行的时间片到了后,能否直接B接上,然后将B的临时文件覆盖掉A的,答案显然是不行的如果这样做了,A下次执行就找不到它的临时文件了。虽然寄存器硬件只有一份但是寄存器里面的数据是你这个进程的,A运行完了不要着急着走,而是将寄存器里面的数据保存到A的PCB里面(将寄存器内容保存到PCB目前先这样理解,但是这种说法是错误的),所以目前cpu中寄存器的内容就没啥用了,然后A就可以走了,接着调度B,B又重复该操作,B走的时候同样拿上自己的临时数据,然后在调度A,但是调度A之前先把之前的临时数据回复到cpu上,这样就继续了上次的运行状态,这就叫做A的保护上下文和恢复上下文。

所以这里的上下文就是:

进程执行时所形成的处理器的寄存器当中与进程强相关的临时数据

保护上下文和恢复上下文:为了让你去做其他事情,但是不耽误当前,并且当你想回来继续学习的时候,可以接着之前你学习的内容,继续学习。


为什么上下午文如此重要?因为通过上下文,我们能感受到进程是被切换的!

查看进程的第二种方式

ls /proc

proc这个目录是linux默认自带的给我们查看进程的一个目录,在命令行上看到的目录都是加载到内存上的。进程也是在内存里面,proc目录让我们以文件的方式查看进程。

这些蓝色的也是目录,这些目录都是进程ID,换句话说我们的进程启动后会在proc目录下形成一个目录,以自身pid的编号作为目录文件名,形成一个文件夹,当进程退出时,这个文件夹会自动消失。

cwd的作用就是创建文件不带路径名,默认在cwd下创建文件



五、通过系统调用创建进程




-fork




初识



fork是创建子进程的函数

eg1:

执行的时候发现打印了两次

eg2:

eg3:

为什么有这种现象呢,因为fork之后有两个执行流,有两个执行流。两个进程接近执行while循环两个执行流一个进if一个进else。其实跑各种指令的时候在系统层面上就是创建进程,只不过你的进程很快就执行完了。

eg:4

这个30807就是命令行就是Bash,31339调用的fork,31339的子进程是31340,31339的父进程是30807,bash是31339的爹, 31339是31340的爹。bash创建子进程,子进程再创建子进程。执行结果第一行是父进程,第二行是子进程。

如何理解fork创建子进程?

目前创建进程的方式有两种:

  • ./cmd或者run command  (执行程序或跑命令)
  • fork

在操作系统的角度,这两种创建进程的方式是没有任何区别的。

新增一个进程fork本质是创建进程,会导致系统里多了一个进程(与进程相关的内核数据结构+进程的代码和数据[在系统里面多了一份这个东西])。就比如运行./myproc,系统一瞬间会启动个进程,创建好各种数据结构,对应新进程的代码数据就是myproc。


可我们只是fork创建了子进程,但是子进程对应的代码和数据呢?

默认情况下会“继承”父进程的代码和数据。因为用fork创建子进程的时候,系统里面多了个进程,和父进程不一样,父进程创建出来,父进程是有对应磁盘的可执行程序去运行的,但子进程只是创建出来了,没有对应的代码和数据,所以子进程会执行父进程fork之后的代码访问父进程相关的数据。另外,除了继承父进程的代码和数据在运行期间,在内核数据结构task_struct也会以父进程为模板初始化子进程的task_struct.

eg:你爸是一个做鞋的工厂的老板,当你诞生的时候可能有两件事情,第一件就是你爸有了你之后,你的基因里面有一部分要以你老爹为模板。第二个:当你长大以后你可能是要子承父业的,你也去做鞋,这叫做继承你爸的代码和数据。当然另一种情况你自己左和你爸不同是事情,这种情况我们放到后面去讲。


fork之后子进程和父进程的代码是共享的。那么创建子进程之前的代码也会共享吗?

答案是也会共享。只不过两个父子进程他都各自执行了后半部分,只不过是之前代码不进行执行。fork之后指的是具有了子进程,在理论上父进程和子进程的代码是完全一样的。(继承了父进程的程序计数器,就执行后面的代码了,当然你也可以修改程序计数器,让子进程执行前面的代码)。代码是不可以被修改的,父子代码只有一份,就比如你继承了你爹的工厂但是你爸还在,所以你和你爹是共享这个工厂的。


那么数据呢?默认情况下数据也是共享的,不过需要考虑修改的情况。

进程是具有独立性的(eg:windows当中同时登录qq,微信,qq挂掉了是不会影响微信的)。如果数据是共享的,那么父进程对数据进行了修改,子进程是可以看到的,相当于父进程可能影响到子进程,那么这两个进程就没有独立性。所以数据是通过一种技术

写时拷贝”

来完成进程数据的独立性。如果父子进程被创建出来,我们俩都不进行写这个数据,那这个数据就是共享的。但是如果任何一个人想修改这个数据,那么系统会立即对它进行管理。

比如进程1和进程2共享了一份数据,当进程1尝试修改数据,操作系统会干涉因为OS是进程的管理这又是内存的管理者,OS就会在内存中重新开一块空间把这部分数据拷贝过来,让一号进程访问新拷贝出来的,二号进程访问原来的,这样进程对数据的操作就分开了,这就叫做写实拷贝。所以通过写时拷贝维护进程的独立性。不要让多个进程运行时互相干扰。


那为什么这么干呢?我干脆让创建子进程的时候把数据让父进程和子进程一人拷贝一份,这样不也行吗?

原因就是自己创建出来的父子进程不是所有的数据都会写入,也不是所有的数据都需要写时拷贝,如果每一次创建子进程的时候都拷贝一份数据,这样会导致fork的时候效率特别的低,会导致浪费空间,有可能父子创建出来从来不需要写入数据就单纯是读的,所以只有用的时候才给你拷贝这样才是合理的。

总结

fork创建子进程,默认情况下会“继承”父进程的代码和数据,内核数据结构task_struct也会以父进程为模板,初始化子进程的task_struct。代码是共享的不可以被修改的,数据则是写时拷贝。

fork的返回值

fork的返回值pid_t 这里

理解为整数即可

我们创建的子进程,就是为了干和父进程一样的事吗?这样有意义吗?一般是没有意义的!!!一般还是要让子进程和父进程做不一样的事情,我们通过fork的返回值完成 ,通过if else分流让父子做不一样的事情。

fork的返回值

  • 创建失败:<0;
  • 创建成功:会有两个返回值,一个是给父进程返回子进程的pid,另一个是给子进程返回0。

eg:

#include<iostream>    
#include<unistd.h>    
int main()    
{    
  pid_t id =fork();//这里将pid_t理解为int即可     
  std::cout<<"hello proc: "<<getpid()<<"  hello parent: "<<getppid()<<" ret: "<< id 
  <<std::endl;                                                                        
  sleep(1);    
  return 0;    
}  

从ret我们可以看出第一个hello是父进程,第二个是子进程。


如何理解有两个返回值?


这里存在一个很重要的问题,我们之前学过的函数只有一种返回值,但是这里的fork却有两种返回值,如何理解有两个返回值?


如果一个函数已经开始执行return了,函数的核心功能执行完了吗?

答案是已经执行完了,fork是创建子进程,当他准备return的时候创建子进程的逻辑已经完了,子进程已经有了,代码是共享的,所以父进程执行后序语句,子进程也要执行后序语句,所以父子进程都return。一个函数开始return,核心功能已经执行完了,只不过我们通过return告诉上面我做完了,当我正准备返回的时候子进程也被创建出来了,所以这个时候return的执行是父与子都进行执行的。return 也是一条语句。

返回值是数据,return的时候也是要进行写入的(有返回值的函数就会有接收方,把数据给给接收方就叫做写入),这个接收方同时是保存数据的空间,当父子两个执行流在返回的时候都要尝试写入,谁先返回就发生了写时拷贝的问题。


id就是一个接收方。

如何理解fork两个返回值的设置?

就好比现实生活中我们只有一个亲生父亲,但是父亲可能有很多个小孩。

父进程:子进程=1:n

;父进程想要找到子进程就必须知道子进程的pid,也就是子进程的唯一标识,而子进程是不需要去找父进程的因为子进程可以直接找到父进程因为它只有一个父进程。因为是一对多所以要给父进程返回子进程的pid达到父进程控制子进程的目的,而子进程不需要知道,通过getppid获得父进程,因为父进程只有一个。

eg:多进程代码

#include<iostream>                                                                                                                                                      
#include<unistd.h>    
int main()    
{    
  pid_t id =fork();//这里将pid_t理解为int即可     
  if(id==0)    
  {    
    //子进程    
    while(true)    
    {    
      std::cout<<"I am child, pid: "<<getpid()<<", ppid:"<<getppid()<<std::endl;    
      sleep(1);    
    }    
  }    
  else if(id>0)    
  {    
    //父进程    
    while(true)    
    {    
      std::cout<<"I am parent,pid: "<<getpid()<<", ppid:"<<getppid()<<std::endl;    
      sleep(2);    
    }    
  }    
  else    
  {    
    //创建失败    
  }    
  sleep(1);    
  return 0;    
}    

fork之后父与子谁先运行?

不确定,调度器决定。

六、进程状态


状态也是数据,在pcb中保存


进程的状态信息在哪里呢?在task_struct(PCB)中。


进程状态的意义:方便OS快速判断进程完成特定的功能,比如调度,本质是一种分类!


linux中的几种状态


  • R


    运行状态(


    running





    :


    并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。

  • S


    睡眠状态(


    sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(


    interruptible sleep


    ))。

  • D


    磁盘休眠状态(


    Disk sleep


    )有时候也叫不可中断睡眠状态(


    uninterruptible sleep


    ),在这个状态的 进程通常会等待IO


    的结束。

  • T


    停止状态(


    stopped


    ): 可以通过发送


    SIGSTOP


    信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT


    信号让进程继续运行。

  • X


    死亡状态(


    dead


    ):这个状态只是一个返回状态,你不会在任务列表里看到这个状态

R—运行状态  一定正在占有cpu吗?

不一定!

它表明进程要么是在运行中要么在运行队列里。

S/D状态

当我们想完成某种任务的时候,任务条件不具备,需要进程进行某种等待,比如你想打游戏,但是你的电脑没开,在你等待电脑开机的这段时间,我们就可以叫做S状态或者D状态。系统当中任何一个资源,硬件,软件,一定有可能有多个进程在进行等待。

当你进行访问某种资源的时候如果你在访问磁盘,别人也在访问磁盘,此时磁盘被别人使用,那么你就不能进行使用。那么一个外设可能有多个进程在等,多个进程在等一定是PCB在等。也就是说有可能我的进程在等待某种外部资源就绪,比如说网卡准备好,显示器准备好……比如:你自己cin的时候,你就是不写,你会发现你的程序卡在那里了,这就叫做你的进程在进行等待。在系统中,是存在你的进程在等,其他的进程也在等的情况的,所以一个外设可能被多个进程在等待,所以这里就会有一个等待队列,PCB在等待,这时候的进程处于等待状态它们就不可被调度,所以它们就成为S/D状态。

千万不要认为进程只会等待CPU资源,进程可以等待任何想等的资源,


其中在等cpu资源的时候叫运行队列,等其他资源的时候叫做等待队列


。对于等待队列,假设磁盘可以读的时候,等待队列把第一个PCB拿出来,这个队列做的不是让这个进程读磁盘,而是把这个进程的状态设为R状态,把这个进程搬到CPU的运行队列中,此时因为等待的众多进程中只有它一个被唤醒了,所以cpu调度它的时候就让它开始访问这个外设,这也叫做它等待成功了。所以当一个进程正在执行对应的代码时发现你要读的磁盘还没好,你要等,可是不能让你在cpu上等,所以就把你这个进程状态设置成R状态,把你从cpu上剥离下来,放到等待队列尾部,然后CPU开始调度下一个进程。

所谓的进程在运行的时候有可能因为运行需要,可以会在不同的队列里,在不同的队列里,所处的状态是不一样的,状态的本质是对进程进行的一种分类,所以我们用不同的队列完成进程不同的分类,完成进程一方面在运行,一方面在等某种资源。

所以我们会发现一个现象:在windows打开某种软件发现他会卡死,为什么会卡死呢?原因就是这个软件可能因为某种条件不就绪,就比如打开网易云后,突然断了网,就会导致它一直在网卡当中等待网络数据就绪,也就是它的状态一直是S状态,一直没有被运行,所以这个时候windows就提醒该程序未响应,是否关闭或者继续等待,你就会看到卡死的现象。

我们把从运行状态的task_struct(run_queue),放到等待队列中,就叫做挂起等待(阻塞),从等待队列,放到运行队列,被CPU调度就叫做唤醒程序。

eg:这个进程就卡住了,它在等待10秒的超时就绪,如果不超时它就在哪一直等待。S状态可以直接ctrl+c终止

  • S状态叫做休眠状态,可中断睡眠(浅度睡眠),可以被杀掉。
  • D状态,深度睡眠,不可中断睡眠。进程如果处于D状态,不可被杀掉。

比如:内存中有一个进程,有个OS,假设进程要把数据写到磁盘上(外设),在磁盘进行写入数据的时候,你的进程因为磁盘在工作,你的进程就只能等磁盘将数据写完告诉你写完的结果,但是这时候OS发现你这个进程啥也不干,且目前系统可用资源太少,并且你这个进程处于休眠状态,所以就把你这个进程干掉了,但是这个时候磁盘完成了工作,当他要把结果返回给这个进程的时候发现进程不在了,最终就会或多或少的造成一些问题。所以进程就出现了D状态,进程如果处于D状态,不可被杀掉。

那么如何处理这个处于D状态的进程呢?1.等他的任务完成 2.计算机重启。


T状态:暂停状态。S状态会有状态刷新,但是T状态就是完全的暂停,不会有任何数据的刷新。


t状态:追踪状态,eg:一个程序被调试的时候


X状态:死亡状态,意味着回收进程的资源(进程相关的内核数据结构+代码和数据)

Z状态:僵尸状态,比如你在路上看到有个人从你身边跑过,但是突然就在路上倒下了,你过去发现他已经处于死亡状态了,此时你会打急救电话,当110到达现场的时候,会封锁现场,法医鉴定这人是自然死亡还是他伤,随后警察就会通知后事部门,通知家属,带走这个人,避免引起社会恐慌。这个人被警察拉走的时候叫做死亡状态,这个人被法医鉴定期间就叫做僵尸状态。为什么有僵尸状态呢?辨别退出死亡的原因,比如进程退出的信息,这个信息也在task_struct中保存。也就是说当一个进程退出的时候,它的资源不会立即被释放,而是先让进程进入僵尸状态,让进程退出的所有信息写入到这个PCB中,供系统或父进程来进行读取,此时这种状态的task_struct就是僵尸状态。

就绪态和运行态就是R;挂起就是S,D,T;停止就是Z。

状态的验证

R状态

这一个空语句没有IO,没访问外设,不需要等待,只需要排队CPU资源就可以

S状态



为什么会处于S状态呢,按道理来讲不应该处于R状态吗?

这里的程序是进行打印,往显示器上打印,显示器就是外设,外设很慢,所以你的程序有输出这个就叫做IO,所以你的程序看起来打印的很快,但是对CPU来说就无比的慢,IO等待外设就绪是需要花时间的,原因就是CPU太快了,一直挂起运行,状态切换特别快,用户感受上是在运行,实际相当大一部分时间都是休眠状态。

eg:R和S切换

如何暂停进程呢?kill -l 指令  18号是继续继承   19号是暂停进程

kill  -19或者kill -SIGSTOP  暂停进程。

查看进程确实发现程序变成了T状态,并且T后面没有+号。

我们通过kill -18 继续让程序运行起来

我们再查看进程发现进程的状态由S+变成了S,甚至我们Ctrl+c也结束不了进程了,因为此时这个进程因为暂停和继续变成了在后台运行。那我们如何干掉这个进程呢?kill  -9


什么叫做前台后台进程?

你直接运行你的程序的时候叫做在前台跑,在前台运行的时候,输入任何内容都是不起作用的,我们可以CRTL+C干掉前台进程。此时进程的状态是S+。

如果你将可执行程序加一个&,这个进程就叫做后台进程,处于后台进程你是可以进行输入命令的,命令也是可以执行的,但是crtl+c就干不掉这个进程,你需要kill -9 + 进程pid 干掉这个进程。此时进程的状态是S。 fg可以把后台命令提到前台 。

Z状态


如果没有人检查或者回收进程(父进程承担检查和回收),该进程退出进入Z状态。如何看到呢?

父子同时跑起来,父进程休眠啥也不干,子进程每隔两秒打印消息,我们在50秒之内将子进程干掉,其中父进程也不管,所以我们就看到子进程已经死了,但是没被回收,也就是子进程处于僵尸状态,

#include<iostream>    
#include<unistd.h>    
int main()    
{    
  pid_t id=fork();    
  if(id==0)    
  {    
    //child    
    while(true)    
    {    
      std::cout<<"I am child,running!"<<std::endl;    
      sleep(2);    
    }    
  }    
  else    
  {    
    //parent    
    sleep(50);    
  } 
  return 0;   
}  

为了方便操作这里写一个简单的监控命令行脚本。

while :; do ps axj | head -1 && ps axj | grep myproc | grep -v grep ; sleep 1; echo "#############";  done

通过复制三次会话同时进行操作


开始没有进程执行,脚本执行如下

之后我们复制会话,在会话3里让程序跑起来。

我们的脚本就会显示对应的进程信息


之后我们在会话2里杀死子进程

我们的脚本同样显示对应信息

defunct的意思就是无用的,失效的。这就是最经典的僵尸进程,长时间不回收就处于占用资源的状态。


刚刚是子进程退出,父进程还在运行,这里我们让父进程先死,子进程还在运行。

  #include<iostream>    
  #include<unistd.h>    
  #include <cstdlib>    
  int main()    
  {    
    pid_t id=fork();    
    if(id==0)    
    {    
      //child    
      while(true)    
      {    
        std::cout<<"I am child,running!"<<std::endl;    
        sleep(2);    
      }    
    }    
    else    
    {    
      //parent 
      std::cout<<"father do nothing!\n"<<std::endl;         
      sleep(10);//父进程10秒之后立即退出    
      exit(1);//代表直接终止进程    
      
    }    
    return 0;    
  }    

我们还是通过刚刚的脚本去进行观察

父进程没了,子进程还在运行,子进程就会被1号进程领养,此时这个子进程就成为

孤儿进程

,领养的目的就是当以后这个子进程退出来,由1号进程回收,1号进程我们一般也称之为操作系统。



僵尸进程危害



  • 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z


    状态?是的!

  • 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在


    task_struct(PCB)


    中,换句话说,Z


    状态一直不退出,


    PCB


    一直都要维护?是的!

  • 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构

  • 对象本身就要占用内存,想想


    C


    中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!

  • 内存泄漏


    ?


    是的

七、进程优先级



基本概念


  • cpu


    资源分配的先后顺序,就是指进程的优先权(


    priority


    )。

  • 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的


    linux


    很有用,可以改善系统性能。

  • 还可以把进程运行到指定的CPU


    上,这样一来,把不重要的进程安排到某个


    CPU


    ,可以大大改善系统整体性能



查看系统进程

以下面代码为例

#include<stdio.h>    
#include<unistd.h>    
int main()                                                                                                                                                              
{    
  while(1)    
  {    
    printf("I am a process,pid:%d . ppid: %d\n",getpid(),getppid());    
    sleep(1);    
  }    
  return 0;    
    
}   

程序执行结果:


在linux


系统中,用


ps –l或者ps -al


命令则会类似输出以下几个内容:


我们很容易注意到其中的几个重要信息,有下:

  • UID : 代表执行者的身份;类比到现实:

    uid就代表现实生活中你的身份证号。

  • PID :


    代表这个进程的代号

  • PPID


    :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号

  • PRI


    :代表这个进程可被执行的优先级,其值越小越早被执行

  • NI


    :代表这个进程的


    nice值,

    nice值叫做优先级修正数据,就比如你高中参加比赛会有加分,等你高考完后,会在你的高考成绩上加分,这就叫修正你的高考分数。

PS:ls -nl 会将用户和所属组以uid的方式展示出来



PRI and NI


  • PRI


    也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被


    CPU


    执行的先后顺序,此值越小进程的优先级别越高。




  • NI





    ?


    就是我们所要说的


    nice


    值了,其表示进程可被执行的优先级的修正数值。

  • PRI


    值越小越快被执行,那么加入


    nice


    值后,将会使得


    PRI


    变为:



    PRI(new)=PRI(old)+nice


  • 这样,当


    nice


    值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行所以,调整进程优先级,在Linux


    下,就是调整进程


    nice


    值。

  • nice


    其取值范围是


    -20





    19


    ,一共


    40


    个级别。


PRI vs NI


  • 需要强调一点的是,进程的


    nice


    值不是进程的优先级,他们不是一个概念,但是进程


    nice


    值会影响到进程的优先级变化。

  • 可以理解


    nice


    值是进程优先级的修正修正数据

查看进程优先级

top命令








top




命令更改已存在进程的




nice







  • top

  • 进入


    top


    后按


    “r”–>


    输入进程


    PID–>


    输入


    nice



根据计算公式:


PRI(new)=PRI(old)+nice,




我们发现第二次进行修改后PRI不是95而是85,证明PRI(old)的值每次设置都是80。

nice值范围的验证

为什么会有优先级?

资源太少!做核酸,食堂吃饭等都体现着优先级。本质是分配资源的一种方式

nice值为何是一个相对比较小的范围呢?

优先级再怎么设置,也只能是一种相对的优先级,不能出现绝对的优先级。比如你去食堂吃饭,别人可以调整自己的优先级,获得资源,假如除了你其他人都很肆无忌惮,想往哪插队就往哪插队,就导致了别人的优先级是一种绝对的优先级,想什么时候靠前访问就什么时候靠前访问,这样就会导致你长时间达不到饭,造成你的饥饿问题,同样进程在排队的时候,如果你进程优先级调整的跨度太大,导致调度器在试驾调度的时候永远调度优先级特别高的进程,就可能导致其他进程长时间得不到CPU的资源进而导致这些进程出现“饥饿问题”。而我们的调度器的核心功能是较为均衡的让每个进程享受到CPU资源,所以nice值不能夸张式的设置优先级保证进程被调度的情况。每个人都要吃到饭。

八、环境变量



基本概念



  • 环境变量


    (environment variables)


    一般是指在操作系统中用来指定操作系统运行环境的一些参数

  • 如:我们在编写


    C/C++


    代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。

  • 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性

  • 语言上面定义变量本质是在内存中开辟空间


    环境变量本质是OS在内存/磁盘文件中开辟的空间,用来保存系统相关的数据(不要去质疑OS开辟空间的能力)。环境变量也是变量名+变量内容。



常见环境变量



  • PATH :


    指定命令的搜索路径

  • HOME :


    指定用户的主工作目录


    (


    即用户登陆到


    Linux


    系统中时


    ,


    默认的目录


    )

  • SHELL :


    当前


    Shell,


    它的值通常是


    /bin/bash





查看环境变量方法



echo $NAME


//NAME:


你的环境变量名称



和环境变量相关的命令



  • 1. echo:


    显示某个环境变量值

  • 2. export:


    设置一个新的环境变量

  • 3. env:


    显示所有环境变量

  • 4. unset:


    清除环境变量

  • 5. set:


    显示本地定义的


    shell


    变量和环境变量

测试PATH


自己编好的程序是一个可执行文件,命令,程序,工具…都是一个可以执行的文件。所以myproc也是一个命令,那么为什么运行myproc的时候为何要./ ,执行系统的可执行命令就可以直接跑呢?

./本质就是帮系统确认对应的程序在哪里。那么为何系统的命令就不用带路径呢?

就是因为环境变量–PATH.系统当中会存在一些系统级别的变量叫做环境变量。

以冒号作为分隔符,分离出多条路径,系统想通过Path查找,查找规则就是先找第一个路径,找不到就下一个以此类推,找到了就不再向下找了,直接把这个路径下的可执行程序跑起来完成路径查找。换言之系统执行命令的时候就是通过环境变量path去搜索对应可执行程序路径的。

如果我也想我的myproc执行的时候不带./该怎么办呢?

  • 1. 把你的命令拷到这些个路径下,但是极其不建议,因为这会污染人家的命令值。
  • 2.把我们的当前路径添加到PATH环境变量中。

ps:所谓的安装软件,就是把这个软件拷贝到系统环境变量中特定的命令路径下 ,所以安装的过程就是拷贝的过程。

添加过程:

知道当前路径(pwd)—>利用export命令

完成之后我们就发现执行myproc不带./就可以直接跑

ps:这个添加仅在当前会话有效。如果想永久添加就得更改配置文件,但是极其不建议。



测试




HOME


为什么不同的用户在登录时起始的目录不一样呢?

根本原因就是因为大家对应的环境变量HOME是不同的

本地变量

系统上还存在一种变量是与本次登录(session)有关的变量,只在本次登陆生效,称之为本地变量。

这个myval就是个本地变量,只要关掉xshell,再次登录的时候,这个变量就消失了。环境变量是全局的。

但是我们可以通过export将本地变量导出成环境变量。只不过这个环境变量没写到系统的配置文件中,xshell关了,它也会释放掉。


通过unset取消

命令行参数

环境变量的组织方式


每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以


’\0’


结尾的环境字符串。

命令行参数


main函数是可以携带参数的,argv是char类型的指针数组,数组元素有几个呢?有argc个。argc和argv就是命令行参数。

#include<stdio.h>    
#include<unistd.h>    
int main(int argc,char *argv[])//argv是char类型的指针数组,     
{    
  for(int i=0;i<argc;i++)    
  {    
    printf("argv[%d] -> %s\n",i,argv[i]);    
  }    
    
  return 0;                                                                                                                                                             
}    

运行结果如下:ps:执行该代码makefile里要改成c99的标准

如果你的命令行参数中只有一个程序名,那么你的数组就只有一个元素。argc=1.如果你给你的程序带-a,-b,-c,-d的时候就会给我们携带上我们所对应的参数。

这里的./myproc,-a,-b,-c,-d我们都把他们看做字符串。这个argv数组是动态变化的,数组中的元素依赖于所传参数的个数,这个数组中有几个有效命令行字符串由argc决定。同时这个数组会多一个元素,最后一个元素执行NULL。这就是命令行参数的一个基本理解。换言之,我们在命令行上传的各种各样的数据,最终都会传递给main函数,然后有main函数拿到后依次保存在argv中,用argc表明我们的个数。


那这个argc岂不是没有啥用,因为argv最后一个元素是NULL?

  #include<stdio.h>    
  #include<unistd.h>    
  int main(int argc,char *argv[])    
  {    
    int i=0;    
    for(;argv[i];i++)    
    {    
      printf("%s\n",argv[i]);                                                                                                                                           
      
    }    
      
  }    

答案当然没错,但是存在即合理。作为数组传参来讲我们一般建议把个数带上,最最主要的是这个参数是用户来填的,如果我们今天想限定用户传的命令行参数是5个,我们就可以通过argv实现

使用命令行参数有什么用呢?

同一个程序我们可以通过给他携带不同参数的方式,来让它呈现出不同的表现形式或者功能

eg:ls命令

如果你写的程序需要有一个多样性功能的话你可以通过选项设置这样的功能

eg:

#include<stdio.h>    
#include<string.h>    
#include<unistd.h>    
int main(int argc,char *argv[])    
{    
  if(argc!=2)  //因为命令行参数必须是两个    
  {    
    printf("Usage: %s -[a|h]\n",argv[0]);   //提示输入格式 
    return 1;    
  }    
  if(strcmp(argv[1],"-h")==0)    
  {    
    printf("hello haha\n");    
  }    
  else if(strcmp(argv[1],"-a")==0)                                                                                                                                      
  {    
    printf("hello gaga\n");    
  }    
  else    
  {    
    printf("hello world\n");    
  }    
    
    
}  

指令有很多选项,用来完成同一个命令的不同子功能,这里的选项底层使用的就是我们的命令行参数!!如果你想让你的程序表现出不同的形态,你可以使用选项让你的程序完成各种子功能。

我们的程序当中除了可以传递命令行参数,同时也可以传递环境变量env,实际上当父进程调用创建子进程的时候,执行子进程对应的代码比如main函数,他会把父进程的环境变量数据也作为参数传递给子进程,当然通过env也能得到环境变量列表。这个env和argv是一模一样的,只不过argv指向的是一个一个命令行参数,env指向的是一个一个的环境变量的字符串所以环境变量的组织方式和命令行参数的组织方式几乎一模一样。

  #include<stdio.h>    
  #include<string.h>    
  #include<unistd.h>    
  int main(int argc,char *argv[],char *env[])    
  {    
    //main函数的参数可以认为有三个    
    for(int i=0;env[i];i++)    
    {    
      printf("%d->%s\n",i,env[i]);                                                                                                                                      
    }                                                                                                                                          
  }                                                                                                                                            
    


这个env就不带个数了(像argc和argv一样),因为这个参数不是用户填的,是程序自动给我们填的,所以这个个数是不需要判断的。



我们程序得到的环境变量和env指令得到的环境变量几乎是一模一样的。

如何通过代码获取环境变量

1.0 如上我们的程序

2.0 environ

environ是char**,env数组元素是char*,我们就可以用environ指向这些元素获取环境变量。

#include<stdio.h>      
#include<string.h>      
#include<unistd.h>    
    
int main()    
{    
  extern char **environ;    
  for(int i=0;environ[i];i++)    
  {    
    printf("%d->%s\n",i,environ[i]);                                                                                                                                    
  }                                                                                                                                          
} 


解释说明

数组元素是char*的,environ是char**,char**指向第一个数组元素(它的地址就是char**),所以对environ++ 就是下一个元素,解引用访问的就是字符串。


我们这里的main()不带参数,系统可不可以给main函数传参呢?也就是函数如果没有参数,可以传参吗?

答案是可以的,只不过获取数据的方式就要利用可变参数列表的方式去获取。


这三个点就是可变参数列表。

对应这里即便main没传参,系统照样可以将env传进来,就可以让environ指向env,进而访问。environ在编译的时候就会自动指定好环境变量的位置, 让你直接访问。



3.0 实际上前两种方式都不常用。使用getenv

#include<stdio.h>    
#include<string.h>    
#include<unistd.h>    
#include<stdlib.h>    
int main()    
{    
  printf("PATH: %s\n",getenv("PATH"));    
  printf("HOME: %s\n",getenv("HOME"));    
  printf("SHELL: %s\n",getenv("SHELL"));                                                                                                                                
}  

总结一下:相当于程序中有两张表,一张是用来记录环境变量的,一直是记录命令行参数的。也就是main函数传递的那两个指针数组。

环境变量通常具有全局属性

以下面代码为例

#include<stdio.h>    
#include<string.h>    
#include<unistd.h>    
#include<stdlib.h>    
    
int main()    
{    
  printf("I am a process pid: %d,ppid: %d\n",getpid(),getppid());                                                                                                       
}  


多跑几次我们发现pid一直在变,ppid一直不变 .那么这个ppid是谁呢?

我们发现它就是bash,也叫做命令行解释器。说明命令行上启动的进程,父进程都是我们的bash!怎么启动的?实际上用的就是fork().那么子进程的环境变量是谁给的呢?我们可理解为是系统给的,也可以理解成是bash给的,bash则是从系统中读的,系统的环境变量在系统的配置当中,在bash登录的时候,bash本身就把系统的环境变量导入到了自己进程的上下文档中。所以env查到的基本上全是shell在自己上下文当中导入的环境变量,这些环境变量是可以继承给子进程的。


如何证明呢?

我们在这设置一个本地变量,env里是没有的

#include<stdio.h>    
#include<string.h>    
#include<unistd.h>    
#include<stdlib.h>    
    
int main()    
{    
  printf("my_env_string:%s\n",getenv("my_env_string"));                                                                                                                 
}

说明刚定义的变量就是本地变量。

将其改为环境变量

实际上导给了父进程bash的环境变量列表。

这里证明将环境变量导给了父进程bash.

./myproc 运行后,发现子进程就获取成功了,说明继承了bash是环境变量。所以环境变量具有全局属性,根本原因是环境变量是可以被子进程继承的。本地变量就不能继承只能bash自己用。所以从bash开始往后所有的进程都能获得这个环境变量,用户就可以找到这些环境变量做某种任务。

从bash开始,一个环境变量被设置,所有的子进程就全都知道了,因为环境变量可以被子进程继承下去,所以bash之后的进程都可以看到对应的环境变量设置。相当于环境变量就影响了整个用户系统。 用户就可以找到这些环境变量做某种任务。eg:我们用的gcc/g++编译器,经常会说他们能找到我们的头文件或者库文件,原因就是gcc/g++也是命令,也是bash的子进程,所以有关bash所有的头文件,库文件查找就可以被子进程找到,所以在编译程序时就不用带很多的选项,默认就可以找。换句话说,可以帮助我们更快的完成程序的编译。

十、程序地址空间



程序地址空间回顾


这里我们使用一个简易版本的帮助大家回顾。

PS:堆栈是栈。

上面那个我们以前叫做C/C++程序地址空间。那这个是内存吗?

答案:这个根本就不是内存.

验证程序地址空间分布

以下面这段代码验证这个程序地址空间分布

#include<stdio.h>                                                                                                                                                       
#include<string.h>
#include<unistd.h>
#include<stdlib.h>

int g_unval;
int g_val=100;
int main()
{
    printf("code addr:%p\n",main); //main函数就是代码区的代表,函数名就代表地址
  
    const  char *s="hello world";
    printf("string rdonly addr:%p\n",s); //取的是字符串的起始地址,所以不是&s
    
    printf("init addr:%p\n",&g_val); //全局变量会自动初始化,不同的编译器呈现不同的表现,实际上
    //一个全局变量没有做初始化值的设定,它的值在编译程序的时候是默认没有给它空间的,只有在程序加载的时候
    //才给空间。加载时才给值,这个全局变量的值给多少就与编译器有关系。有的是0,有的是其他值。(可执行程序的格式,ELF格式)
    
    printf("uninit addr:%p\n",&g_unval);
    
    char *heap=(char*)malloc(10);
    char *heap1=(char*)malloc(10);
    char *heap2=(char*)malloc(10);
    char *heap3=(char*)malloc(10);
    char *heap4=(char*)malloc(10);
    printf("heap addr:%p\n",heap); //&heap就取到了栈上的地址,heap是栈上开辟的一个指针变量
    printf("heap addr:%p\n",heap1); //&heap1就取到了栈上的地址,heap1是栈上开辟的一个指针变量
    printf("heap addr:%p\n",heap2); //&heap2就取到了栈上的地址,heap2是栈上开辟的一个指针变量
    printf("heap addr:%p\n",heap3); //&heap3就取到了栈上的地址,heap3是栈上开辟的一个指针变量
    printf("heap addr:%p\n",heap4); //&heap4就取到了栈上的地址,heap4是栈上开辟的一个指针变量

    
    printf("stack addr:%p\n",&s);
    printf("stack addr:%p\n",&heap);
    
    int a=10;
    int b=30;

    printf("stack addr:%p\n",&a);
    printf("stack addr:%p\n",&b);

}



验证堆是向上增长,栈是向下增长,地址增长方向是由低到高依次增加。

针对完整版的程序地址空间,我们再将命令行参数也都打印下:

  #include<stdio.h>                                                                                                                                                     
  #include<string.h> 
  #include<unistd.h>
  #include<stdlib.h>

  int g_unval;    
  int g_val=100;    
  int main(int argc,char *argv[],char *env[])    
  {                                                                                                                                                                     
      printf("code addr:%p\n",main); //main函数就是代码区的代表,函数名就代表地址    
        
      const  char *s="hello world";    
      printf("string rdonly addr:%p\n",s); //取的是字符串的起始地址,所以不是&s    
          
      printf("init addr:%p\n",&g_val); //全局变量会自动初始化,不同的编译器呈现不同的表现,实际上    
      //一个全局变量没有做初始化值的设定,它的值在编译程序的时候是默认没有给它空间的,只有在程序加载的时候    
      //才给空间。加载时才给值,这个全局变量的值给多少就与编译器有关系。有的是0,有的是其他值。(可执行程序的格式,ELF格式)    
          
      printf("uninit addr:%p\n",&g_unval);    
          
      char *heap=(char*)malloc(10);    
      char *heap1=(char*)malloc(10);    
      char *heap2=(char*)malloc(10);    
      char *heap3=(char*)malloc(10);    
      char *heap4=(char*)malloc(10);    
      printf("heap addr:%p\n",heap); //&heap就取到了栈上的地址,heap是栈上开辟的一个指针变量    
      printf("heap addr:%p\n",heap1); //&heap就取到了栈上的地址,heap是栈上开辟的一个指针变量    
      printf("heap addr:%p\n",heap2); //&heap就取到了栈上的地址,heap是栈上开辟的一个指针变量    
      printf("heap addr:%p\n",heap3); //&heap就取到了栈上的地址,heap是栈上开辟的一个指针变量    
      printf("heap addr:%p\n",heap4); //&heap就取到了栈上的地址,heap是栈上开辟的一个指针变量    
          
      printf("stack addr:%p\n",&s);    
      printf("stack addr:%p\n",&heap);    
          
      int a=10;    
      int b=30;    
  
      printf("stack addr:%p\n",&a);
      printf("stack addr:%p\n",&b);
  
      for(int i=0;argv[i];i++)
      {
        printf("argv[%d]: %p\n",i,argv[i]);
      }
  
      for(int i=0;env[i];i++)
      {
        printf("env[%d]: %p\n",i,env[i]);
      }
  
  
  }

结果如下:

完美契合完整的程序地址空间。

再以下面这段代码展开讨论

#include<stdio.h>                                                                                                                                                       
#include<string.h>    
#include<unistd.h>    
#include<stdlib.h>    
     
int g_val=100;    
int main()                                                                     
{                   
        
   //数据是各自私有一份的(写时拷贝),子进程数据改了,父进程数据不受到影响    
   if(fork()==0)    
   {               
     //child    
     int cnt=5;                                                                    
     while(cnt)    
     {              
       printf("I am child,times: %d, g_val=%d ,&g_val=%p\n",cnt, g_val,&g_val);    
       cnt--;    
       sleep(1);                                                
       if(cnt==3)      
       {                                                         
         printf("############child更改数据#############\n");    
         g_val=200;    
         printf("###########child更改数据done###########\n");    
       }    
     }    
   }              
   else    
   {    
      //parent    
      while(1)    
      {    
        printf("I am father ,g_val=%d,&g_val=%p\n",g_val,&g_val);    
        sleep(1);    
      } 
   }

   return 0;
}

   

运行结果:

我们发现父子进程对应的地址,在子进程更改数据后是没有变化的 !因为如果子进程更改数据后发生了写时拷贝,这样两个数据是独立的,g_val的地址是一定会发生变化的。


如果C/C++打印出来的地址是物理内存的地址,这种现象可能存在吗?

将子进程的g_val改了,发生了写时拷贝,那么发生写时拷贝后,父与子怎么可能读到相同地址的值呢?答案是绝对不可能发生。所以这里我们所使用的地址绝对不是物理地址,而是虚拟地址!换言之C/C++中使用的地址全是虚拟地址。


所以之前的C/C++程序地址空间实际上是进程虚拟地址空间。

进程地址空间是什么?


首先不是物理内存。

比如说这里有一个富豪叫做大富翁身价100亿$,他有10个私生子,但是他们彼此之间都不知道其他人的存在,这样每个私生子都认为他有一个爹叫做大富翁,并且大富翁只有他这一个儿子,所以大富翁就给他的私生子分别说:“小a/b/c/d/e/f….啊,你好好学习,将来等我老去了,你继承我的100个亿。”10个私生子都分别说了这番话,因为10个私生子都不知道其他私生子的存在,所以他们都认为自己在独占大富翁的财富。大富翁为什么要给每个私生子都画了一张大饼呢?这里每个私生子都认为自己将来会有100个亿,所以每个私生子都按照100个亿规划自己的人生,这样的好处是简化私生子处理钱的方式,让每个人都认为自己有10个亿,以统一的方式规划花钱方式。只要每个孩子问大富翁要钱,大富翁都会尽量满足他们,每个私生子依旧会认为自己有100个亿,另外如果10个私生子都向大富翁要50个亿,这种情况是一般不会发生的,一来大富翁可以拒绝,拒绝了以后你依旧认为自己是100个亿的继承人,二来正常人几乎是不会要这么多的(滑稽),所以此时的大富翁是操作系统,各个私生子是进程,大富翁钱所在的银行是物理内存,操作系统给每个进程画的大饼叫做进程地址空间。

每个私生子都被画了张饼,都认为自己有100个亿。同样的每个进程都有一个地址空间,都认为自己在独占物理内存!系统里面的进程很多,所以地址空间也很多,操作系统就要管理起这些地址空间,如何管理呢?先描述,再组织。OS用结构体  mm_struct 描述进程地址空间,换言之,将来创建进程的时候,除了要创建PCB之外,每个进程都要有一个mm_struct的结构体。

所以进程地址空间在内核中是一个数据结构类型可以用它定义具体进程的地址空间变量。在进程中PCB就指向这个mm_struct

地址空间本质是内核中的一种数据类型。别人给我们画大饼是可以通过数据的方式进行的,eg:老板画的大饼是明年给你涨30%的薪资…那什么叫不以数据的方式直接给我呢?eg:老板现在直接把涨的薪资打到你的卡上。OS就以数据结构给进程画大饼。

OS是如何做到的呢?

比如:你和你的同桌之间划分了一条三八线,比如桌子100厘米,从0~50作为你的区域,50~100是你同桌的区域,这件事情的本质就是划分区域。那如何用C语言描述这种场景呢?

struct desktop{
 int start_girl;
 int end_girl;
 
 int start_boy;
 int end_boy;

}

desktop t={0,50,50,100};

如果你想在桌子上放一个铅笔,假设这个铅笔是4个字节,你要把它放在 10~14的区域,你就在你对应的区域把它放在你区域对应的位置。

struct mm_struct{
 unsigned int code_start;
 unsigned int code_end;
 
 unsigned int init_data_start;
 unsigned int init_data_end;
 
 unsigned int uninit_data_start;
 unsigned int uninit_data_end;
 
 unsigned int heap_start;
 unsigned int heap_end;
 
 //...
 unsigned int stack_start;
 unsigned int stack_end;

}

所以对于进程地址空间我们只要有start和end就能划分出对应的区域。虽然这里只有start和end但是每个进程都可以认为mm_struct代表整个内存,且所有的地址为:0x000000…000~0xFFFFFF…FF每个进程都认为地址空间的划分是按照4GB划分的,话句话说每个进程都认为自己拥有4GB。每个进程都认为自己是独占内存的都认为自己有一个4GB空间,可是实际上物理内存才2个G,甚至才1个G。虚拟地址是地址空间上进行区域划分时,对应的线性位置。

eg:我认为我的code_start是从50开始,code_end是100结束,按照50~100进行划分的,那么50,51,52…都认为是虚拟内存地址。可是进程本身的代码和数据一定是在物理内存上存储的,那么这个地址空间和我们的物理内存有什么关系呢?

地址空间和物理内存在什么关系呢?

每个进程都有自己对应的PCB和独立的地址空间,我们的进程所看到的地址并不是物理内存上的地址,而是由mm_struct中每个区域衍生出来的地址,叫做虚拟地址(在linux中也叫做线性地址)。可是光有地址是无用的,必须有代码和数据,代码和数据是在物理内存上存着的,这时候就引入一个新的概念:页表+MMU。MMU是一种硬件,功能是查页表。页表是操作系统的每个进程维护的一张表,可以简单的理解成这个表的左侧是虚拟地址,右侧是物理地址,功能就是将虚拟地址转化成物理地址,所以这张页表本质上就是一张映射表或者叫做一张hash表。进程在实际寻址时,应用层用的是虚拟地址,OS会根据页表将虚拟地址转换成物理地址进而寻找代码和数据。

为什么要搞得这复杂呢?进程直接访问物理内存不就行了吗?为什么还要搞张表?

举个栗子:小时候收了红包的你,刚一到家,你妈就对你说:“儿子,我先帮你把钱管理起来,等你用的时候问我要。”  后来你说我要买本书,买个玩具。你妈都会给你买,可这不也是脱了裤子放屁吗?你自己拿着钱不香吗?为什么非要经过你妈的手呢?你妈这样做的目的就是不让你乱花钱,不是不让你花。本来你是可以直接去商店买东西的,现在多了一个你妈,相当于加了一个中间层,加一个中间层是非常有利于做管理的,做管理并不是不给你,而是监控你的做法到底合适不合适。

如果直接让你的进程访问物理内存,这里会引发什么问题呢?

eg:进程本来是访问的A的代码和数据,但是可能会因为写的bug越界,把其他进程的代码和数据改了,甚至有些恶意进程,既然大家都可以直接访问物理内存,那么我就可以通过一些非法指针操作去直接访问别人的代码和数据,这样严重威胁系统安全,是不好的。就好比:你妈没拿你的压岁钱,你自己去花,你被骗了你妈都不知道。所以我们为了更好的进行操作就要在这里增加一个中间人就是你妈,进程管理中就是OS。地址空间+页表就是OS对应的管理者角色,相当于在进程和物理内存之间加了一个软件层,这层软件层是OS的代言人。


所以当你要通过虚拟地址访问物理地址时这种现象是不存在的,虚拟地址转换成物理地址是页表和MMU做的,页表和MMU转化的时候就是OS在帮你转, 所以你要把虚拟地址转成物理地址页表会帮你找,转不转呢?OS说了算。所以中间加了一个软件层能有效做到权限管理。好比:你是虚拟地址,你要访问到正文代码中某个地址,区域是在你地址空间的合法区域内,可是经过页表查找,你的物理地址根本就没有对应的区域和你映射,证明你访问的地址根本就是不允许你访问的区域,甚至我直接直接访问我的虚拟地址,我要访问的是代码区,但是实际给的地址确是栈区的地址,OS立马意识到你越界了,此时就可以限制你地址区域的寻址。

常量区的代码为什么不能被修改呢?

const char *str="hello";
*str='H';

显然这样干是不行的,为什么呢?因为“hello”在字符常量区,字符常量区不能写。那么为什么不能写?本质:OS给你的权限只有读。这也跟页表有关系,页表除了映射还有权限管理,比如页表将代码区的映射的权限设置成读,所以你在进行使用str指针的时候就是一个虚拟地址,用*str去访问str变量,去指向这个位置进行写入,本质就是访问它的虚拟地址,访问它的虚拟地址就要涉及到虚拟地址到物理地址的转化,OS就帮你转,但是OS看到你的权限只有读权限,你不能写入,OS立马把你的程序崩溃调。

为什么要有地址空间?


1. 通过添加一层软件层,完成有效的对进程操作内存进行风险管理(权限管理),本质目的是为了保护物理内存以及各个进程的数据安全!换句话说这里的不同的进程在经过页表映射的时候在物理内存上一定被映射到了不同的物理内存区域,一定是不可能一样的,不同的进程都有自己的代码和数据。

进程申请1000个字节,进程能立马使用这1000个字节吗?

不一定,就好比你银行拿了1000块钱但是你仍然是1块1块花,可能会存在暂时不会全部使用甚至暂时不会使用!我们在写代码时经常会定义一个大数组,我们不用等之后需要的时候再用,站在操作系统角度,如果空间立马给你是不是意味着整个系统会有一部分空间,本来是可以立马给别人用的,但是现在却被你闲置着?这部分闲置的空间就被你浪费了,那么OS怎么衡量你在用这个空间呢?如果你在申请好了后,有了空间但是从来没有读写,这就证明这块空间给你了,但是你拿着从来没有用。

举个栗子:还是你妈,你妈说:“今年你的压岁钱是1000,妈帮你管理着,你要的时候找妈要。”比如你要给玩具你妈对你说这个玩具不适合你,这叫做你妈就拒绝了你,也就是OS对你进行了权限管理,你又说妈我要买两本书,你妈说可以呀,这时候你就有两种选择,一种就是你立马问你妈要上钱,这就叫做立即把空间给你,此时你妈说现在把钱给你,你也不用,等明天咋俩一起去书店买书的时候在把钱给你,所以在这一晚上,你妈可能把给你买书的100块给了你爸打麻将,你爸赢了100块回来,虽然你说你要买两本书,但你不是立马要去买,你有可能是先把钱拿着你不花,作为你妈来讲你妈就说钱你先别着急,等明天咋俩一块去,到现场就去买了。同样的,进程对OS说我今天想在堆区申请100字节的空空间,OS说好的,给你100字节,堆区就有一个heap_start,heap_end,堆区就给你100字节,给的方式也很简单,heap_end+=100;这样就给你申请成功了,就相当于在地址空间上把区域调大一点,就认为这部分是你的了,此时有进程说:我现在在这等着呢,你不是不着急使用吗?赶紧把空间给我,所以你就把这部分地址空间拿回去了,和task_struct说我把空间申请了,可能过了5秒,19秒后,你说这空间不是我的了吗,我要来读这个虚拟空间了,OS说好的,你要读先等一等,这时候,OS才在物理内存中给你申请一部分空间,把你以前的空间和你新申请的空间建立映射关系,完成之后再来进行读写,这就叫做当你真的使用的时候再给你开辟空间,这也叫做基于缺页中断进行物理内存申请。当你真正想访问的时候OS会让你等一等开辟好空间,建立映射关系再让你访问,对于读写地址空间新开辟的空间的人,

OS做的内存申请动作是透明的

,就好比你妈带你买书,直接给店家手机付款,但是你不知道与此同时你妈给你爸发了个消息让他把昨晚赢的100块钱转过来,用这100块买书。


2. 将内存申请和内存使用的概念在时间上划分清楚,申请是申请,使用是使用,进程要很小的空间,OS立马给你,但是要的多了,OS可以暂时不给你,在你需要的时候在给你。通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和OS进行内存管理操作,进行软件上面的分离。以后进程要空间,OS直接调整的可以是地址空间,也就是给你假的这个空间,这是你申请的,而OS知道你真正要用空间是你在使用的时候才用,所以当你申请的时候OS都给的是虚拟地址空间,当你需要访问的时候,OS再在物理内存给你申请空间,这个事情是你不知道的,你也不用管了。

比如:你学校要交学费了,要8000,你爸说没问题,你就知道你这8000块申请成功了,你并不一定会立马用这8000块钱,你反正知道你爸答应你了,交了学费就完事了,这就叫做应用层申请内存,后来时间点到了,你告诉你爸现在时间到了,该交学费了,你爸就把8000块给你打过去,可是你爸的8000块是你爸和你妈借的?还是你爸的私房钱?还是你爸打麻将赢的?你都不知道,因为这属于你爸你妈在做家庭资产管理时做的事情,你只要向他们要就行,你在打电话的时候你爸承诺把钱给你,这就叫做在OS在地址空间上承诺给你,实际上有可能物理内存已经不够了,而现在你还要,如果放在以前是没办法给你的,但是OS说这个进程可是我亲儿子啊,必须给,就可以执行内存管理算法,比如说:B进程啊,你现在怎么占了这么大的空间你不用呢?OS直接把B进程的空间置换到磁盘上,然后把这块空间申请出来给你用。也就是说物理内存已经是100%,OS照样可以申请出内存,这就叫做地址空间从中起的一个非常重要的作用。  有了地址空间以后我们可以让OS在做内存管理算法的时候让用户完全不知道,也就是这个进程压根就不知道OS给你的空间是OS剩余的空间, 还是OS从别人那要的你都不清楚。


3.站在CPU和应用层的角度,进程统一可以看做统一使用4GB空间,而且每个空间区域的相对位置,是比较确定的。每个进程都认为内存是以地址空间这样的方式布局,有了地址空间以后,我们的程序的代码和数据可以被加载到物理内存的任意位置,因为页表可以通过映射的方案把物理内存中的带无数据映射在地址空间中,也就是在虚拟地址上它是连续的,在物理地址上是任意的,大大减少内存管理负担,对物理内存没有太大的位置要求,也就是加载磁盘上程序时代码和数据加载到物理内存上可以任意放, 只要经过映射可以找到就行。

CPU怎么知道进程的代码在哪里呢?如果没有地址空间,CPU就得找不同进程的起始入口,每个进程的起始入口都是不一样的 ,CPU没有办法已统一的方式快速的找到每个进程对应的其实地址,假如说今天CPU生气了,我就要从0x1234这个地址处开始读取代码,你们自己看着办吧,但是OS一点不慌,这里有磁盘,磁盘上有对应的程序,OS就和CPU说,每个进程都有地址空间,你要读起始代码你就直接从正文段中0x1234地址处开始执行代码,我以后将main函数地址就放在这里你不用管任何进程的差别了,只需要从这里开始读就可以了。CPU说好的,OS在怎么做到呢?程序里面有代码和数据,代码和数据最终要加载到物理内存,每个进程都有页表,OS就将物理内存中的main函数的实际地址放在页表里,与CPU认识的地址0x1234建立起映射关系,,其余每个进程的实际main函数的地址也都与0x1234建立映射关系,也就是每个进程的页表中左面是0x1234右边是实际的地址,这样就解决CPU访问的问题了。对CPU来说它执行的操作就变得非常的简单了。


OS最终这样设计的目的,达到一个目标每个进程都认为自己是独占系统资源的

(每个进程都认为自己有4GB)。

比如:打小的时候,你爸就告诉你是富二代你爸和你说你家有1000万,这是你爸和你说的,可真实情况是你家只有5万块,但是在你的心目中,你认为你家有1000万,你平时要啥的时候你爸也都满足你,这样的错觉就会一直维持下去,OS让每个进程都认为,好处就是让每个进程都已同样的视角进行内存管理,如果没有地址空间,直接让CPU去物理内存访问本质就是一种差异化,而又了地址空间,本质就是一种统一化 ,有差异就意味着更复杂,容易出错,统一化肯定就比较简单。

进程包括描述进程的PCB+页表+进程地址空间整体作用下,再加你的代码和数据合起来才叫进程。

解释程序父子进程全局变量发生写时拷贝后地址为什么仍然一样?

针对上面的问题,学到这里就可以进行一个合理的解释了。

子进程的创建是以父进程为模板的,子进程的地址空间和页表大部分也和父进程一样,所以再开始创建的时候子进程的g_val也执向父进程那一块,所以父进程和子进程打出来的值一样,地址一样,但实际上是不同的进程地址空间的地址,后来子进程把值改了,因为进程是具有独立性的,所以发生了写时拷贝(重新开辟一段物理空间),然后把新空间在建立与地址空间的映射,所以打出来的地址一样,值不一样,虚拟地址没变。父子进程一般的代码是享的。所以,所有只读的数据一般可以只有一份。

小问题:str与p的地址是一样的吗?

原因就是:因为他们都是只读的没人修改,OS维护一份是成本最低的,



版权声明:本文为qq_52433890原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。