汇编入门

汇编入门

从 C 语言到机器码

先从一个非常简单的程序来看编译过程中发生了那些步骤。

#include <stdio.h>

int main() {
printf("hello world\n");
return 0;
}

我们在 Unix 系统上终端上使用 GCC 进行编译:

> gcc -o hello hello.c

这里 GCC 编译器把 hello.c 源文件翻译成可执行文件 hello,这个过程一共可以分为 4 步骤:

  • 预处理器(Pre-processor):把头文件插入到程序文本中,得到 hello.i 文件
  • 编译器(Compiler):编译成汇编语言,把 hello.i 转换为 hello.s
  • 汇编器(Assember):将汇编语言翻译成机器语言,得到 hello.o 文件
  • 连接器(Linker):把 printf 函数从 print.o 以某种方式合到 hello.o程序中,得到 hello 可执行文件

当我们用高级语言编程的时候(比如 C 语言,Java 语言),编译器为我们屏蔽了很多机器级别的实现。当使用汇编写程序的时候,程序员需要指定程序来执行计算的低级指令。

高级语言提供的更高抽象级别进行工作会更加高效和可靠。编译器提供的类型检查有助于检测许多程序错误,并确保我们以一致的方式引用和操作数据。

使用现代的优化编译器,生成的代码通常至少与熟练的汇编语言程序员手动编写的代码效率相同。最重要的是,可以在许多不同的机器上编译和执行以高级语言编写的程序,而汇编代码是高度机器特定的。

既然编译器这么 智能,那我们还要学习汇编呢?

  • 能够阅读和理解汇编代码是一项很重要的技能
  • 理解编译过程的优化能,分析代码中的隐含低效的代码,提高代码效率
  • 挖掘代码中隐藏漏洞,增加安全性

汇编入门

我们先写一个简单的 C 语言代码文件 mstore.c 如下:

long mult2(long, long);

void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
return *dest = t;
}

在命令行中使用 -S 选项可以看到 C 语言编译产生的汇编代码:

> gcc -Og -S mestore.c

打开 mestore.c 文件,除去一些不重要的信息,得到如下汇编代码:

multstore:                 
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret

每一行都对对应一条机器指令,比如 pushq 指令是把寄存器中 %rbx 压入程序栈中。

如果我们使用 -c 命令行选项,GCC 会编译并汇编代码生成 mestore.o 文件

> gcc -Og -c mstore.c

mestore.o 文件中有一段字节序列为:

53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

我们通过反汇编 mestore.o 文件

> objdump -d mestore.o

结果如下,发现反汇编和前面手动编译的代码基本相似。

0000000000000000 <multstore>:
0: f3 0f 1e fa endbr64
4: 53 push %rbx
5: 48 89 d3 mov %rdx,%rbx
8: e8 00 00 00 00 callq d <multstore+0xd>
d: 48 89 03 mov %rax,(%rbx)
10: 5b pop %rbx
11: c3 retq

数据格式

字(word)表示 16 位数据类型,所以是双字(double words)表示 32 位数据类型,四字(quad words)表示 64 位。

C 声明 Intel 数据类型 汇编代码后缀 大小(字节)
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char * 四字 q 8
float 单精度 s 4
double 双精度 l 8

大多数 GCC 生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如数据传送指令有四个变种:movb (传送字节)、moww(传送字)、movl (传送双字) 和 movq (传送四字)。

注意两点:

  • 后缀用 l 表示双字,是因为 32 位数被看成长字(long word)
  • 4 字节整数和 8 字节双精度浮点数都用 l 后缀,并不会产生歧义,因为浮点数使用的不同指令和寄存器

访问信息

x86-64 的 CPU 包含 16 个储存 64 位通用寄存器,其中%rax%rsp 比较常用,而 %r8%r15 是从 32 位转了 64 位新加的。为了兼容 32 位(%eax%esp)和 16 位(%ax%sp)机保持兼容,对应低位表示。如:32 位只能访问低 4 位字节。

  • %rax 一般用作累加器(Accumulator)
  • %rbx 一般用作基址寄存器( Base )
  • %rxc 一般用来计数( Count )
  • %rdx 一般用来存放数据( Data )
  • %rsi一般用作源变址( Source Index )
  • %rdi 一般用作目标变址( DestinatinIndex )
  • %rbp 一般用作基址指针( Base Pointer )
  • %rsp 一般用作堆栈指针( Stack Pointer )

