【进程】:时间片上的舞者,状态机里的棋子

前言:进程是我们Linux系统编程的三座大山之一,是极其重要的一环,是体系结构有质的飞跃的一环,一到七节偏理论,八至十一节偏操作,这篇的上下文有着极其紧密的关联,大家学习时有不懂的可以先往后看,同时注意课后实操!话不多说,开始学习!

目录

一、操作系统

1. 冯诺依曼体系结构

(1)认识结构

(2)体系理解

2. 理解操作系统

3. 系统调用与库函数

二、进程的基本概念与操作

1. 概念

2. PCB

3. 基本操作

4. fork创建进程

三、进程状态

1. 运行状态与就绪状态 (R - Running / Runnable)

2. 睡眠状态 (S & D)

3. 停止状态 (T - Stopped)

4. 僵尸状态 (Z - Zombie)

5. 死亡状态 (X - Dead)

6.孤儿进程(Orphan Process)

四、进程的优先级

1. 基本概念

2. PR与NI

3. 有关优先级的命令

4. 概念补充

五、进程的切换与调度

1. 对 task_struct 的深入理解

list_head的优点

2. 进程的切换

3. O(1)调度算法

(1)双优先级数组机制

(2)基于位图(Bitmap)的快速查找

(3)运行周期的“翻转”逻辑

六、环境变量

1.命令行参数

2. 环境变量

(1)概念

(2)常见环境变量的意义

(3)有关环境变量的命令

(4)通过代码获取环境变量

(5)两个库函数

(6)环境变量的继承性

(7)本地变量

3. 内建命令

七、程序地址空间(一)

1. 演进过程

2. mm_struct与vm_area_struct

3. 与物理地址的联系

4. 程序地址空间的意义

八、进程的创建和终止

1. fork函数

2. 写时拷贝

3. 进程退出

(1)进程终止的三种场景

(2)常见的退出方法

(3)退出码

九、进程等待

1. 概念及原因

2. wait方法

3. waitpid方法

(1)第一个参数pid

(2)第二个参数status

(3)第三个参数options

十、进程程序替换

1. 替换原理

2. 替换函数

十一、自主搭建shell



一、操作系统

1. 冯诺依曼体系结构

(1)认识结构

  • 这里的存储器指的是内存
  • 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)(数据层面)
  • 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
  • 所有设备都只能直接和内存打交道

(2)体系理解

软件运行,为什么必须先加载?

▶速度匹配:CPU 的运算速度极快(纳秒级),而存放软件的磁盘(外设)速度极慢(毫秒级)。如果 CPU 直接从磁盘读取指令,会被活活“饿死”。内存作为高速缓冲区,能让 CPU 以接近其极限的速度获取指令。这也是为什么所有设备都只能直接和内存打交道的关键。

▶体系限制:根据冯·诺依曼体系结构,CPU 在物理链路上只与存储器(内存)直接相连。CPU 无法直接去磁盘上“翻找”代码,必须先由加载器将代码搬运到 CPU 能直接对话的场所——内存中。

程序运行之前,在哪里?

        程序在被你点击运行之前,它是一份静态的文件,存放在磁盘(外部存储器):它是以二进制格式(如 Linux 下的 ELF 格式)保存在硬盘、SSD 或 U 盘等外设中的。此时它不占用 CPU,也不占用内存,只占用磁盘空间。只有当你发起运行指令时,系统才会通过 exec 类调用触发加载器,把它从磁盘这个“仓库”搬进内存这个“车间”。

数据是怎样流动的(以qq发送消息为例)

▶当你打开窗口并输入“你好”点击发送时,数据流动如下:

  • 输入阶段:你通过键盘(输入设备)敲击字符。键盘驱动程序捕捉到电信号,将其转化为扫描码。
  • 存入内存:CPU 接收到输入中断,将这些字符数据通过总线存入 QQ 进程的内存空间
  • 加工处理CPU 从内存中读取字符,进行封装(加上你的 QQ 号、朋友的 QQ 号、加密协议等);处理完的“数据包”再次存回内存
  • 网络发送CPU 发出指令给网卡(输出设备);内存中封装好的数据包通过 DMA(直接存储器访问)技术,直接流向网卡;网卡将数据转化为电磁波信号,发往腾讯服务器,最终到达朋友的电脑。

▶发送文件的过程在体系结构上与发消息类似:

  • 外存到内存:文件的源头是磁盘(外设/存储设备),CPU 指令触发加载动作,将文件数据从磁盘搬运到内存
  • 分片处理:因为文件通常很大(如 1GB),内存放不下。CPU 会配合内存采用“分片”策略:搬一坨数据进内存 ,打包,送往网卡,再搬下一坨。
  • 绕过 CPU (DMA 技术):为了不让 CPU 被这种大规模的数据搬运累死,现代系统使用 DMA 硬件。它允许数据直接在 磁盘 到 内存内存到网卡 之间流动,CPU 只需在开头下个令,在结尾收个工。

2. 理解操作系统

(1)设计OS的目的:对下,与硬件交互,管理所有的软硬件资源对上,为用户程序(应用程序)提供一个良好的执行环境核心功能

(2)OS核心功能:在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件

(3)如何理解 "管理":描述被管理对象,组织被管理对象。即先描述再组织!

3. 系统调用与库函数

        在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发

特性系统调用库函数
层级内核层接口应用层接口
执行模式切换到内核态保持在用户态
效率每次调用开销较大开销小(带有缓冲优化)
移植性较差(依赖具体 OS)较好(如 C 标准库跨平台)
典型案例_exit()(直接杀进程)exit()(先刷缓冲区再杀进程)

二、进程的基本概念与操作

1. 概念

  • 课本概念:程序的一个执行实例,正在执行的程序等
  • 内核观点:担当分配系统资源(CPU时间,内存)的实体。
  • 当前:进程 = 内核数据结构(task_struct) + 自己的程序代码和数据

2. PCB

        进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。课本上称之为PCB(process control block), Linux 操作系统下的 PCB 是: task_struct,即task_struct是PCB的一种,在 Linux 中描述进程的结构体叫做 task_struct ,task_struct 是 Linux 内核的一种数据结构类型,它会被装载到RAM(内存)里并且包含着进程的信息。task_struct是一个极其复杂的结构体,在宏观上可以理解为他们互相以双向链表的形式相连。

task_struct内容分类

  • 标标识符: 描述本进程的唯一标识符,用来区别其他进程。
  • 状态: 任务状态,退出代码,退出信号等。
  • 优先级: 相对于其他进程的优先级。
  • 程序计数器: 程序中即将被执行的下一条指令的地址。
  • 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
  • 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
  • I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
  • 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
  • 其他信息

3. 基本操作

[yhz@VM-0-5-opencloudos lesson12]$ cat test.c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    printf("子pid: %d\n", getpid());
    printf("父ppid: %d\n", getppid());
    sleep(1000);
    return 0;
}
[yhz@VM-0-5-opencloudos lesson12]$ ./test
子pid: 3951409
父ppid: 3948656
...

[yhz@VM-0-5-opencloudos ~]$ ps aux | grep test | grep -v grep
yhz      3951409  0.0  0.0   2548  1588 pts/1    S+   21:47   0:00 ./test
[yhz@VM-0-5-opencloudos ~]$ ls /proc | grep 395
1395
395
3950911
3950966
3951409
3952106
3952107
[yhz@VM-0-5-opencloudos ~]$ cd /proc/3951409
[yhz@VM-0-5-opencloudos 3951409]$ ll | grep cwd && ll | grep exe
lrwxrwxrwx  1 yhz yhz 0 Feb 22 21:47 cwd -> /home/yhz/lesson12
lrwxrwxrwx  1 yhz yhz 0 Feb 22 21:47 exe -> /home/yhz/lesson12/test
维度cwd (Current Working Directory)exe (Executable)
直白含义当前工作目录可执行程序文件
指向目标指向一个目录(文件夹)指向一个二进制文件(程序)
决定因素决定了程序读写相对路径文件的位置决定了程序运行的代码逻辑

        进程的 cwd 是其运行环境的一部分,通过 chdir() 函数可以随时动态修改,影响后续相对路径文件的读写位置;而 exe 则是进程的身份底色,始终指向启动该进程的二进制程序文件路径。

4. fork创建进程

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int a = 10;

