Linux 进程概念深度剖析
Linux 进程概念深度剖析:从硬件到内核的完整拆解
前言
进程是操作系统中最核心的概念之一,但很多人对它的理解停留在"运行中的程序"这个层面。实际上,进程的背后是一套精密的硬件架构、内核数据结构和调度算法。这篇文章从冯诺依曼体系结构出发,一路深入到 task_struct 的内部结构、进程状态机的底层实现、Linux 2.6 内核的 O(1) 调度器、以及虚拟地址空间的页表映射机制,把进程概念从上到下拆解清楚。
1.冯诺依曼体系结构:一切设计的起点
我们常见的笔记本、不常见的服务器,大部分都遵守冯诺依曼体系结构。这套体系由三个核心组件构成:
- 输入单元:键盘、鼠标、扫描仪、手写板等
- 中央处理器(CPU):包含运算器和控制器
- 输出单元:显示器、打印机等
但真正理解冯诺依曼体系,关键不在于记住这些组件,而在于理解它们之间的数据流动规则:
所有设备都只能直接和内存打交道。
这意味着什么?CPU 不能直接从硬盘读取数据,硬盘也不能直接把数据送给显示器。输入设备把数据写入内存,CPU 从内存读取数据进行运算,运算结果再写回内存,最后由输出设备从内存取走数据展示。
图片如下图所示:
举个例子,当你打开 QQ 给朋友发消息,数据的完整流动路径是:键盘输入 → 写入内存 → CPU 处理编码和网络协议 → 结果写回内存 → 网卡从内存读取数据并发送。对方收到消息时,网卡把数据写入内存 → CPU 解析 → 结果写入内存 → 显卡从内存读取并渲染到屏幕上。整个过程中,没有任何一个设备绕过内存直接交互。
这个"以内存为中心"的设计,直接影响了后续操作系统对进程的管理方式——进程的代码和数据必须先加载到内存,CPU 才能执行。
2.操作系统:纯正的"搞管理"的软件
2.1 操作系统在做什么
操作系统在整个计算机软硬件架构中的定位很明确:对下管理所有软硬件资源,对上为应用程序提供良好的执行环境。
操作系统的层状结构如下图所示:
从组成上看,操作系统包括:
- 内核:进程管理、内存管理、文件管理、驱动管理
- 其他程序:函数库、shell 程序等
2.2 如何理解"管理"
实际上管理就是和被管理对象的数据打交道,只要有了被管理对象的相关数据,就可以对被管理对象进行管理
操作系统最核心的功能就是"管理"。那到底什么是管理?可以用一个生活中的例子来理解——学校里的管理结构:校长 → 辅导员 → 学生。
校长不直接接触学生,而是通过辅导员了解学生情况、下达指令。这背后其实是两个步骤:
- 描述被管理对象:用
struct结构体把对象的各种属性记录下来 - 组织被管理对象:用链表或其他高效数据结构把这些结构体串联起来
情况大概就是如下图所示:
先通过struct描述这个对象的各种属性,然后通过链表把这些 struct 全部连接起来,于是就做到了对对象的统一描述以及组织,于是我们成功的管理了这些对象,这个"先描述,再组织"的思路贯穿了整个 Linux 内核设计。进程管理如此,文件系统如此,内存管理,硬件管理也是如此。这其实也是为什么 C++ 主要是在学类以及 STL——因为类就是用来描述对象的工具,而 STL 的数据结构则负责把这些对象组织起来。
2.3 系统调用与库函数
操作系统对外表现为一个整体,但它会暴露部分接口供上层使用,这些接口就是系统调用。系统调用功能比较基础,使用门槛较高,所以开发者会对系统调用进行封装,形成库(比如 glibc),方便更上层的开发者使用。
比如 printf 底层调用了 write 系统调用,malloc 底层调用了 brk 或 mmap。库函数是系统调用的封装,但不是所有库函数都直接对应一个系统调用——有些库函数可能组合了多个系统调用,有些可能完全不涉及系统调用(如 strlen)。
3.进程本质:task_struct 结构体深度解析
3.1 进程是什么
课本上说进程是"程序的一个执行实例",内核观点认为进程是"担当分配系统资源的实体"。但更准确的说法是:
进程 = 内核数据结构(task_struct) + 程序代码和数据
程序躺在硬盘上的时候,它只是一堆二进制指令。当它被加载到内存、操作系统为它创建了一个 task_struct 结构体(用于记录进程的各种属性,其中包括指向代码和数据的指针等信息),它才真正成为一个"进程"。其中 PCB(task_struct)不仅通过指针引用了自己的代码和数据,还通过链表指针指向其他的 task_struct,
所以说我们平时在计算机上运行的任何指令以及程序,实际上都是进程,包括在打开计算机时启动的应用程序 Bash,实际上也是一个进程。
3.2 PCB 与 task_struct
进程的信息被放在一个叫做**进程控制块(PCB, Process Control Block)**的数据结构中,可以理解为进程属性的集合。在 Linux 中,PCB 的具体实现就是 task_struct。
task_struct 是 Linux 内核的一种数据结构类型,它被装载到 RAM(内存)里,包含着进程的全部信息。所有运行在系统里的进程都以 task_struct 双链表的形式存在内核中。
3.3 task_struct 里到底装了什么
task_struct 的内容可以分为以下几类:
| 分类 | 说明 | 为什么需要 |
|---|---|---|
| 标识符 | 描述本进程的唯一标识符(PID),用来区别其他进程 | 进程调度、信号发送都需要唯一标识 |
| 状态 | 任务状态、退出代码、退出信号等 | 内核需要知道进程当前能否被调度 |
| 优先级 | 相对于其他进程的优先级 | 决定 CPU 时间分配的先后顺序 |
| 程序计数器 | 即将被执行的下一条指令的地址 | 进程被重新调度时从哪里继续执行 |
| 内存指针 | 程序代码和数据的指针,以及与其他进程共享的内存块指针 | 定位进程的地址空间 |
| 上下文数据 | 进程执行时处理器寄存器中的数据 | 进程切换时保存和恢复现场 |
| I/O 状态信息 | 分配给进程的 I/O 设备和被进程使用的文件列表 | 资源管理和回收 |
| 记账信息 | 处理器时间总和、时钟数总和、时间限制等 | 系统监控和资源计费 |
其中,上下文数据是最容易被忽略但最关键的部分。当进程被从 CPU 上切换下来时,它当前在寄存器中的所有数据(通用寄存器、程序计数器、栈指针等)都必须保存到 task_struct 中。等这个进程再次被调度时,内核会把这些数据恢复到寄存器中,进程就能从上次暂停的地方继续执行,仿佛从未被打断过。
3.4 查看进程的方式
查看进程可以通过ps axj查看所有正在运行的进程,如果想查看自己所写的对应文件的进程可以通过ps axj|grep 文件名称/进程pid
进程的信息可以通过 /proc 系统文件夹查看。/proc 是一个虚拟文件系统,它的内容不在硬盘上,而是内核动态生成的。要获取 PID 为 1 的进程信息,查看 /proc/1 这个文件夹即可,这个文件夹下有所有正在运行的进程所对应的与pid同名的文件夹,当查看这个与进程PID同名的文件夹的时候,有两个属性分别是exe以及下图所示:

