找回密码
立即注册
搜索
发回帖 发新帖

5084

积分

0

好友

648

主题
发表于 昨天 20:16 | 查看: 14| 回复: 0

本文探讨上交所流式二进制协议 v0.57 与深交所 Binary 交易数据接口规范 Ver1.29 的 C11 实现,重点剖析协议建模、消息分派、内存所有权管理、长度与校验和处理,以及如何验证 C 实现与既有 Rust 版本逐字节兼容。

协议代码由 fin-protoc 根据 PacketDSL 生成;本文只在必要处说明这一来源,不展开代码生成器本身。

项目与代码

组件 仓库
上交所流式二进制协议 v0.57 fin-proto-sse-bin-c
深交所 Binary 交易数据接口规范 Ver1.29 fin-proto-szse-bin-c
C11 二进制编解码运行时 fin-proto-runtime-bin-c
协议编译器 fin-protoc

C 版本的目标不是把文档里的字段机械地翻译成结构体,而是建立一组明确的边界:TCP 字节流怎样组成完整帧、消息类型怎样映射到具体 Body、哪些数据借用输入缓冲区、哪些数据需要释放,以及编码结果如何证明与其他语言一致。

先理解线上帧

沪深两套协议都采用大端字节序,也都可以抽象为"头部 + 变长 Body + 校验和",但头部字段和消息分派复杂度不同。

协议 固定头部 Body 分派 帧尾
SSE v0.57 MsgType u32 + MsgSeqNum u64 + MsgBodyLen u32 14 种 Body Checksum u32,SSE_BIN
SZSE Ver1.29 MsgType u32 + BodyLength u32 77 个 MsgType 键映射到 14 种 Body Checksum i32,SZSE_BIN

沪深两所二进制协议帧结构对比

这里的"流式"描述的是协议运行在连续的 TCP 字节流上,不等于 codec 可以直接接受任意网络分片。应用层仍应先完成组帧:读取固定头部、解析 Body 长度、等待完整帧,再把连续字节交给解码器。

TCP字节流组帧处理流程

当前协议库负责完整帧内的字段编解码。TCP 粘包、拆包、最大帧长限制和连接级序列号策略属于上层传输适配器。把这条边界说清楚,比在 codec 里暗中维护网络状态更容易测试和复用。

最难的不是字段,而是两级消息分派

上交所根消息按 MsgType 直接选择 14 种 Body。深交所更复杂:根消息的 77 个 MsgType 键先归并为 14 种 Body,其中 NewOrder、ExecutionConfirm 和 ExecutionReport 还要根据三字节 ApplID 选择业务扩展体。协议定义中共有 54 个 Extend* 类型。

消息路由分派流程

用 kind + union 表达 C 中的变体类型

C 没有原生 tagged union,所以实现使用一个判别枚举和一个 union。下面是上交所根消息的简化形式:

typedef enum {
    SSE_BINARY_BODY_NONE = 0,
    SSE_BINARY_BODY_HEARTBEAT,
    SSE_BINARY_BODY_LOGON,
    SSE_BINARY_BODY_NEW_ORDER_SINGLE,
    SSE_BINARY_BODY_ORDER_CANCEL,
    /* ... */
} sse_binary_body_kind;

typedef struct sse_binary_sse_binary {
    uint32_t msg_type;
    uint64_t msg_seq_num;
    uint32_t msg_body_len;
    sse_binary_body_kind body_tag;
    union {
        sse_binary_heartbeat heartbeat;
        sse_binary_logon logon;
        sse_binary_new_order_single new_order_single;
        sse_binary_order_cancel order_cancel;
        /* ... */
    } body;
    uint32_t checksum;
} sse_binary_sse_binary;

编码看 body_tag,解码看线上 msg_type。两者职责不同:

  • 编码时,调用方必须构造一致的 msg_type、body_tag 与 union 成员。
  • 解码时,库根据 msg_type 设置 body_tag,再进入对应的子消息 decoder。
  • 未知 msg_type 立即返回 false,不会把未知字节误解释成某个结构体。

深交所的第二级 ApplID 分派使用相同模型:kind 枚举表示当前扩展体,union 保存对应的 Extend* 结构。多值键会合并为同一分支,避免复制相同的解码逻辑。这里的 ApplID、扩展体 kind 和 union 成员同样是独立字段;encoder 不会从 union 反推或校验线上 key,编码前必须由调用方保证三者一致。

