如何逆向NDS游戏(中):ARM32汇编入门

本文翻译自《Reverse Engineering a DS Game》。

汇编语言(简称为汇编或ASM)是一种低级编程语言,它与计算机读取和执行的二进制指令(机器代码)非常接近。与C这样更高级的语言相比,汇编的抽象较少。例如,函数调用在C中是一行语句,但这一操作在汇编中可能对应多行代码。我们稍后会展示具体的例子。

汇编入门

本教程旨在提供汇编语言的简要介绍,帮助你入门。为了简洁起见,我会跳过一些细节。如果你对更全面的NDS汇编参考感兴趣,可以查看ToncARM汇编快速指南

不同的CPU可能使用不同的汇编语言,这取决于CPU支持的操作(即CPU指令集)。虽然所有CPU都支持计算机正常运行所需的最小指令集,但额外的指令则像是快捷方式,使得程序能够编译为更少的汇编代码行数,从而提高程序的执行速度。代价是需要更复杂的CPU硬件来支持这些额外的指令,同时这些指令可能会占用更多的存储空间。本教程将使用ARM指令集,因为这是《空之探险队》代码使用的主要指令集。在本教程中,“汇编”“ARM指令集汇编语言”的简写;在特定游戏的语境中,这是常见的简写方式。

除了ARM,NDSCPU还支持另一种指令集:THUMB。与ARM相比,THUMB的指令更简单,但需要更多的指令来实现与ARM相同的功能。许多DS游戏(如《空之探险队》)主要使用ARM代码,虽然一些DS游戏大量使用THUMB指令集。尽管本教程不涉及THUMB指令集,但一旦你掌握了ARM指令集,学习THUMB会相对容易。

以下是一个简单的汇编赋值指令。

1
mov r0, #0x0

这条语句将数字0(#0x0)赋给r0r0CPU的一个寄存器(Register)。寄存器是啥?让我们先来了解一下。

寄存器

寄存器CPU硬件中用于存储值的位置。当CPU需要保存数据以备后用时,寄存器是读写数据最快的地方。每一条汇编操作都会以某种方式与寄存器交互。

CPU通常只有少量可用的寄存器。在ARM CPU中,寄存器的命名格式为rX,其中“X”是从0开始的寄存器编号。 NDSCPU16个寄存器,从r0r15,每个寄存器可以存储多达32位(4字节)的数据,总计可以存储512位(64字节)数据。

r15是一个特殊的寄存器,被程序计数器(program counter),简称PC。该寄存器保存着下一条将要加载的指令的地址,指令加载后该地址会自动增4(因为每条指令占4字节)。于是汇编指令按行顺序执行。如果设置PC的值,CPU就可以跳转到新地址然后开始执行那里的代码;分支、循环和函数调用等就是这样实现的。 CPU在执行指令前会略微提前加载指令,因此PC可能会比当前正在执行的指令提前几条指令。

其他一些寄存器也有特殊名称和用途,我们稍后会讨论这些。

内存

寄存器容量有限,因此如果程序需要存储的数据超过寄存器的容量,数据会存储到主内存,也被称为RAM(random-access memory,随机存取存储器)或简称为内存。使用内存比使用寄存器存储和访问数据更慢,但内存容量更大。 NDS4MBRAM供常规代码操作使用,此外还有用于特殊操作(如I/O)的保留RAM。

ROM

用于加载游戏的ROM(只读存储器)文件实际上是一个字节数组。这些字节编码了诸如spite图像和音频的资源、游戏逻辑使用的数据值(例如某种治疗物品将为玩家恢复多少生命值)以及用于运行游戏的代码。

在讨论ROM中的数据时,通常根据数据从ROM开头以0起始的地址偏移量)来引用一段数据。例如,ROM文件中的第一个字节位于地址0x0,ROM文件中的第5个字节位于地址0x4。地址几乎总是以十六进制表示;“0x”表示一个值是以十六进制格式表示的。在逆向工程的语境下,十六进制也常用于其他值。

直接访问ROM相对较慢,因此NDS会在执行代码和读取数据之前将ROM加载到内存中。尽管实际上ROM数据是内存/RAM的一部分,但内存中存储ROM数据的区域通常仍被称为“ROM”。内存没有足够的空间一次性加载整个ROM,因此一次只能加载部分ROM。

overlay

