操作系统与进程核心全解:从冯诺依曼到fork系统调用
本文深入解析操作系统与进程核心概念,从冯诺依曼体系结构到fork系统调用主要内容包括: 冯诺依曼体系结构:计算机硬件基础架构,强调所有设备只能直接与内存交互操作系统管理本质:采用"先描述(PCB),再组织(数据结构)"的核心思想进程概念:程序执行实例,本质是内核数据结构task_struct+程序代码/数据进程管理:详细解析task_struct包含的进程标识符、状态、优先级等核心属性进程操作:介
操作系统与进程核心全解:从冯诺依曼到fork系统调用

📝 前言
在学习Linux系统编程的过程中,你是否有过这样的困惑:程序到底是如何在计算机上运行的?操作系统是如何管理成百上千个进程的?为什么一个fork()调用能"神奇地"返回两个不同的值?
提到进程,很多人的第一反应是"程序的执行实例",但这个定义背后隐藏着极其精妙的设计。从冯诺依曼体系结构到进程控制块PCB,从系统调用到父子进程关系,每一个环节都体现着操作系统设计的智慧。
通过本文,你将掌握:
| 技能 | 应用场景 |
|---|---|
| 冯诺依曼体系结构 | 理解计算机硬件与软件的数据流转 |
| 操作系统的管理本质 | 掌握"先描述,再组织"的核心思想 |
| 进程与PCB | 深入理解进程的本质是内核数据结构 |
| task_struct内容 | 了解进程标识符、状态、优先级等核心属性 |
| 进程查看与获取 | 使用ps、/proc、getpid/getppid等工具 |
| fork系统调用 | 理解父子进程创建机制与写时拷贝 |
| fork返回值原理 | 解释为什么fork能返回两个不同的值 |
📌 前置知识: 具备基本的C语言编程基础,了解Linux基本命令操作
文章目录
一、🖥️ 冯诺依曼体系结构:计算机的基石
1.1 什么是冯诺依曼体系
冯诺依曼体系结构是现代计算机的基础架构,无论是我们日常使用的笔记本电脑,还是数据中心的服务器,大多都遵循这一体系。
它由以下几个核心硬件组件构成:
| 组件 | 功能 | 典型设备 |
|---|---|---|
| 输入单元 | 将外部数据送入计算机 | 键盘、鼠标、扫描仪、麦克风 |
| 存储器 | 存储程序和数据 | 内存(RAM) |
| 中央处理器(CPU) | 执行运算和控制 | 包含运算器和控制器 |
| 输出单元 | 将结果呈现给用户 | 显示器、打印机、音箱 |
1.2 冯诺依曼的核心规则
关于冯诺依曼体系,有几个必须强调的关键点:
💡 核心规则一: 这里的存储器指的是内存,而不是磁盘。
💡 核心规则二: 不考虑缓存情况,CPU能且只能对内存进行读写,不能直接访问外设(输入或输出设备)。
💡 核心规则三: 外设(输入或输出设备)要输入或输出数据,也只能写入内存或者从内存中读取。
⚠️ 一句话总结:所有设备都只能直接和内存打交道!
1.3 数据流动示例:QQ聊天过程
为了加深理解,让我们分析一个实际场景——当你登录QQ并与朋友聊天时,数据是如何流动的?
发送消息的过程:
键盘输入 → 内存(QQ程序缓冲区)→ CPU处理 → 内存(网络缓冲区) → 网卡发送
接收消息的过程:
网卡接收 → 内存(网络缓冲区) → CPU解析 → 内存(显示缓冲区) → 显示器显示
💡 思考题: 如果在QQ上发送文件呢?数据又是如何流动的?(提示:文件先要从磁盘读入内存)
二、⚙️ 操作系统:管理一切软硬件的"大管家"
2.1 操作系统的概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS, Operating System)。笼统地说,操作系统包括:
- 内核:进程管理、内存管理、文件管理、驱动管理
- 其他程序:函数库、shell程序等
2.2 设计操作系统的目的
| 目标 | 说明 |
|---|---|
| 对下 | 与硬件交互,管理所有的软硬件资源 |
| 对上 | 为用户程序(应用程序)提供一个良好的执行环境 |
2.3 核心功能:管理
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件。
那么,如何理解"管理"呢?让我们用一个生活中的例子来说明:
管理的例子:学生、辅导员、校长
假设一所学校有 thousands of 学生,校长是如何"管理"这些学生的?
- 校长不可能认识每一个学生
- 但校长可以通过数据来了解学生情况
- 每个学生的信息被描述成一张表格(姓名、学号、成绩等)
- 所有学生的表格被组织起来(按班级、年级分类)
类比到操作系统:
| 现实场景 | 操作系统 |
|---|---|
| 学生 | 进程/硬件资源 |
| 学生信息表 | struct结构体(描述) |
| 按班级组织 | 链表/红黑树等数据结构(组织) |
2.4 操作系统管理硬件的方法
┌─────────────────────────────────────────┐
│ 应用程序(用户层) │
├─────────────────────────────────────────┤
│ 系统调用接口(System Call Interface) │
├─────────────────────────────────────────┤
│ 操作系统内核(进程/内存/文件/驱动管理) │
├─────────────────────────────────────────┤
│ 硬件(物理层) │
└─────────────────────────────────────────┘
💡 承上启下: 那么在还没有学习进程之前,操作系统是怎么进行进程管理的呢?很简单,先把进程描述起来,再把进程组织起来!
三、🔄 进程:程序的执行实例
3.1 进程的基本概念
进程的定义可以从不同角度来看:
| 视角 | 定义 |
|---|---|
| 课本概念 | 程序的一个执行实例,正在执行的程序 |
| 内核观点 | 担当分配系统资源(CPU时间、内存)的实体 |
| 当前理解 | 进程 = 内核数据结构(task_struct) + 自己的程序代码和数据 |
3.2 描述进程:PCB(进程控制块)
PCB(Process Control Block,进程控制块)是操作系统中用于描述进程属性的核心数据结构。
| 操作系统 | PCB名称 |
|---|---|
| 通用概念 | PCB |
| Linux | task_struct |
task_struct是Linux内核的一种数据结构类型,它会被装载到RAM(内存)中,并且包含着进程的所有信息。
3.3 task_struct的内容分类
一个完整的task_struct包含以下关键信息:
| 字段 | 说明 |
|---|---|
| 标识符 | 描述本进程的唯一标识符(PID),用来区别其他进程 |
| 状态 | 任务状态(运行、睡眠、停止等)、退出代码、退出信号等 |
| 优先级 | 相对于其他进程的优先级 |
| 程序计数器 | 程序中即将被执行的下一条指令的地址 |
| 内存指针 | 包括程序代码和进程相关数据的指针 |
| 上下文数据 | 进程执行时处理器的寄存器中的数据 |
| I/O状态信息 | 包括显示的I/O请求、分配给进程的I/O设备等 |
| 记账信息 | 处理器时间总和、使用的时钟数总和等 |
3.4 进程的组织方式
所有运行在系统里的进程都以task_struct双链表的形式存在内核中。
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ task_1 │←──→│ task_2 │←──→│ task_3 │←──→│ task_4 │
│ (init) │ │ (bash) │ │ (vim) │ │ (chrome) │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
四、🔍 查看与获取进程信息
4.1 通过/proc文件系统查看
Linux提供了一个特殊的文件系统/proc,它以文件系统的方式提供了访问内核数据的接口。
# 查看所有进程信息
ls /proc
# 获取PID为1的进程信息(init/systemd进程)
ls /proc/1
/proc目录下的每个数字文件夹代表一个进程,文件夹名就是进程的PID。
4.2 使用ps命令查看进程
ps是Linux下最常用的进程查看工具:
# 显示所有进程(包括其他用户的)
ps aux
# 显示进程归属的进程组ID、会话ID、父进程ID
ps axj
# 常用组合:查看特定进程
ps ajx | grep myprocess
参数说明:
| 参数 | 含义 |
|---|---|
| a | 显示一个终端所有的进程,包括其他用户的进程 |
| x | 显示没有控制终端的进程(后台守护进程) |
| j | 显示进程组ID、会话ID、父进程ID等作业控制信息 |
| u | 以用户为中心的格式显示,提供详细信息 |
4.3 通过系统调用获取进程标识符
在C程序中,可以使用以下系统调用获取进程ID:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
// 获取当前进程ID
printf("pid: %d\n", getpid());
// 获取父进程ID
printf("ppid: %d\n", getppid());
return 0;
}
| 函数 | 返回值 | 说明 |
|---|---|---|
| getpid() | pid_t | 返回当前进程的PID |
| getppid() | pid_t | 返回父进程的PID |
⚠️ 注意:
ctrl+c是杀掉前台进程的快捷键!也可以使用kill -9 PID强制终止指定进程。
五、🌲 通过fork创建进程
5.1 fork初识
fork()是Linux中创建新进程的核心系统调用。调用fork后,会创建一个与当前进程几乎完全相同的子进程。
fork的特点:
- 父子进程代码共享
- 数据各自开辟空间,私有一份(采用写时拷贝技术)
- fork有两个返回值
5.2 fork的基本用法
#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;
}
输出示例:
hello proc : 1234!, ret: 0 ← 子进程打印(返回0)
hello proc : 1233!, ret: 1234 ← 父进程打印(返回子进程PID)
5.3 fork后的分流处理
由于fork会返回不同的值,通常用if-else进行分流:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
if(ret < 0) {
// fork失败
perror("fork");
return 1;
}
else if(ret == 0) {
// 子进程(返回0)
printf("I am child : %d!, ret: %d\n", getpid(), ret);
}
else {
// 父进程(返回子进程PID)
printf("I am father : %d!, ret: %d\n", getpid(), ret);
}
sleep(1);
return 0;
}
fork创建子进程时,代码段的共享是其最核心的特性之一,这一设计极大节省了内存资源,也是fork高效的根本原因。下面从底层实现、具体表现和本质原理三个层面详细说明:
5.3.1 代码共享的底层实现:物理内存完全共享
程序被加载到内存后,会被划分为多个段,其中代码段(Text Segment) 存储的是程序的二进制指令,具有只读属性(由MMU硬件和内核共同保护)。
fork创建子进程时,内核不会为子进程复制代码段的物理内存,而是做以下操作:
- 为子进程创建独立的
task_struct(PCB)和独立的页表 - 子进程的页表中,代码段对应的页表项直接指向父进程代码段的物理内存页面
- 所有代码段的物理页面被标记为"只读",确保父子进程都无法修改
也就是说:父子进程的代码段,虚拟地址相同,且映射到完全相同的物理内存地址。整个系统中,这份代码只存在一份物理副本,被父子进程共享执行。
5.3.2 代码相同的具体表现
5.3.2.1 执行完全相同的指令流
父子进程拥有完全一致的代码逻辑,会执行程序中所有相同的函数、循环和分支判断。
关键注意点:子进程不会从头开始执行整个程序,而是从fork()函数的返回点开始执行。
- 父进程:执行完
fork()的创建逻辑后,返回子进程PID,继续向下执行 - 子进程:内核将其程序计数器(PC)设置为
fork()的返回地址,直接从这里开始执行
这就是为什么fork之前的代码只会被父进程执行一次,而fork之后的代码会被父子进程各执行一次。
5.3.2.2 函数地址、全局函数指针完全相同
由于代码段物理共享,父子进程中:
- 任何函数的地址(如
main()、printf()、自定义函数)完全相同 - 全局函数指针的值完全相同
- 跳转指令、函数调用指令的目标地址完全相同
5.3.2.3 动态链接库代码也共享
不仅程序自身的代码段共享,进程加载的所有动态链接库(如libc.so)的代码段也遵循同样的规则:
- 系统中所有进程共享同一份动态库代码的物理内存
- fork后的父子进程自然也共享这些动态库代码
5.3.3 与数据段"相同"的本质区别
很多初学者会混淆"代码相同"和"数据初始相同",这两者有本质区别:
| 对比项 | 代码段 | 数据段/堆/栈 |
|---|---|---|
| 物理内存 | 完全共享,只有一份副本 | fork后初始指向相同物理页,但采用写时拷贝(COW) |
| 修改行为 | 不可修改,修改会触发段错误 | 未修改时共享,任意一方修改时,内核复制一份新的物理页给修改方 |
| 最终状态 | 永远共享 | 修改后彻底分离,各自拥有独立副本 |
核心原因:代码是只读的,永远不会被修改,因此可以安全共享;而数据是可写的,必须保证进程的独立性,因此通过写时拷贝实现"读时共享,写时分离"。
5.3.4 特殊情况:代码段的写保护
如果尝试在程序中修改代码段的内容(例如通过指针修改函数地址),会触发段错误(Segmentation Fault)。这是因为:
- 内核将代码段的页表项标记为只读
- MMU在执行写操作时会检查权限,发现写只读页面时触发缺页异常
- 内核处理该异常时,发现是非法写操作,直接向进程发送SIGSEGV信号终止进程
这一保护机制确保了代码段的完整性,也保证了fork后代码共享的安全性。
六、🤔 fork核心问题深度解析
6.1 问题一:为什么fork给父子返回各自的不同的返回值?
这是一个设计上的精妙之处:
| 返回值 | 进程类型 | 用途 |
|---|---|---|
| 0 | 子进程 | 子进程可以通过getppid()获取父进程ID,所以只需要返回0表示自己"是子进程" |
| 子进程PID (>0) | 父进程 | 父进程需要知道创建了哪个子进程,以便后续管理(如wait、kill等) |
设计意图: 让父进程能够区分并管理不同的子进程。
6.2 问题二:为什么一个函数会返回两次?
这看似奇怪的现象,实际上是因为:
💡 fork创建了一个新的进程!
执行流程如下:
父进程: fork()调用 → 创建子进程 → 返回子进程PID → 继续执行后续代码
↓
子进程: 从fork返回 → 返回0 → 继续执行后续代码
- 父进程中的fork返回子进程的PID
- 子进程中的fork返回0
所以"返回两次"实际上是两个不同的进程分别返回!
6.3 问题三:为什么一个变量,既==0,又>0?
这是最令人困惑的问题:
int ret = fork();
if(ret == 0) { // 子进程走这里
// ...
}
else if(ret > 0) { // 父进程走这里
// ...
}
答案: 这不是"同一个变量",而是两个进程中的两个独立变量!
详细解释:
- 父进程执行
int ret = fork(),创建子进程 - 子进程会继承父进程的代码和数据(包括ret变量)
- 但子进程中的ret是独立的副本,与父进程的ret不是同一个内存地址
- 当fork返回时:
- 父进程的ret被赋值为子进程PID(>0)
- 子进程的ret被赋值为0
💡 本质: 父子进程有各自独立的地址空间,ret只是恰好同名而已,实际上是两个不同的变量!
七、📊 进程状态概览
Linux内核中,进程有以下状态(定义在kernel源代码中):
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 (Running) | 运行状态,进程要么在运行中,要么在运行队列中 |
| S (Sleeping) | 睡眠状态,进程在等待事件完成(可中断睡眠) |
| D (Disk Sleep) | 磁盘休眠,不可中断睡眠,通常等待IO结束 |
| T (Stopped) | 停止状态,可通过SIGSTOP信号停止,SIGCONT恢复 |
| Z (Zombie) | 僵尸状态,子进程退出但父进程未读取退出状态 |
本节完
✅ 本节完…
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)