本章是对整本书的概览
每一个c语言程序的生命周期都是由一个以.c为后缀的源文件开始的,每一个这样的源文件实际上都是有0和1组成的位(bit)序列,8个位被组织成一组,称为字节,每个字节表示程序中的某个相应文本字符。
大部分的现代系统都使用ASCII标准来表示文本字符(即ASCII码表),这种方式就是用一个唯一的单字节大小(8 bit,00000000 - 11111111, 即十进制的 0 - 127)的整数值来表示每个字符。
hello.c程序
#include
int main()
{
printf("hello, world\n");
}
hello.c程序的ASCII码表示
# | i | n | c | l | u | d | e | < | s | t | d | i | o | . | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
35 | 106 | 110 | 99 | 108 | 117 | 100 | 101 | 32 | 60 | 115 | 116 | 100 | 105 | 111 | 46 |
h | > | \n | i | n | t | m | a | i | n | ( | ) | \n | { | \n | |
104 | 62 | 10 | 105 | 110 | 116 | 32 | 109 | 97 | 105 | 110 | 40 | 41 | 10 | 123 | 10 |
p | r | i | n | t | f | ( | " | h | a | l | l | ||||
32 | 32 | 32 | 32 | 112 | 114 | 105 | 110 | 116 | 102 | 40 | 34 | 104 | 101 | 108 | 108 |
o | , | w | o | r | l | d | \ | n | " | ) | ; | \n | } | ||
111 | 44 | 32 | 119 | 111 | 114 | 108 | 100 | 92 | 110 | 34 | 41 | 59 | 10 | 125 |
像这种hello.c的源程序以字节序列的方式存储在文件中。每个字节都有一个整数值(在计算机中是以8位二进制形式存在),而该整数值对应于某个字符。注意,每个文本行都是以一个不可兼得换行符“\n”来结束的,它所对应的整数值为10。这种只有ASCII字符构成的文件称为文本文件,所有其他的文件都成为二进制文件。
hello.c的表示方法说明了一个基本思想:系统中所有的信息——包括磁盘文件、存储器中的程序、存储器中存放的用户数据以及网络上传送的数据,都是有一串位表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文,这里上下文的意思是相同的东西在不同的地方表示不同的含义,比如,在不同的上下文中,一个同样的字节序列,既可能表示一个机器指令,也可能表示一个字符串。
最后来看一下本节标题,计算机中存储的所有东西都可以说是信息(包括文件、程序、数据等),而信息在计算机中全部由一串位表示(即01组成的序列),区分不同信息只能靠读信息的上下文,所以位+上下文可以唯一确定计算机中一个具体的信息。
hello程序的源文件虽然能够被人读懂,但为了能让它在系统上运行,每条c语句都要被其他程序转化为一系列的低级机器语言指令,然后这些指令按照一种可执行目标程序的格式打包好并以二进制磁盘文件的形式存放起来,才能在系统中运行。
在unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的。
gcc -o hello hello.c
这条命令会让GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello,这个翻译过程分为四个阶段:
1.预处理阶段:
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,比如hello.c文件中第1行#include
2.编译阶段:
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序,即将C语言程序编译成汇编语言程序
3.汇编阶段:
汇编器(as)将hello.s翻译成机器语言指令,然后把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中,hello.o文件是一个二进制文件,它的字节编码是机器语言指令(01)而不是字符。
4.链接阶段:
hello程序调用了printf函数,这个每个C编译器都会提供的标准C库中的一个函数,printf函数存在于一个名为printf.o的单独预编译好了的目标文件中,(我对这段话的理解是printf函数最开始是在一个printf.c文件中实现的,然后经过预处理、编译、汇编形成了目标文件printf.o,,可能每一个自己程序中非自己亲手实现的函数都是一个以.o为后缀的目标文件,然后在链接阶段由链接器(ld)把这所有的目标文件合并,最终实现完整的程序,可执行文件,可以被加载到内存,由系统执行。)
有很多原因促使程序员知道编译系统是如何工作的,其原因如下:
此刻,hello.c源程序已经被编译系统翻译成了可执行目标文件hello,并存放在磁盘上,也就是电脑上的某个文件夹下面。要想运行该文件,可以将他的文件名输入到成为外壳(shell)的应用程序中,也就是终端,即Windows的cmd中
外壳是一个命令行解释器,它输出一个提示符,等待你输入一个命令行。然后执行这个命令。如果该命令行的第一个单词不是一个内置的外壳命令,那么外壳就会假设这是一个可执行文件的名字,它将加载并运行这个文件。 所以在此例中,外壳将加载并运行hello程序。然后等待程序终止。hello程序在屏幕上输出它的信息,然后终止。外壳随后输出一个提示符,等待下一个输入的命令行。
为了理解运行hello程序时发生了什么,我们需要了解一个典型系统的硬件组织
总线
贯穿整个系统的一组电子管道,称作总线,它携带信息字节并负责在各个部件间传递。通常总线被设计成传送定长的字节快,也就是字(word)。字中的字节数(即字长)是一个基本的系统参数,在各个系统中的情况都不相同。 现在机器字长有的是4个字节(4个字节 * 每个字节8位 = 32位),有的是8个字节(8个字节 * 每个字节8位 = 64位),为了讨论方便,假设字长4个字节,并且总线每次只传送一个字。
I/O设备
输入/输出(I/O)设备是系统与外部世界的联系通道,我们的示例系统包括4个I/O设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序的磁盘驱动器(磁盘),最初,可执行程序hello就存放在磁盘上。
每个I/O设备都通过一个控制器或适配器与I/O总线相连。控制器和适配器之间的区别主要在于它们的封装方式。控制器是置于I/O设备本身的或者系统的主印制电路板(主板)上的芯片组,而适配器则是一块插在主板插槽上的卡。 无论如何,他们的功能都是在I/O总线和I/O设备之间传递信息。
主存
主存是一个临时存储设备, 在处理器执行程序时,用来存放程序和程序处理的数据。从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(即数组索引),这些地址从0开始。 一般来说,组成程序的每条机器指令都由不同数量的字节组成。与C程序变量相对应的数据项的大小是根据类型变化的。例如,32位机器上,short类型的数据需要2个字节,int、float、long类型需要4个字节,double类型需要8个字节。
处理器
中央处理单元(CPU),简称处理器,是解释(或执行)存储在主存中指令的引擎。处理器的核心是一个字长的存储设备(寄存器),称为程序计数器(PC)。 在任何时刻,PC都指向主存中的某条机器语言指令(即含有该条指令的地址)
从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。处理器看上去是按照一个非常简单的指令执行模型来操作的。这个模型是由指令集结构决定的。在这个模型中,指令按照严格的顺序执行,而执行一条指令包含一系列的步骤。处理器从程序计数器(PC)指向的存储器处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定与存储器中刚刚执行的指令相邻。
指令指示的简单操作并不多,大多都比较复杂,而且操作是围绕着主存、寄存器未见(register file)和算术/逻辑单元(ALU)进行的。寄存器文件是一个小的存储设备,有一些1字长的寄存器组成,每个寄存器都有唯一的名字。 ALU计算新的数据和地址值,下面列举一些简单操作的例子,CPU在指令的要求下可能会执行以下操作:
处理器看上去只是它的指令集结构的简单实现,但是实际上现代处理器使用了非常复杂的机制来加速程序的执行。因此,我们可以这样区分处理器的指令集结构和微体系结构:指令集结构描述的是每条机器代码指令的效果;而微体系结构描述的是处理器实际上是如何实现的。
前面简单描述了系统的硬件组成和操作,现在开始介绍当我们运行示例程序时到底发生了什么。
总的来说就是,我们在终端一边输入字符,终端一边将这些字符读入寄存器然后放到主存中,等到我们在键盘上敲回车键时,外壳程序就知道我们已经完成了命令的输入,然后外壳就执行一系列的指令来确定我们输入的是个啥,如果确定这东西不是一个内置的外壳命令,那么外壳程序就会假设这是一个可执行文件,然后就会将目标文件中的代码和数据从磁盘复制到主存,一旦被加载到主存,处理器就开始执行程序main中的机器语言指令,把需要显示到屏幕的数据从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示到屏幕上。
系统花费了大量的时间把信息从一个地方挪到另一个地方,所以系统设计者的一个主要目标就是使这些挪的步骤尽快完成。
每个计算机系统中的存储设备都被组织成了一个存储器层次结构。
存储器层次结构的主要思想是一层上的存储器作为低一层存储器的高速缓存,因此寄存器文件就是L1的高速缓存,L1是L2的高速缓存,L2是L3的高速缓存,L3是主存的高速缓存,而主存又是硬盘的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上的数据的高速缓存。
正如可以运用不同的高速缓存的知识提高程序性能一样,程序员同样可以利用对整个存储器层次结构的理解来提高程序性能。
回到hello程序的例子,外壳(shell)没有直接访问磁盘,hello程序也没有直接访问显示器。取而代之的是,它们都是依靠操作系统提供的服务来达到的上述功能。我们可以把操作系统看成是应用程序和硬件之间插入的一层软件,所有应用程序对硬件的操作都必须通过操作系统。
操作系统有两个基本功能:
操作系统通过几个基本的抽象概念(进程、虚拟存储器和文件)来实现这两个功能
如图所示,文件是对I/O设备的抽象表示,虚拟存储器是对主存和磁盘I/O设备的抽象表示,进程则是对处理器,主存和I/O设备的抽象表示。
进程是操作系统对一个正在运行的程序的一种抽象, 使得就好像系统上只有这一个程序在运行,只有这一个程序在使用处理器、主存和I/O设备,处理器看上去就像在不间断地一条接一条的执行程序中的指令,即该程序的代码和数据是系统存储器中唯一的对象。
一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件,无论是单核还是多核系统,一个CPU看上去都像是在并发执行多个进程,并发运行的意思是一个进程的指令和另一个进程的指令是交错执行的。这通过处理器在进程间切换来实现。
操作系统实现这种交错执行的机制称为上下文切换。 操作系统保持跟踪进程运行所需的所有状态信息。这种状态(上下文)包括许多信息,比如PC和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换(保存当前进程上下文,恢复新进程的上下文,然后把控制权传递到新进程,新进程从上次停止的地方开始)
上图为两个并发的进程:shell进程和hello进程,最初,只有shell进程在运行,即等待命令行上的输入,当我们让它运行hello程序时,shell通过调用一个专门的函数,即系统调用,来执行我们的请求,系统调用会将控制权传递给操作系统。操作系统保存shell的上下文,然后将控制权传递给新的hello进程。hello进程终止后,操作系统恢复shell的上下文,并将控制权传回给它,shell将继续等待下一个命令行的输入。
一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。 由于网络服务器对并行处理的需求,线程称为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。 当有多处理器可用的时候,多线程也是一种使程序可以更快运行的方法。
虚拟存储器是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占的使用主存。每个进程看到的是一致的存储器,称为虚拟地址空间。 在Linux中,地址空间最上面的区域是为操作系统中的代码和数据保留的,这对所有进程来说都是一样的。地址空间的底部区域存放用户进程定义的代码和数据。注意:图中的地址是从下往上增大的。
每个进程看到的虚拟地址空间有大量准确定义的区构成,每个区都有专门的功能。 现在先从最低的地址开始,逐步向上地简单了解一下每一个区。
虚拟存储器的运作需要硬件和操作系统软件之间精密复杂的交互。包括对处理器生成的每个地址的硬件编译。其基本思想是把一个进程虚拟存储器的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。
文件就是字节序列。 每个I/O设备,包括磁盘、键盘、显示器,甚至是网络,都可以视为文件。系统中的所有输入输出都是通过使用一小组称为Unix I/O的系统函数调用读写文件来实现的。
现代系统经常通过网络和其他系统连接到一起,从一个单独的系统来看,网络可视为一个I/O设备。当系统从主存将一串字符复制到网络适配器时,数据流经过网络到达另一台机器,相似的,系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存。
继续讨论hello示例,我们可以使用Telnet应用在一个远程主机上运行hello程序。假设用本地主机上的Telnet客户端连接到远程主机上的Telnet服务器。在我们登录到远程主机并运行外壳后,远端的外壳就在等待接收输入命令。从这点开始,远程运行hello程序包括五个基本步骤:
系统不仅仅只是硬件,系统是硬件和系统软件互相交织的集合体,它们必须共同协作以达到运行应用程序的最终目的。
并发是一个通用的概念,指一个同时具有多个活动的系统
并行指的是用并发是一个系统运行的更快。
我所理解的并发就是多个进程交换执行,因为交换的非常快,所以给人一种同时执行的假象,而并行就是真正的多个进程同时执行。
并行可以在计算机系统的多个抽象层次上运用。在此,我们按照系统层次结构中由高到低的顺序重点强调三个层次。
抽象的使用是计算机科学中最为重要的概念之一。为一组函数规定一个简单的应用程序(API)就是一个很好地编程习惯,程序员无需了解它内部的工作便可以使用这些代码。
有三种重要的数字表示:
计算机的表示法使用有限数量的位来对一个数字编码,因此,当结果太大以至于不能表示时,某些运算就会溢出。
整数表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的;而浮点数虽然可以编码一个较大的数值范围,但是这种表示只是近似的,且由于表示的精度有限,浮点运算是不可结合的。
十进制转十六进制
如果一个数可以表示成2的非负整数n次幂时,即 x= 2 n 2^{n} 2n,n可以写成 i + 4j (0 ≤ \leq ≤ i ≤ \leq ≤ 3),这个数x既可以转换成开头十六进制数字1( i=0 ),2( i=1 ),4( i=2 )或者8( i=3 ),后面跟着j个十六进制0
比如:
字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为w位的机器而言,虚拟地址的范围为0 ~ 2 w − 1 2^{w}-1 2w−1 ,程序最多访问 2 w 2^{w} 2w个字节。
这句话不太好理解,可以用字长为8位,画图说明:
2.1.4 p28 参考 《C++ primer plus 第六版 》p111 4.8.3 指针和字符串