Linux系统编程(五)--进程控制

在这里插入图片描述

1. 进程创建

1.1 fork函数

在linux中fork函数是⾮常重要的函数,它从已存在进程中创建⼀个新进程。新进程为⼦进程,⽽原进程为⽗进程。

1 #include <unistd.h>
2 pid_t fork(void);
3 返回值:⾃进程中返回0,⽗进程返回⼦进程id,出错返回-1

进程调用fork,当控制转移到内核中的fork代码后,内核做:

分配新的内存块和内核数据结构给子进程。
将父进程部分数据结构内容拷贝至子进程。
添加子进程到系统进程列表当中。
fork返回,开始调度器调度。

fork之后,父子进程代码共享。例如以下代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main(void)
{
    pid_t pid;

    printf("Before: pid is %d\n", getpid());

    if ((pid = fork()) == -1) {
        perror("fork()");
        exit(1);
    }

    printf("After: pid is %d, fork return %d\n", getpid(), pid);

    sleep(1);

    return 0;
}

运行结果:

 root@localhost linux]# ./a.out
 Before: pid is 43676
 After:pid is 43676, fork return 43677
 After:pid is 43677, fork return 0

这⾥看到了三⾏输出,⼀⾏before,两⾏after。进程43676先打印before消息,然后它有打印after。
另⼀个after消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所⽰。
在这里插入图片描述
所以,fork之前⽗进程独⽴执⾏,fork之后,⽗⼦两个执⾏流分别执⾏。注意,fork之后,谁先执⾏完全由调度器决定。

1.2 fork函数返回值

  • ⼦进程返回0。
  • ⽗进程返回的是⼦进程的pid。

fork函数为什么要给子进程返回0,给父进程返回子进程的PID?

一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的;而对于父进程来说,子进程是需要被标识的,因为父进程创建子进程的目的是让其执行任务的,父进程只有知道了子进程的PID才能很好的对该子进程指派任务。

为什么fork函数有两个返回值?

父进程调用fork函数后,为了创建子进程,fork函数内部将会进行一系列操作,包括创建子进程的进程控制块、创建子进程的进程地址空间、创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表当中,此时子进程便创建完毕了。
也就是说,在fork函数内部执行return语句之前,子进程就已经创建完毕了,那么之后的return语句不仅父进程需要执行,子进程也同样需要执行,这就是fork函数有两个返回值的原因。

1.3 写时拷贝

当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
在这里插入图片描述

1.4 fork常规用法

一个进程希望复制自己,使子进程同时执行不同的代码段。例如父进程等待客户端请求,生成子进程来处理请求。
一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

1.5 fork调用失败的原因

fork函数创建子进程也可能会失败,有以下两种情况:

  1. 系统中有太多的进程,内存空间不足,子进程创建失败。
  2. 实际用户的进程数超过了限制,子进程创建失败。

2. 进程终止

2.1 进程退出场景

进程退出只有三种情况:

代码运行完毕,结果正确。
代码运行完毕,结果不正确。
代码异常终止(进程崩溃)。

2.2 进程退出码

我们都知道main函数是代码的入口,但实际上main函数只是用户级别代码的入口,main函数也是被其他函数调用的,例如在VS2013当中main函数就是被一个名为__tmainCRTStartup的函数所调用,而__tmainCRTStartup函数又是通过加载器被操作系统所调用的,也就是说main函数是间接性被操作系统所调用的。

既然main函数是间接性被操作系统所调用的,那么当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,我们一般以0表示代码成功执行完毕,以非0表示代码执行过程中出现错误,这就是为什么我们都在main函数的最后返回0的原因。

当我们的代码运行起来就变成了进程,当进程结束后main函数的返回值实际上就是该进程的进程退出码,我们可以使用echo $?命令查看最近一次进程退出的退出码信息。

例如,对于下面这个简单的代码:
在这里插入图片描述
代码运行结束后,我们可以查看该进程的进程退出码。
在这里插入图片描述
这时便可以确定main函数是顺利执行完毕了。

