【深入浅出】《深入理解计算机系统》CSAPP

1,005 阅读51分钟

《计算机系统基础》30'

一、处理器的时序电路

1、CPU中的时序电路

答:

CPU中的时序电路:通过RS触发器控制CPU的时序。

2、单周期处理器的设计

答:

CPU在处理指令时,一般需要经过以下几个步骤:

1)取指令(IF)

根据程序计数器PC中的指令地址,从存储器中取出一条指令,同时,根据指令字长度自动递增产生下一条指令所需要的指令地址,但遇到“地址转移”指令时,则控制器把“转移地址”送入PC,当然得到的“地址”需要做些变换才送入PC。

2)指令译码(ID)

对取指令操作中得到的指令进行分析并译码,确定这条指令需要完成的操作,从而产生相应的操作控制信号,用于驱动执行状态中的各种操作。

3)指令执行(EXE)

根据指令译码得到的操作控制信号,具体执行指令动作,然后转移到结果写回状态。

4)存储器访问(MEM)

所有需要访问存储器的操作都将在这个步骤中执行,该步骤给出的存储器的数据地址,把数据写入到存储器中数据地址所指定的存储单元或者从存储器中得到数据地址单元中的数据。

5)结果写回(WB)

指令执行的结果或者访问存储器中得到的数据写回相应的目的寄存器中。

单周期CPU,是在一个时钟周期内完成这五个阶段的处理。

3、流水线处理器的基本原理

答:

流水线(Pipeline)技术是指程序在执行时候多条指令重叠进行操作的一种准并行处理实现技术。通俗的讲将一个时序过程,分解成若干个子过程,每个过程都能有效的与其他子过程同时执行。旨在提高处理器处理效率,争取在一个时钟周期中完成一条指令。

将处理组织成阶段:取指、译码、执行、访存、写回通常一条指令包含很多操作,可以将它们组织成一定的阶段序列,从而便于放入一个通用框架来进行流水线处理。

参考:

流水线处理器的基本原理:blog.csdn.net/pankul/arti…

4、Data Hazard的处理

答:

流水线给处理器带来了效率,当然也有问题。这些问题称之为流水线冒险(HaZard)。

1)结构冒险

由于处理器资源冲突,而无法实现某些指令或阶段的组合实现,就称之为处理器有结构冒险。

比如,早起的处理器中,程序和数据是存储在一起的,那么容易出现下面的情况:在第一个Cycle中,IF和MEM同时访问存储器导致有一个操作要等待,此时hazard就出现了。现在的处理器已经解决了该问题:指令存储在L1P cache中,数据存储在L1D cache中,单独访问,不会影响相互操作。

2)数据冒险

如果流水线中原来有先后顺序的指令同一时刻处理时,可能会导致出现访问了错误的数据的情况。

在汇编语句中,add R1,R2,R3将寄存器R2和R3的和赋予R1,改变R1的值;而紧接着下面的语句:add R4,R1,R5则会使用R1的值,可是R1必须在一条语句中的第5个cycle才能更新到寄存器中,语句二是在第4个cycle就要访问R1,也就是说第二条指令此时在使用错误的R1的值,这是数据hazard出现了。

解决方案:在两条指令中添加一条空指令:nop。但是会影响处理器的指令的执行效率。在现代处理器技术中,已经用forwarding的方式解决了。如下图(没图。。先放着吧???)如果处理器在检测到当前的源操作数正好在流水线的EX或者MEM阶段,直接将EX和MEM寄存器的值传递给ALU的输入,而不是再从寄存器堆中获取数据了。因此此时寄存器堆中的数据可能是没有被及时更新的。

3)控制冒险

**在流水线中的执行指令时,由于并行处理的关系,后面很多指令其实都在流水线中开始处理了,包括预取值和译码。那么,如果此时程序中出现一条跳转语句会怎么办呢?**因为程序已经跑到其他地址处执行,流水线中之前已经做好的预取值和译码动作都不能使用了。这些会被处理器的专有部件flush掉,重新开始新的流水线。此时我们可以称之为出现了控制冒险。这种情况对于程序和效率来水是存在很大的损失的。

解决方案:也就是在jump指令后面(不会被真正使用,但是会进入流水线)添加nop。在MIPS程序中,经常在jump指令后面添加nop语句。

在x86架构中,是通过硬件来实现flush,将无效的流水线排空,以保证正确运行流水线。这里会涉及到分支预测技术的使用。

在其他一些处理器中,用软件的方式来处理,添加nop。同时在编译器中通过乱序的思想用有效指令代替nop。这样也可以避免跳转带来的性能损失。

5、流水线设计中的其他问题

答:

1)每个阶段所用的硬件实际并不是互相独立的;增加的寄存器也会导致延迟增大;每阶段的周期划分也很难做到一致。

2)理想的流水线系统,每个阶段的时间都是相等的。实际上,各个阶段的时间是不等的。运行时钟是由最慢的阶段决定的。

3)另外流水线过深,寄存器的增加会造成延迟增大。当延迟增大到时钟周期的一定比例后,也会成为流水线吞吐量的一个制约因素。

二、优化程序性能

1、优化程序性能

答:

1)程序优化的第一步就是消除不必要的内容,让代码尽可能有效地执行它期望的工作。这包括消除不必要的函数调用、条件测试和存储器引用。这些优化不依赖于目标机器的任何具体属性。

2)程序优化的第二步,利用处理器提供的指令级并行能力,同时执行多条指令

3)最后对大型程序的优化,使用代码剖析程序,代码剖析程序是测量程序各个部分性能的工具,这种分析能够帮助找到代码中低效率的地方,并且确定程序中应该着重优化的部分。

