开源之夏-14

今天终于理解了GmSSL和Tongsuo在构建系统上的设计差异。
我有强迫症。我只想用Best Practice去实现我的SDF模块。
对,我只要最好的办法。然后一直烦老师~

Tongsuo对编译期和运行期的性能优化处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifdef SDF_LIB
# ifdef SDF_LIB_SHARED
static DSO *sdf_dso = NULL;
# else
/* Weak declarations for direct-linked SDF symbols are split out
* to make extension (e.g., 69+ items) easier to maintain. */
# include "sdf_sym_weak.h"
# endif

static CRYPTO_ONCE sdf_lib_once = CRYPTO_ONCE_STATIC_INIT;
static SDF_METHOD sdfm;

DEFINE_RUN_ONCE_STATIC(ossl_sdf_lib_init)
{
# ifdef SDF_LIB_SHARED
# ifndef LIBSDF
# define LIBSDF "sdf"
# endif
}

这个条件编译的判断逻辑看起来重复了。
但实际上他们虽然使用同样的宏名,但作用域和目的都不同:
上边的是“声明/定义哪些符号” => 编译期完成
RUN_ONCE_STATIC里边的指定“运行哪段代码” => 运行期完成。

于是程序在编译器就能同时支持:
静态链接(SDF直接编进库)和动态加载(运行时才去找libsdf.so)

为什么这样做?

