x86-64 汇编介绍

程序就是状态机。——jyy, on 2022 NJU OS Lecture 01

这句话道出了程序的本质. 实际上, 如今的计算机本质上就是通用图灵机, 而我们研究汇编语言就是研究程序在机器层面上的运行.

程序的进行依赖于rip寄存器, 它储存的值是当前运行的指令地址. 程序的进行实际上就是一条指令一条指令的移动(你可以在gdb里随时监控rip). 这和图灵机读写控制的方式本质上是一致的!

书中出示的汇编指令是AT&T格式, 为gccgdb编译选项默认. 如果不熟悉某一种风格的话, set disassembly-flavor att或者intel就可以切换成对应的反汇编风格啦. 大部分情况下两种格式是互通的. 唯一值得注意的是读取顺序, 两者源操作数目的操作数的顺序是反的. 例如下面的语句表示栈指针减8:

1
2
subq     $8,%rsp             # ATT flavor
sub rsp,8 # Intel flavor

访问值和访问地址

%rsp 表示 rsp 寄存器储存的值, 通常来说栈指针保存的是一个地址. 对于更一般的寄存器, 例如rax寄存器, 是可以保存值的, 而且通常是返回值. 我们用 \(r_a\) 表示寄存器a 存储的值. 内存本质上是一个很长的数组, 内存引用表示 \(M[r_a]\), 例如 (%rsp)表示*rsp, 也就是rsp 寄存器储存地址上的值.

leamov指令间的区别: 前者常用于简单的算术运算, 例如:

1
2
/* i in %rsi */
leal (%eax,%eax,2), %eax

实际上进行的操作是: 源操作数计算为S = M[%eax + 2 * %eax] = M[3 * %eax], 然后求出 &S(S的有效地址), 然后将这个值复制给 D. 如果对%rax解引用, 例如gdb指令 p *(int*) $rax, 可以得到3i.

mov指令可以干两件事: 把内存地址复制给另一个寄存器, 或者将一个值复制给寄存器.

1
int x = a[i];
1
2
/* x in %eax, a in %rdx, i in %rcx */
movl (%rdx,%rcx,4),%eax

源操作数S = M[a + 4*i], aint数组的起始地址. 将内存地址S上的值赋值给寄存器rax. 此时 p $rax 就是a[i].

数组和结构本质上就是人为规定的一段内存. 上面的例子显示了数组int a[N]可以用*(a+i)访问a[i]的本质. 对于下面的结构体:

1
2
3
4
typedef struct node {
long val;
node* next;
} node;

假设node类型node1的指针为ptr, 那么*ptr是什么? *(ptr+8)代表什么? **(ptr+8) 又是什么? mov 0x8(%rdx),%rdx 假设rdxptr, 这句指令代表什么?

区别下面的指令:

1
2
mov    0x20(%rsp),%rbx 
lea 0x28(%rsp),%rax

其中rbx的值是内存地址$rsp+32上储存的值, rax现在是一个指针, 它的值是$rsp+40, 也就是一个内存地址.

控制语句

跳转命令jmp等价于C中的goto. if语句对应条件跳转指令. 循环控制语句都可以通过ifgoto所模拟.

1
2
3
4
5
6
7
8
9
10
long absdiff_se(long x, long y) {
long res;
if (x < y) {
res = y - x;
}
else {
res = x - y;
}
return res;
}
1
2
3
4
5
6
7
8
9
10
11
/* x in %rdi, y in %rsi */
absdiff_se:
cmpq %rsi, %rdi ; x - y
jge .L6 ; if >= goto .L6
movq %rsi, %rax ; res = y
subq %rdi, %rax ; res -= x
ret ; return
.L6:
movq %rdi, %rax ; res = x
subq %rsi, %rax ; res -= y
ret ; return

判断条件(if括号内的语句)基于一组条件码寄存器, 通过testcmp指令设置.

Q: 这里发生了什么? A: cmq等同为不修改源操作数的sub指令(a-b), test类似and指令(a&b), 程序根据结果设置OFSF以及其他的条件码寄存器(默认为0).

