文章

ICS2-note

本文目录 73

ICS 笔记

ICS2 复习笔记,主要根据课程 Slides 和 CSAPP 教材整理。

最近实在太忙了,草稿箱里其实已经躺着好几篇 Blog,一直没时间写完。

先用这篇 ICS 复习笔记水一篇,之后应该会补充更多偏思路性的内容,希望最终能把整篇知识体系串联起来。

二更:

由于涉及的知识点实在太多,最近只是稍微整理了一部分。本篇笔记原来是英文的,整理后的版本采用中文,同时也记录了更多思路性的东西,意图感性的展示出整个操作系统的一些知识

整篇笔记按照各个单元进行划分,主要参考 CSAPP 和课程 Slides,同时补充了一些示意图。

  1. 记忆架构
    • Storage & RAM
    • Locality & Cache
    • Cache 写策略
  2. 异常控制流
    • 进程控制(Process Control)
    • 信号(Signals)
    • 非本地跳转(Nonlocal Jumps)
  3. IO
    • 系统级 I/O(Sys-level I/O)
    • RIO(Robust I/O)
    • 标准 I/O(Standard I/O)
  4. 虚拟内存(Virtual Memory)
    • 页表与地址转换
    • TLB & 多级页表
    • VMA & 内存映射
    • 页面置换算法

To be archived

errno:最简单的实现方式就是维护一个全局变量。

Process Scheduling:

  • FIFO
  • SJF
  • STCF

Memory Hierarchy

本章介绍计算机中存储的架构 从不同的存储到一些缓存的机制

Storage

常见的存储介质包括:

  • Disk(机械硬盘,依靠磁盘旋转读写)
  • SSD
  • SRAM
  • DRAM

他们访问的速度是

SRAM > DRAM > SSD > Disk

相应的,造价也会随速度增加。所以如何权衡成本和速度,就是Memory Hierarchy要解决的事情

在日常使用机器中,所谓的内存(主存)就是由DRAM组成的,而硬盘一般是SSD或者Disk

这里有关Disk的旋转读取,以及SSD的颗粒存储就不展开了

RAM

Random Access Memory,是一种访问的方式,可以以几乎相同的时间访问任意地址

sRAM和DRAM都是具有这种方式的存储介质

这种特性保证了CPU能够快速的访问内存、执行程序

注: 这里的随机访问指的是给定一个地址(或索引),可以直接访问对应的数据,而不需要先访问其他数据 而与之对应的顺序访问,则是访问某个数据之前,必须按照存储顺序访问前面的数据,好比数组和链表的区别


Locality

程序运行过程中通常具有 Locality(局部性)

Temporal Locality(时间局部性) :最近访问过的数据,很可能在不久之后再次被访问

Spatial Locality(空间局部性):与当前访问位置相邻的数据,更有可能在之后被访问

Memory Hierarchy

记忆层级,主要是指从CPU的开始,到主存的这一条层级架构

cache-hi

Cache

cache,又或者缓存是什么?

主存使用的存储介质是DRAM,他不是最快的,为了提高访问速度,在cpu和主存之间会引入缓存,将一些数据存在SRAM里,以便更快的访问

当cpu需要访问内存的时候,会从最近的缓存开始访问,如果miss就访问下一级的缓存,并将访问到的数据提升至更高的cache中

L1 -> L2 -> L3 -> 主存

因此,按照金字塔的结构,越靠近 CPU 的存储层级应该:

  • 访问速度越快
  • 容量越小

当发生 Cache miss 时,需要从更低一级存储中取回数据。

如果 Cache 已满,则需要淘汰(Evict)已有的数据块,为新的数据腾出空间

由于 Cache 的容量有限,一个内存块通常只能映射到 Cache 中的一小部分位置,这就是 Placement Policy(放置策略)

Cache Miss 可以分为三种类型:

  • Cold Miss(冷启动未命中)

    Cache 刚开始为空,需要逐渐预热(Warm up)

  • Conflict Miss(冲突未命中)

    Cache 实际上还有空闲空间,但由于映射策略限制,导致多个数据竞争同一个 Cache Line

    这种情况是可以通过调整cache的结构,或者是调整代码来调整变量的地址来进行优化的

  • Capacity Miss(容量未命中)

    Cache 容量确实不足,即使采用最优映射策略也无法避免

