Linux进程

铺垫

冯诺依曼体系结构

在这里插入图片描述

大部分计算机硬件结构和数据流向都遵循冯诺依曼体系,在该体系下,所有设备都要通过存储器进行数据的交换。各种设备之间的数据交互,其实是数据在设备中的来回拷贝,拷贝的速率影响着计算机的效率,如果剔除内存器,让cpu直接与输入输出设备进行信息交换,尽管cpu速率很快,由于输入输出设备数据流动效率非常低,导致cpu长期处于闲置状态等待输入输出设备响应,从而使计算机效率非常低。存储器的数据交换速率介于输入输出设备与cpu之间,其通过预先加载数据和缓存的方法 提高了计算机效率,使计算机速率取决于存储器,同时由硬件问题变成了软件问题(由软件决定预先载入什么)。

操作系统

操作系统是进行软硬件管理的软件,最先被加载到内存的软件,通过操作系统向下管理好软硬件,从而为用户提供良好的运行环境。
在这里插入图片描述

底层硬件以冯诺依曼体系结构组合,同时每种硬件都有自己对应的驱动程序,驱动程序一般由硬件生产商提供,通过其可以对硬件进行对应操作,驱动程序将自己操作硬件的接口提供给操作系统,以供操作系统调用和管理硬件,操作系统又向上提供了一些接口(本质上是有输入和返回的函数),称为系统调用接口以供用户使用,由于使用系统调用接口对用户有一定的要求,于是又对系统调用接口进行一定的封装,产生了用户操作接口,如c标准库等,上层用户通过指令或开发操作操作用户操作接口,就可以较为容易的使用底层硬件。
任何行为都不能越过操作系统操作底层,所有涉及底层硬件的函数,只能经过系统调用接口操作底层。
一些语言具有跨平台性和可移植性,是由于其函数名虽然一样,但其底层在不同操作系统下是不同的,然后将函数封装成库,只要用户在对应的系统下安装对应的库就行了。如果我们直接使用系统调用接口而不进行封装,由于不同操作系统提供的接口不同,注定其只能在某一操作系统上运行。

进程

程序的本质是文件,文件在磁盘即外部设备中,由冯诺依曼体系,要运行该程序首先要将其加载到内存中去。

进程描述

进程有许多个,操作系统需要对这些进程进行管理,而管理一个进程只需要管理其相关的数据就行了,因此需要需要对进程进行数据描述,每一个进程的数据当成一个数据块(即进程属性数据的集合),将所有的进程的数据块通过特定的数据结构链接起来(先描述,再组织),那么对进程的管理就变成了对某一数据结构的管理。
进程的信息放在PCB(进程控制块)中,Linux下的PCB是task_struct,task_struct的内容主要如下:

1.标示符:描述本进程的唯一标识符,以区别于其他进程。

2.状态:任务状态、退出代码、退出信号等。

3.优先级:相对于其他进程被执行的优先级。

4.程序计数器:程序中即将被执行的下一条指令的地址。
cpu功能单一,只能取指令–>分析指令–>执行指令,其上面有大量的寄存器,这些寄存器保存着进程运行过程中产生的大量临时数据(如代码已经运行到哪一行),当时间片到了之后,这些寄存器里的的内容就就会保存到PCB中,当下一次cpu运行该进程时,这些内容又会恢复到寄存器中,以便cpu继续上次的位置调度运行。一个cpu只有一套寄存器,一个运行队列,寄存器里面的数据为某个进程所私有(因为下一个进程使用寄存器时里面的数据被覆盖了),如cpu上面的一个eip寄存器上有一个pc指针记录下一条指令的地址,而判断、循环、函数跳转的本质就是修改pc指针。 ·

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

6.上下文数据:进程执行时处理器的寄存器中的数据(cpu只让每个进程执行一会,记录了程序上次被执行时的情况,以便下一次被执行)。

7.记账信息:包括处理时间总和、使用时钟数总和、时间限制、记账号等。

8.I/O状态信息:显示的I/O请求、分配给进程的I/O设备、被进程使用的文件列表。

9.其他信息。
因此进程就是可执行程序和内核PCB对象,创建进程时先创建PCB对象,再将代码和数据载入内存。

进程查看

查看进程主要有两种方法:
①通过指令

ps axj

②通过查看系统文件

