Skip to content

Latest commit

 

History

History
994 lines (648 loc) · 55.4 KB

summary.md

File metadata and controls

994 lines (648 loc) · 55.4 KB

8086 基础与汇编语言

参考 《汇编语言》 Chapter 2, 3, 4, 12~15

硬件

寄存器

通用寄存器
  • 数据寄存器
    • AX (Accumulator):累加寄存器,也称之为累加器
    • BX (Base):基地址寄存器
    • CX (Count):计数器寄存器
    • DX (Data):数据寄存器
  • 指针寄存器
    • SP (Stack Pointer):堆栈指针寄存器
    • BP (Base Pointer):基指针寄存器
  • 变址寄存器
    • SI (Source Index):源变址寄存器
    • DI (Destination Index):目的变址寄存器
段寄存器

8086 不支持直接向段寄存器写入内存数据,必须使用其他寄存器进行中转。

段寄存器分为可见部分非可见部分。可见部分存放着段选择符,该段选择符是其所指向的段描述符在 GDT 表或 LDT 表中的偏移位置 (下标) 也就是说,GDTR 或 LDTR 中的基地址加上 “段选择符 $\times$ 段描述符长度”,就可以获取到对应的段描述符地址,而从段描述符中就可以找到段的基地址。不可见部分一般存放着段描述符的一些基本信息。这样程序在执行的时候就不用每次都去查找 LDT 表了,可以提高时间效率。

  • CS:代码段寄存器
  • DS:数据段寄存器
  • SS:堆栈段寄存器
  • ES:附加段寄存器
控制寄存器
  • IP (Instruction Pointer):指令指针寄存器
  • FLAG:标志寄存器
    • TF (Trap Flag) 位为追踪标志,用于单步调试;
    • IF (Interrupt-Enable Flag) 位为中断允许标志,决定 CPU 是否响应外部可屏蔽中断。
  • CR0:含有控制 CPU 操作模式和状态的标识
    • PE (Protection Enable):该位置位时即开启保护模式,开启段级保护
    • PG (Paging):该位置位时开启分页机制
  • CR1:保留不用
  • CR2:存储导致页错误的线性地址
  • CR3:含有页目录表的物理内存基址

总线

  • 地址总线的宽度决定了地址空间的大小
  • 数据总线的宽度决定了 CPU 同外界的数据传送速度
  • 控制总线的宽度决定了 CPU 对外部器件有多少种控制

指令

mov

8086 在硬件上不支持从内存到内存的直接数据移动。

寄存器到寄存器的数据复制

​ 寄存器 → 寄存器是通过 CPU 内部的硬件直接完成

寄存器到内存的数据复制

​ 寄存器 → 内存则需要通过分段或者分段和分页机制将逻辑地址转变为物理地址并送入地址总线,同时发送命令到控制总线,然后将内容通过数据总线写入内存。

内存到寄存器的数据复制

​ 内存 → 寄存器则和寄存器 → 内存 相似,只不过是发送到控制总线的是命令,然后CPU通过数据总线读入数据。

in 和 out

实现的功能

​ IN 和 OUT 是端口的读写命令,可以用来进行对外接 IO 设备的读写,比如读入键盘输入。具体用法见下例:

# 累加器为 ax 或者 al, 分别用于处理字数据和字节数据
IN  累加器, 端口号|DX
OUT 端口号|DX, 累加器
端口和控制器的概念

​ 计算机中附带了用来连接计算机主机同外围设备的连接器,连接器内部有用来交换计算机主机同外围设备之间电流特性的集成电路(IC),这些 IC 统称为 I/O 控制器

​ I/O 控制器中有用于临时保存输入输出数据的内存,这个内存就是端口。控制器可以控制许多端口,彼此之间通过端口号区分开来,IN 和 OUT 指令通过指定端口号和 CPU 进行数据交换。

int

  1. 异常、中断的概念和作用
    • 异常一般指软件中断,由 INT 指令产生,用于处理程序中出现的非正常情况或者刻意进行系统调用
    • 中断一般指硬件中断,由硬件向 CPU 的中断引脚发送信号引发,根据不同的中断码引发不同的中断调用,主要用于异步处理 IO

控制指令

  • call X 等价于 push CS; push IP; jmp X 或者 push IP; jmp X,根据转移范围有不同,跳转的目标地址也根据不同的 call 指令版本存储在不同的地方(立即数、寄存器、内存)。
  • ret
  • iret:等价于 pop IP; pop CS; popf;
  • jmp
  • loop

算数运算指令

  • and op1, op2: op1 &= op2
  • or
  • div
  • mul
  • add op1, op2:op1 += op2
  • adc op1, op2:op1 += op2 + CF
  • sub
  • sbb

其他指令

  • push
  • pushf:将标志寄存器压栈
  • pop
  • popf:将标志寄存器弹出
  • movsb
  • movsw
  • rep:根据 cx 的值重复执行后面的指令

实例理解

键盘驱动下函数交替运行

​ 假设内存加载了一个有三个函数的程序,分别是函数 A、B,还有一个是键盘中断函数 C,键盘按下A时,运行函数 A,键盘按下 B 时,运行函数 B,要求保证 A 和 B 的运行状态不会因为切换而被破坏。

实现

​ 可以通过在键盘中断处理函数 C 中根据输入按键跳转到 A 或者 B 执行,跳转时如果发现目前正在执行的和将要跳转的函数一致则不做处理,否则将当前函数的运行状态备份在内存中并从内存中恢复将要运行的函数上次暂停时的状态。

相关问题
  1. 三个函数可以共用一个栈吗?
    • 不可以
    • 如果共用一个栈的话, 假设在 A 执行但还没有结束的时候,要切换到函数 B 执行,函数B的栈帧就要接着在 A。函数的栈上向上生长。接着,如果在 B 函数还没有结束的时候,又切换回 A。函数执行,这时 A 函数的栈帧也要接着向上增长。这样就会破坏函数 B 的栈帧,导致再次切换回 B 的时候产生错误。所以至少要使用两个栈
  2. 需要多少个栈,如何使用?
    • 至少需要两个栈,因为 C 是中断处理函数,所以在执行函数C的时候不会再次发生中断,不需要保存其运行状态,C 根据发生中断时运行的程序使用 A 或者 B 的栈。
    • 在该给定条件下,可以 A,B 各用一个栈,在进行异常处理时需要将现场保存至调用方栈中并将保存状态后的栈指针保存到对应内存地址中以便之后恢复时访问。
  3. C 如何知道 A、B 的入口地址或上次停止的地址?
    • 通过 A 或者 B 压栈的返回地址知道函数 A,B 上次停止的入口。通过 AB 的标号知道入口地址。

