目录
一、进程概念
什么是task_struck
task_struct包含内容
二、查看进程
1. ps 查看:
2. /proc/目录查看
3. top 指令
三、系统调用获取进程标示符
获取自己、父进程ID
四、创建进程
1. 初识fork
2. 理解fork创建子进程
3. fork后的数据修改
4.fork的返回值
fork返回值含义
5. 使用fork的方式
五、进程状态
1. 进程状态定义
2. 阻塞
3. 挂起
4. R状态
5. S状态
5. D状态
6. T状态
6.1 kill指令
6.2 暂停进程、继续进程、杀死进程
6.3 追踪式暂停 t
7. X状态 && Z状态
Z状态
X-死亡状态
六、孤儿进程
七、环境变量
1. 基本概念
2.常见环境变量
3. PATH
4. 获取环境变量
4.1 命令行参数
4.2 环境变量表
4.3 通过 main 函数中的第三个参数 char* envp[] 获取
4.4 通过全局变量 environ (char** 类型) 获取
4.5 使用getenv获取环境变量
5. 环境变量的全局属性
6. 本地变量
八、进程优先级
1. 概念
2. 为什么要有进程优先级
3. 查看系统进程
4. PRI和NI
5.使用top命令更改进程优先级
5.1 更改NI值
5.2 NI的取值范围
5.3 NI取值范围较小的原因
九、程序地址空间
1.程序地址空间分布
2. 真实空间分布
编辑2.1 mm_struct
2.2 页表和MMU
3. 进程地址空间存在的原因
4. 重新理解地址空间
5. 总结
十、进程控制
1. 进程创建
1.1 fork函数
1.2 fork函数返回值
1.3 写时拷贝
2. 进程终止
1. 进程退出码
2. 进程退出方式
2.1 exit()
2.2 二者区别
3. 进程等待
1. 进程等待必要性
2. 进程等待的方法
2.1 wait
编辑
2.2 waitpid
2.3 获取子进程退出信息
十一、进程程序替换
1、替换原理
1.1、进程的角度
1.2、程序的角度
2. 替换函数
2.1 execl
2.2. execv
2.3 execlp
2.4 execvp
2.5. execle
2.6 execvpe
2.7 execve
总结
一般课本定义:进程
是程序的一个执行实例,是正在执行的程序(这种说法不全面)
举个例子: 如果有一个社会人士,想要成为你们学校的学生,那么他需要满足什么样的条件呢?只需要他本人进入到你们的学校,在学校内活动就可以了吗?这显然是不行的,判断一个人是不是这个学校的学生,依据的是这个人有没有被学校所管理,学校会不会给他排课,给他计学分、发毕业证。
所以同样的,把一个可执行程序变为进程,不仅仅要把该可执行程序加载到内存中,还要让这个可执行程序被操作系统所管理。
当我们写完代码之后,编译连接就形成一个可执行程序.exe,本质是二进制文件,储存在磁盘之中。双击这个.exe文件或者使用 ./ 把程序运行起来就是把文件的代码和数据从磁盘加载到内存,然后CPU才能执行其代码语句。做到这一步,就相当于一个社会人士本人进入到了学校之中,但是这样就叫做进程了吗?显然不可能,这些代码和数据还需要被操作系统所管理。
那么操作系统如何管理被加载到内存中的数据呢?遵循我们之前文章中提到的六个字原则:先描述,再组织。
每一个程序在加载到内存之时,操作系统会在内核之中创建一个数据结构对象,这个数据结构叫做 PCB(process control block),在Linux操作系统下的PCB是 task_struct 。由于 Linux 是使用C语言写的,所以Linux操作系统下的 task_struct 就是结构体。这些结构体对象提取并填充了对应进程的属性,并且每个结构体对象里都有一个指向其对应代码和数据的指针。这就是先描述的过程。 这样随着多个进程加载到内存中,操作系统内核里也就有了多个描述结构体,这些结构体都叫 PCB,并以特定的数据结构连接起来。这就是再组织的过程:
操作系统对每一个进程进行了描述,这就有了一个一个的PCB,Linux中的PCB就是task_struct(在其他操作系统中的PCB就不一定叫task_struct),这个struct会有next、prev指针,可以用双向链表把进程链接起来,task_struct结构体的部分指针也可以指向进程的代码和数据:
所有运行在系统里的进程,都以task_struct作为链表节点的形式存储在内核里,这样就把对进程的管理变成了对链表的增删改查操作。
此后所有对于进程的管理都被转换成了对数据结构PCB的增删查改,这是一个对进程的管理建模的过程。
所以进程的正确定义:进程是内核关于进程的相关数据结构与当前进程的代码和数据的结合。
很多课本中着重的强调了当前进程的代码和数据的部分,而忽略掉了内核中相关数据结构的部分。
我们知道文件包括内容+属性,那么在操作系统中,为了管理进程所创建的PCB中的进程属性与磁盘中文件的属性有关联吗?有关联,但是关联不大,有关联的部分包括文件属性中的权限、名称、大小等等,但大部分的属性是没有关联的。
并把它编译形成一个可执行文件。此时,我们使用 ./ 来运行这个可执行文件就会自动创建一个进程。
ps axj
如果我们只想看某一个进程,则可以使用 grep 来进行过滤:
ps axj | grep [进程名]
此时就可以看到有一个 myprocess 进程在进行,至于下面第二行中的 grep -color=automyprocess 字样,则是因为我们在系统之中查找进程时,由于 grep 文本过滤自己也是一个进程,就会导致自己把自己也给过滤出来了,并且显示在下面,如果不想看到这一行,可以通过指令 grep -v grep 来避免显示:
head -1 为显示进程信息的第一行,即:PPID PID PGID SID...
可以发现其中一个属性 PID (即进程的ID) 是不同的,这说明同一个可执行程序被启动了两次,所产生的是两个不同的进程。换言之,把一个可执行程序多次加载到内存中,会存在多个进程。
除了使用 ps 命令查看进程之外,还有一种方式可以查看进程,那就是查看 proc 目录。proc 目录是一个内存级的文件系统,只有当操作系统启动的时候,它才会存在,在磁盘上并不存在 proc 目录。我们可以通过该目录查到内存相关的信息:
这一大堆蓝色的都是目录名称,其中蓝色数字就是系统中特定进程的 PID ,我们进入其中一个以新增进程的 PID 命名的目录,就可以看到所启动的进程相关的属性了:
其中有两个属性我们比较熟悉,一个是可执行程序对应的路径,一个是可执行程序的名称。这就是为什么说管理进程所创建的PCB中的进程属性与磁盘中文件的属性有小部分关联,就体现在这里。
我们将两个进程终止掉之后:
此时,我们再在 proc 目录中查看以进程 PID 命名的目录时,就会提示进程不存在:
所以 proc 目录里的内容是动态变化的。
这个指令之前有介绍过,相当于Windows中的 ctrl+alt+del
调出任务管理器一样,top
指令能直接调起 Linux
中的任务管理器,显然,任务管理器中包含有进程相关信息:
获取进程自己的PID的系统调用:
getpid() 、gertppid()
通过函数说明,我们得知谁调用这个函数,就获取谁的PID。
就可以直接看到该进程的PID了。现在我们使用 Ctrl+c 终止该进程,然后再重新运行可执行文件生成新的进程,再来观察一下:
可以看到每次重新启动进程,PID都不一样,这是很正常的,因为每一次进程在加载启动的时候,操作系统就创建PID,PID是操作系统来维护的,线性递增,而PPID却没有变化,那么PPID是谁呢?
发现父进程的ID是11081,即命令行解释器,但是16021同时也是bash的子进程。
结论:
那么 bash 为什么要创建子进程来执行程序呢?
这是为了防止我们执行的程序发生错误,运行命令行的命令有风险,命令行出错了,不能影响命令行解释,因此在命令行上运行的命令,基本上父进程都是bash。如果 bash 自己来执行程序,如果程序挂了,那么 bash 也就挂了,这是相当危险的事情。
在以前,我们熟悉的创建进程的方式有两种,第一种是在Windows系统下,我们双击一个 .exe 文件,就创建了一个进程,还有一种是在Linux系统下,我们通过在命令行输入 ./ 来将程序变成进程去运行
现在我们再来学习一种创建进程的方式,通过系统调用:fork
发现这两行进程的PID不同,可以说明这是两个不同的进程,之后又发现第二行进程的PPID,刚好是第一行进程的PID,这说明这两个进程是父子关系。此时我们就完成了创建子进程的操作。那我们如何控制父进程与子进程呢?
手册说明:fork 的返回值类型是 pid_t(即有符号整数)。进程创建成功,子进程的PID会返回给父进程,0 会返回给子进程。进程创建失败,-1 会被返回给父进程。
./可执行程序、命令行、fork,站在操作系统角度,创建进程的方式没有差别,都是系统中多了个进程。fork创建出来的子进程,和父进程不一样,父进程在磁盘上是有可执行程序的,运行可执行程序时会把对应的代码和数据加载到内存中去运行。
但是子进程只是被创建出来的,没有进程的代码和数据,默认情况下,子进程会继承父进程的代码和数据,子进程的数据结构task_struct也会以父进程的task_struct为模板来初始化子进程的task_struct。因此子进程会执行父进程fork之后的代码,来访问父进程的数据。
int main()
{
printf("AAAAAAAAA\n");
pid_t ret = fork();
printf("BBBBBBBBB:pid:%d,ppid:%d, ret:%d, &ret:%p\n", getpid(), getppid(), ret, &ret);
sleep(1);
return 0;
}
这时观察到了一个奇怪的现象:打印两次 ret 的值不同,为什么一个函数会有两个返回值呢?这两个 ret 的地址相同,说明他们是同一个变量,但是为什么打印出了两个不一样的值呢?
首先我们需要知道 fork 做了什么,进程 = 内核数据结构 + 进程的代码和数据,当我们创建子进程的时候,并不是把代码和数据又拷贝了一份,而是在内核中再创建一个子进程PCB,子进程PCB的大部分属性会以父进程PCB为模板,并把属性信息拷贝进来。
父进程的PCB指向自己的代码和数据,子进程PCB也指向同样的代码和数据。所以 fork 就相当于在内核中创建独立的PCB结构,并让子进程与父进程共享一份代码和数据。
进程在运行的时候是有独立性的,任何一个进程出现故障不会影响其他进程,父子进程运行的时候也是一样的。
代码是不可以被修改的。 那么数据呢?子进程和父进程共享数据,当父进程修改数据时,子进程看到的数据也被修改了,那么父进程就会影响子进程。那这两个进程还具有独立性吗?
写时拷贝是为了维护进程独立性,为了防止多个进程运行时互相干扰。而在创建子进程时不会让子进程把父进程的所有数据全部都拷贝一份,因为并不是所有情况下都可能产生数据写入,所以这就避免了fork时的效率降低和浪费更多空间的问题。因此只有写入数据时再开辟空间才是合理的。
所以我们再来看看为什么打印出了两个不一样的值?
fork出子进程后,一般会让子进程和父进程去干不同的事情,这时候如何区分父子进程呢?fork函数的返回值如下:
我们知道,当一个函数准备执行 return 语句的时候,该函数的主体功能就已经完成了,return 语句不影响函数的功能,仅仅起到返回结果的作用。因此, fork 系统调用函数在执行 return 语句之前,子进程就已经创建完成并已经在进行中了,所以当执行 return 语句返回结果的时候,就要给父进程与子进程各自返回一份结果,即执行了两次。最终返回结果被赋值给变量 ret 的时候,OS自动触发了写时拷贝,分别把结果存入两者的备份空间中。至于为什么打印出来的 ret 的地址是相同的,这与虚拟地址有关,下面会讲。
给父进程返回子进程的pid的原因是,一个父进程可能有多个子进程,子进程必须得用pid来进行标识区分,所以一般给父进程返回子进程的pid来控制子进程。子进程想知道父进程pid可以通过get_ppid( )来获取。这样就可以维护父子进程了。
总结:
一般情况下,我们使用 fork 创建子进程之后通常要用 if 进行分流:
#include
#include
#include
#include
int main()
{
pid_t ret = fork();
assert(ret != -1);
if(ret == 0)
{
//子进程
while(1)
{
printf("子进程:PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
}
else if(ret > 0)
{
//父进程
while(1)
{
printf("父进程,PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
}
else{}
return0;
}
fork之后,执行流会变成两个执行流,谁先运行由调度器决定。父子进程通过 if 分流分别执行不同的代码块,进而实现不同的功能。
进程在CPU上运行的时候,并不是一直在运行的,而是一个进程先在CPU上运行一会,再切换另一个进程在CPU上运行一会,不断的切换进程周而复始重复运作的。这叫做基于进程切换的分时操作系统,由于CPU的运行速度非常快,切换速度使人类感觉不到,从而使人们有种进程一直在运行的感觉。而CPU会去调用哪一个进程,是由进程的状态来决定的。
一个进程从创建而产生至撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器、有时虽有空闲处理器但因等待某个事件的发生而无法执行,这说明进程和程序不相同,它是活动的且有状态变化的,能够体现一个进程的生命状态,可以用一组状态来描述:
一个进程可以有多个状态,我们先来说明两个最为核心的状态:阻塞和挂起。
进程因为等待某种条件就绪,而导致的一种不推进的状态叫做阻塞状态,给人们最直观的感受就是程序卡住了。换句话说,一个进程阻塞,一定是在等待某种所需要的资源就绪的过程。
想象这样一个场景,我们在下载一些资料的时候,如果网断了,CPU还有必要继续调度这个下载进程吗?肯定是没必要了,因为没有意义,此时就会把该进程设置为阻塞状态。那么这个进程是如何等待网络资源就绪的呢?
我们之前讲过,操作系统要管理网卡、磁盘等外设,是一个先描述再组织的过程,操作系统创建多个结构体类型,这里命名为 struct dev ,并把各个外设的属性信息提取填充进来,再用对应的数据结构把他们链接到一起。同样,操作系统管理大量的进程也是一个先描述再组织的过程。
当网络断开时, 需要等待网络资源的进程就会把自己的PCB从CPU的某些特定队列中拿取出来,连接到网卡设备结构体队列的尾部来排队等待网络资源:
此时,再获取到等待的资源之前,该进程不会再被CPU调度。
PCB是可以被维护在不同的队列中的。进程在等待哪种资源,就会被排列到哪种资源的队列中去。再举个例子,当我们在C语言中使用scanf 函数时,运行程序,如果我们不在键盘上输入内容,进程就会处于阻塞状态,并在键盘的结构体中排队等待资源,只有拿到数据时,进程才会再次被CPU调度。
总结:阻塞就是不被CPU调度——一定是因为当前进程需要等待某种资源就绪——一定是进程tesk_struct结构体需要在某种被OS管理的资源下排队。
如果有时候出现了内存资源紧张的情况,而且阻塞进程的PCB被接入到了所需要等待资源的结构体队列中,不被调度。这时,操作系统就会把阻塞进程的代码和数据交换到磁盘中,同时释放其所在内存中占据的空间,从而起到节省内存空间的目的。等到进程所需要的资源就绪的时候,再把该进程的代码和数据加载到内存中,交由CPU调度。
其中把进程的代码和数据由OS暂时性的交换到磁盘中时,称该进程处于挂起状态。全称为阻塞挂起状态。挂起可以看作一种特殊的阻塞状态。
比如在我们生活中,一边走路一边玩手机很危险,所以此时我们会将玩手机这个 进程挂起
,即把手机揣进兜里,然后 专心执行走路这个 进程。
当一个进程被加载运行时,该进程处于 R 状态。但是进程是 R 状态并不一定代表其一定在CPU上运行,而代表该进程在运行队列中排队。
一个进程是什么状态,一般也看这个进程在哪里排队。 其中在CPU的运行结构体队列中排队等待调度的进程,都是运行状态,即 R 状态。在其他资源结构体队列中排队的进程,都是阻塞状态。
此时,进程的状态就是 R 状态了。
出现这种情况的原因是, printf 函数是向外设打印消息,而外设并不是随时准备就绪的,也就是说进程在执行 printf 函数时,需要在外设的结构体队列中排队等待,当外设资源就绪时,该进程才能被CPU调度,变为 R 状态。其他时间都是阻塞状态, S 状态就是一种阻塞状态。
S(Sleeping) :进程正在等待某事件完成,可以被唤醒,也可被杀死
#include
int main()
{
while(1)
{
int a = 0;
scanf("%d", &a);
printf("%d\n", a);1
}
return 0;
}
此时进程就在等scanf输入键盘资源,也说明 睡眠 S
的本质就是 进程阻塞
,表示此时进程因等待某种资源而暂停运行。
睡眠 S
又称为可中断休眠,当 进程
等待时间过长时,我们可以手动将其关闭,应用卡死后强制关闭也是这个道理。
D 状态也是一种休眠状态,不可被中断休眠。
在一些进程极多、内存压力极大的情况下,OS是有权利杀掉休眠状态的进程以腾出空间保证其他进程正常运行的,这也是十分合理的。
但是在有一种情况下,这种权力变成了不合理,那就是这个休眠的进程正在磁盘区排队,向磁盘存入数据,如果这个时候OS把该进程杀掉了,就会导致磁盘存储数据泄漏,万一这个数据还特别重要,就会造成非常严重的后果。
为了解决这个问题,就设计出了 D 状态,处于 D 状态的进程无法被OS杀死,甚至在系统中存在 D 状态的进程时,计算机都没有办法正常关机。只有当 D 状态的进程自己拿到资源了,进程
才会停止 D 状态。终止 D 进程的一个方法就是切断电源,此时进程是结束了,但整个系统也结束了。
事实上,一般情况下不会出现 D 状态的进程的,D 状态进程一旦出现,就说明磁盘的空间已经非常的紧张,存储速度非常的慢了,需要力保写入数据的进程活着完成任务,长时间内不能被OS杀死。既然OS都已经需要主动杀死休眠的进程并且磁盘资源已经不够了,可见此时内存的情况也好不到哪里去。当系统中出现了一个 D 状态的进程,就离计算机宕机不远了。
T 状态名为暂停状态,也是一种阻塞状态。我们在调试程序时,让程序在断点处停下来,本质上就是让进程暂停!
查看 kill 指令
kill -l
在这里主要使用编号为 9、18、19 的命令选项,功能分别为 杀死进程、继续进程、暂停进程。
我们先运行进程,并查看进程状态,观测到的是 S+ 状态,但实际上程序已经运行了,现在使用指令:
kill -19 [进程PID]
接着使用指令:
kill -18 [进程PID]
使用 kill 指令恢复进程后,可以发现进程状态从原来的 S+ 变为了 S ,并且使用 Ctrl+c 已经没有办法结束进程了:
进程状态的 "+" 号表示前台运行,没有 "+" 号就表示后台运行, Ctrl+c 只能结束前台运行的进程。
此时我们需要使用指令:
kill -9 [进程PID]
在 gdb
中调试代码时,打断点实际上就是 使 进程
在指定行暂停运行,此时 进程
处于 追踪暂停状态 t
一般我们再写 main 函数时,会在最后写一个 return0;,这叫做进程退出码,我们使用以下指令可以查到进程退出码:
echo $?
如果一个子进程结束时,立刻退出,父进程是没有机会拿到退出结果的。所以在Linux中,进程退出时,一般不会立即彻底退出,而是要维持一个 Z 状态,也叫僵尸状态,方便后续父进程读取该子进程的退出结果。
通俗来说,僵尸状态 是给 父进程 准备的,当 子进程 被终止后,会先维持一个 僵尸 状态,方便 父进程 来读取到 子进程 的退出结果,然后再将 子进程 回收。
单纯的在 bash 环境下终止 子进程,是观察不到 僵尸状态 的,因为 bash 会执行回收机制,将 僵尸 回收,我们可以利用 fork() 函数自己创建 父子进程 关系,观察到这一现象:
此时,子进程变为 Z 状态,即僵尸状态。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程就进入 Z 状态。
如果我们不去主动回收 Z 状态的进程,那么该进程就会一直存在,操作系统就会一直维护该进程的PCB,占据内存的空间,可以理解为内存泄漏,所以僵尸进程必须要回收,具体回收的方法以后会详细讲解。
这个状态只是一个返回状态,在任务列表里看不到这个状态。因为当进程退出时,释放进程所占用的资源时一瞬间就释放完了,所以死亡状态看不到。
僵尸进程是子进程先退出,但是父进程没有读取子进程的退出信息。
假如父进程先退出,子进程后退出,此时子进程处于僵尸状态,没有父进程来读取它的退出信息,此时子进程就称为孤儿进程。
最开始时,子进程与父进程同时运行,过一段时间后,父进程终止,子进程继续:
可以发现,父进程退出后,子进程就变成了孤儿进程,但是子进程的PPID变成了1,即子进程的父进程变成了1号进程:
从而我们可以得出结论,父进程在退出后,OS会让 1 号进程成为子进程的新父进程,这个被领养的子进程就是孤儿进程。1号进程是init进程,也叫做操作系统进程,当出现孤儿进程的时候,孤儿进程就会被1号int进程领养,当孤儿进程进入僵尸状态时,就由1号init进程回收。
如果OS不领养孤儿进程,那么该孤儿进程就永远都无法回收,有 内存泄漏
的风险,其PCB永远被维护,占据内存空间。
同时,我们观察到孤儿进程的状态从 S+ 变为了 S ,即从前台运行转为了后台运行,此时我们使用 ctrl + c 已经无法终止它了,需要使用指令 killall [进程名称] 或者 kill -9 [PID] 来终止该进程。
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数,如:我们在编写C/C++代码时,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。环境变量通常具有某些特殊用途,在系统当中通常具有全局特性。
我们可以通过以下指令来查看环境变量:
echo $[环境变量名称]
我们可用通过指令 echo #NAME 查看当前环境变量信息(NAME 指环境变量名)
输入如下指令,可以看到系统的环境变量:
env
问题1:我们运行可执行程序时,为什么需要在可执行程序前面加上 ./ 才能执行捏:
问题2:但是在执行系统命令诸如 ls、pwd 时,为什么不需要在前面加上 ./ 捏:
之所以要加上 ./ ,是因为在程序运行时,需要说明该可执行程序所处的路径,执行一个命令的前提是先找到它。如果我们愿意,也可以使用绝对路径来说明:
接下来回答第二个问题,为什么同为可执行程序的 ls 、 pwd 等等诸多指令在使用时不需加 ./ 来说明其所处路径呢?
Linux中的各种指令都是用 C语言
编写的程序,所以:运行指令 == 运行程序。命令、程序、工具,本质都是可执行文件,./ 的作用就是帮系统确认对应的程序在哪里,由于在系统中存在一个环境变量帮助我们在特定路径下搜索这些默认指令,这个环境变量叫做 PATH。由于PATH的存在,所以执行系统命令时,不需要在系统命令前加 ./
查看环境变量的方法:
echo $PATH
我们在执行命令时,系统会自动在这些路径中从前向后依次寻找,找到了就自动执行,而不需要人为说明其所在路径。即系统执行命令时,操作系统通过环境变量PATH,去搜索对应的可执行程序路径。
使用 which 指令来查看部分系统默认指令所处的路径,可以看到 ls 与 pwd 的路径刚好被包括在 PATH 之中。
解决了如上两个问题后,我们该如何让自己编写的可执行程序在运行时也不需要加上其路径呢?
不能直接把当前路径赋值给PATH,否则上面的6种路径就全没了。很简单,只需要把自己的可执行程序的路径添加到 PATH 中就可以了,使用命令:
export PATH=$PATH:[自己的程序的路径]
可以看到可执行程序的路径已追加到PATH上了,如此一来,便可以直接执行自己的可执行程序了。现在在其他路径下也可以执行该可执行程序了, 比如在家目录下执行, 系统会帮我们找到路径。
注意: 普通用户添加的环境变量只有本次登录有效,下次再登录时,环境变量列表会被恢复初始状态,所以大家也不用担心在设置环境变量时出现错误。普通用户修改 环境变量列表
没什么大问题,但 root
需要谨慎了,避免造成严重后果。
除了这一种做法以外,我们还可以把自己的可执行程序拷贝到 PATH 中的 /usr/bin 下,l达到相同的效果,这里不再演示。 实际上,在Linux中,把可执行程序拷贝到系统默认路径下,让我们可以直接访问的方式,就相当于Linux下软件的安装。把可执行程序从系统默认路径下删除,就相当于软件的卸载。
以前写c代码时,main函数可以带2个参数:
#include
int main(int argc,char *argv[])
{
return 0;
}
其中第二个参数 argv 是指针数组,数组元素一共有 argc 个,argc 决定了有几个有效命令行那个字符串。可以把命令行参数的细节打印出来:
#include
int main(int argc,char *argv[])
{
int i = 0;
for(i = 0;i
命令行参数数组的元素个数是动态变化的,有几个参数就有对应的长度大小:
在命令行中传递的各种各样的数据最终都会传递给main函数,由main函数一次保存在 argv 中,由argc 再表明个数 。
数组结尾是NULL,那么可以不使用argc吗?不可以,原因有两个:
if(argc != 5)
{
//TODO
}
命令行参数的作用在于,同一个程序可以用给它带入不同参数的方式来让它呈现出不同的表现形式或完成不同功能,例如:
实现一个程序,假如输入参数为o或e,就打印hello linux:
#include
#include
#include
int main(int argc,char *argv[])
{
if(argc != 2)//输入参数不为2时
{
printf("Usage: %s -[m|a]\n",argv[0]);
return 1;
}
if(strcmp(argv[1],"-m") == 0)//输入第二个参数为-l
{
printf("good morning! -m\n");
}
else if(strcmp(argv[1],"-a") == 0)//输入第三个参数为-n
{
printf("good afternoon! -a\n");
}
else
{
printf("good!\n");
}
return 0;
}
输入不同的参数就有不同的执行结果:
命令行参数的意义在于,指令有很多选项,用来完成同一个命令的不同子功能。选项底层使用的就是命令行参数。
假如函数没有参数,那么可以使用可变参数列表去获取。
每个进程在启动的时候都会收到一张环境遍历表,环境变量表主要指环境变量的集合,每个进程都有一个环境变量表,用于记录与当前进程相关的环境变量信息。
环境变量表采用字符指针数组的形式进行存储,然后使用全局变量char** envrion来记录环境变量表的首地址,使用NULL表示环境表的末尾:
我们可以在程序中获取 环境变量
main
函数中的第三个参数 char* envp[]
获取environ
(char**
类型) 获取getenv(NAME)
获取,这个比较常用也可以通过 set
指令查看 环境变量表
,不过 set
指令显示的内容比 env
多得多,因为 set
还会显示 本地环境变量
信息。
$ set //显示更加丰富的环境变量表
main
函数中的第三个参数 char* envp[]
获取main
函数有两种写法:带参与不带参,平常我们都是使用不带参数的 main
函数作为程序入口,对于函数参数很少关注,今天就来看看 main
函数中的参数:
int main(int argc, char* argv[], char* envp[])
{}
int argc
传入程序中的元素数,./程序名
算一个char* argv[]
传入程序中的元素表,由 bash
制作,传给 main
函数char* envp[]
环境变量表,所谓全局性就是指 main
函数可以通过此参数获取到环境变量表的信息其中 char* envp[ ] 是一个指针数组,该数组里面的指针都分别指向不同的字符串,并且最后一个指向有效字符串的指针的下一个指针一定指向 "NULL" :
构成了上图所示的表结构形式。
编写如下程序:
#include
#include
int main(int argc, char* argv[], char* envp[])
{
for(int i = 0; envp[i]; i++)
{
printf("envp[%d] -> %s\n", i, envp[i]);
}
return 0;
}
environ
(char**
类型) 获取libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明。
#include
#include
int main(int argc, char* argv[])
{
extern char **environ;
for(int i = 0; environ[i]; i++)
{
printf("environ[%d] -> %s\n", i, environ[i]);
}
return 0;
}
可以发现这些指针指向的就是一个一个环境变量字符串。
讲到这里,我们意识到当函数传参数组时,传递的不是数组本身,而是数组首元素地址,所以在 main 函数的形参列表中写成的 char* envp[ ] 形式,本质上是一个二级指针,也就是 environ ,查看一下 man 手册:
man environ
以后想要获取环境变量就可以通过遍历这个表状结构体获取了。
但是这样做的话太过于麻烦,为了方便起见,主流的获取环境变量的方法是通过函数获取,该函数名为 getenv() 。
getenv("[环境变量名]")
指令 pwd
实现非常简单,通过 getenv("PWD")
获取信息,再输出即可,我们可以自己实现
#include
#include
#include
int main()
{
char *user = getenv("PWD");
if(user == NULL) perror("getenv failed");
else printf("%s\n", user);
return 0;
}
添加到环境变量去,实现跟 pwd 指令一样的效果:显示当前路径
有了以上知识,我们可以知道环境变量本质上就是内存级的一张表,这张表在用户登录系统的时候,由系统给该用户自己单独形成。每一个环境变量都有自己的用途,有的是进行路径查找的,有的是进行身份认证的,有的是进行动态库查找的,有的是用来确认当前路径的等等。每一个环境变量都有自己的特定应用场景。
结论:环境变量
针对的是特定的人在特定场合干特定的事,这句话读起来有点绕,实际上:
环境变量
存储的是用户的个人信息,不同用户的 环境变量表
各不相同环境变量
做信息验证,根据不同变量(选项)执行不同操作比如
ls
指令是显示当前目录下的文件信息,而ls -a
则是显示详细信息,原理很简单,调用ls
程序时传递了-a
这个选项,使得程序一对比,就知道要执行显示详细信息这个操作
环境变量具有全局属性,程序运行时,环境变量表会传递给程序使用 。
其中,环境变量对应的数据,都是从系统的相关配置文件中读取进来的,我们进入自己的家目录,查看该目录下的所有文件:
可以看到两个隐藏配置文件,分别叫做 .bash_profile 与 .bashrc , 打开 .bash_profile ,观察内容:
可以发现这里就有着环境变量 PATH ,当用户登录的时候,程序就会自动执行当前用户下的配置文件,加载完成后,环境变量也就被添加进来了。所以用户每次登录,都会重新加载一遍配置文件。 除了用户自己的环境变量,还有一些全局的环境变量是在 /etc 路径下的配置件 .bashrc 中,在系统启动时添加的,这里暂时不做介绍。
如果想删除已经设置的 本地环境变量
,可以通过 unset NAME
移除设置
$ unset TEST //移除已设置的本地环境变量
我们知道,当用户登录机器时,操作系统会给用户创建一个 shell ,用来为该用户提供命令行解释。 shell 本身是一个进程,会在 shell 中维护上面我们所提到过的环境变量的表状结构。
用户在执行命令时,都是命令行解释器 shell 帮用户执行的,对应到 Linux 中,是 bash 在执行。 bash 除了可以执行命令外,还可以命令行式的自定义变量:
在命令行中写下指令 myval=100 后,shell读取到指令,就会在内存中申请一块空间,并把该变量以字符串 "myval=100" 的形式存放进去,最后在shell内部另外生成一个指针指向该字符串。
myval 是在命令行中定义的,只是前面没有加上 export ,所以 myval 虽然存在,但是并没有被导入表状结构中,这种变量被称为本地变量。
环境变量相对也就是本地变量,针对当前用户的当前进程生效,是一种临时变量,退出本次登陆后就失效了。
如下,变量value的值在没有退出登录前,打印到是100,ctrl+d退出登录后:
再去 echo $myval 发现 myval 已经失效了
提问:本地变量能被子进程继承吗?用 env 查看,发现shell的上下文中是没有的:
结论:本地变量只在shell内部有效,不被子进程所继承,只能bash自己用。
如果在命令行前面加上了指令 export ,那么就会换把表状结构中的空余指针指向该字符串:
再使用指令 env 查看一下环境变量表:我们新增的环境变量 hello 正在其中。
由于所有在命令行中执行的命令都是shell的子进程,所以当我们以后执行命令时,shell就会给这些子进程传参,使子进程拿到这个表状结构。
写一个程序来证明一下:
#include
#include
#include
int main()
{
printf("myenv:%s\n", getenv("hello"));
return 0;
}
ps -l
可以看到该子进程确实读取到了刚刚新增的环境变量 hello 。所以环境变量是可以被所有的子进程继承的,即环境变量具有全局属性。
而在命令行输入指令 echo $myval 时,shell创建的子进程却能够获得该本地变量并把它打印出来,这涉及到另一个概念:内建命令。这部分内容以后会进行详细讲解。
进程的优先级就是CPU资源分配的先后顺序 ,即进程的优先权,优先权高的进程有优先执行权力。
因为CPU资源是有限的,一个CPU只能同时运行一个进程,当系统中有多个进程时,就需要进程优先级来确定进程获取CPU资源的能力。
另外,配置进程优先权对多任务环境的linux很有用,可以改善系统性能。还可以把进程运行到指定的CPU上,这就把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
ps -l
可以看到
- UID : 代表执行者的身份,表明该进程由谁启动
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行,默认为 80
- NI :代表这个进程的nice值,这个只有
Linux
中有,配合修改优先级,范围为[-20, 19]
注意: nice值不是进程的优先级,是进程优先级的修正数据,会影响到进程的优先级变化。
进程优先级
可以被修改,但很少有人会主动修改
先运行一个进程,使用
ps -al
查看进程号、优先级及 NI 值,比如执行./myproc 进程, 可以查看到优先级为80,NI 值为0:
修改步骤
然后查看进程的优先级和NI值,优先级变成了90,NI 值变成了10:
PRI(new) = PRI(old)+nice
PRI(old)一般都是80,这就是为什么没有修改 NI 值之前,用 ps -al 命令查看到的进程的PRI都是80的原因。
现在验证一下NI(nice)的取值范围,假如将NI的值设为100:
再查看进程的优先级和NI值,发现 NI 值变成了19,优先级增加了19:
这说明 NI 的上限就是19,那么下限呢? 将NI值改为-100,需root权限:
发现 NI 值变成了-20,说明本次 的 NI 值变成了-20,优先级减小了20:
这也证明 NI 的取值范围为[-20,19],一共40个级别。
因为优先级再怎么设置,也只能是一种相对的优先级,不能出现绝对的优先级,否则会出现很严重的进程“饥饿问题”,即某个进程长时间得不到CPU资源,而调度器需要较为均衡地让每个进程享受到CPU资源。
注意:
NI
值区间为[-20, 19]
,设置时超出部分无效- 修改优先级时,
最终优先级
=初始优先级
+NI值
,优先级的修改行为并不是连续的,每次都是在最开始的基础上进行修改(默认为 80)- 调度器不允许存在
优先级失衡
的情况,因此优先级修改不能太激进
C/C++程序地址空间:
那么C/C++的程序地址空间是内存吗?为了验证它到底是什么,可以使用如下代码:
#include
#include
#include
int g_value = 100;//全局变量
int main()
{
pid_t id = fork();
assert(id>=0);
if(id == 0)
{
// child
while(1)
{
printf("我是子进程, 我的id是:%d, 我的父进程是:%d, g_value:%d, &g_value:%p\n",
getpid(), getppid(), g_value, &g_value);
sleep(1);
g_value=200; //只有子进程会进行修改
}
}
else
{
// child
while(1)
{
printf("我是父进程, 我的id是:%d, 我的父进程是:%d, g_value:%d, &g_value:%p\n",
getpid(), getppid(), g_value, &g_value);
sleep(1);
}
}
}
父子进程全局变量 g_value 的地址是相同的,但是值却不同。分析:
我们说过,进程具有独立性。而进程 = 内核数据结构 + 代码和数据,因此我们需要保证不同进程之间的数据互相不会影响,OS是通过写时拷贝的做法来实现这个目的的。这就可以解释为什么在子进程修改了 g_value 的值后,父进程中的值没有被影响。
能得出如下结论:
写时拷贝
机制每一个进程在内核中都有一个地址空间结构体,名为 mm_struct ,并且在该进程的 task_struct 中有一个指针指向这个结构体 mm_struct 。
我们观察到虚拟地址空间中包含:代码区、数据区、堆区等等区域,这叫做区域划分,通过对线性区域进行指定 start 和 end 即可完成区域的划分:
划分完区域后,比如 brk_start 的值为 1000 , brk_end 的值为 5000 。那么 [1000,5000] 之间的区域就叫做虚拟地址或线性地址。对于区域的扩大或缩小操作,只需要改变 start 与 end 的数值就可以了。
同 task_struct 一样,mm_struct 中也包含了很多成员,比如不同区域的边界值:
//简单展示其中的成员信息
mm_struct
{
//代码区域划分
unsigned long code_start;
unsigned long code_end;
//堆区域划分
unsigned long heap_start;
unsigned long heap_end;
//栈区域划分
unsigned long stack_start;
unsigned long stack_end;
//还有很多其他信息
……
}
MMU(Memory Manage Unit)内存管理单元,是虚拟地址的整体空间,是对整个用户空间的描述。MMU一般继承在CPU当中。
进程的各个区包括代码区,已初始化区、未初始化区、堆区、栈区、共享区等都是虚拟地址,经过页表和MMU映射成对应的物理地址,再让进程去访问代码和数据。
页表是一种数据结构,记录页面和页框的对应关系,本质是映射表,其左侧是应用时填充的虚拟地址,右侧是该虚拟地址对应的物理地址,增加了权限管理,隔离了地址空间,能够将虚拟地址转换成物理地址。操作系统为每个进程维护一张页表。
如果有多个进程(真实地址空间只有一份),此时情况是这样的:
此时可以理解为什么会发生同一块空间能读取到不同值的现象了
当我们创建子进程后,OS以父进程的PCB为模板创建了子进程的PCB结构。所以子进程也拥有自己的 mm_struct,且在同样的位置存在与父进程相同的虚拟地址:
当我们对子进程或父进程的数据不做修改的时候,父子进程读取的变量数据与变量地址都是相同的。而当我们修改父进程或子进程的数据时,先修改哪一个,哪一个就会发生写时拷贝,即在物理内存中另外再开辟一块空间,并修改页表的映射关系,使之映射到新空间:
我们所观测到的结果就是该变量的虚拟地址不变,但是其实已经被映射到了物理内存的另一处空间,所以读取到的数据也就不同了。即地址相同,但内容不同。
总结:同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!
如果进程直接访问内存行不行呢?为什么中间要映射呢?
这是因为加了一个中间层有利于管理,防止进程的不合法行为。如果让一个进程直接去访问物理内存,可以访问自己的代码和数据,但这个进程也有可能访问修改别的进程的代码和数据,甚至有些恶意进程通过非法指针访问操作别的进程的代码和数据,这会带来严重问题,可能威胁到系统安全。
存在进程地址空间的原因:
① 通过添加一层软件层,完成有效的对进程操作内存进行风险管理(权限管理),本质的目的是为了保护物理内存及各个进程的数据安全。
而有了虚拟地址空间与页表之后,任何一个被 cpu 读进来的数据进行访问的时候,必须要通过地址空间结合页表进行映射,才能够访问对应的物理内存。而在映射的时候,OS会对访问进行检查,如果出现越界的行为,OS会对操作进行拦截,通过添加软件层的方式,来保证在映射过程中,在访问之前对物理空间进行保护。比如:
char* str = "good morning";
*str = "G";
修改str指向的变量的值时,是不被允许的。因为str是栈上的局部变量,但是good morning在字符常量区,不可以修改,因为操作系统给你的权限只有 r 读权限。这就是为什么代码区内容是不可以修改的,字符常量区内容也不能修改的原因。因为页表是有权限管理的,给代码区和字符常量区分配的权限是r读权限。所以str指向的是虚拟地址,当要对*str进行写入的时候,访问的也是虚拟地址,这就需要操作系统进行虚拟地址和物理地址之间的转换,但是看到*str的权限是r读,不能写入,就把进程崩溃掉。
因此虚拟地址空间存在的第一个意义是防止地址随意访问,保护物理内存与其他进程。
② 将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和操作系统管理操作进行软件层面上的分离的目的,让应用和内存管理解耦。
在向操作系统申请空间时,操作系统并不是立刻就把空间交给我们,而是在需要使用的时候才交给我们的。这是因为操作系统一般不允许任何的浪费和不高效。
我们一般在写代码时,常常会先在前面 malloc 一块空间,可能在进程执行到一半甚至快结束时,才会真正使用这块空间。如果没有虚拟地址空间,在我们申请成功之后,使用之前,这段时间内这块空间都是没有被正常使用且别人也用不了的。这不符合操作系统不允许浪费和不高效的标准。
所以在我们申请空间时,OS只需要在虚拟地址空间上给我们申请空间,即改变 start 或 end 的值,而并没有在物理内存上开辟空间,当我们真正需要使用这块空间时,才会在物理内存上开辟空间,并在页表上建立映射关系。我们把这种机制称为缺页中断。 因此我们并不关心我们所使用的物理地址空间是哪一段,我们只关心虚拟地址空间就可以了,操作系统会通过页表映射自己找到对应的位置。
因此虚拟地址空间存在的第二个意义是将进程管理
和内存管理
进行解耦
,方便 OS
进行更高效的管理
当程序被编译、还没有被加载到内存时,就已经有地址了。当程序被加载到内存中时,会分批次的把代码段、已初始化全局数据段、未初始化全局数据段等等字段加载到地址空间中。换句话说,源代码被编译的时候,就已经按照虚拟地址空间的方式对代码和数据编好了对应的地址。虚拟地址的策略不仅会影响OS,还让编译器也遵守这样的规则。在Linux中,程序被编译后的格式称为 ELF 格式。
程序运行时,操作系统会把程序加载到内存中,cpu以固定的入口地址(比如我们熟知的main函数),通过虚拟地址空间结合页表映射到物理地址,开始执行第一条指令,把第一条指令加载到 cpu 里,而指令里涵盖的都是程序编译时早以生成的虚拟地址,所以又再一次通过虚拟地址空间结合页表映射到物理地址,开始执行第二条指令,以此类推,不断的运行下去。
程序的代码和数据是要加载到物理内存的,操作系统需要知道main函数的物理地址。如果每个进程的main函数物理地址都不一样,那么对于CPU来说执行进程代码时,都要去不同的物理地址找main函数,这样很麻烦。每个进程的main函数物理起始地址可能都不相同,但是有了进程地址空间以后,就可以把main函数的物理起始地址,通过页表和MMU都映射成同一个虚拟空间地址,这样就把这一个虚拟空间地址和各个进程的物理地址建立起了映射关系。假如还要运行其它进程,就可以把其他进程的main函数其实地址映射到那个虚拟空间地址。这样CPU在读取进程的时候,main函数起始代码统一都从同一个起始位置去读,每个进程的main函数入口位置都可以找到。
另外,数据和代码可能在物理内存中不连续,而页表通过映射的方式把所有的代码区、已初始化全局数据区、未初始化全局数据区等映射到虚拟地址空间上时,可以把它们映射到连续的区域,形成线性区域。
经过上面的学习,我们能够回答下面三个问题:地址空间是什么,为什么有地址空间,以及如何使用地址空间。
进程的代码和数据并不是一直存在于内存中,是通过虚拟地址空间,在需要的时候才在物理内存上开辟空间,并把对应的代码和数据加载到内存中的。
fork函数从一个已经存在的进程中创建一个新进程,原进程为父进程,新进程为子进程。
#include
pid_t fork(void);
返回值:子进程中返回0,父进程中返回子进程的id,出错返回-1。
进程调用fork,当控制转移到内核中的fork代码后,内核做以下动作:
对于如下代码:
#include
#include
#include
#include
int main()
{
printf("Before fork: PID = %d\n",getpid());
if(fork()==-1)
{
printf("fork error\n");
exit(1);
}
printf("After fork: PID = %d\n",getpid());
sleep(1);
return 0;
}
Befor输出了一次,Afetr输出了两次,这是因为fork之前只有父进程这一个执行流,父进程独立执行;fork之后父子进程两个执行流一起执行:
由于创建的子进程和父进程的所有代码全部都是共享的,即便是已经执行过的代码,所以理论上当父进程执行到fork之后,子进程在返回的时候,并不是从子进程的before位置开始执行的。这是因为父进程正在执行的时候,有一个pc指针,指明当前进程指向了哪里,这个pc指针的数据会被子进程继承下去。因此一旦把子进程创建出来,子进程就会从继承的父进程的pc指针的位置也就是fork之后开始运行,但其实子进程也还是能看到fork之前也就是before那段代码的。
fork之后,父子进程谁先执行完全是由调度器决定的。
为什么fork函数会有2个返回值?
在创建子进程后,操作系统要创建子进程控制块、子进程pcb、子进程地址空间、子进程页表来构建映射关系。子进程创建后,操作系统还要将子进程的进程控制块添加到系统进程列表当中,此时子进程就创建完成了。
在fork函数内部执行return 语句之前,子进程据已经创建完毕了,这时子进程和父进程都会执行return语句,fork函数就有2个返回值了。
为什么给子进程返回0?而给父进程返回子进程的pid呢?
给子进程返回0是因为对于子进程来说,父进程不需要被标识。因为一个父进程有多个子进程,因此给父进程返回子进程id来唯一标识一个子进程,当父进程知道子进程的pid后才可以更好地给子进程分配任务。
fork常规用法:
fork调用失败的原因:
举个例子:程序刚开始运行时,只有一个进程,即父进程, 父进程的pcb指向父进程的地址空间,全局变量定义出来的时候,子进程没有fork,g_Value对应已初始化区域的定义的全局变量,经过页表映射到物理内存上的g_Value
当fork创建子进程时,以父进程为模板为子进程创建新的pcb、地址空间和页表 。子进程把父进程的大部分内容都继承下来了,比如地址空间,子进程的地址空间、页表也都和父进程一样,所以创建子进程后,一开始子进程也指向了父进程的g_Value: 在第3秒的时候,子进程修改了g_Value的值,操作系统并没有让子进程直接把值改了,因为进程具有独立性,互不干扰。修改时发生写时拷贝,给子进程重新开辟一块物理空间,把g_Value变量值拷贝进来,再重新建立子进程的虚拟地址到物理地址之间的映射 :
因此看到的子进程和父进程打印的地址是一样的,是因为虚拟地址是一样的。值不一样的原因是在物理内存上本来就是不同的变量。
进程退出共有以下三种场景:
当进程正常执行完后,会返回退出码。一般而言,当结果正确,退出码为 0 ,当结果不正确,退出码为 非 0 值,比如 1、2、3、4... ,分别对应不同的错误原因,供用户进行进程退出健康状态的判断。
#include
#include
#include
int add_to_top(int top)
{
int sum = 0;
for(int i = 0; i < top; ++i)
sum += i;
return sum;
}
int main()
{
int result = add_to_top(100);
if(result == 5050) return 0;//结果正确
else return 1;//结果不正确
}
计算从 1 到 100 的累加,如果结果等于 5050 ,则说明结果正确,正常返回 0,否则说明结果不正确,返回 1 。
查看进程返回结果的指令:
echo $?
补充说明:
之后再输入 echo $? 后,显示的结果就都是 0 了: 这时因为 $? 只会保留最近一次执行的进程的退出码。
main函数调用结束后,要给操作系统返回相应的退出信息,main函数的返回值是进程的退出码。因此操作系统规定,main函数返回值0表示代码执行成功,返回值非0表示代码执行出现错误,这就是为什么main函数都以return 0作为代码结尾的原因。
为什么要规定返回0就代表代码执行成功,返回非0代表代码执行不成功?
代码执行成功的的情况只有一种,那就是成功了;但是代码执行不成功的原因可能有很多种:数组越界、堆栈溢出、除数为0、内存不足。
那就可以用0来代表执行成功,用非0分别代表执行不成功的各种原因。 关于C语言提供的进程退出码所代表的含义我们可以通过函数 strerror 来获取: 其中 errnum 为退出码。
我们编写如下代码来查看这些退出码的含义:
#include
int main()
{
for(int i = 0; i < 140; i++)
printf("%d : %s\n", i, strerror(i));
return 0;
}
可以看到如果返回值为 0,说明进程成功,如果返回值为 2, 说明没有这个文件或目录。
但是并不是所有指令的退出码都是根据C语言提供的进程退出码为基准的,比如:
我们使用 kill -9 指令来杀死一个不存在的进程时所报的错误如果按照C语言的标准,退出码应该为3,但实际上退出码是 1 。
我们也可以自己来定义进程退出码的含义:
const char *err_string[] = {
"success",
"error"
// ...
};
对一个正在运行中的进程,存在两种终止方式:外部终止和内部终止,外部终止时,通过 kill -9 PID
指令,强行终止正在运行中的程序,或者通过 ctrl + c
终止前台运行中的程序。
内部终止是通过 main函数return、函数 exit()
或 _exit()
实现的。
众所周知,只有 main 函数 return 才标志进程退出,其他函数 return 仅仅代表函数返回,这说明进程执行的本质是 main 执行流执行。
前面的内容已经介绍过 return 退出的方式,接下来讲解 exit 函数退出的方式。之前在程序编写时,发生错误行为时,可以通过 exit(-1) 的方式结束程序运行,代码中任意地方调用此函数,都可以提前终止程序:
编写如下代码:
int main()
{
for(int i = 0; i < 140; i++)
{
printf("%d : %s\n", i, strerror(i));
exit(123);
}
return 0;
}
看到退出码为我们自己写入的 123 。由此我们得知函数 exit(int code) 中的参数 code 代表的就是进程退出码。在代码的任意地方调用 exit 函数都表示进程退出。
函数 exit 为C标准库函数,除此之外还有一个 _exit 函数,该函数为系统调用。
这两个退出函数,从本质上来说,没有区别,都是退出进程,但在实际使用时,还是存在一些区别,推荐使用 exit()
比如在下面这段程序中,分别使用 exit()
和 _exit()
观察运行结果
int main()
{
printf("You can see me");
sleep(1);
//exit(-1); //退出程序
//_exit(-1); //第二个函数
return 0;
}
使用 exit()
时,输出语句
原因:由于打印语句没有\n,不会在显示器上立即刷新,而是保存在用户缓冲区当中,进程退出前会把缓冲区的内容刷新出来。
使用 _exit()
时,并没有任何语句输出
exit()
与 _exit()
的区别在于,exit()
中封装了_exit()
, exit最后也会调用_exit, 但在调用_exit之前,还做了其他工作:
这也就能说明缓冲区不在kernel部分,否则_exit( )也会刷新缓冲区,因此缓冲区不在操作系统层面上,而是用户缓冲区。
进程等待就是通过系统调用,获取子进程退出码或者退出信号的方式,顺便释放内存。
等待进程有两种方式,分别为 wait() 与 waitpid(),后者比较常用
fork出子进程后,子进程和父进程可能都在运行,但并不确定谁先退出。因此父进程需要等待子进程,这是因为:
返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL。
#include
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 5;
while(cnt)
{
printf("子进程,剩余%dS, pid: %d, ppid: %d\n", cnt--, getpid(), getppid());
sleep(1);
}
exit(0);
}
//父进程
sleep(10);
pid_t ret_id = wait(NULL);
printf("父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d\n", getpid(), getppid() , ret_id);
sleep(5);
return 0;
}
在命令行输入:
while :; do ps ajx | head -1 && ps ajx | grep mytest | grep -v grep; sleep 1; echo "--------------"; done
使用监控脚本查看父进程和子进程的进程状态:
这也就证明了:
返回值:
参数:
#include
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 5;
while(cnt)
{
printf("子进程,剩余%dS, pid: %d, ppid: %d\n", cnt--, getpid(), getppid());
sleep(1);
}
exit(0);
}
//父进程
sleep(10);
pid_t ret_id = waitpid(NULL);
printf("父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d\n", getpid(), getppid() , ret_id);
sleep(5);
return 0;
}
使用监控脚本查看父进程和子进程的进程状态:
status :
#include
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 5;
while(cnt)
{
printf("子进程,剩余%dS, pid: %d, ppid: %d\n", cnt--, getpid(), getppid());
sleep(1);
}
exit(123);
}
//父进程
int status = 0;
pid_t ret_id = waitpid(id, &status, 0);
printf("父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d,
child exit code: %d, child exit signal: %d\n", getpid(), getppid(),
ret_id, (status>>8)&0xFF, status & 0x7F);
return 0;
}
子进程的信号为 0 ,退出码为 123 。符合我们的预期。
如果觉得 (status >> 8) & 0xFF 和 (status & 0x7F) 这两个位运算难记,系统还提供了两个宏来简化代码:
我们知道子进程拥有自己的PCB结构 task_struct ,在task_struct中还存在两个变量,分别为 int exit_code 与 int exit_signal 。
当子进程退出时,OS会把退出码填写到 exit_code 中,把退出信号填写到 exit_signal 中,并维护子进程的 task_struct ,此时子进程的状态就是僵尸状态。通过 wait 或者 waitpid 系统调用可以访问到该内核数据结构,并把退出信息以上面所讲过的格式存放在 status 中,顺便释放该数据结构占用的内存空间。
了解了以上知识后,我们应该有一个疑问,父进程在等待子进程退出,并回收子进程。那么如果子进程一直都没有退出,父进程又在做什么呢?
默认情况下,在子进程没有退出的时候,父进程只能一直在调用 wait 或 waitpid 进行等待,我们称之为阻塞等待。
父进程调用waitpid时一定是R运行状态,把父进程的PCB里面的进程状态由R运行状态改为S睡眠状态并放到等待队列中,父进程就什么也不干,代码既不执行也不会被调度,就在等待队列中等待。子进程一旦结束,操作系统识别到子进程结束了,发现父进程是在等待的,就把父进程的节点从等待队列中拿到运行队列中,再执行后续的等待方式,来继续获取子进程的退出结果。
阻塞的本质,是进程的PCB被放入了等待队列,并将进程的状态改为S状态。
返回的本质,是进程的PCB从等待队列拿到R队列,从而被CPU调度,拿到子进程的退出结果。
因此,为什么阻塞等待的时候,上层应用就卡住不动了,因为CPU不调度该进程了。当子进程执行,父进程等待期间,父进程会把自己的状态设为非R状态,放在等待队列里,当子进程退出时,CPU会把父进程调度到运行队列。
设置 waitpid 系统调用的参数option为WNOHANG,那么就是非阻塞式等待,非阻塞式等待的时候,如果子进程没有退出,也没有被阻塞,那么父进程就可以做其他事。父进程等待成功了,就把等待结果拿出来。
非阻塞轮询有三种结果:
注意: 如果不写进程等待函数,会引发僵尸进程问题
下面为一个完整的父进程非阻塞代码:
#include
#include
#include
#inlcude
#include
#include
#define TASK_NUM 10
//预设任务
void sync_dick()
{
printf("刷新数据\n");
}
void sync_log()
{
printf("同步日志\n");
{
void net_send()
{
printf("网络发送\n");
}
//要保存的任务
typedef void (*func_t)();
func_t other_task(TASK_NUM) = { NULL };
int LoadTask(func_t func)
{
for (int i = 0; i < TASK_NUM; i++)
{
if(other_task[i] == NULL) break;
}
if(i == TASK_NUM) return -1;
else other_task[i] = func;
return 0;
}
void InitTask()
{
for (int i = 0; i < TASK_NUM; i++) other_task[i] = NULL;
LoadTask(sync_dick);
LoadTask(sync_log);
LoadTask(net_send);
}
void RunTask()
{
for(int i = 0; i < TASK_NUM; i++)
{
if(other_task[i] == NULL) continue;
other_task[i]();
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 5;
while(cnt--)
{
printf("子进程, pid:%d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
exit(123);
}
// 父进程
InitTask();
while(1)
{
int status = 0;
pid_t ret_id = waitpid(id, &status, WNOHANG);
if(ret_id < 0)
{
printf("waitpid error!\n");
exit(1);
}
else if(ret_id == 0)
{
RunTask();
sleep(1);
continue;
}
else
{
if(WIFEXITED(status))
{
printf("wait success, child exit code: %d\n", WEXITSTATUS(status);
}
else
{
printf("wait success, child exit code: $d\n", status & 0x7F);
}
break;
}
}
return 0;
}
等待程序跑完或者手动kill -9掉进程都可以拿到进程退出信息。
创建子进程无非就两种目的:
为了让子进程执行全新的程序代码,就需要进行程序替换。
用 fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 并未改变。
程序原本存放在磁盘中,当调用 exec 函数时,程序的代码和数据分别加载到当前进程对应的代码段和数据段,代码和数据一旦替换之后,相当于用一个老进程的壳子,去执行一个新的程序的代码和数据。程序替换就相当于程序加载器,我们平常说程序被加载到内存中,其实就是调用了 exec 。在创建进程的时候,是先创建的进程数据结构PCB,再把代码和数据加载到内存的。
进程替换的本质就是把程序的进程代码+数据加载到特定进程的上下文中,C/C++程序要运行,必须要先使用加载器加载到内存中,这就要用到 exec* 系列程序替换函数,它们充当了加载器,把磁盘当中的程序加载到内存。
int execl(const char *path, const char *arg, ...);
简单看一下:
#include
#include
#include
#include
#include
#include
int main()
{
printf("I am a process! pid:%d\n",getpid());
execl("/bin/ls","ls","-a","-l",NULL);//程序替换,不再执行execl函数之后的代码了
printf("you can see me?\n");
return 0;
}
可以看到,打印了第一个printf后,还打印出了ls的内容
返回值:
如果程序替换失败,子进程退出时父进程就可以拿到子进程自己设置的退出码:
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
execl("/bin/lssss", "lssss", "-a", "-l", NULL); // 没有lssss这个命令
ptintf("you can see me again?\n");
exit(1);
}
sleep(5);
int status = 0;
printf("父进程, pid: %d\n", getpid());
waitpid(id, &status, 0);
printf("child exit code: %d\n", WEXITSTATUS(status));
return 0;
}
如果程序替换成功,新程序的退出码会返回给子进程,同样可以被父进程拿到:
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
//execl("/bin/ls", "ls", "-a", "-l", NULL); // 没有lssss这个命令
execl("/bin/ls", "ls", "good.c", NULL);// 没有good.c这个文件
ptintf("you can see me again?\n");
exit(1);
}
sleep(5);
int status = 0;
printf("父进程, pid: %d\n", getpid());
waitpid(id, &status, 0);
printf("child exit code: %d\n", WEXITSTATUS(status));
return 0;
}
因为没有 good.c 这个文件,所以新程序一定会返回对应的退出码给子进程,并最终被父进程获取,预期的退出码跟系统命令的一样:
为什么程序替换之后,子进程被替换了,父子进程代码是共享的,而父进程却没有受影响呢?因为进程具有独立性。由于父子进程独立,进程程序替换会更改代码区的代码,也会发生写时拷贝,所以子进程就会去执行新的程序,而父进程不会受到影响。
int execv(const char *path, char *const argv[]);
修改一下子进程的代码:
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
char *myargv[] = {
"ls",
"-a",
"-l",
NULL
}
execv("/bin/ls", myargv);
printf("you can see me again?\n");
exit(1);
}
注意: 虽然 execv
只需传递两个参数,但在创建 argv
表时,最后一个元素仍然要为 NULL
可能有的人觉得写 PATH 路径很麻烦,还有可能会写错,那么能否换成 自动挡
替换呢?
答案是可以的,execlp 函数在进行程序替换时,可以不用写 path
路径
int execlp(const char *file, const char *arg, ...);
execlp 中的 p 表示能够自动搜索环境变量 PATH,在执行特定程序时,只要知道程序名系统就会自动在环境变量path中搜索程序位置,不需要知道这个程序在哪里。使用 execlp 替换程序更加方便,只要待替换程序路径位于 PATH
中,就不会替换失败
修改一下子进程代码:
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
execlp("ls", "ls", "-a", "-l", NULL);
printf("you can see me again?\n");
exit(1);
}
execv
加个 p
也能实现自动查询替换,即 execv
int execvp(const char *file, char *const argv[]);
修改一下子进程代码:
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
char *myargv[] = {
"ls",
"-a",
"-l",
NULL
}
execvp("ls", myargv);
printf("you can see me again?\n");
exit(1);
}
int execle(const char *path, const char *arg,
..., char * const envp[]);
我们在当前目录的子目录 exec 里再编写一个可执行文件 otherproc :
#include
#include
#include
using namespace std;
int main()
{
for(int i = 0; i < 2; i++)
{
cout << "--------------------------------------------------" << endl;
cout << "另一个程序pid: " << getpid() << endl;
cout << "MYENV: " << (getenv("MYENV") == NULL ? "NULL" : getenv("MYENV")) << endl;
cout << "PATH: " << (getenv("PATH") == NULL ? "NULL" : getenv("PATH")) << endl;
cout << "==================================================" << endl;
sleep(1);
}
return 0;
}
~
因为没有添加MYENV,可以发现该子进程没有环境变量 MYENV 为空 。
在 mytest 中使用 execle 函数调用 otherproc 程序,并给该程序传递环境变量 MYENV :
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
char *const myenv[] = {
"MYENV=YouCanSeeMe",
NULL
};
execle("./exec/otherproc", "otherproc", NULL, myenv);
printf("you can see me again?\n");
exit(1);
}
发现 otherproc 进程中已经有了环境变量 MYENV ,但是 PATH 却没有了。这是因为函数execle 传递环境变量表是覆盖式传递的,老的环境变量表被我们传递的自定义环境变量覆盖了。
如果我们想子进程在拥有系统的环境变量的基础上再添加新的自定义环境变量,则可以使用函数 putenv :
extern char **environ;
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
char *const myenv[] = {
"MYENV=YouCanSeeMe",
NULL
};
putenv("MYENV=YouCanSeeMe");
execle("./exec/otherproc", "otherproc", NULL, environ);
printf("you can see me again?\n");
exit(1);
}
现在可以理解为什么在 bash 中创建程序并运行,程序能继承 bash 中的环境变量表了
environ
传递给指定程序使用e
的替换函数,默认传递当前程序中的环境变量表对 execvp
的再一层封装,使用方法与 execvp
一致,不过最后一个参数可以传递环境变量表
int execvpe(const char* file, char* const argv[], char* const envp[]);
修改一下子进程代码:
extern char **environ;
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
char *const myargv[] = {
"ls",
"-a",
"-l",
NULL
};
execvpe("ls", myargv, environ);
printf("you can see me again?\n");
exit(1);
}
execve
是系统真正提供的程序替换函数,其他替换函数都是在调用 execve
int execve(const char* filename, char* const argv[], char* const envp[]);
比如
execl
相当于将链式信息转化为 argv
表,供 execve
参数2使用execlp
相当于在 PATH
中找到目标路径信息后,传给 execve
参数1使用execle
的 envp
最终也是传给 execve
中的参数3 extern char **environ;
if(id == 0)
{
printf("子进程, pid: %d\n", getpid());
char *const myargv[] = {
"ls",
"-a",
"-l",
NULL
};
putenv("MYENV=YouCanSeeMe");
execle("./exec/otherproc", myargv, environ);
printf("you can see me again?\n");
exit(1);
}
替换函数除了能替换为 C++
编写的程序外,还能替换为其他语言编写的程序,如 Java
、Python
、PHP
等等,虽然它们在语法上各不相同,但在 OS 看来都属于 可执行程序
,数据位于 代码段
和 数据段
,直接替换即可
系统级接口是不分语言的,因为不论什么语言最终都需要调用系统级接口,比如文件流操作中的
open
、close
、write
等函数,无论什么语言的文件流操作函数都需要调用它们
这些函数原型看起来很容易混,但只要掌握了规律就很好记。