int main() {
    pid_t pid = fork(); // 创建子进程

    if (pid < 0) 
    {
        // fork() 失败
        fprintf(stderr, "fork() 失败\n");
        return 1;
    }
    else if (pid == 0) 
    {
        // 子进程执行的代码
        printf("这是子进程, PID: %d, 父进程 PID: %d\n", getpid(), getppid());
        a += 100;
        printf("son a:%d\n", a);
    }
    else 
    {
        // 父进程执行的代码
        printf("这是父进程, PID: %d, 子进程 PID: %d\n", getpid(), pid);
        a += 10;
        printf("fat a:%d\n", a);
    }

    // 父子进程都会执行这里的代码
    printf("tot a:%d\n", a);
    printf("这条消息来自 PID 为 %d 的进程\n", getpid());

    return 0;
}

父子进程具有独立性!

PID为1的是什么进程?

        PID 为 1 的进程是 Linux 内核启动后在用户空间创建的第一个进程(现代系统通常是 systemd),它不仅是系统中所有进程的共同祖先,还承担着“孤儿进程领养人”的重任,负责回收被遗弃子进程的资源。作为系统的核心管理枢纽,它维系着整个操作系统的运行状态,一旦该进程失效,系统将无法继续正常工作。

为什么fork调用后函数执行不符合常理?

        核心原因是执行流的复制,fork() 调用点在内核中只执行了一次(复制操作),但返回操作在两个不同的进程上下文中各执行了一次。父子进程的地址空间是独立的,子进程的 pid 变量和父进程的 pid 变量位于不同的物理内存中fork() 之后,每个进程都有自己的栈空间,都有自己的 pid 变量副本。

三、进程状态

1. 运行状态与就绪状态 (R - Running / Runnable)

这是 ps 命令中最常见的状态。

  • Running:进程正在 CPU 上执行代码。

  • Runnable:进程已经准备好运行,但由于 CPU 正在处理其他任务,它正排在调度队列中等待被分配时间片。

  • 注意:在 Linux 中,这两者都统一标记为 R

2. 睡眠状态 (S & D)

当进程在等待某种资源(如等待用户输入、等待磁盘写入完成)时,会进入睡眠状态。

  • 可中断睡眠 (S - Interruptible Sleep):进程在等待某个事件,但它可以被信号(Signal)唤醒。比如你按 Ctrl+C 就能终止一个正在 sleep 的程序。

  • 不可中断睡眠 (D - Uninterruptible Sleep):通常是在等待 I/O 操作(如磁盘或网络驱动)。这种状态下的进程不接受任何信号(连 kill -9 也杀不掉),必须等到 I/O 设备响应。如果系统出现大量 D 状态进程,通常意味着硬件故障或压力过大。

3. 停止状态 (T - Stopped)

  • 触发方式:进程接收到 SIGSTOP 信号(如你在终端按 Ctrl+Z)时会进入此状态。

  • 恢复:发送 SIGCONT 信号可以向进程发出“继续运行”的指令。

4. 僵尸状态 (Z - Zombie)

这是一个非常特殊的“死亡”阶段。

  • 形成原因:子进程已经运行结束并退出了,但它的父进程还没有调用 wait()waitpid() 来读取子进程的退出状态。

  • 表现:僵尸进程不占用 CPU 和内存空间,但它会占用一个 PID(进程表条目)。

  • 危害:僵尸进程的主要危害在于对 PID 资源 的持续占用,由于 Linux 系统的 PID 总数有限,大量残留的僵尸进程会导致系统无法创建新任务。此外,僵尸进程会保留其在内核中的 PCB 结构 以记录退出信息,若父进程由于逻辑缺陷未及时回收(收尸),将造成内核资源的隐性浪费并可能引发系统崩溃。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() 
{
    pid_t id = fork();
    
    if (id == 0) 
    {
        // 子进程
        printf("我是子进程,PID: %d,我即将退出并进入僵尸状态...\n", getpid());
        exit(0); 
    }
    else 
    {
        // 父进程
        printf("我是父进程,PID: %d,我将陷入死循环且不回收子进程。\n", getpid());
        while (1) {
            // 父进程在此死循环,不调用 wait(),子进程将维持僵尸状态
            sleep(1);
        }
    }
    return 0;
}

5. 死亡状态 (X - Dead)

这是最终状态。当父进程读取了退出信息后,内核会将该进程的所有残留信息彻底从系统中抹去。你在 ps 命令中几乎看不到这个状态,因为它转瞬即逝。

6.孤儿进程(Orphan Process)

当一个父进程在子进程退出之前就已经终止(正常退出或异常崩溃),子进程就失去了它的直接父进程,从而变成“孤儿”。Linux 系统不会允许任何进程在没有父进程的情况下长期存在。一旦进程变成孤儿,内核会自动将其父进程修改为 PID 为 1 的进程。这种机制确保了孤儿进程在未来结束时,依然有一个父进程(PID 1)负责为它“收尸”(回收资源)。与僵尸进程不同,孤儿进程本身并无危害。它们依然是正常的 R(运行)或 S(睡眠)状态进程,正常占用 CPU 和内存。(我们刚刚fork()函数那个例子就是一个孤儿进程)

四、进程的优先级

1. 基本概念

        cpu资源分配的先后顺序,就是指进程的优先权(priority)。优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。

2. PR与NI

Nice 值 (NI):

        Nice 值是用户层面的优先级调整工具。它的取值范围是 -20 到 19-20是最高优先级(最“不客气”,抢占资源);19是最低优先级(最“客气”,只在没人用 CPU 时才用);默认值通常为 0。

  • 权限限制:普通用户只能调高 Nice 值(降低优先级),只有 root 用户可以调低 Nice 值(提高优先级)。

优先级 (PR)

      这是内核真正查看的优先级数值。对于普通进程,公式通常为:PR = 20 + NI。如果 NI 是 0,PR 就是 20;如果 NI 是 -20,PR 就是 0。数值越小,优先级越高。每一次修改都是在20的基础上加,所以你第一次设置nice值为10,那你要是还想以普通用户设置那就必须设大于10的nice值了,大多数服务器基础值是20,但有差异。

        我们创建的大多是普通进程,但系统还有一种是必须立即处理的任务(如音频处理、工业控制),叫实时进程,实时进程的优先级规则与普通进程截然不同,其 PR 范围为 0 到 99,且在 top 中常显示为 rt

3. 有关优先级的命令

启动时指定优先级:

nice -n -5 ./test

调整运行中的进程:

renice -n 10 -p 1234
# 将 PID 为 1234 的进程 Nice 值改为 10

查看优先级:

[yhz@VM-0-5-opencloudos ~]$ ps -l
F S   UID     PID    PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1002 1772868 1772867  0  80   0 -  2099 do_wai pts/1    00:00:00 bash
0 R  1002 1778741 1772868  0  80   0 -  2742 -      pts/1    00:00:00 ps

top指令:

运行 top 后,界面分为两部分:系统概况(上部)进程列表(下部)

  • 指定刷新频率top -d 1(每 1 秒刷新一次,默认 3 秒)。

  • 只监控特定进程top -p 1234(只看 PID 为 1234 的进程)。

  • 运行中的交互指令k:杀死进程。输入 k 后再输入进程的 PID,即可发送信号(默认 15 终止,9 强制杀死)。r:修改 Nice 值(Renice)。输入 r 后输入 PID,再输入新的 Nice 值。

4. 概念补充

  • 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
  • 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
  • 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
  • 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发

五、进程的切换与调度

1. 对 task_struct 的深入理解

        在 C 语言层面,struct task_struct 并不是直接把所有数据结构“塞”进去,而是通过组合、嵌套不透明指针等实现容纳多种数据结构,例如 struct sched_entity se; 直接定义在其中,它包含了红黑树的节点信息。对于非常大或需要共享的资源,只存一个指针。例如 struct mm_struct *mm。下面将一个它的“寄生”式设计:list_head 

#include <iostream>
#include <string>
#include <vector>
#include <cstddef> // 包含 offsetof

// 1. 定义万能“钩子”
struct list_head {
    struct list_head *next, *prev;
};

/**
 * 2. C++ 模板版 container_of
 * T: 宿主类类型 (如 Task)
 * M: 成员类型 (如 list_head)
 * ptr: 指向成员的指针
 * member: 指向成员的类指针常量 (例如 &Task::tasks)
 */
template <typename T, typename M>
T* container_of(M* ptr, M T::*member) {
    // 获取成员在类中的偏移量
    // 在 C++ 中,这是处理类成员指针的标准方式
    size_t offset = (size_t)&((T*)0->*member);
    return (T*)((char*)ptr - offset);
}

// 3. 模拟进程类
struct Task {
    int pid;
    std::string comm;
    list_head tasks; // 钩子

