【Linux 系统编程】进程概念全景:从 PCB、/proc 到进程状态与虚拟地址空间

📅 发布时间:2026/7/15 21:24:55
【Linux 系统编程】进程概念全景:从 PCB、/proc 到进程状态与虚拟地址空间 文章从“操作系统如何管理进程”出发通过可复现的命令和 C 语言实验梳理 PCB、PID/PPID、/proc、cwd/exe、chdir、fork、进程状态、僵尸进程、环境变量以及虚拟地址空间。一、主线操作系统为什么需要“进程”我们平时说“运行一个程序”看起来只是输入一条命令./my_program但是站在操作系统的角度仅有磁盘上的程序文件还不够。系统必须知道这个程序现在运行到哪条指令它占用了哪些内存、打开了哪些文件它的 PID、父进程、权限和当前状态是什么它是否可以继续运行应该获得多少 CPU 时间它退出后退出结果由谁接收。这正是操作系统管理进程的基本思路先描述用内核数据结构记录进程属性再组织根据调度、父子关系、等待事件等需求把这些数据结构组织起来最后管理创建、调度、暂停、唤醒、终止和回收进程。因此一个更完整的理解是进程不是磁盘上的可执行文件而是程序的一次动态执行实例。在当前学习阶段可以先记住下面这个模型进程 程序代码和数据 内核为该执行流维护的数据结构 运行时资源程序是静态文件进程是动态实体。同一个程序可以被运行多次每次运行都会形成不同的进程并拥有不同的 PID 和运行上下文。二、PCB操作系统描述进程的“档案”2.1 什么是 PCB操作系统把描述进程所需的信息集中在进程控制块中即 PCBProcess Control Block。Linux 内核中与 PCB 对应的核心数据结构是task_struct。task_struct中记录或关联的信息包括信息类别典型内容解决的问题身份信息PID、PPID、线程组 ID“它是谁、由谁创建”运行状态R、S、D、T、Z 等“它现在能否运行”调度信息调度策略、优先级、运行时间“什么时候轮到它”CPU 上下文指令地址、寄存器现场“下次从哪里继续”内存信息指向mm_struct的指针“它能看到哪些虚拟内存”文件信息指向文件描述符表的指针“它打开了哪些文件”文件系统信息当前工作目录、根目录等“相对路径从哪里解析”信号信息信号处理方式、阻塞集合“收到异步事件时怎么办”统计与退出信息CPU 时间、退出码“运行了多久、如何结束”可以把几个重要关系简化为task_struct ├── 身份、状态、调度与上下文 ├── mm ── mm_struct ── 虚拟内存区域、页表 ├── files ── files_struct ── 文件描述符表 ├── fs ── fs_struct ── cwd、root 等 └── signal/sighand ── 信号相关信息需要注意Linux 内核并不是只用“一条 PCB 链表”管理所有事情。一个task_struct会按不同目的加入任务链表、调度队列以及父子关系等结构。课堂上“描述起来、组织起来”的模型非常重要但不能把真实内核实现过度简化成单一链表。2.2 PC、EIP 与 RIP 到底是什么课本常说“程序计数器 PC 保存下一条指令的地址”。在 x86 体系下32 位通常称为 EIP64 位称为 RIP。它不是 C 语言中普通的“程序指针”也不能简单理解为“指向哪个程序哪个程序就在运行”。更准确地说CPU 执行某个进程时指令指针寄存器保存该执行流接下来要执行的指令地址发生进程切换时操作系统保存当前寄存器现场并恢复另一个进程先前保存的现场。函数调用、返回、条件分支和循环本质上都会影响指令执行的下一位置。三、从用户态观察一个真实进程3.1 获取 PID、PPID 和当前工作目录先编写一个便于观察的程序// process_observe.c#includelimits.h#includestdio.h#includeunistd.hintmain(void){charcwd[PATH_MAX];if(getcwd(cwd,sizeof(cwd))NULL){perror(getcwd);return1;}printf(pid %ld\n,(long)getpid());printf(ppid %ld\n,(long)getppid());printf(cwd %s\n,cwd);fflush(stdout);while(1){sleep(1);}}编译并放到后台运行gcc-Wall-Wextra-O2process_observe.c-oprocess_observe ./process_observepid$!getpid()返回调用进程的 PIDgetppid()返回其父进程 PID。通过命令行启动程序时父进程通常是当前 Shell但不要把“父进程永远是 bash”当作定律因为程序也可能由其他 Shell、服务管理器或另一个应用创建。3.2 用 ps 查看进程ps-opid,ppid,stat,pri,ni,cmd-p$pid其中PID进程 IDPPID父进程 IDSTAT进程状态PRI、NI调度优先级相关信息CMD命令行。也可以使用课程中常见的命令psauxpsaxj如果使用ps ... | grep process_observe结果中可能还会出现grep自己因为执行grep时它同样是一个进程。更直接的办法是使用pgreppgrep-aprocess_observe3.3 正确理解 CtrlC 与 kill在终端按CtrlC通常是终端驱动向前台进程组发送SIGINT。kill PID默认发送SIGTERM它是在“请求进程终止”进程可以捕获或忽略该信号。kill -9 PID发送SIGKILL进程不能捕获、阻塞或忽略它应当只在普通终止无效时使用。所以kill命令的核心含义是“发送信号”并不只用于杀死进程。四、/proc内核提供的进程观察窗口4.1 /proc 不是普通磁盘目录Linux 会把很多内核和进程信息通过/proc暴露给用户态。它是一个伪文件系统目录和文件看起来可以被ls、cat、readlink访问但其中大部分内容由内核动态生成并不是普通磁盘文件。对于 PID 为 1234 的进程其信息通常位于/proc/1234/当进程退出并被回收后对应 PID 目录会消失。观察前面的实验进程ls-l/proc/$pidcat/proc/$pid/status|headcat/proc/$pid/stattr\0\n/proc/$pid/environ|headstatus更适合人阅读stat适合程序解析但字段较多environ中各变量使用空字符\0分隔所以这里用tr转成换行。4.2 cwd相对路径从哪里开始readlink-f/proc/$pid/cwd/proc/pid/cwd是指向进程当前工作目录的符号链接。程序执行FILE*fpfopen(result.txt,w);时result.txt不是相对于源代码所在位置也不一定相对于可执行文件所在位置而是相对于该进程的当前工作目录解析。4.3 exe进程从哪个可执行文件启动readlink/proc/$pid/exe/proc/pid/exe通常指向启动该进程的可执行文件。做一个很有代表性的实验rm./process_observe readlink/proc/$pid/exeps-p$pid常见现象是exe链接后出现(deleted)但进程仍然运行。为什么“程序已经被复制到内存”是便于入门的说法但还不够完整。进程启动后内核已经建立可执行文件的内存映射并持有相关内核对象的引用。rm删除的是目录项只要仍有引用相关内容不会立刻以进程可见的方式消失已有映射也可以继续使用。因此删除磁盘路径不等于立即撤销正在运行的进程。实验结束后清理进程kill$pidwait$pid2/dev/null五、chdir改变的是“当前进程”的工作目录下面的程序先打印当前目录再切换目录并创建文件// change_dir.c#includelimits.h#includestdio.h#includeunistd.hintmain(intargc,char*argv[]){charbefore[PATH_MAX];charafter[PATH_MAX];if(argc!2){fprintf(stderr,usage: %s directory\n,argv[0]);return1;}if(getcwd(before,sizeof(before))NULL){perror(getcwd before);return1;}if(chdir(argv[1])-1){perror(chdir);return1;}if(getcwd(after,sizeof(after))NULL){perror(getcwd after);return1;}FILE*fpfopen(result.txt,w);if(fpNULL){perror(fopen);return1;}fprintf(fp,created by pid %ld\n,(long)getpid());fclose(fp);printf(before: %s\n,before);printf(after : %s\n,after);printf(created: %s/result.txt\n,after);return0;}gcc-Wall-Wextrachange_dir.c-ochange_dir ./change_dir /tmp这里有一个很重要的边界chdir()只改变调用进程自己的工作目录不能改变父进程 Shell 的工作目录。这也解释了为什么cd必须是 Shell 内建命令。如果 Shell 额外创建一个子进程去执行外部cd改变的只会是子进程目录子进程退出后Shell 本身仍停留在原目录。六、fork一个进程如何变成两个执行流6.1 fork 的基本现象// fork_demo.c#includestdio.h#includesys/types.h#includeunistd.hintmain(void){pid_tretfork();if(ret0){perror(fork);return1;}if(ret0){printf(child : pid%ld, ppid%ld, fork_ret%ld\n,(long)getpid(),(long)getppid(),(long)ret);}else{printf(parent: pid%ld, child_pid%ld, fork_ret%ld\n,(long)getpid(),(long)ret,(long)ret);}return0;}gcc-Wall-Wextrafork_demo.c-ofork_demo ./fork_demofork()调用一次却会在父子两个执行流中各返回一次父进程中返回子进程 PID子进程中返回 0失败时只在原进程中返回 -1并设置errno。fork()之后父子进程从同一代码位置继续向后执行但调度顺序不确定不能根据某一次输出顺序推断“父进程永远先运行”或“子进程永远先运行”。6.2 代码共享数据真的共享吗父子进程创建之初拥有相似的地址空间内容。为了避免立刻复制全部物理内存内核通常使用写时拷贝Copy-On-WriteCOW创建之初父子页表可以暂时映射相同物理页页面被标记为适当的只读/COW 状态某一方尝试写入时触发缺页异常内核为写入方复制页面并修改映射。因此“父子进程数据各自独立”和“创建初期可能共享物理页”并不矛盾前者说的是可观察语义后者说的是内核优化。七、进程状态R 不等于正在占用 CPU常见 Linux 进程状态可以这样理解状态含义关键理解RRunning/Runnable可能正在 CPU 上运行也可能只是在运行队列中等待S可中断睡眠等待事件可被信号唤醒最常见D不可中断睡眠通常在等待 I/O不容易被普通信号打断T停止常由作业控制或SIGSTOP导致t跟踪停止常见于调试、跟踪场景Z僵尸已退出但退出状态尚未被父进程读取X死亡瞬时或内部状态通常很难在ps中观察到最容易误解的是 R它表示“正在运行或具备运行条件”不保证采样这一刻真的占用了 CPU。7.1 僵尸进程为什么出现子进程退出时内核仍要保留少量信息例如退出码和资源使用统计供父进程通过wait()/waitpid()读取。如果父进程一直不读取子进程就会保持 Z 状态。// zombie_demo.c#includestdio.h#includestdlib.h#includesys/types.h#includesys/wait.h#includeunistd.hintmain(void){pid_tchildfork();if(child0){perror(fork);return1;}if(child0){printf(child %ld exits with code 42\n,(long)getpid());fflush(stdout);_exit(42);}printf(parent %ld: observe child %ld within 10 seconds\n,(long)getpid(),(long)child);sleep(10);intstatus0;if(waitpid(child,status,0)-1){perror(waitpid);return1;}if(WIFEXITED(status)){printf(child exit code %d\n,WEXITSTATUS(status));}return0;}在 10 秒窗口内用另一个终端观察ps-opid,ppid,stat,cmd-pchild_pid调用waitpid()后子进程的退出信息被读取PCB 才能被彻底回收。少量僵尸进程占用资源不多但如果服务程序长期创建子进程却不回收进程表项会不断累积最终影响系统创建新进程。7.2 孤儿进程与僵尸进程不是一回事僵尸进程子进程已经退出父进程尚未回收。孤儿进程父进程先退出子进程还在运行。孤儿进程会被系统中的子进程收养者接管通常由 PID 1 或配置了 subreaper 的进程负责后续回收。不能简单把“孤儿”和“僵尸”当作同一种异常状态。八、优先级、并发与上下文切换系统中可运行进程通常多于 CPU 数量因此进程具有竞争性。调度器需要决定下一步运行谁并在合适的时机切换执行流。8.1 nice 值不是优先级本身可以用下面的命令观察ps-ltopnice-n10./my_programrenice5-ppid普通进程的 nice 值通常范围为 -20 到 19。数值越小一般表示获得更高 CPU 调度权重但 nice 值是调度参数不代表进程会立刻运行更不表示可以突破权限和调度策略的约束。8.2 并发与并行并发多个任务在一段时间内都得到推进单核也能通过快速切换实现并行多个任务在同一时刻真正运行在不同 CPU 核心上。8.3 上下文切换保存了什么进程被换下 CPU 时内核需要保存寄存器、指令位置、栈相关状态等上下文另一个进程运行前再恢复它之前保存的上下文。切换让并发成为可能但也有保存/恢复寄存器、调度决策以及缓存扰动等成本。课件中的 Linux 2.6 O(1) 调度队列适合用来理解 active/expired 队列、位图和常数时间选取任务的思想。它属于特定历史阶段的实现模型学习时应把“调度思想”和“当前内核具体实现”区分开来。九、环境变量进程运行环境的一部分常见环境变量包括PATHShell 查找命令的路径列表HOME用户主目录SHELL登录 Shell 路径。查看和设置环境变量echo$PATHenvexportCOURSElinux_system_programmingunsetCOURSE程序中可以通过getenv()读取#includestdio.h#includestdlib.hintmain(void){constchar*valuegetenv(COURSE);printf(COURSE%s\n,value!NULL?value:not set);return0;}这里要区分 Shell 变量和环境变量COURSElinux# 当前 Shell 变量不一定进入子进程环境exportCOURSE# 导出后后续子进程才能继承父进程创建子进程时子进程继承的是环境内容的一个逻辑副本并不是父子进程共享一张可以互相实时修改的全局表。子进程修改自己的环境变量不会反向改变父进程环境。PATH也解释了一个常见问题为什么ls可以直接运行而自己的程序通常要写./a.out因为 Shell 会在PATH指定的目录中搜索命令而当前目录.通常不在PATH中。出于安全考虑不建议随意把.放到PATH最前面。十、虚拟地址空间进程独立性的底座10.1 一个看似矛盾的实验// virtual_address.c#includestdio.h#includesys/types.h#includesys/wait.h#includeunistd.hstaticintg_value0;intmain(void){pid_tchildfork();if(child0){perror(fork);return1;}if(child0){g_value100;printf(child: value%d, address%p\n,g_value,(void*)g_value);return0;}if(waitpid(child,NULL,0)-1){perror(waitpid);return1;}printf(parent: value%d, address%p\n,g_value,(void*)g_value);return0;}常见结果是父子进程打印出的地址相同变量值却不同。这并不矛盾因为程序打印的是虚拟地址。父子进程各有自己的地址空间和页表相同的虚拟地址可以映射到不同物理页。子进程写入后触发写时拷贝于是双方看到不同内容。可以把关系简化为父进程虚拟地址 0x... ── 父进程页表 ── 物理页 A值为 0 子进程虚拟地址 0x... ── 子进程页表 ── 物理页 B值为 10010.2 mm_struct 管理什么Linux 通过mm_struct描述进程的用户态虚拟地址空间其中会关联代码区、数据区、堆、共享映射、栈等虚拟内存区域。task_struct中的相关指针把“进程管理”与“内存管理”连接起来。引入虚拟地址空间的意义主要有隔离与保护普通进程不能随意访问其他进程或内核的物理内存统一视图每个进程都看到相对规整、独立的地址布局解耦加载位置程序使用虚拟地址不必提前知道物理页放在哪里提高效率支持按需分配、写时拷贝、文件映射等机制。因此进程独立性并不是一句抽象口号而是由独立的内核状态、地址空间、页表和资源视图共同保证的。十一、把整章知识连起来学习完这一部分后我对进程的理解可以概括为下面这条链程序被启动 ↓ 内核创建 task_struct 等数据结构 ↓ 建立地址空间、文件和信号等资源视图 ↓ 进程进入可调度状态由调度器选择运行 ↓ 运行中可通过 ps 和 /proc 被观察 ↓ 进程可能睡眠、停止、继续或创建子进程 ↓ 退出时保留退出状态等待父进程回收这条链也回答了几个最初容易困惑的问题程序和进程有什么区别前者是静态文件后者是一次受内核管理的执行实例。操作系统怎样找到一个进程通过 PID 和对应的内核数据结构而不是靠可执行文件名。为什么删除可执行文件后还能运行进程已有内存映射和内核引用删除路径不等于撤销运行实例。为什么父子进程地址相同、值却不同地址是虚拟地址各自页表可以映射不同物理页。为什么子进程退出后还可能存在父进程尚未读取其退出状态进程暂时处于 Z 状态。十二、复习检查清单如果能够不看笔记回答下面的问题这一节就算真正掌握了为什么说进程不等于可执行文件task_struct为什么是理解进程管理的核心/proc/pid/cwd和/proc/pid/exe分别表示什么chdir()为什么不能改变父 Shell 的当前目录fork()为什么会出现两个返回值R、S、D、T、Z 状态的区别是什么僵尸进程为什么必须由父进程回收Shell 变量为什么要export后才能被子进程继承写时拷贝怎样同时保证效率和进程独立性虚拟地址空间怎样保护物理内存并解耦进程管理与内存管理