读书摘要—深入理解计算机系统(CSAPP)

来源:互联网 发布:直播搞笑配音软件 编辑:程序博客网 时间:2024/04/30 11:41
第二章    信息的表示和处理

2.1    信息存储

    每台计算机都有一个字长,指明整数和指针变量的大小。由于虚拟地址是根据字长来编码的,由字长决定的最重要的系统参数就是虚拟地址空间的大小。

    文本数据(字符串)比二进制数据具有更强的平台独立性(可移植性)。二进制代码很少能在不同机器和操作系统组合之间进行移植。

移位运算

    左移运算十分简单,在丢弃k个最高位后在右端补上k个0。

    右移操作的行为有些微妙。一般而言,机器支持两种形式的右移:逻辑右移算术右移。逻辑右移在左端补0,而算术右移则是在左端补原先最高有效位(符号位)的拷贝。算数右移看起来很奇怪,但它对于有符号整数数据的运算非常有用。

    ANSIC并未明确规定应该采用那种类型的右移。对于无符号数据,右移必须是逻辑的;而对于有符号数据,算术或逻辑的右移都可以。很不幸,这意味着基于任何一种右移形式的代码都潜在的存在着移植性问题。然而实际上,几乎所有的编译器/机器组合都对有符号数据使用算术右移,而且许多程序员也假设使用这种右移。

2.2    整数表示

2.2.2    无符号与二进制补码编码   

    有符号整数最常见的表示方法:二进制补码形式(Two's-complement)

    二进制补码形式的三个特点:

    1).    二进制补码的范围是不对称的:|TMin|=|TMax|+1,即不存在与最小值相对应的整数,这容易造成程序中细微的错误。

    2).    位数相同的前提下,无符号数的最大值刚好是二进制补码最大值的2倍加1:UMax=2TMax+1。

    3).    二进制补码中的-1与UMax有相同的位表示——全1位串。

    ANSI C标准并未规定使用二进制补码形式来表示有符号整数,但是几乎所有的机器都是这么做的。


    有符号整数的另外两种标准表示方法:

    二进制反码形式(Ones' complement):与二进制补码的表示方法类似,区别在于最高有效位的权值不同
    符号数值(Sign-Magniutde):最高有效位是符号位,确定剩下的位取负权值还是正权值。

    这两种表示都有一个奇怪的属性,即对于数字0存在两种编码。对于两种方法,[00..0]都被解释成+0 ,而-0在二进制反码中表示为[11..1] ,在符号数值中表示为[10..0]。

    虽然曾经有过基于二进制反码表示法的机器,但几乎所有现代机器都使用二进制补码

    符号数值编码方式使用在浮点数的表示中。

    最后,请留意二进制补码(Two's-complement)和二进制反码(Ones‘ complement)名称中撇号(')的位置区别

2.2.3     符号数与无符号数之间的转换

    ANSI C规定在无符号整数和有符号整数之间进行强制类型转换时,位模式不应该改变。类型转换并未改变对象的位模式,改变的是位模式的解释方式。

    有符号数转换为无符号数时,负数转换为大的正数(可以理解为原值加上2的n次方),而正数保持不变。

    无符号数转换为有符号数时,对于小的数将保持原值,对于大的数则转换为负数(可以理解为原值减去2的n次方)


2.2.4    C中有符号和无符号数

    C语言在处理同时包含无符号数和有符号数的表达式时,会隐式的将有符号数转换为无符号数,并假设这两个操作数都是非负的,然后执行运算。

    这一特性对于标准的算术运算来说无多大差异,但对于像"<"和">"这样的关系运算符,有时会导致和直觉不相符的结果。

2.2.5    扩展一个数字的位表示

    要将一个无符号数转换为一个更大的数据类型,只需简单的最高位前加0,这被称为零扩展

2.2.6    截断数字

    截断一个数字可能会改变其值,这也是值溢出的一种形式。,

    对于一个值为x的无符号数,将其截断成k位,结果等于以2的k次方为模对x取模。
    对于一个值为x的有符号数,有类戏的结论——模运算的结果也按照有符号数来解析。

2.2.7 关于有符号和无符号数的建议

    正如我们之前提到的,C语言中有符号数到无符号的隐式类型转换会导致某些与直觉不相符的行为,而这种细微的错误很难被发现。

    避免这种错误的一个方法就是绝不使用无符号数。实际上,除了C之外很少有其他程序语言支持无符号数——很明显,这些语言的设计者认为支持无符号的麻烦要比益处多得多。
   
    当我们想要把字仅仅看作是位的集合而没有任何数字意义是,无符号数是非常有用的。例如,在一个字中放入描述各种状态的flag;内存地址自然是无符号的,所以系统程序员会发现无符号类型是很有帮助的。

2.3    整数运算

2.3.1    无符号加法

    "字长膨胀":在一系列连续的加法操作中,前一次的运算结果和当前操作数的和超出了当前字长的表示范围,若要想正确保存本次的运算结果,必须增大字长。

    持续的"字长膨胀"意味着要想完整的表示算术运算的结果,不能对字长做任何限制。一些编程语言,例如Lisp,支持无限精度的运算,允许任意字长的整数运算。更常见的是,编程语言只支持固定精度(字长)的运算。

    无符号运算可以被视为某种形式的模运算。无符号加法等价与计算取模后的和,可以通过简单的丢弃超出字长的部分来完成取模计算。

    说一个算术运算溢出了,是指完整的整数结果无法放到数据类型的字长限制中。

    执行C程序时,不会将溢出作为错误而发出警告信号。

    判断无符号运算是否溢出,例如s=x+y(s、x、y均为无符号数),则唯一可靠的判断标准就是s<x或s<y。

    模数加法构成了一种数学结构——阿贝尔群。

2.3.2    二进制补码加法

    大多数计算机使用同样的机器指令来执行无符号或者有符号加法。

    二进制补码加法在溢出时同样会将结果中超过字长的部分丢弃,然而,这样的结果在数学上却能用取模来等同。

    以z=x+y为例,其中x,y,z均为位长为n的有符号数

    情况一:负溢出(Negative Overflow)

        x,y为负值,而最终结果z为正值。

    情况二正溢出(Positive Overflow)

        x,y为正值,而最终结果z为负值。

    总结:当和小于 -power(2,n-1)时,产生负溢出,使得和比预期值增加了power(2,n);当和大于power(2,n-1)时,产生正溢出,使得和比预期值减少了power(2,n)。

      
2.3.3 二进制补码的非

    非运算的结果是运算对象的加法逆元

    一种有名的用来实现二进制补码的非运算的方法时:对每个位取反,然后将结果加1。

2.3.4    无符号乘法

    C中的无符号乘法被定义为对整数乘积结果进行低位截断后的值,即等价于对乘法结果进行取模运算。

2.3.5    二进制补码乘法

    C中的有符号乘法被定义为对整数成绩结果进行低位截断后的值。

2.3.6    2的n次幂

    在大多数机器上,乘法指令和加法、减法、位运算、移位等指令相比要慢很多。因此,编译器使用的一项重要优化措施就是试图用移位和加法的组合来代替乘以常数因子的乘法。

    对于无符号变量x,C表达式x<<k(0<k<n,n为x的位长)等价于x*power(2,k)。特别地,我们可以用1U<<k来计算power(2,k)。

    对于二进制补码数x,以及任意0<k<n,C表达式x<<k(0<k<n,n为x的位长)等价于x*power(2,k)。

    即使在运算结果溢出时,上述等价关系依然成立。

2.3.7 除以2的幂

    在大多数机器上,除法指令比乘法指令还要慢。

    除以2的幂可以通过右移运算来实现。对于无符号和二进制补码数,分别使用逻辑移位和算术移位。

    对于无符号数,逻辑右移k位等价与把它除以power(2,k)。

    对于二进制补码数,以x/y为例,在x>=0时,算术右移的结果就是期待的除法结果。

    但对于x<0和y>0,结果需要进行修正

   这是因为整数除法在结果为正时进行向下朝0舍入,而在结果为负时进行向上朝0舍入。因此,在有舍入发生时,将一个负数算术右移k位不等价与把它除以power(2,k),例如,C表达式-5/2的值为-2;-5的4位表示为[1011];若算术右移一位,得到[1101],而这是-3的二进制补码表示。


2.4    浮点数

2.4.2    IEEE浮点格式

   
符号位|指数域|小数域

    浮点数可分为三种表达方式:

    1).    规格化值:最普遍的情况,在指数域的位模式既不是全0,也不是全1时,就属于规格化值

    2).    非规格化值:指数域的位模式为全0时,表示的数就是非规格化形式的。

    非规格化有两个目的:

      一,提供了一种表示数值0的方法。
   
         在浮点数中,+0.0的位模式为全0——符号位是0,指数域全为0(表明是一个非规格化值),而小数域也全为0。
         奇怪的是,其他位保持0而将符号位改为1,得到的浮点值为-0.0。

          在IEEE浮点数标准中,+0.0和-0.0除了在某些方面被认为不同外,在其他方面是相同的。

       二,用于表示值非常接近0.0的浮点数。非规格数提供了一种称为逐渐溢出的特性。

    3).    特殊数值:当指数域的位模式为全1时。

    小数域全为0时,数值用于表示无穷:符号位为0表示正无穷,符号位为1表示负无穷。

    小数域非全0示,数值用于表示"NaN"(Not a Number)    。这样的数值用于表示诸如对-1开方这样无意义的结果。