Cache Memory

Cache 的整体组织结构

cache organization

Q: 为什么要把地址拆成几份,而不是直接把地址作为索引?

A: 一个cache是一个小的存储空间,因此 它不可能做到像主存一样,一个物理地址对应一块空间;他里面应该是类似于unordered_map的形式。那么就需要用idx去查询:

  1. 如果用完整的address(32bit为例)去做idx,那么idx占32bit,存储的数据只有1byte=8bit,利用率很低

  2. 如果想要拓展存储数据的大小,把address拆成set bit和offset bit,这样就能存储更多的数据;但是有一个很重要的前提—所有的address都能被存入cache,对应到这里无序地图的概念,也就是说cache里条目的数量必需等于2的set-bit次幂的数量,否则就会出现有的address存不进cache。于此同时一个条目的大小也必须是2的offset-bit次幂byte

  3. 以32bit为例,offset nn bit, set 32−n32-n bit,总共的大小就是232−n×2n=232byte2^{32-n} \times 2 ^ n = 2^ {32} \text{byte} 也就是 4GB,远远大于我们想要的效果

  4. 计算出这么一个大的cache的原因,是因为没有引入踢出的机制,或者说在不违反unordered_map之一前提下,一个idx可以索引到不同的条目,比如set bit和offset bit之间空两个bit,这样就是每22=42^2 = 4个条目对应了一个idx了。那么如何区分当前存储里的条目对应的是那个地址条目呢?这时再把刚刚那两个bit引入,作为额外的tag bit就可以分辨了。同时cache的大小也由于tag的存在而被缩减成了1/4!

  5. 在此基础上继续调整这几个bit的大小,我们就能得到想要的cache的大小了,同时还可以拓展一个set里的line的数量,在line满了之后进行踢出和替换

Direct Mapping Cache

Direct Mapping(直接映射)中:

每个内存块只能映射到唯一的一条 Cache Line。

因此:

其中 E 表示每个 Set 中 Cache Line 的数量。

Cache 写策略(Writing Cache)

主要有两种写策略。

Write Through

每次写 Cache 时,都立即同步写回到下一层存储(通常是 Memory)。

优点:

  • 实现简单
  • 数据一致性容易维护

缺点:

  • 写操作较慢
  • Memory 带宽压力较大

Write Back

数据首先只写入 Cache。

只有当对应 Cache Block 被 Evict 时,才真正写回 Memory。

因此需要额外维护一个 Dirty Bit,用于记录该数据是否被修改过。

相比 Write Through:

  • Memory 写次数明显减少
  • 写性能更高

当发生 Write Miss 时,又对应两种不同策略。

Write Allocate

通常与 Write Back 配合使用。

发生 Write Miss 时:

先把对应 Block 调入 Cache,再完成写操作。

也就是说,数据仍然按照 Cache 层级逐层传递。


No Write Allocate

通常与 Write Through 配合使用。

发生 Write Miss 时:

不加载 Cache,直接写入下一层 Memory。

Memory Mountain

(待补充)

Exceptional Control Flow

Process Control(Exception)

Reaping

需要区分下面几个重要概念:

  • Zombie Process(僵尸进程):子进程已经终止,但父进程尚未回收(reap)其退出状态。
  • Orphan Process(孤儿进程):父进程已经退出,但子进程仍然在运行。

父进程通过 reap 操作回收已经终止的子进程(Zombie Process)。

init Process

当父进程退出后,内核会将所有仍在运行的子进程交由 init Process(现代 Linux 中通常是 systemd)接管。

init Process 的特点:

  • 系统启动时创建;
  • 永远不会终止;
  • 是所有进程的祖先。

因此,Orphan Process 最终都会由 init Process 负责回收。

waitpid

pid_t waitpid(pid_t pid, int *statusp, int options);

返回值:

  • 成功:返回被回收子进程的 PID;
  • 返回 0:使用 WNOHANG 时,没有子进程退出;
  • 返回 -1:发生错误。

TODO:整理 options 的三种常用选项。

sleep

(待补充)

execve

execve 用于加载并执行一个新的程序。

它通过传入:

  • 参数列表(argument list)
  • 环境变量列表(environment variable list)