    Task(int p, std::string c) : pid(p), comm(c) {
        tasks.next = &tasks;
        tasks.prev = &tasks;
    }
};

// 4. 通用链表操作
void list_add(list_head* new_node, list_head* head) {
    new_node->next = head->next;
    new_node->prev = head;
    head->next->prev = new_node;
    head->next = new_node;
}

int main() {
    // 初始化链表头
    list_head process_list = { &process_list, &process_list };

    // 创建进程对象
    Task p1(101, "Kernel-Helper");
    Task p2(102, "Shell-Session");

    // 挂载钩子
    list_add(&p1.tasks, &process_list);
    list_add(&p2.tasks, &process_list);

    // 遍历
    std::cout << "Scanning Task List (C++ Mode):" << std::endl;
    for (list_head* curr = process_list.next; curr != &process_list; curr = curr->next) {
        
        // 使用函数版 container_of
        // 参数:当前指针,目标类类型,目标类成员指针
        Task* t = container_of(curr, &Task::tasks);
        
        std::cout << "[PID: " << t->pid << "] Name: " << t->comm << std::endl;
    }

    return 0;
}

list_head的优点

struct task_struct 
{
    int pid;
    char comm[16];
    struct task_struct *next1;     
    struct task_struct *next2;
};

为什么不用上面那种常规链表?

(1)无法写出通用函数

假设你要写一个函数,打印链表里所有进程的 PID。

void print_pids_from_next1(struct task_struct *head) {
    struct task_struct *curr = head;
    while (curr) {
        printf("%d ", curr->pid);
        curr = curr->next1; // 必须指明走 next1 这条路
    }
}

void print_pids_from_next2(struct task_struct *head) {
    struct task_struct *curr = head;
    while (curr) {
        printf("%d ", curr->pid);
        curr = curr->next2; // 必须指明走 next2 这条路
    }
}

// 这个函数只管拉钩子,它根本不知道什么是进程,什么是 PID
void list_for_each(struct list_head *head) {
    struct list_head *curr;
    for (curr = head->next; curr != head; curr = curr->next) {
        // 这里的 curr = curr->next 永远成立!
        // 不管你在 tasks 链表还是 signal 链表,逻辑完全一样。
    }
}

本质就是list_head只是被包含在task_struct一个类型,其他和task_struct完全没关系,解耦性非常强,但是我又能通过list_head访问整个task_struct,我想访问哪个链表直接传该list_head的变量名就行。

(2)无法实现“跨结构体”的通用算法

next1写死了是 struct task_struct *,如果你想把一个 struct timer_list 也挂进去,你的 next1 直接就报错了。 list_head 不分家。管你是进程还是定时器,只要你身体里埋了 list_head 钩子,我那个通用的 list_add 函数就能把你们全串在一起。

(3)缓存局部性

  • next方案:当内核要遍历“信号链表”时,它必须跳到下一个 task_struct 的开头,然后跨过 pidcomm 等几百个字节,才能摸到 next2。这导致 CPU 缓存里塞满了没用的 pidcomm,真正需要的 next2 指针却经常触发 Cache Miss。
  • list_head:当内核遍历链表时,它只访问 list_head。因为 next 指向的是下一个钩子本身,CPU 预取到的全是紧凑的指针地址。只有在你真正确定要操作某个进程时,才去碰那个巨大的 task_struct

2. 进程的切换

        CPU上下文切换:其实际含义是任务切换, 或者CPU寄存器切换。当多任务内核决定运行另外的任务时, 它保存正在运行任务的当前状态, 也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中, 入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器, 并开始下一个任务的运行, 这一过程就是context switch。

TTS

       TSS(Task State Segment,任务状态段)是一个由硬件(x86 架构)定义的特定数据结构,用于存放进程的硬件上下文信息。 在 Linux 中,每个 CPU 核心只有一个全局的 TSS,而不是每个进程一个。在进程切换过程中,内核会更新这个全局 TSS 中的 esp0 字段,使其指向新进程的 task_struct 所关联的内核栈。

时间片

        当代计算机都是分时操作系统,没有进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从CPU中剥离下来。

如何保存状态

3. O(1)调度算法

(1)双优先级数组机制

这是 O(1) 调度器最精妙的设计,它在每个 CPU 的 runqueue 中维护了两个完全对称的优先级数组:

  • 活跃数组 (active):存放当前调度周期内还有剩余时间片的进程。

  • 过期数组 (expired):存放已经用完本轮时间片的进程。

  • nr_active: 总共有多少个运行状态的进程

  • 每个数组都包含一个拥有 140 个成员的 queue 数组,对应 140 个优先级级别(0-99 为实时进程,100-139 为普通进程)。

         当一个进程处于就绪态时,它的 task_struct 就会通过其内部的 list_head “挂”到 runqueue 中对应优先级的那个链表头上。100-139正好对应我们nice值的范围,PR的范围若为[60,99],某一个进程优先级为x,利用x-60+100,这个公式进入对应的数组元素里,类似哈希桶的做法。

(2)基于位图(Bitmap)的快速查找

为了实现常数级查找,算法引入了位图索引:

  • bitmap[5]:这是一个位映射数组,每一位对应 queue[140] 中的一个优先级链表。

  • 查找逻辑:当需要调度新进程时,内核通过硬件指令(如 sched_find_first_bit)在位图中寻找第一个置位(即非空)的位。

  • 效率:这个操作不依赖于进程总数,只需检查固定的位数,从而保证了O(1)的效率。

(3)运行周期的“翻转”逻辑

这是解决进程饥饿和公平性的关键步骤:

  • 时间片消耗:当一个进程在 CPU 上执行完其分配的时间片后,它会被从 active 数组中移除。

  • 移入过期:内核重新计算该进程的优先级和时间片,然后通过其内部的 list_head 将其挂载到 expired 数组中对应的优先级链表上。

  • 指针交换active 数组中所有的进程都用完时间片(位图变为空)时,调度器只需简单地交换 activeexpired 指针。

  • 无缝衔接:原本过期的进程瞬间变回活跃,开启下一个调度循环,这个交换操作的复杂度同样是 O(1)。

六、环境变量

1.命令行参数

        命令行参数是在启动程序时,直接跟在命令后面的字符串。它们通常用于告诉程序“做什么”以及“对谁做”。

#include <stdio.h>
#include <stdlib.h> 

int main(int argc, char* argv[]) 
{
    // 检查参数个数
    if (argc != 3) {
        printf("错误:参数数量不对!\n");
        printf("正确用法: %s <数字1> <数字2>\n", argv[0]);
        return 1; // 返回非零值表示程序异常退出
    }

    // 打印接收到的原始字符串
    printf("程序名称: %s\n", argv[0]);
    printf("接收到的参数 1: %s\n", argv[1]);
    printf("接收到的参数 2: %s\n", argv[2]);

  
    int num1 = atoi(argv[1]);
    int num2 = atoi(argv[2]);

    int sum = num1 + num2;
    printf("计算结果: %d + %d = %d\n", num1, num2, sum);

    return 0; 
}
[yhz@VM-0-5-opencloudos lesson15]$ ./test
错误:参数数量不对!
正确用法: ./test <数字1> <数字2>

[yhz@VM-0-5-opencloudos lesson15]$ ./test 5 8
程序名称: ./test
接收到的参数 1: 5
接收到的参数 2: 8
计算结果: 5 + 8 = 13

2. 环境变量

(1)概念

        环境变量是操作系统中定义的全局变量,存储在内存中,影响当前 shell 会话及其子进程的行为。环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。

(2)常见环境变量的意义

(3)有关环境变量的命令

▶env

  • 作用: 最常用的外部命令,用于列出当前用户会话中所有的环境变量

  • 用法: 直接输入 env。它不会列出本地变量。

▶printenv

  • 作用:env 类似,但功能更专一,专门用于打印环境变量。

  • 优势: 可以指定查看某个具体的变量,例如 printenv PATH,这比 env | grep PATH 更简洁。

▶echo

  • 作用: 打印变量的值。

  • 用法: echo $VAR_NAME。注意必须加上 $ 符号,否则它只会原样输出字符串

▶export

  • 作用: 这是最关键的内建命令。它将一个本地变量“导出”为环境变量。

  • 原理: 一旦 export,该变量就会被放入环境块中,从而能被子进程继承。

  • 用法: export MY_VAR="hello"

