操作系统入门(一):从冯诺依曼到进程概念

一、冯诺依曼体系结构

无论现在的超级计算机多么强大,绝大多数依然遵循着 1945 年诞生的冯诺依曼体系结构。理解这个结构,是理解一切软件逻辑的前提。


1. 核心组件

它将计算机抽象为五大核心部分:

组成部分 描述
输入设备 采集外部数据(键盘、鼠标、网卡等)
输出设备 将处理结果反馈(显示器、音箱、网卡等)
存储器 指内存。程序运行时的临时数据中转
运算器 (ALU) 负责所有的算术运算和逻辑判断
控制器 (CU) 指挥各部件协同工作,负责指令译码

为什么 CPU 不直接和外设打交道

CPU 直接从硬盘读数据、直接往显示器写数据不是更快吗?为什么要中间加个内存?

答案是:木桶效应

CPU 的运算速度是纳秒(ns)级的,而磁盘(外设)的读写速度通常是毫秒(ms)级的。如果 CPU 直接和外设打交道,CPU 执行一条指令只要 1 秒,而等磁盘传数据要花 1 天。那么 CPU 99.9% 的时间都在等待

用 C/C++ 写的程序,编译后生成的 .exe 或 a.out 是存储在磁盘上的

因为 CPU 只认识内存, 在冯·诺依曼体系中,CPU 只能从存储器(内存)中读取指令和数据。为了不让 CPU 被磁盘的慢速拖累,操作系统必须先把程序从磁盘拷贝到内存,然后 CPU 再去内存里取指令运行

内存的核心作用在于作为外设与 CPU 之间的高速缓冲。其读写速度远胜硬盘,虽不及 CPU迅捷,但已能基本匹配处理节奏。通过将数据预先从低速磁盘调入内存,可确保 CPU 持续高效运转


2. 数据流动的本质

如果我们要给计算机的运行效率下一个定义,那本质上就是设备间数据拷贝的效率

在计算机内部,数据永远在做搬运运动:

外设 (输入) 内存 CPU ​ 内存 ​ 外设 (输出)

计算机本质上并不生成数据,而是扮演着数据"加工厂"和"搬运工"的角色。其运算过程实际上是在数据拷贝的同时,由 CPU 附带执行逻辑处理操作

情景示例

假设你在给北京的朋友发了一句"吃了吗?"。在冯诺依曼体系下,这段数据是怎么样流动的呢

第一阶段:数据流出

  1. 输入 :你点击发送,文字通过键盘 / 触摸屏进入内存

  2. 处理:CPU 从内存读取这段文字,进行加密、打包,然后再写回内存

  3. 输出 :内存将处理好的数据包拷贝到网卡(输出设备),网卡通过光纤发往互联网

第二阶段:数据流入

  1. 输入 :北京朋友的手机网卡(输入设备)接收到电信号,将其拷贝到内存

  2. 处理:CPU 从内存读取加密包,进行解密、拆包,还原成文字和图片,再写回内存

  3. 输出 :内存将还原好的数据拷贝到显示器 / 屏幕(输出设备),朋友看到了你的问候

发送信息的本质:基于冯诺依曼体系结构 ,从我的键盘文件拷贝数据到对方的显示器文件

二、计算机与操作系统

让程序员直接操控硬件无异于一场灾难

想象一下:如果写一个 printf("Hello") 都要亲自动手控制显卡的每一个像素点、处理磁盘的磁道寻址、管理电压波动等等

  1. 开发门槛极高:你得是电子工程师才能写程序

  2. 资源利用率极低:如果程序 A 占着打印机不用,程序 B 只能干等

  3. 安全性极低:一个程序的 Bug 可能会直接烧毁硬件或者偷窥另一个程序的数据

这里引出了计算机科学中著名的大卫·惠勒金句

"任何计算机问题都可以通过添加一层软件层(间接层)来解决。"

操作系统正是那一层至关重要的软件层,其诞生正是为了高效处理这些繁琐事务


1. 操作系统的概念与定位

操作系统 是一款专门针对计算机硬件和软件资源进行管理的软件,笼统的理解,操作系统包括:

  • 内核(负责:进程管理、内存管理、文件管理、驱动管理)

  • 其他程序(例如函数库、shell 程序等等)

操作系统的定位 :它处于硬件之上,应用软件之下

  • 向下:它与硬件交互,通过驱动程序管理复杂的物理设备。

  • 向上:它为用户(及程序员)提供一个高效、稳定、安全的执行环境

操作系统的本质定义: 操作系统是一个协调者 。它的存在是为了通过合理的管理,让计算机变得更好用、更稳定


