【操作系统 | 开篇:什么是操作系统?从裸机到多任务系统】

平时使用计算机时,我们很少关心程序究竟怎样控制硬件。打开播放器就能听音乐,启动浏览器就能访问网络,点击保存就能把文档写入磁盘。这些操作背后,都有操作系统参与其中。

那么,为什么需要操作系统?它怎样让多个程序共享同一台机器?进程、上下文、栈和 PCB 这些概念,又是为了解决什么问题而出现的?

这一篇就从最基本的硬件访问开始,沿着"裸机程序 → 单任务系统 → 多任务系统"的思路,逐步认识操作系统。

1. 理解操作系统这个概念

先用一句话理解:操作系统是负责管理计算机资源,并向上提供统一使用接口的系统软件。

它一方面管理 CPU、内存、磁盘和外设,另一方面让应用程序能够方便地使用这些资源,而不必每次都自己处理硬件细节。

比如,程序需要读取一个文件时,通常只需要调用相应接口,提供文件名、读取位置和长度。至于数据位于哪种存储设备上、控制器如何工作、数据怎样传输,则由文件系统、设备驱动等组件协同处理。

常见的设备访问过程可以简化为:

text 复制代码
用户使用应用程序
        ↓
应用调用库函数或系统服务
        ↓
通过系统调用请求内核服务
        ↓
内核中的相关管理模块与设备驱动
        ↓
具体硬件

驱动负责处理设备的具体控制方式,上层接口则尽量屏蔽这些差异。这样,应用程序就能更多地关注"要完成什么功能"。

从资源管理的角度,可以先抓住三条主线:

核心资源 操作系统需要解决的问题 对应内容
CPU 哪个任务运行?运行多久?什么时候切换? 进程、线程与任务调度
内存 空间怎样分配、共享、保护和回收? 内存管理
磁盘 数据怎样组织、存储和访问? 文件系统

除此之外,键盘、显示器、网卡等设备的使用,还需要 I/O 管理机制。

更完整地说,操作系统是一组控制和管理计算机硬件、软件资源,合理组织与调度任务,并方便用户使用计算机的程序集合。

提高资源利用率和系统吞吐量是它的重要目标;同时,它还要兼顾响应速度、公平性、稳定性和安全性。

2. 从裸机程序到操作系统的出现

理解操作系统,可以先暂时把它拿掉,看看程序会面临哪些问题,再一步步把需要的能力补回来。

下面用"操作系统 1.0"和"操作系统 2.0"分别表示简化的单任务、多任务模型。这是为了说明问题的演进,并不是正式的系统版本或严格的历史分期。

2.1 裸机程序

没有操作系统,程序也可以运行。 直接运行在硬件上、不依赖操作系统提供运行环境的程序,通常称为裸机程序。

计算机底层有很多硬件,各自承担不同的职责:

  • CPU:执行程序指令。
  • 内存:保存运行时的指令和数据。
  • 磁盘等存储设备:持久保存程序与数据。
  • 外设控制器:控制 USB、网卡、磁盘等设备的具体操作。
  • 总线与互连:连接 CPU、内存和控制器,传递地址、数据及控制信息。

程序要操作硬件,最终必须按照硬件规定的方式发出命令。

例如,在采用内存映射 I/O 的系统中,控制器的寄存器会映射到特定地址。程序执行对这些地址的读写,就可以设置控制信息或查询设备状态。

以 USB 控制器为例,可以先把基本关系理解为:

text 复制代码
裸机程序
   │ 包含访问设备寄存器的指令
   ↓
CPU
   │ 执行读写操作
   ↓
总线与互连
   │ 将访问送到对应控制器
   ↓
USB 控制器的寄存器
   │ 设置控制信息、查询状态等
   ↓
USB 控制器内部电路
   ↓
通过 USB 接口与设备通信

实际的 USB 通信还涉及协议、缓冲区、传输描述符等机制。这里先抓住一点:软件通过 CPU 执行指令,按照硬件约定控制设备。

这意味着,裸机开发者除了实现业务逻辑,还需要了解不少硬件知识:控制器地址是什么?哪个寄存器用于启动操作?哪个状态位表示完成?中断到来以后怎样处理?

如果一个程序同时要操作磁盘、键盘、显示器和网卡,就可能包含大量设备控制代码:

text 复制代码
App A                         App B
├── 业务逻辑                  ├── 业务逻辑
├── 磁盘控制代码              ├── 磁盘控制代码
├── 键盘控制代码              ├── 键盘控制代码
├── 显示控制代码              ├── 显示控制代码
└── 网络控制代码              └── 网络控制代码

