前言
大家好呀~欢迎来到我的Linux学习笔记!本篇主要记录的是Linux操作系统下对进程的各种关键重要的控制,以及如何合理的利用系统功能调用完成程序里对进程的控制,最后完成一个简易的shell命令行解释器的实现。
废话不多说,直接开始吧:
目录
一、重新理解fork创建子进程
fork是一个系统功能调用。早在我之前的初识Linux下的进程已经介绍过啦,想了解相关的我这里有一个传送门哦:【Linux】从冯诺依曼体系到初识Linux下的进程_柒海啦的博客-CSDN博客
现在我们能否结合着进程地址空间,来重新理解一下这个系统调用呢?
1.解释fork创建大致过程
fork()创建子进程,系统里面多了一个进程。创建其PCB结构,通过页表,将代码数据映射到地址空间并且加载入物理内存。
一个进程的结构如下:
创建一个进程,首先对其属性进行描述,也就是OS创造其对应的PCB结构:
OS会做:1.创建空间 分配新的内存块和内核数据结构给子进程
2.初始化
3.添加入运行队列(就绪)
4.fork返回进程具有独立性,所以子进程必须有自己的内核数据结构。理论上,子进程也会有自己的代码和数据,一般而言,但是没有加载过程,就没有自己的代码和数据,所以此时子进程没有对其数据进行修改,子进程就会使用父进程的代码和数据。
当任意一个进程发生写了操作,两者共享区间就会分开,通过页表重新开辟物理空间,此时两者才是真正的彻底分离。
2.数据的分离
可以发现,在上述流程中,fork之后,父子进程的代码和数据可共享,在一方进行写入的时候就会分开。这是为何呢?
代码:都是被写的,只能读取,父子共享没有问题。
数据:可能被修改的,所以必须分离。对于数据而言:1.创建进程的时候,直接拷贝分离。可以,但是运行 访问所以数据 是否读写 --可能拷贝子进程根本不会用到的数据空间,即便是用到了,也可能只是读取。(编译器编译程序的时候,尚且知道节省空间)--创建子进程,不需要将不会被访问的或者只会读取的数据,拷贝一份 -- 没有必要 -- 但是,你还是必须要拷贝 (独立性) 什么样的数据值得拷贝?将来会被父或者子进程写入的数据!
所以,即便是OS,也无法预知哪些空间被写入。
针对上述,如下图可进行概括:
那么,就干脆先都指向一个空间。当真正的需要区分空间,也就是写入的时候,发生写时拷贝。
写时拷贝
OS为了解决上述分离数据(或者代码)的问题,就引入了写时拷贝技术。
解决过程如下:
(对父子进程进行分离(数据分离两个进程就以及是具备独立性的了))
1.用的时候,再给你分配,是高效使用内存的一种表现
2.OS无法在代码执行前预知哪些空间会被访问
可以和C++中的string的一个深浅拷贝问题进行类比 进程分离的时候不就是拷贝一份嘛。CPU 一般有指令级
写时拷贝的过程:
页表的权限只读,此时子父进程指向同一块空间 父进程或者子进程发生写操作了后,才会分离物理空间,此时页表的权限只读也就修改了。
因为有写时拷贝技术的存在,所以,父子进程得以彻底分离!完成了进程独立性的技术保证!(写时拷贝的好处)
写时拷贝,是一种延时申请技术,可以提高整机内存的效率
在fork之后,代码全部共享。那么,子进程又是如何知道要执行fork下一行的呢?不应该执行共享代码的第一行吗?
1.我们的代码汇编之后,会有很多行代码,而且每行代码加载到内存之后,都会有对应的地址。
2.因为进程随时可能被中断(可能并没有执行完),下次回来还必须从之前的位置继续运行。要求CPU必须随时记录下进程执行的位置,所以CPU内对应的有寄存器数据,用来记录当前进程的执行位置。
EIP(PC指针)程序计数器:当前正在执行代码的下一行代码的地址。
(CPU = 取指令 分析指令 执行指令(无脑))
寄存器在CPU内,只有一份(一套),寄存器内的数据可以有多份的。-- 进程的上下文数据 - > 创建的时候也会给子进程,各自调度,各自会调整EIP,但是不重要了,子进程以及认为自己的代码从当前位置开始。
在重新理解了fork相关知识后,我们重新回到对进程的相关控制上面。
二、进程终止
1.从main函数入手
C语言时期,我们第一次写入了如下的代码:
int main()
{
printf("Hello world!");
return 0;
{
直到现在,凡是写C/C++程序都会保留main函数的这个形式。那么,难道就没有疑问,为什么main函数这么写,这个0返回是传给谁的呢?
我们知道,在写普通函数的时候使用return,目的就是在程序里面调用此函数时执行对应的功能返回我们想要的值,那么main函数返回给谁呢?又有什么用呢?这些都和马上要谈的进程终止相关:
进程终止:
释放进程申请的相关内核数据结构和数据和代码(本质就是释放系统资源)
我们知道,当main函数返回的时候,也就是此程序结束的时候,加载入内存变成进程后同样也是结束的标志。
返回码
实际上,main返回的值就是此进程的返回码。通常我们程序结束后返回0,表示此进程没有任何问题,返回非零就表示了出现其他问题。
在Linux下$?可以获得最近一个进程执行完毕的退出码:
比如利用上述的helloworld代码:
修改返回码1:
而这个不为0的信号传递就可以设置成不同的报错处理,可以自己设计,当然,也可以利用系统默认的返回码提示信息。
利用函数strerror()可以查看系统提供默认的返回码含义:(Linux下大概跑了134个信息)
此函数接口所用的头文件是<string.h>,传入返回码,返回一个字符指针。
#include <stdio.h>
#include <string.h>
int main()
{
// 测试系统默认退出码含义
int i = 0;
for (i = 0; i < 134; i++)
{
printf("%d:%s\n",i, strerror(i));
}
return 1;
}
2.exit&&_exit函数
当然,上述main函数中的return语句可以表示一个进程的终止,返回返回码,但是在函数调用的时候,就不能立马结束进程,或者终止进程。这个时候,系统提供了_exit()接口,里面传入对应的返回码,程序正常运行,运行此处进程退出,返回返回码。
系统调用使用头文件<unistd.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
printf("hello world\n");
_exit(0);
}
可以发现和main函数中的类似,只不过此处是使用函数调用结束的。除此系统调用外,其实还有一个语言进行封装后的函数,也就是exit(),用法和_exit()类似,也是传入返回码,对应的结束进程。
此函数调用使用的头文件是<stdlib.h>。这个函数自然不是系统调用。那么封装这个函数的意义在哪里呢?既然功能相同。
看如下程序的运行结构,或许我们就可以窥见之间的区别:
我们将原本输出语句后的\n删掉,先后运行exit函数结束进程和_exit()结束进程,运行结果如下:
可以发现,我们的封装的exit函数有我们打印出的字符串,但是系统调用却没有。
因为原本\n的本意就是刷新缓冲区,使其打印在屏幕上。把\n删掉后,立马结束进程,exit函数的做法是刷新缓冲区,然后结束程序。但是_exit函数的做法是直接结束程序,并没有刷新缓冲区。
其实,这也就侧面说明了缓冲区一定不是OS维护的,而是在C标准库进行维护的。(基础IO在进行细讲)
3.进程终止情况
综上,我们了解到了main函数中的return或者exit、_exit函数调用可以达到终止进程,返回退出码提示信息。但是,这些的前提是什么呢?程序能够正常的运行。在我们历来写代码的过程中,肯定有很多程序崩溃或者报错的情况。此时的退出码还存在意义吗?没有意义。此时研究重点也就不再是退出码是什么,而是了解进程为什么会崩溃。
这个时候就需要的是退出信号。进程之所以崩溃是为什么?因为进行了非法访问从而被操作系统直接干掉,操作系统通过信号控制将此出问题的进程杀掉,返回退出信号。比如之前用的kill -9实际上也就是一种信号操控。
所以,进程的终止实际上是有三种情况的。
1.程序正常运行:
' 程序正常退出,返回0,未出现问题。
'' 程序异常退出,返回非零,出现问题。
2.程序崩溃 进程因为某些非法操作导致无法运行
根据上述的三种情况,为之后的程序等待的依据以及作用条件埋好了伏笔。
三、进程等待
在了解了进程终止后,我们可以明白:实际上就是将此进程结束的信息传给其父进程,父进程进行接收,处理对应的操作。在平时的单线程的程序同样是有父进程的,在初识进程那一篇文章中也有提及。
一般的,父进程可以接收到子进程的退出信息(退出码、退出信号),使用的函数是wait/waitpid进行接收处理,这里先简单用一下函数wait(NULL)(回收成功返回子进程pid,否则返回-1),来解决之前的僵尸进程问题。
1.wait&&僵尸进程的解决
wait是一个系统调用,头文件是:<sys/wait.h><sys/types.h>
int wait(int* 输出型参数);
1.输出型参数实际上就是我们用来接收退出信息变量,传入地址就可以改变这个变量的值,我们经过处理就可以得到对应信息(waitpid部分详讲,这里提一嘴)
2. 返回值:-1表示未能成功返回或者等待,也就是里面出现了问题。
pid 返回子进程的pid就表示等待成功。
此函数默认接收此进程的任意子进程(-1),并且是阻塞式等待。(即父进程等待子进程退出才会继续执行下一步)
先前的初识进程中我们了解到,如果父进程创建一个子进程,子进程比父进程先终止,但是父进程没有处理或者接收的话,就会变成僵尸进程,这里简易写一个代码来重现场景:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork(); // 创建子进程
if (id == 0) // child
{
printf("我是子进程,pid:%d\n", getpid());
sleep(3); // 休息三秒结束
exit(1); // 子进程结束
}
else if (id > 0) // 父进程
{
// 父进程未对子进程做任何处理
while (1)
{
printf("我是父进程 pid:%d, 子进程pid为:%d\n", getpid(), id);
sleep(1);
}
}
return 0;
}
一段运行,一段使用指令进行筛选检查:
while :; do ps ajx | head -1 && ps axj |grep test | grep -v grep; sleep 1; echo "-------------------------------------"; done
得到的结果如下:
可以发现,如果父进程没有进行接收,那么其子进程就会变成僵尸进程,造成内存泄漏的危害。
现在我们学习了一个系统调用wait进行进程等待,实际上就是父进程接收子进程结束的信息,所以代码可以做出如下调整:(休息2秒在接收,方便查看僵尸进程转化的过程)
运行结果如下:
可以发现,此时僵尸进程被很好的解决了,因为父进程接收了。
2.waitpid&&相关参数介绍
上述使用了一个简单的wait(NULL)函数对子进程进行了回收。实际上进程等待使用的最多的是类似的waitpid函数(参数上是一致的)
头文件:<sys/wait.h><sys/types.h>
pid_t waitpid(pid_t pid, int* status, int options)
返回值:
子进程pid 等待成功
0 等待失败,子进程还在运行中
-1 发生错误
pid:
-1 等待该进程的任意子进程
pid 等待指定pid子进程
status:
输出型参数(传入保存退出码、退出信号相关信息)
(目前了解后16位(一共32位)次8位存储退出码相关信息(程序崩溃无效)后7位存储退出信号(程序运行成功信号为0),倒数第八位存储core dump标志)
options:
0 阻塞式等待 (停着等子进程结束)
1(WNOHANG)非阻塞式等待(宏定义)
比如,如果子进程返回110(保存8位,最多255),我们利用指定pid进行阻塞式等待子进程调用完成,然后父进程打印出退出码、退出信号:
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
int i = 0;
for (i = 0; i < 3; i++)
{
printf("%d\n", i + 1);
sleep(1);
}
exit(110); // 子进程直接退出
}
int status = 0;
pid_t res = waitpid(id, &status, 0); // 指定 输出型参数 阻塞等待
if (res > 0)
{
printf("子进程退出码:%d\n退出信号:%d\n", ((status >> 8) & 0xFF), (status & 0x7F));
}
return 0;
}
计算退出码和退出信号:
我们已知status的[17, 24]表示的是退出码,为了取出其值,(用十进制表示就使用%d),可以先将其右移8位(2进制操作),然后保留此八位,就与1111 1111进行按位与,0xFF,同理,退出信号是后七位,只需要和0111 1111进行按位与即可求出。0x7F。
另外,除了位运算,也可以利用宏进行求值:WEXITSTATUS(status) 返回退出码,WIFEXITED(status)若正常终止子进程的返回状态,返回真。
运行结果:
上面展示的是阻碍的等待子进程退出。那么有没有可能,在等待的这一段时间里,父进程无阻碍的进行等待子进程退出,然后自己可以处理自己的事情呢?我们可以利用下面这串代码进行演示:
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <vector>
#include <iostream>
using namespace std;
typedef void (*def) (); // 函数指针
vector<def> v;
void test1()
{
printf("处理工作1\n");
sleep(1);
}
void test2()
{
printf("处理工作2\n");
sleep(1);
}
void push()
{
v.push_back(test1);
v.push_back(test2);
}
int main()
{
pid_t id = fork();
if (id == 0)
{
int i = 0;
for (i = 0; i < 3; i++)
{
printf("%d\n", i + 1);
sleep(1);
}
exit(110); // 子进程退出
}
int status = 0;
// 非阻塞等待,等待过程中父进程可以处理自己的事情
pid_t res = waitpid(id, &status, WNOHANG); // 指定 输出型参数 阻塞等待
push();
while (res == 0)
{
// 子进程还没有退出
// 父进程执行其任务
v.front()();
v.back()();
res = waitpid(id, &status,WNOHANG); // 指定 输出型参数 阻塞等待
}
if (res > 0)
{
printf("子进程退出码:%d\n退出信号:%d\n", WEXITSTATUS(status), (status & 0x7F));
}
return 0;
}
运行结果:
可以发现,非阻塞式的等待的话,父进程可以做自己的事情,上述代码只是为了方便理解加了一些函数指针而已,日后就可以不断向里面加函数指针,父进程也能够利用好时间,这样程序也就可以高效的进行运转。
综上,在进程控制上,我们首先了解了一个进程如何终止的,然后终止后留下如何的信息,然后到了进程等待接收信息。除此之外,我们进程之间可以将其他程序进行一个替换,以便达到进程替换的效果。
四、进程替换
进程替换,字面意思上理解。就是把一个特定区域(磁盘)中还未加载入内存的一个程序加载到此时被调用的进程中去。其中加载的是代码和数据,并且没有创建新的进程!
如何理解呢?实际上,进程替换,OS提供的接口是exec系列的函数,下图可以方便我们理解进程替换的相关概念:
将新的磁盘上的程序加载到内存,并且和当前进程的页表,重新建立映射,摒弃老的程序。
进程替换,没有创建新的子进程。--只是重新建立了映射关系,内核没有变化。
理解所谓的程序放入内存中:-> 加载!->所谓的exec函数本质,就是如何加载程序的函数。
exec*类接口
系统为我们的程序提供了这样的接口,方便我们进行进程的替换工作。
六大函数如下:
函数 | 解释 |
int execl(const char *path, const char *arg, ...) | "程序路径", 可变参数(最后一个带NULL) |
int execlp(const char *file, const char *arg, ...) | "程序路径"(优先在环境变量下搜索), 可变参数(最后一个带NULL) |
int execle(const char *path, const char *arg,..., char * const envp[]) | "程序路径", 可变参数(最后一个带NULL),传递环境变量(全局属性) |
int execv(const char *path, char *const argv[]) | "程序路径",参数指针数组(最后一个指针元素指向NULL) |
int execvp(const char *file, char *const argv[]) | "程序路径"(优先在环境变量下搜索),参数指针数组(最后一个指针元素指向NULL) |
int execvpe(const char *file, char *const argv[],char *const envp[]) | "程序路径"(优先在环境变量下搜索), ,参数指针数组(最后一个指针元素指向NULL),传递环境变量(全局属性) |
实际上,上述六种exec类函数是经过封装过的,实际上的系统调用是:int execve(const char *path, char *const argv[], char* const envp[]);
exec*类函数的使用
但是实际上分为不同的环境,所以就封装了六种。我们利用系统的指令程序来简单实现以上函数接口的简单使用:
#include <unistd.h>
#include <stdio.h>
int main()
{
// 执行 ls -a -l -i
//execl("/usr/bin/ls", "ls", "-a", "-l", "-i", NULL); // 路径加(路径绝对路径和相对路径均可) 可变参数
//execlp("ls", "ls", "-a", "-l", "-i", NULL); // 优先环境变量搜素 可变参数
char *const _argv[16]= {
(char*)"ls",
(char*)"-a",
(char*)"-l",
(char*)"-i",
NULL
};
//execv("/usr/bin/ls", _argv); // 优先环境变量搜素 可变参数
execvp("ls", _argv);
return 0;
}
上述就是一些简单的测试样例,需要注意是*const对象,成员前要加(char*)进行强转为char*类型,否则就是const char*,传入的参数就会导致不正确,程序编译也会报错。
当然也可以实现不同程序的相互调用哦~这里简单举例:C/C++,以及python之间的调用。
比如调用其他的C/C++程序:
// test2.c
#include <unistd.h>
#include <stdio.h>
int main()
{
// 替换自己的C/C++程序
char *const env[16] = {
(char*)"MY=666",
NULL
}; // 写入环境变量
execle("test", "test", "-b", NULL, env); // 传入相对路径 可变参数 环境变量
return 0;
}
// test.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
// 调用此程序需要传入两个参数
if (argc != 2)
{
printf("调用错误!\n");
exit(1);
}
printf("环境变量MY的值:%s\n", getenv("MY"));
if (strcmp(argv[1], "-a") == 0)
printf("你好!\n");
if (strcmp(argv[1], "-b") == 0)
printf("好呀!\n");
return 0;
}
可以发现替换成功。
现在我们可以调用一个不是用C/C++写的程序,查看是否能调用。对于Python程序文件,不存在编译过程,是植入python解释器之间运行的,所以应该以下面方式进行调用,或者chmod +x改变权限达到自动调用解释器的功能。
// test3.c
#include <unistd.h>
#include <stdio.h>
int main()
{
execlp("python", "python", "test.py", NULL); // 利用解释器在path中进行调用
//execl("test.py","test.py", NULL); // 利用权限给test.py+x权限可运行,自动调用python解释器解释
return 0;
}
#! /usr/bin/python3.6
for i in range(3):
print("hello!I am Python!")
五、模拟实现shell命令行解释器-综合运用
综上,我们已经了解到了进程控制,初步了解了进程的终止返回信息,以及如何等待,进程替换等。
那么,我们是否可以将上面的所以条件混合在一起,一个进程创建子进程,子进程进行进程替换,父进程监视,查看信息。所以,我们就可以对shell命令行解释器进行一个简单的实现。(shell本质)。以下是代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>
// 实现一个简易命令行解释器
#define NUM 1024
#define SIZE 32
#define SEP " "
// 用户输入的缓冲区
char cmd_line[NUM];
// 指针数据 保存打散之后的命令行字符串
char *g_argv[SIZE];
int main()
{
// 首先是一个死循环
while (1)
{
// 打印提示信息
printf("[root@centos myshell]# ");
fflush(stdout); // 刷新显示器
// 首先初始化为\0
memset(cmd_line, '\0', sizeof cmd_line);
// 使用文件输入fgets函数
if (fgets(cmd_line, sizeof cmd_line, stdin) == NULL) // 返回NULL说明报错了
{ continue; } // 一般情况下是不会报错的 报错重新进行即可
// 别忘了最后输入的是一个\n
cmd_line[strlen(cmd_line) - 1] = '\0';
// 将其分开 用指针数组进行
// 使用函数 strtok(字符串, 分隔字符串);进行分隔 返回NULL表示无法分割了 返回第一个分隔字符串位置
g_argv[0] = strtok(cmd_line, SEP);
int index = 1;
// 命令行处理函数 ls 加上 --color=auto
if (strcmp(g_argv[0], "ls") == 0)
g_argv[index++] = "--color=auto";
while(g_argv[index - 1])
{
g_argv[index++] = strtok(NULL, SEP); // 多次调用,解析历史字符串使用NULL确定
}
// 检查是否分割成功:
//for (index = 0; g_argv[index]; ++index)
//{
// printf("%s\n", g_argv[index]);
//}
// 判断是否要内部执行函数,cd的话调用子进程没有用,需要myshell进程也就是父进程切换目录才有效
if (strcmp(g_argv[0], "cd") == 0)
{
// int chdir(char* path) 切换当前工作目录
if (g_argv[1] != NULL) chdir(g_argv[1]);
}
else
{
// 创建子进程
pid_t id = fork();
if (id == 0)
{
// child
// 使用替换函数 execvp
execvp(g_argv[0], g_argv);
exit(1);
}
// father
int status = 0; // 接收返回码
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
if (ret > 0)
printf("exit code:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
综上,进程控制就介绍到这里啦,欢迎大佬们纠错补正!蟹蟹~