一个程序点击事件的汇编指令与解析 - 目标变量的真实虚拟地址 = 逐级解引用并叠加偏移后的结果
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 指针)每次运行都会变化,主要原因如下:
-
堆分配的随机性 + ASLR 影响 :
MFC 框架创建对话框对象时会通过 new 或内部堆分配器在堆上分配内存。现代 Windows 下,堆的分配地址会受到 ASLR 的间接影响(堆管理器基址被随机化),加上分配顺序等因素,导致同一对象的地址每次启动都不相同。
-
模块加载基址的随机化 :
更重要的是,对话框对象的指针(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 地址随机
因此使用多级指针链:从最稳定的模块基址开始,逐级解引用 + 加偏移,动态计算出每次都不同的最终地址。
执行顺序
-
先执行 C :
mov eax,[r8](在 UpdateData(TRUE) 里)
→ 把编辑框当前显示的数字(比如"666")转换成整数,写入成员变量 m_nNum(地址 [this+0x178])
-
然后执行 B :
inc [rbx+0x178](在你的 OnBnClickedButtonAdd 函数里)
→ 直接把内存中的 m_nNum 加 1,变成 667
-
最后执行 A :
mov [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按钮"过程,实际执行顺序为:
-
第三段 → 第 7FF737F72E66 行的
mov eax,[r8](UpdateData(TRUE):从编辑框读取当前值到 m_nNum)
-
第二段 → 第 7FF737F535F3 行的
inc [rbx+0x178](核心:m_nNum += 1;)
-
第一段 → 第 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 = 959035FA98(m_nNum的地址)。
但问题是:rbx的值(959035F920)每次启动会变(ASLR导致对话框对象地址随机),所以需要找rbx的稳定基址------这就是二级偏移的由来。
2. 第二步:找rbx的稳定基址(二级基址) + 二级偏移
rbx的值(对话框对象地址)本身也是某个稳定基址 + 固定偏移得到的,这个稳定基址就是NumAddOne.exe的模块基址 (程序自身的加载基址,是进程中最稳定的基址)。
NumAddOne.exe + XXXXX(比如NumAddOne.exe + 0x266F20)------这个0x266F20就是二级偏移 ;
二级基址:NumAddOne.exe的模块基址(比如7FF737F50000,每次启动会变,可以动态获取)。
3. 第三步:最终的多级偏移链
m_nNum最终地址 = [[二级基址 + 二级偏移] + 一级偏移]
其中方括号 [] 表示读取内存中存储的指针值 (解引用),不是简单算术相加。
详细正确过程如下:
-
第一级 :计算全局指针变量的地址并读取其内容
最稳定基址:NumAddOne.exe 的模块基址(比如 7FF737F50000)
二级偏移:0x266F20(固定不变)
操作:
先计算地址:7FF737F50000 + 0x266F20 = 7FF737F76F20(这是一个全局静态变量的地址)
读取 [7FF737F76F20] 这个地址中存储的值 → 得到对话框对象的 this 指针(rbx 的值,例如 000000959035F920) -
第二级 :使用上一步读取到的指针值 + 一级偏移
中间指针值: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 → 填充字节,调试器断点常用 |