第三章 硬件环境与软件抽象

第三章 硬件环境与软件抽象

操作系统作为一种特殊的软件,同样是由大量的指令组成,但是操作系统拥有更高的权限,能够支配所有的资源,而普通的应用程序只能支配部分硬件资源。

操作系统有能力管理不同的应用程序,还可以提供硬件资源的抽象,并通过系统调用的方式为应用程序提供服务。

3.1 应用程序的硬件运行环境

操作系统的主要任务之一是管理应用程序,当应用程序运行在硬件上时,硬件会保存很多应用程序的状态。在应用程序的启动、切换以及退出时,操作需要将保存在硬件上的状态就行初始化、保存/恢复、清除等。

3.1.1 程序的运行:用指令序列控制处理器

  1. 为什么硬件不能运行 C 语言的源代码?
  2. 同样一份源代码,在 Linux 下编译出的二进制程序,放到 Windows 上是否能运行呢?
  3. 二进制程序在硬件中究竟是如何执行的?

带着上述问题开始下边的学习。

从 C 语言代码到汇编指令再到机器指令

高级语言的表达能力是很强的,如果想让处理器直接理解和执行高级语言代码,就需要在处理器内部实现解析这些高级语言语义的逻辑,会带来难以想象的硬件复杂读,使得处理器的设计和体积显著增大。

鉴于上述问题,硬件设计者选择让处理器支持一套格式相对固定、功能相对简单、通常采用二进制编码的机器指令(Machine Instruction)。通过指令的有机组合来实现复杂高级语言中的复杂语义。

指令的设计是面向处理器的,作为高级语言的使用者:程序员,是不容易理解指令的。处理器设计者为每一条机器指令设计了与其唯一对应的汇编指令(Assembly Instruction)

从源代码到机器指令的过程

  1. 编译工具将文本格式的高级语言代码编译为汇编指令的序列
  2. 再将汇编指令序列汇编为机器指令的序列

不同的编译工具可能会将同一份代码编译为不同的汇编指令序列,但是一份汇编程序一般都只能编译成一份机器程序。

程序员可以用gcc hello.c -o hello将上述两次翻译的过程隐藏。

程序控制硬件的接口与规范:指令集架构(ISA)

应用程序对计算机硬件的控制都是通过发送相应的机器指令给处理器来执行来实现的。

这些机器指令的格式、行为及处理器在执行中的状态又一个规范,成为处理器的指令集架构(Instruction Set Architecture, ISA)

ISA 是处理器给应用程序提供的接口,程序员面对 ISA 编程,无须关注处理器的实现细节。

处理器制造商同样按照 ISA 实现,无须关注上层运行的什么软件。

程序计数器与指令的执行顺序

处理器运行应用程序的流程

  1. 处理器从可执行程序中取出第一条指令,根据 ISA 的规范解析这条指令并执行相应操作。
  2. 取出下一条指令进行重复操作。

每次执行完一条指令后,处理器如何知道接下来该执行那一条指令?顺序执行跳转执行

顺序执行

程序计数器(Program Counter, PC)用于记录即将执行的下一条指令在程序中的位置。在应用程序执行之前,操作系统会将其可执行文件加载到内存中,并将可执行文件在内存中的起始位置记录到 PC 中。当处理器完成一条指令后,会将该指令的长度加上当前 PC 的值,使 PC 指向下一条指令。

跳转执行

ISA 提供了一种将 PC 值修改为由程序指定的目标位置的方式。

3.1.2 处理数据:寄存器、运算和访存

与数据处理相关的硬件状态:

  1. 用于暂存数据的寄存器
  2. 与处理器通过总线相连的内存

寄存器:处理器内部的高速存储单元

寄存器是处理器内部的存储单元,访问速度快、容量小。

寄存器主要分为两大类:

  1. 通用寄存器:可以存储任意数据,在 AArch64 架构下有 31 个通用寄存器。
  2. 特殊寄存器:用来保存一些特定的数据,比如程序计数器、用来存放栈顶位置的栈寄存器、用来存放条件码的寄存器等

通用寄存器如下图所示

未命名文件 (2)

处理器内部的数据运算和移动

编译程序会将高级语言的代码拆解成移位、按位异或和乘法这样的基本运算,每个基本运算都由一条数据处理指令完成。

这类指令使用寄存器中存储的源数据进行算数运算或逻辑运算,并将结果存储到另一个寄存器中。

// 将 w0 和 w1 两个寄存器存储的值相加,重写保存到 w0 寄存器中
add w0, w0, w1