假设结果小于0, 那么SF会被设置为1; 还会判断是否发生数据类型的溢出, 假设溢出, OF会被设置为1.

jge会先对条件码进行位运算, 这里:~(SF ^ OF), 如果结果为1, 那么跳转, 否则程序继续执行下去.

在实际阅读汇编代码的过程中, 可以忽略这部分细节, 明白程序按照条件跳转即可.

-O1级别以上的优化还会出现条件移动指令, 针对的是CPU条件预测的优化. 通常来说条件移动(CMOV类)指令可以用下面的C代码抽象:

1
2
3
4
5
// v = test_expr ? then_expr : else_expr;
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;

下面我们来讨论循环语句: do while 作为最不常用的循环, 模拟方式却是最简单的. 它的流程就和它本身一致.

1
2
3
4
5
loop:
body
t = test_expr;
if(t)
goto loop;

while语句的区别是, while语句在进入之前会进行一次条件判断.

1
2
3
4
5
6
7
goto test;
loop:
body
test:
t = test_expr;
if(t)
goto loop;

至于最常用的for语句, 它有等价的while表述, 此处从略.

做完了要不要试试手? 要不要拆个二进制炸弹试试手:) 在Linux下输入下面的指令:

1
2
3
wget http://csapp.cs.cmu.edu/3e/bomb.tar
tar xvf bomb.tar
cd bomb

你需要做的是gdb bomb然后对汇编代码进行分析, 然后给出拆弹必要的字符串! 在bomb.c给出了程序流, 你至少知道该在哪里打断点. 而函数具体如何实现, 这就得看你汇编的能力了!

函数调用的过程

函数调用的过程满足的前进后出(First in, Last out)原则.

Q: 怎么画函数栈帧的示意图? 是栈顶放最上面, 还是内存高位的一端放在最上面? A: 看个人习惯. UP? 还是 DOWN? 只要你看别人的示意图不会被绕晕就可以. 通常来说, 栈朝着低地址增长, 从这个意义上来说, 往下画是更多人的选择.

当过程P执行过程Q时, 会把返回地址, 也就是call指令的下一句(记作A)压入栈中(PC++), Q返回时就会从A开始继续执行. 我们认为A是P栈帧的一部分. 对应的 ret 会将A从栈中弹出, 然后将rip设置为A.

调用函数的时候怎么传入参数? 一般来说, 如果还有空闲的寄存器, 程序在调用过程Q之前, 在过程P, 会先将参数移动到对应的寄存器, 例如第一个参数会移动到指定寄存器rdi上(rsirdx以此类推), 然后在过程Q中调用这几个寄存器, "共享"过程中, 实际上就实现了参数的传递.

在函数调用的一开始, 会为过程Q分配栈空间. 因为程序运行地址是往低址增长, 所以扩大栈需要减小栈指针rsp(指向栈顶元素). 将栈指针减小一个适当的值可以为没有初始值的数据在栈上分配空间.

通常来说push指令(将参数压入栈)就可以满足需要. 压栈实际上就是复制一个参数. 某些情况下, 还需要通过主动利用sub指令来分配栈帧. 在结束调用时, 对应的需要增加栈指针, 对应指令pop, 本质上是add一个特定的值.

Q1: push指令到底是什么意思? A: push指令实际上可以通过更简单的指令模拟出来: pushq %rbp 等价于 subq $8,%rsp然后 movq %rbp,(%rsp). 区别在于pushq指令占的字节数更少. 同理可以知道pop的含义.

Q2: 还有什么时候需要用到栈帧? A: 上面提到了, 如果寄存器不足够放置所有的本地数据, 就需要用栈去储存. 此外, 对一个临时变量取地址&, 局部变量是数组或结构, 同样需要分配栈帧. 栈帧的具体分配可以查阅书.

理解了函数调用的过程, 实际上你就可以实现将任何递归程序改写成非递归! 比方说, 写一个非递归版的汉诺塔程序.