overlay是一种设计模式,允许计算机运行超过其内存容量的程序。程序的代码被分为若干块,被称为overlay,一次只加载部分overlay到内存中。当程序状态变化并需要不同的overlay时,计算机会将内存中不再需要的overlay替换为程序当前需要的overlay。

NDS将每个overlay加载到内存的特定区域。一些overlay在内存中可能会重叠,但只有当两个overlay不会同时加载时,它们才可以重叠。

《空之探险队》有36overlay。处理迷宫内游戏玩法的代码存储在overlay 29overlay 31中,而处理过场动画和外部世界移动的代码存储在overlay 11中。迷宫和外部世界的玩法都需要overlay 10,因此在这两种模式下都会加载该overlay。overlay 29加载到地址0x22DC240,overlay 10加载到地址0x22BCA80,overlay 31加载到地址0x2382820。

在游戏中访问overlay中的值的时候,使用其在overlay文件中的地址加上该overlay文件的地址。例如,overlay_0029.bin文件中地址0x4的数据在游戏中使用地址0x22DC244(0x4 + 0x22DC240)进行访问。

例外情况是arm9.binarm7.bin。它们包含游戏的核心系统,例如加载overlay所需的代码,因此它们从不会被卸载。 arm9.bin加载到地址0x2000000,而arm7.binDS的次级ARM7TDMI CPU处理。除非你研究它负责的特殊进程(如音频),否则你不需要担心arm7.bin

在之前的Ghidra设置过程中,你将arm9.bin的基址设置为0x2000000,并将overlay文件设置为它们各自的地址。这与DS在内存中加载ROM文件的位置相匹配,将这些地址提供给Ghidra有助于它更好地分析代码。

每个可以加载的overlay子集都需要你将arm9.bin重新导入为项目中的新文件。之前的Ghidra设置可以帮助你逆向工程《空之探险队》的迷宫玩法,加载了arm9.bin并添加了overlay 10、2931。如果你想分析外部世界的玩法,需要重新加载arm9.bin并添加overlay 1011。

查找overlay

每个overlay在内存中的起始地址定义在ROM中的一个文件中,被称为overlayOVT)。OVT是从ROM解压出来的文件y9.bin。你可以使用十六进制编辑器打开这个文件,如HxD(Windows)或Hex Fiend(macOS)。

y9.bin中,每个overlay按顺序列出,首先是overlay编号,然后是地址,接着是其他一些信息,如overlay的大小,最后是4个字节(即一个字)的0作为overlay之间的分隔符。要找到overlay的起始地址,首先查找紧跟在00000000之后的overlay编号,然后查看下一个字,得到overlay的地址。overlay的地址是小端序的,因此需要反向读取字节(例如,小端序的80 CA 2B 02等于0x022BCA80)。

《空之探险队》的OVT,其中overlay 10(0xA)部分被高亮显示。overlay 10的地址为80 CA 2B 02(0x022BCA80)。

一旦知道了overlay的地址,你就可以运行游戏,查看内存中该overlay的地址,并与overlay文件的起始部分进行比较,看看字节是否匹配。如果匹配,则该overlay当前已加载到内存中。

大多数游戏都有一个内存区域列出当前加载的overlay。例如,《空之探险队》的overlay加载列表位于地址0x20AF230。然而,这个列表的位置在不同游戏之间并不统一。找到该列表并不容易,超出了本教程的范围。但如果一个游戏的逆向工程社区已经找到了OVT,那么查找overlay的任务就大大简化了。

指令

指令是用于操作寄存器或内存中的数据的。CPU根据程序计数器的值按顺序执行指令。

每条指令都是4个字节的数据。当指令被编码为字节时,被称为二进制码机器码。逆向工程工具可以读取这些字节,并将它们转换为更易读的汇编代码。

赋值

下面是之前展示的赋值指令:

1
mov r0, #0x0

该指令有三个部分:

  • 助记符:要执行的操作的简写名称。mov指令将一个值赋给寄存器。
  • 目标:要设置值的寄存器。在此指令中,将设置r0的值。
  • :要存储在目标寄存器的值的来源。在这种情况下,值是常量0;在汇编术语中,被称为立即数(immediate number,意为该数不表示任何内存地址而直接表示一个值,所以为什么不翻译成直接数呢——译注)。

