# exit.s
 .section __TEXT, __text
 .globl _main
_main:
 mov1 $0, %rax
 retq

注释

程序的第一行是注释。在macOS的as汇编器语法下,注释由#开头,在进行汇编的时候会自动将其处理为空白字符。

我们习惯上将注释写在语句的上方(如例程)或后方,如:

movq    $0, %rax    # mov 0 to register rax

缩进

在最古老的机器上,汇编代码的文本包含四列:标签、助记符、操作数与注释。汇编器通过识别一个文本在哪个列来判断该文本有什么作用。现代的汇编器已经抛弃了这种方法,采用先进的词法分析技术来判断。但是,我们最好仍然按照这种格式来缩进。

汇编器指令(Directive)

“Directive"是汇编语言中一个重要的组成部分,然而它的中文译名似乎还不固定,这里暂且叫它汇编器指令。在汇编语言中,以.开头的都是汇编器指令,如例程中的.section.globl等。由汇编器指令开头的语句,一般不会被直接翻译成机器码。汇编器指令并不是告诉汇编器做什么, 而是告诉汇编器如何做。就比如说例程中,movq $0, %rax 会被汇编器直接翻译为机器码,最终会由CPU直接执行,而.section __TEXT,__text, 则不会被翻译成机器码,在最终的可执行文件中也不会找到这句话的踪影。它的作用是告诉汇编器如何汇编。下面,就介绍一下.section的作用

.section

我们之前在操作系统基础中提到,mach-o可执行文件的Data部分拥有许多段(Segment), 每个段又有许多节(section). 同一个段的作用往往是类似的,同时在执行的时候一个段会被分配到一个页之中。而.section最常用的格式,就是

.section    segname, sectname

其中segment是段名,sectname是节名。我们目前编写的第一个汇编语言程序,只包含纯代码。在macho中,纯代码被放在了__TEXT段的__text节中,因此,我们在文件的第二行写了

.section    __TEXT, __text

代表之后的语句都是__TEXT段的__text节中。

此外,由于这个节过于常用,因此,汇编器给予了我们一个简单的记号:.text. 我们可以直接用.text代替.section __TEXT, __text. 在以后的程序中,我也都会用这种记号。

除了__TEXT__text节后,还有许多段和节。常用的段和节的名称和作用可参见Assembler Directives. 我们之后更复杂的程序中也会用到更多的段和节。

.globl

我们在由汇编语言翻译机器码的时候,得到的文件并不仅仅包含操作的指令,还需要包含一些名字和记号。比如说,C语言中,程序执行的起点是main函数。那么,这个函数的名字main就要包含在文件中,使得程序执行的时候知道执行哪个函数。

_main

macOS中,汇编语言程序执行的起点是_main函数。关于函数与下一行的_main:标签,我会在之后的文章中提到。是谁决定它叫这个名字的呢,是链接器。如果我们写的程序想把它主函数叫做_start, 那么只需要在链接的时候写上即可。

ld -e _start exit.o -L/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib -lSystem -o exit

movq

movq是我们遇到的第一个真正的指令。在汇编语言中,这种能直接翻译成机器码的指令被称作助记符(mnemonic). 之前我们也提到过,在GAS语法下,一条指令是助记符+源+目的,也就是说,它后面紧跟的是源操作数,然后是目的操作数。在x86-64架构下所有的可以被识别的助记符可以参考64-ia-32-architectures-software-developer-instruction-set-reference-manual, 但值得注意的是,这份官方的参考文档是用的Intel语法,我们只需要把源和目的颠倒过来看就行。

首先我们先要理解mov. 这是一个在汇编语言中很常见的指令,意思是赋值。mov a b就是将a赋值给b. 它可以将立即数赋值给寄存器、内存,可以把寄存器赋值给寄存器、内存,把内存赋值给寄存器。