为什么以0表示代码执行成功,以非0表示代码执行错误?

因为代码执行成功只有一种情况,成功了就是成功了,而代码执行错误却有多种原因,例如内存空间不足、非法访问以及栈溢出等等,我们就可以用这些非0的数字分别表示代码执行错误的原因。

C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息:
在这里插入图片描述
运行代码我们就可以看到各个错误码对应的信息:
在这里插入图片描述
注意: 退出码都有对应的字符串含义,帮助用户确认执行失败的原因,而这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。

2.3 进程正常退出

return退出

在这里插入图片描述
在这里插入图片描述

exit函数

使用exit函数退出进程也是我们常用的方法,exit函数可以在代码中的任何地方退出进程,并且exit函数在退出进程前会做一系列工作:

执行用户通过atexit或on_exit定义的清理函数。
关闭所有打开的流,所有的缓存数据均被写入。
调用_exit函数终止进程。

在这里插入图片描述
运行结果:
在这里插入图片描述

_exit函数

使用_exit函数退出进程的方法我们并不经常使用,_exit函数也可以在代码中的任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前会做任何收尾工作。

例如,以下代码中使用_exit终止进程,则缓冲区当中的数据将不会被输出。

在这里插入图片描述
运行结果:
在这里插入图片描述

return、exit和_exit之间的区别与联系

区别:

只有在main函数当中的return才能起到退出进程的作用,子函数当中return不能退出进程,而exit函数和_exit函数在代码中的任何地方使用都可以起到退出进程的作用。
在这里插入图片描述

exit 是标准库函数,会执行清理操作,适用于大多数正常终止进程的情况。
_exit 是系统调用,直接终止进程,不执行清理操作,适用于需要快速退出的情况。

注意:而我们前面使用_exit的时候缓冲区并没有刷新,所以我们可以判断这个缓冲区一定不是操作系统内部的缓冲区,其实它是一个库缓冲区,C语言提供的缓冲区。

联系;

执行return num等同于执行exit(num),因为调用main函数运行结束后,会将main函数的返回值当做exit的参数来调用exit函数。
在这里插入图片描述
使用exit函数退出进程前,exit函数会先执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再调用_exit函数终止进程。

2.4 进程异常退出

  • 情况一:向进程发生信号导致进程异常退出。例如,在进程运行过程中向进程发生kill -9信号使得进程异常退出,或是使用Ctrl+C使得进程异常退出等。
  • 情况二:代码错误导致进程运行时异常退出。例如,代码当中存在野指针问题使得进程运行时异常退出,或是出现除0的情况使得进程运行时异常退出等。

3. 进程等待

3.1 进程等待的必要性

  • 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。
  • 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程。
  • 对于一个进程来说,最关心自己的就是其父进程,因为父进程需要知道自己派给子进程的任务完成的如何。
  • 父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

3.2 获取子进程status

下面进程等待所使用的两个函数wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统进行填充。
如果对status参数传入NULL,表示不关心子进程的退出状态信息。否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。

status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只研究status低16比特位):
在这里插入图片描述
在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位(0~6)表示终止信号,而第7位比特位是core dump标志。
在这里插入图片描述
我们通过一系列位操作,就可以根据status得到进程的退出码和退出信号。

exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F;      //退出信号

在这里插入图片描述
对于此,系统当中提供了两个宏来获取退出码和退出信号。

  • WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。
  • WEXITSTATUS(status):用于获取进程的退出码。
exitNormal = WIFEXITED(status);  //是否正常退出
exitCode = WEXITSTATUS(status);  //获取退出码

需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。

3.3 进程等待的方法

回顾一下僵尸进程的产生原因

当一个进程(子进程)执行完毕后,它会向父进程发送一个 SIGCHLD 信号,通知父进程它已经结束。如果父进程没有及时处理这个信号或者没有调用 wait() 或 waitpid() 函数来回收子进程的状态信息,子进程就会变成僵尸进程。

