Linux->进程概念(精讲)

引入:本文会讲到的东西有哪些?

**注:**要讲就讲清楚,所以从0到懂,目录在右侧

一:冯·诺依曼体系结构

1:人物介绍

冯·诺依曼是一个伟大的人,他提出了一个体系结构,被命名冯·诺依曼体系结构!至今常见的计算机,如笔记本或我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系!

2:体系结构图

冯·诺依曼体系结构如下图所示:

注意:该图被博主省去了与本博客无关的部分,也就是说远不止于此

以下简称冯·诺依曼体系结构 为 体系结构

在解释这个体系结构之前,需要对图中的一些名词进行通俗的解释:

**1:**存储器即内存,也就是网上售卖的内存条,不要误以为是磁盘。如图:

**2:**运算器和控制器我们不用管,只用知道两个合起来就是CPU;知道CPU具有运算功能即可

3:解释体系结构

以计算机为例,输入设备则为键盘、鼠标、声卡等;而输出设备则为显示屏、喇叭等;计算机通过输入设备得到数据,数据在计算机当中进行CPU的一系列的运算后,通过输出设备进行输出。

Q1:那为什么图中不是数据从输出设备直接到CPU和CPU直接把数据给到输出设备,而是经过了内存这一步?

A1:CPU的速度极快,而输入设备和输出设备相对于CPU来说是极慢的,根据木桶原理可知,输入输出设备极大的拖慢了CPU的速度,所以冯·诺依曼在输入输出设备到CPU之间,加入了内存这一硬件;因为内存速度相较于输入输出设备极快,但却略微逊色于CPU,这就意味着其相较于之前提高了效率,却又不会让CPU过于忙碌!

Q2:但是输入输出设备不还是木桶中的最短的木块吗,为什么就能提高速度了?其次你数据得先从输入设备到内存到CPU,再从CPU到内存到输出设备,这不是更为繁琐吗?

**A2:**这两个疑问,都能被内存所具有的预加载和缓存功能所解决!得益于预加载和缓存机制,数据会被预先载入内存,CPU也能将运算结果缓存到内存中。这样CPU无需等待I/O操作,可以直接从内存读取数据或写入结果。再由于CPU只能与内存直接交互,因此只需优化好预加载和缓存机制,就能显著提升系统性能!而至于预加载和缓存机制怎么优化,我们无需关心

注意:I/O 操作(Input/Output Operation,输入/输出操作)是计算机与外部设备或存储介质之间的数据传输过程。

Q3:是因没有比内存更快的存储单元,所以才选择的内存吗?

**A3:**储单元距离 CPU 越近,访问速度越快、成本越高、单体容量越小;存储单元距离 CPU 越远,访问速度越慢、成本越低、单体容量越大。所以寄存器才是最快的,但同时其成本最高,单体的容量越小,而内存是速度够用,成本合适,单体容量合适(一般为几个G),这就是选择内存的原因!

4:三个疑问

Q1:为什么有人会购买内存条?

**A1:**电脑参数中的16+1T ,手机参数中的8+256,其中的16和8就是内存的大小;有人会去买内存条来扩大内存就是为了提速,内存越大,预加载和缓存对应的数据更多,所以CPU更能发挥自己的性能!这也是为什么内存小的电脑后台挂程序挂多了,电脑会卡,因为内存拥挤!

Q2:为什么不用寄存器来替代所有的存储单元?

**A2:**造价太贵,内存才能让我们在保持计算机的效率的时候,兼具性价比

Q3:为什么常有人说程序在运行的时候,必须先把程序先加载到内存中?

**A3:**任何程序想要得到结果,都是靠CPU,而CPU只与内存进行交互,所以当然必须加载到内存

5:两个场景

场景一:两个人在QQ聊天,这个过程中数据的流向是怎么样的?

A:

场景二:A给B发一个已经写好的实验报告,这个过程中数据的流向是怎么样的?

A:

为什么说冯·诺依曼是一个伟大的人,因为其提出的内存创新,我们才能人人能有一部合适的手机和电脑,世界才会有互联网,大家之间的距离,才会如此的渺小~

**总结:**讲解冯·诺依曼体系结构,我们要记住任何的应用或者程序想要运行,都要先加载到内存中!

二:初识操作系统

1:老式计算机

在计算机刚问世的时候,计算机不仅大,而且只有科学家才会使用,因为软硬件资源管理欠佳!也就是说,以前使用电脑,得输入二进制指令或者通过打孔纸带向电脑输入信息,然后电脑经过计算后,才能够反馈出对应的数据;但是现在我们只需点击开机键,就会呈现出桌面,我们想使用什么软件,双击打开即可,这就是操作系统(OS)带来的好处!

老式计算机 打孔纸带

注:《三体》中叶文洁所使用的就是打孔纸带

2:OS概念

操作系统是一款软件,其作用是与硬件交互,管理所有的软硬件资源,为用户程序(应用程序)提供一个良好的执行环境!所以当我们点击开机键的时候,电脑加载的第一个软件就是操作系统;所以操作系统是计算机系统的核心软件,它是硬件与用户之间的桥梁!
Q:我们使用的电脑,都是windows操作系统,为什么要学Linux?
**A:全球 90% 以上的服务器运行 Linux,**不学 Linux,你极难进入云计算、运维、嵌入式开发等高薪领域

3:操作系统层级交互示意图

下面将对每个层级进行介绍:

①:底层硬件

即网卡 键盘 硬盘等,不再赘述

②:驱动程序

在购买某些键盘或者鼠标的时候,商家会发给你一个链接,让你下载一个软件,这个软件就是驱动程序,所以每个硬件都会自己的驱动程序,驱动程序的功能包括但不限于打开关闭访问硬件........

Q:我的U盘插上就可以用,何来对应的驱动程序?

**A:**实际上,操作系统已内置大量通用驱动,支持即插即用设备(如U盘、普通键鼠);所以只是在你看不见的地方,已经有了对应的驱动程序;所以这也能说明为什么有时候,我们的键盘明明已经插入了,但是却无法立刻使用,要等待几秒,就是因为对应的驱动程序需要启动时间

③:操作系统

可以看见,操作系统这一层级,是位于硬件和软件的上面,因为我们知道操作系统要管理所有的软硬件资源!那究竟是怎么管理的?

举个例子:校长/院长如何管理学生?

你从未谋面过校长,但其已经把你安排的明明白白了,你上什么课,你住哪,你的学费多少,你的同学是哪些,全都已经安排好了,并且不止你一个学生,千千万万个大学生,校长不用见到你,就已经管理好了你。因为每个同学都有对应的一份档案!每个同学的档案的格式都一样,包含姓名,性别,专业等...,然后档案按照不同的专业或者年纪,有着不同的编号,所以校长只需对档案进行管理,就能够实现对同学的管理!并且管理得很轻松!

类比到操作系统如何管理进程的:

这种思想叫作:先描述,再组织

先描述:描述出进程的属性 (如进程的ID,进程的状态等)

再组织:将属性构成的结构体放进链表中

Q:为什么不说操作系统如何管理软硬件的?

**A:**因为本博客重点在于讲解进程,而进程就是软件的一种,所以直接类比OS管理进程!

④:system call

操作系统现在已经把软硬件管理得井井有条了,所以有必要保证内部的安全,(就像银行柜台一样,存钱的人只能通过柜员或者ATM机,而不能直接进入金库)不能让用户直接的接触内存的数据,所以必须要有系统调用接口;用户只能通过系统调用接口来访问操作系统内部的数据!

类比:

c++里面的类,你想访问private的数据,只能通过类提供的public方法来访问

所以我们想访问操作系统的数据,只能通过系统调用接口来访问

所以我们使用的scanf和printf,不仅仅是一个函数这么简单,因为这种函数都是会操控硬件(scanf影响屏幕,printf影响键盘),而上面已经说过硬件都是被OS已经管理好了,只能通过系统调用接口来进行访问!所以printf和scanf这种函数都是已经被封装成为了系统调用接口!

