Linux系统编程:进程概念

目录

一、冯诺依曼体系结构

二、操作系统

1.操作系统的概念

2.管理的本质

三、进程

1.进程的概念

2.PCB(Process Control Block)

3.查看一个进程

ps命令(静态查看)

top命令(动态查看)

4.PID和PPID

5.创建进程的方法

四、进程状态

1.运行态

2.终止态

3.阻塞态

4.挂起态

5.Linux上的进程状态

五、进程优先级

如何让优先级高的进程先运行

六、进程的其它基本概念认识

七、环境变量

1.什么是环境变量

2.常见的几种环境变量

3.查看环境变量方法:

4.环境变量添加

八、进程地址空间

1.虚拟地址

2.什么是进程地址空间

逻辑总结:


一、冯诺依曼体系结构

冯诺依曼体系结构其实是一个老生常谈的内容,我们常见的计算机、服务器等大部分都是遵循冯诺依曼体系结构的。

我们都知道我们的计算机是由很多硬件组成的,比如鼠标键盘、显示器、网卡、CPU、内存、磁盘等等。我们给这些硬件设备分一下类就是:

  • 输入设备:鼠标、键盘、话筒、摄像头、扫描仪、磁盘、网卡……
  • 输出设备:显示器、音响、网卡、磁盘、显卡……
  • 存储器:内存
  • 中央处理器(即CPU):含有运算器和控制器等……

想要了解什么是冯诺依曼体系结构,我们首先要认识什么是体系结构,体系结构其实就相当于是计算机硬件的骨架,这个骨架决定了我们操作系统如何组织我们的硬件设备,将硬件设备组织在一起然后服务于软件,最后通过软硬件的配合就可以完成计算机的工作。

输入设备:比如键盘、鼠标等

存储器:就是内存

运算器+控制器:中央处理器(CPU)

输出设备:显示器等

关于冯诺依曼体系结构,我们需要理解以下几点:

  • 既然冯诺依曼体系结构中的存储器,指的就是我们平时所说的内存,那么为什么需要有内存呢?

我们都知道CPU的运算速度是非常快的,在计算机中,CPU的运算速度>寄存器的速度>缓存速度>内存>>外设(比如说磁盘)>>光盘;我们设想一下假如冯诺依曼体系结构中没有内存,那么CPU会直接与输入设备、输出设备进行数据交换,那么就会导致一个问题:CPU的速度远远大于输入设备和输出设备,从而使得CPU很快地完成了数据接收与运算,但需要等待输入设备和输出设备,因为它们两个的速度太慢了。这样的问题就会导致即使CPU的速度特别快,计算机整体的速度效率也会因为CPU等待输入输出设备而大大降低。因此,我们取了速度处于二者之间的硬件——内存。输入设备的数据传入内存当中,内存再将数据传给CPU,CPU处理完毕后再返回给内存,最后由内存将数据返回给输出设备。

所以所有的设备都只能够直接和内存打交道,不能直接与CPU打交道,这样就可以做到持续计算、输入设备也持续工作。效率大大提升。

我们举个简单的例子来理解一下冯诺依曼体系结构:

在日常生活中我们经常使用微信聊天,其实在微信聊天的过程中就是冯诺依曼体系结构的很好体现。我们能看到下面的示例图:当我们在键盘上输入"你好"点击发送时,输入设备会将这条信息传输给内存内存再将数据给CPU进行处理CPU处理完毕以后返回给内存,内存再将数据传输给输出设备即网卡,通过网络你的信息就能够传送到你好友的网卡上,网卡此时作为输入设备再将数据传输给内存,内存将数据传输给CPU,CPU处理完毕以后再返回给内存,内存最后将数据传输给输出设备即显示器,这样你的好友就能看到你发的消息了。

再比如,我们写的程序在编译好以后必须先加载到内存中,这是因为我们编译好的程序是可执行程序,它是一个文件(在windows下是.exe可执行文件),cpu只能和内存交互,而我们常说的C,D盘都是磁盘文件,上面说了,这是输入设备,无法传递给cpu,只能传给内存。