wait方法

在 Linux 系统中,当子进程执行完毕后,父进程需要对其进行收尾工作,否则子进程就会变成僵尸进程,占用系统资源。wait() 函数就是用来解决这一问题的。

wait() 是一个系统调用,它的作用是让父进程阻塞,等待任意一个子进程退出。当子进程退出后,wait() 函数会回收子进程的资源,包括终止状态信息,从而彻底清除掉这个子进程。具体来说,wait() 函数有以下几个功能:

  1. 阻塞等待子进程退出:父进程会暂停执行,直到子进程结束。
  2. 回收子进程残留资源:释放子进程占用的进程控制块(PCB)等资源。
  3. 获取子进程结束状态:通过参数 status 保存子进程的退出状态,可以进一步判断子进程终止的具体原因。
  • 参数说明
    int* status:输出型参数,用于存储子进程的退出状态。如果父进程不关心子进程的退出状态,可以将其设置为 NULL。

  • 返回值说明
    成功时返回被等待的子进程的进程 ID(pid)。
    失败时(例如没有子进程可等待)返回 -1

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);

例如,创建子进程后,父进程可使用wait函数一直等待子进程,直到子进程退出后读取子进程的退出信息。

  1 #include <stdio.h>
  2 #include <stdlib.h>
  3 #include <unistd.h>
  4 #include <sys/wait.h>
  5 #include <sys/types.h>
  6 int main()
  7 {
  8   pid_t id = fork();
  9   if(id == 0)
 10   {
 11     //child
 12     int count = 5;
 13  	while(count--)
 14     {
 15       printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
 16       sleep(1);
 17     }
 18     exit(0);
 19   }
 20   sleep(6);//比子进程多1s,此时子进程会进入僵尸状态
 21   //father
 22   int status = 0;                                                                                                           
 23   pid_t ret = wait(&status);
 24   if(ret > 0)
 25   {
 26     //wait success
 27     printf("wait child success...\n");
 28     if(WIFEXITED(status))
 29     {
 30       //exit normal
 31       printf("exit code:%d\n", WEXITSTATUS(status));
 32     }
 33   }
 34   sleep(3);
 35   return 0;
 36 }

运行效果:
在这里插入图片描述
在这里插入图片描述
注意事项

  • 如果父进程在调用 wait() 之前子进程已经退出,wait() 会立即返回,不会阻塞。
  • 如果父进程在调用 wait() 时没有子进程可等待,wait() 会阻塞,直到有子进程退出。
  • 如果父进程不关心子进程的退出状态,可以将 status 参数设置为 NULL。
  • 如果父进程需要处理多个子进程,可以使用 waitpid() 函数,它允许指定等待的子进程 ID。

waitpid方法

waitpid() 是 Linux 系统调用中的一个函数,用于等待一个特定的子进程改变状态,或者获取某个子进程的退出状态信息。它是 wait() 函数的一个扩展版本,提供了更多的控制选项。

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);
  • 参数说明:
    pid_t pid:指定要等待的子进程的进程 ID(PID)。
    如果 pid > 0,等待进程 ID 为 pid 的子进程。
    如果 pid == 0,等待同一进程组中的任意子进程。
    如果 pid < 0,等待进程组 ID 为 pid 的绝对值的任意子进程。
    如果 pid == -1,行为与 wait() 相同,等待任意子进程。
    int *status:输出型参数,用于存储子进程的退出状态。如果不需要状态信息,可以设置为 NULL。

int options:选项标志,用于控制 waitpid() 的行为。常见的选项包括:
WNOHANG:如果没有任何子进程退出,则立即返回,不阻塞
WUNTRACED:如果子进程被信号暂停(stopped),也返回其状态。

  • 返回值说明
    成功时返回被等待的子进程的进程 ID(pid)。
    如果 WNOHANG 被设置且没有子进程可等待,返回 0。
    失败时(例如无效的 pid 或其他错误)返回 -1。

