C只支持大小在编译时就能知道的多维数组(对于第一维可能有些例外)。在许多应用程序中,我们需要代码能够动态分配的任意大小的数组进行操作。,为此,我们必须显示地写出从多维数组到一维数组的映射。
异类的数据结构:C提供了两种不同类型的对象结合到一起来创建数据类型的机制;结构,用关键字struct来声明,将多个对象集合到一个单位中;联合,用关键字union来声明,允许用几种不同的类型来引用一个对象。
结构:C的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。结构的各个组成部分是用名字来引用的。结构的实现类似于数组的实现,因为结构的所有组成部分都存放在存储器中连续的区域,而指向结构的指针就是结构第一个字节的地址。编译器保存关于每个结构类型的信息,指示每个域的字节偏移,它以这些偏移作为存储器引用指令中的位移,从而产生对元素构成的应用
struct数据类型的构造函数是C提供的与C++和JAVA对象最接近的东西,它允许程序员保存关于一个数据结构中某些实体的信息,并用名字来引用这些信息。
联合提供了一种方式,能够规避C的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构一样
,不过他们不是用不同的域来引用不同的存储器块,而是引用的同一存储器块。
在一些情况中,联合十分有用,但是,它也引起一些讨厌的错误。因为它绕过了C类型系统提供的安全措施。一种应用情况是,我们事先知道对一个数据结构中的两个不同域的使用时互斥的,那么将这两个域作为联合的一部分,而不是结构的一部分,会减少分配空间的总量。
对齐:许多计算机系统对基本数据类型的可允许地址做出了一些限制,要求某种类型的对象的地址必须是某个值K(通常是2、4、8)的倍数。这种对齐限制简化了处理器和存储器系统之间接口的硬件设计。例如:假设一个处理器总是从存储器中取8个字节出来,则地址必须为8的倍数。如果我们能保证所有的double都将他们的地址对齐成8的倍数,那么可以用一个存储器操作来读或者写值了。否则我们可能需要执行两次存储器访问,因为对象可能分放在两个8字节存储器块中。
无论数据是否对齐,LA32硬件都能正常工作,不过,Intel还是建议要对齐数据以提高存储器系统的性能。Linux沿用的对齐策略是2字节数据类型(例如short)的地址必须是2的倍数,而较大的数据类型(例如int、int*、float和double)的地址必须是4的倍数。注意,这个要求就意味着一个short类型对象的地址的最低位必须等于0.类似地,任何int类型的对象或指针的地址的最低两位必须都是0.
Microsoft Windouws 对对齐的要求更严格——任何k字节(基本)对象的地址都必须是K的倍数。特别地,它要求一个double的地址应该是8的倍数,这种要求提高了存储器性能,代价是浪费了一些空间。
确保每种数据类型性都是按照指定方式来组织和分配,即每种类型的对象满足它的对齐限制,就可保证实施对齐。编译器在汇编代码中放入命令,指明全局数据所需的对齐。
指针式C语言的一个重要特色,他们 提供一种同一方式,能够远程访问数据结构。
在C中指针可以指向任何数据类型。
每个指针都有一个类型,这个类型表明指针指向的对象 是哪一类的。
每个对象都有一个值。这个值是某个指定类型的对象的地址。
指针是用&运算符创建的。这个运 算符可以应用到任何lvalue类的C表达式上,也就是可以出现在赋值语句左边的表达式,这样的例子包括变量以及结构、联合和数组的元素。
操作符用于指针的间接引用。其结果是一个值,它的类型与该指针的类型相关。
数组与指针是紧密相连的。可以引用一个数组的名字(但是不能修改),就好像它是一个指针变量一样。数组引用与指针运算和间接应用有一样的效果。
指针也可以指向函数,这提供了一个很强大的存储和传递代码引用的功能,这些代码可以被程序的某个其他部分调用。
向函数传递数据:其他语言(例如Pascal)提供两种方式来向过程传递数据——传值(by value)和引用(by reference),传值是指调用者提供实际的参数值,而引用是指调用者 提供一个指向该值的指针。在C中所有的函数都是传值的,但是我们可以通过显示地产生一个值的指针,并把该指针传递给过程,从而实现啦引用函数的效果。
使用GDB调试器:GNU的调试器GDB提供了许多有用的特性来支持对机器级程序的运行时评估和分析。有了GDB,通过观察正在运行的程序,同时又对程序的执行有相当的控制,这就使得研究程序的行为变为可能。
GDB的命令语法有点含混晦涩,但是在线帮助信息(用GDB的help命令调用)能克服这些毛病。
存储器的越界引用和缓冲区溢出:我们已经看到,C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如寄存器值和返回指针)都存放在栈中,这两种情况结合到一起就能导致严重的程序错误,一个对越界的数组元素的写操作破坏了存储在栈中的状态信息。然后,当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现严重的错误。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。通常,在栈中分配某个字节数组来保存一个字符串,但是字符串的疮毒超出了为数组分配的空间。
gets从标准输入读入一行,在遇到一个“\n”字符或某个错误情况时会停止。它将这个字符串拷贝到参数s指明的位置,并在字符串结尾加上null字符。
gets的问题是它没有办法确定是否为保存整个字符串分配了足够的空间。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数,这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为exploit code ,另外,还有一些字节会用一个指向缓冲区中那些可执行代码的指针覆盖掉返回指针。所以,执行ret指令的效果就是跳转到exploit code.
一种攻击形式中,exploit code会使用系统调用一个shell程序,提供给攻击者一组操作系统的函数。在另一种形式中,exploit code 执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,(看上去好像)正常返回给调用者。
蠕虫和病毒都是试图在计算机中传播他们自己的代码。蠕虫(worm)是这样一种程序,它可以自己运行,并且能够将一个完全有效的自己传播到其他机器,与此相应地,病毒(virus)是这样一段代码,他能将自己添加到包括操作系统在内的其他程序中,但它不能独立运行。
浮点代码:处理浮点值的指令时IA32体系结构最不优美的特性之一。在最早的intel机器中,浮点是由一个独立的协处理器来完成的,这个部件有他自己的寄存器和处理能力,能够执行一部分指令。
浮点寄存器:浮点单元包括8个浮点寄存器,但是和普通寄存器不一样,这些寄存器是被当成一个浅栈来对待的。当压入栈中的值超过8个时,栈底的那些值就会消失。大多数算术指令不会直接引用存储器,而是从栈中弹出他们的源操作数,计算结果,再将结果压入栈中。
将浮点寄存器组织成一个有界的栈,使得编译器很难用这些寄存器来存放一个调用其他过程的局部变量。对于局部变量的存放,我们已经看到有些通用寄存器可以被指定为由被调用者保存,因此可以用来保存跨过程调用的局部变量,这种指定对IA32来说是不可能的。因为他的标识随着值压入栈中和从栈中弹出是变化的。
另一方面,他会将浮点寄存器作为真正的栈来对待,每次过程调用时,都将本地址压入其中。不幸的是很快就会导致栈溢出,因为只有8个值的位置。作为代替,编译器产生的代码会在调用另一个过程之前,将每个本地浮点值都压入到主程序栈中,然后在返回时把他们取出来。这样引起的存储器访问操作会降低程序的性能。
我们用记符%st(i)来引用浮点寄存器,这里i代表相当于栈顶的位置。值i的范围为0~7.寄存器%st(0)是栈顶元素,%st(1)是第二个。以此类推,当一个新值压入栈中时,寄存器%st(7)中的值就丢失了。当从栈中弹出时,%st(7)的新值是不可预测的。编译器产生的代码必须能在寄存器栈有限的容量中工作。
在过程中使用浮点:同整数参数一样,浮点参数是通过栈传递给调用过程。float4个字节 double8个字节,结果是以扩展精度格式在浮点寄存器栈顶部返回的。
对于浮点,条件码是浮点状态字的一部分,浮点状态字是一个16位寄存器,包含关于浮点单元的各种标志。必须将这个状态字转换成整数字,然后测试某些特殊的位。
现在,优化编译器基本上使得性能优化不再是用汇编代码写程序的一个原因了。一个高质量的编译器产生的代码通常和手工编写的一样好,甚至更好。而C语言基本上使得机器访问不再使用汇编代码了。C语言能够通过联合和指针运算访问低级数据表示,以及能对位级数据表示进行操作,这就为大多数程序员提供了足够多访问机器的能力。尽管如此,有时候用汇编写代码仍然是唯一的选择,特别是实现操作系统时就更是这样。比如:操作系统必须访问一些特殊的寄存器,他们存放着进程状态信息,执行输入和输出操作要使用特殊的指定或是访问特殊的存储器位置。即使是对应用程序员来说,也有一些机器特性,例如条件码的值,是不能用C直接访问的。
现在的问题是要将主要由C组成的代码与少量汇编代码集成到一起。一种方法是用汇编代码写一些关键函数,使用的参数传递和寄存器使用规则与C编译器遵守的一样,这些汇编函数保存在独立的文件中,由连接器将编译好的C代码和编译好的汇编代码结合起来。
基本的内嵌汇编:GCC还可以将汇编与C代码混合起来。内嵌汇编允许用户直接往汇编器产生的代码序列中插入汇编代码。可以提供一些特性,以指定指令操作数和向汇编器说明汇编指令要覆盖哪些寄存器。当然得到的代码是与机器高度相关的,因为不同类型机器的机器指令是不兼容的。asm命令也是与GCC相关的,它与很多其他编译器是不兼容的,尽管如此,这还是一种有效的方式,将于机器相关的代码数量降低到绝对小。
内嵌汇编是作为GCC信息档案的一部分来说明的,在任何安装了GCC的机器上执行命令infogcc,会得到一个分层的文档阅读器。沿着名为“C Extensions”的链接,然后是名为“Extended Asm”的链接,就能找到内嵌汇编的文档,不幸的是这个文档有点不完全,也不太准确。
术语code-string 表示一个以带括号的字符串形式给出的汇编代码序列。编译器会将这个字符串一字不差的插入到产生的汇编代码中,因此,编译器提供的汇编和用户提供的汇编就合并到一起了,编译器不会检查字符串是否出错,因此,要等到汇编器才会报告错误。
asm的扩展格式:GCC提供了asm的一个扩展版本,它允许程序员指定哪些程序值要作为汇编代码序列的操作数,以及那些寄存器要被汇编代码覆盖。有了这些信息,编译器产生的代码就能正确建立所需要的源值,执行汇编指令,并使用计算出的值。这些信息中还包括编译器所需的关于寄存器使用的信息,这样一来,重要的程序值就不会被汇编代码指令覆盖了。
虽然asm语句的语法有点难懂,而且它的使用也使代码的可移植性变差了,但是对于编写用于少量汇编代码来访问机器级特性的程序,这条语句还是非常有用的。
想要代码进行正常工作,是需要进行一些尝试和犯点错误的,最好的方法就是用-s选项编译选项,然后检查生产出的汇编代码,看他是否达到了期望的效果。代码还应该用不同的选项设置来测试,
汇编语言与C代码差别很大。在汇编语言程序中,各种数据类型之间的差别很小。程序是以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈,对程序员来说是直接可见的。仅提供了低级操作来支持数据处理和程序控制。编译器必须用多条指令来产生和操作各种数据结构,来实现像条件、循环和过程这样的控制结构。
C中缺乏边界检查,使得许多程序容易出现缓冲区溢出,而这已经使许多系统容易受到入侵者的恶意攻击。
编译C++与编译C就非常相似。实际上,C++的早起实现就只是你简单地执行了从C++到C的源到源的转换,并对结果运行C编译器,产生目标代码。C++的对象用结构来表示,类似于C的struct。C++的方法是用指向实现方法的代码的指针来表示的。相比而言,JAVA的实现方式完全不同。java的目标代码是一种二进制表示,称为java字节代码。这种代码可以看成是虚拟机的机器级程序,这种机器并不是直接用硬件实现的,相反,软件解释器处理字节代码,模拟虚拟机的行为。这种方法的有优点是相同的java字节代码可以在许多不同的机器上执行。
第四章、处理器体系结构
理解处理器是如何工作的能帮助理解整个计算机系统时如何让工作的。
两个存储器传送指令中的存储器引用方式是简单的基址加位移形式,在地址计算中,我们不支持第二变址寄存器和任何寄存器值的伸缩。
同IA32一样,我们不允许从一个存储器地址直接传送到另一个存储器地址,现在我们也不允许将立即数传送到存储器。
有四个整数操作指令,他们是addl、subl、andl、xorl.
七个跳转指令:jmp 、jle、jl、je、jne、jge、jg
call指令将返回地址入栈,然后跳到目的地址。
pushl和popl指令实现入栈和出栈。
halt指令停止指令的执行。IA32中有一个与之相当的指令,叫hlt。IA32的应用程序不允许使用这条指令,因为它会导致整个系统停止。我们在Y86程序中用halt指令来停止模拟器
有的指令只有一个字节长,而有的需要操作数的指令编码就更长一些。首先,可能有附加的寄存器指示符字节,指定一个或两个寄存器。从指令的汇编代码表示中可以看到,根据指令类型,指令可以指定用于数据源和目的的寄存器,或是用于地址计算的基址寄存器。没有寄存器操作数的指令,例如分支指令和调用指令,就没有寄存器指示符字节。
指令集的一个重要性质就是字节编码必须有唯一的解释。任何一个字节序列要么是一个唯一的指令序列的编码,要么就不是一个合法的字节序列。Y86就具有这个性质,因为每条指令的第一个字节有唯一的代码和组合功能,给定这个字节我们就可以决定所有其他附加字节的长度和含义。这个性质保证了处理器可以无二义性地执行目标代码程序。只要从序列的第一个字节开始处理,即使代码嵌入在程序中其他字节中,我们仍然可以很容易地确定指令序列。反过来说如果不知道一段代码序列的其实位置,我们就不能准确地确定怎样将序列划分成单独的指令。
. pos 0:从地址0处开始产生代码
. align 4:在四字节边界处对齐
逻辑设计和硬件控制语言HCL:在硬件设计中,电子电路被用来计算位的函数,以及在各存储器元素中存储位。大多数现代电路技术都是用信号线上的高电压或低电压来表示不同的位值。通常的技术中,逻辑1是用1.0伏特左右的高电压表示的,而逻辑0是用0.0伏特左右的低电压表示。
要实现一个数字系统需要三个主要的组成部分:计算机的函数的组合逻辑、存储位的存储器元素,以及控制存储器元素更新的时钟信号。
递归过程:每个调用在栈中的都有它自己的私有空间,多个未完成调用的局部变量不会相互影响,当过程被调用时分配局部存储,当返回时释放存储。数组分配和访问:C中数组是一种将标量型数据聚集成更大数据类型的方式。C用来实现数组的方式很简单,因此很容易翻译成机器代码。C的一个不同寻常的特点是可以对数组中的元素产生指针,并对这些指针进行运算,这些运算会在汇编代码中翻译成地址计算。
优化编译器非常善于简化数组索引所使用的地址计算,不过这使得C代码和它到机器代码的翻译之间的对应关系很难理解
指针运算:C允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行调节。
单操作数的操作符&和*可以产生指针和间接引用指针。
数组与循环:在循环代码内,对数组的引用通常有非常规则的模式,优化编译器会使用这些模式。
为什么要避免使用整数乘法?
因为在较老的IA32处理器模型中,整数乘法指令要花费30个时钟周期,所以编译器要尽可能地避免使用它,而在大多数新近的处理器模型中,乘法指令只需要3个时钟周期,所以不一定进行这样的优化了。
嵌套数组:即使是创建数组的数组时,数组分配和引用的通用原则也是有效的。
固定大小的数组:对固定大小的多维数组进行操作的代码,C编译器能够进行多种优化。
逻辑门是数字电路的基本计算元素,他们生产的输出,等于他们输入位置的某个布尔函数
组合电路和HCL布尔表达式:将很多的逻辑门组合成一个网,我们就能得到计算快,即组合电路。如何组成这个网有两条限定:
1、两个或多个逻辑门的输出不能接在一起,否则他们会使线上的信号矛盾,导致一个不合法的电压或电路故障。
2、这个网必须是无环的,也就是在网中不能有路径经过一系列的门而形成一个回路,这样的回路会导致该网络的计算函数有歧义。
我们的HCL表达式很清楚地表明了组合逻辑电路和C中表达式的相似之处,他们都是用布尔操作来对输入进行计算的函数。值得注意的是这两种表达式的区别:
1、因为组合电路是由一些逻辑门组成的,它有个属性就是输出会持续地响应出入的变化。如果电路的输入变化了,在一定的延迟之后,输出也会相应地变化,而C表达式只会在程序执行过程中被遇到时才进行求职。
2、C的逻辑表达式允许参数是任意数,0表示FALSE,其他的任何值都表示TRUE。而我们的逻辑门只对位值0和1进行操作。
3、C的逻辑表达式有个属性就是它们可能只被部分求值。如果一个AND或OR操作的结果只用对第一个参数求值就能确定,那么就不用对第二个结果进行求职了。
字级的组合电路和HCL表达式:通过将逻辑门组成一个更大的网,我们可以构造出能计算更加复杂函数的组合逻辑。通常我们设计了能对数据字进行操作的电路,他们是一些位级的信号代表一个整数或一些控制模式。
执行字级计算的组合电路是根据输出字的各个位,用逻辑门来计算输出子的各个位。
存储器和时钟控制:组合电路从本质上讲,不存储任何信息。相反,他们只是简单地相应输入信号,产生等于输出的某个函数输出。为了产生时序电路,也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备,我们考虑两类存储器设备:1、时钟寄存器(简称寄存器)存储单个位或字。时钟信号控制寄存器加载输入值;
2、随机访问存储器(简称存储器)存储多个字,用地址来选择该读或该写哪个字。
寄存器文件有两个读端口(A和B),还有一个写端口(W),这样一个多端口随机访问存储器允许进行多个读和写操作。
虽然寄存器文件不是组合电路(因为它有内部的存储),但是从中读取字的操作与以地址输入、数据为输出的一块组合逻辑是一样的。
时钟信号按照类似于将值加载进时钟寄存器一样的方式控制向寄存器文件写入字。
将处理组织成阶段:通常,处理一条指令包括很多操作。我们将他们组织成某个特殊的阶段序列,使得即使指令的动作差异很大,但所有的指令都都遵循统一的序列。各个阶段以及各个阶段内执行的操作:
1、取指:取指阶段从存储器读入指令,地址为程序计数器(pc)的值。2、解码:解码阶段从寄存器文件读入最多两个操作数。
3、执行:在执行阶段算术/逻辑单元要么执行指令指明的操作,计算存储器引用的有效地址,要么增加或减少栈指针。
4、访存:访存阶段可以将数据写入存储器,或者从存储器读出数据。
5、写回:写回阶段最多可以写两个结果到寄存器文件。
6、更新PC:将PC设置成下一条指令地址
SEQ唯一的问题就是它太慢了,时钟必须非常慢,以使信号能在一个周期内传播过所有的阶段。
SEQ每次只执行一个指令。
流水线化的设计目的就是每个周期都有一条指令进入执行阶段并最终完成,要是达到这个指令就意味着吞吐量是每个时钟周期一条指令。为了达到这个目的我么必须在取出当前指令之后,马上确定下一条指令位置,不幸的是,如果取出的指令是条件分支指令,要到几个周期后,也就是指令通过执行阶段后,我们才能知道是否要选择分支。类似地,如果取出的指令时ret,要到指令通过访存阶段,才能确定返回地址。
流水线过深,收益反而下降。
将流水线技术引入一个带反馈的系统会导致相邻指令间在发生相关时出现问题,在完成我们的设计之前,必须解决这个问题,这些相关有两种形式:
1、数据相关,下一条指令会用到这一条指令计算出的结果。
2、控制相关:一条指令要确定下一条指令的位置,例如在执行跳转,调用或返回指令时,这些相关可能会导致流水线产生计算错误,称为冒险。同相关一样,冒险也可以分为两类,数据冒险、控制冒险。
用暂停来避免数据冒险:暂停是一种常用的用来避免冒险的技术,暂停时,处理器会停止流水线中一条或多条指令,直到冒险条件不再满足。
用转发来避免数据冒险:我们PIPE的设计是在解码阶段从寄存器文件中读入源数据,但是有可能对这些源寄存器的写要在写回阶段才能进行,与其暂停直到写完成,不如简单地将要写的值传到流水线寄存器E作为源操作数。
有一类数据冒险不能单纯用转发来解决,因为存储器读是在流水线较后面才发生的,例如加载/使用冒险
所有需要流水线控制逻辑进行特殊处理的条件,都会导致我们流水线不能够实现每个时钟周期发射一条新指令的目标,我们可以通过确定往流水线中插入旗袍的频率来衡量这种效率的损失,因为插入旗袍会导致无用的流水线周期,一条返回指令会产生三个气泡,一个加载/使用冒险会产生一个,而一个预测错误的分支会产生两个。我们可以通过计算PIPE执行一条指令所需要的平均时钟周期数的估计值,来量化在这些出处罚对整体性能的影响,这种衡量方法称为CPI(每指令周期数)。这种衡量值是流水线平均吞吐量的倒数,不过时间单位是时钟周期,而不是微微秒。
另一种看待CPI的方法是,假设我们在处理器上运行某个基准程序,并观察执行阶段的运行。每个周期,执行阶段要么会处理一条指令,然后这条指令继续通过剩下的阶段,直到完成,要么会处理一个由三种特殊情况之一的而插入的气泡。
流水线化通过让不同的阶段进行操作,改进了系统的吞吐量性能。
管理复杂性是首要问题。我们想要优化使用硬件资源,在最小的成本下获得最大的性能。
我么不需要直接实现ISA.ISA的实现就意味着一个顺序的设计。为了获得更高的性能,我们想运用硬件能力以同时执行许多操作。这就导致要使用流水线化的设计、
硬件设计人员必须非常谨慎小心,一旦芯片被制造出来,就几乎不可能改正任何错误。
第五章 优化程序性能
编写高效程序需要两类活动,第一、我们必须选择一组最好的算法和数据结构;第二、我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。
对于第二部分,理解优化编译器的能力和局限性是很重要的。C有些特性,例如执行指针运算和强制类型转换的能力,使得对它优化很困难。程序员经常能够以一种使编译器更容易产生高效代码的方式来编写他们的程序。
在程序开发和优化过程中,我们必须考虑代码使用的方式,以及影响它的关键因素,程序员必须在实现和维护程序的简单性与它的运行速度之间做出权衡折衷。
事实上,编译器只能执行有限的程序转换,而且妨碍优化的因素还会阻碍这种优化,妨碍优化的因素就是程序行为中那些严重依赖于执行环境的方面。程序员必须编写易于优化的代码,以帮助编译器。就编译器来说,编译技术被分为“与机器无关”和“与机器有关”两类。
与机器无关:使用这些技术时可以不考虑将执行代码的计算机的特性。
与机器有关:这些技术是依赖于许多机器的低级细节的。
为了使程序性能最大化,程序员和编译器需要一个目标机器的模型,指明如何处理指令,以及各个操作的时序特性特性。
研究汇编代码是理解编译器以及产生的代码会如何运行的最有效的手段之一。
优化编译器的能力和局限性:要求他们决不能改变正确的程序行为,他们对程序行为、对使用他们的环境了解有限,需要很快地完成编译工作。