5.10 組合語言:組合過程與結構

2023-08-24 12:01:36

過程的實現離不開堆疊的應用,堆疊是一種後進先出(LIFO)的資料結構,最後壓入棧的值總是最先被彈出,而新數值在執行壓棧時總是被壓入到棧的最頂端,棧主要功能是暫時存放資料和地址,通常用來保護斷點和現場。

棧是由CPU管理的線性記憶體陣列,它使用兩個暫存器(SS和ESP)來儲存棧的狀態,SS暫存器存放段選擇符,而ESP暫存器的值通常是指向特定位置的一個32位元偏移值,我們很少需要直接操作ESP暫存器,相反的ESP暫存器總是由CALL,RET,PUSH,POP等這類指令間接性的修改。

CPU提供了兩個特殊的暫存器用於標識位於系統棧頂端的棧幀。

  • ESP 棧指標暫存器:棧指標暫存器,其記憶體放著一個指標,該指標永遠指向系統棧最上面一個棧幀的棧頂。
  • EBP 基址指標暫存器:基址指標暫存器,其記憶體放著一個指標,該指標永遠指向系統棧最上面一個棧幀的底部。

在通常情況下ESP是可變的,隨著棧的生成而逐漸變小,而EBP暫存器是固定的,只有當函數的呼叫後,發生入棧操作而改變。

  • 執行PUSH壓棧時,堆疊指標自動減4,再將壓棧的值複製到堆疊指標所指向的記憶體地址。
  • 執行POP出棧時,從棧頂移走一個值並將其複製給記憶體或暫存器,然後再將堆疊指標自動加4。
  • 執行CALL呼叫時,CPU會用堆疊儲存當前被呼叫過程的返回地址,直到遇到RET指令再將其彈出。

10.1 PUSH/POP

PUSH和POP是組合語言中用於堆疊操作的指令,它們通常用於儲存和恢復暫存器的值,引數傳遞和函數呼叫等。

PUSH指令用於將運算元壓入堆疊中,它執行的操作包括將運算元複製到堆疊的棧頂,並將堆疊指標(ESP)減去相應的位元組數。指令格式如下:

PUSH operand

其中,operand可以是8位元,16位元或32位元的暫存器,立即數,以及記憶體中的某個值。例如,要將暫存器EAX的值壓入堆疊中,可以使用以下指令:

PUSH EAX

從組合程式碼的角度來看,PUSH指令將運算元儲存到堆疊中,它實際上是一個入棧操作。

POP指令用於將堆疊中棧頂的值彈出到指定的目的運算元中,它執行的操作包括將堆疊頂部的值移動到指定的運算元,並將堆疊指標增加相應的位元組數。指令格式如下:

POP operand

其中,operand可以是8位元,16位元或32位元的暫存器,立即數,以及記憶體中的某個位置。例如,要將從堆疊中彈出的值儲存到BX暫存器中,可以使用以下指令:

POP EBX

從組合程式碼的角度來看,POP指令將從堆疊中取出一個值,並將其儲存到目的運算元中,它是一個出棧操作。

在函數呼叫時,PUSH指令被用於向堆疊中推播函數的引數,這些引數可以是暫存器、立即數或者記憶體中的某個值。在函數返回之前,POP指令被用於將堆疊頂部的值彈出,並將其儲存到暫存器或者記憶體中。

讀者需要特別注意,在使用PUSHPOP指令時需要保證堆疊的平衡,也就是說,每個PUSH指令必須有對應的POP指令,否則堆疊會失去平衡,最終導致程式出現錯誤。

在讀者瞭解了這兩條指令時則可以執行一些特殊的操作,如下程式碼我們以陣列入棧與出棧為例,執行PUSH指令時,首先減小ESP的值,然後把源運算元複製到堆疊上,執行POP指令則是先將資料彈出到目的運算元中,然後再執行ESP值增加4,並以此分別將陣列中的元素壓入棧,最終再通過POP將元素反彈出來。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

.data
  Array DWORD 1,2,3,4,5,6,7,8,9,10
  szFmt BYTE '%d ',0dh,0ah,0
.code
  main PROC
    ; 使用Push指令將陣列正向入棧
    mov eax,0
    mov ecx,10
  S1:
    push dword ptr ds:[Array + eax * 4]
    inc eax
    loop S1
    
    ; 使用pop指令將陣列反向彈出
    mov ecx,10
  S2:
    push ecx                         ; 保護ecx
    pop ebx                          ; 將Array陣列元素彈出到ebx
    invoke crt_printf,addr szFmt,ebx
    pop ecx                          ; 彈出ecx
    loop S2
    
    int 3
  main ENDP