除此之外还有:

  • %rip 是指令指针,也称为 PC
  • CF、ZF、SF 和 OF 条件码

下面我们通过 movq 这个指令来了解操作数的三种基本类型:立即数(Imm)、寄存器值(Reg)和内存值(Mem)。

Source Dest movq Src, Dest C Analog
Imm Reg movq $0x4, %rax temp = 0x4
Imm Mem movq $-147, (%rax) *p = -147
Reg Reg movq %rax, %rdx temp2 = temp1
Reg Mem movq %rax, (%rdx) *p = temp
Mem Reg movq (%rax), %rdx temp = *p

注意:是没有 movq Mem, Mem 的,不能用一条指令完成内存中的数据交换。

下面看看几种寻址的方式:

表示 计算方式
(R) Mem[Reg[R]]
D(R) Mem[Reg[R] + D]
D(Rb, Ri, S) Mem[Reg[Rb]+S*Reg[Ri]+D]

其中:

  • D - 常数偏移量
  • Rb - 基寄存器
  • Ri - 索引寄存器,不能是 %rsp
  • S - 系数

我们通过具体的例子来巩固一下,这里假设 %rdx 中的存着 0xf000%rcx 中存着 0x0100,那么

  • 0x8(%rdx) = 0xf000 + 0x8 = 0xf008
  • (%rdx, %rcx) = 0xf000 + 0x100 = 0xf100
  • (%rdx, %rcx, 4) = 0xf000 + 4*0x100 = 0xf400
  • 0x80(, %rdx, 2) = 2*0xf000 + 0x80 = 0x1e080

操作指令

加载有效指令 leaq Src, Dst(load effective address),其中 Src 是地址的表达式,然后把计算的值存入 Dst 指定的寄存器。指的是从内存读取数据到寄存器中,但实际上没有引用内存。类似于 C 语言的 Dst = &Src

我们通过一个例子来看:

long m12(long x) {
return x * 12;
}

对应的汇编:

leaq (%rdi, %rdi, 2), %rax      # t <- x+x*2
salq $2, %rax # return t << 2

直接对 %rdi 计算,然后赋值给 %rax

两个操作数指令:

  • addq Src, Dest -> Dest = Dest + Src
  • subq Src, Dest -> Dest = Dest - Src
  • imulq Src, Dest -> Dest = Dest * Src
  • salq Src, Dest -> Dest = Dest << Src
  • sarq Src, Dest -> Dest = Dest >> Src (算术)
  • shrq Src, Dest -> Dest = Dest >> Src (逻辑)
  • xorq Src, Dest -> Dest = Dest ^ Src
  • andq Src, Dest -> Dest = Dest & Src
  • orq Src, Dest -> Dest = Dest | Src

一个操作数指令:

  • incq Dest -> Dest = Dest + 1
  • decq Dest -> Dest = Dest - 1
  • negq Dest -> Dest = -Dest
  • notq Dest -> Dest = ~Dest

控制

到目前为止,我们只考虑了顺序执行,还有一些比如:条件语句、循环和分支语句,是根据测试结果来决定执行顺序。

条件码

我们用一个加法运算来说明,t = a + b ,对应的汇编为 addq Src, Dst 具体判断如下:

条件码 含义 判断 说明
CF 进位标志(Carry Flag) (unsigned) t < (unsigned) a 无符号溢出
ZF 零标志(Zero Flag) t == 0
SF 符号标志(Sign Flag) t < 0 负数
OF 溢出标志(Overflow Flag) (a > 0 && b > 0 && t < 0) || (a < 0 && b < 0 && t > 0) 有符号溢出

访问条件码

条件码通过一定的组合可以得到一些判断条件:

通过一个例子来看:

int gt(long x, long y) {
return x > y;
}

对应汇编代码,%rdi 存储 x,%rsi 储存 y,%rax 表示返回值

comq %rsi, %rdi             # 比较 x 和 y
setg %al # 当 > 设置值
movzbl %al, %eax # 将高位设置为 0
ret

跳转指令

正常情况下指令是一条一条顺序执行,跳转指令(jump)会使程序切换到一个全新的位置。

