系统调用是操作系统为用户程序提供的一种特殊的服务接口,允许用户调用操作系统内核态的函数执行相应任务。本文将使用常用的系统调用函数fork
、exec
、exit
、sleep
、wait
等实现进程控制,包括进程创建、进程调用执行、进程睡眠、进程终止等状态,并总结分析不同系统调用函数的使用方法及区别。
1. 设计思路
首先用fork
命令创建子进程,判断fork
的返回值,若返回值为0,则为子进程;若返回值大于0,则为父进程;其他返回值,则说明fork
错误。
然后在父进程、子进程的分支中进行不同的操作。在子进程中,打印一句话“I am a child process.”
表明身份,利用getpid()
获取子进程的id并输出,最后使用exit(5)
结束子进程的运行。
在父进程中,调用wait
函数等待子进程完成,打印输出子进程的id及exit
返回值。然后打印“I am a parent process.”
表明身份,并输出父进程的的id。接下来,使用execvp
调用shell编程:查看进程与杀死进程一文中的shell程序get_process.sh
。
2. 代码1及运行结果
使用C语言编写程序syscall_fork.c
得到如下代码1:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid;
//使用fork创建子进程
pid = fork();
int status, i;
char *argv[] = {"bash", "get_process.sh", NULL};
if (pid < 0) {
printf("Error in fork!\n");
exit(0);
}
else if (pid == 0) { //子进程
printf("I am a child process. My ID is %d\n", getpid());
//exit返回值为5
exit(5);
}
else {
//调用wait函数等待子进程完成
pid = wait(&status);
i = WEXITSTATUS(status);
//输出子进程ID及exit返回值
printf("child's pid = %d, exit status = %d\n", pid ,i);
printf("I am a parent process. My ID is %d\n", getpid());
//使用execvp调用实验1的shell程序get_process.sh
if (execvp("bash", argv) < 0)
perror("execvp error");
_exit(0);
}
return 0;
}
实验结果如下图所示,父进程编号为2678,子进程编号为2679,子进程exit
返回值为5。
3. 实验中遇到的问题与分析
当我们将shell进程调用的位置从父进程改到子进程时,得到如下代码2,运行后exit
返回值是0,不是5,如下图所示,这是为什么呢?
3.1 修改后得到的代码2
3.2 运行结果
输入0,退出shell程序,最后显示:
3.3 原因
代码2在子进程中调用shell程序时使用了exec
族的函数,就会将原来的程序直接改为执行新的程序(shell程序),而不会再返回到原来的程序了。因此代码2中的exit(5)
这句根本不会被执行,因此exit
的返回值是0;
代码1在父进程中执行shell程序,此时的shell程序与子进程无关,因此子进程中的exit(5)
会被执行,因此exit的返回值为5。
4. 进一步学习附录
4.1 exec族函数的定义及区别
4.1.1 定义
在Linux中,exec
族函数,一共有6个成员函数,分别是execl
、 execlp
、execle
、execv
、execvp
、execve
,下表是这六个函数的基本定义:
所需头文件 | #include <unistd.h> |
---|---|
函数原型 | 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 execve(const char *path, char *const argv[], char *const envp[]); | |
函数返回值 | 出错:-1;成功:无返回 |
这六个函数之间的关系1如下图所示:
由此可见,只有execve
是真正意义上的系统调用,其他五个函数都是调用execve
的库函数。
4.1.2 区别
这6个函数在函数名和使用语法的规则上都有细微的区别,下面根据可执行文件的查找方式、参数表传递方式及环境变量传递这三个方面进行比较。
- 查找方式:
execl
、execle
、execv
、execve
这4个函数的查找方式都是根据完整的文件目录路径和文件名查找可执行文件的。而以p
结尾的两个函数execlp
和execvp
可以只给出可执行文件名,系统就会自动按照环境变量$PATH
所指定的路径进行查找。 - 参数传递方式:
exec函数族的参数传递有两种方式:一种是逐个列举的方式(l),另一种则是将所有参数整体构造指针数组传递(v)。在这里是以函数名的第5位字母来区分的,字母为“l”(list)的表示逐个列举参数的方式,其语法为char *arg
;字母为“v”(vector)的表示将所有参数整体构造字符指针数组传递,其语法为*const argv[]
。
注意:
参数实际上就是用户在执行这个可执行文件时所需的全部命令选项字符串(包括该可执行程序名本身)。参数必须以NULL表示结束,如果使用常数0表示一个空指针,则必须要把它强制转化成一个字符指针((char *) 0),否则exec将把它解释为一个整型参数。
例如:执行cat
命令连接显示两文件内容
execl("/bin/cat","cat","/root/test1.c","/root/test2.c",NULL);
或者execlp("cat","cat","/root/test1.c","/root/test2.c",NULL);
char *argv[]={"cat","/root/test1.c","/root/test2.c",NULL};
execv("/bin/cat",argv);
或者:execvp("cat",argv);
- 环境变量:
exec函数族可以使用系统默认的环境变量,也可以传入指定的环境变量(进程想要为将运行的程序指定一个确定的环境时)。通过以“e”(environment)结尾的两个函数execle()
和execve()
就可以在envp[]
中指定当前进程所使用的环境变量。
4.1.3 小结
前4位 | 统一为exec | |
---|---|---|
第5位 | l:参数传递为逐个列举方式 | execl、execle、execlp |
v:参数传递为构造指针数组方式 | execv、execve、execvp | |
第6位 | e:可传递新进程环境变量 | execle、execve |
p:可执行文件查找方式为文件名 | execlp、execvp |
4.2 fork vs vfork、exit vs _exit、fork vs clone
4.2.1 fork与vfork的区别
fork
创建的子进程复制父进程的资源,子进程完全复制父进程的地址空间,与父进程的地址空间独立2。
vfork
创建的子进程与父进程共享数据段,子进程在父进程的空间中运行。fork
:父子进程的执行次序不确定,取决于内核的调度算法。
vfork
:保证子进程先运行,在调用exec
或exit
之前与父进程数据是共享的,在它调用exec
或exit
之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
4.2.2 exit与_exit的区别
exit()
函数定义在stdlib.h中,而_exit()
定义在unistd.h中。_exit()
函数的作用最为简单:直接使进程停止运行,清除其使用的内存空间,并销毁其在内核中的各种数据结构;exit()
函数则在这些基础上作了一些包装,在执行退出之前加了若干道工序,也是因为这个原因,有些人认为exit
已经不能算是纯粹的系统调用3。
具体来说,_exit
做3件事(man):
- Any open file descriptors belonging to the process are closed
- any children of the process are inherited by process 1, init
- the process’s parent is sent a SIGCHLD signal
exit()
在结束调用它的进程之前,要进行如下步骤:
- 调用
atexit()
注册的函数(出口函数);按ATEXIT
注册时相反的顺序调用所有由它注册的函数,这使得我们可以指定在程序终止时执行自己的清理动作。例如,保存程序状态信息于某个文件,解开对共享数据库上的锁等。 cleanup()
;关闭所有打开的流,这将导致写所有被缓冲的输出,删除用TMPFILE
函数建立的所有临时文件。- 最后调用
_exit()
函数终止进程。
exit()
函数与_exit()
函数最大的区别就在于exit()
函数在调用exit
系统调用之前要检查文件的打开情况,把文件缓冲区中的内容写回文件,就是"清理I/O缓冲"。
简单的说,exit
函数将终止调用进程。在退出程序之前,所有文件关闭,缓冲输出内容将刷新定义,并调用所有已刷新的“出口函数”(由atexit
定义)。
_exit
函数是由Posix定义的,不会运行exit handler和signal handler,在UNIX系统中不会flush标准I/O流。即_exit
终止调用进程,但不关闭文件,不清除输出缓存,也不调用出口函数。
4.2.3 fork与clone的区别
clone
和fork
的调用方式不同。fork
调用无需传入参数,而clone
调用需要传入一个函数,该函数在子进程中执行。clone
和fork
最大不同在于clone
不再复制父进程的栈空间,而是自己创建一个新的。(void *child_stack,)
也就是第二个参数,需要分配栈指针的空间大小,所以它不再是继承或者复制,而是全新的创造。
4.3 对fork返回两次的理解
当程序执行到下面的语句:
pid = fork();
此时,如果fork
成功,操作系统创建一个新的进程(子进程),并且在PCB中为它建立一个新的表项。新进程(子进程)和原有进程(父进程)的可执行代码相同;上下文和数据,绝大部分都是原进程(父进程)的拷贝,但它们的地址空间相互独立。此时PC寄存器在父、子进程的上下文中都声称,这个进程目前执行到fork
调用即将返回(此时子进程不占有CPU,子进程的PC不是真正保存在寄存器中,而是作为进程上下文保存在进程表中的对应表项内)。
接下来,父进程继续执行,操作系统对fork
的实现,使这个调用在父进程中返回刚刚创建的子进程的pid(一个正整数)。
子进程在之后的某个时候得到调度,它的上下文被换入,占据 CPU,操作系统对fork
的实现,使得子进程中fork
调用返回0(子进程没有子进程)。fork
就是这样得到了两次返回值4。