x86 汇编/GNU 汇编语法
本文中的示例使用 GNU AS 中的 AT&T 汇编语法创建。使用此语法的主要优点是它与 GCC 内联汇编语法兼容。但是,这不是表示 x86 操作的唯一语法。例如,NASM 使用不同的语法来表示汇编助记符、操作数和寻址模式,就像一些 高级汇编器 一样。AT&T 语法是类 Unix 系统的标准,但一些汇编器使用 Intel 语法,或者像 GAS 本身一样,可以接受两种语法。有关比较表,请参阅 X86 汇编语言语法。
GAS 指令通常具有以下形式:助记符 源,目标。例如,以下 mov 指令
movb $0x05, %al
这会将十六进制值 5 移动到 al 寄存器中。
GAS 汇编指令通常以字母 "b"、"s"、"w"、"l"、"q" 或 "t" 为后缀,以确定正在操作的操作数的大小。
b
= 字节(8 位)。s
= 单精度(32 位浮点数)。w
= 字(16 位)。l
= 长整型(32 位整数或 64 位浮点数)。q
= 四字节(64 位)。t
= 十字节(80 位浮点数)。
如果未指定后缀,并且指令没有内存操作数,GAS 会根据目标寄存器操作数(最后一个操作数)的大小推断操作数大小。
引用寄存器时,寄存器需要以 "%" 为前缀。常数需要以 "$" 为前缀。
地址操作数最多有 4 个参数,它们以语法 段:偏移量(基址寄存器,索引寄存器,比例因子)
表示。这等效于 Intel 语法中的 段:[基址寄存器 + 偏移量 + 索引寄存器 * 比例因子]
。
基址、索引和偏移量组件可以以任何组合使用,并且每个组件都可以省略;省略的组件从上面的计算中排除[1][2]。
movl -8(%ebp, %edx, 4), %eax # Full example: load *(ebp + (edx * 4) - 8) into eax
movl -4(%ebp), %eax # Typical example: load a stack variable into eax
movl (%ecx), %edx # No index: copy the target of a pointer into a register
leal 8(,%eax,4), %eax # Arithmetic: multiply eax by 4 and add 8
leal (%edx,%eax,2), %eax # Arithmetic: multiply eax by 2 and add edx
本节作为 GAS 的简短介绍。GAS 是 GNU 项目 的一部分,这赋予了它以下良好的特性
- 它在许多操作系统上可用。
- 它与其他 GNU 编程工具(包括 GNU C 编译器 (gcc) 和 GNU 链接器 (ld))很好地集成。
如果你使用的是装有 Linux 操作系统的计算机,那么你可能已经在系统上安装了 GAS。如果你使用的是装有 Windows 操作系统的计算机,你可以通过安装 Cygwin 或 Mingw 来安装 GAS 和其他有用的编程工具。本介绍的剩余部分假设你已安装 GAS 并且知道如何打开命令行界面和编辑文件。
由于汇编语言直接对应于 CPU 执行的操作,因此精心编写的汇编程序可能比用 C 等高级语言编写的相同程序运行得快得多。另一方面,汇编程序的编写通常比用 C 语言编写的等效程序需要更多的努力。因此,快速编写性能良好的程序的典型方法是先用高级语言编写程序(这更容易编写和调试),然后用汇编语言重新编写选定的程序(性能更好)。将 C 程序重新编写为汇编语言的一个好方法是使用 C 编译器自动生成汇编语言。这不仅会给你一个正确编译的汇编文件,而且还能确保汇编程序完全按照你的意图执行。 [3]
我们现在将使用 GNU C 编译器来生成汇编代码,以检查 GAS 汇编语言语法。
这是用 C 语言编写的经典 "Hello, world" 程序
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}
将它保存到名为 "hello.c" 的文件中,然后在提示符处键入
gcc -o hello_c hello.c
这应该编译 C 文件并创建一个名为 "hello_c" 的可执行文件。如果你遇到错误,请确保 "hello.c" 的内容是正确的。
现在你应该能够在提示符处键入
./hello_c
该程序应该将 "Hello, world!" 打印到控制台。
现在我们知道 "hello.c" 键入正确并且按预期执行,让我们生成等效的 32 位 x86 汇编语言。在提示符处键入以下命令
gcc -S -m32 hello.c
这应该创建一个名为 "hello.s" 的文件(".s" 是 GNU 系统为汇编文件提供的文件扩展名)。在更新的 64 位系统上,32 位源代码树可能不包含,这会导致 "bits/predefs.h 致命错误";你可以将 -m32
gcc 指令替换为 -m64
指令来生成 64 位汇编。要将汇编文件编译成可执行文件,请键入
gcc -o hello_asm -m32 hello.s
(请注意,gcc 为我们调用了汇编器 (as) 和链接器 (ld)。) 现在,如果你在提示符处键入以下命令
./hello_asm
该程序也应该将 "Hello, world!" 打印到控制台。不出所料,它与编译后的 C 文件执行相同操作。
让我们看看 "hello.s" 中的内容
.file "hello.c"
.def ___main; .scl 2; .type 32; .endef
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
call __alloca
call ___main
movl $LC0, (%esp)
call _printf
movl $0, %eax
leave
ret
.def _printf; .scl 2; .type 32; .endef
"hello.s" 的内容可能因安装的 GNU 工具版本而异;此版本使用 Cygwin 和 gcc 版本 3.3.1 生成。
以句点开头的行,如 .file
、.def
或 .ascii
,是汇编器指令 — 指示汇编器如何汇编文件的命令。以一些文本后跟冒号开头的行,如 _main:
,是标签,或代码中的命名位置。其他行是汇编指令。
.file
和 .def
指令用于调试。我们可以将它们省略
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
call __alloca
call ___main
movl $LC0, (%esp)
call _printf
movl $0, %eax
leave
ret
.text
这行代码声明了一个代码段的开始。您可以使用此指令命名代码段,这可以让你对生成的机器代码在可执行文件中的位置进行细粒度的控制,这在某些情况下非常有用,例如编程嵌入式系统。使用 .text
本身告诉汇编器将以下代码放入默认段,对于大多数目的来说这已经足够了。
LC0:
.ascii "Hello, world!\12\0"
这段代码声明了一个标签,然后将一些原始 ASCII 文本放入程序中,从标签的位置开始。\12
指定换行符,而 \0
指定字符串末尾的空字符;C 函数使用空字符标记字符串的结束,由于我们要调用 C 字符串函数,因此这里需要这个字符。(注意!C 中的字符串是字符类型数据(char [])的数组,并且不存在任何其他形式,但由于人们会从大多数编程语言中理解字符串作为一个单个实体,因此以这种方式表达它会更加清楚。)
.globl _main
这行代码告诉汇编器标签 _main
是一个全局标签,这允许程序的其他部分看到它。在本例中,链接器需要能够看到 _main
标签,因为与程序链接的启动代码调用 _main
作为子程序。
_main:
这行代码声明了 _main
标签,标记了从启动代码调用的位置。
pushl %ebp
movl %esp, %ebp
subl $8, %esp
这些代码行将 EBP 的值保存在堆栈上,然后将 ESP 的值移入 EBP,然后从 ESP 中减去 8。注意,pushl
自动将 ESP 减去了相应的长度。每个操作码末尾的 l
表示我们想要使用适用于 *长*(32 位)操作数的操作码版本;通常汇编器可以从操作数中推断出正确操作码版本,但为了安全起见,最好包含 l
、w
、b
或其他后缀。百分号表示寄存器名称,美元符号表示字面值。这组指令在子程序开始时很常见,用于在堆栈上为局部变量保留空间;EBP 被用作基址寄存器来引用局部变量,并且从 ESP 中减去一个值来在堆栈上保留空间(因为 Intel 堆栈从高内存地址向低内存地址增长)。在本例中,在堆栈上保留了八个字节。我们将在稍后看到为什么需要这个空间。
andl $-16, %esp
这段代码将 ESP 与 0xFFFFFFF0 进行 `and` 操作,使堆栈与下一个最小的 16 字节边界对齐。检查 Mingw 的源代码表明,这可能是为了让出现在 `_main` 例程中的 SIMD 指令工作,这些指令只对对齐的地址进行操作。由于我们的例程不包含 SIMD 指令,因此这行代码是不必要的。
movl $0, %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
这段代码将零移入 EAX,然后将 EAX 移入内存位置 EBP - 4,即我们在过程开始时在堆栈上保留的临时空间。然后将内存位置 EBP - 4 移回 EAX;显然,这不是优化的代码。注意,括号表示内存位置,而括号前的数字表示相对于该内存位置的偏移量。
call __alloca
call ___main
这些函数是 C 库设置的一部分。由于我们正在调用 C 库中的函数,因此可能需要这些。它们执行的确切操作取决于平台和安装的 GNU 工具版本。
movl $LC0, (%esp)
call _printf
这段代码(终于!)打印了我们的消息。首先,它将 ASCII 字符串的位置移到堆栈的顶部。C 编译器似乎已经将 popl %eax; pushl $LC0
的一系列指令优化成了一个将值移到堆栈顶部的指令。然后,它调用 C 库中的 _printf
子程序将消息打印到控制台。
movl $0, %eax
这行代码将零(我们的返回值)存储在 EAX 中。C 调用约定是在退出例程时将返回值存储在 EAX 中。
leave
这行代码通常出现在子程序的末尾,它通过将 EBP 复制到 ESP 来释放我们在堆栈上保留的空间,然后将 EBP 的保存值弹回到 EBP。
ret
这行代码通过从堆栈中弹出会保存的指令指针来将控制权返回到调用过程。
注意,我们只需要在需要调用 C 库中的函数(如 printf()
)时才需要调用 C 库设置例程。如果我们直接与操作系统通信,可以避免调用这些例程。直接与操作系统通信的缺点是会失去可移植性;我们的代码将被锁定到特定的操作系统。但是,为了教学目的,让我们看一下如何在 Windows 下进行操作。以下是可在 Mingw 或 Cygwin 下编译的 C 源代码
#include <windows.h>
int main(void) {
LPSTR text = "Hello, world!\n";
DWORD charsWritten;
HANDLE hStdout;
hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
WriteFile(hStdout, text, 14, &charsWritten, NULL);
return 0;
}
理想情况下,您希望检查 “GetStdHandle” 和 “WriteFile” 的返回值,以确保它们正常工作,但这对于我们的目的已经足够了。以下是生成的汇编代码
.file "hello2.c"
.def ___main; .scl 2; .type 32; .endef
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -16(%ebp)
movl -16(%ebp), %eax
call __alloca
call ___main
movl $LC0, -4(%ebp)
movl $-11, (%esp)
call _GetStdHandle@4
subl $4, %esp
movl %eax, -12(%ebp)
movl $0, 16(%esp)
leal -8(%ebp), %eax
movl %eax, 12(%esp)
movl $14, 8(%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
movl -12(%ebp), %eax
movl %eax, (%esp)
call _WriteFile@20
subl $20, %esp
movl $0, %eax
leave
ret
即使我们从未使用过 C 标准库,生成的代码也为我们初始化了它。另外,还有许多不必要的堆栈操作。我们可以简化
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
_main:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
pushl $-11
call _GetStdHandle@4
pushl $0
leal -4(%ebp), %ebx
pushl %ebx
pushl $14
pushl $LC0
pushl %eax
call _WriteFile@20
movl $0, %eax
leave
ret
逐行分析
pushl %ebp
movl %esp, %ebp
subl $4, %esp
我们保存了旧的 EBP 并为堆栈保留了四个字节,因为调用 WriteFile 需要一个地方来存储写入的字符数,这是一个 4 字节值。
pushl $-11
call _GetStdHandle@4
我们将常量值 STD_OUTPUT_HANDLE(-11)压入堆栈并调用 GetStdHandle。返回的句柄值在 EAX 中。
pushl $0
leal -4(%ebp), %ebx
pushl %ebx
pushl $14
pushl $LC0
pushl %eax
call _WriteFile@20
我们将 WriteFile 的参数压入堆栈并调用它。注意,Windows 调用约定是将参数从右到左压入堆栈。装入有效地址(lea
)指令将 EBP 的值加上 -4,得到我们在堆栈上保存的打印字符数的位置,我们将它存储在 EBX 中,然后压入堆栈。另外,EAX 仍然包含 GetStdHandle 调用的返回值,因此我们直接将其压入堆栈。
movl $0, %eax
leave
这里我们设置了程序的返回值,并使用 leave
指令恢复 EBP 和 ESP 的值。
UnixWare 汇编器,以及可能其他源自 AT&T 的 ix86 Unix 汇编器,在某些情况下会生成带有反转源寄存器和目标寄存器的浮点指令。不幸的是,gcc 和可能许多其他程序都使用这种反转语法,所以我们只能接受它。
例如
fsub %st, %st(3)
导致 %st(3)
被更新为 %st - %st(3)
,而不是预期的 %st(3) - %st
。这种情况发生在所有带有两个寄存器操作数的非交换算术浮点运算中,其中源寄存器为 %st
,目标寄存器为 %st(i)
。
注意,即使 objdump -d -M intel 仍然使用反转的操作码,因此请使用其他反汇编器检查这一点。有关更多信息,请参见 http://bugs.debian.org/372528。
您可以在 GNU GAS 文档页面阅读更多关于 GAS 的内容
https://sourceware.org/binutils/docs/as/
指令 | 含义 |
---|---|
movq %rax, %rbx
|
rbx ≔ rax |
movq $123, %rax
|
rax ≔ |
movq %rsi, -16(%rbp)
|
mem[rbp-16] ≔ rsi |
subq $10, %rbp
|
rbp ≔ rbp − 10 |
cmpl %eax %ebx
|
将 ebx 与 eax 进行比较,并相应地设置标志。如果 eax = ebx,则零标志被设置。 |
jmp location
|
无条件跳转 |
je location
|
如果等于标志被设置,则跳转到 location |
jg , jge , jl , jle , jne , … |
>, ≥, <, ≤, ≠, … |