AArch64 指令集中常见的数据运算指令

指令 指令描述
add 加法运算
sub 加法运算
mul 乘法运算
div 除法运算
neg 取相反数
and 按位与
orr 按位或
eor 按位异或
mvn 按位取反
asr 算术右移
lsl 逻辑左移
lsr 逻辑右移
ror 循环右移
mvn 数据移动

立即数和修改过的寄存器

mov w1, 48879 中的 48879 就是一个立即数。AArch 64 汇编程序中的立即数可写为一个单独的数值,也可写为一个“#”符号后跟一个数值。

eor w0, w0, w0, asr 16 中的 w0, asr 16 部分在 AArch64 架构中被称为修改过的寄存器,代表将 w0 寄存器的值向右移 16 为后的结果作为 eor 指令的源数据之一。

处理器视角下的内存

处理器在运算之前需要将数据从内存加载到寄存器,计算完成之后再将结果从寄存器写回内存。ISA 需要提供内存的加载指令存储指令,分别实现内存的读取与写入功能,合成为访存指令

AArch64 指令集中常见的访存指令

指令 指令描述
lrd 从内存加载数据到寄存器
ldp 从内存加载数据到两个寄存器
str 将寄存器中的数据存储到内存
stp 将两个寄存器中的数据存储到内存

从处理器视角来看,内存就是一个很大的数组,数组中的每个元素为 1 字节,数组的索引成为内存地址

处理器解析指令得到地址,并根据地址在内存中找到对应的数据,这个过程成为寻址

在 AArch 64 架构中,有两种寻址模式,两种寻址模式都涉及基地址(base)偏移量(offset)

  1. 偏移量寻址:[base, offset],以基地址和偏移量之和作为目标内存地址。
  2. 索引寻址:
    1. 前索引寻址:[base, offset]!将基地址作为目标内存地址,在寻址操作前将基地址的值更新为基地址与偏移量之和。
    2. 后索引寻址:[base], offset 将基地址作为目标内存地址,在寻址操作后将基地址的值更新为基地址与偏移量之和。

3.1.3 条件结构:程序分支和条件码

在 ISA 的支持下,程序可以通过判断一个条件是否满足来决定是否需要跳转到另一条指令的位置,从而实现条件分支和循环等复杂程序设计中不可或缺的结构。

ISA 对跳转到目标代码位置的实现是程序分支,对条件是否满足的实现是条件码

分支目标与分支指令

C 语言代码

int power(int x, unsigned int n) {
    int result = 1;
    for (unsigned int i = n; i > 0; i--) {
        result *= x;
    }
    return result;
}

对应的汇编代码

power:
        mov     w2, w0
        mov     w0, 1
        cbz     w1, .L1
.L3:
        mul     w0, w0, w2
        subs    w1, w1, #1
        bne     .L3
.L1:
        ret

除了函数名 power 之外,还有两个标签 .L3 和 .L1(标签用于定位)。这些标签在汇编语言专门用来定位某处汇编代码或数据的位置。这些标签在汇编程序到二进制程序的转变中会被翻译为真实的地址。

.L3 指示的是 mul w0, w0, w2 指令的地址,.L1 指示的是 ret 指令的地址。

有了用于定位的标签之后,还需要能够跳转到标签位置的分支指令。在上述汇编代码中一共有两个分支指令

  1. bne .L3,它根据前一条指令执行的状态来判断自己的条件是否满足,如果满足,则跳转。bne 代表当运算结果与零不相等时跳转。
  2. cbz w1, .L1 根据本条指令内置计算的执行状态而非前一条指令的执行状态进行判断。在 w1 的值为零时会跳转到 .L1。

条件判断与条件分支

对于 bne 这种没有内置计算功能的指令而言,上一条指令的执行状态是如何传达给它的呢?

一种简单的想法是为每一种可能的条件设置一个寄存器(或某个寄存器中的一位),并根据上一条指令的执行状态设置这些寄存器(某位)。

ISA 也确实是这样设计的,只不过会将复杂的条件表示为寥寥几个特征的组合,这样就只需要记录每次条件计算是否表现出这些特征,大大减少了条件计算的开销。这些特征统称为条件码(Codition Flag),一般会被实现为记录程序运行状态的状态寄存器中的若干位。

条件码一般只包含四位:

  1. 计算结果的正负
  2. 计算结果是否为零
  3. 计算是否产生进位或借位
  4. 计算是否产生有符号溢出。

在 AArch64 架构中,这四种条件码分别称为 N(Negative)、Z(Zero)、C(Carry)、V(Overflow)