4)Amdahl定律,它量化了对系统某个部分进行优化所带来的整体效果

2、优化编译器的能力和局限性以及表示程序性能

答:csapp chapter5 p325

优化编译器的能力

现代编译器运用复杂精密的算法来去定一个程序中计算的是什么值,以及它们是被如何使用的。然后它们会利用一些机会来简化表达式,在几个不同的地方使用同一个计算,以及降低一个给定的计算必须被执行的次数。

优化编译器的局限性

编译器必须很小心地对程序只是用安全的优化,也就是对于程序可能遇到的所有可能的情况,在C语言标准提供的保证之下,优化后得到的程序和未优化的版本有一样的行为,限制编译器只进行安全的优化,消除了一些造成不希望的运行时行为的可能原因,但是这也意味着程序员必须花费更大的力气写出程序使编译器能够将之转换成有效机器代码,两个指针可能指向同一个存储器位置的情况称为存储器别名使用(memory aliasing)。这造成了一个主要的妨碍优化的因素,这也是可能严重限制编译器产生优化代码机会的程序的一个方面:如果编译器不能确定两个指针是否执行同一个位置,就必须假设什么情况都有可能,限制了可能的优化策略。

表示程序性能

引入度量标准每元素的周期数(Cycles Per Element CPE),作为一种表示程序性能并指导我们改进代码的方法是用最小二乘拟合,得到一条形如y=mx+b的线,线性因子的系数m叫做每个元素的周期数CPE的有效数

3、特定体系结果或应用特性的性能优化

答:

1)简单地使用命令行选项,如‘-O1’就会进行一些基本的优化

2)消除循环的低效率:称作"代码移动",这类优化包括识别要执行多次(例如在循环里)但是计算结果不会改变的计算,因而将计算移动到代码前面不会比多次求值的部分

3)减少过程调用:修改代码减少函数的调用,不过会危害一些程序的模块性

4)循环展开:通过增加每次迭代计算的元素的数量,减少循环的迭代次数。从而来个方面改程序的性能,首先它减少了不直接有助于程序结果的操作的数量,例如循环索引计算和条件分支。其次,它提供了一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量

5)提高并行性:多个累计变量;重新结合变换

4、限制因素

1)寄存器溢出

循环并行性的好处收到描述计算的汇编代码的能力限制。特别地,IA32指令集只有很少量的寄存器来存放积累的值(IA32只有4个,x86-64可以12个)。如果我们的并行度p超过了可用的寄存器数量,那么编译器会诉诸溢出(spilling),将某些临时值存放到栈中。一旦出现这种情况,性能会急剧下降。

2)分支预测和预测错误触发

当分支预测逻辑不能正确预测一个分支是否要跳转的时候,条件分支可能会招致严重的预测错误处罚。

3)对于这个问题没有简单的答案,有一些通用原则

(1)不要过分关系可预测分支

(2)书写适合用条件传送实现的代码

5、确认和消除性能瓶颈

答:

处理大程序时连指导该优化什么地方都很困难

1)程序剖析

程序剖析包括运行程序的一个版本,其中插入了工具代码,以确定程序的各个部分需要多少时间,这对于确认需要集中注意力优化的部分很有用,剖析一个有力指出在于可以在现实的基准数据上运行实际程序的同时,进行剖析(Unix系统提供了一个剖析程序GPROF)。通常,假设在有代表性的数据上运行程序,剖析能帮助我们对典型的情况进行优化,但是我们还应该确保对所有可能的情况,程序都有相当的性能。这主要包括避免得到糟糕的渐进性能的算法(例如插入算法)和坏的编程实例

2)Amdahl定律(阿姆达尔定律)

其主要思想是当我们加快系统一部分的速度时,对系统整体性能的影响依赖于这个部分有多重要和速度提高了多少

阿姆达尔曾致力于并行处理系统的研究。对于固定负载情况下描述并行处理效果的加速比s,阿姆达尔经过深入研究给出了如下公式: S=1/(1-a+a/n) 其中,a为并行计算部分所占比例,n为并行处理结点个数。这样,当1-a=0时,(即没有串行,只有并行)最大加速比s=n;当a=0时(即只有串行,没有并行),最小加速比s=1;当n→∞时,极限加速比s→ 1/(1-a),这也就是加速比的上限。例如,若串行代码占整个代码的25%,则并行处理的总体性能不可能超过4。这一公式已被学术界所接受,并被称做“阿姆达尔定律”,也称为“安达尔定理”(Amdahl law)。 image-20201212154937908

三、存储器结构及虚拟存储器

1、局部性

答:

一个编写良好的计算机程序通常具有良好的局部性。

引用临近于其他最近引用的数据项的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理。

局部性两种形式:

时间局部性:被引用过一次的内存位置很有可能在不远的将来再被多次引用(通常在循环里)。

空间局部性:一个内存位置被引用了一次,那么将来他附近的位置也会被引用

局部性和性能的关系

1)有良好局部性的程序比局部性差的程序运行得更快。

2)局部性原理允许计算机设计者通过引入称为高速缓存存储器来保存最近被引用的指令和数据项,从而提高对主存的访问速度。

3)重复引用相同变量的程序有良好的时间局部性

4)对于具有步长为k的引用模式的程序,步长越小,空间局部性越好。

5)具有步长为I的引用模式的程序具有很好的空间局部性。

6)在内存中以大步长跳来跳去的程序空间局部性会很差。

7)对于取指令来说,循环有好的时间和空间局部性。

8)循环体越小,循环迭代次数越多,局部性越好。

2、存储器层次结构

答:

现在随着处理器和存储器在性能发展上的差异越来越大,存储器在容量尤其是访问延迟方面的性能增长越来越跟不上处理器性能发展的需要。为了缩小存储器和处理器两者之间在性能方面的差距,通常在计算机内部采用层次化的存储器体系结构。

image-20201212160208420

可以看出,速度越快则容量越小、越靠近CPU。CPU可以直接访问内部存储器,而外部存储器的信息则要先取主存,然后才能被CPU访问。CPU执行指令时,需要的操作数大部分都来自寄存器;当需要从(向)存储器中取(存)数据时,先访问cache,如果不在cache中,则访问主存,如果不在主存中,则访问硬盘,此时,操作数从硬盘中读出送到主存,然后从主存送到cache,数据使用时一般只在相邻两层之间复制传送,而且总是从慢速存储器复制到快速存储器。传送的单位事一个定长块,因此需要确定定长块的大小,并在相邻两层间建立块之间的映射关系。

3、计算机高速缓存器原理

答:

加快CPU访问速度的主要方式之一是在CPU和主存之间增加高速缓冲存储器(简称高速缓存或者cache)。

cache是一种小容量高速缓冲存储器,由快速的SRAM组成,直接制作在CPU芯片里,速度较快,几乎与CPU处于同一个量级。

在CPU和主存之间设置cache,总师把主存中被频繁访问的活跃程序块和数据块复制到cache中。由于程序访问的局部性,大多数情况下,CPU能直接从cache中取得指令和数据,而不必访问慢速的主存。

为便于cache和主存间交换信息,cache和主存空间都被划分为相等的区域。

例如,将主存按照每512字节划分成一个区域,同时把cache也划分成同样大小的区域,这样主存中的信息就可以按照512字节为单位送到cache中。

我们把主存中的区域称为块,也称为主存块,它是cache和主存之间的信息交换单位;cache中存放一个主存块的区域称为行或槽,也称为cache行。

4、高速缓存对性能的影响

答:

影响cache性能的因素决定系统访问性能的重要因素之一是cache命中率,它与许多因素有关。

1)命中率与关联度有关

关联度越高,命中率越高。关联度反映一个主存块对应的cache行的个数,显然,直接映射的关联度为1;2路组相联映射的关联度为2,4路组相联映射的关联度为4,全相联映射的关联度为cache行数。

2)命中率与cache容量有关

cache容量越大,命中率就越高

3)命中率与主存块的大小有关

采用大的交换单位能很好地利用空间局部性,但是较大的主存块需要花费较多的时间来存取,因此,缺失损失会变大。由此可见,主存块的大小必须适中,不能太大,也不能大小。

此外,设计cache时还要考虑一下因素:

采用单级还是多级cache、数据cache和指令cache是分开还是合在一起、主存-总线-cache--CPI之间采用什么架构等甚至主存DRAM芯片的内部结构、存储器总线的总线事务类型等,也都与cache设计有关,都会影响系统总体性能。

下面对这些问题进行简单分析说明:

目前cache基本上都在CPU芯片内,且使用L1和L2cache,甚至L3cache,CPU的访问顺序为L1cache、L2cache和L3cache。

通常L1cache采用分离cache,即数据cache和指令cache分开设置,分别存放数据和指令。指令cache有时称为代码cache(code cache)。L2cache和L3cache为联合cache,即数据和指令放在一个cache中。

由于多级cache中各级cache所处的位置中,使得对它们的设计目标有所不同。例如假定是两级cache。那么,对于L1cache,通常更关注速度而不要求有很高的命中率,因为,即使不命中,还可以到L2cache,L2cache的速度比主存速度快的多;而对于L2cache,则要求尽量提高其命中率,因为若不命中,则必须到慢速的主存中访问,其缺失损失会很大而影响总体性能。

5、地址空间

答:

每个高级语言源程序经编译、汇编、链接等处理生成可执行的二进制目标代码时,都被映射到同样的虚拟地址空间,因此,所有进程的虚拟地址空间是一致的,这简化了链接器的设计和实现,也简化了程序的加载过程。

虚拟存储机制为程序提供了一个极大的虚拟地址空间(也称为逻辑地址空间),它是主存和磁盘存储器的抽象。虚存机制带来了一个假象,使得每个进程都好像都独占使用主存,并且主存空间极大。

这有三个好处:

1)每个进程具有一致的虚拟地址空间,从而可以简化存储管理

2)它把主存看成是磁盘存储器的一个缓存,在主存中仅保存当前活动的程序段和数据区,并根据需要在磁盘和主存之间进行信息交换,使有限的主存空间得到了有效利用

3)每个进程的虚拟地址空间是私有的,因此,可以保护各自进程不被其他进程破坏。

6、虚拟存储器

答:

一个系统中的进程是与其他进程共享CPU和主存资源的,然而,共享主存会形成一些特殊的情况,如果太多的进程需要太多的存储器,那么他们中的一些就根本无法运行。当一个程序没有空间可用的时候,那就是他运气不好。存储器还容易被迫害,如果一个进程不小心写了另外一个进程使用的存储器,它就可能失去原先的逻辑。为了更有效的管理存储器,现在系统引入了一种对主存的抽象概念,叫做虚拟存储器。

目前,在服务器、台式机和笔记本等各类通用计算机系统中都采用虚拟存储技术。在采用虚拟存储技术的计算机中,指令执行时,通过存储器管理部件(Memory Management Unit,简称MMU)将指令中的逻辑地址(也称虚拟地址或虚地址,简写为VA)转化为主存的物理地址(也称主存地址或实地址,简写为PA)。在地址转换过程中由硬件检查是否发生了访问信息不在主存或地址越界或访问越权,则由操作系统进行相应的异常处理。由此可以看出,虚拟存储技术既解决了编程空间受限的问题,又解决了多道程序共享主存带来的安全问题。