总结:

系统调用接口的存在,便于让用户更好的使用计算机,降低了使用的门槛和成本,而其中一种封装就是是封装成库,我们直接使用库中的函数即可(如scanf printf等函数)!且不管是在哪一个操作系统,仅仅是系统调用接口的内部实现不同,但是最后都会在用户操作接口中被封装成一样的库,成功屏蔽了接口的差异化!

⑤:用户操作接口

④中提到的"其中一种封装就是是封装成库 ",库就位于用户操作接口这一层级!

系统调用接口对我们普通用户来说使用成本又太高了,因为要使用系统调用前提条件是你得对系统有一定了解。所以在系统调用接口之上又构建出了一批库,例如libc和libc++。实际上在语言级别上使用的各种库,就是封装了系统调用接口的,我们就是通过调用这些库当中的各种函数(例如printf和scanf)进行各种程序的编写。

⑥:用户

一个小白用户,就位于用户层级,其能使用到的就是windows的桌面,小白通过点击图标、拖拽文件等操作间接调用系统功能。

总结: 为什么要讲操作系统层级交互示意图,因为"先描述 再组织"的思想,贯穿本博客全文!

三:进程

1:概念

课本上的进程概念: 正在执行的程序/程序的一个执行实例等

**Linux下的进程概念:**占用分配系统资源(CPU时间,内存)的实体。

课本上的概念不能说错,只能说过于宽泛,因为其是对于所有操作系统中的进程的统一概念,所以不得不说得宽泛且不细致;而对于Linux中的进程呢,其概念就是担当分配系统资源(CPU时间,内存)的实体。

Q:正在执行的程序和担当分配系统资源的实体有何区别?(广泛进程概念和Linux进程概念的区别)

**A:**占用资源的实体包括但不限于程序!其还有对应的其他东西,如比内核数据结构,下面我们将会介绍内核数据结构的其中一个,名为PCB

其次这里需要补充和更改一些叫法:

1:程序被执行起来,本质是其从磁盘中拷贝加载到了内存,而程序加载到了内存中,同时会产生一系列的内核数据结构,所以内核数据结构+内存中的可执行程序 = 进程

2:实体(进程) = 内核数据结构 + 内存中的可执行程序

其中内核数据结构中的**"内核"意为该数据结构被操作系统的内核所创建** ,而这种数据结构则有多种,本博客会介绍三种内核数据结构(PCB(task_struct),mm_struct(进程地址空间),页表);内存中的可执行程序意为程序被加载到了内存中!

内核(Kernel) 并不是一个"物理存在"的独立硬件,而是一个始终运行在内存中的核心软件

这也侧面说明了,为什么内存中的可执行程序不叫内核可执行程序,因为其只是加载到了内存中,不是由内核所创建的!

2:PCB及其内容

PCB叫作做进程控制块,根据"先描述 再组织"的思想,操作系统为了好管理进程,所以就有了PCB,其就是存放进程的属性的结构体(类似学生档案),PCB之间类似链表链接;

PCB实际上是对进程控制块的统称,在Linux中描述进程的结构体叫做task_struct!

所以OS对进程的控制和操作,都只和进程的PCB有关,和进程的可执行程序没有关系!其次我们平时说的排队,指的就是PCB的排队,把PCB放进等待队列里面,就叫做排队!

Q:为什么PCB是结构体,不是类?

**A:**Linux操作系统是用C语言进行编写的(99%),那么进程控制块PCB必定是用结构体来实现的

Linux源码中的task_struct:

**解释:**在Linux源码中,能观察到task_struct这个结构体,其中存放着进程的诸多属性

PCB的属性如下:

task_ struct 内容分类:
①:标示符 : 描述本进程的唯一标示符,用来区别其他进程。
②:状态 : 任务状态,退出代码,退出信号等。
③:优先级 : 相对于其他进程的优先级。
④:程序计数器 : 程序中即将被执行的下一条指令的地址。
⑤:内存指针 : 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
⑥:上下文数据 : 进程执行时处理器的寄存器中的数据 [ 休学例子,要加图 CPU ,寄存器 ] 。
⑦:I / O 状态信息 : 包括显示的 I/O 请求 , 分配给进程的 I / O 设备和被进程使用的文件列表。
⑧:记账信息 : 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
⑨:其他信息
注: ++在本博客,会精讲标识符、状态、优先级++,其余在后面博客要用到的时候才讲

3:标识符

标识符就像每个人的身份证号,独一无二,所以其能区别其他的进程

++在Linux中查看一个进程的标识符,有两种方法++

①:指令获取标识符

**场景:**写一个死循环的代码,其对应的可执行文件叫my.exe,然后再执行: ajx | head -1 && ps ajx | grep my.exe指令

cpp 复制代码
ps ajx | head -1 && ps ajx | grep my.exe
//先显示进程列表的表头(列名),再列出所有包含 "my.exe" 的进程信息

如图:

其中的 PID和PPID就是标识符,前者是该进程的标识符,后者是父进程的标识符!

第一行就是我们可执行程序my.exe对应的进程信息,而第二行是grep这个指令对应的可执行程序的对应的进程信息,因为:

  • grep my.exe 也会被 ps 捕获

    ps ajx 列出所有进程时,grep my.exe 这个命令本身也是一个正在运行的进程,且它的命令行中包含 my.exe,因此会被自己的过滤条件匹配到。

②:函数获取标识符

getpid函数可以获取当前进程的PID,getppid用于获取当前进程的父进程的PID,而以子进程的视角看,父进程的PID叫作PPID!

每一个进程,都有自己的父进程

如图:

所以这里可以串联一下前面的知识:PCB是属于操作系统内部的数据,我们用户想要看操作系统内部的数据,所以应该通过系统调用接口才能看得到,所以getpid()就是一个系统调用接口,其可以动态获取当前进程的PID,且其和其他函数被封装成了库,所以我们用的时候也包一下头文件

所以,这是目前需要张掌握的两种获取进程的PID的方法

4:查看进程

在讲解状态、优先级之前,先讲一下如何在Linux中查看一个进程?为什么突然现在讲,因为刚好需要的指令和标识符的知识才刚讲完,趁热乎~

因为要查看一个进程,所以进程应该长久存在,则创建一个死循环程序,让其成为进程,然后再去查看进程

①:找到proc目录

通过proc目录即可查看进程:

proc目录:proc 文件系统(虚拟文件系统)位于根目录下:

proc目录中包含大量的目录,每个进程都会在proc目录中有一个属于自己的目录,且目录的名字就是自己的PID(标识符):

**解释:**proc目录中有一个叫作17972的目录,而my.exe进程的PID就是17972!

②:PID为名的目录的内容

PID为名的目录,里面存放的就是进程的信息,其中有几个需要讲解一下:

其中的exe是一个链接类型的文件,所以其指向的就是该进程对应的可执行程序;而重点了解是cwd,其指向的my.exe所处的目录,有何意义?

cwd是当前工作目录,也就是当前可执行程序对应的目录;而exe是直接说明了是目录的哪一个程序

Q:为什么要了解cwd?
A:在C语言的学习的时候,进行文件操作:

cpp 复制代码
fopen("file.txt","w");//如果不存在这个文件,就会在当前路径下,帮我们创建一个文件!

Q:那注释中的当前路径指的是什么意思?

**A:**指的就是当前进程所处的目录!所以只有我们的C语言程序运行起来,形成进程,在执行到该行代码的时候,就会创建文件;本质就是去拿proc目录中的PID为名字的目录中的cwd文件中的的路径来进行位置的确定,所以才能够知道当前路径在哪!

前面说过:"可执行程序时拷贝加载到内存中,而不是本身加载到内存中"

下面用一个例子来证明:

**解释:**我们把/home/wtt1的my.exe删除了,但进程仍在稳定运行,只不过这里的exe变红,后面显式deleted(被删除),这就证明了程序时从磁盘拷贝到内存上!

