前言

在 Linux 中敲下 ./a.out,看似只是运行了一个程序,内核背后却要完成一整套工作:建立进程描述信息、分配虚拟地址空间、把进程放入调度队列、保存与恢复 CPU 上下文,并在进程退出后回收残留信息。进程不是磁盘上的可执行文件,而是程序的一次动态执行过程,也是操作系统分配 CPU、内存、文件等资源时面对的基本实体。

本文从计算机的数据流和操作系统的管理职责讲起,逐步打通 task_structfork、进程状态、僵尸与孤儿进程、优先级、上下文切换、Linux 2.6 O(1) 调度器、环境变量以及虚拟地址空间。示例以 Linux 和 C 语言为主,既说明“怎么用”,也解释“为什么”。

1. 从冯诺依曼体系到操作系统

1.1 冯诺依曼体系中的数据流

绝大多数个人电脑和服务器都遵循冯诺依曼体系。它包含输入设备、输出设备、存储器、运算器和控制器,其中运算器与控制器合称 CPU。这里的“存储器”主要指内存,不是磁盘。

在这里插入图片描述

在暂不考虑 CPU Cache 等细节时,可以抓住一个关键规则:

CPU 直接读取和写入的是内存;外设要参与计算,也要先把数据送入内存,或从内存取走数据。内存是 CPU 与输入、输出设备之间的数据中转站。

例如,发送一条即时消息时,键盘产生的输入经过设备驱动进入内存,聊天程序读取并处理数据,再通过系统调用交给内核网络协议栈;网卡从内存中的缓冲区取走数据并发出。接收方则经历相反过程:网卡收包、内核写入内存、应用读取、图形系统最终把像素数据交给显示设备。发送文件的路径相似,只是数据源由键盘变成磁盘文件,并且通常会经过页缓存、网络缓冲区等更多环节。

组件 主要职责 与数据的关系
输入设备 产生外部输入 将数据送入内存
内存 暂存指令与数据 连接 CPU 和外设
运算器 完成算术与逻辑运算 处理从内存取得的数据
控制器 解释指令、协调部件 发出控制信号
输出设备 呈现或传出结果 从内存取得待输出数据

1.2 操作系统的定位

操作系统(Operating System,OS)是计算机中的基础软件集合。狭义上常指内核,负责进程管理、内存管理、文件管理和驱动管理;广义上还包括 C 标准库、Shell、系统工具等用户态组件。

操作系统处在硬件与应用之间,目标可以概括为两点:

  • 对下管理资源:屏蔽不同 CPU、磁盘、网卡等硬件的差异,协调它们的使用。
  • 对上提供环境:向应用提供稳定、统一且受保护的执行接口。

应用不能随意读写物理内存,也不应直接操纵磁盘控制器。它通过系统调用请求内核完成敏感操作,这样内核才能检查权限、隔离故障并统一调度资源。

1.3 “先描述,再组织”的管理方法

操作系统面对的进程、文件和设备数量很多,不可能靠一堆零散变量管理。内核采用一种通用方法:先用结构体描述对象的属性,再用合适的数据结构组织这些对象。

以进程为例,内核先用 task_struct 描述 PID、状态、优先级、内存、打开文件等信息,再通过链表、树、哈希表和调度队列等结构组织进程。管理动作最终就变成了对数据结构的增、删、查、改。

操作系统管理进程,并不是一直“盯着”程序代码,而是维护代表进程的内核数据结构,再根据这些数据作出调度、阻塞、唤醒和回收等决策。

系统调用与库函数也要区分:系统调用是用户态进入内核请求服务的接口,如 fork()read()库函数是用户态库提供的封装,如 printf()malloc()。库函数可能使用一个或多个系统调用,也可能完全在用户态完成工作。

对比项 系统调用 库函数
提供者 操作系统内核 libc 等用户态库
执行位置 经受控入口进入内核态 通常在用户态执行
接口特点 基础、稳定、粒度较低 更易用、可移植、封装更完整
示例 forkreadwrite printffopengetenv

2. 进程、PCB 与 fork

2.1 程序和进程不是一回事

程序是磁盘上的静态文件,包含机器指令和初始数据;进程是程序运行起来后的动态实例。同一个程序可以同时启动多次,每次通常对应不同的 PID、地址空间、打开文件和调度状态。