下图是具有虚拟存储机制的CPU与主存的连接示意图,从图中可知,CPU执行指令时所给出的是指令或操作数的虚拟地址,需要通过MMU将虚拟地址转换为主存的物理地址才能访问主存,MMU包含在CPU芯片中。图中显示MMU将一个虚拟地址转换为物理地址4,从而将第4、5、6、7这4个单元的数据组成4字节数据送到CPU。下图仅是一个简单的示意图,其中没有考虑cache等情况。

image-20201212200117959

7、虚拟内存的管理

答:

1)请求分页存储管理

每次访问仅将当前需要的页面调入主存,而进程中其他不活跃的页面放在磁盘上。当访问某个信息所在页不在主存时发生缺页异常,此时,硬件将调出OS内核中的缺页处理程序,将缺失页面从磁盘调入主存。

与主存块大小相比,虚拟页的大小要大得多。因为DRAM比SRAM大约慢10~100倍,而磁盘比DRAM大约慢100000多倍,所以进行缺页处理所花的代价要比cache缺失大得多

而且,根据磁盘的特性,磁盘扇区定位所用的时间要比磁盘读写一个数据的时间长大约100000倍,也即对扇区第一个数据的读写比随后数据的读写要慢100000倍。考虑到缺页代价的巨大和磁盘访问第一个数据的开销,通常将主存和磁盘之间交换的页的大小设定得比较大,典型的有4KB和8KB等,而且有越来越大的趋势。

因为缺页处理代价较大,所以提高命中率是关键,因此,在主存页框和虚拟页之间采用全相联映射方式。此外,当进行写操作时,由于磁盘访问速度很慢,所以,不能每次写操作都同时写DRAM和磁盘,因而,在处理一致性问题时,采用回写方式,而不用全写方式。

2)请求分段存储管理

根据程序的模块化性质,可按程序的逻辑结构划分成多个相对独立的部分,例如,过程、数据表、数据阵列等。这些相对独立的部分被称为端,它们作为独立的逻辑单位可以被其他程序段调用,形成段间连接,从而产生规模较大的程序。段通常有段名、段起点、段长等。段名可用用户名、数据结构名或段号标识,以便于程序的编写、编译器的优化和操作系统的调度管理等。

可以把段作为基本信息单位在主存-辅存之间传送和定位。分段方式下,将主存控件ain按实际程序中的段来划分,每个段在主存中的位置记录在段表中,段的长度可变,所以段表中需有长度指示,即段长。每个进程有一个段表,每个段在段表中有一个段表项,用来指明对应段在主存中的位置、段长、访问权限、使用和装入情况等。段表本身也是一个可再定位段,可以存在外存中,需要时调入主存,但一般驻留在主存中。

在分段式虚拟存储器中,虚拟地址由段号和段内地址组成。通过段表把虚拟地址转换成主存物理地址,分段式管理系统的优点是段的分界与程序的自然分界相对应;段的逻辑独立性使它易于编译、管理、修改和保护,也便于多道程序共享;某些类型的段(如堆、栈、队列等)具有动态可变长度,允许自由调度以便有效利用主存空间。但是,由于段的长度各不相同,段的起点和终点不定,给主存空间分配带来麻烦,而且容易在主存中留下许多空白的零碎空间,造成浪费。

分段式和分页式存储管理各有优缺点,因此可以采用两者结合的段页式存储管理方式。

3)请求段页式存储管理

在段页式虚拟存储器中,程序按模块分段,段内再分页,用段表和页表(每段一个页表)进行两级定位管理。段表中每个表项对应一个段,每个段表项中包含一个指向该段页表起始位置的指针,以及该段其他的控制和存储保护信息,由页表指明该段各页在主存中的位置以及是否装入、修改等状态信息。

程序的调入调出按页进行,但它又可以按段实现共享和保护。因此,它兼有分页式和分段式存储管理的优点。它的缺点是在地址映射过程需要多次查表。

8、翻译和映射

答:

image-20201212204602664

9、后备转换缓冲器TLB

答:

地址转换过程中,访存时首先要到主存查页表,然后才能根据转换得到的物理地址再访问主存以存储指令或数据。如果缺页,则还要进行页面替换、页面修改等,访问主存的次数就更多。因此,采用虚拟存储机制后,使得访存次数增加了。为了减少访存次数,往往把页表中最活跃的几个页表项复制到高速缓存中,这种在告诉缓存中的页表项组成的页表称为后备转换缓冲器(Translation Lookaside Buffer,TLB),通常称为快表,相应地称主存中的页表为慢表。

10、动态存储器分配和垃圾收集

答:

我们可以通过mmap和munmap来创建和删除虚拟存储器区域,但对开发来说使用起来并不方便,况且没有很好的移植性,所以提出了是使用动态存储分配器来管理进程空间中的堆区域。动态存储分配器维护着一个进程的虚拟存储器区域,称为堆。堆事从低位地址向高位向上增长的,对于每个进程,内核维护着一个brk,它指向堆的顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟存储器片,要么是已分配的,要么是空闲的。已分配的显式地保留为供应应用程序使用。空闲块可用来分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是存储器隐式执行的,它们都是显式的来分配存储块的,不同之处在于由哪个实体来负责释放已分配的块。

  • 显式分配器

要求显式的释放已分配的块。如C标准库中的malloc和free,C++中的new和delete操作符。

  • 隐式分配器

要求分配器检测一个已分配的块何时不再被程序使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(Garbage collector),如java语言就依赖类似分配器。

