汇编语言编程的 40 个基本实践






4.96/5 (127投票s)
讨论一些在汇编语言编程中强烈推荐的基本实践。
目录
- 引言
- 关于指令
- 关于寄存器和内存
- 3. 使用内存变量实现
- 4. 如果可以使用寄存器,就不要使用内存
- 在并发编程中
- 5. 使用原子指令
- 小端序
- 6. 内存表示
- 7. 小端序隐藏的代码错误
- 关于运行时堆栈
- 汇编时 vs 运行时
- 11. 使用加号 (+) 而不是 ADD 实现
- 12. 如果可以使用运算符,就不要使用指令
- 13. 如果可以使用符号常量,就不要使用变量
- 14. 在宏中生成内存块
- 关于循环设计
- 15. 将所有循环逻辑封装在循环体中
- 16. 循环入口和出口
- 17. 不要在循环体内更改 ECX
- 18. 当跳转到后方时…
- 19. 实现 C/C++ 的 FOR 循环和 WHILE 循环
- 20. 通过跳转使您的循环更有效
- 关于过程
- 21. 创建清晰的调用接口
- 22. INVOKE vs. CALL
- 23. 按值传递 vs. 按引用传递
- 24. 避免多个 RET
- 对象数据成员
- 25. 间接操作数和 LEA
- 关于系统 I/O
- 26. 减少系统 I/O API 调用
- 关于 PTR 操作符
- 27. 定义指针、类型转换和解引用
- 28. 在过程中使用 PTR
- 无符号和有符号与 CF 和 OF 标志
- 29. 与条件跳转的比较
- 30. 当 CBW、CWD 或 CDQ 错误地遇到 DIV 时…
- 31. 为什么 255-1 和 255+(-1) 对 CF 的影响不同?
- 32. 如何确定 OF?
- 模糊的 "LOCAL" 指令
- 33. 当 LOCAL 用于过程中时
- 34. 当 LOCAL 用于宏时
- 在 C/C++ 中调用汇编过程以及反之
- 35. 两种方式使用 C 调用约定
- 36. 两种方式使用 STD 调用约定
- 37. 在汇编过程中调用 cin/cout
- 关于 ADDR 操作符
- 38. 与数据段中定义的全局变量
- 39. 与在过程中创建的局部变量
- 40. 与从过程中接收的参数
- 摘要
- 参考文献
引言
汇编语言是一种低级编程语言,适用于物联网、设备驱动程序和嵌入式系统等利基平台。通常,这是计算机科学专业的学生应该在课程中学习但很少在未来工作中使用的语言。从 TIOBE 编程社区指数来看,汇编语言最近在最受欢迎的编程语言排名中稳步上升。
早期,当应用程序用汇编语言编写时,它必须适合有限的内存,并尽可能高效地在慢速处理器上运行。随着内存变得充裕且处理器速度急剧提高,我们在开发中主要依赖现成结构和库的高级语言。如有必要,可以使用汇编语言来优化关键部分的性能或直接访问不可移植的硬件。如今,汇编语言在嵌入式系统设计中仍然起着重要作用,因为性能效率仍然被认为是重要的要求。
在本文中,我们将讨论一些特定于汇编语言编程的基本标准和代码技巧。此外,还将重点关注执行速度和内存消耗。我将分析一些与寄存器、内存和堆栈的概念、运算符和常量、循环和过程、系统调用等相关的示例。为了简化,所有示例均为 32 位,但大多数思想可以轻松应用于 64 位。
本文呈现的所有材料均来自我多年的教学 [1]。因此,阅读本文需要对 Intel x86-64 汇编语言有一般的了解,并且假定熟悉 Visual Studio 2010 或更高版本。推荐阅读 Kip Irvine 的教科书 [2] 和 MASM 程序员指南 [3]。如果您正在学习汇编语言编程课程,这可以作为补充阅读材料。
关于指令
前两条规则是通用的。如果您能少用,就不要多用。
1. 使用更少的指令
假设我们有一个 32 位 DWORD
变量
.data
var1 DWORD 123
示例是将 var1
添加到 EAX
。使用 MOV
和 ADD
是正确的
mov ebx, var1
add eax, ebx
但由于 ADD
可以接受一个内存操作数,您只需要
add eax, var1
2. 使用字节更少的指令
假设我们有一个数组
.data
array DWORD 1,2,3
如果要将值重新排列为 3,1,2,您可以
mov eax,array ; eax =1
xchg eax,[array+4] ; 1,1,3, eax =2
xchg eax,[array+8] ; 1,1,2, eax =3
xchg array,eax ; 3,1,2, eax =1
但请注意,最后一条指令应该是 MOV
而不是 XCHG
。虽然两者都可以将 EAX
中的 3
赋给第一个数组元素,但交换 XCHG
的另一方向在逻辑上是不必要的。
请注意代码大小,MOV
需要 5 字节机器码,而 XCHG
需要 6 字节,这是在此处选择 MOV
的另一个原因。
00000011 87 05 00000000 R xchg array,eax
00000017 A3 00000000 R mov array,eax
要检查机器码,您可以在汇编时生成列表文件,或者在 Visual Studio 运行时打开反汇编窗口。您也可以查阅 Intel 指令手册。
关于寄存器和内存
在本节中,我们将使用一个流行的例子,即第 n 个 斐波那契数,来说明汇编语言中的多种解决方案。C 函数将如下所示
unsigned int Fibonacci(unsigned int n)
{
unsigned int previous = 1, current = 1, next = 0;
for (unsigned int i = 3; i <= n; ++i)
{
next = current + previous;
previous = current;
current = next;
}
return next;
}
3. 使用内存变量实现
首先,让我们复制上面从两个变量 previous
和 current
开始的想法
.data
previous DWORD ?
current DWORD ?
我们可以使用 EAX
来存储结果,而无需 next
变量。由于 MOV
不能在内存之间移动,因此必须涉及像 EDX
这样的寄存器来进行赋值 previous = current
。下面是 FibonacciByMemory
过程。它从 ECX
接收 n
并返回计算出的第 n 个斐波那契数的 EAX
。
;------------------------------------------------------------
FibonacciByMemory PROC
; Receives: ECX as input n
; Returns: EAX as nth Fibonacci number calculated
;------------------------------------------------------------
mov eax,1
mov previous,0
mov current,0
L1:
add eax,previous ; eax = current + previous
mov edx, current ; previous = current
mov previous, edx
mov current, eax
loop L1
ret
FibonacciByMemory ENDP
4. 如果可以使用寄存器,就不要使用内存
汇编语言编程中的一个基本规则是,如果您可以使用寄存器,就不要使用变量。寄存器操作比内存操作快得多。32 位中可用的通用寄存器是 EAX
、EBX
、ECX
、EDX
、ESI
和 EDI
。不要触碰 ESP
和 EBP
,它们是用于系统使用的。
现在让 EBX
替换 previous
变量,EDX
替换 current
。下面是 FibonacciByRegMOV
,在循环中只需要三个指令。
;------------------------------------------------------------
FibonacciByRegMOV PROC
; Receives: ECX as input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
mov eax,1
xor ebx,ebx
xor edx,edx
L1:
add eax,ebx ; eax += ebx
mov ebx,edx
mov edx,eax
loop L1
ret
FibonacciByRegMOV ENDP
进一步简化的版本是利用 XCHG
,它通过不使用 EDX
的步骤来提高序列。下面展示了 FibonacciByRegXCHG
的列表显示中的机器码,其中循环体内只有两个指令,三个机器码字节。
;------------------------------------------------------------
000000DF FibonacciByRegXCHG PROC
; Receives: ECX as input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
000000DF 33 C0 xor eax,eax
000000E1 BB 00000001 mov ebx,1
000000E6 L1:
000000E6 93 xchg eax,ebx ; step up the sequence
000000E7 03 C3 add eax,ebx ; eax += ebx
000000E9 E2 FB loop L1
000000EB C3 ret
000000EC FibonacciByRegXCHG ENDP
在并发编程中
x86-64 指令集提供了许多原子指令,能够暂时禁止中断,确保当前正在运行的进程不会被上下文切换,并且在单处理器上就足够了。在某种程度上,它还可以避免多任务环境下的竞争条件。编译器和操作系统编写者可以直接使用这些指令。
5. 使用原子指令
如上所示,原子交换(原子交换)比一些只有一条语句的高级语言更强大。
xchg eax, var1
用寄存器与内存变量 var1
交换的经典方法可能是
mov ebx, eax
mov eax, var1
mov var1, ebx
此外,如果您使用 Intel486 指令集并使用 .486 指令或更高版本,只需使用原子 XADD
在斐波那契过程中会更简洁。XADD
交换第一个操作数(目标)与第二个操作数(源),然后将两个值的和加载到目标操作数。因此,我们有
;------------------------------------------------------------
000000EC FibonacciByRegXADD PROC
; Receives: ECX as input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
000000EC 33 C0 xor eax,eax
000000EE BB 00000001 mov ebx,1
000000F3 L1:
000000F3 0F C1 D8 xadd eax,ebx ; first exchange and then add
000000F6 E2 FB loop L1
000000F8 C3 ret
000000F9 FibonacciByRegXADD ENDP
两个原子移动扩展是 MOVZX
和 MOVSX
。另一个值得一提的是位测试指令,BT
、BTC
、BTR
和 BTS
。对于以下示例
.data
Semaphore WORD 10001000b
.code
btc Semaphore, 6 ; CF=0, Semaphore WORD 11001000b
想象一下没有 BTC
的指令集,同样的逻辑的一个非原子实现将是
mov ax, Semaphore
shr ax, 7
xor Semaphore,01000000b
小端序
x86 处理器以小端序(从小到大)存储和检索内存数据。最低有效字节存储在为数据分配的第一个内存地址中。其余字节存储在连续的下一个内存位置。
6. 内存表示
考虑以下数据定义
.data
dw1 DWORD 12345678h
dw2 DWORD 'AB', '123', 123h
;dw3 DWORD 'ABCDE' ; error A2084: constant value too large
by3 BYTE 'ABCDE', 0FFh, 'A', 0Dh, 0Ah, 0
w1 WORD 123h, 'AB', 'A'
为了简单起见,十六进制常量用作初始化器。内存表示如下
对于多字节 DWORD
和 WORD
数据,它们以小端序表示。基于此,第二个用 'AB'
初始化的 DWORD
应该是 00004142h
,下一个 '123'
应该是 00313233h
,按它们原来的顺序。您不能将 dw3
初始化为 'ABCDE'
,它包含五个字节 4142434445h
,而您确实可以将 by3
初始化为字节内存,因为字节数据没有小端序。同样,请参阅 w1
以获取 WORD
内存。
7. 小端序隐藏的代码错误
从上一节使用 XADD
开始,我们尝试用前 7 个斐波那契数(即 01
、01
、02
、03
、05
、08
、0D
)填充一个字节数组。下面是一个简单但有 bug 的实现。bug 没有立即显示错误,因为它被小端序隐藏了。
FibCount = 7
.data
FibArray BYTE FibCount DUP(0ffh)
BYTE 'ABCDEF'
.code
mov edi, OFFSET FibArray
mov eax,1
xor ebx,ebx
mov ecx, FibCount
L1:
mov [edi], eax
xadd eax, ebx
inc edi
loop L1
为了调试,我故意在字节数组 FibArray
的末尾创建了一个内存 'ABCDEF'
,并用七个 0ffh
初始化。初始内存看起来像这样
让我们在循环中设置一个断点。当填充第一个数字 01
时,后面跟着三个零,如下所示
但是,没关系,第二个数字 01
填充第二个字节,覆盖第一个数字留下的三个零。以此类推,直到第七个 0D
,它恰好适合最后一个字节。
由于小端序,FibArray
中的所有内容都正常,并且结果符合预期。只有当您定义此 FibArray
之后的某些内存时,您的前三个字节才会被零覆盖,例如 'ABCDEF'
变成 'DEF'
。如何轻松修复?
关于运行时堆栈
运行时堆栈是由 CPU 直接管理的内存数组,堆栈指针寄存器 ESP
在堆栈上保存一个 32 位偏移量。ESP
由 CALL
、RET
、PUSH
、POP
等指令修改。当使用 PUSH
和 POP
或类似指令时,您会显式地更改堆栈内容。您应该非常小心,以免影响其他隐式使用,如 CALL
和 RET
,因为您程序员和系统共享同一个运行时堆栈。
8. 使用 PUSH 和 POP 进行赋值效率不高
在汇编代码中,您绝对可以使用堆栈来执行赋值 previous = current
,如 FibonacciByMemory
中所示。下面是 FibonacciByStack
,唯一的区别是使用 PUSH
和 POP
而不是两个带 EDX
的 MOV
指令。
;------------------------------------------------------------
FibonacciByStack
; Receives: ECX as input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
mov eax,1
mov previous,0
mov current,0
L1:
add eax,previous ; eax = current + previous
push current ; previous = current
pop previous
mov current, eax
loop L1
ret
FibonacciByStack ENDP
正如您所想象的,基于内存的运行时堆栈比寄存器慢得多。如果您创建一个测试基准来比较上述过程在长循环中的性能,您会发现 FibonacciByStack
最低效。我的建议是,如果您可以使用寄存器或内存,就不要使用 PUSH
和 POP
。
9. 使用 INC 避免 PUSHFD 和 POPFD
当您使用 ADC
或 SBB
指令将整数添加到前一个进位或从中减去时,您合理地希望使用 PUSHFD
和 POPFD
来保留前一个进位标志 (CF
),因为使用 ADD
进行地址更新会覆盖 CF
。以下 Extended_Add
示例摘自教科书 [2],用于计算两个扩展长整数 BYTE
的逐字节和。
;--------------------------------------------------------
Extended_Add PROC
; Receives: ESI and EDI point to the two long integers
; EBX points to an address that will hold sum
; ECX indicates the number of BYTEs to be added
; Returns: EBX points to an address of the result sum
;--------------------------------------------------------
clc ; clear the Carry flag
L1:
mov al,[esi] ; get the first integer
adc al,[edi] ; add the second integer
pushfd ; save the Carry flag
mov [ebx],al ; store partial sum
add esi, 1 ; point to next byte
add edi, 1
add ebx, 1 ; point to next sum byte
popfd ; restore the Carry flag
loop L1 ; repeat the loop
mov dword ptr [ebx],0 ; clear high dword of sum
adc dword ptr [ebx],0 ; add any leftover carry
ret
Extended_Add ENDP
我们知道,INC
指令将值增加 1,而不影响 CF
。显然,我们可以用 INC
替换上面的 ADD
来避免 PUSHFD
和 POPFD
。因此,循环被简化为这样
L1:
mov al,[esi] ; get the first integer
adc al,[edi] ; add the second integer
mov [ebx],al ; store partial sum
inc esi ; add one without affecting CF
inc edi
inc ebx
loop L1 ; repeat the loop
现在您可能会问,如果要计算两个长整数 DWORD
的逐 DWORD
和,其中每次迭代都必须通过 4 个字节更新地址,就像 TYPE DWORD
一样。我们仍然可以使用 INC
来实现这一点。
clc
xor ebx, ebx
L1:
mov eax, [esi +ebx*TYPE DWORD]
adc eax, [edi +ebx*TYPE DWORD]
mov [edx +ebx*TYPE DWORD], eax
inc ebx
loop L1
在这里应用比例因子会更通用且更受欢迎。同样,在必要时,您也可以使用 DEC
指令,该指令将值减少 1,而不影响进位标志。
10. 避免 PUSH 和 POP 的另一个好理由
由于您和系统共享同一个堆栈,因此您应该非常小心,以免干扰系统的使用。如果您忘记成对使用 PUSH
和 POP
,可能会发生错误,尤其是在过程返回时的条件跳转中。
以下 Search2DAry
搜索二维数组中传递给 EAX
的值。如果找到,则跳转到 FOUND
标签,在 EAX
中返回 1 表示真,否则将 EAX
设置为 0 表示假。
;------------------------------------------------------------
Search2DAry PROC
; Receives: EAX, a byte value to search a 2-dimensional array
; ESI, an address to the 2-dimensional array
; Returns: EAX, 1 if found, 0 if not found
;------------------------------------------------------------
mov ecx,NUM_ROW ; outer loop count
ROW:
push ecx ; save outer loop counter
mov ecx,NUM_COL ; inner loop counter
COL:
cmp al, [esi+ecx-1]
je FOUND
loop COL
add esi, NUM_COL
pop ecx ; restore outer loop counter
loop ROW ; repeat outer loop
mov eax, 0
jmp QUIT
FOUND:
mov eax, 1
QUIT:
ret
Search2DAry ENDP
让我们通过准备指向数组地址的 ESI
和搜索值 EAX
(分别为 31h
或 30h
以表示未找到或找到的测试用例)在 main
中调用它。
.data
ary2D BYTE 10h, 20h, 30h, 40h, 50h
BYTE 60h, 70h, 80h, 90h, 0A0h
NUM_COL = 5
NUM_ROW = 2
.code
main PROC
mov esi, OFFSET ary2D
mov eax, 31h ; crash if set 30h
call Search2DAry
; See eax for search result
exit
main ENDP
不幸的是,它只在未找到 31h
时工作。对于成功搜索,例如 30h
,会发生崩溃,因为堆栈中存在来自外部循环计数器的残留。可悲的是,该残留物被 RET
弹出,成为返回给调用者的地址。
因此,最好在这里使用寄存器或变量来保存外部循环计数器。虽然逻辑错误仍然存在,但如果没有干扰系统,就不会发生崩溃。作为一个好的练习,您可以尝试修复。
汇编时 vs 运行时
我想就汇编语言的这个特性多说一些。最好是,如果您可以在汇编时完成某件事,就不要在运行时完成。在汇编时组织逻辑意味着在静态(编译)时间完成一项工作,而不是消耗运行时。与高级语言不同,汇编语言中的所有运算符都在汇编时处理,例如 +
、-
、*
和 /
,而只有指令在运行时工作,例如 ADD
、SUB
、MUL
和 DIV
。
11. 使用加号 (+) 而不是 ADD 实现
让我们重新计算斐波那契数列,以在汇编时使用加号运算符通过 LEA
指令实现 eax = ebx + edx
。下面是 FibonacciByRegLEA
,它只从 FibonacciByRegMOV
中更改了一行。
;------------------------------------------------------------
FibonacciByRegLEA
; Receives: ECX as input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
xor eax,eax
xor ebx,ebx
mov edx,1
L1:
lea eax, DWORD PTR [ebx+edx] ; eax = ebx + edx
mov edx,ebx
mov ebx,eax
loop L1
ret
FibonacciByRegLEA ENDP
此语句编码为三个字节,在运行时不进行显式加法运算的机器码。
000000CE 8D 04 1A lea eax, DWORD PTR [ebx+edx] ; eax = ebx + edx
与 FibonacciByRegMOV
相比,此示例在性能上没有太大差异。但足以作为实现演示。
12. 如果可以使用运算符,就不要使用指令
对于定义为
.data
Ary1 DWORD 20 DUP(?)
如果您想从第二个元素遍历到中间元素,您可能会像在其他语言中那样考虑
mov esi, OFFSET Ary1
add esi, TYPE DWORD ; start at the second value
mov ecx LENGTHOF Ary1 ; total number of values
sub ecx, 1
div ecx, 2 ; set loop counter in half
L1:
; do traversing
Loop L1
请记住,ADD
、SUB
和 DIV
是运行时动态行为。如果您提前知道值,则不必在运行时计算它们,而是使用汇编时中的运算符。
mov esi, OFFSET Ary1 + TYPE DWORD ; start at the second
mov ecx (LENGTHOF Ary1 -1)/2 ; set loop counter
L1:
; do traversing
Loop L1
这在运行时节省了代码段中的三条指令。接下来,我们保存数据段中的内存。
13. 如果可以使用符号常量,就不要使用变量
与运算符一样,所有指令都在汇编时处理。变量会占用内存,并且必须在运行时访问。至于最后的 Ary1
,您可能想记住它的字节大小和元素数量,如下所示
.data
Ary1 DWORD 20 DUP(?)
arySizeInByte DWORD ($ - Ary1) ; 80
aryLength DWORD LENGTHOF Ary1 ; 20
这是正确的,但不推荐,因为它使用了两个变量。为什么不直接将它们设为符号常量来节省两个 DWORD
的内存?
.data
Ary1 DWORD 20 DUP(?)
arySizeInByte = ($ - Ary1) ; 80
aryLength EQU LENGTHOF Ary1 ; 20
使用等号或 EQU 指令都可以。常量只是在代码预处理期间的替换。
14. 在宏中生成内存块
对于要初始化的数据量,如果您已经知道如何创建的逻辑,您可以使用宏在汇编时生成内存块,而不是在运行时。以下宏在名为 FibArray
的 DWORD
数组中创建所有 47
个斐波那契数。
.data
val1 = 1
val2 = 1
val3 = val1 + val2
FibArray LABEL DWORD
DWORD val1 ; first two values
DWORD val2
WHILE val3 LT 0FFFFFFFFh ; less than 4-billion, 32-bit
DWORD val3 ; generate unnamed memory data
val1 = val2
val2 = val3
val3 = val1 + val2
ENDM
由于宏在汇编器中进行静态处理,因此与前面提到的 FibonacciByXXX
相比,大大节省了运行时初始化。
有关 MASM 中的宏的更多信息,请参阅我的文章 MASM 中您可能不知道的关于宏的一些事 [4]。我还对 VC++ 编译器实现的 switch
语句进行了逆向工程。有趣的是,在某些条件下,switch
语句会选择二分搜索,但运行时没有暴露排序实现的先决条件。可以合理地认为预处理器在编译时对所有已知的 case
值进行排序。静态排序行为(与运行时动态行为相反)可以通过宏过程、指令和运算符来实现。有关详细信息,请参阅 C/C++ 中您可能不知道的关于 switch 语句的一些事 [5]。
关于循环设计
几乎所有语言都提供像 GOTO
这样的无条件跳转,但根据软件工程原理,我们很少使用它。相反,我们使用 break
和 continue
等。而在汇编语言中,我们更多地依赖跳转(条件或无条件)来使控制流更自由。在接下来的部分,我列出了一些代码编写不当的模式。
15. 将所有循环逻辑封装在循环体中
要构建一个循环,请尝试将所有循环内容放在循环体内。不要跳转出去做某事,然后再跳回循环。这里的例子是遍历一维整数数组。如果找到奇数,则增加它,否则不做任何事情。
两个不清晰但结果正确的解决方案可能是
mov ecx, LENGTHOF array
xor esi, esi
L1:
test array[esi], 1
jnz ODD
PASS:
add esi, TYPE DWORD
loop L1
jmp DONE
ODD:
inc array[esi]
jmp PASS
DONE:
| mov ecx, LENGTHOF array
xor esi, esi
jmp L1
ODD:
inc array[esi]
jmp PASS
L1:
test array[esi], 1
jnz ODD
PASS:
add esi, TYPE DWORD
loop L1
|
然而,它们都将递增放在外面然后跳回。它们在循环中进行检查,但左边的在循环后递增,右边的在循环前递增。对于简单的逻辑,您可能不会这样想;而对于复杂的问题,汇编语言可能会误导您产生这种混乱的模式。下面是一个好的例子,它将所有逻辑封装在循环体内,简洁、可读、可维护且高效。
mov ecx, LENGTHOF array
xor esi, esi
L1:
test array[esi], 1
jz PASS
inc array[esi]
PASS:
add esi, TYPE DWORD
loop L1
16. 循环入口和出口
通常首选具有单个入口和单个出口的循环。但如果需要,两个或多个条件出口也是可以的,如 Search2DAry
中的找到和未找到结果所示。
以下是一个糟糕的双入口模式,其中一个通过初始化进入 START
,另一个直接进入 MIDDLE
。这样的代码很难理解。需要重新组织或重构循环逻辑。
; do something
je MIDDLE
; loop initialization
START:
; do something
MIDDLE:
; do something
loop START
以下是一个糟糕的双出口模式,其中一些逻辑会从第一个循环末尾退出,而另一些会在第二个循环末尾退出。这样的代码很令人困惑。尝试使用标签跳转来维护单个循环出口。
; loop initialization
START2:
; do something
je NEXT
; do something
loop START2
jmp DONE
NEXT:
; do something
loop START2
DONE:
17. 不要更改循环体内的 ECX
寄存器 ECX
用作循环计数器,并且在使用 LOOP
指令时其值会自动递减。您可以读取 ECX
并在每次迭代中使用它的值。如上一节的 Search2DAry
所示,我们将间接操作数 [ESI+ECX-1]
与 AL
进行比较。但切勿尝试在循环体内更改循环计数器,这会使代码难以理解和调试。一个好的做法是将循环计数器 ECX
视为只读。
; do initialization
mov ecx, 10
L1:
; do something
mov eax, ecx ; fine
mov ebx, [esi +ecx *TYPE DWORD] ; fine
mov ecx, edx ; not good
inc ecx ; not good
; do something
loop L1
18. 当跳转到后方时…
除了 LOOP
指令之外,汇编语言编程可以严重依赖条件或无条件跳转来创建计数在循环开始前未确定的循环。理论上,对于后向跳转,工作流程可以被视为一个循环。假设 jx
和 jy
是期望的跳转或 LOOP
指令。嵌套在 jx L1
中的以下后向 jy L2
可能被认为是内部循环。
; loop initialization
L1:
; do something
L2:
; do something
jy L2
; do something
jx L1
为了具有 if-then-else 的选择逻辑,使用前向跳转进行分支,如下所示,在 jx L1
迭代中是合理的。
; loop initialization
L1:
; do something
jy TrueLogic
; do something for false
jmp DONE
TrueLogic:
; do something for true
DONE:
; do something
jx L1
19. 实现 C/C++ FOR 循环和 WHILE 循环
高级语言通常提供三种类型的循环结构。当代码中存在已知迭代次数时,通常使用 FOR
循环,它允许初始化循环计数器作为检查条件,并在每次迭代时更改计数变量。当循环计数器未知时,可能使用 WHILE
循环,例如,它可能由运行时用户输入作为结束标志确定。DO-WHILE
循环首先执行循环体,然后检查条件。但是,使用不那么严格清晰和有限,因为一个循环可以通过编程简单地替换(实现)另一个。
让我们看看汇编代码如何实现高级语言中的三种循环结构。前面提到的 LOOP
指令应该像 FOR
循环一样工作,因为您必须在 ECX
中初始化一个已知的循环计数器。"LOOP target
" 语句执行两个操作
- 递减
ECX
- 如果
ECX
!=0
,则跳转到target
要计算 n+(n-1)+...+2+1
的和,我们可以有
mov ecx, n
xor eax, eax
L1:
add eax, ecx
loop L1
mov sum, eax
这与 FOR
循环相同
int sum=0;
for (int i=n; i>0; i++)
sum += i;
那么下面的逻辑呢?对于一个 WHILE
循环,将所有非零输入数字相加,直到输入零。
int sum=0;
cin >> n;
while (n !=0)
{
sum += n;
cin >> n;
}
在这里使用 LOOP
没有意义,因为您无法设置或忽略 ECX
中的任何值。相反,需要使用条件跳转手动构造这样的循环。
xor ebx, ebx
call ReadInt ; Read an integer in EAX
L1:
or eax, eax
jz L2
add ebx, eax
call ReadInt ; Read an integer in EAX
jmp L1
L2:
mov sum, ebx
这里使用 Irvine32 库过程 ReadInt
从控制台读取一个整数到 EAX
。使用 OR
而不是 CMP
仅仅是为了效率,因为 OR
不会影响 EAX
,但会影响 JZ
的零标志。接下来,考虑 DO-WHILE
循环的类似逻辑。
int sum=0;
cin >> n;
do
{
sum += n;
cin >> n;
}
while (n !=0)
仍然使用条件跳转在这里创建循环,代码看起来更直接,因为它先执行循环体然后检查。
xor ebx, ebx
call ReadInt ; Read an integer in EAX
L1:
add ebx, eax
call ReadInt ; Read an integer in EAX
or eax, eax
jnz L1
mov sum, ebx
20. 通过跳转使您的循环更有效
基于上述理解,我们现在可以转向汇编代码中的循环优化。有关详细的指令机制,请参阅 Intel® 64 和 IA-32 架构优化参考手册。在这里,我只使用一个计算 n+(n-1)+...+2+1
的和的例子来展示 LOOP
和条件跳转的迭代实现之间的性能比较。如上一节中的代码所示,我创建了第一个过程,名为 Using_LOOP
。
;--------------------------------------------------------
Using_LOOP PROC
; Receives: ECX, as n, an integer to calculate 1+2+...+n
; Returns: EAX, the sum of 1+2+...+n
;--------------------------------------------------------
xor eax, eax
L1:
add eax, ecx
loop L1
ret
Using_LOOP ENDP
为了手动模拟 LOOP
指令,我只是递减 ECX
,如果它不为零,就回到循环标签。所以我将第二个命名为 Using_DEC_JNZ
。
;--------------------------------------------------------
Using_DEC_JNZ PROC
; Receives: ECX, as n, an integer to calculate 1+2+...+n
; Returns: EAX, the sum of 1+2+...+n
;--------------------------------------------------------
xor eax, eax
L1:
add eax, ecx
; Two instructions here equivalent to LOOP L1
dec ecx
JNZ L1
ret
Using_DEC_JNZ ENDP
一个类似的替代方案是使用 JECXZ
的第三个过程,将其命名为 Using_DEC_JECXZ_JMP
。
;--------------------------------------------------------
Using_DEC_JECXZ_JMP PROC
; Receives: ECX, as n, an integer to calculate 1+2+...+n
; Returns: EAX, the sum of 1+2+...+n
;--------------------------------------------------------
xor eax, eax
L1:
add eax, ecx
; Three instructions here quivalent to LOOP L1
dec ecx
JECXZ L2
jmp L1
L2:
ret
Using_DEC_JECXZ_JMP ENDP
现在,让我们通过接受用户输入的数字 n
来保存循环计数器,然后使用宏 mCallSumProc
调用三个过程(此处 Clrscr
、ReadDec
、Crlf
和 mWrite
来自 Irvine32,稍后会提到)。
main PROC
call Clrscr
mWrite "To calculate 1+2+...+n, please enter n (1 - 4294967295): "
call ReadDec ; read n from user into EAX
mov ecx, eax ; save n to the loop counter ECX
call Crlf
mCallSumProc Using_LOOP
mCallSumProc Using_DEC_JNE
mCallSumProc Using_DEC_JECXZ_JMP
exit
main ENDP
为了测试,请输入一个大数字,如 40 亿。虽然总和远远超过 32 位最大值 0FFFFFFFFh
,但 EAX
中只剩下余数(1+2+...+n)MOD
4294967295,这对我们的基准测试无关紧要。以下是我 Intel Core i7、64 位 BootCamp 的结果。
结果可能会因系统而异。测试可执行文件可在 LoopTest.EXE 处尝试。基本上,使用条件跳转来构造循环比直接使用 LOOP
指令更有效。您可以阅读“Intel® 64 和 IA-32 架构优化参考手册”来了解原因。我还想感谢 Daniel Pfeffer 先生的精彩评论,您可以在评论和讨论中阅读。
最后,我展示了上面未提及的宏,如下所示。它再次包含一些 Irvine32 库过程调用。此部分中的源代码可以在 Loop Test ASM Project 下载。为了进一步理解,请参阅参考文献中的链接。
;------------------------------------------------------
mCallSumProc MACRO SumProc:REQ
; Receives: SumProc, a summation procedure
; ECX as n, to calculate 1+2+...+n
;------------------------------------------------------
push ecx
call GetMseconds ; get start time
mov esi,eax
call SumProc
mWrite "&SumProc: "
call WriteDec
call crlf
call GetMseconds ; get start time
sub eax,esi
call WriteDec ; display elapsed time
mWrite <' millisecond(s) used', 0Dh,0Ah, 0Dh,0Ah >
pop ecx
ENDM
关于过程
与 C/C++ 中的函数类似,我们讨论汇编语言过程中的一些基本知识。
21. 创建清晰的调用接口
在设计过程时,我们希望使其尽可能可重用。使其只执行一项任务,不执行其他任务,如 I/O。过程的调用者应负责输入和输出。调用者应仅通过参数和形参与过程通信。过程应仅在其逻辑中使用参数,而不引用外部定义,不引用任何
- 全局变量和数组
- 全局符号常量
因为使用此类定义实现的过程是不可重用的。
回顾前面五个 FibonacciByXXX
过程,我们使用寄存器 ECX
作为参数和形参,返回值在 EAX
中,以创建清晰的调用接口。
;------------------------------------------------------------
FibonacciByXXX
; Receives: ECX as input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
现在调用者可以这样做:
; Read user’s input n and save in ECX
call FibonacciByXXX
; Output or process the nth Fibonacci number in EAX
为了举例说明,让我们再次看看上一节中调用 Search2DAry
。准备了寄存器参数 ESI
和 EAX
,以便 Search2DAry
的实现不直接引用全局数组 ary2D
。
... ...
NUM_COL = 5
NUM_ROW = 2
.code
main PROC
mov esi, OFFSET ary2D
mov eax, 31h
call Search2DAry
; See eax for search result
exit
main ENDP
;------------------------------------------------------------
Search2DAry PROC
; Receives: EAX, a byte value to search a 2-dimensional array
; ESI, an address to the 2-dimensional array
; Returns: EAX, 1 if found, 0 if not found
;------------------------------------------------------------
mov ecx,NUM_ROW ; outer loop count
... ...
mov ecx,NUM_COL ; inner loop counter
... ...
不幸的是,其弱点是它的实现仍然使用两个全局常量 NUM_ROW
和 NUM_COL
,这使得它无法在其他地方被调用。为了改进,提供另外两个寄存器参数将是一个显而易见的方法,或者参见下一节。
22. INVOKE vs. CALL
除了 Intel 的 CALL
指令之外,MASM 还提供了 32 位 INVOKE
指令,使过程调用更容易。对于 CALL
指令,您只能使用寄存器作为调用接口中的参数/形参对,如上所示。问题是寄存器的数量有限。所有寄存器都是全局的,您可能必须在调用之前保存寄存器,并在调用之后恢复它们。INVOKE
指令提供了带有形参列表的过程的形式,就像您在高级语言中所体验的那样。
当考虑 Search2DAry
带有形参列表而不引用全局常量 NUM_ROW
和 NUM_COL
时,我们可以得到它的原型如下。
;---------------------------------------------------------------------
Search2DAry PROTO, pAry2D: PTR BYTE, val: BYTE, nRow: WORD, nCol: WORD
; Receives: pAry2D, an address to the 2-dimensional array
; val, a byte value to search a 2-dimensional array
; nRow, the number of rows
; nCol, the number of columns
; Returns: EAX, 1 if found, 0 if not found
;---------------------------------------------------------------------
同样,作为一项练习,您可以尝试实现这个修复。现在您只需执行
INVOKE Search2DAry, ary2D, 31h, NUM_ROW, NUM_COL
; See eax for search result
同样,要构造一个带形参列表的过程,您仍然需要遵循不引用全局变量和常量的规则。此外,还要注意
- 整个调用接口应仅通过形参列表进行,而不引用过程外部设置的任何寄存器值。
23. 按值传递 vs. 按引用传递
还要注意,形参列表不应过长。如果过长,请改用对象参数。假设您完全理解函数概念、高级语言中的按值传递和按引用传递。通过学习汇编语言中的堆栈帧,您可以更多地了解底层函数调用机制。通常对于对象参数,我们更倾向于传递引用(对象地址),而不是将整个对象复制到堆栈内存中。
为了演示这一点,让我们创建一个过程来写入 Win32 SYSTEMTIME 结构的对象中的月、日和年。
以下是按值传递的版本,我们使用点运算符从 DateTime
对象中检索单个 WORD
字段成员,并将其 16 位值扩展到 32 位 EAX
。
;--------------------------------------------------------
WriteDateByVal PROC, DateTime:SYSTEMTIME
; Receives: DateTime, an object of SYSTEMTIME
;--------------------------------------------------------
movzx eax, DateTime.wMonth
; output eax as month
; output a separator like '/'
movzx eax, DateTime.wDay
; output eax as day
; output a separator like '/'
movzx eax, DateTime.wYear
; output eax as year
; make a newline
ret
WriteDateByVal ENDP
按引用传递的版本不是那么直接,它接收一个对象地址。不像 C/C++ 中的箭头 -> 指针运算符,我们必须将指针(地址)值保存在像 ESI
这样的 32 位寄存器中。通过将 ESI
用作间接操作数,我们必须将其内存转换回 SYSTEMTIME
类型。然后,我们可以使用点运算符获取对象成员。
;--------------------------------------------------------
WriteDateByRef PROC, datetimePtr: PTR SYSTEMTIME
; Receives: DateTime, an address of SYSTEMTIME object
;--------------------------------------------------------
mov esi, datetimePtr
movzx eax, (SYSTEMTIME PTR [esi]).wMonth
; output eax as month
; output a separator like '/'
movzx eax, (SYSTEMTIME PTR [esi]).wDay
; output eax as day
; output a separator like '/'
movzx eax, (SYSTEMTIME PTR [esi]).wYear
; output eax as year
; make a newline
ret
WriteDateByRef ENDP
您可以在运行时查看两个版本传递的参数的堆栈帧。对于 WriteDateByVal
,八个 WORD
成员被复制到堆栈中并占用十六个字节,而对于 WriteDateByRef
,只需要四个字节作为一个 32 位地址。对于大型结构对象,这会产生很大差异,尽管如此。
24. 避免多个 RET
为了构造一个过程,最好将所有逻辑都放在过程体内。首选具有单个入口和单个出口的过程。由于在汇编语言编程中,过程名直接由内存地址表示,标签也是如此。因此,直接跳转到标签或过程而不使用 CALL
或 INVOKE
是可能的。由于这种异常入口非常罕见,我将不在此讨论。
尽管多重返回有时在其他语言示例中使用,但我并不鼓励在汇编代码中采用这种模式。多个 RET
指令可能会使您的逻辑不易理解和调试。左侧的以下代码是一个分支示例。相反,在右侧,我们在末尾有一个标签 QUIT
并跳转到那里,实现单个出口,在那里可以执行一些通用操作以避免重复代码。
MultiRetEx PROC
; do something
jx NEXTx
; do something
ret
NEXTx:
; do something
jy NEXTy
; do something
ret
NEXTy:
; do something
ret
MultiRetEx ENDP
| SingleRetEx PROC
; do something
jx NEXTx
; do something
jmp QUIT
NEXTx:
; do something
jy NEXTy
; do something
jmp QUIT
NEXTy:
; do something
QUIT:
; do common things
ret
SingleRetEx ENDP
|
对象数据成员
与上面提到的 SYSTEMTIME
结构类似,我们也可以创建自己的类型或嵌套类型。
Rectangle STRUCT UpperLeft COORD <> LowerRight COORD <> Rectangle ENDS .data rect Rectangle { {10,20}, {30,50} }
Rectangle
类型包含两个 COORD 成员,UpperLeft
和 LowerRight
。Win32 COORD
包含两个 WORD
(SHORT
),X
和 Y
。显然,我们可以通过直接或间接操作数使用点运算符访问对象 rect
的数据成员,如下所示。
; directly access
mov rect.UpperLeft.X, 11
; cast indirect operand to access
mov esi,OFFSET rect
mov (Rectangle PTR [esi]).UpperLeft.Y, 22
; use the OFFSET operator for embedded members
mov esi,OFFSET rect.LowerRight
mov (COORD PTR [esi]).X, 33
mov esi,OFFSET rect.LowerRight.Y
mov WORD PTR [esi], 55
使用 OFFSET
操作符,我们使用不同的类型转换来访问不同的数据成员值。回想一下,任何运算符都在汇编时静态处理。如果我们想在运行时检索数据成员的地址(而不是值)怎么办?
25. 间接操作数和 LEA
对于指向对象的间接操作数,您不能使用 OFFSET
操作符来获取成员的地址,因为 OFFSET
只能获取在数据段中定义的变量的地址。
可能有一种情况,我们必须将对象引用参数传递给过程,例如上一节中的 WriteDateByRef
,但想要检索其成员的地址(而不是值)。仍然使用上面的 rect
对象作为示例。以下 OFFSET
的第二次使用在汇编时无效。
mov esi,OFFSET rect
mov edi, OFFSET (Rectangle PTR [esi]).LowerRight
让我们向 LEA
指令寻求帮助,您已经在上一节的 FibonacciByRegLEA
中看到了它。LEA
指令计算并加载内存操作数的有效地址。类似于 OFFSET
操作符,除了 LEA
可以在运行时计算地址。
mov esi,OFFSET rect
lea edi, (Rectangle PTR [esi]).LowerRight
mov ebx, OFFSET rect.LowerRight
lea edi, (Rectangle PTR [esi]).UpperLeft.Y
mov ebx, OFFSET rect.UpperLeft.Y
mov esi,OFFSET rect.UpperLeft
lea edi, (COORD PTR [esi]).Y
我特意在这里使用 EBX
来静态获取地址,您可以在 EDI
中验证相同的地址,它是在运行时从间接操作数 ESI
动态加载的。
关于系统 I/O
从 Computer Memory Basics,我们知道操作系统进行的 I/O 操作非常慢。输入和输出通常以毫秒为单位测量,而寄存器和内存以纳秒或微秒为单位。为了更有效,尝试减少系统 API 调用是一个不错的考虑。我在这里指的是 Win32 API 调用。有关以下提到的 Win32 函数的详细信息,请参阅 MSDN 以了解。
26. 减少系统 I/O API 调用
一个例子是输出 50 个随机字符的 20 行,具有随机颜色,如下所示。
我们绝对可以一次生成一个字符进行输出,使用 SetConsoleTextAttribute 和 WriteConsole。只需通过以下方式设置其颜色。
INVOKE SetConsoleTextAttribute, consoleOutHandle, wAttributes
然后通过以下方式写入该字符。
INVOKE WriteConsole,
consoleOutHandle, ; console output handle
OFFSET buffer, ; points to string
1, ; string length
OFFSET bytesWritten, ; returns number of bytes written
0
写入 50 个字符时,换行。因此,我们可以创建一个嵌套迭代,外层循环用于 20 行,内层循环用于 50 列。由于 50 行乘以 20 列,我们调用这两个控制台输出函数 1000 次。
但是,另一对 API 函数可能更有效,通过一次写入 50 个字符并设置一次颜色。它们是 WriteConsoleOutputAttribute 和 WriteConsoleOutputCharacter。为了利用它们,让我们创建两个过程。
;-----------------------------------------------------------------------
ChooseColor PROC
; Selects a color with 50% probability of red, 25% green and 25% yellow
; Receives: nothing
; Returns: AX = randomly selected color
;-----------------------------------------------------------------------
ChooseCharacter PROC
; Randomly selects an ASCII character, from ASCII code 20h to 07Ah
; Receives: nothing
; Returns: AL = randomly selected character
我们在循环中调用它们来准备一个 WORD
数组 bufColor
和一个字节数组 bufChar
,用于所有 50 个选定的字符。现在我们可以用这两个调用来写入每行 50 个随机字符。
INVOKE WriteConsoleOutputAttribute,
outHandle,
ADDR bufColor,
MAXCOL,
xyPos,
ADDR cellsWritten
INVOKE WriteConsoleOutputCharacter,
outHandle,
ADDR bufChar,
MAXCOL,
xyPos,
ADDR cellsWritten
除了 bufColor
和 bufChar
,我们定义了 MAXCOL = 50
和 COORD
类型 xyPos
,以便 xyPos.y
在 20 行的单个循环中每行递增。总共我们只调用这两个 API 20 次。
关于 PTR 操作符
MASM 提供了 PTR
操作符,它类似于 C/C++ 中的指针 *
。以下是 PTR
的规范。
- type PTR expression
强制表达式被视为具有指定类型。 - [[ distance ]] PTR type
指定类型指针。
这意味着有两种用法可用,例如 BYTE PTR
或 PTR BYTE
。让我们讨论如何使用它们。
27. 定义指针、类型转换和解引用
以下 C/C++ 代码演示了您的系统使用哪种类型的字节序:小端序还是大端序?由于整数类型占用四个字节,因此将数组名 fourBytes
(一个 char
地址)转换为 unsigned int
地址类型。然后,它通过解引用 unsigned int
指针来显示整数结果。
int main()
{
unsigned char fourBytes[] = { 0x12, 0x34, 0x56, 0x78 };
// Cast the memory pointed by the array name fourBytes, to unsigned int address
unsigned int *ptr = (unsigned int *)fourBytes;
printf("1. Directly Cast: n is %Xh\n", *ptr);
return 0;
}
正如在 x86 Intel 系统中所预期的那样,这通过显示十六进制的 78563412
来验证了小端序。我们可以在汇编语言中使用 DWORD PTR
来完成同样的事情,它类似于将地址转换为 4 字节 DWORD
,即 unsigned int
类型。
.data
fourBytes BYTE 12h,34h,56h,78h
.code
mov eax, DWORD PTR fourBytes ; EAX = 78563412h
这里没有显式的解引用,因为 DWORD PTR
将四个字节组合成一个 DWORD
内存,并让 MOV
将其作为直接操作数检索到 EAX
。这可以被认为是等同于 (unsigned int *
) 类型转换。
现在让我们通过使用 PTR DWORD
来做另一种方式。同样,使用相同的逻辑,这次我们首先用 TYPEDEF
定义一个 DWORD
指针类型。
DWORD_POINTER TYPEDEF PTR DWORD
这可以被认为是等同于将指针类型定义为 unsigned int *
。然后,在下面的数据段中,地址变量 dwPtr
占用 fourBytes
内存。最后在代码中,EBX
保存此地址作为间接操作数,并在此进行显式解引用以获取其 DWORD
值到 EAX
。
.data
fourBytes BYTE 12h,34h,56h,78h
dwPtr DWORD_POINTER fourBytes
.code
mov ebx, dwPtr ; Get DWORD address
mov eax, [ebx] ; Dereference, EAX = 78563412h
总而言之,PTR DWORD
表示一个 DWORD
地址类型,用于定义(声明)变量,如指针类型。而 DWORD PTR
表示指向 DWORD
地址的内存,如类型转换。
28. 在过程中使用 PTR
要定义一个带有形参列表的过程,您可能想在两种方式中使用 PTR
。以下是一个示例,用于递增 DWORD
数组中的每个元素。
;---------------------------------------------------------
IncrementArray PROC, pAry:PTR DWORD, count:DWORD
; Receives: pAry - pointer to a DWORD array
; count - the array count
; Returns: pAry, every vlues in pAry incremented
;---------------------------------------------------------
mov edi,pAry
mov ecx,count
L1:
inc DWORD PTR [edi]
add edi, TYPE DWORD
loop L1
ret
IncrementArray ENDP
由于第一个参数 pAry
是一个 DWORD
地址,因此 PTR DWORD
被用作参数类型。在过程中,当递增间接操作数 EDI
指向的值时,您必须使用 DWORD PTR
告诉系统该内存的类型(大小)。
另一个例子是前面提到的 WriteDateByRef
,其中 SYSTEMTIME
是 Windows 定义的结构类型。
;--------------------------------------------------------
WriteDateByRef PROC, datetimePtr: PTR SYSTEMTIME
; Receives: DateTime, an address of SYSTEMTIME object
;--------------------------------------------------------
mov esi, datetimePtr
movzx eax, (SYSTEMTIME PTR [esi]).wMonth
... ...
ret
WriteDateByRef ENDP
同样,我们使用 PTR SYSTEMTIME
作为参数类型来定义 datetimePtr
。当 ESI
从 datetimePtr
接收地址时,它不知道内存类型,就像 C/C++ 中的 void
指针一样。我们必须将其强制转换为 SYSTEMTIME
内存,以便检索其数据成员。
有符号和无符号
在汇编语言编程中,您可以将整数变量定义为有符号(如 SBYTE
、SWORD
和 SDWORD
)或无符号(如 BYTE
、WORD
和 DWORD
)。例如,8 位数据的范围是
BYTE
:0 到 255 (00h
到FFh
),共 256 个数字SBYTE
:一半负数,-128 到 -1 (80h
到FFh
),一半正数,0 到 127 (00h
到7Fh
)
从硬件角度来看,所有 CPU 指令对有符号和无符号整数的操作完全相同,因为 CPU 无法区分有符号和无符号。例如,在定义时
.data
bVal BYTE 255
sbVal SBYTR -1
两者在内存中都保存 8 位二进制 FFh
或移动到寄存器。作为程序员,您全权负责使用正确的 数据类型和指令,并能够解释标志位影响的结果。
- 无符号整数的进位标志
CF
- 有符号整数的溢出标志
OF
以下通常是一些技巧或陷阱。
29. 与条件跳转的比较
让我们检查以下代码,看看它跳转到哪个标签。
mov eax, -1
cmp eax, 1
ja L1
jmp L2
我们知道,CMP
的逻辑与 SUB
相同,但对目标操作数没有破坏性。使用 JA
表示考虑无符号比较,其中目标 EAX
为 FFh
,即 255
,而源为 1
。当然 255
大于 1
,因此它跳转到 L1
。因此,任何无符号比较,如 JA
、JB
、JAE
、JNA
等,都可以记为 A(Above) 或 B(Below)。无符号比较由 CF
和零标志 ZF
确定,如下例所示。
CMP 如果 | 目标 | 源 | ZF(ZR) | CF(CY) |
---|---|---|---|---|
目标<源 | 1 | 2 | 0 | 1 |
目标>源 | 2 | 1 | 0 | 0 |
目标=源 | 1 | 1 | 1 | 0 |
现在让我们看一下有符号比较,如下面的代码所示,看看它跳转到哪里。
mov eax, -1
cmp eax, 1
jg L1
jmp L2
唯一不同的是这里的 JG
而不是 JA
。使用 JG
表示考虑有符号比较,其中目标 EAX
是 FFh
,即 -1
,而源是 1
。当然 -1
小于 1
,因此 JMP
到 L2
。同样,任何有符号比较,如 JG
、JL
、JGE
、JNG
等,都可以看作是 G(Greater) 或 L(Less)。有符号比较由 OF
和符号标志 SF
确定,如下例所示。
CMP 如果 | 目标 | 源 | SF(PL) | OF(OV) |
---|---|---|---|---|
目标<源: (SF != OF) | -2 | 127 | 0 | 1 |
-2 | 1 | 1 | 0 | |
目标>源: (SF == OF) | 127 | 1 | 0 | 0 |
127 | -1 | 1 | 1 | |
目标 = 源 | 1 | 1 | ZF=1 |
30. 当 CBW、CWD 或 CDQ 错误地遇到 DIV 时…
我们知道,DIV
指令用于无符号整数,以执行 8 位、16 位或 32 位整数除法,被除数为 AX
、DX:AX
或 EDX:EAX
。对于无符号数,在使用 DIV
之前,必须通过将 AH
、DX
或 EDX
清零来清除上半部分。但当执行有符号除法时,使用 IDIV
,提供了符号扩展 CBW
、CWD
和 CDQ
来在 IDIV
之前扩展上半部分。
对于正整数,如果其最高位(符号位)为零,则手动清除被除数上半部分与错误地使用符号扩展没有区别,如以下示例所示。
mov eax,1002h
cdq
mov ebx,10h
div ebx ; Quotient EAX = 00000100h, Remainder EDX = 2
这是可以的,因为 1000h
是一个小的正数,并且 CDQ
使 EDX
为零,这与直接清除 EDX
相同。因此,如果您的值是正数且最高位为零,使用 CDQ
和
XOR EDX, EDX
完全相同。
但是,这并不意味着您在执行正数除法时始终可以使用 CDQ
/CWD
/CBW
和 DIV
。例如,对于 8 位数 129/2
,预期商为 64
,余数为 1
。但是,如果您这样做
mov al, 129
cbw ; Extend AL to AH as negative AX = FF81h
mov bl,2
div bl ; Unsigned DIV, Quotient should be 7FC0 over size of AL
尝试在调试器中运行以上代码,看看整数除法溢出是如何发生的。如果确实要使其正确地作为无符号 DIV
,则必须
mov al, 129
XOR ah, ah ; extend AL to AH as positive
mov bl,2
div bl ; Quotient AL = 40h, Remainder AH = 1
另一方面,如果您确实想使用 CBW
,这意味着您正在执行有符号除法。那么您必须使用 IDIV
。
mov al, 129 ; 81h (-127d)
cbw ; Extend AL to AH as negative AX = FF81h
mov bl,2
idiv bl ; Quotient AL = C1h (-63d), Remainder AH = FFh (-1)
如上所示,81h
在有符号字节中是十进制 -127
,因此有符号 IDIV
给出了正确的商和余数,如上所示。
31. 为什么 255-1 和 255+(-1) 对 CF 的影响不同?
为了讨论进位标志 CF
,让我们考虑以下两个算术计算。
mov al, 255
sub al, 1 ; AL = FE CF = 0
mov bl, 255
add bl, -1 ; BL = FE CF = 1
从人类的角度来看,它们执行完全相同的操作,255
减去 1
,结果是 254 (FEh
)。同样,从硬件角度来看,对于任何一种计算,CPU 执行相同的操作,将 -1
表示为二进制补码 FFh
,然后将其加到 255
。现在 255
是 FFh
,-1
的二进制格式也是 FFh
。计算过程如下。
1111 1111 + 1111 1111 ------------- 1111 1110
还记得吗?CPU 对有符号和无符号整数的操作完全相同,因为它无法区分它们。程序员应该能够通过受影响的标志来解释行为。由于我们讨论的是 CF
,这意味着我们将两种计算都视为无符号。关键信息是 -1
是 FFh
,然后是十进制的 255
。因此,CF
的逻辑解释是:
- 对于
sub al, 1
,表示255
减去1
,结果是254
,无需借位,因此CF
=0
。 - 对于
add bl, -1
,似乎255
加255
的结果是510
,但进位1,0000,0000b
(256
) 超出,字节中剩下254
作为余数,因此CF
=1
。
从硬件实现来看,CF
取决于使用哪条指令,ADD
或 SUB
。此处 MSB(最高有效位)是最高位。
- 对于
ADD
指令,add bl, -1
,直接使用来自 MSB 的进位,因此CF
=1
。 - 对于
SUB
指令,sub al, 1
,必须 **反转** 来自 MSB 的进位,因此CF
=0
。
32. 如何确定 OF?
现在让我们看看溢出标志 OF
,仍然使用上面的两个算术计算,如下所示。
mov al, 255
sub al, 1 ; AL = FE OF = 0
mov bl, 255
add bl, -1 ; BL = FE OF = 0
两者都不是溢出,因此 OF
= 0
。我们可以通过两种方式确定 OF
,逻辑规则和硬件实现。
逻辑观点:仅当设置溢出标志时,OF
= 1
。
- 两个正操作数相加,它们的和为负数
- 两个负操作数相加,它们的和为正数
对于有符号数,255
是 -1
(FFh
)。标志 OF
不关心 ADD
或 SUB
。我们的两个示例只是执行 -1
加 -1
,结果为 -2
。因此,两个负数相加,和仍然是负数,所以 OF
= 0
。
硬件实现:对于非零操作数,
OF
= (来自 MSB 的进位)XOR
(进入 MSB 的进位)
再次查看我们的计算。
1111 1111 + 1111 1111 ------------- 1111 1110
来自 MSB 的进位是 1
,进入 MSB 的进位也是 1
。然后 OF
= (1 XOR 1
) = 0
。
为了更多练习,下表列出了不同的测试用例,供您理解。
模糊的 "LOCAL" 指令
如前所述,PTR
操作符有两种用法,如 DWORD PTR
和 PTR DWORD
。但是 MASM 提供了另一个令人困惑的指令 LOCAL
,它根据上下文是模糊的,在哪里使用完全相同的保留字。以下是 MSDN 的规范。
LOCAL localname [[, localname]]...
LOCAL label [[ [count ] ]] [[:type]] [[, label [[ [count] ]] [[type]]]]...
- 在第一个指令中,在宏内部,
LOCAL
定义了对宏的每个实例唯一的标签。 - 在第二个指令中,在过程定义 (PROC) 中,
LOCAL
创建基于堆栈的变量,这些变量存在于过程的生命周期内。标签可以是简单变量,也可以是包含 count 个元素的数组。
此规范不够清晰,无法理解。在本节中,我将揭示它们之间的基本区别,并展示两个使用 LOCAL
指令的示例,一个在过程中,另一个在宏中。为了您的熟悉,这两个示例都计算了早期的第 n 个斐波那契数 FibonacciByMemory
。这里传递的主要重点是:
- 在宏中由
LOCAL
**声明**的变量 **不**局限于宏。它们是数据段上系统生成的全局变量,用于解决重定义。 - 在过程中由
LOCAL
**创建**的变量是真正的局部变量,分配在堆栈帧上,其生命周期仅限于过程。
有关 数据段和堆栈帧 的基本概念和实现,请参考一些教科书或 MASM 手册,它们可能值得几章的篇幅,而在此处不予讨论。
33. 当 LOCAL 用于过程中时
以下是一个带有参数 n
的过程,用于计算第 n 个斐波那契数并返回 EAX
。我让循环计数器 ECX
覆盖参数 n
。请与 FibonacciByMemory
进行比较。逻辑相同,唯一的区别是这里使用了局部变量 pre
和 cur
,而不是 FibonacciByMemory
中的全局变量 previous
和 current
。
;------------------------------------------------------------
FibonacciByLocalVariable PROC USES ecx edx, n:DWORD
; Receives: Input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
LOCAL pre, cur :DWORD
mov ecx,n
mov eax,1
mov pre,0
mov cur,0
L1:
add eax, pre ; eax = current + previous
mov edx, cur
mov pre, edx
mov cur, eax
loop L1
ret
FibonacciByLocalVariable ENDP
以下是运行时从 VS 反汇编窗口生成的代码。正如您所看到的,每一行汇编源代码都已转换为机器码,参数 n
和两个局部变量在堆栈帧上创建,并通过 EBP
引用。
231: ;------------------------------------------------------------
232: FibonacciByLocalVariable PROC USES ecx edx, n:DWORD
011713F4 55 push ebp
011713F5 8B EC mov ebp,esp
011713F7 83 C4 F8 add esp,0FFFFFFF8h
011713FA 51 push ecx
011713FB 52 push edx
233: ; Receives: Input n
234: ; Returns: EAX, nth Fibonacci number
235: ;------------------------------------------------------------
236: LOCAL pre, cur :DWORD
237:
238: mov ecx,n
011713FC 8B 4D 08 mov ecx,dword ptr [ebp+8]
239: mov eax,1
011713FF B8 01 00 00 00 mov eax,1
240: mov pre,0
01171404 C7 45 FC 00 00 00 00 mov dword ptr [ebp-4],0
241: mov cur,0
0117140B C7 45 F8 00 00 00 00 mov dword ptr [ebp-8],0
242: L1:
243: add eax,pre ; eax = current + previous
01171412 03 45 FC add eax,dword ptr [ebp-4]
244: mov EDX, cur
01171415 8B 55 F8 mov edx,dword ptr [ebp-8]
245: mov pre, EDX
01171418 89 55 FC mov dword ptr [ebp-4],edx
246: mov cur, eax
0117141B 89 45 F8 mov dword ptr [ebp-8],eax
247: loop L1
0117141E E2 F2 loop 01171412
248:
249: ret
01171420 5A pop edx
01171421 59 pop ecx
01171422 C9 leave
01171423 C2 04 00 ret 4
250: FibonacciByLocalVariable ENDP
当 FibonacciByLocalVariable
运行时,堆栈帧如下所示。
显然,参数 n
在 EBP+8
。这
add esp, 0FFFFFFF8h
仅仅意味着
sub esp, 08h
将堆栈指针 ESP
向下移动八个字节,以创建两个 DWORD
pre
和 cur
。最后,LEAVE
指令隐式执行
mov esp, ebp
pop ebp
将 EBP
移回 ESP
,释放局部变量 pre
和 cur
。并为 STD 调用约定释放 n
,位于 EBP+8
。
ret 4
34. 当 LOCAL 用于宏时
为了实现宏,我几乎复制了 FibonacciByLocalVariable
的代码。由于宏没有 USES
,我手动使用 PUSH
/POP
来处理 ECX
和 EDX
。而且,由于没有堆栈帧,我必须在数据段上创建 **全局** 变量 mPre
和 mCur
。mFibonacciByMacro
可以是这样的。
;------------------------------------------------------------
mFibonacciByMacro MACRO n
; Receives: Input n
; Returns: EAX, nth Fibonacci number
;------------------------------------------------------------
LOCAL mPre, mCur, mL
.data
mPre DWORD ?
mCur DWORD ?
.code
push ecx
push edx
mov ecx,n
mov eax,1
mov mPre,0
mov mCur,0
mL:
add eax, mPre ; eax = current + previous
mov edx, mCur
mov mPre, edx
mov mCur, eax
loop mL
pop edx
pop ecx
ENDM
如果您只想调用 mFibonacciByMacro
一次,例如。
mFibonacciByMacro 12
您不需要在这里使用 LOCAL
。让我们简单地注释掉。
; LOCAL mPre, mCur, mL
mFibonacciByMacro
接受参数 12
并将 n
替换为 12
。这与以下 MASM 生成的列表一起正常工作。
mFibonacciByMacro 12
0000018C 1 .data
0000018C 00000000 1 mPre DWORD ?
00000190 00000000 1 mCur DWORD ?
00000000 1 .code
00000000 51 1 push ecx
00000001 52 1 push edx
00000002 B9 0000000C 1 mov ecx,12
00000007 B8 00000001 1 mov eax,1
0000000C C7 05 0000018C R 1 mov mPre,0
00000000
00000016 C7 05 00000190 R 1 mov mCur,0
00000000
00000020 1 mL:
00000020 03 05 0000018C R 1 add eax,mPre ; eax = current + previous
00000026 8B 15 00000190 R 1 mov edx, mCur
0000002C 89 15 0000018C R 1 mov mPre, edx
00000032 A3 00000190 R 1 mov mCur, eax
00000037 E2 E7 1 loop mL
00000039 5A 1 pop edx
0000003A 59 1 pop ecx
与原始代码相比,没有任何变化,只是将 12
进行了替换。变量 mPre
和 mCur
是显式可见的。现在让我们调用两次,例如。
mFibonacciByMacro 12
mFibonacciByMacro 13
这对于第一个 mFibonacciByMacro 12
仍然可以,但第二个会导致预处理中的三次重定义 mFibonacciByMacro 13
。不仅是数据标签,即变量 mPre
和 mCur
,还抱怨了代码标签 mL
。这是因为在汇编代码中,每个标签实际上是一个内存地址,任何 mPre
、mCur
或 mL
的第二个标签都应该占用另一个内存,而不是定义一个已经创建的内存。
mFibonacciByMacro 12
0000018C 1 .data
0000018C 00000000 1 mPre DWORD ?
00000190 00000000 1 mCur DWORD ?
00000000 1 .code
00000000 51 1 push ecx
00000001 52 1 push edx
00000002 B9 0000000C 1 mov ecx,12
00000007 B8 00000001 1 mov eax,1
0000000C C7 05 0000018C R 1 mov mPre,0
00000000
00000016 C7 05 00000190 R 1 mov mCur,0
00000000
00000020 1 mL:
00000020 03 05 0000018C R 1 add eax,mPre ; eax = current + previous
00000026 8B 15 00000190 R 1 mov edx, mCur
0000002C 89 15 0000018C R 1 mov mPre, edx
00000032 A3 00000190 R 1 mov mCur, eax
00000037 E2 E7 1 loop mL
00000039 5A 1 pop edx
0000003A 59 1 pop ecx
mFibonacciByMacro 13
00000194 1 .data
1 mPre DWORD ?
FibTest.32.asm(83) : error A2005:symbol redefinition : mPre
mFibonacciByMacro(6): Macro Called From
FibTest.32.asm(83): Main Line Code
1 mCur DWORD ?
FibTest.32.asm(83) : error A2005:symbol redefinition : mCur
mFibonacciByMacro(7): Macro Called From
FibTest.32.asm(83): Main Line Code
0000003B 1 .code
0000003B 51 1 push ecx
0000003C 52 1 push edx
0000003D B9 0000000D 1 mov ecx,13
00000042 B8 00000001 1 mov eax,1
00000047 C7 05 0000018C R 1 mov mPre,0
00000000
00000051 C7 05 00000190 R 1 mov mCur,0
00000000
1 mL:
FibTest.32.asm(83) : error A2005:symbol redefinition : mL
mFibonacciByMacro(17): Macro Called From
FibTest.32.asm(83): Main Line Code
0000005B 03 05 0000018C R 1 add eax,mPre ; eax = current + previous
00000061 8B 15 00000190 R 1 mov edx, mCur
00000067 89 15 0000018C R 1 mov mPre, edx
0000006D A3 00000190 R 1 mov mCur, eax
00000072 E2 AC 1 loop mL
00000074 5A 1 pop edx
00000075 59 1 pop ecx
为了挽救,让我们打开这个。
LOCAL mPre, mCur, mL
再次,两次运行 mFibonacciByMacro
,分别是 12
和 13
,这次没问题,我们有。
mFibonacciByMacro 12
0000018C 1 .data
0000018C 00000000 1 ??0000 DWORD ?
00000190 00000000 1 ??0001 DWORD ?
00000000 1 .code
00000000 51 1 push ecx
00000001 52 1 push edx
00000002 B9 0000000C 1 mov ecx,12
00000007 B8 00000001 1 mov eax,1
0000000C C7 05 0000018C R 1 mov ??0000,0
00000000
00000016 C7 05 00000190 R 1 mov ??0001,0
00000000
00000020 1 ??0002:
00000020 03 05 0000018C R 1 add eax,??0000 ; eax = current + previous
00000026 8B 15 00000190 R 1 mov edx, ??0001
0000002C 89 15 0000018C R 1 mov ??0000, edx
00000032 A3 00000190 R 1 mov ??0001, eax
00000037 E2 E7 1 loop ??0002
00000039 5A 1 pop edx
0000003A 59 1 pop ecx
mFibonacciByMacro 13
00000194 1 .data
00000194 00000000 1 ??0003 DWORD ?
00000198 00000000 1 ??0004 DWORD ?
0000003B 1 .code
0000003B 51 1 push ecx
0000003C 52 1 push edx
0000003D B9 0000000D 1 mov ecx,13
00000042 B8 00000001 1 mov eax,1
00000047 C7 05 00000194 R 1 mov ??0003,0
00000000
00000051 C7 05 00000198 R 1 mov ??0004,0
00000000
0000005B 1 ??0005:
0000005B 03 05 00000194 R 1 add eax,??0003 ; eax = current + previous
00000061 8B 15 00000198 R 1 mov edx, ??0004
00000067 89 15 00000194 R 1 mov ??0003, edx
0000006D A3 00000198 R 1 mov ??0004, eax
00000072 E2 E7 1 loop ??0005
00000074 5A 1 pop edx
00000075 59 1 pop ecx
现在标签名称 mPre
、mCur
和 mL
不可见。相反,运行第一个 mFibonacciByMacro 12
,预处理器会生成三个系统标签 ??0000
、??0001
和 ??0002
来分别代表 mPre
、mCur
和 mL
。对于第二个 mFibonacciByMacro 13
,我们可以找到另外三个系统生成的标签 ??0003
、??0004
和 ??0005
来分别代表 mPre
、mCur
和 mL
。这样,MASM 就解决了多次宏执行中的重定义问题。您必须在宏中用 LOCAL
指令声明您的标签。
但是,根据 LOCAL
这个名称,该指令听起来有误导性,因为系统生成的 ??0000
、??0001
等并**不**局限于宏的上下文。它们实际上是全局范围的。为了验证,我特意将 mPre
和 mCur
初始化为 2
和 3
。
LOCAL mPre, mCur, mL
.data
mPre DWORD 2
mCur DWORD 3
然后,在代码中调用两次 mFibonacciByMacro
**之前**,只需尝试从 ??0000
和 ??0001
中检索值。
mov esi, ??0000
mov edi, ??0001
mFibonacciByMacro 12
mFibonacciByMacro 13
可能令您惊讶的是,当设置断点时,您可以像普通变量一样在 VS 调试地址框中输入 &??0000
。正如我们在此处看到的,??0000
的内存地址是 0x0116518C
,其 DWORD
值为 2
、3
等。这样的 ??0000
与其他命名良好的变量一起分配在数据段上,旁边显示了字符串 ASCII。
总而言之,在宏中声明的 LOCAL
指令是为了防止数据/代码标签被全局重定义。
此外,作为一个有趣的测试问题,请考虑以下多次运行 mFibonacciByMacro
的情况,它在 mFibonacciByMacro
中不需要 LOCAL
指令就能正常工作。为什么?
mov ecx, 2
L1:
mFibonacciByMacro 12
loop L1
在 C/C++ 中调用汇编过程以及反之
大多数汇编编程课程应该提到一个有趣的混合语言编程主题,例如 C/C++ 代码如何调用汇编过程以及汇编代码如何调用 C/C++ 函数。但是,可能不会涉及太多内容,特别是手动堆栈帧操作和名称修饰。在前面两个部分中,我将给出一个简单的 C/C++ 代码调用汇编过程的示例。我将展示 C
和 STD
调用约定,使用具有高级形参列表的过程,或者直接处理堆栈帧和名称修饰。
逻辑只是计算 x-y
,例如 10-3
以显示 7
的结果。
int someFunction(int x, int y)
{
return x-y;
}
cout << "Call someFunction: 10-3 = " << someFunction(10, 3) << endl;
当从 C/C++ 函数调用汇编过程时,两者都必须在调用和命名约定上保持一致,以便链接器可以解析对调用者及其被调用者的引用。对于 Visual C/C++ 函数,C
调用约定可以通过关键字 __cdecl 来指定,它应该是 C/C++ 模块中的默认设置。而 STD
调用约定可以通过 __stdcall 来指定。在汇编语言方面,MASM 也提供了相应的保留字 C
和 stdcall
。在汇编语言模块中,您可以简单地使用 .model
指令来声明所有过程都遵循 C
调用约定,如下所示。
.model flat, C
但您也可以通过指示单个过程使用不同的调用约定来覆盖此全局声明,例如。
ProcSTD_CallWithParameterList PROC stdcall, x:DWORD, y:DWORD
以下部分假设您对以上内容有基本的知识和理解。
35. 两种方式使用 C 调用约定
让我们首先从下面的过程开始,它有一个形参列表。我特意将 .model
指令中的调用约定属性字段留空,但我有 PROC C
来将其定义为 C
调用约定。
.386P
.model flat ; No any convention declared
.code
;-----------------------------------------------------------------------
ProcC_CallWithParameterList PROC C, x:DWORD, y:DWORD
; Explicitly declared, C calling convention with Parameter list
; Receives: x and y as unsigned integers
; Returns: EAX, the result x-y
;-----------------------------------------------------------------------
mov eax, x ; first argument
sub eax, y ; second argument
ret
ProcC_CallWithParameterList endp
过程 ProcC_CallWithParameterList
仅执行减法 x-y
并将差值返回 EAX
。为了从 .CPP
文件中的函数调用它,我必须在 .CPP
文件中声明一个等效的 C
原型,其中 __cdecl
是默认的。
extern "C" int ProcC_CallWithParameterList(int, int);
然后像这样在 main()
中调用它:
cout << "C-Call With Parameters: 10-3 = " << ProcC_CallWithParameterList(10, 3) << endl;
使用语言属性 C
来声明 ProcC_CallWithParameterList
会在幕后隐藏很多东西。请回顾一下 C
调用约定 __cdecl 会发生什么。我想展示的主要一点是:
约定 | 所需实现 | |
参数传递 | 从右到左 | |
堆栈维护 | 调用者从堆栈中弹出参数 | |
名称修饰 | 在函数名前加上下划线字符 (_) |
基于这些规范,我可以手动创建这个过程来适应 C
调用约定。
;-----------------------------------------------------------------------
_ProcC_CallWithStackFrame PROC near
; For __cdecl, manually making C calling convention with Stack Frame
; Receives: x and y on the Stack Frame
; Returns: EAX, the result x-y
;-----------------------------------------------------------------------
push ebp
mov ebp,esp
mov eax,[ebp+8] ; first argument x
sub eax,[ebp+12] ; second argument y
pop ebp
ret
_ProcC_CallWithStackFrame endp
如您所见,在 _ProcC_CallWithStackFrame
前面添加了下划线,两个参数 x
和 y
按相反顺序传递,堆栈帧如下所示。
现在,让我们通过 C++ 调用来验证两个过程是否完全相同。
extern "C" {
int ProcC_CallWithParameterList(int, int);
int ProcC_CallWithStackFrame(int, int);
}
int main()
{
cout << "C-Call With Parameters: 10-3 = " << ProcC_CallWithParameterList(10, 3) << endl;
cout << "C-Call With Stack Frame: 10-3 = " << ProcC_CallWithStackFrame(10, 3) << endl;
// ... ...
}
36. 两种方式使用 STD 调用约定
现在我们可以用类似的方式来看 STD
调用。下面只是一个带有语言属性 stdcall
为 PROC
定义的形参列表过程。
;-----------------------------------------------------------------------
ProcSTD_CallWithParameterList PROC stdcall, x:DWORD, y:DWORD
; Explicitly declared, C calling convention with Parameter list
; Receives: x and y as unsigned integers
; Returns: EAX, the result x-y
;-----------------------------------------------------------------------
mov eax, x ; first argument
sub eax, y ; second argument
ret
ProcSTD_CallWithParameterList endp
除了调用约定之外,ProcSTD_CallWithParameterList
和 ProcC_CallWithParameterList
之间没有区别。为了从 C 函数调用 ProcSTD_CallWithParameterList
,原型应该如下所示。
extern "C" int __stdcall ProcSTD_CallWithParameterList(int, int);
请注意,这次声明 __stdcall
是必须的。同样,使用 stdcall
来声明 ProcSTD_CallWithParameterList
也隐藏了许多细节。请回顾一下 STD
调用约定 __stdcall 会发生什么。需要讨论的主要内容是:
约定 | 所需实现 | |
参数传递 | 从右到左 | |
堆栈维护 | 被调用函数本身从堆栈中弹出参数 | |
名称修饰 | 在函数名前加上下划线字符 (_)。名称后面跟着 at 符号 (@) 和参数列表的字节计数(十进制)。 |
基于这些规范,我可以手动创建此过程以适应STD
调用约定。
;-----------------------------------------------------------------------
_ProcSTD_CallWithStackFrame@8 PROC near
; For __stdcall, manually making STD calling convention with Stack Frame
; Receives: x and y on the Stack Frame
; Returns: EAX, the result x-y
;-----------------------------------------------------------------------
push ebp
mov ebp,esp
mov eax,[ebp+8] ; first argument x
sub eax,[ebp+12] ; second argument y
pop ebp
ret 8
_ProcSTD_CallWithStackFrame@8 endp
尽管对于以相反顺序传递的两个参数x
和y
,堆栈帧是相同的,但一个区别是_ProcSTD_CallWithStackFrame@8
后面跟着数字八,即8个字节的两个int类型参数。另一个区别是ret 8
,它用于此过程本身释放堆栈参数内存。
现在将所有内容放在一起,我们可以验证由 C++ 调用并产生相同结果的四个过程。
extern "C" {
int ProcC_CallWithParameterList(int, int);
int ProcC_CallWithStackFrame(int, int);
int __stdcall ProcSTD_CallWithParameterList(int, int);
int __stdcall ProcSTD_CallWithStackFrame(int, int);
}
int main()
{
cout << "C-Call With Parameters: 10-3 = " << ProcC_CallWithParameterList(10, 3) << endl;
cout << "C-Call With Stack Frame: 10-3 = " << ProcC_CallWithStackFrame(10, 3) << endl;
cout << "STD-Call With Parameters: 10-3 = " << ProcSTD_CallWithParameterList(10, 3) << endl;
cout << "STD-Call With Stack Frame: 10-3 = " << ProcSTD_CallWithStackFrame(10, 3) << endl;
}
37. 在汇编过程中调用 cin/cout
本节将回答一个反向问题:如何从汇编过程中调用 C/C++ 函数。我们确实需要这样的技术来利用现成的、现成的、高级语言的子例程来处理 I/O、浮点数据和数学函数。在这里,我想在一个汇编过程中执行一个减法任务,并像这样通过调用cin
和cout
来输入和输出。
我对两次调用都使用了C
调用约定,为了做到这一点,让我们创建三个C
原型。
extern "C" {
// A C function to be called in DoSubtraction, passing 'X' or 'Y' as an input prompt
int ReadFromConsole(unsigned char);
// A C function to be called in DoSubtraction, to show expression text and integer result
void DisplayToConsole(char*, int);
// An assembly procedure to be called in C++ main()
void DoSubtraction();
}
在DoSubtraction
中定义前两个要调用的函数是微不足道的,而DoSubtraction
则应该在main()
中调用。
int ReadFromConsole(unsigned char by)
{
cout << "Enter " << by <<": ";
int i;
cin >> i;
return i;
}
void DisplayToConsole(char* s, int n)
{
cout << s << n <<endl <<endl;
}
int main()
{
DoSubtraction();
// ... ...
}
现在是时候实现汇编过程DoSubtraction
了。由于DoSubtraction
将调用两个 C++ 函数进行 I/O,我必须创建它们的等效原型,使其可被DoSubtraction
接受和识别。
ReadFromConsole PROTO C, by:BYTE
DisplayToConsole PROTO C, s:PTR BYTE, n:DWORD
接下来,只需填充逻辑即可通过调用ReadFromConsole
和DisplayToConsole
使其工作。
;-----------------------------------------------------------------------
DoSubtraction PROC C
; Call C++ ReadFromConsole to read X, Y and DisplayToConsole show X-Y
;-----------------------------------------------------------------------
.data
text2Disp BYTE 'X-Y =', 0
diff DWORD ?
.code
INVOKE ReadFromConsole, 'X'
mov diff, eax
INVOKE ReadFromConsole, 'Y'
sub diff, eax
INVOKE DisplayToConsole, OFFSET text2Disp, diff
ret
DoSubtraction endp
最后,上面三个部分的所有源代码都可以在 CallingAsmProcInC 下载,包括main.cpp
、subProcs.asm
和 VS 项目。
关于 ADDR 操作符
在 32 位模式下,INVOKE
、PROC
和PROTO
指令为定义和调用过程提供了强大的方式。结合这些指令,ADDR
运算符是定义过程参数的一个基本辅助工具。通过使用INVOKE
,您可以使过程调用几乎与高级编程语言中的函数调用相同,而不必关心运行时堆栈的底层机制。
不幸的是,ADDR
运算符的解释不够充分或文档不完善。MASM 简单地将其描述为 地址表达式(以 ADDR 为前缀的表达式)。教材 [1] 提到了一些更详细的内容。
ADDR
运算符在 32 位模式下也可用,可用于在使用 INVOKE
调用过程时传递指针参数。例如,以下 INVOKE
语句将 myArray
的地址传递给 FillArray
过程。
INVOKE FillArray, ADDR myArray
传递给 ADDR
的参数必须是汇编时常量。以下是错误的。
INVOKE mySub, ADDR [ebp+12] ; error
ADDR
运算符只能与 INVOKE
结合使用。以下是错误的。
mov esi, ADDR myArray ; error
所有这些听起来都不错,但不够清晰或准确,甚至在编程概念上也不易理解。ADDR
不仅可以在汇编时与全局变量(如 myArray
)一起使用以替换 OFFSET
,还可以放在堆栈内存(例如局部变量或过程参数)之前。以下实际上是可能的,而不会导致汇编错误。
INVOKE mySub, ADDR [ebp+12]
不要这样做,仅仅因为它是不必要的,而且在某种程度上是无意义的。INVOKE
指令会自动为您生成序言和结尾代码,并使用 EBP
以 EBP
偏移量的形式推送参数。以下各节将展示 ADDR
运算符的智能之处,在汇编时和运行时具有不同的解释。
38. 在数据段中使用全局变量定义
我们首先创建一个过程来执行减法 C=A-B
,所有三个参数都是地址参数(传址调用)。显然,我们必须使用间接操作数 ESI
并对其进行解引用,以从 parA
和 parB
接收两个值。输出参数 parC
将结果保存回调用者。
;-------------------------------------------------------------------
SubWithADDR PROC, parA:PTR BYTE, parB:PTR BYTE, parC:PTR BYTE
;
; The task to perform subtraction C=A-B.
; Receives: Pointer parameters parA, parB, parC to three BYTE memory
; Returns: The result A-B in parC
;-------------------------------------------------------------------
mov esi, parA
mov al, [esi]
mov esi, parB
sub al, [esi]
mov esi, parC
mov [esi], al
ret
SubWithADDR ENDP
并在 DATA
段中定义三个全局变量。
.data
valA BYTE 7
valB BYTE 3
valC BYTE 0
然后直接将这些全局变量与 ADDR
一起作为三个地址传递给 SubWithADDR
。
; Test 1:
INVOKE SubWithADDR, ADDR valA, ADDR valB, ADDR valC
mov bl, valC
现在让我们使用“列出所有可用信息”选项生成代码列表,如下所示。
列表仅显示了三个被 OFFSET
替换的 ADDR
运算符。
; Test 1:
INVOKE SubWithADDR, ADDR valA, ADDR valB, ADDR valC
0000005B 68 00000002 R * push OFFSET valC
00000060 68 00000001 R * push OFFSET valB
00000065 68 00000000 R * push OFFSET valA
0000006A E8 FFFFFF91 * call SubWithADDR
0000006F 8A 1D 00000002 R mov bl, valC
这在逻辑上是合理的,因为 valA
、valB
和 valC
是在汇编时静态创建的,因此 OFFSET
运算符必须在汇编时应用。在这种情况下,我们也可以使用 OFFSET
代替 ADDR
。让我们试试。
INVOKE SubWithADDR, ADDR valA, OFFSET valB, OFFSET valC
并在此处重新生成列表,看看实际上没有本质区别。
; Test 1:
INVOKE SubWithADDR, ADDR valA, OFFSET valB, OFFSET valC
0000005B 68 00000002 R * push dword ptr OFFSET FLAT: valC
00000060 68 00000001 R * push dword ptr OFFSET FLAT: valB
00000065 68 00000000 R * push OFFSET valA
0000006A E8 FFFFFF91 * call SubWithADDR
0000006F 8A 1D 00000002 R mov bl, valC
39. 在过程中创建局部变量
为了测试 ADDR
应用于局部变量,我们必须创建另一个定义了三个局部变量的过程。
;--------------------------------------------------------
WithLocalVariable PROC
LOCAL locA, locB, locC: BYTE
;
; INVOKE SubWithADDR with three local variable addresses
; Receives: None
; Returns: The result A-B in CL via locC
;--------------------------------------------------------
mov locA, 8
mov locB, 2
INVOKE SubWithADDR, ADDR locA, ADDR locB, ADDR locC
mov cl, locC
ret
WithLocalVariable ENDP
请注意,locA
、locB
和locC
是BYTE
类型的内存。为了通过 INVOKE
重用 SubWithADDR
,我需要为输入参数 locA
和 locB
准备像8
和2
这样的值,并让 locC
返回结果。我必须将 ADDR
应用于这三个变量,以满足 SubWithADDR
原型的调用接口。现在,进行第二次测试。
; Test 2:
call WithLocalVariable
此时,局部变量是在堆栈帧上创建的。这是运行时动态创建的内存。显然,汇编时运算符 OFFSET
不能由 ADDR
假定。正如您可能想到的,LEA
指令应该上场(LEA
已经提到:11。使用加号(+)而不是 ADD 实现 和 21。创建清晰的调用接口)。
果然,ADDR
运算符这次足够智能地选择了LEA
。为了易于阅读,我想避免使用列表来查看到 EBP
的 2 的补码偏移量。相反,请在此处检查运行时直观的反汇编显示。代码显示三个 ADDR
运算符被三个 LEA
指令替换,它们与堆栈上的 EBP
结合使用,如下所示。
43: WithLocalVariable PROC
00401046 55 push ebp
00401047 8B EC mov ebp,esp
00401049 83 C4 F4 add esp,0FFFFFFF4h
44: LOCAL locA, locB, locC: BYTE
45: ;
46: ; INVOKE SubWithADDR with three local variable addresses
47: ; Receives: None
48: ; Returns: The result A-B in CL via locC
49: ;--------------------------------------------------------
50:
51: mov locA, 8
0040104C C7 45 FC 08 00 00 00 mov dword ptr [ebp-4],8
52: mov locB, 2
00401053 C7 45 F8 02 00 00 00 mov dword ptr [ebp-8],2
53: INVOKE SubWithADDR, ADDR locA, ADDR locB, ADDR locC
0040105A 8D 45 F7 lea eax,[ebp-9]
0040105D 50 push eax
0040105E 8D 45 F8 lea eax,[ebp-8]
00401061 50 push eax
00401062 8D 45 FC lea eax,[ebp-4]
00401065 50 push eax
00401066 E8 C5 FF FF FF call 00401030
54: mov cl, locC
0040106B 8A 4D F7 mov cl,byte ptr [ebp-9]
55: ret
0040106E C9 leave
0040106F C3 ret
56: WithLocalVariable ENDP
其中十六进制 00401030
是 SubWithADDR
的地址。由于 LOCAL
指令,MASM 会自动生成带有 EBP
表示的序言和结尾。要查看 EBP
偏移量而不是 locA
、locB
和 locC
等变量名,只需取消选中选项:显示符号名称。
40. 从过程中接收参数
第三个测试是使 ADDR
应用于参数。我创建了一个过程 WithArgumentPassed
并像这样调用它:
; Test3:
INVOKE WithArgumentPassed, 9, 1, OFFSET valC
在此处重用全局变量 valC
并使用 OFFSET
,因为我希望得到结果8
。看看列表是如何推送三个值的很有趣。
; Test3:
INVOKE WithArgumentPassed, 9, 1, OFFSET valC
0000007B 68 00000002 R * push dword ptr OFFSET FLAT: valC
00000080 6A 01 * push +000000001h
00000082 6A 09 * push +000000009h
00000084 E8 FFFFFFB7 * call WithArgumentPassed
WithArgumentPassed
的实现非常直接,它通过传递以ADDR
为前缀的参数argA
和argB
作为地址来重用SubWithADDR
,而ptrC
已经是指针,没有ADDR
。
;----------------------------------------------------------------
WithArgumentPassed PROC argA: BYTE, argB: BYTE, ptrC: PTR BYTE
;
; INVOKE SubWithADDR with three argument addresses
; Receives: Parameters argA, argB in BYTE and ptrC as PTR BYTE
; Returns: The result A-B in DL via ptrC
;----------------------------------------------------------------
INVOKE SubWithADDR, ADDR argA, ADDR argB, ptrC
mov esi, ptrC
mov dl, [esi]
ret
WithArgumentPassed ENDP
如果您熟悉堆栈帧的概念,可以想象 ADDR
的行为必须与局部变量非常相似,因为参数也是运行时在堆栈上动态创建的内存。以下是生成的列表,其中两个 ADDR
运算符被 LEA
替换。唯一的区别是此处到 EBP
的正偏移量。
;-------------------------------------------------------------
00000040 WithArgumentPassed PROC argA: BYTE, argB: BYTE, ptrC: PTR BYTE
;
; INVOKE SubWithADDR with three argument addresses
; Receives: Parameters argA, argB in BYTE and ptrC as PTR BYTE
; Returns: The result A-B in DL via ptrC
;-------------------------------------------------------------
00000040 55 * push ebp
00000041 8B EC * mov ebp, esp
INVOKE SubWithADDR, ADDR argA, ADDR argB, ptrC
00000043 FF 75 10 * push dword ptr ss:[ebp]+000000010h
00000046 8D 45 0C * lea eax, byte ptr ss:[ebp]+00Ch
00000049 50 * push eax
0000004A 8D 45 08 * lea eax, byte ptr ss:[ebp]+008h
0000004D 50 * push eax
0000004E E8 FFFFFFAD * call SubWithADDR
00000053 8B 75 10 mov esi, ptrC
00000056 8A 16 mov dl, [esi]
ret
00000058 C9 * leave
00000059 C2 000C * ret 0000Ch
0000005C WithArgumentPassed ENDP
由于 WithArgumentPassed
的 PROC
带有参数列表,MASM 还会自动生成带有 EBP
表示的序言和结尾。按相反顺序推送的三个地址参数是 EBP
加上 16
(ptrC
)、加上 12
(argB
)和加上 8
(argA
)。
最后,上面三个部分的所有源代码都可以在 TestADDR 下载,包括TestADDR.asm
、TestADDR.lst
和TestADDR.vcxproj
。
摘要
我谈了很多关于汇编语言编程中的各种特性。其中大部分来自我们的课堂教学和作业讨论 [1]。基础实践以简短的代码片段呈现,以便更好地理解,而不涉及不相关的细节。主要目的是展示汇编语言特有的想法和方法,它们比其他语言更强大。
正如所注意到的,我还没有提供一个需要带输入输出的编程环境的完整测试代码。为了方便尝试,您可以 [2] 下载 Irvine32 库并使用 Visual Studio 设置您的 MASM 编程环境,但您必须预先学习很多内容才能做好准备。例如,这里main
中提到的exit
语句不是汇编语言的元素,而是在那里定义为INVOKE ExitProcess,0
。
汇编语言以其指令与其机器码之间的一对一对应关系而著称,正如这里所示的几个列表。通过汇编代码,您可以更接近机器的核心,例如寄存器和内存。汇编语言编程在学术研究和行业开发中都起着重要作用。我希望本文能为学生和专业人士提供有用的参考。
参考文献
- CSCI 241,汇编语言编程课程网站
- Kip Irvine,x86 处理器汇编语言,第 7 版
- MASM 程序员指南,MASM 6.1 文档
- 丁卓留,您可能不知道的 MASM 中的宏
- 丁卓留,您可能不知道的 C/C++ 中的 switch 语句
历史
- 2019 年 1 月 28 日 - 添加:关于 ADDR 运算符,三个部分
- 2017 年 1 月 22 日 - 添加:调用 C/C++ 中的汇编过程以及反之亦然,三个部分
- 2017 年 1 月 11 日 - 添加:FOR/WHILE 循环和提高循环效率,两个部分
- 2016 年 12 月 20 日 - 添加:模糊的“LOCAL”指令,两个部分
- 2016 年 11 月 28 日 - 添加:有符号和无符号,四个部分
- 2016 年 10 月 30 日 - 添加:关于 PTR 运算符,两个部分
- 2016 年 10 月 16 日 - 添加:小端法,两个部分
- 2016 年 10 月 11 日 - 添加:部分,使用 INC 避免 PUSHFD 和 POPFD
- 2016 年 10 月 2 日 - 添加:部分,使用原子指令
- 2016 年 8 月 1 日 - 发布了原始版本