Linux系统编程--进程控制
进程控制
- 进程控制
- 1. 进程创建
- 1.1 认识 fork函数
- 1.2 内核对 fork 的处理
- 1.2.1 第一步:fork 进入内核,分配核心数据结构
- 1.2.2 第二步:复制父进程数据
- 1.2.3 第三步:定制子进程的信息
- 1.2.4 第四步:添加到系统进程列表
- 1.2.5 第五步:fork 返回,开始调度器调度
- 内核级虚拟机的原理
- 1.3 写时拷贝
- 1.3.1 底层解析写时拷贝
- 1.3.2 写时拷贝的意义
- 1.4 fork 的常规用法
- 1.5 fork 调用失败的原因
- 2. 进程终止
- 2.1 进程退出场景
- 2.1.1 具体示例
- 2.1.1.1 入口函数返回值的设计逻辑
- 2.1.1.2 关于返回值的几个常见疑问
- 如果 main 函数不写 return 语句会怎样?
- 返回值是如何在底层传递的?
- 2.1.1.3 代码验证
- 2.2 进程退出码
- 2.2.1 Linux 中的退出码
- 2.2.2 代码示例解析
- 2.3 进程常见的退出方法
- 2.3.1 进程正常退出
- 2.3.1.1 return 退出
- 2.3.1.2 exit函数
- 2.3.1.3 _exit函数
- 2.3.1.4 return、exit 和 _exit 之间的区别与联系
- 2.3.2 进程异常退出
- 3. 进程等待
- 3.1 进程等待的必要性
- 3.1.1 资源回收与僵尸进程的防治
- 3.1.2 获取子进程的退出状态
- 3.2 进程等待的方法
- 3.2.1 wait 系统调用
- 3.2.2 waitpid 系统调用
- 3.2.3 status 位图结构
- 3.2.4 阻塞与非阻塞等待
- 4. 进程程序替换
- 4.1 相关概念
- 4.2 进程程序替换的原理
- 4.2.1 基本原理
- 4.2.2 `exec` 系列函数的行为特性
- 4.2.2.1 替换成功后,后续代码不再执行
- 4.2.2.2 替换失败时,后续代码继续执行
- 4.2.2.3 独特的返回值机制
- 4.3 替换函数
- 4.3.1 exec 系列函数
- 4.3.2 函数命名理解
- 4.3.3 函数的调用
- 4.3.3.1 execl && execlp
- 4.3.3.2 execv && execvp
- 4.3.3.3 execle && execvpe
- 5. 实现自定义 Shell 命令行解释器
- 5.1 目标和实现原理
- 5.1.1 目标
- 5.1.2 实现原理
- 5.2 代码与解析
- 5.2.1 具体代码
- 5.2.2 部分解析
- 5.2.2.1 环境初始化 (`InitEnv`)
- 5.1.2.2 命令行提示符 (`PrintCommandPrompt` & `GetPwd`)
- 5.1.2.3 命令获取与解析 (`GetCommandLine` & `CommandParse`)
- 5.1.2.4 内建命令处理 (`CheckAndExecBuiltin`, `Cd`, `Echo`)
- 5.1.2.5 外部命令执行 (`Execute`)
进程控制
1. 进程创建
1.1 认识 fork函数
在linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
// 返回值: 自进程中返回0,父进程返回子进程id,出错返回-1
1.2 内核对 fork 的处理
1.2.1 第一步:fork 进入内核,分配核心数据结构
进程调用 fork
后,会通过**系统调用(System Call)**陷入内核,执行内核中的 fork
相关代码。内核的第一项任务,就是为即将诞生的子进程准备必要的 PCB 和代码数据。这涉及到分配全新的内存块,用于存放子进程最核心的内核数据结构,主要包括:
task_struct
(任务结构体):这是 Linux 内核中描述一个进程的“总纲”,也称为“进程描述符”(Process Descriptor)。它包含了进程的所有信息,如进程ID、状态、优先级、资源使用情况、打开的文件等。mm_struct
(内存描述符):负责管理进程的虚拟地址空间。vm_area_struct
(虚拟内存区域):用于描述虚拟地址空间中的一段连续区域,例如代码段、数据段、堆、栈等。
内核为子进程创建了这些结构体的全新实例,标志着一个独立的进程在内核层面开始拥有了雏形。
1.2.2 第二步:复制父进程数据
有了基本的框架,接下来就需要填充内容了。内核会将父进程的 task_struct
、mm_struct
等数据结构的内容几乎原封不动地复制到子进程的对应结构中。这保证了子进程在创建之初,拥有和父进程几乎完全相同的上下文环境(如堆、栈、数据段、环境变量、打开的文件描述符等)。
但是,如果完全复制整个进程的物理内存,那将是极其低效和耗时的。为此,Linux 采用了大名鼎鼎的**写时拷贝(Copy-on-Write, COW)**技术。
这种“懒加载”式的复制策略,极大地提升了 fork
的执行效率,因为大多数情况下,子进程创建后会立即调用 exec
函数族来加载一个全新的程序,之前的内存复制就显得毫无意义了。
1.2.3 第三步:定制子进程的信息
虽然子进程是父进程的“克隆”,但它必须拥有自己独一无二的标识。因此,在复制完数据后,内核需要对子进程的数据结构进行一些个性化修改:
- PID (进程ID):为子进程分配一个全新的、系统内唯一的
pid
。 - PPID (父进程ID):子进程的
ppid
被设置为父进程的pid
。 - 时间与统计信息:与进程运行时间相关的统计数据(如
utime
,stime
)会被清零。 - 文件锁:父进程设置的文件锁不会被子进程继承。
- 信号:挂起的信号集合会被清空,不会继承给子进程。
fork
的返回值:内核会将子进程的eax
寄存器(在x86架构中通常用于存放函数返回值)设置为0,这是子进程fork
返回0的根本原因。
1.2.4 第四步:添加到系统进程列表
至此,子进程已经准备就绪。内核会将其 task_struct
结构体添加到系统的进程链表中。在内核中,所有的进程都通过一个双向循环链表进行统一管理,将新进程加入这个链表,意味着它正式成为了系统进程“大家庭”的一员,可以被系统看到和管理。
同时,子进程也会被放入调度器的**运行队列(Run Queue)**中,标记为可运行状态,等待CPU的调度。这也就是前面所介绍的一个进程既可以在一个全局的链表中也可以在一个局部的队列中。
1.2.5 第五步:fork 返回,开始调度器调度
fork
的内核部分工作完成。现在,它准备返回到用户态。正如前面所说,fork
会返回两次:
- 在父进程中,
fork
函数返回新创建的子进程的PID。 - 在子进程中,
fork
函数返回 0。
当 fork
返回后,父、子进程都处于可运行状态。至于接下来调度器会先运行哪一个,这是不确定的,取决于系统的调度策略和当时的负载情况。程序员不应该编写依赖于父、子进程执行顺序的代码。
至此,fork
的整个内核之旅宣告结束。一个与父进程几乎一样,却又拥有独立身份的子进程诞生了,它既可以与父进程协同工作,也可以通过 exec
开启一段完全不同的人生旅程。
内核级虚拟机的原理
前面提到了进程本质是运行对应 PCB 的代码和数据,可是如果此时运行的一个进程的代码数据是一款操作系统的代码数据,就可以实现在 Linux 中运行其他操作系统的目的。而这就是内核级虚拟机的原理。
并且以此类推可以同时运行很多操作系统并且互不影响,因为进程之间是具有独立性的。
1.3 写时拷贝
根据上面的介绍可以知道,子进程会拷贝父进程的进程信息,默认的情况下父子进程的代码数据是共享的,在遇到写入操作的时候执行写时拷贝来保证父子进程之间的独立性,保证进程之间的运行互不干扰。具体过程见下图:
1.3.1 底层解析写时拷贝
创建子进程,共享物理内存
当 fork()
调用发生时,内核为子进程创建了独立的进程控制块(PCB)和虚拟地址空间。然而,在物理内存层面,子进程的页表项直接复制自父进程,这意味着父、子进程的页表指向的是完全相同的物理内存页。(如上图左侧)
修改页表权限
在完成页表复制后,操作系统会遍历与数据段相关的页表项,并将父、子进程中这些页面的权限位都设置为“只读”(Read-Only)。
请注意,代码段本身就是只读的,所以这个操作主要针对的是数据段。即使数据段在逻辑上是可写的,内核也会在此时强制将其在页表层面的权限暂时降级为只读。
写入操作引发缺页中断
现在,父、子进程都在愉快地运行。假设子进程尝试修改其数据段中的一个变量。当它执行写入指令时,CPU 的 MMU 会进行地址翻译和权限检查。
- CPU 根据子进程给出的虚拟地址,通过页表查询对应的物理地址。
- 在检查页表项时,MMU 发现该页面是存在的,但权限位是“只读”。
- 一个写入操作试图在只读页面上进行,这违反了权限规则!于是,MMU 会立即中断当前指令的执行,并触发一个页错误(Page Fault) 异常,将控制权交给操作系统内核。
内核处理,执行真正的拷贝
页错误处理程序是内核的一部分。当它被唤醒时,它会检查导致错误的具体原因。
内核通过分析发现:
- 访问的虚拟地址是合法的(位于进程的数据段内)。
- 错误类型是“向一个只读页面进行写入”。
- 内核自己知道,这个页面之所以是只读,正是因为它被设置为COW共享页面。
于是,内核判断这并非一个真正的访问错误,而是写时拷贝机制被触发了。此时,内核执行以下操作:
- 分配新的物理内存:在物理内存中申请一个新的空闲页。
- 复制数据:将原来共享的那个旧物理页中的数据,完整地复制到这个新分配的物理页中。
- 更新页表:修改触发写入操作的那个进程(这里是子进程)的页表,使其对应的页表项指向这个新的物理页。
- 恢复权限:将这个新的页表项的权限设置为**“可读可写”(Read-Write)**。(如上图右侧)
- 返回并重试:从内核态返回到用户态,让进程重新执行刚才那条失败的写入指令。
这一次,当 CPU 再次执行写入指令时,MMU 通过页表查到的是一个新的、权限为“可读可写”的物理页面,写入操作顺利完成。此后,子进程对这部分数据的任何修改,都将在自己的私有副本上进行,不再影响父进程。
1.3.2 写时拷贝的意义
理解了实现原理后,就能清晰地看到写时拷贝带来的两大核心优势:
-
降低进程创建成本,提升效率
fork()
的执行速度变得极快。因为它不再需要复制可能非常庞大的父进程数据,而仅仅是复制页表等轻量级结构并修改权限位。在面对父进程数据及其庞大的时候,系统可以每次
fork()
的时候直接复制页表等轻量级结构并修改权限位,就可以实现对子进程的管理,而不是要拷贝一份一模一样的进程在内存里,这样极大地节省了空间并提升了创建子进程的时间。 -
节省物理内存,实现精细化控制
同时很多fork()
后的场景子进程并不是对于父进程中的所有数据都进行利用。例如:- 子进程可能只会读取数据而从不写入,那么数据就永远不需要复制,父子进程可以一直共享,极大地节省了内存。
- 子进程可能只会修改一小部分数据。有了COW(写时拷贝),就只需为那一小部分被修改的页面创建副本,而不是整个父进程的数据段。
- 一个常见的模式是
fork()
之后立即调用exec()
,用一个全新的程序来替换子进程。在这种情况下,如果fork()
时就复制了全部数据,那这些数据马上就会被丢弃,造成了极大的浪费。COW完美地避免了这种浪费。
1.4 fork 的常规用法
fork 一般应用于一下两种场景:
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段;例如,父进程等待客户端请求,生成子进程来处理请求。前面使用的 fork 都属于这种情况。
- 一个进程要创建子进程来执行一个不同的程序;例如子进程从 fork 返回后,调用 exec 系列函数。这是下面要重点介绍的内容。
1.5 fork 调用失败的原因
如下两种原因可能会导致 fork 调用失败:
- 系统中有太多的进程。
- 实际用户的进程数超过了限制。
上面两个原因的本质都是内存不足,但是平时的开发很少遇到这种场景,这里只做简单介绍即可。
2. 进程终止
首先需要明白操作系统运行一个进程时,实际上是希望这个进程来完成特定的任务的。
进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
2.1 进程退出场景
一个进程的终止,并非简单地消失。从其创建者(父进程)的角度看,子进程的退出结果至关重要,因为它直接关系到后续的代码执行逻辑。可以将进程退出的场景归纳为以下三种:
代码运行完毕,结果正确:这是最理想的情况。例如,一个排序程序成功地完成了排序任务,或者一个文件写入程序准确无误地写入了指定内容。此时,进程完成了它的使命。
代码运行完毕,结果错误:进程的代码逻辑执行完了,没有崩溃,但最终产出的结果不符合预期。比如,程序本应向文件中写入100条记录,但最终只写了80条;或者一个本该升序排列的程序,结果却排成了降序。虽然进程没有异常中止,但它并未成功完成用户交付的任务。
代码运行异常:进程在运行途中遇到了无法处理的错误,导致程序崩溃。这可能是访问了非法的内存、遇到了除零错误等,使得进程无法继续执行下去,最终被操作系统强制终止。
父进程必须能够区分这三种情况,才能做出正确的判断:是继续下一步流程,还是进行重试,或是记录错误日志并警告用户。
2.1.1 具体示例
在C/C++里,入口函数 int main()
是十分熟悉的。这个 int
类型的返回值,就是返回给了调用这个程序的父进程(通常是操作系统 Shell)。
这个返回值,是进程在退出时向其创建者传递执行状态的主要方式。它是一种约定,一种进程与其创建者之间的通信机制。通过这个整数,父进程可以了解到子进程的最终执行状态,尤其是前文提到的前两种场景(结果正确/结果错误)。
2.1.1.1 入口函数返回值的设计逻辑
在许多主流的编程实践中,都存在一个共同的约定:
- 返回 0 代表 成功。
- 返回 !0 代表 失败。
这种约定的形成,源于一种简洁而实用的设计哲学:成功状态是明确且单一的,而失败的原因则可能是多种多样的。
将这个逻辑映射到计算机程序中:
- 程序执行成功:这意味着程序完成了其预设的全部任务。对于父进程而言,它只需要知道“任务已完成”这一最终结果即可。因此,使用一个唯一的、确定的值
0
来代表这种单一的成功状态,信息量已经足够。 - 程序执行失败:当程序未能成功完成任务时,父进程通常需要了解失败的具体原因,以便进行记录、报告或采取相应的补救措施。失败的原因可能有很多种,例如权限问题、文件缺失、输入数据格式错误等。
为了区分这些不同的失败场景,就需要使用不同的值来表示。因此,非零值就自然地被用作错误码(Error Code)的载体。
例如,我们可以做出如下约定(实际的 Linux 中有自己规定的退出码):
return 1;
代表权限不足。return 2;
代表文件不存在。return 3;
代表输入参数错误。return 123;
代表某个特定的业务逻辑验证失败。等等
通过返回一个具体的非零值,子进程不仅告知了父进程“执行失败”,更重要的是,它明确了“失败的原因是什么”。这种机制为程序的健壮性、自动化运维和问题排查提供了关键的信息。
2.1.1.2 关于返回值的几个常见疑问
如果 main 函数不写 return 语句会怎样?
这是由语言规范定义的问题。在现代C/C++编译器中,如果
main
函数在执行到末尾时没有显式的return
语句,编译器会自动为其添加一条return 0;
。这是一种为了方便和容错而设计的语言特性。它基于一个假定:如果一个程序能够完整地执行到
main
函数的终点而没有因错误提前退出,那么可以默认它的执行是成功的。
返回值是如何在底层传递的?
当一个函数返回时,这个返回值是如何被调用者获取的呢?对于像
int
这样的基本数据类型或者指针,其返回值通常是通过一个特定的CPU寄存器(例如x86架构下的 EAX 或 RAX 寄存器)来传递的。被调用函数执行完毕后,会将返回值存入这个约定的寄存器中;函数返回后,调用者再从这个寄存器中读取结果。这种通过寄存器传递数据的方式效率极高。
2.1.1.3 代码验证
对于下面这个简单代码:
代码运行结束后,可以查看该进程的进程退出码。
echo $? #可以使用此命令查询上一个进程的退出码
这时便可以确定main函数是顺利执行完毕了。
2.2 进程退出码
上面用一个简单的程序展示了退出码,下面对退出码进行更加详细的介绍。
2.2.1 Linux 中的退出码
Linux Shell 中的主要退出码:
退出码 | 解释 |
---|---|
0 | 命令成功执行 |
1 | 通用错误代码 |
2 | 命令(或参数)使用不当 |
126 | 权限被拒绝(或)无法执行 |
127 | 未找到命令,或 PATH 错误 |
128+n | 命令信号从外部结束,或遇到致命错误 |
130 | 通过 Ctrl+C 或 SIGINT 终止(默认终止) |
143 | 通过 SIGTERM 终止(默认终止) |
255/* | 退出码超出了 0-255 的范围,因此重新计算(LCTT 备注:超过 255 后,用退出码) |
退出码 0 表示命令执行无误,这是正常命令的理想状态。
退出码 1 可以将其解释为“不可授权操作”。
例如在没有权限的情况下使用 sudo 权限,使用 yum;再例如除错时,等操作会返回退出码 1,对应的命令为
let a=1/0
130 (
SIGINT
) 和 143 (SIGTERM
) 等退出信号是非常典型的,它们属于128+n
信号,其中 n 表示退出码。可以使用
strerror
函数来获取退出码的描述。
一个进程的返回值通常表明其程序的执行情况,也就是说进程的退出场景是由进程的退出码决定的。而进程的退出码不同的值会表明出不同的情况,会写入到进程的 task_struct
等待父进程的读取。
并且上面提到的三种退出场景中的最后一个代码异常终止的场景的退出码是无意义的!
2.2.2 代码示例解析
#include <stdio.h>
#include <string.h>int main()
{for(int i = 0; i < 150; i++){printf("%d:%s\n", i, strerror(i));}return 0;
}
运行代码后可以看到各个错误码所对应的错误信息:
实际上Linux中的ls、pwd等命令都是可执行程序,使用这些命令后我们也可以查看其对应的退出码。
可以看到,这些命令成功执行后,其退出码也是0。
但是命令执行错误后,其退出码就是非0的数字,该数字具体代表某一错误信息。
注意:
退出码都有对应的字符串含义,帮助用户确认执行失败的原因,而这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。
2.3 进程常见的退出方法
2.3.1 进程正常退出
2.3.1.1 return 退出
在main函数中使用return退出进程是常用的方法。
例如,在main函数最后使用return退出进程。
#include <stdio.h>
int main()
{printf("hello world\n");return 0;
}
运行结果:
2.3.1.2 exit函数
使用 exit 函数退出进程也是常用的方法,exit 函数可以在代码中的任何地方退出进程,并且 exit 函数在退出进程前会做一系列工作:
- 执行用户通过atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入。
- 调用_exit函数终止进程。
例如,以下代码中exit终止进程前会将缓冲区当中的数据输出。
#include <stdio.h>
#include <stdlib.h>void show()
{printf("hello world"); exit(1);
}int main()
{show();return 0;
}
运行结果:
因为没有加 \n
所以hello world
会一直在缓冲区中直到 exit
函数调用结束进程才会将缓冲区的内容刷新出来。
2.3.1.3 _exit函数
使用 _exit
函数退出进程的方法并不经常使用,_exit
函数也可以在代码中的任何地方退出进程,但是 _exit
函数会直接终止进程,并不会在退出进程前会做任何收尾工作。
例如,以下代码中使用_exit终止进程,则缓冲区当中的数据将不会被输出。
#include <stdio.h>
#include <stdlib.h>void show()
{printf("hello world"); _exit(1);
}int main()
{show();return 0;
}
运行结果:
2.3.1.4 return、exit 和 _exit 之间的区别与联系
return、exit和_exit之间的区别
只有在 main 函数当中的 return 才能起到退出进程的作用,子函数当中 return 不能退出进程,而 exit 函数和 _exit 函数在代码中的任何地方使用都可以起到退出进程的作用。
使用 exit 函数退出进程前,exit 函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程,而 _exit 函数会直接终止进程,不会做任何收尾工作。
return、exit 和 _exit 之间的联系
执行 return num
等同于执行 exit(num)
,因为调用 main 函数运行结束后,会将 main 函数的返回值当做 exit 的参数来调用 exit 函数。
使用 exit 函数退出进程前,exit 函数会先执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再调用 _exit 函数终止进程。
并且可以知道 exit 函数是C语言的库函数,而 _exit
函数是系统调用,之前在介绍计算机体系结构的时候说过库的底层封装是系统调用,库函数没有权限对系统中的资源做管理,只是因为其函数内部对系统调用做了封装,调用了系统调用的接口从而实现的系统的管理。
2.3.2 进程异常退出
向进程发生信号导致进程异常退出
例如,在进程运行过程中向进程发生 kill -9
信号使得进程异常退出,或是使用Ctrl+C使得进程异常退出等。
代码错误导致进程运行时异常退出
例如,代码当中存在野指针问题使得进程运行时异常退出,或是出现除0的情况使得进程运行时异常退出等。
3. 进程等待
在多进程编程模型中,父进程创建子进程以执行特定任务。当子进程的生命周期结束时,操作系统提供了一套机制,允许父进程获取其子进程的最终状态并回收其占用的系统资源。这个机制就是进程等待。
3.1 进程等待的必要性
3.1.1 资源回收与僵尸进程的防治
这是进程等待机制在系统层面最根本的功能,具有强制性。
当一个进程终止时,其占用的绝大部分资源(如用户空间内存、文件描述符等)会被操作系统内核立即释放。然而,内核并不会立即移除该进程在内核进程表中的条目,即进程控制块(PCB)。该PCB中保留了关于进程退出的关键摘要信息,例如进程ID、退出状态码、以及资源使用统计等。
内核保留PCB的目的是为了让其父进程有机会读取这些信息。如果子进程终止后,父进程并未调用等待函数来读取这些信息,内核将持续保留该PCB。此时,该子进程就进入了僵尸状态(Zombie State)。
关于僵尸进程,需要明确以下几点:
- 资源泄露:僵尸进程本身不执行代码,也不占用CPU时间。但其残留的PCB会持续占用内核内存空间。如果大量僵尸进程持续累积,将导致内核资源泄露,严重时可能耗尽进程ID或耗尽内核为进程表预留的内存,使得系统无法创建新进程。
- 不可被终止:由于僵尸进程已经处于终止状态,它无法响应任何信号。因此,向僵尸进程发送
SIGKILL
信号(kill -9
)是无效的。 - 唯一的清理方式:清理僵尸进程的唯一方法是由其父进程执行一次等待操作(如调用
wait()
或waitpid()
系统调用)。当父进程执行等待操作时,内核会将子进程的退出信息传递给父进程,并随后彻底清除该子进程的PCB。
3.1.2 获取子进程的退出状态
这是进程等待机制在应用层面的主要功能,其必要性取决于具体的应用需求。
父进程创建子进程通常是为了委托其完成一项任务。任务执行完毕后,父进程可能需要根据子进程的执行结果来决定后续的控制流程。子进程的退出状态精确地反映了任务的执行结果,主要包含两类信息:
- 任务是否成功:通过检查子进程的退出码,父进程可以判断子进程是正常完成任务(通常退出码为0),还是在执行逻辑中遇到了可预见的错误(退出码为非零)。
- 进程终止方式:父进程可以判断子进程是正常执行完毕退出,还是被某个信号异常终止。
父进程获取这些状态信息的唯一途径,就是执行等待操作。获取到的状态信息对于构建健壮的应用程序至关重要,它使得错误处理、任务重试或流程控制成为可能。
在某些应用场景中,父进程可能不需要关心子进程具体的执行结果,这种模式常被称为“即发即忘”(fire and forget)。即便如此,为了履行前述第一点中资源回收的强制性责任,父进程仍需执行等待操作。在这种情况下,父进程可以调用等待函数,但选择忽略返回的退出状态信息。
3.2 进程等待的方法
在 Linux 下,一般通过以下两种系统调用来进行进程等待:
3.2.1 wait 系统调用
#include <sys/types.h>
#include <sys/wait.h>pid_t wait(int* status);/*
返回值:成功返回被等待进程 pid,失败返回 -1。参数:输出型参数,获取子进程退出状态,不关心则可以设置为 NULL
*/
下面举例来演示 wait 的使用:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>int main()
{int id = fork();// 进程创建失败if(id == -1) {printf("fork error\n");exit(-1);} // 子进程else if(id == 0) { int cnt = 5;while(cnt--) {printf("子进程, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);sleep(1);}exit(1);} //父进程else {sleep(10);int status = 0;pid_t ret = wait(&status);if(ret == -1) {printf("wait fail\n");exit(1);} else {printf("wait success\n");}printf("exit code:%d\n", status);}return 0;
}
可以通过一个监控脚本来检测子进程从创建到终止到被父进程回收的过程:
while :; do ps axj | head -1 && ps axj | grep process | grep -v grep; sleep 1; done
可以看到,最开始父子进程都处于睡眠状态 S,之后子进程运行5s退出,此时由于父进程还要休眠5s,所以没有对子进程进行进程等待,所以子进程变成僵尸状态 D。5s过后,父进程使用 wait 系统调用对子进程进行进程等待,所以子进程由僵尸状态变为彻底死亡状态。
3.2.2 waitpid 系统调用
pid_t waitpid(pid_t pid, int *status, int options);/*
返回值:当正常返回的时候waitpid返回收集到的子进程的进程ID;如果设置了选项WNOHANG,而调用的waitpid发现没有退出的子进程可收集,则返回0;如果调用中出错,则返回 -1, 这时errno会被设置成相应的值以指示错误所在;参数:pid:Pid=-1,等待任何一个子进程。与wait等效。Pid>0,等待其进程ID与pid相等的子进程。status: 输出型参数WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否正常退出)WEXITSTATUS(status):若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)options: 默认为0,表示阻塞等待WNOHANG:若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID
*/
具体示例说明 waitpid 的使用:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>int main()
{// 进程创建int id = fork();if(id == -1) {printf("fork error\n");exit(-1);} // 子进程else if(id == 0){ int cnt = 5;while(cnt--) {printf("子进程, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);sleep(1);}exit(1);} // 父进程else { sleep(10);int status = 0;pid_t ret = waitpid(id, &status, 0); //阻塞等待if(ret == -1) {printf("wait fail\n");exit(1);} else {printf("wait success\n");}printf("exit signal:%d, exit code:%d\n", (status & 0x7f), (status >> 8 & 0xff));}return 0;
}
可以看到,waitpid 和 wait 还是有很大区别的,waitpid 可以传递 id 来指定等待特定的子进程,也可以指定 options 来指明等待方式。
3.2.3 status 位图结构
在上面的例子中,子进程使用 exit 终止进程时返回的退出码是1,但是发现保存子进程退出信息的 status 的值非常奇怪,命名返回的错误码是1,可是最后显示出来的 status 值却是256,而这是 status 的位图结构造成的;
wait 和 waitpid,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充。
如果传递
NULL
,表示不关心子进程的退出状态信息;否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。status 不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究 status 低16位):
结构组成:可以看到,status 低两个字节的内容被分成了两部分:第个一字节前七位表示退出信号,最后一位表示 core dump 标志(后面的文件部分再进行详细介绍);第二个字节表示退出状态,退出状态即代表进程退出时的退出码。
**具体分析:**对于正常退出的程序来说,退出信号和 core dump 标志都为0,退出状态等于退出码;对于异常终止的程序来说,退出信号为异常对应的信号编号,此时的该进程被异常终止其退出码无意义。
所以 status 正确的读取方法如下:
手动提取:
printf("exit signal:%d, exit code:%d \n", (status & 0x7f), (status >> 8 & 0xff));
status 按位与上 0x7f 表示保留低七位,其余九位全部置为0,从而得到退出信号。
status 右移8位得到退出状态(退出码),再按位与上 0xff 是为了防止右移时高位补1的情况。
经过上述代码的更改即可得到正确的退出码1。
WIFEXITED 与 WEXITSTATUS 宏提取:
Linux 提供了 WIFEXITED 和 WEXITSTATUS 宏 来帮助用户获取 status 中的退出状态和退出信号,而不需要用户自己去按位操作:
- WIFEXITED (status):若子进程正常退出,返回真,否则返回假;(查看进程是否是正常退出)(wait if exited)
- WEXITSTATUS (status):若 WIFEXITED 为真,提取子进程的退出状态;(查看进程的退出码)(wait exit status)
if(WIFEXITED(status))
//正常退出
{ printf("exit code:%d\n", WEXITSTATUS(status));
} //异常终止
else
{ printf("exit signal:%d\n",WIFEXITED(status));
}
这样直接通过系统中的信号(本质就是宏),即可直接提取进程的退出码和退出状态。
3.2.4 阻塞与非阻塞等待
waitpid
函数的第三个参数用于指定父进程的等待方式:
其中,options 为0代表阻塞式等待,options 为 WNOHANG 代表非阻塞式等待。
阻塞式等待即当父进程执行到 waitpid 函数时,如果子进程还没有退出,父进程就只能阻塞在 waitpid 函数,直到子进程退出,父进程通过 waitpid 读取退出信息后才能接着执行后面的语句。
而非阻塞式等待则不同,当父进程执行到 waitpid 函数时,如果子进程未退出,父进程会直接读取子进程的状态并返回(轮询),然后接着执行后面的语句,不会等待子进程退出。
非阻塞轮询:
轮询是指父进程在非阻塞式状态的前提下,以循环方式不断的对子进程进行进程等待,直到子进程退出。
下面是具体示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>void task1()
{printf("task is running...\n");
}void task2()
{printf("task is runnning...\n");
}int main()
{// 进程创建失败int id = fork();if(id == -1) {printf("fork error\n");exit(-1);} // 子进程else if(id == 0) { int cnt = 5;while(cnt--) {printf("子进程, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);sleep(1);}exit(1);} // 父进程else { int status = 0;// 轮询while(1) { pid_t ret = waitpid(id, &status, WNOHANG); // 非阻塞式等待if(ret == -1) {printf("wait fail\n"); // 调用失败exit(1);} // 调用成功,但子进程未退出else if(ret == 0){ printf("wait success, but child process not exit\n");task1(); // 执行其他命令task2();} // 调用成功,子进程退出else { printf("wait success, and child exited\n");break;}sleep(1);}if(WIFEXITED(status)) { // 正常退出printf("exit code:%d\n", WEXITSTATUS(status));} else { // 异常终止printf("exit signal:%d\n",WIFEXITED(status));}}return 0;
}
4. 进程程序替换
4.1 相关概念
在上面进程创建中提到,fork
函数一般有两种用途。
- 创建子进程来执行父进程的部分代码以及创建子进程来执行不同的程序。
- 创建子进程来执行不同的程序就是进程程序替换。
进程程序替换是指父进程用 fork
创建子进程后,子进程通过调用 exec
系列函数来执行另一个程序。当进程调用某一种 exec
函数时,该进程的用户空间代码和数据完全被新程序替换,然后从新程序的启动例程开始执行。
但是原进程的 task_struct
和 mm_struct
以及进程 id 都不会改变,页表的对应关系可能会因为代码数据的大小不一样而发生一定的调整,所以调用 exec 并不会创建新进程,而是让原进程去执行另外一个程序的代码和数据。
4.2 进程程序替换的原理
4.2.1 基本原理
进程程序替换的原理,可以概括为对现有进程内容的覆盖式加载。
一个运行中的进程,在内核中拥有其独立的进程控制块(PCB),并关联着自身的虚拟地址空间、页表、代码段和数据段。当这个进程调用 exec
系列函数(如 execl
, execv
等)时,会触发以下核心操作:
- 加载新程序:内核根据
exec
函数提供的路径,找到新的可执行程序文件。 - 数据覆盖:内核将新程序文件中的代码段和数据段,加载到当前进程的地址空间中,覆盖掉原有的代码段和数据段。
- 更新执行流:进程的执行入口(程序计数器PC)被重置为新程序的起始地址。
通过这个过程,进程的“内容”被替换了,但进程本身(即它的进程ID和PCB)并未改变。因此,程序替换不会创建新进程。它仅仅是在一个已存在的进程“外壳”下,执行了一个全新的程序。
4.2.2 exec
系列函数的行为特性
基于程序替换的覆盖式原理,exec
系列函数展现出几个关键的行为特性,这可以通过代码实验进行验证。
4.2.2.1 替换成功后,后续代码不再执行
示例代码:
#include<stdio.h>int main()
{// ... 前半部分代码 ...printf("Before program replacement...\n");execl("/bin/ls", "ls", "-l", "-a", NULL); // 尝试执行ls命令// ... 后半部分代码 ...printf("After program replacement...\n"); return 0;
}
运行结果:
当上述程序运行时,如果 execl
调用成功,ls -l -a
命令将被加载并执行。由于原进程的代码段已经被 ls
程序的新代码所覆盖,execl
调用之后的 printf("After program replacement...\n");
语句将永远不会被执行。因为从内核的角度看,那部分代码已经不存在于当前进程的地址空间中了。
4.2.2.2 替换失败时,后续代码继续执行
如果 exec
调用因为某些原因失败(例如,提供的程序路径错误、文件不存在或没有执行权限),内核将无法完成程序加载。此时,程序替换操作失败,当前进程的地址空间保持不变。
因此,进程的执行流会从 exec
函数调用处返回,并继续执行其后的代码。
4.2.2.3 独特的返回值机制
exec
系列函数具有一种独特的返回值处理方式:
- 没有成功返回值:如果函数调用成功,新的程序会立即接管进程的执行权,原程序的代码(包括处理返回值的代码)已不复存在。因此,从逻辑上讲,
exec
系列函数没有“成功”的返回值。 - 仅有失败返回值:只有在调用失败时,
exec
函数才会返回到原始调用点。按照惯例,它会返回-1
,并设置全局变量errno
以指示具体的错误原因。
这个特性导出一个重要的编程实践:我们无需对 exec
函数的返回值进行“成功”判断。代码只要能从 exec
调用后继续执行,就足以证明替换操作已经失败。
基于此,处理 exec
失败的标准模式通常是:
//... execl("/path/to/non_existent_program", "arg0", NULL);// 如果代码能执行到这里,说明execl必定失败了perror("execl failed"); // 打印具体的错误原因exit(1); // 终止进程,因为预期行为未能实现
//...
4.3 替换函数
4.3.1 exec 系列函数
Linux 提供了一系列的 exec 函数来实现进程程序替换,其中包括六个库函数和一个系统调用:
可以看到,实现进程程序替换的系统调用函数就一个 execve,其他一系列的 exec 库函数都是为了满足不同的替换场景而对 execve 系统调用进行的封装,其底层还是 execve。接下来要重点介绍的是这六个 exec 库函数。
#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 execvpe(const char *file, char *const argv[],char *const envp[])
这些函数如果调用成功则加载新的程序并启动代码开始执行,不再返回;如果调用出错则返回-1。
**注意:**exec 函数一旦调用成功,就代表着原程序的代码和数据已经被新程序替换掉了,也就是说,原程序后续的语句都不会再被执行了,所以 exec 调用成功后没有返回值,因为该返回值没有机会被使用。只有 exec 调用失败,原程序可以继续往下执行时,exec 返回值才会被使用。
4.3.2 函数命名理解
上面这六个库函数的函数原型看起来很容易混,但其实只要掌握了规律就很好记:
- **l (list):**表示参数采用列表。
- **v (vector):**表示参数采用数组。
- **p (path):**表示系统会自动到环境变量PATH路径下搜索文件,即对于替换Linux指令相关程序时我们不用带路径。
- **e (env):**表示自己维护环境变量。
4.3.3 函数的调用
想要执行一个程序,无非就两个步骤 :
- 找到该可执行程序
- 指定程序执行的方式
对于 exec 函数来说,“p” 和非 “P” 用来找到程序,“l” “v” 用来指定程序执行方式;“e” 用来指定环境变量。
4.3.3.1 execl && execlp
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
exec 函数的使用其实很简单,第一个参数为要替换的程序的可执行文件的路径,并且如果该可执行文件在PATH环境变量中,且 exec 函数带有 “p”,就可以不带路径,只写函数名。
第一个参数:
这里以Linux指令 “ls
” 为例,ls 是Linux中 “/usr/bin” 目录下的一个可执行程序,且该程序处于PATH环境变量中,那么如果要替换此程序,exec 函数的第一个参数如下:
execl("/usr/bin/ls", ...) //execl 需要带路径
execlp("ls", ...) //execlp 可以不带路径
注意:带 “p” 的 exec 函数可以不带路径的前提是被替换程序处于PATH环境变量中,如果条件不成立,即使函数中有 “p”,仍然要带路径。
第二个参数:
第二个参数为如何去执行程序的,这里只需要记住:在 Linux 命令行中该程序如何执行就如何传参 即可。
需要注意的是,命令行中多个指令是以空格为分隔的一整个字符串,而 exec 中需要对不同选项进行分割,即每一个选项都要单独分为一个字符串,所以可以看到 exec 函数中存在可变参数列表 “…”;同时,需要将最后一个可变参数设置为 NULL,表示传参完毕。
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); //命令行中怎么执行就如何传参
execlp("ls", "ls", "-a", "-l", NULL); //命令行:ls -a -l
**注意:**Linux 中 ls 其实是使用 alias 命令设置别名的,所以我们执行 ls 的时候其实默认带了 “–color=auto” 选项,它让不同类型的文件带有不同的颜色。
所以 ls 在进程程序替换时如果想要让不同类型文件表现为不同颜色的话,需要显示传递 “–color=auto” 选项:
execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);
execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);
具体代码示例:
#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 == -1) {perror("fork");return 1;} // 子进程else if (id == 0) { printf("pid: %d, 子进程开始运行...\n", getpid());//进程程序替换int ret = execl("/usr/bin/ls", "ls", "-l", "-a", "--color=auto", NULL);// 替换失败,以下语句可以被执行if(ret == -1) { printf("process exec failed\n");exit(1);}printf("pid: %d, 子进程结束...\n", getpid());return 0;}// 父进程int status = 0;pid_t ret = waitpid(id, &status, 0); // 进程等待if(ret == -1) {perror("waitpid");return 1;} else {printf("wait pid: %d, exit signal: %d, exit code: %d\n", ret, (status & 0x7f), (status >> 8 & 0xFF));}return 0;
}
4.3.3.2 execv && execvp
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
exec 函数中 “v” 代表参数采用数组的形式传递,argv 是一个指针数组,数组里面的每一个元素都是指针,每一个指针都指向一个参数 (字符串),同样,最后一个元素指向 NULL,代表参数传递完毕。
这里还是以 ls
指令为例:
char* const argv[] = { //存放参数的指针数组(char*)"ls",(char*)"-a", (char*)"-l",(char*)"--color=auto",NULL
}; execv("/usr/bin/ls", argv);
execvp("ls", argv);
**注意:由于 “ls” “-a” 等字符串是常量字符串,而 argv 里面的参数是 char const 而不是 const char 的,所以这里需要强转一下。
4.3.3.3 execle && execvpe
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execvpe(const char *file, char *const argv[],char *const envp[])
exec 函数中 “e” 代表环境变量和 argv 一样,envp 也是一个指针数组,数组里面的每个元素都是一个指针,指向一个环境变量 (字符串),可以显式初始化 envp 来传递用户自定义的环境变量,但是这样做的同时系统环境变量会被自定义的环境变量覆盖。
proc.c
#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 == -1) {perror("fork");return 1;} // 子进程else if (id == 0) { printf("pid: %d, 子进程开始运行...\n", getpid());// 自定义环境变量 char* const envp[] = { (char*)"MYENV=HELLO EXEC",NULL};int ret = execle("./bin", "./bin", NULL, envp); //程序替换// 替换失败,以下语句可以被执行if(ret == -1) { printf("process exec failed\n");exit(1);}printf("pid: %d, 子进程结束...\n", getpid());return 0;}// 父进程int status = 0;pid_t ret = waitpid(id, &status, 0); // 进程等待if(ret == -1) {perror("waitpid");return 1;}else {printf("wait pid: %d, exit signal: %d, exit code: %d\n", ret, (status & 0x7f), (status >> 8 & 0xFF));}return 0;
}
bin.c
#include <stdio.h>
#include <stdlib.h>int main()
{printf("新进程\n");printf("USER: %s\n", getenv("USER"));printf("PWD: %s\n", getenv("PWD"));printf("MYENV: %s\n", getenv("MYENV"));return 0;
}
可以看到,这里只获取到了自定义的环境变量 MYENV,而系统环境变量 USER 和 PWD 则是获取失败。
如何同时获取到自定义环境变量和系统环境变量呢?答案是通过 putenv 函数将自定义环境变量导入到系统环境变量中,然后通过传递环境变量表 environ 实现:
extern char** environ; //系统环境变量
int put = putenv((char*)"MYENV=HELLO_EXECLE"); //导入自定义环境变量
if(put != 0) //导入失败返回非0perror("putenv");int ret = execle("./bin", "./bin", NULL, environ); //传递环境变量
5. 实现自定义 Shell 命令行解释器
在学习了进程创建、进程终止、进程等待以及进程程序替换系列进程控制相关知识后,就可以自己实现一个简易的 shell 命令行解释器了。
5.1 目标和实现原理
5.1.1 目标
- 要能处理普通命令
- 要能处理内建命令
- 要能帮助用户理解内建命令/本地变量/环境变量这些概念
- 要能帮助用户理解shell的允许原理
5.1.2 实现原理
考虑下面这个与shell典型的互动:
[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# psPID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps
用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell 由标识为 sh 的方块代表,它随着时间的流逝从左向右移动。shell 从用户读入字符串"ls"。shell 建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。
然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。
所以要写一个shell,需要循环以下过程:
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
根据这些思路,和前面的学的内容,就可以自己来实现一个shell了。
5.2 代码与解析
5.2.1 具体代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#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;
}const char *GetHostName()
{const char *hostname = getenv("HOSTNAME");return hostname == NULL ? "None" : hostname;
}const char *GetPwd()
{//const char *pwd = getenv("PWD");const char *pwd = getcwd(cwd, sizeof(cwd));if(pwd != NULL){snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);putenv(cwdenv);}return pwd == NULL ? "None" : pwd;
}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;//本来要从配置文件来//1. 获取环境变量for(int i = 0; environ[i]; i++){// 1.1 申请空间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"; //for_testg_env[g_envs] = NULL;//2. 导成环境变量for(int i = 0; g_env[i]; i++){putenv(g_env[i]);}environ = g_env;
}//command
bool Cd()
{// cd argc = 1if(g_argc == 1){std::string home = GetHome();if(home.empty()) return true;chdir(home.c_str());}else{std::string where = g_argv[1];// cd - / cd ~if(where == "-"){// Todu}else if(where == "~"){// Todu}else{chdir(where.c_str());}}return true;
}void Echo()
{if(g_argc == 2){// echo "hello world"// echo $?// echo $PATHstd::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;}}
}// / /a/b/c
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());//snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}void PrintCommandPrompt()
{char prompt[COMMAND_SIZE];MakeCommandLine(prompt, sizeof(prompt));printf("%s", prompt);fflush(stdout);
}bool GetCommandLine(char *out, int size)
{// ls -a -l => "ls -a -l\n" 字符串char *c = fgets(out, size, stdin);if(c == NULL) return false;out[strlen(out)-1] = 0; // 清理\nif(strlen(out) == 0) return false;return true;
}// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
bool CommandParse(char *commandline)
{
#define SEP " "g_argc = 0;// 命令行分析 "ls -a -l" -> "ls" "-a" "-l"g_argv[g_argc++] = strtok(commandline, SEP);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){//childexecvp(g_argv[0], g_argv);exit(1);}int status = 0;// fatherpid_t rid = waitpid(id, &status, 0);if(rid > 0){lastcode = WEXITSTATUS(status);}return 0;
}int main()
{// shell 启动的时候,从系统中获取环境变量// 我们的环境变量信息应该从父shell统一来InitEnv();while(true){// 1. 输出命令行提示符PrintCommandPrompt();// 2. 获取用户输入的命令char commandline[COMMAND_SIZE];if(!GetCommandLine(commandline, sizeof(commandline)))continue;// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"if(!CommandParse(commandline))continue;//PrintArgv();// 检测别名// 4. 检测并处理内键命令if(CheckAndExecBuiltin())continue;// 5. 执行命令Execute();}//cleanup();return 0;
}
5.2.2 部分解析
5.2.2.1 环境初始化 (InitEnv
)
这是Shell启动的第一步,至关重要。
void InitEnv()
{extern char **environ; // 关键点1// ...for(int i = 0; environ[i]; i++){g_env[i] = (char*)malloc(strlen(environ[i])+1); // 关键点2strcpy(g_env[i], environ[i]);g_envs++;}// ...for(int i = 0; g_env[i]; i++){putenv(g_env[i]); // 关键点3}environ = g_env; // 关键点4 (可选但常见)
}
- 知识点拓展 1:
extern char \**environ;
environ
是一个由C标准库提供的全局变量,它是一个以NULL
结尾的字符串指针数组,指向了当前进程启动时所拥有的所有环境变量(如"PATH=/bin:/usr/bin"
,"USER=test"
等)。通过声明它,我们可以直接访问到这些从父进程继承来的环境变量。
- 知识点拓展 2: 为什么要复制环境变量?
environ
指向的内存区域通常是只读的,或者不应该被程序直接修改。为了让我们的Shell能够安全地添加、删除或修改环境变量(例如实现export
命令),我们需要在自己的内存空间(g_env
)中创建一个完整的副本。代码中使用malloc
和strcpy
逐一复制了每一条环境变量字符串。
- 知识点拓展 3:
putenv()
函数putenv()
函数用于将一个"key=value"
形式的字符串添加到当前进程的环境变量中。循环调用putenv(g_env[i])
的作用,就是将我们刚刚在g_env
中建立的副本“注册”到当前Shell进程的实际环境中,这样后续的getenv()
调用或者子进程才能看到这些变量。
- 知识点拓展 4:
environ = g_env;
(代码中未执行,但为常见实践)- 有些实现会直接将全局的
environ
指针指向我们自己的g_env
数组。这样做可以确保当子进程通过execle
或execve
继承环境时,继承的是我们管理的g_env
,但这并非所有场景下都必须。
- 有些实现会直接将全局的
5.1.2.2 命令行提示符 (PrintCommandPrompt
& GetPwd
)
这部分是对之前基础功能的增强。
const char *GetPwd()
{const char *pwd = getcwd(cwd, sizeof(cwd));if(pwd != NULL){snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);putenv(cwdenv);}return pwd;
}std::string DirName(const char *pwd) { /* ... */ }
- 知识点拓展:
getcwd()
vsgetenv("PWD")
getenv("PWD")
: 只是读取名为PWD
的环境变量。如果用户通过某种方式(如cd
命令)改变了目录,但没有相应地更新PWD
变量,那么getenv
获取到的就是过时的信息。getcwd()
: 这是一个系统调用,它直接向操作系统内核查询当前进程的实际工作目录。这总是准确的。- 最佳实践: 代码中的实现是最佳实践。它使用
getcwd()
获取最准确的路径,然后立即用putenv()
更新PWD
环境变量,确保了内部状态和环境变量的一致性。
DirName
函数通过字符串操作,只提取路径的最后一部分(如/home/user/project
->project
),使得提示符更加简洁美观,这是多数现代Shell的标准行为。
5.1.2.3 命令获取与解析 (GetCommandLine
& CommandParse
)
这是将用户输入(一个长字符串)转换为程序可理解的参数列表的过程。
bool CommandParse(char *commandline)
{#define SEP " "g_argv[g_argc++] = strtok(commandline, SEP);while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));g_argc--;// ...
}
- 知识点拓展:
strtok()
函数strtok
用于在字符串中查找由分隔符SEP
分隔的“令牌”(tokens)。- 工作原理:
- 首次调用:
strtok(commandline, SEP)
,它会找到第一个令牌(如"ls"
),在令牌末尾写入\0
以截断原字符串,并返回指向令牌头部的指针。它内部会保存一个静态指针,指向下一个要处理的位置。 - 后续调用:
strtok(nullptr, SEP)
,告诉函数继续在上一次的字符串上工作。
- 首次调用:
- 注意:
strtok
是不可重入的,因为它使用内部静态变量来维持状态。这意味着在多线程环境下使用它是不安全的。同时,它会修改原始字符串。
5.1.2.4 内建命令处理 (CheckAndExecBuiltin
, Cd
, Echo
)
有些命令必须由Shell进程自身直接执行,而不能创建子进程来做,这些就是内建(Built-in)命令。
bool CheckAndExecBuiltin()
{std::string cmd = g_argv[0];if(cmd == "cd"){Cd();return true;}// ...
}
- 知识点拓展: 为什么
cd
必须是内建命令?- 进程的工作目录是进程自身的一个属性。如果
cd
是一个外部程序(如/bin/cd
),fork
创建的子进程会执行它。这个子进程可以通过chdir()
系统调用改变它自己的工作目录,但当子进程执行完毕并退出后,父进程(我们的Shell)的工作目录丝毫未变。因此,cd
必须由Shell自身直接调用chdir()
来执行,才能改变Shell的当前目录。 - 其他常见的内建命令包括
export
,alias
,exit
,history
等,它们都用于修改Shell自身的状态。
- 进程的工作目录是进程自身的一个属性。如果
Echo()
函数: 展示了如何处理带$
的特殊变量。echo $?
:lastcode
全局变量保存了上一个外部命令的退出码,echo $?
就是打印这个值。echo $VAR
: 通过substr(1)
提取变量名,再用getenv()
获取其值并打印。
5.1.2.5 外部命令执行 (Execute
)
这是Shell最核心的功能:运行用户指定的任何其他程序。
int Execute()
{pid_t id = fork();if(id == 0){//childexecvp(g_argv[0], g_argv);exit(1); // 如果execvp失败,子进程必须退出}int status = 0;// fatherpid_t rid = waitpid(id, &status, 0);if(rid > 0){lastcode = WEXITSTATUS(status); // 提取退出码}return 0;
}
- 知识点拓展:
fork-exec-wait
模型- 这是Unix/Linux下创建新进程并执行程序的经典模型。
fork()
: 创建一个与父进程(Shell)几乎一模一样的子进程。fork()
在父进程中返回子进程的PID,在子进程中返回0。execvp()
(在子进程中):exec
系列函数会用一个全新的程序镜像替换当前进程(这里是子进程)的内存空间、代码和数据。子进程从此变成了ls
或grep
等命令。v
表示它接受一个char*
数组(即我们的g_argv
)作为参数。p
表示如果第一个参数g_argv[0]
不包含/
,它会在PATH
环境变量所指定的目录中搜索该可执行文件。- 如果
execvp
执行成功,它永远不会返回。如果它返回了,就意味着出错了(如命令未找到),此时子进程必须调用exit(1)
退出,以防它继续执行Shell的代码。
waitpid()
(在父进程中):- 父进程调用
waitpid
来等待子进程结束。这可以防止子进程变成“僵尸进程”(Zombie Process)。 status
是一个整型变量,内核会把子进程的终止状态信息(包括退出码、是否被信号杀死等)写入其中。
- 父进程调用
WEXITSTATUS(status)
: 这是一个宏,用于从status
变量中提取出子进程的正常退出码(即子进程main
函数的返回值或exit()
的参数)。这个值被保存到lastcode
,以备echo $?
使用。