下面我们看下malloc和free的实现是如何管理一个C程序的16字的小堆的。每个方框代表一个4字节的字。粗线标出的矩形对应于已分配块(有阴影)和空闲块(无阴影),初始时,堆都是一个大小为16个字的、双字对齐的、空闲块组成的。

  • a)程序请求一个4字的块,malloc的响应是:从空闲块的前部切出一个4字的块,并返回一个指向这个块的第一个字的指针p1
  • b)程序请求一个5字的块,malloc的响应是:从空闲块的前部分分配一个6字的块,返回指针p2,填充的一个额外字是为了保持空闲块是双字边界对齐的。
  • c)程序请求一个6字的块,而malloc就从空闲块的前部切出一个6字的块。返回指针p3
  • d)程序释放在b中分配的那6字的块。需要注意的是,在调用free返回之后,指针p2仍然指向被释放的块,在它被一个新的malloc调用重新初始化之前不能在程序中再使用p2
  • e)程序请求一个2字的块。在这种情况下,malloc分配在前一步中释放了的块的一部分,并返回指向新块的指针p4

**垃圾收集器是一种动态存储器分配器,它自动释放程序不再需要的已分配块。这些块称为垃圾。自动回收堆存储的过程叫做垃圾收集。**在java虚拟机中就使用了类似的机制,应用显式分配堆块,但是从不显式地释放他们。垃圾收集器定时识别垃圾块,并相应地调用free,将这些块放回到空闲链表中。

垃圾收集器将存储器视为一张有向可达图,改图的节点被分成一组根节点和一组堆节点。每个堆节点对应于堆中一个已分配块。有向边p->q意味着块中的某个位置指向块q中的某个位置。根节点对应于这样一种不再堆中的位置,他们中包含指向堆中指针。这些位置可以是寄存器、堆里的变量,或者是虚拟存储器中读取数据区域内的全部变量。

当存在一条从任意根节点出发并到达p的有向路径时,我们说节点p是可达的。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色就是维护可达图的某种表示,并通过释放不可达节点将它们返回给空闲链表,来定期回收它们。

四、链接、进程及并发编程

1、静态链接

答:

1)将可重定位的文件和命令行完全链接的、可加载、可运行的目标文件;

2)可重定位目标文件由各代码和数据节组成;

完成静态链接,链接器要完成以下两个工作:

1)符号解析,将每一个符号引用正好和一个符号定义关联起来;

2)重定位:可重定位的目标文件地址都是从零开始的,连接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得他们指向这个内存位置。

2、目标文件

答:

分类:可重定位目标文件;可执行目标文件;共享目标文件;

可重定位目标文件:

包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。

可执行目标文件:

包含二进制代码和数据,其形式可以被直接复制到内存并执行。

共享目标文件:

一个特殊类型的可重定位目标文件,可以在加载或者运行时被动态的加载进内存并链接。

3、符号和符号表

答:

定义:符号表记录了目标模块定义的符号和引用的符号信息。

三种符号类型:

1)由模块m定义并能被其他模块引用的全局符号,非静态的C函数和全部变量;

2)由其他模块定义并被模块m引用的全局变量;

3)由目标模块定义和引用的局部符号,表现为静态全局变量和函数;

符号解析:链接符号引用与符号定义

1)全局符号的多种定义问题

强符号:已被初始化的全部变量和函数

弱符号:未被初始化的全部变量

2)规则

(1)不允许有多个同名的强符号

(2)如果有一个强符号和多个弱符号同名,选强符号

(3)如果有多个弱符号同名,从其中任意选一个

3)符号的地址由链接器确定,但符号的大小以及其类型在编译器就已经确定了,链接只负责解析和重定位符号确认符号的地址,同名符号有且仅有一个地址

4)静态链接可选方式

(1)一组可重定位目标文件

(2)所有相关的目标模块打包成一个单独文件-静态库(存档文件)

5)使用静态库来解析符号

过程:

符号解析时,链接器从左到右按照他们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件(如.c .o)。

链接器维护三个集合:所有目标模块集合E;未定义的集合U;定义的集合D;

扫描开始,链接器来判断输入f是什么,若是目标文件,f添加到E,修改U/D来反应f中的符号定义和引用;若f是存档文件,链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号,若存档文件成员m中有已定义的符号,m放E,修改U/D;如果链接器完成扫描后,U非空,则链接器输出错误并终止,否则他会合并和重定位E中的目标文件

4、重定位和加载

答:

1)重定位(可结合静态链接的两个步骤作答)

需要的术语:

(1)重定位节和符号定义:聚合所有目标文件的相同节,链接器开始将运行时内存地址赋给每一个节和每一个符号☆☆☆

(2)重定位节中的符号引用,链接器修改代码节和数据节中的每个符号引用,使得他们执行正确的运行时地址。

2)过程:

(1)一个概念,当汇编器遇到未知的符号引用,就会生成一个重定位条目,代码的重定位条目存放在.rel.text中,已初始化数据的重定位条目放在.rel.data中

重定位结构的数据结构:

typedef struct

{long offset;

​	long type:32; //符号相对节的偏移

​	symbol:32;

​	long addend;

}EIf64_Rela

(2)符号引用的重定位

只讲两种基本的重定位类型:相对引用;绝对引用。

3)加载☆☆☆

(1)背景:elf可执行文件的格式跟可重定位的格式是和相似的

(2)可执行目标文件的加载

加载器将目标文件的代码和数据复制到内存中,然后跳转到入口点来运行即_start函数的地址;

5、动态链接库

答:

1)出现的原因:

为了解决静态库维护还是相对麻烦以及很多共用库在内存中有很多随便造成的内存浪费。

