Linux_进程

进程的基本概念

  1. 一个基本的程序集合,称为操作系统(OS),且操作系统是一款进行软硬件管理软件
  2. 操作系统存在的目的是为用户程序(应用程序)提供一个良好的执行环境,而达到这点的手段是与硬件交互,管理所有的软硬件资源
  3. 软硬件均为层状结构
  4. 用户不能直接访问操作系统,若需要访问,则必须通过系统调用(类似C语言提供了printf函数,而printf的底层一般都会封装系统调用)间接访问,而这些函数其实都是系统提供的
  5. 我们的程序,只要你判断出他访问了硬件,那么它一定要贯穿整个软硬件体系结构
  6. 对数据的管理,需要遵循"先描述,再组织"的原则,而现在的许多高级语言都是面向对象的,像是C++中,对象的功能就是描述数据,而STL就是通过数据结构/算法组织数据,这也是现在许多高级语言为面对对象的原因之一
  7. 基础的判断高级语言函数是否封装了系统调用的方式:看该函数有没有访问硬件
  8. 进程 = 当前正在运行的程序(更准确的说法是程序的执行示例,因为程序是静态的,进程才是动态的,最好不要说正在运行的程序,而该部分实际指的是虚拟地址映射到的实际物理内存,其中包括代码段、数据段、堆栈段等) + 操作系统为了管理该程序所需的资源(如优先级,ip等,这些资源被存储到了一个结构体中-》即进程控制块PCB),同时,进程控制块只是描述,还需要将所有进程的进程控制块通过数据结构进行连接,操作系统才能够加以准确管理
  9. Linux的进程控制块的名称为task_struct
  10. 查看当前Linux上运行的全部进程:ps axj 或 top ;
    查询自己的进程的命令是:ps axj | grep myprocess(myprocess为需要查询的程序名),需要稍加注意的是,在执行该命令时,由于grep本身也是进程,所以查询时它会把自己也查出来
  11. 可以使用Ctrl + C来杀掉进程,也可以使用kill -9 进程的pid命令来杀掉进程
  12. proc目录下存储了所有进程的信息,可以使用ls /proc查看进程列表,ll /proc查看进程列表的详细信息,单个进程的信息中有两条需要率先了解的,分别是exe(表示该进程的执行文件的绝对路径)cwd(表示该进程的当前工作目录),关于这点的作用,其实C++/C新建文件操作不加路径默认创建在可执行文件同路径的原因
  13. 可以在main函数中使用chdir("路径")函数更改当前可执行程序运行时进程的所在路径(注:更改的进程的路径,而非可执行文件的路径),更改后再进行ofstream ofs("文件名", ios::out);时,就会将文件创建在当前进程的目录下,而非可执行文件的目录下
  14. 可以使用getpid()函数获取当前进程的pid,使用getppid()函数获取当前进程的父进程的pid
  15. 在同一次登录下,命令/可执行文件每次执行都会重新分配一个pid,但需要注意的是,但父进程(ppid)不变,原因是,它们的父进程为bash(命令行解释器),其底层使用了类似printf函数打印如下图信息,并使用scanf等函数进行阻塞,bash的pid是每次用户登录才重新分配的,因此,每次重新登陆后,bash的pid(即可执行程序进程的ppid)会变化
  16. 可以使用#include <unistd.h>​中的fork()函数创建子进程,当创建子进程成功时,该函数可能会返回两次返回值,父进程中返回新创建的子进程的pid,而子进程中返回0,当创建子进程失败时,仅在父进程中返回-1创建的子进程会copy父进程进程控制块中的大部分数据(少部分如pid不会cv),且不会创建新的程序,也就是说,子进程创建后虽然会创建出新的操作系统管理用节点,但该节点与父节点连接了同一块代码和数据,需要注意的是,为了保证进程之间的独立性,当某个进程出现更改数据中某个值的操作时,操作系统会为其创建一份新的该值(即不更改时共同使用,更改时再创建一份源数据中被更改的变量,注意是拷贝那几个被更改的变量,而非直接拷贝整份数据,此行为被称为写时拷贝,在学习python基础面对不可变类型时了解过类似行为)

fork函数的简单使用:

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
	pid_t pid = fork();
	if(pid == 0)
	{
		printf("child process\n");
	}
	else
	{
		printf("parent process\n");
	}
}

写时拷贝:

注意:fork的返回类型pid_t定义在#include <sys/types.h>​中,因此,调用fork时,也需要包含该头文件

  1. 关于fork函数成功创建子进程时会返回两个值的问题,其实fork函数内在return之前就已经创建好子进程了,甚至可能完成了某些调度工作,既然此时fork函数没结束就已经成功创建了,那return时自然就有一个是返回给父进程的,另一个是返回给子进程的
  2. 进程之间具有独立性(父子与子子均是),其中任意挂掉,其他也不会被影响
  3. 进程具备多种状态,如运行中、阻塞、挂起、等待、结束、就绪......其中:
  • 运行:进程正在被CPU调度中(CPU正在执行该进程的代码)
  • 就绪:进程已经准备好被CPU调度,但此时CPU没有空闲,进程位于就绪队列中
  • 阻塞:进程因某些原因(如等待I/O,即getchar,scanf函数等待键盘输入时-》即键盘这个硬件没有就绪)被阻塞,进程位于阻塞队列中
  • 挂起:进程因某些原因(如内存严重不足时)会将位于阻塞状态的进程的代码块唤出到磁盘中,若阻塞状态全部挂起仍未解决原问题(如内存仍旧严重不足),则甚至可能会挂起位于运行队列但未被CPU调度的进程,这些进程都位于挂起的队列中,当进程需要被CPU调度时,对应的代码块才会从磁盘被唤回到内存中
  1. 关于进程连接各个队列使用的特殊的数据结构:根据上面提到的进程状态,进程也就分出了不同的队列,而这些队列的底层其实使用了特殊的双链表,结构简图如下:

    能够观察到,这个链表并非像我们曾经实现的一般,next,prev指针类型并非PCB类型,而是head类型,而每个head结构体中,存放的数据就只有head* next与head* prev两个指针,那么这样实现双链表还能访问head结构体所在的PCB中的数据吗?这样创建的链表与普通的双链表相比,又有什么优势呢?

首先,这种特殊链表是可以访问对应的PCB中的数据的,还记得学习C语言时,曾接触过的offsetof宏吗?该宏位于<stddef.h>,作用是计算结构体/联合体中某个元素的偏移量,曾经还曾模拟过该宏的实现,#define My_offsetof(type,ys) &(((type*)0)->ys),思路其实不难,就是将0强转为该结构体的指针类型,然后使用该指针访问结构体中对应的元素,找到该元素的地址(仅仅返回地址,不访问该内存,是不会造成越界访问的),这里的特殊双链表结构也可以利用同样的思路进行PCB结构体中的数据访问

那么,为什么要如此大费周章的创建这样的特殊链表呢?该链表与普通链表相比具备什么优势呢?原因是,进程具备相当多的状态,如果使用"直接在PCB中创建多个PCB类型的指针"的方式创建链表,那么就没办法通过指针直接访问到对应状态(队列)的指针了,因此,在进行统一状态队列的操作时,也需要使用不同的函数(因为控制每个队列的指针都仅能通过->指针名的方式访问),这样做的话,很显然对于增删查改等常见操作,会出现极高的冗余,但如果使用这里提到的特殊链表结构,每个指针都直接指向其对应的队列的指针,这样,直接通过->next就能找到同进程状态队列的指针,也就能够将常见操作所需的API进行统一了

