一个程序点击事件的汇编指令与解析 - 目标变量的真实虚拟地址 = 逐级解引用并叠加偏移后的结果

一个程序点击事件的汇编指令与解析 - 目标变量的真实虚拟地址 = 逐级解引用并叠加偏移后的结果

flyfish

理论

变量的真实地址是通过从模块基址开始,沿着多级指针链逐级解引用并叠加偏移计算得到的,而不是把所有偏移简单相加。

最终地址 = [ [ [模块基址 + offset1] + offset2 ] + offset3 ] ... + offsetN

方括号 [] 表示读取该地址处存储的 64 位(或 32 位)指针值

实践

一个简单的Windows程序

假如有个程序NumAddOne.exe,点个按钮,实现累加

cpp 复制代码
// 1. 先将控件的当前值同步到变量
UpdateData(TRUE);
// 2. 数字加1
m_nNum += 1;
// 3. 将变量的新值同步回控件
UpdateData(FALSE);
复制代码
m_nNum对应的地址是959035FA98
以下操作访问了959035FA98
标记 地址 指令 对应 C++ 代码阶段 执行时间点
A 7FF7381010A3 89 3B mov [rbx],edi UpdateData(FALSE) ------ 把新值显示到编辑框 第3步(最后执行)
B 7FF737F535F3 FF 83 78010000 inc [rbx+0x178] m_nNum += 1; 第2步
C 7FF737F72E66 41 8B 00 mov eax,[r8] UpdateData(TRUE) ------ 从编辑框读取旧值 第1步(最先执行)

进程视角下的内存编号(如 959035FA98),而非物理内存的实际地址,是 虚拟内存地址

程序每次启动时,存储目标数值的虚拟内存地址均会发生动态变化,该现象源于 Windows 操作系统启用的地址空间布局随机化(ASLR,Address Space Layout Randomization) 安全机制 ------ 系统会对进程的栈 / 堆内存基址、可执行模块(如 NumAddOne.exe)的加载基址以及其他区域进行随机化分配,导致基于这些基址偏移的目标数值最终虚拟地址随程序每次启动呈现不同值(例如某次启动地址为 959035FA98,数值为 7),但数值在内存中的相对偏移(相对于模块基址或其他稳定基址)保持固定。

目标数值(m_nNum)是 CNumAddOneDlg 类的一个成员变量 ,其存储位置属于对话框对象实例的内存布局。

对话框对象实例本身是由 MFC 框架动态创建的(通常在堆上分配),其地址(this 指针)每次运行都会变化,主要原因如下:

  1. 堆分配的随机性 + ASLR 影响

    MFC 框架创建对话框对象时会通过 new 或内部堆分配器在堆上分配内存。现代 Windows 下,堆的分配地址会受到 ASLR 的间接影响(堆管理器基址被随机化),加上分配顺序等因素,导致同一对象的地址每次启动都不相同。

  2. 模块加载基址的随机化

    更重要的是,对话框对象的指针(this)通常被 MFC 框架保存在可执行模块的**数据段(.data 或 .bss)**中的一个全局/静态变量里。

    该全局变量的地址 = NumAddOne.exe 模块基址 + 固定偏移(如 0x266F20)

    而模块基址本身受 ASLR 随机化(每次启动加载到不同位置),因此:存指针的全局变量地址随机 → 读取出的 this 指针随机 → 成员变量 m_nNum 的地址(this + 0x178)也随机。

栈内存的情况

栈内存:局部变量 存储在栈上。程序每次启动或每次函数调用时,栈基址/栈帧位置都会不同(线程栈基址受 ASLR 影响,栈帧偏移由调用链决定),导致栈上局部变量的地址每次都不相同。
但在本例中,m_nNum 不是局部变量,而是类成员变量,不存储在栈上。

堆内存的情况

堆内存:如果变量是通过 new/malloc 等动态分配的,其分配地址会受堆管理器的随机化影响(堆管理器本身受 ASLR 影响),加上分配时机不同,导致地址每次启动都不相同。

进程看到的地址(如 959035FA98)是虚拟地址 ,每次启动变化的主要原因是 ASLR

NumAddOne.exe 模块基址随机(ASLR)

数据段中存储对话框指针的全局变量地址随机(如模块基址 + 0x266F20)

读取该全局变量得到对话框对象地址(this)随机

this + 固定偏移 0x178 → m_nNum 地址随机