二、操作系统

我们上面讲的冯诺依曼体系结构是属于硬件层面的内容,下面我们来谈谈软件层面的内容——操作系统(Operator System)。

1.操作系统的概念

操作系统是计算机中一个基本的程序集合。实质上操作系统就是一款软件,它是一款负责管理的软件。它对下需要管理好软硬件资源对上需要为用户提供良好的、稳定的、安全的服务。

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

  • 内核(进程管理,内存管理,文件管理,驱动管理)
  • 其他程序(例如函数库,shell程序等等)

而根据上图我们也得知,操作系统确实是一个进行管理(软件+硬件)的软件,例如:管理底层的驱动程序和底层硬件。很明显用户无法直接操作各种硬件,因此,在整个计算机软硬件架构中,操作系统的定位是:⼀款纯正的“搞管理”的软件。

2.管理的本质

我们想要弄清楚操作系统是如何进行管理的,首先我们需要弄清楚管理的本质是什么?

在现实生活中我们的管理不一定是直接对着被管理者面对面的管理,就好比说在学校里管理者假设是你的校长,你作为学生就是被管理者,你会发现你在学校里见校长的次数少之又少,但是呢,校长依然能够将所有的学生管理得很好,这是为什么呢?

原因就是管理其实是通过管理数据从而实现的对特定对象的管理。你在学校入学的时候,你作为学生肯定需要录入你自己的个人信息,然后校长手上就会有一份包含你个人信息的档案,这就是你的数据,校长只需要对这一份又一份的学生数据进行管理,即可实现对学生的管理。

再者我们如果需要实现一个学生管理系统(假设用C语言),我们首先要做的事情一定不是各种管理功能的实现,而是定义一个描述学生身份信息的结构,比如描述学生的姓名、性别、专业、班级、绩点等等……当我们来了一个新学生就创建一个新的这种描述,有多少个学生就有多少个描述,每个学生的描述是独立的整体(相当于你的学生档案),接着我们要将学生管理系统的功能进行实现呢则需要将这些一个个独立的描述结构组织起来,从而实现相应的功能。这个组织起来的过程就需要运用到我们的数据结构,比如说将所有学生用链表连接起来,一个链表节点就代表一个学生的档案,这样就实现了组织。最后只需要将需要管理的功能实现出来,这样一个管理系统就完成了。

因此我们得出结论:管理的核心本质是 ”先描述再组织“!

再举一个例子,操作系统就像银行运作系统:

银行的行长可以对银行职员进行管理,同时也能够对银行的硬件设施进行管理,比如说银行的电脑、银行的桌椅;那行长是怎么进行管理的呢?其实也是 先描述再组织 。对于员工,行长在员工入职前会收集员工个人的信息,这就是描述的过程,对于硬件设施,行长也会有每种硬件设施的信息,比如电脑的型号电脑的数量,这也是描述的过程;完成了描述以后,行长会组织银行职员给他们分配各自的工作任务,会安排银行职员如何去使用银行的硬件设施,这就是组织的过程。这样行长就实现了对银行的管理,银行就可以正常运作对外给客户提供服务了。

但是银行我们通常去,都只有下面这个窗口

银行是不会将自己整体暴露给外界的,意思就是说银行系统只会对外提供一个个服务的窗口,客户到银行办理业务是通过窗口实现的,银行怎么存钱怎么取钱,把钱存到什么地方,在什么地方取钱客户是不知道的,因为银行需要确保一个安全性,所以要将自己的整体封闭起来。

操作系统也是同样的,为了安全性,防止用户无意或者有意修改了操作系统的某些数据,从而导致操作系统不能正常运行。所以操作系统也会将自己封闭起来,那么用户在使用的时候呢操作系统会对外提供一些接口,用户只能够通过这些接口来让操作系统实现相应的功能服务。

操作系统对外表现为一个整体,只会暴露出自己的部分接口,供给上层开发使用,调用这部分由操作系统提供的接口,就叫做系统调用
 

