微軟所販售的組合語言開發工具稱為「Microsoft Macro Assembler」,其縮寫為「MASM」,最後的三個字母,顯然是組譯器 ( assembler ) 的縮寫;而第一個「M」是 Macro 的意思 ( 註一 ),Macro 中文翻譯成巨集 ( 大陸翻譯成「宏」)。總之就是,微軟的組合語言開發工具,「Microsoft Macro Assembler」可翻譯為「巨集組譯器」。微軟都以「巨集」稱呼自身的組譯器了,其重要性可見一斑。但是一般教授組合語言的書或網站,很少提 及巨集,非常可惜。小木偶不自量力,想介紹這方面的主題。
在第零章曾提及,微軟包含在許多開發工具的 64 位元組譯器,ML64.EXE,並不提供原本在第 6.x 版就支援的高階指令,例如像 INVOKE、.IF/.ENDIF……等假指令。不過,在許多前輩、先進的努力之下,利用巨集重現了這些高階指令。小木偶私下揣測,要增進組合語言功力,當然要能模仿這些大神級人物,因此不能不懂巨集。即使無法像他們一樣登峰造極,但也心嚮往之。這也是讓小木偶想介紹巨集的原因。
停止廢話,言歸正傳。何謂巨集?巨集是一系列文字敘述或一些指令的集合。一旦宣告巨集之後,就可以在原始程式其他地方引用此巨集。組譯器會在組譯原始程式時期,如果遇見引用巨集時,會預先處理巨集,「展開」成原來的假指令或 x86 指令,安插在引用巨集的地方。這樣只需宣告一次巨集,就能重複多次引用,以達到精簡原始程式的目的。如果巨集再配合條件組譯與巨集運算子,甚至會有意想不到的效果。
巨集分成下面幾種,在這一章裡因為篇幅的關係,只會介紹前三種巨集,最後兩種巨集在第六章介紹:
乍看之下,光是看巨集的種類,就覺得很複雜的樣子,但實際上應用時並不太區分它們之間的不同。本章底下的內容,就依上面的順序逐一介紹這些巨集,先來看看巨集程序。
不管哪一種巨集,都必須先宣告,才能使用,否則便會產生錯誤。宣告巨集的地方可以在檔案開始不遠處、也可以在「.CONST」區段內,甚至在「.CODE」區段內的第一行指令之後都行;也可以放在包含檔 ( 副檔名為 INC 或 MAC ) 中,然後在原始程式中以「INCLUDE」假指令載入。不論哪一種方式,都只有一個條件,就是要放在引用巨集之前。有些文獻把使用巨集,說成呼叫巨集,這也不算什麼錯誤;但是巨集與副程式不同,因此本文以「引用」代替「呼叫」。
宣告巨集程序的方法是使用 MACRO/ENDM 假指令,語法如下:
巨集名稱 MACRO 參數列表 敘述 ENDM
上面的敘述可以是 x86 指令或是符合組合語言語法的假指令,必須夾在 MACRO 與 ENDM 之間。在 MACRO 之後的是參數列表,如果巨集有許多參數的話,每個參數之間以「,」隔開,仍寫在參數列表中。巨集的參數只能在巨集內容使用,其他地方無效。要引用巨集時,只需要寫上巨集名稱,其後 接上引數即可 ( 什麼是引數,見第一章註二 )。
底下,小木偶用 GetStdH 巨集程序作為例子,藉著這個例子說明如何宣告及引用巨集程序。
在第三、四章的程式中,都曾呼叫 GetStdHandle、WriteConsoleA、ReadConsoleA 等 Win64 API 數次,每次都需要傳遞許多參數,很是麻煩。如果把呼叫它們的過程製作成巨集,並且在呼叫它們的地方改成引用巨集,這樣原始程式看起來就會精簡許多。
底下是把呼叫 GetStdHandle 的過程改成 GetStdH 巨集程序:
GetStdH MACRO nStd,handle mov rcx,nStd ;;要取得標準輸入裝置還是標準輸出裝置 call GetStdHandle ;;取得標準裝置代碼 cmp rax,INVALID_HANDLE_VALUE;;檢查是否呼叫失敗 je exit ;;若呼叫失敗,跳至exit: mov handle,rax ;;若呼叫成功,將代碼存起來 ENDM
GetStdH 巨集有兩個參數,nStd 是指定要取得標準輸出裝置代碼,還是標準輸入裝置代碼;handle 則是把取得的代碼,存放在哪一個變數裡。要引用 GetStdH 巨集時,只需要寫上巨集名稱,其後接上適當的引數即可,如下:
GetStdH STD_OUTPUT_HANDLE,hOutput
當組譯器組譯時,遇見像上一行引用 GetStdH 巨集時,就會用 STD_OUTPUT_HANDLE 代入進 nStd 裡面,用 hOutput 代入進 handle 裡面,然後把 GetStdH 內的 nStd、handle 全換成這兩個引數,然後再引入巨集之處,換上已取代的巨集內容。像這樣將巨集內容的參數以引數取代之後,把引用巨集之處,寫入取代後的巨集內容,稱為展開 ( expansion )。因此,原來引用巨集僅一行指令,展開之後會變許多行。展開後的結果,可以在列表檔中看到。
下面的表格就是 GetStdH 展開後的對照,左邊是原始程式引用 GetStdH 巨集,右邊是列表檔展開的結果。展開巨集前,先將引用的巨集抄寫一遍,而後再展開。在機械碼與指令之間會添加「1」,它表示是第一層巨集展開的結果。有第一層,當然有第二層,假如在巨集中引用另一個巨集,組譯器生成的列表檔會標上「2」。
原始程式,引用巨集 | 列表檔,展開的結果 |
GetStdH STD_OUTPUT_HANDLE,hOutput | GetStdH STD_OUTPUT_HANDLE,hOutput 00000004 48/ C7 C1 FFFFFFF5 1 mov rcx,STD_OUTPUT_HANDLE 0000000B E8 00000000 E 1 call GetStdHandle 00000010 48/ 83 F8 FF 1 cmp rax,INVALID_HANDLE_VALUE 00000014 0F 84 000000D6 1 je exit 0000001A 48/ 89 05 1 mov hOutput,rax |
在巨集內也能加上註解,巨集的註解可以用「;」或「;;」。組譯器都會忽略掉在「;」或「;;」之後的文字,因此可以用它們作為註解的起頭。「;」與「;;」的差別在於展開巨集時,以「;」為起頭的註解會列在列表檔裡面;而以「;;」為起頭的不會,就顯得不那麼囉嗦。
另外有一點對於撰寫巨集非常重要,就是要區別字串與數值。一般文字,像英文字母、中文等組成的一個句子,必定是字串,這沒有什麼問題,但是如果是像下面的例子:
x DB "12" y DB 12
x 是字串,因為組譯後會變成「31H、32H」兩個位元組,31H 代表 ASCII 字元的「1」,在較低位址;32H 代表 ASCII 字元的「2」,在較高位址。x 是字串變數,但卻由阿拉伯數字組成,可稱其為數值字串 ( 或數值字串變數 )。y 是數值,組譯後變成「0CH」,就是十進位的 12,可稱 y 為數值變數。假設有一段程式是
mov al,x mov cl,y
執行結果是:AL 為 31H,CL 為 0CH。看起來「mov al,x」似乎是錯誤的,的確如此,但 ML64.EXE 卻能正常組譯不發生錯誤訊息,因此要小心謹慎。
在引用巨集時所用的引數都必須是字串,引數不能用數值。即使是像下面那樣引用巨集都能成功:
GetStdH STD_OUTPUT_HANDLE,hOutput GetStdH -11,hOutput
但實際上,組譯器把「STD_OUTPUT_HANDLE」、「-11」都轉換成字串形式,然後才代入到巨集內容裡面。
如果已經對巨集有了粗略的瞭解之後,小木偶打算先介紹撰寫巨集時,必定會用得上的觀念,例如巨集參數的屬性、巨集內部的變數、巨集運算子;最後是 ECHO 假指令,這個指令在撰寫巨集階段除錯很重要。等這一切都介紹完畢,再依序介紹巨集函式、文字巨集……。
在 MACRO 之後的參數列表中,會有一系列的參數,這些參數以「,」隔開,而且在整個巨集中都無法改變其值,否則組譯器就會告知發生錯誤。除此之外,參數還可以有以下屬性:①必要的、②有預設值的、③可變數量的,底下一一介紹。
組譯器組譯時,並不會檢查參數個數與引數個數是否相等,即使不等,只要展開時沒有語法上的錯誤,就不會產生錯誤訊息。例如把 GetStdH 改成底下的樣子:
GetStdH MACRO nStd,handle mov rcx,nStd ;;要取得標準輸入裝置還是標準輸出裝置 call GetStdHandle ;;取得標準裝置代碼 cmp rax,INVALID_HANDLE_VALUE;;檢查是否呼叫失敗 je exit ;;若呼叫失敗,跳至exit: mov hOutput,rax ;;若呼叫成功,將代碼存起來,但都存於hOutput變數裡 ENDM
以下面方式引用:
GetStdH STD_INPUT_HANDLE
組譯時,組譯器並不會發生錯誤。為了避免這種情形,可以在參數後面加上「:REQ」,代表這是必要的參數不可省略,若省略組譯器會發生錯誤。例如底下的 GetStdH 把兩個參數都設為必要的:
GetStdH MACRO nStd:REQ,handle:REQ ⁝ ENDM
參數除了可設為必要的之外,也可以設定預設值,設定預設值的方式是在參數後面加上「:=文字」。假如是預設的參數是數值的話,必須以一對 < > 括起來。例如底下的 Quit 巨集,把 exit_code 參數的預設值設為 0:
Quit MACRO exit_code:=<0> mov rcx,exit_code call ExitProcess ENDM
如果像下面的方式引用 Quit 巨集時,也就是沒有設定 exit_code 時,exit_code 會自動以零代入:
Quit
最後一種參數屬性,是參數的數量是不固定的。這種參數必須放在參數列表的最後面,並且以「:VARARG」表示。通常會可變數量的參數會搭配 FOR/ENDM,請看下一章重複區塊 FOR/ENDM。
在巨集內部,免不了要對數值或是字串做運算,因此有時必須在巨集內宣告變數。這些變數區分成兩類:數值變數與字串變數。數值變數可以用 EQU 或 = 假指令宣告,差別在於以前者宣告之後無法更改其數值;後者可以。所以嚴格來說,EQU 宣告常數,= 宣告變數,它們的語法是:
符號 EQU 算術運算式 符號 = 算術運算式
其中的符號就是變數名稱或常數名稱;算術運算式是指可以是用算術計算的數學式子,其運算元應該都是數值或已宣告過的變數。以這兩種方式宣告的符號,只有在組譯時期有效,而不是在執行時期。例如底下的 Arithmetic 巨集能列出等差數列 ( arithmetic sequence ),此數列首項是 a1,公差是 d,列出的項數是 terms,其內容是:
Arithmetic MACRO a1,d,terms ;;Arithmetic能在資料區段中定義一等差數列,首項為a1、公差為d,共有terms項 a_s DD a1 ;;首項 n=terms-1 ;;n為項數,但首項已定義好,故項數再少一 an=a1+d ;;第二項為首項加公差 WHILE n DD an ;;定義第n項 an=an+d ;;下一項為前一項加公差 n=n-1 ;;項數少一 ENDM ENDM
如果在資料區段,以下面方式引用 Arithmetic 巨集,就能產生首項是 2,每項比前項增加 10,共八項的等差數列:
Arithmetic 2,10,8
就會產生「a_s DD 2,12,22,32,42,52,62,72」的資料。你可以看見在 Arithmetic 內,a1、d、terms 都是參數,而 n、an 則是變數。變數能依需求進行運算,「=」右邊的運算式可以直接指定一個參數、變數或常數,也可以是它們之間進行加、減……等運算。在 Arithmetic 巨集中的 n、an 都只是在 ML64.EXE 組譯原始程式時,因處理此巨集需要,ML64.EXE 才在記憶體內設立這些變數,等處理完 Arithmetic 巨集後就被回收了。當 ML64.EXE 完成組譯原始程式後,所有在巨集內的變數、甚至參數,都會被回收而消失。像這種只有在組譯時期才存在的變數,就稱為組譯時期的變數。
有關 WHILE/ENDM 在第六章會介紹,現在只要知道當 n 不為零時,會一直重複執行 WHILE 與 ENDM 之間的程式碼。
字串變數的宣告方法是用 EQU 或 TEXTEQU,語法如下:
符號 EQU <字串> 符號 TEXTEQU <字串>
不管用 EQU 還是 TEXTEQU 宣告的字串,都能更改變成另一個字串,差別是 TEXTEQU 能搭配後面要提到的 ECHO 假指令及巨集運算子做運算,而 EQU 不能。有關 TEXTEQU 的功能還不只宣告字串,還有許多,請參考後面的文字巨集。另外,在巨集內也可以對字串進行求出字串長度、搜尋字串中的文字、擷取字串內部分文字、連接數個字串等運算,這些都會在第六章預先定義的巨集函式和字串假指令說明。
假如某個符號 ( 包含變數、標記……) 只有在某個巨集中使用到,那麼可以將此符號設為區域的。把符號設為區域的方法是使用 LOCAL 假指令,它的語法是
LOCAL 符號名稱列表
要注意的是,LOCAL 必須緊接著在 MACRO 下一行,除非兩行之間是註解行。另外在巨集中宣告某些符號是區域性的,與在副程式中宣告區域變數,雖然都是使用 LOCAL,但是兩者意義完全不同。區分方式很簡單,前者是緊接著 MACRO 之後;後者緊接著 PROC 之後。有關在副程式中的區域變數,請參閱前一章。
在巨集中,宣告某個符號是區域性後,每次引用此巨集時,組譯器會自動為已宣告過區域性的符號產生新的名稱。這個新的符號名稱格式是「??XXXX」,其中的 XXXX 是從零開始的十六進位數。因為這樣的緣故,所以表面上看起來,能達到使符號變成區域性的目的。但是知道的這樣的命名規則之後,就可以由巨集以外的地方存取這樣的變數,或是跳躍至這樣的標記處。
下表左欄是 TST.ASM 完整的原始程式,也能組譯並連結成功,組譯產生的列表檔在右欄。因為這個段落介紹 LOCAL,所以著重白色字的 GetStd 巨集程序,引用 GetStd 巨集的地方有兩處,均以黃色表示;而右欄黃色字下方數行的橙色字,則是 GetStd 展開後的結果。
GetStd 的第一行宣告 handle 為 LOCAL,而第三行表明了 handle 是在資料區段內、長度四字組的變數。所以第一次引用 GetStd 時,會在資料區段內定義一個四字組的變數,名稱為「??0000」;第二次引用時,也跟第一次引用時一樣,但變數名稱改為「??0001」。
原始程式 | 列表檔 |
OPTION CASEMAP:NONE EXTRN GetStdHandle:PROC EXTRN ExitProcess:PROC INCLUDELIB e:\masm32\lib64\kernel32.lib STD_INPUT_HANDLE EQU -10 STD_OUTPUT_HANDLE EQU -11 Quit MACRO exit_code:=<0> mov rcx,exit_code call ExitProcess ENDM GetStd MACRO nStd LOCAL handle .DATA handle DQ ? .CODE mov rcx,nStd call GetStdHandle mov handle,rax ENDM ;*************************************** .CODE ;--------------------------------------- main PROC GetStd STD_INPUT_HANDLE GetStd STD_OUTPUT_HANDLE mov rax,??0000 mov rcx,??0001 exit: Quit main ENDP ;*************************************** END |
Microsoft (R) Macro Assembler (x64) Version 14.25.28614.0 07/10/23 13:36:19 tst.asm Page 1 - 1 OPTION CASEMAP:NONE EXTRN GetStdHandle:PROC EXTRN ExitProcess:PROC INCLUDELIB e:\masm32\lib64\kernel32.lib =-0000000A STD_INPUT_HANDLE EQU -10 =-0000000B STD_OUTPUT_HANDLE EQU -11 Quit MACRO exit_code:=<0> mov rcx,exit_code call ExitProcess ENDM GetStd MACRO nStd LOCAL handle .DATA handle DQ ? .CODE mov rcx,nStd call GetStdHandle mov handle,rax ENDM ;*************************************** 00000000 .CODE ;--------------------------------------- 00000000 main PROC GetStd STD_INPUT_HANDLE 00000000 1 .DATA 00000000 0000000000000000 1 ??0000 DQ ? 00000000 1 .CODE 00000000 48/ C7 C1 FFFFFFF6 1 mov rcx,STD_INPUT_HANDLE 00000007 E8 00000000 E 1 call GetStdHandle 0000000C 48/ 89 05 00000000 R 1 mov ??0000,rax GetStd STD_OUTPUT_HANDLE 00000008 1 .DATA 00000008 0000000000000000 1 ??0001 DQ ? 00000013 1 .CODE 00000013 48/ C7 C1 FFFFFFF5 1 mov rcx,STD_OUTPUT_HANDLE 0000001A E8 00000000 E 1 call GetStdHandle 0000001F 48/ 89 05 00000008 R 1 mov ??0001,rax 00000026 48/ 8B 05 00000000 R mov rax,??0000 0000002D 48/ 8B 0D 00000008 R mov rcx,??0001 00000034 exit: Quit 00000034 48/ C7 C1 00000000 1 mov rcx,0 0000003B E8 00000000 E 1 call ExitProcess 00000040 main ENDP ;*************************************** END Microsoft (R) Macro Assembler (x64) Version 14.25.28614.0 07/10/23 13:36:19 tst.asm Symbols 2 - 1 Macros: N a m e Type GetStd . . . . . . . . . . . . . Proc Quit . . . . . . . . . . . . . . Proc Procedures, parameters, and locals: N a m e Type Value Attr main . . . . . . . . . . . . . . P 00000000 _TEXT Length= 00000040 Public exit . . . . . . . . . . . . . L 00000034 _TEXT Symbols: N a m e Type Value Attr ??0000 . . . . . . . . . . . . . QWord 00000000 _DATA ??0001 . . . . . . . . . . . . . QWord 00000008 _DATA ExitProcess . . . . . . . . . . L 00000000 External GetStdHandle . . . . . . . . . . L 00000000 External STD_INPUT_HANDLE . . . . . . . . Number -0000000Ah STD_OUTPUT_HANDLE . . . . . . . Number -0000000Bh 0 Warnings 0 Errors |
這裡有個很有趣的寫法,看到宣告 GetStd 巨集時,是像底下的方式宣告的:
GetStd MACRO nStd LOCAL handle .DATA handle DQ ? .CODE ⁝ ENDM
看到沒?在 GetStd 裡面,先在資料區段中定義一個變數、然後又在程式碼區段寫入一些 x86 指令,如果僅是這樣應該沒有問題。問題是,假設 GetStd 被引用了兩次甚至更多次,那麼展開之後,資料區段與程式碼區段豈不是交錯在一起了嗎?事實上,並非如此。組譯器會把名稱相同的區段都集合在一起,爾後合併成為完整的區段,即使它們原來是散落在原始程式各處。以 .DATA 假指令開始的資料區段,其名稱均為「_DATA」;而以 .CODE 假指令開始的程式碼區段,名稱均為「_TEXT」,因此組譯器能將它們都合併起來。
ECHO 會使組譯器在組譯原始程式期間,把接在 ECHO 之後的資料印在螢幕上。其主要目的,是對巨集除錯。它有兩種用法:
ECHO 字串 %ECHO 字串變數
第一種用法很簡單,就是單純的把字串印在螢光幕上。第二種用法能把字串變數的內容印出來,如果在 ECHO 之前不加上「%」運算子,就只能印出變數的名稱,就變成第一種用法。加了「%」之後,「%」有求得其值的功用,「%」與 ECHO 之間可以有空白,也可以不加空白。例如底下的例子:
Love EQU <I love assembly.> ;;宣告Love字串 ECHO Love ;;組譯時只會印出Love %ECHO Love ;;組譯時會印出「I love assembly.」
其實,ECHO 的第二種用法很有彈性。它不僅可以印出字串變數的內容來,也可以在字串變數前後加上其他字串,甚至也可以把巨集函式的回傳值印出來。例如底下的 regp 巨集:
regp MACRO param,reg disp TEXTEQU %param*8+8 mov [rbp+disp],reg ENDM
regp 巨集中的 TEXTEQU 假指令在後面的「文字巨集」章節中再詳細介紹,這裡只要知道在它後面的是一個字串,TEXTEQU 會讓 disp 等於此字串。而此字串是由「%」運算子運算而得,「%」運算子會指示組譯器把 param*8+8 之數值計算出來並轉換為字串。「%」運算子就在後面,接著要介紹的巨集運算子中。如果以下面方式引用這個巨集:
regp 1,rcx
會產生下面的程式碼並寫入目的檔,連結後寫入可執行檔:
mov [rbp+16],rcx
這是因為傳入的引數,1,使得 param 為 1,在「param*8+8」之前的「%」運算子會去計算「param*8+8」之數值,並將它轉換成字串,最後結果是字串,"16"。然後 TEXTEQU 讓 disp 等於這個字串,所以 disp 是字串變數,其內容為"16"。
有時候,在撰寫程式時,並不確定像 param*8+8 的運算式是否寫對,尤其是較複雜的運算式。這時候可以在「disp TEXTEQU %param*8+8」底下加上一行:
%ECHO mov [rbp+disp],reg
讓組譯器把 disp 及其他剩餘的部分印在螢幕上,好讓程式設計師去檢查,是否按照設想的去運算,藉以除錯。這是撰寫巨集時,常用的除錯技巧。另外,這個巨集可用於 Win64 程式呼叫副程式的時候,當進入副程式後,把前四個存於暫存器的參數存入堆疊裡。param 是第幾個參數,reg 是所相對應的暫存器。
常用的巨集運算子如下表,很明顯前四個運算子是一類,是專門用來對文字進行運算的。這四個巨集運算子,有自己的名稱,並以一個特殊字元作為運算符號。就像數學四則運算的加、減、乘、除一樣,像加法的符號就是+。同樣的道理,文字分隔運算子的符號是「< >」。第五個自成一類,是求得參數屬性的運算子,並沒有特別的名稱。
運算子符號 | 名稱 | 簡要說明 |
< > | 文字分隔運算子 ( text delimiters ) | 將 < > 內的文字變為字串 |
! | 字元運算子 ( literal-character operator ) | 使其後的符號變為文字 |
& | 取代運算子 ( substitution operator ) | 使在兩個 & 之間的文字變為參數 |
% | 展開運算子 ( expansion operator ) | 計算使其後的運算式變為文字 |
OPATTR | 求得符號的屬性 |
①:文字分隔運算子的符號是一對 <、>,在這對符號之間的文字,即使有分隔符號,像空白、逗號 ( , )、引號 ( " )、分號 ( ; )……等,都會被文字分隔運算子忽略,而把這些分隔符號視為一般字元,使整段文字變為一個字串。
例如有個 Print 巨集 ( 事實上,Print 巨集全部內容在註二 ),只有一個參數,此參數為一字串或字串的位址,引用 Print 巨集時,就能把字串印在螢幕上。如果以下面兩種方式引用 Print:
Print "天若無光海無水,山若無峰雲無軌;" Print 0dh,0ah,"人卻有情死生不悔,何懼隔世再夢迴。"
第一行能順利組譯並成功印出來;但第二行組譯時,組譯器就會提出警告,說第二行太多參數了,但還是能組譯成功;執行卻會發生問題,無法印出「人卻有情死生不悔,何懼隔世再夢迴。」。應該改成下面的樣子就行了:
Print <0dh,0ah,"人卻有情死生不悔,何懼隔世再夢迴。">
如果不加上「< >」,組譯器會認為有三個參數,因為「,」是用來分隔參數的,所以前兩個字元 0dh、0ah 各自成一個參數,其餘又成一個參數。加了「< >」之後,會迫使在它們之間的文字,當成是一個字串,逗號是字元而不是分隔符號了,這樣就只有一個參數了。
②:字元運算子的符號是「!」,其作用是把在「!」後面的特殊符號當做一般字元。例如底下的例子就會發生錯誤,因為「!>」中的「!」是字元運算子,會把後面的 > 當做是一般字元,於是缺少了結尾的 >,就發生錯誤:
Love TEXTEQU <I love assembly!> %ECHO Love
如果要印出「I love assembly!」,要像下面:
Love TEXTEQU <I love assembly!!> %ECHO Love
③:取代運算子的符號是「&」。在兩個 & 之間的文字會被組譯器視為參數或變數,而以實際字串代入,這種情形多用於參數或變數位於字串內或者為某個單字的一部分。例如:
ErrMsg MACRO num,message Error&num& DB "Error &num&: &message&" ENDM
如果在資料區段內,以下面方式引用 ErrMsg 巨集:
ErrMsg 1,<Invalid standard input device.> ErrMsg 2,<Invalid standard output device.>
就會產生下面的程式碼:
Error1 DB "Error 1: Invalid standard input device." Error2 DB "Error 2: Invalid standard output device."
如果參數之後是具有分隔意味的符號,例如空白、冒號、引號、逗號……等,那麼後面的 & 可以省略,所以上面的 ErrMsg 巨集也可以寫成下面的樣子:
ErrMsg MACRO num,message Error&num DB "Error &num: &message" ENDM
甚至是具有分隔意味的符號在參數之前,那麼省略前面的 &,僅寫出後面的 & 也是可以的。所以上面的 ErrMsg 巨集也可以寫成下面的樣子:
ErrMsg MACRO num,message Error&num DB "Error num&: message&" ENDM
④:展開運算子的符號是「%」,它用有兩種用法:
⑴:「%」不是在一行的最前面,而是後面接著數學運算式,這裡的數學運算式是由常數組成的運算式。在這種情形,「%」會去計算由數學運算式的結果,並且將其轉換為字串形式。這可以搭配文字巨集 ( TEXTEQU ) 使用,也可以在引用巨集時先計算引數。例如底下的第一個例子是搭配 TEXTEQU 使用,第二個例子是搭配 work 巨集的引數使用:
numstr TEXTEQU %3+10 ;;%先把常數 3 加上 10,得到 13,再轉換成字串,故numstr=<13> work MACRO arg mov rax,arg*4 ENDM ⁝ work 3+4 ;;⑴「mov rax,3+4*4」即「mov rax,19」 work %3+4 ;;⑵「mov rax,(3+4)*4」即「mov rax,28」
在式子⑴引用 work 巨集時,必須把引數,「3+4」看成是字串代入後變成「mov rax,3+4*4」,因此變成「mov rax,19」。還記得吧?組譯器將引數代入巨集時,是以字串形式代入,非數值形式。式子⑵引用 work 巨集時,% 運算子會先把常數組成的數學運算式,就是「3+4」,計算出答案來,結果是「7」,轉換成字串後代入,所以得到「mov rax,28」。
⑵:「%」在一行的最前面,與 ECHO、TITLE 及 SUBTITLE 搭配使用。「%」指示組譯器展開 ECHO、TITLE 及 SUBTITLE 之後的文字巨集或是巨集函式以及其餘文字。底下的 StkArg 巨集是在呼叫 Win64 副程式時,把第五個或是以後的參數存入堆疊的,其內容及引用方式如下:
StkArg MACRO param,arg
disp TEXTEQU %20h+(param-5)*8
%ECHO mov QWORD PTR [rsp+disp],arg
mov QWORD PTR [rsp+disp],arg
ENDM
⁝
StkArg 5,rax ;;把第五個參數,RAX,存入堆疊
若第五個參數是 RAX,引用時的指令是「StkArg 5,rax」。這會產生「mov QWORD PTR [rsp+32],rax」程式碼,而第三行的「%ECHO mov QWORD PTR [rsp+disp],arg」其實沒有必要,可以刪去,但是對於巨集的除錯很有幫助。巨集只在組譯時展開,無法用除錯程式除錯,可以用這種方式將關鍵結果印在螢幕上,以檢查是否設計錯誤。
⑤:OPATTR 運算子的語法是
OPATTR 運算元
OPATTR 是用來求得運算元的屬性,這裡的運算元是某個符號,如果是未定義的符號,結果為 0;如果是已定義的符號,其運算結果為一個十六位元的數值,每一個位元代表一種屬性。第 11∼15 位元未使用,第 0∼10 位元的意義如下表:
位元 | 說 明 |
0 | 運算元在程式碼區段內時,位元零為一;否則為零 |
1 | 運算元是記憶體變數時,位元一為一;否則為零 |
2 | 運算元是常數時,位元二為一;否則為零 |
3 | 運算元使用直接定址時,位元三為一;否則為零 |
4 | 運算元是暫存器,位元四為一;否則為零 |
5 | 運算元為已定義的符號且沒有錯誤時,位元五為一;否則為零 |
6 | 運算元在堆疊區段時,位元六為一;否則為零 |
7 | 運算元為外部符號時,位元七為一;否則為零 |
8∼10 | 與語言類型有關,但 Win64 不使用語言類型,故省略 |
底下的 TSTOPA.ASM 是用來檢視各類符號的屬性,其中有個 TSTOPA 巨集程序,它會把符號的屬性印出來。先來看看原始程式,TSTOPA.ASM 就只是呼叫 ExitProcess,然後多次引用 TSTOPA 巨集,就是讓 TSTOPA 巨集程序在組譯時期印出像 num 變數、subr 副程式這些符號的屬性。在 TSTOPA 之後的註解,列出了十進位、十六進位和二進位的結果,以方便對照理解。
;TEST OPATTR:測試OPATTR運算子,並印出其結果 OPTION CASEMAP:NONE EXTRN ExitProcess:PROC,extvar:WORD INCLUDELIB e:\masm32\lib64\kernel32.lib TRUE EQU 1 TSTOPA MACRO arg temp TEXTEQU %OPATTR arg %ECHO arg=temp ENDM ;********************************************************************* .DATA num DQ 112233h ;********************************************************************* .CODE ;--------------------------------------------------------------------- subr PROC ret ;subr副程式 subr ENDP ;--------------------------------------------------------------------- main PROC LOCAL locvar:QWORD exit: xor rcx,rcx call ExitProcess TSTOPA exit ;標記 = 37d=25h=0010 0101b TSTOPA subr ;副程式名稱= 37d=25h=0010 0101b TSTOPA num ;記憶體變數= 42d=2Ah=0010 1010b TSTOPA locvar ;區域變數 = 98d=62h=0110 0010b TSTOPA rax ;暫存器 = 48d=30h=0011 0000b TSTOPA TRUE ;常數 = 36d=24h=0010 0100b TSTOPA [rcx] ;間接定址 = 34d=22h=0010 0010b TSTOPA [rcx+8] ;間接定址 = 34d=22h=0010 0010b TSTOPA extvar ;外部變數 =170d=AAh=1010 1010b TSTOPA undefn ;未定義符號= 0d= 0h=0000 0000b main ENDP ;********************************************************************* END
這個程式不製作成可執行檔,僅組譯而已。有兩個理由:①因為 TSTOPA.ASM 第二行定義了外部變數,extvar,如要連結還必須在其他檔案定義 extvar,再包含進來,很麻煩;②這個程式只是用來檢驗 OPATTR 的結果,在組譯時期就可達到目的了。要只組譯不連結,只需在組譯時輸入「-c」或「/c」選項即可 ( 注意!「-c」或「/c」中的 c 必須要小寫 ),如下圖:
底下做個小小的結論:
在巨集中常用的運算子、參數的使用均已說明完畢,這些不僅是巨集程序使用,其他巨集也會應用到。底下來說明巨集函式。
巨集函式的宣告方式與巨集程序相同,都是使用 MACRO/ENDM,差別只在於前者有回傳值而後者沒有,以及引用方式不太相同,除此之外它們之間差別並不大。引用巨集函式的方式是:
巨集函式名稱(引數列表)
如果巨集函式有多個參數,那麼引用時,引數列表中,引數與引數之間以「,」隔開。如果巨集函式沒有參數,在引用時,仍然要在巨集函式名字之後加上「()」,只是括號內沒有任何引數。如果在原始程式的程式碼區段或資料區段中,引用巨集函式,那麼組譯時,巨集函式的回傳值會取代引用的巨集函式。假如巨集函式內,有 x86 指令或其他指令,也跟巨集程序一樣,會把這些程式碼寫入目的檔及可執行檔中。
在巨集中,可以利用 EXITM 把回傳值傳回,EXITM 的語法是:
EXITM [textitem]
當組譯器由上而下組譯原始程式的巨集時,如果遇到 EXITM,就會忽略從 EXITM 到 ENDM 之間的巨集程式碼,跳出巨集,並傳回回傳值。
回傳值緊接著 EXITM 之後。上面的語法表明,回傳值以一對「[」、「]」括起來,代表回傳值可以省略。如果省略的話,就沒有回傳值。如果沒有省略的話,textitem 就是巨集函式的回傳值。textitem 必須是字串、或是代表字串的符號名稱,也可以是另一個巨集函式的回傳值。巨集函式可以使用文字分隔運算子 ( 就是一對「<」、「>」),其括起來的內容會轉變為字串。也可以使用展開運算子 ( % ),將數值轉變成字串。
例如底下的 Learn 巨集函式,其內容為:
Learn MACRO language
EXITM <"&language language is wonderful.">
ENDM
% ECHO Learn(Assembly) ;;引用Learn巨集,並把回傳值印在螢幕上
上面的程式碼,會在組譯時期,讓組譯器印出在螢幕上一行字,「Assembly language is wonderful.」。會顯示這一行字串,是因為 Learn 巨集函式的回傳值本身就是字串,所以最後一行的「%ECHO」才會把 Learn 的回傳值印在螢幕上。
因為巨集函式能傳回回傳值,且為一個字串,因此可以將它與 x86 指令或假指令搭配。例如在資料區段中,下面的程式碼:
.DATA asm DB Learn(Assembly) bas DB Learn(BASIC)
結果產生下面的程式碼
.DATA asm DB "Assembly language is wonderful." bas DB "BASIC language is wonderful."
底下還有個巨集函式的例子,可以見底下的 paramx 巨集函式。
組譯器允許使用者將一段文字,或者也可以說是字串,設定為一個符號,而後在原始程式中如果要用到這段文字,就可以用這符號代替。這樣也能精簡原始程式,所以也算是一種巨集,稱為文字巨集。文字巨集只有一種,那就是 TEXTEQU,所以提到文字巨集,其實就是指 TEXTEQU。
TEXTEQU 假指令使一個符號代替一段文字。TEXTEQU 有三種用法,其語法如下:
name TEXTEQU <text> name TEXTEQU macroId | textmacro name TEXTEQU %constExpr
底下的例子是第①種用法的演示:
param5 TEXTEQU <QWORD PTR [rsp+20h]> ;param5即為「QWORD PTR [rsp+20h]」這個字串
然後我們可以在呼叫 WriteConsoleA API 時,用下面的程式碼,把第五個參數傳給 WriteConsoleA API:
mov param5,0
第①種用法也可以是在巨集中,以一符號代替參數,例如下面的程式片段會使 arg1 變為「DWORD PTR button[ rax*4 ]」字串,並在螢幕上印出來。
Print MACRO argument1,argument2 arg1 TEXTEQU <argument1> %ECHO arg1 ENDM ⁝ Print DWORD PTR button[ rax*4 ],r9d
底下的 paramx 巨集函式是第②及第③種用法的演示。它可以用在呼叫像 WriteConsoleA 這種超過四個參數的 Win64 API,把第五個參數,甚至更後面的參數傳給 Win64 API。paramx 內容如下:
paramx MACRO x,arg disp TEXTEQU %(x-5)*8+32 command TEXTEQU <mov QWORD PTR [rsp+disp],arg> EXITM command ENDM
其中 x 是第幾個參數,從 5 開始,arg 是其引數。例如 WriteConsoleA 的第五個參數是零,那麼在原始程式的程式碼區段裡,要呼叫 WriteConsoleA 時,第五個參數填上零的寫法,可以像下面的方式寫出來:
paramx(5,0)
在 paramx 中的字串變數 disp 就是第三種用法,配合 % 運算子,把其後的數學運算式計算出來的數值轉換成字串,然後把 disp 之值指定為此字串。例如當 x=5,(5-5)*8+32 為 32,disp 就是「"32"」字串。paramx 中的 command 變數則是第二種用法,將已宣告過的文字巨集再做運算,也就是把剛才的 disp 安插在「"mov QWORD PTR ...arg"」字串中間。最後得到 command 其實就是字串「mov QWORD PTR [rsp+32],0」。
paramx 的最後一行是 EXITM 假指令,它會傳回 command 字串,而此字串會寫在目的檔中,最後會寫入可執行檔中。也就是說,在 paramx(5,0) 巨集函式的這一行,會被「mov QWORD PTR [rsp+32],0」所取代。
在宣告巨集的地方,再宣告另一個巨集,這種狀況稱為巢狀巨集。一般而言,巢狀巨集的形式如下:
甲 MACRO 甲巨集參數列表 ⁝ 乙 MACRO 乙巨集參數列表 ⁝ ENDM ⁝ ENDM
巢狀巨集最常見的情形,就是在巨集內宣告文字巨集。像上面的 paramx 巨集中,又宣告了 disp 與 command 兩個文字巨集。
微軟 ( Microsoft ) 的縮寫是 MS,這可以從它二十世紀所販售的作業系統 MS-DOS 就可以知道。因此 MASM 的第一個「M」並不是指微軟。
小木偶以第三章的 GREETING.ASM 當作例子,把呼叫 Win64 API 的過程全都製作成巨集,而把呼叫它們的地方變成引用巨集。整個原始程式變成下面的樣子,把它命名為 GREETING1.ASM:
1 2 3 4
5 6 7 8
9 10 11 12
13 14 15 16
17 18 19 20
21 22 23 24
25 26 27 28
29 30 |
;GREETING1.ASM把GREETING.ASM中呼叫GetStdHandle與WriteConsoleA的過程改寫成巨集
;組譯方式:
;path e:\masm32\bin64;%path%
;set link=/subsystem:console /entry:main
;ml64 greeting1.asm (或 ml64 /Fl greeting1.asm 製作greeting1.lst列表檔)
INCLUDE GREETING1.INC
;*******************************************************************************
.DATA
hOutput DQ ? ;標準輸出裝置代碼
hInput DQ ? ;標準輸入裝置代碼
;*******************************************************************************
.CODE
;-------------------------------------------------------------------------------
main PROC
sub rsp,28h
GetStdH STD_OUTPUT_HANDLE,hOutput
GetStdH STD_INPUT_HANDLE,hInput
;在標準輸出裝置上印出提示字串,當做提示讓使用者明白該輸入什麼
Print "請輸入您的姓名(最多四個中文字):"
Input YourName ;請使用者輸入自己的姓名
Print <ADDR YourName>,rcx ;印出使用者姓名
Print ",您好嗎?"
exit: Quit
main ENDP
;*******************************************************************************
END |
如果只看上面的 GREETING1.ASM,似乎與前幾章的組合語言程式碼非常不同,看起來跟高階語言很像,一點都不像是組合語言。的確,這就是巨集令人驚奇的地方。當然,僅有 GREETING1.ASM 是無法組譯成功的,還必須要有底下的 GREETING1.INC 才行。
但是看了 GREETING1.INC 之後,就知道其實骨子裡還是組合語言。不過這些巨集,如果寫得好,其實可以重複利用,所以還是能節省時間。
底下的 GREETING1.INC 中的巨集,有些部分在第六章才會說明。本來照順序,GREETING1.ASM 與 GREETING1.INC 應該放在第六章,但是本章說了太多指令,卻沒有一個完整的應用,會讓人不知如何應用;再者超出本章範圍的部分也不多,僅 IFIDNI/ELSE/ENDIF 與 SUBSTR。為了讓大家能明瞭巨集到底是幹嘛的,就把這個例子放在這裡了。
1 2 3 4
5 6 7 8
9 10 11 12
13 14 15 16
17 18 19 20
21 22 23 24
25 26 27 28
29 30 31 32
33 34 35 36
37 38 39 40
41 42 43 44
45 46 47 48
49 50 51 52
53 54 55 56
57 58 59 60
61 62 63 64
65 66 |
OPTION CASEMAP:NONE
EXTRN GetStdHandle:PROC,WriteConsoleA:PROC,ReadConsoleA:PROC,ExitProcess:PROC
INCLUDELIB e:\masm32\lib64\kernel32.lib
MAX_NAME EQU 4*2+2 ;中文姓名最多四個中文字,每個中文字佔兩個位元組,再加上0dH、0aH
STD_INPUT_HANDLE EQU -10
STD_OUTPUT_HANDLE EQU -11
INVALID_HANDLE_VALUE EQU -1
;-------------------------------------------------------------------------------
GetStdH MACRO nStd,handle
mov rcx,nStd ;;要取得標準輸入裝置還是標準輸出裝置
call GetStdHandle ;;取得標準裝置代碼
cmp rax,INVALID_HANDLE_VALUE;;檢查是否呼叫失敗
je exit ;;若呼叫失敗,跳至exit:
mov handle,rax ;;若呼叫成功,將代碼存起來
ENDM
;-------------------------------------------------------------------------------
;印出字串,sStr可以是字串,也可以是字串的位址:
;1.若為字串,引用時應以兩個「"」括住
;2.若為字串位址,sStr必須是像「ADDR 字串變數名稱」的格式,且slen為字串長度
Print MACRO sStr,slen
LOCAL string,chrWrt
lead SUBSTR <sStr>,1,5 ;;lead為sStr的前五個字元
.DATA
chrWrt DQ ? ;;實際寫入多少字元
;;如果sStr是字串的位址
IFIDNI lead,<ADDR >
.CODE
address SUBSTR <sStr>,6 ;;address=字串之位址
lea rdx,address ;;使RDX=字串之位址
mov r8,slen ;;R8=字串長度
ELSE
;;如果sStr是字串
string DB sStr ;;在資料區段中定義sStr字串
.CODE
lea rdx,string ;;使RDX=sStr之位址
mov r8,SIZEOF string ;;使R8=sStr之長度
ENDIF
lea r9,chrWrt
mov rcx,hOutput
mov QWORD PTR [rsp+20h],0
call WriteConsoleA
ENDM
;-------------------------------------------------------------------------------
;讓使用者輸入一字串。返回時,字串存於string變數之中,且RCX為字串長度
;(單位:位元組),此長度不含0DH、0AH
Input MACRO string
LOCAL ChrRd
.DATA
string DB MAX_NAME DUP (0)
chrRd DQ ?
.CODE
mov rcx,hInput
lea rdx,string
mov r8,MAX_NAME
lea r9,chrRd
mov QWORD PTR [rsp+20h],0
call ReadConsoleA
mov rcx,chrRd ;;輸入的位元組個數,包含0DH、0AH
sub rcx,2 ;;輸入的位元組個數,不含0DH、0AH
ENDM
;-------------------------------------------------------------------------------
Quit MACRO exit_code:=<0>
mov rcx,exit_code
call ExitProcess
ENDM |
如果每次使用巨集前,都要在原始程式中先宣告,就很麻煩,因此才有了 INCLUDE 假指令。
我們可以把已製作好的巨集,以及某些常數,都寫保存在一種稱為包含檔或含入檔的純文字檔案中,這種檔案的副檔名通常是「.INC」或「.MAC」,就是 include file 或 macro file 的意思。然後可以在原始程式中以 INCLUDE 假指令將其含括進來,INCLUDE 的語法如下:
INCLUDE 檔案名稱
列表檔是組譯器將原始程式組譯後,照實列出的每一行指令的機械碼及位址的純文字檔案,其副檔名為「.LST」。可以用印表機列印出來,對於除錯很有幫助。「展開巨集」的結果,也可以在列表檔看出來。
要產生列表檔,可以在組譯原始程式時,輸入「/Fl」選項即可,如下:
E:\HomePage\SOURCE\Win64\CONSOLE>ml64 /Fl greeting1.asm [Enter] →組譯greeting1.asm,同時製作列表檔 Microsoft (R) Macro Assembler (x64) Version 14.25.28614.0 Copyright (C) Microsoft Corporation. All rights reserved. Assembling: greeting1.asm Microsoft (R) Incremental Linker Version 14.25.28614.0 Copyright (C) Microsoft Corporation. All rights reserved. /SUBSYSTEM:CONSOLE /ENTRY:main /OUT:greeting1.exe greeting1.obj E:\HomePage\SOURCE\Win64\CONSOLE>
選項「/Fl」也可以用「-Fl」,其中的 F 一定要大寫,l 一定要小寫 ( 此為英文字母「L」的小寫並非阿拉伯數字的「1」)。在「/Fl」之後也可以加上檔案名稱,這檔案名稱與「/Fl」之間不能有空格。如果加上檔案名稱,列表檔檔名就是「/Fl」之後的檔案名稱。如果不加,就用預設的列表檔;預設的列表檔檔案名稱,其主檔名是組合語言原始程式的主檔名,副檔名是「.LST」。
列表檔的最後面,會有原始程式所有符號列表。這些符號包含巨集、副程式、變數等等,以及這些符號的性質。在除錯時,這些性質也能提供些許幫助。
其中,比較特別的是區段名稱。在第一章時,只提過程式碼與資料在不同的區段,而資料區段又分三種。如今可以由列表檔看出來,這些區段其實都有自己的名稱,如下表:
宣告方式 | 類型 | 名稱 |
.CODE | 程式碼區段 | _TEXT |
.DATA | 資料區段 | _DATA |
.CONST | 常數資料區段 | CONST |
.DATA? | 沒有初始值的資料區段 | _BSS |