分享免费的编程资源和教程

网站首页 > 技术教程 正文

懂汇编的才是好程序员 汇编程序是一种什么软件

goqiw 2024-10-21 06:44:48 技术教程 15 ℃ 0 评论

CPU是由一串二进制编码的机器语言控制的,包括下面的信息:

  1. 指令编好(我们称之为操作码)
  2. 操作数是什么(如果有的话)
  3. 以及将操作的结果存储在哪里(如果产生了结果)

汇编语言,是一种更加人性化的机器语言表示形式,使用了一些助记符代码来表示机器码指令,并使用符号名称来引用寄存器和一些其他的存储位置。

好,我们直接进入正题,下面是在ARM架构CPU中如何相

加两个数字的汇编代码(*c=*a+*b):

; *a = x0, *b = x1, *c = x2
ldr w0, [x0]    ; 从x0指向的位置加载4个字节到w0
ldr w1, [x1]    ; 从x1指向的位置加载4个字节到w1
add w0, w0, w1  ; 将w0和w1相加并将结果保存到w0
str w0, [x2]    ; 将w0的内容写入x2指向的位置

在x86汇编中进行同样操作的示例:

; *a = rsi, *b = rdi, *c = rdx 
mov eax, DWORD PTR [rsi]  ; 从rsi指向的位置加载4个字节到eax
add eax, DWORD PTR [rdi]  ; 将存储在rdi处的内容加到eax
mov DWORD PTR [rdx], eax  ; 将eax的内容写入rdx指向的位置

汇编语言的结构非常简单,与高级编程语言相比,没有太多的语法构造。从上述示例可以看到:

  • 程序就是一组指令序列,每个指令都是名称后跟可变数量的操作数
  • [reg] 语法用于“解引用”存储在寄存器中的指针,在x86架构中,需要在它前面加上占用大小的信息(这里的DWORD意味着32位)。
  • ;符号用于行注释,类似于其他语言中的#//

汇编语言是非常简洁的语言,它尽可能的直接反映机器码,几乎达到了1:1的对应关系。事实上,可以使用反汇编将任何编译过的程序转换为其汇编语言形式。

需要注意的是,上面两段代码不仅在语法上略有不同。同样都是编译器产生的优化代码,但Arm版本使用了4条指令,而X86版本仅使用了3条。add eax, DWORD PTR [rdi]这条指令,是所谓的融合指令,它一步完成了加载和加法操作,这就是CISC指令集的优势之一。

由于这两种指令集架构之间的差异远不止这一点,从这里开始到本书的其余部分,我们将只提供x86的示例,这可能是我们大多数读者将要优化的内容,尽管许多介绍的概念将是架构无关的。

指令和寄存器(Instructions and Registers)

出于历史原因,大多数汇编语言中的指令助记符非常简短。因为人们过去常常手写汇编并反复写下相同的一组常用指令时,所以尽可能的少打每一个字符。

例如,mov 用于“存储/加载一个字节”,inc 用于“增加1”,mul 用于“乘法”,idiv 用于“整数除法”。你可以在x86参考资料中查找指令的描述,但大多数指令的功能就是你认为的那样。

大多数指令将其结果写入第一个操作数,该操作数也可以参与计算,就像我们之前看到的 add eax, [rdi] 示例。操作数可以是寄存器、常量值或内存位置。

寄存器命名为rax、rbx、rcx、rdx、rdi、rsi、rbp、rspr8-r15,共计16个。那些带“字母”的寄存器因历史原因而得名:rax是“累加器”,rcx是“计数器”,rdx是“数据”,等等。

还有一些32位、16位和8位寄存器,具有类似的名称(rax → eax → ax → al)。它们并不是完全独立的,而是别名:rax的最低32位是eaxeax的最低16位是ax,等等。这样做是为了节省芯片空间,同时保持兼容性,这也是为什么在编译的编程语言中基本类型转换通常是零开销的。

这些仅仅是通用寄存器,除了一些例外,你可以在大多数指令中随意使用它们。还有一组专门用于浮点运算的寄存器,一组用于向量扩展的非常宽的寄存器,以及一些用于控制流的特殊寄存器,但我们会逐步介绍。

常量只是整数或浮点值:42、0x2a、3.14、6.02e23。它们更常被称为立即值,因为它们直接嵌入到机器码中。由于这可能会大大增加指令编码的复杂性,某些指令不支持立即值,或者只允许固定的子集。在某些情况下,你必须将一个常量值加载到寄存器中,然后使用它而不是使用立即值。

除了数值之外,还有如hello或world\n这样的字符串常量及其自己的一小部分操作,但这是汇编语言中的一个有点晦涩的小特性,我们在这里不会探讨。

数据传输(Moving Data)