END main

至此當讀者理解了這兩個指令之後,那麼利用堆疊的先進後出特定,我們就可以實現將特殊的字串反轉後輸出的效果,首先我們迴圈將字串壓入堆疊,然後再從堆疊中反向彈出來,這樣就可以實現字串的反轉操作,這段程式碼的實現也相對較為容易;

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

.data
  MyString BYTE "hello lyshark",0
  NameSize DWORD ($ - MyString) - 1
  szFmt BYTE '%s',0dh,0ah,0
.code
  main PROC
    ; 正向壓入字串
    mov ecx,dword ptr ds:[NameSize]
    mov esi,0
  S1: movzx eax,byte ptr ds:[MyString + esi]
    push eax
    inc esi
    loop S1

    ; 反向彈出字串
    mov ecx,dword ptr ds:[NameSize]
    mov esi,0
  S2: pop eax
    mov byte ptr ds:[MyString + esi],al
    inc esi
    loop S2
    
    invoke crt_printf,addr szFmt,addr MyString
    int 3
  main ENDP
END main

10.2 PROC/ENDP

PROC/ENDP 偽指令是用於定義過程(函數)的偽指令,這兩個偽指令可分別定義過程的開始和結束位置。此處讀者需要注意,這兩條偽指令並非是組合語言中所相容的,而是MASM編譯器為我們提供的一個宏,是MASM的一部分,它允許程式設計師使用組合語言定義過程(函數)可以像標準組合指令一樣使用。

對於不使用宏定義來建立函數時我們通常會自己管理函數棧引數,而有了宏定義這些功能都可交給編譯器去管理,下面的一個案例中,我們通過使用過程建立ArraySum函數,實現對整數陣列求和操作,函數預設將返回值儲存在EAX中,並列印輸出求和後的引數。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

.data
  MyArray  DWORD 1,2,3,4,5,6,7,8,9,10
  Sum      DWORD ?
  szFmt    BYTE '%d',0dh,0ah,0
.code
  ; 陣列求和過程
  ArraySum PROC
    push esi                     ; 儲存ESI,ECX
    push ecx
    xor eax,eax
    
  S1: add eax,dword ptr ds:[esi]   ; 取值並相加
    add esi,4                    ; 遞增陣列指標
    loop S1
    pop ecx                      ; 恢復ESI,ECX
    pop esi
    ret
  ArraySum endp

  main PROC
    lea esi,dword ptr ds:[MyArray]   ; 取出陣列基址
    mov ecx,lengthof MyArray         ; 取出元素數目
    call ArraySum                    ; 呼叫方法
    mov dword ptr ds:[Sum],eax       ; 得到結果
    invoke crt_printf,addr szFmt,Sum
    int 3
  main ENDP
END main

接著我們來實現一個具有獲取亂數功能的案例,在C語言中如果需要獲得一個亂數一般會呼叫Seed函數,如果讀者逆向分析過這個函數的實現原理,那麼讀者應該能理解,在呼叫取亂數之前會生成一個亂數種子,這個亂數種子的生成則依賴於0x343FDh這個特殊的常數地址,當我們每次存取該地址都會產出一個隨機的資料,當得到該資料後,我們再通過除法運算取出溢位資料作為亂數使用實現了該功能。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

.data
  seed DWORD 1
  szFmt    BYTE '亂數: %d',0dh,0ah,0
.code
  ; 生成 0 - FFFFFFFFh 的隨機種子
  Random32 PROC
    push  edx
    mov   eax, 343FDh
    imul  seed
    add   eax, 269EC3h
    mov   seed, eax
    ror   eax,8
    pop   edx
    ret
  Random32 endp
  
  ; 生成亂數
  RandomRange PROC
    push  ebx
    push  edx
    
    mov   ebx,eax
    call  Random32
    mov   edx,0
    div   ebx
    mov   eax,edx

    pop   edx
    pop   ebx
    ret
  RandomRange endp

  main PROC
  
    ; 呼叫後取出亂數
    call RandomRange
    invoke crt_printf,addr szFmt,eax
    int 3
  main ENDP
END main

10.3 區域性引數傳遞