时钟中断下控制两函数交叉运行

实现

​ 在内存中保存一个占位一个字节的标志变量 run_A 并初始化为零并执行B,每次执行 C 就对 run_A 取反。每次在 C 更新完毕 run_A 的值后根据其值决定调用 A 或者 B。每次调用 A, B 时从对应内存处恢复 A, B 的执行现场,并在每次运行 C 的开始阶段前根据此时的 run_A 保存 A 或者 B 的现场到对应内存中。

其他

函数调用过程

  • 首先调用方压栈需要保存的寄存器,然后将需要传递的参数压栈
  • 调用方通过 call 指令压栈返回地址并跳转到目的地址
  • 被调方压栈需要保存的寄存器,比如 bp
  • 被调方可以通过访问栈内容获得参数并处理
  • 被调方执行完毕后通过 ret 指令获得返回地址并跳转
  • 调用方通过 ax 获得返回值,并弹栈恢复之前保存的寄存器值

中断处理过程

中断信息中包含识别来源的编码,在 8086 CPU 中通过中断类型码进行识别。

  1. (从中断信息中) 取得中断类型码
  2. 标志寄存器的值入栈 (因为在中断过程中要改变标志寄存器的值,所以先将其保护起来)
  3. 设置标志寄存器的 TF 和 IF 位为 0
  4. CS 入栈
  5. IP 入栈
  6. 获取中断程序入口地址并设置 IP 和 CS 完成跳转

或者更简洁地:

  1. 取得中断类型码 N
  2. pushf
  3. TF = 0, IF = 0
  4. push CS
  5. push IP
  6. (IP) = (N * 4), (CS) = (N *4 + 2)

系统调用

在系统启动时有对 IDT 的初始化,那个初始的地址指向的就是系统调用的总入口函数。这也就是系统调用的实现原理。

  • 在 386 中,系统调用通过 int 80h 指令完成
  • 调用 int 80h 指令后
    • 根据中断号0x80,从 IDT 中找到对应的中断门描述符
    • 特权级检查,检查门描述符的 DPL 是不是大于等于当前任务的 CPL,由于0x80中断的门描述符的DPL 和用户任务的 CPL 都是 3,所以这里的检查会通过.
    • CPU 从当前任务的 TSS 段中得到该任务对应的内核栈的段选择符和栈指针
    • CPU 临时保存当前使用的栈选择符和栈指针,然后切换 SS 和 SP 寄存器,使切换到内核栈中,然后在内核栈中压入刚才临时保存的栈选择符和栈指针
    • CPU 把 EFLAGS、CS 和 IP 寄存器的当前值压入内核栈中并设置 TF = IF = 0
    • CPU 设置 CS:IP 为处理函数的入口,开始执行中断处理函数
  • CPU 运行执行 system_call.s 程序,根据不同的系统调用号执行相应的处理函数
  • 处理函数把返回值放入 eax 寄存器中,使用 ret 指令返回到 system_call.s
  • system_call.s 中,使用 iret 指令把栈再切换回去,返回用户空间的栈
  • CPU 继续执行用户空间的代码,从 eax 寄存器中读取返回值

栈和栈帧的表示

  • 栈顶由 ss:sp 指示,使用两个寄存器
  • 栈帧由一对 bp、sp 的值和段寄存器 ss 指示

概念

  1. 程序地址、段、链接的概念
  • 程序地址:程序地址就是程序指令的起始地址
  • 段:段是一个相对独立的有自己逻辑含义的内存单元
  • 链接:链接用于将声明/调用和实际实现连接起来,从而生成可以直接执行的可执行文件。
  1. CPU 如何从内存中取到指令
  • CPU 通过 CS:IP 得到下一条指令的逻辑地址,将逻辑地址通过分段机制转换为线性地址。如果开启了分页功能的话,则还需要将线性地址通过分页机制映射到物理地址上。从而能够得到对应指令所在的物理内存位置,然后通过总线将指令读取到 CPU 中。

保护

运行级别

​ 在 386 启动过程中,将 CR0 寄存器的保护模式位置位,也就是启用保护模式,使 386CPU 具备了 0-3 四个运行级别。操作系统里说的 CPU 双模式,指的是 386 里的 0 和 3 两个运行级别。0 级别是系统模式,3 级别是用户模式。

  1. 请说说 386 设置 4 个运行级别的目的是什么?

    • 通过不同的运行级别隔离不同程序,保护高运行级别的程序运行免于受到低级别程序的影响。
    • 通过设定不同的运行级别方便管理进程,不同运行级别的进程对于系统资源有不同的访问权限。
  2. 为什么操作系统只用了两个运行级别(双模式)?

    • 仅使用两个运行级别已足够用于区分用户态和内核态,保护内核资源
    • 用更少的级别可以简化相关功能的设计
    • 为了与只提供了两个特权级的处理器兼容

分段

8086 在实模式下通过 “物理地址 = 基础地址 (段地址 << 4 ) + 偏移地址“的方法利用 20 位地址总线寻址。

  1. 试描述一下 CS、DS、SS 和 GDTR、LDTR 的关系。
    • GDTR 和 LDTR 分别存储 GDT 和 LDT 的首地址(线性地址)
    • CS、DS、SS 分别表示代码、数据、堆栈段,存储对应的段选择符 (可见部分) 和段描述符信息 (不可见部分)。
      • 当其为用户程序的段描述符时指向 LDT 中的描述符项,描述符项描述对应的段信息
      • 当其为内核信息时,各自指向 GDT 中的描述符项
  2. GDT、LDT 里面存储的都是段描述符,有什么区别吗?
    • GDT 中的段描述符由内核管理和访问,所描述的地址是全局地址空间中的一部分
    • LDT 中的段描述符可以由用户程序访问,每个程序只能访问自己的 LDT,所以是局部地址空间。

⚡️值得注意的是,可以通过 LDT 让每个进程使用各自独立的完整线性空间 (依赖于分页机制的帮助),也可以将所有的进程空间映射到同一个线性空间 (比如 Linux 0.11)。

分页

分页机制的优缺点

优点
  • 方便性
    • 在加入分页机制并按页分配物理内存后,每个进程可以独享自己的线性地址空间(除了内核占用的地址空间),不必考虑线性地址是否被其他进程占用。
  • 有效性
    • 因为内存使用了这样较小的单元来分配,每个进程的每个段最多产生不大于一页的内存浪费,选择合理的页面大小可以大大提高物理内存的利用效率。
    • 如果仅使用了分段机制,那么存储在物理内存中的一个数据结构必须包含其全部的信息。但如果使用了分页,那么一个数据结构就可以一部分存储在物理内存中,而另一部分保存在外存中从而提高了物理内存的利用效率(仅在需要时加载对应页面)。
