汇编语言

822 阅读4分钟

汇编

程序编码

gcc 输出汇编代码

// 输出一个a.s汇编文件
gcc -Og -S mstore.c

-Og 以较原始C语言组织优化代码,一般调试检查时添加这个选项,高的优化会破坏原来的代码结构
-S 生成汇编代码

2.可以使用objdump和gdb等工具调试查看二进制的执行程序的汇编代码

objdump -d mstore.o

c语言和汇编联合使用的方法(动手试一下):
1.编写完整的函数,放在独立的汇编代码文件中,利用汇编器和链接器合并起来。
2.利用gcc的内联编程特性,利用asm的伪代码在c语言中插入简短的汇编代码。

数据格式

汇编语言后缀的含义

image.png

寄存器

image.png

8086时16位系统有8个寄存器%ax到%sp, IA32架构从16位寄存器拓展到32位寄存器,标志为%eax到%esp,x86-64位系统有%rax到%rsp。除此以外还额外添加了%r8到%r15。

比较特殊的寄存器是%rsp,用来指明运行栈的结束位置。

操作数的格式

image.png

操作数表示执行操作中要使用的源数据值和放置结果的目标地址。分为三类:立即值($0x108)、寄存器(%rax)和内存引用(M[address]的形式)。

mov操作

image.png

S(source)操作是一个立即数,要保存在寄存器或者内存中。D(Destination)操作表示一个寄存器或内存。后缀b、w、l、q是字节大小的不同,数据类型对应第一张表。

movl指令会把高四位字节置为0。因为x86会把任何寄存器生成32位值的高四位重置为0。

movabsq S值必须是64位数值,D必须是寄存器。

mov操作的五种组合

image.png

x86规定mov操作不同内存到内存,所以要把一个值从内存复制到另一个内存,需要分两步,先加载到一个寄存器,再写入到目标内存。

movz操作

image.png

movz(zero-extending)操作会把寄存器和内存为S, 寄存器位D。同时把目的的剩余字节填充为0。

每个movz操作后面有两个字符,第一个字符代表源(S)的数据大小,第二个代表目标(D)的数据大小。

不需要movzlq命令,32位值转移到64位时用到了前面movl命令。

movs操作

image.png

movs(sign-extending)和movz操作类似,不同是会把目标地址的剩余字节填充为符号位。

cltq命令只作用于%eax和%rax寄存器,%eax的32位数值拓展到%rax64位数值时也会进行符号位的拓展。

访问信息

指针的示例

image.png

通常变量的值直接保存在寄存器中,如这里x就是%rax。
在C语言中指针就是地址,这里*xp就是(%rdi)。

压栈和出栈的指令

image.png

等效入栈pushq %rbp的命令

image.png

等效出栈popq %rax的命令

image.png

直接使用pushq和popq只需要1字节,使用等效的汇编指令要8字节。

压栈出栈过程

image.png

执行了pushq %rax 和popq %rdx,出栈后0x100还是保持着0x123的值,直到另外的入栈操作覆盖。

算数和逻辑操作

整数和逻辑操作指令

image.png 指令分为四类:加载有效地址、一元操作、二元操作和移位。
除了leaq,都有b、w、l、q不同数据大小的变种。

加载有效地址

leaq(load effective address)是movq的变形。leaq有两个功能,一是从内存读数据到寄存器(不是内存引用,而是往目的寄存器写入有效地址)。二是描述普通的算术操作。

leaq执行加法和有限形式乘法的例子:

long scale(long x, long y, long z) {
    long t = x + 4 * y + 12 * z;
    return t;
}

编译成汇编代码:

scale:
    leaq    (%rdi,%rsi,4), %rax               t = x + 4*y
    leaq    (%rdx,%rdx,2), %rdx               z = z + 2*z
    leaq    (%rax,%rdx,4), %rax               t = t + 3*z
    ret

一元操作和二元操作

一元操作,只有一个操作数,即使源又是目的,可以是内存和寄存器。
二元操作,两个操作数。第一个操作数是立即数,内存或寄存器。第二个操作数即是源又是目的,可以是内存或寄存器。

例子:

地址
0x1000xFF
0x1080xAB
0x1100x13
0x1180x11
寄存器
%rax0x100
%rcx0x1
%rdx0x3

根据上述寄存器和内存的值,填写下面指令的目的地址和值