来启动新的可执行文件。

#include <unistd.h>

int execve(const char *filename,
           const char *argv[],
           const char *envp[]);

// 成功时不会返回;失败返回 -1

需要注意:

execve 不会创建新的 Process。

它会直接用新的程序替换当前 Process 的地址空间,因此成功执行后,原来的代码将不再继续运行。


Signals

Signal 属于一种更高层次的 Exceptional Control Flow。

Signal 与 Exception 的区别

SignalException
发送者CPU(用户态或内核态产生事件)Kernel
接收者Kernel用户程序
特点异步(Asynchronous)同步(Synchronous)

简单理解:

  • Exception 通常由当前正在执行的指令触发,例如 Page Fault、Divide by Zero。
  • Signal 则更偏向异步通知,例如 Ctrl+C、子进程结束、定时器超时等。

Sending Signal

Process Groups

(待补充)

kill

kill 用于向指定 Process 或 Process Group 发送 Signal。

alarm

unsigned int alarm(unsigned int seconds);

作用:

设置一个定时器。

当指定时间到达后,内核会向当前 Process 发送 SIGALRM。

返回值:

返回上一次 alarm 尚未结束时剩余的秒数。


Receiving Signal

Pending Signal Set

Kernel 会维护一个 Pending Signal Set。

收到 Signal 时:

  • 若该 Signal 当前被阻塞,则加入 Pending Set;
  • 等解除阻塞后,再进行处理。

需要注意:

同一种 Signal 不会排队(Not Queued)。

无论收到多少次,同一种 Signal 在 Pending Set 中最多只会保留一个。

Signal Handler(sighandler)

可以为某个 Signal 注册对应的处理函数(Signal Handler)。

Signal 到达后,Kernel 会中断当前程序的执行,转而调用对应的 Handler。

Handler 执行结束后,再返回原来的程序继续运行。


Blocking and Unblocking

Signal 的阻塞(Blocking)可以分为两种:

  • Implicit Blocking

    Kernel 自动完成。

  • Explicit Blocking

    程序员主动调用 sigprocmask 等接口进行控制。


Signal Handler 的设计

设计 Signal Handler 时,需要注意:

  • Handler 应尽量简单;
  • Handler 应避免执行耗时操作;
  • Handler 应避免调用不可重入(non-reentrant)的函数。

尤其需要牢记:

Pending Signals are not queued.

因此 Handler 的设计不能依赖于:

“收到多少次 Signal,就一定会调用多少次 Handler。”

例如:

父进程通常会捕获 SIGCHLD,然后在 Handler 中循环调用 waitpid,回收所有已经退出的子进程。


避免并发 Bug(Race Condition)

Signal 是异步发生的,因此非常容易产生 Race Condition(竞争条件)。

例如:

父进程:

  1. 调用 fork() 创建子进程;
  2. 将子进程记录到某个数据结构中;
  3. 注册 SIGCHLD 的 Handler,在子进程退出时删除对应记录。

如果:

子进程结束得非常快,

那么:

SIGCHLD 可能会在父进程完成记录之前就已经到达。

最终导致:

Handler 删除了一条根本不存在的记录。

这就是典型的 Race Condition。


显式等待 Signal

一种简单的方法是:

while (!flag)
    pause();

但是这里存在竞争窗口:

Signal 可能恰好发生在:

判断 flag
↓

调用 pause()

之间。

这样程序将永久睡眠。

因此,更推荐使用:

sigsuspend()

它可以将下面三个操作原子化(Atomic):

sigprocmask
↓

pause

↓

sigprocmask

从而彻底避免上述竞争问题。


Nonlocal Jumps

setjmp(jmp_buf env);

longjmp(jmp_buf env, int retval);

longjmp 可以让程序直接跳回到之前执行 setjmp 的位置。

setjmp

setjmp 可以执行多次。

第一次执行时:

返回值始终为:

0

当之后调用对应的 longjmp 时:

setjmp 将再次返回,

返回值为:

retval

longjmp

longjmp 可以调用多次。

它本身不会返回,而是直接恢复程序执行现场,并跳转回对应的 setjmp。


存在的问题

由于会直接跳过中间大量函数,

因此容易导致:

  • Memory Leak
  • Resource Leak
  • 文件未关闭
  • 锁未释放

