一起来理解计算机系统系列文章目录
1. 大致简介
程序结构篇
1. 信息表示与处理
2. 程序的机器级表示
程序运行篇
程序交互篇
文章目录
前言
本节主要介绍下计算机系统中的信息是以什么样的方式表示的,主要涉及无符号数、补码、以及浮点数。
本文参考资料
《深入理解计算机系统》
一、概述
计算机执行机器代码,汇编代码是机器代码的文本表示。
GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。
然后GCC 调用汇编器和链接器,根据汇编代码生成可执行的机器代码。
用高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。
与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。
二、程序编码
GCC是Linux上默认的编译器,我们也可以简单地用cc来启动它。
- 优化等级
编译选项-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。
使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。
因此我们会使用-Og优化作为学习工具,然后当我们增加优化级别时,再看会发生什么。
实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项-O1或-O2指定)被认为是较好的选择。 - 编译过程
实际上gcc命令调用了一整套的程序,将源代码转化成可执行代码。- C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。
- 编译器产生两个源文件的汇编代码,名字分别为pl.s和p2.s。
- 汇编器会将汇编代码转化成二进制目标代码文件p1.o和p2.o。
目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。 - 链接器将两个目标代码文件与实现库函数(例如printf)的代码合并,并产生最终的可执行代码文件p
(由命令行指示符-op指定的)。
1. 机器级代码
对于机器级编程来说, 其中两种抽象尤为重要。
- 由指令集体系结构或指令集架构(Instruction Set Architecture, ISA)
定义了机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响 。 - 机器级程序使用的内存地址是虚拟地址
提供的内存模型看上去是一个非常大的字节数组。
存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
-
常见处理器状态
- 程序计数器
通常称为"PC",在 x86-64 中用%rip表示,给出将要执行的下一条指令在内存中的地址。 - 整数寄存器
包含16个命名的位置,分别存储 64 位的值。
这些寄存器可以存储地址(对应于C语言的指针)或整数数据。
有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,
例如过程的参数和局部变量,以及函数的返回值。 - 条件码寄存器
保存最近执行的算术或逻辑指令的状态信息。
它们用来实现控制或数据流中的条件变化 ,比如说用来实现 if 和 while 语句。 - 一组向量寄存器可以存放一个或多个整数或浮点数值。
- 程序计数器
-
程序内存
机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。
汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。程序内存包含:
程序的可执行机器代码,
操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,
以及用户分配的内存块 (比如说用 malloc 库函数分配的)。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。
一条机器指令只执行一个非常基本的操作。
例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。
编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。
2. 代码示例
-
编译
long mult2(long, long) ; void multstore (long x, long y, long *dest) { long t = mult2 (x, y) ; *dest = t ; }
在命令行上使用“-s"选项,就能看到C语言编译器产生的汇编代码:
multstore: pushq %rbx movq %rdx, %rbx call mult2 movq %rax, (%rbx) popq %rbx ret
这段代码中已经除去了所有关于局部变星名或数据类型的信息。
-
汇编
如果我们使用”-c"命令行选项,GCC会编译并汇编该代码
这就会产生目标代码文件mstore.o,它是二进制格式的,所以无法直接查看。
1368字节的文件mstore.o中有一段14字节的序列,它的十六进制表示为:53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
这就是上面列出的汇编指令对应的目标代码。
从中得到一个重要信息,
即机器执行的程序只是一个字节序列,它是对一系列指令的编码。
机器对产生这些指令的源代码几乎一无所知。 -
反汇编
其中一些关于机器代码和它的反汇编表示的特性值得注意:- x86-64的指令长度从1到15个字节不等。
常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。 - 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。
例如,只有指令pushq %rbx 是以字节值53开头的。 - 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。
它不需要访问该程序的源代码或汇编代码。 - 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。
在我们的示例中,它省略了很多指令结尾的 ’ q '。这些后缀是大小指示符,在大多数情况中可以省略。
相反,反汇编器给call和ret指令添加了'矿后缀,同样,省略这些后缀也没有问题
若将一份代码汇编后,再次反汇编,最后将二者进行对比
这段代码与mstore.c反汇编产生的代码几乎完全一样。- 其中一个主要的区别是左边列出的地址不同——链接器将这段代码的地址移到了一段不同的地址范围中。
- 第二个不同之处在于链接器填上了callq指令调用函数mult2需要使用的地址(反汇编代码第4行)。
链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。 - 最后一个区别是多了两行代码(第8和9行)。这两条指令对程序没有影响,因为它们出现在返回指令后面(第7行)。
插入这些指令是为了使函数代码变为16字节,使得就存储器系统性能而言,能更好地放置下一个代码块。
- x86-64的指令长度从1到15个字节不等。
3. 关于格式的注释
所有以‘·’开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。
另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。
为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分伪指令,但包括行号和解释性说明。
对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性。
一种方法是用汇编代码编写整个函数,在链接阶段把它们和C函数组合起来。
另一种方法是利用GCC的支持,直接在C程序中嵌入汇编代码。
三、数据格式
大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。
例如,数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和movq(传送四字)。
后缀 ‘l’ 用来表示双字,因为32位数被看成是“长字(long word)”。
注意,汇编代码也使用后缀 ''l 来表示4字节整数和8字节双精度浮点数。
这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
四、访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。
这些寄存器用来存储整数数据和指针。
如图3-2中嵌套的方框标明的,指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。
字节级操作可以访问最低的字节,
16位操作可以访问最低的2个字节,
32位操作可以访问最低的4个字节,
而64位操作可以访问整个寄存器。
在后面的章节中,我们会展现很多指令,复制和生成1字节、2字节、4字节和8字节值。
当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:
- 生成1字节和2字节数字的指令会保持剩下的字节不变;
- 生成4字节数字的指令会把高位4个字节置为0。(后面这条规则是作为从IA32到x86-64的扩展的一部分而采用的。)
就像图3-2右边的解释说明的那样,在常见的程序里不同的寄存器扮演不同的角色。
其中最特别的是栈指针%rsp,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。
另外15个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。
更重要的是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。
1. 操作数指示符
大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。
x86-64支持多种操作数格式(参见图3-3)。
源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。
因此,各种不同的操作数的可能性被分为三种类型。
-
第一种类型是立即数(immediate),用来表示常数值。
在ATT格式的汇编代码中,立即数的书写方式是 ‘$’ 后面跟一个用标准C表示法表示的整数,比如, $-577或$0x1F。
不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。 -
第二种类型是寄存器(register),它表示某个寄存器的内容,
16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位。
在图3-3中,我们用符号ra来表示任意寄存器a,用引用R[ra]来表示它的值,
这是将寄存器集合看成一个数组R,用寄存器标识符作为索引。 -
第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。
因为将内存看成一个很大的字节数组,我们用符号Mb[Addr]表示对存储在内存中从地址Addr开始的b个字节值的引用。
为了简便,我们通常省去下标b。
如图3-3所示,有多种不同的寻址模式,允许不同形式的内存引用。
表中底部用语法Imm(rb, ri, s)表示的是最常用的形式。
这样的引用有四个组成部分:一个立即数偏移Imm,一个基址寄存器rb,一个变址寄存器ri,和一个比例因子s,
这里s必须是1、2、4或者8。基址和变址寄存器都必须是64位寄存器。
有效地址被计算为Imm + R[rb] + R[ri] • s。
引用数组元素时,会用到这种通用形式。
其他形式都是这种通用形式的特殊情况,只是省略了某些部分。
正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。
2. 数据传送指令MOV
最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。
操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。
我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。
在我们的讲述中,把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
图3-4列出的是最简单形式的数据传送指令-MOV类。
这些指令把数据从源位置复制到目的位置,不做任何变化。
MOV类由四条指令组成:movb、movw、movl和movq。
这些指令都执行同样的操作;
主要区别在于它们操作的数据大小不同:分别是1、2、4和8字节。
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。
目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。
将一个值从一个内存位置复制到另一个内存位置需要两条指令
–> 第一条指令将源值加载到寄存器中
–> 第二条将该寄存器值写入目的位置。
大多数情况中,MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。
唯一的例外是movl指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。
造成这个例外的原因是x86-64采用的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置成0。
第一个是源操作数,第二个是目的操作数
在将较小的源值复制到较大的目的时使用。
所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。
MOVZ类中的指令把目的中剩余的字节填充为0,
而MOVS类中的指令通过符号扩展来填充,把源操作的最高位进行复制。
剩下的指令的源操作数值是立即数值-1。
回想-1的十六进制表示形如FF…F,这里F的数量是表述中字节数量的两倍。
因此movb指令(第2行)把%rax的低位字节设置为FF,
而movw指令(第3行)把低2位字节设置为FFFF,剩下的字节保持不变。
movl指令(第4行)将低4个字节设置为FFFFFFFF,同时把高位4字节设置为00000000。
最后movq指令(第5行)把整个寄存器设置为FFFFFFFFFFFFFFFF。
movb指令(笫3行)不改变其他字节。
根据 源字节 的最高位,movsbq指令(第4行)将其他7个字节设为全1或全0。
由于十六进制A表示二进制值1010,符号扩展会把高位字节都设置为FF。
movzbq指令(笫5行)总是将其他7个字节全都设置为0
3. 数据传送实例
函数通过把值存储在寄存器%rax或该寄存器的某个低位部分中返回
首先,我们看到C语言中所谓的”指针”其实就是地址。
间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。
其次,像x这样的局部变量通常是保存在寄存器中,而不是内存中。
访问寄存器比访问内存要快得多。
4. 压入和弹出栈数据
通过push操作把数据压入栈中,通过pop操作删除数据;
它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。
栈可以实现为一个数组,总是从数组的一端插入和删除元素。
这一端被称为栈顶。在x86-64中,程序栈存放在内存中某个区域。
如图3-9所示,栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址中最低的。
(根据惯例,我们的栈是倒过来画的,栈“顶”在图的底部。)
栈指针%rsp保存着栈顶元素的地址。
pushq指令的功能是把数据压入到栈上,而popq指令是弹出数据。
这些指令都只有一个操作数一一压入的数据源和弹出的数据目的。
将一个四字值压人栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。
因此,指令 pushq %rbp 的行为等价于下面两条指令:
subq $8, %rsp
movq %rbp, (%rsp)
它们之间的区别是在机器代码中pushq指令编码为1个字节,而上面那两条指令一共需要8个字节。
因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内的任意位置。
五、算术和逻辑操作
1. 加载有效地址leaq
加载有效地址指令leaq实际上是movq指令的变形。
它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。
它的第一个操作数是将有效地址写入到目的操作数。
在图3-10中我们用C语言的地址操作符 &S 说明这种计算。
这条指令可以为后面的内存引用产生指针。
另外,它还可以简洁地描述普通的算术操作。
例如,如果寄存器%rdx的值为x,那么指令leaq 7(%rdx, %rdx, 4),%rax将设置寄存器%rax的值为5x+7。
编译器经常发现leaq的一些灵活用法,根本就与有效地址计算无关。
目的操作数必须是一个寄存器。
leaq指令能执行加法和有限形式的乘法,在编译如上简单的算术表达式时,是很有用处的。
2. 一元和二元操作
-
一元操作
只有一个操作数,既是源又是目的。
这个操作数可以是一个寄存器,也可以是一个内存位置。
比如说,指令incq (%rsp)会使栈顶的8字节元素加1。 -
二元操作
其中,第二个操作数既是源又是目的。
要注意,源操作数是第一个,目的操作数是第二个,对于不可交换操作来说,这看上去很奇特。
例如,指令subq %rax,%rdx使寄存器%rdx的值减去%rax中的值。
(将指令解读成“从**%rdx中减去%rax**"会有所帮助。)第一个操作数可以是立即数、寄存器或是内存位置。
第二个操作数可以是寄存器或是内存位置。
注意,当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。
3. 移位操作
先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。
移位量可以是一个立即数,或者放在单字节寄存器%cl中。
4. 特殊算术操作
六、控制
机器代码提供两种基本的低级机制来实现有条件的行为:
测试数据值,然后根据测试的结果来改变控制流或者数据流。
通常,C语言中的语句和机器代码中的指令都是按照它们在程序中出现的次序,顺序执行的。
用jump指令可以改变一组机器代码指令的执行顺序,
jump指令指定控制应该被传递到程序的某个其他部分,可能是依赖于某个测试的结果。
1. 条件码
除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。
可以检测这些寄存器来执行条件分支指令。
最常用的条件码有:
- CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF:符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作导致一个补码溢出正溢出或负溢出。
leaq指令不改变任何条件码,因为它是用来进行地址计算的。
除此之外,图3-10中列出的所有指令都会设置条件码。
对于逻辑操作,例如XOR,进位标志和溢出标志会设置成0。
对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为0。
INC和DEC指令会设置溢出和零标志,但是不会改变进位标志
还有两类指令(有8、16、32和64位形式),它们只设置条件码而不改变任何其他寄存器;如图3-13所示。
-
CMP指令根据两个操作数之差来设置条件码。
除了只设置条件码而不更新目的寄存器之外,CMP指令与SUB指令的行为是一样的。
在ATT格式中,列出操作数的顺序是相反的,这使代码有点难读。
如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定两个操作数之间的大小关系。 -
TEST指令的行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。
典型的用法是,两个操作数是一样的(例如,testq %rax, %rax用来检查%rax是负数、零,还是正数),
或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。
2. 访问条件码set
条件码通常不会直接读取,常用的使用方法有三种:
- 可以根据条件码的某种组合,将一个字节设置为0或者1,我们将这一整类指令称为SET指令;
它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。
这些指令的后缀表示不同的条件而不是操作数大小 - 可以条件跳转到程序的某个其他的部分,
- 可以有条件地传送数据。
一条SET指令的目的操作数是低位单字节寄存器元素(图3-2)之一,或是一个字节的内存位置,指令会将这个字节设置成0或者1。
为了得到一个32位或64位结果,我们必须对高位清零。
一个计算C语言表达式 a < b 的典型指令序列如下所示,这里a和b都是long类型:
注意cmpq指令的比较顺序(第2行)。
虽然参数列出的顺序先是 %rsi(b) 再是 %rdi(a) ,实际上比较的是a和b。
还要记得,正如在3.4.2节中讨论过的那样,movzbl指令不仅会把%eax的高3个字节清零,还会把整个寄存器%rax的高4个字节都清零。
某些底层的机器指令可能有多个名字,我们称之为“同义名(synonym)”。
比如说,setg(表示“设置大于”)和setnle(表示“设置不小于等于”)指的就是同一条机器指令。
编译器和反汇编器会随意决定使用哪个名字。
来看sete的情况,即“当相等时设置(setwhenequal)"指令。
当a=b时,会得到t=0,因此零标志置位就表示相等。
类似地,考虑用setl,即“当小于时设置(setwhenless)"指令,测试一个有符号比较。
对于无符号比较的测试,现在设a和b是变量a和b的无符号形式表示的整数。
在执行计算t = a - b中,当a - b < 0时,CMP指令会设置进位标志,
因而无符号比较使用的是进位标志和零标志的组合。
注意到机器代码如何区分有符号和无符号值是很重要的。
同C语言不同,机器代码不会将每个程序值都和一个数据类型联系起来。
相反,大多数情况下,机器代码对于有符号和无符号两种情况都使用一样的指令,
这是因为许多算术运算对无符号和补码算术都有一样的位级行为。
有些情况需要用不同的指令来处理有符号和无符号操作,
例如,使用不同版本的右移、除法和乘法指令,以及不同的条件码组合。
3. 跳转指令jmp
正常执行的情况下,指令按照它们出现的顺序一条一条地执行。
跳转(jump)指令会导致执行切换到程序中一个全新的位置。
在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。
在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码为跳转指令的一部分。
图3-15列举了不同的跳转指令。jmp指令是无条件跳转。
它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。
汇编语言中,直接跳转是给出一个标号作为跳转目标的,间接跳转的写法是 ‘ * ’ 后面跟一个操作数指示符,
使用图3-3中描述的内存操作数格式中的一种。
- 用寄存器%rax中的值作为跳转目标
jmp *%rax
- 以%rax中的值作为读地址,从内存中读出跳转目标。
jmp *(%rax)
表中所示的其他跳转指令都是有条件的它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。
这些指令的名字和跳转条件与SET指令的名字和设置条件是相匹配的(参见图3-14)。
同SET指令一样,一些底层的机器指令有多个名字。条件跳转只能是直接跳转。
4. 跳转指令的编码
跳转指令有几种不同的编码,但是最常用都是PC相对的(PC-relative)。
也就是,它们会**将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码**。
这些地址偏移量可以编码为1、2或4个字节。
第二种编码方法是给出“绝对“地址,用4个字节直接指定目标。汇编器和链接器会选择适当的跳转目的编码。
当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
跳转指令提供了一种实现条件执行(if)和几种不同循环结构的方式。
5. 用条件控制来实现条件分支
将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。
汇编代码的实现(图3-16c)首先比较了两个操作数(第2行),设置条件码。
如果比较的结果表明x大于或者等于y,那么它就会跳转到第8行,增加全局变量ge_cnt,计算x-y作为返回值并返回。
由此我们可以看到absdiff_se对应汇编代码的控制流非常类似于gotodiff—se的goto代码。
6. 用条件传送来实现条件分支cmov
实现条件操作的传统方法是通过使用控制的条件转移。
当条件满足时,程序沿着一条执行路径执行,而当条件不满足时,就走另一条路径。
这种机制简单而通用,但是在现代处理器上,它可能会非常低效。
一种替代的策略是**使用数据的条件转移**。
这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。
只有在一些受限制的情况中,这种策略才可行,
但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。
我们将介绍这一策略,以及它在x86-64上的实现。
处理器通过使用流水线(pipelining)来获得高性能,
在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分
(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。
这种方法通过重叠连续指令的步骤来获得高性能,
例如,在取一条指令的同时,执行它前面一条指令的算术运算。
要做到这一点,要求能够事先确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令。
当机器遇到条件跳转(也称为“分支”)时,只有当分支条件求值完成之后,才能决定分支往哪边走。
处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。
只要它的猜测还比较可靠(现代微处理器设计试图达到90%以上的成功率),指令流水线中就会充满着指令。
另一方面,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令己做的工作,
然后再开始用从正确位置处起始的指令去填充流水线。
正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。
另一方面,无论测试的数据是什么,编译出来使用条件传送的代码所需的时间都是大约8个时钟周期。
控制流不依赖于数据,这使得处理器更容易保持流水线是满的。
图3-18列举了x86-64上一些可用的条件传送指令。
每条指令都有两个操作数:源寄存器或者内存地址S,和目的寄存器R。
与不同的SET(3.6.2节)和跳转指令(3.6.3节)一样,这些指令的结果取决于条件码的值。
源值可以从内存或者源寄存器中读取,但是只有在指定的条件满足时,才会被复制到目的寄存器中。
源和目的的值可以是16位、32位或64位长。不支持单字节的条件传送。
无条件指令的操作数的长度显式地编码在指令名中(例如movw和movl),
汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,
所以对所有的操作数长度,都可以使用同一个的指令名字。
同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。
处理器只是读源值(可能是从内存中),检查条件码,然后要么更新目的寄存器,要么保持不变。
基于条件传送的代码,会对then-expr和else-expr都求值,最终值的选择基于对testexpr的求值。
如果这两个表达式中的任意一个可能产生错误条件或者副作用,就会导致非法的行为。
longcread (long*xp)
{
return(xp ? *xp : O);
}
这个实现是非法的,因为即使当测试为假时,movq指令(第2行)对xp的间接引用还是发生了,导致一个间接引用空指针的错误。
所以,必须用分支代码来编译这段代码。
使用条件传送也不总是会提高代码的效率。
例如,如果then-expr或者else-expr的求值需要大量的计算,那么当相对应的条件不满足时,这些工作就白费了。
编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。
说实话,编译器并不具有足够的信息来做出可靠的决定;例如,它们不知道分支会多好地遵循可预测的模式。
我们对GCC的实验表明,只有当两个表达式都很容易计算时,例如表达式分别都只是一条加法指令,它才会使用条件传送。
根据我们的经验,即使许多分支预测错误的开销会超过更复杂的计算,GCC还是会使用条件控制转移。
所以,总的来说,条件数据传送提供了一种用条件控制转移来实现条件操作的替代策略。
它们只能用于非常受限制的情况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。
7. 循环
C语言提供了多种循环结构,即do-while、while和for。
汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
GCC和其他汇编器产生的循环代码主要基于两种基本的循环模式。
7.1 do-while 循环
do
body-statement
while (test-expr) ;
--> 翻译后
loop :
body-statement
t = test-expr ;
if (t)
goto loop ;
条件跳转指令jg(第7行)是实现循环的关键指令,它决定了是需要继续重复还是退出循环。
7.2 while循环
while (test-expr)
body-statement
在第一次执行body-statement之前,它会对test-expr求值,循环有可能就中止了。
有很多种方法将while循环翻译成机器代码,GCC在代码生成中使用其中的两种方法。
这两种方法使用同样的循环结构,与do-while一样,不过它们实现初始测试的方法不同。
第一种翻译方法,我们称之为跳转到中间(jumptomiddle),它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。
可以用以下模板来表达这种方法,这个模板把通用的while循环格式翻译到goto代码:
goto test;
loop:
body-statement
test:
t=test-expr;
if(t)
gotoloop
第二种翻译方法,我们称之为guarded-do,首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-while循环。
当使用较高优化等级编译时,例如使用命令行选项-01,GCC会采用这种策略。
可以用如下模板来表达这种方法,把通用的while循环格式翻译成do-while循环:
t = test-expr;
if(!t)
goto done;
do
body-statement
while(test-expr);
done:
--> 翻译成
t = test-expr;
if(!t)
goto done;
loop:
body-statement
t=test-expr;
if(t)
goto loop;
done:
7.3 for循环
for (init-expr ; test-expr ; update-expr)
body-statement
--> 等同于
init-expr ;
while (test-expr) {
body-statement
update-expr;
}
--> 跳转到中间策略会得到如下goto代码
init-expr;
goto test;
loop:
body-statement
update-expr;
test :
t = test-expr;
if (t)
goto loop;
--> guarded-do 策略得到 :
init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if (t)
goto loop;
done:
8. switch语句
根据一个整数索引值进行多重分支。
它们不仅提高了C代码的可读性,而且通过使用跳转表(jumptable)这种数据结构使得实现更加高效。
跳转表是一个数组,表项t是一个代码段的地址,这个代码段实现当开关索引值等于1时程序应该采取的动作。
程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。
和使用一组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。
GCC根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句。
当开关情况数量比较多(例如4个以上),并且值的范围跨度比较小时,就会使用跳转表。
数组 jt 包含7个表项,每个都是一个代码块的地址。
这些位置由代码中的标号定义,在 jt 的表项中由代码指针指明,由标号加上飞矿前缀组成。
(回想运算符&创建一个指向数据值的指针。
在做这个扩展时,GCC的作者们创造了一个新的运算符&&,这个运算符创建一个指向代码位置的指针。)
原始的C代码有针对值100、102-104和106的清况,但是开关变量n可以是任意整数。
编译器首先将n减去100,把取值范围移到0和6之间,创建一个新的程序变量,在我们的C版本中称为index。
补码表示的负数会映射成无符号表示的大正数,利用这一事实,将index看作无符号值,从而进一步简化了分支的可能性。
因此可以通过测试index是否大千6来判定index是否在0~6的范围之外。
执行switch语句的关键步骤是通过跳转表来访问代码位置。
jmp指令的操作数有前缀" * ",表明这是一个间接跳转,
操作数指定一个内存位置,
索引由寄存器%rsi给出,这个寄存器保存着index的值。
七、过程
过程是软件中一种很重要的抽象。
它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。
然后,可以在程序中不同的地方调用这个函数。
不同编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等,
但是它们有一些共有的特性。
要提供对过程的机器级支持,必须要处理许多不同的属性。
为了讨论方便,假设过程P调用过程Q,Q执行后返回到P。
这些动作包括下面一个或多个机制:
- 传递控制
在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,
然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。 - 传递数据
P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。 - 分配和释放内存
在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
1. 运行时栈
C语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于
使用了栈数据结构提供的后进先出的内存管理原则。
当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。
另一方面,当Q返回时,任何它所分配的局部存储空间都可以被释放。
因此,程序可以用栈来管理它的过程所需要的存储空间,
栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。
当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会释放掉。
x86-64的栈向低地址方向增长,而栈指针%rsp指向栈顶元素。(push / popq)
当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。
这个部分称为过程的栈帧(stackfram)。
当前正在执行的过程的帧总是在栈顶。
当过程P调用过程Q时,会把返回地址压入栈中,指明当Q返回时,要从P程序的哪个位置继续执行。
我们把这个返回地址当做P的栈帧的一部分,因为它存放的是与P相关的状态。
Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。
在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。
大多数过程的栈帧都是定长的,在过程的开始就分配好了。但是有些过程需要变长的帧。
通过寄存器,过程P可以传递最多6个整数值(也就是指针和整数),
但是如果Q需要更多的参数,P可以在调用Q之前在自己的栈帧里存储好这些参数。
为了提高空间和时间效率,x86-64过程只分配自己所需要的栈帧部分。
例如,许多过程有6个或者更少的参数,那么所有的参数都可以通过寄存器传递。
因此,图3-25中画出的某些栈帧部分可以省略。
实际上,许多函数甚至根本不需要栈帧。
当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数
(有时称之为叶子过程,此时把过程调用看做树结构)时,就可以这样处理。
例如,到目前为止我们仔细审视过的所有函数都不需要栈帧。
2. 转移控制call
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。
不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。
在x86-64机器中,这个信息是用指令call Q调用过程Q来记录的。
该指令会把地址A压入栈中,并将PC设置为Q的起始地址。
压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。
对应的指令ret会从栈中弹出地址A,并把PC设置为A。
下表给出的是call和ret指令的一般形式:
call指令有一个目标,即指明被调用过程起始的指令地址。
同跳转一样,调用可以是直接的,也可以是间接的。
在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是*后面跟一个操作数指示符,使用的是图3-3中描述的格式之一。
可以看到,这种把返回地址压入栈的简单的机制能够让函数在稍后返回到程序中正确的点。
C语言(以及大多数程序语言)标准的调用/返回机制刚好与栈提供的后进先出的内存管理方法吻合。
3. 数据传送
过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。
x86-64中,大部分过程间的数据传送是通过寄存器实现的。
当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中。
类似地,当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。
可以通过寄存器最多传递6个整型(例如整数和指针)参数。
寄存器的使用是有特殊顺序的,寄存器使用的名字取决千要传递的数据类型的大小,如图3-28所示。
会根据参数在参数列表中的顺序为它们分配寄存器。
可以通过64位寄存器适当的部分访问小千64位的参数。
如果一个函数有大于6个整型参数,超出6个的部分就要通过栈来传递。
图3-29b中给出proc生成的汇编代码。
前面6个参数通过寄存器传递,后面2个通过栈传递,就像图3-30中画出来的那样。
可以看到,作为过程调用的一部分,返回地址被压入栈中。
因而这两个参数位千相对千栈指针距离为8和16的位置。
在这段代码中,我们可以看到根据操作数的大小,使用了ADD指令的不同版本:
a1(long)使用addq,a2(int)使用addl,a3(short)使用addw,而a4(char)使用addb。
请注意第6行的movl指令从内存读入4字节,而后面的addb指令只使用其中的低位一字节。
4. 栈上的局部存储
到目前为止我们看到的大多数过程示例都不需要超出寄存器大小的本地存储区域。
不过有些时候,局部数据必须存放在内存中,常见的情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符’&',因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
来看一个处理地址运算符的例子,图3-31a中给出的两个函数。
图3-31b展示了caller是如何用栈帧来实现这些局部变量的。
caller的代码开始的时候把栈指针减掉了16;实际上这就是在栈上分配了16个字节。
S表示栈指针的值,可以看到这段代码计算&arg2为S+8(第5行),而&argl为S。
因此可以推断局部变量argl和arg2存放在栈帧中相对千栈指针偏移量为0和8的地方。
当对swap_add的调用完成后,caller的代码会从栈上取出这两个值(第8~9行),计算它们的差,
再乘以swap_add在寄存器%rax中返回的值(第10行)。
最后,该函数把栈指针加16,释放栈帧(第11行)。
通过这个例子可以看到,运行时栈提供了一种简单的、在需要时分配、函数完成时释放局部存储的机制。
5. 寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。
被调用者不会覆盖调用者稍后会使用的寄存器值。
为此,x86-64采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。
根据惯例,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。
有了这条惯例,P的代码就能安全地把值存在被调用者保存寄存器中(当然,要先把之前的值保存到栈上),
调用Q,然后继续使用寄存器中的值,不用担心值被破坏。
所有其他的寄存器,除了栈指针%rsp,都分类为调用者保存寄存器。这就意味着任何函数都能修改它们。
可以这样来理解“调用者保存”这个名字:过程P在某个此类寄存器中有局部数据,然后调用过程Q。
因为Q可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是P(调用者)的责任。
6. 递归过程
每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。
此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储。
八、数组分配和访问
C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。
在机器代码中,这些指针会被翻译成地址计算。
优化编译器非常善于简化数组索引所使用的地址计算。
不过这使得C代码和它到机器代码的翻译之间的对应关系有些难以理解。
1. 基本原则
对于数据类型T和整型常数N,声明如下:
TA[N];
起始位置表示为 x A 。这个声明有两个效果。
首先,它在内存中分配一个L * N字节的连续区域,这里L是数据类型T的大小(单位为字节)。
其次,它引入了标识符A,可以用A来作为指向数组开头的指针,这个指针的值就是 x A 。
可以用0 ~ N-1的整数索引来访问该数组元素。数组元素i会被存放在地址为x A + L*i 的地方。
x86-64的内存引用指令可以用来简化数组访问。
例如,假设E是一个int型的数组,而我们想计算 E[i],在此,E的地址存放在寄存器 %rdx中,而 i 存放在寄存器%rcx中。
然后,指令
movl (%rdx, %rcx, 4), %eax
会执行地址计算xE + 4i,读这个内存位置的值,并将结果存放到寄存器%eax中。
允许的伸缩因子1、2、4和8覆盖了所有基本简单数据类型的大小。
2. 指针运算
C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。
也就是说,如果p是一个指向类型为T的数据的指针,p的值为xp,那么表达式 p + i 的值为xp+L*i,这里L是数据类型T的大小。
单操作数操作符’ & ‘和’ * '可以产生指针和间接引用指针。
也就是,对千一个表示某个对象的表达式Expr,&Expr是给出该对象地址的一个指针。
对于一个表示地址的表达式AExpr,*AExpr给出该地址处的值。因此,表达式Expr与*&Expr是等价的。
可以对数组和指针应用数组下标操作。
数组引用 A[i] 等同千表达式*(A+i)。它计算第 i 个数组元素的地址,然后访问这个内存位置。
3. 嵌套的数组
当我们创建数组的数组时,数组分配和引用的一般原则也是成立的。
例如,声明
intA[5][3];
等价于下面的声明
typedef int row3_t[3];
row3_t A[5];
数据类型row3_t被定义为一个3个整数的数组。
数组A包含5个这样的元素,每个元素需要12个字节来存储3个整数。
整个数组的大小就是4X5X3=60字节。
数组A还可以被看成一个5行3列的二维数组,用 A [0] [0] 到 A [4] [2]来引用。
数组元素在内存中按照“行优先"的顺序排列,意味着第0行的所有元素,可以写作A[0],后面跟着第1行的所有元素(A[i]),
以此类推,如图3-36所示。
要访问多维数组的元素,
编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用某种MOV指令。
通常来说,对于一个声明如下的数组:
4. 定长数组
#define N 16
typedef int fix_matrix [N][N];
5. 变长数组
int A[expr1][expr2];
--> 要访问 n X n 数组的元素i,j
int var_ele (long n, int A[n][n], long i, long j) {
return A[i][j];
}
这个地址的计算类似千定长数组的地址计算(参见3.8.3节),不同点在千
- 由于增加了参数n,寄存器的使用变化了;
- 用了乘法指令来计算n·i(第2行),而不是用leaq指令来计算3i。
因此引用变长数组只需要对定长数组做一点儿概括。
动态的版本必须用乘法指令对i伸缩n倍,而不能用一系列的移位和加法。
在一些处理器中,乘法会招致严重的性能处罚,但是在这种情况中无可避免。
九、异质的数据结构
1. 结构
C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。
用名字来引用结构的各个组成部分。类似千数组的实现,
结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。
编译器维护关于每个结构类型的信息,指示每个字段(如Id)的字节偏移。
它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。
struct rec {
int i ;
int j ;
int a [2] ;
int *p ;
} ;
这个结构包括4个字段:两个4字节int、一个由两个类型为int的元素组成的数组和一个8字节整型指针,总共是24个字节:
为了访问结构的字段,编译器产生的代码要将结构的地址加上适当的偏移。
例如,假设struct rec*类型的变最r放在寄存器%rdi中。那么下面的代码将元素r->i复制到元素r->j:
2. 联合
联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。
联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的内存块。
3. 数据对齐
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或8)的倍数。
这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
例如,假设一个处理器总是从内存中取8个字节,则地址必须为8的倍数。
如果我们能保证将所有的double类型数据的地址对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。
否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
无论数据是否对齐,x86-64硬件都能正确工作。
不过,Intel还是建议要对齐数据以提高内存系统的性能。
对齐原则是任何K字节的基本对象的地址必须是K的倍数。
可以看到这条原则会得到如下对齐:
对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求。
而结构本身对它的起始地址也有一些对齐要求。
十、在机器级程序中将控制与数据结合起来
1. 理解指针
指针和它们映射到机器代码的关键原则
- 每个指针都对应一个类型。这个类型表明该指针指向的是哪一类对象。
- 每个指针都有一个值。这个值是某个指定类型的对象的地址。
特殊的NULL(0)值表示该指针没有指向任何地方 - 指针用" & "运算符创建。
这个运算符可以应用到任何lvalue类的C表达式上,lvalue意指可以出现在赋值语句左边的表达式。
这样的例子包括变量以及结构、联合和数组的元素。 - " * " 操作符用于间接引用指针。
其结果是一个值,它的类型与该指针的类型一致。
间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取 - 数组与指针紧密联系。
一个数组的名字可以像一个指针变量一样引用(但是不能修改)。
数组引用(例如a[3])与指针运算和间接引用(例如*(a+3))有一样的效果。 - 数组引用和指针运算都需要用对象大小对偏移量进行伸缩。
当我们写表达式p+i,这里指针p的值为p,得到的地址计算为p+L*i,这里L是与p相关联的数据类型的大小。 - 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
强制类型转换的一个效果是改变指针运算的伸缩。
例如,如果p是一个char类型的指针,它的值为p,那么表达式(int)p+7计算为p+28,而(int*)(p+7)计算为p+7。
(回想一下,强制类型转换的优先级高千加法。) - 指针也可以指向函数。
这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个其他部分调用。
2. GDB
todo
3. 内存越界引用和缓冲区溢出
我们已经看到,C对千数组引用不进行任何边界检查,
而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。
这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。
通常,在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。
4. 对抗缓冲区溢出攻击
缓冲区溢出攻击的普遍发生给计算机系统造成了许多的麻烦。现代的编译器和操作系统实现了很多机制,以避免遭受这样的攻击,限制入侵者通过缓冲区溢出攻击获得系统控制的方式。
-
栈随机化
栈随机化的思想使得栈的位置在程序每次运行时都有变化。
因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。
实现的方式是:程序开始时,在栈上分配一段0~n字节之间的随机大小的空间,
例如,使用分配函数alloca在栈上分配指定字节数量的空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。
分配的范围n必须足够大,才能获得足够多的栈地址变化,但是又要足够小,不至千浪费程序太多的空间。
在Linux系统中,栈随机化已经变成了标准行为。
它是更大的一类技术中的一种,这类技术称为地址空间布局随机化(Address-SpaceLayoutRandomization),或者简称ASLR -
栈破坏检测
计算机的第二道防线是能够检测到何时栈已经被破坏。破坏通常发生在当超越局部缓冲区的边界时。
在C语言中,没有可靠的方法 来防止对数组的越界写。
但是,我们能够在发生了越界写的时候,在造成任何有害结果之前,尝试检测到它。最近的GCC版本在产生的代码中加入了一种栈保护者(stackprotector)机制,来检测缓冲区越界。
其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊调用者的栈帧的金丝雀(canary)值,
这个金丝雀值,也称为哨兵值(guard value),是在程序每次运行时随机产生的,
因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。
如果是的,那么程序异常中止。栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。
它只会带来很小的性能损失,特别是因为GCC只在函数中有局部char类型缓冲区的时候才插入这样的代码。
当然,也有其他一些方法会破坏一个正在执行的程序的状态,但是降低栈的易受攻击性能够对抗许多常见的攻击策略。 -
限制可执行代码区域
最后一招是消除攻击者向系统中插入可执行代码的能力。
一种方法是限制哪些内存区域能够存放可执行代码。
在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。
其他部分可以被限制为只允许读和写。正如第9章中会看到的,虚拟内存空间在逻辑上被分成了页(page),典型的每页是2048或者4096个字节。
硬件支持多种形式的内存保护,能够指明用户程序和操作系统内核所允许的访问形式。许多系统允许控制三种访问形式:读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看作机器级代码)。
以前,x86体系结构将读和执行访问控制合并成一个1位的标志,这样任何被标记为可读的页也都是可执行的。
栈必须是既可读又可写的,因而栈上的字节也都是可执行的。
已经实现的很多机制,能够限制一些页是可读但是不可执行的,然而这些机制通常会带来严重的性能损失。
5. 支持变长栈帧
todo
# 总结 todo