这些功能如果分别实现,就会产生重复劳动;更换硬件时,应用也可能需要跟着修改。

于是可以把共同的底层能力集中起来,在应用和硬件之间增加一层:

text 复制代码
App A          App B          App C
   \             |             /
    └────── 统一的软件接口 ─────┘
                  ↓
               操作系统
                  ↓
          设备驱动与硬件访问
                  ↓
                硬件

操作系统统一管理和抽象硬件,让应用不必各自重复处理所有底层细节。 这就是从裸机程序走向操作系统的重要原因。

2.2 操作系统 1.0------单任务系统

现在,应用已经能够通过操作系统提供的接口使用硬件。比如,编辑器只需要请求保存文件,不必自己实现每一种磁盘控制器的操作流程。

但假设系统一次主要运行一个应用,仍然会遇到新的问题:这个应用等待设备时,CPU 应该做什么?

例如,一个任务先计算,再读取磁盘,最后继续计算:

text 复制代码
任务 A:计算 ──→ 等待磁盘 I/O ──→ 继续计算
CPU:   忙碌 ──→ 可能空闲      ──→ 再次忙碌

磁盘操作并不会瞬间完成。如果没有其他任务可以调度,等待期间 CPU 就可能闲着,即使它本来有能力完成更多计算。

这个简化的单任务模型主要存在以下局限:

  • 任务不能充分交替推进:当前应用未结束时,其他应用通常难以获得执行机会。
  • CPU 利用率受 I/O 等待影响:当前任务等待设备,CPU 可能缺少可运行的工作。
  • 资源共享能力有限:CPU、内存和外设难以在多个应用之间进行灵活分配。
  • 管理机制较简单:一些早期系统的隔离、保护和资源管理能力也不完善,但这些并不是"单任务"必然决定的属性。

因此,统一硬件接口还不够。为了让资源得到更充分的利用,系统还需要让多个任务共同推进。

2.3 操作系统 2.0------多任务系统

从单任务走向多任务,可以沿着一条线理解:

多个任务共享 CPU → CPU 需要切换任务 → 切换前必须保存执行现场 → 系统需要管理每个任务的上下文、状态和资源。

下面依次把这些问题展开。

多任务

现在,系统里不再只有一个 App,而是同时存在多个需要运行的任务:

text 复制代码
App1          App2          App3
    \          |          /
             操作系统
                ↓
        CPU / 内存 / 磁盘

为了简化讨论,先假设机器只有一个 CPU 核心,而且不考虑硬件多线程。同一时刻,CPU 只能执行一个任务,但可以在不同任务之间交替运行:

text 复制代码
时间 ─────────────────────────────────→

CPU:| App1 | App2 | App3 | App1 | App2 | ......

从一段时间来看,各个任务都在向前推进;切换足够及时,用户就会觉得这些程序在"同时运行"。这种交替推进属于并发 。如果多个核心在同一时刻分别执行不同任务,则属于并行

多道程序设计的重要思路,是让一个任务的等待时间成为其他任务的执行机会:

text 复制代码
App1 运行
    ↓
App1 等待磁盘 I/O
    ↓
切换到已经就绪的 App2
    ↓
App2 等待网络数据
    ↓
切换到已经就绪的 App3

为了让需要交互的任务及时获得响应,分时系统还会利用时间片等机制,让持续计算的任务也适时让出 CPU。

这样可以减少有可运行任务时的 CPU 空闲时间,但任务切换本身也有开销,并不是切得越频繁越好。

此时最关键的问题就出现了:App1 离开 CPU 时,它执行到一半的状态怎么办?

CPU 上下文和上下文切换

假设某个程序正在计算:

c 复制代码
c = a + b;

用一个简化过程表示,CPU 可以先把参与运算的数据放入寄存器,再执行加法:

text 复制代码
内存中的数据
   a = 10,b = 20
          ↓ 读取
CPU 寄存器
   R0 = 10,R1 = 20
          ↓
       执行加法

实际指令会受编译优化和体系结构影响,变量也不一定始终存放在内存里。这里要理解的是:程序运行到某个位置时,CPU 寄存器中会保存当前执行所需的数据和状态。

例如:

text 复制代码
R0、R1 等通用寄存器:中间计算结果、地址等
PC:程序执行的位置
SP:当前栈顶位置
状态寄存器:条件标志等处理器状态

