Linux --进程概念
计算机硬件基石:冯·诺依曼体系结构
在理解进程之前,首先需要弄清楚程序是在什么样的硬件上运行的。
什么是冯·诺依曼体系?
我们日常使用的计算机——无论是笔记本电脑、台式机还是服务器——绝大多数都遵循冯·诺依曼体系结构。这套体系的核心思想可以概括为:程序和数据都存储在存储器中,计算机按照存储的程序逐条执行指令。
硬件组成
冯·诺依曼体系将计算机硬件划分为三大核心部件:
| 部件 | 功能 | 常见设备 |
|---|---|---|
| 输入单元 | 将外部数据送入计算机 | 键盘、鼠标、扫描仪、手写板 |
| 中央处理器(CPU) | 进行算术运算和逻辑控制 | 包含运算器 + 控制器 |
| 输出单元 | 将计算结果输出给用户 | 显示器、打印机 |
几个必须记住的关键规则
-
这里的"存储器"特指内存(而不是硬盘等外部存储)。
-
CPU能且只能对内存进行读写,无法直接访问外设(如键盘、显示器、硬盘)——这是在没有考虑缓存(Cache)的情况下。
-
所有外设(输入/输出设备)要传输数据,也只能通过内存来完成。也就是说,所有设备都只能直接和内存打交道。
举个生活化的例子
想象一下公司里的总裁办(CPU)和文件室(内存):
-
总裁办需要数据时,只能派人去文件室取,不能直接跑到各部门(外设)去拿。
-
各部门要汇报工作,也只能先把文件交到文件室,再由总裁办从文件室取阅。
-
文件室成为了整个公司信息流转的唯一枢纽。
为什么这样设计?
原因一:速度匹配问题
-
CPU 速度 ≈ 纳秒级(10⁻⁹秒)
-
内存速度 ≈ 纳秒级(10⁻⁹秒)— 慢10-100倍
-
硬盘速度 ≈ 毫秒级(10⁻³秒)— 慢百万倍
如果 CPU 直接访问硬盘,CPU 99.99%的时间都在等待,这是极大的浪费。内存作为"缓冲",让 CPU 与外设在速度上解耦。
原因二:统一管理
-
所有数据都通过内存中转,操作系统只需管理好内存这一个核心资源
-
降低了管理复杂度
原因三:程序的可移植性
-
程序只需要"以为"自己在操作内存
-
具体的硬件差异由操作系统和驱动程序屏蔽
数据流动的直观理解:QQ聊天场景
从你登录QQ开始,到给朋友发消息,数据是怎么流动的?
发送方(你):
-
键盘输入消息 → 数据进入内存
-
CPU从内存读取消息 → 进行编码、加密等处理 → 写回内存
-
网卡(输出设备)从内存读取数据 → 发送到网络
接收方(朋友):
-
网卡接收到数据 → 写入内存
-
CPU从内存读取 → 解码、解密 → 写回内存
-
显示器从内存读取 → 显示消息
发送文件也是同样的路径:文件从硬盘 → 内存 → CPU处理 → 内存 → 网卡 → 网络 → 对方网卡 → 内存 → CPU处理 → 内存 → 硬盘。
操作系统(OS):一切的管理者
什么是操作系统?
操作系统是一个基本的程序集合,它本质上是一个软件,但它的地位非常特殊——它是整个计算机系统中最核心的系统软件。
操作系统的构成:
-
内核(Kernel):包含进程管理、内存管理、文件管理、驱动管理
-
其他程序:函数库、Shell程序等
操作系统的两大设计目标
-
对上:为用户程序(应用程序)提供一个良好的执行环境
-
对下:与硬件交互,管理所有的软硬件资源
操作系统 = 一款"搞管理"的软件
操作系统的核心功能就是管理:
-
管理CPU(进程调度)
-
管理内存(内存分配与回收)
-
管理文件(文件系统)
-
管理设备(驱动程序)
如何理解"管理"?
管理的本质分两步:
-
描述被管理对象:用数据结构(如结构体)来描述一个对象的属性
-
组织被管理对象:用高效的数据结构(如链表)将这些对象组织起来
这也正是操作系统管理进程的核心思想:先把进程描述起来,再把进程组织起来!
系统调用与库函数
操作系统为了保护自己,不会把所有功能都暴露给用户程序。它只暴露一部分接口,供上层开发使用,这些接口就叫做系统调用。
用户程序
↓
库函数(封装)
↓
系统调用(OS提供的接口)
↓
操作系统内核
系统调用功能基础、使用门槛较高。于是开发者对部分系统调用进行封装,形成库函数(如C标准库),方便更上层的开发者使用。
进程(Process):程序的"活体"实例
进程是什么?
从不同角度理解进程:
| 角度 | 定义 |
|---|---|
| 课本定义 | 程序的一个执行实例,正在执行的程序 |
| 内核观点 | 担当分配系统资源(CPU时间、内存)的实体 |
| 现代观点 | 进程 = 内核数据结构 + 自己的程序代码和数据 |
程序是"死"的(躺在硬盘上的文件),进程是"活"的(程序被加载到内存中运行)。
PCB(进程控制块):进程的"身份证"
操作系统要管理进程,首先需要用数据结构来描述进程。在Linux中,这个结构体叫做 task_struct。
task_struct 被装载在RAM(内存)中,包含了进程的所有信息。它相当于进程的"身份证"或"档案"。
task_struct 包含哪些信息?
| 信息类别 | 说明 |
|---|---|
| 标识符(PID) | 每个进程唯一的ID,用于区分其他进程 |
| 状态 | 运行、睡眠、停止、僵尸等 |
| 优先级 | 相对于其他进程的优先级 |
| 程序计数器(PC) | 下一条即将执行的指令地址 |
| 内存指针 | 程序代码、数据、共享内存块的指针 |
| 上下文数据 | CPU寄存器的内容(用于进程切换时保存现场) |
| I/O状态信息 | 分配的I/O设备、文件列表 |
| 记账信息 | CPU使用时间、时钟数等 |
进程的组织方式
所有运行在系统中的进程,以 task_struct 双链表的形式存在于内核中。
┌──────┐ ┌──────┐ ┌──────┐
│进程A │←──→│进程B │←──→│进程C │
│PCB │ │PCB │ │PCB │
└──────┘ └──────┘ └──────┘
(双向循环链表)
查看进程的方法
方法1:通过 /proc 文件系统
ls /proc/1 # 查看PID为1的进程信息
cat /proc/1/status # 查看进程状态
方法2:使用 ps 命令
ps aux # 显示所有进程的详细信息
ps axj # 显示进程组、会话、父进程等信息
方法3:使用 top 命令
top # 动态显示进程资源占用
通过系统调用获取进程标识符
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
pid_t pid = getpid(); // 获取当前进程PID
pid_t ppid = getppid(); // 获取父进程PID
printf("PID: %d, PPID: %d\n", pid, ppid);
return 0;
}
fork():创建子进程
fork() 是Linux中创建进程的核心系统调用。它的特点是:
fork 有两个返回值!
#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, PID: %d, ret: %d\n", getpid(), ret);
}
else {
// 父进程
printf("I am father, PID: %d, ret: %d\n", getpid(), ret);
}
sleep(1);
return 0;
}
fork 的执行流程
父进程(PID = 100)
│
├── 执行 fork()
│
├── 内核创建子进程(几乎完全拷贝父进程)
│
├── 父进程返回子进程PID(ret > 0)
│
└── 子进程返回 0(ret == 0)
父子进程的关系
-
代码共享:父子进程共享代码段
-
数据独立:数据各自开辟空间,互不影响(采用写时拷贝技术)
-
执行顺序不确定:谁先运行由调度器决定
进程状态:进程的"生命周期"
Linux 内核中的进程状态
在内核源码中,进程状态定义在 task_state_array 中:
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 - 僵尸态 */
};
各状态详解
R(运行状态)
注意:R状态并不意味着进程一定正在运行,它表示进程要么正在CPU上运行,要么在运行队列中排队等待CPU。
运行队列 → [进程A] [进程B] [进程C] ...
↑
进程A正在CPU上运行,B和C在排队
S(可中断睡眠状态)
进程在等待某个事件完成(如等待用户输入、等待网络数据)。这种睡眠可以被信号中断。
// 例如:scanf() 等待用户输入时,进程处于 S 状态
scanf("%d", &num); // 进程阻塞在这里,进入S状态
D(不可中断睡眠状态)
也叫"磁盘休眠状态"。进程在等待I/O操作完成(如读写磁盘),在此期间不能被信号中断。
为什么需要D状态?如果在写磁盘过程中被中断,可能导致数据损坏或不一致。
T(停止状态)
进程被暂停执行(通过发送 SIGSTOP 信号),可以通过 SIGCONT 信号让其继续运行。
kill -SIGSTOP <PID> # 暂停进程
kill -SIGCONT <PID> # 继续运行进程
X(死亡状态)
进程结束时的短暂状态,只是一个返回状态,我们不会在任务列表中看到它。
Z(僵尸状态)
4.3 僵尸进程(Zombie Process)
什么是僵尸进程?
当一个进程退出后,父进程没有读取子进程的退出状态代码时,子进程就会进入僵尸状态。
进程退出 → 需要告知父进程"任务完成情况" → 父进程不读取 → 进入Z状态
僵尸进程的特征
-
进程已经终止运行(不再消耗CPU)
-
但仍然在进程表中保留一个条目(PCB)
-
一直等待父进程来读取它的退出码
为什么会存在僵尸进程?
进程的退出状态必须被维持,因为父进程需要知道"交给子进程的任务办得怎么样了":
-
正常完成?→ 退出码 0
-
出错了?→ 退出码 > 0(表示具体错误)
僵尸进程的危害
退出状态信息保存在 task_struct (PCB) 中
↓
Z状态一直不退出 → PCB一直保留
↓
父进程创建大量子进程不回收 → 大量PCB占用内存
↓
→ 内存泄漏!
简单理解:就像你给下属分配了任务,他做完后等你验收,但你一直不去验收,他就一直等在那儿,占用着公司的"编制"(PCB)。
模拟僵尸进程的代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t id = fork();
if (id == 0) {
// 子进程
printf("Child: PID=%d, PPID=%d\n", getpid(), getppid());
sleep(5);
exit(EXIT_SUCCESS); // 子进程退出,但父进程不回收 → 变成僵尸
} else {
// 父进程
printf("Parent: PID=%d\n", getpid());
sleep(30); // 父进程长时间不读取子进程状态
// 没有调用 wait(),子进程将一直处于Z状态
}
return 0;
}
深度理解:三种"不运行"状态的区别
┌─────────────────────────────────┐
│ 进程不运行 │
└─────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
等待事件发生 被主动暂停 已终止但未回收
│ │ │
┌──────┴──────┐ │ │
│ │ │ │
可被信号唤醒 不可被信号唤醒 可被SIGCONT恢复 等待父进程回收
│ │ │ │
▼ ▼ ▼ ▼
S状态 D状态 T/t状态 Z状态
关键区别:
-
S状态:进程"活着",只是在等待。可以被信号唤醒。
-
D状态:进程"活着",必须等待I/O完成。不能被信号唤醒——这是为了保护数据完整性。
-
T状态:进程"活着",被暂停。可以用 SIGCONT 恢复。
-
Z状态:进程"已经死了",但尸体还在。需要父进程来"收尸"(调用 wait)。
什么是孤儿进程?
孤儿进程是指:父进程先于子进程退出,子进程就变成了“孤儿”。
但它不会被丢弃,而是被 1号进程(init 或 systemd) 领养,由它负责回收。
// 孤儿进程示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t id = fork();
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;
}
运行后,父进程 3 秒后退出,子进程变成孤儿,被 init 领养。
为什么要领养?
-
如果父进程提前退出,子进程退出时变成 僵尸(Z)状态,没人回收就会一直占用 PCB。
-
1 号进程会定期
wait(),回收孤儿进程,防止资源泄漏。
进程优先级:谁先上CPU?
1. 什么是优先级?
优先级(PRI) 决定进程获取 CPU 资源的先后顺序,值越小,优先级越高。
2. Nice 值(修正值)
-
NI(nice) 取值范围:-20 ~ 19。
-
PRI(new) = PRI(old) + nice
-
nice 为负 → 优先级提高,但需要 root 权限。
# 查看优先级
ps -l
# 调整优先级(top 中按 r)
top
# 输入 PID,再输入 nice 值
竞争、独立、并行与并发
| 概念 | 解释 |
|---|---|
| 竞争性 | 多个进程争抢有限 CPU 资源 |
| 独立性 | 进程互不干扰,拥有自己的地址空间 |
| 并行 | 多个进程在多个 CPU 上同时运行 |
| 并发 | 一个 CPU 上通过时间片切换,让多个进程推进 |
进程切换:CPU 的“换人”机制
1. 上下文切换(Context Switch)
当 CPU 从执行进程 A 切换到进程 B 时,需要:
-
保存 A 的寄存器状态(上下文)
-
加载 B 的寄存器状态
这个过程叫上下文切换,是操作系统调度的基础。
2. 时间片
-
每个进程分配一个时间片(如 10ms)。
-
时间片用完 → 触发中断 → 切换进程。
-
实现“分时”效果,让用户感觉多个程序在同时运行。
Linux 2.6 O(1) 调度算法
1. 为什么需要 O(1)?
进程数量可能成千上万,调度算法必须高效,不能随进程数增加而变慢。
2. 核心数据结构
-
活动队列(active):时间片未用完的进程。
-
过期队列(expired):时间片已耗尽的进程。
struct prio_array {
unsigned int nr_active;
DECLARE_BITMAP(bitmap, MAX_PRIO+1);
struct list_head queue[MAX_PRIO];
};
-
140 个优先级(0~99 实时,100~139 普通)。
-
用 bitmap(5 个 32 位) 快速找到非空队列。
-
切换 active 和 expired 指针,实现 O(1) 调度。
active指针和expired指针
核心概念:两个完全相同的"队列数组"
struct rq {
struct prio_array *active; // 指向活动队列
struct prio_array *expired; // 指向过期队列
struct prio_array arrays[2]; // 真实存储两个队列
};
-
active指针 → 指向当前正在服务的队列 -
expired指针 → 指向暂时休息的队列
两个队列的结构完全一样(都是prio_array),都有140个优先级链表。
真实场景演示
假设系统有3个进程:P1(高优先级)、P2(中优先级)、P3(低优先级)
时间片 = 10ms
轮次1:
active: [P1, P2, P3] → P1运行10ms → expired
active: [P2, P3] → P2运行10ms → expired
active: [P3] → P3运行10ms → expired
active: [] → 交换指针!
轮次2:
active: [P1, P2, P3] → 重新开始(时间片已重新计算)
关键观察:
-
所有进程都被调度(公平)
-
P1优先运行(优先级体现)
-
交换指针瞬间完成(高效)
命令行参数
1. 基本概念
命令行参数是在执行程序时传递给程序的额外信息,通过 main 函数的参数接收。
#include <stdio.h>
int main(int argc, char *argv[]) {
// argc: 参数个数
// argv: 参数字符串数组
printf("参数个数: %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
2. 运行示例
# 编译
gcc -o test test.c
# 运行并传递参数
./test hello world 123
# 输出:
# 参数个数: 4
# argv[0] = ./test
# argv[1] = hello
# argv[2] = world
# argv[3] = 123
3. 重要特性
特性1:argv[0] 是程序本身
printf("程序路径: %s\n", argv[0]);
// 可能输出: ./test 或 /home/user/test 或 test
特性2:参数以空格分隔
./test hello world # 两个参数:"hello", "world"
./test "hello world" # 一个参数:"hello world"
特性3:参数类型都是字符串
// 需要自己转换
int num = atoi(argv[1]); // 字符串转整数
double pi = atof(argv[2]); // 字符串转浮点数
环境变量:进程的“隐形参数”
1. 环境变量是什么?
操作系统预设的一些全局变量,影响进程行为。
| 环境变量 | 作用 |
|---|---|
PATH | 命令搜索路径 |
HOME | 用户主目录 |
SHELL | 当前 shell 路径 |
2. 查看与设置
echo $PATH
export MYENV="hello"
unset MYENV
3. 代码中获取
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("%s\n", getenv("PATH"));
return 0;
}
#include <stdio.h>
int main(int argc, char *argv[]) {
extern char **environ; // 声明外部变量
int i = 0;
while (environ[i] != NULL) {
printf("%s\n", environ[i]);
i++;
}
return 0;
}
环境变量具有全局性,子进程会继承父进程的环境变量。
虚拟地址空间:进程的“专属假地图”
经典内存布局回顾
我们在C语言中学过内存布局,从高地址到低地址大致是:
高地址
+------------------+
| 内核空间 | (用户不可访问)
+------------------+
| 栈 (stack) | ↓ 向下增长
+------------------+
| ↓ |
| ↑ |
+------------------+
| 堆 (heap) | ↑ 向上增长
+------------------+
| 未初始化数据段 | (BSS)
+------------------+
| 已初始化数据段 | (Data)
+------------------+
| 代码段 (Text) |
+------------------+
低地址
可以用一段代码验证各区域的地址分布:
#include <stdio.h>
#include <stdlib.h>
int g_unval; // 未初始化全局变量
int g_val = 100; // 已初始化全局变量
int main(int argc, char *argv[], char *env[]) {
const char *str = "helloworld";
static int test = 10;
char *heap_mem = (char*)malloc(10);
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
printf("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);
return 0;
}
运行结果会显示各区域地址从低到高排列,验证了上述布局。
实验:同样的地址,不同的值
先看一段代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main() {
pid_t id = fork();
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);
}
return 0;
}
输出结果:
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
-
父子进程打印出来的地址完全一样
-
但打印出来的值却不一样
结论:这个地址绝对不是物理内存地址,而是虚拟地址!
什么是虚拟地址空间?
每个进程都认为自己独占了整个内存空间(在32位系统下是4GB),但实际上它使用的物理内存可能只有几MB。
操作系统通过页表将进程的虚拟地址映射到真正的物理地址。
进程视角: 虚拟地址 0x80497e8
↓
页表映射
↓
物理内存: 物理地址 0xXXXXXXXX
父子进程的虚拟地址相同,但页表不同,所以映射到的物理地址也不同——这就解释了为什么地址相同但值不同。
为什么要用虚拟地址空间?
| 问题 | 解决方案 |
|---|---|
| 安全风险 | 进程无法直接访问物理内存,必须通过OS监管的页表,恶意程序无法随意篡改其他进程或内核数据。 |
| 地址不确定 | 程序编译时使用的是虚拟地址,无论物理内存被占用到什么位置,虚拟地址空间对进程来说都是固定的。 |
| 效率低下 | 物理内存不足时,可以按页(4KB) 为单位换入换出,而不需要移动整个进程。 |
延迟分配:只承诺,不兑现
当我们 malloc 一块大内存时,操作系统只是在虚拟地址空间上划了一块区域给你,但物理内存可能一个字节都没分配。
只有当你真正去访问这块内存(读或写)时,才会触发缺页中断,操作系统才真正去申请物理内存并建立页表映射。
这叫延迟分配,它极大地提高了内存利用效率。
内核数据结构全景图
梳理一下整个链路:
用户进程
↓
task_struct (PCB)
├── pid, state, priority...
├── mm → mm_struct (内存描述符)
│ ├── start_code, end_code (代码段)
│ ├── start_data, end_data (数据段)
│ ├── start_brk, brk (堆)
│ ├── start_stack (栈)
│ ├── mmap → vm_area_struct (VMA链表)
│ └── mm_rb → 红黑树 (VMA树)
├── active, expired (调度相关)
└── ...
task_struct:进程控制块,描述一个进程的所有信息。
mm_struct:内存描述符,描述进程的虚拟地址空间布局。
vm_area_struct:虚拟内存区域,描述一段连续的虚拟地址区间(如栈区、堆区、代码段等)。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)