博客源自:http://blog.csdn.net/xuejianhui/article/details/52577937
1.1 信息就是位+上下文
初读此书时,此标题对我触动非常大,如醍醐灌顶!作者一针见血地道出了信息的本质。无论是磁盘中的文本文件、TCP报文协议、基于TCP封装的HTTP报文协议等等,都是基于上下文。开头告诉你怎么解析、后面有多长的内容。GB2312、GBK、UTF-8等字符编码得以区分,也是在文本开头附加标志。
源程序:就是一个由0和1组合的位(bit)
序列,8位组成一字(byte)
,每个字节表示某个文本字符。
系统中所有的信息——包括磁盘文件、存储器中的程序、存储器中存放的用户数据以及网络上传送的数据,都是由一串位表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。
作为程序员,我们需要了解数字的机器表示方式,因为他们和实际的整数和实数是不同的。它们是对真值的有限近似值,有时候会有意向不到的行为表现。见第 02 章详解。
C语言的起源:
由Dennis Ritchie
在1969年~1973年创建的。
美国国家标准学会(American National Standards Institute,ANSI
)在1989年颁布了ANSI C标准,后来由国际标准化组织(International Standards Organization,ISO
)负责C语言的标准化工作。
Kernaghan
和Ritchie
合著的经典书被程序“K&R”。
1.2 程序被其他程序翻译成不同格式
为了在系统上运行hello.c程序,每条C语句都被编译器转化成一系列的低级机器语言
指令。然后这些指令按照一种称为可执行目标程序
的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序
也称为可执行目标文件
。
代码-hello.c文件:
#include <stdio.h>
int main()
{
printf("hello world\n");
}
在Unix系统上,从源文件到目标文件的转化是由GCC编译器驱动程序完成的:
unix> gcc -o hello hello.c
预处理器(cpp)
读取系统头文件stdio.h的内容,并把它直接插入到程序文本中。
生成一个以.i为后缀的文件。编译器(ccl)
将文本文件 hello.i 翻译成文本文件 hello.s(以一种标准的文本格式保存的低级底层机器语言指令
,被称为汇编程序
) , 它包含一个汇编语言程序
(它为不同高级语言的不同编译器提供了通用的输出语言)。汇编器(as)
将 hello.s 翻译成机器语言指令 , 把这些指令打包成一种叫做可重定位目标程序 ( relocatable object program )
的格式 , 并将结果保存在目标文件hello.o中。
hello.o 文件是一个二进制文件 , 它的字节编码是机器语言指令而不是字符。 如果我们在文本编辑器中打开 hello.o 文件 , 看到的将是一堆乱码 。链接器(ld)
printf
函数存在于一个名为printf.o
的单独的预编译好了的目标文件中 , 而这个文件必须以某种方式合并到我们的hello.o程序中 。链接器( ld ) 就 负责处理这种合并。
结果就得到hello文件, 它是一个可执行目标文件
( 或者简称为可执行文件
),可以被加载到内存中 , 由系统执行 。
《程序员的自我修养》中有更深入、更系统的讲解。
1.3 了解编译系统如何工作是大有益处的
程序员要知道编译系统是如何工作的,原因如下:
优化程序性能
例如 , 一个switch
语句是否总是比一系列的if-then-else
语句高效得多?
一个函数调用
的开销有多大?
while
循环比for
循环更有效吗?
指针引用
比数组索引
更有效吗?
为什么将循环求和的结果放到一个本地变量中与将其放到一个通过引用传递过来的参数中相比 , 运行速度要快很多呢?
为什么我们只是简单地重新排列一下一个算术表达式
中的括号就能让一个函数运行得更快?
第 05 章, 你将学习如何通过简单转换 C 语言代码以帮助编译器更好地完成工作 , 从而调整 C 程序的性能。
第 06 章, 你将学习到存储器系统的层次结构特性 , C 语言编译器将数组存放在存储器中的方式, 以及 C 程序又是如何能够利用这些知识从而更高效地运行。理解链接时出现的错误
例如 , 链接器报告它无法解析一个引用
这是什么意思?
静态变量
和全局变量
的区别是什么 ?
如果你在不同的 C 文件中定义了名字相同的两个全局变量会发生什么 ?
静态库
和动态库
的区别是什么 ?
我们在命令行上排列库的顺序有什么影响 ?
最严重的是,为什么有些链接错误直到运行时才会出现 ?
在第 07 章, 你将得到这些问题的答案。避免安全漏洞
多年来 , 缓冲区溢出错误是造成大多数网络和 Internet 服务器上安全漏洞 的主要原因 。 存在这些错误是因为很少有人能理解限制他们从不受信任的站点接收数据的数量和格式的重要性。 学习安全编程的第一步就是理解数据和控制信息存储在程序栈上的方式会引起的后果。作为学习汇编语言的一部分, 我们将在第 03 章中描述堆栈原理和缓冲区溢出错误。我们还将学习程序员 、编译器和操作系统可以用来降低攻击威胁的方法。
1.4 处理器读并解释存储在存储器中的指令
要想在 Unix 系统上运行该可执行文件
, 我们将它的文件名输入到称为外壳 ( shell)
的应用程序中 :
unix> ./hello
hello, world
unix>
1.4.1 系统的硬件组成
为了理解运行 hello 程序时发生了什么 , 我们需要了解一个典型系统的硬件组织,下图是 Intel Pentium
系统产品系列的模型:
总线
贯穿整个系统的是一组电子管道 , 称做总线
, 它携带信息字节并负责在各个部件间传递 。
传送定长的字节块 , 也就是字 ( word )
。
现在的大多数机器字长有的是 4 个字节 ( 32 位 ), 有的是 8 个字节 ( 64 位 )。
假设字长为 4 个字节 , 并且总线每次只传送 1 个字。I/O设备
每个 I/O 设备都通过一个控制器或适配器与 I/O 总线相连 。
控制器
和适配器
之间的区别主要在于它们的封装方式 。
控制器
:置于 I/O 设备本身的或者系统的主印制电路板 ( 通常称为主板
) 上的芯片组 ;
适配器
:则是一块插在主板插槽上的卡。
第 06 章会更多地说明磁盘之类的 I/O 设备是如何工作的。主存
主存是一个临时存储设备 , 在处理器执行程序时 , 用来存放程序和程序处理的数据。
物理上,是一组动态随机存取存储器 ( DRAM )
芯片。
逻辑上 , 是一个线性的字节数组 , 每个字节都有其唯一的地址 ( 即数组索引 ), 这些地址是从零开始的。
例如 , 在运行 Linux 的 IA32 机器上 , short 类型的数据需要 2 个字节 , int 、 float 和 long 类型需要 4 个字节 , 而 double 类型需要 8 个字节 。处理器
中央处理单元 ( CPU ), 简称处理器
, 是解释 ( 或执行 ) 存储在主存中指令的引擎 。
处理器的核心是一个字长的存储设备 (或寄存器
), 称为程序计数器
( PC )。在任何时刻 , PC都指向主存中的某条机器语言指令 (即含有该条指令的地址)。
1.从系统通电开始 , 直到系统断电 , 处理器一直在不断地执行程序计数器指向的指令 , 再更新程序计数器 , 使其指向下一条指令。
2.处理器看上去是按照一个非常简单的指令执行模型来操作的 , 这个模型是由指令集结构决定的。
3.在这个模型中, 指令按照严格的顺序执行, 而执行一条指令包含执行一系列的步骤。
3.1.处理器从程序计数器(PC)指向的存储器处读取指令;
3.2.解释指令中的位;
3.3.执行该指令指示的简单操作;
3.4.然后更新PC, 使其指向下一条指令, 而这条指令并不一定与存储器中刚刚执行的指令相邻。
操作是围绕着主存
、寄存器文件 ( register file )
和算术 / 逻辑 单元 ( ALU )
进行的 。
寄存器文件
是一个小的存储设备 , 由一些1字长的寄存器组成, 每个寄存器都有唯一的名字 。 ALU 计算新的数据和地址值。
下面列举一些简单操作的例子 , CPU 在指令的要求下可能会执行以下操作 :
1.4.2 运行 hello 程序
程序运行步骤大致如下:
1. 初始时,外壳程序执行它的指令,等待我们输入一个命令;
2. 当我们在键盘上输入字符串“./hello”后 , 外壳程序将字符逐一读入寄存器
, 再把它存放到存储器
中;
3. 当我们在键盘上敲回车键时 , 外壳程序
就知道我们已经结束了命令的输入;
4. 然后外壳
执行一系列指令来加载可执行的hello文件, 将 hello目标文件中的代码和数据从磁盘
复制到主存
。 数据包括最终会被输出的字符串“ hello, world\n ”。
5. 一旦目标文件 hello 中的代码和数据被加载到主存
, 处理器
就开始执行 hello 程序的 main 程序中的机器语言指令;
6. 这些指令将“ hello, world\n ”字符串中的字节从主存
复制到寄存器文件
, 再从寄存器文件
中复制到显示设备
, 最终显示在屏幕上 。
另外,在第 06 章中利用直接存储器存取 ( DMA
) 的技术 , 数据可以不通过处理器
而直接从磁盘
到达主存
。如下图:
1.5 高速缓存至关重要
这个简单的示例揭示了一个重要的问题 , 即系统花费了大量的时间把信息从一个地方挪到另 一个地方 。
hello 程序的机器指令最初是存放在磁盘上的 , 当程序加载时 , 它们被复制到主存 ; 当处理器运行程序时 , 指令又从主存复制到处理器。相似地 , 数据串 “ hello, world\n ” 初 始时在磁盘上 , 然后复制到主存 , 最后从主存上覆制到显示设备 。 从程序员的角度来看 , 这些复 制就是开销 , 减缓了程序 “ 真正 ” 的工作 。 因此 , 系统设计者的一个主要目标就是使这些复制操 作尽可能快地完成。
从程序员的角度来看 , 这些复制就是开销 , 减缓了程序“ 真正 的工作。因此, 系统设计者的一个主要目标就是使这些复制操作尽可能快地完成。
根据机械原理 , 较大的存储设备要比较小的存储设备运行得慢 , 而快速设备的造价远高于同类的低速设备 。
例如 , 一个典型系统上的磁盘驱动器
可能比主存大1000倍, 但是对处理器
而言 , 从磁盘驱动器
上读取一个字的时间开销要比从主存
中读取的开销大1000万倍 。
类似地 , 一个典型的寄存器文件
只存储几百字节的信息 , 而主存
里可存放几十亿字节。然而处理器
从寄存器文件
中读数据的速度比从主存
中读取几乎要快100倍。
处理器
与主存
之间的差距还在持续增大。加快处理器的运行速度比加快主存的运行速度要容易和便宜得多。
针对这个差异,系统设计者采用了更小 、更快的存储设备 , 即高速缓存存储器(简称高速缓存)
, 作为暂时的集结区域,用来存放处理器近期可能会需要的信息。
位于处理器芯片上的 L1 高速缓存的容量可以达到数万字节 , 访问速度几乎和访问寄存器文件
一样快。一个容量为数十万到数百万字节的更大的 L2 高速缓存通过一条特殊的总线连接到处理器 。 进程访问L2高速缓存
的时间要比访问L1高速缓存
的时间长5倍, 但是这仍然比访问主存的时间快5~10倍 。 L1和L2高速缓存是用一种叫做静态随机访问存储器(SRAM)
的硬件技术实现的。比较新的 、处理能力更强大的系统甚至有三级高速缓存 : L1 、L2 和L3
。
第 06 章将讲解这些重要的设备以及如何利用它们。
1.6 存储设备形成层次结构
从上文讲到,系统升级这正不遗余力地缩短处理器与主存之间的速度差距,否则处理器会因为等待主存而白白将部分资源浪费掉。
因此,根据存储设备的速度暂时分为下面这几个层次,叫做存储器层次结构。
存储器层次结构的主要思想:一层上的存储器作为低一层的存储器的高速缓存。
不多扯了,通过书中给出的参数。它们彼此间的速度关系如下:
磁盘驱动器
* 1000万倍 => 主存
* 5~10倍 => 高速缓存L2
* 5倍 => 高速缓存L1
* 2~4倍 => 寄存器
;
磁盘驱动器 * 1000万倍 = 主存;
主存 * 5~10倍 = 高速缓存L2;
高速缓存L2 * 5倍 = 高速缓存L1;
高速缓存L1 * 2~4倍 = 寄存器;
程序员可以利用对此结构的理解提高程序性能,第 06 章将详细讨论这个问题。
1.7 操作系统管理硬件
以hello为例,外壳和hello程序都没有直接操作键盘、显示器、磁盘或者主存,而是由操作系统提供服务。
操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用;
- 向应用程序提供简单一致的机制来控制复杂而又通常大相径庭的低级硬件设备。
操作系统通过以下几个基本的抽象概念来实现这两个功能:
- 文件:是对I/O设备的抽象表示;
- 虚拟存储器:是对主存和磁盘I/O设备的抽象表示;
- 进程:是对处理器、主存和I/O设备的抽象表示。
Unix和Posix
这里给出几个贝尔实验室的牛人,大家自行搜索他们的牛逼事迹吧。
Ken Thompson
、Dennis Ritchie
、Doug Mcllroy
、Joe Ossanna
等参与了Honeywell的Multics项目,Multics因过于复杂而告终,Unix算是更小、更简单的版本。
Brian Kernighan
是Unix的命名者,并暗自Multics过于复杂。
Richard Stallman
创建了Posix标准,并使得Unix更加标准化。
1.7.1 进程
操作系统会提供一种假象,就好像系统上只有这个程序在运行,看上去只有这个程序在使用处理器、主存和I/O设备。
图中,这种交错执行的机制叫做上下文切换
。
此处只考虑包含一个CPU的单核处理器的情况,将在1.9.1节讨论多处理器系统。
操作系统保持跟踪进程运行所需要的所有状态信息。这种状态,叫做上下文
,它包括许多信息,例如PC和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器内容都只能执行一个进程的代码。
示例场景中有两个并发的进程:外壳进程和hello进程。
起初,只有外壳进程在运行,即等待命令行的输入。当我们让它运行hello程序时,外壳通过调用一个专门的函数,即系统调用
,来执行我们的请求,系统调用会将控制权传递给操作系统。操作系统保存外壳进程的上下文,创建一个新的hello进程及其上下文,然后将控制权传递给新的hello进程。hello进程终止后,操作系统恢复外壳进程的上下文,并将控制权回给它,外壳进程将继续等待下一个命令行输入。
将在第 08 章揭示这项工作的原理。
1.7.2 线程
此处篇幅比较小,都是比较基础,因为本书从最底层讲起,因此在第 12 章才能看到与并发相关的内容。
由于网络服务器对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。当有多个服务器可用的时候,多线程也是一种使程序可以更快运行的方法。
题外话:在实际编程中,线程比进程更高效,并不绝对。因为锁的干扰,当业务过于复杂时,可能会导致线程频繁地等待锁甚至死锁。比如chrome就是为了避开死锁,而选择进程,使用管道作为进程间通信的方式(管道通信虽然较冷门,但却非常强大,尤其是window的有名管道)。
1.7.3 虚拟存储器
虚拟存储器是一种抽象概念,它提供了一种假象,即每个进程都在独占地使用内存。每个进程看到的是一致的存储器,称为虚拟地址空间
(还有些翻译笼统地称为虚拟内存
)。
在第 07 章,将详细讨论。此处请注意“共享库的存储器映射区域”,这里少有人关注。
从最低地址开始,从下到上,分别是:
程序代码和数据
:可执行目标文件和C 全局变量。一开始就被规定好的大小。堆
:通过malloc和free动态地扩展和收缩,C++建议用new和delete,对抽象对象支持更好,比如会默认调用构造函数和析构函数。且据《Effective C++》上说,new的存储位置与malloc是不同的。共享库
:中间位置存放C标准库
和数学库
等共享库的程序代码和数据
的区域。栈
:编译器用它来实现函数调用,每次调用一个函数时,栈增长;从一个函数返回时,栈收缩。内核虚拟存储器
:内核总是驻留在内存中,是操作系统的一部分。地址空间顶部的区域是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。
后续结合《Effective C++》、《C Primer Plus》和《UNIX环境高级编程》详细介绍下。
1.7.4 文件
文件就是字节序列,因此而已。每个I/O设备都可视为文件,系统中所有输入输出都是通过使用一小组成为Unix I/O的系统函数调用读写文件来实现的。详情见第 10 章。
《UNIX环境高级编程》中,有一个文件描述符
的概念,类似于Windows的句柄
。Unix开篇就说——Everything is a file。
1.8 系统之间利用网络通信
继续以hello为例,我们可以使用telnet应用在一个远程主机上运行hello程序。假设用本地主机上的telnet客户端连接远程主机上的telnet服务端。在我们登录到远程主机并运行外壳后,远程的外壳就在等待接收输入命令。
图中已将过程描述的非常清楚,不再赘述了。
对telnet的介绍不错,寥寥数语清楚明确地道出telnet的原理,有大家风范!
1.9 重要主题
1.9.1 并发和并行
数字计算机的整个历史中,有两个需求是驱动进步的而持续动力:
- 我们想要计算机做得更多
- 我们想要计算机运行的更快
并发(concurrency)
:指一个同时具有多个活动的系统;
并行(parallelism)
:指用并发使一个系统运行得更快。
1. 线程级并发
构建进程这个抽象,我们能够设计出同时执行多个程序的系统,这就导致了并发。
使用线程,我们甚至能够在一个进程中执行多个控制流。从20世纪60年代初期出现分时(time-sharing)以来,计算机系统就开始有了对并发执行的支持。
题外话:《图解TCP/IP》中在讲解网络发展历程中也提到了分时系统对网络方案变更的影响。这本书以网络发展历程为轴,讲解了每个新技术的出现背景、作用和原理,几乎只看图就可以了解《TCP/IP协议》里的内容。另外,此书与另外《图解HTTP》、《图解网路硬件》并成为图解三部曲,《图解服务器网络架构》也不错。
多处理器系统
:由单个操作系统内核控制多处理器的系统。
多核处理器
:将多个CPU(称为核
)集成到一个集成电路芯片上。工业界专家预言最终将会有上百个核集成到一个芯片上。
超线程
,有时称为同时多处线程(simultaneous multi-threading)
,是一项允许一个CPU执行多个控制流的技术。它涉及到CPU某些硬件有多个备份,比如程序计数器和寄存器文件;而其他的硬件部分只有一份,比如执行浮点算术运算的单元。
常规的处理器需要大约2万个时钟周期做不同线程的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得CPU能够更好地利用它的资源。
例如,假设一个线程必须等到某些数据被转载到高速缓存中,那CPU就可以继续去执行另一个线程。举例说明,Intel Core i7处理器可以让一个核执行两个线程,所以一个4核的系统实际上可以并行地执行8个线程。
多处理器的使用可以从两个方面提高系统性能:
- 它减少在执行多个任务时模拟并发的需要;
- 它可以使程序运行得更快。当然,要以多线程方式来写程序,这些线程可以并行地高效执行。
直到多核和超线程系统的出现才极大地激发了人们的一种愿望,即找到书写应用程序的方法利用硬件开发线程级并行性。
在第 12 章将更深入地探讨并发。
2. 指令级并行
指令级并行
:在较低的抽象层面上,现代处理器可以同时执行多条指令的属性。
如1978年的Intel 8086,需要多个(通常是3~10个)时钟周期来执行一条指令,比较先进的处理器可以保持每个时钟周期2~4条指令的执行速度。其实每条指令从开始到结束需要长得多的时间,大约20个或更多的周期。
在 流水线(pipelining)
中,将执行一条指令所需要的活动划分为不同的步骤,将处理器的硬件组织成一系列的阶段,每个阶段执行一个步骤。这些阶段可以并行地操作,用来处理不同指令的不同部分。我们会看到一个相当简单的硬件设计,它能够达到接近一个时钟周期一条指令的执行速率。 在第 04 章将更深入地探讨流水线。
超标量(superscalar)
:处理器达到比一个时钟周期一条指令更快的的执行速率。
在第 05 章将介绍超标量处理器的高级模型。
3. 单指令 、 多数据并行
在最低层次上,许多现在处理器拥有特殊的硬件,允许一条指令产生多个可以并发执行的操作,这种方式称为单指令、多数据
,即SIMD并行
。
提供这些 SIMD 指令多是为了提高处理影像、声音和视频数据应用的执行速度。虽然有IE编辑器视图从 C 程序中自动抽取 SIMD 并行性,但是更可靠的方法是使用编译器支持的特殊向量数据类型来写程序,例如GCC就支持向量数据类型。
1.9.2 计算机系统中抽象的重要性
注:计算机系统中的一个重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性。
1.10 小结
计算机系统是有硬件和系统软件组成的,他们共同协作来运行应用程序。计算机内部的信息被表示为一组组长的位,它们依据上下文有不同的解释方式。程序被其他程序翻译成不同的形式,开始时是ASCII文本,然后被编译器和链接器翻译成二进制可执行文件。
处理器读取并解释存放在主存里的二进制指令。因为计算机把大量时间用于存储器、I/O设备和CPU寄存器之间复制数据,所以将系统中的存储设备划分成层次结构——CPU寄存器在顶部,接着是多层硬件高速缓存存储器、DRAM主存和磁盘存储器。在层次模型中,位于更高层的存储设备比底层的存储设备要更快,单位比特开销也更高。层次结构中较高层次的存储设备可以作为较低层次设备的高速缓存。通过理解和运用这种存储层次结构的知识,程序员可以优化 C 程序的性能。
操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象:
- 文件是对I/O设备的抽象
- 虚拟存储器是对主存的磁盘的抽象
- 进程是对处理器、主存和I/O设备的抽象
最后,网络提供了计算机系统之间的通信手段。从特殊系统的角度来看,网络就是一种I/O设备。
参考文献说明
Ritchie 写了关于早期 C 和 Unix 的有趣的第一手资料 [87, 88]。
Ritchie 和 Thompson 提供了 最早出版的 Unix 资料 [89]。
Silberschatz 、 Gavin 和 Gagne[98] 提供了关于 Unix不同版本的详尽历史。
Posix 标准可以在线获得 ( www.unix.org )。
所有章节内容:
第 01 章:计算机系统漫游
第 02 章:信息的表示和处理
第 03 章:程序的机器级表示
第 04 章:处理器体系结构
第 05 章:优化程序性能
第 06 章:存储器层次结构
第 07 章:链接
第 08 章:异常控制流
第 09 章:虚拟存储器
第 10 章:系统级I/O
第 11 章:网络编程
第 12 章:并发编程