系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以有些开发者已经对部分的系统调用进行适度的封装,从而形成了库,有了库就更有利于用户更好地使用和开发。

所以,到这里我们才恍然大悟,原来我们之前的printf打印操作,是封装好的库函数,这样我们才能一条语句实现打印输出。

三、进程

1.进程的概念

在操作系统的书籍里我们经常能看到进程的定义是:进程是一个运行起来的程序。

但是进程与程序有什么区别呢?

我们看到:我们的程序是在磁盘上的可执行文件,当我们运行可执行文件时必须先将程序从磁盘加载到内存当中。但程序加载到内存以后就叫做进程了吗?答案是:不是的

我们的操作系统需要管理的进程可能不止一个,既然有多个进程,那么操作系统再进行进程管理是也是服从 先描述再组织 的原则。当一个程序加载到内存时,操作系统可不仅仅只是将程序的代码和数据加载到内存,操作系统还要为了管理该进程,创建对应的数据结构,这个数据结构包含了进程的所有属性数据,能够描述该进程。Linux是用C语言写的,在Linux中描述进程的数据结构是struct结构体,名字叫 task_struct 。所以所谓的进程,是将描述进程属性数据的结构和进程的代码数据结合在一起,才叫做进程。

进程 = 可执行程序 + 该进程对应的内核数据结构

2.PCB(Process Control Block)

我们操作系统中用来描述进程的属性数据的结构就叫做 PCB(Process Control Block) 。如上面所说的在Linux系统下的PCB就是 task_struct 。PCB即进程控制块,进程的信息被放在进程控制块中,可以理解为进程控制块是进程属性的集合,用来描述每一个进程。

3.查看一个进程

进程的信息可以通过 /proc 系统⽂件夹查看,我们在根目录下有个名字叫 proc 这么一个目录,它是一个内存文件系统,这个目录里面放的是当前系统实时的进程信息。

同时大多数进程信息同样可以使用top和ps这些用户级⼯具来获取:

ps命令(静态查看)

  • a:显示所有用户的进程
  • u:显示用户信息
  • x:显示没有控制终端的进程
  • -e:显示所有进程(等同于 - A)
  • -f:显示完整格式的进程信息

top命令(动态查看)

  • 按 q 退出
  • 按 P 按 CPU 使用率排序
  • 按 M 按内存使用率排序

4.PID和PPID

在  task struct  结构中,存在两个成员:PID和PPID

PID:全称Process Identity,是进程标识符,每一个进程都有自己的PID,用来描述进程的唯一标识符,类似于我们每个人都有一个独一无二的身份证号码。
PPID:全称Parent Process Identity,顾名思义PPID就是进程的父进程的标识符,也就是父进程的PID。

如何查看PID和PPID

第一种,直接查看进程的信息,ps ajx | head -1 && ps ajx | grep 'mytest' | grep -v grep。

第二种,我们可以通过系统调用来获取PID和PPID,这里需要介绍两个函数:getpid()、getppid()
我们通过man手册查看一下这两个函数,输入指令:
man 2 getpid
此时我们可以看到这两个函数需要包含的头文件以及返回值,这两个函数的作用是获得进程的PID和PPID,返回值pid_t是一个无符号整数。

5.创建进程的方法

第一种方法是我们目前为止最常用的:利用Linux命令行指令来创建进程,即程序编译好以后,输入指令:./可执行程序文件名

第二种方法是通过系统调用来创建进程,这里我们需要学习一个函数fork()
我们先通过man手册来查看一下这个函数,输入指令:
man 2 fork

我们可以看到fork函数需要包的头文件以及返回值和形参,fork是一个用来创建子进程的函数,返回值pid_t是一个无符号整数。

这里还有一个很有趣的点,它说如果创建子进程成功了,那么父进程返回的是子进程的PID,而子进程返回的是0。相当于有两个返回值。

这是为什么呢?fork函数是如何创建进程的?

fork是一个系统调用,由当前正在运行的进程调用,这个调用它的进程被它称为父进程

