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

前端之家收集整理的这篇文章主要介绍了c – 对象如何在汇编级别的x86中工作?前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。
我试图了解对象如何在汇编级别工作.对象如何存储在内存中,以及成员函数如何访问它们?

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

解决方法

类的存储方式与结构完全相同,除非它们具有虚拟成员.在这种情况下,有一个隐含的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中意味着类(使用public:作为默认值而不是私有:).

将结构或类视为一个字节块,可能太大而无法放入寄存器,但会被复制为“值”.汇编语言没有类型系统;内存中的字节只是字节,它不需要任何特殊指令来存储来自浮点寄存器的double并将其重新加载到整数寄存器中.或者做一个未对齐的加载并得到1 int的最后3个字节和下一个的第一个字节.结构只是在内存块之上构建C类型系统的一部分,因为内存块很有用.

这些字节块可以具有静态(全局或静态),动态(malloc或new)或自动存储(本地变量:临时在堆栈上或寄存器中,在普通cpu上的正常C/C++实现中).块中的布局是相同的(除非编译器优化了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.

程序员可以很好地命令struct成员避免在填充上浪费空间,因为C规则不允许编译器为你排序结构. (例如,如果你有一些char成员,将它们放在至少4个组中,而不是与更宽的成员交替.从大到小的排序是一个简单的规则,记住指针在公共平台上可能是64位或32位.)

ABIs等的更多细节可以在https://stackoverflow.com/tags/x86/info找到.Agner Fog的excellent site包括ABI指南以及优化指南.

类(带成员函数)

  1. class foo {
  2. int m_a;
  3. int m_b;
  4. void inc_a(void){ m_a++; }
  5. int inc_b(void);
  6. };
  7.  
  8. int foo::inc_b(void) { return m_b++; }

compiles to(使用http://gcc.godbolt.org/):

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

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

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

C对象与C结构确实不同的地方是涉及虚拟成员函数时.对象的每个副本都必须携带一个额外的指针(对于其实际类型的vtable).

  1. class foo {
  2. public:
  3. int m_a;
  4. int m_b;
  5. void inc_a(void){ m_a++; }
  6. void inc_b(void);
  7. virtual void inc_v(void);
  8. };
  9.  
  10. void foo::inc_b(void) { m_b++; }
  11.  
  12. class bar: public foo {
  13. public:
  14. virtual void inc_v(void); // overrides foo::inc_v even for users that access it through a pointer to class foo
  15. };
  16.  
  17. void foo::inc_v(void) { m_b++; }
  18. void bar::inc_v(void) { m_a++; }

compiles to

  1. ; This time I made the functions return void,so the asm is simpler
  2. ; The in-memory layout of the class is now:
  3. ; vtable ptr (8B)
  4. ; m_a (4B)
  5. ; m_b (4B)
  6. foo::inc_v():
  7. add DWORD PTR [rdi+12],1 # this_2(D)->m_b,ret
  8. bar::inc_v():
  9. add DWORD PTR [rdi+8],1 # this_2(D)->D.2657.m_a,ret
  10.  
  11. # if you uncheck the hide-directives Box,you'll see
  12. .globl foo::inc_b()
  13. .set foo::inc_b(),foo::inc_v()
  14. # since inc_b has the same definition as foo's inc_v,so gcc saves space by making one an alias for the other.
  15.  
  16. # you can also see the directives that define the data that goes in the vtables

有趣的事实:添加m32,在大多数Intel cpu上,imm8比inc m32更快(负载ALU的微融合uop);旧的Pentium4建议避免使用的罕见情况之一仍然适用. gcc总是避免使用inc,即使它会节省代码大小而没有缺点:/ INC instruction vs ADD 1: Does it matter?

函数调度:

  1. void caller(foo *p){
  2. p->inc_v();
  3. }
  4.  
  5. mov rax,QWORD PTR [rdi] # p_2(D)->_vptr.foo,p_2(D)->_vptr.foo
  6. 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对象而不是某些派生类是一个很好的选择.

  1. void caller_bar(bar *p){
  2. p->inc_v();
  3. }
  4.  
  5. # gcc5.5 -O3
  6. caller_bar(bar*):
  7. mov rax,QWORD PTR [rdi] # load vtable pointer
  8. mov rax,QWORD PTR [rax] # load target function address
  9. cmp rax,OFFSET FLAT:bar::inc_v() # check it
  10. jne .L6 #,add DWORD PTR [rdi+8],1 # inlined version of bar::inc_v()
  11. ret
  12. .L6:
  13. 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 + switch手动完成,或者用std :: variant< D1,D2>制作一个union和std :: visit to dispatch,或其他各种方式.另见Contiguous storage of polymorphic typesFastest implementation of simple,pattern in c++?.

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

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

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

  1. struct pair {
  2. int m_a;
  3. int m_b;
  4. };
  5.  
  6. pair addsub(int a,int b) {
  7. return {a+b,a-b};
  8. }
  9.  
  10. int foo(int a,int b) {
  11. pair ab = addsub(a,b);
  12. return ab.m_a * ab.m_b;
  13. }

compiles (with g++ 5.4) to

  1. # The non-inline definition which actually returns a struct
  2. addsub(int,int):
  3. lea edx,[rdi+rsi] # add result
  4. mov eax,edi
  5. sub eax,esi # sub result
  6. # then pack both struct members into a 64-bit register,as required by the x86-64 SysV ABI
  7. sal rax,32
  8. or rax,rdx
  9. ret
  10.  
  11. # But when inlining,it optimizes away
  12. foo(int,int):
  13. lea eax,[rdi+rsi] # a+b
  14. sub edi,esi # a-b
  15. imul eax,edi # (a+b) * (a-b)
  16. ret

请注意,即使按值返回结构也不一定会将其放入内存中. x86-64 SysV ABI传递并返回打包在一起的小结构.不同的ABI为此做出了不同的选择.

猜你在找的C&C++相关文章