常见的分支条件及对应的条件码

条件 含义 对应的条件码
EQ 相等 Z = 1
NE 不等 Z = 0
MI 负数 N = 1
PL 非负数 N = 0
HI 无符号大于 C = 1 且 Z = 0
LO 无符号小于 C = 0
LS 无符号小于或等于 C = 0 或 Z = 1
GE 有符号大于或等于 N = V
LT 有符号小于 N != V
GT 有符号大于 Z = 0 且 N = V
LE 有符号小于或等于 Z = 1 或 N != V

算术逻辑运算指令只有加上 s 后缀才会更新条件码的值。比如 adds、subs等

AArch64 中的分支指令

分支指令可以分为两种:直接分支指令与间接分支指令。

直接分支指令以标签对应的地址作为跳转目标,又进一步分为无条件分支指令 b 和条件分支指令 bcond(cond 代表具体的条件,例如 beg)。

间接分支指令 br 则以寄存器中的地址作为目标地址。

3.1.4 函数的调用、返回与栈

函数的调用与返回意味着控制流在调用者与被调用者之间的交接。ISA 会提供专门的函数调用指令返回指令,函数在运行过程中需要占用部分内存来存放调用参数局部变量

ISA 采用运行时栈为每个函数的实例分配可供临时使用的内存,并在函数实例返回后释放内存。

函数的调用指令和返回指令

在函数的调用过程中,调用者需要知道被调用者第一行代码的位置。被调用者也必须知道调用者调用代码的位置。对于前者,只要一个标签即可,在最终运行时,跳转执行到标签翻译过来的内存地址即可。

对于后者,比较复杂,同一个函数可能被不同的调用者调用,被调用者无法在实际被调用之前确定调用代码的位置。(因为在调用者的代码中,被调用函数可能出现在代码中的任何位置)

因此,处理器需要在发生函数调用时,将调用指令的下一条指令地址保存在某个位置,从而在被调用者返回时,可以从该位置取出地址以继续执行,这个地址被称为返回地址

函数调用指令需要完成两个任务:跳转到目标函数和保存返回地址

不同的架构保存返回地址的方式不尽相同

  1. x86架构:函数调用指令(call)会直接将返回地址保存到内存,返回指令(ret)会从同一个内存位置读取返回地址,并跳转到返回的代码处。
  2. Aarch64 架构:函数调用指令(bl)会将返回地址保存到一个特定的寄存器中,返回指令(ret)会该寄存器读取返回地址,并跳转到返回的代码处,这个寄存器就是返回地址寄存器。为通用寄存器的 X30 寄存器,别名 LR(Link Register)

如果被调用函数在执行过程中又调用了其他函数,那么 LR 寄存器的值就会被覆盖,这样就无法完成第一个函数的执行了,如何解决?

编译器会在每个函数调用指令之前和之后生成额外的代码,分别完成“将 LR 的旧值保存到内存”和“将内存中保存的值恢复到 LR 中”。

如果一个函数没有函数调用的过程,就无须保存和恢复 LR 的值。

运行时栈:保存函数中的局部状态

函数的调用可以嵌套,程序执行过程中往往存在多个未返回的函数,这些函数占用的内存区域被按照调用书序排列在一起,新被调用的函数从这个序列尾部申请内存,已经返回的函数释放自己占用的内存。

这些排列好的内存区域事实上可以被看做栈结构,其中先被调用的函数对应的内存区域更高栈底,也就更晚出栈。我们将这个结构成为运行时栈函数栈

处理器一般会设置一个专门的栈寄存器来存放指向栈顶的指针——栈指针(Stack Pointer, SP),它指示着当前运行时栈的大小。大于栈指针的地址已经分配给某个函数了,小于栈指针的地址是未分配的。(运行时栈一般从高地址向地地址增长)。

在运行过程中,函数可以通过增大与减小栈指针的值分别在站上分配与释放内存。

每个函数在栈上拥有的连续内存空间成为函数的栈帧(Stack Frame)。当前函数的栈帧在内存中的起始位置称为栈指针(Frame Pointer, FP),一般由一个寄存器来专门保存,AArch64 架构将 FP 保存在 X29 通用寄存器中。

编译器在生成函数的汇编代码时,会分别向函数的开头与末尾插入创建栈帧与释放栈帧的代码 。

FP 栈指针的作用?

知道了一个函数的栈帧位置,就可以通过偏移量寻址来访问这个函数管理的全部局部状态。

每个调用者的 FP 都被保存在被调用者的栈帧中,FP 可以被用于实现栈追踪

