具有协变 return 类型的方法在 VC++ 上崩溃
Methods with covariant return types crashes on VC++
以下代码在使用 clang 或 gcc(在 macOS 上)编译时似乎 运行 正常,但在使用 MS Visual C++ 2017 编译时崩溃。在后者上,foo_clone 对象似乎被损坏并且程序崩溃并在行 foo_clone->get_identifier()
.
上出现访问冲突
如果我删除协变 return 类型(所有克隆方法 return IDO*
),或者当 std::enable_shared_from_this
被删除,或者当所有继承都变为虚拟时。
为什么它适用于 clang/gcc 但不适用于 VC++?
#include <memory>
#include <iostream>
class IDO {
public:
virtual ~IDO() = default;
virtual const char* get_identifier() const = 0;
virtual IDO* clone() const = 0;
};
class DO
: public virtual IDO
, public std::enable_shared_from_this<DO>
{
public:
const char* get_identifier() const override { return "ok"; }
};
class D : public virtual IDO, public DO {
D* clone() const override {
return nullptr;
}
};
class IA : public virtual IDO {};
class Foo : public IA, public D {
public:
Foo* clone() const override {
return new Foo();
}
};
int main(int argc, char* argv[]) {
Foo* foo = new Foo();
Foo* foo_clone = foo->clone();
foo_clone->get_identifier();
}
留言:
在 foo.exe 中的 0x00007FF60940180B 抛出异常:0xC0000005:访问冲突读取位置 0x0000000000000004。
这似乎是 VC++ 的错误编译。当 enable_shared_from_this
没有红鲱鱼时,它就会消失;问题只是被掩盖了。
一些背景知识:在 C++ 中解析被覆盖的函数通常是通过 vtables 发生的。然而,在存在多重继承和虚拟继承以及共变 return 类型的情况下,必须应对一些挑战,以及应对这些挑战的不同方式。
考虑:
Foo* foo = new Foo();
IDO* ido = foo;
D* d = foo;
foo->clone(); // must call Foo::clone() and return a Foo*
ido->clone(); // must call Foo::clone() and return an IDO*
d->clone(); // must call Foo::clone() and return a D*
请记住 Foo::clone()
return 是 Foo*
无论如何,从 Foo*
到 IDO*
或 D*
的转换是不是简单的空操作。在完整的 Foo
对象中,IDO
子对象位于偏移量 32(假设 MSVC++ 和 64 位编译),而 D
子对象位于偏移量 8 . 从Foo*
到D*
就是指针加8,得到IDO*
其实就是从Foo*
加载信息,而IDO
子对象位于。
但是,让我们看看为所有这些 classes 生成的 vtable。 IDO
的 vtable 具有以下布局:
0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
D
的 vtable 具有以下布局:
0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
3: D* clone() const
插槽2是因为底座classIDO
有这个功能。插槽 3 在那里,因为这个功能也存在。我们可以省略这个插槽,而是在调用点生成额外的代码以将 IDO*
转换为 D*
吗?也许吧,但那样效率会降低。
Foo
的 vtable 如下所示:
0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
3: D* clone() const
4: Foo* clone() const
5: Foo* clone() const
同样,它继承了 D
的布局并附加了自己的插槽。我实际上不知道为什么有两个新插槽 - 它可能只是出于兼容性原因而保留的次优算法。
现在,我们要为 Foo
类型的具体对象在这些槽中放入什么?插槽 4 和 5 简单得到 Foo::clone()
。但是那个函数 return 是 Foo*
,所以它不适合插槽 2 和 3。对于这些,编译器创建调用主版本并转换结果的存根(称为 thunk),即编译器创建插槽 3 是这样的:
D* Foo::clone$D() const {
Foo* real = clone();
return static_cast<D*>(real);
}
现在我们来看看错误编译:出于某种原因,编译器在看到这个调用时:
foo->clone();
调用的不是插槽 4 或 5,而是插槽 3。但是插槽 3 return是 D*
!然后代码继续使用那个 D*
作为 Foo*
,或者换句话说,你得到的行为与你所做的一样:
Foo* wtf = reinterpret_cast<Foo*>(
reinterpret_cast<char*>(foo_clone) + 8);
这显然不会有好结果。
具体来说,在调用 foo_clone->get_identifier();
时,编译器想要将 Foo* foo_clone
转换为 IDO*
(get_identifier
需要它的 this
指针是 IDO*
因为它最初是在 IDO
中声明的)。正如我之前提到的,IDO
对象在任何 Foo
对象中的确切位置是不固定的;它取决于对象的完整类型(如果完整对象是 Foo
,则为 32,但如果它是从 Foo
派生的 class,则可能是其他类型)。因此,要进行转换,编译器必须从对象中加载偏移量。具体来说,它可以加载位于任何 Foo
对象的偏移量 0 处的 "virtual base pointer" (vbptr),它指向包含偏移量的 "virtual base table" (vbtable)。
但是请记住,我们有一个损坏的 Foo*
已经指向真实对象的偏移量 8。所以我们访问偏移量 8 的偏移量 0,那里有什么?好吧,碰巧的是 enable_shared_from_this
对象中的 weak_ptr
,它是空的。所以我们为 vbptr 获取 null,并且尝试取消引用它以获取对象崩溃。 (虚拟基址的偏移量存储在vbtable中的偏移量4处,这就是为什么你得到的崩溃地址是0x000...004。)
如果删除所有协变恶作剧,vtable 将缩小为 clone()
的一个很好的单个条目,并且不会出现编译错误。
但是为什么如果删除 enable_shared_from_this
问题就消失了?好吧,因为偏移量 8 处的东西不是 weak_ptr
内的某个空指针,而是 DO
子对象的 vbptr。 (一般来说,继承图的每个分支都有自己的 vbptr。IA
有一个 Foo
共享,DO
有一个 D
共享。)而那个 vbptr包含将 D*
转换为 IDO*
所需的信息。我们的 Foo*
实际上是伪装的 D*
,所以一切都恰好正确。
附录
MSVC++ 编译器有一个未记录的选项来转储对象布局。这是 Foo
with enable_shared_from_this
:
的输出
class Foo size(40):
+---
0 | +--- (base class IA)
0 | | {vbptr}
| +---
8 | +--- (base class D)
8 | | +--- (base class DO)
8 | | | +--- (base class std::enable_shared_from_this<class DO>)
8 | | | | ?$weak_ptr@VDO@@ _Wptr
| | | +---
24 | | | {vbptr}
| | +---
| +---
+---
+--- (virtual base IDO)
32 | {vfptr}
+---
Foo::$vbtable@IA@:
0 | 0
1 | 32 (Food(IA+0)IDO)
Foo::$vbtable@D@:
0 | -16
1 | 8 (Food(DO+16)IDO)
Foo::$vftable@:
| -32
0 | &Foo::{dtor}
1 | &DO::get_identifier
2 | &IDO* Foo::clone
3 | &D* Foo::clone
4 | &Foo* Foo::clone
5 | &Foo* Foo::clone
Foo::clone this adjustor: 32
Foo::{dtor} this adjustor: 32
Foo::__delDtor this adjustor: 32
Foo::__vecDelDtor this adjustor: 32
vbi: class offset o.vbptr o.vbte fVtorDisp
IDO 32 0 4 0
这里没有:
class Foo size(24):
+---
0 | +--- (base class IA)
0 | | {vbptr}
| +---
8 | +--- (base class D)
8 | | +--- (base class DO)
8 | | | {vbptr}
| | +---
| +---
+---
+--- (virtual base IDO)
16 | {vfptr}
+---
Foo::$vbtable@IA@:
0 | 0
1 | 16 (Food(IA+0)IDO)
Foo::$vbtable@D@:
0 | 0
1 | 8 (Food(DO+0)IDO)
Foo::$vftable@:
| -16
0 | &Foo::{dtor}
1 | &DO::get_identifier
2 | &IDO* Foo::clone
3 | &D* Foo::clone
4 | &Foo* Foo::clone
5 | &Foo* Foo::clone
Foo::clone this adjustor: 16
Foo::{dtor} this adjustor: 16
Foo::__delDtor this adjustor: 16
Foo::__vecDelDtor this adjustor: 16
vbi: class offset o.vbptr o.vbte fVtorDisp
IDO 16 0 4 0
这是 return-调整 clone
垫片的一些清理后的拆卸:
mov rcx,qword ptr [this]
call Foo::clone ; the real clone
cmp rax,0 ; null pointer remains null pointer
je fin
add rax,8 ; otherwise, add the offset to the D*
jmp fin
fin: ret
下面是错误调用的一些清理反汇编:
mov rax,qword ptr [foo]
mov rcx,rax
mov rax,qword ptr [rax] ; load vbptr
movsxd rax,dword ptr [rax+4] ; load offset to IDO subobject
add rcx,rax ; add offset to Foo* to get IDO*
mov rax,qword ptr [rcx] ; load vtbl
call qword ptr [rax+24] ; call function at position 3 (D* clone)
这里是崩溃调用的一些清理反汇编:
mov rax,qword ptr [foo_clone]
mov rcx,rax
mov rax,qword ptr [rax] ; load vbptr, loads null in the crashing case
movsxd rax,dword ptr [rax+4] ; load offset to IDO subobject, crashes
add rcx,rax ; add offset to Foo* to get IDO*
mov rax,qword ptr [rcx] ; load vtbl
call qword ptr [rax+8] ; call function at position 1 (get_identifier)
以下代码在使用 clang 或 gcc(在 macOS 上)编译时似乎 运行 正常,但在使用 MS Visual C++ 2017 编译时崩溃。在后者上,foo_clone 对象似乎被损坏并且程序崩溃并在行 foo_clone->get_identifier()
.
如果我删除协变 return 类型(所有克隆方法 return IDO*
),或者当 std::enable_shared_from_this
被删除,或者当所有继承都变为虚拟时。
为什么它适用于 clang/gcc 但不适用于 VC++?
#include <memory>
#include <iostream>
class IDO {
public:
virtual ~IDO() = default;
virtual const char* get_identifier() const = 0;
virtual IDO* clone() const = 0;
};
class DO
: public virtual IDO
, public std::enable_shared_from_this<DO>
{
public:
const char* get_identifier() const override { return "ok"; }
};
class D : public virtual IDO, public DO {
D* clone() const override {
return nullptr;
}
};
class IA : public virtual IDO {};
class Foo : public IA, public D {
public:
Foo* clone() const override {
return new Foo();
}
};
int main(int argc, char* argv[]) {
Foo* foo = new Foo();
Foo* foo_clone = foo->clone();
foo_clone->get_identifier();
}
留言:
在 foo.exe 中的 0x00007FF60940180B 抛出异常:0xC0000005:访问冲突读取位置 0x0000000000000004。
这似乎是 VC++ 的错误编译。当 enable_shared_from_this
没有红鲱鱼时,它就会消失;问题只是被掩盖了。
一些背景知识:在 C++ 中解析被覆盖的函数通常是通过 vtables 发生的。然而,在存在多重继承和虚拟继承以及共变 return 类型的情况下,必须应对一些挑战,以及应对这些挑战的不同方式。
考虑:
Foo* foo = new Foo();
IDO* ido = foo;
D* d = foo;
foo->clone(); // must call Foo::clone() and return a Foo*
ido->clone(); // must call Foo::clone() and return an IDO*
d->clone(); // must call Foo::clone() and return a D*
请记住 Foo::clone()
return 是 Foo*
无论如何,从 Foo*
到 IDO*
或 D*
的转换是不是简单的空操作。在完整的 Foo
对象中,IDO
子对象位于偏移量 32(假设 MSVC++ 和 64 位编译),而 D
子对象位于偏移量 8 . 从Foo*
到D*
就是指针加8,得到IDO*
其实就是从Foo*
加载信息,而IDO
子对象位于。
但是,让我们看看为所有这些 classes 生成的 vtable。 IDO
的 vtable 具有以下布局:
0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
D
的 vtable 具有以下布局:
0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
3: D* clone() const
插槽2是因为底座classIDO
有这个功能。插槽 3 在那里,因为这个功能也存在。我们可以省略这个插槽,而是在调用点生成额外的代码以将 IDO*
转换为 D*
吗?也许吧,但那样效率会降低。
Foo
的 vtable 如下所示:
0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
3: D* clone() const
4: Foo* clone() const
5: Foo* clone() const
同样,它继承了 D
的布局并附加了自己的插槽。我实际上不知道为什么有两个新插槽 - 它可能只是出于兼容性原因而保留的次优算法。
现在,我们要为 Foo
类型的具体对象在这些槽中放入什么?插槽 4 和 5 简单得到 Foo::clone()
。但是那个函数 return 是 Foo*
,所以它不适合插槽 2 和 3。对于这些,编译器创建调用主版本并转换结果的存根(称为 thunk),即编译器创建插槽 3 是这样的:
D* Foo::clone$D() const {
Foo* real = clone();
return static_cast<D*>(real);
}
现在我们来看看错误编译:出于某种原因,编译器在看到这个调用时:
foo->clone();
调用的不是插槽 4 或 5,而是插槽 3。但是插槽 3 return是 D*
!然后代码继续使用那个 D*
作为 Foo*
,或者换句话说,你得到的行为与你所做的一样:
Foo* wtf = reinterpret_cast<Foo*>(
reinterpret_cast<char*>(foo_clone) + 8);
这显然不会有好结果。
具体来说,在调用 foo_clone->get_identifier();
时,编译器想要将 Foo* foo_clone
转换为 IDO*
(get_identifier
需要它的 this
指针是 IDO*
因为它最初是在 IDO
中声明的)。正如我之前提到的,IDO
对象在任何 Foo
对象中的确切位置是不固定的;它取决于对象的完整类型(如果完整对象是 Foo
,则为 32,但如果它是从 Foo
派生的 class,则可能是其他类型)。因此,要进行转换,编译器必须从对象中加载偏移量。具体来说,它可以加载位于任何 Foo
对象的偏移量 0 处的 "virtual base pointer" (vbptr),它指向包含偏移量的 "virtual base table" (vbtable)。
但是请记住,我们有一个损坏的 Foo*
已经指向真实对象的偏移量 8。所以我们访问偏移量 8 的偏移量 0,那里有什么?好吧,碰巧的是 enable_shared_from_this
对象中的 weak_ptr
,它是空的。所以我们为 vbptr 获取 null,并且尝试取消引用它以获取对象崩溃。 (虚拟基址的偏移量存储在vbtable中的偏移量4处,这就是为什么你得到的崩溃地址是0x000...004。)
如果删除所有协变恶作剧,vtable 将缩小为 clone()
的一个很好的单个条目,并且不会出现编译错误。
但是为什么如果删除 enable_shared_from_this
问题就消失了?好吧,因为偏移量 8 处的东西不是 weak_ptr
内的某个空指针,而是 DO
子对象的 vbptr。 (一般来说,继承图的每个分支都有自己的 vbptr。IA
有一个 Foo
共享,DO
有一个 D
共享。)而那个 vbptr包含将 D*
转换为 IDO*
所需的信息。我们的 Foo*
实际上是伪装的 D*
,所以一切都恰好正确。
附录
MSVC++ 编译器有一个未记录的选项来转储对象布局。这是 Foo
with enable_shared_from_this
:
class Foo size(40):
+---
0 | +--- (base class IA)
0 | | {vbptr}
| +---
8 | +--- (base class D)
8 | | +--- (base class DO)
8 | | | +--- (base class std::enable_shared_from_this<class DO>)
8 | | | | ?$weak_ptr@VDO@@ _Wptr
| | | +---
24 | | | {vbptr}
| | +---
| +---
+---
+--- (virtual base IDO)
32 | {vfptr}
+---
Foo::$vbtable@IA@:
0 | 0
1 | 32 (Food(IA+0)IDO)
Foo::$vbtable@D@:
0 | -16
1 | 8 (Food(DO+16)IDO)
Foo::$vftable@:
| -32
0 | &Foo::{dtor}
1 | &DO::get_identifier
2 | &IDO* Foo::clone
3 | &D* Foo::clone
4 | &Foo* Foo::clone
5 | &Foo* Foo::clone
Foo::clone this adjustor: 32
Foo::{dtor} this adjustor: 32
Foo::__delDtor this adjustor: 32
Foo::__vecDelDtor this adjustor: 32
vbi: class offset o.vbptr o.vbte fVtorDisp
IDO 32 0 4 0
这里没有:
class Foo size(24):
+---
0 | +--- (base class IA)
0 | | {vbptr}
| +---
8 | +--- (base class D)
8 | | +--- (base class DO)
8 | | | {vbptr}
| | +---
| +---
+---
+--- (virtual base IDO)
16 | {vfptr}
+---
Foo::$vbtable@IA@:
0 | 0
1 | 16 (Food(IA+0)IDO)
Foo::$vbtable@D@:
0 | 0
1 | 8 (Food(DO+0)IDO)
Foo::$vftable@:
| -16
0 | &Foo::{dtor}
1 | &DO::get_identifier
2 | &IDO* Foo::clone
3 | &D* Foo::clone
4 | &Foo* Foo::clone
5 | &Foo* Foo::clone
Foo::clone this adjustor: 16
Foo::{dtor} this adjustor: 16
Foo::__delDtor this adjustor: 16
Foo::__vecDelDtor this adjustor: 16
vbi: class offset o.vbptr o.vbte fVtorDisp
IDO 16 0 4 0
这是 return-调整 clone
垫片的一些清理后的拆卸:
mov rcx,qword ptr [this]
call Foo::clone ; the real clone
cmp rax,0 ; null pointer remains null pointer
je fin
add rax,8 ; otherwise, add the offset to the D*
jmp fin
fin: ret
下面是错误调用的一些清理反汇编:
mov rax,qword ptr [foo]
mov rcx,rax
mov rax,qword ptr [rax] ; load vbptr
movsxd rax,dword ptr [rax+4] ; load offset to IDO subobject
add rcx,rax ; add offset to Foo* to get IDO*
mov rax,qword ptr [rcx] ; load vtbl
call qword ptr [rax+24] ; call function at position 3 (D* clone)
这里是崩溃调用的一些清理反汇编:
mov rax,qword ptr [foo_clone]
mov rcx,rax
mov rax,qword ptr [rax] ; load vbptr, loads null in the crashing case
movsxd rax,dword ptr [rax+4] ; load offset to IDO subobject, crashes
add rcx,rax ; add offset to Foo* to get IDO*
mov rax,qword ptr [rcx] ; load vtbl
call qword ptr [rax+8] ; call function at position 1 (get_identifier)