在組合語言中,可以使用堆疊來傳遞函數引數和建立區域性變數。當程式執行到函數呼叫語句時,需要將函數引數傳遞給被呼叫函數。為了實現引數傳遞,程式會將引數壓入棧中,然後呼叫被呼叫函數。被呼叫函數從棧中彈出引數並執行,然後將返回值儲存在暫存器中,最後通過跳轉返回到呼叫函數。

區域性變數也可以通過在棧中分配記憶體來建立。在函數開始時,可以使用push指令將區域性變數壓入棧中。在函數結束時,可以使用pop指令將變數從棧中彈出。由於棧是後進先出的資料結構,區域性變數的建立可以很方便地通過在棧上壓入一些資料來實現。

區域性變數是在程式執行時由系統動態的在棧上開闢的,在記憶體中通常在基址指標(EBP)之下,儘管在組合時不能給定預設值,但可以在執行時初始化,如下一段C語言虛擬碼:

void MySub()
{
  int var1 = 10;
  int var2 = 20;
}

上述的程式碼經過C編譯後,會變成如下組合指令,其中EBP-4必須是4的倍數,因為預設就是4位元組儲存,如果去掉了mov esp,ebp,那麼當執行pop ebp時將會得到EBP等於10,執行RET指令會導致控制轉移到記憶體地址10處執行,從而程式會崩潰。

MySub PROC
  push ebp                  ; 將EBP儲存在棧中
  mov ebp,esp               ; 堆疊框架的基址
  sub esp,8                 ; 建立區域性變數空間(分配2個區域性變數)

  mov DWORD PTR [ebp-8],10  ; var1 = 10
  mov DWORD PTR [ebp-4],20  ; var2 = 20

  mov esp,ebp               ; 從堆疊上刪除區域性變數
  pop ebp                   ; 恢復EBP指標
  ret 8                     ; 返回,清理堆疊
MySub ENDP

為了使上述程式碼片段更易於理解,可以在上述的程式碼的基礎上給每個變數的參照地址都定義一個符號,並在程式碼中使用這些符號,如下程式碼所示,程式碼中定義了一個名為MySub的過程,該過程將兩個區域性變數分別設定為1020

在該過程中,首先使用push ebp指令將舊的基址指標壓入棧中,並將ESP暫存器的值儲存到ebp中。這個舊的基址指標將在函數執行完畢後被恢復。然後,我們使用sub esp,8指令將8位元組的空間分配給兩個區域性變數。在堆疊上分配的空間可以通過var1_localvar2_local符號來存取。在這裡,我們定義了兩個符號,將它們與ebp暫存器進行偏移以存取這些區域性變數。var1_local的地址為[ebp-8]var2_local的地址為[ebp-4]。然後,我們使用mov指令將1020分別儲存到這些區域性變數中。最後,我們將ESP暫存器的值儲存回ebp中,並使用pop ebp指令將舊的基址指標彈出堆疊。現在,棧頂指標(ESP)下移恢復上面分配的8個位元組的空間,最後通過ret 8返回到呼叫函數。

在使用堆疊傳參和建立區域性變數時,需要謹慎考慮棧指標的位置,並確保遵守呼叫約定以確保正確地傳遞引數和返回值。

var1_local EQU DWORD PTR [ebp-8]   ; 新增符號1
var2_local EQU DWORD PTR [ebp-4]   ; 新增符號2

MySub PROC
  push ebp
  mov ebp,esp
  sub esp,8
  mov var1_local,10
  mov var2_local,20
  mov esp,ebp
  pop ebp
  ret 8
MySub ENDP

接著我們來實現一個具有功能的案例,首先為了能更好的讓讀者理解我們先使用C語言方式實現MakeArray()函數,該函數的內部是動態生成的一個MyString陣列,並通過迴圈填充為星號字串,最後使用POP彈出,並輸出結果,觀察後嘗試用組合實現。

void makeArray()
{
  char MyString[30];
  for(int i=0;i<30;i++)
  {
    myString[i] = "*";
  }
}

call makeArray()

上述C語言程式碼如果翻譯為組合格式則如下所示,程式碼使用組合語言實現makeArray的程式,該程式開闢了一個長度為30的陣列,將其中的元素填充為*,然後彈出兩個元素,並將它們輸出到控制檯。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

.data
  szFmt BYTE '出棧資料: %x ',0dh,0ah,0