#追加PATH
[yhz@VM-0-5-opencloudos lesson13]$ export PATH=$PATH:/lesson13
[yhz@VM-0-5-opencloudos lesson13]$ printenv PATH
/home/yhz/.local/bin:/home/yhz/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/lesson13
#更改PATH
[yhz@VM-0-5-opencloudos lesson13]$ export PATH=/lesson13
[yhz@VM-0-5-opencloudos lesson13]$ printenv PATH
-bash: printenv: command not found

这样修改都是临时的,要想永久变,要先看家目录下的两个文件

.bash_profile,它是环境变量的初始化入口。

  • 存储内容: 通常用于定义需要全局继承且在登录时只设置一次的变量,最典型的就是 PATH(告诉系统去哪里找命令)。

  • 触发时机: 仅在“登录 Shell”(如 SSH 远程连接、su - user 切换用户)时被读取。

.bashrc 它是环境变量的配置中心。

  • 存储内容: 它可以存别名和环境变量,你在文件中写入的 export 语句或 alias 定义是永久保存的。即使你关机、重启,这些代码行也不会消失。

  • 触发时机: 每次打开一个新的终端窗口或运行一个新的 Bash 进程时都会读取。

在你登陆shell时,系统先读 .bash_profile,然后由它顺带把 .bashrc 里的环境变量也加载进来。

▶unset

  • 作用: 从当前 Shell 内存中删除指定的变量或函数。

  • 用法: unset MY_VAR。执行后,该变量在当前会话及后续子进程中都不再可见。

▶set

  • 作用: 列出当前 Shell 中定义的所有变量,包括环境变量和本地变量,以及定义的 Shell 函数。

  • 区别: 如果你想找一个变量但 env 里没有,可以用 set | grep VAR 看看它是不是一个没被 export 的本地变量(后面讲)。

(4)通过代码获取环境变量

  • 通过命令行第三个参数获得
#include <stdio.h>

int main(int argc, char* argv[], char* env[])
{
	for (int i = 0; env[i]; i++)
			printf("%s\n", env[i]);

	return 0;
}
  • 通过全局变量获得
#include <stdio.h>
//environ是没有头文件的全局变量
int main(int argc, char* argv[])
{
	extern char** environ;
	for (int i = 0; environ[i]; i++)
		printf("%s\n", environ[i])

		return 0;
}

每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。

(5)三个库函数

getenv 用于从进程的环境列表中搜索特定的环境变量。

  • 函数原型: char *getenv(const char *name);

  • 作用: 在内存的环境变量块中查找键为 name 的值。

  • 返回值: 如果找到了,返回指向该变量值的指针。如果没有找到,返回 NULL

  • 注意: 返回的是一个指向内部环境存储区的指针,你不应该直接修改这个指针指向的内容,而应该将其拷贝出来使用。

putenv 用于改变已有的环境变量值,或者增加一个新的环境变量。

  • 函数原型: int putenv(char *string);

  • 参数格式: 必须是 "NAME=VALUE" 这种格式的字符串。

  • 作用:如果 NAME 已存在,则更新它的值。如果 NAME 不存在,则将其加入到当前进程的环境列表中。

  • 返回值: 成功返回 0,失败返回非零值。

  • 存储陷阱(重要): putenv 通常不会拷贝你传入的字符串,而是直接将该字符串的地址放入环境数组中。这意味着如果你传入的是一个局部变量(栈空间),函数执行完后该空间被释放,环境变量就会变成“乱码”或导致程序崩溃。putenv 的修改仅限于当前进程内存

#include <stdio.h>
#include <stdlib.h>  // 它们的头文件

int main() {
    // 1. 读取一个不存在的变量
    char *user = getenv("MY_APP_USER");
    printf("修改前: %s\n", user ? user : "未定义");

    // 2. 设置变量 
    // 这里使用字符串常量,因为它在全局区,不会消失
    putenv("MY_APP_USER=666");

    // 3. 再次读取
    user = getenv("MY_APP_USER");
    printf("修改后: %s\n", user);

    return 0;
}

setenv

int setenv(const char *name, const char *value, int overwrite);
  • name: 环境变量的名称(键),例如 "PATH""PWD"

  • value: 环境变量对应的内容(值)。

  • overwrite是覆盖标志位。若为 非 0:如果环境变量已存在,则用新值覆盖旧值。若为 0:如果环境变量已存在,则保持原值不变。

putenv 只是简单地将传入的指针存入环境表,如果传入的是局部变量或临时字符串,函数返回后指针就会失效,导致程序崩溃。setenv 会在内部拷贝传入的字符串并在堆上重新分配内存。即使你传入的是 std::string().c_str() 这种瞬时值,它也会安全地存储一份副本。

(6)环境变量的继承性

        当一个 C 程序启动时,操作系统的加载器会将程序读入内存。环境变量被存储在进程虚拟地址空间的栈(Stack)上方,也就是最高地址区域

环境变量的存储具有继承性,但这种继承是通过拷贝实现的。Bash 是它所启动的所有命令、工具和子 Shell 的父进程。

  • 父进程到子进程: 当你在 Shell 中运行一个程序时,Shell(父进程)会调用 fork()。此时,子进程会完全复制父进程的环境变量存储区。

  • 独立性: 一旦子进程启动,它修改自己的环境变量,只会改变自己内存空间里的那一块数据。

  • 无法反向: 子进程无法修改父进程内存里的环境变量。这就是为什么你在程序里 export 某个变量,运行完后,当前的终端(Shell)里那个变量并不会改变。

系统的“全局存储”在哪里?

  • 磁盘存储: 它们以文本形式存在于配置文件中(如 /etc/environment~/.bashrc)。

  • 加载过程: 当你登录时,Shell 进程读取这些文件;Shell 将这些键值对加载到自己的内存中;之后你运行的所有命令,都是从 Shell 的这份内存拷贝中继承而来的。

(7)本地变量

定义与语法

本地变量的创建非常直接,不需要使用特殊的关键字(在命令行中)。

  • 语法格式变量名=变量值

  • 严格限制:等号 = 两边绝对不能有空格。如果写成 MYVAR = 123,Bash 会将其误认为是一个命令及其参数,导致执行报错。

  • 引号使用:如果值中包含空格,必须使用双引号括起来,例如 STR="hello world"

核心特性:不可继承性

这是本地变量最显著的特征。

  • 作用域限制:本地变量仅在当前定义的 Bash 进程内存中有效。

  • 子进程不可见:当你从当前 Bash 启动一个子进程(如运行一个 C 程序或开启一个新的子 Bash)时,这些本地变量不会被复制到子进程的环境块中。

  • 生存周期:它们随当前终端会话的关闭而销毁,不会记录到 .bashrc 等磁盘文件中。

如何查询与识别

  • 查看全部:使用 set 命令可以查看到当前 Shell 中所有的变量,包括本地变量和环境变量。

  • 排除法识别:如果一个变量通过 echo $VAR 能打出值,但在 envprintenv 的输出列表中找不到,那么它就是一个本地变量

转换与提升

  • 使用 export:执行 export 变量名 后,该变量会从私有内存移动到环境块中,变为环境变量。

  • 持久化:若想让该变量永久存在,需手动将其写入 .bashrc 配置文件中。

作用

  • 脚本内部逻辑: 在编写 Shell 脚本时,很多中间变量(如循环计数器 i、临时路径 tmp_dir)不需要让外部知道,使用本地变量可以避免污染系统的全局环境。

  • 安全性: 敏感信息如果不 export,就不会被意外带入到子进程的查阅范围中。

3. 内建命令

内建命令是直接编写在 Bash 源代码中的程序,它们是 Bash 进程的一部分,运行它们时不需要创建子进程。

  • 外部命令 (External Commands): 存在于磁盘上的独立文件(如 /bin/ls, /usr/bin/python3)。执行时,Bash 必须 fork 一个子进程。

  • 内建命令 (Built-ins): 比如 cd, echo, exit, history, source, export。它们就在 Bash 的内存里。

 为什么要用内建命令?

  • 性能极高: 省去了创建进程、分配内存、搜索路径(PATH)的开销,瞬间执行。

  • 改变 Shell 自身状态: 为什么 cd 必须是内建命令?如果 cd 是个外部程序,它运行在子进程里,它改变的是子进程的当前目录。子进程一结束返回 Bash,Bash 的目录还是没变。所以,只有内建命令能改变 Bash 进程自己的属性(如工作目录、变量等)。

七、程序地址空间(一)

