用MSIL寫程序:寫個函數做加法

前言:

上一篇關於MSIL的文章中我為MSIL正名的篇幅比較多,反而忽略了寫那篇文章初衷--即通過寫MSIL代碼來熟悉它,了解它。那麼既然有上一篇文章做基礎(炮灰),想必各位對MSIL的存在也就釋然了,興許也燃起了一點探索它,掌握它的慾望。那麼我就繼續扯一扯MSIL,接下來的幾篇文章也都以上一篇文章中的那個MSIL實現的Hello Wolrd程序為基礎,繼續通過寫MSIL代碼實現一些功能的方式來和各位探討交流,同時也加深自己對MSIL的掌握和印象。

人生就是做加法

最近我和女朋友登記結婚了,回首往事發現人生就是一個在不斷做加法的過程。因此我決定用MSIL代碼實現一個把2個數相加的功能,讓自己的情愫譜寫在代碼的字裡行間里。

好啦,言歸正傳,其實選擇實現一個加法功能是因為我上一篇文章中舉過一個關於加法的例子,同時上一篇文章也大體上介紹了一下MSIL中如何聲明一個函數。所以趁熱打鐵,一鼓作氣,直接用MSIL實現一個做加法的函數既是對上一篇文章的一個呼應,也能達到本篇文章的目的---聊聊如何用MSIL實現函數。

那麼這個函數的功能呢?簡單的分一下各個功能點,我想到的大概就是這些了:

  1. 顯示「請輸入第一個加數」,並獲取輸入的值,記為num1。顯示「請輸入第二個加數」,並獲取輸入的值,並記為num2。
  2. 將輸入的兩個數,num1和num2相加求和,結果記為result。
  3. 要將結果顯示給用戶,所以我們做的更徹底一點:顯示整個算式:num1+num2 = result。

局部變數

說到函數,就不得不提局部變數了,因為你總得操作一些東西吧?而局部變數正是那些在函數中被你操作的傢伙。

假設我們要實現的函數叫做AddLife好啦。AddLife需要實現將我們輸入的2個數字相加,並且將「和」作為結果返回。所以局部變數的個數是3個。

我們的局部變數的聲明如下:

.locals init (int32 num1,n int32 num2,n int32 result)n

在這裡:

  1. 我們使用.locals指令標識我們要聲明的是局部變數。
  2. init標識我們所聲明的3個int32型變數都被初始化為int32型的默認值,當然如果我們聲明的不是int32型的變數,則被初始化為該變數相應類型的初始值。
  3. int32,代表變數的類型
  4. 當然,在CIL代碼中我們也可以不寫變數的名字num1,num2以及result。因為你可以通過它們的索引獲得它們,這裡寫它們的名字僅僅是為了易讀。

OK,我們聲明了函數中的局部變數。可之後呢?這畢竟不是C#或者別的高級語言啊,否則也不會有很多人討厭去讀它更別說寫它了。所以,這裡我要對MSIL的執行做個小說明,以便各位不和我產生較大的分歧。

基於堆棧記心頭

首先的一點,就是MSIL是基於堆棧的。也就是說它的執行離不開堆棧。所以要搞清楚MSIL的執行,就是要搞清楚MSIL到底是如何使用堆棧的。

因為是基於堆棧的,所以MSIL指令所取的值來自堆棧。也就是說,值的傳遞要經過堆棧這個「橋樑」。所以當你想要為某個MSIL指令傳值(值類型),所傳的就必須要先入棧,之後該指令再將這個值出棧進而使用。

OK,那讓我更進一步。假如我的MSIL指令是call(或者callvirt,二者區別以後會聊)呢?這需要調用一個函數,那call是如何使用堆棧的呢?

2種情況。

首先,你調用的是某個類的實例函數,那麼就要把調用的某個類的實例的引用壓棧。然後呢?當然如果需要的話,這個函數所需要的參數也要壓棧。在調用函數的過程中,這個實例的引用以及它的參數都會出棧,供MSIL指令使用。

