@TOC
目录
一.冯诺依曼体系结构
二. 操作系统(Operator System)
概念
设计OS的目的
定位
总结
系统调用和库函数概念
进程
基本概念
描述进程-PCB
task_struct-PCB的一种
task_ struct内容分类
组织进程
查看进程
通过系统调用获取进程标识符
进程状态
D--深度睡眠状态
Z--僵尸进程
孤儿进程
特点
进程的竞争性
进程的优先级
进程独立性
并行
并发
环境变量
添加环境变量,删除环境变量
添加环境变量
删除环境变量
查看环境变量
PATH
给PATH中添加可执行程序
C/C++获取环境变量
main函数的参数
使用第三个参数获取环境变量
environ外部导入环境变量
关于本地变量的说明
进程控制
进程创建
fork( )
fork啥时候会出错
写时拷贝
进程终止
程序退出码
strerror
程序退出的几种状态
终止的常见做法
exit和_exit
终止的时候,内核做了什么?
进程等待
如何等待
wait
status
waitpid
信号终止
库里面提供的宏
阻塞等待和非阻塞等待
阻塞等待
非阻塞等待
进程替换
原理
如何替换
execl
子进程替换
execv
execlp
execvp
execle/execvpe/execve
函数命名总结
简易shell搭建
基本框架的搭建
ls
cd
export
env
echo
总体代码:
一.冯诺依曼体系结构
截至目前,我们所认识的计算机,都是有一个个的硬件组件组成
- 输入单元:包括键盘, 鼠标,扫描仪, 写板等
- 中央处理器(CPU):含有运算器和控制器等
- 输出单元:显示器,打印机等
关于冯诺依曼,必须强调几点:
- 这里的存储器指的是内存
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
一句话,所有设备都只能直接和内存打交道。
二. 操作系统(Operator System)
概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等等)
设计OS的目的
与硬件交互,管理所有的软硬件资源
为用户程序(应用程序)提供一个良好的执行环境
定位
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件
总结
计算机管理硬件
1.先描述,用struct结构体
2.再组织,用链表或者其他高效的数据结构
系统调用和库函数概念
- 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分 由操作系统提供的接口,叫做系统调用。
- 系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统 调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
进程
基本概念
- 课本概念:程序的一个执行实例,正在执行的程序等
- 内核观点:担当分配系统资源(CPU时间,内存)的实体
描述进程-PCB
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
task_struct-PCB的一种
- 在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_ struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
组织进程
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里。
查看进程
我们在Linux环境中创建一个hello.c的C语言文件,然后运行编译生成的hello文件,调用一下命令:
ps ajx | head -1 && ps ajx | grep hello
也可以在/proc中查询:
ls ./proc
通过系统调用获取进程标识符
- 进程id (PID)
- 父进程id (PPID)
进程创建函数fork()
下面是用fork()创建子进程的函数
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{int ret = fork();if (ret < 0) {perror("fork");return 1;}else if (ret == 0) { //childprintf("I am child : %d!, ret: %d\n", getpid(), ret);}else { //fatherprintf("I am father : %d!, ret: %d\n", getpid(), ret);}sleep(1);return 0;
}
- 我们可以看出fork创建子进程之后有两个返回值,给父进程里面ret的值是一个大于0的值,表明是个父进程,给子进程里面的ret的值是0,表明是一个子进程。
- fork()后执行流会变成两个,是先执行父进程还是子进程是由调度器决定的,fork()后的代码共享,我们通常是用if else 来进行分流的
- 父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)
进程状态
进程状态在kernel内核源代码的定义:
/*
* 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 */
};
- R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
- S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep))。即:阻塞态
- D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
- T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可 以通过发送 SIGCONT 信号让进程继续运行。
- X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
这个R状态比较特殊,请看下面代码:
#include <stdio.h>
#include <unistd.h>int main()
{while(1){printf("my pid is : %d\n",getpid());}return 0;
}
明明是一个不停在循环的代码,按理来说肯定在运行状态,应该是R,怎么是S?
打个比方:我们使用cout往显示屏中打印字符串,这里的输出设备就是我们的屏幕,但是输入输出设备是很慢的相对于CPU来说,可能当输入输出一个字符时CPU就已经跑了上百万行代码了。所以当我们往显示屏打印字符串时,操作系统并不会直接将该进程直接运行,而是等到当某种资源就绪后会将该进程链接到运行队列中,运行是很快的事,但是等待时间就比较长,在查看进程状态,一个死循环,绝大部分时候都是S,某个瞬间会变成R。
如何才能让进程处于运行态呢?很简单,我们写一个不和外设交互的死循环即可
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{while(1){int a = 0;}return 0;
}
那后面的+号是什么意思呢?
状态码上带有+
号,代表进程是一个前台进程
- 能被
CTRL+C
终止的都是前台进程 - 后台进程一直在运行,会影响我们的命令行输入
我们可以使用kill -9
干掉该进程,干掉进程之后,就可以使用CTRL+C
恢复正常的命令行了
。
我们还可以使用命令来改变进程的状态
kill -l
其中我们要用到的是第19和第18,分别用于暂停/恢复一个进程
kill -19 PID //暂停PID这个进程
kill -18 PID //恢复PID这个进程
kill -9 PID //杀掉PID这个进程
D--深度睡眠状态
除了基本的S状态,linux下还有一个专门的disk sleep状态。如同它的名字一样,这个状态是专门为访问硬盘的进程设计的
假设有下面这样一个场景
- 进程A需要访问磁盘资源,写入1GB的数据
- 磁盘很忙,进程A进入S状态等待读写
- 操作系统发现这里有个进程没干活,内存又不够了,于是把进程A干掉了
- 轮到进程A独写的时候,磁盘发现进程A已经被干掉了,于是没管它的1GB数据
- 结果:这个1GB数据丢失了
出现这种数据丢失,谁都不想的嘛。所以Linux就设置了一个D状态,
- S 浅度睡眠
- D 深度睡眠
处于D状态的进程不能被操作系统kill掉。要想杀掉一个D状态的进程,只有下面三种办法
- 等硬盘读写完毕,给进程返回结果之后,进程从D状态变成其他状态,操作系统进行处理
- 关机重启
- 拔掉电脑的电源
Z--僵尸进程
- 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲) 没有读取到子进程退出的返回代码时就会产生僵死(尸)进程 。
- 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
- 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。
子进程只输出了一遍就退出了
原来是在输出第一遍之后就变成了僵尸进程。
不难发现子进程已经处于僵尸状态了,那这样子进程不就没法回收了吗?该进程就会一直占有CPU资源,那不就造成了内存泄露了吗,对的。另外僵尸进程是不能够用命令杀掉的(因为已经退出)。
我们总结下僵尸进程的危害:
- 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),父进程如果一直不读取,那子进程就一直处于Z状态!维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护!
- 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费。因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间! 最终会造成内存泄漏!
孤儿进程
父进程如果提前退出,那么子进程后退出,进入 Z 之后,那该如何处理呢?
父进程先退出,子进程就称之为 “ 孤儿进程 ”。孤儿进程被 1 号 init 进程领养 。
上面我们提到了子进程先退出就是僵尸进程,那么父进程先退出呢?
我们想想,此时父进程会是僵尸状态吗?
答案是不会的,父进程在此时会被他自己的父进程(bash)回收,而它的子进程则会交给1号进程领养,我们可以修改一下代码,让父进程先退出,然后运行:
这时子进程已经被1号进程给领养了。而且观察孤儿进程的状态是S,这样就不能使用Ctrl + c
特点
进程的竞争性
系统进程的数目较多。而CPU资源等其他资源不够用,所以进程之间存在竞争性,也就出现了优先级,这里我们就需要提到一个概念,进程的优先级
进程的优先级
我们知道系统进程众多,不加以约束必然会在运行队列中堵塞, 在运行过程中要想顺利地进行,必须要给他们进行先后排序。
进程在排队获取资源的本质就是在确认优先级。这是因为系统的某些慢资源不够多个进程同时使用,这时候就需要让进程进入排队来先后访问。
优先级越高,操作系统执行他的响应就越快。
可以用下面的命令来查看当前目录下的进程
ps -la
其中的 PRI
和 NI
就是我们进程优先级的数据
- linux的进程优先级= PRI + NI
- linux下进程的默认优先级是80,
PRI
值越低,优先级越高 NI
值是进程优先级的修正数据,我们修改进程优先级,修改的是NI
值而不是PRI
这两个值是有范围限制的,LInux并不支持用户无节制的修改优先级
- -20 <= NI <= 19
- 60 <= PRI <= 99
我们可以使用top命令来进行优先级的修改
linux下修改优先级的操作如下,运行 hello 程序后,先查看它的优先级信息
在使用sudo top
后,进入界面按r
,输入需要设置的进程PID后,再输入需要调整的NI值
这里可以看到,hello 进程的优先级已经被我们改成了70
再来尝试第二次,这次NI设置为20
pid设置成20之后,为啥NI
值变成了19,而PRI
变成了99呢?
依据我们以往的惯性思维,既然进程优先级= PRI + NI,那么修改了之后不应该是原本的70+20=90吗?为什么是99呢?
这是因为每一次设置的时候,PRI都会被重置成80。所以可以直接记住,Linux下进程的优先级=80+Ni值
进程独立性
进程运行具有独立性,不会因为某个进程出错,而影响其他进程的运行
我们知道,一个进程是内核结构task_truck+代码和数据
组成的。而linux系统是通过进程地址空间
方式来保证进程的独立性
并行
并行:多个进程在多个CPU下分割,同时运行
我们一般的电脑都是只有1个cpu,那是怎么做到多个进程运行的?
注意:多个进程都在系统中运行≠多个进程在系统中同时运行。要想知道这是怎么做到的,需要了解并发的概念
并发
大部分操作系统都是分时的,操作系统会给每一个进程赋予一个时间片,这样在一个调度周期中,可以调用到每一个需要运行的进程。
这样,在一个时间段内,多个进程会通过交叉运行的方式,让每一个进程的代码,在这段时间内都能得到运一行
比如每一个进程运行10ms,假设有10个进程需要运行,那么在1s内,这10个进程都会被运行10次。1s=1000ms
cpu进行多个进程的快速交替运行,以实现我们看到的单cpu运行多个进程的情况
这种情况就叫做并发
环境变量
当我们运行自己编译的一个可执行文件的时候,需要带上./指定路径
使用file命令查看系统的关键指令,我会发现它们和我们自己写的hello本质是一样的,都是一个
executable的可执行文件
那为啥运行ls pwd gcc等等系统命令的时间,不需要在前面带上./路径来运行呢?
因为:指向一个可执行程序,前提是需要找到他们。
Linux系统只能找到它自己预设好的命令,找不到我自己得程序
在linux命令行中,输入env
即可查看当前系统的环境变量
其中PATH
就是可执行程序存放的路径!系统就是通过环境变量来查找可执行程序的
添加环境变量,删除环境变量
添加环境变量
bash命令行里面就可以定义变量,变量分为两种类型
- 本地变量( 局部)
- 环境变量(全局)
直接使用变量名 = 值 的方式,就可以定义一个本地变量。使用echo
命令可以查看这个本地变量。这时候我们用 env | grep变量名
在环境变量里面查找,会发现当前的环境变量里面没有这个东西
这时候需要用export命令,创建一个环境变量
或者可以导入当前的本地变量
删除环境变量
删除的时候则使用unset
命令取消环境变量
查看环境变量
- echo: 显示某个环境变量值
- export: 设置一个新的环境变量
- env: 显示所有环境变量
- unset: 清除环境变量
- set: 显示本地定义的shell变量和环境变量
echo 环境变量名 //查看环境变量
set | less //查看所有的shell变量和环境变量
- USER:当前登录的用户
- HOME:当前用户的工作路径
- LANG:当前的语言和编码设定
- PATH:可执行命令的路径
- SHELL:当前使用的命令行是啥
- LOGNAME:当前登录的用户名
- PWD:当前所处路径
- OLDPWD:上一个路径,使用cd -跳回上一个路径
- HISTSIZE:系统会记录的历史命令条数
需要注意的是,系统预载的环境变量都是在配置文件里面的。当前我们对环境变量做的任何操作都只会临时保存。关闭当前的命令行重新开一个,之前设置的环境变量就会消失
系统的环境变量配置文件为/etc/bashrc,用户的则为工作目录下的.bashrc以及.bash_profile
PATH
使用echo $PATH
查看当前系统可执行文件的路径
这里的路径都以:
作为分割,linux查找命令的时候,就会在下面的这些路径里面查找
除了直接调用ls,我们还可以使用路径来调用ls
而如果想让系统能找到自己的可执行程序,就可以直接把可执行程序复制到这些路径中!
给PATH中添加可执行程序
cp 可执行文件名 ~/bin
可执行文件名
比如现在,我把mytest这个可执行程序复制到了~/bin
也就是比如现在,我把mytest这个可执行程序复制到了~/bin
也就是/home/muxue/bin
的路径下,此时直接使用mytest就能找到对应的命令了!的路径下,此时直接使用mytest就能找到对应的命令了!
除了这种办法以外,我们还可以把当前的路径写入PATH
环境变量中
export PATH=$PATH:/path/to/executable
这个命令通过使用export命令将新路径添加到PATH中。$PATH表示已经存在的PATH路径,:/path/to/executable
则是要添加的新路径。注意,要使用冒号将新路径与已有路径分隔开来。
我们设置的这个环境变量都是临时的,所以重启了之后,自己设置的这个路径也会消失。一般情况下不建议在linux系统路径中安装自己的可执行程序,因为这样会污染系统的命令环境!
C/C++获取环境变量
main函数的参数
#include<stdio.h>
//第一个参数指代命令个数,执行该可执行文件时传入的几个命令
//第二个参数是一个指针数组,存放了每一个命令的常量字符串
int main(int arg,char* argv[])
{printf("arg: %d\n",arg);for(int i =0;i<arg;i++){printf("argv[%d]: %s\n",i,argv[i]);}return 0;
}
使用第三个参数获取环境变量
除了上面提到的main函数前两个参数,实际上main函数还可以带第三个参数!
//第一个参数指代命令个数,执行该可执行文件时传入的几个命令//第二个参数是一个指针数组,存放了每一个命令的常量字符串//第三个参数用于导入环境变量!
int main(int arg,char* argv[],char *envs[])
{for(int i =0;envs[i];i++){printf("envs[%d]: %s\n",i,envs[i]);}return 0;
}
因为envs是一个指针数组,所以终止for循环的条件就是envs[ i ] = NULL
除了上面这个办法,我们还可以用下面两种方式来获取环境变量
environ外部导入环境变量
C语言提供了一个environ来导入环境变量,其作用和main函数第三个参数是一样的
extern char ** environ;
printf("get env from [environ]\n");
for(int i = 0; environ[i]; i++)
{printf("%d: %s\n", i, environ[i]);
}
上面都是数组和指针,有个函数也可以获取环境变量
通过这个函数,我们可以写一个只能我自己这个用户能运行的可执行程序
int main(int arg,char* argv[],char *envs[])
{char* user = getenv("USER");if(strcasecmp(user,"pmb")!=0)//strcasecmp忽略大小写{printf("权限禁止!\n");return -1;}printf("成功执行!\n");return 0;
}
关于本地变量的说明
- 本地变量(局部)
- 环境变量(全局)
所谓的本地变量,其实是bash内部定义的变量。
我们首先需要了解的是,linux下大部分的进程或命令都是以子进程方式运行的,其父进程都是当前打开的bash
由此可知,bash内部的本地变量,并不会被这些子进程所继承
而环境变量具有全局属性,可以被子进程继承并获取!
那么问题来了,export/echo也是命令。如果它们也是子进程,那它们是怎么获取到bash内部的本地变量,并将其导入到环境变量中的呢?
实际上,这两个命令都是由bash自己执行的(调用自己的对应的函数完成功能)我们把这种命令称作内建命令。关于内建命令,我会在进程控制阶段讲解。
进程控制
进程创建
fork( )
fork是一个pid_t类型的函数,它的返回值有两个:子进程返回0,父进程返回子进程pid,出错返回-1.
注:pid_t其实就是 int 类型
当一个进程调用fork函数的时候
- 操作系统会给子进程分配一个新的内存块
mm_struct+页表
和内核数据结构task_strcut
给子进程 - 将父进程的部分数据结构拷贝自子进程(写时拷贝)
- 将子进程添加系统进程列表当中
fork
返回,开始调度器调度
简单说来,便是fork
之前只有父进程单独运行。fork之后父子进程的执行流会分别执行,且相互独立。
fork之后,是父进程先执行还是子进程先执行依赖于调度器的调度。并非一定是父进程先执行!
需要注意的是,子进程虽然共享父进程的所有代码,但是它只能从fork之后开始执行
这里涉及到了cpu的eip程序计数器(又称pc指针)这玩意的作用就是保存当前正在执行的指令的下一条指令!
注意,这里说的是CPU执行的指令,并非linux下bash里面的命令
eip程序计数器会把下一个指令拷贝给子进程,子进程就会从该eip所指向的代码处(即fork之后的代码)开始运行
fork啥时候会出错
如果你写一个循环代码一直创建子进程,那么就有可能创建失败!
能够创建的子进程个数依赖于代码的复杂度
写时拷贝
在父进程创建子进程的时候,父进程和子进程中的变量是指向相同的物理地址,但是在父进程或者子进程里面的变量值发生改变时,操作系统就会进行写时拷贝,这时候父进程和子进程的变量的物理地址就不同了(不过虚拟地址是一样的,只是虚拟地址和物理地址的映射不一样了)。
为什么要写时拷贝,创建子进程的时候直接把数据分开不行吗
答,这样会存在内存浪费!
一般情况下,父进程创建子进程之后,会出现下面的一些情况
- 父进程的代码和数据,子进程不一定全部都会使用。即便使用、也不一定会进行修改
- 理想状态下,可以把父子进程会修改的内容进行分离,不会修改的部分共享即可。但是这样的实现非常复杂
- 如果fork的时候,就直接分离父子进程的数据,会增加fork运行的时间复杂度和空间复杂度
所以最终linux采用了写时拷贝的方式,只会在需要的时候,拷贝父子需要修改的数据。这样延迟拷贝,变相提高了内存的使用率
进程终止
程序退出码
相信每个初学C语言的人,都在想main函数结尾return 0到底有什么用?
这个返回值是给操作系统的,有操作系统来接收。
使用echo $?
命令查看环境变量,可以看到我们进程的退出码
#include <stdio.h>int main()
{return 0;
}
如果我们把return 0 改成return 10 那么echo $?出来的结果就是 10
注:?
环境变量存放的是上一次运行的程序的退出码
比如这里我们连续两次访问这个环境变量,可以看到第一次的结果是我们自己运行的程序返回的10,第二次的结果是0(echo命令的返回值)
strerror
这里我们使用for循环打印一下库函数中strerrror
函数内记录的错误码
#include<stdio.h>
#include<string.h>int main()
{int i=0;for(i=0;i<100;i++){printf("[%d] %s\n",i,strerror(i));}return 0;
}
进一步加大循环的次数,能看到C语言中定义的错误码一共是134
个。后续全部打印unknown error
我们设计程序的退出码的时候,可以参照C语言库函数的错误码来进行设置,这样能更好地和库内部进行对接,或用strerror
函数来获取到错误信息
这就是用错误码来实现的异常管理
程序退出的几种状态
一般情况下,程序有下面的几种退出状态:
- 代码跑完,结果与预期相符
- 代码跑完,结果有问题
- 代码没有跑完,提前出现异常终止,或者被外部关闭
一般情况下,我们不会去在乎一个进程为何会成功;而更在乎一个错误的进程到底哪儿有bug。所以就需要想办法获取到这个进程的错误码
错误码表征了程序退出的信息,交由父进程进行读取
上面我们在bash中能通过echo读取上一个进程的退出码,那是因为我们自己运行的可执行程序,其父进程就为当前的bash。bash接受了我们进程的退出码,放入到了环境变量中
终止的常见做法
一般情况下,我们可以在main
函数中return,或者在任何地方使用exit()
来终止程序
这里还需要提及另外一个版本的exit()
,即_exit
最可见的区别便是,exit会刷新缓冲区,而_exit
不会
调用test2函数
调用test3函数
什么都没有打印,注意printf里面不能使用\n,这个是行刷新,会把缓冲区里面的数据全部刷新出来,关于缓冲区在文件系统会讲到。
exit和_exit
在_exit
的man手册中也能看到,该函数会立即干掉这个进程;而exit
还会做一些其他的操作
终止的时候,内核做了什么?
我们知道,进程=内核结构task/mm_struct等+进程代码、数据
操作系统可能并不会释放该进程的task_struct/mm_struct,而是留给下一个进程使用!
要知道,如果想使用一个结构体,就需要对它进行开空间和初始化操作。而在操作系统中,创建、终止进程是一个非常高频的操作。如果总是不断的创建内核结构再释放,其内存利用率就很低,而且拖慢系统运行速度。
这时候系统就会使用内核的数据结构缓冲池,又称slab分派器,来管理这些仍待使用的内核结构。当有新进程出现的时候,更新内核结构的信息,并将其插入到运行队列中
进程等待
之前讲过子进程退出,父进程如果不管不顾,就会造成僵尸进程的问题,从而导致内存泄漏等一系列问题
- 另外,僵尸进程一旦出现,即便是
kill -9
也无法杀掉这个进程
所以父进程需要监看子进程的退出状态,并进行相应的操作
父进程通过进程等待的方式回收子进程资源,获取子进程的退出信息
如何等待
进程等待这里我们需要用到两个函数
pid_t wait(int*status);
pid_t waitpid(pid_t pid, int *status, int options);
头文件分别是
#include <sys/types.h>
#include <sys/wait.h>
wait
wait函数作用是等待子进程退出,status是一个输出型参数,子进程退出后,我们可以从中获取到子进程的退出信息
- status是从子进程的task_struct中拿出来的,子进程会将自己的退出码写入task_struct
- 如果我们不关心子进程的退出状态,则可以给status传一个NULL空指针
- 若等待失败,则返回-1
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>int main()
{int test = 10;pid_t ret = fork();if(ret == 0){int i = 5;while(i--){printf("我是子进程%-5d, ppid:%-5d, ret:%-5d, &ret:%p\n\n",getpid(),getppid(),ret,&ret);sleep(1);}printf("子进程退出\n");exit(0);}else{ printf("我是父进程%-5d, ppid:%-5d, ret:%-5d, &ret:%p\n\n",getpid(),getppid(),ret,&ret);sleep(5);int status = 0;pid_t st = wait(&status);printf("等待成功,子进程pid:%d, 状态信息:%d\n",st,status);sleep(5);} return 0;
}
我们成功获取了子进程的pid以及退出码0
那如果我们修改一下子进程中exit
的值呢?
exit(11);
状态信息为啥是2816,为啥不是11。
status
实际上,输出型参数中status
的值并非是完整的退出状态信息,其分为下面两种情况
所以说,正确访问状态码的方式,是先将status右移8位,再用按位与取出状态码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>int main()
{int test = 10;pid_t ret = fork();if(ret == 0){int i = 5;while(i--){printf("我是子进程%-5d, ppid:%-5d, ret:%-5d, &ret:%p\n",getpid(),getppid(),ret,&ret);//i++;sleep(1);}printf("子进程退出\n");exit(11);}else{ printf("我是父进程%-5d, ppid:%-5d, ret:%-5d, &ret:%p\n\n",getpid(),getppid(),ret,&ret);sleep(5);int status = 0;pid_t st = wait(&status);//printf("等待成功,子进程pid:%d, 状态信息:%d\n",st,status);//直接打印status是错误的!//status的低16位才有效,其中这16位的高8位是状态码printf("等待成功,子进程pid:%d, 状态信息:%d\n",st,(status>>8)&0xFF);//0xff是8个1sleep(5);} return 0;
}
waitpid
函数原型
pid_t waitpid(pid_t pid, int *status, int options);
- pid:
>0
指定等待子进程pid;-1
等待所有子进程 - status:同wait,为输出型参数
- options:若设置为0,则进行阻塞等待;其余选项见下图
返回值:
- 正常返回子进程的pid
- 如果设置了options,而
waitpid
发现没有已退出的子进程可收集,返回0 - 调用中出错,返回
-1
。此时errno
会被设置成相对应的值来显示错误
int main()
{int test = 10;pid_t ret = fork();if(ret == 0){int i = 4;while(i--){printf("我是子进程%-5d, ppid:%-5d, ret:%-5d, &ret:%p\n",getpid(),getppid(),ret,&ret);//i++;sleep(1);}printf("子进程退出\n");exit(0);}else{ printf("我是父进程%-5d, ppid:%-5d, ret:%-5d, &ret:%p\n\n",getpid(),getppid(),ret,&ret);sleep(5);int status = 0;pid_t st = waitpid(ret,&status,0);//指定等待上面创建的子进程//status的低16位才有效,其中这16位的高8位是状态码printf("等待成功,子进程pid:%d, 状态信息:%d\n",st,(status>>8)&0xFF);//0xff是8个1sleep(5);} return 0;
}
信号终止
前面提到了,除了正常的终止,status中还可以保存信号终止的信息
这里的core dump
标志是用来干嘛的我们暂且不提,先来试试用kill来干掉子进程!
这里我们要取出的是status
中最低7位的数据,就需要按位与一个二进制末尾是7个1的数字
注意:如果子进程是因为信号退出,那么我们不需要关注退出码,其没有意义!
库里面提供的宏
自己写按位与多麻烦呀,库里面提供了几个宏供我们使用
WIFEXITED(status)
查看子进程是否是正常退出的,正常退出为真WIFSIGNALED(status)
查看子进程是否为信号终止,信号终止返回真WEXITSTATUS(status)
提取子进程退出码WTERMSIG(status)
提取子进程退出信号
//其余部分代码和上面相同,子进程exit(11)
int status = 0;
pid_t st = waitpid(ret,&status,0);//指定等待上面创建的子进程
if(WIFEXITED(status))//子进程正常退出返回真
{ printf("等待成功,子进程pid:%d, 状态:%d,信号:%d\n",st,WEXITSTATUS(status),WTERMSIG(status));
}
else
{printf("非正常退出,子进程pid:%d, 状态:%d,信号:%d\n",st,WEXITSTATUS(status),WTERMSIG(status));
}
阻塞等待和非阻塞等待
前面的waitpid
函数中的option
参数就和阻塞/非阻塞等待有关
- 0 阻塞
- WNOHANG 非阻塞
阻塞等待
当我们调用某些函数的时候,因为条件不就绪,需要我们进行阻塞等待
本质:当前程序自己变成阻塞状态,当一切就绪的时候再被唤醒。
这时候我们等待的不是硬件资源,而是等待子进程运行结束(软件资源)
阻塞等待时,将父进程放入子进程task_struct中的等待队列。当操作系统检测出子进程退出,就从等待队列中唤醒父进程,阻塞等待成功!
给waitpid
的option
传入0,即为阻塞等待
pid_t st = waitpid(-1,&status,0);//阻塞等待
在子进程被信号干掉或者执行完毕退出之前,父进程不会向后执行代码。在用户层面看来,就是一个程序卡住了
非阻塞等待
给
waitpid的
option传入WNOHANG
,即为非阻塞等待
等待期间,父进程可以干其他的事情
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>int add(int a,int b){return a+b;
}
int pls(int a,int b){return a*b;
}int main()
{pid_t id = fork();if(id == 0){// 子进程int i =5;while(i--){printf("我是子进程, 我的PID: %d, 我的PPID:%d\n", getpid(), getppid());sleep(2);}exit(0);}else if(id >0){// 父进程// 基于非阻塞的轮询等待方案int status = 0;int i = 1, j=2;while(1){pid_t ret = waitpid(-1, &status, WNOHANG);if(ret > 0){printf("等待成功, %d, exit code: %d, exit sig: %d\n", ret, WIFEXITED(status), WTERMSIG(status));break;}else if(ret == 0){//等待成功了,但子进程没有退出printf("子进程好了没?没有,父进程做其他事情\n");printf("add %d ",add(i++,j++));printf("pls %d\n",pls(i++,j++));sleep(1);}else{//errprintf("父进程等待出错!\n");break;}}}return 0;
}
这里我们给父进程写了一个死循环,一直等待子进程退出。每一次循环都会调用一次waitpid
的接口,直到成功获取了子进程的退出信息
这种多次调用waitpid
接口的方式又被称为轮询检测
进程替换
在之前的fork
中,我们的子进程都是运行的已经预先写好的代码,或者说是继承了父进程的代码继续向后执行。
进程替换
就是让子进程可以执行磁盘里面其他的可执行文件,包括Linux系统的命令、其他语言写的代码py c++ php
等等…
原理
其实就是让子进程通过调用操作系统的接口,来执行一个已有的可执行程序
- 这个过程中并没有创建新的子进程,本质上还是当前子进程
程序替换的过程
- 将磁盘中的程序加载进入内核结构
- 重新建立页表映射,因为是子进程调用的程序替换,那么就会修改子进程的页表映射
- 效果:子进程代码和父进程彻底分离,子进程执行了一个全新的程序
如何替换
系统提供了非常多的函数接口,供我们在一个程序中调用系统中其他的可执行程序
要想调用,首先要找到这个程序在那儿,以及要用什么办法执行这个程序(命令行参数)下面以具体的例子来了解一下吧
需要注意的是:我们需要先用fork
创建子进程,再调用上面这些函数接口来使用其他可执行文件。这些函数接口本身并不会创建新的子进程!
execl
int execl(const char *path, const char *arg, ...);
- path是需要运行程序的路径
arg
代表需要执行的程序...
是可变参数,可以传入不定量的参数。这里我们填入的是命令行的参数
#include<stdlib.h>
#include<stdio.h>
#include <unistd.h>
#include <sys/wait.h>int main()
{printf("开始测试\n\n");int ret = execl("/usr/bin/ls","ls","-l",NULL);printf("执行结束 %d\n",ret);return 0;
}
需要注意的是,当我们填入命令行参数的时候,必须要以NULL
作为参数的结尾
我们会发现,调用了其他可执行程序之后,在后面的printf函数并没有被执行!
这是因为,当我们用这个函数来调用其他可执行程序,本质上已经把当前的代码和数据替换掉了!既然是替换,那么原本的printf("执行结束 %d\n",ret);肯定也不会执行
返回值问题
那execl不是有一个int类型的返回值吗?如果程序替换了之后不会执行后面的代码,那这个返回值还有什么用呢?
这个返回值只有出错的时候才会返回-1,同时会更新ERRNO
现在我们把执行文件改成usr/bin/
这个错误文件,那么就会调用失败,同时可以看到调用失败的原因是,我们没有权限去执行/usr/bin
我们在执行exec
这些替换函数的时候,其实没有必要去判断返回值。因为这些函数只有出错的时候,才会执行后面的代码!
- 无需判断返回值,直接打印
errno
找出错误原因即可
子进程替换
了解了替换程序的基本方法了之后,可以先来试试写一个父子进程
int add(int a,int b){return a+b;
}
int pls(int a,int b){return a*b;
}int main()
{pid_t id = fork();if(id == 0){// 子进程int i = 3;while(i--){printf("我是子进程, 我的PID: %d, 我的PPID:%d\n", getpid(), getppid());sleep(2);//便于观察int ret = execl("/usr/local/bin/python3","python3","test.py",NULL);printf("子进程执行出错: %d\n",ret);printf("子进程错误原因: %s\n",strerror(errno));exit(-1);}}else if(id >0){// 父进程// 基于非阻塞的轮询等待方案int status = 0;int i = 1, j=2;printf("我是父进程, 我的PID: %d, 我的PPID:%d\n", getpid(), getppid());while(1){pid_t ret = waitpid(-1, &status, WNOHANG);if(ret > 0){printf("等待成功, %d, exit code: %d,exit status : %d ,exit sig: %d\n", ret, WIFEXITED(status), WEXITSTATUS(status), WTERMSIG(status));break;}else if(ret == 0){//等待成功了,但子进程没有退出printf("子进程好了没?没有,父进程做其他事情\n");printf("add %d ",add(i++,j++));printf("pls %d\n",pls(i++,j++));sleep(1);}else{//errprintf("父进程等待出错!\n");break;}}}return 0;
}
exit code : 1子进程正常退出,exit status : 0子进程退出正确,exit sig :0不是由信号终止的。
可以看到,子进程替换了python程序成功了之后,不会执行后面的printf
假如子进程出现错误,如果该错误被捕获,程序没有崩溃,而是正常退出,比如test.py里面的代码是这样的
print("hello world")
a = 10 / 0
代码虽然有错误,但是该错误会被捕获,程序不会崩溃,输出为
exit status: 1,说明子进程退出码出错。
同时我们也可以看到,子进程执行程序替换,是不会影响父进程的(进程具有独立性)
这是因为数据发生了写时拷贝,程序替换的时候可以理解为代码和数据都通过写时拷贝进行了父子的分离(注意分离的是代码和数据,并非父子关系!)
execv
int execv(const char *path, char *const argv[]);
可以看到这个函数莫得可变参数,而是需要我们用一个指针数组来传入命令行参数!其余都是一样的!
复习一下,const修饰指针有下面两种形式
- 在
*
之前修饰,代表该指针指向对象的内容不能被修改(地址里的内容不能改) - 在
*
之后修饰,代表该指针指向的对象不能被修改(指向的地址不能改)
void testExecv()
{printf("开始测试\n\n");char*const arg[]={"ls","-l","-a",NULL};int ret = execv("/usr/bin/ls",arg);printf("执行结束: %d\n",ret);printf("错误原因: %s\n",strerror(errno));return ;
}
execlp
int execlp(const char *file, const char *arg, ...);
注意,这里参数的说明从path
变成了file
这个函数和execl
的区别在于,它会自己去系统环境变量的PATH
里面查找可执行程序
void testExeclp()
{printf("开始测试\n\n");int ret = execlp("python3","python3","test.py",NULL);printf("执行结束: %d\n",ret);printf("错误原因: %s\n",strerror(errno));return ;
}
调用成功
随意指定一个程序,就会报错
int ret = execlp("python12","python3","test.py",NULL);
execvp
int execvp(const char *file, char *const argv[]);
知道了execv/excel
之间的区别,那么execvp/execlp
之间的区别也就很明显
同样也是只有传参的区别,其他的操作完全一样
void testExecvp()
{printf("开始测试\n\n");char*const arg[]={"ls","-l","-a",NULL};int ret = execvp("ls",arg);printf("执行结束: %d\n",ret);printf("错误原因: %s\n",strerror(errno));return ;
}
execle/execvpe/execve
int execle(const char *path, const char *arg,..., char * const envp[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
int execve(const char *filename, char *const argv[],char *const envp[]);
首先它们的函数名中都有个e,这个e代表的是环境变量,代表我们可以把特定的环境变量传入其中进行处理。它们的环境变量都是在最末尾传的
函数 | 参数 | 说明 |
execle | 可执行文件的完整路径,命令行参数,环境变量 | 利用可变参数传入命令行参数 |
execve | 可执行文件的完整路径,命令行参数,环境变量 | 利用数组传入命令行参数 |
execvpe | 可执行文件名字,命令行参数,环境变量 | 利用数组传入命令行参数;只需要传入可执行文件的名字,会自动在PATH 里面搜索 |
函数命名总结
- l(list):使用可变参数列表
- v(vector):用数组传参
- p(path):自动在环境变量PATH中搜索
- e(env):表示自己维护环境变量
简易shell搭建
基本框架的搭建
为了简便,命令行中的提示符我们可以直接用printf打印,而具体执行命令可以交给子进程去做,现在的关键是如何将获得的命令行中的命令切割。我们在学习C语言时提到了strtok函数,正好这个函数可以用来作为切割。
#define NUM 1024
#define SIZE 32
#define SEP " "int Transfor(char commend[],char* arg[]){//用字符SEP来分割commend字符串,并将第一个标记存储到arg[0]中arg[0]=strtok(commend,SEP);if(arg[0]==NULL)return 1;int i=1;//以NULL作为第一个参数,他会继续上次的操作,从上次标记的位置开始寻找下一个标记 while(arg[i++]=strtok(NULL,SEP));return 0;}int main(){char myenv[MAX][MAX]={0};while(1){char commend[MAX];char* arg[MAX_NUM];printf("[lisi@VM-8-12-centos lesson11]$ ");fflush(stdout);//立即刷新 char*str=fgets(commend,sizeof(commend),stdin);//从键盘中读入一行字符串存到commend中if(str==0) continue;commend[strlen(commend)-1]='\0';//键盘输入最后一个是回车'\n',改成'\0'Transfor(commend,arg);pid_t id=fork();if(id==0){//childexecvp(arg[0],arg);//进程替换,新程序的名字和参数都存在arg数组中,有我们的shell完成}//parentint status=0;pid_t pid=waitpid(id,&status,0);//进程等待父进程获取子进程的退出状态码}return 0;}
ls
if(strcmp(arg[0],"ls")==0){int pos=0;while(arg[pos]) pos++;arg[pos++]=(char*)"--color=auto";//添加 --color=auto 参数,这会使 "ls" 命令在终端中显示彩色的输出,以区分不同类型的文件。arg[pos]=NULL;//因为要调用execvp函数,所以数组最后一个数必然是NULL}
cd
else if(strcmp(arg[0],"cd")==0){ if(arg[1]!=NULL) chdir(arg[1]);//chdir 是一个用于改变当前工作目录的函数。//int chdir(const char *path);//path 参数是一个指向要切换到的目标目录的字符串指针。//需要注意的是,chdir 只会在当前进程中更改当前工作目录,不会影响到父进程或其他 进程的当前工作目录。这意味着,即使在子进程中使用 chdir 更改了当前工作目录,在父进程中依然保持不 变。 continue; }//这段代码的功能是允许用户通过输入 "cd [目录路径]" 来更改当前工作目录。如果用户输入了 "cd" 命令,且命令后面跟着一个目标目录的路径,程序将尝试将当前工作目录更改为这个目标目录。
像上面这种让bash自己执行的任务我们称之为内建命令/内置命令
export
同样的我们导入环境变量时我们想的是将环境变量导给父进程,这样子进程也能够继承父进程的环境变量,但是如果我们只是单纯的将arg中的环境变量通过putenv导入的话会出现问题的,因为我们每次执行新的命令时arg中的内容都会发生改变,也就是其实arg是一个随时有可能被修改的指针数组,这时你导入的结果就可能会出现差错,解决方法是通过自己再定义一个专门存放环境变量的数组中,然后在putenv进去。 一般用户自定义的环境变量,在bash中要用户自己来进行维护,不要用一个经常被覆盖的缓冲区来保存环境变量。
else if(strcmp(arg[0],"export")==0){if(arg[1]!=NULL){strcpy(myenv[envi],arg[1]);putenv(myenv[envi++]);}continue;}
env
这个命令其实还是查看的是父进程的环境变量表,其实我们学过的大多数关于环境变量的命令几乎都是内建命令,为了演示效果更加明显我们可以自己封装一个打印函数
void showEnv(){extern char** environ;int i=0;for(;environ[i];i++) printf("%d:%s\n",i+1,environ[i]);}//下面代码在main函数中else if(strcmp(arg[0],"env")==0){showEnv();continue;}
echo
else if(strcmp(arg[0],"echo")==0){char* targrt_env=NULL;if(arg[1][0]=='$'){if(arg[1][1]=='?'){printf("%d\n",last_exit);continue; }else{targrt_env=getenv(arg[1]+1);}if(targrt_env!=NULL)printf("%s=%s\n",arg[1]+1,targrt_env);}continue;}
总体代码:
#include<stdio.h>#include<string.h>#include<stdlib.h>#include<assert.h>#include<sys/types.h>#include<sys/wait.h>#define MAX 1024#define MAX_NUM 64#define SEP " "int Transfor(char commend[],char* arg[]){arg[0]=strtok(commend,SEP);if(arg[0]==NULL)return 1;int i=1;while(arg[i++]=strtok(NULL,SEP));return 0;}void showEnv(){ extern char** environ;int i=0;for(;environ[i];i++) printf("%d:%s\n",i+1,environ[i]);}int main(){int last_exit=0;char myenv[MAX][MAX]={0};int envi=0;while(1){char commend[MAX];char* arg[MAX_NUM]; printf("[lisi@VM-8-12-centos lesson11]$ ");fflush(stdout);char*str=fgets(commend,sizeof(commend),stdin);if(str==0) continue;commend[strlen(commend)-1]='\0';Transfor(commend,arg);if(strcmp(arg[0],"ls")==0){int pos=0;while(arg[pos]) pos++;arg[pos++]=(char*)"--color=auto";arg[pos]=NULL;}else if(strcmp(arg[0],"cd")==0){if(arg[1]!=NULL) chdir(arg[1]);continue;}else if(strcmp(arg[0],"export")==0){if(arg[1]!=NULL){strcpy(myenv[envi],arg[1]);putenv(myenv[envi++]); }continue;}else if(strcmp(arg[0],"env")==0){showEnv();continue;}else if(strcmp(arg[0],"echo")==0){char* targrt_env=NULL;if(arg[1][0]=='$'){if(arg[1][1]=='?'){printf("%d\n",last_exit);continue;}else{targrt_env=getenv(arg[1]+1);}if(targrt_env!=NULL)printf("%s=%s\n",arg[1]+1,targrt_env);} continue;}pid_t id=fork();if(id==0){//childexecvp(arg[0],arg);}//parentint status=0;pid_t pid=waitpid(id,&status,0);if(pid>0){last_exit=WEXITSTATUS(status);}}return 0;}