2.4. 3    数值示例

    对于浮点数,需要明白,可以表示的数并不是均匀分布的,在越靠近原点处越稠密。

    IEEE浮点数格式的设计,使得浮点数之间可以根据位模式按无符号数来解释的方法进行排序。当然,对于符号位为1的浮点数们,它们是按降序出现的。

2.4.4    舍入


    由于浮点数只能近似的表示实数,这就不可避免的引入了"舍入"的问题——找到最接近的匹配浮点值来表示给定的实数。

    四种舍入方式:向(最近)偶数舍入、向零舍入、向上舍入、向下舍入。其中向偶数舍入是默认方式,其他三种方式可用于计算上届和下界。

2.4.5    浮点计算

    IEEE标准为浮点数的加法和乘法这样的算术运算定义了简单的规则:将浮点值x和y看作实数进行算术运算,对结果进行舍入后得到的浮点值即为最终结果。

    浮点加法具有交换性,但不具有结合性。

    例如,在单精度浮点计算中 表达式((3.14+1e10)-1e10)的值为0.0——因为舍入,值3.14会丢失。另一方面,表达式(3.14+(1e10-1e10))的值为3.14。

    浮点加法不具有结合性,这是缺少的最重要的群属性。
   
    浮点加法满足单调性属性:如果a>=b ,则对于任意x,除非x等于NaN,都有x+a>=x+b。这一属性是无符号或二进制补码加法所具有的。



    浮点乘法也遵循通常乘法具有的许多特性,也就是环的属性。它具有可交换性,乘法单位元为1.0。但由于舍入而导致精度丢失,它不具有可结合性。此外,浮点乘法在加法上不具备分配型。

    最后,浮点乘法满足单调性,这也是无符号或二进制补码的乘法所不具备的。

2.4.4    C语言中的浮点数

    C提供两种浮点类型:float和double,在支持IEEE浮点标准的机器上分别表示单精度和双精度浮点数。

    C标准并未明确要求机器在浮点数的表示上采取IEEE标准 。


第三章    程序的机器级表示

3.3    数据格式

    Intel 用术语"字"表示16位数,"双字"表示32位,"四字"表示64位数。

3.4    访问信息

3.4.1 操作数

    三类操作数:立即数(immediate)、寄存器(register)、存储器引用(momery)。

3.4.2    数据传送指令

    最频繁使用的指令是执行数据传送的指令,这也符合冯.诺伊曼瓶颈的特性。

    IA32中传送指令的两个操作数不能同时属于存储器引用。

    movb movsbl movzbl三者的区别:movb传送单个字节,不影响目标操作中的其他三个字节:而movsbl指令将其他三个字节设置为本字节的符号位;movzbl将其他三个字节设为全0。

    pushl指令先该改变栈顶指针(%esp的值减4),在将目标双字写入栈中。
    popl 指令先将栈顶位置的数据读出,在改变栈顶指针(%esp的值加4)。

3.5    算术和逻辑操作

3.5.1    加载有效地址

    leal(Load Effective Address)是movl指令的变形,指令的功能是将源操作数的地址装入目标操作数(必须为寄存器)。

3.5.3    移位操作
  
    先给出移位量,然后是待移位的对象。
    可以进行算术和逻辑移位。

    左移指令sall和shll的效果一样。
    右移指令sarl执行算术移位(填符号位),而shrl执行逻辑移位(填0)。

3.5.5    特殊的算术运算

    二元运算符imull被称为"双操作数"乘法指令,它从两个32位操作数中产生一个32位的乘积结果。

    IA32还提供了两个"单操作数"乘法指令,以计算两个32位数的全64位乘积——mull用于无符号数乘法,而imull用于二进制补码乘法。这两条指令都要求一个操作数隐式的由寄存器%eax提供,另一个操作数在指令中显式的给出;乘积结果存放在寄存器%edx(高32位)和 %eax(低32位)。

    imull这里被重载,可以根据指令中操作数的数目,进行分辨。

    idivl执行有符号除法,而divl执行无符号除法。
 

