计算机系统漫游

从开机说起


当我们按下计算机的电源开关时候,计算机首先会自动从主板的BIOS(基本输入输出系统)读取其中存储的程序。

它引导各个组件如内存、显卡开始运行,允许你从软盘、光盘或者硬盘中选择一个来启动计算机,之后将控制权移交给正常启动的主操作系统。

首先BIOS将从你所选择的存储设备中读取起始的512bytes(比如光盘一开始的512 bytes,如果我们从光盘启动的话)。这512 bytes叫做主引导记录MBR (master boot record)。MBR会告诉电脑从该设备的某一个分区(partition)来装载引导加载程序(boot loader)。Boot loader储存有操作系统(OS)的相关信息,比如操作系统名称,操作系统内核 (kernel)所在位置等。常用的boot loader有GRUBLILO

随后,boot loader会帮助我们加载kernel。kernel实际上是一个用来操作计算机的程序,它是计算机操作系统的内核,主要的任务是管理计算机的硬件资源,充当软件和硬件的接口。操作系统上的任何操作都要通过kernel传达给硬件。Windows和Linux各自有自己kernel。狭义的操作系统就是指kernel,广义的操作系统包括kernel以及kernel之上的各种应用。

实际上,我们可以在多个分区安装boot loader,每个boot loader对应不同的操作系统,在读取MBR的时候选择我们想要启动的boot loader。这就是多操作系统的原理。

如果我们加载的是Linux kernel,Linux kernel开始工作。kernel会首先预留自己运行所需的内存空间,然后通过驱动程序(driver)检测计算机硬件。这样,操作系统就可以知道自己有哪些硬件可用。随后,kernel会启动一个init进程。它是Linux系统中的1号进程。到此,kernel就完成了在计算机启动阶段的工作,交接给init来管理。

随后,init会运行一系列的初始脚本(startup scripts),这些脚本是Linux中常见的shell scripts。

这些脚本执行如下功能:设置计算机名称,时区,检测文件系统,挂载硬盘,清空临时文件,设置网络等等。

当运行完这些初始脚本,操作系统已经完全准备好了,就等待用户进行登录操作。

小结

电源 -> BIOS -> MBR -> boot loader -> kernel -> init process -> login

追踪HelloWorld程序


好了,既然计算机已经打开了,我们就让它做点事情。

我们将通过追踪一个HelloWorld程序的生命周期来漫游计算机系统——从它被程序员创建,到系统上运行,以及输出简单的消息,然后终止。

下面是一个经典的HelloWorld程序:

1
2
3
4
5
6
7
//hello.c
#include <stdio.h>
int main()
{
printf("Hello World\n");
}

信息=位+上下文


hello程序的生命周期是从一个源文件开始的,就是程序员利用编辑器创建并保存的文本文件,文件名字为hello.c。源程序实际上就是一个由值0和1组成的位序列,每8个位被组织成一组,称为字节。每个字节表示程序中的某一个字符。

大部分现代系统都使用ASCII标准来表示文本字符,这种方式实际上是用一个唯一的单字节大小的整数值(注意这个整数值其实就是由0和1的编码表示)来表示每个字符。

下面就是hello.c程序的ASCII码表示。



像hello.c这样只由ASCII字符构成的文件称为文本文件,所有其他文件称为二进制文件

hello.c的表示方法说明了一个基本的思想:

计算机系统中所有信息——包括磁盘文件、存储器中的程序、存储器中存放的数据以及网络上传送的数据,都是由一串位来表示的。

区分不同的数据对象的唯一方法是我们读到这些数据对象的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令

预处理、编译、汇编和链接


为了可以在计算机上面运行hello.c程序,每一条C语句都必须被程序转换成一系列的低级机器语言指令

然后这些指令按照一种称为可执行目标程序的格式打包好,并以二进制磁盘的文件的形式存放起来。目标文件也称为可执行文件

在Linux上可以通过GCC编译器经过四个阶段得到可执行文件:



1.预处理:处理#include,读取头文件stdio.h内容并将其插入到hello.c中,这样得到另一个程序hello.i。
gcc -E hello.c -> hello.i
2.编译:将hello.i翻译成汇编语言程序hello.s。
gcc -S hello.i -> hello.s
3.汇编:将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件的格式,它的字节编码是机器语言指令而不是字符,所以打开hello.o将得到一堆乱码。
gcc -c hello.s -> hello.o
4.链接:hello程序调用了printf函数,printf函数存在于一个名字为printf.o的单独编译好的目标文件中,我们的程序必须将它合并到hello.o中,得到最终的可执行目标文件hello,它可以被加载到内存中,由系统执行。
gcc -o hello.o -> hello

运行Hello World程序


在得到的hello可执行文件同一目录下,我们在键盘中输入字符串./hello,shell程序将字符逐一读入寄存器,再把它放到存储器中,如下所示:



当我们在键盘上面敲下回车时,shell程序就会知道我们已经结束了命令的输入,然后shell程序执行一系列的指令来加载hello可执行文件,并将hello中的代码和数据从磁盘复制到主存中

一旦目标文件hello中的代码和数据被加载到内存,处理器就开始执行hello程序的main程序中的机器语言指令。

这些指令将Hello World\n字符串中的字节从主存复制到寄存器文件,再从寄存器文件复制到显示设备,最终显示在屏幕上。步骤如下:






高速缓存

上面的展示揭露了一个重要的问题,即系统花费了大量的时间把信息从一个地方挪到另外一个地方。

hello程序的机器指令从磁盘复制到主存,又从主存复制到处理器。而数据串Hello World\n则从磁盘复制到主存,又从主存复制到显示设备。

为了协调CPU和主存之间的差异和利用计算机的局部性原理,系统采用了更小更快的存储设备,即高速缓存存储器。如下所示:

主要思想是高一层的存储器作为低一层存储器的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统磁盘的数据的高速缓存

操作系统提供的抽象


抽象是计算机科学中最为重要的概念之一。

操作系统为我们提供了几个重要的抽象:

  1. 文件:文件是对IO设备的抽象。
  2. 进程:对处理器、主存和IO设备的抽象。进程是程序的一次执行
  3. 虚拟存储器:对主存和磁盘IO设备的抽象。
  4. 虚拟机:提供了对这个计算机(包括操作系统、处理器和程序)的抽象。

进程

进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上面可以同时运行多个进程,而每个进程都好像是在独占地使用硬件。

一个CPU看上去都像在并发执行多个进程,这是由于CPU在进程间快速切换实现的。

操作系统保持追踪进程运行所需要的所有状态的信息。这种状态,也就是上下文,它包括许多信息,例如PC、寄存器信息等。

在任何一个时刻,单处理器系统只能执行一个程序的代码。

当操作系统决定要把控制权从当前进程转移到某一个新的进程的时候,就会发生上下文切换,即保存当前进程的上下文,恢复新进程的上下文,然后把控制权传递给新进程。

我们的shell程序作为A进程以及hello程序作为B进程,上下文切换如下所示:

通常内核中会使用进程表来保存进程的上下文。

进程表中的内容如下:

  1. 进程管理相关:PC、寄存器、程序状态字、进程状态、优先级、调度参数、pid、ppid、pgid、信号、信号屏蔽字、CPU时间、启动时间等;
  2. 内存管理相关:指向正文段的指针、指向数据段的指针、指向堆栈段的指针
  3. 文件管理相关:根目录、工作目录、文件描述符表、用户ID、组ID

虚拟存储器

在早期的计算机中,程序是直接运行在物理内存上的。换句话说,就是程序在运行的过程中访问的都是物理地址。

如果这个系统只运行一个程序,那么只要这个程序所需的内存不要超过该机器的物理内存就不会出现问题,我们也就不需要考虑内存管理这个麻烦事了,反正就一个程序,就这么点内存,用不用完那是程序自身的事情。

然而现在的系统都是支持多任务,多进程的,这样CPU以及其他硬件的利用率会更高,这个时候我们就要考虑到将系统内有限的物理内存如何及时有效的分配给多个程序了,这个事情本身我们就称之为内存管理。

下面举一个早期的计算机系统中,内存分配管理的例子,以便于大家理解。

假如我们有三个程序,程序A、B、C。

程序A运行的过程中需要10M内存,程序B运行的过程中需要100M内存,而程序C运行的过程中需要20M内存。

如果系统同时需要运行程序A和B,那么早期的内存管理过程大概是这样的,将物理内存的前10M分配给A, 接下来的10M-110M分配给B。

现在假设我们这个时候想让程序C也运行,同时假设我们系统的内存只有128M,显然按照这种方法程序C由于内存不够是不能够运行的。

大家知道可以使用虚拟内存的技术,内存空间不够的时候可以将程序不需要用到的数据交换到磁盘空间上去,已达到扩展内存空间的目的。

下面我们来看看这种内存管理方式存在的几个比较明显的问题。

  1. 进程地址空间不能隔离
    由于程序直接访问的是物理内存,这个时候程序所使用的内存空间不是隔离的。举个例子,就像上面说的A的地址空间是0-10M这个范围内,但是如果A中有一段代码是操作10M-128M这段地址空间内的数据,那么程序B和程序C就很可能会崩溃。这样很多恶意程序或者是木马程序可以轻而易举的破坏其他的程序,系统的安全性也就得不到保障了,这对用户来说也是不能容忍的。
  2. 内存使用的效率低
    如上面提到的,如果我们要像让程序A、B、C同时运行,那么唯一的方法就是使用虚拟内存技术将一些程序暂时不用的数据写到磁盘上,在需要的时候再从磁盘读回内存。这里程序C要运行,将A交换到磁盘上去显然是不行的,因为程序是需要连续的地址空间的,程序C需要20M的内存,而A只有10M的空间,所以需要将程序B交换到磁盘上去,而B足足有100M,可以看到为了运行程序C我们需要将100M的数据从内存写到磁盘,然后在程序B需要运行的时候再从磁盘读到内存,我们知道IO操作比较耗时,所以这个过程效率将会十分低下。
  3. 程序运行的地址不能确定
    程序每次需要运行时,都需要在内存中分配一块足够大的空闲区域,而问题是这个空闲的位置是不能确定的,这会带来一些重定位的问题,重定位的问题确定就是程序中引用的变量和函数的地址。