站追踪在程序调试与漏洞修复中是非常重要的信息。

考虑到保存和恢复 FP 需要额外的访存开销,而 FP 在程序的运行中又只起到帮助调试的功能,很多追求性能项目会通过配置编译器来禁用 FP。

LR 和 FP 寄存器在处理器中都只有一份,因此每个函数都需要在覆盖这些值之前保存他们,并在自己声明周期结束签前(即返回前)恢复它们。

3.1.5 函数的调用惯例

由于调用者和被调用者公用一组通用寄存器,它们还需要协调寄存器的分配,并在必要的时候对使用中出现冲突的寄存器进行保存和恢复。

调用者和被调用者之间应当如何协调并不是用硬件 ISA 决定的,而是由软件的调用惯例来规范的。

探讨两个比较重要的约定:

  1. 调用前后通用寄存器应当由谁来保存
  2. 参数与返回值如何在调用者与被调用者间传递

寄存器保存:位置与方式

为防止调用者存放在通用寄存器中的值被覆盖,调用惯例通常将通用寄存器划分为两部分,一部分由调用者保存,另一部分由被调用者保存。

在 AArch64 架构中,X9~X15 由调用者保存,X19 ~ X28 由被调用者保存。

数据传递:参数和返回值

在函数调用期间,调用者和被调用者双方进行数据交换的方式主要有两种:调用者向被调用者传递参数,被调用者返回返回值

使用寄存器完成参数和返回值的传递,在AArch64 架构中分别用 X0~X7 来传递前 8 个参数,并用 X0 传递返回值,如果有更多的参数则会保存到栈上(这部分称为参数构造区)。

完整的栈帧结构

每个函数除了在栈帧中保存 FP 和 LR 的旧值外,还会利用栈帧中的不同区域来分别存储调用时传递的实际参数、函数内部分配的局部变量、需要保存的寄存器的值等。

栈帧结构

栈.drawio

并非所有的函数都需要栈帧。

3.1.6 小结:应用程序依赖的处理器状态

应用程序在执行的过程中,由于某些原因(比如时间片运行结束,需要访问硬件设备等)会切换到操作系统执行,那么在操作系统开始知心前,这些在处理器中属于应用程序的状态必须保存起来,等操作系统运行结束再恢复,从而继续应用程序的运行。

栈的增长方向

大部分的栈都从高地址向低地址增长,这种设计是为了充分利用内存空间。应用程序能够动态分配的内存主要分为两类:栈内存和堆内存。为了让栈和堆可以共享同一个大的内存池,操作系统往往会让栈和堆从一块内存区域的两端向中间增长。

但是这样的设计又会带来安全问题。会出现缓冲区溢出的安全问题

如果应用程序在栈上分配了一块缓冲区并向其写入数据,一旦写入的数据量超过了缓冲区的大小,超出的部分就会覆盖栈上的其他数据,这就是缓冲区溢出(Buffer Overflow)。由于栈的增长方向是从高到低的,与缓冲区溢出的方向相反,因此攻击者可以通过溢出来覆盖返回地址(因为当前函数的返回地址会被保存在比缓冲区更高的地址),使函数返回时跳转到恶意代码处,实现控制流劫持攻击。

3.2 操作系统的硬件运行环境

操作系统的硬件运行环境是应用程序硬件运行环境的超集,除了常见的运算、访存、函数调用等基本功能外,还包括一些只有操作系统才有权限执行的功能。

为了区分操作系统和应用程序的不同运行环境,现代 CPU 通常会提供不同的特权级

3.2.1 特权级别与系统 ISA

允许应用程序能够使用所有的硬件资源会出现什么问题?

  1. 某个应用程序错误的执行了 CPU 重置指令,导致所有应用的状态丢失
  2. 两个应用配合失误,同时向内存或磁盘的同一个位置写入数据,则会导致其中一份数据的丢失。
  3. 如果应用程序运行了一个恶意程序,会给整个计算机带来灾难

简要结构下的操作系统就没有区分特权级别。

CPU 为应用程序和操作系统提供了不同的特权级别:用户态内核态

ISP 作为 CPU 向软件提供的接口,也对应分为用户 ISA系统 ISA

系统 ISA 包含系统状态、系统寄存器与系统指令。系统状态包括当前 CPU 的特权级别、CPU 发生错误时引发错误的指令地址、程序运行状态。存储这些系统状态的寄存器成为系统寄存器,这些寄存器只能由运行在内核态的软件通过系统指令来访问。