因此使用多级指针链:从最稳定的模块基址开始,逐级解引用 + 加偏移,动态计算出每次都不同的最终地址。

执行顺序

  1. 先执行 Cmov eax,[r8]

    (在 UpdateData(TRUE) 里)

    → 把编辑框当前显示的数字(比如"666")转换成整数,写入成员变量 m_nNum(地址 [this+0x178])

  2. 然后执行 Binc [rbx+0x178]

    (在你的 OnBnClickedButtonAdd 函数里)

    → 直接把内存中的 m_nNum 加 1,变成 667

  3. 最后执行 Amov [rbx],edi

    (在 UpdateData(FALSE) 里,调用了运行库的 write_integer 函数)

    → 把新的 m_nNum(667)格式化成字符串"667",写回编辑框显示出来

执行 A:mov [rbx],edi

asm 复制代码
;═══════════════════════════════════════════════════════════════════════════════
; 第一段代码:属于 Visual C++ 运行库 (UCRT) 中的数字转字符串函数
; 完整函数名:__crt_stdio_input::input_processor<wchar_t,...>::write_integer
; 作用:将一个整数值(这里是 m_nNum 的新值)写入临时缓冲区,供后续 sprintf/wsprintf 格式化成字符串
; 执行时机:UpdateData(FALSE) 内部调用,用于把成员变量的值显示到编辑框
;═══════════════════════════════════════════════════════════════════════════════
7FF73810109E  48 89 3B                mov     qword ptr [rbx], rdi  
                                              ; 将 64 位寄存器 rdi 的值写入 [rbx] 指向的内存
                                              ; 这是一条备选路径(针对 64 位整数的情况),实际几乎不执行

7FF7381010A1  EB 0C                   jmp     7FF7381010AF         
                                              ; 无条件跳转,跳过下面的 32 位路径,直接到公共出口
                                              ; (如果前面是 64 位分支,这里跳过 32 位和 16 位代码)

7FF7381010A3  89 3B                   mov     dword ptr [rbx], edi  
                                              ; ★ 实际执行的主要路径
                                              ; 将 32 位寄存器 edi 中的整数值(即当前的 m_nNum 值)写入 [rbx] 指向的缓冲区
                                              ; edi 包含了要格式化输出的整数(例如 667)
                                              ; [rbx] 是临时字符缓冲区的起始地址
                                              ; 这条指令对应 UpdateData(FALSE) 中把 m_nNum 转换为字符串并显示的过程

7FF7381010A5  EB 08                   jmp     7FF7381010AF         
                                              ; 无条件跳转,跳过下面的 16 位路径,到公共出口

7FF7381010A7  66 89 3B                mov     word ptr [rbx], di   
                                              ; 将 16 位寄存器 di 的值写入 [rbx]
                                              ; 备选路径(针对 16 位整数的情况),实际几乎不执行

; 说明:这几条 mov + jmp 构成了一个典型的"运行时宽度选择"代码段。
; 编译器根据实际整数类型(16/32/64 位)动态选择执行哪条 mov。
; 对于 int 类型(32 位),始终执行地址 7FF7381010A3 的那条 mov [rbx],edi。

执行 B:inc [rbx+0x178]

asm 复制代码
;═══════════════════════════════════════════════════════════════════════════════
; 第二段代码:用户自己的按钮处理函数(最核心的部分)
; 函数名:CNumAddOneDlg::OnBnClickedButtonAdd
; 作用:实现"加1"按钮的完整逻辑
;═══════════════════════════════════════════════════════════════════════════════
7FF737F535EB  48 8B D9                mov     rbx, rcx             
                                              ; 将 rcx(x64 调用约定中第一个参数,this 指针)保存到 rbx
                                              ; rbx 是非易失性寄存器,后续代码还会继续使用 this 指针
                                              ; 此时 rbx = CNumAddOneDlg*(对话框对象实例指针)

7FF737F535EE  E8 39720200             call    CWnd::UpdateData     
                                              ; 调用 UpdateData(TRUE)
                                              ; 功能:将编辑框当前显示的文本转换为整数,并写入成员变量 m_nNum
                                              ; 对应 C++ 代码的第一行:UpdateData(TRUE);