并且,这种特殊双链表其实可以直接将包含两指针的结构体嵌入到上层高级数据结构中,以低字节(无需嵌入整个PCB)的方式实现高级数据结构之间的连接

  1. Linux所具备的一些进程状态:

    可以发现,Linux进程状态的表示其实就是通过一个名为state的枚举类型的不同的互斥枚举值(互斥是为了确保一个进程不同时处于多种状态)表示不同标志位的,其中:
  • R表示的就是运行中,即运行态,也就是运行中的进程,示例为:

  • S表示的是Linux的一种阻塞态(S在Linux中被称为可中断睡眠状态),这里的可中断指的是该状态可以直接被杀掉,出现的时机就诸如scanf函数等待键盘就绪时,示例为:


  • D表示的是Linux的另一种阻塞态(D在Linux中被称为不可中断睡眠状态),不可中断就表示该状态不能被杀掉,具体来举例:

    有些进程的功能是向磁盘输送数据的,当磁盘接收到数据后,会进行数据写入,在此期间,进程就会进入该状态D,等待磁盘反馈的数据是否写入成功的信息,那么,为什么要设计为不可中断呢?理由是,操作系统在面对内存不足时,极端情况下会杀死不处于R状态进程,此时,该状态为睡眠数据输送进程,就很有可能被杀死,但是,此时若该进程被杀死,而磁盘又恰巧写入失败(原因可能是磁盘空间不足),那磁盘就会直接将数据丢失,问题就在于,这如果是银行这种场景的数据,那丢失就会造成极大的问题!为此,设计了这个D状态,避免该进程在等待磁盘反馈时被杀死,造成数据丢失

  • T表示的是Linux中的一种等待状态,通常会在用户输入Ctrl + Z时触发,示例为:

    这里注意下,看右边的Xshell界面,T之前的pid是bash的pid,但这里的Ctrl + Z是将cin阻塞的进程设置为了T,T所在行开头的第一个是父进程,也就是ppid,第二个才是pid(即cin阻塞的程序)

  • t表示的是Linux的一种跟踪/等待状态,最常见的出现场景如cgdb/gdb打断点调试程序时,遇到断点,被调试的程序就会进入该状态,示例为:

    这里需要注意,使用cgdb时,若还未使用r启动被调试程序,则被调试程序的进程其实是没启动的,当r到断点,被调试程序就会处于t状态

  • Z表示的是Linux的僵尸态,什么时候会出现该状态呢?其实,当子进程结束时,需要被父进程接收结束状态,若父进程未接收,则结束状态会一直被存放至子进程的PCB中,此时,子进程就处于僵尸态

  • X表示的是Linux的进程结束状态(即进程被杀),需要说明的是,当一个进程处于X状态,则会理解被操作系统回收,而作为用户的我们便很难观察到该状态,不过,用户本身也没必要观察该状态

  1. 僵尸进程z会导致一种内存泄露,且该泄露与之前写代码时出现的malloc或new未free或delete无关,僵尸进程造成内存泄漏的原因是:子进程退出,可以将子进程的代码和数据释放,但在此时,也需要将该子进程的执行结果反馈给父进程,在返回给父进程之前,该返回信息存放于子进程的PCB中,因此,当子进程结束后,若父进程没有进行信息接收操作,尽管释放了子进程的代码和数据,但PCB中的信息还不能释放,从而也就造成了内存泄漏,为什么说这种内存泄露与以前见过的不同呢,我们都知道,new/malloc造成内存泄露的程序(进程)在执行完毕后,会自动将空间全部释放,也就是说,此种情况的内存泄漏只针对于当前程序运行时,进程结束后就没有影响了,而僵尸进程造成的内存泄漏就不同了,它属于系统级别的泄露,操作系统未得到允许不能将该空间释放,而一直不释放的话,僵尸进程越来越多,最终操作系统便会崩溃。

  2. 值得一提的是,死循环+printf函数的进程在间隔1s的连续查询时,结果往往显示为阻塞态,原因是,printf和scanf其实都需要设备就绪,但是scanf是键盘,printf是显示器,虽然printf平时看不出来,但是死循环时,显示器跟不上CPU的速度,就会阻塞一会,直至显示器为就绪态,再开启下次循环

  3. 既然malloc和new造成的内存泄漏会在进程结束后自动释放,为什么还说该行为十分危险呢?原因是,当代大多数程序都是永久运行的(类似死循环,这些进程被称为守护进程/服务进程),像是淘宝,微信,甚至是操作系统,它们作为软件,作为进程不久都是一直运行的吗?因此,若这类服务进程出现内存泄漏,将成为工作中极大的失误

  4. Linux底层对PCB的管理方式使用了slab:我们都知道,malloc/new的效率是较慢的,如果每次启动一个新进程都去重新申请空间,效率难免低下,因此,当内存不吃紧时,Linux底层会使用一些结构(似乎是三级缓存)来存储进程已结束的PCB,当新进程启动时,会优先从该结构中获取一个PCB,避免重新申请空间,以提高效率,这种行为被称为slab,额外说明的是,slab并非Linux独有

  5. 孤儿进程:指的是父进程结束但子进程仍未结束的进程情况,对于该情况,为避免发生内存泄露,一般pid为1的进程(init/systemd)会自动领养孤儿进程,同时,当一个进程状态变为孤儿后,会自动从前台进程转换为后台进程,而后台进程仍可以向前台进行输出打印,同时,Ctrl + C无法杀掉后台进程,目前的解决办法仅有:kill -9 PID

Linux的进程优先级

  1. 进程优先级是什么?

其实理解起来很简单,关键字眼就是优先级,对于一个进程,其优先级的值越大,则该进程的优先级越低,执行次序越靠后,反之,值越小,执行次序越靠前

  1. 为什么要设置进程优先级这个机制呢?

原因很明显,一台计算机顶多也就几个CPU,而一个CPU在同一时间仅可以处理一个进程,但现实是,进程的就绪队列中可能有几十甚至几百个进程在等待CPU调度,即CPU资源稀缺,要通过优先级决定哪个进程优先被执行

  1. 优先级如何设置?

优先级的设置往往依赖两个参数,一个是优先级的默认值,另一个是优先级的调整值NI(nice值),其中,Linux的优先级默认值是80NI的范围是-20到19一个进程的最终优先级 = 80 + NI(范围为60~99),因此,想要调整进程的优先级,仅需设置NI的值,为此,常用的命令(方法)有:

  • top + r + 进程pid + 要设置的新NI值
  • nice + 进程pid + 要设置的新NI值
  1. 可以使用ps -al | head -1 && ps -al | grep 进程名查看进程的优先级,查询结果中的PRI即表示最终优先级(默认值+NI值),同时,使用该命令查询时,会看到UID列,UID所代表的就是启动该进程的用户的身份,比如用户dzh,其UID可能就是1000,而UID产生的原因就是操作系统不是依赖用户名识别权限,而是通过UID进行权限识别的,当启动一个进程(这里拿vim举例)时,该进程会自动记录启动者的UID,然后根据与文件的权限的UID进行对比,以此判断当前启动vim的用户属于拥有者,所属组还是other,并根据权限进行相应的操作

Linux的进程之间具备的特性

  1. 竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的,为了搞笑完成任务,更合理竞争相关资源,便有了进程优先级
  2. 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
  3. 并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
  4. 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都的以推进,称之为并发