因此需要谨慎使用。


Application

Nonlocal Jump 的两个典型用途:

  1. 从深层函数调用中立即返回,而无需层层返回;

  2. 在 Signal Handler 中直接跳回指定位置,而不是继续执行被中断的那条指令。


IO

Sys-level I/O

Unix I/O

在 Unix 中,一切都被抽象为 File(文件)。

无论是:

  • 普通文件
  • 终端(Terminal)
  • Socket
  • Pipe

都可以通过统一的 I/O 接口进行访问。

对于每一个打开的文件,Kernel 都会维护一个 File Offset,表示当前读写的位置。


Opening and Closing Files

调用 open() 可以将一个文件名转换成 File Descriptor(fd)。

File Descriptor 本质上是一个非负整数,用于唯一标识当前进程打开的文件。

int fd = open(pathname, flags, mode);

成功时返回一个 File Descriptor;

失败时返回 -1。

下图展示了文件描述符与内核数据结构之间的关系。

cc87cbcb356fe6dba523352732179f7e


Sharing Files

当父进程调用 fork() 创建子进程时:

父子进程拥有不同的 File Descriptor Table(文件描述符表)。

但是:

它们会共同指向同一个 Open File Table Entry(打开文件表项)。

因此:

  • File Offset 是共享的;
  • 打开文件对象也是共享的;
  • 引用计数 refcnt 会增加。
fork()

Parent fd table
        │
        │
Child fd table
        │
        ▼
Open File
(refcnt++)

如下图所示:

12f1c33ea3c1bb59ba2909b4380696cf

因此:

父子进程读取同一个文件时,会共享同一个 File Offset。


I/O Redirection

Unix 可以利用 dup() 和 dup2() 完成 I/O 重定向。

dup()

int dup(int oldfd);

作用:

复制一个 File Descriptor。

Kernel 会返回当前最小的可用 File Descriptor。

例如:

oldfd = 3

↓

dup(oldfd)

↓

newfd = 4

其中:

3 和 4 指向同一个 Open File。


dup2()

int dup2(int oldfd, int newfd);

作用:

让 newfd 指向 oldfd 所引用的同一个打开文件。

如果 newfd 已经打开,则会先自动关闭。

因此:

dup2() 常用于实现 Shell 中的:

  • <
  • >
  • >>
  • 2>

等 I/O 重定向功能。

如下图所示:

bf60e8ec73a339a3560063aa442f6861


RIO(Robust I/O)

CSAPP 提供了一套封装后的 Robust I/O。

它的目标是:

  • 避免系统调用被 Signal 中断;
  • 保证一次读取或写入指定数量的数据;
  • 提供更稳定、更可靠的 I/O 接口。

无缓冲版本(Unbuffered)

主要包括两个接口:

ssize_t rio_readn();
ssize_t rio_writen();

返回值:

  • 成功:返回实际传输的字节数;
  • EOF:返回 0;
  • 错误:返回 -1。

其中:

rio_readn()

持续调用 read(),

直到:

  • 已读取指定字节数;
  • 遇到 EOF;
  • 或发生错误。

因此能够避免普通 read() 一次读不满的问题。


rio_writen()

持续调用 write(),

直到所有数据都写入完成。

因此不会因为一次 write() 返回较小长度而导致数据丢失。


带缓冲版本(Buffered RIO)

Buffered RIO 在内部维护了一块缓冲区(Internal Buffer)。

所有高级接口最终都会调用内部函数:

rio_read()

首先定义一个数据结构:

typedef struct {
    int rio_fd;                // File Descriptor
    int rio_cnt;               // Buffer 中剩余未读取的字节数
    char *rio_bufptr;          // 下一次读取的位置
    char rio_buf[RIO_BUFSIZE]; // 内部缓冲区
} rio_t;

然后初始化:

void rio_readinitb();

之后即可调用:

ssize_t rio_readlineb();
ssize_t rio_readnb();

rio_readlineb()

按一行读取数据。

读取结束条件:

  • 遇到 \n
  • Buffer 已满
  • EOF

因此非常适合网络程序读取 HTTP Header。


rio_readnb()

读取指定数量的数据。

与 rio_readn() 类似,

区别在于:

它优先从内部 Buffer 中读取,

