开源之夏03:C语言工程实践——多态、插件与构建系统

这个项目典型地使用了 C 语言的”面向对象”写法。函数指针结构体是实现可插拔后端+统一调用接口的核心手段,在 Linux 内核、OpenSSL、GmSSL 中都大量出现。

C 语言如何实现”面向对象”

C 没有 class、虚函数、接口、反射,但”数据结构 + 函数指针表”恰好能一次性补齐这些能力,代价极低——只靠最普通的指针和结构体。

需要的能力 C 原生缺失 函数指针结构体如何补
封装 无 class struct { data..., fn_pointers... }
回调 无闭包/委托 成员放 void (*cb)(void *)
多态 无虚函数 手动 vtable(结构体里放函数指针表)
插件 无接口/反射 结构体当接口,动态库填充后传递

函数指针 typedef

1
2
3
4
5
6
typedef int (*SDF_ExportSignPublicKey_ECC_fn)(void *hSessionHandle,
unsigned int uiKeyIndex, OSSL_ECCrefPublicKey *pucPublicKey);

// 使用时:
SDF_ExportSignPublicKey_ECC_fn myFuncPtr;
// 等价于:int (*myFuncPtr)(void*, unsigned int, OSSL_ECCrefPublicKey*);

这种语法 1970 年代出现在 Unix 内核中,用来在纯 C 里”山寨”面向对象。凡是需要把”数据 + 操作打包在一起”的 C 工程都会用这招。

多态:同一接口,不同实现

1
2
3
4
5
6
7
8
9
10
struct AnimalVTable {
void (*speak)(Animal *a);
void (*walk)(Animal *a);
};

struct Animal {
struct AnimalVTable *vtbl;
/* 数据成员 */
};
// 运行时把 vtbl 指向 DogVTable、CatVTable

在这个项目中,多态有两种使用方式:

  1. 编译期静态绑定:软实现 SDF 编译进库,函数指针直接指向本地函数。
  2. 运行期动态绑定(插件):通过 dlopen/dlsym 在运行时解析 .so 中的符号,填入函数指针表。

extern 声明 vs 定义

这是 C 语言模块化编程中一个常被忽视但至关重要的细节。在文件作用域,不带 extern 的变量声明就是”定义”(分配空间),加了 extern 才只是”声明”(生成符号引用,不分配空间)。

1
2
3
4
5
// file1.c
int g; // 定义 → 占用空间(tentative definition → 真正定义)

// file2.c
extern int g; // 声明 → 只生成符号引用,链接阶段解析到 file1.o 中的实际地址

在 SDF 模块中,extern SDF_METHOD ts_sdf_meth; 声明一个外部全局变量,告诉编译器实际定义在别的源文件(或动态库)里,链接器阶段再找。这保证了多个源文件共享同一个 vtable 实例。

Linux file_operations 与 SDF_METHOD

无论是内核的 file_operations 还是用户态的 SDF_METHOD,本质都是”把一组函数指针收进结构体”,靠运行时填表实现 C 语言下的多态接口。

1
2
3
4
5
6
7
8
9
10
11
12
// 内核:每个驱动填自己的表
const struct file_operations fops_mydev = {
.open = my_open,
.read = my_read,
.write = my_write,
};

// SDF:每家厂商填自己的表
const SDF_METHOD ts_sdf_meth = {
.OpenDevice = ts_OpenDevice,
.Encrypt = ts_Encrypt,
};
维度 Linux file_operations SDF_METHOD
所在空间 内核空间 用户空间
代表对象 文件/设备 密码设备
被谁调度 VFS 层 上层安全中间件
多态方式 函数指针表 函数指针表
动态替换 insmod 驱动 换 .so 插件

DSO 动态加载与安全性

OpenSSL 的 DSO(Dynamic Shared Object) 是跨平台”动态库加载”的封装,本质等价于 Unix 的 dlopen/dlsym + Windows 的 LoadLibrary/GetProcAddress。