那么怎么解决上面这三个问题呢?

有人说过:计算机系统里的任何问题都可以靠引入一个中间层来解决。

现在的内存管理方法就是在程序和物理内存之间引入了虚拟内存这个概念。虚拟内存位于程序和物理内存之间,程序只能看见虚拟内存,再也不能直接访问物理内存。每个程序都有自己独立的进程地址空间,这样就做到了进程隔离。这里的进程地址空间是指虚拟地址。顾名思义既然是虚拟地址,也就是虚的,不是现实存在的地址空间。

既然我们在程序和物理地址空间之间增加了虚拟地址,那么就要解决怎么从虚拟地址映射到物理地址,因为程序最终肯定是运行在物理内存中的,主要有分段分页两种技术。

分段(Segmentation):这种方法是人们最开始使用的一种方法,基本思路是将程序所需要的内存地址空间大小的虚拟空间映射到某个
物理地址空间。

每个程序都有其独立的进程地址空间,可以看到程序A和B的虚拟地址空间都是从0x00000000开始的。

我们将两块大小相同的虚拟地址空间和实际物理地址空间一一映射,即虚拟地址空间中的每个字节对应于实际地址空间中的每个字节,这个映射过程由软件来设置映射的机制,实际的转换由硬件来完成。

这种分段的机制解决了文章一开始提到的3个问题中的进程地址空间隔离和程序地址重定位的问题

程序A和程序B有自己独立的虚拟地址空间,而且该虚拟地址空间被映射到了互相不重叠的物理地址空间,如果程序A访问虚拟地址空间的地址不在0x00000000-0x00A00000这个范围内,那么内核就会拒绝这个请求,所以它解决了隔离地址空间的问题。

程序A只需要关心其虚拟地址空间0x00000000-0x00A00000,而其被映射到哪个物理地址我们无需关心,所以程序永远按照这个虚拟地址空间来放置变量,代码,不需要重新定位。

无论如何分段机制解决了上面两个问题,是一个很大的进步,但是对于内存效率问题仍然无能为力。因为这种内存映射机制仍然是以程序为单位,当内存不足时仍然需要将整个程序交换到磁盘,这样内存使用的效率仍然很低。

那么,怎么才算高效率的内存使用呢?

事实上,根据程序的局部性运行原理,一个程序在运行的过程当中,在某个时间段内,只有一小部分数据会被经常用到。所以我们需要更加小粒度的内存分割和映射方法,另一种将虚拟地址转换为物理地址的方法分页机制应运而生了。

分页:分页机制就是把内存地址空间分为若干个很小的固定大小的页,每一页的大小由内存决定,就像Linux中ext文件系统将磁盘分成若干个Block一样,这样做是分别是为了提高内存和磁盘的利用率。

试想以下,如果将磁盘空间分成N等份,每一份的大小(一个Block)是1M,如果我想存储在磁盘上的文件是1K字节,那么其余的999K字节是不是浪费了。

Linux中一般页的大小是4KB,我们把进程的地址空间按页分割,把常用的数据和代码页装载到内存中,不常用的代码和数据保存在磁盘中,如下图:

我们可以看到进程1和进程2的虚拟地址空间都被映射到了不连续的物理地址空间内。

这个意义很大,如果有一天我们的连续物理地址空间不够,但是不连续的地址空间很多,如果没有这种技术,我们的程序就没有办法运行。

甚至他们共用了一部分物理地址空间,这就是共享内存的概念了。

进程1的虚拟页VP2和VP3被交换到了磁盘中,在程序需要这两页的时候,Linux内核会产生一个缺页异常,然后异常管理程序会将其读到内存中。

分页机制的实现需要硬件的实现,这个硬件名字叫做MMU(Memory Management Unit),他就是专门负责从虚拟地址到物理地址转换的,也就是从虚拟页找到物理页。

这个就是虚拟存储器的具体原理了。

参考


Computer Systems: A Programmer’s Perspective这是一本跨越编译原理、操作系统、计算机体系结构等多个学科的深入了解计算机系统的殿堂级著作。

程序员的自我修养:链接、装载和库程序员内功修炼。