在OS X上玩x86_64汇编: Day 1

96
陶马文
2016.03.21 12:29* 字数 1947

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的地址加上偏移就能定位到那个标号所对应实际内存的地址。

movqleaq不同的是,它不是把地址丢进后面的寄存器,而是把地址上对应的内容丢进去。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的用法,看到了不同的寻址方式,以及一个完整的用汇编写一个程序的流程。这是我们接下来学习的基础,祝大家玩得开心。

All about computing
Web note ad 1