You are on page 1of 29

第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 章 x86/x64 编程基础┃

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 字节序排列。

25 
┃x86/x64 体系探索及编程┃

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("\n");

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

 26
┃第 2 章 x86/x64 编程基础┃

mov di, callee_address


call get_hex_string

mov si, callee_address


call puts
ret

这个 say_hello() 也仅相当于以下几条 C 语句。


void say_hello()
{
printf("hello,world\nThis 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\nThis is my first assembly program...");

27 
┃x86/x64 体系探索及编程┃

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 操作,c2 00 是目标地址偏移量的 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 字节 定义 byte 序列
整数 dw 字 定义 word 序列
dd 双字 定义 doubleword 序列

 28
┃第 2 章 x86/x64 编程基础┃

dq 四字 定义 quadword 序列

续表
类型 伪指令 数据宽度 描述
dt 10bytes 定义 80 位的 double extended-precision 序列
浮点
do 16 bytes 定义 128 位的浮点数

例如,我们可以这样使用 db 伪指令。
hello_message db 13, 10, 'hello,world!', 13,10

这里为 hello_message 定义了一个字符串变量,相当于如下 C 语句。


hello_message[] = “\nhello, world!\n”;

十进制数字 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 位编程里,可以使用的通用寄存器是一样的,如下所示。
8位 16 位 32 位
al ax eax
cl cx ecx
dl dx edx

29 
┃x86/x64 体系探索及编程┃

bl bx ebx

 30
┃第 2 章 x86/x64 编程基础┃

续表
8位 16 位 32 位
ah sp esp
ch bp ebp
dh si esi
bh di edi

在 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 位编程里可以使用的通用寄存器如下表所示。
8位 16 位 32 位 64 位
原 新增 原 新增 原 新增 扩展 新增
al r8b ax r8w eax r8d rax r8
cl r9b cx r9w ecx r9d rcx r9
---
dl r10b dx r10w edx r10d rdx r10
bl r11b bx r11w ebx r11d rbx r11
ah spl r12b sp r12w esp r12d rsp r12
ch bpl r13b bp r13w ebp r13d rbp r13
dh sil r14b si r14w esi r14d rsi r14
bh dil r15b di r15w edi r15d rdi r15

在 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 位代码编译

31 
┃x86/x64 体系探索及编程┃

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 地址形式是不合法的。

 32
┃第 2 章 x86/x64 编程基础┃

在 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 位编程里,内存操作数的寻址模式如下所示。
形式 1 形式 2
[bx+si] [bx+si+disp8/16]
[bx+di] [bx+di+disp8/16]
[bp+si] [bp+si+disp8/16]
[bp+di] [bp+di+disp8/16]
[si] [si+disp8/16]
[di] [di+disp8/16]
[disp16] [bp+disp8/16]
[bx] [bx+disp8/16]

在 16 位寻址模式里基址只能使用 bx 和 bp 寄存器,变址只能使用 si 和 di 寄存器,


displacement 值使用 8 位或 16 位的偏移量。

32 位内存寻址模式
在 32 位编程里,内存操作数的寻址模式如下所示。

33 
┃x86/x64 体系探索及编程┃

寻址形式 描述
[base] 基址
[disp32] 偏移量
[base+index] 基址+变址
[base+disp8/32] 基址+偏移量
[base+index*scale+disp8/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

 34
┃第 2 章 x86/x64 编程基础┃

__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 位的寻址

35 
┃x86/x64 体系探索及编程┃

模式。

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个操作数)或

 36
┃第 2 章 x86/x64 编程基础┃

者目标操作数,接下来是 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)有异曲同工之处。

37 
┃x86/x64 体系探索及编程┃

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

 38
┃第 2 章 x86/x64 编程基础┃

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 的形式。

39 
┃x86/x64 体系探索及编程┃

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

 40
┃第 2 章 x86/x64 编程基础┃

+ 段内 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 指令是最常用的。

41 
┃x86/x64 体系探索及编程┃

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 操作,如下所示。

 42
┃第 2 章 x86/x64 编程基础┃

这种操作是最快的数据传送方法,无须经过 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 段寄存器。

43 
┃x86/x64 体系探索及编程┃

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 里的运行结果如下。

 44
┃第 2 章 x86/x64 编程基础┃

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

45 
┃x86/x64 体系探索及编程┃

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,以

 46
┃第 2 章 x86/x64 编程基础┃

及 cmovnp。
CMOVcc 指令能改进程序的结构和性能,如对于下面的 C 语言代码。
printf("%s\n", 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\n")
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\n")
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\n"); /*C 中调用函数 */
push OFFSET("hello,world") ; 压入字符串 “hello,word” 的地址
call printf

如上所见 stack 具有不可替代的地位,因此 push 和 pop 指令有着举足轻重的作用。

47 
┃x86/x64 体系探索及编程┃

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 位移。

 48
┃第 2 章 x86/x64 编程基础┃

① 左移: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 不变

49 
┃x86/x64 体系探索及编程┃

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 操作
数。
e8 fb0f0000 ; call func
--------
(offset)
|
|
+------------------> eip + offset = target(装入 eip)

为了返回到调用者,call 指令会在 stack 中压入返回地址,ret 指令返回时从 stack 里


取出返回值重新装载到 EIP 里然后返回到调用者。

2.5.6 跳转指令

跳转指令分为无条件跳转指令 JMP 和条件跳转指令 Jcc(cc 是条件码助记符),这


个 cc 条件码和前面 CMOVcc 指令的条件码是同样的意义。

 50
┃第 2 章 x86/x64 编程基础┃

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


下一章。

51 

You might also like