GKLBB

当你经历了暴风雨,你也就成为了暴风雨

导航

应用安全 --- 逆向工程 之 C++类的本质

C++ 类 = C 结构体 + 专属于它的一批函数

1. 类里的数据 = C 结构体
class A {
    int x;
    int y;
};
在内存里 =struct A {
    int x;
    int y;
};
一模一样,没有任何区别。对象就是一块内存,布局完全相同。
2. 成员函数 = 专门操作这个结构体的 C 函数
你写:
void A::set(int v) { x = v; }
编译器内部理解成:
void A_set(struct A *this, int v) {
    this->x = v;
}
调用:
a.set(10);
变成:
A_set(&a, 10);
这就是全部真相。
3. 所谓 “面向对象”,汇编里只多了一件事
C++ 比 C 多的唯一一件事:
自动把对象地址当作隐藏第一个参数 this 传进去。
仅此而已。没有魔法,没有高级结构,没有玄学。
4. 扩展一句:带虚函数的类也没变本质
加了 virtual 之后:
结构体前面多了一个指针 vptr
虚函数表是一个函数指针数组
本质依然是:结构体 + 操作它的函数。
终极一句话(可以刻在脑子里)
C++ 类,就是给 C 结构体绑定了一批专用函数,
并自动把结构体地址当 this 传进去的语法糖。

 我用一个具体的demo演示

#include <iostream>

// 最小化的C++类示例
class SimpleClass {
private:
    int value; // 私有成员变量

public:
    // 构造函数
    SimpleClass(int v) : value(v) {}

    // 成员函数:获取值
    int getValue() const {
        return value;
    }

    // 成员函数:设置值
    void setValue(int v) {
        value = v;
    }

    // 成员函数:显示值
    void display() const {
        std::cout << "Value: " << value << std::endl;
    }
};

int main() {
    // 创建对象
    SimpleClass obj(42);
    
    // 显示初始值
    std::cout << "Initial value: " << obj.getValue() << std::endl;
    
    // 修改值
    obj.setValue(100);
    
    // 显示修改后的值
    std::cout << "Modified value: " << obj.getValue() << std::endl;
    
    // 使用display方法
    obj.display();
    
    return 0;
}

 

C++ 源码与 IDA 反编译对比分析
1. 整体结构对比
text

源码结构                          IDA反编译结构
─────────────────────────────────────────────────────
SimpleClass类                     独立函数形式
  ├── 构造函数                    SimpleClass::SimpleClass()
  ├── getValue()                  SimpleClass::getValue()
  ├── setValue()                  SimpleClass::setValue()
  └── display()                   SimpleClass::display()
2. 关键差异详细分析
2.1 类的本质还原
C++

// 源码:面向对象形式
class SimpleClass {
private:
    int value;  // 高层抽象
public:
    SimpleClass(int v) : value(v) {}
};

// IDA反编译:底层实现暴露
SimpleClass *__fastcall SimpleClass::SimpleClass(SimpleClass *this, int a2)
{
    *(_DWORD *)this = a2;  // 直接操作内存!
    return this;           // 返回this指针
}
核心发现:

类在编译后不存在,只剩下内存布局
value 成员变量 → 变成 *(_DWORD *)this(this偏移0处的4字节)
成员函数 → 普通函数,this指针作为隐式第一参数显式传入
2.2 内存布局分析
text

SimpleClass对象内存布局 (大小=4字节):
┌─────────────────────────┐
│  offset+0: value (int)  │  ← *(_DWORD *)this
│  [4 bytes]              │
└─────────────────────────┘

// IDA中对应:
v10[20]  // 栈上分配,实际只用4字节
         // 20字节是编译器对齐/调试信息导致
2.3 栈对象分配对比
C++

// 源码
SimpleClass obj(42);

// IDA反编译
_BYTE v10[20]; // [rsp+2Ch] [rbp-4h] BYREF
//     ↑                              ↑
//  栈上原始字节数组              标记为可引用(BYREF)

SimpleClass::SimpleClass((SimpleClass *)v10, 42);
////              将栈内存强转为对象指针
源码视角    编译器视角
SimpleClass obj    _BYTE v10[20] 栈内存
自动调用构造函数    显式调用 SimpleClass::SimpleClass(v10, 42)
obj.method()    SimpleClass::method(v10)
2.4 调用约定 __fastcall 分析
C++

__int64 __fastcall SimpleClass::getValue(SimpleClass *this)
//      ↑                                ↑
//  返回值用寄存器              this通过RCX寄存器传递(x64)