3.6    控制

3.6.1    条件码
   
    CF:进位标志——最近的操作使得最高位产生了进位,可用来检查无符合运算的溢出。
    ZF:零标志——最近操作的结果为0
    SF:符号标志——最近操作的结果为负数。
    OF:溢出标志——最近的操作导致二进制补码溢出,正溢出或负溢出。   

    leal指令不会改变任何条件码

    cmpb cmpw cmpl 指令根据其两个操作数之差来设置条件码。若两操作数相等,则置零标志(ZF)为1。
    testb testw testl指令会根据两个操作数的逻辑与(AND)的结果来设置零标志(ZF)和负数标志(SF)。

3.6.2    访问条件码

    一系列形如setxx的指令

3.6.3    跳转指令

    直接跳转 :jmp label
    间接跳转 :jmp *(%eax)

    条件跳转 :je,jz,js等(格式上只能是直接跳转)。

    尽管没有必要深入了解跳转指令的编码的细节,但是跳转的目标是如何编码的,这个问题还是值得了解的。

    最常用的跳转地址编码方法就是计数器相关法(PC-relative),即编码中使用目标地址与当前指令地址的差值。

    另外一种编码方式就是使用绝对地址。

    值得注意的是,在使用PC法时,程序计数器的值是跳转指令之后的哪条指令的地址,而不是跳转指令本身的地址。

3.6.4    翻译条件分支

3.6.5    循环语句

    汇编中没有与循环对应的指令存在,作为替代,将条件测试和跳转组合起来实现循环的效果。

    有趣的是,虽然在多数程序源代码中很少使用 do-while形式俄循环语句,大多数汇编器是根据do-while结构来产生循环代码的。

    在分析较复杂的汇编代码时,创建一个"寄存器-变量对应表",是很有帮助的,特别是出现循环时。

3.6.6 switch 语句

    在汇编实现中常用的技巧是"跳转表"

3.7    过程(函数)

3.7.1    stack frame 结构

    IA32通过栈来支持函数调用。
 
    栈用于传递参数、保存函数返回值、保存寄存器以及分配局部存储空间。

    为每个函数分配的那部分栈空间称之为 stack frame
 
    当前stack frame是通过两个指针来定界的,寄存器%ebp作为frame(基)指针,寄存器%esp作为stack指针。在函数被调用期间,stack指针是可以移动的,因此对信息的访问是以frame指针为基准的。

    假设函数P调用函数Q,则传递给Q的参数放置在P的frame中。此外,P调用完Q后的下一条指令地址(对于Q而言就是其返回地址)会被压入栈中,构成P的frame的末尾。Q的frame从frame指针(%ebp)的值处开始。

3.7.2    转移控制

    call     Label           :直接调用
    call     *Operand    :间接调用

    call的效果是将返回地址压栈,并将控制权转交给被调用函数。返回地址是Call后面那条指令的地址。

    ret指令从栈中弹出返回地址,并跳转至那个位置继续执行。正确使用ret指令的前提是,stack指针正指向存储着返回地址的位置。

    leave指令用来对栈执行返回前的准备工作,它等价于下面的代码序列

    1    movl %ebp,%esp
    2    popl %ebp

3.7.3    寄存器使用惯例

   寄存器组是唯一被所有函数共享的资源。

   IA32规定了一组寄存器使用惯例,所有的函数都必须遵守,包括库函数。
  
   1).    寄存器%eax、%edx和%ecx被划分为由调用者负责保存的寄存器。若函数P调用函数Q时,函数Q不对这些寄存器在Q返回时恢复正常值负责,这是P的责任。
   2).    寄存器%ebx %esi %edi被划分为被调用者负责保存的寄存器,这意味着若函数P调用函数Q,Q必须在覆盖这些寄存器的原值前,将原值压入栈中保存,并在Q返回前恢复它们。
   3).    每个函数被调用时,须保存寄存器%ebp和%esp。


3.8    数组分配和访问

3.9    异类的数据结构

3.10    对齐

    对齐限制(alignment)简化了处理器与存储器系统之间接口的硬件设计。

    Liux沿用的对齐策略是2字节数据(如short)的地址必须是2的倍数,而较大的数据类型(如float、double)的地址必须是4的倍数。

    Windows对对齐的要求更严格,任何大小为K字节的数据对象的地址必须是K的倍数。这种要求提高了存储性能,代价是浪费了一定空间

    Linux的设计策略可能对i386很好,而对于现代处理器来说,windows的对齐策略就是更好的选择了。

3.14    浮点代码

    处理浮点值的指令集是IA32体系结构最不优美的特性之一。

    IA32中浮点单元包括8个浮点寄存器,但是和普通寄存器不同,这些寄存器是被当成一个浅桟(Shallow Stack)来对待的。这些寄存器被标记为%st(0)~&st(7),其中%st(0)在桟顶。

    大部分算术指令不会直接引用寄存器,而是从桟中弹出它们的操作数,计算结果,再将结果压入桟中。

    RPN(Reverse Polish Natation):逆波兰表示

3.14.6    测试和比较浮点值

    类似于整数,确定两个浮点值的相对大小需要先执行比较命令来设置条件码,然后再测试这些条件码。

    不过对于浮点数,条件码是浮点状态字的一部分。浮点状态字是一个16位寄存器,包含关于浮点单元的各种标志。

    注意:NaN与其它浮点值之间不存在相对顺序关系。
 
    IA32的汇编级浮点编程,即使对于有经验的程序员也显得很神秘、难以阅读。基于桟的操作、将状态结果从FPU读到CPU的笨拙以及浮点计算的许多细微之处,都使得机器代码冗长而晦涩。


3.15    C程序中嵌入汇编代码

    一种候选方法是用汇编代码编写一些关键函数,并保证参数传递和寄存器使用规则与C编译器遵守的一样;这些汇编函数保存在独立的文件中,由链接器将编译好的C代码和编译好的汇编代码结合起来。


3.15.1    基本的内嵌汇编(inline assembly)


    asm ( code-string);

    编译器会将code-string代表的汇编代码一字不差的插入到编译器自身产生的汇编代码中。编译器不会检查code-string是否出错。

    这种方法最大的问题是,编译器无法知道程序员的意图,也无法知道嵌入的汇编语句应该如何与其他代码交互。