例如,创建子进程后,父进程可使用waitpid函数一直等待子进程(此时将waitpid的第三个参数设置为0),直到子进程退出后读取子进程的退出信息。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
	pid_t id = fork();
	if (id == 0){
		//child          
		int count = 10;
		while (count--){
			printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
			sleep(1);
		}
		exit(0);
	}
	//father           
	int status = 0;
	pid_t ret = waitpid(id, &status, 0);
	if (ret >= 0)
	{
		//wait success                    
		printf("wait child success...\n");
		if (WIFEXITED(status)){
			//exit normal                                 
			printf("exit code:%d\n", WEXITSTATUS(status));
		}
		else{
			//signal killed                              
			printf("killed by siganl %d\n", status & 0x7F);
		}
	}
	sleep(3);
	return 0;
}

在父进程运行过程中,我们可以尝试使用kill -9命令将子进程杀死,这时父进程也能等待子进程成功。
在这里插入图片描述
子进程被信号编号为 9的信号(SIGKILL)终止的,通过前面讲的位掩码操作 status & 0x7F就可以获得9。
注意: 被信号杀死而退出的进程,其退出码将没有意义。

3.4 阻塞等待

当子进程未退出时,父进程都在一直等待子进程退出,在等待期间,父进程不能做任何事情,这种等待叫做阻塞等待。

上面演示的都是父进程创建以及等待一个子进程的例子,实际上我们还可以同时创建多个子进程,然后让父进程依次等待子进程退出,这叫做多进程创建以及等待的代码模型。
例如,以下代码中同时创建了10个子进程,同时将子进程的pid放入到ids数组当中,并将这10个子进程退出时的退出码设置为该子进程pid在数组ids中的下标,之后父进程再使用waitpid函数指定等待这10个子进程。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	pid_t ids[10];
	for (int i = 0; i < 10; i++){
		pid_t id = fork();
		if (id == 0){
			//child
			printf("child process created successfully...PID:%d\n", getpid());
			sleep(3);
			exit(i); //将子进程的退出码设置为该子进程PID在数组ids中的下标
		}
		//father
		ids[i] = id;
	}
	for (int i = 0; i < 10; i++){
		int status = 0;
		pid_t ret = waitpid(ids[i], &status, 0);
		if (ret >= 0){
			//wait child success
			printf("wiat child success..PID:%d\n", ids[i]);
			if (WIFEXITED(status)){
				//exit normal
				printf("exit code:%d\n", WEXITSTATUS(status));
			}
			else{
				//signal killed
				printf("killed by signal %d\n", status & 0x7F);
			}
		}
	}
	return 0;
}

运行代码,这时我们便可以看到父进程同时创建多个子进程,当子进程退出后,父进程再依次读取这些子进程的退出信息。
在这里插入图片描述

3.5 非阻塞等待

实际上我们可以让父进程不要一直等待子进程退出,而是当子进程未退出时父进程可以做一些自己的事情,当子进程退出时再读取子进程的退出信息,即非阻塞等待。

做法很简单,向waitpid函数的第三个参数options传入WNOHANG,作用如下:

int options:选项标志,用于控制 waitpid() 的行为。常见的选项包括:
WNOHANG:如果没有任何子进程退出,则立即返回,不阻塞
WUNTRACED:如果子进程被信号暂停(stopped),也返回其状态。

例如,父进程可以隔一段时间调用一次waitpid函数,若是等待的子进程尚未退出,则父进程可以先去做一些其他事,过一段时间再调用waitpid函数读取子进程的退出信息。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	pid_t id = fork();
	if (id == 0){
		//child
		int count = 3;
		while (count--){
			printf("child do something...PID:%d, PPID:%d\n", getpid(), getppid());
			sleep(3);
		}
		exit(0);
	}
	//father
	while (1){
		int status = 0;
		pid_t ret = waitpid(id, &status, WNOHANG);
		if (ret > 0){
			printf("wait child success...\n");
			printf("exit code:%d\n", WEXITSTATUS(status));
			break;
		}
		else if (ret == 0){
			printf("father do other things...\n");
			sleep(1);
		}
		else{
			printf("waitpid error...\n");
			break;
		}
	}
	return 0;
}

