找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4131

积分

0

好友

539

主题
发表于 3 小时前 | 查看: 4| 回复: 0

先别急着背结论。把一个最普通的多态类拆开,亲手量一量那根虚表指针到底落在哪个字节,你大概率会发现:市面上流传的那句「vptr 在头还是尾取决于 ABI」,问法本身就把人带进了沟里。

看看如下范例。我们从一个再朴素不过的类开始,它会贯穿整篇文章,后面每一节都在它身上「继续改造」:

// probe.cpp —— 环境:x86-64 Linux,g++ 13,-O0,LP64(指针 8 字节)
#include <cstdio>
#include <cstddef>

struct Widget {
    virtual void draw();   // 第一个虚函数,类从此变多态
    int w;
    int h;
};

int main() {
    Widget x{};
    printf("sizeof(Widget)   = %zu\n", sizeof(Widget));
    printf("offset of w      = %zu\n", offsetof(Widget, w));
    printf("vptr value       = %p\n",  *reinterpret_cast<void**>(&x));
    return 0;
}

执行起来,看一看结果:

sizeof(Widget)   = 16
offset of w      = 8
vptr value       = 0x561e3f0a2d48   // 指向 Widget 的虚表

offsetof(Widget, w) 是 8,意味着对象最前面的 8 个字节被别的东西占了——正是那根 vptr。w 本该在 0,被顶到了 8。把对象想象成一排储物格,vptr 抢了 0 号格子,成员往后顺延。这就是「vptr 在对象头」的直接证据,g++ 遵循的是 Itanium C++ ABI。

有意思的地方在下一步。我把这段代码原封不动丢到 Compiler Explorer 上,编译器换成 MSVC(x64,/d1reportSingleClassLayout 顺手把布局也打出来),offsetof(Widget, w) 依然是 8,vptr 依然在最前面。两套号称「不一样」的 ABI,在这个最典型的多态类上给出了完全一致的答案:vptr 在头,sizeof 是 16。

那「取决于 ABI」这句话到底在说什么?省得你带着错误预期往下读,一句话点破:现代 Itanium 与 MSVC 都把 vptr 钉死在对象头(offset 0),根本没有头尾之争;真正让 sizeof 因 ABI 而分叉的,从来不是 vptr 的头尾,而是虚继承下 MSVC 多出来的那根 vbptr,以及多重继承下不止一根的 vptr。 下面一步步拆,顺带说说我自己在一次跨编译器接口里栽的那个跟头。

1. 一次实测:vptr 真的在头,两套 ABI 都一样

上面那段 probe 里其实埋了一个坑,正好当热身。offsetof(Widget, w) 这一行,g++ 会甩给你一条警告:

probe.cpp:14: warning: 'offsetof' within non-standard-layout type
'Widget' is conditionally-supported [-Winvalid-offsetof]

这不是编译器在找茬。C++ 标准 [support.types.layout] 规定 offsetof 只对 standard-layout 类型有保证,而一个带虚函数的类不是 standard-layout(它多了 vptr、破坏了和 C struct 的布局兼容),所以对它用 offsetof 从 C++11 起就属于 conditionally-supported——实现可以支持,也可以拒绝,标准不背书。GCC 13 支持,但先敲你一记警告。请记住这条:只要类里有 virtual,它就不再是 standard-layout,memcpyoffsetof、和 C 结构体做二进制对拷这些把戏全部失去标准保证。 这也是面试里问「加了虚函数后这个类还能不能和 C 结构体互拷」的标准答案。

要绕开这条警告、也为了后面能量任意成员的偏移,我换成更硬核的量法——直接拿地址相减:

Widget x{};
auto  base = reinterpret_cast<char*>(&x);
printf("w at +%td\n", reinterpret_cast<char*>(&x.w) - base);  // +8
printf("h at +%td\n", reinterpret_cast<char*>(&x.h) - base);  // +12

地址相减不依赖 standard-layout,是探布局最靠得住的土办法。跑出来 w 在 +8、h 在 +12,配上 sizeof 16,整个对象的储物格排布就清清楚楚了:

Widget 对象(x86-64,Itanium ABI / g++)           sizeof = 16
┌──────────────┬────────┬────────┐
│  vptr (8B)   │ w (4B) │ h (4B) │
└──────────────┴────────┴────────┘
偏移: 0          8        12

MSVC 那边,/d1reportSingleClassLayout 打出来的布局是这个样子(我把无关行删了):