从内核视角,可以用下面的关系帮助理解:

进程 = 进程控制块(内核管理信息)+ 该进程可访问的代码和数据 + 运行时资源。

进程是承担资源分配和调度的实体。代码本身不会“竞争 CPU”,进入内核调度队列的是代表执行实例的进程或线程。

2.2 PCB 与 task_struct

用于描述进程的结构称为 PCB(Process Control Block,进程控制块)。在 Linux 中,与 PCB 核心角色对应的数据结构是 task_struct。它位于内核内存中,普通用户程序不能直接修改。

task_struct 内容庞大,但初学阶段抓住下列类别即可:

信息类别 作用 典型内容
标识信息 区分并关联进程 PID、PPID、进程组、会话
状态信息 支持生命周期管理 运行、睡眠、停止、退出状态
调度信息 决定何时获得 CPU 优先级、时间片/调度实体、CPU 亲和性
CPU 上下文 支持暂停后继续 程序计数器、栈指针、通用寄存器
内存信息 描述虚拟地址空间 mm_struct、页表、VMA
I/O 信息 记录资源使用 打开的文件、终端、I/O 状态
记账与限制 统计和约束资源 CPU 时间、资源上限、凭据

所谓“组织进程”,就是把这些 task_struct 通过各种内核数据结构关联起来。早期讲解常用双向链表说明全局任务关系;真实内核还会按照 PID、父子关系、调度类别等建立不同索引与队列,以便高效找到目标进程。

2.3 查看进程与获取 PID

Linux 的 /proc 是内核向用户态暴露状态的伪文件系统。目录 /proc/<PID> 对应该进程的运行信息,例如:

moss@VM-0-4-ubuntu:~$ ls /proc/1
moss@VM-0-4-ubuntu:~$ cat /proc/1/status
moss@VM-0-4-ubuntu:~$ tr '\0' ' ' < /proc/1/cmdline

/proc/1 中的内容并不是普通磁盘文件,而是读取时由内核动态生成。日常排查更常用 pstop

moss@VM-0-4-ubuntu:~$ ps aux
moss@VM-0-4-ubuntu:~$ ps axj
moss@VM-0-4-ubuntu:~$ top
  • a 显示与终端关联的其他用户进程。
  • x 把没有控制终端的进程也显示出来。
  • u 使用面向用户的详细格式,包含 CPU、内存等指标。
  • j 使用作业控制格式,便于观察 PPID、进程组和会话。

在 C 程序中,getpid() 返回当前进程 PID,getppid() 返回父进程 PID:

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

int main(void)
{
    printf("pid=%ld, ppid=%ld\n",
           (long)getpid(), (long)getppid());
    return 0;
}

编译运行:

moss@VM-0-4-ubuntu:~$ gcc pid_demo.c -o pid_demo
moss@VM-0-4-ubuntu:~$ ./pid_demo
pid=24860, ppid=1357

实际数值每次可能不同,这正是 PID 作为一次运行实例标识的体现。

2.4 fork 创建子进程

fork() 复制当前进程,创建一个子进程。调用成功后,父子进程都会从 fork() 返回后的下一条语句继续执行,但返回值不同:

moss@VM-0-4-ubuntu:~$ man 2 fork

这里的 2 表示查看系统调用手册章节,避免与同名命令或库接口混淆。

执行分支 fork() 返回值 含义
父进程 大于 0 返回新建子进程的 PID
子进程 等于 0 当前正在子进程中
调用失败 -1 没有创建子进程,并设置 errno
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    pid_t id = fork();

    if (id < 0) {
        perror("fork");
        return EXIT_FAILURE;
    }
    if (id == 0) {
        printf("child: pid=%ld, fork_return=%ld\n",
               (long)getpid(), (long)id);
    } else {
        printf("parent: pid=%ld, child_pid=%ld\n",
               (long)getpid(), (long)id);
    }
    return EXIT_SUCCESS;
}

“一个函数为什么能返回两次”并不神秘:内核创建子进程时复制了执行上下文,父子进程各自拥有逻辑上独立的寄存器现场,并从同一位置恢复执行。内核分别把不同返回值放入父、子进程的返回值寄存器,所以两个进程看到不同结果。

