前言
进程相关知识点
一、相关命令
- 查看cpu信息的方法
$cat /proc/cpuinfo
- 进程分为用户进程和内核进程,由于内核进程不占用用户虚拟地址,所以也称为用户线程,查看运行中的内核线程。
$ps -elf
#以**[ ]**包括名字的就是内核线程
二、进程管理
1.内核管理进程信息
- 内核把所有的进程维护成一个名为任务队列的双向循环链表。链表的节点类型名为 task_struct,它声明在 /include/linux/sched.h 中,task_struct 可以被称为进程控制块(PCB)。
它里面存储了进程的各种静态信息,包括打开的文件、进程的地址空间、挂起的信号、进程的状态等等。下面是某个版本的task_struct 结构体的声明,在后面的课程中会不断地涉及这些内容。
2. PID
- 为了方便普通用户定位每个进程,操作系统为每个进程分配了一个唯一的正整数标识符,称为进程ID**(PID)**。
- 在Linux中,进程之间存在着亲缘关系,如果一个进程在执行过程中启动了另外一个进程,那么启动者就是父进程,被启动者就是子进程。从 task_struct 声明可知,进程的信息中包含它的进程ID和父进程ID,实际上PID和PCB之间存在着一一对应的关系。使用ps命令可以查看进程相关信息。
$ps -l
#这里会出现两个进程,一个进程是shell,而另一个进程ps。通过PPID可知,ps的父进程就是bash$ ps -elf
- 使用系统调用 getpid 和 getppid 可以获取当前运行进程的进程ID和父进程ID。
//getpid.c
#include <func.h>
int main(){
printf("getpid = %d, getppid = %d\n", getpid(), getppid());
}
//运行以后可以通过ps命令查到其父进程就是bash
3. Linux 开机
- 在Linux启动时,如果所有的硬件已经配置好的情况下,进程0会被bootloader程序启动起来,它会配置实时时钟,启动**init进程(进程1)**和页面守护进程(进程2)。
- 0进程就是所谓的“盘古”进程(在新版本中被systemd取代),它会启动shell进程。在多用户的情况下,init会开启运行/etc/rc中配置的脚本进程,然后这个进程再从 /etc/ttys 中读取数据。 /etc/ttys 中列出了所有的终端,终端可以用于让用户从某种渠道登陆操作系统。
4. 进程的用户ID和组ID
- 进程在运行过程中,必须具有一类似于用户的身份,以便于内核进行进程的权限控制,默认情况下,程序进程拥有启动用户的身份。
- 例如,假设当前登录用户为student,他运行了任意一个程序(无论是不是他创建的),则程序在运行过程中就具有student的身份,该进程的用户ID和组ID分别为student和student所属的组,其ID和组ID就被称为进程的真实用户ID和真实组ID。
- 真实用户ID和真实组ID可以通过函数 getuid() 和 getgid() 获得。getuid 进程启动用户,geteuid 有效身份
结果:默认情况下,uid 和 euid 相同的。
//getuid.c
#include <func.h>
int main(){
uid_t uid;
gid_t gid;
uid = getuid();
gid = getgid();
printf("uid = %d, gid = %d\n",uid,gid);
}
//使用不同的用户启动进程会得到不同的结果
5. 进程的有效用户ID和有效组ID
- 与真实ID对应,进程还具有有效用户ID和有效组ID的属性,内核对进程的访问权限检查时,它检查的是进程的有效用户ID和有效组ID,而不是真实用户ID和真实组ID。默认情况下,用户的有效用户ID和真实ID是相同的,有效组ID和真实组ID是相同的。
- 有效用户ID和有效组ID通过函数**geteuid()和getegid()**获得。
//geteuid.c
#include<func.h>
intmain(){
uid_teuid;
gid_tegid;
uid=geteuid(); gid=getegid();
printf("euid=%d,egid=%d\n",euid,egid);}
//默认情况下uid和euid一致
6.passwd命令的设计原理
- Linux操作系统的密码存储在文件**/etc/shadow中**(影子文件)当中,这个文件的拥有者是root,所在组是shadow。
- 所以如果需要查看文件内容,需要将当前用户加入shadow组(可以修改/etc/group文件)。而只有root才拥有文件的写权限。当拥有读权限的时候,可以使用cat命令或者vim查看shadow文件时,发现密码都是采用密文存储。
- 参看passwd的设计,我们自己也可以实现让另一个用户在运行进程时,修改自己的有效用户ID或者有效组ID。
//changeFile.c
#include<func.h>
intmain(int argc,char*argv[]){
ARGS_CHECK(argc,2);
printf("euid=%d,egid=%d\n",geteuid(),getegid());
intfd=open(argv[1],O_RDWR);
ERROR(fd,-1,"open");
write(fd,"hello",5);
}
如果将上述程序直接编译,切换用户以后将无法执行,但是如果给可执行程序加上s权限以后,程序就能运行了。具体的运行原理就是进程在运行过程中修改了它的有效用户ID。
$./changeFilefile1
#使用其他用户无法打开文件
$chmodu+schangeFile
#给程序加上s权限以后,文件就可以打开了,此时的有效用户ID已经被修改了
使用ls -l命令可以检查passwd命令和sudo命令的权限,会发现它们都拥有s权限。
$whichpasswd
#检查passwd命令所在的位置$ls-l/etc/passwd
$ls-l/etc/sudo
7.sudo的实现原理
- sudo给用户添加权限的方法自然时在运行时修改有效用户ID。那么sudo命令是怎么控制哪些普通用户可以使用sudo的呢?
- sudo命令在执行的时候会检查/etc/sudoers文件,只有文件存在的用户才能使用sudo命令。除此以外sudoers可以给用户配置某些命令的sudo权限。
8. 文件特殊权限
1.简介
- 文件的权限是由12来控制的,其中最低的9位自然是我们所熟知的普通权限。
suid sgid sticky rwx(u) rwx(g) rwx(o)
-
而在最高3位中,最高位和次高位就是上面所述的s权限。
-
对于一个二进制程序,如果最高位为1,且用户的执行权限也为1时,那么该程序运行时将修改自己的有效用户ID为文件拥有者。最高位权限又称为SUID权限。
-
对于一个二进制程序,如果次高位为1,且组的执行执行也为1时,那么该程序运行时将修改自己的有效组ID为文件用户组ID。次高位权限又称为SGID权限。
-
对于一个目录文件,如果它设置了SGID权限后,如果当前用户拥有此目录的r和x的权限,则可以进入该目录。若用户拥有目录的w权限,则该用户新建的文件会和此目录的组一致。
chmod g+s getgid
$mkdir dir1
$chmod g+s dir1
$chmod o+w dir1
#再切换用户,cd到dir1中创建文件,会发现文件的组ID和目录组ID一致
- 粘滞位
当有一个目录文件,o+w ,那么任意o用户在目录下创建文件,删除自己的文件,可删除别人的文件。
2. 身份
- uid :进程的启动者
- euid:
① 文件根据有效uid选择权限。
② 假如可执行程序拥有suid u 的x,o的x,一个身份为o用户可以用过该可执行程序启动一个进程:a. uid的启动用户,
b. euid是可执行程序文件的拥有者 - gid
- egid
假如可执行程序拥有sgid g的x o的x,一个用户为o用户可以用过改可执行程序启动一个进程:
① gid 是启动用户的gid
② egid 是刻执行程序文件的拥有者所在的组
三、进程的状态
1. 状态
在进程从创建到消亡的过程中,进程会存在很多种状态。其中最基本的三种的状态是执行态、就绪态、等待态。
- 执行态:该进程正在运行,即进程正在占用CPU。
- 就绪态:进程已经具备执行的一切条件,正在等待分配CPU的处理时间片。
- 阻塞态:进程不能使用CPU,通常由于等待IO操作、信号量或者其他操作。
2. ps -elf 查看进程状态
- state:
$ps -elf
列出所有信息的状态
#找到第二列,也就是列首为S的一列
#R 运行中, 运行态或就绪态 (在用户尺度上,不区分运行态或就绪态。
#S 睡眠状态,可以被唤醒
#D 不可唤醒的睡眠状态,通常是在执行IO操作,读磁盘(不处理信息号)
#T 停止状态,可能是被暂停或者是被跟踪, 暂停 ctrl+z
#Z 僵尸状态defunct(“zombie”) ,进程已经终止,但是无法回收资源,
#IDLE 空闲程序
除了最基本的上面3种状态以外,使用ps命令还可以观察到一些更细致划分的状态
- uid :有效uid(写一个可执行程序,然后改一下g+s权限,在另一个用户下看一下)
- C:CPU
- PRI 和NI : priority和nice权限,详情见下方
- SZ:大小
- WCHAN :wait channel 阻塞哪个系统调用
- TTY中断
- TIME:CPU占用时间
3. ps aux 查看进程状态
$ps aux
#首列是USER 表示有效用户
#随后是PID 表示进程ID
#随后是%CPU 表示CPU资源百分比
#随后是%MEM 表示占用物理内存百分比
#随后是VSZ 表示占用的虚拟内存量
#随后是RSS 表示占用的固定内存量(内存驻留集即未交换的内存的大小)
#随后是TTY 表示运行终端 本机登录进程是tty1~6 网络连接是pts/n
#随后是STAT 表示进程状态
#随后是START 表示启动时间
#随后是TIME 表示CPU占用时间
#随后是COMMAND 表示进程触发命令
4. free (缓冲和缓存的区别)
free查看内存占用
free
- 缓冲(buffer)和缓存(cache)的区别:
① **缓冲(buffer)**是在向硬盘写入数据时,先把数据放入缓冲区,然后再一起向硬盘写入,把分散的写操作集中进行,减少磁盘碎片和硬盘的反复寻道,从而提高系统性能。
先进先出,平衡不同设备的速度的差异。
② **缓存(cache)**是在读取硬盘中的数据时,把最常用的数据保存在内存的缓存区中,再次读取该数据时,就不去硬盘中读取了,而在缓存中读取,平衡。
cache 是缓存,在告诉设备中复制一份低速设备的数据,提高效率。
简单来说,缓存(cache)是用来加速数据从硬盘中**“读取"的,而缓冲(buffer)是用来加速数据"写入”**硬盘的。
buffers指的是块设备的读写缓冲区,cached指的是文件系统本身的页面缓存。他们都是Linux系统底层的机制,为了加速对磁盘的访问。
5. top指令
展现系统进程:
- 第一行分别显示:
当前时间 系统启动时间、 当前系统登录用户数目、 平均负载(1分钟,10分钟,15分钟)。
平均负载(loadaverage),一般对于单个cpu来说,负载在0~1.00之间是正常的,超过1.00须引起注
意。在多核cpu中,系统平均负载不应该高于cpu核心的总数。
- 第二行分别显示:
total进程总数
running运行进程数
sleeping休眠进程数
stopped终止进程数
zombie僵死进程数
- 第三行分别显示:
%us用户空间占用cpu百分比;
%sy内核空间占用cpu百分比;
%ni用户进程空间内改变过优先级的进程占用cpu百分比;
%id空闲cpu百分比,反映一个系统cpu的闲忙程度。越大越空闲;
%wa等待输入输出(I/O)的cpu百分比;
%hi指的是cpu处理硬件中断的时间;
%si值的是cpu处理软件中断的时间;
%st用于有虚拟cpu的情况,用来指示被虚拟机偷掉的cpu时间。
- 第四行分别显示:
total总的物理内存;
used使用物理内存大小;
free空闲物理内存;
buffers用于内核缓存的内存大小
- 第五行(Swap)分别显示:
total总的交换空间大小;
used已经使用交换空间大小;
free空间交换空间大小;
cached缓冲的交换空间大小
然后下面就是和ps相仿的各进程情况列表了
- 第六行分别显示:
PID 进程号
USER 运行用户
PR优先级,PR(Priority)优先级
NI 任务nice值
VIRT 进程使用的虚拟内存总量,单位kb。VIRT=SWAP+RES
RES 物理内存用量
SHR 共享内存用量
S 该进程的状态。其中S代表休眠状态;D代表不可中断的休眠状态;R代表运行状态;Z代表僵死状态;T代表停止或跟踪状态
%CPU 该进程自最近一次刷新以来所占用的CPU时间和总时间的百分比
%MEM 该进程占用的物理内存占总内存的百分比
TIME+ 累计cpu占用时间
COMMAND 该进程的命令名称,如果一行显示不下
5. 优先级和nice值
-
Linux的优先级总共的范围有140,对于ubuntu操作系统而言,其范围是-40到99,优先级的数值越低,表示其优先级越高。
-
Linux中拥有两种类型的调度策略,分别是实时调度策略和普通调度策略
-
优先级 [40,99]
高: 实时调度 RT_FIFO RT_RR -40-59 (100个)
低: 普通调度 60-99
140个整数 数值越大,优先级越低
1. 普通调度策略
- 普通调度策略又称为OTHER策略,其调度规则即CFS算法。普通调度策略不会一定保证某个进程会在规定时间执行。
- 普通调度策略的优先级是从60到99范围之间。在ubuntu系统中,当一个普通进程创建时,其默认优先级是80,此时nice值为0。
- 普通调度策略优先级的调整是依赖于nice值的。nice值可以用来调整优先级,其范围为-20~19。其中正数表示降低权限,负数表示提升权限。
- root用户可以任意地修改进程的nice值,其他用户只能提升自己的进程的nice值。
$nice -n 10 ./a.out #注意之后不能使用renice调整它的优先级
$renice -5 pid #执行失败
$renice 5 pid #执行成功,普通用户只能提升nice
2. 实时调度策略
-
实时调度策略是针对于实时进程,这些实时进程对于时间延迟非常敏感(想象下如果航天飞机的指令出现延迟造成的灾难性后果),所以普通调度策略不足以满足实时性需求。
-
Linux的实时调度策略有两种,分别是RR和FIFO。
-
其中FIFO以按照先进先出的方式运行进程,除非主动退出,它不会被同级或着更低优先级的进程抢占,只能被更高优先级的进程抢占;RR在FIFO的基础上增加时间片管理,相同优先级的进程会分配相同的时间片,而低优先级的进程无法抢占高优先级的进程,即使高优先级的进程时间片耗尽。
-
只有系统调用 sched_getscheduler 和 sched_setscheduler 才能修改调度策略,使用 nice 系统调用(以及基于它的同名命令)或者 setpriority系统调用这种修改优先级数值的方法无法改变调度策略。
-
提高优先级要加sudo
3. NICE值
- 使用nice命令和renice命令可以用来调整nice值。
- nice = o, pri = 80;
nice =-20,pri = 60;
nice = 19,pri = 99.
$sudo renice -21 pid
#使用renice最多只能降低20
3. jobs命令
使用 jobs 命令可以查看和管理所有的后台任务,可以获得进程编号。
4. 前台和后台
- 通常用户经常会从终端启动shell再启动进程,当进程正在运行时,它可以接受一些键盘发送的信号:比如 ctrl+c 表示终止信号, ctrl+z 表示暂停信号。
- 这种可以直接接受键盘信号的状态被称为前台,否则称为后台。
- 或者来说,针对于一个终端,可以响应键盘中断的是前台 ;ctrl+c ctrl+l不可以响应键盘终端的是后台。
使用 fg 命令可以将后台进程拿到前台来。
使用 bg 命令可以将后台暂停的程序运行起来。
5. kill命令和任务控制
- kill 命令可以用来给指定的进程发送信号。
- 当进程处于后台的时候,只能通过 kill 命令发送信号给它。
- 差用 kill -9 pid 杀死进程
$kill -9 pid
# 以异常方式终止进程
$kill -15 pid
# 以正常方式终止进程
$kill -1 pid
# 重新启动进程
$kill -2 pid
# 中断进程
$kill -19 pid
# 暂停进程
$kill -l
# 显示所有信号
使用 ctrl+z 可以暂停当前运行的前台进程,并将其放入后台。它也会输出一个任务编号到屏幕上。
三、使用系统调用创建进程
1. system
- 在C代码中调用可执行程序
#include <stdlib.h>
int system(const char *command);
- system 函数可以创建一个新进程,新进程使用shell脚本执行传入的命令command。
//system.c
#include <func.h>
int main(){
system("./sleep");
return 0;
}
- 如果在程序执行过程使用 ps 命令查看所有进程,我们会发现创建了3个进程,并且3个进程之间存在父子亲缘关系。
- 由于创建进程的时间消耗是很大的,对于性能要求比较苛刻的任务来说,这种使用system 的方式往往是不能被接受的。
- 除了可以执行shell指令, system 函数还可以嵌入其他编程语言所编写的程序,比如 python :
#文件名为hello.py
print("hello")
//systemPy.c
int main(){
//需要在操作系统上装好python3解释器
system("python3 hello.py");
return 0;
}
2. fork函数 (重点)
1. 简介
- fork 用于拷贝当前进程以创建一个新进程。
#include <unistd.h>
pid_t fork(void);
- fork 执行以后创建的新进程和当前进程拥有着几乎一致的用户态地址空间。新进程和原进程之间存在一些小小的差异:
- fork系统调用的返回结果不一样,子进程返回值为0,父进程返回孩子的PID
- 父子进程之间的PPID也不一样,其中子进程的PPID的进程为它的父进程
//fork.c
#include <func.h>
int main(){
pid_t pid = fork();
if(pid == 0){
printf("I am child, pid = %d\n", pid);//操作系统不保证进程的执行先后顺序,不过
通常进程创建大约在数百毫秒量级
}
else{
printf("I am parent, pid = %d\n", pid);
//sleep(1); 如果不添加sleep,将会出现一些显示异常
}
}
2. 实现原理(7.8.9)
- 首先,我们需要引入中断的概念。
- 所谓中断,就是利用硬件发送信息给CPU,然后CPU通知操作系统处理中断事宜。从这个角度上来看中断的处理是异步的,即当前进程的执行指令和中断发生顺序是未知的。
- 不同类型的中断可以使用唯一的中断号进行区分,操作系统为不同类型的中断提供了对应的中断处理程序。
- 异常是一个类似于中断的概念,它是进程主动发送给CPU的信息,所以也被称为软件中断。在x86体系结构当中,系统调用就是利用了软件中断(这个软件中断名为陷入)实现的。除了信息的来源不同以外,操作系统处理中断和异常的流程是一样的。
- 中断处理程序的设计目标有两个:**就是运行时间短,并且完成任务多。**而显然这两个设计目标之间存在矛盾,一种解决方案是将中断处理分成两个部分:上半部和下半部。
- 上半部要完成一些时间短并且不能延迟的工作,比如调整硬件、或者是不能被抢占的指令。以网卡为例子:上半部阶段,网卡会给内核发送信号,内核会执行中断处理程序,然后将网卡的内容拷贝到系统内存。下半部会要完成一些可以稍后执行的指令,比如网卡中断的下半部中,内核才会处理数据包的解析和其他处理操作。
- 现在我们回到 fork 系统调用,既然是系统调用,它自然可以分为上半部和下半部。
- 上半部当中,它会“拷贝”一份进程控制块,自然也就拷贝了一份地址空间(包括进程的正在执行的状态和指令(PC)),然后修改子进程的 task_struct 的内容,将PPID和PID进行调整。
随后,它将子进程放入就绪度列等待调度,并将子进程 fork 的(将要返回的)返回值修改为0,父进程的返回值设置为子进程的PID。 - 上半部过程中, fork 的执行过程是不能被抢占的,所以能保证一定能执行完成。
随后 fork 要执行它的下半部,那就是将其返回值返回给两个进程,随后修改PC指针,让各个进程继续执行后续的命令。
3. fork的资源
- 通过 fork 创建的子进程,它从父进程继承了进程的地址空间,包括进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设定、进程优先级、进程组ID、当前工作目录、根目录、资源限制、控制终端、缓冲区、当前代码段运行的位置(PC),数据段,FILE;
- 而子进程所独有的只有它的进程ID、资源使用和计时器等。
- fork有两个返回值,父进程返回子进程的pid,子进程返回0。
//forkStack.c
#include <func.h>
int main(){
pid_t pid = fork();
int i = 0;
if(pid == 0){
puts("child");
printf("child i = %d,&i = %p\n",i, &i);
++i;
printf("child i = %d,&i = %p\n",i, &i);
}
else{
puts("parent");
printf("parent i = %d,&i = %p\n",i,&i);
sleep(1);
printf("parent i = %d,&i = %p\n",i,&i);//子进程会拷贝父进程的内容,但是修改的内容会互相独立
}
}
//父子进程中的变量包括其地址是一致
4. 写时复制技术
- fork实现过程中,用到的技术,用户无感知。
- 当执行了 fork 了以后,父子进程地址空间的内容是完全一致,所以完全可以共享同一片物理内存,也就是父子进程的同一个虚拟地址会对应同一个物理内存字节。
- 通常来说,内存的分配单位是页,我们可以为每一个内存页维持一个引用计数。代码段的部分因为只读,所以完全可以多个进程同时共享。
4. 而对于地址空间的其他部分,当进程(父或子都行)对某个内存页进行写入操作的时候,我们再真正执行被修改的虚拟内存页分配物理内存并拷贝数据,这就是所谓的写时复制。 - 在执行拷贝以后,同样的虚拟地址就无法对应同样物理内存字节了。
- 因为很多数据都是只读的,所以可以共享,当写时,再修改。
5. fork对打开文件的影响
- 当执行了 fork 了以后,父子进程地址空间的内容是完全一致,所以完全可以共享同一片物理内存,也就是父子进程的同一个虚拟地址会对应同一个物理内存字节。通常来说,内存的分配单位是页,我们可以为每一个内存页维持一个引用计数。
- 如果先创建文件对象,再fork,父子进程共享文件对象。
- 为了标准输出能在父子进程间共享。
//forkFile
#include <func.h>
int main()
{
int fd = open("file", O_RDWR);//特别注意,文件打开要在fork之前。
ERROR_CHECK(fd,-1,"open");
pid_t pid = fork();
if(pid == 0){
printf("I am child process\n");
//lseek(fd,5,SEEK_SET);
write(fd,"hello",5);
}
else{
printf("I am parent process\n");
sleep(1);
char buf[6] = {0};
read(fd,buf,5);
printf("I am parent process, buf = %s\n", buf);
write(fd,"hello",5);
}
}
结果:
4. 代码段的部分因为只读,所以完全可以多个进程同时共享。
5. 而对于地址空间的其他部分,当进程对某个内存页进行写入操作的时候,我们再真正执行被修改的虚拟内存页分配物理内存并拷贝数据,这就是所谓的写时复制。在执行拷贝以后,同样的虚拟地址就无法对应同样物理内存字节了。
6. 输出了多个’-'号(重点)
- 问题:
(1). 请问下面的程序一共输出多少个“-”
int main()
{
int i=0;
for(i=0;i<2;i++)
{
fork();
printf("-");
}
return 0;
}
(2). 上题中的printf(“-”)换成printf(“-\n”);程序会输出多少个“-”?思考一下为什么?
- 答案:
3. 解析:
① 因为在执行printf时,字符串hello被写入到了C程序的缓冲区中,但并没有输出到显示器上,直到缓冲区内容被刷新后,才被显示到显示器上。
② 缓冲区的刷新策略为:行刷新、程序结束、强制刷新。
③ 上面第二种情况给printf时加上\n,则属于行刷新。
④ 行刷新就会先把 - 刷新到显示器上,此举动会清空缓冲区。
⑤ fork函数在执行时,会把父进程的各种状态也复制一份,包括缓冲区。
⑥ 第一种情况,父进程和子进程1在执行print函数时,将 - 写入缓冲区中,fork函数在复制时将缓冲区复制一份,导致子进程2和3被创建时,缓冲区中就有一个‘-’ 。在程序结束后,一共将从缓冲区中刷新8个 ‘-’。
⑦ 第二种情况,父进程和子进程1在执行print函数时,将 - 写入缓冲区中,而’\n’会引起行刷新,缓冲区中的‘-’被刷新中到屏幕上,缓冲区清空。 fork函数在复制时将缓冲区复制一份,导致子进程2和3被创建时,缓冲区是空的 。接下来到程序结束,又将向缓冲区中写入个 ‘-’(粉红丝箭头)。程序结束后,一共将输出6个‘-’。
ps: 强制刷新则可使用函数fflush(stdout)。
3. exec 函数族
1. 简介
- 让进程去执行一个可执行程序的代码exec* 是一系列的系统调用。它们通常适用于在 fork 之后,将子进程的指令部分进行替换修改。
- 当进程执行到 exec* 系统调用的时候,它会将传入的指令来取代进程本身的代码段、数据段、栈和堆,然后将PC指针重置为新的代码段的入口。 exec* 当中包括多个不同的函数,这些函数之间只是在传入参数上面有少许的区别。
//可变参数 额外补充一个NULL的参数,标记着结束
int execl(const char *path, const char *arg0, ... /*, (char *)0 */);
//数组
int execv(const char *path, char *const argv[]);
exec :
- 清空堆栈
- 代码段和数据段用新的可执行程序去取代‘’
- 重置PC**
//被执行的程序代码 名为add.c
#include <func.h>
int main(int argc, char *argv[])
{
ARGS_CHECK(argc,3);
int i1 = atoi(argv[1]);
int i2 = atoi(argv[2]);
printf("%d + %d = %d\n",i1,i2,i1+i2);
return 0;
}
//调用exec的程序代码
#include <func.h>
int main()
{
pid_t pid = fork();
if(pid == 0){
printf("I am child\n");
//execl("./add","add","3","4",(char *)0);
char *const argv[] = {"add","3","4",NULL};
execv("./add",argv);
printf("you can not see me!\n");//这句话并不会打印
}
else{
printf("I am parent\n");
printf("you can see me!\n");
sleep(1);
}
return 0;
}
实际上,我们之前所使用的 system 函数以及bash或者是其他shell启动进程的本质就是 fork+exec 。
2. 使用环境变量
使用 execve 或者 execle 的方式可以修改当前进程的环境变量。
$env
#查看所有环境变量
$echo $PATH
#可以查看PATH环境变量的内容
//showPath.c
#include <func.h>
int main()
{
system("echo $PATH");
return 0;
}
//execle.c
#include <func.h>
int main()
{
char *const envp[] = {
"PATH=/usr/lib",NULL};
execle("./showPath","showPath",NULL,envp);
return 0;
}
四、进程控制
1. 孤儿进程
- 如果父进程先于子进程退出,则子进程成为孤儿进程,此时将自动被PID为1的进程(即init)收养。
- 当一个孤儿进程退出以后,它的资源清理会交给它的父进程(此时为init)来处理。
- 但在init进程清理子进程之前,它一直消耗系统的资源,所以要尽量避免。
//orphan.c
#include <func.h>
int main()
{
pid_t pid =fork();
if(pid == 0){
printf("I am child\n");
while(1);
}else{
printf("I am parent\n");
return 0;//在main函数中执行return语句是退出进程
}
}
//随后可以使用ps -elf|grep orphan 查看子进程的父进程ID就是1
//也可以在代码中使用getppid()
2. 僵尸进程
- 如果子进程先退出,系统不会自动清理掉子进程的环境,而必须由父进程调用 wait 或 waitpid 函数来完成清理工作,如果父进程不做清理工作,则已经退出的子进程将成为僵尸进程(defunct)。
- 在系统中如果存在的僵尸(zombie)进程过多,占用系统的内存资源,影响系统的性能,而且如果其数目太多,还会导致系统瘫痪,所以必须对僵尸进程进行处理。
//zombie.c
#include <func.h>
int main()
{
pid_t pid =fork();
if(pid == 0){
printf("I am child\n");
return 0;
}else{
printf("I am parent\n");
while(1);
}
}
//随后使用ps -elf|grep zombie 可以看到一个<defunct>的僵尸标记
- 当一个进程执行结束时,它会向它的父进程发送一个SIGCHLD信号,从而父进程可以根据子进程的终止情况进行处理。
- 在父进程处理之前,内核必须要在进程队列当中维持已经终止的子进程的PCB。如果僵尸进程过多,将会占据过多的内核态空间,并且僵尸进程的状态无法转换成其他任何进程状态。
4. wait 函数
- 子进程终止,其资源由父进程收回,父进程会等待子进程终止,终止之后,回收资源。
- wait 和 waitpid 系统调用都会阻塞父进程,等待一个已经退出的子进程,并进行清理工作; wait 随机地等待一个已经退出的子进程,并返回该子进程的PID; waitpid 等待指定PID的子进程;如果为-1表示等待所有子进程。
- 如果父进程此时被杀死,子进程的资源由init进程回收。
#include <sys/wait.h>
pid_t wait(int *stat_loc);
pid_t waitpid(pid_t pid, int *stat_loc, int options);
#include <func.h>
int main()
{
pid_t pid =fork();
if(pid == 0){
printf("I am child,pid = %d, ppid = %d\n",getpid(),getppid());
return 0;
}else{
printf("I am parent\n");
pid_t cpid;
cpid = wait(NULL);
printf("cpid = %d\n", cpid);
return 0;
}
}
5. wait 获取子进程状态
- stat_loc 参数是一个整型指针。如果不关心进程的退出状态,那么该参数可以是一个空指针;否则wait 函数会将进程终止的状态存入参数所指向的内存区域。
- 这个整型的内存区域中由两部分组成,其中一些位用来表示退出状态(当正常退出时),而另外一些位用来指示发生异常时的信号编号,有4个宏可以用来检查状态的情况。
#include <func.h>
int main()
{
pid_t pid = fork();
int status;
if(pid == 0){
printf("child, pid = %d, ppid = %d\n", getpid(),getppid());
char *p = NULL;
*p = 'a';
return 123;
}
else{
printf("parent, pid = %d, ppid = %d\n", getpid(),getppid());
//wait(&status);
waitpid(pid,&status,0);//第三个参数为0,和直接使用wait没有说明区别
if(WIFEXITED(status)){
printf("child exit code = %d\n", WEXITSTATUS(status));
}
else if(WIFSIGNALED(status)){
printf("child crash, signal = %d\n",WTERMSIG(status));
}
}
return 0;
}
- 默认情况下, wait 和 waitpid 都会使进程处于阻塞状态,也就是执行系统调用时,进程会中止运行。
- 如果给 waitpid 的options参数设置一个名为WNOHANG的宏,则系统调用会变成非阻塞模式:当执行这个系统调用时,进程会立刻检查是否有子进程发送子进程终止信号,如果没有则系统调用立即返回。(配合循环)。
#include <func.h>
int main()
{
pid_t pid = fork();
int status = 0;
if(pid == 0){
printf("child, pid = %d, ppid = %d\n", getpid(),getppid());
sleep(5);
return 123;
}
else{
printf("parent, pid = %d, ppid = %d\n", getpid(),getppid());
int ret = waitpid(pid,&status,WNOHANG);
if(ret > 0){
if(WIFEXITED(status)){
printf("child exit code = %d\n", WEXITSTATUS(status));
}
else if(WIFSIGNALED(status)){
printf("child crash, signal = %d\n",WTERMSIG(status));
}
}
printf("ret = %d\n",ret);
}
return 0;
}
waitpid 除了可以等待指定子进程以外,它还可以修改pid参数来支持更多种模式的等待方式。
五、进程终止
1. 简介
在进程阶段,进程总共有5种终止方式,其中3种是正常终止,还有2种是异常终止:
2. return
第一种方式: return 的参数描述退出状态:
约定:0正常,非0不正常,父进程调用wait获取。
如果通过命令行启动一个进程,它的父进程是bash。
echo $?
获取上一个进程的结束状态
3. exit 函数
- 在任何一个地方都能调用 exit 函数.
- 步骤:
① 刷新stdout缓冲区,
② 终止进程。
4. _Exit 函数和 _exit 函数
- 不刷新缓冲区,直接退出
如果在程序没有注意缓冲区,并且又使用了 _exit 或者是 _Exit 的话,很容易出现缓冲区内容丢失的情况。
- exit 函数、 _Exit 函数和 _exit 函数可以立刻终止进程,无论当前进程正在执行什么函数。注意,和一般的函数不同,调用这3个退出函数是没有返回返回值这个过程的。
- 当调用 _exit 和 _Exit 的时候,进程会直接终止返回内核,而 exit 它的实现会多一些额外步骤,它会首先执行终止处理程序(使用atexit 函数可以注册终止处理程序),然后清理标准IO(就是把所有打开的流执行一次 fclose ),最后再终止进程回到内核。
5. 程序异常终止
- 被动异常终止: 其他进程or硬件发信号
当进程处于前台的时候,按下 ctrl+c 或者是 *ctrl+* 可以给整个进程组发送键盘中断信号SIGINT和SIGQUIT。 - 主动异常终止:abort函数
本质: 自己给自己发送一个6号信息号
六、操所系统管理多进程
1. 进程组
- 进程组:进程的集合,每一个进程只能属于一个进程组。
- 组ID :进程组长的ID,组长进程终止了,组ID不变。
- 父进程通过调用fork()函数。默认下,子的组ID和父的组ID是一样的。当使用shell运行程序创建进程的时候,被创建进程是shell的子进程,并且这个进程将会创建一个进程组,再使用 fork 派生的进程都属于这个进程组。
- 组长不能换组,其他进程可以换组。
1. setpgid 函数——换组
- 进程 fork 产生一个子进程以后,子进程默认和父进程属于同一个进程组。
- setpgid 系统调用可以用来修改进程或者是 exec 之前的子进程的进程组ID,使该子进程变成另一个组的组长。
#include <sys/types.h>
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);//将pid进程的进程组ID设置为pgid
//如果pid为0,使用调用者的进程ID
//如果pgid为0,则进程组ID和pid一致
setpgid(0,0);
将本进程脱离原来的进程组,创建行的进程组
前台是一个进程组,如果子进程脱离原来的进程组,不是前台进程,ctrl+z将无法关闭该进程。
#include <func.h>
int main()
{
pid_t pid = fork();
if(pid == 0){
printf("child, pid = %d, ppid = %d, pgid = %d\n", getpid(), getppid(),
getpgid(0));
setpgid(0,0);
printf("child, pid = %d, ppid = %d, pgid = %d\n", getpid(), getppid(),
getpgid(0));
while(1);
exit(0);
}
else{
printf("parent, pid = %d, ppid = %d, pgid = %d\n", getpid(), getppid(),
getpgid(0));
while(1);
wait(NULL);
exit(0);
}
}
如果使用 setpgid 修改进程组,那么再次使用ctrl+c 触发键盘中断信号的时候,将只会终止父进程。
2. getpgid 函数——获取组ID
获取组ID
getpgid(0) 0代表本进程的意思。
#include <func.h>
int main()
{
pid_t pid = fork();
if(pid == 0){
printf("child, pid = %d, ppid = %d, pgid = %d\n", getpid(), getppid(),getpgid(0));
exit(0);
}
else{
printf("parent, pid = %d, ppid = %d, pgid = %d\n", getpid(), getppid(),
getpgid(0));
wait(NULL);
exit(0);
}
}
通过命令行启动的进程是一个新组的组长。
2. 会话 session
- 会话是进程组的集合。
- 会话首进程:会话的第一个进组的进程
- 一个会话可以连接一个终端 会话和终端断开连接,会话内所有进程收到断开连接的信号。
- 一个会话内有至多一个前台进程组,任意多个后台进程组。
#include <sys/types.h>
#include <unistd.h>
pid_t setsid(void);
pid_t getsid(pid_t pid);
- 一个会话可以有一个控制终端。
- 和控制终端建立连接的会话首进程被称为控制进程。(通常登录时会自动连接,或者使用 open 打开文件 /dev/tty )
- 一个会话存在最多一个前台进程组和多个后台进程组,如果会话和控制终端相连,则必定存在一个前台进程组。
- 从终端输入的中断,会将信号发送到前台进程组所有进程。
- 终端断开连接,挂断信号会发送给控制进程。
- 对于目前不是进程组组长的进程,可以使用系统调用 setsid 可以新建一个会话。使用 getsid 可以获取会话ID。
3. 守护进程 daemon
- 守护进程是服务端的进程,脱离原会话的影响。
- 脱离进程创建时的会话,即便终端断开连接,守护进程不受影响。
- 约定: 可执行程序的名字以d为后缀。
- 只能子进程充当守护进程。
- 所用守护进程的工作目录是相同的,都是根目录。
//daemon.c
#include <func.h>
void Daemon()
{
const int MAXFD=64;
int i=0;
if(fork()!=0){
exit(0);
} //父进程退出
setsid(); //成为新进程组组长和新会话领导,脱离控制终端
//修改子进程环境
chdir("/"); //设置工作目录为根目录
umask(0); //重设文件访问权限掩码
for(;i<MAXFD;i++){
close(i);//尽可能关闭所有从父进程继承来的文件
}
}
int main()
{
Daemon(); //成为守护进程
while(1){
sleep(1);
}
return 0;
}
4. 守护进程和后台进程的差别
linux中后台进程与守护进程的区别是:
1、守护进程已经完全脱离终端控制台了,而后台程序并未完全脱离终端(在终端未关闭前还是会往终端输出结果);
2、守护进程在关闭终端控制台时不会受影响,而后台程序会随用户退出而停止;
3、守护进程的会话组和当前目录,文件描述符都是独立的。后台运行只是终端进行了一次fork,让程序在后台执行,这些都没改变。
5. 日志 log
- 使用守护进程经常可以用记录日志。操作系统的日志文件存储在 /var/log/messages 中。
#include <syslog.h>
void syslog(int priority, const char *format, ...);
-
syslog 的后面几个参数和printf一样
-
日志用来记录用户操作、系统运行状态等,是一个系统的重要组成部分。
-
查看log
vim /var/log/syslog
6. 多进程用gdb调试
一般在工作中不使用gdb(并发)
日志系统结构
gdb单个进程内部的报错
7. gdb调试报错
- 编译时加上 -g
- 用gdb打开程序
- 可选set args设置参数
- r运行触发报错
- bt查看堆栈
总结
前言
一、相关命令
二、进程管理
1.内核管理进程信息
2. PID
3. Linux 开机
4. 进程的用户ID和组ID
5. 进程的有效用户ID和有效组ID
6.passwd命令的设计原理
7.sudo的实现原理
8. 文件特殊权限
三、进程的状态
- 状态
- ps -elf 查看进程状态
- ps aux 查看进程状态
- free (缓冲和缓存的区别)
- top指令
- 优先级和nice值
- 前台和后台
- kill命令和任务控制
三、使用系统调用创建进程 - system
- fork函数 (重点)
- fork的资源
- 写时复制技术
- fork对打开文件的影响
- 输出了多个’-'号(重点)
- exec 函数族
四、进程控制 - 孤儿进程
- 僵尸进程
- wait 函数
- wait 获取子进程状态
五、进程终止 - 简介
- return
- exit 函数
- _Exit 函数和 _exit 函数
- 程序异常终止
六、操所系统管理多进程 - 进程组
- 会话 session
- 守护进程 daemon
- 守护进程和后台进程的差别
- 日志 log
- 多进程用gdb调试
- gdb调试报错