3.15.2    asm的扩展格式


    GCC提供了asm的一个扩展格式,它允许程序员指定哪些变量要作为内嵌的汇编代码的操作对象,以及那些寄存器要被汇编代码使用。有了这些信息,编译器在生成代码时就能正确的实现两部分代码的配合。

    尽管asm语句的语法有些难懂,而且使得代码的可移植性变差了,但某些情况下还是非常有用的。

    经验表明,要想嵌入的汇编代码能正常工作,是需要进行一些尝试和犯点错误的。最好的办法就是用-S选项编译源代码,然后检查生成的汇编代码,看是否达到了期望效果。


第四章    处理器体系结构


    ISA(Instruction Set Architecture)指令集体系结构:一个处理器支持的指令和指令的字节级编码。

    ISA在编译器编写者和处理器设计人员之间提供了一个概念抽象层:编译器编写者只需知道那些指令是可用的以及它们的编码格式,而处理器设计者必须构建出执行能这些指令的处理器。

    HLC(Hardware Control Language):硬件控制语言,用于描述硬件控制系统的简单语言。


4.1    Y86指令集体系结构(ISA)

    指令集的一个重要特性就是指令对应的字节编码必须有唯一的解释。任意一个字节序列要么
对应唯一的指令编码,要么就是一个非法字节序列。

习题4.4 &4.5 一个有意思的问题

    我们知道,IA32中在执行pushl指令时,会将桟指针(%esp)的值减4,然后将目标操作数写入桟顶。那么,当执行pushl %esp指令时,注意此时目标操作数的值会被pushl指令修改,处理器会采取什么动作?
    1).    压入%esp的原始值?
    2).    压入%esp的最新值(原始值减4)?

   IA32的做法是压入%esp的原始值。

   对于popl存在同样的问题,IA32的处理是将桟指针设为从桟中弹出的值。

4.2    逻辑设计和硬件控制语言HCL

    实现一个数字系统需要三个主要组成部分:位运算函数的组合逻辑、存储器元素、时钟信号

    VHDL的语法类似于Ada语言,而Verilog HDL的语法类似于C语言。

4.2.1    逻辑门电路

4.2.2    组合电路和HCL布尔表达式

    组合电路的组成有两条限定:

    1).    多个逻辑门的输出不能接在一起,否则会导致输入信号的矛盾。
    2).    必须是无环的。

4.2.3    字级的组合电路和HCL整数表达式

    算术/逻辑单元(ALU)是一种很重要的组合电路。

4.2.4    集合关系

4.2.5    存储器和时钟控制

    组合电路在本质上不存储任何信息,只是简单的响应输入信号,并产生某个函数输出。为了产生时序电路——就是具有状态并以状态为基础进行计算的系统,必须引入按位存储信息的设备。

   两类存储设备:

   时钟寄存器:存储单个字
   随机访问存储器:存储多个字,用地址选择。例子包括虚拟存储器系统、寄存器堆;此处,寄存器标识符(ID)作为地址。

   在硬件和机器级编程中,"寄存器"的含义有细微的差别。在硬件中,寄存器直接将其输入和输出线连接到电路的其它部分。在机器级编程中,寄存器代表CPU中为数不多的可寻址的字,这里的地址是寄存器ID。分别称这两类寄存器为硬件寄存器程序寄存器

4.3    Y86的时序实现

4.3.1    指令处理阶段化

    取指
    解码
    执行
    访存
    写回
    更新PC(指令计数器)

4.3.2 SEQ硬件结构

4.4 流水线的通用原理
 
    流水线化的重要特性就是增加了系统的吞吐量,即单位时间内服务的顾客总数,不过也会轻微的增加执行时间,即服务一个用户需要的时间。

    处理器设计的几个重要经验:

    管理复杂性是首要问题,尽量保持硬件设计的简单。
    不需要直接实现ISA。
    一开始就保证设计正确是非常重要的。



第五章    优化程序性能


    研究汇编代码是理解编译器以及产生的代码会如何运行的最有效的手段之一


5.1    编译器优化的能力和局限性

    妨碍编译器优化的因素:

        存储器别名使用(memory aliasing):不同的指针可能指向存储器中同一个位置
        函数调用

5.2    程序性能的衡量

    CPE(cycles per element):用于更好的理解迭代程序的循环性能。
   
    最小二乘方拟和(least square fit)

5.4    消除循环的低效率

    代码移动(code motion)

5.5    减少函数调用

    对于性能至关重要的程序来说,为了速度,经常需要损害一些模块性和抽象性。

(重点理解)
5.6    消除不必要的存储器引用(指针的使用)

5.7    理解现代处理器
  
   必须建立这样的概念:由于现代处理器采用了复杂的硬件以及流水线技术以使性能最大化,导致处理器的实际操作与观察汇编代码得到的概念大相径庭。

5.7.1 整体操作

    超标量(superscalar):可以在每个时钟周期执行多个操作
    乱序(out-of-order)    :指令执行的顺序并不一定要与汇编程序中的顺序一致。

    ICU(Instruction Control Unit):指令控制单元
    EU (Execution Unit) :            执行单元

    ICU从指令高速缓存(instrucion cache)中读取指令。指令高速缓存是一个特殊的高速缓存。通常ICU会在当前正在执行的指令被执行前很早就已经取指,因此有足够的时间对指令解码。

    分支预测(branch prediction)

    投机执行(speculative execution)
    使用投机执行技术时,操作(计算)结果不会存放到程序寄存器或存储器中,直到处理器能够确定应该被执行的指令就是已被执行的指令。

    退役单元(Retirement Unit):指令解码时,与指令相关的信息会放入退役单元中一个先进先出的队列中,这个信息会一直停留在队列中,直至指令正确执行完毕,或者出现分支预测错误。这样,任何对程序寄存器的更新都只有在指令退役时才会生效。为了加速一条指令到另一条指令之间运算结果的传递,可以将传递过程在执行单元中实现。最常见的执行单元间的传递机制称为"寄存器重命名"。

5.7.2    执行单元的性能

    处理器的每个操作都是由两个周期计数值来刻画的

    执行时间:                功能单元完成操作所需要的时钟周期数。
    发射时间(issue time):连续的两个操作之间的周期数。

    在流水线化的处理器中,大多数操作的发射时间都小于执行时间。

5.7.3    对处理器操作更细微的观察

5.8    降低循环开销

    循环展开(loop unrolling)能降低CPE

    缺点:    1.不适合量级小的数组
                 2.增大了目标代码的长度

    多数编译器都可以实现循环展开的优化

5.9    指针代码
   
    根据经验,指针和数祖代码的相对性能依赖于机器、编译器,甚至于某个特殊的函数。我们已经看过编译器,它们对数组代码应用非常高级的优化,而对指针代码只引用最小限度的优化。为了可读性的考虑,通常数组代码更可取一些。

5.10    提高并行性

5.10.1    循环分割(loop splitting)

    迭代分割(iteration splitting):许多编译器自动进行循环展开,但是进行迭代分割的编译器相对较少。

