目录
[(1)、execl() 和 execlp():](#(1)、execl() 和 execlp():)
[(2)、execv() 和 execvp():](#(2)、execv() 和 execvp():)
[(3)、execle() 和 execve():](#(3)、execle() 和 execve():)
一、进程的创建:
1、fork函数:
fork函数是什么:
fork函数是操作系统中的一个系统调用函数,用于创建一个新的进程。
fork函数的特性:
在调用fork函数时,操作系统会为当前进程创建一个副本,生成一个新的进程,即子进程。子进程和父进程拥有相同的代码、数据和堆栈段,但是拥有独立的进程ID,并且子进程的PID与父进程不同,子进程的PPID为父进程的PID。
fork函数的返回值:
fork函数有一个特点:调用一次,返回两次,一次是在父进程,一次是在子进程。子进程中返回0,父进程中返回子进程的PID(确保父进程能够有效地管理和控制其子进程),如果子进程创建失败则返回-1。
fork函数的应用场景:
一般用于创建多个子进程以完成多任务的场景,例如一个程序使用fork函数创建一个子进程用于处理某些类型的任务,另一个子进程用于处理另外一种类型的任务。还可以用于创建守护进程和shell程序等。
这里简单写一个样例代码:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t Child_process = fork(); // 创建一个进程
while(1)
{
if(Child_process == 0)
{
printf("子进程: pid: %d,ppid: %d\n",getpid(),getppid());
sleep(1);
}
else if(Child_process > 0)
{
printf("父进程: pid: %d,ppid: %d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
}
2、写时拷贝:
写时拷贝的概念:
当有多个进程或线程需要读取同一块内存数据,那么就让它们共享这块内存,而当某个进程或线程需要修改这块内存时,才真正地为其复制一份新的内存数据,并让该进程或线程在新复制的内存上进行修改。
写时拷贝的体现:
刚才说到,在调用fork函数的时候会为当前进程生成一个子进程,而子进程和父进程拥有相同的代码、数据和堆栈段。而写时拷贝技术不会立即复制父进程的所有资源给子进程。子进程在开始时只是复制了父进程的页表,使得父子进程共享相同的物理内存页。当子进程想要修改这些共享的内存页时,操作系统才会为子进程复制一份新的内存页,确保修改不会影响到其他进程。
写时拷贝的优点:
写时拷贝可以提高整机内存使用率,优化内存使用、提高程序稳定性,以及保证数据的独立性和完整性。因为只有在真正需要修改数据时,才会分配新的内存空间,这样就避免了不必要的内存浪费。
二、进程的终止:
1、进程退出的三种场景:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止(进程崩溃)
2、进程的退出码:
概念:
当进程结束时返回给操作系统的一个整数值。这个值通常用来表示进程是正常结束还是因为某种错误而异常退出。
例如一个简单的代码:
cpp
#include <stdio.h>
int main()
{
printf("Hello World!\n");
return 0;
}
当我们的代码运行起来就变成了进程,当进程结束后main函数的返回值实际上就是该进程的进程退出码,main函数的返回值会被操作系统的运行时环境捕获,并用于表示程序的执行状态。
在Linux命令行中,可以用:echo $? 这条命令来查看上一个结束进程的退出码
程序正常退出的退出码为0,那异常退出的退出码则是一个非零值,因为程序执行错误的原因有很多种,例如:程序有bug,内存泄漏,兼容性问题等、我们可以通过以下代码打印对应错误码的错误信息,以便我们快速捕获程序异常所在:
strerror函数可以通过错误码获取该错误码在C语言当中对应的错误信息:
cpp
#include <stdio.h>
#include <string.h>
int main()
{
for(int i = 0; i < 100; i++) // 错误码不止有一百个,这里只是举例
{
printf("%d:%s\n", i, strerror(i));
}
return 0;
}
另外:当进程结束时,操作系统会捕获该进程的退出码,并可能将其传递给父进程或记录在系统日志中。父进程可以使用这个退出码来确定子进程的执行状态,并据此采取相应的操作。
3、进程的退出相关:
return退出:
我们可以在函数的任意地方设置return以此退出函数,在main函数中使用return就会退出进程。
exit退出:
我们可以在函数的任意地方设置exit函数以此退出进程 ,并且exit函数在退出进程前会做一系列收尾工作:
- 清理并关闭所有打开的文件描述符所占用的内存和资源
- 执行所有已经注册的退出处理程序。包括用户通过atexit函数或on_exit函数注册的自定义函数,这些函数在程序正常终止时会被调用。
例如以下代码:注意输出中并未添加\n
cpp#include <stdio.h> #include <stdlib.h> void Test() { printf("Hello World!"); exit(1); } int main() { Test(); return 0; }
当exit函数终止进程时会将缓冲区当中的数据输出。
_exit()退出:
_exit函数的功能与exit函数类似,它不会执行任何退出处理程序,而是直接终止进程。这通常在子进程中使用,以避免执行父进程可能已经设置的退出处理程序。
例如以下代码:注意输出中并未添加\n
cpp#include <stdio.h> #include <unistd.h> void Test() { printf("Hello World!"); _exit(1); } int main() { Test(); return 0; }
当_exit函数终止进程时不会将缓冲区当中的数据输出。
进程的异常退出:
1、进程收到终止信号导致的异常退出
例如:Ctrl + C、kill -9等强行终止进程会使进程异常退出。
2、代码错误导致的进程异常退出
例如:代码当中出现内存非法访问、数组越界、堆栈溢出等会使进程异常退出。
三、进程的等待:
1、概念:
进程等待是操作系统中的一种状态,用于同步父进程和子进程。当父进程需要等待子进程完成某些操作(如运算)时,父进程会进入等待状态。在等待状态下,进程会暂时释放占有的处理器资源,直到特定的事件或条件满足后,进程才会被操作系统调度回到就绪状态,等待处理器的分配。
2、进程等待的必要性:
1、避免产生僵尸进程:
通过进程等待,父进程可以读取子进程的退出状态,确保子进程的资源得到正确释放,避免内存泄漏、产生僵尸进程。
2、获取子进程的运行结果:
通过进程等待,父进程可以判断子进程是否完成其任务,并收集其执行结果或状态信息。即:判断子进程是否正常运行、是否完成了预期的任务以及是否出现了错误等问题。
3、保证时序的正确性:
在某些情况下,需要确保子进程先退出,父进程后退出。通过进程等待,可以确保这种时序的正确性,避免因为父子进程退出顺序不当而导致的问题。
3、wait函数与waitpid函数:
(1)、函数中的status参数:
在wait函数中,status参数是一个整型指针,用于接收子进程的退出状态信息。当子进程结束时,其退出状态会被存储在status所指向的内存单元中。
例如:子进程的退出状态包括正常退出时的返回值、由于某种信号而异常终止时的信号编号等、wait函数可以通过status参数将这些信息返回给父进程,使得父进程可以了解子进程的退出情况。
如果调用wait函数的父进程不关心子进程的退出状态则可以将status参数设置为NULL,但是如果父进程需要获取子进程的退出状态,那么就应该传递一个有效的整形指针作为status参数。
(2)、wait函数:
- **函数原型:**pid_t wait(int* status);
- **函数作用:**当父进程调用wait函数时,如果子进程尚未结束,父进程将被阻塞,暂停执行,直到至少有一个子进程结束。
- **返回值:**成功则返回已终止子进程的PID,用于标识结束的子进程,失败则返回-1,并设置全局变量errno以指示错误原因。
例如下列代码:创建子进程后,父进程使用wait函数等待子进程结束后读取子进程的退出信息。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
// 子进程
pid_t child = fork();
if (child == 0) {
int count = 10;
while (count--) {
printf("子进程: PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
// 父进程
int status = 0;
pid_t parent = wait(&status);
if (parent > 0) {
printf("父进程: 等待子进程退出成功!\n");
if (WIFEXITED(status)) {
//获取子进程的退出码
printf("退出码为:%d\n", WEXITSTATUS(status));
}
}
sleep(2);
return 0;
}
用以下指令循环跟踪进程状态:
cpp
while :; do ps ajx | head - 1 && ps ajx | grep myprocess | grep - v grep; echo "------"; sleep 1; done
(3)、waitpid函数:
**函数原型:**pid_t waitpid(pid_t pid, int* status, int options);
**函数作用:**waitpid函数允许父进程指定一个子进程的PID来等待其结束。这使得父进程能够更精确地控制和管理其子进程。
函数参数:
- **pid:**等待子进程的结束,(若设置为-1,则等待任意子进程结束,与wait类似)。
- **status:**输出型参数,获取子进程的退出状态,不关心可设置为NULL。
- **options:**标志位字段,用于设置waitpid函数的行为。它可以是一个或多个标志位的按位或(OR)结果。常见的选项有:
- **WNOHANG:**非阻塞模式。如果指定的子进程没有结束,waitpid将不会阻塞,而是立即返回0。
- **WUNTRACED:**如果子进程由于接收到一个停止信号(如SIGSTOP)而进入暂停状态,即使它还没有结束,waitpid也会返回。
- **WCONTINUED:**如果子进程从暂停状态被恢复执行(例如,接收到SIGCONT信号),waitpid也会返回。
函数返回值:
- 等待成功则返回被等待进程的PID。
- 如果返回0,表示指定的子进程仍在运行,尚未结束。
- 如果返回-1,表示出现错误,无法等待子进程结束。此时,可以通过errno变量获取具体的错误信息。
例如下列代码:
创建子进程后,父进程使用waitpid函数一直等待子进程退出后读取子进程的退出信息。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
// 子进程
pid_t child = fork();
if (child == 0) {
int count = 10;
while (count--) {
printf("子进程: PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
// 父进程
int status = 0;
pid_t parent = waitpid(child, &status, 0);
if (parent >= 0) {
printf("等待子进程退出成功!\n");
if (WIFEXITED(status)) {
printf("退出码为:%d\n", WEXITSTATUS(status));
}
else {
printf("进程收到信号: %d!!!\n", status & 0x7F);
}
}
sleep(2);
return 0;
}
4、非阻塞等待:
阻塞等待与非阻塞等待:
在上面的例子中,当子进程未退出时,父进程是要一直等待子进程退出的,期间父进程无法执行其他操作,这种等待就叫阻塞等待。
但是实际上我们是可以让父进程在子进程未退出的时候去进行其他的操作,当子进程退出时再去读取子进程的退出信息,这种等待就是非阻塞等待。
方式:
在上面讲解waitpid函数 第三个参数(options)的时候有一个WNOHANG的选项。
作用是:非阻塞模式。如果指定的子进程没有结束,waitpid将不会阻塞,而是立即返回0。这样就不会只单单的等待子进程结束,父进程可以进行其他操作,当等待的子进程正常结束时才会返回该子进程的PID。
代码形式:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
// 子进程
pid_t child = fork();
if (child == 0) {
int count = 3;
while (count--) {
printf("子进程:%d, PPID:%d\n", getpid(), getppid());
sleep(3);
}
exit(0);
}
// 父进程
while (1) {
int status = 0;
pid_t parent = waitpid(child, &status, WNOHANG);
// 当WNOHANG返回大于0的值(就是子进程的PID),代表子进程退出成功
if (parent > 0) {
printf("子进程正常退出成功!!!\n");
printf("退出码为: %d\n", WEXITSTATUS(status));
break;
}
// 当WNOHANG返回0时,代表子进程还未结束
else if (parent == 0) {
printf("子进程未结束,父进程进行其他操作中...\n");
sleep(2);
}
else {
printf("子进程退出错误!!!\n");
break;
}
}
return 0;
}
四、进程程序替换:
1、替换原理:
前面在介绍fork函数时提到过:子进程和父进程拥有相同的代码、数据和堆栈段,执行的也是相同的程序,想要子进程执行别的程序就需用exec系列函数来实现进程替换。
exec函数族的作用是:新的程序完全替换原进程的内容(代码和数据),并且从新的程序的main函数开始执行。原进程的PID保持不变,但程序的代码、数据、堆和栈等内容都被新的程序替换。
如果exec函数失败,那么它会返回-1,并且原进程继续执行(即调用exec的代码之后的代码)。
2、exec系列函数:
(1)、execl() 和 execlp():
函数原型: execl(const char *path, const char *arg, ...)
execl函数通过指定完整的路径来找到并执行一个可执行文件。它接受一个路径参数和可变数量的参数,这些参数将作为新程序的命令行参数。
参数设置:
- 第一个参数为要执行程序的路径
- 后续的参数为可变参数列表,这些参数代表传递给可执行文件的命令行参数列表,最后要以NULL结尾。
例如:子进程要执行一个ls命令,并带上 -al 参数:
cppexecl("/usr/bin/ls", "ls", "-l", "-a", NULL); //注意a和l是各自分开的参数
函数原型:execlp(const char *file, const char *arg, ...)
execlp与execl类似,但它在系统的PATH环境变量中搜索可执行文件。这意味着你可以只提供可执行文件的名称,而不需要完整的路径。
参数设置:
- 第一个参数为字符指针,指向要执行的可执行文件的名称
- 后续的参数为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
例如:子进程要执行一个ls命令,并带上 -al 参数:
cppexeclp("ls", "ls", "-l", "-a", NULL);
(2)、execv() 和 execvp():
函数原型:execv(const char *path, char *const argv[])
execv函数允许你通过传递一个参数数组来执行一个程序。数组的第一个元素是程序的路径,接下来的元素是命令行参数,最后以NULL结束。
参数设置:
- 第一个参数为要执行程序的路径
- 第二个参数为一个指向字符指针数组的指针,数组中的每个元素都是一个指向字符串的指针,这些字符串就是命令行参数。最后要以NULL结尾。
例如:子进程要执行一个ls命令,并带上 -al 参数:
cppchar* myargv[] = { "ls", "-l", "-a" NULL }; execv("/usr/bin/ls", myargv);
函数原型:execvp(const char *file, char *const argv[])
execvp与execv类似,但它同样在PATH环境变量中搜索可执行文件。
参数设置:
- 第一个参数为要执行程序的名称
- 第二个参数是一个字符指针数组,为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
例如:子进程要执行一个ls命令,并带上 -al 参数:
cppchar* myargv[] = { "ls", "-l", "-a" NULL }; execvp("ls", myargv);
(3)、execle() 和 execve():
函数原型:execle(const char *path, const char *arg, ..., char *const envp[])
execle允许你指定环境变量,这对于改变子进程的环境非常有用。它接受一个环境变量数组作为最后一个参数。
参数设置:
- 第一个参数是要执行程序的路径
- 第二个参数是一个字符指针数组,为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
- 最后一个参数是你自己设置的环境变量
例如:子进程要执行一个ls命令,并带上 -al 参数:
cpp// 定义要执行的程序路径 const char *path = "/usr/bin/ls"; // 定义命令行参数 char *argv[] = { "ls", "-l", "-a" }; // 定义环境变量数组 char *envp[] = { "MY_VAR=my_value" }; execle(path, argv[0], argv[1], argv[2], NULL, envp)
函数原型:execve(const char *file, char *const argv[], char *const envp[])
execve结合了execv和execle的功能,既允许你通过参数数组传递命令行参数,又允许你指定环境变量。
参数设置:
- 第一个参数是要执行程序的路径
- 第二个参数是一个字符指针数组,为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
- 最后一个参数为你自己设置的环境变量。
例如: 子进程要执行一个ls命令,并带上 -al 参数:程序完整演示:
cpp
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main() {
// 定义要执行的程序路径
const char *path = "/usr/bin/echo ";
// 定义命令行参数列表
char *argv[] = { "echo", "Hello, world!", NULL };
// 定义环境变量列表
char *envp[] = { "MY_ENV_VAR=my_value", NULL };
// 使用 execve 执行 /usr/bin/echo 程序,并传递参数和环境变量
if (execve(path, argv, envp) == -1) {
// 如果 execve 调用失败,打印错误信息并退出
perror("execve failed");
exit(EXIT_FAILURE);
}
// execve 成功时不会返回,因此下面的代码不会被执行
printf("This line will not be printed if execve is successful.\n");
return 0;
}
补充:
exec系列函数如果调用成功,则加载指定的程序并从启动代码开始执行,不再返回。如果调用出错则返回-1并继续往原本的代码向下执行,只要exec系列函数返回了,那就意味着调用失败。
(4)、函数命名理解:
exec系列函数的函数名都以exec开头,其后缀的含义如下:
- **l(list):**表示参数采用列表的形式,一一列出。
- **v(vector):**表示参数采用数组的形式。
- **p(path):**表示能自动搜索环境变量PATH,进行程序查找。
- **e(env):**表示可以传入自己设置的环境变量。
注意:
exec系列函数中只有execve函数才是真正的系统调用,它直接由内核处理。而其他五个函数则是基于execve的库函数,它们在内部调用execve来完成实际的工作。
下面为exec系列函数之间的关系
五、制作一个简易的shell:
shell就是一个命令解释器,它互动式地解释和执行用户输入的命令;当有命令要执行时,shell创建子进程让子进程去执行命令,而shell只需要等待子进程执行完退出即可。
具体步骤:
- 获取终端输入的命令
- 解析命令
- 创建子进程
- 对子进程进行程序替换
- 等待子进程执行完后退出
cpp
#include <stdio.h>
#include <pwd.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#define LEN 1024 // 设置命令最大长度
#define NUM 32 // 命令拆分后的最大个数
int main()
{
char cmd[LEN]; // 存储命令
char* myargv[NUM]; // 存储命令拆分后的结果
char hostname[32]; // 主机名
char pwd[128]; // 当前目录
while (1) {
//获取命令提示信息
struct passwd* pass = getpwuid(getuid());
gethostname(hostname, sizeof(hostname) - 1);
getcwd(pwd, sizeof(pwd) - 1);
int len = strlen(pwd);
char* p = pwd + len - 1;
while (*p != '/') {
p--;
}
p++;
// 打印命令提示信息
printf("[%s@%s %s]$ ", pass->pw_name, hostname, p);
// 读取命令
fgets(cmd, LEN, stdin);
cmd[strlen(cmd) - 1] = '\0';
// 拆分命令
myargv[0] = strtok(cmd, " ");
int i = 1;
while (myargv[i] = strtok(NULL, " ")) {
i++;
}
pid_t child = fork(); // 创建子进程执行命令
if (child == 0) {
//child
execvp(myargv[0], myargv); // 子进程进行程序替换
exit(1); // 替换失败的退出码设置为1
}
// 父进程 / myshell
int status = 0;
pid_t myshell = waitpid(child, &status, 0); // shell等待子进程退出
if (myshell > 0) {
printf("exit code:%d\n", WEXITSTATUS(status)); // 打印子进程的退出码
}
}
return 0;
}
补充:
- 当自己的命令解释器(myshell)运行起来后,每次子进程执行完任务退出后都会打印退出码,可以以此来分辨自己写的和操作系统的命令解释器。
- 我们自己手写的shell总体上是有一些缺陷的,在读取终端输入的时候会直接读取,方向键和删除键也是会被读进去的(因为方向键在终端中通常被表示为一系列的字节序列),如果想让其发挥功能就得要做对其进行特殊处理。