1. 演进过程

        在 Linux 环境下,用户程序所接触到的地址全部是虚拟地址 。操作系统负责维护虚拟地址与物理地址之间的映射关系,这一过程通常通过分页机制实现 。一个经典的实验可以证明虚拟地址的存在:通过 fork() 创建子进程后,父子进程中同一个全局变量的地址值在打印输出时可能完全一致,但如果其中一个进程修改了变量值,两个进程中该地址对应的内容却变得不同 。这说明相同的虚拟地址通过页表映射到了不同的物理内存区域,这种机制被称为写时拷贝。

2. mm_struct与vm_area_struct

每个进程的 PCB(即 task_struct)中都包含一个指向 mm_struct 的指针 。

mm_struct 详细描述了用户空间的边界和布局,其内部通过一系列成员变量定义了代码段、数据段、堆和栈的起始与结束位置 :

  • 代码段与数据段:由 start_codeend_codestart_dataend_data 定义 。

  • 堆(Heap):由 start_brk(堆起始)和 brk(当前堆顶)定义 。

  • 栈(Stack):由 start_stack 定义起始位置 。

  • 参数与环境段arg_startarg_end 以及 env_startenv_end 分别记录了命令行参数和环境变量的存储区间

由于进程的地址空间通常由多个功能不同的区域(如共享库、内存映射文件等)组成,内核使用 vm_area_struct(简称 VMA)来表示每一个独立的虚拟内存区域 。mm_struct 内部通过两种数据结构来组织这些 VMA :

  • 单链表(mmap 指针):当 VMA 数量较少时,通过链表按地址顺序连接,便于遍历 。
  • 红黑树(mm_rb 指针):当虚拟区间较多时,采用红黑树进行管理,以提高查找特定地址所属 VMA 的效率 。

mm_struct和vm_area_struct的关系是什么?

        在 Linux 内核内存管理中,mm_structvm_area_struct 是描述进程虚拟地址空间的两个核心结构体。简单来说,mm_struct 是对整个进程地址空间的“总览”,而 vm_area_struct 则是对其中各个具体“功能分区”的详细描述,一个 mm_struct 包含多个 vm_area_struct。mm_struct 记录宏观统计信息,它记录了进程整体的内存使用状态,vm_area_struct 记录具体区域属性,它负责描述某一段特定地址空间的细节。

双向绑定

  • mm_struct 中有 mmapmm_rb 指向 VMA 集合。

  • 每个 vm_area_struct 中也有一个 vm_mm 指针,指向它所属的那个 mm_struct。这种双向关系确保了内核在处理特定内存区域时,能随时回溯到整个进程的上下文中。

3. 与物理地址的联系

        我们使用时无法知道真正的物理空间的,而页表是把两者联系起来的核心。页表是实现虚拟内存管理的关键机制,它负责将进程使用的连续虚拟地址映射到离散的物理内存空间中。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int g_val = 100;

int main() 
{
    printf("全局:%p\n\n", &g_val);
    pid_t id = fork();

    if (id < 0) {
        perror("fork");
        return 1;
    }
    else if (id == 0) 
    {
        // 子进程
        g_val = 200;
        sleep(1);
        printf("我是子进程, pid: %d, ppid: %d, g_val: %d, g_val地址: %p\n",getpid(), getppid(), g_val, &g_val);
    }
    else 
    {
        sleep(3);
        g_val += 1;
        printf("我是父进程, pid: %d, ppid: %d, g_val: %d, g_val地址: %p\n",getpid(), getppid(), g_val, &g_val);
        sleep(1);
    }

    return 0;
}



/*
全局:0x403048
 
我是子进程, pid: 1792025, ppid: 1792024, g_val: 200, g_val地址: 0x403048
我是父进程, pid: 1792024, ppid: 1768767, g_val: 101, g_val地址: 0x403048
*/

        在 fork() 创建子进程后,父子进程通过共享页表共同指向同一物理内存,并被内核强制标记为只读权限。当任意一方尝试修改变量时,底层硬件会触发写保护缺页中断,促使内核在物理内存中动态申请新空间并拷贝数据,这一机制即“写时拷贝”。在此过程中,虽然页表指向的物理地址发生了更迭,但进程内部维护的虚拟地址(逻辑标签)保持不变,从而呈现出“地址数值相同,但数据内容独立”的现象,实现了进程间的高效隔离。

4. 程序地址空间的意义


八、进程的创建和终止

1. fork函数

        fork() 是Linux系统中用于创建新进程的系统调用。它的特点是"调用一次,返回两次"。最核心的内容在上面我们已经演示过了,在这里就不多赘述了,我再详细的说一下fork()后内核做什么:分配新的内存块和内核数据结构给子进程;将父进程部分数据结构内容“拷贝”至子进程(其实时共享,后面讲写时拷贝);添加子进程到系统进程列表当中;fork 返回,开始调度器调度。

fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

fork调用失败的原因

  • 系统中有太多的进程
  • 实际用户的进程数超过了限制

2. 写时拷贝

系统如何获取返回值?

        函数返回值的传递主要依赖于 CPU 寄存器(如 rax调用者预留的栈空间。对于简单类型,编译器在函数返回前将临时变量的值“复印”一份存入寄存器;对于复杂类型,则通过隐藏指针直接将数据写入调用者的内存区域。当函数执行完毕,其对应的栈帧(即存放临时变量的物理区域)会被标记为释放,虽然数据可能残留在内存中,但由于栈指针的移动,这块区域已不再受保护,从而完成了从“函数私有”到“外部共享”的值传递。

int get_magic_number() {
    int secret = 42; // 临时变量
    return secret;
}
get_magic_number:
    push    rbp
    mov     rbp, rsp
    mov     DWORD PTR [rbp-4], 42  # 1. 在栈上创建临时变量 secret
    mov     eax, DWORD PTR [rbp-4] # 2. 【核心】将 secret 的值搬运到 eax 寄存器
    pop     rbp                    # 3. 销毁栈帧(secret 逻辑上死亡)
    ret                            # 4. 返回,调用者从 eax 取值

        在 Linux 系统中,写时拷贝(Copy-on-Write,简称 COW) 是一种极其精妙的资源管理优化策略。它的核心思想非常朴素:如果大家都不修改资源,那就先凑合着共用一份;只有当有人想写数据时,才真正去复制一份副本。

#include <unistd.h>
#include <stdio.h>

int main() {
    int data = 100;
    pid_t pid = fork();
    
    if (pid == 0) 
    {
        // 子进程
        printf("Child reads data: %d\n", data);  // 只读,共享内存
        data = 200;  // 写操作触发拷贝
        printf("Child modifies data: %d\n", data);
    } 
    else 
    {
        // 父进程
        printf("Parent reads data: %d\n", data);  // 数据仍然是100
        data = 300;
        printf("Parent modifies data: %d\n", data);
    }
    return 0;
}

写时拷贝的过程

  • 初始状态:父进程中有一个变量data=100,存放在物理页A。
  • 执行fork()后:内核创建子进程,但不复制物理内存。只是把父子进程的页表都设置为指向同一个物理页A,并且都标记为只读。此时父子进程共享data=100。但它被标记为COW。
  • 子进程执行printf读取data:因为是读操作,没问题,直接从物理页A读出100并打印。仍然共享。
  • 子进程执行data=200:CPU发现这个页是只读的,但现在要写入,触发缺页异常,进入内核,内核分配一个新物理页B,把页A的内容100复制到页B,修改子进程的页表,指向页B,并设置为可读写,子进程现在把data改成200,父进程的页表没变,仍然指向页A且只读
  • 父进程执行printf读取data:父进程仍然从页A读到100并打印。仍然没变。
  • 父进程执行data=300:同样触发缺页异常,内核分配新物理页C,从页A复制100到页C,修改父进程页表指向页C且可写,然后父进程把data改成300。
  • 最终:物理页A的100没人用了(引用计数为0),页B是子进程的200,页C是父进程的300。父子进程完全独立。

        因为有写时拷贝技术的存在,所以父子进程得以彻底分离离,完成了进程独立性的技术保证,写时拷贝,是一种延时申请技术,可以提高整机内存的使用率。

怎么理解按需复制?

        在Linux系统中,内存管理的基本单位是,通常是4KB大小。写时拷贝也是以页为单位进行的,而不是以单个变量为单位。也就是说,如果你有100个变量而子进程只用到了4个,假设一页能装下4个变量,如果运气好,这4个变量在同一页,那仅仅只复制这4个变量,如果这4个变量在不同的页,那你就需要复制16个变量了。

3. 进程退出

(1)进程终止的三种场景

进程结束时,只有三种可能的情况:

  • 代码跑完,结果正确:进程完成了预定任务。
  • 代码跑完,结果不正确:进程运行结束,但逻辑上出错了(例如找不到文件)。
  • 代码异常终止:进程还没跑完,就因为触发了某种错误(如段错误、除以 0)被系统强制杀死(通常是收到了信号)。

(2)常见的退出方法

  • main函数中return
  • _exit函数

    #include <unistd.h>
    void _exit(int status);
    //参数:status 定义了进程的终止状态,父进程通过wait来获取该值

    虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255。

  • exit函数
#include <unistd.h>
void exit(int status);

exit最后也会调用_exit, 但在调用_exit之前,还做了其他工作:

  • 执行用户通过 atexit或on_exit定义的清理函数。
  • 刷新所有的标准 I/O 缓冲区(将没写完的数据写到文件/屏幕);关闭打开的文件流。
  • 调用_exit
[yhz@VM-0-5-opencloudos lesson11]$ cat exitcode.cpp
int main()
{
    return 10;
}
[yhz@VM-0-5-opencloudos lesson11]$ g++ exitcode.cpp -o exitcode
[yhz@VM-0-5-opencloudos lesson11]$ ./exitcode
[yhz@VM-0-5-opencloudos lesson11]$ echo $?
10


[yhz@VM-0-5-opencloudos lesson11]$ cat exitcode.cpp
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
    printf("会刷新缓冲区");
    exit(10);
}
[yhz@VM-0-5-opencloudos lesson11]$ g++ exitcode.cpp -o exitcode
[yhz@VM-0-5-opencloudos lesson11]$ ./exitcode
会刷新缓冲区[yhz@VM-0-5-opencloudos lesson11]$ echo $?
10


