ABI 版本
C 的调用约定下,函数签名对不上编译器不会报错。被调方会把数据指针当成函数指针用,或者从寄存器里捞一个垃圾值当参数——然后崩在一个跟问题毫无关系的地方。
C 的调用约定下,函数签名对不上编译器不会报错。被调方会把数据指针当成函数指针用,或者从寄存器里捞一个垃圾值当参数——然后崩在一个跟问题毫无关系的地方。
这个项目为此设了三道闸,各自挡不同的事。
三个版本号
分清楚,它们不是一回事。
| 名字 | 在哪 | 什么时候变 | 当前值 |
|---|---|---|---|
YSU_CAPI_VERSION | 头文件与 ysu-capi | 函数签名变化时 | 2 |
| 产品版本 | version.toml 的 browser_version | 每次发布 | 0.1.0 |
| 引擎版本常量 | ysu::ENGINE_VERSION | 跟 Cargo 的包版本走 | 0.1.0 |
外壳显示的、ysu_engine_version() 返回的是产品版本。
ysu_capi_version() 返回的是接口版本。
什么时候要加一
要加:
- 参数个数、类型、顺序变化
- 返回类型变化
- 删掉导出函数
- 出参枚举的取值含义变化(比如
YSU_TARGET_*的三个值换了数字)
不用加:
- 新增导出函数——旧调用方不受影响
- 函数内部行为修正
- 错误信息文案变化
- 渲染结果的样式变化
新增函数不动版本号,但头文件与实现要一起改,第二道闸盯着这件事。
三个地方要同步
改签名的时候,四处都要动:
ui/include/ysu_capi.hpp里的函数声明crates/ysu-capi/src/lib.rs里的实现ui/include/ysu_capi.hpp里的#define YSU_CAPI_VERSIONcrates/ysu-capi/src/lib.rs里的const VERSION: u32
第 3 与第 4 必须相等,有测试盯着。
三道闸
第一道:启动时比对版本号
ui/src/main.cpp 在 QApplication 建好之后、主窗口建出来之前做一次检查:
if (ysu_capi_version() != YSU_CAPI_VERSION) {
QMessageBox::critical(
nullptr, QStringLiteral("版本不匹配"),
QStringLiteral("头文件是第 %1 版,库里是第 %2 版,两者必须一致。")
.arg(YSU_CAPI_VERSION)
.arg(ysu_capi_version()));
return 1;
}弹窗,然后退出,退出码 1。
它挡的是「外壳链到了旧版本的静态库」。改了 ABI 之后只重新编了 C++ 那边,忘了 cargo build -p ysu-capi,就是这种情况。
比对放在 Qt 建好之后,因为要弹窗;放在主窗口之前,因为窗口一建出来就会调内核。内核侧不做校验,只如实报告版本号——校验放在知道头文件版本的那一侧做才有意义。
第二道:签名一致性测试
版本号挡不住「改了实现忘了改头文件」——那种情况下头文件和库是同一次构建产出的,版本号天然一致,看不出来。
crates/ysu-capi/src/lib.rs 里有个 header_matches_the_implementation 测试,把两份文本读进来,逐个函数对参数个数。
它的边界写在测试注释里:只比对参数个数,不比对类型。把 double 改成 float 它不会报。类型对不上要靠人工看。
这道测试写完时,先拿原来那个错误的签名验了一遍,确认它真报得出来,不是空转。
写这个测试的解析器本身也踩过一次坑:Rust 的多行签名习惯在最后一个参数后面也留逗号,按「逗号数加一」计数会把所有多行签名都多算一个参数。改成按顶层逗号切段、数非空段才对。
第三道:版本号自身的测试
两条:
header_version_matches_the_library:从头文件里 parse 出宏值,和库里的常量比对version_is_current:钉住当前值是 2
第二条看着多余,作用是让「改版本号」这个动作必须显式发生。签名变了但忘了动版本号时,它不会报——它只是保证版本号的值是有意写的,不是随手改的。
头和库必须一起构建
ui/include/ysu_capi.hpp 是手写的,不是生成的。手写的好处是读得懂、能写注释;代价是它会飘。
所以常规流程是:
cargo build --release -p ysu-capi # 先编库
cmake --build ui/build -j # 再链外壳跳过第一步、只跑第二步,如果 ABI 变过就会被第一道闸拦下。如果 ABI 没变(只改了内核实现),链过去是旧库——那样跑起来行为是旧的,不报错。拿不准就重新配置一次 CMake。
为什么不自动生成头文件
生成的头文件读起来不好看,注释也带不过去。这个接口只有 26 个函数,手写加两道测试的代价比引入一个绑定生成器小。
如果函数数量涨到上百个,这个取舍要重新算。
从 C 里用
头文件扩展名是 .hpp,但内容完全是 C 的:
#include "ysu_capi.hpp"从 C 里引也成立——所有函数都在 extern "C" 里,用的类型也都来自 <stdbool.h>、<stdint.h>、<stddef.h>,没有 C++ 独有的东西。
从 C++ 里引时记得包一层:
extern "C" {
#include "ysu_capi.hpp"
}外壳的每个源文件都是这么写的。