跳到内容
Shiny's Blog

内存寻址与大小端

上一章我们认识了寄存器和 x64dbg 窗口。但寄存器只有 8 个,存不了多少东西。程序的大部分数据存在内存里,通过地址来访问。这章先搞懂汇编怎么表示和访问内存,再生成一个练习用的空白程序。

内存怎么表示

汇编里访问内存用方括号 [],类似 C 的指针解引用 *ptr

mov eax, [ebp-4]          ; 从内存地址 (ebp-4) 读 4 字节到 eax
mov [ebp-8], eax          ; 把 eax 的值写到内存地址 (ebp-8)

x64dbg 里显示更详细,会带大小前缀:

mov eax, dword ptr [ebp-4]
mov dword ptr [ebp-8], eax

大小前缀

dword ptr 意思是”这次读/写 4 个字节”。CPU 需要知道操作多大的数据。怎么知道的?两种方式:

方式一:从寄存器推断。 如果一个操作数是寄存器,CPU 根据寄存器大小决定:

mov eax, [ebp-4]          ; EAX 是 32 位,所以读 4 字节
mov ax, [ebp-4]           ; AX 是 16 位,所以读 2 字节
mov al, [ebp-4]           ; AL 是 8 位,所以读 1 字节

方式二:显式指定。 如果没有寄存器可以推断(比如两个操作数都是常数或内存),必须写明:

mov dword ptr [ebp-4], 0  ; 明确写 4 字节的 0
mov word ptr [ebp-4], 0   ; 明确写 2 字节的 0
mov byte ptr [ebp-4], 0   ; 明确写 1 字节的 0

大小前缀对照表:

前缀大小名称对应 C 类型汇编声明
byte ptr1 字节字节char, booldb
word ptr2 字节shortdw
dword ptr4 字节双字int, unsigned int, floatdd
qword ptr8 字节四字long long, doubledq

最右边一列是汇编语言里的数据声明伪指令:db(define byte)、dw(define word)、dd(define dword)、dq(define qword)。你在 IDA 或 x64dbg 的数据窗口里看到 dd 12345678h,就是在说”这里定义了一个 4 字节数据,值是 0x12345678”。

dword ptr 最常见,因为 int 是 4 字节。偶尔看到 byte ptr(char)和 word ptr(short)。

地址是怎么算出来的

方括号里的表达式叫做有效地址(Effective Address),计算公式是:

有效地址 = 基址 + 索引 × 比例 + 偏移

x64dbg 支持几种格式:

格式示例场景
[固定地址]mov eax, dword ptr [0040A000]全局变量
[寄存器]mov eax, dword ptr [ecx]解引用指针
[寄存器 + 偏移]mov eax, dword ptr [ebp-4]局部变量
[寄存器 + 寄存器*比例]mov eax, dword ptr [ecx + edx*4]指针访问数组
[寄存器 + 寄存器*比例 + 偏移]mov eax, dword ptr [ebp+eax*4-10]栈上局部数组

后两种通常用于数组和结构体访问,先混个眼熟,后面遇到再回来查。

不管哪种格式,有效地址的计算方式都是一样的:把各部分加起来。下面用公式标准格式演示,实际 Debug 模式下你会看到 [ebp + eax*4 - 偏移] 的形式(原因见后面的 NOTE)。

假设 EBP = 0x012FF310

mov eax, dword ptr [ebp-4]

有效地址 = 0x012FF310 - 4 = 0x012FF30C。CPU 去 0x012FF30C 这个地址读 4 字节,放进 EAX。

mov eax, dword ptr [ecx + edx*4]

假设 ECX = 0x0040A000,EDX = 3。有效地址 = 0x0040A000 + 3*4 = 0x0040A00C。这就是数组第 3 个元素(每个元素 4 字节)的地址。

Tip