运行结果就是,父进程每隔一段时间就去查看子进程是否退出,若未退出,则父进程先去忙自己的事情,过一段时间再来查看,直到子进程退出后读取子进程的退出信息。
在这里插入图片描述

是不是有点类似并发,不用等待子进程完成,父进程可以先去执行自己的任务,实际上这是时间片的分配机制。
阻塞等待:父进程调用 wait(),进入阻塞状态,操作系统不会分配时间片给父进程,直到子进程完成。
非阻塞等待:父进程调用 waitpid() 并设置WNOHANG,可以继续执行其他任务,操作系统会根据调度算法分配时间片给父进程和子进程。

4. 进程替换

4.1 替换原理

用fork创建子进程后,子进程执行的是和父进程相同的程序(但有可能执行不同的代码分支),若想让子进程执行另一个程序,往往需要调用一种exec函数。

当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,并从新程序的启动例程开始执行。
在这里插入图片描述
当一个进程通过 exec 系列函数进行程序替换时,以下几点是关键:

  • PCB(进程控制块):PCB 是操作系统用于管理进程的数据结构,其中包含了进程的所有状态信息,如进程 ID(PID)、寄存器状态、程序计数器等。在进程替换时,PCB 的大部分内容保持不变,因为这些信息与程序的具体代码和数据无关。
    进程地址空间:进程的虚拟地址空间在 exec 调用后不会改变。新的程序会替换掉旧程序的代码和数据段,但进程的虚拟内存布局(如堆、栈等区域)仍然属于同一个进程。
  • 页表:页表是内存管理单元(MMU)用于将虚拟地址映射到物理地址的数据结构。在进程替换时,页表的某些条目可能会被修改,以反映新的代码和数据在物理内存中的位置,但页表本身仍然属于同一个进程。
  • 物理内存中的数据和代码:exec 调用会导致操作系统加载新的程序代码和数据到物理内存中,替换掉原来的内容。这是唯一发生实质性改变的部分。
  • PID 不变:由于没有创建新的进程,只是替换了当前进程的代码和数据,因此进程的 PID 不会改变。

当子进程刚被创建时,它与父进程共享代码和数据。然而,当子进程需要进行写入操作时,操作系统会触发写时拷贝(Copy-On-Write,COW)机制。写时拷贝会在子进程试图修改共享的内存页面时,将这些页面复制到新的内存区域,从而确保父子进程的内存空间分离。这意味着:

  • 子进程进行写操作时:子进程的内存页面会被复制,父进程的内存页面保持不变。
  • 子进程进行程序替换时:子进程的代码和数据会被新的程序替换,但父进程的代码和数据仍然保持不变。
  • 父子进程的独立性:子进程的任何修改都不会影响父进程,因为它们的内存空间已经分离。

4.2 替换函数

替换函数有六种以exec开头的函数,它们统称为exec函数:

execl函数

int execl(const char *path, const char *arg, ...);

第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。
列如:你要执行ls命令

execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);

execlp函数

int execlp(const char *file, const char *arg, ...);

第一个参数是要执行程序的名字,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。

列如:你要执行ls命令

execlp("ls", "ls", "-a", "-i", "-l", NULL);

execle函数

int execle(const char *path, const char *arg, ..., char *const envp[]);

参数说明:
path:第一个参数是可执行文件的路径。
arg:第二个参数通常是可执行文件的名称,这会作为argv[0]传递给新程序。
可变参数列表:后续的参数是传递给新程序的命令行参数,每个参数都是一个const char *类型的字符串。
envp:最后一个参数是一个指向环境变量字符串数组的指针,数组的每个元素是一个"key=value"格式的字符串,数组以NULL结尾。