缺点
  • 时间效率问题
    • 使用分页机制后,每次访问内存都需要查找多级页表。虽然可以通过 TLB 利用程序的局部性解决这一问题,但是由于不同进程各自使用独立的完整线性空间,所以在进程切换的过程中可能因为切换页目录而导致缓存失效

虚拟存储

​ 通过使用分段和分页机制和缺页保护机制,每个进程和内核一起独享完整的线性地址空间。分页机制可以将线性地址映射到外存上 (有效位初始化为 0)。当该页被访问时会引发缺页保护的异常,异常处理程序把外存上的该页内容换入内存中,从而可以使主存成为外存的一个高速缓存,这就是虚拟存储的概念。

交换空间:交换内存或交换空间是虚拟内存中使用的一部分物理硬盘。

请求调页与缺页异常

​ 在发生缺页中断时就会引发外存页面的换入操作,这就是请求调页策略。

​ 实现方法就是通过分页机制将线性地址空间映射到物理外存,在程序访问外存对应的线性地址时就会因为页表项上的可用标识为 0 而引发一个缺页异常。此时内核会将该外存页面通过一定策略换入内存中 (可能会需要换出其他内存页),然后返回到引发缺页异常的指令处继续执行,此时就可以直接在内存中访问到对应地址 (中的内容) 了。

给定 Page Fault Rate 0 $\leqslant$ p $\leqslant$ 1,Effective Access Time 可以这么计算: $$ \begin{align} EAT =& (1-p) \times \text{memory access}\ & + \text{p (page fault overhead + swap out + swap in)} \end{align} $$

页替换算法和交换过程
  • 页替换算法:在执行请求调页的时,如果物理内存中没有多余的空页就会需要换出页面,也就是将某一物理内存页面替换为新的页面,此时就称发生了一个页面替换。用于决定哪一个页面在接下来被替换的算法就是页替换算法
    • 先进先出算法 (FIFO)
    • 最近最少使用算法 (LRU)
    • 最少使用算法 (LFU)
  • 交换过程:首先访存指令引发一个缺页异常,然后如果物理内存中有空闲页面,则把引发缺页异常的线性地址在外存中对应的页面加载到内存中的空闲页面中;否则由页替换算法决定将要被换出的页面,然后内核将该页面换出到外存中并随后将需要访问的页面加载到内存中的该物理页面。
系统颠簸

​ 系统颠簸 / 内存颠簸:在页面置换过程中的一种最糟糕的情形是,刚刚换出的页面马上又要换入主存,刚刚换入的页面马上就要换出主存,这种频繁的页面调度行为称为抖动颠簸

​ 如果一个进程在换页上用的时间多于执行时间,那么这个进程就在颠簸。频繁的发生缺页中断的主要原因是某个进程频繁访问的页面数目高于可用的物理页帧数目

工作集

​ 工作集(或驻留集)是指在某段时间间隔内,进程要访问的页面集合。经常被使用的页面需要在工作集中,而长期不被使用的页面要从工作集中被丢弃。为了防止系统出现抖动现象,需要选择合适的工作集大小。

​ 工作集模型的原理是:让操作系统跟踪每个进程的工作集,并为进程分配大于其工作集的物理块。如果还有空闲物理块,则可以再调一个进程到内存以增加多道程序数。如果所有工作集之和增加以至于超过了可用物理块的总数,那么操作系统会暂停一个进程,将其页面调出并且将其物理块分配给其他进程,防止出现抖动现象。

实例理解

实例 1

​ 现在假设一个执行文件里保存了内核的代码段和数据段,一个执行文件里保存了用户空间的代码段和数据段。

  1. 我们直接把这四个段装载到物理内存,可能会存在什么问题?
  • 直接存储在物理内存中就无法利用 CPU 提供的保护机制,从而有可能遇到内存被误用的问题
  1. 我们想把内核的段映射到内核空间 (CPL、DPL 为 0),把用户的段映射到用户空间 (CPL、DPL 为 3),直接在 GDT 里创建四段描述符,可以吗?
  • 可以
  1. 在 GDT 里创建内核两个段的描述符,再创建一个LDT的描述符;然后在LDT里再创建两个用户段的描述符,也是一种方案。这两种方案各有什么优缺点?
  • 第一种方案中使用的结构更少,方便管理,只需要访问 GDT 就可以将逻辑地址映射到线性地址。不过没有隔离内核空间的描述符与用户空间的描述符,相比第二种方案缺少内核保护机制(可以通过设定不同的 DPL 级别提供保护)
  • 同时第二种方案提供了一个隔离机制,可以实现多个用户任务对线性空间的隔离和映射,每个用户任务只能使用自己的 LDTR 来访问自己的 LDT 表,无法访问其他用户任务的 LDT 表。

实例 2

​ 为了让内核空间的程序和用户空间的程序能够运行,我们需要分别在内核空间和用户空间各创建一个栈段,也就是要在 GDT、LDT 增加两个段描述符。

  1. 基于实例 1 谈谈可以在哪里增加
    • 基于第一种方案:在 GDT 中添加两个堆栈段描述符
    • 基于第二种方案:在 GDT 中添加一个内核堆栈段描述符,在用户空间对应的LDT 中添加一个用户堆栈段描述符

跟踪一次访存过程

8086 中的一次内存访问过程(假设不使用段寄存器中的不可见部分):

  1. 通过 GDTR 获取 GDT 的基地址(线性地址)
  2. 通过 LDTR 和刚才获得的 GDT 基地址获取 LDT 表的段描述符的线性地址,从而获得 LDT 的线性基地址
  3. 通过访问段寄存器获取对应段的选择子,从而得到段描述符在 LDT 或者 GDT 中的偏移
  4. 利用已经获得的这些线性地址和上一步获取到的偏移就可以获取到需要访问的描述符的线性地址
  5. 利用描述符中的内容获取到需要访问的线性地址
  6. 利用线性地址进一步寻址,这是就要根据是否分页有不同的处理
    • 如果没有开启分页机制:“物理地址 = 线性地址”
    • 如果开启了分页机制
      1. 通过 CR3 寄存器获得当前进程的页目录基址 (物理地址)
      2. 以线性地址的高 10 位作为页框号访问页目录,获得对应页表的物理地址
      3. 以线性地址的次高 10 位作为页框号访问上一步获得的页表,得到物理页框号
      4. 将物理页框号和页内偏移 (线性地址低 12 位) 相加得到需要访问的物理地址

之后再访问内存时就直接利用段寄存器中的不可见部分,跳过 1~4 步,直接根据段寄存器中的线性空间段基地址和偏移得到需要访问的内存的线性地址。前几步中的线性地址也需要按照是否开启分页进行处理,这里从简。