**注:**可以使用chdir函数来更改当前工作目录,就能影响cwd的内容

5:进程状态

①:PCB的结构

说到进程的状态,第一个想到的应该是常说的排队;现在有一个疑问:之前说PCB(task_struct)之间是用链表串起来的,是为了方便管理。而对进程的一切操作,都是对PCB(task_struct)进行,那如果进程去排队了,岂不是PCB(task_struct)就要脱离当前链表?进入其他的队列或者链表中,那脱离了链表还如何管理呢?所以PCB(task_struct)的结构远不止这么简单!

PCB(task_struct)的实际结构如下:

解释: 一个PCB(task_struct)结构体中,不仅包含了进程信息,还有着多个节点对应的结构体,每个结构体中有前驱和后级指针,所以一个PCB(task_struct)可以被同时连入多个链表或队列这种数据结构中!

Q1:一个PCB(task_struct)怎么一会在这,一会在那的,不可能同时出现在不同位置的两个数据结构之中啊?

A:链表是物理地址不连续的结构,不是数组!所以只需给节点的指针一个值即可,自身不用去!

Q2:对next或prev解引用后,只能处于PCB(task_struct)中struct listnode位置,如何才能得到更上方存储的各种进程信息?

A:用struct listnode变量的位置减去偏移量即可,具体如下:

**①:**结构体的偏移量是相对于结构体的起始位置,也就是第一个变量的地址!而不是相对于结构体的末尾,这是由指针运算的基本规则决定的。

②: 如果已经存在了(声明)一个结构体名为struct,那么我们可以直接" (struct*)0 ",意义为:假装内存地址 0 处有一个该结构体的实例,但是其实没有示例,但是我们不访问结构体成员变量的值,而是在 (struct*)0的基础上取某个成员变量的偏移量,假设第一个变量是int age;第二个变量是int id;那我们就可以& ((struct student*)0)->age来取到age变量的偏移量

例子:

cpp 复制代码
#include<iostream>
using namespace std;

struct student {
	int age;       // 4 字节
	int id;		 // 4 字节

};

int main() {
	// 计算 age 的偏移量(应为 0)
	size_t offset_age = (size_t) & ((struct student*)0)->age;

	// 计算 id 的偏移量(应为 4,因为 id 占 4 字节)
	size_t offset_id = (size_t) & ((struct student*)0)->id;



	printf("offset_age:   %zu\n", offset_age);
	printf("offset_id: %zu\n", offset_id);


	return 0;
}

//size_t 是无符号整数类型,专门用于表示内存地址和对象大小(由 sizeof 返回)
//所以推荐强转为size_t
//offset译为偏移量

**解释:**age变量是第一个变量,所以相较于结构体的起始位置的偏移量为0;而id的偏移量为4!

为什么讲求结构体中一个变量的偏移量?因为我们现在已经有了PCB(task_struct) 中的struct listnode这个类型的实例的地址,那此时我们只需要知道该变量的偏移量,而偏移量就是相对于结构体的起始位置,所以我们用struct listnode这个类型的实例的地址减去其偏移量就能得到PCB(task_struct) 中初识变量的地址,就得到了PCB中存储的进程信息!

公式:

cpp 复制代码
(task_struct*)( &n - &((task_struct*)0)->n );
//假设n是struct listnode这个类型的实例
//没有加入强转 只需理解即可

解释: &((task_struct*)0)->n得到了n(struct listnode这个类型的实例)的偏移量,然后用n本身的地址将去偏移量则得到了PCB初始位置,则可以访问进程信息了!

以上虽然没有进行讲解进程状态,但是额外解决了如何得到PCB进程属性的问题!

②:广泛下的进程状态

在介绍Linux下的进程状态时,仍有必要介绍一下广泛下的进程状态,这是站在所有的操作系统这一角度来看待进程状态; 下面介绍广义的三种进程状态

补充一些概念:

每个设备都有自己的队列,CPU有自己的运行队列,键盘有自己的运行队列,声卡有自己的运行队列,所以我们常说的进程排队,就是PCB(task_struct) 在某一硬件的队列上进行等待自己被调度

**运行状态:**PCB在CPU的运行队列中,已经准备好随时被调度;并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列中排队!

**阻塞状态:**假设是在等待键盘的资源,则会进入键盘的运行队列中;此时OS会让进程的PCB链接到键盘的数据结构(队列),并将进程的状态更改为阻塞状态;等键盘输入工作完毕后,OS则再进行队列的更改,状态的更改。

总结:当我们的进程在进行等待软硬件资源的时候,资源如果没有就绪,我们的进程的PCB会:

1.将自己设置为阻塞状态;2.将自己的pcb连入等待的资源提供的等待队列

挂起状态:

挂起状态的原因有多种,我只谈论阻塞挂起!不说其他

当一个进程处于阻塞的时候,且此时内存非常吃紧(无多余内存),则对即将到来的新的进程无法分配内存了,此时OS会将处于阻塞的进程的代码和数据进行唤出操作(从内存唤出到磁盘),也就是放进了磁盘的一个叫作swap分区的空间,当内存很多的阻塞进程被唤出了,此时内存就有空间了,而当OS抗住了这波内存吃紧的压力后,此时的CPU要调度之前被唤出的进程,此时就会被从磁盘唤入到内存中!

而阻塞进程的代码和数据不在内存中了(被唤出到磁盘中),我们就称该进程为挂起状态!

**Q1:为什么是对阻塞的进程进行唤入操作?
A:**因为阻塞进程不是正在使用的进程,风险小!

**Q2:进程的PCB会被唤出吗?
A:**不会! 否则如何进行管理!下一次CPU调度的时候如何找到PCB,如何通过PCB内部指针指找到进程?

**Q3:创建一个进程 是先有PCB还是先有代码和数据?
A:**PCB! 具体原因如下:

**例子:**电脑一般是16(内存)+512(磁盘) 或者32(内存)+1T(磁盘) ,而现在随便一个3A游戏都是超过32g,游戏启动,现在任务管理器也的确显示了该游戏的进程;如果是先有代码和数据,那请问你就算是32g内存,怎么放得下80g的游戏?!所以,进程创建是先有PCB!此时OS就知道有这个进程了,然后此时会根据我们要使用的游戏的哪一部分,进行磁盘的唤入到内存的工作!(这就是为什么游戏为什么是下载到磁盘上),所以再大的游戏,一般也可以在32g内存电脑上游玩,本质就是我们只会使用到游戏的一部分内容!

注意: swap分区大小一般最大和内存一致!

因为访问外设的速度是比较慢的!而唤入唤出就是访问磁盘,所以此时牺牲了速度,为了让操作系统不崩溃!用时间换空间,慢是慢了点,但是内存的空间腾出来了!而如果swap分区过大,我们内存和磁盘的唤入唤出频率会增加,只会让我们的速度只会越来越慢!

③:Linux下的进程状态

一个进程从创建而产生至撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器,有时虽有空闲处理器但因等待某个时间的发生而无法执行,这一切都说明进程和程序不相同,进程是活动的且有状态变化的,于是就有了进程状态这一概念。

解释:

1: R 运行状态( running ) : 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
2: S 睡眠状态( sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep ))。
3: D 磁盘休眠状态( Disk sleep )有时候也叫不可中断睡眠状态( uninterruptible sleep ),在这个状态的 进程通常会等待IO 的结束。
4: T 停止状态( stopped ): 可以通过发送 SIGSTOP 信号给进程来停止( T )进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
5: X 死亡状态( dead ):这个状态只是一个返回状态,你不会在任务列表里看到这个状态
6: 僵死状态( Zombies )是一个比较特殊的状态。当进程退出并且父进程 没有读取到子进程退出的返回代码时就会产生僵死(尸 ) 进程

而Linux中呢,进程状态是一个结构体,因为**"先组织 再描述"** ,可以更好的管理每个进程的状态!如下:

cpp 复制代码
//进程状态结构体
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};

在对每一个进程进行Linux下的举例前,需要补充一些概念:

每个设备都有自己的队列,不止CPU有自己的运行队列,键盘有自己的运行队列,声卡等都有自己的运行队列!但我们常说的进程排队,指PCB(task_struct) 在CPU的队列上排队

R:运行状态

写一个一秒打印一次的死循环代码,编译形成可执行程序,让其运行起来,这应该在进程信息的状态列中是R吧?

Q:结果发现是S+,先不管+是什么,S是睡眠状态,但我程序不是在死循环打印运行吗?

**A:**因为程序中有sleep,所以该程序大部分的时间都是sleep中,极少的时间在执行打印语句!所以其进程对应的S;但是如果注释掉sleep,会发现还是S,因为打印是会影响到外设的,cpu早已运行完语句,但硬件的速度远低于cpu,所以仍然处于等待状态!只是比注释前等待的时间少了一些罢了!所以我们干脆打印都不要了,一个空的死循环,就能够R+!

一个空的死循环对应进程的状态为R!之前的sleep或者循环打印,不一定都是S,如果运气好,极低概率会看见其刚好处于R的那一瞬间!

Q:+号的含义是什么?
A:前台进程!没有+则为后台进程!前台进程可以通过ctrl+c来停止,且我们在死循环期间执行指令没有反馈;而后台进程,我们无法通过ctrl+c停止,但是可以通过指令kill -9 + pid 来杀掉进程!目前仅了解即可!(./可执行程序名字(空格)&指令:可以将前台进程切换为后台进程,+会消失)

Ctrl+c组合键,可以让进程停止

Q1:为什么第一个程序代码有sleep和打印的时候,grep对应的程序也为S?

**A1:**grep的运行非常快,几乎是瞬间完成的你用ps查看时 它已经完成了大部分工作正在收尾阶段或者等候终止处理所以是S的!

Q2:为什么第二个程序代码空的死循环的时候,grep对应的进程的状态却为R?且不管我查多少次grep对应的进程的状态都为R!?

A2:空死循环程序(my.exe)100% 在运行队列中正在运行,导致 grep 进程无法及时运行,但是仍在CPU的运行队列中等待,所以会一直显示为 R 状态(可运行但未实际运行)。

提问并回答这两个问题就是为了证明②中运行进程的一句话:

"进程状态为R:并不意味着进程一定在运行中,也有可能在运行队列中排队"

S:睡眠状态

等待外设资源!也就是处于阻塞状态,所以睡眠状态就等同于阻塞状态!

比如之前R:运行状态 中的例子,"打印是会影响到外设的,CPU早已运行完语句,但硬件的速度远低于cpu"所以进程会是S状态!

差别:

OS中的阻塞:更广泛

进程中的睡眠:阻塞状态的其中一种

睡眠状态也叫作浅度睡眠,因为其可以被Ctrl+c 打断进程!

D:深度睡眠状态

D也是阻塞状态的一种,但是和S:睡眠状态 有区别!

例子:现在有一个进程(比如steam这种下游戏的应用),该进程要让磁盘写入一个10g大小的游戏,而我们下载过游戏的时候都知道,比如steam在下载一段时间后,会弹窗告诉我们是否下载成功还是失败,所以也就是意味着,该进程应该一直在内存中等待磁盘下载后的消息,才知道是否下载成功,但是呢此时很不巧,内存吃紧了,操作系统只能杀掉了这个进程,而过一会磁盘下载完了,但是呢磁盘满了,没下载完游戏,此时他想告诉进程下载失败了,但是进程却没了,磁盘又收到了其它需要占用磁盘空间或者内存发来的唤出请求,磁盘只能把下载的东西丢弃了,用磁盘空间去做其它的事情,此时用户看见进程挂了,也不知道怎么回事,打开一看,东西没下载好,进程还挂了!没告诉用户下载成功与否, 且磁盘还忙其他去了?!懵逼了,所以呢,此时OS就有了D状态,对于这种和磁盘进行交涉的进程,给予其免死金牌D,此时OS再怎么也不会杀掉这个进程了!!

T:停止状态

我们之前用的是kill -9来杀掉进程,现在我们用kill -19用来暂停进程!


**解释:**kill -19 + pid 即可让进程的状态为T ; kill -18恢复之前的状态

但若是在你kill -19之前,进程是S+,现在你恢复运行,只会恢复到S,因为暂停指令(kill -19 + pid)会让进程从前台到后台,所以此时你ctrl+c无法杀死,只能kill -9才能杀掉进程

**Q:进程什么时候处于暂停状态?
A:**当一个进程读取一个设备,但是该设备不允许该进程读取,则OS会将其设置为T,杜绝了进程的危险操作!

t:调试停止

进程在调式中,运行r到一个断点地方停止,则此时状态为t

本质就是进程在断点处停止,等待用户的命令,所以此时也是在等待某种资源

**总结:**SDTt都是阻塞状态!都是在等资源,不过不仅仅是硬件资源!!

fork函数

介绍一下fork函数,因为其马上要用

函数fork,其功能就是创建一个子进程

**解释:**fork之前,只存在main函数这个进程(22343)和main函数的父进程(7856),而在fork之后,多了一个进程(22344),其就是main(22343)的子进程,而第二个printf语句能被执行两次,才会有红框中的效果

所以:父子进程在fork之后的代码会共享,而数据则各自开辟空间,私有一份

数据:全局变量 堆上的变量 栈上的变量等.....

Q1:为什么数据要各自持有一份

**A:**因为父子进程可能会对自己的数据进行不同的修改,为什么不影响父/子进程,所以各自持有!

Q2:父子进程能对自己的数据分别进行修改?父子进程看到的代码不是一样吗,修改一个,不应该两个进程都被修改吗?

**A:**因为fork函数的另一条性质,fork函数会返回两个值,给父进程返回子进程的PID,给子进程返回0,所以if分流后,能够让两个进程各做各的事!

**总结:**fork创建子进程,就是为了让子进程去做一些事情

性质1:父子进程代码共享,数据各自开辟空间,私有一份

性质2:fork函数有两个返回值,给父进程子进程返回PID,给子进程返回0

当然看到这里,我知道你虽然懂了,但你有很多疑问,比如:

Q1:为什么返回给不同的进程不同的值的??
Q2:如何返回给不同的进程不同的值的??难道是返回两次吗??
Q3:id怎么做到接收fork函数的返回值,然后在被判断的时候却有两个值?怎么做到即等于0 又大于0???

**A1:区分父子进程!**把子进程的pid给父进程,是因为方便父进程查找子进程,对其进行控制(一个父会有多个子,得到确切的子的PID,才方便控制);给子进程返回0,因为只需要考虑子进程成功创建与否!

**A2:**fork既然能实现创建子进程的功能,那在其内部实现的最后一句return xx的代码前,就已经创建了子进程!而现在既有本身的父进程,又有创建的子进程,此时两个进程面对一个return id;肯定是会返回两个值的!

父进程:将 fork() 的返回值设为子进程的 PID。

子进程:将 fork() 的返回值设为 0。

**A3:**后面在做解释

++A1解释是对的,A2解释是浅薄的为了便于理解, A3后面再说++

Z:僵尸状态

僵尸状态/僵死状态都可以

人意外死亡后,不会直接火化,而是法医采集有效信息后,才火化

所以进程也是一样,任何进程退出,其父进程都会去读取子进程的退出的返回代码!

而当子进程退出,但父进程没有读取到子进程退出的返回代码时,此时子进程就会变成僵死状态!子进程会以僵尸状态保持在进程表中,并且会一直在等待父进程来读取其的退出状态代码。

所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程就会进入Z状态

