目录
本文的叙述将基于 x86-64
为什么要学习汇编语言
汇编语言其实就是人能识别的机器语言,理解汇编语言可以是学习计算机系统的必经之路。
前置知识
指令集架构(Instruction Set Architecture, ISA)
指令集架构是描述计算机行为的一层抽象,它提供了程序员应当了解的计算机工作的细节,定义了处理器状态、指令的格式、以及每条指令对状态的影响。
处理器状态
-
程序计数器
给出下一条指令在内存中的位置,称为 PC,由
%rip
表示。 -
整数寄存器
有些寄存器存储地址、整数等数据,有些用来保存参数或返回值,还有的保存某些程序状态。
比较重要(但不是全部)的寄存器有:
64位 32位 作用 %rax
%eax
保存返回值 %rbx
%ebx
保存数据 %rbp
%ebp
保存数据 %rdi
%edi
第一个参数 %rsi
%esi
第二个参数 %rdx
%edx
第三个参数 %rsp
%esp
栈指针 其中最特别的是
%rsp
,指明了运行时栈顶的位置。寄存器的不同位数只代表它们使用了这个寄存器的多少位,并不是说明它们是两个不同的寄存器。也就是说,如果使用
%eax
存放了一个 32 位的整数,那么通过%rax
也可以访问这个数。寄存器最低有 8 位。 -
条件码
保存最近执行的条件或逻辑指令的结果,可以用来实现
if, while
语句。条件码有:
名称 标志名 作用 CF 进位标志 无符号溢出 ZF 零标志 结果为0 SF 符号标志 结果为负数 OF 溢出标志 有符号溢出 这些标志配合使用就可以完成
<, >, ==, !=
等逻辑操作。 -
向量寄存器
存储一个或多个整数或浮点数
汇编和 C 的联系
C 语言在不同的优化等级下可以转化为不同的汇编代码,在 gcc
编译时可以提供从 Og, O1, O2, O3
等多个优化等级,在不同的优化等级下汇编代码也会变得不同。在调试或是学习汇编时一般采用 Og, O1
等级。
代码示例
下列是源代码、汇编器生成的代码(已删去各种伪指令)、gdb
反汇编的代码和 objdump
反汇编的代码
// note: 源代码
int max(int x, int y)
{
return x > y ? x : y;
}
; note: 汇编器生成的代码
max:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %eax
cmpl %eax, -8(%rbp)
cmovge -8(%rbp), %eax
popq %rbp
ret
; note: gdb反汇编的代码
0x00000000000005fa <+0>: push %rbp
0x00000000000005fb <+1>: mov %rsp,%rbp
0x00000000000005fe <+4>: mov %edi,-0x4(%rbp)
0x0000000000000601 <+7>: mov %esi,-0x8(%rbp)
0x0000000000000604 <+10>: mov -0x4(%rbp),%eax
0x0000000000000607 <+13>: cmp %eax,-0x8(%rbp)
0x000000000000060a <+16>: cmovge -0x8(%rbp),%eax
0x000000000000060e <+20>: pop %rbp
0x000000000000060f <+21>: retq
; note: objdump反汇编的代码
00000000000005fa <max>:
5fa: 55 push %rbp
5fb: 48 89 e5 mov %rsp,%rbp
5fe: 89 7d fc mov %edi,-0x4(%rbp)
601: 89 75 f8 mov %esi,-0x8(%rbp)
604: 8b 45 fc mov -0x4(%rbp),%eax
607: 39 45 f8 cmp %eax,-0x8(%rbp)
60a: 0f 4d 45 f8 cmovge -0x8(%rbp),%eax
60e: 5d pop %rbp
60f: c3 retq
其中,反汇编的代码指出了指令的绝对地址、相对地址、指令的字节表示以及指令的汇编语言表示。
基本的汇编语句
通常,一个指令的格式为 <code> [<source>,] <target>
。
比如 mov %rsp, %rbp
表示将 %rsp
的值移动到 %rbp
,相当于 %rbp = %rsp
;add %rdx, %rax
表示将 %rdx
的值加到 %rax
,相当于 %rax += %rdx
;push %rbp
表示将 %rbp
压入栈顶。
在 x86-64 架构中,汇编代码可以被分为
-
数据传送指令,
mov
系列主要注意各种寻址方式,
R[]
代表使用寄存器的值,M[]
表示使用内存的值格式 操作数值 描述 例子 $Imm
Imm
Imm
这个数$100
r
R[r]
r
所代表的寄存器%rax
Imm
M[Imm]
Imm
所指向的内存0x100
(r)
M[R[r]]
r
寄存器的值所指向的内存(%rax)
Imm(r)
M[Imm+R[r]]
计算地址后访问内存 4(%rax)
(r1, r2)
M[R[r1]+R[r2]]
计算地址后访问内存 (%rax, %rdx)
Imm(r1, r2)
M[Imm+R[r1]+R[r2]]
计算地址后访问内存 9(%rax, %rdx)
(,r, s)
M[R[r]*s]
计算地址后访问内存 (, %rcx, 4)
Imm(,r, s)
M[Imm+R[r]*s]
计算地址后访问内存 0xFC(, %rcx, 4)
(r1, r2, s)
M[R[r1]+R[r2]*s]
计算地址后访问内存 (%rax, %rdx, 4)
Imm(r1, r2, s)
M[Imm+R[r1]+R[r2]*s]
计算地址后访问内存 8(%rax, %rdx, 4)
-
运算指令
以
add, sub, mul, div
表示的加减乘除,inc, dec
表示的自增自减,and, or, xor, not
表示的与、或、异或、补,sal, shl
表示的左移,sar, shr
表示的算数右移,逻辑右移。比较特殊的是
leaq
指令,原意是计算地址的指令,但可以用作算数和数据传送的组合指令比如
leaq (%rdi, %rsi, 4), %rax
意为%rax = %rdi + %rsi * 4
,虽然左边是内存访问但该指令只会获取内存地址而不会访问。如果是
movq (%rdi, %rsi, 4), %rax
,用类似 C 语言的表示方法就是%rax = *(%rdi + %rsi * 4)
。故该指令常常用于计算,比如
leaq (%rdi, %rdi, 4), %rax
为%rax = %rdi * 5
。 -
与栈相关的指令,
push, pop
系列pushq %rbp
相当于subq $8, %rsp
movq %rbp, (%rsp)
,即将栈顶下移用于存储%rbp
的值。popq %rax
相当于movq (%rsp), %rax
add $8, %rsp
,即将栈顶的值放入%rax
后升高栈顶。 -
流程控制指令
-
条件码
cmp
系列:cmp s1, s2
计算s2 - s1
并设置条件码(注意源和目标)test
系列:test s1, s2
计算s1 & s2
并设置条件码set
系列:set d
将条件码通过某些操作后转移到d
中条件码只有 4 个(
CF, ZF, SF, OF
),但通过一些操作可以表达出更多意思,比如(SF ^ OF) & ~ZF
表示有符号>
,~CF & ~ZF
表示无符号>
。 -
跳转指令
jmp
系列:可以使用绝对地址或相对地址,其中相对地址是目标地址和紧跟在跳转指令后的那条指令的地址的差。基本可以看做goto
,可用来实现if, while
等指令。 -
条件传送指令
cmov
系列:相当于set
和mov
的结合,只有满足一定条件才会进行赋值,类似于?:
三元表达式,可以看一下我的if和三元表达式的区别
一文。
-
注意:之所以说系列,是由于指令可以对不同长度的字节进行操作,一般通过在指令之后添加不同的后缀进行区分。也有一些指令有更多的操作,比如
set, jmp
系列的指令对条件码有很多操作,在这里就不展开了。
C 语言声明 数据类型 汇编后缀 大小(字节) char
字节 b 1 short
字 w 2 int
双字 l 4 long
四字 q 8 void*
四字 q 8 float
单精度 s 4 double
双精度 l 8 浮点数与整型使用一组完全不同的指令。
流程控制语句的实现
从 C 语言到汇编一般使用从一般的 C 语言到使用 goto
的 C 语言再到汇编语言的流程。
if
使用 cmp, jmp
if (<condition-expr>) {
<true-statement>;
} else {
<false-statement>;
}
c = <condition-expr>;
if (!c) goto fail;
<true-statement>;
goto done;
fail:
<false-statement>;
done:
cmp <src>, <dst> ;cmp 只是举例
jne label ;jne 只是举例
<true-statement>
jmp done
lable:
<false-statement>
done:
一些简短的条件语句也许可以使用 cmov
实现。
while
do-while
可以很直接地译为汇编
do {
<statement>;
} while (<condition-expr>);
loop:
<statement>;
c = <condition-expr>;
if (c) goto loop;
loop:
<statement>
cmp <src>, <dst> ;cmp 只是举例
je loop ;jne 只是举例
while
while (<condition-expr>) {
<statement>;
}
有两种策略实现
goto test;
loop:
<statement>;
test:
c = <condition-expr>;
if (c) goto loop;
c = <condition-expr>;
if (!c) goto done;
loop:
<statement>;
c = <condition-expr>;
if (c) goto loop;
done:
for
如果没有 continue
的话,for
和 while
其实是等价的,continue
对 <statement>, <update-expr>
的处理是不同的。
for (<init-expr>; <test-expr>; <update-expr>) {
<statement>
}
<init-expr>;
while (<test-expr>) {
<statement>;
<update-expr>;
}
这里只给出使用带 continue
的 for
转换为 while
的形式
for (<init-expr>; <loop-test-expr>; <update-expr>) {
<first-statement>;
if (<jump-test-expr>) continue;
<second-statement>;
}
<init-expr>;
while (<loop-test-expr>) {
<first-statement>
if (<jump-test-expr>) goto update;
<second-statement>;
update:
<update-expr>;
}
switch
switch
可以通过跳转表来实现,使用这种结构的效率通常高于连续的 if-elif-else
,这也是为什么建议使用 switch
的原因。
如果 case
分布比较集中,比如
switch (c) {
case 100:
return c << 1;
case 101:
return 1;
case 103:
return c * c;
case 98:
return -c;
}
return 0;
那么就很有可能使用跳转表记录每个 case
的(相对)地址:
// &&代表取标签的地址,这个运算符是真实存在的,这段代码也是可运行的
static void *jump_table[] = {
&&case_98, &&case_default, &&case_100,
&&case_101, &&case_default, &&case_103
};
unsigned long index = c - 98;
if (index > 5) goto case_default;
goto *jump_table[index];
case_98:
return -c;
case_100:
return c << 1;
case_101:
return 1;
case_103:
return c * c;
case_default:
return 0;
数组的实现
分配在栈上的数组
众所周知,C 语言的数组是不存储长度信息的。int array[10];
中的 10 其实是编译器带来的。
如果想要分配数组,可以将栈指针 %rsp
下拉需要的长度(字节),并将数组开头赋值给某个寄存器。
如果想要访问数组,mov
指令的各种寻址方式就是为此而造的,比如 (%rbp, %rdi, 4)
就把数组开头,索引,字节个数分好了。
分配在堆上的数组
在 C 语言中使用的是 malloc(), free()
这两个函数,如果反汇编 malloc()
和 free()
可以得到:
0x0000000000000560 <+0>: jmpq *0x200a6a(%rip) # 0x200fd0
0x0000000000000566 <+6>: pushq $0x1
0x000000000000056b <+11>: jmpq 0x540
0x0000000000000550 <+0>: jmpq *0x200a72(%rip) # 0x200fc8
0x0000000000000556 <+6>: pushq $0x0
0x000000000000055b <+11>: jmpq 0x540
实际上使用了操作系统的系统调用,其中细节比较复杂,超出本文内容,有兴趣的可以在网上搜索。
参考书籍
- 深入理解计算机系统(第三版)Computer Systems: A Programmer's Perspective (Third Edition)