用户空间和内核空间的来回切换

理解一个进程程序运行过程中,用户空间和内核空间两个空间的来回切换。

用户进程通过系统调用或者其他中断机制陷入内核态执行后,就开始在内核空间执行了,从中断程序中返回后就继续在用户空间中执行了。

物理内存页的管理(分配和回收)方式

  • 内核使用了一个字节数组 mem_map[] 来表示物理内存页面 (物理内存中 1MB 以上的区域) 的状态。每个字节描述一个物理内存页的占用状态。其中的值表示被占用的次数,0 对应的物理内存空闲着,当申请一页物理内存时,就将对应的字节的值增 1。

  • 在内存管理初始化过程中�,系统首先算出 1MB 以上内存区域对应的内存页面数 (PAGING_PAGES),并把 mem_map[] 所有项置为 100 (占用),然后把主内存区域对应的 mem_map[] 项中的值清零。

2019-12-05-22-00-25

分配内存

get_free_page() 函数用于在主内存区中申请一页空闲内存页,并返回物理内存页的起始地址。它首先扫描内存页面字节图数组 mem_map[],寻找值是 0 的字节项(对应空闲页面)。若无则返回 0 结束,表示物理内存已使用完。若找到值为 0 的字节,则将其置 1,并换算出对应空闲页面的起始地址。然后对该内存页面作清零操作。最后返回该空闲页面的物理内存起始地址。

回收内存

free_page() 用于释放指定地址处的一页物理内存。

  • 其首先判断指定的内存地址是否 <1M,若是则返回,因为 1M 以内是内核专用的
  • 若指定的物理内存地址大于等于实际内存最高端地址,则显示出错信息;
  • 然后由指定的内存地址换算出页面号:(addr - 1M) / 4K;接着判断页面号对应的 mem_map[] 字节项是否为 0
    • 若不为 0,则减 1 返回;
    • 否则显示 “试图释放一空闲页面” 的出错信息。

调度

抢占式、非抢占式调度的概念

CPU调度决定可能发生在以下四种情况下:

  1. 当某个进程从执行状态切换到等待状态 (例如,由于I/O请求);
  2. 当某个进程从执行状态切换到就绪状态 (例如,发生中断);
  3. 当某个进程从等待状态切换到就绪状态 (例如,I/O完成);
  4. 当某个进程终止。
  • 如果仅在 1 或 4 情况下发生的调度,是非抢占式调度
  • 在 2 或 3 情况下发生的调度,是抢占式调度

调度算法的评估标准

  • 面向用户的评价标准
    • 平均等待时间:进程在等待队列中等待的平均时间长度
    • 响应时间:每一个请求提交到响应产生的时间
  • 面向系统的评价标准
    • CPU 利用率
    • 吞吐量:每单位时间内完成的进程数
    • 各类资源的平衡利用
    • 周转时间:进程从生成到结束的时间

线程

定义

线程 (thread) 是操作系统能够进行运算调度的最小单位。

  1. 用户级线程:用户级线程是指不需要内核支持而在用户程序中实现的线程,由用户态程序自己控制切换,不需要内核的干涉。但是一个进程中的多个线程不能同时运行,不能像内核级线程一样更好的运用多核 CPU。在用户级线程策略中,内核以进程为单元进行调度,用户进程自己进行线程的调度,同一个进程中的多个线程对应同一个内核线程。虽然同一个进程的用户级进程彼此之间调度不需要内核参与,但是进行系统调用时还是要通过这个进程中唯一的内核线程进入到内核态执行。
  2. 内核线程:切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态。

TCB 的内容

必须包含的内容

  • 线程使用的运行时栈的栈指针
  • 线程号

如果使用基于栈的线程切换的话,下面的内容就可以直接保存在栈中

  • 线程的运行状态,标明线程是运行还是就绪或者阻塞
  • 线程的 CS 和 IP 寄存器、标志寄存器等 CPU 状态

线程切换

​ ⚡️不同的线程模型有不同的切换方法,这里说的是一对一的线程模型,使用基于栈的切换方式(不是 TSS)。如果是纯用户线程模型,则必须使用基于 TCB 的切换方法,因为内核栈是共用的。注意,此时中断处理的过程就开始和 80386 不一样了!

  1. 线程 A 收到时钟中断信号,硬件完成以下操作
  • 切换 SS:SP 到内核栈,内核栈的 SS:SP 从 TCB 中获得
  • 压栈用户栈的 SS:SP
  • 把自己当前的标志寄存器、CS、IP 压入内核栈中,修改标志寄存器
  • 切换 CS:IP A 进入内核态执行
  1. 此时已经进入了中断处理函数,中断处理函数执行一些准备工作 (保存寄存器状态、修改 DS, ES 等) ,之后调用调度函数 (schedule)。此时返回信息(返回到中断处理函数)压栈在 A 的内核栈
  • 这里有个问题,可能会有人疑惑,就是如何保证 eax 等其他寄存器不受到调度的影响。这里可以这样思考:调度是通过调用 schedule 实现的,只需要能够通过 ret 指令从 schedule 中返回自然就保证了所有寄存器的状态安全。就和任何其他函数调用一样,我运行 call 指令,然后接着运行别的指令,不需要考虑寄存器有没有被修改,就像运行加法指令一样。
  1. 调度函数通过一定的调度算法决定下一步去调用 B 线程,于是找到 B 的 TCB,先把当前的栈指针 (也就是 A 当前的内核栈指针) 保存到 A 的 TCB 中,再从 B 的 TCB 中读取 B 的栈指针,修改当前的 SS 和 SP 完成到 B 的栈的切换。此时就运行在 B 的内核栈上了,之后进行 LDT 等的切换,最后通过一条 ret 指令从 schdule 回到 B 的中断处理函数的栈帧中。
    • 这里有人会认为是从 switch_to 返回,不过其实从结果来看没有区别,如果 switch_to 被封装成了函数,那么就是从 swtich_to 返回到 schedule,然后返回到中断处理函数。在 Linux 0.11 中 switch_to 是一个宏,展开后是一段内联汇编,此时就是直接从 schedule 返回。
  2. 注意,此时回到了 B 的中断处理函数中执行。CPU 接着进行一些处理后执行到中断处理函数的最后一条指令 iret。这条指令会导致从当前栈 ( B 的内核栈) 中恢复标志寄存器、CS:IP 和 B 的用户栈 (SS:SP),这样就相当于恢复了 B 的当初被中断时候的样子,也就完成了调度。

创建线程