[yhz@VM-0-5-opencloudos lesson11]$ cat exitcode.cpp
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
    printf("不会刷新缓冲区");
    _exit(10);
}
[yhz@VM-0-5-opencloudos lesson11]$ g++ exitcode.cpp -o exitcode
[yhz@VM-0-5-opencloudos lesson11]$ ./exitcode
[yhz@VM-0-5-opencloudos lesson11]$ echo $?
10

(3)退出码

        退出码(退出状态)可以告诉我们最后一次执行的命令的状态。在命令结束以后,我们可以知道命令是成功完成的还是以错误结束的。0:表示成功;非 0:表示失败,不同的数字代表不同的错误原因。在 Shell 中执行完程序后,输入 echo $? 即可查看上一个进程的退出码。退出码能以上面的方法自己设置,但没意义。

[yhz@VM-0-5-opencloudos lesson11]$ lll
-bash: lll: command not found
[yhz@VM-0-5-opencloudos lesson11]$ echo $?
127
[yhz@VM-0-5-opencloudos lesson11]$ ls 666
ls: cannot access '666': No such file or directory
[yhz@VM-0-5-opencloudos lesson11]$ echo $?
2
[yhz@VM-0-5-opencloudos lesson11]$ ls -d
.
[yhz@VM-0-5-opencloudos lesson11]$ echo $?
0

使用strerror和perror获取具体错误信息

  errno 是一个定义在 <errno.h> 中的全局变量,专门用于存储系统调用或库函数失败后的错误代码。它作为一个中间桥梁,让开发者能够通过简单的数字获取到诸如“权限拒绝”或“资源不存在”等具体的底层报错信息。

特性strerrorperror
灵活性。可以把错误信息格式化、存入变量或写日志。。只能按照固定格式输出到屏幕。
参数需要手动传入错误码(如 errno)。

不需要。自动读取全局变量 errno

(但必须传参数,可以是空串)

输出流取决于你用的打印函数(如 printf 到 stdout)。固定输出到 stderr(标准错误)。
头文件<string.h><stdio.h>
[yhz@VM-0-5-opencloudos lesson11]$ cat exitcode.cpp
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>

int main() {
    // 尝试执行一个不存在的命令
    execl("/bin/666", "666", NULL);

    // 1. 使用 strerror (需要 printf 打印,因为它只返回字符串)
    printf("strerror 打印结果: %s\n", strerror(errno));

    // 2. 使用 perror (直接调用,它自己会打印,不需要 printf)
    perror("perror 打印结果");

    exit(errno); 
}
[yhz@VM-0-5-opencloudos lesson11]$ g++ exitcode.cpp -o exitcode
[yhz@VM-0-5-opencloudos lesson11]$ ./exitcode
strerror 打印结果: No such file or directory
perror 打印结果: No such file or directory
[yhz@VM-0-5-opencloudos lesson11]$ echo $?
2

九、进程等待

1. 概念及原因

进程等待是指父进程通过系统调用(waitwaitpid)来监控子进程的状态变化。原因有:

  • 回收资源: 当子进程退出时,它会进入“僵尸状态”(Zombie)。如果不被父进程回收,该进程的进程控制块(PCB)会一直残留在内核中,占用系统资源,导致内存泄漏

  • 获取退出信息: 父进程往往需要知道子进程任务是否成功完成(退出码),或者是否由于某种信号异常终止

2. wait方法

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

wait 是最简单的等待方式,它具有以下特点:

  • 阻塞式等待: 父进程调用 wait 后,如果子进程没退出,父进程会挂起(暂停执行),直到任意一个子进程结束。结束后立即返回,回收资源。
  • 随机性: 如果有多个子进程,wait 会回收第一个结束的子进程。

wait 的返回值

  • 返回值 > 0:表示回收成功,数值等于被回收子进程的 PID
  • 返回值 == -1:表示回收失败。通常发生在父进程没有子进程可以回收的情况。

status(后面细讲)

输出型参数,用于存放子进程的退出状态信息(由内核填充)。如果不需要,可以传入 NULL

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

void test_wait() 
{
    pid_t id = fork();
    if (id == 0) 
    { 
        printf("Child PID: %d\n ", getpid());
        sleep(2);
    }
    else 
    { 
        printf("Parent wait阻塞等待 ...\n");
        pid_t rid = wait(NULL); 
        if (rid > 0)   
            printf("[Parent] 等待成功, 子进程 PID: %d\n", rid);
    }
}

3. waitpid方法

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);

(1)第一个参数pid

(2)第二个参数status

        在 Linux 中,wait/waitpidstatus 参数是一个 32 位整数(int),但系统只使用了其中的 低 16 位 来记录进程的退出状态。为了在一个整数里同时表达“正常退出码”、“终止信号”和“是否产生 Core Dump”(暂时不学),内核采用了“位图”的设计思想,将这 16 位划分为不同的区域。

区域位区间 (Bit)描述
高 8 位15 ~ 8退出状态 (Exit Code)仅在进程正常退出时有效。
低 8 位 (最高位)7core dump 标志:如果是信号终止,表示是否生成了核心转储文件。
低 8 位 (低 7 位)6 ~ 0终止信号 (Term Sig):如果进程被信号杀死,记录信号编号。

场景 A:正常退出 (Normal Exit)

当子进程调用 exit(code) 或从 main 函数 return 时:

  • 终止信号(低 7 位):全部为 0

  • 退出码(高 8 位):存储 exit 传入的数值。

  • 计算逻辑: (status >> 8) & 0xFF

场景 B:被信号终止 (Signaled)

当进程被 kill -9 或因段错误(Segmentation Fault)崩溃时:

  • 终止信号(低 7 位):存储导致进程终止的信号编号(如 SIGKILL 是 9,后面会讲)。

  • 退出码(高 8 位):不被使用(通常为 0,但无意义)。

  • 计算逻辑: status & 0x7F

系统提供的便捷宏

WIFEXITED(status):内部实现是!((status) & 0x7F)。如果为真,说明进程是正常退出的。

WEXITSTATUS(status):内部实现是((status) >> 8) & 0xFF。获取具体的退出码。

WIFSIGNALED(status):如果进程是被信号杀死的,返回真。

WTERMSIG(status):获取导致进程终止的信号编号。

如果你不限麻烦和易错,直接写位运算也行。在解析 status 时,必须先判断是否正常退出,再看退出码。

void test_waitpid1() 
{
    pid_t id = fork();

    if (id == 0) 
    {
        printf("ChildPID: %d, 开始运行任务...\n", getpid());
        sleep(3); 
        printf("Child任务完成,准备退出,退出码设为 88\n");
        exit(88);
    }
    else 
    {
        int status = 0;
        printf("ParentPID: %d, waitpid阻塞等待%d \n", getpid(), id);

        /* waitpid 的阻塞模式:
           1. 第一个参数传入子进程 PID
           2. 第二个参数传入 status 变量的地址(输出型参数)
           3. 第三个参数设为 0,代表阻塞式等待
        */
        pid_t rid = waitpid(id, &status, 0);

        if (rid > 0) 
        {
            printf("Parent成功回收子进程 PID: %d\n", rid);

            if (WIFEXITED(status)) {
                int code = WEXITSTATUS(status); 
                printf("子进程退出状态:正常退出, 退出码: %d\n", code);
            }
            else if (WIFSIGNALED(status)) {
                int sig = WTERMSIG(status); 
                printf("子进程退出状态:被信号杀死, 信号编号: %d\n", sig);
            }
        }
    }
}