5.10.2    寄存器溢出

    循环并行性的收益是受到处理器硬件资源特别是寄存器数量的限制的。当并行度导致寄存器数量不足时,只能将临时值存放在堆栈中。一旦出现这种情况,性能就会急剧下降。

    一个通用的原则:无论何时当程序显示出在某个频繁使用的内循环中存在寄存器溢出的迹象时,都应该重写代码,减少循环中涉及的局部变量。

5.12    分支预测和预测错误处罚

    条件转移 & 间接跳转

    人们已经提出了许多方法来预测转移,并对这些方法的性能进行了研究。一种常见的启发式方法是预测任意到较低地址的转移都将被执行,而任何到较高地址的跳转则不会。这十分适合应用于循环,因为在循环中到较低地址的跳转是开始下一次迭代,而循环通常会迭代多次,因此预测到较低地址的转移会被执行是个好主意。

    条件传送指令:在IA32指令集中,从PentiumPro开始,增加了许多形式为cmov的指令。例如cmovll,第一个"l"代表less ,第二个"l"表示long。

    不幸的是,C程序员对于改进程序的分支性能基本是无能力的。

5.13 理解存储器性能

    通常,处理器/存储器接口是处理器设计中最复杂的部分之一

5.15     认和消除性能瓶颈

    code profiler


第六章    存储器层次结构


    作为一个程序员,必须要了解存储器层次结构,因为它对应用程序的性能有巨大的影响。

    存储器层次结构是基于程序局部性原理的。

6.1    存储技术

    不同于系统总线和存储器总线(与CPU密切相关),PCI这样的I/O总线被设计为与底层CPU无关,如PC和MAC都可以使用PCI总线。

    存储器的一个基本事实:增加密度(从而降低成本)比降低访问时间更容易

6.2    局部性

    两种形式:

    时间局部性:    被引用过一次的地址很可能在不久被多次引用。

    空间局部性:    如果一个存储器位置被引用了一次,那么其附近位置的存储器单元很可能之后被引用。

    现代计算机系统的各个层次,从硬件到操作系统到应用程序,它们的设计都利用了局部性原理。

6.2.1    对程序数据引用的局部性

    步长为K的引用模式:随着步长的增加,空间局部性下降。步长为1的引用模式是程序中空间局部性的重要和常见来源。

6.2.2    取指令的局部性

    循环具有良好的空间局部性和时间局部性

6.3    存储器层次结构

缓存不命中的分类

    一个空的缓存被称为
冷缓存(cold cache),此种情况下发生的缓存不命中被称为冷不命中(cold miss)或者强制性不命中(compulsory miss)。冷不命中通常是短暂事件,不会在稳定状态下出现;稳定状态值得就是反复的存储器访问已经将缓存变暖。
   
    冲突不命中(conflict miss):缓存足够大,能够存放被引用的数据对象,但是由于这些对象被映射到同一cache entry,缓存会持续出现未命中的结果。
典型例子是ping-pong和cache 抖动。

    容量不命中(capacity miss):缓存容量过小,无法容纳下工作集。

    概括来说:存储器层次结构的行之有效,是因为较慢的存储设备比较快的存储设备便宜,还因为程序运行的局部性。

6.4    高速缓存存储器

    有意思的问题: 为何高速缓存在解析地址时用中间位而不是高位来作为组索引

    答:这样的设计是为了降低程序局部性原理对于缓存映射的负面影响——如果使用高位作为组索引,那么一些连续的存储器块就会被映射到相同的cache entry上,这样降低了对cache的使用效率。

6.5    编写高速缓存友好的代码

    两个编写高速缓存友好代码的重要问题:

    1).    对局部变量的反复引用是好的,因为这样编译器会选择将它缓存在寄存器中(时间局限性)
    2).    步长为1的引用模式是好的(空间局部性)

6.6    高速缓存对程序性能的影响

    存储器山(memory mountain):程序员应该构造程序使得程序运行于山峰而不是山谷。目标就是利用时间局部性,使得频繁使用的字从L1 Cahce中取出;还要利用空间局部性,使得尽可能多的字从一个L1 Cache行中访问到。

    利用分块提高时间局部性



第七章    链接

    链接可以执行于编译时,也可以执行于加载时,甚至可以执行于运行时

    链接器使得分离编译成为可能。

    链接的分类:传统静态链接、加载时共享库的动态链接、运行时共享库的动态链接。

7.2    静态链接 (static linking)

    为了创建可执行文件,链接器必须先后完成以下两个任务:
   
    1).    符号解析:将每个符号引用和符号定义联系起来
    2).    重定位:对所有的符号引用进行恰当的修改

7.3    目标文件

    目标文件可分为三种:

   1).    可重定位目标文件:可以与其他可重定位目标文件合并起来,生成一个可执行目标文件。
   2).    可执行目标文件  :可以被直接加载至存储器并执行
   3).    共享目标文件:一种特殊的可重定位目标文件,可以在加载或运行时,被动态载入存储器并链接。

7.4    可重定位目标文件

7.5    符号和符号表

    可重定位目标文件(模块m)中的符号表中包含三种类型的符号:

    1).    由模块m定义并可以被其他模块引用的全局符号
    2).    由其他模块定义并被模块m引用的全局符号
    3).    在模块m定义但只能被模块m引用的本地符号( interal linkage)

    注意,本地符号和函数的局部变量是两码事。局部变量在程序运行时在桟中被管理,链接器对该类符号不感兴趣。

    目标文件中的符号表是由汇编器利用编译器生成的.s汇编文件中的符号所生成的。

    ABS:            不应该被重定位的符号
    UNDEF:        未定义的外部符号
    COMMON:    bss section 中的符号

7.6    符号解析

    链接器解析符号引用的方法,是将每个引用与输入的所有可重定位目标文件的符号表中的一个合适的符号定义联系起来。

7.6.1    解析多处定义的全局符号

    全局符号分为强、弱两种类型。函数和初始化的全局变量是强符号,而未初始化的全局变量弱符号

    当发现多处定义的全局符号时,Unix中的链接器按以下规则处理:

    1).    不允许有多个强符号同时存在。
    2).    如果有一个强符号和多个弱符号,那么选择强符号作为定义。
    3).    若不存在强符号,在弱符号中任意选择一个。

