Professional Documents
Culture Documents
x86/x64 编程基础
2.1 选择编译器
2.2 机器语言
一条机器指令由相应的二进制数标识,直接能被机器识别。在汇编语言出现之前,
使用机器指令编写程序是直接将二进制数输入计算机中。
这是一个很麻烦的过程,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 可能是外部变量
最 后 , 假 定 .data 节 的 基 地 址 是 0x00408000 , 那 么 变 量 c 的 地 址 就 是
0x00408000+0x1c = 0x0040801c,经过链接后,最后一条机器指令变成
a3 1c 80 40 00 ; 原始汇编表达形式: mov [c], eax
25
┃x86/x64 体系探索及编程┃
按照惯例,我们先看看“Hello, World”程序的汇编版。
实验 2-1:hello world 程序
下面的代码相当于 C 语言 main()里的代码。
代码清单 2-1(topic02\ex2-1\setup.asm):
main: ; 这是模块代码的入口点。
jmp $
实际上这段汇编语言相当于下面的几条 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
26
┃第 2 章 x86/x64 编程基础┃
当然仅这两段汇编代码还远远不能达到上面的运行结果,这个例子中背后还有
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 位?
2.3.1 使用寄存器传递参数
27
┃x86/x64 体系探索及编程┃
C 编译器大概会如下这样处理。
push hello_message
call printf
使用寄存器传递参数能获得更高的效率。要注意在程序中修改参数值时,是否需要
参数的不变性,按照惯例传递的参数通常是 volatile(可变)的,可是在某些场合下保持
参数的 nonvolatile(不变)能简化代码,应尽量统一风格。
2.3.2 调用过程
call 指令用来调用一个过程,可以直接给出一个目标地址值作为操作数,编译器生成
的机器指令如下。
e8 c2 00 ; call puts
调用过程的另外一些常用形式如下。
mov ax,puts
call ax ; 寄存器操作数
;;; 或者是:
call word [puts_pointer] ; 内存操作数
2.3.3 定义变量
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
不用担心这里会有什么问题,编译器会为每部分生成正确的机器指令。关于 16 位机
器码、32 位机器码以及 64 位机器码,详见笔者个人网站里的《x86/x64 指令系统》篇
章,地址为 http://www.mouseos.com/x64/default.html。
这确实需要简单了解一下。
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
这段代码的汇编语句是完全一样的,只不过是为 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
31
┃x86/x64 体系探索及编程┃
mov r8b, 1
mov spl, r8b
2.4.2 操作数大小
bits 32
这条指令直接使用了 64 位立即操作数。
2.4.3 64 位模式下的内存地址
在 64 位编程里可以使用宽达 64 位的地址值。
canonical 地址形式
然而,在 x64 体系里只实现了 48 位的 virtual address,高 16 位被用做符号扩展。这高
16 位必须要么全是 0,要么全是 1,这种形式的地址被称为 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 位内存寻址模式
在 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]
32 位内存寻址模式
在 32 位编程里,内存操作数的寻址模式如下所示。
33
┃x86/x64 体系探索及编程┃
寻址形式 描述
[base] 基址
[disp32] 偏移量
[base+index] 基址+变址
[base+disp8/32] 基址+偏移量
[base+index*scale+disp8/32] 基址+变址+偏移量
这是典型的“基址(base)加变址(index)寻址加上偏移量寻址”。
64 位内存寻址模式
64 位寻址模式形式和 32 位寻址模式是一致的,基址和变址寄存器默认情况下使用
64 位的通用寄存器。
64 位寻址模式新增了一个 RIP-Relative 寻址形式。
假设有一条指令调用了 GetStdHandle()函数。
00073BEC FF15 DC810700 call dword ptr [__imp__GetStdHandle]
__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]
... ...
000781D4 A3 3E 4D 75
000781D8 2C 3F 4D 75
000781DC 83 51 4D 75 ; GetStdHandle()的入口地址
内存寻址模式的使用
在 16 位编程和 32 位编程下依旧可以使用 16 位地址模式和 32 位地址模式。
bits 16
bits 32
35
┃x86/x64 体系探索及编程┃
模式。
2.4.5 内存寻址范围
2.4.6 使用的指令限制
有 些 指 令 在 64 位环 境 里 是 不 可用 的 , 在 编 程过程 中 应 避 免 , 典 型 的如 push
cs/ds/es/ss 指令和 pop ds/es/ss 指令,这些在 16 位和 32 位下常用的指令在 64 位模式下是
无效的。
call 0x0018:0x00100000 ; 无效
jmp 0x0018:0x00100000 ; 无效
2.5 编程基础
在 x86/x64 平台上,大多数汇编语言(如:nasm)源程序的一行可以组织为
label: instruction-expression ; comment
36
┃第 2 章 x86/x64 编程基础┃
两个操作数都是源操作数,并且第 1 个源操作数是目标操作数,可是还有另外一些
情况。
在一些指令中并没有显式的目标操作数,甚至也没有显式的源操作数。
而在 AVX 指令中 first source operand 也可能不是 destination operand。
2.5.1 操作数寻址
数据可以存放在寄存器和内存里,还可以从外部端口读取。操作数寻址(operand
addressing)是一个寻找数据的过程。
寄存器寻址
register addressing:在寄存器里存/取数据。
系统段寄存器:GDTR(全局描述符表寄存器),LDTR(局部描述符表
寄存器),IDTR(中断描述符表寄存器),以及 TR(任务寄存器)。使用在
系统编程里,是保护模式编程里的重要系统数据资源。
系统段寄存器操作数是隐式提供的,没有明确的字面助记符,这和 IP(Instruction
Pointer)有异曲同工之处。
37
┃x86/x64 体系探索及编程┃
内存操作数寻址
memory addressing:在内存里存/取数据。
内存操作数的寻址如何提供地址值?
直接寻址的对立面是间接寻址,memory 的地址值放在寄存器里,或者需要进行求
值。
mov eax, [ebx] ; 地址值放在 ebx 寄存器里
mov eax, [base_address + ecx * 2] ; 通过求值得到地址值
地址值的产生有多种形式,x86 支持的最复杂形式如下。
在最复杂的形式里,额外提供了一个段值,用于改变原来默认的 DS 段,这个地址
值提供了 base 寄存器加上 index 寄存器,并且还提供了偏移量。
上面的内存地址值是一个对有效地址进行求值的过程。那么怎么得到这个地址值
呢?如下所示。
lea eax, [ebx + ecx*8 + 0x1c]
38
┃第 2 章 x86/x64 编程基础┃
address(加载有效地址)。
立即数寻址
immediate:立即数无须进行额外的寻址,immediate 值将从机器指令中获
取。
I/O 端口寻址
x86/x64 体系实现了独立的 64K I/O 地址空间(从 0000H 到 FFFFH),IN 和 OUT 指
令用来访问这个 I/O 地址。
一些数据也可能来自外部 port。
in 指令读取外部端口数据,out 指令往外部端口写数据。
in al, 20H ; 从端口 20H 里读取一个 byte
内存地址形式
在 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 模式以及非分页的保护模式下就是物理
地址。
effective address(有效地址)
如前面所述,effective address 是 logical address 的一部分,它的意义是段内的有效地
址偏移量。
linear address(线性地址)
有时 linear address(线性地址)会被直接称为 virtual address(虚拟地址),因为
linear address 在之后会被转化为 physical address(物理地址)。线性地址是不被程序代码
中直接使用的。因为 linear address 由处理器负责从 logical address 中转换而来(由段 base
40
┃第 2 章 x86/x64 编程基础┃
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 传送数据指令
41
┃x86/x64 体系探索及编程┃
2.5.2.1 mov 指令
mov 指令形式如下。
move 操作
在处理器的寄存器内部进行数据传送时,属于 move 操作,如下所示。
42
┃第 2 章 x86/x64 编程基础┃
load 操作
当从内存传送数据到寄存器时,属于 load 操作,如下所示。
store 操作
当将处理器的数据存储到内存中时,属于 store 操作,如下所示。
load-and-store 操作
在有些指令里,产生了先 load(加载)然后再 store(存)回去的操作,如下所示。
这条 ADD 指令的目标操作数是内存操作数(同时也是源操作数之一)。它产生了两
次内存访问,第 1 次读源操作数(第 1 个源操作数),第 2 次写目标操作数,这种属于
load-and-store 操作。
注意:这种操作是 non-atomic(非原子)的,在多处理器系统里为了保证指令执行的
原子性,需要在指令前加上 lock 前缀,如下所示。
lock add dword [mem], eax ; 保证 atomic
load 段寄存器
下面的指令进行 load 段寄存器。
43
┃x86/x64 体系探索及编程┃
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!
实验 2-2:测试 les 指令
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
在 Bochs 里的运行结果如下。
44
┃第 2 章 x86/x64 编程基础┃
2.5.2.3 符号扩展与零扩展指令
sign-extend(符号扩展)传送指令有两大类:movsx 系列和 cbw 系列。
45
┃x86/x64 体系探索及编程┃
2.5.2.4 条件 mov 指令
CMOVcc 指令族依据 flags 寄存器的标志位做相应的传送。
signed 数运算结果
G (greater) : 大于
L (less) : 小于
GE (greater or equal) : 大于或等于
LE (less or equal) : 小于或等于
unsigned 数运算结果
A (above) : 高于
B (below) : 低于
AE (above or equal) : 高于或等于
BE (below or equal) : 低于或等于
标志位条件码
另外还有与下面的标志位相关的条件。
① 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");
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
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 位
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,不断地进行测试并上锁
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)
2.5.3.4 位移指令
x86 上提供了多种位移指令,还有循环位移,并且可以带 CF 位移。
48
┃第 2 章 x86/x64 编程基础┃
① 左移:shl/sal
② 右移:shr
③ 符号位扩展右移:sar
④ 循环左移:rol
⑤ 循环右移:ror
⑥ 带进位循环左移:rcl
⑦ 带进位循环右移:rcr
⑧ double 左移:shld
⑨ double 右移:shrd
49
┃x86/x64 体系探索及编程┃
2.5.4 算术指令
CALL 调用子过程,在汇编语言里,它的操作数可以是地址(立即数)、寄存器或内
存操作数。call 指令的目的是要装入目标代码的 IP(Instruction Pointer)值。
2.5.6 跳转指令
50
┃第 2 章 x86/x64 编程基础┃
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 ; 预先定义一个符号(宏名)
或者
e:\x86\source\topic01>nasm –I..\ t.asm
51