指令目的
addq %rcx, (%rax)0x1000xFF + 0x1 = 0x100
subq %rdx, 8(%rax)0x100 + 0x8 = 0x1080xAB - 0x3 = 0xA8
imuq $16, (%rax,%rdx,8)(0x3 << 3) + 0x100 = 0x1180x11 * 16 = 0x11 << 4 = 0x110
incq 16(%rax)0x100 + 0x10 = 0x1100x13 + 1 = 0x14
decq %rcx%rcx0x1 - 1 = 0x0
subq %rdx,%rax%rax0x100 - 0x3 = 0x100 +0x1fd = 0xfd

移位操作

左移操作有SALSHL,两者相同,左移后会填充0;
右移操作有逻辑右移SHR,算术右移SAR,算式右移填充符号位。
移位操作第一项是移位量,第二项是移位的数。移位量可以是立即数,也可以读取单字节寄存器%cl。目的操作数是寄存器或内存。
%cl寄存器最大值是255,所以%cl寄存器移位最大值是255。

特殊算数操作

image.png

imulq有两种形式,一种是单操作数,一种是双操作数。
双操作数时将两个64位操作数相乘产生另一个64位操作数(进行截取)。
单操作数时,imulq时补码乘法,mulq时无符号乘法。默认一个数存在寄存器%rax中,另一个数通过源操作数给出。相乘后结果的高64位存在%rdx,低64位存在%rax中

例子:

#include <inttypes.h>

typedef unsigned __int128 uint128_t;

void store_uprod(uint128_t *dest, uint64_t, uint64_t y) {
    *dest = x * (uint128_t) y;
}

编译成汇编后:

'dest in %rdi, x in %rsi, y in %rdx'
store_uprod:
    movq    %rsi, %rax             复制x到被乘数
    mulq    %rdx                   乘以y
    movq    %rax, (%rdi)           把低八位保存在dest
    movq    %rdx, 8(rdi)           把高八位保存在dest+8
    ret

idivl除法也是单操作数,128位的被除数的高64位存在%rdx,低64位存在%rax。除数作为源操作数给出。
结果的商存在%rax,余数存在%rdx。结果是64位时,无符号运算%rdx保存全0,有符号运算%rdx保存符号位。
读取%rax的符号位到%rdx的所有位,这个操作可以用cqto指令,这个指令不需要操作数。

计算两个64位有符号数的商和余数:

void remdiv(long x, long y, long *qp, long *rp) {
    long q= x / y;
    long r = x % y;
    *qp = q;
    *rp = r;
}

编译成汇编代码后:

'x in %rdi, y in %rsi, qp in %rdx, rp in %rcx'
remdiv:
    movq    %rdx, %r8           复制qp到另一个寄存器,因为被除数要用到%rdx
    movq    %rdi, %rax          把x的低8位移到被除数
    cqto                        把符号位放到高8位
    idivq   %rsi                除以y
    movq    %rax, (%r8)         保存商到qp
    movq    %rdx, (%rcx)        保存余数到rp
    ret

控制(跳转、条件、循环)

汇编的条件判断实现有两种指令,cmptest

image.png

cmpsub操作差不多,但是只操作条件码,不保存具体的值。
testand操作差不多,差别也是只操作条件码。

条件码

CF: 进位标志。产生了进位,可以用来检查无符号操作的溢出
ZF: 零标志。最近的操作得出的结果是0
SF: 符号标志。最近的操作得出的结果是负数
OF: 溢出标志。最近的操作导致一个补码溢出

leaq不会改变条件码,因为是进行地址计算的。xor不使用进位和溢出标志。移位操作,进位标志会设置为最后一个移除的位。incdec指令会设置溢出和零标志,不会使用进位标志。

访问条件码

条件码不会直接读取使用,一般的用途有三种:

  1. 根据条件码的组合设置单字节0或1
  2. 根据条件跳转到程序的其他部分
  3. 可以有条件地传送数据

image.png set指令可以根据条件码组合设置单字节的值。

'a in %rdi, b in %rsi'
comp:
    cmpq      %rsi, %rdi        a-b
    setl      %al               是否小于,设置%eax值01
    movzbl    %al, %eax         把%eax的高位都置为0
    ret

使用cmp比较时,无符号运算使用进位标志和零标志。有符号运算使用溢出标志和符号标志。

