汇编学习

本文基于x86汇编,例子丰富,可放心观看。


先来看一段用于计算阶乘的简单的程序:

int fac(int n) {
    int ret = 1;

    for (int i = 2; i <= n; i++) {
        ret = ret * i;
    }

    return ret;
}

可以通过 godbolt 网站得到其汇编代码:

fac(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-20], edi
        mov     DWORD PTR [rbp-4], 1
        mov     DWORD PTR [rbp-8], 2
        jmp     .L2
.L3:
        mov     eax, DWORD PTR [rbp-4]
        imul    eax, DWORD PTR [rbp-8]
        mov     DWORD PTR [rbp-4], eax
        add     DWORD PTR [rbp-8], 1
.L2:
        mov     eax, DWORD PTR [rbp-8]
        cmp     eax, DWORD PTR [rbp-20]
        jle     .L3
        mov     eax, DWORD PTR [rbp-4]
        pop     rbp
        ret

通过网站贴心的对比功能,可以猜测各部分的作用(当然笔者是在一定基础上写的笔记,故这里可能对初学者引起困惑,但不妨看下去):

  1. 开头

           push    rbp                      ; 保存栈帧
           mov     rbp, rsp             ; 函数栈空间连续
  2. 变量定义与传参

           mov     DWORD PTR [rbp-20], edi ;edi 为参数 n 的值,放到栈中[rbp-20]这个位置上
           mov     DWORD PTR [rbp-4], 1    ;定义ret=1, 放到栈中[rbp-4]的位置
           mov     DWORD PTR [rbp-8], 2     ;定义i=2,放到栈中[rbp-8]的位置
  3. 循环

           jmp     .L2                      ; 先开始判断 i <= n
    .L3:                                 ; 循环体内容
           mov     eax, DWORD PTR [rbp-4]   ; 将ret移动到寄存器eax中
           imul    eax, DWORD PTR [rbp-8]   ; 将eax乘上i
           mov     DWORD PTR [rbp-4], eax   ; 将eax移动到ret
           add     DWORD PTR [rbp-8], 1 ; i++
    .L2:                                 ; 判断语句
           mov     eax, DWORD PTR [rbp-8]   ; 将i移动到寄存器eax
           cmp     eax, DWORD PTR [rbp-20]  ; 比较eax的值与n的大小
           jle     .L3                      ; 若小于则继续循环
  4. 返回

           mov     eax, DWORD PTR [rbp-4]   ; 将ret内容移动到eax
           pop     rbp                      ; 栈中取出rbx,即销毁栈帧
           ret                              ; 退出函数执行

下面会详细地引入汇编的各个概念,希望大家能够对这些代码有更清晰的认识。

储存与寻址

现代x86处理器有8个32位通用寄存器(寄存器大小写不敏感):

寄存器

ESPEBP 用于特殊用途,EAXEBXECXEDX 寄存器可以使用子节(subsection),例如 EAX 的低16位称为 AXAX 的高8位与低8位称为 AHAL,有时处理问题使用这些寄存器更加方便(如字符处理)。

另外在汇编语言中,内存主要分为四个区域:

1.代码段:该区域包含可执行程序的指令代码,通常是只读的,并且无法被修改。

2.数据段:该区域用于存储全局变量、静态变量和常量等数据,可以被读取和修改,包含 .data 段、.bss 段两部分。

3.堆栈段:当程序创建新的函数或者子程序时,这些函数和子程序的参数以及局部变量都会被保存在堆栈段的空间中。栈上的变量是动态分配的,在函数调用结束后,栈上的空间也会被自动释放。x86的栈向下生长,即栈顶 esp 内存地址小于栈底 ebp

4.堆段:这是一个连续未被占用的空间,用于存储动态分配的内存。程序员可以手动通过malloc等方式来申请和释放堆上的内存空间。

内存区域

静态存储

使用 .DATA 可以声明静态数据区域(类似于C中的全局变量),在此之后可使用 DBDWDD 来声明一个、两个和四个字节的存储位置,按照顺序声明的位置在内存中是连续的。另外在x86汇编语言中,数组仅允许声明一维的。

可以使用 DUP 指令来重复给定次数的表达式来声明数组,例如 4 DUP(2) 等价于 2,2,2,2,除此之外还可使用字符串字面值来初始化数组。