接下来,我们需要理解q. 我们思考一下一个场景:我们在C语言中用long a;在一块内存上存储了一个64位整型数a,又用int b;在一块内存上存储了一个32位整型数b。那么,每次我们给a赋值的时候,实质上都是将数放入a的地址对应的内存中。因此,就是一个mov指令。但是,如果只有mov指令的话,那么a = 0x114514;b = 0x114514;这两个C语句翻译成汇编语言的话并没有区别,都是将一个数赋值给一块内存地址。然而我们知道,在x86-64架构下采用小端法,因此,在a的内存区域中实际应该存储的是14 45 11 00 00 00 00 00b的内存区域中存储的是14 45 11 00. 这看上去似乎没有什么区别。然而,在向a赋值的时候,实际上是把整个8个字节的高位都清零,而b仅仅是把4个字节的高位清零。然而,汇编层面并不认得longint的变量之类,因此,就必须扩展助记符来完成这个事情。

在GAS语法中,会在助记符后加上bwlq, 分别表示操作的是1个,2个,4个或8个字节。因此,long的赋值可以用movqint的赋值可以用movl.

$0

接着movq的,是$0, 作为其源操作数。在GAS语法下,一个数字前加上$表示这个数本身。如果不加的话,则表示0这个地址里存储的数。此外,我们也可以在前面加0x来表示16进制数,如

movq $0x2000001, %rax

%rax

我们之前提到,在x86-64架构下,CPU中一共有16个64位通用寄存器,它们的名字依次是rax, rbx, rcx, rdx, rdi, rsi, rbp, rsp, r8, r9, r10, r11, r12, r13, r14, r15. 当我们用这些名字的时候,指的就是这16个64位通用寄存器。此外,对于前8个通用寄存器,也就是名字不是数字的寄存器,还可以用eax, ebx, ecx, edx, edi, esi, ebp, esp指代其低32位,用ax, bx, cx, dx, di, si, bp, sp指代其低16位。而对于rax, rbx, rcx, rdx这四个通用寄存器而言,还可以单独引用它低16位中的高8位和低8位,如对ax而言,ah指代其高8位,al指代其低8位。

在GAS语法中,寄存器名字前面一定要跟着 %.

retq

关于这个,我会在之后的函数部分的文章中提到。

.equ 定义字面量

最简单的定义变量的方式,是利用汇编器指令.equ. 这类似于C语言中的#define. 比如说,我在程序开头写上

.equ    maxCount, 0x114514

那么,我在之后的程序里就可以写

movq    $maxCount, %rax

来表示将0x114514赋值给rax寄存器。

同时这里应当指出,这个指令是汇编器指令,在汇编的时候,会自动将所有的maxCount直接用0x114514替代。比如说,我有以下程序:

.text
    .globl  _main
    .equ    maxCount, 0x114514
_main:
    movq    $maxCount, %rax
    retq

我们通过汇编、链接以后,得到一个test可执行文件。我们可以用之前提到的MachOView软件,或者在终端中键入

otool -v -t ./test

来查看生成的可执行文件中__TEXT__text节的内容

➜ otool -v -t ./test
./test:
(__TEXT,__text) section
_main:
0000000100003fb0        movq    $0x114514, %rax                 ## imm = 0x114514
0000000100003fb7        retq

由此可知,最终生成的文件中,是直接替换得到的。

此外,.equ还有一个比较方便的地方在于,它可以支持简单的算术运算,如加减乘除等。比如说,我可以写.equ maxCount, 1919-810, 那么接下来所有出现maxCount的地方,都会用1109来替代。

但是,正如C语言中的#define定义的宏一样,.equ定义的变量只是一个简单的替换,并不支持对这个变量重新赋值之类的操作。这个变量也没有其地址,只是一个字面量。

局部变量

在几个寄存器中,有一个寄存器和栈的关系非常大,那就是rsp寄存器。从它的名字就可以看出来,stack pointer, 它存储的值永远是栈顶的地址,所以它又被叫做栈顶指针。我们可以用(%rsp)来获取栈顶存储的值,通过 a(%rsp), 其中 a 是任何一个整数,来获取地址是rsp存储的值加a处的内存单元的值。比如说,2(%rsp)就是栈顶上方(逻辑地址增大方向)2个字节处的值,-2(%rsp)就是栈顶下方(逻辑地址减小方向)2个字节处的值。

