§第三章 程序的机器级表示
-
GCC以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,从而根据汇编代码生成可执行的机器代码。
-
现代编译器的优化产生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样的高效和简洁。用高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。
-
程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求程序员能直接使用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。
-
对于机器级编程来说,其中两种抽象尤为重要。第一种是机器级程序的格式和行为,定义为指令集体系结构(Instruction set architecture,ISA)它定义了处理器状态,指令的格式,以及每条指令对状态的改变。大多数ISA,包括IA32和x86_64,将程序的行为描述成好像每条指令按顺序执行的,一条信息结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致。第二种抽象是,机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去像是一个非常大的字节数组。储存器系统的实现实际上是将多个硬件存储器和操作系统软件组合起来的。
-
虽然C语言提供了一种模型,可以在存储器中声明和分配各种数据类型的对象,但是机器代码只是简单的把存储器看成一个很大的、按字节寻址的数组。C语言中的聚合数据类型,例如数组和结构,在机器代码中用连续的一组字节来表示。即使是标量数据类型,汇编代码也不区分有符号数或无符号整数,不区分各种类型的指针,甚至不区分指针和整数。
-
虽然IA32的32位地址可以寻址4GB的地址范围,但是通常一个程序只会访问几兆字节。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器储存器(processor memory)中的物理地址。
-
由于是从16位体系结构扩展成32位的,Intel用术语“字”(word)表示16位数据类型。因此,称32位数为“双字”(double words),称64位数为“四字”(quad words)。
-
浮点数有三种格式:单精度(4字节)值,对应于C语言中的float类型;双精度(8字节)值,对应C语言中的double类型;扩展精度(10字节)值,GCC用数据类型long double来表示扩展精度的浮点值。为了提高存储器系统性能,它将这样的浮点数存储为12字节数。
-
处理器使用流水线(pipelining)来获取高性能。在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分。这种方法通过重叠连续指令的步骤来获取高性能。当机器遇到条件跳转时,它常常还不能确定是否要进行跳转。处理器采用非常精密的分支预测逻辑试图猜测每条指令是否会执行。只要它的猜测还比较可靠,指令流水线中就充满了指令。另一方面,错误预测一个跳转要求处理器丢掉它为该跳转指令后所有指令已经做的工作,然后再开始从正确位置处起始的指令去填充流水线。正如我们看到的,这样的一个错误预测会招致很严重的惩罚。大约20~40个时钟周期的浪费,导致程序性能的严重下降。
-
一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。大多数机器,包括IA32,只提供转移控制到过程和从过程中转移出控制这种简单的指令。数据传递、局部变量的分配和释放通过操纵栈来实现。
-
程序寄存器组是唯一能被所有过程共享的资源。虽然在给定时刻只能有一个过程是活动的,但是我们必须保证当一个过程调用另一个过程的时候,被调用者不会覆盖某些调用者稍后会使用到的寄存器的值。为此,IA32采用了一组统一的寄存器使用惯例,所有的过程都必须遵守,包括程序库中的过程。
根据惯例,寄存器%eax,%edx和%ecx是调用者保存寄存器。当过程P调用过程Q的时候,Q就可以覆盖掉这些寄存器,而不会破坏P所需要的数据。另一方面,寄存器%ebx,%esi,%edi被划分为调用者保存寄存器。这意味着Q必须在覆盖这些寄存器的值之前先将其保存到栈里,并在返回前恢复它们。此外,根据惯例,被调用者要保存%ebp,%esp。
-
GCC坚持了一个x86编程的方针,也就是一个函数使用的所有的栈空间必须是16字节的整数倍。包括保存%ebp的4个字节和返回值的4个字节。采用这个规则是为了保证访问数据的严格对齐(alignment)。
-
编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些字节偏移作为存储器引用指令中的位移,从而产生对结构元素的引用。
-
许多计算机系统对基本数据类型合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或8)的倍数。这种对齐限制简化了形成处理器和存储器系统之间的硬件设计。无论数据是否对其,IA32硬件都能正常工作。不过,Intel还是建议要对数据进行对齐以提高存储器系统的性能。
-
对于大多数IA32指令来说,保持数据对齐能够提高效率,但是它不会影响程序的行为。另一方面,如果程序未对齐,有些实现多媒体操作的SEE指令就无法正确的工作。这些指令要求对16字节数据块进行操作,在SEE单元和存储器之间传送数据的指令要求存储器地址必须是16的倍数。任何试图以不满足对齐要求的地址来访问存储器都会导致异常(exception),默认的行为是程序终止。因此IA32的一个惯例是,确保每个栈帧的长度都是16字节的整数倍。编译器就可以在栈帧中以每个块的存储都是16字节对齐的方式来分配存储器。
-
Microsoft Windows对齐的要求更严格——任何K字节基本对象的地址都必须是K的倍数,K=2,4或者8。特别地,它要求一个double或者long long类型的数据的地址应该是8的倍数。这种要求提高了存储器性能,而代价是浪费了一些空间。Linux的惯例是8字节数据在4字节边界上对齐,这可能对i386很好,因为过去存储器十分缺乏,而存储器接口只有4字节宽。对于现代处理器来说,Microsoft的对齐策略就是更好的选择。在Windows和Linux上,数据类型long double都有4字节对齐的要求,为此GCC产生的IA32代码分配12个字节(虽然实际的数据类型只需要10个字节)。
- 缓冲区溢出的部分内容在以前博文已经有所涉及,不再总结。由于时间关系,64位汇编的特性暂不总结。
§第四章 处理器体系结构
本章略艰涩。学习了逻辑电路之后另行细读。
§第五章 优化程序性能
-
编写高效程序需要几类活动:第一,我们必须选择一组合适的算法和数据结构;第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。对于第二点,理解编译器的优化能力和局限性是很重要的。编写程序的方式中看似只是一点点的变动,都会引起编译器优化方式上很大的变化;第三项技术针对处理运算量特别大的计算,讲一个任务分为多个部分,这些部分可以在多核和多处理器的某种组合上并行的计算。
-
尽管做了广泛的变化,但还是要维护代码一定程度的简洁和可读性。
-
程序员必须编写容易优化的代码,以帮助编译器。主要包括:消除循环的低效率,减少过程调用和消除不必要的存储器引用。
-
在实际的处理器中,是同时对多条指令求值,这个现象叫做指令级并行。特别地,当一系列操作必须严格按照顺序执行时,就会遇到延迟界限(latency bound),因为下一条指令开始之间,这条指令必须结束。当代码中的数据相关限制了处理器利用指令级并行的能力时,延迟界限会限制程序性能。吞吐量界限(throughput bound)刻画了处理器单元的原始计算能力。这个界限是程序性能的终极限制。
-
没有任何编译器能用一个好的算法或数据结构代替低效率的算法或数据结构,因此程序设计的这些方面仍然是程序员主要关心的。