跳转指令

image.png

跳转指令使程序跳转到其他的位置。jmp有直接跳转和间接跳转两种。间接跳转从寄存器和内存读取跳转目标。除了jmp还有一些其他的条件跳转指令,条件跳转指令只能是直接跳转。

直接跳转:

    movq    $0,%rax
    jmp     .L1                  直接跳转到.L1标签处
    movq    (%rax), %rdx
.L1
    popq %rdx

间接跳转:

    jmp    *%rax                 以寄存器的值为目标
    jmp    (%rax)                以寄存器的值作为读取地址,从地址读取跳转目标

跳转地址计算:

4004d3: eb 03           jmp 4004d8
4004d5  48 d1 f8        sar %rax

反汇编后,第一个指令目标编码为0x03, 加上下一条指令地址0x4004d5, 所以跳转地址是0x4004d8。

rep指令通常会和ret指令组合能避免ret指令成为条件跳转指令目标。

条件控制实现条件分支

c语言if-else的通用版本:

if (test-expr)
    then-statement
else
    else-statement

翻译成接近汇编语言的c语言:

    t = test-expr
    if (!t)
        goto false;
    then-statement
    goto done;
false:
    else-statement
done:

例子:

image.png

用条件传送实现条件分支

条件传送指令:
image.png

使用条件传送指令将c语言的if-else代码翻译成:

v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;

条件传送方法通常要比条件控制方法性能要好。

它会计算好两种判断的结果,最后再根据条件选择满足条件的一个。

因为保证了指令的流水执行,减少了机器分支预测逻辑失败的开销。

但是假如then-exorelse-expr都需要大量的计算,那编译器要考虑浪费的计算和分支预测错误造成的消耗那个更大,通常gcc更多使用条件控制转移。

例子:

image.png

循环

do-while

loop:
    body-statement
    t = test-expr;
    if (t)
        goto loop;

实现n!的例子:

image.png

while

两种翻译方法: 跳转到中间(jump to middle)和guarded-do。
第一种gcc使用-Og选项编译,第二种使用-O1。

  1. 跳转到中间
    goto test;
loop:
    body-statement;
test:
    t = test-expr;
    if (t)
        goto loop;

实现n!的例子:

image.png

  1. guarded_do 使初始条件不成立就跳过循环,把代码转换成do-while循环
t = test-expr;
if (!t)
    goto done;
loop:
    body-statement
    t = test-exprl
    if (t)
        goto loop;
done;

实现n!的例子:
image.png

for

for循环格式

for (init-expr; test-expr; update-expr)
    body-statement

会转换成while格式

init-expr:
while (test-expr) {
    body-statement
    update-expr;
}

就是在while的跳转到中间或guarded_do方法前加上init-expr语句。

switch

多重条件分支通过跳转表(jump table)实现,跳转表是一个数组,表项 i 是一个代码段的地址,这个代码段实现开关(case)索引值等于 i 时程序应该采取的动作。当开关(case)数量比较多(比如4个以上),并且值的范围跨度比较小,就会使用跳转表。

switch 语句示例以及翻译到扩展的 C 语言。 该翻译给出了跳转表 jt 的结构, 以及如何访问它。作为对 C 语言的扩展,GCC 支持这样的表。这里&&表示指向代码的指针(区别于指向数据值的指针&)。 image.png 通过n - 100把判断的取值范围缩小在 0 ~ 6之间

image.png
jmp 指令的操作数有前缀 *,表明这是一个间接跳转,操作数指定一个内存位置,索引由寄存S%rsi 给出

image.png
汇编代码的跳转表地址编码关系如上图, 这个关系记录在.rodata(read-only data)的只读文件中。在文件中有 如.L3、.L8这样的7个跳转编码地址,每个地址是8字节。

过程(函数)

过程是用一组指定的参数和一个可选的返回结果,实现某一个功能。过程有多种形式:如函数、方法、子例程、处理函数。

过程有三个特性:
传递控制:P 调用 Q 时,程序计算器能记录 Q 的起始位置,返回P时记录调用 Q 的后面那条指令。
传递数据:P 能传递给 Q 多个参数,Q 能给 P 返回一个值。
分配和释放内存: 调用 Q 时能分配存储空间,返回 P 时能释放分配给 Q 的存储空间。

