一、前言:学习进程之前,先弄清楚操作系统在做什么

之前学习 C/C++ 的时候,我们主要关注的是程序本身:变量存在哪里、函数如何调用、数据结构如何组织,以及代码最终怎样运行。

但当我们开始学习 Linux,就需要换一个角度思考问题:

  • 我们编写的程序,最终是怎样被计算机执行的?
  • CPU、内存、磁盘和输入输出设备之间是如何协作的?
  • 操作系统为什么能够同时管理那么多程序?
  • 操作系统面对大量运行中的程序,究竟是怎样进行管理的?

这些问题看似分散,实际上有一条统一的主线。

计算机硬件负责执行和存储,操作系统负责管理资源,而进程正是操作系统管理运行任务的重要对象。

要理解进程,不能只背诵“进程是程序的一次执行实例”。我们需要先理解程序运行的硬件基础,再理解操作系统的定位,最后进入操作系统的管理方式。

本文主要从三个方面展开:

  1. 冯诺依曼体系结构与计算机中的数据流动。
  2. 操作系统的定位,以及系统调用和库函数的关系。
  3. 操作系统的管理哲学:先描述,再组织。

二、冯诺依曼体系结构:程序运行的硬件基础

2.1 一台计算机由哪些部分组成?

我们日常使用的笔记本电脑,以及服务器等计算设备,大多可以从冯诺依曼体系结构的角度理解。

经典的冯诺依曼体系结构主要包括以下组成部分:
在这里插入图片描述

  • 输入设备:键盘、鼠标等,用于向计算机输入信息。
  • 输出设备:显示器、打印机等,用于向外部呈现处理结果。
  • 存储器:经典模型中主要指内存,用于存放程序和数据。
  • 运算器:负责执行算术运算和逻辑运算。
  • 控制器:负责协调和控制计算机的执行过程。

现代计算机还包含磁盘、网卡、显卡等大量硬件组件。实际硬件结构远比经典模型复杂,但冯诺依曼体系结构依然是理解程序运行的重要起点。

这里需要特别关注三个对象:

CPU、内存和外部设备。

CPU 负责执行指令,内存用于存放正在执行的程序及其数据,而键盘、磁盘、网卡等设备则负责提供或接收信息。

它们不是各自独立工作的,而是需要通过一系列数据传输与控制过程协同完成任务。

2.2 为什么程序通常需要先加载到内存?

先思考一个问题:我们编写的 C 程序,最初保存在哪里?

例如,我们在 Linux 中编写一个 hello.c 文件:

#include <stdio.h>

int main()
{
    printf("hello world\n");
    return 0;
}

经过编译和链接后,我们得到一个可执行文件。

此时,这个可执行文件通常保存在磁盘上。它是一个文件,还没有因为被保存下来就自动开始执行。

当我们运行它时,操作系统需要为程序的执行建立相应的运行环境,并根据需要将程序内容映射或加载到内存中。CPU 随后才能按照执行机制获取指令、处理数据。

为什么要这样设计?

因为 CPU 执行指令时,需要通过计算机的存储层次获取指令和数据。内存为程序执行提供了重要的工作空间,而磁盘主要承担持久化存储的任务。

从性能上看,CPU 的执行速度远高于常规存储设备的访问速度。内存、缓存和寄存器等不同层次的存储结构,正是现代计算机协调性能、容量和成本的重要组成部分。

因此,我们可以先建立一个基本认识:

磁盘主要负责长期保存程序和数据,内存为程序运行提供工作空间,CPU 负责执行指令。

这里的“加载到内存”是帮助理解程序运行的基础模型。现代操作系统还会使用虚拟内存、按需调页、文件映射等机制,并不意味着每次运行都必须把整个可执行文件一次性读入内存。

在这里插入图片描述

2.3 理解数据流动:以键盘输入为例

仅仅记住 CPU、内存和外设的名称还不够,我们还需要理解它们之间的数据是怎样流动的。

假设我们在 Linux 终端中输入:

echo hello

从宏观角度看,可以把这个过程理解为以下几个阶段。

第一阶段:键盘产生输入。

当我们按下键盘上的按键时,键盘硬件会产生相应的输入信息。操作系统通过输入设备相关的驱动和处理机制接收这些信息。

第二阶段:操作系统处理输入。

输入信息经过硬件与操作系统相关的处理流程,进入相应的软件缓冲区或终端输入处理机制。具体经过哪些缓冲区,取决于设备、驱动和终端配置。

第三阶段:程序读取输入并执行。

