256 views
ASM:EASM web aside === ###### tags: `csapp`, `inline-assembly` contributed by 陳則仁 Source: http://csapp.cs.cmu.edu/3e/waside/waside-embedded-asm.pdf 這篇文章是對CMU教材文章做的簡單翻譯。 # 前言 本文是CSAPP3e的補充教材,欲知更多詳情,請洽 csapp.cs.cmu.edu 本文件對外開放,您可以在著名出處的情況下任意複製散佈此文件。 # 正文開始 曾經有段時間程式都是用組語寫的, 大型程式如作業系統也是在沒有高階語言可用的情況下開發出來。這導致開發出來的程式變得非常複雜。因為組語沒有幫你作型別檢查, 你很可能會犯下一些手殘的錯誤像是:把pointer的位置當成整數、而非用來取值的位置。更糟的是寫出來的程式被限制在單一種架構/ISA上, 把程式改寫到另一種ISA架構的機器上跟重寫沒什麼兩樣。 早期編譯器技術沒法把高階語言轉換成有效率的組合碼,而且沒有提供存取系統軟體工程師需要的低階(low-level data representation)資料的方式。當我們需要更好的執行效能、或是存取低階(low-level)資料結構的時候,常常是需要用組語寫的。現今編譯器最佳化技術已經將大部分需要以效能為由寫組合語言的理由排除了。優質的編譯器產生的程式碼通常是跟人寫的一般般好、有些情況甚至更好。C語言也提供了存取低階資料結構的介面,讓寫組合語言的機會變得更少。C語言裡的pointer, union, 及bit-level運算提供了大部分工程師所需的工具。舉例來說,現代的作業系統如Linux, Windows, MacOS,幾乎所有的程式碼都是用C寫的。 然而,即便如此還是有必須要寫組合語言的時機,尤其是在要實作作業系統的時候。舉例來說,開發作業系統需要存取部分存著process資訊的特殊暫存器、操作輸入輸出時需要的特殊的指令跟特定記憶體區域、還有即便是開發應用程式,電腦都有一些特定的功能如condition code,沒辦法直接透過C語言來存取。 於是乎我們的挑戰變成要將組合碼整合到大部分由C寫成的程式碼中。這文件裡我們會介紹兩招:一是把幾個重要的函式用組合碼來寫,後讓連接器(linker)來幫我們把兩邊連結成一支程式。這招對簡單的函式比較有用、而且完全沒用到GCC的任何專屬功能。另外一招是將組合碼嵌入到用C寫的函式中。GCC支援行內組語(inline assembly)。 透過使用asm指令,工程師可以把想要的組合碼插入到編譯器產生的程式碼中。他還提供介面讓工程師設定編譯器產生的程式碼該如何跟組合碼介接。雖然最終結果還是讓產生出來的程式被限制在只能在特定機器下執行,而且這個GCC的特異功能不一定有被其他編譯器支援。but we will see, for example, that it is often possible to have inline assembly that compiles properly on both IA32 and x86-64 machines. GCC特有的asm指令會導致與其他編譯器的不相容。但這還是一個將平台相依的組語(machine-dependent)數量降到最少的好方法。 這份文件基於GCC的官方文件寫成[2],這是一份官方的權威參考資料文件,但也是省話一哥,範例很少。 # Program Example 接下來的介紹我們都將以不同方式來實作以下兩個函式,過程中我們學到如何存取現實世界中常需要用到的狀態碼(condition code),這將讓我們有監看處理器的運行狀態的超能力。 ```clike= /* Multiply x and y. Store result at dest. Return 1 if multiplication did not overflow */ int tmult_ok(long x, long y, long *dest); /* Multiply x and y. Store result at dest. Return 1 if multiplication did not overflow */ int umult_ok(unsigned long x, unsigned long y, unsigned long *dest); ``` 這兩個函式都是計算兩者相乘的結果,並將結果存到dest指定的記憶體位置。回傳值在相乘溢位時為0, 代表我們需要比64 bits更多的空間來儲存計算結果,其餘回傳1。因為有號數及無號數的溢位條件不同,所以有兩個函式。課本中我們也有探討如何寫C語言判斷相乘溢位(練習2.35, 2.36),但用C會需要額外的指令來檢查相乘結果。 從x86的文件可知,`mull`及`imull`都會在溢位的時候設置carry flag `CF`。所以我們可以直接在相乘指令後檢查flag, 簡單達成我們的檢驗目標。 # 3 Handwritten Assembly-Code Functions 即便寫組合語言是令人生畏(daunting)的事情,我們可以先從把一個簡化的函式寫在個別的檔案開始著手。例如p1.c放C code, p2.s 放組語, 然後用以下指令編譯: `linux> gcc -o p p1.c p2.s` 這個指令會把p1.c 編譯, 然後將p2.s組譯,兩個object會在被連結成執行檔p 為了要讓組譯器產生linker需要的資訊,我們必須把函式宣告為全域(global)。然而在C語言裡,除了static以外的函式全是全域的。組譯器會假設檔案裡的函式只能被同一個檔案存取,如果我們有個組合語言的函式`fun`,那麼我們就應該在前頭加` .global fun` 我們發現即便是用組合語言寫函式,最好還是讓GCC盡可能地發揮作用。因此,我們將先用C語言寫功能類似的函式,用GCC的`-S`選項產出組合碼。這份組合碼裡GCC已經幫我們把提取參數、管理stack等的部份做好,比起一行一行柯,我們拿來這份來改會輕鬆很多。 ```clike= int tmult_ok1(long x, long y, long *dest) { long result = x*y; *dest = result; return result > 0; } ``` 這個函式裡面大抵上包含了tmult_ok需要的功能,他把x, y相乘、結果存在dest,並基於計算結果回傳0或1。唯一的缺點就是第五行的檢查項並不是我們要的。 GCC為上述函式生成了以下的組合碼(這是用 gcc -S -Og編譯出來的)。相較於csapp書中常用的格式,我們這裡把原汁原味的組合碼貼出來,方便之後直接修改。 ```clike= .globl tmult_ok1 .type tmult_ok1, @function tmult_ok1: .LFB0: .cfi_startproc imulq %rdi, %rsi movq %rsi, (%rdx) testq %rsi, %rsi setg %al movzbl %al, %eax ret .cfi_endproc ``` 注意到第二行有宣告前面提到的`.globl`。這份code裡面我們要改的只有兩行:第五行基於64bit x, y相乘結果設置狀態碼(condition code)的地方,我們要直接靠`imulq`指令設置狀態碼、第六行原是基於zero, sign flag來設置`%eax`低位位元組(lower-order byte),我們要改成用carry flag. 從課本的圖3.14我們可以找到`setae`能被拿來設置暫存器的lower-order byte, 當carry flag被設定的時候register設為0, 反之則為1. 有了這些資訊之後我們可以開始動手將組合碼修改成我們要的樣子. ```clike= # Hand-generated code for tmult_ok .globl tmult_ok_asm # int tmult_ok_asm(long x, int y, long *dest); # x in %rdi, y in %rsi, dest in %rdx tmult_ok_asm: imulq %rdi, %rsi movq %rsi, (%rdx) # Deleted code # testq %rsi, %rsi # setg %al # Inserted code setae %al # Set low-order byte # End of inserted code movzbl %al, %eax ret ``` 上面我們的修改中可以看到, 適時的註解可以幫助我們理解組合碼. `#`符號右邊的文字都會被組譯器當成是註解. 比較不妙的是, 上面的組合碼並不能用在無號(unsigned)運算`umult_ok_asm`的實作上,所以我們再準備了一版C程式碼. ```clike= /* Multiply arguments and indicate whether it did not overflow */ int umult_ok_asm(unsigned long x, unsigned long y, unsigned long *dest) { unsigned long p = x * y; *dest = p; return p > 0; } ``` GCC會產生如下的組合碼: ```clike= .globl umult_ok_asm umult_ok_asm: imulq %rdi, %rsi movq %rsi, (%rdx) testq %rsi, %rsi setne %al movzbl %al, %eax ret ``` 產出來的程式跟有號(signed)版本幾乎是一樣的! 尤其是它仍是使用有號數乘法的指令`imulq`, 記得我們在課本第二章裡面討論到, 有號數/無號數相乘的結果在low-order的64個位元都是相同的, 所以GCC輸出的程式碼是正確的, 但這裡跟前例一樣沒設置溢位狀態碼. 我們必須用無號乘法指令`nulq`來寫code, 但是這個指令只接受單一運算元(見課本圖3.12), 這個指令的另外一個乘數必須存在`%rax`, 而其運算結果上半部是存在`%rdx`(high-order bytes)、下半部是在`%rax`(low-order bytes)裡. 使用這個指令我們必須要改很多地方,結果如下: ``` # Hand-generated code for umult_ok .globl umult_ok_asm # int umult_ok_asm(unsigned long x, unsigned long y, unsigned long *dest); # x in %rdi, y in %rsi, dest in %rdx umult_ok_asm: movq %rdx, %rcx # Save copy of dest movq %rsi, %rax # Copy y to %rax mulq %rdi # Unsigned multiply by x movq %rax, (%rcx) # Store product at dest setae %al # Set low-order byte movzbl %al, %eax ret ``` 這兩個例子裡我們可以了解到, 先前花時間學習組合語言然我們現在有能力可以寫出自己的組合語言函式. # 4 Basic Inline Assembly 使用行內組語(inline assembly)可以讓GCC幫我們分擔更多整合組合碼到現有程式的工作.行內組語的基本形式看起來像是一般的程序呼叫 `asm( code-strings );` code-strings 這個辭語代表的是一或多段雙引號框住的組合語言程式碼, 編譯器接下來會逐字將組合碼插入到C產生出來的組合碼: ```clike= /* First attempt. Does not work */ int tmult_ok1(long x, long y, long *dest) { long result = 0; *dest = x*y; asm("setae %al"); return result; } ``` 這裡的策略是利用`%eax`是用來儲存函式回傳值的這個特性, 用它來存變數`result`, 我們計劃讓第4行的C程式碼將register設為0, 接著GCC會為我們插入組語, 並適當地設定`%eax` 的low-order byte, 並將其當成回傳值. 但悲劇的是, 產生出來的組合碼並不如預期, 測試中函式每次都回傳0. 將產生的組合碼打開來看我們可以看到: ``` int tmult_ok1(long x, long y, long *dest) # x at %rdi, y at %rsi, dest at %rdx tmult_ok1: imulq %rsi, %rdi Compute x * y movq %rdi, (%rdx) #Store at dest # Code generated by asm setae %al #Set low-order byte of %eax # End of asm-generated code */ movl $0, %eax #Set %eax to 0 ret ``` 看來GCC 對於生成程式碼有他自己的的想法, 比起在函式開頭時將`%eax`設為0, 程式在近尾聲(第五行)才做這件事, 導致函式總是回傳0.基本上這裡的問題是, 編譯器不知道程式設計師的意圖, 以及產生的程式碼該如何和行內組語做介接. 顯然我們需要一個更複雜的機制來做嵌入組合碼到C程式碼裡面. # 5 Extended Form of asm GCC提供程式設計師擴充的行內組語語法, 行內組語和原有的程式介接的資訊,包括哪些C裡面的變數會被拿來當組語的運算元, 有了這些資訊編譯器就可以產出正確配置來源數值, 執行組語, 並使用其計算結果.他也包含需要的暫存器資訊, 讓重要的程式數值不被組合碼複寫. 擴充的行內組語(inline assembly)語法如下: ``` asm( code-strings [ : output-list [ : input-list [ : overwrite-list ] ] ] ); ``` 方括號代表任選引數(optional argument), 輸出輸入列表是逗號分隔的列表, 列表內的各個成員內有三個原件: `[name] tag (expr)`, 給定運算元的名稱, 輸出限制, 以及表示輸出目標位置的C表式.`tag`這個欄位是為了要指定使用輸出運算元的限制, 格式如下雙括號內所列 ## Constraint Meaning * "=r" Update value stored in a register * "+r" Read and update value stored in a register * "=m" Update value stored in memory * "+m" Read and update value stored in memory * "=rm" Update value stored in a register or in memory * "+rm" Read and update value stored in a register or in memory 至於 `expr`這個表示式則可以放任何可指定數值(assignable value), C的左值/載入值(lvalue), 編譯器會產生其需要的程式碼來執行賦值動作。 輸入列表的項目除了tag需要換成"r", "m", or "rm"之外,格式跟輸出是相同的。這三個tag分別指出運算元將會從暫存器、記憶體或是其中任一項讀取資料。每個輸入的運算元都可以是C的表示式。同樣的編譯器會依據輸入列表產生對應的程式碼以求出表示式之值。重寫列表(overwrite list)則是由逗號分隔、雙括號包起來的暫存器名字組成。 作為說明,以下是使用行內組語指令的擴充機能,設定了輸出至result變數,改良版的`tmul_ok`。 ```clike= int tmult_ok2(long x, long y, long *dest) { int result; *dest = x*y; asm("setae %%bl # Set low-order byte\n\t" "movzbl %%bl,%[val] # Zero extend to be result" : [val] "=r" (result) /* Output */ : /* No inputs */ : "%bl" /* Overwrites */ ); return result; } ``` 我們可以看到這段組語有兩行程式碼(code strings),一行是`setae`指令(行5), 一行是將上行的結果低階位元組的高階位元填0作為回傳值(行6)。我們選用`%bl`作為`setae`指令的賦值目標,並作為`movzbl`指令的輸入資料。暫存器運算元在inline assembly需要被寫成`%%bl`, 注意到我們可以加註解,柱姐除了最後一行之外都必須由加`\n\t`。我們給了`val`這個名字來表示組語最後的計算結果。輸出列表(行7)把這段組語的輸出對應到了程式的變數`result`。我們也在重寫列表(overwrite list)(行9)標出了暫存器`%bl`將會被我們的組語改動。 當我們將這段程式碼以x86-64作為目標平台編譯,編譯器將會產生如下的組語: ```clike= #int tmult_ok2(long x, long y, long *dest) #x at %rdi, y at %rsi, dest at %rdx tmult_ok2: pushq %rbx #Save %rbx imulq %rsi, %rdi #Compute x * y movq %rdi, (%rdx) #Store at dest # Code generated by asm setae %bl #Set low-order byte movzbl %bl,%eax #Zero extend %eax # End of asm-generated code popq %rbx #Restore %rbx ret ``` 我們可以看到這段程式將`%rbx`暫存到stack(行2),並在組語結束時復原了舊值(行7)。這個暫存器是被呼叫程式要負責儲存(callee-saved),因為我們已經在行內組語擴充設定裡表明了我們會改到這個暫存器,所以GCC就會幫我們產生保存該數值需要的程式碼。 我們可以透過GCC處理不同資料型別的功能再進一步的簡化、改善現有的程式碼。GCC基於運算元的型別資訊來判斷那種暫存器該被拿來替換擴充行內組語裡的運算元(operand)。從上版的tmult_ok2可以看到,因`result`這個變數型別是`int`,GCC用了32位元的暫存器`%eax`。另一個作法是,我們可以在輸出列表裡用`unsigned char`型別的變數`bresult`,並把他作為`setae`指令的輸出運算元: ```clike= /* Uses extended asm to get reliable code */ int tmult_ok3(long x, long y, long *dest) { unsigned char bresult; *dest = x*y; asm("setae %[b] # Set result" : [b] "=r" (bresult) /* Output */ ); return (int) bresult; } ``` 編譯器將會使用單一位元組暫存器(single-byte register)作為`setae`指令的輸出及`movzbl`的輸入運算元,來完成`bresult`變數的轉型。這個版本裡我們不需要給定暫存器名稱、重寫列表,編譯器會幫我們打理好這些事。 寫`umult_ok`的組語相對需要考量較多。我們必須要配置好`mulq`指令(只有一個運算元)需要資訊並到該指令的指定位置讀取結果,程式碼如下: ```clike= int umult_ok(unsigned long x, unsigned long y, unsigned long *dest) { unsigned char bresult; asm("movq %[x],%%rax # Get x\n\t" "mulq %[y] # Unsigned long multiply by y\n\t" "movq %%rax,%[p] # Store low-order 8 bytes at dest\n\t" "setae %[b] # Set result" : [p] "=m" (*dest), [b] "=r" (bresult) /* Outputs */ : [x] "r" (x), [y] "r" (y) /* Inputs */ : "%rax", "%rdx" /* Overwrites */ ); return (int) bresult; } ``` 這段程式碼運用了擴充行內組語的許多功能。兩個輸出運算元命名為p(乘積), b(狀態位元),而兩個輸出運算元則是x, y。我m份可以看到輸出列表內看到運算元p被設定為存至記憶體(=m)並和表示式`*dest`連在一起。而b則是跟區域變數`bresult`一塊,並被設定為"將存至暫存器"(=r)。我們還需要在重寫列表裡加入`%rax`, `%rdx`。以下可以看到編譯器是如何處理這段行內組語: ```clike= umult_ok: # int umult_ok(unsigned long x, unsigned long y, unsigned long *dest) # x at %rdi, y at %rsi, dest at %rdx umult_ok: movq %rdx, %rcx #Save dest #Code generated by asm movq %rdi,%rax #Get x mulq %rsi #Unsigned long multiply by y movq %rax,(%rcx) #Store low-order 8 bytes at dest setae %dil #Set low-order byte # End of asm-generated code movzbl %dil, %eax #Zero-extend result ret ``` 我們可以看到GCC分配了`%rdi`給變數x, `%rsi`給變數y, `%dil`給變數b. # 6 Concluding Remarks 結論 我們探討了兩種將組語和C程式碼結合, 產出程式的方法. 將整個函式用組語寫在個別的檔案裡,利用了我們既有的熟悉的技術: 組譯器, 鏈接器。使用GCC的功能來插入組語到C函式則是可以大幅減少寫給特定架構組語的數量。 即便asm指令有點難懂(arcane), 而且他讓程式變得比較不能跨平台編譯,在我們需要組語存取底層資訊的時候他可以讓組語變得較少,非常有用。過程中我們也發現要達到我們要的結果需要一定成都的試誤(trail and error)。最好的策略是先用`-S`命令列選項編譯程式,看看產出的組語是否符合預期。還要注意一件重要的事:GCC處理asm指令時不會判斷裡頭組語語意,他只是按照語法規則及設定把運算元及變數名稱替換成暫存器名稱及記憶體參照。產出的程式應該要在不同的最佳化設定下經過妥善測試。 內文中也提供了幾個練習題跟解答: **候補** TODO * volatile? * 練習題 * 再修一版, 調整的更好讀. # 參考資料 [1] Jr. F. P. Brooks. The Mythical Man-Month, Second Edition. Addison-Wesley, 1995. [2] GCC Online Documentation. Available at http://gcc.gnu.org/. # 其他補充: Clang對於inline assembly的支援 https://clang.llvm.org/compatibility.html#inline