Buffer 不足时再调用 read()。

这样能够显著减少系统调用次数,提高 I/O 效率。


Standard I/O

C 标准库提供了另一套 I/O 接口:

例如:

fopen()

fclose()

fread()

fwrite()

fprintf()

fscanf()

它们将文件抽象成 Stream(流)。

与 Buffered RIO 类似,

Standard I/O 同样使用内部缓冲区,以减少系统调用次数。

但是 Standard I/O 更偏向于一次只处理一种 I/O 模式。

例如:

  • 读(Read)
  • 写(Write)

如果频繁在两种模式之间切换,则需要调用:

fflush()

fseek()

rewind()

等接口同步缓冲区。

因此:

对于网络编程,CSAPP 更推荐使用 RIO;

而对于普通文件操作,Standard I/O 更方便、更符合 C 的编程习惯。


Virtual Memory

Physical Memory 与 Virtual Memory

现代操作系统采用 Virtual Memory(VM) 机制,使每个 Process 都拥有一套独立且连续的虚拟地址空间,而实际的数据则存放在 Physical Memory(PM) 中。

这种设计带来了几个重要优点:

  • 简化程序的内存管理;
  • 提供进程隔离,实现内存保护;
  • 支持按需加载(Demand Paging);
  • 能够利用磁盘扩展可用内存空间。

Virtual Memory 作为 Cache

从另一个角度来看,Virtual Memory 本质上也是一种 Cache。

它将磁盘上的数据缓存到 Physical Memory 中。

因此:

Disk
    ↓
Physical Memory

实际上可以看作:

Physical Memory 是 Disk 的高速缓存。

由于 Physical Memory 容量有限,因此需要根据一定策略淘汰页面。

不过需要注意:

缓存的数据始终组织在 Physical Address 上,而不是 Virtual Address。

这一过程依赖后面介绍的 Reverse Mapping 等机制完成。


Page Table 与 Address Translation

由于:

  • Disk 的访问延迟非常高(Large Miss Penalty);
  • 一次访问整个文件开销巨大;

因此 VM 不会按字节管理内存,而是采用固定大小的 Page(页)。

通常:

Page Size = 4KB

Virtual Memory 被划分为:

Virtual Page(VP)

Physical Memory 被划分为:

Physical Page(PP)

地址转换(Address Translation)的任务就是:

Virtual Page
↓

Physical Page

地址组成

无论是 Virtual Address 还是 Physical Address,

都可以划分为两部分:

| Page Number | Page Offset |

其中:

  • 前半部分表示所在的 Page;
  • 后半部分表示页内偏移(Offset)。

如下图所示:

1ff8407363186e9a060a0ee691026f21


Page Table

Page Table 保存了:

VPN
↓

PPN

之间的映射关系。

可以理解为:

VPN
    ↓
查 Page Table
    ↓
PPN

每一个表项称为:

Page Table Entry(PTE)

如下图:

a8763ecf92a3a34fd2a02a403d13c335


##3 Page Fault

如果访问某个 Virtual Page 时:

对应 PTE 中的 Valid Bit 为 0,

说明该页面当前不在 Physical Memory 中。

此时将发生:

Page Fault

随后:

Kernel 会进入 Page Fault Handler。

Handler 会:

  1. 找到对应页面;
  2. 从 Disk 调入 Physical Memory;
  3. 修改 Page Table;
  4. 如果 Physical Memory 已满,则选择一个 Victim Page 进行淘汰(Swap Out)。

随后重新执行导致 Fault 的那条指令。


Page Table 的作用

Page Table 不仅完成地址映射,

还承担多个功能。

1. Memory Management

完成:

VA

↓

PA

之间的地址转换。


2. Memory Protection

PTE 中保存多个 Permission Bit,例如:

  • Read
  • Write
  • Execute

Kernel 根据这些权限决定:

某次访问是否合法。

例如:

Code Segment

↓

Read + Execute

×

Write

即可防止程序修改自己的代码。


3. Memory Recording

PTE 还包含一些状态位,例如:

  • Reference Bit(访问位)
  • Dirty Bit(脏位)

其中:

Reference Bit

记录页面最近是否被访问,

供页面替换算法使用。

Dirty Bit

表示页面是否被修改过。

