精通汇编语言编程(二)
原文:
annas-archive.org/md5/615c1868845695f8399bbdf3f670718e译者:飞龙
第六章:宏指令
使用汇编语言实现你的思想很有趣(我肯定已经说过这个了,而且可能还不止一次)。然而,当涉及到某些操作时,它可能变得相当烦人,因为这些操作必须在程序的不同部分重新实现。一种可能的解决方法是将这些操作实现为一个过程,并在需要时调用它。然而,一旦你有了一个过程,并且它接收超过零个参数,这个方法也可能很快变得令人烦恼。在高级语言中,你只需"传递"参数给一个函数,而在汇编中,你必须根据所选择的调用约定实际将它们传递给一个过程,这反过来可能带来更多的麻烦,尤其是在寄存器管理(如果参数通过特定寄存器传递)或访问栈的过程中。有时候,这种复杂性是值得的,但并非总是如此,尤其是当涉及到一组简短的重复指令时。这正是宏指令可以帮助我们避免许多麻烦和冗余工作的地方,更不用说调用时消耗的 CPU 时间(参数准备、过程的序言和尾声),这些微小的毫秒分数,最终可能会累计成相当可观的延迟。
本章我们将覆盖以下内容:
-
宏指令及其背后的机制
-
宏指令如何参数化
-
学习可变宏指令及其威力
-
了解常见的调用约定
-
审视额外的汇编指令和条件汇编
所有这些对于我们未来与本书的工作至关重要,因为我们将要探讨的方法和算法如果不使用这些方法,将会变得非常繁琐。
什么是宏指令?
首先,在我们深入宏指令的世界之前,我们必须了解它们到底是什么。简单来说,宏指令是指令序列的别名。你可能熟悉这个术语,它在高级语言中出现过(我们说"可能"是因为并不是所有高级语言都实现了这一特性),但我们还是会在这里解释一下。记得上一章中的以下序列吗?
movd xmm3, [dpy]
movlhps xmm3, xmm3
movsldup xmm3, xmm3
这个序列将一个 XMM 寄存器的所有四个单精度浮点数值(在这个特定情况下是 XMM3)从 dpy 指向的内存中加载。我们在代码中多次使用了这种序列,因此,将其替换为一个宏指令是非常自然的。这样,定义以下宏将使我们的代码看起来更加优雅和可读:
macro load_4 xmmreg, addr
{
movd xmmreg, [addr]
movlhps xmmreg, xmmreg
movsldup xmmreg, xmmreg
}
我们在代码中像这样使用它:
load_4 xmm3, dpy
load_4 xmm4, pi_2
这样会使代码看起来更优雅,也更具可读性。
括号是 FASM 的一个很棒的特性,而在 MASM 和 GAS 中则没有这个特性。相反,在 MASM 中,你将写出如下代码:
MACRO macro_name
; 宏体
ENDM
以及下面这段 GAS 代码:
.macro macro_name
; 宏体
`.endm`
它是如何工作的
宏指令的逻辑相当简单。预处理器解析代码中的宏指令定义,并将其存储,简而言之,就像一个字典,其中宏指令的名称是键,它的内容是值。当然,实际上更为复杂,因为宏指令可能有(而且大多数情况下都有)参数,更不用说它们可能还是可变的(即具有未定义数量的参数)。
当汇编器处理代码并遇到未知的指令时,它会检查这个字典,以查找具有相应名称的宏指令。一旦找到这样的条目,汇编器就会用其值替换宏指令——即扩展宏。考虑到汇编器看到如下内容:
load_4 xmm3, dpy
然后,它会参考收集到的宏指令定义,并将这一行替换为实际的代码:
movd xmm3, [dpy]
movlhps xmm3, xmm3
movsldup xmm3, xmm3
如果汇编器找不到相关的宏定义,错误报告机制会通知我们。
带参数的宏指令
虽然你完全可以定义一个不接收任何参数的宏指令,但你很少需要这样做。大多数情况下,你会定义需要至少一个参数的宏指令。以实现过程前言的宏指令为例:
macro prolog frameSize
{
push ebp
mov ebp, esp
sub esp, frameSize
}
上述宏指令中的 frameSize 属性是一个宏参数,在此示例中,用于指定栈帧的大小(以字节为单位)。使用此类宏指令的方法如下:
my_proc:
prolog 8
*; body of the procedure*
mov esp, ebp
pop ebp
ret
上述代码在逻辑上等价于(并且会被预处理器展开为)以下内容:
my_proc:
push ebp
mov ebp, esp
sub esp, 8
*; body of the procedure*
mov esp, ebp
pop ebp
ret
此外,我们还可以定义 return 宏,它实现栈帧的销毁并从过程返回:
macro return
{
mov ebp, esp
pop ebp
ret
}
这将使我们的过程更加简短:
my_proc:
prolog 8
*; body of the procedure*
return
这里,return 宏也是一个很好的无参数宏指令示例。
可变宏指令
在某些情况下,我们不知道同一个宏指令在不同地方被调用时会传递多少个参数,而 FASM 为这样的问题提供了一个非常好且简单的解决方案——支持可变宏指令。术语可变意味着一个操作符、过程或宏可以接受不同数量的操作数/参数。
从语法上看,可变宏指令非常简单。我们以宏关键字开始,然后是宏的名称,后跟一个逗号分隔的参数列表(如果有的话)。参数列表中的可变部分用方括号括起来。例如,如果我们有一个宏指令,它扩展为 printf() 函数或调用它,并且我们希望它具有类似的声明,那么宏声明会像这样开始:
macro printf fmt, [args]
这里,fmt 代表 printf() 函数的格式参数,args 表示所有可选的参数。
让我们考虑一个非常简单的prolog宏重构例子,除了堆栈帧的大小,它还接收一个寄存器列表,这些寄存器需要在过程体内被修改,因此需要保存在栈上:
macro prolog frameSize, [regs]
{
common
push ebp
mov ebp, esp
sub esp, frameSize
forward
push regs
}
在这里,你一定注意到了common和forward关键字,它们对于宏指令展开的正确性至关重要。变参宏指令的一个有趣特点是,它的内容会针对每一个变参(用方括号指定的参数)展开。由于在每次将寄存器(由regs参数指定)压入栈之后创建堆栈帧会显得很奇怪,因此我们必须指示预处理器只展开宏指令的特定部分一次,这正是common关键字的作用。
forward关键字(及其对应的reverse关键字)指示预处理器应该按照何种顺序处理变参。push regs这一行会展开为push指令,针对regs中指定的每个参数,前置的forward关键字指示预处理器按它们写入的顺序处理参数。例如,考虑以下代码:
my_proc:
prolog 8, ebx, ecx, edx
*; body of the procedure*
这段代码会展开为以下内容:
my_proc:
push ebp
mov ebp, esp
sub esp, 8
push ebx
push ecx
push edx
为了完整性,让我们对return宏指令进行适当的修复:
macro return [regs]
{
reverse
pop regs
common
mov esp, ebp
pop ebp
ret
}
在这里,为了举例,我们使用reverse关键字,因为我们指定了应该从栈中以完全相同的顺序恢复寄存器,这些寄存器在传递给prolog宏指令时的顺序。然后,过程会像这样:
my_proc:
prolog 8, ebx, ecx, edx
*; body of the function*
return ebx, ecx, edx
调用约定简介
在编写汇编语言代码时,调用过程时最好遵循一定的调用约定(参数传递给过程的方式),因为首先,这样可以最小化烦人的且难以查找的错误的发生,当然,也能帮助你将汇编模块与高级语言链接起来。对于 Intel 架构来说,有很多种调用约定,但我们只会考虑其中一些,稍后将在本书中使用。
我们已经了解了过程,并且在上一章中提到过“调用约定”这一术语,因此你可能会想,为什么现在才介绍这一机制。答案很简单——调用过程是一个需要某些准备的过程,而这些准备在每次过程调用时都应该是相同的,因此显然可以将这些准备以宏指令的形式实现。
首先,让我们看看本章中我们将涵盖的调用约定:
cdecl(32 位)
cdecl 调用约定是 C 和 C++高级语言中的标准约定。参数存储在栈上,最右边的参数首先压入栈中,最左边的参数最后压入栈中。恢复栈是调用者的责任,调用者在控制权返回时需要恢复栈。
模拟 cdecl 调用过程的最简单宏如下:
macro ccall procName, [args]
{
common
a = 0
if ~args eq
forward
a = a + 4
reverse
push args
end if
common
call procName
if a > 0
add esp, a
end if
}
这里的 if 语句是自解释的;不过,你可以暂时忽略它们,因为它们将在本章稍后部分进行讲解。
stdcall(32 位)
stdcall 调用约定几乎与 cdecl 相同,参数以相同的方式传递到栈中——最右边的参数最先被压入栈中,最左边的参数最后被压入栈中。唯一的区别是调用者无需处理栈的清理:
macro stdcall procName, [args]
{
if ~args eq
reverse
push args
end if
common
call procName
}
让我们考虑一个同时使用两种调用约定的简单示例:
cdecl_proc:
push ebp
mov ebp, esp
*; body of the procedure*
mov esp, ebp
pop ebp,
ret
stdcall_proc:
push ebp
mov ebp, esp
*; body of the procedure*
mov esp, ebp
pop ebp
ret 8 *; Increments the stack pointer by 8 bytes after*
*; return, thus releasing the space occupied*
*; by procedure parameters*
main:
ccall cdecl_proc, 128 *; 128 is a numeric parameter passed to*
*; the procedure*
stdcall stdcall_proc, 128, 32
虽然 cdecl_proc 和 stdcall_proc 过程都很清楚,但让我们更仔细地看一下 main 过程展开后的情况:
main:
push 128
call cdecl_proc
add esp, 4
*;*
push 32
push 128
call stdcall_proc
在前面的示例中,stdcall 宏调用还展示了当有多个参数时发生的情况——最右边的参数最先被压入栈中。这种机制使得在函数内部更容易、更直观地访问参数。鉴于栈帧的性质,我们可以这样访问它们:
mov eax, [ebp + 8] *; Would load EAX with 128*
mov eax, [ebp + 12] *; Would load EAX with 32*
我们使用 EBP 寄存器作为基指针。第一个(最左边的)参数位于 EBP 存储值偏移量8的位置,因为过程的返回地址和先前压入的 EBP 寄存器值正好占用了 8 个字节。下表展示了栈帧创建后的栈内容:
| 从 EBP 偏移量 | 内容 |
|---|---|
| +12 | 最右边的参数(32) |
| +8 | 最左边的参数(128) |
| +4 | 过程返回地址 |
| EBP 指向此处 | EBP 的上一个值 |
| -4 | 第一个栈帧变量 |
| .... | 其他栈帧变量 |
| .... | 保存的寄存器 |
| ESP 指向此处 | 当前栈位置 |
Microsoft x64(64 位)
Microsoft 在 64 位模式(长模式)下使用自己的调用约定,通过混合寄存器/栈的方式传递过程参数。这意味着只有前四个参数可以通过寄存器传递,其余的(如果有的话)应该压入栈中。以下表格展示了哪些寄存器被使用以及如何使用:
| 参数索引 (从零开始) | 整数/指针 | 浮点数 |
|---|---|---|
| 0 | RCX | XMM0 |
| 1 | RDX | XMM1 |
| 2 | R8 | XMM2 |
| 3 | R9 | XMM3 |
所有这些看起来很清楚,但我们需要特别注意两点:
-
栈必须在 16 字节边界上对齐
-
栈上需要 32 字节的影像空间——32 字节用于存放最后压入栈的参数(如果有的话)和返回地址之间的空间
以下宏指令(ms64_call)是简化版的实现,它是这一调用约定的原始实现。此特定宏不支持堆栈参数:
macro ms64_call procName, [args]
{
a = 0
if ~args eq
forward
if a = 0
push rcx
mov rcx, args
else if a = 1
push rdx
mov rdx, args
else if a = 2
push r8
mov r8, args
else if a = 3
push r9
mov r9, args
else
display "This macro only supports up to 4 parameters!",10,13
exit
end if
a = a + 1
end if
common
sub rsp, 32 *; Allocate shadow space*
call procName *; Call procedure*
add rsp, 32 *; Free shadow space*
forward
if ~args eq
if a = 4
pop r9
else if a = 3
pop r8
else if a = 2
pop rdx
else if a = 1
pop rcx
end if
a = a - 1
end if
}
考虑一个调用 64 位代码中标记为 my_proc 的过程的示例,使用 Microsoft x64 调用约定:
ms64_call my_proc, 128, 32
这样的宏指令将被扩展为以下内容:
push rcx *;Save RCX register on stack*
mov rcx, 128 *;Load it with the first parameter*
push rdx *;Save RDX register on stack*
mov rdx, 32 *;Load it with the second parameter*
sub rsp, 32 *;Create 32 bytes shadow space*
call my_proc *;Call the my_proc procedure*
add rsp, 32 *;Destroy shadow space*
pop rdx *;Restore RDX register*
pop rcx *;Restore RCX register*
AMD64(64 位)
默认情况下,64 位类 Unix 系统使用 AMD64 调用约定。它的理念非常相似,只是使用了不同的寄存器集合,并且没有阴影空间的要求。另一个区别是,AMD64 调用约定允许通过寄存器传递最多 6 个整数参数和最多 8 个浮点值:
| 参数索引 (从零开始) | 整数/指针 | 浮点数 |
|---|---|---|
| 0 | RDI | XMM0 |
| 1 | RSI | XMM1 |
| 2 | RDX | XMM2 |
| 3 | RCX | XMM3 |
| 4 | R8 | XMM4 |
| 5 | R9 | XMM5 |
| 6 | 在堆栈上 | XMM6 |
| 7 | 在堆栈上 | XMM7 |
以下宏指令是这种机制的原始实现。就像在微软 x64 示例中一样,这个实现也不处理堆栈参数:
macro amd64_call procName, [args]
{
a = 0
if ~args eq
forward
if a = 0
push rdi
mov rdi, args
else if a = 1
push rsi
mov rsi, args
else if a = 2
push rdx
mov rdx, args
else if a = 3
push rcx
mov rcx, args
else if a = 4
push r8
mov r8, args
else if a = 5
push r9
mov r9, args
else
display "This macro only supports up to 4 parameters", 10, 13
exit
end if
a = a + 1
end if
common
call procName
forward
if ~args eq
if a = 6
pop r9
else if a = 5
pop r8
else if a = 4
pop rcx
else if a = 3
pop rdx
else if a = 2
pop rsi
else if a = 1
pop rdi
end if
a = a - 1
end if
}
使用这样的宏,在面向类 Unix 系统的 64 位代码中调用过程 my_proc,例如:
amd64_call my_proc, 128, 32
将其扩展为:
push rdi *;Store RDI register on stack*
mov rdi, 128 *;Load it with the first parameter*
push rsi *;Store RSI register on stack*
mov rsi, 32 *;Load it with the second parameter*
call my_proc *;Call the my_proc procedure*
pop rsi *;Restore RSI register*
pop rdi *;Restore RDI register*
关于 Flat Assembler 宏功能的说明
Flat Assembler 相对于其他汇编器在英特尔平台上的一个巨大优势是其宏引擎。除了能够执行其原始任务——用宏指令的定义替换宏指令——它还能够执行相对复杂的计算,我敢称之为一种额外的编程语言。前面的示例仅仅展示了 FASM 宏处理器能力的极小一部分。虽然我们只用了 if 条件语句和一个变量,但在必要情况下,我们可以使用循环(while 或 repeat 语句)。例如,假设你有一串字符需要保持加密状态:
my_string db 'This string will be encrypted',0x0d, 0x0a, 0x00
my_string_len = $ - my_string
在这里,my_string_len 是字符串的长度。
$ 是一个特殊符号,表示当前地址。因此,$-my_string 表示当前地址减去 my_string 的地址,这就是字符串的长度。
可以通过一个简单的四行宏实现简化的 XOR 加密:
repeat my_string_len
load b byte from my_string + % - 1
store byte b xor 0x5a at my_string + % - 1
end repeat
这里的 % 符号表示当前的迭代,而 -1 的值是必需的,因为迭代计数从 1 开始。
这是 FASM 宏引擎能够执行的一个简短且原始的示例,实际上它的功能远不止此。然而,尽管本书主要使用 FASM 作为汇编语言,但它专注于英特尔汇编语言,而非特定方言,因此这些额外的信息超出了本书的范围。我强烈建议您参考FASM 文档。
MASM 和 GAS 中的宏指令
尽管宏指令机制背后的核心思想在所有汇编器中都是相同的,但宏指令的语法和引擎的功能有所不同。以下是 MASM 和 GAS 的两个简单宏示例。
Microsoft Macro Assembler
记得我们在第二章中的测试程序,*设置开发
环境*? 我们可以用以下宏指令替换调用show_message过程的代码:
MSHOW_MESSAGE MACRO title, message ;macro_name MACRO parameters
push message
push title
call show_message
ENDM
这可能使代码更具可读性,因为我们可以通过以下方式调用show_message过程:
MSHOW_MESSAGE offset ti, offset msg
GNU 汇编器
GNU 汇编器的宏引擎与微软 MASM 的宏引擎非常相似,但有一些语法差异(不考虑整体语法差异)是我们需要注意的。我们以第二章中的 Linux 测试程序中的output_message过程为例,*设置开发
环境*,并将printf()调用替换为一个简单的宏来演示。
.macro print message *; .macro macro_name parameter*
pushl \message *; Put the parameter on stack*
*; parameters are prefixed with '\'*
call printf *; Call printf() library function*
add $4, %esp *; Restore stack after cdecl function call*
.endm
output_message:
pushl %ebp
movl %esp, %ebp
print 8(%ebp) *; This line would expand to the above macro*
movl $0, %eax
leave
ret $4
其他汇编指令(FASM 特定)
到目前为止,我们大多认为宏指令是一种替代过程调用的方式,尽管我认为更准确的说法是它们是简化代码编写和维护的便捷工具。在本章的这一部分,我们将看到一些所谓的内置宏指令——汇编指令——它们大致可以分为三类:
-
条件汇编
-
重复指令
-
包含指令
根据汇编器的实现,可能还会有其他类别。你应该参考你正在使用的汇编器的文档,获取更多信息。
条件汇编
有时我们可能希望宏指令或代码片段根据特定条件进行不同的汇编。MASM 和 GAS 也提供了这一功能,但让我们回到 FASM(作为最方便的选择),考虑以下宏指令:
macro exordd p1, p2
{
if ~p1 in <eax, ebx, ecx, edx, esi, edi, ebp, esp> &\
~p2 in <eax, ebx, ecx, edx, esi, edi, ebp, esp>
push eax
mov eax, [p2]
xor [p1], eax
pop eax
else
if ~p1 in <eax, ebx, ecx, edx, esi, edi, ebp, esp>
xor [p1], p2
else if ~p2 in <eax, ebx, ecx, edx, esi, edi, ebp, esp>
xor p1, [p2]
else
xor p1, p2
end if
end if
}
起初看起来可能有点复杂,但宏的目的其实很简单。我们扩展了一个 XOR 指令,以便可以指定两个内存位置作为操作数,这是原始指令无法做到的。为了简化,我们只对双字值进行操作。
开始时,我们检查两个参数是否都是内存位置的标签,如果是,我们从其中一个加载值到寄存器,并执行 XOR 操作,就像第一个操作数是内存位置,第二个操作数是寄存器时一样。
如果此条件不为真,我们将进入宏指令的第二部分,根据第一个操作数是内存位置还是第二个操作数,或者它们是否都是通用寄存器,执行适当的 XOR 操作。
作为一个例子,假设我们有两个变量,分别为my_var1和my_var2,它们的值分别是0xCAFECAFE和0x02010201,并通过异或交换它们:
exordd my_var1, my_var2 *; a = a xor b*
mov ebx, [my_var2]
exordd ebx, my_var1 *; b = b xor a*
mov [my_var2], ebx
exordd my_var1, ebx *; a = a xor b*
exordd ebx, ebx *; Reset EBX register for extra fun*
一旦处理完成,上述代码将扩展为:
push eax *; exordd my_var1, my_var2*
mov eax, [my_var2]
xor [my_var1], eax
pop eax
mov ebx, [my_var2]
xor ebx, [my_var1] *; exordd ebx, my_var1*
mov [my_var2], ebx
xor [my_var1], ebx *; exordd [my_var1], ebx*
xor ebx, ebx *; exordd ebx, ebx*
如我们所见,exordd宏指令的展开方式取决于它的参数。
重复指令
有时可能需要重复相同的代码块,可能只会有些微的差异,甚至没有任何差异。汇编器有一些指令(有时称为内建宏指令),可以精确实现这一点。所有三种汇编器——FASM、MASM 和 GAS——都有三种常见的此类指令:
rept count:rept指令后跟count参数,简单地复制count次代码块中的内容。对于 Flat Assembler,我们可以声明第二个参数,它将等于当前迭代次数(从 1 开始)。例如,以下代码:
hex_chars:
rept 10 cnt {db '0' + cnt - 1}
rept 6 cnt {db 'A' + cnt - 1}
这将生成一个名为hex_chars的十六进制字符数组,等同于:
hex_chars db "0123456789ABCDEF"
irp arg, a, b, c, ...:irp指令后跟一个参数和一系列参数列表。参数(此处为arg)在每次迭代时代表一个单独的参数。例如,以下代码:
irp reg, eax, ebx, ecx {inc reg}
按顺序递增寄存器 EAX、EBX,然后是 ECX。
**irps arg, a b c ...**:irps指令与irp相同,区别在于参数列表中不使用逗号分隔。
包含指令
在前几章中,我们几乎没有触及的两条指令,看起来非常有用。这些指令是:
-
include 'filename' -
file 'filename'
包含指令
include指令的语法非常简单。它由指令本身后跟一个带引号的源文件名,表示我们要包含的文件。从逻辑上讲,它的操作类似于 C 或 C++中的#include关键字。在汇编编程中,事情并不总是那么简单,分割代码到多个源文件是一个很好的主意(例如,将所有宏指令定义放到一个单独的文件中),然后通过包含将它们组合到主源代码中。
文件指令
尽管在语法上,include和file指令是相似的,且都可以将一个文件包含到源代码处理当中,但在逻辑上它们非常不同。与include指令不同,file指令不会对被包含的文件进行任何处理。这使得将二进制数据包含到数据段或其他需要的地方成为可能。
概要
在本章中,我们简要介绍了汇编语言编程中宏指令的众多功能。不幸的是,可能需要一本完整的书籍来讨论宏指令的所有应用,尤其是当涉及到 Flat Assembler 时,它具有一个非常强大的预处理器。
一个来自我自身实践的例子:我曾经需要实现一个经过高度混淆的 AES128 解密算法版本,总共写了 2175 行,只有少数几个程序,而其中几乎一半(1064 行)被不同宏指令的定义所占据。正如你可以合理推测的那样,约 30%到 60%的每个程序都包含了宏指令的调用。
在下一章,我们将继续深入探讨预处理器,并处理不同的数据结构,以及其创建和管理方法。
第七章:数据结构
正如本书中已经多次提到的,汇编语言是关于对数据进行移动和执行某些基本操作,汇编编程是关于知道该将数据移动到哪里,并在此过程中对其应用哪些操作。到目前为止,我们主要集中在对不同类型数据执行的操作上,现在是时候讨论数据本身了。
在基于 Intel 架构的处理器中,最小的数据单元是比特,而最小的可寻址单元是字节(在 Intel 架构中是 8 位)。我们已经知道如何处理这样的数据,甚至是字、双字和单精度浮点值。然而,数据可能比这些更复杂,我指的不是四字、双精度浮点数等。
在本章中,我们将学习如何声明、定义和操作简单以及复杂的数据结构,以及这如何使我们作为汇编开发者的工作变得更加轻松。从简单的数据结构(如数组)开始,我们将逐步探讨包含不同数据类型的更复杂结构,并逐步过渡到链表和树形结构,最终介绍更复杂、更强大的数据排列方法。鉴于你作为开发者已经熟悉不同的数据结构,本章的目的是展示在汇编中使用它们的简便性,特别是使用 FASM 这款功能强大的汇编器。
本章中将讨论以下数据结构(数据排列方案):
-
数组
-
结构体
-
结构体数组
-
链表及其特殊情况
-
二叉搜索树及其平衡
-
稀疏矩阵
-
图
数组
到目前为止,我们已经走了很长一段路,主要处理从字节到四字的基本数据类型,为更复杂的数据相关概念做好准备。接下来我们将深入探讨数组,数组可以被视为相同类型数据的顺序存储。从理论上讲,数组成员的大小没有限制,但实际上我们受到诸如寄存器大小的限制。然而,存在一些变通方法,我们将在本章稍后看到。
简单字节数组
一个广泛使用且简单的数组的例子是 AES 算法中使用的正向替代表和/或反向替代表:
aes_sbox:
db 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5
db 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76
db 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0
db 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0
db 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc
db 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15
db 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a
db 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75
db 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0
db 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84
db 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b
db 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf
db 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85
db 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8
db 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5
db 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2
db 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17
db 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73
db 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88
db 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb
db 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c
db 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79
db 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9
db 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08
db 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6
db 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a
db 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e
db 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e
db 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94
db 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf
db 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68
db 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
正如我们可以清楚看到的,所有值的大小都是 1 字节,并且是按顺序一个接一个地存储。访问这样的数组非常简单,甚至可以通过 XLAT 指令完成。例如,假设我们正在进行 AES-128 计算,并且需要将每个字节替换为前面表格中的字节。假设以下是该值:
needs_substitution db 0, 1, 2, 3, 4, 5, 6, 7\
8, 9, 10, 11, 12, 13, 14, 15
以下代码将执行替代操作:
lea ebx, [aes_sbox]
lea esi, [needs_substitution] *; Set the source pointer (ESI) and*
mov edi, esi *; destination pointer (EDI) as we*
*; will be storing substituted*
; byte back
mov ecx, 0x10 *; Set the counter*
@@:
lodsb *; Load byte from the value*
xlatb *; Substitute byte from the s-box*
stosb *; Store new byte to the value*
loop @b *; Loop while ECX != 1*
我们做的第一件事是将表的基地址(S-box 的地址)加载到 EBX 寄存器中,因为 XLAT 指令正是使用这个寄存器来寻址替代/查找表。然后,我们加载需要的数组地址。
将数据替换到 ESI 寄存器中,以避免计算索引,因为 ESI 寄存器会被 lodsb 指令自动递增。将地址复制到 EDI 寄存器中,因为我们将把数据存回。
你也可以通过从最后一个字节到第一个字节处理 16 字节的值,加载 ESI 和 EDI 寄存器为 lea esi, [needs_substitution + 0x0f],将地址复制到 EDI,并使用 std 指令设置方向标志。完成后,别忘了使用 cld 指令清除方向标志。
然后,我们顺序读取值的每个字节,用 XLAT 指令将其替换为来自 S-box 的字节,并将结果存回。作为 XLAT 指令的替代方案(XLAT 指令限制为 256 字节的表,并且只能在 AL 寄存器依赖的字节值上操作),我们可以写出以下内容:
mov al, [aes_sbox + eax] *; aes_sbox is the base and EAX is the index*
然而,我们需要在进入循环之前将整个 EAX 寄存器置为 0,而 XLAT 允许 EAX 寄存器的高 24 位在整个操作过程中保持不变。
字数组、双字数组和四字数组
之前的简单示例展示了一个简单的字节数组,以及如何访问其成员。对于字数组、双字数组或四字数组,只需要做一些扩展,方法是:
-
我们不能在大于 256 字节的数组上使用 XLAT,也不能在数组成员大于 8 位时使用 XLAT。
-
我们需要使用 SIB 寻址(比例索引基址)来访问大于一个字节的数组成员。
-
在 32 位系统上,我们无法将一个四字节值读入单个寄存器。
为了简单起见,我们考虑使用一个查找表来计算 0 到 12 范围内数字的阶乘(此代码适用于 32 位,较大的数字的阶乘无法适应双字)。虽然阶乘计算的算法相当简单,但即使是如此短的范围,使用查找表要方便得多。
首先,将以下内容放入数据段(你也可以将其放入代码段,因为我们这里不会更改任何值,但让我们把数据与数据放在一起):
ftable dd 1,\ *; 0!*
1,\ *; 1!*
2,\ *; 2!*
6,\ *; 3!*
24,\ *; 4!*
120,\ *; 5!*
720,\ *; 6!*
5040,\ *; 7!*
40320,\ *; 8!*
362880,\ *; 9!*
3628800,\ *; 10!*
39916800,\ *; 11!*
479001600 *; 12!*
这是我们的查找表,包含了 13 个阶乘值,范围从 0 到 12,每个条目是双字(32 位)。现在,让我们编写一个过程来使用这个表。这个过程将按照stdcall调用约定实现;它接收一个参数,即我们需要计算阶乘的数字,并返回该数字的阶乘值,如果数字不在允许的范围内,则返回 0(因为 0 不能是阶乘的值)。将以下代码放入代码段:
factorial:
push ebp
mov ebp, esp
;-------------
virtual at ebp + 8 *; Assign a readable name to*
arg0 dd ? *; a location on stack where*
end virtual *; the parameter is stored*
;-------------
mov eax, [arg0] *; Load parameter from the stack*
cmp eax, 0x0c *; Check whether it is in range*
ja .oops *; Go there if not*
mov eax, [ftable + eax * 4] *; Retrieve factorial from*
*; the lookup table*
@@:
leave
ret 4
.oops:
xor eax, eax *; Set return value to 0*
jmp @b
virtual 指令允许我们在特定地址虚拟定义数据。在前面的例子中,我们定义了一个指向存储参数的堆栈位置的变量。在virtual块内定义的所有内容都被汇编器视为合法标签。在这种情况下,arg0 转换为 ebp + 8。如果我们有两个甚至更多通过堆栈传递给过程的参数,我们可以这样写:
virtual at ebp + 8
arg0 dd ?
arg1 dd ?
; 其余部分
end virtual
在这里,arg1 会被转换为 ebp+12,arg2(如果定义了的话)为 ebp+16,以此类推。
这个过程确实非常简单,它做的就是这个:
-
检查参数是否适合范围
-
如果参数不符合范围,则返回
0 -
使用参数作为查找表中的索引,并返回由表的基地址加上索引(我们的参数)乘以表中条目的大小所引用的值
结构体
作为开发者,我相信你会同意,大多数时候我们处理的不是统一数据的数组(我绝对不是低估常规数组的强大)。由于数据可以是任何东西,从 8 位数字到复杂结构体,我们需要一种方式来为汇编器描述这些数据,而“结构体”这一术语就是关键。Flat Assembler 和其他任何汇编器一样,允许我们声明结构体,并将其作为额外的数据类型(类似于 C 语言中的typedef结构)来使用。
让我们声明一个简单的结构体,即字符串表的一个条目,然后看看它是什么:
struc strtabentry [s]
{
.length dw .pad - .string *; Length of the string*
.string db s, 0 *; Bytes of the string*
.pad rb 30 - (.pad - .string) *; Padding to fill 30 bytes*
.size = $ - .length *; Size of the structure (valid*
*; in compile time only)*
}
结构体成员名前面的点符号 (.) 表示它们是更大命名空间的一部分。在这个具体的例子中,*.length*属于 strtabentry。
这样的声明在 C 语言中相当于以下声明:
typedef struct
{
short length;
char string[30];
}strtabentry;
然而,在 C 语言中,我们必须初始化类型为strtabentry的变量,如下所示:
*/* GCC (C99) */*
strtabentry my_strtab_entry = {.length = sizeof("Hello!"), .string = {"Hello!"} };
*/* MSVC */*
strtabentry my_strtab_entry = {sizeof("Hello!"), {"Hello!"} };
在汇编语言中,或者更准确地说,在使用 Flat Assembler 时,我们会以更简单的方式初始化这样的变量:
my_strtab_entry strtabentry "Hello!"
无论哪种方式,结构体的大小都是 32 字节(因为字符串缓冲区是静态分配的,大小为 30 字节),并且只有两个成员:
-
length:这是包含字符串长度的字长整数,加 1 以包含 null 终止符 -
string:这是实际的文本
访问结构体成员
关于如何访问结构体的各个成员,需要做一些说明。当结构体是静态分配时,我们可以通过它的标签/名称来引用结构体,这个标签会被转换成结构体的地址。例如,如果我们在数据段中定义了一个名为se的strtabentry结构,并且我们需要从字符串中读取第n个字节,那么我们所需要做的就是:
mov al, [se.string + n] *; 0 <= n < 30*
另一方面,如果我们不能使用标签(例如,在一个过程内,而结构体的指针是其参数),那么我们可以使用强大的virtual指令。作为快速演示,这里是一个返回字符串长度的过程,不包括终止的零字符:
get_string_length:
push ebp
mov ebp, esp
push ebx
*;=========*
virtual at ebp + 8 *; Give a name to the parameter on stack*
.structPtr dd 0 *; The parameter itself is not modified*
end virtual
*;---------*
virtual at ebx *; Give local name to the structure*
.s strtabentry 0 *; The structure is not really defined*
end virtual *; so the string is not modified*
*;=========*
mov ebx, [.structPtr] *; Load structure pointer to EBX*
mov ax, [.s.length] *; Load AX with the length*
movzx eax, ax * ; Upper 16 bits may still contain garbage*
*; so we need to clean it*
*dec eax ; Exclude null terminator*
pop ebx
leave
ret 4
为了帮助记忆,我们再看一下从栈中读取指针以及将字符串长度加载到 AX 寄存器的那几行。第一行如下所示:
mov ebx, [.structPtr]
前面的代码从栈中加载参数。正如我们所记得的,声明一个虚拟标签可以让我们为那些无法通过其他方式命名的内存位置赋予可读名称,而栈就是一个例子。在这个特定情况下,.structPtr 转换为 ebp + 8,因此该行代码等价于以下内容:
mov ebx,[ebp + 8]
同样,如果有第二个参数,虚拟声明将如下所示:
virtual at ebp + 8
.structPtr dd 0
.secondParam dd 0
end virtual
在这种情况下,读取第二个参数将如下所示:
mov ecx, [.secondParam]
另外,它将转化为以下内容:
mov ecx, [ebp + 12] *; Which is ebp + 8 + sizeof(.structPtr)*
这是我们感兴趣的第二行:
mov ax, [.s.length]
在这个特定情况下,我们正在访问结构体的第一个成员—.length,这意味着该行代码可以转换为以下内容:
mov ax, [ebx]
然而,如果我们需要访问字符串本身,例如,如果我们需要加载一个寄存器以获取字符串的地址,代码将如下所示:
lea eax, [.s.string]
这将转化为以下形式:
lea eax, [ebx + 2]
结构体数组
到现在为止,我们在访问结构体及其成员方面已经没有问题了,但如果我们有多个相同类型的结构体该怎么办?我们自然会将它们组织成一个结构体数组。看起来很简单,部分而言,确实是的。
为了简化访问数组成员的过程,我们可以使用指针数组,并通过某种查找表访问数组中的每个结构体。在这种情况下,我们只需使用以下方式从查找表中读取指针:
mov ebx, [lookup_table + ecx * 4] *; ECX contains the index into array*
*; of pointers and 4 is the scale (size*
*; of pointer on 32-bit systems)*
拥有指向感兴趣结构体的指针后,我们照常继续工作。
我们的示例结构非常方便,因为它的大小仅为 32 字节。如果我们将许多此类结构排列成一个数组,我们将能够轻松地访问一个包含 134,217,727 个成员的数组(在 32 位系统上),该数组占用 4GB 的内存。虽然我们几乎不会需要这么多最大长度为 30 字节的字符串(或者根本不需要这么多字符串),但在这种特定情况下,地址计算非常简单(再次强调,得益于结构体的舒适大小)。我们仍然使用结构体数组中的索引,但是由于不能利用 SIB 寻址的比例部分将索引按 32 字节进行缩放,我们需要在访问数组之前先对索引进行乘法运算。
让我们定义一个宏指令,用来首先创建这样的数组(同时构建指针查找表以供演示):
macro make_strtab strtabName, [strings]
{
common
label strtabName#_ptr dword *; The # operator concatenates strings*
local c *; resulting in strtabName_ptr*
c = 0
forward
c = c + 1 *; Count number of structures*
common
dd c *; Prepend the array of pointers with*
*; number of entries*
forward *; Build the pointer table*
local a
dd a
common *; Build the array of structures*
label strtabName dword
forward
a strtabentry strings
}
前面宏的调用,使用以下参数,形式如下:
make_strtab strtabName,\ *; Spaces are intentionally appended to*
"string 0",\ *; strings in order to provide us with*
"string 1 ",\ *; different lengths.*
"string 2 ",\
"string 3 "
这将导致内存中数据的以下排列:
如你所见,strtabName_ptr 变量包含数组中的结构体/指针数量,后面跟着四个指针的数组。接下来,在 strtabName 处(我们可以在调用宏时选择任何符合命名规则的名称),我们有了四个结构体的实际数组。
现在,如果我们需要检索结构体中索引为 2 的字符串长度(索引从 0 开始),我们将修改 get_string_length 程序,使其接受两个参数(结构体数组指针和索引),如下所示:
get_string_length:
push ebp,
mov ebp, esp
push ebx ecx
virtual at ebp + 8
.structPtr dd ? *; Assign label to first parameter*
.structIdx dd ? *; Assign label to second parameter*
end virtual
virtual at ebx + ecx
.s strtabentry ? *; Assign label to structure pointer*
end virtual
mov ebx, [.structPtr] *; Load pointer to array of structures*
mov ecx, [.structIdx] *; Load index of the structure of interest*
shl ecx, 5 *; Multiply index by 32*
mov ax, [.s.length] *; Read the length*
movzx eax, ax
dec eax
pop ecx ebx
leave
ret 8
程序调用将如下所示:
push 2 *; push index on stack*
push strtabName *; push the address of the array*
call get_string_length
指向结构体的指针数组
前一小节向我们展示了如何处理均匀结构体的数组。由于没有特别的理由需要固定大小的字符串缓冲区,因此也没有必要使用固定大小的结构体。首先,我们需要对结构体声明做一点小修正:
struc strtabentry [s]
{
.length dw .pad - .string *; Length of the string*
.string db s, 0 *; Bytes of the string*
.size = $ - .length *; Size of the structure (valid*
*; in compile time only)*
}
我们只移除了 strtabentry 结构体中的 .pad 成员,使其可以具有可变大小。显然,我们不能再使用相同的 get_string_length 程序,因为我们没有固定的步长来遍历数组。但你可能已经注意到前面图像中的 strtabName_ptr 结构。这个结构就是用来帮助我们解决没有固定步长的问题的。我们可以重写 get_string_length 程序,使它接受一个指向结构体数组指针的指针,而不是直接接受数组指针和目标结构体的索引。修改后的程序如下所示:
get_string_length:
push ebp,
mov ebp, esp
push ebx ecx
virtual at ebp + 8
.structPPtr dd ? *; Assign label to first parameter*
.structIdx dd ? *; Assign label to second parameter*
end virtual
virtual at ebx
.s strtabentry ? *; Assign label to structure pointer*
end virtual
mov ebx, [.structPPtr] *; Load pointer to array of structures*
mov ecx, [.structIdx] *; Load index of the structure of interest*
shl ecx, 2 *; Multiply index by 4 (size of pointer
* *; on a 32-bit platform
* cmp ecx, [.structPPtr] *; Check the index to fit the size of the
* *; array of pointers
* jae .idx_too_big *; Return error if* index exceeds the bounds
mov ebx, [ebx + ecx + 4]*; We have to add 4 (the size of int), in
* *; order to skip the number of structure
* *; pointers in the array*
mov ax, [.s.length] *; Read the length*
movzx eax, ax
.return:
dec eax
pop ecx ebx
leave
ret 8
.idx_too_big:
xor eax, eax *; The value of EAX would be -1 upon return*
jmp .return
完成!我们只需要做一些小的修改,添加这一行,再加上一行,现在我们就能够处理具有可变大小的结构体了。
到目前为止,内容并不复杂,接下来的内容也不难理解。虽然数据类型不多,但它的排列方式却有很多。结构体可以被视为一种数据类型,也可以看作是非均匀数据的排列方法,但为了方便起见,我们将其视为一个可以自由定义的数据类型。到现在为止,我们已经看到了当数据排列在静态内存中且排列不变时的情况,但是如果我们正在处理动态数据,而数据量在编写代码时无法确定该怎么办呢?在这种情况下,我们需要知道如何处理动态数据。这就引出了数据排列的下一个阶段——链表及其类型。
链表
链表顾名思义,由通过指针相互连接的数据项(节点)组成。基本上,链表有两种类型:
-
链表:每个节点都有指向下一个节点的指针
-
双向链表:每个节点有指向下一个节点和前一个节点的指针
以下图表展示了两者之间的区别:
两种类型的链表都可以通过几种方式进行寻址。显然,链表中至少有一个指向第一个节点的指针(称为top),可选地伴随有一个指向链表最后一个节点的指针(称为tail)。当然,若有需要,还可以添加多个辅助指针。节点中的指针字段通常称为next和previous。正如我们在图示中看到的,链表的最后一个节点以及双向链表中的第一个和最后一个节点都有next、previous和next字段,这些字段不指向任何地方——这样的指针被视为终止符,表示链表的结束,并且通常会填充null值。
在继续示例代码之前,让我们对本章使用的结构体做一个小改动,添加next和previous指针。结构体应该如下所示:
struc strtabentry [s]
{
.length dw .pad - .string
.string db s, 0
.pad rb 30 - (.pad - .string)
.previous dd ? *; Pointer to the next node*
.next dd ? *; Pointer to the previous node*
.size = $ - .length
}
我们将保留make_strtab宏不变,因为我们仍然需要一些东西来构建strtabentry结构体的集合;然而,我们将不再把它视为结构体数组。同时,我们将添加一个变量(类型为双字)来存储top指针。我们把它命名为list_top。
我们将不再编写一个宏指令来将四个结构体连接成一个双向链表,而是编写一个过程来向列表中添加新节点。这个过程需要两个参数——指向list_top变量的指针和指向我们想要添加到列表中的结构体的指针。如果我们是在 C 语言中编写,则对应函数的原型如下:
void add_node(strtabentry** top, strtabentry* node);
然而,由于我们并非在编写 C 语言,我们将写下以下代码:
add_node:
push ebp
mov ebp, esp
push eax ebx ecx
virtual at ebp + 8
.topPtr dd ?
.nodePtr dd ?
end virtual
virtual at ebx
.scx strtabentry ?
end virtual
virtual at ecx
.sbx strtabentry ?
end virtual
mov eax, [.topPtr] *; Load pointer to list_top*
mov ebx, [.nodePtr] *; Load pointer to new structure*
or dword [eax], 0 *; Check whether list_top == NULL*
jz @f *; Simply store the structure pointer*
*; to list_top if true*
mov ecx, [eax] *; Load ECX with pointer to current top*
mov [.scx.next], ecx *; node->next = top*
mov [.sbx.previous], ebx *; top->previous = node*
@@:
mov [eax], ebx *; top = node*
pop ecx ebx eax
leave
ret 8
现在,过程已经准备好,我们将从主过程调用它:
_start:
push strtabName + 40 *; Let the second structure be the first*
push list_top *; in the list*
call add_node
push strtabName + 120 *; Then we add fourth structure*
push list_top
call add_node
push strtabName + 80 *; Then third*
push list_top
call add_node
push strtabName *; And first*
push list_top
call add_node
第一、第二、第三和第四个指的是结构体在内存中的位置,而不是双向链表中节点的位置。因此,在执行前面代码的最后一行后,我们得到一个由strtabentry结构体组成的双向链表(通过其在链表中的位置显示){0, 2, 3, 1}。让我们通过以下截图来看一下结果的演示:
为了方便起见,结构体按其在内存中出现的顺序命名为struct_0、struct_1、struct_2和struct_3。最后一行是top指针list_top。如我们所见,它指向struct_0,这是我们最后添加到列表中的结构体,而struct_0反过来只包含一个指向下一个结构体的指针,同时它的previous指针的值为NULL。struct_0结构体的next指针指向struct_2,struct_2结构体的next指针指向struct_3,而previous指针则以相反顺序引导我们返回。
显然,链表(单向链表,无论是前向还是后向)比双向链表要简单一些,因为我们只需要处理节点中的单个指针成员。实现一个描述链表节点(无论是简单链表还是双向链表)的单独结构,并为创建/填充链表、查找节点和删除节点编写一套过程,可能是个好主意。以下结构就足够了:
*; Structure for a simple linked list node*
struc list_node32
{
.next dd ? *; Pointer to the next node*
.data dd ? *; Pointer to data object, which*
*; may be anything. In case data fits*
*; in 32 bits, the .data member itself*
*; may be used for storing the data.*
}
*; Structure for a doubly linked list node*
struc dllist_node32
{
.next dd ?
.previous dd ? *; Pointer to the previous node*
.data dd ?
}
如果你在编写长模式(64 位)的代码,那么唯一需要做的改变是将dd(表示 32 位双字)替换为dq(表示 64 位四字),以便能够存储长模式指针。
除此之外,你可能还想或需要实现一个描述整个链表的结构,拥有所有必要的指针、计数器等(在我们的示例中,它是list_top变量;虽然不是严格意义上的结构体,但它完成了任务)。然而,谈到链表数组时,使用指向链表的指针数组会更方便,因为这将使访问数组中的成员更加容易,从而使代码更少出错、更简单和更快速。
链表的特殊情况
除非你是自学成才的开发者,否则你很可能已经在编程课上听过很多除了数组和链表之外的不同数据结构,在这种情况下,你可能仍然听说过或读过这些内容。这里所指的不同数据结构是堆栈、队列、双端队列和优先队列。然而,作为奥卡姆剃刀原则的拥护者,我相信我们应该面对现实,承认所有这些都只是链表的特殊情况,除非它们的实现是基于数组的(在某些情况下这也可能是可行的)。
堆栈
堆栈是LIFO(后进先出)的数据排列方式。最简单的例子是进程/线程堆栈。尽管这种实现方式主要基于数组,但它很好地展示了这一机制。
然而,大多数时候,我们无法提前知道所需堆栈的大小,可能只能做一个大致估算。更不用说我们几乎不需要只存储双字或四字;我们大多数时候会有更复杂的结构。堆栈的最常见实现是一个仅由top指针管理的单向链表。理想情况下,堆栈上只允许进行三种操作:
-
push:用于向列表中添加一个新成员 -
top:用于查看/读取列表中最后添加的成员 -
pop:用于移除列表中最后添加的成员
虽然push和pop操作类似于在单向链表中添加和删除成员,但TOP操作基本上是获取top指针的值,从而访问链表中最上面的(最后添加的)成员。
队列与双端队列
队列正如名称所示,是一组元素的队列。链表通过两个指针进行访问——一个指向top元素,另一个指向tail元素。就本质而言,队列是FIFO(先进先出)的数据排列方式,这意味着最先入队的元素也会最先出队。队列的开始和结束完全由你决定——top是队列的开始还是结束,tail也是一样。如果我们希望将本章中使用的链表示例转换为队列,只需要添加一个list_tail指针。
双端队列是双向队列,这意味着元素可以根据算法从top元素或tail元素推入队列。同样地,弹出元素时也是如此。
优先队列
优先队列是常规队列的一种特例。唯一的区别是,加入其中的元素每个都有一定的优先级,这由算法定义,并根据需求来确定。其思想是,优先级高的元素先被服务,然后是优先级低的元素。如果两个元素具有相同的优先级,那么它们被服务的顺序是根据它们在队列中的位置来决定的,因此至少有两种可能的方式来实现这种排列。
一种实现方式是排序算法,它会根据元素的优先级来添加新元素。这仅仅是将双端队列转换为一个排序列表。
另一个方法是通过双端队列来寻找具有最高优先级的元素并优先服务它们,这使得双端队列与链表没有太大区别。唯一的区别,可能是元素只能被添加到top元素或tail元素。
循环链表
循环链表可能是仅次于单链表最容易实现的。两者之间的唯一区别是,链表的最后一个元素指向链表的第一个元素,而不是其next指针指向NULL。
链表特殊情况总结
正如我们所见,之前提到的链表的特殊情况实际上只是同一思想的不同逻辑范式。在汇编语言的情况下尤其如此,与更高级的语言(如 C 语言以上)不同,汇编语言没有内置的实现这些方法,因此它发挥了奥卡姆剃刀的作用,剔除了多余的概念,展示了低级现实中的事物。
然而,我们需要考虑阿尔伯特·爱因斯坦说过的话:
“一切事物应尽可能简单,但不能更简单。”
在将链表及其特殊情况尽可能简化后,我们需要继续处理更复杂、更强大的数据排列形式。在本章的下一节中,我们将介绍树——一种非常强大且有用的数据存储方法。
树
有时,我们已经覆盖的数据排列方案并不适合解决某些问题。例如,当处理一组经常被搜索或修改的数据,并且需要保持排序时,我们可以将它们放入数组或有序链表中,但搜索时间可能不理想。在这种情况下,最好将数据安排成树的形式。例如,二叉搜索树就是在搜索动态(变化的)数据时,最小化搜索时间的最佳方式。实际上,这同样适用于静态数据。
首先,什么是计算机中的树结构?谈到树结构时,人们可能会想到一种特殊类型的图(图将在本章后面简要介绍),它由一些节点组成,每个节点都有一个父节点(根节点除外,根节点通常称为“根节点”),并且可能有零个或多个子节点。在汇编语言中,我们可以像这样声明树节点的结构:
struc tnode dataPtr, leftChild, rightChild
{
.left dd leftChild *; Pointer to left node or 0*
.right dd rightChild *; Pointer to right node or 0*
.data dd dataPtr *; Pointer to data*
}
所以,我们有一个结构,它包含指向左子节点的指针(传统上,左子节点的值较小),指向右子节点的指针(传统上,右子节点的值较大),以及指向节点表示的数据的指针。通常来说,添加指向父节点的指针并不是一个坏主意,这有助于平衡树结构;然而,在本章接下来的例子中,我们并不需要这个指针。上面的节点结构就足够用于构建这样的树结构:
这张图展示了一个理想的平衡二叉搜索树的情况。然而,在实际情况中,这并不常见,而且取决于平衡方法。不幸的是,树的平衡方法稍微超出了本书的范围。不过,主要的思路是将较小的值放在左边,将较大的值放在右边,这通常涉及对子树,甚至是整个树,应用一定的旋转操作。
一个实际的例子
够了,别再讲枯燥的解释了。作为开发者,你很可能已经熟悉了树状结构及其平衡方法,或者至少听说过这些方法。相信通过实例学习是理解事物最有效的方式之一,我建议我们看一下下面的例子。
示例——简单的加密虚拟机
这个例子的思路广泛应用并且非常著名——一个简单的,不得不说是原始的,虚拟机。假设我们需要实现一个虚拟机,用一个单字节的密钥,通过异或操作来执行简单的字符串加密。
虚拟机架构
虚拟处理器的架构相当简单——它有几个寄存器,用于存储当前的执行状态:
| 寄存器名称 | 寄存器功能 |
|---|---|
register_a | 一个 8 位通用寄存器。该寄存器可以被虚拟机代码访问。 |
register_b | 一个 8 位通用寄存器,该寄存器可以被虚拟机代码访问。 |
register_key | 一个 8 位寄存器,存储加密密钥字节。 |
register_cnt | 一个 8 位寄存器,存储vm_loop指令的计数器。该寄存器可以被虚拟机代码访问。 |
data_base | 一个 32 位寄存器(长模式下为 64 位寄存器)。存储要加密数据的地址。 |
data_length | 一个 32 位寄存器,存储要加密数据的长度(仅使用 8 位,因此数据不能超过 256 字节)。 |
虚拟处理器的指令集非常有限,但它们并不是按顺序编码的:
| 操作码 | 助记符 | 含义 |
|---|---|---|
| 0x00 | vm_load_key | 将虚拟机过程的key参数加载到虚拟处理器的key寄存器中。 |
| 0x01 | vm_nop | 这是 NOP 指令,表示不执行任何操作。 |
| 0x02 | vm_load_data_length | 将要加密的字符串长度加载到虚拟处理器的data length寄存器中。 |
| 0x10 | vm_loop target | 如果counter寄存器小于data length寄存器,则跳转到target。 |
| 0x11 | vm_jump target | 无条件跳转到target地址。 |
| 0x12 | vm_exit | 通知虚拟处理器停止运行。 |
| 0x20 | vm_encrypt regId | 对register[regId]的内容和key寄存器的内容进行异或操作。 |
| 0x21 | vm_decrement regId | 递减register[regId]的内容。 |
| 0x22 | vm_increment regId | 递增register[regId]的内容。 |
| 0x30 | vm_load_data_byte regId | 从data_base_address + counter_register加载字节到register[regId]中。 |
| 0x31 | vm_store_data_byte regId | 将register[regId]中的字节存储到data_base_address + counter_register中。 |
向 Flat Assembler 添加虚拟处理器的支持
我们将跳过为处理器声明单独结构的步骤;相反,处理器的状态将存储在堆栈中。不过,我们需要做一些准备工作。首先,我们需要让 Flat Assembler 理解我们的助记符并生成适当的二进制输出。为此,我们将创建一个附加的源文件,并命名为vm_code.asm。由于该文件将包含宏指令的声明和虚拟机代码(它们将作为数据处理),因此要在主源文件中包含此文件,可以通过添加以下内容:
include 'vm_code.asm'
在数据部分的某个位置添加这一行。下一步,我们必须定义可以转换为虚拟处理器理解的二进制输出的宏指令。这是 FASM 的一个非常强大的功能,因为人们可以通过一组宏指令为几乎任何架构添加支持(顺便提一下,这正是 Flat Assembler G 的核心思想):
macro vm_load_key
{
db 0x00
}
macro vm_nop
{
db 0x01
}
macro vm_load_data_length
{
db 0x02
}
macro vm_loop loopTarget
{
db 0x10
dd loopTarget - ($ + 4)
}
macro vm_jump jumpTarget
{
db 0x11
dd loopTarget - ($ + 4)
}
macro vm_exit
{
db 0x12
}
macro vm_encrypt regId
{
db 0x20
db regId
}
macro vm_decrement regId
{
db 0x21
db regId
}
macro vm_increment regId
{
db 0x22
db regId
}
macro vm_load_data_byte regId
{
db 0x30
db regId
}
macro vm_store_data_byte regId
{
db 0x31
db regId
}
*; Let's give readable names to registers*
register_a = 0
register_b = 1
register_cnt = 2
虚拟代码
显然,我们写前面的所有代码不是为了好玩;我们需要为虚拟处理器编写一些代码。由于架构非常有限且专门针对特定任务,因此代码的形式选择不多:
*; Virtual code ; Binary output*
vm_code_start:
vm_load_key *; 0x00*
vm_load_data_length *; 0x02*
vm_nop *; 0x01*
.encryption_loop:
vm_load_data_byte register_b *; 0x30 0x01*
vm_encrypt register_b *; 0x20 0x01*
vm_store_data_byte register_b *; 0x31 0x01*
vm_loop .encryption_loop *; 0x10 0xf5 0xff 0xff 0xff*
vm_exit *; 0x12*
虚拟处理器
到目前为止,一切似乎都很清楚,除了一个问题——这一切与树有什么关系?我们快到了,因为我们必须实现虚拟处理器本身,这就是我们在这里要做的。
虚拟处理器最简单且可能最常见的实现是while()循环,它通过读取虚拟机内存中的指令运行,并通过实现为间接跳转和跳转表(跳转目标地址表)的switch()语句来选择合适的执行路径。尽管我们的示例可能在这种方式下运行效果最好,而且下面描述的架构更适合复杂指令集,但它故意简化以避免讨论那些与树形结构明显无关的方面。
如指令/操作码表所示,我们的操作码都是 1 字节大小,再加上一个 1 字节或 4 字节的操作数(对于需要操作数的指令),范围从0x00到0x31,并且有相对较大的间隔。然而,操作码的数量使我们可以将它们安排成一个几乎完美的二叉搜索树:
我们说“几乎”是因为如果每个表示操作码0x11(vm_jump)和0x20(vm_encrypt)的节点都有两个子节点,那么它将是一个理想的二叉搜索树(但谁说我们不能再添加四个指令呢?)。
图中的每个节点代表一个tnode结构,包含所有必要的指针,包括一个指向小结构的指针,该结构将操作码映射到虚拟处理器循环中的真实汇编代码:
struc instruction opcode, target
{
.opcode dd opcode
.target dd target
}
因此,首先要做的就是建立一个将所有操作码映射到汇编代码的表格。表格的格式相当简单。每行包含以下内容:
-
双字操作码
-
一个指向汇编代码的指针(32 位模式为双字,64 位模式为长模式)。
在代码中实现表格相当简单:
i_load_key instruction 0x00,\
run_vm.load_key
i_nop instruction 0x01,\
run_vm.nop
i_load_data_length instruction 0x02,\
run_vm.load_data_length
i_loop instruction 0x10,\
run_vm.loop
i_jump instruction 0x11,\
run_vm.jmp
i_exit instruction 0x12,\
run_vm.exit
i_encrypt instruction 0x20,\
run_vm.encrypt
i_decrement instruction 0x21,\
run_vm.decrement
i_increment instruction 0x22,\
run_vm.increment
i_load_data_byte instruction 0x30,\
run_vm.load_data_byte
i_store_data_byte instruction 0x31,\
run_vm.store_data_byte
最后,我们已经到达了树。我们跳过树的构建和平衡过程,因为树是静态分配的,而且我们特别关注的是结构本身。在下面的代码中,我们实际上创建了一个tnode结构的数组,这些结构并不是通过base+index来访问,而是通过树进行连接。最后一行定义了一个指向树根节点tree_root的指针,它指向t_exit:
t_load_key tnode i_load_key,\ ; 0x00 <-\
0,\ ; |
0 ; |
t_nop tnode i_nop,\ ; 0x01 | <-\
t_load_key,\ ; ---------/ |
t_load_data_length ; ---------\ |
t_load_data_length tnode i_load_data_length,\ ; 0x02 <-/ |
0,\ ; |
0 ; |
t_loop tnode i_loop,\ ; 0x10 | <-\
t_nop,\ ; -------------/ |
t_jmp ; --------\ |
t_jmp tnode i_jump,\ ; 0x11 <-/ |
0,\ ; |
0 ; |
t_exit tnode i_exit,\ ; 0x12 |
t_loop,\ ; -----------------/
t_decrement ; --------\
t_encrypt tnode i_encrypt,\ ; 0x20 | <-\
0,\ ; | |
0 ; | |
t_decrement tnode i_decrement,\ ; 0x21 <-/ |
t_encrypt,\ ; ------------/
t_load_data_byte ; --------\
t_increment tnode i_increment,\ ; 0x22 | <-\
0,\ ; | |
0 ; | |
t_load_data_byte tnode i_load_data_byte,\ ; 0x30 <-/ |
t_increment,\ ; ------------/
t_store_data_byte ; --------\
t_store_data_byte tnode i_store_data_byte,\ ; 0x31 <-/
0,\
0
tree_root dd t_exit
编译后,执行文件的数据部分看起来是这样的:
搜索树
我们需要处理一个过程,该过程会在开始实现虚拟处理器循环之前从树中提取虚拟指令的汇编实现的正确地址。
tree_lookup过程需要两个参数:
-
tree_root变量的地址 -
将字节操作码转换为双字(double word)。
当此过程被调用时,它会按照树排序的规则逐个节点地“遍历”树,并将操作码参数与当前节点所引用的指令结构中的操作码值进行比较。该过程返回操作码的汇编实现地址,若未定义该操作码,则返回零:
tree_lookup:
push ebp
mov ebp, esp
push ebx ecx
virtual at ebp + 8
.treePtr dd ? *; First parameter - pointer to tree_root*
.code dd ? *; Second parameter - opcode value*
end virtual
virtual at ecx
.node tnode ?,?,? *; Lets us treat ECX as a pointer*
*; to tnode structure*
end virtual
virtual at eax
.instr instruction ?, ? *; Lets us treat EAX as a pointer*
*; to instruction structure*
end virtual
mov ecx, [.treePtr] *; Load the pointer to tree_root*
mov ecx, [ecx] *; Load the pointer to root node*
mov ebx, [.code] *; Read current opcode*
movzx ebx, bl *; Cast to unsigned int*
@@:
or ecx, 0 *; Check whether ECX points to a node*
jz .no_such_thing *; and return zero if not*
mov eax, [.node.data] *; Load pointer to instruction structure*
cmp ebx, [.instr.opcode] *; Compare opcode value*
jz @f
ja .go_right *; If node contains lower opcode, then*
*; continue searching the right subtree*
mov ecx, [.node.left] *; Otherwise continue searching the*
jmp @b *; left subtree*
.go_right:
mov ecx, [.node.right]
jmp @b
@@:
mov eax, [.instr.target] *; Relevant instruction structure has*
*; been found, so return the address*
*; of instruction implementation*
@@:
pop ecx ebx *; We are done*
leave
ret 8
.no_such_thing: *; Zero out EAX to denote an error*
xor eax, eax
jmp @b
循环
循环的实现稍微有些长,并且我们有许多其他有趣的内容填充本章的空间,因此请参考附带的源代码获取完整版本。不过,在这里我们将检查实现的某些部分:
- 创建栈帧和参数标记:该过程的前导代码和往常一样——我们在栈上分配一些空间,并保存那些我们希望在过程执行过程中不受影响的寄存器,也就是过程中的所有寄存器:
run_vm:
push ebp
mov ebp, esp
sub esp, 4 * 3 *; We only need 12 bytes for storing*
*; the state of virtual cpu*
push eax ebx ecx edx esi *; We will use these registers*
virtual at ebp + 8 *; Assign local labels to parameters*
.p_cmd_buffer_ptr dd ? *; Pointer to VM code*
.p_data_buffer_ptr dd ? *; Pointer to data we want to
; encrypt*
.p_data_length dd ? *; Length of data in bytes*
.p_key dd ? *; Key value cast to double word*
end virtual
virtual at ebp - 0x0c *; Assign local labels to stack
; variables*
.register_a db ? *; Register A of virtual processor*
.register_b db ? *; Register B of virtual processor*
.register_key db ? *; Register to hold the key*
.register_cnt db ? *; Counter register*
.data_base dd ? *; Pointer to data buffer*
.data_length dd ? *; Size of the data buffer in size*
end virtual
- 准备虚拟处理器循环:该循环本身首先从当前虚拟代码的位置读取操作码(opcode),然后调用
tree_lookup过程,若tree_lookup返回错误(零),则跳转至.exit,否则跳转至tree_lookup返回的地址:
virtual_loop:
mov al, [esi + ebx] *; ESI - points to array of bytes
; containing*
*; virtual code*
*; EBX - instruction pointer (offset
; into virtual code)*
movzx eax, al *; Cast opcode to double word*
push eax
push tree_root
call tree_lookup *; Get address of opcode emulation
; code*
or eax, 0 *; Check for error*
jz .exit
jmp eax *; Jump to emulation code*
上述代码后面是模拟代码片段的指令集,如附带源代码中所示。
run_vm过程的最后几行实际上是vm_exit操作码的仿真:
.exit:
pop esi edx ecx ebx eax *; Restore saved registers*
add esp, 4 * 3 *; Destroy stack frame*
leave
ret 4 * 4
树平衡
现在,当我们知道了二叉搜索树在汇编编程级别上的样子时,若不回到二叉搜索树平衡的问题上就是不正确的。这个问题有几种解决方法,但我们只考虑其中一种——Day-Stout-Warren 算法(包含在附带的代码中)。该算法非常简单:
-
分配一个树节点,并将其作为树的“伪根”,使得原始根节点成为伪根的右子节点。
-
通过中序遍历将树转换为排序的链表(此步骤还会计算原树中的节点数量)。不需要额外的分配,因为此步骤会重用树节点中已有的指针。
-
将链表重新转换为完整的二叉树(其中最底层的节点从左到右严格填充)。
-
使伪根的右子节点成为树的根。
-
处理伪根节点。
将此算法应用于我们的操作码树将会得到以下结构:
结构几乎保持不变——四个层级,包括根节点,以及最底层的四个节点。操作码的顺序有所变化,但在这个特定的例子中,这并不太重要。然而,如果我们设计一个期望承载更大负载的更复杂系统,我们可以将操作码的编码设计成这样:最常用的操作码使用上层的值进行编码,而最不常用的操作码则使用底层的值。
稀疏矩阵
稀疏矩阵很少被讨论,如果有的话,是因为它们的实现和维护相对复杂;然而,在某些情况下,它们可能是一个非常方便和有用的工具。基本上,稀疏矩阵在概念上与数组非常相似,但它们在处理稀疏数据时效率更高,因为它们节省内存,从而使得可以处理更大规模的数据。
以天文摄影为例。对于我们这些不熟悉这个领域的人来说,业余天文摄影意味着将数码相机连接到望远镜,选择夜空中的某个区域并拍摄照片。然而,由于拍摄是在没有手电筒或任何其他辅助设备的情况下进行的(其实用手电筒照亮天体是很愚蠢的做法),所以需要拍摄几十张相同物体的照片,然后使用特定算法将这些图像堆叠在一起。在这种情况下,存在两个主要问题:
-
噪声抑制
-
图像对齐
缺乏专业设备(即没有配备冷却 CCD 或 CMOS 矩阵的大型望远镜),就会面临噪声问题。曝光时间越长,最终图像中的噪声就越多。当然,有许多噪声抑制算法,但有时,某些真实的天体可能会被错误地当作噪声,并被噪声抑制算法去除。因此,处理每一张图像并检测潜在的天体是个好主意。如果某个“光点”,如果没有被认为是噪声,至少在 80%的图像中出现(很难相信任何噪声能够在没有变化的情况下存活这么长时间,除非我们在谈论坏点),那么这个区域需要不同的处理。
然而,为了处理图像,我们需要决定如何存储结果。当然,我们可以使用一个结构数组来描述每一个像素,但这样做在内存方面的开销太大。另一方面,即使我们拍摄的是夜空中人口密集的区域,天体所占的区域也远小于“空白”空间。相反,我们可以将图像划分成较小的区域,分析这些较小区域的某些特征,并且只考虑那些看起来被填充的区域。下图展示了这个想法:
该图(展示了梅西耶 82 天体,也被称为雪茄星系)被划分为 396 个较小的区域(一个 22 x 18 的矩阵,每个区域为 15 x 15 像素)。每个区域可以通过其亮度、噪声比以及许多其他方面来描述,包括它在图中的位置,这意味着它可能占用相当可观的内存。如果将这些数据存储在一个二维数组中,并同时存储超过 30 张图像,可能会产生数兆字节的无意义数据。正如图中所示,只有两个感兴趣的区域,它们共同构成约 0.5%的数据(这更完美地符合稀疏数据的定义),这意味着如果我们选择使用数组,我们将浪费 99.5%的内存。
利用稀疏矩阵,我们可以将内存的使用减少到仅存储重要数据所需的最小值。在这种特定情况下,我们将有一个 22 列头节点、18 行头节点的链表,并且只有 2 个数据节点。以下是这种排列的一个非常粗略的示例:
前面的示例非常粗略;实际上,实施中还会包含一些其他链接。例如,空列头节点的down指针会指向它自身,空行头节点的right指针也会指向它自身。行中的最后一个数据节点的right指针会指向行头节点,同样,列中的最后一个数据节点的down指针会指向列头节点。
图
图的一般定义是,图是由一组顶点(V)和边(E)组成的数据结构。顶点可以是任何东西(任何东西意味着任何数据结构),边则由它连接的两个顶点-v和w来定义。边有方向,这意味着数据从顶点v流向顶点w,并且有权重,表示流动的难度。
最简单且可能是最常见的图结构示例是感知机——一种人工神经网络范式:
传统上,感知机是从左到右绘制的,因此我们有三个层:
-
输入层(传感器)
-
隐藏层(大多数处理发生的地方)
-
输出层(形成感知机的输出)
尽管人工神经网络的节点被称为神经元,但由于我们讨论的是图,因此我们将它们称为顶点,而不是 ANN(人工神经网络)。
在前面的图中,我们看到一个典型的多层感知机布局,用于解决 XOR 问题的人工神经网络。
人工神经网络中的 XOR 问题是指使得一个 ANN 实现能够接收两个在 {0, 1} 范围内的输入并产生一个结果,仿佛两个输入进行了异或操作。单层感知机(其中隐藏层也是输出层)无法找到该问题的解决方案,因此需要添加额外的层。
顶点S0和S1不执行任何计算,它们作为顶点N0和N1的数据源。正如所述,边具有权重,在这个示例中,来自S0和S1的数据会与边的权重进行相乘,边的权重包括 [s0, n0]、[s0, n1]、[s1, n0] 和 [s1, n1]。同样的操作适用于通过 [bias, n0]、[bias, n1]、[n0, o] 和 [n1, o] 传输的数据。
然而,图形可以是任意形状,边缘可以将数据传递到任何方向(甚至传递到同一顶点),具体取决于它们要解决的问题。
摘要
在本章中,我们简要介绍了几种数据结构(不要与汇编中的 struc[tures] 混淆)并回顾了它们的一些可能应用。然而,由于数据结构的主题非常广泛,可能需要为这里简要描述的每种结构及其变种单独开设章节,这不幸超出了本书的范围。
从下一章(第八章,将汇编语言编写的模块与高级语言编写的模块混合)开始,我们将解决更多实际问题,并开始应用迄今为止所学的知识,力求找到优雅的解决方案。
在下一章中,我们将看到如何将为 32 位和 64 位 Windows 及 Linux 操作系统编写的汇编代码与现有的汇编或高级语言编写的库链接。我们甚至会讨论.NET 与汇编代码的互操作性(在 Linux 和 Windows 上均适用)。
第八章:混合使用用汇编语言编写的模块和用高级语言编写的模块
我们已经走了很长一段路,几乎涵盖了汇编语言编程基础的各个方面。事实上,到目前为止,我们应该能够用汇编语言实现任何算法;然而,还有一些重要的内容我们尚未涉及,但这些内容同样重要。
尽管在产品开发的时间表上,用汇编语言编写较大部分(甚至是整个产品)可能不是最佳选择,但它仍然是一个非常有趣且具有挑战性的任务(也具有教育意义)。有时,使用汇编语言实现某些算法的部分,可能比使用高级语言更为方便。还记得我们用来进行异或加密的微型虚拟机吗?为了举例说明,我们将在汇编语言中实现一个简单的加密/解密模块,并看看它如何与高级语言一起使用。
在本章中,我们将涵盖以下主题:
-
实现一个简单的加密模块核心
-
为进一步与高级语言编写的代码进行链接,构建目标文件:
-
OBJ:适用于 Windows 的目标文件(32 位和 64 位);
-
O:适用于 Linux 的可链接 ELF(32 位和 64 位);
-
-
为 Windows 和 Linux(32 位和 64 位)构建 DLL(动态链接库)和 SO(共享对象),以便在.NET 平台上使用
加密核心
本章的主要项目是一个完全用汇编语言编写的小型简单(不能说是原始的)加密/解密模块。由于本章的主题是汇编语言模块与高级语言模块的接口,我们不会深入讨论加密原理,而是将重点放在代码的可移植性和互操作性上,同时使用稍微修改过的异或算法。该算法的基本思想是接收一个字节数组并执行以下操作:
-
获取一个字节,并将其左移指定的位数(计数器在编译时随机生成)。
-
用 1 字节的密钥(在编译时随机生成)对结果进行异或操作。
-
将字节写回数组。
-
如果有更多字节需要加密,回到步骤 1;否则跳出循环。
以下截图是我们即将实现的算法的一个输出示例:
这不是最好的加密方式,但对于我们的需求来说绝对足够。
可移植性
我们的目标是编写可以在 32 位和 64 位 Windows 以及 Linux 平台上使用的可移植代码。这个目标可能听起来不可能实现,或者是非常繁琐的工作,但其实非常简单。首先,我们需要定义一些常量和宏,这将使我们的后续工作更加轻松,所以让我们从创建platform.inc和crypto.asm源文件开始,其中后者是主要的源文件,也是我们将要编译的文件。
Flat Assembler 能够生成多种格式的文件,从原始二进制输出和 DOS 可执行文件,到 Windows 特定格式,再到 Linux 二进制文件(包括可执行文件和对象文件)。假设你至少熟悉以下几种格式:
-
32 位 Windows 对象文件(MS COFF 格式)
-
64 位 Windows 对象文件(MS64 COFF 格式)
-
32 位 Windows DLL
-
64 位 Windows DLL
-
32 位 Linux 对象文件(ELF)
-
64 位 Linux 对象文件(ELF64)
不需要深入了解它们,因为 Flat Assembler 为我们完成了所有繁重的工作,我们所要做的就是告诉它我们感兴趣的格式(并相应地格式化我们的代码)。我们将使用一个编译时变量ACTIVE_TARGET进行条件编译,并使用以下常量作为可能的值:
; Put this in the beginning of 'platform.inc'
type_dll equ 0
type_obj equ 1
platform_w32 equ 2
platform_w64 equ 4
platform_l32 equ 8
platform_l64 equ 16
TARGET_W32_DLL equ platform_w32 or type_dll
TARGET_W32_OBJ equ platform_w32 or type_obj
TARGET_W64_DLL equ platform_w64 or type_dll
TARGET_W64_OBJ equ platform_w64 or type_obj
TARGET_L32_O equ platform_l32 or type_obj
TARGET_L64_O equ platform_l64 or type_obj
指定输出格式
和往常一样,主源文件(在我们这个例子中是crypto.asm)应该以输出格式规范开始,从而告诉汇编器在创建输出文件时如何处理代码和段。正如我们之前提到的,编译时变量ACTIVE_TARGET将用于选择汇编器处理的正确代码。
下一步将是定义一个宏,该宏将有条件地生成正确的代码序列。我们将其命名为set_output_format:
macro set_output_format
{
if ACTIVE_TARGET = TARGET_W32_DLL
include 'win32a.inc'
format PE DLL
entry DllMain
else if ACTIVE_TARGET = TARGET_W32_OBJ
format MS COFF
else if ACTIVE_TARGET = TARGET_W64_DLL
include 'win64a.inc'
format PE64 DLL
entry DllMain
else if ACTIVE_TARGET = TARGET_W64_OBJ
format MS64 COFF
else if ACTIVE_TARGET = TARGET_L32_O
format ELF
else if ACTIVE_TARGET = TARGET_L64_O
format ELF64
end if
}
这个宏会告诉汇编器评估ACTIVE_TARGET编译时变量,并且只使用特定的代码。例如,当ACTIVE_TARGET等于TARGET_W64_OBJ时,汇编器将只处理以下行:
format MS64 COFF
因此,它将生成一个 64 位 Windows 对象文件。
条件声明代码和数据段
在告诉编译器我们期待什么输出格式后,我们需要声明各个段。由于我们在编写可移植代码,因此我们将使用两个宏来为前面提到的每种格式正确声明代码段和数据段。由于我们习惯在代码段后看到数据段(至少在本书中是这样,顺序可能会有所不同),我们将首先声明一个宏,负责正确声明代码段的开始:
macro begin_code_section
{
if ACTIVE_TARGET = TARGET_W32_DLL
section '.text' code readable executable
*; This is not obligatory, but nice to have - the DllMain procedure*
DllMain:
xor eax, eax
inc eax
ret 4 * 3
else if ACTIVE_TARGET = TARGET_W32_OBJ
section '.text' code readable executable
else if ACTIVE_TARGET = TARGET_W64_DLL
section '.text' code readable executable
*; DllMain procedure for 64-bit Windows DLL*
DllMain:
xor rax, rax
inc eax
ret
else if ACTIVE_TARGET = TARGET_W64_OBJ
section '.text' code readable executable
else if ACTIVE_TARGET = TARGET_L32_O
section '.text' executable
else if ACTIVE_TARGET = TARGET_L64_O
section '.text' executable
end if
}
我们接下来会声明数据段的宏:
macro begin_data_section
{
if ACTIVE_TARGET = TARGET_W32_DLL
section '.data' data readable writeable
else if ACTIVE_TARGET = TARGET_W32_OBJ
section '.data' data readable writeable
else if ACTIVE_TARGET = TARGET_W64_DLL
section '.data' data readable writeable
else if ACTIVE_TARGET = TARGET_W64_OBJ
section '.data' data readable writeable align 16
else if ACTIVE_TARGET = TARGET_L32_O
section '.data' writeable
else if ACTIVE_TARGET = TARGET_L64_O
section '.data' writeable
end if
}
导出符号
系列中的最后一个宏将使得某些符号得以导出。我们实现的加密核心将只导出一个符号——GetPointers()过程——它将返回一个指向结构的指针,结构包含指向其他过程的指针。这个宏遵循之前定义的模式:
*; In this specific case, when the macro would be called*
*; at the end of the source, we may replace the*
*; "macro finalize" declaration with the "postpone" directive.*
macro finalize
{
if ACTIVE_TARGET = TARGET_W32_DLL
section '.edata' export data readable
export 'MA_CRYPTO.DLL',\
GetPointers, 'GetPointers'
else if ACTIVE_TARGET = TARGET_W32_OBJ
public GetPointers as '_GetPointers'
else if ACTIVE_TARGET = TARGET_W64_DLL
section '.edata' export data readable
export 'MA_CRYPTO.DLL',\
GetPointers, 'GetPointers'
else if ACTIVE_TARGET = TARGET_W64_OBJ
public GetPointers as 'GetPointers'
else if ACTIVE_TARGET = TARGET_L32_O
public GetPointers as 'GetPointers'
else if ACTIVE_TARGET = TARGET_L64_O
public GetPointers as 'GetPointers'
end if
}
上面的宏会使符号对静态或动态链接器可见,具体取决于我们正在构建的目标。或者,我们可以用postpone指令替换macro finalize,这将强制在源文件结束时自动执行宏体。
现在我们可以保存platform.inc文件,因为我们在未来不会以任何方式修改它。
核心过程
在处理了所有输出格式的细节后,我们可以安全地继续实现核心代码。正如之前提到的,我们只需导出一个入口;但我们仍需实现其他部分。我们的核心中只有四个过程:
-
f_set_data_pointer:此过程接受一个参数,即指向我们要处理的数据的指针,并将其存储到data_pointer全局变量中 -
f_set_data_length:此过程接受一个参数,即我们要加密/解密的数据长度,并将其存储到data_length全局变量中 -
f_encrypt:此过程实现了加密循环 -
f_decrypt:这是f_encrypt的反操作
然而,在实现这些之前,我们首先需要准备模板,或者更准确地说,为我们的主源文件准备一个框架。由于宏指令的广泛使用,这个模板看起来与我们习惯的稍有不同。但不要让它让你困惑,从结构上来说(从汇编语言工程师的角度看)它与我们之前处理的结构是相同的:
*; First of all we need to include all that we have written this far*
include 'platform.inc'
*; The following variable and macro are used in compile time
; only for generation of* *pseudorandom sequences, where
; count specifies the amount of pseudorandom bytes to* *generate*
seed = %t
macro fill_random count
{
local a, b
a = 0
while a < count
seed = ((seed shr 11) xor (seed * 12543)) and 0xffffffff
b = seed and 0xff
db b
a = a + 1
end while
}
*; ACTIVE_TARGET variable may be set to any of the
; TARGET* constants*
ACTIVE_TARGET = TARGET_W32_DLL
*; Tell the compiler which type of output is expected
; depending on the value of* *the ACTIVE_TARGET variable*
set_output_format
*; Create code section depending on selected target*
begin_code_section
*; We will insert our code here*
*; Create appropriate declaration of the data section*
begin_data_section
*; Tell the compiler whether we are expecting 32-bit
; or 64-bit output*
if(ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
use32
else if(ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL) |\
(ACTIVE_TARGET = TARGET_L64_O)
use64
end if
*; This, in fact, is a structure which would be populated with
; addresses of our procedures*
pointers:
fill_random 4 * 8
*; Here the core stores the address of the data to be processed*
data_pointer:
fill_random 8
*; And here the core stores its length in bytes*
data_length:
fill_random 8
*; Pseudorandom encryption key*
key:
fill_random 2
*; The following line may be omitted if we used the postpone*
*; directive instead of "macro finalize"*
finalize
尽管前面的代码看起来与我们通常看到的有所不同,但它其实是自解释的,不需要额外的说明。所有的艰难工作都交给了之前定义的宏指令,唯一需要我们关注的就是位容量。正如你所看到的,大小和地址默认分配了 8 字节。这是为了使它们适应 32 位和 64 位的需求。我们本可以插入另一个if…else语句,但由于我们只有 3 个受位容量影响的数据项,在 32 位模式下每个数据项多占用 4 字节也不成问题。
加密/解密
由于我们在这里开发的是加密核心,因此自然要先实现加密功能。以下代码根据我们之前定义的算法执行数据加密:
f_encrypt:
*; The if statement below, when the condition is TRUE, forces the assembler to produce*
*; 32-bit code*
if (ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
push eax ebx esi edi ecx
lea esi, [data_pointer]
mov esi, [esi]
mov edi, esi
lea ebx, [data_length]
mov ebx, [ebx]
lea ecx, [key]
mov cx, [ecx]
and cl, 0x07
*; Encryption loop*
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec ebx
or ebx, 0
jnz @b
pop ecx edi esi ebx eax
ret
*; In general, we could have omitted the "if" statement here,
; but the assembler*
*; should not generate any code at all, if
; the value of ACTIVE_TARGET is not valid.*
*; In either case, the following block is processed only
; when we are expecting* *a 64-bit output*
else if (ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL) |\
(ACTIVE_TARGET = TARGET_L64_O)
push rax rbx rsi rdi rcx
lea rsi, [data_pointer]
mov rsi, [rsi]
mov rdi, rsi
lea rbx, [data_length]
mov ebx, [rbx]
lea rcx, [key]
mov cx, [rcx]
and cl, 0x07
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec rbx
or rbx, 0
jnz @b
pop rcx rdi rsi rbx rax
ret
end if
到现在为止,你应该能够自己区分过程的不同部分,看到哪里是前导代码的结束,哪里是尾部代码的开始,以及核心功能所在的位置。在这个特定的案例中,大部分代码都用于保存/恢复寄存器和访问参数/变量,而核心功能可以归结为以下代码:
*; Encryption loop*
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec ebx
or ebx, 0
jnz @b
用于 32 位平台,或者这段代码:
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec rbx
or rbx, 0
jnz @b
用于其 64 位平台。
很明显,解密过程的实现将与加密过程几乎完全相同,唯一的变化就是交换旋转和XOR指令(当然还需要改变旋转方向)。因此,f_decrypt的 32 位版本会是这样的:
xor al, ch
ror al, cl
同样,它的 64 位版本也只是这两行代码。
设置加密/解密参数
正如你可能已经注意到的(希望你已经注意到),上一节讨论的过程完全没有接收任何参数。因此,我们确实需要提供两个额外的过程,以便能够告诉核心数据的位置以及需要处理多少字节。由于每个过程只接受一个参数,代码将更加分段,以便反映所使用的调用约定,在我们的情况下,调用约定如下:
-
适用于 32 位目标的 cdecl
-
适用于基于 Windows 的 64 位目标的 Microsoft x64
-
适用于基于 Linux 的 64 位目标的 AMD64
f_set_data_pointer
这个过程接收一个 void* 类型的参数。当然,汇编器并不关心某个过程期望的参数类型。更准确地说,汇编器作为编译器,并不理解过程参数的概念,更不用说它根本没有过程的概念。让我们看一下 f_set_data_pointer 过程的实现:
f_set_data_pointer:
if (ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
push eax
lea eax, [esp + 8]
push dword [eax]
pop dword [data_pointer]
pop eax
ret
else if (ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL)
push rax
lea rax, [data_pointer]
mov [rax], rcx
pop rax
ret
else if (ACTIVE_TARGET = TARGET_L64_O)
push rax
lea rax, [data_pointer]
mov [rax], rdi
pop rax
ret
end if
这段代码也不复杂。传递给这个过程的参数只是被写入到 data_pointer 位置。
f_set_data_length
这个过程与 f_set_data_pointer 完全相同,唯一的区别是参数写入的地址。只需复制前面的代码,并将 data_pointer 更改为 data_length。
另一种选择是实现一个单一的过程,从而消除冗余代码,它将接受两个参数:
-
实际参数(无论是数据的指针还是其大小),因为汇编器并不关心类型
-
一个选择器,用于告诉过程参数值应存储的位置
尝试自己实现这个;这将是一个很好的快速练习。
GetPointers()
GetPointers() 过程是我们唯一公开的过程,只有这个过程对动态链接器或静态链接器可见,具体取决于选择的输出目标。这个过程的逻辑很原始。它创建一个结构体(在这个例子中,结构体是静态分配的),并用核心过程的地址填充它,最后返回这个结构体的地址:
GetPointers:
if (ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
push dword pointers
pop eax
mov [eax], dword f_set_data_pointer
mov [eax + 4], dword f_set_data_length
mov [eax + 8], dword f_encrypt
mov [eax + 12], dword f_decrypt
ret
else if (ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL) |\
(ACTIVE_TARGET = TARGET_L64_O)
push rbx
mov rbx, pointers
mov rax, rbx
mov rbx, f_set_data_pointer
mov [rax], rbx
mov rbx, f_set_data_length
mov [rax + 8], rbx
mov rbx, f_encrypt
mov [rax + 16], rbx
mov rbx, f_decrypt
mov [rax + 24], rbx
pop rbx
ret
end if
一旦所有前面的过程都添加到主源文件中,你可以安全地编译它,并看到所选择的输出格式的输出被生成。如果你在这里指定了目标,你应该能够看到一个 32 位的 Windows DLL 被创建。
与 C/C++ 接口
让我利用本章的主题说一说。够了,够了,汇编语言,我们做点 C 语言的东西(对于那些愿意将汇编代码与 C++ 链接的人,这个 C 示例应该很容易理解;如果不理解——那你拿错书了)。作为一个例子,我们将从我们的汇编源文件中生成一个目标文件,并将其与在 C 中编写的代码链接,目标平台包括 32 位和 64 位的 Windows 和 Linux。
静态链接 - Visual Studio 2017
首先,让我们看看如何生成目标文件。我相信你已经了解了如何生成不同目标,特别是如何为本例生成目标。我们从 32 位的 MSCOFF 目标文件开始,通过将ACTIVE_TARGET变量设置为TARGET_W32_OBJ并编译主源文件来实现。
在 Visual Studio 中创建一个 C/C++项目,并将目标文件复制到项目目录中,如以下截图所示(截图显示了 32 位和 64 位的目标文件):
如前面的截图所示,我们还需要至少一个文件,即头文件。由于我们的加密引擎相当简单,所以不需要复杂的头文件。这里显示的这个头文件就足够了:
上述代码中有一个小陷阱。在阅读下一个段落之前,尝试找出其中不正确的部分。
从技术上讲,代码是正确的。它会编译并运行没有问题,但在将汇编语言编写的模块与其他语言链接时,有一个非常重要且初看并不明显的方面:结构成员对齐。在这个例子中,我们只使用了一个结构(用于存储过程指针),并且我们小心地处理了它,以确保指针根据平台正确对齐。虽然我们在字节边界上对数据进行了对齐(顺序存储),但 Visual Studio 的默认结构成员对齐值是“默认”,这个值并没有提供太多信息。我们可以做出假设(在这种情况下,我们可以假设“默认”意味着第一种选项,即 1 字节对齐),但这并没有保证,我们必须明确指定对齐方式,因为假设不仅在汇编语言中并不总是有效,而且还会带来严重的风险。需要提到的是,尽管我们在这一段中提到了 Visual Studio,但同样的情况适用于任何 C 编译器。
指定结构成员对齐的一种方式是通过项目设置,如下所示:
对于我们的例子来说,这已经足够了,但在更大的项目中可能会导致问题。强烈建议在没有合理需求的情况下不要改变整个项目的结构成员对齐方式。相反,我们可以对我们的头文件做一个小修改,告诉编译器如何处理这个特定结构的成员对齐。在crypto_functions_t结构声明之前插入以下代码:
#ifdef WIN32 *// For Windows platforms (MSVC)*
#pragma pack(push, 1) *// set structure member alignment to 1*
#define PACKED
#else *// Do the same for Unix based platforms* (GCC)
#define PACKED __attribute__((packed, aligned(1)))
#endif
在声明之后插入以下内容:
#ifdef WIN32 *// For Windows platforms*
#pragma pack(pop) *// Restore previous alignment settings*
#endif
现在,考虑以下这一行:
}crypto_functions_t, *pcrypto_functions_t;
将前一行更改为:
}PACKED crypto_functions_t, *pcrypto_functions_t;
然后,按照以下截图所示,添加main.c文件:
main.c文件中的代码不言自明。这里只有两个局部变量;testString变量代表我们要处理的数据,funcs将存储指向我们加密核心中pointers结构的指针。
不要急着构建项目,因为我们还没有告诉 Visual Studio 关于我们的目标文件。右键点击项目,选择“属性”。以下截图展示了如何为 64 位平台项目添加我们的目标文件。32 位项目也应该做同样的操作,只是需要注意将哪个目标文件分配给哪个平台:
在附带的示例项目中,crypto_w64.obj文件用于 x64 平台,crypto_w32.obj则用于 x86 平台。
你现在可以自由地构建和运行项目(无论是 x86 还是 x64,只要目标文件正确指定)。我建议你在main.c文件的第 13 行和第 15 行设置断点,以便能够观察到testString所指向内存的变化。运行时,你会看到类似于以下的内容(之所以说“类似”,是因为每次构建加密核心时,密钥都会不同):
上一截图展示了加密前传入核心的数据。接下来的截图则展示了相同的数据,在加密后状态:
解密这些加密数据将会让我们回到那个熟悉的Hello, World!。
静态链接 - GCC
在将汇编源代码编译为目标文件并链接到高级语言代码时,Visual Studio 和 GCC 之间并没有太大区别。实际上,坦率地说,我们必须承认,从汇编代码编译出来的目标文件与从高级语言编译出来的目标文件并没有什么不同。对于 GCC 来说,我们有高级语言源代码(C 源代码和头文件,文件无需修改)和两个目标文件,为了方便起见,我们将其命名为crypto_32.o和crypto_64.o。用于构建可执行文件的命令会略有不同,具体取决于所使用的平台。如果你正在运行 32 位 Linux 系统,则需要执行以下命令,分别构建 32 位和 64 位的可执行文件:
gcc -o test32 main.c crypto_32.o gcc -o test64 main.c crypto_64.o -m64
第二个命令只有在你安装了 64 位开发工具/库时才能工作。
如果你正在运行 64 位系统,则需要对命令进行轻微修改(并确保安装了 32 位开发工具和库):
gcc -o test32 main.c crypto_32.o -m32
以及:
gcc -o test64 main.c crypto_64.o
在使用 GDB 检查内存内容时,当运行其中一个testxx文件时,你将看到类似于以下截图的内容,这是加密前的状态:
加密后,你将看到类似于以下内容:
动态链接
动态链接意味着使用动态链接库(在 Windows 上)或共享对象(在 Linux 上),其原理与其他 DLL/SO 相同。动态链接的机制将在下一章简要介绍。
然而,我们现在需要构建动态链接库和共享对象,以便能够继续进行。编译 crypto.asm 文件时,将 ACTIVE_TARGET 编译时变量设置为 TARGET_W32_DLL,以生成 Windows 的 32 位 DLL,然后设置为 TARGET_W64_DLL,以生成 64 位 DLL。请注意,改变 ACTIVE_TARGET 不会影响输出文件的名称,因此我们需要相应地重命名每次编译的结果。
在 Windows 上,你只需改变 ACTIVE_TARGET 编译时变量,并通过 GUI 中的“运行 | 编译”选项进行编译(或按 Ctrl + F9 快捷键),而在 Linux 上,你需要先构建目标文件,然后在终端中输入另一个命令。该命令将是以下之一:
*# For 64-bit output on 64-bit machine*
gcc -shared crypto_64.o -o libcrypto_64.so
*# For 64-bit output on 32-bit machine*
gcc -shared crypto_64.o -o libcrypto_64.so -m64
*# For 32-bit output on 64-bit machine*
gcc -shared crypto_32.o -o libcrypto_32.so -m32
*# For 32-bit output on 32-bit machine*
gcc -shared crypto_32.o -o libcrypto_32.so
现在我们有了 Windows 的 DLL 和 Linux 的共享对象,可以继续进行,看看如何将用汇编编写的模块与 .NET 等框架进行集成。
汇编语言与托管代码
正如我们之前看到的那样,静态或动态链接并不像看起来那样困难,只要我们处理的是本地代码。但当我们决定将用汇编语言编写的代码与用 C# 编写的程序(它是一个托管环境,并不是由处理器直接运行,而是由某种虚拟机运行)结合时,会发生什么呢?许多人害怕混合本地模块和托管模块。将由汇编源代码编译的本地模块与托管代码结合,似乎甚至更可怕或不可能。然而,正如我们之前所见,在二进制层面,最初用汇编语言编写的模块与其他语言编写的模块之间没有区别。当涉及到像 C# 这样的托管代码时,事情变得比链接本地对象文件或使用 DLL/SO 稍微复杂一些。以下内容不适用于托管 C++ 代码,在这种情况下,你可以简单地按照本章前面讨论的步骤,将本地对象与托管代码链接,因为托管 C++ 是 Visual Studio 唯一支持的可以提供这种功能的语言。
然而,对于 C# 来说,我们只能使用 DLL/SO,因为 C# 是一个纯托管环境,无法处理以对象文件形式存在的本地代码。在这种情况下,我们需要一种适配器代码。在我们的示例中,我们将使用一个简单的类,它从 Windows 上的 crypto_wxx.dll 或 Linux 上的 libcrypto_xx.so 导入核心功能,并通过其方法将这些功能暴露给代码的其他部分。
有一种普遍的误解认为 .NET 平台仅限于 Windows。遗憾的是,这种误解相当普遍。然而,实际上,.NET 平台几乎像 Java 一样具有良好的可移植性,并支持多种平台。不过,我们将重点讨论 Windows(32/64 位)和 Linux(32/64 位)。
本地结构与托管结构
当我们尝试将类似于我们核心接口实现的东西与 .NET 等平台结合使用时,首先会遇到的问题是如何在托管代码和本地代码之间传递数据。托管代码和本地代码几乎不可能访问相同的内存区域。这不代表不可能,但绝对不健康,因此我们必须在这两个领域之间传递数据——托管领域和本地领域。幸运的是,.NET 框架中有一个类允许我们相对轻松地执行此类操作——System.Runtime.InteropServices.Marshal。由于我们使用的是一个指向包含指向导出过程的指针的结构的指针,因此我们需要实现一个托管结构,用于与我们的 .NET 加密类一起使用,这可以通过一种相当简单的方式完成:
*// First of all, we tell the compiler how members of the*
*//struct are stored in memory and alignment thereof*
[StructLayout(LayoutKind.Sequential, Pack=1)]
*// Then we implement the structure itself*
internal struct Funcs
{
internal IntPtr f_set_data_pointer;
internal IntPtr f_set_data_length;
internal IntPtr f_encrypt;
internal IntPtr f_decrypt;
}
前面的代码完美地声明了我们需要的结构类型,我们可以开始实现加密类。尽管 C# 类的实现远远超出了本书的范围,但在这种情况下,似乎适合用几行代码定义方法和委托。
从 DLL/SO 导入和函数指针
.NET 中的互操作性是一个有趣的话题,但最好参考专门讨论它的资源。在这里,我们只考虑 .NET 中的函数指针的类比以及动态导入 DLL 和共享对象导出函数的误解。但首先,让我们构建类,导入 GetPointers() 过程,并定义函数指针委托:
internal class Crypto
{
Funcs functions;
IntPtr buffer;
byte[] data;
*// The following two lines make up the properties of the class*
internal byte[] Data { get { return data; } }
internal int Length { get { return data.Length; } }
*// Declare binding for GetPointers()*
*// The following line is written for 64-bit targets, you should*
*// change the file name to crypto_32.so when building for*
*// 32-bit systems.
// Change the name to crypto_wXX.dll when on Windows, where XX*
*// stands for 32 or 64.*
[DllImport("crypto_64.so", CallingConvention = CallingConvention.Cdecl)]
internal static extern IntPtr GetPointers();
*// Declare delegates (our function pointers)*
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dSetDataPointer(IntPtr p);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dSetDataSize(int s);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dEncrypt();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dDecrypt();
*// Constructor*
internal Crypto()
{
*// Luckily when we get a pointer to structure by calling*
*// GetPointers() we do not have to do more than just let*
*// the framework convert native structure to managed one*
functions = (Funcs)Marshal.PtrToStructure(
GetPointers(),
typeof(Funcs));
*// Set initial buffer ptr*
buffer = IntPtr.Zero;
}
*// SetDataPointer() method is the most complex one in our class,*
*// as it includes invocation of SetDataLength()*
internal void SetDataPointer(byte[] p)
{
*// If an unmanaged buffer has been previously allocated,*
*// then we need to free it first.*
if(IntPtr.Zero != buffer)
Marshal.FreeHGlobal(buffer);
buffer = Marshal.AllocHGlobal(p.Length);
*// Copy data to both the local storage and unmanaged buffer*
data = new byte[p.Length];
Array.Copy(p, data, p.Length);
Marshal.Copy(p, 0, buffer, p.Length);
*// Call f_set_data_pointer with a pointer to unmanaged buffer*
((dSetDataPointer) Marshal.GetDelegateFromFunctionPointer(
functions.f_set_data_pointer,
typeof(dSetDataPointer)))(buffer);
*// Tell the core what the length of the data buffer is*
((dSetDataSize) Marshal.GetDelegateFromFunctionPointer(
functions.f_set_data_length,
typeof(dSetDataSize)))(p.Length);
}
*// The remaining two methods are more than simple*
internal void Encrypt()
{
// Encrypt the data in the unmanaged buffer and copy it
// to local storage
((dEncrypt)Marshal.GetDelegateFromFunctionPointer(
functions.f_encrypt,
typeof(dEncrypt)))();
Marshal.Copy(buffer, data, 0, data.Length);
}
internal void Decrypt()
{
// Decrypt the data in the unmanaged buffer and copy it
// to local storage
((dDecrypt)Marshal.GetDelegateFromFunctionPointer(
functions.f_decrypt,
typeof(dDecrypt)))();
Marshal.Copy(buffer, data, 0, data.Length);
}
}
前面的代码适用于 Linux 版本;然而,通过将共享对象的名称更改为 DLL 的名称,它可以很容易地转换为 Windows 版本。使用这个类,操作我们的 Crypto Core 变得相当简单,可以通过以下代码总结:
Crypto c = new Crypto();
string message = "This program uses \"Crypto Engine\" written in Assembly language.";
c.SetDataPointer(ASCIIEncoding.ASCII.GetBytes(message);
c.Encrypt();
c.Decrypt();
然而,尽管如果我们实现前面的类并尝试在代码中使用它,它会顺利编译,但我们仍然无法实际运行它。这是因为我们需要根据所选平台提供 DLL 或共享对象。提供库的最简单方法是将它们复制到解决方案文件夹中,并告诉 IDE(Visual Studio 或 Monodevelop)正确处理它们。
第一步是将库(Windows 上的 DLL 和 Linux 上的 SO)复制到项目文件夹中。下图显示了 Linux 上的 Monodevelop 项目文件夹,但对于 Linux 和 Windows,过程完全相同:
下一步是告诉 IDE 如何处理这些文件。首先,右键点击项目,选择“添加 | 现有项”(Visual Studio)或“添加 | 添加文件”(Monodevelop),然后设置每个库的属性,如下图所示。
在 Visual Studio 中设置属性:
在 Monodevelop 中设置属性:
虽然图形界面不同,但两者都需要将构建操作设置为 Content,并在 Visual Studio 中将“复制到输出目录”设置为“始终复制”,在 Monodevelop 中勾选该选项。
现在我们可以构建项目(无论是在 Windows 还是 Linux 上)并运行它。我们可以观察内存中加密/解密的数据,或者添加一个小函数,打印出特定范围内的内存内容。
如果一切设置正确,那么在 Windows 上的输出应类似于以下内容:
Linux 上的输出将类似于以下内容:
总结
本章中,我们仅介绍了将程序集代码与外部世界进行接口的几个方面。目前有许多编程语言,但我们决定集中讲解 C/C++ 和 .NET 平台,因为它们是最能展示如何将用汇编语言编写的模块与用高级语言编写的代码进行绑定的方式。简单来说,任何编译为本地代码的语言都会使用与 C 和 C++ 相同的机制;另一方面,任何像 .NET 这样的平台,尽管有特定平台的绑定机制,但在低层次上会使用相同的方式。
不过,我想有一个问题仍然悬而未决,那就是如何将第三方代码链接到我们的程序集程序中?尽管本章的标题可能暗示这个话题已经包括在内,但将其放在下一章讨论会更有意义,因为我们将讨论的唯一内容就是如何在用汇编语言编写的程序中使用第三方代码。