7.6.2    与静态库链接

    所有的编译系统都提供一种机制,来将相关的目标模块打包为一个单独的文件,称为静态库。它可以作为链接器的输入;当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。

    问题: 用什么方法向程序员提供C语言的库函数?

    方法一:由编译器识别出对库函数的调用,并生成相应的代码。
    缺点:由于C语言定义了大量的库函数,这种方法将给编译器增加显著的复杂度;而且库函数作出任何改动,都需要一个新的编译器版本。

    方法二:将所有库函数放在一个单独的可重定位目标模块中,程序员将这个模块链接到对应的可执行文件中。
    这种方法的优点是它将编译器的实现与标准函数的实现分离开来,并对程序员保持适度的便利,但是很大的缺点是每个可执行文件都包含一份完整标准库函数的完整拷贝,这是对磁盘和内存资源的极度浪费;另外,库的更新和维护也变得很复杂。

    方法三:为每个库函数生成一个独立的可重定位文件,程序员显示的链接需要的目标模块——这是一个耗时且易出错的过程。
  
    静态库的提出,是为了解决上述方法的缺点。

    UNIX系统中,静态库是以存档(archive)这种特殊文件格式存储的的,它是一组连接(串)起来的可重定位目标文件的集合,后缀名为.a

7.6.3    使用静态库中的符号解析

    注意静态库之间的依赖关系

7.7    重定位

    重定位的工作由两步组成:
  
    1).    链接器将所有相同类型的节进行合并,该步完成后,程序中的每条指令和全局变量就都有唯一确定的运行时地址了。
  
    2).    修改所有的符号引用,保证其指向正确的运行时地址。

7.7.1    重定位表

    在汇编器生成一个目标模块时,它并不知道数据和代码最终运行时会载入到存储器的什么位置,也不知道这个模块引用的任何外部定义的函数或全局变量的位置。因此,在汇编器遇到对最终位置未知的对象的引用时,就为整个对象生成一个重定位表表项。

    重定位表目:Per-reference,not Per-definition

    最基本的两种重定位类型:32位PC相对地址 &32位绝对地址

7.8    可执行目标文件

7.9    加载可执行目标文件

    所有C程序的入口点都是_start,这段启动代码是在目标文件ctrl.o中定义的。启动代码中首先调用初始化函数,然后调用atexit()函数,用于注册在exit()函数被调用时应该被调用的函数。最后,启动代码调用main函数。在main函数返回后,调用系统调用_exit(),将控制返回给操作系统。
   
    注意细节:Unix系统在可执行文件的加载过程中,除了读取必要的头部信息,并没有任何从磁盘到存储器的数据拷贝,只是简单进行映射;直到CPU访问一个被映射的虚拟页产生缺页异常后,才进行拷贝。

7.10    动态链接共享库

    存储器的一个有趣属性:不论一个系统有多大的存储器,它总是一种稀有资源。

    共享库的"共享"意味着两个方面:无论在文件系统还是存储器中,都只有一个副本,供所有使用者共享。

    使用共享库的程序在启动时,控制权由加载器交给动态链接器,再由动态链接器交给应用程序的有效代码。

7.11    运行时加载和链接共享库

7.12    位置无关代码(PIC)

    PIC模式与非PIC模式最大的不同就是前者不直接patch指令,而是patch GOT

    PIC:代码可以在任何地址被加载和执行,而不需要链接器修改库代码。

    GCC中使用"-fPIC" 参数来指示编译系统生成位置无关代码。

    IA32系统中对模块内部函数的调用是不需要特殊处理的,因为其已经是PIC了(使用偏移量来引用)。

7.12.1    PIC数据引用   


    基于一个有趣的事实:无论共享库在存储器的何处加载,数据段总是紧跟在代码段之后,代码段中任何指令和数据段中任何数据之间的偏移量是常值,与加载位置是无关的。
 
    全局偏移量表GOT(global offsettable):GOT中每个被这个模块引用的本地全局变量都有一个表项,内容是全局变量的绝对地址。同时,编译器为GOT的每个表项创建一个重定位记录。在加载时,动态链接器会修正GOT中每个表项的值,使之包含正确的绝对地址。代码中通过GOT间接引用全局变量。

    PIC存在性能上的缺陷

7.12.2    PIC函数调用


    函数调用也可以通过GOT来实现PIC

    此外可以使用一种叫做"延迟绑定"的技术,将函数地址的绑定延迟至第一次被调用时。函数第一次被调用时的运行开销很大,但是其后的每次调用只花费一条指令和一个间接的存储器引用。

    延迟绑定是通过GOT和PLT这两个数据结构之间的交互来实现的。

    PLT(procedure linkage table):过程链接表。

    GOT属于.data section,而PLT属于.text section。
 


第八章    异常控制流

    异常控制流ECF(exceptional control flow)

8.1    异常

8.2    进程


    进程是计算机科学最成功最深刻的概念之一。

8.2.1    逻辑控制流

8.2.2    私有地址空间

8.2.3    内核模式和用户模式


    一般而言,硬件高速缓存不能和中断和上下文切换这样的异常控制流很好的交互——因为在这种情景下,cache通常都是"冷"的,即程序需要的数据都不在缓存中,或者说缓存中的数据都不是当前程序需要的,这种现象称为Cache Pollution

8.3    系统调用

8.4    进程控制

8.5    信号

 

第九章    测量程序执行时间

9.1    计算机系统中的时间流


9.2    通过间隔计数(interval counting)来测量时间


9.3    IA32周期计数(cycle counters)


    测量结果很容易受上下文切换、高速缓存和分支预测等因素的影响。这些因素导致过高的估计了真实的执行时间。

    如何回答这个问题:"程序X在机器Y上运行得有多块?"

    1).    如果X预期得运行时间很长(如大于1.0s),那么基于间隔计数的测量方法就可以工作的足够好,而且测量值对处理器负载不敏感。
    2).    如果X预期的运行时间在10ms至1000ms之间,那么在负载很轻的系统上,使用更准确的、基于时钟周期的测量方法就很重要了
    3).    如果X预期的运行时间小于10ms,那么只要测量方法是基于时钟周期的,即使是在负载很重的系统中,也可以完成精确的测量。

    经验教训:

    1).    时间测量这个问题,是和所基于的系统密切相关的。
    2).    在负载很重的系统上获得准确的计时特别困难。

    总结:

    计算机系统由于支持多个进程同时运行的机制,导致很难获得对程序性能可靠的测量值。计算机系统的活动倾向于在两个不同的时间尺度上进行。在微观级别上,指令的执行时间是以ns来衡量的。宏观级别上,输入/输出之间交互的延迟是以ms来衡量的。

    计算机系统可以用两种不同的方式来记录时间的流逝。从宏观角度看,时钟中断的频率似乎很高,但是从微观的角度看却很慢。

    基于间隔计数(interval counter——OS在每次时钟中断时更新进程中的计数值)的测量方法能获得程序执行时间非常粗略的测量值,这种方法只适用于持续时间较长(至少1s)的测量。

   基于周期计数器的测量方法可以在微观尺度上获得良好额测量值,然而进程切换在负载很大的情况下会对这种方法造成很大的误差。

   K次最优(K-Best)计时方法