// x64寄存器传参规则:
// RCX = 第1参数 (this指针)
// RDX = 第2参数
// R8  = 第3参数
// R9  = 第4参数
2.5 成员函数逐一对比
构造函数
C++

// 源码
SimpleClass(int v) : value(v) {}

// IDA
SimpleClass *__fastcall SimpleClass::SimpleClass(SimpleClass *this, int a2)
{
    *(_DWORD *)this = a2;  // value = v
    return this;           // 源码中隐式,编译后显式返回this
}
getValue()
C++

// 源码
int getValue() const { return value; }

// IDA
__int64 __fastcall SimpleClass::getValue(SimpleClass *this)
{
    return *(unsigned int *)this;  // 读取this[0]处4字节
    //      ↑ 注意:用unsigned int,与源码int有符号性差异
}
⚠️ 类型信息丢失:int → unsigned int,IDA无法100%还原有符号性

setValue()
C++

// 源码
void setValue(int v) { value = v; }

// IDA
SimpleClass *__fastcall SimpleClass::setValue(SimpleClass *this, int a2)
{
    *(_DWORD *)this = a2;  // value = v
    return this;           // 源码返回void,但编译器仍传递this
}
⚠️ 返回值差异:源码void,IDA显示返回SimpleClass*
这是因为x64调用约定中RAX寄存器始终存有this,IDA误判为返回值

display()
C++

// 源码
void display() const {
    std::cout << "Value: " << value << std::endl;
}

// IDA
__int64 __fastcall SimpleClass::display(SimpleClass *this)
{
    v1 = std::operator<<<...>(refptr__ZSt4cout, "Value: ");
    v2 = std::ostream::operator<<(v1, *(unsigned int *)this);
    //   ↑ 链式调用被拆解为顺序的中间变量
    return std::ostream::operator<<(v2, refptr__ZSt4endl...);
}
2.6 流操作符还原对比
C++

// 源码(简洁的链式调用)
std::cout << "Initial value: " << obj.getValue() << std::endl;

// IDA(完全展开)
v3 = std::operator<<<std::char_traits<char>>(
         refptr__ZSt4cout,      // cout对象
         "Initial value: ");    // 字符串

Value = SimpleClass::getValue((SimpleClass *)v10);

v5 = std::ostream::operator<<(v3, Value);  // 输出整数

std::ostream::operator<<(                  // 输出endl
    v5, 
    refptr__ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
    //  ↑ Name mangling后的endl符号名
);
3. Name Mangling(名称修饰)
text

源码符号名                    编译后符号名(Mangled)
─────────────────────────────────────────────────────
std::endl              →  _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
std::cout              →  _ZSt4cout
SimpleClass::getValue  →  _ZN11SimpleClass8getValueEv
text

解码规则示例:_ZN11SimpleClass8getValueEv
_Z          = 名称修饰标记
N           = 命名空间/类开始
11SimpleClass = 11个字符的"SimpleClass"
8getValue   = 8个字符的"getValue"
E           = 命名空间/类结束
v           = 参数为void
4. 总结:编译过程信息损失表
特性    源码    IDA反编译    信息损失
类定义    class SimpleClass    无    ❌ 完全丢失
访问控制    private/public    无    ❌ 完全丢失
成员变量名    value    *(DWORD*)this    ❌ 丢失
类型有符号性    int    unsigned int    ⚠️ 部分丢失
链式调用    cout<<a<<b    拆分为v1,v2,v3    ⚠️ 结构改变
函数语义    const修饰    无    ❌ 丢失
构造/析构    自动调用    显式调用    ✅ 可识别
函数名    正常名称    需demangle    ⚠️ 可还原

 

我来定位 `main`、`SimpleClass` 的构造函数、`getValue`、`setValue`、`display` 这几个关键符号。拿到所有关键函数地址后,读取它们的反汇编:`main` (0x140001640)、`SimpleClass::SimpleClass` (0x140001730)、`getValue` (0x140001760)、`setValue` (0x140001780)、`display` (0x1400017B0)。素材齐全了。下面把这份 IDA 反汇编和源码逐行对照,把"C++ 类在机器层面的真相"讲清楚。

---

# C++ 类在底层到底长什么样——对照 IDA 反汇编逐个讲

## 一、先看个"总地图":源码里的 5 个成员 ↔ IDA 里的 5 个符号

| C++ 源码中的写法 | IDA 里变成的符号(修饰后) | 地址 |
|---|---|---|
| `SimpleClass::SimpleClass(int v)` | `_ZN11SimpleClassC1Ei` | `0x140001730` |
| `SimpleClass::getValue() const` | `_ZNK11SimpleClass8getValueEv` | `0x140001760` |
| `SimpleClass::setValue(int v)` | `_ZN11SimpleClass8setValueEi` | `0x140001780` |
| `SimpleClass::display() const` | `_ZNK11SimpleClass7displayEv` | `0x1400017B0` |
| `int main()` | `main` | `0x140001640` |