在 x64dbg 里看到内存寻址时,怎么判断它在访问什么?看基址寄存器:

  • [ebp - 8][ebp + 10] → 几乎一定是局部变量,EBP 是当前函数的栈帧基址
  • [eax][ecx] → 寄存器里存的是一个指针,正在解引用
  • [ecx + edx*4]数组访问ecx 是首地址,edx 是下标,*4 说明每个元素 4 字节(int
  • [ebp + eax*4 - 20] → 栈上的局部数组ebp-20 是首地址,eax 是下标

看到 *4 说明每个元素 4 字节,多半是 int 数组。

为什么 x64dbg 里看不到 [ecx + edx*4]

上面的例子用了 [ecx + edx*4] 这种干净格式,但你在 x64dbg 里实际看到的数组访问几乎都是 [ebp + eax*4 - 10] 这种带偏移的形式。

原因很简单:Debug 模式下,局部变量都分配在栈上,每个变量有固定的位置。比如 int iarr[3] 的首地址是 ebp-10,那 iarr[x] 的地址就是:

首地址 + 下标 × 4 = (ebp - 10) + x*4

这就是 [ebp + eax*4 - 10],公式还是同一个,只不过基址和偏移都是栈上的具体位置。

想看到纯粹的 [ecx + edx*4],有两种方法:

  • 用指针访问数组int *p = iarr; p[x];,编译器必须先把首地址放进寄存器,就会生成干净的格式
  • 切换到 Release 模式:编译器会把变量放进寄存器而不是栈上,偏移自然消失

不管格式长什么样,读懂的方法都一样:ebp-10 是数组首地址,eax 是下标,*4 是每个元素的字节数。

搞懂了地址怎么算,还有一个问题:数据在内存里是怎么排的? 同一个地址,读出来的字节顺序是大端还是小端,决定了你看到的数值。x86 用的是小端序。

大小端

x86 是小端序(Little-Endian),低字节存在低地址。

比如 EAX = 0x12345678,写到 [ebp-4] 时,内存里是这样的:

地址         字节
[ebp-4]     78     <- 最低字节在最低地址
[ebp-3]     56
[ebp-2]     34
[ebp-1]     12     <- 最高字节在最高地址

所以在 x64dbg 的内存窗口里,你看到 78 56 34 12,要反着读,实际值是 0x12345678

为什么 x86 用小端? 你在纸上算加法,一定从个位开始,因为要处理进位。CPU 做加法也是一样的,必须先拿到低位,算出进位,再算高位。

小端序恰好把低位放在最低地址。CPU 从内存读数据时,最先读到的就是低位,直接送进加法器就能算,一路往高地址推进就行。如果用大端序,低位存在更高的地址,CPU 得先跳过去取低位,算完再退回来处理高位,多了一趟折腾。

8 位总线时代的历史真相

这个”先低位再高位”的优势在 8 位总线时代最明显。70 年代的 CPU(如 8080)一次只能从内存读 1 个字节,但寄存器是 16 位的。算 0x1122 + 0x3344 时:

  • 小端序:内存里先存低位(2244),CPU 从低地址一路往下读,边读边算,指针只往一个方向走。
  • 大端序:内存里先存高位(1133),CPU 必须先跳到后面取低位,算完进位再退回来取高位,地址总线来回折腾。

小端序用最少的晶体管、最简单的电路就能完成 16 位加法。现在 CPU 总线都是 64 位了,一次能吞好几个字节进缓存,大端小端的硬件差距已经被抹平,但 x86 的选择从 70 年代沿用至今。

小端序还有一个附带好处:同一地址读取不同宽度时,起始地址不变。比如地址 0x100 存了 0x12345678

  • 读 byte:[0x100] = 0x78
  • 读 word:[0x100] = 0x5678
  • 读 dword:[0x100] = 0x12345678

都是 0x100 起,不需要为不同宽度换起始地址。

大小端在不同窗口的表现

同样的数据 0x12345678,在不同窗口里看起来不一样:

窗口显示样子说明
CPU 窗口mov dword ptr [ebp-4], 12345678指令里直接写正常数值,不需要反着读
寄存器窗口EAX : 12345678也是正常显示
内存窗口78 56 34 12按字节顺序显示,需要反着读
堆栈窗口78 56 34 12同内存窗口,按字节显示,需要反着读

规律:CPU 窗口和寄存器窗口已经帮你把小端序转换好了,直接看。只有内存窗口和堆栈窗口是原始字节流,需要你自己反着读。

这其实就是”显示方式”的区别:内存窗口是”这块内存里到底存了什么字节”,是原始的、底层的;CPU 窗口和寄存器窗口是”这些字节代表什么数值”,是经过解读的。

生成一个练习用的空白程序

后面的章节会经常让你在 x64dbg 里手写汇编指令来实验。你需要一个代码段全是 NOP 的 exe,相当于一块空白画布,想写什么指令都可以。

用 C 写一个全是 __nop() 的函数?不太干净,编译器会偷偷加各种初始化代码。最纯粹的方法是用 MASM(Microsoft Macro Assembler),直接写汇编源码,编译出来的 exe 代码段就是你写的那些指令,不多不少。

步骤

  1. 打开 VS,创建一个 C++ 空项目(Empty Project)

    VS 创建 C++ 空项目

  2. 在右侧解决方案资源管理器中,右键项目名称 -> 生成依赖项(Build Dependencies) -> 生成自定义(Build Customizations…)

    VS 生成自定义菜单

    在弹出的窗口中勾选 masm -> 确定

    VS 勾选 masm

  3. 在右侧解决方案资源管理器中,右键”源文件”文件夹 -> 添加 -> 新建项 -> 创建一个名为 main.asm 的文件(后缀必须是 .asm

    VS 添加 main.asm 文件

  4. 把项目顶部的配置改成 Release | x86(我们要生成 32 位程序,且 Release 模式不会有额外的调试代码)

  5. 右键 main.asm -> 属性,把项类型(Item Type) 改为 Microsoft Macro Assembler

    VS 设置 asm 文件项类型

  6. main.asm 的内容替换成:

.386                     ; 声明使用 80386 处理器指令集
.model flat, stdcall     ; 32位 Windows 必须的平坦内存模型

.code                    ; 代码段开始

_main PROC               ; 32位下,内部函数名加个下划线 _main
    ; 循环生成 4096 个 NOP
    REPT 4096
        nop
    ENDM

    ret
_main ENDP

END                      ; 结束
  1. 设置入口点:右键项目 -> 属性 -> 链接器(Linker) -> 高级(Advanced) -> 把入口点(Entry Point) 改为 _main

    VS 链接器设置入口点为 _main

  2. 关闭安全异常处理:同在链接器 -> 高级页面,把映像具有安全异常处理程序(Image Has Safe Exception Handlers) 改为 否(/SAFESEH:NO)

    VS 关闭 SAFESEH

  3. Ctrl+B 编译

  4. 用 x32dbg 打开生成的 exe,按 Alt+F9 跳到用户代码,你会看到一大片 nop,就是你的空白画布

Tip

和第一章一样,建议关闭 ASLR 和增量链接(项目属性 -> 链接器),这样每次编译后地址固定。是否启用 ASLR 取决于链接器的 /DYNAMICBASE 设置;如果在 MASM 项目里找不到这些选项,记得额外确认生成的 exe 是否真的关闭了地址随机化。

怎么用这块画布

Tip

在 x64dbg 里怎么找到那片 NOP 区域?程序加载后会停在系统断点,按 Alt+F9(执行到用户代码)就能跳到你的 NOP 区域。按 空格键,输入你想实验的指令(比如 mov eax, 0x12345678),回车确认。光标自动移到下一行,你可以继续输入下一条。输入完毕后在第一条按 F2 设断点,F9 运行到这里,然后 F8 单步执行,观察寄存器和标志位的变化。

后面每章的 x64dbg 实操都可以用这个程序来练习。写错了也不用怕,重新把 exe 拖进 x32dbg 就恢复原样了。

Warning

你手写的指令不能超出 NOP 区域的边界。4096 个 NOP = 4096 字节的空间,对练习来说绰绰有余。如果输入的指令太多超出了,可能会覆盖到后面的 ret,程序退出时就会出问题。遇到这种情况重新拖进去就行。

练习

  1. 假设 EBP = 0x012FF310mov eax, dword ptr [ebp-8] 读取的内存地址是多少?

    参考答案

    0x012FF308(即 0x012FF310 - 8)。

  2. 内存地址 0x012FF308 处存着 EF BE AD DE(按字节顺序)。作为 32 位整数读取,值是多少?

    参考答案

    0xDEADBEEF。内存窗口显示的是小端序(低字节在低地址),要反着读:DE AD BE EF -> 0xDEADBEEF

  3. ECX = 0x0040A000,EDX = 5mov eax, dword ptr [ecx + edx*4] 读取的地址是多少?

    参考答案

    0x0040A014(即 0x0040A000 + 5*4)。这就是数组 array[5](每个元素 4 字节)的地址。

  4. 打开你生成的 nop.exe,在第一个 NOP 处按空格,输入 mov dword ptr [ebp-4], 0x12345678。这条指令机器码占多少字节?(看机器码列)

    参考答案

    取决于具体编码,通常是 7 字节左右(C7 45 FC 78 56 34 12)。你可以观察:机器码最后的 78 56 34 12 就是要写入的值 0x12345678 的小端序表示。


目录