这些为了恢复执行而需要保留的 CPU 状态,就是任务的 CPU 上下文(CPU Context)

它回答了两个问题:程序运行到哪里了?接下来应该在什么状态下继续?

如果 CPU 要从 App1 切换到 App2,就不能直接让 App2 覆盖 App1 的寄存器状态。正确的做法是先保存旧现场,再恢复新现场:

text 复制代码
CPU 正在执行 App1
        ↓
把 App1 的寄存器状态保存到内存
        ↓
从内存恢复 App2 的寄存器状态
        ↓
CPU 从 App2 保存的位置继续执行

等 App1 再次获得 CPU 时,再把它原来的状态恢复回来。这里不需要等 App2 整个执行完,只需要等下一次调度选择 App1。

可以把这个过程理解成游戏存档:离开之前保存进度,再次进入时读取进度。不过,上下文切换通常不会复制整个进程的内存,而是保存、恢复必要的执行状态;跨进程切换时还可能涉及地址空间等状态的变化。

因此,上下文切换使任务能够从暂停的位置继续运行。

任务上下文管理

保存和恢复寄存器只是其中一部分。要真正管理多个任务,操作系统还需要知道:

  • App1 现在正在运行,还是等待某个事件?
  • 它之前的上下文保存在哪里?
  • 它的栈和其他内存在哪里?
  • 它打开了哪些文件,占用了什么资源?
  • 它什么时候可以再次参与调度?

因此,任务管理包含了一整套工作:保存上下文、恢复上下文、维护任务状态,以及管理任务使用的资源。

这时,只用一个"程序文件"已经不足以描述正在运行的任务。

例如,磁盘上的 demo 可执行文件是静态的,里面保存着代码和相关数据。启动之后,它还会拥有自己的运行环境:

text 复制代码
磁盘上的程序文件 demo
          ↓ 启动
一次运行实例
├── 代码和数据的内存映射
├── 栈等运行空间
├── 当前寄存器状态
├── 打开的文件
└── 当前运行状态

这就引出了进程(Process)

程序是静态的代码和数据,进程是程序的一次运行实例。操作系统通过进程组织和管理这次运行所需的资源与状态。

同一个普通命令行程序可以独立启动多次,形成多个进程。它们可以执行相同的代码,却拥有不同的进程 ID,以及各自的地址空间和执行状态。

为了便于入门,本文先按"一个进程只有一条执行流"来理解。在现代多线程系统中,进程主要承载资源,每个线程有自己的执行现场,CPU 通常调度内核可见的线程。这部分后续再展开。

栈与 SP(栈顶指针)

任务暂停以后,除了要保留 CPU 寄存器状态,还需要保留它原来的函数调用现场。其中很重要的一块内存,就是栈(Stack)

假设 App1 正在执行一串函数调用:

text 复制代码
A()
 └── B()
      └── C()

C() 执行完毕,程序需要知道该回到 B() 的什么位置;之后 B() 返回,也要能接着执行 A()。调用过程中的局部数据和必要的返回信息,都需要有地方保存。

栈通常就用于组织这些调用现场。概念上,可以这样理解:

text 复制代码
App1 的栈                     App2 的栈
┌──────────────────┐         ┌──────────────────┐
│ App1 的函数调用现场 │         │ App2 的函数调用现场 │
│ 局部数据、返回信息等 │         │ 局部数据、返回信息等 │
└──────────────────┘         └──────────────────┘
          ↑                            ↑
         SP1                          SP2

具体哪些局部变量、参数或返回信息放在栈上,要看调用约定和编译结果;其中一些信息也可能保存在寄存器中。

但基本原则很明确:独立执行的任务通常需要各自的栈空间,不能随意破坏其他任务尚未结束的调用现场。

如果 App1 正在 C() 中执行,此时切到 App2,App2 就应该使用自己的栈。等切回 App1,再恢复它对应的栈指针,继续使用原来的调用现场。

SP(Stack Pointer,栈指针)用来指示当前栈位置,也是需要保存和恢复的上下文之一。 栈的内容通常仍留在内存中,不必在每次切换时整块复制。

系统及相应运行库在建立执行环境时,会准备好初始栈、入口位置等信息:

text 复制代码
准备栈空间
    ↓
设置初始 SP
    ↓
设置程序入口和初始寄存器状态
    ↓
使任务具备运行条件
    ↓
获得 CPU 后开始执行

所以,在正常的应用开发中,程序员通常不需要手工初始化 SP,也不需要自己实现系统级的上下文切换。