fork函数内部大概原理:

根据父进程创建一个子进程,子进程PCB结构成员参数大部分仿照父进程,生成唯一PID标识符。(即不是一个函数本身返回两次,而是父子进程各自从fork返回)

子进程和父进程共享代码,数据则是由子进程在访问时可以直接访问父进程的,子进程如果要修改数据会直接在内存创建与父进程一样的进行修改(写时拷贝)(写的时候拷贝数据)

好了,讲到这里我们梳理一下:

为什么fork()给父进程返回子进程的pid,给子进程返回0呢?这样做有什么意义?

我们知道一个父进程都有可能会有多个子进程,而每一个子进程呢有且仅有一个父进程,所以父进程和子进程可能存在一对多的关系,父进程必须要有能够标识子进程的方案,因此fork()给父进程返回子进程的pid,父进程可以根据这个pid查找到对应的子进程。而给子进程返回0,其实是因为子进程找父进程的成本比较低(比如可以用 getppid() 函数获取父进程的pid),子进程只需要知道自己被创建成功就可以了。

fork()函数之后,操作系统做了什么?

进程的管理是先描述再组织,所以每一个进程都会有对应的task_struct+进程代码和数据,那么子进程被创建fork()函数是用来创建子进程的,当创建子进程成功以后,操作系统中就多了一个进程。我们都知道操作系统对成功以后,操作系统多了一个进程以后,也会为这个新增的进程创建其对应的task_struct+进程代码和数据。

子进程被创建好以后,子进程是怎么被运行起来的呢?

我们知道进程是通过PCB描述起来的,每个进程都有对应的PCB,每一个CPU都会存在一个运行队列,所谓的运行队列我们叫做runqueue,里面放的全都是PCB,每一个PCB里面都有该进程的属性信息,并且每一个PCB都有指向该进程自己的代码和数据。CPU中的调度器会去runqueue中根据优先级选择合适的进程被CPU运行。同样的,子进程被创建出来以后,将被放入到CPU的运行队列当中,从而让CPU去调度

四、进程状态

进程的状态体现了一个进程的生命状态,在进程执行的不同时间里进程可能会处于几种不同的状态。我们介绍一下操作系统的运行态、终止态、阻塞态和挂起态。

我们可以用ps aux / ps axj 命令来直接查看。

1.运行态

运行态指的是只要是在运行队列里排队等待调度的进程,都叫处于进程态。运行态不代表进程正在运行,代表进程已经准备好了在运行队列里随时等待调度。

2.终止态

同样的,想要理解什么是终止态,我们只需要弄清楚一个问题:
终止态指的是这个进程已经被释放了,还是指这个进程依然存在,只不过永远不会运行了,随时等待被释放?
答:答案是后者,原因是进程运行完了并不一定马上就能够释放,有可能存在当前操作系统可调度资源有限,简单来说就是操作系统很忙,那么同时存在很多个运行完的进程,就需要排队等待释放。

3.阻塞态

想要理解阻塞态,我们首先要明确一点的就是:

一个进程在使用资源的时候,可不仅仅只是在申请CPU资源,进程还可能在申请其他更多的资源,比如说磁盘资源、网卡资源、显卡资源、显示器资源等等……

我们上面提到CPU的速度是很快的,而慢设备比如磁盘、网卡的速度是比较慢的,所以就会存在这样的情况:当进程访问某些资源时,比如说CPU即将准备运行的进程需要对磁盘数据进行读取,但此时磁盘资源暂时还没有准备好,或者是磁盘正在给其它进程提供服务,那CPU将要运行的进程就会没办法读取磁盘数据只能够等待磁盘资源。但是CPU的这个进程没理由在CPU的运行队列里等着吧,俗话说不要占着茅坑不拉屎,所以:

  1. CPU当前的进程要从CPU的运行队列中暂时移除;
  2. 将暂时移除的进程放入到对应设备的等待队列当中,等待对应设备的资源准备就绪。

当我们的慢设备资源准备就绪时,即相当于告诉CPU现在我可以被你读取了,那么这个进程将再次被放回CPU的运行队列中等待被运行处理。