此外, 关于栈帧有一个有趣的话题: 内存越界和缓冲区溢出. 主要是这样一个库函数gets(), 它可以无视字符串长度向字符数组写入, 引起内存越界, 小则修改栈上的值, 大则修改返回地址, 使得函数调用到奇怪的地方!

有关这部分, CSAPP提供了attack lab, 让你模拟为一名hacker去攻击程序! 很有意思.

在这里, 我们给出缓冲区溢出的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void win(int arg)
{
if(arg == 0x15213) {
printf("You win 1 cookie! Great start!\n");
return;
}
if(arg == 0x18213) {
printf("You win 2 cookies! Woohoo!\n");
return;
}
if(arg == 0x18613) {
printf("You win 3 cookies! That's right!\n");
return;
}
}

void solve(void)
{
volatile long before = 0xb4;
char buf[16];
volatile long after = 0xaf;

gets(buf);

if(before == 0x3331323531)
win(0x15213);
if(after == 0x3331323831)
win(0x18213);
}

修改after的值, 这个其实不难做到. 按照临时变量在栈上的顺序, 首先before先压栈, 然后是buf, 最后是after. 又因为栈是朝内存低的方向生长的, 知buf超出的部分会影响到before的值. 具体在内存中是怎么分配的呢? 可以尝试反汇编调试一下:

1
2
3
4
5
6
7
8
Dump of assembler code for function solve:
0x00000000004006b5 <+0>: sub $0x38,%rsp
0x00000000004006b9 <+4>: movq $0xb4,0x28(%rsp)
0x00000000004006c2 <+13>: movq $0xaf,0x8(%rsp)
0x00000000004006cb <+22>: lea 0x10(%rsp),%rdi
0x00000000004006d0 <+27>: call 0x40073f <Gets>
0x00000000004006d5 <+32>: mov 0x28(%rsp),%rdx
0x00000000004006da <+37>: movabs $0x3331323531,%rax

bufrsp+0x10 的位置, buf[16]0x20, 而 0xb4rsp+0x28 的位置, 也就是要覆盖before的值还必须填充8个字节. 于是, 可以构造攻击字符串00001111222233334444555515213, 就可以获得1个cookie啦!

想一想, 你可以怎么做, 来完成剩余的关卡? 例如, 修改after的值, 或者直接修改传递的arg参数值? 而这一切都可以通过缓冲区的溢出来实现! 如果你感兴趣, 可以下载CMU官网提供的rec:

1
2
3
wget https://www.cs.cmu.edu/~213/activities/rec5.tar
tar xvf rec5.tar
cd rec5

补充一些细节: 传递参数存在保护机制. 方才我们提到参数传入靠的是pushpop, 实际是参数的复制. 在此之外, 我们还有一组特殊的寄存器: rbx, rbpr12 ~ r15寄存器被称为被调用者保存寄存器. 在P调用Q的过程中, Q必须保护这些寄存器的值, 使得当Q返回P的时候与调用Q时是一样的.

1
2
3
4
5
6
long P(long x, long y)
{
long u = Q(y);
long v = Q(x);
return u + v;
}

第四行sub $8,%rsp是为了内存对齐. 第五行把x储存到了rbp寄存器上, 这样就不需要担心rdi寄存器后来被修改导致x丢失. 同样rbx储存了Q(y)的值, 这样就不用担心rax寄存器修改导致Q(y)的丢失.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
P:
pushq %rbp Save %rbp
pushq %rbx Save %rbx
subq $8, %rsp Align stack frame
movq %rdi, %rbp Save x
movq %rsi, %rdi Move y to first argument
call Q Call Q(y)
movq %rax, %rbx Save result
movq %rbp, %rdi Move x to first argument
call Q Call Q(x)
addq %rbx, %rax Add saved Q(y) to Q(x)
addq $8, %rsp Deallocate last part of stack
popq %rbx Restore %rbx
popq %rbp Restore %rbp
ret