class Widget       size(16):
        +---
 0      | {vfptr}
 8      | w
12      | h
        +---

MSVC 管它叫 vfptr(virtual function pointer),Itanium 阵营习惯叫 vptr,名字不同,位置一模一样:偏移 0,8 字节。到这里,「头还是尾」这个二选一的问题已经没有悬念——在最常见的单继承多态类上,头是唯一答案,两套 ABI 没有分歧。 换句话说,如果面试官拿这个类问你 vptr 在头还是尾,正确回答是「在头,而且这跟 Itanium 还是 MSVC 无关」。

那「尾」是从哪来的?它不是凭空捏造,而是一段被讲课件辗转抄旧了的历史。

2. 为什么现代 ABI 一致选了对象头

「vptr 可以放尾」这个说法,源头在 Lippman 的《Inside the C++ Object Model》。书里说得很清楚:vptr 放在对象开头还是末尾是编译器的自由裁量,早期的 cfront 就放在末尾。请注意时间——cfront 是上世纪八十年代的 C++ 到 C 翻译器,那本书描述的是那个年代的实现光谱。三十多年过去,「放尾」在主流工具链里早已绝迹,可它作为一句「取决于实现」的教条活了下来,被无数博客和面试题当成「Itanium vs MSVC」的现实差异转述——这是把历史可能性错当成了当代事实。

放尾当年图的是什么?兼容。若 vptr 在末尾,一个不含虚函数的基类子对象就能保持和纯 C 结构体一致的布局,(Base*) 强转、和 C 代码对拷都还能凑合工作。听着很美,但它有两个要命的代价,最终让所有现代 ABI 都倒向了对象头。

第一个代价是虚调用的寻址。虚函数调用要先取 vptr、再查虚表。如果 vptr 在对象头,取它就是「从 this 指向的地址直接 load 一个指针」,一条指令:

; g++ -O2 生成的 Widget::draw 虚调用(已略去取 this 的栈操作),vptr 在 offset 0
mov    rax, QWORD PTR [rdi]        ; rax = *this,即 vptr(对象头,无需加偏移)
call   QWORD PTR [rax]             ; 调用虚表第 0 项(draw 是首个虚函数)

[rdi] 就是 this 加 0。可一旦 vptr 挪到对象末尾,每次取 vptr 都得先算 this + sizeof(对象) - 指针宽度,而这个偏移还随派生层级变化——每个多态类都要背一个「我的 vptr 在第几字节」的常数。虚调用是热路径,给它平白加一次地址计算,纯亏。

第二个代价更致命,它直接判了「放尾」的死刑:多重继承。看下面这一步改造,我给 Widget 家族引入第二条继承线:

struct Drawable { virtual void draw(); };
struct Clickable { virtual void onClick(); };
struct Button : Drawable, Clickable { int id; };

Button 同时是 Drawable 又是 Clickable,它得能被安全地当成任意一个基类来用。而多态调用要求:拿到一个 Clickable*,从它指向的地址头部就能取到能查 onClick 的那张虚表。于是 Button 里必须有两根 vptr,一根服务 Drawable,一根服务 Clickable

Button 对象(x86-64,两套 ABI 一致)              sizeof = 24
┌──────────────────┬──────────────────┬─────────┐
│ vptr_Drawable(8) │ vptr_Clickable(8)│ id (4B) │
└──────────────────┴──────────────────┴─────────┘
偏移: 0              8                  16

当你写 Clickable* c = &button;,编译器会把指针悄悄加 8,让 c 正好落在第二根 vptr 上——这就是所谓的 this 指针调整(this adjustment)。整个机制的前提是「每个多态子对象的 vptr 都在自己那段的开头」。你现在把 vptr 挪到对象末尾试试?第二根 vptr 该往哪放、Clickable* 该加多少偏移,整个模型直接散架。多重继承在语言里存在的那一天,就注定了 vptr 只能放头。 Itanium ABI 文档(itanium-cxx-abi 的 abi.html)把这套规则写死成了标准:主基类的 vptr 在 offset 0,次级基类各自带 vptr。MSVC 没有公开 ABI 文档,但用 /d1reportSingleClassLayoutButton 打出来,你会看到一模一样的两根 vfptr,偏移 0 和 8。

到这儿,第一个可以贴墙上的结论成立了:不是「Itanium 放头、MSVC 放尾」,而是两套现代 ABI 被同样的工程约束逼到了同一个选择——vptr 一律在对象头。 那句流传甚广的「取决于 ABI」,在头尾这个问题上是彻底过时的。