而这个等待的过程,就是我们日常生活中所说的,卡住了的过程。

所以,现在我们就知道了当进程等待某种资源(非CPU),资源没有准备就绪的时候,进程需要到该资源的等待队列中进程排队,此时进程的代码不能够运行,进程所处的状态就叫做阻塞态。

4.挂起态

从图中我们就可以看到进程既不处于运行状态而又待在内存里,也不运行,挂起态就绪以后才会运行,就好像是一直是预备状态,但是,这个时候还没轮到你运行啊,怎么办,你不能占着位置不让我运行其他进程吧,所以,操作系统就会在磁盘上找到一个专门的区域(磁盘中的swap分区),将这部分进程的代码和数据置换到这个区域里,再将内存原来的代码和数据释放掉,这样就能腾出空间来让其他进程使用

所以操作系统可以通过这种方式,可以让内存只短暂地留存进程的PCB,剩下的代码和数据全部置换到磁盘上,此时这样的进程就叫做进程挂起。详细点说就是:因为资源空间不足而被操作系统将进程的代码和数据临时地置换到磁盘当中,此时进程的状态就叫进程挂起。

5.Linux上的进程状态

Linux系统下有7种状态,分别是:R(running)、S(sleeping)、D(disk sleep)、T(stopped)、T(tracing stop)、Z(zombie)、X(dead)

这里我们着重讲一下,僵尸进程和孤儿进程。

Z状态叫作僵尸状态,Z状态是一种已经死亡的状态,但并不是直接进入X状态(死亡状态,资源可以立马被回收了),操作系统不会将它的资源立马释放,而是进入Z状态。

进程被创建出来的原因一定是因为有任务要被这个进程执行。

那么当进程运行完以后,操作系统或者父进程怎么知道这个运行完的进程是否完成了它的任务呢?

所以一般需要将进程的执行结果告知父进程或者操作系统。Z进程就是为了维护进程退出时的信息,这个退出信息通过task_struct来存储,可以让父进程或者操作系统读取

我们这里测试一个维持30s的僵尸进程。,可以看到,此时子进程结束任务已经进入僵尸状态,并且一直维持这个状态。

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

维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中, 换句话说,Z状态⼀直不退出,PCB⼀直都要维护?是的!

说到这里我们就会发现,此时就会发生严重的内存泄漏。因为子进程的PCB会一直占用这部分内存不撒手,直到父进程来释放。

那如果父进程已经推出了呢?我们上面是父进程存在,但是不回收,但是如果父进程早早就退出了,只留下子进程。⽗进程先退出,⼦进程就称之为“孤⼉进程”

我们来实验一下:

可以看到,父进程退出以后,子进程的父进程就变成了一号进程,这个结果就有点奇怪了,按理来说父进程也会有它自己的父进程,它退出的时候也应该和子进程退出一样,处于僵尸进程的呀,为什么会直接退出了呢?
原因是父进程的父进程其实是bash,bash是操作系统的进程,这个进程已经实现了对子进程的回收,也就是说父进程即使作为别人的子进程,它在退出的时候被它的父进程回收了,所以不会处于僵尸状态而是直接退出。
那么原先的子进程在父进程提前退出以后,子进程的父进程变为了1号进程,这个1号进程其实就是操作系统,这里我们可以理解为子进程被操作系统领养了。

五、进程优先级

优先级就是进程获取资源的先后顺序。优先级和权限有什么区别呢?权限讨论的是能与不能的问题,拥有权限是可以访问可以获取资源,而优先级是都可以访问都可以获取资源,只不过是先后顺序的问题。
那么为什么需要有优先级呢?
优先级存在的核心原因是资源不足,操作系统中永远都是进程占大多数,而资源是少数。其实现实生活中我们也是这样的,想象一下如果我们没有排队来确认优先级,那只有用蛮力竞争,这会导致资源倾斜过于严重从而导致问题的发生。