根据不同的线程定义有不同的创建方法,以纯用户线程的概念为例:

  • 资源分配:要把一个运行单元创建为一个纯用户线程,先要在内存中要分割出一块空间作为该线程的 TCB,然后给该线程分配一个唯一的线程号保存在系统的线程表中同时将线程号和 TCB 关联起来,最后分配用户态的运行栈空间。
  • 初始化:按照需要初始化 TCB (设定 CS:IP、SS:SP 等)。初始化新的线程的运行时栈,把它制作成一个线程暂停执行时的样子,以便于第一次调用它的时候,使用iret指令能正确恢复执行
  • 调度:设置 TCB 中记录的线程的状态为就绪态,从而从此刻开始可以被操作系统调度执行

释放线程

如果要终止一个线程,只需要把线程对应的 TCB 块和运行栈的内存释放,然后从系统的线程表中删除该线程的线程号。

进程

每个进程创建时获得一个唯一编号 PID 并对应到一个进程控制块 (PCB)。

⚡️所谓进程、线程之类的术语,笔者没有发现有脱离具体模型之外的统一定义:

  • 只能说对于不同的实际操作系统有各自实现相关的具体说明。比如 Linux 就没有线程,也就没有线程的的调度,所有调度是在进程之间完成的,此时要如何讨论进程和线程的区别呢?只不过 Linux 中有些进程之间共享了文件等系统资源,那么他们在实际意义上和那些不与其他进程共享任何资源的进程(进程是一个动态的概念,进程在初始化后必然与其父进程共享了内存等资源,不过之后随时间变化可能会逐渐独立)有所区别,如果非要说它们就是线程,那也是可以的,因为这方面本来就没有共识存在。
  • 所以说很多进程和线程之间的许多概念都是相似或者说相同的,所以本节和下一节的 “轻量级进程 LWP” 都比较简略,具体内容参考 “线程” 这一节对比体会就好。

​ 对于一个显示支持线程的操作系统(比如 Windows 10)来说,每个进程对应了一个进程描述符,这个描述符会轮流指向自己的 N 个线程。进程描述符用于指明这 N 个线程之间共享的资源,包括内存空间和打开的文件,然后线程各自描述自己独享的资源,包括线程 ID、信号掩码等。也就是说实际调度运行是以线程作为单位,进程用于资源的分配管理。但是因为显示支持线程,操作系统有方法可以明确地将线程调度和进程调度(不同进程之间的线程切换)区分开来,在线程调度时进行更少的上下文切换工作。值得注意的是,因为在这种模型中线程之间共享地址空间,所以线程切换是对 TLB 等缓存友好的。

⚡️总结:线程是调度的基本单元,进程是资源分配的基本单元。Linux 中没有区别于进程的线程概念,与其他进程共享部分系统资源的进程叫做轻量级进程,可以理解为每个进程也是一个线程或,既是调度基本单元也是资源分配基本单元。Windows 10 中进程包括多个线程,资源按进程分配,调度以线程为单位,如果切换到另一个进程的线程,就是进程切换。

定义

进程是系统进行资源分配的基本单元,进程之间使用 PID 区分。

PCB的内容

  • 基本信息: 进程号、进程组、进程运行状态、寄存器快照等
  • 文件信息: 打开的文件描述符、工作目录等
  • 内存信息: 页目录的基地址 (CR3)、LDT 段选择符、不同特权级的堆栈指针等等

状态

image-20191106191804188

进程切换

不同进程的线程切换就是进城切换,具体见于线程切换的介绍。

轻量级进程 LWP

定义

​ Linux 中没有显示的线程概念,通过轻量级进城的概念实现调度单元之间的资源共享。实际上,轻量级进程就是和其他进程共享所有 (或大部分) 逻辑地址空间和系统资源 (文件等) 的进程。因为其只独享一个最小的执行上下文和调度程序所需的统计信息,所以叫做轻量级进程

线程对比轻量级进程

​ Linux 没有线程,所以题目更全的说法可以是 “显示支持线程的操作系统中的线程与 Linux 中的轻量级进程的对比”。

  • LWP有它自己的进程标识符,并和其他进程有着父子关系,这些都是线程没有的

  • 线程既可由应用程序管理,又可由内核管理,而 LWP 只能由内核管理并像普通进程一样被调度

死锁

概念

​ 死锁描述多个进程互相持有一定资源并等待其他进程的资源,进而造成这些进程全都因为无法获取需要的资源而暂停运行的状况

充要条件

以下条件其实也可以理解为 ”必要条件“。为什么说是 “必要条件” 而不是 “充要条件”:假设一个在资源等待环路 (A -> B -> C -> A) 中的进程 A 对于资源 a 除了可以向 B 请求外,还可以向环路以外的进程 D 请求资源,如果某一时刻 D 释放了他的 a 资源,则可以解除循环等待。这里假设了资源 a 可以有多份,也就是说虽然每一份不可以共享,但是系统可以提供的资源有多份。

  • 非抢占式:在资源被进程释放前,其他进程无法使用该资源
  • 占有且等待:资源在请求新的资源时持续占有现有的资源
  • 循环等待:若干进程之间形成一种头尾相接的循环等待资源关系
  • 互斥条件:一个资源每次只能被一个进程使用。

应对方法

  • 预防:对可能引发死锁的资源进行访问控制,可以通过为资源添加访问锁等方法实现
  • 避免:在调度过程中避免产生循环等待
  • 检测:检测进程之间资源持有和等待关系对应的有向图中是否存在回路
  • 恢复:可以通过挂起进程后重新分配资源来解除死锁

实例理解

实例 1

​ 纯用户线程的 TCB 位于用户空间,使用用户空间的栈运行,假设 A 用户线程运行过程中,调用 read 系统调用,调用过程中因为磁盘忙面阻塞。而后到了 B 用户线程,而 B 线程运行过程中,需要调用 write 系统调用。请问这时的 B 线程能做 write 系统调用吗?为什么?

这里和🐒题中的说法不一样,笔者只是提供一种解读方法,希望大家能够自己思考一遍。

  • 假设 A 和 B 是由同一个进程创建的纯用户级线程,那么 A 和 B 共享一个内核线程。
    • A 进行系统调度阻塞,内核代码就调动 schedule 切换到另一个进程
    • 也就是说在 A 阻塞结束前,B 线程也会一直阻塞无法运行
    • 所以 B 线程运行过程中,如果需要调用 write 系统调用,那就可以调用,因为此时 A 的 read 调用一定已经返回了,否则 B 无法运行。
  • 假设 A 和 B 是由不同进程创建的纯用户级线程,那么 A 和 B 不共享内核线程
    • A 进行系统调度阻塞,内核代码就调动 schedule 切换到另一个进程
    • 到 B 执行时,无论 A 是否还在阻塞,因为 A 和 B 不共享内核线程,所以 B 可以进行系统调用。

