操作系统内部布局回顾

内存布局详细解析

img

进程概念

进程的重新定义

首先,第一个核心概念:

进程实际上是在执行流(Execution Flow)概念封装下,承担系统资源分配的基本实体。

重新定义进程:

进程是操作系统进行系统资源分配的基本实体。

那么进程如何承担资源分配?

它依靠的是操作系统内核中的相关数据结构。

例如:

  • PCB(Process Control Block,进程控制块);
  • 地址空间(Address Space);
  • 页表(Page Table);
  • 文件描述符表(File Descriptor Table);
  • 信号相关结构;
  • 其他内核管理结构。

这些资源管理结构,以及进程对应的:

  • 代码;
  • 数据;

共同组成:

进程资源(Process Resources)。

##进程资源与地址空间

进程拥有的资源最终通过:

地址空间 + 页表映射关系

实现对资源的访问。

例如:

  • 代码区;
  • 数据区;
  • 堆区;
  • 栈区;
  • 共享区域;
  • 动态库映射区域等。

也就是说,一个进程能够访问哪些资源,本质上由它自己的地址空间以及页表映射关系决定。


每一个进程都有自己的:

  • PCB;
  • 地址空间;
  • 页表。

其中 Linux 内核中的 PCB 对应的数据结构为:

img

task_struct

进程地址空间

每个进程都有独立的虚拟地址空间。

一个进程的虚拟地址空间通常划分为:

  • 用户空间(User Space):0~3GB
  • 内核空间(Kernel Space):3~4GB

其中用户空间主要包括:

  • 代码区(Text Segment)
  • 已初始化全局数据区(Data Segment)
  • 未初始化全局数据区(BSS Segment)
  • 堆区(Heap)
  • 栈区(Stack)
  • 共享库映射区等

内核空间部分在信号、用户态与内核态切换等内容中已经涉及。

虚拟地址与物理地址映射

一个进程中的所有地址实际上都是:

虚拟地址(Virtual Address)

虚拟地址经过页表转换后,最终映射到:

物理地址(Physical Address)

其中:

  • 虚拟地址空间属于进程视角;
  • 物理内存属于硬件实际存储;
  • 页表负责维护虚拟地址到物理地址的映射关系。

实际系统中页表通常采用多级页表结构。

简化理解:

虚拟地址
    |
    ↓
多级页表
    |
    ↓
物理地址
    |
    ↓
物理内存

物理内存进一步划分为固定大小的页框(Page Frame),通常大小为:

4KB

因此:

  • 代码区;
  • 数据区;
  • 堆区;
  • 栈区;

这些虚拟地址区域最终都会通过页表映射到物理内存中的不同位置。

地址转换过程

例如进程访问某个变量:

  1. 获取变量对应的虚拟地址;
  2. CPU 中的 MMU(Memory Management Unit,内存管理单元)查询页表;
  3. 完成虚拟地址到物理地址转换;
  4. 访问对应物理内存。

过程如下:

虚拟地址
    |
    ↓
MMU + 页表
    |
    ↓
物理地址
    |
    ↓
物理内存

软硬件结合的地址转换机制

虚拟地址转换为物理地址,并不是单纯依靠软件完成,而是采用:

软件维护 + 硬件执行

的方式。

其中:

  • 页表属于软件维护的数据结构;
  • MMU 属于硬件模块,负责地址转换。

为什么采用软硬件结合?

因为:

硬件优势:
  • 执行速度快;
  • 可以通过电路直接完成固定逻辑操作。

但是:

  • 灵活性较低;
  • 修改成本高;
  • 扩展困难。
软件优势:
  • 易维护;
  • 易扩展;
  • 可以动态调整。

但是:

  • 执行速度较慢。

因此现代计算机系统采用:

软件负责维护页表,硬件 MMU 负责完成虚拟地址到物理地址的快速转换。

##完整进程组成

一个完整进程除了包含:

  • PCB;
  • 地址空间;
  • 页表;
  • 代码和数据;

还包括:

  • 系统调用;
  • 文件描述符表;
  • 信号机制;
  • 父子进程关系;
  • CPU 调度;
  • 上下文切换;
  • 进程状态;
  • 优先级等。

进程的运行过程

在线程学习之前,对进程的定义:

进程 = 内核数据结构 + 代码和数据

一个程序最开始存储在磁盘中。

当程序运行时:

  1. 操作系统通过加载机制(例如 ELF 文件加载)将程序加载到内存;
  2. 创建对应的 PCB;
  3. 建立进程虚拟地址空间;
  4. 创建页表,完成虚拟地址到物理地址的映射;
  5. CPU 根据调度策略执行该进程。

可执行程序在编译阶段已经按照虚拟地址空间布局生成。

因此加载时,操作系统主要完成:

  • 创建进程控制结构;
  • 建立地址空间;
  • 建立页表映射;
  • 加载代码和数据。

最终:

CPU 调度该进程执行,完成程序运行。


线程概念与进程关系

当我们理解线程这个概念时,首先需要思考:

线程和进程之间是什么关系?

这里研究:

Linux 操作系统中的多线程实现机制。

不同操作系统对于线程底层实现方式可能不同。

类似于:

不同学校都有:

  • 学生;
  • 老师;
  • 校长。

但是不同学校的管理方式不同。

同样:

不同操作系统都有线程概念,但是底层实现机制可能不同。

Linux 中的线程实现具有自身特点,但最终满足操作系统理论中线程的基本定义。

Linux 中线程实现方式

之前创建子进程时,例如调用:

fork()

操作系统需要:

  1. 创建新的 PCB;
  2. 创建新的地址空间;
  3. 创建新的页表;
  4. 通过写时复制(Copy On Write)机制共享父进程代码和数据。

结构如下:

父进程
 |
 +-- PCB
 +-- 地址空间
 +-- 页表
 +-- 代码数据


子进程
 |
 +-- PCB
 +-- 地址空间
 +-- 页表

虽然父子进程初始可以共享代码和数据,但是它们拥有独立的资源管理结构。

重新理解进程资源

一个进程真正拥有的核心资源包括:

  • 代码区;
  • 已初始化数据区;
  • 未初始化数据区;
  • 堆区;
  • 栈区;
  • 共享区;
  • 动态库映射区域等。

这些资源主要由:

虚拟地址空间 + 页表

决定。

其中:

虚拟地址空间决定:

一个进程能够看到哪些资源。

可以将进程理解为:

  • 房间 = 虚拟地址空间;
  • 窗户 = 访问资源的入口;
  • 窗外景象 = 进程能够访问的数据和代码。

一个进程能够看到什么,本质上由它的地址空间决定。

Linux 线程实现模型

如果不创建新的地址空间,而只是创建多个 PCB:

PCB1
 |
 |
地址空间A


PCB2
 |
 |
地址空间A


PCB3
 |
 |
地址空间A

多个 PCB 共享同一个地址空间。

那么:

  • 每个 PCB 都可以访问同一份代码和数据;
  • 每个 PCB 可以执行不同的代码区域。

此时:

同一个进程内部存在多个执行流。

因此:

Linux 中线程的本质可以理解为:

线程就是进程内部的一个执行流。

具体实现:

Linux 线程并不是传统意义上的独立资源实体,而是创建独立的任务控制结构(task_struct),共享所属进程的地址空间、页表以及大部分资源。

线程栈与 mmap

为什么子线程栈存放在 mmap 区域?

因为:

除主线程之外,其他动态创建的线程,其线程栈通常通过 mmap 系统调用动态分配。

传统栈区限制

经典进程虚拟地址空间中:

  • 栈区默认只有一份;
  • 位于地址空间顶部;
  • 服务于主线程。

主线程栈具有:

  • 动态增长;
  • 缺页异常自动扩展。

但是多线程情况下:

每个线程都必须拥有独立栈空间,用于保存:

  • 局部变量;
  • 函数调用信息;
  • 返回地址;
  • 寄存器上下文。

否则多个线程会互相覆盖栈数据。

因此:

子线程不能继续使用传统栈区。

mmap 作为线程栈的原因

① 动态创建和销毁方便

线程运行期间可能动态创建:

例如:

pthread_create()

使用 mmap 创建匿名映射区域:

  • 可以向内核申请独立虚拟空间;
  • 可以指定大小;
  • 线程结束后使用 munmap 回收。

管理更加方便。

② 支持保护页机制

为了防止线程栈溢出影响其他线程:

Linux 会在线程栈附近设置:

Guard Page(保护页)

例如:

高地址

+----------------------+
| 子线程A 栈空间        |
+----------------------+
| Guard Page           |
+----------------------+
| 子线程B 栈空间        |
+----------------------+

低地址

保护页权限通常设置为:

PROT_NONE

没有读写权限。

当线程栈溢出访问保护页:

  1. CPU 检测非法访问;
  2. 触发异常;
  3. 内核捕获;
  4. 产生 Segmentation Fault。

避免破坏其他线程数据。

③ 子线程栈大小固定

主线程:

  • 启动时由内核建立;
  • 位于传统 Stack 区;
  • 可以动态扩展。

子线程:

  • 创建时通过 mmap 分配;
  • 栈大小提前确定;
  • 通常默认 8MB;
  • 可以通过线程属性修改。

因此:

子线程栈不能像主线程一样自由增长。

进程与线程总结

进程:

  • 拥有独立 PCB;
  • 拥有独立地址空间;
  • 拥有独立页表;
  • 是系统资源分配的基本单位。

线程:

  • 拥有独立 task_struct(Linux 中线程控制结构);
  • 共享所属进程的地址空间;
  • 共享代码段、数据段、堆等资源;
  • 拥有独立栈空间;
  • 是 CPU 调度的基本单位;
  • 是进程内部的执行流。

最终:

一个进程可以包含多个线程,每个线程拥有自己的执行上下文,但共享同一个进程资源。

线程重新定义

当进程完成资源分配后,可以进一步思考:
是否可以将进程中的部分资源,分配给某一个执行流?
答案:
可以。
因此:

线程属于进程内部的一个执行流,是 CPU 调度的基本单位。

两个核心概念:


进程:

承担系统资源分配的基本实体。


线程:

进程内部的执行流,是操作系统调度的基本单位。


Logo

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

更多推荐