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

4515

积分

0

好友

581

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

对象头上那个 vptr,指向一张函数指针数组(vtable);照着下标查表、取地址、跳过去——十有八九,你脑子里那张虚调用的图就长这样。这张图大体是对的,可它能带你走多远?走到构造函数门口就卡住了:为什么在构造函数里调虚函数,跑的是基类版本、够不到你刚写的派生类重写?走到那条标了 final 的叶子类跟前又卡住了:profile 里那个 call 凭什么纹丝不动?

这篇就沿着一次虚调用往下走,从 C++ 源码走到 GCC 编出来的汇编:拆开 vtablevptr 前面藏的两个槽,跟住 this 在多继承里被回拨的那一步,再看编译器什么时候把整个 call 抹掉。看完你手里会多一条能算的分界线:一次虚调用是编译器和 Itanium C++ ABI 之间的一份合同,不是一次数组下标访问。

查表跳转这张图,解释不了五件事

先把这张图说到它自己的边界上。它对的部分是:多态对象头上确实有个 vptr,确实指向一张 vtable,虚调用确实要经过这张表做一次间接跳转。如果你写的每个类都是单继承、没有虚基类、虚函数只在对象活着时被调、而且编译器永远看不清动态类型——这张图就够用了。

问题是这四个前提,工程里一个都不总成立。把它们逐个松开,图就漏了:对象正在构造,vptr 还没指向你以为的那张表;对象是多继承来的,this 指向的根本不是完整对象的头;调用点其实能看清动态类型,那次间接跳转就该消失;而即便前三条都不发生,那张表里除了函数指针,还硬塞着两个你从没主动读过、却每次虚调用都在为之让路的字段。

所以这张图缺的是一整个维度:它把一个动态过程画成了静态查表。 真实的虚调用里,vptr 的值随对象生命周期变、this 的值随继承路径变、那次间接跳转本身随编译器的视野变。下面五节,每节钉死其中一处,用的都是同一件工具——把源码丢给 GCC,看它吐出来的指令承认了什么,再拿 Itanium ABI 文档里写明的布局来对质。

虚表里躺着的,不止是函数指针

先问一个几乎没人主动查的问题:vptr 指向的那个地址,前面有没有东西?

有。而且是每次 RTTI、每次 dynamic_cast、每次多继承析构都要用的东西。拿最素的一对类看 GCC 怎么摆(下文一律 GCC 13、x86-64 Linux、-O2,遵循 Itanium C++ ABI,Compiler Explorer 可复验;Clang/MSVC 细节略有出入):

struct Shape {
    virtual double area() const;
    virtual double perimeter() const;
    virtual ~Shape();
    int tag = 0;
};
struct Circle : Shape {
    double r = 1.0;
    double area() const override;       // πr²
    double perimeter() const override;  // 2πr
};

g++ -fdump-lang-class 把 Circle 的虚表打出来,裁掉无关行是这样(偏移是相对虚表变量开头的字节数):

Vtable for Circle  —  6 entries
偏移 0    offset_to_top = 0      ← 附加槽
偏移 8    typeinfo for Circle    ← 附加槽
──────  vptr 落点 (address point) ──────
偏移 16   Circle::area           ← vptr[0]
偏移 24   Circle::perimeter      ← vptr[1]
偏移 32   Circle::~Circle  [D1]
偏移 40   Circle::~Circle  [D0]

关键在那条分割线。vptr 不指向虚表的开头,它指向中间那个 address point,也就是第一个函数指针的位置。开头两个槽排在 vptr 前面,得用负下标去够:vptr[-1] 是 typeinfo 指针(dynamic_casttypeid 从这里读类型信息),vptr[-2] 是 offset_to_top(本子对象块距完整对象头的字节距离,第五节的析构就靠它)。Itanium ABI 文档里明写,这两个字段分别排在 address point 前的第 −1 和 −2 个机器字,非多态类这两个槽为空。

再看正向的槽。函数指针严格按声明顺序排:areavptr[0]perimetervptr[1]。析构函数占两个槽——D1 完整对象析构、D0 deleting 析构(析构完再调 operator delete),delete p 走后者。基类虚析构、派生类 delete 才不漏内存,靠的就是 vptr 取到派生类的 D0,尺寸和释放器都对得上。

对象和表的关系,拆成两张窄图看。先是对象:

Circle 对象 (sizeof=24)
┌────────────┐ 0
│ vptr       │──┐  指向虚表的 address point
├────────────┤ 8└──────┐
│ tag (+pad) │         │
├────────────┤ 16      │
│ r          │         ▼
└────────────┘        (见下表)

vptr 指过去落在这里:

Circle 的虚表
   offset_to_top = 0
   typeinfo
── address point ──   ← vptr 落点
   area        vptr[0]
   perimeter   vptr[1]
   ~Circle D1
   ~Circle D0

所以“表里全是函数指针”这句话,第一个槽还没开始就已经不成立。vptr 前面压着的 offset_to_top 和 typeinfo,是这张表为多继承和 RTTI 交的常驻成本——每个多态类型一张表,这两个字段占掉 16 字节,你不用 RTTI 也躲不掉。

两次访存,一次间接跳转

图里“取地址、跳过去”这一下,究竟编成几条指令?直接问 GCC,只贴它回答这个问题的三行。s 是个 Shape*,调 perimeter(第 1 个虚函数,vptr[1]):

; double y = s->perimeter();     进来时 rdi = s (即 this)
    mov     rax, QWORD PTR [rdi]   ; 第 1 次访存:从对象头取出 vptr
    call    QWORD PTR [rax+8]      ; 第 2 次访存 + 间接跳转:跳到 vptr[1]

就两条核心指令,但每条都在花钱。第一条 mov 从对象头把 vptr 装进 rax——一次内存加载,对象在 cache 里就快,不在就是一次 miss。第二条 call [rax+8] 更值得说:rax 是 address point,+8perimeter 的槽位偏移(第 k 个虚函数就是 +8*k,这是 Itanium ABI 文档里写死的布局),这条指令先从 vptr[1] 把目标函数地址读出来(第二次访存),再间接跳过去this 一直原样待在 rdi 里,被调函数直接用。

两次访存本身还不算贵,真正花钱的是那个间接call [rax+8] 的目标要到运行时才知道,CPU 的分支目标缓冲(BTB)得预测它跳去哪。派发目标稳定(老是同一个动态类型)时预测命中,几乎免费;派发目标乱跳(一个 vector<Shape*> 里什么派生类都有)时 BTB 命不中,就是一次十几到二十几个周期的流水线气泡,量级和一次 L2 miss 相当。

但对架构师来说,气泡还是小事。真正的代价是这条间接跳转竖起的一堵优化墙编译器call [rax+8] 这里看不见被调函数是谁,内联、跨函数常量传播、循环向量化跟着全被这个不透明调用打断——热循环里的实际损失八成在这儿,那条 call 本身的周期数反而是小头。记住这点,第六节的去虚化才讲得通:它真正买到的是内联机会。

构造函数里的虚调用,为什么不听你的

这一节反过来讲:先给你一个几乎每个人都栽过的结果,再回去看 vptr 承认了什么。

我见过不止一次这样的封装:基类构造函数里调一个虚 on_init() 钩子,指望派生类的重写版本去把派生状态填上。

struct Widget {
    Widget() { on_init(); }              // 想调派生类的版本
    virtual void on_init(){ /* 空 */ }
};
struct Button : Widget {
    std::string label;
    void on_init() override { label = "OK"; }  // 期望它来填 label
};

Button b; 之后,label 是空的。跑的是 Widget::on_init 那个空版本,Button::on_init 一次没被调到。派生状态没初始化,某个字段留着零值,崩点常常在很远的地方,排查半天才想起来问题出在构造顺序。

这不是编译器“知道”自己在构造函数里就特殊照顾——它没那么聪明。真正的原因是 vptr 是个运行期变量,构造到哪一层,它就指向哪一层的虚表。看 GCC 给两个构造函数编的开头:

Widget::Widget():
    lea     rax, [rip + vtable_for_Widget + 16]  ; Widget 虚表的 address point
    mov     QWORD PTR [rdi], rax                  ; 对象.vptr = &Widget 虚表
    ...                                           ; 此刻调 on_init → Widget::on_init

Button::Button():
    call    Widget::Widget()                      ; 先跑基类构造,vptr 被设成 Widget 表
    lea     rax, [rip + vtable_for_Button + 16]   ; 基类构造返回后
    mov     QWORD PTR [rbx], rax                  ; 对象.vptr = &Button 虚表(这才升级)
    ...