解密符号命名(Itanium ABI):

```
_ZN 11SimpleClass C1 E i        ← 11=类名长度, C1=完整构造器, E 结束, i=int参数
_ZNK11SimpleClass 8getValue Ev  ← K=const 成员, v=无参
_ZN 11SimpleClass 8setValue Ei
_ZNK11SimpleClass 7display  Ev
```

光从符号名就能看出:**成员函数跟普通函数没有本质区别**,只是 C++ 编译器把"所属类""参数表"一起编码进了函数名而已。

---

## 二、底层原理一句话先说清

> **C++ 里的"对象",在机器层面就是"一块裸内存";所谓"对象.方法()",在机器层面就是"把对象的首地址当成隐藏的第 1 个参数(`this`),去调用一个普通函数"。**

Windows x64 的 ABI(也就是你这个 exe 用的)规定:前 4 个参数依次放进 `RCX, RDX, R8, R9`。C++ 再加一条:**`this` 永远占用第 1 个参数的位置**。所以:

```
obj.getValue()         → getValue(RCX=&obj)
obj.setValue(100)      → setValue(RCX=&obj, RDX=100)
SimpleClass obj(42);   → SimpleClass::C1(RCX=&obj, RDX=42)
```

下面我们就一条条去 IDA 里验证。

---

## 三、`main` 逐行拆解(0x140001640)

```
.text:140001640 push rbp
.text:140001641 mov  rbp, rsp
.text:140001644 sub  rsp, 40h          ; ★ 在栈上直接腾出空间放对象 obj
                                        ;   对象 obj 就在 rbp-0x4 这个位置(见下文)
```

**关键点 ①:对象不是"new 出来的",而是"栈上的一段普通内存"**。`SimpleClass` 只有一个 `int value`(4 字节),所以它在机器层面就是 4 个字节,占位符 `[rbp-4]`。

### 3.1 `SimpleClass obj(42);`

```
.text:140001654 lea  rax, [rbp+var_4]   ; rax = &obj      ← 取对象地址
.text:140001658 mov  edx, 2Ah           ; rdx = 42        ← 构造参数 v
.text:14000165D mov  rcx, rax           ; rcx = this = &obj
.text:140001660 call _ZN11SimpleClassC1Ei   ; ★ 调构造函数
```

**翻译**:这三条汇编把 `rax` 指向栈上 `obj` 所占的那 4 个字节,然后以"第 1 参数=this、第 2 参数=42"的形式调用构造函数。源码一句 `SimpleClass obj(42);`,底层就变成了 *"开一块内存 + 调一次普通函数"*。

### 3.2 `obj.getValue()`

```
.text:140001665 lea  rax, [rbp+var_4]       ; &obj
.text:140001669 mov  rcx, rax               ; rcx = this
.text:14000166C call _ZNK11SimpleClass8getValueEv  ; 返回值 42 放在 EAX 里
.text:140001671 mov  esi, eax               ; 保存到 esi 备用

; 然后把字符串和数字喂给 std::cout << ... << ...
.text:140001673 mov  edx, offset "Initial value: "
.text:140001678 mov  rcx, offset std::cout
.text:14000167F call std::operator<<(std::ostream&, char const*)

.text:140001684 mov  edx, esi             ; 把前面保存的返回值传给 << int
.text:140001686 mov  rcx, rax             ; rcx = 上一次 << 的返回值(也就是 cout 自己)
.text:140001689 call std::ostream::operator<<(int)
; ... 后面再 << endl
```

**两个关键观察**1. 成员函数的返回值跟普通函数一模一样,放在 `EAX/RAX` 里。
2. `cout << ...` 这种看起来很"面向对象"的流式写法,底层其实是**链式调用**:每次 `<<` 返回 `cout` 本身,下一次 `<<` 的 `this` 就用这个返回值——这是为什么你会看到 `mov rcx, rax` 这种"把前一个 call 的返回值当下一个 call 的 this 用"的模式。

### 3.3 `obj.setValue(100);`

```
.text:1400016BC lea  rax, [rbp+var_4]
.text:1400016C0 mov  edx, 64h           ; 64h = 100  ← 参数 v
.text:1400016C5 mov  rcx, rax           ; this = &obj
.text:1400016C8 call _ZN11SimpleClass8setValueEi
```

和普通函数调用**没有任何区别**。唯一"面向对象"的味道,就是第 1 参数固定是对象地址而已。