将这三部分结合起来,这条指令将r0的值设置为0,丢弃了r0中先前的值。

来源也可以是另一个寄存器。以下指令将r1的值复制到r0中。

1
mov r0, r1

算术

我们来看另一种类型的指令。下面的指令是一条加法操作。

这条指令将1加到r1中的值,并将其存储到r0中。

1
add r0, r1, #0x1

r0是与之前一样的目标寄存器。由于加法需要两个操作数,这里有两个来源,r1#0x1。可以将一个寄存器与一个立即数相加,或者两个寄存器相加。如果指令使用立即数,立即数必须始终位于最后;这个限制与指令在CPU硬件中的实现方式有关。

如果你想将立即数加到寄存器值上,并将新值存回同一个寄存器,你可以使用以下简写。

1
add r0, #0x1

这条指令把1r0相加,结果存储在r0中。或者你可以说这是自增r0中的值。

其他可用的数学运算符包括减法、乘法、取负、按位与//异或/非,以及逻辑/算术(无符号/有符号)左/右移位。这些操作中的一些比加法更为严格,例如不支持立即数,但它们都遵循类似的结构。有关支持的指令的完整列表,请参考ARM的开发者文档

你可能注意到没有除法指令。与上面列出的操作相比,任意数的除法要复杂得多,因此它实现为一个函数,而不是一条指令。这意味着除法比其他数学操作要慢得多。注意,右移操作符可以通过2的幂将数字除以某些值,从而在一条指令中实现某些除法。

如果两个来源都是寄存器,ARM还支持对第二个来源寄存器进行移位,并在指令中使用移位后的值。

1
add r0, r1, r2, lsl #0x2

上面的指令先将r2左移2位(即乘以4),然后进行加法操作,等效于r0 = r1 + r2 * 4

加载/存储内存

寄存器只能存储少量的值,因此程序的大部分数据存储于内存。前文提到,内存(也可称之为主内存或RAM)是存储大部分数据的地方,因为寄存器的存储容量有限。

以下是一条把值存储到内存的指令。

