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