其二,你需要用一個引用類型作為參數傳入某個方法中。同樣,這個引用類型的參數在被使用之前,也要把它的引用壓棧。

那麼,一直說壓棧和出棧。可處理壓棧和出棧的MSIL指令是啥呢?2個最基本也是本文要用到的:(更具體的可以看匹夫之前的文章)

  1. ldloc,用來做壓棧操作。將變數的值壓入堆棧中。
  2. stloc,用來做出棧操作。將堆棧的值傳入變數中。

之後,各位可能還會注意到上面我加粗的幾個字,如果對所謂的值類型和引用類型有一個簡單的概念的話,是不是覺得恍然大悟呢?

  1. 值類型的值直接存在堆棧上。(當然引用類型,比如某個類的實例中的值類型欄位不再此列,它也會和該類的實例一起出現在堆上,不過這並非我們今天要探討的主要內容)
  2. 引用類型的實例並非存在堆棧上,堆棧上存的是它的引用。

第一個功能:顯示提示輸入加數,並獲取輸入的值

好啦。來到我們要實現的第一個功能了,那就是要顯示「請輸入第一個加數」這樣一個字元串,同時要讀取用戶輸入的數值,然後賦給我們的變數num1。

飯要一口一口吃,碼要一行一行寫。所以就從顯示字元串開始吧。其實我上一篇文章介紹如何輸出一個Hello World的時候,就已經實現了字元串的輸出。那麼讓我們依樣畫葫蘆,輸出「請輸入第一個加數」這樣的語句吧。

//在屏幕上顯示「請輸入第一個加數」nldstr "請輸入第一個加數"ncall void [mscorlib]System.Console::WriteLine(string)n

首先將「請輸入第一個加數」壓棧,然後使用call來調用mscorlib程序集中System.Console類的WriteLine方法,此時屏幕上會顯示「請輸入第一個加數」。

第二步,我們要獲取用戶輸入的值。

//獲取用戶的輸入值ncall string [mscorlib]System.Console::ReadLine()n

調用mscorlib程序集中System.Console類的ReadLine方法,並返回一個字元串。因為返回的是一個字元串,所以我們在將正確的值賦給變數num1之前,還需要對這個字元串進行轉化,轉化成int32的過程如下:

//將輸入的字元串轉化成intncall int32 [mscorlib]System.Int32::Parse(string)n

注意,以上的三步,雖然看上去只有一個ldstr將字元串壓棧,但是其實每一步,每一行都伴隨著壓棧或出棧的過程。簡單的敘述下這個過程是這樣的:

  1. 將「請輸入第一個加數」壓棧。
  2. call調用Write方法,同時會將「請輸入第一個加數」出棧,作為WriteLine的參數。
  3. call調用ReadLine方法,該方法從用戶處獲得一個輸入,並且將該值作為一個string型壓棧。
  4. 由於這個從用戶處得到的string型已經在堆棧中了,所以在最後一個call調用[mscorlib]System.Int32::Parse之前,無需對那個string型壓棧。反而是直接從堆棧中彈出該string值,作為[mscorlib]System.Int32::Parse的參數。之後在將已經轉化為int型的值壓棧。

簡單描述了下過程。在這裡,我只想說:記住,只要涉及到數據,就要用到堆棧。

好啦,完成上面的過程,也就是完成了從用戶處獲取值的過程,此時的值已經躺在堆棧中了。之後,我們還要將這個值賦給變數num1:

//值出棧,賦給局部變數num1nstloc num1n

之後獲取第二個加數的過程就是上面幾步的重複。各位可以自己實現下。

第二個功能:相愛相殺,不對,應該是相愛相加...

好啦,用戶輸入的數值我們已經搞到手了,那麼是不是就該實現這個方法最核心的功能,對2個數相加求和呢?答案是yes。

不過這裡我們會涉及到這篇文章已經介紹過的一個指令----ldloc。顧名思義,ldloc?不就是loadlocal嘛~所以其作用也就十分明了了:使用ldloc我們可以將局部變數num1,num2中的值壓入堆棧,這樣才能供之後使用。

