x86汇编语言中,对象是如何工作的?

17

我试图了解对象在汇编级别是如何工作的。对象如何存储在内存中,成员函数又如何访问它们?


1
如果使用C/C++,通常可以使用-S选项编译代码,许多编译器将提供高级语言生成的汇编输出。创建一个带有简单对象的程序,使用-S选项进行编译,然后查看输出(通常具有.S扩展名)。这将使您能够回答自己的问题。 ;) - enhzflep
2
你还可以使用http://gcc.godbolt.org/,以查看已剥离杂乱内容(汇编指令等)的asm输出。它甚至可以着色以尝试将源代码行映射到asm行。 - Peter Cordes
@PeterCordes - 听起来不错,感谢新的书签。 "colourise"功能相当不错-比思考代码要快得多,也更容易。 - enhzflep
2个回答

35

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

结构体被存储为一块连续的内存区域(如果编译器没有优化掉或将成员值保留在寄存器中)。在结构体对象内部,其元素的地址按照定义成员的顺序递增。(来源: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 ++实现中)。块内的布局始终相同(除非编译器优化结构体局部变量的实际内存;请参见下面内联返回结构体的函数示例)。

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

编译系统的ABI规则指定何时以及在哪里插入填充以确保每个成员具有足够的对齐方式,即使你做了这样一个事情:struct { char a; int b; };(例如,x86-64 System V ABI,用于Linux和其他非Windows系统,指定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的优秀网站包括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++; }

编译为 (使用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存储在结构体/类的开头4字节处。请注意聪明地使用lea来实现后增量运算符,将旧值留在eax中。
没有为inc_a生成代码,因为它是在类声明内定义的。它被视为非成员函数的inline版本。如果它真的很大且编译器决定不对其进行内联,则可以发出其独立版本。
当涉及到虚成员函数时,C++对象与C结构体的真正区别在于每个对象的副本必须携带额外的指针(用于指向其实际类型的虚表)。
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++; }

编译为

  ; 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

有趣的事实:在大多数英特尔CPU上,add m32,imm8inc m32更快(负载+ALU uops的微融合);这是旧奔腾4建议避免使用inc的少数情况之一。不过,即使没有缺点,gcc始终避免使用inc,即使可以节省代码大小 :/ INC指令与ADD 1:有关吗?


虚函数调度:
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甚至会在编译时猜测类型,对虚函数进行静态绑定。在上述代码中,编译器无法看到任何继承自“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++虚拟调度只能通过对象的指针进行,因此您无法在没有hack或昂贵的间接通过指针数组(这会破坏很多硬件和软件优化:在C ++中实现简单、虚拟、观察者模式的最快方法?)的情况下拥有一个多态数组。

如果你想要一种多态性/分发,但只针对一组已知的类型(即所有类型在编译时已知),你可以手动使用 union + enum + switch 或者使用 std::variant<D1,D2> 来创建一个联合体,并使用 std::visit 进行分发,或者其他各种方式。另请参见 多态类型的连续存储C++中简单、虚拟、观察者模式的最快实现?

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

使用struct并不会强制编译器将内容实际存储在内存中,就像小数组或指向本地变量的指针一样。例如,返回值为struct的内联函数仍然可以完全优化。

适用于as-if规则:即使结构体逻辑上有一些内存存储,编译器也可以生成汇编代码,将所有所需成员保存在寄存器中(并进行转换,这意味着寄存器中的值不对应于在运行源代码的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;
}

这个程序使用g++ 5.4编译后得到:

# 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对此做出了不同的选择。

3
非常好的答案。总结一下:1)汇编中的所有内容都是二进制“值”(字节、短字、整数、浮点数等),存储在某个“偏移量”上(与可执行文件地址相关或与当前堆栈指针/缓冲区指针相关)。2)"数组"和"结构体"是在此基础上的抽象:它们是一个数据“块”(在一个偏移量上),每个元素或字段都有另一个偏移量。3)"对象"是在结构体之上的一个抽象:它们有每个成员的偏移量,并且它们还有一个用于虚拟方法的"vtable"指针。 - paulsm4
@PeterCordes 在 英特尔软件开发优化手册 的 3.5.1.1 节中指出,为了提高速度,应该优先选择 ADDSUB 指令,而不是分别使用 INCDEC 指令,因为 ADDSUB 可以通过覆盖所有标志位来消除部分标志依赖。 - owacoder
2
@owacoder:P4已经不再相关,因此该部分已过时。当前的英特尔和AMD CPU在除非您执行依赖于CF的操作之外,不会出现部分标志延迟的问题,例如在inc之后进行某些操作。在这种情况下,ADD将破坏CF。在这种情况下最好使用LEA r,[r + 1] / JECXZ,例如在同时执行带有进位加法并更新循环计数器的循环中。请参见https://dev59.com/FNeP0ogBFxS5KdRjoIka#32087095作为例子。 - Peter Cordes
“inc”与“add”的更新。INC指令与ADD 1:有关系吗?涵盖了我所知道的所有情况,其中“inc”比ADD更差(P4、Silvermont / KNL以及在其他英特尔CPU上使用内存目标)。 - Peter Cordes
1
你从哪里得到了 jmp [QWORD PTR [rax]] 语法?双括号看起来非常奇怪。 - ecm
1
@ecm:GCC -masm=intel 输出是从之前问题中的 Godbolt 链接复制粘贴的。显然,这是 jmp *(%rax) 的 GAS Intel 语法等效形式,其中我猜测额外的括号替换了 *,以提醒这是一个间接跳转。你可以将其视为内存间接寻址,以获取从跳转目标处获取的代码,而不仅仅是要加载到 RIP 中的值。但这与它用于寄存器间接跳转的 jmp rax(AT&T jmp *%rax)不一致 :/。我假设外部的 [] 是可选的。 - Peter Cordes

8
旧版C++编译器生成的是C代码而非汇编代码。下面这个类:
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”变成了结构体类型的数据项。与C++相比,所有函数在C中都有一个额外的元素:“this”指针。该“struct”的第一个元素是指向类所有函数列表的指针。

因此,下面的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"),构造函数或析构函数的调用以及结构元素的初始化。


非常好。我一开始认为“这并没有回答问题”,但它是彼得答案的一个很好的补充 - Jongware
1
如果我没记错的话,free() 函数不能保证与 new 分配的内存兼容,反之亦然。是的,它们都在 C++ 中可用,但你应该将它们视为不同的内存分配器。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接