通过跟踪hello
程序的生命周期来对系统进行学习
$ vim hello.c
#include <stdio.h>
int main(void)
{
printf("hello, world!\n");
return 0;
}
信息就是“位+上下文”
hello
程序的生命周期是从一个源文件开始,也就是程序员利用编辑器创建并保存的文本文件,源程序实际是由值0和1组成的位序列,8位(bit)组成一组称为一个字节(byte),每个字节表示程序中某个文本字符。
大部分现代系统都使用ASCII标准来表示字符,也就是用一个唯一的单字节大小的整数值来表示每个字符。
hello.c的ASCII文本表示hello.c
程序以字节序列的方式存储在文件中,每个字节都有一个整数值,而该整数值对应于某个字符。
例如:
- 第1个字节:整数值是35,对应的字符是
#
。 - 第2个字节:整数值是105,对应的字符是
i
。
...
注意:每个文本上都是以一个不可见的换行符\n
来结束的,它所对应的整数值为10。
类似hello.c
这样只由ASCII字符构成的文件成为文本文件,所有其他文件都称为二进制文件。
hello.c
的表示方法说明了一个基本思想:
系统中所有的信息,包括磁盘文件、存储器中的程序、存储器中存放的用户数据、网络上传输的数据等等,都是由一串位表示的。区分不同数据对象的唯一方法是读取数据对象时的上下文。在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或是机器指令。
程序被其他程序翻译成不同的格式
hello
程序的生命周期是从一个高级语言C程序开始的,因为这种形式能够被人读懂。然后,为了在系统中运行hello
程序,每条C语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来,目标程序也称为可执行目标文件。
在UNIX系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:
$ gcc -o hello hello.c
GCC编译器驱动程序读取源文件hello.c
,并将它翻译为一个可执行目标文件hello
。翻译的过程可分为四个阶段,执行这四个阶段的程序分别为:预处理器、编译器、汇编器、链接器,四者一起构成了编译系统(compilation system)。
- 预处理阶段
预处理器cpp
根据字符#
开头的命令,修改原始的C程序,如hello.c
中的#include <stdio.h>
命令告诉预处理器去读取系统头文件stdio.h
的内容,并将它直接插入到程序文本中。结果就得到另外一个C程序,通常是以.i
作为文件扩展名。
- 编译阶段
编译器ccl
将文本文件hello.i
翻译成文本文件hello.s
,它包含一个汇编语言程序。
汇编语言程序中的每条语句都以一种标准的文本格式确切地描述一条低级机器语言指令。汇编语言为不同高级语言的不同编译器提供了通用的输出语言。例如C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言。
- 汇编阶段
汇编器as
将hello.s
翻译成机器语言指令,把这写指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o
中。hello.o
文件是一个二进制文件,它的字节编码是机器语言指令而不是字符。如果在文本编辑器中打开hello.o
文件将会看到一堆乱码。
- 链接阶段
hello
程序调用了printf
函数,printf
函数是每个C编译器都会提供的标准C库中的一个函数。printf
函数存在于一个名为printf.o
单独的预编译好的目标文件中,这个文件必须以某种方式合并到hello.o
程序中。
链接器ld
负责处理合并,结果就会得到hello
文件,hello
文件是一个可执行目标文件(可执行文件),可以被加载到内存中由系统执行。
处理器读取并解释存储在存储器中的指令
hello.c
源程序被编译系统翻译成可执行目标文件hello
,并存放在磁盘上。要在UNIX系统中运行可执行目标文件,可以将文件名输入到shell
的应用程序中:
$ ./hello
shell
是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令行。如果命令行的第一个单词不是内置的shell
命令,shell
会假设这是一个可执行文件的名字,会将它加载并运行这个文件。
系统的硬件组成
为了理解运行hello
程序时发生了什么,需要了解一个典型系统的硬件组织。
备注
- CPU:中央处理器
- ALU:算逻单元(算术/逻辑单元)
- PC:程序计数器
- USB:通用串行总线
- 总线
总线是贯穿整个系统的一组电子管道,它携带信息字节并负责在各个部件间传递。总线被设计成传送定长的字节块,也就是字(word)。字中的字节数即字长是一个基本系统参数,在各个系统中情况各不相同。
现代大多数机器字长是4字节32位、8字节64位,为了方便理解假设字长为4个字长并且每次只传送1个字。
- I/O设备
输入/输出(I/O)设备时系统与外界联系的通道,示例中包含4个I/O设备:作为用户输入的键盘和鼠标、作为用户输出的显示器、用于长期存储数据和程序的磁盘驱动器。
每个I/O设备都通过一个控制器或适配器与I/O总线相连,控制器和适配器之间的区别主要在于它们的封装方式。控制器是置于I/O设备本身的或是系统的主印刷电路板(主板)上的芯片组,而适配器则是一块插在主板插槽上的卡。它们都功能都是在I/O总线和I/O设备之间传递信息。
- 主存
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。从物理上来说,主存是由一组动态随机存取储存器(DRAM)芯片组成。从逻辑上来说,存储器是一个线性的字节数组,每个字节都由唯一的地址(数组索引),这些地址是从0开始的。
一般来说,组成程序的每条机器指令都由不同数量的字节构成。与C程序变量相对应的数据项的大小是根据类型变化的。例如,在运行Linux的IA32机器上,short
类型的数据需要2个字节,int
、float
、long
类型需要4个字节,而double
类型需要8个字节。
- 处理器
中央处理器(CPU)简称处理器,是解释或执行存储在主存中指令的引擎,处理器的核心是一个字长的存储设备或寄存器称为程序计数器(PC)。在任何时刻PC都指向主存中的某条机器语言指令,即含有该条指令的地址。
从系统通电开始直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。处理器看上去是按照一个非常简单的指令执行模型来操作的,这个模型是由指令集结构决定的。在这个模型中,指令按照严格的顺序执行,而执行一条指令包含执行一系列的步骤。
处理器从程序计数器(PC)指向的存储器处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定与存储器中刚刚执行的指令相邻。
这样的简单操作并不多,而且操作时围绕着主存、寄存器文件(register file)、算术\逻辑单元(ALU)进行的。寄存器文件是一个小的存储设备,由一些1字节长的寄存器组成,每个寄存器都由唯一的名字。ALU计算新的数据和地址值。
一些简单操作中CPU在指令的要求下可能会执行以下操作
- 加载:把一个字节或一个字从主存复制到寄存器,以覆盖寄存器原来的内容。
- 存储:把一个字节或一个字从寄存器复制到主存的某个位置,以覆盖这个位置上原来的内容。
- 操作:把两个寄存器的内容复制到ALU,ALU对这两个字做算术操作,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。
- 跳转:从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC中原来的值。
处理器看上去只是它的指令集结构的简单实现,但是实际上现代处理器使用了非常复杂的机制来加速程序的执行。因此,可以区分处理器为指令集结构和微体系结构:指令集结构描述的是每条机器代码指令的效果,微体系结构描述的是处理器实际上是如何实现的。
运行hello程序
从键盘上读取hello命令初始时,shell
执行它的指令,等待输入命令。当在键盘上输入./hello
字符串后,shell
将字符逐一读入寄存器,再将其存放到存储器中。当在键盘上敲回车键时,shell
就知道已经结束了命令的输入,然后shell
执行一系列指令来加载可执行的hello
文件,将hello
目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串hello, world\n
。
可利用直接存储器存取(DMA)的技术,使数据可以不通过处理器而直接从磁盘到达主存。
从磁盘加载可执行文件到主存一旦目标文件hello
中的代码和数据被加载到主存,处理器就开始执行hello
程序的main
程序中的机器语言指令。这些指令将hello,world\n
字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。
高速缓存至关重要
系统花费大量时间把信息从一个地方挪到另一个地方:hello
程序的机器指令最初是存放在磁盘上的,当程序加载时它们被复制到主存,当处理器运行程序时,指令又从主存复制到处理器。类似的,数据串hello, world\n
初始时在磁盘上,然后复制到主存,最后从主存上复制到显示设备。从程序的角度来看,这些复制就是开销,减缓了程序真正的工作。因此,系统设计者的一个主要目标就是使这些复制操作尽可能快的完成。
根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于同类的低速设备。例如,典型系统上的磁盘驱动器可能比主存大1000倍,但对处理器而言,从磁盘驱动器读取一个字的时间开销要比从主存中读取的开销大1000w倍。
类似的,典型的寄存器文件只存储几百字节的信息,而主存可存放几十亿字节。然而,处理器从寄存器文件中读取数据的速度比从主存中读取几乎要快100倍。更麻烦的是,随着半导体技术的进步,这种处理器与主存之间的差距还在持续增大。加快处理器的运行速度比加快主存的运行速度要容易和便宜的多。
针对处理器与主存之间的差异,系统设计者采用更小、更快的存储设备,即高速缓存存储器(高速缓存)作为暂时集结区域,用来存放处理器近期可能会需要的信息。
高速缓存存储器位于处理器芯片上的L1高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。一个容量为数十万到数百万字节的更大的L2高速缓存通过一条特殊的总线连接到处理器。进程访问L2高速缓存的时间要比访问L1高速缓存的时间长5倍,但仍然比访问主存的时间快5到10倍。L1和L2高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的。比较新的、处理能力更强大的系统甚至有三级高速缓存:L1、L2、L3。系统可以获得一个很大的存储器,同时访问速度也很快,原因是利用高速缓存的局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能经常访问的数据的方法,大部分存储器操作都能在快速的高速缓存中完成。
存储设备形成层次结构
在处理器和一个又大又慢的设备如主存之间插入一个更小更快的存储设备如高速缓存的想法已经称为一个普遍的观念,实际上每个计算机系统的存储设备都被组织成一个存储层次结构。
存储器层次结构在存储设备的层次结构中,从上到下设备访问速度变得越来越慢、容量越来越大,每个字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶端,也就是第0级L0
。
存储器层次结构的主要思想是高一层上的存储器作为低一层存储器的高速缓存,因此,寄存器文件就是L1的高速缓存,L1是L2的高速缓存,L2是L3的高速缓存,L3是主存的高速缓存,主存是磁盘的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上数据的高速缓存。
操作系统管理硬件
当shell
加载并运行hello
程序后hello
程序输出自己的消息时,shell
和hello
程序都没有直接访问键盘、显示器、磁盘、主存。取而代之的是,它们依靠操作系统提供的服务。可以把操作系统看成是应用程序和硬件之间插入的一层软件。所有应用程序对硬件的操作尝试都必须通过操作系统。
操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂而大相径庭的低级硬件设备。
操作系统通过基本的抽象概念:进程、虚拟存储器、文件来实现这两个功能。
- 文件是对I/O设备的抽象表示
- 虚拟存储器是对主存和磁盘I/O设备的抽象表示
- 进程是对处理器、主存、I/O设备的抽象表示
进程
hello
程序在现代系统上运行时,操纵系统会提供一种假象,就好像系统上只有这个程序在运行,看上去只有这个程序在使用处理器、储存、I/O设备。处理器看上去就像在不间断地一条接着一条地执行程序中的指令,即该程序的代码和数据是系统存储器中唯一的对象。这些假象是通过进程的概念来实现的,进程是计算机科学中最重要和最成功的概念之一。
进程是操作系统对一个正在运行的程序的一种抽象,在一个系统上可以同时运行多个进程,每个进程都好像是独占地使用硬件。而并发运行则是说一个进程的指令和另一个进程的指令是交错执行的。
在大多数系统中,需要运行的进程数量是多于可以运行它们的CPU个数的,传统系统在一个时刻只能执行一个程序,而先进的多核处理器同时能够执行多个程序。无论是在单核还是多核系统中,一个CPU看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。
操作系统保持跟踪进程运行所需的所有状态信息,这种状态就是上下文,它包含了需要信息。例如PC和寄存器文件的当前值以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,也就是保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从上次停止的地方开始。
进程的上下文切换场景中有两个并发的进程:shell
进程和hello
进程。最初,只有shell
进程在运行,即等待命令行上的输入。当让它运行hello
程序时,shell
通过调用一个专门的函数,即系统调用来执行我们的请求,系统调用会将控制权传递给操作系统。操作系统保存shell
进程的上下文,并创建一个新的hello
进程以及上下文,然后将控制权传递给新的hello
进程。hello
进程终止后,操作系统恢复shell
进程的上下文,并将控制权传回给它,shell
进程将继续等待下一个命令行输入。
网友评论