Linux的进程切换

  1. 死循环进程如何运行?

    首先需要理解的是,尽管CPU的处理速度很快,但其在处理一个进程时,也大概率不是运行完再切换到下一个进程的,具体行为往往是,为每个进程都分配一个微秒/毫秒级别的时间片,当一个进程占用了一个时间片的时间后,就会让其重新进入就绪队列进行排队,当然,说是大概率,是因为可能存在代码量极少的程序,足以在一个时间片内就运行完成

  2. CPU与寄存器
    一个CPU内往往具备许多寄存器,这些寄存器各司其职,功能各异,例如,当一个进程被CPU调度后,某些寄存器就会存储该进程的代码与数据信息,比如计算1 + 2时,会有寄存器存储1,还有寄存器存储add,然后也会有寄存器存储2,最后,还会安排一个寄存器存储计算结果3

  3. 具体如何进行进程的切换操作

    当一个进程的代码还未执行完,却已占用CPU一个时间片时,就需要存储当前代码运行到了多少行,处理的数据到了什么程度这些信息,而存储这些信息的往往是一个名为TTS的结构体

Linux的优先级与就绪队列(运行队列)的底层逻辑(进程的调度过程)

上面提到了进程的优先级,同时也提到了就绪队列,那么两者之间是如何确定一个进程的执行次序的呢?首先,来看看就绪队列的大致组成:

  1. 所有的处于就绪状态的进程都被存储至就绪队列的两个哈希结构中(图中的两个queue[140]),而每个哈希桶中存储的就是优先级相同的进程,单个哈希的结构如下图:

  2. 为什么哈希(queue变量)的大小为140呢?这是因为Linux中其实共具备140个优先级,但是,上面提到的Linux优先级知识点中,不是说Linux的优先级范围是60~99吗,其实,上面提到的60~99是Linux中分时操作系统的优先级范围,先说明,操作系统分为实时操作系统和分时操作系统两类:

  • 实时操作系统:大致可以理解为运行完一个进程后,在继续运行下一个进程(实际机制会更复杂),此操作系统类别常用于工厂制作等场景,像是汽车的智能驾驶功能,比如用户此时在执行音乐进程,但前方突遇障碍物,急需刹车,此时如果使用分时操作系统,则需要先等待音乐进程的时间片结束,再执行刹车进程,这显然是不可理解的,正确做法应该是,刹车进程的优先级高于音乐进程,这样,当需要刹车时,直接运行该进程
  • 分时操作系统:分时操作系统上面也有提到,就是按照时间片来运行进程,常见场景为互联网行业

那么,回到正题,既然说Linux有140个优先级,而其中分时操作系统的优先级范围是60~99,那么,就说明Linux也支持实时操作系统喽?没错,毕竟一个操作系统一定会渴望在多个领域内发光发热,只不过,我们作为计算机专业的用户,在该领域,常用的就是分时操作系统,同时,需要注意的是,上面提到的60~99其实也并非操作系统内真正的优先级,这个范围其实对应的是Linux底层优先级的100~139

  1. 查找一个进程的时间复杂度以及所用结构:由于是hash,当知道一个进程的优先级时,可以快速找到该进程优先级的链表,然后从链表中找到进程,该过程整体的时间复杂度为O(1);但是,当不确定一个进程的优先级时,就需要遍历所有进程了,此时的时间复杂度为O(n),而n为进程总数,这消耗显然很高,此时,就需要借助运行队列/就绪队列(这里是runqueue,但不同系统,不同版本的名称可能有所差异)中的int bitmap[5]了,其是一个位图,由于是int类型,所以该位图中共具备5 * 8 * 4 = 160个比特位,而一个比特位就代表一个优先级,若比特位为1,则表示当前优先级对应的链表不为空,即存在进程,通过bitmap[5],可以不使用遍历140次判断哪个hash为空,仅凭几次就能找到非空hash桶,时间复杂度为O(1);同时,运行队列中还使用nr_active来存储其对应的queue[140]中的进程总数

  2. 运行队列的底层行为:上面的图中能够发现,runqueue中具备两组哈希结构queue,且每个哈希结构都对应一个bitmap[5]与nr_active,那么,为什么会有两组这样的结构呢?其实,首先需要明确一个进程的运行流程:进程们会先根据优先级尾插到对应的哈希桶中(保证先进先出),然后按照哈希桶代表的优先级顺序,依次遍历每个哈希桶,过程中,每个进程都会运行一个时间片(代码量极少的进程除外,下面也是,忽略该情况),那么,问题就来了,一个进程明明还没有运行完,但是时间片已经过了,但因为该进程已经运行了一个时间片,肯定不能尾插到当前优先级对应的哈希桶中(会导致一直运行不到后面优先级的进程),所以,此时就需要另外一个哈希结构了,已经运行过一个时间片的进程会先被插入到另外的hash结构中,当当前hash结构中已经不存在进程时,就会交换两个hash结构的身份,转而去运行另一个hash结构中的进程

  3. active与expired指针,从上图可以看到就绪队列的结构体中还具备这两个指针,其实,它们就是指向上述两个hash结构的指针,其中,active指针会指向当前正在运行进程的hash结构,而expired指针会指向当前没有正在运行进程的hash结构,当一个hash机构运行完毕后,就会swap(&active, &expired)(这里取地址是C的写法,理由是Linux底层是用C写的,若为C++的话,参数设置为引用即可)

  4. 对于新插入的进程,其会被尾插到active的hash结构中,以便被快速调度

  5. 第3点中的一些概念需要更正,调度器并不需要寻找某个特定的进程,其仅仅需要找到下一个需要被调度的进程,而调度该进程,说明该进程前面已经没有进程了(不是已经执行完毕就是被链接到了另一个hash结构中),那么,要做的就仅仅是从140个哈希桶中找到第一个非空的桶,这个桶的头即为下一个需要被调度的进程(所求),因此,在不借助位图的情况下,需要查找140次(优先级数量),此为常数级别,而使用位图时,查找效率能够进一步提高(不过也是常数级别)

历史遗留问题------命令行参数的再了解

1. 所需知识

在介绍环境变量前,先来聊聊Linux中命令的选项,首先,我们都已知道:

  1. 大部分Linux的命令都是由C来编写的

  2. C语言程序的入口函数其实并非main函数,而是一个被称为_start的函数(此为Linux平台下入口函数的名称,实际会根据平台有所差别),这个函数会调用main函数

  3. 以前写main函数时,都不加参数,而实际上,main函数最多可以具备三个参数,分别为int argc、char* argv[]和char* envp[],其中argc表示的是argv指针数组中元素的个数,而argv[]数组中存放的就是命令行参数,envp[]数组中存放的则是环境变量

    其中,argv[]中存储的内容简单来说就是:bash获取调用该可执行程序的整个字符串(如ls -a),然后,bash会将整个字符串以空格分割为多个子串,然后将这些子串存放至命令行参数表,这里需要注意,命令行参数中包含了命令本身,因此,命令行参数的个数是参数个数+1

2. 命令行参数的实现方式

首先,main函数是由_start函数调用的,而_start函数在调用main函数前,能够通过__libc_start_main函数判断出main的参数个数,进而通过if语句将正确的参数传递给main函数,但需要注意,_start传递的参数的顺序是固定的,因此,main函数中就可以通过argv[](也就是选项)来执行不同的操作了,下面先来打印一下argv数组中的内容:

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

int main(int argc, char* argv[])
{
	for(int i = 0; argv[i]; ++i)//循环条件为argv[i],原因是argv的最后一个元素为NULL,而NULL == 0
	{
		cout << argv[i] << "\n";
	}
	return 0;
}

运行结果为:

可以观察到,/test本身也是位于argv数组中的

接下来简单实现下根据不同选项(命令行参数)区分不同功能的操作:

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