父子进程初始看到相同的代码和数据,但并非立即把全部物理内存复制一遍。Linux 通常采用写时复制(Copy-on-Write,COW):只读阶段共享相同物理页;某一方准备写入时触发缺页异常,内核才复制相关页并修改页表。这样既保持进程独立性,又降低 fork() 的时间和内存成本。

3. 进程状态与生命周期

3.1 Linux 进程状态

一个进程不可能始终占用 CPU。当它等待键盘输入、磁盘 I/O、网络数据或定时器时,内核会让它睡眠,把 CPU 交给其他可运行进程。事件完成后,进程再被唤醒并进入可运行队列。

在这里插入图片描述

psSTAT 列经常出现以下主要状态字母:

状态 名称 准确含义
R Running/Runnable 正在 CPU 上运行,或已在运行队列中等待 CPU
S Interruptible Sleep 可中断睡眠,等待事件,可被信号唤醒
D Uninterruptible Sleep 不可中断睡眠,常见于等待关键 I/O;不只限于磁盘
T Stopped 被作业控制信号停止
t Tracing Stop 正被调试器或跟踪器暂停
Z Zombie 已退出,但父进程尚未读取退出状态
X Dead 正在彻底消亡的短暂内部状态,通常观察不到

部分新内核和工具还可能显示 I,表示空闲内核线程。STAT 后面的附加字符也有含义,例如 s 表示会话首进程,+ 表示位于前台进程组,< 表示较高优先级,N 表示较低优先级。

调试器通常通过 ptrace() 等内核接口观察和控制目标进程,因此被跟踪任务可能显示 t。日常排查系统调用则更常直接使用 strace,它在底层利用跟踪机制展示进程与内核的交互。

可以用信号观察停止和继续:

moss@VM-0-4-ubuntu:~$ kill -STOP 24860
moss@VM-0-4-ubuntu:~$ ps -o pid,ppid,stat,comm -p 24860
moss@VM-0-4-ubuntu:~$ kill -CONT 24860

SIGSTOP 不能被捕获或忽略;SIGCONT 让停止的进程继续运行。这里的 kill 命令本质是发送信号,不等于一定“杀死进程”。

3.2 僵尸进程:退出了,为什么还存在

子进程退出时,大部分资源会被释放,但内核仍要保留少量信息,包括 PID、终止原因和退出码,供父进程读取。若父进程一直没有调用 wait()waitpid(),子进程就处于 Z 状态。

下面的程序故意让子进程先退出,父进程暂时不回收:

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

int main(void)
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return EXIT_FAILURE;
    }
    if (id == 0) {
        printf("child %ld exits now\n", (long)getpid());
        _exit(42);
    }

    printf("parent %ld sleeps, child=%ld\n",
           (long)getpid(), (long)id);
    sleep(30);
    return EXIT_SUCCESS;
}

在另一个终端观察:

moss@VM-0-4-ubuntu:~$ gcc zombie.c -o zombie
moss@VM-0-4-ubuntu:~$ ./zombie
moss@VM-0-4-ubuntu:~$ ps -o pid,ppid,stat,cmd -C zombie

僵尸进程不会继续执行代码,也几乎不再持有用户态内存,但它会占用 PID 和内核进程表项。少量僵尸通常不会耗尽内存;如果服务持续创建子进程却从不回收,进程表项和 PID 最终可能耗尽,导致新的进程无法创建。把这种情况笼统称作“内存泄漏”不够准确,更准确的说法是子进程回收缺失造成内核元数据资源泄漏

正确做法是由父进程回收:

int status = 0;
pid_t result = waitpid(id, &status, 0);
if (result == id && WIFEXITED(status)) {
    printf("child exit code=%d\n", WEXITSTATUS(status));
}

父进程还可以处理 SIGCHLD,或在事件循环中使用 waitpid(-1, &status, WNOHANG) 回收多个子进程。僵尸进程已经死去,向它发送 SIGKILL 没有意义;应修复父进程的回收逻辑,必要时终止失职的父进程,让系统接管其子进程。

3.3 孤儿进程:父进程先退出

如果父进程先退出,而子进程仍在运行,子进程就成为孤儿进程。内核会把它重新托管给合适的 subreaper(子进程收割者);在简单系统中通常最终是 PID 1 的 systemdinit。新的父进程负责在它退出后完成回收。

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