是的,这种方式兼顾了灵活性和性能:

  1. 灵活性:
  • 通过 #ifdef SDF_LIB_SHARED,可以选择运行时动态加载(DSO/dlopen)SDF库,实现运行时切换、热插拔等灵活特性。
  • 通过 #else 分支,可以直接静态链接(编译期绑定)SDF实现,适合对性能有极致要求的场景。
  1. 性能:
  • 静态链接分支(#else)下,所有SDF方法直接赋值为本地实现,无需运行时查找和函数指针跳转,性能最佳。
  • 动态库分支(SDF_LIB_SHARED)下,虽然有动态加载和函数指针分发,但带来更好的可扩展性。
  1. 可维护性:
  • 通过 #include "sdf_bind_shared.inc"sdf_bind_static.inc,将符号绑定和扩展分离,便于维护和扩展。

总结:
这种设计思路许你在编译时选择“极致性能”或“最大灵活性”,是业界常见的高质量多态实现方式。你理解完全正确!

对比GmSSL可以发现,Tongsuo还是吃了历史的亏,但是从中也看到了不同时代的Best Practice实现方式。
这就可以体会到,为什么后来出现了CMake,出现了各种各样的现代化构建项目工具。
它们对代码开发和项目易用性提供了强有力的支持。

所以说,它们的做法都属于Best Practice。只是因为Tongsuo沿用OpenSSL构建项目的模式,使用自研脚本生成Makefile,无法在构建脚本和链接器完成,所以必须使用一些#ifdef

你的理解非常准确。

GmSSL 和 Tongsuo 的模块裁剪、可选编译做法都属于业界 Best Practice。两者的主要区别在于:

  • GmSSL 采用 CMake,模块开关和源码裁剪都在 CMake 脚本中完成,条件编译宏(如 ENABLE_SDF)主要用于源码组织和编译选项,构建流程更现代、自动化程度高。
  • Tongsuo 沿用 OpenSSL 的构建体系,依赖自研脚本生成 Makefile,部分模块裁剪和符号控制需要通过 #ifdef 宏在源码层面实现,因为 Makefile 生成和链接阶段无法像 CMake 那样灵活处理所有细节。

总结:
两种方式都合理,都是主流工程项目的最佳实践。只是构建工具和项目历史不同,导致实现细节略有差异。最终目标都是让用户可以灵活裁剪、按需编译,保证工程质量和可维护性。

BestPractice

OpenSSL这类项目的工程规范都相当经典和古老,当然这已经是一种高质量多态实现方式了。
现代大型项目也会使用这种思路:

  • 静态链接(直接把实现编进主程序)
  • 动态加载(运行时才去找 libsdf.so/.dll/.dylib)
    但源文件里不出现任何 #ifdef,也不牺牲单头文件易用性

结论先行
“同一份 .c 里用大量 #ifdef 同时容纳静态/动态两套实现” 并不是现代中大型项目的 Best Practice;它更多是历史包袱或极端场景下的折中。今天的主流做法是:

  1. 编译期决策 → 用 条件源文件条件链接,让源文件本身零 #ifdef
  2. 运行期决策 → 用 插件/动态分派,把 if 留在运行时而不是预处理器。
  3. 只有在“必须单文件发布”或“极度追求可移植性”时,才会回到 OpenSSL 这种写法。

换句话说,OpenSSL 的做法是 历史产物 而非 现代范式

在绝大多数工程里,都不会像这样把“静态/动态两套实现”硬塞进 同一份 .c 文件,而是用下面三种主流套路把“编译期决策”和“运行期实现”彻底拆开,既减少 #ifdef 污染,又方便独立演进。

  1. 双源文件 + 链接器选择
    • 典型做法

    1
    2
    3
    engine/
    ├─ sdf_static.c // 只放静态分支代码
    └─ sdf_shared.c // 只放动态加载代码

    • 在 CMakeLists.txt / Makefile 里用 条件编译单元

    1
    2
    3
    4
    5
    if(WITH_SDF_SHARED)
    target_sources(engine PRIVATE sdf_shared.c)
    else()
    target_sources(engine PRIVATE sdf_static.c)
    endif()

    这样**源文件里没有任何 #ifdef**,编译期只把需要的 .o 编进来;运行期只剩一条路径,零多余符号。
    • 例子:Linux 内核的 arch/*/lib 目录,每个架构只把需要的实现编进去。

  2. 统一接口 + 运行时动态分派(插件/DSO)
    • 把“真正干活”的函数指针塞进一个 struct ops
    • 启动时根据配置或环境变量 dlopen/LoadLibrary 去选 .so/.dll

    1
    2
    3
    4
    5
    static const struct sdf_ops *ops;
    void engine_init(void) {
    void *h = dlopen(getenv("SDF_BACKEND"), RTLD_NOW);
    ops = dlsym(h, "sdf_ops");
    }

    • 例子:OpenSSL 的 ENGINE 机制、PostgreSQL 的 FDW、Chromium 的 PPAPI 插件。

  3. 条件编译 + 独立头文件(最小侵入)
    • 如果一定要在同一份 .c 里共存,就把所有条件编译压到头文件:

    1
    2
    3
    4
    5
    6
    /* sdf_backend.h */
    #ifdef SDF_LIB_SHARED
    # include "sdf_backend_shared.h"
    #else
    # include "sdf_backend_static.h"
    #endif

    • 源文件只 #include "sdf_backend.h",逻辑干净;
    • 例子:zlib-ng 的 fallback.h,根据 CPU 指令集级别切换实现。

小项目:直接用 CMake 条件源文件,代码最干净。
中/大项目:统一接口 + 插件,把“编译期 if”变成“运行期 if”,彻底消灭 #ifdef

1
2
3
4
5
在OpenSSL这类的密码库里可以看到这样的使用方式。
./Configure linux-x86_64 # 默认静态
./Configure linux-x86_64 shared # 动态
make ……
这种构建系统的方式叫什么?

这套做法在业界一般叫 “单一源码树 + 条件配置脚本”(single-source tree with conditional configuration script)。
具体到 OpenSSL,它用的是 自研的 Configure 脚本,本质是一种 Autotools 之前的“手写配置脚本”,功能类似于:

  • Autoconf 的 ./configure --enable-shared/--disable-shared
  • CMake 的 cmake -DBUILD_SHARED_LIBS=ON/OFF

所以没有一个专有名词,只能描述为:

“OpenSSL 的 手写 Configure 脚本 根据传入的 shared/no-shared 参数,在同一源码树里通过宏定义和条件编译生成静态或动态库。”

还有一个疑问,源码里没有任何 #ifdef——条件全部交给构建脚本和链接器完成。
是链接器升级了吗?为什么这可以交给链接器完成?

不是“链接器升级”,而是“把问题挪到了链接阶段”
老链接器就能做,关键在于“同符号名 + 不同可见性/库” 的优先级规则一直存在,只是过去没人把“静态/动态”放到同一次构建里一起用。

核心原理(任何时代的 ld/LLD 都支持)

  1. 静态库 vs 动态库优先级
    当可执行文件里**同时出现符号 sdf_get_backend**,ld 的解析顺序是

    1
    主程序自身 .o → 静态库 (.a) → 动态库 (.so)

    因此只要把 sdf_plugin.o 编进主程序,符号就优先解析到它,dlopen 时就算有同名 .so 也被忽略。

  2. 动态库未找到时的弱符号 / 空桩兜底
    如果主程序没有sdf_plugin.o 链接进来,符号表里没有 sdf_get_backend,编译期也不会报错
    (这句话的前提是,主程序没有进行sdf_get_method())
    一旦主程序里出现直接调用(而非通过 dlsym 取函数指针),就必须提供定义(stub、弱符号或真正的实现),否则链接器会在链接期报错。
    所以,可以看到,现有代码中 stub 被做成了弱符号 __attribute__((weak)),找不到就自动为空,防止编译报错。

所以并不是链接器“升级”了,而是把原先在源码里用 #ifdef 做的“二选一”改到构建脚本里用“是否把 .o/.a 编进来”完成,链接器一直以来的符号解析规则就能自动处理剩下的优先级问题。

已经有一份空桩(stub)实现的前提下,弱符号就不再是必须品;二者可以互相替代,但各有侧重:

方式作用域优点缺点
空桩 (stub)功能级即使链接进来,也明确返回 NOTSUPPORT,代码清晰始终占用代码/数据段
弱符号符号级没有强符号时可被丢弃,体积更小;实现可被强符号一键替换如果忘了写 stub,直接调用会跳 NULL

那我还是都保留吧。

  • 已经有完整的空桩 ts_sdf_meth弱符号可省
  • 加弱符号的唯一好处是:
    – 当不需要 SDF 功能时,链接器可把空桩整个优化掉,体积再小一点;
    – 厂商库只需提供同名强符号即可“无缝替换”。
    可以都保留,但务必给它们排好优先级,避免“两套兜底”造成混乱:
  1. 在源码里只留下 一份真正的空桩(放在 sdf_meth.c)。
  2. 把弱符号 仅用作“保险丝”,即
    1
    2
    __attribute__((weak))
    const SDF_METHOD *SDF_get_method(void) { return NULL; } /* 最后一道防线 */
  3. 构建脚本保证 优先级
    • sdf_meth.c 一旦参与编译,它就是强符号,弱符号立刻被忽略;
    • 只有确实没有任何实现被编译进来时,弱符号才会生效。

这样既保留了“空桩可扩展”的便利,也保留了“弱符号兜底”的安全垫,且不会同时出现两份符号表。

总结:

为了构建SDF抽象层,我们进行这设计。
这件事情,最拧巴的点在于,静态链接对动态链接具有更高的优先级,而我们的项目构建工具能力有限:
整理一下思路:

静态实现
(WITH_SDF=static)
动态实现
(WITH_SDF=shared)
实际行为(源码级规则)
0 0 走软实现 / 空桩实现
0 1 SDF_get_method() 返回运行时 DSO_Load 得到的多态实现
1 1 构建脚本仅选 sdf_static.cLIBSDF_SHARED 宏被忽略,静态库优先
1 0 走软实现 / 空桩实现

坑点:

容易把多态实现方式和条件编译,构建系统方式混了。

铜锁和GmSSL的多态实现方式都是函数指针表,但是GmSSL使用CMake工具实现了构建系统,无需代码上条件编译的麻烦。

TSAPI写穿了

开源项目的难点是以前由多位开发者协作完成,可能出现思路不一致的情况。
这是国产密码适配代码常见的“条件编译耦合”问题
在这个部分,TSAPI直接条件编译了厂商库,写穿了:
按照工程设计,SDF文件夹下的sdf_lib.c将使用函数映射表的方式构建起一套抽象。让TSAPI层屏蔽厂商,底层细节。
SDF 层想做“插件式抽象”,TSAPI 层为了方便直接用了厂商接口,导致条件编译策略不一致。
这里被前人写穿了。TSAPI用了很多厂商符号,我需要对其包装一层SDF接口,包括扩展功能接口。

要实现“彻底抽象”,让 tsapi_lib.c 只依赖 SDF 层的抽象接口(如 TSAPI_SDF_xxx),不再直接 include sdfe_api.h 和使用 SDFE 扩展类型/函数,你需要:


1. 原则

  • TSAPI 只通过 SDF 层的标准 API(如 TSAPI_SDF_OpenDeviceTSAPI_SDF_InternalEncrypt_ECC 等)访问底层,不再直接用 sdfe_login_arg_tSDFE_LoginUsr 等厂商扩展符号。
  • 所有厂商扩展能力,统一在 SDF 层实现和适配,TSAPI 层只用“通用参数”调用。

2. 重构步骤

a. SDF 层适配

  1. 在 SDF 层(如 sdf_lib.c/sdf_local.h)为所有 TSAPI 需要的功能,提供标准化的接口(如 TSAPI_SDF_LoginUserTSAPI_SDF_DelECCKey 等),参数用通用类型(如 void*const char*int 等),不要暴露 SDFE 专有类型。
  2. 在 SDF 层内部实现时,再去 include sdfe_api.h,并做类型转换和适配。

b. TSAPI 层调用

  1. TSAPI 只 include SDF 层的头文件(如 <openssl/sdf.h>),不再 include sdfe_api.h
  2. 所有原本用 SDFE 类型/函数的地方,改为调用 SDF 层的抽象 API,并用通用参数传递。

c. 示例

SDF 层(sdf.h/sdf_lib.c)

1
2
3
4
// sdf.h
int TSAPI_SDF_LoginUser(void *hSessionHandle, const char *user, const char *password);
int TSAPI_SDF_DelECCKey(void *hSessionHandle, int area, int index);
// ... 其他适配接口 ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// sdf_lib.c
int TSAPI_SDF_LoginUser(void *hSessionHandle, const char *user, const char *password) {
#ifdef SDF_LIB
sdfe_login_arg_t login_arg;
memset(&login_arg, 0, sizeof(login_arg));
if (user) {
if (strlen(user) >= sizeof(login_arg.name))
return OSSL_SDR_FAIL;
strcpy((char *)login_arg.name, user);
}
login_arg.passwd = (uint8_t *)password;
login_arg.passwd_len = password ? strlen(password) : 0;
return SDFE_LoginUsr(hSessionHandle, &login_arg);
#else
return OSSL_SDR_NOTSUPPORT;
#endif
}

TSAPI 层(tsapi_lib.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int TSAPI_DelSm2KeyWithIndex(int index, int sign, const char *user, const char *password)
{
int ok = 0;
void *hDeviceHandle = NULL;
void *hSessionHandle = NULL;
int area = sign ? SDFE_ASYM_KEY_AREA_SIGN : SDFE_ASYM_KEY_AREA_ENC;

if (TSAPI_SDF_OpenDevice(&hDeviceHandle) != OSSL_SDR_OK)
goto end;
if (TSAPI_SDF_OpenSession(hDeviceHandle, &hSessionHandle) != OSSL_SDR_OK)
goto end;
if (TSAPI_SDF_LoginUser(hSessionHandle, user, password) != OSSL_SDR_OK)
goto end;
if (TSAPI_SDF_DelECCKey(hSessionHandle, area, index) != OSSL_SDR_OK)
goto end;
ok = 1;
end:
TSAPI_SDF_CloseSession(hSessionHandle);
TSAPI_SDF_CloseDevice(hDeviceHandle);
return ok;
}

3. 迁移要点

  • 所有 TSAPI 需要的 SDFE 能力,都要在 SDF 层包一层“通用接口”。
  • TSAPI 层不再 include sdfe_api.h,不再用 SDFE 专有类型。
  • 这样,TSAPI 只依赖 SDF 层的抽象,SDF 层负责所有底层适配和条件编译。

4. 总结

  • 这样重构后,TSAPI 层和 SDF 层完全解耦,TSAPI 只用抽象接口,SDF 层负责所有厂商适配和条件编译。
  • 你可以先挑一两个典型接口试点迁移,确认无误后批量迁移。

开源之夏-14
https://43.242.201.154/2025/08/21/开源之夏-14/
Author
Dong
Posted on
August 21, 2025
Licensed under