操作系统并不一定运行在内核态,UNIX 把操作系统分为“内核”和“外壳”(shell)两部分,内核运行在内核态,shell 运行在用户态并通过命令行和用户进行交互。

由于内核态拥有计算机各种硬件资源的最高控制权,如果内核存在 bug 或者被攻击者控制,后果非常严重。因此,操作系统的设计者往往选择将部分功能放到用户态,从而减少内核的代码。(微内核架构)

案例分析:ARM 的特权级别和系统 ISA

AArch64 中的特权级别也被称为异常级别,分为 4 个级别。

  1. EL0:用户态,应用程序的运行级别。
  2. EL1:内核态,操作系统的运行级别。
  3. EL2:用于虚拟化场景,虚拟机监视器运行在该级别。
  4. EL3:与安全特性 TrustZone 有关,负责普通世界和安全世界之间的切换。

当 CPU 在内核态运行用户 ISA 时,一般会使用用户 ISA 的寄存器。这也是为什么从用户态切换到内核态时,首先需要将用户态寄存器的值保存到内存。

系统寄存器负责保存硬件系统状态,以及为操作系统提供管理硬件的接口。

CPU 在执行相关指令前会根据 PSTATE 中的特权级状态来判断执行的指令是否合法。

AArch64 中的常用寄存器在 EL0 和 EL1 特权级下的可见情况

寄存器 寄存器 EL0 EL1 描述
通用寄存器 X0 ~ X30
特殊寄存器 PC 程序计数器
SP_EL0 用户态栈寄存器
SP_EL1 内核态栈寄存器
PSTATE 状态寄存器
系统寄存器 ELR_EL1 异常链接寄存器
SPSR_EL1 已保存的程序状态寄存器
VBAR_EL1 异常向量表基地址寄存器
ESR_EL1 异常症状寄存器

PSTATE寄存器中仅有 NZCV 四个条件码在 EL0 下可见

3.2.2 异常机制与异常向量表

在提供多个特权级别的同时,CPU 还需要提供在不同特权级之间切换的机制。

用户态与内核态的切换使用的是异常机制。从用户态切换到内核态的过程成为下陷

在内核中处理异常情况的代码通常称为异常处理程序

虽然“异常”是从应用程序的角度看的,但实际上 CPU 在内核太运行代码时也可能出现异常事件,同样也会由异常处理程序处理。

触发 CPU 异常机制的事件主要有三件:

  1. 中断:在用户程序正常执行的过程中,CPU 可能收到一些来自外部事件,不得不使 CPU 下陷到内核态交由操作系统处理。
  2. 异常:异常来自与 CPU 内部。程序在运行过程中可能会遇到一些自身无法处理的问题。这些事件同样会触发 CPU 下陷,操作系统处理完成后,应用程序继续执行。若操作系统无法处理,则可强制终止程序。
  3. 系统调用:用户主动触发异常,向操作系统发出执行特定操作的请求,CPU 通常会为这种情况提供一条特殊指令(svc)。系统调用也是发生于 CPU 内部,所以系统调用也可以被看做异常的一种。

当系统发生异常事件导致“下陷”到内核态时,CPU 只允许从固定的入口开始执行。

操作系统需要提前将代码的入口地址“告诉”处理器。

不同类型的异常事件,CPU 通常支持设置不同的入口。这些入口通常以一张表的形式记录在内存中,也就是异常向量表。在系统启动后,操作系统会将异常向量表的内存地址写入 CPU 上的一个特殊寄存器——异常向量表基地址寄存器

在计算机系统初始化完成之后,操作系统之所以需运行,是因为发生了异常事件需要处理。即,如果应用程序运行一切“正常”,没有发生任何“异常”事件,那么操作系统就没有机会运行。

如果有一个应用程序运行while(true) 的空循环,没有任何系统调用,也不会出错,是不是就会独占 CPU,使操作系统永远没有机会运行呢?

不是的。还有中断。操作系统在运行应用程序之前,会先设置好硬件时钟以某个固定的频率产生中断,从而保证在应用云心一定时间之后,一定会通过异常机制回到操作系统。

通过中断,操作系统牢牢地把握住了运行的主动权。

3.2.3 案例分析:ChCore 启动与异常向量表初始化

在计算机启动时,执行的第一段代码其实并非ChCore(操作系统)代码,而是主板中的固件和 bootloader,进行基本的初始化工作,然后才是 ChCore(操作系统)代码。