.code
  makeArray PROC
    push ebp
    mov ebp,esp
    
    ; 開闢區域性陣列
    sub esp,32                    ; MyString基地址位於 [ebp - 30]
    lea esi,[ebp - 30]            ; 載入MyString的地址
    
    ; 填充資料
    mov ecx,30                    ; 迴圈計數
  S1: mov byte ptr ds:[esi],'*'     ; 填充為*
    inc esi                       ; 每次遞增一個位元組
    loop S1
    
    ; 彈出2個元素並輸出,出棧資料
    pop eax
    invoke crt_printf,addr szFmt,eax
    
    pop eax
    invoke crt_printf,addr szFmt,eax  
    
    ; 以下平棧,由於我們手動彈出了2個資料
    ; 則平棧 32 - (2 * 4) = 24 
    add esp,24                    ; 平棧
    mov esp,ebp
    pop ebp                       ; 恢復EBP
    ret
  makeArray endp

  main PROC
    call makeArray
    invoke ExitProcess,0
  main ENDP
END main

在該程式的開始部分,我們首先通過push ebpmov ebp,esp指令儲存舊的基址指標並將當前棧頂指標(ESP)儲存到ebp中。然後,我們使用sub esp, 32指令開闢一個長度為30的陣列MyString。我們將MyString陣列的基地址儲存在[ebp - 30]的位置。使用lea esi, [ebp - 30]指令將MyString的基地址載入到esi暫存器中。該指令偏移ebp-30是因為ebp-4MakeArray函數的第一個引數的位置,因此需要增加四個位元組。我們利用MOV byte ptr ds:[esi],'*'指令將MyString中的所有元素填充為*

然後,使用pop eaxinvoke crt_printf, addr szFmt, eax指令兩次彈出兩個元素,並使用crt_printf函數輸出這些元素。該函數在msvcrt.dll庫中實現,用於將格式化的資訊輸出到控制檯。在輸出資料之後,我們通過add esp,24mov esp,ebp指令將堆疊平衡,恢復舊的基址指標ebp,然後從堆疊中彈出ebp,並通過ret指令返回到呼叫程式。


接著我們繼續來對比一下堆疊中引數傳遞的異同點,平棧的方式一般可分為呼叫者平棧和被呼叫者平棧,在使用堆疊傳參時,需要平衡棧以恢復之前的堆疊指標位置。

  • 當平棧由被呼叫者完成時,被呼叫函數使用ret指令將控制權返回到呼叫函數,並從堆疊中彈出返回地址。此時,被呼叫函數需要將之前分配的區域性變數從堆疊中彈出,以便呼叫函數能夠恢復堆疊指標的位置。因此,被呼叫函數必須知道其在堆疊上分配的記憶體大小,並將該大小與其ret指令中的引數相匹配,以便呼叫函數可以正確恢復堆疊指標位置。

  • 當平棧由呼叫者完成時,呼叫函數需要在呼叫子函數之前平衡堆疊。因此,呼叫函數需要知道子函數在堆疊上分配的記憶體大小,並在呼叫子函數之前向堆疊提交額外的空間。呼叫函數可以使用add esp, N指令來恢復堆疊指標的位置,其中 N 是被呼叫函數在堆疊上分配的記憶體大小。然後,呼叫函數呼叫被呼叫函數,該函數將返回並將堆疊指標恢復到呼叫函數之前的位置。

如下這段組合程式碼中筆者分別實現了兩種呼叫方式,其中MyProcA函數是一種被呼叫者平棧,由於呼叫者並沒有堆疊修正所以需要在函數內部通過使用ret 12的方式平棧,之所以是12是因為我們使用了三個區域性變數,而第二個MyProcB函數則是呼叫者平棧,該方式在函數內部並沒有返回任何引數,所以在呼叫函數結束後需要通過add esp,4的方式對堆疊進行修正。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

.data
  szFmt BYTE '資料: %d ',0dh,0ah,0