(3)第三个参数options

   0表示默认阻塞等待;WNOHANG表示非阻塞等待。如果子进程没结束,立即返回 0,父进程可以继续干别的事(通常配合循环进行“轮询”,就是每次过一会问问完了没)。

waitpid返回值

返回值类型数值含义发生场景
正数子进程 PID成功回收了一个指定的(或任意的)子进程。
子进程还在运行仅在设置了 WNOHANG 时出现。表示目前没有子进程退出,父进程可以继续干别的。
负数调用出错比如 PID 写错了、没有子进程、或者被信号中断(EINTR)。
void test_waitpid2()
{
    pid_t id = fork();
    if (id == 0) {
        printf("[Child] PID:  %d  \n", getpid());
        sleep(5);
        exit(123);
    }
    else
    {
        int status = 0;
        sleep(1);
        while (1)
        {
            // WNOHANG: 若子进程未结束,立即返回 0
            pid_t rid = waitpid(id, &status, WNOHANG);

            if (rid > 0)
            { // 等待成功
                printf("[Parent] 轮询结束, 子进程退出码: %d\n", WEXITSTATUS(status));
                break;
            }
            else if (rid == 0)
            { // 子进程还在跑
                printf("[Parent] 子进程还在运行,我执行其他任务...\n");
                sleep(1);
            }
            else
            { // 出错
                perror("waitpid error");
                break;
            }
        }
    }
}

十、进程程序替换

1. 替换原理

        进程程序替换的核心原理是用一个全新的程序替换当前进程的代码和数据exec* 函数是用来实现他的主要函数,当进程调用一种 exec* 函数时,该进程的用户空间代码和数据完全被新程序替换。

  • 不创建新进程:调用 exec 并不创建新的进程,因此进程的 PID(进程标识符)不会改变。执行exec后,进程的PPID、当前工作目录、文件创建屏蔽字、信号屏蔽字等属性也会被保留。

  • 资源覆盖:新程序会覆盖原有的代码段、数据段、堆和栈。(写时拷贝)

  • 页表映射更新:从操作系统的角度看,原本指向旧程序物理内存的页表映射被断开,重新指向磁盘上新程序的映像并加载。

  • 执行流改变:一旦替换成功,原程序中 exec 之后的代码将永远不会被执行(除非替换失败返回 -1)。

2. 替换函数

Linux提供了多个exec族函数来实现程序替换,它们都包含在<unistd.h>头文件中。这些函数在底层都调用了同一个系统调用——execve。

函数名参数格式程序查找方式环境变量函数原型示例
execl列表 (list)需指定路径继承当前环境

int execl(const char *path, const char *arg, ...);

execv数组 (vector)需指定路径继承当前环境int execv(const char *path, char *const argv[]);
execlp列表 (list)PATH 环境变量继承当前环境int execlp(const char *file, const char *arg, ...);
execvp数组 (vector)PATH 环境变量继承当前环境int execvp(const char *file, char *const argv[]);
execle列表 (list)需指定路径可自定义 (env)int execle(const char *path, const char *arg, ..., char *const envp[]);
execvpe数组 (vector)PATH 环境变量可自定义 (env)int execvpe(const char *file, char *const argv[], char *const envp[]);

exec*的返回值问题

  • 成功:不返回(程序被替换),同一进程后面的代码永远不会被执行
  • 失败:返回-1,并设置errno

执行管道命令的语法

execl("/bin/sh", "sh", "-c", "ls -l | grep .c", NULL);

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>

int main() {
    printf("父进程PID: %d\n\n", getpid());
    
    int status;
    pid_t pid;

    // 1. execl - 列表参数,完整路径
    printf("execl\n");
    if ((pid = fork()) == 0)
    {
        printf("子进程PID: %d\n", getpid());
        execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
        exit(1);
    }
    wait(&status);
    
    // 2. execv - 数组参数,完整路径
    printf("execv\n");
    if ((pid = fork()) == 0) {
        char *argv[] = {(char*)"ls", (char*)"-a", (char*)"-l", NULL};
        execv("/usr/bin/ls", argv);
        exit(1);
    }
    wait(&status);
    
    // 3. execlp - PATH搜索,列表参数
    printf("execlp\n");
    if ((pid = fork()) == 0) {
        execlp("ls", "ls", "-a", "-l", NULL);
        exit(1);
    }
    wait(&status);
    
    // 4. execvp - PATH搜索,数组参数
    printf("execvp\n");
    if ((pid = fork()) == 0) 
    {
        char *argv[] = {(char*)"ls", (char*)"-a", (char*)"-l", NULL};
        execvp("ls", argv);
        exit(1);
    }
    wait(&status);
    
    // 5. execle - 自定义环境变量
    printf("execle\n");
    if ((pid = fork()) == 0) {
        char *env[] = {(char*)"MY_VAR=12", NULL};
        execle("/bin/sh", "sh", "-c", "env | tail -3", NULL, env);
        exit(1);
    }
    wait(&status);
    
    // 6. execvpe - PATH搜索 + 自定义环境变量
    printf("execvpe\n"); 
    if(fork() == 0)
    {
        printf("I am Child, My Pid Is: %d\n", getpid());
        sleep(1);
        char *env[] = {(char*)"a=1",(char*)"b=2",(char*)"c=3" ,NULL};

        char *const argv[] = 
        {
            (char*)"other",
            NULL
        };

        for(int i = 0; env[i]; i++)
        {
            putenv(env[i]);
        }
        extern char **environ;
        execvpe("./other", argv, environ);
        exit(1); 
    }
    wait(&status);
    
    return 0;
}  
//other.c
#include <iostream>
#include <cstdio>
#include <unistd.h>

int main(int argc, char *argv[], char *env[])
{
    std::cout << "hello C++, My Pid Is: " << getpid() << std::endl;

    for(int i = 0; i < argc; i++)
    {
        printf("argv[%d]: %s\n", i, argv[i]);
    }

    printf("\n");

    int count = 0;
    
    while (environ[count] != NULL) {
        count++;
    }
    
    for (int i = count - 3; i < count; i++) {
        if (i >= 0) {  
            printf("  [%d] %s\n", i, environ[i]);
        }
    }
    return 0;
}
#makefile
other:other.c
	g++ other.c -o other
exec:exec.cpp
	g++ exec.cpp -o exec

.PHONY:clean run
clean:
	rm -r exec other
run:
	./exec

加载器是什么?怎么理解加载器与exec*函数的关系?

        在 Linux 操作系统中,加载器(Loader) 是将程序从磁盘“搬”到内存并启动执行的幕后英雄,其核心任务是将可执行文件的内容映射到进程的虚拟地址空间。要理解它与 exec 函数族的关系,我们可以把 exec 看作是指令发送者,而加载器是具体执行者exec 是程序员触发加载行为的唯一官方渠道;当 exec 被调用时,内核会激活加载器来覆盖当前进程的内容,从而实现程序的更新与切换。

当你打开终端输入 ls 并回车时,发生了以下具体步骤:

第一步:fork() —— 搭建舞台副本。Shell 进程(父进程)先复制出一个自己(子进程)。此时子进程里运行的还是 Shell 的代码。

第二步:execvp("ls", ...) —— 发出替换指令。子进程调用 exec 函数。这时,内核加载器介入了:查找文件:加载器去 /bin/ 目录下找到名为 ls 的可执行文件;清空旧居:加载器把子进程里原有的 Shell 代码段、堆栈全部擦除;搬运新家:加载器读取 ls 文件的头部信息,它看到 ls 需要 50KB 存放代码,于是就在进程空间划出一块区域,它看到 ls 需要一些初始化的全局变量,于是又划出一块区域;初始化环境:加载器把命令行参数(如 -l)和环境变量压入新程序的栈中;开启新篇:加载器修改 CPU 的寄存器,让它直接指向 ls 程序的起点。

        加载器的作用就是顺应上面说的冯·诺依曼体系:把数据从磁盘搬到内存,让 CPU 在内存这个“唯一能直接对话的场所”里开始执行指令。