这里的"自己的栈"强调的是分别维护调用现场。多线程进程中的线程虽然通常各有自己的用户栈,但它们共享进程地址空间,并不因此具有进程之间那样的内存隔离。

PCB

有了多个进程,操作系统还需要一份管理信息,记录每个进程是谁、当前状态如何,以及资源和上下文在哪里。

这就是 PCB(Process Control Block,进程控制块)

可以把 PCB 理解成操作系统为进程维护的一份档案:

text 复制代码
PCB(App1)
├── 身份信息:进程 ID(PID)等
├── 状态信息:就绪、运行、阻塞等
├── 调度信息:优先级、队列关系等
├── 内存信息:地址空间相关管理信息
├── 资源信息:打开的文件等
└── 执行上下文或其保存位置

于是,操作系统管理多个进程的关系可以概括为:

text 复制代码
                 操作系统
                    │
         ┌──────────┼──────────┐
         ↓          ↓          ↓
        PCB1       PCB2       PCB3
         │          │          │
         ↓          ↓          ↓
      进程 App1  进程 App2   进程 App3
      资源与状态  资源与状态   资源与状态

假设 App1 等待磁盘数据,系统准备切换到已经就绪的 App2,可以按下面的逻辑理解:

text 复制代码
App1 正在运行
      ↓
App1 因等待 I/O 而阻塞
      ↓
保存 App1 的执行现场,更新相关管理信息
      ↓
调度器选择已经就绪的 App2
      ↓
切换必要的运行环境,恢复 App2 的上下文
      ↓
CPU 继续执行 App2

等 I/O 完成,App1 会重新进入就绪状态,等待后续调度。

这里要区分三种基本状态:

状态 含义
运行(Running) 正在 CPU 上执行
就绪(Ready) 具备运行条件,正在等待 CPU
阻塞/等待(Blocked / Waiting) 等待 I/O、同步事件等,暂时不能继续执行

如果 App1 只是时间片用完,通常回到就绪状态;如果等待的数据还没到,则进入阻塞状态。被切走不一定意味着阻塞,被唤醒也不意味着立刻运行。

实际系统的数据结构名称和组织方式可能不同,但都需要某种机制记录并关联这些管理信息。

PCB 与上下文的关系

这两个概念很容易混在一起,可以从它们回答的问题来区分:

  • 上下文:任务暂停在哪里,恢复时需要什么执行状态?
  • PCB:操作系统怎样管理这个进程的身份、状态、资源和执行信息?

上下文关心恢复执行所需的现场,PCB 管理的信息范围更广。概念上可以表示为:

text 复制代码
PCB 所管理的信息
├── 进程身份
├── 进程状态
├── 调度信息
├── 内存与其他资源信息
└── 关联的 CPU 上下文
    ├── PC
    ├── SP
    ├── 通用寄存器
    └── 处理器状态等

因此,上下文属于进程管理信息的一部分,PCB 则保存或关联这些信息。

"关联"意味着寄存器值未必全部直接放在某一个 PCB 结构体中。根据系统实现,它们可能保存在内核栈、线程控制结构或其他内存区域,再通过相应结构找到。

总结

把前面的推导串起来,就能看出这些概念之间的联系:

text 复制代码
单任务系统
    ↓
任务等待 I/O 时,CPU 可能闲置
    ↓
引入多任务,让多个任务共享 CPU
    ↓
CPU 在任务之间切换
    ↓
为了继续执行,需要保存、恢复上下文
    ↓
为了管理一次运行,需要进程这一抽象
    ↓
独立执行流需要维护各自的栈和 SP
    ↓
操作系统通过 PCB 等结构管理资源、状态与上下文
    ↓
实现多个任务的调度和切换

这并不表示"只有多任务系统才有进程或栈",而是从多任务需求出发,更容易理解这些机制为什么重要。

到这里,可以先记住四句话:

  • 进程是程序的一次运行实例,用来组织这次运行的资源与状态。
  • 上下文是暂停后恢复执行所需的运行现场。
  • 栈与 SP帮助保留并继续使用各自的函数调用现场。
  • PCB是操作系统记录和关联进程管理信息的数据结构。
题外话:为什么操作系统还需要生态?(了解)

一个教学内核可以先只支持固定硬件,完成启动、简单调度、内存管理和基本 I/O。但要成为大量用户日常使用的系统,还需要适配不同的 CPU、显卡、网卡、存储设备和其他外设。

操作系统通常通过设备驱动、硬件抽象接口和统一的上层接口,把这些差异尽量集中到底层处理。