第十章    虚拟存储器

    虚拟存储器是计算机系统最重要的概念之一。它成功的一个主要原因就是因为它是沉默的、自动完成工作的,不需要程序员的任何干涉。既然虚拟存储器在幕后工作的如此得如此之好,为什么程序员还需要理解它呢? 有以下几个原因:

    1).    虚拟存储器是重要的,它遍及计算机系统的所有层面,在硬件异常、汇编器、链接器、加载器、共享对象、文件和进程的设计中扮演着重要的角色。
    2).   
虚拟存储器是强大的
    3).   
虚拟存储器是危险的

    虚拟存储器是硬件异常、硬件地址翻译、内存、磁盘文件和OS的完美交互。


10.1    物理和虚拟地址

    地址翻译需要硬件与OS之间的紧密合作——MMU完成具体翻译工作,而OS负责提供翻译的依据。

10.3    从缓存的角度看虚拟存储器

    从概念上讲,虚拟存储器可以被视为一个存放在磁盘上的由N个连续的字节大小的单元组成的数组。每个字节都有唯一的虚拟地址作为在数组中的索引。磁盘(虚拟存储器)中的内容被缓存到主存中。

    下文中术语SRAM缓存代表L1和L2 Cache;DRAM缓存代表虚拟存储器的缓存,它在主存中缓存虚拟页。

    DRAM缓存的组织结构完全是受缓存访问未命中时的巨大开销驱动的。由于未命中时导致的巨大开销,DRAM缓存是全相联的,即任何虚拟页可以放置在任何物理页中。此外,替换算法也必须仔细设计,因为错误替换同样会导致巨大的开销。-

    虚拟存储器是在20世纪60年代早期发明的,这个时间远在"CPU-存储器"速度差距的加大导致SRAM缓存被使用之前。因此虚拟存储器系统使用了和SRAM缓存不同的术语,尽管它们的许多概念都是相似的。

    按需页面调度

10.4    从存储器管理的角度看虚拟存储器

    虚拟存储器简化了链接、加载共享数据和代码以及分配存储空间的实现。

10.5    从存储器保护的角度看虚拟存储器

10.6    地址翻译

    在页面命中的情况下,一切工作都是由硬件自动完成的;而在缺页的情况下,需要硬件和OS相互配合共同完成。

10.6.1    虚拟存储器与Cache的结合

   在任何同时使用虚拟存储器和Cache的系统中,都存在着在对Cache寻址时应该使用虚拟地址还是物理地址的问题大多数系统选择使用物理地址来对
Cache寻址。使用物理地址寻址,可以很简单的满足多个进程同时在Cahce中缓存数据以及多个虚拟页共享同一物理页这样的要求。另外,Cache在设计时无需考虑保护问题,因为访问权限的检查应该在之前的地址转换这一步中完成。

10.6.2    利用TLB加速地址翻译

    MMU中集成了一个用于缓存PTE的Cache,称为TLB。TLB通常具有高度的相联性。

    注意,TLB是基于虚拟地址的

10.6.3    多级页表

10.8    存储器映射

    将一个虚拟存储器区域与一个磁盘上的文件关联起来,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射。

    虚拟存储器可以映射到两种类型的对象:

    1).    文件系统中的普通文件

    2).    匿名文件:匿名文件是由内核创建的,包含的全部为0值。这种情况下,存储器与磁盘之间并没有实际的数据传送。这样的虚拟页面有时也被称为demand-zero page。

    
存储器映射的概念基于一个聪明的发现:如果虚拟存储器系统可以集成到文件系统中,那么就可以使用一个简单而高效的方法将程序和数据加载到存储器中

10.8.1    共享对象 &私有对象

    一个文件对象被映射到虚拟存储器的一个区域,可以作为共享对象,也可以作为私有对象。

    共享对象:如果一个进程将一个共享对象映射到自己的虚拟地址空间的某个区域后,该进程对于这个区域的任何写操作,对于其他也对该共享对象进行映射的进程而言,都是可见的;同时,这些写操作也会反映在磁盘上的原始对象中。

    私有对象:对于一个映射到私有对象的区域所有的修改,对于其他进程是不可见的;同时,这些修改都不会反映在磁盘上的原始对象中。私有对象的实现利用了COW(copy on write)技术。通过延迟私有对象的拷贝直至最后时刻,可以最充分的利用稀有的存储器资源。

10.8.2    fork函数

10.8.3    execve函数

   新进程的文本区、数据区、bss和栈区都是以私有的COW方式进行映射的。bss区域映射到匿名文件。

10.9    动态存储器分配

   分配器将堆视为一组不同大小的块的集合来进行维护。每个块是一个连续的存储器区间。

   按照由谁负责释放已分配的块,
分配器可分为显式分配器隐式分配器(垃圾收集器)


10.9.3    显
分配器的要求和目标

    1).    处理任意请求序列:分配器不应该对分配和释放请求的顺序作出任何假设
    2).    立即响应请求
    3).    只使用堆
    4).    满足对齐要求
    5).    不修改已分配的块

    分配器的实现应该实现吞吐量最大化存储器使用率最大化,而这两个性能目标经常是互相冲突

    最大化吞吐量:最大化单位时间内服务的请求数。开发一个具有合理性能的分配器并不困难,所谓合理性能是指
一个分配请求的最坏运行时间与空闲块的数量成线性关系,而一个释放请求的运行时间为常数。

    最大化存储器利用率:常用衡量方法——峰值利用率

    天真的程序员经常不正确的假设虚拟存储器是一个无限的资源实际上,一个系统中所有进程实际分配的虚拟空间的总和,是受磁盘上交换空间空间大小限制的。作为一个优秀的程序员,应该意识到虚拟空间也是必须有效使用的有限资源
   

10.9.4    碎片

    造成堆利用率低下的主要原因是称为碎片的现象。

    内碎片(internal fragmentation):    实际分配空间大于有效载荷,原因可能是为了满足对齐要求。
    外碎片(external fragmentation):   空闲空间的总和虽然能满足分配请求,但没有一个单独的空闲块足够大可以满足该请求。

    由于外碎片难以量化和预测,所以分配器通常采用启发式策略来试图维护少量的大空闲块,而不是大量的小空闲块。

10.9.5    实现

    需要考虑的问题:

    1).    如何组织和记录空闲块?
    2).    如何当前的空闲块中选择出合适的一个来满足分配请求?
    3).    一个空闲块用于满足一个分配请求后,如何处理(假如存在的话)空闲块中的剩余部分。
    4).    如何处理一个被释放的块?是否进行合并?
  
