【Linux】进程控制
一、进程创建
1.1 fork函数核心原理
fork函数是Linux进程创建的基石,其核心功能是从已存在进程(父进程)中创建一个新进程(子进程)。但初学者往往会被其特殊行为困惑,我们先从核心疑问入手:
问题解答
-
为什么fork有两个返回值?
- fork调用时,内核会为子进程分配内存和PCB,拷贝父进程部分数据结构,然后将子进程加入系统进程列表。
- 完成这些操作后,fork会返回两次:一次在父进程中返回子进程PID,一次在子进程中返回0。这是因为fork执行到返回阶段时,父子进程已同时存在,内核会分别向两个进程返回结果。
-
为什么父进程返回子进程PID,子进程返回0?
- 父进程可能创建多个子进程,需要通过PID唯一标识每个子进程,以便后续管理(如等待子进程退出)。
- 子进程只有一个父进程,通过getppid()即可获取父进程PID,返回0是为了明确区分子进程身份,简化逻辑判断。
-
为什么fork后父子进程执行顺序不确定?
- fork完成后,父子进程处于就绪状态,CPU调度权由操作系统调度器决定,没有固定的执行顺序。可能父进程先执行,也可能子进程先执行。
基础用法示例
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void) {
pid_t pid;
printf("Before: pid is %d\n", getpid()); // 仅父进程执行
if ((pid = fork()) == -1) {
perror("fork() failed");
exit(1);
}
// 以下代码父子进程都会执行
printf("After: pid is %d, fork return %d\n", getpid(), pid);
sleep(1);
return 0;
}
运行结果:
Before: pid is 43676
After: pid is 43676, fork return 43677 // 父进程:返回子进程PID
After: pid is 43677, fork return 0 // 子进程:返回0
1.2 写时拷贝(Copy-On-Write)机制
核心原理
fork创建子进程时,内核不会立即拷贝父进程的全部数据(代码段、数据段等),而是让父子进程共享这些资源。只有当任意一方试图修改数据时,才会触发拷贝操作,为修改方创建独立副本。
问题:为什么需要写时拷贝?
- 节省内存资源 :如果子进程创建后只是读取数据(如执行
ls命令),无需拷贝数据,直接共享可大幅减少内存占用。 - 提高创建效率:避免创建时的大量拷贝操作,让进程创建速度更快(延时分配思想的典型应用)。
写时拷贝流程示意图
| 操作阶段 | 父子进程内存关系 |
|---|---|
| 未修改数据 | 共享物理内存页,页表项标记为只读 |
| 一方修改数据 | 触发页错误,内核为修改方拷贝物理内存页,更新页表指向新页 |
1.3 vfork函数(特殊创建方式)
与fork的区别
- vfork创建的子进程会与父进程共享地址空间(不触发写时拷贝),子进程先执行,父进程会被阻塞直到子进程调用exit或exec。
- 风险提示:子进程修改数据会直接影响父进程,容易导致程序异常,现代Linux中已较少使用,建议优先使用fork。
1.4 fork调用失败的常见原因
- 系统进程数量达到上限,无法创建新的PCB结构。
- 实际用户的进程数超过了系统限制(可通过
ulimit -u查看限制)。
二、进程终止
2.1 进程终止的本质与退出场景
进程终止的核心是释放系统资源,包括PCB、内存空间、打开的文件描述符等。常见退出场景分为三类:
- 代码运行完毕,结果正确(如
ls命令执行完成)。 - 代码运行完毕,结果不正确(如除法运算中除数为0)。
- 代码异常终止(如按下
Ctrl+C触发信号中断)。
2.2 常见退出方法与区别(重点)
正常终止方式
-
return退出(main函数专用)
- 执行
return n等同于exit(n),main函数的返回值会作为进程退出码。 - 疑问:为什么其他函数return不能终止进程?因为return仅退出当前函数,而main函数是进程的入口,退出main函数即意味着进程结束。
- 执行
-
exit函数(标准库函数)
- 头文件:
#include <stdlib.h> - 函数原型:
void exit(int status); - 核心特性:退出前会完成三件事:执行用户通过
atexit注册的清理函数、刷新并关闭所有打开的文件流、最终调用_exit函数。
- 头文件:
-
_exit函数(系统调用)
- 头文件:
#include <unistd.h> - 函数原型:
void _exit(int status); - 核心特性:直接终止进程,不执行清理函数,不刷新文件缓存,速度更快。
- 头文件:
对比:exit与_exit的区别
c
// 示例1:使用exit
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("hello"); // 缓冲区数据未刷新
exit(0); // 退出前刷新缓冲区,数据会输出
}
// 运行结果:hello
// 示例2:使用_exit
#include <stdio.h>
#include <unistd.h>
int main() {
printf("hello"); // 缓冲区数据未刷新
_exit(0); // 直接退出,不刷新缓冲区
}
// 运行结果:无输出
异常终止方式
- 外部信号触发:如
Ctrl+C发送SIGINT信号,kill -9发送SIGKILL信号。 - 进程自身异常:如访问非法内存地址触发
SIGSEGV信号(段错误)。
2.3 退出码与状态查看
核心概念
退出码用于表示进程的退出状态,0表示成功,非0表示失败(不同非0值对应不同错误类型)。
问题:如何查看进程退出码?
- 在Shell中,通过
echo $?命令查看上一个进程的退出码。 - 注意:
$?仅保留最近一次进程的退出码,执行新命令后会被覆盖。
常见退出码含义
| 退出码 | 解释 | 典型场景 |
|---|---|---|
| 0 | 命令执行成功 | ls、pwd等正常执行 |
| 1 | 通用错误 | 除数为0、参数不匹配 |
| 2 | 命令使用不当 | 传递无效参数给命令 |
| 126 | 权限拒绝 | 执行无执行权限的脚本 |
| 127 | 命令未找到 | 输入错误命令(如lss) |
| 130 | Ctrl+C终止 | 手动中断进程 |
| 143 | SIGTERM终止 | kill命令默认信号终止 |
三、进程等待
3.1 进程等待的必要性(重点)
核心问题:僵尸进程的危害
子进程退出后,若父进程未及时处理其退出状态,子进程会变成僵尸进程(Z状态),其PCB会一直保留在系统中,导致内存泄漏。更严重的是,僵尸进程无法通过kill -9强制删除,只能通过终止父进程间接清理。
进程等待的作用
- 回收子进程资源,避免僵尸进程。
- 获取子进程退出信息(正常退出码或异常终止信号),判断任务执行结果。
3.2 两种等待方法:wait与waitpid
3.2.1 wait函数(简单阻塞等待)
- 头文件:
#include <sys/wait.h>和#include <sys/types.h> - 函数原型:
pid_t wait(int *status); - 返回值:成功返回被等待子进程PID,失败返回-1(如无子进程)。
- 参数说明:
status为输出型参数,用于存储子进程退出状态,不关心可设为NULL。
3.2.2 waitpid函数(灵活等待)
- 函数原型:
pid_t waitpid(pid_t pid, int *status, int options); - 核心优势:支持指定子进程、非阻塞等待,功能更强大。
参数详解(重点)
| 参数 | 取值与含义 |
|---|---|
| pid | -1:等待任意子进程(同wait);>0:等待PID等于该值的子进程;0:等待同组子进程 |
| status | 输出型参数,存储退出状态,需通过宏解析 |
| options | 0:阻塞等待(子进程未退出时父进程暂停);WNOHANG:非阻塞等待(子进程未退出时立即返回0) |
3.3 退出状态解析(status参数处理)
问题:status参数为什么不能直接当整数用?
status是一个32位整数,其低16位存储退出状态信息,需通过系统提供的宏进行解析,直接读取会得到错误结果。
关键解析宏
| 宏 | 功能 |
|---|---|
| WIFEXITED(status) | 判断子进程是否正常退出,正常退出返回真 |
| WEXITSTATUS(status) | 若WIFEXITED为真,提取子进程退出码(仅低8位有效) |
| WIFSIGNALED(status) | 判断子进程是否被信号终止,是则返回真 |
| WTERMSIG(status) | 若WIFSIGNALED为真,提取终止子进程的信号编号 |
实战示例:解析子进程退出状态
c
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
exit(1);
}
if (pid == 0) {
// 子进程:睡眠20秒后正常退出,退出码为10
sleep(20);
exit(10);
} else {
int status;
pid_t ret = wait(&status);
if (ret > 0) {
// 判断是否正常退出
if (WIFEXITED(status)) {
printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
}
// 判断是否被信号终止
else if (WIFSIGNALED(status)) {
printf("子进程被信号终止,信号编号:%d\n", WTERMSIG(status));
}
}
}
return 0;
}
测试结果:
- 正常等待20秒:输出
子进程正常退出,退出码:10 - 另一个终端执行
kill -9 子进程PID:输出子进程被信号终止,信号编号:9
3.4 阻塞等待与非阻塞等待
阻塞等待(默认方式)
- 特点:父进程暂停执行,直到子进程退出后才继续运行(如上述示例)。
- 适用场景:父进程无需执行其他任务,只需等待子进程完成。
非阻塞等待(WNOHANG选项)
- 特点:父进程发起等待后立即返回,若子进程未退出则返回0,可继续执行其他任务,定期检查子进程状态。
- 适用场景:父进程需要同时处理多个任务(如服务器处理多个客户端请求)。
非阻塞等待实战示例
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程睡眠5秒后退出
printf("子进程运行中,PID:%d\n", getpid());
sleep(5);
exit(1);
} else {
int status = 0;
pid_t ret = 0;
do {
// 非阻塞等待:子进程未退出时返回0
ret = waitpid(-1, &status, WNOHANG);
if (ret == 0) {
printf("子进程仍在运行,父进程执行其他任务...\n");
sleep(1); // 模拟父进程其他任务
}
} while (ret == 0); // 直到子进程退出(ret != 0)
// 解析退出状态
if (WIFEXITED(status)) {
printf("子进程退出,退出码:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
四、进程程序替换:exec函数簇实战
4.1 替换原理与核心疑问
核心原理
进程调用exec函数后,其用户空间的代码和数据会被全新程序替换,从新程序的启动例程开始执行。注意:exec不会创建新进程,进程PID保持不变。
问题:exec替换后,原进程的代码还会执行吗?
- 若exec调用成功,原进程的代码和数据被完全替换,exec之后的代码不会执行(新程序从main函数开始运行)。
- 若exec调用失败(如程序路径错误),则会返回-1,继续执行后续代码。
4.2 exec函数簇解析(6个函数的区别与记忆技巧)
exec函数簇包含6个函数,核心区别在于参数格式、是否自动搜索路径、是否自定义环境变量。记忆技巧:通过函数名后缀字母快速区分功能。
后缀字母含义
- l(list):参数采用列表形式,以NULL结尾。
- v(vector):参数采用字符串数组形式,数组最后一个元素为NULL。
- p(path):自动搜索环境变量PATH,无需指定程序全路径。
- e(env):自定义环境变量,需传入环境变量数组。
函数对比表
| 函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 | 示例 |
|---|---|---|---|---|
| execl | 列表 | 否(需全路径) | 是 | execl("/bin/ls", "ls", "-l", NULL); |
| execlp | 列表 | 是(自动搜PATH) | 是 | execlp("ls", "ls", "-l", NULL); |
| execle | 列表 | 否 | 否(自定义环境) | execle("/bin/ls", "ls", "-l", NULL, envp); |
| execv | 数组 | 否 | 是 | execv("/bin/ls", argv);(argv为字符串数组) |
| execvp | 数组 | 是 | 是 | execvp("ls", argv); |
| execve | 数组 | 否 | 否 | execve("/bin/ls", argv, envp); |
关键说明
- 只有execve是真正的系统调用,其他5个函数最终都调用execve。
- 参数列表/数组中,第一个参数必须是程序名(与实际程序名一致,可省略路径),最后一个参数必须是NULL,标记参数结束。
4.3 实战示例:exec函数用法
c
#include <unistd.h>
#include <stdlib.h>
int main() {
// 1. execlp:自动搜索PATH,列表参数
execlp("ps", "ps", "-ef", NULL);
// 2. execvp:自动搜索PATH,数组参数(若上面execlp成功,下面代码不会执行)
char *argv[] = {"ps", "-ef", NULL};
execvp("ps", argv);
// 3. 若exec调用失败,会执行以下代码
perror("exec failed");
exit(1);
}
4.4 常见错误与排查
- 参数列表未以NULL结尾:导致exec函数解析参数越界,调用失败。
- 程序路径错误且未使用p后缀 :如
execl("ls", "ls", NULL)会失败,需写全路径/bin/ls或用execlp。 - 自定义环境变量时未包含PATH :如execle调用时,环境变量数组需包含
PATH=/bin:/usr/bin,否则无法找到系统命令。
五、实战:手动实现微型Shell命令行解释器
5.1 Shell运行原理(初学者必懂)
Shell本质是一个循环执行的程序,核心流程为:
- 打印命令提示符(如
[root@localhost ~]#)。 - 读取用户输入的命令(如
ls -l)。 - 解析命令(拆分命令名和参数)。
- 创建子进程(fork)。
- 子进程执行程序替换(exec),运行命令。
- 父进程等待子进程退出(waitpid)。
- 重复上述步骤。
问题:为什么cd、export等命令需要Shell自己执行?
这类命令称为内建命令,需要修改Shell进程自身的状态(如cd修改当前工作目录,export修改环境变量)。若通过子进程执行,修改的是子进程的状态,子进程退出后修改失效,因此必须由Shell进程直接执行。
5.2 微型Shell实现源码(带详细注释)
c
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
using namespace std;
const int BASE_SIZE = 1024; // 命令缓冲区大小
const int ARGV_NUM = 64; // 命令参数最大数量
const int ENV_NUM = 64; // 环境变量最大数量
char *g_argv[ARGV_NUM]; // 命令参数数组
int g_argc = 0; // 参数个数
int g_last_code = 0; // 上一个命令的退出码
char *g_env[ENV_NUM]; // 自定义环境变量数组
char g_pwd[BASE_SIZE]; // 当前工作目录
// 去除字符串首尾空格
#define TRIM_SPACE(pos) do { \
while (isspace(*pos)) pos++; \
} while (0)
// 获取用户名
string GetUserName() {
string name = getenv("USER");
return name.empty() ? "None" : name;
}
// 获取主机名
string GetHostName() {
string hostname = getenv("HOSTNAME");
return hostname.empty() ? "None" : hostname;
}
// 获取当前工作目录
string GetPwd() {
if (nullptr == getcwd(g_pwd, sizeof(g_pwd))) return "None";
char pwd_env[BASE_SIZE];
snprintf(pwd_env, sizeof(pwd_env), "PWD=%s", g_pwd);
putenv(pwd_env); // 更新环境变量中的PWD
return g_pwd;
}
// 获取当前目录名称(如/home/root → root)
string GetLastDir() {
string curr = GetPwd();
if (curr == "/" || curr == "None") return curr;
size_t pos = curr.rfind("/");
return pos == string::npos ? curr : curr.substr(pos + 1);
}
// 打印命令提示符
void PrintPrompt() {
string prompt = "[" + GetUserName() + "@" + GetHostName() + " " + GetLastDir() + "]# ";
printf("%s", prompt.c_str());
fflush(stdout); // 刷新缓冲区,确保提示符立即显示
}
// 读取用户输入的命令
bool ReadCommand(char *buf, int size) {
char *ret = fgets(buf, size, stdin);
if (!ret) return false; // 读取失败(如EOF)
buf[strlen(buf) - 1] = '\0'; // 去除换行符
return strlen(buf) != 0; // 空命令返回false
}
// 解析命令(拆分命令名和参数)
void ParseCommand(char *buf) {
memset(g_argv, 0, sizeof(g_argv));
g_argc = 0;
TRIM_SPACE(buf); // 去除开头空格
const char *sep = " ";
// 拆分第一个参数(命令名)
g_argv[g_argc++] = strtok(buf, sep);
// 拆分剩余参数
while ((g_argv[g_argc++] = strtok(nullptr, sep)));
g_argc--; // 去掉最后一个NULL的计数
}
// 执行内建命令(cd、export、env、echo)
bool ExecBuiltinCommand() {
// cd命令:切换工作目录
if (strcmp(g_argv[0], "cd") == 0) {
if (g_argc == 2) {
chdir(g_argv[1]); // 修改Shell进程自身的工作目录
}
g_last_code = 0;
return true;
}
// export命令:添加环境变量
else if (strcmp(g_argv[0], "export") == 0) {
if (g_argc == 2) {
int i = 0;
while (g_env[i]) i++;
g_env[i] = (char*)malloc(strlen(g_argv[1]) + 1);
strcpy(g_env[i], g_argv[1]);
g_env[i + 1] = nullptr;
}
g_last_code = 0;
return true;
}
// env命令:打印环境变量
else if (strcmp(g_argv[0], "env") == 0) {
for (int i = 0; g_env[i]; i++) {
printf("%s\n", g_env[i]);
}
g_last_code = 0;
return true;
}
// echo命令:打印内容(支持$?查看退出码)
else if (strcmp(g_argv[0], "echo") == 0) {
if (g_argc == 2) {
if (g_argv[1][0] == '$' && g_argv[1][1] == '?') {
printf("%d\n", g_last_code); // 打印上一个命令的退出码
} else {
printf("%s\n", g_argv[1]);
}
}
g_last_code = 0;
return true;
}
return false; // 非内建命令
}
// 执行外部命令(通过fork+exec)
bool ExecExternalCommand() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return false;
} else if (pid == 0) {
// 子进程:执行程序替换
execvpe(g_argv[0], g_argv, g_env);
// 若exec返回,说明执行失败
perror("exec failed");
exit(1);
} else {
// 父进程:等待子进程退出
int status;
waitpid(pid, &status, 0);
// 更新退出码
if (WIFEXITED(status)) {
g_last_code = WEXITSTATUS(status);
} else {
g_last_code = 100; // 异常退出码
}
}
return true;
}
// 初始化环境变量(从父Shell继承)
void InitEnv() {
extern char **environ;
int i = 0;
while (environ[i]) {
g_env[i] = (char*)malloc(strlen(environ[i]) + 1);
strcpy(g_env[i], environ[i]);
i++;
}
g_env[i] = nullptr;
}
int main() {
InitEnv(); // 初始化环境变量
char command_buf[BASE_SIZE];
while (true) {
PrintPrompt(); // 1. 打印提示符
if (!ReadCommand(command_buf, BASE_SIZE)) continue; // 2. 读取命令
ParseCommand(command_buf); // 3. 解析命令
if (ExecBuiltinCommand()) continue; // 4. 执行内建命令
ExecExternalCommand(); // 5. 执行外部命令
}
return 0;
}
5.3 编译与测试
编译命令
bash
g++ -o myshell myshell.cpp
测试步骤
- 运行微型Shell:
./myshell - 执行内建命令:
cd ..:切换目录,执行echo $PWD验证。export MYENV=hello:添加环境变量,执行env查看。echo $?:查看上一个命令的退出码(成功为0)。
- 执行外部命令:
ls -l:列出当前目录文件。ps:查看进程信息。
5.4 核心知识点总结
- 内建命令与外部命令的区别:内建命令由Shell进程直接执行,外部命令通过子进程执行。
- 环境变量的继承性:子进程会继承父进程的环境变量,Shell的环境变量修改会影响其创建的子进程。
- 命令行解析的核心:将用户输入的字符串拆分为命令名和参数数组,为exec函数做准备。
六、总结与进阶方向
本文从进程创建、终止、等待、程序替换四个核心操作入手,结合实战代码解答了初学者的常见疑问,最终通过微型Shell的实现,将所有知识点串联起来。掌握这些内容后,你已具备Linux进程控制的核心能力。
进阶学习方向
- 进程间通信(IPC):管道、消息队列、共享内存等。
- 信号与信号处理:深入理解
SIGINT、SIGCHLD等信号的使用。 - 线程管理:对比进程与线程的区别,学习 pthread 库的使用。
- Shell高级功能:实现重定向(
>、<)、管道(|)、后台运行(&)等。