在第1章和第2章中,我们讨论了如何在汇编语言程序中声明和访问简单变量。本章将全面讨论x86-64内存访问过程。在本章中,我们将学习如何有效地组织对变量的声明,以加速对其数据的访问。我们还将讨论x86-64栈,以及如何处理栈中的数据。
本章将讨论几个重要概念,具体包括以下内容:
●内存组织
●程序的内存分配
●x86-64内存寻址模式
●间接索引寻址模式和缩放索引寻址模式
●数据类型的强制转换
●x86-64栈
本章将指导读者如何有效地利用计算机的内存资源。
一个正在运行的程序会以多种方式使用内存,具体取决于数据的类型。以下是汇编语言程序中常见的一些数据分类。
● 代码: 对机器指令进行编码的内存值。
● 未初始化的静态数据: 计算机为整个程序运行期间存在的未初始化变量在内存中预留的一个区域。当Windows将程序加载到内存中时,会将该存储区域的值全部初始化为0。
● 初始化的静态数据: 在程序运行的整个过程中一直存在的一段内存。Windows会从程序的可执行文件中,加载该段中出现的所有变量的值,以便在程序首次开始执行时变量都具有初始值。
● 只读数据: 与初始化的静态数据类似,Windows从可执行文件中,加载此内存段的初始数据。但是,该内存段被标记为只读(read-only),以防止无意中修改数据。程序通常在该内存段中存储常量和其他不变的数据(请注意,代码段也被操作系统标记为只读)。
● 堆: 内存中的特殊段,用来保存动态分配的存储空间。C语言的malloc和free等函数负责在堆区域中分配和释放存储空间。4.6.4节将更详细地阐述动态内存分配。
● 栈: 内存中的特殊段,程序在其中维护过程和函数的局部变量、程序状态信息以及其他瞬态数据(瞬态数据)。有关栈段的更多信息,请参阅3.9节中的相关内容。
以上所述都是常见程序(汇编语言程序或其他语言程序)中的典型内存段。较小的程序不会用到以上所有内存段(程序一般至少包含代码段、栈段和数据段)。复杂的程序可能会为自己要实现的目标在内存中创建额外的内存段。有些程序可能会将若干内存段组合在一起。例如,许多程序将代码段和只读数据段合并到内存的同一个段中(因为两个段中的数据都标记为只读)。有些程序会将未初始化的数据段和初始化的数据段合并在一起(将未初始化的所有变量都初始化为0)。通常由链接器程序完成合并段的操作。有关合并段的详细信息,请参阅Microsoft链接器的相关文档 。
Windows倾向于将不同类型的数据放入内存的不同段中。尽管在运行链接器时,可以通过指定各种参数,以根据选择重新配置内存,但在默认情况下,Windows使用类似于图3-1的组织架构将MASM程序加载到内存中 。
图3-1 MASM典型的运行时内存组织架构
Windows本身将保留最低位的内存地址。通常情况下,应用程序无法访问这些低位地址的数据(或者执行这些低位地址的指令)。操作系统保留此内存空间的一个原因是它可以帮助捕获空指针引用,也就是说,如果程序尝试访问内存位置0(NULL),那么操作系统将生成一般保护故障(general protection fault),也称为段故障(segmentation fault),这意味着程序访问了不包含有效数据的内存位置。
内存映射中的其余6个段包含与程序相关的不同类型的数据。内存的这些段包括栈段、堆段、“.code”(代码)段、“.data”(静态数据)段、“.const”(常量)段、“.data?”(存储)段。每个段都对应一种可以在MASM程序中创建的数据类型。接下来将展开阐述“.code”段、“.data”段、“.const”段和“.data?”段 。
“.code”段中包含MASM程序中的机器指令。MASM将用户编写的每条机器指令转换为一个或多个字节值组成的序列。在程序执行期间,CPU将这些字节值解释为机器指令。
在默认情况下,当MASM链接用户的程序时,会告诉系统用户的程序可以执行指令并从代码段读取数据,但不能将数据写入代码段。如果用户试图将任何数据存储到代码段中,那么操作系统将生成一般保护错误。
“.data”段通常是程序放置变量的地方。除了声明静态变量外,还可以将数据列表嵌入“.data”段。一些伪指令既可以将数据嵌入“.code”段,也可以将数据嵌入“.data”段,如byte、word、dword、qword等伪指令。请考虑下面的例子:
.data
b byte 0
byte 1,2,3
u dword 1
dword 5,2,10;
c byte?
byte 'a','b','c','d','e','f';
bn byte?
byte true;假定true被定义为1
MASM使用这些伪指令将值放置到“.data”内存段时,会将这些值写入伪指令前的变量所在段的后面。例如,字节值1、2和3被写入“.data”内存段中b变量的第0个字节之后。由于没有与这些值相关联的标签,因此用户无法在程序中直接访问这些值,但可以使用索引寻址模式访问这些额外的值。
在前面的示例中,请注意变量c和bn没有(显式)初始值。如果没有提供初始值,那么MASM会将“.data”段中的变量初始化为0,因此MASM将NULL字符(ASCII代码0)指定给c,作为其初始值;将false指定为bn的初始值(假设false定义为0)。“.data”段中的变量声明总是会占用内存,即使用户没有为这些变量分配初始值。
“.const”数据段用于保存常量、表和程序在执行期间无法更改的其他数据。可以在“.const”段中声明并创建只读对象。“.const”段类似于“.data”段,但存在以下3个区别。
●常量段以保留关键字“.const”开始,而数据段以保留关键字“.data”开始。
●“.const”段中声明的所有变量都必须有一个初始值。
●在程序运行过程中,系统不允许用户将数据写入“.const”段中的变量中。
下面是一个例子:
.const
pi real43.14159
e real42.71
MaxU16 word 65535
MaxI16 sword 32767
所有的常量对象声明必须具有初始值,因为无法在程序控制下初始化对象的值。出于许多目的,可以将常量对象视为字面常量。由于它们实际上是内存对象,所以它们的行为类似于(只读)数据对象。并不是在所有允许字面常量的地方都可以使用常量对象;例如,不能在寻址模式中将常量对象用作位移(具体请参阅3.7节中的相关内容),并且不能在常量表达式中使用常量对象。在实践中,可以在任何能合法读取数据变量的地方,使用常量对象。
与“.data”段一样,使用byte、word、dword等伪指令,可以在“.const”段中嵌入数据值,但所有的数据声明都必须初始化。例如:
.const
roArray byte 0
byte 1,2,3,4,5
qwVal qword 1
qword 0
请注意,还可以在“.code”段中声明常量值。因为Windows对“.code”段进行写保护,所以在“.code”段中声明的数据值也是只读对象。如果确实在“.code”段中放置了常量声明,那么应该注意将这些常量放置在程序不会尝试以代码形式执行它们的位置(例如在jmp或ret指令之后)。除非用户使用数据声明手动编码x86机器指令(这种情况很少见,而且只能由专家程序员完成),否则用户不希望程序尝试将数据作为机器指令执行,因为结果通常是未定义的 。
“.const”段要求用户初始化所有声明的对象。“.data”段允许用户选择性地初始化对象(或者不初始化对象,在这种情况下,对象的默认初始值为0)。“.data?”段则允许用户声明在程序开始运行时未初始化的变量。“.data?”段开始于保留关键字“.data?”,包含不带初始值的变量声明。以下是一个例子:
.data?
UninitUns32 dword ?
i sdword ?
character byte ?
b byte ?
Windows将程序加载到内存时,将所有“.data?”对象初始化为0。然而,并不建议依赖这种隐式初始化变量的方式。如果需要使用值0来初始化对象,可以在“.data”中声明该对象,并显式将其设置为0。
在“.data?”段中声明的变量,可能会使程序的可执行文件占用更少的磁盘空间。这是因为MASM将常量对象和数据对象的初始值写入可执行文件中,但MASM可能会采用打包格式表示“.data”段中未初始化的变量。请注意,具体的行为取决于操作系统版本和对象模块的格式。
在一个程序中,“.data”段、“.const”段、“.data”段和“.code”段可以出现零次或多次。段的声明可能以任何顺序出现,如下例所示:
.data
i_static sdword 0
.data?
i_uninit sdword ?
.const
i_readonly dword 5
.data
j dword?
.const
i2 dword 9
.data?
c byte?
.data?
d dword ?
.code
此处是代码
end
这些段的位置顺序没有限制,并且给定的声明段可能在程序中出现多次。如前所述,当在程序的声明段中,出现同一类型的多个声明段(例如,上面示例中的3个“.data?”段)时,MASM会将它们合并到一个组中(按任意顺序)。
x86-64的内存管理单元(Memory Management Unit,MMU)将内存划分为人们称为页(page)的块 。操作系统负责管理内存中的页,因此应用程序通常不必关心页的组织方式。但是,在处理内存中的页时,我们应该注意以下几个问题:具体而言,CPU是否允许访问给定的内存位置,以及给定的内存是读取/写入模式,还是只读模式(写保护)。
每个程序段都位于内存中的连续MMU页中。也就是说,“.const”段从MMU页中的偏移量0处开始存储,该段中的所有数据顺序占用内存中的页。存储内存中一个段的开始页紧跟在存储上一个段的最后一页之后,并从开始页中的偏移量0处开始存放一个段(可能是“.data”段)的内容。如果上一个段(例如,“.const”段)并没有占用4KB的整数倍空间,则该段的数据结尾与最后一页的结尾之间将包含填充空间,以确保下一个段从MMU的页边界开始。
每一个新的段都从自己的MMU页开始,因为MMU使用页粒度来控制对内存的访问。例如,MMU控制内存中的页是采取可读取/写入模式还是只读模式。对于“.const”段,我们希望内存为只读的。对于“.data”段,我们希望同时允许读取和写入。因为MMU只能逐页强制执行这些属性,所以“.data”段的信息与“.const”段的信息不能存储在同一个MMU页中。
通常情况下,所有这些操作对用户代码都是完全透明的。在“.data”段(或者.data?段)中声明的数据是可读写的,“.const”段(以及“.code”段)中的数据是只读的(“.code”段的数据也是可执行的)。除了将数据放在特定的段之外,用户不必太关心内存页的属性。
但是存在这样一种情况,用户确实需要考虑内存中MMU页的组织方式。有时,访问(读取)内存中数据结构末尾以外的数据非常方便(想要了解这其中合理缘由的读者,请参阅第11章的SIMD指令和第14章的字符串指令),但是如果该数据结构与MMU页的结尾对齐,则访问内存中的下一页时可能会存在问题。内存中的某些页是无法访问的(inaccessible),MMU不允许对这些页上进行读取、写入或者在这些页上执行操作。
尝试这样做将导致生成x86-64一般保护(段)错误,并中止程序的正常执行 。如果有一个跨越页边界的数据访问,并且内存中的下一页不可访问,则用户的程序将崩溃。例如,恰好在MMU页末尾的一个字节处,尝试访问一个字对象,如图3-2所示。
一般而言,我们不应该读取数据结构末尾以外的数据 。如果出于某种原因需要这样做,那么应该确保访问内存中的下一个页是合法的(遗憾的是,现代x86-64 CPU系列没有提供允许该操作的指令。保证访问合法的唯一方法是,确保在我们正访问的数据结构之后确实存在有效的数据)。
图3-2 在MMU页的末尾访问一个字对象
MASM将4个声明段(“.code”段、“.data”段、“.const”段和“.data?”段)分别与一个当前位置计数器相关联。这些位置计数器最初的值为0,每当用户在其中一个段中声明变量(或者在“.code”段中写入代码)时,MASM就将该段的位置计数器的当前值与该变量相关联。此外,MASM也会根据用户声明的对象的大小来增加该位置计数器的值。请阅读以下的一个示例,假设程序中只有一个“.data”声明段:
.data
b byte?;位置计数器的值=0,大小=1
w word?;位置计数器的值=1,大小=2
d dword?;位置计数器的值=3,大小=4
q qword?;位置计数器的值=7,大小=8
o oword?;位置计数器的值=15,大小=16
;此时,位置计数器的值=31
正如所见,在(单个)“.data”段中声明的变量,具有在该段中连续的偏移量(位置计数器的值)。根据上面的声明示例,在内存中,w将紧跟b存放,d将紧跟w存放,q将紧跟d存放,依此类推。这些偏移量并不是变量的实际运行时地址。在运行时,系统将每个段分别加载到内存中的一个(基)地址处。链接器和Windows将内存段的基址加到每个位置计数器值[我们称之为位移量(displacement)或偏移量]中,以生成变量的实际内存地址。
请记住,可以将其他模块(例如,C标准库中的函数)与用户编写的程序链接在一起。而且,同一个源文件中可能会包含其他的“.data”段,链接器必须合并各“.data”段。每个段都有自己的位置计数器,在为段中的变量分配存储空间时,该计数器也从0开始。因此,单个变量的偏移量可能对其最终内存地址影响不大。
请记住,MASM将我们在“.const”段、“.data”段和“.data?”段中声明的内存对象分配到完全不同的内存区域。因此,我们不能假设以下3个内存对象出现在相邻的内存位置(实际上,这些内存对象可能并不会位于相邻的内存位置):
.data
b byte?
.const
w word 1234h
.data?
d dword ?
事实上,MASM甚至不能确保用户在单独的“.data”(或任意)段中声明的变量在内存中是相邻存放的,即使代码中的声明之间没有任何其他内容。例如,在以下声明中,我们不能假设b、w和d位于相邻的内存位置,也不能假设这些变量在内存中不相邻:
.data
b byte ?
.data
w word 1234h
.data
d dword ?
如果代码要求这些变量占用相邻的内存位置,则必须在同一个“.data”段中声明这些变量。
标签(label)声明允许我们在段(“.code”段、“.data”段、“.const”段和“.data?”段)中声明变量,且不用为变量分配内存。label伪指令指示MASM在声明段中将当前地址分配给变量,但不为对象分配任何存储空间。该变量与变量声明段中出现的下一个对象共享相同的内存地址。以下是标签声明的语法:
variable_name label type
以下代码序列给出了一个在“.const”段中使用标签声明的示例:
.const
abcd label dword
byte 'a','b','c','d'
在上面的示例中,abcd是一个双字,其低阶字节包含97(字母a的ASCII码值),第一个字节包含98(字母b的ASCII码值),第二个字节包含99(字母c的ASCII码值),高阶字节包含100(字母d的ASCII码值)。MASM不会为变量abcd保留存储空间,因此MASM将内存中的以上四个字节(由byte伪指令分配)与变量abcd关联在一起。
回顾1.8节中的相关内容,其中指出x86-64在内存中存储多字节的数据类型时,将低阶字节存于内存中的最小地址处,高阶字节存于内存中的最大地址处(具体请参见图1-5)。内存中的这种数据组织方式称为小端(little endian)模式。小端模式的数据组织方式(低阶字节在前,高阶字节在后)是许多现代CPU共享的一种常见内存组织。然而,小端模式并不是唯一的数据组织方式。
大端(big endian)模式数据组织方式与小端模式数据组织方式在内存字节的顺序上刚好相反。大端模式数据结构的高阶字节首先出现(在最小内存地址中),低阶字节出现在最大内存地址中。表3-1、表3-2和表3-3分别描述了字、双字和四字的内存组织方式。
表 3-1 字对象的小端模式和大端模式数据组织方式
表 3-2 双字对象的小端模式和大端模式数据组织方式
表 3-3 四字对象的小端模式和大端模式数据组织方式
通常情况下,我们不会太关心x86-64 CPU上的大端模式内存组织方式。但是,有时可能需要处理由不同CPU(或由使用大端模式数据组织方式作为其标准整数格式的协议,如TCP/IP)生成的数据。如果要将内存中的大端模式数据值加载到CPU寄存器中,则计算结果将不正确。
如果内存中有一个16位的大端模式数据值,并且用户将其加载到一个16位寄存器中,那么该数据值将进行字节交换。对于16位值,可以使用xchg指令纠正此问题。xchg指令的语法形式如下所示:
xchg reg , reg
xchg reg , mem
其中reg是任何8位、16位、32位或64位通用寄存器,mem则是任何合适的内存位置。第一条指令中的两个reg操作数的大小,以及第二条指令中的reg和mem操作数的大小都必须相同。
使用xchg指令可以实现任意两个(大小一致的)寄存器或者寄存器与内存位置之间值的交换,也可以实现(16位)小端模式和大端模式数据格式之间的转换。例如,如果AX寄存器包含大端模式数据格式的值,并且在进行某些计算之前需要将其转换为小端模式数据格式的值,则可以使用以下指令交换AX寄存器中的字节实现该目的:
xchg al,ah
使用xchg指令,以及低位和高位寄存器标识符(AL和AH、BL和BH、CL和CH、DL和DH),可以实现16位寄存器AX、BX、CX和DX中的小端模式和大端模式数据值之间的转换。
遗憾的是,xchg指令不适用于AX、BX、CX和DX以外的寄存器。为了处理较大的值,英特尔引入了bswap(byte swap,字节交换)指令。顾名思义,这个指令交换32位或者64位寄存器中的字节内容,包括交换HO字节和LO字节的内容,以及交换(HO-1)字节和(LO+1)字节(还有交换64位寄存器中的其他所有相反字节对)的内容。bswap指令适用于所有通用32位和64位寄存器。
x86-64 CPU通过数据总线从内存中获取数据。在理想化的CPU中,数据总线与CPU上标准整数寄存器的大小相同,因此我们期望x86-64 CPU具有64位数据总线。在实践中,现代CPU通常采用更大的物理数据总线连接到主存,以提高系统性能。在某次操作中,总线从内存中读取大量数据,并将这些数据放入CPU的高速缓存(cache)中,该高速缓存充当CPU和物理内存之间的缓冲区。
从CPU的角度来看,高速缓存就是内存。因此,本节的其余部分在讨论内存时,通常是讨论高速缓存中的数据。系统将内存访问透明地映射到高速缓存中,从而我们可以假设高速缓存不存在从而讨论内存,并根据需要讨论高速缓存的优点。
在早期的x86处理器上,内存被排列为字节数组(对于8位计算机,例如8088)、字数组(对于16位计算机,例如8086和80286)、双字数组(对于32位计算机,例如80386)。在16位计算机上,地址的低阶位实际上不出现在地址总线中。因此,地址126和127在地址总线上放置了相同的位模式(126,第0位的值隐含为0),如图3-3所示 。
当读取字节时,CPU使用地址的最低位去选择数据总线上的低阶字节或者高阶字节。图3-4显示了从偶数地址(图3-4中的126)读取字节的过程。图3-5显示了从奇数地址(图3-5中的127)读取字节的过程。注意,在图3-4和图3-5中,地址总线上出现的地址都是126。
图3-3 16位处理器的地址总线和数据总线
图3-4 在16位CPU上从偶数地址读取字节
图3-5 在16位CPU上从奇数地址读取字节
那么,当16位CPU想要访问奇数地址上的16位数据时,会发生什么呢?例如,假设在图3-4和图3-5中,CPU要读取地址125处的字。当CPU将地址125放到地址总线上时,低阶位不会实际出现。因此,总线上的实际地址是124。此时,如果CPU从数据总线上读取低阶8位,则它读取的将是地址124而不是地址125处的数据。
幸运的是,CPU足够聪明,能够弄清楚这里发生了什么,并读取地址总线上高阶8位的数据,将其用作数据操作数的低阶8位。但是,在数据总线上找不到CPU需要的数据的高阶8位。CPU必须启动第二次读取操作,将地址126放在地址总线上,以获取高阶8位(将位于数据总线的低阶8位,但CPU可以解决这个问题)。完成此读取操作的代价是需要两个内存周期。因此,执行从奇数地址的内存单元读取数据的指令,其时间将比执行从偶数地址(2的整数倍)读取数据的指令的时间更长。
在32位处理器上存在同样的问题,只是32位数据总线允许CPU一次读取4字节。如果地址不是4的整数倍,那么从该地址读取32位值会导致相同的性能损失。请注意,在奇数地址访问16位操作数则不一定会消耗额外的内存周期,只有当地址除以4的余数等于3时,才会产生性能损失。特别地,如果在低阶2位为01b的地址访问16位值(在32位总线上),那么CPU可以在单个内存周期内读取该字,如图3-6所示。
带有高速缓存系统的现代x86-64 CPU在很大程度上解决了这个问题。只要数据(大小为1、2、4、8或10字节)完全位于高速缓存线内,访问未对齐就不会造成内存周期的损失。如果访问确实跨越了缓存线边界,那么CPU会执行两个内存操作以获取(或存储)数据,因此运行速度会减慢。
图3-6 在32位数据总线上访问字
为了编写高效的程序,需要确保合理对齐内存中的数据对象。合理对齐意味着一个对象的起始地址是某个大小的倍数,如果对象的大小是2的幂并且不大于32字节,则该大小通常是对象的大小。对于大小大于32字节的对象,在8、16或32字节的地址边界上对齐对象就足够了。对于大小小于16字节的对象,可以将比对象大小大的第一个2的幂作为对象对齐地址。如果数据未在适当的地址上对齐,则访问这些数据可能需要额外的时间(如前一节所述),因此如果需要确保提高程序的运行速度,应该尝试根据数据对象的大小进行对齐。
当需要为相邻内存位置中不同大小的对象分配存储空间时,数据就会发生错位。例如,如果声明了一个字节变量,则该字节变量将占用一个字节的存储空间,当在该声明段中声明下一个变量时,该变量的地址是字节对象所在的地址加上1。如果字节变量的地址恰好是偶数地址,则该字节后面的变量将从奇数地址开始。如果后面的变量是一个字对象或双字对象,则其起始地址将不是最优的。在本节中,我们将探讨如何根据对象的大小确保变量在适当的起始地址对齐。
请考虑下面的MASM变量声明:
.data
dw dword ?
b byte ?
w word ?
dw2 dword ?
w2 word ?
b2 byte ?
dw3 dword ?
在Windows下运行时,程序第一行的“.data”声明将其变量放置在4096字节的偶数倍的地址处。无论“.data”段中的第一个变量是什么,都保证将该变量对齐到合适的地址。其后的每个变量都将分配到一个地址,该地址是所有前面变量的大小加上“.data”段的起始地址。因此,假设MASM将前一示例中的变量分配到4096的起始地址,则MASM将在以下地址分配这些变量:
;起始地址 长度
dw dword ?;4096 4
b byte ?;4100 1
w word ?;4101 2
dw2 dword ?;4103 4
w2 word ?;4107 2
b2 byte ?;4109 1
dw3 dword ?;4110 4
除了第一个变量(在4KB边界上对齐)和字节变量(是否对齐不重要)之外,其余变量都未适当对齐。w、w2和dw2变量从奇数地址开始;虽然dw3变量在偶数地址上对齐,但该地址不是4的倍数。
确保变量适当对齐的一个简单方法是在声明中首先放置所有的双字变量,接着放置字变量,最后放置字节变量,如下所示:
.data
dw dword ?
dw2 dword ?
dw3 dword ?
w word ?
w2 word ?
b byte ?
b2 byte ?
这种变量的组织方式会在内存中生成以下的地址分配:
;起始地址 长度
dw dword ?;4096 4
dw2 dword ?;4100 4
dw3 dword ?;4104 4
w word ?;4108 2
w2 word ?;4110 2
b byte ?;4112 1
b2 byte ?;4113 1
正如所见,这些变量都在合理的地址上对齐。遗憾的是,以这种方式排列变量的可能性并不大。无法按这种方式排列变量有很多原因,一个有说服力的实践原因是MASM不允许我们按逻辑功能的方式组织变量的声明(也就是说,我们可能希望相关变量彼此相邻,而不管这些变量的实际大小)。
为了解决这个问题,MASM提供了align伪指令,这个伪指令的语法形式如下所示:
align integer_constant
integer_constant(整型常量)必须是以下的小无符号整数值之一,即1、2、4、8、16中的一个。如果MASM在“.data”段中遇到align伪指令,它将对齐下一个变量到integer_constant的偶数倍地址。可以使用align伪指令重写前面的示例,如下所示:
.data
align 4
dw dword ?
b byte ?
align 2
w word ?
align 4
dw2 dword ?
w2 word ?
b2 byte ?
align 4
dw3 dword ?
如果MASM确定align伪指令的当前地址(位置计数器值)不是指定常量值的整数倍,则MASM将在上一个变量声明之后,自动生成额外的填充字节,直到“.data”段中的当前地址是指定值的倍数。这样处理的结果是,程序代码会稍微长一些(但也只是长若干字节而已),但对数据的访问变快了。考虑到使用此功能时,程序长度只会增加若干字节,因此这可能是一个很好的折中方案。
一般而言,如果希望以尽可能快的速度访问数据,那么应选择与需要对齐的对象大小相等的对齐值。也就是说,应该使用“align 2”语句将字对齐到偶数边界,使用“align 4”将双字对齐到4字节边界,使用“align 8”将四字对齐到8字节边界,等等。如果对象的大小不是2的幂,则将对象与比其大小大的第一个2的幂对齐(最多16字节)。但是,请注意,只需要在8字节边界上对齐real80(以及tbyte)对象。
请注意,数据对齐并非总是必须的。现代x86-64 CPU系列的高速缓存体系结构实际上可以处理绝大多数未适当对齐的数据。因此,应该仅对某些变量(快速访问这些变量至关重要)使用对齐指令。这是一个合理的空间与速度之间的权衡方案。
到目前为止,我们只讨论了一种访问变量的方法:PC相对寻址(PC-relative addressing)模式。在本节中,我们将讨论用于程序访问内存的其他x86-64内存寻址模式。寻址模式(addressing mode)是CPU用来确定指令将访问的内存位置地址的机制。
x86-64的内存寻址模式提供了对内存的灵活访问,允许我们轻松地访问变量、数组、记录、指针以及其他复杂的数据类型。掌握x86-64的寻址模式是掌握x86-64汇编语言的第一步。
x86-64提供了以下几种寻址模式。
●寄存器寻址模式。
●PC相对寻址模式。
●寄存器间接寻址模式:[reg 64 ]。
●间接加偏移寻址模式:[reg 64 +expression]。
●缩放索引寻址模式:[reg 64 +reg 64 * scale]和[reg 64 +expression+reg 64 * scale]。
以下各节将介绍各种寻址模式。
寄存器寻址模式提供对x86-64通用寄存器集的访问。将寄存器的名称指定为指令的操作数,就可以访问该寄存器的内容。本节将使用x86-64的mov(move)指令来演示寄存器寻址模式。mov指令的通用语法形式如下所示:
mov destination , source
mov指令将数据从source(源)操作数复制到destination(目标)操作数。8位、16位、32位和64位寄存器都是此指令的有效操作数。唯一的限制是两个操作数的大小必须相同。以下mov指令演示了各种寄存器的使用:
mov ax,bx ;将BX中的值复制到AX
mov dl,al ;将AL中的值复制到DL
mov esi,edx ;将EDX中的值复制到ESI
mov rsp,rbp ;将RBP中的值复制到RSP
mov ch,cl ;将CL中的值复制到CH
mov ax,ax ;是的,这是合法的!(虽然没有什么意义)
寄存器是保存变量的最佳位置。使用寄存器的指令比访问内存的指令更短更快。因为大多数计算至少需要一个寄存器操作数,所以寄存器寻址模式在x86-64汇编代码中十分常见。
x86-64系列提供的寻址模式包括PC相对寻址模式、寄存器间接寻址模式、间接加偏移寻址模式、缩放索引寻址模式。这四种形式的变体构成了x86-64上的所有寻址模式。
3.7.2.1 PC 相对寻址模式
PC相对或RIP相对寻址模式是最常见的寻址模式,也是最容易理解的寻址模式。这个模式由一个32位常量组成,CPU将该常量与RIP(instruction pointer,指令指针)寄存器的当前值相加,得到目标位置的地址。
PC相对寻址模式的语法使用了我们在MASM段(“.data”“.data?”“.const”“.code”等)中声明的symbol:
mov al,symbol;PC相对寻址模式会自动提供[RIP]
假设变量j是一个int8变量,位于相对于RIP的偏移量8088h处,则指令“mov al,j”将内存位置RIP+8088h处的字节副本加载到AL寄存器中。同样,如果int8变量K位于内存中的地址RIP+1234h处,则指令“mov K,dl”将DL寄存器中的值存储到内存位置RIP+1234h中(参见图3-7)。
图3-7 PC相对寻址模式
MASM并不会将j或K的地址直接编码到指令的操作码(operation code或opcode,指令的数字机器编码)中,而是对从当前指令地址末尾到内存中变量地址的有符号位移量进行编码。例如,如果下一条指令的操作码位于内存地址8000h(当前指令的末尾)处,则MASM将指令操作码中的j编码为32位有符号常量88h。
在x86-64处理器上,还可以通过指定字或双字的第一个字节的地址,来访问字或双字(参见图3-8)。
图3-8 使用PC相对寻址模式访问字或双字
3.7.2.2 寄存器间接寻址模式
x86-64 CPU系列允许我们使用寄存器间接寻址模式(register-indirect addressing mode),通过寄存器间接访问内存。术语“间接”(indirect)表示操作数不是实际地址,但操作数的值指定了需要使用的内存地址。在寄存器间接寻址模式的情况下,寄存器中保存的值是需要访问的内存位置所在的地址。例如,指令“mov[rbx],eax”指示CPU将EAX中的值存储到当前RBX中保存的位置处(RBX左右的方括号指示MASM使用寄存器间接寻址模式)。
x86-64包含16种类似上述形式的寻址模式。以下指令给出了其中一个示例:
mov[ reg 64 ],al
其中,reg 64 是以下64位通用寄存器之一:RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP、R8、R9、R10、R11、R12、R13、R14或R15。寄存器间接寻址模式引用的内存位置位于方括号内寄存器指定的偏移量处。
寄存器间接寻址模式要求使用64位寄存器,不能在方括号中指定32位、16位或8位寄存器。从技术上而言,可以加载具有任意数值的64位寄存器,并使用寄存器间接寻址模式间接访问该数值代表的位置:
mov rbx,12345678
mov[rbx],al;尝试访问位置12345678
遗憾的是(或者幸运的是,这取决于我们的视角),这可能会导致操作系统产生保护错误,因为访问任意内存位置并不总是合法的。事实证明,要将对象的地址加载到寄存器中,可以使用更好的方法。接下来我们简要讨论这些方法。
可以使用寄存器间接寻址模式访问指针所引用的数据,也可以使用这种寻址模式逐步遍历数组数据。通常情况下,在程序运行时,无论何时需要修改变量地址,都可以使用寄存器间接寻址模式。
寄存器间接寻址模式提供了匿名变量(anonymous variable)的示例。使用寄存器间接寻址模式时,可以通过变量的数字内存地址(加载到寄存器中的值)而不是变量的名称来引用变量的值。
MASM提供了一条简单的指令,用于获取变量的地址,并将该地址放入64位寄存器中,即lea指令:
lea rbx,j
执行此lea指令后,可以使用寄存器间接([rbx])寻址模式,间接访问j的值。
3.7.2.3 间接加偏移寻址模式
间接加偏移寻址模式(indirect-plus-offset addressing mode)通过将32位有符号常量加上64位寄存器的值来计算有效地址(effective address) 。然后,指令使用内存中该有效地址处的数据。
间接加偏移寻址模式的语法形式如下所示:
mov[ reg 64 + constant ], source
mov[ reg 64 - constant ], source
其中reg 64 为64位通用寄存器,constant为4字节常量,source(源操作数)为寄存器或常量值。
如果constant为1100h,且RBX包含12345678h,则以下指令将AL中的值存储到内存中的12346778h位置处(参见图3-9):
mov[rbx+1100h],al
图3-9 间接加偏移寻址模式
间接加偏移寻址模式适合于访问类和记录/结构的字段。对此,我们将在第4章中做更详细的讨论。
3.7.2.4 缩放索引寻址模式
缩放索引寻址模式(scaled-indexed addressing mode)与索引寻址模式(indexed addressing mode)类似,不同之处在于缩放索引寻址模式允许组合两个寄存器和一个位移量,并将索引寄存器乘以(缩放)因子1、2、4或8来计算有效地址。(图3-10显示了一个示例,包含作为基址寄存器的RBX和作为索引寄存器的RSI。)
缩放索引寻址模式的语法形式如下所示:
[ base_reg 64 + index_reg 64 * scale ]
[ base_reg 64 + index_reg 64 * scale + displacement ]
[ base_reg 64 + index_reg 64 * scale - displacement ]
base_reg 64 表示任何通用64位寄存器,index_reg 64 表示除RSP之外的任何通用64位寄存器,并且scale(缩放因子)必须是常量1、2、4或8。
图3-10 缩放索引寻址模式
在图3-10中,假设RBX包含1000FF00h,RSI包含20h,const为2000h,那么以下指令可将地址10011F80h(1000FF00h+(20h×4)+2000h)处的字节移动到AL寄存器中:
mov al,[rbx+rsi * 4+2000h]
缩放索引寻址模式非常适用于访问大小为2字节、4字节或8字节的数组元素。当有指向数组开头的指针时,这些寻址模式对于访问数组元素也很有用。
64位地址的一个优越性是可以访问非常多的内存单元(在Windows下大约为8TB个)。默认情况下,微软链接器(当链接器将C++和汇编语言代码链接在一起时)会将一个名为LARGEADDRESSAWARE的编译标志选项设置为true(yes),这使得应用程序能够访问大量内存。但是,在LARGEADDRESSAWARE模式下的操作需要付出代价:[reg 64 +const]寻址模式中的const部分被限制为32位,因此不能跨越整个地址空间。
由于指令编码的限制,const值只能取±2GB范围内的有符号值。当寄存器包含64位基址,并且我们希望访问该基址周围的固定偏移量(小于±2GB)处的内存位置时,这些值可能远远足够了。使用这种寻址模式的典型代码如下所示:
lea rcx,someStructure
mov al,[rcx+fieldOffset]
在引入64位地址之前,(32位)间接加偏移寻址模式中出现的const偏移量可以跨越整个(32位)地址空间。因此,如果有一个数组声明,例如:
.data
buf byte 256 dup(?)
就可以使用以下寻址模式访问该数组的元素:
mov al,buf[ebx];EBX用于32位处理器上
试着在64位程序中汇编指令“mov al,buf[rbx]”(或者涉及buf但非PC相对寻址模式的其他寻址模式),会发现尽管MASM能够正确汇编代码,但链接器将报告错误:
error LNK2017:'ADDR32' relocation to 'buf' invalid without/LARGEADDRESSAWARE:NO
链接器报告的错误旨在说明:在超过32位的地址空间中,不可能将偏移量编码到buf缓冲区,因为机器指令操作码仅提供32位偏移量来保存buf的地址。
不过,如果我们人为地将应用程序使用的内存数量限制在2GB,MASM便可以将相对于buf的32位偏移量编码到机器指令中。只要我们遵守限制,绝不使用超过2GB的内存,间接加偏移寻址模式和缩放索引寻址模式就有可能发展出一些新变体。
为了关闭LARGEADDRESSAWARE功能,需要在ml64命令中添加一个额外的命令行选项。利用build.bat批处理文件,可以很容易地实现添加。接下来创建一个新的build.bat文件,并称之为sbuild.bat(表示small build,小规模构建)。sbuild.bat文件将包含以下几行命令:
echo off
ml64/nologo/c/Zi/Cp%1.asm
cl/nologo/O2/Zi/utf-8/EHa/Fe%1.exe c.cpp%1.obj/link/largeaddressaware:no
这组命令指示MASM,向链接器传递一个命令,以关闭LARGEADDRESSAWARE功能。MASM、MSVC和微软链接器将构造一个只需要32位地址的可执行文件(忽略在寻址模式下出现的64位寄存器中的32个高阶位)。
一旦禁用LARGEADDRESSAWARE,用户编写的程序就可以使用以下几种间接加偏移寻址模式和缩放索引寻址模式的新变体:
variable [ reg 64 ]
variable [ reg 64 + const ]
variable [ reg 64 - const ]
variable [ reg 64 * scale ]
variable [ reg 64 * scale + const ]
variable [ reg 64 * scale - const ]
variable [ reg 64 + reg_not_RSP 64 * scale ]
variable [ reg 64 + reg_not_RSP 64 * scale + const ]
variable [ reg 64 + reg_not_RSP 64 * scale - const ]
其中,variable(变量名)是我们使用诸如byte、word、dword等的伪指令在源文件中声明的对象名称,const是一个(最大32位)常量表达式,scale是1、2、4或8。这些寻址模式使用变量的地址作为基址,并将其与64位寄存器的当前值相加(示例请参见图3-11至图3-16)。
图3-11 间接加偏移寻址模式的基址形式
图3-12 间接加偏移寻址模式的小地址加常量形式
图3-13 基址加缩放索引寻址模式的小地址形式
图3-14 基址加缩放索引加常量寻址模式的小地址形式
图3-15 缩放索引寻址模式的小地址形式
图3-16 缩放索引加常量寻址模式的小地址形式
虽然小地址形式(LARGEADDRESSAWARE:NO)既方便又高效,但是如果用户的程序使用了超过2GB的内存,程序就会出现错误,使用这些地址(使用全局数据对象作为基址,而不是将基址加载到寄存器中)的所有指令都需要重写。这可能是一件非常痛苦的事情,并且容易出错。在使用LARGEADDRESSAWARE:NO之前,请三思。
通常情况下,在访问内存中的变量和其他对象时,我们需要访问变量之前或之后的内存位置,而不是访问变量指定地址处的内存位置。例如,当访问数组的元素或者结构/记录的字段时,确切的元素或字段可能并不位于变量本身的地址处。地址表达式(address expression)提供了一种机制,将算术表达式附加到地址上,以访问变量地址周围的内存位置。
在本书中,地址表达式是任何一种合法的x86-64寻址模式,该模式包括位移量(也即变量名)或者偏移量。例如,以下是合法的地址表达式:
[ reg 64 + offset ]
[ reg 64 + reg_not_RSP 64 * scale + offset ]
考虑下面有关内存地址的一个合法MASM语法,它实际上不是一个新的寻址模式,而只是PC相对寻址模式的一种简单扩展:
variable_name [ offset ]
这个扩展形式通过将变量的地址加上方括号内的常量偏移量来计算变量的有效地址。例如,指令“mov al,Address[3]”将Address对象后的3个字节,加载到AL寄存器中(参见图3-17)。
图3-17 使用地址表达式访问变量以外的数据
请注意,这些示例中的offset值必须是常量。如果index是int32变量,那么variable[index]不是合法的地址表达式。如果希望指定在运行时可以变化的索引,则必须使用间接寻址模式或者缩放索引寻址模式。
还需要注意的是,Address[offset]中的offset是字节地址。尽管这种语法让人联想到C/C++或者Java等高级程序设计语言中的数组索引,但除非Address是字节数组,否则不能正确地索引到对象数组中。
在此之前,所有寻址模式示例中的偏移量都是单个数字常量。其实,MASM还允许在任何偏移量合法的地方使用常量表达式(constant expression)。常量表达式由一个或者多个常量项组成,这些常量项进行加法、减法、乘法、除法、求余(模)等各种运算。然而,大多数地址表达式只涉及加法、减法、乘法,有时候还涉及除法运算。请考虑下面的例子:
mov al,X[2 * 4+1]
上述指令将X的地址+9处的字节移动到AL寄存器中。
地址表达式的值始终在编译的时候进行计算,而不是在程序运行的时候。当MASM遇到上面的指令时,将会立即计算2×4+1,并将计算结果累加到内存中X的基址上。MASM将此计算累加和(X的基址值加上9)的操作编码为指令的一部分,不会发出额外的指令来在运行时计算这个表达式的累加和(这种处理方式很合理,虽然效率会比较低)。因为MASM在编译时计算地址表达式的值,所以表达式的所有构成部分都必须是常量,因为MASM在编译程序时无法知道变量运行时的值。
地址表达式对于访问内存中变量以外的数据非常有用,特别是当用户在“.data”段或“.const”段中使用了byte、word、dword等语句,用以在数据声明后附加额外的字节时。例如,考虑程序清单3-1中的代码,该程序使用地址表达式访问4个连续字节,而这4个连续字节与变量i相关联。
程序清单 3-1 地址表达式的演示示例
构建和运行该程序的命令及输出结果如下所示:
C:\> build listing3-1
C:\> echo off
Assembling:listing3-1.asm
c.cpp
C:\> listing3-1
Calling Listing 3-1:
i[0]=0 i[1]=1 i[2]=2 i[3]=3
Listing 3-1 terminated
程序清单3-1中的代码显示了4个值,即0、1、2和3,并且将这4个值作为数组中的各个元素来处理。这是因为地址i处的值为0。地址表达式i[1]指示MASM获取位于i的地址加1处的字节内容。该位置的值为1,这是因为这个程序的byte语句在“.data”段中的值0之后定义了值1。同理,对于i[2]和i[3],该程序显示值2和值3。
请注意,MASM还提供了一个特殊的运算符this,用于返回位于段中的当前位置计数器的值。可以使用this运算符在地址表达式中表示当前指令的地址。有关更多详细信息,请参阅4.3.1节中的相关内容。
x86-64在内存的栈段中维护栈。栈(stack)是一种动态的数据结构,大小可以根据程序的特定需求变大和变小。栈还可以存储有关程序的重要信息,包括局部变量、子例程信息和临时数据。
x86-64通过RSP(stack pointer,栈指针)寄存器控制其栈。当程序开始执行时,操作系统使用栈内存段中最后一个内存位置的地址初始化RSP。通过将数据“压入”(push)栈,将数据写入栈段;通过从栈中“弹出”(pop)数据,将数据从栈段中删除。
x86-64的push指令语法形式如下所示:
push reg 16
push reg 64
push memory 16
push memory 64
pushw constant 16
push constant 32 ;将 constant 32 符号扩展到64位
这六种形式允许我们压入以下数据:16位的寄存器数据、64位的寄存器数据、16位的内存数据、64位的内存数据、16位的常量以及64位的常量。然而,不能压入32位的寄存器数据、32位的内存数据以及32位的常量。
push指令执行以下操作:
RSP:=RSP- size_of_register_or_memory_operand (2 or 8)
[RSP]:= operand's_value
例如,假设RSP寄存器中的值为00FF_FFFCh,指令“push rax”将RSP寄存器中的值设置为00FF_FFF4h,并将RAX寄存器中的当前值存储到内存单元00FF_FFF4中,如图3-18和图3-19所示。
尽管x86-64支持16位入栈操作,但是16位入栈操作主要用于16位的环境,例如微软磁盘操作系统(Microsoft Disk Operating System,MS-DOS)。为了获得最佳的性能,栈指针的值应该始终为8的倍数。实际上,如果RSP寄存器中包含的值不是8的倍数,则在64位的操作系统下,用户的程序可能会出现故障。一次只能往栈中压入少于8个字节数据的唯一实际原因是通过4次连续的字压入操作来构建一个四字数据。
图3-18 执行“push rax”操作之前的栈段
图3-19 执行“push rax”操作之后的栈段
为了提取已经压入栈中的数据,可以使用pop指令。基本的pop指令语法形式如下所示:
pop reg 16
pop reg 64
pop memory 16
pop memory 64
与push指令一样,pop指令仅支持16位和64位的操作数,不能从栈中弹出8位或32位的值。另外,与push指令一样,应该避免弹出16位的值(除非连续弹出四个16位的值),因为弹出16位的值可能会使RSP寄存器中包含的值不是8的倍数。push和pop之间的一个主要区别是不能将一个常量值弹出栈(这是显而易见的,因为push的操作数是源操作数,而pop的操作数是目标操作数)。
从形式上而言,pop指令执行以下的操作:
operand :=[RSP]
RSP:=RSP+ size_of_operand (2 or 8)
正如所见,pop操作与push操作正好相反。请注意,在调整RSP寄存器中的值之前,pop指令先从内存单元[RSP]处复制数据。有关这个操作的详细信息,请参见图3-20和图3-21。
图3-20 执行“pop rax”操作之前的内存
图3-21 执行“pop rax”操作之后的内存
注意,从栈中弹出的值仍然存在于内存中,并不会被擦除。弹出操作只是调整栈指针,使栈指针指向弹出值上方的一个值。但是,永远不要尝试访问已经从栈中弹出的值。下一次将某些内容压入栈时,弹出栈的值才会被抹去。除了用户自己的代码外,其他程序也会使用栈(例如,操作系统会使用栈,子例程也会使用栈),所以一旦从栈中弹出数据,就无法确保出栈后的数据仍然保存在栈内存中。
push和pop指令最常见的用途可能是在中间计算过程中保存寄存器的值。由于寄存器是保存临时值的最佳场所,并且很多寻址模式也需要用到寄存器,因此在编写执行复杂计算的代码时,很容易耗尽所有的寄存器。当发生这种情况时,可以借助于push和pop指令来保存寄存器的状态。
请考虑下面的程序框架:
需要使用RAX寄存器的指令序列
需要使用RAX寄存器的指令序列,但是与第一个指令序列的用途不同
需要使用RAX中原始值的指令序列
push和pop指令非常适合于以上程序框架中所述的情况。在中间指令序列之前插入一个push指令,在中间指令序列之后再插入一个pop指令,就可以在这些计算中保留RAX寄存器值,相应的程序框架如下:
需要使用RAX寄存器的指令序列
push rax
需要使用RAX寄存器的指令序列,但是与第一个指令序列的用途不同
pop rax
需要使用RAX中原始值的指令序列
以上程序框架中的push指令将在第一个指令序列中计算的数据复制到栈中。现在,中间的那个指令序列可以将RAX寄存器用于其他任何目的。当中间的指令序列完成后,pop指令恢复RAX寄存器中的值,以便最后一个指令序列可以继续使用RAX中的原始值。
我们可以将多个值压入栈中,而无须先从栈中弹出以前的值。栈是一个后进先出(last-in,first-out,LIFO)的数据结构,因此在压入和弹出多个值的时候,特别需要注意数据的顺序。例如,假设希望在指令块中保留RAX和RBX寄存器的值,以下代码演示了处理此问题的一种显而易见的方法:
push rax
push rbx
此处是使用RAX和RBX的代码
pop rax
pop rbx
遗憾的是,上述代码无法正常工作!图3-22至图3-25显示了问题所在。这段代码先将RAX的值压入栈中,再将RBX的值压入栈中,结果是栈指针指向栈中RBX的值。当执行“pop rax”指令时,将从栈中弹出最初位于RBX中的值,并将该值放入RAX中!同样,“pop rbx”指令将原来位于RAX中的值弹出并放到RBX寄存器中。结果,这段代码交换了寄存器RAX和RBX的值,因为是按相同的顺序对两个寄存器进行弹出与压入的。
为了纠正这个问题,必须注意,栈是一个后进先出的数据结构,最先弹出栈的内容必须是最后压入栈中的内容。因此,我们必须始终遵守以下准则:始终按照与往栈中压入值时相反的顺序弹出栈中的值。
图3-22 压入RAX后的栈
图3-23 压入RBX后的栈
图3-24 弹出RAX后的栈
图3-25 弹出RBX后的栈
先前代码的更正如下所示:
push rax
push rbx
此处是使用RAX和RBX的代码
pop rbx
pop rax
另一条需要牢记的重要准则是:在栈中,弹出的字节数必须总是与所压入的字节数完全相同。这通常意味着入栈的操作次数必须和出栈的操作次数完全一致。如果pop操作次数太少,则会在栈中留下数据,这可能使正在运行的程序出现混乱。如果pop操作次数太多,又会意外地删除以前入栈的数据,通常会带来灾难性的后果。
上述重要准则的一个推论是,在循环语句中将数据入栈和出栈时要特别小心谨慎。经常会出现将push指令置于循环中,而将pop指令留在循环外(反之亦然)的现象,从而创建不一致的栈。请记住,重要的是push和pop指令如何执行,而不是程序中出现的push和pop指令的数量。在运行时,程序执行的push指令的数量(以及顺序)必须与pop指令的数量(以及相反顺序)相匹配。
最后需要注意的事情是:微软ABI要求栈在16字节边界上对齐。当往栈中压入和从栈中弹出数据项时,请确保在调用符合微软ABI的函数或过程之前,栈在16字节边界上对齐(并要求之后栈也要在16字节边界上对齐)。
除了基本指令外,x86-64还提供了如下四条额外的push和pop指令:
pushf popf
pushfq popfq
pushf、pushfq、popf和popfq指令用于压入和弹出RFLAGS寄存器的值。这些指令允许用户在执行一系列指令的过程中保留条件码和其他标志位的设置。遗憾的是,保存单个标志位必定耗费巨大的精力。当使用pushf(q)和popf(q)指令时,要么对所有的标志位进行操作,要么不对任何标志位进行操作:当压入栈时,保留所有的标志位;当弹出栈时,恢复所有的标志位。
我们应该使用pushfq和popfq指令压入和弹出RFLAGS寄存器完整的64位,而不仅是其中16位的标志位。尽管在编写应用程序时,压入和弹出的额外48位基本上会被忽略,但仍应通过压入和弹出四字来保持栈对齐。
经常会有的情况是不再需要使用已经压入栈的数据。尽管我们可以将这类数据从栈中弹出并放到某个未使用的寄存器或者内存单元中,但还有一种更简单的方法可以从栈中移除我们不再需要的数据,即只调整RSP寄存器中的值,从而跳过栈中的这些数据。
请考虑下面的代码困境(伪代码,不是实际的汇编语言):
push rax
push rbx
代码序列,将计算结果值保存到RAX和RBX中
if( 执行了计算 )then
;我们不希望弹出RAX和RBX!
;该如何处理栈?
else
;没有执行计算,因此需要恢复RAX和RBX。
pop rbx
pop rax
endif;
在if语句的then部分中,希望移除寄存器RAX和RBX中的旧值,但是不会影响其他任何寄存器或者内存单元的内容。我们该如何用代码实现呢?
因为RSP寄存器包含栈顶部的数据项的内存地址,所以将该数据项的大小累加到RSP寄存器中,就可以从栈顶部移除该数据项。在前面的示例中,我们希望从栈的顶部移除2个四字数据项,这可以通过将栈指针加上16来轻松实现(有关详细信息,请参见图3-26和图3-27):
push rax
push rbx
代码序列,将计算结果值保存到RAX和RBX中
If( 执行了计算 )then
;从栈中移除不需要的RAX/RBX值。
add rsp,16
else
;没有执行计算,因此需要恢复RAX和RBX。
pop rbx
pop rax
endif;
图3-26 从栈中移除数据(执行“add rsp,16”指令之前)
图3-27 从栈中移除数据(执行“add rsp,16”指令之后)
实际上,这段代码在没有将数据移动到任何地方的情况下,成功地将数据从栈中弹出。还请注意,这段代码比两条伪pop指令执行速度要快,因为它可以仅使用一条add指令就从栈中移除任意数量的字节。
注意: 请记住,需要在四字边界上保持栈对齐。因此,只要从栈中移除数据时,就应该向RSP中添加一个常量,该常量必须是8的倍数。
有的时候,我们会将数据压入栈中,然后希望获得该数据值的副本,或者可能希望更改该数据的值,而又不真正将数据从栈中弹出(即我们希望稍后才将数据从栈中弹出)。x86 - 64的[reg 64 ±offset]寻址模式提供了实现该功能的机制。
请考虑在执行以下两条指令后的栈(参见图3-28):
push rax
push rbx
图3-28 压入RAX和RBX后的栈
如果希望访问RBX中的原始值而不将其从栈中移除,那么可以从栈中弹出该值,然后立即再次压入该值。进一步,假设希望访问RAX的旧值,或者访问栈中离栈顶更远的另一个值。沿用刚刚的方法,就需要从栈中弹出所有的中间值然后将这些值再压入栈中,这样做即使在最好的情况下也很容易出问题,而在最坏的情况下是不可能实现的操作。如图3-28所示,在内存中,压入栈中的每个值都与RSP寄存器有一定的偏移量,因此我们可以使用[rsp±offset]寻址模式直接访问我们感兴趣的值。在前面的示例中,可以使用以下单个指令重新加载RAX的原始值到RAX中:
mov rax,[rsp+8]
此代码将从内存地址rsp+8开始的8字节数据值复制到RAX寄存器中,这个值恰好是之前压入栈中的RAX值。可以使用相同的方法访问压入栈中的其他数据值。
注意: 需要牢记的是,每次往栈中压入或从栈中弹出数据时,RSP与栈中值之间的偏移量都会发生变化。滥用此功能会创建难以修改的代码。如果在整个代码中使用此功能,则在首次将数据压入到栈中的位置,以及决定使用[rsp+offset]内存寻址模式再次访问该数据的位置之间,想要压入和弹出其他数据项将变得非常困难。
上一节讨论了通过向RSP寄存器添加常量,从栈中移除数据的方法。以下伪代码实现的示例可能更加安全:
push rax
push rbx
代码序列,将计算结果值保存到RAX和RBX中
If( 执行了计算 )then
使用新的RAX/RBX值覆盖栈中保存的值
( 这样后续的pop操作指令就不会更改RAX/RBX中的值 )。
mov[rsp+8],rax
mov[rsp],rbx
endif;
pop rbx
pop rax
上述代码序列的计算结果存储在栈的顶部。稍后,当程序从栈中弹出这些计算值后,会将这些值加载到RAX和RBX中。
在本章讨论的功能中,唯一影响微软ABI功能的是数据对齐。作为一条通用的规则,微软ABI要求所有数据在该数据对象的自然边界(natural boundary)上对齐。自然边界是对象大小的倍数(最多16字节)地址。因此,如果用户打算将word/sword、dword/sdword或qword/sqword值传递给C++程序,则应尝试将对象分别对齐到2、4或8字节的边界上。
当调用由遵循微软ABI规范的语言编写的代码时,在发出一条call(调用)指令之前,必须确保栈在16字节边界上对齐。这会严重限制push和pop指令的实用性。如果在发出一条调用指令之前使用push指令保存寄存器的值,则必须确保在调用之前压入两个(64位)值,或者确保RSP地址是16字节的倍数。第5章将更详细地探讨这个问题。
在作者另一本较旧的16位版本的教程《汇编语言的编程艺术》(可以从https://artofasm.randallhyde.com/获取)中,可以找到有关8086的16位寻址模式和段的信息。该书的出版版本(No Starch出版社,2010年)涵盖了32位寻址模式。当然,英特尔x86文档(http://www.intel.com/)提供了有关x86-64寻址模式和机器指令编码的完整信息。
1.PC相对寻址模式对哪个64位寄存器进行了索引?
2.opcode代表什么?
3.PC相对寻址模式通常用于什么类型的数据?
4.PC相对寻址模式的地址范围是多少?
5.在寄存器间接寻址模式中,寄存器中包含什么内容?
6.以下哪个寄存器适用于寄存器间接寻址模式?
a.AL
b.AX
c.EAX
d.RAX
7.通常可以使用什么指令将内存对象的地址加载到寄存器中?
8.什么是有效地址?
9.在缩放索引寻址模式下,哪些缩放值是合法的?
10.在使用LARGEADDRESSAWARE:NO选项编译的应用程序中,内存限制范围是多少?
11.使用LARGEADDRESSAWARE:NO选项来编译应用程序,具有哪些优越性?
12.“.data”段和“.data?”段的区别是什么?
13.标准MASM的哪些内存段是只读的?
14.标准MASM的哪些内存段是可读写的?
15.什么是位置计数器?
16.请解释如何使用label伪指令将数据强制转换为其他类型。
17.请说明在MASM源文件中,如果存在两个(或两个以上)“.data”段,会发生哪些情况。
18.如何将“.data”段中的一个变量对齐到8字节边界?
19.MMU代表什么?
20.如果b是可读写存储器中的字节变量,请说明为什么“mov ax,b”指令会导致一个一般保护故障。
21.什么是地址表达式?
22.MASM PTR操作符的作用是什么?
23.大端模式值和小端模式值之间有什么区别?
24.如果AX包含一个大端模式值,则可以使用什么指令将其转换为一个小端模式值?
25.如果EAX包含一个小端模式值,则可以使用什么指令将其转换为一个大端模式值?
26.如果RAX包含一个大端模式值,则可以使用什么指令将其转换为一个小端模式值?
27.逐步详细解释“push rax”指令的操作过程。
28.逐步详细解释“pop rax”指令的操作过程。
29.使用push和pop指令保留寄存器时,必须始终按照压入时的____顺序弹出寄存器中的值。
30.LIFO代表什么?
31.如何在不使用push和pop指令的情况下访问栈中的数据?
32.在调用与微软ABI规则兼容的函数之前,将RAX的值压入栈,可能产生什么问题?