运行时设计:小而明确

运行时只提供几类基础能力:

  • 可增长的 fc_buffer,负责编码和原地回填;
  • 只读游标 fc_reader,负责边界检查和大端字节序标量读取;
  • 定长字符串、动态字符串和 repeat 列表;
  • CRC16、CRC32、SSE_BIN、SZSE_BIN 校验和;
  • fc_string 形式的借用字符串。

它只依赖 C 标准库,没有全局注册表,也没有隐藏线程状态。字节序保存在 buffer/reader 中,因此生成的消息函数不需要为每个整数再提供 _be、_le 两套 API。

字符串零拷贝:借用而不是复制

定长 char[N] 和动态字符串在解码后都表示为:

typedef struct fc_string {
    const char* data;
    size_t len;
} fc_string;

data 直接指向输入帧。解码定长字符串时,reader 先取得对应字节区间,再根据协议配置去掉左侧或右侧填充;整个过程不复制 payload。fc_string 也不是 NUL 结尾字符串,传给传统 C API 前必须结合 len 处理。

字符串借用与生命周期

这个设计适合"收包 → 解码 → 处理 → 释放帧"的网关热路径。如果消息要进入异步队列或长期缓存,调用方应复制所需字段,不能只保存 fc_string。

repeat 是唯一需要动态分配的解码结构

标量和字符串都不分配;repeat 需要一个外层数组。例如 ExecRptSync 会先读取 u16 数量,再 calloc 对应数量的子结构。每个消息都提供对称的 <message>_free:

void sse_binary_exec_rpt_sync_free(sse_binary_exec_rpt_sync *self) {
    for (uint32_t i = 0; i < self->sub_exec_rpt_sync_count; i++) {
        sse_binary_sub_exec_rpt_sync_free(&self->sub_exec_rpt_sync[i]);
    }
    free(self->sub_exec_rpt_sync);
    self->sub_exec_rpt_sync = NULL;
    self->sub_exec_rpt_sync_count = 0;
}

解码函数开头会把输出结构清零;根消息又会在进入子 decoder 前设置 body_tag。因此即使某个字段中途失败,调用方仍可以对根消息调用 *_free,清理已经建立的 repeat 数组。这个约定把 C 中常见的"部分初始化对象"变成了可管理状态。

长度回填与校验和

编码一帧时,Body 长度只有写完 Body 才知道。实现采用"预留 → 编码 → 回填 → 计算校验和"的顺序:

编码回填与校验流程

SSE_BIN 对校验和字段之前的字节求和并折叠到低 8 位,结果以 u32 写入;SZSE_BIN 对字节求和后按 256 取余,以 i32 写入。校验和类型与算法在消息定义中固定,不在运行时按字符串查表。

根 encoder 的 checksum 输入是 buf->data[0..buf->len],所以一个完整帧必须从空 buffer 开始。复用已分配容量时先调用 fc_buffer_reset;批量发送多帧时,应分别编码每个完整帧,再拼接到传输缓冲区。若直接在未 reset 的同一个 fc_buffer 中连续追加,后一帧的 checksum 会错误地覆盖前面已经写入的字节。

需要特别说明:当前 decoder 读取 BodyLength 和 Checksum,但不会自动重算并拒绝不匹配的帧,也不会用 BodyLength 创建受限子 reader。因此生产接入层应在调用消息 decoder 前后完成:

  1. 最大帧长和完整帧边界检查;
  2. Body 实际消费长度与头部长度的一致性检查;
  3. repeat count 的业务上限,以及 count 与剩余 Body 字节是否相容;
  4. checksum 重算与比较。

这是当前 API 的明确边界,不应把"编码时会生成 checksum"误解为"解码时已经验证 checksum"。

FIN-PROTOC 在这里扮演什么角色

沪深协议的字段数量和分派分支都很大,手工维护 C、Rust、Zig 三套实现很容易发生漂移。项目因此用 PacketDSL 保存协议结构,再由 fin-protoc 生成 C11 消息代码。

# 上交所
fin-protoc -f proto/sse_bin_v0.57.pdsl -C src/

# 深交所
fin-protoc -f proto/szse_bin_v1.29.pdsl -C src/

生成器解决的是重复劳动和一致性问题;协议 framing、运行时内存模型、输入限制与互操作测试仍然是实现设计的一部分,不能交给"生成"两个字带过。

