Linux进程的学习(持续更新)
文章目录
- 冯诺依曼体系结构
- 概念:
- IO
- QQ发消息的数据流向
- 为什么要这样设计呢?
- 小知识
- 操作系统(Operator System)
- 概念
- 操作系统的目的
- 操作系统如何管理?(表面理解)
- 对下层硬件的管理
- 对上层的服务
- 进程
- 概念
- task_ struct内容分类
- 进程的属性
- 查看进程的属性
- 通过系统调用获取当前进程ID
- 通过系统调用获取父进程ID
- 通过系统调用创建进程-fork初识
- 进程的状态
- 笼统的操作系统概念:
- 并发和并行
- 时间片
- 进程具有独立性
- 等待的本质
- Linux中进程的状态
- 进程的优先级
- 进程的切换
- 进程调度算法
- 补充知识
- 命令行参数
- 环境变量
- 认识环境变量
- PATH
- HOME
- SHELL和PWD
- 环境变量具有全局属性
- 环境变量 VS 本地变量
- 环境变量具有全局属性
- 进程地址空间
- 了解进程地址空间
- 未完待续。。。
冯诺依曼体系结构
概念:
冯・诺依曼体系结构是一种计算机体系结构,由美籍匈牙利科学家约翰・冯・诺依曼提出。它奠定了现代计算机的基本结构。
计算机分为以上五大部件组成:
- 输入设备:键盘,鼠标,网卡,测盘等等
- 输出设备:网卡,磁盘,打印机等等
- 存储器:存储数据和程序的部分,分为内存和外存。
- 运算器:进行算术运算和逻辑运算的部件。它能够对数据进行加、减、乘、除等基本算术运算,以及与、或、非等逻辑运算。
- 控制器:整个计算机的指挥中心。它负责从存储器中取出指令,并对指令进行译码,产生各种控制信号,控制计算机的各个部件协调工作。
中央处理器就是我们的CPU
,通过上图我们可以看到,我们CPU可以直接控制我们的输入输出设备与存储器,但是这只是控制方面上,在数据层面上,CPU
不会直接与硬件打交道,数据都会先输入到我们的内存当中,然后CPU直接从内存当中读取数据和代码进行执行,把运算的结果给内存,再从内存到输出设备,最后控制器给输出设备一个刷新的指令,然后结果就会在输出设备(显示器)显示。这是由体系结构所决定的。
★CPU在数据层面,不会和外设直接打交道,只会和内存进行交互。
IO
这里的IO也不陌生,就是imput
和output
。平常我们写的代码生成的可执行程序其实就是一个二进制文件,而文件是存放在磁盘当中的,当我们运行程序的时候,我们可执行程序会先被加载到内存★。然后再通过CPU执行最后再通过内存把结果给输出设备,这里也可以解释为什么我们C/C++会有缓冲区的概念,就是就是一小块内存片段。
QQ发消息的数据流向
如上图,当我们在北京给我们深圳的朋友发消息的时候,QQ本质就是一个软件,那么就是一个可执行程序,当程序运行起来的时候会先加载到内存当中,但我们输入消息的时候消息会直接到内存当中,然后进行CPU执行加密程序,再把结果给内存然后再刷新到我们此时的输出设备上(网卡),通过网络传播到朋友的QQ上,朋友的电脑第一个接受收据的硬件一定是网卡。然后再把数据写入内存当中,执行解密程序,再刷新到输出设备(显示器),所以我们才能看见消息内容。
为什么要这样设计呢?
为什么不像上述一样,输入和输出设备直接和CPU进行交互呢?反而需要一个内存呢?
CPU造价贵,速度快。输入和输出设备造价相对便宜,但是速度慢。所以这样的话,我们CPU运算的效率就会降低,这里简单举例例子:我们CPU读写的速度是纳米级别,而输入和输出设备的读写效率会毫米级别,差了100万倍啊(打比方),所以我们CPU执行的效率取决于输入和输出设备读写的效率。所以我们加了一个内存在输入和输出设备中间,我们每次都把数据加载到内存,然后CPU直接从内存当中读数据即可,内存的速度比输入和输出要快,比CPU要慢,但是价格对比CPU缺失要便宜不少。这样我们就可以把一台计算机的价格降下来,同时效率也能保持一个较好的运行速度,主打的就是一个性价比,试想一下,如果每一台计算机都那么贵的话,哪有怎么可能会有互联网呢?
小知识
我们输入输出设备的数据和代码到内存当中,其实就是一种拷贝,所以当我们想要获取某个硬件的数据时,就是一种拷贝。这也是为什么固态硬盘的电脑要比机械硬盘的电脑快的原因,固态硬盘的拷贝速度更快。
操作系统(Operator System)
概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
内核(进程管理,内存管理,文件管理,驱动管理)
其他程序(例如函数库, shell程序等等)
而操作系统本质上也是一种软件,不过是一种软硬件资源管理的软件。
操作系统的目的
所以本质上操作系统就是为了给用户更好的体验。方便用户进行各种操作。
操作系统如何管理?(表面理解)
对下层硬件的管理
每一个硬件都需要一个驱动程序来管理,那么操作系统究竟是如何管理硬件的呢?这里我们把操作系统称为管理者,而硬件称为被管理者。这里举个例子来方便理解:
在学校当中,作为学生我们是被管理者,校长则是管理者,平常当中我们一般不会跟校长见面,但是我们什么时候考试,放假,能不能毕业都是被校长管理者的,那么我们都没有跟校长见面,是如何被管理的呢?
那么校长是通过辅导员从而知道了我的信息,假设每一个学生的信息都有50字,那么学校上千上万的学生校长如何管理呢?
操作系统大部分都是有C语言写的,我们把每一个学生的信息写入一个struct结构体中,然后把这些struct结构体像链表一样一个接着一个链接起来,组成一个单链表,那么以后校长管理学生的时候只需要对链表进行增删查改即可。
那么操作系统管理硬件也是一样的,我们创建一个结构体,存储每一个硬件的属性,然后把这些结构体像链表一样连接起来,那么以后我们管理硬件就可以像对链表进行增删查改就可以了,上述这个过程我们称为:先描述再组织。
所以这也是为什么现在面向对象语言都要提供两个功能:1. 面向对象的能力 2. 标准库,比如C++,就是为了方便进行管理。任何计算机对象都遵守先描述再组织这个原则。
这也是为什么我们需要学习数据结构的原因,同时为什么现在主流语言都是面向对象。
对上层的服务
对于银行来说本身也是一个操作系统,行长作为管理者,设备工作者等等作为被管理者,那么如果我们需要去存钱或者取钱,那么银行会让我们去到他们的仓库里面去存钱取钱吗?那肯定是不行的。所以操作系统也是一样,**为了保护硬件的安全,操作系统不会让我们去直接去接触底层的硬件,那么不接触如何使用呢?所以操作系统就会提供各种各样的系统调用接口。**对比银行来说就是开设人工窗口,来对外进行服务。而如果有些老人家不识字,那么窗口也不直到如何使用呢?所以就有大堂经理这个角色,就是一步一步带着去完成取钱存钱的服务。
程序方面:而对于一般程序员来讲,系统调用使用起来会比较麻烦,所以就些人通过这些系统调用的接口,开发出来了shell外壳,标准库,图形化界面来方便程序员进行软件开放等等。
进程
概念
课本描述:程序的一个执行示例,运行中的程序。
内核观点:担当分配系统资源的实体。
我们写.c文件经过编译链接之后生成的可执行程序为二进制文件,都是存储在磁盘当中,当我们运行程序的时候会先加载到内存当中(这个步骤本质就是拷贝数据到内存当中)。但是进程不单单只是拷贝了源数据而已,而是形成了一份结构体信息。
这里我们每一个程序都有一份结构体信息,它存储这程序的运行ID,运行状态,优先级,内存地址等等,当我们要运行某个进程的时候,通过内存地址的指针找到代码所存储的空间位置,然后再去执行,我们把每一个struct信息像链表一样连接起来,那么日后管理进程的时候,只需要像对链表一样进行增删查改即可。这就是上文讲的先描述,后组织
所以我们进程 = 程序的代码和数据 + 内核数据结构(★)。
同是我们进程还是动态的,什么意思呢?就是进程会有一个优先级,当我们需要执行哪个程序的时候,操作系统就会优先执行哪个进程。而且每一个进程都会有一个执行的时间,每过10ms就会调度一个。所以进程是具有动态特征的。例如我们C语言的scanf,如果我们一直不输入,那么操作系统就会把这个进程从CPU拿下了,放入一个进程等待区中等着,只有我们继续输入的时候才会继续执行。
所以运行中的程序 --> 进程会根据task_struct的属性被 OS调度,运行。
task_ struct内容分类
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合 ,课本上称之为PCB(process control block),而在Linux中被称为task_struct。那么这个结构体里面有什么呢?
task_struct内如下:
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。 ✅
- 状态: 任务状态,退出代码,退出信号等。✅
- 优先级: 相对于其他进程的优先级。✅
- 程序计数器: 程序中即将被执行的下一条指令的地址。 ✅ pc指针
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。✅
- I/ O状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
进程的属性
上述讲到我们可执行程序运行的时候会先加载到内存,这个过程本质是拷贝一份数据到内存当中,但是不单单是拷贝数据,还是有一份程序的结构体属性,代码数据 + 结构体属性为一个进程。其实我们每一个可执行程序运行起来都是一个个进程。
而进程又分为两种类型:1. 瞬时进程,执行完就退出,例如ls,pwd。。2.一直不退,直到用户退出。
那么有没有一种方式可以查看结构体属性呢?
查看进程的属性
我们先写一个程序:
在Linux中我们可以使用 ps + ajx
他会显示我们机器上所有的进程信息,上述我们先显示head -1
显示头一行标题,然后再显示了myproc
这个可执行程序的属性,不过这里的属性并不全面。
这里我们先来看第一个属性:标识符
他是用来区分进程的,就是我们上述的PID
,他表示这个进程的ID值,就跟学生的学号一样。
PPID
表示的是当前这个myproc进程的父进程的ID值。那么一个进程不可能只有这么些属性,那么如何查看详细的属性呢?
在我们的根目录当中有一个proc
文件夹,它里面会存储这我们所有进程的属性,那么只要我们程序运行起来,就会有一个属性文件夹在/proc
这个目录下生成。
如上图,蓝色的文件夹都是我们的进程属性文件夹。而这个文件夹的命名方式就是进程的ID值,上述我们生成的进程ID值为6429,那么我们就可以去查看/proc/6429这个文件夹里面的内容。
如上图,这都是我们进程的属性,那么这里我们主要观察两个:1. cwd 2. exe
cwd
:current work dir - 当前工作目录,在C/C++语言中我们使用
f
o
p
e
n
(
"
f
i
l
e
n
a
m
e
"
,
"
w
"
)
;
fopen("filename","w");
fopen("filename","w");的时候如果只写了文件名,那么这个文件会默认创建在当前目录下,就是因为进程它存储了当前工作目录,当我们使用fopen创建文件的时候如果只写了文件名,那么就会默认在当前工作目录创建。当然我们也可以更改工作目录位置:
如上图,使用chdir
函数可以更改工作目录。
exe
:就是当前可执行程序所在位置。
通过系统调用获取当前进程ID
上文提到我们每个进程都一个ID值,那么如何获取这个进程ID呢?
运行结果如下:
getid()
获取的是当前进程的ID值。同时子进程是由父进程创建的。在操作系统启动的会创建一个-bash进程,
通过系统调用获取父进程ID
父进程跟子进程的比例关系为:1 : n。就是一个父进程会有多个子进程,但是一个子进程只有一个父进程。并且每一个子进程都是由父进程创建的。
通过系统调用创建进程-fork初识
我们先来看一下现象:
如上图,我们fork会创建一个子进程,而且这里我们会发现我们printf输出了两次,这是为什么呢?
如上图,fork的作用类似分流。当我们创建子进程之后,会去执行两次printf语句,同时如果你是子进程那么fork就会返回0,如果是父进程那么就会返回子进程的ID值,也就是说fork返回了两次,一个给父进程,一个给子进程。
同时父子进程的代码是共享的,数据是独立的来看一下程序:
如上图,对于父进程和子进程,对其中一个进程的val++,另外一个进程并不受影响。这就是数据的独立性。
下面我们画图来分析一下fork过程中发生了什么:
进程的状态
上述我们的ID值进程的标识符,接下来我们说进程的状态,进程的状态我们从笼统的操作系统进程概念与具体某一种操作系统(Linux)的进程状态分别讲起
笼统的操作系统概念:
如上图,我们进程的状态总共由以上几种,在具体要接进程状态之前我们需要几个预备知识:
并发和并行
并发:一个CPU执行多个进程。
并行:多个CPU,每个CPU执行一个进程。
时间片
每个进程一般不会一直在一个CPU上执行,如上图都是循环调度的。那么这里我们根据进程执行时间差异又分为两种操作系统:
-
分时操作系统:每个进程执行的时间是平均的。
-
实时操作系统:优先处理某一个进程,例如:在汽车急刹车的时候,优先执行刹车这个进程。
进程具有独立性
父子进程代码共享,数据是相互独立的。
等待的本质
在我们进程运行的时候,每个进程都会有一个task_struct(PCB)存储进程属性,操作系统在调度进程的时候会有一个运行队列, 每一个启动的进程只要在这个进程队列当中,那么它就是运行状态。(就绪状态也是这样),所以一个进程即使是处于运行状态,他是否运行也是有争议的。每一个在运行队列中的进程都会依次调用去CPU上执行一个时间片然后被拿下来换下一个进程运行,就这样循环调度。
我们知道只要进程需要IO操作,那么一定会访问磁盘等硬件,就那C语言的scanf来举例,当我们程序运行起来的时候,操作系统是如何直到我们像键盘当中写入数据了呢?这就是操作系统对硬件的管理了:先描述再组织,所以在操作系统中会有每一个硬件的task_struct(下图的struct device),里面存储着用户是否进行了输入操作的状态。当我们用户没有在键盘上输入数据的时候,此时这个进程就会处于阻塞状态,他此时会存在等待队列(wait_queue)当中,只有当我们用户进行输入的时候,这个进程才会重新回到运行队列当中继续运行。
挂起状态:当我们计算机资源不足的时候,如果一个进程处于阻塞状态(scanf等待输入),那么操作系统就会把他挂起,就是把他的代码和数据从内存移到磁盘当中(swap分区),缓解内存压力,当用户重新输入的时候才会再次调回内存继续执行。不过一般如果对效率有规定的话不会这样做。与磁盘IO比较浪费时间。
所以运行和阻塞的本质其实就是:让进程在不同的队列当中。
Linux中进程的状态
Linux进程状态分为如下几种:
内核代码
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
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 */
};
- R(运行状态):并不意味着程序一定在运行,也可能在运行队列中等待调度。
- S(阻塞状态):睡眠状态 - 阻塞等待状态
如上图,此时我们的./mypro虽然已经运行起来了,但是为什么状态却显示的是S+呢?
- 我们printf()IO时间远比我们while循环执行的时间长,基本都在IO,所以这里显示的为S+,同时我们还可以通过
kill -9 + PID
把这个进程杀掉,所以S+也叫做可中断状态
- D: Disk(磁盘),磁盘睡眠状态,不可中断状态 - 深度睡眠。
为什么操作系统要单独为磁盘设置一个状态呢?磁盘使用来存储数据的,并且是可以永久存储。举个例子:加入一个银行的操作系统有一个进程A,他现在在执行一个向磁盘写入今天的交易记录的任务(100万条数据大),但是此时呢,操作系统内存不足了,所以操作系统要进行保护自己了,所以直接把进程A给干掉了,但是此时呢,磁盘空间也不足了,所以他需要向执行它的进程返回错误信息,但是此时的进程A已经被干掉了,所以这个时候磁盘的数据就没了。为了防止向磁盘写入重要文件的时候突发情况导致数据流失,所以为向磁盘写入大量数据的进程添加了一个D状态
,这样即使当操作系统内存不足的时候,也不会为了保护自己而直接把这个进程给干掉了。
- T (stopped): 由于一些非法但是不致命的操作被操作系统暂停
如上图,我们使用kill指令停止当前进程。
当我们取消暂停的时候发现,当前进程的状态有S+ -》 S
,此时我们的进程就变为了后台程序
,此时ctrl + c
无法终止进程,并且还可以使用ls等基本指令,这里就相当于在windows环境下,我们点了右上角的-号
让程序进入后台运行。而我们不同运行的程序则是前台程序,使用ctrl + c
即可终止。那么后台程序如何终止呢?这里只能使用kill -9 + PID
终止程序。
- t (tracing stop):
如上图,当我们调试的时候运行到断点处就会出现t状态。
- x(dead):释放进程
- z(zombie):僵尸进程
这里先来说一下为什么要创建进程呢?创建是为了完成用户的任务,我们平常写代码当然知道自己是什么时候结束的,但是操作系统和父进程该如何知道子进程运行结束了呢?所以进程结束后都会返回一个结果,让操作系统或者父进程这要运行的结果如何。
如上图,ls本质也是一个二进制文件,而且是由C语言写的,那么肯定会有Main函数,$?
是最近一个结束进程的返回值,如上图,当我们使用ls展现一个没有的文件名时,会返回2,正常情况下会返回0。
先来说一下为什么会有僵尸进程这个概念,上文提过,进程 = 内核数据结构(task_strcut) + 程序代码和数据,那么在进程执行完准备退出的过程中,它并不是直接就把进程的所有东西回收的,而是先把程序代码和数据可以回收了,但是内核数据结果中存储着我们这个进程的退出信息,里面包括运行结果等等,而**这个退出信息需要让父进程来获取之后才可以完全把这个进程回收掉。而如果一个进程退出之后在父进程还没有获取退出信息这中间的状态就叫做僵尸进程。**僵尸进程虽然让父进程或者操作系统更好的管理这个每个进程,但是有一个问题:如果父进程迟迟没有过来获取退出信息,这个子进程的task_struct始终在内存当中存储着,但是却没有回收,这个就是经典的内存泄露了,关于如何解决这个问题我们日后再来解决。
接下来我们通过代码来实现一下僵尸进程的状态:
至于x状态则就是全部释放之后的标志。
- 孤儿进程
上述僵尸进程使我们子进程运行完了,但是父进程却没有运行完毕到时了父进程没有去获取子进程的退出信息,所以导致的僵尸进程。而孤儿进程则是子进程还在父进程直接不在了(干掉)。同时,如果一个进程的父进程被干掉了,那么这个进程就会被操作系统领养。
进程的优先级
- 什么是进程优先级?
CPU中会有一个运行队列,进程在队列中排队等待被调用就叫做进程的优先级。同时在LINUX中优先级使用整数代表。
- 为什么会有进程优先级?
资源少,一般都是有多个进程但是只有几个CPU。
- 如何查看优先级?
使用ps -l
:
如上图,PRI
:默认初始的优先值,NI
:对进程值的修正值。什么意思呢?一个进程的最终优先级 = 默认优先值 + nice(NI)修改后的值,同时这里的PRI
就是就代表了我们的最终优先值。
**优先值越小优先级越高。**如何修改优先值呢,这里我们只可以修改NI的值:
如上图,这里我们明明输入的是100,为什么最后这里的NI却只是19呢?
nice的上界 = [0,19]。
nice的下界 = [-20,0]。
同时我们发现上面我们本来应该是99-40,但是这里还是60,这说明我们每一次的优先值都是默认给的80,就相当于每次我们的优先值都是重置为了80,同时我们的nice修改范围是:[-20,19]
进程的切换
这里先说一个预备知识:
- 时间片:上述也说了,就是每一个进程不会一直在CPU上运行,而是运行一个时间片。
- Linux中进程的调度是循环调度的,每次运行完时间片的进程都直接到运行队列的末尾等待下一次调度。
- 一个进程运行一个时间片之后不一定跑完了,还可以在任意位置进行继续调度。 - 进程的切换。
如上图,这里我们CPU调度进程的时候,先去问pc寄存器该执行哪块代码了?然后根据pc
寄存的地址我们lr
获取对应地址的指令,交给CPU处理,然后CPU又问pc寄存器,pc寄存器继续往下偏移一个长度的地址。
这里有两个小知识:
- 我们寄存器里面存储的是进程的瞬时数据。
- CPU内存里有很多个寄存器,但是只有一套寄存器,寄存器 != 寄存器中存储的数据
上述进程我们命名为进程A
,来看如下图:
如上图,当我们还有一个进程B的时候我们发现此时寄存器中关于进程A的信息就被覆盖住了,那么下一次调用进程A的时候从哪里开始执行呢?所以在进程切换的过程,进程的下一次执行位置等等临时数据都要被保存下来的,方便下一次调度的时候使用。这种临时数据我们叫做上下文数据
。
如上图,这样下一次调度进程A的时候CPU就知道该从哪执行了。
总结:
- CPU调度进程的时候是一种循环的过程:
- pc获取下一条指令地址
- lr获取对应地址的指令
- 交给CPU处理指令
- 进程切换的时候需要保存上下文数据,这些数据被存放在
task_struct
中,以便下一次CPU调度使用。
进程调度算法
那么进程调度究竟是如何设计的呢?难道runqueue
真的就是一个普通的队列吗,上述我们也说了,进程是根据优先级调度的,队列肯定是不满足条件的。
如上图,我们实际的进程队列其实是一个哈希表,队列中每个元素都是一个指针,它代表每个进程的指针。那么通过哈希函数我们可以算出每一个进程在队列中的具体位置, 然后再插入具体位置但顺序调用即可。
进程调度有三种情况:
- 运行就退出 (ls)
- 运行到时间片结束
- 新建进程
前面讲过我们进程再CPU运行完它的时间片之后会从CPU上拿下来然后换下一个进行运行,但是无论怎样最后我们都是要插入会队列当中,那么我们优先等级高的,每一次都是插入在高优先级的地方,同时新创建的进程如果优先级都比较高 ,他们就会一直处于一个先调度的状态,这样我们优先级低的进程不是一直都不能被调度的吗?
如上图,我们这里简单说一下内核设计方案,在调度过程中我们会有一个数组array[2]
,它没分元素都是一个结构体,nr_active
代表当前活跃的进程数目,queue
就是上面的哈希表。bitmap[5]
则是一个位图。我们先来说一下active
和retire
这两个指针,他们分别指向array数组,在进程调度的过程中,如果一个进程运行玩时间片之后会直接从活跃队列中弹出来,插入到过期队列中,而新建进程也是一样,**这样设计的目的就是为了防止优先级高的一直循环调度,优先级低的没有调度的问题。**当我们的nr_active
减为0的时候,代表我们活跃队列中进程数目为0,那么说明进程全部插入到过期队列当中了,那么此时只需要执行一个swap(active,retire)
就可以了。
再来说一下bitmap[5]
的作用,一个整形是32个bit位,那么bitmap[5]就是160是bit位,那么我们位图都是用来标记存不存在的,当我们开始调度的时候我们肯定是要遍历哈希表,找到第一个不为空的指针,然后一次调度对应位置的进程,我们这里需要一个一个遍历,而我们可以把140个进程用140个bit位来标记,只要有进程就变为1,那么之后我们只需要遍历bitmap
数组,这样就不需要暴力遍历140个位置了,我们一个整形就可以代表32个位置,只要bitmap[i]
中不是0就说明有进程需要调度,此时我们再依次遍历找到需要调度的进程即可。
补充知识
我们知道一个程序运行起来加载到内存的时候会变为一个进程,同时每一个进程都会有一个task_strcut(PCB)
,通过每个进程用链表串起来方便管理,而在运行的时候进程还会再运行队列中等候,如果像scanf
一样需要读取用户输入,进程还需要到阻塞队列当中(等待队列)等待用户输入,那么进程是如何做到既在链表中,又在不同的队列当中呢?
来看一下在C语言中我们定义链表节点的定义方式:
struct Node
{
Type data;
struct Node* prev;
struct Node* next;
}
如上图,除了前驱节点和后继节点,我们一般都会有一个数据成员data;那么如果我们的task_strcut
也是这样设计的可以完成上述操作吗?
struct task_strcut
{
int pid;
int ppid;
int state;
进程属性
struct task_strcut* prev;
struct task_strcut* next;
}
答案是不行的,一个是不优雅而且可扩展性低。这里我的理解是:由于我们进程需要插入到不同队列当中,那么队列节点指针就不是strcut task_strcut
类型了,就不能管理进程属性了。**那Linux中是如何设计的呢?**如下图:
这里有两个问题:
- 意义是什么?
- 如何访问对应task_strcut的其他属性呢?
- 先来说第一个,我们上图相当于在
task_strcutz
中定义了一个数据结构,每个node节点把每一个进程连接起来了。如上图:我们每个进程都要去不同的队列当中,同时还要在一个总的链表当中,那么我们的节点都定义为同一种类型,只不过是不同的名字罢了,这样我们无论插入什么队列,都只需要把对应的节点插入即可!。
那么如何访问结构体的其他数据呢?
这就需要我们C语言的知识点了。在C语言结构体部分我们有一个宏可以计算结构体成员偏移量offsetof()
,例如我们有如下的结构体:
typedef struct A
{ //偏移量
int i; 0
char b; 4
double c; 8
}A;
A a;
在这个结构体当中我们可以通过offsetof()
来计算出每个成员对应的偏移量(与结构体A地址的字节距离)。那么offsetof
是如何实现的呢?我们可以把0强转为strcut A*
,这里假设我们知道c的地址(已知节点)。
看如下代码:&(((strcut A*)0)->c)
。
注意这里我们并没有访问空指针所指向的内存,而是对一个假设位于地址 0
的 struct A
类型的指针进行解引用,并获取其成员 c
的地址,那么当我们对(((strcut A*)0)->c)
取地址的时候就获得了此时c的地址(0x00000008)
,但是由于我们结构体的地址为0,所以成员c
的地址就是偏移量。
那么如何得到a
结构体的地址呢?我们现在知道a.c
的地址,我们这里只需要用a.c
的地址减去对应的偏移量即可。**注意⚠️:**我们不同指针减去一个整数效果是不一样的,这里我们只是减去8个字节的长度即可,所以我们需要把a.c
强转为(char*)
,然后把最后的结果还要强转为strcut A*
这样才能访问其他成员。代码如下:
(struct A*)((char*)(&a.c)-&(((strcut A*)0)->c))
。
测试代码:
#define GETOFFSETOF(TYPE,MEMBER) (unsigned int)(&(((TYPE*)0)->MEMBER)) //获取偏移量
#define GET_STRUCT_ENTRY(TYPE,PTR,MEMBER) (TYPE*)(((char*)PTR)-(GETOFFSETOF(TYPE,MEMBER))) //获取对应结构体地址
typedef struct A
{
int i;
char b;
double c;
}A;
int main()
{
A a = {0};
double* ptr_c = &a.c;
char* ptr_b = &a.b;
int* ptr_i = &a.i;
//计算对应成员偏移量
printf("offsetof i: %d\n", GETOFFSETOF(A, i));
printf("offsetof b: %d\n", GETOFFSETOF(A, b));
printf("offsetof c: %d\n", GETOFFSETOF(A, c));
//获取结构体地址
printf("get struct address by i: %p\n", GET_STRUCT_ENTRY(A, ptr_i,i));
printf("get struct address by b: %p\n", GET_STRUCT_ENTRY(A, ptr_b,b));
printf("get struct address by c: %p\n", GET_STRUCT_ENTRY(A, ptr_c,c));
//通过结构体地址获取成员地址
A* Pstrcut = GET_STRUCT_ENTRY(A, ptr_i, i);
printf("strcut member i address is: %p\n", &(Pstrcut->i));
printf("strcut member b address is: %p\n", &(Pstrcut->b));
printf("strcut member c address is: %p\n", &(Pstrcut->c));
return 0;
}
命令行参数
在平常我们写C语言代码的时候我们的main函数都不会有参数,但其实main函数是有参数的,如下图:
如上图,main函数有两个参数,argc
和argv
。用户在命令行输入的我们叫做命令行参数,argc
代表着参数个数,argv
则是一个字符类型的指针数组。我们用户输入的命令行参数会被按照空格分隔出来成一个一个的字符串,最后存入argv[]
当中。**那么为什么会有命令行参数呢?**来看如下代码:
所以我们的ls
的-a -l -....
选项是如何实现的呢?我们说过ls
指令也是一个可执行程序,ls -a -l
等功能就是通过命令行参数传入到ls
这个程序中去调用不同的功能。所以命令行参数就是为了传入对应的选项去调用对应的功能。
那么我们这个的main的命令行参数是谁传进去的呢? - > shell
。
当我们用户通过命令行输入会被Shell程序拿到,然后会根据空格把数据打散最后形成一张表。
我们用户的程序加载到内存当中会形成进程,而父进程都是bash(Shell)
,所以我们的main()
函数会去父进程的表中,读取已经被处理好的数据。
环境变量
认识环境变量
我们main函数其实还有一个参数,环境变量
。
PATH
如上图,红色框柱的部分都是我们的环境变量,那么他们有什么用呢?我们以其中一个PATH
为🌰:
如上图,为什么我们的ls
,pwd
可以直接使用,但是我们的code
却不定直接使用呢?
如上图,我们发现我们的ls
和pwd
都在/user/bin
这个目录下,之前也提到过,我们运行指令都是因为在这个目录下,那为什么这个目录下的可执行程序可以直接运行呢? —> 因为我们环境变量中有/user/bin
这个路径。
我们想要执行这个某个可执行程序就需要找到文件所在位置,因为我们PATH
这个环境变量中存储了/user/bin目录,所以我们的ls才可已使用,**也就是说PATH
就是系统可调用程序的一个搜索文件集。**当我们运行某个指令的时候会去PATH
这个文件集存储过的目录去查找如果找到了就运行,否则就报错。
我们使用 echo + $ + 环境变量名
即可单独查看某一个变量里面的内容了。那么有什么办法可以让我们无需指定目录直接运行我们自己写的可执行程序吗?
- 直接把对应的可执行程序复制一份到
/user/bin
目录。 - 在
PATH
中添加运行目录。
这里我们只说第二种:
这里我们需要注意:我们PATH
中不同路径是通过冒号分隔的,并且PATH=address
这种做法是直接赋值过去的,**所以我们还需要把原来的路径保留在后面单独加上我们自己的路径即可。**如果不小心改错了也不用着急,当我们退出XShell之后再登录就自动恢复了。
那为什么会自动恢复呢?其实环境变量是存储在操作系统的配置文件的当中的。当我们登录的时候,操作系统会先创建Shell
进程,这个进程里面会开一份空间, 然后把配置文件里面的内容拷贝进来,所以当我们像上述修改的时候只是修改的我们Shell进程自己内部空间的环境变量,系统重新登录的时候会重新拷贝一份。
上如图,在我们用户的家目录下有两个配置文件:.bashrc
和.bash_profile
。
如上图,我们的PATH
就存储在其中,这也能解释为什么我们每一次用户第一次登录一定在自己的家目录当中,不然就没有办法拷贝环境变量了。
HOME
在环境变量中还有HOME
变量:
如上图,当我们在不同用户的时候输出的结果是不一样的,所以HOME
环境变量记录的是登录用户的家目录,其实su -本质就是以root身份重新登录一次。
那么这里就有一个问题了:我们用户登录的时候是先创建bash进程然后在读取配置文件的内容还是先读取配置文件的内容再创建进程呢?
如上图可知,因为bash本身其实也是一个进程所以我们是先读取配置文件内容然后在创建Bash进程,同时bash再更改自己的cwd
。
如何更改呢?chdir("address")
。
如上图,在我们更该路径的时候,Bash进程的cwd也在跟着更改,所以也可以解释在bash进程之后创建的进程中他们的task_struct
属性中的cwd
是从哪来的,他们的cwd也是继承bash进程的cwd的。
SHELL和PWD
SHELL
显示当前的bash进程。
PWD
:记录当前路径。
如上图通过环境变量我们可以实现pwd
指令功能。
环境变量具有全局属性
我们不仅仅可以查看系统配置文件的环境变量同时还可以创建本地变量。
那么这个本地变量存储在哪呢?其实存储在Bash内部,在我们定义变量的时候bash会把整个命令行的输入当做字符串,然后通过按照key-valu的形式把变量存储起来。同时bash不仅仅可以看懂创建本地变量还可以识别while和for循环,所以bash其实也可以写代码的,我们管这个叫做SHell脚本。
同时本地创建的变量不会出现在环境变量中。
环境变量 VS 本地变量
如上图是我们bash进程创建过程中会生成的一些表。同时我们的本地变量可以通过export
变为环境变量。
环境变量具有全局属性
如上图,当我们设置ISRUNNING
为本地变量时我们子进程并没有这个变量,而设置为环境变量时则有这个变量。所以环境变量可以被子进程继承。
那么如果我们有多个进程,又因为每一个进程都是相互独立的,所以每个进程都可以使用这个环境变量,所以我们才说环境变量是具有全局性的。
进程地址空间
在讲解进程地址空间的时候,我们先来看一个现象:
如上图,我们发现虽然我们父子进程的数据是独立的,但是gval
的地址确实一样的?这怎么可能呢?
那么只能说明一件事,这个地址不是真是的物理地址(真是硬件的地址)。这里我们管他叫做:进程空间地址/虚拟地址。
了解进程地址空间
- 理解进程地址空间
如上图,我们真是的物理空间不会让进程去直接访问,而是给每一个进程一种假设,让进程以为他们是独自享用整个空间的。类似老板给每个员工画饼,只不过还需要把饼管理好(先描述再组织)。
进程PCB中会有一个mm_strcut指针
,它指向的是当前进程的进程地址空间,也就是可用内存。同时我们进程还会对进程地址空间进行划分(上图左半边)。
- 理解进程地址空间划分
进程地址空间是由 一个一个的整数来进行每一个区域的划分的,例如[xxx_start1,xxx_end1]代表代码区,[xxx_start2,xxx_end2]代表初始数据区。举个🌰:你和同桌两个人坐一张100厘米的桌子, [ 0 , 50 ] 属于你, [ 51 , 100 ] 属于同桌 [0,50]属于你,[51,100]属于同桌 [0,50]属于你,[51,100]属于同桌。所以我们进程空间的划分只需要知道区间的开始与结束即可。
如上图,当我们存储一个gval的时候,我们会在进程地址空间有一份地址空间,**同时进程当中会形成一个页表。它存储的是我们进程地址空间与真是物理空间的一份映射关系。**那么如果再创建一份子进程会怎样呢?
当我们创建一份子进程的时候,大部分数据都是拷贝父进程的,只需要修改少数东西即可。这里类似浅拷贝一样,我们子进程的虚表直接去拷贝父进程虚表中的东西。但只不过是浅拷贝,如果我们子进程对于父进程的代码和数据只是只读的话浅拷贝是没有任何问题的,但是只要对数据进行修改,那么子进程就会在拷贝一份数据在另一份空间,但是虚拟地址不变。当我们打印gval
的地址的时候其实我们是打印的进程地址空间,但实际上我们子进程指向的物理空间已经改变所以这也是为什么我们父子进程虽然地址一样但是内容却不一样得原因。
这里我们需要重新理解进程独立这个性质。
- 变量和地址
每一个变量都有一个进程地址空间,例如一个父子进程,如果子进程对于父进程的代码和数据只是只读的话他们两个其实就是同一个地址,但是如果子进程需要对父进程的数据进行修改的话,那么子进程就会单独有自己的真实物理空间,虽然我们用户看到的地址是一样的,但是物理空间不一样,这样我们父子进程的数据是独立的。
进程 = 内核数据结构(task_strcut/mm_strcut/页表) + 数据代码(独立)
。
每一个进程都有自己的task_strcut和mm_struct和页表,而且数据代码都是独立的,所以进程也是独立的。