我们在这里用ps命令会类似输出以下⼏个内容:

  • UID:代表执⾏者的⾝份
  • PID:代表这个进程的代号 
  • PPID:代表这个进程是由哪个进程发展衍⽣⽽来的,亦即⽗进程的代号
  • PRI:代表这个进程可被执行的优先级,其值越⼩越早被执⾏ 
  • NI:代表这个进程的nice值

Linux下的优先级由两部分组成,分别是PRI(priority)和NI(nice),PRI就是进程的优先级,NI是优先级的修正数据,准确来说Linux下的优先级就是PRI和NI的和。在Linux下的优先级默认是80。
所以如果我们想要修改进程的优先级,只需要更改进程的NI值即可。但是一个进程的优先级不能轻易的被修改,这是对系统的保护。超级用户可以修改优先级,但也不是无节制地修改优先级,Linux下最小的NI值为-20,最大为19。
Linux下优先级的计算:pri=pri_old+nice(每一次设置优先级,这个pri_old都会变为默认的80)

如何让优先级高的进程先运行

我们也知道操作系统中有不同优先级地进程能够同时存在,并且相同优先级地进程是可能存在多个的。那么如果此时运行队列来了一个优先级更高的进程,而队列又只能够是先进先出,不能让优先级高的进程“插队”到靠前的位置,那操作系统是怎么保证优先级的呢?

其实运行队列并不只有一条,实际上操作系统有多少个优先级,就会有多少条运行队列。具体是怎么样的呢?

我们举个例子来看一下:
假设当前操作系统只有0、1、2、3、4五种优先级,那么操作系统会创建维护一个指针数组task_struct *queue[5],这个数组存放的指针都指向一条条运行队列,每一条运行队列都代表一个优先级。操作系统根据不同的优先级,将特定的进程放入到不同的队列中。然后按照优先级从高到低调度进程,这也就保证了高优先级的进程先被调度,低优先级的进程后被调度。

六、进程的其它基本概念认识

  1. 竞争性:系统当中进程的数目是非常多的,而CPU的资源是少量的,所以进程之间会存在竞争属性,也就是进程之间共同竞争CPU资源。竞争的手段是通过不断地入队列,来完成优先级的确认。
  2. 独立性:进程运行的时候是具有独立性的,也就是进程与进程之间是相互独立的,多进程运行的时候,每一个进程都是独享各种资源的,多进程运行期间互不干扰。或者说当多个进程运行时,不会因为一个进程挂掉或者异常而导致其它进程出现问题。
  3. 并行:多个进程在多个CPU下分别并且同时运行,这就是并行。
  4. 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间内,让多个进程都得以推进,这就是并发。

咱们用买咖啡的事儿再白话点说:
并发:就像你和朋友排两队,但店里就 1 台咖啡机 —— 店员一会儿给你队做一杯,一会儿给朋友队做一杯,两队都在动,但其实咖啡机是 “轮着用” 的,不是真的同时做两杯。
并行:店里有 2 台咖啡机,你队用一台,朋友队用另一台 —— 俩机器同时在做咖啡,两队是真・一起往前动。

说到进程切换,就要明确抢占式内核:

如果当前的运行队列来了一个优先级更高的进程,操作系统还是简单地根据队列来进行先后调度嘛?答案是不是的!当代CPU一般都是抢占式内核,也就是说如果正在运行的是低优先级进程,突然来了一个优先级更高的进程,我们的调度器会直接把进程从CPU上剥离,放上优先级更高的进程,这就叫进程抢占。

这就是为什么,下载速度,后台下载慢,正在运行的速度快,这是系统根据你的优先级所作出的最优排序。

七、环境变量

1.什么是环境变量

环境变量(environmentvariables)⼀般是指在操作系统中⽤来指定操作系统运⾏环境的⼀些参数

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

2.常见的几种环境变量

  • PATH:系统中可执行程序的搜索路径
  • HOME:指定用户的主工作目录(即用户登录到Linux系统中时,默认的目录)
  • SHELL:当前shell,它的值通常是/bin/bash

3.查看环境变量方法:

