x86-64 汇编基础 ---- 记读 《CS: APP》
通常情况下, 使用现代的优化编译器产生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样有效
1. 看懂汇编码
1). 汇编码的格式
· ATT格式
这是GCC/ OBJDUMP和其它一些工具的常用格式, 由AT&T公司命名
使用命令gcc -S <file.c>
输出的汇编码就是这种格式.
一段示例代码如下所示:
movl (%rdi), %eax
imull %eax, %eax
movl %eax, (%rdi)
ret
在《CS: APP》一书中, 使用ATT格式书写汇编码, 因此, 无特殊说明时, 下文的所有汇编码也是ATT格式.
· Intel格式
Intel和Microsoft的文档中经常会出现这种格式的代码, 它个ATT格式的代码有些许不同, 如下示例:
mov eax, DWORD PTR [rdi]
imul eax, eax
mov DWORD PTR [rdi], eax
ret
使用命令gcc -S -masm=intel <file.c>
以输出这种格式的代码
Introduction to x64 Assembly | Intel® Software
注意, 上述代码中的源操作数和目的操作数的位置发生了变化, 因此在分析代码时应注意汇编码的格式问题.
使用GCC编译器输出的汇编码中, 以 "." 开头的行是 伪指令 , 可以适当忽略.
2) 数据格式
在C语言中, 变量具有其"数据类型", 但是汇编码中的"变量"没有此概念(或者说, 没有"变量"的概念). 但是C语言中数据类型的概念在汇编语言中必须有与其相对应的机制, 在x86-64汇编中, 控制指令操作数的长度, 与C语言中的数据类型概念对应.
下表是摘自书中的C语言数据类型在x86-64中的大小:
C声明 | Intel 数据类型 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char * | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
在汇编码(ATT格式)中, 通过在指令后加"后缀"表明操作数的大小. (后缀见上表)
3) 操作数指示符
几乎每一条指令都有对应的操作数, 操作数的值有几种形式: 立即数/ 寄存器/ 内存, 其中, 立即数和寄存器可以直接参与运算, 但存放于内存中的数参与运算时, 需要访存操作(此时立即数或寄存器中的值是内存地址).
最完整的一种地址形式是 8(%rax, %rdx, 4)
.
ATT格式汇编中的操作数格式如下表所述, 其中, R[r]
表示从寄存器组中取出寄存器r
的值; M[addr]
表示从内存的地址addr
处取值.
类型 | 格式 | 操作数值 | 名称 |
---|---|---|---|
立即数 | $Imm |
Imm |
立即数寻址 |
寄存器 | r |
R[r] |
寄存器寻址 |
存储器 | Imm |
M[Imm] |
绝对寻址 |
存储器 | (r) |
M[R[r]] |
间接寻址 |
存储器 | Imm(r) |
M[Imm+R[r]] |
(基址 + 偏移量)寻址 |
存储器 | (rb, ri) |
M[R[rb] + R[ri]] |
变址寻址 |
存储数 | Imm(rb, ri) |
M[Imm + R[rb] + R[ri]] |
变址寻址 |
存储器 | (, ri, s) |
M[R[ri] * s] |
比例变址寻址 |
存储器 | Imm(, ri, s) |
M[Imm + R[ri] * s] |
比例变址寻址 |
存储数 | (ra, ri, s) |
M[R[ra] + R[ri] * s] |
比例变址寻址 |
存储器 | Imm(ra, ri, s) |
M[Imm + R[ra] + R[ri] * s] |
比例变址寻址 |
应当注意, 取值(尤其是从内存中取值)时的数据长度由指令(后缀)决定.
2. 通用寄存器
x86-64中的寄存器比RISC架构的CPU的寄存器少.
一个x86-64的CPU包含一组16个存储64位值的通用目的寄存器. 它们的名字都已%r
开头, 不过由于指令集的历史演化, 它们的命名还有一些不同.
下图所述为这16个寄存器:
63~0位 | 31~0位 | 15~0位 | 7~0位 | 用途 |
---|---|---|---|---|
%rax | %eax | %ax | %al | 返回值 |
%rbx | %ebx | %bx | %bl | 被调用者保存 |
%rcx | %ecx | %cx | %cl | 第4个参数 |
%rdx | %edx | %dx | %dl | 第3个参数 |
%rsi | %esi | %si | %sil | 第2个参数 |
%rdi | %edi | %di | %dil | 第1个参数 |
%rbp | %ebp | %bp | %bpl | 被调用者保存 |
%rsp | %esp | %sp | %spl | 栈指针 |
%r8 | %r8d | %r8w | %r8b | 第5个参数 |
%r9 | %r9d | %r9w | %r9b | 第6个参数 |
%r10 | %r10d | %r10w | %r10b | 调用者保存 |
%r11 | %r11d | %r11w | %r11b | 调用者保存 |
%r12 | %r12d | %r12w | %r12b | 被调用者保存 |
%r13 | %r13d | %r13w | %r13b | 被调用者保存 |
%r14 | %r14d | %r14w | %r14b | 被调用者保存 |
%r15 | %r15d | %r15w | %r15b | 被调用者保存 |
对于生成小于8字节结果的指令, 寄存器中剩下的字节遵循一下两条规则:
- 生成1字节和2字节数字的指令会保持剩下的字节不变;
- 生成4字节数字的指令会把高位4个字节置为0
3. 数据传送指令
最频繁使用的指令是将数据从一个位置复制到另一个位置的指令, <MOV
类.
指令格式 | 指令功能 | 效果 |
---|---|---|
MOV Src, Dest | 传送 | Src→Dest |
movb S,D | 传送一个字节(8位) | S->D |
movw S,D | 传送一个字(16位) | S->D |
movl S,D | 传送双字(32位) | S->D |
movq S,D | 传送四字(32位扩展为64位) | S->D |
movabsq I,R | 传送绝对的四字(64位) | I->R |
应当注意, x86-64加了一条限制, 传送指令的两个操作数不能都指向内存位置. 因此, 将一个值从一个内存位置复制到另一个内存位置需要"先从内存加载到寄存器, 再从寄存器写入到内存".
关于movabsq
: 常规的movq
指令只能以表示为32位补码数字的立即数作为源操作数, 然后把这个值符号扩展得到64位的值, 放到目的位置. movabsq
指令能够以任意64位立即数值作为源操作数, 并且只能以寄存器作为目的.
大多数情况下, MOV
指令只会更新目的操作数指定的那些寄存器字节或内存位置, 唯一例外的是movl
指令以寄存器为目的时, 它会把该寄存器的高位4字节设置为0(如上"规则"所述)
在将较小值复制到较大目的(类似于强制类型转换)时, 可使用一下"带有填充"的数据移动指令:
- 零填充
指令格式 | 指令功能 | 效果 |
---|---|---|
MOVZ Src, Dst | 传送, 高位补零 | Src → Dst |
movzbw | ||
movzbl | ||
movzbq | ||
movzwl | ||
movzwq |
- 符号填充
指令格式 | 指令功能 | 效果 |
---|---|---|
MOVS Src, Dst | 传送, 高位以符号填充 | Src → Dst |
movsbw | ||
movsbl | ||
movsbq | ||
movswl | ||
movswq | ||
movslq | ||
cltq | 把%eax符号扩展到%rax | 符号扩展(%eax)→%rax |
4. 运算指令
- 加载有效地址
指令 | 效果 | 描述 |
---|---|---|
leaq S, D | D = &S | 只运算地址, 不操作数据. |
leaq
指令用于生成(运算)地址, 但是不对地址指向的目标做操作. 编译器会使用该指令进行一些算术运算.
例如:
leaq 1(%rdi, %rsi, 4), %rax
执行后, %rax = 1 + %rdi + 4 * %rsi
- 算数运算
指令 | 效果 | 描述 |
---|---|---|
INC D | D++ | 自增 |
DEC D | D-- | 自减 |
NEG D | D = -D | 取负 |
ADD S, D | D += S | 加法 |
SUB S, D | D -= S | 减法 |
IMUL S, D | D *= S | 乘法 |
- 特殊的算术操作
两个64位有符号或无符号整数相乘得到的乘积需要128位来表示, x86-64指令集对128位数的操作提供有限的支持. Intel 把16字节的数称为"八字"
指令 | 效果 | 描述 |
---|---|---|
imulq S | R[%rdx]: R[%rax] = S * R[%rax] | 有符号全乘法 |
mulq S | R[%rdx]: R[%rax] = S * R[%rax] | 无符号全乘法 |
clto | R[%rdx]: R[%rax] = 符号扩展(R[%rax]) | 转换为八字 |
idivq S | R[%rdx] = R[%rdx] : R[%rax] mod S; R[%rax] = R[%rdx] : R[%rax] / S | 有符号除法 |
divq S | R[%rdx] = R[%rdx] : R[%rax] mod S; R[%rdx] = R[%rdx] : R[%rax] / S | 无符号除法 |
- 逻辑运算
指令 | 效果 | 描述 |
---|---|---|
NOT D | D = ~D | 取补 |
XOR S, D | D ^= S | 异或(负负得正) |
OR S, D | D |= S | 或 |
AND S, D | D &= S | 与 |
SAL k, D | D <<= k | 左移 |
SHL k, D | D <<= k | 左移 |
SAR k, D | D >>= k | 算数右移 |
SHR k, D | D >>= k | 逻辑右移 |
5. 条件及跳转
条件码
除了整数寄存器, CPU还维护着一组单个位的条件码(Condition Code)寄存器, 它们描述了最近的算数或逻辑操作的属性, 可以检测这些寄存器来执行条件分支指令. 最常用的条件码有:
-
CF: 进位标志
最近的操作使最高位产生了进位
-
ZF: 零标志
最近的操作得出的结果为0
-
SF: 符号标志
最近的操作得到的结果为负数
-
OF: 溢出标志
最近的操作导致了一个补码溢出 ---- 正溢出或负溢出
例如, 假设有一条 ADD
指令完成等价于C表达式 t = a + b
的功能, 这里a, b, t
都是整型的.
寄存器 | 等效条件表达式 | 描述 |
---|---|---|
CF | (unsigned) t < (unsigned) a |
无符号溢出 |
ZF | (t == 0) |
零 |
SF | (t < 0) |
负数 |
OF | (a < 0 == b < 0) && (t < 0 != a < 0) |
有符号溢出 |
生成条件码 ---- 比较/测试
虽然一般的运算指令也会设置条件码寄存器, 但是其会改变原数值, 有两类指令能够只设置条件码而不改变任何其它寄存器, 如下所示:
指令 | 等效比较表达式 | 描述 |
---|---|---|
CMP S1, S2 | S2 - S1 | 比较 |
cmpb | ||
cmpw | ||
cmpl | ||
cmpq | ||
TEST S1, S2 | S1 & S2 | 测试 |
testb | ||
testw | ||
testl | ||
testq |
访问条件码
条件码通常不会直接读取, 常用的使用方法有三种:
- 根据条件码的某种组合将一个字节设置为0或1
- 使用条件跳转指令跳转
- 有条件的传送数据
对于第一种情况, 使用如下所示的SET
指令, SET
指令的目的操作数是低位单字节寄存器元素之一或是一个字节的内存位置, 指令会将这个字节设置成0或者1
指令 | 同义名 | 效果 | 设置条件 (than zero) |
---|---|---|---|
sete D | setz | D = ZF | 相等/零 |
setne D | setnz | D = ~ZF | 不等/非零 |
sets D | D = SF | 负数 | |
setns D | D = ~SF | 非负数 | |
setg D | setnle | D = ~(SF ^ OF) & ~ZF | 大于 (有符号>) |
setge D | setnl | D = ~(SF ^ OF) | 大于等于 (有符号>=) |
setl D | setnge | D = SF ^ OF | 小于 (有符号<) |
setle D | setng | D = (SF ^ OF) | ZF | 小于等于 (有符号<=) |
seta D | setnbe | D = ~CF & ~ZF | 超过 (无符号>) |
setae D | setnb | D = ~CF | 超过或相等 (无符号>=) |
setb D | setnae | D = CF | 低于 (无符号<) |
setbe D | setna | D = CF |ZF | 低于或等于 (无符号<=) |
跳转指令JMP
指令 | 同义名 | 跳转条件 | 设置条件 (than zero) |
---|---|---|---|
jmp Label | 1 | 直接跳转 | |
jmp *Operand | 1 | 间接跳转 | |
je Label | setz | ZF | 相等/零 |
jne Label | setnz | ~ZF | 不等/非零 |
js Label | SF | 负数 | |
jns Label | ~SF | 非负数 | |
jg Label | jnle | ~(SF ^ OF) & ~ZF | 大于 (有符号>) |
jge Label | jnl | ~(SF ^ OF) | 大于等于 (有符号>=) |
jl Label | jnge | SF ^ OF | 小于 (有符号<) |
jle Label | jng | (SF ^ OF) | ZF | 小于等于 (有符号<=) |
ja Label | jnbe | ~CF & ~ZF | 超过 (无符号>) |
jae Label | jnb | ~CF | 超过或相等 (无符号>=) |
jb Label | jnae | CF | 低于 (无符号<) |
jbe Label | jna | CF |ZF | 低于或等于 (无符号<=) |
条件传送指令CMOV
指令 | 同义名 | 传送条件 | 设置条件 (than zero) |
---|---|---|---|
cmove Src, Dst | cmovz | ZF | 相等/零 |
cmovne Src, Dst | cmovnz | ~ZF | 不等/非零 |
cmovs Src, Dst | SF | 负数 | |
cmovns Src, Dst | ~SF | 非负数 | |
cmovg Src, Dst | cmovnle | ~(SF ^ OF) & ~ZF | 大于 (有符号>) |
cmovge Src, Dst | cmovnl | ~(SF ^ OF) | 大于等于 (有符号>=) |
cmovl Src, Dst | cmovnge | SF ^ OF | 小于 (有符号<) |
cmovle Src, Dst | cmovng | (SF ^ OF) | ZF | 小于等于 (有符号<=) |
cmova Src, Dst | cmovnbe | ~CF & ~ZF | 超过 (无符号>) |
cmovae Src, Dst | cmovnb | ~CF | 超过或相等 (无符号>=) |
cmovb Src, Dst | cmovnae | CF | 低于 (无符号<) |
cmovbe Src, Dst | cmovna | CF |ZF | 低于或等于 (无符号<=) |
可以看出, 在汇编代码中, 使用指令来区别对待不同数据类型的数据. 大多数情况下, 机器代码对于有符号和无符号两种情况都使用一样的指令, 这是因为许多算术运算对无符号和补码算术都有一样的位级行为. 有些情况需要用不同的指令来处理有符号和无符号操作, 例如, 使用不同版本的右移/除法和乘法指令, 以及不同的条件码组合.
关于条件传送:
源操作数可以是寄存器或是一个内存位置, 目的操作数是一个寄存器; 源和目的的值可以是16位/32位/64位, 但不可以是单字节(单字节有自己的指令SET
), 汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度, 所以对所有的操作数长度, 都可以使用同一个的指令名字.
同条件跳转不同, 处理器无需预测测试的结果就可以执行条件传送, 处理器只是读源值(可以是从内存中, 因此应注意空指针调用), 检查条件码, 然后决定是否更新目的寄存器.
无论测试结果如何, 处理器都会对各分支求值, 然后再决定跳转, 但是应当注意, 当这些表达式可能产生错误条件或者副作用时, 就会导致非法的行为.例如:
使用条件传送也不总是会提高代码的效率. 例如, 分支表达式中需要大量的计算, 或者各分支并不是"平衡"的. 实验表明, 只有当两个(分支)表达式都很容易计算时, GCC才会使用条件传送, 即使是许多分支预测错误的开销会超过更复杂的计算.
switch语句与跳转表
对比一份示例代码:
- C语言
void switch_eg(long x, long n, long *dest) {
long val = x;
switch (n) {
case 100:
val *= 13;
break;
case 102:
val += 10;
case 103:
val += 11;
case 104:
case 106:
val *= val;
break;
default:
val = 0;
}
*dest = val;
}
- 汇编
switch_eg:
subq $100, %rsi
cmpq &6, %rsi
ja .L8
jmp *.L4(, %rsi, 8)
.L3:
leaq (%rdi, %rdi, 2), %rax
leaq (%rdi, %rax, 4), %rdi
jmp .L2
.L5:
addq $10, %rdi
.L6:
addq $11, %rdi
jmp .L2
.L7:
imulq %rdi, %rdi
jmp .L2
.L8:
movl $0, %edi
.L2:
movq %rdi, (%rdx)
ret
- 汇编等效C语言
void switch_eg_impl(long x, long n, long *dest) {
static void *jt[7] = {
&&loc_A, &&loc_def, &&loc_B, &&loc_C, &&loc_D, &&loc_def, &&loc_D
};
unsigned long index = n - 100;
long val;
if (index > 6)
goto loc_def;
goto *jt[index];
loc_A:
val = x * 13;
goto done;
loc_B:
x = x + 10;
loc_C:
val = x + 11;
goto done;
loc_D:
val = x * x;
goto done;
loc_def:
val = 0;
done:
*dest = val;
}
6. 栈内存
传递控制, 传递数据, 分配和释放内存
栈内存
栈内存向下生长
地址 | 栈 | 说明 |
---|---|---|
↑地址增大 ↓地址减小 |
... | 早期的帧 |
P的其它栈空间 | 调用函数P的帧 | |
参数n | ||
... | ||
参数7 | ||
返回地址 | ||
被保存的寄存器(寄存器压栈) | 正在执行的函数Q的帧 | |
局部变量 | ||
栈指针%rsp | 参数构造区 |
函数调用时, 有6个寄存器可以用于传递参数. 因此, 超过6个参数的函数调用便需要用到内存(栈)进行传递
-
寄存器组是唯一被所有过程(函数)共享的资源. 虽然在给定时刻只有一个过程是活动的, 但仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时, 被调用者不会覆盖调用者稍后会使用的寄存器. 为此, x86-64采用了一组统一的寄存器使用惯例, 所有的过程(程序或函数)都必须遵循.
根据惯例, 寄存器
%rbx, %rbp, %r12 ... %r15
被划分为被调用者保存寄存器. 当过程P调用过程Q时, Q必须保证这些寄存器的值在它返回时与被调用时一致. 因此一旦过程需要使用这些寄存器时, 都会进行"压栈"操作将其保存到内存, 待返回时"弹栈"将其取出. -
除上述所描述的寄存器和栈指针
%rsp
外, 其余所有寄存器都分类为调用者保存寄存器. 顾名思义, 这部分寄存器在进行函数调用时应当有调用者自行保存(压栈).
2. 转移控制
将控制从函数P转移到函数Q只需要将PC设置为Q的代码的起始位置. 不过, 当稍后从Q返回的时候, 处理器必须记录好它继续P的执行的代码位置. 在 x86-64 机器中, 这个信息是由call
指令自动记录的. 该指令会把返回地址压入栈中, 并将 PC 设置为Q的起始地址. 其中, 返回地址是"紧跟在call
指令后的那条指令的地址". 对应的, ret
指令会中栈中弹出该返回地址, 并把PC设置为该地址.
下面是call
和ret
指令的一般形式:
指令 | 描述 |
---|---|
call Label |
过程调用 |
call *Operand |
过程调用 |
ret |
返回 |
call
指令的操作数是一个地址, 因此可以是一个Label标签, 也可以是一个代表地址的操作数
3. 栈上的局部存储
并不是所有时候局部变量都保存到寄存器中的, 有时候也需要栈内存来保存局部变量, 常见的情况包括:
- 寄存器不足以存放所有的本地数据
- 对一个局部变量使用地址运算符
&
, 因此必须能够为它产生一个地址 - 某些局部变量是数组或结构, 因此必须能够通过数组或结构引用被访问到.
以下是一段函数调用的示例代码, 包括C语言源码和汇编码
C语言源码:
long swap_add(long *xp, long *yp) {
long x = *xp;
long y = *yp;
*xp = y;
*yp = x;
return x + y;
}
long caller() {
long arg1 = 534;
long arg2 = 1057;
long sum = swap_add(&arg1, &arg2);
long diff = arg1 - arg2;
return sum * diff;
}
汇编码:
swap_add:
movq (%rdi), %rdx
movq (%rsi), %rax
movq %rax, (%rdi)
movq %rdx, (%rsi)
addq %rdx, %rax
ret
caller:
subq $16, %rsp 'Allocate 16 byte for stack frame
movq $534, (%rsp) 'Store 534 in arg1
movq &1057, 8(%rsp) 'Store 1057 in arg2
leaq 8(%rsp), %rsi 'Compute $arg2 as second argument
movq %rsp, %rdi 'Compute $arg1 as first argument
call swap_add 'Call swap_add(&arg1, $arg2)
movq (%rsp), %rdx 'Get arg1
subq 8(%rsp), %rdx 'Compute diff = arg1 - arg2
imulq %rdx, %rax 'Compute sum * diff
addq $16, %rsp 'Deallocate stack frame
ret 'Return
4. 分配和释放内存
数据对齐
7. 数组和复合结构
无论是数组还是结构, 其在内存中都是一段连续的空间. 因此, 在汇编码中, 反应数组和复合结构的代码其实是一段地址(指针运算)
数组
以下是一段循环计算矩阵乘积的C语言代码及其汇编码. 注意, 汇编码使用 -O1
选项优化(可是从中看出编译器优化技巧)
- C语言
#define N 16
typedef int fix_matrix[N][N];
int fix_prod_ele (fix_matrix A, fix_matrix B, long i, long k) {
long j;
int result = 0;
for (j=0; j<N; j++)
result += A[i][j] * B[j][k];
return result;
}
- 汇编码
fix_prod_ele:
salq $6, %rdx
addq %rdx, %rdi
salq $2, %rcx
leaq (%rsi,%rcx), %rdx
leaq 1024(%rsi,%rcx), %rsi
movl $0, %eax
.L2:
movl (%rdi), %ecx
imull (%rdx), %ecx
addl %ecx, %eax
addq $4, %rdi
addq $64, %rdx
cmpq %rsi, %rdx
jne .L2
rep ret
结构体和联合体
8. 杂项
1. C语言中的指针
2. 内存越界引用和缓冲区溢出
3. 对抗缓冲区溢出攻击
栈随机化
- 对坑"栈随机化"的一种方式 ---- "空操作雪橇"
一堆nop , 用来加大在内存中占用的地址长度, 增大概率(几何概率模型) |
exploit code |
---|---|
只要PC能指向这里, 就可以执行到exploit code |
栈破坏检测
由编译器实现
限制可执行代码区域
由CPU实现