前言
经过前面的学习,我们对汇编语句的关键字和语法等有了一定程度的了解。同时我们也学会了汇编开发工具的简单使用,现在我们需要通过一个项目,能够让我们掌握简单的汇编程序编写能力。在此过程中可能会遇到一些问题,比如语句从 C/C++ 转换到汇编的过程,以及写完程序后不知道如何进行调试。完成本程序后,同时进一步熟悉在 OD中调试编写好的程序。
一、项目概述
本项目实现一个可以增删改查的电话本,电话号码数据非永久保存,关闭程序后数据会丢失,后续可以自行实现永久保存到文本或数据库。实现过程中我们将熟悉汇编代码开发,能够在 OD中看懂反汇编代码,知道如何通过 OD 调试自己写的程序。
二、项目参考知识点
2.1 模式定义
程序的第一部分包含模式和源程序格式的定义语句。这些定义语句指定了所使用的指令集、工作模式和格式。
-
386:这是一个汇编语言的伪指令,表示在 80386 及更高版本的处理器中使用该指令集。 -
model flat, stdcall:模式定义语句,格式为model 内存模式[, 调用模式]。这里指定了内存模式为flat,调用模式为stdcall。 -
option casemap:none:选项设定语句,设定为对大小写敏感。
2.2 头文件
与 C 语言类似,我们可以在汇编工程中添加头文件和源文件。在汇编语言中,头文件的后缀名为 .INC,而源文件的后缀名为 .ASM。当我们需要使用系统函数或自定义函数时,可以通过 include 指令引入相应的文件。
-
include windows.inc:引入 Windows 系统头文件。 -
include msvcrt.inc:引入 C 语言库的头文件。 -
includelib msvcrt.lib:引入 C 语言库对应的.lib文件。
2.3 段的定义
数据段:
-
.data:用于定义可读可写的已初始化变量。这些变量在源程序中已经定义了初始值,并且在程序执行过程中可能会被修改。 -
.data?:用于定义可读可写的未初始化变量。这些变量通常在程序运行时才被使用,不会占用可执行文件的体积。 -
.const:用于定义常量,如需要显示的字符串信息。这些常量在程序执行过程中不会被修改,具有“可读不可写”的属性。
代码段:
-
所有指令都必须放在代码段中。在 Win32 环境中,数据段是不可执行的。
堆栈段:
-
在程序中不需要显式定义堆栈段,系统会自动分配堆栈空间。堆栈段的内存属性是可读可写的。
2.4 数据类型
MASM 定义了多种内部数据类型,每种数据类型描述了该类型的变量和表达式的取值范围。以下是一些常见的数据类型及其说明:
-
BYTE:8 位无符号整数。
-
SBYTE:8 位有符号整数。
-
WORD:16 位无符号整数。
-
SWORD:16 位有符号整数。
-
DWORD:32 位无符号整数。
-
SDWORD:32 位有符号整数。
-
FWORD:48 位整数。
-
QWORD:64 位整数。
-
TBYTE:80 位(10 字节)整数。
-
REAL4:32 位(4 字节)IEEE 浮点数。
-
REAL8:64 位(8 字节)IEEE 浮点数。
-
REAL10:80 位(10 字节)IEEE 浮点数。
2.5 标号,变量和结构体
标号的作用:
-
在程序中,当需要跳转到另一个位置时,需要有一个标识来指示新的位置。通过在目标地址前加上一个标号,可以在指令中使用标号来代替直接使用地址。
变量:
-
变量的值在程序运行过程中是需要改变的,因此必须定义在可写的段内。根据定义位置的不同,变量可以分为全局变量和局部变量两种。
结构体:
-
结构体实际上是由多个字段组成的数据模板,相当于一种自定义的数据类型。结构体中的每个字段可以是字节、字、双字、字符串等数据类型。
MASM 中标号和变量的命名规则:
-
可以使用字母、数字、下划线及符号
@、$和?组成。 -
第一个字符不能是数字。
-
长度不能超过 240 个字符。
-
不能使用指令名等关键字。
-
在作用域内必须是唯一的。
2.6 标号的定义
在程序中使用跳转指令时,可以使用标号来表示跳转的目的地。编译器在编译时会将标号替换为实际的地址。标号可以定义在目的指令同一行的头部,也可以定义在目的指令前一行。
如果标号后面有一个冒号(:),表示该标号的作用域为当前子程序,且标号不能重名。如
标号名:
目的指令字
如果标号后面有两个冒号(::),表示该标号的作用域为整个程序,且标号可以重名。如
标号名::
目的指令字
2.7 全局变量
全局变量的作用域是整个程序。在 Win32 汇编中,全局变量定义在 `.data` 和 `.data?` 段内,可以同时定义变量的类型和长度。
- `.data` 段:
变量名 类型 初始化1, 初始化2, ...
- `.data?` 段:
变量名 类型 重复次数 dup(初始化1, 初始化2, ...)
`dup` 关键字用于初始化数据,按照 `初始化1, 初始化2, ...` 的顺序在内存中重复设定的次数。
2.8 局部变量
局部变量的作用域仅限于单个子程序。在进入子程序时,通过修改堆栈指针 `esp` 来预留出所需的空间;在 `ret` 指令返回主程序之前,通过恢复 `esp` 来丢弃这些空间。MASM 使用 `local` 伪指令来支持局部变量的定义。
- `local` 伪指令的语法:
```asm
local 变量名1:类型
local 变量名1:重复次数 dup(类型), 变量名2:重复次数 dup(类型)
```
**注**:
- 局部变量无法在定义时指定初始化值,因为 `local` 伪指令只能简单地预留空间。与全局变量不同,局部变量的起始值是随机的,是其他子程序执行后在堆栈中留下的垃圾。因此,对局部变量的值一定要进行初始化。通常调用 API 或 C 库函数来初始化。
**示例**:
```asm
.data
; 声明全局变量
g_stContacts CONTACTSSTRUCT 100 dup(<'0'>) ; 定义结构体数组
g_nCount DWORD 0 ; 元素个数
g_nCountMax DWORD 100 ; 最大存放元素
g_strTemContacts CONTACTSSTRUCT<'0','0'> ; 接收输入信息
```
2.9 结构体的定义
汇编语言中的结构与 C/C++ 中的结构基本相同。在 MASM 中,使用 `STRUCT` 和 `ENDS` 伪指令来定义结构体。在结构体内部,使用与定义普通变量相同的格式来定义域。
- 结构体定义的语法:
```asm
结构体名字 STRUCT
域的声明
结构体名字 ENDS
```
**注**:
- 域的初始化:如果结构体的域有初始化值,在定义结构体变量时,这些初始值将成为结构体该域的默认值。
- 结构体中可以使用多种类型的初始值:
- 未定义:使用 `?` 表示域内容未定义。
- 字符串:用引号包括字符串。
- 整数:整数常量或整数表达式。
- 数组:当域是一个数组时,可以使用 `dup` 操作符初始化数组元素。
**示例**:
```asm
.data
; 定义结构体
CONTACTSSTRUCT STRUCT
szName BYTE 25 dup(0) ; 名字
szPhNumber BYTE 12 dup(0) ; 电话号码
CONTACTSSTRUCT ENDS
```
2.10 过程的定义和使用
在高级语言中,我们通常将程序分成若干个子函数,这样做方便维护和理解。一个类中的函数或方法等同于封装在一个汇编语言模块中的过程和数据的集合。在 MASM 中,使用 `PROC` 和 `ENDP` 伪指令来定义过程。过程块内通常包含除程序启动过程之外的其他语句,并以 `RET` 指令结束。
- 过程定义的语法:
```asm
过程名字 PROC [参数1:类型, 参数2:类型]
语句块
过程名字 ENDP
```
**CALL 和 RET 指令**:
- `CALL` 指令指示处理器在新的内存地址执行指令,以实现对过程的调用。
- 过程使用 `RET` 指令从过程返回到程序中过程被调用的地方的下一条语句处。
2.11 函数调用堆栈平衡
我们知道函数的参数与局部变量都是保存在栈中的,一般情况下使用栈寄存器 `ESP` 加上偏移即可访问到我们需要的数据。但是如果此时我们调用一个函数,它很有可能会拥有自己的参数与局部变量,因此免不了要对堆栈进行一定的操作。如果此时不加任何处理就返回到主程序中,就会引起 `ESP` 的混乱,导致主程序无法正确访问自己的参数与局部变量。正是因为这个问题,才诞生了 4 种函数调用约定,这些约定不仅规定了堆栈由谁来“清理”,还规定了调用函数时参数的入栈顺序。

