L02 操作系统概述2

回顾

上一堂课的内容主要回顾了操作系统的基本概念、抽象和历史演变。操作系统的抽象主要包括以下几个重要的方面:

  • CPU 与进程:操作系统将 CPU 抽象为进程,通过进程的调度和管理来协调 CPU 资源的使用。
  • 存储设备与文件系统:磁盘和其他存储设备被抽象为文件,操作系统通过文件系统来管理数据的存取。
  • 内存与地址空间:操作系统将内存抽象为地址空间,进程通过地址空间访问内存,从而保证了进程间的独立性和安全性。

这些抽象的概念在后续的实验和实践中将会更深入理解,特别是涉及到操作系统的并发、共享、虚拟化和异步等核心特征。

操作系统的发展演变

操作系统并不是一开始就具有现代的特征。它随着硬件技术和应用需求的变化不断演变,经历了多个阶段的发展。

  • 硬件驱动的演变:操作系统的功能和结构随着硬件的发展而变化。例如,早期的个人电脑操作系统功能较弱,但满足了当时的需求。
  • 应用需求的变化:随着应用的多样化,操作系统也逐步增加了新的特性,例如多任务、多用户支持等。

历史上,操作系统的发展经历了螺旋上升的过程,有时甚至出现短暂的回退。例如,早期的个人电脑操作系统功能相对简化,是对大型计算机操作系统的功能性“退步”,但它适应了个人电脑有限的硬件资源和应用场景。

操作系统的定义和作用

操作系统的定义并不是一个严格的数学定义,而是一个随着需求变化的动态定义。基本上,操作系统有两个主要功能:

  • 向上为应用提供服务:操作系统通过提供抽象层来简化应用程序对硬件资源的使用。
  • 向下管理硬件资源:操作系统负责协调和管理底层硬件资源,使得多个应用能够高效地共享和使用这些资源。

操作系统的具体特性可能在不同阶段、不同场景中发生变化。例如,在早期实验中,某些特性可能没有被实现,而随着课程进展,这些特性会逐步被引入。

面向服务器与面向手机的操作系统

面向服务器的操作系统和面向手机的操作系统在功能上有所不同,主要体现在以下几个方面:

  • 用户体验:手机操作系统更加注重用户的交互体验,强调流畅度和易用性。相比之下,服务器操作系统更注重性能和稳定性,通常通过命令行进行操作,缺少图形界面的需求。
  • 硬件管理:服务器操作系统需要处理更强大的硬件资源,例如多核处理器、大量内存和存储设备,而手机操作系统则需要在有限的资源下优化功耗和性能。
  • 应用场景:手机操作系统主要面向个人用户,强调人机交互,而服务器操作系统主要处理后台服务,面向大规模并发请求。

尽管存在这些差异,操作系统的核心任务,即管理硬件资源和为应用提供抽象服务,仍然是相同的。

网络浏览器与操作系统的整合

随着技术的发展,网络浏览器已经逐渐成为现代通用操作系统的一部分。在过去的 30 年中,操作系统的组成发生了显著的变化。早期,浏览器并不是操作系统的核心组件,但随着互联网的普及和应用需求的增长,浏览器已成为现代操作系统中的标配功能,尤其是在桌面和移动设备中。现代操作系统,特别是通用操作系统,如桌面操作系统和手机操作系统,已经默认集成了网络浏览器。这反映了操作系统随着需求和技术发展的动态变化。

理解操作系统的这种演变,要求我们从不同历史阶段和需求背景下理解其组成部分的变化。早期操作系统的功能相对简单,但随着时间的推移,越来越多的功能被纳入其中,网络浏览器便是其中之一。

关于操作系统是否应当包含网络浏览器的问题,答案取决于应用场景。现代操作系统在设计时,往往会包含支持网络通信的基本功能,如 TCP/IP 协议栈和 HTTP 协议库等。这些功能为网络浏览器的运行提供了基础支持。随着网络应用的普及,网络浏览器已经成为操作系统中不可或缺的一部分,尤其是在桌面和移动设备中。

然而,在服务器操作系统中,图形用户界面和网络浏览器通常并不是核心需求,因为服务器通常通过命令行和远程控制进行管理。

实验进展和实践要求

关于实验部分,助教已经在平台上发布了实验的初步文档和代码,后续还会有测试的更新。实验任务的安排将根据课程进度进行同步,学生可以根据需要提前进行实验。通过实验的实践,学生能够更深入理解操作系统的概念和特性,特别是操作系统的并发、共享、虚拟化和异步特性。

操作系统的核心抽象

操作系统的核心抽象概念可以总结为三个关键部分:

  • 进程(Process):管理 CPU 资源,通过进程调度来确保多任务处理。
  • 文件(File):抽象存储设备,操作系统通过文件系统管理存储数据的存取。
  • 地址空间(Address Space):内存的抽象,确保每个进程拥有独立的内存空间,防止进程间的相互干扰。

尽管这些抽象概念表面上看起来简单,背后涉及大量复杂的设计与实现细节。课程的主要目标之一是帮助学生理解这些抽象,并掌握它们在操作系统中的具体实现方式。

操作系统与应用程序的交互

操作系统与应用程序之间的交互,主要通过系统调用实现。应用程序通过系统调用请求操作系统提供的服务,这种请求通常是单向的,即应用程序请求操作系统执行某些操作,而操作系统并不会主动请求应用程序执行任务。

然而,在数据层面,交互是双向的。应用程序可能将数据从用户空间传递给操作系统内核进行处理,内核在处理后也可能将结果数据返回给应用程序。理解这一点对于掌握操作系统的互操作性非常重要。

操作系统的并发、共享、虚拟化与异步特征

操作系统的四个核心特征包括:

  • 并发(Concurrency):多个程序可以同时运行,体现为多任务并行的能力。
  • 共享(Sharing):多个进程可以共享同一个资源,如同一文件的读写。
  • 虚拟化(Virtualization):操作系统通过虚拟化技术,使每个进程感觉自己独占整个计算机资源,即使实际情况并非如此。
  • 异步(Asynchrony):程序的执行不一定按照预期的顺序完成,存在不确定性。

通过实际编写小程序来体现这些特征,可以加深对操作系统设计和实现的理解。例如:

  • 并发可以通过让两个程序同时运行来体现。
  • 共享可以通过两个程序同时对同一文件进行读写操作来展示。
  • 虚拟化可以通过申请比实际物理内存更多的内存空间进行测试。
  • 异步特征可以通过测量程序的实际执行时间,发现其存在不确定性。

这些特征不仅仅体现在应用层面,也体现在操作系统设计的深层次逻辑中。

基于 C 和 Java 应用的执行环境差异

C 和 Java 应用程序的执行环境存在显著差异:

  • C 程序的执行环境:C 程序直接运行在操作系统提供的环境中,操作系统负责为其管理资源,如内存、文件系统和 CPU。
  • Java 程序的执行环境:Java 程序运行在 Java 虚拟机(JVM)中,JVM 是 Java 的抽象执行环境,负责跨平台兼容性和资源管理。JVM 自身运行在操作系统之上,提供了额外的抽象层。

这一区别意味着 C 程序直接依赖操作系统的服务,而 Java 程序则通过 JVM 间接与操作系统交互。这是两者在执行环境上的根本不同点。

操作系统的系统结构

操作系统(OS)是一种非常复杂的软件系统。以我们在实验中编写的操作系统为例,它至少包含几千行代码。而像 Linux、Windows 这样的现代操作系统,往往包含数千万行代码,甚至更多。如此庞大的软件系统需要借助软件架构的设计来支撑,这类复杂软件的设计属于软件工程的范畴。

简单结构

