配置探测靠编译并链接一个测试程序,判断目标平台上某个函数或特性是否存在,这个结论经常不准。问题有四个,探测浪费、结论脆弱、串行执行慢、输入变了不重跑。更稳的做法分两层,用基于条件的预期配置替掉大部分探测,剩下的改成包含标准头文件再编译调用点,再配一个对照探测把特性缺失和其他编译失败分开。
探测在解决什么问题
autoconf 和 CMake 判断某个函数能不能用,做法是编译并链接一个小程序,链接通过就认定特性存在。strlcpy 和 strlcat 不属于 C 标准,也不是每个 libc 都提供,代码想用就得先探一次,没有就带上兜底实现。
误判从哪里来
探测把编译链接失败直接等同于特性不存在,失败的原因远不止这一种。测试程序写错、构建配置有问题、漏掉 _GNU_SOURCE 这类特性宏,都会让编译失败。有一批探测集体编译不过,原因是 GCC 和 Clang 不再接受某些长期废弃的 C 写法。假阴性很安静,代码不再使用本来存在的函数,构建时一句报错都没有。
符号存在不等于函数可用
autoconf 和 CMake 写函数探测时不看头文件里的声明,而是在测试程序里自己再声明一遍,检查的是库里有没有对应符号。函数可能是内联的或者编译器内建的,根本没有符号。符号在,头文件里的声明却要特定宏打开后才出现。签名不符时,调用点照样编译不过。glibc 2.38 之后,探测自己声明 strlcpy 就能链接成功,哪怕没定义 _GNU_SOURCE。
浪费、串行执行和变更跟踪缺失
在 FreeBSD 上继续探测 strl*() 没有意义。单个探测不费时间,几百个一起跑就很明显,autoconf 和 CMake 还是串行执行的。变更跟踪缺失指输入变了不重跑,glibc 2.38 加入了 strl*(),从 2.37 升级上来的机器上,配置过的项目不会察觉。交叉编译让这两条更麻烦,探测链接的是目标库,工具链和头文件版本换了,结论不会自动跟着变。
先用预期配置替掉大部分探测
预期配置先写下条件,条件满足就按特性存在处理。
- 把探测项列出来,标出每一项在哪些平台上可能缺失。
- 给每一项写条件,例如目标为 FreeBSD 或者 glibc 2.38 及以上时,strl*() 按存在处理。
- 条件不成立就链接兜底的 strlcpy.c 和 strlcat.c,成立就定义 HAVE_STRLCPY 这类宏。
- 覆盖不到的组合留给探测。
必须探测时改用调用点编译和对照探测
- 探测改成包含标准头文件再编译调用点,不再自己声明函数。编译 C 探测要关掉隐式函数声明,GCC 和 Clang 可以加 -fsyntax-only,MSVC 用 /Zs。
- 每个探测再写一个对照版本,和原探测同放一个源文件,用 CONTROL 这类宏切换。对照版本的头文件、语言构造和逻辑都一样,只把待测函数换成一定存在的等价写法,比如用 strcpy 顶替 strlcpy。
- 先编译对照版本,诊断信息原样放出来。它失败说明探测环境有问题,停下来查。
- 再编译探测版本并忽略诊断信息,成功按存在处理,失败按不存在处理。
- 从对照版本提取头文件依赖,交给构建系统跟踪。
把探测并进构建系统
探测就是并行编译一批源文件并跟踪依赖,构建系统每天做这件事。麻烦出在加载构建描述时就需要探测结果,结果却要等目标更新完才有。GNU make 处理 include 的 makefile 时不停下来更新,最后才回头更新。build2 做了加载期间更新,遇到需要探测的地方停下、更新目标、读回结果再继续加载。实测 500 个探测连同对照在 Intel i9-12900K 上约半秒。
常见问题
迁移时已有的探测怎么处理? 按平台和 libc 分类,能写成条件的替换掉,其余保留。
哪些场景还得靠探测? 目标平台或 libc 实现未知、条件写不出来的组合。