3. 非多态基类加 virtual:MSVC 也没把 vptr 甩到尾巴上

还有一个更隐蔽的场景,是「vptr 位置有分歧」这个误解残存的藏身处。很多人记得这么个案例:一个不含虚函数的普通基类,被一个新增了虚函数的派生类继承,vptr 该塞哪?直觉会说:为了不破坏基类那段的布局,应该把 vptr 追加到对象末尾。这不就「放尾」了吗?

我们照着写,继续在同一组类上改造:

struct Pod { int a; int b; };            // 无虚函数,standard-layout
struct Sub : Pod { virtual void f(); int c; };   // 派生类首次引入虚函数

先猜后验,这是深水区最该保持的节拍。假设「放尾以保基类布局」成立,那 SubPoda 应该还在偏移 0。写段代码验一下:

Sub s{};
auto base = reinterpret_cast<char*>(&s);
printf("a at +%td\n", reinterpret_cast<char*>(&s.a) - base);
printf("c at +%td\n", reinterpret_cast<char*>(&s.c) - base);
printf("sizeof(Sub) = %zu\n", sizeof(Sub));

执行起来,看一看结果:

a at +8
c at +12
sizeof(Sub) = 24

a 在 +8,不在 0。猜测被打脸了:Itanium 把 vptr 提到了对象最前面,Pod 那整段被整体往后推了 8 字节。布局长这样:

Sub 对象(Itanium ABI / g++)                    sizeof = 24
┌──────────────┬────────┬────────┬────────┬──────┐
│  vptr (8B)   │ a (4B) │ b (4B) │ c (4B) │ pad4 │
└──────────────┴────────┴────────┴────────┴──────┘
偏移: 0          8        12       16

也就是说,Itanium 宁可牺牲「SubPod 二进制兼容」,也要把 vptr 放头——因为放头才能让虚调用一条指令取到 vptr,这个收益压倒了那点兼容性。那 MSVC 呢?会不会它才是那个「放尾派」,把 vptr 追加到末尾以保住 Pod 的布局?我把 Sub 丢给 MSVC 的布局 dump:

class Sub  size(24):
        +---
 0      | {vfptr}
 8      | +--- (base class Pod)
 8      | | a
12      | | b
        | +---
16      | c
        +---

vfptr 在偏移 0,Pod 基类子对象从偏移 8 开始,和 g++ 一字不差。MSVC 同样把 vptr 放头、把非多态基类往下推,同样放弃了基类布局兼容。 所谓「MSVC 会把 vptr 放到尾巴上保基类」的说法,在现代 MSVC 上根本不存在——它可能是把 cfront 的历史行为张冠李戴安到了 MSVC 头上。两边 sizeof 都是 24,布局逐字节一致。

顺带收一个反直觉的点:Sub 继承自 standard-layout 的 Pod,自己却不是 standard-layout,因为它引入了 vptr。所以「基类是 POD,派生类就还能和 C 对拷」是错的——派生链上任何一环加了 virtual,从那一环往下就都不再是 standard-layout。我见过团队把「看着还像 POD」的派生对象直接 fwrite 落盘持久化,换台机器读回来,vptr 那 8 字节是进程相关的虚表地址,反序列化成野指针,第一次虚调用就段错误。切记,切记:含 virtual 的对象永远不能按字节持久化或跨进程传递。

4. 真正的分歧:虚继承下 MSVC 多背一根 vbptr

前面三节像是在拆台——把「头尾之争」一路拆成了伪命题。但 Itanium 和 MSVC 的 ABI 确实不同,sizeof 确实会因它们而分叉,只是分叉点不在 vptr 的位置,而在虚继承。这才是这篇文章真正想让你带走的东西。

虚继承是为了解决菱形继承里基类被重复包含的问题:让多条继承路径共享同一份虚基类子对象。要做到「共享」,每个中间类就得在运行时能找到那份唯一的虚基类在哪——这需要额外的间接信息。两套 ABI 在「这个额外信息塞哪、怎么塞」上分道扬镳了。

继续改造我们的类,构造一个既有虚函数、又虚继承的中间类,这是让分歧最大化的配方:

struct VBase { int v; };
struct Mid : virtual VBase {      // 虚继承 VBase
    virtual void f();             // 同时自己有虚函数
    int m;
};