简单结构的操作系统,像早期的 MS-DOS,其设计理念非常简单,应用程序与操作系统的关系类似于库函数调用。应用程序可以直接调用操作系统提供的服务,且两者之间没有严格的隔离或保护机制。这种架构虽然简单,但也因此容易出现安全隐患。

然而,简单结构并非完全过时。在某些应用需求非常简单的场景下,简单结构依然有其价值。例如,某些工业控制领域,系统复杂度较低,使用简单结构可以降低开发成本和复杂度。

单体分层结构

单体分层结构是目前主流操作系统(如 UNIX、Linux、BSD、MacOS、Windows 等)采用的架构。这种结构通过分层的方式将操作系统划分为多个模块,从最底层的硬件抽象层,到上层的文件系统、进程管理和内存管理等。

这种分层设计的优点是每一层只依赖于下一层的服务,从而在开发过程中只需关注与下一层的交互,不需要了解整个系统的全貌。然而,随着操作系统规模的不断增加,实际开发中不同层次之间的耦合关系变得更加复杂,理想中的分层结构逐渐被打破,导致开发和维护变得更加困难。

尽管如此,单体内核结构仍然是大多数通用操作系统的首选,因为它能够在性能与复杂度之间取得较好的平衡。

微内核结构

微内核架构主要来自学术界,最早由卡内基梅隆大学(CMU)的研究者提出。他们认为,操作系统的复杂性随着功能的增加而膨胀,为了应对这一问题,微内核架构将内核的功能缩减到最小,只保留最基础的硬件控制功能和进程间通信机制。

在微内核架构中,传统操作系统中的大部分功能(如文件系统、内存管理和设备驱动等)被移到用户态运行,以减少内核的复杂度。微内核通过消息传递机制(如本地过程调用 LPC(Local Procedure Call))来实现进程间的通信。这种设计理论上可以降低系统复杂性,增强安全性。

然而,微内核架构的一个主要问题是性能。由于大量功能从内核移到了用户态,数据传递和控制流变得更加间接,导致性能显著下降。与单体内核相比,微内核架构在相同的硬件上运行同样的应用程序时,性能可能会降低数倍。因此,尽管微内核在安全性上有所优势,但在性能需求较高的领域,仍然难以得到广泛应用。

不过,在某些对安全性有极高要求的场景,如核电站、医疗设备等,微内核架构依然有其存在的价值。

外核架构

外核架构是由 MIT 的教授 Frans Kaashoek 和他的学生提出的。与传统的内核架构不同,外核架构(Exokernel)旨在将操作系统的许多传统功能下放到应用层。通常情况下,内核负责管理硬件资源,例如文件系统、网络协议栈和设备驱动。然而,外核架构认为这些功能应该与应用程序紧密耦合,使应用程序能够直接控制硬件资源。

外核的设计理念

外核的核心理念是将内核的功能最小化,只保留硬件的直接管理,例如中断处理和进程通信。而文件系统、网络协议等高级服务则作为库提供,直接绑定到应用程序中。这样,应用程序可以根据自身需求直接访问硬件,而无需通过系统调用进入内核,从而减少性能开销。

例如,TCP/IP 协议栈和文件系统可以被设计成应用程序库,通过直接访问硬件提供更高效的操作。这种设计在性能上有一定优势,因为它避免了传统内核中频繁的上下文切换和内核空间与用户空间之间的数据传递。然而,它的缺点在于安全性,因为应用程序拥有更多直接访问硬件的权限,一旦发生错误,可能影响系统的稳定性。

外核架构的实际应用与局限

虽然外核架构在理论上具有很高的灵活性和性能,但它在实际中很少被广泛应用。外核架构的一个局限性是,它要求每个应用程序都为其功能定制操作系统库,这在开发和维护上增加了复杂性。虽然外核架构曾被广泛讨论,并且在学术界引起了很大的兴趣,但并未在主流操作系统中得到广泛应用。

与外核架构相似的思想在云计算的虚拟化技术中得到了扩展和实际应用。现代虚拟化技术利用类似的概念,将硬件资源进行虚拟化,多个虚拟机(VM)可以同时运行在同一台物理机器上,每个虚拟机都有独立的操作系统和应用程序。

虚拟机与云计算

虚拟机的思想是将一台物理计算机虚拟化成多个虚拟计算机,每个虚拟机运行自己的操作系统和应用程序。这种方式能够最大化硬件资源的利用率,尤其是在云计算的数据中心中。通过在一台物理服务器上运行多个虚拟机,服务提供商可以为多个用户同时提供云服务。

虚拟化技术的核心思想是与外核架构类似的硬件虚拟化,不同的是,虚拟化技术更侧重于提供完整的虚拟硬件环境,使操作系统和应用程序可以不加修改地运行在虚拟机中。而外核架构的重点是为每个应用定制轻量级的操作系统服务,减少系统开销,提升性能。

虚拟化与外核架构的联系与区别

外核架构的目标是通过直接将硬件资源暴露给应用程序,减少系统开销。而现代虚拟化技术则是通过抽象出多个虚拟的物理机,实现对硬件资源的高效利用。两者的主要区别在于:

  • 外核架构旨在为每个应用程序定制一个“轻量级内核”,减少系统的中间层,提高性能。
  • 虚拟机架构则是通过在一台物理机上运行多个完整的虚拟机,最大化硬件资源的使用率。

尽管外核架构并未在实际中广泛应用,但它的思想对现代操作系统设计,尤其是虚拟化技术,产生了深远的影响。

现今的虚拟化技术应用

虚拟化技术已经成为现代云计算基础设施的核心。像阿里云、腾讯云等大型云服务提供商,通过虚拟化技术,将闲置的硬件资源进行虚拟化处理,然后以低成本的形式提供给用户。这不仅提高了服务器的资源利用率,还降低了用户的使用成本。用户可以根据需求购买不同性能的虚拟机,无需关心底层硬件的实际配置。

这种技术的广泛应用使得云计算成为现代计算领域的重要组成部分。无论是个人开发者还是企业,都可以通过云服务轻松部署和扩展自己的应用程序,从而实现高效的资源利用。

UNIX / Linux

首先,我们讨论 UNIX 和 Linux 的渊源及其发行版之间的关系。

UNIX 与 Linux 的历史背景

UNIX 是一种早期的操作系统,起源于 20 世纪 60 年代,由 AT&T 的贝尔实验室开发。它的设计哲学强调简洁和模块化,使其成为后续众多操作系统的蓝本。Linux 则是基于 UNIX 思想开发的开源操作系统,最初由林纳斯·托瓦兹(Linus Torvalds)在 1991 年发布。Linux 内核是一个完全从零开始编写的操作系统内核,但它与 UNIX 共享很多相同的设计理念。

Linux 的发行版

Linux 内核只是一个操作系统的核心部分,基于该内核的操作系统通常会打包大量的应用程序、库和工具,这些被称为发行版(distributions)。各类 Linux 发行版,例如 Ubuntu、Fedora、SUSE、国内的麒麟、欧拉等,都是在 Linux 内核的基础上进行的定制开发,差异主要体现在系统工具、包管理器、桌面环境等方面。这些发行版虽然名字不同,但它们的核心——Linux 内核——是相同的。

因此,尽管 Linux 发行版种类繁多,但它们在系统核心上是统一的,即所有发行版都是基于 Linux 内核进行开发的。差别主要体现在用户体验、工具链和针对不同应用场景的优化。

虚拟机与 Linux 的应用

在虚拟机环境中,Linux 也被广泛应用。以 Windows 上的 WSL(Windows Subsystem for Linux)为例,WSL 允许用户在 Windows 上运行一个完整的 Linux 子系统。WSL 实际上是在 Windows 操作系统上运行的虚拟机环境,用户可以在 WSL 中安装诸如 Ubuntu 或者 Fedora 等 Linux 发行版。