顺序一目了然:Button 构造先调 Widget 构造,而 Widget 构造头一件事就是把 vptr 设成 Widget 自己的表。所以在 Widget() 体内那一刻,对象的 vptr 指向 Widget 表,on_init 槽里放的是 Widget::on_init。等 Widget() 返回,Button() 才把 vptr 改写成 Button 表。C++ 标准规定([class.cdtor]/4):构造或析构期间的虚调用,落在当前这一层类的 final overrider上,绝不下降到更派生的重写——ABI 用“vptr 随构造逐层升级、随析构逐层降级”来兑现它。

这里有个专家面试常追问的推论:既然机制是 vptr 的值,那换成显式 this->on_init()、或把 this 传给别的函数再虚调用,结果一样是 Widget 版本——它拦的是那一刻 vptr 指向谁,跟你用什么语法写无关。析构反着来:~Button 先把 vptr 降回 Button 表跑派生析构,再进 ~Widgetvptr 已是 Widget 表,所以析构里的虚调用够不到已析构的派生层,这是安全设计。

多继承里,this 的值在进函数前就被改了

前四节的对象,this 都等于对象头地址。多继承一来,这条也破了。C 覆盖了两个虚函数——f 来自主基类 A,g 来自非主基类 B——把这两个覆盖在虚表里的落点摆到一起对质,就看得清 this 是在哪一步、被谁改的。

struct A { virtual void f(); };
struct B { virtual void g(); };
struct C : A, B {
    void f() override;
    void g() override;
};

C 的对象里,A 是主基类(primary base),和完整对象共享头;B 是非主基类,被排在偏移 8 处(A 的 vptr 占了前 8 字节)。于是 B* b = &c; 这一步,static_cast 会把指针加 8,让 b 正确指向 B 子对象——不然 b 访问 B 的成员、查 B 的虚表都对不上号。

矛盾就在这儿:b->g() 经 B 的虚表调到 C::g,可 C::g 的函数体按完整 C 对象的视角写,它访问 C 的成员用的是相对完整对象头的偏移。要是把 b(完整对象 +8)当 this 原样传进去,C::g 里每个成员访问都会偏 8 字节,直接越界。所以 this 必须在进 C::g 前被回拨 8

这个回拨在哪做?不在调用点——调用点静态类型是 B*,它既不知道也不该知道动态类型是 C,更不知道该减多少。答案在虚表的槽里。C 有两张虚表,fg 各落在其中一张,槽里放的东西不一样:

主虚表   (A/C 共享头)  f 槽 →  C::f 真身
非主虚表 (B 子对象)    g 槽 →  _ZThn8_N1C1gEv

f 来自主基类 A,A 和完整对象共享头、this 本就对,槽里直接放 C::f 真身。g 来自非主基类 B,经 B* 进来的 this 差了 8,所以它的槽里放的不是 C::g 真身,而是一段跳板。这段跳板,Itanium ABI 文档里写明叫 non-virtual thunk,GCC 编出来就三行:

_ZThn8_N1C1gEv:              ; “Thn8” = 非虚 thunk,this 减 8
    sub     rdi, 8           ; this -= 8:B 子对象地址 → 完整 C 对象地址
    jmp     C::g             ; 尾跳进真身,不开新栈帧

Thn8 这个名字直接把语义写进符号:Th 是 thunk,n8 是负 8。它把 this 减回 8,再 jmp(尾跳,不留返回地址,像个透明转接头)进 C::g调用点对这一切一无所知b->g() 编出来和普通虚调用一模一样(取 vptr、取槽、call),它取到的“函数指针”本来就是 thunk 地址,回拨发生在 call 之后、进真身之前的 callee 一侧。

这套设计的漂亮在于调用点对主表、非主表完全一致,差异全藏在“vptr 指向哪张表、那张表的槽里放了真身还是 thunk”。第二节那个 offset_to_top 也在这里派上用场:非主虚表的 offset_to_top 是 −8,delete b 时得靠它把 this 拨回完整对象头,operator delete 拿到的起始地址才对。(虚基类会更进一步,把这个固定的 −8 换成运行时从虚表现读的 vcall offset,那是另一套虚 thunk 机制,这里只点到,真用到再翻 ABI 的 construction vtable 一节。)

有时候,那个 call 根本不存在