其中cwd记录的是当前的工作目录,exe 记录的是当前运行文件所在的目录,于是便可以解释为什么当我们创建文件的时候,只需要输入文件名,操作系统就可以知道我们应该在哪个文件夹下创建,实际上就是把创建文件的进程(比如说touch)中的工作目录与文件名进行拼接,于是就知道了该在哪个目录下创建文件
更常用的方式是 top 和 ps 命令:
# 查看所有进程的详细信息
tys@localhost:~$ ps aux
# 查看进程的父子关系和进程组信息
tys@localhost:~$ ps axj
# 实时监控进程状态
tys@localhost:~$ top
3.5通过系统调用获取进程标识符
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("pid: %d\n", getpid()); // 获取当前进程ID
printf("ppid: %d\n", getppid()); // 获取父进程ID
return 0;
}
getpid() 和 getppid() 都是系统调用,它们内部会读取当前进程的 task_struct 中的 PID 和 PPID 字段。每个进程都有一个父进程——除了 PID 为 1 的 init/systemd 进程,它是所有进程的祖先。
4.fork 的秘密:写时拷贝与双返回值
4.1 fork 基本用法
fork 是创建进程的唯一方式(除了内核初始化阶段)。它的行为看起来很反直觉:一次调用(由父进程发起),两次返回:
- 向父进程返回子进程的 PID
- 向子进程返回 0
如下图所示,当成功创建子进程之后,程序就会分两个执行流分别执行接下来的代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int 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;
}
实际上,父进程在创建子进程的时候,就是为子进程创建一个新的 PCB,即 task_struct,在默认情况下(未发生修改数据的时候),父进程和子进程共享父进程的代码和数据,于是便引起了下面三个问题:
1.为什么 fork 有两个返回值?
fork 之后,内核中实际上存在两个进程——父进程和子进程。它们共享同一段代码,但 fork 的返回值不同:
- 对父进程返回子进程的 PID(一个正数)
- 对子进程返回 0
这个设计很巧妙:父进程可能有很多子进程,它需要知道具体是哪个子进程被创建了,所以返回子进程 PID;而子进程只有一个父进程,它可以通过 getppid() 获取父进程 PID,所以返回 0 只是为了区分自己。
2.为什么一个函数会返回两次?
因为当 fork 即将返回的时候,fork 函数的主体逻辑已经执行完毕,子进程已经被创建了,此时系统中已经同时存在了父进程和子进程,所以 fork 函数会分别向父进程和子进程返回值。
3.为什么一个变量同时大于0又同时等于0?
即上面代码中的 ret 变量,实际上是因为子进程默认是和父进程共享同一份代码和数据,但是当子进程或者父进程中有一个进程试图修改共享的数据时,此时就会触发操作系统的写时拷贝机制,操作系统会单独拷贝一份数据和代码给另一个进程,这样子,这个进程就可以修改这个变量和代码,而不影响到另一个进程的代码和数据,而 fork 函数的返回值其实就是一种对变量的改变,如下图所示
代码运行如下图:
4.2 写时拷贝(Copy-On-Write)
fork 之后,父子进程代码共享,数据各自开辟空间。但这里有一个性能优化:数据并不是在 fork 的瞬间就完整拷贝一份,而是采用写时拷贝(COW, Copy-On-Write)机制。
fork 时,内核只是把父进程的页表项复制给子进程,并将所有页表项标记为只读。父子进程读取同一份数据时完全没有问题。但当其中一方尝试写入时,MMU 会触发缺页异常,内核这时才真正分配新的物理页,把数据拷贝过去,然后修改页表映射。
这个机制的好处是:如果子进程 fork 之后立刻 exec 加载新程序(这是最常见的情况),那么之前那些根本没被写入的数据页就不需要拷贝了,节省了大量内存和时间。
5.进程状态机:从 R 到 Z 的完整生命周期
5.1 Linux 内核中的进程状态定义
在 Linux 内核源代码中,进程状态定义如下:
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 */
};
注意这些状态值不是连续的整数,而是 2 的幂次方——这是因为它们被设计成位标志,可以组合使用。

