OS X提供了和Unix兼容的汇编语言,是基于AT&T语法的,和早先更广为流传的NASM汇编器所使用的Intel语法有很多不一样的地方。废话少说,先上代码,第一天我们做一件很简单的事,就是在Terminal里输入一些字符,然后再原样输出。
1. 先写代码
我们的第一段代码要设置几个变量,它们是系统调用(syscall)的代号,用来实现键盘输入和屏幕输出,以及在进程退出时向内核发送正确的信号,而不是让内核以为这个程序是出错才退出的。调用它们有点像调用函数,但是和使用C标准库的printf和scanf又不太一样,它们叫做FreeBSD System call,顾名思义是从FreeBSD继承来的。我们马上会看到要如何使用它。
.set SyscallExit, 0x2000001
.set SyscallDisplay, 0x2000004
.set SyscallRead, 0x2000003
第二段要开辟出一个空间,来存放键盘输入的内容
.section __DATA, __data
InputBufferLength:
.quad 0
InputBuffer:
.fill 64, 1, 0x20
InputBufferEnd:
其中.section __DATA, __data
这句话的意思是把这部分代码放进初始化的代码段里。按照x86的汇编风格,一个完整二进制代码分为若干段,有的放指令,有的放数据。对于初始化的数据,值是直接写进二进制代码中的,而不是在二进制代码运行的时候才写入。另外还有.bss段,是未初始化的代码,那么C语言中的static
变量如果没有赋值,变量的地址都会被安排在.bss段里。
凡从一行开始忽略起始空格,以字母开头冒号结尾的都叫做语句标号,它实际指向一个地址,譬如上面代码中的InputBufferLength
就是一个地址。这个标号不会出现在指令中,也不占用代码的空间(这么说不严谨,因为出于调试的需要,标号和地址的对应关系默认会保存在二进制文件里,但是在编译时可以选择去掉来减少二进制的大小,并不会影响运行)
.quad 0
是编译器宏,表示生成8个字节长的数字,而它的值是0。所谓宏就是编译时的逻辑,只是针对二进制本身的操作,不会影响到二进制的运行时。编译器的宏非常强大,它本身是一套图灵完备的语言,而它操作的对象是未来要运行的二进制代码。其实有点像HTML的模版语言,如jekyll或EJS。未来我们还会接触到更强大的宏。最起初的.set
指令也是宏,SyscallDisplay等名称也不会保存在二进制中。
下一部分是InputBuffer
是实际的记录键盘输入的区域,.fill
代表在接下来的64次重复中,把0x20这个值填入1个字节里。
最后一个标号不指向数据,而是用来计算buffer长度。另外语句表号其实很大程度上可以代替注释的功能,所以要善于使用。那么我们所需的数据就到此为止,接下来是代码段。
.section __TEXT, __text
.globl _main
由于我们使用gcc (llvm-gcc)来编译,main是默认的程序入口名称。所以我们把`_main
声明为一个全局标签,这样编译器就会去找那个_main
的标签,以它作为程序的入口逐条执行指令。
接下来是两段宏定义,让大家久等了,我们终于见到了实际的代码。然而需要注意的是,严格来说它们仍不是实际的代码。因为如果不引用这些定义,它们不会出现在编译后的二进制中。
.macro Print
movq $SyscallDisplay, %rax
movq $(1), %rdi
leaq InputBuffer(%rip), %rsi
movq InputBufferLength(%rip), %rdx
syscall
.endm
.macro ScanInputBuffer
movq $SyscallRead, %rax
movq $(1), %rdi
leaq InputBuffer(%rip), %rsi
movq $(InputBufferEnd - InputBuffer), %rdx
syscall
movq %rax, InputBufferLength(%rip)
.endm
在以上两段宏定义中,我们看到了最初定义的syscall是如何使用的。在编译时它们会被替换为那些数字。所有%开头的都是寄存器,就是在高级语言中不会直接接触的存储单元。x86_64有16个通用数据寄存器可以直接使用,具体可自行维基。还有很多更专一功能的寄存器,我们会在很久以后才会遇到。
Print
做了这样几件事。当执行syscall
时,它先看%rax
寄存器中的编号来决定是哪个system call,%rdi
中存储的是退出代码,放1代表结束call时是成功退出的。
接下来的两个内容比较关键,均涉及到牛逼而复杂的概念。第一个是leaq
指令,它做的事情是将第一个argument的有效地址丢到%rsi
里。我们刚才提到InputBuffer
不是已经是个地址了么?我在这里需要声明一下,编译器会通过各种方式来使用这个地址,在不同场合它的值其实是不同的。
InputBuffer(%rip)
是一个典型的offset(base-addr)
的寻址方式,具体内容可参考这里。而在这里的特别之处是,%rip
是指令指针寄存器,当汇编器遇到label(%rip)
这种用法时,它不代表从段起始地址到标号的偏移,而是当前指令的地址到那个标号的偏移。因为%rip存储着当前指令的地址,所以%rip的地址加上偏移就能定位到那个标号所对应实际内存的地址。
movq
和leaq
不同的是,它不是把地址丢进后面的寄存器,而是把地址上对应的内容丢进去。syscall指令把四个寄存器的内容作为参数,输出以%rsi
所存内容为起始地址的,以%rdx
所存内容为长度的字符串。对比来看,我们看到下面ScanInputBuffer
中所使用的寄存器及用法也都是一样的。这里我们会遇到写汇编需要在头脑中保持清醒的事情,就是寄存器的数据不区分是数据还是地址,按着不同的寻址方式,寄存器的内容既可以按数据来使用,也可以按地址来使用。所以写代码的时候要保持头脑清醒。
最后我们终于进入了实际执行的代码:
_main:
ScanInputBuffer
Print
movq $SyscallExit, %rax
syscall
在这里我们像函数调用一样使用了两个宏定义,但事实上编译器做的工作是把代码插了进去。因此上面的宏定义内的代码对寄存器的影响会持续下去。最后我们使用了三个system call的最后一个,也就是退出。
好了,我们今天要完成的全部代码都在这里:
.set SyscallExit, 0x2000001
.set SyscallDisplay, 0x2000004
.set SyscallRead, 0x2000003
.section __DATA, __data
InputBufferLength:
.quad 0
InputBuffer:
.fill 64, 1, 0x20
InputBufferEnd:
.section __TEXT, __text
.globl _main
.macro Print
movq $SyscallDisplay, %rax
movq $(1), %rdi
leaq InputBuffer(%rip), %rsi
movq InputBufferLength(%rip), %rdx
syscall
.endm
.macro ScanInputBuffer
movq $SyscallRead, %rax
movq $(1), %rdi
leaq InputBuffer(%rip), %rsi
movq $(InputBufferEnd - InputBuffer), %rdx
syscall
movq %rax, InputBufferLength(%rip)
.endm
_main:
ScanInputBuffer
Print
movq $SyscallExit, %rax
syscall
2.再写Makefile
我们先写一个简单的,日后再往进添加功能
all: Main.s
cc $^ -lc -o exor
clean:
rm exor
3. 运行
这里包含了我们日后要往Makefile里添加的东西,可以先忽略。内容大致是代码段的代码和数据段的数据。
我们可以看到结果。
4.小结
我们今天看到了syscall的用法,看到了不同的寻址方式,以及一个完整的用汇编写一个程序的流程。这是我们接下来学习的基础,祝大家玩得开心。
网友评论