例:

.DATA           
var     DB 64           ; 声明1字节的数据,初始值为64
var2    DW ?            ; 声明2字节的数据,不初始化
        DD 10           ; 声明数据区域但是不命名
Z       DD 1, 2, 3      ; 声明4字节的长度为3的数组, 初始化为1,2,3,Z+8位置数据的值为3
bytes   DB 10 DUP(?)    ; 声明1字节的长度为10的数组,不初始化
str     DB 'hello',0    ; 定义长度为6的字符串,初始化为`hello`的字符串的ascii码,最后一位为0

另外对于在 C 中定义的全局变量,编译器会加入一些列伪指令,如下面程序:

int a[100000000] = {1, 2, 3};
long long k = 1;
char b[] = "qiqi";

会编译为:

        .globl  a
        .data
        .align  32
        .type   a, @object
        .size   a, 400000000
a:
        .long   1
        .long   2
        .long   3
        .zero   399999988

        .globl  k
        .align  8
        .type   k, @object
        .size   k, 8
k:
        .quad   1

        .globl  b
        .type   b, @object
        .size   b, 5
b:
        .string "qiqi"
        .text
        .globl  fac(int)
        .type   fac(int), @function

其中 .align 为设置对齐方式,.long .quad .string 为数据定义伪操作,.globl 为定义全局符号,.type 用来指定一个符号的类型是函数类型(@function)或者是对象类型(@object)。

注意 .data 段与 .bss 段是不同的,这里留个坑,有空再填。

内存寻址

现代x86处理器能够寻址32位宽内存地址,例如上面声明变量后,使用名称就可以引用该内存区域(事实上被替换为32位内存地址)。除此之外,x86提供了另外一种方案:将两个寄存器和一个带符号常量加起来计算内存地址,其中一个寄存器可以数乘2、4或8,如 [esi+4*ebx]

寻址模式可以用于多种指令,下面使用 mov 命令演示寄存器与内存之间移动数据。mov 命令需要两个参数,第一个参数是移动到的位置,第二个参数是源位置。(注:这里的移动实际上是赋值)

mov eax, [ebx]      ; 将(储存在ebx中地址位置的值)移动到eax
mov [var], ebx      ; 将ebx的值移动到常量var储存的地址位置
mov eax, [esi-4]    ; 将地址位置为[esi-4]的存储数据的值移动到eax
mov [esi+4*eax], cl ; 将cl的内容移动到地址位置为[esi+4*eax]的值

下面是 不合法 的操作:

mov eax, [ebx-ecx]      ; 寄存器变量只能加法
mov [eax+esi+edi], ebx  ; 寻址最多支持两个寄存器的运算

指定长度

如果使用 mov [ebx], 2,无法判断是将多少位的整数2移动到 [ebx] 的位置,引起歧义,故需要指定长度 BYTE PTRWORD PTRDWORD PTR,分别代表1、2、4字节的数据。

例如 int a[10]; void set(int n) { a[n] = n; } 对应的汇编代码为:

a:
        .zero   40
