316 lines
7.3 KiB
Markdown
316 lines
7.3 KiB
Markdown
|
|
# 第06讲:进程控制(进阶)
|
|||
|
|
|
|||
|
|
> 🎯 **本节目标**:深入理解 fork/exec 的工作原理,掌握 Shell 的实现机制
|
|||
|
|
|
|||
|
|
## 📋 前置知识
|
|||
|
|
- [[06_进程控制]] — fork、exec、wait 的基本概念
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🤔 为什么需要这个?
|
|||
|
|
|
|||
|
|
你每天都在用 Shell(命令行),但你有没有想过:
|
|||
|
|
- Shell 是怎么执行你的命令的?
|
|||
|
|
- 为什么输入 `ls` 就能列出文件?
|
|||
|
|
- 后台运行 `&` 是怎么实现的?
|
|||
|
|
|
|||
|
|
理解这些,需要深入掌握 fork 和 exec 的配合机制。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📖 核心概念
|
|||
|
|
|
|||
|
|
### 1. Shell 的工作原理
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TD
|
|||
|
|
A[用户输入命令] --> B[Shell 解析命令]
|
|||
|
|
B --> C[Shell 调用 fork]
|
|||
|
|
C --> D[子进程调用 execvp]
|
|||
|
|
D --> E[执行命令程序]
|
|||
|
|
E --> F[子进程结束]
|
|||
|
|
F --> G[Shell 调用 waitpid]
|
|||
|
|
G --> A
|
|||
|
|
|
|||
|
|
style A fill:#e1f5fe
|
|||
|
|
style D fill:#e8f5e9
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Shell 的核心逻辑**:
|
|||
|
|
```c
|
|||
|
|
while (1) {
|
|||
|
|
printf("%% "); // 打印提示符
|
|||
|
|
fgets(cmdline); // 读取命令
|
|||
|
|
if (feof(stdin)) exit(0);
|
|||
|
|
|
|||
|
|
pid = fork(); // 创建子进程
|
|||
|
|
if (pid == 0) { // 子进程
|
|||
|
|
execvp(argv[0], argv); // 执行命令
|
|||
|
|
exit(1); // exec 失败
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!background) // 前台运行
|
|||
|
|
waitpid(pid); // 等待子进程结束
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. fork() 的实现细节
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TD
|
|||
|
|
A[父进程调用 fork] --> B[内核复制父进程的 PCB]
|
|||
|
|
B --> C[复制页表(写时复制)]
|
|||
|
|
C --> D[设置子进程的 PID]
|
|||
|
|
D --> E[父子进程各返回一次]
|
|||
|
|
|
|||
|
|
style A fill:#e1f5fe
|
|||
|
|
style B fill:#fff3e0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**写时复制(Copy-on-Write)**:
|
|||
|
|
- fork() 时不立即复制物理内存
|
|||
|
|
- 父子进程共享同一份物理页面
|
|||
|
|
- 只有当某一方尝试写入时,才复制该页面
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant 父进程
|
|||
|
|
participant 子进程
|
|||
|
|
participant 内存
|
|||
|
|
|
|||
|
|
父进程->>内存: fork()
|
|||
|
|
Note over 内存: 父子共享物理页面
|
|||
|
|
子进程->>内存: 尝试写入
|
|||
|
|
Note over 内存: 触发写时复制
|
|||
|
|
Note over 内存: 复制该页面给子进程
|
|||
|
|
子进程->>内存: 写入新页面
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. exec() 的工作流程
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TD
|
|||
|
|
A[调用 execvp] --> B[查找可执行文件]
|
|||
|
|
B --> C[释放旧的地址空间]
|
|||
|
|
C --> D[加载新的代码段]
|
|||
|
|
D --> E[加载新的数据段]
|
|||
|
|
E --> F[设置新的栈]
|
|||
|
|
F --> G[跳转到新程序入口]
|
|||
|
|
|
|||
|
|
style A fill:#e1f5fe
|
|||
|
|
style G fill:#e8f5e9
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**exec 的关键特性**:
|
|||
|
|
- **不创建新进程**:只是替换当前进程的内容
|
|||
|
|
- **PID 不变**:进程还是原来的进程
|
|||
|
|
- **文件描述符保留**:打开的文件不会自动关闭(除非设置了 close-on-exec)
|
|||
|
|
|
|||
|
|
### 4. 进程组与会话
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TD
|
|||
|
|
A[会话 Session] --> B[前台进程组]
|
|||
|
|
A --> C[后台进程组1]
|
|||
|
|
A --> D[后台进程组2]
|
|||
|
|
|
|||
|
|
B --> B1[Shell]
|
|||
|
|
B --> B2[当前命令]
|
|||
|
|
|
|||
|
|
style A fill:#ffcdd2
|
|||
|
|
style B fill:#e8f5e9
|
|||
|
|
style C fill:#e1f5fe
|
|||
|
|
style D fill:#e1f5fe
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**进程组**:一组相关进程的集合
|
|||
|
|
- 用于信号的批量发送
|
|||
|
|
- 用于作业控制(前台/后台切换)
|
|||
|
|
|
|||
|
|
**会话**:一个用户登录期间的所有进程
|
|||
|
|
- 一个终端对应一个会话
|
|||
|
|
- 会话有一个控制终端
|
|||
|
|
|
|||
|
|
### 5. 守护进程
|
|||
|
|
|
|||
|
|
守护进程是在后台运行的特殊进程,没有控制终端:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TD
|
|||
|
|
A[创建子进程] --> B[父进程退出]
|
|||
|
|
B --> C[创建新会话]
|
|||
|
|
C --> D[改变工作目录]
|
|||
|
|
D --> E[关闭文件描述符]
|
|||
|
|
E --> F[重定向 stdin/stdout/stderr]
|
|||
|
|
F --> G[进入主循环]
|
|||
|
|
|
|||
|
|
style A fill:#e1f5fe
|
|||
|
|
style G fill:#e8f5e9
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 💻 动手实践
|
|||
|
|
|
|||
|
|
### 示例1:实现简单的 Shell
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
// myshell.c - 简单的 Shell 实现
|
|||
|
|
#include <stdio.h>
|
|||
|
|
#include <stdlib.h>
|
|||
|
|
#include <string.h>
|
|||
|
|
#include <unistd.h>
|
|||
|
|
|
|||
|
|
int main(void) {
|
|||
|
|
int ret, i, k, len, pid;
|
|||
|
|
char cmd[100]; // 命令串,最多100个字符
|
|||
|
|
char *arg[20]; // 参数数组,最多20个参数
|
|||
|
|
|
|||
|
|
printf("%% "); // 打印提示符
|
|||
|
|
|
|||
|
|
fgets(cmd, 100, stdin); // 从标准输入读取一行命令
|
|||
|
|
|
|||
|
|
// 将命令串中的空格替换成'\0',并将各参数提取出来
|
|||
|
|
// 例如 cmd[100]="ls -l -a\0",替换后变为 cmd="ls\0-l\0-a\0"
|
|||
|
|
len = strlen(cmd);
|
|||
|
|
cmd[len - 1] = '\0'; // 去掉 fgets 加在串尾的换行符
|
|||
|
|
|
|||
|
|
for (i = 0; i < len - 1; i++)
|
|||
|
|
if (cmd[i] == ' ') cmd[i] = '\0';
|
|||
|
|
|
|||
|
|
// 准备参数数组 arg
|
|||
|
|
// arg[0]=cmd, arg[1]=cmd+3, arg[2]=cmd+6, arg[3]=NULL
|
|||
|
|
arg[0] = cmd;
|
|||
|
|
k = 1;
|
|||
|
|
for (i = 1; i < len - 1; i++) {
|
|||
|
|
if (cmd[i] == '\0') {
|
|||
|
|
arg[k] = cmd + i + 1;
|
|||
|
|
k++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
arg[k] = NULL;
|
|||
|
|
|
|||
|
|
pid = fork();
|
|||
|
|
if (pid == 0) {
|
|||
|
|
ret = execvp(arg[0], arg); // 子进程执行命令
|
|||
|
|
if (ret == -1) {
|
|||
|
|
perror("exec error");
|
|||
|
|
exit(1);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
wait(-1); // 父进程等待子进程结束
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**编译运行**:
|
|||
|
|
```bash
|
|||
|
|
gcc -o myshell myshell.c
|
|||
|
|
./myshell
|
|||
|
|
% ls -l
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 示例2:后台运行
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
// shellex.c - 支持后台运行的 Shell
|
|||
|
|
#include "wrapper.h"
|
|||
|
|
|
|||
|
|
#define MAXARGS 128
|
|||
|
|
|
|||
|
|
int parseline(char *buf, char **argv) {
|
|||
|
|
char *delim;
|
|||
|
|
int argc;
|
|||
|
|
int bg; // 后台作业标志
|
|||
|
|
|
|||
|
|
buf[strlen(buf) - 1] = ' '; // 用空格替换末尾换行
|
|||
|
|
while (*buf && (*buf == ' '))
|
|||
|
|
buf++;
|
|||
|
|
|
|||
|
|
argc = 0;
|
|||
|
|
while ((delim = strchr(buf, ' '))) {
|
|||
|
|
argv[argc++] = buf;
|
|||
|
|
*delim = '\0';
|
|||
|
|
buf = delim + 1;
|
|||
|
|
while (*buf && (*buf == ' '))
|
|||
|
|
buf++;
|
|||
|
|
}
|
|||
|
|
argv[argc] = NULL;
|
|||
|
|
|
|||
|
|
if (argc == 0) return 1;
|
|||
|
|
|
|||
|
|
// 检查是否应该在后台执行
|
|||
|
|
if ((bg = (*argv[argc - 1] == '&')) != 0)
|
|||
|
|
argv[--argc] = NULL;
|
|||
|
|
|
|||
|
|
return bg;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void execute(char *cmdline) {
|
|||
|
|
char *argv[MAXARGS];
|
|||
|
|
char buf[MAXLINE];
|
|||
|
|
int bg;
|
|||
|
|
pid_t pid;
|
|||
|
|
|
|||
|
|
strcpy(buf, cmdline);
|
|||
|
|
bg = parseline(buf, argv);
|
|||
|
|
|
|||
|
|
if (argv[0] == NULL) return; // 忽略空行
|
|||
|
|
|
|||
|
|
if ((pid = fork()) == 0) { // 子进程
|
|||
|
|
if (execvp(argv[0], argv) < 0) {
|
|||
|
|
printf("%s: Command not found.\n", argv[0]);
|
|||
|
|
exit(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!bg) { // 前台运行
|
|||
|
|
int status;
|
|||
|
|
if (waitpid(pid, &status, 0) < 0)
|
|||
|
|
perror("waitpid error");
|
|||
|
|
} else { // 后台运行
|
|||
|
|
printf("%d %s", pid, cmdline);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int main() {
|
|||
|
|
char cmdline[MAXLINE];
|
|||
|
|
while (1) {
|
|||
|
|
printf("%% ");
|
|||
|
|
fgets(cmdline, MAXLINE, stdin);
|
|||
|
|
if (feof(stdin)) exit(0);
|
|||
|
|
execute(cmdline);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**编译运行**:
|
|||
|
|
```bash
|
|||
|
|
gcc -o shellex shellex.c -L. -lwrapper
|
|||
|
|
./shellex
|
|||
|
|
% sleep 10 & # 后台运行
|
|||
|
|
% ps # 查看进程
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔗 知识关联
|
|||
|
|
- Shell 的 I/O 重定向在 [[04_文件IO编程]] 中的 dup2 有详细讲解
|
|||
|
|
- 守护进程在 [[09_网络编程]] 中会实际使用
|
|||
|
|
- 进程组在 [[11_处理机调度]] 中用于作业控制
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📝 思考题
|
|||
|
|
|
|||
|
|
1. **为什么 exec() 后文件描述符还保留?** 什么时候需要关闭它们?
|
|||
|
|
2. **写时复制的优势是什么?** 如果 fork() 时立即复制所有内存会怎样?
|
|||
|
|
3. **Shell 是怎么实现管道的?** 例如 `ls | grep .c` 的执行过程是什么?
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📚 扩展阅读
|
|||
|
|
- 《UNIX环境高级编程》第8章:进程控制
|
|||
|
|
- 《深入理解计算机系统》第8章:异常控制流
|
|||
|
|
- [Bash 源码](https://git.savannah.gnu.org/cgit/bash.git/)
|