int main(void)
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return EXIT_FAILURE;
    }
    if (id == 0) {
        printf("child: before, ppid=%ld\n", (long)getppid());
        sleep(5);
        printf("child: after,  ppid=%ld\n", (long)getppid());
        return EXIT_SUCCESS;
    }

    printf("parent exits, pid=%ld\n", (long)getpid());
    return EXIT_SUCCESS;
}
对比项 僵尸进程 孤儿进程
是否还在执行 否,已经退出 是,仍可正常运行
形成原因 父进程未读取子进程退出状态 父进程先于子进程退出
主要风险 大量积累会耗尽 PID/进程表项 通常没有直接危害
最终处理 父进程调用 wait/waitpid 由 subreaper 或 PID 1 接管并回收

4. 优先级、调度与进程切换

4.1 竞争、独立、并发与并行

系统中的可运行进程往往多于 CPU 核心,因此它们具有竞争性;内核必须决定谁先运行、运行多久。进程又具有独立性,每个进程原则上拥有独立地址空间和资源视图,一个普通进程的错误不应直接破坏另一个进程。

并发和并行容易混淆:

概念 运行方式 观察尺度
并发 单个 CPU 核心快速切换多个任务 一段时间内多个任务都在推进
并行 多个 CPU 核心同一时刻执行不同任务 同一时刻确实有多个任务运行

现代多核系统通常同时存在并发与并行。调度器不仅选择下一个任务,还要考虑多核负载均衡、CPU 亲和性、实时性和交互响应。

4.2 nice 值与进程优先级

优先级表达进程获得 CPU 的相对先后倾向。对普通分时进程,用户最常操作的是 nice 值,范围为 -2019:数值越小,进程越“不客气”,获得 CPU 的倾向越高;数值越大,越愿意把 CPU 让给别人。

资料中常见 PRI(new) = PRI(old) + nice 的写法适合理解“修正方向”,但不是所有现代调度器内部优先级计算的完整公式。**nice 不是优先级本身,而是影响普通进程调度权重的用户接口。**降低 nice 值通常需要更高权限。

moss@VM-0-4-ubuntu:~$ ps -l -p 24860
moss@VM-0-4-ubuntu:~$ nice -n 10 ./cpu_task
moss@VM-0-4-ubuntu:~$ renice -n 5 -p 24860
moss@VM-0-4-ubuntu:~$ taskset -cp 0,1 24860

ps -l 中常见字段如下:

字段 含义
UID 进程所属用户
PID 当前进程 ID
PPID 父进程 ID
PRI 工具展示的调度优先级,解释需结合调度策略
NI nice 值

top 中按 r 可输入 PID 和新的 nice 值。程序也可以调用 getpriority()setpriority();它们声明在 <sys/resource.h> 中。要注意,getpriority() 的合法返回值也可能是 -1,因此判断失败前应先把 errno 置零,再同时检查返回值和 errno

4.3 时间片与上下文切换

内核决定换下当前任务时,要保存它继续运行所必需的状态,再恢复另一个任务的状态,这叫 上下文切换(context switch)。需要保存的内容包括程序计数器、栈指针、通用寄存器以及架构相关状态;这些内容由内核保存在任务的内核栈和相关进程结构中,而不是简单塞进用户栈。

一次典型切换可以概括为:

  1. 时钟中断、阻塞、主动让出或更高优先级任务唤醒,触发调度判断。
  2. 保存当前任务的 CPU 上下文并更新运行状态。
  3. 调度器从可运行任务中选出下一个任务。
  4. 必要时切换地址空间,再恢复新任务的寄存器上下文。
  5. CPU 从新任务先前暂停的位置继续执行。

时间片可以理解为调度器允许任务连续使用 CPU 的时间预算,但现代 Linux 的具体算法不只是固定倒计时。上下文切换本身会消耗 CPU 时间,还可能扰动 Cache、TLB 和分支预测器,因此并非越频繁越好。

4.4 Linux 2.6 的 O(1) 调度器

Linux 2.6 早期内核采用过经典的 O(1) 调度器。它是理解调度数据结构的好案例,但必须明确:**这是历史实现,不是当前 Linux 普通任务调度器的完整现状。**后续内核采用 CFS,而较新的内核又引入 EEVDF 选取机制。

在这里插入图片描述

