第2章 x86/x64编程基础
在这一章里,我们主要了解在x86和x64平台上编写汇编程序的基础和常用的一些指令。
2.1 选择编译器
nasm?fasm?yasm?还是masm、gas或其他?
前面三个是免费开源的汇编编译器,总体上来讲都使用Intel的语法。yasm是在nasm的基础上开发的,与nasm同宗。由于使用了相同的语法,因此nasm的代码可以直接用yasm来编译。
yasm虽然更新较慢,但对nasm一些不合理的地方进行了改良。从这个角度来看,yasm比nasm更优秀些,而nasm更新快,能支持更新的指令集。在Windows平台上,fasm是另一个不错的选择,平台支持比较好,可以直接用来开发Windows上的程序,语法也比较独特。在对Windows程序结构的支持上,fasm是3个免费的编译器里做得最好的。
masm是微软发布的汇编编译器,现在已经停止单独发布,被融合在Visual Studio产品中。gas是Linux平台上的免费开源汇编编译器,使用AT&T的汇编语法,使用起来比较麻烦。
由于本书的例子是在祼机上直接运行,因此笔者使用nasm,因为它的语法比较简洁,使用方法简单,更新速度非常快。不过如果要是用nasm来写Windows程序则是比较痛苦的,这方面的文档很少。
从nasm的官网可以下载最新的版本:http://www.nasm.us/pub/nasm/releasebuilds/?C=M;O=D,也可以浏览和下载其文档:http://www.nasm.us/docs.php。
2.2 机器语言
一条机器指令由相应的二进制数标识,直接能被机器识别。在汇编语言出现之前,使用机器指令编写程序是直接将二进制数输入计算机中。
C语言中的c=a+b在机器语言中应该怎样表达?
这是一个很麻烦的过程,a、b和c都是变量,在机器语言中应该怎样表达?C语言不能直接转换为机器语言,要先由C编译器译出相当的assembly,然后再由assembler生成机器指令,最终再由链接器将这些变量的地址定下来。
我们来看看怎样转化机器指令。首先用相应的汇编语言表达出来。
mov eax,[a] ; 变量 a 的值放到 eax 寄存器中 add eax,[b] ; 执行 a+b mov [c],eax ; 放到 c 中
在x86机器中,如果两个内存操作数要进行加法运算,不能直接相加,其中一方必须是寄存器,至少要将一个操作数放入寄存器中。这一表达已经是最简单形式了,实际上当然不止这么简单,还要配合程序的上下文结构。如果其中一个变量只是临时性的,C编译器可能会选择不放入内存中。那么这些变量是局部变量还是外部变量呢?编译器首先要决定变量的地址。
mov eax,[ebp-4] ; 变量 a 是局部变量 add eax,[ebp-8] ; 执行 a+b,变量b也是局部变量 mov [0x0000001c],eax ; 放到 c 中,变量c可能是外部变量
变量a和b是在stack上。在大多数的平台下,变量c会放入到.data节,可是在进行链接之前,c的地址可能只是一个偏移量,不是真正的地址,链接器将负责用变量c的真正地址来代替这个偏移值。
上面的汇编语言译成机器语言为
8b 45 fc ; 对应于 mov eax,[ebp-4] 03 45 f8 ; 对应于 add eax,[ebp-8] a3 1c 00 00 00 ; 对应于 mov [0x0000001c],eax
x86机器是CISC(复杂指令集计算)体系,指令的长度是不固定的,比如上述前面两条指令是3字节,最后一条指令是5字节。
x86机器指令长度最短1字节,最长15字节。
最后,假定.data节的基地址是0x00408000,那么变量c的地址就是0x00408000+0x1c=0x0040801c,经过链接后,最后一条机器指令变成
a3 1c 80 40 00 ; 原始汇编表达形式: mov [c],eax
指令同样采用little-endian存储序列,从低到高依次存放a3 1c 80 40 00字节,其中1c 80 40 00是地址值0x0040801c的little-endian字节序排列。
2.3 Hello world
按照惯例,我们先看看“Hello,World”程序的汇编版。
实验2-1:hello world程序
下面的代码相当于C语言main()里的代码。
代码清单2-1(topic02\ex2-1\setup.asm):
main: ; 这是模块代码的入口点。 mov si,caller_message call puts ; 打印信息 mov si,current_eip mov di,caller_address current_eip: call get_hex_string ; 转换为 hex mov si,caller_address call puts mov si,13 ; 打印回车 call putc mov si,10 ; 打印换行 call putc call say_hello ; 打印信息 jmp $ caller_message db 'Now:I am the caller,address is 0x' caller_address dq 0 hello_message db 13,10,'hello,world!',13,10 db 'This is my first assembly program...',13,10,13,10,0 callee_message db "Now:I'm callee - say_hello(),address is 0x" callee_address dq 0
实际上这段汇编语言相当于下面的几条C语言语句。
int main() { printf("Now:I am the caller,address is 0x%x",get_hex_string(current_eip)); printf(" "); say_hell0(); /* 调用 say_hello() */ }
相比而言,汇编语言的代码量就大得多了。下面是say_hello()的汇编代码。
代码清单2-2(topic02\ex2-1\setup.asm):
;-------------------------------------------; say_hello() ;------------------------------------------- say_hello: mov si,hello_message call puts mov si,callee_message call puts mov si,say_hello mov di,callee_address call get_hex_string mov si,callee_address call puts ret
这个say_hello()也仅相当于以下几条C语句。
void say_hello() { printf("hello,world This is my first assembly program..."); printf("Now:I'm callee - say_hello(),address is 0x%x",get_hex_string(say_hello)); }
代码清单2-1和2-2就组成了我们这个16位实模式下的汇编语言版本的hello world程序,它在VMware上的运行结果如下所示。
当然仅这两段汇编代码还远远不能达到上面的运行结果,这个例子中背后还有boot.asm和lib16.asm的支持,boot.asm用来启动机器的MBR模块,lib16.asm则是16位实模式下的库(在lib\目录下),提供类似于C库的功能。
main()的代码被加载到内存0x8000中,lib16.asm的代码被加载到0x8a00中,作为一个共享库的形式存在。这个例子里的全部代码都在topic02\ex2-1\目录下,包括boot.asm源文件和setup.asm源文件,而lib16.asm则在x86\source\lib\目录下。main()所在的模块是setup.asm。
16位?32位?还是64位?
在机器启动时处理器工作于16位实模式。这个hello world程序工作于16位实模式下,在编写代码时,需要给nasm指示为16位的代码编译,在代码的开头使用bits 16指示字声明。
bits 32指示编译为32位代码,bits 64指示编译为64位代码。
2.3.1 使用寄存器传递参数
C语言中的__stdcall和__cdecl调用约定会使用stack传递参数。
printf("hello,world This is my first assembly program...");
C编译器大概会如下这样处理。
push hello_message call printf
在汇编程序里尽量采用寄存器传递参数,正如前面的hello world程序那样:
mov si,hello_message ; 使用 si寄存器传递参数 call puts
使用寄存器传递参数能获得更高的效率。要注意在程序中修改参数值时,是否需要参数的不变性,按照惯例传递的参数通常是volatile(可变)的,可是在某些场合下保持参数的nonvolatile(不变)能简化代码,应尽量统一风格。
2.3.2 调用过程
call指令用来调用一个过程,可以直接给出一个目标地址值作为操作数,编译器生成的机器指令如下。
e8 c2 00 ; call puts
e8是指令call的opcode操作,c200是目标地址偏移量的little-endian排列,它的值是0x00c2,因而目标地址就在地址ip+0x00c2上。ip指示出了下一条指令地址。
instruction pointer在16位下表示为ip,32位下表示为eip,64位下表示为rip。
调用过程的另外一些常用形式如下。
mov ax,puts call ax ; 寄存器操作数 ;;; 或者是: call word [puts_pointer] ; 内存操作数
这些是near call的常用形式,[puts_pointer]存放puts过程的地址值,puts_pointer相当于C语言中的函数指针!是不是觉得很熟悉。它们的机器指令形式如下。
ff d0 ; call ax ff 16 10 80 ; call word [0x8010]
如上所示,在0x8010地址上存放着puts过程的地址。
2.3.3 定义变量
在nasm的汇编源程序里,可以使用db系列伪指令来定义初始化的变量,如下所示。
例如,我们可以这样使用db伪指令。
hello_message db 13,10,'hello,world!',13,10
这里为hello_message定义了一个字符串变量,相当于如下C语句。
hello_message[]=“ hello,world! ”;
十进制数字13是ASCII码中的回车符,10是换行符,当然也可以使用十六进制数0x0d和0x0a来赋初值。在nasm中可以使用单引号或双引号表达字符串常量。
callee_message db "Now:I'm callee - say_hello(),address is 0x"
2.4 16位编程、32位编程,以及64位编程
在nasm中可以在同一个源代码文件里同时指出16位代码、32位代码,以及64位代码。
bits 16 … … ; 以下是 16位代码 bits 32 … … ; 以下是 32位代码 bits 64 … … ; 以下是 64位代码
不用担心这里会有什么问题,编译器会为每部分生成正确的机器指令。关于16位机器码、32位机器码以及64位机器码,详见笔者个人网站里的《x86/x64指令系统》篇章,地址为http://www.mouseos.com/x64/default.html。
16位编程、32位编程,以及64位编程有什么不同之处?
这确实需要简单了解一下。
2.4.1 通用寄存器
在16位和32位编程里,可以使用的通用寄存器是一样的,如下所示。
在16位编程里可以使用32位的寄存器,在32位编程里也可以使用16位的寄存器,编译器会生成正确的机器码。
bits 16 ; 为16 位代码而编译 mov eax,1 ; 机器码是:66 b8 01 00 00 00
上面这段代码为16位代码编译,使用了32位的寄存器,编译器会自动加上default operand-size override prefix(66H字节),这个66H字节用来调整为正确的操作数。
bits 32 ; 为32位代码而编译 mov eax,1 ; 机器码是:b8 01 00 00 00
这段代码的汇编语句是完全一样的,只不过是为32位代码而编译,它们的机器码就是不一样的。
在x64体系里,在原来的8个通用寄存器的基础上新增了8个寄存器,并且原来的寄存器也得到了扩展。在64位编程里可以使用的通用寄存器如下表所示。
在64位编程里可以使用20个8位寄存器,和16个16位、32位及64位寄存器,寄存器体系得到了完整的补充。
所有的16个寄存器都可以分割出相应的8位、16位或32位寄存器。在16位编程和32位编程里,sp、bp、si及di不能使用低8位。在64位编程里,可以使用分割出的spl、bpl、sil及dil低8位寄存器。
64位的r8~r15寄存器分割出相对应的8位、16位及32位寄存器形式为:r8b~r15b、r8w~r15w,以及r8d~r15d。
bits 64 ; 为64位代码编译 mov r8b,1 mov spl,r8b
比如上面这两条指令必须在64位下使用,r8b和spl寄存器在16位和32位下是无效的。
2.4.2 操作数大小
在16位编程和32位编程下,寄存器没有使用上的不便,32位的操作数依旧可以在16位编程里使用,而16位的操作数也可以在32位编程下使用。
bits 16 push word 1 ; 16位操作数 push dword 1 ; 32位操作数 call ax ; 16 位操作数 call eax ; 32 位操作数 bits 32 push word 1 ; 16位操作数 push dword 1 ; 32位操作数 call ax ; 16 位操作数 call eax ; 32 位操作数
上面的代码完全可以用在16编程和32位编程里。在64位编程里操作数可以扩展到64位。
bits 64 mov rax,0x1122334455667788 ; 机器码是:b8 8877665544332211
这条指令直接使用了64位立即操作数。
2.4.3 64位模式下的内存地址
在64位编程里可以使用宽达64位的地址值。
canonical地址形式
然而,在x64体系里只实现了48位的virtual address,高16位被用做符号扩展。这高16位必须要么全是0,要么全是1,这种形式的地址被称为canonical地址,如下所示。
与canonical地址形式相对的是non-canoncial地址形式,如下所示。在64位模式下non-canonical地址形式是不合法的。
在64位的线性地址空间里,
① 0x00000000_00000000到0x00007FFF_FFFFFFFF是合法的canonical地址。
② 0x00008000_00000000到0xFFFF7FFF_FFFFFFFF是非法的non-canonical地址。
③ 0xFFFF8000_00000000到0xFFFFFFFF_FFFFFFFF是合法的canonical地址。
在non-canonical地址形式里,它们的符号扩展位出现了问题。
2.4.4 内存寻址模式
在16位和32位编程里,16位和32位的寻址模式都可以使用。在64位下,32位的寻址模式被扩展为64位,而且不能使用16位的寻址模式。
16位内存寻址模式
在16位编程里,内存操作数的寻址模式如下所示。
在16位寻址模式里基址只能使用bx和bp寄存器,变址只能使用si和di寄存器,displacement值使用8位或16位的偏移量。
32位内存寻址模式
在32位编程里,内存操作数的寻址模式如下所示。
基址和变址可以是8个通用寄存器。displacement的值是8位或32位。
如以下指令中地址操作数的使用:
mov eax,[eax + ecx*4 + 0x1c]
这是典型的“基址(base)加变址(index)寻址加上偏移量寻址”。
64位内存寻址模式
64位寻址模式形式和32位寻址模式是一致的,基址和变址寄存器默认情况下使用64位的通用寄存器。
64位寻址模式新增了一个RIP-Relative寻址形式。
RIP-Relative寻址:[rip+disp32]
这个displacement值是32位宽,地址值依赖于当前的RIP(指令指针)值。可是nasm的语法并不支持直接使用rip,像下面的用法是错误的。
mov rax,[rip + 0x1c] ; error:symbol 'rip' undefined
rip是处理器内部使用的寄存器,并不是外部编程可用的资源,但在yasm语法上是支持的。nasm中的解决方案是使用rel指示字。
mov rax,[rel table] ; rel指示字后面跟上一个地址label
这样就将编译为RIP-Relative寻址模式。RIP-Relative寻址最直接的好处是很容易构造PIC代码结构。
什么是PIC?PIC是指Position-Independent Code(不依赖于位置的代码)。
假设有一条指令调用了GetStdHandle()函数。
00073BEC FF15 DC810700 call dword ptr [__imp__GetStdHandle]
call指令从 [__imp__GetStdHandle] 里读取 Kernel32.dll 库里的 GetStdHandle() 入口地址,这里的__imp__GetStdHandle是绝对地址,地址值为 0x000781DC。
__imp__ReadFile: 000781D4 A3 3E 4D 75 __imp__XXXX: 000781D8 2C 3F 4D 75 __imp__GetStdHandle: 000781DC 83 51 4D 75
在0x000781DC(__imp__GetStdHandle)里放着的就是GetStdHandle()在库里的地址0x754D5183。
那么这条call指令就属于PDC(Position-Dependent Code,依赖于位置的代码)。
FF15 DC810700 call dword ptr [__imp__GetStdHandle@4 (781DCh)] ---------- 依赖于这个绝对地址
由于使用了绝对地址,当__imp__GetStdHandle的位置因重定位而有可能改变时,这条call指令就会出错,这个绝对地址已经不是__imp__GetStdHandle的地址了。
在x64体系的64位环境下,使用RIP-Relative很容易得到改善。
00073BEC 48 8d 85 e1 45 00 00 lea rbx,[rip + 0x45e1] ; 得到 __IMP_FUNCTION_TABLE的地址 00073BF3 48 03 1c c3 add rbx,[rbx + rax * 8] ; 得到 __imp_GetStdHandle 的地址 00073BF7 ff 13 call [rbx] ; call [__imp_GetStdHandle] ... ... __IMP_FUNCTION_TABLE: ; 函数表地址在 0x000781D4 000781D4 A3 3E 4D 75 000781D8 2C 3F 4D 75 000781DC 83 51 4D 75 ; GetStdHandle()的入口地址
在nasm里应该是lea rbx,[rel__IMP_FUNCTION_TABLE],这里使用rip是为了便于理解。使用lea指令配合RIP-Relative寻址得到的__IMP_FUNCTION_TABLE的地址不会因为重定位改变而改变,因为这里使用基于RIP的相对地址,没什么绝对地址,而这个代码的相对地址是不会变的。
内存寻址模式的使用
在16位编程和32位编程下依旧可以使用16位地址模式和32位地址模式。
bits 16 mov ax,[bx+si] ; 使用 16 位地址模式 mov eax,[eax+ecx*4] ; 使用 32 位地址模式 bits 32 mov ax,[bx+si] ; 使用 16 位地址模式 mov eax,[eax+ecx*4] ; 使用 32 位地址模式
指令的默认地址(16位或32位)依赖于CS.D标志位(在保护模式章节会有详细的描述),CS.D=1时使用32位的寻址模式,CS.L=0使用16位的寻址模式。
上面的代码中,编译器会生成正确的机器指令,当改变default address-size(默认的地址尺寸)时,生成的机器指令会相应地插入67H(address-size override prefix)这个前缀值。
在64位模式下,也可以使用67H改变默认的64位寻址模式,改变为32位的寻址模式。
2.4.5 内存寻址范围
在正常的情况下,16位实模式编程里,虽然可以使用32位的寻址模式,可是依然逃不过64K内存空间的限制(实际上可以改变地址值大小,在后面实模式的章节里进行探讨)。
假如在16位实模式下写出如下代码。
mov eax,0x200000 ; 2M 地址 mov eax,[eax] ; 错误:> 64K mov eax,0x2000 mov ecx,1 mov eax,[eax + ecx * 4] ; 正确:<= 64K
在32位保护模式下,可以寻址4G的线性空间,OS通常的做法会使用最大的4G寻址空间;而在64位环境,寻址空间增加到了64位,这个空间大小是不会改变的。
2.4.6 使用的指令限制
有些指令在64位环境里是不可用的,在编程过程中应避免,典型的如push cs/ds/es/ss指令和pop ds/es/ss指令,这些在16位和32位下常用的指令在64位模式下是无效的。
call 0x0018:0x00100000 ; 无效 jmp 0x0018:0x00100000 ; 无效
这些常用的direct far call/jmp(直接的远程call/jmp)也是无效的。此外还需要注意是否有权限去执行指令,像cli/sti这类指令需要0级的执行权限,in/out指令需要高于eflags.IOPL的执行权限。这里不再一一列举。
2.5 编程基础
在x86/x64平台上,大多数汇编语言(如:nasm)源程序的一行可以组织为
label: instruction-expression ; comment
一行有效的汇编代码主体是instruction expression(指令表达式),label(标签)定义了一个地址,汇编语言的comment(注释)以“;”号开始,以行结束为止。
最前面是指令的mnemonic(助记符),在通用编程里x86指令支持最多3个operand(操作数),以逗号分隔。前面的操作数被称为first operand(第1个操作数)或者目标操作数,接下来是second operand(第2个操作数)或源操作数。
有的时候,first operand会被称为first source operand(第1个源操作数),second operand会被称为second source operand(第2个源操作数):
两个操作数都是源操作数,并且第1个源操作数是目标操作数,可是还有另外一些情况。
在一些指令中并没有显式的目标操作数,甚至也没有显式的源操作数。而在AVX指令中first source operand也可能不是destination operand。
例如mul指令的目标操作数是隐含的,lodsb系列指令也不需要提供源操作数和目标操作数,它的操作数也是隐式提供的。使用source和destination来描述操作数,有时会产生迷惑。使用first operand(第1个操作数)、second operand(第2个操作数)、third operand(第3个操作数),以及fourth operand(第4个操作数)这些序数来描述操作数更清晰。
2.5.1 操作数寻址
数据可以存放在寄存器和内存里,还可以从外部端口读取。操作数寻址(operand addressing)是一个寻找数据的过程。
寄存器寻址
register addressing:在寄存器里存/取数据。
x86编程可用的寄存器操作数有GPR(通用寄存器)、flags(标志寄存器)、segment register(段寄存器)、system segment register(系统段寄存器)、control register(控制寄存器)、debug register(调试寄存器),还有SSE指令使用的MMX寄存器和XMM寄存器,AVX指令使用的YMM寄存器,以及一些配置管理用的MSR。
系统段寄存器:GDTR(全局描述符表寄存器),LDTR(局部描述符表寄存器),IDTR(中断描述符表寄存器),以及TR(任务寄存器)。使用在系统编程里,是保护模式编程里的重要系统数据资源。
系统段寄存器操作数是隐式提供的,没有明确的字面助记符,这和IP(Instruction Pointer)有异曲同工之处。
LGDT [GDT_BASE] ; 从内存 [GDT_BASE] 处加载GDT的base和limit值到 GDTR
x86体系里还有更多的隐式寄存器,MSR(Model Specific Register)能提供对处理器更多的配置和管理。每个MSR有相应的编址。在ecx寄存器里放入MSR的地址,由rdmsr指令进行读,wdmsr指令进行写。
mov ecx,1bH ; APIC_BASE 寄存器地址 rdmsr ; 读入APIC_BASE寄存器的64位值到edx:eax mov ecx,C0000080h ; EFER 地址 rdmsr ; 读入EFER原值 bts eax,8 ; EFER.LME=1 wdmsr ; 开启 long mode
用户编程中几乎只使用GPR(通用寄存器),sp/esp/rsp寄存器被用做stack top pointer(栈顶指针),bp/ebp/rbp寄存器通常被用做维护过程的stack frame结构。可是它们都可以被用户代码直接读/写,维护stack结构的正确和完整性,职责在于程序员。
内存操作数寻址
memory addressing:在内存里存/取数据。
内存操作数由一对[]括号进行标识,而在AT&T的汇编语法中使用()括号进行标识。x86支持的内存操作数寻址多种多样,参见前面所述内存寻址模式。
内存操作数的寻址如何提供地址值?
直接寻址是memory的地址值明确提供的,是个绝对地址。
mov eax,[0x00400000] ; 明确提供一个地址值
直接寻址的对立面是间接寻址,memory的地址值放在寄存器里,或者需要进行求值。
mov eax,[ebx] ; 地址值放在ebx寄存器里mov eax,[base_address + ecx * 2] ; 通过求值得到地址值
地址值的产生有多种形式,x86支持的最复杂形式如下。
在最复杂的形式里,额外提供了一个段值,用于改变原来默认的DS段,这个地址值提供了base寄存器加上index寄存器,并且还提供了偏移量。
上面的内存地址值是一个对有效地址进行求值的过程。那么怎么得到这个地址值呢?如下所示。
lea eax,[ebx + ecx*8 + 0x1c]
使用lea指令可以很容易获得这个求出来的值,lea指令的目的是load effective address(加载有效地址)。
立即数寻址
immediate:立即数无须进行额外的寻址,immediate值将从机器指令中获取。
在机器指令序列里可以包括immediate值,这个immediate值属于机器指令的一部分。
b8 01 00 00 00 ; 对应 mov eax,1
在处理器进行fetch instruction(取指)阶段,这个操作数的值已经确定。
I/O端口寻址
x86/x64体系实现了独立的64K I/O地址空间(从0000H到FFFFH),IN和OUT指令用来访问这个I/O地址。
一些数据也可能来自外部port。
in指令读取外部端口数据,out指令往外部端口写数据。
in al,20H ; 从端口20H里读取一个 byte
in和out指令是CPU和外部接口进行通信的工具。许多设备的底层驱动还是要靠in/out指令。端口的寻址是通过immediate形式,还可以通过DX寄存器提供port值。immediate只能提供8位的port值,在x86上提供了64K范围的port,访问0xff以上的port必须使用DX寄存器提供。
在x86/x64体系中device(设备)还可以使用memory I/O(I/O内存映射)方式映射到物理地址空间中,典型的如VGA设备的buffer被映射到物理地址中。
内存地址形式
在x86/x64体系里,常见的有下面几种地址形式。
① logical address(逻辑地址)。
② linear address(线性地址)。
③ physical address(物理地址)。
virtual address(虚拟地址)
virtual address并不是独立的,非特指哪一种地址形式,而是泛指某一类地址形式。physical address的对立面是virtual address,实际上,logical address和linear address(非real模式下)都是virtual address的形式。
logical address(逻辑地址)
逻辑地址是我们的程序代码中使用的地址,逻辑地址最终会被处理器转换为linear address(线性地址),这个linear address在real模式以及非分页的保护模式下就是物理地址。
逻辑地址包括两部分:segment和offset(segment:offset),这个offset值就是段内的effective address(有效地址值)。
segment值可以是显式或隐式的(或者称为默认的)。逻辑地址在real模式下会经常使用到,保护模式下在使用far pointer进行控制权的切换时显式使用segment值。
在高级语言层面上(典型的如C语言)我们实际上使用的是逻辑地址中的effective address(有效地址)部分,例如:变量的地址或者指针都是有效地址值。因此,在我们的程序中使用的地址值可以称为逻辑地址或虚拟地址。
effective address(有效地址)
如前面所述,effective address是logical address的一部分,它的意义是段内的有效地址偏移量。
logic addres(逻辑地址):Segment:Offset。Offset值是在一个Segment内提供的有效偏移量(displacement)。
这种地址形式来自早期的8086/8088系列处理器,Offset值基于一个段内,它必须在段的有效范围内,例如实模式下是64K的限制。因此,effective address就是指这个Offset值。
如上所示,这条lea指令就是获取内存操作数中的effective address(有效地址),在这个内存操作数里,提供了显式的segment段选择子寄存器,而最终的有效地址值为
effective_address=ebx + ecx * 8 + 0x1c
因此,目标操作数eax寄存器的值就是它们计算出来的结果值。
linear address(线性地址)
有时linear address(线性地址)会被直接称为virtual address(虚拟地址),因为linear address在之后会被转化为physical address(物理地址)。线性地址是不被程序代码中直接使用的。因为linear address由处理器负责从logical address中转换而来(由段base+段内offset而来)。实际上线性地址的求值中重要的一步就是:得到段base值的过程。
典型地,对于在real模式下一个逻辑地址segment:offset,有
linear_address=segment << 4 + offset
这个real模式的线性地址转换规则是segment*16+offset,实际上段的base值就是segment<<4。在protected-mode(保护模式)下,线性地址的转化为
linear_address=segment_base + offset
段的base值加上offset值,这个段的base值由段描述符的base域加载而来。而在64位模式下,线性地址为
linear_address=offset ; base 被强制为0值
在64位模式下,除了FS与GS段可以使用非0值的base外,其余的ES、CS、DS及SS段的base值强制为0值。因此,实际上线性地址就等于代码中的offset值。
physical address(物理地址)
linear address(或称virtual address)在开启分页机制的情况下,经过处理器的分页映射管理转换为最终的物理地址,输出到address bus。物理地址应该从以下两个地址空间来阐述。
① 内存地址空间。
② I/O地址空间。
在这些地址空间内的地址都属于物理地址。在x86/x64体系里,支持64K的I/O地址空间,从0000H到FFFFH。使用IN/OUT指令来访问I/O地址,address bus的解码逻辑将访问外部的硬件。
物理内存地址空间将容纳各种物理设备,包括:VGA设备,ROM设备,DRAM设备,PCI设备,APIC设备等。这些设备在物理内存地址空间里共存,这个DRAM设备就是机器上的主存设备。
在物理内存地址空间里,这些物理设备是以memory I/O的内存映射形式存在。典型地local APIC设置被映射到0FEE00000H物理地址上。
在Intel上,使用MAXPHYADDR这个值来表达物理地址空间的宽度。AMD和Intel的机器上可以使用CPUID的80000008 leaf来查询“最大的物理地址”值。
2.5.2 传送数据指令
x86提供了非常多的data-transfer指令,在这些传送操作中包括了:load(加载),store(存储),move(移动)。其中,mov指令是最常用的。
2.5.2.1 mov指令
mov指令形式如下。
目标操作数只能是register或者memory,源操作数则可以是register、memory或者immediate。x86/x64上不支持memory到memory之间的直接存取操作,只能借助第三方进行。
mov eax,[mem1] mov [mem2],eax ; [mem2] <- [mem1]
还要注意的是将immediate操作数存入memory操作数时,需要明确指出operand size(操作数大小)。
这是错误的!编译器不知道立即数1的宽度是多少字节,同样也不知道[mem]操作数到底是多少字节。两个操作数的size都不知道,因此无法生成相应的机器码。
mov eax,[mem1] ; OK! 目标操作数的 size 是 DWORD
编译器知道目标操作数的size是DWORD大小,[mem1]操作数无须明确指示它的大小。
mov dword [mem1],1 ; OK! 给目标操作数指示 DWORD 大小 mov [mem1],dword 1 ; OK! 给源操作数指示 DWORD 大小
nasm编译器支持给立即数提供size的指示,在有些编译器上是不支持的,例如:masm编译器。
mov dword ptr [mem1],1 ; OK! 只能给 [mem1] 提供 size 指示
微软的masm编译器使用dword ptr进行指示,这也是Intel与AMD所使用的形式。
什么是move、load、store、load-and-store操作?
在传送指令中有4种操作:move,load,store,以及load-and-store。下面我们来了解这些操作的不同。
move操作
在处理器的寄存器内部进行数据传送时,属于move操作,如下所示。
这种操作是最快的数据传送方法,无须经过bus上的访问。
load操作
当从内存传送数据到寄存器时,属于load操作,如下所示。
内存中的数据经过bus从内存中加载到处理器内部的寄存器。
store操作
当将处理器的数据存储到内存中时,属于store操作,如下所示。
MOV指令的目标操作数是内存。同样,数据经过bus送往存储器。
load-and-store操作
在有些指令里,产生了先load(加载)然后再store(存)回去的操作,如下所示。
这条ADD指令的目标操作数是内存操作数(同时也是源操作数之一)。它产生了两次内存访问,第1次读源操作数(第1个源操作数),第2次写目标操作数,这种属于load-and-store操作。
注意:这种操作是non-atomic(非原子)的,在多处理器系统里为了保证指令执行的原子性,需要在指令前加上lock前缀,如下所示。
lock add dword [mem],eax ; 保证 atomic
2.5.2.2 load/store段寄存器
有几组指令可以执行load/store段寄存器。
load段寄存器
下面的指令进行load段寄存器。
MOV sReg,reg/mem POP sReg LES/LSS/LDS/LFS/LGS reg
store段寄存器
下面的指令进行store段寄存器。
MOV reg/mem,sReg PUSH sReg
CS寄存器可以作为源操作数,但不能作为目标操作数。对于CS寄存器的加载,只能通过使用call/jmp和int指令,以及ret/iret返回等指令。call/jmp指令需要使用far pointer形式提供明确的segment值,这个segment会被加载到CS寄存器。
mov cs,ax ; 无效opcode,运行错误 #UD 异常 mov ax,cs ; OK!
pop指令不支持CS寄存器编码。
push cs ; OK! pop cs ; 编译错误,无此opcode!
les系列指令的目标操作数是register,分别从memory里加载far pointer到segment寄存器和目标寄存器操作数。far pointer是32位(16:16)、48位(16:32),以及80位(16:64)形式。
注意:在64位模式下,push es/cs/ss/ds指令、pop es/ss/ds指令及les/lds指令是无效的。而push fs/gs指令和pop fs/gs指令,以及lss/lfs/lgs指令是有效的。
实验2-2:测试les指令
在这个实验里,使用les指令来获得far pointer值,下面是主体代码。
代码清单2-3(topic02\ex2-2\protected.asm):
les ax,[far_pointer] ; get far pointer(16:16) current_eip: mov si,ax mov di,address call get_hex_string mov si,message call puts jmp $ far_pointer: dw current_eip ; offset 16 dw 0 ; segment 16 message db 'current ip is 0x', address dd 0,0
在Bochs里的运行结果如下。
2.5.2.3 符号扩展与零扩展指令
sign-extend(符号扩展)传送指令有两大类:movsx系列和cbw系列。
在movsx指令里8位的寄存器和内存操作数可以符号扩展到16位、32位及64位寄存器。而16位的寄存器和内存操作数可以符号扩展到32位和64位的寄存器。
movsxd指令将32位的寄存器和内存操作数符号扩展到64位的寄存器,形成了x64体系的全系列符号扩展指令集。
cbw指令族实现了对al/ax/eax/rax寄存器的符号扩展。而cwd指令族将符号扩展到了dx/edx/rdx寄存器上。
int a; /* signed DWORD size */ short b; /* signed WORD size */ a=b; /* sign-extend */
像上面这样的代码,编译器会使用movsx指令进行符号扩展。
movsx eax,word ptr [b] ; WORD sign-extend to DWORD mov [a],eax
zero-extend(零扩展)传送指令movzx在规格上和符号扩展movsx是一样的。
mov ax,0xb06a movsx ebx,ax ; ebx=0xffffb06a movzx ebx,ax ; ebx=0x0000b06a
2.5.2.4 条件mov指令
CMOVcc指令族依据flags寄存器的标志位做相应的传送。
在x86中,flags寄存器标志位可以产生16个条件。
signed数运算结果
G (greater) :大于 L (less) :小于 GE (greater or equal) :大于或等于 LE (less or equal) :小于或等于
于是就有了4个基于signed数条件CMOVcc指令:cmovg,cmovl,cmovge,以及cmovle,这些指令在mnemonic(助记符)上还可以产生另一些形式。
G => NLE(不小于等于) L => NGE(不大小等于) GE => NL(不小于) LE => NG(不大于)
因此,cmovg等价于cmovnle,在汇编语言上使用这两个助记符效果是一样的。
unsigned数运算结果
A (above) :高于 B (below) :低于 AE (above or equal) :高于或等于 BE (below or equal) :低于或等于
于是就有了4个基于unsigned数条件的CMOVcc指令:cmova,cmovb,cmovae,以及cmovbe,同样每个条件也可以产生否定式的表达:NBE(不低于等于),NAE(不高于等于),NB(不低于),以及NA(不高于)。
标志位条件码
另外还有与下面的标志位相关的条件。
① O(Overflow):溢出标志。
② Z(Zero):零标志。
③ S(Sign):符号标志。
④ P(Parity):奇偶标志。
当它们被置位时,对应的COMVcc指令形式为:cmovo,cmovz,cmovs,以及cmovp。实际上,OF标志、ZF标志和SF标志,它们配合CF标志用于产生signed数条件和unsigned数条件。
当它们被清位时,CMOVcc指令对应的指令形式是:cmovno,cmovnz,cmovns,以及cmovnp。
CMOVcc指令能改进程序的结构和性能,如对于下面的C语言代码。
printf("%s ",b == TRUE ? "yes" :"no");
这是一个典型的条件选择分支,在不使用CMOVcc指令时如下。
mov ebx,yes ; ebx=OFFSET "yes" mov ecx,no ; ecx=OFFSET "no" mov eax,[b] test eax,eax ; b == TRUE ? jnz continue mov ebx,ecx ; FALSE:ebx=OFFSET "no" continue: push ebx push OFFSET("%s ") call printf
使用CMOVcc指令可以去掉条件跳转指令。
mov ebx,yes ; ebx=OFFSET "yes" mov ecx,no ; ecx=OFFSET "no" mov eax,[b] test eax,eax ; b == TRUE ? cmovz ebx,ecx ; FALSE:ebx=OFFSET "no" push ebx push OFFSET("%s ") call printf
2.5.2.5 stack数据传送指令
栈上的数据通过push和pop指令进行传送。
stack的一个重要的作用是保存数据,在过程里需要修改寄存器值时,通过压入stack中保存原来的值。
push ebp ; 保存原stack-frame基址 mov ebp,esp ... mov esp,ebp pop ebp ; 恢复原stack-frame基址
像C语言,大多数情况下的函数参数是通过stack传递的。
printf("hello,world "); /*C中调用函数 */ push OFFSET("hello,world") ; 压入字符串 “hello,word” 的地址 call printf
如上所见stack具有不可替代的地位,因此push和pop指令有着举足轻重的作用。
2.5.3 位操作指令
x86也提供了几类位操作指令,包括:逻辑指令,位指令,位查询指令,位移指令。
2.5.3.1 逻辑指令
常用的包括and、or、xor,以及not指令。and指令做按位与操作,常用于清某位的操作;or指令做按位或操作,常用于置某位的操作。
and eax,0xFFFFFFF7 ; 清eax寄存器的Bit3位 or eax,8 ; 置eax寄存器的Bit3位
xor指令做按位异或操作,用1值异或可以取反,用0值异或可以保持不变,常用于快速清寄存器的操作。
xor eax,eax ; 清eax寄存器,代替 mov eax,0 xor eax,0 ; 效果等同于 and eax,eax xor eax,0xFFFFFFFF ; 效果类似于 not eax(不改变eflags标志)
not指令做取反操作,但是并不影响eflags标志位。
2.5.3.2 位指令
x86有专门对位进行操作的指令:bt,bts,btr,以及btc,它们共同的行为是将某位值复制到CF标志位中,除此而外,bts用于置位,btr用于清位,btc用于位取反。
bt eax,0 ; 取Bit0值到CF bts eax,0 ; 取Bit0值到CF,并将Bit0置位 btr eax,0 ; 取Bit0值到CF,并将Bit0清位 btc eax,0 ; 取Bit0值到CF,并将Bit0取反
这些指令可以通过查看CF标志来测试某位的值,很实用。
lock bts DWORD [spinlock],0 ; test-and-set,不断地进行测试并上锁
如果不想使用烦人的and与or指令,就可以使用它们(缺点是只能对1个位进行操作)。第1个operand可以是reg和mem,第2个operand可以是reg与imm值。
2.5.3.3 位查询指令
bsf指令用于向前(forward),从LSB位向MSB位查询,找出第1个被置位的位置。bsr指令用于反方向(reverse)操作,从MSB往LSB位查询,找出第1个被置位的位置。
mov eax,70000003H bsf ecx,eax ; ecx=0(Bit0为1) bsr ecx,eax ; ecx=30(Bit30为1)
它们根据ZF标志查看是否找到,上例中如果eax寄存器的值为0(没有被置位),则ZF=1,目标操作数不会改变。找到时ZF=0,当然可能出现bsf与bsr指令的结果一样的情况(只有一个位被置位)。
2.5.3.4 位移指令
x86上提供了多种位移指令,还有循环位移,并且可以带CF位移。
① 左移:shl/sal
② 右移:shr
③ 符号位扩展右移:sar
④ 循环左移:rol
⑤ 循环右移:ror
⑥ 带进位循环左移:rcl
⑦ 带进位循环右移:rcr
⑧ double左移:shld
⑨ double右移:shrd
SHL/SAL指令在移位时LSB位补0,SHR右移时MSB补0,而SAR指令右移时MSB位保持不变。
ROL移位时,MSB移出到CF的同时补到LSB位上。ROR指令移位时,LSB移出CF的同时补到MSB位上。
如上所示,RCL与RCR都是带进位标志的循环移位,CF值会分别补到LSB和MSB。
SHLD和SHRD指令比较独特,可以移动的操作数宽度增加一倍,改变operand 1,但operand 2并不改变。
mov eax,11223344H mov ebx,55667788H ; shld ebx,eax,8 ; ebx=66778811H,eax不变
2.5.4 算术指令
① 加法运算:ADD,ADC,以及INC指令。
② 减法运算:SUB,SBB,以及DEC指令。
③ 乘法运算:MUL和IMUL指令。
④ 除法运算:DIV和IDIV指令。
⑤ 取反运算:NEG指令。
加减运算是二进制运算,不区别unsigned与signed数,乘除运算按unsigned和signed区分指令。neg指令是对singed进行取负运算。ADC是带进位的加法,SBB是带借进的减法,用来构造大数的加减运算。
add eax,ebx ; edx:eax + ecx:ebx adc edx,ecx ; edx:eax=(edx:eax + ecx:ebx) sub eax,ebx ; edx:eax – ecx:ebx sbb edx,ecx ; edx:eax=(edx:eax – ecx:ebx)
2.5.5 CALL与RET指令
CALL调用子过程,在汇编语言里,它的操作数可以是地址(立即数)、寄存器或内存操作数。call指令的目的是要装入目标代码的IP(Instruction Pointer)值。
目标地址放在register里时,EIP从寄存器里取;放在memory里时,从memory里获得EIP值。在汇编语言表达里,直接给出目标地址作为call操作数的情况下,编译器会计算出目标地址的offset值(基于EIP偏移量),这个offset值作为immediate操作数。
为了返回到调用者,call指令会在stack中压入返回地址,ret指令返回时从stack里取出返回值重新装载到EIP里然后返回到调用者。
2.5.6 跳转指令
跳转指令分为无条件跳转指令JMP和条件跳转指令Jcc(cc是条件码助记符),这个cc条件码和前面CMOVcc指令的条件码是同样的意义。
jmp系列指令与call指令最大的区别是:jmp指令并不需要返回,因此不需要进行压stack操作。
2.6 编辑与编译、运行
选择一个自己习惯的编辑器编写源代码。有许多免费的编辑器可供选择,其中Notepad++就非常不错。然后使用编译器对源码进行编译。
nasm t.asm ; 输出t.o在当前目录下 nasm t.asm –oe:\test\t.o ; 提供输出文件 nasm t.asm –fbin ; 提供输出文件 nasm t.asm –Ie:\source\ ; 提供编译器的当前工作目录 nasm t.asm –dTEST ; 预先定义一个符号(宏名)
-I<目录>参数似乎是提供include文件路径,实际上,对于nasm来说理解为提供当前工作目录更为适合,如果t.asm文件里有
%include “..\inc\support.inc” ; include类似C的头文件
inc目录在source目录下,如果当前的目录为source\topic01\,那么你应该选择的命令是
e:\x86\source\topic01>nasm –Ie:\x86\source\ t.asm
或者
e:\x86\source\topic01>nasm –I..\ t.asm
关于nasm的详细资料请查阅nasm的帮助文档。关于运行编译出来的程序,请参考下一章。