使用示例:

#include <stdio.h>
#include <unistd.h>

int main() {
    char *envp[] = {
        (char*const)"HOME=/user/home",
        (char*const)"PATH=/bin:/usr/bin",
        NULL
    };
    execle("/bin/ls", "ls", "-l", "--color=auto", NULL, envp);
    // 如果execle失败,会返回到这里
    perror("execle failed");
    return 1;
}

运行效果:
在这里插入图片描述
在被调用的程序中:
被调用的程序可以通过main函数的argc、argv和envp参数来访问这些命令行参数和环境变量:

int main(int argc, char *argv[], char *envp[]) {
    // argv[0] 是 "ls"
    // argv[1] 是 "-l"
    // argv[2] 是 "--color=auto"
    // argv[3] 是 NULL

    // envp是一个指向环境变量的指针数组
    // envp[0] 是 "HOME=/user/home"
    // envp[1] 是 "PATH=/bin:/usr/bin"
    // envp[2] 是 NULL
    // ...
}

execv函数

int execv(const char *path, char *const argv[]);

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。
示例:

char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", myargv);

execvp函数

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。

例如,要执行的是ls程序。

int execvp(const char *file, char *const argv[]);

使用示例:

char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execvp("ls", myargv);

execvpe函数

第一个参数是要执行文件的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量。

int execvpe(const char *file, char *const argv[], char *const envp[]);

execve函数

int execve(const char *path, char *const argv[], char *const envp[]);

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量。

例如,你设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。

char* myargv[] = { "mycmd", NULL };
char* myenvp[] = { "MYVAL=2021", NULL };
execve("./mycmd", myargv, myenvp);

函数解释

  • 这些函数如果调用成功,则加载指定的程序并从启动代码开始执行,不再返回。
    如果调用出错,则返回-1。
  • 也就是说,exec系列函数只要返回了,就意味着调用失败。

命名理解
这六个exec系列函数的函数名都以exec开头,其后缀的含义如下:

l(list):表示参数采用列表的形式,一一列出。
v(vector):表示参数采用数组的形式。
p(path):表示能自动搜索环境变量PATH,进行程序查找。
e(env):表示可以传入自己设置的环境变量。

其他exec函数与execve的关系

  • 其他exec函数(如execl、execv、execle等)实际上是execve的封装,它们在内部调用execve来执行程序。这些封装函数的主要目的是为了提供更方便的接口,以满足不同的调用场景。例如:
  • execl和execle使用可变参数列表来传递命令行参数。
  • execv和execle使用数组来传递命令行参数。
  • execlp和execvp会在环境变量PATH中搜索可执行文件。

通过这些封装函数,开发者可以根据具体需求选择最合适的接口来执行外部程序。

5. ⾃主Shell命令⾏解释器

shell也就是命令行解释器,其运行原理就是:当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可

在这里插入图片描述

其实shell需要执行的逻辑非常简单,其只需循环执行以下步骤:

  1. 获取命令行。
  2. 解析命令行。
  3. 创建子进程。
  4. 替换子进程。
  5. 等待子进程退出

下面是一个简易版的Shell,只实现了部分功能,后面学习了其他的内容,还可以继续往里面加。

#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unordered_map>

#define COMMAND_SIZE 1024  // 定义命令行输入的最大长度
#define FORMAT "[%s@%s %s]# "  // 定义命令行提示符格式

// 下面是shell定义的全局数据

// 1. 命令行参数表
#define MAXARGC 128  // 最大参数个数
char *g_argv[MAXARGC];  // 存储命令及其参数的数组
int g_argc = 0;  // 参数个数

// 2. 环境变量表
#define MAX_ENVS 100  // 最大环境变量个数
char *g_env[MAX_ENVS];  // 存储环境变量的数组
int g_envs = 0;  // 环境变量个数

// 3. 别名映射表
std::unordered_map<std::string, std::string> alias_list;  // 用于管理命令别名

