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函数创建子进程也可能会失败,有以下两种情况:
- 系统中有太多的进程,内存空间不足,子进程创建失败。
- 实际用户的进程数超过了限制,子进程创建失败。
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() 函数有以下几个功能:
- 阻塞等待子进程退出:父进程会暂停执行,直到子进程结束。
- 回收子进程残留资源:释放子进程占用的进程控制块(PCB)等资源。
- 获取子进程结束状态:通过参数 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需要执行的逻辑非常简单,其只需循环执行以下步骤:
- 获取命令行。
- 解析命令行。
- 创建子进程。
- 替换子进程。
- 等待子进程退出
下面是一个简易版的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 和其子进程提供上下文环境。
本篇博客到此结束,欢迎各位评论区留言~