例子:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if (id == 0) {
        // 子进程代码
        int cnt = 5;
        while (cnt) {
            printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(0); // 子进程退出
    }
    else (id > 0) {
        // 父进程代码
        while (1) {
            printf("I am father, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    
    return 0;
}

**解释:**子进程被exit(0)强制退出了,但此时父进程却一直死循环,无法去读取子进程的退出代码,所以此时的子进程会进程Z状态!

解释:

注: 其中的defunct译为:死人,很符合Z状态

进程变成僵尸状态,代码和数据会被释放,但是PCB会被一直维持在内存!等待父进程来进行读取PCB中的进程的退出信息

总结:

为什么要有Z状态?创建进程是希望这个进程给用户完成工作的,而子进程必须得有结果数据来告诉父进程完成工作怎么样了,数据就放在PCB中的。所以进程已经退出,但是当前进程的状态需要自己维持住PCB,供上层读取,必须处于Z

僵尸进程危害:

而若父进程一直不读取子进程的pcb,就会造成内存资源的浪费!!因为数据结构PCB本身放在内存中的,所以就要占用内存,而父进程长时间不读取,就会造成内存泄漏!!

而系统提供了方法来避免这种内存泄漏的情况!wait函数就是其中一种

例子:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork(); // 创建子进程
    
    if (id == 0) {
        // 子进程代码
        int cnt = 5;
        while (cnt) {
            printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(0); // 子进程主动退出
    }
    else (id > 0) {
        // 父进程代码
        int cnt = 10;
        while (cnt) {
            printf("I am father, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        wait(NULL); // 阻塞等待子进程退出
        printf("father wait child done...\n");
        sleep(5);  // 父进程额外等待5秒(用于观察状态)
    }
    
    return 0;
}

解释:子进程 :循环5次后退出;父进程 :循环10次后,再通过 wait(NULL) 回收子进程,所以子进程会先成为Z状态,再被回收!

**解释:**不是父进程要靠wait才能回收子进程,而是演示wait能够回收子进程,在某些父进程无法回收的情况下,我们可以用wait进行显式的回收!

X:死亡状态

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

6:孤儿进程

子进程退出,而父进程没来得及读取子进程的退出信息,则子进程会陷入Z状态

但若是**父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。**若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号进程领养,此后当孤儿进程进入僵尸状态时就由1号进程进行处理回收。

例子:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
	printf("I am running...\n");
	pid_t id = fork();
	if(id == 0){ //child
		int count = 5;
		while(1){
			printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid(), count);
			sleep(1);
		}
	}
	else if(id > 0){ //father
		int count = 5;
		while(count){
			printf("I am father...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);
			sleep(1);
			count--;
		}
		printf("father quit...\n");
		exit(0);
	}
	else{ //fork error
	}
	return 0;
} 

**解释:**对于以上代码,fork函数创建的子进程会一直打印信息,而父进程在打印5次信息后会退出,此时该子进程就变成了孤儿进程 ,所以前5秒

代码运行结果:

**解释:**在父进程未退出时,子进程的PPID就是父进程的PID,而当父进程退出后,子进程的PPID就变成了1,即子进程被1号进程领养了。

通过指令来查看进程信息:

**解释:**子进程S+,变成孤儿进程状态会变成S,会前台变成了后台进程,所以ctrl c无法杀掉进程,需要kill -9指令来杀死!

7:优先级

①:进程的优先级有什么用?

进程优先级实际上就是进程获取CPU资源分配的先后顺序,优先级高的进程会优先被CPU调度!

②:进程为什么会有优先级?

CPU只有一个,所以一定会有进程之间的优先级!好比食堂打菜窗口少于学生人数,一定会排队!若有10个窗口,9个学生,则不会出现排队,更不会有优先级!(注:10个窗口的菜一样)

③:Linux如何实现进程优先级?

首先,进程优先级也是属于进程信息,所以我们用ps -la指令可以看到进程的信息

cpp 复制代码
ps -la
//用于显示当前用户的所有进程(包括其他终端的进程),并以长格式输出详细信息

**解释:**其中的PRI就是优先级 ,单词优先级priority的前三个字母,Linux下的优先级默认从80开始,但可以人为修改进程的优先级,范围为[60,99]共40个

Q1:为什么Linux为什么要让优先级被限制范围?

A: 如果不加限制,每个人都想让自己的进程的优先级最低!这样私人进程会一直占用硬件资源!导致系统会有一些正常进程会被影响;好比食堂排队,总有人插队,那不插队的人,就会被饿!

所以Linux让优先级被限制范围,这样就保证了优先级的调试是较为公平的调整!

**注:**正常进程一直无法享受到硬件资源,导致一直不被CPU调度,这种现象叫作进程饥饿

Q2:现在我明白了即使可以修改,优先级最少也只能改到60,但是为什么人为修改进程的范围是范围为[60,99]共40个?

**A:**后面会讲

演示如何修改进程的PRI:

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

Linux不允许直接修改进程的PRI,只能间接修改进程的NI值,然后PRI会自己根据NI值来进行更新,所以我们要想让进程的优先级从80 变到90 ,我们只能修改NI的值为10!

修改进程优先级分为三步:

**1:**输入top指令

**2:**在弹出的页面中输入r,再输入修改进程的PID,回车

**3:**输入你想要修改为的NI值

例子:

**解释:**我们输入的10就是NI的值,此时PRI更新为90,因为PRI = 旧的PRI + NI

博主不再做演示了,直接说吧!NI值的范围在[-20,19]才是有效的,即使你top指令中修改NI值为100,它只会当做19;你输入-100,它只会当做-20。

**细节:**NI值的范围为[-20,19] ,这不就是为什么PRI值的范围是[60,99],因为这是NI值能影响到的极限了!(80+19 = 99 ;80+(-20)= 60,所以为PRI[60-99]!)

注意: 若是想将NI值调为负值,也就是将进程的优先级调高,需要使用sudo命令提升权限。

四:进程的调度和切换

1:补充概念

介绍调度和切换之前,补充几个概念

①:并发

并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发;并发不是同时进行,只是交替进行,但交替进行的极快,所以用户会感觉是多个进程一起运行!(非常高频的进程切换!)

注:好比老式的动画片,使用一张一张图片画出来的,但是当图片切换过快,就连续了!

②:寄存器

在CPU中存在寄存器,寄存器根据其功能可分为多种类型,各类寄存器存储的数据类型及用途各不相同。现对主要寄存器类型说明如下:

通用寄存器(eax/ebx/ecx/edx):

功能:存储临时数据

典型应用:如C语言函数调用时的参数传递及返回值存储
指令指针寄存器(eip,Program Counter):

功能:保存下一条待执行指令的地址

进程调度作用:在进程上下文切换时,准确记录当前执行位置(如:某进程执行至第49行代码后切换,其eip将保存第50行地址)
程序状态字寄存器(PSW):

功能:存储处理器状态标志

典型标志:溢出位、零值位等运算结果状态
栈指针寄存器(esp/ebp):

功能:维护函数调用栈帧

分工:

esp:始终指向栈顶

ebp:标记当前栈帧基址

**总结:**一个进程在被CPU调度的时候,CPU的寄存器会围绕这个进程进行工作!

++听不懂,没事,只需知道,寄存器对于进程很重要,需要保存进程的数据和信息即可++

③:进程为什么会被切换?

因为进程在CPU运行的时候,并非要把进程代码跑完才行!操作系统都是基于时间片进行轮转执行的进程的!超过时间,则你会被切换,另一个进程将会被调度!

**注:时间片:**给每一个进程规定一个在CPU运行的最大时间

2:切换

**硬件上下文:**一个进程在被切换前,寄存器已经保存了进程的数据,这些数据就称作该进程的硬件上下文

所以当一个进程被切换的时候,这些数据会被保存一个空间中(暂不深究),这个行为叫保护上下文!当进程进行完了保护上下文的工作呢,就意味着该进程已经被CPU切换了,进程就可以去其他的队列啊 等等.......此时下一个进程就可以被调度运行了,寄存器会去保存新进程产生的数据!但若某一个进程不是第一次被CPU调度运行,则进程会将自身的硬件上下文数据给寄存器即可,此时可以接着该进程上次运行的地方接着运行!比如一个程序代码中scanf要接受键盘的值,你迟迟不输入,此时进程已经被切换到其他队列, 而当你键盘输入后,该进程才会被CPU再次调度运行!

总结:

保护上下文的工作是为了最终的恢复

恢复上下文是为了继续上次的运行位置继续运行

Q:寄存器只有一套,为什么可以保存多个进程的上下文,不会冲突吗?

**A:**当一个进程被切换的时候,该进程会带走寄存器中所存储的数据,此时寄存器再去存储新进程的数据,何来冲突

如:图书馆的座位,是公共设施,但是每一刻,座位只会属于一个人!

注:进程被切换带走的是寄存器中的数据,而不是寄存器!

3:调度

一个进程被CPU执行就叫作进程被调度了

OS为了实现一个优秀的调度算法,需要考虑优先级,考虑效率,考虑很多东西.....

所以OS提供了两个队列,一个叫活跃队列,一个叫过期队列

活跃队列:

存放当前**仍有时间片(time slice)**的进程。调度器优先从该队列选择进程执行。
过期队列:

存放已耗尽时间片的进程。当活跃队列为空时,调度器将交换两个队列的角色(原过期队列变为新的活跃队列)。

类比:现在有两个办理业务的窗口,会先办理完A窗口的人,然后此时再去办理B窗口的人

先调度运行完了活跃队列的进程,此时过期队列成为新的活跃队列,旧的活跃队列变成了过期对了,此时再去调度新的活跃队列的进程

设计目的:确保所有进程公平共享CPU时间,避免饥饿(低优先级进程长期得不到执行)

因为如果只有一个队列,你低优先级的进程,一直被插队,你会一直饥饿,而这个设计,会让你再低优先级的进程,都有被执行的那一刻,而插队的高优先级的进程,也会在另一个队列中名列前茅,当队列切换的时候,高优先级进程也会被立马执行!

活跃队列和过期队列:

**注:**省略了用不到的部分

**解释:**所以CPU的运行队列(runqueue)远不止一个队列这么简单,其中有一堆指针不说,还有两个结构体,一个叫活跃队列,一个叫过期队列,两个结构体中各有一个140大小的数组!

queue下标说明:

  • 普通优先级:100~139。
  • 实时优先级:0~99。

也就是说queue这个数组中的0~99是系统的进程所占用的,我们不关心,我们自己的进程只是普通进程,只会占用100~139的位置,这就是为什么前面讲优先级的时候,我们优先级的范围为[60,99]共40个!因为正好对应了这里数组中的40个位置!比如当前进程的PRI为60,则代表其在这40个位置中的第一个位置!

但其实呢,queue[140]这个数组中,每个元素指向了一个队列:

解释: 所以这也解释了,为什么我们进行ps -la指令的时候,会发现**一大堆的进程的PRI都是80,**如果 queue[140]这个数组中,每个位置只能存放一个进程,那不矛盾了吗,所以上图结构才能证明为什么会有一堆的优先级相同的进程,因为全部都是下标为[120]的元素指向的对列中!

Q1:之前说过,当活跃队列的进程全部执行完毕,则会更换队列,然后执行新的活跃队列,那请问如何判断活跃队列进程全部执行完毕?40个队列也不少啊,每次CPU都要去遍历数组queue数组中元素指向的队列,才能判断是否还有队列存在吗?

A: bitmap[5]会帮我们更快识别!!

bitmap[5]类似位图,5个元素都是int类型,所以共有5*32比特位=160个比特位 ,它用位来表示队列是否有进程的情况:

位的位置对应的就是队列的位置

位的值用来判断队列是否为空

比如:bitmap[0] == 0 则代表前32个队列 都无进程!!则不用遍历前32个队列了,如不为0,也只需在这32个队列中去找,所以无论如何,其都会提高查找的效率!

之前说过,过期队列和活跃队列之前会进行切换,这就归功于active指针和expired指针

  • active指针永远指向活动队列。
  • expired指针永远指向过期队列。
  • 所以队列的切换,仅仅只是指针的指向的改变!

总结:

调度过程如下:

1:从0下标开始遍历queue[140]。

2:找到第一个非空队列,该队列必定为优先级最高的队列。

3:拿到选中队列的第一个进程,开始运行,调度完成。

4:接着拿到选中队列的第二个进程进行调度,直到选中进程队列当中的所有进程都被调度。

5:继续向后遍历queue[140],寻找下一个非空队列。

由于活动队列上时间片未到期的进程会越来越少,而过期队列上的进程数量会越来越多(新创建的进程都会被放到过期队列上),那么总会出现活动队列上的全部进程的时间片都到期的情况,这时将active指针和expired指针的内容交换,就相当于让过期队列变成活动队列,活动队列变成过期队列,就相当于又具有了一批新的活动进程,如此循环进行即可。

五:环境变量

1:main的参数

在介绍环境变量之前,需要先介绍一下main的参数,是的,main函数也是有参数的,我们正常用main只需main()即可,是因为我们用不到它的参数

cpp 复制代码
//main函数的三个参数
int main(int argc, char *argv[], char *envp[])

而为什么现在说main的参数,是因为第三个参数和环境变量有关!

①:int argc和char *argv[ ]

argv[ ]是一个指针数组,而argc是该数组的元素个数,而当我们直接进行./my.exe的时候,其实就已经开始使用main的参数了,argc此时为1,argv[ ]此时的第一个元素为"./my.exe"字符串的地址,argv[ ]的大小为2,因为结尾还内置了一个结束标记 NULL

注:argv[ ]是一个指针数组,里面存放的都是字符串指针

使用int argc和char *argv[ ]的例子:

cpp 复制代码
#include <stdio.h>
#include <string.h>

// 要实现三种不同的功能
// ./myprocess -3
int main(int argc, char *argv[])
{

    //教用户如何输入
    //应该输入./my.exe + 字符串  所以判断argc需要为2才对
    if(argc != 2)
    {
        printf("Usage:\n\t%s -number[1-3]\n", argv[0]);
        return 1;
    }
    
    //如果用户输入的第二个字符串 也就是argv[1] 等于 "-1" 则执行功能1
    if(strcmp("-1", argv[1]) == 0)
    {
        printf("function 1\n");
    }

     //如果用户输入的第二个字符串 也就是argv[1] 等于 "-2" 则执行功能2
    else if(strcmp("-2", argv[1]) == 0)
    {
        printf("function 2\n");
    }

     //如果用户输入的第二个字符串 也就是argv[1] 等于 "-3" 则执行功能3
    else if(strcmp("-3", argv[1]) == 0)
    {
        printf("function 3\n");
    }

    else
    {
        printf("unknown!\n");
    }

    return 0;
}

运行结果:

解释: 程序要求输入./my.exe + 一个字符,所以当我们直接./my.exe会打印教程信息,反之 -1则执行功能1,-2则执行功能2,-3则执行功能3!这不就是我们的指令选项的本质吗?

我们可以ls -l或ls -1或ls -a,本质就是因为ls对应的程序的参数类似于main的参数,可以接收多个参数,ls在内部对不同的参数进行了不同的功能书写!

只不过我们没有对不同的选项,实现具体的功能罢了!

②:char *env[ ]

而env[ ]数组中存储的全是环境变量,在下一点中先讲完环境变量再讲char *env[ ]

2:获取环境变量

环境变量(environment variables)是指在操作系统创建的变量,其保存了一些重要数据!

我们为什么输入pwd,OS就能返回当前路径;为什么输入whoami,OS能正确返回用户名;这就是因为有OS创建了众多的环境变量,来保存了相关的信息!

**Q:语言中定义一个变量 我能理解 为什么系统能定义自己的变量?
A:**定义一个变量的本质就是开辟一块空间,给这个空间取个名字,才有了变量这个概念;而开辟空间,不一定是你在语言中定义变量时候!甚至是你在运行程序的时候去堆上栈上开辟空间,而操作系统Linux不也是一个程序,那Linux这个程序肯定可以在运行中开辟空间啊!

env指令即可查看所有的环境变量:

**解释:**环境变量有很多,全是kv类型的键值对,并且在系统当中通常具有全局特性,且子进程会继承父进程的环境变量,其中很多的那一个环境变量是配色方案,所以很多

main函数的char *env[ ]数组中存储的全是环境变量的地址,因为进程可以继承父进程的环境变量

**注:**char *env[ ]和 char *argv[ ]一样,都是指针数组

例子:

cpp 复制代码
#include <stdio.h>


int main(int argc, char *argv[], char *env[])
{
    
    int i = 0;
    // 打印所有环境变量
    for(i = 0; env[i]; i++)
    {
        printf("---env[%d] -> %s\n", i, env[i]);
    }

   
    
    return 0;
}

**解释:**env数组里面值全是字符串指针,类似"PWD=/home/wtt1" 的指针这种,指向的全是继承而来的环境变量

**Q:如果一个程序的接口没有接收环境变量的参数,那如何获取环境变量?
A:**在程序内部使用getenv()函数即可,传参环境变量名字,其返回环境变量的值

但是使用getenv()函数需像下图这样:

解释:

  • getenv() 返回的是一个 const char* 指针,指向环境变量值的字符串

  • 如果环境变量不存在,它会返回 NULL 指针

  • 这就是为什么需要先检查返回值是否为 NULL(如第8行所示)

3:修改环境变量

①:浅度修改

环境变量也是可以人为修改的,但修改分为两种,一种是浅度修改,一种是深度修改,我们以修改PATH这个环境变量为例。

cpp 复制代码
PATH: 指令的搜索路径。

解释: 其值为一串用冒号 : 分隔的目录路径 ,当我们输入任何指令的时候,PATH都会在这些路径下找一遍,所以为什么我们可以直接pwd ls ,而不可以my.exe,因为pwd ls这些程序已经被放进了PATH中的路径中,而my.exe没有,我们只能./my.exe,其中./就是告诉了OS路径,所以它才能执行,./不就是相当于pwd,OS进行替换即可!

所以现在我们可以选择将my.exe放进PATH的其中一条路径中,或者直接修改PATH环境变量的值,为其增加一条路径(/home/wtt1),博主演示后面这种:

介绍两个指令:

cpp 复制代码
echo $+环境变量名
//用于查看环境变量的值

export 环境变量+值
//export AGE=21

解释:$PATH就能直接得到PATH的值,而echo只是起到让其打印到屏幕上的作用

二者结合即可修改PATH环境变量的值:

cpp 复制代码
 export PATH=$PATH:/home/wtt1
  • $PATH :表示当前系统的 PATH 环境变量(原有的路径列表)。

  • :/home/cl/dirforproc/ENV :用冒号 : 分隔,追加新路径到 PATH 末尾。

  • export :将修改后的 PATH 设为环境变量,对当前终端及其子进程生效。

解释: 相当于先读取原有 $PATH 的值,在其后追加 :/new/path(冒号 : 是路径分隔符),将结果赋给新的 PATH

结果:

实现了自己的程序可以直接执行了

++但是重启环境后,PATH会恢复之前的样子,我们还是得./my.exe,所以叫作浅修改++

**注:**所以学校安装一些比如java,python的相关软件的时候,老师会教我们去配置PATH,本质就是为了我们的程序能够被找到!

②:深度修改

在说深度修改之前,得介绍一下OS最开始是如何获取到环境变量的?大家都继承了来自父进程的环境变量,那请问最开始的那一个进程如何获取环境变量的?在Linux中,最开始的进程名为bash进行,其的PID为1!而当bash进程每次启动的时候,会读取家目录下的一个文件 .bash_profile,这个文件中就存有环境变量!所以深度修改,本质就是直接修改了.bash_profile文件中的内容

这也是为什么一开始用户登录就是处于家目录下的,因为家目录下有.bash_profile,所以bash启动的时候,可以直接进行读取!

查看.bash_profile文件:

在通过vim打开该文件:vim .bash_profile ,则可以进行自己环境变量的自定义

**解释:**格式跟着文件里面的PATH照着写

博主是自增了一个无关紧要的变量,也可以修改PATH,这样即使环境重启后,仍会起效

但不建议自己进行深度修改,不安全!浅度修改满足几次使用就够了!

Q:为什么.bash_profile里面只有几个环境变量?

**A:**因为.bash_profile只是第一层,往后再走几个文件,才能读取到全部的环境变量,这是一种保护机制:

Q:自己深度修改了环境变量,不会影响到其他的用户吗?

**A:**每一个用户都有自己的一套环境变量,也就是都有自己对应的.bash_profile,不会影响

4:本地变量

本地变量 :定义的时候不需要export

cpp 复制代码
//定义本地变量
$myvalue=aaa

//读取本地变量
echo $变量名

//移除本地变量
unset myvalue=aaa

也可以通过getenv函数获取本地变量的值:

用法和获取环境变量类似!

注意:

本地变量不能用env指令来查看本地变量, 因为其不是环境变量
本地变量可以用set指令来看 ,set能看很多变量,包括不限于环境变量 本地变量......
本地变量只会在bash内部有效 不能被继承

六:进程地址空间

在学习C或C++的时候,大家都见过这张图片,老师会说这就是不同类型的数据在内存中的布局!

注:先不管这张图为什么它叫进程地址空间,后面会讲

1:验证进程地址空间

Linux同样遵循,下面验证一下:

①:验证各种类型数据的分布

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_unval;
int g_val = 100;

int main()
{
    // 打印代码段地址(函数指针)
    printf("code addr: %p\n", main);
    
    // 打印已初始化全局变量地址(数据段)
    printf("init data addr: %p\n", &g_val);
    
    // 打印未初始化全局变量地址(BSS段)
    printf("uninit data addr: %p\n", &g_unval);
    
    // 动态分配堆内存并打印地址
    char *heap = (char*)malloc(20);
    printf("heap addr: %p\n", heap);
    
    // 打印栈变量地址
    printf("stack addr: %p\n", &heap);
    
    return 0;
}

结果:

**解释:**的确符合图,由低到高!堆区和栈区地址差距极大,正是因为共享区占用了,验证成功!

②:验证堆区/栈区地址增长规律

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

int main() {
    // 连续分配4块堆内存
    char *heap = (char*)malloc(20);
    char *heap1 = (char*)malloc(20);
    char *heap2 = (char*)malloc(20);
    char *heap3 = (char*)malloc(20);
    
    // 打印堆内存地址(观察内存增长方向)
    printf("heap addr: %p\n", heap);
    printf("heap1 addr: %p\n", heap1);
    printf("heap2 addr: %p\n", heap2);
    printf("heap3 addr: %p\n", heap3);
    
    // 打印栈变量地址(观察内存增长方向)
    printf("stack addr: %p\n", &heap);
    printf("stack addr: %p\n", &heap1);
    printf("stack addr: %p\n", &heap2);
    printf("stack addr: %p\n", &heap3);
    
    // 释放内存(实际运行时建议添加)
    free(heap);
    free(heap1);
    free(heap2);
    free(heap3);
    
    return 0;
}

结果:

**解释:**符合图中的规律。验证成功!

③:验证命令行参数/环境变量地址

cpp 复制代码
#include <stdio.h>

int main(int argc, char *argv[], char *env[])
{
    
    int i = 0;
    // 打印命令行参数及地址
    for(i = 0; argv[i]; i++)
    {
        printf("argv[%d]=%p\n", i, argv[i]);
    }

    int j = 0;
    // 打印环境变量及地址(修复了原代码中的格式字符串错误)
    for(j = 0; env[j]; j++)
    {
        printf("env[%d]=%p\n", j, env[j]);  // 原代码中错误的"%mv"修正为"env"
    }

    return 0;
}

结果:

解释: 无论是命令行参数(argv)的地址还是所有环境变量(env)的地址,都是在栈之上!**验证成功!**之前的栈的地址量级在0x7ffef8d19098,而此图中随便一种都远大于栈地址

不妨再额外验证一下char *argv[], 和 char *envp[] 两个数组的地址,因为前面说过:

  • argvenvp 本身指针数组(每个元素是 char* 类型)

  • 这些数组在内存中的存储分为两部分:

    • 数组本体:连续存储的指针(每个指针占4/8字节)

    • 指针指向的字符串:分散存储在内存其他位置

总结:不论是两个数组,还是数组内的元素(指针)指向的内容,都在栈区的上面!

注: static修饰的变量,就是全局变量,但其和在main外的全局变量相比,static修饰的变量,会被后定义(因为进入main才开始定义),所以其所处的地址范围一定是图中的已初始化全局变量到未初始化全局变量之间,但 static 和普通全局变量的地址没有固定的大小关系,取决于编译器和链接器的实现
而且static修饰的变量不初始化,会被自动置 0,可以看做其必定是一个初始化全局变量

**注:**c是一个static修饰的变量

2:虚拟地址

现在我们终于进入解决fork函数板块的第三个问题了

先看fork函数的一个现象吧:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_val = 100;

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        // 子进程
        while(1)
        {
            printf("child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", 
                  getpid(), getppid(), g_val, &g_val);
            sleep(2);
        }
    }
    else
    {
        // 父进程
        while(1)
        {
            printf("father, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", 
                  getpid(), getppid(), g_val, &g_val);
            sleep(2);
        }
    }
    return 0;
}

**解释:**代码演示了 fork() 创建子进程后,父子进程一开始共享全局变量 g_val 的初始值,父子进程会分别打印自己的 PID、父进程 PPID、全局变量值和地址 ,这与我们的预期一致!

但当我们在子进程进入if的时候,给g_val赋值200,就会出现奇怪的现象:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_val = 100;

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        g_val = 200;//新增代码
        // 子进程
        while(1)
        {
            printf("child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", 
                  getpid(), getppid(), g_val, &g_val);
            sleep(2);
        }
    }
    else
    {
        // 父进程
        while(1)
        {
            printf("father, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", 
                  getpid(), getppid(), g_val, &g_val);
            sleep(2);
        }
    }
    return 0;
}