// for test
char cwd[1024];  // 当前工作目录
char cwdenv[1024];  // 当前工作目录的环境变量形式

// last exit code
int lastcode = 0;  // 上次命令的退出码

// 获取用户名
const char *GetUserName()
{
    const char *name = getenv("USER");  // 从环境变量中获取用户名
    return name == NULL ? "None" : name;  // 如果获取失败,返回"None"
}

// 获取主机名
const char *GetHostName()
{
    const char *hostname = getenv("HOSTNAME");  // 从环境变量中获取主机名
    return hostname == NULL ? "None" : hostname;  // 如果获取失败,返回"None"
}

// 获取当前工作目录
const char *GetPwd()
{
    const char *pwd = getcwd(cwd, sizeof(cwd));  // 使用getcwd获取当前工作目录
    if(pwd != NULL)
    {
        snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);  // 将当前目录更新到环境变量中
        putenv(cwdenv);
    }
    return pwd == NULL ? "None" : pwd;  // 如果获取失败,返回"None"
}

// 获取用户主目录
const char *GetHome()
{
    const char *home = getenv("HOME");  // 从环境变量中获取用户主目录
    return home == NULL ? "" : home;  // 如果获取失败,返回空字符串
}

// 初始化环境变量
void InitEnv()
{
    extern char **environ;  // 外部环境变量指针
    memset(g_env, 0, sizeof(g_env));  // 初始化环境变量数组
    g_envs = 0;

    // 从系统环境变量中复制
    for(int i = 0; environ[i]; i++)
    {
        g_env[i] = (char*)malloc(strlen(environ[i])+1);  // 为每个环境变量分配内存
        strcpy(g_env[i], environ[i]);  // 复制环境变量
        g_envs++;
    }
    g_env[g_envs++] = (char*)"HAHA=for_test";  // 添加一个测试用环境变量
    g_env[g_envs] = NULL;

    // 将自定义环境变量导出到系统环境
    for(int i = 0; g_env[i]; i++)
    {
        putenv(g_env[i]);
    }
    environ = g_env;
}

// 实现cd命令
bool Cd()
{
    if(g_argc == 1)  // 如果没有参数,默认切换到用户主目录
    {
        std::string home = GetHome();
        if(home.empty()) return true;
        chdir(home.c_str());
    }
    else
    {
        std::string where = g_argv[1];  // 获取目标目录
        if(where == "-")  // 如果参数为"-", TODO: 处理cd -
        {
            // Todu
        }
        else if(where == "~")  // 如果参数为"~", TODO: 处理cd ~
        {
            // Todu
        }
        else
        {
            chdir(where.c_str());  // 切换到指定目录
        }
    }
    return true;
}

// 实现echo命令
void Echo()
{
    if(g_argc == 2)  // 如果只有一个参数
    {
        std::string opt = g_argv[1];
        if(opt == "$?")  // 如果参数为"$?", 输出上次命令的退出码
        {
            std::cout << lastcode << std::endl;
            lastcode = 0;
        }
        else if(opt[0] == '$')  // 如果参数以"$"开头,尝试输出对应的环境变量值
        {
            std::string env_name = opt.substr(1);
            const char *env_value = getenv(env_name.c_str());
            if(env_value)
                std::cout << env_value << std::endl;
        }
        else  // 否则直接输出参数内容
        {
            std::cout << opt << std::endl;
        }
    }
}

// 从完整路径中提取当前目录名
std::string DirName(const char *pwd)
{
#define SLASH "/"
    std::string dir = pwd;
    if(dir == SLASH) return SLASH;
    auto pos = dir.rfind(SLASH);  // 找到最后一个"/"的位置
    if(pos == std::string::npos) return "BUG?";
    return dir.substr(pos+1);  // 提取最后一个"/"之后的部分
}

// 生成命令行提示符
void MakeCommandLine(char cmd_prompt[], int size)
{
    snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}