ls /proc

通过系统调用可以获取进程的标示符
getpid():获取进程的pid
getppid():获取父进程的pid

kill -9 pid//杀死进程
kill -19 pid//暂停进程
kill -18 pid//启动进程

ctrl+c只能杀死前台进程,想要杀死后台进程可以用kill指令。

进程创建

可以通过系统调用fork来创建子进程,如果成功,会给父进程返回子进程的pid,给子进程返回0,失败返回-1。fork之后代码由父子进程共享,数据各自开辟私有一份(写时拷贝),父子进程轮流被执行(谁先被执行不确定)。任何进程之间都是互不影响的,因此就算杀死了父进程,子进程也可以正常运行。
fork之后,内核做:
①分配新的内存块和内核数据结构给子进程
②将父进程部分数据结构内容拷贝至子进程
③添加子进程到系统进程列表中
④fork返回,开始调读器调度
父子进程之所以都有返回值且值不同是因为在fork函数返回之前,子进程就已经创建好了,后面的代码共享,各自都return了一次。

一般我们创建子进程是希望父子进程做不同的事,所以fork之后通常会使用if语句分流。

进程状态

进程的状态是用不同的整型数字来表示的,在task_struct的status变量中填入不同的数字以表示进程的不同状态,状态决定了进程的后序动作。通常有以下几种状态:
1.运行状态(R状态):在该状态下,进程要么正在被cpu运行,要么在运行队列等待被cpu运行(即R状态包括了就绪态和执行态)。

2.可中断睡眠状态(S状态):在该状态下,进程在等待某件事完成,如进行资源等待,使用scanf函数等待输入等,可以响应信号被唤醒,属于阻塞状态的一种。

3.不可中断睡眠状态(D状态):在该状态下,进程正在等待某些不可中断的事件,不会响应任何类型的信号,操作系统无法杀死该状态下的进程(操作系统在资源吃紧时,会杀死某些进程),属于阻塞状态的一种。

4停止状态(T状态):在该状态下,进程停止执行,进入后台,进程在做一些操作系统认为危险的事时进入该状态,也可以通过向进程发送SIGSTOP信号让其进入T状态,发送SIGCONT解除暂停状态,属于阻塞状态的一种。

5.t状态:在该状态下进程停止,同时具有追踪功能,一般在调试断点时进入该状态,属于阻塞状态的一种。

6.僵死状态(Z状态):在该状态下,进程已经退出,PCB还没有被释放,在等待父进程获取子进程的退出信息。

7.死亡状态(X状态):在该状态下,进程已经结束执行,资源也已经被操作系统回收,PCB已经被释放,该状态不能被查看到。

此外,当内存资源吃紧时,一些进程会进入挂起状态,操作系统将内存中进程的数据搬到磁盘的swap分区,等到运行该进程时,再将该进程的数据从磁盘加载到内存中,该操作虽然降低了运行速率,却可以有效防止操作系统挂掉。swap分区的大小一般设为与内存大小相同,以降低操作系统对swap分区的依赖性,从而使运行效率不至于太低。
其主要的三态模型如下:
在这里插入图片描述

每个硬件都有自己的等待队列,进程进行软硬件资源等待时,如果资源没有准备就绪,进程就将自己设为阻塞状态,状态的变迁就是操作系统将进程对应的PCB搬到对应硬件资源提供的等待队列中(如cpu、键盘、磁盘等提供的等待队列)。
现代操作系统,进程都是基于时间片轮转运行,否则一旦进程进入死循环,cpu将一直被占用,使操作系统崩溃。

僵尸进程和孤儿进程

只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程就处于僵死状态,处于僵死状态的进程称为僵尸进程。所有进程在退出时都会进入僵死状态,其意义是告知父进程他创建的子进程的执行情况,如果Z状态一种不退出,那么内存就要一种维护该进程的PCB,造成内存一直被占用,属于内存泄漏。
一个进程如果其父进程先退出了,那么称该进程为孤儿进程,孤儿进程会由init进程(即1号进程)领养,孤儿进程退出时由initi进程读取孤儿进程的退出信息以让其正常退出。

进程优先级

进程的优先级决定了cpu资源分配的先后顺序(优先级决定先后,权限决定能不能)。
使用以下指令可以查看进程的优先级详细信息:

ps -l

在这里插入图片描述
UID:代表执行者的身份
PID:进程的代号
PPID:父进程代号
PRI:进程可被执行的优先级,值越小,优先级越高
NI:代表该进程的nice值

nice值是进程可被执行的优先级的修正值,其范围为[-20,19]共40个级别,PRI的默认值是80,其范围是[60,99],真正的PRI(new)=PRI(old)+nice,所有Linux调整进程的优先级,其实是调整nice值。
可以使用top指令修改已经存在的进程的nice值:
输入top指令–>输入’r’–>输入进程的PID–>输入nice值
如果修改的nice值超出范围,只能取极值(如将nice值改为100,最大只能取19),Linux之所以这样限制优先级的调整,是为了防止用户将自己的进程的优先级设置得非常高,从而使一些常规进程不能被正常调度,引发进程饥饿问题。
由于cpu资源为少数,而进程为多数,所有进程之间具有竞争性,而优先级就是为了让进程可以更合理的竞争相关资源,高效完成任务。多个进程在多个cpu下分别同时运行,称为并行,多个进程在一个cpu下采用进程切换的方式,在一段时间内让多个进程得以推进,称为并发,并发是我们接下来的重点。

环境变量

环境变量就是在操作系统中用来指定操作系统运行环境的一些参数,如库在进行链接时,就会有存放了库的地址的环境变量来帮助编译器查找到库。总之,环境变量具有某项特殊用途,由系统自己开辟空间、取名和填充内容,通常具有全局属性。
常见的环境变量:
①PATH:指定命令的搜索路径
②HOME:指定用户的主工作目录(即用户登录Linux默认的目录)
③SHELL:查看当前的Shell(值一般为/bin/bash)
④PWD:记录当前路径
⑤USER:记录当前用户

和环境变量相关的命令如下:
①echo:查看环境变量的值

echo $PATH//查看PATH的值

②export:创建一个新的环境变量
③env:显示所有环境变量
④unset:删除本地变量和环境变量
⑤set:显示本地定义的Shell变量和环境变量
注意本地变量和环境变量的区别:本地变量是为当前进程创建和服务的,只对当前进程透明,不能被子进程继承,环境变量是为所有进程服务的,对所有进程透明,可以被子进程继承。

如果我们在test.c里面写了一段代码,然后编译成名为test的可执行文件,我们可以直接指明该文件的路径运行该文件,现在我们希望这个可执行文件可以像普通的指令一样:test就可以直接运行,有两个方法:
①:将该可执行文件放到PATH环境变量指定的路径下。这样相当于将该程序安装到系统中,会污染系统,因此我们不建议这样做。
②:将程序的路径加入到PATH环境变量中

PATH=路径:$PATH

注意$PATH一定要加上,否则PATH原来的内容将会被覆盖,加上这个只是在原来的内容上追加。当然了,如果不小心覆盖了也没事,因为现在这些变量都是内存级的,所有的环境变量在当前用户的家目录下的.bash_profile文件中保存着,所以退出重进即可。

我们输入的指令,会被bash捕获并转为字符串。main函数也是有参数列表的,只不过我们一般不显示写出来:

main(int argc , char*argv[] , char* environ[])

整型变量argc用来记录字符串的个数(以空格为分隔符),char型指针数组用来记录各个字符串,末尾的指针一定存有NULL值,environ(环境表)是指向环境变量字符串数组的指针,这些环境变量由操作系统提供。
在这里插入图片描述
这样我们就可以实现指令的选项功能了,即用不同的选项获得不同的功能

int main(int argc , char* argv[],char* environ[])
{
	if(strcmp("-a",argv[1])==0)
	{
		fun1();
	}
	//...
}

在Linux中,每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的字符。
在这里插入图片描述

环境变量可以被子进程继承,其实就相当于环境变量具有全局属性。
获取环境变量通常有以下几种方法:
①直接从main函数参数的环境表获取(打出来查看)
②使用getenv()和putenv()函数获取特定环境变量(推荐)

const char* str=getenv("USER");

③引入指向环境变量表的指针,再打印出来

extern char** environ;
environ是指向环境变量表的全局变量,
但没有包含在任何头文件中,因此需要extern说明

进程地址空间