运行时的栈

x86-64过程需要的存储空间超过寄存器的储存大小时,会在栈上分配空间,这部分叫做栈帧(stack fram)

栈帧用于传递参数、储存返回信息、保存寄存器、保存局部变量。结构如下图:
image.png

大多数过程的栈帧是定长的,在调用时初始化分配好了,有一些过程需要变长的帧(使用了动态分配malloc等)。

过程 P 调用 Q 时通过寄存器最多能传递6个整数值(整数和指针),更多的参数需要在自己的栈帧中存储好这些参数。

转移控制(调用)

call指令会调用过程,把返回地址(P调用Q的下一条指令地址)压入栈中,并让PC设置为过程的起始地址。

ret指令将返回地址从栈中弹出,并让pc设置为弹出的返回地址。

image.png

call指令能直接调用,也能间接调用。间接调用是 * 后面加操作数指示符。

例子:

image.png

数据传送(传参)

过程调用中传递6个整数参数的寄存器:
image.png

超过6个参数的,需要栈帧存储,所有数据大小都向8的倍数对齐

例子:

image.png

最后两个参数a4和 *a4p在栈顶+8和+16的位置。a4变量是char类型只有1字节,在栈帧结构如下:

image.png

栈上的局部存储

栈帧使用局部存储的几种情况:

  1. 寄存器的数量不够存储
  2. 有局部变量使用的地址运算符'&'
  3. 某些特殊的局部变量,数组或结构

例子:

image.png

栈帧的结构图:

image.png

寄存器的局部存储

我们要确保被调用者(Q)不会覆盖调用者(P)稍后会使用的寄存器。如P的参数是x(%rdi),调用者的参数也是x,我们需要先用一个寄存器保存x的值,以免x的值被调用者修改。

寄存器%rbx、%rbp和%r12 ~ %r15都是被调用者保存寄存器。被调用函数中,要么不修改这些寄存器的值,要么在栈上保存原始值,修改了寄存器的值后,再从栈上读取恢复为原始值(栈帧结构上有标号seved registers)。

除了被调用者保存寄存器和%rsp, 其余分类为调用者保存寄存器。被调用者过程可以随意使用。

例子:

image.png

递归过程

每个被调用过程都有自己的私有空间和状态信息。

实现递归的例子:

image.png

每次调用的参数n - 1,保存在寄存器%rbx, 通过栈和寄存器的结合实现了每次调用函数的私有状态。同时每个返回结果都保存在寄存器%rax。

数组分配和访问

数组是一个连续区域,可以通过地址跳转访问。

image.png

数组访问如下:

image.png

嵌套数组的访问公式如下:

image.png
L为数据类型大小,C为列的元素个数

异常的数据结构

结构

结构声明:

struct rec {
    int i;
    int j;
    int a[2];
    int *p;
}

产生的偏移如图:

image.png

假如定义一个rec* r变量,获取r->j的值

r 在%rdi寄存器
movl 4(%rdi), %eax

联合

联合(union)的声明语法和结构(struct)一样。我们预先知道数据结构中有两个不同字段是互斥的,就可以将这两个字段声明为一个联合来减少内存的分配。

如定义一颗二叉树,只有叶子节点会存一个double数组值,而内节点都没有数据。那么声明如下:

union node_u {
    struct {
        union node_u *left;
        union node_u *right;
    } internal;
    double data[3];
}

这里internal为16字节,data为24字节。所以每个节点只需要24字节,要么存储internal,要么存储data。

数据对齐

数据对齐能提高内存系统的性能。

不同类型的对象访问的地址为K的倍数,K的数值如下:

image.png

如定义了一个结构如下:

struct S1 {
    int i;
    char c;
    int j;  
}

那么产生的偏移结构如下:

image.png

S1的大小为4、1、4字节,为了使访问j满足4字节对齐,在c和j之间插入了3字节的间隙(不使用的内存)。

在机器级程序中将控制和数据结合起来

使用gdb

gdb命令如下:

image.png

内存越界和缓冲区溢出

向缓存区写入溢出的内容会造成缓冲区溢出(buffer overflow),一般如数组的越界写入。

image.png

假如使用指针修改buf数组的值时,超过了数组范围,首先会修改到未使用的栈空间,然后修改到返回地址,最抠修改到调用者栈帧的内容。