例如,在 WSL 中,你可以运行 Ubuntu 20.04,尽管它是作为虚拟环境存在,但其核心仍然是基于 Linux 的完整操作系统。

MacOS 与 UNIX 的渊源

MacOS 是苹果公司的操作系统,它实际上也是基于 UNIX 发展而来的。具体来说,MacOS 是基于 BSD(Berkeley Software Distribution),这是 UNIX 的一个分支。虽然 MacOS 在图形用户界面(GUI)方面做了大量改进,但其底层仍然遵循 UNIX 的设计原则。

为什么选择 Linux?

Linux 的最大优势在于其开源性、丰富的文档以及广泛的社区支持。Linux 操作系统被广泛应用于从超级计算机到服务器,再到移动设备的各个领域。尤其在服务器和超级计算机领域,Linux 占据了主导地位,得益于其高度的可定制性和稳定性。

在桌面操作系统市场上,Linux 的影响力相对较小,Windows 和 MacOS 占据了主要市场份额。然而,Linux 依然是许多开发者和技术人员的首选,特别是在软件开发和服务器管理领域。Linux 的命令行界面(CLI)和强大的脚本支持,使其成为开发和自动化任务的理想选择。

Linux 系统的服务与抽象

Linux 提供了多种服务来支持应用程序的运行。主要包括以下几个抽象层次:

  1. 进程管理:操作系统负责管理应用程序的运行,将每个运行的程序作为一个进程管理,并进行进程调度。
  2. 内存管理:Linux 提供地址空间的抽象,分配内存给进程,并提供虚拟内存机制,以确保进程之间的隔离。
  3. 文件系统:数据以文件的形式存储在磁盘上,文件系统负责管理文件的存储、访问权限等。
  4. 多用户支持:Linux 提供多用户环境,用户可以根据权限进行不同操作。
  5. 网络支持:Linux 内核支持 TCP/IP 等网络协议,允许程序通过网络进行通信。

这些服务通过系统调用(Syscalls)实现,程序通过系统调用与内核交互。例如,open() 函数用于打开文件,write() 用于写入数据,这些函数调用在底层实际上是系统调用的封装。

系统调用的数量与复杂性

尽管 Linux 操作系统的代码量高达数千万行,但它提供的系统调用(Syscall)数量相对较少,约为 400 个左右。操作系统提供的系统调用数量有限,但每个调用背后可能包含大量复杂的代码和功能支持。

举个例子,简单的文件操作可能涉及多个系统调用,如 open()write()fork() 等。这些看似简单的函数调用实际上会涉及到内核的复杂操作,但 Linux 通过 C 库将这些系统调用封装成用户友好的接口,使得应用程序开发者不必直接处理复杂的系统级细节。

操作系统(OS)的核心系统调用

下面讨论操作系统(OS)的核心系统调用,特别是进程管理、内存管理以及文件系统的相关功能。以 UNIX 和 Linux 系统为例探讨这些系统调用的实现及其抽象背后的原理。

1. 系统调用与进程管理

操作系统的系统调用是用户程序与内核交互的主要方式。在操作系统中,进程管理是核心任务之一,下面是与进程相关的几个重要系统调用:

  • fork():用于创建一个新进程。调用 fork() 后,系统会复制一个与父进程几乎完全相同的子进程。子进程会继承父进程的大部分资源。xv6:fork()

  • exit() 和 wait():进程在完成任务后会调用 exit() 退出,父进程可以通过 wait() 等待子进程的结束,并获取子进程的退出状态。exit()wait() 形成了一对,用于进程生命周期的管理。xv6:exit()/wait()

  • kill():用于强制终止进程,传入的参数是进程的 PID(进程标识符),即需要被终止的进程号。xv6:kill()

  • getpid():获取当前进程的 PID,以便在需要时可以识别进程。

  • sleep():让当前进程暂停执行一段时间,相当于让进程“睡眠”,在指定的时钟周期后再恢复运行。xv6: Sleep & Wakeup

  • exec():将当前进程的执行内容替换为一个新的程序。这是 fork() 后通常会调用的函数,通过 exec() 加载并运行新的程序代码。xv6:fork & exec

这些系统调用主要围绕进程的创建、调度和终止进行,构成了进程管理的基础。

2. 内存管理

内存管理是操作系统的重要功能,操作系统负责为进程分配、回收内存:

  • sbrk():调整进程的数据段的大小,用于增加或减少进程的内存空间。sbrk() 的参数可以是正数也可以是负数,正数表示增加内存,负数表示减少内存。尽管直接使用 sbrk() 的场景不多,但它是底层内存分配的基础。xv6:sbrk

  • malloc() 和 free():高层次的内存管理函数。malloc() 从堆中分配一块内存,而 free() 用于释放内存。这些库函数实际上调用了 sbrk() 来进行内存的底层操作。操作系统和库(如 C 标准库)共同管理内存,库函数一般通过内存池的机制来减少频繁的系统调用,从而提高效率。

3. 文件系统管理

文件系统的操作是应用程序与持久存储设备交互的方式。常见的文件操作包括打开、读取、写入和关闭文件:

  • open():用于打开一个文件,返回一个文件描述符(FD),后续对文件的操作都通过文件描述符来进行。

  • write():向打开的文件中写入数据。需要提供文件描述符、内存中的数据缓冲区以及要写入的字节数。

  • read():从文件中读取数据到内存缓冲区,同样需要提供文件描述符和缓冲区,以及读取的字节数。

  • close():关闭文件描述符,释放与文件相关的资源。

这些文件操作通过系统调用实现,它们使得程序可以与底层存储设备交互,完成数据持久化。

文件描述符的管理

  • dup(): 创建一个新的文件描述符,指向与现有文件描述符相同的文件。这样,两个文件描述符(FD1 和 FD2)可以同时访问同一个文件。

dup() 的主要作用在于进程间通信和资源共享,通过为同一个文件创建多个文件描述符,进程可以通过不同的描述符进行并行操作。这在需要同时读写同一文件的场景下非常有用。

4. 目录操作

  • chdir(): 用于改变当前工作目录。
  • mkdir(): 创建一个新的目录。

这些操作扩展了文件系统的功能,允许操作系统对目录进行管理。在 UNIX 和 Linux 中,目录是文件系统的核心组成部分,通过这些系统调用,程序可以创建、删除或导航目录。

5. 特殊的文件和设备文件

  • mknod(): 用于创建一个特殊文件,通常是设备文件。设备文件是操作系统中对硬件设备的一种抽象,UNIX 系统中“所有东西都是文件”(Everything is a File)的设计哲学意味着设备也可以通过文件系统进行访问。

例如,操作系统可以通过 mknod() 创建一个表示磁盘或网络设备的文件,这个文件可以像普通文件一样被打开、读写。这个抽象使得操作系统能够简化对硬件设备的访问,将所有设备视为文件来统一处理。

6. 文件状态与信息获取

  • fstat() 和 stat(): 获取文件的状态信息,如文件大小、创建时间、权限等。

这些系统调用让用户可以获取有关文件的详细信息,以便于进一步处理。通过这些调用,用户可以判断文件是普通文件、目录还是设备文件。

7. 链接与解除链接

  • link():用于创建文件的硬链接。硬链接是指在文件系统中为同一个文件创建多个名称。这意味着一个文件可以通过不同的路径和名称被访问,但它们指向的是同一个底层文件数据。当一个进程对其中一个文件名进行操作时,实际上是在操作同一个物理文件。
  • unlink():用于删除文件的一个链接。重要的是,它并不立即删除文件,而是删除文件的一个名称。当文件所有的硬链接都被删除后,操作系统才会真正删除文件的内容。