7FF737F535F3  FF 83 78010000          inc     dword ptr [rbx+00000178]  
                                              ; ★ 重要指令
                                              ; 将 [rbx + 0x178] 处的 32 位整数值加 1
                                              ; rbx 此时仍是 this 指针,因此 [rbx + 0x178] 正是成员变量 m_nNum 的真实内存地址
                                              ; 编译器直接把 m_nNum += 1; 优化为单条 inc 指令(最高效率)
                                              ; 对应 C++ 代码的第二行:m_nNum += 1;

7FF737F535F9  33 D2                   xor     edx, edx             
                                              ; 将 edx 清零
                                              ; 为接下来的 UpdateData(FALSE) 调用准备第二个参数(bSaveAndValidate = FALSE)

7FF737F535FB  48 8B CB                mov     rcx, rbx             
                                              ; 再次将 this 指针(rbx)放入 rcx
                                              ; 因为 x64 调用约定要求第一个参数放在 rcx
                                              ; 准备调用 UpdateData(FALSE)
                                              ; (紧接着会有 call 指令,虽然在本段代码中未显示,但实际会执行)

执行 C:mov eax,[r8]

asm 复制代码
;═══════════════════════════════════════════════════════════════════════════════
; 第三段代码:MFC 框架内部的 DDX_Text 机制(数据交换函数)
; 函数名:DDX_Text(位于 MFC 库中)
; 作用:在 UpdateData(TRUE) 时,将控件(编辑框)的当前值读取并写入成员变量
;═══════════════════════════════════════════════════════════════════════════════
7FF737F72E5F  4C 89 44 24 20          mov     qword ptr [rsp+20], r8  
                                              ; 将 r8(指向控件当前数值结构的指针)临时保存到栈上偏移 0x20 的位置
                                              ; 这是为了在不同路径中保持 r8 的值一致

7FF737F72E64  EB 07                   jmp     7FF737F72E6D         
                                              ; 无条件跳转,跳过下面的 32 位读取路径,直接到公共代码
                                              ; (类似第一段的路径选择机制)

7FF737F72E66  41 8B 00                mov     eax, dword ptr [r8]    
                                              ; ★ 实际执行的主要路径
                                              ; 从 r8 指向的控件内部结构读取 32 位整数值(编辑框当前显示的数字)
                                              ; 例如读取用户看到的"666",将其作为整数放入 eax
                                              ; 对应 UpdateData(TRUE) 中从控件读取数值到变量的过程

7FF737F72E69  89 44 24 20             mov     dword ptr [rsp+20], eax  
                                              ; 将 eax 中的整数值写回到栈上偏移 0x20 的位置
                                              ; MFC 的 DDX 机制会随后把这个值写入真正的成员变量(即 this+0x178 的 m_nNum)

7FF737F72E6D  4C 8D 05 B4D41A00       lea     r8, [rip + 0x1AD4B4]    
                                              ; 加载一个错误提示字符串的地址(例如转换失败时的提示信息)
                                              ; 供后续可能的异常处理使用(如果文本无法转换为数字时显示错误)

执行顺序与对应关系)

一次完整的"点击加1按钮"过程,实际执行顺序为:

  1. 第三段 → 第 7FF737F72E66 行的 mov eax,[r8]

    (UpdateData(TRUE):从编辑框读取当前值到 m_nNum)

  2. 第二段 → 第 7FF737F535F3 行的 inc [rbx+0x178]

    (核心:m_nNum += 1;)

  3. 第一段 → 第 7FF7381010A3 行的 mov [rbx],edi

    (UpdateData(FALSE):将新值格式化并显示到编辑框)

RAX RBX RCX RDX RSI RDI RBP RSP 等等

寄存器 英文原意 中文直译 缩写来历
RAX Accumulator Register 累加器 最早 4004/8008 时代就叫 A = Accumulator,所有算术运算默认都用它
RBX Base Register 基址寄存器 8086 时代叫 BX = Base,用来放数组的基地址(比如 mov bx, offset array)
RCX Counter Register 计数器 8086 时代叫 CX = Counter,最经典用途是 loop 指令自动减一计数(loop 指令专门看 CX)
RDX Data Register 数据寄存器 8086 时代叫 DX = Data,乘除法时和高 32 位放在 DX 里(比如 mul ax → 结果高位在 DX,低位在 AX)
RSI Source Index 源索引 8086 时代专门用于字符串操作(movsb、lodsb 等)的"源"地址(SI = Source Index)
RDI Destination Index 目的索引 8086 时代字符串操作的"目的"地址(DI = Destination Index)
RBP Base Pointer (also called Frame Pointer) 基指针 / 栈帧指针 8086 叫 BP = Base Pointer,用来指向当前栈帧的基底(方便访问局部变量和参数)
RSP Stack Pointer 栈指针 8086 叫 SP = Stack Pointer,永远指向栈顶
R8~R15 General Purpose Register 8 ~ 15 通用寄存器 8~15 x86-64 新增的 8 个寄存器,Intel 懒得起名字了,直接 R8-R15(R = Register)
RIP Instruction Pointer 指令指针 64 位时代的 PC(Program Counter),Intel 改名叫 RIP(Instruction Pointer Register)
RFLAGS Flags Register 标志寄存器 原来叫 FLAGS(16 位)/EFLAGS(32 位),64 位叫 RFLAGS