2. 操作系统的核心功能

为了确保计算机高效运行,操作系统主要承担四项核心职责:

  1. 进程管理: 决定哪个程序什么时候运行,运行多久,如何处理多程序并发

  2. 内存管理: 为每个程序分配生存空间,处理虚拟地址与物理内存的映射,防止越界访问。

  3. 文件管理: 将磁盘上的 0 和 1 抽象成我们熟悉的文件夹和文件,处理数据的读写与持久化

  4. 设备管理: 管理各种外设(磁盘、网卡等),通过中断和驱动机制实现数据的输入输出


3. 如何理解 "管理"

这是学习进程概念前最重要的一步。操作系统是如何管理千万个进程或硬件的

管理的本质:先描述,再组织

描述

就像校长管理学生,他不需要认识每个学生,只需要每个人的学籍档案。 在 C 语言中,这就是一个 struct 结构体。操作系统会将每个被管理对象的属性(学号、姓名、成绩、状态等)抽取出来,封装成一个结构体

cpp 复制代码
struct Student
{
    const char* num;
    const char* name;
    int score;
    bool state;
}

组织

有了成千上万份档案,校长只需要用链表、树或其他数据结构将这些结构体连起来

  • 所谓管理学生 :变成了对学籍档案链表的增删查改

  • 所谓管理进程:变成了对进程控制块链表的维护

三、用户与内核的交互

为了保证系统的绝对安全,操作系统不信任任何用户程序。它禁止你直接操作内存、直接往磁盘写数据或直接控制网卡

我们可以把操作系统看作一家银行

  • 内核:是银行的内部柜台和金库,存储着核心资源(硬件)

  • 用户:是办理业务的顾客

  • 系统调用 :是银行的业务窗口

你不能直接冲进金库自己拿钱(直接操作硬件),你必须通过窗口(系统调用),向柜员(内核)提交申请。柜员会检查你的身份和权限,确认无误后,由他替你进入金库完成操作

系统调用是操作系统内核 提供给外界的接口。它是用户程序访问内核资源的唯一合法通道。 而库函数其实是编程语言对系统调用的二次封装


库函数封装的意义

既然最终都要通过系统调用完成操作,为什么编程语言不直接暴露系统调用接口呢

编程语言的可移植性

这是封装最伟大的贡献。 不同的操作系统,系统调用的接口是不一样的。Linux 下叫 write,Windows 下可能叫 WriteFile

  • 如果你的程序直接用 write,你的代码就只能在 Linux 上跑,windows 根本不认识什么 write

  • 如果用 C 语言标准库的 printf,库函数会根据你所在的系统自动选择调用 write 还是 WriteFile。这实现了 "一份代码,到处编译"

让我们拆解一下当你调用 printf("Hello World\n") 时,底层发生了什么:

  • 用户层(库函数):你的程序调用 printf

  • 缓冲区处理 :printf 将字符串存入 C 语言级别的缓冲区 ,进行格式化处理。当满足刷新条件时,printf 内部会调用底层的系统接口 write

  • 内核执行:CPU 找到对应的显示器驱动程序,将数据发送到显存

  • 返回结果:printf 执行完毕,程序继续往下走

四、进程概念

在很多教科书中,进程被定义为 "一个正在执行的程序"。但这个定义过于抽象,无法触达操作系统的运行本质。要理解进程,我们必须先厘清程序进程的边界


1. 什么是进程

  • 程序(Program) :它是一个存储在磁盘中的二进制文件,本质上是一组静态的指令和数据集合。就像电影的剧本一样,它包含着预先编排好的内容

  • 进程(Process) :进程是指程序被加载并由 CPU 执行时的运行状态 。它是动态的,会经历创建、运行、挂起和终止等生命周期阶段。可以将其类比为正在根据剧本放映的电影

在 Linux 操作系统的视角下,一个进程的本质可以这么表达:

进程 = 内核数据结构 + 自己的程序代码和数据

1. 代码和数据

当我们运行 ./a.out 时,操作系统首先会将磁盘上的二进制指令(代码)和初始变量(数据)拷贝到内存中

  • 代码:定义了进程的执行逻辑

  • 数据:定义了进程的内容

但仅有这些是不够的,如果只有代码和数据,操作系统根本不知道这个程序运行到了哪一行、谁在运行它、它的优先级是多少

2. 内核数据结构 (task_struct)

操作系统通过创建 "数字化替身" 来管理运行中的程序,这个替身就是PCB(进程控制块)。在Linux 系统中,PCB的具体实现是 struct task_struct 结构体

