【Linux】进程控制

【Linux】进程控制

一、进程创建

1.1 fork函数核心原理

fork函数是Linux进程创建的基石,其核心功能是从已存在进程(父进程)中创建一个新进程(子进程)。但初学者往往会被其特殊行为困惑,我们先从核心疑问入手:

问题解答
  1. 为什么fork有两个返回值?

    • fork调用时,内核会为子进程分配内存和PCB,拷贝父进程部分数据结构,然后将子进程加入系统进程列表。
    • 完成这些操作后,fork会返回两次:一次在父进程中返回子进程PID,一次在子进程中返回0。这是因为fork执行到返回阶段时,父子进程已同时存在,内核会分别向两个进程返回结果。
  2. 为什么父进程返回子进程PID,子进程返回0?

    • 父进程可能创建多个子进程,需要通过PID唯一标识每个子进程,以便后续管理(如等待子进程退出)。
    • 子进程只有一个父进程,通过getppid()即可获取父进程PID,返回0是为了明确区分子进程身份,简化逻辑判断。
  3. 为什么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、内存空间、打开的文件描述符等。常见退出场景分为三类:

  1. 代码运行完毕,结果正确(如ls命令执行完成)。
  2. 代码运行完毕,结果不正确(如除法运算中除数为0)。
  3. 代码异常终止(如按下Ctrl+C触发信号中断)。

2.2 常见退出方法与区别(重点)

正常终止方式
  1. return退出(main函数专用)

    • 执行return n等同于exit(n),main函数的返回值会作为进程退出码。
    • 疑问:为什么其他函数return不能终止进程?因为return仅退出当前函数,而main函数是进程的入口,退出main函数即意味着进程结束。
  2. exit函数(标准库函数)

    • 头文件:#include <stdlib.h>
    • 函数原型:void exit(int status);
    • 核心特性:退出前会完成三件事:执行用户通过atexit注册的清理函数、刷新并关闭所有打开的文件流、最终调用_exit函数。
  3. _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 命令执行成功 lspwd等正常执行
1 通用错误 除数为0、参数不匹配
2 命令使用不当 传递无效参数给命令
126 权限拒绝 执行无执行权限的脚本
127 命令未找到 输入错误命令(如lss
130 Ctrl+C终止 手动中断进程
143 SIGTERM终止 kill命令默认信号终止

三、进程等待

3.1 进程等待的必要性(重点)

核心问题:僵尸进程的危害

子进程退出后,若父进程未及时处理其退出状态,子进程会变成僵尸进程(Z状态),其PCB会一直保留在系统中,导致内存泄漏。更严重的是,僵尸进程无法通过kill -9强制删除,只能通过终止父进程间接清理。

进程等待的作用
  1. 回收子进程资源,避免僵尸进程。
  2. 获取子进程退出信息(正常退出码或异常终止信号),判断任务执行结果。

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 常见错误与排查

  1. 参数列表未以NULL结尾:导致exec函数解析参数越界,调用失败。
  2. 程序路径错误且未使用p后缀 :如execl("ls", "ls", NULL)会失败,需写全路径/bin/ls或用execlp
  3. 自定义环境变量时未包含PATH :如execle调用时,环境变量数组需包含PATH=/bin:/usr/bin,否则无法找到系统命令。

五、实战:手动实现微型Shell命令行解释器

5.1 Shell运行原理(初学者必懂)

Shell本质是一个循环执行的程序,核心流程为:

  1. 打印命令提示符(如[root@localhost ~]#)。
  2. 读取用户输入的命令(如ls -l)。
  3. 解析命令(拆分命令名和参数)。
  4. 创建子进程(fork)。
  5. 子进程执行程序替换(exec),运行命令。
  6. 父进程等待子进程退出(waitpid)。
  7. 重复上述步骤。
问题:为什么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
测试步骤
  1. 运行微型Shell:./myshell
  2. 执行内建命令:
    • cd ..:切换目录,执行echo $PWD验证。
    • export MYENV=hello:添加环境变量,执行env查看。
    • echo $?:查看上一个命令的退出码(成功为0)。
  3. 执行外部命令:
    • ls -l:列出当前目录文件。
    • ps:查看进程信息。

5.4 核心知识点总结

  • 内建命令与外部命令的区别:内建命令由Shell进程直接执行,外部命令通过子进程执行。
  • 环境变量的继承性:子进程会继承父进程的环境变量,Shell的环境变量修改会影响其创建的子进程。
  • 命令行解析的核心:将用户输入的字符串拆分为命令名和参数数组,为exec函数做准备。

六、总结与进阶方向

本文从进程创建、终止、等待、程序替换四个核心操作入手,结合实战代码解答了初学者的常见疑问,最终通过微型Shell的实现,将所有知识点串联起来。掌握这些内容后,你已具备Linux进程控制的核心能力。

进阶学习方向

  1. 进程间通信(IPC):管道、消息队列、共享内存等。
  2. 信号与信号处理:深入理解SIGINTSIGCHLD等信号的使用。
  3. 线程管理:对比进程与线程的区别,学习 pthread 库的使用。
  4. Shell高级功能:实现重定向(><)、管道(|)、后台运行(&)等。
相关推荐
Miraitowa_cheems2 小时前
LeetCode算法日记 - Day 104: 通配符匹配
linux·数据结构·算法·leetcode·深度优先·动态规划
fengyehongWorld2 小时前
Linux stat命令
linux
人工智能训练3 小时前
Docker中容器的备份方法和步骤
linux·运维·人工智能·ubuntu·docker·容器·nvidia
MasonYyp3 小时前
Docker安装和使用kkfileview
运维·docker·容器
渡我白衣3 小时前
深入 Linux 内核启动:从按下电源到用户登录的全景解剖
java·linux·运维·服务器·开发语言·c++·人工智能
三川6983 小时前
1. 网络编程基础
开发语言·网络
代码炼金术士3 小时前
linux的nginx版本升级
linux·运维·nginx
讨厌下雨的天空3 小时前
进程优先级
linux·服务器
大柏怎么被偷了3 小时前
【Linux】版本控制器git
linux·运维·服务器