2)动态链接:

共享库在加载或运行时加载在任意内存位置,并和在内存中的程序链接起来,由动态链接器完成。

3)相关细节:

动态链接不会复制共享库的代码和数据段,仅会复制一些重定位信息和符号表;

信息和符号表

共享库中会有一个.interp节,这个节包含动态链接器的路径名,动态链接器本身就是一个共享目标如(Id-linux.so)。

当运行一个可执行文件时,加载器会通过.interp节的信息找到动态链接器来运行,而动态链接器重定位相关的.so来完成链接任务:

位置无关代码PIC(position independent code):共享库若想要被进程共享就要求使用位置无关代码,位置无关代码是可以加载到内存的任意位置,而无需链接器修改即可以加载和重定位;

6、异常和进程

答:

异常就是控制流突变,用来响应处理器状态中的某个变化,一部分由硬件实现,一部分由操作系统来实现,每个异常都会被分配一个异常号,一些由硬件设计者来分配,常见的有:内存缺页、算法溢出、内存访问违规、除零,一些由内核设计者分配,常用的有系统调用和外部I/O设备的信号,异常有如下分类:中断、故障、陷阱、终止。其中中断是异步发生的,中断函数处理结束后返回下条指令,陷阱是同步的总是返回下条指令,故障是同步的,由潜在可恢复的错误造成(缺页),返回当前的指令,终止是同步的,不可恢复,不会返回。

1)异常处理与过程调用的一些不同之处

(1)返回地址不一样,异常返回地址需要根据异常类型来确定

(2)异常处理时,处理器将一些额外的处理状态压入栈中

(3)若是由用户态进入内核态,则异常处理使用内核栈

(4)异常处理运行在内核态

2)四种不同的类型

中断:不是由一条专门的指令造成的,他是处理器在每次执行一条指令后检测是否有中断产生;

陷阱和系统调用:陷阱是有意义的异常,像中断处理程序一样,陷阱处理程序将返回下一条指令。陷阱最重要的作用是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。系统调用都有一个服务号,指令“syscall n”来请求服务n;

中断与系统调用的区别:系统调用采用陷阱门,中断进入中断服务cpu自动关闭中断IF清0,防止嵌套;陷阱门进入服务程序时IF不变,是开中断下进行的,所有系统调用可被中断;

系统调用与一般程序区别:调用的栈不同,一个运行在内核模式一个运行在用户模式;

故障:若故障能够被恢复,则返回到引起故障的指令,否则终止;

终止:不可恢复的错误。

进程:

1)定义:

一个执行中的程序的实例。

进程与程序的区别:程序是一堆代码可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中,进程是执行程序的一个具体实例,程序总是运行在某个进程的上下文中。

2)进程提供应用程序的关键抽象:

一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。

一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

3)逻辑控制流

PC值的序列-逻辑流

4)用户模式下访问内核

唯一的模式就是通过异常(中断、故障、陷阱)

处理器通常是用某个控制寄存器中的一个模式位,该寄存器描述了当前进程享有的特权。当设置了模式位时,进程就运行。在内核模式中(也叫超级用户模式)一个运行在内核模式的进程可以执行指令集中的任何指令,并可以访问系统中的任何内存位置。没有设置,就是普通模式,不允许执行特权指令,比如停止处理器等。

5)上下文切换过程

每个进程维持一个上下文,上下文是内核重新启动一个被抢占的进程所需的状态。

过程:

(1)保存当前进程的上下文

(2)恢复某个先前被抢占的进程被保存的上下文

(3)将控制传递给这个新恢复的进程

7、进程控制和信号

答:

1)进程控制

(1)获取进程号(进程唯一的进程号 正数 ID/PID) getpid;

(2)创建进程和终止进程

程序员角度分三种(?TODO)

运行:进程在cpu运行或等待运行且最终被内核调度

停止:进程的执行被挂起,且无触发条件不会被调度

终止:永远停止了,如收到一个信号终止了进程,或调用exit

创建:fork、vfork(fork:调用一次返回两次,和父进程用相同的地址空间,与父进程并发执行,与父进程共享文件,pid不一样;)

(3)回收子进程

一个终止的进程若没有被父进程回收,会变成僵尸进程,仍然会占用内存空间,直到被回收。若其父进程先终止,则另init进程收养、回收。init进程pid为1,是所有进程的祖先

(4)进程的休眠:sleep pause;

2)信号

(1)一次软件形式的异常,一个信号就是一条消息,它通知进程系统发生了一个某种类型的事件

底层的硬件异常是由内核异常处理程序来处理,一般情况下对用户而言是不可见的,但信号提供了一种机制告诉用户进程系统发生了什么样的异常。

(2)发生信号的原理:内核改变目的进程的上下文中某个状态来传递一个信号给目的的进程

(收发信号可简单理解为:上下文中存与取)

接收信号后常见的信号处理行为有以下几种:

  • 进程终止:SIGKELL
  • 进程终止并转储内存
  • 进程停止(挂起)知道被SIGXONT信号重启
  • 进程忽略该信号

(3)进程组的概念:每个进程都属于一个进程组,且父子进程同属于一个进程组。

(4)阻塞和解除阻塞

隐式阻塞:阻塞任何与当前正在处理的信号类型的待处理的信号,只是等不用条件触发。

显示阻塞:明确的阻塞和解除阻塞选定的信号。

(5)非本地跳转

一种用户级异常控制流,他可以将控制直接从一个函数转移到另外一个当前正在执行的函数而不用通过函数栈机制。

8、进程间的通信

答:

1)管道及有名管道

