1、现代计算机系统 由一个或多个处理器、主存、磁盘、打印机、键盘、鼠标、显示器、网络接口 以及 各种其他输入/输出设备组成
2、操作系统,它的任务是 为用户程序提供一个更好、更简单、更清晰的计算机模型,并管理刚才提到的所有设备
3、用户与之交互的程序,基于文本的通常称为 shell,而基于图标的则称为图形用户界面(Graphical User Interface, GUI),实际上并不是操作系统的部分,尽管这些程序使用操作系统来完成工作
4、多数计算机 有两种运行模式:内核态 和 用户态。软件中最基础的部分是 操作系统,它运行在 内核态(也称为管态、核心态)。在这个模式中,操作系统 具有对所有硬件的完全访问权,可以执行 机器能够运行的任何指令。软件的其余部分 运行在用户态。在用户态下,只使用了 机器指令中的一个子集。特别地,那些会影响机器的控制或可进行 I/O(输入/输出)操作的指令,在用户态中的程序里是禁止的
5、用户接口程序(shell或者GUI)处于用户态,程序中的最低层次,允许用户运行其他程序,诸如Web浏览器、电子邮件阅读器 或 音乐播放器等。这些程序 也大量使用操作系统
操作系统 运行在裸机之上,为所有其他软件提供基础的运行环境
6、操作系统 和 普通软件(用户态)之间的主要区别是,如果用户不喜欢 某个特定的电子邮件阅读器,他可以自由选择另一个,或者 自己写一个,但是不能自行写一个 属于操作系统一部分的 时钟中断处理程序。这个程序由硬件保护,防止 用户试图对其进行修改
一些在用户态下 运行的程序 协助操作系统完成特权功能。例如,经常有一个程序 供用户修改其口令之用。但是这个程序 不是操作系统的一部分,也不在 内核态下运 行,不过它明显地带有一些敏感的功能,并且必须以某种方式给予保护
一些传统上被认为是操作系统 的部分(诸如文件系统)在用户空间中运行。在这种类系中,很难划分出一条明显的界限。在内核态 中运行的当然是操作系统的一部分,但是一些在内核外运行的程序 也有争议地被认为 是操作系统的一部分,或者至少与操作系统密切相关
7、操作系统 更加大型、 复杂和长寿
1、什么是操作系统
1、操作系统是一种 运行在内核态的软件。操作系统 有两个基本独立的任务,即为应用程序员(实际上是应用程序)提供一个资源集的清晰抽象,并管理这些硬件资源,而不仅仅是一堆硬件
1.1 作为扩展机器的操作系统
1、多数计算机的体系结构(指令集、存储组织、I/O和总线结构)是很原始的,而且编程是很困难的,尤其是对输入/输出操作而言
使用 一些叫作硬盘驱动 的软件来和硬件交互。这类软件 提供了 读写硬盘块的接口,而不用深入细节
所有的操作系统 都提供使用硬盘的又一层抽象:文件。使用该抽象,程序能创建、读写文件,而不用 处理硬件实际工作中那些恼人的细节
抽象 是管理复杂性的关键。好的抽象 可以把一个几乎不可能管理的任务 划分为 两个可管理的部分。其第一部分是 有关抽象的定义和实现,第二部分是 随时用这些抽象解决问题
操作系统的任务是 创建好的抽象,并实现和管理它所创建的对象
2、操作系统的任务之一 就是隐藏硬件,呈现给程序(以及程序员)良好、清晰、优雅、一致的抽象
操作系统的实际客户是 应用程序。它们直接 与操作系统及其抽象打交道。相反,最终用户 与用户接口提供的抽象打交道,或是命令行 shell 或是 图形用户接口,提供了非常不同的用户接口,操作系统下面的抽象是相同的
1.2 作为资源管理者的操作系统
1、把操作系统 看作向应用程序提供基本抽象的概念,是一种 自顶向下的观点。按照另一种 自底向上的观点,操作系统则用来 管理一个复杂系统的各个部分
操作系统的任务是 在相互竞争的程序之间 有序地控制对处理器、存储器以及其他I/O接口设备的分配
2、现代操作系统 允许同时在 内存中运行多道程序。假设 在同一台计算机上运行的三个程序 试图同时在同一台打印机上输出计算结果,那么开始的几行 可能是程序1的输出,接着几行 是程序2的输出,然后又是程序3的输出等,最终结果将是一团糟
采用将打印结果 送到磁盘上缓冲区的方法,操作系统 可以把潜在的混乱有序化。在一个程序结束后,操作系统可以 将暂存在磁盘上的文件 送至打印机输出,同时其他程序 可以继续产生更多的输出结果,很显然,这些程序的输出 还没有真正送往打印机
3、当一台计算机(或网络)有多个用户时,管理和保护存储器、I/O设备 以及 其他资源的需求变得强烈起来,因为用户间 可能会互相干扰。另外,用户通常 不仅共享硬件,还要 共享信息(文件、数据库等)。操作系统的主要任务是 记录哪个程序在使用什么资源,对资源请求 进行分配,评估使用代价,并且为不同的程序和用户 调解互冲突的资源请求
4、资源管理 包括 用以下两种不同方式实现多路复用(共享)资源:在时间上复用 和 在空间上复用。当一种资源在时间上复用时,不同的程序或用户轮流使用它
资源是如何实现 时间复用的 —— 谁应该是下一个 以及 运行多长时间等——则是操作系统的工作
5、另一类复用是 空间复用。每个客户得到资源的一部分,从而取代了 客户排队。例如,通常在若干运行程序之间 分割内存,这样每一个运行程序 都可以同时驻留内存(例如,为了轮流使用CPU)。假设有足够的内存 可以存放多个程序,那么在内存中 同时存放若干个程序的效率,比把所有内存都给一个程序的效率要高得多,特别是,如果一个程序 只需要整个内存的一小部分,结果更是如此
有关空间复用的其他资源 还有磁盘。在许多系统中,一个磁盘 同时为许多用户保存文件。分配磁盘空间 并记录谁正在使用哪个磁盘块,是操作系统的典型任务
3、计算机硬件简介
操作系统 扩展了 计算机指令集 并管理计算机的资源。为了能够工作,操作系统必须 了解大量的硬件,至少需要了解 硬件如何面对程序员
3.1 处理器
1、从内存中 取出指令并执行之。在每个CPU基本周期中,首先从 内存中取出指令,解码 以确定其类型和操作数,接着执行之,然后取指、解码 并执行下一条指令
每个 CPU 都有一套可执行的专门指令集。x86处理器不能执行ARM程序。由于 用来访问内存以得到指令或数据的时间 要比执行指令花费的时间长得多,因此,所有的 CPU 内都有些用来 保存关键变量和临时数据的寄存器。这样,通常在指令集中 提供一些指令,用以将一个字 从内存调入寄存器,以及将一个字 从寄存器存入内存。其他的指令 可以把来自寄存器、内存的操作数组合,或者 用两者产生一个结果,如将两个字相加 把结果存在寄存器或内存中
2、除了用来保存变量和临时结果的 通用寄存器 之外,多数计算机 还有一些对程序员可见的专用寄存器。其中之一是 程序计数器,它保存了 将要取出的下一条指令的内存地址。在指令取出之后,程序计数器 就被更新 以便指向后继的指令
3、另一个寄存器是 堆栈指针,它指向内存中 当前栈的顶端。该栈包含了 每个执行过程的栈帧
管理函数调用 和 执行的重要数据结构,当一个函数 被调用时,系统会为 该函数分配一个栈帧,它位于 运行时栈(call stack)中。栈帧存储了 函数的局部变量、参数、返回地址 和 其他控制信息。当函数调用结束时,栈帧被销毁,程序控制 返回给调用函数
一个过程的栈帧中 保存了有关的输入参数、局部变量 以及 那些没有保存在寄存器中的临时变量
4、程序状态字 (Program Status Word, PSW) 寄存器。这个寄存器 包含了 条件码位 (由比较指令设置,用来反映最近一次算术、逻辑或比较指令执行结果的一组位,用来为程序的下一步决策提供依据)、CPU优先级、模式(用户态 或 内核态),以及 各种其他控制位。用户程序通常 读整整个PSW,但是,只对其中的少量字段写入。在系统调用 和 I/O 中,PSW 的作用很重要
5、操作系统 必须知晓 所有的寄存器。在 时间多路复用 CPU中,操作系统 经常会在停止正在运行的某个程序 并启动(或再启动)另一个程序。每次停止一个运行着的程序时,操作系统必须保存 所有的寄存器值,这样在稍后该程序被再次运行时,可以 把这些寄存器重新装入
许多现代 CPU 具有 同时取出多条指令的机制。例如,一个 CPU 可以有单独的取指单元、解码单元 和 执行单元,于是它执行指令 n 时,还可以对指令 n+1 解码并且读取指令 n + 2。这样的机制称为 流水线
在多数的流水线设计中,一旦一条指令被取进流水线中,它就必须被执行完毕,即令前一条取出的指令是条件转移,它也必须被执行完毕
(流水线允许处理器的多个部分同时处理多条指令的不同阶段,从而提高处理速度。)
1. 指令 A (条件转移)
2. 指令 B
3. 指令 C
如果指令 A 是 一条条件转移指令(例如 跳转到某个位置或不跳转),在流水线中 遇到条件转移指令时,处理器还无法知道是否需要跳转,因此会 继续取入后续指令 B 和 C,并将 它们放入流水线中开始处理。这带来了一个问题:如果指令 A 的条件判断结果 决定了程序执行流程,而在判断结果出来之前,处理器已经开始执行 B 和 C,那么如果判断结果表明需要跳转,B 和 C 就是不应该被执行的
因此,问题的关键在于:
控制依赖:指令 B 和 C 的执行是依赖于指令 A 的执行结果的
指令取出和执行:即使处理器不知道条件转移的结果,它也会继续将指令放入流水线,以确保流水线不会闲置。这样做的代价是,可能需要对错误取入的指令 进行处理
现代处理器通常会 采用分支预测 技术,即根据 某些历史信息或其他启发式方法,猜测条件转移的结果。如果猜测正确,流水线 可以继续不间断地工作;如果猜测错误,处理器就 必须回滚那些已经进入流水线 但不应被执行的指令(例如指令 B 和 C)
6、比流水线更先进的设计是 超标量CPU
有多个执行单元,例如,一个 CPU 用于整数算术运算,一个 CPU 用于浮点算术运算,一个 CPU 用于布尔运算。两个或更多的指令被同时取出、解码 并装人暂存缓冲区中,直至 执行完毕。只要有空闲的执行单元 就检查保持缓冲区中 是否有可处理的指令,如果有,就把指令从缓冲区中 移出并执行之。这种设计有一种隐含的作用,即程序的指令 经常不按顺序执行。在多数情况下,硬件负责保证这种运算的结果 与顺序执行指令时的结果相同
7、多数 CPU 都有两种模式,即前面已经提及的 内核态和用户态。通常,在 PSW(程序状态字)中有一个二进制位控制这两种模式。当在内核态运行时,CPU 可以执行指令集中的每一条指令,并且使用硬件的每一种功能。在台式机和服务器上,操作系统在内核态下运行,从而可以访问整个硬件。而在大多数嵌入式系统中,一部分操作系统运行在 内核态,其余的部分则运行在 用户态
用户程序 在用户态下运行,仅允许执行整个指令集的一个子集 和 访问所有功能的一个子集。一般而言,在用户态中有关 I/O 和 内存保护的 所有指令是禁止的。当然,将 PSW(程序状态字)中的模式位设置成 内核态也是禁止的
8、为了 从操作系统中获得服务,用户程序 必须使用 系统调用 以陷入内核并调用操作系统。TRAP 指令(生成一个软件中断(软中断),引发控制权 从用户模式切换到内核模式)把用户态切换成内核态,并启用操作系统。当有关工作完成之后,在系统调用后面的指令 把控制权返回给用户程序
计算机使用陷阱 而不是一条指令来执行系统调用。其他的多数陷阱 是由硬件引起的,用于警告有异常情况发生,如试图被零除或浮点下溢等。在所有的情况下,操作系统都得到控制权 并决定如何处理异常情况
由于出错的原因,程序不得不终止。在其他情况下可以忽略出错(如溢数可以置为零)
最后,若程序已经提前宣布 它希望处理某类条件,那么控制权 还必须返回给该程序,让它处理相关的问题
9、超线程和多核芯片
下一步 不仅是有多功能部件,某些控制逻辑 也会出现多个:多线程 或 超线程,多线程允许 CPU 保持两个不同的线程状态,然后 在纳秒级的时间尺度上来回切换。多线程 并不提供真正的并行处理。在一个时刻 只有一个进程在运行,但是线程的切换时间 则减少到纳秒数量级
每个线程在操作系统看来 就像是单个的 CPU。考虑一个实际有两个 CPU 的系统,每个 CPU 有两个线程。这样操作系统将把它看成4个 CPU。如果在某个特定时间点上,只能维持两个 CPU 忙碌的工作量,那么在一个 CPU 上调度两个线程,而让另一个 CPU 完全空转,就没有优势了。这种选择 远远不如在每个 CPU 上运行一个线程的效率高
包含2或4个完整处理器或内核的CPU芯片
多核芯片上有有效地装有 4 个小芯片,每个小芯片 都是一个独立的CPU
GPU 指的是由成千上万个微核 组成的处理器。他们擅长 处理大量并行的简单计算,比如 在图像应用中渲染多边形。他们不太能胜任 串行任务,并且很难编程。虽然 GPU 对操作系统很有用(比如加密或者处理网络传输),但操作系统 本身不可能运行在GPU上
3.2 存储器
1、存储器系统 采用一种分层次的结构
2、存储器系统的顶层是 CPU 中的寄存器。他们用与 CPU 相同的材料制成,所以和 CPU 一样快。显然,访问他们是无延迟的。其典型的存储容量是,在 32 位 CPU 中为 32×32 位,而在 64 位 CPU 中为 64×64 位。在这两种情景下,其存储容量都小于1KB。程序必须在软件中自行管理 这些寄存器
3、下一层是 高速缓存,它多数 由硬件控制。主存被分割成 高速缓存行,其典型大小为64字节,地址0至63对应高速缓存行0,地址64至127对应高速缓存行1,以此类推。最常用的高速缓存行 放置在 CPU 内部 或者 非常接近 CPU 的高速缓存中
当某个程序需要读一个存储字时,高速缓存硬件 检查所要求的高速缓存行 是否在高速缓存中。如果是,称为高速缓存命中,缓存满足了请求,就不需要 通过总线把访问请求送往主存。高速缓存命中 通常需要两个时钟周期。高速缓存未命中 就必须访问内存,这要付出大量的时间代价。由于高速缓存的价格昂贵,所以其大小有限。有些机器 具有两级甚至三级高速缓存,每一级高速缓存比前一级慢且容量更大
例如,多数操作系统 在内存中 保留频繁使用的文件 (的一部分),以避免从磁盘中 重复地调取这些文件。相似地,类似于 /home/ast/projects/minix3/src/kernel/clock.c
的长路径名 转换成 文件所在的磁盘地址的结果,也可以放入缓存,以避免 重复寻址
还有,当一个 Web 页面(URL)的地址 转换为 网络地址(IP地址)后,这个转换结果 也可以缓存起来供将来使用
对于 CPU 缓存中的 主存缓存行,每当有缓存未命巾时,就会调新的内容。通常 通过所引用内存地址的高位计算应该使用的缓存行
现代CPU 中设计了 两个缓存。第一级 或称为L1缓存 总是CPU中,通常用来将 已解码的指令调入CPU的执行引擎。对于那些频繁使用的数据字,多数芯片安排有 第二个L1缓存。典型的 L1 缓存大小为 16KB。另外,往往还设计有二级缓存,称为 L2 缓存,用来存放近来使用过的 若干兆字节的内存储字。L1 和 L2 缓存之间的差别 在于时序。对L1缓存的访问,不存在任何延时;而对L2缓存的访问,则会延时 l 或 2 个时钟周期
一个 L2 缓存被所有的核共享 或者 每个核有自己的 L2 缓存
每种策略都有自己的优缺点。共享L2缓存 需要有一种更复杂的缓存控制器,而 拥有自己的缓存方式 在设法保持 L2 缓存一致性上存在困难
4、再下一层是 主存。这是 存储器系统的主力。主存通常称为 随机访问存储器(Random Access Memory, RAM),所有 不能在高速缓存中 得到满足的访问请求 都会转往主存
5、除了 主存之外,许多计算机已经在使用 少量的非易失性随机访问存储器。它们与RAM不同,在电源切断之后,非易失性随机访问存储器 并不丢失其内容
只读存储器(Read Only Memory, ROM)在工厂中 就被编程完毕,然后 再也不能被修改。ROM 速度快且便宜。在有些计算机中,用于启动计算机的引导加载模块 就存放在 ROM 中。另外,一些 I/O 卡也采用 ROM 处理底层设备控制
EEPROM(Electrically Erasable PROM,电可擦除可编程ROM)和 闪存(flash memory)也是非易失性的,但是与 ROM 相反,它们可以 消除和重写
6、计算机利用 CMOS 存储器保持当前时间和日期。CMOS 存储器和递增时间的时钟电路 由一块小电池驱动,所以,即使计算机没有上电,时间 也仍然可以正确地更新。CMOS 存储器还可以 保存配置参数,如哪一个磁盘是 启动磁盘等。之所以采用 CMOS 是因为它消耗的电能非常少
3.3 磁盘
虚拟内存机制,将程序放在磁盘上,而将 主存作为一种缓存,用来保存 最频繁使用的部分程序。这种机制 需要快速地映像内存地址,以便 把程序生成的地址 转换为 有关字节在 RAM 中的物理地址。这种映像由 CPU 中的一个称为 存储器管理单元 (Memory Management Unit, MMU) 的部件来完成
在多道程序系统中,从一个程序 切换到 另一个程序,有时称为 上下文切换,有必要 对来自缓存的所有改动过的块 进行写入磁盘操作,并修改 MMU 中的映像寄存器
3.4 I/O设备
1、I/O设备 一般包括两个部分:设备控制器 和 设备本身。控制器 是插在电路板上的一块芯片或一组芯片,这块电路板 物理地控制设备。它从操作系统接收命令,例如,从设备读数据,并且完成数据的处理
控制器的任务是 为操作系统提供一个简单的接口,由于实际的设备接口 隐藏在控制器中,所以,操作系统 看到的是 对控制器的接口,这个接口 可能和设备接口有很大的差别
2、每类设备控制器 都是不同的,所以,需要不同的软件 进行控制。专门与控制器对话,发出命令 并接收响应的软件,称为 设备驱动程序。每个控制器厂家 必须为所支持的操作系统 提供相应的设备驱动程序
设备驱动程序 可以在内核外运行。绝大多数驱动程序仍然需要 在内核态运行(程序运行 可以分为两种模式:用户态(内核外运行)和内核态(内核态运行))。只有很少一部分现代系统 在用户态运行全部驱动程序。在用户态运行的驱动程序 必须能够以某种受控的方式访问设备
3、要将 设备驱动程序装入操作系统,有三个途径
第一个途径是 将内核与设备驱动程序重新链接,然后重启动系统。许多UNIX系统以这种方式工作。第二个途径是 在一个操作系统文件中设置一个入口,并通知 该文件需要一个设备驱动程序,然后重启动系统。在系统启动时,操作系统 去找寻所需的设备驱动程序并装载之。Windows就是以这种方式工作。第三种途径是,操作系统 能够在运行时接受新的设备驱动程序 并且立即将其安装好,无须重启动系统
4、每个设备控制器 都有少量用于 通信的寄存器。例如,一个最小的磁盘控制器 也会有用于指定磁盘地址、内存地址、扇区计数和方向(读或写)的寄存器。要激活控制器,设备驱动程序 从操作系统获得一条命令,然后翻译成对应的值,并写进设备寄存器中。所有设备寄存器的集合 构成了 I/O 端口空间
设备寄存器 被映射到 操作系统的地址空间(操作系统可使用的地址),就可以像 普通存储字一样 读出和写入。在这种计算机中,不需要专门的 I/O 指令,用户程序 可以被硬件阻挡在外,防止 其接触这些存储器地址(例如,采用 基址和界限寄存器)
在另外一些计算机中,设备寄存器 被放入一个专门的 I/O 端口空间中,每个寄存器 都有一个端口地址。在这些机器中,提供在内核态中 可使用的专门 IN 和 OUT 指令,供设备驱动程序 读写这些寄存器用。前一种方式 不需要专门的 I/O 指令,但是 占用了一些地址空间。后者 不占用地址空间,但是需要专门的指令
5、实现 输入和输出的方式 有三种
1)在最简单的方式中,用户程序 发出一个系统调用,内核 将其翻译成一个对应设备驱动程序的过程调用。然后设备驱动程序启动 I/O 并在一个连续不断的循环中 检查该设备,看该设备是否完成了工作(一般有一些二进制位用来指示设备 仍在忙碌中)。当 I/O 结束后,设备驱动程序 把数据送到指定的地方(若有此需要),并返回。然后操作系统 将控制返回给调用者。这种方式称为 止等待(busy waiting),其缺点是要占据 CPU, CPU 一直轮询设备 直到对应的 I/O 操作完成
2)第二种方式是 设备驱动程序启动设备 并且让该设备在操作完成时 发出一个中断。设备驱动程序 在这个时刻返回。操作系统 接着在需要时阻塞调用者 并安排其他工作进行。当设备驱动程序检测到 该设备的操作完毕时,它发出一个中断 通知操作完成
中断是非常重要的,I/O的三步过程
在第1步,设备驱动程序 通过写设备寄存器 通知设备控制器做什么。然后,设备控制器 启动该设备。当设备控制器 传送完毕 被告知要进行读写的字节数量后,它在第2步中 使用特定的总线发信号 给中断控制器芯片。如果中断控制器 已经准备接收中断(如果正忙于一个更高级的中断,也可能不接收),它会在CPU芯片的一个管脚上 声明,这就是第3步(中断控制器 是用于管理和处理 各种外部设备请求(中断信号)的硬件组件。当设备发出中断请求时,中断控制器的作用是 确定哪个中断需要被处理,以及 如何向CPU传达这些请求)
CPU的中断响应机制: CPU不能直接接收所有设备的中断请求,它依赖于中断控制器对这些请求进行管理。中断控制器会根据请求的优先级进行排序,选择某个请求发送给CPU。如果中断控制器 认为当前有中断需要处理,且CPU可以接收中断,那么中断控制器会在 CPU芯片的一个专用引脚(管脚) 上发送信号,通知CPU有中断请求
在第4步中,中断控制器 把该设备的编号放到总线上,这样CPU可以读总线,并且知道 哪个设备刚刚完成了操作(可能同时有许多设备在运行)
一旦 CPU 决定取中断,通常 程序计数器 和 PSW 就被压入 当前堆栈中,并且 CPU 被切换到用户态。设备编号 可以成为 部分内存的一个引用,用于寻找 该设备中断处理程序程序的地址。这部分内存称为 中断向量。当中断处理程序 (中断设备的设备驱动程序的一部分) 开始后,它取走 已入栈的程序计数器 和 PSW,并保存之,然后查询设备的状态。在中断处理程序 全部完成之后之后,它返回到先前 运行的用户 程序程序中 尚未执行的头一条指令
3)第三种方式是,为 I/O 使用一种特殊的 直接存储器访问 (Direct Memory Access, DMA) 芯片,它可以控制在内存和某些控制器之间的位流,而无需持续的 CPU 干预。CPU 对 DMA 芯片进行设置,说明需要传送的字节数、有关的设备和内存地址 以及操作方向,接着启动 DMA。当 DMA 芯片完成时,它引发一个中断,其处理方式如前所述
6、中断会 在非常不合适宜的时刻发生,比如,在另一个中断程序 正在运行时发生。正由此,CPU 有办法关闭中断 并在稍后再开启中断。在中断关闭时,任何已经发出中断的设备,可以继续保持其中断信号,但是CPU不会被中断,直至中断 再次启用为止。如果在关闭中断时,已有多个设备发出了中断,中断控制器 将决定先处理哪个中断,通常这取决于 事先赋予每个设备的静态优先级。最高优先级的设备 赢得竞争 并且首先获得服务,其他设备则必须等待
3.5 总线
图中的系统有很多总线(例如 高速缓存、内存、PCIe、PCI、USB、SATA 和 DMI),每条总线的传输速度 和 功能都不同。操作系统 必须了解所有总线的配置和管理。其中主要的总线是PCle(Peripheral Component Interconnect Express)总线
1、共享总线架构(shared bus architecture)表示 多个设备使用一些相同的导线传输数据。因此,当多个设备同时需要发送数据时,需要仲裁器 决定哪个设备可以使用总线。PCIe 恰好相反,它使用分离的端到端的链路。传统 PCI 使用的并行总线架构(parallel bus architecture)表示通过多条导线 发送数据的每一个字,例如,在传统的 PCI 总线上,一个 32 位数据通过 32 条并行的导线发送;与之相反,PCIe 使用 串行总线架构(serial bus architecture),通过一条 被称为数据通路的链路 传递集合了所有位的一条消息,这非常像 网络包。这样做简单了很多,因为 不用再确保所有 32 位 在同一时刻精确地到达目的地。通过 将多个数据通路并行起来,并行性仍可有效利用。例如,可以使用32个数据通路并行传输 32 条消息
2、CPU 通过 DDR3 总线与内存对话,通过 PCIe 总线与外围图形设备对话,通过DMI(Direct Media Interface)总线 经集成中心与所有其他设备对话。而集成中心 通过 通用串行总线与 USB 设备对话,通过 SATA 总线与硬盘 和 DVD 驱动器对话,通过 PCIe 传输以太网络帧
每一个核 不仅有独立的高速缓存,而且还 共享一个大得多的高速缓存。每一种高速缓存 都引入了一条总线
USB(Universal Serial Bus)是用来 将所有慢速 I/O 设备(如键盘和鼠标)与计算机连接的
SCSI(Small Computer System Interface)总线 是一种高速总线,用在高速硬盘、扫描仪和其他需要较大带宽的设备上
3、要在如上图 展示的环境下工作,操作系统 必须了解 有什么外部设备连接到计算机上,并对其进行配置。这种需求 一种名为即插即用(plug and play)的 I/O 系统
在即插即用之前,每块 I/O 卡有一个固定的中断请求级别 和 用于I/O寄存器的固定地址,例如,键盘的中断级别是1,并使用0x60至0x64的I/O地址;软盘控制器是中断6级 并使用0x3F0至0x3F7的I/O地址;而打印机是中断7级并使用0x378至0x37A的I/O地址等
用户买了 一块声卡和调制解调卡,并且它们都是可以使用 中断4的,但此时问题发生了,两块卡互相冲突,结果不能在一起工作。解决方案是 在每块 I/O 卡上提供 DIP 开关 或 跳接器(硬件配置的物理开关 或 连接器,用来改变 电子设备或电路板的某些设置),并指导用户对其设置 选择中断级别和 I/O 地址,使其不会与其他部件冲突
即插即用所做的工作是,系统自动收集有关 I/O 设备的信息,集中赋予中断级别 和 I/O 地址,然后通知每块卡所使用的数值
3.6 启动计算
简要启动过程:在每台计算机上 有一块“母板”。在母板上 有一个称为基本输入输出系统(Basic Input Output System, BIOS)的程序。BIOS 内有底层 I/O 软件,包括 读键盘、写屏幕、进行磁盘I/O 以及 其他过程。这个程序 存放在一块闪速RAM中,它是非易失性的,但是在发现BIOS中有错误时 可以通过操作系统对它进行更新
在计算机启动时,BIOS 开始运行。它首先检查所安装的 RAM 数量,键盘和其他基本设备是否已安装 并正常响应。接着,它开始扫描 PCIe 和 PCI 总线并找出连在上面的所有设备。即插即用设备也被记录下来。如果现有的设备和系统上次启动时的设备不同,则新的设备将被配置
然后,BIOS 通过尝试存储在 CMOS 存储器中的设备清单 来决定启动设备。用户可以在系统刚启动之时进入一个BIOS配置程序,对设备清单进行修改。典型地,如果存在 CD-ROM(有时是 USB),则系统试图从中启动;如果失败,系统将从硬盘启动。启动设备上的第一个扇区 被读入内存并执行。这个扇区 包含一个对保留在启动扇区末尾的 分区表检查的程序,以确定哪个分区是活动的。然后,从该分区读入第二个 启动装载模块。来自活动分区的这个装载模块 被读入操作系统,并启动之
然后,操作系统询问 BIOS,以获得配置信息。对于每种设备,系统检查对应的设备驱动程序 是否存在。如果没有,系统要求用户插入 含有该设备驱动程序的 CD-ROM(由设备供应商提供)或者 从网络下载驱动程序。一旦有了全部的设备驱动程序,操作系统 就将它们调入内核。然后 初始化有关表格,创建 需要的任何背景进程,并在每个终端上 启动登录程序 或 GUI
4、操作系统大观园
4.1 大型机操作系统
用于大型机的操作系统 主要面向 多个作业的同时处理, 多数这样的作业 需要巨大的 I/O 能力。系统主要提供三类服务: 批处理、事务处理 和 分时
批处理系统处理 不需要交互式用户干预的周期性作业。保险公司的索赔处理或连锁商店的销售报告 通常是以批处理方式完成的。事务处理系统 负责大量小的请求, 例如 银行的支票处理或航班预订。每个业务量都很小, 但是系统 必须每秒处理成百上千个业务。分时系统 允许多个远程用户同时在计算机上运行作业, 如 在大型数据库库上的查询
4.2 服务器操作系统
服务器 可以是 大型的个人计算机、工作站,甚至是 大型机。它们通过网络 同时为若干个用户提供服务, 并且 允许用户 共享硬件和软件资源。服务器 可提供打印服务、文件服务或 Web 服务。Internet 提供商运行着许多台服务器机器, 为用户提供支持,使 Web 站点 保存Web页面并处理请求
4.3 多处理器操作系统
获得大量联合计算能力的常用方式是 将多个CPU连接成单个的系统。依据连接 和 共享方式的不同,这些系统称为 并行计算机、多计算机 或 多处理器
4.4 个人计算机操作系统
现代个人计算机操作系统 都支持多道程序处理,在启动时,通常 有几十个程序开始运行
4.5 掌上计算机操作系统
平板电脑、智能手机 和 其他掌上计算机系统
4.6 嵌入式操作系统
嵌入式系统 在用来控制设备的计算机中运行,这种设备 不是一般意义上的计算机,并且 不允许用户安装软件。典型的例子 有微波炉、电视机、汽车、DVD刻录机、移动电话 以及 MP3播放器一类的设备。区别嵌入式系统与掌上设备的主要特征是,不可信的软件 肯定不能在嵌入式系统上运行。用户不能给自己家里的微波炉 下载新的应用程序 —— 所有的软件都保存在 ROM 中。这意味着 在应用程序之间 不存在保护,这样系统就获得了某种程度上的简化
4.7 传感器节点操作系统
微小传感器节点网络。这些节点 是一种 可以彼此通信 并且使用无线通信基站的微型计算机
传感器 是一种内建有无线电的电池驱动的小型计算机。它们能源有限,必须长时间工作在无人的户外环境中,通常是恶劣的条件下。其网络 必须足够健壮,以允许 个别节点失效。随着电池开始耗尽,这种失效节点会不断增加
每个传感器节点 是一个配有 CPU、RAM、ROM 以及 一个或多个环境传感器的实实在在的计算机。节点上运行一个小但是真实的操作系统,通常这个操作系统是 事件驱动的,可以响应外部事件,或者 基于内部时钟进行周期性的测量。该操作系统必须 小且简单,因为这些节点的 RAM 很小,而且电池寿命是一个重要问题。另外,和嵌入式系统一样,所有的程序 是预先装载的
4.8 实时操作系统
系统的特征是 将时间作为关键参数,如果某个动作 必须绝对地在规定的时刻(或规定的时间范围)发生,这就是 硬实时系统;另一类实时系统是 软实时系统,在这种系统中,虽然不希望 偶尔违反最终时限,但仍可以接受,并且 不会引起任何永久性的损害
4.9 智能卡操作系统
最小的操作系统 运行在智能卡上。智能卡 是一种包含一块CPU芯片的信用卡。它有非常严格的运行能耗 和 存储空间限制
5、操作系统概念
5.1 进程
1、进程 本质上是 正在执行的一个程序。与每个进程 相关的是地址空间, 这是 从某个最小值的存储位置(通常是零)到某个最大值的存储位置的列表。在这个地址空间中,进程 可以进行读写。该地址空间中 存放有可执行程序、程序的数据 以及 程序的堆栈。与每个进程相关的 还有资源集,通常包括 寄存器(含有程序计数器 和 堆栈指针)、打开文件的清单、突出的报警、有关进程清单,以及 运行该程序所需要的所有其他信息。进程基本上是 容纳运行一个程序所需要所有信息的容器
2、分析一个多道程序设计系统,有了(至少)三个活动进程:视频编辑器、Web浏览器 以及 电子邮件接收程序。操作系统 周期性地挂起一个进程 然后启动另一个进程,这可能是 由于在过去的一两秒钟内,第一个进程已使用完分配给它的时间片
3、一个进程 暂时被挂起后,在随后的某个时刻里,该进程 再次启动时的状态 必须与先前暂停时完全相同,这就意味着 在挂起时该进程的所有信息 都要保存下来。例如,为了 同时读入信息,进程打开了若干文件。与每个被打开文件有关的是 指向当前位置的指针(即 下一个将读出的字节 或 记录)。在一个进程 暂时被挂起时,所有这些指针 都必须保存起来,这样在该进程重新启动之后,所执行的读调用 才能读到正确的数据。在许多操作系统中,与一个进程有关的所有信息,除了该进程自身地址空间的内容以外,均存放在操作系统的一张表中,称为进程表 ,进程表是数组(或链表)结构,当前存在的每个进程 都要占用其中一项
一个(挂起的)进程包括:进程的地址空间,以及对应的进程表项(其中包括 寄存器 以及 稍后重启动该进程所需要的许多其他信息)
4、与进程管理有关的 最关键的系统调用 是那些进行 进程创建 和 进程终止的系统调用。命令解释器(command interpreter)或 shell的进程 从终端上读取命令。此时,用户刚键入一条命令 要求编译一个程序。shell 必须先创建一个新的进程 来执行编译程序。当执行编译的进程结束时,它执行一个系统调用 来终止自己
5、若一个进程 能够创建一个或多个进程(称为子进程),而且这些进程 又可以创建子进程,则很容易得到进程树。合作完成某些作业的相关进程 经常需要彼此通信 以便同步它们的行为。这种通信称为 进程间通信
其他可用的进程系统调用包括:申请更多的内存(或释放不再需要的内存)、等待一个子进程结束、用另一个程序覆盖该程序等
有时,需要 向一个正在运行的进程 传送信息 而该进程并没有等待接收信息。例如,一个进程 通过网络向另一台机器上的进程发送消息 进行通信。为了保证一条消息 或 消息的应答不会丢失,发送者要求 它所在的操作系统在指定的若干秒后 给一个通知,这样如果对方尚未收到确认消息 就可以进行重发。在设定该定时器后,程序可以继续做其他工作
在限定的秒数流逝之后,操作系统 向该进程发送一个警告信号。此信号 引起该进程暂时挂起,无论该进程正在做什么,系统将其寄存器的值 保存到堆栈,并开始 运行一个特殊的信号处理过程,比如重新发送可能丢失的消息。这些信号是 软件模拟的硬件中断,除了定时器到期之外,该信号 可以由各种原因产生。许多由硬件检测出来的陷阱,如执行了非法指令 或 使用了无效地址等 也被转换成该信号并交给这个进程
6、系统管理员器授权 每个进程使用一个给定的UID(User IDentification)。每个被启动的进程 都有一个启动该进程的用户 UID。子进程拥有 与父进程一样的UID。用户可以是某个组的成员,每个组也有一个 GID
在 UNIX 中,有一个 UID 称为超级用户,或者Windows中的管理员,它具有特殊的权力,可以违背一些保护规则
5.2 地址空间
1、允许 在内存中 同时运行多道程序。为了避免它们互相干扰(包括操作系统),需要有 某种保护机制。虽然这种机制 必然是硬件形式的,但是 由操作系统掌控
进程的地址空间:每个进程 有一些可以使用的地址集合,典型值从0开始直到某个最大值。在最简单的情形下,一个进程 可拥有的最大地址空间 小于主存。在这种方式下,进程可以用满 其地址空间,而且内存中 也有足够的空间容纳该进程
2、如果一个进程 比计算机拥有的主存还大,而且该进程 希望使用全部的内存,虚拟内存的技术
操作系统 可以把部分地址空间装入主存,部分留在磁盘上,并且在需要时 来回交换它们。在本质上,操作系统创建了一个地址空间的抽象,作为一个进程可以引用地址的集合。该地址空间 与机器的物理内存解耦,可能大于也可能小于该物理空间
5.3 文件
1、文件系统:隐藏磁盘 和 其他 I/O 设备的细节特性,给程序员一个良好、清晰的独立于设备的抽象文件模型。显然,创建文件、删除文件、读文件和写文件等 都需要系统调用。在文件可以读取之前,必须 先在磁盘上定位和打开文件
2、为了提供 保存文件的地方,大多数操作系统 支持目录 的概念,从而可将文件分类成组。目录项 可以是文件 或者 目录,这样就产生了层次结构 —— 文件系统
3、进程和文件层次 都可以组成 树状结构,但这两种树状结构 有不少不同之处
一般进程的树状层次 不深(很少超过三层),而文件树状层次 常常多达四层、五层 或 多层
进程树层次结构 是暂时的,通常最多存在几分钟,而目录层次 则可能存在数年之久
进程和文件在所有权 及保护方面也是有区别的。典型的,只有父进程 能控制和访问子进程,而在文件和目录中通常有一种机制,使文件所有者之外的其他用户 也可以访问该文件
4、目录层结构中 的每一个文件都可以通过从目录的顶部 即根目录开始的路径名来确定。绝对路径名 包含了 从根目录到该文件的 所有目录清单,它们之间用正斜线隔开(/Faculty/Prof.Brown/Courses/CS101)。最开始的正斜线表示 这是从根目录开始的绝对路径。Windows 中用反斜线(\)字符作为分隔符,替代了正斜线(/)
每个进程有一个工作目录,对于 没有以斜线开头 给出绝对地址的路径名,将在这个工作目录下寻找。如果/Faculty/Prof.Brown是工作目录,那么 Courses/CS101 与上面给定的 绝对路径名(/Faculty/Prof.Brown/Courses/CS101) 表示的是同一个文件。进程可以通过 使用系统调用 指定新的工作目录,从而变其更工作目录
5、在读写文件之前,首先要打开文件,检查其访问权限。若权限许可,系统 将返回一个小整数,称作文件描述符,供后续操作使用。若禁止访问,系统则返回一个错误码
6、UNIX 允许把光盘上的文件系统 接到主文件树上。在 mount 调用之前,根文件系统在硬盘上,而第二个文件系统在 CD-ROM 上,它们是分离且无关的
不能使用 CD-ROM 上的文件系统,因为上面没有可指定的路径。UNIX 不允许在路径前面加上 驱动器名称或代码,那样做 就完全成了设备相关类型了,这是 操作系统应该消除的(因为 UNIX 强调将所有设备 统一抽象为文件的形式,所有的设备、文件和目录都被组织在一个单一的、层次化的目录结构中。通过这种方式,UNIX 操作系统尽量减少了与具体硬件设备的耦合,保持了设备无关性)
替代的方法是,mount 系统调用允许把在 CD-ROM 上的文件系统连接到程序所希望的根文件系统上。CD-ROM 上的文件系统安装到了目录b上,这样就允许访问文件 /b/x 以及 /b/y
7、特殊文件。提供特殊文件是 为了使 I/O 设备看起来 像文件一般。这样,就像 使用系统调用读写文件一样,I/O设备 也可通过同样的系统调用 进行读写。有两类特殊文件:块特殊文件 和 字符特殊文件
块特殊文件 指那些由可随机存取(允许随机访问。这意味着 应用程序可以直接访问设备上的任意数据块,而无需通过文件系统的中间层进行管理,跳过操作系统的 文件系统管理层)的块组成的设备,如磁盘等。比如 打开一个块特殊文件,然后读第4块,程序可以直接访问设备的第4块 而不必考虑 存放该文件的文件系统结构
字符特殊文件用于打印机、调制解调器和其他接收或输出字符流的设备(字符设备 则是按字符流进行读写的设备,它们通常用于顺序访问。字符设备 不能随机访问指定的块,而是 以字符的方式流式处理数据)。按照惯例,特殊文件保存在/dev目录中。例如,/dev/lp 是打印机(曾经称为行式打印机)
8、既与进程有关 也与文件有关:管道
管道(pipe)是一种 虚文件,它可连接 两个进程
如果进程 A 和 B 希望 通过管道对话,它们必须 提前设置该管道。当进程 A 想对进程 B 发送数据时,它把数据写到管道上,仿佛管道 就是输出文件一样。进程 B 可以通过 读该管道而得到数据,仿佛该管道就是 一个输入文件一样。这样,在 UNIX 中两个进程之间的通信 就非常类似于普通文件的读写了。更为强大是,若进程 想发现它所写的输出文件 不是真正的文件而是管道,则需要使用特殊的系统调用
5.4 输入/输出
每个操作系统 都有管理其 I/O 设备的 I/O 子系统。某些 I/O 软件是设备独立的,即这些 I/O 软件部分可以 同样应用于 许多或者全部的I/O设备上。I/O软件的其他部分,如 设备驱动程序,是专门为特定的 I/O 设备设计的
5.5 保护
管理系统的安全性 完全依靠 操作系统,例如,文件 仅供授权用户访问
UNIX 操作系统 通过对每个文件赋予 一个9位的二进制保护代码,对 UNIX 中的文件实现保护。该保护代码有三个3位字段,一个用于 所有者,一个用于 与所有者同组(用户被系统管理员划分成组)的其他成员,一个用于 其他人。每个字段中 有一位用于读访问,一位用于写访问,一位用于执行访问。这些位 就是知名的rwx位
对一个目录而言,x 的含义是允许查询
5.6 shell
操作系统 是进行系统调用的代码。编辑器、编译器、汇编程序、链接程序、实用程序 以及 命令解释器等,不是操作系统的组成部分,UNIX 的命令解释器,称为 shell,体现了 许多操作系统的特性,并很好地说明了 系统调用的具体用法
以终端 作为标准输入和标准输出。首先 显示提示符(一个美元符号),提示用户 shell 正在等待接收命令。假如用户键入
date
shell 创建一个子进程,并运行 date 程序作为 子进程。在该子进程运行期间,shell 等待它结束。在子进程结束后,shell 再次显示提示符,并等待下一行输入
用户 可以将标准输出重定向到一个文件,如键入
date > file
同样,也可以 将标准输入重定向,如:
sort < file1 > file2
该命令 调用 sort 程序,从 file1 中取得输入,输出送到 file2
可以 将一个程序的输出通过管道 作为另一程序的输入,因此有
cat file1 file2 file3 | sort > /dev/lp
cat程序 将这三个文件合并,其结果送到 sort 程序 并按字典序排列(sort 程序接受标准输入,并将输入的内容按字典序排序)。sort 的输出 又被重定向到文件 /dev/lp 中。显然,这是打印机
如果 用户在命令后加上一个 “&” 符号,则 shell 将不等待其结束,而直接 显示出提示符。所以
cat file1 file2 file3 | sort > /dev/lp &
将启动 sort 程序作为后台任务执行,这样就 允许用户继续工作,而 sort 命令也继续进行
GUI 与 shell 类似,GUI 只是一个运行在操作系统顶部的程序
6、系统调用
1、操作系统 具有两种功能:为用户程序提供抽象 和 管理计算机资源。在多数情形下,用户程序和操作系统之间的交互处理的是 前者,例如,创建、写入、读出 和 删除文件。对用户而言,资源管理部分 主要是 透明和自动完成的。这样,用户程序和操作系统之间的交互主要就是 处理抽象。必须仔细地分析这个接口。接口中 所提供的调用 随着操作系统的不同而变化
任何单 CPU 计算机 一次只能执行一条指令。如果一个进程 正在用户态运行一个用户程序,并且 需要一个系统服务,比如 从一个文件读数据,那么它就必须执行一个陷阱 或 系统调用指令,将控制 转移到操作系统。操作系统接着 通过参数检查 找出所需要的调用进程。然后,它执行系统调用,并把控制返回给 在系统调用后面跟随的指令。在某种意义上,进行系统调用 就像进行 一个特殊的 过程调用(一个程序中的某个部分(通常称为主程序)调用 另一个预先定义的子程序(或者过程、函数、方法)来执行特定的任务),但是只有系统调用 可以进入内核,而过程调用则不能
2、过程调用
1)过程调用的步骤
传递参数(可选):在调用过程中,主程序 可能会 传递一些参数给被调用的子程序。这些参数作为子程序的输入,用于完成其任务
保存上下文:调用过程中,程序通常 需要保存当前的上下文信息(如寄存器内容、程序计数器 等),以便在过程执行完毕后 恢复调用点并继续执行。上下文保存的内容 包括调用者的执行状态、返回地址等
跳转到子程序:调用者将程序的执行流程 跳转到 被调用者(即子程序)的起始位置,开始执行 子程序的代码
执行子程序:被调用的子程序开始执行,处理传入的参数 并进行计算,直到完成任务
返回值(可选):子程序 可能会生成一个返回值 并将其传递回调用者
恢复上下文:子程序执行完毕后,程序跳回 调用者保存的返回地址,并恢复之前保存的上下文,继续执行 调用者的后续代码
2)过程调用的种类
静态过程调用(Static Call):在程序中,调用的是 已知的函数或子程序。编译器在编译时 就可以确定调用的目标,例如在 C 语言中的普通函数调用
int sum(int a, int b) {
return a + b;
}
int main() {
int result = sum(3, 5); // 调用 sum 函数
return 0;
}
动态过程调用(Dynamic Call):调用的过程 可能在运行时才确定,典型例子是 通过函数指针 或 接口来实现的调用。比如在面向对象编程中,虚函数调用 就是动态调用的一种
int (*funcPtr)(int, int); // 定义一个函数指针
funcPtr = ∑ // 将指针指向 sum 函数
int result = funcPtr(3, 5); // 通过指针调用函数
3)过程调用的实现
不同的编程语言 和 平台 对于过程调用的实现机制 可能有所不同,但大多数语言和架构 都使用了以下几种方式来管理过程调用:
栈帧:过程调用过程中,每次调用函数时,都会在调用栈上 分配一个栈帧,用来存储局部变量、参数 和 返回地址等信息。调用结束时,栈帧被释放
传递参数:
通过值传递:调用者 将参数的值传递给 子程序,子程序操作的是 这些值的副本,不影响原始值
通过引用传递:调用者传递的是 参数的地址,子程序直接操作 调用者的原始数据
返回值:大多数过程或函数调用都会有一个返回值,返回值可以通过寄存器或栈来传递。
递归调用:过程调用 允许函数调用自身,这种形式被称为递归。在递归调用中,新的栈帧 会在栈上创建,直到递归终止条件满足 并开始回溯
3、尽管过程调用带来了很多好处,但也有一些性能上的开销:
栈的管理:每次过程调用时,需要在栈上 创建栈帧,并在返回时 销毁栈帧,这会有一定的开销
传递参数:尤其是 当传递大量参数时,参数的传递也会影响效率
为了减少这种开销,有些编译器可以优化过程调用,如内联函数,将小的函数 直接嵌入调用者的代码中,避免真正的函数调用过程
4、read 系统调用。它有三个参数:第一个参数 指定文件,第二个 指向缓冲区,第三个 说明要读出的字节数。几乎与所有的系统调用一样,它的调用由 C 程序完成,方法是 调用一个与 该系统调用名称相同的库过程:read。由C程序进行的调用形式如下:
count = read(fd, buffer, nbytes);
系统调用(以及 库过程)在 count 中返回实际读出的字节数。这个值通常和 nbytes 相同,但也可能更小,例如,在读过程中 遇到了文件尾的情形就是这样
如果 系统调用不能执行,不论是 因为无效的参数 还是 磁盘错误,count 都会被置为 -1,而在全局变量 errno 中放入错误号(表示最近一次系统调用 或 库函数调用的错误号,为了将 errno 翻译成可读的错误信息,可以使用 strerror() 函数,或者在命令行中查阅 perror() 函数的输出)。程序应该经常检查系统调用的结果,以了解是否出错
在准备 调用这个实际用来进行 read 系统调用的 read 库过程时,调用程序 首先把参数压进堆栈
(感觉压入栈的方向有问题)
C 以及 C++ 编译器使用逆序(从右往左压入)(必须把第一个参数赋给 printf(格式字符串),放在堆栈的顶部)。第一个和第三个参数是 值传递,但是第二个参数 通过引用传递,即传递的是 缓冲区的地址(由&指示),而不是 缓冲区的内容。接着是 对库过程的实际调用(第4步)。这个指令是 用来调用 所有过程的正常过程调用指令
在可能是 由汇编语言写的库过程中,一般把系统调用的编号 放在操作系统所期望的地方,如 寄存器中(第5步)。然后执行一个 TRAP 指令,将用户态 切换到 内核态,并在内核中的一个固定地址 开始执行(第6步)。TRAP 指令实际上 与过程调用指令 非常相似,它们后面都跟随一个 来自远处位置的指令,以及供以后使用的 保存在栈中的返回地址
然而,TRAP 指令 与 过程指令(过程调用指令)存在两个方面的差别
首先,它的副作用是,切换到内核态。而过程调用指令 并不改变模式。其次,不像给定过程 所在的相对或绝对地址那样,TRAP 指令 不能跳转到任意地址上(具体的处理程序地址是 由操作系统维护的,用户程序 无法直接控制 或 指定目标地址)。根据机器的体系结构,或者是 跳转到一个固定的地址上,或者是 指令中有—个 8 位长的字段,它给了内存中一张表格的索引,这张表格中 含有跳转地址
跟随在 TRAP 指令后的内核代码 开始检查系统调用编号,然后分派给 正确的系统调用处理器,这通常是通过 一张 由系统调用编号所引用的、指向系统调用处理器的指针表 来完成(第7步)。此时,系统调用处理器 运行(第8步)。一旦系统调用处理器 完成其工作,控制可能会在跟随 TRAP 指令 后面的指令中 返回给用户空间库过程(第9步,将控制权 返回给用户空间程序)。这个过程接着 以通常的过程调用返回的方式,回到用户程序(第10步,内核在系统调用 处理完毕后,会通过特殊的返回指令(例如 iret、sysret、syscall 的返回部分等)将控制权返回到用户空间。这时,CPU 切换回用户模式,并恢复用户程序的状态(包括程序计数器、寄存器等))
为了完成整个工作,用户程序 还必须清除堆栈,如同 它在进行任何过程调用之后一样(第11步)。假设堆栈向下增长,如经常所做的那样,编译后的代码 准确地 增加堆栈指针值,以便清除 调用 read 之前压入的参数
控制 可能会在跟随 TRAP 指令 后面的指令中 返回给用户空间库过程(系统调用 结束后,控制权 通常返回到 发起系统调用的用户空间的库函数中),这是有原因的。系统调用 可能堵塞调用者,避免它继续执行。例如,如果 试图读键盘,但是并没有任何键入,那么调用者 就必须被阻塞。在这种情形下,操作系统 会查看是否有其他可以运行的进程。稍后,当需要的输入出现时,进程会提醒系统注意,然后步骤 9 ~ 步骤 11 会接着进行
用户进程发出 TRAP 指令后,内核可能不会立刻返回控制权,而是使调用者进入阻塞状态,直到满足条件
1)TRAP 指令和系统调用的行为
TRAP 指令作为系统调用的触发点,意味着程序从用户空间 进入内核空间,在此期间,用户程序的执行 会暂停,直到 内核处理完请求并返回控制权
2)系统调用可能是阻塞的
“阻塞”是系统调用中的一个重要概念。某些系统调用 可能无法立即完成操作,比如 read、accept 或 wait,因为它们依赖于外部资源(如文件输入、网络连接、进程状态等)来完成操作。如果 这些资源暂时不可用,系统调用会阻塞,导致调用者(用户进程)暂停执行,直到资源准备好为止
例如:
read() 系统调用:如果一个进程 尝试从文件 或 设备中读取数据,但数据暂时不可用(例如从网络套接字或终端设备读取数据时),read() 会阻塞,直到数据可以读取
5、堆栈用于 管理过程调用时的局部变量、返回地址、参数等。堆栈通常是 向下增长的(从高地址到低地址)。这意味着 每当有数据被压入栈中,堆栈指针 会减小,而当从栈中弹出数据时,堆栈指针会增加
调用 read 时,堆栈上会有一些参数,如文件描述符、缓冲区指针等。这些参数是通过减小堆栈指针将其压入栈的。
在系统调用完成并返回后,用户程序需要清除这些参数,这通常通过增加堆栈指针来实现。增加堆栈指针相当于跳过这些参数,就像从栈中弹出数据一样
6、POSIX系统调用
由这些调用 所提供的服务 确定了多数操作系统应有的功能,而在个人计算机上,资源管理功能是较弱的(至少 与多用户的大型机 相比较是这样)。所包含的服务 有创建与终止进程,创建、删除、读出 和 写入文件,目录管理 以及 完成输入/输出
将 POSIX 过程 映射到 系统调用 并不是一对一的。POSIX 标准 定义了 一套构造系统所必须提供的过程,但是没有规定 它们是系统调用、库调用 还是其他的形式。如果不通过系统调用 就可以执行一个过程(即无须陷入内核),那么 从性能方面考虑,它通常 会在用户空间中完成。不过,多数 POSIX 过程 确实进行系统调用,通常是一个过程 直接映射到一个系统调用上。在一些情形下,特别是 所需要的 只是某个调用的变体时,一个系统调用 会对应若干个库调用
6.1 用于进程管理的系统调用
1、fork 是唯一可以在 POSIX 中创建进程的途径。它创建一个原有进程的 确切副本,包括 所有的文件描述符、寄存器等 内容。在 fork 之后,原有的进程 包括其副本(父与子)就 跟新进程 分开了
在 fork 时,所有的变量具有一样的值,虽然父进程的数据 被复制以创建子进程,但是其中的一个的后续变化 并不会影响到另一个。(由父进程和子进程共享的程序正文,是不可改变的)
fork 调用 返回一个值,在子进程中 该值为零,并且在父进程中 等于子进程的进程标识符(Process IDentifier,PID)。使用返回的PID,就可以在两个进程中 看出哪一个 是父进程,哪一个 是子进程
2、多数情形下,在 fork 之后,子进程需要执行 与父进程不同的代码。这里考虑 shell 的情形。它从终端读取命令,创建一个子进程,等待 该子进程执行命令,在该子进程终止时,读入下一条命令。为了等待子进程结束,父进程执行 waitpid 系统调用,它只是等待,直至子进程终止(若有多个子进程的话,则直至任何一个 子进程终止)。waitpid 可以等待一个特定的子进程,或者通过 将第一个参数设置为 -1 的方式,等待任何一 个老的子进程
在 waitpid 完成之 后,将把第二个参数 statloc 所指向的地址设置为 子进程的退出状态(正常 或 异常终止 以及 退出值)
statloc 指向的值可以通过几个宏来解析:
1)WIFEXITED(statloc)
用来判断 子进程是否正常退出。如果返回 true,则可以使用 WEXITSTATUS 来获取子进程的退出码
if (WIFEXITED(statloc)) {
int exit_status = WEXITSTATUS(statloc);
printf("Child exited with status: %d\n", exit_status);
}
2)WIFSIGNALED(statloc)
用来判断 子进程是否因信号而异常终止。如果返回 true,则可以使用 WTERMSIG 获取 导致子进程终止的信号编号
if (WIFSIGNALED(statloc)) {
int signal_num = WTERMSIG(statloc);
printf("Child terminated by signal: %d\n", signal_num);
}
3)WIFSTOPPED(statloc)
用来判断子进程 是否因信号而暂停。如果返回 true,则可以使用 WSTOPSIG 获取导致暂停的信号编号
if (WIFSTOPPED(statloc)) {
int stop_signal = WSTOPSIG(statloc);
printf("Child stopped by signal: %d\n", stop_signal);
}
4)WIFCONTINUED(statloc)
如果使用了 WCONTINUED 选项,该宏可以用来判断子进程是否已恢复执行(从暂停状态恢复)
还有各种可使用的选项,它们 由第三个参数确定。例如,如果没有已经退出的子进程 则立即返回,例如 WNOHANG,表示非阻塞等待(程序在等待某个事件(例如子进程的退出或文件的可读性)时,不会被阻塞,而是可以立即返回 以继续执行其他任务)
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
// 调用 fork() 后,操作系统会创建一个子进程,这个子进程是调用进程(即父进程)的副本
// 创建子进程后,父进程和子进程会同时执行,并且从 fork() 调用之后的代码开始运行
// 可以使用 if (pid == 0) 来判断当前代码是在子进程中执行的,而 else 块则在父进程中执行
if (pid == -1) {
// Fork 失败
perror("fork failed");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// 子进程
printf("Child process running...\n");
sleep(5); // 模拟子进程执行任务
printf("Child process exiting...\n");
exit(42); // 子进程返回状态码42
} else {
// 父进程
int status;
printf("Parent process: non-blocking wait for child process.\n");
// 非阻塞等待
while (1) {
pid_t result = waitpid(pid, &status, WNOHANG);
if (result == 0) {
// 子进程尚未退出
printf("Child still running...\n");
sleep(1); // 父进程可以执行其他任务
} else if (result == -1) {
// 出现错误
perror("waitpid failed");
exit(EXIT_FAILURE);
} else {
// 子进程退出
if (WIFEXITED(status)) {
printf("Child exited with status: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("Child was terminated by signal: %d\n", WTERMSIG(status));
}
break;
}
}
}
return 0;
}
输出结果(相当于两个进程 同时执行这一段代码)
现在考虑 shell 如何使用 fork。在键入一条命令后,shell 调用 fork 创建一个新的进程(子进程 继承了 父进程的大部分属性,包括代码、数据、打开的文件描述符等,唯一的区别在于 fork() 的返回值)。这个子进程 必须执行用户的命令。通过使用 execve 系统调用 可以实现这一点(在子进程中使用 execve() 执行用户的命令),这个系统调用会引起其整个核心映像 被一个文件所替代,该文件由第一个参数给定(execve() 会将子进程的整个内存映像 替换为 要执行的程序,意味着 子进程原本的程序代码、数据等都会被新的程序替换。然而,进程的 PID 不变,因此在系统层面上,仍然是同一个进程)
为什么需要创建子进程?
如果 shell 在自己的进程中 直接执行用户的命令,那么 shell 本身就会 被替换成那个命令,无法继续提供交互
通过创建子进程,shell 可以在父进程中继续运行,保持对用户的交互,而子进程 则负责执行用户的命令
当在子进程中调用 execve() 时,以下事情会发生:
内存替换:子进程的整个内存空间(代码段、数据段、堆、栈)被新程序的内存映像替换
开始执行新程序:新程序 从它的 main() 函数开始执行。
PID 不变:尽管内存映像被替换,进程的 PID 保持不变。这对于父进程来说 非常重要,因为它可以通过 PID 追踪子进程
为什么要替换内存映像?
这是一种高效的方式 来执行新程序,而不需要 创建新的进程
通过这种方式,子进程完全变成了 用户要执行的命令程序,但在系统层面上 仍然是同一个进程
在子进程执行用户命令的同时,父进程(shell)可以选择:
等待子进程完成:如果命令是前台执行的,shell 会调用 wait() 或 waitpid() 等待子进程完成
继续执行其他任务:如果命令是后台执行的(如在命令后加 &),shell 会立即返回提示符,接受新的用户输入
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
char *command = "/bin/ls"; // 要执行的命令
char *args[] = {"ls", "-l", NULL}; // 命令参数列表
pid_t pid = fork(); // 创建子进程
if (pid == -1) {
// 创建子进程失败
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程中执行,默认情况下 在调用 execve() 时,打开的文件描述符会被继承到新程序中
execve(command, args, NULL); // 用新程序替换子进程的内存映像
// execve() 的第三个参数 envp[] 可以用于传递环境变量
// 如果 execve 返回,说明出现错误
perror("execve failed");
return 1;
} else {
// 父进程中执行
int status;
waitpid(pid, &status, 0); // 等待子进程完成
// 继续执行其他任务
}
return 0;
}
3、execve 有三个参数:将要执行的文件名,一个指向变量数组的指针,以及 一个指向环境数组的指针。这里对这些参数做一个简要的说明。各种库例程,包括execl, execv, execle以及execve允许略掉参数 或 以各种不同的方式给定
exec 系列函数是在 C 语言中用于替换当前进程映像的新程序
这些函数包括:
execl
execv
execle
execve
execlp
execvp
l:参数列表(list),参数以变长参数的形式传递,类似于 execl(path, arg0, arg1, …, argn, NULL),execl 不允许指定新的环境变量,继承当前进程的环境变量 execl("/bin/ls", "ls", "-l", "/home", NULL);
v:参数向量(vector),参数以数组的形式传递,类似于 execv(path, argv) char *args[] = {"ls", "-l", "/home", NULL}; execv("/bin/ls", args);
e:指定新的环境变量,允许传递 自定义的环境变量数组 envp[] int execle(const char *path, const char *arg, ..., NULL, char *const envp[]); // e 表示环境变量(environment):允许传递新的环境变量数组 envp[]。 参数列表后需要有一个 NULL,然后是 envp[]
p:使用 PATH 环境变量搜索可执行文件,无需提供可执行文件的完整路径
int execve(const char *path, char *const argv[], char *const envp[]);
e 表示环境变量(environment):允许传递新的环境变量数组 envp[]
这是最基础的 exec 函数,其他函数都是基于它实现的
char *args[] = {"env", NULL};
char *new_env[] = {"USER=guest", "PATH=/tmp", NULL};
execve("/usr/bin/env", args, new_env);
示例对比
使用 execl
// 执行 "/bin/ls -l /home",使用当前环境变量
execl("/bin/ls", "ls", "-l", "/home", NULL);
使用 execv
char *args[] = {"ls", "-l", "/home", NULL};
// 执行 "/bin/ls -l /home",使用当前环境变量
execv("/bin/ls", args);
使用 execle
char *new_env[] = {"USER=guest", "PATH=/tmp", NULL};
// 执行 "/usr/bin/env",传递新的环境变量
execle("/usr/bin/env", "env", NULL, new_env);
使用 execve
char *args[] = {"env", NULL};
char *new_env[] = {"USER=guest", "PATH=/tmp", NULL};
// 执行 "/usr/bin/env",传递参数和新的环境变量
execve("/usr/bin/env", args, new_env);
使用 execlp
// 使用 PATH 搜索 "ls" 可执行文件,执行 "ls -l"
execlp("ls", "ls", "-l", NULL);
使用 execvp
char *args[] = {"ls", "-l", NULL};
// 使用 PATH 搜索 "ls" 可执行文件,执行 "ls -l"
execvp("ls", args);
在本书中,在所有涉及的地方 使用 exec 描述系统调用
cp file1 file2
该命令将 file1 复制到 file2。在 shell 创建进程之后,该子进程定位和执行文件 cp,并将源文件名和目标文件名 传递给它
cp主程序(以及其他多数C程序的主程序)都有声明
main(argc, argv, envp)
其中 argc 是该命令行内 有关参数数目的计数器,包括 程序名称。例如,上面的例子中,argc为3
第二个参数 argv 是一个指向数组的指针。该数组的元素 i 指向该命令行第 i 个字符串的指针。在本例中,argv[0] 指向字符串“cp”,argv[1] 指向字符串“file1”,argv[2] 指向字符串“file2”
main 的第三参数 envp 是一个指向环境的指针,该环境是 一个数组,含有 name=value 的赋值形式,用以将诸如终端类型以及根目录等信息传送给程序。还有 供程序调用的库过程,用来取得环境变量,这些变量 通常用来决定 用户希望如何完成特定的任务(例如,使用默认打印机)
4、其他 POSIX 的系统调用
exit,这是在进程完成执行后 应执行的系统调用。这个系统调用 有一个参数——退出状态(0至255,接受的这一个整数参数称为退出状态码,当一个子进程调用 exit 后,父进程可以通过 wait() 或 waitpid() 系统调用来获取子进程的退出状态,该参数可以通过 waitpid 系统调用中的 statloc 返回给父进程)
// 子进程调用 exit
#include <stdlib.h>
int main() {
// 子进程的代码
// ... 执行一些操作
// 假设成功完成,返回状态码 0
exit(0);
// 或者发生错误,返回非零状态码,例如 1
// exit(1);
}
// 父进程等待子进程并获取退出状态
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == -1) {
// 创建子进程失败
perror("fork failed");
return 1;
}
if (pid == 0) {
// 子进程执行
printf("Child process running...\n");
// 模拟一些操作
sleep(2);
// 退出,返回状态码 42
exit(42);
} else {
// 父进程等待子进程
int status;
pid_t wpid = waitpid(pid, &status, 0);
if (wpid == -1) {
// 等待子进程失败
perror("waitpid failed");
return 1;
}
if (WIFEXITED(status)) {
// 子进程正常终止,获取退出状态码
int exit_status = WEXITSTATUS(status);
printf("Child exited with status: %d\n", exit_status);
} else if (WIFSIGNALED(status)) {
// 子进程被信号终止
int term_sig = WTERMSIG(status);
printf("Child terminated by signal: %d\n", term_sig);
} else {
// 其他情况
printf("Child exited abnormally.\n");
}
}
return 0;
}
5、在 UNIX 中的进程 将其存储空间划分为三段:正文段(如程序代码)、数据段(如变量)以及 堆栈段。数据向上增长 而堆栈向下增长。夹在中间的是未使用 的地址空间。堆栈在需要时 自动地向中间增长,不过数据段的扩展是 显式地通过系统调用 brk 进行的,在数据段 扩展后,该系统调用指定一个新地址(brk 接受一个参数,指定新的数据段结束地址。通过调用 brk(new_address)
,程序可以将数据段扩展到 new_address。如果 new_address 大于当前的结束地址,数据段会向高地址扩展,提供更多的堆内存)。但是,这个调用不是 POSIX 标准 中定义的,对于存储器的动态分配,POSIX 标准 鼓励 程序员使用 malloc 库过程(malloc 提供了一个抽象层,屏蔽了底层的内存管理细节,提高了程序的可移植性和安全性)
brk 和 sbrk 是 Unix 系统中用于管理进程数据段(通常称为堆)的系统调用。通过这些调用,程序可以显式地 调整数据段的大小,以满足动态内存分配的需求
6.2 用于文件管理的系统调用
讨论在单个文件上的操作
1、要读写一个文件,先要用 open 打开该文件。这个系统调用通过绝对路径名 或 指向工作目录的相对路径名 指定要打开文件的名称,而 O_RDWR、O_RDONLY 或 O_WRONLY 的含义 分别是只读、只写 或 两者都可以。为了创建一个新文件,使用O_CREAT 参数。然后 可使用返回的文件描述符进行读写操作。接着,可以用 close 关闭文件,这个调用 使得该文件描述符在后续的 open 中被再次使用
2、最常见的调用是 read 和 write,write 具有与 read 相同的参数
与每个文件相关的是 一个指向文件当前位置的指针。在顺序读(写)时,该指针 通常指向要读出(写出)的下一个字节。lseek调用 可以改变该位置指针的值,这样后续的 read 或 write 调用 就可以在文件的任何地方开始
3、lseek 有三个参数:第一个是 文件的描述符,第二个是 文件位置,第三个说明 该文件位置是相对于文件起始位置、当前位置 还是 文件的结尾。在修改了指针之后,lseek 所返回的值是 文件中的绝对位置
4、UNIX 为每个文件保存了 该文件的类型(普通文件、特殊文件、目录等)、大小、最后修改时间 以及 其他信息。程序可以通过 stat 系统调用 查看这些信息。第一个参数指定了 要被检查的文件;第二个参数是 一个指针,该指针指向 存放这些信息的结构。对于一个打开的文件而言,fstat 调用 完成同样的工作
6.3 用于目录管理的系统调用
讨论 与目录或整个文件系统有关 的某些系统调用
1、mkdir 和rmdir 分别用于创建和删除空目录。下一个调用是 link。它的作用是允许同一个文件 以两个或更多的名称出现,多数情形下是 在不同的目录中这样做。它的典型应用是,在同一个开发团队中 允许若干个成员 共享一个共同的文件,他们每个人都在自己的目录中 有该文件,但可能采用的是 不同的名称。共享一个文件,与每个团队成员 都有一个私有的副本 并不是同一件事,因为共享文件意味着 任何成员所做的修改 都立即为其他成员所见——只有一个文件存在。而在复制了一个文件的多个副本之后,对其中一个副本所进行的修改 并不会影响到其他的副本
有两个用户 ast 和 jim,每个用户都有一些文件的目录。若 ast 现在执行一个包含系统调用的程序
link("/usr/jim/memo","/usr/ast/note");
jim 目录中的文件 memo 以文件名 note 进入 ast 的目录。之后,/usr/jim/memo 和 /usr/ast/note 都引用同一个文件
2、在 UNIX 中,每个文件 都有唯一的编号,即 i-编号,用以标识文件。该 i-编号是对 i-节点表格的一个引用,它们一一对应,说明该文件的拥有者、磁盘块的位置等。目录就是一个包含(i-编号,ASCII名称)对集合的文件
link 所做的 只是利用某个已有文件的 i-编号,创建一个新的目录项(也许用一个新名称)。如果 使用 unlink 系统调用 将其中一个文件移走了,可以保留另一个。如果 两个都被移走了,UNIX 看到尚且存在的文件没有目录项(i-节点中的一个域 记录着指向该文件的目录项),就会把该文件从磁盘中移去
3、mount 系统调用 允许将两个文件系统合并成为一个。通常的情形是,在硬盘某个分区中的根文件系统 含有常用命令的二进制(可执行)版和其他常用的文件;用户文件在另一个分区。并且,用户 可插入包含需要读入的文件的U盘
在操作系统(如 Linux 或 Unix)中,硬盘可以被划分为多个分区(Partition),每个分区 可以包含一个独立的文件系统
-
根文件系统(Root Filesystem)和分区:
根文件系统是指 挂载在根目录 / 上的文件系统,包含了系统启动 和 运行 所需的最基本文件和目录
分区是 硬盘上的一个逻辑区域,可以 被格式化成文件系统,用于 存储特定类型的数据 -
根文件系统包含常用命令的二进制文件和其他常用文件:
常用命令的二进制文件:这些是 系统运行所需的可执行文件,通常位于 /bin、/sbin、/usr/bin、/usr/sbin 等目录
/bin:基本用户命令,例如 ls、cp、mv 等
/sbin:系统管理员命令,例如 fdisk、ifconfig 等
其他常用文件:包括系统配置文件(/etc)、库文件(/lib、/usr/lib)、设备文件(/dev)、内核和引导加载程序(/boot)等 -
用户文件存储在另一个分区:
用户文件主要存储在 /home 目录下,每个用户 都有自己的主目录,例如 /home/user1、/home/user2
将用户文件放在单独的分区,即将 /home 挂载到一个独立的分区中,这样做有以下几个好处:
1)数据安全性:如果系统崩溃 或需要重新安装操作系统,用户数据 不会受到影响,因为它们存储在独立的分区中
2)系统稳定性:防止用户文件 占满根分区的空间,导致系统无法正常运行
3)灵活性:可以独立调整用户数据分区的大小,而不影响系统分区
4)备份和恢复方便:可以单独备份 用户数据分区,提高数据管理的效率 -
理解方式:
文件系统层次结构:Linux 的文件系统 采用层次化的目录结构,通过 将不同的分区 挂载到不同的目录上,实现了逻辑上的统一
分区规划:在实际部署中,系统管理员 会根据需要,将系统文件和用户文件 分别放在不同的分区中,以提高系统的性能和管理效率
通过执行 mount 系统调用,可以将一个 USB 文件系统 添加到根文件系统中,完成安装操作的典型 C 语句为
mount("/dev/sdb0","/mnt",0); // 0(读写)或 1(只读)
第一个参数是 USB 驱动器0 的块特殊文件名称,第二个参数是 要被安装在树中的位置,第三个参数 说明将要安装的文件系统是 可读写的还是只读的
旧的系统中,mount 系统调用 可能只有三个参数,新 mount 系统调用的函数原型通常如下:
int mount(const char *specialfile, const char *dir, int rwflag);
source:要挂载的设备 或 特殊文件的路径,例如 /dev/sdb0
target:挂载点,即一个已存在的目录,例如 /mnt
filesystemtype:文件系统类型,例如 “ext4”、“ntfs” 等
mountflags:挂载选项,可以是 0 或多个标志的组合,用于 指定挂载行为
data:与特定文件系统相关的 额外数据,一般用于 传递文件系统特定的参数
#include <stdio.h>
#include <sys/mount.h>
#include <errno.h>
int main() {
const char *source = "/dev/sdb0";
const char *target = "/mnt";
const char *filesystemtype = "ext4";
unsigned long mountflags = 0;
const void *data = NULL;
if (mount(source, target, filesystemtype, mountflags, data) == -1) {
perror("挂载失败");
return 1;
}
printf("挂载成功!\n");
return 0;
}
常见的挂载标志(可以在 mountflags 中使用):
MS_RDONLY:只读挂载
MS_NOSUID:不允许 SUID 或 SGID 位生效
MS_NODEV:不解释字符或块设备文件
MS_NOEXEC:不允许在此文件系统上执行程序
在 mount 调用 之后,驱动器0上的文件 可以用 从根目录开始的路径 或 工作目录路径,而不考虑 文件在哪一个驱动器上。事实上,第二个、第三个 以及 第四个驱动器 也可安装在树上的任何地方。mount 调用 使得可移动介质 都集中到一个文件层次中成为可能,而不用 考虑文件在哪个驱动器上。尽管这是个 CD-ROM 的例子,但是也可以用同样的方法 安装硬磁盘 或者 硬磁盘的一部分(常称为分区或次级设备),外部硬磁盘 和 USB盘也一样。当不再需要一个文件系统时,可用 umount 系统调用卸载之
6.4 各种系统调用
1、chdir用来改变当前的工作目录 chdir("/usr/ast/test");
之后,打开 xyz 文件,会打开 /usr/ast/test/xyz。工作目录的概念 消除了总是 键入(长)绝对路径名的需要
2、在 UNIX 中,每个文件有一个保护模式。该模式 包括针对所有者、组 和 其他用户的 读写执行位。chmod 系统调用 可以改变文件的模式。例如,要使一个文件 对除了所有者之外的用户只读,可以执行
chmod("file", 0644);
3、kill 系统调用 供用户 或 用户进程 发送信号用。若一个进程 准备好捕捉一个特定的信号,那么,在信号到来时,运行一个信号处理程序。如果 该进程没有准备好,那么信号的到来 会杀掉该进程(此调用名称的由来)
4、POSIX 定义了 若干处理时间的过程。例如,time 以秒为单位返回当前时间,0 对应着1970年1月1日午夜(从此日开始,没有结束)。在一台 32 位字的计算机中,time的最大值是 232-1秒(假定是无符号整数)
6.5 Windows Win32 API
Windows 和 UNIX 的主要差别在于 编程方式。UNIX 程序 包括做各种处理的代码 以及 完成特定服务的系统调用。相反,Windows 程序 通常是事件驱动程序。其中 主程序等待某些事件发生,然后 调用一个过程处理该事件。典型的事件 包括被敲击的键、移动的鼠标、被按下的鼠标 或 插入的U盘。调用事件处理程序 处理事件,刷新屏幕,并 更新内部程序状态
在UNIX中,系统调用(如read)和 系统调用所使用的库过程(如read)之间几 乎是一一对应的。换句话说,对于每个系统调用,差不多 就涉及一个被调用的库过程
在 Windows 中,情况就大不相同了。首先,库调用和实际的系统调用 几乎是不对等的。微软 定义了一套过程,称为Win32应用程序接口
Windows 中没有类似 UNIX 中的进程层次,所以不存在 父进程和子进程的概念。在进程创建之后,创建者和被创建者 是平等的
7、操作系统结构
分析其内部
7.1 单体系统
1、在大多数常见的组织中,整个操作系统 在内核态 以单一程序的方式运行。整个操作系统 以过程集合的方式编写,链接成 一个大型可执行二进制程序。使用这种技术,系统中 每个过程 可以自由调用其他过程,只要后者提供了前者所需要的一些有用的计算工作
在使用 这种方式构造实际的目标程序时,首先 编译所有的单个过程,或者 编译包含过程的文件,然后 通过系统链接程序 将它们链接成 单一的目标文件。每个过程对其他过程都是可见的(相反,构造中有模块或包,对信息隐藏处理,其中多数信息隐藏在模块之中,而且 只能通过正式设计的入口点实现模块的外部调用)
信息隐藏 是软件工程中的一项重要原则,旨在 将模块或对象的内部实现细节隐藏起来,只通过 定义良好的接口与外部交互。
这种做法 有助于 降低模块之间的耦合,提高系统的可维护性和可扩展性
在模块化的设计中,大部分实现细节 被封装在模块内部,外部只能 通过模块提供的正式设计的入口点(如公共接口、公共方法)来访问模块的功能
2、即使 在单体系统中,也可能有一些结构存在。可以 将参数放置在良好定义的位置(如栈),通过这种方式,向操作系统请求所能提供的服务(系统调用),然后 执行一个陷阱指令。这个指令 将机器从用户态切换到内核态,并把控制传给操作系统。然后,操作系统 取出参数 并且确定应该执行 哪一个系统调用。随后,它在一个表格中检索,在该表格的 k 列中存放着指向执行系统调用 k 过程的指针
1)需要一个主程序,用来处理 服务过程请求
2)需要一套服务过程,用来 执行系统调用
3)需要一套实用过程,用来 辅助服务过程
在此模型中,每一个系统调用 都通过一个服务过程 为其工作并运行之。要有一组实用程序 来完成一些服务过程 所需用到的功能,如从用户程序取数据等。可将各种过程 划分为一个三层的模型
主程序充当 整个系统的核心控制器或调度器,负责接收来自外部(如用户、客户端程序、其他系统)的服务请求
请求解析: 主程序需要解析请求的内容,确定请求的类型、所需的服务,以及相关的参数
调度服务过程: 根据请求的类型,主程序 将请求转发给 相应的服务过程来处理
如:
操作系统内核: 在操作系统中,内核主循环 负责处理 来自用户空间的系统调用请求,并将其分发给 对应的内核服务过程
服务器程序: 如Web服务器的主线程,监听客户端的连接请求,并将请求 交给 工作线程或进程处理
服务过程的作用:
功能实现: 服务过程包含了 系统提供的实际功能或服务的实现,例如 文件读写、网络通信、进程管理等
系统调用处理: 当主程序接收到 系统调用请求后,服务过程负责 执行具体的系统调用操作,与底层硬件或操作系统 进行交互
示例:
文件系统服务过程: 处理文件的打开、关闭、读写、删除等操作
网络服务过程: 处理数据的发送、接收、连接管理等网络操作
进程管理服务过程: 负责创建、调度、终止进程等操作
实用过程的作用:
提供通用功能: 实用过程(Utility Procedures)提供服务过程 需要的辅助功能,如数据处理、格式转换、错误处理等
减少重复代码: 通过 将常用的功能 抽象为实用过程,避免在多个服务过程中 重复实现相同的逻辑
支持服务过程: 实用过程 为服务过程提供支持,简化服务过程的实现,使其更专注于核心功能
示例:
日志记录过程: 提供统一的日志记录接口,供服务过程 在需要时调用
错误处理过程: 定义标准的错误处理和报告机制,帮助 服务过程处理异常情况
数据验证过程: 对输入的数据进行校验,确保数据的完整性和正确性
3、除了 在计算机初启时所装载的核心操作系统外,许多操作系统 支持可装载的扩展,诸如 I/O 设备驱动 和 文件系统。这些部件 可以按照需要加载。在 UNIX 中它们被称为 共享库,在Windows中则被称为 动态链接库(Dynamic Link Library),它们的扩展类型为.dll
7.2 层次式系统
1、上层软件都是在下一层软件的基础之上 构建的
在0层之上,系统 由一些连续的进程 所组成,编写这些进程时 不再考虑在单处理器上多进程运行的细节
在第1层之上,进程 不用考虑 它是在磁鼓上 还是在内存中运行。第1层软件 保证一旦需要访问某一页,该页必定已在内存中,并在页 不再需要时将其移出
第2层处理 进程与操作员控制台(即用户)之间的通信。在这层的上部,可以认为 每个进程都有自己的操作员控制台
第3层管理 I/O 设备和相关的信息缓冲区。在第3层上,每个进程 都与有良好特性的抽象 I/O 设备打交道,而不必考虑 外部设备的物理细节
第4层是用户程序层。用户程序 不用考虑进程、存储、控制台 或 I/O 设备管理等细节。系统操作员进程 位于第5层中
2、在 MULTICS 系统中 采用了 更进一步的通用层次化概念。MULTICS 由许多的同心环构造而成,而不是 采用层次化构造,内环 比外环有更高的级别(这些环层 可以理解为操作系统中 不同的权限级别)。当外环的过程 欲调用内环的过程时,它必须执行一条 等价于系统调用的 TRAP 指令。在执行该 TRAP 指令前,要进行严格的参数合法性检查。在 MULTICS 中,尽管整个操作系统是 各个用户进程的地址空间的一部分,但是硬件 仍能对单个过程(实际是内存中的一个段)的读、写和执行进行保护
外环(ring 1, ring 2, …, ring n)则逐渐降低权限,通常是用户层或者应用层
THE分层方案 只是为设计提供了一些方便,因为该系统的各个部分 最终仍然被链接成了完整的单个目标程序。而在 MULTICS 里,环形机制在运行中 是实际存在的,而且是由硬件实现的。环形机制的一个优点是 很容昜扩展,可以用以构造用户子系统
7.3 微内核
1、在分层方式中,设计者要确定 在哪里划分内核 - 用户的边界。传统上,所有的层都在内核中,但是这样做没有必要。事实上,尽可能减少 内核态中功能的做法更好,因为内核中的错误 会快速拖累系统。相反,可以把用户进程 设置为具有较小的权限
为了实现高可靠性,将操作系统 划分成小的、良好定义的模块,只有其中一个模块 —— 微内核 —— 运行在内核态,其余的模块 由于功能相对弱些,则作为 普通用户进程运行
特别地,由于把每个设备驱动和文件系统 分别作为普通用户进程,这些模块中的错误 虽然会使这些模块崩溃,但是 不会使得整个系统死机。所以,音频驱动中的错误 会使声音中断或停止,但是不会使整个计算机垮掉
相反,在单体系统中,由于所有的设备驱动都在内核中,一个有故障的音频驱动 很容易引起对无效地址的引用,从而造成恼人的系统立即停机
2、MINIX 3 微内核只有12000 行C 语言代码 和 1400 行用于非常底层次功能的汇编语言代码,诸如捕获中断、进程切换等。C 代码管理和调度进程、处理进程间通信 (在进程之间传送信息)、提供大约40 个内核调用,它们使得操作系统的其余部分 可以完成其工作。这些调用 完成诸如连接中断句柄、在地址空间中移动数据 以及 为新创建的进程安装新的内存映像等功能
连接中断句柄:设置和管理 中断处理程序,当硬件设备发出中断信号时,系统能够正确响应
在地址空间中移动数据:在不同进程的地址空间之间 传输数据,或者 在内核和用户空间之间 移动数据
为新创建的进程 安装新的内存映像:当创建新进程时,加载 其可执行代码和数据到内存,设置其运行环境
3、句柄(Handle):
在操作系统中,句柄是一种抽象的引用,用于标识和管理系统资源,例如文件、内存块、设备等。可以将句柄理解为资源的“代号”或“指针”,应用程序 通过句柄来访问和操作这些资源,而无需 直接处理资源的底层实现细节
连接中断句柄:在上下文中,连接中断句柄是指 为特定的硬件中断 设置对应的中断处理程序。当硬件设备(如键盘、鼠标、网络接口)产生中断信号时,操作系统 需要调用相应的处理程序 来响应这个中断。微内核 通过句柄来管理 这些中断处理程序的连接和调用,确保系统 能够正确地响应和处理硬件事件
4、内存映像(Memory Image):
内存映像是指 程序或进程在内存中的完整表示,包含了 可执行代码、已初始化和未初始化的数据、堆栈、堆 以及 其他运行所需的资源
为新创建的进程 安装新的内存映像:当操作系统创建一个新进程时,需要在内存中为其分配空间,并将其可执行代码和数据 加载到内存。这包括:
加载程序代码:将可执行文件的指令和数据 加载到内存中
设置数据段:初始化全局变量和静态变量
配置堆栈和堆:为函数调用、参数传递 和 动态内存分配准备空间
设置入口点:指定程序开始执行的位置
通过 安装内存映像,操作系统确保 新创建的进程 具有完整的运行环境,可以独立地执行其任务
5、在内核的外部,系统的构造 有三层进程,它们都在用户态运行。最底层中 包含设备驱动器。由于 它在用户态运行,所以不能物理地访问 I/O 端口空间,也不能直接发出 I/O 命令。相反,为了能够对 I/O 设备编程,驱动器构建了一个结构,指明 哪个参数值写到哪个 I/O 端口,并生成一个内核调用,通知内核 完成写操作。这个处理意味着 内核可以检查驱动 正在对 I/O 的读(或写)是否得到授权使用的
在驱动器上面 是另一用户态层,包含服务器,它们 完成操作系统的多数工作。由一个或多个文件服务器 管理着文件系统,进程管理器创建、销毁 和 管理进程等
系统 对每个进程的权限 有着许多限制。正如已经提及的,设备驱动器 只能与授权的 I/O 端口接触,对内核调用的访问 也是按单个进程进行控制的
让每个驱动和服务器 只拥有完成其工作所需的权限,这样就 极大地限制了 故障部件可能造成的危害
内核中的机制与策略分离的原则。考虑 进程调度。一个比较简单的调度算法是,对每个进程 赋予一个优先级,并让内核 执行具有最高优先级的进程。这里,机制 (在内核中) 就是寻找最高优先级的进程 并运行之。而策略 (赋予进程优先级) 可以由用户态中的进程完成。在这种方式下,机制和策略是分离的,从而使系统内核变得更小
7.4 客户端-服务器模式
1、一个微内核思想的略微变体是 将进程划分为两类:服务器,每个服务器 提供某种服务;客户端,使用这些服务,这个模式的本质是 存在客户端进程和服务器进程
客户端和服务器之间的通信是 消息传递。为了获得一个服务,客户端进程 构造一段消息,说明所需要的服务,并将其发给合适的服务器。该服务器完成工作,发送回应。由于客户端 通过发送消息与服务器通信,客户端 并不需要 知道这些消息是在本地机器上处理,还是 通过网络 被送到远程机器上处理
7.5 虚拟机
1、VM/370
分时系统 应该提供这些功能:
(1) 多道程序,
(2) 一个比裸机更方便的、有扩展界面的计算机
这个系统的内核 称为虚拟机监控程序,它在裸机上运行 并且具备多道程序功能。系统向上层提供了 若干台虚拟机。仅仅是 裸机硬件的精确复制品。这个复制品 包含了 内核态 / 用户态、I/O功能、中断 及 其他真实硬件所应具有的全部内容
由于每台虚拟机 都与裸机相同,所以在每台虚拟机上 都可以运行一台裸机所能运行的 任何类型的操作系统。不同的虚拟机 可以运行不同的操作系统
在早期的 VM/370 系统上,有一些系统运行 OS / 360 或者 其他大型 批处理 或 事务处理操作系统,而另一些虚拟机 运行单用户、交互式系统 供分时用户使用,这个系统 称为会话监控系统 (Conversational Monitor System, CMS),当一个 CMS 程序执行 系统调用时,该调用 被陷入到其虚拟机的操作系统上,而不是 VM/370 上,似乎它运行在实际的机器上,而不是 在虚拟机上。CMS 然后发出普通的硬件 I/O 指令 读出虚拟磁盘 或 其他要执行的调用。这些 I/O 指令由 VM/370 陷入,然后,作为对实际硬件模拟的一部分,VM/370 完成指令
批处理 是指一次处理大量数据的过程,通常这些数据的处理 不需要即时响应。批处理系统 可以收集大量的交易记录 或 其他数据,然后在预定的时间(如每天结束时)一次性处理这些数据。这种方式适合于 那些需要处理大量历史数据的应用场景,比如 财务结算、报表生成 等
事务处理 则是指处理一系列需要保证原子性、一致性、隔离性和持久性的操作。事务处理通常要求 快速响应,确保每一次操作都能及时完成,并且在遇到故障时 能够回滚到之前的状态,保持数据的一致性。这种处理方式常见于 银行转账、在线购物等需要即时反馈的应用中
ACID特性:
原子性(Atomicity):事务是一个不可分割的工作单位,事务中的操作要么全部完成,要么全部不完成
一致性(Consistency):事务必须使数据库从一个一致状态转变到另一个一致状态
隔离性(Isolation):事务的执行互不干扰,一个事务的中间状态 对其他事务是不可见的
持久性(Durability):一旦事务提交,它对数据库的改变应该是永久的,即使系统发生故障
高并发:事务处理系统需要支持高并发,能够同时处理多个用户的请求
2、虚拟机的再次发现
另一个使用虚拟机的领域,是为了运行 Java 程序,Java 编译器为 JVM 生成代码,这些代码以后可以通过 Internet 传送到任何有 JVM 解释器的计算机上,并在该机器上执行。使用 JVM 的另一种优点是,如果解释器正确地完成,并不意味着 就结束了,还要对所输入的 JVM 进行安全性检查,然后在一种保护环境下执行
7.6 外核
1、与 虚拟机克隆真实机器 不同,另一种策略是 对机器进行分区,换句话说,给每个用户整个资源的一个子集。这样,某个虚拟机 可能得到磁盘的 0 至 1023 盘块,而另一台虚拟机 会得到 1024 至 2047 盘块
在外核机制中,一种称为外核的程序 在内核态运行。它的任务是 为虚拟机分配资源,并检查 使用这些资源的企图,以确保没有机器会使用他人的资源
2、外核机制的优点是,它减少了映像层。在其他的虚拟机设计中,每个虚拟机 都认为它有自己的磁盘,其盘块号从0到最大编号,这样虚拟机监控程序 必须维护一张表格 以重映像磁盘地址 (以及其他资源)。有了外核,这个重映像处理就不需要了。外核只需要记录 已经分配给各个虚拟机的有关资源即可。这个方法 还有一个优点,它将多道程序 (在外核内) 与用户操作系统代码 (在用户空间内) 加以隔离,而且相应负载并不重,这是因为外核所做的 只是保持多个虚拟机彼此不发生冲突
减少映像层: 在传统的虚拟机设计中,每个虚拟机 都会有自己独立的 “磁盘映像”,虚拟机 认为自己有一块完整的磁盘,盘块号从0开始。而实际情况是,底层硬件资源是共享的,因此虚拟机监控程序 需要进行磁盘地址映射,把虚拟机请求的盘块号 转换为 物理硬盘上的具体地址。这增加了 额外的处理负担
外核的机制 则简化了这个过程。外核 不再进行复杂的磁盘地址映射,而是 直接记录 每个虚拟机被分配的物理资源,外核直接将物理硬件资源分配给 各个虚拟机,只需记录 哪些资源已经被分配给 哪个虚拟机。因此,外核不需要 再进行重映像处理(磁盘地址转换),从而减少了开销
隔离多道程序 和 用户操作系统: 外核 将多道程序与用户操作系统代码分离。多道程序是指 在外核中管理多个任务的能力,而用户操作系统代码 则运行在用户空间。这种分离的好处是 减少了相互之间的干扰和冲突,外核 负责最底层的资源管理 和 安全隔离,而用户操作系统代码 运行在用户空间,负责更高层次的功能
负载较轻: 外核的任务 仅仅是 管理资源分配,确保不同虚拟机之间 不发生冲突,而不进行复杂的资源映射操作。因此,整体负载相对较轻
8、依靠 C 的世界
8.3 大型编程项目
1、每个 .c 由 C 编译器编译成一个目标文件。目标文件使用后缀 .o,含有目标机器的二进制代码
C编译器的 第一道称为 C 预处理器。在它读入每个 .c 文件时,每当遇到一个 #include 指令,就取来该名称的头文件,并加以处理、扩展宏、处理条件编译(以及 其他事务),然后 将结果传送给编译器的下一道,仿佛它们原先就包含在该文件中一样
由于操作系统非常大(500万行代码是很寻常的),每当文件修改后 就重新编译是无法忍受的。另一方面,改变了 用在成千上万个文件中的一个关键头文件,确实需要 重新编译这些文件。没有一定的协助,要想记录哪个目标文件与哪个头文件相关 是完全不可行的
有个名为 make 的程序(其大量的变体如 gmake、pmake 等),它读入 Makefile,该 Makefile 说明 哪个文件与哪个文件相关。make 的作用是 在构建操作系统二进制码时,检查此刻需要哪个目标文件,而且 对于每个文件,检查自从上次目标文件创建之后 是否有任何它依赖的文件(代码和头文件)已经被修改了。如果有的话,目标文件 需要重新编译。在 make 确定了哪个 .o 文件需要重新编译之后,它调用 C 编译器 重新编译这些文件,这样就把编译的次数降到最低限度
一旦所有的 .o 文件就绪,这些文件 被传递给称为 linker 的程序,将其组合成 一个可执行的二进制文件,在 linker 完成之后,得到一个可执行程序,在 UNIX 中传统上称为 a.out 文件
8.4 运行模型
1、在 操作系统二进制代码 链接完成后,计算机 就可以重新启动,新的操作系统 开始运行。一旦运行,系统会动态调入 那些没有静态包括在二进制代码中的模块,如设备驱动和文件系统
在运行过程中,操作系统 可能由若干段组成,有文本段(程序代码)、数据段 和 堆栈段。文本段 通常是不可改变的,在运行过程中不可修改。数据段 开始时有一定的大小,并用确定的值进行初始化,但是 随后就被修改了,其大小需要增长。堆栈段被初始化为空,但是 随着对函数的调用和从函数返回,堆栈段时刻在增长和缩小。通常文本段放置在接近内存底部的位置,数据段在其上面,这样可以向上增长。而堆栈段处于 高位的虚拟地址,具有向下增长的能力,不过不同系统的工作方式各不相同
在所有情形下,操作系统代码都是直接在硬件上执行的,不用解释器,也不是即时编译,如Java通常做的那样
2、
- 文本段
定义: 文本段是 存储程序代码(也叫指令)的部分,包含编译后生成的机器指令。它是只读的,防止程序 意外修改自己的代码
作用: 负责存储 已经编译的指令,供CPU执行。在程序运行时,文本段被加载到内存中,由操作系统和硬件控制程序 执行指令
特点:
只读段,防止代码被修改
多个进程 可以共享相同的程序代码,从而节省内存(如果 多个进程运行相同的程序,操作系统 可以让它们共享同一个文本段的拷贝) - 数据段
定义: 数据段是 存储程序中的静态全局变量的部分。该段用于 保存程序运行时所需的初始化数据
作用: 存储程序在编译时 已经确定的数据和全局变量。这些数据在程序的整个生命周期中 都可以访问
特点:
包含 全局变量和静态变量
可以分为 已初始化的数据段 和 未初始化的数据段(未初始化的数据 在程序启动时 通常被设置为0)
读写权限,可以被程序修改 - 堆栈段
定义: 堆栈段是 存储函数调用相关信息的部分,包括函数的参数、本地变量(局部临时变量通常存储在 堆栈段 中。局部变量是 函数内部定义的变量,它们的生命周期 是短暂的,随着函数的调用和结束 而创建和销毁) 和 返回地址等。堆栈以“后进先出”(LIFO)的方式操作
作用: 每次函数调用时,都会将函数参数和返回地址 压入堆栈,当函数返回时,堆栈中对应的项被弹出。它用于 管理程序的控制流和本地数据
特点:
自动管理,随着函数调用的进行,系统会动态地 向堆栈压入或弹出数据
堆栈大小有限,过度使用可能导致“堆栈溢出”错误
读写权限,存储的数据 在函数调用过程中是动态的,可以被函数修改
使用 malloc 函数动态分配的内存空间位于 堆段(Heap Segment) 中,而不是在堆栈段或其他段中,由程序员手动分配和释放,不会像堆栈那样自动管理,如果没有正确释放,则会引发内存泄漏
12、小结
考察操作系统 有两种观点:资源管理观点 和 扩展的机器观点。在资源管理观点中,操作系统的任务是 有效地管理系统的各个部分。在扩展的机器观点中,系统的任务是 为用户提供比实际机器更便于运用的抽象。这些抽象 包括进程、 地址空间 以及 文件
分时系统和多道程序系统的区别是什么?
在分时系统中,多个用户 可以使用 他们自己的终端 同时访问和执行计算系统上的计算。 多道程序设计系统 允许一个用户 同时运行多个程序
所有分时系统都是多道程序设计系统,但并非所有多道程序设计系统 都是分时系统,因为多道程序设计系统可以在 只有一个用户的PC上运行
为了使用高速缓存,主存被划分为 若干 cache 行,每行长32或64字节。每次缓存 一整个 cache 行,每次缓存一整行 而不是一个字节或一个字,这样的优点是什么
经验证据表明,存储器访问 表现出 引用局部原则,即如果读取某一个位置,则接下来访问这个位置的概率非常高,尤其是 紧随其后的内存位置。 因此,通过缓存整个缓存行,接下来 缓存命中的概率会增加