.code
  ; 第一種方式:被呼叫者平棧
  MyProcA PROC
    push ebp
    mov ebp,esp
    
    xor eax,eax
    mov eax,dword ptr ss:[ebp + 16]   ; 獲取第一個引數
    mov ebx,dword ptr ss:[ebp + 12]   ; 獲取第二個引數
    mov ecx,dword ptr ss:[ebp + 8]    ; 獲取第三個引數
    
    add eax,ebx
    add eax,ebx
    add eax,ecx
    
    mov esp,ebp
    pop ebp
    ret 12       ; 此處ret12可平棧,也可使用 add ebp,12
  MyProcA endp

  ; 第二種方式:呼叫者平棧
  MyProcB PROC
    push ebp
    mov ebp,esp
    
    mov eax,dword ptr ss:[ebp + 8]
    add eax,10
    
    mov esp,ebp
    pop ebp
    ret
  MyProcB endp

  main PROC
    ; 第一種被呼叫者MyProcA平棧 3*4 = 12
    push 1
    push 2
    push 3
    call MyProcA
    invoke crt_printf,addr szFmt,eax
    
    ; 第二種方式:呼叫者平棧
    push 10
    call MyProcB
    add esp,4
    invoke crt_printf,addr szFmt,eax
    
    int 3
  main ENDP
END main

當然瞭如果讀者認為自己維護堆疊很繁瑣,則此時可以直接使用MASM組合器提供的PROC定義過程,使用該偽指令組合器會自行計算所需要使用的變數數量並自行在結尾處新增對應的平棧語句,這段程式碼實現起來將變得非常容易理解。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

.data
  szFmt BYTE '計算引數: %d ',0dh,0ah,0

.code
  my_proc PROC x:DWORD,y:DWORD,z:DWORD   ; 定義過程區域性引數
    LOCAL @sum:DWORD               ; 定義區域性變數存放總和
    
    mov eax,dword ptr ds:[x]
    mov ebx,dword ptr ds:[y]       ; 分別獲取到區域性引數
    mov ecx,dword ptr ds:[z]
    
    add eax,ebx
    add eax,ecx                    ; 相加後放入eax
    mov @sum,eax
    ret
  my_proc endp

  main PROC
    LOCAL @ret_sum:DWORD
    push 10
    push 20
    push 30          ; 傳遞引數
    call my_proc
    mov @ret_sum,eax ; 獲取結果並列印
    
    invoke crt_printf,addr szFmt,@ret_sum
    
    int 3
  main ENDP
END main

這裡筆者還需要擴充套件一個偽指令LOCAL,LOCAL是一種組合語言中的偽指令,用於定義儲存在堆疊上的區域性變數。使用LOCAL指令定義的區域性變數只在函數執行時存在,當函數返回後,該變數將被刪除。根據使用LOCAL指令時指定的記憶體空間大小,組合器將為每個變數保留足夠的空間。

例如,下面是一個使用LOCAL定義區域性變數的範例:

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib

.code
  main PROC
    ; 定義區域性變數,自動壓棧/平棧
    LOCAL var_byte:BYTE,var_word:WORD,var_dword:DWORD
    LOCAL var_array[3]:DWORD
    
    ; 填充區域性變數
    mov byte ptr ds:[var_byte],1
    mov word ptr ds:[var_word],2
    mov dword ptr ds:[var_dword],3
    
    ; 填充陣列方式1
    lea esi,dword ptr ds:[var_array]
    mov dword ptr ds:[esi],10
    mov dword ptr ds:[esi + 4],20
    mov dword ptr ds:[esi + 8],30
    
    ; 填充陣列方式2
    mov var_array[0],100
    mov var_array[1],200
    mov var_array[2],300
    
    invoke ExitProcess,0
  main ENDP
END main

在上述範例程式碼中,main過程使用LOCAL指令定義了幾個區域性變數,包括一個位元組型別的變數var_byte、一個字型別的變數var_word、一個雙字型別的變數var_dword和一個包含三個雙字元素的陣列var_array

在程式碼中,我們使用mov指令填充這些變數的值。對於位元組型別、字型別和雙字型別的變數,使用mov byte ptr ds:[var_byte], 1mov word ptr ds:[var_word], 2mov dword ptr ds:[var_dword], 3指令將相應的常數值儲存到變數中。在填充陣列時,分別使用了兩種不同的方式。一種方式是使用lea指令將陣列的地址載入到esi暫存器中,然後使用mov dword ptr ds:[esi],10等指令將相應的常數值儲存到陣列中。另一種方式是直接存取陣列元素,如mov var_array[0], 100等指令。需要注意,由於陣列元素在記憶體中是連續儲存的,因此可以使用[]操作符存取陣列元素。

在組合中使用LOCAL偽指令來實現自動計算區域性變數空間,以及最後的平棧操作,將會極大的提高開發效率。

