开源之夏-14
今天终于理解了GmSSL和Tongsuo在构建系统上的设计差异。
我有强迫症。我只想用Best Practice去实现我的SDF模块。
对,我只要最好的办法。然后一直烦老师~
Tongsuo对编译期和运行期的性能优化处理
1 |
|
这个条件编译的判断逻辑看起来重复了。
但实际上他们虽然使用同样的宏名,但作用域和目的都不同:
上边的是“声明/定义哪些符号” => 编译期完成
RUN_ONCE_STATIC里边的指定“运行哪段代码” => 运行期完成。
于是程序在编译器就能同时支持:
静态链接(SDF直接编进库)和动态加载(运行时才去找libsdf.so)
为什么这样做?
是的,这种方式兼顾了灵活性和性能:
- 灵活性:
- 通过
#ifdef SDF_LIB_SHARED
,可以选择运行时动态加载(DSO/dlopen)SDF库,实现运行时切换、热插拔等灵活特性。 - 通过
#else
分支,可以直接静态链接(编译期绑定)SDF实现,适合对性能有极致要求的场景。
- 性能:
- 静态链接分支(
#else
)下,所有SDF方法直接赋值为本地实现,无需运行时查找和函数指针跳转,性能最佳。 - 动态库分支(
SDF_LIB_SHARED
)下,虽然有动态加载和函数指针分发,但带来更好的可扩展性。
- 可维护性:
- 通过
#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;它更多是历史包袱或极端场景下的折中。今天的主流做法是:
- 编译期决策 → 用 条件源文件 或 条件链接,让源文件本身零
#ifdef
。 - 运行期决策 → 用 插件/动态分派,把
if
留在运行时而不是预处理器。 - 只有在“必须单文件发布”或“极度追求可移植性”时,才会回到 OpenSSL 这种写法。
换句话说,OpenSSL 的做法是 历史产物 而非 现代范式。
在绝大多数工程里,都不会像这样把“静态/动态两套实现”硬塞进 同一份 .c 文件,而是用下面三种主流套路把“编译期决策”和“运行期实现”彻底拆开,既减少 #ifdef
污染,又方便独立演进。
双源文件 + 链接器选择
• 典型做法1
2
3engine/
├─ sdf_static.c // 只放静态分支代码
└─ sdf_shared.c // 只放动态加载代码• 在
CMakeLists.txt
/Makefile
里用 条件编译单元1
2
3
4
5if(WITH_SDF_SHARED)
target_sources(engine PRIVATE sdf_shared.c)
else()
target_sources(engine PRIVATE sdf_static.c)
endif()这样**源文件里没有任何
#ifdef
**,编译期只把需要的.o
编进来;运行期只剩一条路径,零多余符号。
• 例子:Linux 内核的arch/*/lib
目录,每个架构只把需要的实现编进去。统一接口 + 运行时动态分派(插件/DSO)
• 把“真正干活”的函数指针塞进一个struct ops
;
• 启动时根据配置或环境变量dlopen
/LoadLibrary
去选.so
/.dll
:1
2
3
4
5static 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 插件。
条件编译 + 独立头文件(最小侵入)
• 如果一定要在同一份.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 |
|
这套做法在业界一般叫 “单一源码树 + 条件配置脚本”(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 都支持)
静态库 vs 动态库优先级
当可执行文件里**同时出现符号sdf_get_backend
**,ld 的解析顺序是1
主程序自身 .o → 静态库 (.a) → 动态库 (.so)
因此只要把
sdf_plugin.o
编进主程序,符号就优先解析到它,dlopen
时就算有同名.so
也被忽略。动态库未找到时的弱符号 / 空桩兜底
如果主程序没有把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 功能时,链接器可把空桩整个优化掉,体积再小一点;
– 厂商库只需提供同名强符号即可“无缝替换”。
可以都保留,但务必给它们排好优先级,避免“两套兜底”造成混乱:
- 在源码里只留下 一份真正的空桩(放在
sdf_meth.c
)。 - 把弱符号 仅用作“保险丝”,即
1
2__attribute__((weak))
const SDF_METHOD *SDF_get_method(void) { return NULL; } /* 最后一道防线 */ - 构建脚本保证 优先级
sdf_meth.c
一旦参与编译,它就是强符号,弱符号立刻被忽略;- 只有确实没有任何实现被编译进来时,弱符号才会生效。
这样既保留了“空桩可扩展”的便利,也保留了“弱符号兜底”的安全垫,且不会同时出现两份符号表。
总结:
为了构建SDF抽象层,我们进行这设计。
这件事情,最拧巴的点在于,静态链接对动态链接具有更高的优先级,而我们的项目构建工具能力有限:
整理一下思路:
静态实现 (WITH_SDF=static) |
动态实现 (WITH_SDF=shared) |
实际行为(源码级规则) |
---|---|---|
0 | 0 | 走软实现 / 空桩实现 |
0 | 1 | SDF_get_method() 返回运行时 DSO_Load 得到的多态实现 |
1 | 1 | 构建脚本仅选 sdf_static.c ;LIBSDF_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_OpenDevice
、TSAPI_SDF_InternalEncrypt_ECC
等)访问底层,不再直接用sdfe_login_arg_t
、SDFE_LoginUsr
等厂商扩展符号。 - 所有厂商扩展能力,统一在 SDF 层实现和适配,TSAPI 层只用“通用参数”调用。
2. 重构步骤
a. SDF 层适配
- 在 SDF 层(如
sdf_lib.c
/sdf_local.h
)为所有 TSAPI 需要的功能,提供标准化的接口(如TSAPI_SDF_LoginUser
、TSAPI_SDF_DelECCKey
等),参数用通用类型(如void*
、const char*
、int
等),不要暴露 SDFE 专有类型。 - 在 SDF 层内部实现时,再去 include
sdfe_api.h
,并做类型转换和适配。
b. TSAPI 层调用
- TSAPI 只 include SDF 层的头文件(如
<openssl/sdf.h>
),不再 includesdfe_api.h
。 - 所有原本用 SDFE 类型/函数的地方,改为调用 SDF 层的抽象 API,并用通用参数传递。
c. 示例
SDF 层(sdf.h/sdf_lib.c)
1 |
|
1 |
|
TSAPI 层(tsapi_lib.c)
1 |
|
3. 迁移要点
- 所有 TSAPI 需要的 SDFE 能力,都要在 SDF 层包一层“通用接口”。
- TSAPI 层不再 include
sdfe_api.h
,不再用 SDFE 专有类型。 - 这样,TSAPI 只依赖 SDF 层的抽象,SDF 层负责所有底层适配和条件编译。
4. 总结
- 这样重构后,TSAPI 层和 SDF 层完全解耦,TSAPI 只用抽象接口,SDF 层负责所有厂商适配和条件编译。
- 你可以先挑一两个典型接口试点迁移,确认无误后批量迁移。