当进程被创建时,系统会同步创建一个 task_struct 对象,并将其填入该进程的各种属性(如 PID、状态、内存指针等)

为什么必须包含内核数据结构

回到先描述,再组织的原则:

  • 如果不描述:操作系统只看到内存里有一堆乱七八糟的 0 和 1(代码和数据),它无法感知这到底是一个进程还是仅仅是一块废弃的内存

  • 有了 task_struct:操作系统只需管理这些结构体即可。例如,调度进程的过程实际上是将 task_struct 从等待队列移动到运行队列;而终止进程则意味着将该进程对应的 task_struct 从内核链表中移除,并释放其占用的内存资源


2. 描述进程------PCB

操作系统作为一个管理者,它并不直接盯着内存里那几兆、几百兆的代码看。它管理进程的方式,就是通过 PCB

  • 唯一性:每个进程有且仅有一个 PCB

  • 状态切换:当 CPU 要从进程 A 切换到 进程 B 时,OS 必须先把 A 的当前进度记录在 PCB 里,再从 B 的 PCB 里读取它上次运行到的位置

  • 资源分配:操作系统通过检查 PCB 来获取该进程打开的文件数量、内存使用情况以及运行时长等信息

虽然不同操作系统的 PCB 实现各有差异,但它们包含的核心信息大同小异(在 Linux 中叫 task_struct)。我们可以将其归纳为以下几大类:

属性类别 功能描述与技术细节
进程标识符 系统分配的全局唯一编号,用于区分不同的活跃进程
任务状态 运行 (R)、睡眠 (S/D)、停止 (T)、僵尸 (Z) 等状态,含退出码与信号
调度优先级 决定进程获取 CPU 时间片的先后顺序
程序计数器 存储下一条即将执行的指令地址,确保进程被切回时能准确接续进度
内存指针 指向程序代码段、数据段、堆栈以及共享内存块的虚拟地址映射表
上下文数据 进程切出 CPU 时,寄存器中数据的瞬时快照
I/O 状态信息 包含进程打开的文件描述符列表及待处理的 I/O 请求
记账信息 记录 CPU 耗时总和、内核/用户态时间、时间配额及计费信息

为什么 PCB 必须常驻内存?

代码和数据在不运行的时候可以留在磁盘上,但 PCB 必须始终留在内存的内核空间中

操作系统需要随时扫描所有 PCB 以进行调度决策。如果 PCB 也在磁盘上,OS 还要先发起磁盘 I/O 去读 PCB,而磁盘 I/O 本身又需要 OS 进行进程调度......这就陷入了逻辑死循环


3. 组织进程------task_struct

Linux 内核的进程管理机制并非简单的静态线性存储。在进程的生命周期内,其 task_struct 实例会被编排进不同的逻辑容器中

如果按照传统的思维,我们要把一个 task_struct 放入不同的链表,似乎需要不断的拷贝这个巨大的结构体。但 Linux 内核采用了另一种设计:不让链表包含进程,而让进程包含链表节点

这种设计被称为 "嵌入式链表"。task_struct 内部包含多个 list_head 成员作为连接点,使得单个进程对象能够同时被挂载到不同的管理结构中

代码示例:

我们简化一下 Linux 内核的逻辑,用一段 C 语言伪代码来展示这个神奇的结构:

cpp 复制代码
// 内核通用的双向链表节点
struct list_head {
    struct list_head *next, *prev;
};

// 描述进程的 PCB
struct task_struct {
    int pid;               // 进程身份证号
    int status;            // 状态:运行、睡眠等
    char comm[16];         // 进程名称
   
    /* --- 核心:组织进程的成员 --- */
    
    // 全量链表节点,用于将系统中所有进程串在一起
    struct list_head all_list;
    
    // 运行队列节点,当进程处于就绪态时,挂载在 CPU 的运行队列中
    struct list_head run_queue;
    
    // 等待队列节点,当进程在等磁盘/网卡 I/O 时,挂载在设备等待队列中
    struct list_head wait_queue;
};

你可能会问:OS 遍历链表时,拿到的只是 all_list 这样的成员的地址,它是怎么找回整个 task_struct 的首地址,从而读取 pid 或 status 的呢

在 Linux 内核中,有一个宏定义叫做 container_of。它的逻辑如下

cpp 复制代码
container_of(成员地址, 结构体类型, 成员名)

它利用 C 语言的偏移量(Offset)计算:用当前的成员地址,减去该成员在结构体内部的偏移字节量,从而精准反推回整个结构体的起始位置