为什么操作系统内核的启动代码要用汇编语言而非 C 语言编写?

  1. 系统 ISA 的许多指令在 C 语言中并没有对应的语句。
  2. C 语言所依赖的运行时栈在系统刚启动的阶段尚未建立起来,SP 寄存器也没有初始化,所以现阶段无法运行 C 语言的函数调用。

3.2.4 用户态与内核态的切换

在用户态与内核态的切换过程中,有许多任务需要做。这些任务大致归为两类:

  1. 保存用户程序的状态:用户与操作系统共同使用、可能被操作系统覆盖的我处理器状态。
  2. 准备操作系统的运行环境。
    1. 为了处理异常事件,需要准备许多与异常事件相关的信息,如异常事件的种类、触发事件的指令地址等
    2. 由于操作系统使用的栈不同于应用程序使用的栈,因此要切换栈。
    3. 根据不同的异常事件找到对应的异常处理函数地址,并跳转过去执行。

处理器在特权级切换过程中的任务

  1. 将发生异常事件的指令地址(PC寄存器中的值)保存在 ELR_EL1(异常链接寄存器) 中。
  2. 将异常事件的原因保存在 ESR_EL1(异常症状寄存器)中。
  3. 将处理器的当前状态(即 PSTATE 寄存器中的值)保存在 SPSR_EL1 (已保存的程序状态寄存器)中。
  4. 保存与特定异常相关的信息。
  5. 栈寄存器不再使用 SP_EL0,开始使用 SP_EL1。
  6. 修改 PSTATE 寄存器中的特权级标志位,设置为内核态。
  7. 根据 VBAR_EL1 寄存器中保存的异常向量表基地址,以及发生异常事件的类型,找到异常处理函数的入口地址,并将该地址写入 PC,开始运行操作系统代码。

为什么操作系统不直接使用应用程序在用户态的栈呢?

为了操作系统执行的安全。应用程序的栈是用户态代码可以读写的,内核的数据不允许用户态的代码访问。即,如果通用一个栈,用户态的代码就可能读写内核的数据。

操作系统在特权级切换过程中的任务

操作系统还需进一步将属于应用程序的 CPU 状态保存到内存中,用于之后恢复应用程序继续执行。应用程序需要保存的运行状态称为处理器上下文(Processor Context)。处理器上下文中的寄存器具体包括:

  1. 通用寄存器 X0 ~ X30。应用程序和操作系统在运行时都需要使用这些寄存器。
  2. 特殊寄存器,主要包括 PC、SP 和 PSTATE。由于 PC 和 PSTATE 已经被 CPU 保存在额外的寄存器中,而 SP 寄存器在 CPU 中存在多份,不用将他们保存在内存中——除非操作系统决定切换到另一个应用程序。
  3. 系统寄存器:包括页表基地址寄存器等。这些寄存器无法直接由用户操作,不直接包含需要保存的用户程序状态,也不用保存——仅在操作系统切换应用时需要保存。

3.2.5 系统调用

系统调用是操作系统提供给应用程序的接口。

系统调用是一种用户程序主动触发的异常,与一般的异常不同,系统调用需要在用户态和内核态之间传递一些额外的信息。

系统调用和普通函数调用的区别:

  1. 普通的被调用者还调用者都在用户态,公用一个栈;系统调用的调用者在用户态,被调用者在内核态,两者使用不同的栈。

系统调用的参数和返回值等信息,需要遵守一套与普通函数调用不同的调用惯例。不同的操作系统的系统调用以及调用惯例往往不同,在应用程序中直接使用系统调用会使程序难以移植。

为了解决不同系统不一致的问题,一些可移植操作系统接口标准逐渐发展起来,例如 POSIX。

3.2.6 系统调用的优化

系统调用时延比普通的函数调用高 1 到 2 个数量级。怎么才能绕过费时的异常机制来实现系统调用呢?可以在用户态和内核态之间共享一小块内存的方式,在应用与内核之间创建一条新的通道。具体来说分为两种方法。

  1. 内核将一部分数据通过只读的形式共享给应用,允许应用直接读取。比如在 libc 中 gettimeofday 就是采取这样的方式。

    缺点:如果系统调用需要修改内核中的变量,或者在运行过程中需要读取更多内核数据,该方法就不适用了。

  2. 允许应用以“向某一块内存页写入请求”的方式发起系统调用,并通过轮询的方式来等待系统调用完成。内核同样通过轮询来等待用户的请求,然后执行系统调用,并将系统调用的返回值写入同一块内存页表示完成。

    设计:让内核独占一个 CPU 核心,这个核心一直在内核态运行,其他 CPU 核心则一直在用户态运行。对于任意一个 CPU 核心,都不会发生从用户态到内核态的切换。

    缺点:

    • 如果某个应用发起系统调用请求的时候,内核正在处理上一个系统调用,则时延依然会很长。解决方法:让多个 CPU 核心同时运行在内核态并轮询用户的请求。核心的数量可以根据具体的负载确定。
    • 如果整个系统只有一个 CPU 核心,那么该怎么办?将轮询改成批处理。当 CPU 在用户态时,应用程序一次发起多个系统调用请求,同样将请求和参数写入共享内存中。然后 CPU 切换到内核态,内核一次性将所有系统调用处理完,把结果写入共享内存,在切换回用户态执行。

