「现代C++设计魅力」虚函数继承-thunk技术初探
简介:工作中使用LLDB调试器调试这一段C++多继承程序的时候,发现通过lldb print(expression命令的别名) 命令获取的指针地址和实际理解的C++的内存模型的地址不一样。那么到底是什么原因呢?
作者 | 扬阜
来源 | 阿里技术公众号
一 问题背景
1 实践验证
工作中使用LLDB调试器调试这一段C++多继承程序的时候,发现通过lldb print(expression命令的别名) 命令获取的指针地址和实际理解的C++的内存模型的地址不一样。那么到底是什么原因呢?程序如下:
通过lldb print命令获取的地址如下图:
按正常的理解的C++内存模型:pDerived转换为Base 类型pBase,地址偏移了16,是没问题的。
pDerived转化为VBaseA,由于共用了首地址为0x0000000103407f30,一样可以理解。pDerived转化为Base,地址偏移了16个字节(sizeof(VBaseA))为0x0000000103407f40,也是符合预期的。
但是pDerived转化为VBase 类型pBaseB内存地址应该偏移24,为0x0000000103407f48;而不是0x0000000103407f30(对象的首地址),这个到底是什么原因引起的的呢?
2 验证引发的猜测
对于上面的这段代码
Base 类中没有虚函数,VBaseB 中有虚函数test和foo,猜测如下
2.含有虚函数的(含有虚表的)基类指针,在类型转换时,编译器实际上没有做地址的偏移,还是指向派生类,并没有指向实际的VBaseB类型。
二 现象带来的问题
1.有虚函数的(含有虚表的)基类指针,在派生类类型转换为有虚函数的基类时,编译器背后有做真实的地址偏移吗?
2.如果做了偏移
- 那C++中在通过基类指针调用派生类重写的虚函数以及通过派生类指针调用虚函数的时候,编译器是如何保证这两种调用this指针的值是一样的,以确保调用的正确性的?
- 那为什么LLDB expression获取的地址是派生类对象的首地址呢?
3.如果没有做偏移,那是如何通过派生类的指针调用基类成员变量和函数的?
三 现象核心原因
- 编译器背后和普通的非虚函数继承一样,也做了指针的偏移。
- 做了指针偏移,C++ 中基类对象指针调用派生类对象时,编译器通过thunk技术来实现每次参数调用和参数返回this地址的调整。
- LLDB expression显示的是派生类对象的首地址(0x0000000103407f30),而不是偏移后基类对象的首地址(0x0000000103407f48),是由于LLDB调试器在expression向用户展示的时候,对于虚函数继承的基类指针LLDB内部会通过summary format来对要获取的结果进行格式化。summary format时,会根据当前的内存地址获取C++运行时的动态类型和地址,来向用户展示。
四 证实结论过程
1 指针类型转换时编译器是否做了偏移?
汇编指令分析
基于上面的猜测,通过下面运行时反汇编的程序,来验证上面的猜测:
在开始反汇编程序之前,有一些下面要用到的汇编知识的普及。如果熟悉,可以忽略跳过。
注意:由于小编使用的是mac操作系统,所以处理器使用的是AT&T语法;和Intel语法不一样。
AT&T语法的指令是从左到右,第一个是源操作数,第二个是目的操作数,比如:
而Intel指令是从右到左,第二个是源操作数,第一个是目的操作数
在x86_64的寄存器调用约定规定中
1.第一个参数基本上放在:RDI/edi寄存器,第二个参数:RSI/esi寄存器,第三个参数:RDX寄存器,第四个参数:RCD寄存器,第五个参数:R8寄存器,第六个参数:R9 寄存器;
2.如果超过六个参数在函数里就会通过栈来访问额外的参数;
3.函数返回值一般放在eax寄存器,或者rax寄存器。
下面使用的mac Unix操作系统,本文用到的汇编指令都是AT&T语法,在函数传参数时的第一个参数都放在RDI寄存器中。
下面是上面的main程序从开始执行到退出程序的所有汇编程序
内存分析
上面的猜测,后来我通过LLDB调试器提供的:memory read ptr(memory read 命令缩写 x )得到了验证
我们发现不同类型的指针 在内存中确实读取到的内容分别是pDerived:0x103407f30 pvBaseB:0x103407f48内存地址都不一样;都是实际偏移后地址。
2 虚函数调用如何保证this的值一致的呢?
在网上查阅资料得知:C++在调用函数的时候, 编译器通过thunk技术对this指针的内容做了调整,使其指向正确的内存地址。那么什么是thunk技术?编译器是如何实现的呢?
虚函数调用汇编指令分析
通过上面main函数不难发现的pvBaseB->test() 的反汇编:
我们再跳到VDerived::test函数的汇编实现, 在这里通过lldb的命令:register read rdi 查看函数的第一个传参,也就是 this的地址,已经是派生类的地址了,不是调用前基类的地址
llvm-thunk源代码分析
小编使用的IDE都使用的是LLVM编译器,于是通过翻看LLVM的源码找到了答案: 在VTableBuilder.cpp的AddMethods函数,小编找到了答案,描述如下:
编译器在编译的时候会判断基类的虚函数派生类有没有覆盖,如果有实现的时候,则动态替换虚函数表中的地址为派生类的地址,同时:
1.会计算调用时this指针的地址是否需要调整,如果需要调整的话,会为当前的方法开辟一块新的内存空间;
2.也会为需要this返回值的函数开辟一块新的内存空间;
代码如下:
(mangle和demangle:将C++源程序标识符(original C++ source identifier)转换成C++ ABI标识符(C++ ABI identifier)的过程称为mangle;相反的过程称为demangle。wiki)
thunk汇编指令分析
至此,通过LLVM源码我们解开了thunk技术的真面目,那么我们通过反汇编程序来验证证实一下, 这里使用objdump 或者逆向利器 hopper都可以,小编使用的是hopper,汇编代码如下:
1.我们先来看编译器实现的thunk 版的test函数
派生类实现的test函数
编译器实现的thunk的test函数地址为0x100003e30
派生类实现的test函数地址为0x100003e00
下面我们来看下派生类的虚表中存的真实地址是那一个
上面分析的*(rcx)间接寻址:就是调用thunk函数的实现,然后在thunk中去调用真正的派生类覆盖的函数。
在这里我们可以确定的 thunk技术:
就是编译器在编译的时候,遇到调用this和返回值this需要调整的地方,动态的加入对应的thunk版的函数,在thunk函数的内部实现this的偏移调整,和调用派生类实现的虚函数;并将编译器实现的thunk函数的地址存入虚表中,而不是派生类实现的虚函数的地址。
thunk函数的内存布局
也可以确定对应的内存布局如下:
故(继承链中不是第一个)虚函数继承的基类指针的调用顺序为:
注意:在这里可以看到,内存中有两份VBase,在多继承中分为普通继承、虚函数继承、虚继承。虚继承主要是为了解决上面看到的问题:在内存中同时有两份Vbase 的内存,将上面的代码改动一下就会确保内存中的实例只有一份:
class VBaseA: public VBase 改成 class VBaseA: public virtual VBase
class VBaseB: public VBase 改成 class VBaseB: public virtual VBase
这样内存中的VBase就只有一分内存了。
到这里还有问题没有解答,就是上面截图里的thunk函数类型是:
我们发现thunk函数是 non-virtual-thunk类型,那对应的virtual-thunk是什么类型呢?
在解答这个问题之前我们现看下下面的例子?
虚函数继承和虚继承相结合,且该类在派生类的继承链中不是第一个基类的时候,则该派生类实现的虚函数在编译器编译的时候,虚表里存放就是virtual-trunk类型。
只有虚函数继承的时候,且该类在派生类的继承链中不是第一个基类的时候,则该派生类实现的虚函数在编译器编译的时候,虚表里存放就是no-virtual-trunk类型。
3 为什么LLDB调试器显示的地址一样呢?
到了现在了解了什么是thunk技术,还没有一个问题没有解决:就是LLDB调试的时候,显示的this的地址是基类偏移后的(派生类的地址),前面通过汇编分析编译器在类型转换的时候,做了真正的偏移,通过读取内存地址也发现是偏移后的真实地址,那lldb expression获取的地址为啥还是派生类的地址呢?由此可以猜测是LLDB调试器通过exppress 命令执行的时候做了类型的转换。
通过翻阅LLDB调试器的源码和LLDB说明文档,通过文档得知LLDB在每次拿到一个地址,需要向用户友好的展示的时候,首先需要通过summary format()进行格式化转换,格式化转化的依据是动态类型(lldb-getdynamictypeandaddress)的获取,在LLDB源码的bool ItaniumABILanguageRuntime::GetDynamicTypeAndAddress (lldb-summary-format)函数中找到了答案,代码如下
五 总结
- 上面主要验证了在指针类型转换的时候,编译器内部做了真实的地址偏移;
- 通过上面的分析,我们得知编译器在函数调用时通过thunk技术动态调整入参this指针和返回值this指针,保证C++调用时this的正确性;
- 在通过LLDB expression获取非虚函数基类指针内容时,LLDB内部通过summary format进行格式化转换,格式化转化时会进行动态类型的获取。
六 工具篇
1 获取汇编程序
预处理->汇编
clang++ -E main.cpp -o main.i
clang++ -S main.i
objdump
objdump -S -C 可执行程序
反汇编利器: hopper
下载hopper,可执行程序拖入即可
Xcode
Xcode->Debug->Debug WorkFlow->Show disassembly
2 导出C++内存布局
Clang++编译器
clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts main.cpp
七 参考文献
相关技术:
llvm-virtual-thunk
llvm-no-virtual-thunk
lldb-summary-format
lldb-getdynamictypeandaddress
本文为阿里云原创内容,未经允许不得转载。