Linux 的调度算法

  • CFS (Completely Fair Scheduling) 算法:CFS 允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法。CFS 在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠 nice 值来计算时间片

Windows 的调度算法

  • Windows 采用基于优先级的、抢占调度算法来调度线程。用于处理调度的 Windows 内核部分称为调度程序,Windows 调度程序确保具有最高优先级的线程总是在运行的。由于调度程序选择运行的线程会一直运行,直到被更高优先级的线程所抢占,或终止,或时间片已到,或调用阻塞系统调用(如 I/O)。如果低优先级线程运行时,更高优先级的实时线程编程就绪,那么低优先级线程就被抢占。

对比 Linux 和 Windows 的线程模型。

  • Linux 内核中准确来讲没有线程的概念,Linux 的线程是一些与其他进程共享内存等资源的轻量级进程
  • Windows 内核中天然支持线程的概念。每个进程都会创建一个主线程,这个主线程在运行过程中又会创建其他的线程。内核通过调度线程来分配 CPU 时间等资源

进程切换 对比 线程切换

可以认为下面的说明是针对 Windows 这样有线程概念的系统。Linux 中不区分进程和线程,所以没有对比

  • 联系:当 CPU 从属于进程 A 的线程切换到一个属于进程 B 的线程的时候,就叫做发生了进程的切换。也就是说进程切换必定带来了线程切换
  • 区别:进程的切换要保存和恢复大量的上下文信息,会比较耗时

哲学家就餐问题

这是一个关于 “死锁” 问题的实例。

img

​ 哲学家就餐问题可以这样表述,假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗米饭,每位哲学家之间各有一支筷子。因为用一支筷子很难吃到米饭,所以假设哲学家必须用两支筷子吃东西。他们只能使用自己左右手边的那两支筷子。

​ 哲学家从来不交谈,这就很危险,可能产生死锁,每个哲学家都拿着左手的餐叉,永远都在等右边的餐叉(或者相反)。

解法 1 —— 服务生解法

​ 一个简单的解法是引入一个餐厅服务生,哲学家必须经过他的允许才能拿起餐叉。因为服务生知道哪支餐叉正在使用,所以他能够作出判断避免死锁。

​ 为了演示这种解法,假设哲学家依次标号为 A 至 E 。如果 A 和 C 在吃东西,则有四支餐叉在使用中。B 坐在 A 和 C 之间,所以两支餐叉都无法使用,而 D 和 E 之间有一只空余的餐叉。假设这时 D 想要吃东西。如果他拿起了第五支餐叉,就有可能发生死锁。相反,如果他征求服务生同意,服务生会让他等待。这样,我们就能保证下次当两把餐叉空余出来时,一定有一位哲学家可以成功的得到一对餐叉,从而避免了死锁。其实也就是通过检测预防来防止进入死锁状态

解法 2 —— 资源分级解法

​ 另一个简单的解法是为资源(这里是餐叉)分配一个偏序或者分级的关系,并约定所有资源都按照这种顺序获取,按相反顺序释放,而且保证不会有两个无关资源同时被同一项工作所需要。在哲学家就餐问题中,资源(餐叉)按照某种规则编号为 1 至 5,每一个工作单元(哲学家)总是先拿起左右两边编号较低的餐叉,再拿编号较高的。用完餐叉后,他总是先放下编号较高的餐叉,再放下编号较低的。在这种情况下,当四位哲学家同时拿起他们手边编号较低的餐叉时,只有编号最高的餐叉留在桌上,从而第五位哲学家就不能使用任何一支餐叉了。而且,只有一位哲学家能使用最高编号的餐叉,所以他能使用两支餐叉用餐。当他吃完后,他会先放下编号最高的餐叉,再放下编号较低的餐叉,从而让另一位哲学家拿起后边的这只开始吃东西。

​ 尽管资源分级能避免死锁,但这种策略并不总是实用的,特别是当所需资源的列表并不是事先知道的时候。例如,假设一个工作单元拿着资源 3 和 5,并决定需要资源 2,则必须先要释放 5,之后释放 3,才能得到 2,之后必须重新按顺序获取 3 和 5。对需要访问大量数据库记录的计算机程序来说,如果需要先释放高编号的记录才能访问新的记录,那么运行效率就不会高,因此这种方法在这里并不实用。

Chandy/Misra 解法

1984年,K. Mani Chandy和J. Misra提出了哲学家就餐问题的另一个解法,允许任意的用户(编号$P_{1},\cdots ,P_{n}$)争用任意数量的资源。与資源分級解法不同的是,这里编号可以是任意的。

  • 把餐叉湊成對,讓要吃的人先吃,沒餐叉的人得到一張換餐叉券。
  • 餓了,把換餐叉券交給有餐叉的人,有餐叉的人吃飽了會把餐叉交給有券的人。有了券的人不會再得到第二張券。
  • 保證有餐叉的都有得吃。

这个解法允许很大的并行性,适用于任意大的问题。

进程通信与同步

​ 首先给出临界区的概念:在同步的程式设计中,临界区(Critical section)指的是一个存取公用资源(例如:共用装置或是共享内存)的程式片段,而这些共用资源有无法同时被多个线程存取的特性

同步: 我们把异步环境下的一组并发进程因直接制约而互相发送消息、进行互相合作、互相等待,使得各进程按一定的速度执行的过程称为进程间的同步

互斥:由于操作系统各进程需要使用共享资源,而这些资源需要排他性使用,各进程之间竞争使用这些资源,这些关系称为进程互斥。

​ 锁是解决临界区问题的一种机制。 当一个进程进入临界区的时候会获得锁,这样其他进程就不能访问该临界区的公共变量了。当进程退出临界区的时候会释放锁,这时其他进程将可以修改临界区中的公共变量。

​ 锁的实现依赖于原子操作(锁的获取和释放)。原子操作可以通过许多方式实现,在单处理器系统中可以通过关中断轻松实现。

关中断

​ 在单处理器系统中。如果 CPU 在执行某一段指令之前关闭中断,就表示该 CPU 在执行这段指令的过程中不会响应中断。所以从关闭中断到打开中断之间的几个指令就相当于是原子操作

⚡️单处理器系统也可以直接将临界区代码放在 clisti 中实现临界区的互斥访问。

信号

信号是一种进程间的软件中断通知和处理机制,可以通过在等待特定信号和接收到特定信号前挂起等操作实现同步。信号可以有不同的值,不同值表示不同的信号和对应的含义。

接收处理

  • 捕获:执行进程指定的信号处理函数被调用
  • 忽略:执行操作系统指定的缺省处理 (进程终止,进程挂起)
  • 屏蔽:禁止进程接收和处理信号