五、进程操作

1. 查看进程信息

在 Linux 系统中,可通过多种文件和工具来查看进程信息

/proc 系统文件夹

Linux 的设计哲学秉承 "一切皆文件"。为了让用户能看到内核里的 task_struct 信息,Linux 提供了一个名为 /proc 的伪文件系统

它不是磁盘文件, /proc 并不占用磁盘空间,它存在于内存中。当你访问它时,内核会动态地把进程信息转换成文本呈现给你,我们可以这么查看:

bash 复制代码
ls /proc

我们会看到大量以数字命名的文件夹,每一个数字对应的就是进程的 PID

如果要获取PID为1的进程信息,查看 /proc/1 这个文件夹即可

ps 命令

ps (Process Status) 是最常用的查看进程的工具。它能瞬间拉取当前系统的进程列表

常用组合:

ps aux(偏向查看所有进程占用情况)

  • a:显示终端上的所有进程。

  • u:以用户为主的格式显示。

  • x:显示没有控制终端的进程。

ps ajx(偏向查看进程间的父子关系、会话关系)

输出字段解析:

  • USER:启动该进程的用户

  • PID:进程 ID

  • PPID:父进程 ID

  • %CPU:占用 CPU 百分比

  • STAT:进程状态

我们可以配合 grep 命令来过滤想查看的进程

bash 复制代码
# 先打印表头,再打印目标进程信息
ps ajx | head -1 && ps ajx | grep 'my_process'

top 命令

它是一个交互式的实时监控工具,类似于 Windows 的任务管理器

  • 关键指标

    • load average:系统的负载情况(1分钟、5分钟、15分钟的平均负载)

    • %CPU / %MEM:按资源占用排序

  • 交互操作

    • 按 M:按内存占用排序。

    • 按 P : 按 CPU 占用排序。

    • 按 q:退出监控


2. 获取进程标识符(PID)

PID (Process Identifier) 是操作系统分配给每个进程的一个非负整数

  • 唯一性:在任何给定的时刻,系统中的每一个 PID 都是独一无二的

  • 复用性:当一个进程彻底销毁后,它的 PID 可能会被回收,并在一段时间后分配给新的进程

PID 的核心作用

为什么我们需要这个数字?

  1. 操作系统能通过 PID 快速定位该进程的 task_struct

  2. 要终止死循环程序,可以通过执行命令 kill -9 1234 来实现,其中1234代表目标进程的PID号

  3. 每个进程都会记录自己的 PID 和父进程的 PID (PPID),从而构建出类似家族族谱的进程树

必须通过系统调用来获取标识符

这是一个极其重要的知识点:PID 存储在内核的 task_struct 结构体中。

根据我们之前提到的 "银行柜台" 理论,用户程序严禁直接翻阅内核态的数据。因此,我们无法通过简单的内存访问去拿 PID

必须通过操作系统提供的系统调用,委派内核帮你把 PID 读出来并返回给你

代码示例

cpp 复制代码
#include <stdio.h>
#include <unistd.h>    // 系统调用接口头文件
#include <sys/types.h> // 提供 pid_t 类型定义

int main()
{
    pid_t pid = getpid();
    pid_t ppid = getppid();

    printf("pid: %d, ppid: %d\n", pid, ppid);

    return 0;
}

细节拆解:

  • pid_t 类型:本质上是一个被 typedef 过的整数类型(通常是 int),但使用 pid_t 能提供更好的跨平台兼容性

  • 父进程是谁? :如果你在 Bash 命令行里运行这个编译后的程序,你会发现它的 PPID 永远是同一个数字------Bash 解释器 的 PID。这意味着,你在命令行启动的所有程序,都是 Bash 的子进程


3. fork 创建进程

fork 是一个系统调用,其功能是以父进程为模板,创建一个新的子进程

  • 父进程:原本正在运行的那个进程

  • 子进程:通过 fork 产生的新进程

fork 的返回值

  • 给父进程返回:子进程的 PID

  • 给子进程返回:0(子进程可以通过 getppid() 随时找到父亲,它只需要知道自己创建成功了)

  • 失败返回:-1

为什么要这么设计?

父进程需要记录所有子进程的 PID 才能准确发送指令,而子进程只需通过 getppid() 系统调用即可随时获取父进程信息,无需额外存储

父子进程数据的关系

  • 共享的

    • 程序代码:由于代码是只读的,父子进程共用同一份内存中的代码段,这样可以节省大量空间
  • 独立的

    • 内核数据结构:子进程拥有自己独立的 task_struct

    • 数据段:虽然初始内容一样,但在逻辑上,子进程拥有自己独立的一份数据副本