如果 Dirty Bit 为 0,

页面可以直接丢弃;

否则需要写回磁盘。


TLB

虽然 Page Table 功能强大,

但一次地址转换需要额外访问内存。

例如:

访问一次数据:

VA

↓

查 PT(一次 Memory Access)

↓

得到 PA

↓

再次访问 Memory

因此:

一次访存实际上需要:

Memory

↓

Memory

共两次访问。

为了解决这个问题,

CPU 引入了:

TLB(Translation Lookaside Buffer)

TLB 本质上就是:

Page Table 的 Cache。

它通常采用 SRAM 实现,访问速度远快于 DRAM。

如果:

TLB Hit

则可以直接获得 PPN,

无需再次访问 Page Table。


Multi-level Page Table

如果采用单级 Page Table,

假设:

Virtual Address = n bit

Page Size = 4KB

那么:

需要维护:

2^(n-12)

个 Page Table Entry。

对于 64 位系统而言,

这将消耗极大的内存。

因此现代系统采用:

Multi-level Page Table(多级页表)

将整个页表拆分为多个层次。

只有真正使用到的部分才会分配内存。

这样能够显著降低空间开销。

第一层 Page Table 的基地址保存在:

CR3 Register

由于 CPU 每次地址转换都需要访问它,

因此:

第一层 Page Table 本身不能被换出。

如下图所示:

intel


VMA

Linux 使用:

Virtual Memory Area(VMA)

来描述一段连续 Virtual Address Space。

对应的数据结构如下:

VMA

其中:

  • vm_prot

    表示访问权限。

  • vm_flags

    表示映射属性,例如:

    • Shared
    • Private

每个 Process 通常包含多个 VMA,例如:

  • Code
  • Data
  • Heap
  • Stack
  • Memory Mapped File

Memory Mapping

一个 VMA 可以通过 Memory Mapping 获得初始内容。

映射对象可以来自:

  • Disk 上的普通文件(Regular File)
  • Anonymous File(匿名映射,没有对应文件)

fork() 再次分析

执行:

fork();

之后,

Kernel 会复制:

  • mm_struct
  • vm_area_struct
  • Page Table

但是:

Physical Memory 并不会立即复制。

这是因为:

Linux 采用:

Copy On Write(COW)

策略。

具体来说:

父子进程最开始共享同一份 Physical Page。

只有当其中某一方第一次写入时,

Kernel 才真正复制对应页面。

因此:

COW 属于一种典型的 Lazy Strategy(延迟策略)。


mmap()

用户程序也可以直接调用:

mmap()

建立 Memory Mapping。

相比:

Disk

↓

Kernel Buffer

↓

User Buffer

传统读写方式,

mmap() 可以直接建立:

Disk

↓

Virtual Memory

之间的映射。

因此:

程序访问内存时,

Kernel 会自动完成页面调入,

避免了额外的数据复制。

如下图:

mmap


Page Swapping 与 Replacement Policy

当 Physical Memory 不足时,

Kernel 会将部分页面交换到 Disk,

这一过程称为:

Page Swapping。

通常,

Kernel 会维护:

  • High Water Mark
  • Low Water Mark

当可用内存低于阈值时,

后台线程开始进行页面回收。


常见页面替换算法

OPT

Optimal Algorithm。

理论上的最优算法:

始终淘汰未来最长时间不会再次访问的页面。

由于需要知道未来访问情况,

因此只能作为理论上界。


FIFO

First In First Out。

最早进入 Memory 的页面最先被淘汰。

实现简单,

但可能出现 Belady’s Anomaly(Belady 异常):

增加 Memory 容量反而导致更多的 Page Fault。


LRU

Least Recently Used。

淘汰最长时间没有被访问的页面。

利用程序具有 Temporal Locality 的特点,

通常能够取得较好的效果。

由于精确实现成本较高,

现代操作系统通常采用近似 LRU 算法。


Clock Algorithm

Clock(时钟算法)是 LRU 的一种近似实现。

每个页面维护一个 Reference Bit。

系统像时钟一样循环扫描所有页面:

  • Reference Bit 为 1,则清零并跳过;
  • Reference Bit 为 0,则选择该页面作为 Victim。

Linux 中的页面回收算法也是基于类似思想进行改进实现的。