汇编代码小结

本文的叙述将基于 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 = %rspadd %rdx, %rax 表示将 %rdx 的值加到 %rax,相当于 %rax += %rdxpush %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 系列:相当于 setmov 的结合,只有满足一定条件才会进行赋值,类似于 ?: 三元表达式,可以看一下我的 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 的话,forwhile 其实是等价的,continue<statement>, <update-expr> 的处理是不同的。

for (<init-expr>; <test-expr>; <update-expr>) {
    <statement>
}

<init-expr>;
while (<test-expr>) {
    <statement>;
    <update-expr>;
}

这里只给出使用带 continuefor 转换为 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)
posted @ 2022-10-27 11:50  Violeshnv  阅读(383)  评论(0编辑  收藏  举报