系统调用execve和其他C语言库函数的区别是什么?

        execve是内核提供的原始系统调用,其他exec*函数是C库在用户态对execve的包装,目的是提供更友好的调用方式。execve是最底层接口,必须提供完整路径、argv数组、envp数组,直接陷入内核执行。

exec函数和命令行参数的关系是什么?

exec函数执行命令的本质就是父进程直接为新程序构造main函数的argv参数数组。

  • 当你在命令行输入命令时,是shell帮你解析字符串、构造argv数组,然后调用exec

  • 当你在程序中调用exec时,是你自己直接构造argv数组,然后交给exec

  • exec函数负责把这个argv数组从父进程空间拷贝到新程序空间

  • 新程序启动后,它的main函数接收到的argc和argv,就是父进程通过exec传递过来的

所以无论通过哪种方式调用,最终新程序的main函数拿到的argv数组结构完全一样:argv[0]是程序名,后面依次是各个参数,以NULL结尾。

十一、自主搭建shell

//myshell.hpp
#ifndef MYSHELL_HPP
#define MYSHELL_HPP

#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <sstream>
#include <algorithm>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>

using namespace std;

#define FORMAT "[%s@%s %s]# "
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3

// 全局变量声明
extern vector<string> g_args;
extern vector<string> g_env_list;
extern int redir;
extern string filename;
extern int lastcode;

string GetUserName();
string GetHostName();
string GetPwd();
string DirName(const string &pwd);
void InitEnv();
bool Cd();
void Echo();
void RedirCheck(string &cmd);
bool CommandParse(string &line);
bool CheckAndExecBuiltin();
void Execute();

#endif
//myshell.cpp

#include "myshell.hpp"

vector<string> g_args;
vector<string> g_env_list;
int redir = 0;
string filename = "";
int lastcode = 0;

void InitEnv()
{
    g_env_list.clear();
    extern char **environ;
    for (int i = 0; environ[i]; i++)
    {
        g_env_list.push_back(environ[i]);
    }
    g_env_list.push_back("myval=666");

    for (auto &e : g_env_list)
    {
        putenv((char *)e.c_str());
    }
}

string GetUserName()
{
    char *user = getenv("USER");
    if (user)
        return user;
    else
        return "None";
}

// Ubuntu下HostName是本地变量
string GetHostName()
{
    char name[256];
    if (gethostname(name, sizeof(name)) == 0)
    {
        return name;
    }
    return "None";
}

string GetPwd()
{
    char npwd[256];
    if (getcwd(npwd, sizeof(npwd)) != nullptr)
    {
        return npwd;
    }
    return "None";
}

string DirName(const string &pwd)
{
    if (pwd == "/")
        return "/";
    auto pos = pwd.rfind('/');
    if (pos != string::npos)
        return pwd.substr(pos + 1);
    else
        return "error";
}

void RedirCheck(string &cmd)
{
    // 清空上一条命令
    redir = 0;
    filename.clear();
    // Lambda表达式
    auto check = [&](string symbol, int type) -> bool
    {
        auto pos = cmd.find(symbol);
        if (pos == string::npos)
            return false;
        string temp = cmd.substr(pos + symbol.size());
        cmd = cmd.substr(0, pos); // 截断指令(这也是为什么要先做重定向检查)

        // 清理文件名前后的空格
        int start = 0;
        int end = (int)temp.size() - 1;

        // 1. 从左往右跳过空格
        while (start <= end && isspace(temp[start]))
            start++;
        // 2. 从右往左跳过空格
        while (end >= start && isspace(temp[end]))
            end--;
        // 3. 如果还有剩下内容,就是文件名
        if (start <= end)
            filename = temp.substr(start, end - start + 1);

        redir = type;
        return true;
    };

    // 检查是不是重定向,是的话修改上面两个全局变量
    //  注意检测顺序:先检测 >> 再检测 >
    if (check(">>", APPEND_REDIR))
        return;
    if (check(">", OUTPUT_REDIR))
        return;
    if (check("<", INPUT_REDIR))
        return;
}

bool CommandParse(string &line)
{
    g_args.clear();
    stringstream ss(line);
    string words;
    while (ss >> words)
    {
        g_args.push_back(words);
        words.clear();
    }
    return !g_args.empty();
}
bool Cd()
{
    if (g_args.size() == 1)
        return true;
    string path;
    if (g_args[1] == "~")
    {
        path = getenv("HOME");
    }
    else if (g_args[1] == "-")
    {
        // 增加对 OLDPWD 的支持
        return true;
    }
    else
    {
        path = g_args[1];
    }

    if (chdir(path.c_str()) == 0)
    {
        setenv("PWD", GetPwd().c_str(), 1);
        lastcode = 0;
    }
    else
    {
        perror("cd");
        lastcode = 1;
    }
    return true;
}

void Echo()
{
    if (g_args.size() < 2)
    {
        std::cout << std::endl;
        return;
    }

    string content;

    // 1. 优先处理特殊符号 $?
    if (g_args[1] == "$?")
    {
        content = to_string(lastcode);
    }
    // 2. 处理环境变量 $PATH 等
    else if (g_args[1][0] == '$')
    {
        string temp = g_args[1].substr(1);
        char *env_val = getenv(temp.c_str());
        if (env_val)
            content = env_val;
        else
            content = "";
    }
    // 3. 普通字符串拼接
    else
    {
        for (size_t i = 1; i < g_args.size(); ++i)
        {
            content += g_args[i];
            if (i != g_args.size() - 1)
                content += " ";
        }
    }

    if (redir == NONE_REDIR)
    {
        std::cout << content << std::endl;
    }
    else
    {
        // 打开文件
        int flags = O_WRONLY | O_CREAT;
        if (redir == OUTPUT_REDIR)
            flags |= O_TRUNC; // > 覆盖
        else if (redir == APPEND_REDIR)
            flags |= O_APPEND; // >> 追加

        int fd = open(filename.c_str(), flags, 0666);
        if (fd < 0)
        {
            perror("open");
            return;
        }

        // 写入内容并加换行
        std::string out_str = content + "\n";
        write(fd, out_str.c_str(), out_str.size());

        close(fd); // 记得关掉文件
    }

    lastcode = 0;
}

bool CheckAndExecBuiltin()
{
    if (g_args.empty())
        return false;

    if (g_args[0] == "cd")
    {
        return Cd();
    }
    else if (g_args[0] == "echo")
    {
        Echo();
        return true;
    }
    else if (g_args[0] == "exit")
    {
        cout << "程序已退出" << endl;
        exit(0);
    }
    else
        return false;
}

void Execute()
{
    int id = fork();
    // 重定向处理
    if (redir != NONE_REDIR)
    {
        int fd = -1;
        if (redir == INPUT_REDIR)
            fd = open(filename.c_str(), O_RDONLY);
        else if (redir == OUTPUT_REDIR)
            fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0666);
        else if (redir == APPEND_REDIR)
            fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);

        if (fd < 0)
            exit(1);
        dup2(fd, (redir == INPUT_REDIR ? 0 : 1));
        close(fd);
    }
    if (id == 0)
    {
        std::vector<char *> argv;
        for (auto &s : g_args)
        {
            argv.push_back((char *)s.c_str());
        }
        argv.push_back(nullptr);

        // 使用 .data() 获取数组首地址
        execvp(g_args[0].c_str(), argv.data());

        perror("execvp");
        exit(1);
    }

    int status;
    waitpid(id, &status, 0);
    if (WIFEXITED(status))
    {
        lastcode = WEXITSTATUS(status);
    }
}
//main.cpp

#include "myshell.hpp"

int main()
{
    // 初始化环境变量
    InitEnv();

    // 循环命令行
    while (true)
    {
        // 1.输出提示符
        printf(FORMAT, GetUserName().c_str(), GetHostName().c_str(), DirName(GetPwd()).c_str());
        fflush(stdout);

        // 2.获取用户输入命令
        string command;
        if (!getline(cin, command))
            break;
        if (command == "")
            continue;

        // 3.判断是否存在重定向(更新redir和filename)
        RedirCheck(command);

        // 4.解析命令,将一整个string字符串变为vector<string>
        if (!CommandParse(command))
            break;

        // 5.判断是否为内建命令
        if (CheckAndExecBuiltin())
            continue;

        // 6.判断是否为外部命令
        Execute();
    }
    return 0;
}

源码在此



后记:既要往后学又要赶博客真的是太困难了,导致一个博客周期很长,进程这个内容面试中经常会考,大家一定牢牢把握!如果有帮助,麻烦点个小心心吧!

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值