hello,你好鸭,我是Ethan,很高兴你能来阅读,昵称是希望自己能不断精进,向着优秀程序员前行!💪💪💪
目前博客主要更新Java系列、数据库、项目案例、计算机基础等知识点。感谢你的阅读和关注,在记录的同时希望我的博客能帮助到更多人。✔✔✔
人生之败,非傲即惰,二者必居其一,勤则百弊皆除。你所坚持最终会回报你!加油呀!🏃🏃🏃
文章目录
- 写在前面
- 一、计算机总体认识(1)
- 第1章 计算机系统漫游
- 二、程序结构和执行(2~6)
- 第2章 信息的表示和处理
- 第3章程序的机器级表示
- 第4章 处理器体系结构
- 第5章优化程序性能
- 第6章存储器层次结构
- 三、在系统上运行程序(7~9)
- 第7章 链接
- 第8章 异常控制流
- 第9章虚拟内存
- 四、程序间的交互和通信(10~12)
- 第10章 系统级I/O
- 第11章 网络编程
- 第12章并发编程
写在前面
学完本书——
如果你全力投身学习本书中的概念,完全理解底层计算机系统以及它对应用程序的影响,那么你会步上成为为数不多的“大牛”的道路。
终身学习——
即使学完本书,你也要做好更深人探究的准备,研究像编译器、计算机体系结构、操作系统、嵌人式系统、网络互联和网络安全这样的高级题目。
章节说明——
一、计算机总体认识(1)
二、程序结构和执行(2~6)
三、在系统上运行程序(7~9)
四、程序间的交互和通信(10~12)
一、计算机总体认识(1)
第1章 计算机系统漫游
源于hello world——
如图所示的hello程序非常简单,但是为了让它实现运行,系统的每个主要组成部分都需要协调工作。从某种意义上来说,本书的目的就是要帮助你了解当你在系统上执行hello程序时,系统发生了什么以及为什么会这样——
我们通过跟踪hello程序的生命周期来开始对系统的学习——从它被程序员创建开始到在系统上运行,输出简单的消息,然后终止。我们将沿着这个程序的生命周期,简要地介绍一些逐步出现的关键概念、专业术语和组成部分。后面的章节将围绕这些内容展开。
1.1信息就是位+上下文
hello程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员通过编辑器创建并保存的文本文件,文件名是hello.c。源程序实际上就是一个由值0和1组成的位(又称为比特)序列,)8个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。
像hello.c这样只由ASCII字符构成的文件称为文本文件,所有其他文件都称为二进制文件。
1.2程序被其他程序翻译成不同的格式
hello程序的生命周期是从一个高级C语言程序开始的,(因为这种形式能够被人读懂。——向上抽象。)
然而,为了在系统上运行hello.c程序,每条C语句都必须被其他程序转化为一系列的低级机器语言指令)然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。
在这里,GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。这个翻译过程可分为四个阶段完成,如图1-3所示。
执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统(compilation system)。
预处理阶段——
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第1行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,
通常是以.i作为文件扩展名。
编译阶段——
编译阶段。编译器(ccl)将文本文件hello.i翻译成文本文件 hello.s,它包含一个汇编语言程序。
该程序包含函数main的定义,如下所示——
定义中2~7行的每条语句都以一种文本格式描述了一条低级机器语言指令。汇编语言是非常有用的,(因为它为不同高级语言的不同编译器提供了通用的输出语言。
例如,C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言。
汇编阶段——
汇编阶段。接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o中。hello.o文件是一个二进制文件,它包含的 17 个字节是函数 main的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
链接阶段——
链接阶段。请注意,hello程序调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件或者简称为可执行文件),可以被加载到内存中,由系统执行。
1.3了解编译系统如何工作是大有益处的
与后面章节的关系——
在第3章中,我们会讲述编译器是怎样把不同的C语言结构翻译成这种机器语言的。
在第4章中,我们会研究流水线pipelining的使用。
在第5章中,你将学习如何通过简单转换C语言代码,帮助编译器更好地完成工作,从而整C程序的性能。
在第6章中,你将学习存储器系统的层次结构特性,C语言编译器何将数组存放在内存中,以及C程序又是如何能够利用这些知识从而更高效地运行。
在第7章中,你将得到这些问题的答案——
C语言无法解析一个引用,这是什么意思?静态变量和全局变量的区别是什么?
如果你在不同的C文件中定义了名字相同的两个全局变量会发生什么?
静态库和动态库的区别是什么?
我们在命令行上排列库的顺序有什么影响?
最严重的是,为什么有些链接错误直到运行时才会出现?
在第10章中,你将学习如何在应用程序中利用Unix I/O接口访问设备。我们将特别关注网络类设备.
在第12章中,你将学习并发的基本概念,包括如何写线化的程序。
1.4处理器读并解释储存在内存中的指令
此刻,hello.c源程序已经被编译系统翻译成了可执行目标文件hello,并被存放在磁盘上。要想在Unix系统上运行该可执行文件,我们将它的文件名输人到称为shell的应用程序中:
linux> ./hello
hello, world
linux>
shell 是一个命令行解释器,它输出一个提示符,等待输人一个命令行,然后执行这个命令。如果该命令行的第一个单词不是一个内置的shell命令,那么shell 就会假设这是一个可执行文件的名字,它将加载并运行这个文件。
所以在此例中,shell将加载并运行hello程序,然后等待程序终止。hello程序在屏幕上输出它的消息,然后终止。shell随后输出一个提示符,等待下一个输人的命令行。
1.4.1系统的硬件组成
为了理解运行 hello程序时发生了什么,我们需要了解一个典型系统的硬件组织——
一、总线
贯穿整个系统的是一组电子管道,称作总线。它携带信息字节并负责在各个部件间传递。通常总线被设计成传送定长的字节块,也就是字(word)。字中的字节数(即字长)是一个基本的系统参数,各个系统中都不尽相同。现在的大多数机器字长要么是4个字节(32位),要么是8个字节(64位)。
二、 l/O 设备
I/O(输人/输出)设备是系统与外部世界的联系通道。我们的示例系统包括四部分I/O设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序的磁盘驱动器(简单地说就是磁盘)。
最开始,可执行程序hello就存放在磁盘上。每个I/O设备都通过一个控制器或适配器与I/〇总线相连。控制器和适配器之间的区别主要在于它们的封装方式。
控制器是I/〇设备本身或者系统的主印制电路板(通常称主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都在I/0总线和I/O设备之间传递信息。
三、主存
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。
从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。
从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(数组索引),这些地址是从零开始的。
一般来说,组成程序的每条机器指令都由不同数量的字节构成。与C程序变量相对应的数据项的大小是根据类型变化的。
四、处理器
中央处理单元(CPU),简称处理器,是解释(或执行)存储在主存中指令的引擎。
处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。
在任何时刻,PC都指向主存中的某条机器语言指令(即含有该条指令的地址)。
从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令再更新程序计数器,使其指向下一条指令。处理器看上去是按照一个非常简单的指令执行模型来操作的,这个模型是由指令集架构决定的。在这个模型中,指令按照严格的顺序行,而执行一条指令包含执行一系列的步骤。
处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令。 而这条指令并不一定和在内存中刚刚执行的指令相邻。
这样的简单操作并不多,它们围绕着主存、寄存器文件(register fie)和算术/逻辑单元(ALU)进行。 寄存器文件是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字。ALU计算新的数据和地址值。
指令集架构描述的是每条机器代码指令的效果。
1.4.2运行 hello程序
前面简单描述了系统的硬件组成和操作,现在开始介绍当我们运行示例程序时到底发生了些什么。————
初始时,shell程序执行它的指令,等待我们输入一个命令。当我们在键盘上输人字符串"./hello"后,shell程序将字符逐一读人寄存器,再把它存放到内存中,如图——
**当我们在键盘上敲回车键时,shell程序就知道我们已经结束了命令的输入。**然后shell 执行一系列指令来加载可执行的hello文件,**这些指令将hello目标文件中的代码和数据从磁盘复制到主存。**数据包括最终会被输出的字符串“hello,world\n”。
利用直接存储器存取(DMA,将在第6章中讨论)技术,**数据可以不通过处理器而接从磁盘到达主存。**这个步骤如图所示。
一旦目标文件hello中的代码和数据被加载到主存,处理器就开始执行hello程序的main程序中的机器语言指令(这些指令将“hello,world\n”字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。如图——
1.5高速缓存至关重要
问题引出——
上面的这个简单的示例揭示了一个重要的问题,即系统花费了大量的时间把信息从一个地方挪到另一个地方。
hello程序的机器指令 最初是存放在磁盘上,当程序加载时,它们被复制到主存;当处理器运行程序时,指令又从主存复制到处理器。
相似地,数据串“hello,world/n”开始时在磁盘上,然后被复制到主存,最后从主存上复制到显示设备。
从程序员的角度来看,这些复制就是开销,减慢了程序“真正”的工作。因此,系统设计者的一个主要目标就是使这些复制操作尽可能快地完成————
一个典型的寄存器文件只存储几百字节的信息,而主存里可存放几十亿字节。然而,处理器从寄存器文件中读数据比从主存中读取几乎要快100倍。
解决方案——
针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器(cache memory,简称为cache或高速缓存),作为暂时的集结区域,存放处理器近期可能会需要的信息。
本书得出的重要结论之一——
利用高速缓存将程序的性能提高一个数量级。
1.6存储设备形成层次结构
在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为一个普遍的观念。实际上,(每个计算机系统中的存储设备都被组织成了一个存储器层次结构。在这个层次结构中,从上至下,设备的问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。
寄存器文件在层次构中位于最顶部,也就是第0级或记为L0。
这里我们展示的是三层高速缓存L1到L3占据存储器层次结构的第1层到第3层。
主存在第4层,以此类推。
共展示了七级缓存——
主要思想
存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。
因此,寄存器文件就是L1的高速缓存,L1是L2的高速缓存,L2是L3的高速缓存,L3是主存的高速缓存,而主存又是磁盘的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上的数据的高速缓存。
1.7操作系统管理硬件
让我们回到 hello程序的例子。当 shell 加载和运行 hello程序时,以及 hello 程序输出自己的消息时,shell和hello程序都没有直接访问键盘、显示器、磁盘或者主存。
取而代之的是,它们依靠操作系统提供的服务。我们可以把操作系统看成是应用程序和硬件之间插人的一层软件。
计算机系统的分层视图
操作系统两个基本功能
所有应用程序对硬件的操作尝试都必须通过操作系统操作系统有两个基本功能——
(1)防止硬件被失控的应用程序滥用;
(2)向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。
操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能——
操作系统提供的抽象表示
1.7.1进程
像hello这样的程序在现代系统上运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和I/O设备。 **处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对象。**这些假象是通过进程的概念来实现的,进程是计算机科学中最重要和最成功的概念之一。
进程是操作系统对一个正在运行的程序的一种抽象。 在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多于可以运行它们的CPU个数的。传统系统在一个时刻只能执行一个程序,而先进的多核处理器同时能够执行多个程序。(无论是在单核还是多核系统中,一个CPU看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换来实现的(**操作系统实现这种交错执行的机制称为上下文切换。**为了简化讨论,我们只考虑包含一个 CPU的单处理器系统的情况。
操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,包括多信息,此如PC和寄存器文件的当前值,以及主存的内容。
在任何一个时刻,单处理系统都只能执行一个进程的代码。**当操作系统决定要把控制权从当前进程转移到某个新程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。**新进程就会从它上次停止的地方开始。
示例代码说明——
示例场景中有两个并发的进程:——shell进程和hello进程。
最开始,只有shell进程在运行,即等待命令行上的输人。当我们让它运行hello程序时,shell通过调用一个专门的函数,即系统调用,来执行我们的请求,系统调用会将控制权传递给操作系统。 操作系统保存shell 进程的上下文,创建一个新的hello进程及其上下文,然后将控制权传给的hello进程。hello进程终止后,操作系统恢复shell进程的上下文,并将控制权传给它,shell进程会继续等待下一个命令行输人。
内核——
从一个进程到另一个进程的转换是由操作系统内核(kernel)管理的 。**内核是操作系统代码常驻主存的部分。**当应用程序需要操作系统的某些操作时,比如读写文件,它就执行一条特殊的系统调用(systemcall)指令,将控制权传递给内核。然后内核执行被请求的操作并返回应用程序。注意,内核不是一个独立的进程。相反,它是系统管理全部进程所用代码和数据结构的集合。
1.7.2线程
一个进程实际上尽管通常我们认为一个进程只有单一的控制流,但是在现代系统中,一个进程可以由多个称为线程的执行单元组成,(每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。)
由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,**也因为线程一般来说都比进程更高效。**当有多处理器可用的时候,(多线程也是一种使得程序可以运行得更快的方法)
1.7.3虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使主存。)每个进程看到的内存都是一致的,称为虚拟地址空间。图1-13所示的是 Linux进程虚拟地址空间。
在Linux中,地址空间最上面的区域是保留给操作系统中的代码和数据的,这对所有进程来说都是一样。)地址空间的底部区域存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。
每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。我们从最低的地址开始,逐步向上介绍————
程序代码和数据区——
对所有的进程来说,代码是从同一固定地址开始,紧接着的是和C全局变量相对应的数据位置。(代码和数据区是直接按照可执行目标文件的内容初始化的,在示例中就是可执行文件hello。在第7章我们研究链接和加载时,你会学习更多有关地址空间的内容。
堆区——
代码和数据区后紧随着的是运行时堆。代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像malloc和free这样的C标准库函数时,可以在运行时动态地扩展和收缩。在第9章学习管理虚拟内存时,我们将更详细地研究堆。
共享库——
大约在地址空间的**中间部分是一块用来存放像C标准库和数学库这样的共享库的代码和数据的区域。**共享库的概念非常强大,也相当难懂。在第7章介绍动态链接时,将学习共享库是如何工作的。
栈——
位于用户虚拟地址空间顶部的是用户栈, 编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。特别地,每次我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩。在第3章中将学习编译器是如何使用栈的。
内核虚拟内存——
地址空间顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些操作。
硬件翻译——
虚拟内存的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的个地址的硬件翻译。
**基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作磁盘的高速缓存。**第9章将解释它如何工作,以及为什么对现代系统的运行如此重要。
1.7.4文件
文件就是字节序列,仅此而已。
每个I/O设备,包括磁盘、键盘、显示器,甚至都可以看成是文件。
系统中的所有输人输出都是通过使用一小组称为 UnixI/O 的系统函数调用读写文件来实现的。
文件这个简单而精致的概念是非常强大的,因为 它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的/O设备。 例如,处理磁盘文件内容的应用程序员可以非常幸福,因为他们无须了解具体的磁盘技术。进一步说,同一个程序可以在使用不同磁盘技术的不同系统上运行。你将在第10章中学习UnixI/O。
1.8系统之间利用网络通信
现代系统经常通过网络和其他系统连接到一起。从一个单独的系统来看,网络可视为一个I/O设备)
(**当系统从主存复制一串字节到网络适配器时,数据流经过网络到达另一台机器,而不是比如说到达本地磁盘驱动器。**相似地、系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存)
——
远程运行程序——
从一台主机复制信息到另外一台主机已经成为计算机系统最重要的用途之一。比如,像电子邮件、即时通信、万维网、FTP和telnet这样的应用都是基于网络复制信息的功能。——
回到hello示例,我们可以使用熟悉的telnet应用在一个远程主机上运行hello程序。
**假设用本地主机上的telnet客户端连接远程主机上的telnet服务器。**在我们登录到远程主机并运行shell后,远端的shell 就在等待接收输入命令。此后在远端运行hello程序包括五个基本步骤——
1.8.1并发和并行
并发——
术语并发(concurrency)是一个通用的概念,指一个同时具有多个活动的系统
在以前,即使处理器必须在多个任务间切换,大多数实际的计算也都是由一个处理器来完成的。这种配置称为单处理器系统。
当构建一个由单操作系统内核控制的多处理器组成的系统时,我们就得到了一个多处理器系统。其实从20世纪80年代开始,在大规模的计算中就有了这种系统,但是直到最近,随着多核处理器和超线程(hyperthreading)的出现,这种系统才变得常见。
多核处理器是将多个 CPU(称为“核”)集成到一个集成电路芯片上的典型多核处理器的组织结构,其中微处理器芯片有4个CPU核,每个核都有自己的L1、L2高速缓存,其中的L1高速缓存分为两个部分(一个保存最近取到的指令,另一个放数据。这些核共享更高层次的高速缓存,以及到主存的接口)——
超线程——
有时称为同时多线程(simultaneous multi-threading),是一项允许一个CPU执行多个控制流的技术。这使得CPU能够更好地利用它的处理资源。比如,**假设一个线程必须等到某些数据被装载到高速缓存中,那CPU就可以继续去执行另一个线程。**举例来说IntelCorei7 处理器可以让每个核执行两个线程,所以一个4核的系统实际上可以并行地执行8个线程。
指令级并行——
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行
早期的微处理器,如1978年的Intel8086,需要多个(通常是3-10 个)时钟周期来执行一条指令。最近的处理器可以保持每个时钟周期2~4条指令的执行速率。
其实每条指令从始到结束需要长得多的时间,大约20个或者更多周期,但是处理器使用了非常多的聪明技巧来同时处理多达100条指令。
在第4章中,我们会研究流水线pipelining的使用。在流水线中,将执行一条指令所需要的活动划分成不同的步骤,将处理器的硬件组织成一系列的阶段,每个阶段执行一个步骤。这些阶段可以并行地操作,用来处理不同指令的不同部分。我们会看到一个相当简单的硬件设计,它能够达到接近于一个时钟周期一条指令的执行速率。
单指令、多数据并行——
在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即SIMD并行。
1.8.2计算机系统中抽象的重要性
指令集架构——
在处理器里,指令集架构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像运行在一个一次只执行一条指令的处理器上。
文件——
文件是对I/O设备的抽象
虚拟内存——
虚拟内存是对程序存储器的抽象
进程——
进程是对一个正在运行的程序的抽象
虚拟机——
它提供对整个计算机的抽象,包括操作系统、处理器和程序。
二、程序结构和执行(2~6)
第2章 信息的表示和处理
2.1 信息存储
2.1.1十六进制表示法
2.1.2字数据大小
2.1.3寻址和字节顺序
2. 1.4表示字符串
2. 1.5表示代码
2. 1.7C语言中的位级运算
2. 1. 8C语言中的逻辑运算
2. 1. 9C语言中的移位运算
2.2 整数表示:
2. 2. 1整型数据类型
2. 2. 2无符号数的编码
2. 2. 3补码编码
2. 2. 4有符号数和无符号数之间的转换:
2. 2. 5C语言中的有符号数与无符号数
2. 3整数运算
2. 3. 1无符号加法
2. 3. 2补码加法
2. 3. 3补码的非
2. 3. 4无符号乘法
2. 3. 5补码乘法
2. 3. 6乘以常数
2. 3. 7除以2的希
2. 3. 8关于整数运算的最后思考
2. 4浮点数
2. 4. 1二进制小数
2. 4.2IEEE 浮点表示
2. 4. 3数字示例
2. 4. 4舍入
2. 4. 5浮点运算
2. 4. 6C语言中的浮点数
第3章程序的机器级表示
前言——
1、计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。
2、编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。
3、GCC语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。
3. 2程序编码
3. 2. 1机器级代码
3.2.3 关于格式的注解
3. 3数据格式
3. 4访问信息
3. 4.1操作数指示符
3. 4. 2数据传送指令
3. 4. 3数据传送示例
3.4.4 压入和弹出栈数据
3. 5算术和逻辑操作
3. 5. 1加载有效地址
3. 5. 2一元和二元操作
3. 5. 3移位操作
3. 5. 5特殊的算术操作
3.6 控制
3. 6.1条件码
3. 6. 2访问条件码
3. 6. 3跳转指令
3. 6. 4跳转指令的编码
3. 6.5用条件控制来实现条件分支
3. 6. 6用条件传送来实现条件分支
3. 6. 7循环
3. 6.8switch 语句
3.7过程
3. 7.1运行时栈
3. 7.2转移控制
3. 7.3数据传送
3. 7.4栈上的局部存储
3. 7. 5寄存器中的局部存储空间
3. 7.6递归过程
3.8 数组分配和访问
3.8.1基本原则
3. 8. 2指针运算
3. 8. 3嵌套的数组
3. 8. 4定长数组
3. 8. 5变长数组
3.9异质的数据结构
3.9.1 结构
3. 9.2联合
3.9.3 数据对齐
3. 10在机器级程序中将控制与数据结合起来
3. 10.4对抗缓冲区溢出攻击
3. 10.5支持变长栈帧:
3.11 浮点代码
3.11.1 浮点传送和转换操作
3. 11. 2过程中的浮点代码
3. 11. 3浮点运算操作
3. 11. 4定义和使用浮点常数
3. 11. 5在浮点代码中使用位级操作
3. 11. 6浮点比较操作
3.11.7 对浮点代码的观察结论
第4章 处理器体系结构
4.1 Y86-64指令集体系结构
4.1.1 程序员可见的状态中
4.1.2Y86-64指令…
4.1.3 指令编码
4.1.4 Y86-64 异常m
4.1.5 Y86-64程序……
4.1.6 一些Y86-64 指令的详情
4.2 逻辑设计和硬件控制语言HC
4.2.1逻辑门
4.2.2 组合电路和HCL布尔表达式
4.2.3 字级的组合电路和HCI整数表达式………
4.2.4 集合关系……………
4.2.5 存储器和时钟
4.3 Y86-64的顺序实现
4.3.1将处理组织成阶段
4.3.2 SEQ硬件结构
4.3.3SEO的时序……
4.3.4SEQ阶段的实现
4.4流水线的通用原理
4.4.1 计算流水线
4.4.2 流水线操作的详细说明
4.4.3 流水线的局限性
4.4.4 带反馈的流水线系统
4.5 Y86-64的流水线实现
4.5.1 SEQ+:重新安排计算
4.5.2 插入流水线寄存器
4.5.3 对信号进行重新排列和标号…
4.5.4预测下一个PC
4.5.5流水线冒险
4.5.6异常处理………………
4.5.7 PIPE各阶段的实现
4.5.8流水线控制逻辑
4.5.9 性能分析 …
4.5.10 未完成的工作
第5章优化程序性能
5.1优化编译器的能力和局限性
5.2表示程序性能
5.3程序示例
5.4消除循环的低效率
5.5 减少过程调用
5.6消除不必要的内存引用
5.7 理解现代处理器
5.7.1 整体操作
5.7.2 功能单元的性能
5.7.3 处理器操作的抽象模型
5.8循环展开
5.9 提高并行性
5.9.1 多个累积变量
5.9.2 重新结合变换
5.10优化合并代码的结果小结
5.11 一些限制因素·
5.11.1 寄存器溢出
5.11.2 分支预测和预测错误处罚
5.12理解内存性能……
5.12.1 加载的性能….
5.12.2 存储的性能…
5.13应用:性能提高技术
5.14确认和消除性能瓶颈
5.14.1 程序剖析…
5.14.2 使用剖析程序来指导优化
第6章存储器层次结构
6.1 存储技术
6.1.1 随机访问存储器
6.1.2 磁盘存储
6.1.3 固态硬盘
6.1.4 存储技术趋势
6.2局部性
6.2.1 对程序数据引用的局部性
6.2.2取指令的局部性
6.2.3局部性小结
6.3存储器层次结构
6.3.1 存储器层次结构中的缓存
6.3.2存储器层次结构概念小结
6.4高速缓存存储器
6.4.1 通用的高速缓存存储器组织结构
6.4.2直接映射高速缓存
6.4.3组相联高速缓存
6.4.4 全相联高速缓存
6.4.5有关写的问题
6.4.6一个真实的高速缓存层次结构的解剖
6.4.7 高速缓存参数的性能影响
6.5 编写高速缓存友好的代码
6.6综合:高速缓存对程序性能的影啊
6.6.1 存储器山
6.6.2重新排列循环以提高空间局部性
6.6.3 在程序中利用局部性
6.7 小结
三、在系统上运行程序(7~9)
继续我们对计算机系统的探索,进一步来看看构建和运行应用程序的系统软件。链接器把程序的各个部分联合成一个文件:处理器可以将这个文件加载到内存,并且执行它。现代操作系统与硬件合作,为每个程序提供一种幻象,好像这个程序是在独占地使用处理器和主存,而实际上,在任何时刻,系统上都有多个程序在运行。
在本书的第一部分,你很好地理解了程序和硬件之间的交互关系。本书的第二部分将拓宽你对系统的了解,使你牢固地掌握程序和操作系统之间的交互关系。你将学习到如何使用操作系统提供的服务来构建系统级程序,例如Unix shell和动态内存分配包
第7章 链接
链接(linking)是将各种(代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于(编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于(加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(runtime),也就是由应用程序来执行。
在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。链接通常是由链接器来默默地处理的,对于那些在编程人门课堂上构造小程序的学生而言,链接不是一个重要的议题。那为什么还要这么麻烦地学习关于链接的知识呢?
-
理解链接器将帮助你构造大型程序。
构造大型程序的程序员经常会遇到由于缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。 -
理解链接器将帮助你避免一些危险的编程错误。
Linux链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。在默认情况下,错误地定义多个全局变量的程序将通过链接器,而不产生任何警告信息)由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。我们将向你展示这是如何发生的,以及该如何避免它。 -
理解链接将帮助你理解语言的作用域规则是如何实现的。
例如,全局和局部变量之间的区别是什么?当你定义一个具有static属性的变量或者函数时,实际到底意味着什么? -
理解链接将帮助你理解其他重要的系统概念。
链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,(比如加载和运行程序、虚拟内存、分页、内存映射。理解链接将使你能够利用共享库。多年以来,链接都被认为是相当简单和无趣的。然而,随着共享库和动态链接在现代操作系统中重要性的日益加强,链接成为一个复杂的过程,为掌握它的程序员提供了强大的能力。比如,许多软件产品在运行时使用共享库来升级压缩包装的(shrink-wrapped)二进制程序。还有,大多数Web服务器都依赖于共享库的动态链接来提供动态内容,这一章提供了关于链接各方面的全面讨论,从传统静态链接到加载时的共享库的动态链接,以及到运行时的共享库的动态链接。我们将使用实际示例来描述基本的机制,而且指出链接问题在哪些情况中会影响程序的性能和正确性。为了使描述具体和便于理解,我们的讨论是基于这样的环境:一个运行Linux的x86-64系统,使用标准的ELF-64(此后称为ELF)目标文件格式。不过,无论是什么样的操作系统、**ISA(指令集架构)**或者目标文件格式,基本的链接概念是通用的,认识到这一点是很重要的。细节可能不尽相同,但是概念是相同的。
7.1 编译器驱动程序
大多数编译系统提供编译器驱动程序(compilerdriver),它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。比如,要用GNU编译系统构造示例程序,我们就要通过在shell中输人下列命令来调用GCC驱动程序:
linux> gcc -Og -o prog main.c sum.c
图7-2概括了驱动程序在将示例程序从ASCII码源文件翻译成可执行目标文件时的行为。(如果你想看看这些步骤,用-v选项来运行GCC。)
驱动程序首先运行C预处理器(cpp),它将C的源程序 main.c 翻译成一个ASCII码的中间文件main.i
сpp main.c /tmp/main.i[other arguments]
接下来,驱动程序运行C编译器(cc1),它将main.i翻译成一个ASCII汇编语言文件 main.s:
ccl /tmp/main.i -0g [other arguments]-o /tmp/main.s
然后,驱动程序运行汇编器(as),它将main.s翻译成一个可重定位目标文件main.o
as [other arguments]-o /tmp/main.o /tmp/main.s
驱动程序经过相同的过程生成sum.0。最后,它运行链接器程序ld,将 main.o和sum.。以及一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)prog:
ld -o prog [system object fles and args] /tmp/main.o /tmp/sum.o
要运行可执行文件prog,我们在 Linux shell 的命令行上输人它的名字
linux> ./prog
shell调用操作系统中一个叫做加载器(loader)的函数)它将可执行文件prog 中的代码和数据复制到内存,然后将控制转移到这个程序的开头,之后由CPU解析指令并执行。
7.2 静态链接
像linux LD程序(GNU-ld链接脚本 )这样的静态链接器(static linker)以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输人的可重定位目标文件由各种不同的**代码和数据节(section)**组成,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外一节中。
为了构造可执行文件,链接器必须完成两个主要任务:
符号解析(symbol resolution)。
目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量),符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
重定位(relocation)。
编译器和汇编器生成从地址0开始的代码和数据节,链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。接下来的章节将更加详细地描述这些任务。在你阅读的时候,要记住关于链接器的一些基本事实:目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。
7.3 目标文件
目标文件有三种形式:
* 可重定位目标文件
包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
* 可执行目标文件
包含二进制代码和数据,其形式可以被直接复制到内存并执行
* 共享目标文件
一种特殊类型的可重定位目标文件,(可以在加载或者运行时被动态地加载进内存并链接。)
编译器和汇编器生成可重定位目标文件(包括共享目标文件),链接器生成可执行目标文件。
从技术上来说,(一个目标模块(object module)就是一个字节序列,而一个目标文件(object file)就是一个以文件形式存放在磁盘中的目标模块。不过,我们会互换地使用这些术语!
目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。从贝尔实验室诞生的第一个 Unix 系统使用的是a.out格式(直到今天,可执行文件仍然称为a.out文件)。Windows使用可移植可执行(Portable Executable,PE)格式。MacOS-X使用 Mach-〇格式。现代 x86-64 Linux和 Unix系统使用可执行可链接格式(Executable and Linkable Format,ELF)。尽管我们的讨论集中在 ELF上,但是不管是哪种格式基本的概念是相似的。
7.4 可重定位目标文件
图7-3展示了一个典型的ELF可重定位目标文件的格式。(ELF头(ELF header)以个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息)其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节
.text:已编译程序的机器代码。
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同**.symtab符号表不包含局部变量的条目**。
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
.rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。用编译器驱动程序时,才会得到这张表。
7.5 符号和符号表
每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
- 由模块m定义并能被其他模块引用的全局符号。
全局链接器符号对应于非静态的函数和全局变量。 - 由其他模块定义并被模块m引用的全局符号。
这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量。 - 只被模块m定义和引用的局部符号。
它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
认识到本地链接器符号和本地程序变量不同是很重要的。.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣。
有趣的是,定义为带有C static属性的本地过程变量是不在栈中管理的。相反,编译器在.data或.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。
比如,假设在同一模块中的两个函数各自定义了一个静态局部变量x:
在这种情况中,编译器向汇编器输出两个不同名字的局部链接器符号。比如,它可以用x.1表示函数f中的定义,而用x.2表示函数g中的定义。
- 利用static属性隐藏变量和函数名字
C程序员使用 static属性隐藏模块内部的变量和函数声明,就像你在Java和C++中使用 public和 private 声明一样。在C中,源文件扮演模块的角色。任何带有static属性声明的全局变量或者函数都是模块私有的。类似地,任何不带static属性声明的全局变量和函数都是公共的,可以被其他模块访问。尽可能用static属性来保护你的变量和函数是很好的编程习惯。
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。symtab节中包含ELF符号表。这张符号表包含一个条目的数组。图7-4展示了每个条目的格式。
每个符号都被分配到目标文件的某个节,由section字段表示,该字段也是一个到节头部表的索引。
有三个特殊的伪节(pseudosection),它们在节头部表中是没有条目的:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,value字段给出对齐要求,而size给出最小的大小。注意只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。
COMMON和.bss的区别很细微。现代的GCC版本根据以下规则来将可重定位目标文件中的符号分配到COMMON和.bss中:
COMMON——未初始化的全局变量.
.bss——未初始化的静态变量,以及初始化为0的全局或静态变量
7.6 符号解析
链接器解析符号引用的方法是将每个引用与它输人的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器行号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。
7.6.1 链接器如何解析多重定义的全局符号
链接器的输人是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名的全局符号,会发生什么呢?
下面是Linux编译系统采用的方法。在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak)而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
- **规则1:**不允许有多个同名的强符号。
- **规则2:**如果有一个强符号和多个弱符号同名,那么选择强符号
- **规则3:**如果有多个弱符号同名,那么从这些弱符号中任意选择一个
7.6.2 与静态库链接
课程引出——
迄今为止,我们都是假设链接器读取一组可重定位目标文件,并把它们链接起来,形成一个输出的可执行文件。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(static library),它可以用做链接器的输人。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。为什么系统要支持库的概念呢?以ISOC99为例,它定义了一组广泛的标准1/0、字符串操作和整数数学函数,例如atoi、printf、scanf、strcpy和 rand。它们在 libc.a库中,对每个C程序来说都是可用的。ISOC99还在1ibm.a库中定义了一组广泛的浮点数学函数,例如sin、cos和sqrt。让我们来看看如果不使用静态库,编译器开发人员会使用什么方法来向用户提供这些函数。一种方法是让编译器辨认出对标准函数的调用,并直接生成相应的代码。Pascal(只提供了一小部分标准函数)采用的就是这种方法,但是这种方法对C而言是不合适的,因为C标准定义了大量的标准函数。这种方法将给编译器增加显著的复杂性,而且每次添加、删除或修改一个标准函数时,就需要一个新的编译器版本。然而,对于应用程序员而言,这种方法会是非常方便的,因为标准函数将总是可用的。
另一种方法是将所有的标准C函数都放在一个单独的可重定位目标模块中(比如说libc.o中)应用程序员可以把这个模块链接到他们的可执行文件中:
linux> gcc main.c /usr/lib/libc.o
静态库概念被提出来,以解决这些不同方法的缺点。相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。比如,使用C标准库和数学库中函数的程序可以用形式如下的命令行来编译和链接:
linux> gcc main.c usr/lib/libm,a /usr/lib/libc.a
在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。另一方面,应用程序员只需要包含较少的库文件的名字(实际上,C编译器驱动程序总是传送libc.a给链接器,所以前面提到的对libc.a的引用是不必要的)。
在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。
为了使我们对库的讨论更加形象具体,考虑图7-6中的两个向量例程。每个例程,定义在它自己的目标模块中,
当链接器运行时,它判定 main2.o引用了addvec.o定义的addvec符号,所以复制addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制1ibc.a中的printf.o模块,以及许多C运行时系统中的其他模块。
7.6.3链接器如何使用静态库来解析引用
虽然静态库很有用,但是它们同时也是一个程序员迷惑的源头,原因在于Linux链接器使用它们解析外部引用的方式。在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令行中所有的.c文件翻译为.0文件。)
在这次扫描中,链接器维护一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号集合U(即引用了但是尚未定义的符号),以及一个在前面输入文件中已定义的符号集合D。初始时E、U 和 D 均为空。
对于命令行上的每个输人文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输人文件。
如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,直到U和D都不再发生变化。此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。
7.7 重定位
课程引出——
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目) 关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。
现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。
重定位由两步组成:
重定位节和符号定义。
在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
重定位节中的符号引用。
在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocationentry)的数据结构,我们接下来将会描述这种数据结构。
7.7.1 重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。
所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
图7-9展示了ELF重定位条目的格式。ofset是需要被修改的引用的节偏移。symbo1标识被修改引用应该指向的符号。type告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
7.7.2 重定位符号引用
7.8可执行目标文件
课程引出——
在前面我们已经看到链接器如何将多个目标文件合并成一个可执行目标文件。我们的示例C程序,开始时是一组ASCII文本文件,现在已经被转化为一个二进制文件,且这个二进制文件包含加载程序到内存并运行它所需的所有信息。图7-13概括了一个典型的ELF可执行文件中的各类信息。
可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以它不再需要rel 节。
ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。程序头部表(program header table)描述了这种映射关系。图 7-14 展示了可执行文件prog的程序头部表,是由OBJDUMP显示的。
7.9加载可执行日标文件
要运行可执行目标文件prog,我们可以在Linux shell 的命令行中输入它的名字:
linux>./prog
因为prog不是一个内置的shell命令,所以shell会认为prog 是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。任何Linux程序都可以通过调用execve函数来调用加载器,我们将在8.4.6节中详细描述这个函数。
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。每个 Linux程序都有一个运行时内存映像,类似于图7-15中所示。在 Linuxx86-64系统中,代码段总是从地址0x400000处开始,后面是数据段。运行时堆在数据段之后,通过调用 ma11oc库往上增长。(我们将在9.9节中详细描述 ma11oc 和堆。)堆后面的区域是为共享模块保留的。用户栈总是从最大的合法用户地址(28-1)开始,向较小内存地址增长。栈上的区域,从地址2开始,是为内核(kernel)中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分。
为了简洁,我们把堆、数据和代码段画得彼此相邻,并且把栈顶放在了最大的合法用户地址处。实际上,由于.data段有对齐要求(见7.8节),所以代码段和数据段之间是有间隙的。同时,在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布局随机化(ASLR,参见3.10.4节)。虽然每次程序运行时这些区域的地址都会改变,它们的相对位置是不变的。
当加载器运行时,它创建类似于图7-15所示的内存映像。在程序头部表的引导下加载器将可执行文件的片(chunk)复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctr1.o中定义的,对所有的C程序都是一样的。start函数调用系统启动函数 __libc_start_main,该函数定义在 libc.so中。它初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并且在需要的时候把控制返回给内核。
加载器实际是如何工作的?
我们对于加载的描述从概念上来说是正确的,但也不是完全准确,这是有意为之要理解加载实际是如何工作的,你必须理解进程、虚拟内存和内存映射的概念,这些我们还没有加以讨论。在后面第8章和第9章中遇到这些概念时,我们将重新回到加载的问题上,并逐渐向你揭开它的神秘面纱。
对于不够有耐心的读者,下面是关于加载实际是如何工作的一个概述:
Linux系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell运行一个程序时,父 shell 进程生成一个子进程,它是父进程的一个复制。子进程通过execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到 start地址,它最终会调用应用程序的 main 函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
7.10动态链接共享库
课程引出——
我们在7.6.2节中研究的静态库解决了许多关于如何让大量相关函数对应用程序可用的问题。然而,静态库仍然有一些明显的缺点。静态库和所有的软件一样,需要定期维护和更新。如果应用程序员想要使用一个库的最新版本,他们必须以某种方式了解到该库的更新情况,然后显式地将他们的程序与更新了的库重新链接。
另一个问题是几乎每个C程序都使用标准I/0函数,比如printf和scanf。在运行时,这些函数的代码会被复制到每个运行进程的文本段中。在一个运行上百个进程的典型系统上,这将是对稀缺的内存系统资源的极大浪费。(内存的一个有趣属性就是不论系统的内存有多大,它总是一种稀缺资源。磁盘空间和厨房的垃圾桶同样有这种属性。)
共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。共享库也称为共享目标(sharedobject),在Linux系统中通常用**.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)**。
共享库是以**两种不同的方式来“共享”**的。
首先,在任何给定的文件系统中,对于一个库只有一个.so文件(所有引用该库的可执行目标文件共享这个so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。
其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。在第9章我们学习虚拟内存时将更加详细地讨论这个问题。
图7-16概括了图7-7中示例程序的动态链接过程。为了构造图7-6中示例向量例程的共享库1ibvector.so,我们调用编译器驱动程序,给编译器和链接器如下特殊指令:
-fpic选项指示编译器生成与位置无关的代码(下一节将详细讨论这个问题)。-shared选项指示链接器创建一个共享的目标文件。一旦创建了这个库,随后就要将它链接到图7-7的示例程序中:
这样就创建了一个可执行目标文件prog2l,而此文件的形式使得它在运行时可以和libvector.so链接。基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。认识到这一点是很重要的:此时,没有任何1ibvector.so的代码和数据节真的被复制到可执行文件prog21中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对1ibvector.so中代码和数据的引用。当加载器加载和运行可执行文件prog21时,它利用7.9节中讨论过的技术,加载部分链接的可执行文件prog21。接着,它注意到prog21包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(如在Linux系统上的1d-linux.so)。加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:
重定位libc.so的文本和数据到某个内存段。
重定位libvector.so的文本和数据到另一个内存段。
重定位prog21中所有对由libc.so和libvector.so定义的符号的引用。
最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
7.11 从应用程序中加载和链接共享库
课程引出——
到目前为止,我们已经讨论了在应用程序被加载后执行前时,动态链接器加载和链接共享库的情景。然而,应用程序还可能在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中。
动态链接是一项强大有用的技术。下面是一些现实世界中的例子:
分发软件。
微软 Windows应用的开发者常常利用共享库(DLL)来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。
构建高性能 Web服务器。
许多 Web 服务器生成动态内容,比如个性化的 Web页面、账户余额和广告标语。早期的Web服务器通过使用fork和execve创建一个子进程,并在该子进程的上下文中运行CGI程序来生成动态内容。然而,现代高性能的Web服务器可以使用基于动态链接的更有效和完善的方法来生成动态内容。其思路是将每个生成动态内容的函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用它,而不是使用fork和execve在子进程的上下文中运行函数。函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用的开销就可以处理随后的请求了。这对一个繁忙的网站来说是有很大影响的。更进一步地说,在运行时无需停止服务器,就可以更新已存在的函数,以及添加新的函数。
共享库和 Java 本地接口
Java 定义了一个标准调用规则,叫做Java本地接口(Java Native Interface,JNI),它允许Java 程序调用“本地的”C和 C++ 函数。JNI的基本思想是将本地C函数(如 foo)编译到一个共享库中(如 foo.so)。当一个正在运行的Java 程序试图调用函数 foo 时,Java 解释器利用 dlopen 接口(或者与其类似的接口)动态链接和加载 foo.so,然后再调用 foo。
7.12 位置无关代码
课程引出——
共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,因而节约宝贵的内存资源。那么,多个进程是如何共享程序的一个副本的呢?一种方法是给每个共享库分配一个事先预备的专用的地址空间片,然后要求加载器总是在这个地址加载共享库。虽然这种方法很简单,但是它也造成了一些严重的问题。它对地址空间的使用效率不高,因为即使一个进程不使用这个库,那部分空间还是会被分配出来。它也难以管理我们必须保证没有片会重叠。每次当一个库修改了之后,我们必须确认已分配给它的片还适合它的大小。如果不适合了,必须找一个新的片。并且,如果创建了一个新的库,我们还必须为它寻找空间。随着时间的进展,假设在一个系统中有了成百个库和库的各个版本库,就很难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞。更糟的是,对每个系统而言,库在内存中的分配都是不同的,这就引起了更多令人头痛的管理问题。
要避免这些问题,现代系统以这样一种方式编译共享模块的代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。使用这种方法,无限多个进程可以共享一个共享模块的代码段的单一副本。(当然,每个进程仍然会有它自己的读/写数据块。)
可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC)。
用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。共享库的编译必须总是使用该选项。
7.13 库打桩机制
Linux链接器支持一个很强大的技术,称为库打桩(library interpositioning),它允许你截获对共享库函数的调用,取而代之执行自己的代码。使用打机制,你可以追踪对某个特殊库函数的调用次数,验证和追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。
下面是它的基本思想:
给定一个需要打桩的目标函数,创建一个包装函数,它的原型与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是目标函数了。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返回值传递给调用者。
7.13.1 编译时打桩
7.13.2 链接时打桩
小结
链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有3种不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到内存中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用 dlopen库的函数时。
链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。
静态链接器是由像GCC这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引人微妙的错误。
多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源。
加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。
第8章 异常控制流
从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列——
a1,a2,a3,a4……an
其中,每个a是某个相应的指令I的地址。每次从ak到ak-1的过渡称为控制转移(control transfer)。这样的控制转移序列叫做处理器的控制流(flowofcontrol或control flow)。最简单的一种控制流是一个“平滑的”序列,其中每个I k和I k+1在内存中都是相邻的。这种平滑流的突变(也就是I k+1与I k不相邻)通常是由诸如跳转、调用和返回这样一些熟悉的程序指令造成的。这样一些指令都是必要的机制,使得程序能够对由程序变量表示的内部程序状态中的变化做出反应。
但是系统也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。比如,一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器后,必须存放在内存中。
程序向磁盘请求数据,然后休眠,直到被通知说数据已就绪。当子进程终止时,创造这些子进程的父进程必须得到通知。现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变称为异常控制流(ExceptionalControlFlow,ECF)。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。
8.1 异常
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。因为它们有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同。然而,对于每个系统而言,基本的思想都是相同的。在这一节中我们的目的是让你对异常和异常处理有一个一般性的了解,并且向你揭示现代计算机系统的一个经常令人感到迷惑的方面。
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。
8.1.1 异常处理
异常可能会难以理解,因为处理异常需要硬件和软件紧密合作。很容易搞混哪个部分执行哪个任务。让我们更详细地来看看硬件和软件的分工吧。
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exceptionnumber)。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部I/O设备的信号。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址。图8-2展示了异常表的格式。
在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序。图8-3展示了处理器如何使用异常表来形成适当的异常处理程序的地址。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊 CPU 寄存器里。
8.1.2 异常的类别
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。
1.中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。图8-5概述了一个中断的处理。I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断这个异常号标识了引起中断的设备。
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行,就好像没有发生过中断一样,
剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction)。
8.1.3 Linux/x86-64系统中的异常
8.2 进程
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输人一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。关于操作系统如何实现进程的细节的讨论超出了本书的范围。反之,我们将关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
8.2.1 逻辑控制流
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。考虑一个运行着三个进程的系统,如图8-12所示。处理器的一个物理控制流被分成了三个逻辑流,每个进程一个。每个竖直的条表示一个进程的逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程A运行了一会儿,然后是进程B开始运行到完成。然后,进程C运行了一会儿,进程A接着运行直到完成。最后,进程C可以运行到结束了。
8.2.2 并发流私有地址空间
8.2.3私有地址空间
8.2.4 用户模式和内核模式
8.2.5上下文切换
8.3 系统调用错误处理
8.4 进程控制
8.4.1 获取进程ID…
8.4.2 创建和终止进程
8.4.3回收子进程
8.4.4 让进程休眠和
8.4.5 加载并运行程序
8.4.6 利用fork和execve运行程序
8.5 信号
8.5.1信号术语
8.5.2 发送信号
8.5.3 接收信号
8.5.4 阻塞和解除阻塞信号
8.5.5 编写信号处理程序
8.5.6 同步流以避免讨厌的并发错误“”
8.5.7 显式地等待信号
8.6非本地跳转
8.7 操作进程的工具由
8.8小结
第9章虚拟内存
9.1物理和虚拟寻址
9.2地址空间
9.3 虚拟内存作为缓存的工具
9.3.1 DRAM缓存的组织结构页表中
9.3.2页表
9.3.3 页命中
9.3.4 缺页
9.3.5 分配页面
9.3.6 又是局部性救了我们
9.4虚拟内存作为内存管理的工具
9.5虚拟内存作为内存保护的工具
9.6地址翻译
9.6.1 结合高速缓存和虚拟内存
9.6.2 利用TIB加速地址翻译
9.6.3 多级页表
9.6.4综合:端到端的地址翻译
9.7案例研究:IntelCorei7/Linux内存系统
9.7.1 Corei7地址翻译
9.7.2 Linux虚拟内存系统
9.8 内存映射
9.8.1 再看共事对象市中
9.8.2 再看fork函数
9.8.3 再看execve函数……
9.8.4 使用mmap函数的用户级内存映射
9.9动态内存分配中中中
9.9.1malloc和free函数
9.9.2为什么要使用动态内存分配
9.9.3分配器的要求和目标
9.9.4碎片
9.9.5 实现问题
9.9.6 隐式空闲链表
9.9.7 放置已分配的块分割空闲块
9.9.8获取额外的堆内存
9.9.10 合并空闲块
9.9.11 带边界标记的合并
9.9.12 综合:实现一个简单的分配器·
9.9.13 显式空闲链表
9.9.14 分离的空闲链表
9.10垃圾收集……
9.10.1 垃圾收集器的基本知识
9.10.2 Mark&Sweep垃圾收集器……
9 .10.3 C程序的保守Mark&Sweep
9.11 C程序中常见的与内存有关的错误
9.11.1 间接引用坏指针
9.11.2 读未初始化的内存
9.11.3 允许栈缓冲区溢出…
9.11.4假设指针和它们指向的对象是相同大小的
9.11.5 造成错位错误…
9.11.6引用指针,而不是它所指向的对象
9.11.7误解指针运算
9.11.8引用不存在的变量
9.11.9引用空闲堆块中的数据
9.11.10 引起内存泄漏
9.12 小结…
四、程序间的交互和通信(10~12)
第10章 系统级I/O
10.1 Unix I/O
10.2文件…
10.3 打开和关闭文件
10.4读和写文件……
10.5 用RI包健壮地读写
10.5.1 RIO的无缓冲的输入输出函数…
10.5.2RIO的带缓冲的输入函数……
10.6读取文件元数据
10.7 读取目录内容
10.8共享文件………
10.9 I/0重定向
10.10 标准I/O
10.11综合:我该使用哪些I/O函数?
10.12 小结
第11章 网络编程
11.1客户端-服务器编程模型
11.2网络……
11.3 全球IP因特网……
11.3.1IP地址……
11.3.2 因特网域名……
11.3.3 因特网连接……
11.4套接字接口……
11.4.1 套接字地址结构……
11.4.2socket函数……
11.4.3connect函数……
11.4.4 bind函数……
11.4.5 listen 函数……
11.4.6 accept函数……
11.4.7主机和服务的转换………
11.4.8 套接字接口的辅助函数……
11.4.9 echo客户端和服务器的示例……
11.5 Web服务器……
11.5.1Web基础……
11.5.2Web 内容……
11.5.3HTTP事务……
11.5.4 服务动态内容……
11.6综合:TINYWeb服务器……
11.7 小结……
第12章并发编程
12.1 基于进程的并发编程册
12.1.1基于进程的并发服务器
12.1.2进程的优劣…
12.2基于I/O多路复用的并发编起
12.2.1基于I/O多路复用的并发事件驱动服务器
12.2.2I/O多路复用技术的优劣
12.3基于线程的并发编程
12.3.1 线程执行模型………
12.3.2 Posix线程
12.3.3创建线程中
12.3.4终止线程…
12.3.5 回收已终止线程的资源
12.3.6分离线程………
12.3.7 初始化线程……
12.3.8 基于线程的并发服务器…
12.4多线程程序中的共享变量
12.4.1 线程内存模型……
12.4.2 将变量映射到内存
12.4.3 共享变量………
12.5用信号量同步线程
12.5.1 进度图………
12.5.2 信号量………
12.5.3 使用信号量来实现互斥
12.5.4 利用信号量来调度共享资源……
12.5.5 综合:基于预线程化的并发服务器·
12.6使用线程提高并行性
12.7其他并发问题…………
12.7.1线程安全………………
12.7.2可重入性……………
12.7.3在线程化的程序中使用已存在的库函数……
12.7.4竞争…
12.7.5死锁……….
12.8小结…
📣推荐阅读
Java面试总结:点击进入 Java面试专栏 关注走一波
Java基础知识:点击进入 Java基础总结 关注走一波
Java项目专栏:点击进入 Java项目专栏 关注走一波
📣非常感谢你阅读到这里,如果这篇文章对你有帮助,希望能留下你的点赞👍 关注❤ 分享👥 留言💬thanks!!!
📚愿大家都能学有所得,功不唐捐!