10. 库与第三方依赖
把第三方库加入工程,就要同时接受它的接口规则、构建方式、错误处理方式、版本策略和许可证条件。只看调用示例,很难判断它是否适合长期留在工程中。
本章用 paul-j-lucas/c_exception 做一次完整评估。它用标准 C 的 setjmp、longjmp 和宏拼出 cx_try、cx_catch、cx_throw 和 cx_finally,接口简洁,使用时却有不少限制,适合作为评估的例子。第一部分已经讲过 setjmp 与 longjmp 的语言规则;本章讨论这个库的文件组成、接入方式、资源所有权,以及是否采用的判断。
1. 评估要回答的问题
评估开始前,先说明项目希望第三方库解决什么问题。c_exception 面向的场景是:错误可能发生在很深的调用路径中,作者希望把错误传播到较高层的统一处理点,并使用接近异常处理的表面语法。
已有的返回值、错误码和 goto 统一清理 是比较的对象。评估至少要回答下列问题:
| 维度 | 需要回答的问题 |
|---|---|
| 功能 | 它解决的错误传播问题是否真实存在? |
| 接口 | 调用方需要遵守哪些命名、控制流和生存期约束? |
| 实现 | 表面语法最终展开成什么 C 控制流? |
| 资源 | 跳过多层函数返回时,谁释放文件、堆内存和锁? |
| 构建 | 需要哪些工具、生成文件、编译选项和链接产物? |
| 可移植性 | 目标编译器、操作系统和线程模型是否覆盖? |
| 验证 | 上游测试覆盖了什么,下游还需要验证什么? |
| 维护 | 应固定发布版本、标签,还是某个提交? |
| 授权 | 仓库元数据、许可证正文与源码声明是否一致? |
第三方依赖是一组持续生效的契约
第三方依赖的成本贯穿获取、构建、调用、测试、升级和分发。可靠的采用结论应记录上游快照、适用范围、退出方案以及重新评估的触发条件。
2. 固定上游提交
第三方仓库的默认分支会不断前进。教程、缺陷报告和工程决策所引用的源码,应固定到某个具体提交,以便日后复查。
本案例核对的版本
本案例在 2026 年 8 月 8 日核对上游 master,固定到提交 ec8b37a2e27500db09ec4a0ef5b79320de167e4c。后文对源码、构建文件和测试的描述均以这个提交为准。
可以用下面的命令取得同一份源码:
git clone https://github.com/paul-j-lucas/c_exception.git
cd c_exception
git checkout --detach ec8b37a2e27500db09ec4a0ef5b79320de167e4c
git rev-parse HEAD2
3
4
--detach 使工作目录直接停在指定提交上,不跟随任何分支。即使默认分支后来有新提交,看到的源码也不会改变。真正集成到项目时,还应把提交、来源 URL、校验值和更新方式写入依赖清单或锁定文件。
先固定快照,再形成结论
版本名称提供人类可读的发布标识,提交哈希提供精确的源码身份。评估记录应同时保留二者;当两者无法对应时,以已经核对的源码提交限定结论范围。
3. 目录结构与构建方式
查看仓库的顶层目录和 src/,可以大致了解它的构建方式:
c_exception/
├── bootstrap
├── configure.ac
├── Makefile.am
├── lib/
├── m4/
└── src/
├── c_exception.h
├── c_exception.c
├── c_exception_test.c
└── Makefile.am2
3
4
5
6
7
8
9
10
11
各文件的职责如下:
| 路径 | 职责 | 对接入方的含义 |
|---|---|---|
src/c_exception.h | 公开类型、宏和函数声明 | 调用方必须遵守其中各个宏的控制流规则 |
src/c_exception.c | 异常记录、恢复点链和状态迁移 | 需要单独编译并链接到最终程序 |
src/c_exception_test.c | 上游单元测试 | 可用于理解受支持行为并建立回归基线 |
configure.ac | 编译器、功能与检测选项 | 上游构建要求 C11,并提供若干 Sanitizer 开关 |
lib/、m4/ | Gnulib 文件与 Autoconf 宏 | 直接抽取两个核心源码文件时仍需处理生成配置和辅助头文件 |
src/Makefile.am 会构建 libc_exception,再把测试程序链接到该库。可见它不是只含头文件的库,c_exception.c 必须单独编译并链接进最终程序。上游使用 Autotools 构建;改用 CMake、XMake 等构建系统接入时,需要自行编写相应的构建脚本,并重新处理生成的配置头文件、辅助头文件和编译选项。
完整构建契约覆盖公开头文件之外的文件
公开头文件只描述调用接口。实现源文件、生成配置、辅助库、编译定义和链接选项共同决定最终行为。依赖接入应从上游构建图出发,逐项映射到本项目的构建图。
4. 公开接口
库默认暴露带 cx_ 前缀的名字:
cx_trycx_catch(...)cx_throw(...)cx_finallycx_cancel_try()cx_current_exception()
在包含头文件前把 CX_USE_TRADITIONAL_KEYWORDS 定义为 1,还可以使用 try、catch、throw 和 finally。上游默认关闭这些短名字,以免与调用方的标识符冲突。C 没有命名空间,给所有公开名字加上统一前缀,是 C 库避免名字冲突的常用做法。
cx_exception_t 记录抛出位置、整数异常 ID 和可选的 void * 用户数据。异常 ID 可以精确匹配,也可以通过 cx_catch() 捕获任意 ID;库还允许调用方安装自定义匹配函数。
下面是一个只展示公开接口的最小调用示例:
#include "c_exception.h"
#include <stdio.h>
enum {
EX_PARSE = 1
};
static void parse_record(void) {
cx_throw(EX_PARSE);
}
int main(void) {
cx_try {
parse_record();
}
cx_catch(EX_PARSE) {
puts("parse failed");
}
cx_finally {
puts("cleanup");
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
输出:
parse failed cleanup
这段代码只演示了一条成功捕获的路径。决定是否采用之前,还要弄清宏展开后的代码、未捕获异常如何传播、提前离开代码块时会发生什么,以及资源释放和线程状态如何处理。
异常风格语法仍然受 C 控制流约束
cx_try 等名字由预处理宏提供。编译器最终看到的是 for、if、setjmp、函数调用和普通代码块,因此每个宏都可能改变周围语句的结合方式和跳转含义。
5. 宏展开与实现
阅读 c_exception.h 可以看到,cx_try 的骨架由一个 for、两层 if 和 setjmp 组成;cx_catch 与 cx_finally 继续拼接在这条语句链上。阅读 c_exception.c 可以看到,抛出动作最终调用 longjmp 回到最近的恢复点。
各部分的职责如下:
| 层 | 主要工作 |
|---|---|
| 宏层 | 组合出接近异常处理的表面语法 |
| 恢复帧 | 保存 jmp_buf、父恢复帧、当前状态和异常 ID |
| 状态机 | 在 INIT、TRY、THROWN、CAUGHT、FINALLY 之间迁移 |
| 抛出实现 | 写入异常记录,并向最近恢复帧执行 longjmp |
| 捕获实现 | 按异常 ID 或自定义规则判断是否匹配 |
| 收尾实现 | 执行 finally,并继续传播未捕获异常 |
为了支持嵌套,恢复帧通过 parent 形成链。当前异常记录与恢复帧链使用线程局部存储,因此每个线程拥有独立的传播路径。终止处理函数和异常 ID 匹配函数则保存在全进程共享的静态存储中,最好在程序启动时设置好,此后不再修改;如果必须在运行期间修改,需要自行加锁同步。
宏负责语法,状态机负责传播
setjmp 保存返回点,longjmp 完成非局部跳转,恢复帧链确定传播方向,状态机决定捕获、收尾和继续传播。四者合在一起,才构成库对外呈现的异常风格控制流。
6. 使用限制
上游 README.md 明确提醒该库具有严格要求和多项限制。头文件中的 API 注释 给出了具体规则:
| 约束 | 原因 | 工程影响 |
|---|---|---|
在 cx_try 外声明、在块内修改、离开块后继续读取的自动存储期局部对象需要 volatile 限定 | longjmp 跳回之后,未用 volatile 限定的这类对象的值不确定 | 评审代码时要找出所有这类对象 |
使用 cx_try 的函数避开 VLA | 这是上游明确规定的使用条件 | 栈上动态尺寸对象需要改用固定尺寸或显式分配 |
break 只适用于调用方自己写出的循环或 switch | cx_try 自身展开为循环 | 外层控制流可能与代码表面形状不同 |
从 try、catch、finally 中执行 goto 或 return 前先调用 cx_cancel_try() | 库需要先弹出当前恢复帧 | 不能直接写 return 或 goto 离开 |
continue 会推进宏内部状态机 | 它可能直接进入 finally | 代码评审要按宏展开理解目标位置 |
| 用户数据指针指向的对象需要活到处理完成 | cx_throw 只保存指针值 | 每种异常的说明中要写明用户数据的所有权与生存期 |
| 未捕获异常会调用终止处理函数 | 已经没有可用恢复帧 | 需要决定顶层如何终止、如何记录,并为此编写测试 |
cx_cancel_try() 还会改变收尾语义:从 try 或 catch 中调用它后,当前 finally 不会执行,未捕获异常也不会继续传播。因此,调用 cx_cancel_try() 的分支必须自己完成原本由 finally 负责的清理。
这些限制都源于同一个事实:宏只是用现有的 C 语句拼出新的写法。C 没有栈展开和析构,跳转语句的含义也不会因为宏而改变。采用这类库时,应把上述限制写进代码评审清单,并用自动化测试覆盖。
宏接口的展开结果属于公开契约
当宏引入循环、分支或非局部跳转时,它与 break、continue、goto、return 的交互会直接影响程序行为。接口文档、示例和测试都应覆盖这些交互。
7. 资源所有权
longjmp 直接跳过中间各层函数的正常返回过程,除了转移控制之外不做任何事。关闭文件、释放堆内存、解锁互斥量等清理工作都要由程序显式完成。每项资源都应有明确的所有者,由所有者保证在正常完成、当前层捕获和继续向外传播三种情况下都释放它。
下面的结构让取得文件的函数同时负责关闭文件。内层 finally 在正常完成和异常传播前都会执行;缺少匹配的 catch 时,库会在 finally 之后把异常继续传给外层恢复帧。
#include "c_exception.h"
#include <stdio.h>
enum {
EX_OPEN = 1
};
static void parse_stream(FILE *fp);
static void parse_file(const char *path) {
FILE *fp = fopen(path, "rb");
if (fp == NULL) {
cx_throw(EX_OPEN);
}
cx_try {
parse_stream(fp);
}
cx_finally {
fclose(fp);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这种写法要求每个资源的所有者各自安排清理。若 parse_stream 内部还取得了堆内存或锁,这些资源也应由取得它们的那一层释放。另一种常见方案是使用区域分配器或集中的资源栈,由较高层一次回收整组资源;采用这种方案时,整个子系统都要遵循同一套资源登记规则。
对多数 C 接口而言,返回状态码、通过输出参数传回结果、用 goto 跳到统一的清理标签,就足以写出清楚的错误处理代码,每一步失败后如何处理都直接写在调用处。在解释器、解析器这类调用层次很深的程序中,异常风格的控制流可以省去逐层转发错误的代码,但跳转路径不再出现在代码表面,对资源管理的要求也更严格。是否采用,取决于实际的调用深度、资源种类和团队习惯。
资源所有权保持在取得资源的代码层
控制流可以直接越过若干调用层,资源责任仍留在取得资源的代码层。可靠设计需要让每个所有者在传播发生前完成清理,或把资源统一登记到可整体回收的区域中。
8. 构建与测试
上游要求 C11,并以 Autoconf、Automake、Libtool 和 M4 生成构建文件。仓库中的 bootstrap 会运行 autoreconf -fi。在具备这些工具的 POSIX shell 环境中,基本验证流程为:
./bootstrap
./configure
make
make check2
3
4
configure.ac 还提供 AddressSanitizer、MemorySanitizer 和 UndefinedBehaviorSanitizer 开关。选用哪些开关取决于编译器是否支持,并且应在干净的构建目录中执行。例如:
./configure --enable-asan --enable-ubsan
make
make check2
3
上游 README.md 建议调用方使用 -Wno-dangling-else 和 -Wno-shadow。如果接受这些选项,应只对第三方库本身和确实使用这些宏的源文件启用,项目的其余源码仍保留完整的警告。关闭了哪些警告,也要写进采用记录。
当前测试文件覆盖正常完成、精确捕获、任意捕获、深层函数抛出、自定义 ID 匹配、嵌套传播、重新抛出和用户数据。下游集成测试还应补充:
cx_cancel_try()与提前离开代码块;- 未捕获异常和自定义终止处理函数;
- 每条传播路径上的文件、堆内存和锁释放;
- 多线程分别嵌套恢复帧;
- 运行期间修改共享处理函数时的同步策略;
- 目标编译器的优化构建与 Sanitizer 构建。
在 Windows 工程中,可以选择提供 Autotools 的兼容环境运行上游流程,也可以为现有构建系统编写适配目标。后一条路径需要显式处理 config.h、Gnulib 辅助文件、线程局部存储写法和平台编译选项,并在 CI 中持续验证。
上游测试是基线,下游测试负责真实接入环境
上游测试证明维护者声明的若干行为,下游测试证明固定快照在本项目的编译器、构建选项、线程模型和资源路径中满足要求。两层证据共同支撑采用结论。
9. 版本与许可证
固定提交中的 configure.ac 把项目版本写为 1.2,NEWS 也记录了 1.2;在本案例核对日期,公开标签列表 只列出 c_exception-1.1。因此,采用方需要在“较旧的已标记版本”和“较新的精确提交”之间做出明确选择,并记录选择依据。
各处的许可证声明也不一致:
- GitHub 仓库元数据与
COPYING指向 LGPL 3.0; NEWS写明 LGPL 第 3 版或更新版本;src/c_exception.h与核心源码头部写明 GPL 第 3 版或更新版本。
许可证需要向上游确认
同一提交中的许可证声明互不一致。实际复制、修改、链接或分发之前,应向上游维护者确认适用的许可证,并保留书面答复。本节只列出所见事实,不构成法律意见。
可见仓库页面上的许可证徽章只能作为线索,还要逐一核对许可证正文、源码头部和发布说明,并结合项目实际的分发方式判断。
版本身份与授权身份都要能够追溯
工程需要知道“采用了哪份源码”以及“依据什么条件使用和分发”。版本记录解决前一个问题,一致且明确的授权材料解决后一个问题。
10. 采用记录
调查完成后,把结论写成一份简短的记录,例如:
依赖:paul-j-lucas/c_exception
用途:
固定快照:
目标平台与编译器:
接入方式:
公开接口:
失败传播规则:
资源清理规则:
线程配置规则:
上游验证:
下游验证:
版本依据:
授权依据:
替代方案:
采用结论:
重新评估触发条件:2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
就本案例而言,结论可以是:c_exception 适合作为学习宏、状态机、非局部跳转和依赖评估的源码材料。若要在生产工程中采用,应先确认许可证,固定具体提交,在目标构建环境中完成验证,为资源所有权和提前离开代码块制定统一规则,并补齐下游集成测试。这些工作完成之前,继续使用返回值、错误码和统一清理标签处理错误。
采用记录还应写明退出方案。例如在项目中定义一层自己的适配接口,业务代码只依赖本项目的错误处理方式;将来更换实现时,只需修改适配层和少量集成测试。
采用结论必须能够指导下一步行动
有效记录会给出固定快照、接入方式、验证要求、未决事项、退出方案和重评条件。它既能支持当前决策,也能让未来升级或替换依赖时复用已有证据。
11. 习题
阅读固定快照中的 cx_try、cx_catch、cx_finally 与 cx_impl_try_condition(),画出以下三条路径的状态迁移:正常完成、成功捕获、内层未捕获后向外层传播。每条路径都要标出 setjmp、longjmp 和 finally 的执行位置。
为最小调用示例增加一个文件资源和一块堆内存,分别在取得每项资源后注入 cx_throw。设计测试证明正常完成、当前层捕获和向外层传播三种情况下都只释放一次,并说明每项资源的所有者。
按照本章模板写一份 c_exception 采用记录。目标环境由你选择,但必须包含编译器、构建系统、线程模型、固定快照、警告处理、上游与下游测试、授权未决事项、替代方案和重新评估触发条件。