int main(int argc, char* argv[])
{
	//判断输入的选项的个数是否正确
	if(argc != 2)
	{
		cout << "argc must == 2" << endl;
		return 1;
	}
	
	//若选项的个数正确,则开始根据选项执行不同的操作
	const char* arg = argv[1];
	if(0 == strcmp(arg, "a"))
	{
		cout << "开始执行a选项相关操作:" << endl;
	}
	else if(0 == strcmp(arg, "b"))
	{
		cout << "开始执行b选项相关操作:" << endl;
	}
	else if(0 == strcmp(arg, "c"))
	{
		cout << "开始执行c选项相关操作:" << endl;
	}
	else
	{
		cout << "请输入正确的选项!" << endl;
	}
	return 0;
}

运行结果为:

验证发现,运行结果达到预期

3. 命令行参数表

上述其实已经提到过了,bash命令行解释器会对输入的命令行按照空格进行分割,然后将分割得到的子串存放到一个char* argv[]的命令行参数表中,而调用可执行程序时,会将该表作为参数传递给main函数

环境变量

1. 环境变量的基本概念

  1. 环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数,如:在链接C/C++中的库函数时,需要找到库的位置,此时,就可以通过设置环境变量来告知编译器指定库的位置,配置过python的话,就会知晓windows上也有环境变量这个概念,菜单搜索一下就会弹出该窗口,而在Linux环境中,可以通过env命令查询到全部的环境变量:

    能够发现,这些环境变量都是以key=value的形式存在,其中,key为环境变量的名称,value为环境变量所代表的值,而这些值,有些就单纯是数值,有些则代表路径,同时,一个环境变量可能会对应多个值,而每个值都是用:分隔,并且,也可以通过echo $var命令来查看某个环境变量的值

    需要注意,此处的$就类似Makefile中使用的$,作用是转义,若直接echo var,则会将var当作字符串处理,打印结果为var

同时,这也是默认情况下执行自己的可执行程序时,必须./test,而不是test的原因,但可以通过将该可执行文件添加到PATH中,直接进行执行,但通常不推荐,原因是自己的程序安全性不确定,添加则可能有安全风险

  1. 与命令行参数的存储类似,环境变量存储在bash的环境变量表中,并且变量的类型仍旧是指针数组,但需要注意,环境变量表的创建时机是用户登录时,并且,由于对于同一台机器,每有一个用户登录时都会创建一个不同的bash,所以,环境变量表也各不相同
  2. 这些环境变量的源头是系统相关的配制文件,主要有/etc/bashrc、~/.bashrc、~/.bash_profile,它们的调用关系是:/.bash_profile调用/.bashrc,~/.bashrc调用/etc/bashrc
  3. 其实环境变量仅仅是PCB内核数据的一份位于用户空间的拷贝,真正控制进程的是PCB中的内核数据,环境变量的作用仅仅是便于用户使用(可通过environ全局变量访问环境变量)

2. 常见的Linux的环境变量

  1. PATH,其代表的是Linux中绝大多数命令所处的路径,同时可以发现,PATH这个环境变量对应了多个路径,因此,在运行一个命令时,实际上bash的操作为:对于一个命令如ls,bash会先从/usr/local/sbin路径中进行寻找(寻找即将两者拼接为:/usr/local/sbin/ls),若为找到,则去/usr/local/bin目录继续查找......若遍历了PATH仍未找到,则会报出"Command not found"
  2. HOME,其代表用户主目录,即/home/username,像是cd 底层就是将替换为HOME,然后映射到家目录
  3. USER,其代表当前用户名,即登录者账号的名称,这里需要注意一点,使用su命令时,bash中的USER不会改为root,但su -相当于让root重新登陆,此时则会将USER改为root
  4. LOGNAME,其代表当前登录用户名,即登录者账号的名称,和USER一样,使用su命令时,bash中的LOGNAME不会改为root,但su -相当于让root重新登陆,此时则会将LOGNAME改为root,值得一提的是,LOGNAME更倾向于记录会话创建者的身份,而USER更倾向于记录会话执行者的身份(不过su -相当于以root创建一个新的会话,因此,USER和LOGNAME都会改为root)
  5. HISTSIZE,功能是设置bash的历史记录条数,默认值为1000,以便上下翻找历史命令,并且,可以使用history命令查看历史命令(共计1000条,但起始可能不为0/1)
  6. HOSTNAME,功能是设置当前主机名,默认值为localhost
  7. SSH_TTY,功能是设置ssh连接的tty设备,即tty设备名,默认为/dev/pts/0
  8. LANG,功能是设置当前编码格式,默认值为en_US.UTF-8
  9. PWD,功能是设置当前工作目录
  10. SHELL,功能是设置当前shell命令的目录,默认值为/bin/bash
  11. OLDPWD,功能是设置上一次工作目录,因此可以通过cd -命令回到上一次工作目录

3. 环境变量的添加和删除和更改

  1. 环境变量的添加命令:export,语法为:export var=value(需要注意这里的=两边不能添加空格)

    示例:

    能够发现,成功添加,不过需要注意,这只是将该环境变量添加到了环境变量列表中,并没有添加至系统配置文件中,因此,若重启bash,则该环境变量将丢失

  2. 环境变量的删除命令:unset,语法为:unset var

    示例:

  3. 直接使用环境变量名=新值即可对环境变量进行修改,但需要注意,此方式进行的修改不是在原值的基础上进行增添,而是覆盖源值,若想进行增添,则需要使用环境变量名=$环境变量名:追加值

    覆盖示例:

    能够观察到,将PATH修改为当前可执行程序所在目录时,可以直接通过可执行程序名执行该程序

    追加示例:

    可以看到,成功进行了追加

4. 使用C/C++函数获取环境变量

在程序中获取环境变量的方法一般有三种,分别为:

  1. 位于stdlib.h/cstdlib库的getenv()函数,功能是获取环境变量的值,返回值为char*,若获取失败,则返回NULL(由于可以指定环境变量名称,所以为最推荐方法),示例:
cpp 复制代码
#include<iostream>
#include<string.h>
#include<cstdlib>
using namespace std;

int main(int argc, char* argv[], char* env[])
{
	static_cast<void>(argc);//这三句强转的原因是,gcc/g++对于未被使用的函数参数会报warning,因此,需要将参数声明为void
	static_cast<void>(argv);
	static_cast<void>(env);

	const char* who = getenv("USER");
	cout << who << endl;
	return 0;
}

运行结果为:

  1. 在main函数中添加第三个参数char *envp[],然后,直接使用env参数进行获取,示例为:
cpp 复制代码
#include<iostream>
#include<string.h>
#include<cstdlib>
using namespace std;

int main(int argc, char* argv[], char* env[])
{
	static_cast<void>(argc);//这两句强转的原因是,gcc/g++对于未被使用的函数参数会报warning,因此,需要将参数声明为void
	static_cast<void>(argv);

	for(int i = 0; env[i]; ++i)//循环条件为env[i],原因是env的最后一个元素为NULL,而NULL == 0
	{
		cout << env[i] << "\n";
	}
	return 0;
}

运行结果为:

  1. 使用位于unistd.h库的extern char** environ全局变量,功能与env参数相同,因此使用时main函数可以没有env参数,但需要在全局声明environ变量(以NULL结尾),使用示例为:
cpp 复制代码
#include<iostream>
#include<string.h>
#include<cstdlib>
#include<unistd.h>
using namespace std;