以下是每种调用约定的示例代码,展示了参数入栈顺序和堆栈回收方式:
(1). C 规范(`cdecl`)
- **关键字**:`cdecl`
- **参数入栈顺序**:从右到左
- **回收堆栈**:调用者负责
#### 示例代码
#include <stdio.h>
// 使用 cdecl 调用约定
int __cdecl add(int a, int b) {
return a + b;
}
int main() {
int result = add(1, 2);
printf("Result: %d\n", result);
return 0;
}
#### 解释
- `add(1, 2)` 调用时,参数 `2` 先入栈,然后是 `1`。
- 调用者(`main` 函数)负责清理堆栈。
(2). Pascal 规范(`pascal`)
- **关键字**:`pascal`
- **参数入栈顺序**:从左到右
- **回收堆栈**:被调用者负责
#### 示例代码
program Example;
function Add(a, b: Integer): Integer; pascal;
begin
Add := a + b;
end;
begin
WriteLn('Result: ', Add(1, 2));
end.
#### 解释
- `Add(1, 2)` 调用时,参数 `1` 先入栈,然后是 `2`。
- 被调用者(`Add` 函数)负责清理堆栈。
(3). 快速调用规范(`fastcall`)
- **关键字**:`fastcall`
- **参数入栈顺序**:从右到左,使用寄存器传参
- **回收堆栈**:被调用者负责
#### 示例代码
#include <stdio.h>
// 使用 fastcall 调用约定
int __fastcall add(int a, int b) {
return a + b;
}
int main() {
int result = add(1, 2);
printf("Result: %d\n", result);
return 0;
}
#### 解释
- `add(1, 2)` 调用时,参数 `2` 先入栈,然后是 `1`。
- 优先使用寄存器传递参数。
- 被调用者(`add` 函数)负责清理堆栈。
(4). 标准调用规范(`stdcall`)
- **关键字**:`stdcall`
- **参数入栈顺序**:从右到左
- **回收堆栈**:被调用者负责
#### 示例代码
#include <stdio.h>
// 使用 stdcall 调用约定
int __stdcall add(int a, int b) {
return a + b;
}
int main() {
int result = add(1, 2);
printf("Result: %d\n", result);
return 0;
}
#### 解释
- `add(1, 2)` 调用时,参数 `2` 先入栈,然后是 `1`。
- 被调用者(`add` 函数)负责清理堆栈。
每种调用约定在参数入栈顺序和堆栈回收方式上有所不同。了解这些调用约定的特点有助于更好地理解和编写跨平台的代码。
### 汇编示例
例如下面的例子:调用该函数时(观察 `ESP`),先传入两个参数,然后调用 `CALL` 指令执行函数。`CALL` 指令的下一条指令不是 `ADD ESP, XX`,则说明是栈内平衡堆栈。那么当函数返回时,此时的 `ESP` 应该和调用该函数时传参之前是一样的,如果不同则表示破坏了平衡。
三、项目实现
创建控制台项目程序:

设置代码中文兼容:

项目结构:

运行界面:

项目代码如下:
Asm_PhoneBook.Inc
.386
.model flat,stdcall
option casemap:none
include windows.inc
include msvcrt.inc
includelib msvcrt.lib
include kernel32.inc
includelib kernel32.lib
.data
;定义结构体
CONTACTSSTRUCT STRUCT
szName byte 25 dup(0) ;名字
szPhNumber byte 12 dup(0) ;电话号码
CONTACTSSTRUCT ends
PCONTACTSSTRUCT typedef ptr CONTACTSSTRUCT ;取别名(指正类型)
;声明全局变量
g_stContacts CONTACTSSTRUCT 100 dup(<'0'>) ;定义结构体数组
g_nCount dword 0 ;元素个数
g_nCountMax dword 100 ;最大的存放元素
g_strTemContacts CONTACTSSTRUCT <'0','0'> ;接收输入信息
;定义格式控制符
g_szScanfFormat byte "%s %s",0h
g_szScanName byte "%s",0h
g_szScanfFormatS db "%s",0
g_szScanfFormatSS db "编号:%d",0dh,0ah,
"姓名:%s",0dh,0ah,
"号码:%s",0dh,0ah,0
g_szEmpty db "暂无数据!",0
g_szAddStr byte "请输入姓名 号码:",0
g_szRemove byte "请输入要删除的用户名:",0
g_szModify byte "请输入要修改的用户名:",0
g_szModifyData byte "请输入新的用户名 新的号码:",0
g_szFind byte "请输入要查询的姓名:",0
g_nTemp dword 0
g_szCLS byte "cls",0
g_szPause byte "pause",0
g_szOK byte "操作成功",0
g_szScanfFormatD byte "%d",0
g_menu byte 0ah,0dh,"欢迎使用电话本",0ah,0dh,
"1.增加记录",0ah,0dh,
"2.删除记录",0ah,0dh,
"3.修改记录",0ah,0dh,
"4.查询记录",0ah,0dh,
"5.查询所有",0ah,0dh,
"请输入你的操作选项:",0ah,0dh,0
.code
sys_cls proc
push offset g_szCLS
call crt_system
add esp,4
ret
sys_cls endp
;输出菜单
MenuInfo proc ;无参数
call sys_cls
push offset g_menu
call crt_printf
add esp,4
ret
MenuInfo endp
;选择菜单项
SwitchMenu proc ;无参数
cmp eax,1
jnz @T1
cmp [g_nTemp],1 ;1 添加
jz Step1
cmp [g_nTemp],2 ;2 删除
jz Step2
cmp [g_nTemp],3 ;3 修改
jz Step3
cmp [g_nTemp],4 ;4 查询
jz Step4
cmp [g_nTemp],5 ;5 查询
jz Step5
jmp STEND
;1 添加
Step1:
call ADD_USER
jmp STEND
;2 删除
Step2:
call RemoveData
jmp STEND
;3 修改
Step3:
call ModifyData
jmp STEND
;4 查询
Step4:
call FindData
jmp STEND
Step5:
call FindAll
jmp STEND
STEND:
ret
@T1:
call crt_getchar
cmp eax,0ah
jnz @T1
jmp CYCLE_MAIN
SwitchMenu endp
FindAll proc
call sys_cls
;传入首地址
lea eax,g_stContacts
xor ebx,ebx
;对比当前容量
cmp dword ptr [g_nCount],0
je Empty
jne CYCLE_READ
;无数据结束
Empty:
push offset g_szEmpty
push offset g_szScanfFormatS
call crt_printf
add esp,8
jmp ShowAll_End
;读取数据
CYCLE_READ:
;传参从右至左
add eax,19h
push eax
sub eax,19h
push eax
push ebx
push offset g_szScanfFormatSS
call crt_printf
add esp,16
;printf函数会影响A/C/D寄存器的值,需要重新传入地址
inc ebx
mov ecx,sizeof(CONTACTSSTRUCT)
;mov ecx,30
imul ecx,ebx
lea eax,g_stContacts
add eax,ecx
cmp dword ptr [g_nCount],ebx
je ShowAll_End
jmp CYCLE_READ
;查询结束
ShowAll_End:
push offset g_szPause
call crt_system
add esp,4
ret
FindAll endp
ADD_USER proc ;无参数
push eax
push ebx ;先保存一份
lea eax,g_szAddStr
push eax ;r/m32/imm32
call crt_printf
add esp,4
;根据ecx的值找到下一个结构体名字数组的地址
lea esi,[g_stContacts] ;保存数据的结构体数组
mov ecx,g_nCount ;获取当前已插入的用户个数
mov eax,sizeof(CONTACTSSTRUCT) ;计算结构体的大小
imul eax,ecx
add esi,eax ;移动结构体数组的指针(用户个数*结构体的大小)
;调用crt_scanf函数接收输入数据
lea eax,[esi+CONTACTSSTRUCT.szPhNumber] ;第二个参数 电话号码
lea edx,[esi+CONTACTSSTRUCT.szName] ;第一个参数 姓名
push eax
push edx
push offset g_szScanfFormat ;格式控制符
call crt_scanf
add esp,12 ;平衡堆栈
;inc g_nCount ;用户个数加1
inc dword ptr[g_nCount]
pop eax ;平衡堆栈
pop ebx
push offset g_szOK ;操作成功
call crt_printf
add esp,4
push offset g_szPause
call crt_system
add esp,4
ret
ADD_USER endp
FindData proc
push offset g_szFind
call crt_printf
add esp,4
;先清除
lea edi,[g_stContacts]
mov ebx,sizeof(CONTACTSSTRUCT)
push ebx
push 0
push edi
call crt_memset
add esp,12
;输入数据
lea edi,[g_strTemContacts.szName] ;保存结构体中名字的地址
push edi
push offset g_szScanName ;格式控制符
call crt_scanf
add esp,8
;开始查询
mov ecx,0 ;初始化循环次数 默认从0开始
mov eax,[g_nCount]
CYCLE_MARK:
cmp ecx,g_nCount ;判断是否结束循环
;ret
je @end
lea esi,[g_stContacts] ;保存数据的结构体数组
lea edi,[g_strTemContacts.szName] ;获取当前已插入用户
mov eax,sizeof(CONTACTSSTRUCT) ;计算结构体的大小
imul eax,ecx
add esi,eax
;比较字符串
mov eax,ecx ;保存外层循环的次数
mov ecx,6
repe cmpsd dword ptr[esi],dword ptr[edi]
je CARRIEDOUT_MARK ;如果找到则跳转 输出信息
mov ecx,eax ;如果没有找到则继续外层循环
inc ecx ;层循环次数加1
jmp CYCLE_MARK ;无条件跳转到外层循环开始位置
CARRIEDOUT_MARK:
;输出信息
mov ecx,eax
lea esi,[g_stContacts]
mov ebx,sizeof(CONTACTSSTRUCT)
imul ebx,ecx
add esi,ebx
lea eax,[esi+CONTACTSSTRUCT.szPhNumber]
push eax
push offset g_szScanName
call crt_printf
add esp,8
push offset g_szPause
call crt_system
add esp,4
ret
@end:
ret
FindData endp
ModifyData proc
push offset g_szModify
call crt_printf
add esp,4
;因为修改信息的第一步也是要将当前输入的信息在已保存额数组中查询
;输入数据
lea edi,[g_strTemContacts.szName] ;保存结构体中名字的地址
push edi
push offset g_szScanName ;格式化控制符
call crt_scanf
add esp,8 ;平衡
;开始查询
mov ecx,0
CYCLE_MARK:
cmp ecx,g_nCount ;判断是否结束循环
je @end
;根据ecx的值找到下一个结构体名字数组的地址
lea esi,[g_stContacts] ;保存数据的结构体数组
lea edi,[g_strTemContacts.szName] ;获取当前已插入用户个数
mov eax,sizeof(CONTACTSSTRUCT) ;计算结构体的大小
imul eax,ecx
add esi,eax
;比较字符串
mov eax,ecx ;保存外层循环的次数
mov ecx,6
repe cmpsd dword ptr[esi],dword ptr[edi]
je CARRIEDOUT_MARK ;如果找到则跳转 修改信息
mov ecx,eax ;如果没有找到则继续外层循环
inc ecx ;层循环次数加1
jmp CYCLE_MARK ;无条件跳转到外层循环开始位置
CARRIEDOUT_MARK:
;修改信息
mov ecx,eax
lea esi,[g_stContacts]
mov ebx,sizeof(CONTACTSSTRUCT)
imul ebx,ecx
add esi,ebx
push offset g_szModifyData
call crt_printf
add esp,4
lea ebx,[esi+CONTACTSSTRUCT.szName]
lea eax,[esi+CONTACTSSTRUCT.szPhNumber]
push eax
push ebx
push offset g_szScanfFormat
call crt_scanf
add esp,12
push offset g_szOK ;操作成功
call crt_printf
add esp,4
push offset g_szPause
call crt_system
add esp,4
ret
@end:
ret
ModifyData endp
RemoveData proc
push offset g_szRemove
call crt_printf
add esp,4
;输入数据
lea edi,[g_strTemContacts.szName] ;保存结构体中名字的地址
push edi
push offset g_szScanName ;格式化控制符
call crt_scanf
add esp,8 ;平衡
;开始查询
mov ecx,0
CYCLE_MARK:
cmp ecx,g_nCount ;判断是否结束循环
;ret
je @end
;根据ecx的值找到下一个结构体名字数组的地址
lea esi,[g_stContacts] ;保存数据的结构体数组
lea edi,[g_strTemContacts.szName] ;获取当前已插入用户个数
mov eax,sizeof(CONTACTSSTRUCT) ;计算结构体的大小
imul eax,ecx
add esi,eax
;比较字符串
mov eax,ecx ;保存外层循环的次数
mov ecx,6
repe cmpsd dword ptr[esi],dword ptr[edi]
je CARRIEDOUT_MARK ;如果找到则跳转 修改信息
mov ecx,eax ;如果没有找到则继续外层循环
inc ecx ;层循环次数加1
jmp CYCLE_MARK ;无条件跳转到外层循环开始位置
CARRIEDOUT_MARK:
;删除
;将esi设置为当前要删除的结构体数组的首地址
mov edi,eax ;eax是在上面获取到的表示当前找到的数据的位置
lea edi,[g_stContacts]
mov ebx,sizeof(CONTACTSSTRUCT)
imul ebx,ecx ;edi此时保存的是当前要删除的结构体数组的首地址
mov esi,edi ;esi此时也是当前要删除的结构体数组的首地址
mov ebx,sizeof(CONTACTSSTRUCT)
add esi,ebx ;esi指向要删除的结构体数组的下一个元素的首地址
add ecx,1 ;因为保存数据时是从数组0开始的,所以加1用于计算
;需要移动多少个元素,和数据结构中线性表一样,中间某一个元素被删除后面的向前移动
mov eax,g_nCount
sub eax,ecx ;需要移动的次数
mov ebx,sizeof(CONTACTSSTRUCT)
push ebx ;大小
push 0 ;内容
push edi ;删除的首地址
call crt_memset ;调用置初始化函数
add esp,12
;dec g_nCount
dec dword ptr[g_nCount]
push offset g_szOK ;操作成功
call crt_printf
add esp,4
push 1000
call Sleep
ret
@end:
ret
RemoveData endp
Asm_PhoneBook.Asm
include Asm_PhoneBook.Inc
start:
push ebp
mov ebp,esp
;操作菜单
CYCLE_MAIN:
;清屏
call sys_cls
;invoke crt_system , addr g_szCLS
;显示菜单
call MenuInfo
;输入操作选项
push offset g_nTemp
push offset g_szScanfFormatD ;%d
call crt_scanf
add esp,8 ;循环内平衡堆栈
;其他数
call SwitchMenu
;循环
JMP CYCLE_MAIN
MAIN_END:
mov esp,ebp
pop ebp
end start
end
2264





