←返回主页

汇编学习

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


 

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

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

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

  1. 开头

  2. 变量定义与传参

  3. 循环

  4. 返回

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

储存与寻址

现代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,除此之外还可使用字符串字面值来初始化数组。

例:

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

会编译为:

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

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

内存寻址

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

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

下面是 不合法 的操作:

指定长度

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

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

指令集

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

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

符号含义
<reg32>任意32位寄存器(EAXEBXECXEDXESIEDIESPEBP
<reg16>任意16位寄存器(AXBXCXDX
<reg8>任意8位寄存器(AHBHCHDHALBLCLDL
<reg>任意寄存器
<mem>内存地址(例如 [eax][var + 4]dword ptr [eax+ebx]
<con32>任意32位常量
<con16>任意16位常量
<con8>任意8位常量
<con>任意8、16、32位常量

数据操作

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

然而 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代码:

对应的汇编代码为:

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

控制语句

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

算数操作

略,比较简单

调用约定

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

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

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

img

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

调用方:

函数:

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

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

对应的汇编为:

可以看到通过参数倒序入栈来进行传递,函数内通过 [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 assembly syntax OFFSET