说明: 以下是在 opencode 、k3和deepseek v4 pro交互后的产物。全程AI。谨供参考。

XTP Rust FFI 学习笔记

基于 D:\xtp-rust-k3\xtp-rust-fixed\ 项目源码分析


〇、白话入门:用送牛奶讲 XTP

假设你住在一个小区,想每天喝到新鲜牛奶。小区里有两家配送公司用的是同一套运营体系:

公司 产品 XTP 对应 一句话
🥛 鲜奶公司 牛奶(行情数据) QuoteApi + QuoteSpi “我看行情”
🥤 酸奶公司 酸奶(交易服务) TraderApi + TraderSpi “我下单交易”

下面以鲜奶公司为例,把整个 XTP 体系讲清楚。


第一步:拨通热线(= 创建 QuoteApi)

你想订牛奶,首先要打通鲜奶公司的客服热线

rust 侧:  QuoteApi::new(1, "./data", Debug, true)?
牛奶侧:  "您好,鲜奶公司客服热线已接通,工号 1 为您服务。"

你告诉客服你的客户编号(client_id = 1。1~99 是普通用户,100~255 是公司内部预留号)、存放订单记录的文件夹(save_file_path),以及你希望客服跟你唠多细(log_level = Debug 表示"芝麻小事也告诉我")。

这里有一个关键细节:鲜奶公司是外资企业,客服只会说英语(C++)。但你只会中文(Rust)。所以你雇了一个翻译员(FFI 层),每次你打电话,翻译员帮你把中文指令转成英语,再把英语回复转成中文。

你(Rust)             翻译员(quote_ffi.rs)         鲜奶公司(C++ DLL)
───────              ──────────────────         ──────────────────
"我要订牛奶"    →    "I want milk"        →    客服系统处理
                     ↑                          ↓
                   翻译员用 libloading       "OK, what kind?"
                   加载 DLL + 查找函数符号    ←
                   mangled name 双保险查找

这就是 RawQuoteApi 干的事——加载 DLL、找到 CreateQuoteApi 这个"接线员培训手册"、拨通热线、拿到一个客服工号obj 指针)。


第二步:告知地址(= 注册 SPI / RegisterSpi)

光打电话不够——你得告诉公司牛奶送到哪儿

api.register_spi(Box::new(MySpi));
// "我叫张三,住阳光小区 3 栋 101,门口有个蓝色牛奶箱。"

你填了一张地址登记表QuoteSpiHolder),上面有两栏:

栏位 内容 牛奶公司用途
送达方式速查表(vtable 指针 → QUOTE_SPI_VTABLE 38 种牛奶该放哪个格子 送奶工一看就知道:全脂奶放左边、脱脂奶放右边…
你的口味偏好(callback → Box<dyn QuoteSpi> “我喜欢全脂奶,脱脂奶不要,酸奶可以” 你告诉公司怎么处理每种情况

翻译员把这张表翻译成英语版,交给鲜奶公司存档。从此鲜奶公司的**送奶工(后台线程)**就知道:张三 = 阳光小区 3 栋 101 = 蓝色牛奶箱。

关键:地址登记表的原件(Box<QuoteSpiHolder>)你留了一份在手上。鲜奶公司只拿了一份复印件(裸指针)。将来你搬家注销服务时,你先通知公司停送,再自己销毁原件——顺序不能反(Drop 顺序保证)。


第三步:支付认证(= Login)

地址登记了,但你还没付钱。

api.login("127.0.0.1", 6001, "zhangsan", "pass123", ProtocolType::Tcp, None)?;
// 客服: "请提供支付宝账号和密码,我们验证一下。"

login 是一个同步阻塞电话——你拿着电话等,直到客服说"验证通过"才能挂。如果客服一直不接(服务器没开),你会在电话里等很久然后收到"连接失败"。

你提供的信息 XTP 参数 含义
服务器地址 "127.0.0.1":6001 鲜奶公司总部的电话
支付宝账号 "zhangsan" 你的用户名
支付宝密码 "pass123" 你的密码
配送方式 ProtocolType::Tcp 普通快递还是无人机

第四步:选牛奶品种(= Subscribe)

支付成功,现在你可以指定要哪些牛奶了。

api.subscribe_market_data(&["600000", "000001"], ExchangeType::SH)?;
// "我要上海牧场的 600000 号全脂奶和 000001 号脱脂奶。"

注意:这个电话立刻挂断——客服说"收到,已记录"就结束了(同步返回 Ok(()))。但牛奶不是电话里给你的——送奶工后面会上门送达

subscribe 电话流程:
  你: "我要 600000"
  客服: "好的已记录。"  ← 同步返回 Ok(())
  ——电话挂断——

送奶工上门流程(异步回调):
  送奶工: "叮咚!600000 号全脂奶,新鲜价 12.34 元/瓶"
         ↓ on_depth_market_data(&md, ...)  ← 这是异步的!
  送奶工: "叮咚!600000 号全脂奶,最新价 12.35 元/瓶"
         ↓ 又来了...

第五步:送奶工上门(= SPI 回调)

鲜奶公司的送奶工有自己的送货路线(后台线程)。他们不看你的中文偏好表——只看公司系统里的英文版速查表(vtable):

送奶工视角(C++ DLL 内部):
  "我要给张三送全脂奶 → 查速查表 → 全脂奶放左边格子
   → 走到蓝色牛奶箱 → 打开左边格子 → 放进去 → 下一家"

翻译员(trampoline)站在你家门口,把送奶工的动作翻译给你:

送奶工: 放全脂奶到左边格子
翻译员: "老板,全脂奶到了,价格 12.34!"  → 你的代码: on_depth_market_data(...)
送奶工: 放脱脂奶到右边格子  
翻译员: "老板,脱脂奶到了!"              → 你的代码: on_order_book(...)
送奶工: 贴一张"今日特价"通知
翻译员: "老板,有通知!"                  → 你的代码: on_error(...)

关键规则:送奶工很忙,每天要送几百家。你必须在他按门铃后 3 秒内开门取奶(回调必须快速返回),否则他后面的配送全部延误。如果你需要 5 分钟来处理牛奶(比如做杯拿铁),你应该先把奶拿进屋里(send 到 channel),关上门慢慢处理——别堵在门口让送奶工等。


第六步:取消服务(= Release / Drop)

某天你搬家了,不再需要牛奶。你需要主动打电话取消

// 你不需要手动调 release()——搬家(drop)时自动处理:
drop(api);

取消的正确顺序很重要:

⚠️  错误顺序(危险)                      ✅ 正确顺序
─────────────────────────               ─────────────
① 把门口牛奶箱拆了(释放 spi)           ① 打电话取消服务(Release)
② 打电话取消服务(Release)              ② 公司停止所有送奶工(停线程)
    ↑                                   ③ 把门口牛奶箱拆了(释放 spi)
   送奶工还在路上!到了发现牛奶箱不在       ↑
   奶洒一地 → crash!                    送奶工已收工,拆箱子安全 ✅
   这就是 Drop 顺序保证的意义。

还有一个已知事故(Issues §14):如果你只拨通热线但从未支付认证new() 了但没 login()),直接打电话说"我要取消"——客服系统会崩溃(DLL 内部状态不一致导致 Release() crash)。所以保险的做法是:至少尝试付一次款(哪怕失败了),再取消。


两种配送方式的区别(TCP vs UDP)

场景 TCP(普通快递) UDP(无人机直送)
怎么送 快递员开车,每趟签字确认 无人机空投,不等回执
速度 稳但慢 极快
丢件 发现丢了会补发 丢了就丢了,你要主动申请补发(Rebuild)
换地址(断连重连) 需要重新告诉公司你喜欢哪些奶 不需要——公司记得你的偏好
特殊服务 无人机可以绑定特定停机坪(CPU 亲和性)
只能用无人机的服务 🥤 酸奶特供(HKC 港股通数据)、📰 小区公告(指数推送)

鲜奶公司 vs 酸奶公司 的指令对比

你做的事 鲜奶公司(Quote) 酸奶公司(Trader)
拨热线 QuoteApi::new() TraderApi::new()
留地址 register_spi(QuoteSpi) register_spi(TraderSpi)
支付验证 login(...) login(...) → 返回 session_id
订奶 subscribe_market_data(...) insert_order(...)(下单买酸奶)
查询 query_all_tickers(...) query_position(...)(查库存)
送上门 on_depth_market_data(...) on_order_event(...)(成交通知)
出错了 on_error(...) on_error(...)
断连 on_disconnected(reason) on_disconnected(sid, reason)
取消 drop(api) drop(api)

翻译员的工具箱(FFI 封装的关键技术)

翻译员工具 小白比喻 对应技术
装载公司通讯录 找到鲜奶公司的电话本 libloading::Library::new()
接线员速查表 38 种牛奶 → 对应哪个送奶流程 vtable(QuoteApiVTable
地址登记表 你的门牌号和奶箱规格 QuoteSpiHolder(offset 0 = vtable 指针)
“稍等,我翻译一下” trampoline 函数中转 extern "C" fnguard() → trait 方法
空奶箱 有些送奶服务你不需要,但速查表不能空着 no-op stub(占住 vtable 槽位)
先停送奶再拆箱 取消顺序有讲究 字段声明顺序控制 Drop
查字典 中文 ↔ 英文互译 CStringCStri64 ↔ 时间戳解析
验钞机 确保收的钱是对的 结构体 sizeof/offset 布局验证(gen_layout.c

完整的一天(代码视角)

// === 早上 9:00: 起床订奶 ===
let mut api = QuoteApi::new(1, "./data", LogLevel::Info, false)?;
// "喂,鲜奶公司吗?我是 1 号客户。"

// === 9:01: 告诉公司我喜欢什么 ===
api.register_spi(Box::new(MySpi));
// "我叫张三,住阳光小区 3-101,门口蓝色牛奶箱。"

// === 9:02: 付款 ===
api.login("127.0.0.1", 6001, "zhangsan", "pass123", ProtocolType::Tcp, None)?;
// "支付宝验证通过,开始配送。"

// === 9:03: 选奶 ===
api.subscribe_market_data(&["600000"], ExchangeType::SH)?;
// "我要上海牧场的 600000 号全脂奶。"

// === 全天: 收奶 ===
// 送奶工不定时按门铃 → MySpi::on_depth_market_data() 被调用
// "600000 涨了!" "600000 跌了!" ...

// === 下午 3:00: 收盘,退订 ===
api.logout()?;
// 挂电话

// === 下午 3:01: 搬家 ===
drop(api);
// ① 通知公司停送(Release)
// ② 公司召回所有送奶工
// ③ 拆掉门口牛奶箱(释放 spi)
// ④ 扔掉电话本(卸载 DLL)

一句话总结:XTP 封装就是雇了一个翻译员,帮你把中文指令(Rust)翻译成英文(C++ DLL),并且帮你管理好"先停送奶、再拆奶箱"这类容易出错的先后顺序。剩下的章节是翻译员的操作手册——每一章讲一个具体技术细节。

一、C++ 对象 vtable 在 Rust FFI 中的读取方式

1.1 问题代码

// quote_ffi.rs line 144
let vtable = unsafe { *(obj as *const *const QuoteApiVTable) };

1.2 为什么是双重指针?

MSVC C++ x64 多态对象的内存布局:

obj (QuoteApi*) ──→ ┌──────────────────────┐
                    │ vtable_ptr  (8 bytes) │ ← offset 0
                    ├──────────────────────┤
                    │ 成员数据 1            │
                    │ ...                   │
                    └──────────────────────┘

对象的第一个 8 字节不是 vtable 本身,而是一个指向 vtable 的指针

1.3 拆解双重指针解引用

步骤 类型 含义
obj *mut c_void 指向 C++ 对象的指针
obj as *const *const QuoteApiVTable *const *const QuoteApiVTable 把这块内存重新解释为"存放 vtable 地址的位置"
*(...) *const QuoteApiVTable 解引用读出 8 字节,得到真正的 vtable 地址

1.4 内存关系图

obj = 0xAABBCC00
         │
         ▼
  ┌─────────────┐      ┌─────────────────────┐
  │ 0x12345000  │ ───→ │ release()           │
  │ (member)    │      │ get_api_version()   │
  │ ...         │      │ register_spi()      │  ← QuoteApiVTable
  └─────────────┘      │ login()             │
     C++ 对象           │ ...                 │
                        └─────────────────────┘

两跳才能到达函数入口:对象指针 → vtable 指针(对象前 8 字节)→ vtable 表(函数指针数组)。

1.5 常见错误

如果写成单层指针 *(obj as *const QuoteApiVTable),会把对象本身当成 vtable 使用——release() 的函数地址会读成对象头 8 字节的随机值,调用即崩溃。


二、libloading::Symbol<T> 用法

2.1 核心概念

let lib = unsafe { Library::new("xtpxquoteapi.dll") }?;
let sym: Symbol<CreateQuoteApiFn> = unsafe { lib.get(b"CreateQuoteApi")? };

Symbol<T> 是一个带生命周期约束的智能指针,指向 DLL 内部的函数地址。

2.2 与 Rust 原生函数的对比

概念 Rust 原生 libloading
获取函数地址 编译时链接 运行时动态查找
生命周期 'static 受 DLL 存活约束
类型安全 编译器保证 Symbol<T> 泛型标记

2.3 泛型参数 T 的作用

let create_fn: Symbol<CreateQuoteApiFn> = ...;

T 只做类型标签,不存任何数据。Symbol<T> 实现了 Deref<Target = T>,所以可以直接像函数指针一样调用 create_fn(...)

2.4 关键安全保证

Symbol<T> 借用 Library 的生命周期。一旦 Library 被 drop(DLL 卸载),所有从它派生的 Symbol 都跟着失效——Rust 的借用检查在编译期阻止 use-after-free。

2.5 项目中的符号查找双保险

// quote_ffi.rs line 123-134
let create_fn: Symbol<CreateQuoteApiFn> = 'find: loop {
    let names = [
        "?CreateQuoteApi@QuoteApi@API@XTPX@@...@Z",  // MSVC mangled name
        "CreateQuoteApi",                              // unmangled 回退
    ];
    for name in &names {
        if let Ok(sym) = unsafe { library.get::<CreateQuoteApiFn>(name.as_bytes()) } {
            break 'find sym;
        }
    }
    return Err(...);
};

C++ DLL 可能导出 mangled 或 unmangled 名称,尝试两种。


三、_library: Arc<Library> 在 RawQuoteApi 中的设计

3.1 字段定义

// quote_ffi.rs line 90-99
pub struct RawQuoteApi {
    _library: Arc<Library>,
    vtable: *const QuoteApiVTable,
    obj: *mut std::ffi::c_void,   // QuoteApi*
}

3.2 四个设计要点

29.2.1 生命周期锚点——防止 DLL 提前卸载
RawQuoteApi {
    _library  →  Library (DLL 句柄)  ← 唯一保持 DLL 加载的东西
    vtable    →  0x12345000  ────┐
    obj       →  0xAABBCC00  ────┤ 都在 DLL 地址空间内
}                                │
                                 ▼
                         xtpxquoteapi.dll 内存

只要 _library 存活,DLL 就不卸载,vtableobj 就始终有效。

29.2.2 Arc<Library> 而非 Library

允许多个 RawQuoteApi 实例共享同一个 DLL:

     RawQuoteApi #1 ──┐
                      ├──→ Arc<Library> { ref_count: 2 }
     RawQuoteApi #2 ──┘         │
                                ▼
                         xtpxquoteapi.dll (只加载一次)

最后一个实例 drop 时引用计数归零,DLL 才卸载。

29.2.3 命名前缀 _

_library 告诉 Rust “这个字段不会被显式读取,但别报警”。
它在结构体中的唯一目的就是用生命周期做担保——没有人会写 self._library.some_method()

29.2.4 声明顺序 = drop 顺序

Rust 按声明顺序 drop 字段。RawQuoteApi 有自己的 Drop impl:

时间线 ─────────────────────────────────────────────────→

  RawQuoteApi::drop() 执行
    └→ (self.vt().release)(self.obj)   ← ① Release() 先调用(DLL 仍加载)

  _library drop                          ← ② 后释放(DLL 可能卸载)
  vtable drop                            ← ③ no-op
  obj drop                               ← ④ no-op

如果 _library 在后面声明,它会在 Drop::drop() 之前被 drop → DLL 先卸载 → release() 调用已释放的 vtable → crash。


四、QuoteApi 结构体设计

4.1 字段定义

// quote.rs line 486-493
pub struct QuoteApi {
    // NOTE: field declaration order is load-bearing.
    // `raw` is dropped first (calling C++ `Release()`,
    // which stops callback threads), only afterwards
    // is `spi` freed.
    raw: RawQuoteApi,
    spi: Option<Box<QuoteSpiHolder>>,
    logged_in: bool,
}

4.2 为什么 spilogged_in 不放入 RawQuoteApi?

三个原因:

① 关注点分离

职责 对 SPI 的认知
quote_ffi.rs (RawQuoteApi) DLL 加载、vtable 跳转、裸 unsafe 调用 只知道 *mut c_void
quote.rs (QuoteApi) trait 回调、类型安全接口、登录状态 知道 QuoteSpiHolder、38 个回调、QuoteSpi trait

② 依赖方向不可逆

quote.rs ──→ quote_ffi.rs  (安全层依赖 FFI 层)

如果把 spi 放进 RawQuoteApi,要么形成循环依赖,要么把 400+ 行的 SPI 逻辑全部下沉到 FFI 文件。

③ 两组独立的 drop 约束无法合并

约束
RawQuoteApi DLL 卸载前必须调用 Release()
QuoteApi 回调线程停止后才能释放 SPI 内存

合并在一个 struct 里,需要同时满足两组排序。Rust 字段顺序只能表达一种。分离后各层管各层,编译器各自验证。

4.3 为什么 spiOption<Box<...>>

register_spi() 必须在 login() 之前调用,但 QuoteApi::new() 时还没有 SPI。Option 支持延迟注册:初始为 Noneregister_spi 时变成 Some


五、QuoteApi 的完整 Drop 顺序

5.1 执行流程

QuoteApi 没有自定义 Drop,字段按声明顺序依次 drop。rawRawQuoteApi,它有自定义 Drop,执行时会先跑 drop() 再释放内部字段。

────────────────── 时间线 ──────────────────────────────────→

① raw 开始 drop
   │
   ├─ RawQuoteApi::drop()               ← 自定义 drop,最先执行
   │   └─ (self.vt().release)(self.obj) ← C++ Release(),同步停止所有回调线程
   │
   ├─ ② _library drop                  ← Arc refcount -1(可能卸载 DLL)
   ├─ ③ vtable drop                    ← 裸指针 no-op
   └─ ④ obj drop                       ← 裸指针 no-op

⑤ spi 开始 drop
   │
   ├─ Option::drop() → Some → Box::drop()
   ├─ QuoteSpiHolder 内部释放:
   │   ├─ vtable 指针 → no-op
   │   └─ Box<dyn QuoteSpi> → 释放 trait object

⑥ logged_in drop                       ← bool,no-op

5.2 关键时序保证

① Release() 停回调线程 ──┐
                         │ 先后顺序由字段声明保证
⑤ spi 内存释放 ─────────┘

① Release() ────────────┐
                         │ 先后顺序由 RawQuoteApi 字段声明保证
② _library 卸载 DLL ────┘

两个 struct 嵌套,两层 drop 顺序,各自独立解决问题,互不干扰。


六、MSVC Mangled Name——符号查找原理与获取方法

6.1 问题代码

// quote_ffi.rs line 123-134
let create_fn: Symbol<CreateQuoteApiFn> = 'find: loop {
    let names = [
        "?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@EPEBDW4XTP_LOG_LEVEL@23@_N@Z",
        "CreateQuoteApi",
    ];
    for name in &names {
        if let Ok(sym) = unsafe { library.get::<CreateQuoteApiFn>(name.as_bytes()) } {
            break 'find sym;
        }
    }
    return Err(...);
};

6.2 不加 mangled name 会有什么问题?

libloadingget()字节精确匹配符号名。"CreateQuoteApi" 是 undecorated name,只有以下情况才会出现在 DLL 导出表中:

  1. 源码用 extern "C" 声明
  2. 用了 .def 文件显式指定导出名

如果 XTP 的 DLL 没有做上述处理,导出表里只存在 MSVC 自动生成的 mangled name:

CreateQuoteApi  ← 查不到,符号不存在
?CreateQuoteApi@QuoteApi@API@XTPX@@...@Z  ← 唯一的导出名

结果lib.get(b"CreateQuoteApi") 返回 ErrRawQuoteApi::new() 返回 SymbolNotFound,API 初始化失败。

即使 DLL 两种名字都导出了,把 mangled name 放第一位有额外好处——MSVC mangled name 是唯一的(编码了完整的函数签名),不会发生重名。

6.3 mangled name 结构拆解

以本项目中的符号为例:

?CreateQuoteApi              ← 函数名
@QuoteApi@API@XTPX           ← 类/命名空间层级(XTPX::API::QuoteApi)
@@SA                         ← static 成员函数
PEAV123@                     ← 返回类型:QuoteApi*(123 = 回指第3个名字组件)
E                            ← 参数列表开始
PEBD                         ← 参数1:const char*
W4XTP_LOG_LEVEL@23@          ← 参数2:enum XTP_LOG_LEVEL(23 = 回指引用)
_N                           ← 参数3:bool
@Z                           ← 结束标记

6.4 获取 mangled name 的方法

方法 1:dumpbin /EXPORTS(首选,最可靠)

MSVC 自带工具,在 Developer Command Prompt 中:

dumpbin /EXPORTS xtpxquoteapi.dll

输出示例:

ordinal  hint  RVA       name
     1     0  00012340  ?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@...
     2     1  00012890  ?Release@QuoteApi@API@XTPX@@QEAAXXZ
     3     2  00012A00  ?Login@QuoteApi@API@XTPX@@QEAAHPEBDH000H0@Z

优点:100% 准确,原始符号直接从 PE 导出表读取;缺点:需要安装 Visual Studio。

方法 2:Python pefile(自动化/CI 友好)
import pefile
pe = pefile.PE("xtpxquoteapi.dll")
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
    name = exp.name.decode() if exp.name else f"(ordinal {exp.ordinal})"
    print(f"  {name}")

优点pip install pefile 即可,适合脚本集成;缺点:第三方库依赖。

方法 3:Dependency Walker(depends.exe

经典 Windows GUI 工具,拖入 DLL 后展开函数列表,直接复制 mangled name。

方法 4:DLL Export Viewer(NirSoft)

免费 GUI 小工具,功能类似 depends.exe 但更现代、支持 x64。

方法 5:undname 反验证

MSVC 自带,把 mangled name 转回可读形式:

undname "?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@..."
# 输出: static class XTPX::API::QuoteApi * __cdecl
#        XTPX::API::QuoteApi::CreateQuoteApi(...)
方法 6:dumpbin /DISASM + 头文件反推

DLL 无导出表时,结合反汇编 + 头文件中的函数签名,根据参数个数和类型推断函数入口,再通过 undname 验证。

方法 7:nm / objdump -t(备选)
nm -D xtpxquoteapi.dll
objdump -t xtpxquoteapi.dll

注意:GNU 工具对 MSVC mangling 支持不完整,可能显示十六进制。

6.5 方法对比

方法 准确度 前置条件 适用场景
dumpbin /EXPORTS ★★★★★ VS 安装 首选,一次拿到所有符号
Python pefile ★★★★★ pip install pefile 自动化脚本/CI
Dependency Walker ★★★★★ GUI 工具 快速浏览,不想用命令行
DLL Export Viewer ★★★★★ 免费 GUI 比 depends.exe 更现代
undname ★★★★ VS 安装 验证/反推 mangled name
nm / objdump ★★★ MinGW/Cygwin 无 VS 时的备选
反汇编 + 头文件 ★★★★ VS + 头文件 DLL 无导出表时的最后手段

七、整体架构——作业流程与数据流转

7.1 文件职责总览

src/
├── common.rs          ← 共享:错误类型、枚举、字符串工具
│
├── quote_types.rs     ← Quote 数据结构(#[repr(C)] 映射 C++ struct)
├── quote_ffi.rs       ← Quote FFI 层:DLL 加载、vtable 调用、裸 unsafe
├── quote.rs           ← Quote 安全封装:trait 定义、SPI 桥接、生命周期管理
│
├── trader_types.rs    ← Trader 数据结构
├── trader_ffi.rs      ← Trader FFI 层
├── trader.rs          ← Trader 安全封装
│
└── lib.rs             ← crate 入口 + prelude

7.2 三层架构模型

以 Quote 为例:

┌────────────────────────────────────────────────────────────────┐
│  用户代码 (main.rs)                                             │
│  struct MySpi; impl QuoteSpi for MySpi { ... }                 │
│  api.login(...); api.subscribe_market_data(...);                │
└──────────────────────┬──────────────────────┬──────────────────┘
                       │ 调用                  │ 回调
                       ▼                       ▲
┌────────────────────────────────────────────────────────────────┐
│  安全封装层 (quote.rs)                                         │
│  QuoteApi { raw, spi, logged_in }                              │
│  - 类型安全的 public API                                       │
│  - QuoteSpi trait (38 个回调方法,均有默认空实现)               │
│  - QuoteSpiHolder: 构造 C++ 兼容的 SPI 对象                     │
│  - QuoteSpiVTable: 38 个 trampoline 函数指针                    │
│  - 字段声明顺序控制 drop 顺序                                   │
└──────────────────────┬──────────────────────┬──────────────────┘
                       │ delegate              │ trampoline 接收
                       ▼                       ▲
┌────────────────────────────────────────────────────────────────┐
│  FFI 层 (quote_ffi.rs)                                         │
│  RawQuoteApi { _library, vtable, obj }                         │
│  - libloading: 动态加载 xtpxquoteapi.dll                        │
│  - QuoteApiVTable: C++ 虚函数表的 Rust 镜像                     │
│  - 符号查找 (mangled + unmangled 双保险)                        │
│  - 裸 unsafe 调用: (self.vt().xxx)(self.obj, ...)              │
└──────────────────────┬──────────────────────┬──────────────────┘
                       │ extern "C"            │ extern "C"
                       ▼                       ▲
┌────────────────────────────────────────────────────────────────┐
│  C++ DLL (xtpxquoteapi.dll)                                    │
│  - CreateQuoteApi() → QuoteApi* (C++ 对象)                     │
│  - QuoteApi::RegisterSpi(QuoteSpi*)                             │
│  - QuoteApi::Login(...) → 建立 TCP/UDP 连接                    │
│  - 后台线程: 接收行情 → 调用 QuoteSpi 虚函数                     │
└────────────────────────────────────────────────────────────────┘

7.3 初始化流程(Rust → C++)

QuoteApi::new(1, "./data", Debug, true)
│
├─ ① quote_ffi.rs: RawQuoteApi::new()
│   │
│   ├─ Library::new("xtpxquoteapi.dll")          ← 加载 DLL
│   │   └→ _library: Arc<Library>                ← 保持 DLL 存活
│   │
│   ├─ library.get(b"CreateQuoteApi")            ← 查找 CreateQuoteApi 符号
│   │   └→ create_fn: Symbol<CreateQuoteApiFn>
│   │
│   ├─ create_fn(client_id, path, level, udp)    ← 调用 DLL 创建 C++ 对象
│   │   └→ obj: *mut c_void (QuoteApi*)
│   │
│   └─ *(obj as *const *const QuoteApiVTable)    ← 读取 vtable 指针
│       └→ vtable: *const QuoteApiVTable
│
└─ ② quote.rs: QuoteApi { raw, spi: None, logged_in: false }

7.4 SPI 注册流程

用户调用 api.register_spi(Box::new(MySpi)) 时的完整链路:

QuoteApi::register_spi(spi: Box<dyn QuoteSpi>)
│
├─ ① 构造 QuoteSpiHolder
│   QuoteSpiHolder {
│       vtable: &QUOTE_SPI_VTABLE,     ← 指向静态 vtable
│       callback: spi                  ← 用户的 Box<dyn QuoteSpi>
│   }
│
├─ ② raw.register_spi(&holder_ptr as *mut c_void)
│   └→ (self.vt().register_spi)(self.obj, holder as *mut c_void)
│       └→ DLL: QuoteApi::RegisterSpi(QuoteSpi*)
│           将 holder 指针存入 DLL 内部
│
└─ ③ self.spi = Some(Box::new(holder))
    将 holder 的所有权保留在 Rust 侧,防止被提前释放

关键点:holder 指针同时被 C++ DLL 和 Rust self.spi 持有。C++ 用裸指针调回调,Rust 用 Option<Box<...>> 管理生命周期。

7.5 主调流程(Rust → C++:登录、订阅、查询)

用户: api.login("127.0.0.1", 6001, "user", "pass", Tcp, None)
│
├─ QuoteApi::login(...)
│   ├─ self.raw.login(ip, port, user, password, sock_type, local_ip)
│   │   └→ RawQuoteApi::login()
│   │       ├─ CString::new(...) 将 &str 转为 C 字符串
│   │       └─ unsafe { (self.vt().login)(self.obj, cip, port, cu, cp, ...) }
│   │           └→ DLL: 建立 TCP/UDP 连接到 XTP 行情服务器
│   │               └→ 返回 0 = 成功
│   └─ self.logged_in = true          ← 更新状态
│
用户: api.subscribe_market_data(&["600000"], ExchangeType::SH)
│
├─ QuoteApi::subscribe_market_data(...)
│   └→ self.raw.subscribe_market_data(tickers, exchange_id)
│       └→ RawQuoteApi::call_sub_unsub(vfn, tickers, ex, "SubscribeMarketData")
│           ├─ sub_cstrs(tickers) → Vec<CString>, Vec<*mut c_char>
│           └─ unsafe { vfn(self.obj, ptrs, count, ex) }
│               └→ DLL: QuoteApi::SubscribeMarketData(...)
│                   └→ 服务器开始推送行情

所有主调遵循同一模式:&str → CString → 裸指针 → vtable 跳转 → DLL

7.6 回调流程(C++ → Rust:行情数据推送)

这是最复杂的路径。XTP 服务器推送行情数据 → DLL 后台线程接收 → 通过虚函数回调 Rust:

XTP 行情服务器
│  TCP/UDP 推送
▼
DLL 内部回调线程
│ 持有 QuoteSpi* = &QuoteSpiHolder (在 Rust 堆上)
│
├─ QuoteSpi::OnDepthMarketData(...)
│   └→ 虚函数调用 → vtable[slot_4]
│       └→ tramp_on_depth_market_data(this: *mut QuoteSpiHolder, ...)
│           │  ← 这里已经从 C++ 进入了 Rust!
│           │
│           ├─ holder!(this)                            ← 从裸指针恢复 &QuoteSpiHolder
│           ├─ data_ref!(market_data)                   ← 从裸指针恢复 &XtpMarketData
│           ├─ c_slice(bid1_qty, bid1_count)            ← 从裸指针构建 &[i64]
│           └─ guard(|| holder.callback.on_depth_market_data(...))
│               │  ← catch_unwind 包裹,panic 不会 unwind 到 C++
│               │
│               └→ 用户代码: MySpi::on_depth_market_data(&data, ...)
│                   println!("{}: last={}", data.ticker_str(), data.last_price);
│                   // ← 必须快速返回,不能阻塞 DLL 回调线程

7.7 完整数据流示意图

                    ┌── 主调方向(Rust → C++)───────────────────→

  Rust 用户代码                Rust 安全层              Rust FFI 层           C++ DLL
  ────────────               ────────────             ────────────          ────────
  api.login(...)      →     self.raw.login(...)  →   vt().login(obj,...) →  Login()
  api.subscribe(...)  →     self.raw.subscribe() →   vt().subscribe()    →  Subscribe()
  api.query(...)      →     self.raw.query()     →   vt().query()        →  Query()

                    ←── 回调方向(C++ → Rust)────────────────────

  MySpi::on_data()    ←   trampoline 转调       ←   QuoteSpi vtable   ←  后台线程
     ↑                      ↑                         ↑                   ↑
  Box<dyn QuoteSpi>    QuoteSpiHolder.vtable    QUOTE_SPI_VTABLE     DLL 持有裸指针
  (在 Rust 堆上)        (在 Rust 堆上)           (static 全局)        &QuoteSpiHolder

7.8 回调路径上的安全措施(5 层防护)

位置 安全措施
① 空指针守卫 holder! / data_ref! C++ 传入 null 时跳过回调,不 crash
② 错误容错 rsp_ref! C++ 传入 null XTPRI 时用 XtpRspInfo::OK 替代
③ panic 隔离 guard() 函数 catch_unwind(AssertUnwindSafe(...)) 包裹,panic 不会 unwind 跨 FFI 边界
④ 切片安全 c_slice() 函数 null 指针或 count≤0 返回空切片,避免 from_raw_parts 越界
⑤ 生命周期 _library + 字段顺序 DLL 在 Release() 调用前不卸载,SPI 在回调线程停止后才释放

7.9 销毁流程

QuoteApi::drop() 触发
│
├─ ① raw.drop()                          ← 字段声明顺序保证先执行
│   ├─ RawQuoteApi::drop()
│   │   └─ (self.vt().release)(self.obj) ← C++ Release() — 停止所有回调线程
│   ├─ _library drop                     ← DLL 引用计数 -1
│   ├─ vtable drop                       ← 裸指针,no-op
│   └─ obj drop                          ← 裸指针,no-op
│
├─ ② spi.drop()                          ← 此时已无回调线程访问
│   └─ Box<QuoteSpiHolder>::drop()
│       ├─ vtable 指针 → no-op
│       └─ Box<dyn QuoteSpi>::drop()
│           └→ 用户 trait object 释放
│
└─ ③ logged_in.drop()                    ← no-op

八、关键设计决策总结

决策 原因
动态加载(libloading)而非静态链接 不需要 XTP SDK 编译时存在;用户只需提供 DLL
vtable 手工建模而非 bindgen XTP 头文件含大量 C++ 模板/宏,bindgen 无法生成;手工建模更精确且可控
SPI 作为 Rust trait 而非 C callback 提供类型安全 + 默认空实现(只需覆写关心的回调)+ 借用检查
Arc<Library> 而非 &'static Library 实际生命周期受 DLL 加载限制,不是 'staticArc 支持多实例共享
Box<dyn QuoteSpi> 而非泛型 <T: QuoteSpi> 用户 SPI 类型在运行时确定,dyn 避免单态化膨胀
字段声明顺序控制 drop 编译器强制保证顺序,零运行时开销,不会出错
panic 隔离 (catch_unwind) Rust panic 如果 unwind 进 C++ 栈帧是 UB;隔离后最多丢一个回调

九、C++ 对象内存布局的逆向方法

9.1 三类对象,三类拆解方式

对象类型 来源 拆解目标 Rust 产物
虚函数类(QuoteApi / QuoteSpi) xtpx_quote_api.h 虚函数声明顺序 → vtable 槽位编号 QuoteApiVTable / QuoteSpiVTable
数据结构体(XtpMarketData 等) xquote_x_api_struct.h sizeof / alignof / offsetof → 字段排列 #[repr(C)] struct
枚举 xquote_x_api_data_type.h 判别值 #[repr(u32/i32)] enum

9.2 虚函数类 → vtable 槽位编号

C++ 头文件中的虚函数声明顺序 = vtable 槽位顺序:

class QuoteApi {
public:
    static QuoteApi* CreateQuoteApi(...);   // static → 不进 vtable
    virtual void Release();                 // slot 0
    virtual const char* GetApiVersion();    // slot 1
    virtual XTPRI* GetApiLastError();       // slot 2
    virtual void RegisterSpi(QuoteSpi*);    // slot 3
    // ... 逐行数,跳过 static,跳过非 virtual
};

规则

  • static 成员函数不占 vtable 槽位
  • 析构函数如果没写 virtual,也不占
  • 槽位从 0 开始,严格按声明顺序递增
  • x64 下每个槽位 8 字节(一个函数指针)

验证:MSVC 的 /d1reportSingleClassLayout 开关:

cl /d1reportSingleClassLayoutQuoteApi test.cpp

输出类似:

class QuoteApi size(8):
  +-- 0  {vfptr}
QuoteApi::$vftable@:
  0 | &QuoteApi::Release
  1 | &QuoteApi::GetApiVersion
  2 | &QuoteApi::GetApiLastError
  ...

9.3 数据结构体 → 逐字段对齐

头文件中的关键信息:

#pragma pack(1)           // ← 决定对齐方式的指令
struct XtpMarketData {
    char ticker[16];      // offset 0,  size 16
    double last_price;     // offset 16, size 8
    double pre_close_price;// offset 24, size 8
    // ...
};
C++ 对齐指令 Rust 对应
#pragma pack(1) #[repr(C, packed)] — 但需要验证字段自身对齐
#pragma pack(8) 或默认 #[repr(C)] — x64 默认 8 字节对齐
无 pragma #[repr(C)]

位域特殊处理:MSVC 在 pack(1) 下,每个位域成员独立占 1 字节。Rust 必须每个 bit 用一个 u8

struct XtpNqFullInfo {
    uint32_t is_bond     : 1;   // → u8,不能用 bitflags 打包成 u32
    uint32_t is_etf      : 1;   // → u8
    uint32_t is_lof      : 1;   // → u8
    // ... 每个 bit 一个 u8,否则后续字段 offset 全错
};

十、内存布局的事前检测方法

10.1 检测时机分层

编译期 → 静态断言 → 单元测试 → CI 自动化 → 构造函数验证 → 冒烟测试
  ↑          ↑          ↑          ↑            ↑            ↑
 零成本    零成本    毫秒级     秒级        微秒级        毫秒级

10.2 编译期检测

// 方法 1: 常量数组长度技巧(无外部依赖)
macro_rules! const_assert_size {
    ($ty:ty, $expected:expr) => {
        const _: [(); $expected] = [(); std::mem::size_of::<$ty>()];
    };
}
const_assert_size!(XtpRspInfo, 128);

// 方法 2: static_assertions crate
use static_assertions::assert_eq_size;
assert_eq_size!(XtpMarketData, [u8; 预期的_SIZEOF]);

10.3 单元测试期检测

#[test]
fn xtp_market_data_layout() {
    assert_eq!(size_of::<XtpMarketData>(), 预期的_SIZEOF);
    assert_eq!(offset_of!(XtpMarketData, ticker),          0);
    assert_eq!(offset_of!(XtpMarketData, last_price),      16);
    assert_eq!(offset_of!(XtpMarketData, pre_close_price), 24);
    // ... 覆盖所有字段
}

10.4 运行时构造函数验证

impl RawQuoteApi {
    pub fn new(...) -> Result<Self, XtpError> {
        let obj = create_fn(...);
        let vtable = unsafe { *(obj as *const *const QuoteApiVTable) };

        // 1. vtable 非空
        if vtable.is_null() {
            return Err(XtpError::ApiError("null vtable".into()));
        }

        // 2. 探测 slot 1 (GetApiVersion) 非空 + 返回合理字符串
        let vt = unsafe { &*vtable };
        let ver = unsafe { (vt.get_api_version)(obj) };
        if ver.is_null() {
            return Err(XtpError::ApiError("vtable slot 1 (GetApiVersion) returned null — layout mismatch".into()));
        }
        let s = unsafe { std::ffi::CStr::from_ptr(ver) }.to_string_lossy();
        if s.is_empty() || s.len() > 100 {
            return Err(XtpError::ApiError(format!("vtable layout suspicious: GetApiVersion='{s}'")));
        }

        Ok(...)
    }
}

为什么探测 GetApiVersion:它是 slot 1,第一个有返回值的虚函数,调用无副作用。如果 vtable 槽位错位,返回的"字符串指针"要么是乱码(CStr 解析 crash),要么是 null(被守卫捕获)。

10.5 自动生成 C 黄金数据 + Rust 自动对比

见第十一章完整方案。


十一、C 生成布局清单 + Rust 自动对比方案

11.1 体系总览

gen_layout.c                          ← 编译运行,输出 layout.csv
     │
     ├─ 输出:sizeof / alignof / offsetof / enum discriminant
     │
     ▼
layout.csv  ────────────────────────── 黄金数据文件(检入版本控制)
     │
     │  被 read_layout_csv() 读取
     ▼
rust_layout_verify.rs                  ← cargo test 时逐行对比
     │
     ├─ 编译期 static_assert!          ← 第一道防线
     ├─ #[test] 逐 struct 断言         ← 第二道防线
     └─ #[test] CSV 对比测试           ← 第三道防线

11.2 C 端:gen_layout.c(在 XTP SDK 环境编译运行)

/**
 * gen_layout.c — XTP 内存布局 CSV 生成器
 *
 * 用法(在 XTP SDK 目录下):
 *   cl /I"include" gen_layout.c && gen_layout.exe > layout.csv
 *
 * 输出 CSV 列:
 *   category, name, field, kind, size, align, offset, discriminant
 */

#include <stdio.h>
#include <stddef.h>
#include <stdint.h>

// === XTP 头文件 ===
#include "xquote_x_api_struct.h"
#include "xquote_x_api_data_type.h"
#include "xtpx_api_struct_common.h"

// === 辅助宏 ===
#define CSV_HEADER "category,name,field,kind,size,align,offset,discriminant\n"

#define DUMP_SIZEOF(t) \
    printf("sizeof,%s,,size,%zu,%zu,,\n", #t, sizeof(t), _Alignof(t))

#define DUMP_OFFSET(t, f) \
    printf("offset,%s,%s,field,%zu,%zu,%zu,\n", #t, #f, sizeof(((t*)0)->f), _Alignof(((t*)0)->f), offsetof(t, f))

#define DUMP_ENUM(t, v) \
    printf("enum,%s,%s,discriminant,,,,%d\n", #t, #v, (int)(v))

int main(void) {
    printf(CSV_HEADER);

    // ============ 行情数据结构 ============

    DUMP_SIZEOF(XtpMarketData);
    DUMP_OFFSET(XtpMarketData, ticker);
    DUMP_OFFSET(XtpMarketData, last_price);
    DUMP_OFFSET(XtpMarketData, pre_close_price);
    DUMP_OFFSET(XtpMarketData, open_price);
    DUMP_OFFSET(XtpMarketData, high_price);
    DUMP_OFFSET(XtpMarketData, low_price);
    DUMP_OFFSET(XtpMarketData, qty);
    DUMP_OFFSET(XtpMarketData, turnover);
    DUMP_OFFSET(XtpMarketData, total_bid_qty);
    DUMP_OFFSET(XtpMarketData, weighted_avg_bid_price);
    DUMP_OFFSET(XtpMarketData, total_ask_qty);
    DUMP_OFFSET(XtpMarketData, weighted_avg_ask_price);

    DUMP_SIZEOF(XtpOrderBook);
    DUMP_OFFSET(XtpOrderBook, ticker);
    DUMP_OFFSET(XtpOrderBook, last_price);
    DUMP_OFFSET(XtpOrderBook, bid);
    DUMP_OFFSET(XtpOrderBook, ask);

    DUMP_SIZEOF(XtpTickByTick);
    DUMP_OFFSET(XtpTickByTick, ticker);
    DUMP_OFFSET(XtpTickByTick, type);
    DUMP_OFFSET(XtpTickByTick, entrust);

    DUMP_SIZEOF(XtpSpecificTicker);
    DUMP_OFFSET(XtpSpecificTicker, ticker);
    DUMP_OFFSET(XtpSpecificTicker, exchange_id);

    DUMP_SIZEOF(XtpQuoteStaticInfo);
    DUMP_OFFSET(XtpQuoteStaticInfo, ticker);

    DUMP_SIZEOF(XtpTickerPriceInfo);
    DUMP_OFFSET(XtpTickerPriceInfo, ticker);

    DUMP_SIZEOF(XtpQuoteFullInfo);
    DUMP_SIZEOF(XtpNqFullInfo);

    DUMP_SIZEOF(XtpQuoteRebuildReq);
    DUMP_SIZEOF(XtpQuoteRebuildResultRsp);

    DUMP_SIZEOF(Iopv);

    DUMP_SIZEOF(XtpIndexPress);
    DUMP_SIZEOF(XtpIndexPressStaticInfo);

    DUMP_SIZEOF(XtpHkcRealtimeLimit);
    DUMP_SIZEOF(XtpHkcMarketData);
    DUMP_SIZEOF(XtpHkcStaticInfo);

    // ============ 公共结构 ============

    DUMP_SIZEOF(XtpRspInfo);
    DUMP_OFFSET(XtpRspInfo, error_id);
    DUMP_OFFSET(XtpRspInfo, error_msg);

    // ============ 枚举判别值 ============

    DUMP_ENUM(XTP_LOG_LEVEL,     XTP_LOG_LEVEL_FATAL);
    DUMP_ENUM(XTP_LOG_LEVEL,     XTP_LOG_LEVEL_ERROR);
    DUMP_ENUM(XTP_LOG_LEVEL,     XTP_LOG_LEVEL_WARNING);
    DUMP_ENUM(XTP_LOG_LEVEL,     XTP_LOG_LEVEL_INFO);
    DUMP_ENUM(XTP_LOG_LEVEL,     XTP_LOG_LEVEL_DEBUG);
    DUMP_ENUM(XTP_LOG_LEVEL,     XTP_LOG_LEVEL_TRACE);

    DUMP_ENUM(XTP_EXCHANGE_TYPE, XTP_EXCHANGE_SH);
    DUMP_ENUM(XTP_EXCHANGE_TYPE, XTP_EXCHANGE_SZ);
    DUMP_ENUM(XTP_EXCHANGE_TYPE, XTP_EXCHANGE_NQ);
    DUMP_ENUM(XTP_EXCHANGE_TYPE, XTP_EXCHANGE_HK);
    DUMP_ENUM(XTP_EXCHANGE_TYPE, XTP_EXCHANGE_UNKNOWN);

    DUMP_ENUM(XTP_MARKET_TYPE,   XTP_MKT_INIT);
    DUMP_ENUM(XTP_MARKET_TYPE,   XTP_MKT_SZ_A);
    DUMP_ENUM(XTP_MARKET_TYPE,   XTP_MKT_SH_A);
    DUMP_ENUM(XTP_MARKET_TYPE,   XTP_MKT_BJ_A);
    DUMP_ENUM(XTP_MARKET_TYPE,   XTP_MKT_HK);
    DUMP_ENUM(XTP_MARKET_TYPE,   XTP_MKT_UNKNOWN);

    DUMP_ENUM(XTP_PROTOCOL_TYPE, XTP_PROTOCOL_TCP);
    DUMP_ENUM(XTP_PROTOCOL_TYPE, XTP_PROTOCOL_UDP);

    // ============ 常量字符串长度 ============

    printf("const,XTP_TICKER_LEN,,size,%zu,,,\n", (size_t)XTP_TICKER_LEN);
    printf("const,XTP_TICKER_NAME_LEN,,size,%zu,,,\n", (size_t)XTP_TICKER_NAME_LEN);
    printf("const,XTP_ERR_MSG_LEN,,size,%zu,,,\n", (size_t)XTP_ERR_MSG_LEN);

    printf("# EOF\n");
    return 0;
}

11.3 CSV 输出示例

运行 gen_layout.exe > layout.csv 后得到:

category,name,field,kind,size,align,offset,discriminant
sizeof,XtpMarketData,,size,88,8,,
offset,XtpMarketData,ticker,field,16,1,0,
offset,XtpMarketData,last_price,field,8,8,16,
offset,XtpMarketData,pre_close_price,field,8,8,24,
offset,XtpMarketData,open_price,field,8,8,32,
...
sizeof,XtpRspInfo,,size,128,4,,
offset,XtpRspInfo,error_id,field,4,4,0,
offset,XtpRspInfo,error_msg,field,124,1,4,
...
enum,XTP_EXCHANGE_TYPE,XTP_EXCHANGE_SH,discriminant,,,,1
enum,XTP_EXCHANGE_TYPE,XTP_EXCHANGE_SZ,discriminant,,,,2
...
const,XTP_TICKER_LEN,,size,16,,,
const,XTP_TICKER_NAME_LEN,,size,64,,,
const,XTP_ERR_MSG_LEN,,size,124,,,

11.4 Rust 端:rust_layout_verify.rs

//! XTP 内存布局验证模块
//!
//! 提供三层检测:
//! 1. 编译期 static_assert!     — 修改源码就能触发
//! 2. #[test] 逐 struct 断言    — cargo test 执行
//! 3. #[test] CSV 对比测试      — 与 C 生成的 layout.csv 逐行对比

use std::mem::{size_of, align_of, offset_of};

use crate::common::*;
use crate::quote_types::*;

// ============================================================================
// 1. 编译期静态断言
// ============================================================================

// 技巧:数组长度不匹配 → 编译失败
macro_rules! const_assert_size {
    ($ty:ty, $sz:expr) => {
        const _: [(); $sz] = [(); std::mem::size_of::<$ty>()];
    };
}
macro_rules! const_assert_offset {
    ($ty:ty, $field:ident, $off:expr) => {
        const _: [(); $off] = [(); std::mem::offset_of!($ty, $field)];
    };
}

// === 公开结构体(尺寸从 C 侧 layout.csv 抄过来) ===
const_assert_size!(XtpRspInfo, 128);
const_assert_offset!(XtpRspInfo, error_id, 0);
const_assert_offset!(XtpRspInfo, error_msg, 4);

// 注:以下数值需要在拿到 layout.csv 后填入
// const_assert_size!(XtpMarketData, ???);
// const_assert_offset!(XtpMarketData, ticker, 0);
// const_assert_offset!(XtpMarketData, last_price, 16);

// ============================================================================
// 2. 单元测试 — 逐 struct 断言
// ============================================================================

#[cfg(test)]
mod layout_tests {
    use super::*;

    #[test]
    fn xtp_rsp_info() {
        assert_eq!(size_of::<XtpRspInfo>(), 128);
        assert_eq!(offset_of!(XtpRspInfo, error_id), 0);
        assert_eq!(offset_of!(XtpRspInfo, error_msg), 4);
    }

    #[test]
    fn xtp_market_data() {
        // 从 layout.csv 中读取黄金值后填入
        // assert_eq!(size_of::<XtpMarketData>(), 黄金值);
        // assert_eq!(offset_of!(XtpMarketData, ticker), 0);
        // assert_eq!(offset_of!(XtpMarketData, last_price), 16);
    }

    #[test]
    fn xtp_order_book() {
        // assert_eq!(size_of::<XtpOrderBook>(), 黄金值);
    }

    #[test]
    fn xtp_tick_by_tick() {
        // assert_eq!(size_of::<XtpTickByTick>(), 黄金值);
    }

    // ============ 枚举判别值 ============

    #[test]
    fn enum_exchange_type() {
        assert_eq!(ExchangeType::SH      as u32, 1);
        assert_eq!(ExchangeType::SZ      as u32, 2);
        assert_eq!(ExchangeType::NQ      as u32, 3);
        assert_eq!(ExchangeType::HK      as u32, 4);
        assert_eq!(ExchangeType::Unknown as u32, 5);
    }

    #[test]
    fn enum_protocol_type() {
        assert_eq!(ProtocolType::Tcp as i32, 1);
        assert_eq!(ProtocolType::Udp as i32, 2);
    }

    #[test]
    fn enum_log_level() {
        assert_eq!(LogLevel::Fatal   as i32, 0);
        assert_eq!(LogLevel::Error   as i32, 1);
        assert_eq!(LogLevel::Warning as i32, 2);
        assert_eq!(LogLevel::Info    as i32, 3);
        assert_eq!(LogLevel::Debug   as i32, 4);
        assert_eq!(LogLevel::Trace   as i32, 5);
    }

    #[test]
    fn enum_market_type() {
        assert_eq!(MarketType::Init    as i32, 0);
        assert_eq!(MarketType::SZA     as i32, 1);
        assert_eq!(MarketType::SHA     as i32, 2);
        assert_eq!(MarketType::BJA     as i32, 3);
        assert_eq!(MarketType::HK      as i32, 4);
        assert_eq!(MarketType::Unknown as i32, 5);
    }

    // ============ 常量 ============

    #[test]
    fn string_constants() {
        assert_eq!(XTP_TICKER_LEN,      16);
        assert_eq!(XTP_TICKER_NAME_LEN, 64);
        assert_eq!(XTP_ERR_MSG_LEN,     124);
    }

    // ============ vtable 尺寸 ============

    #[test]
    fn vtable_sizes() {
        use crate::quote_ffi::QuoteApiVTable;
        use crate::quote::QuoteSpiVTable;
        // x64 下每个槽位 = 8 字节 (函数指针)
        assert_eq!(size_of::<QuoteApiVTable>(), 38 * 8);
        assert_eq!(size_of::<QuoteSpiVTable>(), 38 * 8);
    }
}

// ============================================================================
// 3. CSV 对比测试 — 自动读取 layout.csv 逐行校验
// ============================================================================

#[cfg(test)]
mod csv_verify_tests {
    use std::collections::HashMap;
    use std::fs;
    use std::mem::{size_of, align_of, offset_of};
    use std::path::Path;

    /// 解析 layout.csv 中一行数据的结构
    #[derive(Debug)]
    struct CsvRow {
        category: String,    // sizeof / offset / enum / const
        name:     String,    // 结构体/枚举名
        field:    String,    // 字段名 (offset 行有值)
        kind:     String,    // size / field / discriminant
        size:     usize,
        align:    usize,
        offset:   usize,
        disc:     i64,       // 枚举判别值
    }

    /// 读取并解析 layout.csv
    fn read_layout_csv() -> Vec<CsvRow> {
        let csv_paths = [
            "layout.csv",
            "../layout.csv",
            "../../layout.csv",
        ];

        let content = csv_paths.iter()
            .find_map(|p| fs::read_to_string(p).ok())
            .expect("layout.csv not found. Run gen_layout.exe > layout.csv first");

        content.lines()
            .skip(1) // skip header
            .filter(|l| !l.is_empty() && !l.starts_with('#'))
            .filter_map(|line| {
                let cols: Vec<&str> = line.split(',').collect();
                if cols.len() < 8 { return None; }
                Some(CsvRow {
                    category: cols[0].to_string(),
                    name:     cols[1].to_string(),
                    field:    cols[2].to_string(),
                    kind:     cols[3].to_string(),
                    size:     cols[4].parse().unwrap_or(0),
                    align:    cols[5].parse().unwrap_or(0),
                    offset:   cols[6].parse().unwrap_or(0),
                    disc:     cols[7].parse().unwrap_or(0),
                })
            })
            .collect()
    }

    /// 注册:Rust 类型名 → (size, align, offset_map)
    struct RustLayout {
        size:   usize,
        align:  usize,
        offsets: HashMap<String, usize>,
    }

    /// 注册需要验证的 Rust 结构体布局
    fn register_rust_layouts() -> HashMap<String, RustLayout> {
        use std::mem::{size_of, align_of, offset_of};

        let mut map = HashMap::new();

        // 宏简化注册
        macro_rules! reg {
            ($name:literal, $ty:ty, [ $($field:ident),* $(,)? ]) => {
                let mut offs = HashMap::new();
                $( offs.insert(stringify!($field).to_string(), offset_of!($ty, $field)); )*
                map.insert($name.to_string(), RustLayout {
                    size:  size_of::<$ty>(),
                    align: align_of::<$ty>(),
                    offsets: offs,
                });
            };
        }

        reg!("XtpRspInfo",      XtpRspInfo,      [error_id, error_msg]);
        reg!("XtpMarketData",   XtpMarketData,   [ticker, last_price, pre_close_price, open_price, high_price, low_price, qty, turnover]);
        reg!("XtpOrderBook",    XtpOrderBook,    [ticker, last_price, bid, ask]);
        reg!("XtpTickByTick",   XtpTickByTick,   [ticker, type_, entrust]);
        reg!("XtpSpecificTicker", XtpSpecificTicker, [ticker, exchange_id]);
        reg!("XtpQuoteStaticInfo", XtpQuoteStaticInfo, [ticker]);
        reg!("XtpTickerPriceInfo", XtpTickerPriceInfo, [ticker]);
        reg!("XtpQuoteFullInfo", XtpQuoteFullInfo, []);
        reg!("XtpNqFullInfo",   XtpNqFullInfo,    []);
        reg!("Iopv",            Iopv,             []);

        map
    }

    #[test]
    fn csv_compare_sizes() {
        let csv_rows = read_layout_csv();
        let rust = register_rust_layouts();

        for row in &csv_rows {
            if row.category != "sizeof" { continue; }

            let name = &row.name;
            let c_size = row.size;

            match rust.get(name) {
                Some(rl) => {
                    assert_eq!(
                        rl.size, c_size,
                        "{} size mismatch: Rust={}, C={}", name, rl.size, c_size
                    );
                }
                None => {
                    println!("  NOTE: '{}' not in Rust registry — skipping", name);
                }
            }
        }
    }

    #[test]
    fn csv_compare_offsets() {
        let csv_rows = read_layout_csv();
        let rust = register_rust_layouts();

        for row in &csv_rows {
            if row.category != "offset" { continue; }

            let name = &row.name;
            let field = &row.field;
            let c_offset = row.offset;

            let rl = rust.get(name)
                .unwrap_or_else(|| panic!("Unknown struct '{}' in CSV offset row", name));

            match rl.offsets.get(field) {
                Some(&r_offset) => {
                    assert_eq!(
                        r_offset, c_offset,
                        "{}::{} offset mismatch: Rust={}, C={}", name, field, r_offset, c_offset
                    );
                }
                None => {
                    println!("  NOTE: {}::{} not in Rust offset registry — skipping", name, field);
                }
            }
        }
    }

    #[test]
    fn csv_compare_enum_discriminants() {
        let csv_rows = read_layout_csv();

        // 手动注册枚举判别值(也可以用宏,这里保持显式)
        let enums: HashMap<(&str, &str), i64> = {
            let mut m = HashMap::new();
            m.insert(("XTP_EXCHANGE_TYPE", "XTP_EXCHANGE_SH"),      1);
            m.insert(("XTP_EXCHANGE_TYPE", "XTP_EXCHANGE_SZ"),      2);
            m.insert(("XTP_EXCHANGE_TYPE", "XTP_EXCHANGE_HK"),      4);
            m.insert(("XTP_PROTOCOL_TYPE", "XTP_PROTOCOL_TCP"),     1);
            m.insert(("XTP_PROTOCOL_TYPE", "XTP_PROTOCOL_UDP"),     2);
            m
        };

        for row in &csv_rows {
            if row.category != "enum" { continue; }
            let key = (row.name.as_str(), row.field.as_str());

            match enums.get(&key) {
                Some(&rust_disc) => {
                    assert_eq!(
                        rust_disc, row.disc,
                        "{}::{} enum discriminant mismatch: Rust={}, C={}",
                        row.name, row.field, rust_disc, row.disc
                    );
                }
                None => {
                    println!("  NOTE: {}::{} not in Rust enum registry — skipping",
                             row.name, row.field);
                }
            }
        }
    }
}

11.5 使用步骤

步骤 1: 在 XTP SDK 目录编译运行 C 生成器
  cl /I"include" gen_layout.c && gen_layout.exe > layout.csv

步骤 2: 将 layout.csv 放到 Rust 项目根目录
  copy layout.csv D:\xtp-rust-k3\xtp-rust-fixed\

步骤 3: 运行 Rust 测试
  cargo test layout
  cargo test csv_verify

步骤 4 (CI): 将步骤 1-3 写入 CI 流水线,每次提交自动验证

11.6 各方法覆盖范围总表

┌─────────────────────┬──────────┬──────────┬──────────┬──────────┐
│ 检测目标             │ 编译期   │ 单元测试  │ CSV 对比  │ 构造函数  │
├─────────────────────┼──────────┼──────────┼──────────┼──────────┤
│ struct sizeof        │    ✓     │    ✓     │    ✓     │    ✗     │
│ struct align         │    ✓     │    ✓     │    ✓     │    ✗     │
│ struct field offset  │    ✓     │    ✓     │    ✓     │    ✗     │
│ enum discriminant    │    ✓     │    ✓     │    ✓     │    ✗     │
│ vtable slot count    │    ✓     │    ✓     │    ✗     │    ✗     │
│ vtable slot non-null │    ✗     │    ✗     │    ✗     │    ✓     │
│ vtable slot ordering │    ✗     │    ✗     │    ✗     │    ✓¹    │
│ callback 端到端      │    ✗     │    ✗     │    ✗     │    ✗     │
└─────────────────────┴──────────┴──────────┴──────────┴──────────┘

¹ 通过 GetApiVersion 返回合理字符串间接验证

---

## 十二、Drop 顺序与指针释放完整清单

### 12.1 涉及的指针和所有权总览

┌─────────────────────────────────────────────────────────────┐
│ Rust 堆 │
│ │
│ QuoteApi │
│ ├── raw: RawQuoteApi ──────────────────────────────┐ │
│ │ ├── _library: Arc ──→ DLL 句柄 │ │
│ │ ├── vtable: *const QuoteApiVTable ──→ DLL 内部 │ │
│ │ └── obj: mut c_void ──→ DLL 堆上的 C++ 对象 │ │
│ │ │ │
│ └── spi: Option<Box> ──→ 堆上 │ │
│ ├── vtable: &QUOTE_SPI_VTABLE (static 只读) │ │
│ └── callback: Box │ │
│ ↑ │ │
│ │ C++ DLL 内部 │ │
│ │ ───────────── │ │
│ └────── m_spi: QuoteSpi
(裸指针) │ │
│ 后台线程可能正在通过它回调 │ │
└─────────────────────────────────────────────────────────────┘


| 指针 | 持有者 | 类型 | 谁负责释放 | 释放方式 |
|------|--------|------|-----------|---------|
| `vtable` (`QuoteApiVTable*`) | `RawQuoteApi` | 裸指针 | **不释放** | 指向 DLL 内部只读段,DLL 卸载后自动失效 |
| `obj` (`QuoteApi*`) | `RawQuoteApi` | 裸指针 | **C++ DLL** | `Release()` 虚函数 |
| `_library` | `RawQuoteApi` | `Arc<Library>` | **Rust** | `Arc::drop()` → `refcount-1` → `FreeLibrary` |
| `spi` holder | `QuoteApi` + C++ DLL | `Box` + 裸指针 | **Rust** | `Box::drop()` |
| `callback` trait object | `QuoteSpiHolder` | `Box<dyn QuoteSpi>` | **Rust** | `Box::drop()` |
| `QUOTE_SPI_VTABLE` | 全局 static | `static` | **不释放** | 程序结束时回收 |

### 12.2 RawQuoteApi 的 Drop 完整顺序

```rust
pub struct RawQuoteApi {
    _library: Arc<Library>,           // ← 声明第 1 → drop 第 2 (在 Drop::drop 之后)
    vtable: *const QuoteApiVTable,    // ← 声明第 2 → drop 第 3
    obj: *mut std::ffi::c_void,       // ← 声明第 3 → drop 第 4
}
时间线 ────────────────────────────────────────────────────→

① RawQuoteApi::drop()  执行   ← Rust 规则:自定义 Drop 先于字段 drop
    └─ unsafe { (self.vt().release)(self.obj); }
        └─ DLL 内 Release() 虚函数
            ├─ 停止所有内部回调线程
            ├─ 断开网络连接
            ├─ 释放 C++ 对象内部资源
            └─ 销毁 C++ 对象 (delete this 或类似机制)

② _library drop               ← Arc<Library>::drop()
    └─ refcount - 1
        └─ if refcount == 0: FreeLibrary() → DLL 卸载

③ vtable drop                  ← 裸指针,no-op
④ obj drop                     ← 裸指针,no-op(C++ 侧已在①中释放)

12.3 QuoteApi 的 Drop 完整顺序

pub struct QuoteApi {
    raw: RawQuoteApi,                   // ← 声明第 1 → drop 第 1
    spi: Option<Box<QuoteSpiHolder>>,   // ← 声明第 2 → drop 第 2
    logged_in: bool,                    // ← 声明第 3 → drop 第 3
}

QuoteApi 无自定义 Drop,字段严格按声明顺序释放。

时间线 ────────────────────────────────────────────────────→

① raw.drop() 展开为 RawQuoteApi 完整 drop 流程(见 12.2)
    ├─ Release() 停止 C++ 回调线程
    └─ DLL 引用计数 -1

② spi.drop()
    ├─ Option::drop() → Some 分支
    ├─ Box::drop() → 释放 QuoteSpiHolder 堆内存
    └─ QuoteSpiHolder 内部字段 drop:
        ├─ vtable: &QUOTE_SPI_VTABLE → no-op(静态变量不释放)
        └─ callback: Box<dyn QuoteSpi>::drop() → 释放用户 trait object

③ logged_in.drop() → bool, no-op

12.4 关键先后顺序约束(为什么不能错)

约束 1: Release() 必须在 _library 释放之前
  ──────────────────────────────────────────
  原因: Release() 是 DLL 内部的虚函数。如果 _library 先释放导致 DLL 卸载,
        vtable 指向的地址变成无效内存 → 调用 Release() = 跳转到随机地址 → crash
  保证: _library 声明在 vtable/obj 之前 → Drop::drop 先于 _library drop

约束 2: Release() 必须在 spi 释放之前
  ──────────────────────────────────────────
  原因: C++ 后台回调线程持有 QuoteSpiHolder 的裸指针。
        spi 先释放 → 回调线程仍可能访问 → use-after-free
  保证: raw 声明在 spi 之前 → raw.drop() 先于 spi.drop()

约束 3: DLL 卸载必须在所有 vtable 调用之后
  ──────────────────────────────────────────
  原因: obj/vtable 指针指向 DLL 地址空间
  保证: 约束 1 已覆盖

约束 4: _library 必须最后 drop(在 RawQuoteApi 内部)
  ──────────────────────────────────────────
  原因: Drop::drop() 调用 Release() 时需要 DLL 仍在内存
  保证: 自定义 Drop 在所有字段 drop 之前执行

12.5 正确顺序 vs 错误顺序对比

✅ 正确                                      ❌ 错误 (如果字段顺序颠倒)
────────────────────────                   ────────────────────────
① Release()           ← 停线程             ① spi 释放              ← use-after-free 窗口
② _library drop       ← 卸载 DLL           ② Release()            ← 此时 spi 已释放
③ vtable drop                             ③ _library drop
④ obj drop                                ④ vtable drop
⑤ spi 释放            ← 安全               ⑤ obj drop
⑥ logged_in drop

12.6 Drop 注意事项清单

⚠️ 事项 1:未 Login 就 Drop 会 Crash
// quote_ffi.rs line 251-255
// QUIRK (XTPX 1.2.1-r.3): Release() crashes inside the DLL if the
// object never attempted Login(). The vendor demos always log in
// first, so this path is untested upstream.

原因:XTP DLL 的 Release() 内部访问了 Login 时才初始化的内部状态,未 Login 时这些状态是垃圾值。

规避

// 如果创建了 QuoteApi 但从未 login,手动处理:
let api = QuoteApi::new(...)?;
// 选项 A: 至少调用一次 login(失败也可以)
let _ = api.login(...);
// 选项 B: 用 ManuallyDrop 跳过 Release
// 选项 C: 进程退出时由 OS 回收(不优雅但无害)
⚠️ 事项 2:字段声明顺序不可随意调整

原则:涉及 Drop 顺序的 struct 字段必须在声明上方加 // NOTE: field order is load-bearing 注释。

禁止

// ❌ 错误:把 logged_in 移到 raw 前面
pub struct QuoteApi {
    logged_in: bool,                    // 先 drop — 无害但打乱顺序
    raw: RawQuoteApi,
    spi: Option<Box<QuoteSpiHolder>>,
}

虽然 logged_in 的 drop 无害,但这种调整暗示开发者没有意识到字段顺序的重要性,可能导致后续危险的重排。

正确

// ✅ 正确:raw 始终第一,新增字段追加在末尾
pub struct QuoteApi {
    raw: RawQuoteApi,
    spi: Option<Box<QuoteSpiHolder>>,
    logged_in: bool,
    // 新增字段放这里 ← 最后 drop,不影响 raw/spi 顺序
    new_field: NewType,
}
⚠️ 事项 3:不能给 QuoteApi 加自定义 Drop
// ❌ 危险:自定义 Drop 会打乱字段 drop 顺序
impl Drop for QuoteApi {
    fn drop(&mut self) {
        // 如果这里手动释放 raw 或 spi,可能和自动 drop 冲突 → 双重释放
        self.raw.release();  // ⚠️ Drop::drop() 会再调一次 Release()
    }
}

原则:字段声明顺序已经够用,不要画蛇添足。

⚠️ 事项 4:Arc 的引用计数
场景 A: 单实例
  QuoteApi 创建 → Arc refcount = 1
  QuoteApi 销毁 → Arc refcount = 0 → DLL 卸载 ✅

场景 B: 多实例共享 DLL
  QuoteApi #1 创建 → Arc refcount = 1
  QuoteApi #2 创建 → Arc refcount = 2
  QuoteApi #1 销毁 → Arc refcount = 1 → DLL 不卸载 ✅
  QuoteApi #2 销毁 → Arc refcount = 0 → DLL 卸载 ✅

场景 C: 手动 clone Arc
  let lib = Arc::new(library);
  // 传给两个 RawQuoteApi,refcount = 3(含原始 Arc)
  最后一个释放时 DLL 才卸载 ✅

注意事项

  • 不要在 DLL 卸载后继续持有从该 DLL 派生的 vtable/obj 指针
  • 如果手动 clone Arc<Library>,确保它不会比所有 RawQuoteApi 活得更久
  • Send + Sync 已经手动 unsafe impl,多线程场景需自行同步
⚠️ 事项 5:drop 期间的 panic
impl Drop for RawQuoteApi {
    fn drop(&mut self) {
        unsafe { (self.vt().release)(self.obj); }
        // ⚠️ 如果 Release() 里发生了 Rust panic(不太可能,因为
        // 这是 extern "C" fn),会 abort 整个进程。
        // Drop 期间 panic = 程序 abort,不会尝试恢复。
    }
}

原则

  • Drop 实现必须是不可失败的(infallible)
  • 不要在 Drop 中调用可能 panic 的 Rust 代码
  • C++ 函数不会触发 Rust panic,这里是安全的
⚠️ 事项 6:mem::forget / ManuallyDrop 的风险
let api = QuoteApi::new(...)?;
api.register_spi(Box::new(MySpi));
api.login(...)?;
std::mem::forget(api);  // ❌ 永远不会调用任何 drop!

后果

  • RawQuoteApi::drop() 不被调用 → Release() 不被调用 → C++ 对象泄漏
  • Library 不被 drop → DLL 永远不卸载
  • spi 不被释放 → trait object 泄漏
  • 如果 C++ 后台线程还在运行,继续回调已注册的 SPI → 如果 SPI 在其他地方被手动释放,use-after-free
⚠️ 事项 7:C++ 侧时序依赖
已知依赖链:
  CreateQuoteApi() → [配置] → RegisterSpi() → Login() → [业务操作] → Logout() → Release()
  
  Release() 假设 Login() 至少被调用过一次(哪怕是失败的)。
  跳过 Login() 直接 Release() 可能 crash(见 事项 1)。
⚠️ 事项 8:Logout 不是必须的
// Logout 只是断开连接,不会停止回调线程。
// 即使没有显式 Logout,Release() 也会处理断连。
// 但如果已经 Logout 且想重连,直接再 Login 即可。
pub fn logout(&mut self) -> Result<(), XtpError> {
    let r = self.raw.logout();
    if r.is_ok() { self.logged_in = false; }
    r
}

12.7 Drop 安全检查清单

在修改涉及 Drop 的代码时,逐项确认:

□  新 struct 是否有自定义 Drop?
    → 如果有,是否考虑了字段 drop 顺序?

□  字段声明顺序是否正确?
    → "先停外部引用者,再释放被引用者"

□  raw (RawQuoteApi) 是否在 spi 之前声明?
    → Release() 先于 spi 释放

□  _library 是否在 vtable/obj 之前声明?
    → DLL 在 Release() 之后才卸载

□  是否存在手动 drop 某个字段的代码?
    → 如果有,是否会与自动 drop 冲突?

□  新增的字段是否带 Drop 行为?
    → 如果有,需要确认顺序放在哪里

□  是否将 Arc<Library> clone 出去了?
    → 如果 clone 了,生命周期是否比本 struct 短?

□  是否有路径跳过 Login 直接 Release?
    → 如果有,需注意 Issues §14 的 crash bug

□  Drop 实现中是否有 .unwrap() / .expect() / panic!()?
    → 不允许,Drop 必须 infallible

□  测试是否覆盖了 drop 路径?
    → 至少覆盖:正常 Login→Logout→Drop / 未 Login→Drop

十三、Win64 vs Linux 平台封装差异详解

13.1 前置事实:XTP SDK 是 Windows-Only

XTP(中泰证券极速交易平台)的 C++ SDK 只提供 Windows DLL。不存在 Linux 原生 .so 版本。因此以下讨论分两个层面:

层面 含义
纯技术对比 如果 XTP 有 Linux 版本,FFI 封装在调用约定、ABI、符号解析等方面的差异
实际可用方案 在没有 Linux SDK 的前提下,如何让 Rust 封装在 Linux 上运行

本章先讲纯技术差异(13.2–13.8),再讲实际可用方案(13.9)。

13.2 二进制格式与加载机制

维度 Windows (x64) Linux (x64)
二进制格式 PE32+ (Portable Executable) ELF64
动态库后缀 .dll .so
导入库 .lib(编译时链接)或运行时 LoadLibrary 不需要导入库,直接 dlopen
加载 API LoadLibraryA / FreeLibrary dlopen / dlclose
符号查找 GetProcAddress(handle, name) dlsym(handle, name)
卸载行为 FreeLibrary 后立即从地址空间移除 dlclose 后仅在引用计数为 0 时卸载

对 Rust 封装的影响

// Windows:
unsafe { Library::new("xtpxquoteapi.dll") }

// Linux(如果存在):
unsafe { Library::new("libxtpxquoteapi.so") }

libloading 在内部自动适配两种平台,调用方代码基本不变。但库名和搜索路径策略不同。

13.3 调用约定(Calling Convention)

x64 下两个平台都是单一调用约定,不存在 x86 时代的 __stdcall / __cdecl / __fastcall 混乱。但寄存器使用和栈布局完全不同

维度 Windows x64 (MSVC) Linux x64 (System V AMD64)
整数参数寄存器 RCX, RDX, R8, R9(前 4 个) RDI, RSI, RDX, RCX, R8, R9(前 6 个)
浮点参数寄存器 XMM0–XMM3 XMM0–XMM7
返回值寄存器 RAX (int), XMM0 (float) RAX (int), XMM0 (float)
Shadow space 调用者在栈上预留 32 字节 shadow space
栈对齐 16 字节 16 字节(进入函数时 8 偏移)
栈清理 调用者清理 调用者清理
this 指针位置 RCX(第 1 个参数) RDI(第 1 个参数)

对 Rust 封装的影响

好消息是 Rust 的 extern "C" 在编译时自动适配目标平台的调用约定。只要 Rust 侧声明为 extern "C" 且 C++ 侧也是标准调用约定,跨平台调用是透明的。

// 这段代码在 Windows 和 Linux 上自动做正确的事:
pub release: unsafe extern "C" fn(this: *mut std::ffi::c_void),

坏消息是:如果你在 Linux 上加载了一个 Windows DLL(如通过 Wine),Rust 的 extern "C" 会生成 Linux ABI 的调用——但 DLL 期望 Windows ABI——直接 crash

13.4 C++ 符号修饰(Name Mangling)

编译器 修饰方案 示例
MSVC 自有方案 ?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@EPEBDW4XTP_LOG_LEVEL@23@_N@Z
GCC/Clang (Itanium C++ ABI) Itanium 方案 _ZN4XTPX3API8QuoteApi14CreateQuoteApiEhPKcNS0_13XTP_LOG_LEVELEb

对 Rust 封装的影响:这是最直接且最痛苦的跨平台差异

// Windows — 查找 MSVC mangled name:
let names = [
    "?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@EPEBDW4XTP_LOG_LEVEL@23@_N@Z",
    "CreateQuoteApi",  // unmangled 回退
];

// Linux(如果 GCC 编译) — 需要用完全不同的 mangled name:
let names = [
    "_ZN4XTPX3API8QuoteApi14CreateQuoteApiEhPKcNS0_13XTP_LOG_LEVELEb",
    "CreateQuoteApi",
];

如果 DLL 用 extern "C" 导出,则 mangled name 不会出现,用 "CreateQuoteApi" 即可统一。

13.5 VTable 布局差异

单继承场景最简单,但 MSVC 和 Itanium ABI 仍有微妙差异:

维度 MSVC x64 Itanium C++ ABI (GCC/Clang)
vtable 指针位置 object[0] object[0](与 MSVC 相同)
vtable 第一个槽 第一个虚函数 第一个虚函数(单继承相同)
RTTI 指针 通常无,通过单独机制 vtable[-1]
虚析构函数 deleting destructor 占 1 槽 D2/D1/D0 可能占 2–3 槽
this 调整 thunk 有,用于多重继承 有,方式不同

对 Rust 封装的影响

对于 XTP 这种单继承场景(QuoteApi / QuoteSpi),vtable 布局基本一致:对象偏移 0 处是 vtable 指针,vtable 槽位按声明顺序排列

// 单继承下,这段代码在两个平台都有效:
let vtable = unsafe { *(obj as *const *const QuoteApiVTable) };
let result = unsafe { (vtable.read().get_api_version)(obj) };

差异点

  • 如果 XTP 用了多重继承(不太可能,但需验证头文件),Linux 侧可能需要 this 指针调整
  • 虚析构函数如果有,在 Itanium ABI 下可能占多个槽位(D1/D2/D0)
  • XTP 的 QuoteApi 没有虚析构函数(已验证),避免了这个问题

13.6 数据结构布局差异

维度 MSVC GCC/Clang
#pragma pack(1) 严格按 1 字节对齐 行为基本一致
位域布局 每个 bit 独立占字节(pack 1 下) 可能合并相邻同类型位域
空基类优化 有,但规则不同
尾部填充 包含在 sizeof 中 包含在 sizeof 中
long double 8 字节(同 double) 16 字节(x64)
wchar_t 2 字节 4 字节

对 Rust 封装的影响

// 必须用 C 黄金数据重新验证每个 struct 的 sizeof/offsetof
#[test]
fn linux_layout_compatibility() {
    // 以下值必须在目标 Linux 编译 C 程序验证:
    assert_eq!(size_of::<XtpMarketData>(), LAYOUT_CSV_MD_SIZE);
    assert_eq!(offset_of!(XtpMarketData, last_price), LAYOUT_CSV_MD_LAST_PRICE_OFFSET);
    // ...
}

最大风险点

  1. 位域:XtpNqFullInfo 的 6 个位域在 Linux GCC 下可能被合并,导致后续字段偏移改变
  2. long 类型:Windows 下 4 字节,Linux x64 下 8 字节。如果 XTP 结构体中有 long 字段,offset 会错位
  3. wchar_t 大小不同:如果 XTP 结构体中有宽字符数组,尺寸完全不同

13.7 线程模型差异

维度 Windows Linux
线程 API CreateThread / _beginthreadex pthread_create
线程本地存储 __declspec(thread) / TlsAlloc __thread / thread_local
DLL 线程通知 DllMain(THREAD_ATTACH/DETACH) 无直接等价物
回调线程生命周期 DLL 管理 .so 管理

对 Rust 封装的影响

XTP DLL 的 Release() 停止内部回调线程。在 Wine 环境下:

  • Wine 将 Windows 线程映射到 Linux pthread
  • Release() 的线程停止行为需要 Wine 正确实现
  • Wine 的线程模型与原生 Windows 不完全一致,存在边缘情况

13.8 符号可见性

维度 Windows Linux
默认可见性 全部隐藏(需 __declspec(dllexport) 显式导出) 全部可见(需 -fvisibility=hidden 显式隐藏)
隐式导入 .lib 导入库 + __declspec(dllimport) 不需要导入库
循环依赖 DLL 间可以互相引用 .so 也可以,但更复杂

对 Rust 封装的影响

Linux 上如果用 GCC 编译 XTP SDK,导出符号默认全部可见。用 dumpbin /EXPORTS 等价物(nm -D / objdump -T)查看导出表时,会看到更多内部符号——但 libloading 仍能精确匹配。

13.9 实际可用方案:在没有 Linux SDK 的前提下

方案 A:Wine + 原生 DLL(可行,已验证)
┌────────────────────────────────────┐
│  Linux 系统                         │
│  ┌──────────────────────────────┐  │
│  │  Rust 程序 (Linux 原生编译)    │  │
│  │  ├─ extern "C" (Linux ABI)   │  │  ← 问题:用 Linux ABI 调 Windows DLL
│  │  └─ libloading (dlopen)      │  │  ← 问题:dlopen 不能加载 PE DLL
│  └──────────────────────────────┘  │
│               │                    │
│               ▼                    │
│  ┌──────────────────────────────┐  │
│  │  Wine (Windows 兼容层)         │  │
│  │  ├─ winegcc / winelib         │  │  ← 关键:把 DLL 包装成 .so
│  │  └─ xtpxquoteapi.dll.so       │  │
│  └──────────────────────────────┘  │
└────────────────────────────────────┘

核心问题:Rust 的 extern "C" 在 Linux 上是 System V ABI,但 Windows DLL 期望 MSVC ABI。直接跨 ABI 调用会 crash。

解法:用 winelib 或一个 C 桥接层,将 Windows ABI 包装为 Linux ABI 的 .so

// bridge.c — 编译为 xtp_bridge.so(用 winegcc)
// 此 .so 在 Wine 内部加载 DLL,对外暴露 Linux ABI
#include "xtpx_quote_api.h"

// 每个需要导出的函数包一层:
void* bridge_create_quote_api(uint8_t cid, const char* path, int level, bool udp) {
    return XTPX::API::QuoteApi::CreateQuoteApi(cid, path, (XTP_LOG_LEVEL)level, udp);
}
void bridge_release(void* obj) {
    ((XTPX::API::QuoteApi*)obj)->Release();
}
// ... 包装所有虚函数
# 编译桥接层:
winegcc -shared -o xtp_bridge.so bridge.c -L. -lxtpxquoteapi

然后 Rust 侧用 libloading 加载 .so 而非 .dll

方案 B:独立网关进程(生产环境推荐)
┌──────────────────────┐        ┌──────────────────────┐
│  Linux 机器           │        │  Windows 机器          │
│                      │  TCP   │                      │
│  Rust 策略程序 ──────┼───────→│  XTP 网关进程          │
│  (无 XTP 依赖)        │←───────│  ├─ xtpxquoteapi.dll  │
│                      │  JSON  │  ├─ xtptraderapi.dll   │
│                      │        │  └─ 连接 XTP 服务器    │
└──────────────────────┘        └──────────────────────┘

网关在 Windows 上运行,接收 Linux 侧的 JSON/Protobuf 请求,转发给 XTP DLL,回调结果回推。Linux 端完全不依赖任何 Windows 组件。

方案 C:Windows 子系统(WSL2)
┌──────────────────────────────────┐
│  WSL2 (轻量虚拟机)                │
│  ┌────────────────────────────┐  │
│  │  Rust 程序                   │  │
│  │  ├─ xtpxquoteapi.dll        │  │  ← 直接在 Windows 内核上运行
│  │  └─ libloading (LoadLibrary)│  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

WSL2 完全支持 Windows 原生二进制。Rust 编译为 Windows 目标即可:

rustup target add x86_64-pc-windows-msvc
cargo build --target x86_64-pc-windows-msvc

不需要任何代码修改。这是最简单直接的方案。

13.10 跨平台兼容代码的写法

如果想让同一份 Rust 源码在 Windows 原生和 Linux+Wine 上都能编译:

// === 库名平台适配 ===
#[cfg(target_os = "windows")]
const QUOTE_DLL: &str = "xtpxquoteapi.dll";
#[cfg(target_os = "linux")]
const QUOTE_DLL: &str = "xtp_bridge.so";  // Wine 桥接层

// === 符号名平台适配 ===
#[cfg(target_os = "windows")]
const MANGLED_CREATE: &str = "?CreateQuoteApi@QuoteApi@API@XTPX@@...@Z";
#[cfg(target_os = "linux")]
const MANGLED_CREATE: &str = "bridge_create_quote_api";  // 桥接层暴露的符号

// === 调用约定 — extern "C" 自动适配,无需特殊处理 ===

// === 结构体布局 — 每个平台单独验证 ===
#[cfg(all(test, target_os = "windows"))]
mod win_layout;
#[cfg(all(test, target_os = "linux"))]
mod linux_layout;

// === 虚函数声明 — 单继承下 vtable 槽位顺序跨平台一致 ===
// 如果 XTP 用了多重继承或虚析构,则需分别定义:
#[cfg(target_os = "windows")]
mod vtable_msvc;
#[cfg(target_os = "linux")]
mod vtable_itanium;

13.11 完整差异对照清单

┌──────────────────────┬─────────────────────────────────┬─────────────────────────────────┐
│ 差异维度              │ Windows x64 (MSVC)              │ Linux x64 (GCC/Clang)           │
├──────────────────────┼─────────────────────────────────┼─────────────────────────────────┤
│ 二进制格式            │ PE32+ (.dll)                    │ ELF64 (.so)                     │
│ 库命名                │ xtpxquoteapi.dll                │ libxtpxquoteapi.so              │
│ 加载机制              │ LoadLibrary / FreeLibrary       │ dlopen / dlclose                │
│ 符号查找              │ GetProcAddress                  │ dlsym                          │
│ 导入库                │ 需要 .lib                       │ 不需要                          │
│ C++ name mangling     │ MSVC 自有方案                   │ Itanium C++ ABI                │
│ 调用约定 (x64)        │ RCX,RDX,R8,R9 + shadow space   │ RDI,RSI,RDX,RCX,R8,R9          │
│ vtable[0] 位置        │ 对象 offset 0                   │ 对象 offset 0 (单继承相同)      │
│ RTTI 位置             │ 单独机制                        │ vtable[-1]                     │
│ 虚析构函数            │ 1 槽 (deleting destructor)      │ 2–3 槽 (D0/D1/D2)              │
│ #pragma pack(1)       │ 支持                            │ 支持 (行为基本一致)             │
│ 位域布局 (pack 1)     │ 每个 bit 独立占 1 字节           │ 可能合并相邻同类型             │
│ long 类型大小          │ 4 字节                          │ 8 字节 (x64)                    │
│ wchar_t 大小          │ 2 字节                          │ 4 字节                          │
│ long double 大小       │ 8 字节                          │ 16 字节                         │
│ 线程 API              │ CreateThread                    │ pthread_create                  │
│ TLS                   │ __declspec(thread)              │ __thread / thread_local        │
│ DLL 线程通知           │ DllMain(THREAD_ATTACH/DETACH)  │ 无直接等价物                    │
│ 默认符号可见性         │ 隐藏 (需 dllexport)             │ 可见 (可设 -fvisibility=hidden) │
│ Rust extern "C"       │ MSVC x64 ABI                   │ System V AMD64 ABI             │
│ 运行时跨 ABI 调用      │ —                               │ 需 Wine + 桥接层               │
│ XTP SDK 是否提供       │ ✅ 官方提供                    │ ❌ 不提供                       │
└──────────────────────┴─────────────────────────────────┴─────────────────────────────────┘

13.12 迁移到 Linux 的推荐路径

1. 确认需求
   ├─ 只是开发环境 Linux?→ WSL2 (方案 C),零开销
   └─ 生产环境 Linux?
       ├─ 低延迟要求 → 独立网关进程 (方案 B)
       └─ 可接受 Wine 开销 → Wine + winelib 桥接 (方案 A)

2. 准备
   ├─ 在目标 Linux 上用 GCC 编译 gen_layout.c → 拿到 layout_linux.csv
   ├─ 对比 layout_windows.csv 和 layout_linux.csv,确认结构体偏移
   └─ 如果有 wchar_t / long / 位域 差异 → 条件编译 #[cfg(...)]

3. 如果走 Wine 路径
   ├─ 写桥接 C 文件 → winegcc 编译为 .so
   ├─ Rust 侧用 libloading 加载 .so 而非 .dll
   ├─ 清理所有 MSVC mangled name 引用,改用桥接层符号
   └─ 在 Wine 环境下做完整的集成测试

4. 如果走网关路径
   ├─ 定义应用层协议 (JSON / Protobuf / flatbuffers)
   ├─ Windows 侧实现网关
   ├─ Linux 侧实现 SDK (不包含任何 FFI 代码)
   └─ 端到端延迟测试

5. 验证
   ├─ cargo test --target linux → 布局测试通过
   ├─ 行情回调数据正确
   ├─ Drop 顺序在 Wine/Linux 下同样安全
   └─ 长时间运行无内存泄漏

十四、内存泄漏检测

14.1 代码中每条分配路径的审计

先逐行审查谁分配了什么、谁负责释放:

分配路径                                  释放机制                    状态
──────────────────────────────────────   ─────────────────────────  ──────
① Library::new("xtpxquoteapi.dll")      Arc::drop() → FreeLibrary   ✅ 安全
② create_fn() → obj                     Release() (在 Drop 中)      ✅ 安全
③ CString::new(path/ip/user/pass)       作用域结束自动 drop          ✅ 安全
④ Box::new(QuoteSpiHolder)              QuoteApi 字段 drop 顺序     ✅ 安全
⑤ Box::new(MySpi)                       在 QuoteSpiHolder 内部      ✅ 安全
⑥ Symbol<CreateQuoteApiFn>              作用域结束自动 drop          ✅ 安全
⑦ Vec<CString> (sub_cstrs)              作用域结束自动 drop          ✅ 安全
⑧ DLL 内部分配 (C++ new/delete)         Release() 内部处理           ⚠️ 信任 DLL

14.2 ⚠️ 已识别的潜在泄漏点

泄漏点 1:vtable 为 null 时的 C++ 对象泄漏
// quote_ffi.rs line 137-147
let obj = unsafe { create_fn(...) };          // C++ 对象已创建
if obj.is_null() {
    return Err(...);                           // obj 为 null → 无泄漏
}

let vtable = unsafe { *(obj as *const *const QuoteApiVTable) };
if vtable.is_null() {
    return Err(XtpError::ApiError("null vtable"));  // ⚠️ obj 非 null 但 vtable 为 null
    // C++ 对象已分配但无法调用 Release()(没有 vtable 找不到 Release 入口)
    // → C++ 对象泄漏(但这是不可避免的,因为无法安全释放)
}

结论:这是一个理论泄漏点,但在正常 DLL 中不会触发(vtable 不可能为 null)。如果触发,说明 DLL 已损坏,此泄漏远不如 crash 严重。

泄漏点 2:panic 导致 Drop 不执行
let api = QuoteApi::new(1, "./data", LogLevel::Debug, true)?;
api.register_spi(Box::new(MySpi));

// 如果这里 panic:
// - QuoteApi::drop() 不会被调用
// - RawQuoteApi::drop() 不会被调用
// - Release() 不会被调用
// - C++ 对象泄漏(进程退出时 OS 回收,但 DLL 内部资源可能无法正常清理)

检测:用 std::panic::catch_unwind 包裹并用 AssertUnwindSafe 确保 drop 运行。

泄漏点 3:Arc 循环引用
// 当前代码不会发生,但如果将来:
// QuoteSpi 实现中持有了 Arc<Library>
// 而 Library 内部又间接持有 QuoteSpi → 循环引用 → 永不释放

检测:所有 Arc 的引用关系应该是单向的(QuoteApi → Library,不应该有反向引用)。

14.3 检测方法分层

从轻到重,从开发期到生产期:
─────────────────────────────────────────────────────→
① 代码审查   ② Drop 计数测试   ③ 自定义分配器   ④ 外部工具   ⑤ 长期压力测试
   零成本       毫秒级            运行时开销      分析模式      真实环境

14.4 方法 ①:代码审查清单(开发期,零成本)

□ 每个 C++ new / malloc 是否有对应的 delete / free?
   → 本项目:只有 create_fn() → Release()

□ 每个 Box<T> 是否最终被 drop?
   → QuoteSpiHolder (self.spi) → QuoteApi 字段 drop 顺序保证

□ 每个 Arc<T> 是否有引用计数归零的路径?
   → _library: 最后一个 RawQuoteApi drop 时归零

□ 每个 CString 是否在作用域内正确释放?
   → login / subscribe 等方法中,CString 在函数返回时自动 drop

□ 是否有 unsafe 手动分配的内存没配对释放?
   → 本项目无手动 alloc 操作

□ 返回给 C++ 的指针是否由 C++ 管理生命周期?
   → QuoteSpiHolder* 由 Rust 管理,C++ 只持有裸指针不负责释放

□ 从 C++ 返回的指针是否需要 Rust 侧释放?
   → get_api_version() → DLL 内部静态字符串 → 不需释放
   → get_api_last_error() → DLL 内部对象 → 不需释放

14.5 方法 ②:Drop 计数测试(cargo test)

这是最轻量且最有效的 Rust 侧检测方法:

// tests/leak_tests.rs
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

// ============ Drop 计数器 ============

struct DropCounter {
    count: Arc<AtomicUsize>,
}
impl DropCounter {
    fn new(count: Arc<AtomicUsize>) -> Self {
        count.fetch_add(1, Ordering::SeqCst);  // 创建 +1
        Self { count }
    }
}
impl Drop for DropCounter {
    fn drop(&mut self) {
        self.count.fetch_sub(1, Ordering::SeqCst);  // 销毁 -1
    }
}

#[test]
fn quote_api_create_drop_no_leak() {
    let counter = Arc::new(AtomicUsize::new(0));

    {
        let _api = QuoteApi::new(1, "./data", LogLevel::Debug, true).unwrap();
        // 如果 _api 不被 drop,counter 不会归零
    }
    // _api 离开作用域 → drop 发生

    // 等待 DLL 清理(Release 可能异步)
    std::thread::sleep(std::time::Duration::from_millis(100));
}

// ============ Library 加载/卸载计数 ============

#[test]
fn dll_load_unload_balance() {
    // 记录初始 DLL 引用:
    // - 进程启动时的 DLL 基数(用 GetModuleHandle 检查)
    // - 创建 QuoteApi 后 DLL 引用 +1
    // - Drop 后 DLL 引用归零

    // Windows 实现:
    #[cfg(windows)]
    {
        use std::ffi::OsStr;
        use std::os::windows::ffi::OsStrExt;

        let dll_name: Vec<u16> = OsStr::new("xtpxquoteapi.dll")
            .encode_wide()
            .chain(std::iter::once(0))
            .collect();

        let before = unsafe {
            winapi::um::libloaderapi::GetModuleHandleW(dll_name.as_ptr())
        };

        {
            let api = QuoteApi::new(1, "./data", LogLevel::Debug, true).unwrap();
            let during = unsafe {
                winapi::um::libloaderapi::GetModuleHandleW(dll_name.as_ptr())
            };
            assert!(!during.is_null(), "DLL should be loaded");
        }
        // api dropped → DLL should be unloaded (if refcount was 1)

        let after = unsafe {
            winapi::um::libloaderapi::GetModuleHandleW(dll_name.as_ptr())
        };
        // 注意:libloading 可能缓存 DLL,不一定立即卸载
        println!("DLL handle before={:?}, after={:?}", before, after);
    }
}

// ============ 多次创建/销毁循环测试 ============

#[test]
fn repeated_create_drop_no_leak() {
    // 创建和销毁 100 个 QuoteApi 实例
    // 用外部内存监控观察进程内存是否持续增长
    for i in 0..100 {
        let api = QuoteApi::new(
            (i % 99 + 1) as u8,  // client_id 1~99 循环
            "./data",
            LogLevel::Warning,    // 生产级别,减少日志输出
            false,
        );
        if let Ok(mut api) = api {
            // 至少尝试登录(绕过 Issues §14 的 Release crash bug)
            let _ = api.login("127.0.0.1", 6001, "test", "test",
                              ProtocolType::Tcp, None);
        }
        // api 在此 drop → Release() 调用
    }
    // 100 轮结束后,内存应该回到基线
}

// ============ SPI 注册/注销循环 ============

#[test]
fn spi_register_drop_no_leak() {
    struct DummySpi;
    impl QuoteSpi for DummySpi {}

    for _ in 0..50 {
        let mut api = QuoteApi::new(1, "./data", LogLevel::Warning, false).unwrap();
        api.register_spi(Box::new(DummySpi));
        // 不 login —— 验证未 login 场景的 drop
    }
    // ⚠️ 注意 Issues §14:未 Login 的 Release 可能 crash
    // 这个测试的目的正是验证这个行为
}

14.6 方法 ③:自定义全局分配器(Rust 侧泄漏)

// 放在 lib.rs 或单独的 allocator.rs
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};

struct TrackingAllocator;

static ALLOCATED: AtomicUsize = AtomicUsize::new(0);
static DEALLOCATED: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for TrackingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        ALLOCATED.fetch_add(layout.size(), Ordering::SeqCst);
        System.alloc(layout)
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        DEALLOCATED.fetch_add(layout.size(), Ordering::SeqCst);
        System.dealloc(ptr, layout)
    }
}

#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator;

/// 获取当前 Rust 堆的净分配量(近似值)
pub fn net_allocated() -> isize {
    ALLOCATED.load(Ordering::SeqCst) as isize
        - DEALLOCATED.load(Ordering::SeqCst) as isize
}

#[test]
fn rust_heap_no_leak_after_cycle() {
    let before = net_allocated();

    for _ in 0..10 {
        let api = QuoteApi::new(1, "./data", LogLevel::Warning, false).unwrap();
        drop(api);
    }

    let after = net_allocated();
    let diff = after - before;

    // 允许少量的分配器内部碎片/缓存差异
    assert!(
        diff.abs() < 4096,
        "Rust heap leak detected: net change = {} bytes after 10 cycles", diff
    );
}

局限:这只追踪 Rust 侧的分配。C++ DLL 内部的 new/delete 不走 Rust 分配器,无法监控。

14.7 方法 ④:Windows CRT 调试堆(C++ 侧泄漏)

这是追踪 DLL 内部 C++ 泄漏的最有效方法:

// tests/crt_leak_tests.rs
// 注意:必须在程序入口处设置,且仅 Debug 构建有效

#[cfg(windows)]
#[test]
fn crt_memcheck_quote_api_cycle() {
    unsafe {
        // 启用 CRT 内存泄漏检测
        // _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
        // 完整 API:
        // extern "C" {
        //     fn _CrtSetDbgFlag(newFlag: i32) -> i32;
        //     fn _CrtDumpMemoryLeaks();
        // }
    }

    {
        let api = QuoteApi::new(1, "./data", LogLevel::Debug, true).unwrap();
        // 不 login → 测试 Release crash 路径
    }

    // 程序退出时 CRT 自动打印泄漏报告到调试输出
    // 用 DebugView (Sysinternals) 捕获输出
}

更实用的方式:单独写一个 C 测试程序:

// crt_leak_check.c
#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>
#include "xtpx_quote_api.h"

int main() {
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

    {
        // 模拟 Rust 封装的行为
        QuoteApi* api = QuoteApi::CreateQuoteApi(1, "./data", XTP_LOG_LEVEL_DEBUG, false);
        api->RegisterSpi(NULL);
        api->Release();
    }

    _CrtDumpMemoryLeaks();  // 手动触发泄漏报告
    return 0;
}

编译运行:

cl /MDd /Zi crt_leak_check.c /I"include" /link xtpxquoteapi.lib
crt_leak_check.exe
# 输出示例(如果有泄漏):
# Detected memory leaks!
# Dumping objects ->
# {150} normal block at 0x0000025C..., 128 bytes long.
#  Data: <...> ...

14.8 方法 ⑤:Windows 平台专用工具

工具 类型 检测范围 用法
Dr. Memory 外部分析 Rust + C++ drmemory.exe -- target\debug\xtp-test.exe
UMDH (User-Mode Dump Heap) 快照对比 Rust + C++ 抓两个时刻的堆快照,diff 看增长
Process Explorer 实时监控 进程整体 观察 Private Bytes 是否持续增长
ETW (Event Tracing) 内核级 全局分配 xperf -start HeapSession -heap -pid <PID>
Application Verifier 运行时注入 DLL 堆 对 exe 开启 Full Page Heap 检测越界和泄漏
Dr. Memory 快速使用:
# 1. 写一个简单的泄漏复现测试
# tests/leak_repro.rs
#[test]
fn leak_repro() {
    for _ in 0..100 {
        let api = QuoteApi::new(1, "./data", LogLevel::Warning, false).unwrap();
        drop(api);
    }
}

# 2. 用 Dr. Memory 运行:
drmemory.exe -brief -- cargo test leak_repro -- --nocapture

# Dr. Memory 会在进程退出时报告:
# - Leak: 已分配但未释放的内存块
# - Possible Leak: 仍有指针可达但疑似泄漏
# - Still Reachable: 进程退出时仍可达(通常不是泄漏)
UMDH 快照对比:
# 1. 启用进程的堆栈跟踪
gflags.exe /i test.exe +ust

# 2. 启动程序,在关键点抓快照
umdh.exe -p:<PID> -f:snapshot1.txt
# ... 执行疑似泄漏的操作 ...
umdh.exe -p:<PID> -f:snapshot2.txt

# 3. 对比分析
umdh.exe snapshot1.txt snapshot2.txt -f:diff.txt
# diff.txt 显示两次快照之间新增的分配 → 定位泄漏来源

14.9 方法 ⑥:长时间压力测试(生产环境模拟)

// tests/stress_leak_test.rs
// 注意:这个测试需要 XTP 真实环境,标记为 #[ignore]

#[test]
#[ignore = "requires XTP server connection"]
fn stress_test_24h_memory_stable() {
    use std::time::{Duration, Instant};

    let start = Instant::now();
    let mut samples = Vec::new();

    struct SpySpi {
        msg_count: AtomicUsize,
    }
    impl QuoteSpi for SpySpi {
        fn on_depth_market_data(&self, _: &XtpMarketData, _: &[i64], _: i32, _: i32, _: &[i64], _: i32, _: i32) {
            self.msg_count.fetch_add(1, Ordering::Relaxed);
        }
    }

    let spi = Arc::new(SpySpi { msg_count: AtomicUsize::new(0) });
    let mut api = QuoteApi::new(1, "./data", LogLevel::Info, false).unwrap();
    api.set_config_file("./quote_config.ini").unwrap();
    api.register_spi(Box::new(spi.clone()));
    api.login("127.0.0.1", 6001, "user", "pass", ProtocolType::Tcp, None).unwrap();
    api.subscribe_all_market_data(ExchangeType::SH).unwrap();

    let mut last_mem = 0;
    while start.elapsed() < Duration::from_secs(3600) {  // 1 小时
        std::thread::sleep(Duration::from_secs(60));

        // 用 Windows API 获取当前进程 Working Set
        let current_mem = get_process_working_set();
        samples.push(current_mem);

        // 内存持续增长超过阈值 → 可能存在泄漏
        if samples.len() > 10 {
            let trend: f64 = samples[samples.len()-10..]
                .iter()
                .enumerate()
                .map(|(i, &m)| (i as f64) * (m as f64))
                .sum::<f64>() / 10.0;

            assert!(
                trend < 1024.0 * 1024.0 * 10.0,  // 10MB/10min
                "Memory growing: {:?}", &samples[samples.len()-10..]
            );
        }
    }

    api.logout().ok();
    drop(api);
}

#[cfg(windows)]
fn get_process_working_set() -> usize {
    use std::mem;
    unsafe {
        let h = winapi::um::processthreadsapi::GetCurrentProcess();
        let mut pmc: winapi::um::psapi::PROCESS_MEMORY_COUNTERS = mem::zeroed();
        pmc.cb = mem::size_of_val(&pmc) as u32;
        winapi::um::psapi::GetProcessMemoryInfo(h, &mut pmc, pmc.cb);
        pmc.WorkingSetSize
    }
}

14.10 Rust Nightly: Address Sanitizer (ASan)

# 需要 nightly Rust:
rustup default nightly
rustup component add rust-src --toolchain nightly

# 编译时启用 ASan:
set RUSTFLAGS=-Zsanitizer=address
cargo test --target x86_64-pc-windows-msvc -- --nocapture

# ASan 检测:
# - heap-use-after-free
# - heap-buffer-overflow
# - stack-buffer-overflow
# - memory leaks (LeakSanitizer)
# - double-free

注意:MSVC 的 ASan 支持较晚(VS 2022 17.0+),需要 /fsanitize=address

14.11 检测方法总览表

┌─────────────────┬──────────┬──────────┬───────────┬──────────┬──────────┐
│ 检测方法          │ Rust 堆  │ C++ 堆   │ DLL 内部  │ 开销     │ 侵入性   │
├─────────────────┼──────────┼──────────┼───────────┼──────────┼──────────┤
│ 代码审查清单      │    ✓     │    ✓     │    —      │ 零       │ 零       │
│ Drop 计数测试     │    ✓     │    —     │    —      │ 极小     │ 低       │
│ 自定义全局分配器   │    ✓     │    ✗     │    ✗      │ 极小     │ 低       │
│ CRT Debug Heap   │    ✗     │    ✓     │    ✓      │ 中       │ 中       │
│ Dr. Memory        │    ✓     │    ✓     │    △¹     │ 高 (10x) │ 低       │
│ UMDH 快照对比     │    ✓     │    ✓     │    ✓      │ 中       │ 低       │
│ Address Sanitizer │    ✓     │    ✓     │    △²     │ 中 (2x)  │ 中       │
│ Process Explorer  │    △³    │    △³    │    △³     │ 零       │ 零       │
│ 长时间压力测试     │    △³    │    △³    │    △³     │ 零       │ 零       │
└─────────────────┴──────────┴──────────┴───────────┴──────────┴──────────┘

¹ Dr. Memory 对 DLL 内部的检测取决于 DLL 是否用 /MD 编译
² ASan 需要 DLL 也用 /fsanitize=address 编译才能追踪 DLL 内部
³ 只能检测"进程总内存持续增长",无法定位具体泄漏点

14.12 针对本项目的泄漏检测操作清单

第一步:快速自查(开发期,每天)
─────────────────────────────────
□ 运行 cargo test -- leak  ← 执行 Drop 计数和循环测试
□ 对比测试前后 net_allocated() ← 确认 Rust 侧无泄漏

第二步:CRT 验证(提测前,每周)
─────────────────────────────────
□ 编译 crt_leak_check.c (单独程序,不走 Rust)
□ 运行并确认 _CrtDumpMemoryLeaks 输出为空
□ 覆盖 CreateApi→RegisterSpi→Login→Release 完整路径

第三步:Dr. Memory 全量扫描(发版前)
─────────────────────────────────
□ drmemory.exe cargo test -- --nocapture
□ 过滤 known false positives
□ 确认所有 leak/possible leak 都是 Still Reachable

第四步:长时间压力测试(准入条件)
─────────────────────────────────
□ 启动行情订阅,运行 ≥ 1 小时
□ 内存采样频率 ≥ 每分钟一次
□ 确认线性回归斜率 < 1KB/min
□ 确认退出后进程无残留句柄

第五步:生产监控(持续)
─────────────────────────────────
□ Prometheus / Grafana 监控进程 Working Set
□ 设置内存增长率告警阈值
□ 如果长时间(8h+)线性增长 → 回滚版本,启动 Dr. Memory 分析

十五、封装质量评估体系

15.1 评估模型:S.C.O.R.E 五维框架

                    Safety(安全性)
                         │
        Ergonomics ──────┼────── Correctness
        (易用性)         │       (正确性)
                         │
                    Reliability ──── Completeness
                    (可靠性)       (完整性)

15.2 维度一:Safety(安全性)—— 权重 30%

衡量"会不会 UB / crash / 内存不安全"。

15.2.1 量化指标
指标 本项目实测 目标(生产级) 目标(原型)
unsafe 密度 7.9% (321/4082 LOC) < 5% < 15%
裸指针解引用处是否都有 SAFETY 注释 FFI 层有,安全层无 FFI 层 100% 覆盖 FFI 层 ≥ 80%
as 转型是否有限制 部分使用 as 无校验 关键转型用 try_into() 指针级 as 可接受
是否有 as any / @ts-ignore 等价物 0 0
catch_unwind 防护 FFI 边界 ✅ 有(guard() 所有回调入口 关键回调入口
Drop 顺序是否文档化 ✅ 有注释 文档化 + 测试验证 文档化
Send + Sync 是否经审查 ✅ unsafe impl + 注释 审查 + 推理文档 推理注释
15.2.2 本项目评分
unsafe 密度 7.9%  →  中(FFI 场景合理偏高,但可优化)
SAFETY 注释覆盖  →  高(FFI 层关键 unsafe 都有注释)
panic 隔离       →  高(guard() 覆盖所有 C→Rust 回调路径)
Drop 安全         →  高(字段顺序保证 + 文档)
Send/Sync        →  中(仅注释推理,无形式化证明)

安全性综合: ★★★★☆ (4/5)

15.3 维度二:Correctness(正确性)—— 权重 25%

衡量"封装的行为是否符合 XTP 原始 API 的契约"。

15.3.1 量化指标
指标 本项目实测 目标(生产级)
结构体 sizeof 与 C++ 对齐验证 部分(仅 XtpRspInfo=128 验证) 每个 struct 都应有编译期断言
枚举 discriminant 验证 ✅ 有(ExchangeType, ProtocolType, LogLevel) 所有枚举覆盖
vtable 槽位数量验证 ✅ 有(38*8=304 测试) 编译期 + 运行时验证
vtable 槽位排序验证 运行时 GetApiVersion 探测 双验证(编译期 + 运行时)
布局回归测试 ✅ layout_verify.rs CI 自动运行
与 C 黄金数据自动对比 已设计(gen_layout.c + CSV) 已接入 CI
所有虚函数调用签名是否匹配 手工对齐 手工对齐 + 冒烟测试
返回值错误码映射完整 ✅ XtpError 覆盖主要错误 完整映射
15.3.2 正确性评分
struct 布局验证     →  中(框架已有,黄金数据待填入)
enum 验证           →  高(主要枚举已覆盖)
vtable 验证         →  中高(尺寸验证 + 运行时探测)
签名匹配            →  中(手工对齐,缺乏自动化回归)
返回值映射          →  高(完整错误类型)

正确性综合: ★★★★☆ (4/5)

15.4 维度三:Completeness(完整性)—— 权重 20%

衡量"覆盖了 XTP API 的多大比例"。

15.4.1 量化指标
指标 本项目实测 目标
Quote API 虚函数覆盖率 38/38 = 100%(vtable 全槽位覆盖) 100%
Trader API 虚函数覆盖率 45/66 ≈ 68%(vtable 部分槽位覆盖) ≥ 90%
Quote SPI 回调覆盖率 38/38 = 100%(全部有 trait 默认实现) 100%
Trader SPI 回调覆盖率 14/~55 ≈ 25%(仅暴露常用回调,其余用 no-op stub) 核心回调 ≥ 80%
配置函数覆盖 Quote: 心跳 + UDP 亲和性。Trader: 心跳 + 软件密钥。✅ 核心配置全覆盖
错误码覆盖 ✅ 主要错误码已映射常量(9 个常见码) 关键错误 ≥ 80%
异步/同步模式 ✅ 通过 SPI 回调实现 与原始 API 一致
15.4.2 Trader API 覆盖详情(对照 XTP 2.2.50.8 头文件)

基于 xtp_trader_api.h(2.2.50.8)中 TraderApi 类的所有虚函数声明,与 Rust 封装逐项核对:

✅ 已覆盖 (45 个虚函数 — 核心交易功能):
  Release, GetTradingDay, RegisterSpi, GetApiLastError, GetApiVersion
  GetClientIDByXTPID, GetAccountByXTPID
  SubscribePublicTopic, SetSoftwareVersion, SetSoftwareKey
  SetHeartBeatInterval, SetMaxOrderBufQty
  Login, Logout, IsServerRestart, ModifyUserTerminal
  QueryTradeMarket, GetNewOrderXTPID
  InsertOrder, InsertOrderExtra, CancelOrder
  QueryOrderByXTPID, QueryOrders, QueryUnfinishedOrders
  QueryOrdersByPage, QueryOrderByXTPIDEx, QueryOrdersEx
  QueryUnfinishedOrdersEx, QueryOrdersByPageEx
  QueryTradesByXTPID, QueryTrades, QueryTradesByPage
  QueryPosition, QueryAsset, QueryStructuredFund
  FundTransfer, QueryFundTransfer, QueryOtherServerFund
  QueryETF, QueryETFBasket
  QueryIPOInfoList, QueryIPOQuota
  QueryBondIPOList, QueryBondSwap

❌ 未覆盖 (21 个虚函数 — 2.2.50.8 新增功能):
  SetUDPBufferSize, SetUDPRecvThreadAffinity, SetUDPRecvThreadAffinityArray
  SetUDPParseThreadAffinity, SetUDPParseThreadAffinityArray, SetUDPSeqLogOutPutFlag
  CreditCashRepay, CreditCashRepayDebtInterestFee
  QueryCreditCashRepayInfo, QueryCreditFundInfo
  QueryCreditDebtInfo, QueryCreditTickerDebtInfo, QueryCreditAssetDebtInfo
  QueryCreditTickerAssignInfo, QueryCreditExcessStock, QueryMulCreditExcessStock
  CreditExtendDebtDate, QueryCreditExtendDebtDateOrders
  QueryCreditFundExtraInfo, QueryCreditPositionExtraInfo
  InsertOptionCombinedOrder, CancelOptionCombinedOrder
  QueryOptionCombinedOrders / Ex / ByPage / ByPageEx
  QueryOptionCombinedTrades / ByPage
  QueryOptionCombinedPosition, QueryOptionCombinedStrategyInfo
  QueryOptionCombinedExecPosition, QueryStrategy, SetUserTerminalInfo

未覆盖部分说明

  • 融资融券(Credit* 系列 12 个):仅在信用账户场景使用
  • 期权组合策略(OptionCombined* 系列 12 个):仅在期权组合交易场景使用
  • UDP 解析线程配置(4 个):UDP 模式专属,与 Quote UDP 配置同理
  • 算法策略查询(QueryStrategy):算法交易专属
  • SetUserTerminalInfo:终端信息设置

这些未覆盖的 API 主要属于特定业务场景(融资融券、期权组合、算法交易),而非通用交易核心。对于普通股票交易场景,已有覆盖率达到 100%

15.4.3 Trader SPI 回调覆盖详情

Rust TraderSpi trait 只暴露了 14 个常用回调(总共 ~55 个虚函数),其余 41 个用 no-op stub 占位。暴露的 14 个覆盖了核心业务流程:

回调 场景 是否暴露
on_disconnected 断连通知
on_error 错误通知
on_order_event 订单状态变化
on_trade_event 成交回报
on_cancel_order_error 撤单错误
on_query_order / on_query_order_ex 查询订单
on_query_trade 查询成交
on_query_position 查询持仓
on_query_asset 查询资产
on_fund_transfer / on_query_fund_transfer 资金划拨
其余 ~41 个(信用、期权组合、算法) 特定场景 no-op stub
15.4.4 完整性评分(修正后)
Quote API 虚函数    →  极高(38/38 = 100%)
Quote SPI 回调      →  极高(38/38 = 100%)
Trader API 虚函数   →  中高(~45/~66 = 68%,但核心交易场景 100%)
Trader SPI 回调     →  中(14/~55 = 25%,但核心回调 100%)
常用配置            →  高
错误码              →  高

完整性综合: ★★★★☆ (4/5)
(从原来的 5/5 下调至 4/5,因 Trader API 未覆盖信用/期权组合/算法场景)

15.5 维度四:Ergonomics(易用性)—— 权重 15%

衡量"Rust 开发者是否觉得这个封装好用"。

15.5.1 量化指标
指标 本项目表现 评价
API 是否类型安全 &str*const c_charenumi32 优秀
Trait 默认实现 ✅ 38 个回调均有默认空实现 优秀
错误处理 Result<T, XtpError> + thiserror 优秀
RAII 生命周期 ✅ Drop 自动调用 Release 优秀
泛型 vs dyn Box<dyn QuoteSpi>(选 dyn 合理) 良好
文档示例 lib.rs 有 Quick Start 良好
是否有 unsafe 暴露给用户 (所有 unsafe 封装在内部) 优秀
函数命名是否符合 Rust 惯例 snake_caselogin/logout 优秀
Builder 模式 无(参数直接传入 new() 可接受
15.5.2 易用性评分
类型安全      →  优秀(完全隐藏裸指针)
错误处理      →  优秀(Result + 结构化错误)
RAII          →  优秀(无需手动 Release)
文档          →  良好(有示例,缺 API 级文档)
命名          →  优秀

易用性综合: ★★★★★ (5/5)

15.6 维度五:Reliability(可靠性)—— 权重 10%

衡量"异常路径、边界条件、长期运行下的稳定性"。

15.6.1 量化指标
指标 本项目表现 评价
null 指针处理 ✅ holder!/data_ref!/rsp_ref! 宏 优秀
panic 隔离 ✅ guard() 包裹所有回调 优秀
CString 分配失败处理 CString::new() 返回 Err 优秀
并发安全性 ✅ unsafe impl Send+Sync 需更多文档
已知边缘 bug ⚠️ Issues §14(未 Login 的 Release crash) 需修复
重连场景 待验证(on_disconnected 后调 login 待测
长时间运行 待验证 待测
日志输出控制 ✅ LogLevel 枚举 良好
15.6.2 可靠性评分
null 处理       →  优秀
panic 隔离       →  优秀
边缘 case       →  中(已知 Release crash bug)
并发            →  中
长时间稳定      →  待验证

可靠性综合: ★★★☆☆ (3/5)

15.7 综合评分

维度          权重    评分    加权分
──────────   ────   ────   ──────
Safety        30%    4.0     1.20
Correctness   25%    4.0     1.00
Completeness  20%    4.0     0.80  ← 从 5.0 下调,因 Trader API 仅覆盖 68%
Ergonomics    15%    5.0     0.75
Reliability   10%    3.0     0.30
────────────────────────────────
总分                         4.05 / 5.0

评级: B+ (生产可用,有已知限制;核心交易场景覆盖完整)

15.8 各维度的改进优先级

╔══════════════════╦══════════════════════════════════════════════╗
║ 改进项            ║ 动作                                        ║
╠══════════════════╬══════════════════════════════════════════════╣
║                   ║                                              ║
║ 🔴 P0: 未Login   ║ 在 RawQuoteApi::drop() 加守卫:             ║
║    Release crash ║ if never logged in → skip Release() 或空实现 ║
║                   ║                                              ║
║ 🟡 P1: 布局验证   ║ 填入 layout.csv 黄金值,启用所有 const_     ║
║    补齐          ║ assert! + 所有 struct 的 #[test]             ║
║                   ║                                              ║
║ 🟡 P1: 测试覆盖   ║ 添加:创建/销毁循环测试、登录失败测试、     ║
║    补齐          ║ 重连测试、长时间运行测试                      ║
║                   ║                                              ║
║ 🟡 P1: Trader API ║ 按需补充融资融券/期权组合/算法策略的 API    ║
║    缺口补齐      ║ 封装(→ 15.4.2 未覆盖的 21 个虚函数)        ║
║                   ║                                              ║
║ 🟢 P2: unsafe    ║ 审查每个 unsafe 块,补充 # Safety 注释,     ║
║    注释补齐      ║ 说明不变量和前置条件                          ║
║                   ║                                              ║
║ 🟢 P2: 并发文档   ║ 补充 Send+Sync 的安全性推理文档             ║
║                   ║                                              ║
║ 🔵 P3: API 文档   ║ 为每个 pub fn 添加文档注释和示例            ║
║                   ║                                              ║
║ 🔵 P3: 集成测试   ║ 连接真实 XTP 环境做端到端测试               ║
║                   ║                                              ║
╚══════════════════╩══════════════════════════════════════════════╝

15.9 同类型 FFI 封装的横向对比参考

项目 语言 规模 Safety 得分 特点
本项目 (xtp-api) Rust ~4K LOC B+ 手工 vtable,Drop 设计精良
windows-rs Rust >1M LOC A 微软官方,自动生成 + 审查
wgpu Rust ~100K LOC A 社区主力,逐层安全封装
典型手工 FFI crate Rust ~3K LOC B 常见水平:工作但不完善
不安全的 FFI crate Rust ~2K LOC C 大量 as any、无 Drop impl

本项目定位:处于"手工 FFI crate"的上游水平。架构设计(分层、Drop 顺序、SPI 桥接)达到生产级标准;执行细节(测试覆盖、边缘 case、布局验证自动化)有提升空间。

15.10 质量评估检查清单

□ Safety
  □ unsafe 密度 < 10%
  □ 每个 unsafe 块有 # Safety 注释
  □ 无 as any / transmute 滥用
  □ FFI 回调入口有 catch_unwind
  □ Drop 顺序有文档和测试
  □ Send/Sync impl 有安全推理

□ Correctness
  □ 所有 #[repr(C)] struct 有 sizeof 断言
  □ 所有 enum 有 discriminant 断言
  □ vtable 槽位数有编译期断言
  □ 与 C 黄金数据自动对比通过
  □ 关键字段 offset 有断言

□ Completeness
  □ API 覆盖率 ≥ 90%
  □ 回调覆盖率 = 100%
  □ 错误码覆盖率 ≥ 80%
  □ 配置选项全覆盖

□ Ergonomics
  □ 无 unsafe 暴露到 pub API
  □ 全部使用 Result<T, Error> 非 panic
  □ Trait 方法有默认实现
  □ RAII 自动管理资源
  □ lib.rs 有 Quick Start 示例

□ Reliability
  □ 所有裸指针解引用前有 null 检查
  □ 已知边缘 bug 已修复或文档化
  □ 有创建/销毁循环测试
  □ 有内存泄漏检测流程
  □ 长时间运行的稳定性已验证

十六、Rust 与 C++ 字符串处理差异清单

16.1 核心差异总览

╔═══════════════════╦══════════════════════════════╦══════════════════════════════════╗
║ 维度               ║ C++ (MSVC / XTP API)         ║ Rust                             ║
╠═══════════════════╬══════════════════════════════╬══════════════════════════════════╣
║ 存储模型           ║ Null-terminated (以 \0 结尾)  ║ Length-prefixed (ptr + len)     ║
║ 核心类型           ║ char*, const char*, char[16] ║ &str, String, [u8; N], CString  ║
║ 长度获取           ║ strlen() O(n)                ║ .len() O(1)                     ║
║ 有效载荷           ║ 可以包含除 \0 外的任意字节    ║ &str 必须是合法 UTF-8           ║
║ 编码               ║ 系统 locale (GBK/UTF-8)       ║ 强制 UTF-8                     ║
║ 空指针语义         ║ NULL = "没有值"               ║ Option<&str> 显式表示           ║
║ 固定缓冲区溢出     ║ 静默截断(危险)              ║ 编译期检查 / panic               ║
║ 内嵌 \0            ║ 字符串在第一个 \0 处截断      ║ CStr 可以处理内嵌 \0            ║
║ 内存所有权         ║ 需手动管理(容易泄漏)        ║ 编译器自动管理                   ║
╚═══════════════════╩══════════════════════════════╩══════════════════════════════════╝

16.2 类型映射表

16.2.1 XTP API 中的字符串参数 → Rust 类型映射
XTP API 参数类型 语义 方向 Rust 入参类型 FFI 层转换目标
const char* ip IP 地址 Rust→C++ &str CString.as_ptr()
const char* user 用户名 Rust→C++ &str CString.as_ptr()
const char* password 密码 Rust→C++ &str CString.as_ptr()
const char* filename 文件路径 Rust→C++ &str CString.as_ptr()
const char* save_path 存储路径 Rust→C++ &str CString.as_ptr()
char* ticker[] 股票代码数组 Rust→C++ &[&str] Vec<CString> + Vec<*mut c_char>
const char* (返回值) 版本号 C++→Rust CStr::from_ptr()String
char[16] ticker 代码字段 C++→Rust [u8; 16]CStr::from_bytes_until_nul()String
char[124] error_msg 错误消息 C++→Rust [u8; 124]CStr::from_bytes_until_nul()&str
16.2.2 FFI 边界的 Rust 类型体系
Rust 用户侧                  FFI 边界转换层                C++ 侧
───────────                 ──────────────                ──────
&str          ──→  CString::new(s)?  ──→  .as_ptr()  ──→  const char*
Option<&str>  ──→  .and_then(|s| CString::new(s).ok())
                       ──→  .as_ref().map_or(null, |c| c.as_ptr())  ──→  const char* (可为 null)

              ←──  CStr::from_ptr(p)  ←──  .to_string_lossy()  ←──  const char* (返回值)
              ←──  [u8; N]  ←──  CStr::from_bytes_until_nul()  ←──  char[N] (结构体字段)
              ←──  CStr::from_ptr(p)  ←──  .as_ref().unwrap_or()  ←──  XTPRI* (可为 null)

16.3 场景一:Rust &str → C++ const char*(出站)

标准转换模式
// 第1步: &str → CString(检查内部 \0,分配堆内存,追加尾 \0)
let c_str = CString::new(filename)
    .map_err(|e| XtpError::InvalidArgument(e.to_string()))?;

// 第2步: CString → *const c_char(借出指针,CString 必须保持存活)
let ptr: *const c_char = c_str.as_ptr();

// 第3步: 传递给 C++
unsafe { (self.vt().set_config_file)(self.obj, ptr) };

// 第4步: c_str 离开作用域 → 自动释放
本项目中的实际代码
// === 单字符串示例:SetConfigFile ===
pub fn set_config_file(&self, filename: &str) -> Result<(), XtpError> {
    let c = CString::new(filename)
        .map_err(|e| XtpError::InvalidArgument(e.to_string()))?;
    if unsafe { (self.vt().set_config_file)(self.obj, c.as_ptr()) } {
        Ok(())
    } else {
        Err(self.err("SetConfigFile", -1))
    }
    // c 在此 drop → 堆内存释放 ✅
}

// === 多字符串示例:Login ===
pub fn login(&self, ip: &str, port: i32, user: &str, password: &str,
             sock_type: ProtocolType, local_ip: Option<&str>) -> Result<(), XtpError> {
    let cip = CString::new(ip).map_err(|e| ...)?;
    let cu  = CString::new(user).map_err(|e| ...)?;
    let cp  = CString::new(password).map_err(|e| ...)?;
    // Option<String> → Option<CString> → Option<*const c_char>
    let cl  = local_ip.and_then(|s| CString::new(s).ok());

    let r = unsafe {
        (self.vt().login)(
            self.obj,
            cip.as_ptr(),    // 所有 CString 在此函数栈帧内保持存活
            port as c_int,
            cu.as_ptr(),
            cp.as_ptr(),
            sock_type as i32,
            cl.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()),  // null 或 ptr
        )
    };
    // cip/cu/cp/cl 在此 drop ✅
}

// === 股票代码数组示例:subscribe_market_data ===
fn sub_cstrs(&self, tickers: &[&str]) -> Result<(Vec<CString>, Vec<*mut c_char>), XtpError> {
    let cs: Vec<CString> = tickers.iter()
        .map(|t| CString::new(*t).map_err(|e| XtpError::InvalidArgument(e.to_string())))
        .collect::<Result<_, _>>()?;
    let ptrs: Vec<*mut c_char> = cs.iter()
        .map(|t| t.as_ptr() as *mut c_char)
        .collect();
    Ok((cs, ptrs))  // cs 和 ptrs 必须同时返回,保证 ptrs 指向的内存在 cs 中
}

fn call_sub_unsub(&self, vfn: ..., tickers: &[&str], ex: ExchangeType, name: &str) -> ... {
    let (_cs, mut ptrs) = self.sub_cstrs(tickers)?;  // _cs 保持存活
    let r = unsafe { vfn(self.obj, ptrs.as_mut_ptr(), ptrs.len() as c_int, ex as u32) };
    // _cs 和 ptrs 在此 drop ✅
}

16.4 场景二:C++ const char* → Rust String(入站)

模式 A:DLL 返回的 const char*(借用,需立即拷贝)
// C++ DLL 返回一个指向内部静态 buffer 的 const char*
// → Rust 不能持有指针引用(DLL 可能卸载)→ 必须立即拷贝到 String

pub fn get_api_version(&self) -> String {
    unsafe {
        let p = (self.vt().get_api_version)(self.obj);
        if p.is_null() {
            String::new()                    // null → 空串
        } else {
            std::ffi::CStr::from_ptr(p)       // ① 包装为 CStr(不拷贝)
                .to_string_lossy()            // ② 转换为 &str(处理非 UTF-8)
                .into_owned()                 // ③ 拷贝到堆上的 String
        }
    }
    // p 指向的内存仍归 DLL,但我们已经拷贝了 ✅
}
模式 B:结构体中的 char[N] 固定缓冲区
// XtpMarketData.ticker 在 Rust 侧是 [u8; 16]
// 固定长度 char 数组的转换:

// 方法1: 项目中的 from_cstring_bytes(common.rs)
pub fn from_cstring_bytes(buf: &[u8]) -> String {
    CStr::from_bytes_until_nul(buf)             // ① 找到第一个 \0 截断
        .map(|c| c.to_string_lossy().into_owned())  // ② UTF-8 转换 + 拷贝
        .unwrap_or_default()                       // ③ 无 \0 → 返回空串
}

// 使用:
impl XtpMarketData {
    pub fn ticker_str(&self) -> String {
        from_cstring_bytes(&self.ticker)
    }
}
模式 C:可能为 null 的 XTPRI* → Rust 引用
// rsp_ref! 宏处理 C++ 可能传入 null XTPRI* 的情况
macro_rules! rsp_ref {
    ($ptr:expr) => {
        // 如果 C++ 传入 null → 返回 &XtpRspInfo::OK (全零值)
        // 如果非 null → 从裸指针恢复引用
        unsafe { ($ptr as *const XtpRspInfo).as_ref() }
            .unwrap_or(&XtpRspInfo::OK)
    };
}

// XtpRspInfo.error_msg 在 Rust 侧是 [u8; 124]
impl XtpRspInfo {
    pub fn error_msg_str(&self) -> &str {
        std::str::from_utf8(&self.error_msg)
            .unwrap_or("(invalid utf8)")      // 非 UTF-8 → fallback
            .trim_end_matches('\0')            // 去掉尾 \0
    }
}

16.5 关键差异与陷阱

陷阱 1:&str 可能包含内部 \0 → CString::new() 失败
let s = "hello\0world";             // Rust &str 允许内嵌 \0
let c = CString::new(s);            // ❌ Err(NulError) — C 字符串不允许

本项目处理:用 map_err 转为 XtpError::InvalidArgument,让调用者知道入参非法。

let c = CString::new(filename)
    .map_err(|e| XtpError::InvalidArgument(e.to_string()))?;
陷阱 2:CString::as_ptr() 返回的指针在 CString drop 后失效
// ❌ 错误:ptr 在 c drop 后悬垂
let ptr: *const c_char = {
    let c = CString::new("hello").unwrap();
    c.as_ptr()           // 返回指向 c 内部 buffer 的指针
};  // c 在此 drop → ptr 变成悬垂指针!

unsafe { some_c_function(ptr); }  // 💥 use-after-free

本项目保证:所有 CString 变量在 FFI 调用完成后才离开作用域。

// ✅ 正确:c 在整个 unsafe 块中存活
let c = CString::new("hello").unwrap();
unsafe { some_c_function(c.as_ptr()); }
// c 在此 drop ✅
陷阱 3:C++ 返回的 const char* 寿命不确定
// ❌ 假设错误:以为可以长期持有 C++ 返回的指针
let version: &str = unsafe {
    let p = (self.vt().get_api_version)(self.obj);
    CStr::from_ptr(p).to_str().unwrap()  // 返回 &str 借用了 p 指向的内存
};
// version 仍借用 DLL 内部内存!
// 如果 DLL 被卸载 → version 变成悬垂引用 → 💥

本项目处理:立即 .to_string_lossy().into_owned() 拷贝到 String

// ✅ 正确:立即拷贝
let version: String = unsafe {
    let p = (self.vt().get_api_version)(self.obj);
    if p.is_null() { String::new() }
    else { CStr::from_ptr(p).to_string_lossy().into_owned() }
};
陷阱 4:char[16] 写成不到 16 字节 → 尾 \0 后是垃圾
C++ ticker[16] = "600000\0?????????"
                  ─────────           ← 有效
                            ───────── ← 垃圾(未初始化)

Rust from_cstring_bytes 从第一个 \0 截断 → 忽略垃圾 ✅
如果不截断直接 str::from_utf8(&buf[..16]) → 读到垃圾 → 可能乱码

本项目处理CStr::from_bytes_until_nul() 在第一个 \0 处停止。

陷阱 5:UTF-8 vs 系统编码
C++ char*       →  编码取决于系统和源数据
                  - 中文 Windows: GBK (CP936) 或 UTF-8
                  - XTP 行情数据: 通常是 ASCII(ticker 只有英文数字)
                  - 错误消息: 可能是 GBK

Rust &str       →  强制 UTF-8

如果 XTP 返回 GBK 编码的中文错误消息:
  CStr::to_string_lossy() → 非 UTF-8 字节替换为 U+FFFD (�)

本项目处理to_string_lossy() 对非法 UTF-8 做容错替换,不 crash。

陷阱 6:Option<&str> → 可为 null 的 const char*
当地址参数可选时:
  C++ local_ip: 传 NULL 表示使用默认值
  Rust: Option<&str>

转换链:
  Option<&str> → .and_then(|s| CString::new(s).ok())
              → Option<CString>
              → .as_ref().map_or(std::ptr::null(), |c| c.as_ptr())

本项目 login() 中的实际代码:

let cl = local_ip.and_then(|s| CString::new(s).ok());
// cl.as_ref().map_or(std::ptr::null(), |s| s.as_ptr())
//   Some(c) → c.as_ptr() → 有效指针
//   None    → null

16.6 &str 到固定长度 char[N] 的转换

项目中 to_cstring_bytes() 用于在 Rust 侧构造结构体时写入固定长度 char 数组:

pub fn to_cstring_bytes(s: &str, buf: &mut [u8]) {
    let bytes = s.as_bytes();
    let len = bytes.len().min(buf.len() - 1);  // 留 1 字节给 \0
    buf[..len].copy_from_slice(&bytes[..len]);
    buf[len] = 0;                               // 写入尾 \0
}
边界情况 行为
s 短于 buf 正确写入 + \0,剩余字节不动(保持原值)
s 长于 buf-1 静默截断,最后一个字节永远是 \0
s 是空串 "" buf[0] = 0
buf[u8; 1] len = 0,仅 buf[0] = 0

16.7 两条转换路径的完整生命周期

═══ Rust → C++ ═══════════════════════════════════════════════

&str "600000"
    │
    ├─ CString::new("600000")?              ← 检查 \0,分配堆内存
    │   └→ CString { inner: Box<[u8]> }     ← 堆上: [54,30,30,30,30,30, 0]
    │
    ├─ c_str.as_ptr()                       ← 借出 *const c_char
    │   └→ 0x1A2B3C40
    │
    ├─ DLL: SubscribeMarketData(..., 0x1A2B3C40, ...)
    │   └→ C++ 使用指针(必须同步返回)
    │
    └─ c_str.drop()                         ← 函数返回时自动释放
        └→ Box<[u8]> 释放                   ← 堆内存回收 ✅


═══ C++ → Rust ═══════════════════════════════════════════════

DLL 内部 char ticker[16] = "600000\0........."
    │ 地址: 0xB0C0D000 (在 DLL 或栈上)
    │
    ├─ Rust 侧类型: [u8; 16]
    │
    ├─ CStr::from_bytes_until_nul(&ticker)
    │   └→ 扫描找到 [6] = \0
    │   └→ CStr { ptr: 0xB0C0D000, len: 6 }
    │
    ├─ .to_string_lossy()
    │   └→ 检查 UTF-8 → 合法 → Cow::Borrowed("600000")
    │   └→ 如果有非法字节 → Cow::Owned 替换为 U+FFFD
    │
    └─ .into_owned()
        └→ String::from("600000")            ← 堆分配,所有权归 Rust ✅
                                            ← 不再依赖 DLL 内存

16.8 本项目所有字符串转换点汇总

┌────────────────────────────┬───────────┬─────────────────────────────────┐
│ XTP API 调用                │ 方向       │ 字符串操作                      │
├────────────────────────────┼───────────┼─────────────────────────────────┤
│ CreateQuoteApi              │ Rust → C++ │ CString::new(save_path)         │
│ SetConfigFile               │ Rust → C++ │ CString::new(filename)          │
│ SetHeartBeatInterval        │ Rust → C++ │ 无字符串(u32)                 │
│ RegisterSpi                 │ Rust → C++ │ 无字符串(裸指针)              │
│ Login                       │ Rust → C++ │ CString × 4 (ip/user/pass/lip) │
│ SubscribeMarketData         │ Rust → C++ │ Vec<CString> (ticker 数组)      │
│ UnsubscribeMarketData       │ Rust → C++ │ Vec<CString> (ticker 数组)      │
│ SubscribeOrderBook          │ Rust → C++ │ Vec<CString> (ticker 数组)      │
│ SubscribeTickByTick         │ Rust → C++ │ Vec<CString> (ticker 数组)      │
│ (及 6 个其他订阅/查询)       │ Rust → C++ │ Vec<CString> (ticker 数组)      │
│ GetApiVersion               │ C++ → Rust │ CStr::from_ptr → String         │
│ GetApiLastError             │ C++ → Rust │ XtpRspInfo.error_msg: [u8;124]  │
│ OnDepthMarketData (回调)    │ C++ → Rust │ XtpMarketData.ticker: [u8;16]   │
│ OnOrderBook (回调)           │ C++ → Rust │ XtpOrderBook.ticker: [u8;16]    │
│ OnTickByTick (回调)          │ C++ → Rust │ XtpTickByTick.ticker: [u8;16]   │
│ (所有 38 个回调)             │ C++ → Rust │ 结构体中的 [u8;16] ticker       │
└────────────────────────────┴───────────┴─────────────────────────────────┘

16.9 字符串处理检查清单

□ Rust → C++ 的 &str 是否都通过了 CString::new() 转换?
  → 每个 login/subscribe/config 调用都应可见 CString 构造

□ CString::new() 的错误是否可恢复?
  → 不应该 unwrap(),应该 ? 传播或 map_err

□ CString 的生命周期是否覆盖了 FFI 调用?
  → 函数调用前创建,调用后 drop → ✅
  → 如果 CString 是临时值直接 .as_ptr(),需确保在同一语句中调用

□ Vec<CString> 和 Vec<*mut c_char> 是否同时存活?
  → sub_cstrs 返回元组 (Vec<CString>, Vec<*mut c_char>),cs 必须比 ptrs 活得久

□ C++ 返回的 *const c_char 是否立即拷贝到 String?
  → ❌ 不可以存 &str(借用了 DLL 内存)
  → ✅ 用 .to_string_lossy().into_owned() 立即转 String

□ char[16] 固定缓冲区是否用了 CStr::from_bytes_until_nul()?
  → ✅ 跳过末尾垃圾
  → ❌ 不要直接 str::from_utf8(&buf[..16])

□ 处理 null 返回了吗?
  → get_api_version: p.is_null() → String::new()
  → XTPRI*: rsp_ref! 宏用 XtpRspInfo::OK 替代 null

□ 非 UTF-8 字节如何处理?
  → to_string_lossy() → 替换为 U+FFFD
  → str::from_utf8().unwrap_or("(invalid utf8)")

□ Option<&str> 到 null 指针的映射是否正确?
  → None → std::ptr::null()
  → Some(s) → CString::new(s).as_ptr()

十七、Rust ↔ C++ 指针转换完整目录

17.1 指针转换分类总图

┌─────────────────────────────────────────────────────────────────┐
│                      指针转换全景                                │
│                                                                 │
│  Rust 安全侧                    FFI 边界              C++ 侧     │
│  ──────────                    ────────              ──────     │
│                                                                 │
│  ① Symbol<T>         ←──    符号查找    ──→   DLL 导出函数地址  │
│  ② *mut c_void       ←──  CreateQuoteApi ──→   QuoteApi*       │
│  ③ *const VTable     ←──  对象[0]解引用  ──→   vtable 地址     │
│  ④ &VTable           ←──  &*裸指针      ──→   函数指针表       │
│  ⑤ *mut c_void       ──→  this 指针     ──→   C++ 虚函数       │
│  ⑥ CString.as_ptr()  ──→  const char*   ──→   IP/用户/路径     │
│  ⑦ Vec<*mut c_char>  ──→  char**        ──→   股票代码数组     │
│  ⑧ CStr::from_ptr()  ←──  const char*   ←──   版本号/返回值    │
│  ⑨ ptr.as_ref()      ←──  XTPRI*/MD*    ←──   结构体指针       │
│  ⑩ &mut Holder as *  ──→  QuoteSpi*     ──→   SPI 注册         │
│  ⑪ *mut Holder       ←──  this (回调)   ←──   虚函数调用       │
│  ⑫ from_raw_parts()  ←──  *i64 + count  ←──   数组 + 长度      │
│  ⑬ &T as *const _    ──→  const T*      ──→   只读参数         │
│  ⑭ Vec.as_mut_ptr()  ──→  *mut i32      ──→   数组传递         │
│  ⑮ Arc<Library>      ──→  DLL 句柄      ──→   生命周期锚       │
└─────────────────────────────────────────────────────────────────┘

17.2 逐一详解(15 种转换)


转换 ①:DLL 导出函数 → Symbol<T> → 函数指针
来源:  DLL 导出表 (PE 文件)
中间:  libloading::Library::get::<T>(name)
目标:  Symbol<CreateQuoteApiFn> — 带生命周期的函数指针包装
// 步骤:
let library = unsafe { Library::new("xtpxquoteapi.dll") }?;          // ① 加载 DLL
let sym: Symbol<CreateQuoteApiFn> = unsafe { library.get(name) }?;   // ② 按名查找
// sym 自动 Deref 为 CreateQuoteApiFn = unsafe extern "C" fn(...)

// 调用:
let obj = unsafe { create_fn(client_id, save_path, log_level, udpseq) };
//       = unsafe { (*sym)(client_id, save_path, log_level, udpseq) };
类型转换 说明
Library DLL 句柄的 RAII 包装(LoadLibrary/FreeLibrary
Symbol<T> 函数指针的 RAII 包装,生命周期受 Library 约束
T = CreateQuoteApiFn unsafe extern "C" fn(u8, *const c_char, i32, bool) -> *mut c_void

转换 ②:C++ 对象创建(Rust ← C++)
来源:  DLL 内部 new QuoteApi(...)
表达:  create_fn(...) → *mut c_void
语义:  QuoteApi* — 指向 MSVC C++ 多态对象的指针
存储:  RawQuoteApi.obj: *mut c_void
let obj = unsafe { create_fn(client_id, save_path.as_ptr(), log_level as i32, udpseq_output) };
// 类型: *mut c_void (= QuoteApi*)

// 校验:
if obj.is_null() { return Err(...); }

// 存储:
RawQuoteApi { _library, vtable, obj }  // 裸指针,不拥有所有权
要点 说明
*mut c_void 而非 *mut QuoteApi C++ 类型未知,用 c_void 做不透明句柄
所有权 C++ 侧分配(new),Rust 不负责释放——由 Release() 虚函数释放
*mut 而非 *const DLL 内部可能修改对象状态

转换 ③:vtable 指针提取(对象内部指针 → 函数表指针)
来源:  C++ 对象的前 8 字节
步骤:  obj → *const *const VTable → 解引用
结果:  *const QuoteApiVTable
// obj 指向 C++ 对象,对象的前 8 字节是 vtable 地址
let vtable = unsafe { *(obj as *const *const QuoteApiVTable) };
//   obj: *mut c_void = 0xAABBCC00
//   内存 [0xAABBCC00..0xAABBCC08] = 0x12345000  (vtable 地址)
//   结果: vtable = 0x12345000 (*const QuoteApiVTable)
步骤 类型
obj *mut c_void 0xAABBCC00
obj as *const *const QuoteApiVTable *const *const QuoteApiVTable 0xAABBCC00(重新解释类型)
*(...) *const QuoteApiVTable 0x12345000(读出 8 字节)

转换 ④:vtable 裸指针 → 安全引用(Deref)
来源:  RawQuoteApi.vtable: *const QuoteApiVTable
步骤:  &*self.vtable
结果:  &QuoteApiVTable (Rust 引用)
fn vt(&self) -> &QuoteApiVTable {
    unsafe { &*self.vtable }   // *const T → &T
}

// 使用时自动解引用:
self.vt().login       // &QuoteApiVTable → .login 字段访问

安全前提self.vtable 指向 DLL 内部只读段,在 _library 存活期间始终有效。


转换 ⑤:this 指针传给 C++ 虚函数(Rust → C++)
来源:  RawQuoteApi.obj: *mut c_void
用法:  每个 vtable 调用的第一个参数
方向:  Rust → C++(x64 下通过 RCX 寄存器传递)
// 所有 vtable 调用的模式:
unsafe { (self.vt().login)(self.obj, cip.as_ptr(), port, ...) }
//                         ^^^^^^^^
//                         this 指针 = 原始的 obj
vtable 方法 this 类型 说明
release *mut c_void Release 在 C++ 侧是 void Release()
login *mut c_void int Login(const char*, int, ...)
register_spi *mut c_void void RegisterSpi(QuoteSpi*)

所有的 this 指针始终是同一个 self.obj——它指向 CreateQuoteApi 返回的那个 C++ 对象。


转换 ⑥:Rust &str → C++ const char*(出站单字符串)
来源:  Rust &str
步骤:  &str → CString → .as_ptr() → *const c_char
方向:  Rust → C++
// 模式:
let c = CString::new(filename)?;          // ① 分配堆内存 + 追加 \0
let ptr: *const c_char = c.as_ptr();      // ② 借出内部指针
unsafe { c_func(ptr); }                   // ③ 调用 C++(c 必须保持存活)
// c 在此 drop → ④ 堆内存自动释放
步骤 操作 内存
CString::new("hello") 分配 Box<[u8]>,写入 hello\0 Rust 堆
.as_ptr() 返回指向内部 buffer 的指针 不分配
c.drop() 释放 Box<[u8]> 自动

转换 ⑦:Rust &[&str] → C++ char**(出站字符串数组)
来源:  Rust &[&str] (股票代码列表)
步骤:  Vec<CString> → Vec<*mut c_char> → .as_mut_ptr()
结果:  *mut *mut c_char (char**)
fn sub_cstrs(&self, tickers: &[&str]) -> Result<(Vec<CString>, Vec<*mut c_char>), XtpError> {
    // ① &[&str] → Vec<CString>(每个 &str 转 CString)
    let cs: Vec<CString> = tickers.iter()
        .map(|t| CString::new(*t).map_err(|e| ...))
        .collect::<Result<_, _>>()?;

    // ② Vec<CString> → Vec<*mut c_char>(取每个 CString 的内部指针)
    let ptrs: Vec<*mut c_char> = cs.iter()
        .map(|t| t.as_ptr() as *mut c_char)   // *const c_char → *mut c_char
        .collect();

    Ok((cs, ptrs))  // ← 必须同时返回!cs 保持 ptrs 的指针有效
}
内存布局(3 个 ticker):
cs:   [CString("600000"), CString("000001"), CString("399001")]
        ↓                   ↓                   ↓
ptrs:  [0x1000,             0x1100,             0x1200]
        ↓
ptrs.as_mut_ptr() = 0x2000 → char*[3]
传给 C++: SubscribeMarketData(obj, 0x2000, 3, ex)
约束 说明
cs 必须比 ptrs 活得久 ptrs 中的指针指向 cs 内 CString 的内部 buffer
ptrs.as_mut_ptr()*mut *mut c_char 传给 C++ 的 char*[] 参数
as *mut c_char 需要 cast CString::as_ptr() 返回 *const c_char,但 API 声明为 *mut c_char

转换 ⑧:C++ const char* → Rust String(入站返回值)
来源:  C++ 虚函数返回值 *const c_char
步骤:  CStr::from_ptr() → .to_string_lossy() → .into_owned()
方向:  C++ → Rust(立即拷贝,不持有 DLL 内部指针)
pub fn get_api_version(&self) -> String {
    unsafe {
        let p = (self.vt().get_api_version)(self.obj);
        //     ^^^^^^^^^^^^^ C++ 函数签名: const char* GetApiVersion()
        // p: *const c_char → 指向 DLL 内部静态字符串

        if p.is_null() {
            String::new()                              // null → 空串
        } else {
            std::ffi::CStr::from_ptr(p)                // ① 包装为 CStr(不拷贝)
                .to_string_lossy()                     // ② 检查 UTF-8,容错
                .into_owned()                          // ③ 拷贝到 String(堆分配)
        }
    }
}
步骤 类型 所有权
p *const c_char DLL 所有
CStr::from_ptr(p) &CStr 借用 DLL 内存
.to_string_lossy() Cow<str> 可能借用,可能拥有
.into_owned() String Rust 所有,安全

转换 ⑨:C++ 结构体指针 → Rust 引用(入站数据)
来源:  C++ 传入的 *mut XtpRspInfo / *mut XtpMarketData 等
步骤:  .as_ref() → Option<&T> → unwrap_or / ? / 守卫
方向:  C++ → Rust(借用,不拷贝,回调期间有效)

三种模式

// 模式 A: 可 null 指针 → Option<T> → 回退默认值
let ptr: *mut XtpRspInfo = unsafe { (self.vt().get_api_last_error)(self.obj) };
let rsp: XtpRspInfo = unsafe { ptr.as_ref() }.copied().unwrap_or(XtpRspInfo::OK);
//                              ^^^^^^^^    ^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^^^
//                              *mut → &T   拷贝值     null 时用 OK 替代

// 模式 B: 必须非 null 的指针 → 直接 as_ref() → 守卫跳过
macro_rules! data_ref { ($ptr:expr) => {
    match unsafe { $ptr.as_ref() } {
        Some(r) => r,           // 有效 → 返回引用
        None => return,          // null → 跳过整个回调
    }
};}

// 模式 C: 在回调中接收 C++ 传入的 this → 恢复 Rust 结构体
macro_rules! holder { ($this:expr) => {
    match unsafe { ($this as *const QuoteSpiHolder).as_ref() } {
        Some(h) => h,
        None => return,
    }
};}

转换 ⑩:Rust Box → C++ QuoteSpi*(SPI 注册)
来源:  Rust Box<QuoteSpiHolder>(堆上分配)
步骤:  &mut *holder → as *mut QuoteSpiHolder → as *mut c_void
方向:  Rust → C++(C++ 持有裸指针,Rust 保留所有权)
pub fn register_spi(&mut self, spi: Box<dyn QuoteSpi>) {
    let mut holder = Box::new(QuoteSpiHolder {
        vtable: &QUOTE_SPI_VTABLE,
        callback: spi,               // Box<dyn QuoteSpi> 所有权移入
    });

    // ① &mut QuoteSpiHolder → *mut QuoteSpiHolder
    // ② *mut QuoteSpiHolder → *mut c_void (类型擦除)
    self.raw.register_spi(&mut *holder as *mut QuoteSpiHolder as *mut c_void);

    // ③ 所有权保留在 Rust
    self.spi = Some(holder);
}
内存布局:
┌─────────────────────────┐
│ QuoteSpiHolder (堆)      │
│ ┌─────────────────────┐ │
│ │ vtable_ptr (8 bytes)│←│─── C++ 认为这是 QuoteSpi 对象
│ │ callback (16 bytes) │ │
│ └─────────────────────┘ │
└─────────────────────────┘
         ↑                          ↑
   C++ DLL 持有裸指针          Rust self.spi 持有 Box
   (不负责释放)                 (负责释放)

转换 ⑪:C++ 回调 this → Rust QuoteSpiHolder(入站回调)
来源:  C++ DLL 回调线程传入的 this (裸指针)
步骤:  *mut QuoteSpiHolder → as *const QuoteSpiHolder → .as_ref() → &QuoteSpiHolder
方向:  C++ → Rust
// trampoline 函数签名中的 this:
unsafe extern "C" fn tramp_on_depth_market_data(
    this: *mut QuoteSpiHolder,        // ← C++ 的 this = holder 的地址
    market_data: *mut XtpMarketData,
    ...
) {
    // holder! 宏展开:
    let holder: &QuoteSpiHolder = match unsafe {
        (this as *const QuoteSpiHolder).as_ref()
    } {
        Some(h) => h,     // this 非 null → 恢复引用
        None => return,     // this 为 null → 跳过(防御)
    };

    // 通过 holder 访问用户 trait object:
    holder.callback.on_depth_market_data(...);
}

关键安全前提

  • this 指向的 QuoteSpiHolderself.spi 中保持存活
  • Release() 先于 self.spi drop(字段顺序保证)
  • C++ 在调用 trampoline 期间不会修改或释放 this

转换 ⑫:C++ 数组指针 → Rust 切片(入站数组)
来源:  C++ 传入的 *mut i64 + count
步骤:  from_raw_parts(ptr, count) → &[i64]
方向:  C++ → Rust
unsafe fn c_slice<'a>(ptr: *const i64, count: c_int) -> &'a [i64] {
    if ptr.is_null() || count <= 0 {
        &[]                                    // null → 空切片
    } else {
        unsafe { std::slice::from_raw_parts(ptr, count as usize) }
        //                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        //                     从裸指针 + 长度构造 &[i64]
        //                     count 由 C++ 保证 ≤ 实际数组长度
    }
}

// 使用:
let bid_qty: &[i64] = unsafe { c_slice(bid1_qty, bid1_count) };
holder.callback.on_depth_market_data(md, bid_qty, bid1_count, ...);
安全前提 说明
ptr 有效 DLL 在回调期间保证指针有效
count ≤ 实际长度 信任 C++ 传入的长度值
生存期 切片在回调返回前有效,不应 escape 到用户代码之外

转换 ⑬:Rust &T → C++ const T*(出站只读参数)
来源:  Rust 引用 &XtpQuoteRebuildReq
步骤:  as *const _ → as *mut _
方向:  Rust → C++
pub fn request_rebuild_quote(&self, param: &XtpQuoteRebuildReq) -> Result<(), XtpError> {
    let r = unsafe {
        (self.vt().request_rebuild)(
            self.obj,
            param as *const _ as *mut _     // &T → *const T → *mut T
        )
    };
}
步骤 说明
param as *const _ &XtpQuoteRebuildReq*const XtpQuoteRebuildReq
as *mut _ *const XtpQuoteRebuildReq*mut XtpQuoteRebuildReq

注意as *mut _ 去掉了 const——因为 C++ API 声明为 *mut。实际 C++ 侧不应修改该数据,但类型系统不保证。


转换 ⑭:Rust &[i32] → C++ *mut i32(出站数组)
来源:  Rust &[i32] (CPU 亲和性列表)
步骤:  .to_vec() → .as_mut_ptr()
方向:  Rust → C++
pub fn set_udp_thread_affinity(&self, cpu_ids: &[i32]) -> Result<(), XtpError> {
    let mut cpus: Vec<i32> = cpu_ids.to_vec();     // ① 拷贝到 Vec
    let ok = unsafe {
        (self.vt().set_udp_thread_affinity)(
            self.obj,
            cpus.as_mut_ptr(),                    // ② Vec<i32> → *mut i32
            cpus.len() as i32                     // ③ usize → i32
        )
    };
    // cpus 在此 drop ✅
}
差异点 sub_cstrs (转换⑦) set_udp_thread_affinity (转换⑭)
源类型 &[&str] &[i32]
中间类型 Vec<CString> + Vec<*mut c_char> Vec<i32>
指针获取 t.as_ptr() as *mut c_char cpus.as_mut_ptr()
*const*mut 需要 as *mut c_char .as_mut_ptr() 直接返回 *mut i32

转换 ⑮:Arc — 隐式生命周期指针
类型:  Arc<Library>
作用:  不是传统的指针转换,但承担"生命周期指针"的角色
存储:  RawQuoteApi._library: Arc<Library>
pub struct RawQuoteApi {
    _library: Arc<Library>,            // ← DLL 句柄的 RAII 包装
    vtable: *const QuoteApiVTable,     // ← 指向 DLL 内部
    obj: *mut std::ffi::c_void,        // ← 指向 DLL 内部
}
// _library 在 vtable/obj 之前声明 → 在它们之后 drop
// → vtable/obj 在 _library 存活期间始终有效

17.3 转换方向汇总

                      Rust → C++ (出站)
                      ═══════════════
 ⑤  self.obj               ──→  this 指针(每次虚函数调用)
 ⑥  CString::as_ptr()      ──→  *const c_char(字符串参数)
 ⑦  Vec<*mut c_char>       ──→  char**(ticker 数组)
 ⑩  &mut Holder as *mut    ──→  QuoteSpi*(SPI 注册)
 ⑬  &T as *const _         ──→  const T*(结构体参数)
 ⑭  Vec::as_mut_ptr()      ──→  *mut i32(整数数组)


                      C++ → Rust (入站)
                      ═══════════════
 ①  DLL 符号查找           ──→  Symbol<T>(函数指针)
 ②  CreateQuoteApi()       ──→  *mut c_void(对象指针)
 ③  对象[0] 解引用          ──→  *const VTable(vtable 指针)
 ④  &*raw vtable          ──→  &VTable(安全引用)
 ⑧  CStr::from_ptr()       ──→  String(版本号等)
 ⑨  结构体.as_ref()        ──→  &T(回调中的行情数据)
 ⑪  *mut Holder.as_ref()   ──→  &Holder(回调 this 恢复)
 ⑫  from_raw_parts()       ──→  &[i64](数组切片)

17.4 指针转换安全性分级

┌──────┬─────────────────────────────────────┬──────────┐
│ 等级  │ 转换                                 │ 风险      │
├──────┼─────────────────────────────────────┼──────────┤
│ 🟢   │ ⑥ CString::as_ptr() — 自动管理       │ 极低     │
│ 🟢   │ ⑭ Vec::as_mut_ptr() — 自动管理       │ 极低     │
│ 🟢   │ ⑨ ptr.as_ref() + null 守卫           │ 低       │
│ 🟢   │ ⑧ CStr::from_ptr() + 立即 .to_owned()│ 低       │
│ 🟡   │ ⑤ self.obj 作为 this — 信任 DLL       │ 中       │
│ 🟡   │ ⑦ sub_cstrs — 双 Vec 生命周期依赖     │ 中       │
│ 🟡   │ ⑪ 回调 this → Holder — 信任 C++       │ 中       │
│ 🟡   │ ⑫ from_raw_parts — 信任 count         │ 中       │
│ 🔴   │ ③ 对象[0] 读 vtable — vtable 必须有效  │ 高       │
│ 🔴   │ ⑩ Box → 裸指针给 C++ — 跨语言所有权    │ 高       │
│ 🔴   │ ⑬ as *const _ as *mut _ — 去除 const  │ 高       │
└──────┴─────────────────────────────────────┴──────────┘

17.5 指针转换检查清单

□ ③ vtable 提取
  → obj 非 null 已校验?
  → vtable 非 null 已校验?
  → _library 声明在 vtable/obj 之前?

□ ⑤ this 指针
  → self.obj 始终是 CreateQuoteApi 返回的原始值?
  → Release() 调用在 _library drop 之前?

□ ⑥/⑦ 字符串转换
  → CString 生命周期覆盖 FFI 调用?
  → 数组场景中 cs 比 ptrs 活得久?
  → as *mut c_char 的 cast 是否需要检查?

□ ⑧ C++ 返回值字符串
  → 立即 .to_string_lossy().into_owned() 而非持有 &str?
  → null 指针有处理?

□ ⑨/⑪ 结构体指针入站
  → .as_ref() 的 null 路径有处理?
  → 回调期间引用有效,不会 escape?

□ ⑩ SPI 注册
  → Box 所有权保留在 self.spi 中?
  → 传递给 C++ 的是 &mut *holder,不是 &holder?
  → spi 字段在 raw 之后声明(raw 先 drop)?

□ ⑫ 数组切片
  → count 来自 C++,是否 ≤ 实际长度?(信任但验证)
  → null 指针 + count>0 的组合有处理?

□ ⑬ const cast
  → as *const _ as *mut _ — C++ 侧是否真的不会修改?

□ 全体
  → 所有涉及 unsafe 的指针操作是否有 # Safety 注释?

十八、错误处理设计方案

18.1 错误处理架构全景

┌──────────────────────────────────────────────────────────────┐
│  错误来源                   →  捕获层           →  最终形式    │
├──────────────────────────────────────────────────────────────┤
│  C++ 返回码 (int≠0)        →  RawQuoteApi::err() → XtpError │
│  C++ 返回码 (bool=false)    →  RawQuoteApi 方法   → XtpError │
│  C++ XTPRI* (回调)          →  rsp_ref! 宏        → &XtpRspInfo│
│  C++ null 指针              →  data_ref!/holder!   → 跳过回调  │
│  DLL 加载失败               →  Library::new()      → XtpError │
│  符号查找失败               →  library.get()       → XtpError │
│  &str 含 \0                →  CString::new()       → XtpError │
│  client_id 不合法           →  validate_client_id() → XtpError │
│  未登录调用                →  (待实现守卫)         → XtpError │
│  Rust panic (回调中)        →  guard() catch_unwind → 静默吞掉  │
└──────────────────────────────────────────────────────────────┘

18.2 XtpError 类型设计

#[derive(Debug, thiserror::Error)]
pub enum XtpError {
    // ─── DLL/环境错误 (初始化阶段) ───
    #[error("DLL not found: {0}")]
    DllNotFound(String),

    #[error("Failed to load DLL '{dll}': {source}")]
    DllLoadError { dll: String, source: libloading::Error },

    #[error("Symbol '{symbol}' not found in DLL '{dll}'")]
    SymbolNotFound { dll: String, symbol: String },

    // ─── API 调用错误 (运行时) ───
    #[error("API call failed: {0}")]
    ApiError(String),

    #[error("XTP error: {0}")]
    XtpRspError(XtpRspInfo),

    // ─── 状态错误 (逻辑约束) ───
    #[error("Not logged in")]
    NotLoggedIn,

    #[error("Already logged in")]
    AlreadyLoggedIn,

    // ─── 参数错误 (调用者问题) ───
    #[error("Invalid argument: {0}")]
    InvalidArgument(String),

    // ─── 编码错误 ───
    #[error("UTF-8 conversion error: {0}")]
    Utf8Error(#[from] std::str::Utf8Error),

    #[error("Nul byte error: {0}")]
    NulError(#[from] std::ffi::NulError),
}

设计决策

决策 原因
使用 thiserror 而非手工 Display 减少模板代码,#[error("...")] 一行搞定
#[from] 自动转换 Utf8Error/NulError ? 操作符直接传播
DllLoadError 包含 source: libloading::Error 保留原始错误链,不丢失信息
ApiError(String) 而非细分类 过多分支增加维护成本,String 描述足够定位
NotLoggedIn / AlreadyLoggedIn 独立 variant 调用者可以 match 做不同处理
InvalidArgument(String) 包含描述 告诉调用者哪个参数有问题

18.2.1 thiserror 库选型与方案对比

为什么选 thiserror

Rust FFI 项目的错误处理有四种主流方案。本项目选择 thiserror,权衡如下:

方案 核心机制 代码量 适用场景
thiserror(本项目) #[derive(Error)] 宏,编译期生成 Display + Error impl 极少 库 crate、需要精确匹配错误类型
anyhow anyhow::Error 动态错误包装,bail!() / context() 极少 应用层、不需要区分错误类型
eyre anyhow 的 fork,增加彩色输出 + 建议 极少 应用层、CLI 工具
snafu 结构化错误,Snafu derive,每个 variant 对应一个 struct 中等 需要极度精细的错误上下文
手工 impl Error 纯手写 Display + Error + source() 很多 零依赖要求
为什么不用 anyhow
// anyhow 风格(本项目没有选):
fn login(...) -> anyhow::Result<()> {
    let ret = unsafe { (self.vt().login)(...) };
    if ret != 0 {
        bail!("Login failed with code {}", ret);
    }
    Ok(())
}

// 问题:调用者无法 match 错误类型:
match api.login(...) {
    Err(e) => {
        // e 是 anyhow::Error,无法知道是网络错误还是认证错误
        // 只能 e.to_string() 或 e.downcast_ref::<XtpError>()
    }
}

anyhow 适合"出错了就报日志然后退出"的应用层。XTP 封装是一个库 crate,调用者需要精确区分错误类型来决策——"行情服务器未开启"和"密码错误"的处理策略完全不同。anyhow 的类型擦除特性恰恰抹掉了这种区分能力。

为什么不用 snafu
// snafu 风格:
#[derive(Debug, Snafu)]
enum XtpError {
    #[snafu(display("Failed to load DLL '{dll}': {source}"))]
    DllLoad { dll: String, source: libloading::Error },

    #[snafu(display("API call failed: {msg}"))]
    Api { msg: String },
}

// 问题:每个 variant 背后是一个 struct,构造错误变成:
DllLoadSnafu { dll, source }.build()
// 而非简单的 XtpError::DllLoadError { dll, source }

snafu 提供了最精细的错误上下文控制,但构造错误的语法噪声较大。对于 XTP 这种 variant 数少(9 个)、逻辑简单的错误类型,snafu 过度设计。

为什么不用手工 impl
// 手工实现一个 variant:
impl fmt::Display for XtpError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            XtpError::DllNotFound(msg) => write!(f, "DLL not found: {msg}"),
            XtpError::DllLoadError { dll, source } => {
                write!(f, "Failed to load DLL '{dll}': {source}")
            }
            // ... 9 个 variant 要手写 9 行
        }
    }
}

impl std::error::Error for XtpError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            XtpError::DllLoadError { source, .. } => Some(source),
            XtpError::Utf8Error(e) => Some(e),
            XtpError::NulError(e) => Some(e),
            _ => None,
        }
    }
}

手工实现 ≈50 行模板代码。thiserror#[derive(Error)] 把同样的逻辑压缩到 0 行——宏在编译期自动生成。对于库 crate,减少维护负担的价值远大于一个轻量编译依赖的成本。

thiserror 在本项目中的三个关键用法
// ① 固定字符串错误(无参数 variant)
#[error("Not logged in")]
NotLoggedIn,

// ② 单参数格式化
#[error("Invalid argument: {0}")]
InvalidArgument(String),

// ③ 具名字段 + 错误链(source)
#[error("Failed to load DLL '{dll}': {source}")]
DllLoadError {
    dll: String,
    source: libloading::Error,   // ← 自动成为 .source()
},

// ④ 自动转换(#[from])— 无需手动 .map_err
#[error("UTF-8 conversion error: {0}")]
Utf8Error(#[from] std::str::Utf8Error),
// 使用: let s = str::from_utf8(&buf)?;  // ? 自动转为 XtpError::Utf8Error

#[error("Nul byte error: {0}")]
NulError(#[from] std::ffi::NulError),
// 使用: let c = CString::new(s)?;  // NulError 自动转为 XtpError::NulError
最终选型结论
场景: FFI 库 crate,9 个错误 variant,需要精确匹配
选择: thiserror

权衡:
  ✅ thiserror  → 编译期宏,零运行时开销,自动 source(),#[from] 自动转换
  ❌ anyhow     → 类型擦除,不适合库 crate(调用者无法匹配)
  ❌ snafu      → 语法噪声大,对 9 个 variant 过度设计
  ❌ 手工 impl  → 维护负担,50 行模板代码不值得

18.2.2 thiserror 在 FFI 封装中的自定义错误类型设计

错误类型的四层分类

XtpError 的 9 个 variant 并非随意命名,而是按错误来源和调用者应对策略分为四层:

第一层: 环境错误 (初始化阶段,调用者应终止)
  DllNotFound       — DLL 不在搜索路径
  DllLoadError      — DLL 损坏/依赖缺失
  SymbolNotFound    — DLL 版本不匹配

第二层: 运行时错误 (API 调用阶段,调用者应重试或降级)
  ApiError          — API 调用返回非零,但无 XTP 错误码
  XtpRspError       — API 调用返回非零,带有 XTP 结构化错误码

第三层: 状态约束错误 (逻辑前置条件,调用者应调整调用顺序)
  NotLoggedIn       — 未登录就调用了需要登录的 API
  AlreadyLoggedIn   — 重复登录

第四层: 参数错误 (调用者传入数据问题,调用者应修复入参)
  InvalidArgument   — 入参格式/范围不合法
  Utf8Error         — 字符串编码错误
  NulError           — 字符串含内部 \0

设计原则:每层的错误处理策略互不相同。一个不区分"DLL 没找到"和"密码错误"的 anyhow::Error 会让调用者无法做出正确决策——前者应该 panic! 终止程序,后者应该提示用户重试。

#[from] 的自动转换链
#[error("Nul byte error: {0}")]
NulError(#[from] std::ffi::NulError),

#[from] 让 Rust 的 ? 操作符自动传播错误,无需手动 .map_err

// ❌ 没有 #[from] 时:
let c = CString::new(filename).map_err(|e| XtpError::NulError(e))?;

// ✅ 有 #[from] 时:
let c = CString::new(filename)?;
//       ^^^^^^^^^^^^^^^^^^^^
//       NulError 自动转为 XtpError::NulError,零行额外代码

整个 FFI 调用链的错误传播

CString::new(s)          →?→  NulError 自动转为 XtpError::NulError
CStr::from_ptr(p).to_str() →?→  Utf8Error 自动转为 XtpError::Utf8Error
self.raw.login(...)      →?→  int 返回码 → self.err() → XtpRspError
self.raw.login(...)      →?→  bool 返回码 → self.err() → ApiError
validate_client_id(id)   →?→  参数检查   → InvalidArgument

调用的每一层都可以用 ? 无缝衔接,错误从最底层(CString::new)到最顶层(调用者 main自动传播、自动转换、保留原始错误链

为什么用 XtpRspError(XtpRspInfo) 而非内联字段
// 方案 A(本项目选择): 用 struct 包装
#[error("XTP error: {0}")]
XtpRspError(XtpRspInfo),
//          ^^^^^^^^^  XtpRspInfo 本身是 #[repr(C)] struct,带 error_id + error_msg

// 方案 B(未选择): 展开为两个字段
XtpRspError { error_id: i32, error_msg: String },

选择方案 A 的原因

  1. XtpRspInfoCopy(128 bytes #[repr(C)]),可以直接从 DLL 的 *mut XtpRspInfo 拷贝出来,无需分配 String
  2. 保留原始语义XtpRspInfo 既是 C++ 结构体也是 Rust 结构体,包含了 is_ok()is_none_record()is_real_error() 等工具方法
  3. 单一来源:error_id 和 error_msg 永远一起出现,拆开会增加调用者 match 的复杂度
// 调用者使用:
match api.login(...) {
    Err(XtpError::XtpRspError(rsp)) => {
        if rsp.error_id == XTP_ERR_QUOTE_LOGIN_FAILED {
            println!("行情服务器未开启");
        }
        if rsp.is_none_record() {
            println!("查询无记录(正常)");
        }
    }
    Err(XtpError::ApiError(msg)) => {
        eprintln!("API 调用失败: {msg}");
    }
    _ => {}
}
source() 链的设计

thiserror 自动为标记了 source 的字段生成 Error::source() 实现:

#[error("Failed to load DLL '{dll}': {source}")]
DllLoadError {
    dll: String,
    source: libloading::Error,  // ← thiserror 自动识别为 source
},

#[error("UTF-8 conversion error: {0}")]
Utf8Error(#[from] std::str::Utf8Error),  // ← #[from] 自动成为 source

#[error("Nul byte error: {0}")]
NulError(#[from] std::ffi::NulError),

错误链示例:

调用者调用 .source() 可以拿到原始错误:
  XtpError::DllLoadError { source: libloading::Error }
      .source() → Some(&libloading::Error)

  XtpError::Utf8Error(utf8_err)
      .source() → Some(&std::str::Utf8Error)

  XtpError::XtpRspError(rsp_info)
      .source() → None  (XtpRspInfo 不是 Error trait)
没有 source 链的 variant 何时该加
// ✅ 有 source — DLL/OS 级别的错误
DllLoadError { dll, source: libloading::Error }

// ❌ 没有 source — XTP 业务错误
ApiError(String)          // String 不是 Error trait
XtpRspError(XtpRspInfo)  // C 结构体,不是 Error trait
InvalidArgument(String)   // 参数错误,原始原因就是调用者自己
NotLoggedIn               // 状态约束,没有底层错误

原则:只有当你包装了一个实现了 std::error::Error 的类型时,才加 sourceString#[repr(C)] 结构体不加——它们不携带错误链语义。

调用者视角的完整错误处理模式
fn connect_and_subscribe() -> Result<(), Box<dyn std::error::Error>> {
    let mut api = QuoteApi::new(1, "./data", LogLevel::Debug, true)?;
    //                        ↑ DllNotFound / DllLoadError / SymbolNotFound → 终止

    api.set_config_file("./quote_config.ini")
        .map_err(|e| {
            log::error!("Config file missing: {e}");
            e
        })?;
    // ↑ InvalidArgument (路径含\0) / ApiError (DLL 返回失败) → 终止

    api.register_spi(Box::new(MySpi));

    loop {
        match api.login("127.0.0.1", 6001, "user", "pass", ProtocolType::Tcp, None) {
            Ok(()) => break,
            Err(XtpError::XtpRspError(ref rsp)) if rsp.error_id == XTP_ERR_QUOTE_LOGIN_FAILED => {
                println!("服务器未开启,10 秒后重试...");
                std::thread::sleep(Duration::from_secs(10));
                continue;  // 重试
            }
            Err(e) => return Err(e.into()),  // 其他错误 → 终止
        }
    }

    api.subscribe_market_data(&["600000"], ExchangeType::SH)?;
    // ↑ ApiError / XtpRspError / NotLoggedIn → 终止

    Ok(())
}

四个错误类别对应四种处理策略——这正是四个 layer 分层设计的目的。

18.3 错误产生层:C++ 返回码 → Rust Result

这是最核心的错误转换路径:

// quote_ffi.rs — 核心转换函数
fn err(&self, api: &str, ret: i32) -> XtpError {
    let ptr = unsafe { (self.vt().get_api_last_error)(self.obj) };
    //                 ^^^^^^^^^^^^^^^^^ C++ 函数: XTPRI* GetApiLastError()

    let rsp = unsafe { ptr.as_ref() }
        .copied()                                    // 拷贝值(XtpRspInfo 是 Copy)
        .unwrap_or(XtpRspInfo::OK);                  // null → 视为无错误

    if rsp.error_id != 0 {
        XtpError::XtpRspError(rsp)                   // 有 XTP 错误码 → 结构化错误
    } else {
        XtpError::ApiError(format!("{} returned {}", api, ret))  // 无 XTP 错误码 → 通用描述
    }
}
调用模式分类
// 模式 A: 返回 int,0 = 成功
pub fn login(&self, ...) -> Result<(), XtpError> {
    let r = unsafe { (self.vt().login)(self.obj, ...) };
    if r == 0 { Ok(()) } else { Err(self.err("Login", r)) }
}

// 模式 B: 返回 bool,true = 成功
pub fn set_config_file(&self, filename: &str) -> Result<(), XtpError> {
    let c = CString::new(filename).map_err(|e| XtpError::InvalidArgument(e.to_string()))?;
    if unsafe { (self.vt().set_config_file)(self.obj, c.as_ptr()) } {
        Ok(())
    } else {
        Err(self.err("SetConfigFile", -1))   // bool 无返回码,用 -1 占位
    }
}

// 模式 C: 通过泛型回调统一处理(call_sub_unsub)
fn call_sub_unsub(&self, vfn: ..., tickers: &[&str], ex: ExchangeType, name: &str)
    -> Result<(), XtpError>
{
    let (_cs, mut ptrs) = self.sub_cstrs(tickers)?;
    let r = unsafe { vfn(self.obj, ptrs.as_mut_ptr(), ptrs.len() as c_int, ex as u32) };
    if r == 0 { Ok(()) } else { Err(self.err(name, r)) }
}

// 模式 D: 无参调用(logout, query_*_info 等)
fn call_sub_unsub_noarg(&self, vfn: ..., name: &str) -> Result<(), XtpError> {
    let r = unsafe { vfn(self.obj) };
    if r == 0 { Ok(()) } else { Err(self.err(name, r)) }
}

18.4 错误传播链(从 DLL 到调用者)

C++ DLL 内部                           Rust FFI 层                Rust 调用者
───────────                           ──────────                ──────────

Login() 返回 -1                  →    r == -1
GetApiLastError() 返回 XTPRI*   →    error_id = 10200000
                                  →    XtpError::XtpRspError(
                                          XtpRspInfo {
                                              error_id: 10200000,
                                              error_msg: "行情服务器未开启..."
                                          }
                                       )
                                                               →  match err {
                                                                      XtpRspError(rsp) => {
                                                                          if rsp.error_id == XTP_ERR_QUOTE_LOGIN_FAILED {
                                                                              // 重试登录
                                                                          }
                                                                      }
                                                                      _ => ...
                                                                  }

18.5 特殊错误语义:XTPRI* 可能为 null

C++ 官方文档明确指出 XTPRI* 回调参数可能为 null:

// rsp_ref! 宏 — 处理回调中可能为 null 的 XTPRI*
macro_rules! rsp_ref {
    ($ptr:expr) => {
        unsafe { ($ptr as *const XtpRspInfo).as_ref() }
            .unwrap_or(&XtpRspInfo::OK)  // null → 视为 "无错误"
    };
}

// 使用:
unsafe extern "C" fn tramp_on_error(this: *mut ThisFn, error_info: *mut XtpRspInfo) {
    guard(|| holder!(this).callback.on_error(rsp_ref!(error_info)));
    //                                       ^^^^^^^^^^^^^^^^^^^^^^^^
    //                                       永远不为 null,最差是 OK
}

err() 的对比

场景 函数 null 处理 语义
同步调用后取错误 err() .unwrap_or(XtpRspInfo::OK) “没有 XTP 错误信息 = 不是 XTP 错误”
回调中接收 XTPRI* rsp_ref! .unwrap_or(&XtpRspInfo::OK) “null = 本次回调没有关联错误”

18.6 “no record” 不是错误

XTP 的 11000350 (XTP_ERR_NONE_RECORD) 在官方 FAQ 中明确标注为不是错误

impl XtpRspInfo {
    pub fn is_real_error(&self) -> bool {
        self.error_id != 0 && !self.is_none_record()
    }
}
// 调用者可以这样处理:
match api.query_all_tickers(ExchangeType::SH) {
    Err(XtpError::XtpRspError(rsp)) if rsp.is_none_record() => {
        // 空的持仓/订单列表 — 正常,不报警
        println!("No records found");
    }
    Err(e) => {
        // 真正的错误
        log::error!("Query failed: {e}");
    }
    Ok(()) => {}
}

18.7 回调中的错误处理:4 层防护

DLL 回调线程
│
├─ ① null 守卫: holder! / data_ref!
│    → this 指针为 null → 跳过整个回调
│    → 行情数据指针为 null → 跳过整个回调
│
├─ ② null 容错: rsp_ref!
│    → XTPRI* 为 null → 替换为 XtpRspInfo::OK (全零)
│
├─ ③ panic 隔离: guard()
│    → catch_unwind(AssertUnwindSafe(f))
│    → 用户代码 panic 不会 unwind 到 C++ 栈帧 (UB)
│
└─ ④ 切片安全: c_slice()
     → null 指针或 count≤0 → 返回空切片 &[]
unsafe extern "C" fn tramp_on_depth_market_data(
    this: *mut ThisFn,
    market_data: *mut XtpMarketData,
    bid1_qty: *mut i64, bid1_count: c_int, max_bid1_count: c_int,
    ask1_qty: *mut i64, ask1_count: c_int, max_ask1_count: c_int,
) {
    guard(|| {                               // ③ panic 隔离
        let h = holder!(this);               // ① null → return
        let md = data_ref!(market_data);     // ① null → return
        let bq = unsafe { c_slice(bid1_qty, bid1_count) };  // ④ 安全切片
        let aq = unsafe { c_slice(ask1_qty, ask1_count) };
        h.callback.on_depth_market_data(md, bq, bid1_count, max_bid1_count, aq, ask1_count, max_ask1_count);
        // ↑ 用户代码如果 panic → guard 捕获 → 静默吞掉
    });
}

为什么 panic 静默吞掉而非传播

选项 后果
让 panic unwind 到 C++ UB — Rust panic 穿越 C++ 栈帧 = 未定义行为
catch_unwind + 重新 panic 无意义 — 回调在 DLL 的线程中,没有 Rust 调用者接住
catch_unwind + 静默吞掉 安全 — 最多丢一个回调数据,不会整进程 crash
catch_unwind + 记录日志 最优 — 可加 eprintln!log::error! 记录 panic 信息

18.8 输入验证

client_id 验证
pub fn validate_client_id(client_id: u8) -> Result<(), XtpError> {
    if client_id == 0 || client_id > XTP_CLIENT_ID_MAX {
        Err(XtpError::InvalidArgument(format!(
            "client_id {} 不合法:官方建议取值 1~99(0 及 100~255 均为 XTP 预留)",
            client_id
        )))
    } else {
        Ok(())
    }
}
CPU 亲和性数组验证
pub fn set_udp_thread_affinity(&self, cpu_ids: &[i32]) -> Result<(), XtpError> {
    if cpu_ids.is_empty() || cpu_ids.len() > i32::MAX as usize {
        return Err(XtpError::InvalidArgument(
            "cpu_ids must be non-empty and fit in i32".to_string()
        ));
    }
    // ...
}
CString 构造验证(&str 含 \0 检测)
let c = CString::new(filename)
    .map_err(|e| XtpError::InvalidArgument(e.to_string()))?;
// 如果 filename 包含内部 \0 → Err → 传播

18.9 登录状态验证(待完善)

pub struct QuoteApi {
    raw: RawQuoteApi,
    spi: Option<Box<QuoteSpiHolder>>,
    logged_in: bool,         // 跟踪登录状态
}

impl QuoteApi {
    pub fn login(&mut self, ...) -> Result<(), XtpError> {
        self.raw.login(...)?;
        self.logged_in = true;   // 成功后设置状态
        Ok(())
    }

    pub fn logout(&mut self) -> Result<(), XtpError> {
        let r = self.raw.logout();
        if r.is_ok() { self.logged_in = false; }
        r
    }

    pub fn is_logged_in(&self) -> bool { self.logged_in }
}

待完善:目前 NotLoggedIn / AlreadyLoggedIn variant 已定义但未在 subscribe/query 等方法中作为前置检查。理想设计:

pub fn subscribe_market_data(&self, tickers: &[&str], ex: ExchangeType) -> Result<(), XtpError> {
    // 待添加:
    if !self.logged_in {
        return Err(XtpError::NotLoggedIn);
    }
    self.raw.subscribe_market_data(tickers, ex)
}

18.10 不同类型的错误处理策略

┌─────────────────────┬────────────────┬─────────────────────────┐
│ 错误类别             │ 调用者应如何处理  │ 示例                    │
├─────────────────────┼────────────────┼─────────────────────────┤
│ DllNotFound          │ 终止程序        │ DLL 不在搜索路径        │
│ DllLoadError         │ 终止程序        │ DLL 损坏、依赖缺失      │
│ SymbolNotFound       │ 终止程序        │ DLL 版本不匹配          │
│ ApiError             │ 日志 + 重试     │ subscribe 返回 -1       │
│ XtpRspError          │ 按 error_id 处理│ 11000350 = 无记录(正常)  │
│ NotLoggedIn          │ 先调 login()    │ 未登录就调 subscribe    │
│ AlreadyLoggedIn      │ 先调 logout()   │ 重复登录                │
│ InvalidArgument      │ 修复调用代码    │ ticker 含非法字符       │
│ Utf8Error/NulError   │ 终止或降级      │ 数据损坏                │
│ panic(回调中)         │ 记录日志 + 继续 │ 用户代码 panic          │
└─────────────────────┴────────────────┴─────────────────────────┘

18.11 设计决策和权衡

决策 做了什么 权衡
thiserror vs 手工 impl thiserror 增加编译依赖,但大幅减少模板代码
ApiError(String) vs 细分 variant String 灵活性高但调用者无法 match 具体 API 错误
err() 统一包装 vs 每个方法单独处理 统一 err() 函数 代码复用但每个调用点都要传 API 名称字符串
回调 panic 吞掉 vs 传播 静默吞掉 丢一个回调 vs 整进程 UB,选安全
logged_in 跟踪 vs 每次查 C++ Rust 侧跟踪 有不同步风险(C++ 侧可能被动断连)
XtpRspInfo::OK vs Option<XtpRspInfo> 用 OK 哨兵值 简化调用方代码(不需要解包 Option)
错误码常量 vs 内联数字 定义 const 可读性好,但需要手动同步 C++ 头文件

18.12 错误处理检查清单

□ 错误类型
  → 是否覆盖了所有可能的失败路径?
    DLL 加载、符号查找、API 调用、参数校验、状态约束
  → 是否用 thiserror 或手工实现了 Display + Error?
  → 错误信息是否包含足够的上下文(哪个 API、什么参数)?

□ C++ 返回码处理
  → 每个 FFI 调用是否检查了返回值?
  → int 返回: r == 0 才是成功?
  → bool 返回: true/false 哪个是成功?
  → 失败时是否调用了 err() 获取 XTPRI 错误详情?

□ null 处理
  → 所有从 C++ 过来的指针是否检查了 null?
  → API 返回值: get_api_version null → String::new()
  → 回调数据: data_ref! null → skip
  → 错误信息: rsp_ref! null → XtpRspInfo::OK

□ panic 安全
  → 所有 extern "C" fn(回调入口)是否有 catch_unwind?
  → 是否用了 AssertUnwindSafe?(FFI 上下文中是合理的)
  → 是否有遗漏的 .unwrap() / .expect() 在 FFI 路径上?

□ 输入验证
  → client_id 在 1~99 范围内?
  → 路径参数不含内部 \0?
  → 数组参数非空且在合理大小内?

□ 状态验证
  → subscribe/query 前是否检查了登录状态?
  → 重复 login 是否被拦截?

□ 错误恢复
  → on_disconnected 回调后是否支持重连?
  → 是否区分 XTP_ERR_NONE_RECORD (正常) 和真正的错误?
  → 长时间运行时错误是否会累积导致资源泄漏?

十九、字符串长度精准传递机制

19.1 问题本质

XTP C++ 头文件定义了一系列缓冲区长度常量:

// xtpx_api_data_type.h
#define XTP_TICKER_LEN      16    // 合约代码 char ticker[16]
#define XTP_TICKER_NAME_LEN 64    // 合约名称 char ticker_name[64]
#define XTP_ERR_MSG_LEN     124   // 错误消息 char error_msg[124]
#define XTP_VERSION_LEN     16    // 版本号

Rust 侧使用完全相同的值定义了常量:

// common.rs
pub const XTP_TICKER_LEN: usize      = 16;
pub const XTP_TICKER_NAME_LEN: usize = 64;
pub const XTP_ERR_MSG_LEN: usize     = 124;

核心问题:如果 Rust 侧的 16 碰巧写错成 1532,会发生什么?如何保证它们永远一致?

19.2 两条字符串传输路径

项目中字符串从 Rust 到 C++ 有两条根本不同的路径:

路径 A: 函数参数(动态长度)
  &str → CString::new() → .as_ptr() → *const c_char
  用于: IP地址、用户名、密码、文件路径
  C++ 侧: 读到 \0 为止,不需要知道长度

路径 B: 结构体字段(固定长度)
  &str → to_cstring_bytes() → [u8; N] → repr(C) struct 字段
  用于: ticker[16]、error_msg[124]、ticker_name[64]
  C++ 侧: 读 sizeof(char[N]) 字节,期望以 \0 结尾

两条路径的"长度正确性"保障机制完全不同。


19.3 路径 A:CString 参数 — 长度由内容决定

// 示例: login 中的 IP 地址
let cip = CString::new("192.168.1.100")?;
unsafe { (self.vt().login)(self.obj, cip.as_ptr(), ...) };

CString 的内存布局

CString::new("192.168.1.100")
  → 堆上分配: [49, 57, 50, 46, 49, 54, 56, 46, 49, 46, 49, 48, 48, 0]
                '1' '9' '2' '.' '1' '6' '8' '.' '1' '.' '1' '0' '0' \0
                ←─── 13 有效字节 ──→ NUL
  → .as_ptr() 返回指向 '1' 的指针
  → C++ 侧: while (*p != '\0') ... → 读到第 14 个字节停止

长度精准性保证

保证项 机制
Rust 不会写越界 CString 在堆上分配精确需要的字节数 + 1
C++ 不会读越界 C 字符串以 \0 结尾,读到即停
内部 \0 被拦截 CString::new() 检查并返回 Err(NulError)
不需要两边常量一致 C++ 侧不查长度常量,全由 \0 驱动

结论:路径 A 不需要常量对齐——\0 本身就是长度信号


19.4 路径 B:固定缓冲区 — 长度由常量定义

这是需要精确对齐的场景。以 XtpMarketData.ticker 为例:

// C++ 头文件:
struct XtpMarketData {
    char ticker[16];  // sizeof = 16,XTP_TICKER_LEN = 16
    // ...
};
// Rust 侧:
pub struct XtpMarketData {
    pub ticker: [u8; XTP_TICKER_LEN],  // 如果 XTP_TICKER_LEN ≠ 16 → sizeof 错误
    // ...
}
如果常量不一致会发生什么?
场景 Rust 常量 C++ 实际 后果
Rust 偏小 ticker: [u8; 15] char ticker[16] struct sizeof 比 C++ 小 1 字节 → last_price 的 offset 错位 → 读到的价格是乱码
Rust 偏大 ticker: [u8; 32] char ticker[16] struct sizeof 比 C++ 大 16 字节 → 所有后续字段 offset 全错
一致 ticker: [u8; 16] char ticker[16] ✅ 正常

写入数据时的截断行为

pub fn to_cstring_bytes(s: &str, buf: &mut [u8]) {
    let bytes = s.as_bytes();
    let len = bytes.len().min(buf.len() - 1);   // ← 关键:至少留 1 字节给 \0
    buf[..len].copy_from_slice(&bytes[..len]);
    buf[len] = 0;                                // ← 最后的字节永远写 \0
}
输入 buf 大小 结果
"600000" [u8; 16] buf[0..6]="600000", buf[6]=0, buf[7..15] = 原值不动
"12345678901234567890" [u8; 16] buf[0..15]="123456789012345", buf[15]=0静默截断
"" [u8; 16] buf[0]=0
"hello" [u8; 2] buf[0]='h', buf[1]=0 — 极端截断

关键保证to_cstring_bytes 绝不会写越界——min(buf.len()-1, ...) 保证写入不超过 buffer。


19.5 常量的三重验证链

━━━ 第一道防线: C 生成黄金数据 ━━━

gen_layout.c (在 XTP SDK 目录编译)
  │
  ├─ printf("const,XTP_TICKER_LEN,,size,%zu,,,\n", (size_t)XTP_TICKER_LEN);
  │   输出: const,XTP_TICKER_LEN,,size,16,,,
  │
  └─ 输出 layout.csv


━━━ 第二道防线: 编译期静态断言 ━━━

// layout_verify.rs
const_assert_size!(XtpMarketData, 88);    // sizeof 对不上 → 编译失败
// 间接验证了 XTP_TICKER_LEN = 16 的正确性


━━━ 第三道防线: 单元测试 ━━━

#[test]
fn string_constants() {
    assert_eq!(XTP_TICKER_LEN,      16);   // ← 直接验证
    assert_eq!(XTP_TICKER_NAME_LEN, 64);
    assert_eq!(XTP_ERR_MSG_LEN,     124);
}

#[test]
fn csv_compare_sizes() {
    // 从 layout.csv 读取 C 侧的值,逐行对比 Rust 侧
    // const,XTP_TICKER_LEN,,size,16,,,
    // → Rust XTP_TICKER_LEN == 16? ✓
}

验证链完整性

XTP C++ 头文件                    Rust 侧
─────────────────                ────────
#define XTP_TICKER_LEN 16   →    pub const XTP_TICKER_LEN: usize = 16;
        │                                │
        │  gen_layout.c 输出              │  #[test] 断言
        │  "const,XTP_TICKER_LEN,,        │  assert_eq!(XTP_TICKER_LEN, 16)
        │   size,16,,,"                  │
        │                                │
        └──── layout.csv ──── csv_compare ─┘
              C 侧黄金数据         Rust 自动对比测试

19.6 每种字符串参数的长度保障汇总

┌──────────────────────┬────────────┬───────────────────┬──────────────────────┐
│ 参数                  │ 传输路径    │ 长度约束来源        │ 如何保证精准           │
├──────────────────────┼────────────┼───────────────────┼──────────────────────┤
│ IP 地址 (login)       │ CString    │ 无固定上限          │ \0 终止,自动         │
│ 用户名 (login)        │ CString    │ 无固定上限          │ \0 终止,自动         │
│ 密码 (login)          │ CString    │ 无固定上限          │ \0 终止,自动         │
│ 文件路径 (config)     │ CString    │ 无固定上限          │ \0 终止,自动         │
│ 存储路径 (create)     │ CString    │ 无固定上限          │ \0 终止,自动         │
│ ticker (函数参数)     │ CString[]  │ 无固定上限          │ \0 终止,自动         │
│ ticker (结构体字段)   │ [u8;16]    │ XTP_TICKER_LEN=16  │ 常量验证 + 截断守卫   │
│ ticker_name (结构体)  │ [u8;64]    │ XTP_TICKER_NAME_LEN│ 常量验证 + 截断守卫   │
│ error_msg (结构体)    │ [u8;124]   │ XTP_ERR_MSG_LEN    │ 常量验证 + 截断守卫   │
└──────────────────────┴────────────┴───────────────────┴──────────────────────┘

19.7 当用户传入超长字符串时

// 场景: 用户构造 XtpQueryOrderReq,传入超长 ticker

// trader_types.rs:
pub fn set_ticker(&mut self, s: &str) {
    to_cstring_bytes(s, &mut self.ticker);  // ticker 是 [u8; 16]
}

// 用户调用:
req.set_ticker("12345678901234567890");  // 20 个字符!

// to_cstring_bytes 内部:
// len = min(20, 16-1) = 15
// buf[0..15] = "123456789012345"
// buf[15] = 0
// → 静默截断为 "123456789012345"

这是设计取舍:静默截断 vs 返回错误:

选择 优点 缺点
静默截断(当前方案) 简单,不会 panic 用户不知情,可能发错股票
返回 Result 用户必须处理 API 复杂化,setter 不能链式调用
panic 快速失败 不适合生产环境
debug_assert! 开发期发现,生产零开销 生产环境仍静默截断

推荐改进

pub fn set_ticker(&mut self, s: &str) -> Result<(), XtpError> {
    let bytes = s.as_bytes();
    if bytes.len() >= XTP_TICKER_LEN {
        return Err(XtpError::InvalidArgument(format!(
            "ticker '{}' exceeds max length of {} bytes", s, XTP_TICKER_LEN - 1
        )));
    }
    to_cstring_bytes(s, &mut self.ticker);
    Ok(())
}

19.8 从 C++ 读回字符串时的长度处理

// C++ 返回 char ticker[16] = "600000\0__________"
// Rust 侧是 [u8; 16]

pub fn from_cstring_bytes(buf: &[u8]) -> String {
    CStr::from_bytes_until_nul(buf)    // ① 扫描到第一个 \0
        .map(|c| c.to_string_lossy()   // ② UTF-8 验证 + 容错
                 .into_owned())        // ③ 拷贝到 String
        .unwrap_or_default()           // ④ 无 \0 → 空串
}
边界情况 处理
"600000\0..." 读到 "600000"
"600000" (无 \0) unwrap_or_default()""
[0x80, 0xFF, ...] (非法 UTF-8) to_string_lossy() 替换为 U+FFFD
全部为 \0 读空串 ""
C++ 填入正好 16 字节无 \0 \0""(可能不是期望行为)

19.9 确保常量精准的检查清单

□ 常量定义
  → gen_layout.c 中 DUMP_CONST(XTP_TICKER_LEN) 有对应行?
  → Rust 侧 common.rs 中的值与 C 头文件一致?
  → 所有使用到 XTP_*_LEN 的 struct 字段都引用了常量(非硬编码数字)?

□ 编译期验证
  → 每个有固定 char[N] 字段的 struct 有 const_assert_size!?
  → struct sizeof 匹配 C 侧的黄金值?

□ 测试期验证
  → string_constants() 测试通过?
  → csv_compare_sizes() 测试通过?
  → csv_compare_offsets() 覆盖了所有 char[N] 字段的 offset?

□ 运行时安全
  → to_cstring_bytes() 写入的每个 buf 都留了 1 字节给 \0?
  → from_cstring_bytes() 能处理无 \0 的 buffer?
  → 用户侧是否需要超长字符串的警告/错误?

□ 版本更新
  → XTP SDK 升级后,重新运行 gen_layout.c 生成新的 layout.csv?
  → 对比新旧 layout.csv 的 diff,确认常量变化?
  → 如果常量变了,Rust 侧同步更新 + 更新测试断言?

二十、C++ 时间日期格式在 Rust 侧的解析

20.1 XTP 的时间表示方式

XTP C++ API 不使用 struct tmtime_tstd::chrono 等标准时间类型,而是将时间编码为整数:

C++ 类型 编码格式 示例 含义
int64_t / long long YYYYMMDDHHMMSSsss 20240101103000123 2024年1月1日 10:30:00.123
int32_t YYYYMMDD 20240101 2024年1月1日(仅日期)
int64_t Unix 毫秒时间戳 1704066600123 极少使用(部分 trader 字段)

为什么 XTP 选择整数编码?

原因 说明
C 兼容性 int64_t 在不同编译器/平台上布局一致,没有 struct 填充问题
排序友好 整数比较 = 时间先后比较(单调递增),数据库索引高效
FFI 简单 i64 在 Rust 侧直接映射,不需要额外 #[repr(C)] 结构体
无时区歧义 数值本身不携带时区信息,由 API 文档约定为北京时间 (UTC+8)

20.2 项目中所有时间日期字段

Quote API (行情侧)
字段 Rust 类型 格式 所在结构体
data_time i64 YYYYMMDDHHMMSSsss XtpMarketData, XtpOrderBook, XtpTickByTick, XtpIndexPress, XtpHkcRealtimeLimit, Iopv
last_enquiry_time i64 YYYYMMDDHHMMSSsss XtpSpecificTicker
hang_out_date i32 YYYYMMDD XtpQuoteFullInfo
value_date i32 YYYYMMDD XtpQuoteFullInfo
maturity_date i32 YYYYMMDD XtpQuoteFullInfo
complex_event_start_time i64 YYYYMMDDHHMMSSsss XtpIndexPress
complex_event_end_time i64 YYYYMMDDHHMMSSsss XtpIndexPress
Trader API (交易侧)
字段 Rust 类型 格式 所在结构体
insert_time i64 Unix 毫秒或 YYYYMMDDHHMMSSsss XtpOrderInfo
update_time i64 同上 XtpOrderInfo
cancel_time i64 同上 XtpOrderInfo
trade_time i64 YYYYMMDDHHMMSSsss XtpTradeReport
begin_time i64 查询参数 XtpQueryOrderReq
end_time i64 查询参数 XtpQueryOrderReq
transfer_time i64 YYYYMMDDHHMMSSsss XtpFundTransferNotice

20.3 当前实现:仅做字符串化

// quote_types.rs — 目前的实现
impl XtpMarketData {
    pub fn data_time_str(&self) -> String {
        format!("{}", self.data_time)   // 20240101103000123 → "20240101103000123"
    }
}

问题:这只是把 i64 转成十进制字符串,没有做任何解析——用户拿到的是 "20240101103000123" 而非 NaiveDateTime

20.4 手动解析:整数除法拆解

XTP 的时间格式是固定宽度的十进制编码,可用整数运算拆解:

20240101103000123
├─ 2024  ──→ 年
├─ 01    ──→ 月
├─ 01    ──→ 日
├─ 10    ──→ 时
├─ 30    ──→ 分
├─ 00    ──→ 秒
└─ 123   ──→ 毫秒
/// 解析 XTP 的 YYYYMMDDHHMMSSsss 格式时间戳
pub fn parse_xtp_timestamp(ts: i64) -> Option<NaiveDateTime> {
    if ts <= 0 { return None; }

    let millis = ts % 1000;             // 123
    let rest   = ts / 1000;
    let second = rest % 100;            // 00
    let rest   = rest / 100;
    let minute = rest % 100;            // 30
    let rest   = rest / 100;
    let hour   = rest % 100;            // 10
    let rest   = rest / 100;
    let day    = rest % 100;            // 01
    let rest   = rest / 100;
    let month  = rest % 100;            // 01
    let year   = rest / 100;            // 2024

    NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32)?;
    let date = NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32)?;
    let time = NaiveTime::from_hms_milli_opt(
        hour as u32, minute as u32, second as u32, millis as u32
    )?;
    Some(NaiveDateTime::new(date, time))
}

/// 解析 XTP 的 YYYYMMDD 格式日期
pub fn parse_xtp_date(d: i32) -> Option<NaiveDate> {
    if d <= 0 { return None; }
    let year  = d / 10000;
    let month = (d / 100) % 100;
    let day   = d % 100;
    NaiveDate::from_ymd_opt(year, month as u32, day as u32)
}

20.5 改进:为结构体添加解析方法

impl XtpMarketData {
    /// 解析行情时间戳为 NaiveDateTime(北京时间)
    pub fn data_time_parsed(&self) -> Option<chrono::NaiveDateTime> {
        parse_xtp_timestamp(self.data_time)
    }

    /// 格式化为人类可读字符串
    pub fn data_time_display(&self) -> String {
        self.data_time_parsed()
            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string())
            .unwrap_or_else(|| format!("{} (raw)", self.data_time))
    }
}

impl XtpOrderInfo {
    pub fn insert_time_parsed(&self) -> Option<chrono::NaiveDateTime> {
        parse_xtp_timestamp(self.insert_time)
    }
}

20.6 边界情况和陷阱

陷阱 1:零值和负值
let ts: i64 = 0;  // XTP 用 0 表示 "无效/未设置" 而非 1970-01-01
let result = parse_xtp_timestamp(0);  // → None ✅

规则≤ 0 的时间戳应返回 None 而非尝试解析。

陷阱 2:非法月/日值
20241301103000123
    ^^ 月=13 → 非法
20240132103000123
      ^^ 日=32 → 非法
// chrono 的 from_ymd_opt 会优雅处理:
NaiveDate::from_ymd_opt(2024, 13, 1);   // → None ✅
NaiveDate::from_ymd_opt(2024, 1, 32);   // → None ✅
陷阱 3:毫秒部分前导零丢失
20240101103000005
              ^^ 5ms = 不是 50ms!

// 正确: 00005 → 5ms
// 错误: 把 00005 当成 50ms

整数除法天然正确:20240101103000005 % 1000 = 5

陷阱 4:闰秒 / 夏令时

XTP 时间用北京时间 (UTC+8),无夏令时。NaiveDateTime 不处理时区——这恰好匹配 XTP 的时间语义(本地时间,无时区)。

陷阱 5:不同字段使用不同格式
// ❌ 错误:对 i32 日期字段使用 parse_xtp_timestamp
let date = parse_xtp_timestamp(info.maturity_date as i64);  // 20240101 → 解析为 2024-01-01 00:00:00.001 ← 错了!

// ✅ 正确:使用日期专用函数
let date = parse_xtp_date(info.maturity_date);  // → NaiveDate(2024, 1, 1)
陷阱 6:时间精度是毫秒而非微秒/纳秒
XTP 精度: YYYYMMDDHHMMSSsss → 毫秒 (10^-3)
chrono:   NaiveTime::from_hms_milli() → 毫秒
std:      SystemTime → 纳秒精度,但实际精度取决于 OS

// 将 XTP 时间转为 std::time 需要额外处理:
// chrono NaiveDateTime → 假设为 UTC → DateTime<Utc> → SystemTime

20.7 时间字段在 Rust → C++ 方向的使用

查询接口需要传入时间范围(Rust → C++):

// trader_types.rs
pub struct XtpQueryOrderReq {
    pub ticker: [u8; XTP_TICKER_LEN],
    pub begin_time: i64,  // ← Rust 侧需要构造 XTP 格式
    pub end_time: i64,
}

构造辅助函数

/// 将 NaiveDateTime 编码为 XTP 的 YYYYMMDDHHMMSSsss 格式
pub fn to_xtp_timestamp(dt: NaiveDateTime) -> i64 {
    let year   = dt.date().year() as i64;
    let month  = dt.date().month() as i64;
    let day    = dt.date().day() as i64;
    let hour   = dt.time().hour() as i64;
    let minute = dt.time().minute() as i64;
    let second = dt.time().second() as i64;
    let milli  = dt.time().nanosecond() as i64 / 1_000_000;

    year * 100_000_000_000_00
        + month  * 100_000_000_000
        + day    * 1_000_000_000
        + hour   * 10_000_000
        + minute * 100_000
        + second * 1_000
        + milli
}

/// 将 NaiveDate 编码为 XTP 的 YYYYMMDD 格式
pub fn to_xtp_date(d: NaiveDate) -> i32 {
    let year  = d.year();
    let month = d.month() as i32;
    let day   = d.day() as i32;
    year * 10000 + month * 100 + day
}

// 使用:
let begin = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
    .and_hms_milli_opt(9, 30, 0, 0).unwrap();
let req = XtpQueryOrderReq {
    begin_time: to_xtp_timestamp(begin),
    end_time:   to_xtp_timestamp(end),
    ..
};

20.8 编译期验证:时间解析的正确性

#[test]
fn xtp_timestamp_roundtrip() {
    let original = 20240101103000123_i64;

    let dt = parse_xtp_timestamp(original).unwrap();
    assert_eq!(dt.year(),   2024);
    assert_eq!(dt.month(),  1);
    assert_eq!(dt.day(),    1);
    assert_eq!(dt.hour(),   10);
    assert_eq!(dt.minute(), 30);
    assert_eq!(dt.second(), 0);

    let encoded = to_xtp_timestamp(dt);
    assert_eq!(encoded, original, "roundtrip failed");
}

#[test]
fn xtp_edge_cases() {
    // 零值 → None
    assert!(parse_xtp_timestamp(0).is_none());

    // 午夜
    let midnight = parse_xtp_timestamp(20240101000000000).unwrap();
    assert_eq!(midnight.hour(), 0);
    assert_eq!(midnight.minute(), 0);

    // 非法月份 → None
    assert!(parse_xtp_timestamp(20241301103000123).is_none());

    // 非法日期 → None
    assert!(parse_xtp_timestamp(20240230103000123).is_none());  // 2月30日

    // 毫秒 = 5
    let dt = parse_xtp_timestamp(20240101103000005).unwrap();
    assert_eq!(dt.nanosecond(), 5_000_000);  // 5ms
}

#[test]
fn xtp_date_roundtrip() {
    let original: i32 = 20240101;
    let date = parse_xtp_date(original).unwrap();
    assert_eq!(date.year(), 2024);
    assert_eq!(date.month(), 1);
    assert_eq!(date.day(), 1);

    let encoded = to_xtp_date(date);
    assert_eq!(encoded, original);
}

20.9 chrono vs time crate 选择

维度 chrono time
成熟度 最广泛使用 较新,API 更现代
NaiveDateTime ✅ 无时区的日期时间 PrimitiveDateTime
from_ymd_opt ✅ 返回 Option Date::from_calendar_date
格式化 format("%Y-%m-%d") format_description!
no_std 支持 需 feature flag ✅ 原生支持
编译时间 较慢 较快
serde 集成 需 feature 需 feature

推荐 chrono,因为它是 Rust 生态中时间处理的事实标准,且 NaiveDateTime 的语义与 XTP 的"无时区时间戳"完美匹配。

20.10 时间字段完整使用示例

use chrono::NaiveDateTime;

// ===== 读取行情时间 =====
impl QuoteSpi for MySpi {
    fn on_depth_market_data(&self, data: &XtpMarketData, ..) {
        // 方式1: 原始整数
        let raw_ts: i64 = data.data_time;

        // 方式2: 解析为 NaiveDateTime
        if let Some(dt) = data.data_time_parsed() {
            println!("行情时间: {}", dt.format("%H:%M:%S%.3f"));
        }

        // 方式3: 直接显示
        println!("时间: {}", data.data_time_display());
        // 输出: "时间: 2024-01-01 10:30:00.123"
    }
}

// ===== 查询时构造时间 =====
fn query_today_orders(api: &QuoteApi) {
    let now = chrono::Local::now().naive_local();  // 北京时间
    let begin = now.date_naive()
        .and_hms_milli_opt(9, 30, 0, 0).unwrap();   // 今日 09:30
    let end = now;

    let req = XtpQueryOrderReq {
        begin_time: to_xtp_timestamp(begin),
        end_time:   to_xtp_timestamp(end),
        ..
    };
    api.query_all_tickers(ExchangeType::SH).unwrap();
}

20.11 时间处理检查清单

□ 读取侧 (C++ → Rust)
  → 所有 i64 时间字段是否有 parse_xtp_timestamp() 方法?
  → 所有 i32 日期字段是否有 parse_xtp_date() 方法?
  → 零值/负值是否返回 None 而非错误解析?
  → 非法月/日值是否安全处理(不 panic)?
  → 是否区分了 YYYYMMDDHHMMSSsss 和 Unix 毫秒格式?

□ 写入侧 (Rust → C++)
  → 构造查询请求时是否用 to_xtp_timestamp() 而非手工拼数字?
  → 拼接时各段是否保证了宽度(月日必须补零)?
  → 毫秒部分是否 ≤999?

□ 测试
  → roundtrip 测试通过?(parse → encode → assert_eq)
  → 边界测试覆盖?(0, 午夜, 月末, 年末, 闰年2月29日)
  → 非法值测试覆盖?(月=13, 日=32, 负数)

□ 性能
  → 高频行情回调中是否避免了不必要的解析?
    (如果不需要格式化显示,直接用 i64 做比较/排序最快)
  → 批量查询的日期构造是否只做一次?

二十一、极端值和边界条件处理

21.1 极端值风险全景

┌─────────────────────────────────────────────────────────────┐
│  风险类别           │  典型值          │  后果              │
├─────────────────────┼─────────────────┼────────────────────┤
│  整数溢出截断        │  usize→i32 cast  │  静默截断/负值    │
│  枚举越界            │  from_raw(u32::MAX)│  fallback 合理?   │
│  浮点特殊值          │  f64::NAN        │  Debug 输出乱码   │
│  零值哨兵语义        │  0 = "无效"       │  误判为正常值     │
│  负值参数            │  count=-1         │  from_raw_parts UB│
│  数组越界            │  ptrs[1000]       │  segfault          │
│  字符串超长          │  ticker 50 字符   │  静默截断          │
│  DLL 异常响应        │  返回 null 对象   │  Release() crash   │
└─────────────────────────────────────────────────────────────┘

21.2 整数类型转换的溢出风险

21.2.1 已经处理的
// ✅ quote_ffi.rs:183 — CPU 亲和性数组长度
if cpu_ids.is_empty() || cpu_ids.len() > i32::MAX as usize {
    return Err(XtpError::InvalidArgument("cpu_ids too large"));
}
let count: i32 = cpus.len() as i32;  // 已验证安全
// ✅ common.rs:159 — client_id 范围
if client_id == 0 || client_id > XTP_CLIENT_ID_MAX {  // XTP_CLIENT_ID_MAX = 99
    return Err(XtpError::InvalidArgument(...));
}
21.2.2 未处理的风险点
// ⚠️ quote_ffi.rs:198 — ticker 数组长度
let r = unsafe {
    vfn(self.obj, ptrs.as_mut_ptr(), ptrs.len() as c_int, ex as u32)
    //                              ^^^^^^^^^^^^^^^^^^
    // ptrs.len() 是 usize,在 x64 上是 u64
    // c_int 是 i32
    // 如果用户传了 >2^31 个 ticker(几乎不可能但类型系统不保证)
    // → 高位截断 → C++ 读到错误的数量
};

风险评估:实际场景中用户不会传 20 亿个 ticker(XTP 限制 100 只),风险极低但类型系统无法证明。

推荐改进

fn call_sub_unsub(&self, vfn: ..., tickers: &[&str], ex: ExchangeType, name: &str) -> Result<(), XtpError> {
    let count: c_int = tickers.len().try_into()
        .map_err(|_| XtpError::InvalidArgument("too many tickers".into()))?;
    // ... 使用 count 而非 ptrs.len() as c_int
}
21.2.3 安全无需处理的转换
// ✅ log_level as i32 — LogLevel 是 #[repr(i32)],值域 0~5
let obj = unsafe { create_fn(client_id, save_path, log_level as i32, ...) };

// ✅ exchange_id as u32 — ExchangeType 是 #[repr(u32)],值域 1~5
let r = unsafe { vfn(self.obj, ..., ex as u32) };

// ✅ port as c_int — 在 Rust 侧 port 是 i32,c_int 在 Windows x64 也是 i32
let r = unsafe { (self.vt().login)(self.obj, ..., port as c_int, ...) };

21.3 枚举越界值

impl ExchangeType {
    pub fn from_raw(v: u32) -> Self {
        match v {
            1 => ExchangeType::SH,
            2 => ExchangeType::SZ,
            3 => ExchangeType::NQ,
            4 => ExchangeType::HK,
            _ => ExchangeType::Unknown,         // 0, 5, 6, ..., u32::MAX → Unknown
        }
    }
}
C++ 传入值 Rust 结果 是否合理
1 ExchangeType::SH ✅ 正确
5 ExchangeType::Unknown ✅ XTP 定义 UNKNOWN=5
0 ExchangeType::Unknown ✅ 未初始化的安全兜底
255 ExchangeType::Unknown ✅ 垃圾数据不会 panic
u32::MAX ExchangeType::Unknown ✅ 不会 panic

设计要点from_raw 永远不 panic,未识别值统一回退到 Unknown。这是 “宽容接收,严格发送”(Postel 定律)。

21.4 浮点数的特殊值

XTP 行情数据中的价格/数量字段是 f64,可能出现的异常值:

含义 风险
f64::NAN 非法价格(停牌、未初始化) Debug 输出 NaN,排序不可靠
f64::INFINITY 理论上不应出现 比较运算异常
-0.0 0.0 语义相同但 == 比较不同 误判零值
0.0 价格=0(停牌、涨跌停无成交) 需区分"价格为0"和"无数据"
f64::MIN 极小的负值 显示混乱
subnormal 极小值(< 约 2.2e-308) 性能影响,显示为 0.000...
impl XtpMarketData {
    /// 价格是否有效(非 NAN、非 INF、非负无穷)
    pub fn is_price_valid(&self) -> bool {
        self.last_price.is_finite() && self.last_price >= 0.0
    }

    /// 安全的价格格式化
    pub fn last_price_display(&self) -> String {
        if self.last_price.is_nan() {
            "N/A".to_string()
        } else if self.last_price.is_infinite() {
            if self.last_price > 0.0 { "+∞" } else { "-∞" }.to_string()
        } else {
            format!("{:.3}", self.last_price)
        }
    }
}

21.5 零值作为哨兵值

XTP API 用 0 表示多种"无效/未设置"语义,不能盲目信任零值的普通含义

字段 0 的含义
client_id = 0 非法(XTP 预留值)
timestamp = 0 无效时间,非 1970-01-01
error_id = 0 成功,无错误
last_price = 0.0 可能是停牌,也可能是真的 0
qty = 0 无成交,非"成交量为 0 股"(微妙差异)
exchange_id = 0 可能未初始化,回退到 ExchangeType::Unknown
log_level = 0 LogLevel::Fatal — 这是一个有效值
begin_time = 0 (查询) 通常表示 “不限制起始时间”
seq = 0 (tick-by-tick) 可能表示无效序列号

处理原则

// ❌ 错误:假设 0 = 无效
let time = if data.data_time != 0 {
    Some(parse_xtp_timestamp(data.data_time))
} else {
    None
};

// ✅ 正确:用 ≤0 更安全(防御性)
let time = if data.data_time > 0 {
    parse_xtp_timestamp(data.data_time)
} else {
    None
};

21.6 数组和切片边界

// ⚠️ quote.rs:287 — from_raw_parts 依赖 C++ 传入的 count
unsafe fn c_slice<'a>(ptr: *const i64, count: c_int) -> &'a [i64] {
    if ptr.is_null() || count <= 0 {
        &[]                              // 安全兜底
    } else {
        unsafe { std::slice::from_raw_parts(ptr, count as usize) }
        //     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        //     count 来自 C++,如果 C++ 传错了(比如 count=999,但实际数组只有 10 个元素)
        //     → Rust 认为有 999 个元素 → 访问越界 → UB
    }
}

这是本项目中最危险的潜在 UB 来源——完全信任 C++ 传入的 count

C++ 传入 实际数组 后果
count=10 10 元素 ✅ 正确
count=10 5 元素 💥 越界读取 5 个垃圾 i64
count=-1 任意 count ≤ 0 被过滤
count=0 任意 ✅ 返回空切片
count=10, ptr=null 不存在 ptr.is_null() 被过滤

防御措施(但受限于 FFI 场景无法完全解决):

unsafe fn c_slice<'a>(ptr: *const i64, count: c_int, max_count: c_int) -> &'a [i64] {
    if ptr.is_null() || count <= 0 {
        &[]
    } else {
        let actual = count.min(max_count) as usize;
        unsafe { std::slice::from_raw_parts(ptr, actual) }
    }
}

// 使用:
let bq = unsafe { c_slice(bid1_qty, bid1_count, max_bid1_count) };
//                        C++ 给的 count    C++ 给的最大容量
//                        信任但有上限      用他们自己给的上限来兜底

21.7 从 C++ 返回的裸指针中的垃圾数据

// quote_ffi.rs:161 — XTPRI* 可能为 null
let ptr = unsafe { (self.vt().get_api_last_error)(self.obj) };
let rsp = unsafe { ptr.as_ref() }
    .copied()
    .unwrap_or(XtpRspInfo::OK);   // null → OK(全零)
// quote_ffi.rs:170 — GetApiVersion 可能返回 null 或野指针
let p = unsafe { (self.vt().get_api_version)(self.obj) };
if p.is_null() {
    String::new()
} else {
    std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
    // ⚠️ 如果 p 不是有效的 null 终止字符串(野指针)
    //    → from_ptr 会一直读到遇到 \0 或 segfault
}

风险评估:在正常 DLL 中不会返回野指针。如果 DLL 损坏,这是一个 crash 点。

21.8 client_id 极端值

let client_id: u8 = 255;  // u8::MAX

// validate_client_id 的行为:
// 255 == 0? → false
// 255 > 99? → true → Err ✅

但如果忘记调用 validate_client_id

let api = QuoteApi::new(255, "./data", LogLevel::Debug, true)?;
// ⚠️ 255 被传给 DLL 的 CreateQuoteApi(255, ...)
// C++ 侧: client_id 是 uint8_t → 与 u8 一致
// 但 XTP 文档说 100~255 是系统预留 → DLL 行为未定义

21.9 Unsafe 指针转型中的极端值

// quote_ffi.rs:244 — 结构体指针转型
pub fn request_rebuild_quote(&self, param: &XtpQuoteRebuildReq) -> Result<(), XtpError> {
    let r = unsafe {
        (self.vt().request_rebuild)(
            self.obj,
            param as *const _ as *mut _
            //   ^^^^^^^^^^^^^^^^^^^^^^^^
            //   &XtpQuoteRebuildReq → *const XtpQuoteRebuildReq → *mut XtpQuoteRebuildReq
            //
            // 安全前提: C++ 侧不修改 param(const 被去掉了)
            // 极端情况: 如果 C++ 在 future 版本中开始修改这个参数 → Rust 侧的 & 引用被破坏 → UB
        )
    };
}

21.10 count 字段的特殊语义

// quote_types.rs — XtpMarketData 中的 count 字段
pub max_bid1_count: i32,  // C++ 分配的数组最大容量
pub bid1_count: i32,      // 实际有效元素数量

关键关系bid1_count ≤ max_bid1_count。如果 C++ 返回 bid1_count > max_bid1_count

let bq = unsafe { c_slice(bid1_qty, bid1_count) };
// bid1_count = 100, 但实际数组只有 max_bid1_count = 10 个元素
// → from_raw_parts(ptr, 100) → 读取到数组外的 90 个 i64 → UB

21.11 完整极端值检查清单

□ 整数转型
  → 所有 usize→i32/c_int 转换是否检查了溢出?
  → 特别关注:数组长度、count 参数、port 参数
  → 推荐: 用 try_into() 返回 Result 替代 as cast

□ 枚举越界
  → 所有 from_raw() 是否处理了未定义的值?
  → 默认回退是否安全?(Unknown / default variant)
  → 用户构造的枚举值是否在有效范围内?

□ 浮点特殊值
  → f64 字段是否检查了 is_nan() / is_infinite()?
  → 价格显示/比较是否过滤了 NaN?
  → 排序/聚合操作是否对 NaN 做了特殊处理?

□ 零值语义
  → 每个整数/浮点字段的 0 值含义是否明确?
  → timestamp=0 → 无效,不解析
  → error_id=0 → 成功,不是错误
  → 文档中是否记录了每个字段的 sentinel 值?

□ 指针安全
  → 所有从 C++ 收到的裸指针是否检查了 is_null()?
  → from_raw_parts 的 count 是否 ≤ 已知的 max_count?
  → 回调中的 this 指针是否通过 holder! 宏守卫?

□ 数组边界
  → ticker 数组是否会被写入超过 XTP_TICKER_LEN?
  → bid/ask 数组的读取是否受 max_xxx_count 约束?

□ DLL 异常
  → CreateQuoteApi 返回 null 是否处理?
  → vtable 提取失败是否处理?
  → GetApiVersion 返回 null 是否处理?

□ 输入参数
  → client_id 是否校验 1~99?
  → 路径/文件名是否合理(长度、编码)?
  → protocol_type 是否只有 TCP(1) / UDP(2)?

二十二、UDP 与 TCP 模式差异详解

22.1 模式选择

XTP 行情 API 在 login() 时通过 ProtocolType 参数选择通信模式:

pub enum ProtocolType {
    Tcp = 1,  // TCP 传输
    Udp = 2,  // UDP 传输
}

// 使用:
api.login("127.0.0.1", 6001, "user", "pass", ProtocolType::Tcp, None)?;
api.login("127.0.0.1", 6002, "user", "pass", ProtocolType::Udp, None)?;

Tcp 和 Udp 使用 不同的端口(XTP 服务器分别监听)。

22.2 核心差异总览

╔═══════════════╦════════════════════════════════╦═══════════════════════════════╗
║ 维度          ║ TCP 模式                       ║ UDP 模式                      ║
╠═══════════════╬════════════════════════════════╬═══════════════════════════════╣
║ 传输可靠性     ║ 可靠 (重传 + 有序)            ║ 不可靠 (丢包不重传)          ║
║ 延迟          ║ 较高 (握手 + 流控)             ║ 极低 (无连接开销)            ║
║ 带宽利用率     ║ TCP 流控限制                   ║ 通常更高 (无拥塞控制)        ║
║ 断连后重订阅   ║ ✅ 需要重新 subscribe          ║ ❌ 无需重新订阅 (自动恢复)    ║
║ local_ip 参数  ║ 可选 (多网卡场景)              ║ 必填或在组播时必须设          ║
║ CPU 亲和性     ║ 无专用 API                     ║ ✅ set_udp_thread_affinity    ║
║ 丢包诊断      ║ 不需要                         ║ ✅ udpseq_output 日志          ║
║ 适用场景      ║ 公网/测试环境/低频行情          ║ 生产环境/极速高频行情         ║
║ 订阅限制      ║ 订阅上限较低                   ║ 订阅上限较高                  ║
║ 专属功能      ║ —                              ║ 指数推送 (Index Press)        ║
║              ║ —                              ║ 港股通 (HKC Market Data)      ║
╚═══════════════╩════════════════════════════════╩═══════════════════════════════╝

22.3 断连重连行为(最关键差异)

TCP 模式断连流程:
──────────────────
  on_disconnected(reason) 回调触发
  │
  ├─ 用户调用 api.login(...) 重新登录
  │    ↓
  └─ ⚠️ 需要重新 subscribe 所有之前订阅的标的!
      (TCP 断连后,服务器清空该连接的订阅列表)

UDP 模式断连流程:
──────────────────
  on_disconnected(reason) 回调触发
  │
  ├─ 用户调用 api.login(...) 重新登录
  │    ↓
  └─ ✅ 不需要重新 subscribe!
      (UDP 是无连接协议,订阅信息由 XTP 内部维护)

代码中的明确注释:

// quote.rs:484-485
/// - On `on_disconnected`: do **not** drop the API; just call `login()` again
///   (and re-subscribe in TCP mode). See the official reconnection guide.

TCP 模式的 re-subscribe 陷阱:如果用户忘记在 on_disconnected 后的重连中重新订阅,行情数据将静默停止推送——不会报错,只是收不到数据。

22.4 UDP 专属功能

22.4.1 udpseq_output — 丢包诊断日志
// quote.rs:503-504
/// * `udpseq_output` - Enable UDP sequence log output (packet-loss
///   diagnosis; disable once stable to save a CPU core)
pub fn new(
    client_id: u8,
    save_file_path: &str,
    log_level: LogLevel,
    udpseq_output: bool,  // ← 仅 UDP 模式有意义
) -> Result<Self, XtpError>

作用:启用后 DLL 将每个 UDP 包的序列号和到达时间写入日志文件,用于诊断网络丢包。

开销:每个包一次文件 I/O → 额外消耗约一个 CPU 核。生产环境稳定后应关闭。

22.4.2 set_udp_thread_affinity — CPU 核绑定
// quote_ffi.rs:180-181
/// Set UDP receive thread CPU affinity (only meaningful for UDP mode).
/// Must be called after SetConfigFile and before Login.
pub fn set_udp_thread_affinity(&self, cpu_ids: &[i32]) -> Result<(), XtpError> {
    let mut cpus: Vec<i32> = cpu_ids.to_vec();
    let ok = unsafe {
        (self.vt().set_udp_thread_affinity)(self.obj, cpus.as_mut_ptr(), cpus.len() as i32)
    };
    // ...
}

作用:将 DLL 内部的 UDP 接收线程绑定到指定 CPU 核,避免线程在核间迁移导致的缓存失效和延迟抖动。

使用建议:绑定到物理核(非超线程的兄弟核),且避开被其他高负载线程占用的核。

// 绑定 UDP 接收线程到 CPU 核心 2 和 3
api.set_config_file("./quote_config.ini")?;
api.set_udp_thread_affinity(&[2, 3])?;   // ← Login 之前调用
api.login("127.0.0.1", 6002, "user", "pass", ProtocolType::Udp, None)?;
22.4.3 UDP-Only 订阅
// quote.rs:714
/// Subscribe to index press data (UDP only).
pub fn subscribe_all_index_press(&self) -> Result<(), XtpError>

// quote.rs:724
/// Subscribe to HKC (Hong Kong Connect) market data (UDP only).
pub fn subscribe_all_hkc_market_data(&self) -> Result<(), XtpError>

这两个订阅仅在 UDP 模式下有效。TCP 模式下调用不会报错,但收不到数据。

22.5 UDP 模式的性能优势原理

TCP 数据路径:
┌─────────┐   SYN/ACK   ┌─────────┐
│ XTP 客户端│←──────────→│ XTP 服务器│   三次握手
└─────────┘             └─────────┘
     ↑                       │
     │   ACK (确认收到)       │  数据包 1
     │ ←─────────────────────│
     │                       │  数据包 2 (等待 ACK)
     │                       │
     │  数据有严格的顺序保证    │
     │  丢包时: 暂停, 重传, 恢复│

UDP 数据路径:
┌─────────┐              ┌─────────┐
│ XTP 客户端│←─────────────│ XTP 服务器│   无连接,直接发
└─────────┘              └─────────┘
     ↑                       │
     │   数据包 1,2,3,4...    │  连续发送,不等确认
     │ ←─────────────────────│
     │                       │
     │  无顺序保证, 丢包不重传  │
     │  延迟 = 网络延迟 + 0    │  (TCP 额外有 ACK/重传延迟)

延迟量化:局域网 UDP 比 TCP 低 10–50μs,广域网差距更大。对高频交易策略,这个差距是决定性的。

22.6 XTP 的 UDP 可靠性增强

XTP 在标准 UDP 之上做了应用层增强以弥补丢包问题:

增强机制 说明
序列号 每个 UDP 包带递增序列号,客户端可检测丢包
行情重建 (Rebuild) request_rebuild_quote() — 检测到丢包后主动请求服务器重发丢失的行情快照
udpseq_output 记录每个包的序列号和到达时间,用于离线分析丢包率
双路冗余 部分部署同时走两条 UDP 路径,任意一条到达即可
// 丢包后的修复流程:
// 1. 检测到序列号跳跃 → 中间有丢包
// 2. 调用 Rebuild 请求重发
api.request_rebuild_quote(&XtpQuoteRebuildReq {
    // 指定需要 rebuild 的 ticker 和时间范围
    ..
})?;
// 3. DLL 通过 SPI 回调重新推送缺失的数据

22.7 local_ip 参数在不同模式下的含义

pub fn login(
    &mut self,
    ip: &str,               // XTP 服务器 IP
    port: i32,              // 服务器端口 (TCP 和 UDP 通常不同)
    user: &str,             // 用户名
    password: &str,         // 密码
    sock_type: ProtocolType,// TCP 或 UDP
    local_ip: Option<&str>, // 本机网卡 IP (可选)
) -> Result<(), XtpError>
模式 local_ip 用法
TCP 通常传 None,OS 自动选择出网卡
UDP (单播) 通常传 None
UDP (组播) 必须指定,告知 XTP 加入哪个网卡的组播组
多网卡 指定 Some("192.168.1.100") 绑定特定网卡

22.8 模式的 API 调用顺序差异

TCP 调用顺序:
  new() → set_config_file() → login(TCP) → subscribe_*() → [收数据] → logout()
                                    ↑                    ↑
                                TCP 连接建立          订阅绑定到连接

UDP 调用顺序:
  new() → set_config_file() → [set_udp_thread_affinity()] → login(UDP) → subscribe_*() → [收数据] → logout()
                                    ↑ (可选,login 之前)       ↑                     ↑
                                  CPU 核绑定             加入组播组           订阅独立于连接

22.9 模式选择决策树

需要港股通 (HKC) 数据?
  → 是 → 必须 UDP

需要指数推送 (Index Press)?
  → 是 → 必须 UDP

极速交易场景(微秒级延迟敏感)?
  → 是 → 推荐 UDP

公网环境 / 测试环境 / 低带宽?
  → 是 → 推荐 TCP(可靠,不受防火墙干扰)

UDP 丢包率高且不可接受?
  → 是 → 用 TCP 或部署双路 UDP

无法绑定 CPU 亲和性?
  → UDP 仍可用,但延迟优化效果减半

22.10 Rust 侧的建议封装

impl QuoteApi {
    /// 以 TCP 模式登录(最常用)
    pub fn login_tcp(&mut self, ip: &str, port: i32, user: &str, password: &str) -> Result<(), XtpError> {
        self.login(ip, port, user, password, ProtocolType::Tcp, None)
    }

    /// 以 UDP 模式登录(低延迟场景)
    pub fn login_udp(
        &mut self,
        ip: &str,
        port: i32,
        user: &str,
        password: &str,
        local_ip: Option<&str>,
        cpu_affinity: Option<&[i32]>,
    ) -> Result<(), XtpError> {
        if let Some(cpus) = cpu_affinity {
            self.set_udp_thread_affinity(cpus)?;
        }
        self.login(ip, port, user, password, ProtocolType::Udp, local_ip)
    }

    /// 检查当前是否使用 UDP
    pub fn is_udp(&self) -> bool {
        // 无法从 API 查询,需调用者自行记录
        // self.protocol_type (需要新增字段追踪)
        todo!()
    }
}

22.11 UDP/TCP 注意事项清单

□ 模式选择
  → 生产环境是否评估过 TCP vs UDP 的延迟差异?
  → 是否了解 UDP 丢包后的 Rebuild 机制?
  → HKC / Index Press 功能是否必须用 UDP?

□ TCP 模式
  → on_disconnected 回调中重连后是否重新 subscribe?
  → 是否处理了 TCP 断连后订阅列表被清空的问题?
  → local_ip 是否按需设置(多网卡)?

□ UDP 模式
  → 生产环境是否关闭了 udpseq_output(省 CPU)?
  → 是否设置了合理的 CPU 亲和性?
  → 是否监控了 UDP 丢包率?
  → 是否在丢包后调用 request_rebuild_quote()?
  → local_ip 在组播模式下是否正确设置?

□ 通用
  → login 的 port 参数是否匹配模式?(TCP/UDP 不同端口)
  → 同一 client_id 能否同时建立 TCP 和 UDP 连接?(通常不能)
  → 防火墙是否放行了 UDP 端口?

二十三、同步与异步机制详解

23.1 总体模型:混合模式

XTP API 采用混合模型——API 调用是同步阻塞的,但数据推送是异步回调的:

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   同步 (Sync)                    异步 (Async)                │
│   ─────────                      ──────────                 │
│   Login() 等连接建立             行情数据通过 SPI 回调推送    │
│   Subscribe() 返回成功/失败      查询结果通过 SPI 回调返回    │
│   Logout() 断开连接              断连通知通过 SPI 回调触发    │
│   所有配置 API                   —                           │
│                                                             │
│   用户线程主动调用                 DLL 后台线程主动推送        │
│   调用线程被阻塞                  回调在 DLL 线程中执行        │
│   返回 Result<T,E>                回调必须快速返回            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

23.2 同步操作:全部 API 调用

以下所有函数都是同步阻塞的——调用线程等待 DLL 完成操作后才返回:

// === 生命周期管理 (同步) ===
QuoteApi::new(...)               // 返回前完成 DLL 加载 + C++ 对象创建
api.login(...)                   // 返回前完成 TCP/UDP 连接建立(可能超时)
api.logout()                     // 返回前完成断开
// Release() 在 Drop 中调用     // 同步等待 DLL 清理

// === 配置 (同步) ===
api.set_config_file(...)         // 返回前配置文件已读取
api.set_heart_beat_interval(...) // 返回前配置已生效
api.set_udp_thread_affinity(...) // 返回前 CPU 亲和性已设置

// === 订阅/查询请求 (同步) ===
api.subscribe_market_data(...)   // 返回前订阅请求已发送并收到确认
api.query_all_tickers(...)       // 返回前查询请求已发送
//                                ↑ 但结果不在此处返回!见下文

Login 是特殊的长阻塞调用

// quote.rs:659-670
/// Login to the quote server.
///
/// This is a synchronous blocking call: `Ok(())` means connected.
pub fn login(&mut self, ip: &str, port: i32, user: &str, password: &str,
             sock_type: ProtocolType, local_ip: Option<&str>,
) -> Result<(), XtpError> {
    self.raw.login(ip, port, user, password, sock_type, local_ip)?;
    self.logged_in = true;
    Ok(())
}

Login() 会阻塞调用线程直到以下任一条件满足:

  • TCP 连接建立 + 认证通过 → Ok(())
  • 连接超时 → Err(XtpRspError(...))
  • 认证失败 → Err(XtpRspError(...))

23.3 异步操作:全部数据通过 SPI 回调返回

关键认知subscribe_*()query_*() 返回 Ok(()) 只表示请求被接受——实际数据通过回调异步抵达

时间线上的行为差异:

subscribe_market_data(&["600000"], SH)
│
├─ 返回 Ok(())  ← 同步,立即(毫秒级)
│   "订阅请求已发送,服务器确认收到"
│
│  ... 时间流逝 ...
│
├─ on_sub_market_data(...)  ← 异步回调
│   "订阅确认:600000 订阅成功"
│
│  ... 更多时间 ...
│
├─ on_depth_market_data(...) ← 异步回调 × N
│   "600000 最新价 12.34"
│
├─ on_depth_market_data(...)
│   "600000 最新价 12.35"
│
└─ ... 持续推送直到 unsubscribe ...
// ❌ 错误理解:以为 subscribe 返回的数据在返回值里
let data = api.subscribe_market_data(&["600000"], ExchangeType::SH)?;
// data 只是 () — 行情不在这里!

// ✅ 正确:回调中接收
struct MySpi;
impl QuoteSpi for MySpi {
    fn on_depth_market_data(&self, md: &XtpMarketData, ...) {
        println!("{}: {}", md.ticker_str(), md.last_price);
        // ← 行情数据在这里!
    }
}

23.4 每个 API 调用的同步/异步属性

┌─────────────────────────────┬──────────┬──────────────────────────────────┐
│ API                         │ 调用方式  │ 结果获取                          │
├─────────────────────────────┼──────────┼──────────────────────────────────┤
│ CreateQuoteApi              │ 同步     │ 返回值 = C++ 对象指针             │
│ Release                     │ 同步     │ 返回值 = void                    │
│ Login                       │ 同步阻塞  │ 返回 Ok = 连接成功               │
│ Logout                      │ 同步     │ 返回 Ok = 断开成功               │
│ SetConfigFile               │ 同步     │ 返回 Ok = 配置已读               │
│ SetHeartBeatInterval        │ 同步     │ 返回 Ok                        │
│ SetUDPThreadAffinity        │ 同步     │ 返回 Ok                        │
│ SubscribeMarketData         │ 同步†    │ 返回 Ok + 异步 on_sub/on_md     │
│ UnsubscribeMarketData       │ 同步†    │ 返回 Ok + 异步 on_unsub         │
│ SubscribeOrderBook          │ 同步†    │ 返回 Ok + 异步 on_sub/on_ob     │
│ SubscribeTickByTick         │ 同步†    │ 返回 Ok + 异步 on_tbt           │
│ SubscribeAllMarketData      │ 同步†    │ 返回 Ok + 异步 on_sub_all_md    │
│ QueryAllTickers             │ 同步†    │ 返回 Ok + 异步 on_query_all     │
│ QueryTickersPriceInfo       │ 同步†    │ 返回 Ok + 异步 on_query_price   │
│ QueryAllTickersFullInfo     │ 同步†    │ 返回 Ok + 异步 on_query_full    │
│ QueryLatestMD               │ 同步†    │ 返回 Ok + 异步 on_query_latest  │
│ RequestRebuild              │ 同步†    │ 返回 Ok + 异步 on_rebuild       │
├─────────────────────────────┼──────────┼──────────────────────────────────┤
│ on_disconnected             │ 异步     │ DLL 主动 push                    │
│ on_error                    │ 异步     │ DLL 主动 push                    │
│ on_depth_market_data        │ 异步     │ DLL 主动 push (高频)              │
│ on_order_book               │ 异步     │ DLL 主动 push                    │
│ on_tick_by_tick             │ 异步     │ DLL 主动 push (极高频)             │
│ on_index_press              │ 异步     │ DLL 主动 push                    │
│ ... (其余 33 个回调)         │ 异步     │ DLL 主动 push                    │
└─────────────────────────────┴──────────┴──────────────────────────────────┘

† "同步"指调用立即返回,但实际业务结果(数据/查询结果)通过异步回调返回

23.5 DLL 内部线程模型

进程内线程分布:

┌─────────────────────────────────────────────────────┐
│  用户主线程                                          │
│  ├─ QuoteApi::new()           ← 同步               │
│  ├─ api.login()               ← 阻塞等待连接        │
│  ├─ api.subscribe_market_data() ← 同步返回          │
│  └─ loop { sleep(); }         ← 等待数据            │
│        ↑                                            │
│        │ 通过 mpsc::channel 接收                     │
│        │                                            │
├─────────────────────────────────────────────────────┤
│  DLL 内部线程 1 (TCP/UDP 接收)                       │
│  ├─ recv() 数据包                                   │
│  ├─ 解析行情数据                                     │
│  └─ m_spi->OnDepthMarketData(&md, ...)              │
│      └→ tramp_on_depth_market_data()  ← 进入 Rust   │
│          └→ holder.callback.on_depth_market_data()   │
│              └→ tx.send(data)  → channel → 主线程    │
│                                                    │
│  DLL 内部线程 2 (心跳/重连)                          │
│  └─ m_spi->OnDisconnected(reason)                   │
│                                                    │
│  DLL 内部线程 N (UDP 可能有多个接收线程)              │
└─────────────────────────────────────────────────────┘

关键约束

// quote.rs:57-60
/// **IMPORTANT**: All callback methods are called from the XTP internal thread.
/// They must return quickly to avoid blocking the data stream.
/// Use channels (`std::sync::mpsc` or `crossbeam`) to pass data to your main thread.

回调在 DLL 内部线程中执行。如果在回调中做耗时操作(如文件 I/O、复杂计算):

后果 严重程度
阻塞数据接收线程 🔴 后续数据包堆积 → 延迟增加
数据包堆积超过缓冲区 🔴 丢包
在 TCP 模式 🟡 TCP 流控反压 → 服务器降速
在 UDP 模式 🔴 直接丢包,不可恢复

23.6 用户侧异步模式:channel 桥接

推荐的用户代码模式:

use std::sync::mpsc::{channel, Sender};

enum MarketEvent {
    MarketData(XtpMarketData),
    OrderBook(XtpOrderBook),
    TickByTick(XtpTickByTick),
    Disconnected(i32),
}

struct ChannelSpi {
    tx: Sender<MarketEvent>,
}

impl QuoteSpi for ChannelSpi {
    fn on_depth_market_data(&self, md: &XtpMarketData, ..) {
        // 不在此做任何耗时操作,直接发到 channel
        let _ = self.tx.send(MarketEvent::MarketData(*md));
        //                                      ^^^^
        //                        XtpMarketData 是 Copy,零分配拷贝
    }

    fn on_disconnected(&self, reason: i32) {
        let _ = self.tx.send(MarketEvent::Disconnected(reason));
    }

    // 其他回调同理 — 只做 send,不做处理
}

fn main() {
    let (tx, rx) = channel();

    let mut api = QuoteApi::new(1, "./data", LogLevel::Info, false).unwrap();
    api.register_spi(Box::new(ChannelSpi { tx }));
    api.login("127.0.0.1", 6001, "user", "pass", ProtocolType::Tcp, None).unwrap();
    api.subscribe_all_market_data(ExchangeType::SH).unwrap();

    // 主线程事件循环
    loop {
        match rx.recv() {  // ← 阻塞等待,但不阻塞 DLL 接收线程
            Ok(MarketEvent::MarketData(md)) => {
                // 在这里做耗时处理 — 不影响 DLL 线程
                process_market_data(&md);
            }
            Ok(MarketEvent::Disconnected(reason)) => {
                eprintln!("Disconnected: {reason}");
                // 重连
                api.login("127.0.0.1", 6001, "user", "pass", ProtocolType::Tcp, None).ok();
                api.subscribe_all_market_data(ExchangeType::SH).ok();
            }
            Err(_) => break,
        }
    }
}

为什么用 std::sync::mpsc 而非 tokio::mpsc

方案 优点 缺点
std::sync::mpsc 零依赖,回调中 send 不阻塞 recv 阻塞主线程
crossbeam::channel 高性能,支持多生产者 额外依赖
tokio::mpsc 与 async 生态集成 需要 tokio runtime,回调非 async 上下文

对于纯行情接收场景,std::sync::mpsc 足够且最简单。

23.7 不同回调的推送频率

回调 触发频率 典型间隔 回调中能做的事
on_depth_market_data 每笔行情 0~几 ms send 到 channel
on_tick_by_tick 每笔逐笔成交/委托 < 1ms send 到 channel
on_order_book 盘口变化时 几十 ms send 到 channel
on_sub_market_data 订阅确认 一次性 可以轻度处理
on_query_* 查询响应 一次性/分批 可以轻度处理
on_error 出错时 极少 日志
on_disconnected 断连时 极少 触发重连逻辑

23.8 为什么没有封装为 Rust async/await?

原因 1: DLL 是同步 C++ 代码
  → DLL 内部的 Login() 是同步阻塞的
  → 无法在 Rust 侧用 tokio::spawn_blocking 无代价地包装

原因 2: 回调在未知线程
  → DLL 在自己的线程池中调用 SPI
  → Rust Future 需要 Waker 机制 → 需要知道在哪个 executor 上唤醒

原因 3: 性能考虑
  → 极速交易场景每微秒都重要
  → async 的状态机分配 + Waker 开销不可接受
  → mpsc channel 零分配拷贝 (Copy struct)

如果要封装 async,推荐在外面包一层:

// 用户侧自行封装:
use tokio::sync::mpsc;

async fn quote_stream() -> mpsc::UnboundedReceiver<MarketEvent> {
    let (tx, rx) = mpsc::unbounded_channel();
    std::thread::spawn(move || {
        // 在独立线程中运行同步 XTP API
        let mut api = QuoteApi::new(...).unwrap();
        api.register_spi(Box::new(ChannelSpi { tx: std_sync_tx }));
        api.login(...).unwrap();
        loop { std::thread::sleep(Duration::from_secs(1)); }
    });
    rx
}

23.9 注意事项清单

□ 同步操作
  → Login 可能长时间阻塞,是否考虑了超时?
  → 同步操作失败时是否需要重试策略?
  → 是否在 UI 线程中调用了 Login?(会卡界面)

□ 异步回调
  → 回调中是否只做了轻量操作(send 到 channel)?
  → 回调中禁止:文件 I/O、网络请求、sleep、锁等待
  → 是否理解了每个回调的触发频率?
  → 高频回调的结构体是否 Copy?(XtpMarketData 是 Copy ✅)

□ 线程安全
  → QuoteSpi 实现是否 Send + Sync?
  → channel 的 send 是否在回调线程和主线程间安全?
  → 是否有共享可变状态需要加锁?(尽量减少)

□ 断连与恢复
  → on_disconnected 回调中的重连逻辑是否正确?
  → TCP 重连后是否重新订阅?
  → 重连期间丢失的行情是否需要 Rebuild?

□ 性能
  → 是否避免了在回调中 clone 大型结构体?
  → channel 是否用了 unbounded?(防止回调被阻塞在 send 上)
  → 生产环境是否关闭了 udpseq_output?
  → 是否设置了 UDP 线程 CPU 亲和性?

二十四、no-op 桩函数与 vtable 槽位机制

24.1 问题本质:vtable 按编号调用,不能有空缺

C++ DLL 的虚函数调用等价于以下伪代码:

// C++ DLL 内部:收到行情 → 回调用户 SPI
void* vtable = spi_object[0];            // 读 vtable 指针
void* func   = vtable[slot_index];       // 按编号取函数指针
func(spi_object, market_data, ...);      // 调用
//    ↑ DLL 不知道这个槽位 Rust 侧有没有实现
//    它只认编号:slot 3 = 第 4 个虚函数

核心约束:DLL 按固定编号调用,如果 Rust 侧 vtable 中某个槽位缺失(或用了错误签名的函数),后续所有槽位全部偏移,每个回调都调到错误的函数,签名不匹配 → crash。

┌─────────────────────────────────────────────────────────────────┐
│  DLL 期望的 vtable 编号          Rust 侧实际 vtable             │
├─────────────────────────────────────────────────────────────────┤
│  [0] → OnDisconnected     ←→    [0] tramp_on_disconnected  ✅  │
│  [1] → OnError            ←→    [1] tramp_on_error         ✅  │
│  [2] → OnQueryAccount*    ←→    [2] ??? 如果这里缺了!      ❌  │
│  [3] → OnOrderEvent       ←→    [2] tramp_on_order_event   💥  │
│                                  ↑ DLL 调 slot 3,但 Rust 的    │
│                                    slot 2 被当成了 slot 3        │
│                                    参数类型全错 → crash         │
└─────────────────────────────────────────────────────────────────┘

24.2 no-op 桩的作用:占住槽位

no-op(no operation)是一个不做任何事的函数,唯一的目的是占住 vtable 中的槽位编号:

// no-op 桩函数:参数签名必须与 C++ 虚函数完全匹配
unsafe extern "C" fn noop_on_query_order_by_page(
    _this: *mut TraderSpiHolder,   // this 指针 — 不读取
    _data: *mut c_void,            // 数据指针 — 不读取
    _a: i64, _b: i64, _c: i64,    // 参数 — 全部忽略
    _d: c_int, _e: bool, _f: u64,
) {
    // 函数体为空 — 什么都不做
}

填入 vtable 后

DLL 调用 slot 2 (OnQueryAccount)
    │
    ▼
vtable[2] = noop_on_query_account
    │
    ▼
函数执行 → 立即返回 → DLL 无感知 ✅

DLL 调用 slot 3 (OnOrderEvent)
    │
    ▼
vtable[3] = tramp_on_order_event  ← 编号正确 ✅

24.3 Trader API:65 个槽位,14 个真实实现

// trader.rs — 用宏批量生成 no-op
macro_rules! stub {
    ($name:ident($($arg:ty),*)) => {
        unsafe extern "C" fn $name(_this: *mut TraderSpiHolder, $(_: $arg),*) {}
    };
}

// 51 个 no-op(类型签名来自 C++ 头文件 xtp_trader_api.h)
stub!(noop_on_query_account_trade_market(c_int, *mut XtpRspInfo, c_int, u64));
stub!(noop_on_query_order_by_page(*mut c_void, i64, i64, i64, c_int, bool, u64));
stub!(noop_on_query_order_by_page_ex(*mut c_void, i64, i64, i64, c_int, bool, u64));
stub!(noop_on_query_trade_by_page(*mut c_void, i64, i64, i64, c_int, bool, u64));
stub!(noop_on_query_structured_fund(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_other_server_fund(*mut c_void, *mut XtpRspInfo, c_int, u64));
stub!(noop_on_query_etf(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_etf_basket(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_ipo_info_list(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_ipo_quota_info(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_bond_ipo_info_list(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_bond_swap_stock_info(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_option_auction_info(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_credit_cash_repay(*mut c_void, *mut XtpRspInfo, u64));
stub!(noop_on_credit_cash_repay_debt_interest_fee(*mut c_void, *mut XtpRspInfo, u64));
stub!(noop_on_query_credit_cash_repay_info(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_credit_fund_info(*mut c_void, *mut XtpRspInfo, c_int, u64));
stub!(noop_on_query_credit_debt_info(*mut c_void, *mut XtpRspInfo, c_int, u64));
stub!(noop_on_query_credit_ticker_debt_info(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_credit_asset_debt_info(f64, *mut XtpRspInfo, c_int, u64));
stub!(noop_on_query_credit_extend_debt_date(*mut c_void, *mut XtpRspInfo, u64));
stub!(noop_on_query_credit_extend_debt_date_orders(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_algo_connected());
stub!(noop_on_algo_disconnected(c_int));
// ... 共 51 个

// vtable 填入:
static TRADER_SPI_VTABLE: TraderSpiVTable = TraderSpiVTable {
    // 槽位 0~13: 真实实现
    on_disconnected:     tramp_on_disconnected,
    on_error:            tramp_on_error,
    // 槽位 2~13: 混合真实实现 + no-op
    on_query_account_trade_market: noop_on_query_account_trade_market,
    // ...
    // 槽位 14~64: 全部 no-op
    on_algo_connected:         noop_on_algo_connected,
    on_algo_disconnected:      noop_on_algo_disconnected,
    // ... 共 65 个槽位,无一空缺
};

24.4 Quote API:38 槽位全部真实实现

pub trait QuoteSpi: Send + Sync {
    fn on_disconnected(&self, reason: i32) {}     // ← 默认空实现
    fn on_error(&self, error_info: &XtpRspInfo) {}// ← 默认空实现
    // ... 38 个全覆盖
}

// 所有槽位都指向真实的 trampoline 函数:
static QUOTE_SPI_VTABLE: QuoteSpiVTable = QuoteSpiVTable {
    on_disconnected: tramp_on_disconnected,   // ← 真实 trampoline
    on_error:        tramp_on_error,          // ← 真实 trampoline
    // ... 38 个,全部是 tramp_xxx
};

为什么 Quote 不需要 no-op? 因为 38 个回调全部有默认空实现——用户即使不覆写,trampoline 也会调用 holder.callback.on_xxx(),而 trait 的默认空实现直接返回。效果上等同于 no-op,但保留了将来激活的能力。

24.5 no-op vs trait 默认实现 vs 未实现

场景 代码 效果
Trader no-op 桩 stub!(noop_on_xxx(...)) DLL 调用 → 立即返回,不进入 Rust trait 层
Quote trait 默认空实现 fn on_xxx(&self, ...) {} DLL 调用 → trampoline → trait 方法 → 立即返回
未实现(危险) 槽位空着或用错误函数 DLL 调用 → 跳到错误地址 → crash

trait 默认实现的额外开销(微小):

no-op 桩路径:
  DLL → vtable[slot] → noop_fn → return (3 条指令)

trait 默认实现路径:
  DLL → vtable[slot] → trampoline → holder! →
  &self → .callback.on_xxx() → trait default → return
  (多了 holder 恢复 + vtable 查找,约 10 条指令)

对每秒百万级行情回调,这个开销不可忽视——这是 Trader 用 no-op 而非 trait 默认实现的原因

24.6 如何确定哪些槽位需要 no-op

步骤 1: 打开 C++ 头文件 xtp_trader_api.h
步骤 2: 找到 class TraderSpi 的所有 virtual 函数声明
步骤 3: 从第一个 virtual 开始编号 (slot 0, 1, 2, ...)
步骤 4: 对每个虚函数:
  → 如果 Rust trait 已经实现 → 填入 tramp_on_xxx
  → 如果 Rust trait 没有实现 → 需要 no-op 桩函数
步骤 5: 确保 vtable 结构体的字段顺序 = 头文件的声明顺序
// 简化的 C++ 头文件:
class TraderSpi {
public:
    virtual void OnDisconnected(uint64_t sid, int reason) = 0;       // slot 0
    virtual void OnError(XTPRI* error_info) = 0;                      // slot 1
    virtual void OnQueryAccountTradeMarket(int, XTPRI*, int, uint64_t); // slot 2
    virtual void OnOrderEvent(XTPOrderInfo*, XTPRI*, uint64_t);       // slot 3
    // ... 数到最后一个 virtual → 共 65 个
};
// Rust vtable 必须逐槽位对齐:
struct TraderSpiVTable {
    on_disconnected:                ...,  // slot 0  → trampoline ✅
    on_error:                       ...,  // slot 1  → trampoline ✅
    on_query_account_trade_market:  ...,  // slot 2  → no-op    ⚠️
    on_order_event:                 ...,  // slot 3  → trampoline ✅
    // ...
}

24.7 激活一个 no-op 的步骤

如果将来需要激活某个 no-op(比如 on_front_connected):

① 在 no-op 列表中定位目标槽位
   → stub!(noop_on_algo_connected());  ← 这个可能就是 on_front_connected

② 在 TraderSpi trait 中添加回调方法:
   fn on_front_connected(&self) {}

③ 写 trampoline:
   unsafe extern "C" fn tramp_on_front_connected(this: *mut TraderSpiHolder) {
       guard(|| holder!(this).callback.on_front_connected());
   }

④ 替换 vtable 中的槽位:
   on_algo_connected: tramp_on_front_connected,  // 替换 noop_on_algo_connected

⑤ 确保替换后的槽位编号不变(字段位置没移动)
   → 只改变函数指针值,不改变字段声明顺序

二十五、QuoteSpiHolder 深度设计:在 Rust 里手搓一个 C++ 对象

25.1 问题:C++ DLL 期望什么

XTP DLL 内部的代码大致是这样的(简化后):

// C++ DLL 内部:
class QuoteApi {
    QuoteSpi* m_spi;  // 存着用户注册的回调对象指针

    void RegisterSpi(QuoteSpi* spi) {
        m_spi = spi;  // 就存一下
    }

    // 后台线程收到行情数据时:
    void OnDataReceived(XtpMarketData* md) {
        if (m_spi) {
            m_spi->OnDepthMarketData(md, ...);  // ← 通过 vtable 调用虚函数
        }
    }
};

C++ 对 QuoteSpi* 的认知只有一条:它指向的那个地址的前 8 个字节,是一个 vtable 指针。除此之外,C++ 对这个对象的内部结构一无所知——它不关心对象的实际类型、大小、或者后面存了什么数据。

这给了我们一个机会:只要 Rust 在堆上造一块内存,前 8 个字节放一个有效的 vtable 指针,C++ 就会把它当成真正的 QuoteSpi 对象来用。

25.2 真实 C++ 对象 vs QuoteSpiHolder 的逐字节对比

C++ 头文件中的 QuoteSpi(官方源码,xtp_quote_api.h
// xtp_quote_api.h — 官方头文件逐字引用
namespace XTP {
    namespace API {
        class QuoteSpi
        {
        public:
            virtual void OnDisconnected(int reason) {};
            virtual void OnError(XTPRI *error_info) {};
            virtual void OnTickByTickLossRange(int begin_seq, int end_seq) {};
            virtual void OnSubMarketData(XTPST *ticker, XTPRI *error_info, bool is_last) {};
            virtual void OnUnSubMarketData(XTPST *ticker, XTPRI *error_info, bool is_last) {};
            virtual void OnDepthMarketData(XTPMD *market_data, int64_t bid1_qty[],
                int32_t bid1_count, int32_t max_bid1_count,
                int64_t ask1_qty[], int32_t ask1_count, int32_t max_ask1_count) {};
            virtual void OnETFIOPVData(IOPV *iopv) {};
            // ... 共 38 个虚函数,全部是 public virtual,全部有 {} 空实现
            // 没有构造函数、没有数据成员、没有 virtual 析构函数
        };
    }
}

三个关键事实

事实 含义
所有虚函数都是 {} 空实现,没有 = 0 纯虚函数 C++ 可以实例化,不需要子类覆写全部方法 ✅ 与 Rust trait 的默认空实现完美对应
类声明中没有任何数据成员(无 intstd::string 等) sizeof(QuoteSpi) = 8(仅 vtable 指针),无额外填充
析构函数不是 virtual vtable 中没有编译器插入的 deleting destructor 槽位 ✅
如果用户用 C++ 实现一个子类:
class MyQuoteSpi : public XTP::API::QuoteSpi {
    // offset 0: vtable_ptr (8 bytes) ← 编译器自动生成,指向 MyQuoteSpi 的 vtable
    // offset 8: 用户添加的成员变量
    std::string name;          // sizeof=32 (MSVC x64)
    int some_counter;          // sizeof=4
    // 总 sizeof = 8 + 32 + 4 + padding = 48 bytes
};

编译后的内存布局:

真实的 C++ MyQuoteSpi 对象 (new 在堆上)
┌──────────────────────┐ offset 0
│ vtable_ptr (8 bytes) │ → 指向 MyQuoteSpi 编译器生成的虚函数表
├──────────────────────┤ offset 8
│ name (std::string)   │ 32 bytes (MSVC x64)
├──────────────────────┤ offset 40
│ some_counter (int)   │ 4 bytes + 4 bytes padding
└──────────────────────┘
总 sizeof = 48
Rust 侧造的 QuoteSpiHolder:
#[repr(C)]
struct QuoteSpiHolder {
    vtable: *const QuoteSpiVTable,   // offset 0: 8 bytes  ← 手工放的 vtable 指针
    callback: Box<dyn QuoteSpi>,     // offset 8: 16 bytes (data_ptr + vtable_ptr)
}
// 总 sizeof = 24 bytes

内存布局:

Rust 侧的 QuoteSpiHolder (Box::new 在堆上)
┌──────────────────────────────┐ offset 0
│ vtable_ptr (8 bytes)         │ → 指向 QUOTE_SPI_VTABLE (Rust static 全局)
├──────────────────────────────┤ offset 8
│ callback (Box<dyn QuoteSpi>) │ 16 bytes
│  ├─ data_ptr  (8 bytes)      │ → 指向 MySpi 实例的堆地址
│  └─ vtable_ptr (8 bytes)     │ → Rust trait object 自身的虚函数表
└──────────────────────────────┘
总 sizeof = 24
并排对比
C++ MyQuoteSpi 对象                    Rust QuoteSpiHolder
───────────────────────────           ───────────────────────
offset 0: ┌──────────────┐            offset 0: ┌──────────────┐
          │ vtable_ptr   │──→ 虚函数表           │ vtable_ptr   │──→ QUOTE_SPI_VTABLE
          ├──────────────┤            (8 bytes)  ├──────────────┤
          │ name         │                       │ callback     │
          │ (std::string)│                       │ ├ data_ptr   │──→ MySpi
          │              │            (16 bytes) │ └ vtable_ptr │──→ Rust trait vtable
          ├──────────────┤                       └──────────────┘
          │ some_counter │                       总 sizeof = 24
          └──────────────┘
          总 sizeof = 48

C++ 只看 offset 0 的 8 字节 → 两者对它来说完全等价 ✅
两个对象的核心差异
┌──────────────────────┬─────────────────────────────┬──────────────────────────────┐
│ 属性                  │ C++ MyQuoteSpi               │ Rust QuoteSpiHolder          │
├──────────────────────┼─────────────────────────────┼──────────────────────────────┤
│ 基类 QuoteSpi        │ 38个虚函数,无数据成员,无析构│ —                            │
│ vtable 来源          │ 编译器自动生成 (MSVC)         │ 手工 static QUOTE_SPI_VTABLE │
│ vtable 内容          │ 编译时填入成员函数地址        │ 手工填入 trampoline 地址      │
│ 虚函数调用            │ 直接调 C++ 成员函数           │ trampoline → trait 方法       │
│ 成员数据              │ std::string + int 等          │ Box<dyn QuoteSpi> (16B)       │
│ sizeof               │ 编译器计算 (48B)               │ 手工保证 (24B)               │
│ 构造函数              │ 编译器自动合成                 │ 手工设 vtable + 移入 callback │
│ RTTI                 │ 有 (typeid)                   │ 无 (不需要)                   │
│ 分配器               │ ::operator new (C++ CRT)      │ Box::new (Rust allocator)     │
│ 释放                 │ delete (C++ CRT)              │ Box::drop (Rust allocator)    │
│ 虚析构函数            │ 无 (基类未声明 virtual ~)     │ 无 (不需要)                   │
└──────────────────────┴─────────────────────────────┴──────────────────────────────┘

为什么 sizeof 不同(48 vs 24)却仍然兼容?

C++ DLL 对 QuoteSpi 的所有操作都通过 m_spi 裸指针和 vtable 完成。它从不 sizeof(*m_spi),也从不 delete m_spi(因为基类析构函数不是 virtual,且 Release() 不走 delete 路径)。所以只要 offset 0 的 8 字节是一个有效的 vtable 指针,C++ 就完全正常工作——后面的数据有多少、是什么类型,对 C++ 完全不可见。

25.3 静态单例 vtable 的设计

/// 全局唯一的 vtable 实例——所有 SPI holder 共享同一份
static QUOTE_SPI_VTABLE: QuoteSpiVTable = QuoteSpiVTable {
    on_disconnected:     tramp_on_disconnected,
    on_error:            tramp_on_error,
    on_depth_market_data: tramp_on_depth_market_data,
    // ... 38 个槽位,全部指向具体的 trampoline 函数
};

为什么用 static 而非每个 holder 创建一份?

方案 内存 性能 安全性
static 全局单例(本项目) 一份 vtable,所有 holder 共享 最佳,无额外分配 vtable 不可变(static),安全
每个 holder 一份 每个holder 304 bytes × N 个实例 浪费 无优势
运行时动态构造 每次创建分配 最差 没必要

关键:vtable 的内容(函数指针指向哪里)在编译时完全确定,永远不变。38 个 tramp_on_xxx 都是编译时已知的函数地址。所以用 static 是最佳选择——零运行时分配、零初始化开销、不可变保证线程安全。

25.4 Trampoline 是如何把 C++ 调用转到 Rust trait 的

DLL 后台线程                          Rust 侧
───────────                          ───────

m_spi->OnDepthMarketData(&md, ...)  ← C++ 虚函数调用
│
├─ ① 读 m_spi[0] → 得到 vtable 指针
│      = &QUOTE_SPI_VTABLE (Rust static)
│
├─ ② 读 vtable[4] → 得到函数指针
│      = tramp_on_depth_market_data 的地址
│
└─ ③ call 函数指针(m_spi, &md, ...)
    │
    │  ←── 从此进入 Rust ──→
    │
    ▼
unsafe extern "C" fn tramp_on_depth_market_data(
    this: *mut QuoteSpiHolder,    // = m_spi (holder 的地址)
    market_data: *mut XtpMarketData,
    bid1_qty: *mut i64, bid1_count: c_int, ...
) {
    // ④ 从裸指针恢复 Rust 引用
    let holder: &QuoteSpiHolder = match (this as *const QuoteSpiHolder).as_ref() {
        Some(h) => h,    // this 一定非 null(C++ 保证)
        None => return,  // 防御性 null 检查
    };

    // ⑤ 通过 holder 访问用户 trait object
    //    holder.callback 是 Box<dyn QuoteSpi>
    //    自动 Deref 到 &dyn QuoteSpi
    guard(|| {
        let md = &*market_data;                  // ⑥ 恢复行情数据结构体
        let bq = slice::from_raw_parts(...);      // ⑥ 恢复数组切片
        holder.callback.on_depth_market_data(md, bq, ...);
        //        ^^^^^^^^  Box<dyn QuoteSpi> 自动 Deref
        //                   → 调用用户实现的 trait 方法
    });
}

关键转换链路

C++ 裸指针 (m_spi)
    ↓ as_ref()
&QuoteSpiHolder           ← offset 已知,Rust 可以安全解释内存
    ↓ .callback
Box<dyn QuoteSpi>         ← 存在 holder 里,所有权在 Rust
    ↓ Deref
&dyn QuoteSpi             ← trait object 引用
    ↓ 虚函数调用(Rust 自己的 vtable)
用户 fn on_depth_market_data(&self, ...)  ← 你的代码!

25.5 为什么是 Box<dyn QuoteSpi> 而不是泛型 <T: QuoteSpi>

// 方案 A: 泛型 (本项目没选)
struct QuoteSpiHolder<T: QuoteSpi> {
    vtable: *const QuoteSpiVTable,
    callback: T,
}
// 问题: QuoteApi 的类型会变成 QuoteApi<T> → 整个调用链都带泛型参数
//       fn register_spi(&mut self, spi: T) → QuoteApi<T>
//       无法把不同类型的 SPI 放进同一个 Vec 等

// 方案 B: Box<dyn QuoteSpi> (本项目选择)
struct QuoteSpiHolder {
    vtable: *const QuoteSpiVTable,
    callback: Box<dyn QuoteSpi>,   // ← 类型擦除
}
// 优势: QuoteApi 类型不依赖于 SPI 类型
//       fn register_spi(&mut self, spi: Box<dyn QuoteSpi>) → QuoteApi 类型不变

25.6 所有权分裂:一份内存,两个"主人"

QuoteSpiHolder 分配在 Rust 堆上 (Box::new)
          │
          ├─→ self.spi = Some(holder)         ← Rust 持有所有权 (Box)
          │   负责: 生命周期管理、最终释放
          │
          └─→ &mut *holder as *mut c_void     ← C++ 拿到裸指针
              传给 DLL: RegisterSpi(ptr)
              DLL 存储: m_spi = (QuoteSpi*)ptr
              负责: 在回调时通过虚函数调用
              不负责: 释放内存

时间线:
  register_spi ──────────────────────────────────────→ drop(QuoteApi)
  │                                                     │
  │  C++ 持有裸指针                                      │ ① Release() 停回调
  │  Rust 持有 Box                                      │ ② C++ 不再用裸指针
  │                                                     │ ③ Box::drop() 释放
  │                                                     │
  └────────── C++ 回调期间 ──────────────────────────┘
            两个"主人"共存,各司其职

为什么不能把所有权交给 C++? Rust 的 Box 被 drop 后内存由 Rust 的全局分配器回收。C++ DLL 用的是自己的 CRT(可能是 /MDmsvcrt/MT 的静态链接),两个分配器不互通——Rust 分配的内存用 C++ 的 delete 释放 → UB。所以 Rust 必须保留所有权,C++ 只能持有裸指针。

25.7 与真实 C++ 对象的差异汇总

┌──────────────────────┬─────────────────────────────┬──────────────────────────────┐
│ 属性                  │ 真实 C++ QuoteSpi 子类       │ Rust QuoteSpiHolder          │
├──────────────────────┼─────────────────────────────┼──────────────────────────────┤
│ 创建方式              │ new MyQuoteSpi()             │ Box::new(QuoteSpiHolder{...})│
│ 堆内存管理            │ delete (C++ CRT)             │ Box::drop() (Rust 全局分配器) │
│ vtable 位置           │ 编译器在只读段自动生成        │ 手工 static QUOTE_SPI_VTABLE │
│ vtable 内容           │ 编译器填入成员函数地址        │ 手工填入 trampoline 地址      │
│ 虚函数调用            │ 直接调 C++ 成员函数           │ trampoline → trait 方法       │
│ 成员数据              │ 任意 C++ 类型                 │ Box<dyn QuoteSpi> + vtable ptr│
│ RTTI (运行时类型信息)  │ 有 (typeid, dynamic_cast)    │ 无 (不需要)                   │
│ 虚析构函数            │ 有 (如果是 virtual ~)         │ 无 (XTP 的 QuoteSpi 没虚析构) │
│ 多重继承 this 调整    │ 编译器自动处理                │ 单继承,不需要调整             │
│ sizeof                │ 编译器计算                    │ 手工保证 offset 0 是 vtable   │
│ 构造函数              │ 编译器自动调                   │ 手工设 vtable + 移入 callback │
└──────────────────────┴─────────────────────────────┴──────────────────────────────┘

25.8 这个设计为什么能工作

三个前提条件,缺一不可:

前提 是否满足 说明
单继承 QuoteSpi 只有一个基类,无多重继承。多继承下 this 指针需要偏移调整,offset 0 可能不是 vtable
无虚析构函数 XTP 的 QuoteSpi 析构函数不是 virtual。如果有虚析构,vtable 的前几个槽位会被编译器插入的 deleting destructor 占据,槽位编号全部偏移
MSVC x64 ABI 稳定 x64 下 MSVC 调用约定统一为 extern "C" 兼容格式。x86 下有 __stdcall/__thiscall 等差异,不能直接用

25.9 送牛奶比喻版

QuoteSpiHolder = 一张"地址登记表"

┌─────────────────────────────┐
│ 地址登记表                   │
│                             │
│ [vtable 速查表编号]          │ ← C++ DLL 只看这一行
│   全脂奶→左边格子            │   告诉送奶工: 照着这个速查表送货
│   脱脂奶→右边格子            │
│   酸奶→中间格子              │
│   ...共38种                  │
│                             │
│ [你的口味偏好]               │ ← Rust 代码只看这一行
│   Box<dyn 口味偏好>          │   你的 trait 实现
│   "全脂奶要、脱脂奶不要"     │
└─────────────────────────────┘

你(Rust)把这张表交给鲜奶公司(C++)存档。
鲜奶公司只关心第一行(速查表编号)——
送奶工按这个编号查表,找到对应的送奶流程。
至于第二行(你的口味偏好),送奶工不关心,
只有翻译员(trampoline)在收到奶后,
按你的偏好决定要不要通知你。

二十六、对照 XTP 2.2.50.8 官方头文件的校核

基于 XTP_API_20250806_2.2.50.8/bin/include/ 官方 C++ 头文件与 Rust 封装逐项对比。
注意:本项目的 Rust 封装是基于更早版本的 XTP DLL(可能为 1.2.x),本章发现的差异不代表 Rust 封装有 bug——它只是针对不同 DLL 版本。

26.1 ⚠️ XtpSpecificTicker 字段顺序与 C++ 头文件相反

C++ 头文件xquote_api_struct.h):

#pragma pack(8)

typedef struct XTPSpecificTickerStruct {
    XTP_EXCHANGE_TYPE exchange_id;    // offset 0, 4 bytes
    char ticker[XTP_TICKER_LEN];      // offset 4, 16 bytes
} XTPST;

Rust 封装quote_types.rs):

pub struct XtpSpecificTicker {
    pub ticker: [u8; XTP_TICKER_LEN],   // offset 0, 16 bytes
    pub exchange_id: u32,                // offset 16, 4 bytes
}
⚠️ 字段声明顺序相反!

C++ 内存: [exchange_id: 4B][ticker: 16B]     = 20 bytes
Rust 内存: [ticker: 16B]       [exchange_id: 4B] = 20 bytes  (同大小,顺序错)

该结构体在 OnSubMarketData 等回调中从 C++ 传入 Rust。
如果字段顺序与 C++ DLL 不匹配:
  - ticker 读到 exchange_id 的字节 → 乱码
  - exchange_id 读到 ticker 的前 4 字节 → 乱码

需要确认实际使用的 DLL 版本中 XTPST 的布局。
如果是新版本 → 修复: 交换 ticker 和 exchange_id 声明顺序。

26.2 struct packing 差异

头文件 实际 #pragma Rust 侧注释 实际行为
xtp_api_data_type.h pack(8) repr(C) x64 下自然 8 对齐,一致
xquote_api_struct.h pack(8) 注释说 pack(1) ⚠️ 注释有误,但 repr(C) 在 x64 与 pack(8) 一致
xtp_api_struct_common.h pack(8) repr(C) ✅ 128 bytes, offset 正确

pack(8)XTPRI 的影响

#pragma pack(8)
typedef struct XTPRspInfoStruct {
    int32_t error_id;              // offset 0, 4 bytes
    char error_msg[124];           // offset 4, 124 bytes
} XTPRI;                           // = 128 bytes

char[124] 对齐为 1,紧接 int32_t 后面从 offset 4 开始,总计 128 bytes。与 Rust repr(C) 一致。

26.3 CreateQuoteApi 签名差异

C++ 头文件 (2.2.50.8)

static QuoteApi *CreateQuoteApi(
    uint8_t client_id,
    const char *save_file_path,
    XTP_LOG_LEVEL log_level = XTP_LOG_LEVEL_DEBUG
    // 只有 3 个参数!
);

Rust 封装

type CreateQuoteApiFn = unsafe extern "C" fn(
    client_id: u8,
    save_file_path: *const c_char,
    log_level: i32,
    udpseq_output: bool,     // ← 多了一个参数
) -> *mut c_void;

在 2.2.50.8 中,SetUDPSeqLogOutPutFlag(bool) 替代了 CreateQuoteApiudpseq_output 参数。但旧版 DLL 可能确实是 4 参数——需按实际使用的 DLL 版本确认签名。

26.4 QuoteApi vtable 槽位差异

从 2.2.50.8 头文件中 QuoteApi 的虚函数声明顺序:

Slot  0: Release()
Slot  1: GetTradingDay()                    ← Rust 封装中无此槽位
Slot  2: GetApiVersion()                    ← Rust 中为 slot 1
Slot  3: GetApiLastError()                  ← Rust 中为 slot 2
Slot  4: SetUDPBufferSize(uint32_t)         ← 新增
Slot  5: RegisterSpi(QuoteSpi*)
Slot  6: SetHeartBeatInterval(uint32_t)
Slot  7: SetUDPRecvThreadAffinity(int32_t)
Slot  8: SetUDPRecvThreadAffinityArray(int32_t[], int32_t)
Slot  9: SetUDPParseThreadAffinity(int32_t)        ← 新增
Slot 10: SetUDPParseThreadAffinityArray(int32_t[], int32_t) ← 新增
Slot 11: SetUDPSeqLogOutPutFlag(bool)              ← 新增
Slot 12: SubscribeMarketData(...)
...
Slot  ?: LoginToRebuildQuoteServer(...)            ← 新增
Slot  ?: LogoutFromRebuildQuoteServer()            ← 新增
Slot  ?: RequestRebuildQuote(...)
⚠️ 如果使用 2.2.50.8 的 DLL,当前 Rust vtable 槽位全部错位:
GetTradingDay 在 GetApiVersion 前面,
SetUDPBufferSize 等 4 个新方法在 RegisterSpi 前面。
每个偏移都会导致函数调用跳转到错误的 DLL 函数 → crash。

本项目针对的是旧版 DLL,需要确认实际使用的 DLL 版本后重新映射。

26.5 QuoteSpi vtable 新增槽位

从 2.2.50.8 头文件中 QuoteSpi 的虚函数声明顺序,以下回调在 Rust 封装中缺失:

新增回调 (按 vtable 顺序):
  OnTickByTickLossRange(begin_seq, end_seq)    — 在 OnError 之后
  OnRebuildQuoteServerDisconnected(reason)     — 回补服务器断连
  OnSubscribeAllOptionOrderBook(...)           — 全市场期权订单簿
  OnUnSubscribeAllOptionOrderBook(...)
  OnSubscribeAllOptionTickByTick(...)
  OnUnSubscribeAllOptionTickByTick(...)

26.6 Rust 封装中缺失的 QuoteApi 方法

C++ 方法 说明
GetTradingDay() 获取当前交易日字符串
SetUDPBufferSize(uint32_t) UDP 缓冲区大小(MB,默认 64MB)
SetUDPParseThreadAffinity(int32_t) UDP 解析线程 CPU 绑定(已废弃)
SetUDPParseThreadAffinityArray(...) UDP 解析线程多核绑定
SetUDPSeqLogOutPutFlag(bool) 替代旧 udpseq_output 参数
LoginToRebuildQuoteServer(...) 登录回补服务器
LogoutFromRebuildQuoteServer() 登出回补服务器
QueryAllTickersPriceInfo() 查询所有合约最新价格(无交易所参数)

26.7 常量核对

常量 C++ 头文件 Rust 状态
XTP_VERSION_LEN 16 16
XTP_TICKER_LEN 16 16
XTP_TICKER_NAME_LEN 64 64
XTP_ERR_MSG_LEN 124 124
XTP_TRADING_DAY_LEN 9 缺失 ⚠️
XTP_LOCAL_ORDER_LEN 11 trader 侧 待核对
XTP_ACCOUNT_NAME_LEN 16 trader 侧 待核对
XTP_ORDER_EXCH_LEN 17 trader 侧 待核对
XTP_EXEC_ID_LEN 18 trader 侧 待核对

26.8 文档注释中的有价值信息

从官方头文件中提取的、前面章节未覆盖的要点:

  1. CreateQuoteApi 的 client_id:“相同的 client_id 只能保持一个 session 连接,后面的登录在前一个 session 存续期间,无法连接”

  2. SetUDPBufferSize:“默认大小和最小设置均为 64MB。此缓存大小单位为 MB,请输入 2 的次方数”

  3. LoginToRebuildQuoteServer:“回补服务器会在无消息交互后定时断线,请注意仅在需要回补数据时才保持连接,回补完成后请及时 logout”

  4. OnTickByTickLossRange:“此函数只有在逐笔发生丢包时才会有调用,如果丢包的上下限一致,表示仅丢失了一个包,注意此包仅为数据包,包含 1 个或者多个逐笔数据”

  5. OnRebuildMarketData / OnRebuildTickByTick:“此函数调用与 OnDepthMarketData / OnTickByTick 不在一个线程内”

  6. ~QuoteApi() 是 protected:不能直接用 delete,必须通过 Release() 虚函数释放。这解释了为什么没有虚析构函数槽位。

26.9 操作建议(按优先级)

🔴 P0 — 如果使用新版 DLL,必须立即处理:
  □ 确认实际使用的 DLL 版本
  □ 如果是 2.2.50.8 → 重新映射 QuoteApiVTable 全部槽位
  □ 如果是 2.2.50.8 → 补充 QuoteSpi 的新增回调(至少用 no-op 占位)
  □ 核实 CreateQuoteApi 函数签名(3 参数 vs 4 参数)
  □ 核实 XtpSpecificTicker 字段顺序

🟡 P1 — 功能补齐:
  □ 补充 GetTradingDay() — 有用的查询
  □ 补充 OnTickByTickLossRange — 丢包检测
  □ 补充 QueryAllTickersPriceInfo() — 查询接口

🟢 P2 — 增强功能:
  □ UDP 解析线程 CPU 亲和性
  □ SetUDPSeqLogOutPutFlag (替代旧 udpseq_output)
  □ 回补服务器登录/登出

🔵 P3 — 文档修正:
  □ 修正 packing 注释(pack(8) 不是 pack(1))
  □ 补充 XTP_TRADING_DAY_LEN 常量

二十七、手写 mini-XTP:用 Rust 模拟 DLL 回调全链路

项目路径:C:\Users\songroom\Desktop\sim-xtp\
目的:用极简代码复现 XTP DLL 的内部回调机制,理解 vtable、RegisterSpi、后台线程回调的全过程。

27.1 项目结构

sim-xtp/
├── sim_xtp_dll/                    ← "XTP DLL" 角色 (cdylib)
│   ├── Cargo.toml                  crate-type = ["cdylib"]
│   └── src/lib.rs                  DLL 内部实现
│
└── sim_xtp_client/                 ← "Rust 封装层" 角色 (bin)
    ├── Cargo.toml                  依赖 libloading
    └── src/main.rs                 客户端调用代码

27.2 架构图

┌─────────────────────────────────────────────────────────────────┐
│  sim_xtp_client (外部调用者)                                      │
│                                                                 │
│  ① libloading 动态加载 DLL                                       │
│  ② 构造 QuoteSpiVTable { on_market_data, ... }                  │
│  ③ create() → handle                                            │
│  ④ register_spi(handle, &vtable) ──────────────┐                │
│  ⑤ login(handle) → DLL 启动后台线程              │                │
│  ⑥ subscribe(handle, "600000")                  │                │
│                                                  │                │
│  ╔═══════════════════════════════════════════════╪═════════════╗ │
│  ║ DLL 后台线程                   持有 vtable →  │              ║ │
│  ║                                              ▼              ║ │
│  ║  loop {                                       QuoteSpiVTable│ │
│  ║    let vt = &*vtable_ptr;                     ┌────────────┐║ │
│  ║    vt.on_market_data(ticker, price, vol) ──→  │ 函数指针    │║ │
│  ║  }                                            │ 函数指针    │║ │
│  ╚════════════════════════════════════════════════│ 函数指针    │╚═╝
│                                                   └────────────┘
│                                                          │
│  ⑦ 回调触发: my_on_market_data() 被执行  ←──────────────┘
│     println!("📊 行情推送...")
│
│  ⑧ logout(handle) → 停止推送
│  ⑨ release(handle) → 停止线程 + 回收
└─────────────────────────────────────────────────────────────────┘

27.3 DLL 侧核心代码 (sim_xtp_dll/src/lib.rs)

对外暴露的 SPI 虚函数表
/// 外部调用者构造此结构体,填入回调函数指针,通过 RegisterSpi 传入 DLL
#[repr(C)]
pub struct QuoteSpiVTable {
    pub on_disconnected: Option<unsafe extern "C" fn(reason: c_int)>,
    pub on_market_data:  Option<unsafe extern "C" fn(ticker: *const c_char, price: f64, volume: i64)>,
    pub on_subscribe_ack: Option<unsafe extern "C" fn(ticker: *const c_char, success: bool)>,
}
DLL 内部存储 SPI 指针
struct SimQuoteApi {
    spi_vtable: Mutex<Option<*const QuoteSpiVTable>>,  // ← 外部传入的 vtable
    spi_context: Mutex<Option<*mut c_void>>,            // ← 外部上下文指针
    logged_in: AtomicBool,
    running: AtomicBool,
    // ...
}

// 裸指针不是 Send/Sync,需要手动声明(与 RawQuoteApi 同理)
unsafe impl Send for SimQuoteApi {}
unsafe impl Sync for SimQuoteApi {}
RegisterSpi 的实现
#[no_mangle]
pub unsafe extern "C" fn RegisterSpi(
    handle: *mut c_void,
    spi_vtable: *const QuoteSpiVTable,
    spi_context: *mut c_void,
) {
    let api = &*(handle as *const Arc<SimQuoteApi>);
    *api.spi_vtable.lock().unwrap() = Some(spi_vtable);   // 存起来
    *api.spi_context.lock().unwrap() = Some(spi_context);
}
后台线程回调
fn worker_loop(&self) {
    loop {
        // 读 vtable 指针
        let vtable = self.spi_vtable.lock().unwrap();
        if let Some(vtable_ptr) = *vtable {
            let vt = &*vtable_ptr;            // 解引用恢复为结构体
            if let Some(on_md) = vt.on_market_data {
                on_md(ticker_ptr, price, volume);  // 回调外部代码
            }
        }
    }
}

27.4 客户端侧核心代码 (sim_xtp_client/src/main.rs)

步骤 ③④:构造 vtable 并注册
// 手工构造虚函数表 — 等同于 C++ 中继承 QuoteSpi 并覆写虚函数
let vtable = Box::new(QuoteSpiVTable {
    on_disconnected: Some(my_on_disconnected),
    on_market_data:  Some(my_on_market_data),
    on_subscribe_ack: Some(my_on_subscribe_ack),
});

// 关键: 把 vtable 指针传给 DLL
unsafe { register_spi(handle, &*vtable, std::ptr::null_mut()) };
// DLL 现在持有 vtable 指针 → 后续回调通过它触发
步骤 ⑦:回调被触发
unsafe extern "C" fn my_on_market_data(
    ticker: *const c_char, price: f64, volume: i64,
) {
    let ticker_str = std::ffi::CStr::from_ptr(ticker).to_string_lossy();
    println!("📊 行情推送 | ticker={} price={:.2} volume={}", ticker_str, price, volume);
}

27.5 与真实 XTP 的对照

┌────────────────────────────────┬──────────────────────────────────┐
│  sim_xtp                        │  真实 XTP                         │
├────────────────────────────────┼──────────────────────────────────┤
│ QuoteSpiVTable                 │ 编译器自动生成的 QuoteSpi 虚函数表  │
│ on_market_data 函数指针          │ OnDepthMarketData 虚函数          │
│ RegisterSpi(handle, &vtable)   │ QuoteApi::RegisterSpi(QuoteSpi*)  │
│ DLL 后台线程每秒推送              │ DLL 后台线程收到行情即推送         │
│ vt.on_market_data(...) 调用     │ m_spi->OnDepthMarketData(...)     │
│ 回调函数是 Rust fn              │ 回调函数是 C++ 虚函数              │
│ vtable 由调用者手工构造           │ vtable 由编译器自动生成            │
│ 裸指针 + unsafe impl Send       │ C++ 裸指针,隐式线程安全            │
│ handle → Arc<SimQuoteApi>       │ QuoteApi* → C++ 对象              │
│ Release() → Box::from_raw       │ Release() → delete this           │
└────────────────────────────────┴──────────────────────────────────┘

27.6 运行验证

cd sim-xtp\sim_xtp_dll && cargo build --release
cd ..\sim_xtp_client && cargo run --release

输出:

╔════════════════════════════════════════════════════════╗
║     sim_xtp — XTP 回调机制演示                         ║
╚════════════════════════════════════════════════════════╝

=== ① 加载 DLL ===    ✅
=== ② 查找导出函数 === ✅
=== ③ 构造 SPI 虚函数表 === ✅  (vtable 地址: 0x208d65b11f0)
=== ④ 创建 API 对象 === ✅  (handle: 0x208d65ada90)
=== ⑤ 注册 SPI ===     ✅  (DLL 持有 vtable 指针)
=== ⑥ 登录 ===         ✅  (后台线程已启动)
=== ⑧ 订阅行情 ===     ✅  (订阅成功: 600000)

📊 行情推送 | ticker=000001   price=   10.01 volume=   10001
📊 行情推送 | ticker=000858   price=   24.52 volume=   10002
📊 行情推送 | ticker=600000   price=   11.87 volume=   10003
   ⚠️  价格突破 12.50!
...(共 29 条)...

=== ⑩ 登出 === ✅
=== ⑪ 释放资源 === ✅ (API + vtable 均释放,无 crash)

27.7 这个项目教会了你什么

知识点 体现在哪里
vtable 到底是什么 QuoteSpiVTable — 就是一个函数指针数组,offset 0 = 第一个虚函数
RegisterSpi 做了什么 把外部的函数指针表地址存到 DLL 内部的一个变量里
DLL 如何回调外部代码 let vt = &*vtable_ptr; vt.on_market_data(...) — 解引用 + 函数指针调用
为什么 vtable 槽位不能少 如果删掉一个字段,后面所有函数指针的偏移全错
为什么需要 unsafe impl Send 裸指针 *const QuoteSpiVTable 不是 Send,但实际使用是安全的
Release 的必要性 停止后台线程 + 回收 Arc<SimQuoteApi>,否则线程访问已释放内存
C++ 调用 Rust 的完整路径 DLL 后台线程 → 函数指针 → Rust fn → CStr 解析 → println!

二十八、指针处理原则与数据拷贝全景

28.1 指针所有权总览

封装中涉及的每一根指针,都有明确的所有者:

┌──────────────────────────────────────────────────────────────────┐
│  指针                         │ 所有者   │ 生命周期              │
├───────────────────────────────┼─────────┼──────────────────────┤
│  obj: *mut c_void (QuoteApi*) │ C++ DLL │ 从 CreateQuoteApi     │
│                               │         │ 到 Release()          │
│  vtable: *const QuoteApiVTable│ DLL 内存│ 从 DLL 加载到卸载      │
│  _library: Arc<Library>       │ Rust    │ refcount 归零时        │
│  QuoteSpiHolder (Box)         │ Rust    │ QuoteApi.spi drop 时  │
│  spi_holder 传给 C++ 的裸指针  │ C++ DLL │ RegisterSpi 到 Release │
│  CString 内部 buffer          │ Rust    │ 函数栈帧结束时          │
│  CString.as_ptr() 返回的指针   │ 借自 Rust│ FFI 调用期间有效       │
│  Vec<CString> + Vec<*mut c_char>│ Rust │ 借出 ptrs 需要 cs 存活  │
│  XTPRI* (回调传入)            │ C++ DLL │ 回调函数执行期间        │
│  XTPMD* (回调传入)            │ C++ DLL │ 回调函数执行期间        │
│  const char* (返回值)         │ C++ DLL │ 拷贝后立即放弃引用      │
│  int64_t[] + count (回调)     │ C++ DLL │ 回调函数执行期间        │
└──────────────────────────────────────────────────────────────────┘

28.2 核心原则(五条)

原则 1:Rust 不持有 C++ 对象的所有权

// RawQuoteApi 只存裸指针,不负责释放:
obj: *mut c_void,   // ← 裸指针,只存不看

// 释放由 C++ DLL 自己的 Release() 虚函数完成:
impl Drop for RawQuoteApi {
    fn drop(&mut self) {
        unsafe { (self.vt().release)(self.obj); }  // ← 请 DLL 自己释放
    }
}

为什么:C++ 对象的 new 走 DLL 的 CRT 堆,Rust 的全局分配器无法释放。必须由 DLL 自己释放。

原则 2:从 C++ 拿到指针后,立即拷贝到 Rust 拥有所有权的类型

从 DLL 返回的指针 → Rust 处理策略:

*const c_char    → CStr::from_ptr() → .to_string_lossy().into_owned() → String
                   不持有引用         容错处理                          堆上拷贝
*const c_char    → CStr::from_bytes_until_nul() → .to_string_lossy() → String
                   结构体里的 char[16]                             堆上拷贝
*mut XtpRspInfo  → .as_ref() → .copied() → XtpRspInfo (128B 栈上 Copy)
                   安全解引用    逐字节拷贝   不再依赖 DLL 内存
*mut XTPMD       → .as_ref() → &XtpMarketData
                   安全解引用    借用,仅回调期间有效
                   不拷贝(性能考虑,高频行情每秒万次)
场景 是否拷贝 原因
GetApiVersion 的 const char* ✅ 立即 .into_owned() DLL 可能卸载,不能持有引用
回调中的行情数据 XTPMD* ❌ 借用 &XtpMarketData 高频,拷贝开销大。回调期间 DLL 保证有效
回调中的错误信息 XTPRI* .copied()(栈拷贝) 128 bytes,Copied 几乎无开销
结构体里的 ticker[16] from_cstring_bytes() 固定缓冲区,解析时拷贝

原则 3:传给 C++ 的指针,Rust 侧必须保证在调用期间存活

// ✅ 正确: c 在整个 FFI 调用期间存活
let c = CString::new("hello").unwrap();
unsafe { c_func(c.as_ptr()) };  // ← 调用时 c 还活着
// c 在函数结束时 drop

// ❌ 错误: 临时值在 FFI 调用前就被 drop
unsafe { c_func(CString::new("hello").unwrap().as_ptr()) };
//             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//             临时 CString,在这一行结束时销毁
//             as_ptr() 返回的指针立即悬垂 → 💥
// ✅ 正确: Vec<CString> 和 Vec<*mut c_char> 同时返回,保证指针有效
fn sub_cstrs(&self, tickers: &[&str])
    -> Result<(Vec<CString>, Vec<*mut c_char>), XtpError>
{
    let cs = tickers.iter()
        .map(|t| CString::new(*t).map_err(...))
        .collect::<Result<Vec<_>, _>>()?;
    let ptrs = cs.iter()
        .map(|t| t.as_ptr() as *mut c_char)  // ptrs 指向 cs 的内部 buffer
        .collect();
    Ok((cs, ptrs))  // ← 必须同时返回!cs 保持 ptrs 有效
}

原则 4:回调中收到的指针,只在回调函数体内有效

// ❌ 错误: 将回调中的指针 escape 出去
fn on_depth_market_data(&self, md: &XtpMarketData, ...) {
    // 把引用存到全局变量——危险!
    *self.latest_md.lock().unwrap() = Some(md);  // md 回调返回后失效!
}

// ✅ 正确: 立即拷贝需要的数据
fn on_depth_market_data(&self, md: &XtpMarketData, ...) {
    let owned = *md;  // XtpMarketData 是 Copy,逐字节拷贝
    self.tx.send(owned);  // 发送拷贝,安全
}

原则 5:DLL 的生命周期必须覆盖所有指向 DLL 内部的指针

时间线:
  Library::new() ───────────────────────────── Library::drop()
  │                                                │
  │  obj/vtable 指针有效                            │  obj/vtable 悬垂
  │                                                │
  ├─ CreateQuoteApi() → obj                        │
  ├─ 所有 FFI 调用使用 obj/vtable                   │
  └─ Release() ← Drop::drop() 在 Library::drop 之前 │

保证机制: _library 字段声明在最前 → 最后 drop
         Drop::drop() 在所有字段 drop 之前执行

28.3 数据拷贝全景表

┌──────────────────────────────────────┬──────────┬──────────┬──────────┐
│  数据路径                             │ 方向      │ 拷贝次数  │ 拷贝量    │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ CreateQuoteApi: save_path            │ Rust→C++ │ 1 次      │ N bytes   │
│   &str → CString::new → .as_ptr()   │          │ (CString) │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ Login: ip/user/pass/local_ip         │ Rust→C++ │ 4 次      │ N bytes   │
│   &str → CString × 4                │          │           │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ Subscribe: ticker 数组               │ Rust→C++ │ N 次      │ N × 16B  │
│   &[&str] → Vec<CString>            │          │ (CString) │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ GetApiVersion: 版本号                │ C++→Rust │ 1 次      │ N bytes   │
│   *const c_char → CStr → String     │          │ (String)  │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ GetApiLastError: 错误信息            │ C++→Rust │ 1 次      │ 128 bytes │
│   *mut XTPRI → .copied()            │          │ (栈 Copy) │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ 回调: 行情数据                       │ C++→Rust │ 0 次      │ 0         │
│   *mut XTPMD → .as_ref() → &T       │          │ (借用)    │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ 回调: 买一卖一队列                   │ C++→Rust │ 0 次      │ 0         │
│   *mut i64 + count → from_raw_parts │          │ (切片)    │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ 结构体 ticker/name 字段              │ C++→Rust │ 1 次      │ N bytes   │
│   char[16] → from_cstring_bytes     │          │ (String)  │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ RequestRebuildQuote: 参数            │ Rust→C++ │ 0 次      │ 0         │
│   &XtpQuoteRebuildReq → *const _    │          │ (借用)    │           │
├──────────────────────────────────────┼──────────┼──────────┼──────────┤
│ SetUDPThreadAffinity: CPU 数组       │ Rust→C++ │ 1 次      │ N × 4B   │
│   &[i32] → .to_vec() → .as_mut_ptr()│          │ (Vec)     │           │
└──────────────────────────────────────┴──────────┴──────────┴──────────┘

28.4 零拷贝路径(高频路径的性能保证)

行情回调是每秒上万次的路径,不能有任何额外的堆分配:

// 零拷贝路径:
unsafe extern "C" fn tramp_on_depth_market_data(
    this: *mut ThisFn,           // 0 拷贝 — 直接解引用恢复 holder
    market_data: *mut XtpMarketData, // 0 拷贝 — as_ref() 借用
    bid1_qty: *mut i64,          // 0 拷贝 — from_raw_parts 切片
    bid1_count: c_int,
    ...
) {
    guard(|| {
        let h = &*(this as *const QuoteSpiHolder);  // 0: 恢复引用
        let md = &*market_data;                      // 0: 恢复引用

        // 这整条路径唯一的"分配"是 send 到 channel 时的 Arc 引用计数
        // XtpMarketData 是 Copy,send 按值传递 → 栈拷贝 736 bytes
        // 无堆分配 ✅
        h.callback.on_depth_market_data(md, ...);
    });
}

零拷贝的三个前提

前提 保证方式
数据在回调期间不被 DLL 释放 XTP 文档保证 + Release() 先于所有回调线程停止
XtpMarketDataCopy #[repr(C)] + derive(Copy, Clone)
切片不越界 c_slice() 的 null + count≤0 守卫

28.5 容易出错的指针操作清单

□ Rust→C++ 字符串
  → CString 生命周期是否覆盖 FFI 调用?
  → 有没有用临时 CString::new(...).as_ptr()?
  → 数组场景中 cs (Vec<CString>) 是否比 ptrs (Vec<*mut c_char>) 活得久?

□ C++→Rust 返回值
  → const char* 是否立即 .to_string_lossy().into_owned()?
  → *mut XTPRI 的 null 路径是否用 .copied().unwrap_or(XtpRspInfo::OK)?
  → 回调中的引用(&XtpMarketData)是否 escape 到回调函数体外?

□ DLL 生命周期
  → _library 是否在 vtable/obj 之前声明(保证最后 drop)?
  → Release() 是否在 Library::drop() 之前执行?

□ 裸指针的 Send/Sync
  → 含裸指针的 struct 是否手动 unsafe impl Send/Sync?
  → 手动 impl 的前提条件是否满足(DLL 线程安全)?

□ 数组传递
  → from_raw_parts 的 count 是否来自 C++(信任但验证 null/≤0)?
  → 是否检查了 ptr.is_null() || count <= 0?

□ 所有权
  → Rust Box 分配的内存是否由 Rust 释放(不能传 delete 给 C++)?
  → C++ new 分配的内存是否由 C++ Release() 释放(不能传 Box::drop)?

---

你说的非常准确。来看一段 XTP 行情接收代码:

```cpp
// 你定义了一个"函数"
void MyQuoteSpi::OnDepthMarketData(XTPMD *market_data, ...) {
    std::cout << "收到行情: " << market_data->last_price << std::endl;
}

// 但你从来不会这样调用它:
// mySpi.OnDepthMarketData(data, ...);  ← 你永远不会写这行代码

你的困惑完全正确OnDepthMarketData 是一个函数,但你从来不主动调用它。它被"别人"调用了——每次交易所推送一笔行情数据,这个函数就自动被执行一次。

这就是**回调(Callback)**的核心特征:你把一个函数"交给"底层系统,当特定事件发生时,底层系统替你调用它。


二十九、XTP回调:以QuoteSpi为例

QuoteSpi 是 XTP 行情 API 中的一个 C++ 抽象基类(SPI = Service Provider Interface,服务提供者接口)。官方文档明确定义:

QuoteSpi类提供了行情相关的回调接口,用户需要继承该类并重写这些接口,以获取响应数据。

namespace XTP {
    namespace API {
        class QuoteSpi {
        public:
            // 这些全是 virtual 函数,默认是空实现 {}
            virtual void OnDisconnected(int reason) {};
            virtual void OnError(XTPRI *error_info) {};
            virtual void OnSubMarketData(XTPST *ticker, XTPRI *error_info, bool is_last) {};
            virtual void OnDepthMarketData(XTPMD *market_data, ...) {};
            virtual void OnTickByTick(XTPTBT *tbt_data) {};
            virtual void OnOrderBook(XTPOB *order_book) {};
            // ... 还有 30+ 个类似的虚函数
        };
    }
}

QuoteSpi 中包含了所有行情相关的回调接口(约 35 个),分成三大类:

类别 典型函数 触发时机
应答回调 OnSubMarketDataOnQueryAllTickersFullInfo 你发起请求后,服务器确认/拒绝时的应答
数据推送回调 OnDepthMarketDataOnTickByTickOnOrderBook 实时行情数据持续推送
事件通知回调 OnDisconnectedOnError 连接断开、发生错误等事件

29.1 设计模式:好莱坞原则

回调的本质是观察者模式(Observer Pattern),遵循"好莱坞原则":

“Don’t call us, we’ll call you.”(不要打电话给我们,我们会打给你。)

用 XTP 的场景来解释:

  • 你(上层应用):想获取行情数据
  • XTP SDK(底层系统):负责和交易所/券商服务器通信

正常的函数调用是你主动调用 SDK(如 SubscribeMarketData),而回调的方向正好相反——SDK 主动调用你的函数

29.2 注册机制:RegisterSpi

在你使用回调之前,必须先"告诉"SDK 你的回调对象是谁。这就是 RegisterSpi 的作用:

// 1. 创建 QuoteApi 实例(SDK 的核心对象)
QuoteApi* api = QuoteApi::CreateQuoteApi(1, "./", XTP_LOG_LEVEL_DEBUG);

// 2. 创建你自己的回调对象
MyQuoteSpi* spi = new MyQuoteSpi();   // MyQuoteSpi 继承自 QuoteSpi

// 3. 注册——把 spi 的指针"交给" api
api->RegisterSpi(spi);

RegisterSpi 内部做了什么?

RegisterSpi(QuoteSpi *spi) 内部:
    this->m_pSpi = spi;   // SDK 保存了你的对象指针

此后,SDK 内部任何地方都可以通过 m_pSpi 来调用你的回调函数:
    m_pSpi->OnDepthMarketData(market_data, ...);
    m_pSpi->OnDisconnected(reason);
    m_pSpi->OnSubMarketData(ticker, error_info, is_last);

这就是回调的关键:SDK 持有一个指向你对象的指针(QuoteSpi*),在合适的时机通过这个指针调用你重写的虚函数。

29.3 完整的数据流

下面以"订阅行情并接收数据"为例,展示从发起请求到收到数据的完整链路:

时间线 ────────────────────────────────────────────────────────────►

【你的代码线程】                    【XTP SDK 内部线程】
                                        │
  1. api->Login(...)                    │
     │                                  │
     └──► 同步阻塞,TCP连接服务器 ──────► 建立连接
          返回 0(登录成功)             │
                                        │
  2. api->SubscribeMarketData(          │
       {"600000","600001"}, 2, SH)      │
     │                                  │
     └──► 发送订阅请求 ────────────────► 发送到服务器
          立即返回 0(异步)            │
                                        │
  3. (你继续做其他事)                 │ 收到服务器应答
                                        ├─► spi->OnSubMarketData("600000", ok, false)
                                        ├─► spi->OnSubMarketData("600001", ok, true)
                                        │
                                        │ 交易所推送行情...
                                        ├─► spi->OnDepthMarketData(600000 最新价=10.50, ...)
                                        ├─► spi->OnDepthMarketData(600001 最新价=25.30, ...)
                                        ├─► spi->OnDepthMarketData(600000 最新价=10.52, ...)
                                        ├─► spi->OnDepthMarketData(600001 最新价=25.28, ...)
                                        │   ...持续推送,每秒可能数千次...

关键点

  1. SubscribeMarketData异步的——函数立即返回,不等待服务器应答
  2. 订阅的应答(成功/失败)通过 OnSubMarketData 异步回调通知你
  3. 真正的行情数据通过 OnDepthMarketData 持续推送,你完全无法控制推送的频率和时机
  4. 你没有调用任何回调函数,全是 SDK 在合适的时机调用你的函数

29.4 为什么回调函数标注了 virtual?

因为 C++ 的多态机制。看这个调用过程:

// SDK 内部代码(伪代码)
class QuoteApiImpl : public QuoteApi {
    QuoteSpi* m_pSpi;  // 持有你注册的指针

    void onDataReceived(XTPMD* data) {
        // 这里 m_pSpi 的静态类型是 QuoteSpi*
        // 但实际指向的是 MyQuoteSpi 对象(你定义的子类)
        // 由于 OnDepthMarketData 是 virtual 的,
        // C++ 虚函数表会确保调用到你的重写版本
        m_pSpi->OnDepthMarketData(data, ...);
    }
};

你继承 QuoteSpi 并重写虚函数时,C++ 的**虚函数表(vtable)**保证了即使 SDK 只持有 QuoteSpi* 类型的指针,实际调用的仍然是你 MyQuoteSpi 中重写的版本。

虚函数表原理

MyQuoteSpi 对象的内存布局:
┌──────────────────────┐
│ vtable_ptr ──────────┼──→ ┌───────────────────────┐
│ (指向虚函数表)        │    │ &MyQuoteSpi::OnDisc.. │ ← slot 0
├──────────────────────┤    │ &MyQuoteSpi::OnError  │ ← slot 1
│ 成员变量...           │    │ &MyQuoteSpi::OnSub... │ ← slot 2
└──────────────────────┘    │ &MyQuoteSpi::OnDepth..│ ← slot 3
                            │ ...                   │
                            └───────────────────────┘

SDK 调用: m_pSpi->OnDepthMarketData(data)
           ↓
实际执行: vtable[slot_3](this, data)  →  MyQuoteSpi::OnDepthMarketData(data)

XTP 的回调分两种:应答回调数据推送回调,它们的触发逻辑完全不同。

29.5 应答回调(Request-Response Callback)

你主动发起请求 → 服务器处理 → 异步回调通知结果。

你的代码:                    SDK 内部:                   回调:
api->SubscribeMarketData()    发送请求到服务器
  └── 立即返回 0              ...等待...
                              收到服务器应答
                              每个合约单独应答 ──► OnSubMarketData(ticker, error, is_last)
                                                   is_last=true 表示所有合约应答完毕

特点

  • 由你主动发起的操作触发(订阅、查询等)
  • 有明确的"请求-应答"对应关系
  • is_last 参数标志着一批请求是否全部应答完成
  • 如果你订阅了 2 只股票,OnSubMarketData 会被回调 2 次(每只股票一次)

29.6 数据推送回调(Push Notification Callback)

你订阅后,数据持续推送到你的回调函数,你无需也不能主动拉取。

你的代码:                    SDK 内部:                   回调:
api->SubscribeMarketData()    发送订阅请求
  └── 立即返回 0              收到 OnSubMarketData 应答...
                              交易所实时推送行情数据 ──► OnDepthMarketData(data1)
                                                        OnDepthMarketData(data2)
                                                        OnDepthMarketData(data3)
                                                        ...无限持续...

特点

  • 不由你的一次请求直接触发,而是由交易所的数据流持续驱动
  • 推送频率取决于市场活跃度(盘中每秒可能成百上千次)
  • 你必须尽快处理并返回,否则会堵塞后续数据

三十、底层线程模型

这是理解回调最关键的部分:回调函数运行在哪个线程?

30.1 XTP SDK 的内部线程架构

┌─────────────────────────────────────────────────────────┐
│                    你的主线程                             │
│  CreateQuoteApi() → RegisterSpi() → Login()              │
│  → SubscribeMarketData() → 进入事件循环(如 sleep)       │
│                                                          │
│  (你的主线程不需要做任何事,甚至可以 sleep)              │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                XTP SDK 内部线程(不可见)                  │
│                                                          │
│  ┌──────────────┐    ┌──────────────┐                   │
│  │ 网络接收线程  │    │ 数据解析线程  │                   │
│  │ (UDP/TCP)    │───►│ (Parse)      │                   │
│  │ 从socket收包  │    │ 解析为结构体  │                   │
│  └──────────────┘    └──────┬───────┘                   │
│                              │                           │
│                              ▼                           │
│                    ┌──────────────────┐                  │
│                    │ 回调分发          │                  │
│                    │ m_pSpi->OnXXX()  │                  │
│                    └──────────────────┘                  │
│                              │                           │
│              ┌───────────────┼───────────────┐           │
│              ▼               ▼               ▼           │
│   OnDepthMarketData()  OnTickByTick()  OnOrderBook()     │
│   (在你的回调函数里执行)                                │
└─────────────────────────────────────────────────────────┘

核心事实OnDepthMarketData 等回调函数是在 SDK 的内部线程中执行的,不是在你的主线程中执行的。

官方文档明确指出:

使用UDP接收行情时,可设置接收行情线程绑定的CPU(SetUDPRecvThreadAffinityArray)
使用UDP接收行情时,可设置解析行情线程绑定的CPU(SetUDPParseThreadAffinityArray)

这说明 SDK 内部至少有两个独立线程:一个负责收包,一个负责解析和回调。

关于回调线程的具体分布,官方文档进一步说明:

OnDepthMarketData在使用UDP的时候可能是两个线程,通常对于一只股票来说,第一个行情快照是TCP线程,后续的都是UDP线程。跟市场无关,跟是否在一个组播组有关。

另外,回补数据的回调明确在独立线程中:

回补的逐笔行情数据 OnRebuildTickByTick,此函数调用与OnTickByTick不在一个线程内
回补的快照行情数据 OnRebuildMarketData,此函数调用与OnDepthMarketData不在一个线程内

30.2 这意味着什么?

// 你的回调函数
void MyQuoteSpi::OnDepthMarketData(XTPMD *market_data, ...) {
    // ⚠️ 这段代码运行在 SDK 的内部线程中,不是你的主线程!

    // 如果你在这里做耗时操作:
    // 1. 计算复杂指标(如 100 周期均线)         ← 危险
    // 2. 写入数据库(同步 I/O)                   ← 危险
    // 3. 发送网络请求                             ← 危险
    // 4. std::cout 大量输出                       ← 危险

    // 正确做法:尽快保存数据,交给自己的处理线程
    m_dataQueue.push(copyMarketData(market_data));  // 原子操作,快速返回
}

官方文档反复强调:

需要快速返回,否则会堵塞后续消息,当堵塞严重时,会触发断线。

在Spi回调函数中为何不很快返回有可能导致断线?
当用户在Spi回调函数中处理过慢,会导致数据接收缓冲区被填满,服务器无法向客户端发送数据,此时会触发断线。

如果回调函数执行太久(比如 100ms),数据就会在缓冲区堆积,最终缓冲区满 → 丢包 → 断线。

30.3 正确的回调处理模式

// 模式一:生产者-消费者队列
class MyQuoteSpi : public QuoteSpi {
    std::shared_ptr<ThreadSafeQueue<MarketData>> m_queue;  // 线程安全队列

    void OnDepthMarketData(XTPMD *market_data, ...) override {
        // 在 SDK 线程中:只做浅拷贝,快速返回(< 1微秒)
        MarketData copy;
        copy.ticker = market_data->ticker;
        copy.last_price = market_data->last_price;
        copy.volume = market_data->qty;
        // ...
        m_queue->push(copy);  // 原子操作
    }
};

// 你的独立处理线程
void processingThread() {
    while (running) {
        MarketData data = m_queue->pop();  // 阻塞等待
        // 在这里做任何耗时操作都没问题
        calculateIndicators(data);
        saveToDatabase(data);
        sendSignal(data);
    }
}

三十一、同步 vs 异步:Login 和 SubscribeMarketData 的区别

XTP API 中有一个重要的区分:

接口 执行方式 返回值含义
Login() 同步阻塞 返回值直接告诉你登录是否成功
Logout() 同步阻塞 返回值直接告诉你登出是否成功
SubscribeMarketData() 异步 返回值只告诉你"请求是否成功发出",真正的结果在 OnSubMarketData 回调中
QueryAllTickers() 异步 返回值只告诉你查询请求是否发出,结果在 OnQueryAllTickers 回调中
InsertOrder()(交易API) 异步 返回值是订单编号,订单状态在 OnOrderEvent 回调中

官方文档明确:

API中登录Login、Logout这类接口为同步阻塞式,当函数返回后,可以视为已经登录成功、登出成功,即可进行后续操作。其余所有接口均为异步的。

这就是为什么你需要回调:

  • 同步接口 → 返回值直接告诉你结果,不需要回调
  • 异步接口 → 返回值只表示请求发出,真正结果通过回调通知

三十二、完整代码示例:从初始化到接收行情

// ============ 第一步:定义回调类 ============
class MyQuoteSpi : public XTP::API::QuoteSpi {
public:
    void OnDisconnected(int reason) override {
        // SDK 内部线程调用
        std::cout << "行情断开,原因:" << reason << std::endl;
        // 在此重连...
    }

    void OnSubMarketData(XTPST *ticker, XTPRI *error_info, bool is_last) override {
        // SDK 内部线程调用
        if (error_info && error_info->error_id != 0) {
            std::cout << ticker->ticker << " 订阅失败: " << error_info->error_msg << std::endl;
        } else {
            std::cout << ticker->ticker << " 订阅成功" << std::endl;
        }
        if (is_last) {
            std::cout << "所有合约订阅应答完毕" << std::endl;
        }
    }

    void OnDepthMarketData(XTPMD *market_data, int64_t bid1_qty[], ...) override {
        // ⚡ SDK 内部线程调用 —— 必须快速返回!
        // 将数据放入队列或直接处理(处理要极快)
        processQuickly(market_data);
    }
};

// ============ 第二步:创建 API 和注册回调 ============
int main() {
    // 创建 QuoteApi(SDK 核心对象)
    XTP::API::QuoteApi* api = XTP::API::QuoteApi::CreateQuoteApi(
        1,           // client_id
        "./",        // 日志路径
        XTP_LOG_LEVEL_DEBUG
    );

    // 创建你自己的回调对象
    MyQuoteSpi* spi = new MyQuoteSpi();

    // 注册回调 —— 把 spi 的指针"注入"到 api 中
    api->RegisterSpi(spi);

    // 设置心跳(必须在 Login 前)
    api->SetHeartBeatInterval(15);

    // ============ 第三步:登录(同步) ============
    int ret = api->Login("192.168.1.100", 6001, "user", "pass",
                         XTP_PROTOCOL_TCP, "192.168.1.50");
    if (ret != 0) {
        // Login 是同步的,这里直接知道结果
        std::cout << "登录失败" << std::endl;
        return -1;
    }
    // 登录成功,此时 SDK 内部已启动网络线程

    // ============ 第四步:订阅行情(异步) ============
    char* tickers[] = { "600000", "600001" };
    ret = api->SubscribeMarketData(tickers, 2, XTP_EXCHANGE_SH);
    // ret==0 只表示请求成功发送,不表示订阅成功
    // 真正的订阅结果在 OnSubMarketData 中异步回调

    // ============ 第五步:等待回调 ============
    // 你的主线程不需要轮询,SDK 内部线程会自动调用回调
    // 主线程可以做其他事,或者简单保持存活
    while (true) {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        // 此时 SDK 的内部线程正在疯狂调用 OnDepthMarketData...
    }

    // 退出时清理
    api->Logout();
    api->Release();
    return 0;
}

三十三、TCP vs UDP 连接方式下的回调差异

维度 TCP 连接 UDP 连接(Level2)
连接方式 普通快递,每趟签字确认 无人机空投,不等回执
断线后 需重新订阅行情 UDP 组播不受 TCP 断连影响,可选择性重连
数据接收 全量接收后筛选 无论是否订阅,行情全接收后由本地 API 筛选
缓冲区 默认大小 需通过 SetUDPBufferSize() 预设(推荐 256MB 或 512MB)
线程控制 无需绑核 可通过 SetUDPRecvThreadAffinityArray() 绑核优化
丢包 自动补发 丢包需主动请求回补(RequestRebuildQuote)

官方文档说明:

如果连接的是UDP行情服务器,无论是否订阅,都是行情全接收后再本地Api筛选过滤。
使用UDP行情服务器时,如果本地缓存满了,会引发丢包。


三十四、常见疑问解答

34.1 为什么我不能在回调里做耗时操作?

时间 →
SDK收包线程:[收包1][收包2][收包3][收包4][收包5]...
回调执行:   [OnDepthMarketData(包1)──────────────────────────────────]
                                 ↑
                    你的回调处理了 200ms
                    包2、3、4、5 在缓冲区等待...

缓冲区大小有限(UDP 默认 64MB),一旦填满:
    → 新数据无法写入缓冲区
    → 触发丢包
    → 心跳超时
    → OnDisconnected() 被调用
    → 连接断开

34.2 多个回调函数在同一个线程吗?

不完全是。 不同数据源的回调可能在不同线程中执行:

  • OnDepthMarketData:通常在 UDP 解析线程中(首次可能是 TCP 线程)
  • OnTickByTick:逐笔行情独立线程
  • OnRebuildTickByTick / OnRebuildMarketData:回补数据独立线程(官方明确说与订阅回调不在同一线程)
  • OnDisconnected:在 TCP 控制通道线程中

因此你的回调函数必须具备线程安全性,多个回调可能同时在不同线程执行。

34.3 指针参数的生命周期?

void OnDepthMarketData(XTPMD *market_data, ...) {
    // market_data 指向的内存在函数返回后就可能被回收!
    // ❌ 错误:只保存指针
    g_last_market_data_ptr = market_data;  // 悬垂指针!

    // ✅ 正确:深拷贝数据
    g_last_market_data_copy = *market_data;  // 值拷贝
}

官方文档强调:

注意此处不能仅仅保存数据的指针,指针所指向的内存数据将在此函数return后失效。

34.4 回调的顺序有保证吗?

官方文档:

api对消息也会保序,保序时间大概在3~5秒内

例如:一笔订单部成后发起撤单,撤单响应和成交回报消息在 3 秒内先后到达,API 会保证先收到 OnTradeEvent(成交),再收到 OnOrderEvent(撤单响应)。

34.5 查询结果是逐个回调还是一次性返回?

官方文档:

所有查询数据都是按个推送的,每次推送一个,即当查询结果有N个数据时,会回调N次接口,当最后一个数据推送时,会设置参数is_last为true。

例如查询沪深两市所有合约信息,会触发 OnQueryAllTickers 数百次,每次推送一个合约,最后一个 is_last=true

34.6 断线后会自动重连吗?

不会。 官方明确:

Api不会自动重连,当断线发生时,请用户自行选择后续操作。可以在此函数中调用Login重新登录。注意用户重新登录后,需要重新订阅行情。

你需要在 OnDisconnected 中自己处理重连逻辑。对于 UDP 行情(2.2.33.5+),TCP 断连不影响 UDP 组播数据接收,可根据实际情况决定是否重连 TCP。


三十五、深度追踪:从一个网络包到一次 push 的完整执行流

你问到了最核心的地方:DLL 送来了数据,触发了函数运行,再驱动了 push 操作——这一切到底是怎么发生的?

让我们用调用栈(call stack)的视角,把一个行情数据包的"一生"完整追踪一遍。

35.1 先理解一个关键事实:回调发生时,你在谁的线程里?

这是理解一切的前提。我们来看两个场景的对比——

场景 A:你调用一个普通函数

你的 main 线程:
  main()
    → api->SubscribeMarketData(...)     ← 你主动调用
      → QuoteApiImpl::SubscribeMarketData(...)   ← 进入 DLL 代码
        → send_to_server(...)
        → return 0                      ← 一路返回到 main
  // 调用栈清空,main 线程继续干别的事

场景 B:DLL 回调你的函数

DLL 内部的 UDP 解析线程(不是你创建的!):
  udp_parse_thread()                     ← DLL 在 Login 时自己创建的线程
    → recvfrom(socket, buf, ...)         ← 阻塞等待数据
    → 收到一个 UDP 包!
    → parse_market_data(buf)             ← 解析二进制 → XTPMD 结构体
    → m_pSpi->OnDepthMarketData(&md, ...)  ← 调用你注册的回调
      → MyQuoteSpi::OnDepthMarketData(...) ← 现在执行权在你手里!
        → m_dataQueue.push(copy)         ← 你的 push 代码在这里执行
        → return                         ← 返回给 DLL
    → 继续 recvfrom 等待下一个包...

关键洞察:当 m_dataQueue.push(copy) 执行时,CPU 上运行的线程是 DLL 创建的 UDP 解析线程。你的 main 线程此刻可能在 sleep,完全不知道这一切在发生。

35.2 逐帧拆解:从网卡中断到 push 完成

下面用 CPU 指令级的时间线,追踪一个行情数据包的完整旅程:

时刻 T0: 交易所服务器
  │  发送 UDP 包: [header][600000][last_price=10.50][bid1=10.49][...共约200字节]
  │  通过互联网路由...
  ▼
时刻 T1: 你的网卡
  │  DMA 将数据写入内核缓冲区
  │  触发硬件中断
  ▼
时刻 T2: Windows 内核网络栈
  │  NDIS 驱动处理 → IP 层 → UDP 层
  │  数据拷贝到 socket 的接收缓冲区
  │  唤醒在 recvfrom() 上阻塞的线程
  ▼
时刻 T3: DLL 的 UDP 接收线程(CPU 核 2 上)
  │  recvfrom() 返回,buf 里是 200 字节的原始数据
  │
  ├─► 解析二进制协议:
  │     buf[0..1]   = msg_type   → "快照行情"
  │     buf[2..17]  = ticker     → "600000"
  │     buf[18..25] = last_price → 10.50 (IEEE 754 double)
  │     buf[26..33] = bid1_price → 10.49
  │     ... 填充 XTPMD 结构体 ...
  │
  ├─► 结构体解析完毕,现在有一个栈上的 XTPMD 变量:
  │     XTPMD md;
  │     md.ticker = "600000";
  │     md.last_price = 10.50;
  │     md.bid[0] = 10.49;
  │     ...
  │
  ├─► 调用虚函数(通过 vtable):
  │     m_pSpi->OnDepthMarketData(&md, bid1_qty, bid1_count, ...)
  │     │
  │     │  实际执行: m_pSpi->vtable[slot_3](m_pSpi, &md, ...)
  │     │                          │
  │     │           ┌──────────────┘
  │     │           ▼
  │     │   现在 CPU 的指令指针(RIP)指向你的代码!
  │     │
  │     ├─► void MyQuoteSpi::OnDepthMarketData(XTPMD *market_data, ...)
  │     │   {
  │     │     // ⚡ CPU 正在执行这里的每一条指令 ⚡
  │     │
  │     │     // 步骤 1: 深拷贝(在栈上创建副本)
  │     │     MarketData copy;
  │     │     copy.ticker     = market_data->ticker;     // mov 指令
  │     │     copy.last_price = market_data->last_price; // movsd 指令
  │     │     copy.volume     = market_data->qty;        // mov 指令
  │     │     // 约 10~20 条 CPU 指令,~50 纳秒
  │     │
  │     │     // 步骤 2: push 到队列
  │     │     m_dataQueue.push(copy);
  │     │     │
  │     │     │  内部:
  │     │     │   lock cmpxchg [queue->tail], new_node   ← 原子 CAS 操作
  │     │     │   // 约 1 条指令 + 内存屏障,~20 纳秒
  │     │     │
  │     │     │  如果队列之前是空的:
  │     │     │    notify_one() 唤醒消费者线程          ← 可能触发系统调用
  │     │     │
  │     │     │  返回
  │     │     │
  │     │     }  // ← return,控制权交还给 DLL
  │     │
  │     ▼  回到 DLL 的 UDP 解析线程
  │
  ├─► 检查返回值,确认回调正常返回
  │
  ├─► 回收栈上的 XTPMD(此时你拷贝出去的数据仍然安全)
  │
  └─► 循环: 再次调用 recvfrom(),等待下一个行情包
       (从 T3 到 T4,如果一切顺利,整个回调执行 < 1 微秒)

35.3 "push 执行时的线程"与"消费 push 数据的线程"是两条线

这是理解回调驱动架构最关键的一张图:

════════════════════════════════════
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