操作系统与进程核心全解:从冯诺依曼到fork系统调用

在这里插入图片描述

🌈 say-fall:个人主页
🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 | 《小游戏与项目》
💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。

📝 前言

在学习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创建子进程时,内核不会为子进程复制代码段的物理内存,而是做以下操作:

  1. 为子进程创建独立的task_struct(PCB)和独立的页表
  2. 子进程的页表中,代码段对应的页表项直接指向父进程代码段的物理内存页面
  3. 所有代码段的物理页面被标记为"只读",确保父子进程都无法修改

也就是说:父子进程的代码段,虚拟地址相同,且映射到完全相同的物理内存地址。整个系统中,这份代码只存在一份物理副本,被父子进程共享执行。

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)。这是因为:

  1. 内核将代码段的页表项标记为只读
  2. MMU在执行写操作时会检查权限,发现写只读页面时触发缺页异常
  3. 内核处理该异常时,发现是非法写操作,直接向进程发送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) {  // 父进程走这里
    // ...
}

答案: 这不是"同一个变量",而是两个进程中的两个独立变量

详细解释:

  1. 父进程执行int ret = fork(),创建子进程
  2. 子进程会继承父进程的代码和数据(包括ret变量)
  3. 但子进程中的ret是独立的副本,与父进程的ret不是同一个内存地址
  4. 当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 | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