正确性:从字段 round trip 到跨语言 wire bytes

验证分为三层:

四层验证策略

  • 运行时测试覆盖大小端标量、定长填充、动态字符串、repeat、短输入和已知 checksum 向量。
  • 每个消息都有生成的 round-trip 测试,tests.c 自带 main,无需额外测试框架。
  • 上交所 Logon 等用例与 Rust 实现比较十六进制输出,要求包含长度和 checksum 在内的整帧完全一致。
  • 协议仓库在 CI 中重新编译并运行测试,防止生成代码、运行时和协议定义脱节。

round-trip 只能证明"自己编码的内容能被自己读回";跨语言字节对照才是防止共同偏差的关键。生产环境还应补充交易所样例帧或抓包 golden files。

集成与使用

C 运行时可以直接以源码方式加入 Makefile、CMake 或 Bazel。协议库只需要运行时头文件和源文件:

RUNTIME ?= ../fin-proto-runtime-bin-c

$(CC) $(CFLAGS) \
  -I$(RUNTIME)/include -Isrc \
  src/*.c $(RUNTIME)/src/*.c app.c

解码一条已经完成组帧和校验的上交所消息:

#include "root.h"

int handle_frame(const uint8_t* frame, size_t frame_len) {
    fc_reader reader;
    fc_reader_init(&reader, frame, frame_len, 0); /* 0 = 大端 */

    sse_binary_sse_binary msg;
    if (!sse_binary_sse_binary_decode(&reader, &msg)) {
        sse_binary_sse_binary_free(&msg);
        return -1;
    }

    if (msg.body_tag == SSE_BINARY_BODY_LOGON) {
        fc_string sender = msg.body.logon.sender_comp_id;
        /* sender.data 借用 frame,长度是 sender.len */
    }

    sse_binary_sse_binary_free(&msg);
    return 0;
}

free 只清理 repeat 等由 decoder 分配的结构;输入 frame 的所有权始终属于调用方。

性能应怎样解读

在仓库的同口径微基准中,C 的无 repeat 消息解码会让字符串借用输入帧。与构造 owned String 的 Rust 路径相比,它避免了分配和复制;与新增的 Rust borrowed decode 相比,双方才具有更接近的内存语义。这比较的是当前内存语义和实现路径,不是脱离上下文的语言排名。

完整的本地 Mac 与 GitHub Actions 数据、工具链版本和测试方法见《fin-protoc 现在支持 C 与 Zig》。实际系统还应测量网络 I/O、业务校验、队列、日志和延迟分位数。

当前实现边界

  • codec 接收完整帧,不负责 TCP 粘包与拆包。
  • decoder 当前不自动验证 checksum,也不强制 BodyLength 与实际消费字节一致。
  • fc_string 借用输入缓冲区,不是 NUL 结尾,也不能超过输入帧寿命。
  • repeat 数量来自线上数据;当前 runtime 会先按 count 分配数组,再逐项读取。接入层必须限制 count,并检查它与剩余 Body 字节相容;只限制整帧长度并不充分。
  • encoder 不会从 union 推导或校验 key;调用方必须保证 MsgType、body_tag、union 成员,以及深交所消息中的 ApplID、扩展体 kind、扩展 union 成员一致。
  • 每个完整帧应从 reset 后的 fc_buffer 开始编码,再按需拼接到传输缓冲区。

结语

用 C11 实现沪深流式二进制协议,核心并不在于写出多少个结构体,而在于把协议边界变成稳定的工程约定:传输层负责完整帧与校验,codec 负责字段和变体,字符串借用输入帧,repeat 由对称的 free 管理,跨语言测试负责证明 wire bytes 一致。

这套设计保留了 C 的可移植 ABI 和低依赖优势,同时把最容易出错的字段排列与分派关系交给同一份协议定义维护。若要接入或复现,可以从 fin-proto-sse-bin-c 和 fin-proto-szse-bin-c 开始。对协议实现、组帧与校验有更深入需求的开发者,也欢迎在云栈社区交流讨论。




上一篇:C11实现沪深流式二进制协议:消息分派与零拷贝解码
下一篇:OpenAI再抛三项科学突破:数学证明、ETH与QCD,谁来验算?
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

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

GMT+8, 2026-11-1 03:30 , Processed in 0.079935 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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