先在 g++(Itanium)上量:

class Mid : virtual VBase   (Itanium ABI / g++)   sizeof = 24
┌──────────────┬────────┬──────┬────────┐
│  vptr (8B)   │ m (4B) │ pad4 │ v (4B) │←VBase 挪到对象末尾
└──────────────┴────────┴──────┴────────┘
偏移: 0          8        12     16

关键在于:Itanium 只用了一根 vptr。这根 vptr 指向的虚表,既存虚函数地址,也在负偏移区存着「虚基类 VBase 相对本对象的偏移量」(vbase offset)。查虚基类位置时,就通过这唯一的 vptr 去虚表里读那个偏移。一根指针,两份职责。sizeof 是 24。

同一个 Mid,MSVC 的布局 dump 却是这样:

class Mid   size(32):
        +---
 0      | {vfptr}
 8      | {vbptr}          ←—— MSVC 独有的第二根指针
16      | m
        +---
        +--- (virtual base VBase)
24      | v
        +---

看到那根 {vbptr} 了吗?MSVC 用了两根指针vfptr 管虚函数调用,vbptr(virtual base table pointer)单独指向一张 vbtable,专门存虚基类的偏移。两根指针各司其职,互不复用。于是 sizeof 从 Itanium 的 24 涨到了 32——整整多出一根指针的 8 字节,纯粹因为 ABI 选择不同。

这就是那句「sizeof 取决于 ABI」唯一站得住脚的地方,也是它被误安到「vptr 头尾」上的真正来源。把两边并排:

                 Itanium (g++)      MSVC
含 vf 的普通类     vptr×1             vfptr×1        —— 同
虚继承 + 虚函数     vptr×1(复用)      vfptr + vbptr  —— MSVC 多 8 字节

我在一次跨编译器的插件接口上,就实打实栽在这 8 字节上。宿主程序在 Windows 上用 MSVC 编译,插件那侧一段数值内核图省事用 MinGW 的 g++ 编成 DLL——关键就在这:GCC 哪怕在 Windows 上,走的也是 Itanium ABI,不是 MSVC ABI。 两边共享一个头文件里定义的配置类,而那个类——今天回头看是设计失误——用了虚继承去复用一个公共基类。宿主按 MSVC 布局算 sizeof 是 32,DLL 里 g++ 按 Itanium 算 24,同一个对象、同一个字段,两边偏移差了 8。宿主写进去的 m,插件读出来落在隔壁 v 的位置,参数整体错位。诡异的是它在某些机器上时好时坏——取决于那 8 字节错位有没有正好落进 padding。这种「布局对不上」的 bug 最坑人,编译两边都不报错,跑起来才神出鬼没。根因一句话:跨 ABI 边界传对象,只要类里有虚继承,sizeof 两边就不一样,字段全线错位。 面试里但凡问到「Itanium 和 MSVC 的对象布局差异」,这根 vbptr 就是那个能让你和背课件的人拉开差距的答案,比复述半天「头还是尾」值钱得多。

再补一句支线:MSVC 在虚继承叠加覆写虚函数的特定场景下,还会插入一个叫 vtordisp 的隐藏字段,在构造/析构期间修正 this 调整,进一步吃布局——Itanium 里没有这东西。它平时不出现、/vd0 能关,排查 MSVC 端诡异 sizeof 时记一笔即可。

5. 再往下探一层:虚表内部布局,以及跨 ABI 为什么必然崩

对象里那根 vptr 位置一致,不代表它指向的虚表本身布局也一致。恰恰相反,虚表内部是两套 ABI 分歧最大的地方之一,也是「为什么 MSVC 编的二进制和 GCC 编的永远不能混用」的底层原因。把这一层挖开,前面的结论才算真正落地。

Itanium 的虚表不是从「第 0 个函数指针」开始的。vptr 指向的位置叫 address point,在它之前(负偏移)还藏着两样东西:

Itanium 虚表结构(vptr 指向 address point)
        ┌─────────────────────┐
 -16    │ offset-to-top       │  到最外层对象顶部的偏移
 -8     │ typeinfo ptr        │  RTTI,指向 std::type_info
        ├─────────────────────┤ ←—— vptr 指到这里(address point)
  0     │ &Base::func0        │
  8     │ &Base::func1        │
        │ ...                 │
        └─────────────────────┘