extern char** environ;
int main(int argc, char* argv[])
{
	static_cast<void>(argc);
	static_cast<void>(argv);
	
	for(int i = 0; environ[i]; ++i)// environ全局变量是以NULL结尾的,因此可直接environ[i]作为循环条件
	{
		cout << environ[i] << "\n";
	}
	
	return 0;
}

运行结果为:

5. getenv()函数的简单使用(创建一个只有指定用户才能正确运行的程序)

cpp 复制代码
#include<iostream>
#include<string.h>
#include<cstdlib>
//#include<unistd.h>
using namespace std;

//extern char** environ;
int main(int argc, char* argv[])
{
	static_cast<void>(argc);
	static_cast<void>(argv);
	
	const char* who = getenv("USER");
	if(who == nullptr)
	{
		return 1;
	}
	else if(0 == strcmp(who, "dzh"))
	{
		cout << "程序正确运行" << endl;
	}
	else
	{
		cout << "只有dzh能够运行该程序!" << endl;
	}
	return 0;
}

运行结果为:

6. 环境变量与本地变量

  1. 环境变量的一个特性
    环境变量会被所有子类继承(可被所有子进程共享),例如getenv函数可以直接在可执行程序中获取bash中的环境变量,而可执行程序的子进程也可以获得,不过需要注意,这个继承指的是子继承父,反过来,在子进程中创建的环境变量,其实无法被父进程获得,也正因为这个继承形式,原本的环境变量基本都位于bash中,而使用gcc/g++等进程进行程序的编译时,就会自动继承bash的环境变量,因此,可以直接查询到所需库的路径,同时,需要注意,所谓的环境变量继承甚至是PCB内核数据的继承,其实都是子进程对父进程的写时拷贝,只有当对数据进程更改时才会创建新的数据

注:环境变量中的内容其实都是进程PCB核心源数据的一份拷贝,如PATH环境变量,其实拷贝自当前进程的PCB中的源数据,而环境变量仅仅是位于PCB中页表的一份源数据的拷贝,同时,像是chdir等系统调用函数仅仅会更改源数据,而不会更改环境变量

  1. 本地变量

    本地变量,诸如在命令行解释器bash中输入i=10,那么i便是bash的一个本地变量,该变量无法被bash的子进程获取,更无法被父进程获取,准确来说,其只能被创建其的进程使用,指的一提的是,可以通过echo $i获得本地变量的值,也可以使用export i将本地变量变为环境变量,并且, set命令可以查看所有的环境变量与本地变量,而本地变量的作用就是在当前进程中具备一定的功能性,比如说:
    ,代表的是bash的命令行格式

    同时,可以使用unset命令删除本地变量,如:

  2. 环境变量与本地变量的区分

    首先,环境变量是系统级别的,任何进程都可以访问,而本地变量是进程级别的,只有当前进程才能访问(可以通过新建一个同用户名会话,由于两个bash不属于同一进程,所以无法在一个bash中查到另一个bash中创建的本地变量),并且,可以通过上面提到的性质对两者进行区分:环境变量会被所有子类继承,但若在一个进程中(比如父进程)创建了本地变量,则无法被其他进程(比如子进程)获得

  3. C中更改环境变量推荐使用的系统调用函数

    setenv函数,其函数原型为:int setenv(const char *name, const char *value, int overwrite);,其中name为环境变量名,value为环境变量值,overwrite为是否覆盖,若为0,则表示不允许覆盖(维持原值),若为1,则表示允许覆盖(更改为新值),返回值为0时表示成功,为-1时表示失败,同时,虽然putenv函数也可以实现该需求,但问题是,putenv函数需要保证传入的更改用字符串长期有效,因此不推荐使用,这里也不再介绍了

  4. 环境变量与内核数据的关系:其实上面也已经渗透过了,环境变量其实就是内核数据中部分数据的副本,同时,内核数据(PCB)中也具备指向环境变量的指针,也就是说,内核存储的进程的工作目录,进程用户组,打开的文件等源数据environ也会存储这些数据的副本,一个进程中共计两份这种重要进程控制数据的拷贝,而在自定义shell的书写中,还建议额外维护一张环境变量表,用于存储环境变量以及shell自身的局部变量(与环境变量的区别就是多了shell自身的局部变量),也就是说,对于自定义shell,具备三份进程的工作目录,进程用户组,打开的文件等数据,其中一份是位于PCB的源,两份是位于用户进程空间的拷贝

注:对于一般进程,内核数据和环境变量都是写时拷贝自父进程,基本不会出现环境变量拷贝自自己的内核数据的情况,而对于特殊进程(如shell或第一个启动的进程),它们内核数据的来源是特定文件,而非父进程

7. 内建命令

在上面直接使用当前工作目录覆盖环境变量PATH后,会发现很多命令都用不了了,原因很简单,环境变量被更改,导致找不到这些命令所在的目录了,但是,有一些命令,如pwd,export等还可以使用,其实这些命令就被称为内建命令,简单理解就是,它们本身就在bash进程中,当使用这些命令时,bash内部会先判断命令的字符串是否与"export"相等,若相等,会直接在bash的进程中调用相应的函数,这也能够解释"环境变量明明不能被父进程获取,但export创建的环境变量却可以被bash获取"的疑惑

地址空间

1. 代码中各种变量所处的地址空间

先来验证一下代码中各种变量所处的地址空间:

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

int g_unval;//未初始化的全局变量
int g_val = 100;//已初始化的全局变量

int main(int argc, char *argv[], char *env[])
{
    const char *str = "helloworld";//一个字符串常量
    printf("code addr: %p\n", main);//main函数的地址
    printf("init global addr: %p\n", &g_val);
    printf("uninit global addr: %p\n", &g_unval);

    static int test = 10;//静态成员变量 
    char *heap_mem = (char*)malloc(10);//堆区
    char *heap_mem1 = (char*) malloc(10);
    char *heap_mem2 = (char*) malloc(10);
    char *heap_mem3 = (char*) malloc(10);
    printf("heap addr: %p\n", heap_mem);
    printf("heap addr: %p\n", heap_mem1);
    printf("heap addr: %p\n", heap_mem2);
    printf("heap addr: %p\n", heap_mem3);

    printf("test static addr: %p\n", &test);//指针变量,也是栈区地址
    printf("stack addr: %p\n", &heap_mem);
    printf("stack addr: %p\n", &heap_mem1);
    printf("stack addr: %p\n", &heap_mem2);
    printf("stack addr: %p\n", &heap_mem3);

    printf("read only string addr: %p\n", str);
	
	//打印全部的命令行参数
    for(int i = 0 ;i < argc; i++)
    {
        printf("argv[%d]: %p\n", i, argv[i]);
    }

	//打印全部的环境变量
    for(int i = 0;env[i]; i++)
    {
        printf("env[%d]: %p\n", i, env[i]);
    }

	return 0;
}

运行结果为:

关于不同区域的示图如下