终端程序根据用户输入的内容进行处理。当我们执行 echo hello 时,Shell 会解析命令,并通过相应的执行机制启动或调用命令程序。

第四阶段:产生输出。

echo 产生字符串 hello,输出数据通过操作系统提供的输入输出机制传递到终端,最终由显示设备呈现出来。

我们可以把整体流程简化为:

键盘
  |
  v
输入设备与驱动
  |
  v
操作系统的输入处理机制
  |
  v
终端 / Shell 处理
  |
  v
命令执行与输出
  |
  v
操作系统的输出机制
  |
  v
显示设备

这张图展示的是宏观的数据处理流程,不代表每一步都对应一次独立的内存复制。

需要注意,数据流动不等于所有数据都必须由 CPU 亲自搬运一遍。在真实系统中,设备控制器、DMA、缓冲区以及不同的 I/O 机制都可能参与数据传输。

对于初学阶段,我们首先需要理解的是:程序执行、输入输出和硬件设备并不是相互孤立的,它们需要操作系统和硬件机制协同完成工作。

2.4 为什么需要操作系统参与?

假设我们编写一个程序,需要读取文件、接收键盘输入、向屏幕输出内容。

如果每个应用程序都必须自己了解每一种硬件的工作方式,直接控制设备的寄存器,并自行处理资源分配,那么程序开发会变得非常复杂。

而且,如果多个程序同时使用同一个设备或争夺有限的内存资源,又该由谁来协调?

这就引出了操作系统。

操作系统将大量通用的硬件管理、资源分配和访问控制机制集中起来,为上层程序提供统一的使用方式。

应用程序不需要了解所有硬件的内部细节,也不应该随意控制整台计算机的资源。

它可以通过操作系统提供的接口提出请求,再由操作系统按照相应规则处理。

这就是我们接下来要理解的内容。


三、操作系统:连接应用程序与硬件的管理者

3.1 操作系统到底是什么?

从广义上看,操作系统通常包括内核以及相关系统程序和工具。

其中,内核是操作系统最核心的部分,负责进程管理、内存管理、文件管理、设备驱动等重要工作。

我们在 Linux 中学习的进程管理,主要就是围绕 Linux 内核展开的。

可以将计算机的软件结构简化为:
在这里插入图片描述
这张图是帮助理解的简化模型。库函数不一定每次都会调用系统调用,驱动程序也不一定总是以独立的层次出现在每条执行路径中。

不过,它说明了一个重要事实:

应用程序通常通过操作系统提供的受控接口使用系统资源,而不是直接掌管整个硬件。

3.2 操作系统的两个主要目标

从课件的角度来看,操作系统的目标可以概括为两个方面。

对下:管理和协调计算机资源。

操作系统需要管理 CPU 的使用、内存的分配、文件的访问以及设备的使用等。

例如,当多个程序同时运行时,CPU 的执行时间是有限的,内存容量也是有限的。操作系统需要协调这些资源,让不同的程序能够按照规则获得执行机会。

对上:为应用程序提供良好的运行环境。

应用程序不需要每次读取文件时都重新编写一套磁盘控制逻辑,也不需要为每一种网卡都实现一套完整的硬件控制机制。

操作系统通过相应的接口和机制,将底层的复杂性封装起来,让应用程序可以使用相对统一的方式完成工作。

这两个目标并不是彼此独立的。

操作系统正是通过管理底层资源,才能为上层程序提供相对稳定、可靠的运行环境。

3.3 为什么操作系统不能让应用程序随意操作硬件?

我们可以把操作系统想象成一个大型图书馆。

图书馆里有大量图书、阅览座位和设备。每个读者都可以使用这些资源,但不能因为自己想看某本书,就随意拿走所有藏书,也不能擅自破坏其他读者正在使用的设备。

图书馆需要制定规则、记录资源状态,并按照一定的方式管理使用过程。

操作系统也有类似的职责。

如果一个普通应用程序可以不受限制地修改其他程序的内存、随意访问设备或者直接控制整个系统,那么系统的安全性和稳定性就很难得到保障。

因此,现代操作系统通常区分用户态和内核态,并通过权限控制和受控接口限制应用程序对系统资源的访问。

这不是为了故意增加开发难度,而是为了让多个程序能够在同一台计算机上相对安全地运行。


四、系统调用与库函数:应用程序怎样请求操作系统服务?

理解了操作系统的定位,接下来就要回答一个具体问题:

应用程序想要使用操作系统的功能,究竟应该怎么做?