5.2 操作系统各状态详解
- R(Running):并不意味着进程一定正在 CPU 上运行,它表明进程要么在运行中,要么在运行队列里等待被调度
- S(Sleeping):进程在等待某个事件完成,也叫可中断睡眠。大多数处于 S 状态的进程都是在等待 I/O 或信号
- D(Disk Sleep):不可中断睡眠状态,进程通常在等待 IO 结束。这个状态下连
kill -9都无法终止进程 - T(Stopped):通过发送
SIGSTOP信号暂停进程,可以用SIGCONT信号恢复 - X(Dead):死亡状态,只是一个过渡状态,你不会在任务列表里看到它
- Z(Zombie):僵尸状态,接下来重点讨论
这里主要详细讲述三种运行状态,分别为运行、阻塞、挂起:
1.运行状态:在现代计算机中,由于CPU执行的速度极快,所以运行状态就是只要在 CPU 的调度队列(runqueue)中就可以称作为运行状态(这里的调度队列指的是每个 CPU 中都有一个进程的调度队列,CPU 会根据调度队列执行调度算法,从而执行相应的进程)示意图如下:
2.阻塞状态:阻塞状态指的是当进程需要用到某一个硬件或者是资源的时候,而这个资源或者是硬件没有就绪的话,操作系统就会把这个进程列入到对应的资源或硬件的等待队列中(一般情况下讲,每个资源和硬件都有一个属于自己的等待队列),直到这个资源或者是硬件(硬件也遵循,先描述,再组织)就绪的时候,才把它重新链接到到 CPU 的调度队列中执行。
3.挂起状态:所谓的挂起状态,就是当操作系统中的内存资源严重不足时,操作系统会把加载到内存中且在等待队列中的进程对应的数据和代码交换到磁盘中的 swap 分区中,直到这个进程重新回到 CPU 的调度队列时,才会把 swap 分区中属于它的代码和数据重新加载到内存中,当内存资源极度匮乏的时候,操作系统甚至可能把在等待队列中的进程的 PCB 也短暂地交换到 swap 分区.事实上,挂起状态是操作系统应对内存资源不足而采取的一种手段.
在这里,我们还需要学习一个知识点:那就是为什么在操作系统中同一份数据却可以属于不同的链表结构?
前面的学习我们可以知道,task_struct 既有属于自己的链表结构,把每一个 task_struct 都可以链起来,而 task_struct 又属于 CPU 的调度队列的链表结构,所以这是如何做到的?
实际上是操作系统在设计的时候,在 task_struct 里面设计了很多这种只有 next 和 prev 的结构的指针,而我们只需要通过如下的取地址操作,就可以得到每一个 task_struct 的地址:
说白了就是先取出对应结构体指针在结构体中和 task_struct 的首地址的偏移量,由于我们知道每一个结构体指针的地址,只需要用结构体指针减去对应的偏移量,就能得到每一个task_struct的地址(即 task_struct 第一个元素所对应的地址).于是我们只需要设计多个这样的只含 next 和 prev 的结构体指针,就可以将同一段数据列入到不同的数据结构中
5.3 linux系统中的具体状态
实际上linux系统中的进程状态就是定义的宏,事实上就是一个整数:
1.R(running)状态,所谓的R状态很通俗易懂,就是运行状态。
2.S (sleeping)状态,S 状态对应的是可中断睡眠状态,属于阻塞状态的一种,但 S 状态其实对应的是浅度睡眠,在这个状态下,进程是要响应操作系统的指令的,就是说,当操作系统要杀掉这个进程的时候,这个进程必须得服从
3.D(disk sleep)也是阻塞状态的一种,但是D状态所对应的是深度睡眠,它可以不响应操作系统的指令,这种状态一般是用在高 IO 的时候,以避免操作系统因为内存资源不足而误把这个进程给杀掉,导致数据丢失。
4.T(stopped)和t(tracing stop)这两个状态都是暂停状态,t 对应的是追溯暂停,一般是在 Debug 模式下打断点的时候会出现的状态;T对应的状态是:当操作系统认为这个进程出现问题或越界时,会暂停该进程,然后交给用户处理
5.X(dead):死亡状态对应的就是进程被杀掉
6.Z(zombie)僵尸状态,指的是当一个子进程退出,而父进程还没有接收子进程的返回信息时,此时子进程会把自己的代码和数据释放,而不会释放自己的 task_struct,它需要把退出时的信息保存着,等待父进程来获取结束信息,然后释放对应子进程的PCB,如果父进程一直不回收的话,子进程就会一直保持僵尸状态,而如果长期占着内存不释放,此时可能就会造成内存泄漏问题。
5.3.1僵尸进程:退出了但没完全退出
当进程退出时,它的代码和数据会被释放,但它的 task_struct 不会立即被销毁。进程退出状态、退出码等信息需要保留下来,等父进程通过 wait() 系统调用来读取。如果父进程一直不调用 wait(),子进程的 task_struct 就一直存在——这就是僵尸进程。
#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) {
// 父进程:睡30秒,不调用wait
printf("parent[%d] is sleeping...\n", getpid());
sleep(30);
}
else {
// 子进程:5秒后退出,变成僵尸
printf("child[%d] is begin Z...\n", getpid());
sleep(5);
exit(EXIT_SUCCESS);
}
return 0;
}
子进程退出后的 25 秒内,用 ps 命令可以看到子进程状态为 Z。
在这里插入图片描述
僵尸进程的危害是内存泄漏。虽然进程的代码和数据已经释放,但 task_struct 这个结构体本身占用内存。如果父进程不断创建子进程但不回收,内核中就会堆积大量 task_struct,最终耗尽内存。
5.4 孤儿进程:被 systemd领养
如果父进程先于子进程退出,子进程就变成"孤儿进程"。孤儿进程会被 PID 为 1 的 init/systemd 进程领养,由 init 负责回收。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
else if (id == 0) {
// 子进程:睡10秒
printf("I am child, pid : %d\n", getpid());
sleep(10);
}
else {
// 父进程:3秒后退出
printf("I am parent, pid: %d\n", getpid());
sleep(3);
exit(0);
}
return 0;
}
父进程退出后,子进程的 PPID 会变成 1。孤儿进程不会造成危害(不会造成内存泄漏问题),因为 init 会自动回收它们,在被领养之后,子进程会变成后台进程。
如下图所示:
6.进程优先级
6.1 与优先级有关的几个小问题
1.什么是进程优先级?
进程优先级指的是进程获取 CPU 资源的先后顺序
2.为什么要有进程优先级?
因为CPU资源是有限的,为了争夺CPU资源,所以必须得有优先级
3.进程优先级的规则是什么?
1.进程优先级本质上是一组数值,用于表示进程被调度的先后顺序
2.数字越小代表进程优先级越高
3.当代操作系统普遍采用基于时间分片的调度方式,为了兼顾公平性,进程的优先级可能动态变化,但不会频繁大幅波动
查询进程优先级的指令是:ps -al
当我们查询进程优先级的时候,会有几个我们不认识的东西比如说uid:
实际上,UID就是操作系统为每一个用户分配的一个独特的ID,拥有这个ID,操作系统就可以区分不同的用户.UID 也是权限很重要的一部分。当要打开或者读一个文件的时候,操作系统就会将该文件所有者的 UID 与打开这个文件的用户的 UID 进行对比,看看是否属于这个用户。
6.2 PRI 与 NI
Linux 中进程优先级由两个值共同决定:
- PRI(Priority):进程的优先级,值越小越早被执行,默认值一般是 80
- NI(Nice):优先级的修正数值,取值范围 -20 到 19,共 40 个级别
两者的关系是:PRI(new) = PRI(默认值) + NI
注意,不管在哪种情况下,每一次计算 PRI 都是用 PRI 的默认值加 NI
所以,NI 为负值时,PRI 变小,优先级变高,进程更快被执行。调整进程优先级本质就是调整 NI 值。
# 用 top 命令修改进程的 nice 值
tys@localhost:~$ top
# 进入 top 后按 r → 输入进程 PID → 输入 nice 值
# 或用 renice 命令
tys@localhost:~$ renice -5 -p 1234
需要注意的是,普通用户只能增大 NI 值(降低优先级),只有 root 用户才能减小 NI 值(提高优先级)。
6.4 竞争、独立、并行、并发
| 概念 | 含义 |
|---|---|
| 竞争性 | 系统进程众多,CPU 资源稀缺,进程之间天然具有竞争属性 |
| 独立性 | 多进程运行期间互不干扰,各自独享资源 |
| 并行 | 多个进程在多个 CPU 上同时运行 |
| 并发 | 多个进程在一个 CPU 上通过进程切换,在一段时间内都得以推进(基于时间片) |
7.1 进程切换与 O(1) 调度器:上下文切换
7.1与进程切换有关的几个问题
1.死循环会一直占用CPU吗?
实际上,死循环并不会一直占用 CPU,因为如果死循环一直占用 CPU,我们就无法杀掉这个死循环进程。实际上,CPU是基于时间片对不同的进程进行运行调度
2.寄存器是什么?
如上图所示,寄存器就是用于保存 CPU 在运行中进程的临时数据的地方。
7.2进程是如何切换的?
实际上,进程的切换就是对进程上下文数据的保存以及恢复,当一个进程在自己的时间片完了之后,此时,CPU就会停止对这个进程的运行。于是操作系统就会将这个进程的临时数据保存起来,然后把当前这个进程拿下 CPU。
然后重新加入到 CPU 的调度队列中,当CPU再次运行到这个进程的时候,操作系统就会把之前保存的进程所对应的每个寄存器中的数据,重新恢复到寄存器中,然后接着运行这个进程,这么一次保存与一次恢复就称为一次进程的切换。所以进程切换最核心的内容,就是对进程上下文数据的保存以及恢复
下面是比较系统的解释:
CPU 上下文切换的实际含义是任务切换。当内核决定运行另一个任务时,它需要:
- 保存当前运行任务的 CPU 寄存器状态到该任务的栈中
- 从下一个要运行的任务的栈中恢复其寄存器状态
- 开始执行下一个任务
这个过程就是 context switch。当代计算机都是分时操作系统,每个进程都有它合适的时间片。时间片到达,进程就被操作系统从 CPU 中剥离下来。
于是这又引发了另一个问题,那就是这些寄存器中临时数据都是保存在哪里的呢?
实际上,当我们查看源码的时候可以发现:
这些临时数据都是保存在一个名叫tss的结构体中的。
7.3Linux 2.6 内核 O(1) 调度器
Linux 2.6 内核引入了 O(1) 调度器,它的核心思想是:查找最合适调度的进程的时间复杂度是常数,不随进程增多而增加。
7.3.1 数据结构
每个 CPU 拥有一个 runqueue(运行队列),其核心数据结构如下:
struct rq {
spinlock_t lock;
unsigned long nr_running; // 运行状态进程总数
unsigned long long nr_switches; // 总切换次数
struct task_struct *curr, *idle; // 当前运行进程和idle进程
// 两个优先级数组:活动和过期
struct prio_array *active, *expired, arrays[2];
// ...
};
struct prio_array {
unsigned int nr_active; // 活动进程数
DECLARE_BITMAP(bitmap, MAX_PRIO+1); // 位图:标记哪些队列非空
struct list_head queue[MAX_PRIO]; // 140个进程队列
};
7.3.2 工作原理