信号量

​ 信号量可以分为二值信号量和计数信号量,其中二值信号量与互斥锁的作用基本相同。一般提到信号量就是指计数信号量,它可以用于控制访问具有多个实例的资源。

​ 一种实际的应用方法就是:信号量的初值设定为可用资源的数量,而某一刻信号量的值则表示还有多少个资源实例可以被其余进程使用。如果信号量为负值,则表示有多少个进程在等待使用该资源。执行一次 wait 操作时,计数值减一;执行一次 signal 时,计数值加一。

条件变量

​ 条件变量一般用于在多线程程序中实现 “等待 -> 唤醒” 逻辑,条件变量利用统一进程的线程之间共享全局变量的特点进行同步。主要包括两个动作:

  • 线程等待 “条件变量成立” 而挂起
  • 线程使 “条件成立”

​ 为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起,线程改变条件变量状态前必须首先锁住互斥锁。函数 pthread_cond_wait 把自己放到等待条件的线程列表上并挂起。

​ 条件变量从实现来讲可以有这样一个数据结构:一个变量和一个等待队列。变量的大小和范围表示某种条件,不满足该条件时进程将会被加入到等待队列,满足该条件时队列中的进程会被唤醒,变成就绪态。

​ 相比于其他方法,条件变量一般可以降低系统的开销,适合于需要频繁切换运行的场景。

管程

管程相比于信号量是一种更高级的同步工具

管程的定义

管程是由局部于自己的若干公共变量及其说明和所有访问这些公共变量的过程所组成的软件模块。

monitor monitor_name
{
    /* shared variable declarations */
    function P1 (...) {
        ...
    }
    function P2 (...){
        ...
    }
    function Pn ( . . . ) {
        ...
    }


    initialization_code (...){
        ...
    }
}

因为管程是互斥进入的,每次只有一个进程在管程内处于活跃状态。所以在管程的入口可以维护有一个进程等待队列:

管程的示意图

不过上面定义的管程在处理某些同步问题时还不够强大,所以需要定义附加的同步机制。这些可以由条件变量来实现,执行 x.wait() 的进程会被挂起,直到另一个进程执行了 x.signal()。使用不同的条件变量维护不同的资源等待队列,每种资源一个队列:

具有条件变量的管程

组成部分
  • 局部于管程的共享变量
  • 对数据结构进行操作的一组过程
  • 对局部于管程的数据进行初始化的语句

引入管程机制的目的

  1. 把分散在各进程中的临界区集中起来进行管理
  2. 防止进程有意或无意的违法同步操作

管程的属性

  • 共享性:管程可被系统范围内的进程互斥访问,属于共享资源
  • 安全性:管程的局部变量只能由管程的过程访问,不允许进程或者其他管程直接访问,管程不能访问不是局部于它的变量
  • 互斥性:多个进程对管程的访问是互斥的。任何时刻,管程中只能有一个活跃进程
  • 封装性:管程内的数据结构是私有的,只能在管程内使用

共享内存

共享内存是多个进程之间通信的一种简单方法,其允许多个进程同时访问同一块内存,当一个进程改变了这块内存中的内容时,其他进程也都能察觉到这个变化(这就要求任何时候只要缓存被更新了,就要写入到内存中,从而令不同处理器能够立即察觉到)。

img

操作系统没有提供对共享内存访问的直接同步措施,一般需要用户使用互斥锁或者信号量控制进程访问共享内存。

优缺点

  • 优点:快速共享大量数据
  • 缺点:需要使用信号量等机制实现各进程之间的同步操作

共享内存的操作

  • 分配共享内存:创建或者打开一块共享内存,返回共享内存的标识符
  • 共享内存的映射:将一块共享内存映射到调用进程的数据段中,返回的指针指向共享内存在当前进程地址空间中的地址
  • 解除共享内存的映射:将共享内存和当前进程分离
  • 控制共享内存的属性
// 创建或者打开共享内存
int shmget(key_t key, size_t size, int shmflg);

// 共享内存的映射
void* shmat(int shmid, const void *shmaddr, int shmflg);

// 解除映射
int shmdt(const void *shmaddr);

// 属性控制
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

管道

进程间基于内存文件的通信机制,因为子进程从父进程继承文件描述符,所以可以知道父进程的管道信息。

消息队列

​ 消息队列是一种进程间通信或者同一进程的不同线程间的通信方式。使用者可以向一个 FIFO 队列中写入或者读取输入,包括事件发生的时间、处理需要的参数等。消息的发送方和接收方不需要同时与消息队列交互,消息会保存在队列中,直到接收者取走它。和信号相比,消息队列能够传递更多的信息。与管道相比,消息队列提供了有格式的数据,这可以减少开发人员的工作量。

消息和事件

操作系统可以感知到事件的发生,并将其转换为一个特定的消息发送到给定的消息队列中

  • 消息:一份信息,可以由操作系统产生。由用户触发的事件转换而来,或者由另一个消息产生
  • 事件:一个动作,只能由用户触发,是消息的来源之一

实例理解

生产者-消费者问题

问题:有一些生产者和一些消费者,生产者每次生产一个单位并放入缓存中,消费者每次从缓存中消耗一个单位,如何保证各个生产者和消费者实现合作以达到按顺序消费产品的目的。

假设:

  • 建立一个生产者进程,N 个消费者进程
  • 用文件建立一个共享缓冲区
  • 生产者依次写入 0, 1, ...., max_iteration
  • 消费者每次从缓冲区中读取一个数,将取出的数字删除并输出本进程 PID + 数字到标准输出
  • 缓冲区大小为 10

读者-写者问题

  • 当没有写者时,多个读者线程可以同时读取资源
  • 只允许一个写者访问资源
  • 读、写互斥

读者-写者问题可以分为三种情况

读者优先
  • 在读线程读取的过程中,如果有写进程想访问资源,需要等待到所有读线程都读取完毕
  • 除非当前有写者正在写资源,否则读者线程不需要等待就可以直接读

分析一下为什么叫做读者优先:

  • 当读者获得临界区的访问权,则此时的 reader_count > 0 则读者尚未释放 file_guard。写者不能获得临界区的访问权,有一个被阻塞在 file_guard 信号中,其余的被阻塞在 write_guard ,只有当读者访问完毕,写者才有机会获得临界区的访问权
  • 若写者获得临界区的访问权,而且有源源不断的写者过来,那么读者能不能抢得临界区的访问权呢?答案是肯定的。考虑此时的读者进程阻塞情况,有一个读者进程阻塞在 file_guard 中,其余的读者均阻塞在 reader_count_guard,而由于 write_guard 的存在每个时刻只有一个写者进程阻塞在 file_guard 中,其余的全被阻塞在 write_gurad中。则当写者进程访问完毕后,此时阻塞在 file_guard 中的进程只有读者进程,则也就只有读者进程先被激活访问

