关于 DreamCat

主题名称:DreamCat | 版本:3.0.240224

主题开发:HanFengA7 | CornWorld

Designed by HanFengA7 Power by Typecho

Copyright © 2015-2024 by LychApe All rights reserved!

menu
refresh

x86-64 汇编基础 ---- 记读 CS APP

作者: ciaoℒy

时间:

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) 操作数指示符

几乎每一条指令都有对应的操作数, 操作数的值有几种形式: 立即数/ 寄存器/ 内存, 其中, 立即数和寄存器可以直接参与运算, 但存放于内存中的数参与运算时, 需要访存操作(此时立即数或寄存器中的值是内存地址).

最完整的一种地址形式是 Imm(r_a, r_i, s) , 它的运算后有效地址为 Imm + R[r_a] + R[r_i] \cdot s, 例如, 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. 生成1字节和2字节数字的指令会保持剩下的字节不变;
  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

访问条件码

条件码通常不会直接读取, 常用的使用方法有三种:

  1. 根据条件码的某种组合将一个字节设置为0或1
  2. 使用条件跳转指令跳转
  3. 有条件的传送数据

对于第一种情况, 使用如下所示的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 参数构造区

img

函数调用时, 有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设置为该地址.

下面是callret指令的一般形式:

指令 描述
call Label 过程调用
call *Operand 过程调用
ret 返回
  • call指令的操作数是一个地址, 因此可以是一个Label标签, 也可以是一个代表地址的操作数

3. 栈上的局部存储

并不是所有时候局部变量都保存到寄存器中的, 有时候也需要栈内存来保存局部变量, 常见的情况包括:

  1. 寄存器不足以存放所有的本地数据
  2. 对一个局部变量使用地址运算符 & , 因此必须能够为它产生一个地址
  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实现

支持变长栈帧

9. 浮点类型


#本文链接:https://blog.chaol.top/archives/18.html
#本文采用 CC BY-NC-SA 4.0 协议进行许可
#如无特别声明,该文章均为 ciaoℒy 原创,转载请遵循 署名-非商业性使用 4.0 国际(CC BY-NC 4.0)协议,即转载请注明文章来源。
#最后编辑时间为: 2021 年 09 月 27 日

create 添加新评论


account_circle
email
language
textsms



加我的QQ
加我的微博
加我的支付宝
加我的微信