offset-to-top 服务多重继承下的 this 调整(从次级子对象回推到完整对象顶部),单继承时它是 0。typeinfo ptr 是 RTTI 的入口,dynamic_casttypeid 全靠它。Itanium ABI 文档里明确写着:这个 typeinfo 指针对多态类有效,对非多态类是 0——这也解释了为什么你对一个没有虚函数的类用 typeid,拿到的是编译期静态类型:它压根没有虚表、没有 RTTI 入口可查。

MSVC 的虚表(vftable)里则只有函数指针,没有 offset-to-top、也没有内嵌的 typeinfo 指针。RTTI 走的是另一套:虚表前面(索引 -1)挂着一个指向 RTTICompleteObjectLocator(COL)的指针,dynamic_cast 从这个 COL 出发去查完整对象定位信息。this 调整则不靠 offset-to-top,而是靠前面说的 vbtable / vtordisp 那套。

MSVC 虚表结构(vfptr 指向第一个函数指针)
 -8     │ &RTTICompleteObjectLocator │  ←—— RTTI 走这条,索引 -1
        ├────────────────────────────┤ ←—— vfptr 指到这里
  0     │ &Base::func0               │
  8     │ &Base::func1               │
        └────────────────────────────┘

两张虚表连「RTTI 信息挂在正偏移还是负偏移、内嵌还是外挂」都不一样。所以哪怕对象里 vptr 都在 offset 0、连虚表里函数指针的排列顺序都恰好一致,一段 MSVC 编的代码去解 g++ 生成的对象、试图从 vptr 负偏移 8 处读 typeinfo,读到的也会是 MSVC 语义下的 COL 指针位置——在 Itanium 布局里那儿放的是 typeinfo,语义对不上,dynamic_cast 直接指向乱七八糟的地方。这就是为什么两套 ABI 编出来的二进制在任何涉及多态、RTTI、异常的地方都绝不可能互操作,name mangling 只是表层,对象和虚表的内存模型压根是两种。它们各自内部自洽,但没有任何一个字节的约定是共享的。

到这一层,「vptr 位置」这个最初的问题其实已经显得很小了:它只是两套内存模型里恰好一致的一个点,而这两套模型在它周围的几乎所有地方都在分岔。

小结

把这篇的实测结论收一下:

  1. 单继承多态类:Itanium 和 MSVC 都把 vptr 放对象头(offset 0),sizeof 一致,没有头尾之争。「vptr 放尾」是 cfront 时代的历史行为,被误当成了当代 ABI 差异。
  2. 非多态基类 + 派生类加 virtual:两套 ABI 都把 vptr 提到头、把基类往下推,都放弃基类布局兼容,sizeof 依然一致。
  3. 虚继承:这才是 sizeof 真正因 ABI 分叉的地方——Itanium 一根 vptr 复用(存 vbase offset),MSVC 是 vfptr + vbptr 两根指针,同一个类 MSVC 大一根指针(示例里 32 对 24)。
  4. 虚表内部:Itanium 把 offset-to-top 和 typeinfo 放在 address point 负偏移,MSVC 虚表只放函数指针、RTTI 外挂 COL——两套二进制在多态处永不兼容。

还有一句得叮嘱:别急着把「vptr 一律在头」也刻进代码里。它是当代实现的事实,不是标准的承诺——C++ 标准([class.mem] 只规定同访问级成员的次序)从没规定 vptr 的位置,sizeof 里有没有它、放哪、多重继承下有几根,全是实现细节。真到多重继承那一步,「vptr 在头」连描述都不准了:次级基类的那根 vptr 就卡在对象中段,Clickable* c = &button 背后那次悄悄的 +8 偏移,才是多态在内存里真实的样子。所以这些字节偏移,探究可以,量它是为了看懂机制、看懂两套 ABI 为什么这么设计;但一旦你的代码开始依赖某个具体偏移、依赖 sizeof 是某个值、依赖对象能按字节搬运,那不是在用 C++ 的对象模型,是在赌某个编译器某个版本的实现细节——而这种赌局,我见过的每一次都是在生产环境上输掉的。

声明:本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。

对 C++ 对象模型有更深的疑问?欢迎来 云栈社区 与众多开发者深入探讨。

CPP对象模型 #内存布局 #虚函数 #跨编译器 #面试必知




上一篇:DRAM合约价涨幅超预期,谷歌资本开支或增50%存储需求
下一篇:秒杀系统高并发架构:7大核心设计思路与实战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-7-28 05:45 , Processed in 1.131957 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表