目录
一、进程创建
1、fork——创建一个子进程
fork是一个系统函数,在头文件<unistd.h>中被声明,声明如下:
pid_t fork(void);
fork()通过复制调用进程(称为父进程),创建一个新的进程。这个新的进程(称为子进程)是父进程的精确的复制品,但是要注意:
(1)子进程有自己唯一的PID(process id 进程编号),并且这个PID与任何已经存在的进程组的ID不匹配;
(2)子进程的PPID(parent process id 父进程编号)是父进程的PID;
接下来看一段代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
printf("fork() error!\n");
}
else if (id == 0)
{
printf("child: pid=%d, ppid=%d.\n", getpid(), getppid());
}
else
{
printf("parent: pid=%d, ppid=%d\n", getpid(), getppid());
}
return 0;
}
执行结果如下:
$ ./my_process
parent: pid=29357, ppid=19641
child: pid=29358, ppid=29357.
这里看到两行输出,但是在我们的认知中,一个程序执行if else语句只会执行其中的一个逻辑,要么error,要么child,要么parent,怎么会执行两个逻辑呢,为什么id一会等于0,一会又大于0?
2、fork()返回值
在一个程序中,如果调用了fork()函数,在程序执行到fork()函数内部时,操作系统会做如下工作:
(1)分配新的内存块和内核数据结构给子进程;
(2)将父进程的部分数据结构内容拷贝至子进程;
(3)将子进程添加到系统的进程列表中;
(4)fork返回;
也就是说,当一个进程调用fork时(称为父进程),就会创建一个和父进程二进制代码相同的子进程,而且它们都运行到相同的地方;
在fork()函数返回值之前,子进程也一定被创建好了,并且执行到和父进程一样的地方——返回fork()函数值。
在父进程中,若子进程创建失败,id = -1;若子进程创建成功,就会给父进程返回子进程的PID(>0),给子进程返回0;
这个时候,系统中就有两个进程,每个进程的id不同,所以执行了不同的if else语句,因此会输出两行。
还有一点要注意的是,创建了子进程后,父子进程谁先被调度,先被执行是不确定的,在上述的代码和结果中,虽然if else语句中child的判断逻辑在parent之前,但是先输出的是parent,说明父进程先被调度执行。
3、写时拷贝
在子进程被创建后,为了节省空间,子进程享用父进程的代码和数据,只有当父进程/子进程要修改自己的数据时,会重新拷贝一份数据供两个进程分别使用,这种方式称为写时拷贝。
二、进程终止
1、退出码
进程是来完成某一件事的,这件事完成得怎么样,是成功了,还是失败了,为什么失败了,我们是需要知道的。进程告诉我们它的工作完成得怎么样,就是靠退出码。举个例子:
#include <stdio.h>
int main()
{
printf("hello world!\n");
return 1;
}
在main()函数尾部,有一个return 1;语句,这个就标识了这个进程的退出码是1,当一个进程运行结束之后,可以通过echo $?命令来查看上一个进程的退出码:
$ ./mytest
hello world!
$ echo $?
1
$ echo $?
0
$ echo $?
0
当我们./mytest执行我们的程序结束后,使用echo指令查看其退出码是1,再次使用echo指令,查看的就是echo进程的退出码,是0;
在计算机中,我们可以通过设置不同的退出码来标识不同的退出状态,一般情况下,0表示成功,非0表示出现了问题,每一个数字都对应一种状态,这种状态可以我们自己自定义,也可以使用系统中的映射关系,C语言头文件<string.h>,有一个函数strerror可以查看退出码对应具体意义:
#include <stdio.h>
#include <string.h>
int main()
{ int i = 0;
for(i = 0; i < 150; ++i)
{
printf("%d: %s\n", i, strerror(i));
}
return 0;
}
运行结果如下:
其中,在134之后,就都是没有定义的退出码了。
2、进程退出的三种方式
#include <unistd.h>
void _exit(int status);
void exit(int status);
调用两个函数可以使进程退出,其中,status表示的是进程的退出状态,只有后16位是有效的,接下来再讲status,现在先比较 _exit() 与 exit() 两个函数。
执行下面的代码,分别调用两个函数
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("hello world");
_exit(0);
// exit(0);
return 0;
}
会出现上图的结果,是因为调用exit()后,exit()刷新了缓冲区,让缓冲区中的内容显示出来。
实际上exit()函数被调用后主要包括以下一些步骤:
(1)执行用户定义的清理函数;
(2)关闭所有打开的流,所有的缓存数据均被写入;
(3)调用_exit()函数。
第三种退出方式是return退出,main函数的调用函数会接收main函数返回的return值当作status传给exit()函数。
三、进程等待
之前学习的僵尸进程,是因为子进程退出后父进程没有及时回收子进程的退出信息,导致内存泄漏,而进程等待,则可以让父进程获取子进程的退出信息,并释放资源。
1、wait
pid_t wait(int *status);
wait系统调用会暂停调用进程的执行,直到它的子进程的其中一个终止。如果调用成功,返回终止的子进程id,否则,返回-1。
2、waitpid
pid_t waitpid(pid_t pid, int *status, int options);
waitpid系统调用会暂停调用进程的执行,直到pid指定的子进程状态改变。
当pid的值:
<-1 时,等待进程组号为pid的绝对值的任意一个子进程;
为-1时,等待任意一个子进程;
为0时,等待进程组号与调用进程相同的任何子进程,也就是任意一个和调用进程在同一个进程组的进程;
>0时,等待进程号为pid的进程。
当option的值:
为0时,没有效果;
WNOHANG,如果子进程没有退出,立刻返回。
若函数调用成功,返回状态改变的子进程的id;如果WNOHANG被指定,并且pid指定的进程存在但状态没有改变,返回0。否则,返回-1.
3、status
如果status传递NULL,表示不关心子进程的退出状态信息。
否则,操作系统会在status中填入子进程的退出信息。
但是status虽然是int有32位,但只有后16位是有效的,其中,如果进程正常退出,低八位全0,次低八位表示退出状态,如果进程不是正常退出,则低七位表示终止信号,第八位是core dump标志(这个标值下一篇会讲到),次低八位无意义。
代码测试如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
// 测试wait()
pid_t id = fork();
if (id < 0)
{
perror("fork fail");
exit(1);
}
if (id == 0)
{
// child
int cnt = 5;
while(cnt--)
{
printf("I'm child, pid:%d, ppid:%d, cnt=%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(10);
}
if (id > 0)
{
wait(NULL);
printf("wait success!\n");
}
return 0;
}
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
int main()
{
// 测试waitpid()和status
pid_t id = fork();
if (id < 0)
{
perror("fork fail");
exit(1);
}
if (id == 0)
{
// child
int cnt = 5;
while(cnt--)
{
printf("I'm child, pid:%d, ppid:%d, cnt=%d\n", getpid(), getppid(), cnt);
sleep(1);
if (cnt == 1)
{
// 这里空指针访问会异常退出
int *p = NULL;
*p = 10;
}
}
exit(10);
}
if (id > 0)
{
// parent
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret > 0 && (status & 0x7F) == 0)
{
// 正常退出
// 退出码应为10(上面设置的)
printf("exit code: %d\n", (status >> 8) & 0xFF);
}
else if (ret > 0)
{
// 异常退出
int sig_code = status & 0x7F;
printf("signal code: %d\n", sig_code);
printf("%s\n", strerror(sig_code));
}
}
return 0;
}
4、阻塞等待和非阻塞等待
在waitpid()函数的第三个参数option中,可以选择输入WNOHANG,表示如果子进程没有退出,函数会直接返回,意味着父进程可以不用一直等待子进程,在得知子进程没有退出后,父进程可以先去做其他工作,这叫做非阻塞等待,相反,如果父进程一直等到子进程退出才继续其他工作,这叫阻塞等待。
上面的代码测试的都是阻塞等待,接下来对非阻塞等待进行测试。为了方便,先学习几个宏,来对status进行分析,而不用自己再使用移位、按位与等操作。
WIFEXITED(status):如果子进程正常终止(通过exit,_exit,或者来自main()的return)返回真。
WEXITSTATUS(status):返回子进程的退出码,这个宏应该仅在WIFEXITED返回真的情况下使用。
WIFSIGNALED(status):如果子进程被一个信号终止,返回真。
WTERMSIG(status):返回造成子进程终止的信号序号,这个宏应该仅在WIFSIGNALED返回真的情况下使用。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
int main()
{
// 测试非阻塞等待
pid_t id = fork();
if (id < 0)
{
perror("fork fail");
exit(1);
}
if (id == 0)
{
// child
int cnt = 5;
while(cnt--)
{
printf("I'm child, pid:%d, ppid:%d, cnt=%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(10);
}
if (id > 0)
{
// parent
int status = 0;
while (1)
{
int ret = waitpid(id, &status, WNOHANG);
if (ret == 0)
{
printf("子进程未退出,于是父进程去做其他事了...\n");
sleep(1);
}
else if (WIFEXITED(status))
{
// 正常退出
// 退出码应为10(上面设置的)
printf("子进程正常退出, exit code: %d\n", WEXITSTATUS(status));
exit(0);
}
else if (WIFSIGNALED(status))
{
// 异常退出
int sig_code = WTERMSIG(status);
printf("子进程异常退出, signal code: %d\n", sig_code);
printf("%s\n", strerror(sig_code));
exit(0);
}
}
}
return 0;
}
四、进程程序替换
1、exec系列函数
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
exec系列函数的作用是用另一个程序来替换当前正在执行的程序,那其中第一个execl来举例,其中第一个参数表示的是要执行程序的路径名,第二个参数表示要执行程序带的选项,其中的...表示可变参数列表,表示执行一个程序可以带多个选项,最后一个选项以NULL结束,例如,我们要执行"ls -a -l --color=auto",其中ls这个程序再user/bin/目录下。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello world!\n");
execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);
printf("hello world!\n");
return 0;
}
从运行结果可以看出,程序确实执行了ls命令,但是两条printf语句只执行了一句!
这就是进程程序替换,替换成功之后,原进程的代码和数据都会被替换,执行新的程序。
2、l,p,e,v的功能
接下来来了解一下另外几个exec系列函数。
函数名带 l,表示传入执行程序的选项时用的时可变参数列表,一个一个传参;
相对的,函数名带 v,表示传入执行程序的选项时用的是一个数组,数组中每一个元素都是选项,最后一个依然需要以NULL结束;
函数名带 p,表示第一个参数不用再传入路径,直接传入要执行程序的名称即可,系统会根据环境变量自动寻找该程序。
函数名带 e,表示需要自己传入环境变量参数,不带e默认继承父进程的环境变量。
3、配合fork()测试exec系列函数
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
extern char **environ;
int main()
{
pid_t id = fork();
assert(id >= 0);
if (id == 0)
{
printf("First:\n");
// 子进程
execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);
exit(1);
}
// 不关心子进程的退出状态
waitpid(id, NULL, 0);
id = fork();
assert(id >= 0);
if (id == 0)
{
printf("\nSecond:\n");
// 省略路径,直接写程序的名字
execlp("pwd", "pwd", NULL);
exit(1);
}
waitpid(id, NULL, 0);
id = fork();
assert(id >= 0);
if (id == 0)
{
printf("\nThird:\n");
// 尝试执行当前路径下的自己的程序, environ是系统提供的环境变量
execle("./mytest", "mytest", NULL, environ);
exit(1);
}
waitpid(id, NULL, 0);
id = fork();
assert(id >= 0);
if (id == 0)
{
printf("\nFourth:\n");
// 函数名带v,选项参数就用数组传递
char *const my_argv[] = { "ls", "-a", "--color=auto", NULL };
execv("/usr/bin/ls", my_argv);
exit(1);
}
waitpid(id, NULL, 0);
id = fork();
assert(id >= 0);
if (id == 0)
{
printf("\nFifth:\n");
char *const my_argv[] = { "cat", "mytest.c", NULL };
execvp("cat", my_argv);
perror("wrong:");
exit(1);
}
waitpid(id, NULL, 0);
id = fork();
assert(id >= 0);
if (id == 0)
{
printf("\nSixth:\n");
char *const my_env[] = { (char*)"MYENV=11223344", NULL };
char *const my_argv[] = { (char*)"mytest", NULL };
execvpe("./mytest", my_argv, my_env);
exit(1);
}
waitpid(id, NULL, 0);
return 0;
}
五、简易shell的实现
一个shell,首先需要有串提示符,显示当前用户和主机名、当前路径,因此,我们先通过getenv()来获取环境变量,输出提示信息。
第二,输入命令,我们需要读取命令,并把命令存放到command_line这个数组中。
第三,分解命令,将读入的命令分解成一个一个选项。
第四,创建子进程,让子进程调用exec系列函数来执行读入的命令。
(其中cd命令是一个内建命令需要特殊处理,我们最后再解释,先看一下代码和效果吧!)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/wait.h>
#define NUM_COMMAND 128
#define NUM_OPTION 32
char command_line[NUM_COMMAND] = { 0 };
char *option[NUM_OPTION] = { 0 };
int main()
{
while (1)
{
printf("[%s@%s 当前路径] $ ", getenv("USER"), getenv("HOSTNAME"));
fflush(stdout);
// 读取命令行输入到command_line中
fgets(command_line, NUM_COMMAND - 1, stdin);
// 读入的换行符改成'\0'
command_line[strlen(command_line) - 1] = 0;
// 将读入的命令分割开来,如"ls -a -l"->"ls" "-a" "-l" NULL
option[0] = strtok(command_line, " ");
int i = 1;
while ((option[i++] = strtok(NULL, " ")) != NULL);
// 内建(内置)命令 cd
if (option[0] != NULL && strcmp(option[0], "cd") == 0)
{
if (option[1] != NULL)
{
chdir(option[1]);
}
continue;
}
// 让子进程来执行命令
pid_t id = fork();
assert(id >= 0);
if (id == 0)
{
execvp(option[0], option);
exit(1);
}
waitpid(id, NULL, 0);
}
return 0;
}
内建命令
在谈论上述的cd内建命令之前,我们先来看一下下面的运行结果:
我们让左边的程序一直输出自己的pid,通过右边查看进程列表,可以看到该进程下有两个路径,其中exe表示的是当前进程对应的是磁盘中哪个文件。cwd表示的是当前进程的工作路径。
shell也是一个进程,使用cd命令后,改变的路径也是shell的工作路径(如果改变的是shell对应在磁盘中的位置,那cd命令岂不是可以更改文件位置?)。
假如shell让子进程来执行cd命令,子进程的工作目录当然会改变,但是父进程也就是shell的工作目录却没有改变,因此如果注释掉内建命令那一段代码,cd命令就会无法正常执行。
类似cd这样的需要父进程自己来执行的内建命令还有很多,就不在此一一列举了,毕竟这只是一个简易shell的实现,大家有兴趣可以自己来尝试完成呀。