c++ - 对象在 assembly 级的x86中如何工作?

标签 c++ object assembly x86 vtable

我正在尝试了解对象在组装级别如何工作。对象如何精确地存储在内存中,成员函数如何访问它们?

(编者注:原始版本过于宽泛,并且一开始就对汇编和结构的工作方式感到困惑。)

最佳答案

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

结构存储为连续的内存块(if the compiler doesn't optimize it away or keep the member values in registers)。在struct对象中,其元素的地址按定义成员的顺序增加。 (来源:http://en.cppreference.com/w/c/language/struct)。我链接了C定义,因为在C++中,struct表示class(使用public:代替private:作为默认值)。

可以将structclass视为一个字节块,该字节块可能太大而无法容纳在寄存器中,但会被复制为“值”。 汇编语言没有类型系统。内存中的字节仅是字节,不需要任何特殊指令即可存储浮点寄存器中的double并将其重新加载到整数寄存器中。或者执行未对齐的加载,并获得1 int的最后3个字节和下一个的第一个字节。 struct只是在内存块顶部构建C的类型系统的一部分,因为内存块很有用。

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

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

您要编译的系统的ABI规则指定了插入填充的时间和位置,以确保每个成员都具有足够的对齐方式,即使您执行struct { char a; int b; };之类的操作(例如,在Linux和其他非Windows系统上使用的the x86-64 System V ABI也会指定该操作) int是一种32位类型,可在内存中进行4字节对齐 ABI可以使C和C++标准保持“实现依赖”的某些特征,因此该ABI的所有编译器都可以编写可调用的代码彼此的功能。)

请注意,您可以使用 offsetof(struct_name, member) 来查找有关结构布局的信息(在C11和C++ 11中)。另请参见C++ 11中的 alignof 或C11中的_Alignof

由于C规则不允许编译器为您对结构进行排序,因此程序员应合理地对结构成员进行排序,以避免浪费填充空间。 (例如,如果您有一些char成员,请将它们至少分成4个一组,而不是与较宽的成员交替。从大到小的排序是一个简单的规则,请记住,在常见平台上指针可能是64位或32位。)

有关ABI的更多详细信息,请参见https://stackoverflow.com/tags/x86/info。 Agner Fog的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(使用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指针作为隐式第一个参数传递(在SysV AMD64 ABI中的rdi中)。 m_b从struct/class的开始存储在4个字节处。请注意,巧妙地使用lea来实现后递增运算符,而将旧值保留在eax中。

由于inc_a是在类声明中定义的,因此不会发出任何代码。它被视为与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

有趣的事实:在大多数Intel CPU上,add m32, imm8inc m32更快(负载+ ALU运算符的微融合);很少有旧的Pentium4建议避免inc的情况之一仍然适用。 gcc始终避免使用inc,即使它可以节省代码大小且没有缺点:/INC instruction vs ADD 1: Does it matter?

虚拟函数分派(dispatch):
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中条目的顺序可能与类中声明的顺序匹配,因此在一个转换单元中对类声明重新排序将导致虚函数到达错误的目标。就像重新排序数据成员一样,它将更改类的ABI。

如果编译器具有更多信息,则可以取消对的调用虚拟化。例如如果可以证明foo *始终指向bar对象,则可以内联bar::inc_v()

当GCC能够确定编译时类型可能是什么时,GCC甚至会通过推测性的方式对进行虚拟化。在上面的代码中,编译器看不到任何从bar继承的类,因此可以肯定地认为bar*指向bar对象,而不是某些派生类。
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指针。只要派生类型没有覆盖该虚函数,它仍然可以使用内联的bar::inc_v()。覆盖其他虚拟函数不会影响这一功能,但是需要使用不同的vtable。

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

但这在某些用途上带来了一定的效率成本:C++虚拟分派(dispatch)仅通过指向对象的指针起作用,因此您不能拥有没有黑客攻击的多态数组,也不能拥有通过指针数组进行的昂贵间接调用(这会破坏许多硬件和软件优化) :Fastest implementation of simple, virtual, observer-sort of, pattern in c++?)。

如果您想要某种多态性/分派(dispatch),但仅针对一组封闭的类型(即在编译时已知的所有类型),则可以使用union + enum + switch std::variant<D1,D2>进行联合,并通过std::visit进行分派(dispatch),或者进行其他操作方法。另请参见Contiguous storage of polymorphic typesFastest implementation of simple, virtual, observer-sort of, pattern in c++?

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

使用struct不会强制编译器将东西实际放入内存中,这比小型数组或指向局部变量的指针要多。例如,按值返回struct的内联函数仍可以完全优化。

视情况规则适用:即使一个结构在逻辑上具有一些内存存储,编译器也可以使asm将所有需要的成员保留在寄存器中(并进行转换,这意味着寄存器中的值不对应于变量的任何值)或临时在C++抽象机中“运行”源代码)。
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传递并返回打包到寄存器中的小型结构。不同的ABI为此做出不同的选择。

关于c++ - 对象在 assembly 级的x86中如何工作?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/33556511/

相关文章:

ruby - 有没有一种聪明的方法来检查自定义对象是否在数组中?

javascript - 删除已过滤的对象 Javascript 数组

performance - 为什么有些编程语言比其他语言快?

c++ - 为什么在Visual Studio x64(MASM)上将gs段寄存器的地址设置为0x0000000000000000?

caching - 直接映射缓存示例

c++ - VectorList 类构造函数

c++ - 这个 bitset::count() 的实现是如何工作的?

java - For 循环跳过赋值

c++ - 如何将 Direct2D 渲染目标清除为完全透明

python - 为 Python 3.5 构建 Fortran 扩展或为 2.7 构建 C 扩展