某些指令可能有相同的助记符,但具有不同的操作数类型,在这种情况下,它们被视为不同的指令,因为它们可能执行略有不同的操作并需要不同的执行时间。mov 指令就是一个生动的例子,因为它有大约20种不同的形式,都与数据传输有关:要么是在内存和寄存器之间,要么是在两个寄存器之间。尽管名字是“移动”,它实际上是复制一个值到寄存器,保留原始值。

当用于在两个寄存器之间复制数据时,mov 指令内部实际上执行寄存器重命名(通知CPU由寄存器X引用的值实际上存储在寄存器Y中),这样不会引起任何额外的延迟,除了读取和解码指令本身。出于同样的原因,交换两个寄存器的 xchg 指令也不会有任何开销。

正如我们在上面看到的融合的 add,你不必为每个内存操作都使用 mov:一些算术指令很方便地支持内存位置作为操作数。

地址模式(Addressing Modes)

内存寻址是用 [] 运算符完成的,但它不仅仅是将寄存器中存储的值重新解释为内存位置。地址操作数最多可以包含四个参数,其语法如下:

SIZE PTR [base + index * scale + displacement]

其中 displacement 需要是一个整数常量,scale 可以是2、4或8。它所做的是计算指针 base + index * scale + displacement 并解引用它。

使用复杂的寻址方式比直接解引用指针最多慢一个周期,并且当你有一个结构数组并想要加载其第i个元素的特定字段时非常有用。

寻址运算符需要用大小指定符作为前缀,以指定所需数据的位数:

  • BYTE 代表 8 位
  • WORD 代表 16 位
  • DWORD 代表 32 位
  • QWORD 代表 64 位

还有较少见的 TBYTE 用于 80位,以及 XMMWORDYMMWORDZMMWORD 分别用于 128、256 和 512位。所有这些类型不必全部用大写字母写,但大多数编译器都是这样生成它们的。

地址计算本身往往非常有用:LEA 指令的主要功能是计算操作数的内存地址并将其存储在寄存器中,这个操作只需要一个周期,并且不涉及任何实际的内存读写操作。虽然 LEA 指令的初衷是用于计算内存地址,但它也经常被用作一种算术技巧,用来执行本来需要一次乘法和两次加法的操作。

举个例子,可以使用 LEA 指令来实现乘以 3、5 或 9 的操作。这是因为 LEA 指令可以利用寄存器间接寻址和基于偏移的寻址模式来计算这样的表达式。

例如,假设我们要将寄存器 eax 中的值乘以 3。通常,这可能需要一个乘法指令,但是使用 LEA 指令,我们可以这样实现:

assemblyCopy code
lea eax, [eax * 2 + eax]  ; eax = eax * 3

在这个例子中,LEA 指令通过将 eax 的值乘以 2 然后加上原始的 eax 值来计算 eax * 3,从而避免了直接使用乘法指令。

这种使用 LEA 指令进行算术计算的技巧在性能关键的代码中很有用,因为 LEA 指令通常比乘法指令更快,并且不占用 ALU (算术逻辑单元) 的资源。

它还经常用作 add 的替代品,因为它不需要单独的 mov 指令将结果移动到其他地方:add 只能以两个寄存器的 a += b 模式工作,而 lea 允许你执行 a = b + c(甚至 a = b + c + d 如果其中一个是常量)。

替代语法(Alternative Syntax)

实际上有多种使用不同的汇编语言汇编器(将汇编语言转换为机器码的程序),但现在只有两种x86语法被广泛使用。它们通常以在那个时代对编程产生主导影响的两家公司命名:

  • AT&T 语法,默认由所有Linux工具使用。
  • Intel 语法,默认情况下由英特尔使用。

这些语法有时也被称为 GASNASM,分别是使用它们的两个主要汇编器的名称(GNU汇编器和Netwide汇编器)。

我们在本章中使用了Intel语法,并将继续优先使用它以及本书的其余部分。作为比较,这是同样的 *c = *a+*b 示例在AT&T汇编语言中的样子:

movl (%rsi), %eax
addl (%rdi), %eax
movl %eax, (%rdx)

其中关键差异可以总结如下:

  1. 使用最后一个操作数来指定目标。
  2. 寄存器和常量需要分别以 %$ 为前缀(例如,addl $1, %rdx 表示增加 rdx)。
  3. 内存寻址的格式是这样的:displacement(%base, %index, scale)
  4. 可以使用 ;# 进行行注释,同时也可以使用 /* */ 进行块注释。

最重要的是,在AT&T语法中,指令名称需要添加“后缀”(如 addq, movl, cmpq 等),以指定正在操作的操作数的大小:

  • b = 字节(8位)
  • w = 字(16位)
  • l = 长(32位整数或64位浮点)
  • q = 四倍字(64位)
  • s = 单(32位浮点)
  • t = 十字节(80位浮点)

在Intel语法中,这些信息是从操作数中推断出来的(这就是为什么需要指定指针的大小)。

大多数生成或使用x86汇编的工具都可以使用这两种语法,所以大家可以选择自己更喜欢的那一种,不用担心。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表