如果你上过 C 语言的课,你会知道程序是怎么写出来的——变量、函数、循环,最终编译成可执行文件躺在硬盘上。但你有没有想过一个更底层、也更重要的问题:这个躺在硬盘上的文件,是怎么变成 CPU 上"正在跑"的那一堆指令的? Linux 上我们天天挂在嘴边的"进程"到底是什么?为什么同一个变量在两次运行里打印的地址一模一样,可内容却各自不同?为什么有时候你的程序死掉了,top 里还能看到一个杀不掉的 Z 状态进程?
这一篇是 Linux 系统编程系列的"进程篇"。我们从计算机最底层的冯诺依曼体系讲起,一路走过操作系统怎么"管理"资源、系统调用和库函数是什么关系,然后重点拆解进程本身——PCB 是什么、怎么描述一个进程、进程有哪些状态、什么叫僵尸/孤儿进程、fork 和 exec 是怎么回事,再到进程优先级、进程切换和历史上著名的 O(1) 调度算法,最后落到命令行的参数、环境变量,以及"进程地址空间"这颗理解现代计算机内存的定心丸。
整个过程我会尽量把每个概念讲透,配上能亲手敲、能跑、能验证的命令和代码,遇到边界和坑会单独标出来。任何地方你如果真的动手做了一遍,那获得的收益远比你"看懂了"要大得多。
你应该有的知识准备
在往下读之前,建议你先对下面几样东西有概念,这里只做一句话的回顾,不清楚可以回头补:
- 编译与链接:源代码从
.c文件到可执行程序,要经过预处理、编译、汇编、链接四步。可执行文件里既有我们的指令,也有元信息。 - 内存与地址:程序运行是把指令和数据装上内存。地址分物理地址和后面要讲的虚拟地址,这是理解进程地址空间的关键。
- C 语言指针与
printf:我们很多验证代码要用%p打印地址、用getpid()拿进程号,基础语法你最好顺手。 - 基本的 Shell 命令:会开终端、用
ps、top、echo、export,能编译运行 C 程序(gcc)。
如果你已经在用 Linux,这些大概都碰过;万一某条还生疏,恰好说明下面这堂课对你有大用。
冯诺依曼体系结构
一切从硬件讲起。我们常见的计算机,不管是笔记本、台式机,还是你实验室机柜里那些一摞摞的服务器,绝大多数都遵守冯诺依曼体系结构(Von Neumann architecture)。这个结构是美籍匈牙利数学家和冯·诺依曼在 1945 年提出的,至今仍是绝大多数通用计算机的原型。
所谓"体系结构",说的是计算机由哪些硬件组件组成、这些组件之间怎么协作。经典划分是这样的——计算机由一个个硬件组件构成:
- 输入单元:键盘、鼠标、扫描仪、写字板等,负责把外部信息送进计算机。
- 中央处理器(CPU):含有运算器(ALU,算术逻辑单元,负责加减乘除、逻辑判断)和控制器(负责取指令、指挥执行)等,是计算机的"大脑"。
- 存储器:主要指内存(RAM),临时存放指令和数据,断电即失。
- 输出单元:显示器、打印机等,负责把结果展示出来。
关于冯诺依曼体系,有几条"铁律"你必须刻进脑子:
- 这里的存储器指的就是内存,不是硬盘。硬盘在这里被归到输入/输出设备那一类。
- 不考虑缓存的情况下,CPU 能且只能对内存进行读写,不能直接访问外设(输入或输出设备)中的数据。
- 外设要输入或者输出数据,也只能写入内存或者从内存中读取,不能跟 CPU 直接"对话"。
- 一句话总结:所有设备都只能直接和内存打交道,CPU 不是和键盘、鼠标、显示器直接通信,而是统统通过内存这个"中转站"。
这其实是一个很好的理解现代计算机的抽象:内存是所有数据的集散地,CPU 只认内存这一条路;外设只跟内存这一条路打交道。 至于"为什么 CPU 不直接访问磁盘、不直接读键盘",原因很多:速度差异巨大(寄存器、缓存、内存、磁盘的速度差可以达到好几个数量级)、硬件接口千差万别、CPU 管不过来等。把"一切对外的通信都收敛到内存"这一层,对硬件设计、对操作系统、对上层软件都是巨大的简化。
那么怎么从"概念"深入到"对软件数据流的理解"呢?我们做一个真实的推演——你登录 QQ 之后和一位朋友聊天,消息的数据是怎么流动的:
- 你在键盘上敲下"在吗",键盘(输入设备)把按键数据写入内存;
- CPU 从内存中取回这些字节,通过网络程序处理、包装成网络包,交给网卡(输出设备)——注意,网卡要发出数据,也是先从内存读走网络包,而不是直接从 CPU 拿;
- 数据经过路由器、互联网,到达你朋友的电脑,朋友的网卡把收到的数据写入内存;
- 朋友的 CPU 从内存读走数据,解码,然后把"在吗"这两个字写入屏幕的显存进行渲染,最终显示在他的显示器(输出设备)上。
如果换成发送文件,流程类似:你的硬盘把文件扇区数据读出,写入内存,CPU 加工后交给网卡从内存取走到网络上;对方网卡先把数据写进内存,再落盘到对方的硬盘。整个链路里,内存始终是那个命中注定的"必经之中间站"。
你可能会问:这张"所有设备都只认内存"的图,跟线程、进程、操作系统有什么关系?关系太大了——因为**操作系统正是建立在"CPU 只访问内存"这个事实之上来"管理一切"**的。我们要学的进程、地址空间、页表,全都是围绕着"CPU 访问的是什么样的内存、怎么访问的"展开的。所以这一节不是可有可无的开场白,而是地基。
思考题:验证"所有设备都只和内存打交道"
思考: 既然 CPU 不能直接访问外设,那么拷一个 1GB 的文件进 U 盘,请描述这条数据到底经历了哪些"设备 ↔ 内存"的往返?能用一个方向图简单勾勒吗?
详解答案: 数据流的往返大致是:
- 硬盘 → 内存:CPU 发起读请求,硬盘控制器把对应扇区数据读入内存的某个缓冲区;
- 内存 → CPU:CPU 从内存中读取这些数据,根据程序逻辑做校验、组装等处理(也可能直接把数据从内核缓冲区"映射/拷贝"到用户态缓冲区,这里不深究);
- 内存 → U 盘:最终 CPU 把数据准备好,交由 USB 控制器/磁盘控制器从内存取走,写入 U 盘。
可见数据在"硬盘↔U盘"之间看似"直接拷贝",实则每一站都是以内存为跳板,CPU 只是"发号施令的中枢",真正的大流量数据搬运走的是"设备 ↔ 内存"。这也解释了为什么"直接内存访问(DMA)"技术能提升吞吐——它让数据在设备与内存之间直接搬运而不用每字节都经过 CPU,恰好印证了"所有设备都只和内存打交道"这条铁律。
操作系统:它在计算机里到底是个什么角色
知道了硬件长什么样,接下来要问:这么多硬件,谁来管理它们?答案就是我们熟悉的操作系统(Operating System,简称 OS)。
任何计算机系统都包含一个最基本的程序集合,称为操作系统。笼统地说,操作系统包括两部分:
- 内核(kernel):操作系统的核心,负责进程管理、内存管理、文件管理、驱动管理等最底层的功能。
- 其他程序:例如各种函数库、Shell 程序(命令行解释器,比如 bash)等等,它们依托内核运行。
操作系统设计的目的是什么? 两句话就能说清:
- 对下:与硬件交互,管理所有的软硬件资源;
- 对上:为用户程序(应用程序)提供一个良好的执行环境。
换句话说,操作系统卡在硬件和应用程序之间,它一头"伺候"硬件,另一头"服务"上层软件。那么它的核心功能或者说定位是什么?用一个词概括就是——"管理"。操作系统整个就是一款纯正的"搞管理"的软件:它不管你去算加法还是发文件,它管的是怎么把这台机器的 CPU、内存、磁盘、网卡公平且高效地分给这成千上万跑着的程序。
如何理解"管理"?——描述 + 组织
"管理"听起来很玄,其实拆开就两层意思。举个生活化的例子:学生、辅导员、校长。
某个系里有一千个学生,还有十个辅导员和一个校长。校长要管理这一千名学生,但他不可能记住每个学生的每一条信息,也不可能用一对一的方式管理。真实的做法是:
- 描述被管理对象:每位学生有一份档案(姓名、学号、宿舍、成绩、违纪记录……)。在计算机里,这份"档案"就是一个结构体
struct。把每个学生的信息都"描述"成一个结构体变量。 - 组织被管理对象:光有档案不行,得把一千份档案"组织"起来,用数组、链表、二叉树这类数据结构串起来,校长查档案、改档案、增删学生才能高效。
这就是"管理 = 描述 + 组织"的本质:先想办法把一个对象的所有信息描述成数据结构,再用高效的数据结构把它们组织起来。 管理硬件、管理进程,全都是这个套路。
我们说"计算机管理硬件",落实下来就是两句话:
- 描述起来,用
struct结构体; - 组织起来,用链表或其他高效的数据结构。
你很快会看到,进程管理正是这样:每个进程用 task_struct(PCB)来描述,再把所有 task_struct 用链表组织起来。这个"描述 + 组织"的心法,是理解内核一切"管理"功能的万能钥匙。
系统调用和库函数
操作系统对外会表现出一个整体,但它并不会把底层全部暴露给开发者。它只会露出一部分接口,供上层使用——这部分由操作系统提供的接口,叫作系统调用(system call)。例如"创建进程"用 fork()、"获取进程号"用 getpid()、"读写文件"用 open/read/write 等,都是系统调用。
系统调用有下面两个特点:
- 功能比较基础:它是内核直接提供的"原子操作",粒度很细,用起来比较"原始";
- 对使用者的要求相对较高:你得知道参数怎么传、错误怎么处理,直接用它写大程序很累。
所以,有心的开发者会对部分系统调用做适度的封装,从而形成"库"。有了库,就更有利于上层用户或开发者进行二次开发。比如 C 标准库 libc 里的 printf、scanf,它们底层最终都会调用 write、read 这类系统调用,但把格式解析、缓冲这些麻烦事帮我们做掉了。系统调用是"直通内核"的大门,库函数是门前的"门童"——它帮你开门,也帮你整理。
这里有个很容易考的细节:所有能访问内核资源的操作,最终都要穿过系统调用这一层。因为用户程序跑在"用户态",没有权限直接碰内核数据结构(比如设备、进程表),必须借助系统调用陷入内核态,由内核代劳。关于"用户态/内核态",我们放在地址空间那一节展开,因为那是理解权限隔离的关键。
承上启下
好,你现在已经掌握了"管理 = 描述 + 组织"这把钥匙。那么回到正题:操作系统是怎么进行进程管理的呢? 答案呼之欲出——先把进程描述起来,再把进程组织起来! 描述用什么?下面要讲的 PCB / task_struct。组织用什么?内核对 task_struct 维护的双链表。接下来,我们就正式进入进程的世界。
进程:程序正在运行时的那具"躯体"
进程 vs 程序:别再傻傻分不清
很多人把"进程"和"程序"混为一谈,这是学系统编程的第一道坎。我们需要从几个视角分别看清它们:
- 课本概念:进程是程序的一个执行实例,是"正在执行的程序"。
- 内核观点:进程是担当分配系统资源(CPU 时间、内存)的实体。内核发 CPU、发内存,是发给"进程"的,不是发给"程序"的。
- 我们现阶段最实用的等式:进程 = 内核数据结构(task_struct/PCB)+ 自己的程序代码和数据。
一句话区分:程序是"菜谱",进程是"正在按菜谱做的那顿饭"。菜谱(程序)可以同时被很多厨子(CPU/进程)照着做——同一个可执行文件可以同时跑成多个进程;菜谱(程序)放在硬盘上不动,而"做饭"(进程)是一个有时间、有状态、会变化的动态过程。
再补一个直观的对比:
| 维度 | 程序 | 进程 |
|---|---|---|
| 本质 | 静态的指令与数据的集合,存在磁盘上 | 程序的动态执行实例,存活在内存/CPU 上 |
| 状态 | 无生命,不"运行" | 有生命周期,有各种状态(运行、睡眠……) |
| 资源 | 不占用 CPU,不占运行内存 | 被分配 CPU 时间、内存、文件等资源 |
| 数量关系 | 一份程序 | 可对应多个进程 |
描述进程——PCB 与 task_struct
"管理 = 描述 + 组织"。描述进程靠什么?靠一个叫 PCB(Process Control Block,进程控制块) 的数据结构。PCB 可以理解为进程属性的集合,进程的所有"身份信息""状态信息""资源信息"都装在里面。课本上把这种结构叫做 PCB,而在 Linux 操作系统下,这个 PCB 的具体实现叫 task_struct——task_struct 是 PCB 的一种(Linux 的实现)。
// 以下是概念示意,并非真实 Linux 源码全文。
// 真实内核在 include/linux/sched.h 中定义了 struct task_struct。
// task_struct 是 Linux 中最庞大的结构体之一,这里只列几类代表字段帮助理解。
struct task_struct {
// 标识符:描述本进程的唯一定位号,用于区别其他进程(如 pid/ppid)
pid_t pid; // 本进程的进程ID
pid_t parent_pid; // 父进程ID
// 状态:任务状态、退出代码、退出信号等
volatile long state; // 进程当前状态:R/S/D/T/Z等,见下文
int exit_code; // 进程退出码(由exit设置)
// 优先级:相对于其他进程的优先级
int prio; // 动态优先级(约等于控制调度时机)
int static_prio; // 静态优先级
int normal_prio; // 正常优先级
// 程序计数器:CPU中即将执行的下一条指令的地址(由struct pt_regs保存,这里示意)
//(真正保存各寄存器现场的是内核栈中的 pt_regs / thread_struct)
// 内存指针:代码指针、进程相关数据的指针、与其他进程共享内存块的指针
struct mm_struct *mm; // 指向该进程的地址空间描述符(后面详讲)
// 上下文数据:进程执行时处理器的寄存器数据(运行时快照,用于切换)
// I/O状态信息:I/O请求、分配给进程的I/O设备和进程使用中的文件列表
struct list_head files; // 打开的文件描述符表(示意)
// 记账信息:处理器时间总和、时钟数总和、时间限制、记账号等
unsigned long utime; // 用户态累计CPU时间
unsigned long stime; // 内核态累计CPU时间
// 其他信息:信号、地址空间、文件系统、进程间的亲属关系链表等
// 两个指针把 task_struct 串进内核的"任务链表",从而被组织起来
struct list_head tasks; // 链接到全局任务链表(双向链表)
};task_struct 里装的信息按类别大致包括:
- 标示符:描述本进程的唯一标识符(
pid、ppid等),用来区别其他进程; - 状态:任务状态、退出代码、退出信号等;
- 优先级:相对其他进程的优先级;
- 程序计数器:指示程序中即将被执行的下一条指令的地址(运行时由寄存器承载);
- 内存指针:包括程序代码和进程相关数据的指针,以及和其他进程共享的内存块的指针;
- 上下文数据:进程执行时处理器的寄存器中的数据。这就是"休学复课"的比喻——你休学时要把课本、笔记、正在做的题的进度都记录下来,复课时照着恢复;进程被切换走时,把 CPU 寄存器现场保存进
task_struct/内核栈,换回来时再恢复; - I/O 状态信息:包括显示的 I/O 请求、分配给进程的 I/O 设备和被进程使用的文件列表;
- 记账信息:可能包括处理器时间总和、使用的时钟数总和、时间限制、记账号等;
- 其他信息:具体细节非常多,我们会随着课程不断补充。
那么,这么多进程的 task_struct 是怎么"组织"起来的?所有运行在系统里的进程,都以 task_struct 双向链表的形式存在于内核中。 用一个全局链表把头结点指出来,task_struct 里的 tasks 字段把每个进程串进去,内核就能遍历、查找、调度所有进程了。你可以去读内核源码(include/linux/sched.h),task_struct 就定义在那里;它也印证了我们那句心法——描述用结构体,组织用链表。
怎么"看"到一个进程?——/proc 与 ps、top
进程是对操作系统而言很真实的东西,但在我们眼里,怎么"看见"它?
/proc虚拟文件系统:/proc不是一个普通的目录,它是内核暴露给用户的一个"虚拟文件系统",很多内核数据结构都以文件的形式呈现在里面。进程信息可以通过/proc系统文件夹查看。例如要获取 PID 为 1 的进程的信息,查看/proc/1这个文件夹即可。 里面的stat、status、cmdline、maps等文件分别装着该进程的状态行、详细信息、命令行参数、内存映射等。- 用户级工具:绝大多数进程信息同样可以用
top和ps这些用户级工具获取。它们本质上是"把/proc里的数据读取出来并排版展示"。
我们用一个小实验直观感受一下"进程活着的时候有数据可查"。
// loop.c
#include <stdio.h> // 标准输入输出(这里其实用不到,但习惯性包含)
#include <unistd.h> // 提供 sleep() 函数原型
#include <sys/types.h>// 提供 pid_t 等类型
int main()
{
while (1) { // 死循环,让进程持续存在不退出
sleep(1); // 每秒睡1秒,让进程进入S睡眠态,可被中断
} // 循环体结束,回到 while(1) 继续
return 0; // 正常返回(实际上永远走不到这里)
}# 编译运行,让它在后台持续运行
gcc loop.c -o loop
./loop &
# 用 ps 查看它的存在($! 是刚才后台进程的PID的Shell变量)
ps -ef | grep loop
# 查看 /proc 目录:用你的loop进程PID替换<PID>
ls -l /proc/<PID>
cat /proc/<PID>/status # 能看到状态、PPID等
cat /proc/<PID>/stat # 内核格式的状态行你会在 status 里看到 State: S (sleeping),因为我们的进程正卡在 sleep(1) 里;看到 PPid 是你当前 shell 的 PID;看到 VmSize 等内存信息。每个正在运行的进程,都对应着 /proc 里的一个目录。 这就是"进程是被内核用一个可查的数据结构描述的实体"最直观的体现。
通过系统调用获取进程标识符:getpid 与 getppid
进程这个概念如此重要,那我们自己的 C 程序里怎么拿到自己的"身份证号"?用系统调用 getpid() 获取进程 ID(PID),用 getppid() 获取父进程 ID(PPID)。
// getid.c
#include <stdio.h> // printf
#include <sys/types.h> // pid_t 类型定义
#include <unistd.h> // getpid/getppid
int main()
{
// getpid() 返回当前进程的PID;%d 打印出来
printf("pid: %d\n", getpid());
// getppid() 返回当前进程的父进程PID(一般是启动它的Shell)
printf("ppid: %d\n", getppid());
return 0; // 正常退出
}gcc getid.c -o getid
./getid
# 输出示例(你的数值会不同):
# pid: 3072
# ppid: 3008你可以再执行 ./getid 一次,会发现 pid 每次都变(进程号由内核分配、可能复用),而 ppid 大概率不变——因为父进程始终是你那个一直开着的 shell。
坑提示:pid 不会像你想的那样"每次 +1"连续,它可能复用被回收的 PID;而 ppid 如果变了,说明你的程序被"收养"了(孤儿进程那一节会讲被谁收养、为什么)。
创建进程——fork 初识
Linux 里创建进程最经典的系统调用是 fork()。先运行 man fork 读一下手册(man 是 Linux 下查看函数/命令手册的命令,man 2 fork 表示查第 2 手册节,属于"系统调用")。
fork() 有几个让你第一次接触就一脸懵的性质:
fork有两个返回值;- 父子进程代码共享、数据各自开辟空间(私有一份),采用写时拷贝(Copy-On-Write,COW)。
先看最小实验:
// fork_basic.c
#include <stdio.h> // printf
#include <sys/types.h> // pid_t 类型
#include <unistd.h> // fork / getpid
int main()
{
// fork():创建子进程。父进程里返回子进程PID;子进程里返回0;失败返回-1
int ret = fork();
// 这段 printf 会被父、子进程各执行一次,所以会输出两行
printf("hello proc : %d!, ret: %d\n", getpid(), ret);
sleep(1); // 睡1秒,让两个进程都有充足时间把缓冲打到屏幕
return 0; // 父子各自退出
}gcc fork_basic.c -o fork_basic
./fork_basic
# 输出类似(顺序可能略有差异):
# hello proc : 3100!, ret: 3101
# hello proc : 3101!, ret: 0看到没有?一行 printf 被打了两次。 第一次是父进程打的(printf("hello") 那行之前的 ret 是子进程 PID 3101),第二次是子进程打的(ret 是 0)。fork 之后,父子进程各自拥有一份代码,从 fork 返回的那一行继续往下执行——所以 fork 之后的代码会被"复制"成两份分别执行。
那么为什么会有两个返回值?怎么给父子分别返回值?关键在 fork 的返回值语义:
- 在父进程中,
fork()返回子进程的 PID(一个大于 0 的数),父进程靠它来"指挥"子进程; - 在子进程中,
fork()返回 0,子进程靠 0 知道"我是孩子"; - 若创建失败,返回 -1。
所以 fork 之后,通常紧接着用 if 进行分流,让父子各走各的代码分支。这才是写 fork 程序的正确姿态:
// fork_branch.c
#include <stdio.h> // printf / perror
#include <sys/types.h> // pid_t
#include <unistd.h> // fork / getpid
#include <stdlib.h> // exit
int main()
{
int ret = fork(); // 创建子进程,ret两种取值:父得子PID、子得0
if (ret < 0) { // 分支一:fork失败,ret为-1
perror("fork"); // 打印失败原因,如"Resource temporarily unavailable"
return 1; // 以错误码返回
}
else if (ret == 0) { // 分支二:子进程,ret为0
printf("I am child : %d!, ret: %d\n", getpid(), ret);
}
else { // 分支三:父进程,ret为子进程PID(>0)
printf("I am father : %d!, ret: %d\n", getpid(), ret);
}
sleep(1); // 稍作停留,方便观察,也避免缓冲未刷新就退出
return 0; // 父子各自正常退出
}gcc fork_branch.c -o fork_branch
./fork_branch
# 输出类似:
# I am father : 3120!, ret: 3121
# I am child : 3121!, ret: 0到这里有个著名的困惑会在所有初次接触 fork 的人脑海里炸开:"一个变量 ret,怎么可能让 if 分支和 else if 分支同时成立?" 在这个例子里,父进程走 else,子进程走 else if,两个分支都被"执行"了——但它们分别是在父、子两份内存空间里各执行了一次。要真正讲清楚"同一份变量怎么在不同的进程里各自独立却又地址相同",需要等我们讲完"进程地址空间/虚拟地址"那一节。现在先记住结论:
- 代码共享:父子进程共用同一份代码段,所以
printf源码只有一份; - 数据私有:父子进程的数据(变量、堆、栈)各自独立,互不影响;
- 采用写时拷贝(COW):一开始它们共享同一块物理页,只有某一方真正去修改数据时,才会触发"拷贝一份给修改者"的动作(具体机制地址空间一节详述)。
坑提示:为什么代码里频繁出现 sleep(1)?因为 printf 有缓冲区,父、子进程退出时缓冲区会被刷新,但父子刷新的时机与顺序不定。稍微睡一下能让输出更规整、现象更清晰。不睡也能跑,但那可能让你误判"为什么有时只打了一行"——其实是被缓冲吞了。
关于 fork 更多的边界与坑(比如父子谁先执行并不确定、写时拷贝如何让"地址相同但内容不同"成立),我们在虚拟地址那一节用实验一起揭晓。这里你先埋下这两个问题:
fork为什么会有两个返回值?- 两个返回值各自如何给父子返回?
(后面"虚拟地址/写时拷贝"一节会给出彻底的回答。先记住:子进程拿 0,父进程拿子进程的 PID,失败返回 -1。)
进程状态:它此刻在干嘛
一个进程不可能一直在 CPU 上狂奔。它可能正在等键盘输入、可能刚把数据交给磁盘正等它写完、可能被调试器按住了暂停、也可能已经退出但还没来得及被"收尸"。这些不同的处境,就是进程的状态。Linux 内核用一个数组来"翻译"每个状态对应的字符串。
内核里的 state 定义
下面这段状态数组来自 Linux 内核源码(历代版本大致如此,现代内核还增加了 I (idle)、P (parked) 等,但核心几类不变)。注意它的下标是 0、1、2、4、8、16、32——这是位图式的设计,因为内核允许用"按位或"组合出"多个原因叠加的睡眠":
/*
* 任务状态数组是一个奇特的“位图”,记录“睡眠的原因”。
* 因此“running”为0,你可以用简单的位测试来组合检测其他状态。
*/
static const char *const task_state_array[] = {
"R (running)", /* 0 */ // 运行状态
"S (sleeping)", /* 1 */ // 可中断睡眠
"D (disk sleep)", /* 2 */ // 不可中断睡眠(磁盘等IO等待)
"T (stopped)", /* 4 */ // 停止状态
"t (tracing stop)", /* 8 */ // 被追踪(调试)停止
"X (dead)", /* 16 */ // 死亡状态(几乎只在瞬间出现)
"Z (zombie)", /* 32 */ // 僵尸状态
};逐个解释这些状态的真实含义:
- R(running)运行状态:并不意味着进程一定正在 CPU 上跑。它表明进程"要么正在运行中,要么在运行队列里等待被调度"。换句话说,R 状态 = "我随时可以被派上 CPU"。它对应内核里"就绪 + 正在执行"这两种情况的集合。
- S(sleeping)睡眠状态:意味着进程在等待某个事件完成(等键盘、等定时器、等网络数据……)。这里的睡眠可以随时被信号或其他事件唤醒,所以也叫可中断睡眠(interruptible sleep)。我们之前的
sleep(1)就会让进程进 S。 - D(disk sleep)磁盘休眠状态:也叫不可中断睡眠(uninterruptible sleep)。处在这个状态的进程通常在等待 IO 的结束(比如正在等磁盘把数据写完)。这里的"不可中断"指的是"不能随便用普通信号打断它",因为内核正依赖它完成一次关键的 IO,打断会导致数据不一致。这也是为什么你有时候想
kill -9一个卡在 D 状态的进程都杀不动。 - T(stopped)停止状态:可以通过发送
SIGSTOP信号给进程来让它停止(进入 T)。这个被暂停的进程可以通过发送SIGCONT信号让它继续运行。常用场景:在终端按Ctrl+Z会把前台进程挂起成 T 状态。 - t(tracing stop)追踪停止:进程被调试器(如 gdb)跟踪时,在断点处停下,就是 t。它用小写 t,和手动暂停的 T 区分。
- X(dead)死亡状态:只是一个瞬时返回状态,进程已经彻底退出、回收完资源。你几乎不会在任务列表里看到它,因为它在极短的时间内就消失了。
- Z(zombie)僵尸状态:这是本课的重头戏,我们专门开一节讲。
用 ps 查看进程状态
ps 是 Process Status(进程状态)的缩写,它的常见组合:
ps aux:查看系统里几乎全部进程的详细信息(CPU、内存占用等)。ps axj:除了进程信息,还显示进程组 ID、会话 ID、父进程 ID 等与作业控制相关的信息。
各选项含义:
a:显示一个终端下所有的进程,包括其他用户的进程(不光是自己的);x:显示没有控制终端的进程,例如后台运行的守护进程;j:显示进程归属的进程组 ID、会话 ID、父进程 ID,以及与作业控制相关的信息;u:以用户为中心的格式显示进程信息,提供用户的详细信息,如用户、CPU 和内存使用情况等。
# 查看当前终端的父子关系与状态
ps axj | head -20
# 查看进程的 CPU/内存详细信息
ps aux | head -20
# 只盯你自己的一个程序:先跑起来再查
./loop &
ps aux | grep loopps 输出的 STAT 一列会显示状态码(可能是 S、R、T、Z 等,还可能带类似 S+ 表示前台进程组、Ss 表示会话首进程等附加字母)。
Z(zombie)僵尸进程:已经"死了"还赖着不走
僵尸进程是系统编程初学者极容易踩到的现象,也是很好的考点。先看定义:
- 僵死状态(Zombies)是一个比较特殊的状态。当子进程退出、但父进程(使用
wait()系统调用,后面讲)没有读取到子进程退出的返回代码时,就会产生僵死(尸)进程。 - 僵死进程会以终止状态保持在进程表里,并且会一直在等待父进程读取它的退出状态代码。
- 所以只要满足:"子进程退出,父进程还在运行,但父进程没有读取子进程状态",子进程就进入 Z 状态。
为什么需要这个状态? 因为子进程退出后,必须把它"任务办得怎么样"(退出码)留给关心的它的父进程。这些退出信息保存在 task_struct(PCB)里。父进程调用 wait() 系列调用,才能把这份"死亡报告"取走。只要父进程一直不取,子进程的 PCB 就只能一直留在进程表里,以 Z 状态占着位置。
来一个维持 30 秒僵尸的实验:
// zombie.c
#include <stdio.h> // printf
#include <stdlib.h> // exit
#include <unistd.h> // fork / sleep
#include <sys/types.h> // pid_t
int main()
{
pid_t id = fork(); // 创建子进程
if (id < 0) { // fork 失败
perror("fork");
return 1;
}
else if (id > 0) { // 父进程:id 是子进程PID
printf("parent[%d] is sleeping...\n", getpid());
sleep(30); // 父进程睡30秒,期间不读子进程退出状态
}
else { // 子进程
printf("child[%d] is begin Z...\n", getpid());
sleep(5); // 子进程先活5秒
exit(EXIT_SUCCESS); // 5秒后退出,进入Z状态等待父进程
}
return 0; // 父进程30秒后退出
}开启另一个终端来做监控:
gcc zombie.c -o zombie
./zombie &
# 在另一个终端持续刷新,观察一个进程进入 Z 状态的瞬间
watch -n 1 'ps axj | grep zombie'你会看到:第 5 秒左右,子进程的 STAT 列变成 Z,ps 的输出里进程名旁出现 <defunct>("已失效")字样。它虽然已经不再执行任何代码,但那个 Z 状态的任务一直挂在进程表里,等父进程 30 秒后醒来。父进程退出后,如果没人管它,它通常会被 PID 为 1 的进程(init/systemd)回收,Z 才消失。
注意:ptrace 系统调用可以用来追踪进程运行,比如 gdb 就是靠它工作。有兴趣的同学可以自己研究一下 man ptrace,它能让你看到 t(tracing stop)状态是怎么来的。
僵尸进程的危害
你以为僵尸进程只是"看起来碍眼"?它实际上会造成实实在在的资源问题。逐层推理:
- 进程的退出状态必须被维持下去——因为它要告诉关心它的进程(父进程):"你交给我的任务,我办得怎么样了"。可父进程如果一直不读取,子进程就一直处于 Z 状态吗?是的! 它会一直占着。
- 维护退出状态本身就是要用数据维护的,这也属于进程基本信息,保存在
task_struct(PCB)中。换句话说,Z 状态一直不退出,PCB 就要一直被维护。 - 那一个父进程创建了很多子进程、就是不回收,是不是就会造成内存资源的浪费? 是的!因为数据结构对象本身就要占用内存。 想想 C 语言中定义一个结构体变量(对象),是要在内存的某个位置开辟空间存放的;每一个
task_struct也一样占着内核内存。 - 这就是内存泄漏吗? 是的!虽然单个
task_struct不大,但成百上千个不回收的僵尸,累积起来就是被白白占用、无法利用的内存。 - 如何避免? 后面在讲解
wait/waitpid时会给答案。核心思想就是:父进程及时用wait回收子进程,或者让父进程死掉、由 1 号进程统一收养回收孤儿。
一句话总结僵尸进程的本质:程序已经执行完,代码和数据都可丢弃,但"死亡的身份证(PCB)"还摆在进程表里,等父进程来凭票领取死因。 父进程不来领,它就赖着不走,还占着名额。
孤儿进程:爸爸先走了,谁来养我?
和僵尸进程"尸体滞留"对应的,是"亲爹提前退场"的另一个极端——孤儿进程。
- 父进程如果提前退出,那么子进程后退出,进入 Z 之后没人给它收尸——这该如何处理?
- 父进程先退出,子进程就称之为"孤儿进程"。
- 孤儿进程会被 PID 为 1 的进程(init/systemd)"领养",自然也要由
init/systemd来回收它、收取它的退出状态。
来一段代码亲手制造一个孤儿进程,看看它被谁收养:
// orphan.c
#include <stdio.h> // printf / getpid
#include <unistd.h> // fork / sleep
#include <stdlib.h> // exit
int main()
{
pid_t id = fork(); // 创建子进程
if (id < 0) { // fork 失败
perror("fork");
return 1;
}
else if (id == 0) { // 子进程:立刻执行
printf("I am child, pid : %d\n", getpid());
sleep(10); // 子进程活10秒
}
else { // 父进程
printf("I am parent, pid: %d\n", getpid());
sleep(3); // 父进程只活3秒
exit(0); // 3秒后父进程先退出 -> 子进程成为孤儿
}
return 0; // (父进程不会走到这里)
}gcc orphan.c -o orphan
./orphan &
# 立刻(3秒内)查看:此时父进程还在,sub进程的PPID是父进程
ps axj | grep orphan
# 等上5秒再查看:父进程已退出,子进程的PPID已经变成1(init/systemd)
sleep 5 && ps axj | grep orphan你会清楚地看到:在父进程退出后,子进程的 PPID 从原来的父进程 PID 变成了 1——这就是"被 1 号进程收养"的铁证。1 号进程(现代系统多为 systemd,历史上是 init)会负责回收这些孤儿,避免它们沦为无人收尸、永远占着位置的僵尸。
思考题(必做): 既然孤儿进程会被 1 号进程领养并回收,为什么还要反复强调"父进程要及时 wait 回收子进程,否则会内存泄漏"?被领养不就能自动解决了吗?
详解答案: 关键在于"回收"发生在父进程死后。当你写一个长期运行的服务程序(比如一个常驻后台的程序)时,它一边工作一边用 fork 创建一堆子进程帮忙干临活。如果你在这个"活着的父进程"里不调用 wait 回收,那么每个子进程退出后都会以 Z 状态挂着,而父进程自己又不退出——这些 Z 状态的 PCB 就会一直没人来收,累积占着内核内存,这就是泄漏。1 号进程的"领养回收"只在父进程也已经退出(孤儿诞生)之后才生效,对"父进程还活着却不收尸"的情况无能为力。所以真正的工程规范是:父进程要成为负责任的监护人——主动 wait,尽早收尸;绝不能指望进程死了才被 1 号进程兜底。
进程优先级:谁先上 CPU
系统里跑的进程成千上万,CPU 却屈指可数(哪怕现代机器也是十几几十个物理核)。那 CPU 时间怎么分?这里就要引入优先级(priority)。
基本概念
- cpu 资源分配的先后顺序,就是指进程的优先权(priority)。
- 优先权高的进程有优先执行的权利。配置进程优先权对多任务环境的 Linux 很有用,可以改善系统性能。
- 还可以把进程运行到指定的 CPU 上,这样一来,把不重要的进程安排到某个 CPU,可以大大改善系统整体性能(这就是亲和性 / CPU 绑定的话题)。
用 ps -l 查看优先级信息
在 Linux/Unix 系统里,用 ps -l(l 表示 Long,长格式)会输出这样的内容:
ps -l
# F S UID PID PPID C PRI NI ADDR ...
# 0 S 1000 3100 3008 0 80 0 - ...其中几个重要的字段:
- UID:代表执行者的身份(用户 ID);
- PID:代表这个进程的代号(进程号);
- PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号;
- PRI:代表这个进程可被执行的优先级,其值越小越早被执行;
- NI:代表这个进程的 nice 值。
PRI 和 NI:优先级与它的"修正液"
- PRI 比较好理解——进程的优先级,或者通俗点说,就是程序被 CPU 执行的先后顺序。此值越小,进程的优先级别越高,越快被执行。
- NI(nice,友善值)——表示进程可被执行的优先级的修正数值。它不直接是优先级,而是给优先级"加分/减分"用的修正量。
两者关系常用这个公式表达:
PRI(new) = PRI(old) + NI
理解这句话:
- 因为
PRI值越小越先执行,那么当NI为负值时,PRI(new)变小,即优先级变高,进程越快被执行; - 当
NI为正值时,PRI(new)变大,优先级变低,进程被"礼让"到后面; - 所以,调整进程优先级,在 Linux 下,就是调整进程的 nice 值;
- NI 的取值范围是
-20到19,一共 40 个级别。
一句话区分 PRI 与 NI:进程的 nice 值不是进程的优先级,它们不是一个概念,但是进程的 nice 值会影响到进程优先级的变化。可以这样理解:nice 值是进程优先级的"修正数据"——PRI 是"排队的先后",NI 是"往前往后挪多少格的修正量"。
补充一个现代内核的准确细节:在 Linux 实现里,真正的调度值 static_prio 其实是 100 + nice(普通进程),所以 nice 0 对应优先级 120。但不同教材和工具对 ps -l 里那个 PRI 的基准值定义并不完全一致(有的把 PRI 基准显示为 80)。对初学者,你只需要牢牢记住方向:PRI 越小越先执行;NI 越小(负得越多)PRI 也越小、越被优待;用调整 nice 来调优先级。
查看和修改优先级的命令
top:进入top之后,按r键 → 输入进程 PID → 输入新的 nice 值,即可修改该进程的优先级。这需要足够的权限(普通用户一般只能把 nice 调大、把优先级调低;调高需要 root)。- 其他调整优先级的命令:
nice(以指定 nice 启动程序)、renice(对一个已在运行的进程改 nice)。
# 用 nice 以 -5 启动程序(普通用户通常不允许负值,需要 root)
sudo nice -n -5 ./loop &
# 用 renice 修改已运行进程的优先级(-20到19)
renice -n 10 -p 3100
# 用 top 实测:查看优先级两列 PRI / NI
topC 语言里的对应系统函数(声明在 <sys/time.h> 和 <sys/resource.h>):
#include <sys/time.h>
#include <sys/resource.h>
// getpriority 获取进程/进程组/用户的优先级
// which: 对象类型(PRIO_PROCESS / PRIO_PGRP / PRIO_USER)
// who: 对象的ID(进程号 / 进程组号 / 用户ID,0表示自身)
int getpriority(int which, int who);
// setpriority 设置优先级,prio 取值范围 -20 ~ 19
int setpriority(int which, int who, int prio);注意:这两个函数返回的是 nice 值(不是 PRI)。它们常用于在代码里动态调整自己或子进程的优先级。
补充概念:竞争、独立、并行、并发
学到这里,有几个形容"多进程世界"的词必须分清,它们是后续学多线程、并行的基础:
- 竞争性:系统进程数目众多,而 CPU 资源只有少量,甚至只有 1 个,所以进程之间是具有竞争属性的。为了高效完成任务、更合理地竞争相关资源,于是引入了优先级。优先级就是竞争时的"排位依据"。
- 独立性:多进程运行时,需要独享各种资源,多进程在运行期间互不干扰。我们后面讲"写时拷贝、各自独立的地址空间"就是在保障这种独立性。
- 并行(parallelism):多个进程在多个 CPU 上分别、同时进行运行,这称之为并行。它是"同一时刻,真的有多条指令在多个核上同时执行"。
- 并发(concurrency):多个进程在一个 CPU 下采用进程切换的方式,在一段时间之内让多个进程都得以推进,称之为并发。它强调的是"宏观上都在推进、微观上轮流使用 CPU"。
一句话记忆:并行是"同时唱",并发是"轮流唱但都唱得下去"。 单核靠并发,多核才有并行。
进程切换与 CPU 上下文切换
既然 CPU 要"轮流伺候"那么多进程,那每次从进程 A 切到进程 B,发生了什么事?
CPU 上下文切换:其实际含义是任务切换,或者 CPU 寄存器切换。当多任务内核决定运行另外的任务时:
- 它保存正在运行任务的当前状态——也就是 CPU 寄存器中的全部内容;
- 这些内容被保存在任务自己的堆栈(内核栈)中;
- 入栈工作完成后,把下一个将要运行任务的当前状况从该任务的栈中重新装入 CPU 寄存器;
- 开始下一个任务的执行——这一过程就是 context switch(上下文切换)。
有趣的是,这个机制在 Linux 内核 0.11 时代就已经确立,一直演进到今天。你可以去翻老内核源码里的 switch_to 宏、sched.c 里的 schedule,体会"保存现场、恢复现场"的朴素之美。你可以把上下文切换理解为"替身文学换班":每个进程都是一个"替身",被叫上台前要把"上一个替身"的卖力姿态(寄存器)原样收好(存进自己的栈/PCB),再把下一个替身的姿态原样摆好(从它的栈/PCB 恢复),然后继续演。
伴随上下文切换,还有一个概念叫时间片(time slice):
当代计算机基本都是分时操作系统,没有一个进程天生独享 CPU。每个进程都有它合适的时间片(其实就是个计数器)。时间片一到,进程就被操作系统从 CPU 上"剥离"下来,让位给下一个进程。
这样,一个 CPU 才能"同时"服务成百上千的进程——它们各吃一勺时间片,快速轮转,你肉眼看不出切换的缝隙,感觉就像大家都在"同时跑",这就是并发的真正落地形态。
Linux 2.6 内核的 O(1) 调度队列
了解了"怎么切换",自然要问:每次该挑哪一个进程上 CPU?怎么挑才快? 这就要说到 Linux 进程调度(scheduling)。
Linux 历史上用过多种调度器。这里我们聚焦教材常讲的、位于 2.6 内核前中期的 O(1) 调度器。它的核心设计极具教学价值,能让你深刻理解"用数据结构换时间效率"。
(说明:现代 Linux 早已改用 CFS/EEVDF 等更公平的调度器,但 O(1) 的"活动/过期双队列 + 位图加速"思想,是理解调度如何做到"找进程花常数时间"的最好教材。你学它的重点是思想,而不是 cd 到今天的 sched/core.c 里找 prio_array——那里已经找不到了。)
一个 CPU 拥有一个 runqueue
- 每个 CPU 有一个运行队列(runqueue,结构体
rq),专门存放等待被这个 CPU 调度的就绪进程。 - 如果有多个 CPU,就要考虑进程个数的负载均衡问题——不能让某个核忙死、另一个核闲死,内核会定期让进程在核之间"搬家"。
对应的核心代码片段(2.6 时代的 rq 和 prio_array):
// 以下是 2.6 内核 runqueue 的精简示意(完整版很长,这里只保留关键字段帮助理解)
struct rq {
// ...很多字段用于统计、负载、锁等...
struct task_struct *curr, *idle; // curr: 当前正在运行的进程; idle: 空闲进程
// active 指向"活动队列",expired 指向"过期队列",arrays[2] 是这两块的实际存储
struct prio_array *active, *expired, arrays[2];
// ...SMP负载均衡、调度统计等字段...
};
// prio_array:一次"按优先级排队"的数据结构
struct prio_array {
unsigned int nr_active; // 该队列里共有多少个活跃进程
DECLARE_BITMAP(bitmap, MAX_PRIO+1); // MAX_PRIO=140,位图:哪个优先级非空
struct list_head queue[MAX_PRIO]; // queue[140]:每个元素是一个双向链表,
// 下标就是优先级,同优先级进程按FIFO排队
};优先级
- 普通优先级:100 ~ 139(我们都是普通的进程,回想一下 nice 值的取值范围 -20~19,恰好能对应上:nice -20 对应 100,nice 0 对应 120,nice 19 对应 139);
- 实时优先级:0 ~ 99(实时进程,一般不用关心,比所有普通进程都优先)。
所以数组 queue[140] 的 140 个位置,正好对应 0~139 共 140 个优先级。
活动队列(active)
- 时间片还没有结束的所有进程,都按照优先级放在活动队列里。它们的优先级相同的前提下,谁先进来谁靠前(FIFO,先进先出)。
nr_active:该队列里总共有多少个运行状态的进程。queue[140]:一个元素就是一个进程队列,相同优先级的进程按照 FIFO 规则排队调度,所以数组下标就是优先级。
那么,从活动中挑选一个最合适的进程,过程是怎样的?
- 从下标 0 开始遍历
queue[140]; - 找到第一个非空队列,该队列必定是优先级最高的队列(因为是从低优先级的 0 开始找)——注意这里优先级数值小=优先级高,而下标就是优先级,所以从 0 往下插找;
- 拿到选中队列的第一个进程,开始运行,调度完成;
- 遍历
queue[140]的时间复杂度是常数(最大 140 次比较),但还是太低了!于是有了位图加速。
bitmap[5]:一共 140 个优先级、140 个进程队列。为了提高"查找非空队列"的效率,可以用 5 个 32 位字(共 160 位)的位图来表示每个优先级队列"是否为空"。比如第 i 位是 1 表示"优先级 i 的队列非空",0 表示空。查找时,只需找位图里最低的、为 1 的那一位,就能在常数时间内定位到优先级最高的非空队列,大大提高了查找效率。
过期队列(expired)
- 过期队列的结构和活动队列一模一样;
- 过期队列上放置的进程,都是时间片耗尽的进程;
- 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算,给他们换上新时间片。
这样,活动的进程一个个把时间片用完、排到过期队列;过期队列攒够了,重新发时间片,又变成新的活动进程。这个"打怪→复活→再打"的循环,保证了每个进程都能拿到属于自己的 CPU 时间,实现公平与不饿死。
active 指针与 expired 指针:手动"翻牌子"
active指针永远指向活动队列;expired指针永远指向过期队列;- 问题是:活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期这种事是一直存在的。
- 没关系!在合适的时候,只要交换
active指针和expired指针的内容,就相当于拥有了一批全新的活动进程!
这两根指针只需要 swap 一下,忙碌与空闲的队列角色瞬间对调,根本不需要把进程搬来搬去——这是个非常优雅的常数时间操作。
总结:为什么叫 O(1)?
- 在系统里查找一个"最合适被调度"的进程,时间复杂度是一个常数,不随进程数量的增长而增加——所以我们称之为进程调度 O(1) 算法。
- 无论是"用位图定位最高优先级"还是"通过交换指针切换活动/过期队列",每一步花费的时间都是固定的,不依赖系统里到底有多少个进程在排队。这就是"O(1)"(常数时间复杂度)的含义:进程再多,挑一个出来跑的时间也不变长。
思考题(必做):为什么说 O(1) 的关键在"位图 + 双指针交换"?
思考: 如果不使用 bitmap,只靠遍历 queue[140] 数组来挑最高优先级非空队列,最坏要比较多少次?如果系统里同时有 1 万个、10 万个进程,这个次数会变吗?交换 active/expired 指针比"把进程一个个挪到另一个队列"好在哪?
详解答案:
- 不靠位图、只遍历
queue[140],最坏要比较 140 次(对每个数组元素检查队列是否为空)。这是个上界固定为 140 的常数,不随进程数变化。 - 但 140 次里其实绝大多数白掠(很多优先级根本没有进程在排队),太浪费。用位图后,硬件一条
bsf(bit scan forward,找最低的置 1 位)/类似的软实现指令就能一步定位到"最高优先级的非空队列下标",把"最多 140 次循环"压缩到"几条指令",接近 O(1) 的极优实现。 - 系统里 1 万个、10 万个进程,
queue[140]还是那 140 个链表头、bitmap还是那 160 位——挑选最优进程的代价完全不变。真正受影响的是进程"插入某个队列"的动作(链表操作也是 O(1)),整体依然是常数时间。 - 交换
active/expired指针(就是一个struct prio_array *的赋值交换)是 O(1) 的;而如果要把过期队列里的进程一个个挪到活动队列,每个进程都要做链表操作,那就是 O(n)。双指针交换把"整批进程改头换面"的成本降到了常数——这是 O(1) 调度器能在海量进程下依然高效的精髓。
命令行参数和环境变量
从调度回到"程序自己"这一层。我们写的程序,是怎么收到用户在命令行输入的那些参数(./myprog a b c),以及操作系统给的一堆"环境设置"(比如终端类型、主目录)的?
基本概念
环境变量(environment variables)一般指操作系统中用来指定操作系统运行环境的一些参数。它们通常具有某些特殊用途,并且在系统当中通常具有全局特性。
一个直观的例子:我们在编写 C/C++ 代码链接的环节,从来都不知道所链接的静态/动态库在哪个目录,但照样能链接成功、生成可执行程序——原因就是有相关环境变量帮助编译器进行库的查找(比如 LIBRARY_PATH、LD_LIBRARY_PATH 等)。
常见环境变量
PATH:指定命令的搜索路径。当你在 shell 里敲一个命令名字时,shell 会按PATH里列出的目录顺序去找这个可执行文件。HOME:指定用户的主工作目录(即用户登录到 Linux 系统时默认所处的目录)。SHELL:当前 Shell,它的值通常是/bin/bash。
查看环境变量的方法
用 echo $NAME 可以查看,NAME 是你的环境变量名称(命令里的 $ 让 shell 把变量展开成它的值)。
echo $PATH # 用冒号分隔的一串目录
echo $HOME # 例如 /home/yourname
echo $SHELL # 例如 /bin/bash亲手做一遍 PATH 实验,你才能真正理解 PATH 和 ./ 的关系:
- 写一个最简单的 hello world 程序并编译:
// hello.c
#include <stdio.h> // printf
int main()
{
printf("hello world!\n"); // 打印一条消息
return 0; // 正常退出
}gcc hello.c -o hello- 对比
./hello执行和直接hello执行:- 输入
./hello能运行; - 输入
hello(不带路径)大概率会报bash: hello: command not found。
- 输入
- 为什么有些指令可以直接执行、不需要带路径(比如
ls、ps),而我们的二进制程序必须带路径才能执行? 因为ls、ps所在的目录(/usr/bin等)已经被写进了PATH,shell 能自动去那些目录找;而./hello所在目录(当前目录)不在PATH里,shell 找不到,必须你显式用./"指明路径"。 - 把我们的程序所在路径加入环境变量 PATH 中,那么以后直接打
hello就能执行:
export PATH=$PATH:$(pwd) # 把当前目录追加进 PATH(用分号/冒号分隔,Linux用冒号)
hello # 现在直接打名字也能执行了- 对比测试:改成
./hello和hello都能跑了。 - 还有什么方法可以不带路径直接运行? 当然有,比如把它拷贝/链接到
PATH里已有的目录(如sudo cp hello /usr/local/bin/),或者把当前目录$(pwd)永久写进PATH(见思考题)。
坑提示:往 PATH 里"前面"追加当前目录是有安全风险的——如果当前目录里恰好有一个叫 ls 的恶意程序,你敲 ls 就会执行它。这就是为什么很多系统刻意不让当前目录出现在 PATH 里。做实验可以,工程项目建议建立自己的专用目录(如 ~/bin)再放入 PATH。
再做一遍 HOME 实验:
# 用 root 和普通用户分别执行,对比差异
echo $HOME # 普通用户:/home/你的用户名 ; root:/root
# 执行 cd ~ ; pwd,体会 ~ 和 $HOME 的关系
cd ~ ; pwd # 输出就是 $HOME 的值,说明 ~ 就代表 $HOME和环境变量相关的命令
echo:显示某个环境变量的值;export:设置一个新的环境变量(导出为环境变量,可被子进程继承);env:显示所有环境变量;unset:清除环境变量;set:显示本地定义的 shell 变量和环境变量(比env多,还包括 shell 内部变量和函数等)。
export MYVAR=abc # 设置并导出环境变量 MYVAR
echo $MYVAR # 输出 abc
env | grep MYVAR # 用 env 看到它
unset MYVAR # 清除它
echo $MYVAR # 现在为空
set | head -5 # 查看本地变量 + 环境变量(示例)环境变量的组织方式
每个程序都会收到一张"环境表"。那张表是什么?环境表是一个字符指针数组,每个指针指向一个以 '\0' 结尾的环境字符串。比如 "PATH=/usr/bin:/bin" 这样的字符串,数组以一个 NULL(空指针)结尾。
通过代码获取环境变量
第 1 种方式:main 函数的第三个参数。main 除了 argc(参数个数)、argv(参数字符串数组),其实还有第三个参数 env——指向环境表的指针。
// env_third.c
#include <stdio.h>
int main(int argc, char *argv[], char *env[]) // 第三个参数就是环境表
{
int i = 0;
for (; env[i]; i++) { // 遍历,直到遇到 NULL(环境数组以NULL结尾)
printf("%s\n", env[i]); // 逐行打印环境项,如 "PATH=..."
}
return 0;
}gcc env_third.c -o env_third
./env_third | head -5 # 打印出的就是一份环境表第 2 种方式:通过第三方变量 environ 获取。libc 里定义了一个全局变量 environ 指向环境变量表。注意:environ 没有包含在任何头文件里,所以使用时要自己用 extern 声明。
// env_global.c
#include <stdio.h>
int main(int argc, char *argv[]) // 不需要第三个参数了
{
extern char **environ; // 声明 libc 的全局环境指针
int i = 0;
for (; environ[i]; i++) { // 遍历环境表
printf("%s\n", environ[i]); // 打印每一项
}
return 0;
}gcc env_global.c -o env_global
./env_global | head -5通过系统调用/库函数获取或设置环境变量
常用的是 getenv 和 putenv 来访问特定的环境变量。
getenv:本次讲解。
// getenv_demo.c
#include <stdio.h> // printf
#include <stdlib.h> // getenv(在 stdlib.h 里声明)
int main()
{
// getenv("PATH") 返回指向 PATH 值的指针;没有则返回 NULL
printf("%s\n", getenv("PATH"));
return 0;
}gcc getenv_demo.c -o getenv_demo
./getenv_demo # 打印出 PATH 的内容putenv:设置环境变量,后续章节详解(它可以直接把 "VAR=value" 这样的字符串放进环境表)。
环境变量的全局属性:被子进程继承
这是环境变量最重要的特性之一——环境变量通常具有全局属性,可以被子进程继承下去。我们用代码验证:
// getmyenv.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
char *env = getenv("MYENV"); // 尝试读取环境变量 MYENV
if (env) { // 如果存在(返回非NULL)
printf("%s\n", env); // 打印它的值
}
return 0;
}gcc getmyenv.c -o getmyenv
./getmyenv
# 现在没有任何输出 —— 说明环境变量 MYENV 根本不存在然后导出环境变量:
export MYENV="hello world" # 导出,使它对子进程可见
./getmyenv # 再次运行,这次会打印 "hello world"!终于打印出来了!这说明了:环境变量是可以被子进程继承下去的。为什么?因为进程是"父生子的",fork 创建的子进程会复制父进程的环境表(char **environ),所以你在 shell 里 export 了 MYENV,之后由该 shell 启动的每一个程序(作为 shell 的子进程)都能读到它。
思考题(必做): 实验 —— 如果只进行 MYENV="hello world"(不带 export),再用我们的程序查看,会有什么结果?为什么?
详解答案: 结果是没有输出。原因是:不带 export 时,MYENV="hello world" 只创建了一个普通的 shell 局部变量,它只存在于当前 shell 进程内部,并不在环境表 environ 里。而 export 的作用恰恰是把"普通变量"提升为"环境变量"、放进 environ,从而让 fork 的子进程能把它复制继承。我们的程序是通过 getenv(本质是读环境表 environ)来取值的,局部变量不在环境表里,自然取不到。一句话:只有 export 过的环境变量才会被子进程继承。
再深入一步:为什么说"环境变量能被继承",而"C 语言里的普通全局变量却不能被子进程看到"?因为环境变量是进程启动时由父进程写入后、位于真实地址空间的一部分,fork 会把整个地址空间(包括环境表所在内存区域)按写时拷贝稍作共享地复制过去;而 C 字面意义的"全局变量"是编译期就定在程序里的符号,父子各自有一份,与"继承"无关。环境变量的"继承"本质上是在进程创建时,把父进程的环境表这份用户空间数据交给了子进程。
扩展实验(时间允许就做): 修改 ~/.bash_profile 或 ~/.bashrc,在文件里写一行 export MYENV="...",然后重新打开终端(或 source ~/.bashrc),让这种"文件级"的环境变量在每次登录/启动 shell 时自动生效。你会更直观体会"环境变量服务于整个运行环境"。
程序的地址空间:C 内存布局与虚拟地址
最后,也是理解 Linux 内存管理最重要的一节。我们前面反复挖的坑——"同一个变量地址相同、内容却不同"——现在要一次性填平。
研究平台说明
本节许多现象以 kernel 2.6.32 + 32 位平台为观察对象(经典的《进程概念》教材实验环境)。现在的 x86-64 系统地址会更大、布局偏移不同,但"各区域相对位置"的大格局完全一致。请带着"看规律"的心态去读,别纠结具体地址数值。
回顾 C 语言的内存空间布局
学 C 的时候,老师给你画过这样一张内存布局图(自高地址到低地址大致是):
高地址 +-----------------------+
| 命令行参数/环境变量 | (argc/argv、environ)
|-----------------------|
| 栈(stack) | ← 向下增长(地址从高往低)
| (局部变量等) |
+-----------------------+
| 空/共享库区域 | (.so 映射、mmap)
+-----------------------+
| 堆(heap) | → 向上增长(地址从低往高)
| (malloc/new 分配) |
+-----------------------+
| 未初始化数据 .bss | (未初始化的全局变量,初值为0)
|-----------------------|
| 初始化数据 .data | (已初始化的全局变量/静态变量)
|-----------------------|
| 只读数据 .rodata | (只读字符串常量等)
|-----------------------|
| 代码段 .text | (程序指令;main 函数地址在这)
低地址 +-----------------------+
我们先用一段代码把这块布局边到边验证一遍:
// memmap.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval; // 未初始化的全局变量 -> .bss
int g_val = 100; // 已初始化的全局变量 -> .data
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld"; // 指向只读字符串常量 -> .rodata(str本身在栈)
printf("code addr: %p\n", main); // 函数名取址 -> 代码段 .text
printf("init global addr: %p\n", &g_val); // 已初始化全局 -> .data
printf("uninit global addr: %p\n", &g_unval);// 未初始化全局 -> .bss
static int test = 10; // 静态局部变量其实也放 .data(注意这个不是栈的)
char *heap_mem = (char*)malloc(10); // 堆上分配
printf("test static addr: %p\n", &test); // 静态变量仍在数据段
printf("heap addr: %p\n", heap_mem); // 堆地址(随分配递增)
printf("stack addr: %p\n", &heap_mem); // 取第一个局部变量地址 -> 栈
printf("read only string addr: %p\n", str); // 只读字符串
for (int i = 0; i < argc; i++) // 打印命令行参数地址
printf("argv[%d]: %p\n", i, argv[i]);
for (int i = 0; env[i]; i++) // 打印环境变量地址
printf("env[%d]: %p\n", i, env[i]);
return 0;
}gcc memmap.c -o memmap
./memmap x y
# 在 32 位老环境下的输出大致是:
# code addr: 0x40055d
# init global addr: 0x601034
# uninit global addr: 0x601040
# test static addr: 0x601038
# heap addr: 0x1791010
# stack addr: 0x7ffd0f9a4368
# read only string addr: 0x400800
# argv[0]: 0x7ffd0f9a4811
# env[0]: 0x7ffd0f9a4819
# ...仔细观察这些地址的分布,你会发现它们恰好符合那张布局图:
- 代码段地址最小(
0x40055d、只读字符串0x400800)——在最低处; - 数据段(
0x6010xx)紧接着代码段; - 堆(
0x179xxxx)比数据段更高一点; - 栈(
0x7ffd...)和参数/环境变量(0x7ffd...)在最顶端——地址最大。
堆怎么增长? 你连续 malloc 几次,把它们的地址都打出来,会看到堆地址一路"向上"(越来越大);栈怎么增长? 连续定义几个局部变量取地址,会看到地址一路"向下"(越来越小)。这印证了栈向低地址增长、堆向高地址增长。
虚拟地址:那个"地址相同内容不同"的真相
还记得 fork 那节留下的悬念吗?来一段能解开一切的程序:
// virt_val.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0; // 全局变量,初始为0
int main()
{
pid_t id = fork(); // 创建子进程
if (id < 0) { // fork失败
perror("fork");
return 0;
}
else if (id == 0) { // 子进程
g_val = 100; // 子进程把 g_val 改成 100
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val); // 打印值+地址
}
else { // 父进程
sleep(3); // 睡3秒,确保子进程先改先打印,让父进程再读
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val); // 打印值+地址
}
sleep(1);
return 0;
}gcc virt_val.c -o virt_val
./virt_val
# 输出(地址在不同环境可能不同,看规律):
# child[3046]: 100 : 0x80497e8
# parent[3045]: 0 : 0x80497e8看出什么了?父进程和子进程拿到的地址 0x80497e8 一模一样,但父进程打印的是 0,子进程打印的是 100! 这怎么解释?一个小女孩一个加十岁,住址却完全相同?结论只能是:
- 变量内容不一样 → 父子进程输出的变量绝对不是同一个变量(否则值不会不同);
- 地址值一样 → 这个地址绝对不是物理地址(否则不可能两个不同的 0 和 100 挤在同一地址)。
那么这个地址到底是什么?——虚拟地址!
什么叫虚拟地址空间?——和物理内存"脱钩"的地址
在 Linux 下,C/C++ 语言里你所见到的所有指针、地址、& 取到的地址,全部都是虚拟地址(virtual address)。物理地址用户一概看不到,由操作系统(配合 MMU 硬件)统一管理,负责把虚拟地址转换成物理地址。
来理解这个转换:每个进程都"假装"自己独占一整块连续的大空间(32 位下就是 4GB),手里拿到的地址都是这套"虚拟地址空间"里的编号。同一份虚拟地址,在不同进程里,可以各自通过页表被映射到不同的物理内存,所以在父子进程里都打印出 0x80497e8 这个虚拟地址,但子进程的 0x80497e8 指向的是"子进程那块物理页",父进程的 0x80497e8 指向的是"父进程那块物理页"。值自然就不同了。
还要注意一点:我们把"子进程改值后再读"的顺序固定成"先子后父"(父进程 sleep 3),是为了让观察更清晰——子进程先写自己的 copy(写时拷贝触发拷贝),父进程再去读自己的、完好的 0。
分页与虚拟地址空间,一张图说明问题
把上面的"同一虚拟地址映射到不同物理地址"画出来(示意):
虚拟地址空间(进程视角) 物理内存(RAM)
父进程: 0x80497e8 ----------------映射--------> 物理页 P1 (存放0)
子进程: 0x80497e8 ----------------映射--------> 物理页 P2 (存放100)
- 上面的图就足以说明问题:同一个变量,地址(虚拟地址)相同,内容不同,其实是被映射到了不同的物理地址;
- 谁来维护"虚拟地址 → 物理地址"的映射?——页表(page table),由操作系统创建和维护,MMU(Memory Management Unit,内存管理单元,CPU 内的硬件)在每次访存时用页表做翻译。
两个帮助你记忆的比喻:
- 如何理解虚拟地址空间? 想象一个"大富翁"游戏:玩家各自面前摆着一套一样的地图坐标,可实际每家的地块(物理内存)是分开的。大家看的是同一张地图(同一套虚拟地址),走的却是自家的地。
- 如何理解区域划分? 可以联想"38 线"的比喻——空间被划分成不同的区(代码区、数据区、堆、栈……),区与区之间有清晰的边界,像一条条分界线,互不越界。而"按页"内存管理(分页)则是把物理内存切成固定大小(如 4KB)的页,配合页表来映射,这就是分页机制。
(提示:为了讲透"地址相同内容不同",本节特意把映射讲成"不同页不同内容"。实际上在 fork 后、写之前,父子共享同一物理页(写时拷贝);谁先写,谁触发拷贝独立出来。最终效果正是"地址相同但内容彼此独立"。)
进程地址空间:mm_struct 与 vm_area_struct
我们说"程序地址空间"其实不准确,准确的说法应该是进程地址空间——因为每个进程都各自拥有一份独立的地址空间。
描述一个进程地址空间所有信息的结构体是 mm_struct(内存描述符)。每个进程只有一个 mm_struct,在每个进程的 task_struct 里,有一个指针指向它:
struct task_struct {
/* ... */
struct mm_struct *mm; // 普通用户进程:指向该进程虚拟地址空间的用户空间部分
// 注意:内核线程的这个字段为 NULL
struct mm_struct *active_mm; // 内核线程使用:内核线程 mm 为 NULL,但所有进程关于内核
// 的映射都一样,所以内核线程可借用任意进程的地址空间
/* ... */
};一句话:mm_struct 结构是对整个用户空间的描述;每一个进程都有自己独立的 mm_struct,这样每一个进程都有自己独立的地址空间、才能互不干扰。
mm_struct 定义在内核头文件 include/linux/mm_types.h 里(task_struct 则在 include/linux/sched.h)。它的关键字段记录着各段的起止:
struct mm_struct {
/* ... */
struct vm_area_struct *mmap; /* 指向虚拟区间链表的头(虚拟区较少时用) */
struct rb_root mm_rb; /* 红黑树根(虚拟区间多时用它组织) */
unsigned long task_size; /* 本进程虚拟地址空间的大小 */
/* ... */
/* 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址 */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
/* ... */
};那么多进程的 mm_struct 怎么组织? 和 task_struct 双链表不同,地址空间内部更常见的是**虚拟内存区(VMA)**的组织,两种方式:
- 当虚拟区间较少时,采用单链表,由
mmap指针指向这个链表; - 当虚拟区间多时,采用红黑树管理,由
mm_rb指向这棵树。
Linux 内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(Virtual Memory Area,VMA)。由于每个不同质的虚拟内存区域(代码、数据、堆、栈、映射文件……)功能和内部机制都不同,因此一个进程使用多个 vm_area_struct 结构来分别表示不同类型的虚拟内存区域。上面那两种组织方式用的就是 vm_area_struct 来连接各个 VMA,方便进程快速访问。vm_area_struct 里记录着该区域(VMA)的起止地址、可读可写可执行等标志、关系到哪个文件、链表/红黑树里的位置等。到这里,"描述 + 组织"这套心法再次登场——连地址空间本身,也都是用结构体描述、用链表/红黑树组织的。
为什么要有虚拟地址空间?——没有它,世界会乱套
这个问题可以转化为:如果程序直接可以操作物理内存,会造成什么问题? 早期计算机正是"灾难现场",我们把问题逐条摆出来,你就懂虚拟地址和分页的意义了。
在早期计算机中,运行一个程序,就直接把整个程序装入内存,程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量小于物理内存的大小。例如:一台总内存 128M 的机器,同时跑两个程序 A(需 10M)和 B(需 110M),只好把上前 10M 给 A,剩下的 110M 给 B。这种"简单直接"的分配方式问题非常多:
- 安全风险:每个进程都可以访问任意内存空间,任意一个进程都能去读写系统相关的内存区域。如果是一个木马/病毒,它就能随意修改内存空间,让设备直接瘫痪。没有隔离,就没有安全。
- 地址不确定:编译完的程序放在硬盘上,运行时搬到内存。如果直接用物理地址,我们无法预知程序这次会落在内存的哪个位置。第一次执行时内存空,搬移到
0x00000000;第二次执行时内存已有 10 个进程,它落的位置又变了。程序里明明写死了地址,落点却每次不确定,让所有"预设好地址"的代码全乱套。 - 效率低下:用物理内存时,一个进程是作为一个整体(内存块)操作的。一旦物理内存不够,通常要把不常用的进程拷贝到磁盘的交换分区(swap)去腾内存。可是如果用物理地址,就得把整个进程一起拷走——这么大的整块搬运在内存与磁盘间往返,时间太长,效率极低。
有了虚拟地址空间和分页机制,就能解决这些问题吗?当然能! 逐个击破:
- 地址空间和页表是操作系统创建并维护的。是不是意味着:凡是想使用地址空间和页表进行映射访问,也一定要在 OS 的监管之下来进行访问! 进而也就保护了物理内存中所有的合法数据(包括各个进程以及内核的有效数据)。普通进程如果想去访问别人的页,页表里没有对应项,立刻触发非法访问(段错误/保护异常),病毒也动不了内核。
- 因为有地址空间和页表映射的存在,物理内存可以对未来的数据进行任意位置的加载——物理内存的分配和进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了"解耦"。程序"虚拟上"的起始地址是固定的、连续且规整的,但物理上它到底散落/映射在哪些页,进程根本不用关心。
- 因为有地址空间的存在,C/C++ 里
new、malloc空间的时候,其实是在"地址空间"上申请的,物理内存可以连一个字节都不给你。等你真正去访问这块空间、往里写数据时,才执行内存管理算法去帮你申请物理内存、构建页表映射——这就是延迟分配(按需调页,demand allocation)。这一切由操作系统自动完成,用户和进程完全无感。所以你会看到"malloc 了很大一块内存但 RSS 占用很小"的现象——因为还没真正碰它。 - 因为页表映射的存在,程序理论上在物理内存的任意位置都能加载,它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角,所有的内存分布都是有序、连续、固定的。
坑提示(大厂面试常问):"malloc 成功就代表真的拿到内存了吗?" 不是。malloc 只是在虚拟地址空间里划了一块虚拟区域,并不立即占用等价物理内存;只有你写进去时,缺页异常才触发真正分配物理页。观察方式:malloc 后看 /proc/<pid>/status 里的 VmSize(虚拟)会变大,而 VmRSS(常驻物理)可能基本不变;一旦 memset 写一遍,VmRSS 才涨上去。
思考题(必做):为什么同一个程序运行两次,打印的 &局部变量 地址却经常相同(稳定),而不是每次随机变化?
思考: 结合虚拟地址与页表,解释"几乎每次运行的同一个变量地址都一样"这种稳定现象从何而来。反过来,为什么某些系统开启了 ASLR(地址空间布局随机化)后,这个稳定地址又变得每次启动都不同了?
详解答案:
- 你打印的是虚拟地址,不是物理地址。进程对内存的一切访问都通过"虚拟地址 → 页表 → 物理地址"完成,物理地址每个进程、每次运行可能天差地别,但虚拟地址是由编译器和程序运行映像的布局决定的——可执行文件的代码段、数据段、栈、堆在"虚拟地址空间"里的相对位置基本固定,所以每次启动,同一符号落在同一虚拟地址附近。这就是"稳定"的来源。
- 每次运行物理内存分配位置不同,只会让翻译后的物理地址不同,不会改变 coder 看到的虚拟地址——因为页表把"虚拟地址"与"物理地址"解耦了。
- 但为防攻击者利用这个"稳定"去猜内存布局(栈溢出/ret2libc 攻击常利用固定地址),现代系统默认开启 ASLR(Address Space Layout Randomization,地址空间布局随机化):加载器在每次启动时,把栈、堆、共享库、乃至可执行文件(PIE 时)的基址打乱。于是你会观察到:同一程序多次运行,
printf("%p",&x)的值这次是0x7ffd...a100、下次是0x7fff...c3e0。这也从反面证明了地址的稳定性/随机性都发生在虚拟地址层,与物理内存无关。 - 实验验证:
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space(开 ASLR)多次运行与对照关闭(0)多次运行,对比%p输出即可看到差异。结论:虚拟地址让"地址像身份号一样可预测/可管理",物理地址则完全不可见、由内核包办。
一点 exec 的前瞻:程序"换皮不换身"
文章标题里写了"fork 与 exec",前面把 fork 讲完了,这里补充 exec 的概念,方便你形成完整拼图(它属于后续章节的详细内容,这里先建立直觉)。
fork是"克隆自己"——创建一个几乎一模一样的子进程;exec一族(execl、execv、execvp、execve等)是"换程序"——用另一个可执行文件替换当前进程的代码段、数据段、堆、栈,让当前进程"跑一个全新的程序",但进程号(PID)不变,PCB 里主体还在。
于是经典的"造进程"组合拳就是:先用 fork 造出一个子进程(包括一份独立的地址空间),在子进程里调用 exec 去"变身"成目标程序;父进程则继续跑原来的程序(并负责 wait 回收子进程)。 你在终端敲下 ls、ps,shell 干的正是"fork 一个自己,然后在子进程里 exec ls/ps"这件事。
// myexec.c —— 演示 fork + exec 组合的骨架(exec 后续章节详解)
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
pid_t pid = fork(); // 1. 造一个子进程
if (pid < 0) { // fork失败
perror("fork");
return 1;
}
else if (pid == 0) { // 2. 子进程:去"变身"
execlp("ps", "ps", "-ef", NULL); // 用 ps -ef 替换当前映像
perror("execlp"); // 只有exec失败才会执行到这里
exit(1);
}
else { // 3. 父进程:等孩子跑完并回收
wait(NULL); // wait 回收子进程,避免僵尸
printf("child has finished\n");
}
return 0;
}gcc myexec.c -o myexec
./myexec # 先看到一份 ps -ef 的输出,然后打印 "child has finished"execlp 的第一个参数是程序路径(p 表示会在 PATH 里找),第二个及之后是 argv[0]、argv[1]……,以 NULL 结尾。这里你只需抓住"exec 换程序、fork 造分身、wait 管收尸"这条主线即可,细节留给进程控制章节。
总结:从硬件到进程的完整图景
我们这一路走得很长,现在帮你把整幅图串起来:
- 计算机按冯诺依曼体系运转——CPU 只能访问内存,所有设备都只和内存打交道,操作系统正是在这个地基上做"管理"。
- 操作系统"管理一切"的心法是"描述 + 组织":描述用结构体,组织用链表/树等数据结构。管理进程,就是描述成
task_struct(PCB),组织进任务链表。 - 程序是静态的菜谱,进程是动态的那顿饭。进程 = "内核数据结构 task_struct(PCB)+ 自己的程序代码和数据"。
- PCB/task_struct 装着进程的身份、状态、优先级、寄存器现场、文件表、记账信息等一大票属性;可以用
/proc/<pid>或者ps/top查看;程序里用getpid/getppid拿身份。 - 创建进程用
fork:父得子 PID、子得 0、失败 -1,代码共享、数据各自独立(写时拷贝)。行驶"换程序"还要用exec组合完成变身。 - 进程有丰富的状态:R/S/D/T/t/X/Z。子进程退出父进程不回收 → 僵尸进程;父进程先退出 → 孤儿进程被 1 号(init/systemd)收养。父进程要及时
wait,否则内存泄漏。 - 得到的资源如何分?优先级说了算:
PRI越小越先执行,nice是修正量(PRI(new)=PRI(old)+NI),取值-20~19。由此衍生出进程的竞争性、独立性、以及并行/并发之分。 - 多进程轮流占用 CPU 就是进程切换:保存现场(寄存器→内核栈/PCB)、恢复现场,每个进程配一个时间片。让"挑进程"不随进程数变慢的经典是 O(1) 调度器:位图 + 双队列 + 指针交换。
- 程序启动会收到命令行参数和一张环境表。环境变量(PATH/HOME/SHELL...)用
echo/export/env/unset/set管理,代码里用argv[2]/environ/getenv访问,并且能被fork的子进程继承。 - 最终的谜底是虚拟地址/进程地址空间:你在 C/C++ 里看到的地址全是虚拟地址。每个进程有一套独立的
mm_struct描述的虚拟空间(代码/数据/堆/栈/参数环境各居其位),通过页表 + MMU 映射到物理内存,实现安全性、灵活装载、延迟分配,也因此"地址相同、内容不同"成为可能。
如果你跟着上面的代码和命令一个个敲过、亲手制造过一次僵尸、一次孤儿,那次 ps axj 里跳出的 Z 和变成 1 的 PPID,会比任何文字都更结实。
全篇综合思考题(必做,把知识焊死)
思考 1: 你有一个程序,父进程 fork 出很多子进程后自己睡大觉,既不 wait 也不退出。慢慢运行下去,你能看到什么现象?ps 里会积累什么状态的进程?会造成什么后果?怎么治?
详解答案: 会看到子进程一个个退出后堆积成 Z(僵尸)状态的进程(<defunct>),父进程一直在睡、不收尸,它们的 task_struct/PCB 一直占着内核内存与进程表项。时间一长,是内核内存泄漏,严重时进程表满会导致新的进程无法创建(fork 报 Resource temporarily unavailable)。治法:在父进程里对每一个 fork 出的子进程调用 wait()/waitpid() 及时回收;或用信号机制(后续章节讲)在子进程退出时自动收尸。绝对不要把"等父进程死掉被 init 收养"当方案——那只会让泄漏持续到父进程退出为止。
思考 2(真题思路): 下面的程序会打印几次 "hello"?为什么?
#include <stdio.h>
#include <unistd.h>
int main()
{
fork();
fork();
printf("hello\n");
return 0;
}详解答案: 会打印 4 次。推理:第一个 fork 后,进程变成 2 个(原父 + 子1);第二个 fork 时,这两个进程各自都再 fork 一次,于是变成 4 个。它们从第二个 fork 返回后都走到 printf("hello"),所以打印 4 次。这个"fork 执行几次就有 2 的 n 次方个进程"的指数增长,是丢面试里很常见的"数打印次数"题,核心要抓住:每次 fork 返回后,后续所有代码都会在父子两个进程里各自执行一遍。
思考 3: fork 之后,同一个变量 x,父子进程的虚拟地址相同,但各自修改互不影响。请用"写时拷贝 + 页表"一句话解释这种"同址不同值"是怎么做到的。
详解答案: fork 之后,父子共享同一物理页,且该页被标记为"只读";谁要写它,触发缺页异常,内核为"写者"复制出一个新物理页、把新页映射到同一个虚拟地址,之后两人各自拥有独立物理页——于是虚拟地址相同(都是原来那个虚拟地址),但各自指向不同的物理页,内容自然互不影响;未写的一方继续共享原来的只读页。
思考 4: 为什么操作系统不把物理内存直接暴露给用户程序?请至少从"安全、装载、效率"三方面各说一条理由。
详解答案:(1)安全:直接访问物理内存意味着任何进程都可能读到/改写其他进程乃至内核的数据,病毒能直接瘫痪系统;虚拟地址 + 页表让每次访问都受内核监管、完成进程间隔离。(2)装载:物理地址装载位置每次运行都不一定,程序里定死地址会乱套;虚拟地址让程序在"虚拟层"拥有固定、连续、规整的空间,物理上放在哪由内核决定,二者解耦。(3)效率:物理地址下换出进程要把整块程序搬去磁盘,代价高昂;分页 + 虚拟地址后可把不用的"页"细粒度换进换出,且支持按需分配的延迟载入,速度与内存利用率都大幅改善。
如果这些思考题你能不看答案先自己推演一遍,再对照详解,那这一章的内容就真正长在你身体里了。下一站,我们将拿起 fork/exec/wait 这三件套,正式走进"进程控制"的战场——那是我们亲手创造、指挥、回收进程的地方。准备好了吗?
还没有评论 — 第一条由你来留。