所以我們將值壓棧的語句就是:

//將值從變數中壓入堆棧nldloc num1nldloc num2n

當然在本文一開頭就說過,局部變數什麼的作為我們人類也可以不給它們起名字,只需要使用它們的索引就可以了。所以你也可以這樣寫來實現數值壓棧的過程:

//如果不寫變數名nldloc.0nldloc.1n

反正我是挺討厭這種不是給人看的寫法的。

變數已經躺進堆棧了,那麼下一步呢?求和唄,MSIL可是帶求和指令的哦~沒錯:add

//求和naddn

add指令會將剛剛壓入堆棧的2個值彈出,然後計算和,最後將結果在壓入堆棧中。可是我們還有一個局部變數result沒用呢,所以我們還要將結果賦值給result變數。

//將結果賦值給resultnstloc resultn

最後一個功能,關鍵的其實是裝箱

好啦好啦,求和這個事情其實已經做完了,但是我們總的輸出一點東西好讓用戶看到我們的確已經求過和了。那麼如何實現文章一開始時,我定下的最後一條功能呢?也就是按照」num1 + num2 = result「這個格式顯示結果呢?

首先,我們把顯示的字元串的格式規定好:

//顯示的格式nldstr "{0} + {1} = {2}"n

其次我們要把格式中的{0},{1},{2}替換成具體的數值,或者說」object「。因為我們要調用WriteLine方法,所以這裡就會涉及到一個值類型到object的裝箱的過程。首先我們還是將變數中的值壓棧,之後再對棧中的值進行裝箱。

//將num1,num2,result裝箱,供之後的writeLine使用。nldloc num1nbox int32nldloc num2nbox int32nldloc resultnbox int32n

這裡終於聊到了裝箱這個話題,所以我繼續扯一扯裝箱,也就是box指令在MSIL中的執行過程吧:

  1. 首先將壓棧的值彈出。
  2. 同時在堆上構造一個新的object,並且這個object包含該值類型的值的拷貝
  3. 最後將這個新的object的引用壓棧。

所以,各位是不是覺得C#中的一些概念通過MSIL來看更加直觀呢?其實這也是我對MSIL感興趣的一個原因。

最後一步,就是輸出結果咯,因為3個值類型的值已經裝箱了,所以我們就這樣寫:

//將算式顯示出來ncall void [mscorlib]System.Console::WriteLine(string, object, object, object)n

[mscorlib]System.Console::WriteLine(string, object, object, object)中的第一個參數string代表第一個被壓棧的格式字元串"{0} + {1} = {2}",之後依次是經過裝箱的num1的值,num2的值,result的值。

此時,以上一篇文章中實現Hello World輸出的chen.il文件為基礎,再加入我們的AddLife函數之後,大體上就長這個樣子了(26行之前是上篇文章時寫的代碼):

此時眼尖的小夥伴一定會發現,好像是少了點什麼呀?對,沒有.entrypoint。因為.entrypoint還在上一篇文章中的Fanyou()這個方法里呢。然後呢?還少了.maxstack,因為匹夫光顧著看邏輯了(04/02/15-23:35剛寫完。。。)所以,沒有提前考慮到底需要多大的堆棧槽。那麼到底最多到底需要使用多少堆棧槽呢?答案是4,使用最多堆棧槽的地方就是我們最後顯示算式的時候,WriteLine要用4個參數。

現在我們把.entrypoint和.maxstack 4加入到現在的AddLife方法,編譯並運行它。

和上文一樣的順序:

//編譯chen.ilnilasm chen.iln

運行生成的chen.exe

//運行生成的chen.exenmono chen.exe n

結果如圖:

輸入1和2,最後顯示結果為:1 + 2 = 3。

OK,大功告成。


推薦閱讀:

為何沒有國產的 廣泛流行的編程語言?
函數 為什麼要Currying化,currying化有什麼優點?
學習編程有什麼前景?
Sublime Text 2的Haskell開發環境設置
為什麼 WhatsApp 後台使用 Erlang 而不是 C?

TAG:C# | 编程语言 | 计算机 |