通常我们认为Linux的内存空间分布是这样的:
在这里插入图片描述
但这并不是真正的物理内存空间分布,我们称之为进程地址空间,我们平时看到的地址也不是真正的物理地址,而是虚拟地址,真正的物理地址用户看不到,由操作系统进行管理,并通过页表将虚拟地址转为物理地址。
在这里插入图片描述
每一个进程都有一个自己的进程地址空间(在32位下,大小为4G),但真正能给到进程的内存不会有4G,因为其他进程也要占用内存。这样做的意义是可以对所以进程进行统一管理,让物理空间由无序变得有序(只需管理虚拟地址就行了,可以通过页表找到背后无序的物理空间),同时将进程管理和内存管理解耦合,页表还可拦截非法访问,保护内存安全。
所有进程地址空间之间也会用某种数据结构连接起来以进行管理(先描述,再组织),对进程地址空间的数据描述类似下面这样:

struct NAME
{
	int start1,end1;
	int start2,end2;
	//...
}

页表是可以被子进程继承的。父进程创建子进程后,子进程获得一份与父进程一样的页表,同时父子进程之间代码和数据共享。当其中一方试图修改数据时,发生写时拷贝。
页表还有一列表示权限(常量的本质是该权限改为只读),操作系统在创建子进程时,会将父子进程页表的数据段权限改为只读,当用户试图修改数据时,触发缺页中断,这时操作系统就会将该权限改为可读写,同时发生写时拷贝。
在这里插入图片描述
在上图中,操作系统只修改了页表的映射关系,并没有修改页表的虚拟地址,这就是父子进程共享代码段变量地址相同但内容不同原因。

这里再谈一下动态内存开辟,在使用malloc和new开辟空间时,操作系统在页表中返回一个虚拟地址,但并没有立即开辟空间,页表中也没有相应映射的物理地址,当用户真正向内存写入时,发生缺页中断,操作系统先拦截用户访问,再去开辟空间并建立相应映射。这样其实是将原来一次性做完的事分成两部分来做,却可以提高操作系统对资源的使用效率.

进程调度队列

每一个cpu都拥有一个runqueue,如图所示:
在这里插入图片描述
queue数组我们只关心100到139这40个普通优先级(实时优先级0-99我们不管),依次对应前面的60-99这40个优先级。用active指针指向活跃队列,expired指针指向过期队列,cpu遍历活跃队列的queue数组,按优先级先后只运行在活跃队列排队的进程,即cpu只运行active指针指向的队列。时间片到了的进程和新建的进程都放入过期队列中,以防止一直有优先级高的进程产生占用cpu,导致级别低的进程无法被执行(引起进程饥饿问题)。当活跃队列的所有进程都被运行完后,交换active和expired指针的指向。
如果我们一遍遍的遍历queue数组来寻找哪里有进程需要被执行,效率会比较低下,因此我们定义了一个数组bitmap[5](共160个比特位,大于140),比特位位0表示该级运行队列为空,比特位为1表示该级运行队列非空,这样就极大提高了查找效率。

进程终止

进程的退出场景只有3种:
①代码执行完毕,结果正确
②代码执行完毕,结果错误
③代码异常终止

进程常见退出方法也只有4种:
①从main函数返回
main函数的返回值就是进程的退出码,0表示执行成功,非零表示异常终止,且该非零值表示失败原因的代号,使用strerror(number)查看错误码number的含义。
②调用_exit()函数
③调用exit函数
其中_exit函数为系统调用接口,exit函数在底层调用了_exit函数,并增加了刷新缓冲区的功能。
④使用ctrl+c异常终止

需要注意的是,只有操作系统可以终止进程,而进程终止则是进程收到了异常信号。普通函数退出时也有自己的错误码,需要用户自己用erron变量获取。

进程等待

进程变为僵尸进程后,会占用内存资源,而且kill指令无法杀死该进程,而父进程可以通过进程等待的方式回收子进程资源获取子进程退出信息
进程等待需要获取子进程的信息,而子进程信息属于内核数据,归操作系统管理,所以等待方法底层需要调用系统接口,进行进程等待的方法有两种,这些方法在头文件<sys/wait.h>中
①wait方法

pid_t wait(int* status);