先做下总结:

  1. :是最熟悉的区域,在函数中创建的除static以外的变量,全部位于栈中,函数递归的过程也会牵扯到函数栈帧,并且,在栈中创建的变量,地址会按照定义的顺序依次降低
  2. :第二熟悉的区域,malloc/new创建的变量,全部位于堆中,同时,在堆中创建的变量,地址会按照创建的顺序依次递增
  3. 共享区:这是栈和堆区之间的镂空区域,具体作用目前还没了解,只知道这块空间相对较大,之后会补充
  4. 正文代码,即代码区:在程序中可通过打印main函数的地址,来近似得到代码区的开头地址,但经过上述学习,其实已经了解到,代码区的开头应该是_start函数,值得一提的是,代码区中存放的是代码(可以当作将这些代码以字符串形式进行存储),而代码中的变量值会另外存放到其他区域
  5. 初始化变量区与未初始化变量区(两者也被称为数据段):在上面的验证程序中也有体现,会存储初始化/未初始化的全局变量/静态变量(包括全局和局部)
  6. 命令参数和环境变量区:即存储argv和environ的区域,在图中也有所体现,它们的地址基本上是最大的
  7. 只读数据段(常量区):可以理解成位于代码区之后,初始化数据段之前,其中存放的是char* str = "helloworld"出的常量字符串(但通过字符数组创建的字符串会存放到变量区/所在函数的栈区),const 全局变量,以及const 静态变量(无论全局还是局部),不过需要注意,const 局部变量会存储到所在函数的栈区
  8. 根据上面的实验与图示,其实,这些区域从低地址处到高地址处的顺序依次为:正文代码区、只读数据段、初始化变量区、未初始化变量区、栈区、共享区、堆区、命令参数和环境变量区
  9. 除了栈和堆的地址是按照定义顺序递减/增外,其他区域可能仅仅是单纯的映射关系,不一定满足什么线性关系(例如hash的映射般)

2. 进程地址空间(虚拟地址空间)

但其实,1中提到的这些区域,并非是内存的实际排布,实际上,这些排布被称作虚拟地址空间,可以通过fork函数创建子进程然后查看两个进程中值不同但地址相同的现象来证实

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
using namespace std;

int main()
{
	int a = 0;
	pid_t pid = fork();
	if(pid == 0)
	{
		a = 999;
		cout << "这是子进程中变量a的地址:" << &a << "\n这是a的值:" << a << "\n";
	}
	else
	{
		cout << "这是父进程中变量a的地址:" << &a << "\n这是a的值:" << a << "\n";
	}
	return 0;
}

运行结果为:

而根据之前的知识,可以得知,这两个变量的物理地址绝不相同

实际上,虚拟地址空间的确不是真正的物理空间,对于每一个进程,都具备一个属于它自己的虚拟空间,而这个虚拟空间中的虚拟地址会通过一个类似hash的结构(即页表)映射到真正的物理地址上,大致结构如下:

借着这张图,先来多了解一下虚拟地址空间,首先,每个进程都有一个独属于自己的虚拟地址空间,对于32位机器,它的每个进程的虚拟地址空间大小均为2 ^ 32 * 1Byte = 4G(地址范围均为0x00000000~0xFFFFFFFF),而64位机器的每个进程的虚拟地址空间大小为2 ^ 64 * 1Byte = 16EB,为了便于理解,下面都会以更简单的32位为例进行说明

然后,再来聊聊页表,首先说明,进程的PCB中存在指针指向其页表,页表与虚拟地址空间类似,每个进程也都有一张独属于自己的页表,且从图中可以看到,页表结构的左侧为虚拟地址,而对应的中间部分则为物理地址,当需要在程序中访问虚拟地址时,底层会通过页表映射到正确的内存中的物理地址,也就是说,虚拟地址空间中不存储变量的值等信息,事实上,虚拟地址空间的本质就是一个名称为mm_struct的结构体,其中的元素大多数都是用于存储各个区域的开头与结尾的信息(准确来说,存储的是0x00000000~0xFFFFFFFF的数字,即直接使用虚拟地址来进行区域划分)

与此同时,也能观察到页表结构的右侧,具备一些rw等字符,这其实就是页表为进程操作设置的权限,比如说,对于一个const变量,页表会将其地址权限设置为只读,当尝试修改时,会被页表的拒绝,以此避免进程对数据的错误操作

那么,设计虚拟地址和页表的目的是什么呢?

  1. 通过上述虚拟地址的范围均为0x00000000~0xFFFFFFFF,可以得知,操作系统让每个进程都认为自己拥有4G的地址空间,这样做的目的是:
    • 虚拟地址中,各个数据都被按照顺序,或者说很有规律的被存储,即,将原本无序的物理内存转换为了有序的虚拟内存,这有利于进程对数据的使用
    • 由于每个进程都认为自己独占4GB,所以这有利于进程之间互不干扰,即提高进程的独立性
    • 让进程无需操心内存中哪里正在被使用,哪里是空闲的,可以被当前进程使用,如何减少内存碎片的出现,转而专心于进程自身的运行上,而寻找可用内存并进行合理分配、管理的这部分工作会交给操作系统,通过页表的虚拟地址与物理地址之间的映射来解决,与此同时,当由于该机制的存在,当需要更改进程中的某个数据的指向时,无需更改PCB中存储进程数据的指针,只需要更改页表项即可,此行为也可以总结为:对进程与内存管理之间进行一定的解耦合
  2. 实际上内存的大小可能仅有2GB,当然,也有可能是8GB乃至16GB,但虚拟地址可以配合缺页中断技术,使得2GB的内存甚至可以运行4GB的程序,缺页中断简单来说,就是页表的虚拟地址填好后,可以先不为其映射物理地址,或者说,仅对部分虚拟地址进行映射,当这部分被映射的代码被执行完毕后,再继续对原本未映射的地址进行映射
  3. 在介绍页表时其实已经提到了这点,页表中可以直接通过r,w等对地址进行权限管理,以避免对数据的误操作,这里再举个例子,比如野指针导致的程序崩溃,本质就是该地址本身不位于页表的虚拟地址范围中,然后页表转手就通过操作系统把进程杀了,那么,可能也会有疑问,为什么有时候野指针程序不会崩溃呢?其实也很好解释,就是这个野指针恰好是虚拟地址范围中的某个值

3. 父子进程的创建流程

接下来,来简单捋捋这个结构的工作流程:

  1. 当父进程创建子进程时,其实会将父进程中的所有信息(包括虚拟地址空间的相关数据)拷贝一份到子进程中,而对于虚拟地址空间中的数据,拷贝的其实就是指针变量,也就是浅拷贝,既然是浅拷贝,同时又需要保证进程之间的独立性,所以,当子进程需要修改父进程中的值时,会发生写时拷贝(上面也提到过,就是不改变时使用浅拷贝,共享同一块空间,当需要修改时,则改为深拷贝,对深拷贝的数据再做操作)
  2. 经过步骤一,子进程得到了一份与父进程几乎完全相同的PCB与页表(pid等内容会做修改),但这张页表的虚拟地址虽然可能都存在,但对应的物理地址却可能并不存在,可能只有当CPU调度该进程时,才会为虚拟地址映射物理地址,这样做是为了避免进程之间数据互相影响,也为了以低物理内存运行高虚拟内存的程序,即上面提到的缺页中断技术

4. 更具体的虚拟地址空间结构(mm与VMA)


通过图示可以观察到,mm其实也被task_struct(即PCB)管理,而mm(内存描述符)结构体中具备mmap指针连接一个由VMA结构体组成的链表,VMA结构体中存储着虚拟地址空间中各个区域的信息(其中存储的信息比mm中的更细),实际上,VMA才是直接管理各个区域的结构体,值得一提的是,由于堆区空间使用的不确定性(由用户手动申请和释放)以及堆区空间相对较大,极容易导致大量内存碎片,所以,可能会使用多个VMA结构体将堆区空间分成多份进行单独管理(特别是一次性申请很大空间如1GB时,很可能会直接创建一个新的VMA),像是栈区,由于直接由内核进行管理,申请和释放的时机都可预期,所以不会出现内存碎片,并且栈的空间相对较小,所以平时仅使用一个VMA进行管理