### 3.4 `obj.display();`

```
.text:140001719 lea  rax, [rbp+var_4]
.text:14000171D mov  rcx, rax
.text:140001720 call _ZNK11SimpleClass7displayEv
```

完全一样的套路。

---

## 四、四个成员函数的反汇编

### 4.1 构造函数 `SimpleClass::SimpleClass(int v)` @ `0x140001730`

```
.text:140001730 push rbp
.text:140001731 mov  rbp, rsp
.text:140001734 mov  [rbp+arg_0], rcx    ; 保存 this
.text:140001738 mov  [rbp+arg_8], edx    ; 保存参数 v
.text:14000173B mov  rax, [rbp+arg_0]    ; rax = this
.text:14000173F mov  edx, [rbp+arg_8]    ; edx = v
.text:140001742 mov  [rax], edx          ; ★★ *this = v;   (把 v 写到 this 指向的 4 字节)
.text:140001744 nop
.text:140001745 pop  rbp
.text:140001746 retn
```

**通俗翻译**:构造函数的本质,就是"**把初始化值写进这块内存**"。源码 `value(v)` 编译下来就是一条 `mov [rax], edx`。没有什么魔法。

**为什么是 `[rax]` 而不是 `[rax+偏移]`?** 因为 `value` 是这个类里唯一的成员、偏移就是 0;如果你加一个 `int value2;`,它就会变成 `mov [rax+4], ...`。成员变量"住在哪里",在编译期就已经固定成偏移常量了。

### 4.2 `getValue()` @ `0x140001760`

```
.text:140001763 mov  [rbp+arg_0], rcx    ; 保存 this
.text:140001767 mov  rax, [rbp+arg_0]    ; rax = this
.text:14000176B mov  eax, [rax]          ; ★ eax = *this  (读 value)
.text:14000176D pop  rbp
.text:14000176E retn
```

**通俗翻译**:`return value;` = "从 this 指向的内存里读 4 字节,放进 EAX 返回"**为什么 `const` 没在汇编里留痕?** 因为 `const` 是**编译期约束**:它只阻止你在源码里修改 `*this`,到机器层面早就"信息擦除"了——`getValue()` 的汇编跟一个"只读全局函数"毫无差别。

### 4.3 `setValue(int v)` @ `0x140001780`

```
.text:140001783 mov  [rbp+arg_0], rcx    ; 保存 this
.text:140001787 mov  [rbp+arg_8], edx    ; 保存参数 v
.text:14000178A mov  rax, [rbp+arg_0]
.text:14000178E mov  edx, [rbp+arg_8]
.text:140001791 mov  [rax], edx          ; ★ *this = v
.text:140001793 nop
.text:140001794 pop  rbp
.text:140001795 retn
```

**它和构造函数几乎一模一样**,这并非巧合:

> 对 C++ 编译器来说,**"构造一个只含 int 的对象""赋值 int 字段" 在指令层面是同一件事**——都是往同一个偏移写同一个值。二者的差别完全在源码语义层(生命周期开始 vs 生命周期中赋值),编译后就都塌缩成了 `mov [rax], edx`。

### 4.4 `display() const` @ `0x1400017B0`

```
.text:1400017B0 push rbp
.text:1400017B1 push rbx               ; 要用到 rbx(被调方保存寄存器),先保存
.text:1400017B2 sub  rsp, 28h          ; 给 Win64 的 "shadow space" 留位子
.text:1400017B6 lea  rbp, [rsp+20h]
.text:1400017BB mov  [rbp+20h], rcx    ; 保存 this

; --- std::cout << "Value: " ---
.text:1400017BF mov  edx, "Value: "
.text:1400017C4 mov  rcx, offset std::cout
.text:1400017CB call std::operator<<(ostream&, char const*)

; --- << value ---
.text:1400017D0 mov  rbx, rax          ; 上一个 << 返回的 cout 存进 rbx
.text:1400017D3 mov  rax, [rbp+20h]    ; rax = this
.text:1400017D7 mov  eax, [rax]        ; ★ eax = this->value
.text:1400017D9 mov  edx, eax          ; 第 2 参数 = value
.text:1400017DB mov  rcx, rbx          ; 第 1 参数 = 上一次的 cout
.text:1400017DE call std::ostream::operator<<(int)

; --- << std::endl ---
.text:1400017E3 mov  rdx, offset std::endl<char, std::char_traits<char>>
.text:1400017EA mov  rcx, rax
.text:1400017ED call std::ostream::operator<<(std::ostream& (*)(std::ostream&))
```

