YSU
C ABI

ABI 版本

C 的调用约定下,函数签名对不上编译器不会报错。被调方会把数据指针当成函数指针用,或者从寄存器里捞一个垃圾值当参数——然后崩在一个跟问题毫无关系的地方。

C 的调用约定下,函数签名对不上编译器不会报错。被调方会把数据指针当成函数指针用,或者从寄存器里捞一个垃圾值当参数——然后崩在一个跟问题毫无关系的地方。

这个项目为此设了三道闸,各自挡不同的事。

三个版本号

分清楚,它们不是一回事。

名字在哪什么时候变当前值
YSU_CAPI_VERSION头文件与 ysu-capi函数签名变化时2
产品版本version.tomlbrowser_version每次发布0.1.0
引擎版本常量ysu::ENGINE_VERSION跟 Cargo 的包版本走0.1.0

外壳显示的、ysu_engine_version() 返回的是产品版本

ysu_capi_version() 返回的是接口版本

什么时候要加一

要加:

  • 参数个数、类型、顺序变化
  • 返回类型变化
  • 删掉导出函数
  • 出参枚举的取值含义变化(比如 YSU_TARGET_* 的三个值换了数字)

不用加:

  • 新增导出函数——旧调用方不受影响
  • 函数内部行为修正
  • 错误信息文案变化
  • 渲染结果的样式变化

新增函数不动版本号,但头文件与实现要一起改,第二道闸盯着这件事。

三个地方要同步

改签名的时候,四处都要动:

  1. ui/include/ysu_capi.hpp 里的函数声明
  2. crates/ysu-capi/src/lib.rs 里的实现
  3. ui/include/ysu_capi.hpp 里的 #define YSU_CAPI_VERSION
  4. crates/ysu-capi/src/lib.rs 里的 const VERSION: u32

第 3 与第 4 必须相等,有测试盯着。

三道闸

第一道:启动时比对版本号

ui/src/main.cppQApplication 建好之后、主窗口建出来之前做一次检查:

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"
}

外壳的每个源文件都是这么写的。

On this page