结果:

Q:同样的地址,却呈现出了不同的值?????!!!!

**A:**因为这是虚拟地址,不是变量存储在内存中的真实地址!所以老师说"进程地址空间那张图是在内存中的分布",这句话也是错的,只是想细节解释,也解释不清楚,所以为了方便理解,就说是在内存中的位置了!

我们所用到的语言级别的地址(C/C++语言等),全部都是虚拟地址!而物理地址,用户一概看不到,由OS统一管理!

下面统一称为虚拟地址和物理地址(内存中真实的地址)

所以我们需要重新看待进程地址空间这张图!

Q:什么是进程地址空间?

**A:**进程地址空间是指一个进程所能访问的所有虚拟地址范围的集合,而它也是需要被管理的,所以根据"先描述,再组织"的思想,它由内核通过一个数据结构名为mm_struct结构体所管理的!该结构体包括但不限于存储了这些地址的布局等相关属性!

**由图:**mm_struct结构体中划定了类似图中的多个范围,不同类型处于不同的范围!

所以一个进程中变量的地址,我们只能看到虚拟地址,而这个虚拟地址,会被映射到物理地址,所以现在又有两个疑问了?

Q:映射是通过什么映射的?

**A:**需要一个东西将虚拟地址转化到物理地址,这个东西就是页表,通过页表进行映射,页表类似于哈希表,具有映射关系