10.4 USES/ENTER

USES是組合語言中的偽指令,用於儲存一組暫存器的狀態,以便函數呼叫過程中可以使用這些暫存器。使用USES時,程式可以儲存一組需要保護的暫存器,組合器將在程式入口處自動向堆疊壓入這些暫存器的值。讀者需注意,我們可以在需要儲存暫存器的程式段中使用USES來保護暫存器,但不應在整個程式中重複使用暫存器。

ENTER也是一種偽指令,用於建立函數呼叫過程中的堆疊幀。使用ENTER時,程式可以定義一個名為ENTER的指定大小的堆疊幀。該指令會將新的基準指標ebp 壓入堆疊同時將當前的基準指標ebp儲存到另一個暫存器ebx中,然後將堆疊指標esp減去指定大小的值,獲取新的基地址,並將新的基地址儲存到ebp 中。之後,程式可以在此幀上建立和存取區域性變數,並使用LEAVE指令將堆疊幀刪除,將ebp恢復為舊的值,同時將堆疊指標平衡。

在使用USES和ENTER指令時,需要了解這些指令在具體的平臺上的支援情況,以及它們適用的呼叫約定。通常情況下,在函數開頭,我們將使用ENTER建立堆疊幀,然後使用USES指定需要保護的暫存器。在函數末尾,我們使用LEAVE刪除堆疊幀。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib

.code
  ; USES 自動壓入 eax,ebx,ecx,edx
  my_proc PROC USES eax ebx ecx edx x:DWORD,y:DWORD
    enter 8,0          ; 自動保留8位元組堆疊空間
    add eax,ebx
    leave
  my_proc endp

  main PROC
    mov eax,10
    mov ebx,20
    call my_proc
    
    int 3
  main ENDP
END main

10.5 STRUCT/UNION

STRUCT和UNION是組合語言中的資料型別,STRUCT是一種複合資料型別,它將多個不同型別的變數按順序放置在一起,並使用單個名稱來參照集合。使用STRUCT時,我們可以將不同型別的變數組合成一個結構體並定義其屬性,如結構體中包含的成員變數的資料型別、名稱和位置。

例如,下面是一個使用STRUCT定義自定義型別的範例:

; 定義一個名為 MyStruct 的結構體,包含兩個成員變數。
MyStruct STRUCT
   Var1 DWORD ?
   Var2 WORD ?
MyStruct ENDS

在上述範例程式碼中,我們使用STRUCT定義了一個名為MyStruct 的結構體,其中包含兩個成員變數Var1Var2。其中,Var1DWORD型別的資料型別,以問號?形式指定了其預設值,Var2WORD型別的資料型別。

另一個資料型別是UNION,它也是一種複合資料型別,用於將多個不同型別的變數疊加在同一記憶體位置上。使用UNION時,程式記憶體中的資料將只佔用所有成員變數中最大的資料型別變數的大小。與結構體不同,聯合中的所有成員變數共用相同的記憶體位置。我們可以使用一種成員變數來參照記憶體位置,但在任何時候僅能有一種成員變數儲存在該記憶體位置中。

例如,下面是一個使用UNION定義自定義型別的範例:

; 定義一個名為 MyUnion 的聯合,包含兩個成員變數。
MyUnion UNION
   Var1 DWORD ?
   Var2 WORD ?
MyUnion ENDS

在上述範例程式碼中,我們使用UNION定義了一個名為MyUnion的聯合,其中包含兩個不同型別的成員變數Var1Var2,將它們相對應地置於聯合的同一記憶體位置上。

讀者在使用STRUCTUNION時,需要根據記憶體分佈和變數型別來正確存取成員變數的值。在組合語言中,結構體和聯合主要用於定義自定義資料型別、通訊協定和系統資料結構等,如下一段程式碼則是組合語言中實現結構體賦值與取值的總結。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib

; 定義座標結構
MyPoint Struct
  pos_x DWORD ?
  pos_y DWORD ?
  pos_z DWORD ?
MyPoint ends

; 定義人物結構
MyPerson Struct
  Fname db 20 dup(0)
  fAge  db 100
  fSex  db 20
MyPerson ends

.data
  ; 宣告結構: 使用 <>,{}符號均可
  PtrA MyPoint <10,20,30>
  PtrB MyPoint {100,200,300}
  
  ; 宣告結構: 使用MyPerson宣告結構
  UserA MyPerson <'lyshark',24,1>