O(1) 调度器的关键设计如下:

  • 每个 CPU 拥有独立的 runqueue,多核之间需要负载均衡。
  • 优先级范围共 140 级:实时优先级 0~99,普通优先级 100~139
  • active 指向时间配额尚未耗尽的活动数组,expired 指向配额耗尽的过期数组。
  • 每个 prio_arrayqueue[140],相同优先级的任务进入对应链表。
  • 位图标记哪些优先级队列非空。32 位环境下覆盖 140 个优先级通常需要 5 个 32 位字。
  • 活动数组用尽后,交换 activeexpired 指针,不必搬移整批进程。

调度器借助位图快速找到第一个非空的最高优先级队列,选择成本不随系统进程总数线性增加,因此称为 O(1)。其核心不是“任何调度工作都只执行一步”,而是选取下一个可运行任务的时间复杂度有常数上界。

结构 作用
runqueue 保存某个 CPU 的可运行任务与调度统计
active 指向当前活动优先级数组
expired 指向时间配额已耗尽的优先级数组
queue[140] 按优先级分别组织任务
bitmap 快速判断哪些优先级队列非空
nr_active 统计数组中的活动任务数

5. 命令行参数与环境变量

5.1 argc、argv 与环境表

Shell 启动程序时,不只交付可执行代码,还会传入命令行参数和环境变量。C 程序常见入口形式是:

int main(int argc, char *argv[], char *envp[])

argc 是参数个数,argv 是以空指针结尾的参数指针数组;envp 则指向环境表。环境表同样是字符指针数组,每个元素指向一个以 \0 结尾的 NAME=value 字符串,数组末尾以 NULL 结束。

#include <stdio.h>

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

    for (int i = 0; envp[i] != NULL; ++i)
        printf("envp[%d]=%s\n", i, envp[i]);
    return 0;
}

libc 还提供全局变量 environ 指向当前进程环境表。规范用法是包含 <unistd.h> 后使用 extern char **environ;。遍历环境表适合观察整体结构;查找单个变量应优先使用 getenv()

5.2 常见变量与 Shell 命令

环境变量用于向进程传递运行配置,具有“名字对应字符串值”的形式。常见变量包括:

变量 作用 常见示例
PATH Shell 搜索可执行命令的目录列表 /usr/local/bin:/usr/bin:/bin
HOME 当前用户主目录 /home/moss
SHELL 登录 Shell 路径 /bin/bash/bin/zsh
moss@VM-0-4-ubuntu:~$ echo "$PATH"
moss@VM-0-4-ubuntu:~$ env
moss@VM-0-4-ubuntu:~$ export MYENV="hello world"
moss@VM-0-4-ubuntu:~$ unset MYENV
moss@VM-0-4-ubuntu:~$ set

其中 env 主要显示当前环境变量;set 在 Bash 中还会显示 Shell 变量、函数等更多内容。export 的关键作用不是简单“赋值”,而是把 Shell 变量标记为可导出,使后续启动的子进程能够在自己的环境表中得到它。

为什么系统命令可以直接输入名字,而自己编译的程序常要写 ./hello?Shell 会依次到 PATH 列出的目录中搜索命令,当前目录通常不在 PATH 中。下面可以完整验证:

#include <stdio.h>

int main(void)
{
    puts("hello world");
    return 0;
}
moss@VM-0-4-ubuntu:~$ gcc hello.c -o hello
moss@VM-0-4-ubuntu:~$ hello
bash: hello: command not found
moss@VM-0-4-ubuntu:~$ ./hello
hello world
moss@VM-0-4-ubuntu:~$ export PATH="$PATH:$HOME/bin"

把程序移动到 $HOME/bin 后即可通过名字运行。不要把任意可写目录随意加入 PATH,更不要把 . 放到搜索路径最前面,否则可能误执行当前目录中的同名恶意文件。

HOME~ 密切相关,但角色不同:HOME 是环境变量,~ 是 Shell 展开语法。Shell 通常把 ~ 展开成当前用户主目录,再交给 cd 等命令处理。

5.3 继承、读取与修改环境变量

Shell 中仅执行下面的赋值,会创建普通 Shell 变量:

moss@VM-0-4-ubuntu:~$ MYENV="hello"
moss@VM-0-4-ubuntu:~$ sh -c 'echo "$MYENV"'

子进程看不到它。导出后,新启动的子进程才能继承:

moss@VM-0-4-ubuntu:~$ export MYENV="hello"
moss@VM-0-4-ubuntu:~$ sh -c 'echo "$MYENV"'
hello