Q:即使用映射,父进程和子进程相同的虚拟地址,为什么会映射到不同的内存的物理地址?

**A:**因为每个进程都有自己的PCB,都有自己的虚拟地址,都有自己的页表,所以同样的虚拟地址,在不同的进程中,通过页表映射后,会指向不同的物理地址!

总图如下:

Linux源码下的mm_struct:

至此,我们知道了fork函数的第三个问题是为什么 ,因为虽然父子进程的变量的虚拟地址一样,但是通过各自页面对应的物理地址不同,所以呈现出地址一样,变量指却不一样的现象!

回答几个问题:

Q1:每个进程怎么知道自己变量的虚拟地址,也就是怎么找到mm_struct?

Q2:页表只是具有映射关系,但是根据映射找到物理地址的工作,谁来做?

Q3:CR3保存的是页表的虚拟地址还是物理地址?

Q4:为什么要有进程地址空间,也就是为什么不让我直接获取物理地址?
**A1:**很简单,进程的PCB(task_struct) 内部有一个指针指向对应的mm_struct!

A2:MMU (Memory Management Unit,内存管理单元)是计算机硬件中的一个关键组件,主要负责处理CPU发出的内存访问请求,实现虚拟地址到物理地址的转换,它是现代操作系统(如Linux、Windows)实现虚拟内存管理的硬件基础!CPU中的CR3寄存器会存储页表的地址,MMU会根据CR3寄存器去到页表中对应变量的虚拟地址,然后根据页表的映射关系,访问到其对应的物理内存!