echo $PATH

和环境变量相关的命令

1. echo:显示某个环境变量值

2. export:设置⼀个新的环境变量

3. env:显示所有环境变量

4. unset:清除环境变量

5. set:显示本地定义的shell变量和环境变量

4.环境变量添加

如果我们后面需要自己添加环境变量,那么根据分类添加时需要注意是全局还是局部:

  • 直接定义变量(比如 age=20):默认是 局部变量,只有当前终端能用,打开新终端就没了
  • 用 export 声明(比如 export age=20):变成 全局变量,新打开的子终端也能看到

例如:我们每次在执行编译好的可执行程序时,需要加前缀 ./ ,现在可以添加路径直接调用

(添加到系统的环境变量:环境变量名=$环境变量名 : 添加路径)

export 变量名=变量值
# 多个值用冒号 : 分隔,追加变量用 $变量名 拼接

八、进程地址空间

我们在学习c语言的时,一直看过这样的一幅图:

但有一个问题是:我们怎么知道进程地址空间分布就是像这张图一样的呢?我们有什么办法能验证一下呢?下面我们写一个代码来验证一下:

正文代码我们可以用main函数的地址来代表。
我们定义一个全局变量g_val并且对其进行初始化,它的地址代表初始化数据的地址。
我们定义一个全局变量un_g_val但不对其进行初始化,它的地址代表未初始化数据的地址。
我们动态申请一个字符数组tmp,由于动态申请内存是向堆申请的,所以tmp里申请的内容是位于堆上的,tmp里的内容的地址代表的是堆上的地址。
由于tmp这个字符数组本质上也是一个定义在栈上的数组,只不过它的内容是向堆申请的,所以我们取tmp的地址代表的是栈上的地址。
argv数组代表的是命令行参数的地址。
env数组代表的是环境变量的地址。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
 const char *str = "helloworld";
 printf("code addr: %p\n", main);
 printf("init global addr: %p\n", &g_val);
 printf("uninit global addr: %p\n", &g_unval);
 static int test = 10;
 char *heap_mem = (char*)malloc(10);
 char *heap_mem1 = (char*)malloc(10);
 char *heap_mem2 = (char*)malloc(10);
 char *heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
 printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
 printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
 printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
 printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
 printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
 printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
 printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
 printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
 printf("read only string addr: %p\n", str);
 for(int i = 0 ;i < argc; i++)
 {
 printf("argv[%d]: %p\n", i, argv[i]);
 }
 for(int i = 0; env[i]; i++)
 {
 printf("env[%d]: %p\n", i, env[i]);
 }
 return 0;
}

打印出来是这样:

$ ./a.out 
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
env[1]: 0x7ffd0f9a482e
env[2]: 0x7ffd0f9a4845
env[3]: 0x7ffd0f9a4850
env[4]: 0x7ffd0f9a4860
env[5]: 0x7ffd0f9a486e
env[6]: 0x7ffd0f9a4892
env[7]: 0x7ffd0f9a48a5
env[8]: 0x7ffd0f9a48ae
env[9]: 0x7ffd0f9a48f1
env[10]: 0x7ffd0f9a4e8d
env[11]: 0x7ffd0f9a4ea6
env[12]: 0x7ffd0f9a4f00
env[13]: 0x7ffd0f9a4f13
env[14]: 0x7ffd0f9a4f24
env[15]: 0x7ffd0f9a4f3b
env[16]: 0x7ffd0f9a4f43
env[17]: 0x7ffd0f9a4f52
env[18]: 0x7ffd0f9a4f5e
env[19]: 0x7ffd0f9a4f93
env[20]: 0x7ffd0f9a4fb6
env[21]: 0x7ffd0f9a4fd5
env[22]: 0x7ffd0f9a4fdf

这就证明了上图的存在。

1.虚拟地址

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
 pid_t id = fork();
 if(id < 0){
 perror("fork");
 return 0;
 }
 else if(id == 0){ //child
 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }else{ //parent
 printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }
 sleep(1);
 return 0;
}