前五节都在讲这套机制怎么运转。这一节反证我自己:它常常整个不运转——而这恰恰是架构师最该会算的一处,因为它决定你那条派发热路径是快是慢。

先认个赔。有一回我为了榨一条算子派发热路径,把叶子算子类标了 final,满心以为编译器会把 base->eval() 那个间接 call 干掉、顺带把 eval 内联进循环。profile 拉出来,那个 call 一动没动。我在“final 为什么没生效”上耗了小半天,最后才认清一件事:*final 标在叶子类上,对一个 `Base的调用点毫无帮助**——调用点看到的静态类型是Base*,编译器在这儿根本不知道它指向那个 final 叶子,Base` 底下可能还挂着别的派生类,它凭什么敢直接调?

把去虚化的真实条件摆出来,对照着看就清楚。GCC 文档里写,-fdevirtualize-O2 默认打开;它要能把间接调用换成直接调用,得满足下面之一:

double f1(){ Circle c; return c.area(); }   // ① 静态类型精确到具体类
double f2(Circle& c){ return c.area(); }    // ② 引用/指针静态类型已是具体类
double f3(Shape* s){ return s->area(); }    // ③ 只知道是 Shape*:默认仍间接调用

f1f2 里编译器在调用点就确知对象是 Circle(局部对象、或静态类型已是 Circle),直接调 Circle::area 并内联,vptr 加载和查表全消失。f3 里它只知道 Shape*,默认老实间接调用。让 f3 也去虚化,得额外给它一个“这个 Shape* 只可能是某个确定类型”的证明来源:把 Circleareafinal且调用点静态类型已经够具体;或者用匿名命名空间、LTO 把整个继承层级 seal 住,让 GCC 的类型继承分析证明 Shape 全程只有一个 override。这也解释了我那次的错——final 标在叶子上却经 Base* 调,既没精确静态类型、也没 seal 整个层级,两个条件一个没占。

还有第三条路 -fdevirtualize-speculatively:编译器赌一个最可能的类型,编成“若 vptr 等于预测的那张表就走直接调用(可内联),否则退回间接调用”。赌中了拿到内联收益,赌不中多一次比较,是个下注不是消除。

绕回第三节那句话:去虚化真正的收益在别处。间接调用一旦变直接调用,那堵优化墙就塌了——编译器重新看得见被调函数,内联、常量折叠、向量化跟着回来。所以判断你的虚调用能不能去虚化,别去数 vtable,去看调用点的静态类型:它是不是已经精确到具体类或 final 类型?是,那次 call 大概率不存在;不是,final 标在哪个叶子上都白搭,你得从数据结构上把派发点的静态类型做具体(模板、CRTP 特化到具体类),或者上 LTO 让整个层级可证。

顺带交代时代性:C++20/23 没动 Itanium 这套 vtable + thunk 布局——ABI 是稳定承诺,动一下所有旧二进制都得重编;deducing this(C++23)改的是显式写 this 参数的语法,碰不到这套跨基类回拨。本文这套机制,可预见的未来都成立。

回到那张图,补三行小字

“查表跳转”那张图还能用,只是每个部件底下都得补一行小字:vptr 会随构造、析构改值,this 会在多继承里被 thunk 挪位置,那次 call 会在编译器看清类型时整个消失。三样东西你原以为是常量,其实全在——把虚调用当成一次静态的数组下标访问,就是把这三个变量看死了,五处惊讶各对应你看死了哪一个。

所以留给你带走的不是布局细节,那 Compiler Explorer 上五分钟能翻出来。是这条能落到你自己代码上的判据:下次想让一条虚派发热路径跑得快,先别急着标 final,去看调用点手里攥着的静态类型精确到哪一层。精确到具体类或 final 类型,编译器替你把 call 删了;只攥着一个基类指针,你标什么都省不掉那次间接跳转,和它挡住的所有优化。虚调用快不快,从来不写在虚表里,写在调用点的静态类型里。如果你也喜欢这样对着汇编逐行较真,云栈社区 上还有不少同好会拿 Compiler Explorer 结果互相验证。

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




上一篇:知识图谱本体论+RAG+Agent落地:老刘说NLP社区纳新
下一篇:GPT-6 Astra 自称 AGI,跑分之外的电脑操作与漏洞利用更需警惕
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-9-6 08:43 , Processed in 0.794937 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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