set(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        cdqe                                ; 符号位的拓展
        mov     edx, DWORD PTR [rbp-4]
        mov     DWORD PTR a[0+rax*4], edx   ; a[n] = n;
        nop
        pop     rbp
        ret

指令集

这里只介绍一些常用的指令,如果想要查阅所有指令,可以看本文末 [2] 的开发手册。

方便介绍参数的类型,我们使用下面的符号来代表各种类型。

符号 含义
reg32 任意32位寄存器( EAX 、 EBX 、 ECX 、 EDX 、 ESI 、 EDI 、 ESP 或 EBP )
reg16 任意16位寄存器( AX 、 BX 、 CX 或 DX )
reg8 任意8位寄存器( AH 、 BH 、 CH 、 DH 、 AL 、 BL 、 CL 或 DL )
reg 任意寄存器
mem 内存地址(例如 [eax] 、 [var + 4] 与 dword ptr [eax+ebx] )
con32 任意32位常量
con16 任意16位常量
con8 任意8位常量
con 任意8、16、32位常量

数据操作

  • mov 指令:将其第二个参数(即寄存器内容、内存内容或常量)的数据复制到其第一个参数(即寄存器或内存)所引用的位置。特别地,mov 无法将内存内容移动到内存内容,需要先放到寄存器来间接移动。

    语法:

    mov 
    <reg>,<reg>
    mov 
    <reg>,<mem>
    mov 
    <mem>,<reg>
    mov 
    <reg>,<const>
    mov 
    <mem>,<const>
  • push 指令:参数入栈。实现原理为将 esp 减4后将操作数放到 [esp] 的32位位置。

    语法:

    push 
    <reg32>
    push 
    <mem>
    push 
    <con32>
  • pop 指令:出栈到参数中。实现原理先移动 [esp] 后将 esp 增加4。

    语法:

    pop 
    <reg32>
    pop 
    <mem>
  • lea 指令:将第二个参数(内存位置)的地址放入到第一个参数(寄存器)。该指令不加载内存位置的内容,只计算有效地址并将其放入寄存器。

    语法:

    lea 
    <reg32>,<mem>

有些人认为 lea 是可以被取代的,因为下面代码是等价的:

mov ebx, offset Value 
lea ebx, Value 

然而 lea 并非只是 mov 的附庸,其实有一些好处:

  1. lea 指令效率高。

    指令本身是单时钟周期的,且由 CPU 的 AGU(地址生成单元,address generation unit)执行,不会与上下文算数逻辑产生流水相关,且 AGU 与 ALU 并行执行,提高吞吐量。

  2. lea 可以巧妙地模拟三元算数逻辑。

    intel 指令集中不存在许多 risc 机器支持的三元算数逻辑,如 ARM 中的 add r0,r1,r2,而 lea 的第二个参数可以支持两寄存器的加法。

    例如要计算两寄存器的和,不想破坏原来的值,可以使用 lea ebx,[eax+edx],如果使用 add,无法使用一条指令完成。

另外还有一种有趣的取址方式,下面C代码:

    int *b = &a;
    int **c = &b;
    int ***d = &c;

对应的汇编代码为:

        mov     QWORD PTR [rbp-16], OFFSET FLAT:a
        lea     rax, [rbp-16]
        mov     QWORD PTR [rbp-24], rax
        lea     rax, [rbp-24]
        mov     QWORD PTR [rbp-8], rax

可以看到对于一维指针,编译器直接使用了 OFFSET FLAT:a 来进行取址,还是有点意思的。

控制语句

x86会维护一个指令指针的32位寄存器(IP),指向内存中当前指令的起始位置,执行完语句后该指针递增,指向下一条指令。IP 寄存器无法直接操作,只能通过控制语句进行隐式更改。

  • jmp 指令:跳转到指定标签。

    可以使用 `

  • jcondition 指令:条件跳转。

    这组指令是基于一组条件状态的条件跳转,这些条件状态存储在一个称为 machine status word 的特殊寄存器中。机器状态字的内容包括有关上次执行的算术运算的信息。

    语法:

    je 
    <label>   ; 相等时跳转
    jne 
    <label>  ; 不相等时跳转
    jz 
    <label>   ; 上一个结果为0时跳转
    jg 
    <label>   ; 大于时跳转
    jge 
    <label>  ; 大于等于时跳转
    jl 
    <label>   ; 小于时跳转
    jle 
    <label>  ; 小于等于时跳转
  • cmp 指令:修改 machine status word

    大部分条件跳转的上一条指令为 cmp 指令。

    cmp 
    <reg>,<reg>
    cmp 
    <reg>,<mem>
    cmp 
    <mem>,<reg>
    cmp 
    <reg>,<con>
  • callret 指令:实现函数的调用与返回。

    call 指令首先将当前代码位置入栈,然后跳转到参数对应的代码位置。相比于 jmpcall 保存了 ret 后要返回的位置。

    语法:

    call 
    <label>
    ret

    例如C语言程序:

    void a() {}
    int b() { return 0; }
    
    void d() { a(); b(); }

    编译后得到:

    a():
          push    rbp
          mov     rbp, rsp
          nop
          pop     rbp
          ret
    b():
          push    rbp
          mov     rbp, rsp
          mov     eax, 0
          pop     rbp
          ret
    d():
          push    rbp
          mov     rbp, rsp
          call    a()
          call    b()
          nop
          pop     rbp
          ret

    其中 nop 为空指令,用于进行指令的对齐(有些CPU对跨边界的指令需要额外的周期来做译码工作)。

算数操作

略,比较简单

调用约定

由于汇编语言比较底层,对于栈与参数的传递都需要手动进行,故通常使用通用的调用约定来保证函数传参、返回值的一致性。

这里以C调用约定来讲解:C调用约定很大程度上依赖于使用硬件支持的栈。它基于push, pop, call和ret指令。子程序参数在栈上传递。寄存器保存在栈上,子例程使用的局部变量放在栈的内存中。在大多数处理器上实现的绝大多数高级过程语言都使用了类似的调用约定。

调用约定分为两组规则。第一组规则由函数的调用者使用,第二组规则由函数的编写者(被调用者)遵守。需要强调的是,这种调用约定不同于驼峰命名法之类的约定,如果不按照统一的调用约定,程序可能直接运行错误。

img

上面的图像描述了在执行具有三个参数和三个局部变量的函数调用期间堆栈的内容。堆栈中描述的单元是32位宽的内存位置,因此单元的内存地址间隔为4字节。第一个参数位于距基指针8字节的偏移处。调用指令将返回地址放置在堆栈上的参数上方(基指针下方),从而导致从基指针到第一个参数的额外4字节偏移。当使用 ret 指令从函数返回时,它将跳转到存储在堆栈上的返回地址。

调用方:

  • 保存寄存器的内容。函数可能会对作为参数的寄存器进行修改,如果调用者仍然需要其值,需要提前入栈。
  • 参数应倒序传入。
  • 函数的返回值可以在 eax 中获取。

函数:

  • 开头需要进行固定的一些操作:

    EBP 入栈,然后将 ESP 的值复制到 EBP

      push ebp
      mov  ebp, esp

    这是用来维护调用方的栈与当前函数所在的栈。

    然后在栈上创建空间以分配局部变量,注意栈是向小内存生长,故声明多少字节的局部变量,esp 就需要递减多少。

  • 在返回前,还需要进行下面的操作:

    恢复调用者保存的已修改寄存器的旧值(可以通过出栈到寄存器来恢复)。

    释放局部变量,通常写法为 mov esp ebp

    恢复调用者的 ebp,通常写法为 pop ebp

    最后使用 ret 返回。

特别地,可以使用 leave 指令整合倒数二、三步,其等价于:

    mov ebp, esp
    pop ebp

下面是一个调用含多个参数的函数的例子,其C程序为

void long_func(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) {
    a = i;
    b = j;
}
int d() {
    int a = 1, b = 2;
    long_func(1,2,3,a,5,6,b,8,9,10);
}

对应的汇编为:

long_func(int, int, int, int, int, int, int, int, int, int):
        push    ebp
        mov     ebp, esp
        mov     eax, DWORD PTR [ebp+40]
        mov     DWORD PTR [ebp+8], eax
        mov     eax, DWORD PTR [ebp+44]
        mov     DWORD PTR [ebp+12], eax
        nop
        pop     ebp
        ret
d():
        push    ebp
        mov     ebp, esp
        sub     esp, 16
        mov     DWORD PTR [ebp-4], 1
        mov     DWORD PTR [ebp-8], 2
        push    10
        push    9
        push    8
        push    DWORD PTR [ebp-8]
        push    6
        push    5
        push    DWORD PTR [ebp-4]
        push    3
        push    2
        push    1
        call    long_func(int, int, int, int, int, int, int, int, int, int)
        add     esp, 40
        ud2

可以看到通过参数倒序入栈来进行传递,函数内通过 [ebp+x] 来访问参数。


参考资料:

[1]. Guide to x86 Assembly 相当有用的教程,简明扼要地解释了各种语句的含义。 [2]. Intel® 64 and IA-32 Architectures Software Developer Manuals 开发手册,查阅使用,不建议直接看,例如 [1] 中使用‘gory’一次来形容它。 [3]. Embedded System: Memory Layout when using Assembly Language 汇编语言的内存结构。 [4]. 汇编语言中PTR的含义及作用 详细写明 PTR 的作用,以及 MOVLEA 的关系。 [5]. gcc – Intel c++ syntax OFFSET

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