对象在 x86 中如何在汇编级别工作?

How do objects work in x86 at the assembly level?

我正在尝试了解对象在程序集级别的工作方式。对象究竟是如何存储在内存中的,成员函数如何访问它们?

(编者注:原始版本 方式 过于宽泛,并且首先对汇编和结构的工作方式有些困惑。)

类 的存储方式与结构完全相同,除非它们具有虚拟成员。在那种情况下,有一个隐式 vtable 指针作为第一个成员(见下文)。

结构存储为连续的内存块 (if the compiler doesn't optimize it away or keep the member values in registers). Within a struct object, addresses of its elements increase in order in which the members were defined. (source: http://en.cppreference.com/w/c/language/struct)。我链接了 C 定义,因为在 C++ 中 struct 表示 class(默认为 public: 而不是 private:)。

可以将 structclass 视为一个字节块,它可能太大而无法放入寄存器,但它被复制为 "value"。 汇编语言没有类型系统;内存中的字节只是 bytes 并且不需要任何特殊指令即可从浮点寄存器存储 double 并将其重新加载到整数寄存器中。或者进行未对齐加载并获取 1 int 的最后 3 个字节和下一个字节的第一个字节。 struct 只是在内存块之上构建 C 类型系统的一部分,因为内存块很有用。

这些字节块可以有静态(全局或static)、动态(mallocnew)或自动存储(局部变量:临时在堆栈上或在寄存器,在普通 CPU 上的普通 C/C++ 实现中)。无论如何,块内的布局都是相同的(除非编译器优化了结构局部变量的实际内存;参见下面的示例,内联一个 returns 结构的函数。)

结构或class与任何其他对象相同。在 C 和 C++ 术语中,即使 int 也是一个对象:http://en.cppreference.com/w/c/language/object。即,您可以 memcpy 的连续字节块(C++ 中的非 POD 类型除外)。

您正在编译的系统的 ABI 规则指定插入填充的时间和位置,以确保每个成员都有足够的对齐,即使您执行类似 struct { char a; int b; }; 的操作(例如,the x86-64 System V ABI,用于 Linux 和其他非 Windows 系统指定 int 是一种 32 位类型,在内存中获得 4 字节对齐。ABI 是什么确定 C 和 C++ 标准留下的一些内容 "implementation dependent",以便该 ABI 的所有编译器都可以编写可以调用彼此函数的代码。)

请注意,您可以在 C++11 中使用 offsetof(struct_name, member) to find out about struct layout (in C11 and C++11). See also alignof,或在 C11 中使用 _Alignof

由程序员很好地排序结构成员以避免在填充上浪费 space,因为 C 规则不允许编译器为您对结构进行排序。 (例如,如果您有一些 char 成员,请将它们放在至少 4 个组中,而不是与更广泛的成员交替。从大到小排序是一个简单的规则,记住指针可能是 64 位或 32 位的通用平台。)

有关 ABI 等的更多详细信息,请访问 https://whosebug.com/tags/x86/info. Agner Fog's excellent site,其中包括 ABI 指南以及优化指南。


类(有成员函数)

class foo {
  int m_a;
  int m_b;
  void inc_a(void){ m_a++; }
  int inc_b(void);
};

int foo::inc_b(void) { return m_b++; }

compiles to (using http://gcc.godbolt.org/):

foo::inc_b():                  # args: this in RDI
    mov eax, DWORD PTR [rdi+4]      # eax = this->m_b
    lea edx, [rax+1]                # edx = eax+1
    mov DWORD PTR [rdi+4], edx      # this->m_b = edx
    ret

如您所见,this 指针作为隐式第一个参数传递(在 rdi 中,在 SysV AMD64 ABI 中)。 m_b 存储在从 struct/class 开始的 4 个字节处。注意 lea 的巧妙使用来实现 post 递增运算符,将旧值保留在 eax.

没有发出 inc_a 的代码,因为它是在 class 声明中定义的。它被视为与 inline 非成员函数相同。如果它真的很大并且编译器决定不内联它,它可以生成它的独立版本。


C++ 对象与 C 结构的真正不同之处在于涉及 虚拟成员函数。该对象的每个副本都必须携带一个额外的指针(指向其实际类型的 vtable)。

class foo {
  public:
  int m_a;
  int m_b;
  void inc_a(void){ m_a++; }
  void inc_b(void);
  virtual void inc_v(void);
};

void foo::inc_b(void) { m_b++; }

class bar: public foo {
 public:
  virtual void inc_v(void);  // overrides foo::inc_v even for users that access it through a pointer to class foo
};

void foo::inc_v(void) { m_b++; }
void bar::inc_v(void) { m_a++; }

compiles to

  ; This time I made the functions return void, so the asm is simpler
  ; The in-memory layout of the class is now:
  ;   vtable ptr (8B)
  ;   m_a (4B)
  ;   m_b (4B)
foo::inc_v():
    add DWORD PTR [rdi+12], 1   # this_2(D)->m_b,
    ret
bar::inc_v():
    add DWORD PTR [rdi+8], 1    # this_2(D)->D.2657.m_a,
    ret

    # if you uncheck the hide-directives box, you'll see
    .globl  foo::inc_b()
    .set    foo::inc_b(),foo::inc_v()
    # since inc_b has the same definition as foo's inc_v, so gcc saves space by making one an alias for the other.

    # you can also see the directives that define the data that goes in the vtables

有趣的事实:add m32, imm8 在大多数 Intel CPU 上比 inc m32 快(负载的微融合 + ALU uops);避免 inc 的旧 Pentium4 建议仍然适用的罕见情况之一。但是,gcc 总是避免 inc,即使它可以节省代码大小而没有任何缺点:/


虚函数调度:

void caller(foo *p){
    p->inc_v();
}

    mov     rax, QWORD PTR [rdi]      # p_2(D)->_vptr.foo, p_2(D)->_vptr.foo
    jmp     [QWORD PTR [rax]]         # *_3

(这是一个优化的尾调用:jmp 替换 call/ret)。

mov 从对象加载 vtable 地址到寄存器中。 jmp 是内存间接跳转,即从内存加载新的 RIP 值。 跳转目标地址是vtable[0],即vtable中的第一个函数指针。如果有另一个虚函数,mov不会改变,但是jmp 将使用 jmp [rax + 8].

vtable 中条目的顺序大概与 class 中的声明顺序相匹配,因此在一个翻译单元中重新排序 class 声明将导致虚函数转到错误的目标。就像重新排序数据成员会改变 class 的 ABI 一样。

如果编译器有更多信息,它可以去虚拟化调用。例如如果它能证明 foo * 总是指向一个 bar 对象,它就可以内联 bar::inc_v().

GCC 甚至会 推测去虚拟化 当它可以在编译时找出 可能 的类型时。在上面的代码中,编译器看不到任何继承自 bar 的 classes,所以可以肯定 bar* 指向一个 bar 对象,而不是比一些衍生的 class.

void caller_bar(bar *p){
    p->inc_v();
}

# gcc5.5 -O3
caller_bar(bar*):
    mov     rax, QWORD PTR [rdi]      # load vtable pointer
    mov     rax, QWORD PTR [rax]      # load target function address
    cmp     rax, OFFSET FLAT:bar::inc_v()  # check it
    jne     .L6       #,
    add     DWORD PTR [rdi+8], 1      # inlined version of bar::inc_v()
    ret
.L6:
    jmp     rax               # otherwise tailcall the derived class's function

记住,foo * 实际上可以指向派生的 bar 对象,但是 bar * 不允许指向纯 foo 对象。

虽然这只是一个赌注;虚函数的部分要点是可以扩展类型而无需重新编译在基类型上运行的所有代码。这就是为什么它必须比较函数指针并在错误时回退到间接调用(在本例中为 jmp tailcall)。编译器启发式决定何时尝试它。

请注意,它正在检查实际的函数指针,而不是比较 vtable 指针。只要派生类型没有覆盖 that 虚函数,它仍然可以使用内联的 bar::inc_v()。覆盖 other 虚函数不会影响这个,但需要不同的 vtable。

允许在不重新编译的情况下进行扩展对于库来说很方便,但也意味着大型程序各部分之间的耦合更松散(即您不必在每个文件中包含所有头文件)。

但这会为某些用途带来一些效率成本:C++ 虚拟分派只能通过指向对象的 指针 工作,因此如果没有 hack 或昂贵的间接寻址,就无法拥有多态数组通过指针数组(这会破坏很多硬件和软件优化:)。

如果你想要某种多态性/分派但只针对一组封闭的类型(即编译时所有已知的类型),你可以使用 union + enum 手动完成+ switch,或使用 std::variant<D1,D2> 进行合并,使用 std::visit 进行调度,或使用其他各种方式。另请参阅 多态类型的连续存储


对象并不总是存储在内存中。

使用 struct 并不强制编译器实际将内容放入内存,就像小数组或指向局部变量的指针一样。例如,returns a struct 值的内联函数仍然可以完全优化。

as-if 规则适用:即使结构 逻辑上 有一些内存存储,编译器可以使 asm 将所有需要的成员保存在寄存器中(并进行转换意味着寄存器中的值不对应于 C++ 抽象机中变量或临时值的任何值 "running" 源代码)。

struct pair {
  int m_a;
  int m_b;
};

pair addsub(int a, int b) {
  return {a+b, a-b};
}

int foo(int a, int b) {
  pair ab = addsub(a,b);
  return ab.m_a * ab.m_b;
}

那个compiles (with g++ 5.4) to:

# The non-inline definition which actually returns a struct
addsub(int, int):
    lea     edx, [rdi+rsi]  # add result
    mov     eax, edi
    sub     eax, esi        # sub result
                            # then pack both struct members into a 64-bit register, as required by the x86-64 SysV ABI
    sal     rax, 32
    or      rax, rdx
    ret

# But when inlining, it optimizes away
foo(int, int):
    lea     eax, [rdi+rsi]    # a+b
    sub     edi, esi          # a-b
    imul    eax, edi          # (a+b) * (a-b)
    ret

请注意,即使按值返回结构也不一定将其放入内存。 x86-64 SysV ABI 将 returns 小结构打包到寄存器中。不同的ABI为此做出不同的选择。

(抱歉,由于代码示例,我无法 post 将此作为对 Peter Cordes 回答的“评论”,因此我必须 post 此作为“回答”。)

旧的 C++ 编译器生成 C 代码而不是汇编代码。以下class:

class foo {
  int m_a;
  void inc_a(void);
  ...
};

... 将产生以下 C 代码:

struct _t_foo_functions {
  void (*inc_a)(struct _class_foo *_this);
  ...
};
struct _class_foo {
  struct _t_foo_functions *functions;
  int m_a;
  ...
};

一个“class”变成一个“struct”,一个“object”变成一个struct类型的数据项。 C 中的所有函数都有一个附加元素(与 C++ 相比):“this”指针。 “struct”的第一个元素是指向 class.

的所有函数列表的指针

所以下面的 C++ 代码:

m_x=1; // implicit this->m_x
thisMethod(); // implicit this->thisMethod()
myObject.m_a=5;
myObject.inc_a();
myObjectp->some_other_method(1,2,3);

... 在 C:

中将如下所示
_this->m_x=1;
_this->functions->thisMethod(_this);
myObject.m_a=5;
myObject.functions->inc_a(&myObject);
myObjectp->functions->some_other_method(myObjectp,1,2,3);

使用那些旧的编译器,C 代码被翻译成汇编程序或机器代码。你只需要知道在汇编代码中结构是如何处理的,以及函数指针的调用是如何处理的...

虽然现代编译器不再将 C++ 代码转换为 C 代码,但生成的汇编代码看起来仍然与您首先执行 C++ 到 C 的步骤相同。

“new”和“delete”会导致函数调用内存函数(你可以调用“malloc”或“free”来代替),构造函数或析构函数的调用以及结构元素的初始化。