cover

CPP中的虚表

谈到虚表, 自然而然的我们会想到cpp中"继承与多态"; Rust有意地弱化了面向对象的概念, 转而使用了 "特征Trait" 这一更"扁平化"的方式来描述多态. 今天我们先来谈谈Cpp中的多态:)

c语言结构体的等号赋值操作

由于CPP选择兼容C, 让我们先来观察C语言中对结构体的操作:

这是一段很基础的代码, 但这已经足够, 我们来观察一下其在未开启优化时生成的汇编代码是如何的:

即使读不懂也没关系, 在这里快速的为你讲解一下: 首先略过asm中开头和结尾的代码, 只关注我们程序中main函数内的两行代码。第一行代码对应了6句asm代码, 每两句为一个组, 对应了为foo2结构体的三个赋值操作. 其中第一句li a5,6将某个立即数分配到一个寄存器上, 其中寄存器为a5, 立即数为6; 第二句代码sw a5,-32(s0)a5上的1 word数据(1word = 4字节 = 32位)存放到目标地址-32(s0)中, 目前s0内是当前函数的栈底地址, -32代表从s0位置向下偏移32字节. 经过三组赋值操作后, 目前栈中的数据看起来是这样的:

其中foo2内的数据排列为:

padding: 为了满足指令集的​内存对齐要求​, 这里为8字节对齐.

之前我们略过的开头和结尾的代码, 实际上这部分代码为编译器自动插入的用来保存(还原)需要被调用者函数保存(还原)的寄存器内的数据, 函数的返回地址(比如main函数调用fucA后的下一条指令), 保存(恢复)调用者函数的栈帧基址, 以及分配(释放)函数栈帧的代码. 由 addi sp,sp,-32 可知asm指令为当前main函数分配了一个大小为32字节的栈帧(立即将栈指针寄存器地址向下偏移32个字节)

函数调用约定: 在"函数调用另一个函数"这一动作发生时, 产生了调用者和被调用者两个角色, 两个角色有着各自的职责, 比如调用者函数如何向被调用者传递参数, 被调用者如何返回值, 调用双方如何保存当前程序的运行状态, 用以还原函数调用发生前的现场(保存函数调用发生前的寄存器状态).

回到我们的主线上来, 需要关注的是第二行代码所对应的asm指令: 前两个指令将全局变量foo1的地址读取到寄存器a5当中; ld a4,-32(s0)sd a4,0(a5)将以s0下偏移32字节处为基址的double word数据(8字节数据)加载至a4寄存器中, 并存放到a5寄存器内地址位置处。这实际上是将当前栈帧内的foo2.xfoo3.c with padding 复制到了全局变量foo1.xfoo2.c with padding位置处, 之后以同样的方式将foo2.y 复制到了 foo1.y位置处。值得一提的是foo1可能位于数据段.bss内, 该数据段一般存放全局的还未初始化的数据.

抽象化的地址空间 需要澄清的是, 我们在编写代码时所考虑的stack, heap, .bss, .text等逻辑段在物理内存中并不存在(或者说并不一一对应), 原因在于操作系统为每一个应用都提供了一个抽象化且透明的地址空间, 逻辑段在该地址空间中是连续且一一对应的. 操作系统会进程分配一个页表, 将抽象化的地址空间中所被用到的地址各自映射到一个物理地址, 这一映射过程以为单位, 由于不同的页转换到物理内存遵循着不同的线性映射, 所以逻辑段不连续于物理内存中.

page-table

由此我们得出, c语言中的结构体的等号赋值操作实际对应了1 : 1的内存复制指令. 这非常关键, 由于cpp要兼容于c, 所以我们大胆的推测: cpp中的拷贝构造对应了c语言中的1:1内存复制.

cpp 中的单继承

以下是cpp中单继承的示例, 其中Derived类派生自Base类:

我们在main函数中创建了一个局部变量派生类对象d1, 并使用拷贝构造的方式将它赋值给了局部变量基类b1, 其中发生了"隐式的类型转换".

我们都知道, 在d1赋值给b1(也可以形象地但不准确地描述为d1转化为b1)后, 原本存在于d1中的成员变量和函数将不复存在, 无法再使用b1.derived_var来访问该值, 这其中又发生了什么呢?让我们来观察一下该段示例代码的汇编代码:

首先是main函数的片段:

在该main函数片段中一共做了3件事情, 第1行和第4行确定了局部变量d1main函数栈帧中的基地址, 存储至a0寄存器; 第2, 3行将Derived类的构造函数参数(两个立即数)添加至a1a2中; 第5行则是调用Derived类的构造函数.

再来观察Derived类的构造函数的汇编指令代码片段:

该片段中, 从上一步骤中的3个参数(参数13, 参数27, 构建局部变量的基地址)中取出了其中两个: 父类构造函数所需的参数13 和基地址, 并先行调用了父类的构造函数_ZN4BaseC2Ei, 待其执行完毕后, 从函数栈内取出位于s0下偏移24处的双字长局部变量基地址至a5, 取出位于s0下偏移32处的双字长(8字节)参数27a4, 最后, 在基地址偏移量8处存放了参数27.

为什么偏移量为8? 由于基类的Base的大小为4字节, 不满足RISCV的8字节内存对齐要求, 故在Base的数据后添加4字节的padding.

经过此实验我们得出了一个严谨的结论: 派生类的构建是从父类开始的, 并且是先存放父类的成员, 再存放派生类的成员.

此时d1main函数栈帧上的数据排列为:

可以想象在继承链存在时(长度大于2的继承), 子类的数据排列是如何的: 从继承链最开始依次排列.

对象切片 (Object Slicing)

事情还没有结束, 接着观察接下来的main函数的汇编代码片段:

第2行代码从s0下偏移32处-即为d1的基地址处取出单字长的数据存放至a4, 即取出了d1.base_var, 第3行代码直接将a4的数据拷贝b1所在的位置.

d1被截断了🤯! 在编译期d1转换至b1的过程中, d1.derived_var被汇编代码直接丢弃, 仿佛以下的情形发生:

这就是单继承下的对象切片 (是 [from,to] 的截取) , 截断 (是 [0,to] 的截取) 是切片的特例.

如标题与上述实验所言, 在单继承时, 数据是从基类到派生类依次排列的, 有一种"设计与实现协调"之美, 但是在多继承下, 事情变得复杂起来, 因为多继承不仅仅是纵向的延申, 还伴有横向的扩展, 这使得类型转换不再是截断, 而是切片. 在这里我们不讨论多继承下的情况.

cpp 中的虚函数与虚表

面向对象的另一特性是多态, 其允许​不同对象对同一消息 (方法调用) 做出差异化响应, 具体实现依赖于对象的​实际类型​ (动态类型) 而非声明类型 (静态类型) . 其本质是通过​抽象接口统一操作, 隐藏具体实现细节, 实现代码的​松耦合​扩展性.

考虑一下代码:

这是一个十分不标准的例子, 但之后我们会修改它. 这段代码中result的值是什么呢? 答案就在汇编代码当中:

我们可以看到一切在编译期就已经确定, drawShape内部调用的是Drawabledraw()函数, 所以答案是false. 可这并不能满足我们的要求, 我们希望drawShape函数可以动态地找到实现类对应的方法, 该怎么办呢🤔? 答案就是使用虚函数:

当一个类存在虚函数或重写了父类的虚函数时,其头部会内含一个(在多继承下是多个)虚函数表指针,它指向虚函数表,虚函数表内记录了当前类对于虚函数的实现(重写)函数的地址。

当构建c时, 会向其头部写入虚表指针:

当执行drawShape(c);时:

mainc的地址(引用)传递给drawShape, 其内通过重要的 jalr (jump an link register) 命令, 直接跳转到Circle::draw()的入口位置进行执行, 并将结果存放至 a0

编译器优化对于虚表的影响

在正常的多态流程中,对象会保存虚表的地址。在调用虚函数时,程序首先读取对象的虚表指针,之后再通过偏移量访问虚函数表内的某一个特定函数。但在当前的流程中,由于不涉及复杂情况(多继承等),程序使用保存虚函数内函数指针替代保存虚表的方式,能够减少汇编代码行数。比如在当前示例中,由于c直接保存了虚函数表内的Circle::draw()的入口地址,所以drawShape的汇编代码可精简为:

jalr 伪指令

jalr是多态的重要实现方式,其用于间接跳转到寄存器中存储的地址,同时保存返回地址到 ra 寄存器。这是 RISC-V 中实现间接函数调用的标准方式。虚函数地址在编译时无法确定(因为多态),必须在运行时从虚表中动态加载到寄存器,再通过 jalr 跳转。