管道用于有亲缘关系进程间的通信,有名管道克服了管道没有名字的限制,因此,有名管道可用于无亲缘关系进程间通信。

2)信号

用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发信号给进程本身。

3)消息队列

是消息的链表,存放在内核中,并由消息队列标识符标示。客服了信号传输信息少,管道只能承载无格式字节及缓存区大小受限等问题;

4)信号量

信号量是一个计数器,可以用来控制多个进程对共享资源的访问;

5)共享内存

多个进程可以访问同一块内存,是最快的IPC形式,一般结合信号量使用;

6)套接字

更一般的进程间通信机制,可以完成本机或跨机器的进程通信;

9、进程间信号量的控制

答:

信号量是一个计数器,是一个具有非负值的全局变量,能做到如下两个操作:

P(s):如果s为非零,那么P将s减1,并且立即返回。如果s为零,那么就挂起这个线程,直到s变为非零,而一个V操作会重启这个线程。重启后P操作将s减1,并将控制返回给调用者。

V(s):V操作将s加1,如果有任何现场阻塞在P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。

10、信号量

信号量是一个特殊的变量,程序对其访问都是原子操作,且只允许对它进行P和V操作,最简单的信号量是只能取0和1的变量,这也是信号量最常用的一种形式,叫做二进制信号量,而可以取多个常量的信号量被称为通用信号量。

11、各种并发编程模式

答:

概念:

如果逻辑控制流在时间上有重叠,那它们就是并发的;

场景:

  • 访问慢速I/O设备
  • 与人交互
  • 推迟工作以降低延迟
  • 服务多个网络客户端
  • 在多核机器上并行计算

三种并发编程模式:

1)基于进程的并发编程

每个逻辑控制流都是一个进程,由内核来调度和维护

优缺点,

优点:共享文件表,不共享用户空间,更加的安全;

缺点:开销大,共享信息困难,要使用显式的IPC

2)基于I/O多路复用的并发编程

基本的思想是select函数,要求挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序;I/O多路复用可以用做并发事件驱动程序的基础。

优缺点:

优点:程序员能更好的控制程序,共享数据简单;

缺点:编码复杂,并且程度越小,复杂度越高;

3)基于线程并发模型

概念:

线程就是运行在进程上下文中的逻辑流,线程也由内核调度,每个线程都有自己的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器。

线程的切换比进程快很多,线程是对等的,任何线程都可以访问共享虚拟内存的任何位置。

分离线程:

线程要么是可结合的,要么是可分离的,区别:

(1)可结合线程:可以被其他线程回收和杀死,被回收之前,它所占用的资源不会释放;

(2)可分离进程:不可以回收不可以杀死,它的内存资源在其终止时自动释放,默认创建的都是可结合线程;

12、共享变量和线程同步

答:

1)线程内存模型

每个线程都有自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条形码和通用目的寄存器。线程与其他线程共享进程上下文的剩余部分。包括整个用户虚拟地址空间。

2)将变量映射到内存

全局变量:定义在函数之外的变量,运行时,全局变量只有一个实例,任何线程都可以引用。

本地自动变量:定义在函数内,但没有static属性的变量,运行时每个线程的栈都包含它自己的所有本地自动变量实例。

本地静态变量:函数内部用static属性的变量,运行时只有一个实例。

3)共享变量

我们说一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。如果只有一个线程引用就不是共享。共享变量是简单的,但这种共享方式会引来同步错误,因为线程之间的运行是竞争关系,没办法确认进程运行的顺序,为了解决这种错误,可以使用信号量来对共享资源加锁,使其确定线程对该变量的互斥访问。

4)线程同步

确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥访问。通过对二元信号量(也叫互斥锁)的使用,对共享资源进行加锁,使得每次只会有一个线程能够访问,直到访问完成,其他线程才能访问,这样就能对资源的互斥访问,达到线程同步。

13、其他并行问题

答:

1)线程安全

如果一个函数被称为线程安全的,那当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。

四种线程不安全函数:

  • 不保护共享变量的函数
  • 保持跨越多个调用状态的函数
  • 返回指向静态变量的指针函数
  • 调用线程不安全函数的函数

2)可重入性

有一类重要的线程安全函数,叫做可重入函数,当它们被多个线程调用时,不会引用任何共享数据,尽快线程安全和可重入性不等价,可重入函数属于线程安全函数;

显式可重入函数:不依赖调用者,所有的数据引用都是本地自动栈变量,且参数是传值传递的(不是指针);

隐式可重入函数:形参可以使指针,小心的传递指向非共享数据的指针;

3)linux提供不安全的可重入版本(安全)函数名多以_r结尾;

4)竞争:当程序的结果依赖一个线程要在另一个线程达到y点前到达它的控制中的x点时,就会产生竞争;

5)死锁:一个线程阻塞了,等待一个永远不为真的条件。

五、系统级I/O和网络编程

《CSAPP》

1、I/O相关概念

为了更加有效地管理存储器并且少出错,现代系统提供了一种对主存的抽象概念,输入/输出(I/O)是在主存(main memory)和外部设备(例如磁盘驱动器、终端和网络)之间拷贝数据的过程。输入操作是从I/O设备拷贝数据到内存,输出操作是从主存拷贝数据到I/O设备。

所有的运行时系统都提供I/O的较高级别的工具,例如ANSI C提供标准的I/O库,包含像printf和scanf这样执行带缓冲的I/O函数。C++用它的重载操作符<<(输入)和>>(输出)提供了类似的功能。在Unix系统中,是通过使用内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的。

image-20201214201221467

2、文件及文件操作

答:

一个Unix文件就是一个m字节的序列B0,B1,...,Bk,...,B(m-1)