写实拷贝

如果父进程占用了 1GB 内存,fork 时 OS 是否要立刻拷贝 1GB 数据给子进程?
答案是否定的。 这样做的代价太高,且如果子进程后续立刻去加载新程序,这 1GB 的拷贝就全白费了

写时拷贝(COW)逻辑

  1. fork 之后,父子进程的物理内存是共享的,且被标记为"只读"

  2. 只要父子进程都不改数据,就一直共享下去

  3. 谁先尝试修改数据,OS 就会立刻为该进程开辟新的物理内存并拷贝一份数据

写时拷贝确保了 fork 的高效,实现了 "按需分配" 的懒加载哲学


fork 为什么有两个返回值?

在 Linux 下,我们通常这样写 fork

cpp 复制代码
int main() {

    pid_t id = fork(); 

    if (id < 0) {
        // 创建失败
        perror("fork");
        return 1;
    } else if (id == 0) {
        // 子进程
        while (1) {
            printf("I am Child!  pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    } else {
        // 父进程
        while (1) {
            printf("I am Parent! pid: %d, my_child_pid: %d\n", getpid(), id);
            sleep(1);
        }
    }

    return 0;
}

要理解 fork 的两个返回值,不能简单地将其视为普通 C 函数,而应将其看作内核的一次 "细胞分裂" 过程

当你的程序执行到 fork() 这一行时,它会陷入内核。内核会做以下几件事:

  1. 分配新的内存块和内核数据结构给子进程

  2. 将父进程的 tast_struct 属性拷贝给子进程

  3. 将子进程添加到系统调度队列中

关键点:

在 fork() 函数内部,所有的准备工作(拷贝、创建)完成后,在即将执行函数内部的 return 语句之前,子进程已经创建成功了

此时,系统中存在两个一模一样的执行流。既然是拷贝,子进程的 PC(程序计数器)指向的也是 fork() 函数内部即将返回的那一行

  • 父进程:从内核的 fork() 函数里准备返回

  • 子进程:也从内核的 fork() 函数里准备返回

这就是 fork 函数,返回两次的真相:两个独立的进程分别执行了各自的 return


变量 id 怎么能既是 0 又是非 0?

如果运行上面的代码,我们会惊奇地发现 if 和 else if 里面都在打印。这意味着 id == 0 和 id > 0 在同一时刻同时成立了

从内存角度来看:

  1. 初始状态:fork 刚刚完成时,父子进程共享同一块物理内存

  2. 返回时刻 :内核在向 id 变量写入返回值时,触发了写时拷贝

  3. 结果

    • 内核为父进程的 id 变量写了一个值(子进程 PID)

    • 内核为子进程的 id 变量写了另一个值(0)

虽然在代码里的变量名都叫 id,但在 fork 之后,它们已经是两个完全独立的变量了

总结

从冯诺依曼体系到操作系统的引入,再到进程与 PCB 的概念,本质上是在逐步回答一个问题:计算机如何高效地管理和运行程序。操作系统通过先描述、再组织的方式,将进程这一抽象实体转化为可被统一管理的数据结构,并借助系统调用实现用户态与内核态之间的交互

在此基础上,进程不再只是 "程序的运行" ,而成为操作系统调度与资源分配的基本单位。理解这一点,意味着我们已经从 "写程序" 迈入了 "理解系统如何运行程序" 的层面

在下一篇中,我们将进一步深入进程的运行机制,探讨进程的创建细节、地址空间以及调度等更底层的问题,逐步揭开操作系统内部工作的全貌

相关推荐
坤坤藤椒牛肉面2 小时前
linux驱动1
linux·运维·服务器
摸鱼仙人~2 小时前
LLM量化技术全景对比:AWQ、GPTQ、GGUF与FP8/INT8/INT4的抉择指南
运维·服务器
zoujiahui_20182 小时前
ubuntu使用中的问题
linux·ubuntu·github
这辈子谁会真的心疼你2 小时前
如何修改视频媒体修改时间?两个方法介绍
java·服务器·数据库
默|笙2 小时前
【Linux】线程互斥与同步_线程互斥
linux
wanhengidc2 小时前
服务器 网络信息安全
运维·服务器·网络
wanhengidc2 小时前
裸金属服务器都有什么作用
运维·服务器
Harvy_没救了2 小时前
MySQL主从架构深度解析:原理、优化与实践指南
运维·mysql·架构
CHANG_THE_WORLD2 小时前
演示宽度数组解析
linux·服务器·前端