在汇编语言中,压栈和弹栈的助记符分别是pushpop. 这两个操作均有一个操作数。push的操作是将栈顶指针向下移动(也就是将rsp内的值减小),并将移动后rsp对应位置内存区域的值赋为其操作数,而pop则相反。这里“向下移动”的距离是根据push后面跟着的字母决定的,如pushq就是把rsp内的值减8.

此外,如果是想获得栈顶的值,而不弹栈,可以直接用mov来实现。如popq %rax是将栈顶的8个字节内存储的值赋给rax, 并且栈顶指针向上移动8个字节。而movq (%rsp), %rax则是只将栈顶的8个字节内存储的值赋给rax, 不涉及栈顶指针的移动。而如果只想弹栈却不想赋值,那么直接对rsp进行add即可。如想把栈顶的8个字节的数据弹栈,就直接addq $8, %rsp.

使用局部变量

使用局部变量就是将局部变量放到栈上,然后使用的时候直接去访问栈上对应的地址空间就行。然后在返回之前,把栈恢复即可。

通过对rsp中存储的地址加偏移量去访问局部变量,如果之后又有了压栈、弹栈的操作偏移量就会改变。这种不稳定性十分不利于我们编程。因此,我们又用了另一个寄存器rbp来解决这个问题。rbp, 顾名思义,base pointer, 基地址指针,一般是用来使用偏移量寻址的。 使用的技巧是,先将rbppush进栈,然后对rsp sub. 再利用rbp的偏移量来引用局部变量。最后在返回前将rbp赋值给rsp, 此时栈顶指针指向的是最初对rbppush之后的位置,然后将栈顶pop出来给rbp,最后返回。

总结

因此,根据以上的讨论,我们可以将第一个汇编程序翻译成C程序了:

// exit.c
int main()
{
    return 0;
}

这就是我们第一个汇编程序的作用,也就是将main函数返回0. 至于为什么要将0传入rax寄存器而不是别的寄存器,后面关于调用约定的文章中会提及。在终端下,我们可以先运行这个程序exit:

./exit

什么都没出现,它正确退出了。接着,我们可以用

echo $?

来查看上一个程序的返回结果。不出所料,它返回的是0.

我们也可以通过修改第一个汇编程序,将不同的数赋值给rax寄存器,那么,最终main函数返回的值也会不同,我们通过echo $?查看的结果也会不同。这也是我们初期不用调试器时查看汇编程序结果的一个简单的方法。


(Byte order) 计算机中通常采用的字节存储机制主要分为两种: 大端(Big-endian)和小端(Little-endian)。

MSB 是 Most Significant Bit/Byte 的首字母缩写,通常译为最重要的位或最重要的字节。它通常用来表明在一个 bit 序列(如一个 byte 是8个 bit 组成的序列)或一个 byte 序列(如 word 是两个 byte 组成的一个序列)中对整个序列取值影响最大的那个 bit/byte。

LSB 是 Least Significant Bit/Byte 的首字母缩写,通常译为最不重要的位或最不重要的字节。它通常用来表明在一个 bit 序列(如一个 byte 是8个 bit 组成的序列)或一个 byte 序列(如 word 是两个 byte 组成的一个序列)中对整个序列取值影响最小的那个 bit/byte。

比如一个十六进制的整数 0x12345678 里面 0x12 0x34 0x56 0x78

0x12 就是 MSB, 0x78 就是 LSB。 而对于 0x78 这个字节而言,它的二进制是 01111000, 那么最左边的0就是 MSB, 最右边的那个 0 就是 LSB。

Big-endian 和 little-endian 的区别就是 big-endian 规定 MSB 在存储时放在低地址,在传输时 MSB 放在流的开始; LSB存储时放在高地址,在传输时放在流的末尾。 little-endian 则相反。

Big-endianlittle-endian
0字节0x120x78
1字节0x340x56
2字节0x560x34
3字节0x780x12

Little-endian 主要用于现在的 CPU 中,即 Intel 的 x86 系列兼容机 Big-endian 则主要用于目前的 Mac 机器中, 一版指 PowerPC 系列处理器

目前的 TPC/IP 网络及 Java 虚拟机的字节序都是 Big-endian 的。