该方法默认进行的是阻塞等待,在阻塞等待中,父进程会被暂停直到子进程结束,非阻塞等待则是父进程只询问一次子进程信息,不会暂停自己等待子进程结束,当后面子进程结束时父进程接收到某种信号回收子进程,避免其长期处于僵尸状态。当父进程调用wait方法时,先暂停自己的执行,直到其中一个子进程结束,成功返回被等待进程的pid,失败则返回-1。
其参数为输出型参数,获取子进程的退出状态,倘若不关心子进程退出状态可以给该参数传NULL,可以用库提供的宏来解析其容:
WIFEXITED(status):用于查看子进程是否正常退出,若子进程正常终止,则返回真。
WEXITSTATUS(status):查看进程的退出码,若WIFEXITED非零,则提取子进程的退出码。
②waitpid方法

pid_t waitpid(pid_t pid , int* status , int options);

参数pid表示需要被等待的进程的pid,pid=-1表示等待任意一个子进程,与wait方法等效,pid>0表示等待进程标示符为pid的子进程。
参数status为进程的退出状态。
参数options表示等待方式,WNOHANG表示非阻塞等待,即在父进程调用waitpid方法时子若进程还没有结束,不会暂停父进程,并返回0,若子进程已经结束则返回子进程的pid。0表示阻塞等待,通常设为0即可。
若方法执行成功则返回子进程的pid,失败返回-1。

函数执行完毕后,我们希望直到函数的执行结果和执行情况,进程结束我们也希望知道进程的执行情况和执行结果,而这些信息已经放在参数status中了,不过我们只关心他的低16位比特位,status的信息如下:
在这里插入图片描述

进程程序替换

当我们创建了一个子进程后,我们希望子进程可以运行其他程序的代码,这就需要用到进程替换,而使用一些exec开头的函数可以进行进程替换,这些函数不创建新的进程,只是将旧的进程的用户空间代码和数据进行替换,因此调用exec函数前后该进程的pid不会改变。前面我们已经知道,在创建进程时,先创建pcb、页表、地址空间等,再将进程加载到内存中,由此可知,程序的替换本质就是加载。
共有6个以exec开头的函数,统称为exec函数:

int execl(const char* path, const char* arg, ...);
int execv(const char* path, char* const argv[]);

int execlp(const char* file, const char* arg, ...);
int execvp(const char* file, char* const argv[]);

int execle(const char* path, const char* arg, ...,char* const envp[]);
int execve(const char* path, char* const argv[], char* const envp[]);

这些函数有一定的规律:
①l(list):参数采用列表
②v(vector):参数采用数组
③p(path):自动搜索环境变量PATH
④e(env):自己维护环境变量
总的来说就是函数名带p则可以不写全文件路径,系统自动到PATH变量保存的路径下寻找,函数名带e,需要自己组装环境变量。

这些函数只有出错返回值,没有成功返回值,即如果调用成功则启动新的程序开始执行,不再返回,出错返回-1,同时需要注意这些函数带省略号的参数最后一定要用NULL结尾,参数arg和argv只要正常填入指令操作时要输入的字符串就行了(字符串以空格分隔)。

这些函数在进行程序替换时,不会替换环境变量数据,前面我们已经知道,可以通过extern char** environ的方式获得子进程从父进程继承来的环境变量,如果我们在使用带e函数时,直接传入自己创建的环境变量表,那么原来的环境变量表就会被覆盖,因此我们可以使用putenv函数将自己创建的环境变量追加到environ指向的环境变量表中,如果重名了,会将该环境变量原来的内容覆盖掉。这些增加的环境变量只会影响当前程及其子进程,不会对前面的进程造成影响。

char* const myenv[]={"PATH=/bin","USER=myuser",NULL};//自定义环境表
execle("/xxx/ls","ls","-a",NULL,myenv);

//以上方法我们不建议,我们可以这样
extern char** environ;
putenv("PATH=/bin");
putenv("USER=myuser");
execle("/xxx/ls","ls","-a",NULL,environ);

这里说明一下,在上面的6个函数中,只有execve函数是系统调用接口,其他函数都是在底层封装了该函数。
在这里插入图片描述
通过以上内容,我们就可以做一个简易的Shell了,我们只需要循环以下过程就行了:
①获取命令行
②解析命令行
③使用fork建立一个子进程
④使用exec函数替换子进程
⑤用wait函数让父进程等待子进程退出

  • 9
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值