例如,用户可以在一个目录下创建文件 file1,然后在另一个目录下通过 link() 创建 file2,它们都指向同一个文件。当 unlink() 删除 file2 后,file1 依然存在,文件的内容并不会被删除,只有当 file1 也被 unlink() 后,文件才会真正消失。

关于 link()unlink() 的设计

  • 灵活性:硬链接的设计极大增强了文件管理的灵活性,允许同一个文件在不同的目录下有多个名称,这为复杂的文件组织和共享提供了便利。
  • 安全性:通过多个文件名的映射机制,即使一个用户或进程删除了一个文件名,其他使用相同文件的进程仍可以继续操作该文件,直到所有硬链接都被删除。

系统调用的特点与效率

C 语言在系统调用的参数设计上非常简洁,大部分参数都是整数类型(如 PID、文件描述符等)。这反映了 C 语言的灵活性,但也带来了一定的风险,因为类型系统较为弱化,容易出现类型不匹配或参数误用的问题。

现代操作系统如 Linux 的系统调用数量大约在 400 个左右,尽管操作系统的代码量可能达到数千万行,但提供给用户的接口相对精简。系统调用的实现细节由操作系统内核处理,而用户程序通过调用封装在 C 库中的系统调用接口与内核通信。

C 语言与系统调用的封装

在 UNIX 系统中,系统调用并不是直接通过汇编语言调用内核,而是通过 C 库的函数封装实现的。这些封装函数为开发者提供了简洁的接口。例如,malloc()free() 是对 sbrk() 的进一步封装,提供了更高层次的内存管理机制。通过这种封装,操作系统可以屏蔽底层细节,简化应用程序的开发流程。

文件系统的抽象

UNIX 操作系统的设计哲学之一是将所有资源抽象为文件,不仅普通文件、目录是文件,设备、管道、网络套接字等也被视为文件。通过这种抽象,操作系统可以为不同类型的资源提供统一的访问接口,使得应用程序开发更加简洁和一致。

这种设计的一个优点是,它允许不同类型的设备通过同样的系统调用(如 open()read()write())进行操作,降低了系统和应用程序的复杂性。虽然文件系统本质上处理的是磁盘上的数据,但它扩展了功能,能够管理各种硬件和系统资源

copy.c 示例程序

copy.c是一个非常简单的 C 程序,用来演示文件的读取和写入过程。该程序使用标准的 read()write() 系统调用从标准输入(键盘)读取数据,并将其输出到标准输出(屏幕)。

// copy.c: copy input to output.

#include "kernel/types.h"
#include "user/user.h"

int
main()
{
    char buf[64];

    while(1){
        int n = read(0, buf, sizeof(buf));
        if(n <= 0)
            break;
        write(1, buf, n);
    }
    exit(0);
}

程序结构

  • buffer:程序首先定义了一个缓冲区,用来存储从标准输入读取的字节数据。
  • read():从标准输入读取 buffer 中的内容,这里 FD 0 是标准输入的文件描述符,即键盘输入。
  • write():将读取到的字节数据写入到标准输出,FD 1 是标准输出的文件描述符,即屏幕。
  • exit():程序结束时,调用 exit(0),表示程序成功结束。

这个程序的实际效果是:

  1. 从键盘输入字符串;
  2. 然后程序将字符串原样显示在屏幕上。

文件描述符的角色

FD 0FD 1 分别对应标准输入和标准输出,这是 UNIX 和 Linux 系统中的常见约定。在进程创建时,系统默认会为每个进程打开标准输入、标准输出和标准错误(FD 2)这三个文件描述符,因此程序无需手动调用 open() 来打开这些描述符。

Xv6 实验环境

copy.c 这个程序是在 MIT 的 Xv6 操作系统上运行的。Xv6 是一个基于 UNIX 的简化版操作系统,常用于操作系统课程的教学。Xv6 的设计遵循 UNIX 的核心思想,但代码量和复杂度大大降低,便于学习和理解。

  • QEMU:在实验中使用了 QEMU 模拟器运行 Xv6,这是一种模拟硬件的方式,可以让学生在虚拟机中运行自己的操作系统。
  • Shell:Xv6 提供了一个简单的 Shell 作为用户接口,学生可以在其中输入命令、执行程序。

在执行 copy.c 时,程序运行在 Xv6 的 Shell 中,通过键盘输入数据,程序将数据读取并回显到屏幕上。

操作系统复杂性与用户应用便利性

通过这个简单的 copy.c 示例,展示了操作系统如何通过一系列底层的系统调用来为应用程序提供服务。尽管 copy.c 程序只有几十行代码,但操作系统需要支持的功能却非常复杂,涉及到文件管理、进程调度、内存管理等诸多内容。这也是操作系统设计的挑战所在——为用户提供简洁、直观的接口,同时在底层实现复杂的功能。

文件操作与 open()

open() 系统调用用于打开文件,并返回一个文件描述符,随后可以通过 read()write() 对该文件进行读写操作。在讨论中,解释了如何通过 open() 创建或打开文件,然后使用 write() 将数据写入文件。

以下是一个简单的示例流程:

  • open():打开一个文件,指定读写权限(如 O_WRONLY 表示只写)。
  • write():将数据写入打开的文件。
  • close():关闭文件,释放文件描述符。

这是一套常见的文件操作流程,通过 open() 获取文件描述符后,可以对文件执行一系列读写操作。

fork()exec() 的结合

在 UNIX 和 Linux 系统中,进程管理是通过 fork()exec() 系统调用来实现的。fork() 创建一个新的进程(称为子进程),子进程几乎完全复制了父进程的所有资源。而 exec() 则用于在子进程中执行一个新的程序代码,替换掉子进程原有的代码。

  • fork():用于创建一个新的进程,称为子进程。子进程几乎复制了父进程的所有资源,除了它们的进程 ID(PID)不同。fork() 返回两次:一次在父进程中,返回子进程的 PID;一次在子进程中,返回 0。

  • exec():用于在子进程中执行新的程序。通过 exec(),子进程的地址空间被新程序替换,因此子进程的执行逻辑发生变化。exec() 不返回到调用点,因为一旦执行新程序,旧的进程内容就被新程序覆盖了。

xv6:fork & exec例子中,程序演示了如何通过 fork() 创建子进程,并用 exec() 加载新的程序逻辑,这两个调用的结合是 UNIX 系统中多任务处理的基础。

父子进程通信和退出机制

  • wait():父进程通过 wait() 等待子进程的结束。当子进程结束时,操作系统会将子进程的退出状态(status)传递给父进程,这样父进程就知道子进程已经完成了任务。
  • exit():子进程完成任务后调用 exit(),并传递一个退出状态(例如 0 表示成功)。操作系统捕获这个状态并通知父进程。

这种父子进程间的通信和同步机制是通过系统调用实现的,确保父进程可以获取到子进程的执行结果。

fork() 的返回值解释

fork() 的返回值决定了当前是在父进程还是子进程中执行:

  • 子进程fork() 返回 0,表示这是子进程。此时子进程可以执行新的任务,通常会调用 exec() 来加载并运行新的程序。
  • 父进程fork() 返回子进程的 PID,表示这是父进程。父进程可以选择继续执行自己的任务,也可以通过 wait() 等待子进程完成。

通过这样的设计,父子进程可以执行不同的代码逻辑,父进程通常等待子进程完成,而子进程可以执行不同的程序。

示例程序中的 fork()exec()

