如何修复 autoconf 式的配置探测

Hacker News Top 新闻

摘要

build2 的 Boris Kolpackov 撰文分析 autoconf/CMake 式编译探测配置的四大问题(浪费、脆弱、缓慢、缺乏变更追踪),并提出基于期望(expectation-based configuration)的替代方案。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/10 00:28

# 如何修复 Autoconf 风格的配置探测 来源:https://build2.org/blog/fix-autoconf.xhtml *发布于`2026年10月8日`,作者:`Boris Kolpackov`* 配置探测(configuration probing)的实现方式——如`autoconf`/CMake 等——是通过编译并链接一个测试程序,来确定目标平台上是否可用某个特定功能,例如某个函数。举例来说,我们可能希望在代码库中使用`strl\*\(\)`系列函数。然而,这些函数尚未(完全)标准化,并非所有 libc 实现都提供。因此,我们可能需要检测这些函数是否存在;如果不存在,则提供回退实现或使用替代方案。 实现这种检测的一种方式是编译并链接一个测试程序,尝试使用我们关注的函数。如果成功,我们就得出结论:这些函数可用。 表面上看,这个方案很有吸引力。特别是它具备自适应性——我们不需要为那些甚至尚未存在的平台做任何适配工作。例如,如果有人决定为 Linux 再写一个全新的 libc,我们无需做任何事情来支持它——现有的`strl\*\(\)`探测会自行搞定。事实上,甚至我们项目已发布的版本也会自动支持这个新 libc。 这种方式确实存在几个令人头疼的问题。主要如下: 1. **它很浪费**:在 FreeBSD 这样的平台上,这些函数早已存在,完全没必要反复编译`strl\*\(\)`探测程序。极端情况下这会变得荒唐可笑,比如某个最新目标平台连执行探测程序都无法完成,却还在为它探测某个特性。一个好的例子请参阅[《A Generation Lost in the Bazaar》](https://queue.acm.org/doi/10.1145/2346916.2349257)。 2. **它很脆弱**:我们根据测试程序编译/链接失败来判断某个特性不存在。但导致编译或链接失败的原因还有很多:测试程序中的错误、构建配置错误、缺少特性测试宏(如`\_GNU\_SOURCE`)等等。例如,近期因草率编写的探测程序导致的错误否定(false negative),引发了大量[哀叹](https://news.ycombinator.com/item?id=35213667)和[抱怨](https://news.ycombinator.com/item?id=39429627)。这些探测之所以停止编译,是因为 GCC 和 Clang 不再接受某些早已废弃的 C 语言构造。其失败模式同样很隐蔽:错误否定会悄无声息地导致该特性不被使用,造成缺失功能、性能不佳等问题。 3. **它很慢**:虽然编译单个探测程序不需要多长时间,但编译几百个就是可感知的耗时了。更糟糕的是,`autoconf`和 CMake 都是串行执行这些编译的。 4. **它缺乏变更追踪**:现有工具(`autoconf`、CMake)在其输入发生变化时不会重新运行相关探测。例如,`strl\*\(\)`是在 glibc 2\.38 中添加的。如果我们从 2\.37 升级到 2\.38,我们会希望机器上所有已配置的项目都能检测到这一变化,并开始使用新提供的函数。 要解决第一个问题(浪费),需要一种完全不同的方法。一种替代方案是使用我们可称之为"基于期望的配置"(expectation-based configuration)的方法:如果满足某些条件,我们就假定某个特性可用。例如,对于`strl\*\(\)`,我们可以假设如果目标平台是 FreeBSD 或 glibc 版本为 2\.38 及以上,这些函数就可用(当然,[一个完整的实现](https://github.com/build2/libbuild2-autoconf/blob/master/libbuild2-autoconf/libbuild2/autoconf/checks/HAVE_STRLCPY.h)还需要检查其他平台和/或 libc 实现)。这种方法已在`build2`中被成功用于 Qt 和 FFmpeg 这类配置密集型项目(详见[libbuild2-autoconf](https://github.com/build2/libbuild2-autoconf/))。 好的,假设出于某些原因或在某些特殊场景下,我们仍然希望进行配置探测。那么我们能否解决,或者至少缓解剩下的问题?让我们把脆弱性问题留到最后讨论,先攻克剩下的两个:速度慢和缺乏变更追踪。 我们进行配置探测时所做事情的高层视图可以概括如下:我们编译并链接一系列测试程序,只不过我们要的结果不是这些程序本身,而是状态——编译和链接成功还是失败。我们希望能够并行执行这些操作,同时追踪输入的变更:测试源文件本身、它递归包含的头文件集合、编译/链接选项等。 这个场景的形态是不是看起来很眼熟?什么样的现有问题需要我们并行编译和链接一堆源文件,并且还要正确追踪变更?没错,这就是我们使用现有构建系统构建软件的方式。即使是`make`也能相当好地做到这一点。 显然,CMake 会为每个探测生成一个独立的项目,然后运行底层构建系统来构建它。但 CMake 既没有利用这一点来并行运行多个探测,也没有用来追踪变更。那么,我们能不能干脆直接使用构建系统来做探测?而且,在此过程中我们能不能去掉整个独立的配置/项目生成步骤? 其工作方式可以是这样的:我们运行构建系统来更新项目,它根据需要构建(或重新构建)探测程序,然后利用产生的信息来构建我们的项目源代码。具体到我们的`strl\*\(\)`示例,如果这些函数的探测返回否定结果,构建系统就会编译并链接`strlcpy\.c`和`strlcat\.c`回退实现。 使用真正的构建系统来做探测的另一个好处是可以在探测结果之间建立依赖关系。例如,如果环境中没有`<string\.h>`,就没必要浪费时间探测`strl\*\(\)`。 不过,有一个棘手的问题:在加载和评估构建文件(buildfile)时,探测结果必须已经可知。例如,我们在评估构建文件定义时就要决定是否将`strlcpy\.c`和`strlcat\.c`纳入构建。以下是一个基于 GNU`make`的示例: ``` hello: hello.o hello.o: hello.c ifndef have_strlcpy hello: strlcpy.o strlcpy.o: strlcpy.c else CPPFLAGS += -DHAVE_STRLCPY endif ifndef have_strlcat hello: strlcat.o strlcat.o: strlcat.c else CPPFLAGS += -DHAVE_STRLCAT endif ``` 在上面的示例中,`have_strlcpy`和`have_strlcat`需要在`make`评估此 makefile 时就已知,但如果运行相应探测是整体构建的一部分,那么它们的值只有在 makefile 被评估完毕、`make`开始实际构建目标之后才能得知。 如果我们拥有暂停加载构建文件、更新特定目标、将结果加载回构建文件、然后恢复加载的能力,就能轻松克服这个障碍。GNU make 具备此功能的一个变体:如果`include`指令指定的 makefile 不存在或已过期,`make`会尝试更新它。然而,其实现有两个不幸的特性: 首先,`make`在遇到`include`指令时不会停下来去更新 makefile。相反,它会忽略不存在的或已过期的被包含 makefile,继续评估直到结束,然后才尝试更新它们。这意味着我们的 makefile 需要能够处理探测结果尚未知或已过期的情况。 其次,如果任何被包含的 makefile 被更新了,`make`会从头开始重新加载 makefile。这对于大型项目会带来相当可观的性能损失。 在`build2`中,我们实现了对[加载期间更新](https://build2.org/build2/doc/build2-build-system-manual.xhtml#directives-update)的"完整"支持,没有上述任何缺陷。具体而言,我们会停止评估构建文件,更新所有相关目标,加载结果,然后继续加载,不会有任何重启。我们将此功能用于在主构建过程中实现配置探测,效果令人满意(见下文的一些性能数据)。 一旦你的构建系统具备了加载期间更新的能力,你往往会开始发现各种需要发现并向构建系统反馈信息的场景。例如,此功能可用于提取 C 或 C\+\+ 编译器的预定义宏(predefs),并在评估构建文件时将其作为变量使用。 在尝试解决脆弱性问题之前,让我们讨论另一个相关细节。`autoconf`和 CMake 实现函数探测的方式是编译并链接一个测试程序。它们也不依赖函数声明在任何头文件中是否存在,而是自行声明。换句话说,它们真正检查的是相应符号在库中是否存在。这种方法有一长串的边界情况和缺点: 函数可能是内联的或编译器内建的(因此没有符号)。符号可能存在,但对应的头文件中的函数声明可能未被启用。或者函数签名可能与我们预期不匹配,导致我们的调用点无效。 举一个具体的例子:从 glibc 2\.38 起,一个自带`strlcpy\(\)`声明的探测程序即使在没有定义`\_GNU\_SOURCE`的情况下编译也能正常链接。但``中`strlcpy\(\)`的声明只有在编译时定义了该宏的情况下才会被启用。 相较于检查库符号是否存在,一种替代方法是通过包含标准头文件来获取声明,并检查调用点是否能编译通过。这种方法不存在上述任何边界情况,而且与实际代码中使用该函数的方式非常接近。它确实需要在编译 C 探测程序时禁用(已废弃的)隐式函数声明,但对于现代 C 编译器来说这并不困难。 因此,我的建议是对函数探测使用调用点编译(call-site compilation)的方式。这种方法还有一个额外优势:我们可以使用 GCC 和 Clang 的`\-fsyntax\-only`选项(MSVC 使用`/Zs`)来显著加快速度。 解决脆弱性问题颇具挑战性。简而言之,我们需要将由所探测特性缺失导致的失败与所有其他失败区分开来。直接做到这一点需要分析编译器诊断信息——希望你也能看出这显然是行不通的。分析诊断信息的问题在于有众多不同的编译器,而且诊断信息的措辞甚至可能在不同版本之间发生变化。同时,一个探测程序可能以多种方式失败来表明某个特性缺失:头文件缺失、函数声明缺失、参数/实参不匹配(各种形式)、返回值不匹配等等。这意味着你需要维护一份不断增长的编译器/版本的诊断信息模式列表。 提高成功概率的一种方法是设法将自己限制在单一编译器版本上。也许最近开源的[EDG 编译器前端](https://edgcpp.org/)在这方面能派上用场? 我们可以尝试的次优方案是设置一个"对照"探测(control probe)。其思想是编写原始探测程序的一个变体,该变体在所有相同的情况下都应失败,除了我们关注的特性确实缺失的情况。对照探测应尽可能模拟原始探测:包含相同的头文件、使用相同的语言构造、具有相同的逻辑等。事实上,最好将两种变体实现在同一个源文件中。以下是`strlcpy\(\)`探测的一个示例: ```c #include <string.h> size_t f (void) { char dst[8]; #ifndef CONTROL size_t n = sizeof (dst); size_t r = strlcpy (dst, "strlcpy", n); #else strcpy (dst, "strlcpy"); size_t r = 7; #endif return r; } ``` 将所有内容整合在一起,运行探测涉及两个步骤: 1. 编译对照探测,传递所有诊断信息,如果编译失败则探测失败。同时提取头文件依赖信息。 2. 编译实际探测,忽略所有诊断信息。如果编译成功,则认为特性存在;否则——认为缺失。 虽然对照探测的想法看起来像是一个巧妙的解决方案,但它并非没有缺点。主要在于编写一个好的对照探测可能颇具挑战性。对于`strlcpy\(\)`,使用`strcpy\(\)`来实现一个非常接近的对照探测相当容易。这可以确保我们包含的头文件存在且可用、使用的语言语法和逻辑是正确的等。在其他情况下,编写一个贴近的对照探测可能就更困难了。 我们已经在`build2`中测试了所有这些改进,你可以在 [HOWTO 文章](https://github.com/build2/HOWTO/blob/master/entries/implement-configuration-probing.md)和[示例](https://github.com/build2/autoprobe)中找到更多细节。我们还评估了整个方法的性能:在现代硬件(如 Intel i9\-12900K)上运行 500 个探测(包括对照探测)的开销大约为半秒钟。

相似文章