以 Android 这类平台为例,应用可以通过框架提供的接口使用摄像头、蓝牙等能力;底层再由系统服务、硬件抽象层和驱动等组件协同完成具体操作。应用不必为每一款手机重新实现一套硬件控制逻辑。

硬件支持之外,还需要开发工具、软件库、应用和兼容性保障。浏览器、办公软件、游戏以及开发者愿不愿意持续适配,也会直接影响系统是否好用。

所以,操作系统的成熟度既取决于内核能力,也取决于硬件支持和软件生态。

3. 从程序角度来看 OS

前面已经建立了操作系统与多任务的基本认识。接下来换一个程序开发的视角,简要看看这些机制如何出现在日常编程中。

3.1 操作系统面临的问题

即使只是一个输出 Hello World 的程序,也离不开几个问题:

问题 相关机制
源文件和可执行文件怎样保存、读取? 文件系统
程序启动后由谁执行,多个任务怎样共享 CPU? 进程、线程与调度
代码、数据和栈放在哪里,如何保护? 内存管理
输出内容怎样送到终端、文件或其他目的地? I/O 管理

这些问题可以归结为操作系统学习的几条主线:管理 CPU、管理内存、组织文件,以及管理 I/O。 多个任务需要协作时,还会进一步涉及同步与互斥。

至于"C 源码怎样变成 CPU 能执行的指令",主要由编译工具链完成;操作系统则负责建立和管理程序的运行环境。

3.2 编译器的作用

编译器与操作系统的关系

编译工具链通常面向特定的目标环境,生成符合目标 CPU 指令集、操作系统及 ABI 要求的程序。ABI 可以先理解为程序之间进行二进制交互时需要遵守的约定。

因此,编译得到的可执行文件不能随意复制到任意机器上运行,还要考虑架构、文件格式和运行库等条件。裸机开发也有对应工具链,但需要开发者提供相应的启动代码和运行支持。

GCC 的编译过程

常见的处理流程是:

text 复制代码
源代码 → 预处理 → 编译 → 汇编 → 链接 → 可执行文件

hello.c 为例,下面几条命令可以分别观察中间产物:

bash 复制代码
# 预处理:处理头文件包含、宏等
gcc -E hello.c -o hello.i

# 编译:生成目标体系结构的汇编代码
gcc -S hello.i -o hello.s

# 汇编:生成目标文件
gcc -c hello.s -o hello.o

# 链接:组合目标文件与所需库
gcc hello.o -o hello

上面的 -E-S-c 控制工具链停在哪个阶段,具体定义见 GCC:Overall Options。还可以使用 file hello 查看产物类型,使用 nm hello.o 查看目标文件的符号表。

链接负责组合目标文件、处理符号和地址关系;程序启动后的地址空间管理与内存保护,则由操作系统等运行机制负责。普通 C 程序通常通过 gcc 驱动链接,以便自动补充适当的启动文件和库。

总结

这部分先记住分工即可:工具链把代码转换并组织成目标平台的程序文件,操作系统建立运行环境、分配资源,并管理它的执行。

如果这篇文章对你有帮助,欢迎点赞、评论、关注、收藏。你们的支持是我前进的动力!

相关推荐
迪丽热爱2 小时前
多媒体应用18-901(补)
学习
流浪0012 小时前
Linux系统篇36——线程(一) 线程的概念、本质和Linux的实现方式
linux·面试·操作系统·线程
传奇开心果编程2 小时前
【Rust入门练中学】第4课:函数与所有权入门
开发语言·学习·rust
sunoo-2293 小时前
【ARM嵌入式学习笔记Day5】一文打通GPIO中断全链路:从硬件引脚→GIC→汇编入口→C语言处理
arm开发·笔记·学习·中断·gic·裸机开发
m4Rk_3 小时前
【论文阅读】Agent 记忆机制(76):SEEM——从碎片检索到完整事件重建
论文阅读·人工智能·学习·开源·github
我命由我123453 小时前
Mac 操作系统 - 一些使用记录
运维·windows·学习·系统安全·运维开发·mac·学习方法
千谦阙听3 小时前
【 C++篇】:模板初阶——泛型编程、函数模板与类模板
开发语言·c++·学习·visual studio
imDwAaY3 小时前
消息队列四大核心问题:顺序性、幂等性、可靠性与一致性
学习·kafka·rabbitmq
老王爱玩车3 小时前
第6讲:数组和函数实践-----控制台扫雷
c语言·开发语言·学习