实现方案:

int reader_count = 0;             // 读者队列的长度
semaphore reader_count_guard = 1; // 保护对 `reader_count` 的访问
semaphore file_guard = 1;         // 保护对文件资源的访问
semaphore write_guard = 1;        // 保证阻塞在 file_guard 的写者唯一

void reader() {
    wait(reader_count_guard);
    if (!reader_count) {
        // if currently no reader, request file resource
        wait(file_guard);
    }
    reader_count += 1;
    signal(reader_count_guard);

    // 执行读者代码

    wait(reader_count_guard);
    reader_count -= 1;
    if (!reader_count) {
        // release file resource if no reader
        signal(file_guard);
    }
    signal(reader_count_guard);
}

void writer() {
    wait(write_guard);
    wait(file_guard);

    // 执行写者代码

    signal(file_guard);
    signal(write_guard);
}
写者优先
  • 当有写者在阻塞队列时,应该阻止后续的读请求,禁止加入读者队列

分析一下为什么叫做写者优先

  • 首先要解决的是当读者获得了访问临界区的权利时,且读者进程访问的很密集时 (即很多读者都需要访问),写者如何抢得访问权呢?由于 enter_guard 的存在每次阻塞在 read 中的读者进程最多只有一个,而当读者进程访问时,写者进程一个被阻塞在 read 中,其余的全部阻塞在 writer_count_guard 中。当读者访问完毕,释放 read,此时,阻塞在其中的进程只有写者进程,则写者进程得到激活。
  • 当写者获得临界区的访问权时,读者只能等到临界区空闲时才能得到临界区访问权。因为当写者获得临界区时,所有的读者都会阻塞在 enter_guard 信号和 read 信号中。 而只有最后一个写者访问完临界区时,才会 signal(read), 使得阻塞在 read 中唯一的读者获得临界区访问权。
int reader_count = 0; // 读者队列的长度
int writer_count = 0; // 写者队列的长度
semaphore read = 1;
semaphore reader_count_guard = 1; // 保护对 `reader_count` 的访问
semaphore writer_count_guard = 1; // 保护对 `writer` 的访问
semaphore file_guard = 1;         // 保护对文件资源的访问
semaphore enter_guard = 1;	      // 保证阻塞在 read 的只有一个线程

void reader() {
    wait(enter_guard);
    wait(read);
    wait(reader_count_guard);
    if (!reader_count) {
        wait(file_guard);
    }
    reader_count += 1;
    signal(reader_count_guard);
    signal(read);
    signal(enter_guard);

    // perform reader code

    wait(reader_count_guard);
    reader_count -= 1;
    if (!reader_count) {
        signal(file_guard);
    }
    signal(reader_count_guard);
}

void writer() {
    wait(writer_count_guard);
    if (!writer_count) {
        wait(read);
    }
    writer_count += 1;
    signal(writer_count_guard);

    wait(file_guard);

    // perform writer code

    signal(file_guard);

    wait(writer_count_guard);
    writer_count -= 1;
    if (!writer_count) {
        signal(read);
    }
    signal(writer_count_guard);
}
公平竞争
  • 读者和写者都是只要抢到 enter_guard 就可以阻止新的对方线程进入资源的等待队列
int reader_count = 0; // 读者队列的长度
semaphore reader_count_guard = 1; // 保护对 `reader_count` 的访问
semaphore file_guard = 1;         // 保护对文件资源的访问
semaphore enter_guard = 1;	      // 保证阻塞在 read 的只有一个线程

void reader() {
    wait(enter_guard);
    wait(reader_count_guard);
    if (!reader_count) {
        wait(file_guard);
    }
    reader_count += 1;
    signal(reader_count_guard);
    signal(enter_guard);

    // perform reader code

    wait(reader_count_guard);
    reader_count -= 1;
    if (!reader_count) {
        signal(file_guard);
    }
    signal(reader_count_guard);
}

void writer() {
    wait(enter_guard);
    wait(file_guard);

    // perform writer code

    signal(file_guard);
    signal(enter_guard);
}

文件系统

文件系统是操作系统中管理持久性数据的子系统,提供数据存储和访问功能。

数据结构

  • 卷控制块(每个文件系统一个),在文件系统挂载时被加载到内存中
  • 文件控制块(每个文件一个),在文件被访问时进入内存
  • 目录节点(每个目录项一个),在遍历文件路径时进入内存

虚拟文件系统

  • 提供统一的文件和文件系统接口
  • 管理所有文件和文件系统关联的数据结构
  • 高效查询例程,遍历文件系统
  • 提供和特定文件系统模块的交互功能

文件数据块分配方式

  • 连续分配
  • 链式分配
  • 索引分配

其他

​ 说明:Linux0.11是把多程序(进程)的空间映射到了一个线程空间,但现代OS一般把一个程序(进程)和内核合在一起单独映射成一个线性空间。把程序空间和内核空间映射到一个线性空间,如果提前规定好内核和用户程序使用哪部分的线性空间,这样就可在链接的时候,由链接器直接生成线性地址,也就是在链接时,就把内核和用户程序的各个段都映射到线性空间(进程空间)了。这一点等同于 Intel386 的平台地址思想。实际上在运行前,进程的分段映射就已经完成了,实际运行时,只需要分页就可以了。

局部性

  • 时间局部性
    • 程序在运行过程中往往会重复访问同一个内存地址
  • 空间局部性
    • 程序在访问某一地址后,常常会访问其附近的另一地址。程序经常访问的内存地址常常有聚集性

可执行文件格式

ELF 格式:

  • ELF 头部
  • 程序头表(可选)
  • 第一节
  • ..…..…
  • 第 n 节
  • “节”头表 “

参考资料

  1. https://www.cnblogs.com/BoyXiao/archive/2010/11/20/1882716.html
  2. https://www.cnblogs.com/zhuyuanhao/archive/2012/10/16/3262870.html
  3. https://github.com/jokmingwong/Monkey50
  4. https://zhuanlan.zhihu.com/p/57349087
  5. http://c.biancheng.net/view/1234.html
  6. https://baike.baidu.com/item/%E7%AE%A1%E7%A8%8B
  7. https://zhuanlan.zhihu.com/p/37808566
  8. https://zh.wikipedia.org/wiki/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97
  9. https://blog.csdn.net/c1194758555/article/details/52805918
  10. http://c.biancheng.net/cpp/html/2601.html
  11. https://www.jianshu.com/p/2226a61740a7
  12. https://blog.csdn.net/JY_Sharer/article/details/16960385