.code
  main PROC
    ; 獲取結構中的資料
    lea esi,dword ptr ds:[PtrA]
    mov eax,(MyPoint ptr ds:[esi]).pos_x
    mov ebx,(MyPoint ptr ds:[esi]).pos_y
    mov ecx,(MyPoint ptr ds:[esi]).pos_z
    
    ; 向結構中寫入資料
    lea esi,dword ptr ds:[PtrB]
    mov (MyPoint ptr ds:[esi]).pos_x,10
    mov (MyPoint ptr ds:[esi]).pos_y,20
    mov (MyPoint ptr ds:[esi]).pos_z,30
    
    ; 直接獲取結構中的資料
    mov eax,dword ptr ds:[UserA.Fname]
    mov ebx,dword ptr ds:[UserA.fAge]
    int 3
  main ENDP
END main

接著我們來實現一個輸出結構體陣列的功能,結構陣列其實就是一維的空間,因此使用兩個比例因子即可實現定址操作,如下程式碼我們先來實現一個簡單的功能,只遍歷第一層,結構陣列外層的資料。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

; 定義座標結構
MyPoint Struct
  pos_x DWORD ?
  pos_y DWORD ?
  pos_z DWORD ?
MyPoint ends

; 定義迴圈結構
MyCount Struct
  count_x DWORD ?
  count_y DWORD ?
MyCount ends

.data
  ; 宣告結構: 使用 <>,{}符號均可
  PtrA  MyPoint <10,20,30>,<40,50,60>,<70,80,90>,<100,110,120>
  Count MyCount <0,0>
  szFmt BYTE '結構資料: %d',0dh,0ah,0

.code
  main PROC
    ; 獲取結構中的資料
    lea esi,dword ptr ds:[PtrA]
    mov eax,(MyPoint ptr ds:[esi]).pos_x          ; 獲取第一個結構X
    mov eax,(MyPoint ptr ds:[esi + 12]).pos_x     ; 獲取第二個結構X
    
    ; while 迴圈輸出結構的每個首元素元素
    mov (MyCount ptr ds:[Count]).count_x,0
  S1: cmp (MyCount ptr ds:[Count]).count_x,48        ; 12 * 4 = 48
    jge lop_end
    
    mov ecx,(MyCount ptr ds:[Count]).count_x
    mov eax,dword ptr ds:[PtrA + ecx]              ; 尋找首元素
    invoke crt_printf,addr szFmt,eax
    
    mov eax,(MyCount ptr ds:[Count]).count_x
    add eax,12                                     ; 每次遞增12
    mov (MyCount ptr ds:[Count]).count_x,eax
    jmp S1

  lop_end:
    int 3
  main ENDP
END main

接著我們遞增難度,通過每次遞增將兩者的偏移相加,獲得比例因子,通過因子巢狀雙層迴圈實現定址列印。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

; 定義座標結構
MyPoint Struct
  pos_x DWORD ?
  pos_y DWORD ?
  pos_z DWORD ?
MyPoint ends

; 定義迴圈結構
MyCount Struct
  count_x DWORD ?
  count_y DWORD ?
MyCount ends

.data
  ; 宣告結構: 使用 <>,{}符號均可
  PtrA  MyPoint <10,20,30>,<40,50,60>,<70,80,90>,<100,110,120>
  Count MyCount <0,0>
  szFmt BYTE '結構資料: %d',0dh,0ah,0

.code
  main PROC
    ; 獲取結構中的資料
    lea esi,dword ptr ds:[PtrA]
    mov eax,(MyPoint ptr ds:[esi]).pos_x          ; 獲取第一個結構X
    mov eax,(MyPoint ptr ds:[esi + 12]).pos_x     ; 獲取第二個結構X
    
    ; while 迴圈輸出結構的每個首元素元素
    mov (MyCount ptr ds:[Count]).count_x,0
  S1: cmp (MyCount ptr ds:[Count]).count_x,48        ; 12 * 4 = 48
    jge lop_end
    
    mov (MyCount ptr ds:[Count]).count_y,0
  S3: cmp (MyCount ptr ds:[Count]).count_y,12        ; 3 * 4 = 12
    jge S2
    
    mov eax,(MyCount ptr ds:[Count]).count_x
    add eax,(MyCount ptr ds:[Count]).count_y       ; 相加得到比例因子
    
    mov eax,dword ptr ds:[PtrA + eax]              ; 使用相對變址定址
    invoke crt_printf,addr szFmt,eax
    
    mov eax,(MyCount ptr ds:[Count]).count_y
    add eax,4                                      ; 每次遞增4
    mov (MyCount ptr ds:[Count]).count_y,eax
    jmp S3 
    
  S2: mov eax,(MyCount ptr ds:[Count]).count_x
    add eax,12                                     ; 每次遞增12
    mov (MyCount ptr ds:[Count]).count_x,eax
    jmp S1

  lop_end:
    int 3
  main ENDP
