x86/x64体系探索及编程
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

第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的帮助文档。关于运行编译出来的程序,请参考下一章。