操作系统提供的基本抽象与接口

基于异常机制和系统 ISA,操作系统为应用程序提供底层硬件的抽象与接口

处理器的抽象是进程

物理内存抽象的是虚拟内存

存储等外部设备的抽象是文件

操作系统通过这些抽象,使程序员不用考虑硬件资源管理的繁杂细节,降低了应用程序的编程复杂度。

3.3.1 进程:对处理器的抽象

如何设计一种机制,让多个应用程序高效的、按需的、尽可能公平的运行在有限的程序上?

操作系统的做法是让多个程序“轮流”使用处理器,又称为分时复用

一个程序在处理器上运行一段时间后,会被操作系统暂停执行并切换走,把处理器让给另一个应用程序。切换的时机往往是在操作系统处理完某个异常或中断之后,且在返回用户态之前。

操作系统设置每个程序每次最长连续运行的时间(称为时间片


为了更好的管理多个运行中的程序,操作系统提出了进程的抽象。进程即运行中的程序,操作系统会为每个进程记录进程标识号(Process ID, PID)、运行状态以及所占用的计算资源,这些信息记录在一个称为进程控制块(Process Control Block, PCB)的数据结构中,操作系统为每一个进程维护一个 PCB。

在切换进程时,操作系统会进行一系列操作,其中一个重要的步骤是将上一个进程在处理器中的状态保存到对应的 PCB 中,这些状态就是处理器上下文(处理器中寄存器的状态)

3.3.2 案例分析:使用 POSIX 进程接口实现 shell

POSIX 规范提供了一组用于操作进程的接口

  • spawn 接口用户创建进程
  • waitpid 接口用于等待进程退出并回收资源
  • exit 接口用户退出进程
  • getpid 接口用于获得当前进程的 ID。

利用 POSIX 实现的一个只包含最基础功能的 shell:mysh

# include <stdio.h>
# include <spawn.h>
# include <sys/wait.h>
# include <string.h>
# include <stdlib.h>
static void eval(char *cmdline);
# define MAXLEN 1000

int main() {
    // 定义一个字符数组
    char cmdline[MAXLEN];
    while (1)
    {
        // 死循环,输入提示符
        printf("mysh>");
        fflush(stdout);
        // 从控制台中得到输入的字符,到cmdline 中
        fgets(cmdline, MAXLEN, stdin);
        // 将 cmdline 中的 \n 替换为 \0
        cmdline[strcspn(cmdline, "\n")] = '\0';
        // 执行命令
        eval(cmdline);
    }
    return 0;
}
void eval(char *cmdline) {
    char *argv[2] = {cmdline, NULL};
    
    // 比较cmdline 中的命令是不是 quit 如果是,直接终止程序
    if (strcmp(cmdline, "quit") == 0) {
        exit(0);
    }

    // 输入的命令不是 quit 创建新的进程执行命令
    pid_t pid;

    // 创建进行
    posix_spawn(&pid, cmdline, NULL, NULL, argv, NULL);
    // 创建一个接受命令执行完毕的状态变量
    int exit_status;
    waitpid(pid, &exit_status, 0);
    // 判断执行是否成功
    if (!WIFEXITED(exit_status)) {
        printf("Program terminated unexpectedly!\n");
    }
}

运行结果

image-20240419211335643

3.3.3 虚拟内存:对内存的抽象

假设有两个进程,分别是进程 A 和进程 B,通过分时复用的方式运行在一个处理器上,两个进程都需要使用内存,如何让多个进程同时使用内存?

在应用程序和物理内存之间加入一个新的抽象——虚拟内存

引入虚拟内存后,应用程序不再直接使用物理内存,而是使用虚拟内存间接的使用物理内存。

虚拟地址会被 CPU 中的 MMU 翻译为物理地址,然后再访问对应的物理内存。

程序员面向虚拟地址编写应用程序,应用程序也只能使用虚拟内存。

从虚拟地址到物理地址的地址翻译是由处理器与操作系统协同完成的:处理器负责将虚拟地址翻译为物理地址,操作系统负责建立虚拟地址和物理地址之间的映射关系。


虚拟内存的好处?

  • 程序员在编程时,无需考虑物理内存的继续分配,也不用关心使用的地址是否会和其他进程重叠,而是可以使用连续的整个虚拟地址空间。实际运行时的内存资源由操作系统为其分配,大大减轻了程序员的负担
  • 每个进程的虚拟地址空间是彼此隔离的,应用程序在运行期间的内存读写对其它进程是不可见的,这样就保证了不同进程之间的隔离
  • 操作系统可以选择只将程序实际正在使用的虚拟地址映射到物理内存地址,对于未被进程使用的虚拟地址,操作系统还可以不将其映射到任何位置,从而提高了内存的资源利用率
  • 操作系统可以选择将部分虚拟内存区域的数据暂存到磁盘上,从而允许应用程序使用的内存大小突破污泥内存的容量限制。
  • 操作系统可以为不同的虚拟内存区域设置不同的权限,包括可读、可写、可执行,以是允许用户态访问还是仅内核态访问,从而增强程序执行和系统整体的安全性。

3.3.4 进程的虚拟内存布局

每个进程的虚拟内存空间都是一段从 0 开始的连续地址空间。从低地址到高地址的分步如下:

  • 代码段和数据段。数据段主要保存的是全局变量的值,代码段保存的是执行代码。这两个部分都保存在可执行文件中,在进程执行前,操作系统会将它们载入虚拟地址空间。
  • 用户堆。堆管理的是进程在运行过程中动态分配的内存,例如通过 malloc 分配的区域。堆的扩展方向是自底向上:堆顶在高地址。
  • 代码库。进程的执行有时需要依赖共享的代码库(比如 libc),这些代码会被映射到用户栈下方的虚拟地址,并标记为只读。
  • 用户栈。栈保存了进程需要使用的各种临时数据。栈是一个可以伸缩的数据结构,扩展方向是自顶向上:栈底在高地址。当临时数据被压入栈内时,栈顶会向低地址扩展。
  • 内核部分:进程地址空间顶端的部分通常是为操作系统内核保留的区域。应用程序无法直接访问这部分内存区域,只有操作系统内核才能访问。对于不同的进程来说,这部分虚拟地址空间的映射都是一样的。

进程内存布局

在 Linux 中,用户可以通过 cat /proc/PID/maps 查看某个进程的聂村布局

可以看到,内存布局基本和上述一样。由于内核地址空间对用户进程不可见,因此 maps 内容没有包含内核部分的映射。

image-20240419214847351

3.3.5 文件:对存储设备的抽象

为了将不同存储设备的细节隐藏起来,让程序员可以通过统一、便利的方式来访问这些设备,操作系统使用了文件这一抽象。

操作系统提供了一组接口用于文件访问,文件的、读取、写入、关闭等。

应用程序可以通过 open 接口打开一个文件。在打开成功后,open 会返回一个文件描述符(File Descriptor, FD)

除了通过 read 和 write 接口来访问文件外,操作系统也支持直接将文件映射到虚拟地址空间中来访——这种访问成为内存映射,具体为 mmap 系统调用。通过将文件与一块连续的虚拟地址内存区域相关联,用户程序可以直接通过读写内存的方式来访问文件。

# include <stdlib.h>
# include <fcntl.h>
# include <sys/mman.h>

int main() {
    int fd = open("test.txt", O_RDONLY);
    void *start = mmap(NULL, 13, PROT_READ, MAP_PRIVATE, fd, 0);
    write(1, start, 13);
}

3.3.6 文件:对所有设备的抽象

Linux 将文件作为所有设备的抽象

在 Linux 中,为了让应用程序能够访问某个设备,会首先为这个设备创建一个特殊的文件——设备文件,通常放在 /dev 目录下。

但是,并非所有设备都适合被抽象为文件并通过 read、write 接口来操作。例如,如果将音频数据写入声音设备就能播放该音频的话,那么调整音量应该如何操作呢?为了实现这些并不标准的操作,操作系统提供了更加通用的底层接口 ioctl,允许应用程序以更灵活的方式向设备发送命令与数据。

对于一些常用的设备文件类型,如网卡设备,操作系统还专门提供了套接字(Socket)的抽象与相应的接口,使应用能够方便地使用这些设备。

posted @ 2024-04-19 22:42  Sstarry  阅读(78)  评论(0)    收藏  举报