1
str r0, [r1, #0x4]
  • 助记符“str”表示“存储寄存器(store register)”。
  • r0是源寄存器,包含要存储到主内存中的值。
  • 中括号里的r1(寄存器)和#0x4(可以是立即数或寄存器)相加,结果作为存储值的内存地址。立即数可以为0,表示将值直接存储到r1中的地址。

例如,如果r0的值是3,r1的值是0x2000000,那么内存地址0x2000004(0x2000000 + 4)将会写入值3。由于寄存器的值长度为4个字节,完整的值将存储在内存地址0x20000040x2000007之间(包括在内)。

替代指令strhstrb分别用于存储源寄存器的低2字节(半字,half word)和1字节(byte)。

从内存加载数据的格式与存储类似,只不过数据流的方向相反。

1
ldr r0, [r1, #0x4]

“ldr”表示“加载寄存器(load register)”。该指令从r1加上立即偏移量4的地址加载数据,并将加载的值存储到r0中。与str一样,也有用于加载半字(ldrh)和单字节(ldrb)的指令。

除了从寄存器地址加载数据,ldr还可以加载汇编代码中的硬编码值。这些值在Ghidra中标记为DAT_<address>,其中<address>ROM中值的地址。

1
2
3
4
ldr r0,[DAT_02090fe8]
...
DAT_02090fe8
02000010

这将把值0x2000010加载到r0中。

为了方便,Ghidra使用DAT_<address>标记数据值。在底层,ldr指令包含从指令在ROM中的地址到数据值地址的13位有符号偏移量。

分支

到目前为止,所有指令都是按顺序逐行执行的。程序执行一条指令,程序计数器加4(每条指令占4个字节),然后执行内存中的下一条指令,依此类推。例如,如果程序计数器的初始值为0x2000000,程序执行顺序如下:

  1. 执行地址为0x2000000的指令。
  2. 将程序计数器增加到0x2000004。
  3. 执行地址为0x2000004的指令。
  4. 将程序计数器增加到0x2000008。
  5. 执行地址为0x2000008的指令。

如此继续。

分支指令(也被称为条件语句或跳转)可以将程序计数器设置为特定的值,从而使程序执行跳转到指定的指令。

以下是一条无条件分支指令。

1
b LAB_02090fdc

LAB_02090fdc被称为标签。标签表示内存中的某条指令。在本例中,标签指的是ROM中地址为0x2090FDC的指令。

以上例为例,如果分支指令位于地址0x2000004:

  1. 执行地址为0x2000000的指令。
  2. 将程序计数器增加到0x2000004。
  3. 执行地址为0x2000004b LAB_02090fdc指令。
  4. 分支指令将程序计数器设置为0x2090FDC。
  5. 执行地址为0x2090FDC的指令。
  6. 将程序计数器增加到0x2090FE0。
  7. 执行地址为0x2090FE0的指令。

如此继续。分支之后,程序计数器继续按顺序增加并执行指令。

与数据值类似,分支指令在底层包含从指令地址到跳转目标的偏移量。对于分支指令,偏移量长度为26位。

除了b指令,还有其他无条件分支指令用于特定情况:

  • bl:用于函数调用的分支指令,稍后将讨论。
  • bx:把寄存器的值作为地址跳转。

条件分支

可以编写仅在满足某个条件时才执行的分支指令。如果条件不满足,条件分支指令将被跳过,程序计数器递增并继续执行下一条指令。

条件分支由两条指令组成。以下是一个示例。

1
2
cmp   r0, #0x1
beq LAB_02090f14

在这一组指令中,如果r0的值等于1,则程序跳转到LAB_02090f14。如果r0不等于1,程序将跳过分支并执行下一条指令。

  • cmp指令比较两个值来设置条件分支。第一个值始终是寄存器,第二个值可以是立即数也可以是另一个寄存器。
  • 所有条件分支指令都以字母'b'开头,并以一个助记符扩展(也即条件码)结尾,指定需要满足的条件类型。在本例中,扩展eq表示如果比较值相等,则执行分支。

条件分支指令支持所有基本的比较操作符:

  • 等于:beq
  • 不等于:bne
  • 大于:bgtbhi
  • 大于等于:bgebcs
  • 小于:bltbcc
  • 小于等于:blebls

等于和不等于各有一条指令,而其他比较操作符有不同的版本,以支持无符号整数、有符号整数和浮点数的比较。每个比较操作符的条件码的完整列表可以在ARM的开发者文档中找到。

ARM还支持单个指令的简化形式的条件分支。

1
2
cmp   r0, #0x1
moveq r0, r1

与普通条件分支一样,cmp指令用于设置分支。在cmp之后,下一条指令不是b指令,而是附加了条件码的其他指令。在上述示例中,只有当r0等于1时,r0才会被赋值为r1。所有指令都允许附加条件码。

C这样的高级语言使用条件关键字如if/else if/else和循环关键字如while/do while/for。这些结构通常在编译为汇编代码时转换为条件分支语句。

CPU内部,cmp指令设置了四个位的条件标志,分别为C、N、VZ。每条条件分支指令都检查特定的条件标志以决定是否执行分支。例如,beq指令会在Z=1时执行分支。你很可能不需要直接与这些条件标志交互,了解条件码就足够了。

函数

在概念层面,汇编中的函数与高级语言中的函数类似。一个函数可以被调用,然后该函数运行,最后返回到调用该函数的代码。函数还可以有参数和返回值。让我们更深入地了解汇编中的函数是如何工作的。

一个函数可以通过如下方式调用:

1
bl FUN_022de288

bl是一条用于函数调用的特殊分支指令。在这种情况下,程序将跳转到函数FUN_022de288的第一条指令。在分支之前,程序计数器的当前值会被保存到链接寄存器(link register, lr),对于NDSCPU,lr就是r14。函数结束时将检索链接寄存器的值,以便将程序返回到调用函数的位置。

下面是一个简单的函数:

1
2
3
4
FUN_022de288

ldr r0, [r0, #0x0]
bx lr

默认情况下,Ghidra根据函数开始的内存地址命名函数。此函数从内存地址0x22DE288开始,因此该函数被命名为FUN_022de288

大多数函数包含三部分:序幕主体尾声。序幕和尾声分别包括函数执行的标准设置和清理步骤,而主体是函数执行的主要逻辑。在上述函数中:

  • 该函数简单到没有序幕。
  • 主体包含指令ldr r0, [r0, #0x0]
  • 尾声包含指令bx lr。该指令将pc设置为lr中的值,于是程序将返回到调用该函数的地方。

一旦函数从尾声返回到调用函数,pc会像往常一样递增,于是程序在函数调用后直接执行下一条指令。

函数参数

要将参数传递给函数,需要在调用函数之前将参数存储在寄存器中。r0-r3可用于传递参数。如果函数需要超过四个参数,任何其他参数会被压进栈里,稍后再讨论。

在下面的代码中,给r0赋值以作为参数传递给FUN_022de288

1
2
3
...
add r0, r4, #0x0
bl FUN_022de288

一旦进入函数,函数就可以使用来自r0的参数。

1
2
3
4
FUN_022de288

ldr r0, [r0, #0x0]
...

返回值

如果函数需要返回一个值,返回值将在函数返回之前存储在r0中。调用函数可以根据需要使用r0中的返回值。

在函数FUN_022de288中,函数体在返回时将一个值赋给r0,然后用bx返回。

1
2
3
4
FUN_022de288

ldr r0, [r0, #0x0]
bx lr

调用者可以调用该函数,然后从r0中检索返回值以用于处理。

1
2
3
bl    FUN_022de288
cmp r0, #0x1
...

调用栈

当调用一个函数时,调用者可能已经在使用寄存器来存储值。寄存器数量有限,函数也可能需要这些寄存器来完成其工作。在函数使用寄存器之前,它应该保存它计划使用的寄存器的现有值。当函数完成时,它应该将保存的值恢复到寄存器中,这样调用者在恢复执行时不会丢失当前状态。

寄存器r0-r3r12被指定为临时寄存器,使用它们的函数不保存它们的值。而寄存器r4-r11保留寄存器(或变量寄存器),它们的值需要由函数保存和恢复。如果函数调用其他函数,则还会保存lr(译注:也就是r14)。

由于一个函数可以调用另一个函数,而该函数又可以调用另一个函数,如此反复,所以每个函数都必须在适当的时间存储和恢复寄存器的值。这是通过使用被称为调用栈(call stack)的内存位置来完成的。

调用栈,通常简称为,是内存中的一个特殊位置,用于在函数调用时保存寄存器的值。如果寄存器中没有足够的空间,它还用于存储局部变量。顾名思义,它是一个先进后出的(LIFO)数据结构。栈顶的地址由r13跟踪,通常被称为栈指针(sp)。

函数序幕的主要目的之一就是将寄存器的值保存到栈中。寄存器的值通过stmdb(store multiple, decrement before)指令压到栈顶(即sp的地址),也可记作push指令。此指令用于将多个寄存器的值存储在连续的地址中:递减栈指针,存储第一个值,再次递减栈指针,依此类推。

以下是一个序幕示例,保存了寄存器的值。

1
stmdb sp!, {r4 lr}

该序幕取自一个使用r4进行计算的函数。该函数还调用了另一个带有bl指令的函数,这将覆盖lr中的现有值。因此,该函数必须将当前的r4lr的值保存到栈中。

在函数尾声中,通过使用ldmia(load multiple, increment after)指令,也可记作pop,将保存的寄存器值恢复到原始值。这是上面序幕对应的尾声。

1
ldmia sp!, {r4 pc}

ldmia会删除栈顶的值并将它们分配给指定的寄存器。ldmia还会递增sp,使栈顶移动到已经出栈的项之后。在上述示例中,r4恢复为其原始值。pc被分配给最初从lr保存的值,使程序跳回到调用者函数的捷径。

除了保存和恢复寄存器值之外,栈还有几个其他用途。

栈中的局部变量

如果一个函数有很多局部变量,或者像结构体、数组这样的大型局部变量,可能会耗尽寄存器来存储所有变量。如果发生这种情况,溢出的值都将存储在栈中。如下代码展示了这种情况。

1
2
3
4
5
sub   sp, sp, #0x1c
...
str r0, [sp, #0x4]
...
add sp, sp, #0x1c

在函数序幕中,栈指针被减去0x1c,为局部变量腾出空间。函数通过使用相对于sp的偏移量在栈中存储和加载局部变量。在函数尾声中,函数通过将值加回sp来清理分配的局部变量空间。

Ghidra根据栈中的局部变量在栈中的位置分配名称,如local_24,而不是显示相对于sp的原始偏移量。该数字是通过将局部变量区域的总大小(0x1c)减去变量的偏移量(0x4)得出的,然后转换为十进制。0x1c - 0x4 = 0x18 = 24。

栈中的函数参数

有四个寄存器可用于函数传参。如果函数需要超过四个参数,额外的参数将被存储在栈中进行传递。栈也用于传递较大的数据类型,如结构体和数组。

下面是一个向函数传递五个参数的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
; Prologue
sub sp, #0x4
...
; Body
str r2, [sp, #0x0]
add r0, r5, #0x0
add r1, r4, #0x0
mov r2, r9
add r3, r6, #0x0
bl FUN_02332bac
...
; Epilogue
add sp, #0x4

与栈中的局部变量一样,序幕通过从sp中减去值来为在栈中传递的函数参数分配栈空间。当调用函数FUN_02332bac时,r0-r3用于四个参数,栈用于最后一个参数(使用str r2, [sp, #0x0]设置)。尾声通过将值加回sp来清理栈空间。

FUN_02332bac函数内部,函数根据栈参数相对于sp的偏移量进行访问。

1
2
3
4
5
FUN_02332bac

...
ldr r0,[sp, #Stack[0x0]]
...

以上关于传递参数、返回值、保存寄存器和分配局部变量的模式都是ARM 调用约定的一部分。这是一组标准,指导在函数调用时汇编代码应如何使用寄存器和内存,以确保程序的正确执行。如果你编写更高级别的代码(如C语言),编译器会生成符合调用约定的汇编代码。如果你手动编写汇编代码,尽管没有什么阻止你违反这种约定,但偏离约定是不好的做法,且很容易导致错误。

结构体

分析汇编代码,可以判断出像C这样高级语言中的结构体是如何编译成汇编代码的。

C中,一个结构体定义可能如下所示。对于本教程,假设int的大小为4字节:

1
2
3
4
5
struct Position
{
int x;
int y;
}

这个结构体的大小是8字节。由于x是结构体中定义的第一个变量,所以它位于结构体的开始处(即偏移量为0)。 x占用4字节,因此下一个变量y的偏移量为4,从结构体的开始处计算。

典型的汇编代码会维护一个指向结构体开始位置的指针,并使用偏移量来访问结构体的每个字段。以下代码是将值存储到结构体中的一个示例。

1
2
3
4
5
ldr   r0, [DAT_02073b70]  ; Load the address of a Position.
mov r1, #0x6
str r1, [r0, #0x0] ; position.x = 6;
mov r1, #0x4
str r1, [r0, #0x4] ; position.y = 4;

在逆向工程社区中,常见的情况是有一个结构体字段,但是用途尚不明确。社区通常会根据未知的字段的偏移量命名。例如,如果上面的结构体还没有被识别为存储位置信息的结构体,它可能会使用类似如下的命名:

1
2
3
4
5
struct unkStruct
{
int unk0;
int unk4;
}

复制结构体数据

对结构体的一种常见操作是将值从一个结构体复制到另一个结构体。有一些特殊的指令可以批量加载和存储值:ldmiastmia。我们已经在入栈和出栈时见过类似的指令,现在让我们更详细地了解它们。

ldmia指令代表“load multiple, increment after”,形式如下:

1
ldmia r1!, {r3 r4 r5}

r1存储初始的加载数据的地址。对于方括号中的每个寄存器,4字节的数据将从r1的地址加载到寄存器中,然后r1递增4。这将导致从r1中的地址加载12字节的数据到寄存器r3r4r5

例如,如果r1最初存储地址0x2000000,ldmia指令将执行以下操作:

  • 将地址0x2000000处的值加载到r3
  • r1递增到0x2000004。
  • 将地址0x2000004处的值加载到r4
  • r1递增到0x2000008。
  • 将地址0x2000008处的值加载到r5
  • r1递增到0x200000C。

也可以传递一组寄存器范围来进行加载,而不是列出每个单独的寄存器。

指令中的“!”表示“写回模式”,这意味着源寄存器会被指令递增。在某些指令集中,可以省略“!”以保持源寄存器不变。

stmia指令(store multiple, increment after)的工作方式与ldmia类似,只不过它将每个方括号中的寄存器的值存储到一个地址中。与ldmia一样,stmia可以接受一个寄存器列表或寄存器范围。

1
stmia r2!, {r3 r4 r5}

如果r2最初存储地址0x2000000,stmia指令将执行以下操作:

  • r3中的值存储到地址0x2000000。
  • r2递增到0x2000004。
  • r4中的值存储到地址0x2000004。
  • r2递增到0x2000008。
  • r5中的值存储到地址0x2000008。
  • r2递增到0x200000C。

ldmiastmia中,递增后的寄存器地址最终位于加载/存储的最后一个值的下一个地址。这使得可以链接ldmia/stmia以复制任意大小的数据。指令链可能如下所示:

1
2
3
4
ldmia   r1!, {r3 r4 r5}
stmia r2!, {r3 r4 r5}
ldmia r1!, {r3 r4 r5}
stmia r2!, {r3 r4 r5}

这些指令将从r1中的地址复制24字节的数据到r2中的地址。

使用ldmiastmia从一个结构体复制数据到另一个结构体所需的指令要比逐个复制每个字段的指令少得多。

数组

在汇编中,有几种方式可以实现数组的访问。

在很多方面,数组与结构体类似。如果访问一个硬编码的数组索引(即循环外的访问),会使用偏移量来访问数据,类似于结构体。复制数组数据也类似于复制结构体,通常使用相同的ldmia/stmia指令链。

如果在循环中访问数组,仍然可以使用偏移量,只不过每个数组元素的偏移量必须递增或重新计算。以下示例遍历了一个包含54字节值(例如指针)的数组。

1
2
3
4
5
6
7
8
9
10
ldr   r2, [DAT_02073b70]  ; Load pointer to start of array.
mov r6, #0x0 ; Initialize array index.

LAB_02073ac0
lsl r1, r6, #0x2 ; Calculate array offset by left-shifting by 2 (multiplying by 4).
ldr r0, [r2, r1] ; Load current array element.
... ; Process current array element.
add r6, #0x1 ; Increment array index.
cmp r6, #0x5
ble LAB_02073ac0 ; Go back to loop start if iteration is not finished.

另一种方式是从数组指针初始化当前数组元素的指针并递增。

1
2
3
4
5
6
7
8
9
10
ldr   r2, [DAT_02073b70]  ; Load pointer to start of array.
mov r6, #0x0 ; Initialize array index.

LAB_02073ac0
ldr r0, [r2, #0x0] ; Load current array element.
... ; Process current array element.
add r6, #0x1 ; Increment array index.
add r2, #0x4 ; Increment current array element pointer.
cmp r6, #0x5
ble LAB_02073ac0 ; Go back to loop start if iteration is not finished.

Switch语句

在汇编中,switch语句通常使用跳转表实现。汇编中存储了一系列b指令,传入switch语句的值会加到pc中,以跳转到相应的b指令,然后跳转到处理该case的逻辑。

例如,让我们看一个简单的C语言switch语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int type;
// Logic to assign type variable
switch (type)
{
case 2:
case 3:
case 5:
// Logic to handle case.
break;
case 0:
case 1:
// Logic to handle case.
break;
case 4:
// Logic to handle case.
break;
default:
break;
}

上面的switch语句在汇编中可能如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
; Logic to assign r0 (type variable).
...
cmp r0, #0x5
addls pc, pc, r1, lsl #0x2 ; Multiply type by 4 to get jump table offset to branch instruction.
b switchD_02334050::caseD_6 ; Go to end of function and do nothing if value is out of range.

switchD_02334050::caseD_0
b LAB_023344e8
switchD_02334050::caseD_1
b LAB_023344e8
switchD_02334050::caseD_2
b LAB_023344d0
switchD_02334050::caseD_3
b LAB_023344d0
switchD_02334050::caseD_4
b LAB_02334510
switchD_02334050::caseD_5
b LAB_023344d0

有些switch语句会放弃使用跳转表,而是使用一系列条件分支,有时也会将这两种方法结合使用。

汇编入门总结

读到这里,你现在已经具备了阅读NDSARM汇编代码的基础知识,并能识别一些最常见的汇编模式。虽然本教程没有涵盖一些不常见的汇编操作和模式,但你应该已经有足够的上下文来通过文档(例如官方ARM文档Tonc)和你喜欢的搜索引擎来补充这些知识。与大多数技能一样,练习是提高汇编阅读能力的最佳方法。

下一步是探索一些能帮助逆向工程NDS游戏的工具。我们将从静态代码分析器Ghidra开始。

to Be continued ->

0条搜索结果。