环境的继承方向是父进程到子进程的拷贝。子进程修改自己的环境,不会反向改变父进程;已经启动的兄弟进程也不会自动收到更新。

C 程序可使用以下 libc 接口:

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

int main(void)
{
    const char *path = getenv("PATH");
    printf("PATH=%s\n", path ? path : "<not set>");

    if (setenv("MYENV", "from-c", 1) == -1) {
        perror("setenv");
        return EXIT_FAILURE;
    }
    printf("MYENV=%s\n", getenv("MYENV"));

    unsetenv("MYENV");
    return EXIT_SUCCESS;
}

getenv() 查找变量;setenv() 负责复制名称和值并可选择是否覆盖;unsetenv() 删除变量;putenv() 则把传入字符串直接纳入环境,其生命周期和可修改性更容易踩坑。它们是 libc 函数,不是内核系统调用

需要长期生效的用户配置,可以按 Shell 的启动规则写入 ~/.bashrc~/.profile 或相应 Shell 配置文件。登录 Shell 与交互式非登录 Shell 读取的文件不同,修改后应重新登录或显式加载对应文件,而不是盲目把同一配置复制到所有启动文件。

6. 进程地址空间与虚拟内存

6.1 从代码段到栈:经典地址空间布局

进程看到的内存不是一整块没有边界的空间,而是按用途划分为多个区域。下面是经典 32 位 Linux 示意图,常用 3G 用户空间加 1G 内核空间帮助教学;具体比例受体系结构和内核配置影响,64 位系统也不是这个固定大小

在这里插入图片描述

区域 典型内容 常见特征
代码段(text) 机器指令、只读常量 通常只读且可执行
已初始化数据段 非零初值的全局/静态变量 可读写,映射自可执行文件
BSS 未初始化或零初始化全局/静态变量 装载时按零初始化
堆(heap) malloc/new 管理的动态对象 经典示意中向高地址增长
共享映射区 动态库、mmap 文件、匿名映射 位于堆与栈之间的映射区域
栈(stack) 局部变量、调用帧、保存现场 经典示意中向低地址增长
参数和环境 argv、环境字符串 进程启动时由内核布置

下面的程序可以观察各类对象的地址关系。地址受 ASLR、编译选项、架构和运行环境影响,因此应关注相对区域,不要死记具体十六进制数值。

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

int g_uninitialized;
int g_initialized = 100;

int main(int argc, char *argv[], char *envp[])
{
    static int static_value = 10;
    const char *literal = "hello";
    char *heap1 = malloc(16);
    char *heap2 = malloc(16);

    printf("code        %p\n", (void *)main);
    printf("data        %p\n", (void *)&g_initialized);
    printf("bss         %p\n", (void *)&g_uninitialized);
    printf("static      %p\n", (void *)&static_value);
    printf("heap1       %p\n", (void *)heap1);
    printf("heap2       %p\n", (void *)heap2);
    printf("stack       %p\n", (void *)&heap1);
    printf("literal     %p\n", (void *)literal);
    printf("argv[0]     %p\n", (void *)argv[0]);
    printf("envp[0]     %p\n", (void *)envp[0]);

    free(heap2);
    free(heap1);
    return 0;
}

6.2 fork 实验揭示虚拟地址

父子进程可以打印出相同变量地址,却观察到不同的变量值:

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

int g_value = 0;

int main(void)
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return EXIT_FAILURE;
    }
    if (id == 0) {
        g_value = 100;
        printf("child: value=%d, address=%p\n",
               g_value, (void *)&g_value);
    } else {
        sleep(1);
        printf("parent: value=%d, address=%p\n",
               g_value, (void *)&g_value);
    }
    return EXIT_SUCCESS;
}

典型现象是父子进程打印的地址文本一致,子进程值为 100,父进程仍为 0。这说明 C 程序打印的普通指针值是当前进程视角下的虚拟地址,不是可直接比较归属的物理地址。父子进程各自的页表可以把同一个虚拟地址映射到不同物理页。

在这里插入图片描述

在子进程写入前,父子页表还可能根据 COW 共同映射同一只读物理页;写入触发缺页异常后,内核复制页面并分别建立映射。于是“地址字符串相同”和“变量彼此独立”可以同时成立。

6.3 mm_struct、VMA 与页表

