Linux系统编程学习 NO.12——进程控制、shell的模拟实现
进程创建
在已学习的知识体系下,在Linux系统中创建一个进程可以通过./程序名称 创建并运行我们自己写的可执行程序。以及使用fork()函数在代码中创建一个子进程。
而fork()函数的使用上篇文章已有介绍,这里不赘述。简单复习一下fork()函数具体做了什么,首先,操作系统会为进程分配新的内存块以及创建对应的task_struct、mm_struct等数据结构。然后,将父进程的地址空间、页表的内容拷贝给子进程。随后,将子进程先加到系统进程的管理列表中。最后,函数返回子进程pid,子进程被调度器开始调度。
写时拷贝
子进程再被创建时,操作系统会将父进程的地址空间和页表拷贝一份给子进程。此时,子进程的地址空间和物理地址的映射关系是和父进程一致的。当子进程或者父进程修改了它们对应的数据时,会触发写时拷贝。操作系统是如何触发的写时拷贝?一开始页表的权限字段是只读的。当写入新的数据时,操作系统会开辟新的物理空间,然后将数据拷到对应内存空间。最后将新的映射关系建立好。
循环创建多个子进程
通过代码创建了5个子进程。通过上面的代码实验,可以发现每个子进程的调度顺序并不是绝对有序。被调度取决于不同自己成在调度器(运行队列)的排队顺序。
进程退出
什么是进程退出
进程退出指一个正在运行的进程结束其生命周期并释放相关资源的过程。当进程完成了它的任务,或者遇到了无法处理的错误、接收到外部终止信号等情况时,就会退出。
进程正常退出的情况
进程退出的情况无非三种,情况一、运行结束,进程正常退出,此时退出码为0。情况二、运行结束,进程正常退出,此时退出码为非0。情况三、进程异常退出。既然程序退出会有退出码,那这个退出码是交付给谁的呢?答案是交付给父进程的。因为只有父进程才会关心你的退出情况。在C/C++中通常是main函数的return 语句的返回值来返回对应的退出码的。下面就用Linux系统来验证一下进程退出是什么样的。
?这个用于表示上一个程序的退出码,echo $?表示输出上一次进程的退出码
退出码是给计算机看的,而我们人通常需要通过对应的退出码来解析出对应的错误原因。下面就看看C标准库的错误描述接口strerror。顺带对C标准库提供的错误码解析有一个简单的了解。
C标准库提供了134个错误码以及对应的错误描述信息。
通过上图的场景,用cat命令去找一个不存在的文件时,由于文件不存在,此时cat命令的退出码就是2号退出码,而bash通过2号错误码解析出错误描述信息,输出在终端让我们看到为什么指令没能执行出正确的结果。父进程关心子进程的退出码一般来说是为了交付给用户。让用户直到自己运行的子进程的运行结果。这就是为什么进程需要退出码的原因。
进程异常终止
进程异常终止时,进程对应的代码肯定没有执行完。所以,参考异常终止进程的退出码是没有意义的。但是,我们依旧需要关心异常的原因。下面,就看一看进程异常终止的场景,如除零错误、野指针错误等。
进程异常终止的本质是收到了信号。由于信号这个概念之前没听说过。这里不做详细介绍。下面就带大家验证一下这个观点。
通过kill -l命令可以查看当前系统所有的信号。除零错误异常终止本质是进程收到了来自操作系统的8号新号SIGFPE。
所以同理可得出,当访问野指针错误导致进程异常终止是因为收到了操作系统的11号信号。
进程退出的方式
在代码中控制进程推出的方式有如下三种main函数的return语句、exit()函数和_exit函数。return语句相信大家不陌生了,治理不赘述。重点介绍一下exit函数和_exit函数。exit函数时c标准库提供的库函数,而_exit是系统调用接口。exit函数本质也是需要调用_exit函数的,但是,exit函数再调用之前会调用清理函数以及冲刷缓冲区等操作。exit()函数还是比较常用的,而系统调用_exit()相对使用的不多。所以在下面的场景中exit和_exit的结果有区别。
int main()
{
printf("hello");
exit(0);
}
/*
运行结果:
[root@localhost linux]# ./a.out
hello[root@localhost linux]#
*/
int main()
{
printf("hello");
_exit(0);
}
/*
运行结果:
[root@localhost linux]# ./a.out
[root@localhost linux]#
*/
这里抛出一个现象,就是缓冲区的位置肯定不在内核区。因为_exit的操作执行是在内核区,若刷新缓冲区的操作在内核区,那么_exit也应该要和exit一样会将缓冲区的内容打印出来。既然冲刷缓冲区的操作时发生在调用_exit之前的 ,所以缓冲区是在用户区。至于缓冲区的概念后面会在谈的。
进程等待
什么是进程等待
通过系统调用wait()/waitpid(),来进行对子进程的状态进行检测以及对子进程进行回收。
为什么要进程等待
在介绍进程状态时谈到过,当子进程退出,父进程不管不顾的话,会导致僵尸进程的问题。而僵尸进程“刀枪不入”,进而引发内存泄漏。所以进程等待是必须要有的。
不仅如此,通过进程等待获取子进程的退出情况。进而了解父进程关心的子进程将任务完成的怎么样了。
如何等待?
wait接口以及周边问题
使用系统调用接口wait/waitpid进程等待回收僵尸状态的子进程。下面先介绍wait接口,它用于等待回收已经退出的子进程。等待成功返回子进程pid,失败返回-1。至于参数wstatus,等介绍waitpid时再谈,暂时不做介绍,设置为空即可。下面就带大家通过代码看一看进程等待。
上面的代码通过创建一个子进程,在运行五秒后,子进程退出。此时由于父进程处于while循环中,子进程暂时变成僵尸进程。父进程执行完循环逻辑后,开始回收子进程,然后z状态的子进程被回收,仅剩父进程在运行。在sleep 5秒后,父进程退出,程序结束。
就目前为止,可以认为等待是必须的。因为,不等待的话会导致子进程僵尸,进而导致内存泄漏,系统的资源越来越少。下面再看看创建多个子进程,并等待回收的场景。
如果子进程一直不退出,父进程会怎么做呢?将上面的代码进行一些调整后,可以观察到一个现象,当子进程一直不退出,父进程在调用wait时也不会退出。此时,父进程就处于阻塞状态。前面在进程状态部分介绍时, 我们谈到等待某种资源就绪的状态称为阻塞状态。这里的阻塞状态就是等待子进程退出这个软件资源就绪。
waitpid
头文件部分与wait一致,需要包含<sys/types.h>和<sys/wait.h>。返回值也是和wait一致。
第一个参数pid,可以为-1,表示所有子进程。如果传某个具体的子进程pid,就等待那个pid的子进程。
第二个参数wstatus,是一个输出型参数。用于记录子进程的退出信息。传NULL表示不关心子进程的退出状态。int本身是32位的数据类型,这里*wstatus不能以整型数据的视角看待,而是以位图的视角进行看待。可以通过一些宏来解释status中的信息,例如WIFEXITED(status)宏用于检查子进程是否正常退出(通过调用exit或_exit函数),如果是,则WEXITSTATUS(status)宏可以获取子进程的退出状态码(正常退出时返回的值)。
第三个参数options用于指定等待子进程的选项,它是一个位掩码,可以使用按位或(|)操作来组合多个选项。常见的选项有WNOHANG和WCONTINUED。WNOHANG:如果指定了这个选项,waitpid会在没有子进程退出的情况下立即返回,而不是阻塞等待。返回值为 0,表示没有子进程退出。WCONTINUED:用于等待一个已经停止(例如通过信号停止)但后来又继续执行的子进程的状态报告。
返回值部分成功时,waitpid返回终止的子进程的进程 ID。如果设置了WNOHANG选项,并且没有符合条件的子进程退出,返回 0。失败时,返回 - 1,并设置errno来指示错误类型,例如ECHILD错误表示没有指定的子进程可供等待(比如所有子进程都已经结束)。
下面通过样例演示一下它的简单实用
通过上面的实验可以观察到子进程退出后,退出码是1。但是父进程在获取它的退出信息时,输出的是256这是为什么呢?这是因为status的32位被拆分成了两个部分。其中16位保存异常终止时(被信号杀死时)退出信息,另外16位表示进程正常终止时的退出信息。最低8位保存的时信号信息,第8位就是core dump标志位(后面信号部分详细介绍)。而9-15位保存的是退出状态的退出码。其余位暂时不考虑。
这里由于是正常退出的状态,所以高16位不考虑。低十六为中,9-15用于存放退出码。由于第九位是1,所以转化成10进制后就是256。
上面我们提的子进程退出的三种场景就是为这里进行的铺垫。那父进程等待子进程时,无非就是期望获取子进程是否异常的信息和正常退出,但是退出码非0的情况。既然如此,为什么还需要单独在waitpid中设置一个输出型形参来获取对应的状态描述信息呢?用一个全局变量不就行了吗?答案是不行的, 因为进程具有独立性。虽然看起来子进程父进程在一份代码里面。但是,它们本质还是无法直接通过一个全局变量来交换各自信息。就好比你和你的舍友在同一间宿舍,这不代表你就知道它的隐私。更重要的是填写status描述字段的数据是内核的数据字段。而操作系统不相信任何人,所以,我们无法绕过操作系统直接访问内核的数据。
下面通过代码层面验证一下我们上述理论。修改一下付进程等待部分的代码。通过位运算来进行验证。由于退出码是在第9-15位,所以获取它需要向右移8位后再与上全1,即0xff。而信号部分直接与0x7f,即与7位为1的操作。
下面再演示一下特定场景的问题,如野指针错误和除零错误的信号部分。
父进程只能等待自己的子进程。下面我修改一下子进程的pid,再调用一下waitpid,来验证这一说法。
下面介绍一下status参数相关的两个宏,WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出) WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)。由于上面的位操作可读性和复用性较差,所以库里还提供了这两个宏给我们使用,具体原理其实就是宏替换上面代码的位操作。下面简单演示一下使用。
非阻塞轮询
waitpid的第三个参数options,最常传的一个是WNHANG。如果指定了这个选项,waitpid会在没有子进程退出的情况下立即返回,而不是阻塞等待。返回值为 0,表示没有子进程退出。HANG即夯,表示阻塞或宕机的意思。
下面通过一个故事带大家理解一下什么是非阻塞轮询。小明是一个计算机专业的学生,明天他就要期末考了,考的是数据结构与算法。但是,小明平时比较调皮,他害怕明天过不了考试。于是乎他就找了努力型学霸小红。他打了一个电话给小红,“小红,你有空吗?明天就要考试了,可以帮帮我复习一下吗?”。小红答应道“好的,但你需要等我一会儿,我先把笔记复习一下”。小明就到了小红的楼下等啊等。每过了三分钟,小明给小红打电话询问她的状态。终于等了二十多分钟后,他们愉快的去自习室复习了。小明的每过三分钟打电话动作就是非阻塞轮询。
有了数据结构与算法考试通过的经验,在考操作系统的时候,小明还想让小红给他复习。这一次他吸取了上一次的教训,他不再一直傻等着给小红打电话等她。而是每隔三分钟打一次电话询问她具体的状态如何?然后,他拿出操作系统的书就开始复习。过了一会儿小红下来了,他们去自习室复习去了。这一次小明在等小红的时候,不再像之前那样一直非阻塞轮询 + 傻等,而是非阻塞轮询 + 做自己的事情。这样效率的大大提高了。父进程在等待子进程时,可以做非阻塞轮询 + 执行自己的代码的方式,来提高进程等待的效率。
下面通过父进程部分代码演示一下,非阻塞轮询是什么样的。
下面谈谈非阻塞轮询中父进程可以执行其他的代码(做自己的事情)。首先,这是进程等待的附加动作。此时父进程它的核心还是等待子进程,所以在此时执行的任务只能是一些轻量级的任务。下面写一份demo的代码带大家感受一下父进程执行自己的代码。
然后, 在父进程部分调用InitTask()初始化任务,然后再等待的过程中调用ExcuteTask()来执行任务。
当父进程创建多个子进程执行时,哪个子进程先被调度或是先被等待,得看操作系统的调度器决定。而父进程永远是最后一个退出的,因为它要等所有子进程退出被回收后,才会退出。
进程替换
什么是进程替换
进程替换是指在一个进程的执行过程中,用一个全新的程序来替换当前正在执行的程序,使得进程的执行代码和相关的数据段等内容发生改变,就好像这个进程从一开始就执行新的程序一样。这种替换是在进程运行的环境下完成的,进程的标识符(PID)等基本属性通常保持不变。
如何进程替换
下面通过代码演示来带大家看看进程替换。首先,介绍一个系统调用接口execl,它用于执行一个可执行文件。第一个参数为该可执行文件的路径,第二个参数为该可执行文的名称,…表示可变参数包,可以传该可执行文件的选项。
通过上面样例的演示观察出现象,我们居然将程序替换成了系统指令ls。但是,我们第二个printf()函数并没有执行。
单进程程序替换的基本原理
当./运行myCommand程序时,程序的代码和数据从磁盘上加载到内存。当程序执行到execl函数时,操作系统会去execl的path参数所指向的路径上,将名称为第二个参数arg的进程的代码和数据替换内存中myCommand的代码和数据。在页表的虚拟地址部分程序替换并不影响它,当然mm_struct以及task_struct都不会改变。这就是单进程程序替换的基本原理。
多进程程序替换的代码演示
在子进程执行execl前,父子进程共享同一份代码。当子进程执行execl时,子进程触发写时拷贝,它的代码和数据被踢换成了ls指令的代码和数据。之前在进程地址空间时谈到代码区数据只读。那这里怎么触发写时拷贝呢?这是因为程序员没有权限写入,并不代表操作系统没有能力做这个事情。至于父进程的是否会被影响?答案是不会的,因为进程具有独立性。子进程替换不可以影响父进程。
那程序替换不会创建新的进程。从上面的实验中可以观察到,子进程的pid并没有改变。由此可以得出结论进程替换仅仅是对执行execl的进程的代码和数据进行替换。
由于程序替换后,原来的代码和数据被替换。所以,exec系列接口调用后,当前进程后续的代码不会执行。exec系列接口程序替换成功后,没有返回值,因为没有意义。而调用时失败应该有一个关于描述调用错误的返回值。
补充知识:ELF 是一种用于二进制文件、可执行文件、目标文件和共享库的标准文件格式。它在 Linux 系统中被广泛使用,其设计目的是提供一种灵活且通用的方式来表示程序代码和数据,使得操作系统能够有效地加载和执行程序。ELF表其中的程序头表描述了文件中的各个段(segment)在内存中的布局,包括段的类型(如代码段、数据段等)、段在内存中的虚拟地址、物理地址(如果有)、段的大小等信息。操作系统的加载器使用这些信息将文件内容正确地加载到内存中。在 Linux 中,exec 函数族(如 execl)用于执行程序替换。当调用 exec 函数时,新的程序会替换当前正在运行的进程的地址空间。这个过程涉及到对新程序的 ELF 文件的处理,特别是程序头表。操作系统的加载器会读取新程序 ELF 文件的程序头表。根据程序头表中的 p_type(段的类型) 为 PT_LOAD (可加载段,包含代码和数据等需要加载到内存的内容)) 的段信息,将程序的代码和数据段加载到内存中合适的位置。例如,对于一个新的可执行文件,加载器会使用程序头表中的 p_vaddr(段在虚拟内存地址空间) 和 p_filesz(段在文件中的大小) 等信息,将文件中的代码和数据加载到对应的虚拟内存地址,并分配足够的内存空间(根据 p_memsz,即段在内存中的大小)。
认识exec系列接口
exec系列接口一共有7个,其中execve这个接口比较特殊,它是2号man手册的系统调用接口,其余的exec系列接口都是库函数。
从execl入手,exec系列接口中的带l的接口其实就是list的意思,并且它们参数都可以传可变参数包。list 顾名思义就是链表,而可变参数包意味着它们的参数是一个一个链接起来的链表,最后的结尾位NULL。将调用它们的代码与我们在bash输入的ls指令 + 参数的形式可以是一模一样,无非只是函数以 “,” 为分割符号 , 而bash以空格为分隔符。而它的第一个参数pathname,需要这个可执行程序的绝对路径。因为要执行一个程序首先要知道它在磁盘中的位置,这样才方便找到它再执行它。总结一下execl函数的使用命令行如何输入的,调用execl系列接口就怎么传递参数。
下面介绍一下execlp,首先,它的一个参数并不是pathname,而是file。因为它默认回去系统的PATH环境变量里的路径找可执行程序。第一个参数传你要执行的可执行程序的名字。而第二个参数和execl意义一样这里不赘述。下面简单演示一下它的使用。
下面介绍一下execv接口。execv系列接口中的v就是vector的意思。它的第一个参数pathname和上面的execl意义一样。用于找到替换的可执行程序。而第二个参数是一个argv是一个字符串指针数组,它的指向不能被修改。它的本质其实跟前面提的main函数的可变参数中的argv是一样的。使用execv需要我们自己在代码中创建一个字符串指针数组并初始化内容,然后传给execv函数。下面就在代码上演示一下。
通过execv函数的使用,我们可以理解一下,其实execl系列函数,本质也是将可变参数包给插入到了ls对应的main函数的argv字符串指针数组参数中去了。在Linux 系统中所有用户大部分启动的进程都是bash的子进程,本质bash调用了exec系列函数,去执行了用户在命令行输入的指令以及对应的指令选项。exec系列函数也就承担了加载器的作用。
下面介绍一下execle接口,e代表的是环境变量,对应的参数envp接受的参数的是字符串指针数组,数组内是环境变量。在正式介绍之前,先验证一个结论,exec系列接口不仅能执行系统指令,还可以执行我们自己写的可执行程序。 我们采用一个C++代码写一个可执行程序来充当我们调用exec系列接口的程序,补充一个细节C++源文件有三种后缀名分别是.cpp、.cxx 、 .cc。
接下来解决一下如何用Makefile同时生成两个可执行程序。如果不添加这个all伪目标,就没法生成两个可执行程序,只会生成自顶向下第一个可执行方法。添加一个伪目标,并让这个伪目标依赖两个对应生成两个可执行程序的方法就能做到同时生成两个可执行程序。
通过上面实验可以观察到,我们的C代码编写的程序中调用了exec系列接口后,将程序替换成了C++代码编写的可执行程序。其实解释型语言或者是脚本语言所生成可执行程序都可以被exec系列接口进行进程替换的操作。下面进行一下简单的演示。
无论是什么类型的语言写的程序本质上都是要被操作系统以进程的方式进行调度。只要是进程就可以被exec系列的接口调用,exec系列接口就是加载器,很多高级语言都会提供类似功能的接口,这些接口本质就是对exec系列函数进行了封装。
通过上面的实验可以发现,在使用execv函数时,并没有传递环境变量。但是,替换后的进程还是获取到了对应的环境变量。环境变量是什么时候给进程的呢?答案是在子进程创建的时候就会从父进程那里继承父进程的环境变量。在程序替换中,子进程从父进程那里继承到的环境变量不会被替换。
下面演示一下给子进程传递环境变量的方式,分别是在bash命令行上创建新的环境变量,然后让子进程通过继承的得到对应的环境变量。
还有一种方式就是使用系统调用接口putenv,在当前进程内导入环境变量,随后子进程在创建时,就会继承这个环境变量。
下面以execle为例进行一下演示。 先以导入系统的环境变量为例,然后再导入自定义环境列表来替换系统的环境变量表。
模拟实现shell
shell即外壳程序,它是用户和内核的中间解释层。它本质就是创建子进程去执行用户所输入在命令行中的指令。下面就通过前面所学知识来模拟一个shell命令行程序。现将对应的Makefile编写一下。
先实现出shell命令行提示符的效果。借助环境变量获取接口getenv,就通过printf函数将我们bash的提示符效果做出来。
shell程序就要接收用户传递的指令。所以,我们需要从键盘(标准输入流)获取用户输入的信息。这里用scanf是不行的,因为我们在输入像ls -a -l这类的带选项指令时,以空格作为分隔符。而scanf读取到换行或者是空格就会停止读取。这里我们用fgets来进行读取键盘输入。fgets函数如下,
char *fgets(char *str, int n, FILE *stream);
其中第一个参数用于接收我们输入的内容,第二个参数表示读取的最大字符数,第三个参数表示从哪个指定的文件流读取数据。读取失败返回NULL,成功返回str。
我们在键盘上敲得回车换行键’\n’ 也会被fgets读取,这不便于我们后面解析字符串。所以,需要处理一下这个’\n’。
接下来,对输入的字符串做切割,以便后续调用。这里使用strtok接口进行字符串切割。需要注意的是,strtok函数需要先将第一个空格之前的字符串部分切分出来,然后就循环切分即可。
下面就要对切分后的指令的类型做一个判断,因为指令大体分为两类,内建命令和普通命令。对于普通命令我们的shell程序会创建子进程去调用exec系列接口执行。对于内建命令则是shell程序自己去执行。下面先处理一下普通命令的逻辑。
其实内建命令其实本质就是shell内部的一个函数。下面以常见的内建命令echo、cd为例,对自定义shell的内建命令处理逻辑进行一个完善。对于cd命令,我们需要通过系统调用chdir来进行修改当前的目录,然后使用系统调用接口getcwd将修改后的路径再写入到PWD环境变量中。这样就能实现类似于shell的效果。对于echo命令,需要对"$?“获取上一次指令的退出码进行特殊处理,还需要对” $ “进行特殊处理即可。对于用户手动输入的消息会显到显示器中(需要对”"做处理)。下面是参考代码。
点击获取参考代码
模拟实现shell可以让我们大致了解它的基本运行原理。它本质也是一个进程,它一启动就需要从当前用户的家目录下的 .bash_profile配置文件中导入对应的环境变量。它的核心逻辑就是等待用户键盘输入,然后在用户输入后读取并解析对应的字符串内容。若是内建命令,则shell本身回去调用对应的方法来执行。若是普通命令,则shell本身创建子进程去调用相应的方法执行,shell只需要等待子进程完成任务后,查看对应的退出码就能返回给用户结果。
原文地址:https://blog.csdn.net/m0_71927622/article/details/143757488
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!