在OS X上玩x86_64匯編: Day 1

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的用法,看到了不同的尋址方式,以及一個完整的用匯編寫一個程序的流程。這是我們接下來學習的基礎,祝大家玩得開心。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容