1
2
3
// Tongsuo sdf_lib.c 中的加载逻辑
sdf_dso = DSO_load(NULL, "sdf", NULL, 0);
sdfm.OpenDevice = DSO_bind_func(sdf_dso, "SDF_OpenDevice");

相比系统原生 API,OpenSSL DSO 额外做了一层安全加固:

加固点 说明
受控搜索路径 默认不搜当前目录,优先 OPENSSL_DIR
文件名白名单 只允许加载约定好的名字
符号前缀过滤 部分 engine 在 bind 前做 strncmp(symbol, "SDF_", 4)
只读映射 Unix 使用 `RTLD_NOW

系统 API 只管”把文件映射进来”;OpenSSL DSO 在前后加了”白名单 + 路径限制”,相当于给 dlopen 套了条安全带。

构建系统:GmSSL vs Tongsuo

GmSSL 和 Tongsuo 同为 Best Practice,但构建路径不同:

  • GmSSL:使用 CMake,模块开关和源码裁剪在 CMake 脚本中完成,条件编译宏主要用于源码组织,构建流程现代、自动化程度高。
  • Tongsuo:沿用 OpenSSL 的构建体系——自研 Configure 脚本 + Perl 生成 Makefile + build.info 依赖描述。因为 Makefile 生成和链接阶段无法像 CMake 那么灵活,必须通过 #ifdef 宏在源码层面处理模块裁剪。

这种 Old School 方式叫”单一源码树 + 条件配置脚本”(single-source tree with conditional configuration script),本质是 Autotools 之前的手写配置脚本,根据传入参数生成静态或动态库。

条件编译的三种姿势

Tongsuo 用 #ifdef 在同一 .c 文件里同时容纳静态/动态两套实现,这并非现代 Best Practice:

  1. 双源文件 + 链接器选择(推荐):sdf_static.c + sdf_shared.c,构建脚本决定编哪个,源文件零 #ifdef。
  2. 统一接口 + 运行时动态分派:启动时根据配置 dlopen 选择 .so,所有 if 留在运行时而非预处理器。
  3. 条件编译 + 独立头文件(最小侵入):把条件编译压到头文件,源文件只 #include。

为什么链接器能替代 #ifdef? 不是链接器升级了,而是”把问题挪到链接阶段”。静态库 vs 动态库的符号优先级规则一直存在:主程序 .o → 静态库 (.a) → 动态库 (.so)。只要通过构建脚本决定是否把某个 .o 编进来,链接器自动处理优先级,不需要在源码里用 #ifdef 做二选一。

弱符号与空桩

在已有空桩实现时,弱符号是可选的保险丝:

1
2
3
4
5
6
// 空桩:始终存在,返回 NOTSUPPORT,代码清晰
const SDF_METHOD ts_sdf_meth = { ... }; // 所有函数指向 not_support

// 弱符号:只有确实没有任何实现被编译时才生效
__attribute__((weak))
const SDF_METHOD *SDF_get_method(void) { return NULL; }

构建脚本保证优先级:sdf_meth.c 一参与编译就是强符号,弱符号被忽略;只有无任何实现时弱符号才兜底。

Configure 脚本与 build.info

Configure 脚本 负责全局决策:启用/禁用模块(生成宏定义供 C 程序使用)、动态库路径(-L)、头文件路径(-I)、链接库名(-l)。

build.info 负责模块间依赖指定。每个子目录有自己的 build.info,Perl 脚本自动收集所有分散的 build.info 统一生成最终 Makefile。这种设计牺牲了一些集中性,换来了模块独立、易维护、自动化友好。

在 Configure 中启用 SDF 支持:

1
2
3
./Configure --enable-sdf-lib-dynamic \
--with-sdf-lib=/path/to/gmssl/lib \
--with-sdf-include=/path/to/gmssl/include

DSO 机制默认加载名为 "sdf" 的动态库。配置完成后使用 openssl sdf 命令测试。


开源之夏03:C语言工程实践——多态、插件与构建系统
https://47.108.189.123/2025/08/23/开源之夏/03-C语言工程实践:多态、插件与构建系统/
Author
Dong
Posted on
August 23, 2025
Licensed under