什么是进程
通过冯诺依曼体系我们可以得知,现代操作系统的核心是内存和cpu,输入设备将数据传入到内存上,内存中的数据经过cpu处理之后再将那个数据输出到输出设备之上,这样的一个过程可以说是一个进程所要做的事。进程即程序执行的一个实例,也是操作系统进行资源分配和调度的基本单位,从内核观点上来看,进程即担当分配系统资源(CPU时间,内存)的实体。在现代计算机中所要执行的每一个程序都要被加载到内存之中,被加载到内存中的程序就变成了进程,这样一个程序才算在运行,所以,计算机中所有运行的程序都是进程。
操作系统向下管理的思路
对于操作系统这样一个概念,不同的人有不同的认知,有些人认为操作系统仅指操作系统内核(进程管理,内存管理,文件管理,驱动管理,内核是开机后第一个加载到内存的程序,常驻内存直至关机(如Linux的 vmlinuz 文件)),也有些人认为操作系统包括内核以及其他程序(例如函数库,shell程序等等),但是无论怎样,操作系统的定位就是一款纯正搞管理的软件,操作系统需要对大量的资源进行管理。对于操作系统来说,管理的精髓就是先描述再组织,这种思想同样也可以应用于我们日常的编程当中。什么是先描述再组织呢?即对一个抽象的事物群用一种统一的结构描述出来,再将这些结构组织起来,这样就完成了对这个事物群的管理。拿我们操作系统管理设备来说,计算机的外部设备太多了,每个设备又很不一样,每个设备都有对应的驱动程序会提供大量的信息,我们想要管理,怎么办呢?我们先给定一个结构来描述所有这些设备的进行管理时所必须的且都有的一些信息,在操作系统中,一般就是用结构体来完成,这样,原本杂乱的设备信息就变成了一个个相同结构的结构体了,这时我们再用特定的数据结构比如链表,就可以完成对这些结构体的管理。这样一趟下来,对设备的管理就转换成了对于特定数据结构的管理,对于特定数据结构的管理无非就是对数据结构的增删查改,原本抽象模糊的管理逐渐的清晰起来了,这就是操作系统管理的哲学,我们日常写代码时很多时候也同样是用了这样的思路。
进程管理
PCB
说了操作系统向下管理的思路,对于进程管理,不用说,管理的方式就是先将进程描述出来,在将进程组织起来。那么在操作系统中,进程是如何被描述的呢?答案是进程控制块(process control block)简称PCB,一个进程的信息都被放在这里面,PCB也可以理解成进程属性的集合。一个进程想要运行就必须要有对应的代码和数据,有些人就错误的将进程理解为加载到内存中的代码和运行时的数据,但是实际上,进程应该等于进程PCB+进程对应程序的代码和数据。
再Linux中,进程PCB叫做task_struct,没错,PCB只是一种统称,不同的操作系统中会有不同的称呼,task_struct是PCB的一种,Linux中的task_struct主要包含以下信息:
(1)标示符: 描述本进程的唯一标示符,用来区别其他进程,进程PID。
(2)状态: 任务状态,退出代码,退出信号等。
(3)优先级: 相对于其他进程的优先级。
(4)程序计数器: 程序中即将被执行的下一条指令的地址。
(5)内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
(6)上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
(7)I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
(8)记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
(9)其他信息。
task_struct的组织方式
task_struct在Linux内核以链表的形式存在,但这并不意味着task_struct只以链表的形式组织,task_struct可以同时属于多个数据结构以达到不同的目的,这在操作系统中是很常见的,由此可见,Linux中的各种数据结构和我们所学的纯粹的数据结构完全不一样,是非常错综复杂的。
查看进程
ps
ps指令可以用于查看系统进程信息,拥有众多的选项,一般我们使用
ps -axj
或
ps -aux
就可以显示当前所有的进程信息的快照,两个命令差不多,在显示参数方面有差异,我们还可以搭配管道文件搜索指定的进程信息,
ps -axj | head -1 && ps -axj | grep 指定文件名
head -1打印头行标签方便查看。
top
ps指令只能打印当前进程信息的快照,不能持续更新,而top可以,直接使用
top
指令即可刷屏进入进程实时显示的状态,使用箭头键可以上下翻找,ctrl+c可以推出。
/proc目录
/proc目录是Linux系统以文件系统的方式做的一个系统信息的实时镜像,这其中就包含了进程信息。值得注意的是,这样的一个目录是一个内存级的文件,主机启动时创建,关机时消失,它是不会存到磁盘中的,只会存在内存中,信息实时更新,可以通过它观察系统的各种实时信息。
转进一个进程中可以看到各种进程相关信息,
这些文件本质上是由内核实时生成的、对进程控制块 (PCB) 信息的可读展示,可以看到当中也有cwd(Current Working Directory,当前工作目录)和exe(进程对应可执行文件所在目录),由此可知为什么有很多指令在不指定文件夹时是直接在当前路径下进行操作,因为进程对应的task_struct中存有cwd,所以可以找到默认执行的路径。
创建进程
讲了那么多,我们又应该如何自己创建进程呢?在我们自己的进程中,我们可以使用fork函数进行创建。
pid_t fork(void);
需要注意的是,这里的fork函数并不是c语言提供的库函数了,而是系统提供的系统接口函数。
库函数与系统接口函数
库函数与系统接口函数都属于供应用程序使用的接口,但两者之间还是有很大的区别的。
库函数运行在用户空间,由编程语言的标准库或第三方库提供。而系统调用接口由操作系统内核提供,对于Linux来说,其内核主要由C语言编写,其接口也是c语言式的接口,我们在Linux中使用c或c++写代码时可以直接无障碍地使用这些接口,对于编程语言的库函数,其内部很多也是使用了系统调用的接口来完成对应的操作,因为库函数所在的用户层级是没有权限与操作系统的内核进行交互的,必须使用内核提供的系统调用接口来完成对应的操作,这也是Linux极致系统安全的体现。
fork函数的使用很简单,在想要创建进程的地方写一个就行,我们先写一个简单的代码看一下效果,
可以看到这时我们得到了两个进程,且明明是一段代码,if-else语句却触发了多个,这是怎么回事呢,fork函数究竟干了什么?
父进程与子进程
fork函数的作用是创建进程,更准确地说,是创建子进程,我们用一个进程去创建另一个进程,创建进程的那个就是父进程,被创建的那个就是子进程。在使用ps或top指令查看进程时,我们会发现有两个参数分别是PID和PPID,一个是自己的PID(进程标识符),另一个就是父进程的PID,我们可以使用系统调用接口
pid_t getpid(void);
pid_t getppid(void);
来获取这些信息,可以看到我们子进程的PPID就是父进程的PID,那么问题来了,父进程的PPID又是谁呢?查询后发现是一个叫-bash的进程,bash进程之后会讲到。
bash进程
bash进程是运行Bash Shell程序的进程实例,我们使用Linux操作系统时,由于其是TUI的缘故(不装图形化界面的软件),我们都是使用指令来与操作系统进行交互的,在我们输入指令时,前面都有一行文本,
这就是Bash Shell程序程序打印的,Bash Shell程序就是命令行解释器,它是用户与Linux内核交互的中间人,负责用户命令的解释与执行,我们在bash命令行使用指令,bash就会去系统的路径下面找到对应的程序执行,如果是自己的程序,也是一样,直接执行,执行的程序对应内存中的一个个进程,这些进程就都是bash的子进程,这些子进程结束退出时由bash接受退出状态释放资源。
fork() 会创建一个与父进程几乎完全相同的新进程(子进程)。什么叫做几乎完全相同呢?子进程创建之后会拥有与父进程完全相同的代码,数据也是几乎相同,为什么是几乎呢?因为fork对于父子进程会返回不同的值,对于父进程,如果创建子进程成功,就会返回子进程的PID,失败则会返回-1(系统中进程太多就可能会失败),对于子进程,则只会返回0,这点引起了父子进程的不同,我们可以通过这点不同进一步放大实现父子执行不同的工作,另外,子进程也会继承父进程的进程信息,fork函数之后两个进程都会接着fork函数往后运行而不是子进程从头运行。那么,这究竟是怎么做到的呢?
fork函数的原理
我们都知道每一个在系统中运行的进程都有其对应的PCB结构,fork函数想要创建子进程,首先要做的就是拷贝父进程的PCB结构体,当然这种拷贝并不是简单无脑的直接全部拷贝过来,对于进程的一些唯一标识(PID,PPID(父进程PID)等),系统是会做更新的,除此之外的全部都是和父进程一样的,我们都知道,PCB结构体中是有对应的指针指向代码和数据所在的位置的,这些指针子进程也会一并拷贝,也就是说,代码和数据在拷贝之后是父子共享的,
但这种共享并不会一直保持,对于代码段还好说,一般不会被修改,但是对于数据,由于fork函数的返回值对于父子进程会不一样,这种不一样还会进一步引发更大的变化,所以数据是会变化的,如果还继续共享,进程之间势必会相互影响,要知道,Linux中的进程之间绝对是相互独立的,不管是不是父子关系,那要怎么办呢?这时就会触发写实拷贝,即在一个数据父子进程都不做修改的情况下,共享状态会一直保持,当任意一方想要修改时,就会触发写时拷贝,操作系统会为其分配新的内存空间存储数据。
所以可以想到,当父进程调用fork函数创建子进程时,函数内部创建好进程之后,在返回值时就已经是两个进程了,这时通过条件判断分辨父子进程返回不同的值就完成了一个函数返回两个值的操作。
进程状态
操作系统的理论进程状态
明白了fork函数是怎么返回两个值后,我们还应知道为什么要返回两个值,即子进程创建成功时为什么要返回子进程PID给父进程,返回0给子进程。在这之前,我们需要对进程状态有清晰的认知。
以上就是操作系统理论中的进程可能会有的所有状态。
创建状态:此时的进程刚被创建,但尚未载入内存,内核会为其创建PCB,初始化其中一些数据。
就绪状态:进程已获取除 CPU 外的所有资源,等待调度器分配 CPU。此时进程被插入就绪队列。
运行状态:进程获得 CPU 执行权,指令正在 CPU 上运行,单核 CPU 同一时刻仅一个进程会处于此状态。
阻塞状态:进程因等待外部事件而暂停执行,进程此时会被插入等待队列。
终止状态:进程执行完毕成功退出或出现异常退出,此时进程保留退出状态码等待父进程回收。
在操作系统理论中,进程的就绪状态、阻塞状态都有对应的队列,所有等待运行的进程会被排入就绪队列等待cpu执行,在计算机中,cpu和进程之间是少对多的情况,一个cpu核心同时只能处理一个进程,为了进程执行时cpu资源的公平分配,也为了避免单一进程执行时间过长影响其他进程,cpu执行进程时是采用的时间片机制,即一个进程在cpu上运行有对应的时间片,时间片以内运行完就从cpu上面拿掉,进程直接进入终止状态等待父进程回收,如果时间片之内没有执行完,也要从cpu上拿掉,进入新的就绪队列,这样执行下去,期间新运行的进程会插入新就绪队列,关于就绪队列,其实系统中维护了很多就绪队列,对应了不同的优先级,系统用自己的一套算法维护这些队列,确保cpu资源得到公正的分配,系统通过进程切换的方式实现了所有进程的并发执行。期间如果出现了进程需要等待外部事件的情况,就会将进程插入到对应外部设备的等待队列,Linux中这样的等待队列也是有很多,因为cpu处理进程的速度非常快,时间片都是毫秒级的,和外设根本不在一个量级,所以计算机的进程很多时候都是在阻塞等待状态,当然等待的不只是外部设备,比如进程之间也会等待。
进程挂起
进程挂起是进程的一种特殊的状态,Linux中没有直接对应的状态。当我们的内存存在资源严重不足时,我们的操作系统为了腾出内存空间,就会将内存中一些优先级不高的非活跃进程挂起到磁盘,腾出物理内存,也就是将内存的进程数据和进程上下文(内核栈、寄存器状态等)拷贝到外设也就是磁盘中,磁盘中会有对应的swap分区,进而省出空间处理其他进程,等到进程被唤醒时,再将数据拷回内存。
进程切换
多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。并发是进程无时无刻都在做的事,cpu通过内部的大量寄存器来完成这一操作。进程在运行时,cpu的寄存器保存了进程的不少信息,比如程序计数器pc就是一个记录eip(下一行指令地址)的寄存器,当我们的进程运行满时间片时切换时,这些进程上下文被PCB保存,在之后恢复时再写入对应的寄存器,继续上一次从cpu上拿下来的状态运行,cpu通过这样的方式进行进程切换。
Linux中的实际进程状态
对于Linux的进程状态,我们可以通过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 */
};
可以看到,Linux的进程状态和操作系统理论的是由差别的。
R(running):运行状态,这里的运行状态并不意味着进程一定在cpu上运行,Linux将操作系统理论中的就绪状态与运行状态合并到了一起,其实也好理解,因为cpu太快了,如果将进程在cpu上运行的状态视为R,那怕是只有非常小的概率才能捕捉到这一状态,事实上,即使是将运行状态与就绪状态合并了,R状态在Linux中也是不好捕捉的,因为进程很多时候都是在等待队列等待某种资源的。
S(sleeping):睡眠状态,意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
D(disk sleep):磁盘休眠状态,有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的
进程通常会等待IO的结束。在操作系统进程过多,内存压力过大时操作系统会杀进程,此时操作系统会挑选不会被频繁调用的不重要的进程杀,比如睡眠状态的,如果等待IO的进程也被设为S状态,就有可能被杀,因为操作系统想要确保关键I/O操作正常进行,不想要进程中断进而引发关键数据丢失,所以就规定了D状态,D状态的进程无法被杀,即使是系统自身也不行。一般来说,当主机的D状态到了能被用户观察到时(表示这种状态持续很久了),就表示主机的压力已经非常大了,随时可能崩溃。
T(stopped):停止状态,可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可
以通过发送 SIGCONT 信号让进程继续运行。SIGSTOP和SIGCONT都是信号的一种,Linux中我们可以使用kill指令发送信号。我们在看到一个进程是S时,它一定是等待某种资源,T的话可能是等待资源可能是被控制了。
t(tracing stop):小写t是调试场景下的特殊暂停状态,与T本质相同但用途更特定。比如gdb调试时的暂停状态。S、D、T、t都能看做一种阻塞状态。
X(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
Z (zombie):僵尸状态,这个状态需要详细讲讲。
僵尸进程
什么是僵尸进程,即进程已经结束,但是还没有到达死亡状态被系统回收,为什么一个进程已经结束却不让它死亡呢?这就好比一个人突然死亡,但是不会直接入土一样,我们需要先对其进行调查,搞明白死因是什么?是自然死亡还是异常死亡,再将这些信息通知给关心这些信息的人也就是死者的家人。套用在进程上也是完全一致,一个进程结束了,进程的结束信息会被保存在PCB中,我们若直接让其死亡被系统回收释放资源,我们就无法知道进程的退出状态,假如进程出了问题发生异常退出,我们也无法得知及时做出措施补救,所以我们有必要在进程结束时不让它立即死亡,而是一致保持僵尸状态等待父进程来读取退出状态码。当然,僵尸进程进程也不是让进程完全不释放任何资源,进程结束时只会保留父进程要获取的进程退出信息,其他没用的都是会释放的,这样保证了父进程能获取到进程推出信息,也节省了空间。但是,我们应该要注意,如果父进程一直不去回收僵尸进程,那它就会一直存在在内存中,占用内存资源,这就会变成一种内存泄漏,我们自己在创建子进程时一定要注意释放。
下面我写了一段子进程先退出(使用exit函数)的情况,可以看到子进程处于僵尸状态,具体怎么回收下面会讲。
孤儿进程
什么是孤儿进程,我们都知道进程有父子之分,孤儿进程,顾名思义,就是没有父进程的进程,什么情况下会出现这种情况呢?当我们出现父进程先于子进程退出时子进程就会变成孤儿进程,我们写一段代码来看看这种情况,
我们让父进程先于子进程退出,看看会发生什么,
可以发现子进程和父进程同时运行时,子进程的ppid是父进程,这还正常,等到父进程结束时,子进程的ppid就变成了1,1又是什么进程的pid呢?我们查一查,
可以看到进程名是/usr/lib/systemd,这是init进程,是系统启动过程中创建的第一个用户空间进程,由内核在引导后期直接启动。它的核心职责是初始化系统环境并托管所有孤儿进程。我们可以将其理解为系统自身,也就是父进程退出抛弃子进程之后,子进程被系统领养了,之后子进程结束退出时,由系统接受子进程退出状态,释放子进程资源,因而子进程不会一直处于僵尸状态占用内存。那为什么不将其托管给bash进程呢?首先我们应该明白,进程之间只有父进程会对子进程负责,一旦跨级就没关系了,进程没有爷孙之称,不是bash的子进程bash没义务也没能力管,而这个init进程就不一样了,它属于内核,可以认为无所不能,不管什么进程,都能托管回收。
进程前面的+是什么
我们在使用ps指令查看进程状态时,有时会看到进程状态前面有一个+,
这是什么意思呢?这表示我们的进程在前台运行,没有则表示在后台运行。前台运行和后台运行意味着什么呢?有人可能会认为,我们的进程不断在命令行终端打印文本,这就表示程序在前台运行,没有就是在后台运行,其实不然,其实我们把打印的代码去掉,只让程序死循环,进程状态还是前台运行,
这前台运行是指进程启动之后立刻接管终端窗口,此时shell进程处于阻塞状态,shell会一直等待进程结束才能输入新命令,期间输入被进程独占,此时的进程直接响应键盘输入,接受通过命令行直接发送信号(如ctrl+c)。而后台进程则与前台进程相反,后台进程支持输出打印到终端(可能干扰输入),建议重定向到文件。
可以看到我们通过在指令后面加上&的方式让程序指定运行在后台,此时仍能向命令行打印。我们若在前台进程运行期间使用指令,shell命令行解释器是没有反应的,但是后台进程运行时,即使一直在向命令行打印,指令输入也是有反应的。
说了那么多,我们这是也就能回答之前fork函数的问题了,即为什么fork函数要对父进程返回子进程的pid,对子进程返回0。在Linux中不存在孙子进程的概念,进程只对其子进程负责,所以对于子进程来说,它不必知道信息,而父进程有必要知道子进程的信息,用来接受子进程的退出状态,至于怎么知道的,这个之后才讲。
进程优先级
我们都知道进程与cpu在计算机中是多对少的关系,而进程与进程之间亦有差异,有的进程重要,需要有限执行,有的进程则不需要。操作系统对于这种需求退出了进程优先级的方案,即对进程给出一个指标也就是优先级,优先级高的优先执行。我们在使用指令
ps -l
指令时,会看到
很多参数,其中值得注意的是PRI和NI参数,PRI其实就是priority(优先)的缩写,数值越高,优先级越低,NI是nice的缩写,nice值会被加到PRI上形成最后的PRI值,PRI的值一直都不会变,这里我们系统默认是80,那我们的PRI其实一直都是80,对PRI的修改其实是对NI值的修改,最后加上NI形成最后的PRI,我们这里看到的是最后形成的PRI,这里因为没有修改过,所以是默认的80,当我们修改时,
就会发现NI有值了,这里我们top采用的又是另一个默认值20了,其实什么默认值都一样,这些值最终都要拿来比较,默认值一变,大家都变,比的是相对大小,不会有影响。我们注意到PRI的值也变了,正好减掉NI值就是默认的20,我之前一直强调PRI的值不会变这点,是因为当我们修改PRI值时,改的都是NI,PRI并不是每次修改NI都会累加上NI值,这样PRI值会一直变大,我们把NI设成10,PRI就是30,再设成19,就是39,PRI一致都是以默认值加上来的,NI值本身又有限制(NI值范围是-20到19),这两套组合拳下来,将PRI的值范围紧紧地限制住了,这是为了限制用户过度修改进程优先级破坏系统秩序,下面介绍一些改变进程优先级的方法。
nice
nice -n NI值 指令
这是再运行指令前指定进程NI值,-n表示设置具体nice值。
renice
renice -n 20 -p 进程pid
-n:指定新的nice值
-p:指定目标进程ID(PID)
-u:指定目标用户
-g:指定进程组ID
renice可以修改运行的进程的nice值。注意修改进程优先级需要root权限。
top内修改优先级
top可以监视进程信息,在top内部,我们也是可以修改的,我们在top中按r键表示renice,之后输入对应的pid,在输入对应的NI就行。
最后想说的是,虽然我介绍了对应的进程优先级的修改方式,但是我是不推荐初学者修改进程优先级的,初学者尝试修改进程优先级极大概率是帮倒忙。Linux系统中有一套非常公平完善的进程调度方式,操作系统理论是给不同优先级的进程都设置了队列,cpu先执行优先级高的队列,进程在执行过程中有对应的奖励惩罚算法动态调整优先级,维护进程运行的公平与稳定,Linux系统则采用了更加先进的CFS算法,采用红黑树搭配进程优先级权重实现更为公平稳定的进程运行环境,我们一般情况下完全不用去考虑进程优先级的问题,改变优先级的操作一般是用不到的,而且我们也看到,Linux改变优先级本身就收到了种种限制,说明操作系统本身也是希望我们尽量不修改的。
环境变量
我们在命令行运行自己写的程序时,不能直接使用可执行程序的名字,而是在前面加上./(假设在当前目录)指定路径,而我们使用指令时却可以直接使用,要知道指令也是可执行程序,它们存在系统指定的路径下,这是为什么呢?答案是环境变量PATH记录了指令的系统路径,我们在执行这些指令时,由于没有路径,shell就会去环境变量PATH路径下找,找到了就执行,没找到就显示command not found(找不到命令)。我们使用指令
echo $环境变量
打印指定环境变量,echo指令加上$表示打印变量名对应的值而不是直接打印字符串。
我们可以试着将一些路径加入PATH环境变量,这样执行那些路径下的可执行文件时就不用指定目录了,
这里注意以PATH=的方式是直接覆盖的,我们在前面加上$PATH:就行,因为可以看到PATH中的路径之间都是以:分割的。
除了PATH之外,我们还想看看其他的环境变量,怎么做呢?使用指令
env
查看所有环境变量
getenv
环境变量是每一个进程都有的,而且,环境变量是可以继承的,子进程在创建时可以获得父进程的所有环境变量,那么我们在进程内部想要获取这些变量要怎么做呢?我们可以使用函数
char *getenv(const char *name);
main函数的参数
我们的main函数其实是可以加参数的(只不过如果不是系统编程的话用不到),因为main函数并不是程序启动时调用的第一个函数,main函数也要被startup函数调用,startup函数负责c/c++程序启动时的初始化工作,包括了给main函数传参。那么我们的main可以加什么参数呢?
int main(int argc, char *argv[]);
这两个参数有什么什么用呢?我们都知道我们使用指令时是可以使用选项的,这些选项是怎么被系统识别的呢,其实就是靠这两个参数,怎么做到的呢?我们可以试着把这个字符串数组打印出来,
可以看到,虽然我们的程序没有参数,但我还是加了几个,这时会发现字符串数组中的内容就是我打的命令,有空格的地方被分割了。没错,两个参数第一个记录了第二个字符串数组的尺寸,第二个数组以空格为分隔符存储了命令行的指令,这样我们就能利用这两个参数实现对选项的响应。
除了这两个参数以外,Linux系统还有一个扩展的第三个参数,是非C标准的。它就是
int main(int argc, char* argv[], char* envp[]);
它可以被系统识别传入全部的环境变量,我们可以利用它打印全部的环境变量,我们可以利用数组以NULL结尾来打印,argv也可以的,这里的三个变量名可以随便取,只不过我这种是标准的取法。
environ
我们除了main函数的第三个参数和getenv之外,还可以使用声明变量environ的方式获取环境变量表的指针
extern char **environ;
C库的启动代码(如 _start)会从内核获取环境变量表地址,并将其赋值给全局变量environ,我们先要使用只要extern声明就行。
我们实际使用时用getenv和environ就行,main函数第三个参数是非c标准的,跨平台可能会出问题。需要注意,这三种修改方式都只是暂时性的修改,重新登陆主机时就会失效,bash的环境变量被保存在配置文件中,启动时才会载入,我们只是修改了内存中的,等到进程重启时配置文件载入,又是原来的样子了,我们要是想要永久修改就要更改对应的配置文件。
本地变量VS环境变量
我们想要给shell命令行加环境变量,怎么做呢?我们可以试着直接定义
我们可以发现,定义的变量可以被echo打印,但是不能被env打印,这是为什么呢?因为我们直接这么定义的变量不叫环境变量,只是本地变量,环境变量和本地变量的区别是环境变量可以被继承但本地变量不能,我们使用指令
set
可以查看所有的环境变量和本地变量,
这里打印的变量太多我截不全,但是可以看到我定义的My_Value被打印了出来。那么如果我想要定义一个环境变量,我要怎么做呢?我们可以使用指令
export 要定义的变量
来实现
export是导出的意思,我们可以用它来设置环境变量,上面是同时定义且导出,我们也能对一个本地变量导出,即将一个本地变量变成环境变量。
export 要导出的变量
如果我们想要删除一个环境变量或本地变量,我们可以使用指令
unset 想要删除的变量
内建命令
我们都知道环境变量能继承而本地变量不能,那么我们究竟是怎么用echo指令打印本地变量的呢?如果echo是bash的子进程,那么echo是无法获得bash的本地变量的,这究竟是怎么回事呢?其实,echo不是普通的指令,它是内建命令,内建命令在执行时并不是由bash创建子进程来完成,而是由bash自己直接完成,有可能是自己写的函数,也有可能是系统提供的系统调用接口。类似echo这样的内建命令还有不少,常见的还有我们的cd指令,我们都知道,每个进程都有自己的cwd(当前工作目录),cd指令如果是普通指令,那么cd只会把自己这个进程的cwd改变,通过系统的系统调用接口
int chdir(const char *path);
来完成,因为进程之间相互独立,cd进程无法影响bash进程的cwd,但我们又都知道cd指令是改变shell的目录,也就是bash的cwd,所以可以确定,cd也是内建命令。
进程地址空间
我们都知道,我们的程序在运行时内存划分成
我们可以写个程序验证一下
我们这时写一个fork函数创建子进程的程序出来,
这时我们惊奇的发现,父子进程的ret值的地址是一样的,但是我们在前面说过了,fork函数会给父进程返回子进程的PID,给子进程返回0,而父进程与子进程在一开始是共享代码和数据的,但是当一方要修改数据时,就会写实拷贝要修改的数据,维持进程的独立性,所以按照理论,父子进程的ret变量的地址应该不一样才对,可是为什么地址是一样的呢,一样的地址又怎么会有不一样的值呢?要知道,我们的物理地址是绝对不会重复的,存进内存中的值也不会变样,那么答案只有一个:我们进程所使用的,不是实际物理地址!!!。
mm_struct
事实上,我们进程使用的都是虚拟地址,是的,我们日常写的程序所使用的指针,打印的地址,都不是实际的物理地址,都是虚拟的。我们的系统在进程创建时,会为进程分配进程地址空间,进程地址空间是操作系统为每个运行中的进程分配的虚拟内存范围,它定义了进程可访问的内存布局,包括代码、数据、堆、栈等区域。Linux中进程地址空间有对应的结构体来描述它,他就是mm_struct,这个结构体对进程地址空间各区域的范围进行了划分,划分范围的空间中的任意地址都可以使用。
struct mm_struct {
struct {
// 指向虚拟内存区域(VMA)链表头
struct vm_area_struct *mmap; /* VMA链表 [1,2,6](@ref) */
// 指向VMA红黑树根节点(优化查找)
struct rb_root mm_rb; /* VMA红黑树 [2,3](@ref) */
// 最近访问的VMA缓存(基于局部性原理优化)
struct vm_area_struct *mmap_cache; /* 最近访问的VMA [3](@ref) */
// 用户空间内存映射基地址(如mmap区域起始)
unsigned long mmap_base; /* mmap映射区基址 [1,3](@ref) */
// 进程用户空间大小(64位系统通常为128TB)
unsigned long task_size; /* 用户空间总大小 [1,6](@ref) */
// 页全局目录(PGD),即进程页表根
pgd_t *pgd; /* 页表目录指针 [1,2,6](@ref) */
// 共享该地址空间的进程数(如线程数)
atomic_t mm_users; /* 共享进程数 [2,6](@ref) */
// mm_struct自身的引用计数(归零时释放结构体)
atomic_t mm_count; /* 结构体引用计数 [2,3,6](@ref) */
// VMA数量
int map_count; /* VMA区域数量 [1,3](@ref) */
// 保护VMA和页表的同步机制
struct rw_semaphore mmap_sem; /* VMA读写信号量 [6](@ref) */
spinlock_t page_table_lock; /* 页表自旋锁 [2](@ref) */
// 链接所有mm_struct的双向链表(头节点为init_mm)
struct list_head mmlist; /* 全局mm_struct链表 [2,3](@ref) */
/* 内存区域统计 */
unsigned long total_vm; /* 总映射页数 [1,2](@ref) */
unsigned long locked_vm; /* 不可换出的页数 */
unsigned long exec_vm; /* 可执行映射页数 */
unsigned long stack_vm; /* 栈区页数 [2](@ref) */
/* 关键地址范围描述 */
unsigned long start_code, end_code; /* 代码段起止 [2,7](@ref) */
unsigned long start_data, end_data; /* 数据段起止 [1,7](@ref) */
unsigned long start_brk, brk; /* 堆区起止 [1,2](@ref) */
unsigned long start_stack; /* 栈起始地址 [2](@ref) */
/* 命令行参数与环境变量 */
unsigned long arg_start, arg_end; /* 命令行参数起止 */
unsigned long env_start, env_end; /* 环境变量起止 [3](@ref) */
/* 架构相关扩展 */
mm_context_t context; /* 架构特定上下文(如ASID)[3](@ref) */
/* 高级特性支持 */
#ifdef CONFIG_MEMORY_FAILURE
atomic_long_t _cpupad; /* 内存错误处理 */
#endif
atomic_t tlb_flush_pending; /* TLB刷新状态 [1](@ref) */
} __randomize_layout;
};
但是划分归划分,虚拟的空间终究还是要和物理地址对接上才行,毕竟我们的程序运行还是要在物理内存上跑的,凭空捏造的虚拟内存可跑不了。我们的进程地址空间要怎么和实际物理内存对接上呢?答案是页表.
页表
页表是操作系统中实现虚拟内存管理的核心数据结构,负责将进程使用的虚拟地址转换为物理内存的实际物理地址。页表将进程虚拟地址空间的地址与实际开辟的物理地址之间建立映射关系,同时给对应的地址加上一些标记(利用位图这样的数据结构),如对应的物理内存时能不能读取,能不能写入,这样的标记就实现了我们进程地址空间中的各区域的特性,比如代码段的数据就不能写更改(通常),只能读取。这也就解释了我们的常量区代码段明明不能修改但却能初始化的原因,哪有什么不能修改,内存都是一样的内存,载入数据时都没啥问题,但是载入完页表一标记,只读,你就不能修改了。再比如我们还能给对应的虚拟地址加上是否加载的标识符来表示实际数据是否从外设磁盘加载到内存中了,比如我们的进程被挂起了,又或者原本就没必要全部加载进来,这里就得谈到一个惰性加载的概念。
惰性加载
我们日常会晚一些游戏的人都知道,现在的游戏很多都很大,远远超出我们内存的空间大小,可是我们还是能实际运行,这就是内存的惰性加载机制,即会用到的数据才加载,暂时用不到的就先不加载,对应在页表上,我们对这样的数据会分配好虚拟地址,但是没有物理地址,然后会在对应的标记位上标记,这是当我们需要这段数据时,就会触发缺页中断,这时进程阻塞,操作系统会帮我们重新申请内存加载数据,将对应页表的物理内存填上,再继续运行。对于父子进程修改共享数据时也差不多,我们也是给了一个只读标记位,当我们要对标记数据修改时,触发类似缺页中断,系统会额外申请内存储存数据。
我们mm_sruct对应的页表的地址,在进程运行时会进行高频访问,这时我们就会将其拷贝到寄存器cr3中,因为寄存器的速度足够快,等到进程切换时在拷回去。这样,我们的进程实际上是 task_struct(PCB) -> mm_struct -> 页表 -> 物理内存 的结构。我们之前对进程的描述也可以改一改,进程 = 内核数据结构(task_struct(PCB) + mm_struct + 页表)+ 程序代码和数据。
进程地址空间的意义
将进程放在进程地址空间中,我们可以利用mm_struct中对进程中各区域详细的划分和页表中的各种标记来对越界访问等违规动作做及时拦截,保护物理内存。同时mm_struct、页表的存在使我们能将进程管理板块、内存管理板块分开,我们在管理任意的进程时都能以统一的视角看待内存,无需考虑内存问题,我们在管理内存时也不用考虑进程问题,只需要和页表对接就行,这极大地降低了系统的耦合度,将系统板块化,降低了维护成本,提升了效率。同时,由于进程虚拟地址空间的存在,实际物理内存申请时可以利用碎片空间,因为有页表映射,不必申请连续线性的空间,因为内存空间都是频繁申请释放的,空间一般都很碎,用一段连续的内存换取碎片的物理内存,极大提升了内存利用率。 32位系统内存最大就4GB,我们为每个进程也是划分4GB的空间,因为其地址总线的宽度为32,寻址能力最大是4GB,我们给进程虚拟地址空间划分4GB,实际是用不到的,但是这给了每个进程最大的发挥空间,进程地址空间中的每个地址进程都能访问,系统划分多个4GB空间进程,实现了内存超量使用,实际使用各种优化手段维持进程运行,保证进程以统一视角看内存并尽可能跑满内存,实现了内存的最高效使用。
到这里,我们就能解释为什么地址一样但是数据不一样了,我们子进程创建时,拷贝了父进程的task_struct、mm_struct、页表(会改一些数据),这样两个页表还是共享物理内存,对应的数据会被标记上只读,当我们要修改时,触发缺页中断,此时进行写实拷贝,系统会额外申请空间储存数据,申请完之后更新页表对应虚拟地址映射的物理进程,再将父子两个页表中被修改的对应数据的虚拟地址的只读标记去掉,之后就能随意对这两个数据随意修改了,此时就变成了同样的虚拟地址对应不同的物理地址,进而变成了我们所看到的,同样的地址对应不同的值这种令人费解的状况了。
进程控制
进程创建
进程创建我们使用系统调用接口
pid_t fork(void);
这个我在之前已经讲过了,我们是通过fork函数引申出进程管理的一系列问题的,fork函数串联起了我之前的内容,这个函数的相关问题都分析过一遍了,不做过多赘述。
进程终止
一个程序总有跑完的时候,程序退出的方式可以分为:
(1)程序跑完,结果正确
(2)程序跑完,结果不正确
(3)程序异常退出
我们在写c/c++程序,都会在main函数结尾写return 0,为什么要这么写呢?这里返回的就是我们的进程退出状态,这个状态码会被父进程获得,从而得知子进程的退出状态,因为父进程需要为子进程负责。而return 0这个0就是退出状态中表示进程正常跑完结果正确的意思。除了0之外的所有退出状态都是表示进程执行时出现了错误的意思,所以我们又叫它错误码,我们可以通过函数
char *strerror(int errnum);
来打印错误码对应的信息,这里我们写了一段代码来打印所有的错误码对应的错误信息
这里虽然给了200的范围,但是错误码到134就是未知错误了,所以标准定义的错误码信息只有133个(不含成功退出的0)。在c/c++程序中,我们还能使用函数
void perror(const char *s);
来打印最近一次错误的信息,这个函数需要依赖一个全局变量errno,引入这个全局变量需要头文<errno.h>,当我们的程序的某一步出现问题时,就会将对应的错误码写入errno,这时我们应及时处理,因为作为全局变量,errno会在下次错误发生时被写入覆盖,我们最好是在错误可能发生的位置下面就写一个perror及时打印,下面我写一段代码演示一下
我们还能通过指令
echo $?
打印最近一个通过bash运行的程序的错误码
通过之前的退出码打印我们可以知道,2对应的就是No such file or directory,如果我们再次使用这个指令
可以发现最近一个通过bash运行的程序的错误码变成了0,这是因为这时最近一个通过bash运行的程序变成了echo,echo是运行结果正确退出的。
exit
除了可以使用return返回错误码终止程序,我们还可以使用exit函数终止进程
void exit(int status);
exit内部写对应的错误码,这个错误码同样可以被父进程接受。事实上,main函数的return等同于exit,因为调用main的运行时函数会将main的返回值当做 exit的参数。
_exit
和exit函数一样,_exit同样可以终止进程,
void _exit(int status);
但是与exit不同的是,_exit是系统调用接口,所以,其实exit是对_exit封装而来的。除此之外,exit和_exit还有一些区别,我们可以通过一段代码来演示
这时我们发现只有子进程打印了字符串,如果我们在打印的字符串后面加上\n
这时两个都打印了出来了,为什么呢?我们在打印内容时,内容会先输出到缓冲区,而\n可以刷新缓冲区将内容输出到标准输出流,所以在有\n时两个进程都能打印,而如果没有\n,就只有用exit函数退出的才打印了。这是因为exit函数刷新了缓冲区,而_exit没有。事实上,不仅如此,exit还做了很多_exit没做的事,比如关闭所有打开的文件描述符、删除临时文件等,这些事我们使用return结束进程也是会自动执行的,而_exit不会,这就是系统调用的特点,绝不做多余的事,终止进程就只是终止进程,极致效率,我们也能通过这个明白IO缓冲区绝不是内核维护的,因为如果内核花精力维护了,这里调用_exit就绝不会对缓冲区不管不顾了。
进程等待
之前我们说过僵尸进程,子进程退出时,如果父进程不管,就会一直保持僵尸进程状态,占用内存,造成内存泄漏。那么父进程到底要怎么做才能获取子进程退出信息让子进程释放资源被系统回收呢?答案是通过进程等待。
wait
父进程可以使用
pid_t wait(int *stat_loc);
对子进程进行进程等待,wait函数等待成功时返回子进程PID,失败时返回-1,wait函数的参数是一个输出型参数,即传参不是为了给函数什么,而是指望函数返回一些信息,这里我们需要自己创建一个int变量传进去,等待成功时会将子进程的退出码(和return、exit、_exit返回的错误码不一样,我们之后会细讲)写入这个变量当中,当然,如果我们根本不想关心子进程的退出状态,只想让子进程死去,我们可以直接传个NULL进去,这里我写了一段代码来演示
可以看到子进程确实是退出了。那如果是有多个子进程怎么办呢?我也写了一段代码
可以看到,当父进程面对多个僵尸状态子进程时,父进程还是对最先结束的子进程进行了等待,让其死亡的,这是因为我们系统是会维护一个僵尸进程队列的,FIFO,父进程等待时会从队列中挑选最先僵尸进入队列的进程。当然事实上我们要等待还是全部等待回收了好,一个循环就能做到。
waitpid
waitpid作用和wait函数一样,都是等待进程,但是waitpid的功能更多一点,
pid_t waitpid(pid_t pid, int *stat_loc, int options);
可以看到,waitpid有三个参数,首先返回值还是和wait函数一样,等待成功会返回子进程的PID,失败会返回-1,再说第一个参数,第一个参数可以指定PID进行等待,而不是像wait函数一样从僵尸进程队列中取,但是如果我们还是想要像wait函数一样,从僵尸进程队列中取,我们可以给这个参数传-1,我们再看第二个参数,和wait函数一样,输出型参数,传入的参数我们自己创建,等待成功会给这个参数写入对应子进程的退出码,第三个参数则可以选择函数的等待模式,这个需要详细说。
非阻塞轮询
我们的wait函数可以用来等待子进程,可子进程只有退出处于僵尸状态时才能被等待,若子进程在父进程等待时还没有跑完,父进程就只能一直阻塞等待下去,若子进程一直不跑完,父进程就得一直等待,听着挺蠢的。那么我们有什么办法吗?有,我们可以在waitpid函数中更改等待模式,也就是waitpid的第三个参数。当我们给waitpid函数的第三个参数传0时,waitpid采用的就是和wait函数一样的默认阻塞模式,无子进程退出时父进程休眠。我们也能将等待行为改为WNOHANG模式,在hang在英语中有挂的意思,对应到操作系统中有因“阻塞”导致系统或进程停止响应的现象(挂起),而WNOHANG中的NOHANG就是指不挂起也就是不阻塞,W是等待的意思,所以整体就是说非阻塞式等待的意思。我们将waitpid改成非阻塞式等待的模式呢?直接将WNOHANG传进去就行,WNOHANG是一个宏,是预先定义好的,我们直接用就行,WNOHANG对应的值一般是1,但其实我们无需关心,直接用就行。那么说了这么多,非阻塞式等待的方式是什么呢?当我们指定非阻塞式等待时,函数指定PID的进程若没有结束,则函数返回0,不予以等待。若正常结束,则返回该子进程的ID。调用中出错则返回-1。我们就可以使用这种方式对一个进程一直等待,也就是一直查看指定进程有没有结束,结束就等待成功了返回PID了,没结束就继续等,一直等到进程结束为止。这里我写了一段代码演示一下
当然了,我们这样做没有把非阻塞式轮询的优势发挥出来,因为我们还是一直在等待,期间没有做其他事,这样的话和阻塞式等待是一样的,因为非阻塞式等待的特点就是不会阻塞,调用函数了就看一下子进程有没有结束,没有立刻返回0,结束了就接收结束信息等待成功了,所以我们可以在等待的同时做一些其他的工作,这才是我们选择非阻塞轮询的意义。
进程退出码
我们使用wait和waitpid函数时都要给函数传一个输出型参数status,我们说了这个参数会在等待成功时被写入进程退出状态码,这个和我们认知的进程退出码是不一样的。我们都知道进程退出有三种情况:进程运行结果正确退出、进程运行结果错误退出、进程运行异常退出。我们此前一直说的都是进程运行能正常退出的情况,不管结果正不正确,进程也是会运行异常退出的,当进程遇到了比较严重的错误时(如除0错误,段错误(空指针)),系统会向该进程发送信号,直接终止该进程,这就是我们所说的进程异常退出,当我们进程出现异常退出时,就没有程序退出码了,就算有也不能相信,因为出问题的进程得出的结果也会有问题,不能轻易相信。那么我们这时应该怎么得知进程的退出信息呢?之前也说过,系统是通过信号杀死进程的,我们通过发送信号的不同查看进程的异常原因。
kill
kill指令是linux中的一个非常实用的指令,可以用来发送信号,使用指令
kill -l
可以查看可以发送信号的种类
我们使用该表上的信号对应的数字就能发送对应的信号了,我写了一段代码演示一下
我故意写了两段错误代码,可以看到,系统运行这种严重错误的代码时是发送信号直接终止的,发送信号之后还将对应的信号代表的意思打印了出来,我们可以在上面的信号表中找到对应的,除0错误对应SIGFPE信号(8号信号),空指针对应SIGSEGV信号(11号信号)。我们怎么验证呢?我们可以直接给一个原本正确的进程发送信号看看会怎么样,使用指令
kill -信号对应的数字 进程PID
就能对指定进程发送信号了,
可以看到,我们通过这些信号成功的终止了进程,打印的字符串也是一样的。
进程退出码
这样我们就明白了,要等待一个进程,会有两种情况,一种是它正常跑完了,返回退出状态,一种是进程异常,我们这时应该要获取终止它的信号。所以,我们的进程退出码实际上是考虑到了这点的,我们的进程退出码实际上是这样组成的
进程推出码是pid_t,实际上就是int,int有32位,但我们只用前16位,我们用高8位(15~8)存退出状态,这样存储的范围是0~255,我们系统规定的是0~133,绰绰有余了。我们用低7位(7~0)存终止信号,这样存储的范围是0~127,系统规定的信号是1~64,同样绰绰有余。同时我们发现信号是没有0信号的,所以我们就能通过这个判断到底是进程异常退出还是进程成功退出了,即如果低7位为0,因为没有0信号,所以就是正常退出,这是我们可以读取高8位的进程退出状态,如果低7位不为0,进程就是异常退出了,我们读取对应的信号查找原因。core dump这里可以忽略,它有其他的用途,在本章我们不对其做说明。我们写一段代码提取一下进程退出码
可以看到我们通过位运算确实获得了进程的退出状态。当然实际运算时我们可以不用这么麻烦,因为系统为我们提供了宏函数,我们可以利用宏函数方便快捷的进行提取:
WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)。
WEXITSTATUS(status):若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)。
WIFSIGNALED(status):检测子进程是否因未捕获的信号终止(如 SIGSEGV、SIGKILL)。
WTERMSIG(status):返回终止子进程的信号编号(如 11对应 SIGSEGV)。
通过这几个宏函数我们就能直接提取了,还是很方便的。
进程替换
我们在使用fork函数创建子进程时,是否想过这种方式很难受,因为我们必须要在一段代码中同时写出父子进程要做的事,很麻烦,而且有时候这种方式也具有局限性,有些逻辑写不出来,很难受,我们能不能创建一个进程,然后让这个进程执行别的可执行文件的代码呢?可以的!我们可以使用exec系列的函数来完成进程替换的工作,
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
单进程替换
这个系列的函数很多,看着就难用,但其实认真了解过之后会发现很好用的。我们先详细讲一讲第一个函数,execl的第一个参数是要执行的可执行文件的路径和文件名,之后的参数是以怎样的方式执行这个文件,我写了一段代码演示一下
参数的书写方式就像上面一样,第一个写出可执行文件的路径,绝对或者相对都行,表示文件所在路径和文件名,后面的可变参数列表是我们执行程序的方式,这里我选了系统指令来执行,看得更明白一点,就是我们在命令行上怎么写的这里就怎么写,以空格分隔开形成一个个参数,这里语法规定一定要以NULL结尾。我们观察执行代码的结果,我们会发现替换的指令确实被执行了出来,而且我们还发现源代码后一句的打印没有被执行,这是怎么一回事呢?事实上,当我们执行execl时,这个函数会将要替换的程序的代码数据写入进程中,从而达到进程替换的目的,这时,进程原本的代码数据已经被覆盖替换,execl函数后面的代码自然就不会被执行了,而且我们应该明白,在代码数据替换成功时,我们的execl函数是不会返回值的,因为已经没有对应的代码逻辑能让它返回的了,而如果我们的函数执行失败,进程替换失败了,execl则会返回-1,这是源代码execl后面的代码逻辑会被执行。这时有些读者可能会疑问,尽然连代码也能写入覆盖吗?是的,我们自己写代码时不能向代码段写入是因为我们是用户,系统内核则是可以做到,我们在进程地址空间中讲过,哪有什么不能写入的区域,那些都只是系统在页表上标记了只读不让我们用户写入维护系统安全而已,实际上内存都是一样的内存,没有什么只能写入的内存,内核要是想写入,像这里的代码数据,都是能写入的。
多进程替换
我们试过了单个进程替换,现在我们来试试多进程替换,实际上多进程替换才是我们用得比较多的。这里同样写了一段代码,
可以看到我们对应的子进程被替换了,但是父进程没有被替换。这就表示我们的父子没有共享代码和数据,这也好理解,根据我们之前讲的子进程创建的原理,父子进程原本共享代码和数据,进程数据结构也是子进程拷贝的父进程做了一些修改的来的,在我们数据和代码写入替换时,因为其在页表上有只读标记,这就触发了缺页中断,引发写实拷贝,子进程额外申请空间存储替换程序的数据和代码,从而保证了进程之间的独立性。
子进程在进程替换之后仍然与父进程构成父子关系吗?我们的子进程在进程替换之后PID有没有变呢?我们可以写一段代码验证一下,这里我们就不能使用系统的指令了,我们自己另写一个程序,正好演示一下用自己写的程序替换的场景
这里有一点要注意,我们execl函数第二个参数是tmp_1,而不是./tmp_1,为什么,很多人认为我们执行时在命令行就是输./tmp_1的,这里为什么可以不用写,这里要明确,我们在命令行写./是因为我们的可执行程序的路径不在环境变量PATH中,所以系统会找不到,如果我们使用指令我们也不用指明路径因为系统找得到,这里我们第一个参数已经标明程序位置了,所以不用再重复写了。
可以看到替换前后进程PID是一样的,父进程也是没有变,这就表明进程替换并不会改变PID,父子关系也没变,这就像灵魂替换一样,人还是同样的人,只是灵魂不一样了。进程替换不改父子关系和PID就意味着子进程结束时父进程仍然需要对其进行等待回收资源,由于PID没有变,倒也不麻烦。
ELF文件头
我们在进程替换时,cpu是如何得知程序的入口的呢?答案是ELF文件头,我们的可执行程序并不是杂乱无章的,每个可执行程序都有一个ELF文件头记录了程序的各种信息,其中就包括了程序入口,当我们使用exec系函数时,函数会对ELF文件头进行解析,找到文件入口,顺利运行程序。
实际上我们的execl系函数不止能替换c/c++的程序,或者说,任何想要在Linux系统中运行的程序,都能作为替换程序,因为我们Linux创建进程就是靠fork函数+exec系函数实现的。Linux系统中除了最初的init进程可以说没有父进程,剩下的所有进程都有父进程,这些进程都是靠exec系函数创建的,只要你想运行,你就必须能替换。这里我写了一段用python程序替换的代码
由于python是脚本语言,所以实际运行的是python的解释器,test.py本身其实是写指令的文本文件。
环境变量
对于环境变量来说,进程的替换会对其有影响吗?我写了一段代码演示
这里我用了putenv函数
int putenv(char *string);
我们可以用这个函数给进程追加环境变量,
putenv("XXX=XXX");
追加成功返回0,失败返回-1。我们也能用这个函数置空环境变量,
putenv("XXX=");
可以看到这里我追加了环境变量,追加的换进变量也被替换后的子进程打印了出来,这表明子进程替换不会改变环境变量,子进程页表的环境变量表映射还是指向和父进程共享的那一块。当我们要修改环境变量表时会触发写实拷贝。
其他的exec系函数
接下来讲讲其他的exec系函数。
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
我们发现exec系列函数的前面都是exec,只是后面的字母不一样,没错,这些字母直接决定了它这个函数的传参方式,我们接下来详细讲一讲。
l和v
l就是list的意思,v就是vector的意思,函数名中有l就表示它是可变参数列表,需要我们一个个传执行方式进去,最后还要以NULL结尾,而vector则是直接传字符串数组进去,数组内是程序的执行方式,数组同样以NULL结尾,我们写一段代码演示一下
这里可以用常量字符串直接初始化,可能是编译器管的比较松,直接过了,不然只能用字符数组初始化了。实际上,我们使用含l函数传可变参数时,函数内部还是将其转换成了字符串数组的。
p
p表示path,当我们使用含p字母的exec函数时,我们在写文件路径和名称时可以不写路径只写名字,这个函数可以自动在环境变量PATH中的路径中寻找指定文件,我们写一段代码演示一下
当然我们要是用了p命令要是写了不在环境变量PATH路径下的可执行文件还没有指定路径的话会导致进程替换失败。
e
e表示environment,就是环境变量的意思,我们的环境变量这个参数就像main函数的第三个参数一样,其实,就算我们不去调用含e字母的exec函数,环境变量还是会继承下来,就像我们即使不用给main函数传参,使用getenv或environ同样能获取环境变量。那我们为什么还要写这个参数呢?当我们想要完全定制传递给子进程的环境变量时,就可以对这个参数传我们自己创建的环境变量表,触发写实拷贝完全覆盖环境变量表。
环境变量表同样需要以NULL结尾。
execve
exec系列函数只有函数execve是系统调用接口,其他所有的exec系函数都是对execve函数的进一步封装。execve我之前没有列出,因为其在手册2中,系统调用接口都在手册2中。我之前说我们虽然传的是list,但是最终还是会转换成vector的原因,就是因为系统调用接口是vector的,所以我们必须这么转化,另外还有环境变量,我们不传到头来调用execve函数时还是会默认传environ环境变量表指针,传了的话如果还是environ的话和没传是一样的,只有传的是自己自定义的环境变量表时才会触发写实拷贝额外申请空间存储新的环境变量表。
写一个简易bash程序
有了上面所讲的知识,我们就能写一个简单的bash程序了
#include<iostream>
#include<stdio.h>
#include<string.h>
#include <stdlib.h>
#include<unistd.h>
#include <sys/wait.h>
#define PATH_MAX 1024
#define INPUT_MAX 1024
using namespace std;
// char* myenv[INPUT_MAX] = {0};
char myenv[INPUT_MAX][INPUT_MAX] = {0};
int myenv_sz = 0;
int lastcode = 0;
void Print_Menu()// 菜单打印
{
char arr_cwd[PATH_MAX];
getcwd(arr_cwd, PATH_MAX);//获取当前目录
char* pm_token = strtok(arr_cwd, "/");
char* prev_pm_token = NULL;
char tmp[] = "/";
while (pm_token)//处理绝对路径,将其变为所在目录
{
prev_pm_token = pm_token;
pm_token = strtok(NULL, "/");
}
if (!prev_pm_token) prev_pm_token = tmp;//特殊处理,当所在目录是根目录/时
cout << "mybash:[" << getenv("USER") << "@" << getenv("HOSTNAME") << " "
<< prev_pm_token << "]"<< (strcmp(getenv("USER"), "root") ? "$ " : "# ");//根据环境变量打印
}
int My_Cd(char* str)//cd指令,内建命令手动实现
{
setenv("PWD", str, 1);//更改PWD环境变量
return chdir(str);//系统调用接口转盘
}
int My_Export(char* str)//export指令,内建命令手动实现
{
// char* tmp = new char[INPUT_MAX];
// strcpy(tmp, str);
// myenv[myenv_sz++] = tmp;
strcpy(myenv[myenv_sz++], str);//真正的拷贝
return putenv(myenv[myenv_sz - 1]);//更改环境变量
}
int My_Echo(char* str)
{
if(strcmp(str, "$?") == 0)//打印最近进程的退出状态
{
printf("%d\n", lastcode);
lastcode = 0;
}
else if(*str == '$')//打印变量
{
char* val = getenv(str+1);
if(val) printf("%s\n", val);
}
else//直接打印
{
printf("%s\n", str);
}
return 0;
}
int Partition(char* AR[], char AI[])//对shell命令行输入的命令切割
{
fgets(AI, INPUT_MAX, stdin);
size_t len = strlen(AI);
if (len > 0 && AI[len - 1] == '\n')
{
AI[len - 1] = '\0';
}
int i = 0;
AR[i++] = strtok(AI, " ");
while (AR[i++] = strtok(NULL, " ")){}
return i - 1;
}
int Branch(char** AR, int sz)//选择分支
{
if (strcmp(AR[0], "cd") == 0 && sz == 2)
{
return My_Cd(AR[1]);
}
else if(strcmp(AR[0], "export") == 0 && sz == 2)
{
return My_Export(AR[1]);
}
else if(strcmp(AR[0], "echo") == 0 && sz == 2)
{
return My_Echo(AR[1]);
}
else
{
int ret = fork();
if (ret == 0)
{
execvp(AR[0], AR);
}
else if (ret > 0)// 返回退出错误码或异常信号
{
int st = 0;
wait(&st);
if (WIFEXITED(st))
{
return WEXITSTATUS(st);
}
else
{
return WTERMSIG(st) + 128;
}
}
else//fork函数错误
{
return ret;
}
}
return 0;
}
int main()
{
while (1)
{
Print_Menu();
char* ARGV[INPUT_MAX] = { 0 };
char arr_input[INPUT_MAX] = { 0 };
int num = Partition(ARGV, arr_input);
if(num == 0) continue;
lastcode = Branch(ARGV, num);
}
return 0;
}
程序的实现相对简单,我只想说一个地方
int My_Export(char* str)//export指令,内建命令手动实现
{
// char* tmp = new char[INPUT_MAX];
// strcpy(tmp, str);
// myenv[myenv_sz++] = tmp;
strcpy(myenv[myenv_sz++], str);//真正的拷贝
return putenv(myenv[myenv_sz - 1]);//更改环境变量
}
就是export导出环境变量这条内建指令的实现,我们一定要使用不会因函数栈帧销毁而销毁的变量也就是非main函数中的局部变量和会因为循环被重复写入从而被覆盖的变量,因为我们的环境变量表实际上是一张指针表,对应的环境变量上没有值,而是一个个指针,我们使用putenv传的参数会被直接写进环境变量表,这时如果我们使用会消失的值,指针就变成了野指针,环境变量打印时检测到格式不对就打印空,亦或者我们使用会变化的值,也是同理的,所以一定要用全局变量或堆上的空间这种不会变化的值传参,这里我向堆申请空间和全局二维数组的逻辑都写了。