5. 简单理解缺页中断

  1. 简单来说,缺页中断就是被CPU调度的进程仅有部分代码和数据被加载到了物理内存,剩下的部分等到需要使用时再进行加载
  2. 在正常情况下(即使内存充裕),也会用到缺页中断技术,即进程的代码和数据仅仅一部分被加载到内存,作用是,启动一个进程时,可以避免前面长时间的加载,以提高进程的启动速度
  3. 当内存吃紧,或需要运行一个大内存进程(如需要4GB,但内存本身仅有2GB),可以通过缺页中断 + 页面置换技术,先将该进程的部分数据加载到内存中,当前面的代码和数据运行完毕后,先释放空间,然后将剩余的数据和代码加载到内存中,这样,就可以做到以少量内存运行高虚拟内存的程序
  4. 即使进程目前未被调度,其代码和数据可能也部分存储于内存中,这是为了提高分时操作系统调度进程的效率
  5. 综上,缺页中断主要的功能有两个,内存充足时就是加快进程的启动,内存不足时就能配合页面置换运行一个大内存进程

进程控制

1. 关于写时拷贝的了解

首先,在不创建子进程时,父进程的代码段是只读,而数据段的常量区是只读,非常量区是可读可写;当父进程创建子进程时(如使用fork函数),就会先将父进程的非常量区的数据段的权限也改为只读,并将虚拟内存和页表以浅拷贝的方式复制到子进程,接下来,若父进程或子进程中有修改数据的操作,就会出现页表的权限错误,但该错误是意料之内的(并非是野指针之类的意料之外的错误),操作系统能够识别该错误,然后进行写时拷贝(即 先为子进程的数据虚拟地址映射一个新的物理地址,再将发生修改的进程中被修改的数据的权限重新更改为可读可写,并进行更改)

注意事项:上面的理解中,提到的对数据权限的一个一个修改,以及写时拷贝会每次新映射一个物理空间,这里的单位都是不准确的,更精确的说法是,每次都会修改一页权限,新映射一页物理空间,这里的页指的是内存页这是操作系统和CPU所操作的最小数据单位(通常是4KB),即,就算仅仅修改了一个字节的数据,操作系统也会对该字节所在页的所有数据进行权限修改与新的物理映射

写时拷贝的的作用是,通过按需分配、延迟复制,更精确的控制内存的分配,尽可能减少空间的冗余

进程控制

1. 进程创建

  1. fork函数可以在一个进程中创建子进程
  2. fork函数创建子进程成功时,在父进程中返回子进程的pid,而在子进程中返回0;若子进程创建失败,则会在父进程中返回-1(创建失败的原因有多种,可能是当前创建进程所需的内存不足等......)
  3. 写时拷贝,这点上面已经重点作了介绍,简而言之就是使用fork函数成功创建子进程后,若无需更改fork函数前创建的变量,则父子进行共享该变量(同时,相应变量所在页的权限会被更改为只读),若任意一个进程中对该类变量进行了更改操作,则会出现页表权限错误,此时操作系统才会对该页的数据进行深拷贝,使两进程不在共享,同时,页的权限会被更改为可读可写
  4. fork函数常用于创建命令的选项等需要创建子进程以执行新任务的场景

2. 进程终止

  1. 进程的三种退出场景
    • 程序正常退出:即程序如预期般成功计算,处理等
    • 程序异常退出:即程在运行过程中,由于某种原因(如内存不足,文件不存在)导致程序异常退出
    • 运行时异常退出:即程序运行过程中,由于某种原因(如除零错误)导致程序异常退出
  2. 进程的常见退出方式
    • 在main函数中使用return语句退出:这里需要稍加注意,此种退出需满足两个条件,一是main函数中,二是使用return,若return语句所处的函数并非main函数,则效果仅为退出使用的函数,而不是该进程,虽然这是敲代码时早就发现的现象,但从未正式提起过
    • 使用exit/_exit函数退出:在任意函数中,使用exit函数或_exit函数都可以确保退出进程,即,与return语句退出的最主要区别为,不刚需在main中使用,不过,这里需要稍微了解一下exit函数和_exit函数的差异,实际上,exit函数是C标准提供的函数,而_exit则是系统提供的函数,也就是说,exit函数的实现依赖于调用_exit函数,也正因此,调用exit函数后,虽然会导致进程退出,但其中还内置了刷新缓冲区,关闭流等函数,但_exit函数的功能仅仅是退出进程,不会进行额外操作
  3. 退出码
    使用return语句以及exit/_exit函数时,都需要一个退出码,该退出码代表的含义有可能存在平台差距,但一般情况下,退出码为0表示程序正常退出,而非0多半是异常退出,可以使用位于string.h头文件的strerror函数将退出码转换为错误信息字符串进行输出,另外值得一提的是,当程序因为运行时异常而退出时,其退出码没有意义,应该关注退出时的发出的信号(在之后的信号章节会进行介绍)

    注:可以在shell中使用echo $?获取上一个退出的进程的退出码,同时需要注意,由于echo $?这个命令执行时本身就是一个进程,所以连续使用echo $?时后面显示出的结果为前面的echo $?进程的退出码(不是后续进程等待函数中的status的混合码,$?获取的仅仅是10进制的退出码)

strerror函数的使用示例:

c 复制代码
#include <stdio.h>
#include <string.h>// strerror函数所在文件
#include <errno.h>// errno参数所在文件

// 打印所有错误码表示的信息
void func1()
{
	for(int i = 0; i < 150; ++i)// 由于不确定错误码的范围,这里先假设有150个错误码
	{
		printf("%d->%s\n", i, strerror(i));// strerror的参数可以传递整数,返回值为字符串类型的该参数对应的错误信息,不过通常情况下建议传递下面func2函数中使用的errno参数
	}
}

// 使用errno+strerror函数打印错误信息
void func2()
{
	FILE* fp = fopen("nihao.txt", "r");
	if(fp == NULL)
	{
		printf("%s\n", strerror(errno));// errno变量,当遇到某些错误时,会被自动设置为对应的错误码,像这里,由于r方式尝试打开不存在的文件,所以errno就被设置为了表示No such file or directory错误信息的错误码
	}
}

int main()
{
	func1();
	func2();
	return 0;
}

3. 进程等待

3.1 进程等待的作用

进程等待的作用是,正确处理僵尸态进程,避免内存泄漏(注:就算是kill -9命令都无法杀掉僵尸态,仅可以通过进程等待才能正确处理)

3.2 进程等待的方法
  1. wait函数,其参数为一个指针,该指针指向一个int类型的变量,其作用是保存子进程的退出码,若该参数为NULL,则变量中将保存0
    • 关于该整形变量中被存入的值的方式,需要有所了解:

      即,int的前两个字节不被使用,而第3个字节被用来保存退出码,第4个字节被用来保存信号,因此,需要退出码时,需要使用>>8 && 0xFF进行取值,而获取信号时,需要使用&0x7F进行取值,同时,获取时无需用户再进行位操作,可以使用WIFEXITED(status)宏判断子进程是否正常退出(本质就是检查第4字节是否为0,若信号为0,则说明该子进程是正常退出的),也可以使用WEXITSTATUS(status)宏获取进程的退出码,但需要注意,该宏只有在WIFEXITED(status)为真时才有意义(即需要保证程序是正常退出的)
  2. waitpid函数,该函数具备三个参数,分别为pid_t pid,int *status,int options,作用分别为:pid为-1时,表示等待任意子进程;pid为正数时,表示等待指定子进程(其余参数范围目前不考虑);status与上面的wait函数参数作用一致;而对于options参数,可以向其传递多个值,这里主要介绍两个:
    • 当其为0时表示阻塞等待子进程,就类似scanf与cin等待键盘输入进入的阻塞一致,不能继续执行当前进程的后续代码,
    • 当传递WNOHANG作为参数时表示非阻塞等待,即不会阻塞,若执行到该函数时,没有子进程,则返回-1表示失败,若子进程已退出,则返回子进程的pid,若子进程未退出,则返回0,无论函数返回了什么,都会继续执行后续代码,也因为非阻塞时的行为,所以常常配合循环使用并使用if判断每次调用函数时得到的返回值,这个过程也被称为非阻塞轮询