优先级分为两类:
- 普通优先级(分时优先级):100~139(对应 nice 值 -20~19)
- 实时优先级:0~99
活动队列中,时间片还没结束的所有进程按优先级放在 queue[140] 中。数组下标就是优先级,相同优先级的进程按 FIFO 规则排队。即操作系统会根据进程的优先级,把进程链入相应的链表位置。其中 queue 数组是一个指针数组,操作系统会把优先级相同的进程链到同一 queue 数组元素开头的链表中。
bitmap相当于是一个位图,以比特位计算,一共有160位,其中前140位则表示的是当前queue数组指针中,是否有链入进程,如果没有,则标记为0,有则标记为1。
选择下一个运行进程的过程:
- 从 bitmap 中找到第一个非空队列(位运算,O(1))
- 取该队列的第一个进程运行
为了保证 CPU 执行进程的相对公平性,Linux 操作系统设计了两个对应的相同的结构体(array[2]),一个设置为 active,一个设置为 expired,当操作系统开始调度进程的时候,会首先默认从 active 开始调度,然后当一个进程的时间片用完的时候操作系统就会把这个进程从 CPU 上拿下来,然后链入到 expired 所指向的调度队列中,通过这样的算法,从而保证了当运行队列中的进程全部运行完的时候,所有进程都可以被运行一遍,在一定程度上保证了进程运行的公平性.
运行进程的过程就是首先从 bitmap 中查找有进程的对应queue位置,
时间片耗尽的进程被移到过期队列。当活动队列全部处理完毕后,交换 active 和 expired 指针,过期队列变成新的活动队列——这个过程也是 O(1)。
bitmap 的设计是 O(1) 算法的关键:140 个优先级对应 140 个进程队列,用 5 个 32 位整数(共 160 bit)就能表示所有队列是否为空。查找最高优先级的非空队列只需几次位运算,与进程数量完全无关。
8.环境变量:进程的运行时配置
8.1基本概念
环境变量是操作系统中用来指定运行环境的一些参数。比如我们在编写 C/C++ 代码时,链接器不知道动态静态库在哪里,但照样能链接成功——因为有 PATH、LD_LIBRARY_PATH 等环境变量帮助查找。
常见环境变量:
| 变量 | 作用 |
|---|---|
PATH |
指定命令的搜索路径 |
HOME |
指定用户的主工作目录 |
SHELL |
当前 Shell,通常为 /bin/bash |
8.2 认识命令行参数
首先我们先尝试运行以下代码:
#include<stdio.h>
#include<string.h>
int main(int argc,char *argv[])
{
if(argc!=2)
{
printf("please cin myprocess -[a|b|c]\n");
return 0;
}
if(strcmp(argv[1],"-a")==0)
printf("这次输入 myprocess %s\n",argv[1]);
else if(strcmp(argv[1],"-b")==0)
printf("这次输入 myprocess %s\n",argv[1]);
else if(strcmp(argv[1],"-c")==0)
printf("这次输入 myprocess %s\n",argv[1]);
else
printf("please cin myprocess -[a|b|c]\n");
}
于是可以得到以下结果:
所以可以看出当程序开始运行的时候,bash 会把我们输入的指令根据空格切分成一块一块的,argv(也称为命令行参数表)是一个字符串指针数组,数组中的每一个元素指向一个被切割下来的字符串,而 argc 则表示的是这个字符串数组中的元素个数。这样做的目的实际上是为了让指令能够支持更多的子功能,即实现选项功能,例如 ls -a -l 这种指令的实现实际上就是依靠命令行参数表。这个切分指令的过程实际上是由 bash 完成的,这里不做详细说明,以后会详细讲解
8.3认识环境变量
当我们的电脑在运行一个指令或者程序的时候,计算机必须先知道这个指令的位置才可以运行。比如我们运行自己的程序的时候,是向如下运行:

但是当我们运行系统自带的指令的时候(比如说ls,pwd,cd …),只需要输入指令它就可以完成运行,这是为什么呢?
实际上,这是通过环境变量实现的,我们可以输入如下指令echo $PATH,于是我们便可以查看PATH环境变量对应的具体内容如下:

我们不妨再查看一下这些系统指令的位置:

实际上,每次当我们运行一个指令的时候,bash 就会获取我们的指令,然后将这个指令字符串与 PATH 中的各个路径进行拼接,然后在其对应的不同路径下进行查找,如果未查找成功则会报错,所以PATH环境变量中对应的路径就是系统对应的默认路径.
8.4关于环境变量的几个问题
有了以上的认识,我们不禁会思考以下两个问题:
1.从存储的角度,如何理解环境变量?
实际上,环境变量就是 bash 中维护的一张表,Bash 创建以后,bash 中就会维护两张表,一张是命令行参数表,一张就是环境变量参数表,所以,当我们输入指令env的时候,就会打印系统内此时的所有环境变量,实际上这个过程就是 bash 把自己维护的那一张环境变量表打印出来。
2.那么 bash 是如何获取这些环境变量的呢,这些环境变量从哪里来?
实际上,这些环境变量都是存在于系统的配置文件中的,当我们在家目录下,我们可以看到以下两个隐藏文件:

这两个文件中就是相关对应的环境变量文件,每一次启动机器的时候,Bash 就会从这些文件中读取相关的环境变量,并形成一张 char *类型的环境变量表。
8.5 认识更多环境变量
从上面我们认识了PATH环境变量,接下来我们认识更多的环境变量如下图:
- HOSTNAME
含义:当前 Linux 系统的主机名。
作用:用于在网络或本地标识这台机器。你在终端提示符 [whb@bite-alicloud ~]$ 中看到的 bite-alicloud 就来源于此。
2. SHELL
含义:当前用户默认使用的 Shell 解释器路径。
作用:当你登录系统或执行脚本时,系统会调用这个程序来解析命令。这里表示使用的是 Bash(Bourne Again SHell),这是 Linux 最主流的 Shell。
3. HOME
含义:当前用户的家目录(主目录)绝对路径。
作用:
登录后默认进入的目录。
波浪号 ~ 就是它的简写(例如 cd ~ 等价于 cd /home/whb)。
用户级的配置文件(如 .bashrc、.vimrc、.ssh/)通常存放在这里。
4.OLDPWD
含义:Old Present Working Directory,即“上一次所在的工作目录”。
作用:
当你使用 cd 命令切换目录时,Shell 会自动把你切换前所在的目录路径保存到 OLDPWD 变量中。
它最主要的用途是配合 cd - 命令使用。执行 cd - 可以快速跳回上一个目录(相当于在两个目录之间来回切换)。
5.LOGNAME
含义:Login Name,即当前用户的登录用户名。
作用:
它记录了你最初登录系统时使用的用户名。在这里,它的值是 whb,说明你是以 whb 这个用户身份登录的。
许多程序(如邮件客户端、日志记录工具、who 命令等)会读取这个变量来识别“你是谁”。
8.6 获取环境变量的方式
有3种方式:
1.使用echo $(环境变量),用于打印某个具体的环境变量,或者使用env获取所有环境变量
2.可以在程序中使用env字符串数组用于打印所有环境变量(现在我们完整地清楚了,main 函数的参数列表最多可以有 3 个,其中有一个环境变量参数表,有一个命令行参数表,这些都是由 bash 解释以后,然后传参给 main 函数的。)
3.可以使用全局指针environ 这个全局环境变量指针打印所有环境变量,如下图所示:
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以 \0 结尾的环境字符串。
// 方式一:通过 main 函数第三个参数获取
#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;
}
// 方式二:通过全局变量 environ 获取
#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;
}
// 方式三:通过系统调用获取特定环境变量
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
8.7 环境变量的继承性
通过以上的学习我们知道环境变量具有继承性,父进程可以把自己的环境变量继承给子进程
环境变量具有全局属性,可以被子进程继承。这意味着在 shell 中用 export 导出的环境变量,在之后启动的所有程序中都能访问到。
# 导出环境变量
tys@localhost:~$ export MYENV="hello world"
# 之后运行的程序可以通过 getenv("MYENV") 获取到 "hello world"
tys@localhost:~$ ./my_program
hello world
但如果没有 export,只是 MYENV="hello world",这个变量只是 shell 的局部变量,不会传递给子进程。也就是说,Shell 的本地变量是不会传递给子进程的
当使用 export 的时候,我们就可以将自己设定的环境变量导入到bash中,但是由前面的学习我们知道,进程之间是独立的,子进程可以继承父进程的环境变量,当我们使用 export 指令的时候,按理来说,export 作为一个子进程,是无法将环境变量导入到 bash 中的,所以这里引出了一个新的知识点。
有一种指令叫做内建命令,即 bash 会自己执行这个指令,而不是创建子进程去执行这个指令。所以 export 就是一个内建命令,相当于 bash 自己去执行 export,而不是创建子进程让子进程去执行,所以 bash 就成功将一个环境变量导入到自己的环境变量表中
相关命令:
| 命令 | 功能 |
|---|---|
echo $NAME |
显示某个环境变量值 |
export |
设置新的环境变量 |
env |
显示所有环境变量 |
unset |
清除环境变量 |
set |
显示本地定义的 shell 变量和环境变量 |
9.虚拟地址空间:进程的内存幻觉
9.1一个颠覆认知的实验
先看一段代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 0;
}
else if (id == 0) {
// 子进程修改全局变量
g_val = 100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
else {
// 父进程等子进程先执行
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
输出结果:
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
父子进程输出同一个变量的地址完全一样,但值却不同。
如果这个地址是物理地址,这是不可能的——同一个物理地址不可能同时存储两个不同的值。所以结论是:我们在 C/C++ 中看到的所有地址都是虚拟地址,物理地址由操作系统统一管理,用户一概看不到。
9.2虚拟地址空间是什么?
由之前的学习我们可以知道,进程具有独立性,加载到内存的代码和数据都是独立的,由以前的C语言和C++的学习,我们也经常可以看到一张图:
每一个进程对应的都是这么一张图,所以对于每一个进程来说,自己都独占所有的内存,这实际上就是操作系统为每一个进程分配的虚拟地址空间。虚拟地址空间在内核中对应一个结构体,名为 struct mm_struct,操作系统会为每一个进程都分配一个 struct mm_struct,然后操作系统再通过页表把虚拟地址和物理地址对应起来,即形成映射关系。
当然,既然是一个结构体,操作系统就要对每一个虚拟地址空间进行管理,在虚拟地址空间管理上,操作系统依然使用的是先描述再组织,先描述 struct mm_struct 结构体的结构,然后用链表将其关联起来进行管理。
接下来我们可以通过一个例子,更好的理解页表的映射:
如上图所示,执行的代码实际上是父进程创建了一个子进程,然后子进程试图修改当前代码中的一个全局变量,此时就会发生写时拷贝,由之前的学习基础,我们知道子进程会继承父进程的大多数属性,只会把个别属性进行修改,所以同理,子进程的页表也会继承于父进程的页表,所以默认状态下,子进程的页表映射关系与父进程的页表映射关系是相同的。但是当子进程试图修改某一个变量的时候,操作系统就会介入,它会在物理内存空间上再开辟一段空间,然后把它想修改的那个值拷贝一份到新开辟的物理空间中,然后修改页表的映射关系,即保持虚拟内存空间不变,但是修改虚拟内存空间所对应的物理空间,这也是上面的实验中,为什么一个相同的地址可以对应两个不同的值。
9.3 进程地址空间布局
在 32 位平台上,进程的地址空间布局如下:
用一段代码验证各区域的地址分布:
#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"; // 字符串常量 -> 只读数据段
static int test = 10; // 静态变量 -> data段
char *heap_mem = (char*)malloc(10);
printf("code addr: %p\n", main); // 代码段
printf("init global addr: %p\n", &g_val); // data段
printf("uninit global addr: %p\n", &g_unval); // BSS段
printf("heap addr: %p\n", heap_mem); // 堆
printf("stack addr: %p\n", &heap_mem); // 栈
printf("read only string addr: %p\n", str); // 只读段
return 0;
}
运行后可以看到:代码段地址最低,然后是 data 段、BSS 段,堆地址高于 BSS 段且向上增长,栈地址在高位且向下增长,只读字符串地址靠近代码段。
9.4 mm_struct:进程地址空间的内核描述
知道了虚拟地址空间的大概分布,那么它的分布是如何做到的呢?即虚拟地址空间中是如何进行空间划分的?
在内核中,每个进程的地址空间由 mm_struct 结构体描述,它存在于 task_struct 中:
struct task_struct {
/* ... */
struct mm_struct *mm; // 指向进程的虚拟地址空间
/* ... */
};
mm_struct 中记录了各段的起止地址:
struct mm_struct {
struct vm_area_struct *mmap; // 指向虚拟区间(VMA)链表
struct rb_root mm_rb; // 红黑树根节点
unsigned long task_size; // 虚拟地址空间大小
// 各段的起始和结束地址
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 数据段
unsigned long start_brk, brk; // 堆
unsigned long start_stack; // 栈
unsigned long arg_start, arg_end; // 命令行参数
unsigned long env_start, env_end; // 环境变量
/* ... */
};
这其实就是虚拟地址空间的划分,通过 start 和 end 对不同的地址空间进行划分,由以上我们也可以知道,虚拟地址空间的不同区域大小是可以进行调整的,一般调整就是根据实际进程中各个数据区域对应的大小进行调整的。实际上初始化虚拟地址空间的时候,也是根据进程各个数据区域对应的大小进行初始化的,其过程就是先创建页表,然后把进程中的数据加载到物理内存中去,根据物理空间中各个数据区域的大小,对虚拟地址空间的大小进行调整,然后用页表将物理内存与虚拟地址空间映射起来。
9.5 vm_area_struct:虚拟内存区域
Linux 内核使用 vm_area_struct(VMA)来表示一个独立的虚拟内存区域。由于不同区域的属性不同(代码段只读、堆可读写、共享段可能有不同权限),一个进程使用多个 VMA 来分别描述:
struct vm_area_struct {
unsigned long vm_start; // 虚存区起始地址(单个VMA的开始)
unsigned long vm_end; // 虚存区结束地址(单个VMA的结束)
struct vm_area_struct *vm_next, *vm_prev; // 链表前后指针
struct rb_node vm_rb; // 红黑树节点
struct mm_struct *vm_mm; // 所属的 mm_struct
unsigned long vm_flags; // 标志位(读/写/执行等)
const struct vm_operations_struct *vm_ops; // VMA 对应的操作函数
struct file *vm_file; // 映射的文件
/* ... */
};
VMA 的组织方式有两种:
- 链表:当虚拟区较少时,由
mmap指针指向链表头,适合遍历 - 红黑树:当虚拟区较多时,由
mm_rb指向红黑树根,适合快速查找
他们的关系如下图所示
一个虚拟地址空间的区域可能由多个VMA组成,所以每一个VMA需要记录自己的开始与结束,然后mm_struct中需要记录每个区域具体空间的开始与结束
9.6 为什么需要虚拟地址空间
如果程序直接操作物理内存,会有三大问题:
1. 安全隐患:每个进程都能访问任意物理内存,一个恶意程序可以修改系统关键数据,直接瘫痪设备。
2. 地址不确定:程序每次加载到内存的位置取决于当时内存使用情况,无法确定。这意味着程序中的绝对地址每次运行都不同, relocation 的负担完全落在程序员身上。
3. 效率低下:物理内存不够时需要把整个进程拷到磁盘交换分区,时间太长。
虚拟地址空间配合页表机制,完美解决了这些问题:
-
有效防止非法访问:页表中不同的区域也有不同的读写执行权限,可以通过权限设置限制用户对物理内存的访问,比如说常量区就只有可读权限,当用户试图通过地址对常量区的数据进行修改时,操作系统会查询对应页表的权限,发现只有可读权限,于是拒绝执行,甚至直接杀死进程,导致程序崩溃。同理,野指针则是访问了页表上不存在的地址空间,所以操作系统直接拒绝执行,甚至导致程序崩溃
-
进程隔离:每个进程有自己的虚拟地址空间和页表,无法直接访问其他进程的物理内存。想通过地址空间和页表进行映射,必须在 OS 监管下进行,保护了物理内存中的合法数据。
-
解耦合:物理内存的分配和进程的管理彻底解耦。进程管理模块只管虚拟地址空间,内存管理模块只管物理页框。物理内存可以对未来的数据进行任意位置加载,通过页表映射到虚拟地址空间。
-
延迟分配:
malloc申请空间时其实只是在地址空间上做了记录,物理内存可以一个字节都不给(所以是先有了内核数据结构,再加载数据和代码)。当你真正访问那块内存时,MMU 触发缺页异常,内核才执行内存分配算法、构建页表映射——整个过程由操作系统自动完成,进程完全无感知。
这就是为什么 fork 之后父子进程的同一个变量地址相同但值不同——虚拟地址相同,但通过各自的页表映射到了不同的物理页。
总结
从冯诺依曼体系结构到虚拟地址空间,进程概念的每一层都有精密的设计:
- 冯诺依曼体系规定了以内存为中心的数据流动模式,是进程必须驻留内存的根本原因
- 操作系统通过"先描述再组织"的方式管理一切,
task_struct就是进程的"身份证" - 进程状态机的每个状态都有明确语义,僵尸进程的存在是为了传递退出信息,孤儿进程被 init 领养是系统的安全网
- O(1) 调度器用 bitmap + 双队列的设计,把进程调度的复杂度从 O(n) 降到了常数级
- 虚拟地址空间通过页表映射机制,在进程隔离、内存利用率和安全性之间取得了完美平衡
理解这些底层机制,才能在排查进程问题、优化程序性能时有的放矢,而不是停留在表面概念上。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)