END main

結構體同樣支援內嵌的方式,如下Rect指標中內嵌兩個MyPoint分別指向左子域和右子域,這裡順便定義一個MyUnion聯合體把,其使用規範與結構體完全一致,只不過聯合體只能儲存一個資料.

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib

; 定義座標結構
MyPoint Struct
  pos_x DWORD ?
  pos_y DWORD ?
  pos_z DWORD ?
MyPoint ends

; 定義左右結構
Rect Struct
  Left MyPoint <>
  Right MyPoint <>
Rect ends

; 定義聯合體
MyUnion Union
  my_dword DWORD ?
  my_word WORD ?
  my_byte BYTE ?
MyUnion ends

.data
  PointA Rect <>
  PointB Rect {<10,20,30>,<100,200,300>}
  test_union MyUnion {1122h}
  szFmt BYTE '結構資料: %d',0dh,0ah,0
.code
  main PROC
    ; 巢狀結構的賦值
    mov dword ptr ds:[PointA.Left.pos_x],100
    mov dword ptr ds:[PointA.Left.pos_y],200
    mov dword ptr ds:[PointA.Right.pos_x],100
    mov dword ptr ds:[PointA.Right.pos_y],200
    
    ; 通過地址定位
    lea esi,dword ptr ds:[PointB]
    mov eax,dword ptr ds:[PointB]        ; 定位第一個MyPoint
    mov eax,dword ptr ds:[PointB + 12]   ; 定位第二個內嵌MyPoint

    ; 聯合體的使用
    mov eax,dword ptr ds:[test_union.my_dword]
    mov ax,word ptr ds:[test_union.my_word]
    mov al,byte ptr ds:[test_union.my_byte]
  main ENDP
END main

當然有了結構體這一成員的加入,我們同樣可以在組合層面實現連結串列的定義與輸出,如下程式碼所示,首先定義一個ListNode用於儲存連結串列結構的資料域與指標域,接著使用TotalNodeCount定義連結串列節點數量,最後使用REPEAT偽指令開闢ListNode物件的多個範例,其中的NodeData域包含一個1-15的資料,後面的($ + Counter * sizeof ListNode)則是指向下一個連結串列的頭指標,通過不斷遍歷則可輸出整個連結串列。

  .386p
  .model flat,stdcall
  option casemap:none

include windows.inc
include kernel32.inc
includelib kernel32.lib
include msvcrt.inc
includelib msvcrt.lib

ListNode Struct
  NodeData DWORD ?
  NextPtr  DWORD ?
ListNode ends

TotalNodeCount = 15
Counter = 0

.data
  LinkList LABEL PTR ListNode
  REPEAT TotalNodeCount
    Counter = Counter + 1
    ListNode <Counter,($ + Counter * sizeof ListNode)>
  ENDM
  ListNode<0,0>                ; 標誌著結構連結串列的結束

  szFmt BYTE '結構地址: %x 結構資料: %d',0dh,0ah,0
.code
  main PROC
    mov esi,offset LinkList
    
    ; 判斷下一個節點是否為<0,0>
  L1: mov eax,(ListNode PTR [esi]).NextPtr
    cmp eax,0
    je lop_end
    
    ; 顯示節點資料
    mov eax,(ListNode PTR [esi]).NodeData
    invoke crt_printf,addr szFmt,esi,eax
    
    ; 獲取到下一個節點的指標
    mov esi,(ListNode PTR [esi]).NextPtr
    jmp L1
    
  lop_end:
    int 3
    
  main ENDP
END main

本文作者: 王瑞
本文連結: https://www.lyshark.com/post/e43f6d19.html
版權宣告: 本部落格所有文章除特別宣告外,均採用 BY-NC-SA 許可協定。轉載請註明出處!