有些攻击者可以利用缓冲区溢出的漏洞,修改掉返回地址的值,跳转到设计好的地址,执行攻击代码。

对抗缓冲区溢出攻击

有三种方法: 栈随机化、栈破坏检测、限制可执行的代码区域

栈随机化

攻击者攻击时需要知道攻击代码在栈中的位置,最早时候栈开始位置是固定的,攻击者确定一种常见的web服务器,就能对这类web服务器都实施攻击。

栈随机化使程序开始的栈空间地址每次都不一样,Linux系统中栈随机化成为标准行为,是地址空间布局随机化技术(Address-Space Layout Randomization)的一种。

攻击者后面使用蛮力来破解栈随机化,使用nop指令,这个指令使程序计数器加一。攻击者在这段序列(空操作雪橇——nop sled)中猜中某个地址,就能计算出攻击代码地址。

32位系统的随机化范围大小约 2232^{23}, 64位系统范围大小约 2322^{32}。建立一段256 ( 282^{8} )字节的nop sled,枚举 2152^{15}(32 768),就可以破解 2232^{23} 方的随机化(32位系统)

栈破坏检测

在任意缓冲区于栈状态之间存储一段特殊的金丝雀值(canary)。最后返回结果时,检查金丝雀值被修改过。 1669878992002.png

限制可执行代码区域

内存引入了 NX(Not-exceute) 位,能够在栈上标记代码是否能够被执行。如知道用户输入是字符串类型,不是代码,就可以限制保存输入的区域不可执行。

浮点代码

这部分内容基于 AVX2 指令,gcc通过参数-mavx2产生AVX2代码。

浮点数使用的寄存器
image.png

浮点传送与转换操作

浮点数传送的命令
image.png

其中 v 是 AVX 指令的前缀,ss 表示 scalar single-precision,sd 表示 scalar double-precision,a 表示 aligned,ps 表示 packed single-precision,pd 表示 packed double-precision。也就是说,s 结尾的用于 float,d 结尾的用于 double。

浮点数转换为整数命令
image.png

转换为整数时会进行截断,值会向 0 舍入。

其中 cvttss2si 的意思是: cvt -> convert, t -> (with) truncation, ss -> scalar single-precision, 2 -> to, si -> signed integer。

ss 用于 float,sd 用于 double;结尾为 q 的用来转成 64 位整数。

整数转换为浮点数指令

image.png

这些指令有三个操作数,第二个操作数会影响高位保存结果。因为我们通常使用 xmm 寄存器(128位),所以第二个第三个操作数保持一致。

浮点的精度转换
单精度转换为双精度:
vcvtss2sd %xmm, %xmm, %xmm 等同于:

vunpcklps    %xmm, %xmm, %xmm
vcvtps2pd    %xmm, %xmm

双精度转换为单精度:
vcvtsd2ss %xmm, %xmm, %xmm
等同于:

vmovddup    %xmm, %xmm
vcvtpd2psx     %xmm, %xmm

浮点数操作指令

运算指令

1670125697930.png

第一个操作数 S1 是 XMM 寄存器或者内存地址,第二个操作数和目的操作数是 XMM 寄存器。

浮点数的常量

AVX 浮点操作不能使用立即数,要分配常量空间进行存储。

'temp in %xmm0'

cel2fahar:
  vmulsd    .LC2(%rip), %xmm0, %xmm0      1.8 * temp
.LC2:
  .long     3435973837
  .long     1073532108

低四字节为 3435973937 (0xcccccccd), 高四字节为 107352108 (0x3ffccccc)。
浮点公式 v = (1)s(-1)^{s} * M * 2ebias2 ^ {e - bias}
0x3ff 看出来是规范化浮点数,所以有:
s = 0 (最高位是0)
e = 0x3ff = 1023
bias = 21012^{10} - 1 = 1023
M = 1 + f = 1 + 0.8 ( 0xcccccccccccccd ) = 1.8
所以v = 1.8

浮点数的位级操作

位级指令
image.png

条件码结果
假如有一个参数(S1 或 S2)是NaN,比较就没有意义。这时候进位(CF)和零位(ZF)都是1。特别地会使用奇偶位(PF)来快速识别这种情况。

image.png

参考资料

《Computer Systems A Programmer’s Perspective》 —— Randal E. Bryant、David R. O’Hallaron