演变表

年代 寄存器名字 叫法
1974 4004/8008 只有 A(Accumulator)
1978 8086/8088(16 位) AX BX CX DX SI DI BP SP
1985 80386(32 位) EAX EBX ECX EDX ESI EDI EBP ESP + EFLAGS + EIP
2003 x86-64(64 位) RAX RBX RCX RDX RSI RDI RBP RSP + R8~R15 + RIP + RFLAGS

E = Extended(32 位扩展)

R = Register(64 位时代统一前缀)

详细的说

asm 复制代码
7FF737F535F3 FF 83 78 01 00 00 inc dword ptr [rbx+178h]

代码中m_nNum += 1实现

部分 含义 & 作用
7FF737F535F3 这条汇编指令在代码段的存储地址(属于NumAddOne.exe模块,只读,受ASLR轻微影响);
FF 83 78 01 00 00 指令的机器码(CPU能直接执行的二进制,十六进制显示),对应inc [rbx+178h]
inc 汇编指令助记符:自增1 ,是m_nNum +=1的直接体现;
dword ptr 数据类型说明:操作的是4字节(双字)整数 ,正好对应C++中int m_nNum的类型;
[rbx+178h] 内存寻址:rbx基址寄存器178h(即0x178)是偏移量 ,合起来表示「取rbx的值 + 0x178这个地址中的数据」------这个地址就是m_nNum的存储地址。

基址+多级偏移的逻辑

看到的m_nNum地址(比如959035FA98)是最终虚拟地址 ,但它每次启动会变(ASLR),而基址+多级偏移是:
找到稳定不变的基址 + 固定的偏移链,动态计算每次启动都变化的最终地址

1. 第一步:明确最终地址 = 一级基址(rbx) + 一级偏移(0x178)

从汇编[rbx+178h]可知:

一级基址:rbx的值(比如图中RBX=000000959035F920,这是对话框对象的this指针地址);

一级偏移:0x178(固定不变,是m_nNum相对于对话框对象this指针的偏移);

最终地址:959035F920 + 0x178 = 959035FA98m_nNum的地址)。

但问题是:rbx的值(959035F920)每次启动会变(ASLR导致对话框对象地址随机),所以需要找rbx的稳定基址------这就是二级偏移的由来。

2. 第二步:找rbx的稳定基址(二级基址) + 二级偏移

rbx的值(对话框对象地址)本身也是某个稳定基址 + 固定偏移得到的,这个稳定基址就是NumAddOne.exe的模块基址 (程序自身的加载基址,是进程中最稳定的基址)。
NumAddOne.exe + XXXXX(比如NumAddOne.exe + 0x266F20)------这个0x266F20就是二级偏移

二级基址:NumAddOne.exe的模块基址(比如7FF737F50000,每次启动会变,可以动态获取)。

3. 第三步:最终的多级偏移链

m_nNum最终地址 = [[二级基址 + 二级偏移] + 一级偏移]

其中方括号 [] 表示读取内存中存储的指针值 (解引用),不是简单算术相加

详细正确过程如下:

  1. 第一级 :计算全局指针变量的地址并读取其内容

    最稳定基址:NumAddOne.exe 的模块基址(比如 7FF737F50000)

    二级偏移:0x266F20(固定不变)

    操作:

    先计算地址:7FF737F50000 + 0x266F20 = 7FF737F76F20(这是一个全局静态变量的地址)
    读取 [7FF737F76F20] 这个地址中存储的值 → 得到对话框对象的 this 指针(rbx 的值,例如 000000959035F920)

  2. 第二级 :使用上一步读取到的指针值 + 一级偏移

    中间指针值:959035F920(rbx 的值)

    一级偏移:0x178(固定不变)

    操作:959035F920 + 0x178 = 959035FA98(这才是 m_nNum 的最终地址)