10.9.6 隐式空闲链表
 
    任何实际的分配器都需要一定的数据结构,以允许用来区别块边界,并区分已分配的块和空闲块。大多数分配器将这些信息直接嵌在块本身中
    
    隐式空闲链表的优点是简单,显著的缺点是任何操作的开销都与堆中所有已分配块和空闲块的总数成线性关系。

    请意识到系统的对齐要求和分配器在块格式上的选择,使得分配器所处理的最小块受到限制。

10.9.7    放置分配的块

    放置策略

    1).    首次适配(first fit):    从空闲链表的表头开始搜索,选择第一个满足请求的空闲块。
    2).    下一次适配(next fit):和首次适配很相似,区别在于搜索的起始点不是表头,而是上一次搜索结束的地方。
    3).    最佳适配(best fit):    遍历整个链表,选择满足请求的最小空闲块。

    首次适配的优点之一是它趋向于将大的空闲块留在链表的后部,缺点是趋向于在链表的前部产生小空闲块的碎片,这样会增大对较大块的搜索时间。
    下一次适配运行速度明显优于首次匹配,但存储器利用率要差。
    研究表明,最佳适配比前两者的利用率都要高一些,但最大的缺点是需要对堆进行彻底的搜索,这会导致较长的搜索时间。

10.9.8    分割空闲块

    分配器在选择好一个匹配的空闲块后,必须作出另外一个策略决定:必要的话,是否对空闲块进行分割?

    不分割的策略简单而快捷,但会造成内碎片。

10.9.10    合并空闲块

    分配器释放一个已分配块时,可能有其他空闲块与这个刚释放的块相邻,这种现象称为假碎片(fault fragmentation)

    为了消除假碎片问题,任何实际的分配器都必须合并相邻的空闲块。

    关键策略:什么时候执行合并?

    1).    立即合并(immediate coalescing):每当有块被释放时,就尝试合并相邻块。

    优点:简单明了,可以在常数时间内完成。
    缺点:对于某些请求模式,可能会造成"抖动"——块会反复的合并,然后又马上被分割。

    2).    推迟合并(deferred  coalescing): 在稍后的某个时间执行合并操作。
   
    快速的分配器通常会选择某种形式的推迟合并。

10.9.11 带边界标定的合并

    合并中存在的问题:与下一个空闲块合并很简单,但与前一个空闲块合要麻烦一些

    边界标定(boundarytag):在每个块的尾部增加一个tag,内容是该块头部的一个副本;这样刚被释放的块就可以很容易的确认自己之前的块的状态和大小(因为前一个块的footer永远位于本块起始位置之前的一个字),从而可以在常数时间内完成与前面空闲块的合并。

    边界标记的概念是简单优雅的,然而在操作数量众多的小块时,头部和脚部的开销会显得很大。


10.9.12    实现一个简答的分配器

10.9.13    显式空闲链表

    由于隐式空闲链表中块分配与堆中空闲块的总数成线性关系,因为对于通用型分配器来说,隐式空闲链表是不可取的方法。
   
    更好的方式是将空闲块组织为某种形式的显式数据结构。

    使用双向空闲链表而不是隐式空闲链表,将使首次适配的时间从与块总数成线性关系减少到与空闲块数成线性关系。另一方面,释放一个块的时间有可能是线性的,也可能是常数,这取决于在空闲链表中对块排序所选择的策略。

    若采取LIFO的方式维护空闲链表——新释放的块插入链表头部,释放块可以在常数时间内完成。

    若按照地址顺序维护空闲链表,则释放块需要线性时间来搜索链表;另一方面,基于地址排序的首次适配比基于LIFO的首次适配有更高的存储器利用率。

    显式空闲链表的缺点式要求空闲块要足够大以包含需要的指针以及头部、脚部,这导致最小块尺寸的增大,也潜在的提高了内碎片的程度。

10.9.14    分离的空闲链表
  
    分离存储(segregated storage):维护多个空闲链表,每个链表中的块具有大致相等的大小。

    一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(size class)。

    各种分离存储方法的主要区别在于它们如何定义大小类、何时进行合并、是否允许分割、何时向OS请求扩展堆等等。

    两种基本的方法:简单分离存储 VS 分离适配(segregated fit)  

    简单分离存储:每个等价类的空闲链表中包含大小相等的块,块的大小就是这个等价类中最大元素的大小。
    这种简单方法具有很多优点:分配和释放都是很快的常数时间操作;空闲块不执行分割,也不执行和并;每个块需要的额外开销很少。另一方面,其显著缺点就是很容易造成内碎片和外碎片。
 
    分离适配:分配器维护一个空闲链表的数组,每个空闲链表和一个等价类相关联,并组织为某种类型的显式或隐式链表,每个链表中包含潜在的大小不同的块。
    这种方法执行块分割和块合并。优点在于既快速——因为搜索的范围限制为堆的某部分,对存储器的使用也很有效率——对分离空闲链表的首次适配搜索相当于对整个堆的最佳适配搜索。GNU C 中的malloc包就是采用的该方法。

    伙伴系统(Buddy System):分离适配的一种特例。
    其主要优点式快速搜索和快速合并,主要缺点是由于限定块大小为2的幂,可能导致显著的内碎片。因此伙伴系统不适用于通用系统

10.10    垃圾收集

    垃圾收集器是一种动态存储分配器,用于自动释放程序不再需要的已分配块。

    垃圾收集器将存储器视为一张有向可达图。图的节点分为根节点和堆节点。根结点对应于自身不存放在堆中,但指向存储在堆中数据的指针变量。

    当存在一条从任意根结点除法到达堆节点p的有向路径时,称节点p是可达的。而不可达节点就是收集器需要回收的对象

    向C/C++中加入垃圾回收器的关键想法是由收集器代替应用程序去调用free来释放存储块。

10.10.2    Mark & Sweep垃圾收集器


    Mark & Sweep垃圾收集器由标记(mark)和清除(sweep)两阶段组成。标记阶段标记出根结点的所有可达且已分配的后继节点;而清除阶段释放每个未被标记的已分配块。

10.11    C程序中常见的与存储器有关的错误

10.11.1    间接引用坏指针

   
    经典错误:scanf中本应传递的参数是变量地址,但却传递了变量内容

    读未初始化的存储器:不同于.bss段,堆存储器中的内容并未初始化为0

    桟缓冲区溢出

    误以为指针与其所指向的对象具有相同的大小

    错位错误

    引用不存在的变量

    内存泄漏

10.12    总结虚拟存储器的关键概念


    一个关键的经验教训:即使虚拟存储器是由系统自动提供的,它也是一种有限的存储器资源,应用程序应该有效的使用它。