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

时间:2015-11-05 23:06:30

标签: c++ assembly x86

我正在尝试了解对象在程序集层面的工作方式。对象如何存储在内存中,以及成员函数如何访问它们?

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

2 个答案:

答案 0 :(得分:19)

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

结构存储为连续的内存块(if the compiler doesn't optimize it away or keep the member values in registers)。在结构对象中,其元素的地址按成员的定义顺序增加。 (来源: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局部变量的实际内存;请参阅下面的内联返回结构的函数的示例。)

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

您正在编译的系统的ABI规则指定插入填充的时间和位置,以确保每个成员具有足够的对齐,即使您执行struct { char a; int b; };之类的操作(例如,the x86-64 System V ABI在Linux和其他非Windows系统上使用,指定int是32位类型,在内存中获得4字节对齐。 ABI是指C和C ++标准的东西保留"依赖于实现",以便该ABI的所有编译器都可以创建可以相互调用的代码。)

请注意,您可以使用offsetof(struct_name, member)来了解struct layout(在C11和C ++ 11中)。另请参阅C ++ 11中的alignof或C11中的_Alignof

由程序员很好地命令struct成员以避免在填充上浪费空间,因为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指针作为隐式的第一个参数传递(在rys中,在SysV AMD64 ABI中)。 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

有趣的事实:add m32, imm8在大多数英特尔CPU上都比inc m32快(加载+ ALU uops的微融合);旧的Pentium4避免inc的建议仍然适用的极少数情况之一。 gcc总是避免使用inc,即使它会保存代码大小而没有任何缺点:/ INC instruction vs ADD 1: Does it matter?

虚拟功能调度:

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 {1}}将使用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 ++虚拟调度只能通过指针到对象,所以你不能拥有没有黑客的多态数组,或者通过数组进行昂贵的间接寻址指针(失败了许多硬件和软件优化:Fastest implementation of simple, virtual, observer-sort of, pattern in c++?)。

如果您想要某种多态/分派,但仅适用于一组封闭的类型(即在编译时都已知),您可以使用union + enum + switchstd::variant<D1,D2>手动执行此操作建立联盟并std::visit发送或以其他各种方式发送。另请参阅Contiguous storage of polymorphic typesFastest implementation of simple, virtual, observer-sort of, pattern in c++?

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

使用struct并不强制编译器实际将内容放入内存,不只是小数组或指向局部变量的指针。例如,按值返回struct的内联函数仍可以完全优化。

as-if规则适用:即使struct 逻辑有一些内存存储,编译器也可以使asm将所有需要的成员保存在寄存器中(并进行转换,表示寄存器中的值不对应于C ++抽象机器中变量或临时值的任何值&#34;运行&#34;源代码。)

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

答案 1 :(得分:3)

(抱歉,由于代码示例,我无法将此作为&#34;评论&#34;发布给Peter Cordes&#39;因此我必须将此发布为&#34;回答&#34 ;)

旧的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;
  ...
};

A&#34; class&#34;成为&#34; struct&#34;,&#34;对象&#34;成为结构类型的数据项。所有函数在C中都有一个额外的元素(与C ++相比):&#34; this&#34;指针。 &#34;结构的第一个元素&#34;是指向该类所有函数列表的指针。

以下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->some_other_method(myObjectp,1,2,3);

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

虽然现代编译器不再将C ++代码转换为C代码,但生成的汇编程序代码仍然与您首先执行C ++ - to-C步骤的方式相同。

&#34;新&#34;和&#34;删除&#34;将导致函数调用内存函数(您可以调用&#34; malloc&#34;或&#34; free&#34;代替),构造函数或析构函数的调用以及结构元素的初始化。