Trio Played
在之前的 文章 中已經介紹組合如何轉變成機械語言,而這邊文章則是介紹如何使用組合語言, 以及使用組合語言來完成需要的邏輯。
Subroutine
一個語言都會帶著抽象的邏輯來重複表達一個共通的邏輯以達到節省的目的。這個區塊邏輯我們稱之為子程式 或者 subroutine 。在極端的 例子 中,可以重複使用 Buffalo 來表達複雜的邏輯。 然而這個例子過於極端 (因為一個字同時代表若干種不同的邏輯),為了簡化問題, 程式語言會利用不同的參數帶入到邏輯中來得到不同的結果。
在組合語言中可以使用 jmp 來跳轉到特定的指令,這種做法我們可以產生一個區塊的 code 包含特定的邏輯 ,當需要使用這個邏輯的時候就可以使用 jmp 來使用:在下面的例子中,利用 jmp 跳轉到 0x012345
mov rax, 0x123
jmp 0x12345
add rax, 0x123
sub rcx, 0x222
mul rax, 0x04 ; 0x012345
sub rax, 0x04
但是為了使用完之後的方便性需要回到原本呼叫的位址 (上述的例子就是 add rax, 0x123), 因而紀錄跳轉前的位址是必須的,因而會得到下述的狀況:
mov rax, 0x123
push rip ; save RIP
jmp 0x12345
add rax, 0x123
sub rcx, 0x222
mul rax, 0x04 ; 0x012345
sub rax, 0x04
然而這是一個十分常用的狀況,而且因為不同的狀況 (可能是 jmp、je、jz 等不同跳轉指令), 所以提供複合指令 call 來同時滿足兩個動作:push rip (下個指令的 RIP) 以及跳轉到指定的位址。 相對的,則是利用 ret 來回到呼叫 call 的下一個指令,也就是 pop rip 後在 jmp 到 rip 的位址。
Prologue / Epilogue
因為抽象化的概念出現,區段程式碼很有可能代表著一個完整的抽象邏輯,因此隨時都有機會被呼叫 (call)。 在這種情況下,很容易會有 register 污染的狀況發生。例如有一個邏輯是將 rax + 1,因此每呼叫一次就會將 rax 增加 1:下面的邏輯就是重複呼叫 inc rax 的邏輯
inc rax ; 0x012345
mov rcx, 0x01
ret
-> mov rax, 0x04
call 0x012345
mul rax, 0x04
call 0x012345
sub rax, 0x05
然而在上述的例子中,每次呼叫設計的邏輯時,都會將 rcx 重新設值為 1,這導致如果在呼叫後還需要 rcx 的值則有 register 污染的狀況發生。為了避免這樣的狀況,則會有兩種解決方式:分別站在 caller 與 callee 角度
- 在邏輯內,將暫時會被污染的值先儲存起來。也就是先 push rcx,並在結束後 pop rcx。
- 在 call 邏輯前,先將以知道會被污染的值儲存起來,結束後在儲存回去。
因此在 x86 的環境下就有支援 pusha 將所有 registers 都儲存起來,並且可以等到 subroutine 結束後使用 popa 將 registers 恢復到原本的樣子。然而在 x86-64 的狀況下因為提供的 register 太多,因而將這個邏輯交由 Programmer 決定。在 subroutine 中可能會使用到超過 register 數量的狀況或需暫存資料 (也就是 local variable )。在這種狀況下就需要有空間拿來儲存這些值。實作上,會將不敷使用的變數塞到 memory 當中,也就是特定的記憶體空間:可以是直接指定的 (例如 [rax]) 或者是相對的記憶體空間 (push rax)。 為了避免污染到其他的 local variable,在進入 subroutine 之後會重新定義自己可以使用的 stack 空間:
push rbp ; prologue
mov rbp, rsp
inc rax ; 0x012345
mov rcx, 0x01
pop rbp ; epilogue
ret
-> mov rax, 0x04
call 0x012345
mul rax, 0x04
call 0x012345
sub rax, 0x05
在上述的例子中,在進入 subroutine 的會將 callee 的 stack 的 rbp (stack base pointer) 儲存起來, 比且在 subroutine 中將 rbp 設定為最後 stack 可以使用的位址 (rsp)。當結束後則恢復原本的 stack 狀態, 讓 callee 的 local variable 不因此被污染。
Variable
在上個章節中,有提到 local variable 的概念。在組合於言當中並沒有變數的概念,而是讓 Programmer 定義了 register 以及 memory 所代表的意義:所有儲存與表達的都是利用這兩個方式來儲存資料。register 會因為 architecture 不同而有不一樣的大小,例如 x86-64 的平台中,register 可以從 8bits (1 bype) ~ 64bits (8 bytes) 都可以儲存,而 memory 則是用來儲存 register 的空間。
在這個概念下,變數都會被視為是 register 的一種呈現方式,舉例來說可以被視為帶號 (sign) 或非帶號 (unsign) 的 數字 、用來顯示的 ASCII 或 Unicode 字符,或者是 subroutine 所在的位址。 因此如何解讀 register 代表的意義則是由 Programmer 來決定。在下面的例子中,都是將變數放置在 rax 當中,並且呼叫兩個各自 subroutine 當中,在第一個例子中則是用來表達 ASCII 中的換行符號 (’\n’) ,而第二個例子卻是用來表示函數結束的回傳值 (0x20)。
push rbp ; prologue 0x111111
mov rbp, rsp
mov rsi, rax
mov rax, 0x200005
mov rdi, 0x01
mov rdx, 0x01
syscall ; write(stdout, rax, 1)
pop rbp
ret
...
push rbp ; prologue 0x222222
mov rbp, rsp
mov rdi, rax
mov rax, 0x200001
syscall ; exit(rax)
pop rbp
ret
-> mov rax, 0x20
call 0x111111
mov rax, 0x20
call 0x222222
在更複雜的例子中,則會用 stack 空間來呈現一個複雜的物件:在下面的例子中,會先將 rsp 設定為新的 stack 起始點並且往後移動 0x08 bytes,接下來前面 4 bytes 塞入 object 的名稱、 後面 4 bytes 則是塞入一個 member function 的位址 (也就是另一個地方的 subroutine)。 最後呼叫 0x111111 得時候就會產生一個暫時存在的變數。並且當 subroutine 結束的時候, 變數自然地消失 (再也沒有方式來解讀這塊 memory 資料所代表的含義)。
push rbp ; prologue 0x111111
mov rbp, rsp
sub rsp, 0x08 ; allocate statck size
mov [rbp-0x04], 0x466F6F00 ; object name - Foo
mov [rbp-0x08], 0x00222222 ; member function
pop rbp
ret
...
push rbp ; prologue 0x222222
mov rbp, rsp
mov rdi, rax
mov rax, 0x200001
syscall ; exit(rax)
pop rbp
ret
-> mov rax, 0x20
call 0x111111
全域變數 (global variable) 則是代表 memory 空間中的資料,在任意地方都可以被解讀的意思: 也就是所有 subroutine 都可以存取到這個位址 (代表是利用絕對位址) 以及解讀。
Conditional Branch
這個部分則是有趣的應用:會根據條件的不同而有不同的邏輯。簡單來說,會根據運作當下的條件 (test expression) 來判斷是否需要執行接下來的邏輯。在這個需求下,會需要做到兩個功能:
- 執行條件式,並且得到結果 (假設將結果放到 rax)
- 根據結果決定是否執行邏輯或者跳轉到下一個邏輯區塊
在條件式當中邏輯的操作並不困難,主要利用 je、jne 等方式來操控
-> mov rax, 0x03 ; save the test expression
xor rax, rax ; check the test-expr is true (1) or false (0)
je 0x12 ; jump to next block if false (zero)
mov rdi, 0x03 ; condition scope
mov rax, 0x2000001
syscall
ret ; end of condition branch
而稍微複雜的例子就是 if-else 的條件式利用。在這種狀況下就需要區分為兩個區段並產生 2 個邏輯區塊來跳轉:除了最後一個條件區塊外,都需要在結束後離開整個條件區塊, 避免執行到不相干的邏輯。
-> mov rax, 0x03 ; save the test expression
xor rax, rax ; check the test-expr is true (1) or false (0)
je 0x19 ; jump to next block if false (zero)
mov rdi, 0x03 ; condition scope for true
mov rax, 0x2000001
syscall
je 0x12 ; exit condition scope
mov rdi, 0x03 ; condition scope for false
mov rax, 0x2000001
syscall
ret ; end of condition branch
而更特殊的 if-elif-else 條件式或 switch 條件式都是進階的使用方式, 多重的使用條件判斷來執行不同的邏輯。在 if-elif-else 的狀況下,也是一種進階的 if-else 使用方式, 產生的組合語言邏輯並沒有兩樣:
-> mov rax, 0x03 ; save the test expression
xor rax, 0x03 ; check the rax == 0x03 : true (1) or false (0)
je 0x19 ; jump to next block if false (zero)
mov rdi, 0x03 ; if-par
mov rax, 0x2000001
syscall
je 0x3B ; exit condition scope
mov rax, 0x04 ; save the another result for the new test expression
xor rax, 0x02 ; check the rax == 0x02 : true (1) or false (0)
je 0x2D ; jump to next block if false (zero)
mov rdi, 0x03 ; elif-part
mov rax, 0x2000001
syscall
je 0x12 ; exit condition scope
mov rdi, 0x03 ; else-part
mov rax, 0x2000001
syscall
ret ; end of condition branch
而在 switch 的使用情況,exst-expr 只會執行一次但會重複的檢查結果是否符合預期, 而非在 if-elif-else 狀況下,可能會執行新的測試 (test-expression)。
-> mov rax, 0x03 ; save the test expression
xor rax, 0x03 ; check the rax == 0x03 : true (1) or false (0)
je 0x19 ; jump to next block if false (zero)
mov rdi, 0x03 ; case for 0x03 part
mov rax, 0x2000001
syscall
je 0x34 ; exit condition scope
xor rax, 0x02 ; check the rax == 0x02 : true (1) or false (0)
je 0x2D ; jump to next block if false (zero)
mov rdi, 0x03 ; case for 0x02 part
mov rax, 0x2000001
syscall
je 0x12 ; exit condition scope
mov rdi, 0x03 ; default poart
mov rax, 0x2000001
syscall
ret ; end of condition branch
Object
這個章節提到更加實際的應用:產生一個物件。物件在高階語言當中代表著一個 封裝 後的邏輯。 這裡的封裝不一定指的是 物件導向 (OO) 的封裝,而是泛指將一堆複雜邏輯集中在一個變數上, 因此就有了 First-Class Object 的稱呼,這表示物件可以用來:
- 存入任何變數或者其他結構
- 當作參數傳遞給其他函數
- 可作為函數的回傳值
- 可以在執行階段產生
而物件的宣告方式又分為靜態與動態兩種:物件在動態時間產生時會在 ASM 的環境中, 呼叫特定的邏輯 (subroutine) 並在不特定的位址中建構物件並寫入特定格式的資料。 這些資料是物件所帶有的 屬性 (Properties) 與 方法 (method) 。靜態宣告,則是在 編譯時間 (compile time) 直接將物件宣告在變數區塊當中。
-> mov rax, 0x40
call &buffer ; create buffer with size = 0x40 and save on rax
mov [rax-0x08] &FooConstruct ; set the methods
mov [rax-0x10] &FooDestruct
mov [rax-0x18] 0x04 ; set the properties
lea [rax-0x20] &id
call [rax-0x08] ; call the construction
Indexing
另一種存取記憶體的使用方式則是 indexing (索引)。在高階語言中,都會封裝陣列或者等價的概念讓 programer 方便存取其中的元素。用最簡單的方式來描述,陣列就是依序排列的 registers 並且陣列本身具有邊界, 也就是當超出邊界存取就會發生 SEGFALT 的例外狀況。 在 Intel / AMD64 架構中記憶體間的是不建議直接做搬移,如果要搬移記憶體間的內容就需要用 registers 做陳接。 實際上有提供類似的指令集 ( movs ) 但大致上來說都 不建議 使用,除了額外使用到兩個 registers (rsi、rdi) 之外, 效能也沒有使用 register 當作中介來得好 。
在上述的前提下,我們會遇到兩種操作記憶體的狀況:mov mem, reg 以及 mov reg, mem。但實際上使用, 則會有以下幾種邏輯操作:
- 讀取記憶體的內容。mov reg, mem
- 讀取記憶體中代表位址的內容:mov reg, mem 兩次 (第一次讀取位址,第二次才是真實的獲得內容)
- 寫入到記憶體。mov mem, reg
- 寫入到記憶體中代表的位址。mov reg, mem (先獲得位址)、mov mem, reg (在寫入內容)