这样的一段的代码,我们输出会发现:

parent[2995]: 0 : 0x80497d8
child[2996]: 0 : 0x80497d8

输出出来的变量值和地址是⼀模⼀样的,这样好像没什么奇怪的,因为变量一样,都指向0

所在的空间。但是当parent变为100,我们在输出一下

child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8

所以我们可以大胆猜测:我们在C/C++中使用的地址,绝对不是物理地址!

因为如果是物理地址的话,访问同一块物理空间怎么会出现不同的值呢?

其实上面访问的确实不是物理地址,而是虚拟地址,或者叫线性地址/逻辑地址。虚拟地址是为了对操作系统以及各种硬件进行保护。如果可以直接访问物理地址,我们一旦误操作,比如越界访问,内存泄漏就会造成很大的甚至是不可挽救的问题。

所以说OS必须负责将 虚拟地址 转化成 物理地址 。

2.什么是进程地址空间

我们每一个进程在启动的时候,都会让操作系统给它创建一个地址空间,这个地址空间就是进程地址空间。既然每个进程都会有一个自己的进程地址空间,那么操作系统要不要管理这些地址空间呢?
答案是:要的!管理的方法也是先描述再组织,所谓的进程地址空间其实是内核的一个数据结构,在Linux当中这个结构叫作sturct mm_struct.

上图我们就能大致推断出,os是通过页表这个结构实现对虚拟地址和物理地址的精确映射。

其实进程地址空间存在的最大意义就是:让每一个进程都认为自己是独占操作系统中的所有资源的!
所谓的地址空间,其实就是操作系统通过软件的方式,给进程提供一个软件视角,让进程认为自己会独占系统的所有资源。(独立性!!)

我们的进程地址空间内部被划分成了很多个区域,每一个区域都有对应的地址,这个地址就是虚拟地址。而我们的物理内存内部也同样会被划分成很多个区域,每一个区域也会有对应的地址,这个地址我们叫物理地址。


在每一个进程被创建的时候,操作系统都会自动生成一个页表结构,这个页表就建立起了进程地址空间和物理内存之间的映射关系。页表的一边放着的是虚拟地址,另一边放着的是物理地址。我们进程访问的是虚拟地址,在虚拟地址上做操作,页表通过这个虚拟地址映射到物理地址,通过这个物理地址才能访问到物理内存。所以页表就是进程地址空间与物理内存之间的中间桥梁!

所以我们再来回答一下刚才的问题,为什么改变以后,地址依然相同,变量却不同了,就是因为当子进程对变量的内容进行修改以后,父子进程访问到的内容就不一样了。原因是虽然父子进程访问的变量地址还是相同的,但那只是虚拟地址相同,物理内存上会为子进程开辟一份新的空间并将原来的空间拷贝过来,再让父进程指向原来的物理空间,子进程则指向新的空间。这样子进程对变量内容的修改是在新空间内修改,父进程指向的旧空间并不会被修改。这就是为什么父子进程访问的变量地址相同(虚拟地址相同,物理地址不同了),但访问的变量内容却不同的原因。

逻辑总结:

  1. 当一个进程被创建时,首先形成的是PCB(task_struct结构对象);
  2. 同时在PCB里面有一个指针指向了这个进程的进程地址空间(即虚拟内存,本质类似PCB,也是一个结构对象),这个虚拟内存是这个进程在真实物理内存的映射,包含了物理内存的严格区域、范围划分等信息;
  3. 操作系统从进程地址空间中提取该进程的物理地址,同时为该进程生成页表,用来放置对应各个区域的虚、物理地址和权限、状态信息
  4. 再通过CPU的控制寄存器(设为cr3),来指向当前进程的页表,当进程需要调/修改某个资源时,CPU会通过 cr3 指针访问页表查看该资源空间对应状态。
  5. 更改的时候改变pcb的描述,再改变cr3的指向,就能完成对进程的更改!

所以我们进一步可以得出,进程  =  内核PCB  +  代码数据  +  进程地址空间  +  页表

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值