// 输出命令行提示符
void PrintCommandPrompt()
{
    char prompt[COMMAND_SIZE];
    MakeCommandLine(prompt, sizeof(prompt));
    printf("%s", prompt);
    fflush(stdout);  // 刷新输出缓冲区
}

// 获取用户输入的命令
bool GetCommandLine(char *out, int size)
{
    char *c = fgets(out, size, stdin);  // 从标准输入读取一行
    if(c == NULL) return false;
    out[strlen(out)-1] = 0;  // 去掉末尾的换行符
    if(strlen(out) == 0) return false;
    return true;
}

// 命令行解析,将命令行分割成命令和参数
bool CommandParse(char *commandline)
{
#define SEP " "
    g_argc = 0;
    g_argv[g_argc++] = strtok(commandline, SEP);  // 使用strtok分割命令行
    while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));
    g_argc--;
    return g_argc > 0 ? true:false;
}

// 打印命令行参数(调试用)
void PrintArgv()
{
    for(int i = 0; g_argv[i]; i++)
    {
        printf("argv[%d]->%s\n", i, g_argv[i]);
    }
    printf("argc: %d\n", g_argc);
}

// 检测并执行内置命令
bool CheckAndExecBuiltin()
{
    std::string cmd = g_argv[0];
    if(cmd == "cd")
    {
        Cd();
        return true;
    }
    else if(cmd == "echo")
    {
        Echo();
        return true;
    }
    else if(cmd == "export")
    {
    }
    else if(cmd == "alias")
    {
       // std::string nickname = g_argv[1];
       // alias_list.insert(k, v);
    }

    return false;
}

// 执行外部命令
int Execute()
{
    pid_t id = fork();  // 创建子进程
    if(id == 0)
    {
        execvp(g_argv[0], g_argv);  // 在子进程中执行命令
        exit(1);
    }
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);  // 父进程等待子进程结束
    if(rid > 0)
    {
        lastcode = WEXITSTATUS(status);  // 获取子进程的退出码
    }
    return 0;
}

// 释放分配的内存
void Destory()
{
    for(int i = 0; i < g_envs; i++)
    {
        if(g_env[i] != NULL)
        {
            free(g_env[i]);  // 释放环境变量表中分配的内存
            g_env[i] = NULL;
        }
    }
    g_envs = 0;
}

// 主函数
int main()
{
    InitEnv();  // 初始化环境变量

    while(true)
    {
        PrintCommandPrompt();  // 输出命令行提示符

        char commandline[COMMAND_SIZE];
        if(!GetCommandLine(commandline, sizeof(commandline)))  // 获取用户输入的命令
            continue;

        if(!CommandParse(commandline))  // 解析命令行
            continue;
        //PrintArgv();

        if(CheckAndExecBuiltin())  // 检测并执行内置命令
            continue;

        Execute();  // 执行外部命令
    }

    Destory(); // 在程序退出前释放内存
    return 0;
}

cd 命令为什么必须是内建命令

假设 cd 不是内建命令,而是通过调用外部程序来实现。那么每次执行 cd 时,Shell 需要创建一个子进程来运行 cd 程序。然而,子进程的当前工作目录更改并不会影响父进程(即 Shell 本身)的工作目录。因此,cd 必须由 Shell 本身直接处理,以确保更改当前工作目录的操作能够正确影响 Shell 的状态。

我们前面说过Shell通常会有两张重要的表,分别是 命令行参数表 和 环境变量表。这两张表在 Shell 的运行和命令执行过程中起着关键作用。
以上代码也标注了这两张表,我们的操作基本都是围绕这两张表进行操作的。

  • 命令行参数表(g_argv):用于存储和管理用户输入的命令及其参数,是命令解析和执行的基础。
  • 环境变量表(g_env):用于存储和管理系统及用户配置信息,为 Shell 和其子进程提供上下文环境。

本篇博客到此结束,欢迎各位评论区留言~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值