我试图了解对象在汇编级别是如何工作的。对象如何存储在内存中,成员函数又如何访问它们?
我试图了解对象在汇编级别是如何工作的。对象如何存储在内存中,成员函数又如何访问它们?
类和结构体的存储方式完全相同,除非它们有虚函数成员。在这种情况下,会有一个隐式的vtable指针作为第一个成员(见下文)。
结构体被存储为一块连续的内存区域(如果编译器没有优化掉或将成员值保留在寄存器中)。在结构体对象内部,其元素的地址按照定义成员的顺序递增。(来源:http://en.cppreference.com/w/c/language/struct)。我链接了C语言的定义,因为在C++中struct
意味着class
(默认为public:
而不是private:
)。
struct
或class
视为一块字节,可能太大而无法适应寄存器,但作为“值”复制。汇编语言没有类型系统;存储在内存中的字节只是字节,并且不需要任何特殊指令即可从浮点寄存器存储double
并重新加载到整数寄存器中。或者进行非对齐加载并获取1个int
的最后3个字节和下一个字节的第一个字节。由于内存块很有用,因此struct
只是在内存块上构建C的类型系统的一部分。static
)、动态(malloc
或new
)或自动存储(本地变量:堆栈上的临时变量或寄存器,在普通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
。char
成员,请将它们分组至少4个,而不是与更宽的成员交替。从大到小排序是一个简单的规则,记住指针可能在常见平台上为64位或32位。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
版本。如果它真的很大且编译器决定不对其进行内联,则可以发出其独立版本。
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,imm8
比inc 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()
。
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
对象。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
ADD
和 SUB
指令,而不是分别使用 INC
和 DEC
指令,因为 ADD
和 SUB
可以通过覆盖所有标志位来消除部分标志依赖。 - owacoderinc
之后进行某些操作。在这种情况下,ADD
将破坏CF。在这种情况下最好使用LEA r,[r + 1] / JECXZ
,例如在同时执行带有进位加法并更新循环计数器的循环中。请参见https://dev59.com/FNeP0ogBFxS5KdRjoIka#32087095作为例子。 - Peter Cordesjmp [QWORD PTR [rax]]
语法?双括号看起来非常奇怪。 - ecm-masm=intel
输出是从之前问题中的 Godbolt 链接复制粘贴的。显然,这是 jmp *(%rax)
的 GAS Intel 语法等效形式,其中我猜测额外的括号替换了 *
,以提醒这是一个间接跳转。你可以将其视为内存间接寻址,以获取从跳转目标处获取的代码,而不仅仅是要加载到 RIP 中的值。但这与它用于寄存器间接跳转的 jmp rax
(AT&T jmp *%rax
)不一致 :/。我假设外部的 []
是可选的。 - Peter Cordesclass foo {
int m_a;
void inc_a(void);
...
};
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"),构造函数或析构函数的调用以及结构元素的初始化。
free()
函数不能保证与 new
分配的内存兼容,反之亦然。是的,它们都在 C++ 中可用,但你应该将它们视为不同的内存分配器。 - Peter Cordes