通过一个例子来看:

long absdiff(long x, long y) {
long result;
if (x > y)
result = x - y;
else
result = y - x;
return result;
}

没有优化,对应汇编代码,%rdi 存储 x,%rsi 储存 y,%rax 表示返回值

absdiff:
cmpq %rsi, %rdi
jle .L4
movq %rdi, %rax
subq %rsi, %rax
ret
.L4: # x <= y
movq %rsi, %rax
subq %rdi, %rax
ret

我们知道 CPU 比较喜欢顺序工作,执行一系列操作会有缓存,所以效率比较高,如果遇到分支,会打破这种顺序工作,带来很大的性能影响。因此人们通常使用分支预测来解决,如果只是对这种简单的分支可以直接把两种结果直接算出来。简化为我们熟知的二元运算 test ? then_expr : else_expr

优化后,对应的汇编代码

absdiff:
movq %rdi, %rax # x
subq %rsi, %rax # result = x-y
movq %rsi, %rdx
subq %rdi, %rdx # eval = y-x
cmpq %rsi, %rdi # x:y
cmovle %rdx, %rax # if <=, result = eval
ret

需要注意的是,有些场景不适合:

  • 如果两个分支比较大的计算量
  • 如果两个计算会相互影响

循环

看看 do-while 语句

long pcount_do(unsigned long x)
{
long result = 0;
do {
result += x & 0x1;
x >>= 1;
} while (x);
return result;
}

和对应的汇编

    movl    $0, %eax    # result = 0
.L2: # loop:
movq %rdi, %rdx
andl $1, %edx # t = x & 0x1
addq %rdx, %rax # result += t
shrq %rdi # x >>= 1
jne .L2 # if (x) goto loop
rep; ret

过程调用

过程调用是一个是软件中一个很重要的抽象,提供了一种封装代码方式,在不同编程语言中有不同的形式,如:函数(function)、方法(method)、子例程(subroutine)和处理函数(handler)等。但这些都必须满足如下:

  • 传递控制:包括如何开始执行过程代码,以及如何返回到开始的地方
  • 传递数据:包括过程需要的参数以及过程的返回值
  • 内存管理:如何在过程执行的时候分配内存,以及在返回之后释放内存

栈结构

当 x86-64 过程调用中所需要的存储空间超过了寄存器能够存放的大小时,就会在栈上分配空间,这个空间叫作栈帧(stack fram)。栈可以用来传递参数、存储返回信息、保存寄存器和局部变量等。

重要指令:

  • call Lable 过程调用,直接调用
    • push 返回地址到栈中
    • 跳到 label
  • ret 从过程调用中返回
    • pop 地址从栈中
    • 跳到对应地址

接下来我看一个过程调用的例子:

void multstore (long x, long, y, long *dest) {
long t = mult2(x, y);
*dest = t;
}

long mult2(long a, long b) {
long s = a * b;
return s;
}

对应的汇编

0000000000400540 <multstore>:
# x 在 %rdi 中,y 在 %rsi 中,dest 在 %rdx 中
400540: push %rbx # 通过压栈保存 %rbx
400541: mov %rdx, %rbx # 保存 dest
400544: callq 400550 <mult2> # 调用 mult2(x, y)
# t 在 %rax 中
400549: mov %rax, (%rbx) # 结果保存到 dest 中, *dest = t
40054c: pop %rbx # 通过出栈恢复原来的 %rbx
40054d: retq # 返回

0000000000400550 <mult2>:
# a 在 %rdi 中,b 在 %rsi 中
400550: mov %rdi, %rax # 得到 a 的值
400553: imul %rsi, %rax # a * b
# s 在 %rax 中
400557: retq # 返回 callq 的下一行
  • 在 ret 之前会释放栈,比如前面通过subq $32,%rsp 分配了空间,后面就会通过 addq $32, %rsp 释放
  • 如果参数超过了 6 个,寄存器放不下就会放到栈帧中

总结

本文简单介绍 x86-64 汇编相关内容,主要介绍了一下汇编中的常用指令和使用,除了一些逆向工程师和一些底层专业人员需要比较深入了解汇编,大多数开发,并不需要写汇编语言,但是希望自己能够阅读和理解一些简单汇编代码,主要为了 Boom Lab 做准备。

参考

Comments