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 汇编语言语法。
以下是经典的“Hello, world”程序,用 C 语言编写
#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 fatal error”;你可以将 -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 数据类型的数组 (char[]),并且不存在其他形式,但由于大多数编程语言会将字符串理解为单个实体,因此以这种方式表达它更清晰。)
.globl _main
此行告诉汇编器,标签 _main
是一个全局标签,这允许程序的其他部分看到它。在本例中,链接器需要能够看到 _main
标签,因为与程序链接的启动代码会将 _main
作为子程序调用。
_main:
这行代码声明了 `_main` 标签,标记了从启动代码调用的位置。
pushl %ebp
movl %esp, %ebp
subl $8, %esp
这些行将 EBP 的值保存到堆栈中,然后将 ESP 的值移到 EBP 中,然后从 ESP 中减去 8。请注意,`pushl` 会自动将 ESP 按适当的长度递减。每个操作码末尾的 `l` 指示我们想要使用适用于 *long*(32 位)操作数的操作码版本;通常汇编器能够从操作数中确定正确操作码版本,但为了安全起见,最好包含 `l`、`w`、`b` 或其他后缀。百分号表示寄存器名称,美元符号表示字面量值。这组指令序列通常在子例程开始时使用,为局部变量在堆栈中保留空间;EBP 用作基址寄存器来引用局部变量,并且从 ESP 中减去一个值以在堆栈中保留空间(因为 Intel 堆栈从更高的内存位置增长到较低的内存位置)。在本例中,在堆栈中保留了 8 个字节。我们将在后面看到为什么需要这个空间。
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
这一行通过从堆栈中弹出保存的指令指针来将控制权返回给调用过程。
直接与操作系统通信
[edit | edit source]请注意,我们只需要在需要调用 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 并在堆栈中保留了 4 个字节,因为调用 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 的值。
注意事项
[edit | edit source]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。
其他 GAS 阅读材料
[edit | edit source]您可以在 GNU GAS 文档页面阅读更多关于 GAS 的信息
https://sourceware.org/binutils/docs/as/
快速参考
[edit | edit source]指令 | 含义 |
---|---|
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 , … |
>, ≥, <, ≤, ≠, … |