现代硬件算法[2.2]: 汇编语言
汇编语言
CPUs由机器语言(machine language)控制,机器语言只是一个二进制编码指令流,用于指定
-
指令号(称为操作码(opcode))
-
它的操作数(operands)是什么(如果有的话)
-
以及将结果存储在哪里(如果产生了结果)
一种更人性化的机器语言,称为汇编语言(assembly language),使用助记码来指代机器码指令,使用符号名来指代寄存器和其他存储位置。
让我们直接开始,下面是使用Arm汇编进行两数相加*c = *a + *b
的操作:
; *a = x0, *b = x1, *c = x2 |
下面是x86汇编执行相同的加法操作:
; *a = rsi, *b = rdi, *c = rdx |
汇编非常简单,因为与高级编程语言相比,它没有太多语法结构。从上面的例子中可以观察到:
-
程序是一系列指令,每一个指令都写成指令名+可变数量的操作数的形式;
-
[reg]
语法用于“解引用”存储在寄存器中的指针,在x86上,你需要在其前面加上大小信息(这里的DWORD
表示32 bit); -
;
符号用于行注释,类似于其他语言中的#
和//
;
汇编是一种非常简单的语言,因为它需要这样。它尽可能地反映机器语言,机器代码和汇编之间几乎有1:1的对应关系。事实上,你可以使用一个名为反汇编[1](disassembly)的过程将任何编译后的程序转换回其汇编形式,但是所有非必要的东西(如注释)都不会被保留。
请注意,上面的两个代码片段的差异不仅在于不同的语法。两者都是由编译器生成的优化代码,但Arm版本使用了4条指令,而x86版本使用了3条。add eax, [rdi]
指令就是所谓的融合指令(fused instruction),它一次完成加载和相加操作,这是CISC可以提供的好处之一。
由于指令集架构之间的差异远不止这一个,因此从现在开始,在本书的剩余部分,我们将只提供x86的示例,这可能是我们大多数读者都会优化的,尽管许多引入的概念都与架构无关。
指令与寄存器
由于历史原因,大多数汇编语言中的指令助记符都非常简洁。当人们习惯于手工编写汇编并重复编写同一组通用指令时,少输入一个字符就离发疯更远一步。
例如,mov
表示“存储/加载一个字”,inc
表示“递增1”,mul
表示“乘法”,idiv
表示“整数除法”。你可以在这个指令参考中按名称查找指令的描述,但大多数指令都会执行你认为的字面意义上的操作。
大多数指令将结果写入第一个操作数,该操作数也可以参与计算,就像我们之前看到的add eax, [rdi]
示例一样。操作数可以是寄存器、常数值或内存地址。
寄存器(registers)被命名为rax
、rbx
、rcx
、rdx
、rdi
、rsi
、rbp
、rsp
和r8
-r15
,总共16个寄存器。由于历史原因,“字母”寄存器的命令是这样来的:rax
是累加器(accumulator),rcx
是计数器(counter),rdx
是数据(data)等等——当然,它们不必仅用于这些功能。
还有32位、16位和8位寄存器具有类似的名称(rax
→ eax
→ ax
→ al
)。它们不是完全分离的,而是复用的:rax
的最低32位是eax
,eax
的最低16位是ax
,依此类推。这样做是为了在保持兼容性的同时节省芯片空间,这也是编译型编程语言中基本类型的类型转换通常是零成本的原因。
这些只是通用(general-purpose)寄存器,除了一些特殊情况,你可以在大多数情况下随意使用。除此之外,还有一组单独的寄存器用于浮点运算,一组非常宽的寄存器用于矢量扩展,以及一些特殊寄存器用于流控制,后面我们会讲到这些内容。
常量就是整数值或浮点值:42
、0x2a
、3.14
、6.02e23
。它们通常被称为立即值(immediate value),因为它们可以直接嵌入到机器代码中。因为它可能会大大增加指令编码的复杂性,所以有些指令不支持立即值,或者只允许使用它们的固定子集。在某些情况下,你必须将常数值加载到寄存器中,然后使用寄存器而不是立即数。
除了数值,还有一些字符串常量,如hello
或world\n
,它们有自己的一小部分操作,但这是汇编语言中一个有点偏僻的角落,我们不打算在这里探讨。
移动数据
一些指令可能具有相同的助记符,但具有不同的操作数类型,在这种情况下,它们被视为不同的指令,因为它们可能执行不太相同的操作,执行时间也不同。mov
指令就是一个生动的例子,它有大约20种不同的形式,都与移动数据有关:要么在内存和寄存器之间,要么只在两个寄存器之间。尽管名字是“移动”,但它不会将值移动到寄存器中,而是复制它,保留原始值。
当用于在两个寄存器之间复制数据时,mov
指令会在内部执行寄存器重命名(register renaming),通知CPU寄存器X引用的值实际上存储在寄存器Y中,而不会造成任何额外的延迟,除了指令读取和指令译码本身。出于同样的原因,交换两个寄存器的xchg
指令也不需要任何代价。
正如我们在上面使用融合add
指令所看到的那样,不必对每个内存操作都使用mov:一些算术指令方便地支持将内存地址作为操作数。
寻址方式
内存寻址是通过[]
运算符完成的,但它可以做的不仅仅是将存储在寄存器中的值重新解释为内存地址。地址操作数在语法上最多可以用4个显式参数来表示:
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位,XMMWORD
、YMMWORD
和ZMMORD
分别用于128、256和512位。所有这些类型都不必一定用大写字母书写,但这是大多数编译器的默认形式。
地址计算本身通常很有用:lea
(load effective address)指令在一个时钟周期内计算操作数的内存地址并将其存储在寄存器中,而无需进行任何实际的内存操作。虽然它的预期用途是实际内存地址的计算,但它也经常被用作算术技巧,否则相同的算数运算会涉及到1次乘法运算,2次加法运算——例如,你可以用它乘以3、5和9。
它也经常作为add
的替代,它可以将结果移动到其他地方而不需要单独的mov指令:add
只在两个寄存器a+=b
模式下工作,而lea
允许你执行a=b+c
(如果其中一个是常数,甚至可以执行a=b+c+d
)。
替代语法
实际上,有多个汇编器(assemblers,将汇编代码转换为机器代码的程序)使用不同的汇编语言,但现在只有两种x86语法被广泛使用。它们以使用它们并在那个时代对编程产生主导影响的两家公司的名字命名:
- AT&T语法,默认情况下被所有Linux工具使用。
- Intel语法,默认情况下,当然,被英特尔使用。
这些语法有时也被分别称为GAS和NASM,由使用它们的两个主要汇编器(GNU Assembler 和 Netwide Assembler)的名称命名。
我们在本章中使用了Intel语法,并将在本书的其余部分继续使用它。为了进行比较,以下是AT&T语法中相同的*c=*a+*b
示例的样子:
movl (%rsi), %eax |
主要差异可总结如下:
-
最后一个操作数时目标操作数;
-
寄存器和常量需要分别以
%
和$
作为前缀(例如,addl $1, %rdx
递增rdx
); -
内存寻址看起来是这样的:
displacement(%base, %index, scale)
; -
;
和#
都可以用于行注释,/* */
也可以用于块注释。
而且,最重要的是,在AT&T语法中,指令名称需要加后缀(addq
、movl
、cmpq
等),以指定要操作的操作数尺寸:
b
= byte (8 bit)w
= word (16 bit)l
= long (32 bit 整数 或 64-bit 浮点数)q
= quad (64 bit)s
= single (32-bit 浮点数)t
= ten bytes (80-bit 浮点数)
在Intel语法中,此信息是从操作数推断出来的(这就是为什么你还是要指定指针的大小)。
大多数生成或使用x86汇编的工具都可以同时使用这两种语法,所以你可以选择更喜欢的一种,不必过于担心。
注释
[1] 在Linux上,要反汇编已编译的程序,可以调用objdump -d {path to binary}
。