**这里有一个极其重要的原理揭示**:你看 `std::endl` 不是一个"字符"而是 `offset` 一个**函数的地址**> C++ 的 `std::endl` 本质上是一个函数 `ostream& endl(ostream&)`,`cout << endl` 被解析成"调用一个接收函数指针的 `operator<<` 重载",那个 `operator<<` 内部再调用该函数指针去真正完成"输出 `\n` 并刷新缓冲区"的工作。

这也解释了一个常见面试题:*"为什么 `"\n"` 比 `std::endl` 快?"* ——因为 `"\n"` 走的是"输出一个字符串"重载,而 `endl` 走的是"再 call 一个函数指针(里面还包含 flush)"重载。你在 IDA 里就能看到这差别。

---

## 五、把源码一行行翻译成机器动作

| 源码 | 机器层面发生了什么 |
|---|---|
| `class SimpleClass { int value; };` | 在编译期告诉编译器:"这个类型大小 = 4 字节,`value` 字段偏移 = 0"。运行期无任何代码产生。 |
| `SimpleClass obj(42);` | 在栈上预留 4 字节,把其地址作 `this` 传给构造函数,构造函数写入 42。 |
| `obj.getValue()` | `call getValue(this=&obj)` → 返回 `*this` 放进 `EAX`。 |
| `obj.setValue(100)` | `call setValue(this=&obj, v=100)` → 往 `*this` 写 100。 |
| `obj.display()` | `call display(this=&obj)` → 读 `*this` 并喂给 `cout << ...` 链。 |
| `private:` | **纯编译期权限检查**,0 条机器指令。 |
| `const` 成员函数 | **纯编译期限制**(禁止修改 `*this`),0 条机器指令差别。 |

---

## 六、"" 的底层本质——4 条结论

通过这次汇编对照,我们可以把 C++ 类的底层原理归纳成 **4 条铁律****结论 1:类 = "一块内存 + 一组跟内存首地址相关的函数"**

`obj` 本身在内存里只有 4 字节,没有藏任何"函数指针""类型信息"(因为没有虚函数)。**成员函数不占对象的空间**,它们是独立存在于 `.text` 段里的普通函数。

**结论 2:`this` 是一个"隐藏的第 1 参数"**

这是 C++ 成员函数唯一真正"特殊"的地方。源码里"隐身"的 `this`,到汇编层永远是 `RCX`(在 Win64 ABI 下)。

**结论 3:成员变量访问 = 固定偏移寻址**

`this->value` 在底层就是 `[rcx+偏移]`。偏移在**编译期**就定死了。这也解释了为什么 C++ 访问成员变量和 C 访问结构体成员速度完全一样——**它们就是同一回事****结论 4:访问控制、`const` 等修饰符是"编译期魔法",运行期不存在**

`private`、`public`、`const`、`override`、`final` 这些关键字,在机器码里一个字节都不占。它们只在编译器的检查阶段起作用。IDA 里你看不到任何 `private` 的痕迹——因为根本没有。

---

## 七、最后给你一个"记忆口诀"

如果你用 C 的视角重写一遍这个例子,会立刻看穿 C++ 的把戏:

```c
// 等价的 C 代码(这就是编译器内部看到的样子)
typedef struct {
    int value;
} SimpleClass;

void SimpleClass_ctor    (SimpleClass *this, int v)     { this->value = v; }
int  SimpleClass_getValue(const SimpleClass *this)       { return this->value; }
void SimpleClass_setValue(SimpleClass *this, int v)     { this->value = v; }
void SimpleClass_display (const SimpleClass *this)       { printf("Value: %d\n", this->value); }

int main() {
    SimpleClass obj;
    SimpleClass_ctor(&obj, 42);
    printf("Initial value: %d\n", SimpleClass_getValue(&obj));
    SimpleClass_setValue(&obj, 100);
    printf("Modified value: %d\n", SimpleClass_getValue(&obj));
    SimpleClass_display(&obj);
    return 0;
}
```

**这份 C 代码的汇编,和你 IDA 里看到的那份 C++ 代码的汇编,在结构上是一一对应的。**

所以,**C++ 类只是 C 的语法糖**——只不过编译器替你自动:① 把对象地址变成第 1 参数;② 把函数名加上类名前缀防止冲突;③ 编译期帮你做权限/const 检查。仅此而已。至于虚函数、多态那些"真正花哨的东西",就是在此基础上再加一个 vptr + vtable(就像我们之前分析 Dobby 的 `CPURegister` 那样),原理依然是"一块内存 + 一组函数"

 

posted on 2026-04-19 19:00  GKLBB  阅读(9)  评论(0)    收藏  举报