Linux 使用 mm_struct 描述一个进程的用户虚拟地址空间,task_struct 中的 mm 指针关联到它。普通用户进程通常有自己的 mm_struct;内核线程的 mm 常为 NULL,执行时可借用先前进程的地址空间上下文,相关信息通过 active_mm 表示。

mm_struct 不会为每个字节单独建记录,而是管理多个 VMA(Virtual Memory Area,虚拟内存区域)。每个 vm_area_struct 描述一段连续、权限和后端来源相近的虚拟地址区间,关键概念包括:

struct vm_area_struct {
    unsigned long vm_start;          /* 区间起始虚拟地址 */
    unsigned long vm_end;            /* 区间结束虚拟地址 */
    unsigned long vm_flags;          /* 读、写、执行等属性 */
    struct mm_struct *vm_mm;         /* 所属地址空间 */
    const struct vm_operations_struct *vm_ops;
    unsigned long vm_pgoff;          /* 文件映射偏移 */
    struct file *vm_file;            /* 文件映射的后端文件 */
};

在这里插入图片描述

在 Linux 2.6 教学模型中,VMA 同时通过链表和红黑树组织:链表适合顺序遍历,红黑树适合快速查找。现代内核实现已经演进,例如 Linux 6.1 起使用 Maple Tree 管理 VMA;但“mm_struct 描述整体、VMA 描述区间”的核心认识仍然成立。

mm_struct 还记录代码段、数据段、堆、栈、参数和环境等边界信息,例如 start_codeend_codestart_brkbrkstart_stackarg_startenv_end。**VMA 负责说明某段虚拟地址是否合法、具有什么权限和来源;页表负责把虚拟页翻译到物理页。**两者职责不同,不应混为一谈。

6.4 为什么必须有虚拟地址空间

假设程序直接使用物理地址,多进程系统会立刻面对三个难题:

  • 安全与隔离:任意程序都可能读写内核或其他进程的内存,一个越界指针就能破坏全系统。
  • 装载地址不确定:程序每次启动时空闲物理内存位置不同,代码难以使用稳定地址组织自身数据。
  • 分配与换入换出低效:必须围绕连续大块物理内存安排整个程序,碎片严重,也难以按页回收和调入。

虚拟地址、页表和分页机制把“进程如何看内存”与“物理页实际放在哪里”分离开:

能力 虚拟内存带来的效果
地址隔离 每个进程拥有独立地址空间,非法访问由内核拦截
灵活装载 连续虚拟页可映射到分散的物理页
权限控制 页表可设置只读、可写、可执行、用户/内核权限
按需分配 申请虚拟区间后,可在首次实际访问时分配物理页
共享能力 动态库、共享内存可映射到多个进程
模块解耦 进程管理使用稳定虚拟视图,内存管理独立安排物理页

调用 malloc() 成功,通常只说明进程获得了一段可用的虚拟地址范围以及用户态分配器的承诺,并不保证对应物理页已经全部就位。进程首次读写某页时可能触发缺页异常,内核再分配物理页、从文件读取内容或恢复交换页,并建立页表映射,这就是常说的按需分页与延迟分配。

CPU 中的 MMU 根据页表完成虚拟地址转换;TLB 缓存近期转换结果以降低开销。页表由内核创建和维护,用户态只能通过 mmap()brk()mprotect() 等受控接口请求改变映射。这样既保护物理内存和内核数据,也让每个进程看到整洁、连续且相对稳定的地址空间。

总结

理解 Linux 进程,关键不是背诵命令,而是建立一条完整链路:硬件围绕内存交换数据,操作系统把被管理对象“描述并组织”,task_struct 保存进程属性,调度器在可运行任务之间分配 CPU,fork() 通过独立执行上下文和写时复制创建子进程,父进程再负责回收退出状态。环境表解释了配置怎样随父子关系传递,mm_struct、VMA、页表和 MMU 则解释了指针为何是虚拟地址,以及进程隔离、按需分配和共享映射如何实现。

实践时应始终结合 /procpstop 和小型 C 程序验证现象,同时区分教学模型与当前实现:32 位 3G/1G 布局、Linux 2.6 O(1) 调度器和 VMA 红黑树都具有明确的历史版本背景。抓住稳定原理,再理解具体内核版本的数据结构,才不会把实现细节误当成永恒规则。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