5.10 汇编语言:汇编过程与结构

举报
微软技术分享 发表于 2023/08/24 09:56:05 2023/08/24
【摘要】 过程的实现离不开堆栈的应用,堆栈是一种后进先出(LIFO)的数据结构,最后压入栈的值总是最先被弹出,而新数值在执行压栈时总是被压入到栈的最顶端,栈主要功能是暂时存放数据和地址,通常用来保护断点和现场。栈是由CPU管理的线性内存数组,它使用两个寄存器(SS和ESP)来保存栈的状态,SS寄存器存放段选择符,而ESP寄存器的值通常是指向特定位置的一个32位偏移值,我们很少需要直接操作ESP寄存器,...

过程的实现离不开堆栈的应用,堆栈是一种后进先出(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 许可协议。转载请注明出处!

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。