forkexec.c 程序使用 fork() 创建子进程,然后子进程通过 exec() 执行新程序,父进程则通过 wait() 等待子进程的退出。这是一种常见的编程模式,尤其是在实现多任务处理时:

  • fork() 创建子进程:父进程调用 fork(),此时操作系统复制父进程,创建一个几乎相同的子进程。
  • 子进程调用 exec():子进程用 exec() 执行新程序,替换自身的地址空间为新程序。
  • 父进程调用 wait():父进程通过 wait() 等待子进程结束,并获取子进程的退出状态。

例如,Shell 程序(命令行解释器)通常通过 fork() 创建子进程,然后子进程通过 exec() 来执行用户输入的命令。父进程(Shell)则等待子进程执行完成后,再继续处理其他用户命令。

进程退出和 status

子进程结束时,通过 exit() 将退出状态传递给父进程。父进程通过 wait() 获取子进程的退出状态:

  • exit(status):子进程调用 exit(),并将状态码 status 传递给操作系统。
  • wait():父进程通过 wait() 获取子进程的退出状态,并可以对其进行处理。例如,status == 0 表示子进程成功结束。

I/O 重定向

I/O 重定向是 UNIX 系统中的一个关键功能,它允许将程序的标准输入、输出重定向到文件或其他程序。redirect.c

  • 标准输入 (stdin):通常是键盘输入,文件描述符为 FD 0
  • 标准输出 (stdout):通常是屏幕输出,文件描述符为 FD 1
  • 标准错误 (stderr):用于输出错误信息,文件描述符为 FD 2

在示例中,copy.c 程序通过 read() 从标准输入读取数据,通过 write() 将数据写入标准输出。这种方式可以轻松地将用户输入通过程序处理,并输出结果到屏幕上。

I/O 重定向与管道机制

UNIX 系统设计哲学之一是“小而美”,即通过小型、专注的程序组合来完成复杂任务。管道(|)和 I/O 重定向是实现这种组合的关键机制。

  • 管道 (|):将一个程序的输出作为另一个程序的输入。例如,cat file.txt | grep "pattern"cat 的输出通过管道传递给 grepgrep 再对其进行处理。
  • I/O 重定向 (>, <):将程序的输出重定向到文件,或从文件读取输入。例如,ls > output.txt 会将 ls 命令的输出保存到 output.txt 文件中。

通过这种方式,多个小程序可以组合起来执行复杂任务,而每个程序本身都只专注于某一特定功能。这种设计不仅提高了程序的复用性,还使得系统更加灵活。

fd 的概念

