目录
进程创建
fork函数
在上一篇中我们已经知道了他是什么,也知道了他怎么用,这里就不过多赘述了,但是我们还是要看一下,fork创建子进程操作系统都做了什么。
- 分配新的内存块和内核数据结构给子进程。
- 将父进程部分数据结构内容拷贝至子进程。
- 添加子进程到系统进程列表当中。
- fork返回,开始调度器调度。
我们一步一步看,创建了一个子进程,系统中也就多了一个进程,再次强调:进程 = 内核数据结构 + 代码和数据。为子进程分配和初始化他的数据结构,子进程的数据结构都是来自父进程,进行拷贝操作,再把他添加到运行队列中。
但是子进程没有从硬盘加载到内存这个操作,所以他没有自己的代码和数据,只能共享父进程的,代码通常也是只读的,共享没有问题,但是数据是可能会修改的,所以数据必须要分离。分离就给你拷贝一份,但是子进程拷贝根本就用不到的数据,那么这个进程创建出来也只是多了个进程,并且和父进程一模一样,要是再不退出那就一直占着空间,那这就是浪费空间。
所以创建子进程不需要将不会被访问或只读的数据进行拷贝,只用拷贝将来会写入的数据,但是将来发生什么是是不知道的啊,所以操作系统选择了写时拷贝来进行父子进程数据的分离,从而保证了进程的独立性。
fork用法
- 一个父进程希望复制自己,与子进程同时执行不同的代码。
- 一个进程要执行不同的程序,例如子进程fork返回后调用exec函数,这后面再说。
fork失败原因
- 系统中有太多进程。
- 实际用户的进程数超过了限制。
进程终止
进程终止时操作系统要释放进程申请的相关内核数据结构和代码。
进程退出的场景:
- 代码跑完,结果正确
- 代码跑完,结果不正确
- 代码没有跑完,程序崩溃
这些退出场景是什么意思,接下来就来看一下。
通常我们写的main函数的返回值不都是0吗,这个0是什么,是啥意思呢?
其实这里并不一定要是0,它表示的是这个进程的退出码。0代表代码跑完了,结果是正确的。在命令行中,我们想要获取最近一次进程的退出码使用的是:echo $?。
通常情况下,0表示success结果正确,而非0表示结果不正确,非0值有无数个,不同的值表示不同的错误,从而给程序运行结束之后定位错误原因。
#include <stdio.h> #include <unistd.h> #include <string.h> int main() { for (int i = 0; i < 135; i++) { printf("[%d]: %s\n", i, strerror(i)); } return 0; }
这里为什么写i < 135是因为strerror打印的信息一共就135条。
这只是strerror提供的错误信息,如果不想用它的也可以自己定义退出码。
ls也是一个程序,如果使用错误的选项或者不存在的文件会怎么样呢?
错误码是2,也可以对应上面的图找找,2也就是No such file or directory。
这里演示一下程序崩溃。int main() { printf("hello 1\n"); printf("hello 1\n"); printf("hello 1\n"); int* p = NULL; *p = 1; // 野指针错误 printf("hello 2\n"); printf("hello 2\n"); printf("hello 2\n"); return 0; }
这为啥是139啊?所以程序崩溃的时候,退出码无意义。
进程常见退出方法
正常的终止方法:
- 只有main函数内的return语句就是终止进程的。
- 调用exit();他在任何地方调用都表示终止进程。
- _exit();
他们两个就什么区别呢?
#include <stdio.h> #include <unistd.h> #include <string.h> #include <stdlib.h> int main() { printf("hello"); sleep(3); exit(1); }
如果我们不写"\n"代表数据还在缓冲区内,exit会帮我们刷新缓冲区。
#include <stdio.h> #include <unistd.h> #include <string.h> #include <stdlib.h> int main() { printf("hello"); sleep(3); _exit(1); }
_exit是一个系统调用,它直接帮我们终止程序。
那我们就要再来说一下系统调用和库函数的关系了,库函数是用户为了更好的使用而封装了系统调用接口,exit也是把_exit封装了一下,\n会自动刷新缓冲区,exit也会刷新,所以这个缓冲区一定不在操作系统中,如果在的话,_exit也会刷新缓冲区,所以这个缓冲区是C标准库维护的,关于这些细节以后会说的。
进程等待
进程等待的必要性
- 如果子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,这样就会造成内存泄漏。
- 子进程一旦变成僵尸进程,就算是kill -9 命令也无法将其杀死,因为无法杀死一个已经死去的进程。
- 对于一个子进程,父进程必须要知道自己派给子进程的任务完成有没有完成。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程的退出信息。
还是这段代码,还是这个现象。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> int main() { pid_t id = fork(); if (id < 0) { perror("fork"); exit(1); // 进程运行完毕,结果不正确 } else if (id == 0) { // 子进程 int cnt = 3; while (cnt--) { printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid()); sleep(1); } exit(0); } else { // 父进程 while (1) { printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); } } return 0; }
三秒后子进程退出,父进程还在运行,父进程没有读取子进程的退出信息,所以子进程进入了僵尸状态,不想让他这样那就让父进程接受它的退出信息就可以了。
wait()
这里又是一个系统调用接口。
作用:等待任意子进程
参数:是输出型参数,获取子进程的退出状态,不需要知道退出状态可设置为NULL。
返回值:等待成功返回等待进程的pid,失败返回-1。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t id = fork(); if (id < 0) { perror("fork"); exit(1); // 进程运行完毕,结果不正确 } else if (id == 0) { // 子进程 int cnt = 3; while (cnt--) { printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid()); sleep(1); } exit(0); } else { // 父进程 printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid()); pid_t ret = wait(NULL); // 阻塞式的等待 if (ret > 0) { printf("等待子进程成功,ret: %d\n", ret); } while (1) { printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); } } return 0; }
子进程先运行三秒,使用wait的时候是阻塞式的等待,只有等待成功才往后执行,父进程也回收了处于僵尸状态的子进程。
waitpid()
还有一个就是waitpid,pid_t waitpid(pid_t pid, int* status, int options);
作用:等待指定子进程或任意子进程。
参数:
1.如果pid=-1,表示等待任意一个子进程。
2.如果pid>0,等待进程id与pid相等的子进程。
3.status如果想要结果需要传入status的地址,用来获取子进程的退出结果。
4.options默认为0,表示阻塞等待。
返回值:
1.正常返回的是子进程的pid。
2.如果选型中是WNOHANG,而子进程没有退出,返回的就是0.
3.调用出错返回-1
waitpid(pid, NULL, 0) == wait(NULL)
status
下面就来看看status是怎么用的。
int main() { pid_t id = fork(); if (id < 0) { perror("fork"); exit(1); // 进程运行完毕,结果不正确 } else if (id == 0) { // 子进程 int cnt = 3; while (cnt--) { printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid()); sleep(1); } exit(5); } else { // 父进程 printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid()); int status = 0; pid_t ret = waitpid(id, &status, 0); // 阻塞式的等待 if (ret > 0) { printf("等待子进程成功,ret: %d, status: %d\n", ret, status); } } return 0; }
还是子进程运行3秒,然后退出,此时父进程等待,等待成功拿到了退出信息,status应该帮我们拿到exit(5)的值,但是这里为什么不是5呢?
这就要说一下,status不是按照整型来使用的,而是按照bit位的方式,将32个bit位进行划分,现在就先看低16位,这16位的前8位就存放着退出信息,如何拿到这8位,先向右移8位再按位与上0xFF,这样只有后八位是1,其余都是0就可以拿到这八位。
// ... int status = 0; pid_t ret = waitpid(id, &status, 0); // 阻塞式的等待 if (ret > 0) { printf("等待子进程成功,ret: %d, status8位退出码: %d\n", ret, status >> 8 & 0xFF); // 0xFF -> 0000...0000 1111 1111 } // ...
这样就拿到了5。
还要再说的一点就是,平常写代码的时候遇到程序崩溃,准确的来说应该是进程崩溃,这是由操作系统发送信号才让他崩溃的,那么这种信号被存放在status的低7位。
//... int status = 0; pid_t ret = waitpid(id, &status, 0); // 阻塞式的等待 if (ret > 0) { printf("等待子进程成功,ret: %d, status7位信号: %d, status8位退出码: %d\n", ret, status & 0x7F, status >> 8 & 0xFF); // 0x7F -> 0000...0000 0111 1111 ; 0xFF -> 0000...0000 1111 1111 } //...
信号为0就代表正常退出。使用kill -l来查看所有的信号。
如果这时候子进程来一个除0错误会怎么样呢?
8号信号就可以知道是SIGFPE,这是浮点数错误。这就代表程序崩溃,收到操作系统发来的信号,此时的退出码就没有意义了。
如果子进程是个死循环,父进程使用kill -9 信号处理。
所以程序异常不止是内部的问题,也有可能是外力操作。
既然我们已经知道了怎么拿到这几位信息,但这样是不是太麻烦了,所以系统为我们提供了两个宏:
- WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。(Wait IF EXIT EnD,方便记忆)
- WEXITSTATUS(status):用于获取进程的退出码(Wait EXIT STATUS)。
int main() { pid_t id = fork(); if (id < 0) { perror("fork"); exit(1); // 进程运行完毕,结果不正确 } else if (id == 0) { // 子进程 int cnt = 3; while (cnt--) { printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid()); sleep(1); } exit(5); } else { // 父进程 printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid()); int status = 0; pid_t result = waitpid(id, &status, 0); // 阻塞式的等待 if (result > 0) { if (WIFEXITED(status)) { // 子进程正常退出 printf("子进程执行完毕,子进程的退出码: %d\n", WEXITSTATUS(status)); } else { printf("子进程异常退出: %d\n", WIFEXITED(status)); } } } return 0; }
waitpid的第三个参数option,默认0的情况下就是阻塞等待,库中也帮我们定义了一个宏define WNOHANG 1;,它的值其实就是1,宏就是为了帮助我们见名知意,Wait NO HANG,hang这个单词也有挂起吊死的意思,有的时候一个进程怎么都不动,CPU可能很忙,没有调度这个进程,所以他要么在阻塞队列中要么在等待被调度,所以NO HANG就是非阻塞等待的意思。
那么waitpid是怎么做到阻塞或者非阻塞的呢,在waitpid这个函数中,操作系统要先检测子进程是否退出,在task_struct中存放着这些信息。
如果子进程退出了,如果有status就把status的信息填充好,然后返回它的pid。
如果子进程没退出,还要再检测你的option是几,是0就是阻塞等待,在CPU中的寄存器中保存它的上下文,然后把父进程挂起,放到等待队列中,所以进程阻塞是阻塞在函数内部的这一行,后面的代码就不执行了,只有满足条件的时候才被唤醒,从寄存器中拿到这一行的位置,继续向后执行;如果是1就代表非阻塞,函数直接就return,继续执行其他代码。
int main() { pid_t id = fork(); if (id < 0) { perror("fork"); exit(1); // 进程运行完毕,结果不正确 } else if (id == 0) { // 子进程 int cnt = 3; while (cnt--) { printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid()); sleep(1); } exit(5); } else { // 父进程 int quit = 0; while (!quit) { int status = 0; pid_t res = waitpid(id, &status, WNOHANG); // 非阻塞式的等待 if (res > 0) { // 等待成功,子进程退出 printf("等待子进程退出成功,退出码: %d\n", WEXITSTATUS(status)); sleep(2); quit = 1; } else if (res == 0) { printf("子进程还在运行,父进程可以继续处理其他事\n"); sleep(1); } else { // 等待失败 } } } return 0; }
我们前面老是提到,进程具有独立性,但是进程的退出码也是子进程的数据,父进程为什么能拿到?
子进程退出了,变成了僵尸进程,但也保留着进程PCB的信息,在PCB中也会有退出码和退出信号等信息,这就要说到wait和waitpid了,他们两个是系统调用接口,说白了就是系统帮我们用这两个函数从子进程的PCB中拿到了status的值。
进程替换
在进程创建的部分说到了fork创建子进程,之后父子各自执行父进程代码的一部分,如果子进程不想执行父进程的代码,换言之就是让子进程执行一个新的代码,这就需要进程替换来完成,进程替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中。
子进程要调用一种exec函数,从而执行另一个程序。当进程调用这种exec函数时,该进程的用户空间代码和数据完全被新程序替换,页表的映射关系重新建立,从新程序的第一行开始执行。调用exec并不会创建新的进程,它的本质就是加载程序的函数,所以调用exec前后该进程的pid并未改变。
替换函数
返回值:只有替换失败了才有返回值为-1,替换成功会把自己也替换掉,所以成功没有返回值。
替换函数有六种以exec开头的函数,它们统称为exec函数,先来看第一个:
execl
int execl(const char *path, const char *arg, ...);
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。
例如ls -l -a,ls就是/usr/bin下的一个可执行程序,所以就用代码调用一下它。
int main() { printf("begin---------------------\n"); execl("/usr/bin/ls", "ls", "-l", NULL); printf("end-----------------------\n"); return 0; }
这样就在代码中使用了命令,奇怪的一点是,我的end代码怎么没有打印出来?
execl是程序替换,调用函数成功,会将当前进程的代码和数据都进行替换,只要替换成功,后面的代码就不会执行了。
在进程替换之前,父子进程的代码是共享的,数据写时拷贝。进程替换后,子进程要写入新的程序并重新建立映射关系,所以父子进程的代码也要分离,代码也要进行写时拷贝,这样父子进程的代码和数据都分离了。
execv
再来看下一个函数:
int execv(const char *path, char *const argv[]);
从命名上execl最后一个是“l”,可以理解为list也就是列表,最后一个参数也是可变参数列表,把参数一个一个写出来。而execv最后一个是“v”,这就是vector,像一个数组一样,最后一个参数也是指针数组。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> #define NUM 16 int main() { pid_t id = fork(); if (id == 0) { // 子进程 printf("子进程开始运行,pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); char* const _argv[NUM] = { "ls", "-l", "-a", NULL }; execv("/usr/bin/ls", _argv); exit(1); } else { // 父进程 printf("父进程开始运行,pid: %d, ppid: %d\n", getpid(), getppid()); int status = 0; pid_t res = waitpid(id, &status, 0); // 阻塞等待,子进程先运行,父进程再运行 if (res > 0) { printf("wait success, exit_code: %d\n", WEXITSTATUS(status)); } } return 0; }
和execl的使用没有太大区别。
execlp
再来看下一个函数:
int execlp(const char *file, const char *arg, ...);
execlp中的“l”代表列表的形式传参,而“p”代表的是传入一个文件名,他自己会在环境变量中找。
execlp("ls", "ls", "-l", "-a", NULL);
execvp
那么execvp也就好理解了。
int execvp(const char *file, char *const argv[]);
char* const _argv[NUM] = { "ls", "-l", "-a", NULL }; execvp("ls", _argv);
execle
下一个是execle。
int execle(const char *path, const char *arg, ..., char *const envp[]);
函数名没有“p”,所以不会从环境变量中找,“l”是列表的形式,“e”就代表第三个参数你可以设置环境变量。
char* const _env[NUM] = { "MY_VAL=100", NULL }; execle("...", "...", NULL, _env);
假如我不行使用ls这种系统提供的程序,我想要替换自己写的程序,那就需要传入这个程序的路径、可变参数,最后一个就可以传入想给这个进程的环境变量。
execvpe
int execvpe(const char *file, char *const argv[], char *const envp[]);
这个函数也就不太难理解了,传入的参数可以在环境变量中找到,以数组的形式传入,可以传入环境变量给替换的进程。
execve系统调用
上面所说的6个函数都是对这个系统调用的封装,为了满足不同的需求。
int execve(const char *path, char *const argv[], char *const envp[]);
命名的理解
- l(list) : 表示参数用列表形式
- v(vector) : 参数用数组形式
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
简易shell
简易的shell就是一个命令行解释器,原理就是当有命令需要执行的时候,shell(父进程)创建子进程让它执行命令,而shell(父进程)只需要等待子进程退出。
通过这个函数拿到从键盘中输入的字符串。
通过这个函数分割字符串。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> #define NUM 1024 #define SIZE 32 #define SEP " " // 保存用户输入的字符串 char cmd_line[NUM]; // 保存分割后的字符数组 char* g_argv[SIZE]; int main() { // 0. 命令行解释器:一定是一个不退出的进程 while (1) { // 1. 打印提示信息 printf("[root@localhost myshell]# "); // 没有\n是不会打印的 fflush(stdout); // 刷新缓冲区 memset(cmd_line, '\0', sizeof(cmd_line)); // 2. 获取输入的指令和选项 if (fgets(cmd_line, sizeof(cmd_line), stdin) == NULL) // 判断 { continue; } cmd_line[strlen(cmd_line) - 1] ='\0' ; // 输入的应该是:ls -l -a\n,要把\n去掉 // 3. 命令行字符串解析 g_argv[0] = strtok(cmd_line, SEP); // 第一次要传入字符串 int index = 1; while (g_argv[index++] = strtok(NULL, SEP)); // 第二次如果还要解析上一个字符串就要传入NULL // 创建子进程 pid_t id = fork(); if (id == 0) { // 子进程 execvp(g_argv[0], g_argv); exit(1); } else { // 父进程 int status = 0; pid_t ret = waitpid(id, &status, 0); // 阻塞等待 if (ret > 0) printf("exit_code: %d\n", WEXITSTATUS(status)); } } }
这样一个简易的shell就完成了,但是这个shell还有一点缺点。
当我们运行自己写shell,输入cd ..命令返回上一级目录的时候就会有问题。
使用了cd命令,但是没有执行,这时为什么呢?
因为这行命令都交给了子进程,子进程执行cd只会影响它的当前路径,所以需要特殊处理,说白了它就是父进程中的函数调用。
原来我们讲过export添加环境变量也是要在父进程中添加的从而影响全局。这也是父进程中的函数调用,要注意的是添加环境变量的时候定义了字符数组,并把要添加的环境变量拷贝到数组中,使用数组添加环境变量,
还可以继续优化一下,ls可以加上配色,输入ll的也可以处理一下。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> #define NUM 1024 #define SIZE 32 #define SEP " " // 保存用户输入的字符串 char cmd_line[NUM]; // 保存分割后的字符数组 char* g_argv[SIZE]; // 存放想要添加的环境变量 char g_val[32]; int main() { // 0. 命令行解释器:一定是一个不退出的进程 while (1) { // 1. 打印提示信息 printf("[root@localhost myshell]# "); // 没有\n是不会打印的 fflush(stdout); // 刷新缓冲区 memset(cmd_line, '\0', sizeof(cmd_line)); // 2. 获取输入的指令和选项 if (fgets(cmd_line, sizeof(cmd_line), stdin) == NULL) // 判断 { continue; } cmd_line[strlen(cmd_line) - 1] ='\0' ; // 输入的应该是:ls -l -a\n,要把\n去掉 // 3. 命令行字符串解析 g_argv[0] = strtok(cmd_line, SEP); // 第一次要传入字符串 int index = 1; if (strcmp(g_argv[0], "ls") == 0) { g_argv[index++] = "--color=auto"; // 也可以为ls加上配色 } if (strcmp(g_argv[0], "ll") == 0) { g_argv[0] = "ls"; g_argv[index++] = "-l"; g_argv[index++] = "--color=auto"; } while (g_argv[index++] = strtok(NULL, SEP)); // 第二次如果还要解析上一个字符串就要传入NULL // export添加环境变量也是要在父进程添加的 if (strcmp(g_argv[0], "export") == 0 && g_argv[1] != NULL) { strcpy(g_val, g_argv[1]); // 如果使用g_val[1]去添加环境变量会添加失败 int ret = putenv(g_val); if (ret == 0) printf("export success\n"); continue; } // 4. 处理内置命令,是让父进程(shell)执行的,是要影响父进程的 if (strcmp(g_argv[0], "cd") == 0) { if (g_argv[1] != NULL) chdir(g_argv[1]); continue; } // 5. 创建子进程 pid_t id = fork(); if (id == 0) { // 子进程 execvp(g_argv[0], g_argv); exit(1); } else { // 父进程 int status = 0; pid_t ret = waitpid(id, &status, 0); // 阻塞等待 if (ret > 0) printf("exit_code: %d\n", WEXITSTATUS(status)); } } }