《Linux 系统编程》第三章的学习笔记。原文太长(一万五千多字),拆成两篇发,方便分段消化。
上篇回答四个问题:计算机为什么长成冯诺依曼那样;操作系统到底在「管理」什么;进程究竟是什么;它有哪些状态、怎么抢 CPU、怎么被切换。
目录
-
- 一、先看硬件:冯诺依曼体系结构
-
- [1.1 五个部件](#1.1 五个部件)
- [1.2 三条必须记住的结论](#1.2 三条必须记住的结论)
- [1.3 为什么非得这么设计](#1.3 为什么非得这么设计)
- [1.4 拿它解释一件具体的事:QQ 发一条消息](#1.4 拿它解释一件具体的事:QQ 发一条消息)
- 二、操作系统:一个纯粹的「管理者」
-
- [2.1 它到底是什么](#2.1 它到底是什么)
- [2.2 它的定位:一款纯正的「搞管理」的软件](#2.2 它的定位:一款纯正的「搞管理」的软件)
- [2.3 什么叫「管理」------学生、辅导员、校长](#2.3 什么叫「管理」——学生、辅导员、校长)
- [2.4 分层:系统调用和库函数](#2.4 分层:系统调用和库函数)
- 三、进程:一个被描述出来的执行流
-
- [3.1 三个层次的定义](#3.1 三个层次的定义)
- [3.2 PCB 与 task_struct](#3.2 PCB 与 task_struct)
- [3.3 组织:从双链表说起](#3.3 组织:从双链表说起)
- [3.4 怎么看见它:/proc、ps、top](#3.4 怎么看见它:/proc、ps、top)
- [3.5 getpid / getppid](#3.5 getpid / getppid)
- [3.6 fork:一次调用,两次返回](#3.6 fork:一次调用,两次返回)
-
- 为什么会有两个返回值
- [那为什么 `if` 和 `else if` 会「同时成立」](#那为什么
if和else if会「同时成立」) - 代码共享,数据私有:写时拷贝
- 父子谁先跑
- 四、进程状态:七个字母的故事
- [五、优先级:CPU 只有一个,进程有几百个](#五、优先级:CPU 只有一个,进程有几百个)
-
- [5.1 为什么需要优先级](#5.1 为什么需要优先级)
- [5.2 PRI 和 NI](#5.2 PRI 和 NI)
- [5.3 怎么改](#5.3 怎么改)
- [六、进程切换与 O(1) 调度](#六、进程切换与 O(1) 调度)
-
- [6.1 上下文切换到底是什么](#6.1 上下文切换到底是什么)
- [6.2 时间片](#6.2 时间片)
- [6.3 Linux 2.6 的 O(1) 调度](#6.3 Linux 2.6 的 O(1) 调度)
-
- [第一层:一个 CPU 一个 runqueue](#第一层:一个 CPU 一个 runqueue)
- [第二层:140 个优先级队列](#第二层:140 个优先级队列)
- 第三层:怎么找下一个该跑的进程
- [第四层:bitmap 的妙用](#第四层:bitmap 的妙用)
- [第五层:active 和 expired 双队列](#第五层:active 和 expired 双队列)
- [闭环:为什么叫 O(1)](#闭环:为什么叫 O(1))
- 七、命令行参数与环境变量
-
- [7.1 环境变量是什么](#7.1 环境变量是什么)
- [7.2 三个必知的环境变量](#7.2 三个必知的环境变量)
- [7.3 一个实验:为什么 `ls` 不用带路径,`./hello` 就要](#7.3 一个实验:为什么
ls不用带路径,./hello就要) - [7.4 和环境变量相关的命令](#7.4 和环境变量相关的命令)
- [7.5 环境表是怎么组织的](#7.5 环境表是怎么组织的)
- [7.6 代码里怎么拿环境变量](#7.6 代码里怎么拿环境变量)
- [7.7 全局性:能不能被子进程继承](#7.7 全局性:能不能被子进程继承)
- [7.8 留一个问题:环境表到底存在哪](#7.8 留一个问题:环境表到底存在哪)
- 八、上篇小结
-
- [8.1 一条主线](#8.1 一条主线)
- [8.2 复习清单](#8.2 复习清单)
- 下篇预告
一、先看硬件:冯诺依曼体系结构
1.1 五个部件
我们手上绝大多数计算机------笔记本、台式机、服务器------都遵守冯诺依曼体系。拆开看就五个部分:
text
┌─────────────┐ ┌─────────────┐
│ 输入设备 │ │ 输出设备 │
│ 键盘 鼠标 │ │ 显示器 打印机│
└──────┬──────┘ └──────▲──────┘
│ │
│ ┌─────────────┐ │
└───►│ 内存 │◄───┘
│ (存储器) │
└──────▲──────┘
│
┌──────┴──────┐
│ CPU │
│ 运算器+控制器│
└─────────────┘
注意箭头的方向:输入设备只指向内存,输出设备只从内存出发,CPU 也只跟内存相连。没有一条线是「键盘直连 CPU」或者「CPU 直连显示器」的。
1.2 三条必须记住的结论
课件强调了三句话,值得单独拎出来:
- 这里的「存储器」指的是内存,不是硬盘,也不是 CPU 里的寄存器。
- 不考虑缓存的情况下,CPU 能且只能对内存进行读写,不能直接访问任何外设(数据层面)。
- 外设要输入或输出数据,也只能写进内存或从内存读。
一句话总结:所有设备都只能直接和内存打交道,内存是唯一的交通枢纽。
1.3 为什么非得这么设计
这个问题的本质是速度不匹配。把各类硬件的量级摆在一起看:
| 部件 | 大致速度 | 相对 CPU 的倍数 |
|---|---|---|
| CPU 寄存器 | 亚纳秒级 | 1 |
| CPU Cache (L1) | 1~4 个周期 | 几倍 |
| 内存 | 约 100 个周期 | 百倍 |
| SSD | 微秒级 | 万倍以上 |
| 机械硬盘 | 毫秒级 | 百万倍以上 |
| 键盘输入 | 人的反应速度(百毫秒) | 上亿倍 |
如果让 CPU 直接等键盘,那就是「让法拉利等红绿灯」。所以必须有一个缓冲区在中间做速率解耦------内存就是这个缓冲区。
内存的速度虽然还是比 CPU 慢两个数量级,但至少差距缩小到了「可以用 Cache 掩盖」的程度。这就是为什么整个体系结构长成中间是内存、四周是慢速设备的样子。
顺带纠正一个常见误解:有人会问「DMA 不就是设备直接访问内存吗,那 CPU 不也参与了?」------DMA 确实是设备 ↔ 内存直接搬运,不经过 CPU 的运算器,但它依然遵守「设备只和内存打交道」这条规则,只是把「谁来搬」从 CPU 换成了 DMA 控制器。规则没被打破。
1.4 拿它解释一件具体的事:QQ 发一条消息
课件在这里留了个思考题,我展开说一下,因为这是检验「有没有真的理解冯诺依曼」的最好方式。
你在 QQ 窗口敲下「在吗」并点发送:
- 键盘 (输入设备)产生中断,把扫描码送进内存中的键盘缓冲区;
- QQ 进程(在 CPU 上跑)从内存读走这些数据,转成字符,在内存里拼成消息;
- 网卡驱动把内存里的这段数据交给网卡(输出设备)发出去;
- 对方机器的网卡 (输入设备)收到电信号,写入内存;
- 对方的 QQ 进程从内存读出来,渲染成界面,通过显卡写显存 ,最后到显示器(输出设备)。
全程没有任何一步是「CPU 直接和网卡对话」或者「键盘直接送到显示器」。你在键盘上敲的字,物理上先在内存里躺了一会儿。
如果发的是文件,唯一的区别是数据块更大,以及中间多了一层磁盘读写------文件从本地磁盘读进内存,再交给网卡。路径变了,规则没变。
二、操作系统:一个纯粹的「管理者」
2.1 它到底是什么
任何计算机系统里都有一大坨基本程序,合起来叫操作系统(Operating System)。分两块:
- 内核:进程管理、内存管理、文件管理、驱动管理
- 其他程序:函数库、shell 等等
很多人以为「我打开终端敲的 ls 就是操作系统」,其实 ls 只是跑在操作系统之上的一个普通用户程序。真正意义上的操作系统,是那个一直在后台、你根本看不见的内核。
2.2 它的定位:一款纯正的「搞管理」的软件
这句话是课件里最精辟的一句。展开就是两个方向:
- 对下:和硬件交互,管理所有软硬件资源
- 对上:给用户程序(应用程序)提供一个良好的执行环境
注意它是个软件。硬件资源(CPU、内存、磁盘、网卡)本身没有「被管理」的自觉,是操作系统用代码把它们统一纳管起来,再包装成一套接口卖给上层。
2.3 什么叫「管理」------学生、辅导员、校长
这是理解整个 Linux 内核设计思想的钥匙,课件用了这个例子:
text
校长 ──管理──► 辅导员 ──管理──► 学生
▲ │
└──────── 信息(数据)──────────┘
校长要管几千个学生,他不可能每天去宿舍点名。他的做法是:
- 描述:给每个学生建一份档案(姓名、学号、成绩、违纪记录......)
- 组织:把这些档案按学院、按班级串起来,需要时能快速找到
然后校长所有的「管理动作」,本质上都是对档案的操作,而不是对真人的操作。要给学生记过,改档案;要评奖学金,翻档案排序。学生本人甚至不知道校长动过手。
操作系统管硬件,用的是同一招:
- 描述 :用
struct结构体把硬件属性记录下来 - 组织:用链表或其他高效数据结构把这些结构体串起来
c
struct device {
char name[32];
int status;
void *regs; /* 寄存器基址 */
struct device *next;
/* ... */
};
于是「操作系统管理所有硬件」这句话,翻译成代码就是「操作系统维护着一堆结构体,并对这些结构体做增删改查」。
这条思路贯穿整章 。后面讲进程,第一句话就是「进程先被描述成
task_struct,再被组织成双链表」;讲内存,是「先描述成mm_struct,再组织成红黑树」。你会发现内核里所有子系统都是这个套路。看懂一个,后面的都能推出来。
顺便解释一个「为什么」。为什么不直接操作硬件,非要先建一份数据结构?
- 速度:查内存里的链表比读硬件寄存器快几个数量级;
- 统一 :不同厂商的网卡寄存器布局完全不同,抽象成
struct后上层代码只认结构体; - 解耦:上层只管调接口,硬件换了实现,上层不用改。
2.4 分层:系统调用和库函数
操作系统对外表现为一个整体,但它必须暴露一部分接口 给上层用,否则应用程序没法干活。这套接口叫系统调用(system call)。
text
┌──────────────────────────────────┐
│ 用户程序(QQ、vim、ls) │
├──────────────────────────────────┤
│ 库函数(glibc: printf/fopen)│ ← 对系统调用的封装
├──────────────────────────────────┤
│ 系统调用(open/read/write) │ ← 唯一合法入口
├──────────────────────────────────┤
│ 内核 │
│ 进程管理│内存管理│文件管理│驱动 │
├──────────────────────────────────┤
│ 硬件 │
└──────────────────────────────────┘
为什么要有两层,而不是让应用直接调系统调用?
- 系统调用功能基础、门槛高 。
write(fd, buf, n)只认文件描述符和字节流,你得自己处理缓冲区、错误码、格式化。这对普通开发者太不友好了。 - 跨平台 。Windows 的
CreateFile和 Linux 的open完全不同。如果 C 程序直接调系统调用,代码一行都搬不走。而fopen是 C 标准库提供的,它在 Linux 下调open、在 Windows 下调CreateFile,把差异吃掉了。 - 性能优化空间 。
printf不是每打一个字就调一次write,它先在用户态缓冲区里攒着,攒够或遇到\n才真正陷进内核。这个优化只有库函数层能做。
所以关系是:库函数是系统调用的封装,系统调用是进入内核的唯一合法通道。你在用户态写的任何代码,想动硬件、想创建进程、想读写文件,最终都必须通过系统调用「陷入」内核。
一个容易混的点 :
printf不是系统调用,write才是;malloc不是系统调用,brk/mmap才是。面试常问,别答错。
三、进程:一个被描述出来的执行流
3.1 三个层次的定义
课本定义:程序的一个执行实例,正在执行的程序。
这个定义有两个毛病:太模糊(「正在执行」到底是什么状态?),而且解释不了「同一个程序跑两份,算一个进程还是两个」。
内核观点:担当分配系统资源(CPU 时间、内存)的实体。
这个定义准了------进程是资源分配的单位。CPU 时间分给谁、内存页分给谁,都是按进程分配的。
现在的理解(最实用):
text
进程 = 内核数据结构(task_struct) + 自己的程序代码和数据
这个公式是本章的地基。它把「进程」拆成了看得见的部分(代码和数据,存在磁盘上)和看不见的部分(内核里的那坨结构体,存在内存里)。
为什么要拆成这样? 想一下「程序」和「进程」的区别:
- 程序是静态 的,就是磁盘上一个
a.out文件,睡在那里不动; - 进程是动态的,它需要 CPU 时间、需要内存空间、需要打开的文件、需要信号处理......
这些东西没法写在可执行文件里,因为它们每个时刻都在变。所以内核必须额外维护一份「运行时状态」,就是 task_struct。
一个程序可以对应多个进程(你开两个终端各跑一次 ./a.out,就是两个进程,各自的 task_struct 互不相干),但一个进程一定对应一个程序。
3.2 PCB 与 task_struct
进程的所有信息被放在一个叫**进程控制块(PCB, Process Control Block)**的数据结构里。课本统称 PCB,Linux 下的具体实现是 task_struct。
task_struct 是 Linux 内核的一种数据结构类型,会被装载到 RAM 里,包含进程的全部信息。它的字段按用途分几类:
| 分类 | 内容 | 解决什么问题 |
|---|---|---|
| 标识符 | PID、TGID | 区分不同进程 |
| 状态 | 运行/睡眠/停止/僵尸,退出代码,退出信号 | 进程现在什么情况 |
| 优先级 | 相对于其他进程的优先级 | 谁先被 CPU 执行 |
| 程序计数器 | 即将执行的下一条指令地址 | 被打断后从哪继续 |
| 内存指针 | 代码段、数据段指针,共享内存块指针 | 数据在哪 |
| 上下文数据 | 处理器寄存器中的值 | 切换回来时恢复现场 |
| I/O 状态 | 分配的 I/O 设备、打开的文件列表 | 它在等什么、拿着什么 |
| 记账信息 | CPU 时间总和、时钟数、时间限制 | 统计与限制 |
这张表不用背,但要有印象:一个进程活着所需的全部「身外之物」,都在这里。
3.3 组织:从双链表说起
所有 task_struct 在内核里以双链表的形式串起来。课件原话是「所有运行在系统里的进程都以 task_struct 双链表的形式存在内核里」。
text
tasks(链表头)
│
┌─────────┴─────────┐
▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│ PCB1 │◄►│ PCB2 │◄►│ PCB3 │
│ pid=1│ │pid=xx│ │pid=yy│
└──────┘ └──────┘ └──────┘
用双链表而不是单链表,是因为内核经常要「从当前进程找到下一个」,也可能要「从某进程回退」,双向指针让两种操作都是 O(1)。
补充一点课件没展开的 :现代 Linux 的
task_struct不止挂在一个链表上。为了快速按 PID 查找,它同时被挂在一棵红黑树 (pid_hash/pid_rbtree)和哈希表里;为了调度,它还要挂在运行队列的红黑树中。同一个结构体可以同时属于多个容器 ------这是内核数据结构设计的常见手法,靠的是把list_head/rb_node作为成员嵌进结构体,而不是让结构体去继承容器。这个技巧叫「侵入式链表」,是读内核代码必须跨过的一道坎。
3.4 怎么看见它:/proc、ps、top
进程信息可以通过 /proc 这个伪文件系统 查看。要获取 PID 为 1 的进程信息,就看 /proc/1/ 这个目录:
bash
ls /proc/1/
# attr cmdline cwd environ exe fd maps status ...
/proc 里的文件不是真实存在于磁盘上的,是内核在你读取时才动态生成 的。读 /proc/1/status 就是让内核现场把 task_struct 里的字段格式化成文本吐给你。这就是「描述 + 组织」的直接体现------你看到的一切,都是结构体的投影。
常用的用户级工具:
bash
ps aux # 所有进程,用户友好格式
ps axj # 显示进程组、会话、父进程等作业控制信息
top # 动态刷新
几个参数的含义(容易记混):
| 参数 | 作用 |
|---|---|
a |
显示一个终端所有进程,包括其他用户的 |
x |
显示没有控制终端的进程(如后台守护进程) |
u |
以用户为中心格式显示,含 CPU/内存占用 |
j |
显示进程组 ID、会话 ID、父进程 ID |
3.5 getpid / getppid
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("pid: %d\n", getpid());
printf("ppid: %d\n", getppid());
return 0;
}
每次运行的 PID 都不一样(内核分配,通常递增),但 PPID 通常是固定的------因为你的程序是被 shell(bash)或者 IDE fork 出来的,父进程一直是那个 shell。
bash
$ ./a.out
pid: 12345
ppid: 12300
$ echo $$ # shell 自己的 pid
12300
对上了。这就是「进程之间有父子关系」的最直接证据。
3.6 fork:一次调用,两次返回
这是本章第一个硬骨头。
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
printf("hello proc : %d!, ret: %d\n", getpid(), ret);
sleep(1);
return 0;
}
输出:
text
hello proc : 12345!, ret: 0
hello proc : 12344!, ret: 12345
一行 printf,打了两遍。因为 fork 之后,世界上多了一个进程,两个进程各自从 fork 之后继续往下跑。
fork 的返回值规则:
| 返回值 | 含义 |
|---|---|
< 0 |
创建失败,errno 被设置 |
== 0 |
当前是子进程 |
> 0 |
当前是父进程,返回值是子进程的 PID |
所以正常写法一定要分流:
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
if (ret < 0) {
perror("fork");
return 1;
} else if (ret == 0) {
printf("I am child : %d!, ret: %d\n", getpid(), ret);
} else {
printf("I am father : %d!, ret: %d\n", getpid(), ret);
}
sleep(1);
return 0;
}
为什么会有两个返回值
关键在于:「返回值」这个动作,是在两个进程里各做了一次。
fork 在内核里做的事情,粗略说是三步:
- 复制父进程的
task_struct、地址空间(页表)、文件描述符表,造出一个新的 PCB; - 把这个新 PCB 挂进调度队列,让它变成「可运行」;
- 把子进程的 PID 塞进父进程的返回值存放位置(x86 上是
eax寄存器),把 0 塞进子进程的返回值存放位置。
第 3 步是精髓。内核在返回用户态之前,改了两个不同进程的寄存器。于是:
- 父进程从
fork返回,看到eax = 子进程 PID; - 子进程从「它诞生的那一刻」返回,看到
eax = 0。
它们看到的不是同一个值,因为寄存器本来就不是同一份。
那为什么 if 和 else if 会「同时成立」
初学者最困惑的是这个:ret 明明是一个变量,怎么能既等于 0 又不等于 0?
因为 ret 也不是同一个变量了。
fork 之后,父子进程有各自独立的地址空间 。父进程的 ret 和子进程的 ret 是两个不同物理内存位置上的两份数据,只是它们在各自的虚拟地址里「看起来地址相同」(下一节讲地址空间时会解释这个诡异现象)。
所以严格来说,不是「一个变量同时满足两个分支」,而是「两个进程各走各的分支」。你以为你写了一个程序,实际上操作系统把它变成了两个。
代码共享,数据私有:写时拷贝
课件这里写了一句很关键的话:父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)。
fork 刚完成时,内核并没有真的把父进程的内存复制一遍。它做的是:
- 让子进程的页表指向父进程的同一批物理页;
- 把这些页的权限改成只读(原本可写的也先标成只读)。
然后父子各自跑。只要没人写,就一直共享,一分钱内存都不多花。
一旦某一方试图写某个页,CPU 触发缺页异常,内核一看:「哦,这个页是共享的,有人在写」------于是真正复制一份新的物理页,把写的那一方的页表指向新页,恢复可写,然后重新执行那条写指令。
text
fork 之前: [ 物理页 A ]
▲ ▲
父页表 子页表
fork 之后(未写): [ 物理页 A ](只读)
▲ ▲
父页表 子页表
子进程写入后: [ 物理页 A ] [ 物理页 B ]
▲ ▲
父页表 子页表
这叫 Copy-on-Write(COW,写时拷贝) 。好处很直接:fork 变得极快,而且如果子进程马上就 exec(这是最常见的用法),那一整份复制就白做了。
一个必须澄清的误解 :很多人说「
fork会复制父进程的全部内存」,这在语义上对,在实现上是错的。实际复制发生在第一次写的时候,而且只复制被写的那一页。这个差别在进程体积很大时是数量级的。
父子谁先跑
不确定。 完全由调度器决定。可能父先、可能子先、可能交替。任何依赖「父进程一定先执行」的代码都是错的,必须用 wait/waitpid 或者管道、信号来同步。
四、进程状态:七个字母的故事
4.1 内核源码怎么定义的
Linux 内核里进程也叫「任务」(task)。状态定义在源码里:
c
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char *const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
先注意那段注释,它解释了一个很奇怪的现象:为什么这些值不是 0、1、2、3、4、5、6,而是 0、1、2、4、8、16、32?
因为内核注释说得很清楚------这是一个 bitmap of reasons to sleep(睡眠原因位图)。每个值是 2 的幂,占一个独立的比特位:
text
R = 0b000000
S = 0b000001
D = 0b000010
T = 0b000100
t = 0b001000
X = 0b010000
Z = 0b100000
这样设计的好处是可以用位运算组合判断 。比如内核想知道「这个进程是不是睡着」,不需要写 if (state == T || state == t || state == S || state == D),只要 if (state & 0x1F) 一句话。而且 R 被设计成 0,意味着「运行」这个最常见的情况判定成本最低(state == 0 一条指令)。
这就是「为什么源码要那样写」的典型例子。看内核代码时,遇到 2 的幂的常量,第一反应就应该是「这是位标志」。
4.2 逐个说清楚
R --- 运行状态(running)
这个状态不代表进程此刻一定在 CPU 上跑。它表示:进程要么正在运行,要么在运行队列里排队等着被运行。两者都算 R。
这是最容易被误解的一条。你在单核机器上一个死循环程序 while(1);,ps 看到它是 R,但 CPU 显然不可能 100% 只给它------它其实大部分时间在排队。
S --- 可中断睡眠(interruptible sleep)
进程在等某个事件完成,比如等用户输入、等 sleep 时间到、等网络数据到达。这个等待是可被信号打断 的------你 kill 它,它会醒过来处理信号。
绝大多数「闲着」的进程都在 S 状态。比如你的 shell 在等你敲命令,就是 S。
D --- 不可中断睡眠(disk sleep / uninterruptible sleep)
进程通常在等待 I/O 结束,而且这个等待不响应信号。
这是最需要小心理解的状态。为什么会有「不可被打断」的睡眠?因为内核在做某些关键操作时,如果被信号打断,数据结构可能处于半完成状态,会崩溃。典型场景:进程向磁盘发起写请求,内核必须等磁盘返回「写成功/失败」,才能知道数据到底落盘没有,中途不能撤。
特别注意:kill -9 对 D 状态的进程无效。 你发了信号,它也不会醒,因为它的唤醒条件只有「I/O 完成」。这时候你只能等,或者处理底层 I/O 问题(比如 NFS 服务器挂了,一堆进程会卡在 D 状态)。这也是为什么有时候 kill -9 杀不掉进程------不是 kill 不灵,是进程压根没在听。
T --- 停止状态(stopped)
通过 SIGSTOP 信号可以让进程进入 T 状态(暂停)。这个被暂停的进程可以通过 SIGCONT 信号恢复运行。
注意 SIGSTOP 和 SIGCONT 不能被捕获、不能被忽略 ,这是内核强制规定的。对比一下 SIGINT(Ctrl+C)是可以被程序捕获并自行处理的。
t --- 跟踪停止(tracing stop)
被调试器(gdb)跟踪的进程在断点处停下来,就是这个状态。和 T 的区别是:T 是被人为 SIGSTOP 停的,t 是被调试器停的。
X --- 死亡状态(dead)
这只是一个返回状态 ,你不可能在任务列表里看到它。进程真正被销毁的瞬间太短暂,ps 抓不到。
Z --- 僵尸状态(zombie)
下面单独讲,这是重点。
4.3 僵尸进程
怎么产生的
text
子进程退出 → 内核保留它的退出状态(退出码)→ 等父进程来读
↓
父进程一直不读(没调 wait)
↓
子进程进入 Z 状态
课件原话:僵死状态是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时,就会产生僵死进程。
复现代码(父子各睡一会儿,制造时间窗口):
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
} else if (id > 0) { /* 父进程 */
printf("parent[%d] is sleeping...\n", getpid());
sleep(30); /* 父进程不回收,干睡 */
} else { /* 子进程 */
printf("child[%d] is begin Z...\n", getpid());
sleep(5);
exit(EXIT_SUCCESS); /* 子进程先退出 */
}
return 0;
}
编译后运行,在另一个终端里监控:
bash
ps aux | grep a.out
# 能看到 <defunct> 字样,STAT 列是 Z+
<defunct> 就是「已死但未安葬」。Z+ 里的 + 表示在前台进程组。
为什么必须保留
有人会问:进程都退出了,为什么不干脆立刻把 task_struct 释放掉,还要留个僵尸?这不是浪费吗?
因为退出状态是有价值的 。进程退出时,它要告诉父进程一件事:「你交给我的任务,我办得怎么样了」。这个信息就是退出码 (exit(0) 的 0、main 返回的值、或者被信号杀死时的信号编号)。
如果内核立刻把 PCB 释放了,父进程再去 wait 的时候就什么都拿不到了------它甚至不知道子进程是正常结束还是段错误挂掉的。
所以内核的设计是:进程的代码和数据可以释放,但 PCB 里的退出状态必须留着,等父进程来取。 取走之后(wait 成功返回),PCB 才真正销毁。
这也解释了为什么僵尸进程看起来「什么也没干」却占着资源------它占的是内核里的一个 task_struct,不是内存里的代码段。
危害
课件列了几条,我串起来说:
- PCB 本身要占内存 。
task_struct是个几百字节到几 KB 的结构体(现代内核约 9KB 含相关结构),fork一万次不回收,就是几十 MB 的内核内存被白白占着。 - PID 会耗尽 。Linux 的 PID 是有限的(默认
pid_max通常是 32768 或 4194304),僵尸不会释放 PID。僵尸攒多了,新进程fork就会失败,报EAGAIN。这是比内存泄漏更致命的问题------新进程起不来,整个服务就废了。 - 无法用信号清理 。僵尸已经是死人了,
kill -9没有任何意义。唯一的办法是让它的父进程 去wait,或者干脆把父进程杀掉,让它变成孤儿被init收养,由init来回收。
面试常考的追问:「怎么清理僵尸进程?」标准答案是:杀掉父进程,让子进程变孤儿被 1 号进程收养并回收。但要注意副作用------父进程死了,它的其他子进程也全变孤儿了。
4.4 孤儿进程
反过来:父进程先退出,子进程后退出,这个子进程就叫孤儿进程。
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
} else if (id == 0) { /* 子进程 */
printf("I am child, pid : %d\n", getpid());
sleep(10); /* 子进程活得久 */
} else { /* 父进程 */
printf("I am parent, pid: %d\n", getpid());
sleep(3);
exit(0); /* 父进程先死 */
}
return 0;
}
父进程死后,子进程变成孤儿,被 1 号进程(init 或 systemd)领养 。用 ps 看,它的 PPID 会变成 1。
孤儿进程有害吗?没有。 因为 init/systemd 是个尽职的养父,它会 wait 每一个被收养的子进程,所以孤儿退出后会被正常回收,不会变僵尸。
text
父进程活着,子进程死了 + 不回收 → 僵尸(有害,占 PCB)
父进程死了,子进程还活着 → 孤儿(无害,被 init 收养)
这两个概念经常被搞混,记住一句话就行:看谁先死。
五、优先级:CPU 只有一个,进程有几百个
5.1 为什么需要优先级
CPU 资源只有少量,甚至只有一个,而系统里的进程数目众多。这就是竞争性------进程之间天然在抢资源。
为了「高效完成任务,更合理地竞争相关资源」,就有了优先级:优先权高的进程有优先执行的权利。
补充两个必须一起理解的概念:
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。你写的程序里一个野指针崩了,不会影响隔壁 QQ 跑得好好的------这就是独立性,靠的是各自独立的地址空间(第八节细讲)。
并行 vs 并发(这两个概念每年都有人弄混):
| 并行(Parallel) | 并发(Concurrent) | |
|---|---|---|
| CPU 数量 | 多个 | 一个 |
| 机制 | 真正同时运行 | 快速切换,轮流推进 |
| 宏观感受 | 同时 | 「看起来」同时 |
| 例子 | 4 核跑 4 个计算任务 | 1 核跑 100 个进程 |
text
并行(2 核 2 进程):
CPU0: ──A──A──A──A──
CPU1: ──B──B──B──B── 真同时
并发(1 核 2 进程):
CPU0: ─A─B─A─B─A─B── 切换,宏观上都在推进
一句话:并发是逻辑上的同时,并行是物理上的同时。单核机器上你说的「同时聊微信和听歌」是并发,不是并行。
5.2 PRI 和 NI
ps -l 能看到几个关键字段:
bash
$ ps -l
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 1234 1233 0 80 0 - 1234 wait pts/0 00:00:00 bash
| 字段 | 含义 |
|---|---|
| UID | 执行者身份 |
| PID | 进程代号 |
| PPID | 父进程代号 |
| PRI | 可被执行的优先级,值越小越早被执行 |
| NI | nice 值,优先级的修正数值 |
公式:
text
PRI(new) = PRI(old) + nice
几个要点:
- nice 值为负 时,
PRI(new)变小 → 优先级变高 → 更快被执行。 - nice 的取值范围是 -20 到 19,一共 40 个级别。
- 所以调优先级,在 Linux 下就是调 nice 值。
- nice 不是优先级,它是优先级的修正数据。这个区别在面试里经常被拿来挖坑。
一个容易踩的坑 :课件里说
PRI(old)是固定的 80,每次调整都是在 80 的基础上加 nice,而不是在「上一次调整后的值」上累加。也就是说,renice是覆盖 而不是叠加 。你把 nice 从 0 调到 -5,PRI 变 75;再调到 5,PRI 是 85,不是 80。原因是内核里PRI = 80 + nice是算出来的,不是存下来的。补充一个显示口径的问题 :
ps里看到的 PRI 基准是 80,而内核代码里普通优先级范围是 100~139(nice 0 对应 120)。两者差 40,纯粹是历史遗留的显示习惯。看到「100~139」和「80」不要觉得矛盾,它们是同一样东西的两种刻度。
5.3 怎么改
方法一:top 交互式
bash
top
# 按 r → 输入 PID → 输入 nice 值
方法二:命令行
bash
nice -n 10 ./a.out # 启动时指定
renice -n -5 -p 1234 # 修改已运行进程
方法三:系统调用
c
#include <sys/time.h>
#include <sys/resource.h>
int getpriority(int which, int who);
int setpriority(int which, int who, int prio);
which 可以是 PRIO_PROCESS(按进程)、PRIO_PGRP(按进程组)、PRIO_USER(按用户)。
权限规则要记住 :普通用户只能调高 nice(降低优先级) ,想调低(提高优先级)必须 root。这是为了保护系统------否则谁都能把自己设成 -20,把别人饿死。这个规则和「普通用户不能
chown给别人」是同一个思路:不能让用户获得破坏公平性的能力。
六、进程切换与 O(1) 调度
6.1 上下文切换到底是什么
CPU 上下文切换(context switch),实际含义就是任务切换,或者叫 CPU 寄存器切换。
过程:
text
1. 内核决定要换人了
2. 把当前任务的 CPU 寄存器全部内容,保存到该任务自己的栈(或 PCB)里
→ 这就是"上下文"
3. 把下一个任务的寄存器内容,从它的栈里重新装回 CPU 寄存器
4. 跳到新任务的代码继续执行
为什么必须保存寄存器?因为 CPU 只有一套寄存器,而进程有几百个。寄存器里存着程序计数器(下一条指令在哪)、栈指针(栈顶在哪)、各种临时变量。这些一旦被覆盖,进程就再也回不来了。
text
进程 A 运行中 切换 进程 B 运行
────────────── ────── ──────────────
CPU 寄存器: CPU 寄存器:
eip = 0x401000 eip = 0x402000
esp = 0x7ffd1000 esp = 0x7ffd2000
eax = 42 eax = 7
│ ▲
│ ① 全存进 A 的栈/PCB │ ② 从 B 的栈/PCB 恢复
▼ │
A.stack: [eip,esp,eax...] B.stack: [eip,esp,eax...]
6.2 时间片
当代计算机都是分时操作系统 ,每个进程有它合适的时间片(本质就是一个计数器)。时间片一到,进程就被操作系统从 CPU 上剥离下来,换下一个。
这个「剥离」是强制的,进程自己无法拒绝(除非它在内核态做不可中断的关键操作,也就是 D 状态的那段时间)。
顺带说一句切换的成本 :上下文切换不是免费的。除了保存恢复寄存器,更贵的是 Cache 和 TLB 失效------新进程的数据不在 Cache 里,得从内存重新加载;新进程的页表映射不在 TLB 里,得重新查页表。一次切换的代价在微秒级,看着不多,但一秒切几万次就非常可观。这就是为什么「进程不是越多越好」,也是后来线程(共享地址空间,切换不用换页表)被发明出来的重要原因。
6.3 Linux 2.6 的 O(1) 调度
这是本章最抽象的一块,我们拆成几层看。
第一层:一个 CPU 一个 runqueue
每个 CPU 有自己的运行队列(runqueue)。
text
CPU0 ──► runqueue0 ──► 待运行的进程们
CPU1 ──► runqueue1 ──► 待运行的进程们
为什么不让所有 CPU 共用一个?因为共享就要加锁,加锁就有竞争,竞争就慢。一个 CPU 一个队列,大多数操作都是本地的,不需要锁。
代价是出现了负载均衡 问题:如果 CPU0 排了 100 个进程,CPU1 只有 1 个,那就浪费了。所以需要一套机制在 CPU 之间迁移进程。课件只点了一句「如果有多个 CPU 就要考虑进程个数的负载均衡问题」,实际内核里有 sched_domain、load_balance 一整套逻辑。
第二层:140 个优先级队列
c
struct prio_array {
unsigned int nr_active; /* 有多少个可运行的进程 */
DECLARE_BITMAP(bitmap, MAX_PRIO + 1); /* 每个优先级一个比特位 */
struct list_head queue[MAX_PRIO]; /* 140 个链表头 */
};
- 普通优先级:100 ~ 139(我们的进程都在这,正好对应 nice 的 -20~19)
- 实时优先级:0 ~ 99(实时进程,本章不关心)
queue[140] 是个数组,数组下标就是优先级 。相同优先级的进程在同一个链表里,按 FIFO 规则排队。
text
queue[0] → (空,实时优先级)
...
queue[100] → [进程A] → [进程B] ← nice = -20
...
queue[120] → [进程C] ← nice = 0
...
queue[139] → [进程D] → [进程E] ← nice = 19
第三层:怎么找下一个该跑的进程
朴素做法:从下标 0 开始遍历 queue[140],找到第一个非空的队列,取它的队头。
c
for (int i = 0; i < 140; i++) {
if (!list_empty(&queue[i])) {
next = list_first_entry(&queue[i]);
break;
}
}
这是常数时间(最多 140 次比较,与进程数量无关),但课件评价得很直接:「时间复杂度是常数!但还是太低效了!」
为什么低效?因为 140 次循环每次都跑,而实际上绝大多数队列是空的。CPU 的时间应该花在进程上,不是花在找进程上。
第四层:bitmap 的妙用
text
bitmap[5]: 一共 140 个优先级,用 5*32 个比特位表示队列是否为空
32 位平台上 unsigned long 是 32 位,5 个 long = 160 个比特位,够表示 140 个优先级还剩下 20 个位置。哪个队列非空,就把对应的比特位置 1:
text
bitmap: 0000 0000 ... 0001 0000 0001 0000 ...
↑ ↑
queue[120] queue[128]
查找:不遍历数组,直接找"第一个为 1 的比特位"
→ 一条 CPU 指令(bsf / __ffs)搞定
→ 真正的 O(1)
从 140 次循环压缩到一条位扫描指令,这就是「提高查找效率」的全部含义。
补一句 64 位的情况 :现在都是 64 位机器,
unsigned long是 64 位,DECLARE_BITMAP(bitmap, MAX_PRIO+1)会自动算成 3 个 long(141/64 向上取整)。课件里的bitmap[5]是 32 位时代的写法。看内核代码时要注意这种跟着平台走的宏。
第五层:active 和 expired 双队列
问题来了:如果只有一套队列,一个进程时间片用完了,把它放回原队列,那它可能马上又被选中------因为它的优先级还是最高的。结果是高优先级进程把 CPU 全吃掉,低优先级进程饿死。
解法是两套队列:
c
struct rq {
...
struct prio_array *active, *expired, arrays[2];
...
};
- active 队列:时间片还没结束的进程
- expired 队列:时间片已经耗尽的进程
新进程进来放 active。跑完时间片就挪到 expired,并且重新计算时间片 。等 active 队列被处理空了(nr_active == 0),把 active 和 expired 两个指针交换:
text
交换前:
active ──► arrays[0](已空)
expired ──► arrays[1](塞满了时间片耗尽的进程,时间片已重算)
交换后:
active ──► arrays[1](这批进程又满血复活了)
expired ──► arrays[0](空,等着接收新的耗尽者)
这个交换只是一次指针赋值,O(1)。 不需要搬数据、不需要重建队列。
这就是 O(1) 调度器的核心技巧:把所有 O(n) 的操作(重算时间片、重新排队)都摊到单个进程身上,把全局操作压缩成 O(1) 的指针交换。
闭环:为什么叫 O(1)
课程总结说得很清楚:
在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度 O(1) 算法。
注意关键词是不随进程增多而增加 。进程从 10 个涨到 10 万个,选下一个该跑谁的时间不变。这在 2.6 时代是个了不起的突破,因为之前的调度器(O(n) 调度器)每次调度都要遍历整个进程链表,进程一多就卡。
补一段课件外的背景 :O(1) 调度器后来被完全公平调度器(CFS)取代了(2.6.23 起)。原因不是 O(1) 不够快,是它不公平 ------它靠「优先级 + 固定时间片」分配 CPU,对交互式进程(比如你敲键盘的那个终端)响应不够好,而且睡眠惩罚机制很粗糙。CFS 换成了红黑树 + 虚拟运行时间(
vruntime),查找是 O(log n),虽然理论上慢了,但公平性和交互体验好得多。学 O(1) 调度器的价值不在于它现在还在用,而在于它展示了「用数据结构把复杂度打下来」的思路------bitmap 位扫描、双队列指针交换,这两个技巧在很多地方都能见到。
七、命令行参数与环境变量
7.1 环境变量是什么
**环境变量(environment variables)**一般是指在操作系统中用来指定运行环境的一些参数。
课件举了个特别好的例子:我们写 C/C++ 代码时,链接的时候从来不知道所链接的动态静态库在哪里,但照样能链接成功------原因就是有相关环境变量帮编译器查找。
环境变量有两个特点:
- 通常具有某些特殊用途(不是给你随便存的)
- 通常具有全局特性(可以被所有进程看到、被子进程继承)
7.2 三个必知的环境变量
| 变量 | 含义 |
|---|---|
PATH |
指定命令的搜索路径 |
HOME |
用户的主工作目录(登录时默认进入的目录) |
SHELL |
当前 shell,值通常是 /bin/bash |
查看方式:echo $NAME
bash
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$ echo $HOME
/home/y3562
$ echo $SHELL
/bin/bash
7.3 一个实验:为什么 ls 不用带路径,./hello 就要
bash
$ cat hello.c
#include <stdio.h>
int main()
{
printf("hello world!\n");
return 0;
}
$ gcc hello.c -o hello
$ ./hello
hello world!
$ hello
bash: hello: command not found
为什么 ls 直接敲就能跑,hello 必须加 ./?
因为 shell 执行命令时,会去 PATH 里列的每个目录下找同名文件 。ls 在 /usr/bin/ls,/usr/bin 在 PATH 里,所以找到了。而你的 hello 在当前目录,当前目录不在 PATH 里 (注意:. 默认不在 PATH 中,这是刻意的安全设计),所以找不到。
验证一下 PATH 的作用:
bash
$ export PATH=$PATH:$PWD
$ hello
hello world!
把当前目录加进 PATH,hello 就能直接跑了。
为什么不把
.默认放进 PATH? 因为那等于把「当前目录下的任何可执行文件」都变成「可信命令」。你在/tmp下解压了一个压缩包,里面有个叫ls的恶意程序,你敲ls就中招了。这是经典的安全设计取舍。
还有另一个办法能不带路径运行:把程序放到 PATH 里已有的目录 (比如 /usr/local/bin,需要 root 权限)。
7.4 和环境变量相关的命令
| 命令 | 作用 |
|---|---|
echo $NAME |
显示某个环境变量的值 |
export NAME=value |
设置一个新的环境变量 |
env |
显示所有环境变量 |
unset NAME |
清除环境变量 |
set |
显示本地定义的 shell 变量和环境变量 |
注意 env 和 set 的区别:env 只显示环境变量,set 显示 shell 变量 + 环境变量。这个区别是 7.7 节实验的关键。
7.5 环境表是怎么组织的
每个程序都会收到一张环境表:
text
环境表 (char *env[])
┌──────────┐
env[0] │ *─────┼──► "PATH=/usr/local/sbin:..."
├──────────┤
env[1] │ *─────┼──► "HOME=/home/y3562"
├──────────┤
env[2] │ *─────┼──► "SHELL=/bin/bash"
├──────────┤
... │ ... │
├──────────┤
env[n] │ NULL │ ← 结尾标记
└──────────┘
环境表是一个字符指针数组 ,每个指针指向一个以 '\0' 结尾的环境字符串,格式是 NAME=value。数组以 NULL 结尾------所以遍历时可以写 for (i = 0; env[i]; i++),靠的就是最后那个空指针。
这个结构和 argv 完全一样,都是 char *[] + NULL 结尾。这不是巧合,它们在内核里就是挨着放的,都在用户栈的最顶端(第八节会看到它们的地址)。
7.6 代码里怎么拿环境变量
方式一:main 的第三个参数
c
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for (; env[i]; i++) {
printf("%s\n", env[i]);
}
return 0;
}
main 其实可以有三个参数,第三个就是环境表。这个参数是启动代码(crt0)帮你传进来的,不是你写了才有。
方式二:第三方变量 environ
c
#include <stdio.h>
int main(int argc, char *argv[])
{
extern char **environ;
int i = 0;
for (; environ[i]; i++) {
printf("%s\n", environ[i]);
}
return 0;
}
libc 里定义了一个全局变量 environ,指向环境变量表。它没有包含在任何头文件里 ,所以用的时候必须自己 extern 声明。这是个历史遗留问题,但面试可能会问。
方式三:getenv(最常用)
c
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
getenv 按名字查,返回指向值部分的指针(NAME= 后面那段),找不到返回 NULL。配套的还有 putenv/setenv,用来设置。
7.7 全局性:能不能被子进程继承
课件这个实验很经典,值得亲手做一遍。
c
#include <stdio.h>
#include <stdlib.h>
int main()
{
char *env = getenv("MYENV");
if (env) {
printf("%s\n", env);
}
return 0;
}
第一次跑:
bash
$ ./a.out
$ # 没输出,说明 MYENV 不存在
导出后再跑:
bash
$ export MYENV="hello world"
$ ./a.out
hello world
结论:环境变量可以被子进程继承。
然后做对比实验(课件留的思考题):
bash
$ MYENV2="hello world" # 只赋值,不 export
$ ./a.out # 读 MYENV2 → 读不到
为什么?这里牵扯到 shell 的一个关键区分:
| shell 变量(本地变量) | 环境变量 | |
|---|---|---|
| 定义方式 | NAME=value |
export NAME=value |
| 存在位置 | 只在当前 shell 进程内 | 在 shell 的环境表里 |
| 能否被子进程继承 | 不能 | 能 |
env 能看到吗 |
不能 | 能 |
set 能看到吗 |
能 | 能 |
fork 出的子进程只继承环境表 ,不继承 shell 的普通变量。export 的作用就是把一个本地变量「提升」到环境表里。
这也解释了另一个现象:
bash
$ bash # 开一个子 shell
$ echo $MYENV2 # 空
$ exit
$ echo $MYENV2 # 有值(回到父 shell 了)
再补一个延伸 :想让变量永久生效,得写进配置文件。
~/.bashrc(每次开交互式 shell 读)、~/.bash_profile(登录时读)、/etc/profile(全局)。但要注意:改了~/.bashrc之后必须source ~/.bashrc或者重新登录,否则当前 shell 用的是老环境。
7.8 留一个问题:环境表到底存在哪
到这里,「环境变量能被子进程继承」这件事已经被实验证实了。但还有一个问题没答:
这张环境表,在进程的内存里究竟放在什么位置?为什么它天然就能被继承?
这不是一个玄学问题,它有精确的答案------就写在进程地址空间的最顶端。下篇第二章打印地址时会看到它。
八、上篇小结
8.1 一条主线
text
硬件:冯诺依曼 → 所有设备只和内存打交道 → 必须有中间人协调
↓
中间人:操作系统 → 管理 = 描述(struct)+ 组织(链表/树)
↓
管理对象:进程 → 描述成 task_struct,组织成链表
↓
进程活着会怎样:
① 有状态(R/S/D/T/X/Z)→ 状态字段存在 task_struct 里
② 抢 CPU(PRI/NI) → 优先级字段存在 task_struct 里
③ 被切换(上下文) → 上下文存在 task_struct 里
这一篇的所有内容,都可以归到「描述 + 组织」这四个字上。 冯诺依曼是为什么要描述,操作系统是怎么描述,进程是描述的结果,状态/优先级/上下文是描述的具体字段,调度是组织的方式。
8.2 复习清单
冯诺依曼
- 为什么 CPU 不能直接读硬盘?DMA 算不算打破了这个规则?
- 从你敲键盘到对方看到消息,数据在内存里走了几趟?
操作系统
- 「管理 = 描述 + 组织」,在进程管理里具体对应什么?
-
printf和write是什么关系?malloc和brk呢? - 为什么需要库函数这一层,不能直接调系统调用?
进程
- 程序和进程的区别?一个程序能有几个进程?
-
task_struct里的「上下文数据」是干什么用的?不存会怎样? -
fork为什么有两个返回值?if/else为什么「都成立」? - 写时拷贝解决了什么问题?如果不做 COW 会怎样?
-
fork之后父子谁先跑?
状态
-
task_state_array为什么是 0/1/2/4/8/16/32? - R 状态等于「正在 CPU 上跑」吗?
- D 状态为什么
kill -9杀不掉? - 僵尸进程占的是什么资源?为什么不立刻释放?
- 僵尸和孤儿的区别,一句话说清?
优先级与调度
- nice 是优先级吗?范围是多少?
-
renice是叠加还是覆盖? - 并发和并行的区别?
- O(1) 调度里的 bitmap 省掉了什么操作?
- active/expired 双队列为什么能防止饿死?
下篇预告
上篇有一个问题从头到尾没回答:
fork之后,父子进程打印同一个变量,地址一模一样,值却不一样。
这用「内存就是一块连续的物理空间」这个直觉完全解释不了------同一个门牌号,不可能进到两个不同的房间。
下篇就从这个矛盾开始,一路讲到 mm_struct、VMA、两级页表、PTE 位布局、缺页中断和写时拷贝。见《Linux 进程概念(下):程序地址空间与虚拟内存》。
如果这篇帮你把「进程是什么」理顺了,点个赞再走。