不能直接把 0x266F20 和 0x178 简单相加 ,因为 0x266F20 指向的是一个存储指针的变量,必须先读出其内容。

ASLR的随机化是分层的

最外层(最稳定):模块基址(ASLR 影响整个模块)

中间层:对话框对象地址(rbx的值,随机化程度高)

最内层:m_nNum地址(rbx+0x178,随rbx变化)。

多级偏移是逐级读取内存值 + 叠加偏移的链式过程,不是简单算术求和

级别 操作方式 说明
第0级 NumAddOne.exe 模块基址 最稳定,每次启动变化,可通过 API 动态获取
第1级偏移 + 0x266F20 计算全局静态变量的地址(该变量存储对话框对象的指针)
第1级解引用 读取该地址的内容 得到对话框对象(this 指针,rbx 的值)
第2级偏移 + 0x178 得到 m_nNum 成员变量的最终地址

语法参考

指令 全称 含义 例子(地址+解释)
mov Move 复制 / 赋值 mov rbx, rcx → 把 this 指针复制给 rbx 保存起来
inc Increment by 1 变量加 1 inc dword ptr [rbx+178h] → m_nNum += 1
dec Decrement by 1 变量减 1 dec eax → 计数器减 1(循环常用)
add Add 加法 add rax, 8 → 指针往后跳 8 字节
sub Subtract 减法 sub rsp, 32 → 函数开头给栈腾出 32 字节阴影空间
lea Load Effective Address 取地址(不解引用) lea rcx, [rip+0x12345] → 把当前指令后面某个字符串地址装进 rcx
jmp Jump 无条件跳转 jmp 7FF7381010AF → 直接跳走(前面那几条 mov 的分支选择)
je Jump if Equal 等于时跳转(ZF=1) je loc_xxx → if(a==b) 跳转
jne Jump if Not Equal 不等于时跳转 jne short loc_xxx → if(a!=0) 继续
jg Jump if Greater 有符号大于时跳转 jg short loc_xxx → if(a > b) 跳转
jl Jump if Less 有符号小于 jl short loc_xxx → if(a < b) 跳转
ja Jump if Above 无符号大于 常见于循环计数
jb Jump if Below 无符号小于 常见于数组越界检查
call Call 调用函数 call CWnd::UpdateData → 调用 UpdateData(TRUE)
ret Return 函数返回 ret → OnBnClickedButtonAdd 执行完返回
xor Exclusive OR 异或(最常用:清零神器) xor edx, edx → 把 edx 清零(比 mov edx,0 更快)
and Bitwise AND 按位与(常用于掩码) and rax, 0xFF → 取低 8 位
or Bitwise OR 按位或(常用于置位) or eax, 1 → 把最低位设为 1
test Test 逻辑与(只设标志位不存结果) test eax, eax → 判断 eax 是否为 0(最常见写法)
cmp Compare 比较(做减法但不保存结果) cmp eax, 5 → 后面接 je/jne 判断
push Push 把值压入栈 push rbx → 函数开头保存 rbx(调用约定要求)
pop Pop 从栈弹出 pop rbx → 函数结尾恢复 rbx
nop No Operation 什么都不做(占位/对齐 nop → 填充字节,调试器断点常用
相关推荐
2501_918126911 天前
nes游戏语言是6502,有没有一种方法可以实现,开发另一种更高效的汇编语言,替代6052,并本土化,弯道超过nes的底层语言?
汇编·硬件工程·个人开发
啊森要自信1 天前
【C语言】 C语言文件操作
c语言·开发语言·汇编·stm32·单片机
云qq1 天前
x86操作系统19——键盘驱动
linux·c语言·汇编
_Voosk1 天前
C指针存储字符串为何不能修改内容
c语言·开发语言·汇编·c++·蓝桥杯·操作系统
天途小编2 天前
融合空域相关法规核心条款汇编
汇编·无人机
天途小编2 天前
无人机相关国家根本条例核心汇编
汇编·无人机
2301_789015622 天前
C++:模板进阶
c语言·开发语言·汇编·c++
Hollis Arthur4 天前
mips栈帧详解
开发语言·汇编·学习·mips
fengye2071614 天前
板凳----------(枯藤 )vs2019+win10(第四章-3)
汇编