所有的I/O设备,例如网络、磁盘和中断,都被模型化为文件,而所有的输入和输出都被当做相应的文件读和写来执行。这种将设备优雅地映射为文件的方式,运行Uninx内核以一种统一的且一致的方式来执行:

  • 打开文件
  • 改变当前的文件位置
  • 读写文件
  • 关闭文件

共享文件

image-20201214201556351

无共享

同一个进程的不同表项,通过文件表指向了同一个位置

image-20201214201634782

image-20201214201639624

Unix共享文件的内核用三种相关的数据结构来表示打开的文件:

  • 描述符表(descriptor table):每个文件都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表的一个表项。
  • 文件表(file table):打开文件的集合是由一张文件表表示的,所有的进程共享这张表。
  • v-node表(v-node table):同文件表一样,所有的进程共享v-node表。

多个描述符也可以通过不同的文件表项来引用同一个文件。关键思想是每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据。

3、文件、网络编程

image-20201214202130217

网络应用集成了我们已经学到的很多概念:进程、信号、字节顺序、存储器映射、动态分配等,同时客户端-服务端模型是一个新的知识,我们将所有的这些结合起来,创建一个微小的web服务器,提供浏览器静态和动态访问。

就是我们平常TCP和UDP两种编程:

  • TCP需要三次握手,才能成功连接
  • UDP直接丢包,例如视频软件就使用UDP

4、客户端-服务器模型(C/S Model)

image-20201214202440887

每个网络应用都是基于客户端-服务器模型的。

客户端-服务器模型中的基本操作是事务的。一个客户端-服务器由以下四步组成:

1)当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务

2)服务器收到请求后,解释它,并以适当的方式操作它的资源

3)服务器给客户端发送一个响应,并等待下一个请求

4)客户端收到响应并处理它

TIPS:客户端和服务器是进程,而不是机器或主机,因为一台主机可以同时运行不同的客户端和服务器。

5、套接字接口socket

答:

image-20201214202835068

一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,由一个因特网地址和一个16位的整数端口组成,用“地址:端口”表示。

1)套接字接口是一组函数,它们和Unix I/O函数结合起来,用以创建网络应用

从Linux内核看,一个套接字就是通信的一个端点,从Linux程序看,套接字就是一个有相应描述符的打开文件。

2)客户端和服务器使用socket函数来创建一个套接字描述符

例子:对于ipv4创建socket,其中使用tcp协议

sock = socket(AF_INET, SOCK_STREAM, 0);

3)客户端通过调用connect函数建立和服务器的连接

4)bind函数告诉内核将addr中的服务器套接字地址和套接字描述符sockfd联系起来

5)listen函数将sockfd从一个主动套接字转化为一个监听套接字

6)TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个链接请求。TCP服务器端监听到这个请求后,就会调用accept()函数去接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类似于普通文件的读写I/O操作。

7)getaddrinfo函数将主机名、主机地址、服务名和端口号的字符串表示转化成套接字地址结构,这个函数可重入的,适用于任何协议。

8)将一个套接字地址结构转换成相应的主机和服务名字符串。

TODO CODE_DEMO 

6、HTTP请求

答:

一个HTTP请求:一个请求行(request line)后面跟随0个或多个请求报头(request header),再跟随一个空的文本行来终止报头。

请求行:<method><url><version>

HTTP支持许多方法,包括GET,POST,PUT,DELETE,OPTIONS,HEAD,TRACE。

URI是相应URL的后缀,包括文件名和可选参数

version字段表示该请求所遵循的HTTP版本

请求报头:<header name>:<header data>为服务器提供了额外的信息,例如浏览器的版本类型HTTP1.1中,一个IP地址的服务器可以是多宿主主机,例如www.test1.com www.test2.com都可以存在于同一个服务器上。HTTP1.1中必须有host请求报头,如host:www.google.com:80。如果没有这个host请求报头,每个主机名都只有唯一IP,IP地址很快将会用尽。

7、Web服务器

答:

web服务器以两种不同的方式向客户端提供内容:

1)静态内容:取一个磁盘文件,并将它的内容返回给客户端

务器套接字地址和套接字描述符sockfd联系起来

5)listen函数将sockfd从一个主动套接字转化为一个监听套接字

6)TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个链接请求。TCP服务器端监听到这个请求后,就会调用accept()函数去接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类似于普通文件的读写I/O操作。

7)getaddrinfo函数将主机名、主机地址、服务名和端口号的字符串表示转化成套接字地址结构,这个函数可重入的,适用于任何协议。

8)将一个套接字地址结构转换成相应的主机和服务名字符串。

TODO CODE_DEMO 

6、HTTP请求

答:

一个HTTP请求:一个请求行(request line)后面跟随0个或多个请求报头(request header),再跟随一个空的文本行来终止报头。

请求行:<method><url><version>

HTTP支持许多方法,包括GET,POST,PUT,DELETE,OPTIONS,HEAD,TRACE。

URI是相应URL的后缀,包括文件名和可选参数

version字段表示该请求所遵循的HTTP版本

请求报头:<header name>:<header data>为服务器提供了额外的信息,例如浏览器的版本类型HTTP1.1中,一个IP地址的服务器可以是多宿主主机,例如www.test1.com www.test2.com都可以存在于同一个服务器上。HTTP1.1中必须有host请求报头,如host:www.google.com:80。如果没有这个host请求报头,每个主机名都只有唯一IP,IP地址很快将会用尽。

7、Web服务器

答:

web服务器以两种不同的方式向客户端提供内容:

1)静态内容:取一个磁盘文件,并将它的内容返回给客户端

2)动态内容:执行一个可执行文件,并将它输出返回给客户端