这就涉及系统调用与库函数。

4.1 什么是系统调用?

系统调用是应用程序请求操作系统内核提供服务的重要接口。

例如,在 Linux 中,我们经常会接触到:

  • fork():创建进程。
  • read():读取数据。
  • write():写入数据。
  • open():打开文件。
  • close():关闭文件。

这些接口让应用程序能够请求内核执行相应的操作。

以文件写入为例,应用程序不能随意修改磁盘上的任意位置,而是需要通过操作系统提供的文件访问机制完成操作。操作系统会结合文件描述符、访问权限和具体 I/O 路径等信息进行处理。

系统调用的意义在于,它为应用程序提供了受控的内核服务入口。

需要注意,系统调用并不等于普通的 C 函数调用。系统调用通常涉及从用户态进入内核态的机制,并由内核处理相应请求。

4.2 什么是库函数?

我们在 C 语言中经常使用:

printf("hello world\n");

这里的 printf() 是 C 标准库提供的格式化输出函数。

它负责按照指定格式组织输出内容,并通过相应的底层机制完成输出。

在 Linux 中,printf() 通常会使用标准 I/O 缓冲机制,并在需要时通过底层的 write() 等机制输出数据。

这里必须区分两个概念:

  • printf() 是库函数。
  • write() 是 Linux 提供的系统调用接口。

它们不是同一个层次的东西。

库函数可以封装更方便的功能,系统调用则为应用程序提供请求内核服务的入口。

此外,并不是每个库函数都会调用系统调用。例如,一些字符串处理函数完全可以在用户态完成工作。

4.3 用一条输出语句理解调用关系

当我们执行:

printf("hello world\n");

可以从宏观上理解为:

  1. 程序调用 printf()。
  2. C 标准库对输出内容进行格式化,并根据需要使用缓冲机制。
  3. 当需要向终端等目标输出数据时,库函数可能通过 write() 等系统调用请求内核服务。
  4. 内核根据相应的文件描述符、设备和 I/O 机制处理请求。
  5. 输出最终通过相应的设备路径呈现给用户。

这不是说每次调用 printf() 都一定立即执行一次 write()。如果使用了缓冲机制,实际输出可能被推迟到缓冲区刷新时。

我们真正要理解的是:库函数负责提供方便的编程接口,系统调用负责让应用程序请求内核服务。

通过这种分层设计,应用程序不必重复实现大量底层机制,也不必直接控制所有硬件细节。


五、操作系统的管理哲学:先描述,再组织

前面的内容解释了操作系统为什么存在,以及应用程序如何使用它提供的服务。

现在来到本篇最重要的部分:

操作系统究竟怎样管理大量的对象?

课件中给出了一个非常重要的思路:先描述,再组织。

这也是后续理解 Linux 进程管理的基础。

5.1 第一步:先描述被管理对象

假设学校需要管理几千名学生。

如果只知道学校里有很多学生,却不知道每个人是谁、属于哪个班级、成绩如何,那么管理工作就无法开展。

学校需要记录每个学生的相关信息,例如:

  • 学号。
  • 姓名。
  • 班级。
  • 年龄。
  • 成绩。

这样,每个学生就不再只是一个模糊的个体,而是有了一组可以被记录、查询和处理的属性。

在程序中,我们可以使用结构体描述学生:

struct Student
{
    int id;
    char name[32];
    int age;
    float score;
};

这里的 struct Student 描述了学生对象具有哪些属性。

需要注意,结构体定义本身只是规定了数据的组织形式,并不会自动创建所有学生对象。只有在定义变量、动态分配内存等操作发生后,才会有相应的对象实例。

例如:

struct Student s1 = {1001, "Tom", 19, 90.0f};
struct Student s2 = {1002, "Jerry", 20, 85.0f};

现在,两个学生的信息就可以分别保存在相应的结构体对象中。

这种做法把现实中的管理对象抽象成计算机可以处理的数据。

所谓“描述”,就是把管理对象的重要属性整理成明确的数据结构。

5.2 第二步:再组织被管理对象

如果学校只有两名学生,分别保存两个结构体对象就已经足够直观。

但假设学生数量达到几千甚至几万人,我们还需要频繁进行查找、遍历、添加和删除等操作。

这时,仅仅描述每个学生还不够。

我们还需要考虑:怎样把这些对象组织起来,方便后续管理?

例如,可以使用链表组织学生节点:

struct Student
{
    int id;
    char name[32];
    int age;
    float score;