**A3:**如果是虚拟地址,MMU就应该根据这个虚拟地址去找到页表在内存中的地址,但已经是虚拟地址了,MMU此时肯定无法获取页表,谈何实现从虚拟地址到物理地址的转换?所以CR3存放的是一定是页表的物理地址!

A4:

①: 将物理内存从无序变有序,让进程以统一的视角去看待内存

你代码的全局变量/栈区变量/堆区变量有可能是在内存中随意存放的,但又如何,其对应的虚拟地址一定是被归纳在对应一个区间中的

②: 地址空间+页表是保护内存安全的重要手段!

就好比压岁钱永远被你妈存起来,而不是你自己拥有,如果你想买什么东西,你妈妈觉得有用才给你!所以当进程想访问一个内存的空间,请求非法则被拒绝或拦截!甚至有时候会直接把你进程干掉,比如野指针的访问!但是不会影响其他的进程和OS,因为拦截你的是你自己的地址空间和页表,只会影响你这一个进程!

3:进程地址空间解释一些问题

所以我们之前在C/C++中申请堆区或者栈区的内存,本质是在申请虚拟地址!先给你一个虚拟地址,甚至页表连映射关系都没建立,等到你真正使用的时候,也就是通过虚拟地址进行内容的写入的时候,才会给你开辟内存,建立映射等操作!

好处: 充分保证了内存的使用率!甚至提升了new和malloc的速度,只需先快速申请虚拟地址,而后序操作 视情况而定

相关推荐
小糖学代码7 小时前
LLM系列:1.python入门:3.布尔型对象
linux·开发语言·python
shizhan_cloud7 小时前
Shell 函数的知识与实践
linux·运维
Deng8723473487 小时前
代码语法检查工具
linux·服务器·windows
霍夫曼9 小时前
UTC时间与本地时间转换问题
java·linux·服务器·前端·javascript
月熊10 小时前
在root无法通过登录界面进去时,通过原本的普通用户qiujian如何把它修改为自己指定的用户名
linux·运维·服务器
大江东去浪淘尽千古风流人物11 小时前
【DSP】向量化操作的误差来源分析及其经典解决方案
linux·运维·人工智能·算法·vr·dsp开发·mr
打码人的日常分享11 小时前
智慧城市一网统管建设方案,新型城市整体建设方案(PPT)
大数据·运维·服务器·人工智能·信息可视化·智慧城市
赖small强11 小时前
【Linux驱动开发】NOR Flash 技术原理与 Linux 系统应用全解析
linux·驱动开发·nor flash·芯片内执行
风掣长空12 小时前
Google Test (gtest) 新手完全指南:从入门到精通
运维·服务器·网络
IT运维爱好者13 小时前
【Linux】LVM理论介绍、实战操作
linux·磁盘扩容·lvm