4. 进程程序替换

4.1 替换原理
  1. 使用进程替换函数时,本质就是替换调用该函数的进程的代码和数据,但并不替换PCB,因此,程序替换不会改变pid
  2. 由于进程中的代码和数据都被替换了,所以,无需使用if判断替换函数的返回值,因为若替换成功,则该if会被替换,若替换失败(原因可能是未找到参数中的程序),则一般建议使用exit()退出当前进程(注:替换失败会返回-1,替换成功无返回)
  3. 替换的程序只要是可执行文件就可以,不必须是系统提供的程序,也不必须通过C++/C来写,事实上,C/C++等程序可以通过下面4.2中介绍的替换方法替换为python乃至java写的进程
  4. 由于环境变量位于页表中,当使用进程替换函数(非e系列)时,不会替换页表中的环境变量,即替换的进程会继承源进程的环境变量,而当需要将源进程的环境变量覆盖时,就需要使用exece系列函数了
4.2 替换方法(exec系列函数位于<unistd.h>文件)
  1. 进程替换函数execl(这里的l可以理解为list,即传参方式为连续传多个参数):execl(const char *path, const char *arg0, ..., (char *)NULL),其参数依次为:path为可执行文件的路径,arg0为可执行文件的名称,...为可执行文件的参数,同时该可变参数需要以NULL为结束标志,具体示例为:execl("/bin/ls", "ls", "-l", NULL);
  2. 进程替换函数execlp(这里的p可以理解为环境变量PATH):execlp(const char *file, const char *arg0, ..., (char *)NULL),其参数依次为:file为可执行文件的名称,arg0也为可执行文件的名称,...与上述execl函数的第三个参数一致,为可执行文件的参数,同时该可变参数需要以NULL为结束标志,具体示例为:execlp("ls", "ls", "-l", NULL);,简单来说,相比execl函数,execlp函数会自动搜索PATH环境变量中的可执行文件,因此第一个参数无需传路径了,但简洁的同时也会带来一些问题,就比如说,此函数不能搜索到未在PATH环境变量中的可执行文件,比如,/usr/local/bin/python3.7,因此,execlp函数一般只用于系统提供的可执行文件
    注:关于p系列函数的一点细节补充,其实更具体来说,其行为是若第一个参数的字符串中含有'/'时,会按照参数的路径寻找,若不包含'/'时,会按照PATH环境变量中的可执行文件寻找
  3. execv(这里的v可以理解为vector,即传参方式为指针数组):execv(const char *path, char *const argv[]),其参数依次为:path为可执行文件路径,argv为可执行文件的参数,该参数是一个数组,数组的元素为可执行文件的参数,最后一个元素必须为NULL,具体示例为:char *argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv);,也就是说,相比execl函数,execv函数就是传参形式被更改为了指针数组,因此,可以将调用execv函数的main函数的第二个参数(命令行参数数组)直接传递给execv函数进行调用
  4. execvp(这里的p为环境变量PATH,v是vector):execvp(const char *file, char *const argv[]),其参数依次为:file为可执行文件的名称,argv为可执行文件的参数,该参数是一个数组,数组的元素为可执行文件的参数,最后一个元素必须为NULL,具体示例为:char *argv[] = {"ls", "-l", NULL}; execvp("ls", argv);,也就是说,相比execlp函数,execvp函数就是传参形式被更改为了指针数组
  5. execvpe(p为环境变量PATH,v为vector,e为环境变量):execvpe(const char *file, char *const argv[], char *const envp[]),其参数依次为:file为可执行文件的名称,argv为可执行文件的参数,该参数是一个数组,数组的元素为可执行文件的参数,最后一个元素必须为NULL,envp为环境变量,该参数是一个数组,数组的元素为环境变量,最后一个元素必须为NULL,具体示例为:char *argv[] = {"ls", "-l", NULL}; char envp[] = {"PATH=/usr/local/bin", NULL}; execvpe("ls", argv, envp);,需要注意的是,envp参数会直接覆盖(完全替换)从父进程继承下来的环境变量,对于上面的其他函数,就算没有传递环境变量,其实也会得到从父进程继承下来的环境变量(因为环境变量位于页表中)
    注:使用exec
    e系列函数时,若不想直接将源进程的环境变量覆盖,则可以使用位于unistd.h库的extern char** environ全局变量做该函数的环境变量参数
  6. execle(l为list,e为环境变量):execle(const char *path, const char *arg0, ..., (char *)NULL, char *const envp[]),其参数依次为:path为可执行文件路径,arg0为可执行文件的名称,...与上述execl函数的第三个参数一致,为可执行文件的参数,同时该可变参数需要以NULL为结束标志,envp为环境变量,该参数是一个数组,数组的元素为环境变量,最后一个元素必须为NULL,具体示例为:char *envp[] = {"PATH=/usr/local/bin", NULL}; execle("/bin/ls", "ls", "-l", NULL, envp);

注:为当前进程新增环境变量的函数putenv,其函数原型为int putenv(char *string),参数为:string为环境变量,格式为"变量名=变量值",该函数会向当前进程的页表中添加该环境变量(注意是在原环境变量的基础上进行添加,而非exec*e系列函数一样直接覆盖)

  1. 系统调用函数execve:execve(const char *path, char *const argv[], char *const envp[]),其参数依次为:path为可执行文件路径,argv为可执行文件的参数,该参数是一个数组,数组的元素为可执行文件的参数,最后一个元素必须为NULL,envp为环境变量,该参数是一个数组,数组的元素为环境变量,最后一个元素必须为NULL,具体示例为:char argv[] = {"ls", "-l", NULL}; char envp[] = {"PATH=/usr/local/bin", NULL}; execve("/bin/ls", argv, envp);,这里需要重点注意的是上面介绍的1~6函数均为使用语言对execve系统调用函数进行封装的语言级别的函数,系统调用函数中,更换进程的函数仅有execve一个,而关于系统调用函数明明需要传递环境变量,但上面却存在无需传递环境变量的替换函数这点,其实就是语言级函数内部直接使用了extern char environ全局变量
相关推荐
开开心心就好6 小时前
AI人声伴奏分离工具,离线提取伴奏K歌用
java·linux·开发语言·网络·人工智能·电脑·blender
lucky-billy7 小时前
Ubuntu 下一键部署 ROS2
linux·ubuntu·ros2
Thera7777 小时前
【Linux C++】彻底解决僵尸进程:waitpid(WNOHANG) 与 SA_NOCLDWAIT
linux·服务器·c++
Wei&Yan7 小时前
数据结构——顺序表(静/动态代码实现)
数据结构·c++·算法·visual studio code
阿梦Anmory7 小时前
Ubuntu配置代理最详细教程
linux·运维·ubuntu
云姜.7 小时前
线程和进程的关系
java·linux·jvm
wregjru7 小时前
【QT】4.QWidget控件(2)
c++
浅念-7 小时前
C++入门(2)
开发语言·c++·经验分享·笔记·学习
小羊不会打字7 小时前
CANN 生态中的跨框架兼容桥梁:`onnx-adapter` 项目实现无缝模型迁移
c++·深度学习