    struct Student* next;
};

其中,next 指针指向下一个学生节点。

多个节点可以通过指针连接起来,形成一条链表。

head
 |
 v
[学生1] -> [学生2] -> [学生3] -> NULL

这样,我们就可以从链表头部出发,逐个访问学生节点,并在满足相应条件时执行查询、插入或删除等操作。
在这里插入图片描述

当然,链表并不是唯一的组织方式。

如果我们需要快速查找学生,也可以考虑哈希表;如果需要按照某种顺序维护数据,也可以考虑平衡搜索树。

描述解决的是“对象有哪些属性”,组织解决的是“对象之间如何关联,以及怎样被高效管理”。

二者缺一不可。

5.3 把这个思想放回操作系统

现在将视角从学校切换到 Linux。

操作系统需要管理大量进程。每个进程都有自己的标识、状态、资源使用情况以及执行上下文等信息。

如果操作系统只知道“当前有很多程序在运行”,却没有办法记录和组织每个进程的信息,那么它就无法有效地进行调度、资源管理和状态维护。

因此,Linux 内核使用专门的数据结构描述进程。

在 Linux 中,这个重要的数据结构就是:

struct task_struct;

task_struct 是 Linux 内核用于描述任务的重要结构体类型,其中包含了进程管理所需要的各种信息。

需要说明的是,task_struct 并不是用户自己定义的普通业务结构体,而是 Linux 内核实现的一部分。它的具体字段和组织方式与内核版本有关。

在后续学习中,我们会逐渐看到它所涉及的进程标识、状态、调度信息、内存管理信息等内容。

而操作系统不仅需要描述单个进程,还需要将大量进程纳入内核的管理机制。

Linux 内核会通过链表、树、队列等多种数据结构和相关管理机制组织不同对象。具体使用哪一种结构,取决于管理任务和内核实现,并不是所有对象都简单地放在同一条链表中。

因此,我们可以把操作系统的管理思路概括为:

大量管理对象
      |
      v
提取对象属性
      |
      v
建立相应的数据结构
      |
      v
组织对象及其关系
      |
      v
执行查询、调度、分配和回收等管理操作

这就是“先描述,再组织”的核心含义。

5.4 为什么这个思想值得反复理解?

学到这里,可能会觉得这只是一个普通的管理方法。

但如果结合我们以前学过的 C/C++,就会发现它与数据结构的设计思想有很强的联系。

在 C 语言中,我们使用结构体组织数据;在数据结构中,我们通过链表、树、哈希表等方式组织对象;在 Linux 内核中,也需要使用相应的数据结构描述和管理进程及其他系统对象。

它们并不是完全相同的东西,但都体现了一个共同思路:

先明确需要处理的对象及其属性,再根据实际需求选择合适的数据组织方式,最后在这些结构上实现相应的操作。

这也解释了为什么学习 Linux 之前,掌握 C 语言、指针和数据结构非常重要。

当我们以后阅读 task_struct 的代码时,不应该只把它看成一大段陌生的内核代码。

我们应该先问:

  1. 它描述的对象是什么?
  2. 为什么需要这些属性?
  3. 这些对象之间有什么关系?
  4. 内核通过什么数据结构组织它们?
  5. 这种组织方式如何支持后续的管理操作?

当我们能够从这些角度阅读代码时,理解内核实现就会更有方向。


六、本篇总结

通过本文,我们建立了理解 Linux 进程管理所需要的第一层基础。

首先,从冯诺依曼体系结构出发,我们理解了 CPU、内存和外部设备之间的基本关系,以及程序运行为什么离不开硬件和操作系统的协作。

其次,我们认识到操作系统不仅负责管理底层资源,还为上层应用程序提供相对统一的运行环境。系统调用和库函数则分别处于不同的软件层次,帮助应用程序使用相关功能。

最后,我们回到操作系统管理本身,理解了“先描述,再组织”的核心思想。

对于 Linux 进程管理来说,这个思想尤其重要:操作系统必须能够描述每个进程,并通过相应的数据结构和管理机制组织这些进程,才能进一步完成调度、资源分配和状态维护。

下一篇,我们将正式进入进程的基本概念,重点理解:

  • 程序与进程有什么区别?
  • 为什么一个程序运行起来后,需要额外的管理信息?
  • Linux 为什么使用 task_struct 描述进程?
  • PID、PPID 和进程控制块分别承担什么作用?

从这里开始,我们就正式进入 Linux 进程管理的核心内容。

Logo

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

更多推荐