文件描述符(fd)是操作系统中用于表示打开文件的抽象概念。每个进程在运行时都有一个文件描述符表,记录着当前进程所打开的所有文件。常见的 fd 包括:

  • 0: 标准输入(stdin
  • 1: 标准输出(stdout
  • 2: 标准错误(stderr

在应用程序中,open() 返回的文件描述符用于后续的读写操作。当文件被关闭后,文件描述符也会被释放。

UNIX 系统中的进程管理哲学

UNIX 系统中的进程管理非常简洁,通过简单的系统调用如 fork()exec(),操作系统可以创建和管理多个进程。每个进程都有独立的地址空间,并且可以通过文件描述符与外部系统(如文件、设备、其他进程)进行交互。进程之间的通信则可以通过管道和共享内存等机制实现。

这种设计哲学保证了系统的灵活性和可扩展性,应用程序可以通过简单的调用组合,构建出复杂的功能,而操作系统则负责底层的资源管理。

第二讲 实践与实验介绍

第一节 实践与实验简要分析

操作系统的调试与调试工具

当编写操作系统时,调试过程比在应用程序中复杂得多。因为操作系统运行在底层,缺乏现成的调试工具和环境。通常开发者使用硬件模拟器(如 QEMU)或集成的调试器(如 GDB)来调试内核代码。

  • GDB(GNU 调试器):可以通过模拟器(如 QEMU)调试操作系统。通过 GDB,开发者可以设置断点、单步执行代码以及检查寄存器和内存状态。
  • 物理硬件调试的困难:在真实硬件上调试操作系统更加复杂,因为缺乏模拟环境中的便利工具。这时,通常依赖串口输出、内核日志等简单方式调试代码。

循续渐进的操作系统实验

批处理系统

批处理系统是最早期的操作系统模型之一,其核心目的是提高计算机的利用率,通过在一段时间内执行多个程序。批处理系统的关键点在于:

  • 多程序执行:批处理系统允许多个程序依次执行,但同时内存中通常只有一个程序在运行。虽然这种方式提高了计算效率,但仍有一些局限。
  • 特权级与隔离机制:为了确保批处理系统中的应用程序不会破坏操作系统,现代操作系统使用硬件的特权级机制(Privilege Levels)来实现隔离。应用程序运行在用户态,而操作系统运行在内核态,这种特权级的划分确保了系统的安全性和稳定性。

特权级与系统调用

在批处理系统中,为了实现应用程序与操作系统之间的安全交互,依赖于硬件支持的特权级机制。特权级机制通过不同的权限划分,确保应用程序无法直接访问或破坏操作系统的资源:

  • 特权级切换:通过系统调用、异常或中断机制,应用程序可以请求操作系统执行某些受限操作。在这个过程中,CPU 切换到内核态(特权级更高),以执行操作系统代码。
  • 状态保存与恢复:当从用户态切换到内核态时,操作系统需要保存当前程序的状态(如寄存器内容),以便在任务切换完成后恢复程序的执行。

多道程序设计

多道程序设计(Multiprogramming)是批处理系统的改进,它允许多个程序同时驻留在内存中,以提高 CPU 利用率和系统响应速度。多道程序设计的关键挑战是:

  • 内存共享:多个程序共享一个内存空间,操作系统必须确保每个程序只能访问自己的内存区域,防止相互干扰。
  • 内存地址空间问题:在多道程序设计中,程序必须知道自己在内存中的起始地址,这导致程序编写复杂。随着虚拟内存技术的发展,这个问题得到了有效解决。

分时多任务系统

分时多任务操作系统的最大特点是支持多个程序的并发执行,并且能够通过抢占式调度提高系统的交互性和响应速度:

  • 抢占式调度:当一个程序占用 CPU 时间过长,操作系统可以强制中断其执行,切换到另一个程序运行。这避免了某个应用程序垄断 CPU,保证了系统的公平性和响应速度。
  • 上下文切换:当系统从一个程序切换到另一个程序时,必须保存当前程序的状态,并恢复目标程序的状态。这个过程称为上下文切换,它涉及到保存和恢复 CPU 的寄存器内容、堆栈信息等。

地址空间与虚拟内存

地址空间是操作系统为每个程序分配的内存区域,它可以是物理内存的一部分或通过虚拟内存技术实现。虚拟内存通过将物理内存抽象为连续的虚拟地址空间,简化了程序编写和内存管理:

  • 虚拟内存的优势:虚拟内存使得每个程序都可以使用从地址 0 开始的虚拟地址空间,而无需关心物理内存的实际布局。操作系统通过页表机制将虚拟地址映射到物理地址。
  • 隔离性与安全性:通过虚拟内存,操作系统可以为每个程序分配独立的地址空间,从而防止程序相互干扰。每个程序只能访问自己的内存区域,增强了系统的安全性和稳定性。

内存管理与虚拟存储

内存管理是操作系统中的一个关键任务,它包括:

  • 静态和动态内存分配:静态内存分配在编译时完成,而动态内存分配则在程序运行时根据需求动态分配内存。
  • 分页与页表:操作系统通过分页机制管理内存,页表用于维护虚拟地址与物理地址的映射关系。页表的高效管理对系统性能至关重要。
  • TLB(Translation Lookaside Buffer):TLB 是一种缓存机制,用于加速虚拟地址到物理地址的转换。操作系统必须管理好 TLB 以确保内存访问的效率。
  • 虚拟存储:虚拟存储允许应用程序申请比物理内存更大的地址空间,通过将不常用的数据存储在硬盘上。当需要时,操作系统将数据从磁盘加载到内存。这种机制大大提升了系统的灵活性。 例如,常用的数据存放在内存中,不常用的数据则存储在硬盘上。通过置换算法(如 LRU, Least Recently Used),操作系统决定哪些数据留在内存中,哪些数据需要被交换到硬盘上。这种机制优化了内存的利用效率,减少了数据交换的频率,提高了系统性能。

进程管理与进程调度

进程是操作系统管理程序执行的基本单位,操作系统不仅负责进程的创建和销毁,还需要对进程的执行进行有效的调度:

  • 进程抽象:进程是操作系统用来管理程序执行的抽象。操作系统为每个进程分配资源(如内存、CPU 时间等)并对进程进行调度。
  • 进程的创建和销毁:通过 fork() 创建新进程,使用 exit() 销毁进程。子进程可以创建新的子进程,形成进程树。
  • 进程调度:操作系统使用不同的调度算法(如先来先服务,短作业优先,时间片轮转等)来决定哪个进程获得 CPU 资源。对于单处理器系统,进程调度是一个关键问题,而在多处理器系统中,调度变得更加复杂。

多处理器调度

随着多核处理器的普及,操作系统必须考虑如何在多个处理器上有效调度任务,以提高系统的并行性能:

  • 多处理器调度:操作系统需要管理多个处理器或处理器核的调度,确保任务能够有效地分配到不同的核上执行。不同的任务可以同时运行在不同的处理器上,从而提高系统的并行度和效率。
  • 并行编程:在多核系统中,应用程序可以使用并行编程技术,充分利用多核处理器的优势。OS 需要支持多线程和进程的并行调度,并处理线程之间的同步与通信问题。

文件系统与文件抽象

文件系统是操作系统用于管理持久存储数据的核心模块,提供了一种层次化的存储结构:

  • 文件系统的实现:操作系统通过文件系统来组织和管理磁盘上的数据,文件系统的核心功能包括文件的创建、删除、读取、写入等操作,以及目录结构的管理。
  • 文件抽象:在操作系统中,文件不仅代表磁盘上的数据块,还可以代表其他资源,如管道、设备等。文件描述符(FD)是操作系统提供的一种抽象,允许程序以统一的方式操作各种资源。
  • 虚拟文件系统:通过文件系统的抽象,操作系统能够将不同类型的资源(如网络连接、进程间通信)都视为文件,统一接口,简化了程序员对资源的操作。

进程间通信(IPC)

进程间通信(IPC)是操作系统提供的一系列机制,允许不同进程之间交换数据或协调工作:

  • 管道(Pipe):管道是最简单的进程间通信机制,允许一个进程的输出作为另一个进程的输入。
  • 消息队列、共享内存:消息队列允许进程发送和接收结构化的数据,而共享内存则允许进程共享同一个内存空间,从而实现高效的通信。
  • 信号量与锁:用于处理进程或线程之间的同步问题,确保多个进程或线程在访问共享资源时不会产生冲突。

线程与同步机制

线程是进程的子集,允许进程内部并行执行多个任务。线程共享进程的地址空间,这带来了高效的资源使用,但也引发了并发控制的问题:

  • 线程的抽象:一个进程可以包含多个线程,多个线程可以并行执行并共享进程的资源(如内存、文件描述符等)。线程的切换比进程切换开销小,适合并发任务。
  • 线程同步与互斥:由于线程共享地址空间,需要通过同步机制(如锁、信号量、条件变量等)来控制多个线程对共享数据的访问,防止竞争条件(race condition)和数据不一致问题。

协程与并发模型

  • 协程(Coroutine):协程是比线程更轻量级的并发单位,通常不依赖于操作系统的线程调度,而是在应用层通过特定的协程库管理。协程的切换开销比线程更小,适合处理大量 I/O 密集型任务。
  • 协程与线程的关系:协程通常运行在线程之上,一个线程可以包含多个协程。协程的切换在用户态完成,而不需要操作系统的介入,因此效率更高。

进程、线程与协程的区别与联系

  • 进程:进程是操作系统分配资源的基本单位,一个进程拥有独立的内存空间,彼此隔离。进程切换时,操作系统需要保存并恢复其上下文,包括寄存器、内存映射等。
  • 线程:线程是进程内的执行单位,多个线程共享同一个进程的资源(如内存和文件描述符)。相比于进程,线程的创建和切换开销较小,因为它们不需要切换整个内存空间。
  • 协程:协程是一种比线程更轻量级的并发单元。协程在用户态调度,不依赖操作系统的线程调度机制,因此切换开销比线程更小。多个协程可以在同一个线程内执行,适合处理 I/O 密集型任务。

随着粒度逐渐减小,进程、线程、协程的管理开销依次降低。协程的优势在于它们的调度是用户态完成的,因此对性能的影响较小,更适合处理大量并发任务。

并发问题与死锁

并发编程中,多个控制流(如线程或协程)可能同时访问共享资源,导致数据竞争、死锁等问题。以下是关键概念:

  • 同步与互斥:为了防止多个线程或进程同时访问共享资源,操作系统提供了同步和互斥机制。常见的同步机制包括锁(Lock)、信号量(Semaphore)和条件变量(Condition Variable)。这些机制确保在访问共享资源时,多个线程能够按顺序进行,不会产生冲突。
  • 死锁(Deadlock):死锁是一种特殊的并发问题,指多个进程或线程因相互等待资源而进入永久阻塞状态。解决死锁问题的方法包括资源分配图、死锁检测算法、避免循环等待等。

设备管理与 I/O 操作

设备管理是操作系统的重要任务之一,尤其是在现代操作系统中,外设的种类繁多(如网络设备、显示设备、存储设备等)。设备的管理和交互涉及多个层次:

  • 设备交互模型:操作系统通过轮询、中断和 DMA(直接内存访问)与外设进行交互。中断是一种高效的机制,允许设备在完成任务时通知 CPU,从而避免 CPU 的忙等待。
  • 同步与异步 I/O:I/O 操作可以是同步的或异步的。在同步 I/O 中,程序必须等待 I/O 操作完成后才能继续执行;而在异步 I/O 中,程序在发出 I/O 请求后无需等待操作完成,而是可以继续执行,等到 I/O 操作完成时再处理结果。

课程实验设计

为了帮助学生深入理解操作系统的原理与实现,课程安排了五个实验,每个实验都涵盖了操作系统的不同关键功能:

  • 实验一:任务切换与系统调用。该实验重点是实现任务切换和系统调用,理解操作系统如何在不同的任务间进行切换,以及如何通过系统调用与用户程序交互。
  • 实验二:地址空间管理。该实验涉及虚拟地址空间和异常处理,学生将学习如何实现地址空间的映射、页面置换以及处理内存缺页等问题。
  • 实验三:进程管理与调度。学生将在该实验中实现进程的创建、销毁、调度等功能,理解进程的生命周期管理。
  • 实验四:文件系统。学生将扩展现有的文件系统,增加目录结构等功能,学习如何组织和管理存储设备上的数据。
  • 实验五:同步与并发控制。该实验将引导学生实现线程同步机制,如锁、信号量等,并解决可能出现的并发问题,如死锁。

扩展实验与项目

课程提供了一些扩展实验的机会,学生可以根据兴趣选择扩展项目,深入探索操作系统的某个方面,甚至设计新的特性。扩展实验可能包括:

  • 开发游戏或图形界面:比如在操作系统中实现一个简单的图形化游戏,如贪吃蛇,通过学习显示设备的管理和显存的操作,增强对设备管理的理解。
  • 实现新特性:学生还可以通过扩展操作系统,增加如多线程支持、设备驱动程序、网络协议栈等功能,从而对操作系统的设计与实现有更深入的理解。

本课程通过理论讲解与实践结合,涵盖了操作系统的核心概念与实现技术。从进程、线程、协程到并发控制,从内存管理到文件系统,学生将学习操作系统的设计原理和实现技巧。通过一系列的实验,学生不仅能加深对操作系统各个模块的理解,还能掌握操作系统开发中的实际技能。同时,扩展实验为学生提供了进一步探索和创新的机会。

操作系统作为计算机系统的核心,为应用程序提供基础服务,因此理解其设计与实现是计算机科学中至关重要的技能。通过本课程,学生将具备深入理解操作系统的理论知识,并能够通过实践应用这些知识。

第二节 Compiler与OS

编译器与操作系统的关系

编译器和操作系统是紧密相关的两部分,尽管它们的任务不同:

  • 编译器的任务是将高级编程语言(如C或Rust)编写的源代码转换为可以在特定硬件上运行的机器码。
  • 操作系统的任务是管理计算机资源,并加载和执行编译后的程序。

这两者的关系可以总结为:编译器负责生成可执行的二进制文件,而操作系统负责加载这些文件并让它们在硬件上运行。

编译过程与执行流程

编译过程通常分为以下几个步骤:

  1. 编译(Compilation):将源代码转换为汇编代码(或中间代码)。
  2. 汇编(Assembly):将汇编代码转换为机器码,生成目标文件(Object File)。
  3. 链接(Linking):将多个目标文件链接在一起,生成一个可执行文件(Executable File)。
    • 链接器会处理外部依赖,确保所有的函数调用和变量引用都能被解析。

操作系统需要读取这个可执行文件的结构,然后根据它的格式将相应的代码和数据加载到内存中执行。

交叉编译与工具链

交叉编译(Cross Compilation)是指在一种平台上编译出在另一种平台上运行的程序。例如:

  • 你可以在x86平台上编译一个适用于RISC-V架构的程序。
  • 这需要使用针对目标架构的编译器工具链,比如 RISC-V 的 GCC 工具链。

编译器工具链的基本组成包括:

  • 编译器(Compiler):如 GCC 或 Rust 编译器,负责将源代码转换为目标代码。
  • 汇编器(Assembler):将汇编代码转换为机器码。
  • 链接器(Linker):将多个目标文件链接为一个可执行文件。

可执行文件格式

操作系统必须能够理解编译器生成的可执行文件格式。常见的文件格式有:

  • ELF (Executable and Linkable Format):Linux 等类 Unix 操作系统使用的一种标准可执行文件格式。它包含了程序的代码段、数据段、符号表等信息。
  • PE (Portable Executable):Windows 操作系统使用的可执行文件格式。

以 ELF 为例,操作系统在加载程序时需要理解其内部结构,包括哪些部分是代码,哪些部分是数据,以及程序的入口点在哪里。

加载与执行

当操作系统加载一个可执行文件时,它需要完成以下任务:

  1. 解析可执行文件:操作系统读取文件头,了解文件的结构和内容。
  2. 加载到内存:根据文件的描述,将代码段、数据段等加载到合适的内存区域
  3. 设置程序入口:操作系统将控制权转交给程序的入口地址,使程序开始执行。

这一过程不仅适用于普通的用户程序,操作系统本身也是通过类似的过程启动的。

Bootloader 与操作系统加载

当一台计算机启动时,操作系统本身需要被加载并运行,这个任务通常由 Bootloader 完成。Bootloader 是操作系统加载的第一步:

  • Bootloader 是一个小型的程序,负责将操作系统加载到内存中。它通常被嵌入到计算机的固件或硬盘的引导扇区中。
  • 启动时,计算机硬件首先加载 Bootloader,Bootloader 再将操作系统的内核加载并启动。

这是一个递归的过程,从硬件加载 Bootloader,再从 Bootloader 加载操作系统,最终操作系统加载并运行用户程序。

操作系统的自举问题(Bootstrapping)

操作系统自身也是一个可执行文件,最初也是通过编译器生成的。因为操作系统需要管理整个系统的资源,操作系统的启动过程尤为复杂。通常操作系统的启动依赖于 Bootloader 完成初始加载工作。

Bootloader 本质上也是一个编译后的程序,它的任务是将操作系统内核从存储设备加载到内存,并启动内核。

编译器与操作系统之间的协议

操作系统和编译器之间有一些隐式的协议,这些协议规定了如何生成可以由操作系统正确加载和执行的程序。这些协议包括:

  • 文件格式协议:如 ELF 或 PE 格式,规定了可执行文件的结构。
  • 系统调用协议:编译器生成的代码可能需要调用操作系统提供的服务(例如 I/O 操作、进程管理等),这通过系统调用接口实现。

ELF 文件格式简介

ELF(Executable and Linkable Format)是一种常见的可执行文件格式,广泛用于 Linux 和其他类 Unix 操作系统。它的作用是定义了程序的结构,包括代码段、数据段和文件头信息,便于操作系统识别和加载。ELF 文件格式将程序的不同部分(如代码和数据)分割成不同的段,并提供了这些段的位置信息,让操作系统能够将它们正确加载到内存中。

ELF 文件的基本结构

  • 文件头:描述整个文件的布局,包含元数据信息,如程序入口点、段表的偏移等。
  • 段(Sections):包括代码段、数据段等,分别保存程序的指令和数据。
  • 符号表(Symbol Table):用于解析全局变量和函数调用的符号信息。

编译器与生成 ELF 格式

编译器在处理 CRust 代码时,会通过编译(Compile)、汇编(Assemble)和链接(Link)步骤,将源代码转换为 ELF 格式的可执行文件。

  • 编译:将源代码转换为中间汇编代码。
  • 汇编:将汇编代码转换为机器码,生成目标文件(Object File)。
  • 链接:将多个目标文件链接成一个完整的可执行文件,形成 ELF 格式的程序。

RustC 编译中,编译器会生成 ELF 文件,通过标准工具链(如 GCCLLVM 等)完成这些步骤。

裸机程序(Bare-metal programming)

裸机编程是在没有操作系统支持的环境下,直接与硬件交互的编程方式。因为没有操作系统,程序必须直接处理硬件资源和异常。因此,裸机编程的代码通常非常简单,主要关注初始化硬件和运行关键功能。

在使用 RustC 语言进行裸机编程时,程序不依赖于任何操作系统提供的库(如标准库 libcRust 的标准库 std)。这使得程序需要更直接地与硬件交互,并且通常需要提供特殊的错误处理机制。

Rust 裸机程序中的 Panic 处理

Rust 是一种相对新兴的系统编程语言,提供了强大的内存安全和并发支持。在裸机编程中,Rust 语言由于其严格的安全检查机制,要求程序必须定义一些关键的处理方法,比如当程序崩溃时的 panic 处理。

#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}
}

Panic handler 是 Rust 程序中的一个强制性部分,它用于定义当程序出现未处理的错误时应该执行的操作。在上例裸机编程中,这个函数会进入一个死循环,防止程序崩溃时不受控地运行。

Bare-metal 程序的构建

为了构建裸机程序,无论是 C 还是 Rust,都需要确保以下几点:

  • 不依赖标准库:裸机程序不能使用诸如 std 这样的标准库,因为这些库依赖操作系统。
  • 自定义入口点:裸机程序需要自行定义程序的入口点,而不是使用操作系统提供的默认入口。
  • 硬件初始化:程序需要自行初始化硬件,如设置堆栈指针、配置中断等。

Rust 中,构建裸机程序时可以通过禁用标准库并启用核心库 core

#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
}
  • no_std:禁用标准库。
  • no_main:禁用 Rust 默认的 main 函数。

如何加载和执行裸机程序

操作系统通过解析 ELF 文件格式,将可执行程序加载到内存中并开始执行。裸机程序的加载过程不同于一般应用程序,因为它没有操作系统的帮助。以下是两种典型的加载方式:

  1. 通过 Bootloader 加载

    • 裸机程序通常通过一个简化的引导加载程序(Bootloader)来启动。Bootloader 是嵌入在硬件中的一段代码,负责加载操作系统或裸机程序到内存中并运行。
  2. 直接加载到内存

    • 在一些情况下(如嵌入式系统开发),程序可能被直接加载到特定的内存位置,并且 CPU 会从预定的地址开始执行。

Binary Image 格式与 ELF 格式的区别

ELF(Executable and Linkable Format)是现代操作系统中常用的可执行文件格式,它包含了程序代码、数据段和元数据,如程序的入口点、各段的位置等,方便操作系统加载和管理程序。然而,在某些情况下,ELF 格式解析可能过于复杂,例如在较为简单的 BOOTLOADER 或嵌入式系统中。

Binary Image 格式 是一种更为简化的可执行文件格式,它去掉了所有的元数据(如 ELF 的头部信息),只保留程序的实际代码段和数据段。这种格式通常用于直接将程序加载到内存的某个特定位置,简化了加载过程。

  • ELF 格式:包含丰富的元数据,适用于复杂的 OS 和应用程序。
  • Binary Image 格式:没有元数据,只包含纯代码和数据,适用于简单的系统或直接内存加载。

BOOTLOADER 的作用

BOOTLOADER 是系统启动时负责加载操作系统的关键组件。在复杂的系统中,BOOTLOADER 可能会解析 ELF 格式,将代码和数据段加载到内存的适当位置。但对于简化的系统,BOOTLOADER 可能无法解析 ELF 格式,只能将 Binary Image 格式的程序加载到指定的内存地址。这种情况下,开发者需要预先约定好程序的加载地址,如 0x80000000,确保 BOOTLOADER 能将程序加载到正确的位置。

步骤

  1. BOOTLOADER 启动,负责从存储设备(如闪存)加载程序。
  2. 根据预先约定的内存地址,将程序的代码和数据段加载到内存中。
  3. 跳转到指定的程序入口地址,开始执行程序。

OS 对应用程序的加载

当 OS 加载应用程序时,通常是通过读取 ELF 文件格式并解析其中的代码段、数据段等部分。解析 ELF 文件头可以帮助 OS 知道哪些段需要加载到内存的哪个位置,程序的入口点在哪里等。

然而,在实验环境或简单系统中,OS 也可以加载 Binary Image 格式的应用程序。这时,OS 不需要解析复杂的 ELF 结构,只需要将二进制代码直接加载到内存的某个位置,然后跳转执行即可。

内存管理——栈 (Stack) 和堆 (Heap)

内存管理是操作系统的核心职责之一。在应用程序运行时,OS 需要为其分配内存空间,特别是栈(用于函数调用和局部变量)和堆(用于动态内存分配)。

  • 栈 (Stack):主要用于管理函数调用和局部变量。编译器生成的代码依赖栈来保存函数的返回地址、局部变量和参数。OS 在加载应用程序时会为栈分配特定的内存空间,并在执行过程中动态管理栈的增长和收缩。

  • 堆 (Heap):堆空间主要用于动态内存分配,应用程序可以通过调用 malloc(在 C 语言中)或其他类似函数申请动态内存。堆的管理通常由操作系统与标准库(如 libc 或 Rust 的 alloc 库)共同完成。

当编写操作系统时,开发者必须手动为 OS 本身管理栈空间,因为 OS 需要为自身以及所有应用程序维护多个栈。在应用程序层面,栈和堆的管理则是通过 OS 提供的机制和库函数完成的。

OS 初始化中的栈管理

操作系统不仅需要为应用程序管理内存,它在自身初始化时也需要分配栈空间,以便执行函数调用和管理局部变量。通常情况下,OS 会在启动时自行选择一块内存作为栈区域。OS 初始化时的栈管理与应用程序类似,但由于 OS 本身需要管理整个系统的内存,它可以直接访问并配置物理内存中的栈区域。

栈与堆的区别

栈是为函数调用准备的,具有固定的大小和生命周期,通常由编译器生成代码管理;堆则用于动态分配内存,大小和生命周期不固定,由程序员通过函数(如 mallocfree)控制。栈的内存管理效率较高,但灵活性较低;堆的灵活性高,但管理复杂,容易出现内存泄漏。

编译器与 OS 的协作

操作系统和编译器在处理程序时有紧密的合作关系。编译器负责生成符合 ELF 或 Binary Image 格式的可执行文件,OS 负责将这些程序加载到内存并执行。为了实现这种协作,编译器和 OS 之间必须有一致的约定,如内存地址的分配、栈的初始化等。

编译器生成的代码不仅包含程序的逻辑,还包含了 OS 如何调用和管理它的指令。OS 则通过系统调用接口和底层硬件管理程序的执行。

第三节 硬件启动与软件启动

QEMU 模拟器与虚拟机

QEMU 是一个开源的虚拟化工具,能够模拟各种硬件平台。在这次课程中,使用 QEMU 模拟 RISC-V 架构的处理器和系统,包括虚拟的 CPU、内存、存储设备和串口等基本硬件。

  • QEMU的模拟配置:典型的配置包括 128 MB 的内存,基础的存储和串口设备,并且可以通过命令行参数 -nographic 禁用图形界面,使得所有输出都通过命令行窗口显示。

操作系统启动流程

操作系统的启动需要经过多个阶段,主要包括:

  1. 硬件启动:在模拟的环境中,QEMU 会启动并首先执行一段固化在 ROM 中的代码。这个代码一般放在固定的内存地址,称为固件代码(如 0x1000 处)。
  2. BOOTLOADER 加载:固件代码会启动 BOOTLOADER(启动加载器),它负责加载操作系统的核心部分。BOOTLOADER 通常位于某个指定的内存地址(如 0x80000000)。
  3. 操作系统加载:BOOTLOADER 将操作系统的核心代码加载到内存并跳转到相应的入口点开始执行操作系统的初始化过程。
  4. OS 初始化:操作系统完成一系列初始化任务后,最终启动应用程序(如 init 进程)。

BIOS 和 BOOTLOADER 的作用

  • BIOS(基本输入输出系统):在这里,BIOS 是一段固化在内存中的代码,它的主要任务是初始化硬件,并启动 BOOTLOADER。
  • BOOTLOADER:负责加载操作系统到内存。它从 ROM 读取硬件信息,包括 CPU 和外设配置,并将操作系统的核心部分加载到指定的内存地址。

操作系统的内存布局

QEMU 模拟的虚拟机中,内存布局相对简单。ROM 固件一般位于较低的地址(如 0x1000),而操作系统被加载到 0x80000000 的地址。BIOS 的功能是识别系统的外设、处理器信息,并将这些信息传递给 BOOTLOADER。

RISC-V 汇编代码分析

在固件中,有一段 RISC-V 汇编代码,它的功能是将控制权交给 BOOTLOADER。这段代码的关键部分包括:

  • 初始化寄存器:通过 RISC-V 指令 AUIPCADD 等指令来设置跳转目标地址,最终跳转到 BOOTLOADER 的入口。
  • 传递参数:通过寄存器 A0 和 A1 传递两个关键参数,A0 表示当前的 CPU 核心 ID,A1 则是系统的外设信息。

这段汇编代码的执行逻辑较为简单,主要是完成对 BOOTLOADER 的跳转和基础参数传递。