AutoTools-指南第二版-全-

AutoTools 指南第二版(全)

原文:zh.annas-archive.org/md5/448fd4fd09416549ad42e2d9b26daeb9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Image

很少有开源软件开发者会否认,GNU Autoconf、Automake 和 Libtool(即Autotools)已经彻底改变了开源软件的世界。然而,尽管有成千上万的 Autotools 支持者,也有许多软件开发者讨厌Autotools,甚至怀有强烈的反感。我认为这种对 Autotools 的恐惧源于,当你在没有理解它们管理的底层基础设施的情况下使用它们时,你会发现自己正在与系统对抗。

本书通过首先提供一个框架,帮助读者理解 Autotools 的底层基础设施,然后在此基础上采用基于教程的方式,按逻辑顺序讲解 Autotools 概念,从而解决了这一问题。

谁应该阅读本书

本书主要面向那些希望成为 Autotools 专家的开源软件包维护者。不过,本书也为希望了解下载、解压和构建由 Autotools 管理的构建过程的软件包的最终用户提供了指导。现有的相关资料主要限于 GNU Autotools 手册和一些基于互联网的教程。多年来,绝大多数实际问题都通过 Autotools 邮件列表得到了回答,但邮件列表的教学效率较低,因为同样的问题会一遍又一遍地被回答。本书提供了一种类似食谱的方式,涵盖了在真实项目中遇到的实际问题。

本书的组织结构

本书从最终用户的角度介绍了 Autotools,接着从高层次的开发构建概念讲解到中层的用例和示例,最后以更高级的细节和示例做总结。就像学习算术一样,我们将从一些基础数学——代数和三角学开始,然后逐步深入到解析几何和微积分。

第一章提供了关于 Autotools 的最终用户视角。它涵盖了 Linux 高级用户所需了解的主题,虽然他们不一定是软件开发者,但为了充分利用 Autotools 管理的源包(所谓的“tarballs”)的功能,了解这些内容是必要的。这些源包可能包含用户想要尝试的软件的最新 beta 版本,通常从项目网站下载。Linux 用户常常发现,解决软件问题的办法是更新到包含修复的版本,但结果却发现所需的版本太新,以至于他们选择的 Linux 发行版的任何软件包仓库中都没有该版本的 RPM 或 Debian 包。第一章为需要了解如何处理包含configure脚本和所有.c源文件的tar.gz文件的新手提供了帮助。

第二章开始讨论对软件开发者感兴趣的概念。它概述了被认为是 GNU Autotools 一部分的各个软件包。本章描述了这些软件包之间的交互关系,以及每个软件包所使用和生成的文件。在每个案例中,图示展示了从手工编写的输入到最终输出文件的数据流动。

第三章介绍了开源软件项目的结构和组织。本章还详细讨论了GNU 编码标准 (GCS)文件系统层次标准 (FHS),这两个标准在 GNU Autotools 的设计中起到了至关重要的作用。本章展示了一些设计每个 Autotools 的基本原则。有了这些概念,你将更好地理解 Autotools 设计师在架构决策背后的理论。

在本章中,我们还将从头到尾设计一个简单的项目——Jupiter,使用手工编写的 makefile。随着我们发现可以简化任务并为开源软件用户提供期望功能的功能,我们将一步步添加到 Jupiter 项目中。

第四章和第五章介绍了 GNU Autoconf 工程师为简化创建和维护可移植、功能性项目配置脚本所设计的框架。GNU Autoconf 包提供了创建复杂配置脚本的基础,项目维护者只需提供几行信息即可。

在这些章节中,我们将快速将手工编写的 makefile 转换为 Autoconf Makefile.in 模板,并开始向其中添加内容,以便获得一些最重要的 Autoconf 好处。第四章讨论了生成配置脚本的基础知识,而第五章则介绍了更高级的 Autoconf 主题、特性和应用。

第六章从将 Jupiter 项目的 Makefile.in 模板转换为 Automake Makefile.am 文件开始。在这里,你将发现 Automake 对于 makefile 就像 Autoconf 对于配置脚本一样。 本章介绍了 Automake 的主要特性,以一种随着 Automake 新版本发布而不至于过时的方式呈现。

第七章和第八章解释了共享库的基本概念,并展示了如何使用 Libtool 构建共享库——这是一个用于共享库功能的独立抽象,可与其他 Autotools 一起使用。第七章从共享库简介开始,接着介绍了一些基本的 Libtool 扩展,使得 Libtool 成为可以替代 Automake 提供的更基础的库生成功能的现成工具。第八章则介绍了库版本管理和 Libtool 提供的运行时动态模块管理抽象。

第九章介绍了 Autotools 中的一个相对较新的功能——autotest。Autoconf 中的 autotest 功能使你能够轻松创建和管理集成测试执行框架。在之前的章节中,我们已经讨论了单个 makefile 中的单元测试。autotest 提供了一种机制,用于添加更多依赖于项目多个组件的全局测试。说实话,autotest 可以用于几乎任何你想要的测试。我们将重点介绍如何添加 autotest 测试套件,以确保你的项目按预期自动运行。

第十章讨论了查找编译时和链接时依赖关系的概念,并将适当的引用添加到构建工具命令行中。具体来说,本章介绍了pkg-config,它已成为 Linux 软件开发中的事实标准,提供了一个框架,便于查找和使用你的软件包依赖的组件。本章向你展示了如何使用pkg-config.pc文件来查找依赖关系,以及如何为你的项目提供.pc文件,遵循良好的实践。

第十一章和第十二章分别讨论了国际化(缩写为i18n)和本地化(l10n)——在你的项目中轻松管理文本字符串和其他与地区相关的属性(如数字、货币和日期的引用),这些内容在本地化版本的项目中应有所不同。

第十三章讨论了通过使用 Gnulib,在你的项目中获得最大的可移植性。

第十四章和第十五章展示了如何将一个已有的、相对复杂的开源项目(FLAIM)从使用手工构建系统转变为使用 Autotools 构建系统的过程。这个例子将帮助你理解如何将你自己的现有项目进行autoconfiscate(即自动配置化)。

第十六章概述了与深入理解 Autoconf 相关的 M4 宏处理器的功能。本章还讨论了编写自己 Autoconf 宏的过程。

第十七章讨论了使用 Autotools 构建旨在运行在 Microsoft Windows 平台上的软件。我将向你展示如何在 Linux 上为 Windows 进行交叉编译,以及如何安装和使用三种最流行的基于 Windows 的 POSIX 平台——Cygwin、Msys2 和 MinGW——使用 GNU 工具,包括 Autotools,来构建 Windows 软件。

微软提供了一套很棒的免费工具,用于构建 Windows 软件,但如果你的软件包已经在 Linux 上运行并使用 POSIX 构建工具进行构建,使用 Autotools 构建 Windows 软件可以让你快速启动并运行。在此基础上,你可以决定你现有的工具是否足够好,或者是否需要为 Windows 提供一个原生的构建环境。

第十八章是关于 Autotools 问题的技巧、窍门和可重用解决方案的汇编。本章中的解决方案以一组独立的主题或条目呈现,每个条目可以在没有上下文的情况下单独理解。

第三章至第九章围绕 Jupiter 项目构建。第十四章和第十五章介绍 FLAIM 项目。第十一章和第十二章讨论 gettext 项目,第十三章讨论 b64 项目。这些项目可以在 GitHub 的 NSP-Autotools 站点找到,链接是* github.com/NSP-Autotools*。

除了 FLAIM 项目外,每个这些仓库都有标记,标记的提交表示主题的过渡。这些标签在相关章节的边缘被标出。当你在书中看到标签时,你可以通过检查仓库中的标记提交来轻松跟随。

本书中使用的约定

本书包含数百个程序列表,大致分为两类:控制台示例和文件列表。控制台示例没有标题,用户输入部分是加粗的。

通常,我会使用 Linux 的ls命令并加上各种选项,在进行更改前或更改后显示目录内容。不同的 Linux 发行版通常默认启用ls的别名。我正在使用 Linux Mint 18 和 Cinnamon 桌面编写本书;我为ls定义的别名是:

$ alias
--snip--
alias ls='ls --color=auto'
$

你可能会发现你的系统上有一个ls别名,它提供了不同的默认功能,这可能会影响你精确复制我控制台示例的尝试。只需了解这些可能差异的原因。

文件列表包含文中讨论的文件的完整或部分列表。所有命名的列表都提供在关联的 git 仓库中。我尽力为修改过的部分提供足够的上下文,这样你就能轻松看到哪些行被添加或更改。然而,有一些列表中删除了行。在这些情况下,我在靠近列表的文本中指出了删除的行。

没有文件名的列表完全包含在打印的列表中,应该独立地看待这些内容,而不是作为提供的源代码库的一部分。一般来说,和之前列出的文件版本保持一致的文本会被灰色显示,而修改过的部分则会用黑色文本显示。

对于与 Jupiter 和 FLAIM 项目相关的列表,标题首先指定文件相对于项目根目录的路径,然后提供该文件在列表中所做的更改描述。

在整本书中,我将 GNU/Linux 操作系统简称为 Linux。应理解的是,使用 Linux 一词时,我指的是 GNU/Linux,它的正式名称。我使用 Linux 仅仅是作为该正式名称的缩写。

本书中使用的 Autotools 版本

Autotools 一直在更新——平均而言,三个最重要工具(Autoconf、Automake 和 Libtool)的每次重大更新大约每一年半发布一次,且小更新每三到六个月发布一次。Autotools 开发者会尽力保持新版本的合理向后兼容性,但偶尔会有重大内容被破坏,旧文档因此变得过时。最近,Autotools 被认为已经成熟完整;发布周期变慢,重大变化也很少发生。这对社区和你作为读者来说都是好事,因为这意味着你在本书中找到的内容将在很长一段时间内保持相关性。

虽然我描述了最近发布的 Autotools 版本的新特性,但为了让本书更具长久性,我尽量坚持描述一些已经广泛使用多年的 Autotools 特性(例如 Autoconf 宏)。细节偶尔会有所变化,但一般使用方法在多个版本中保持一致。

在本书的适当位置,我提到了我使用的 Autotools 版本,但在这里我将做一个总结。我使用了版本 2.69 的 Autoconf,版本 1.15 的 Automake(截至写作时,最新版本实际上是 1.16.1),以及版本 2.4.6 的 Libtool。在出版过程中,我能够做出一些小的修正,并随着新版本发布进行更新。

此外,我使用了版本 0.19.7 的 GNU gettext(最新版本为 0.20.1)和版本 0.29.1 的 pkg-config(最新版本为 0.29.2)。GNU 可移植性库 Gnulib 不是以软件包形式发布,而是作为一组代码片段,直接从 GNU 网站下载(https://www.gnu.org/software/gnulib/)。

第一章:GNU Autotools 的最终用户视角

我不害怕风暴,因为我在学习如何驾驶我的船。

路易莎·梅·奥尔科特,《小妇人》

Image

如果你不是一名软件开发者,无论是职业上的还是兴趣上的,你可能在某些时候仍然需要或希望构建开源软件以安装在你的计算机上。也许你是一个图形艺术家,想使用最新版本的 GIMP,或者你是一个视频爱好者,需要构建一个较新的 FFmpeg 版本。因此,本章可能是你在本书中阅读的唯一一章。我希望情况不是这样,因为即使是高级用户,通过努力理解幕后发生的事情,也能获得更多的收获。不过,本章是为你设计的。在这里,我将讨论如何处理你从项目网站下载的所谓 tarball。我将使用 Autoconf 包来说明,并尽量提供足够的背景信息,使你能够遵循相同的流程来处理任何你从项目网站下载的包^(1)。

如果你是软件开发者,那么本章的内容很可能对你来说过于基础;因此,我建议你跳到下一章,我们将在那里深入讨论 Autotools,更侧重于开发者的内容。

软件源代码归档

开源软件作为单文件源代码归档进行分发,包含了在你的系统上构建软件所需的源代码和构建文件。Linux 发行版通过预构建这些源代码归档,并将已构建的二进制文件打包成以 .rpm(针对 Red Hat 系统)和 .deb(针对 Debian/Ubuntu 系统)等扩展名结尾的安装包,减轻了最终用户的痛苦。使用系统的包管理器安装软件相对容易,但有时你需要某些软件的最新功能集,而这些功能还没有为你的 Linux 版本打包。在这种情况下,你需要从项目网站的下载页面下载源代码归档文件,然后自己构建和安装它。让我们首先下载版本 2.69 的 Autoconf 包:

$ wget https://ftp.gnu.org/gnu/autoconf/autoconf-2.69.tar.gz

源代码归档文件的名称通常遵循 Autotools 支持的事实标准格式。除非项目维护者特别修改了这个格式,否则 Autotools 会自动生成一个源代码归档文件,其命名遵循以下模板:pkgname-version.format。这里,pkgname 是软件的简短名称,version 是软件的版本,format 表示归档格式或文件扩展名。format 部分可能包含多个点,具体取决于归档的构建方式。例如,.tar.gz 表示格式中有两个编码——一个 tar 归档,使用 gzip 工具进行了压缩,就像 Autoconf 源代码归档文件那样:

$ ls -1
autoconf-2.69.tar.gz
automake-1.16.1.tar.gz
gettext-0.19.8.1.tar.gz
libtool-2.4.6.tar.gz
pkg-config-0.29.2.tar.gz
$

解压源代码归档

按照惯例,源代码归档包含一个作为顶级条目的单一根目录。你应该放心地将源代码归档解压缩到当前目录中,只会看到一个新目录,目录名称与归档文件的名称相同,只是去掉了 格式 部分。使用基于 Autotools 的构建系统打包的源代码归档,从不将原始顶级目录的内容解压到当前目录中。

然而,偶尔你会下载一个归档文件并解压缩它,结果发现当前目录中出现了数十个新文件。因此,将一个来源不明的源代码归档解压缩到一个新的、空的子目录中是明智的做法。如果需要,你随时可以将其移动到上一级目录。此外,你可以使用 tar 工具的 t 选项(而不是 x 选项)来查看将会发生什么,这会列出归档文件的内容,而不解压缩它。unzip 工具也支持 -l 选项来达到同样的效果。

源代码归档可以有多种形式,每种形式的文件扩展名都不相同:.zip.tar.tar.gz(或 .tgz)、.tar.bz2.tar.xztar.Z 等等。这些源代码归档文件中包含的是用于构建软件的源代码和构建文件。最常见的这些格式是 .zip.tar.gz(或 .tgz)和 .tar.bz2。近年来逐渐流行的新格式包括 .xz(最新的 Autotools 已经原生支持)和 .zstd

ZIP 文件使用的是几十年前由 Phil Katz 在 Microsoft DOS 系统上开发的压缩技术。ZIP 是一种专有的多文件压缩归档格式,后来被公开发布。此后,Windows 和 Linux 以及其他类 Unix 操作系统都为其编写了版本。在 Windows 的较新版本中,用户只需在 Windows 资源管理器中右键点击 .zip 文件并选择 提取 菜单选项,就可以解压缩该文件。Linux Gnome 桌面上的 Nautilus 文件浏览器(在 Mint 的 Cinnamon 桌面中为 Nemo)也有相同的功能。

ZIP 文件可以通过 Linux 命令行使用几乎无处不在的 unzip 程序解压缩,命令如下:

$ unzip some-package.zip

ZIP 文件通常是项目维护者为在 Microsoft Windows 系统上使用而创建的。Linux 平台上更常用的格式是压缩的 .tar 文件。tar 这个名称来源于 磁带归档tar 工具最初是为了将在线存储介质(如硬盘驱动器)的内容流式传输到更具存档性的存储格式(如磁带)而设计的。由于磁带不是随机访问格式,因此它没有分层文件系统。相反,数据是以一长串比特写入磁带,归档文件是一个接一个地附加在一起。要在磁带上找到特定的文件,你必须从磁带的开头开始读取,直到找到你感兴趣的文件。因此,将较少的文件存储在磁带上是更好的选择,可以减少搜索时间。

tar 工具的设计目的是将一组文件从层级文件系统转换为一个这样的长位串——即归档文件。tar 工具的设计并不是为了以减少占用空间的方式压缩数据,因为已经有其他工具可以做这种事情——记住,Unix 的一个基本原则是每个工具只负责一个功能。实际上,.tar 文件通常比其包含的文件大小总和稍大,因为存储层次结构、文件名和文件属性所需的开销。

偶尔,你会遇到一个仅以 .tar 扩展名结尾的源归档文件。这意味着该文件是一个未压缩的 .tar 归档文件。然而,更常见的是,你会看到 .tar.gz.tgz.tar.bz2 等扩展名。这些是压缩过的 .tar 归档文件。一个归档文件是通过使用 tar 工具从目录树的内容创建的,然后归档文件被使用 gzipbzip2 工具压缩。一个扩展名为 .tar.gz.tgz 的文件是一个已经使用 gzip 工具压缩过的 .tar 归档文件。从技术上讲,你可以使用命令管道来提取 .tar.gz 文件的内容,首先使用 gunzip 解压 .gz 文件,然后使用 tar 解包剩下的 .tar 文件,方法如下:

$ gunzip -c autoconf-2.69.tar.gz | tar xf -

然而,tar 工具自用于创建磁带数据流以来已经发展了很多。如今,它被用作一个通用的归档文件管理工具。它根据文件扩展名,有时还根据归档的初始字节,知道如何执行正确的工具来解压一个压缩过的 .tar 归档文件,再解包文件。例如,以下命令将 autoconf-2.69.tar.gz 识别为一个 .tar 归档文件,且该文件随后使用 gzip 工具进行了压缩:

$ tar xf autoconf-2.69.tar.gz

该命令首先执行 gunzip 程序(或者带 -d 选项的 gzip 程序)来解压归档文件,然后使用内部算法将归档文件转换回其原始的多文件目录结构,且保留原始的时间戳和文件属性。

构建软件

解包完源归档文件后,下一步通常是检查解包后的目录树内容,试图确定如何构建和安装软件。在开源世界中,几个模式已经变得广泛流行,GNU 和 Autotools 尝试推动这些模式作为基于 Autotools 的项目的默认行为。

首先,在解包后的归档文件的根目录中查找名为 INSTALL 的文件。该文件通常包含如何构建和安装软件的逐步说明,或者告诉你如何找到这些说明——可能是通过项目网页上的 URL 引用。

GNU 软件包的 INSTALL 文件,比如 Autoconf 文件,内容相当冗长。GNU 项目通常会尽力为其他开源世界树立榜样。尽管如此,它确实详细列出了构建 Autoconf 包所需的步骤。我建议至少完整阅读一次 GNU 项目的 INSTALL 文件,因为它包含了大多数 GNU 项目如何构建和安装的细节。事实上,和 Autoconf 包捆绑在一起的文件其实是一个通用文件,GNU 在许多包中都会捆绑这个文件——这本身就是 Autotools 生成的构建系统一致性的证明。让我们深入了解一下它是如何指导我们构建 Autoconf 的:

$ tar xf autoconf-2.69.tar.gz
$ cd autoconf-2.69
$ more INSTALL
--snip--

说明中提到,你应该使用 cd 命令切换到包含项目源代码的目录,然后输入 ./configure 来为你的系统配置该包。然而,应该很明显的是,如果你正在阅读 INSTALL 文件,你可能已经在包含 configure 的目录中了。

运行 configure 可能需要一段时间,特别是当软件包很大且复杂时。对于 Autoconf 包来说,只需要几秒钟,并且在过程中会输出一页文本到屏幕。让我们仔细看看在成功的 Autoconf 配置过程中会显示哪些内容:

$ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
--snip--
configure: creating ./config.status
config.status: creating tests/Makefile
--snip--
config.status: creating bin/Makefile
config.status: executing tests/atconfig commands
$

configure 的输出基本上分为两部分。第一部分包含以 checking 开头的行(虽然中间有些行以 configure: 开头)。这些行表示 configure 被编程用来查找的功能的状态。如果某个功能未找到,后面的文字将显示 no。另一方面,如果功能被发现,后面的文字有时会是 yes,但通常会是发现的工具或功能的文件系统位置。

configure 因缺少工具或实用程序而失败并不罕见,尤其是在这是一个新安装的系统,或者你没有在这个系统上下载和构建很多软件时。此时,新用户通常会开始在在线论坛上发布问题,或者直接放弃。

理解这一部分的内容非常重要,因为它可以帮助你找出如何解决问题。解决失败问题通常很简单,比如使用系统的包管理器安装一个编译器。对于 Autoconf 包,大多数 Linux 系统默认会安装所需的工具。然而,也有一些例外。例如,以下是 configure 在一个没有安装 M4 的系统上的输出:

$ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
--snip--
checking for GNU M4 that supports accurate traces... configure: error: no
    acceptable m4 could be found in $PATH.
GNU M4 1.4.6 or later is required; 1.4.16 or newer is recommended.
GNU M4 1.4.15 uses a buggy replacement strstr on some systems.
Glibc 2.9 - 2.12 and GNU M4 1.4.11 - 1.4.15 have another strstr bug.
$

在这里,你会注意到最后几行显示了一个错误。Autoconf 包是一个 GNU 软件工具,按照其惯例,它提供了很多信息,帮助你找出问题所在。你需要安装一个 M4 宏处理器,我们将通过包管理器来安装它。我的系统是基于 Ubuntu 的 Linux Mint 系统,因此我将使用apt工具。如果你使用的是基于 Red Hat 的系统,你可以使用yum来完成同样的操作,或者直接通过图形用户界面(GUI)使用你的系统包管理器。这里的关键是我们正在安装 m4 包:

$ sudo apt install m4

现在configure可以成功完成了:

   $ ./configure
   checking for a BSD-compatible install... /usr/bin/install -c
   checking whether build environment is sane... yes
   --snip--
➊ configure: creating ./config.status
   config.status: creating tests/Makefile
   config.status: creating tests/atlocal
   --snip--
   config.status: creating bin/Makefile
   config.status: executing tests/atconfig commands
   $

第二部分是一组以config.status:开头的行。该部分从configure: creating ./config.status这一行开始,位于➊处。configure做的最后一件事是创建另一个脚本,名为config.status,然后执行该脚本。以config.status:开头的行实际上是由config.status显示的。config.status的主要任务是根据configure的结果生成构建系统。该脚本输出的行仅仅是告诉你正在生成的文件的名称。

如果你愿意,也可以从其他目录运行configure,方法是使用相对路径来调用configure命令。如果项目源代码是通过 CD 或只读 NFS 挂载提供给你的,这会很有用。例如,你可以在你的主目录中创建一个构建目录,然后通过相对路径或绝对路径,从只读源目录中执行configureconfigure脚本将在当前目录中为项目创建整个构建树,包括 makefile 和任何其他构建项目所需的文件。

一旦configure完成,就可以运行make了。在此之前,目录树中没有名为Makefile的文件。在configure之后运行make将会得到以下结果:

$ make
make  all-recursive
make[1]: Entering directory '/.../autotools/autoconf-2.69'
Making all in bin
make[2]: Entering directory '/.../autotools/autoconf-2.69/bin'
--snip--
make[2]: Leaving directory '/.../autotools/autoconf-2.69/man'
make[1]: Leaving directory '/.../autotools/autoconf-2.69'
$

configure的主要任务是确保make能够成功,因此make失败的可能性不大。如果它失败了,问题可能是非常特定于你的系统的,所以我无法提供任何指导,除非建议你仔细阅读make的输出,以确定导致失败的原因。如果你通过阅读输出无法发现问题,你可以查阅 Autoconf 邮件列表档案,直接在邮件列表上询问,最后在 Autoconf 项目网站上提交一个 bug 报告。

测试构建

一旦我们使用make构建了软件,运行项目维护者可能已添加到构建系统中的任何测试会很有意义,这些测试能够提供某种程度的保证,确保软件能够在我们的系统上正确运行。

当我们构建软件时,我们运行了没有任何命令行参数的 make。这使得 make 假设我们想要构建默认目标,根据约定,这个目标是 all 目标。因此,运行 make all 就等同于运行没有任何参数的 make。然而,Autotools 构建系统有许多目标,可以直接在 make 命令行中指定。我们此时关注的目标是 check 目标。

在源代码目录中运行 make check 会构建并执行项目维护者所包含的任何测试程序(对于 Autoconf 来说,这需要几分钟才能完成):

$ make check
if test -d ./.git; then \
  cd . && \
  git submodule --quiet foreach test '$(git rev-parse $sha1)' \
     = '$(git merge-base origin $sha1)' \
    || { echo 'maint.mk: found non-public submodule commit' >&2; \
   exit 1; }; \
else \
  : ; \
fi
make  check-recursive
make[1]: Entering directory '/home/jcalcote/Downloads/autotools/autoconf-2.69'
Making check in bin
--snip--
/bin/bash ./testsuite
## ----------------------------- ##
## GNU Autoconf 2.69 test suite. ##
## ----------------------------- ##
Executables (autoheader, autoupdate...).
  1: Syntax of the shell scripts                     skipped (tools.at:48)
  2: Syntax of the Perl scripts                      ok
--snip--
501: Libtool                                         FAILED (foreign.at:61)
502: shtool                                          ok
Autoscan.
503: autoscan                                        FAILED (autoscan.at:44)
## ------------- ##
## Test results. ##
## ------------- ##
ERROR: 460 tests were run,
6 failed (4 expected failures).
43 tests were skipped.
## -------------------------- ##
## testsuite.log was created. ##
## -------------------------- ##
Please send `tests/testsuite.log' and all information you think might help:
   To: <bug-autoconf@gnu.org>
   Subject: [GNU Autoconf 2.69] testsuite: 501 503 failed
You may investigate any problem if you feel able to do so, in which
case the test suite provides a good starting point.    Its output may
be found below `tests/testsuite.dir'.
--snip--
make: *** [check] Error 2
$

注意

你的输出可能会与我的稍有不同。不同的 Linux 发行版和工具版本显示方式不同,因此不要太担心细微的差异。由于系统中安装的工具不同,跳过或失败的测试数量也可能因系统而异。

如你所见,Autoconf 包提供了 503 个测试,其中 460 个被执行,43 个被故意跳过。在执行的 460 个测试中,有六个失败,但其中四个是预期的失败,所以我们只有两个问题:测试 501 和测试 503。

在 460 个测试中只有两个失败,从个人角度来看,我会称这是一个巨大的成功,但如果你想深入挖掘一下这些问题的根源,可以采取两种方法。第一种方法是访问 Autoconf 邮件列表的档案,搜索类似的问题及其答案,或者直接向列表提问;注意在前面的输出中请求将 tests/testsuite.log 文件发送到 bug-autoconf@gnu.org

另一种选择需要稍微多一点的编程技巧。这些测试是由 Autoconf 的 autotest 框架运行的,它会为每个失败的测试自动创建一个目录,放在 tests/testsuite.dir 下。在 testsuite.dir 下找到的每个目录都以失败测试的编号命名。如果你查看这些目录,你会看到六个目录,其中包括四个预期失败的目录。每个编号目录中都有一个 run 脚本,它将重新执行失败的测试,并将输出显示到 stdout,而不是日志文件中。这允许你对系统进行实验(例如,可能为测试 501 安装不同版本的 Libtool),然后重新运行测试。

然而,也有一种可能性,尽管很小,那就是项目维护者已经知道这些测试失败的情况。在这种情况下,他们可能会通过电子邮件回复你类似的评论(或者快速搜索档案也可能会找到相同的答案),这时你可以简单地忽略失败的测试。

安装构建的软件

运行 make 通常会将构建好的软件产品——可执行文件、库和数据文件——散布在整个构建目录树中。放心,你快完成了。最后一步是将已构建的软件安装到系统上,这样你就可以使用它了。幸运的是,大多数构建系统,包括由 Autotools 管理的构建系统,都提供了安装已构建软件的机制。

复杂的构建系统只有在假设了许多基本的默认设置时,对非专家才有用;否则,用户就需要为即使是最简单的构建也指定数十个命令行选项。软件安装位置就是这样的一个假设;默认情况下,构建系统假设你想将已构建的软件安装到 /usr/local 目录树中。

/usr/local 目录树镜像了 /usr 目录树;它是本地构建软件的标准安装位置。而 /usr 目录树则是 Linux 发行版包安装的位置。例如,如果你使用命令 sudo apt-get install autoconf(或 sudo yum install autoconf)安装了 Autoconf 包,那么包的二进制文件将安装到 /usr/bin 目录。当你安装手动构建的 Autoconf 二进制文件时,它们默认会安装到 /usr/local/bin

大多数情况下,/usr/local/bin 会在你的 PATH 环境变量中位于 /usr/bin 之前。这使得你本地构建和安装的程序可以覆盖由你的发行版包管理器安装的程序。

如果你希望覆盖这个默认行为,并将软件安装到不同的位置,可以在 configure 的命令行中使用 --prefix 选项,^(3) 如下所示:

$ ./configure --prefix=$HOME

这将使得 configure 生成构建脚本,使可执行的二进制文件被安装到你的 $HOME/bin 目录中。^(4) 如果你没有系统管理员权限,这是一个不错的折中方案,允许你安装已构建的软件而不需要向系统管理员申请额外权限。

选择不同的 --prefix 安装位置的另一个原因是为了让你将软件安装到一个隔离的位置。然后,你可以在安装后检查该位置,查看安装了哪些内容以及相对于 --prefix,它们被安装到了哪里。

让我们首先安装到一个私人安装位置,这样我们就可以看到 Autoconf 项目安装到了我们的系统上:

Image

注意

和早期的构建过程一样,系统上的文件和目录数量可能会因我们的工具可用性差异而略有不同。如果你安装了额外的文档工具,例如,你可能会看到比我更多的目录,因为如果工具可用,Autoconf 会构建更多的文档。

请注意,我在configure的命令行上指定了安装位置,并使用了完整路径——PWD环境变量包含当前目录在 shell 中的绝对路径。在--prefix中始终使用完整路径是很重要的。在很多情况下,使用相对路径会导致安装失败,因为在安装过程中--prefix参数会从不同的目录引用。^(5)

我在private-install目录上使用了tree命令,以便获得 Autoconf 安装内容的可视化图像。^(6) 共安装了 61 个文件,分布在private-install中的 11 个目录里。

现在,让我们将 Autoconf 安装到默认位置/usr/local/bin

$ ./configure
--snip--
$ make
--snip--
$ sudo make install
--snip--
$

需要注意的是,在此命令行中使用了sudo,以便以 root 权限运行make install。当你在主目录之外安装软件时,你需要更高的权限。如果你将--prefix目录设置为主目录内的某个位置,那么你可以省略在命令中使用sudo

摘要

此时,你应该已经理解了什么是源代码归档,并且知道如何下载、解压、构建、测试和安装它。我也希望我能激发你进一步探索并了解更多关于开源构建系统的知识。由 Autotools 生成的系统严格遵循常见的模式,因此它们是相当可预测的。关于你可以做的事情的提示,试着运行./configure --help

还有其他构建系统。它们大多数遵循一套合理的模式,但偶尔你会遇到一个与其他所有系统明显不同的系统。所有开源构建系统都倾向于遵循一些非常基本的高层次概念——例如,先进行配置过程,然后是构建步骤,这是其中之一。然而,配置过程的性质以及用来构建软件的命令,可能与我们在这里讨论的有所不同。Autotools 的一个好处是它们生成的构建系统具有一致性。

如果你想了解所有这些魔法是如何运作的,请继续阅读。

第二章:GNU Autotools 简要介绍

*我们将不会停止探索,所有探索的终点将是回到我们开始的地方,并第一次认识这个地方。

——T.S. 艾略特,《四重奏第 4 号:小吉丁》*

Image

正如本书前言中所述,GNU Autotools 的目的是为了简化最终用户的工作,而非维护者的工作。然而,长期使用 Autotools 将使你作为项目维护者的工作变得更加轻松,尽管这可能不是你所预期的原因。鉴于其提供的功能,Autotools 框架尽可能简化。Autotools 的真正目的是双重的:它满足用户的需求,并且使你的项目变得极其可移植——即使是在你从未测试、安装或构建过代码的系统上。

在本书中,我将经常使用Autotools这一术语,尽管你在 GNU 档案中找不到这个标签的包。我使用这个术语来指代以下三个 GNU 项目,这些项目被社区认为是 GNU 构建系统的一部分:

  • Autoconf,用于为项目生成配置脚本

  • Automake,用于简化创建一致且功能完整的 makefile 的过程

  • Libtool,用于提供便捷的跨平台共享库创建抽象

其他构建工具,如开源项目 CMake 和 SCons,试图提供与 Autotools 相同的功能,但以更用户友好的方式实现。然而,由于这些工具试图通过图形界面和脚本生成器隐藏大部分复杂性,它们实际上变得功能更少,且更难以管理,因为构建系统不再那么透明。归根结底,这种透明性正是 Autotools 使其既易于使用又易于理解的原因。因此,最初对 Autotools 的挫败感并非来自其复杂性——因为它们实际上非常简单——而是来自于它们广泛使用了一些不太为人所熟知的工具和子系统,比如 Linux 命令行(Bash)、make工具以及 M4 宏处理器和随附的宏库。实际上,Automake 提供的元语言是如此简单,以至于只需几个小时的手册阅读,就能完全消化和理解(尽管这种元语言的深层含义可能需要更多的时间来彻底内化)。

谁应该使用 Autotools?

如果你正在编写面向 Unix 或 Linux 系统的开源软件,你绝对应该使用 GNU Autotools,即使你编写的是针对 Unix 或 Linux 系统的专有软件,使用 Autotools 仍然会带来显著的好处。Autotools 为你提供了一个构建环境,使你的项目能够在未来版本或发行版中成功构建,而几乎无需修改构建脚本。即使你只打算针对单一的 Linux 发行版,这也是有用的,因为——说实话——你真的无法预知你的公司是否希望你的软件将来能在其他平台上运行。

什么时候不应该使用 Autotools?

唯一不使用 Autotools 的合理情况是当你编写的软件会在非 Unix 平台上运行,比如 Microsoft Windows。

Windows 上的 Autotools 支持需要一个 Msys^(1)环境才能正常工作,因为 Autoconf 生成的配置脚本是 Bourne shell 脚本,而 Windows 不提供原生的 Bourne shell。^(2) Unix 和 Microsoft 工具在命令行选项和运行时特性上有些微差异,因此通常更简单的方法是使用 GNU 工具的 Windows 移植版本,如 Cygwin、Msys2 或 MinGW,通过 Autotools 构建 Windows 程序。

基于这些原因,我将主要集中在 POSIX 兼容平台上使用 Autotools。然而,如果你有兴趣在 Windows 上尝试 Autotools,可以参考第十七章获取详细概述。

注意

我不是典型的 Unix 偏执者。虽然我热爱 Unix(尤其是 Linux),但我也欣赏 Windows 在其擅长的领域中的表现。^(3) 对于 Windows 开发,我强烈推荐使用 Microsoft 工具。使用 GNU 工具开发 Windows 程序的初衷现在更像是学术上的讨论,因为 Microsoft 已经免费提供了大部分工具。有关下载信息,请参阅 Visual Studio Community:visualstudio.microsoft.com/vs/express/

苹果平台和 Mac OS X

Macintosh 操作系统自 2007 年发布的 macOS 10 版本“Leopard”以来,一直是 POSIX 兼容的。OS X 源自 NeXTSTEP/OpenStep,基于 Mach 内核,并部分采用 FreeBSD 和 NetBSD。作为一个 POSIX 兼容的操作系统,OS X 提供了 Autotools 所需的所有基础设施。你在 OS X 上遇到的问题很可能涉及 Apple 的图形用户界面和软件包管理系统,这些是特定于 Mac 的。

用户界面呈现了与在其他 Unix 平台上使用 X Window 系统时相遇的相同问题,甚至更多。主要区别在于,X Window 系统大多只用于 Unix 系统,而 macOS 则有自己的图形用户界面,称为 Cocoa。虽然 X Window 系统可以在 Mac 上使用(苹果提供了一种窗口管理器,使得 X 应用程序看起来像原生的 Cocoa 应用程序),但 Mac 程序员有时希望充分利用操作系统提供的本地用户界面功能。

Autotools 通过简单地忽略 Unix 平台之间的包管理差异来回避这个问题。相反,它们创建的包只是使用 targzip 工具压缩的源代码归档,并通过 make 命令行安装和卸载产品。macOS 的包管理系统是安装应用程序时的一个重要部分,像 Fink (www.finkproject.org/) 和 MacPorts (www.macports.org/) 等项目通过提供简化的机制将 Autotools 包转换为可安装的 Mac 包,帮助使现有的开源包在 Mac 上可用。

结论是,只要记住这些注意事项,Autotools 在运行 OS X 或更高版本的 Apple Macintosh 系统上可以非常有效地使用。

语言选择

选择编程语言是决定是否使用 Autotools 时需要考虑的另一个重要因素。请记住,Autotools 是由 GNU 团队为管理 GNU 项目而设计的。在 GNU 社区中,有两个因素决定计算机编程语言的重要性:

  • 是否有任何 GNU 软件包是用该语言编写的?

  • GNU 编译器工具集是否支持该语言?

Autoconf 根据这两个标准提供对以下语言的本地支持(本地支持意味着 Autoconf 可以在这些语言中编译、链接并运行源代码级特性检查):

  • C

  • C++

  • Objective C

  • Objective C++

  • Fortran

  • Fortran 77

  • Erlang

  • Go

因此,如果你想构建一个 Java 包,可以配置 Automake 来执行此操作(正如你将在第十四章和第十五章中看到的那样),但你不能要求 Autoconf 来编译、链接或运行基于 Java 的检查^(4),因为 Autoconf 本身不支持 Java。然而,你可以找到 Autoconf 宏(我将在后续章节中详细介绍),这些宏增强了 Autoconf 管理 Java 项目配置过程的能力。

一般的观点认为,Java 自带许多运行良好的构建环境和工具(例如 Maven);因此,增加对 Java 的完全支持似乎是一种浪费。尤其是在 Java 及其构建工具本身具有很高的可移植性——甚至可以运行在 Windows 等非 Unix/Linux 平台上。

Automake 确实提供了对 Java 编译器和 JVM 的基础支持。我自己在项目中使用过这些功能,它们运行得很好,只要你不试图将它们推得太远。

如果你对 Smalltalk、ADA、Modula、Lisp、Forth 或其他非主流语言感兴趣,你可能并不太想将你的代码移植到数十个平台和 CPU 上。然而,如果你确实使用非主流语言并且关心构建系统的可移植性,考虑自己为 Autotools 添加对你语言的支持。这并不像你想象的那样令人畏惧,我保证当你完成后,你将成为 Autotools 专家。^(5)

生成你的软件包构建系统

GNU Autotools 框架包括三个主要的软件包:Autoconf、Automake 和 Libtool。这些软件包中的工具可能依赖于 gettext、M4、sed、make 和 Perl 等包的实用程序和功能;然而,这些软件包生成的构建系统仅依赖于 Bourne shell 和make工具。

在 Autotools 方面,重要的是区分维护者的系统和最终用户的系统。Autotools 的设计目标规定,Autotools 生成的构建系统应该只依赖于最终用户机器上现成可用并已预安装的工具(假设最终用户的系统具有从源代码构建程序的基础支持)。例如,维护者用来创建发行版的机器需要一个 Perl 解释器,但最终用户用来从发布版源代码归档构建产品的机器不应需要 Perl(除非该项目的源代码是用 Perl 编写的)。

一个推论是,最终用户的机器不需要安装 Autotools——最终用户的系统只需要一个合理符合 POSIX 的make版本和一个可以执行生成的配置脚本的 Bourne shell 变种。当然,任何软件包也会要求编译器、链接器以及将源文件转换为可执行二进制程序、帮助文件和其他运行时资源所需的工具。

配置

大多数开发者都理解make工具的用途,但configure有什么用呢?尽管 Unix 系统已经遵循事实上的标准 Unix 内核接口几十年了,大多数软件仍然需要超越这些边界。

最初,配置脚本是手工编写的 shell 脚本,旨在根据平台特定的特征设置环境变量。它们还允许用户在运行 make 之前配置软件包选项。这种方法在几十年里效果良好,但随着 Linux 发行版和类 Unix 系统的数量增加,特性、安装和配置选项的多样性也爆炸性增长,因此编写一个体面的便携式配置脚本变得非常困难。事实上,编写一个便携式配置脚本比编写新项目的 makefile 还要困难。因此,大多数人只是通过复制和修改类似项目的脚本来为自己的项目创建配置脚本。

在 1990 年代初期,许多开源软件开发者明显意识到,如果不采取措施简化编写庞大复杂的 shell 脚本来管理配置选项的负担,项目配置将变得异常困难。GNU 项目包的数量已经增长到数百个,而保持这些独立构建系统的一致性比维护这些项目的代码本身更为费时。这些问题必须得到解决。

Autoconf

Autoconf ^(6) 几乎一夜之间改变了这种局面。David MacKenzie 于 1991 年启动了 Autoconf 项目,但通过查看 Savannah Autoconf 项目^(7) 仓库中的 AUTHORS 文件,你可以大致了解有多少人参与了这款工具的开发。尽管配置脚本长且复杂,但用户在执行时只需指定少数几个变量。这些变量大多数仅仅是关于组件、特性和选项的选择,比如 构建系统在哪里可以找到库文件和头文件?我想把成品安装到哪里?我想将哪些可选组件编译到我的产品中?

开发者现在不需要修改和调试成百上千行本应便携的 shell 脚本,而是可以使用一种简洁的、基于宏的语言编写一个简短的元脚本文件,Autoconf 会生成一个完美的配置脚本,该脚本比手工编写的更加便携、准确和易于维护。此外,Autoconf 经常能够捕捉到语义或逻辑错误,这些错误如果不加以调试,可能需要几天的时间才能发现。Autoconf 的另一个优点是,它生成的 shell 代码在大多数 Bourne shell 的变体之间是便携的。在 shell 之间的便携性问题是非常常见的,且不幸的是,这些错误是最难以发现的,因为没有任何开发者能访问到所有 Bourne-like 的 shell。

注意

尽管像 Perl 和 Python 这样的便携式脚本语言现在比 Bourne shell 更为普及,但在最初构思 Autoconf 时并非如此。

Autoconf 生成的配置脚本提供了一组通用选项,这些选项对于所有在 POSIX 系统上运行的可移植软件项目都很重要。这些选项包括修改标准位置的选项(这是我将在第三章中详细介绍的概念),以及在 configure.ac 文件中定义的项目特定选项(我将在第五章中讨论)。

autoconf 包提供了多个程序,包括以下内容:

  • autoconf

  • autoreconf

  • autoheader

  • autoscan

  • autoupdate

  • ifnames

  • autom4te

autoconf 程序是一个简单的 Bourne shell 脚本。它的主要任务是确保当前的 shell 包含执行 m4 宏处理器所需的功能。(我将在第四章中详细讨论 Autoconf 如何使用 M4。)脚本的其余部分解析命令行参数并执行 autom4te

autoreconf

autoreconf 工具会根据项目的需要执行 autoconf、automake 和 libtool 包中的配置工具。该工具最小化了由于时间戳、特性和项目状态变化而需要重新生成的内容。它的编写旨在整合现有的维护者编写的脚本工具,这些工具会按照正确的顺序运行所有必需的 Autotools。你可以将 autoreconf 看作是一种智能的 Autotools 启动工具。如果你只有一个 configure.ac 文件,可以运行 autoreconf 来执行所有必需的工具,并确保 configure 文件能够正确生成。图 2-1 展示了 autoreconf 如何与 Autotools 套件中的其他工具交互。

Image

图 2-1:autoreconf 工具的数据流图

然而,有时项目需要的不仅仅是启动 Autotools 来让开发人员在新检出的代码库工作区上快速开始。在这种情况下,一个运行 autoreconf 和任何非 Autotools 相关进程的小型 shell 脚本是合适的。许多项目将这样的脚本命名为 autogen.sh,但由于有一个 GNU Autogen 项目,这常常会让开发人员感到困惑。一个更好的命名应该是像 bootstrap.sh 这样的名称。

此外,当与 -i 选项一起使用时,autoreconf 会通过添加 GNU 推荐或要求的缺失文件,将项目启动到可分发状态。这些文件包括适当的 ChangeLog 文件、模板 INSTALLREADMEAUTHORS 文件等。

autoheader

autoheader工具从configure.ac中的各种结构生成一个 C/C++兼容的头文件模板。这个文件通常被称为config.h.in。当最终用户执行configure时,配置脚本会从config.h.in生成config.h。作为维护者,你将使用autoheader来生成你将在发布包中包含的模板文件。(我们将在第四章中更详细地探讨autoheader。)

autoscan

autoscan程序为一个新项目生成默认的configure.ac文件;它还可以检查现有的 Autotools 项目中的缺陷和改进机会。(我们将在第四章和第十四章中更详细地讨论autoscan。)autoscan是为使用非 Autotools 构建系统的项目提供的一个很好的起点,但它也可以为增强现有 Autotools 项目提供建议。

自动更新

autoupdate工具用于更新configure.ac或模板(.in)文件,以匹配当前版本的 Autotools 所支持的语法。

ifnames

ifnames程序是一个小型且通常未充分使用的工具,它接受命令行上的源文件名列表,并显示 C 预处理器定义的列表。这个工具旨在帮助维护者确定应该将哪些内容放入configure.acMakefile.am文件中,以使其具有可移植性。如果你的项目在一定程度上考虑了可移植性,ifnames可以帮助你确定这些可移植性的尝试在源代码树中的位置,并为你提供潜在的可移植性定义名称。

autom4te

autom4te工具是一个基于 Perl 的智能缓存包装器,用于m4,它被大多数其他 Autotools 工具使用。autom4te缓存通过减少后续工具访问configure.ac构造的时间,最多可以提高 30%的效率。

我不会花太多时间讲解autom4te(发音为自动化),因为它主要由 Autotools 内部使用。它工作的唯一迹象是在你运行autoconfautoreconf后,在你的顶层项目目录中出现的autom4te.cache目录。

协作工作

在之前列出的工具中,autoconfautoheader是项目维护者在生成configure脚本时唯一会使用的工具,而autoreconf是开发者需要直接执行的唯一工具。图 2-2 展示了输入文件与autoconfautoheader之间的交互,它们生成相应的产品文件。

Image

图 2-2:autoconfautoheader的数据流图

注意

我在本书中使用的数据流图格式如 图 2-2 所示。深色框代表由用户或 Autotools 包提供的对象。浅色框代表生成的对象。方角框是脚本和程序,圆角框是数据文件。大部分标签的含义应该是显而易见的,但至少有一个需要解释:术语 ac-vars 指的是 Autoconf 特定的替换文本。我会稍后解释 aclocal.m4 框的渐变阴影效果。

这套工具的主要任务是生成一个配置脚本,该脚本可用于为目标平台(不一定是本地主机)配置项目构建目录。这个脚本不依赖于 Autotools 本身;事实上,autoconf 旨在生成可以在所有类 Unix 平台和大多数 Bourne shell 变种中运行的配置脚本。这意味着,你可以使用 autoconf 生成配置脚本,然后在没有安装 Autotools 的机器上成功执行该脚本。

autoconfautoheader 程序要么由你直接执行,要么由 autoreconf 间接执行。它们从你的项目的 configure.ac 文件和各种 Autoconf 风格的 M4 宏定义文件(按照惯例,这些文件扩展名为 .m4)中获取输入,并使用 autom4te 来维护缓存信息。autoconf 程序生成一个配置脚本,名为 configure,它是一个非常便携的 Bourne shell 脚本,使你的项目能够提供许多有用的配置功能。程序 autoheader 根据 configure.ac 中的某些宏定义生成 config.h.in 模板。

Automake

一旦你做了几次,写一个新项目的基本 makefile 就相对简单了。但当你试图做的不仅仅是基础操作时,可能会出现问题。让我们面对现实——哪个项目维护者会满足于一个基本的 makefile 呢?

细节决定一个开源项目的成功。用户很容易对一个项目失去兴趣——尤其是当他们期望的功能缺失或编写不当时。例如,资深用户已经习惯了 makefile 支持某些标准目标或目标,这些目标在 make 命令行中指定,如下所示:

$ make install

常见的 make 目标包括 allcleaninstall。在这个例子中,install 是目标。但你应该意识到,这些都不是 真实的 目标:真实目标 是构建系统生成的文件系统对象——通常是一个文件(但有时是目录或链接)。例如,当构建名为 doofabble 的可执行文件时,你会期望能够输入:

$ make doofabble

对于这个项目,doofabble是一个真实的目标,并且该命令适用于 doofabble 项目。然而,要求用户在make命令行上输入真实目标要求比较高,因为每个项目的构建方式不同——make doofabblemake foodabblemake abfooble,等等。标准化的make目标允许所有项目通过使用像make allmake clean这样的常见命令以相同的方式构建。但常见的并不意味着自动化的,编写和维护支持这些目标的 makefile 既繁琐又容易出错。

Automake 的任务是将您项目构建过程的简化规范转换为可以始终正确工作的模板 makefile 语法,并且提供所有预期的标准功能。Automake 创建的项目支持在GNU 编码标准中定义的准则(在第三章中讨论)。

就像autoconf生成的configure脚本可以移植到多种类型的 Bourne shell 中,automake生成的make脚本也可以移植到多种类型的make中。

automake 包提供了以下形式为 Perl 脚本的工具:

  • automake

  • aclocal

automake

automake程序从高级构建规范文件(命名为Makefile.am)生成标准 makefile 模板(命名为Makefile.in)。这些Makefile.am输入文件本质上只是常规的 makefile。如果你只在Makefile.am文件中放入几个必需的 Automake 定义,你将得到一个包含数百行参数化make脚本的Makefile.in文件。

如果你向Makefile.am文件中添加额外的make语法,Automake 会将这段代码移动到结果Makefile.in文件中功能最正确的位置。事实上,你可以编写Makefile.am文件,让它们只包含普通的make脚本,而生成的 makefile 也能正常工作。这种通行特性使你能够扩展 Automake 的功能,以适应项目的特定需求。^(8)

aclocal

GNU Automake 手册中,aclocal工具被描述为针对 Autoconf 某些灵活性不足的临时解决方法。Automake 通过添加大量宏来增强 Autoconf,但 Autoconf 并不是为了这种程度的增强而设计的。

添加用户定义的宏到 Autoconf 项目的最初文档方法是创建一个名为aclocal.m4的文件,将用户定义的宏放入该文件,并将文件放置在与configure.ac相同的目录中。Autoconf 在处理configure.ac时会自动包含这一组宏。Automake 的设计者发现这一扩展机制非常有用,因此无法放弃;然而,用户需要在可能不必要的aclocal.m4文件中添加m4_include语句,以包含 Automake 宏。由于用户定义的宏和 M4 的使用都被认为是高级概念,这一要求被认为过于苛刻。

aclocal脚本旨在解决这个问题。这个工具为一个项目生成一个aclocal.m4文件,包含用户定义的宏和所有必需的 Automake 宏。^(9) 项目维护者现在应将用户定义的宏添加到一个新文件中,称为acinclude.m4,而不是直接将其添加到aclocal.m4中。

为了让读者清楚地知道 Autoconf 并不依赖于 Automake(也许出于一些固执的原因),GNU Autoconf 手册并没有过多提到aclocal工具。GNU Automake 手册最初建议,在将 Automake 添加到现有 Autoconf 项目时,应该将aclocal.m4重命名为acinclude.m4,这一方法仍然被广泛使用。aclocal的数据流如图 2-3 所示。

图片

图 2-3:aclocal的数据流图

然而,最新的 Autoconf 和 Automake 文档建议,整个范式现在已过时。开发者现在应指定一个包含 M4 宏文件集的目录。目前的推荐做法是,在项目的根目录中创建一个名为m4的目录,并将宏作为单独的.m4文件添加到其中。该目录中的所有文件将在 Autoconf 处理configure.ac之前被收集到aclocal.m4中。^(10)

现在可能更容易理解为什么图 2-2 中的aclocal.m4框无法决定应该使用哪种颜色。当你在没有 Automake 和 Libtool 的情况下使用它时,你需要手动编写aclocal.m4。然而,当你与 Automake 一起使用时,该文件是由aclocal工具生成的,你需要将项目特定的宏提供给acinclude.m4m4目录中。

Libtool

如何在不同的 Unix 平台上构建共享库,而不需要向构建系统和源代码中添加大量非常特定于平台的条件代码?这是 Libtool 项目试图解决的问题。

类 Unix 平台之间有大量的共通功能。然而,有一个非常显著的区别与共享库的构建、命名和管理方式有关。一些平台将它们的库命名为 libname.so,而另一些则使用 libname.a,甚至 libname.sl。Windows 的 Cygwin 系统将 Cygwin 生成的共享库命名为 cygname.dll。还有一些平台甚至不提供本地共享库。有些平台提供 libdl.so,以允许软件在运行时动态加载并访问库的功能,而其他平台则提供不同的机制,甚至有些平台根本不提供此功能。

Libtool 的开发者仔细考虑了所有这些差异。Libtool 支持数十个平台,不仅提供一组可以在 makefile 中隐藏库命名差异的 Autoconf 宏,还提供了一个可选的动态加载器功能库,可以添加到程序中。这些功能使得维护者能够使其运行时的动态共享对象管理代码更具可移植性,并更容易维护。

libtool 包提供以下程序、库和头文件:

  • libtool(程序)

  • libtoolize(程序)

  • ltdl(静态和共享库)

  • ltdl.h(头文件)

libtool

随 libtool 包一起发布的 libtool shell 脚本是一个通用版本,用于生成 libtoolize 为项目生成的自定义脚本。

libtoolize

libtoolize shell 脚本为你的项目准备使用 Libtool。它会生成一个自定义版本的通用 libtool 脚本,并将其添加到你的项目目录中。这个自定义脚本与项目一起发布,并与 Automake 生成的 makefile 一起,在用户系统上的适当时刻执行该脚本。

ltdl,Libtool 的 C API

libtool 包还提供了 ltdl 库及相关的头文件,提供跨平台的一致性运行时共享对象管理器。ltdl 库可以静态或动态链接到你的程序中,从而为不同平台之间提供一致的运行时共享库访问接口。

图 2-4 展示了 automakelibtool 脚本之间的互动,以及用于创建配置和构建项目的输入文件。

Automake 和 Libtool 都是标准的可插拔选项,可以通过简单的宏调用添加到 configure.ac 中。

Image

图 2-4:automakelibtool 的数据流图

构建你的包

作为维护者,你可能经常构建你的软件包,而且你也可能非常熟悉你项目的组件、架构和构建系统。然而,你应该确保你的用户的构建体验比你自己的更加简化。一种方法是为用户提供一个简单易懂的构建模式,以便他们在构建你的软件包时能够轻松跟随。在接下来的部分中,我将展示 Autotools 支持的构建模式。

运行 configure

在运行完 Autotools 后,你会得到一个名为configure的 shell 脚本和一个或多个Makefile.in文件。这些文件是作为项目发布包的一部分进行分发的。^(11) 你的用户将下载这些包,解压后,从顶层项目目录输入./configure && makeconfigure脚本将根据automake生成的Makefile.in模板和由autoheader生成的config.h.in模板,生成 makefile(称为Makefile)和config.h头文件。

Automake 生成Makefile.in模板,而不是 makefile,因为没有 makefile,用户就无法运行make;你不希望他们在运行configure之前就运行make,而这种功能正是防止他们这样做的原因。Makefile.in模板与你手动编写的 makefile 几乎相同,除了你不需要亲自编写它们。它们还做了许多大多数人不愿意手动编写的事情。不提供直接运行的 makefile 的另一个原因是,这样可以让configure有机会将平台特性和用户指定的可选功能直接插入到 makefile 中。这使得 makefile 更加适合其目标平台和最终用户的构建偏好。最后,makefile 还可以在源代码树之外生成,这意味着你可以为同一源代码目录树在不同的目录中创建自定义构建系统。我将在第 28 页的“构建源代码目录之外”部分详细讨论这个话题。

图 2-5 展示了configure与在配置过程中执行的脚本之间的交互,以便生成 makefile 和config.h头文件。

图片

图 2-5:configure的数据流图

configure脚本与另一个名为config.status的脚本有双向关系。你可能认为你的configure脚本生成了 makefile。但实际上,configure生成的唯一文件(除了日志文件)是config.status

configure 脚本的设计目的是确定用户系统上可用的平台特性和功能,这些内容在维护者编写的 configure.ac 文件中有说明。一旦获得这些信息,它就会生成 config.status,该文件包含所有的检查结果,然后执行该脚本。config.status 脚本反过来使用其中嵌入的检查信息来生成平台特定的 config.h 和 Makefile 文件,以及 configure.ac 中指定的任何其他基于模板的输出文件。

注意

如图 2-5 中的双头粗箭头所示,config.status* 也可以调用 configure。当与 --recheck 选项一起使用时,config.status 将使用生成 config.status 时使用的相同命令行选项来调用 configure。*

configure 脚本还会生成一个名为 config.log 的日志文件,如果 configure 在用户的系统上执行失败,该日志文件将包含非常有用的信息。作为维护者,你可以利用这些信息进行调试。config.log 文件还记录了 configure 的执行方式。(你可以运行 config.status --version 来发现生成 config.status 时使用的命令行选项。)当用户,比如说,度假归来并且不记得他们最初生成项目构建目录时使用了哪些选项时,这个功能尤其有用。

注意

要重新生成 Makefile 和 config.h 头文件,只需在项目构建目录中输入 ./config.status 。输出文件将使用最初用于生成 config.status 的相同选项生成。*

config.site 文件可用于根据传递给它的 --prefix 选项自定义 configure 的工作方式。config.site 文件是一个脚本,但它并不打算直接执行。相反,configure 会查找 $(prefix)/share/config.site 并将其“源”作为自己脚本的一部分,之后才会执行它自己的代码。这是指定许多软件包的相同选项的便捷方式,所有这些软件包都将按相同的方式构建并安装。由于 configure 只是一个 shell 脚本,config.site 应该只包含 shell 代码。

config.cache 文件是当使用 -C--config-cache 选项时,由 configure 生成的。配置测试的结果会缓存到此文件中,供子目录中的 configure 脚本或未来的 configure 执行使用。默认情况下,config.cache 是禁用的,因为它可能会成为配置错误的潜在来源。如果你对配置过程很有信心,config.cache 可以显著加快每次执行 configure 时的配置速度。

在源目录之外构建

Autotools 构建环境的一个鲜为人知的特点是,它们不需要在项目源代码树内生成。也就是说,如果用户从源代码目录以外的目录执行 configure,他们仍然可以在一个独立的构建目录中生成完整的构建环境。

在以下示例中,用户下载 doofabble-3.0.tar.gz,解压缩后创建两个兄弟目录,分别命名为 doofabble-3.0.debugdoofabble-3.0.release。他们切换到 doofabble-3.0.debug 目录;使用相对路径执行 doofabble 的 configure 脚本,并使用专为 doofabble 定制的 debug 选项;然后在同一目录内运行 make。接着,他们切换到 doofabble-3.0.release 目录,执行相同的操作,这次运行 configure 时不使用 debug 选项:

$ gzip -dc doofabble-3.0.tar.gz | tar xf -
$ mkdir doofabble-3.0.debug
$ mkdir doofabble-3.0.release
$ cd doofabble-3.0.debug
$ ../doofabble-3.0/configure --enable-debug
--snip--
$ make
--snip--
$ cd ../doofabble-3.0.release
$ ../doofabble-3.0/configure
--snip--
$ make
--snip--

用户通常不关心远程构建功能,因为他们通常只希望在自己的平台上配置、构建和安装代码。而维护者则认为远程构建功能非常有用,因为它不仅可以保持一个相对干净的源代码树,还可以为他们的项目维护多个构建环境,每个环境都可以有不同的复杂配置选项。维护者无需重新配置单个构建环境,只需切换到另一个已配置不同选项的构建目录即可。

然而,有一种情况,用户可能希望使用远程构建。假设用户获取了一个项目的完整解压源代码(例如通过 CD 或只读 NFS 挂载方式)。在源代码树外构建的能力可以使用户在不需要将项目复制到可写介质的情况下进行构建。

运行 make

最后,运行普通的 make。Autotools 的设计者们花费了大量的精力,确保你不需要任何特殊版本或品牌的 make。图 2-6 展示了 make 与在构建过程中生成的 makefile 之间的交互。

注意

近几年,关于只支持 GNU make 的讨论出现在 Autotools 邮件列表中,因为现代的 GNU make 比其他 make 工具要功能强大得多。几乎所有类 Unix 平台(甚至包括微软 Windows)今天都有 GNU make 版本,因此继续支持其他品牌的 make 已不再像以前那样重要。

如你所见,make 会运行几个生成的脚本,但这些脚本实际上都是 make 过程的辅助部分。生成的 makefile 包含在适当条件下执行这些脚本的命令。这些脚本是 Autotools 的一部分,通常随你的软件包一起提供,或者由配置脚本生成。

Image

图 2-6:make 的数据流图

安装最新版本的 Autotools

如果你正在运行某个 Linux 变种,并且已经选择安装用于开发 C 语言软件的编译器和工具,你可能已经在系统上安装了某个版本的 Autotools。要确定你正在使用的 Autoconf、Automake 和 Libtool 的版本,只需打开一个终端窗口并输入以下命令(如果你的系统没有which工具,可以尝试输入type -p代替):

$ which autoconf
/usr/local/bin/autoconf
$
$ autoconf --version
autoconf (GNU Autoconf) 2.69
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+/Autoconf: GNU GPL version 3 or later
<http://gnu.org/licenses/gpl.html>, <http://gnu.org/licenses/exceptions.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by David J. MacKenzie and Akim Demaille.
$
$ which automake
/usr/local/bin/automake
$
$ automake --version
automake (GNU automake) 1.15
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv2+: GNU GPL version 2 or later <http://gnu.org/licenses/gpl-2.0.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Tom Tromey <tromey@redhat.com>
       and Alexandre Duret-Lutz <adl@gnu.org>.
$
$ which libtool
/usr/local/bin/libtool
$
$ libtool --version

libtool (GNU libtool) 2.4.6
Written by Gordon Matzigkeit, 1996

Copyright (C) 2014 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$

注意

如果你已经在系统中安装了这些 Autotools 包的 Linux 发行版版本,执行文件可能会出现在 /usr/bin 而不是 /usr/local/bin中,正如你可以从此处的which命令输出看到的那样。*

如果你选择从 GNU 网站下载、构建并安装任何这些包的最新发布版本,则必须对它们全部执行相同的操作,因为 Automake 和 Libtool 包会将宏安装到 Autoconf 宏目录中。如果你尚未安装 Autotools,可以通过系统包管理器(例如yumapt)或通过源代码安装,使用它们的 GNU 发行版源档案来安装。后者可以通过以下命令完成(确保根据需要更改版本号):

$ mkdir autotools && cd autotools
$ wget -q https://ftp.gnu.org/gnu/autoconf/autoconf-2.69.tar.gz
$ wget -q https://ftp.gnu.org/gnu/autoconf/autoconf-2.69.tar.gz.sig
$ gpg autoconf-2.69.tar.gz.sig
gpg: assuming signed data in `autoconf-2.69.tar.gz'
gpg: Signature made Tue 24 Apr 2012 09:17:04 PM MDT using RSA key ID 2527436A
gpg: Can't check signature: public key not found
$
$ gpg --keyserver keys.gnupg.net --recv-key 2527436A
gpg: requesting key 2527436A from hkp server keys.gnupg.net
gpg: key 2527436A: public key "Eric Blake <eblake@redhat.com>" imported
gpg: key 2527436A: public key "Eric Blake <eblake@redhat.com>" imported
gpg: no ultimately trusted keys found
gpg: Total number processed: 2
gpg:               imported: 2    (RSA: 2)$ gpg autoconf-2.69.tar.gz.sig
gpg: assuming signed data in `autoconf-2.69.tar.gz'
gpg: Signature made Tue 24 Apr 2012 09:17:04 PM MDT using RSA key ID 2527436A
gpg: Good signature from "Eric Blake <eblake@redhat.com>"
gpg:                 aka "Eric Blake (Free Software Programmer) <ebb9@byu.net>"
gpg:                 aka "[jpeg image of size 6874]"
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 71C2 CC22 B1C4 6029 27D2    F3AA A7A1 6B4A 2527 436A
$
$ gzip -cd autoconf* | tar xf -
$ cd autoconf*/
$ ./configure && make all check
        # note – a few tests (501 and 503, for example) may fail
        # – this is fine for this release)
--snip--
$ sudo make install
--snip--
$ cd ..
$ wget -q https://ftp.gnu.org/gnu/automake/automake-1.16.1.tar.gz
$ wget -q https://ftp.gnu.org/gnu/automake/automake-1.16.1.tar.gz.sig
$ gpg automake-1.16.1.tar.gz.sig
gpg: assuming signed data in `automake-1.16.1.tar.gz'
gpg: Signature made Sun 11 Mar 2018 04:12:47 PM MDT using RSA key ID 94604D37
gpg: Can't check signature: public key not found
$
$ gpg --keyserver keys.gnupg.net --recv-key 94604D37
gpg: requesting key 94604D37 from hkp server keys.gnupg.net
gpg: key 94604D37: public key "Mathieu Lirzin <mthl@gnu.org>" imported
gpg: no ultimately trusted keys found
gpg: Total number processed: 1
gpg:               imported: 1    (RSA: 1)
$
$ gpg automake-1.16.1.tar.gz.sig
gpg: assuming signed data in `automake-1.16.1.tar.gz'
gpg: Signature made Sun 11 Mar 2018 04:12:47 PM MDT using RSA key ID 94604D37
gpg: Good signature from "Mathieu Lirzin <mthl@gnu.org>"
gpg:                 aka "Mathieu Lirzin <mthl@openmailbox.org>"
gpg:                 aka "Mathieu Lirzin <mathieu.lirzin@openmailbox.org>"
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: F2A3 8D7E EB2B 6640 5761    070D 0ADE E100 9460 4D37
$
$ gzip -cd automake* | tar xf -
$ cd automake*/
$ ./configure && make all check
--snip--
$ sudo make install
--snip--
$ cd ..
$ wget -q https://ftp.gnu.org/gnu/libtool/libtool-2.4.6.tar.gz
$ wget -q https://ftp.gnu.org/gnu/libtool/libtool-2.4.6.tar.gz.sig
$ gpg libtool-2.4.6.tar.gz.sig
gpg: assuming signed data in `libtool-2.4.6.tar.gz'
gpg: Signature made Sun 15 Feb 2015 01:31:09 PM MST using DSA key ID 2983D606
gpg: Can't check signature: public key not found
$
$ gpg --keyserver keys.gnupg.net --recv-key 2983D606
gpg: requesting key 2983D606 from hkp server keys.gnupg.net
gpg: key 2983D606: public key "Gary Vaughan (Free Software Developer) <gary@vaughan.pe>" imported
gpg: key 2983D606: public key "Gary Vaughan (Free Software Developer) <gary@vaughan.pe>" imported
gpg: no ultimately trusted keys found
gpg: Total number processed: 2
gpg:               imported: 2    (RSA: 1)
$
$ gpg libtool-2.4.6.tar.gz.sig
gpg: assuming signed data in `libtool-2.4.6.tar.gz'
gpg: Signature made Sun 15 Feb 2015 01:31:09 PM MST using DSA key ID 2983D606
gpg: Good signature from "Gary Vaughan (Free Software Developer) <gary@vaughan.pe>"
gpg:                 aka "Gary V. Vaughan <gary@gnu.org>"
gpg:                 aka "[jpeg image of size 9845]"
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: CFE2 BE70 7B53 8E8B 2675    7D84 1513 0809 2983 D606
$
$ gzip -cd libtool* | tar xf -
$ cd libtool*/
$ ./configure && make all check
--snip--
$ sudo make install
--snip--
$ cd ..
$

上面的示例展示了如何使用相关的.sig文件验证 GNU 包的签名。该示例假设你尚未在系统上配置 gpg 密钥服务器,并且没有安装任何这些包的公钥。如果你已经配置了首选的密钥服务器,可以跳过gpg命令行中的--keyserver选项。一旦你导入了这些包的公钥,以后就不需要再做一次了。

你可能还希望以不需要 root 权限通过sudo的方式进行安装。为此,请执行带有--prefix选项的configure,例如--prefix=$HOME/autotools,然后将~/autotools/bin添加到你的PATH环境变量中。

现在你应该能够成功执行前面示例中的版本检查命令。如果你仍然看到旧版本,请确保你的PATH环境变量中正确地包含了/usr/local/bin(或者你安装的任何位置),并且排在/usr/bin之前。

总结

在本章中,我提供了 Autotools 的高层次概述,以便让你了解如何将所有内容结合在一起。我还展示了在使用 Autotools 构建系统创建的分发 tarball 时,构建软件的模式。最后,我展示了如何安装 Autotools,以及如何查看已安装的版本。

在第三章中,我们将暂时离开 Autotools,开始为一个名为Jupiter的玩具项目创建一个手工编码的构建系统。你将了解一个合理构建系统的需求,并且熟悉 Autotools 最初设计背后的理念。通过这些背景知识,你将开始理解为什么 Autotools 会以这种方式操作。我无法过多强调这一点:第三章是本书中最重要的章节之一,因为它将帮助你克服因误解而对 Autotools 产生的任何情感偏见

第三章:理解 GNU 编码标准

我不知道人们怎么了:他们不是通过理解来学习,而是通过其他方式——死记硬背什么的。他们的知识是如此脆弱!

—理查德·费曼*,《你一定是在开玩笑,费曼先生!》

Image

在第二章中,我概述了 GNU Autotools 以及一些能够帮助减少学习曲线的资源。在这一章中,我们将稍微后退一步,探讨一些项目组织技巧,这些技巧不仅适用于使用 Autotools 的项目,也适用于任何项目。

当你读完本章后,你应该熟悉常见的make目标及其存在的原因。你还应该对为什么项目会按这种方式组织有一个扎实的理解。事实上,你已经走在了成为 Autotools 专家的路上。

本章提供的信息主要来自两个来源:

  • GNU 编码标准(GCS)^(1)

  • 文件系统层次标准(FHS)^(2)

如果你想复习一下make的语法,你可能会发现GNU Make 手册 ^(3) 非常有用。如果你特别感兴趣于便携式make语法,那么查看一下make的 POSIX 手册页^(4)吧。需要注意的是,目前在 Autotools 邮件列表中有关于将 GNU make作为目标标准的讨论,因为如今它已广泛可用。因此,便携式make脚本的重要性不再像过去那样突出。

创建一个新的项目目录结构

当你为一个开源软件项目设置构建系统时,你需要问自己两个问题:

  • 我将针对哪些平台?

  • 我的用户期望什么?

第一个问题是简单的——你可以决定目标平台,但不应过于限制。开源软件项目的好坏取决于其社区,而随意限制平台数量会减少你的社区的潜在规模。然而,你可以考虑只支持目标平台的当前版本。你可以通过与用户组和社区进行交流,确定哪些版本是相关的。

第二个问题更难回答。首先,让我们将范围缩小到一个可管理的范畴。你真正需要问的是,我的用户对我的构建系统有什么期望? 有经验的开源软件开发人员通过下载、解压、构建和安装数百个软件包,逐渐了解这些期望。最终,他们会直觉地知道用户对构建系统的期望是什么。但即便如此,软件包的配置、构建和安装过程差异巨大,因此很难定义一个固有的标准。

与其自己调查所有存在的构建系统,你可以查阅自由软件基金会(FSF),这是 GNU 项目的赞助商,它已经为你做了大量的工作。FSF 提供了一些关于自由开源软件的最佳权威信息来源,包括GCS,它涵盖了与编写、发布和分发自由开源软件相关的各种话题。即使是许多非 GNU 的开源软件项目也与GCS保持一致。为什么?因为 FSF 发明了自由软件的概念,而且这些理念大多数情况下是有道理的。^(5) 在设计一个管理软件打包、构建和安装的系统时,有几十个问题需要考虑,而GCS 已经考虑到了其中的大部分。

名字代表什么?

你可能知道,开源软件项目通常有一些奇特的名字——它们可能以某种设备、发明、拉丁术语、过去的英雄、古代神祇命名,或者它们可能以某些小型毛茸茸的动物命名,这些动物与软件有(模糊的)相似特征。也有些名字是捏造的词或缩写,既吸引人又易于发音。一个好项目名字的另一个重要特征是独特性——你的项目必须容易与其他项目区分开来。你还希望项目名字在搜索引擎中能够与其他使用该名字的结果区分开。此外,你还应该确保项目的名字在任何语言或文化中没有负面含义。

项目结构

我们将从一个基本的示例项目开始,并在继续探索源代码级软件分发的过程中逐步构建它。我们将项目命名为Jupiter,并使用以下命令创建项目目录结构:

$ cd projects
$ mkdir -p jupiter/src
$ cd jupiter
$ touch Makefile src/Makefile jupiter/src/main.c
$

现在我们有了一个名为src的源代码目录,一个名为main.c的 C 源文件,以及两个目录中的每个文件都包含一个 makefile。简洁,是的,但这是一个新的尝试,成功的开源软件项目的关键在于演化。从小做起,根据需要扩展——以及根据你的时间和兴趣。

让我们从添加支持构建和清理项目开始。稍后我们需要为构建系统添加其他重要功能,但这两个功能足以让我们开始。目前,顶级的 makefile 仅做了很少的工作;它只是将请求递归地传递给src/Makefile。这构成了一个相当常见的构建系统类型,称为递归构建系统,之所以这样命名,是因为 makefile 会递归地调用 make 来处理子目录的 makefile。^(6) 我们将在本章的最后花些时间思考如何将递归系统转换为非递归系统。

列表 3-1 到 3-3 显示了目前为止这三份文件的内容。

Git 标签 3.0

all clean jupiter:
        cd src && $(MAKE) $@

.PHONY: all clean

清单 3-1:Makefile:Jupiter 顶层 makefile 的初步草稿

all: jupiter

jupiter: main.c
        gcc -g -O0 -o $@ main.c
clean:
        -rm jupiter

.PHONY: all clean

清单 3-2: src/Makefile:Jupiter 的 src 目录 makefile 的初步草稿

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char * argv[])
{
    printf("Hello from %s!\n", argv[0]);
    return 0;
}

清单 3-3: src/main.c:Jupiter 项目中唯一的 C 源文件的第一个版本

注意

当你阅读这段代码时,你可能会注意到一些地方,其中的 makefile 或源代码文件包含了不是以最简单的方式编写的结构,或者可能不是你选择的编写方式。我的疯狂背后是有方法的:我试图使用许多 make 工具可以兼容的结构。

现在让我们讨论一下 make 的基础。如果你已经相当熟悉它了,可以跳过下一节。否则,快速阅读一下,我们稍后会在本章的后续部分回到 Jupiter 项目。

Makefile 基础

如果你不经常使用 make,通常很难记住在 makefile 中具体该放什么内容,因此这里有一些需要记住的要点。除了以井号(#)开头的注释外,makefile 中只有两种基本类型的实体:

  • 规则定义

  • 变量赋值

尽管在 makefile 中还有其他几种类型的结构(包括条件语句、指令、扩展规则、模式规则、函数变量和包含语句等),但为了我们的目的,我们只会在需要时简单提及它们,而不是详细讨论所有内容。这并不意味着它们不重要。相反,如果你要手动编写自己的复杂构建系统,它们非常有用。不过,我们的目标是为理解 GNU Autotools 打下必要的基础,因此我只会讲解你需要了解的 make 相关部分。

如果你想更深入了解 make 语法,可以参考 GNU Make 手册。对于严格的可移植语法,POSIX 中的 make 手册页是一个很好的参考。如果你想成为 make 专家,要准备花费相当多的时间学习这些资源——make 工具的功能远比最初看到的要复杂得多。

规则

规则遵循 清单 3-4 中展示的通用格式。

targets: [dependencies][; command-0]
[<tab>command-1
<tab>command-2
--snip--
<tab>command-N]

清单 3-4:Makefile 中规则的语法

在此语法定义中,方括号([])表示规则的可选部分,<tab> 表示一个制表符(ctrl-i)字符。

除了制表符字符和换行符外,所有其他空白字符都是可选的并且会被忽略。当 makefile 中的一行以制表符字符开始时,make通常会将其视为命令(续行除外,后面会讨论)。事实上,对于初学者和专家来说,makefile 语法中最令人沮丧的一点就是命令必须以一个本质上不可见的字符为前缀。当遗留的 UNIX make工具缺少所需的制表符(或被编辑器转换为空格),或当意外的制表符被插入到可能被解释为规则的行的开头时,生成的错误消息往往模糊不清。GNU make在这些错误消息上做得更好。尽管如此,在 makefile 中使用制表符字符时仍然要小心——它们必须始终且仅仅出现在命令之前。^(7)

请注意,规则中的几乎所有内容都是可选的;规则中唯一必须的部分是targets部分及其冒号(:)字符。使用第一个命令,command-0及其前面的分号(;)是一种可选形式,通常 Autotools 不推荐使用,但如果你只有一个命令要执行,make语法是完全合法的。你甚至可以将command-0与其他命令结合使用,但这几乎从不发生。

一般来说,目标是需要构建的对象,而依赖是为目标提供源材料的对象。因此,目标被认为依赖于依赖。依赖本质上是目标的前提条件,因此应该首先更新它们。^(8)

列表 3-5 展示了 makefile 的一般布局。

var1 = val1
var2 = val2
--snip--
target1 : t1_dep1 t1_dep2 ... t1_depN
<tab>shell-command1a
<tab>shell-command1b
--snip--
target2 : t2_dep1 t2_dep2 ... t2_depN
<tab>shell-command2a
<tab>shell-command2b
--snip--

列表 3-5:makefile 的一般布局

makefile 的内容包含一种声明性语言,在其中你定义了一组期望的目标,make决定实现这些目标的最佳方式。make工具是一个基于规则的命令引擎,工作中的规则指示哪些命令应当执行,以及何时执行。当你在规则中定义命令时,你实际上是在告诉make,当前面的目标需要构建时,你希望它从 shell 中执行以下每个语句。假定这些命令实际上会创建或更新目标。规则中提到的目标和依赖项的文件存在性和时间戳指示是否应该执行命令以及执行的顺序。

make 处理 makefile 中的文本时,它会构建一个依赖关系链的网络(技术上称为有向无环图,或 DAG)。在构建特定目标时,make 必须从整个图中倒推回到每个“链”的起始点。在遍历链时,make 会执行每个规则的命令,从距离目标最远的规则开始,一直到所需目标的规则。当 make 发现某些目标比它们的依赖项更新较旧时,它必须执行相关的命令集来更新这些目标,然后才能处理链中的下一个规则。只要规则编写正确,这个算法确保了 make 会用尽可能少的操作构建一个完全最新的产品。实际上,正如我们稍后会看到的,当 makefile 中的规则编写得当时,看它在项目中的文件发生变化后运行,实际上是一种乐趣。

变量

包含等号(=)的 makefile 行是变量定义。在 makefile 中的变量与 Shell 或环境变量有些相似,但也有一些关键的区别。

在 Bourne Shell 语法中,你可以这样引用一个变量:${my_var}。同样可以使用不带花括号的形式:$my_var。在 makefile 中引用变量的语法几乎相同,不过你可以选择使用花括号或圆括号:$(my_var)。为了减少混淆,已成为一种约定,在取消引用 make 变量时使用圆括号而非花括号。对于单字符 make 变量,使用这些分隔符是可选的,但为了避免歧义,应该使用它们。例如,$X 在功能上等同于 $(X)${X},但是 $(my_var) 需要使用圆括号,以便 make 不会将引用解释为 $(m)y_var

注意

make命令中取消引用一个 Shell 变量时,通过将美元符号加倍来转义它—例如,$${shell_var}。转义美元符号告诉make不要解释变量引用,而是将其视为命令中的字面文本。因此,变量引用将在命令执行时由 Shell 进行插值。

默认情况下,make 在处理 makefile 之前会读取进程环境到其变量表中;这使你可以访问大多数环境变量,而无需在 makefile 中显式定义它们。然而,注意,在 makefile 中设置的变量将覆盖从环境中获得的变量。^(9) 通常,不建议在构建过程中依赖环境变量的存在,尽管可以在条件情况下使用它们。此外,make 定义了几个有用的变量,例如 MAKE 变量,其值为当前进程中调用 make 的命令。

你可以在 makefile 中的任何位置分配变量。然而,你需要注意的是,make 会对 makefile 进行两次处理。在第一次处理中,它会将变量和规则收集到表格和内部结构中。在第二次处理中,它会解析规则定义的依赖关系,根据第一次处理时收集到的文件系统时间戳调用规则,以重建依赖关系。如果规则中的依赖项比目标更新,或者目标缺失,那么 make 会执行该规则的命令来更新目标。某些变量引用在第一次处理规则时会立即解析,而其他的则在执行命令时,在第二次处理中解析。

每个命令使用单独的 Shell

在处理规则时,make 会独立执行每个命令。也就是说,规则下的每个命令都会在各自的 shell 中执行。这意味着你不能在一个命令中导出一个 shell 变量,然后在下一个命令中尝试访问它的值。

如果你想做类似的事情,你需要将命令串联在同一命令行上,使用命令分隔符(例如,Bourne shell 语法中的分号)。当你像这样写命令时,make 会将串联的命令作为一条命令行传递给同一个 shell。为了避免过长的命令行并增加可读性,你可以在每一行的末尾使用反斜杠将它们包裹起来——按照惯例,在分号后面。^(10) 这类命令的包装部分也可以用制表符字符来开头。POSIX 指定 make 在处理命令之前会移除所有前导的制表符字符(即便是那些跟在转义的换行符后的制表符),但需要注意的是,某些 make 实现可能会输出这些嵌入在包裹命令中的制表符字符——通常是无害的。^(11)

列表 3-6 显示了一些简单的多命令示例,这些命令将由同一个 shell 执行。

➊ foo: bar.c
           sources=bar.c; \
           gcc -o foo $${sources}
➋ fud: baz.c
           sources=baz.c; gcc -o fud $${sources}
➌ doo: doo.c
           TMPDIR=/var/tmp gcc -o doo doo.c

列表 3-6:一个包含多个命令示例的 makefile,这些命令将由同一个 shell 执行

在第一个例子 ➊ 中,两行命令由同一个 shell 执行,因为反斜杠将两行之间的换行符转义。make 工具会在将一条多命令语句传递给 shell 之前,删除所有转义的换行符。第二个例子 ➋ 与第一个例子在 make 的角度上是相同的。

第三个例子 ➌ 有些不同。在这种情况下,我仅为将运行 gcc 的子进程定义了 TMPDIR 变量。^(12) 注意缺少的分号;从 shell 的角度来看,这只是一个单独的命令。^(13)

注意

如果你选择用一个反斜杠来换行命令,请确保反斜杠后面没有空格或其他不可见字符。反斜杠转义了换行符,因此它必须紧接在换行符之前。

变量绑定

在命令中引用的变量可能在 makefile 中的命令之后才被定义,因为这些引用的值直到make将命令传递给 shell 执行之前才会被绑定——也就是说,直到整个 makefile 被读取完之后。在一般情况下,make会尽可能推迟将变量绑定到值。

由于命令在规则之后处理,命令中的变量引用会比规则中的变量引用晚绑定。在规则中找到的变量引用在make根据规则构建有向图时会被扩展。因此,在规则中引用的变量必须在 makefile 中完全定义,且在引用规则之前。 清单 3-7 展示了一个 makefile 的部分内容,说明了这两个概念。

   --snip--
   mytarget = foo
➊ $(mytarget): $(mytarget).c
        ➋ gcc -o $(mytarget) $(mytarget).c
   mytarget = bar
   --snip--

清单 3-7:makefile 中的变量扩展

在➊规则中,$(mytarget)的两个引用在第一次处理时都被扩展为foo,因为那时make正在构建变量列表和有向图。然而,结果可能并不是你预期的,因为在➋命令中的两个$(mytarget)引用要到稍后才会被扩展,远在make已经将bar赋值给mytarget,并覆盖了原本的foo赋值之后。

清单 3-8 展示了在变量完全扩展后,make如何看待相同的规则和命令。

--snip--
foo: foo.c
        gcc -o bar bar.c
--snip--

清单 3-8:代码在清单 3-7 中的变量扩展结果

这个故事的寓意是,你应该了解在 makefile 结构中,变量会在哪里被扩展,这样你就不会感到惊讶,当make处理你的 makefile 时不按预期的方式执行。良好的实践(也是避免头痛的好方法)是,在打算使用变量之前,总是先赋值给它们。有关 makefile 中变量的即时扩展和延迟扩展的更多信息,请参考GNU Make 手册中的“make 如何读取 makefile”一节。

规则详细信息

我在示例中使用的规则,称为常见make规则,包含一个冒号字符(:)。冒号将左边的目标与右边的依赖项分开。

记住,目标是产物——即通过执行一个或多个命令(如 C 或 C++编译器、链接器,或者像 Doxygen 或 LaTeX 这样的文档生成器)可以生成的文件系统实体。而依赖关系则是源对象,或者是用来创建目标的对象。这些对象可能是计算机语言源文件、先前规则构建的中间产品,或者任何可以被命令用作资源的东西。

你可以在make命令行中直接指定任何在 makefile 规则中定义的目标,make将执行生成该目标所需的所有命令。

注意

如果你没有在make命令行中指定任何目标,make将使用默认目标——即它在 makefile 中找到的第一个目标。

例如,一个 C 编译器接受依赖项 main.c 作为输入并生成目标 main.o。然后,链接器以 main.o 作为输入并生成一个命名的可执行目标——在这种情况下是 program

图 3-1 显示了数据流的过程,这些流程可能由 Makefile 中定义的规则指定。

Image

图 3-1:编译和链接过程的数据流图

make 工具实现了一些相当复杂的逻辑,用来判断何时执行一个规则,依据是目标文件是否存在,以及它是否比其依赖项旧。列表 3-9 显示了一个包含规则的 Makefile,其中一些规则会执行 图 3-1 中的操作。

program: main.o print.o display.o
     ➊ ld main.o print.o display.o ... -o program

main.o: main.c
        gcc -c -g -O2 -o main.o main.c

print.o: print.c
        gcc -c -g -O2 -o print.o print.c

display.o: display.c
        gcc -c -g -O2 -o display.o display.c

列表 3-9:使用多个 make 规则编译并链接程序

这个 Makefile 中的第一个规则指出,program 依赖于 main.oprint.odisplay.o。其余的规则则表示每个 .o 文件依赖于相应的 .c 文件。最终,program 依赖于这三个源文件,但由于过程分为编译和链接两个步骤,中间还需要生成结果,因此必须有对象文件作为中间依赖。在每个规则中,make 使用关联的命令列表根据依赖项列表构建规则的目标。

Unix 编译器设计为比链接器更高级的工具。它们内置了关于特定系统链接器要求的低级知识。在 列表 3-9 中的 Makefile,➊ 处的省略号是一个占位符,用于表示构建系统上所有程序所需的系统特定、低级对象和库的列表。编译器可以用来调用链接器,并默默地传递这些系统特定的对象和库。(这种方法非常有效且广泛使用,以至于通常很难发现如何在给定系统上手动执行链接器。)列表 3-10 显示了你如何重写 列表 3-9 中的 Makefile,使用编译器来编译源代码并在单个规则中调用链接器。

sources = main.c print.c display.c

program: $(sources)
        gcc -g -O2 -o program $(sources)

列表 3-10:使用单个 make 规则将源代码编译成可执行文件

注意

在这种情况下,使用单个规则和命令来处理这两个步骤是可行的,因为这个示例非常基础。对于更大的项目,通常不建议在一个步骤中直接从源代码跳到可执行文件。然而,在任何情况下,使用编译器调用链接器可以减轻确定需要链接到应用程序的许多系统对象的负担,实际上,这个技术非常常见并被广泛使用。更复杂的示例中,每个文件会被单独编译,编译器会将每个源文件编译成对象文件,然后使用编译器调用链接器将所有文件链接成一个可执行文件。

在这个例子中,我添加了一个 make 变量(sources),它允许我们将所有产品依赖项整合到一个位置。现在,我们有一个源文件列表,该列表在两个地方引用:在依赖列表中和命令行中。

自动变量

依赖列表中可能包含其他不是 sources 变量中的对象,包括预编译对象和库。这些其他对象必须在规则中和命令行中分别列出。如果我们能为引用规则的整个依赖列表提供简写形式,那该有多好?

实际上,各种 自动 变量可以在执行命令时引用控制规则的部分。不幸的是,如果你关心 make 实现之间的可移植性,这些大多数都几乎无用。$@ 变量(引用当前目标)是可移植且有用的,但其他大多数自动变量过于有限,无法发挥太大作用。^(14) 以下是 POSIX 为 make 定义的完整可移植自动变量列表:

  • $@ 指当前目标的完整目标名称或库归档目标的归档文件名部分。此变量在显式和隐式规则中都有效。

  • $% 指归档成员,仅在当前目标是归档成员时有效——即静态库中的目标文件。此变量在显式和隐式规则中都有效。

  • $? 指比当前目标更新的依赖项列表。此变量在显式和隐式规则中都有效。

  • $< 指依赖列表中允许选择该规则的依赖项。此变量仅在隐式规则中有效。

  • $* 指当前目标名称,去掉其后缀部分。POSIX 保证此变量仅在隐式规则中有效。

GNU make 大大扩展了 POSIX 定义的列表,但由于 GNU 扩展不可移植,除 $@ 外不建议使用这些扩展。

依赖规则

假设 print.cdisplay.c 各自有一个同名的头文件,以 .h 结尾。每个源文件都包含自己的头文件,但 main.c 包含了 print.hdisplay.h。根据 Listings 3-9 和 3-10 中的 makefile,如果你执行 make 来构建 program,然后修改其中一个头文件——比如 print.h,再重新执行 make,你觉得会发生什么?什么都不会发生,因为 make 根本不知道这些头文件的存在。在 make 看来,你并没有更改任何与 program 相关的内容。

在列表 3-11 中,我将sources变量替换为objects变量,并用对象文件列表替换了源文件列表。这个版本的 makefile 在列表 3-10 中也通过利用标准和自动变量,消除了冗余。

objects = main.o print.o display.o

main.o: main.c print.h display.h
print.o: print.c print.h
display.o: display.c display.h

program: $(objects)
        gcc -g -O2 -o $@ $(objects)

列表 3-11:在命令中使用自动变量

我还添加了三个依赖规则,这些规则没有命令,目的是澄清编译器输出文件与依赖的源文件和头文件之间的关系。因为print.hdisplay.h(假定)被main.c包含,若这两个文件中的任何一个发生变化,main.c就必须重新编译;然而,make并不能知道这两个头文件是被main.c包含的。依赖规则允许开发者告知make这种后端关系。

隐式规则

如果你试图根据列表 3-11 中的 makefile 规则,心算出make将构建的依赖图,你会发现网络中似乎有个漏洞。根据文件中的最后一条规则,program可执行文件依赖于main.oprint.odisplay.o。此规则还提供了将这些目标链接成可执行文件的命令(这次只是调用链接器,而使用编译器)。这些目标文件通过三个依赖规则与它们对应的 C 源文件和头文件相关联。但编译.c文件成.o文件的命令在哪里?

我们可以将这些命令添加到依赖规则中,但其实没有必要,因为make有一个内建规则,知道如何从.c文件构建.o文件。make并没有什么神奇之处——它只知道你通过编写规则描述给它的关系。但make确实有一些内建规则,描述了例如.c文件和.o文件之间的关系。这个特定的内建规则提供了从具有相同基础名称的.c文件构建任何.o扩展名文件的命令。这些内建规则被称为后缀规则,或者更一般地说,隐式规则,因为依赖关系(源文件)的名称是通过目标(目标文件)的名称隐含表达的。

为了使内建隐式规则更广泛可用,它们的命令通常会使用一些知名的make变量。如果你设置了这些变量,覆盖了默认值,你可以对执行内建规则有一定的控制。例如,在内建隐式规则的标准 POSIX 定义中,.o文件转为.c文件的命令是:^(15)

$(CC) $(CPPFLAGS) $(CFLAGS) -c

在这里,你几乎可以通过设置CC(编译器)、CPPFLAGS(传递给 C 预处理器的选项)和CFLAGS(传递给 C 编译器的选项)来覆盖这个内建规则的各个方面。

如果需要,你可以自己编写隐式规则。你甚至可以用自己的版本覆盖默认的隐式规则。隐式规则是一个强大的工具,不容忽视,但在本书中,我们不会深入讨论。你可以通过阅读GNU Make 手册中的“使用隐式规则”部分,了解更多关于在 makefile 中编写和使用隐式规则的信息。

为了说明这种隐式功能,我创建了简单的 C 源代码和头文件,配合列表 3-11 中的示例 makefile。以下是我执行make命令时发生的情况:

➊ $ make
   cc    -c -o main.o main.c
   $
➋ $ make program
   cc    -c -o print.o print.c
   cc    -c -o display.o display.c
   gcc -g -O2 -o program main.o print.o display.o
   $

如你所见,cc通过-c-o选项被神奇地执行,从main.c生成main.o。这是常见的命令行语法,用于让 C 语言编译器从源代码构建对象,实际上,这种功能已内建在make中。如果你在现代的 GNU/Linux 系统中查找cc,你会发现它是/usr/bin中的一个软链接,指向系统的 GNU C 编译器。在其他系统中,它指向系统的本地 C 编译器。几十年来,调用系统 C 编译器为cc已经成为事实上的标准。^(16)

在➊那行输出中,cc-c之间的额外空格表示CPPFLAGSCFLAGS变量之间的空格,这些变量默认被定义为空。

那么,为什么我们在➊处输入make时,make工具只构建了main.o呢?仅仅是因为main.o的依赖规则提供了 makefile 中的第一个(因此也是默认)目标。在这种情况下,要构建program,我们需要执行make program,就像我们在➋所做的那样。记住,当你在命令行输入make时,make工具会尝试构建当前目录中名为Makefile的文件内第一个显式定义的目标。如果我们想让program成为默认目标,我们可以重新排列规则,使得program规则成为 makefile 中列出的第一个目标。

为了查看依赖规则的实际作用,可以触摸其中一个头文件,然后重新构建program目标:

$ touch display.h
$ make program
cc -c -o main.o main.c
cc -c -o display.o display.c
gcc -g -O0 -o program main.o print.o display.o
$

在更新了display.h之后,只有display.omain.oprogram被重新构建。由于print.c不依赖于display.h,根据 makefile 中指定的规则,print.o对象不需要重新构建。

虚拟目标

目标并不总是文件。它们也可以是所谓的虚拟目标,例如allclean。这些目标不指代文件系统中的真实产品,而是特定的结果或操作——当你执行这些目标时,项目会被清理所有产品会被构建,等等。

多目标

正如你可以在冒号右侧列出多个依赖项一样,你也可以通过在冒号左侧列出目标,将多个目标的规则与相同的依赖项和命令结合起来,如列表 3-12 所示。

all clean:
        cd src && $(MAKE) $@

示例 3-12:在规则中使用多个目标

虽然这在一开始可能不太明显,但这个示例包含了两个独立的规则:一个针对 all,另一个针对 clean。因为这两个规则有相同的依赖集(在这种情况下没有依赖)和相同的命令集,我们可以利用 make 支持的简写符号,将它们的规则合并为一个规范。

为了帮助你理解这个概念,考虑 示例 3-12 中的 $@ 变量。它指向哪个目标?这取决于当前执行的是哪个规则——是 all 还是 clean。由于一个规则在任何给定时刻只能执行一个目标,$@ 只能指向一个目标,即使控制规则的定义包含多个目标。

Makefile 作者的资源

GNU make 比原始的 AT&T UNIX make 工具要强大得多,尽管只要避免使用 GNU 扩展,GNU make 完全向后兼容。GNU Make 手册^(17) 可以在线访问,而 O'Reilly 也出版了一本关于原始 AT&T UNIX make 工具的优秀书籍^(18),详细讲解了它的各种细节。虽然你仍然可以找到这本书,但出版商已经将其内容合并进了一个新的版本,其中也涵盖了 GNU make 的扩展。^(19)

这部分结束了对 makefile 语法和 make 工具的基本讨论,尽管在本章接下来的内容中,我们会在遇到时继续探讨其他 makefile 结构。通过这些基本信息的学习,让我们回到 Jupiter 项目,开始添加一些更有趣的功能。

创建源代码分发档案

为了将 Jupiter 的源代码真正提供给用户,我们需要创建并分发一个源代码档案——一个 tarball。我们可以编写一个单独的脚本来创建这个 tarball,但既然我们可以使用虚拟目标在 makefile 中创建任意功能集,为什么不设计一个 make 目标来执行这项任务呢?为分发构建源代码档案通常归属于 dist 目标。

在设计一个新的 make 目标时,我们需要考虑它的功能是否应该在项目的 makefile 中分布,还是在单一位置处理。通常的经验法则是利用递归构建系统的特性,让每个目录管理自己部分的流程。我们在示例 3-1 中就是这么做的,将构建 jupiter 程序的控制权交给了包含源代码的 src 目录。然而,从目录结构中构建压缩档案并不真的是一个递归过程。^(20) 因此,我们必须在两个 makefile 中执行整个任务。

全局进程通常由项目目录结构中最高相关级别的 makefile 处理。我们将在顶级 makefile 中添加dist目标,如清单 3-13 所示。

Git 标签 3.1

➊ package = jupiter
   version = 1.0
   tarname = $(package)
   distdir = $(tarname)-$(version)

   all clean jupiter:
           cd src && $(MAKE) $@

➋ dist: $(distdir).tar.gz

➌ $(distdir).tar.gz: $(distdir)
           tar chof - $(distdir) | gzip -9 -c > $@
           rm -rf $(distdir)

➍ $(distdir):
           mkdir -p $(distdir)/src
           cp Makefile $(distdir)
           cp src/Makefile src/main.c $(distdir)/src

➎ .PHONY: all clean dist

清单 3-13: Makefile:将dist目标添加到顶级 makefile 中

除了在➋处添加dist目标外,我还做了一些其他修改。我们一个一个地来看。我已将dist目标添加到➎处的.PHONY规则中。.PHONY规则是一种特殊的内置规则,叫做点规则指令make工具理解几种点规则。.PHONY的目的是简单地告诉make,某些目标不会生成文件系统对象。通常,make通过比较目标与其依赖项的时间戳来决定运行哪些命令——但虚拟目标没有关联的文件系统对象。使用.PHONY可以确保make不会去查找这些目标所命名的不存在的产品文件。它还确保,如果一个名为dist的文件或目录不小心被添加到目录中,make仍然会将dist目标视为非真实目标。

.PHONY规则添加一个目标还有另一个效果。由于make无法通过时间戳来判断目标是否是最新的(即比它的依赖项更新),make唯一能做的就是始终执行与虚拟目标关联的命令,无论这些目标是通过命令行请求的,还是出现在依赖链中。

为了提高可读性、模块化和维护性,我将dist目标的功能分为三个单独的规则(➋、➌和➍)。这是任何软件工程过程中一个很好的经验法则:将大型过程构建为较小的过程,并在合理的地方重用这些小的过程

➋处的dist目标依赖于最终目标的存在——在这种情况下,是一个源级压缩归档包,jupiter-1.0.tar.gz。我使用了一个变量来保存版本号(这样以后更新项目版本更容易),另一个变量用于包名在➊处,这样如果我决定将这个 makefile 用于另一个项目时,改名就会更容易。我还在逻辑上将包名和 tarball 名的功能分开;默认的 tarball 名称是包名,但我们也有选择将它们设置为不同的名称。

构建 tarball 的规则在➌处指出了如何使用gziptar工具通过一个命令来创建该文件。但请注意,该规则有一个依赖关系——要归档的目录。目录名是从 tarball 的名称和包版本号派生的;它存储在另一个名为distdir的变量中。

我们不希望上次构建尝试中的目标文件和可执行文件出现在归档中,因此我们需要构建一个图像目录,包含我们想要发布的所有内容——包括构建和安装过程中所需的任何文件,以及任何额外的文档或许可文件。不幸的是,这几乎要求必须使用单独的复制(cp)命令。

由于 makefile 中有一条规则(在 ➍ 处)指示如何创建这个目录,并且该规则的目标是 tarball 的依赖项,make 会先执行该规则的命令,再执行 tarball 规则的命令。回想一下,make 会递归地处理规则,以从底部到顶部构建依赖关系,直到它能够执行请求的目标的命令。^(21)

强制执行规则

$(distdir) 目标中有一个微妙的缺陷,现在可能不太明显,但它将在最糟糕的时候露出它丑陋的面目。如果在执行 make dist 时归档图像目录 (jupiter-1.0) 已经存在,那么 make 就不会尝试创建它。试试这个:

$ mkdir jupiter-1.0
$ make dist
tar chof - jupiter-1.0 | gzip -9 -c > jupiter-1.0.tar.gz
rm -rf jupiter-1.0
$

请注意,dist 目标并没有复制任何文件——它只是从现有的 jupiter-1.0 目录中创建了一个归档文件,而该目录是空的。当用户解压这个 tarball 时,肯定会大吃一惊!更糟糕的是,如果之前归档尝试中的图像目录仍然存在,那么新的 tarball 将包含我们上次尝试创建发行版 tarball 时的过时源代码。

问题在于,$(distdir) 目标是一个没有依赖关系的实际目标,这意味着只要它在文件系统中存在,make 就会认为它是最新的。我们可以将 $(distdir) 目标添加到 .PHONY 规则中,以强制 make 每次执行 dist 目标时都重新构建它,但它并不是一个虚假目标——它是一个实际的文件系统对象。确保 $(distdir) 始终被重新构建的正确方法是确保在 make 尝试构建它之前,它并不存在。实现这一目标的一种方式是创建一个真正的虚假目标,使其始终执行,然后将该目标添加到 $(distdir) 目标的依赖列表中。此类目标的常见名称是 FORCE,我在 Listing 3-14 中实现了这个概念。

Git 标签 3.2

   --snip--
   $(distdir).tar.gz: $(distdir)
           tar chof - $(distdir) | gzip -9 -c > $@
           rm -rf $(distdir)

➊ $(distdir): FORCE
           mkdir -p $(distdir)/src
           cp Makefile $(distdir)
           cp src/Makefile $(distdir)/src
           cp src/main.c $(distdir)/src

➋ FORCE:
           -rm $(distdir).tar.gz >/dev/null 2>&1
           rm -rf $(distdir)

  .PHONY: FORCE all clean dist

Listing 3-14: Makefile: 使用 FORCE 目标

FORCE 规则的命令(在 ➋ 处)每次都会执行,因为 FORCE 是一个虚假目标。由于我们将 FORCE 作为 $(distdir) 目标的依赖项(在 ➊ 处),我们有机会在 make 开始评估是否应执行 $(distdir) 的命令之前,删除任何先前创建的文件和目录。

领先的控制字符

命令前缀的破折号字符(-)告诉make不关心它所执行的命令返回的状态码。通常,当make遇到返回非零状态码的命令时,它会停止执行并显示错误信息,但如果你使用了破折号,它会忽略错误并继续执行。我在FORCE规则中的第一个rm命令前加了破折号,因为我想删除可能存在也可能不存在的先前创建的产品文件,如果尝试删除不存在的文件,rm会返回错误。

通常来说,一个更好的选择是在rm命令行中使用-f标志,这样rm就会忽略缺失文件的错误。使用-f的另一个好处是,我们不再需要将错误消息重定向到/dev/null,因为我们其实更关心其他错误——例如权限错误。从现在开始,我们将在所有rm命令前去掉破折号,并确保使用-f

你可能会遇到的另一个重要符号是@符号(@)。以@符号为前缀的命令告诉make不要执行其正常行为,即在执行命令时不将命令打印到stdout设备。通常在echo语句前使用@符号。你不希望make打印echo语句,因为这样你的消息就会被打印两次:一次由make,然后再由echo语句本身打印。

注意

你还可以以任意顺序组合这些前导字符(@-+)。加号(+)字符用于强制执行一个原本不会执行的命令,例如由于-n命令行选项,它告诉make执行所谓的干运行。有些命令即使在干运行中也有意义。

最好谨慎使用@符号。我通常将它保留用于那些我永远不想看到的命令,例如echo语句。如果你喜欢安静的构建系统,可以考虑在 makefile 中使用全局.SILENT指令。或者,更好的是,干脆什么都不做,让用户可以选择在其make命令行中添加-s选项,这样用户就可以选择他们想看到多少输出。

自动测试分发

构建归档目录的规则可能是这个 makefile 中最令人沮丧的规则,因为它包含了将单个文件复制到分发目录中的命令。每次我们更改项目中的文件结构时,我们都必须在顶层 makefile 中更新这个规则,否则我们会破坏dist目标。但是我们能做的也不多——我们已经将这个规则简化到极限。现在我们只需要记住正确管理这个过程。

不幸的是,如果忘记更新distdir规则的命令,可能会发生比破坏dist目标更糟糕的事情。看起来dist目标似乎工作正常,但实际上可能没有将所有必要的文件复制到 tarball 中。事实上,更有可能发生这种情况,而不是错误,因为添加文件到项目中比移动或删除文件更为常见。新文件将不会被复制,但dist规则不会注意到这个区别。

有一种方法可以对dist目标执行自检。我们可以创建另一个虚拟目标,叫做distcheck,它会做完全相同的事情:解压缩 tarball 并构建项目。我们可以让这个规则的命令在临时目录中执行这个任务。如果构建过程失败,那么distcheck目标会失败,告诉我们在分发包中遗漏了某些重要的内容。

清单 3-15 展示了为了实现distcheck目标,我们对顶级 makefile 所做的修改。

Git 标签 3.3

--snip--
$(distdir): FORCE
        mkdir -p $(distdir)/src
        cp Makefile $(distdir)
        cp src/Makefile src/main.c $(distdir)/src

distcheck: $(distdir).tar.gz
        gzip -cd $(distdir).tar.gz | tar xvf -
        cd $(distdir) && $(MAKE) all
        cd $(distdir) && $(MAKE) clean
        rm -rf $(distdir)
        @echo "*** Package $(distdir).tar.gz is ready for distribution."
--snip--
.PHONY: FORCE all clean dist distcheck

清单 3-15: Makefile: 向顶级 makefile 添加 distcheck 目标

distcheck目标依赖于 tarball 本身,因此首先执行构建 tarball 的规则。然后,make 工具执行distcheck命令,它解压缩刚刚构建的 tarball,然后在结果目录内递归地对allclean目标运行make。如果该过程成功,distcheck目标将输出一条消息,表示用户在使用这个 tarball 时很可能不会遇到问题。

现在,你只需要记住在发布 tarball 供公众分发之前,执行 make distcheck

单元测试,谁来做?

有些人坚称单元测试是邪恶的,但他们能提出的唯一不做单元测试的理由就是懒惰。适当的单元测试是艰苦的工作,但最终是值得的。做单元测试的人通常在儿童时期就学会了一个关于延迟满足价值的教训。

一个好的构建系统应该包括适当的单元测试。最常用的测试构建的目标是check目标,所以我们将像往常一样添加它。实际的单元测试可能应该放在src/Makefile中,因为这是构建jupiter可执行文件的地方,因此我们将从顶级 makefile 传递check目标。

但是,我们应该在check规则中放入什么命令呢?嗯,jupiter是一个非常简单的程序——它打印消息 Hello from some/path/jupiter!,其中 some/path 取决于从哪个位置执行jupiter。我将使用grep工具来测试jupiter是否确实输出了这样的字符串。

清单 3-16 和 3-17 分别展示了对顶级和src目录 makefile 所做的修改。

Git 标签 3.4

--snip--
all clean check jupiter:
        cd src && $(MAKE) $@
--snip--
.PHONY: FORCE all clean check dist distcheck

清单 3-16: Makefile: check 目标传递到 src/Makefile

--snip--
src/jupiter: src/main.c
        $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ src/main.c

check: all
        ./jupiter | grep "Hello from .*jupiter!"
        @echo "*** ALL TESTS PASSED ***"
--snip--

.PHONY: all clean check

清单 3-17: src/Makefile: check 目标中实现单元测试

请注意,check 依赖于 all。除非产品是最新的,能够反映最近对源代码或构建系统所做的任何更改,否则我们无法真正测试我们的产品。如果用户想要测试产品,他们当然希望产品是存在的并且是最新的。我们可以通过将 all 添加到 check 的依赖列表中来确保它们存在并且是当前的。

我们可以对构建系统进行另一个改进:我们可以将 check 添加到 distcheck 规则中由 make 执行的目标列表中,放在执行 allclean 之间。清单 3-18 显示了在顶级 makefile 中执行此操作的位置。

Git 标签 3.5

--snip--
distcheck: $(distdir).tar.gz
        gzip -cd $(distdir).tar.gz | tar xvf -
        cd $(distdir) && $(MAKE) all
        cd $(distdir) && $(MAKE) check
        cd $(distdir) && $(MAKE) clean
        rm -rf $(distdir)
        @echo "*** Package $(distdir).tar.gz is ready for distribution."
--snip--

清单 3-18: Makefile: check 目标添加到 $(MAKE) 命令中

现在,当我们运行 make distcheck 时,它将测试与包一起发布的整个构建系统。

安装产品

我们已经达到了这样的阶段,用户在构建 Jupiter 项目时应该会感到相当顺利——甚至愉快。用户只需要解压分发包,进入分发目录,然后输入 make。实际上,它已经简单到不能再简单了。

但我们仍然缺少一个重要的功能——安装。就 Jupiter 项目而言,这相对简单。只有一个程序,大多数用户会正确猜测,安装它时,他们应该将 jupiter 复制到他们的 /usr/bin/usr/local/bin 目录中。然而,更复杂的项目可能会让用户对将用户和系统二进制文件、库、头文件和文档(包括 man 页、info 页、PDF 文件,以及通常与 GNU 项目相关联的 READMEAUTHORSNEWSINSTALLCOPYING 文件)放在哪里感到困惑。

我们实际上并不希望用户自己搞清楚这些问题,因此我们将创建一个 install 目标来管理在构建完成后将文件放到合适的位置。事实上,为什么不直接把安装作为 all 目标的一部分呢?嗯,我们还是不要太冲动。实际上,存在一些不这样做的合理原因。

首先,构建和安装是两个独立的逻辑概念。第二个原因是文件系统权限的问题。用户有权限在自己的主目录中构建项目,但安装通常需要 root 权限才能将文件复制到系统目录。最后,用户可能有多个原因希望构建但不安装项目,因此将这些操作绑定在一起是不可取的。

虽然创建分发包可能不是一个本质上递归的过程,但安装过程肯定是递归的,所以我们允许项目中的每个子目录管理其组件的安装。为此,我们需要修改顶级和 src 级别的 makefile。修改顶级 makefile 很简单:因为顶级目录中没有需要安装的产品,我们只是按惯例将责任交给 src/Makefile

添加 install 目标的修改在列表 3-19 和 3-20 中展示。

Git 标签 3.6

--snip--
all clean check install jupiter:
        cd src && $(MAKE) $@
--snip--

.PHONY: FORCE all clean check dist distcheck install

列表 3-19: Makefile: install 目标传递给 src/Makefile

--snip--
check: all
        ./src/jupiter | grep "Hello from .*jupiter!"
        @echo "*** All TESTS PASSED"

install:
        cp jupiter /usr/bin
        chown root:root /usr/bin/jupiter
        chmod +x /usr/bin/jupiter
--snip--

.PHONY: all clean check install

列表 3-20: src/Makefile: 实现 install 目标

在列表 3-19 中展示的顶级 makefile 中,我将 install 添加到了传递给 src/Makefile 的目标列表中。文件的安装由列表 3-20 中展示的 src 级别的 makefile 处理。

安装比简单复制文件要复杂一些。如果文件被放置在 /usr/bin 目录中,那么 root 应该是其所有者,这样只有 root 才能删除或修改该文件。此外,jupiter 二进制文件应该被标记为可执行文件,因此我使用了 chmod 命令来设置文件的权限。这可能是多余的,因为链接器确保 jupiter 被创建为可执行文件,但并不是所有类型的可执行产品都是由链接器生成的——例如 shell 脚本。

现在我们的用户只需要输入以下命令序列,Jupiter 项目就会在他们的平台上以正确的系统属性和所有权进行构建、测试和安装:

$ gzip -cd jupiter-1.0.tar.gz | tar xf -
$ cd jupiter-1.0
$ make all check
--snip--
$ sudo make install
Password: ******
--snip--
$

安装选项

这一切看起来都不错,但在安装位置上,可能还需要更灵活一些。有些用户可能可以接受将 jupiter 安装到 /usr/bin 目录下,而另一些用户则会问为什么它不安装到 /usr/local/bin 目录——毕竟,这是一个常见的约定。我们可以将目标目录改为 /usr/local/bin,但这时用户可能会问,为什么他们不能选择将其安装到自己的主目录中。这正是需要一点命令行支持灵活性的理想情况。

我们当前构建系统的另一个问题是,安装文件需要做很多操作。大多数 Unix 系统提供了一个系统级的程序——有时只是一个简单的 shell 脚本——叫做 install,它允许用户指定安装文件的各种属性。正确使用这个工具可以简化 Jupiter 的安装过程,因此在添加位置灵活性的同时,我们也可以使用 install 工具。这些修改在列表 3-21 和 3-22 中有所展示。

Git 标签 3.7

   package = jupiter
   version = 1.0
   tarname = $(package)
   distdir = $(tarname)-$(version)

   prefix=/usr/local
➊ export prefix

   all clean check install jupiter:
           cd src && $(MAKE) $@
--snip--

列表 3-21: Makefile: 添加 prefix 变量

--snip--
install:
     ➋ install -d $(prefix)/bin
        install -m 0755 jupiter $(prefix)/bin
--snip--

清单 3-22: src/Makefile:install 目标中使用 prefix 变量

请注意,我只在顶级 makefile 中声明并赋值了 prefix 变量,但我在 src/Makefile 中引用了它。我之所以能这么做,是因为我在顶级 makefile 的 ➊ 位置使用了 export 修饰符——这个修饰符将变量导出到 make 执行时在 src 目录中启动的 shell 中。make 的这一功能使我们能够在一个明显的位置——顶级 makefile 的开头——定义所有用户变量。

注意

GNU make 允许你在赋值行使用 export 关键字,但这种语法在 GNU make 和其他版本的 make 之间并不兼容。从技术上讲,POSIX 根本不支持使用 export,但大多数 make 实现都支持它。

我现在已经声明 prefix 变量为 /usr/local,这对于那些想要将 jupiter 安装到 /usr/local/bin 的用户非常有用,但对于那些希望将其安装到 /usr/bin 的用户就不太友好了。幸运的是,make 允许你在命令行上定义 make 变量,方法如下:

$ sudo make prefix=/usr install
--snip--

请记住,命令行上定义的变量会覆盖在 makefile 中定义的变量。^(22) 因此,想要将 jupiter 安装到 /usr/bin 目录的用户,现在可以在 make 命令行中指定这一点。

有了这个系统,用户可以将 jupiter 安装到他们选择的任何目录下的 bin 目录中,包括他们的主目录中的位置(此时他们不需要额外的权限)。事实上,这就是我们在 清单 3-22 的 ➋ 位置添加 install -d $(prefix)/bin 命令的原因——该命令在安装目录不存在时会创建它。由于我们允许用户在 make 命令行上定义 prefix,我们实际上并不知道用户将 jupiter 安装在哪里;因此,我们必须为目录尚不存在的可能性做好准备。试试看吧:^(23)

$ make all
$ make prefix=$PWD/inst install
$
$ ls -1p
inst/
Makefile
src/
$
$ ls -1p inst
bin/
$
$ ls -1p inst/bin
jupiter
$

卸载软件包

如果用户在安装了我们的软件包后不喜欢它,只想从系统中删除它怎么办?对于 Jupiter 项目来说,这种情况相当可能,因为它几乎没有用,而且会占用 bin 目录中的宝贵空间。然而,对于你们的项目而言,更可能的是,用户希望进行一个全新安装,安装一个更新版本,或者用他们 Linux 发行版中带有的正式打包版本替换从项目网站上下载的测试版。在这种情况下,支持 uninstall 目标会非常有用。

清单 3-23 和 3-24 展示了我们向两个 makefile 中添加 uninstall 目标的过程。

Git 标签 3.8

--snip--
all clean check install uninstall jupiter:
        cd src && $(MAKE) $@
--snip--

.PHONY: FORCE all clean check dist distcheck install uninstall

清单 3-23: Makefile:向顶级 makefile 中添加 uninstall 目标

--snip--
install:
        install -d $(prefix)/bin
        install -m 0755 jupiter $(prefix)/bin

uninstall:
        rm -f $(prefix)/bin/jupiter
        -rmdir $(prefix)/bin >/dev/null 2>&1
--snip--

.PHONY: all clean check install uninstall

列表 3-24: src/Makefile: uninstall 目标添加到 src 级别的 makefile

install 目标一样,如果用户使用系统前缀(如 /usr/usr/local),则该目标需要 root 权限。你应该非常小心如何编写 uninstall 目标;除非目录专门属于你的软件包,否则不应该假设是你创建了它。如果你这么做,可能会删除像 /usr/bin 这样的系统目录!

另一方面,如果在 install 目标中目录最初缺失,我们确实创建了该目录,因此如果可能的话,我们应该将其删除。在这里,我们可以使用 rmdir 命令,它的作用是删除空目录。即使该目录是像 /usr/bin 这样的系统目录,如果它为空,删除它也是无害的,但如果它不为空,rmdir 将失败。回想一下,命令失败会停止 make 进程,因此我们也会在前面加上一个短横线字符。而且我们不想看到这样的失败,所以我们会将它的输出重定向到 /dev/null

我们的构建系统中需要维护的事项列表已经有些失控了。当我们更改安装过程时,现在有两个地方需要更新:installuninstall 目标。不幸的是,当编写自己的 makefile 时,这大概是我们能期望的最好的结果,除非我们使用相当复杂的 shell 脚本命令。但坚持下去——在第六章中,我将展示如何使用 GNU Automake 以更简单的方式重写这个 makefile。

测试安装和卸载

现在让我们在 distcheck 目标中添加一些代码,测试 installuninstall 目标的功能。毕竟,确保这两个目标在我们的发行版 tarball 中正确运行是非常重要的,所以我们应该在声明 tarball 版本可发布之前,在 distcheck 中对它们进行测试。列表 3-25 展示了顶层 makefile 中所需的更改。

Git 标签 3.9

--snip--
distcheck: $(distdir).tar.gz
        gzip -cd $(distdir).tar.gz | tar xvf -
        cd $(distdir) && $(MAKE) all
        cd $(distdir) && $(MAKE) check
        cd $(distdir) && $(MAKE) prefix=$${PWD}/_inst install
        cd $(distdir) && $(MAKE) prefix=$${PWD}/_inst uninstall
        cd $(distdir) && $(MAKE) clean
        rm -rf $(distdir)
        @echo "*** Package $(distdir).tar.gz is ready for distribution."
--snip--

列表 3-25: Makefile: installuninstall 目标添加 distcheck 测试

请注意,我在 $${PWD} 变量引用中使用了双美元符号,确保 make 在执行命令之前将变量引用传递给 shell,而不是在行内展开它。我希望这个变量由 shell 解引用,而不是由 make 工具解引用。^(24)

我们在这里做的是测试,确保 installuninstall 目标不会生成错误——但这不太可能,因为它们做的就是将文件安装到构建目录中的临时目录。我们可以在 make install 命令之后立即添加一些代码,检查应该被安装的产品,但这超出了我的承受范围。到了一定的程度,做检查的代码和安装代码一样复杂——在这种情况下,检查就变得毫无意义。

但是我们可以做其他事情:我们可以编写一个或多或少通用的测试,检查我们安装的所有东西是否都被正确删除。由于安装前阶段目录是空的,卸载后它最好保持相似的状态。清单 3-26 展示了这个测试的增加。

Git 标签 3.10

--snip--
distcheck: $(distdir).tar.gz
        gzip -cd $(distdir).tar.gz | tar xvf -
        cd $(distdir) && $(MAKE) all
        cd $(distdir) && $(MAKE) check
        cd $(distdir) && $(MAKE) prefix=$${PWD}/_inst install
        cd $(distdir) && $(MAKE) prefix=$${PWD}/_inst uninstall
     ➊ @remaining="`find $(distdir)/_inst -type f | wc -l`"; \
        if test "$${remaining}" -ne 0; then \
        ➋ echo "*** $${remaining} file(s) remaining in stage directory!"; \
           exit 1; \
        fi
        cd $(distdir) && $(MAKE) clean
        rm -rf $(distdir)
        @echo "*** Package $(distdir).tar.gz is ready for distribution."
--snip--

清单 3-26: Makefile: 添加一个测试以检查uninstall完成后剩余的文件

这个测试首先在➊生成一个数字值,存储在名为remaining的 shell 变量中,表示在我们使用的阶段目录中找到的常规文件数量。如果这个数字不为零,测试会在➋向控制台打印一条信息,指示有多少文件在uninstall命令执行后被遗留下来,然后它会带着错误退出。提前退出让阶段目录保持不变,这样我们可以检查它,找出我们忘记卸载的文件。

注意

这个测试代码很好地利用了多个 shell 命令传递给一个 shell。我不得不这样做,以便remaining的值可以被if语句使用。当闭合的if没有由与开头的if相同的 shell 执行时,条件语句的工作效果不好!

我不想通过打印嵌入的echo语句来吓到人们,除非它真的应该被执行,所以我在整个测试前加上了一个 at 符号(@),这样make就不会将代码打印到stdout。由于make将这五行代码视为一个命令,抑制打印echo语句的唯一方法是抑制打印整个命令。

现在,这个测试并不完美——远远不完美。这个代码只检查常规文件。如果你的安装过程创建了软链接,这个测试将不会注意到它们是否被遗留下来。安装过程中构建的目录结构故意保持不变,因为检查代码并不知道阶段目录中的子目录是属于系统的还是属于项目的。uninstall规则中的命令可以知道哪些目录是项目特定的并正确地删除它们,但我不想在distcheck测试中加入项目特定的知识——这又是那个收益递减的问题。

文件系统层次标准

你可能现在在想,我是从哪里得到这些目录名的。如果某些 Unix 系统没有使用/usr/usr/local怎么办呢?首先,这也是提供prefix变量的原因之一——让用户在这些问题上有一些选择。然而,现在大多数类 Unix 系统尽可能遵循文件系统层次标准(FHS)FHS 定义了多个标准位置,包括以下根级目录:

/bin /etc /home
/opt /sbin /srv
/tmp /usr /var

这个列表绝不是详尽无遗的。我只提到了与我们研究开源项目构建系统最相关的目录。此外,FHS 定义了这些根级目录下的几个标准位置。例如,/usr 目录应包含以下子目录:

/usr/bin /usr/include /usr/lib
/usr/local /usr/sbin /usr/share
/usr/src

/usr/local 目录应包含与 /usr 目录非常相似的结构。/usr/local 目录为软件安装提供了一个位置,可以覆盖安装在 /usr 目录结构中的相同软件包版本,因为系统软件更新通常会覆盖 /usr 中的软件而不加区别。/usr/local 目录结构允许系统管理员决定在系统上使用哪个版本的包,因为 /usr/local/bin 可能(且通常会)被添加到 PATH 中,优先于 /usr/bin。设计 FHS 时经过了相当多的思考,GNU Autotools 充分利用了这一共识。

FHS 不仅定义了这些标准位置,还详细解释了它们的用途以及应该存放哪些类型的文件。总的来说,FHS 给了你作为项目维护者足够的灵活性和选择,使你的工作既有趣又不至于让你怀疑是否把文件安装到了正确的位置。^(25)

支持标准目标和变量

除了我已经提到的内容外,GNU 编程标准 列出了你应该在项目中支持的一些重要目标和变量——主要是因为用户会期待你对它们的支持。

GCS 文档中的一些章节应谨慎对待(除非你正在进行一个 GNU 资助的项目)。例如,你可能不会太关心 GCS 中 第五章 关于 C 源代码格式的建议。你的用户肯定不会在乎,因此你可以使用任何你喜欢的源代码格式样式。

这并不是说 第五章 对非 GNU 开源项目没有价值。例如,“系统类型间的可移植性”和“CPU 之间的可移植性”小节提供了有关 C 源代码可移植性的宝贵信息。此外,“国际化”小节也提供了一些使用 GNU 软件进行项目国际化的有用建议。我们将在本书的 第十一章 中更详细地讨论国际化。

虽然GCS的第六章讨论了 GNU 方式的文档,但第六章中的一些部分描述了项目中常见的各种顶级文本文件,如AUTHORSNEWSINSTALLREADMEChangeLog文件。这些信息都是受过良好开源软件培训的用户期待在任何声誉良好的项目中看到的内容。

真正有用的信息出现在GCS文档的第七章,“发布过程”。这一章对你作为项目维护者至关重要,因为它定义了用户对你项目构建系统的期望。第七章包含了源代码分发包中用户选项的事实标准。

标准目标

GCS文档第七章中的“配置应如何工作”小节定义了配置过程,我在“配置你的包”一节中简要介绍了这一过程,见第 77 页。GCS的“Makefile 约定”小节涵盖了所有标准目标和许多用户在开源软件包中期望看到的标准变量。GCS定义的标准目标包括以下内容:

all install install-html
install-dvi install-pdf install-ps
install-strip uninstall clean
distclean mostlyclean maintainer-clean
TAGS info dvi
html pdf ps
dist check installcheck
installdirs

你不需要支持所有这些目标,但应该考虑支持那些对你的项目有意义的目标。例如,如果你构建并安装 HTML 页面,你可能需要考虑支持htmlinstall-html目标。Autotools 项目支持这些及更多目标。有些目标对最终用户有用,而有些则仅对项目维护者有用。

标准变量

你应该根据需要支持的变量包括下表中列出的变量。为了为最终用户提供灵活性,大多数这些变量是通过其中一些变量定义的,最终归结为一个变量:prefix。由于没有更标准的名称,我将这些称为前缀变量。这些变量中的大多数可以归类为安装目录变量,它们指向标准位置,但也有一些例外,比如srcdir

这些变量的目的是由make完全解析,因此它们是通过make变量来定义的,使用圆括号而非花括号。表 3-1 列出了这些前缀变量及其默认值。

表 3-1: 前缀变量及其默认值

变量 默认值
prefix /usr/local
exec_prefix $(prefix)
bindir $(exec_prefix)/bin
sbindir $(exec_prefix)/sbin
libexecdir $(exec_prefix)/libexec
datarootdir $(prefix)/share
datadir $(datarootdir)
sysconfdir $(prefix)/etc
sharedstatedir $(prefix)/com
localstatedir $(prefix)/var
includedir $(prefix)/include
oldincludedir /usr/include
docdir $(datarootdir)/doc/$(package)
infodir $(datarootdir)/info
htmldir $(docdir)
dvidir $(docdir)
pdfdir $(docdir)
psdir $(docdir)
libdir $(exec_prefix)/lib
lispdir $(datarootdir)/emacs/site-lisp
localedir $(datarootdir)/locale
mandir $(datarootdir)/man
manNdir $(mandir)/manN (N = 1..9)
manext .1
manNext .N (N = 1..9)
srcdir 构建树中与当前目录对应的源代码树目录

基于 Autotools 的项目会自动支持这些以及其他有用的变量,根据需要;Automake 对此提供完全支持,而 Autoconf 的支持则较为有限。如果你编写自己的 makefile 和构建系统,你应该支持在构建和安装过程中使用的尽可能多的这些变量。

将位置变量添加到 Jupiter

为了支持我们在 Jupiter 项目中使用的变量,我们需要添加bindir变量,以及它所依赖的任何变量——在这种情况下,是exec_prefix变量。列表 3-27 和 3-28 展示了如何在顶级和src目录的 makefile 中进行此操作。

Git 标签 3.11

--snip--
prefix = /usr/local
exec_prefix = $(prefix)
bindir = $(exec_prefix)/bin
export prefix
export exec_prefix
export bindir
--snip--

列表 3-27: Makefile: 添加 bindir 变量

--snip--
install:
        install -d $(bindir)
        install -m 0755 jupiter $(bindir)

uninstall:
        rm -f $(bindir)/jupiter
        -rmdir $(bindir) >/dev/null 2>&1
--snip--

列表 3-28: src/Makefile: 添加 bindir 变量

即使我们只在src/Makefile中使用bindir,我们也必须导出prefixexec_prefixbindir,因为bindir是通过exec_prefix定义的,而exec_prefix又是通过prefix定义的。当make运行install命令时,它将首先将bindir解析为$(exec_prefix)/bin,然后为$(prefix)/bin,最后为/usr/local/bin。因此,src/Makefile在此过程中需要访问这三个变量。

这些递归变量定义如何使最终用户的生活更轻松?毕竟,用户只需输入以下命令,就可以将根安装位置从/usr/local更改为/usr

$ make prefix=/usr install
--snip--

在多个层级更改前缀变量的能力对于 Linux 发行版打包人员(在 Linux 公司工作的员工或志愿者,负责将你的项目专业地打包成.deb.rpm包)尤其有用,因为他们需要将包安装到非常特定的系统位置。例如,发行版打包人员可以使用以下命令将安装前缀更改为/usr,系统配置目录更改为/etc

$ make prefix=/usr sysconfdir=/etc install
--snip--

如果没有在多个层级更改前缀变量的能力,配置文件将最终位于/usr/etc,因为$(sysconfdir)的默认值是$(prefix)/etc

将你的项目加入 Linux 发行版

当一个 Linux 发行版接手你的软件包进行分发时,你的项目几乎一夜之间就从数十个用户的范围,扩展到数万个用户的范围—几乎是立竿见影。有些人甚至可能在不知情的情况下使用你的软件。由于开源软件对于开发者的一大价值是免费帮助改进软件,所以这可以看作是一件好事——社区规模的急剧增加。

通过在你的构建系统中遵循 GCS,你去除了许多将你的项目包含到 Linux 发行版中的障碍。如果你的 tar 包遵循所有常规约定,发行版的打包人员会立刻知道该如何处理它。这些打包人员通常会根据所需的功能以及他们对你软件包的看法,决定是否将其包含到他们的 Linux 版本中。由于他们在这个过程中拥有相当大的权力,取悦他们对你来说是有利的。

GCS 的第七部分包含一个小小的子节,讨论了支持 分阶段安装 的内容。在你的构建系统中支持这一概念很容易,但如果你忽视了它,几乎总是会给打包人员带来问题。

包装系统,如 Red Hat 包管理器(RPM),接受一个或多个 tar 包、一组补丁文件和一个规范文件。所谓的 spec 文件 描述了在特定系统上构建和打包项目的过程。此外,它定义了所有安装到目标安装目录结构中的产品。包管理软件使用这些信息将你的软件包安装到临时目录中,然后从中提取指定的产品,将它们存储在包安装程序(例如 rpm)能够理解的特殊二进制归档中。

要支持分阶段安装,你只需要一个名为 DESTDIR 的变量,它作为一个超级前缀作用于所有已安装的产品。为了向你展示如何实现这一点,我将给 Jupiter 项目添加分阶段安装支持。这是非常简单的,只需对 src/Makefile 做四处更改即可。所需的更改在 Listing 3-29 中已标出。

Git 标签 3.12

--snip--
install:
        install -d $(DESTDIR)$(bindir)
        install -m 0755 jupiter $(DESTDIR)$(bindir)

uninstall:
        rm -f $(DESTDIR)$(bindir)/jupiter
        -rmdir $(DESTDIR)$(bindir) >/dev/null 2>&1
--snip--

Listing 3-29: src/Makefile: 添加分阶段构建功能

如你所见,我已经将 $(DESTDIR) 前缀添加到了 installuninstall 目标中引用安装路径的 $(bindir) 变量上。你无需为 DESTDIR 定义默认值,因为当它未定义时,它会展开为一个空字符串,这对其前置的路径没有影响。

注意

不要在$(DESTDIR)* 后面加斜杠,通常它是空的。前缀变量最终解析为以斜杠开始的内容;因此,在$(DESTDIR) 后加斜杠是多余的,并且在某些情况下可能会引起意外的副作用。*

我不需要为包管理器的原因在 uninstall 规则的 rm 命令中添加 $(DESTDIR),因为包管理器不关心你的包是如何卸载的。它们只会安装你的包,以便从阶段目录中复制产品。要卸载阶段目录,包管理器只需要删除它。像 rpm 这样的包管理程序使用它们自己的规则来从系统中删除产品,这些规则基于包管理器数据库,而不是你的 uninstall 目标。

然而,为了对称性,并且为了完整性,将 $(DESTDIR) 添加到 uninstall 中并不会有坏处。此外,我们需要它来完整地支持 distcheck 目标,我们将修改它,以便利用我们的分阶段安装功能。这个修改展示在清单 3-30 中。

Git 标签 3.13

--snip--
distcheck: $(distdir).tar.gz
        gzip -cd $(distdir).tar.gz | tar xvf -
        cd $(distdir) && $(MAKE) all
        cd $(distdir) && $(MAKE) check
        cd $(distdir) && $(MAKE) DESTDIR=$${PWD}/inst install
        cd $(distdir) && $(MAKE) DESTDIR=$${PWD}/inst uninstall
        @remaining="`find $(distdir)/inst -type f | wc -l`"; \
        if test "$${remaining}" -ne 0; then \
          echo "*** $${remaining} file(s) remaining in stage directory!"; \
          exit 1; \
        fi
        cd $(distdir) && $(MAKE) clean
        rm -rf $(distdir)
        @echo "*** Package $(distdir).tar.gz is ready for distribution."
--snip--

清单 3-30: Makefile: distcheck 目标中使用 DESTDIR

installuninstall 命令中将 prefix 更改为 DESTDIR 使我们能够正确测试完整的安装目录层级,正如我们稍后将看到的那样。

此时,一个 RPM 规范文件可以提供以下文本作为 Jupiter 包的安装命令:

%install
make prefix=/usr DESTDIR=%BUILDROOT install

不要担心包管理器的文件格式。相反,只需专注于通过 DESTDIR 变量提供分阶段安装功能。

你可能会想,为什么 prefix 变量无法提供此功能。一方面,并不是系统级安装中的每个路径都是相对于 prefix 变量定义的。例如,系统配置目录(sysconfdir)通常由打包者定义为 /etc。你可以在表 3-1 中看到,sysconfdir 的默认定义是 $(prefix)/etc,因此,只有显式在 configuremake 命令行中设置它,sysconfdir 才会解析为 /etc。如果你这样配置,只有像 DESTDIR 这样的变量才会在分阶段安装过程中影响 sysconfdir 的基本位置。随着我们在本章稍后的项目配置讨论,和接下来的两章讲解,其他原因会变得更加清晰。

构建与安装前缀重写

此时,我想稍微离题一下,解释一下关于 prefix 和其他路径变量在 GCS 中定义的一个难以捉摸(或者至少不显而易见)的概念。在前面的例子中,我在 make install 命令行上使用了 prefix 重写,像这样:

$ make prefix=/usr install
--snip--

我想要解决的问题是:使用前缀覆盖 make allmake install 有什么区别?在我们的简化样本 makefile 中,我们设法避免在与安装无关的任何目标中使用前缀,所以你现在可能还不清楚前缀在构建阶段是否有用。然而,正如清单 3-31 所示,前缀变量在构建阶段通过在编译时替换路径到源代码中非常有用。

program: main.c
       gcc -DCFGDIR="\"$(sysconfdir)\"" -o $@ main.c

清单 3-31:在编译时将路径替换到源代码中

在这个例子中,我在编译器命令行中定义了一个名为 CFGDIR 的 C 预处理器变量,供 main.c 使用。假设在 main.c 中有类似于清单 3-32 所示的代码。

#ifndef CFGDIR
# define CFGDIR "/etc"
#endif
const char *cfgdir = CFGDIR;

清单 3-32:在编译时替换 CFGDIR

在代码中稍后,你可能会使用 C 全局变量 cfgdir 来访问应用程序的配置文件。

Linux 发行版打包者通常会在 RPM 规范文件中的构建和安装命令行使用不同的前缀覆盖。在构建阶段,实际的运行时目录通过类似于清单 3-33 中显示的 ./configure 命令被硬编码到可执行文件中。

%build
%setup
./configure prefix=/usr sysconfdir=/etc
make

清单 3-33:构建源代码树的 RPM 规范文件部分

请注意,我们必须显式地指定 sysconfdirprefix,因为正如我之前提到的,系统配置目录通常位于 prefix 目录结构之外。包管理器将这些可执行文件安装到一个临时目录中,然后在构建二进制安装包时再从其安装位置复制它们。相应的安装命令可能类似于清单 3-34 中显示的命令。

%install
make DESTDIR=%BUILDROOT% install

清单 3-34:RPM 规范文件的安装部分

在安装过程中使用 DESTDIR 会临时覆盖 所有 安装前缀变量,因此你无需记住在配置时已覆盖哪些变量。根据清单 3-33 中显示的配置命令,在清单 3-34 中显示的方式使用 DESTDIR,与清单 3-35 中显示的代码效果相同。

%install
make prefix=%BUILDROOT%/usr sysconfdir=%BUILDROOT%/etc install

清单 3-35:在安装过程中覆盖默认的 sysconfdir

警告

这里的关键点是我之前提到过的。永远不要在 makefile 中为 make all 或即使是部分产品构建编写安装目标。安装功能应该仅限于复制文件,尽可能如此。否则,如果用户使用前缀覆盖,他们将无法访问你的阶段安装功能。

限制安装功能的另一个原因是,它允许用户将一组软件包作为一个整体安装到隔离位置,然后创建指向实际文件的链接到适当的位置。有些人在测试一个软件包时喜欢这样做,并希望追踪它的所有组件。^(26)

最后一点:如果你正在安装到系统目录结构中,你需要root权限。人们通常会像这样运行 make install

$ sudo make install

如果你的 install 目标依赖于构建目标,并且你之前忽略了构建它们,make 会在安装之前很高兴地构建你的程序——但是本地副本将全都由root拥有。通过让 make install 因缺少需要安装的内容而失败,而不是在以root身份运行时直接进入构建过程,你可以轻松避免这种不便。

用户变量

GCS 定义了一组对用户至关重要的变量。这些变量应该被 GNU 构建系统引用,但永远不应该被 GNU 构建系统修改。这些所谓的用户变量包括在 表 3-2 中列出的 C 和 C++ 程序相关的变量。

表 3-2: 一些用户变量及其用途

变量 目的
CC 系统 C 编译器的引用
CFLAGS 所需的 C 编译器标志
CXX 系统 C++ 编译器的引用
CXXFLAGS 所需的 C++ 编译器标志
LDFLAGS 所需的链接器标志
CPPFLAGS 所需的 C/C++ 预处理器标志
--snip--

这个列表绝不是详尽无遗的,值得注意的是,在 GCS 中并没有找到一个完整的列表。实际上,这些变量大多来自 make 工具本身的文档。这些变量用于 make 工具的内建规则中——它们在 make 中有些是硬编码的,因此它们实际上由 make 定义。你可以在 GNU Make Manual 的“隐式规则使用的变量”部分找到一个相对完整的程序名称和标志变量列表。

请注意,make 会根据常见的 Unix 工具名称为许多变量分配默认值。例如,CC 的默认值是 cc,在 Linux 系统上,cc 是指向 GCC C 编译器(gcc)的软链接。在其他系统上,cc 是指向系统自有编译器的软链接。因此,我们不需要将 CC 设置为 gcc,这很好,因为在非 Linux 平台上可能没有安装 GCC。你可能会在某些时候希望在 make 命令行中设置 CC,例如使用 clang 之类的替代编译器,或者在使用 ccache 工具缓存 gcc 结果以加速重新编译时。

对于我们的目的来说,表 3-2 中显示的变量已经足够,但对于一个更复杂的 makefile,你应该熟悉 GNU Make Manual 中列出的更大列表。

要在我们的 makefile 中使用这些变量,我们只需要将gcc替换为$(CC)。我们对CFLAGSCPPFLAGS也做同样的处理,尽管CPPFLAGS默认是空的。CFLAGS变量也没有默认值,但这是一个添加默认值的好时机。我喜欢使用-g来构建带有符号的目标文件,使用-O0来禁用调试构建中的优化。对src/Makefile的更新见列表 3-36。

Git 标签 3.14

4CFLAGS = -g -O0
--snip--
jupiter: main.c
        $(CC) $(CPPFLAGS) $(CFLAGS) -o $@ main.c
--snip--

列表 3-36: src/Makefile: 添加适当的用户变量

之所以有效,是因为make工具允许命令行选项覆盖这些变量。例如,要切换编译器并设置一些编译器命令行选项,用户只需要输入以下命令:

$ make CC=ccache CFLAGS='-g -O2' CPPFLAGS=-Dtest

在这种情况下,我们的用户决定使用ccache工具代替gcc,生成调试符号,并通过二级优化来优化他们的代码。他们还决定通过使用 C 预处理器定义来启用test选项。请注意,这些变量是在make命令行中设置的;这种看似等效的 Bourne shell 语法将无法按预期工作:

$ CC=ccache CFLAGS='-g -O2' CPPFLAGS=-Dtest make

原因在于我们仅仅是在 shell 传递给make工具的本地环境中设置了环境变量。记住,环境变量不会自动覆盖在 makefile 中设置的变量。为了实现我们想要的功能,我们可以在 makefile 中使用一些 GNU make特有的语法,如列表 3-37 所示。

--snip--
CFLAGS ?= -g -O0
--snip--

列表 3-37:在 makefile 中使用 GNU make特有的查询赋值操作符(?=

?= 操作符是一个 GNU make特有的操作符,只有在变量未在其他地方设置时,才会在 makefile 中设置该变量。这意味着我们现在可以通过在环境中设置这些特定的变量来覆盖它们。但请记住,这只在 GNU make中有效。一般来说,最好在make命令行中设置make变量。

非递归构建系统

既然我们花了这么多时间为我们的项目创建了完美的构建系统,接下来让我们看看一个更完美的解决方案——一个非递归系统。我在本章开始时提到过递归构建有一个问题,我们将在后面讨论。

递归构建系统的根本问题在于它们人为地在make的有向图中引入了缺陷——make用来确定什么依赖什么以及何时需要重新构建的规则集。对于 Jupiter 来说,由于只有一个顶层 makefile 调用make并在单个子目录的 makefile 上执行,所以几乎不可能出错,但让我们考虑一个更复杂的项目,其中多个子模块以任意深度嵌套,彼此之间以更复杂的方式相互依赖。

使用一个单一的 makefile,单个的 make 进程可以“看到全局”。也就是说,它能够看到并理解系统中所有的相互依赖关系,并且它可以创建一个正确表示项目中所有文件系统对象之间相互依赖关系的有向无环图(DAG)。使用多个 makefile 时,父级 make 执行的每个子 make 进程只能看到依赖图的一部分。最终,这可能会导致 make 按顺序构建产品,导致一个依赖于不在自己视野中的前置条件的产品,在这些前置条件更新之前就被构建出来。

当你使用 parallel make 并在 make 命令行中添加 -j 选项时,前述问题会更加复杂。-j 选项告诉 make 检查它的 DAG,找到那些不互相依赖的部分,然后同时执行这些部分。在多处理器系统上,这可以显著加速大项目的构建过程。然而,这从两个不同的角度带来了问题。首先,由于 make 无法看到全局图景,它可能会对哪些任务可以并行执行做出错误的假设。其次,就顶级 make 来看,子 make 进程是完全独立的,可以并行运行,而这显然并非如此。一个不依赖于递归和非递归构建系统差异的例子是以下命令行:

$ make -j clean all

make 而言,cleanall 是 100% 独立的,因此 make 会很高兴地同时执行它们。即使是新手也能看出这个假设的问题。关键是,make 不理解 cleanall 之间的高级关系。这种关系只有 makefile 的作者才理解。类似的障碍在递归构建系统中,父级和子级 make 调用的边界之间人为地引入,导致 make 无法理解全局图景。

那么,把 Jupiter 的递归构建系统转换为非递归系统有多难呢?我们希望保持模块化,因此我们仍然希望每个目录中有一个 Makefile 来管理该目录的任务。这可以通过使用常见的 make 的另一个特性——include 指令来轻松实现。include 指令允许我们将单个的父级 makefile 拆分成特定目录规则的片段,然后将这些片段包含到顶级 makefile 中。Listing 3-38 展示了更新后的完整顶级 makefile 的样子。

Git 标签 3.15

   package = jupiter
   version = 1.0
   tarname = $(package)
   distdir = $(tarname)-$(version)

   prefix = /usr/local
   exec_prefix = $(prefix)
   bindir = $(exec_prefix)/bin

➊ #export prefix
   #export exec_prefix
   #export bindir

➋ all jupiter: src/jupiter

   dist: $(distdir).tar.gz

   $(distdir).tar.gz: $(distdir)
           tar chof - $(distdir) | gzip -9 -c > $@
           rm -rf $(distdir)
 $(distdir): FORCE
           mkdir -p $(distdir)/src
           cp Makefile $(distdir)
           cp src/Makefile $(distdir)/src
           cp src/main.c $(distdir)/src

   distcheck: $(distdir).tar.gz
           gzip -cd $(distdir).tar.gz | tar xvf -
           cd $(distdir) && $(MAKE) all
           cd $(distdir) && $(MAKE) check
           cd $(distdir) && $(MAKE) DESTDIR=$${PWD}/_inst install
           cd $(distdir) && $(MAKE) DESTDIR=$${PWD}/_inst uninstall
           @remaining="`find $${PWD}/$(distdir)/_inst -type f | wc -l`"; \
           if test "$${remaining}" -ne 0; then \
             echo "*** $${remaining} file(s) remaining in stage directory!"; \
             exit 1; \
           fi
           cd $(distdir) && $(MAKE) clean
           rm -rf $(distdir)
           @echo "*** Package $(distdir).tar.gz is ready for distribution."

   FORCE:
           -rm -f $(distdir).tar.gz >/dev/null 2>&1
           -rm -rf $(distdir) >/dev/null 2>&1
➌ include src/Makefile

  .PHONY: FORCE all clean check dist distcheck install uninstall

Listing 3-38: Makefile: 顶级 makefile 的非递归版本

这里进行了三项更改,但请注意,实际上对这个 Makefile 做的唯一重大更改是在 ➋ 位置替换了递归规则,使用了一个单独的规则来处理 allcleancheckinstalluninstall 和一个明确的 jupiter 目标。即便如此,如果我们不关心新的默认目标会变成 dist,如果我们没有在这个位置添加 all 目标,替换也可以仅仅是一个删除操作。我还添加了一个明确的 jupiter 目标,它映射到 src/jupiter,以保持与旧系统的功能一致。

第二个更改是在 ➌ 位置包含了src级别的 Makefile。最后,我还注释掉了 ➊ 位置的 export 语句,因为我们不再需要将变量导出到子 make 进程中;它们仅作为注释保留,用于示范。

现在,让我们来看一下src级别 Makefile 中的变化。完整的更新版本显示在 Listing 3-39 中。

CFLAGS = -g -O0

src/jupiter: src/main.c
        $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ src/main.c

check: all
 ./src/jupiter | grep "Hello from .*jupiter!"
        @echo "*** All TESTS PASSED"

install:
        install -d $(DESTDIR)$(bindir)
        install -m 0755 src/jupiter $(DESTDIR)$(bindir)

uninstall:
        rm -f $(DESTDIR)$(bindir)/jupiter
        -rmdir -f $(DESTDIR)$(bindir) >/dev/null 2>&1

clean:
        rm -f src/jupiter

Listing 3-39: src/Makefile: 一个非递归版本的 src 级别 Makefile

首先,all 目标被移除。现在我们不再需要它,因为这个 Makefile 不是直接执行的,而是由父级 Makefile 引用。因此,我们不需要默认目标。第二,所有指向src目录中对象的引用现在都使用相对于父级目录的路径来表示。原因是 make 只在父级目录中执行一次,因此,指向src目录中对象的引用必须相对于 make 执行的地方——即父级目录。

我们还移除了底部的 .PHONY 指令,因为这个指令包含了父级 Makefile 中 .PHONY 指令的一个适当子集,因此显得冗余。简而言之,我们只是将这个 Makefile 转换为一个可以被包含在父级 Makefile 中的片段,移除了冗余,并确保所有文件系统引用现在都相对于父级目录。希望你能看到,这些更改实际上简化了我们之前的做法。直观上,它似乎更复杂,但实际上更简单。

这个 Makefile 是我们递归系统的一个更准确且更快速的版本。我说“这个 Makefile”是因为这里实际上只有一个 Makefile——包含的文件可以直接粘贴到父级 Makefile 中的引用位置(在 Listing 3-38 中的 ➋ 位置),就像在 C 语言源文件中包含头文件一样。最终,在所有包含解析完毕后,只有一个 Makefile 和一个 make 进程执行基于该 Makefile 中规则的命令。

非递归构建系统的一个显而易见的缺点是,你不能仅仅在src目录下输入make来构建与该目录相关的项目部分。相反,你必须切换到父目录并运行make,这样会构建整个项目。但这也是一个错误的顾虑,因为你一直都有能力通过在make命令行上准确指定目标来执行构建系统的任何部分。不同之处在于,现在构建的实际上是应该构建的内容,因为make理解你命令它构建的任何目标的所有依赖关系。

正如我们将在接下来的章节中看到的,Automake 完全支持非递归构建系统。我鼓励你开始以非递归方式编写下一个项目的构建系统,因为尽管如我们所见,实际上并不那么困难,但改造现有系统往往看起来是一个让人不知所措的任务。

配置你的软件包

GCS在第七部分“配置应如何工作”小节中描述了配置过程。到目前为止,我们仅使用 makefile 就能够完成几乎所有的事情,因此你可能会好奇配置到底是做什么的。GCS中这一小节的开头段落回答了我们的疑问:

每个 GNU 分发包应附带一个名为configure的 shell 脚本。这个脚本会接受描述你想为其编译程序的机器和系统类型的参数。configure脚本必须记录配置选项,以便它们影响编译过程。

这里的描述是 GNU 软件包中configure脚本接口的规范。许多软件包使用 GNU Autoconf(参见《Autoconf 简介》)和/或 GNU Automake(参见《Automake 简介》)来实现它,但你不必使用这些工具。你可以以任何你喜欢的方式实现它;例如,可以让configure成为一个完全不同的配置系统的封装器。

configure脚本操作的另一种方式是将标准名称(如config.h)链接到所选系统的正确配置文件。如果你使用这种技术,分发包应包含名为config.h的文件。这样做是为了防止人们在没有先进行配置的情况下构建程序。

configure还能做的另一件事是编辑Makefile。如果这样做,分发包中应包含名为Makefile的文件。相反,它应包含一个名为Makefile.in的文件,该文件包含用于编辑的输入。再一次,这是为了让人们在没有先配置的情况下无法构建程序。^(27)

那么,典型配置脚本的主要任务如下:

  • 从包含替换变量的模板生成文件。

  • 为项目源代码生成一个 C 语言头文件(config.h)以供包含。

  • 为特定的make环境设置用户选项(调试标志等)。

  • 将各种包选项设置为环境变量。

  • 测试工具、库和头文件是否存在。

对于复杂的项目,配置脚本通常会从项目开发者维护的一个或多个模板生成项目的 makefile。这些模板包含易于识别(并替换)的配置变量。配置脚本将这些变量替换为在配置过程中确定的值——这些值可能来自用户指定的命令行选项,也可能来自对平台环境的详细分析。此分析包括检查某些系统或包的头文件和库是否存在,搜索各个文件系统路径以寻找所需的工具和工具,甚至运行小程序来指示 shell、C 编译器或所需库的功能集。

过去,变量替换的工具首选是sed流编辑器。一个简单的sed命令就可以在一次文件遍历中替换 makefile 模板中的所有配置变量。然而,Autoconf 2.62 及以后的版本更倾向于使用awk而不是sed来进行此操作。如今,awk工具几乎与sed一样普遍,它提供了更多的功能,允许高效地替换多个变量。对于我们 Jupiter 项目的需求,这两种工具都足够使用。

摘要

我们现在已经手动创建了一个完整的项目构建系统,唯一的重要例外是:我们还没有根据GNU 编码标准GNU Coding Standards)设计一个configure脚本。我们可以做这件事,但即便是勉强符合这些规范的脚本,也需要再写十几页的文本。然而,GCS中有一些与 makefile 特别相关的关键构建特性是非常理想的。其中之一就是 vpath 构建的概念。这是一个重要的特性,只有通过实际编写符合GCS规范的配置脚本才能恰当地展示。

我宁愿不花时间和精力现在完成这个,而是希望直接转到讨论第四章中的 Autoconf 内容,这将使我们能够用两三行代码构建其中一个配置脚本。在这方面有了基础之后,将 vpath 构建和其他常见的 Autotools 功能添加到 Jupiter 项目中将变得非常简单。

第四章:使用 AUTOCONF 配置你的项目

*来吧,我的朋友们,‘寻找一个新的世界’还不算太晚。

——阿尔弗雷德·丁尼生,《尤利西斯》*

Image

Autoconf 项目有着悠久的历史,始于 1992 年,当时 David McKenzie 在自愿为自由软件基金会工作时,正在寻找一种简化创建复杂配置脚本的方式,以支持当时每天添加到 GNU 项目的目标平台。与此同时,他还在马里兰大学帕克分校攻读计算机科学学士学位。

在 McKenzie 最初的 Autoconf 工作之后,他一直是该项目的重要贡献者,直到 1996 年,Ben Elliston 接手了项目的维护。从那时起,维护者和主要贡献者包括 Akim Demaille、Jim Meyering、Alexandre Oliva、Tom Tromey、Lars J. Aas(autom4te 这个名字的发明者之一)等人,Mo DeJong、Steven G. Johnson、Matthew D. Langston、Paval Roskin 和 Paul Eggert(贡献者名单更长,详情见 Autoconf 的 AUTHORS 文件)。

今天的维护者,Eric Blake,自 2012 年起开始为 Autoconf 做出重要贡献。从那时起,他一直担任该项目的维护者,并为 Red Hat 工作。由于 Automake 和 Libtool 本质上是 Autoconf 框架的附加组件,因此花一些时间专注于在没有 Automake 和 Libtool 的情况下使用 Autoconf 是有益的。这将提供一些洞察,帮助理解 Autoconf 的操作方式,因为许多由 Automake 隐藏的工具细节将得以显现。

在 Automake 出现之前,Autoconf 是单独使用的。事实上,许多遗留的开源项目从未从 Autoconf 过渡到完整的 GNU Autotools 套件。因此,在较旧的开源项目中,找到一个名为 configure.in 的文件(这是原始的 Autoconf 命名约定),以及手写的 Makefile.in 模板,并不罕见。

在本章中,我将向你展示如何为现有项目添加 Autoconf 构建系统。我将在本章的大部分时间里讲解 Autoconf 的基础功能,在 第五章中,我将详细介绍一些更复杂的 Autoconf 宏是如何工作的以及如何正确使用它们。在整个过程中,我们将继续使用 Jupiter 项目作为示例。

Autoconf 配置脚本

autoconf 程序的输入是带有宏调用的 Bourne shell 脚本。输入数据流还必须包括所有引用宏的定义——包括 Autoconf 提供的宏和你自己编写的宏。

在 Autoconf 中使用的宏语言叫做 M4。(这个名字的含义是 M,外加 4 个字母,或者是 这个词。^(1)) m4 工具是一个通用的宏语言处理器,最初由 Brian Kernighan 和 Dennis Ritchie 于 1977 年编写。

虽然你可能不熟悉它,但你可以在今天使用的每个 Unix 和 Linux 变体(以及其他系统)中找到某种形式的 M4。这个工具的普及性是 Autoconf 使用它的主要原因,因为 Autoconf 的原始设计目标就是能够在所有系统上运行,而不需要添加复杂的工具链和实用程序集。^(2)

Autoconf 依赖于相对较少的工具:Bourne shell、M4 和 Perl 解释器。它生成的配置脚本和 Makefile 依赖于一组不同的工具,包括 Bourne shell、grepls以及sedawk。^(3)

注意

不要将 Autotools 的要求与它们生成的脚本和 Makefile 的要求混淆。Autotools 是维护工具,而生成的脚本和 Makefile 是最终用户工具。我们可以合理地预期开发系统中安装的功能比最终用户系统中更多。

配置脚本确保最终用户的构建环境已正确配置,以构建你的项目。该脚本检查已安装的工具、实用程序、库和头文件,以及这些资源中的特定功能。Autoconf 与其他项目配置框架的不同之处在于,Autoconf 的测试还确保这些资源能够被你的项目正确使用。你看,不仅仅是你的用户在他们的系统上正确安装了libxyz.so及其公共头文件,更重要的是,他们的文件版本是否兼容。Autoconf 在这类测试上非常严格。它通过为每个功能编译和链接一个小的测试程序,确保最终用户的环境符合项目要求——如果你愿意,它就像是一个示范例子,做的事情与项目源代码在更大范围内所做的相同。

难道我不能仅仅通过在库路径中搜索文件名来确保 libxyz.2.1.0.so 已安装吗? 这个问题的答案是有争议的。在一些合法的情况下,库和工具会悄无声息地更新。有时,项目所依赖的特定功能是以安全错误修复或库功能增强的形式添加的,在这种情况下,供应商甚至不需要更新版本号。但通常很难判断你拥有的是版本 2.1.0.r1 还是版本 2.1.0.r2,除非你查看文件大小或调用库函数来确保它按预期工作。

此外,供应商经常会将新产品中的错误修复和功能回移到旧平台,而不更新版本号。因此,仅通过查看版本号,你无法判断库是否支持在该版本库发布后新增的功能。

然而,不依赖库版本号的最重要原因是,它们并不代表库的特定营销版本。如我们在第八章中将讨论的,库版本号表示的是特定平台上的二进制接口特性。这意味着,同一功能集的库版本号可能在不同平台之间有所不同。因此,除非进行编译和链接到该库,否则你可能无法判断某个库是否具备你的项目所需的功能。

最后,有几个重要情况是,在不同的系统上,完全不同的库提供相同的功能。例如,你可能在一个系统中找到 libtermcap 提供光标操作功能,在另一个系统中找到 libncurses,而在另一个系统中找到 libcurses。但你不需要知道所有这些边缘情况,因为当你的项目在用户的系统上由于这些差异而无法构建时,用户会告诉你。

当报告此类 bug 时,你该怎么办?你可以使用 Autoconf 的 AC_SEARCH_LIBS 宏来测试多个库是否具备相同的功能。只需将一个库添加到搜索列表中,完成即可。由于这个修复非常简单,发现问题的用户很可能会直接发送一个补丁到你的 configure.ac 文件。

由于 Autoconf 测试是用 shell 脚本编写的,你在测试操作的方式上有很大的灵活性。你可以编写一个仅检查用户系统中常见位置是否存在某个库或工具的测试,但这绕过了 Autoconf 的一些重要特性。幸运的是,Autoconf 提供了数十个符合其特性测试哲学的宏。你应当仔细研究并使用可用宏的列表,而不是编写自己的宏,因为它们专门设计来确保所需功能在尽可能多的系统和平台上可用。

最简洁的 configure.ac 文件

autoconf 的输入文件叫做 configure.ac。最简单的 configure.ac 文件只有两行,如清单 4-1 所示。

AC_INIT([Jupiter], [1.0])
AC_OUTPUT

清单 4-1:最简单的 configure.ac 文件

对于新接触 Autoconf 的人来说,这两行看起来像是几个函数调用,可能是某种晦涩的编程语言的语法。不要让它们的外观把你吓到——这些是 M4 宏调用。这些宏定义在与 autoconf 软件包一起分发的文件中。例如,你可以在 Autoconf 的安装目录中的 general.m4 文件中找到 AC_INIT 的定义(通常是 /usr/(local/)share/autoconf/autoconf)。AC_OUTPUT 的定义在同一目录下的 status.m4 中。

将 M4 与 C 预处理器进行比较

M4 宏在许多方面类似于在 C 语言源文件中定义的 C 预处理器(CPP)宏。C 预处理器也是一种文本替换工具,这并不奇怪:M4 和 C 预处理器是由 Kernighan 和 Ritchie 在差不多同一时期设计和编写的。

Autoconf 使用方括号将宏参数括起来作为引用机制。引用仅在宏调用的上下文可能导致歧义,而宏处理器可能错误地解决这种歧义时才需要。我们将在第十六章中详细讨论 M4 的引用。现在,只需在每个参数周围使用方括号,以确保生成预期的宏展开。

与 CPP 宏一样,你可以定义 M4 宏来接受以逗号分隔并括在括号中的参数列表。与 CPP 中通过预处理器指令定义宏:#define name(args) expansion不同,在 M4 中,宏是通过内建宏定义的:define(name, expansion)。另一个显著的区别是,在 CPP 中,宏定义中指定的参数是必需的^(4),而在 M4 中,参数化宏的参数是可选的,调用者可以简单地省略它们。如果没有传递参数,你也可以省略括号。传递给 M4 宏的额外参数会被忽略。最后,M4 不允许宏调用中的宏名和开括号之间有空格。

M4 宏的性质

如果你已经在 C 语言中编程多年,你无疑遇到过一些来自低层次黑暗区域的 C 预处理器宏。我说的就是那些真正邪恶的宏,它们展开后会生成一到两页的 C 代码。它们本应被写成 C 函数,但它们的作者要么过于担心性能,要么只是过于兴奋,结果现在轮到你来调试和维护它们了。但是,正如任何资深 C 程序员会告诉你的,使用宏而不是函数所带来的轻微性能提升,并不足以弥补你给维护者带来的调试麻烦。调试这样的宏可能是一场噩梦,因为宏生成的源代码通常无法通过符号调试器访问^(5)。

编写这种复杂的宏被 M4 程序员视为一种宏的极乐世界——它们越复杂、越功能强大,就越“酷”。在清单 4-1 中的两个 Autoconf 宏展开后会生成一个包含近 2400 行 Bourne-shell 脚本的文件,总大小超过 70KB!但你通过查看它们的定义是猜不到这一点的。它们都相当简短——每个只有几十行。这个明显差异的原因很简单:它们是以模块化的方式编写的,每个宏都扩展其他几个宏,后者又扩展其他几个宏,依此类推。

与编程人员被教导不滥用 C 预处理器的原因相同,广泛使用 M4 会给那些试图理解 Autoconf 的人带来相当大的困惑。这并不是说 Autoconf 不应该这样使用 M4;恰恰相反——这正是 M4 的领域。但也有一种观点认为,M4 对于 Autoconf 来说是一个不太好的选择,因为前面提到的宏问题。幸运的是,通常有效地使用 Autoconf 并不需要深入理解它附带的宏的内部工作原理。^(6)

执行 autoconf

运行 Autoconf 非常简单:只需在与configure.ac文件相同的目录中执行autoconf。虽然我可以为本章中的每个示例都执行这个操作,但我将使用autoreconf程序,而不是autoconf程序,因为运行autoreconf的效果与运行autoconf完全相同,只不过autoreconf在你开始向构建系统中添加 Automake 和 Libtool 功能时,也会正确地执行所有 Autotools。也就是说,它会根据configure.ac文件的内容按正确的顺序执行所有 Autotools。

autoreconf程序足够智能,只会按正确的顺序执行你需要的工具,并使用你想要的选项(有一个小小的限制,我会在稍后提到)。因此,运行autoreconf是执行 Autotools 工具链的推荐方法。

首先,将来自清单 4-1 的简单configure.ac文件添加到我们的项目目录中。当前的顶层目录仅包含一个Makefile和一个src目录,后者包含其自己的Makefile和一个main.c文件。一旦你将configure.ac添加到顶层目录中,运行autoreconf

Git 标签 4.0

$ autoreconf
$
$ ls -1p
autom4te.cache/
configure
configure.ac
Makefile
src/
$

首先,注意到autoreconf默认是静默运行的。如果你想看到一些执行过程,可以使用-v--verbose选项。如果你希望autoreconf以详细模式执行 Autotools,也可以在命令行中添加-vv。^(7)

接下来,注意到autoconf会创建一个名为autom4te.cache的目录。这是autom4te缓存目录。该缓存加速了在连续执行 Autotools 工具链中的实用工具时对configure.ac的访问。

configure.ac通过autoconf处理的结果基本上是相同的文件(现在叫做configure),但是所有的宏都已经完全展开。你可以查看configure文件,但如果你立即无法理解其中的内容,也不必太惊讶。configure.ac文件已经通过 M4 宏展开转换成一个包含数千行复杂 Bourne shell 脚本的文本文件。

执行 configure

正如在“配置你的软件包”一节中讨论的,在 第 77 页,GNU 编程标准 指出手写的 configure 脚本应该生成另一个名为 config.status 的脚本,它的任务是从模板生成文件。毫不意外,这正是你在 Autoconf 生成的配置脚本中会找到的功能。这个脚本有两个主要任务:

  • 执行请求的检查

  • 生成并调用 config.status

configure 执行的检查结果会写入 config.status,以便作为 Autoconf 替换变量的替换文本,用于模板文件中(Makefile.inconfig.h.in 等)。当你执行 ./configure 时,它会告诉你它正在创建 config.status。它还会创建一个名为 config.log 的日志文件,包含一些重要的属性。我们来运行 ./configure,然后看看我们的项目目录中新增加了什么:

$ ./configure
configure: creating ./config.status
$
$ ls -1p
autom4te.cache/
config.log
config.status
configure
configure.ac
Makefile
src/
$

我们可以看到,configure 确实生成了 config.statusconfig.log 文件。config.log 文件包含以下信息:*

** 用来调用 configure 的命令行(非常有用!)

  • configure 执行时的平台信息

  • configure 执行的核心测试信息

  • configure 中生成并调用 config.status 的行号

在日志文件的这一部分,config.status 接管生成日志信息,并添加以下内容:

  • 用于调用 config.status 的命令行

config.status 从模板中生成所有文件后,它退出并将控制权返回给 configure,然后 configure 将以下信息附加到日志中:

  • config.status 用来执行任务的缓存变量

  • 可能会在模板中替换的输出变量列表

  • configure 返回给 shell 的退出代码

当你调试 configure 脚本及其相关的 configure.ac 文件时,这些信息非常宝贵。

为什么 configure 不直接执行它写入 config.status 中的代码,而要经历生成第二个脚本并立即调用它的麻烦呢?有几个很好的理由。首先,执行检查和生成文件是概念上不同的操作,make 工具在将概念上不同的操作与独立的目标关联时效果最佳。第二个原因是,你可以单独执行 config.status,从而重新生成输出文件,而不需要再次执行那些繁琐的检查,这样可以节省时间。最后,config.status 会记住最初在 configure 命令行上使用的参数。因此,当 make 检测到需要更新构建系统时,它可以调用 config.status 重新执行 configure,使用最初指定的命令行选项。

执行 config.status

现在你已经了解了configure是如何工作的,你可能会想自己执行config.status。这正是 Autoconf 设计者和GCS的作者们的初衷,他们最初构思了这些设计目标。然而,将检查与模板处理分开更为重要的原因是,make规则可以使用config.status来重新生成 makefile,当make判断模板比对应的 makefile 更新时。

不要调用configure进行不必要的检查(因为你的环境没有改变——只是模板文件改变了),应该编写 makefile 规则来指示输出文件依赖于其模板。这些规则的命令会运行config.status,并将规则的目标作为参数传递。例如,如果你修改了其中一个Makefile.in模板,make会调用config.status来重新生成相应的Makefile,然后make会重新执行它最初的命令行——基本上是重新启动自己。^(8)

列表 4-2 显示了该Makefile.in模板的相关部分,包含了重新生成相应Makefile所需的规则。

Makefile: Makefile.in config.status
        ./config.status $@

列表 4-2:一个规则,如果其模板发生变化,将导致make重新生成 Makefile*

这里触发规则的目标是Makefile。这个规则允许make在模板发生变化时从其模板重新生成源 makefile。它会在执行用户指定的目标或默认目标之前执行,如果没有指定特定目标的话。这个功能是make内建的——如果有一个目标为Makefile的规则,make总是会首先评估这个规则。

列表 4-2 中的规则表明,Makefile依赖于config.statusMakefile.in,因为如果configure更新了config.status,它可能会不同地生成Makefile。也许提供了不同的命令行选项,以便configure现在能够找到之前找不到的库和头文件。在这种情况下,Autoconf 替代变量可能会有不同的值。因此,如果Makefile.inconfig.status中的任何一个被更新,Makefile应该重新生成。

由于config.status本身是一个生成的文件,因此可以推理出,你可以编写这样的规则,在需要时重新生成这个文件。在前面的例子基础上,列表 4-3 添加了所需的代码,以便在configure改变时重建config.status

Makefile: Makefile.in config.status
        ./config.status $@

config.status: configure
        ./config.status --recheck

列表 4-3:当configure改变时,重建config.status的规则

由于config.statusMakefile目标的依赖项,make会寻找一个目标为config.status的规则,并在需要时执行其命令。

添加一些实际功能

我曾经建议过,您应该在 makefiles 中调用 config.status 来从模板生成这些 makefiles。清单 4-4 显示了实际执行此操作的 configure.ac 中的代码。这只是 清单 4-1 中两个原始行之间的一个额外宏调用。

Git 标签 4.1

AC_INIT([Jupiter],[1.0])
AC_CONFIG_FILES([Makefile src/Makefile])
AC_OUTPUT

清单 4-4: configure.ac: 使用 AC_CONFIG_FILES

该代码假设存在 Makefilesrc/Makefile 的模板,分别称为 Makefile.insrc/Makefile.in。这些模板文件看起来与其 Makefile 对应文件完全相同,唯一的例外是:任何我希望 Autoconf 替换的文本,都标记为 Autoconf 替代变量,使用 @VARIABLE@ 语法。

要创建这些文件,只需将现有的 Makefile 文件在顶层和 src 目录中重命名为 Makefile.in。这是将项目 autoconfiscate 的常见做法:

$ mv Makefile Makefile.in
$ mv src/Makefile src/Makefile.in
$

有了这些更改后,我们现在已经有效地在 Jupiter 中使用新的 configure.ac 文件来生成 makefiles。为了让它更有用,我们可以添加一些 Autoconf 替代变量来替换原始的默认值。在这些文件的顶部,我还添加了 Autoconf 替代变量 @configure_input@,并在注释符号后面添加。 清单 4-5 显示了在 Makefile 中生成的注释文本。

Git 标签 4.2

# Makefile. Generated from Makefile.in by configure.
--snip--

清单 4-5: Makefile: 从 Autoconf @configure_input@ 变量生成的文本

我还将之前示例中的 makefile 重新生成规则添加到了每个模板中,每个文件中略有不同的路径差异,以考虑到它们相对于构建目录中的 config.statusconfigure 的不同位置。

清单 4-6 和 4-7 突出了从 第三章 末尾部分到最终递归版本的 Makefilesrc/Makefile 所需的更改。稍后我们将在讲解 Automake 时考虑编写这些文件的非递归版本——使用 Autoconf 和手写的 Makefile.in 模板的过程几乎与我们在 第三章 中使用 makefiles 所做的完全相同。^(9)

# @configure_input@

# Package-specific substitution variables
package = @PACKAGE_NAME@
version = @PACKAGE_VERSION@
tarname = @PACKAGE_TARNAME@
distdir = $(tarname)-$(version)

# Prefix-specific substitution variables
prefix = @prefix@
exec_prefix = @exec_prefix@
bindir = @bindir@

all clean check install uninstall jupiter:
        cd src && $(MAKE) $@
--snip--
$(distdir): FORCE
        mkdir -p $(distdir)/src
        cp configure.ac $(distdir)
        cp configure $(distdir)
        cp Makefile.in $(distdir)
        cp src/Makefile.in src/main.c $(distdir)/src

distcheck: $(distdir).tar.gz
        gzip -cd $(distdir).tar.gz | tar xvf -
        cd $(distdir) && ./configure
        cd $(distdir) && $(MAKE) all
        cd $(distdir) && $(MAKE) check
        cd $(distdir) && $(MAKE) DESTDIR=$${PWD}/_inst install
        cd $(distdir) && $(MAKE) DESTDIR=$${PWD}/_inst uninstall
        @remaining="`find $${PWD}/$(distdir)/_inst -type f | wc -l`"; \
        if test "$${remaining}" -ne 0; then \
          echo "*** $${remaining} file(s) remaining in stage directory!"; \
          exit 1; \
        fi
        cd $(distdir) && $(MAKE) clean
        rm -rf $(distdir)
        @echo "*** Package $(distdir).tar.gz is ready for distribution."
--snip--
FORCE:
        rm -f $(distdir).tar.gz
        rm -rf $(distdir)

Makefile: Makefile.in config.status
        ./config.status $@

config.status: configure
        ./config.status --recheck

.PHONY: FORCE all clean check dist distcheck install uninstall

清单 4-6: Makefile.in: 来自 第三章 的 Makefile 所需修改

# @configure_input@

# Package-specific substitution variables
package = @PACKAGE_NAME@
version = @PACKAGE_VERSION@
tarname = @PACKAGE_TARNAME@
distdir = $(tarname)-$(version)

# Prefix-specific substitution variables
prefix = @prefix@
exec_prefix = @exec_prefix@
bindir = @bindir@

CFLAGS = -g -O0
--snip--
clean:
        rm -f jupiter
 Makefile: Makefile.in ../config.status
        cd .. && ./config.status src/$@

../config.status: ../configure
        cd .. && ./config.status --recheck

.PHONY: all clean check install uninstall

清单 4-7: src/Makefile.in: 来自 第三章 的 src/Makefile 所需修改

我已经从顶层的 Makefile.in 中移除了 export 语句,并将所有的 make 变量(最初只在顶层 Makefile 中)复制到了 src/Makefile.in 中。由于 config.status 会生成这两个文件,因此我可以通过直接将这些变量的值替换到这两个文件中,从中获得显著的好处。这样做的主要优势是,我现在可以在任何子目录中运行 make,而无需担心那些原本由更高级别的 makefile 传递的未初始化变量。

由于 Autoconf 为这些make变量生成完整的值,你可能会想通过删除这些变量,只在文件中使用@prefix@来替代当前使用的$(prefix),从而简化内容。保留make变量是有几个充分理由的。首先,我们将保留make变量的原始优点;最终用户可以继续在make命令行中替换他们自己的值。(即使 Autoconf 为这些变量设置了默认值,用户可能希望覆盖它们。)其次,对于像$(distdir)这样的变量,其值由多个变量引用组成,在一个地方构建这个名称并通过单个变量在其他地方使用,这样会显得更简洁。

我还稍微改变了一些分发目标中的命令。我不再分发 makefile,而是需要分发Makefile.in模板、新的configure脚本和configure.ac文件。^(10)

最终,我修改了distcheck目标的命令,使其在运行make之前先运行configure脚本。

从模板生成文件

请注意,你可以使用AC_CONFIG_FILES从同一目录中找到具有.in扩展名的同名文件生成任何文本文件。.in扩展名是AC_CONFIG_FILES的默认模板命名模式,但你可以覆盖这个默认行为。我稍后会详细讲解。

Autoconf 将sedawk表达式生成到结果configure脚本中,然后将它们复制到config.status中。config.status脚本使用这些表达式在输入模板文件中执行字符串替换。

sedawk都是处理文件流的文本处理工具。流编辑器的优点(sed这个名字是stream editor的缩写)是它能在字节流中替换文本模式。因此,sedawk可以处理非常大的文件,因为它们不需要将整个输入文件加载到内存中进行处理。Autoconf 根据各种宏定义的变量列表构建config.status传递给sedawk的表达式列表,其中许多宏我将在本章稍后更详细地讲解。重要的是要理解,Autoconf 替换变量是模板文件中生成输出文件时唯一会被替换的内容。

到目前为止,我几乎没有花费多少精力,就创建了一个基本的configure.ac文件。我现在可以执行autoreconf,然后执行./configure,接着是make,以便构建 Jupiter 项目。这个简单的三行configure.ac文件生成了一个完全可用的configure脚本,符合GCS所规定的正确配置脚本定义。

生成的配置脚本会运行各种系统检查,并生成一个 config.status 脚本,该脚本可以替换在此构建系统中指定的模板文件集中的许多替换变量。这仅仅是三行代码就实现了这么多功能。

添加 VPATH 构建功能

在 第三章 结束时,我提到过,我还没有讲解一个重要的概念——即 vpath 构建。vpath 构建 是一种使用 make 构造(VPATH)来在不同于源目录的目录中 configure 和构建项目的方法。如果你需要执行以下任何任务,这个概念很重要:

  • 维护一个单独的调试配置

  • 侧边比较不同的配置

  • 在本地修改后,为补丁差异保留一个干净的源目录

  • 从只读源目录进行构建

VPATH 关键字是 虚拟搜索路径 的缩写。一个 VPATH 语句包含一个由冒号分隔的路径列表,用于查找相对路径的依赖项,当它们在当前目录中无法找到时。换句话说,当 make 无法在当前目录中找到一个前置文件时,它会依次在 VPATH 语句中的每个路径中查找该文件。

使用 VPATH 向现有的 makefile 添加远程构建功能非常简单。清单 4-8 展示了在 makefile 中使用 VPATH 语句的一个示例。

VPATH = some/path:some/other/path:yet/another/path

program : src/main.c
        $(CC) ...

清单 4-8:在 makefile 中使用 VPATH 的示例

在这个(假设的)示例中,如果 make 在处理规则时无法在当前目录中找到 src/main.c,它将依次查找 some/path/src/main.csome/other/path/src/main.c,最后查找 yet/another/path/src/main.c,然后因无法找到 src/main.c 而报错。

只需做几处简单的修改,我们就能完全支持 Jupiter 中的远程构建。清单 4-9 和 4-10 说明了对项目的两个 makefile 进行必要更改的方式。

Git 标签 4.3

--snip--
# Prefix-specific substitution variables
prefix = @prefix@
exec_prefix = @exec_prefix@
bindir = @bindir@

# VPATH-specific substitution variables
srcdir = @srcdir@
VPATH = @srcdir@
--snip--
$(distdir): FORCE
        mkdir -p $(distdir)/src
        cp $(srcdir)/configure.ac $(distdir)
        cp $(srcdir)/configure $(distdir)
        cp $(srcdir)/Makefile.in $(distdir)
        cp $(srcdir)/src/Makefile.in $(srcdir)/src/main.c $(distdir)/src
--snip--

清单 4-9: Makefile.in: 向顶层 makefile 添加 VPATH 构建功能

--snip--
# Prefix-specific substitution variables
prefix = @prefix@
exec_prefix = @exec_prefix@
bindir = @bindir@

# VPATH-specific substitution variables
srcdir = @srcdir@
VPATH = @srcdir@
--snip--
jupiter: main.c
        $(CC) $(CPPFLAGS) $(CFLAGS) -o $@ $(srcdir)/main.c
--snip--

清单 4-10: src/Makefile.in: 向低级 makefile 添加 VPATH 构建功能

就是这样,真的。当 config.status 生成一个文件时,它会将一个叫做 @srcdir@ 的 Autoconf 替换变量替换为模板源目录的相对路径。在构建目录结构中的给定 Makefile 中,替换 @srcdir@ 的值是源目录结构中包含相应 Makefile.in 模板的目录的相对路径。这里的概念是,对于远程构建目录中的每个 MakefileVPATH 提供了一个相对路径,指向该构建目录的源代码所在的目录。

注意

不要期望VPATH* 在命令中有效。VPATH 只允许make 查找依赖项;因此,你只能期望VPATH 在规则中的目标和依赖列表中生效。你可以像我在 Listing 4-10 中所做的那样,在命令中使用$(srcdir)/作为文件系统对象的前缀,针对jupiter 目标规则。*

支持远程构建所需的更改在你的构建系统中总结如下:

  • make 变量 srcdir 设置为 @srcdir@ 替代变量。

  • VPATH 变量设置为 @srcdir@

  • 命令中,所有文件依赖项前缀都需要加上 $(srcdir)/

注意

不要在VPATH* 语句中使用$(srcdir),因为一些旧版本的makeVPATH 语句中不会替换变量引用。*

如果源目录与构建目录相同,@srcdir@ 替代变量会退化为一个点(.)。这意味着所有这些 $(srcdir)/ 前缀都会简单地退化为 ./,这无害。^(11)

一个简单的例子是展示这个功能如何工作的最简单方法。现在,Jupiter 在远程构建方面已经完全功能化,让我们试试看。在 Jupiter 项目目录中开始,创建一个名为build的子目录,然后进入该目录。使用相对路径执行 configure 脚本,然后列出当前目录的内容:

$ mkdir build
$ cd build
$ ../configure
configure: creating ./config.status
config.status: creating Makefile
config.status: creating src/Makefile
$
$ ls -1p
config.log
config.status
Makefile
src/
$
$ ls -1p src
Makefile
$

整个构建系统已由 configureconfig.statusbuild 子目录中构建完成。从build 目录内进入 make 以构建项目:

$ make
cd src && make all
make[1]: Entering directory '.../jupiter/build/src'
cc -g -O0 -o jupiter ../../src/main.c
make[1]: Leaving directory '.../jupiter/build/src'
$
$ ls -1p src
jupiter
Makefile
$

无论你身处何地,只要可以通过相对路径或绝对路径访问项目目录,你就可以从该位置进行远程构建。这仅仅是 Autoconf 在 Autoconf 生成的配置脚本中为你做的一件事。想象一下,在你自己编写的配置脚本中管理源目录的适当相对路径!

稍作休息

到目前为止,我已经展示了一个几乎完整的构建系统,包含了GCS中概述的几乎所有功能。Jupiter 的构建系统的特性都相对独立,并且容易理解。手动实现的最困难的功能是配置脚本。实际上,与使用 Autoconf 的简单性相比,手写配置脚本是如此繁琐,以至于我在 第三章 中完全跳过了手写版本。

尽管像我这里展示的那样使用 Autoconf 是非常简单的,但大多数人并不会像我展示的那样创建他们的构建系统。相反,他们会尝试复制另一个项目的构建系统,并对其进行调整,以使其在自己的项目中工作。后来,当他们开始一个新项目时,他们又会做同样的事情。这可能会导致问题,因为他们复制的代码从来没有打算以他们现在尝试的方式使用。

我曾见过一些项目,其中的 configure.ac 文件包含与所属项目无关的垃圾。这些残留的内容来自某个遗留项目,但维护者并不了解 Autoconf,因此无法正确地删除所有多余的文本。使用 Autotools 时,通常最好从小开始,根据需要添加内容,而不是从另一个功能齐全的构建系统中复制一个 configure.ac 文件,然后试图将其缩减到适合的大小或修改它以适应新项目。

我相信你一定觉得 Autoconf 还有很多东西需要学习,你说得对。我们将在本章剩余的部分研究最重要的 Autoconf 宏以及它们在 Jupiter 项目中的使用方式。但首先,让我们回过头来看看,是否可以通过使用 Autoconf 包中另一个工具,进一步简化 Autoconf 启动过程。

使用 autoscan 更快速的开始

创建一个(基本)完整的 configure.ac 文件的最简单方法是运行 autoscan 工具,它是 Autoconf 包的一部分。该工具会检查项目目录的内容,并使用现有的 makefile 和源文件生成一个 configure.ac 文件的基础(autoscan 将其命名为 configure.scan)。

让我们看看 autoscan 在 Jupiter 项目中表现如何。首先,我将清理掉我之前实验中留下的痕迹,然后我将在 jupiter 目录中运行 autoscan

注意

如果你正在使用本书附带的 git 仓库,你可以简单地运行 git clean -df 来删除所有未被 git 源控制管理的文件和目录。别忘了,如果你仍然在构建目录中,切换回父目录。

请注意,我并没有删除我的原始 configure.ac 文件——我只是让 autoscan 告诉我如何改进它。在不到一秒钟的时间里,我在顶层目录下得到了一些新的文件:

   $ cd ..
   $ git clean -df
   $ autoscan
➊ configure.ac: warning: missing AC_CHECK_HEADERS([stdlib.h]) wanted by:
     src/main.c:2
   configure.ac: warning: missing AC_PREREQ wanted by: autoscan
   configure.ac: warning: missing AC_PROG_CC wanted by: src/main.c
   configure.ac: warning: missing AC_PROG_INSTALL wanted by: Makefile.in:18
   $
   $ ls -1p
   autom4te.cache/
   autoscan.log
   configure.ac
   configure.scan
   Makefile.in
   src/
   $

autoscan 工具会检查项目目录结构,并创建两个文件:configure.scanautoscan.log。该项目可能已经为 Autotools 做了准备,也可能没有——这并不重要,因为 autoscan 是完全无破坏性的。它绝不会修改项目中任何现有的文件。

autoscan 工具会为它在现有的 configure.ac 文件中发现的每个问题生成一条警告消息。在这个例子中,autoscan 注意到 configure.ac 应该使用 Autoconf 提供的 AC_CHECK_HEADERSAC_PREREQAC_PROG_CCAC_PROG_INSTALL 宏。它是根据从现有的 Makefile.in 模板和 C 语言源文件中获取的信息做出这些假设的,正如你在警告语句后的评论中看到的那样,警告语句从 ➊ 开始。你可以通过检查 autoscan.log 文件来查看这些消息(更详细的信息)。

注意

你从autoscan* 接收到的通知和你的 configure.ac 文件的内容可能会根据你安装的 Autoconf 版本与我的略有不同。我系统中安装的是 GNU Autoconf 的 2.69 版本(截至本文写作时为最新版本)。如果你的 autoscan 版本较旧(或较新),你可能会看到一些小的差异。*

查看生成的 configure.scan 文件,我注意到 autoscan 向该文件添加的文本比我原始的 configure.ac 文件中的内容更多。查看后我确保理解所有内容后,我发现最简单的方法是用 configure.scan 文件覆盖 configure.ac 文件,然后更改一些特定于 Jupiter 的信息:

$ mv configure.scan configure.ac
$ cat configure.ac
#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.69])
AC_INIT([FULL-PACKAGE-NAME], [VERSION], [BUG-REPORT-ADDRESS])
AC_CONFIG_SRCDIR([src/main.c])
AC_CONFIG_HEADERS([config.h])

# Checks for programs.
AC_PROG_CC
AC_PROG_INSTALL

# Checks for libraries.

# Checks for header files.
AC_CHECK_HEADERS([stdlib.h])

# Checks for typedefs, structures, and compiler characteristics.

# Checks for library functions.
AC_CONFIG_FILES([Makefile
                 src/Makefile])
AC_OUTPUT
$

我的第一次修改涉及更改 Jupiter 的 AC_INIT 宏参数,如 示例 4-11 所示。

Git 标签 4.4

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.69])
AC_INIT([Jupiter], [1.0], [jupiter-bugs@example.org])
AC_CONFIG_SRCDIR([src/main.c])
AC_CONFIG_HEADERS([config.h])
--snip--

示例 4-11: configure.ac: 调整 autoscan 生成的 AC_INIT

autoscan 工具为你做了大量的工作。GNU Autoconf 手册^(12) 中指出,在使用此文件之前,你应根据项目的需求修改该文件,但除了与 AC_INIT 相关的问题外,只有一些关键问题需要关注。我将依次讲解这些问题,但首先,我们需要处理一些行政细节。

在讨论 autoscan 时,我必须提到 autoupdate。如果你已经有了一个工作正常的 configure.ac 文件,并且你更新到更新版本的 Autoconf,你可以运行 autoupdate 来更新你现有的 configure.ac 文件,使其包含自旧版本 Autoconf 以来更改或新增的构造。

所谓的 bootstrap.sh 脚本

autoreconf 出现之前,维护者们会传递一个简短的 shell 脚本,通常命名为 autogen.shbootstrap.sh,该脚本会按照正确的顺序运行所需的所有 Autotools。这个脚本的推荐名称是 bootstrap.sh,因为 Autogen 是另一个 GNU 项目的名称。bootstrap.sh 脚本可以相当复杂,但为了处理缺失的 install-sh 脚本问题(请参见“Autoconf 中缺失的必需文件”),我将只添加一个简单的临时 bootstrap.sh 脚本到项目的根目录,如 示例 4-12 所示。

Git 标签 4.5

   #!/bin/sh
   autoreconf --install
➊ automake --add-missing --copy >/dev/null 2>&1

示例 4-12: bootstrap.sh: 一个临时的 bootstrap 脚本,用于执行所需的 Autotools

Automake --add-missing 选项将所需的缺失工具脚本复制到项目中,--copy 选项表示应该创建真正的副本(否则,会创建指向安装目录中文件的符号链接,链接文件是与 Automake 包一起安装的)。^(13)

注意

我们不需要看到执行 automake 时的警告,因此我在这个脚本的 ➊ 处将 stderrstdout 流重定向到 /dev/null。在第六章中,我们将移除 bootstrap.sh 并简单地运行 autoreconf --install,但目前为止,这解决了我们缺失文件的问题。

Autoconf 中缺少的必需文件

当我第一次尝试在 清单 4-11 中的 configure.ac 文件上执行 autoreconf 时,我发现了一个小问题,关于在没有 Automake 的情况下使用 Autoconf。当我运行 configure 脚本时,它因错误而失败:configure: error: cannot find install-sh, install.sh, or shtool in "." "./.." "./../.."

Autoconf 旨在实现可移植性,但不幸的是,Unix 的 install 工具并不像它本可以那样可移植。从一个平台到另一个平台,安装功能的关键部分差异足够大,以至于会引发问题,因此 Autotools 提供了一个名为 install-sh(已弃用名称:install.sh)的 shell 脚本。该脚本作为系统自带的 install 工具的包装器,屏蔽了不同版本的 install 之间的重要差异。

autoscan 注意到我在 src/Makefile.in 模板中使用了 install 程序,因此它生成了 AC_PROG_INSTALL 宏的扩展。问题是 configure 无法在我的项目中找到 install-sh 包装脚本。

我推测缺失的文件是 Autoconf 包的一部分,只需要安装它即可。我还知道 autoreconf 接受一个命令行选项来将这些缺失的文件安装到项目目录中。--install-i)选项由 autoreconf 支持,用于将特定于工具的选项传递给它调用的每个工具,以便安装缺失的文件。然而,当我尝试这个方法时,我发现文件依然丢失,因为 autoconf 不支持安装缺失文件的选项。

我本可以手动从 Automake 安装目录(通常是 */usr/(local/)share/automake-**)复制 install-sh,但为了寻找更自动化的解决方案,我尝试手动执行 automake --add-missing --copy。这个命令生成了大量警告,表明项目没有为 Automake 配置。然而,我现在可以看到 install-sh 已经被复制到了我的项目根目录,这正是我需要的。执行 autoreconf --install 并没有运行 automake,因为 configure.ac 没有为 Automake 设置。

Autoconf 应该随 install-sh 一起提供,因为它提供了一个需要该脚本的宏,但那样的话 autoconf 还需要提供一个 --add-missing 命令行选项。不过,实际上有一个相当明显的解决方案。install-sh 脚本并不是 Autoconf 生成的任何代码所必需的。怎么会呢?Autoconf 并不生成任何 makefile 构造——它只是将变量替换到你的 Makefile.in 模板中。因此,Autoconf 没有理由抱怨缺少 install-sh 脚本。

更新 Makefile.in

让我们使 bootstrap.sh 可执行,然后执行它,看看最终会得到什么:

   $ chmod +x bootstrap.sh
   $ ./bootstrap.sh
   $ ls -1p
   autom4te.cache/
   bootstrap.sh
➊ config.h.in
   configure
   configure.ac
➋ install-sh
   Makefile.in
   src/
   $

从 ➊ 处的文件列表中,我们知道已经创建了 config.h.in,因此我们知道 autoreconf 已执行了 autoheader。我们还看到新创建的 install-sh 脚本在 ➋ 处,它是在我们执行 bootstrap.sh 中的 automake 时创建的。任何 Autotools 提供或生成的文件都应该复制到归档目录中,以便可以随发行的 tarball 一起打包。因此,我们将为这两个文件添加 cp 命令到顶层 Makefile.in 模板中的 $(distdir) 目标。请注意,我们不需要复制 bootstrap.sh 脚本,因为它完全是一个维护工具—用户不应该需要从 tarball 分发版中执行它。

列表 4-13 展示了对顶层 Makefile.in 模板中的 $(distdir) 目标所需的更改。

Git 标签 4.6

--snip--
$(distdir): FORCE
        mkdir -p $(distdir)/src
        cp $(srcdir)/configure.ac $(distdir)
        cp $(srcdir)/configure $(distdir)
        cp $(srcdir)/config.h.in $(distdir)
        cp $(srcdir)/install-sh $(distdir)
        cp $(srcdir)/Makefile.in $(distdir)
        cp $(srcdir)/src/Makefile.in $(distdir)/src
        cp $(srcdir)/src/main.c $(distdir)/src
--snip--

列表 4-13: Makefile.in:在分发归档镜像目录中需要的附加文件

如果你开始觉得这可能会变成一个维护问题,那么你是对的。我之前提到过 $(distdir) 目标维护起来很痛苦。幸运的是,distcheck 目标仍然存在并按预期工作。它会捕捉到这个问题,因为没有这些附加文件,从 tarball 构建的尝试将失败—如果构建失败,distcheck 目标肯定不会成功。当我们在 第六章 中讨论 Automake 时,我们会清理掉很多维护上的麻烦。

初始化和包信息

现在,让我们回到 列表 4-11 中的 configure.ac 文件内容(以及该列表之前的控制台示例)。第一部分包含 Autoconf 初始化宏。这些是所有项目所必需的。让我们单独考虑每个宏,因为它们都很重要。

AC_PREREQ

AC_PREREQ 宏仅仅定义了可以成功处理此 configure.ac 文件的最早版本的 Autoconf:

AC_PREREQ(version)

GNU Autoconf 手册 表示,AC_PREREQ 是唯一可以在 AC_INIT 之前使用的宏。这是因为,在处理任何其他可能依赖版本的宏之前,确保使用新版本的 Autoconf 是一个好做法。

AC_INIT

AC_INIT宏,顾名思义,用于初始化 Autoconf 系统。以下是它的原型,定义在GNU Autoconf 手册中:^(14)

AC_INIT(package, version, [bug-report], [tarname], [url])

它最多接受五个参数(autoscan仅生成带有前三个参数的调用):packageversion,以及可选的bug-reporttarnameurlpackage参数是包的名称。在你执行make dist时,它会以标准形式作为 Automake 生成的发布版本 tarball 名称的一部分。

注意

Autoconf 在 tarball 名称中使用了包名称的标准化形式,因此,如果你愿意,可以在包名中使用大写字母。Automake 生成的 tarballs 默认命名为tarname-version.tar.gz,但tarname被设置为包名称(小写,所有标点符号转换为下划线)的标准化形式。选择包名称和版本字符串时请记住这一点。

可选的bug-report参数通常设置为一个电子邮件地址,但任何文本字符串都是有效的——一个接受项目 bug 报告的网页 URL 是常见的替代方式。一个名为@PACKAGE_BUGREPORT@的 Autoconf 替换变量会为它创建,并且该变量也会添加到config.h.in模板中,作为 C 预处理器定义。这样做的目的是让你在代码中使用这个变量,在适当的位置显示电子邮件地址或用于报告 bug 的 URL——可能是在用户请求帮助或版本信息时。

虽然version参数可以是你喜欢的任何内容,但有一些常用的开源软件规范可以让你更轻松地处理这个问题。最广泛使用的规范是传入major.minor(例如,1.2)。然而,并没有规定你不能使用major.minor.revision,这种方式也没有问题。生成的VERSION变量(Autoconf、shell 或make)在任何地方都不会被解析或分析——它们只是作为占位符,用于在不同位置替换文本。^(15) 所以如果你愿意,你甚至可以在这个宏中添加非数字文本,例如0.15.alpha1,这种做法有时也很有用。^(16)

注意

另一方面,RPM 包管理器对版本字符串中的内容比较严格。为了兼容 RPM,你可能希望将版本字符串文本限制为仅包含字母数字字符和句点——不允许使用连字符或下划线。

可选的url参数应该是你项目网站的 URL。它会显示在configure --help命令的帮助文本中。

Autoconf 根据AC_INIT的参数生成替换变量@PACKAGE_NAME@@PACKAGE_VERSION@@PACKAGE_TARNAME@@PACKAGE_STRING@(包名称和版本信息的样式化连接)、@PACKAGE_BUGREPORT@@PACKAGE_URL@。你可以在Makefile.in模板文件中使用这些变量中的任何一个或所有。

AC_CONFIG_SRCDIR

AC_CONFIG_SRCDIR宏是一个合理性检查。它的目的是确保生成的configure脚本知道它正在执行的目录实际上是项目目录。

更具体来说,configure需要能够找到它自己,因为它生成的代码需要执行自己,可能是从一个远程目录中执行。有许多方式可以不小心让configure找到其他的configure脚本。例如,用户可能会不小心为configure提供一个错误的--srcdir参数。$0这个 Shell 脚本参数最多也只是可靠,它可能包含的是 Shell 的名称,而不是脚本的名称,或者可能是configure在系统搜索路径中找到的,因此在命令行上没有指定路径信息。

configure脚本可以尝试在当前或父目录中查找,但它仍然需要一种方法来验证它找到的configure脚本是否确实是它自己。因此,AC_CONFIG_SRCDIRconfigure提供了一个重要的提示,告诉它正在正确的位置查找。以下是AC_CONFIG_SRCDIR的原型:

AC_CONFIG_SRCDIR(unique-file-in-source-dir)

参数可以是任何源文件的路径(相对于项目的configure脚本)。你应该选择一个在你的项目中唯一的文件,以尽量减少configure被误导为其他项目的配置文件的可能性。我通常选择一个代表项目的文件,例如一个定义了项目特性的源文件。这样,即使我以后决定重新组织源代码,也不太可能因为文件重命名而丢失它。然而,在这种情况下,我们只有一个源文件,main.c,这使得遵循这个约定有点困难。无论如何,autoconfconfigure都会告诉你和你的用户如果它找不到这个文件。

实例化宏

在深入讨论AC_CONFIG_HEADERS的细节之前,我想花点时间介绍一下 Autoconf 提供的文件生成框架。从一个高层次的角度来看,configure.ac中有四个主要的内容:

  • 初始化

  • 检查请求处理

  • 文件实例化请求处理

  • 生成configure脚本

我们已经覆盖了初始化——它并不复杂,尽管有一些宏你应该了解。更多信息可以查看GNU Autoconf 手册,查找AC_COPYRIGHT作为一个示例。现在让我们继续讨论文件实例化。

实际上有四个所谓的实例化宏AC_CONFIG_FILESAC_CONFIG_HEADERSAC_CONFIG_COMMANDSAC_CONFIG_LINKS。实例化宏接受标签或文件列表;configure将根据包含 Autoconf 替代变量的模板生成这些文件。

注意

你可能需要在你的版本的 configure.scan 中将AC_CONFIG_HEADER(单数)更改为AC_CONFIG_HEADERS(复数)。单数版本是该宏的旧名称,旧宏比新宏功能少。^(17)

这四个实例化宏具有一个有趣的共同签名。以下原型可用于表示它们中的每一个,适当的文本将替换宏名称中的XXX部分:

AC_CONFIG_XXXS(tag..., [commands], [init-cmds])

对于这四个宏中的每一个,标签参数的形式是OUT[:INLIST],其中INLIST的形式是IN0[:IN1:...:INn]。通常,你会看到这些宏的调用只有一个参数,如下三个示例所示(请注意,这些示例表示宏调用,而不是原型,所以方括号实际上是 Autoconf 引号,而不是可选参数的指示):

AC_CONFIG_HEADERS([config.h])

在这个示例中,config.h是前述规范中的OUT部分。INLIST的默认值是OUT部分并附加了.in。换句话说,前面的调用与以下内容完全等效:

AC_CONFIG_HEADERS([config.h:config.h.in])

这意味着config.status包含的 shell 代码将从config.h.in生成config.h,并在此过程中替换所有 Autoconf 变量。你还可以在INLIST部分提供一个输入文件列表。在这种情况下,INLIST中的文件将被连接起来形成结果OUT文件:

AC_CONFIG_HEADERS([config.h:cfg0:cfg1:cfg2])

在这里,config.status将通过连接cfg0cfg1cfg2(按此顺序),在替换所有 Autoconf 变量后生成config.hGNU Autoconf 手册将这个完整的OUT[:INLIST]构造称为一个标签

为什么不直接称它为文件呢?嗯,这个参数的主要用途是提供一种类似 makefile 目标名称的命令行目标名称。它还可以用作文件系统名称,如果相关宏生成文件的话,就像AC_CONFIG_HEADERSAC_CONFIG_FILESAC_CONFIG_LINKS那样。

但是AC_CONFIG_COMMANDS的独特之处在于它不会生成任何文件。相反,它运行用户在宏参数中指定的任意 shell 代码。因此,与其根据次要功能(文件生成)为这个第一个参数命名,GNU Autoconf 手册根据其主要用途,更一般地将其称为一个命令行标签,可以在./config.status命令行中指定,方式如下:

$ ./config.status config.h

这个命令将根据configure.ac中对AC_CONFIG_HEADERS宏的调用重新生成config.h文件。它只会重新生成config.h

输入 ./config.status --help 查看执行 ./config.status 时可以使用的其他命令行选项:

   $ ./config.status --help
   `config.status' instantiates files and other configuration actions
   from templates according to the current configuration.    Unless the files
   and actions are specified as TAGs, all are instantiated by default.
➊ Usage: ./config.status [OPTION]... [TAG]...

    -h, --help       print this help, then exit
    -V, --version    print version number and configuration settings, then exit
     ➋ --config      print configuration, then exit
    -q, --quiet, --silent
                     do not print progress messages
    -d, --debug      don't remove temporary files
        --recheck    update config.status by reconfiguring in the same conditions
     ➌ --file=FILE[:TEMPLATE]
                     instantiate the configuration file FILE
        --header=FILE[:TEMPLATE]
                       instantiate the configuration header FILE

➍ Configuration files:
   Makefile src/Makefile

➎ Configuration headers:
   config.h

   Report bugs to <jupiter-bugs@example.org>.
   $

请注意,config.status 提供了有关项目的 config.status 文件的自定义帮助。它列出了我们可以在命令行中使用的配置文件 ➍ 和配置头文件 ➎,这些文件在用法中指定 [TAG]... 的位置 ➊。在这种情况下,config.status 只会实例化指定的对象。在命令的情况下,它将执行通过关联的 AC_CONFIG_COMMANDS 宏展开中传递的标签指定的命令集。

这些宏可以在 configure.ac 文件中多次使用。结果是累积的,我们可以在 configure.ac 中根据需要多次使用 AC_CONFIG_FILES。还需要注意的是,config.status 支持 --file= 选项(在 ➌ 位置)。当你在命令行中调用 config.status 时,唯一可以使用的标签是帮助文本中列出的可用配置文件、头文件、链接和命令。当你使用 --file= 选项执行 config.status 时,你是在告诉 config.status 生成一个新文件,该文件尚未与任何在 configure.ac 中找到的实例化宏调用关联。这个新文件是从一个关联的模板生成的,使用的是通过上次执行 configure 确定的配置选项和检查结果。例如,我可以通过以下方式执行 config.status(使用一个虚构的模板 extra.in):

$ ./config.status --file=extra:extra.in

注意

默认的模板名称是文件名加上 .in 后缀,因此这个调用可以在不使用 :extra.in 选项部分的情况下完成。我在这里添加它是为了更清晰地说明。

最后,我想指出 config.status 的一个新特性——版本 2.65 的 Autoconf 添加了 --config 选项,在 ➋ 位置显示。使用此选项会显示传递给 configure 的显式配置选项。例如,假设我们以这种方式调用了 ./configure

$ ./configure --prefix=$HOME

当你使用新的 --config 选项时,./config.status 会显示以下内容:

$ ./config.status --config
'--prefix=/home/jcalcote'

注意

较旧版本的 Autoconf 会生成一个 config.status 脚本,当使用 --version 选项时,它会显示这些信息,但它是更大一块文本的一部分。较新的 --config 选项使得查找和重用最初传递给 configure 脚本的配置选项变得更加容易。

现在让我们回到第 102 页底部的实例化宏签名。我已经向你展示了 tag... 参数具有复杂的格式,但省略号表示它也代表多个标签,用空格分隔。你将在几乎所有 configure.ac 文件中看到的格式,如清单 4-14 所示。

AC_CONFIG_FILES([Makefile
                 src/Makefile
                 lib/Makefile
                 etc/proj.cfg])

清单 4-14:在 AC_CONFIG_FILES 中指定多个标签(文件)

这里的每一项都是一个标签规范,如果完全指定,应该类似于清单 4-15 中的调用。

AC_CONFIG_FILES([Makefile:Makefile.in
                 src/Makefile:src/Makefile.in
                 lib/Makefile:lib/Makefile.in
                 etc/proj.cfg:etc/proj.cfg.in])

清单 4-15:在 AC_CONFIG_FILES 中完全指定多个标签

回到实例化宏原型,有两个在这些宏中很少使用的可选参数:commandsinit-cmdscommands 参数可用于指定一些任意的 shell 代码,这些代码将在 config.status 生成与标签相关的文件之前执行。通常不在文件生成实例化宏中使用此功能。你几乎总是会看到 commands 参数与 AC_CONFIG_COMMANDS 一起使用,因为默认情况下该宏不会生成任何文件,因为如果没有要执行的命令,调用此宏基本上是没有用的!^(18) 在这种情况下,tag 参数成为告诉 config.status 执行一组特定的 shell 命令的方法。

init-cmds 参数用于在 config.status 文件顶部初始化 shell 变量,变量值来自 configure.acconfigure。重要的是要记住,所有实例化宏的调用与 config.status 共享一个公共命名空间。因此,你应该尽量谨慎选择 shell 变量名,以减少它们与彼此之间以及与 Autoconf 生成的变量发生冲突的可能性。

旧有的谚语“画面胜于千言万语”在这里同样适用,因此让我们做一个小实验。创建一个包含 清单 4-16 内容的 configure.ac 文件的测试版本。你应该在一个单独的目录中执行此操作,因为在此实验中我们不依赖于 Jupiter 项目目录结构中的其他文件。

AC_INIT([test], [1.0])
AC_CONFIG_COMMANDS([abc],
                   [echo "Testing $mypkgname"],
                   [mypkgname=$PACKAGE_NAME])
AC_OUTPUT

清单 4-16:实验 #1——使用 AC_CONFIG_COMMANDS 的简单 configure.ac 文件

现在以不同的方式执行 autoreconf./configure./config.status,观察会发生什么:

   $ autoreconf
➊ $ ./configure
   configure: creating ./config.status
   config.status: executing abc commands
   Testing test
   $
➋ $ ./config.status
   config.status: executing abc commands
   Testing test
   $
➌ $ ./config.status --help
   'config.status' instantiates files from templates according to the current configuration.
   Usage: ./config.status [OPTIONS]... [FILE]...
   --snip--
   Configuration commands:
    abc

   Report bugs to <bug-autoconf@gnu.org>.
   $
➍ $ ./config.status abc
   config.status: executing abc commands
   Testing test
   $

如你在 ➊ 处看到的,执行 ./configure 会导致 config.status 被执行且没有命令行选项。由于在 configure.ac 中没有指定检查,所以像我们在 ➋ 处做的那样手动执行 ./config.status,几乎会产生相同的效果。查询 config.status 获取帮助(如我们在 ➌ 处所做的)表明 abc 是一个有效的标签;在命令行中执行带有该标签的 ./config.status(如我们在 ➍ 处所做的)只是简单地运行关联的命令。

总结一下,关于实例化宏的关键点如下:

  • config.status 脚本从模板生成所有文件。

  • configure 脚本执行所有检查,然后执行 ./config.status

  • 当你不带命令行选项执行 ./config.status 时,它会根据最后一组检查结果生成文件。

  • 你可以调用 ./config.status 来执行任何实例化宏调用中指定的文件生成或命令集。

  • config.status 脚本可能会生成与 configure.ac 中任何标签未关联的文件,在这种情况下,它将基于最后一组检查结果替换变量。

从模板生成头文件

如你现在无疑已经得出结论,AC_CONFIG_HEADERS 宏允许你指定一个或多个头文件,让 config.status 从模板文件生成这些文件。配置头文件模板的格式非常具体。一个简短的示例见 列表 4-17。

/* Define as 1 if you have unistd.h. */
#undef HAVE_UNISTD_H

列表 4-17:一个简短的头文件模板示例

你可以在头文件模板中放置多条类似的语句,每行一条。当然,注释是可选的。让我们尝试另一个实验。创建一个新的 configure.ac 文件,如 列表 4-18 中所示。同样,你应该在一个隔离的目录中执行此操作。

AC_INIT([test], [1.0])
AC_CONFIG_HEADERS([config.h])
AC_CHECK_HEADERS([unistd.h foobar.h])
AC_OUTPUT

列表 4-18:实验 #2——一个简单的 configure.ac 文件

创建一个名为 config.h.in 的模板头文件,包含 列表 4-19 中的两行。

#undef HAVE_UNISTD_H
#undef HAVE_FOOBAR_H

列表 4-19:实验 #2 继续——一个简单的 config.h.in 文件

现在执行以下命令:

   $ autoconf
   $ ./configure
   checking for gcc... gcc
   --snip--
➊ checking for unistd.h... yes
   checking for unistd.h... (cached) yes
   checking foobar.h usability... no
   checking foobar.h presence... no
➋ checking for foobar.h... no
   configure: creating ./config.status
➌ config.status: creating config.h
   $
   $ cat config.h
   /* config.h.    Generated from config.h.in by configure.    */
   #define HAVE_UNISTD_H 1
➍ /* #undef HAVE_FOOBAR_H */
   $

你可以在 ➌ 看到,config.status 从我们编写的简单 config.h.in 模板生成了一个 config.h 文件。这个头文件的内容基于 configure 执行的检查。由于由 AC_CHECK_HEADERS([unistd.h foobar.h]) 生成的 shell 代码能够在系统包含目录中找到 unistd.h 头文件(➊),因此相应的 #undef 语句被转换成了 #define 语句。当然,如你所见,系统包含目录中没有找到 foobar.h 头文件,正如在 ➋ 的 ./configure 输出中所示;因此,它的定义被保留为注释,正如 ➍ 所示。

因此,你可以将 列表 4-20 中所示的代码添加到项目中适当的 C 语言源文件中。

#include "config.h"
#if HAVE_UNISTD_H
# include <unistd.h>
#endif
#if HAVE_FOOBAR_H
# include <foobar.h>
#endif

列表 4-20:在 C 语言源文件中使用生成的 CPP 定义

注意

如今,unistd.h 头文件已经如此标准,以至于在 AC_CONFIG_HEADERS 中检查它其实不是必需的,但在这个示例中,它作为我确信在我的系统上存在的文件出现。

使用 autoheader 生成包含文件模板

手动维护 config.h.in 模板比实际需要的麻烦。config.h.in 的格式非常严格——例如,#undef 行前后不能有空格,且你添加的 #undef 行必须使用 #undef 而不是 #define,主要是因为 config.status 只知道如何将 #undef 替换为 #define,或者注释掉包含 #undef 的行。^(19)

你从 config.h.in 中需要的大部分信息在 configure.ac 中都可以找到。幸运的是,autoheader 会根据 configure.ac 的内容为你生成一个格式正确的头文件模板,因此你通常不需要编写 config.h.in 模板。让我们回到命令提示符,进行一个最终实验。这个很简单——只需删除实验 #2 中的 config.h.in 模板,然后运行 autoheader,接着是 autoconf

   $ rm config.h.in
   $ autoheader
   $ autoconf
   $ ./configure
   checking for gcc... gcc
   --snip--
   checking for unistd.h... yes
   checking for unistd.h... (cached) yes
   checking foobar.h usability... no
   checking foobar.h presence... no
   checking for foobar.h... no
   configure: creating ./config.status
   config.status: creating config.h
   $
➊ $ cat config.h
   /* config.h. Generated from config.h.in by configure.    */
   /* config.h.in. Generated from configure.ac by autoheader.    */
   /* Define to 1 if you have the <foobar.h> header file. */
   /* #undef HAVE_FOOBAR_H */
   --snip--
   /* Define to 1 if you have the <unistd.h> header file. */
   #define HAVE_UNISTD_H 1
   /* Define to the address where bug reports for this package should be sent. */
   #define PACKAGE_BUGREPORT ""
   /* Define to the full name of this package. */
   #define PACKAGE_NAME "test"
   /* Define to the full name and version of this package. */
   #define PACKAGE_STRING "test 1.0"
   /* Define to the one symbol short name of this package. */
   #define PACKAGE_TARNAME "test"
   /* Define to the version of this package. */
   #define PACKAGE_VERSION "1.0"
   /* Define to 1 if you have the ANSI C header files. */
   #define STDC_HEADERS 1
   $

注意

*再次,我鼓励你使用 autoreconf,如果它注意到 AC_CONFIG_HEADERS 在 configure.ac 中扩展,它将自动运行 autoheader

如你所见,➊ 处 cat 命令的输出显示,autoheaderconfigure.ac 中派生出了一整套预处理器定义。

列表 4-21 展示了一个更实际的示例,说明如何使用生成的 config.h 文件来提高项目源代码的可移植性。在这个示例中,AC_CONFIG_HEADERS 宏的调用表示应该生成 config.h,而 AC_CHECK_HEADERS 的调用会使 autoheaderconfig.h 中插入一个定义。

AC_INIT([test], [1.0])
AC_CONFIG_HEADERS([config.h])
AC_CHECK_HEADERS([dlfcn.h])
AC_OUTPUT

列表 4-21:使用 AC_CONFIG_HEADERS 的一个更实际的示例

config.h 文件的目的是在你希望使用 C 预处理器在代码中测试已配置的选项时包含它。这个文件应当首先包含在源文件中,以便它可以影响后续系统头文件的包含。

注意

autoheader 生成的 config.h.in 模板不包含包含保护结构,因此你需要小心确保它不会在源文件中被包含多次。一个好的经验法则是始终将 config.h 作为每个 .c 源文件中第一个包含的头文件,并且不要在其他地方包含它。遵循这一规则将确保它永远不需要包含保护。

在项目中,通常每个 .c 文件都需要包含 config.h。在这种情况下,一个有趣的方法是使用 gcc-include 选项,在编译器命令行中将其包含在每个编译的源文件顶部。这可以在 configure.ac 中通过将 -include config.h 添加到 DEFS 变量中来实现(当前该变量仅用于定义 HAVE_CONFIG_H——如果你更喜欢简洁的方法,可以改用 CFLAGS)。完成后,你可以假设 config.h 是每个翻译单元的一部分。

如果你的项目将库和头文件作为产品的一部分进行安装,千万不要犯将 config.h 包含在公共头文件中的错误。有关此主题的更详细信息,请参见 第 499 页 的“项 1:将私有细节排除在公共接口之外”。

使用示例 4-21 中的configure.ac文件,生成的configure脚本将创建一个config.h头文件,并根据编译时的判断来确定当前系统是否提供dlfcn接口。为了完成可移植性检查,你可以将示例 4-22 中的代码添加到你的项目源文件中,来使用动态加载器功能。

   #include "config.h"
➊ #if HAVE_DLFCN_H
   # include <dlfcn.h>
   #else
   # error Sorry, this code requires dlfcn.h.
   #endif
   --snip--
➋ #if HAVE_DLFCN_H
       handle = dlopen("/usr/lib/libwhatever.so", RTLD_NOW);
   #endif
   --snip--

示例 4-22:检查动态加载器功能的示例源文件

如果你已经有了包含dlfcn.h的代码,autoscan将会在configure.ac中生成一行,调用AC_CHECK_HEADERS,并在其参数列表中包含dlfcn.h,作为要检查的头文件之一。作为维护者,你的任务是在现有的dlfcn.h头文件的包含部分以及调用dlfcn接口函数的部分周围添加条件语句,标记为➊和➋。这是 Autoconf 可移植性支持的关键。

注意

如果你选择在包含检查失败时“报错”,技术上你不需要预处理器条件语句围绕代码,但这样做会使读者明显看到哪些部分的源代码受到了条件包含的影响。

你的项目可能更倾向于使用动态加载器功能,但在必要时也能没有它。也有可能你的项目确实需要一个动态加载器,在这种情况下,如果缺少关键功能,构建应该终止并报错(如此代码所示)。通常,这是一个可接受的临时解决方案,直到有人来为源代码添加更系统特定的动态加载器服务支持。

注意

如果你必须在配置时就报错,最好在配置时而不是编译时处理。一般的做法是尽早退出。

如前所述,HAVE_CONFIG_H是一个由 Autoconf 替换变量@DEFS@传递给编译器命令行的一系列定义的一部分。在autoheaderAC_CONFIG_HEADERS功能出现之前,Automake 将所有的编译器配置宏添加到@DEFS@变量中。如果你没有在configure.ac中使用AC_CONFIG_HEADERS,你仍然可以使用这种方法,但不推荐这样做——主要是因为大量的定义会导致编译器命令行非常长。

回到远程构建的话题

当我们结束这一章时,你会注意到我们已经走了一圈。我们一开始介绍了一些初步的信息,然后讨论了如何将远程构建添加到 Jupiter 中。现在我们将暂时回到这个话题,因为我还没有讲解如何让 C 预处理器正确地找到生成的config.h文件。

由于此文件是从模板生成的,因此它在构建目录结构中的相对位置将与其对应模板文件config.h.in在源代码目录结构中的位置相同。模板位于顶层source目录(除非你选择将其放在其他地方),因此生成的文件将位于顶层build目录。嗯,这很简单——它总是比生成的src/Makefile高一级。

在我们对头文件位置做出任何结论之前,先考虑一下头文件可能出现在项目中的位置。我们可能会在当前的构建目录中生成它们,作为构建过程的一部分。我们也可能将内部头文件添加到当前的源代码目录中。我们知道在顶层构建目录中有一个config.h文件。最后,我们还可能为我们的包提供的库接口头文件创建一个顶层include目录。这些不同的include目录的优先级顺序是什么?

我们在编译器命令行中放置include 指令-Ipath选项)的顺序,就是它们被搜索的顺序,因此顺序应该基于与当前正在编译的源文件最相关的文件。因此,编译器命令行应首先包含当前构建目录(.)的-Ipath指令,然后是源代码目录[$(srcdir)],接着是顶层构建目录(..),最后是我们的项目的include目录(如果有的话)。我们通过在编译器命令行中添加-Ipath选项来强制执行此顺序,如清单 4-23 所示。

Git 标签 4.7

--snip--
jupiter: main.c
        $(CC) $(CPPFLAGS) $(CFLAGS) -I. -I$(srcdir) -I.. -o $@ \
          $(srcdir)/main.c
--snip--

清单 4-23: src/Makefile.in: 添加正确的编译器包含指令

现在我们知道了这一点,我们需要向我们在第 92 页上创建的列表中添加另一个远程构建的经验法则:

  • 按照顺序添加当前构建目录、相关源代码目录和顶层构建目录(或者如果config.h.in位于其他地方,则是其他构建目录)的预处理器命令。

总结

在本章中,我们涵盖了一个完全功能的 GNU 项目构建系统几乎所有主要特性,包括编写configure.ac文件,Autoconf 从中生成一个完全功能的configure脚本。我们还涵盖了如何通过VPATH语句向 makefile 添加远程构建功能。

那还有什么呢?当然有!在下一章,我将继续向你展示如何使用 Autoconf 在用户运行make之前测试系统特性和功能。我们还将继续增强配置脚本,以便当我们完成时,用户将有更多选项,并且完全理解我们的包将如何在他们的系统上构建。

第五章:更有趣的 Autoconf:配置用户选项

希望不是确信某事会顺利发展,而是确信某事有意义,无论结果如何。

—瓦茨拉夫·哈维尔*,《打破和平》

图片

在第四章中,我们讨论了 Autoconf 的基本内容——如何启动一个新的或现有的项目,以及如何理解一些configure.ac文件的基本方面。在本章中,我们将介绍一些更复杂的 Autoconf 宏。我们将从讨论如何将自定义变量替换到模板文件(例如Makefile.in)中开始,并介绍如何在配置脚本中定义我们自己的预处理器定义。在本章中,我们将继续通过添加重要的检查和测试来开发 Jupiter 项目的功能。我们将涵盖至关重要的AC_OUTPUT宏,最后将讨论如何应用在configure.ac文件中指定的用户定义的项目配置选项。

除此之外,我将介绍一种分析技巧,可以帮助你解读宏的内部工作原理。以稍微复杂的AC_CHECK_PROG宏为例,我将向你展示一些了解其工作原理的方法。

替换和定义

我们将通过讨论 Autoconf 套件中三个最重要的宏开始本章内容:AC_SUBSTAC_DEFINE,以及后者的兄弟宏AC_DEFINE_UNQUOTED

这些宏提供了配置过程与构建和执行过程之间的主要通信机制。替换到生成文件中的值为构建过程提供配置信息,而在预处理器变量中定义的值则在构建时为编译器提供配置信息,并在运行时为构建的程序和库提供配置信息。因此,深入了解AC_SUBSTAC_DEFINE是非常值得的。

AC_SUBST

你可以使用AC_SUBST扩展 Autoconf 中作为核心部分的变量替换功能。每个与替换变量相关的 Autoconf 宏,最终都会调用这个宏,从现有的 Shell 变量中创建替换变量。有时这些 Shell 变量来自环境变量;有时,较高级的宏会在调用AC_SUBST之前,作为其功能的一部分设置这些 Shell 变量。这个宏的签名相对简单(注意,该原型中的方括号表示可选参数,而不是 Autoconf 的引用):

AC_SUBST(shell_var[, value])

注意

如果在调用 M4 宏时选择省略任何尾随的可选参数,你也可以省略尾随的逗号。^(1)然而,如果你省略了中间部分的任何参数,必须为缺失的参数提供逗号作为占位符。

第一个参数,shell_var,表示一个 shell 变量,其值将被替换到通过 config.status 从模板生成的所有文件中。可选的第二个参数是赋给该变量的值。如果没有指定,shell 变量的当前值将被使用,无论它是继承的还是由某些之前的 shell 代码设置的。

替代变量将与 shell 变量具有相同的名称,只是在模板文件中,它将被 @ 符号括起来。因此,一个名为 my_var 的 shell 变量将变成替代变量引用 @my_var@,你可以在任何模板文件中使用它。

configure.ac 中调用 AC_SUBST 不应具有条件性;也就是说,它们不应在类似 if-then-else 结构的条件 shell 语句中被调用。当你仔细考虑 AC_SUBST 的目的时,这一点变得清晰:你已经将替代变量引用硬编码到模板文件中,因此你最好对每个变量无条件地使用 AC_SUBST,否则输出文件将保留变量引用,而不是应该被替换的值。

AC_DEFINE

AC_DEFINEAC_DEFINE_UNQUOTED 宏定义 C 预处理器宏,这些宏可以是简单的宏或类似函数的宏。这些宏要么在 config.h.in 模板中定义(如果你使用 AC_CONFIG_HEADERS),要么通过 Makefile.in 模板中的 @DEFS@ 替代变量传递给编译器命令行。回想一下,如果你没有自己编写 config.h.inautoheader 会根据你在 configure.ac 文件中调用这些宏的情况自动生成它。

这两个宏名称实际上代表四个不同的 Autoconf 宏。以下是它们的原型:

AC_DEFINE(variable, value[, description])
AC_DEFINE(variable)
AC_DEFINE_UNQUOTED(variable, value[, description])
AC_DEFINE_UNQUOTED(variable)

这些宏的正常版本与 UNQUOTED 版本的区别在于,正常版本原样使用指定的值作为预处理器宏的值。UNQUOTED 版本对 value 参数进行 shell 扩展,并使用结果作为预处理器宏的值。因此,如果值包含你希望 configure 扩展的 shell 变量,应该使用 AC_DEFINE_UNQUOTED。(在头文件中将 C 预处理器宏设置为未扩展的 shell 变量没有意义,因为 C 编译器或预处理器在编译源代码时都不知道该如何处理它。)

单参数版本和多参数版本的区别在于预处理器宏的定义方式。单参数版本仅保证宏在预处理器命名空间中被定义,而多参数版本确保宏以特定的值被定义。

可选的第三个参数,description,告诉autoheaderconfig.h.in模板中为此宏添加注释。(如果你不使用autoheader,传递描述就没有意义——因此,它是可选的。)如果你希望定义一个没有值的预处理器宏并提供description,你应该使用这些宏的多参数版本,但将value参数留空。另一种选择是使用AH_TEMPLATE——一个特定于autoheader的宏——它在给定description但不需要value时,与AC_DEFINE做相同的事情。

检查编译器

AC_PROG_CC宏确保用户系统中有一个有效的 C 语言编译器。以下是这个宏的原型:

AC_PROG_CC([compiler-search-list])

如果你的代码需要特定类型或品牌的 C 编译器,你可以在这个参数中传递一个由空格分隔的程序名称列表。例如,如果你使用AC_PROG_CC([cc cl gcc]),该宏会扩展为在 shell 代码中搜索ccclgcc,按此顺序进行。通常,可选的参数会被省略,允许宏找到用户系统中最佳的编译器选项。

你会记得在“通过autoscan更快开始”中提到的内容,位于第 95 页,当autoscan在目录树中发现 C 源文件时,它会在 Jupiter 的configure.scan文件中插入一个无参数调用该宏的命令。列表 5-1 复现了生成的configure.scan文件中的相关部分。

--snip--
# Checks for programs.
AC_PROG_CC
AC_PROG_INSTALL
--snip--

列表 5-1: configure.scan:检查编译器和其他程序

注意

如果 Jupiter 目录树中的源文件后缀是 .cc, .cxx, .C (这些都是常见的 C++源文件扩展名),autoscan会改为插入对AC_PROG_CXX的调用。

AC_PROG_CC宏在系统搜索路径中查找gcc,然后是cc。如果它没有找到任何一个,它会继续查找其他 C 编译器。当它找到一个兼容的编译器时,宏会设置一个已知的变量CC,并根据需要提供移植性选项,除非用户已经在环境变量或configure命令行中设置了CC

AC_PROG_CC宏还定义了以下 Autoconf 替换变量,其中一些你可能会认作是用户变量(在表格 3-2 中的第 71 页列出):

  • @CC@(编译器的完整路径)

  • @CFLAGS@(例如,-g -O2用于gcc

  • @CPPFLAGS@(默认空)

  • @EXEEXT@(例如,.exe

  • @OBJEXT@(例如,o)^(2)

AC_PROG_CC 配置这些替换变量,但除非你在你的Makefile.in模板中使用它们,否则你只是浪费时间运行./configure。方便的是,我们已经在我们的Makefile.in模板中使用了它们,因为在 Jupiter 项目的早期,我们将它们添加到了我们的编译命令行中,并为CFLAGS添加了一个默认值,用户可以在make命令行中覆盖它。

唯一剩下的事情是确保config.status为这些变量引用进行替换。列表 5-2 显示了src目录Makefile.in模板的相关部分以及使这一切发生所需的更改。

Git 标签 5.0

--snip--
# VPATH-specific substitution variables
srcdir = @srcdir@
VPATH = @srcdir@

# Tool-specific substitution variables
CC = @CC@
CFLAGS = @CFLAGS@
CPPFLAGS = @CPPFLAGS@

all: jupiter

jupiter: main.c
        $(CC) $(CPPFLAGS) $(CFLAGS) -I. -I$(srcdir) -I.. -o $@ $(srcdir)/main.c
--snip--

列表 5-2: src/Makefile.in: 使用 Autoconf 编译器和标志替换变量

检查其他程序

紧接在调用AC_PROG_CC之后(请参见列表 5-1)是调用AC_PROG_INSTALL。所有的AC_PROG_*宏都会设置(然后使用AC_SUBST进行替换)指向已定位工具的各种环境变量。AC_PROG_INSTALLinstall工具做了相同的事情。要使用此检查,你需要在你的Makefile.in模板中使用相关的 Autoconf 替换变量,就像我们之前对@CC@@CFLAGS@@CPPFLAGS@所做的那样。列表 5-3 展示了这些更改。

Git 标签 5.1

--snip--
# Tool-specific substitution variables
CC = @CC@
CFLAGS = @CFLAGS@
CPPFLAGS = @CPPFLAGS@
INSTALL = @INSTALL@
INSTALL_DATA = @INSTALL_DATA@
INSTALL_PROGRAM = @INSTALL_PROGRAM@
INSTALL_SCRIPT = @INSTALL_SCRIPT@
--snip--
install:
        $(INSTALL) -d $(DESTDIR)$(bindir)
 $(INSTALL_PROGRAM) -m 0755 jupiter $(DESTDIR)$(bindir)
--snip--

列表 5-3: src/Makefile.in: 在你的 Makefile.in 模板中替换 install 实用程序

@INSTALL@的值显然是已定位的安装程序的路径。@INSTALL_DATA@的值是${INSTALL} -m 0644。基于此,你可能会认为@INSTALL_PROGRAM@@INSTALL_SCRIPT@的值会是${INSTALL} -m 0755之类的,但事实并非如此。这些值被简单地设置为${INSTALL}。^(3)

你可能还需要检查其他重要的实用程序,包括lexyaccsedawk。如果你的程序需要这些工具中的一个或多个,你可以添加AC_PROG_LEXAC_PROG_YACCAC_PROG_SEDAC_PROG_AWK的调用。如果它在你项目的目录树中检测到带有.yy.ll扩展名的文件,autoscan将会将AC_PROG_YACCAC_PROG_LEX的调用添加到configure.scan中。

你可以使用这些更专业的宏检查大约十几种不同的程序。如果程序检查失败,生成的configure脚本将失败,并显示一条消息,指示无法找到所需的工具,并且在工具正确安装之前,构建无法继续。

程序和编译器检查会导致 autoconf 将特别命名的变量替换到模板文件中。你可以在 GNU Autoconf Manual 中找到每个宏的变量名称。你应该在 Makefile.in 模板中的命令中使用这些 make 变量来调用它们代表的工具。Autoconf 宏会根据用户系统上安装的工具设置这些变量的值,如果用户尚未在环境中设置它们

这是 Autoconf 生成的 configure 脚本中的一个关键方面——用户可以 始终 通过在执行 configure 之前导出或设置适当的变量来覆盖 configure 对环境所做的任何更改。^(4)

例如,如果用户选择使用安装在主目录中的特定版本的 bison 构建,可以输入以下命令,以确保 $(YACC) 引用正确版本的 bison,并且 shell 代码 AC_PROG_YACC 生成的内容仅仅是将现有的 YACC 值替换为你 Makefile.in 模板中的 @YACC@

$ cd jupiter
$ ./configure YACC="$HOME/bin/bison -y"
--snip--

注意

将变量设置作为参数传递给configure的功能类似于在 shell 环境中通过命令行为configure进程设置变量(例如,YACC="$HOME/bin/bison -y" ./configure)。使用此示例中给出的语法的优点是,config.status --recheck* 可以跟踪该值,并通过最初传递给它的选项正确地重新执行 configure。因此,你应该始终使用参数语法,而不是 shell 环境语法,来为 configure 设置变量。有关强制使用此语法的方法,请参阅 Autoconf 手册中 AC_ARG_VAR 的文档。

要检查某个程序是否存在,如果该程序未被这些更专用的宏覆盖,你可以使用通用的 AC_CHECK_PROG 宏,或编写你自己的特定用途宏(参见第十六章)。

这里需要记住的关键点如下:

  • AC_PROG_* 宏用于检查程序是否存在。

  • 如果找到程序,则会创建一个替代变量。

  • 你应该在 Makefile.in 模板中使用这些替代变量来执行相关的工具。

Autoconf 中的一个常见问题

我们应借此机会解决开发者在使用 Autotools 时常遇到的一个问题。以下是 GNU Autoconf Manual 中给出的 AC_CHECK_PROG 的正式定义:

AC_CHECK_PROG(variable, prog-to-check-for, value-if-found,
    [value-if-not-found], [path], [reject])

检查程序prog-to-check-for是否存在于path中。如果找到,设置variablevalue-if-found,否则设置为value-if-not-found,如果给定的话。即使reject(一个绝对路径文件名)在搜索路径中最先找到,也总是跳过它;在这种情况下,使用找到的不是rejectprog-to-check-for的绝对路径名来设置variable。如果variable已经设置,则不做任何操作。调用AC_SUBSTvariable。此测试的结果可以通过设置variable变量或缓存变量ac_cv_prog_variable来覆盖。^(5)

这段话比较复杂,但仔细阅读后,你可以从中提取以下内容:

  • 如果在系统搜索路径中找到prog-to-check-for,则variable被设置为value-if-found;否则,设置为value-if-not-found

  • 如果指定了reject(作为完整路径),并且它与在前一步中在系统搜索路径中找到的程序相同,则跳过它并继续搜索系统搜索路径中的下一个匹配程序。

  • 如果reject首先在path中找到,然后找到另一个匹配项(不同于reject),则将variable设置为第二个(非reject)匹配项的绝对路径名。

  • 如果用户已经在环境中设置了variable,则variable保持不变(从而允许用户在运行configure之前通过设置variable来覆盖检查)。

  • 调用AC_SUBST以使variable成为 Autoconf 替代变量。

初读这段描述时,似乎存在冲突:在第一项中,我们看到如果在系统搜索路径中找到prog-to-check-forvariable将被设置为两个指定值之一。但随后我们在第三项中看到,如果首先找到并跳过reject,则variable将被设置为某个程序的完整路径。

发现AC_CHECK_PROG的真实功能就像读一个小的 shell 脚本一样简单。虽然你可以参考 Autoconf 的programs.m4宏文件中对AC_CHECK_PROG的定义,但那时你会离执行检查的实际 shell 代码有一层隔离。直接查看AC_CHECK_PROG生成的 shell 脚本岂不是更好?我们将使用 Jupiter 的configure.ac文件来玩这个概念。暂时根据列表 5-4 中突出显示的更改修改你的configure.ac文件。

--snip--
AC_PREREQ(2.69)
AC_INIT([Jupiter], [1.0], [jupiter-bugs@example.org])
AC_CONFIG_SRCDIR([src/main.c])
AC_CONFIG_HEADER([config.h])

# Checks for programs.
AC_PROG_CC
_DEBUG_START_
AC_CHECK_PROG([bash_var], [bash], [yes], [no],, [/usr/sbin/bash])
_DEBUG_END_
AC_PROG_INSTALL
--snip--

列表 5-4:首次尝试使用AC_CHECK_PROG

现在执行autoconf,打开生成的configure脚本,并搜索_DEBUG_START_

注意

_DEBUG_START__DEBUG_END_ 字符串被称为栅栏。我将它们添加到 configure.ac 中,唯一目的是帮助我找到由 AC_CHECK_PROG 宏生成的 shell 代码的开始和结束位置。我特意选择这些名称,因为在生成的 configure 脚本中,你不太可能找到它们。^(6)

列表 5-5 显示了该宏生成的 configure 代码部分。

   --snip--
   _DEBUG_START_
➊ # Extract the first word of "bash" so it can be a program name with args.
   set dummy bash; ac_word=$2
   { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
   $as_echo_n "checking for $ac_word... " >&6; }
   if ${ac_cv_prog_bash_var+:} false; then :
     $as_echo_n "(cached) " >&6
   else
     if test -n "$bash_var"; then
     ac_cv_prog_bash_var="$bash_var" # Let the user override the test.
   else
     ac_prog_rejected=no
   as_save_IFS=$IFS; IFS=$PATH_SEPARATOR
   for as_dir in $PATH
   do
     IFS=$as_save_IFS
     test -z "$as_dir" && as_dir=.
       for ac_exec_ext in '' $ac_executable_extensions; do
     if as_fn_executable_p "$as_dir/$ac_word$ac_exec_ext"; then
     ➋ if test "$as_dir/$ac_word$ac_exec_ext" = "/usr/sbin/bash"; then
           ac_prog_rejected=yes
           continue
         fi
        ac_cv_prog_bash_var="yes"
        $as_echo "$as_me:${as_lineno-$LINENO}: found $as_dir/$ac_word$ac_exec_ext"
    >&5
        break 2
      fi
    done
      done
  IFS=$as_save_IFS

➌ if test $ac_prog_rejected = yes; then
    # We found a bogon in the path, so make sure we never use it.
    set dummy $ac_cv_prog_bash_var
    shift
    if test $# != 0; then
      # We chose a different compiler from the bogus one.
      # However, it has the same basename, so the bogon will be chosen
      # first if we set bash_var to just the basename; use the full file name.
      shift
      ac_cv_prog_bash_var="$as_dir/$ac_word${1+' '}$@"
    fi
  fi
    test -z "$ac_cv_prog_bash_var" && ac_cv_prog_bash_var="no"
  fi
  fi
  bash_var=$ac_cv_prog_bash_var
  if test -n "$bash_var"; then
    { $as_echo "$as_me:${as_lineno-$LINENO}: result: $bash_var" >&5
  $as_echo "$bash_var" >&6; }
  else
       { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
  $as_echo "no" >&6; }
  fi

  _DEBUG_END_
  --snip--

列表 5-5:由 AC_CHECK_PROG 生成的 configure 代码部分

在此 shell 脚本的 ➊ 位置,开头的注释提示 AC_CHECK_PROG 具有一些未记录的功能。显然,你可以在 prog-to-check-for 参数中传递参数和程序名称。稍后,我们将查看一种可能需要这样做的情况。

在脚本的 ➋ 位置,你可以看到 reject 参数被添加进来,以便让 configure 搜索特定版本的工具。从 ➌ 位置的代码中,我们可以看到我们的 bash_var 变量可能有三种不同的值:如果请求的程序在搜索路径中未找到,则为空;如果找到了指定的程序,则为该程序;如果 reject 首先被找到,则为指定程序的完整路径。

reject 是在哪里使用的呢?例如,在安装了专有 Sun 工具的 Solaris 系统上,默认的 C 编译器通常是 Solaris C 编译器。但是某些软件可能需要使用 GNU C 编译器。作为维护者,我们不知道哪个编译器会在用户的搜索路径中首先找到。AC_CHECK_PROG 允许我们确保如果搜索路径中首先找到其他 C 编译器,gcc 会被使用,并且会提供完整的路径。

正如我之前提到的,M4 宏会意识到传递的参数是给定的、为空的,还是缺失的,并根据这些条件执行不同的操作。许多标准的 Autoconf 宏被编写成充分利用空的或未指定的可选参数,并在每种条件下生成完全不同的 shell 代码。Autoconf 宏还可以根据这些不同的条件优化生成的 shell 代码。

根据我们现在知道的内容,我们可能应该改为这样调用 AC_CHECK_PROG

AC_CHECK_PROG([bash_shell],[bash -x],[bash -x],,,[/usr/sbin/bash])

你可以从这个例子中看到手册在技术上是准确的。如果没有指定reject,并且系统路径中找到了bash,那么bash_shell将被设置为bash -x。如果系统路径中没有找到bash,那么bash_shell将被设置为空字符串。另一方面,如果reject 指定,并且在路径中首先找到不想要的bash版本,那么bash_shell将被设置为路径中找到的下一个版本的完整路径,并带上最初指定的参数(-x)。宏之所以在这种情况下使用完整路径,是为了确保configure避免执行路径中首先找到的版本——reject。接下来的配置脚本可以使用bash_shell变量来运行所需的 Bash shell,只要它不为空。

注意

如果你在自己的代码中跟着一起操作,别忘了从你的 configure.ac 文件中移除清单 5-4 中的临时代码

库和头文件检查

是否在项目中使用外部库是一个艰难的决定。一方面,你希望重用现有代码来提供所需的功能,而不是自己编写。重用是开源软件世界的一个标志。另一方面,你不想依赖那些在所有目标平台上可能不存在,或者可能需要进行大量移植才能使你需要的库在所需位置可用的功能。

偶尔,基于库的功能在不同平台之间可能会有所不同。尽管这些功能在本质上是等效的,但库的包名或 API 签名可能会有所不同。例如,POSIX 线程库(pthread)在功能上类似于许多本地线程库,但这些库的 API 通常会有一些微小的差异,而且它们的包名和库名几乎总是不同。假设我们尝试在一个不支持pthread的系统上构建一个多线程项目,考虑一下会发生什么;在这种情况下,你可能想在 Solaris 上使用libthreads库。

Autoconf 库选择宏允许生成的配置脚本智能地选择提供必要功能的库,即使这些库在不同平台之间的名称不同。为了说明 Autoconf 库选择宏的使用,我们将为 Jupiter 项目添加一些微不足道(而且相当牵强的)多线程功能,使得jupiter能够使用后台线程打印其信息。我们将使用pthread API 作为我们的基础线程模型。为了通过基于 Autoconf 的配置脚本实现这一点,我们需要将pthread库添加到我们的项目构建系统中。

注意

正确使用多线程需要定义额外的替代变量,这些变量包含适当的标志、库和定义。AX_PTHREAD* 宏会为你完成所有这些工作。你可以在 Autoconf 宏库网站上找到 AX_PTHREAD 的文档。^(7) 请参阅 第 384 页中的“正确使用线程”章节,了解如何使用 AX_PTHREAD 的示例。*

首先,让我们解决源代码的更改。我们将修改 main.c,使消息由一个辅助线程打印,正如在 列表 5-6 中所示。

Git 标签 5.2

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

static void * print_it(void * data)
{
    printf("Hello from %s!\n", (const char *)data);
    return 0;
}

int main(int argc, char * argv[])
{
    pthread_t tid;
    pthread_create(&tid, 0, print_it, argv[0]);
    pthread_join(tid, 0);
    return 0;
}

列表 5-6: src/main.c: 向 Jupiter 项目源代码中添加多线程

这显然是一个荒谬的线程使用方式;然而,它 确实 是线程使用的典型形式。考虑一个假设的情况,其中后台线程执行一些长时间的计算,而 main 正在做其他事情,同时 print_it 也在工作。在多处理器机器上,以这种方式使用线程可以将程序的吞吐量翻倍。

现在,我们只需要一种方法来确定应该将哪些库添加到编译器(链接器)命令行中。如果我们不使用 Autoconf,我们只需将库添加到 makefile 中的链接器命令行中,正如在 列表 5-7 中所示。

program: main.c
        $(CC) ... -lpthread ...

列表 5-7:手动将 pthread 库添加到编译命令行中

相反,我们将使用 Autoconf 提供的 AC_SEARCH_LIBS 宏,这是基础的 AC_CHECK_LIB 宏的增强版。AC_SEARCH_LIBS 宏允许我们在一个库列表中测试所需的功能。如果某个指定的库中存在所需功能,合适的命令行选项会被添加到 @LIBS@ 替代变量中,然后我们会在 Makefile.in 模板中使用该变量,将其添加到编译器(链接器)命令行中。以下是来自 GNU Autoconf 手册AC_SEARCH_LIBS 宏的正式定义:

AC_SEARCH_LIBS(function, search-libs,
    [action-if-found], [action-if-not-found], [other-libraries])

如果库中还没有定义 function,则搜索一个定义该函数的库。这相当于首先调用 AC_LINK_IFELSE([AC_LANG_CALL([], [function])]),并且不使用任何库,然后对每个在 search-libs 中列出的库进行操作。

-llibrary 添加到 LIBS 中,以便为找到的第一个包含 function 的库添加链接,并运行 action-if-found。如果未找到 function,则运行 action-if-not-found

如果与 library 链接时出现未解析的符号,而这些符号通过与额外库的链接可以解析,则将这些库作为 other-libraries 参数传递,多个库之间用空格分隔:例如 -lXt -lX11。否则,这个宏将无法检测到 function 是否存在,因为测试程序始终由于未解析的符号而无法链接。

这个测试的结果被缓存到ac_cv_search function变量中,如果function已经可用,则为none required,如果未找到包含function的库,则为no,否则为需要添加到LIBS前缀的-llibrary选项。^(8)

你能看出为什么生成的配置脚本如此庞大吗?当你在调用AC_SEARCH_LIBS时传递一个特定的函数,链接器命令行参数会被添加到一个名为@LIBS@的替代变量中。这些参数确保你将链接到包含传递函数的库。如果第二个参数中列出了多个库,并且由空格分隔,configure将确定这些库中哪些在用户的系统上可用,并使用最合适的一个。

示例 5-8 展示了如何在 Jupiter 的configure.ac文件中使用AC_SEARCH_LIBS来查找包含pthread_create函数的库。如果AC_SEARCH_LIBS没有在pthread库中找到pthread_create,它将不会向@LIBS@变量添加任何内容。

--snip--
# Checks for libraries.
AC_SEARCH_LIBS([pthread_create], [pthread])
--snip--

示例 5-8: configure.ac: 使用AC_SEARCH_LIBS检查系统上的pthread

正如我们在第七章中将详细讨论的那样,不同系统的库命名规则各不相同。例如,一些系统将库命名为libbasename.so,而其他系统使用libbasename.salibbasename.a。基于 Cygwin 的系统生成命名为cigbasename.dll的库。AC_SEARCH_LIBS通过使用编译器计算库的实际名称来优雅地解决了这个问题;它通过尝试将一个小的测试程序与测试库中的请求函数链接来实现这一点。编译器命令行上只传递-lbasename,这是 Unix 编译器之间的一个几乎通用的约定。

我们将不得不再次修改src/Makefile.in,以便正确使用现在已填充的@LIBS@变量,正如示例 5-9 所示。

--snip--
# Tool-specific substitution variables
CC = @CC@
LIBS = @LIBS@
CFLAGS = @CFLAGS@
CPPFLAGS = @CPPFLAGS@
--snip--
jupiter: main.c
        $(CC) $(CFLAGS) $(CPPFLAGS) -I. -I$(srcdir) -I..\
          -o $@ $(srcdir)/main.c $(LIBS)
--snip--

示例 5-9: src/Makefile.in: 使用@LIBS@替代变量

注意

我在编译器命令行上的源文件之后添加了$(LIBS),因为链接器关心目标文件的顺序——它会按命令行上指定的顺序搜索文件中的必需函数。

我希望main.c成为jupiter的主要目标代码来源,所以我会继续将其他目标文件,包括库,添加到这个文件之后的命令行中。

这对吗,还是只是够好?

到目前为止,我们已经确保我们的构建系统能在大多数系统上正确使用pthread。^(9) 如果我们的系统需要特定的库,该库的名称将被添加到@LIBS@变量中,然后在编译器命令行上使用。但我们还没有完成。

这个系统通常工作得很好,但在某些极端情况下可能会失败。因为我们希望提供卓越的用户体验,所以我们将把 Jupiter 的构建系统提升到一个新的水平。在此过程中,我们需要做出一个设计决策:如果configure未能在用户的系统上找到pthread库,我们是应该让构建过程失败,还是构建一个没有多线程的jupiter程序?

如果我们选择让构建失败,用户会注意到,因为构建会因错误信息而停止(尽管这个错误信息可能并不是很友好——编译或链接过程会因缺失头文件或未定义符号而出现难以理解的错误信息)。另一方面,如果我们选择构建一个单线程版本的jupiter,我们需要显示一些清晰的消息,说明程序正在构建时没有多线程功能,并解释原因。

一个潜在的问题是,有些用户的系统可能安装了pthread共享库,但没有安装pthread.h头文件——很可能是因为安装了pthread可执行文件(共享库)包,但没有安装开发者包。共享库通常与静态库和头文件分开打包,虽然可执行文件作为更高层次应用程序的依赖链的一部分安装,但开发者包通常由用户直接安装。^(10) 因此,Autoconf 提供了宏来测试库和头文件的存在。我们可以使用AC_CHECK_HEADERS宏来确保特定头文件的存在。

Autoconf 的检查非常彻底。它们通常不仅确保文件存在,而且确保文件是正确的,因为它们允许你指定关于文件的断言,然后宏会验证这些断言。AC_CHECK_HEADERS宏不仅仅是扫描文件系统寻找请求的头文件。像AC_SEARCH_LIBS一样,AC_CHECK_HEADERS宏会构建一个短小的测试程序,并将其编译,以确保编译器既能找到该文件,又能使用它。本质上,Autoconf 宏不仅仅是测试特定功能是否存在,而是测试这些功能所需的功能性。

AC_CHECK_HEADERS宏在GNU Autoconf Manual中的定义如下:

AC_CHECK_HEADERS(header-file..., [action-if-found],
    [action-if-not-found], [includes = 'AC_INCLUDES_DEFAULT'])

对于空格分隔的每个给定系统头文件header-file,如果它存在,则定义HAVE_header-file(全大写)。如果给定了action-if-found,它是找到某个头文件时执行的额外 Shell 代码。你可以给它一个break的值,在第一次匹配时跳出循环。如果给定了action-if-not-found,则在没有找到某个头文件时执行该代码。

includes的解释方式与AC_CHECK_HEADER相同,用于选择在测试头文件之前提供的一组预处理指令。^(11)

通常,AC_CHECK_HEADERS 只会在第一个参数中调用一个所需头文件的列表。其余参数是可选的,且不常使用,因为该宏在没有这些参数时通常已经能很好地工作。

我们将使用 AC_CHECK_HEADERSconfigure.ac 中检查 pthread.h 头文件。如你所见,configure.ac 已经调用了 AC_CHECK_HEADERS 来查找 stdlib.hAC_CHECK_HEADERS 接受一个文件名列表,所以我们只需将 pthread.h 添加到该列表中,文件名之间用空格分隔,如示例 5-10 所示。

Git 标签 5.3

--snip--
# Checks for header files.
AC_CHECK_HEADERS([stdlib.h pthread.h])
--snip--

示例 5-10: configure.ac: pthread.h 添加到 AC_CHECK_HEADERS 宏中

为了让尽可能多的人使用这个包,我们将使用双模式构建方法,这将允许我们在没有 pthread 库的情况下,至少提供 jupiter 程序的某种形式给用户。为了实现这一点,我们需要在 src/main.c 中添加一些条件预处理器语句,如示例 5-11 所示。

#include "config.h"

#include <stdio.h>
#include <stdlib.h>

#if HAVE_PTHREAD_H
# include <pthread.h>
#endif

static void * print_it(void * data)
{
    printf("Hello from %s!\n", (const char *)data);
    return 0;
}

int main(int argc, char * argv[])
{
#if HAVE_PTHREAD_H
    pthread_t tid;
    pthread_create(&tid, 0, print_it, argv[0]);
    pthread_join(tid, 0);
#else
    print_it(argv[0]);
#endif
    return 0;
}

示例 5-11: src/main.c: 根据 pthread.h 的存在添加条件代码

在这个版本的 main.c 中,我们添加了对头文件的条件检查。如果 AC_CHECK_HEADERS 生成的 shell 脚本找到了 pthread.h 头文件,HAVE_PTHREAD_H 宏将在用户的 config.h 文件中定义为值 1。如果 shell 脚本没有找到该头文件,原始的 #undef 语句将保留在 config.h 中被注释掉。因为我们依赖于这些定义,我们还需要在 main.c 的顶部包含 config.h

如果你选择不在 configure.ac 中使用 AC_CONFIG_HEADERS 宏,那么 @DEFS@ 将包含所有通过调用 AC_DEFINE 宏生成的定义。在这个例子中,我们使用了 AC_CONFIG_HEADERS,因此 config.h.in 将包含大部分这些定义,而 @DEFS@ 只会包含 HAVE_CONFIG_H,而我们实际上并未使用它。^(12) config.h.in 模板方法显著缩短了编译器命令行(并且也使得在非 Autotools 平台上手动修改模板并拍摄快照变得简单)。示例 5-12 显示了对 src/Makefile.in 模板的必要更改。

--snip--
# Tool-related substitution variables
CC = @CC@
DEFS = @DEFS@
LIBS = @LIBS@
CFLAGS = @CFLAGS@
CPPFLAGS = @CPPFLAGS@
--snip--
jupiter: main.c
        $(CC) $(CFLAGS) $(DEFS) $(CPPFLAGS) -I. -I$(srcdir) -I..\
          -o $@ $(srcdir)/main.c $(LIBS)
--snip--

示例 5-12: src/Makefile.in: 在 src 级别的 Makefile 中添加 @DEFS@ 的使用

注意

我在 $(DEFS) 前添加了 $(CPPFLAGS),给最终用户提供了在命令行中覆盖我的任何政策决策的选项

我们现在拥有了所有需要的条件来构建 jupiter 程序。如果用户的系统已安装 pthread 功能,用户将自动构建一个使用多线程执行的 jupiter 版本;否则,他们只能选择串行执行。剩下的工作就是向 configure.ac 中添加一些代码,以便如果 configure 找不到 pthread 库,它将显示一条消息,指示将构建一个使用串行执行的程序。

现在,考虑一个不太可能的情况:用户已安装头文件,但没有安装库。例如,如果用户执行 ./configure 时使用了 CPPFLAGS=-I/usr/local/include,但忽略了添加 LDFLAGS=-L/usr/local/libconfigure 会认为头文件可用,但库缺失。只需简单地跳过头文件检查(如果 configure 找不到库),就能轻松解决此问题。清单 5-13 显示了对 configure.ac 所做的必要更改。

Git 标签 5.4

--snip--
# Checks for libraries.
have_pthreads=no
AC_SEARCH_LIBS([pthread_create], [pthread], [have_pthreads=yes])

# Checks for header files.
AC_CHECK_HEADERS([stdlib.h])

if test "x${have_pthreads}" = xyes; then
    AC_CHECK_HEADERS([pthread.h], [], [have_pthreads=no])
fi

if test "x${have_pthreads}" = xno; then
    AC_MSG_WARN([
 ------------------------------------------
  Unable to find pthreads on this system.
  Building a single-threaded version.
  ------------------------------------------])
fi
--snip--

清单 5-13: configure.ac: 添加代码以指示在配置过程中多线程不可用

现在,当我们运行 ./bootstrap.sh./configure 时,我们将看到一些额外的输出(在此处突出显示):

$ ./bootstrap.sh
$ ./configure
checking for gcc... gcc
--snip--
checking for library containing pthread_create... -lpthread
--snip--
checking pthread.h usability... yes
checking pthread.h presence... yes
checking for pthread.h... yes
configure: creating ./config.status
config.status: creating Makefile
config.status: creating src/Makefile
config.status: creating config.h
$

例如,如果用户的系统缺少 pthread.h 头文件,他们将看到不同的输出。为了测试此情况,我们可以使用一个涉及 Autoconf 缓存变量的技巧。通过预设表示 pthread.h 头文件存在的缓存变量为 no,我们可以欺骗 configure 使其根本不去查找 pthread.h,因为如果缓存变量已经设置,它会认为搜索已经完成。让我们尝试一下:

$ ./configure ac_cv_header_pthread_h=no
checking for gcc... gcc
--snip--
checking for library containing pthread_create... -lpthread
--snip--
checking for pthread.h... (cached) no
configure: WARNING:
  ------------------------------------------
  Unable to find pthreads on this system.
  Building a single-threaded version.
  ------------------------------------------
configure: creating ./config.status
config.status: creating Makefile
config.status: creating src/Makefile
config.status: creating config.h
$

如果我们选择在找不到 pthread.h 头文件或 pthread 库时使构建失败,那么源代码将会更简单;无需进行条件编译。在这种情况下,我们可以将 configure.ac 更改为如下 清单 5-14 所示。

--snip--
# Checks for libraries.
have_pthreads=no
AC_SEARCH_LIBS([pthread_create], [pthread], [have_pthreads=yes])

# Checks for header files.
AC_CHECK_HEADERS([stdlib.h])

if test "x${have_pthreads}" = xyes; then
    AC_CHECK_HEADERS([pthread.h], [], [have_pthreads=no])
fi

if test "x${have_pthreads}" = xno; then
    AC_MSG_ERROR([
  ------------------------------------------
  The pthread library and header files are
  required to build jupiter. Stopping...
  Check 'config.log' for more information.
  ------------------------------------------])
fi
--snip--

清单 5-14: 如果未找到 pthread 库,则使构建失败

注意

Autoconf 宏生成的 Shell 代码用于检查系统功能的存在,并根据这些测试设置变量。然而,作为维护者,你需要向 configure.ac 中添加 Shell 代码,以便根据结果变量的内容做出功能决策。

打印消息

在前面的示例中,我们使用了几个 Autoconf 宏来在配置过程中显示消息:AC_MSG_WARNAC_MSG_ERROR。以下是 Autoconf 提供的各种 AC_MSG_* 宏的原型:

AC_MSG_CHECKING(feature-description)
AC_MSG_RESULT(result-description)
AC_MSG_NOTICE(message)
AC_MSG_ERROR(error-description[, exit-status])
AC_MSG_FAILURE(error-description[, exit-status])
AC_MSG_WARN(problem-description)

AC_MSG_CHECKINGAC_MSG_RESULT 宏是配合使用的。AC_MSG_CHECKING 宏会打印一行,指示正在检查某个特性,但不会在该行的末尾打印换行符。当该特性在用户的机器上找到(或未找到)后,AC_MSG_RESULT 宏会打印结果,并在行的末尾添加换行符,完成由 AC_MSG_CHECKING 开始的行。result-description 文本应在 feature-description 消息的上下文中有意义。例如,消息 Looking for a C compiler... 可能以找到的编译器名称结束,或者以 not found 结束。

注意

作为 configure.ac 的作者,您应尽力避免这两个宏调用之间出现额外的文本,因为如果这两组输出之间有不相关的文本,用户会很难跟踪

AC_MSG_NOTICEAC_MSG_WARN 宏仅将字符串打印到屏幕上。AC_MSG_WARN 的前缀文本是 configure: WARNING:,而 AC_MSG_NOTICE 的前缀文本仅为 configure:

AC_MSG_ERRORAC_MSG_FAILURE 宏会生成错误消息,停止配置过程,并将错误代码返回给 shell。AC_MSG_ERROR 的前缀文本是 configure: error:AC_MSG_FAILURE 宏会打印一条通知,指示错误发生的目录、用户指定的消息,然后是文本 See 'config.log' for more details。这些宏中的可选第二个参数 (exit-status) 允许维护者指定返回给 shell 的特定状态码。默认值是 1

这些宏输出的文本消息会显示在 stdout 上,并发送到 config.log 文件中,因此使用这些宏很重要,而不是仅仅使用 shell 的 echoprintf 语句。

在这些宏的第一个参数中提供多行文本尤其重要,特别是在警告消息的情况下,警告仅表示构建在有限制的情况下继续进行。在大型配置过程中,快速构建机器上,一条单行警告信息可能会快速出现并被用户忽略。对于 configure 因错误终止的情况,这不是问题,因为用户很容易在输出的最后发现问题。^(13)

支持可选功能和包

我们已经讨论了如何处理 pthread 库存在与否的不同情况。但如果用户希望在安装了 pthread 库的情况下构建 jupiter 的单线程版本怎么办?我们当然不希望在 Jupiter 的 README 文件中添加一条提示,告诉用户重命名他们的 pthread 库!我们也不希望用户必须使用我们的 Autoconf 缓存变量技巧。

Autoconf 提供了两个用于处理可选功能和外部软件包的宏:AC_ARG_ENABLEAC_ARG_WITH。它们的原型如下:

AC_ARG_WITH(package, help-string, [action-if-given], [action-if-not-given])
AC_ARG_ENABLE(feature, help-string, [action-if-given], [action-if-not-given])

与许多 Autoconf 宏一样,这两个宏仅用于设置一些环境变量:

AC_ARG_WITH ${withval}${with_package}

AC_ARG_ENABLE ${enableval}${enable_feature}

宏也可以以更复杂的形式使用,其中环境变量由 shell 脚本在宏的可选参数中使用。无论哪种情况,生成的变量必须在configure.ac中使用,否则执行检查就没有意义。

这些宏的设计目的是将选项 --enable-feature[=yes|no](或 --disable-feature)和 --with-package[=arg](或 --without-package)添加到生成的配置脚本的命令行界面,并在用户输入 ./configure --help 时生成适当的帮助文本。如果用户提供了这些选项,宏将在脚本中设置前面的环境变量。(这些变量的值可能稍后在脚本中用于设置或清除各种预处理器定义或替代变量。)

AC_ARG_WITH 控制项目对可选外部软件包的使用,而 AC_ARG_ENABLE 控制可选软件功能的包含或排除。选择使用其中一个或另一个通常取决于你对正在考虑的软件的看法,有时只是个人偏好,因为这两个宏提供的功能集有些重叠。

例如,在 Jupiter 项目中,可以合理地认为 Jupiter 使用 pthread 代表使用了一个外部软件包,因此你会使用 AC_ARG_WITH。然而,也可以说 异步处理 是一个可以通过 AC_ARG_ENABLE 启用的软件功能。事实上,这两种说法都是正确的,选择使用哪个选项应该由对你所提供可选访问的功能或软件包的高层架构视角来决定。pthread 库不仅提供线程创建函数,还提供互斥锁和条件变量,这些都可以被一个不创建线程的库包使用。如果一个项目提供的库需要在多线程进程中以线程安全的方式工作,它很可能会使用 pthread 库中的互斥锁对象,但可能永远不会创建线程。因此,用户可以选择在配置时禁用异步执行功能,但项目仍然需要链接到 pthread 库以访问互斥锁功能。在这种情况下,指定 --enable-async-exec--with-pthreads 更有意义。

一般来说,当用户需要在不同包或项目内部提供的不同功能实现之间进行选择时,你应该使用AC_ARG_WITH。例如,如果jupiter有某种原因需要加密文件,它可能会选择使用内部加密算法或外部加密库。默认配置可能使用内部算法,但该包可能允许用户通过命令行选项--with-libcrypto来覆盖默认值。谈到安全性,使用广为人知的库确实能帮助你的包获得社区的信任。

为功能选项编写代码

决定使用AC_ARG_ENABLE后,我们如何默认启用或禁用async-exec功能呢?这两种情况下如何在configure.ac中编码的区别仅限于帮助文本和传递给action-if-not-given参数的 shell 脚本。帮助文本描述了可用的选项和默认值,而 shell 脚本则指明了如果没有指定选项时希望发生的情况。(当然,如果指定了,我们不需要假设任何事情。)

假设我们决定将异步执行作为一个风险较大或实验性的功能,默认情况下希望禁用它。在这种情况下,我们可以将 Listing 5-15 中的代码添加到configure.ac中。

--snip--
AC_ARG_ENABLE([async-exec],
    [  --enable-async-exec     enableasync exec],
    [async_exec=${enableval}], [async_exec=no])
--snip--

Listing 5-15: 默认情况下禁用的功能

另一方面,如果我们决定异步执行对 Jupiter 来说是基本功能,那么我们可能应该像 Listing 5-16 中那样默认启用它。

--snip--
AC_ARG_ENABLE([async-exec],
    [  --disable-async-exec    disableasync exec],
    [async_exec=${enableval}], [async_exec=yes])
--snip--

Listing 5-16: 默认情况下启用的功能

现在问题是,我们是否在不管用户是否需要这个功能的情况下检查库和头文件,还是只有在启用async-exec功能时才检查?在这种情况下,这是一个偏好问题,因为我们仅为这个功能使用pthread库。(如果我们还因为非特定功能的原因使用它,那么无论如何都必须检查它。)

在需要即使功能禁用也需要库的情况下,我们会像前面的例子那样添加AC_ARG_ENABLE,并额外调用AC_DEFINE来为这个功能创建一个config.h定义。由于我们并不希望在库或头文件缺失时启用该功能——即使用户特别要求启用——我们还会添加一些 shell 代码,在库或头文件缺失时将该功能关闭,如 Listing 5-17 所示。

Git 标签 5.5

--snip--
# Checks for programs.
AC_PROG_CC
AC_PROG_INSTALL

# Checks for header files.
AC_CHECK_HEADERS([stdlib.h])

# Checks for command line options
AC_ARG_ENABLE([async-exec],
    [  --disable-async-exec    disable async execution feature],
    [async_exec=${enableval}], [async_exec=yes])

have_pthreads=no
AC_SEARCH_LIBS([pthread_create], [pthread], [have_pthreads=yes])

if test "x${have_pthreads}" = xyes; then
    AC_CHECK_HEADERS([pthread.h], [], [have_pthreads=no])
fi

if test "x${have_pthreads}" = xno; then
 ➊ if test "x${async_exec}" = xyes; then
        AC_MSG_WARN([
  ------------------------------------------
  Unable to find pthreads on this system.
  Building a single-threaded version.
  ------------------------------------------])
    fi
    async_exec=no
fi

if test "x${async_exec}" = xyes; then
    AC_DEFINE([ASYNC_EXEC], [1], [async execution enabled])
fi

# Checks for libraries.

# Checks for typedefs, structures, and compiler characteristics.
--snip--

Listing 5-17: configure.ac: 在配置期间正确管理可选功能

我们正在将原有的库检查替换为新的命令行参数检查,这样不仅能检查用户未指定偏好的默认情况下的库,还带来了额外的好处。正如你所看到的,现有的大部分代码保持不变,只是添加了一些额外的脚本来处理用户的命令行选择。

注意

在清单 5-17 中有些地方似乎有多余的空白或任意的缩进。这是故意的,因为它使得在运行configure时输出能够正确格式化。我们将在稍后通过添加额外的宏来修复其中一些问题

请注意,在➊处,我还添加了一个对async_exec变量中yes值的额外测试,因为这些文本真正属于功能测试,而不是pthread库测试。请记住,我们正在尝试在测试pthread功能和测试async-exec功能本身的需求之间创建逻辑分隔。

当然,现在我们还必须修改src/main.c,以使用新的定义,如清单 5-18 所示。

--snip--
#if HAVE_PTHREAD_H
# include <pthread.h>
#endif

static void * print_it(void * data)
{
    printf("Hello from %s!\n", (const char *)data);
    return 0;
}

int main(int argc, char * argv[])
{
#if ASYNC_EXEC
    pthread_t tid;
    pthread_create(&tid, 0, print_it, argv[0]);
    pthread_join(tid, 0);
#else
    print_it(argv[0]);
#endif
    return 0;
}

清单 5-18: src/main.c: 更改async-exec特定代码的条件

请注意,我们保留了HAVE_PTHREAD_H检查以便在包含头文件时,可以便于以不同于此功能要求的方式使用pthread.h

为了仅在启用该功能时检查库和头文件,我们将原始的检查代码包装在async_exec的测试中,如清单 5-19 所示。

Git 标签 5.6

--snip--
# Checks for command line options.
AC_ARG_ENABLE([async-exec],
  [    --disable-async-exec        disable async execution feature],
  [async_exec=${enableval}], [async_exec=yes])

if test "x${async_exec}" = xyes; then
    have_pthreads=no
    AC_SEARCH_LIBS([pthread_create], [pthread], [have_pthreads=yes])

    if test "x${have_pthreads}" = xyes; then
        AC_CHECK_HEADERS([pthread.h], [], [have_pthreads=no])
    fi

    if test "x${have_pthreads}" = xno; then
        AC_MSG_WARN([
  -----------------------------------------
  Unable to find pthreads on this system.
  Building a single-threaded version.
  -----------------------------------------])
        async_exec=no
    fi
fi

if test "x${async_exec}" = xyes; then
    AC_DEFINE([ASYNC_EXEC], 1, [async execution enabled])
fi
--snip--

清单 5-19: configure.ac: 仅在启用功能时检查库和头文件

这次,我们将async_exec的测试从仅围绕消息语句移动到围绕整个头文件和库检查集合,这意味着如果用户禁用了async_exec功能,我们甚至不会查找pthread头文件和库。

格式化帮助字符串

我们将对清单 5-17 中AC_ARG_ENABLE的使用做最后的修改。请注意,在第二个参数中,方括号和参数文本开始之间正好有两个空格。你还会注意到,参数和描述之间的空格数量取决于参数文本的长度,因为描述文本应该与特定列对齐呈现。在清单 5-16 和 5-17 中,--disable-async-exec和描述之间有四个空格,但在清单 5-15 中,--enable-async-exec后面有五个空格,因为单词enabledisable少一个字符。

但是,如果 Autoconf 项目的维护者决定更改配置脚本的帮助文本格式怎么办?或者如果你修改了选项名称,但忘记调整帮助文本的缩进呢?

为了解决这些潜在问题,我们将使用一个名为AS_HELP_STRING的 Autoconf 助手宏,其原型如下:

AS_HELP_STRING(left-hand-side, right-hand-side,
    [indent-column = '26'], [wrap-column = '79'])

这个宏的唯一目的是抽象化关于在帮助文本中的各个位置应该嵌入多少空格的知识。要使用它,只需将AC_ARG_ENABLE中的第二个参数替换为AS_HELP_STRING的调用,如清单 5-20 所示。

Git 标签 5.7

--snip--
AC_ARG_ENABLE([async-exec],
    [AS_HELP_STRING([--disable-async-exec],
        [disable asynchronous execution @<:@default: no@:>@])],
    [async_exec=${enableval}], [async_exec=yes])
--snip--

清单 5-20: configure.ac: 使用AS_HELP_STRING

注意

关于清单 5-20 中围绕default: no的奇怪字符序列的详细信息,请参阅第 143 页的“Quadrigraphs”。

检查类型和结构定义

现在,让我们考虑如何测试系统或编译器提供的类型和结构定义。在编写跨平台网络软件时,人们很快就会意识到,机器之间发送的数据需要以一种不依赖于特定 CPU 或操作系统架构的方式进行格式化。一些系统的本地整数大小是 32 位,而另一些则是 64 位。有些系统将整数值从最低有效字节到最高有效字节存储在内存和磁盘中,而另一些则相反。

让我们考虑一个例子。当使用 C 语言结构格式化网络消息时,你将遇到的第一个障碍是缺乏从一个平台到另一个平台具有相同大小的基本 C 语言类型。一个 32 位机器字大小的 CPU 可能会有一个 32 位的intunsigned类型。C 语言中基本整数类型的大小是实现定义的。这是设计使然,目的是允许实现使用对每个平台最优的charshortintlong的大小。

尽管这一语言特性对于优化设计为在单个平台上运行的软件非常有用,但在选择类型以便将数据在平台之间移动时却并不太有帮助。为了解决这个问题,工程师们尝试了从将网络数据作为字符串发送(如 XML 和 JSON)到发明自己的大小类型的各种方法。

为了弥补语言中的这一不足,C99 标准提供了大小类型intN_tuintN_t,其中N可以是8163264。不幸的是,并不是所有今天的编译器都提供这些类型。(不足为奇的是,GNU C 已经领先一段时间,提供了通过包含stdint.h头文件来支持 C99 大小的类型。)

为了在某种程度上缓解这一痛苦,Autoconf 提供了宏来确定 C99 特定的标准化类型是否存在于用户的平台上,然后在它们不存在时进行定义。例如,你可以在configure.ac中添加一个AC_TYPE_UINT16_T的调用,以确保uint16_t在你的用户平台上存在,不管是作为stdint.h中的系统定义,还是作为更为普遍的非标准inttypes.h,或者作为 Autoconf 在config.h中的定义。

这些针对整数类型的编译器测试通常由配置脚本编写,作为一段类似于示例 5-21 中的 C 代码。

int main()
{
 ➊ static int test_array[1 - 2 * !((uint16_t) -1 >> (16 - 1) == 1)];
    test_array[0] = 0;
    return 0;
}

示例 5-21:编译器检查 uint16_t 的正确实现

你会注意到,在示例 5-21 中,重要的代码行在 ➊ 位置,即 test_array 的声明位置。Autoconf 依赖于一个事实,即所有 C 编译器在你尝试定义一个负大小的数组时都会生成错误。如果在该平台上 uint16_t 不是恰好为 16 位的无符号数据,则数组大小将是负数。

还要注意,示例中的带括号的表达式是一个编译时表达式。^(14) 是否能通过更简单的语法来实现,这谁也说不清,但这段代码在所有 Autoconf 支持的编译器中都能成功运行。只有在满足以下三个条件时,数组才会被定义为非负大小:

  • uint16_t 在其中一个包含的头文件中定义。

  • uint16_t 的大小恰好是 16 位。

  • uint16_t 在此平台上是无符号的。

按照示例 5-22 中显示的模式使用此宏提供的定义。即使在没有 stdint.hinttypes.h 的系统上,Autoconf 也会在 config.h 中添加代码,如果系统的头文件没有提供 uint16_t,它将会定义该类型,这样你就可以在源代码中使用该类型,而无需额外的测试。

#include "config.h"

#if HAVE_STDINT_H
# include <stdint.h>
#elif HAVE_INTTYPES_H
# include <inttypes.h>
#endif
--snip--
uint16_t x;
--snip--

示例 5-22:正确使用 Autoconf 的 uint16_t 定义的源代码

Autoconf 提供了几十种类型检查,如 AC_TYPE_UINT16_T,详情请参见《GNU Autoconf 手册》的第 5.9 节。此外,通用类型检查宏 AC_CHECK_TYPES 允许你指定一个逗号分隔的、你项目需要的可疑类型列表。

注意

这个列表使用逗号分隔,因为某些定义(比如 struct fooble)可能包含空格。由于它们是用逗号分隔的,因此如果列出多个类型,必须使用 Autoconf 的方括号引号围绕这个参数。

这是 AC_CHECK_TYPES 的正式声明:

AC_CHECK_TYPES(types, [action-if-found], [action-if-not-found],
    [includes = 'AC_INCLUDES_DEFAULT'])

如果你没有在最后一个参数中指定头文件列表,则在编译器测试中将使用默认头文件,通过宏 AC_INCLUDES_DEFAULT 来实现,宏扩展为示例 5-23 中显示的文本。

#include <stdio.h>
#ifdef HAVE_SYS_TYPES_H
# include <sys/types.h>
#endif
#ifdef HAVE_SYS_STAT_H
# include <sys/stat.h>
#endif
#ifdef STDC_HEADERS
# include <stdlib.h>
# include <stddef.h>
#else
# ifdef HAVE_STDLIB_H
#  include <stdlib.h>
# endif
#endif
#ifdef HAVE_STRING_H
# if !defined STDC_HEADERS && defined HAVE_MEMORY_H
#  include <memory.h>
# endif
# include <string.h>
#endif
#ifdef HAVE_STRINGS_H
# include <strings.h>
#endif
#ifdef HAVE_INTTYPES_H
# include <inttypes.h>
#endif
#ifdef HAVE_STDINT_H
# include <stdint.h>
#endif
#ifdef HAVE_UNISTD_H
# include <unistd.h>
#endif

示例 5-23:Autoconf 版本 2.69 中 AC_INCLUDES_DEFAULT 的定义

如果你知道你的类型没有在这些头文件之一中定义,那么你应该指定一个或多个需要包含在测试中的头文件,如示例 5-24 所示。这个列表首先包括默认的头文件,然后是额外的头文件(通常仍然需要一些默认头文件)。

   AC_CHECK_TYPES([struct doodah], [], [], [
➊ AC_INCLUDES_DEFAULT
   #include<doodah.h>
   #include<doodahday.h>])

示例 5-24:在检查 struct doodah 时使用非默认的 include

请注意在清单 5-24 中的➊,我将宏的最后一个参数分成了三行写在configure.ac文件中,并且没有缩进。这个参数的文本会原样包含在测试源文件中,因此你需要确保你放入这个参数中的内容在你使用的编程语言中是有效的代码。

注意

与测试相关的问题通常是开发者在使用 Autoconf 时抱怨的类型。当你遇到这种语法问题时,请检查config.log文件,里面包含了所有失败测试的完整源代码,包括在编译测试时生成的编译器输出。这些信息通常能提供解决问题的线索

AC_OUTPUT 宏

最后,我们来到了AC_OUTPUT宏,它在configure中展开为 shell 代码,根据前面宏展开中指定的数据生成config.status脚本。所有其他宏必须在AC_OUTPUT展开之前使用,否则它们对你生成的configure脚本将没有多大价值。(额外的 shell 脚本可以在AC_OUTPUT之后放入configure.ac中,但它不会影响config.status执行的配置或文件生成。)

考虑在AC_OUTPUT后添加 shell 的echoprintf语句,告诉用户构建系统是如何根据指定的命令行选项进行配置的。你还可以使用这些语句告诉用户make的其他有用目标。例如,我们可以在 Jupiter 的configure.ac文件中,在AC_OUTPUT之后添加代码,如清单 5-25 所示。

Git 标签 5.8

--snip--
AC_OUTPUT

cat << EOF
-------------------------------------------------

${PACKAGE_NAME} Version ${PACKAGE_VERSION}

Prefix: '${prefix}'.
Compiler: '${CC} ${CFLAGS} ${CPPFLAGS}'

Package features:
  Async Execution: ${async_exec}

Now type 'make @<:@<target>@:>@'
  where the optional <target> is:
    all                - build all binaries
    install            - install everything

--------------------------------------------------
EOF

清单 5-25: configure.ac: 将配置摘要文本添加到configure的输出中

configure.ac文件的末尾添加这样的输出是一个方便的项目功能,因为它可以让用户一目了然地看到配置过程中发生了什么。由于像async_exec这样的变量会根据配置设置为yesno,用户可以看到请求的配置是否真正生效。

注意

Autoconf 版本 2.62(及之后版本)比早期版本更好地解读用户关于方括号使用的意图。在过去,你可能需要使用四重符号强制 Autoconf 显示一个方括号,但现在你可以直接使用字符本身。大多数发生的问题是由于没有正确引用参数。这种增强的功能主要来自 Autoconf 库宏的增强,它们可能接受带方括号字符的参数。为了确保方括号在你自己的configure.ac代码中不会被误解,你应该阅读“引用规则”中的 M4 双重引号部分,详见第 438 页

四重符号

在清单 5-25 中,围绕单词<target>的那些有趣的字符序列被称为四字符序列,简称四字符组。它们与转义序列的作用相同,但四字符组比转义字符或转义序列更可靠,因为它们永远不会受到歧义的影响。

序列@<:@是方括号字符的四字符序列,而@:>@是闭合方括号字符的四字符序列。这些四字符组将始终autom4te输出为字面意义上的方括号字符。这发生在 M4 处理完文件后,因此没有机会将它们误解为 Autoconf 的引号字符。

如果你有兴趣更详细地研究四字符组,请查阅GNU Autoconf 手册的第八部分。

摘要

在本章中,我们介绍了许多项目的configure.ac文件中发现的一些更高级的结构。我们从生成替代变量所需的宏开始。我称这些为“高级”宏,因为许多更高级别的 Autoconf 宏在内部使用AC_SUBSTAC_DEFINE,使它们对你来说有些透明。然而,了解这些宏有助于你理解 Autoconf 的工作原理,并为你学习编写自己的宏提供必要的背景信息。

我们讲解了编译器和其他工具的检查,以及在用户系统上检查一些不太常见的数据类型和结构。本章中的示例旨在帮助你理解 Autoconf 类型和结构定义检查宏的正确使用方法,以及其他相关宏。

我们还研究了一种调试复杂 Autoconf 宏使用的技巧:在configure.ac中的宏调用周围使用栅栏,快速定位configure中生成的相关文本。我们查看了库和头文件的检查,并审视了这些 Autoconf 宏正确使用的一些细节。我们详细探讨了构建一个健壮且用户友好的配置过程,包括向 Autoconf 生成的configure脚本添加项目特定的命令行选项。

最后,我们讨论了AC_OUTPUT宏在configure.ac中的正确位置,以及添加一些总结生成的 Shell 代码,旨在帮助用户了解在其系统上配置你的项目时发生了什么。

从第四章和第五章中要带走的一个重要 Autoconf 概念,在第四章的开头就已明确指出:Autoconf 从你写入 configure.ac 的 shell 源代码生成 shell 脚本。这意味着,只要你理解所调用宏的正确用法,你就能对最终生成的配置脚本拥有 完全 的控制权。事实上,你可以在 configure.ac 中做任何你想做的事情。Autoconf 宏的存在,仅仅是为了让你选择的操作更加一致,并且更容易编写。你越少依赖 Autoconf 宏来执行配置任务,你的用户在配置过程中与其他开源项目相比,就越不一致。

下一章将暂时离开 Autoconf,我们将关注 GNU Automake,这是一个 Autotools 工具链的附加组件,它抽象化了为软件项目创建功能强大的 makefile 的许多细节。

第六章:使用 Automake 自动生成 Makefile

*如果你理解了,事情就是它们本来的样子;如果你不理解,事情就是它们本来的样子。

—禅宗格言*

Image

在 Autoconf 开始走向成功后不久,David MacKenzie 开始着手开发一个用于自动生成 GNU 项目 makefile 的新工具:Automake。在GNU 编码标准(GCS)的早期开发中,MacKenzie 意识到,因为GCS对项目产品的构建、测试和安装的要求非常具体,很多 GNU 项目的 makefile 实际上是模板内容。Automake 利用这一点,使得维护者的工作更加轻松,并且让用户的体验更加一致。

MacKenzie 在 Automake 上的工作持续了近一年,直到 1994 年 11 月左右结束。一年后,即 1995 年 11 月,Tom Tromey(来自 Red Hat 和 Cygnus)接管了 Automake 项目,并在其发展中发挥了重要作用。尽管 MacKenzie 最初用 Bourne shell 脚本编写了 Automake 的版本,Tromey 完全用 Perl 重写了这个工具,并在接下来的五年中继续维护和增强 Automake。

到 2000 年底,Alexandre Duret-Lutz 几乎接管了 Automake 项目的维护工作。他作为项目负责人一直持续到大约 2007 年中期,此时 Ralf Wildenhues^(1)接手了项目,偶尔有 Akim Demaille 和 Jim Meyering 的参与。从 2012 年到 2017 年初,Stefano Lattarini 在为 Google 瑞士分部工作期间负责 Automake 的维护。现任维护者是 Mathieu Lirzin,他是法国波尔多大学计算机科学硕士生。

我所见到的大多数关于 Autotools 的抱怨最终都与 Automake 有关。原因很简单:Automake 为构建系统提供了最高级别的抽象,并且对使用它的项目强加了一个相当严格的结构。Automake 的语法简洁——实际上,它是简练的,几乎到了极致。一个 Automake 语句代表了大量功能。但是,一旦你理解了它,你就能在短时间内(也就是几分钟,而不是几个小时或几天)建立起一个相对完整、复杂且功能正确的构建系统。

在本章中,我将为你提供一些关于 Automake 内部工作原理的见解。通过这些见解,你将不仅对 Automake 能为你做什么感到熟悉,而且会开始在其自动化不足的领域进行扩展。

开始正式工作

让我们面对现实吧——正确编写 Makefile 通常很难。正如人们所说,魔鬼藏在细节中。考虑在我们继续改进 Jupiter 项目的构建系统时,对项目目录结构中的文件进行以下更改。让我们从清理工作区开始。你可以使用 make distclean 来完成这项工作,或者如果你是从 GitHub 仓库工作区构建的,也可以使用 git clean 命令的某种形式:^(2)

Git 标签 6.0

   $ git clean -xfd
   --snip--
➊ $ rm bootstrap.sh Makefile.in src/Makefile.in
➋ $ echo "SUBDIRS = src" > Makefile.am
➌ $ echo "bin_PROGRAMS = jupiter
   > jupiter_SOURCES = main.c" > src/Makefile.am
➍ $ touch NEWS README AUTHORS ChangeLog
   $ ls -1
   AUTHORS
   ChangeLog
   configure.ac
   Makefile.am
   NEWS
   README
   src
   $

➊ 处的 rm 命令删除了我们手动编写的 Makefile.in 模板和我们为确保所有支持脚本和文件被复制到项目根目录中而编写的 bootstrap.sh 脚本。由于我们正在将 Jupiter 升级为正式的 Automake,因此不再需要这个脚本。(为了简洁起见,我在 ➋ 和 ➌ 使用了 echo 语句来写入新的 Makefile.am 文件;如果你愿意,可以使用文本编辑器。)

注意

在 ➌ 处的行末有一个硬回车符。Shell 会继续接受输入,直到引号关闭为止。

我在 ➍ 使用了 touch 命令来创建项目根目录中新的、空的 NEWSREADMEAUTHORSChangeLog 文件。(INSTALLCOPYING 文件是通过 autoreconf -i 添加的。)这些文件是 GCS 对所有 GNU 项目所要求的。尽管它们对于非 GNU 项目并非必需,但它们已经成为开源世界中的一种惯例;用户已经习惯了这些文件的存在。^(3)

注意

GCS 覆盖了这些文件的格式和内容。第 6.7 和 6.8 节分别讲解了 NEWSChangeLog 文件,第 7.3 节则涵盖了 READMEINSTALLCOPYING 文件。AUTHORS 文件是一个列出需要给与归属的人员(姓名和可选的电子邮件地址)清单。^(4)

维护 ChangeLog 文件可能有点痛苦——特别是因为你已经在为你的仓库提交添加提交信息时做过一次了。为了简化这个过程,考虑使用一个 shell 脚本在你做新版本发布之前,将仓库日志抓取到 ChangeLog 中。网络上有现成的脚本可供使用;例如,gnulib(见 第十三章)提供了 gitlog-to-changelog 脚本,可以用来在发布之前将 git 仓库的日志信息导入 ChangeLog 中。

在 configure.ac 中启用 Automake

为了在构建系统中启用 Automake,我向 configure.ac 添加了一行代码:在 AC_INITAC_CONFIG_SRCDIR 之间调用 AM_INIT_AUTOMAKE,如 列表 6-1 所示。

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.
AC_PREREQ([2.69])
AC_INIT([Jupiter], [1.0], [jupiter-bugs@example.org])
AM_INIT_AUTOMAKE
AC_CONFIG_SRCDIR([src/main.c])
--snip--

列表 6-1:向 configure.ac 添加 Automake 功能

如果你的项目已经使用 Autoconf 进行了配置,这将是启用 Automake 的唯一必要行,前提是配置文件configure.ac有效。AM_INIT_AUTOMAKE宏接受一个可选参数:一个由空格分隔的选项标签列表,可以将这些标签传递给此宏,以修改 Automake 的通用行为。有关每个选项的详细描述,请参阅GNU Automake 手册的第十七章。^(5) 但是,我将在这里指出一些最有用的选项。

gnits, gnu, foreign

这些选项设置 Automake 的严格性检查。默认值为gnugnits选项使 Automake 变得比原来更加挑剔,而foreign选项则稍微放宽一些——使用foreign时,你不需要像 GNU 项目那样强制要求INSTALLREADMEChangeLog文件。

check-news

check-news选项会导致如果项目的当前版本(来自configure.ac)没有出现在NEWS文件的前几行中,make dist命令失败。

dist-bzip2, dist-lzip, dist-xz, dist-shar, dist-zip, dist-tarZ

你可以使用dist-*选项来更改默认的分发包类型。默认情况下,make dist会生成一个.tar.gz文件,但开发者常常希望分发例如.tar.xz格式的包。这些选项使得更改变得非常简单。(即使没有dist-xz选项,你也可以通过使用make dist-xz来覆盖当前的默认设置,但如果你总是希望构建.xz包,使用该选项会更简单。)

readme-alpha

readme-alpha选项会在项目的 Alpha 版本发布期间临时更改构建和分发过程的行为。使用此选项会自动分发项目根目录中的名为README-alpha的文件。使用此选项还会更改项目的版本控制方案。

-W category, --warnings=category

-W category--warnings=category 选项表示项目希望使用 Automake 并启用各种警告类别。可以使用多个这样的选项,每个选项可以有不同的类别标签。请参考GNU Automake 手册,查找有效类别的列表。

parallel-tests

parallel-tests功能允许在执行check目标时并行执行检查,以便在多处理器机器上利用并行执行。

subdir-objects

subdir-objects选项在你打算引用当前目录以外的目录中的源代码时是必需的。使用此选项会导致 Automake 生成make命令,使得目标文件和中间文件与源文件生成在同一目录下。有关此选项的更多信息,请参阅“非递归 Automake”部分,见第 175 页。

version

version 选项实际上是一个占位符,用于表示此项目接受的最低版本的 Automake 版本号。例如,如果传入 1.11 作为选项标记,如果 Automake 的版本低于 1.11,则在处理 configure.ac 时会失败。如果你打算使用只有较新版本的 Automake 才支持的功能,这会非常有用。

现在,已经有了新的 Makefile.am 文件,并且在 configure.ac 中启用了 Automake,接下来我们可以运行 autoreconf 并使用 -i 选项,以便为我们的项目添加 Automake 可能需要的任何新工具文件:

$ autoreconf -i
configure.ac:11: installing './compile'
configure.ac:6: installing './install-sh'
configure.ac:6: installing './missing'
Makefile.am: installing './INSTALL'
Makefile.am: installing './COPYING' using GNU General Public License v3 file
Makefile.am:     Consider adding the COPYING file to the version control system
Makefile.am:     for your code, to avoid questions about which license your
project uses src/Makefile.am: installing './depcomp'
$
$ ls -1p
aclocal.m4
AUTHORS
autom4te.cache/
ChangeLog
compile
config.h.in
configure
configure.ac
COPYING
depcomp
INSTALL
install-sh
Makefile.am
Makefile.in
missing
NEWS
README
src/
$

AM_INIT_AUTOMAKE 宏添加到 configure.ac 中,会导致 autoreconf -i 现在执行 automake -i,这将包括一些额外的工具文件:aclocal.m4install-shcompilemissingdepcomp。此外,Automake 现在会从 Makefile.am 生成 Makefile.in

我在第二章中提到过 aclocal.m4,在第四章中提到过 install-shmissing 脚本是一个小的辅助工具脚本,当命令行上指定的工具不可用时,它会打印一个格式化的错误信息。其实没有必要了解更多细节;如果你感兴趣,可以在项目目录中执行 ./missing --help

我们稍后会谈到 depcomp 脚本,但在此我想提一下 compile 脚本的目的。这个脚本是一些旧编译器的包装器,它们不理解同时使用 -c-o 命令行选项。当你使用特定产品的标志(我们稍后会讨论)时,Automake 必须生成代码,这些代码可能会多次编译源文件,每次编译使用不同的标志。因此,它必须为每组标志命名不同的目标文件。compile 脚本简化了这个过程。

Automake 还会添加默认的 INSTALLCOPYING 文本文件,这些文件包含与 GNU 项目相关的模板文本。你可以根据需要修改这些文件以适应你的项目。我发现默认的 INSTALL 文件内容对于与 Autotools 构建项目相关的通用指令非常有用,但在将其提交到我的代码库之前,我喜欢在文件顶部添加一些项目特定的信息。Automake 的 -i 选项在项目中已经包含这些文本文件时不会覆盖它们,因此,一旦通过 autoreconf -i 添加了这些文件,你可以根据需要修改这些默认文件。

COPYING 文件包含 GPL 许可证的文本,可能适用于或不适用于你的项目。如果你的项目是根据 GPL 许可发布的,只需保留该文本不变。如果你是根据其他许可证(如 BSD、MIT 或 Apache Commons 许可证)发布的,请将默认文本替换为适合该许可证的文本。^(6)

注意

你只需要在新检出的工作区或新创建的项目中使用一次-i选项。添加缺失的工具文件后,除非你向configure.ac中添加某些宏,否则你可以在以后调用autoreconf时省略-i选项,添加的宏可能会导致使用-i选项以添加更多缺失的文件。我们将在后面的章节中看到这类情况。

上述命令创建了一个基于 Automake 的构建系统,包含了我们在原始Makefile.in模板中编写的所有内容(除了稍后会提到的check功能之外),但是这个系统在功能上更加完整,且符合GCS的规范。查看生成的Makefile.in模板,我们可以看到 Automake 为我们做了大量工作。生成的顶层Makefile.in模板几乎有 24KB,而原来的手工编写的 makefile 只有几百字节。

一个 Automake 构建系统支持以下重要的make目标(从 Automake 生成的Makefile派生):

all check clean ctags
dist dist-bzip2 dist-gzip dist-lzip
dist-shar dist-tarZ dist-xz dist-zip
distcheck distclean distdir dvi
html info install install-data
install-dvi install-exec install-html install-info
install-pdf install-ps install-strip installcheck
installdirs maintainer-clean mostlyclean pdf
ps tags ininstall

如你所见,这远远超出了我们在手动编写的Makefile.in模板中能提供的功能。Automake 将这些基础功能写入每个使用它的项目中。

一个隐藏的好处:自动依赖关系跟踪

在《依赖规则》一节中(见第 46 页),我们讨论了make依赖规则。这些规则是我们在 makefile 中定义的,以便make能够意识到 C 语言源文件与包含的头文件之间的隐含关系。Automake 花费了大量精力来确保你不需要为它能够理解的语言(如 C、C++和 Fortran)编写这些依赖规则。这是对于包含多个源文件的项目来说非常重要的一个特性。

手动为数十或数百个源文件编写依赖规则既繁琐又容易出错。事实上,这已经成为一个问题,以至于编译器作者经常提供一种机制,使编译器能够根据其对源文件和语言的内部知识自动编写这些规则。GNU 编译器等支持一系列-M选项(-M-MM-MF-MG等)。这些选项告诉编译器为指定的源文件生成一个make依赖规则。(其中一些选项可以在正常的编译命令行中使用,因此可以在源文件被编译时生成依赖规则。)

这些选项中最简单的是基本的 -M 选项,它使编译器为指定的源文件在 stdout 上生成一个依赖关系规则,然后终止。这个规则可以被捕获到一个文件中,随后由 makefile 包含,从而将该规则中的依赖关系信息纳入到 make 构建的有向图中。

但是,当系统中的本地编译器不提供依赖关系生成选项,或者它们与编译过程无法配合工作时,会发生什么情况呢?在这种情况下,Automake 提供了一个名为depcomp的包装脚本,该脚本会执行两次编译:第一次生成依赖关系信息,第二次编译源文件。当编译器缺少生成任何依赖关系信息的选项时,可以使用另一个工具递归地确定哪些头文件会影响给定的源文件。在没有这些选项可用的系统上,自动依赖关系生成将失败。

注意

关于依赖关系生成编译器选项的更详细描述,请参阅第 529 页的“项目 10:使用生成的源代码”。关于 Automake 依赖关系管理的更多内容,请参阅 GNU Automake 手册的相关章节。

现在是时候咬紧牙关,尝试一下了。和上一章的构建系统一样,运行 autoreconf(由于我们之前运行过 autoreconf -i,所以这是可选的,但没有害处),接着运行 ./configuremake

$ autoreconf
$ ./configure
--snip--
$ make
make  all-recursive
make[1]: Entering directory '/.../jupiter'
Making all in src
make[2]: Entering directory '/.../jupiter/src'
gcc -DHAVE_CONFIG_H -I. -I..         -g -O2 -MT main.o -MD -MP -MF .deps/main.Tpo -c -o main.o main.c
mv -f .deps/main.Tpo .deps/main.Po
gcc  -g -O2   -o jupiter main.o  -lpthread
make[2]: Leaving directory '/.../jupiter/src'
make[2]: Entering directory '/.../jupiter'
make[2]: Leaving directory '/.../jupiter'
make[1]: Leaving directory '/.../jupiter'
$

如果不尝试我们已经熟悉的其他 make 目标,你是无法真正体会到 Automake 所做的工作。自己尝试 installdistdistcheck 目标,以确认你在删除手写的 Makefile.in 模板之后,仍然拥有之前的所有功能。

注意

check 目标目前作为一个无操作的目标存在,但在我们能将测试加回来之前,我们需要更详细地研究 Automake 构造。当我们深入到这一部分时,你会发现它甚至比我们最初编写的代码更简单。

Makefile.am 文件实际上包含了什么?

在第四章中,我们讨论了 Autoconf 如何将一个包含 M4 宏的 shell 脚本作为输入,并生成同样的 shell 脚本,其中这些宏得到了完全展开。同样,Automake 将一个包含 Automake 命令的 makefile 作为输入。正如 Autoconf 的输入文件仅仅是增强版的 shell 脚本一样,Automake 的Makefile.am 文件也不过是标准的 makefile,只是额外包含了 Automake 特有的语法。

Autoconf 和 Automake 之间的一个显著区别是,Autoconf 输出的唯一文本是输入文件中现有的 shell 脚本,以及嵌入的 M4 宏展开后产生的任何附加 shell 脚本。另一方面,Automake 假设所有的 makefile 都应该包含一个最小的基础设施,用于支持GCS,除了你指定的任何目标和变量之外。

为了说明这一点,在 Jupiter 项目的根目录中创建一个 temp 目录,并向其中添加一个空的 Makefile.am 文件。接下来,用文本编辑器将这个新的 Makefile.am 添加到项目的 configure.ac 文件中,并从顶层的 Makefile.am 文件中引用它,如下所示:

   $ mkdir temp
   $ touch temp/Makefile.am
➊ $ echo "SUBDIRS = src temp" > Makefile.am
   $ vi configure.ac
   --snip--
   AC_CONFIG_FILES([Makefile
                    src/Makefile
                 ➋ temp/Makefile])
   --snip--
   $ autoreconf
   $ ./configure
   --snip--
   $ ls -1sh temp
   total 24K
➌ 12K Makefile
   0 Makefile.am
➍ 12K Makefile.in
   $

我在 ➊ 处使用了一个 echo 语句,重写了一个新的顶层 Makefile.am 文件,其中 SUBDIRS 同时引用 srctemp。我用文本编辑器将 temp/Makefile 添加到 Autoconf 将从模板生成的 makefile 列表中(➋)。如您所见,每个 makefile 中生成了一些 Automake 认为不可或缺的支持代码。即使是一个空的 Makefile.am 文件,也会生成一个 12KB 的 Makefile.in 模板(➍),然后 configure 从中生成一个类似大小的 Makefile(➌)。^(7)

由于 make 工具使用一套相当严格的规则来处理 makefile,Automake 对你额外的 make 代码有一些灵活的处理。以下是一些具体内容:

  • Makefile.am 文件中定义的 make 变量被放置在生成的 Makefile.in 模板的顶部,紧跟在任何 Automake 生成的变量定义之后。

  • Makefile.am 文件中指定的 make 规则被放置在生成的 Makefile.in 模板的末尾,紧跟在任何 Automake 生成的规则之后。

  • 大多数由 config.status 替换的 Autoconf 变量被转换为 make 变量,并初始化为那些替换变量。

make 工具不关心规则之间的位置关系,因为它会在处理任何规则之前,将每条规则读取到一个内部数据库中。变量也类似,只要它们在使用之前被定义。为了避免任何变量绑定问题,Automake 会将所有变量按定义顺序放在输出文件的顶部。

分析我们的新构建系统

现在让我们来看一下我们在这两个简单的 Makefile.am 文件中放了什么,从顶层的 Makefile.am 文件开始(如列表 6-2 所示)。

SUBDIRS = src

列表 6-2: Makefile.am: 顶层 Makefile.am 文件仅包含一个子目录引用。

这一行文本告诉 Automake 我们项目的几个信息:

  • 一个或多个子目录包含要处理的 makefile,除了这个文件之外。^(8)

  • 此空格分隔的目录列表应按指定的顺序处理。

  • 此列表中的目录应为所有主要目标递归处理。

  • 除非另有说明,否则此列表中的目录应视为项目分发的一部分。

和大多数 Automake 构造一样,SUBDIRS只是一个make变量,对于 Automake 有特殊的含义。SUBDIRS变量可用于处理具有任意复杂目录结构的Makefile.am文件,目录列表可以包含任何相对目录引用(不仅仅是直接的子目录)。可以说,SUBDIRS就像是在使用递归构建系统时,将 makefile 连接在项目目录层次结构中的“粘合剂”。

Automake 生成递归的make规则,这些规则在处理SUBDIRS列表中指定的目录后,隐式地处理当前目录,但有时需要在其他某些或所有目录之前构建当前目录。可以通过在SUBDIRS列表中的任何位置引用当前目录(使用点符号)来更改默认的构建顺序。例如,要在src目录之前构建顶层目录,可以按如下方式修改列表 6-2 中的SUBDIRS变量:

SUBDIRS = . src

现在让我们转到src目录中的Makefile.am文件,如列表 6-3 所示。

bin_PROGRAMS = jupiter
jupiter_SOURCES = main.c

列表 6-3: src/Makefile.am:这个 Makefile.am 文件的初始版本只包含两行

第一行是产品列表变量的规范,第二行是产品源变量的规范。

产品列表变量

产品在Makefile.am文件中通过产品列表变量(PLV)进行指定,像SUBDIRS一样,PLV 是make变量的一类,对 Automake 具有特殊意义。以下模板显示了 PLV 的常见格式:

[modifier-list]prefix_PRIMARY = product1 product2 ... productN

列表 6-3 中第一行的 PLV 名称由两部分组成:前缀bin)和主元素PROGRAMS),由下划线(_)分隔。该变量的值是由此Makefile.am文件生成的产品的一个以空格分隔的列表。

安装位置前缀

列表 6-3 中显示的产品列表变量的bin部分是一个安装位置前缀的示例。GCS定义了许多常见的安装位置,大多数在表 3-1 中列出,位于第 65 页。然而,任何以dir结尾且值为文件系统位置的make变量,都是有效的安装位置变量,并且可以作为 Automake PLV 中的前缀使用。

在 PLV 前缀中引用安装位置变量时,应省略变量名称中的dir部分。例如,在列表 6-3 中,当$(bindir) make变量用作安装位置前缀时,只需称其为bin

Automake 还识别四个以特殊 pkg 前缀开头的安装位置变量:pkglibdirpkgincludedirpkgdatadirpkglibexecdir。这些 pkg 版本的标准 libdirincludedirdatadirlibexecdir 变量表示列出的产品应安装在这些位置的子目录中,子目录名称与软件包相同。例如,在 Jupiter 项目中,带有 lib 前缀的 PLV 中列出的产品将安装到 $(libdir) 中,而带有 pkglib 前缀的 PLV 中列出的产品将安装到 $(libdir)/jupiter 中。

由于 Automake 从所有以 dir 结尾的 make 变量中推导出有效的安装位置和前缀列表,因此您可以提供自己的 PLV 前缀,指向自定义的安装位置。要将一组 XML 文件安装到系统数据目录中的 xml 目录,您可以在 清单 6-4 中的 Makefile.am 文件中使用代码。

xmldir = $(datadir)/xml
xml_DATA = file1.xml file2.xml file3.xml ...

清单 6-4:指定自定义安装目录

安装位置变量将包含由 Automake 生成的 makefile 或您在 Makefile.am 文件中定义的默认值,但用户总是可以在 configuremake 命令行中覆盖这些默认值。如果您不希望在特定构建过程中安装某些产品,请在命令行中的安装位置变量中指定空值;Automake 生成的规则将确保不安装目标目录中的产品。例如,要仅为一个软件包安装文档和共享数据文件,您可以输入 make bindir='' libdir='' install。^(9)

与安装无关的前缀

某些前缀与安装位置无关。例如,noinstcheckEXTRA 分别用于表示不安装的产品、仅用于测试的产品或可选构建的产品。以下是关于这三个前缀的更多信息:

noinst

表示列出的产品应该构建,但不需要安装。例如,一个所谓的静态 便利库 可能会作为中间产品构建,然后在构建过程的其他阶段中用于构建最终产品。noinst 前缀告诉 Automake 不应安装该产品,仅构建静态库。(毕竟,构建一个不会安装的共享库是没有意义的。)

检查

表示仅为测试目的构建的产品,因此不需要安装。在 PLV 中以 check 为前缀列出的产品仅在用户输入 make check 时才会构建。

EXTRA

用于列出有条件构建的程序。Automake 要求所有源文件都必须在Makefile.am文件中静态指定,而不是在构建过程中计算或推导,以便它能够生成一个适用于任何可能命令行的Makefile.in模板。然而,项目维护者可以选择允许某些产品根据传递给configure脚本的配置选项有条件地构建。如果产品在由configure脚本生成的变量中列出,它们也应该在Makefile.am文件中的 PLV 中列出,并以EXTRA为前缀。这个概念在清单 6-5 和 6-6 中有所说明。

AC_INIT(...)
--snip--
optional_programs=
AC_SUBST([optional_programs])
--snip--
if test "x$(build_opt_prog)" = xyes; then
➊ optional_programs=$(optional_programs) optprog
fi
--snip--

清单 6-5:在configure.ac 中定义的有条件构建的程序,存储在一个 Shell 变量中

➋ EXTRA_PROGRAMS = optprog
➌ bin_PROGRAMS = myprog $(optional_programs)

清单 6-6:在 Makefile.am 中使用EXTRA前缀有条件地定义产品

在清单 6-5 中的➊处,optprog被附加到一个名为optional_programs的 Autoconf 替代变量中。在清单 6-6 中的➋处,EXTRA_PROGRAMS变量列出了optprog,作为一个可能构建或不构建的产品,取决于最终用户的配置选择,这些配置决定了➌处的$(optional_programs)是否为空或包含optprog

尽管在configure.acMakefile.am中都指定optprog看起来可能是冗余的,但 Automake 需要在EXTRA_PROGRAMS中提供该信息,因为它无法尝试解释在configure.ac中定义的$(optional_programs)的可能值。因此,在这个示例中将optprog添加到EXTRA_PROGRAMS中,告诉 Automake 生成规则来构建它,即使$(optional_programs)在某次构建中不包含optprog

主要产品

主要产品就像产品类别,它们代表可能由构建系统生成的产品类型。一个主要产品定义了构建、测试、安装和执行特定类别产品所需的一组步骤。例如,程序和库使用不同的编译器和链接器命令来构建,Java 类需要虚拟机来执行,而 Python 程序需要解释器。某些产品类别,如脚本、数据和头文件,没有构建、测试或执行语义——只有安装语义。

支持的主要产品列表定义了可以由 Automake 构建系统自动构建的产品类别集合。Automake 构建系统仍然可以构建其他产品类别,但维护者必须在项目的Makefile.am文件中显式地定义make规则。

彻底理解 Automake 的主要产品是正确使用 Automake 的关键。目前支持的主要产品的完整列表如下。

PROGRAMS

当在 PLV 中使用PROGRAMS主要产品时,Automake 会生成使用编译器和链接器来构建列出产品的二进制可执行程序的make规则。

LIBRARIES/LTLIBRARIES

使用 LIBRARIES 主项会导致 Automake 生成规则,使用系统编译器和库管理器构建静态库(库文件)。LTLIBRARIES 主项做同样的事情,但生成的规则还会构建 Libtool 共享库,并通过 libtool 脚本执行这些工具(以及链接器)。 (我将在第七章和第八章中详细讨论 Libtool 包。) Automake 限制了 LIBRARIESLTLIBRARIES 主项的安装位置:它们只能安装在 $(libdir)$(pkglibdir) 中。

LISP

LISP 主项主要用于管理 Emacs Lisp 程序的构建。因此,它期望引用一个 .el 文件列表。您可以在 Automake 手册第 10.1 节中找到有关使用该主项的详细信息。

PYTHON

Python 是一种解释型语言;python 解释器逐行将 Python 脚本转换为 Python 字节码,并在转换的同时执行它,因此(像 shell 脚本一样)Python 源文件可以直接执行。使用 PYTHON 主项告诉 Automake 生成规则,将 Python 源文件 (.py) 预编译为标准 (.pyc) 和优化 (.pyo) 字节编译版本,使用 py-compile 工具进行编译。由于 Python 源代码通常是解释执行的,这种编译发生在安装时,而不是在构建时。

JAVA

Java 是一个虚拟机平台;使用 JAVA 主项会告诉 Automake 生成规则,使用 javac 编译器将 Java 源文件 (.java) 转换为 Java 类文件 (.class)。虽然这个过程是正确的,但并不完整。Java 程序(有实际意义的程序)通常包含多个类文件,通常以 .jar.war 文件打包,这些文件可能还包含多个附带的文本文件。JAVA 主项是有用的,但仅此而已。(我将在《使用 Autotools 构建 Java 源代码》一章中详细讨论如何使用——以及扩展——JAVA 主项,详情请见 第 408 页。)

脚本

在这个上下文中,脚本 指任何解释型文本文件——无论是 shell、Perl、Python、Tcl/Tk、JavaScript、Ruby、PHP、Icon、Rexx 还是其他任何类型的文件。Automake 允许为 SCRIPTS 主项设置受限的安装位置,包括 $(bindir)$(sbindir)$(libexecdir)$(pkgdatadir)。虽然 Automake 不会生成构建脚本的规则,但它也不假设脚本是项目中的静态文件。脚本通常由 Makefile.am 文件中的手写规则生成,有时通过使用 sedawk 工具处理输入文件。因此,脚本不会自动分发。如果您项目中有一个静态脚本,并希望 Automake 将其添加到您的分发归档中,则应像“PLV 和 PSV 修饰符”中所讨论的那样,在 SCRIPTS 主项前添加 dist 修饰符,详情请参见 第 161 页。

数据

任意数据文件可以通过 PLV 中的 DATA 主项进行安装。Automake 允许 DATA 主项的限制安装位置,包括 $(datadir)$(sysconfdir)$(sharedstatedir)$(localstatedir)$(pkgdatadir)。数据文件不会自动分发,因此如果你的项目包含静态数据文件,请在 DATA 主项上使用 dist 修饰符,如在 第 161 页的《PLV 和 PSV 修饰符》中所讨论的那样。

HEADERS

头文件是一种源文件形式。如果不是因为某些头文件已经被安装,它们本可以直接列在产品源代码中。包含已安装库产品公共接口的头文件会被安装到 $(includedir) 或由 $(pkgincludedir) 定义的包特定子目录中,因此此类已安装头文件的最常见 PLV 是 include_HEADERSpkginclude_HEADERS 变量。像其他源文件一样,头文件会自动分发。如果你有一个生成的头文件,请使用 nodist 修饰符与 HEADERS 主项一起使用,具体如在 第 161 页的《PLV 和 PSV 修饰符》中所讨论的那样。

MANS

Man 页面 是包含 troff 标记的 UTF-8 文本文件,用户查看时由 man 渲染。Man 页面可以通过 man_MANSmanN_MANS 产品列表变量安装,其中 N 代表介于 0 到 9 之间的单数字节或字母 l(用于数学库主题)或 n(用于 Tcl/Tk 主题)。man_MANS PLV 中的文件应具有表示其所属 man 部分的数字扩展名,从而指示其目标目录。manN_MANS PLV 中的文件可以使用数字扩展名或 .man 扩展名命名,并将在 make install 安装时重命名为相关的数字扩展名。项目的 man 页面默认不进行分发,因为 man 页面通常是生成的,因此你应该使用 dist 修饰符,如在 第 161 页的《PLV 和 PSV 修饰符》中所讨论的那样。

TEXINFOS

在 Linux 或 Unix 文档中,Texinfo^(10)是 GNU 项目的首选格式。makeinfo工具接受 Texinfo 源文件(.texinfo.txi.texi),并渲染包含 UTF-8 文本的 info 文件(.info),这些文本用 Texinfo 标记注释,info工具将其渲染为格式化文本供用户使用。与 Texinfo 源一起使用的最常见的产品列表变量是info_TEXINFOS。使用此 PLV 会导致 Automake 生成构建.info.dvi.ps.html文档文件的规则。然而,只有.info文件会在make all时构建,并通过make install安装。为了构建和安装其他类型的文件,必须在make命令行中明确指定dvipspdfhtmlinstall-dviinstall-psinstall-pdfinstall-html目标。由于许多 Linux 发行版默认未安装makeinfo工具,生成的.info文件会自动添加到分发归档中,以便最终用户不必去寻找makeinfo

产品源变量

清单 6-3 中的第二行是一个 Automake 产品源变量PSV)的示例。PSV 符合以下模板:

[modifier-list]product_SOURCES = file1 file2 ... fileN

和 PLV 一样,PSV 由多个部分组成:产品名称(此处为jupiter)和SOURCES标签。PSV 的值是一个由空格分隔的源文件列表,这些文件用于构建product。在清单 6-3 的第二行中,PSV 的值是用于构建jupiter程序的源文件列表。最终,Automake 将这些文件添加到生成的Makefile.in模板中的各种make规则依赖列表和命令中。

只有make变量中允许的字符(字母、数字、@符号和下划线)才允许出现在 PSV 的product标签中。因此,Automake 会对 PLV 中列出的产品名称进行转换,以呈现关联 PSV 中使用的product标签。Automake 将非法字符转换为下划线,如清单 6-7 所示。

➊ lib_LIBRARIES = libc++.a
➋ libc___a_SOURCES = ...

清单 6-7:非法的make变量字符在product标签中转换为下划线。

在这里,Automake 将 PLV 中的libc++.a(在➊处)转换为 PSV 的product标签libc___a(即三个下划线),以在Makefile.am文件中找到关联的 PSV(在➋处)。你必须了解这些转换规则,以便能够编写与产品匹配的 PSV。

PLV 和 PSV 修饰符

之前定义的 PLV 和 PSV 模板中的modifier-list部分包含一组可选的修饰符。以下类似 BNF 的规则定义了这些模板中modifier-list元素的格式:

modifier-list = modifier_[modifier-list]

修饰符改变了它们前置的变量的正常行为。当前定义的前缀修饰符集包括distnodistnobasenotrans

dist 修饰符表示一组应该分发的文件(即应该包含在通过执行 make dist 时生成的分发包中)。例如,假设某些产品的源文件应该被分发,而某些不应该被分发,清单 6-8 中展示的变量可能在产品的 Makefile.am 文件中定义。

dist_myprog_SOURCES = file1.c file2.c
nodist_myprog_SOURCES = file3.c file4.c

清单 6-8:在 Makefile.am 文件中使用 distnodist 修饰符

Automake 通常会从 HEADERS PLV 中的头文件列表中去除相对路径信息。nobase 修饰符用于抑制从子目录中通过 Makefile.am 文件获取的安装头文件路径信息的去除。例如,查看 清单 6-9 中的 PLV 定义。

nobase_pkginclude_HEADERS = mylib.h sys/constants.h

清单 6-9:在 Makefile.am 文件中使用 nobase PLV 修饰符

在这一行中,我们可以看到 mylib.hMakefile.am 位于同一目录下,而 constants.h 位于名为 sys 的子目录中。通常,两个文件都会通过 pkginclude 安装位置前缀安装到 $(pkgincludedir) 中。然而,由于我们使用了 nobase 修饰符,Automake 将保留第二个文件路径中的 sys/ 部分进行安装,constants.h 将被安装到 $(pkgincludedir)/sys 中。当你希望安装(目标)目录结构与项目(源代码)目录结构相同,并且文件在安装过程中被复制时,这非常有用。

notrans 修饰符可以用于 man 页面 PLV,针对那些在安装过程中不应被转换的 man 页面(通常,Automake 会生成规则,将 man 页面扩展名从 .man 重命名为 .N,其中 N01、...、9ln,当它们被安装时)。

你还可以使用 EXTRA 前缀作为修饰符。当与产品源变量(例如 jupiter_SOURCES)一起使用时,EXTRA 指定与 jupiter 产品直接关联的额外源文件,如 清单 6-10 中所示。

EXTRA_jupiter_SOURCES = possibly.c

清单 6-10:与产品 SOURCES 变量一起使用 EXTRA 前缀

在这里,possibly.c 是否被编译取决于在 configure.ac 中定义的一些条件。

单元测试:支持 make check

在 第三章,我们向 src/Makefile 中添加了代码,执行 jupiter 程序并检查当用户执行 check 目标时是否有正确的输出字符串。现在我们有足够的信息将我们的 check 目标测试重新添加到新的 Automake 构建系统中。我已经在 清单 6-11 中复制了 check 目标代码,以供接下来的讨论参考。

--snip--
check: all
        ./jupiter | grep "Hello from .*jupiter!"
        @echo "*** ALL TESTS PASSED ***"
--snip--

清单 6-11:来自 第三章 的 check 目标

幸运的是,Automake 对单元测试有很好的支持。为了将我们简单的 grep 测试重新添加到新的 Automake 生成的构建系统中,我们可以在 src/Makefile.am 的底部添加几行,如 Listing 6-12 所示。

Git 标签 6.1

   bin_PROGRAMS = jupiter
   jupiter_SOURCES = main.c

➊ check_SCRIPTS = greptest.sh
➋ TESTS = $(check_SCRIPTS)

   greptest.sh:

 echo './jupiter | grep "Hello from .*jupiter!"' > greptest.sh
         chmod +x greptest.sh

➌ CLEANFILES = greptest.sh

Listing 6-12: src/Makefile.am: 支持 check 目标所需的额外代码

➊ 处的 check_SCRIPTS 行是一个 PLV,它指向一个在构建时生成的脚本。由于前缀是 check,我们知道此行列出的脚本只有在用户输入 make check 时才会被构建。然而,我们必须提供一个 make 规则来构建脚本,并在执行 clean 目标时删除该文件。我们在 ➌ 处使用 CLEANFILES 变量,来扩展 Automake 在执行 make clean 时删除的文件列表。

➋ 处的 TESTS 行是 Listing 6-12 中的重要部分,因为它指示在用户执行 check 目标时会执行哪些目标。(由于 check_SCRIPTS 变量包含了这些目标的完整列表,我在这里简单引用了它,作为实际的 make 变量。)请注意,在这个特定的例子中,check_SCRIPTS 是多余的,因为 Automake 会生成规则,确保在执行测试之前,所有在 TESTS 中列出的程序都已经构建完毕。然而,当需要在执行 TESTS 列表中的程序之前构建额外的帮助脚本或程序时,check_* PLV 就变得重要了。

这里可能不是很明显,但由于我们添加了第一个测试,在运行 make check 之前,我们需要重新执行 autoreconf -i 以添加一个新的实用程序脚本:test-driver。你可以在 Automake 文档中找到明确说明必须这样做的地方,但更简单的方法是让构建系统告诉你在缺少某些内容时,必须执行 autoreconf (-i)。为了让你感受一下这个过程,让我们先不运行 autoreconf 来试试:

$ make check
Making check in src
make[1]: Entering directory '/.../jupiter/src'
 cd .. && /bin/bash /.../jupiter/missing automake-1.15 --gnu src/Makefile
parallel-tests: error: required file './test-driver' not found
parallel-tests:   'automake --add-missing' can install 'test-driver'
Makefile:255: recipe for target 'Makefile.in' failed
make[1]: *** [Makefile.in] Error 1
make[1]: Leaving directory '/.../jupiter/src'
Makefile:352: recipe for target 'check-recursive' failed
make: *** [check-recursive] Error 1
$

现在让我们先运行 autoreconf -i

$ autoreconf -i
parallel-tests: installing './test-driver'
$
$ make check
/bin/bash ./config.status --recheck
running CONFIG_SHELL=/bin/bash /bin/bash ./configure --no-create --no-recursion
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... /bin/mkdir -p
--snip--
Making check in src
make[1]: Entering directory '/.../jupiter/src'
make  greptest.sh
make[2]: Entering directory '/.../jupiter/src'
echo './jupiter | grep "Hello from .*jupiter!"' > greptest.sh
chmod +x greptest.sh
make[2]: Leaving directory '/.../jupiter/src'
make  check-TESTS
make[2]: Entering directory '/.../jupiter/src'
make[3]: Entering directory '/.../jupiter/src'
PASS: greptest.sh
============================================================================
Testsuite summary for Jupiter 1.0
============================================================================
# TOTAL: 1
# PASS:  1
# SKIP:  0
# XFAIL: 0
# FAIL:  0
# XPASS: 0
# ERROR: 0
============================================================================
make[3]: Leaving directory '/.../jupiter/src'
make[2]: Leaving directory '/.../jupiter/src'
make[1]: Leaving directory '/.../jupiter/src'
make[1]: Entering directory '/.../jupiter'
make[1]: Leaving directory '/.../jupiter'
$

运行 autoreconf -i 后(并且注意到 test-driver 已经安装到我们的项目中),我们可以看到 make check 现在成功运行了。

请注意,在运行 autoreconf -i 后,我不需要手动调用 configure。构建系统通常足够智能,知道何时应该为你重新执行 configure

使用便捷库减少复杂性

Jupiter 作为开源软件项目来说相当简单,因此为了突出 Automake 的一些关键特性,我们将稍微扩展它。我们将首先添加一个便捷库,然后修改 jupiter 来使用这个库。

便利库是一个静态库,仅在包含它的项目中使用。这种临时库通常用于当一个项目中的多个二进制文件需要集成相同的源代码时。我将把main.c中的代码移动到一个库源文件,并从jupitermain例程中调用这个库中的函数。首先,从项目的顶级目录执行以下命令:

Git 标签 6.2

$ mkdir common
$ touch common/jupcommon.h
$ cp src/main.c common/print.c
$ touch common/Makefile.am
$

现在将列表 6-13 和 6-14 中的高亮文本分别添加到新建的common目录中的.h.c文件中。

int print_routine(const char * name);

列表 6-13: common/jupcommon.h: 这个文件的初始版本

#include "config.h"

#include "jupcommon.h"

#include <stdio.h>
#include <stdlib.h>

#if HAVE_PTHREAD_H
# include <pthread.h>
#endif

static void * print_it(void * data)
{
    printf("Hello from %s!\n", (const char *)data);
    return 0;
}

int print_routine(const char * name)
{
#if ASYNC_EXEC
    pthread_t tid;
    pthread_create(&tid, 0, print_it, (void*)name);
    pthread_join(tid, 0);
#else
    print_it(name);
#endif
    return 0;
}

列表 6-14: common/print.c: 这个文件的初始版本

如你所见,print.c仅仅是main.c的一个副本,经过了几个小的修改(在列表 6-14 中高亮显示)。首先,我将main重命名为print_routine,然后在包含config.h之后添加了对jupcommon.h头文件的包含。这个头文件将print_routine的原型提供给src/main.c,在那里它从main中被调用。

接下来,我们按列表 6-15 所示修改src/main.c,然后将列表 6-16 中的文本添加到common/Makefile.am中。

#include "config.h"

#include "jupcommon.h"

int main(int argc, char * argv[])
{
    return print_routine(argv[0]);
}

列表 6-15: src/main.c: 修改以让main调用新库

注意

config.h 包含在 src/main.c 的顶部可能显得有些奇怪,因为在那个源文件中似乎没有什么地方使用它。GCS建议遵循标准做法,将config.h包含在所有源文件的顶部,所有其他包含语句之前,以防其他包含的头文件中的某些内容使用了config.h中的定义。我建议严格遵循这个做法*。

noinst_LIBRARIES = libjupcommon.a
libjupcommon_a_SOURCES = jupcommon.h print.c

列表 6-16: common/Makefile.am: 这个文件的初始版本

让我们来看看这个新的Makefile.am文件。第一行指示了这个文件应该构建和安装哪些产品。noinst前缀表示这个库仅仅是为了方便在common目录中使用源代码。

我们正在创建一个名为libjupcommon.a的静态库,也叫做归档。归档类似于.tar文件,只包含目标文件(.o)。它们不能像共享库那样被执行或加载到进程地址空间中,但可以像目标文件一样被添加到链接命令行中。链接器足够聪明,能够识别出这些归档只是目标文件的集合。

注意

链接器会将命令行中显式指定的每个目标文件添加到二进制产物中,但它们只从库中提取实际在链接的代码中引用的目标文件。因此,如果你链接到一个包含 97 个目标文件的静态库,但你只直接或间接调用其中两个文件的函数,那么只有这两个目标文件会被添加到你的程序中。相反,链接到 97 个原始目标文件则会将所有 97 个文件添加到你的程序中,无论你是否使用其中的任何功能

列表 6-16 中的第二行是一个产品源变量,包含与该库关联的源文件列表。^(11)

产品选项变量

现在我们需要向src/Makefile.am中添加一些额外的信息,以便生成的Makefile能够找到我们添加到common目录中的新库和头文件。让我们向现有的Makefile.am文件中再添加两行,如列表 6-17 所示。

   bin_PROGRAMS = jupiter
   jupiter_SOURCES = main.c
➊ jupiter_CPPFLAGS = -I$(top_srcdir)/common
➋ jupiter_LDADD = ../common/libjupcommon.a
   --snip--

列表 6-17: src/Makefile.am: Makefile.am 文件中添加编译器和链接器指令

jupiter_SOURCES变量一样,这两个新变量是从程序名称派生的。这些产品选项变量(POVs)用于指定构建工具所使用的与产品相关的选项,这些工具从源代码构建产品。

jupiter_CPPFLAGS变量在➊处将产品特定的 C 预处理器标志添加到jupiter程序的所有源文件的编译器命令行中。-I$(top_srcdir)/common指令告诉 C 预处理器将$(top_srcdir)/common添加到其查找头文件引用的位置列表中。^(12)

jupiter_LDADD变量在➋处将库添加到jupiter程序的链接器命令行中。文件路径../common/libjupcommon.a仅仅是将一个目标文件添加到链接器命令行,以便该库中的代码能够成为最终程序的一部分。

注意

你还可以使用$(top_builddir)代替../来引用此路径中common目录的位置。使用$(top_builddir)的附加优势在于,它使得将这个Makefile.am文件移动到另一个位置时无需修改它变得更加简单

program_LDADDlibrary_LIBADD变量中添加一个库仅在该库作为你自己包的一部分构建时才需要。如果你正在将程序与已经安装在用户系统上的库进行链接,则在configure.ac中调用AC_CHECK_LIBAC_SEARCH_LIBS将使生成的configure脚本通过LIBS变量将适当的引用添加到链接器命令行中。

Automake 支持的 POV 集合主要来源于 Table 3-2 中列出的标准用户变量的子集,见 第 71 页。你可以在 GNU Autoconf Manual 中找到程序和库选项变量的完整列表,但这里列出了一些重要的变量。

product_CPPFLAGS

使用 product_CPPFLAGS 将标志传递给 C 或 C++ 预处理器,添加到编译器命令行中。

product_CFLAGS

使用 product_CFLAGS 将 C 编译器标志传递到编译器命令行中。

product_CXXFLAGS

使用 product_CXXFLAGS 将 C++ 编译器标志传递到编译器命令行中。

product_LDFLAGS

使用 product_LDFLAGS 将全局和与顺序无关的共享库与程序链接器配置标志和选项传递给链接器,包括 -static-version-info-release 等。

program_LDADD

使用 program_LDADD 将 Libtool 对象(.lo)或库(.la)或非 Libtool 对象(.o)或档案(.a)添加到链接命令行中,进行程序链接。^(13)

library_LIBADD

使用 library_LIBADD 将非 Libtool 链接器对象和档案添加到非 Libtool 档案的 ar 工具命令行中。ar 工具将把命令行中提到的档案并入产品档案,因此你可以使用此变量将多个档案合并成一个。

ltlibrary_LIBADD

使用 ltlibrary_LIBADD 将 Libtool 链接器对象(.lo)和 Libtool 静态或共享库(.la)添加到 Libtool 静态或共享库中。

你可以使用此列表中的最后三个选项变量,将依赖顺序的静态和共享库引用传递给链接器。你也可以使用这些选项变量来传递 -L-l 选项。以下是可接受的格式:-Llibpath-llibname[relpath/]archive.a[relpath/]objfile.$(OBJEXT)[relpath/]ltobject.lo,以及 [relpath/]ltarchive.la。(请注意,术语 relpath 表示项目中的相对路径,可以是相对目录引用,使用点,或 $(top_builddir)。)

每个 Makefile 的选项变量

你通常会看到 Automake 变量 AM_CPPFLAGSAM_LDFLAGS 被用在 Makefile.am 文件中。这些每个 Makefile 的形式用于当维护者想要将相同的标志集应用于 Makefile.am 文件中所有指定的产品时。^(14) 例如,如果你需要为 Makefile.am 文件中的所有产品设置一组预处理器标志,然后为特定产品(prog1)添加额外的标志,你可以使用 Listing 6-18 中展示的语句。

   AM_CFLAGS = ... some flags ...
   --snip--
➊ prog1_CFLAGS = $(AM_CFLAGS) ... more flags ...
   --snip--

Listing 6-18: 同时使用每个产品和每个文件的标志

每个产品变量的存在会覆盖 Automake 对每个 makefile 变量的使用,因此您需要在每个产品变量中引用每个 makefile 变量,以便让 makefile 变量影响该产品,如清单 6-18 中的 ➊ 所示。为了允许每个产品变量覆盖它们对应的 makefile 变量,最好首先引用 makefile 变量,然后再添加任何特定于产品的选项。

注意

用户变量,例如CFLAGS,是为最终用户保留的,配置脚本或 makefile 不应设置这些变量。Automake 会始终将它们附加到适当的工具命令行,从而允许用户覆盖 makefile 中指定的选项

构建新库

接下来,我们需要编辑顶级 Makefile.am 文件中的 SUBDIRS 变量,以包括我们刚刚添加的新的 common 目录。我们还需要将 common 目录中生成的新 makefile 添加到 configure.acAC_CONFIG_FILES 宏调用生成的文件列表中。这些更改如清单 6-19 和清单 6-20 所示。

SUBDIRS = common src

清单 6-19: Makefile.am: 将公共目录添加到 SUBDIRS 变量

--snip--
AC_CONFIG_FILES([Makefile
                 common/Makefile
                 src/Makefile])
--snip--

清单 6-20: configure.ac: common/Makefile 添加到 AC_CONFIG_FILES

这是我们到目前为止所做的最大一组更改,但我们正在重新组织整个应用程序,因此这是合理的。让我们尝试一下更新后的构建系统。将 -i 选项添加到 autoreconf 命令行中,以便在这些增强之后安装可能需要的任何额外缺失文件。经过这么多更改后,我喜欢从一个干净的环境开始,因此可以先执行 make distclean,或者如果你是从 Git 仓库的工作区运行,则使用某种形式的 git clean 命令。

   $ make distclean
   --snip--
   $ autoreconf -i
   configure.ac:11: installing './compile'
   configure.ac:6: installing './install-sh'
   configure.ac:6: installing './missing'
   Makefile.am: installing './INSTALL'
   Makefile.am: installing './COPYING' using GNU General Public License v3 file
   Makefile.am:     Consider adding the COPYING file to the version control
   system
   Makefile.am:     for your code, to avoid questions about which license your
   project uses
➊ common/Makefile.am:1: error: library used but 'RANLIB' is undefined
   common/Makefile.am:1:  The usual way to define 'RANLIB' is to add 'AC_PROG_
   RANLIB'
   common/Makefile.am:1:  to 'configure.ac' and run 'autoconf' again.
   common/Makefile.am: installing './depcomp'
   parallel-tests: installing './test-driver'
   autoreconf: automake failed with exit status: 1
   $

看来我们还没有完全完成。由于我们向构建系统中添加了一种新的实体——静态库,automake(通过 autoreconf)在 ➊ 告诉我们,我们需要向 configure.ac 文件中添加一个新的宏 AC_PROG_RANLIB。^(15)

如清单 6-21 所示,将此宏添加到 configure.ac 文件中。

--snip--
# Checks for programs.
AC_PROG_CC
AC_PROG_INSTALL
AC_PROG_RANLIB
--snip--

清单 6-21: configure.ac: 添加 AC_PROG_RANLIB

最后,再次输入 autoreconf -i。添加 -i 确保如果我们在 configure.ac 中添加的新功能需要安装任何额外的文件,autoreconf 会执行安装。

$ autoreconf -i
$

没有更多的抱怨;一切顺利。

什么应该包含在发布包中?

Automake 通常会自动确定在使用make dist创建的分发包中应该包含哪些文件,因为它非常清楚每个文件在构建过程中的作用。为此,Automake 需要知道每个用于构建产品的源文件以及每个安装的文件和产品。这意味着,当然,所有文件必须在某个时刻通过一个或多个 PLV 和 PSV 变量来指定。^(16)

Automake 的 EXTRA_DIST 变量包含一个以空格分隔的文件和目录列表,这些文件和目录应当在执行 dist 目标时添加到分发包中。例如:

EXTRA_DIST = windows

你可以使用 EXTRA_DIST 变量将一个源代码目录添加到分发包中,而 Automake 并不会自动添加它——例如,一个特定于 Windows 的目录。

注意

在这种情况下,windows 是一个目录,而不是文件。Automake 会自动递归地将该目录中的每个文件添加到分发包中;这可能包括一些你其实并不想要的文件,比如隐藏的 .svn .CVS 状态目录。请参阅 第 389 页上的“Automake -hook 和 -local 规则”,了解如何解决这个问题。

关于实用工具脚本

Autotools 已经在我们的项目目录结构根目录下添加了几个文件:compiledepcompinstall-shmissing。因为 configure 或生成的 Makefile 会在构建过程中不同的阶段执行这些脚本,最终用户将需要它们;然而,我们只能从 Autotools 获取这些脚本,我们不希望用户必须安装 Autotools。因此,这些脚本会自动添加到分发包中。

那么,你是否应该将它们提交到源代码仓库中呢?这个问题的答案是有争议的,但通常我推荐你不要这样做。任何需要创建分发包的维护者应该已经安装了 Autotools,并且应该从一个仓库工作区中进行工作。因此,这些维护者也会运行 autoreconf -i(可能与 --force 选项*)一起使用,以确保他们拥有最新的 Autotools 提供的实用工具脚本。如果你将它们提交到仓库中,这只会增加它们随着时间推移变得过时的可能性。它还会导致你的仓库修订历史中出现不必要的波动,因为贡献者在使用不同版本的 Autotools 生成的文件之间反复切换。

我也将这种观点扩展到configure脚本。有些人认为将工具和configure脚本检查到项目代码库中是有益的,因为这可以确保如果有人从工作区检出了项目,他们可以从工作区构建项目,而无需安装 Autotools。然而,我个人的观点是,开发者和维护者应该预期安装这些工具。偶尔,最终用户可能需要从工作区构建项目,但这应该是例外情况,而不是典型的情况,在这些特殊情况下,用户应愿意承担维护者的角色和要求。

  • 小心使用--force选项;它也会覆盖文本文件,如INSTALL,这些文件可能已根据项目修改,且与 Autotools 随附的默认文本文件不同。

维护者模式

有时,分发源文件的时间戳会比用户系统时钟的当前时间更晚。无论原因如何,这种不一致性会使make感到困惑,导致它认为每个源文件都过时了,需要重新构建。因此,它会重新执行 Autotools,以尝试更新configureMakefile.in模板。但是作为维护者,我们并不真正期望我们的用户安装 Autotools——或者至少没有安装我们系统中最新版本的 Autotools。

这就是 Automake 的维护者模式的作用。默认情况下,Automake 会在 makefile 中添加规则,重新生成模板文件、配置脚本和从维护者源文件(如Makefile.amconfigure.ac)以及 Lex 和 Yacc 输入文件生成的源文件。然而,我们可以在configure.ac中使用 Automake 的AM_MAINTAINER_MODE宏来禁用这些维护者级别的make规则的默认生成。

对于那些希望这些规则在构建系统发生变化后保持其构建系统适当更新的维护者,AM_MAINTAINER_MODE宏提供了一个configure脚本命令行选项(--enable-maintainer-mode),它告诉configure生成包含必要规则和命令以执行 Autotools 的Makefile.in模板。

维护者必须意识到在他们的项目中使用AM_MAINTAINER_MODE。在运行configure时,他们需要使用这个命令行选项,以生成完整的构建系统,这样当源代码被修改时,能够正确地重建由 Autotools 生成的文件。

注意

我还建议在项目的INSTALLREADME文件中提及使用维护者模式,以便最终用户在修改 Autotools 源文件时不会感到意外,且这些修改没有效果。

虽然 Automake 的维护者模式有其优势,但你应该知道,也有各种反对使用它的观点。大多数观点集中在 make 规则不应被故意限制的想法上,因为这样做会生成一个在某些情况下总是失败的构建系统。不过,我要声明的是,后来的 Autotools 版本在告知你缺少所需工具时做得要好得多。事实上,这正是 missing 脚本的作用。大多数工具调用都被封装在 missing 脚本中,脚本会相当清晰地告诉你缺少什么,以及如何安装它。

使用这个宏时的另一个重要考虑因素是,你现在已经将测试矩阵中的行数翻倍了,因为每个构建选项都有两种模式——一种假设 Autotools 已经安装,另一种假设没有安装。如果你决定使用该宏默认禁用维护者模式以供最终用户使用,请牢记这些要点。

打破噪音

基于 Autotools 的构建系统产生的噪音量一直是 Automake 邮件列表上最具争议的话题之一。一方喜欢安静的构建,只显示重要的信息,如警告和错误。另一方则认为有价值的信息通常嵌入在所谓的“噪音”中,因此所有信息都很重要,应该显示出来。偶尔,新的 Autotools 开发者会发帖询问如何减少 make 显示的信息量。这几乎总会引发一场激烈的辩论,持续好几天,几乎上百封邮件。老手们只是笑笑,常开玩笑说“有人又把开关打开了”。

事情的真相是,双方都有其合理的观点。GNU 项目本身就是关于选项的,因此 Automake 的维护者们增加了一个功能,允许你可选地将静默规则提供给用户。静默规则在 Automake 的 makefile 中并不是真正的静默,它们只是比传统的 Automake 生成的规则稍微安静一些。

静默规则不会显示整个编译器或链接器的命令行,而是显示一行简短的信息,指示正在处理该工具的工具和文件名。make 生成的输出仍然会显示,用户可以知道当前正在处理哪个目录和目标。这是启用了静默规则后的 Jupiter 构建输出(首先执行 make clean 确保有东西被构建):

$ make clean
--snip--
$ configure --enable-silent-rules
--snip--
$ make
make  all-recursive
make[1]: Entering directory '/.../jupiter'
Making all in common
make[2]: Entering directory '/.../jupiter/common'
  CC       print.o
  AR       libjupcommon.a
ar: `u' modifier ignored since `D' is the default (see `U')
make[2]: Leaving directory '/.../jupiter/common'
Making all in src
make[2]: Entering directory '/.../jupiter/src'
  CC       jupiter-main.o
  CCLD     jupiter
make[2]: Leaving directory '/.../jupiter/src'
make[2]: Entering directory '/.../jupiter'
make[2]: Leaving directory '/.../jupiter'
make[1]: Leaving directory '/.../jupiter'
$

如你所见,使用静默规则对 Jupiter 并没有太大影响——Jupiter 的构建系统花费大量时间在目录之间切换,实际构建的时间非常短。然而,在包含数百个源文件的项目中,你会看到一长串 CC filename.o 行,有时还会显示 make 切换目录或链接器正在构建产品的信息——编译器警告往往会引起注意。例如,输出中的 ar 警告如果没有静默规则的话,可能会被忽视。^(17)

静默规则默认是禁用的。要在 Automake 生成的 Makefile.am 模板中默认启用静默规则,可以在 configure.ac 中调用 AM_SILENT_RULES 宏,并传递 yes 参数。

无论如何,用户始终可以通过在 configure 命令行中使用 --enable-silent-rules--disable-silent-rules 来设置构建的默认详细程度。然后,构建将根据配置的默认值以及用户是否在 make 命令行中指定 V=0V=1 来决定是“静默”构建还是正常构建。

注意

configure 选项并不是必须的——静默规则的实际调用最终是由生成的 makefile 中的 V 变量控制的。configure 选项仅仅设置 V 的默认值。

对于较小的项目,我发现 Automake 的静默规则比起在 make 命令行中简单地将 stdout 重定向到 /dev/null 更不实用,可以按以下方式操作:

$ make >/dev/null
ar: `u' modifier ignored since `D' is the default (see `U')
$

正如本示例所示,警告和错误仍然会显示在 stderr 上,通常会提供足够的信息,帮助你确定问题所在(尽管在此例中并未显示)。在这种情况下,无警告构建才是真正的静默构建。你应当定期使用这种技巧来清理源代码中的编译器警告。静默规则非常有用,因为警告在构建输出中很突出。

非递归 Automake

现在我们已经将手写的 Makefile.in 模板转换为 Automake 的 Makefile.am 文件,让我们看看如何将这个递归构建系统转换为非递归构建系统。在前面的章节中,我们看到使用 makeinclude 指令有助于将 makefile 分割为各个子目录负责的部分。然而,在 Automake 中,将所有内容放入顶层的 Makefile.am 文件更加简单,因为内容非常简短,我们可以一眼看出整个构建系统。如果需要进一步划分责任,只需简单的注释即可。

关键点是,和我们之前提到的一样,参考内容时要假设 make 是从顶层目录运行的(再说一次,它确实是)。

清单 6-22 包含了顶层 Makefile.am 文件的全部内容——这是我们在此转换中唯一使用的 makefile。

Git 标签 6.3

noinst_LIBRARIES = common/libjupcommon.a
common_libjupcommon_a_SOURCES = common/jupcommon.h common/print.c

bin_PROGRAMS = src/jupiter
src_jupiter_SOURCES = src/main.c
src_jupiter_CPPFLAGS = -I$(top_srcdir)/common
src_jupiter_LDADD = common/libjupcommon.a

check_SCRIPTS = src/greptest.sh
TESTS = $(check_SCRIPTS)

src/greptest.sh:
        echo './src/jupiter | grep "Hello from .*jupiter!"' > src/greptest.sh
        chmod +x src/greptest.sh

CLEANFILES = src/greptest.sh

清单 6-22: Makefile.am: Jupiter 的非递归 Automake 实现

如你所见,我已经将SUBDIRS变量从顶级Makefile.am文件中替换为每个目录中Makefile.am文件的完整内容。这些目录是由该变量引用的。我接着为每个输入对象和产品引用添加了适当的相对路径信息,以便源文件可以从顶级目录访问,而这些文件实际上位于各自的子目录中,并且产品也能最终被放到正确的位置——与其源输入文件一起(或者至少在源树外构建时,放到正确的对应目录)。我已在顶级文件中粘贴了每个子目录Makefile.am文件的更改部分。

请注意,common_src_被加到了产品源变量的前面,因为这些前缀现在确实是产品名称的一部分。最终,这些名称用于创建make目标,这些目标的定义不仅依赖于名称,还依赖于它们的位置。通常,位置是当前目录,因此目录部分通常会被省略。对于我们的非递归构建,产品现在会生成到除当前目录以外的位置,因此必须明确指出这些位置。和产品名称中的其他特殊字符一样,目录分隔的斜杠在 PSV 中会变成下划线。

我们还需要添加一个 Automake 选项,并从configure.ac中移除多余的Makefile引用,如清单 6-23 所示。

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.69])
AC_INIT([Jupiter], [1.0], [jupiter-bugs@example.org])
AM_INIT_AUTOMAKE([subdir-objects])
AC_CONFIG_SRCDIR([src/main.c])
AC_CONFIG_HEADERS([config.h])
--snip--
# Checks for library functions.

AC_CONFIG_FILES([Makefile])
AC_OUTPUT

cat << EOF
--snip--

清单 6-23: configure.ac: 移除非递归构建中的额外 Makefile 引用

Automake 的subdir-objects选项是必要的,它告诉 Automake 你打算访问来自其他目录的源文件,而这些源文件并非位于当前目录。它还需要声明你希望目标文件和其他中间产品生成到与源文件相同的目录中(或者生成到正确的外部构建对应目录)。这个选项不仅对于非递归构建是必须的,还适用于任何需要在源文件所在目录外构建一个或多个源文件的情况。如果省略了这个选项,构建通常仍然会工作,但你会看到两个效果:autoreconf(或automake)会生成警告,提示你应该使用这个选项;而目标文件会被错误地放在不正确的目录下。如果你恰好在不同目录中有多个同名的源文件,那么第二个目标文件会覆盖第一个,这通常会导致链接错误,因为链接器找不到第一个被覆盖的目标中的符号。

最后,我们可以简单地删除commonsrc目录中的Makefile.am文件:

$ rm common/Makefile.am src/Makefile.am
$

总结

在本章中,我们讨论了如何使用已经为 Autoconf 做过准备的项目来为 Automake 做准备。(较新的项目通常同时为 Autoconf 和 Automake 做准备。)

我们讨论了如何使用SUBDIRS变量将Makefile.am文件连接起来,以及围绕产品列表、产品源和产品选项变量的相关概念。除了产品列表变量外,我还介绍了 Automake 的主要概念——这也是 Automake 的核心概念。最后,我讲解了如何使用EXTRA_DIST将附加文件添加到分发包中,使用AM_MAINTAINER_MODE宏确保用户不需要安装 Autotools,如何转换为非递归的 Automake 构建系统,以及如何使用 Automake 的静默规则。

通过这一切,我们用简短、简洁的Makefile.am文件替换了手写的Makefile.in模板,这些文件提供了显著更多的功能。我希望这个练习能让你开始意识到使用 Automake 而不是手写 makefile 的好处。

在第七章和第八章中,我们将研究如何将 Libtool 添加到 Jupiter 项目中。在第九章中,我们将通过深入探讨 Autoconf 的便携式测试框架——autotest,完成对 Autotools 的介绍。接着,在第十章至第十三章中,我们将暂时从 Autotools 中抽离,处理一些重要的旁支话题,但我们将在第十四章和第十五章中回归,带着“Autotool 化”一个真实项目,同时探索 Automake 的其他几个重要方面。

第七章:使用 LIBTOOL 构建库

岁月教会了许多白日无法知道的事。

——拉尔夫·沃尔多·爱默生,《经验》*

Image

在没有 GNU Libtool 帮助的情况下,经历了太多在多个平台上构建共享库的糟糕经验后,我得出了两个结论。首先,应该给发明共享库概念的人加薪……并且发放奖金。其次,应该对决定将共享库管理接口和命名约定留给实现的人进行鞭打。

Libtool 的存在本身就是这句话真理的见证。Libtool 存在的唯一原因是——为那些希望以可移植方式创建和访问共享库的开发者提供一个标准化、抽象的接口。它抽象了共享库构建过程和在运行时动态加载与访问共享库所用的编程接口。

Libtool 包概念是由 Gordon Matzigkeit 在 1996 年 3 月设计并初步实现的。在此之前,没有标准的跨平台机制用于构建共享库。Autoconf 和 Automake 在构建可移植项目时表现得非常好——只要你不尝试构建共享库。然而,一旦你开始这条路,你的代码和构建系统就会充满用于共享库管理的条件构造。这是一个巨大的工程,因为,正如我们将看到的,构建共享库在某些平台上有显著不同。

Thomas Tanner 从 1998 年 11 月开始贡献了他的跨平台抽象用于共享库动态加载——ltdl。自那时以来,其他贡献者包括 Alexandre Oliva、Ossama Othman、Robert Boehne、Scott James Remnant、Peter O’Gorman 和 Ralf Wildenhues。目前,Libtool 包由 Gary V. Vaughn(自 1998 年以来一直在贡献)和 Bob Friesenhahn(自 1998 年以来他的优秀建议被采纳)维护。

在我开始讨论 Libtool 的正确使用之前,我会用几段话介绍共享库所提供的功能和特性,以便你理解我所涉及内容的范围。

共享库的好处

共享库提供了一种以方便的方式部署可重用功能块的方式。你可以通过操作系统加载器在程序加载时自动将共享库加载到进程地址空间中,或者通过应用程序本身的代码手动加载。应用程序从共享库绑定功能的时机非常灵活,开发者可以根据程序设计和最终用户需求来确定这一点。

程序可执行文件和定义为共享库的模块之间的接口必须设计得合理,因为共享库接口必须有明确的规范。这种严格的规范促进了良好的设计实践。当你使用共享库时,系统本质上迫使你成为一个更好的程序员。

共享库可能(正如名字所示)在进程之间共享。这种共享非常字面化。共享库的代码段可以一次加载到物理内存页中。这些相同的内存页可以同时映射到多个程序的进程地址空间中。当然,数据页必须对于每个进程是唯一的,但全局数据段通常比共享库的代码段要小得多。这才是真正的高效。

在程序升级过程中,更新共享库非常简单。即使基础程序在软件包的两个版本之间没有变化,只要新版本的接口没有发生变化,你就可以用新的共享库版本替换旧版本。如果接口发生了变化,同一个共享库的两个版本可能会共存在同一个目录中,因为共享库使用的版本控制机制(并且被 Libtool 支持)允许在不同平台上,同一库的多个版本可以在文件系统中有不同的命名,但操作系统加载器将其视为同一个库。旧程序将继续使用旧版本的库,而新程序可以自由地使用新版本。

如果一个软件包定义了一个明确的插件接口,那么共享库可以用来实现用户可配置的可加载功能。这意味着,在程序发布后,额外的功能可以变得可用,第三方开发者甚至可以为你的程序添加功能,前提是你发布了描述插件接口规范的文档(或者他们足够聪明,能够自己弄明白)。

有一些广为人知的这类系统示例。例如,Eclipse 几乎是一个纯粹的插件框架。基础可执行文件支持的功能仅仅是一个明确的插件接口。Eclipse 应用程序中的大部分功能来自于库函数。Eclipse 是用 Java 编写的,使用 Java 类库和 .jar 文件,但这一原理是相同的,无论语言或平台如何。

共享库是如何工作的

POSIX 兼容操作系统实现共享库的具体方式因平台而异,但基本思路是相同的。共享库提供了操作系统可以加载到程序地址空间并执行的可执行代码块。以下讨论适用于链接器在构建程序时解析的共享库引用以及操作系统加载器在加载程序时解析的引用。

虽然编译器生成的目标文件(.o)包含可执行代码,但它们不能直接从命令行执行。这是因为它们是不完整的,包含了指向外部实体(函数和全局数据项)的符号引用或链接,这些需要修补。这个修补过程是通过使用专门的工具来管理这些链接,以便将包含这些引用的目标文件集合起来。

动态链接时加载

在构建程序可执行映像时,链接器(正式称为链接编辑器)维护一个符号表——函数入口点和全局数据地址。链接器在发现每个符号时,都会将其添加到符号表中。随着符号定义的找到,链接器将符号表中的符号引用解析为代码中的地址。在链接过程结束时,所有包含引用符号定义的目标文件(或简称目标文件)会被链接在一起,并成为程序可执行映像的一部分。

在静态库中(也称为档案)找到的、没有引用符号定义的目标文件将被丢弃,但显式链接的目标文件会被添加到二进制映像中,即使它们没有引用符号定义。如果在分析完所有目标文件后,符号表中仍有未解决的引用,链接器将退出并显示错误信息。若成功,最终的可执行映像可以被用户加载并执行。此时,映像已经完全自包含,不再依赖任何外部二进制代码。

假设所有未定义的引用都在链接过程中解决,如果要链接的目标文件列表包含一个或多个共享库,链接器将从命令行上指定的所有非共享目标文件中构建可执行映像。这包括所有单独的目标文件(.o)和所有包含在静态库档案(.a)中的目标文件。然而,链接器将向二进制映像头部添加两个表。第一个是未解决的外部引用表——一个在链接过程中只在共享库中找到的符号定义的引用表。第二个是共享库表,包含在未解决的未定义引用中找到的共享库名称和版本的列表。

当操作系统加载程序时,加载器必须解析外部引用表中剩余的未解决引用,这些引用指向共享库表中列出的共享库中的符号。如果加载器无法解决所有引用,则会发生加载错误,进程将终止并显示操作系统错误信息。请注意,这些外部符号并不绑定到特定的共享库。只要它们出现在共享库表中搜索到的任何一个库中,就会被接受。

注意

这个过程与 Windows 操作系统加载器解析动态链接库(DLL)中的符号的方式略有不同。在 Windows 中,链接器会在程序构建时将特定符号与特定命名的 DLL 绑定。^(1)

使用自由浮动的外部引用有其利弊。在某些操作系统中,未绑定的符号可以由用户指定的库来满足。也就是说,用户可以通过简单地预加载一个包含相同符号的库,在运行时完全替换一个库(或库的一部分)。例如,在基于 BSD 和 Linux 的系统中,用户可以使用LD_PRELOAD环境变量将一个共享库注入到进程的地址空间中。由于加载器在任何其他库之前加载这些库,因此当加载器尝试解析外部引用时,会首先在预加载的库中查找符号。程序作者预期的库甚至不会被检查,因为这些库提供的符号已经被预加载的库解决。

在下面的示例中,Linux 的df工具在包含LD_PRELOAD变量的环境中执行。这个变量被设置为指向一个库的路径,这个库假定包含一个与 C 的malloc接口兼容的堆管理器。这个技术可以用于调试程序中的内存问题。通过预加载你自己的堆管理器,你可以在日志文件中捕获内存分配——例如调试内存块溢出问题。这种技术被像Valgrind工具包等广为人知的调试工具所使用。^(2)

在这里,LD_PRELOAD环境变量设置在与执行df程序相同的命令行中。此 shell 代码会使只有df子进程的环境包含LD_PRELOAD变量,并将其设置为指定的值:

$ LD_PRELOAD=$HOME/lib/libmymalloc.so /bin/df
--snip--
$

不幸的是,自由浮动符号也可能引发问题。例如,两个库可能提供相同的符号名称,而动态加载器可能不小心将可执行文件绑定到错误库中的符号上。最好的情况下,这将导致程序崩溃,当错误的参数被传递给不匹配的函数时。最坏的情况是,它可能带来安全风险,因为不匹配的函数可能会用来捕获未经怀疑的程序传递的密码和安全凭证。

C 语言符号不包含参数信息,因此符号发生冲突的可能性较大。C++符号稍微安全一些,因为整个函数签名(不包括返回类型)会被编码进符号名称中。然而,即便是 C++也不能免疫黑客故意将安全函数替换为他们自己版本的函数(当然,前提是黑客能够访问你的运行时共享库搜索路径)。

运行时自动动态链接

操作系统加载器还可以使用一种非常晚期的绑定形式,通常称为懒绑定。在这种情况下,程序头中的外部引用表条目被初始化,使它们引用动态加载器本身中的代码。

当程序首次调用懒惰入口时,调用会被路由到加载器,加载器将(可能)加载正确的共享库,确定函数的实际地址,重置跳转表中的入口点,最后将处理器重定向到共享库中的函数(现在可用)。下次发生这种情况时,跳转表条目将已经正确初始化,程序将直接跳转到被调用的函数。这是非常高效的,因为修正后的跳转开销与正常的间接函数调用相当,而且初始加载和链接的成本会在整个进程生命周期中通过多次调用该函数得到摊销。^(3)

这种懒绑定机制使得程序启动非常快,因为共享库的符号直到需要时才会绑定,甚至在应用程序首次引用它们之前,这些库根本不会被加载。但请考虑一下——程序可能永远不会引用它们。这意味着这些库可能永远不会被加载,从而节省了时间和空间。一个很好的例子可能是一个带有同义词库功能的文字处理器,它是通过共享库实现的。你有多频繁使用同义词库?如果程序使用的是自动动态链接,通常情况下,包含同义词库代码的共享库在大多数文字处理过程中可能永远不会被加载。

尽管这个系统看起来非常好,但也可能存在一些问题。虽然使用自动运行时动态链接可以提高加载速度、提升性能并更有效地利用空间,但它也可能导致应用程序突然终止且没有任何警告。如果加载器无法找到请求的符号——可能是缺少所需的库——它除了中止进程之外别无他法。

为什么不在程序加载时确保所有符号都存在呢?因为如果加载器在加载时解决了所有符号,它也许还会在那个时候填充跳转表条目。毕竟,它必须加载所有库以确保符号确实存在,因此这将完全违背使用懒绑定的初衷。此外,即使加载器在程序首次启动时检查了所有外部引用,也没有什么能阻止某人在程序使用这些库之前删除一个或多个这些库,而程序仍在运行中。^(4)因此,甚至预检查也被打破了。

这个故事的寓意是:没有免费的午餐。如果你不想为较长的前期加载时间和更多的空间消耗支付保险费(即使你可能永远不需要它),那么你可能不得不承担在运行时缺少符号的风险,导致程序崩溃。

运行时手动动态链接

解决上述问题的一个可能方法是让程序对系统加载器的一部分工作负责。这样,当事情出错时,程序对结果有更多的控制权。在词库模块的例子中,如果无法加载词库或词库没有提供正确的符号,真的有必要终止程序吗?当然没有——但是操作系统加载器无法知道这一点,只有软件程序员能做出这样的判断。

当程序在运行时手动管理动态链接时,链接器完全不参与其中,程序不会直接调用任何导出的共享库函数。相反,共享库函数通过程序在运行时填充的函数指针进行引用。

它的工作原理是这样的:一个程序调用操作系统的函数(dlopen)手动将共享库加载到它自己的进程地址空间中。这个函数返回一个句柄,即一个表示已加载库的不透明值。程序接着调用另一个加载函数(dlsym)从句柄所指向的库中导入一个符号。如果一切顺利,操作系统会返回所请求的函数或数据项的地址。然后,程序可以通过这个指针调用函数或访问全局数据项。

如果这个过程中出了问题——符号在库中找不到或库找不到——那么就由程序来定义结果,可能通过显示错误信息来提示程序配置不正确。在前面的文字处理器例子中,一个简单的对话框提示词库不可用就完全足够了。

这比自动动态运行时链接方式稍好一些;虽然加载器别无选择只能中止,但应用程序有更高层次的视角,可以更优雅地处理问题。当然,缺点是作为程序员的你必须在应用程序代码中管理加载库和导入符号的过程。不过,这个过程并不困难,正如我将在下一章中演示的那样。

使用 Libtool

关于共享库及其在各种系统中实现的细节,可以写一本完整的书。你刚刚阅读的简短入门内容已经足够满足我们的眼前需求,现在我将继续讲解如何使用 Libtool 让包维护者的工作变得更轻松。

Libtool 项目的设计初衷是扩展 Automake,但你也可以在手动编写的 Makefile 中独立使用它。截止目前,Libtool 的最新版本是 2.4.6,也是我在此示例中使用的版本。

构建过程抽象

首先,让我们来看一下 Libtool 如何在构建过程中提供帮助。Libtool 提供了一个脚本(ltmain.sh),config.status 会在 Libtool 启用项目中使用。config.status 脚本将 configure 测试结果和 ltmain.sh 脚本转化为一个定制版的 libtool 脚本,专门为你的项目量身定制。^(5) 然后,你项目的 Makefile 使用这个 libtool 脚本来构建任何在 Automake 产品列表变量中列出的共享库,这些变量是通过 Libtool 特定的 LTLIBRARIES 主项定义的。libtool 脚本实际上只是编译器、链接器和其他工具的一个华丽包装。你应该在发布归档中随项目一起提供 ltmain.sh 脚本,作为构建系统的一部分。Automake 生成的规则会确保这一过程正确执行。

libtool 脚本将构建系统的作者与在不同平台上构建共享库的细节隔离开来。此脚本接受一组明确定义的选项,将它们转换为目标平台和工具集上特定的、适当的平台和链接器选项。因此,维护者无需担心在每个平台上构建共享库的具体细节——他们只需了解可用的 libtool 脚本选项。这些选项在GNU Libtool Manual中有详细说明,^(6) 我将在本章和下一章中讲解其中的许多选项。

在不支持共享库的系统上,libtool 脚本使用适当的命令和选项来构建和链接仅静态归档库。此外,维护者在使用 Libtool 时,无需担心构建共享库和构建静态库之间的差异。你可以通过在 Libtool 启用项目的 configure 命令行上使用 --disable-shared 选项来模拟在仅静态系统上构建你的包。此选项会使 Libtool 假定目标系统无法构建共享库。

运行时抽象

你还可以使用 Libtool 抽象操作系统提供的库加载和符号导入的编程接口。如果你曾在 Linux 系统上动态加载过一个库,那么你就熟悉标准的 POSIX 共享库 API,包括 dlopendlsymdlclose 函数。系统级共享库,通常简称为 dl,提供了这些函数。这通常会转化为一个名为 libdl.so 的二进制镜像文件(在使用不同库命名约定的系统中,名称可能不同)。

不幸的是,并非所有支持共享库的 Unix 系统都提供名为 libdl.so 的库或函数。为了解决这些差异,Libtool 提供了一个名为 ltdl 的共享库,它导出了一个清晰、可移植的库管理接口,类似于 POSIX dl 接口。使用这个库是可选的,但强烈建议使用,因为它不仅在共享库平台之间提供了一个通用的 API,还为共享库与非共享库平台之间的手动动态链接提供了一个抽象层。

什么?!这怎么可能工作?! 在不支持共享库的系统上,Libtool 实际上在可执行文件内部创建了符号表,包含所有原本会在共享库中找到的符号(在支持共享库的系统上)。通过在这些平台上使用这些符号表,lt_dlopenlt_dlsym 函数可以让你的代码看起来像是加载了库并导入了符号,实际上,库加载函数只是返回一个指向相应内部符号表的句柄,而导入函数仅返回已静态链接到程序本身的代码地址。在这些系统上,项目的共享库代码会直接链接到通常在运行时加载它们的程序中。

安装 Libtool

如果你想在开发你的软件包时使用 Libtool 的最新版本,你可能会发现你需要手动下载、构建并安装它,或者寻找分发提供商提供的更新版 libtool 包。

下载、构建和安装 Libtool 实际上非常简单,正如你在这里看到的那样。然而,在执行这些步骤之前,你应该检查一下 GNU Libtool 网站^(7),以确保你获得的是最新的软件包。我已经从第一章中复制了基本步骤:

$ wget https://ftp.gnu.org/gnu/libtool/libtool-2.4.6.tar.gz
--snip--
$ tar xzf libtool-2.4.6.tar.gz
$ cd libtool-2.4.6
$ ./configure && make
--snip--
$ sudo make install
--snip--

请注意,默认的安装位置(与大多数 GNU 包一样)是 /usr/local。如果你希望将 Libtool 安装到 /usr 目录下,你需要在 configure 命令行中使用 --prefix=/usr 选项。推荐的做法是将分发提供的软件包安装到 /usr 目录下,将用户自行构建的软件包安装到 /usr/local 目录下,但如果你想让手工构建的 Libtool 版本与分发提供的 Autoconf 和 Automake 版本兼容,你可能需要将 Libtool 安装到 /usr 目录下。避免软件包相互依赖问题的最简单方法是将所有三个软件包的手工构建版本安装到 /usr/local,或者更好的是,安装到你家目录下的某个目录,并将该目录添加到你的 PATH 中。

将共享库添加到 Jupiter

现在我已经介绍了必要的背景信息,接下来我们来看看如何将 Libtool 共享库添加到 Jupiter 项目中。首先,让我们考虑使用共享库可以为 Jupiter 添加哪些功能。也许我们想为用户提供一些库功能,供他们自己的应用程序使用。或者我们可能有一个包含多个应用程序的包,这些应用程序需要共享相同的功能。共享库对于这两种场景都是一个很好的工具,因为它可以让你重用代码并节省内存——共享代码使用的内存成本会在多个应用程序中摊销,这些应用程序可以是项目内部的,也可以是外部的。

让我们为 Jupiter 添加一个提供打印功能的共享库。我们可以通过让新的共享库调用 libjupcommon.a 静态库来实现这一点。记住,调用静态库中的例程与将被调用的例程的目标代码直接链接到调用程序中是一样的。被调用的例程最终会成为调用的二进制镜像(程序或共享库)的一个不可分割的部分。^(8)

此外,我们还将提供一个来自 Jupiter 项目的公共头文件,允许外部应用程序调用相同的功能。这样,其他应用程序就可以以与 jupiter 程序相同的方式显示内容。(如果我们在 Jupiter 中做一些更有用的事情,这样会显得更酷,但你明白我的意思。)

使用 LTLIBRARIES 主体

Automake 内置对 Libtool 的支持;提供 LTLIBRARIES 主体的是 Automake 包,而不是 Libtool 包。Libtool 并不完全算是一个纯粹的 Automake 扩展,它更像是 Automake 的一个附加包,Automake 为这个特定的附加包提供必要的基础设施。没有 Libtool,你无法访问 Automake 的 LTLIBRARIES 主体功能,因为使用这个主体会生成调用 libtool 脚本的 make 规则。

Libtool 单独发布,而不是作为 Automake 的一部分,因为你可以相当有效地独立使用 Libtool。如果你想单独尝试 Libtool,我会把你引导到GNU Libtool 手册;手册的开头几章描述了如何将 libtool 脚本作为独立产品使用。只需要修改你的 makefile 命令,使得编译器、链接器和库管理器通过 libtool 脚本调用,然后根据 Libtool 的要求修改一些命令行参数,就能实现这一点。

公共包含目录

名为 include 的项目子目录应只包含公共头文件——那些在你的项目中暴露公共接口的文件。我们现在将向 Jupiter 项目中添加这样一个头文件,所以我们将在项目根目录下创建一个名为 include 的目录。

如果我们有多个共享库,我们需要做出选择:是为每个库的源目录创建单独的 include 目录,还是添加一个单一的顶层 include 目录?我通常使用以下经验法则来做决定:如果这些库是作为一个组一起工作的,并且如果使用这些库的应用程序通常会一起使用它们,那么我会使用一个单一的顶层 include 目录。另一方面,如果这些库可以独立使用,并且它们提供的功能集相对独立,那么我会在各自的库目录中提供单独的 include 目录。

最终,这其实并不重要,因为这些库的头文件将安装在与你项目中存在的目录结构完全不同的目录中。事实上,你应该确保不会在项目中的两个不同库中不小心使用相同的公共头文件名——如果这样做,你将遇到安装这些文件的问题。它们通常会全部放在 $(prefix)/include 目录中,尽管你可以通过在 Makefile.am 文件中使用 includedir 变量或 pkginclude 前缀来覆盖这个默认设置。

includedir 变量允许你通过定义 Automake 的 $(includedir) 变量的确切值来指定你希望将头文件安装到哪里,通常该值是 $(prefix)/include。使用 pkginclude 前缀表示你希望将头文件放在 $(includedir) 目录下的一个私有的、特定于包的子目录中,目录名称为 $(includedir)/$(PACKAGE)

我们还将为 Jupiter 的新共享库 libjupiter 添加另一个根目录(libjup)。这些更改要求你将新目录的引用添加到顶层 Makefile.am 文件的 SUBDIRS 变量中,然后在 configure.ac 文件中的 AC_CONFIG_FILES 宏中添加相应的 Makefile 引用。由于我们将对项目进行重大更改,最好在开始之前清理一下工作区。然后我们将创建目录并将新的 Makefile.am 文件添加到新的 include 目录中:

Git 标签 7.0

   $ make maintainer-clean
   --snip--
   $ mkdir libjup include
➊ $ echo "include_HEADERS = libjupiter.h" > include/Makefile.am
   $

include 目录下的 Makefile.am 文件非常简单—它仅包含一行,其中一个 Automake 的 HEADERS 主体引用了公共头文件 libjupiter.h。注意在 ➊ 处我们在这个主体前使用了 include 前缀。你会记得,这个前缀表示在这个主体中指定的文件将被安装到 $(includedir) 目录中(例如,/usr/(local/)include)。HEADERS 主体类似于 DATA 主体,因为它指定了一组文件,这些文件将被当作数据安装,而无需修改或预处理。唯一真正的区别在于,HEADERS 主体将安装位置限制在那些适合头文件的位置。

libjup/Makefile.am 文件比简单的单行文件更复杂,包含了四行内容。该文件显示在清单 7-1 中。

➊ lib_LTLIBRARIES = libjupiter.la
➋ libjupiter_la_SOURCES = jup_print.c
➌ libjupiter_la_CPPFLAGS = -I$(top_srcdir)/include -I$(top_srcdir)/common
➍ libjupiter_la_LIBADD = ../common/libjupcommon.a

清单 7-1: libjup/Makefile.am: 该文件的初始版本

让我们逐行分析这个文件。➊行是主规格,它包含了库文件的常见前缀:lib。这个前缀所引用的产品将被安装到 $(libdir) 目录中。(我们也可以使用 pkglib 前缀,表示我们希望将库文件安装到 $(libdir)/jupiter。)这里,我们使用了 LTLIBRARIES 主体,而不是原始的 LIBRARIES 主体。使用 LTLIBRARIES 告诉 Automake 生成使用 libtool 脚本的规则,而不是直接调用编译器(以及可能的图书馆管理员)来生成产品。

➋行列出了用于第一个(也是唯一)产品的源文件。

➌行表示一组 C 预处理器标志,这些标志将在编译器命令行上使用,用于查找相关的共享库头文件。这些选项指示预处理器应在顶层的 includecommon 目录中搜索源代码中引用的头文件。

最后一行(在 ➍ 处)表示此产品的链接器选项。在这种情况下,我们指定应该将 libjupcommon.a 静态库链接到(即,成为)libjupiter.so 共享库的一部分。

注意

更有经验的 Autotools 库开发人员会注意到这个 Makefile.am 文件中的一个微妙缺陷。这里有个提示:它与将 Libtool 库链接到非 Libtool 库有关。这个概念对于许多新人来说是一个主要的绊脚石,所以我在编写这个文件的初始版本时有意展示了这个错误。不过不用担心—我们将在本章稍后通过合乎逻辑的方式解决这个问题时修正这个缺陷。

有一个关于 *_LIBADD 变量的重要概念,你应该努力完全理解:在同一项目中作为一部分构建并被使用的库,应通过相对路径在 build 目录层次结构中内部引用,使用父目录引用或 $(``top_builddir``) 变量。外部项目的库通常不需要显式引用,因为项目的 configure 脚本在处理由 AC_CHECK_LIBAC_SEARCH_LIBS 宏生成的代码时,应该已经将适当的 -L-l 选项添加到 $(LIBS) 环境变量中。

接下来,我们将把这些新目录接入项目的构建系统。为此,我们需要修改顶层的 Makefile.amconfigure.ac 文件。这些更改分别显示在清单 7-2 和 7-3 中。

SUBDIRS = common include libjup src

清单 7-2: Makefile.am: include libjup 添加到 SUBDIRS 变量中

   #                                               -*- Autoconf -*-
   # Process this file with autoconf to produce a configure script.

   AC_PREREQ([2.69])
   AC_INIT([Jupiter],[1.0],[jupiter-bugs@example.org])
   AM_INIT_AUTOMAKE
➊ LT_PREREQ([2.4.6])
   LT_INIT([dlopen])
   AC_CONFIG_SRCDIR([src/main.c])
   AC_CONFIG_HEADERS([config.h])

   # Checks for programs.
   AC_PROG_CC
➋ AC_PROG_INSTALL

   # Checks for header files.
   AC_CHECK_HEADERS([stdlib.h])
   --snip--
   AC_CONFIG_FILES([Makefile
                    common/Makefile
                 ➌ include/Makefile
                    libjup/Makefile
                    src/Makefile])
   AC_OUTPUT
   --snip--

清单 7-3: configure.ac: 添加 include libjup 目录的 makefile

configure.ac 中做了三个不相关的更改。第一个是在 ➊ 位置添加了 Libtool 设置宏 LT_PREREQLT_INITLT_PREREQ 宏的作用类似于 Autoconf 的 AC_PREREQ 宏(用于前几行)。它指示能够正确处理此项目的 Libtool 最早版本。你应选择这些宏参数中的最低合理值,因为较高的值会不必要地限制你和你的共同维护者使用更新版本的 Autotools。^(9) LT_INIT 宏用于为该项目初始化 Libtool 系统。

第二个更改同样很有趣。我在 ➋ 位置的行之后移除了 AC_PROG_RANLIB 宏的调用。(而且这还是我们最初为了将它放进去而做的所有努力!)因为 Libtool 现在正在构建项目中的所有库,并且它理解库构建过程的各个方面,所以我们不再需要指示 Autoconf 确保 ranlib 可用。事实上,如果你保留这个宏,当你执行 autoreconf -i 时会收到一个警告。

最后的更改出现在 ➌ 位置的 AC_CONFIG_FILES 参数中,在那里我们已添加对我们在 includelibjup 目录中添加的两个新 Makefile.am 文件的引用。

使用 LT_INIT 选项定制 Libtool

你可以在传递给 LT_INIT 的参数列表中指定启用或禁用静态库和共享库的默认值。LT_INIT 宏接受一个单一的可选参数:一个由空格分隔的关键字列表。以下是此列表中允许的最重要的关键字,并附有它们正确使用的说明。

dlopen

此选项启用对dlopen支持的检查。《GNU Libtool Manual》指出,如果软件包使用了libtool中的-dlopen-dlpreopen标志,则应使用此选项;否则,libtool将假定系统不支持dl-opening。使用-dlopen-dlpreopen标志的唯一原因是:你打算在项目的源代码中动态加载和导入共享库的功能。此外,除非你打算使用ltdl库(而不是直接使用dl库)来管理运行时的动态链接,否则这两个选项几乎没有作用。因此,只有在你打算使用ltdl库时,才应使用此选项。

win32-dll

如果你的库已经正确移植到 Windows DLL,使用了__declspec(dllimport)__declspec(dllexport),则使用此选项。如果你的库正确地使用这些关键字来导入和导出 Windows DLL 的符号,而你没有使用此选项,那么 Libtool 只会在 Windows 上构建静态库。我们将在第十七章中更详细地讨论这个主题。

aix-soname=aix|svr4|both

configure的命令行添加--with-aix-soname标志。在版本 2.4.4 之前,Libtool 总是表现得像是将aix-soname设置为aix。如果你经常在 AIX 上构建共享库,你会理解这个选项的含义。如果你想了解更多内容,请阅读《GNU Libtool Manual》的第 5.4.1 节。

disable-fast-install

此选项会改变LT_INIT的默认行为,以禁用针对需要时的快速安装优化。快速安装的概念存在是因为未安装的程序和库可能需要在构建树内执行(例如在make check期间)。在某些系统中,安装位置会影响最终链接的二进制文件,因此当执行make install时,Libtool 必须重新链接这些系统上的程序和库,或者在make check时重新链接程序和库。默认情况下,Libtool 选择在make check时重新链接,允许原始二进制文件快速安装,而无需在make install期间重新链接。用户可以根据平台支持通过在configure中指定--enable-fast-install来覆盖此默认行为。

shared 和 disable-shared

这两个选项会改变创建共享库的默认行为。shared选项的效果是在所有 Libtool 知道如何创建共享库的系统中都是默认行为。用户可以通过在configure命令行中指定--disable-shared--enable-shared来覆盖默认的共享库生成行为。

static 和 disable-static

这两个选项会改变创建静态库的默认行为。static 选项的效果是,在禁用共享库的所有系统以及大多数启用了共享库的系统上,都是默认行为。如果启用了共享库,用户可以通过在 configure 命令行中指定 --disable-static 来覆盖此默认设置。对于没有共享库的系统,Libtool 会始终生成静态库。因此,你不能同时使用 LT_INITdisable-shareddisable-static 参数,或者 configure--disable-shared--disable-static 命令行选项。(但是,请注意,你可以同时使用 sharedstaticLT_INIT 选项,或者 --enable-shared--enable-static 的命令行选项。)

pic-only 和 no-pic

这两个选项会改变创建和使用 PIC 对象代码的默认行为。用户可以通过在 configure 命令行中指定 --without-pic--with-pic 来覆盖这些选项设置的默认值。我将在《那么,究竟什么是 PIC?》一节中讨论 PIC 对象代码的含义,参见第 200 页。

现在我们已经完成了新库的构建系统设置,可以开始讨论源代码。清单 7-4 显示了在 libjup/Makefile.am 第二行中引用的新的 jup_print.c 源文件的内容。清单 7-5 显示了新的 include/libjupiter.h 库头文件的内容。

#include "config.h"

#include "libjupiter.h"
#include "jupcommon.h"

int jupiter_print(const char * name)
{
     return print_routine(name);
}

清单 7-4: libjup/jup_print.c:共享库源文件的初始内容

#ifndef LIBJUPITER_H_INCLUDED
#define LIBJUPITER_H_INCLUDED

int jupiter_print(const char * name);

#endif /* LIBJUPITER_H_INCLUDED */

清单 7-5: include/libjupiter.h:共享库公共头文件的初始内容

这引出了另一个软件工程的通用原则。我听到过许多不同的说法,但我通常使用的一个是DRY 原则——这个缩写代表不要重复自己。C 函数原型非常有用,因为当正确使用时,它们能够确保公众对函数的理解与包维护者的理解一致。我常常看到一些源文件没有包含相应的头文件。很容易在函数或原型中做出小改动,而忘记在另一个位置也进行相同的修改——除非你在源文件中包含了公共头文件。只要你始终这样做,编译器就会帮你捕捉到任何不一致之处。

我们还需要包含静态库头文件(jupcommon.h),因为我们在公共库函数中调用了它的函数(print_routine)。你可能也注意到,我将config.h放在最前面,紧接着是公共头文件——这么做是有原因的。我在第六章中已经提到过,config.h应该始终排在每个源文件的最前面。通常情况下,我会说公共头文件应该排在最前面,但公共头文件应该写成这样,它们的功能不应被config.h修改。因此,从技术上讲,公共头文件在config.h之前或之后包含并不重要。例如,在公共头文件中使用像off_t这样的编译器模式相关类型,会导致应用程序二进制接口(ABI)不仅在不同平台之间发生变化(这不一定是坏事),还会在同一平台上根据消费代码设置的编译环境而发生变化(这就不好了)。事实上,你应该以一种方式编写公共头文件,使得无论是在config.h之前还是之后包含它们,都不重要;它们应该被特别设计成不依赖于config.h可以配置的任何内容。关于这一主题的更详细讨论,请参见第十八章,第 1 项:将私有细节从公共接口中剔除。

通过将公共头文件放在源文件的最前面(在config.h之后),我们可以确保该公共头文件的使用不依赖于项目中任何内部头文件中的定义。例如,假设公共头文件隐含依赖于某些在内部头文件(如jupcommon.h)中定义的构造(如类型定义、结构体或预处理器定义)。如果我们在包含jupcommon.h之后再包含公共头文件,那么当编译器开始处理公共头文件时,这个依赖就会被隐藏,因为所需的构造已经在翻译单元(源文件与所有已包含的头文件组合)中可用。

我想最后再谈谈清单 7-5 中的内容。预处理器条件构造通常被称为包含保护。它是一种机制,用于防止你的头文件在同一个翻译单元中被意外地多次包含。我在所有的头文件中都常规使用包含保护,养成这样的习惯是很好的做法。一个优秀的优化编译器(例如gcc,特别是它的预处理器)会识别头文件中的包含保护,并在后续的包含中完全跳过该文件,在同一个翻译单元中避免重复包含。10

由于公共头文件将被外部源代码使用,因此在这些头文件中严格使用包含保护是至关重要的。虽然你可以控制自己的代码库,但你无法控制你的库消费者编写的代码。我在这里提倡的是,你应该假设自己是最好的程序员,其他人稍微低于你的技术水平。你可以通过不向任何人提及这一点来巧妙地做到这一点,但在编写公共头文件时,你应该表现得像这是一条事实。

接下来,我们将修改 Jupiter 应用程序的 main 函数,使其调用共享库中的函数,而不是调用常见的静态库。这些更改显示在列表 7-6 中。

#include "config.h"

#include "libjupiter.h"

int main(int argc, char * argv[])
{
    return jupiter_print(argv[0]);
}

列表 7-6: src/main.c: main 改为调用共享库函数

在这里,我们将打印函数从静态库中的 print_routine 改为新共享库提供的 jupiter_print。我们还将顶部包含的头文件从 libjupcommon.h 改为 libjupiter.h

我对公共函数和头文件的命名选择是随意的,但基于提供一个干净、合理和信息丰富的公共接口的考虑。名称 libjupiter.h 很清楚地表明该头文件指定了 libjupiter.so 的公共接口。我尽量将库接口函数命名为清晰地表明它们是接口的一部分。如何选择公共接口成员的名称——文件、函数、结构、类型定义、预处理器定义、全局数据等等——取决于你,但你应该考虑使用类似的理念。记住,目标是提供一个良好的终端用户体验。直观的命名应该是你策略的一个重要部分。例如,选择一个通用的前缀来命名程序和库符号是一种良好的通用实践。^(11)

最后,我们还必须修改 src/Makefile.am 文件,以便使用新的共享库,而不是 libjupcommon.a 静态库。这些更改显示在列表 7-7 中。

   bin_PROGRAMS = jupiter
   jupiter_SOURCES = main.c
➊ jupiter_CPPFLAGS = -I$(top_srcdir)/include
➋ jupiter_LDADD = ../libjup/libjupiter.la
   --snip--

列表 7-7: src/Makefile.am: 向 src 目录的 Makefile 添加共享库引用

在这里,我们已经在 ➊ 修改了 jupiter_CPPFLAGS 语句,使其引用新的顶级include目录,而不是common目录。我们还在 ➋ 修改了 jupiter_LDADD 语句,使其引用新的 Libtool 共享库对象,而不是libjupcommon.a静态库。其余部分保持不变。引用 Libtool 库的语法与引用较旧的静态库的语法相同——只是库扩展名不同。Libtool 库扩展名 .la 代表 libtool 存档

让我们退后一步,想一想。我们真的需要做这个改动吗?不,当然不需要。jupiter应用程序将继续像最初编写时一样正常工作。直接将静态库中的print_routine代码链接到应用程序中,与调用新的共享库例程(最终包含相同的代码)一样有效。事实上,调用共享库例程的开销稍微大一些,因为调用共享库跳转表时会增加一个额外的间接层。

在一个真实的项目中,你可能会选择保持原样。因为两个公共入口点,mainjupiter_print,调用的是完全相同的函数(print_routine),该函数位于libjupcommon.a中,它们的功能是相同的。为什么还要通过公共接口调用,即使有一点点额外的开销呢?其中一个原因是你可以利用共享代码。通过使用共享库函数,你不会重复代码——无论是在磁盘上还是在内存中。这正是 DRY 原则的体现。

另一个原因是锻炼你为共享库用户提供的接口。如果你的项目代码以你期望的方式使用共享库,你会更快地发现公共接口中的错误。

在这种情况下,你可能现在会考虑直接将静态库中的代码移动到共享库中,从而完全去除静态库的需求。然而,我请求你宽容地看待我的这一人为示例。在一个更复杂的项目中,我很可能需要这种配置。常见代码通常会被集中到静态便利库中,而且更多时候,这些公共代码只有一部分会在共享库中重复使用。为了教学目的,我将保持原样。

重新配置并构建

由于我们向项目构建系统中添加了 Libtool 这一重要新组件,我们将向autoreconf命令行添加-i选项,以确保所有必要的辅助文件都安装到项目根目录中:

   $ autoreconf -i
➊ libtoolize: putting auxiliary files in '.'.
   libtoolize: copying file './ltmain.sh'
   libtoolize: Consider adding 'AC_CONFIG_MACRO_DIRS([m4])' to configure.ac,
   libtoolize: and rerunning libtoolize and aclocal.
   libtoolize: Consider adding '-I m4' to ACLOCAL_AMFLAGS in Makefile.am.
   configure.ac:8: installing './compile'
➋ configure.ac:8: installing './config.guess'
   configure.ac:8: installing './config.sub'
   configure.ac:6: installing './install-sh'
   configure.ac:6: installing './missing'
   Makefile.am: installing './INSTALL'
   Makefile.am: installing './COPYING' using GNU General Public License v3 file
   Makefile.am:     Consider adding the COPYING file to the version control system
   Makefile.am:     for your code, to avoid questions about which license your
   project uses
   common/Makefile.am: installing './depcomp'
   parallel-tests: installing './test-driver'
   $

因为我们已完全移除了项目目录中的所有生成和复制的文件,所以这些通知大多数与替换我们之前讨论过的文件有关。然而,也有一些值得注意的例外。

首先,注意到来自libtoolize的评论,见➊。它们大多数只是建议我们转向新的 Autotools 约定,将 M4 宏文件添加到项目根目录中的m4目录中。我们暂时忽略这些评论,但在第十四章和第十五章中,我们将实际为一个真实项目做这件事。

如你在 ➋ 处所见,Libtool 的添加似乎导致了几个新文件被添加到我们的项目中——即config.guessconfig.sub 文件。在 ➊ 处的部分又添加了一个新文件,名为ltmain.shconfigure脚本使用ltmain.sh为 Jupiter 项目构建一个项目特定的libtool版本。稍后我会介绍config.guessconfig.sub脚本。

让我们继续执行configure,看看会发生什么:

$ ./configure
--snip--
checking for ld used by gcc... /usr/bin/ld
checking if the linker (/usr/bin/ld) is GNU ld... yes
checking for BSD- or MS-compatible name lister (nm)... /usr/bin/nm -B
checking the name lister (/usr/bin/nm -B) interface... BSD nm
checking whether ln -s works... yes
checking the maximum length of command line arguments... 1572864
--snip--
checking for shl_load... no
checking for shl_load in -ldld... no
checking for dlopen... no
checking for dlopen in -ldl... yes
checking whether a program can dlopen itself... yes
checking whether a statically linked program can dlopen itself... no
checking whether stripping libraries is possible... yes
checking if libtool supports shared libraries... yes
checking whether to build shared libraries... yes
checking whether to build static libraries... yes
--snip--
configure: creating ./config.status
--snip--
$

首先需要注意的是,Libtool 为配置过程添加了显著的开销。我这里只展示了自从添加了 Libtool 之后新增的几行输出。我们在configure.ac文件中所做的唯一修改是添加了对LT_INIT宏的引用,但我们几乎将configure的输出量翻了一倍。这应该能让你对创建可移植共享库所需检查的系统特性数量有一些了解。幸运的是,Libtool 为你做了大量工作。

现在,让我们运行make命令,看看我们会得到什么样的输出:

   $ make
   --snip--
   Making all in libjup
   make[2]: Entering directory '/.../jupiter/libjup'
➊ /bin/bash ../libtool    --tag=CC     --mode=compile gcc -DHAVE_CONFIG_H -I. -I..
   -I../include -I../common     -g -O2 -MT libjupiter_la-jup_print.lo -MD -MP -MF
   .deps/libjupiter_la-jup_print.Tpo -c -o libjupiter_la-jup_print.lo `test -f
   'jup_print.c' || echo './'`jup_print.c
➋ libtool: compile:    gcc -DHAVE_CONFIG_H -I. -I.. -I../include -I../common -g
   -O2 -MT libjupiter_la-jup_print.lo -MD -MP -MF .deps/libjupiter_la-jup_print.Tpo -c jup_print.c    -fPIC -DPIC -o .libs/libjupiter_la-jup_print.o
➌ libtool: compile:    gcc -DHAVE_CONFIG_H -I. -I.. -I../include -I../common -g
   -O2 -MT libjupiter_la-jup_print.lo -MD -MP -MF .deps/libjupiter_la-jup_print.
   Tpo -c jup_print.c -o libjupiter_la-jup_print.o >/dev/null 2>&1
➍ mv -f .deps/libjupiter_la-jup_print.Tpo .deps/libjupiter_la-jup_print.Plo
➎ /bin/bash ../libtool    --tag=CC     --mode=link gcc    -g -O2     -o libjupiter.la
   -rpath /usr/local/lib libjupiter_la-jup_print.lo ../common/libjupcommon.a
   -lpthread
➏ *** Warning: Linking the shared library libjupiter.la against the
   *** static library ../common/libjupcommon.a is not portable!
   libtool: link: gcc -shared    -fPIC -DPIC    .libs/libjupiter_la-jup_print.o     ../
   common/libjupcommon.a -lpthread    -g -O2     -Wl,-soname -Wl,libjupiter.so.0 -o
   .libs/libjupiter.so.0.0.0
➐ /usr/bin/ld: ../common/libjupcommon.a(print.o): relocation R_X86_64_32 against
   `.rodata.str1.1' can not be used when making a shared object; recompile with
   -fPIC
   ../common/libjupcommon.a: error adding symbols: Bad value
   collect2: error: ld returned 1 exit status
   Makefile:389: recipe for target 'libjupiter.la' failed
   make[2]: *** [libjupiter.la] Error 1
   make[2]: Leaving directory '/.../jupiter/libjup'
   Makefile:391: recipe for target 'all-recursive' failed
   make[1]: *** [all-recursive] Error 1
   make[1]: Leaving directory '/.../jupiter'
   Makefile:323: recipe for target 'all' failed
   make: *** [all] Error 2
   --snip--
   $

我们似乎有一些错误需要修复。第一个值得注意的问题是,在 ➊ 处执行了libtool,并使用了--mode=compile选项,这使得libtool充当了一个包装脚本,围绕标准gcc命令行的某个修改版进行操作。你可以在接下来的两行编译命令中看到这一点,分别在 ➋ 和 ➌ 处。两个编译命令? 没错。看起来 Libtool 正在针对我们的源文件执行两次编译。

这两行命令的仔细对比表明,第一条命令使用了两个额外的标志,-fPIC-DPIC。第一行似乎还将输出文件定向到一个.libs子目录,而第二行则将其保存在当前目录中。最后,第二行将stdoutstderr输出流重定向到/dev/null

注意

有时,你可能会遇到一种情况,某个源文件在第一次编译时能够正常编译,但由于与 PIC 相关的源代码错误,在第二次编译时失败。这类问题虽然很少见,但一旦发生会让人头疼,因为make* 会因错误而停止构建,却没有给出任何错误信息来解释问题!遇到这种情况时,只需在make命令行的CFLAGS变量中传递-no-suppress标志,告诉 Libtool 不要将第二次编译的输出重定向到/dev/null

这个双重编译特性多年来在 Libtool 邮件列表上引起了不少焦虑。大多数原因是人们对 Libtool 尝试做的事情以及为什么它是必要的缺乏理解。通过使用 Libtool 的各种configure脚本命令行选项,你可以强制执行单次编译,但这样做会带来一定的功能丧失,我将在稍后解释这一点。

➍ 行将依赖文件从.Tpo*重命名为.Plo*。你可能还记得第三章和第六章,依赖文件包含make规则,这些规则声明了源文件和引用的头文件之间的依赖关系。当你使用-MT编译器选项时,C 预处理器会生成这些规则。但是,这里要理解的首要概念是,一个 Libtool 命令可能(并且经常)执行一组 shell 命令。

➎ 行是另一个对libtool脚本的调用,这次使用--mode=link选项。此选项生成一个调用,以在链接模式下执行编译器,并传递在Makefile.am文件中指定的所有库和链接器选项。

在 ➏,我们遇到了第一个问题——关于将共享库与静态库链接的可移植性警告。具体来说,此警告是关于将 Libtool 共享库与非 Libtool 静态库链接。请注意,这不是错误。如果不是因为我们稍后会遇到的其他错误,那么尽管有此警告,仍会构建该库。

在可移植性警告之后,libtool尝试将请求的对象链接到名为libjupiter.so.0.0.0的共享库中。但是在这里,脚本遇到了真正的问题:在 ➐,链接器错误表明从libjupcommon.a内部的某个地方——更具体地说,在print.o内部——无法执行x86_64对象重定位,因为原始源文件 (print.c) 显然没有正确编译。链接器非常友好地告诉我们解决问题需要做什么(在示例中突出显示):我们需要使用-fPIC编译器选项编译源代码。

如果你现在遇到了这个错误,并且对-fPIC选项一无所知,明智的做法是打开gcc的手册页,并在随意插入编译器和链接器选项直到警告或错误消失之前仔细研究它(不幸的是,这是缺乏经验的程序员的常见做法)。软件工程师应该理解他们的构建系统中使用的工具的每个命令行选项的含义和细微差别。否则,他们并不真正知道他们的构建完成时得到了什么。它可能会按预期工作,但如果是这样,那也是靠运气而不是靠设计。优秀的工程师了解他们的工具,最好的学习方法是在继续前进之前,研究错误消息及其修复方法,直到完全理解问题为止。

那么,PIC 到底是什么?

当操作系统创建新的进程地址空间时,它们通常会将程序的可执行镜像加载到相同的内存地址。这个神奇的地址是系统特定的。编译器和链接器理解这一点,并且知道在任何给定的系统中,神奇的地址是什么。因此,当它们生成对函数调用或全局数据的内部引用时,它们可以将这些引用生成绝对地址。如果你以某种方式将可执行文件加载到进程虚拟地址空间的不同位置,它将无法正常工作,因为代码中的绝对地址将不正确。至少,当处理器在函数调用时跳转到错误的位置时,程序将崩溃。

请考虑一下图 7-1。假设我们有一个系统,其神奇的可执行加载地址是0x10000000;该图描绘了系统中的两个进程地址空间。在左边的进程中,可执行镜像正确加载到地址0x10000000。在代码的某个点,jmp指令告诉处理器将控制转移到绝对地址0x10001000,并在程序的另一区域继续执行指令。

Image

图 7-1:可执行镜像中的绝对寻址

在右边的进程中,程序被错误地加载到地址0x20000000。当遇到相同的分支指令时,处理器会跳转到地址0x10001000,因为该地址是硬编码在程序镜像中的。当然,这会失败——通常是通过崩溃的方式,或者有时会带来更微妙且狡猾的后果。

这就是程序镜像的工作方式。然而,当为某些类型的硬件(包括 AMD64)构建共享库时,编译器和链接器都无法事先知道库将被加载到哪里。这是因为许多库可能会被加载到一个进程中,而它们的加载顺序取决于可执行文件的构建方式,而不是库本身。此外,谁能说清楚哪个库拥有位置 A,哪个库拥有位置 B 呢?事实上,库可能会被加载到进程地址空间中的任何地方,只要在它被加载时有足够的空间。只有操作系统加载器知道它最终会驻留在哪里——即使如此,它也只会在库被实际加载之前才知道这个位置。^(12)

因此,共享库只能由一种特殊类型的目标文件——称为 PIC 对象——构建。PIC位置无关代码(position-independent code)的缩写,这意味着对象代码中的引用不是绝对的,而是相对的。当你在编译器命令行中使用-fPIC选项时,编译器会在分支指令中使用效率较低的相对寻址。这种位置无关的代码可以加载到任何地方。

图 7-2 展示了生成 PIC 对象时使用的相对寻址概念。使用相对寻址时,地址无论图像加载到哪里都能正确工作,因为它们始终是相对于当前指令指针进行编码的。在 图 7-2 中,图示表示共享库与 图 7-1 中的地址相同加载(即,0x100000000x20000000)。在这两种情况下,jmp 指令中使用的美元符号代表当前的指令指针(IP),因此 $ + 0xC74 告诉处理器,它应该跳转到当前指令指针位置前进 0xC74 字节处的指令。

图片

图 7-2:共享库图像中的相对寻址

生成和使用位置无关代码有各种细节,你应该在使用之前熟悉它们,以便能够选择最适合你情况的选项。例如,GNU C 编译器也支持 -fpic 选项(小写),它使用稍微更快但功能更有限的机制生成可重定位目标代码。^(13)

修复 Jupiter PIC 问题

根据我们现在的理解,修复链接器错误的一种方法是将 -fPIC 选项添加到编译器命令行中,针对构成 libjupcommon.a 静态库的源文件。清单 7-8 展示了对 common/Makefile.am 文件所需的更改。

noinst_LIBRARIES = libjupcommon.a
libjupcommon_a_SOURCES = jupcommon.h print.c
libjupcommon_a_CFLAGS = -fPIC

清单 7-8: common/Makefile.am:生成静态库中 PIC 对象所需的更改

现在,让我们重新尝试构建:

   $ autoreconf
   $ ./configure
   --snip--
   $ make
   make    all-recursive
   make[1]: Entering directory '/.../jupiter'
   Making all in common
   make[2]: Entering directory '/.../jupiter/common'
   gcc -DHAVE_CONFIG_H -I. -I..        -fPIC -g -O2 -MT libjupcommon_a-print.o -MD
   -MP -MF .deps/libjupcommon_a-print.Tpo -c -o libjupcommon_a-print.o `test -f
   'print.c' || echo './'`print.c
   --snip--
   Making all in libjup
   make[2]: Entering directory '/.../jupiter/libjup'
   /bin/bash ../libtool    --tag=CC     --mode=link gcc    -g -O2     -o libjupiter.
   la -rpath /usr/local/lib libjupiter_la-jup_print.lo ../common/libjupcommon.a
   -lpthread

➊ *** Warning: Linking the shared library libjupiter.la against the
   *** static library ../common/libjupcommon.a is not portable!
   libtool: link: gcc -shared    -fPIC -DPIC    .libs/libjupiter_la-jup_print.o
   ../common/libjupcommon.a -lpthread    -g -O2     -Wl,-soname -Wl,libjupiter.so.0
   -o .libs/libjupiter.so.0.0.0
   libtool: link: (cd ".libs" && rm -f "libjupiter.so.0" && ln -s "libjupiter
   .so.0.0.0" "libjupiter.so.0")
   libtool: link: (cd ".libs" && rm -f "libjupiter.so" && ln -s "libjupiter
   .so.0.0.0" "libjupiter.so")
   libtool: link: ar cru .libs/libjupiter.a ../common/libjupcommon.a    libjupiter
   _la-jup_print.o
   ar: `u' modifier ignored since `D' is the default (see `U')
   libtool: link: ranlib .libs/libjupiter.a
   libtool: link: ( cd ".libs" && rm -f "libjupiter.la" && ln -s "../libjupiter
   .la" "libjupiter.la" )
   make[2]: Leaving directory
   --snip--
   $

现在我们已经根据系统要求,成功构建了一个正确的共享库,并且它包含了位置无关代码。然而,我们仍然在 ➊ 处看到那个关于将 Libtool 库与静态库链接的移植性问题的奇怪警告。这里的问题不在于我们做了什么,而是我们是如何做的。你看,PIC 的概念并不适用于所有硬件架构。一些 CPU 的指令集不支持任何形式的绝对寻址。因此,这些平台的本地编译器不支持 -fPIC 选项——对它们来说没有意义。未知选项可能会被悄悄忽略,但在大多数情况下,编译器会因为未知选项而停止,并给出错误信息。

如果我们尝试在 IBM RS/6000 系统上使用原生 IBM 编译器编译这段代码,遇到链接器命令行中的 -fPIC 选项时,它会出现问题。因为在一个所有代码都是生成位置无关代码的系统上,支持这种选项是没有意义的。

我们可以通过使 -fPIC 选项在 Makefile.am 中根据目标系统和所使用的工具而变化来解决这个问题。但这正是 Libtool 设计的目的!我们需要考虑所有不同的 Libtool 目标系统类型和工具集,以便处理 Libtool 已经处理的所有条件。此外,一些系统和编译器可能需要不同的命令行选项来实现相同的目标。

要解决这个可移植性问题,方法是让 Libtool 也生成静态库。Libtool 区分了作为开发者包一部分安装的静态库和仅在项目内部使用的静态库。它将这些内部静态库称为便利库,是否生成便利库取决于与 LTLIBRARIES 主命令一起使用的前缀。如果使用了 noinst 前缀,Libtool 会认为我们想要一个便利库,因为生成一个永远不会安装的共享库是没有意义的。因此,便利库总是作为未安装的静态归档生成,除非它们被链接到项目中的其他代码,否则没有任何价值。

区分便利库和其他形式的静态库的原因是,便利库总是构建的,而安装的静态库仅在 configure 命令行中指定了 --enable-static 选项时才会构建——或者反过来,如果没有指定 --disable-static 选项,并且默认库类型已经设置为 static。从旧的静态库转换到新的 Libtool 便利库非常简单——我们只需要在主命名中添加 LT,然后删除 -fPIC 选项和 CFLAGS 变量(因为在该变量中没有使用其他选项)。还要注意,我已将库扩展名从 .a 更改为 .la。别忘了将 SOURCES 变量中的前缀更改为反映新库名——libjupcommon.la。这些更改在 示例 7-9 和 7-10 中突出显示。

Git 标签 7.1

noinst_LTLIBRARIES = libjupcommon.la
libjupcommon_la_SOURCES = jupcommon.h print.c

示例 7-9: common/Makefile.am: 从静态库更改为 Libtool 静态库

lib_LTLIBRARIES = libjupiter.la
libjupiter_la_SOURCES = jup_print.c
libjupiter_la_CPPFLAGS = -I$(top_srcdir)/include -I$(top_srcdir)/common
libjupiter_la_LIBADD = ../common/libjupcommon.la

示例 7-10: libjup/Makefile.am: 从静态库更改为 Libtool 静态库

现在,当我们尝试构建时,得到的是:

   $ make
   --snip--
   Making all in libjup
   make[2]: Entering directory '/.../jupiter/libjup'
➊ /bin/bash ../libtool   --tag=CC   --mode=compile gcc -DHAVE_CONFIG_H -I. -I..
   -I../include -I../common    -g -O2 -MT libjupiter_la-jup_print.lo -MD -MP -MF
   .deps/libjupiter_la-jup_print.Tpo -c -o libjupiter_la-jup_print.lo `test -f
   'jup_print.c' || echo './'`jup_print.c
   libtool: compile:  gcc -DHAVE_CONFIG_H -I. -I.. -I../include -I../common -g
   -O2 -MT libjupiter_la-jup_print.lo -MD -MP -MF .deps/libjupiter_la-jup_print.
   Tpo -c jup_print.c  -fPIC -DPIC -o .libs/libjupiter_la-jup_print.o
   libtool: compile:  gcc -DHAVE_CONFIG_H -I. -I.. -I../include -I../common -g
   -O2 -MT libjupiter_la-jup_print.lo -MD -MP -MF .deps/libjupiter_la-jup_print.
   Tpo -c jup_print.c -o libjupiter_la-jup_print.o >/dev/null 2>&1
   mv -f .deps/libjupiter_la-jup_print.Tpo .deps/libjupiter_la-jup_print.Plo
   /bin/bash ../libtool  --tag=CC   --mode=link gcc  -g -O2   -o libjupiter.la
   -rpath /usr/local/lib libjupiter_la-jup_print.lo ../common/libjupcommon.la
   -lpthread
   libtool: link: gcc -shared  -fPIC -DPIC  .libs/libjupiter_la-jup_print.o
   -Wl,--whole-archive ../common/.libs/libjupcommon.a -Wl,--no-whole-archive
   -lpthread  -g -O2   -Wl,-soname -Wl,libjupiter.so.0 -o .libs/libjupiter.
   so.0.0.0
   libtool: link: (cd ".libs" && rm -f "libjupiter.so.0" && ln -s "libjupiter.
   so.0.0.0" "libjupiter.so.0")
   libtool: link: (cd ".libs" && rm -f "libjupiter.so" && ln -s "libjupiter.
   so.0.0.0" "libjupiter.so")
   libtool: link: (cd .libs/libjupiter.lax/libjupcommon.a && ar x "/.../jupiter/
   libjup/../common/.libs/libjupcommon.a")
➋ libtool: link: ar cru .libs/libjupiter.a  libjupiter_la-jup_print.o
   .libs/libjupiter.lax/libjupcommon.a/print.o
   ar: `u' modifier ignored since `D' is the default (see `U')
   libtool: link: ranlib .libs/libjupiter.a
   libtool: link: rm -fr .libs/libjupiter.lax
   libtool: link: ( cd ".libs" && rm -f "libjupiter.la" && ln -s "../libjupiter.
   la" "libjupiter.la" )
   make[2]: Leaving directory '/.../jupiter/libjup'
   --snip--
   $

你可以在➋处看到,公共库现在作为静态便利库构建,因为ar工具构建了libjupcommon.a。Libtool 似乎也在构建具有新扩展名的文件——更仔细观察会发现扩展名如.la.lo(查看➊处的行)。如果你检查这些文件,你会发现它们实际上是包含对象和库元数据的描述性文本文件。列表 7-11 展示了common/libjupcommon.la的部分内容。

   # libjupcommon.la - a libtool library fil e
   # Generated by libtool (GNU libtool) 2.4.6 Debian-2.4.6-0.1
   #
   # Please DO NOT delete this file!
   # It is necessary for linking the library.

   # The name that we can dlopen(3).
   dlname=''

   # Names of this library.
➊ library_names=''

   # The name of the static archive.
➋ old_library='libjupcommon.a'

   # Linker flags that cannot go in dependency_libs.
   inherited_linker_flags=''

   # Libraries that this one depends upon.
➌ dependency_libs=' -lpthread'
   --snip--

列表 7-11: common/libjupcommon.la: 库归档文件中的文本元数据(.la)文件

这些文件中的各个字段帮助链接器——或者更准确地说,libtool包装脚本——确定一些选项,否则维护者必须记住并手动传递给链接器。例如,库的共享和静态名称在这里的➊和➋处进行了记录,还有这些库所需的任何库依赖项(在➌处)。

注意

这是一个便利库,所以共享库名称为空

在这个库中,我们可以看到libjupcommon.a依赖于pthreads库。但是,通过使用 Libtool,我们不需要在libtool命令行上传递-lpthread选项,因为libtool可以从这个元数据文件的内容中(特别是➌处的行)检测到链接器需要这个选项,并且会为我们传递这个选项。

使这些文件对人类可读是一项小小的天才之举,因为它们可以让我们一眼看出有关 Libtool 库的很多信息。这些文件被设计为与相关的二进制文件一起安装到最终用户的机器上,事实上,Automake 为 Libtool 库生成的make install规则正是这样做的。

现在大多数 Linux 发行版倾向于从库项目的官方构建中筛选出.la文件——也就是说,它们不会将其安装到/usr目录结构中,因为.la文件仅在包引用 Libtool 库并且位于项目目录结构中的构建过程中有用。由于发行版提供者已经为你预先构建好了所有内容,并且你自己不会构建这些包,它们只是占用了空间(尽管不多)。当你在系统中安装的库(无论是 Libtool 还是其他库)中进行链接时,实际上是使用AC_CHECK/SEARCH宏来查找库并直接链接.a.so文件,因此在这种情况下也不会使用.la文件。

总结

在本章中,我概述了共享库的基本原理。作为练习,我们向 Jupiter 中添加了一个共享库,该共享库集成了我们之前创建的便利库中的功能。我们从一种或多或少直观的方法开始,将静态库集成到 Libtool 共享库中,在此过程中我们发现了使用 Libtool 便利库实现这一点的更具可移植性和正确性的方法。

与 Autotools 工具链中的其他软件包一样,Libtool 为你提供了丰富的功能和灵活性。但正如你可能已经注意到的,功能和灵活性的提升也带来了代价——复杂性。随着 Libtool 的加入,Jupiter 的配置脚本大小大幅增加,编译和链接我们项目所需的时间也相应增加。

在下一章,我们将继续讨论 Libtool,重点讲解库版本问题以及 Libtool 如何解决手动动态运行时库管理带来的可移植性问题。

第八章:库接口版本控制与运行时动态链接

偶尔他会碰到真相,但总是匆忙站起来,像什么也没发生一样继续前进。

—温斯顿·丘吉尔爵士,在《无法抑制的丘吉尔》中引用

Image

在上一章中,我解释了动态加载共享库的概念。我还展示了如何轻松地将 Libtool 共享库功能和灵活性添加到你的项目中,无论你的项目是提供共享库、静态库、便利档案,还是这些的某种混合体。我们仍然有两个主要的 Libtool 话题需要讨论。第一个是库版本控制,第二个涉及使用 Libtool 的ltdl库,在你的项目中便捷地构建和使用动态加载模块。

当我谈到一个库的版本时,我特指库公共接口的版本,但我需要在此上下文中清楚地定义接口这一术语。共享库接口指的是共享库与外界所有的连接方面。除了库导出的函数和数据签名之外,这些连接还包括文件和文件格式、网络连接和数据格式、IPC 通道和协议等等。在考虑是否为共享库分配一个新版本时,你应当仔细检查库与外部世界的所有交互,以确定变动是否会导致库在用户角度上的表现发生变化。

Libtool 试图隐藏共享库平台之间的差异,其设计如此巧妙,以至于如果你一直使用 Libtool 来构建共享库,你可能甚至没有意识到不同平台之间共享库版本控制的方式存在显著差异。

系统特定的版本控制

让我们来看看在几个不同的系统上共享库版本控制是如何工作的,从而为 Libtool 抽象提供一些背景。

共享库版本控制可以是内部的也可以是外部的。内部版本控制意味着库的名称与其版本没有任何关联。因此,内部版本控制意味着某种形式的可执行文件头信息向链接器提供适当的函数调用,以满足所请求的应用程序二进制接口(ABI)。这也意味着所有版本的库的函数调用都保存在同一个共享库文件中。Libtool 支持根据平台要求使用内部版本控制,但它更倾向于使用外部版本控制。在外部版本控制中,版本信息直接在文件名中指定。

除了库级别的版本控制,在这种控制中,特定的版本号或字符串表示整个库接口,许多 Unix 系统还支持一种导出级别或符号级别的版本控制,其中共享库导出多个具有命名或编号版本的相同函数或全局数据项。虽然 Libtool 不会妨碍在每个系统上使用这种导出级别的版本控制方案,但它也没有为其提供任何特定的可移植性支持。因此,我不会详细讨论这个话题。

Linux 和 Solaris 库版本控制

现代 Linux 借鉴了 Oracle Solaris 操作系统 9 版的库版本控制系统。^(1)这些系统使用一种外部库版本控制形式,其中版本信息编码在共享库的文件名中,遵循特定的模式或模板。我们来看一下典型 Linux 系统中/usr/lib/x86_64-linux-gnu目录的部分目录列表——特别是与一个相当典型的库libcurl相关的文件:

   $ ls -lr /usr/lib/x86_64-linux-gnu/libcurl*
➊ -rw-r--r-- ... 947448 ... libcurl.a
   lrwxrwxrwx ...     19 ... libcurl-gnutls.so.3 -> libcurl-gnutls.so.4
   lrwxrwxrwx ...     23 ... libcurl-gnutls.so.4 -> libcurl-gnutls.so.4.4.0
   -rw-r--r-- ... 444800 ... libcurl-gnutls.so.4.4.0
   -rw-r--r-- ...    953 ... libcurl.la
➋ lrwxrwxrwx ...     16 ... libcurl.so -> libcurl.so.4.4.0
   lrwxrwxrwx ...     12 ... libcurl.so.3 -> libcurl.so.4
➌ lrwxrwxrwx ...     16 ... libcurl.so.4 -> libcurl.so.4.4.0
➍ -rw-r--r-- ... 452992 ... libcurl.so.4.4.0
   $

注意

此控制台目录列表中的内容特定于我的系统,该系统基于 Debian 发行版。如果您的发行版不是基于 Debian 的,您可能会看到稍微不同的目录列表——甚至可能会有显著不同。在这种情况下,请不要试图在您的系统上跟随操作。相反,阅读以下描述时,只需按照我的示例进行。这里讨论的重点是概念,而不是文件名

Linux 系统上的库名称遵循标准格式:libname.so.X.Y。格式中的 X.Y 部分表示版本信息,其中 X 是主版本号(始终是一个数字),Y 是次版本号(可能包含多个由点分隔的部分)。一般规则是,X 的变化表示对库的 ABI 的非向后兼容的更改,而 Y 的变化表示向后兼容的修改,包括库接口的孤立添加和不干扰的错误修复。

通常,您会看到一个看起来像是第三个编号组件的部分。例如,4 号位置的条目表示实际的curl共享库,libcurl.so.4.4.0。在这个例子中,最后两个数字(4.0)实际上只是代表一个两部分的次版本号。次版本号中的这种附加数字信息有时被称为库的补丁级别。^(2)

➌ 处的 libcurl.so.4 条目被称为库的 共享对象名称(soname)^(3),实际上是一个软链接,指向二进制文件。soname 是消费程序和库在内部引用的格式——也就是说,链接器在构建时将此名称嵌入到消费程序或库中。软链接由 ldconfig 工具创建,ldconfig 除了其他功能外,还确保适当的 soname 可以定位到已安装库的最新次版本。ldconfig 通常由安装后脚本以及 RPM 和 Debian 包的触发器执行。因此,尽管 soname 不是通过 make install 目标创建或安装的,但它通常是由发行版安装包创建的,因此也由 Linux 打包工具创建。

注意,这种版本管理方案如何允许不同主版本的多个 soname 以及多个具有不同主版本和次版本的二进制文件在同一目录中共存。

库的开发包(以 -dev-devel 结尾)通常还会安装一个所谓的 链接器名称 条目(在 ➋ 处)。链接器名称是一个仅以 .so 结尾的软链接,通常指向 soname,尽管在某些情况下(如本例)它可能直接指向二进制共享库。链接器名称是链接器命令行中引用库的名称。开发库使得你可以运行那些链接到库的最新版本的程序,但在较旧版本的库上进行开发,反之亦然。

➊ 处的条目指的是库的静态归档形式,在 Linux 和 Solaris 系统上具有 .a 扩展名。其余条目表示为特定于 curl 包生成的 curl 库集的其他形式。

curl 库已经成为现代 Linux 系统中不可或缺的一部分;它被许多其他安装在系统上的程序使用,其中一些程序尚未升级到最新的主版本。维护者声明,主版本 4 向后兼容主版本 3。因此,指向版本 3 的 soname 会在安装了版本 4 的系统上指向版本 4 的库文件。这并不一定是常见的做法,但在这种情况下它是有效的。

从此开始,外部和内部共享库版本管理技术的奇怪阵列使得问题变得复杂。这些不太直观的系统每一个都是为了克服多年来在 Solaris 系统中发现的一些基本问题而设计的。^(4)让我们看一下其中的一些。

IBM AIX 库版本管理

传统上,IBM 的 AIX 使用一种内部版本控制形式,将所有库代码存储在一个单独的归档文件中,该文件遵循 libname.a 的模式。这个文件实际上可能同时包含静态和共享形式的代码,以及 32 位和 64 位代码。内部来说,所有共享库代码都存储在归档文件中的一个单一逻辑共享对象文件中,而静态库对象则存储为归档文件中的单独逻辑对象文件。

我说“传统上”是因为更近期的 AIX 版本(包括所有 64 位版本)现在支持直接从物理 .so 文件加载共享库代码的概念。

Libtool 在 AIX 上使用这两种方案生成共享库代码。如果命令行上指定了 AIX -brtl 本地链接器标志,Libtool 会生成带有 .so 扩展名的共享库。否则,它会生成遵循旧版单文件方案的合并库。^(5)

在 AIX 上使用 .so 文件方案时,Libtool 会生成采用 Linux/Solaris 命名模式的库,以便与这些更流行的平台保持一定程度的兼容性。然而,无论使用哪种共享库扩展名,版本信息仍然不会存储在文件名中;它存储在库和使用的可执行文件内部。就我所知,Libtool 会确保创建正确的内部结构,以反映共享库头文件中的正确版本信息。它通过将适当的标志传递给本地链接器,嵌入由 Libtool 版本字符串派生的版本信息来实现这一点。

大多数 Unix 系统上的可执行文件也支持嵌入式运行时库搜索路径的概念(在 AIX 上称为 LIBPATH),通常指定一组用冒号分隔的文件系统路径,用于搜索共享库依赖项。你可以使用 Libtool 的 -R 命令行选项来为程序和库指定一个库搜索路径。Libtool 会将此选项转换为适当的 GNU 或本地链接器选项,适用于任何给定的系统。

我说可执行文件通常支持这个选项,因为在 AIX 上有一些细微差别。如果 LIBPATH 中指定的所有目录都是真实目录,一切按预期工作——也就是说,LIBPATH 仅作为库搜索路径。然而,如果 LIBPATH 的第一个部分不是一个真实的文件系统条目,它就充当所谓的加载器域,本质上是某个特定共享库的命名空间。因此,同名的多个共享库可以存储在同一个 AIX 档案(.a)文件中,每个都被分配(通过链接器选项)到不同的加载器域。与 LIBPATH 中指定的加载器域匹配的库将从档案中加载。如果你通过 LIBPATH 分配了一个加载器域,后来恰好成为一个真实的文件系统条目,这可能会产生不良副作用。另一方面,你也可以在 LIBPATH 中指定一个搜索目录,恰好与共享库中的某个加载器域匹配。如果该目录后来被删除,你将无意中开始使用该加载器域。正如你所想,奇怪的行为随之而来。AIX 开发人员通过确保加载器域字符串与文件系统路径完全不同,解决了大部分这些问题。

在 AIX 系统上,所有代码,无论是静态的还是共享的,都是以位置独立代码(PIC)形式编译的,因为 AIX 只曾移植到 PowerPC 和 RS/6000 处理器上。这些处理器的架构仅支持 PIC 代码,因此 AIX 编译器无法生成非 PIC 代码。

微软 DLL 版本管理

考虑微软 Windows 的动态链接库(DLL),它们在所有意义上都是共享库,并提供了适当的应用程序编程接口(API)。但不幸的是,微软过去并未提供集成的 DLL 接口版本管理方案。因此,Windows 开发人员常常将 DLL 版本管理问题(我相信他们开玩笑的)称为DLL 地狱

作为解决此问题的临时补救方法,Windows 系统上的 DLL 可以安装到与使用它们的程序相同的目录中。Windows 操作系统加载器总是会尝试先使用本地副本,然后才会在系统路径中查找副本。这在一定程度上缓解了问题,因为它允许你与需要该库的包一起安装特定版本的库。尽管这是一个合理的解决方案,但并不是一个理想的解决方案,因为共享库的主要优点之一是它们可以共享——无论是在磁盘上还是在内存中。如果每个应用程序都有自己的一份不同版本的库副本,那么共享库的这一优点就会丧失——无论是在磁盘上还是在内存中。

自从多年前引入这一部分解决方案以来,微软对 DLL 共享效率问题并未给予太多关注。原因包括对磁盘空间和 RAM 消耗的轻视态度,以及实现 Windows DLL 时的技术问题。微软的系统架构师没有生成位置无关代码,而是选择将 DLL 与特定的基地址链接,然后在库镜像头部的基表中列出所有的绝对地址引用。当 DLL 无法在期望的基地址加载时(因为与另一个 DLL 发生冲突),加载器会重定位该 DLL,通过选择一个新的基地址并更改代码段中基表所引用的所有绝对地址。当 DLL 以这种方式被重定位时,它只能与那些恰好将 DLL 重定位到相同地址的进程共享。尤其是对于包含许多 DLL 组件的应用程序,偶然遇到这种情况的几率是相当小的。

最近,微软发明了并行缓存(有时称为SxS)的概念,它允许开发人员将一个唯一的标识值(实际上是一个 GUID)与安装在系统位置的特定版本的 DLL 关联。该位置目录的名称是从 DLL 名称和版本标识符派生出来的。基于 SxS 版本库构建的应用程序,其可执行文件头中会存储元数据,指示它们所需的特定版本的 DLL。如果在 SxS 缓存中找到正确的版本(通过更新的操作系统加载器),那么它会被加载。根据 EXE 头部元数据中的策略,加载器可以回退到旧的方案,即先查找本地副本,然后查找全局副本的 DLL。这比早期的解决方案有了极大的改进,并提供了一个非常灵活的版本控制系统。

并行缓存实际上将 Windows 的 DLL 架构向 Unix 管理共享库的方式更靠近一步。可以把 SxS 看作是库的系统安装位置——就像 Unix 系统中的/usr/lib目录一样。与 Unix 类似,相同的 DLL 的多个版本可以并行安装在并行缓存中。

尽管有相似之处,但由于 DLL 使用的是重定位技术,而不是 PIC 代码,针对管理大量共享库的应用程序,并行缓存仍然是一个相当温和的效率提升。SxS 实际上是为许多应用程序可能会使用的系统库设计的。这些库通常位于不同的地址上,因此发生冲突(进而需要重定位)的可能性降低,但并没有完全消除。

共享库的整个基础方法的主要缺点是,程序的地址空间可能会变得相当分散,因为系统加载器会在 32 位地址空间中随机选择基址并予以处理。幸运的是,64 位寻址在这一领域帮助巨大,因此在 64 位 Windows 系统上,你可能会发现并排缓存对于提高内存使用效率更为有效,而如今 64 位系统已是常态。

HP-UX/AT&T SVR4 库版本管理

惠普的 Unix 版本(自 HP-UX 10.0 版本以来)增加了一种库级版本管理方式,这与 AT&T UNIX System V Release 4 中使用的版本管理非常相似。对于我们的目的,你可以认为这两种系统的工作方式几乎相同。

本地链接器会根据基础名称和.sl扩展名查找指定的库。然而,使用的程序和库包含对该库内部名称的引用。内部名称是通过链接器命令行选项分配给库的,并且应该包含该库的接口版本号。

实际的库仅以主要接口版本号作为扩展名命名,并且创建一个软链接,指向具有.sl扩展名的库。因此,在这些系统上,共享库将遵循以下模式:

libname.X
libname.sl -> libname.X

我们所能使用的唯一版本信息是一个主要版本号,用于表示从一个版本到下一个版本之间的不兼容更改。由于没有像 Linux 或 Solaris 那样的次要版本号,我们不能保留特定接口版本的多个修订版。唯一的选择是,如果进行了错误修复或向后兼容的增强(即对接口的非侵入性添加),则用更新后的版本零替换版本零。

然而,我们仍然可以共同安装多个主要版本的库,Libtool 充分利用了这些系统上可用的功能。

Libtool 库版本管理方案

Libtool 的作者们努力提供一个可以映射到任何 Libtool 平台所使用的任何版本管理方案的方案。Libtool 版本管理方案被设计得足够灵活,以便与现有 Libtool 平台的合理未来更改兼容,甚至可以兼容新的 Libtool 平台。

然而,这并不是万灵药。当 Libtool 扩展到一种新的共享库平台时,已经发生了(并且仍然会发生)一些需要认真评估的情况。没有人能成为所有系统的专家,因此 Libtool 开发者非常依赖外部贡献,以创建从 Libtool 版本管理方案到新平台或潜在 Libtool 平台方案的正确映射。

库版本管理即接口版本管理

你应该有意识地避免将库版本号(无论是 Libtool 的版本号,还是特定平台的版本号)看作是产品的主版本次版本修订版(也叫做补丁微版本)。事实上,这些版本值对操作系统加载器有非常具体的含义,它们必须在每个新版本的库中正确更新,以避免混淆加载器。一个混淆的加载器可能会根据分配给库的错误版本信息加载错误的库版本。

几年前,我和公司内部的版本控制委员会一起,为整个公司制定软件版本管理政策。委员会希望工程师们确保我们共享库名称中的版本号与公司软件版本管理策略保持一致。花了我大半天时间才说服他们,共享库版本与产品版本没有任何关系,也不应该由他们或任何其他人建立或强制这种关系。

这是为什么:共享库上的版本号实际上并不是库版本,而是接口版本。我这里提到的接口是指库向用户展示的应用程序二进制接口,另一个程序员希望调用由该接口提供的函数。一个可执行程序有一个单一、明确、标准的入口点(通常在 C 语言中称为 main)。但共享库有多个入口点,这些入口点通常没有被广泛理解的标准化方式。这使得判断特定版本的库是否与同一库的另一个版本接口兼容变得更加困难。

在 Libtool 的版本控制方案中,共享库被认为支持一系列接口版本,每个版本由一个唯一的整数值标识。如果接口的任何公开可见部分在公开发布之间发生变化,就不能再认为它是同一个接口;它因此成为一个新的接口,由一个新的整数标识符标识。每次公开发布库时,如果接口发生了变化,就会获得下一个连续的接口版本号。那些在发布之间以向后兼容的方式发生变化的库被认为同时支持旧接口和新接口;因此,一个特定的库版本可能支持版本 2 到 5 之间的接口。

Libtool 库版本信息通过 libtool 命令行中的 -version-info 选项进行指定,如清单 8-1 所示。

libname_la_LDFLAGS = -version-info 0:0:0

清单 8-1:在 Makefile.am 文件中设置共享库版本信息

Libtool 开发者明智地选择了冒号作为分隔符,而不是使用句点,以避免开发者试图将 Libtool 的版本字符串值与各个平台上共享库文件末尾附加的版本号直接关联。版本字符串中的三个值分别称为接口的当前修订版年龄值。

当前值表示当前接口的版本号。当接口自上次库的公共发布以来以某种公开可见的方式发生变化时,就必须声明新的接口版本,且此值会发生变化。根据约定,库中的第一个接口版本号为零。假设在一个共享库中,开发者自上次公共发布以来添加了一个新函数到该库暴露的函数集中。由于多了一个新函数,这个版本的接口不再和上一个版本相同。因此,当前值必须从零增加到一。

年龄值表示共享库支持的回溯版本的数量。从数学角度来看,可以认为库支持的接口范围是 current − agecurrent。在我刚才给出的例子中,由于向库中添加了一个新函数,所以这个版本的库接口与之前的版本不同。然而,之前的版本仍然完全支持,因为旧接口是当前接口的一个适当子集。因此,年龄值应该从零增加到一。

修订版值仅表示当前接口的一个序列修订版本。也就是说,如果在两个发布版本之间,库的接口没有任何公开可见的更改——可能只是优化了一个内部函数——那么库的名称应该以某种方式发生变化,至少是为了区分这两个版本。但是,当前值和年龄值将保持不变,因为从用户的角度来看,接口没有发生变化。因此,修订版值会增加,以反映这是相同接口的新版本。在前面的例子中,修订版值将保持为零,因为其他两个值之一或两者都已经增加。

为了简化共享库的发布过程,应遵循 Libtool 版本算法,逐步处理每个即将公开发布的库的新版本:^(6)

  1. 对于每个新的 Libtool 库,版本信息从 0:0:0 开始。(如果你从传递给 libtool 脚本的链接器标志列表中省略 -version-info 选项,这一过程会自动完成。)对于现有库,从之前的公共发布的 Libtool 版本信息开始。

  2. 如果自上次更新以来,库的源代码有任何更改,那么修订版c:r:a变为c:r+1:a)。

  3. 如果自上次更新以来,任何导出的函数或数据有添加、删除或更改,增加 current 并将 revision 设置为 0。

  4. 如果自上次公开发布以来,任何导出的函数或数据有添加,增加 age

  5. 如果自上次公开发布以来,任何导出的函数或数据被删除,设置 age 为 0。

请记住,这是一个算法;因此,它设计为逐步执行,而不是直接跳到看似适用于你情况的步骤。例如,如果你自上次发布以来删除了一个 API 函数,你不会简单地跳到最后一步并将 age 设置为零。而是,你需要遵循所有步骤直到最后一步,然后 设置 age 为零。

注意

记住,仅在软件的公开发布之前立即更新版本信息。更频繁的更新是不必要的,并且只会确保 current 接口编号变得更大更快

我们来看一个例子。假设这是库的第二个发布版本,而第一个发布版本使用了 -version-info 字符串 0:0:0。在这个开发周期中,向库接口添加了一个新功能,并删除了一个现有功能。这个新发布版本的版本信息字符串将如下所示:

  1. 从上一个版本信息开始:0:0:0

  2. 0:0:0 变为 0:1:0(库的源代码发生了变化)。

  3. 0:1:0 变为 1:0:0(库的接口已被修改)。

  4. 1:0:0 变为 1:0:1(添加了一个新功能)。

  5. 1:0:1 变为 1:0:0(删除了一个旧功能)。

到现在为止应该很清楚,Libtool 的 currentrevisionage 值与 Linux 的主要版本、次要版本和可选补丁级别之间没有 直接 的关联。相反,使用映射规则将一个方案中的值转换为另一个方案中的值。

返回到前面的例子,其中库的第二个发布版本添加了一个功能并删除了一个功能,我们最终得到了一个新的 Libtool 版本字符串 1:0:0。版本字符串 1:0:0 表示该库与之前的版本不兼容(age 为零),因此 Linux 共享库文件将命名为 libname.so.1.0.0。这看起来像是 Libtool 版本字符串——但不要被迷惑。这种常见的巧合可能是 Libtool 版本化抽象中最让人困惑的方面之一。

我们稍微修改一下例子,假设我们添加了一个新的库接口功能,但没有删除任何东西。重新从原始版本信息 0:0:0 开始,并遵循算法:

  1. 从上一个版本信息开始:0:0:0

  2. 0:0:0 变为 0:1:0(库的源代码发生了变化)。

  3. 0:1:0 变为 1:0:0(库的接口已被修改)。

  4. 1:0:0 变为 1:0:1(添加了一个新功能)。

  5. 不适用(没有删除任何东西)。

这次,我们得到了一个 Libtool 版本字符串 1:0:1,但生成的 Linux 或 Solaris 共享库文件名是 libname.so.0.1.0。考虑一下,在主要版本、次要版本和补丁级别值的情况下,Libtool 版本字符串中有一个非零的 age 值意味着什么。一个 age 值为 1(如本例所示)意味着我们实际上仍然支持 Linux 主版本为零,因为这个新版本的库与之前的版本完全向后兼容。共享库文件名中的次要版本从零增加到一,以表明这实际上是 soname 的更新版本,libname.so.0。补丁级别值保持为零,因为这个值表示对某个 soname 的特定次版本的错误修复。

一旦你完全理解了 Libtool 版本控制,你会发现即使这个算法也无法涵盖所有可能的接口修改场景。例如,假设你维护的共享库版本信息为 0:0:0。现在假设你为下一个公开版本添加了一个新函数。第二个版本正确地定义了版本信息为 1:0:1,因为该库支持接口版本 0 和 1。然而,在库的第三个版本发布之前,你意识到实际上并不需要那个新函数,所以你将其移除。这是本次发布中唯一公开可见的库接口更改。按照算法,版本信息字符串应该设置为 2:0:0。但实际上,你只是移除了第二个接口,现在又重新呈现了原始接口。从技术上讲,这个库应该配置为版本信息字符串 0:1:0,因为它展示了共享库接口版本 0 的第二次发布。这个故事的寓意是,你需要充分理解 Libtool 版本控制的工作方式,然后根据理解决定适当的下一个版本值应该是什么。

我还想指出,GNU Libtool 手册很少描述接口在不同版本的库之间可能存在的各种差异。接口版本不仅表示功能语义,还表示 API 语法。如果你更改了一个函数的语义方式,但保持函数签名不变,那么你仍然改变了这个函数。如果你更改了由共享库发送的数据的网络线格式,那么从消费代码的角度来看,这实际上就不再是相同的共享库了。当操作系统加载器试图确定加载哪个库时,唯一关心的问题是,这个库能像那个库一样正常工作吗? 在这些情况下,答案肯定是否定的,因为即使 API 接口是相同的,两个库在公开可见的方式上做事的方式却不相同。

当库版本控制不足以满足需求时

对库接口的这些更改非常复杂,以至于项目维护者通常会直接重命名库,从而完全避免了库版本问题。一种很好的重命名库的方法是使用 Libtool 的 -release 标志。此标志将库版本信息作为一个独立的类别添加到库的基本名称中,从操作系统加载器的角度来看,这实际上使它成为一个全新的库。-release 标志的使用方式如 列表 8-2 中所示。

libname_la_LDFLAGS = -release 2.9.0 -version-info 0:0:0

列表 8-2:在 Makefile.am 文件中设置共享库发布信息

在这个例子中,我在同一组 Libtool 标志中同时使用了 -release-version-info,只是为了向你展示它们可以一起使用。你会注意到,发布字符串被指定为一系列用点分隔的值。在这种情况下,你的 Linux 或 Solaris 共享库的最终名称将是 libname-2.9.0.so.0.0.0

开发人员选择使用发布字符串的另一个原因是为了在不同平台之间提供某种程度的库版本关联。如前所示,特定的 Libtool 版本信息字符串可能会导致不同平台上库名称的不同,因为 Libtool 将版本信息以不同的方式映射到库名称中,具体取决于平台。发布信息在各平台间保持稳定,但你应该仔细考虑如何在共享库中使用发布字符串和版本信息,因为你选择的使用方式将影响库版本之间的二进制兼容性。如果两个版本的库有不同的发布字符串,操作系统加载器将不会认为它们兼容,无论这些字符串的值如何。

使用 libltdl

现在让我们继续讨论 Libtool 的 ltdl 库。同样,我们需要为 Jupiter 项目添加一些功能,以便说明这些概念。这里的目标是创建一个插件接口,jupiter 程序可以使用该接口根据最终用户的策略选择修改输出。

必要的基础设施

当前,jupiter 输出 Hello from jupiter!(实际上,打印的名称更可能是一个长而难看的路径,包含一些 Libtool 目录垃圾和 jupiter 的某些衍生名称,但现在假装它打印的是 jupiter)。我们将为 common 静态库方法 print_routine 添加一个名为 salutation 的额外参数。这个参数也将是 char 类型的指针,包含 jupiter 问候语中的前导词或短语——即问候语。

列表 8-3 和 8-4 指出了我们需要对 common 子目录中的文件进行的更改。

Git 标签 8.0

--snip--
static void * print_it(void * data)
{
    const char ** strings = data;
    printf("%s from %s!\n", strings[0], strings[1]);
    return 0;
}

int print_routine(const char * salutation, const char * name)
{
    const char * strings[] = {salutation, name};
#if ASYNC_EXEC
    pthread_t tid;
    pthread_create(&tid, 0, print_it, strings);
    pthread_join(tid, 0);
#else
    print_it(strings);
#endif
    return 0;
}

列表 8-3: common/print.c: print_routine 函数添加问候语

int print_routine(const char * salutation, const char * name);

列表 8-4: common/jupcommon.h: print_routine 原型添加问候语

列表 8-5 和 8-6 显示了我们需要对 libjupinclude 子目录中的文件进行的更改。

--snip--
int jupiter_print(const char * salutation, const char * name)
{
    print_routine(salutation, name);
}

列表 8-5: libjup/jup_print.c: jupiter_print 函数添加问候语

--snip--
int jupiter_print(const char * salutation, const char * name);
--snip--

列表 8-6: include/libjupiter.h: jupiter_print 原型添加问候语

最后,列表 8-7 显示了我们需要在 src 目录下的 main.c 中所做的更改。

--snip--
#define DEFAULT_SALUTATION "Hello"

int main(int argc, char * argv[])
{
    const char * salutation = DEFAULT_SALUTATION;
    return jupiter_print(salutation, argv[0]);
}

列表 8-7: src/main.c: jupiter_print 传递问候语

需要明确的是,我们所做的实际上只是将问候语参数化到打印程序中。这样,我们可以从 main 中指定我们想使用的问候语。我将默认问候语设置为 Hello,以便从用户的角度看,什么都没有改变。因此,这些更改的整体效果是无害的。还需要注意的是,这些都是源代码级的更改——我们没有对构建系统做任何更改。我想将这些更改独立出来,以免将这种必要的重构与我们为添加新的模块加载功能所做的构建系统改动混淆。

在做出这些更改后,是否应该更新这个共享库的版本号?这取决于你在做出更改之前是否已经发布过这个库(即发布了一个 tarball)。版本控制的目的是保持对公共接口的一定控制——但是如果只有你一个人看过它,那么更改版本号就没有意义了。

添加插件接口

我希望能够通过简单地改变运行时加载的插件模块来更改显示的问候语。我们需要对代码和构建系统做的所有更改,将仅限于 configure.ac 文件以及 src 目录及其子目录中的文件。

首先,我们需要定义实际的插件接口。我们将通过在 src 目录下创建一个新的私有头文件 module.h 来完成这项工作。这个文件如 列表 8-8 所示。

Git 标签 8.1

   #ifndef MODULE_H_INCLUDED
   #define MODULE_H_INCLUDED

➊ #define GET_SALUTATION_SYM "get_salutation"

➋ typedef const char * get_salutation_t(void);
➌ const char * get_salutation(void);

   #endif /* MODULE_H_INCLUDED */

列表 8-8: src/module.h: 该文件的初始内容

这个头文件有许多有趣的方面。首先,让我们来看位于 ➊ 的预处理器定义 GET_SALUTATION_SYM。这个字符串表示你需要从插件模块导入的函数名。我喜欢在头文件中定义这些,这样所有需要对齐的信息都能集中在一个地方。在这种情况下,符号名称、函数类型定义和函数原型必须保持一致,你可以用这个单一的定义来处理所有三个部分。

另一个有趣的项是位于 ➋ 的类型定义^(7)。如果我们不提供一个类型定义,用户就必须自己发明一个,或者在 dlsym 函数的返回值上使用复杂的类型转换。因此,我们将在这里提供它,以确保一致性和便捷性。

最后,看看 ➌ 处的函数原型。这不仅是为了调用者,而是为了模块本身。提供此函数的模块应包含此头文件,以便编译器能捕捉到潜在的函数名拼写错误。

传统方式操作

对于第一次尝试,让我们使用 Solaris/Linux libdl.so 库提供的 dl 接口。在下一节中,我们将把这段代码转换为 Libtool ltdl 接口,以提高移植性。

为了正确完成此操作,我们需要在 configure.ac 中添加检查,寻找 libdl 库和 dlfcn.h 头文件。这些对 configure.ac 的更改在 清单 8-9 中突出显示。

   --snip--
   # Checks for header files.
➊ AC_CHECK_HEADERS([stdlib.h dlfcn.h])
   --snip--
   # Checks for libraries.

   # Checks for typedefs, structures, and compiler characteristics.

   # Checks for library functions.
➋ AC_SEARCH_LIBS([dlopen], [dl])
   --snip--
   cat << EOF
   -------------------------------------------------

   ${PACKAGE_NAME} Version ${PACKAGE_VERSION}

   Prefix: '${prefix}'.
   Compiler: '${CC} ${CFLAGS} ${CPPFLAGS}'
➌ Libraries: '${LIBS}'
   --snip--

清单 8-9: configure.ac: 添加对 dl 库和公共头文件的检查

在 ➊ 处,我将 dlfcn.h 头文件添加到传递给 AC_CHECK_HEADERS 宏的文件列表中,然后在 ➋ 处,我检查了 dl 库中的 dlopen 函数。这里需要注意的是,AC_SEARCH_LIBS 宏会在库列表中查找一个函数,因此此调用属于“库函数检查”部分,而非“库检查”部分。为了帮助我们查看实际链接的库,我还在文件末尾的 cat 命令中添加了一行。 ➌ 处的 Libraries: 行显示了 LIBS 变量的内容,该变量由 AC_SEARCH_LIBS 宏修改。

注意

LT_INIT 宏也会检查 dlfcn.h 头文件的存在,但我在这里显式地进行检查,以便观察者能明确看到我希望使用这个头文件。这是一个很好的经验法则,只要它不会对性能造成太大影响。由于 Autoconf 会缓存检查的结果,因此这种情况不太可能发生。你可以通过查看 configure 输出中出现的 (cached) ... 来知道这正在发生。

添加模块需要做几个更改,所以我们将一起进行这些更改,首先执行以下命令:

$ mkdir -p src/modules/hithere
$

我创建了两个新的子目录。第一个是 modules,位于 src 目录下,第二个是 hithere,位于 modules 目录下。每个新增的模块都将在 modules 目录下有自己的子目录。hithere 模块将提供问候语 Hi there

清单 8-10 说明了如何将 SUBDIRS 变量添加到 src/Makefile.am 文件中,以确保构建系统能够处理 modules/hithere 目录。

➊ SUBDIRS = modules/hithere

   bin_PROGRAMS = jupiter
➋ jupiter_SOURCES = main.c module.h
   --snip--
   greptest.sh:
➌ echo './jupiter | grep ".* from .*jupiter!"' > greptest.sh
   --snip--

清单 8-10: src/Makefile.am: 向此 Makefile.am 文件添加 SUBDIRS 变量

我在 ➊ 使用 SUBDIRS 的方式引入了一个新概念。到目前为止,Jupiter 的 Makefile.am 文件只引用了当前目录的直接子目录,但正如你所见,这并非严格必要。事实上,对于 Jupiter,modules 目录只会包含额外的子目录,因此提供一个 modules/Makefile.am 文件只是为了引用其子目录没有太大意义。

在编辑文件时,您应该将新的module.h头文件添加到SOURCES变量中,如➋所示。如果不这样做,jupiter仍然会为您作为维护者正确编译和构建,但distcheck目标将失败,因为没有任何Makefile.am文件提到module.h

我们还需要改变greptest.sh脚本的构建方式,以便它能够测试任何类型的问候语。只需在➌处简单修改正则表达式即可。

我在新的hithere子目录中创建了一个Makefile.am文件,里面包含了如何构建hithere.c源文件的指令,然后我将hithere.c源文件添加到该目录中。这些文件分别显示在列表 8-11 和 8-12 中。

   pkglib_LTLIBRARIES = hithere.la
   hithere_la_SOURCES = hithere.c
➊ hithere_la_LDFLAGS = -module -avoid-version

列表 8-11: src/modules/hithere/Makefile.am: 该文件的初始版本

#include "../../module.h"

const char * get_salutation(void)
{
    return "Hi there";
}

列表 8-12: src/modules/hithere/hithere.c: 该文件的初始版本

hithere.c源文件通过双引号相对路径引用半私有的module.h头文件。由于 Automake 会自动将-I$(srcdir)添加到使用的include路径列表中,C 预处理器将正确处理相对路径。然后,文件定义了get_salutation函数,其原型位于module.h头文件中。该实现简单地返回指向静态字符串的指针,只要库被加载,调用者就可以访问该字符串。然而,调用者必须注意插件模块返回的数据引用的作用域;否则,程序可能会在调用者完成使用之前卸载该模块。

hithere/Makefile.am的最后一行(在列表 8-11 中的➊处)需要一些解释。在这里,我们在hithere_la_LDFLAGS变量上使用了-module选项。这是 Libtool 的一个选项,告诉 Libtool 您希望将库命名为hithere,而不是libhithereGNU Libtool 手册中指出,模块不需要以lib为前缀。而且,由于您的代码将手动加载这些模块,因此不必担心确定和正确使用特定平台的库名称前缀。

如果您不关心对动态加载的(dlopen加载的)模块使用版本控制,可以尝试使用 Libtool 的-avoid-version选项。此选项使 Libtool 生成一个共享库,库的名称是libname.so,而不是libname.so.0.0.0。它还会抑制生成libname.so.0libname.so的软链接,这些链接指向二进制镜像。因为我同时使用了这两个选项,所以我的模块将简单地命名为hithere.so

为了使这个模块能够构建,我们需要将新的hithere模块的 makefile 添加到configure.ac中的AC_CONFIG_FILES宏中,如列表 8-13 所示。

--snip--
AC_CONFIG_FILES([Makefile
                 common/Makefile
                 include/Makefile
                 libjup/Makefile
                 src/Makefile
                src/modules/hithere/Makefile])
--snip--

列表 8-13: configure.ac: hithere 目录的 makefile 添加到AC_CONFIG_FILES

最后,为了使用该模块,我们需要修改src/main.c以便加载模块、导入符号并调用它。这些对src/main.c的更改在清单 8-14 中以粗体显示。

   #include "config.h"

   #include "libjupiter.h"
➊ #include "module.h"

➋ #if HAVE_DLFCN_H
   # include <dlfcn.h>
   #endif

   #define DEFAULT_SALUTATION "Hello"

   int main(int argc, char * argv[])
   {
       int rv;
       const char * salutation = DEFAULT_SALUTATION;
➌ #if HAVE_DLFCN_H
       void * module;
       get_salutation_t * get_salutation_fp = 0;
    ➍ module = dlopen("./module.so", RTLD_NOW);
       if (module != 0)
       {
           get_salutation_fp = (get_salutation_t *)dlsym(
                     module, GET_SALUTATION_SYM);
           if (get_salutation_fp != 0)
               salutation = get_salutation_fp();
       }
   #endif

       rv = jupiter_print(salutation, argv[0]);

➎ #if HAVE_DLFCN_H
       if (module != 0)
           dlclose(module);
   #endif

       return rv;
   }

清单 8-14: src/main.c:main函数使用新的插件模块

我在➊处包含了新的私有module.h头文件,并且在➋处添加了一个预处理指令,以有条件地包含dlfcn.h。最后,我在原始调用jupiter_print的前后分别添加了两段代码(分别在➌和➎处)。这两段代码都是基于动态加载器的存在条件编译的,这使得代码能够在没有提供libdl库的系统上正确构建和运行。

我决定是否进行条件编译时使用的一般哲学是:如果configure因缺少某个库或头文件而失败,那么我不需要有条件地编译使用configure检查项的代码。如果我在configure中检查某个库或头文件,但允许它缺失而继续,那么我最好使用条件编译。

关于使用dl接口函数,还有一些小细节需要提及。首先,在➍处,dlopen接受两个参数:一个文件名或路径(绝对路径或相对路径)和一个flags标志,它是你选择的多个在dlfcn.h中定义的标志值的按位组合。可以查看dlopen的手册页,了解更多关于这些标志位的信息。如果使用路径,dlopen会完全遵循该路径,但如果使用文件名,则会在库搜索路径中查找模块。通过在文件名前加上./,我们告诉dlopen不要搜索库路径。

我们希望能够配置jupiter使用哪个模块,因此我们加载了一个通用名称,module.so。事实上,构建后的模块位于构建树中src目录下的多个子目录中,因此我们需要在当前目录创建一个名为module.so的软链接,指向我们希望加载的模块。这是一种相对简陋的 Jupiter 配置方式,但它有效。在实际应用中,你会通过某种配置文件中定义的策略来指定要加载的模块,但在这个示例中,为了简便起见,我忽略了这些细节。

注意

我在清单 8-14 中忽略了一些错误处理。在生产代码中,如果模块未能加载或模块未导出符号,你可能希望记录或显示某些信息。

以下命令序列展示了我们可加载模块的工作情况:

$ autoreconf -i
--snip--
$ ./configure && make
--snip--
$ cd src
$ ./jupiter
Hello from ...jupiter!
$
$ ln -s modules/hithere/.libs/hithere.so module.so
$ ./jupiter
Hi there from ...jupiter!
$

注意

符号链接module.so指向一个隐藏的 .libs 目录中的文件。可执行文件和库由 Autotools 构建系统生成,并存放在关联源目录内的 .libs 目录中

转换为 Libtool 的 ltdl 库

Libtool 提供了一个名为ltdl的包装库,它抽象并隐藏了在多个不同平台上使用共享库时遇到的一些可移植性问题。大多数应用程序因为使用它时的复杂性而忽略了ltdl库,但实际上需要处理的问题并不多。我将在这里列出这些问题,并在后续详细讲解。

  • ltdl函数遵循基于dl库的命名约定。经验法则是,ltdl库中的dl函数以lt_为前缀。例如,dlopen被命名为lt_dlopen

  • dl库不同,ltdl库必须在应用程序中适当的位置进行初始化和终止。

  • 应用程序应该使用-dlopen modulename选项在链接器命令行中进行构建(在*_LDFLAGS变量中)。这告诉 Libtool 在没有共享库的平台或静态链接时,将模块代码链接到应用程序中。

  • LTDL_SET_PRELOADED_SYMBOLS()宏应在程序源代码的适当位置使用,以确保在非共享库平台或构建仅静态配置时,模块代码能够被访问。

  • 设计为使用dlopen加载的共享库模块应在链接器命令行中使用-module选项(可选地,还可以使用-avoid-version选项)(在*_LDFLAGS变量中)。

  • ltdl库提供了超出dl库的广泛功能;这可能会让人感觉有些吓人,但请知道,所有这些额外的功能都是可选的。

让我们来看看如何修改 Jupiter 项目的构建系统,以便使用ltdl库。首先,我们需要修改configure.ac文件,去查找ltdl.h头文件并搜索lt_dlopen函数。这意味着需要修改在AC_CHECK_HEADERSAC_SEARCH_LIBS宏中对dlfcn.hdl库的引用,如示例 8-15 所示。

Git 标签 8.2

--snip--
# Checks for header files.
AC_CHECK_HEADERS([stdlib.h ltdl.h])
--snip--
# Checks for libraries.

# Checks for typedefs, structures, and compiler characteristics.

# Checks for library functions.
AC_SEARCH_LIBS([lt_dlopen], [ltdl])
--snip--

示例 8-15: configure.ac:在 configure.ac 中从dl切换到ltdl

即使我们使用了 Libtool,我们仍然需要检查ltdl.hlibltdl,因为ltdl是一个独立的库,必须安装在最终用户的系统上。它应该像任何其他所需的第三方库一样对待。通过在用户的系统中搜索这些已安装的资源,并在找不到时失败配置,或者在源代码中正确使用预处理器定义,你可以提供与使用其他第三方资源时相同的配置体验。

我希望你能意识到,这是我们第一次看到用户需要在其系统上安装 Autotools 软件包的要求——这正是大多数人避免使用 ltdl 的原因。《GNU Libtool 手册》提供了详细的描述,说明如何将 ltdl 库与项目一起打包,以便在构建和安装你的软件包时,它会在用户系统上构建并安装。^(8)

有趣的是,将 ltdl 库的源代码随你的软件包一起发布是让你的程序与 ltdl 库进行静态链接的唯一方法。与 ltdl 静态链接的副作用是,不需要用户在他们的系统上安装 ltdl 库,因为该库会成为项目可执行文件的一部分。然而,有一些警告需要注意。如果你的项目还使用了一个动态链接到 ltdl 的第三方库,你将会遇到共享版和静态版 ltdl 库之间的符号冲突。^(9)

我们需要进行的下一个重大更改是在源代码中——在这种情况下,仅限于 src/main.c,并在 清单 8-16 中突出显示。

#include "config.h"

#include "libjupiter.h"
#include "module.h"

#if HAVE_LTDL_H
# include <ltdl.h>
#endif

#define DEFAULT_SALUTATION "Hello"

int main(int argc, char * argv[])
{
    int rv;
    const char * salutation = DEFAULT_SALUTATION;

#if HAVE_LTDL_H
    int ltdl;
  ➊ lt_dlhandle module;
     get_salutation_t * get_salutation_fp = 0;

  ➋ LTDL_SET_PRELOADED_SYMBOLS();

  ➌ ltdl = lt_dlinit();
     if (ltdl == 0)
     {
      ➍ module = lt_dlopen("modules/hithere/hithere.la");
         if (module != 0)
         {
             get_salutation_fp = (get_salutation_t *)lt_dlsym(
                       module, GET_SALUTATION_SYM);
             if (get_salutation_fp != 0)
                 salutation = get_salutation_fp();
         }
      }
#endif

    rv = jupiter_print(salutation, argv[0]);

#if HAVE_LTDL_H
    if (ltdl == 0)
    {
 if (module != 0)
            lt_dlclose(module);
        lt_dlexit();
    }
#endif

    return rv;
}

清单 8-16: src/main.c: 在源代码中从dl切换到ltdl

这些更改在原始代码中是非常对称的。通常,之前提到 DLdl 的地方现在变成了提到 LTDLlt_dl。例如,#if HAVE_DLFCN_H 变成了 #if HAVE_LTDL_H,等等。

一个重要的变化是,ltdl 库必须在➌处通过调用 lt_dlinit 进行初始化,而 dl 库则不需要初始化。在一个较大的程序中,调用 lt_dlinitlt_dlexit 的开销会在更大的代码基础中得到摊销。

另一个重要细节是在➋处添加了 LTDL_SET_PRELOADED_SYMBOLS 宏调用。该宏配置了 lt_dlopenlt_dlsym 函数所需的全局变量,适用于不支持共享库的系统,或者在终端用户特别要求静态库的情况下。对于使用共享库的系统,这个调用是无害的。

最后一项细节是,dlopen 的返回类型是 void *,即通用指针,而 lt_dlopen 的返回类型是 lt_dlhandle(见 ➊ 和 ➍)。这种抽象的存在是为了让 ltdl 可以移植到使用与通用指针不兼容的返回类型的系统上。

当系统不支持共享库时,Libtool 实际上会将所有可能加载的模块直接链接到程序中。因此,jupiter 程序的链接器(libtool)命令行必须包含对这些模块的某种形式的引用。这是通过使用 -dlopen modulename 构造来完成的,如 清单 8-17 所示。

--snip--
jupiter_LDADD = ../libjup/libjupiter.la -dlopen modules/hithere/hithere.la
--snip--

清单 8-17: src/Makefile.am: LDADD 行中添加 -dlopen 选项

如果你忘记了将这个内容添加到src/Makefile.am中,你将得到一个关于未定义符号的链接器错误——类似于lt__PROGRAM__LTX_preloaded _symbols。如果 Libtool 没有检测到任何模块被链接到应用程序中,它不会将符号污染程序的全局符号空间,这些符号永远不会被引用;如果符号表为空,ltdl库所需的符号将会缺失。

看起来ltdl在指定lt_dlopen时可以引用模块的路径信息方面不如dl灵活。为了解决这个问题,我将正确的相对路径(modules/hithere/hithere.la)硬编码到main.c中。此外,这个示例对当前工作目录很敏感。如果你从另一个目录运行jupiter,它也会无法找到该模块。一个真正的程序无疑会使用更健壮的配置方法,比如包含目标模块名称绝对路径的配置文件。^(10)

预加载多个模块

如果 Libtool 在没有共享库支持的系统上将多个模块链接到一个程序中,并且这些模块各自提供自己的get_salutation版本,那么在程序的全局符号空间中就会出现公共符号冲突。这是因为所有这些模块的符号都成为程序全局符号空间的一部分,而链接器通常不允许将两个同名的符号添加到可执行符号表中。应该尊重哪个模块的get_salutation函数呢?不幸的是,解决这个冲突没有一个好的启发式方法。GNU Libtool 手册通过定义一种约定来解决这种情况,从而保持符号命名的唯一性:

  • 所有导出的接口符号应该以modulename_LTX _(例如,hithere_LTX_get_salutation)作为前缀。

  • 所有剩余的非静态符号应当具有合理的唯一性。Libtool 建议的方法是以_modulename_(例如_jupiter_somefunction)作为前缀。

  • 即使模块在不同的目录中构建,它们的命名也应当不同。

尽管手册中没有明确说明,lt_dlsym函数首先会以modulename_LTX_symbolname的形式查找指定的符号,如果找不到带前缀的符号,它接着会查找完全相同的symbolname。可以看出,这一约定是必要的,但仅仅在 Libtool 可能会将这些可加载模块静态链接到不支持共享库的系统上的应用程序中时,才需要此约定。在不支持共享库的系统上,Libtool 所提供的共享库的幻象代价相当高,但它是为了在所有平台上获得相同的可加载模块功能所必须支付的代价。

要修复hithere模块的源代码,使其符合这一约定,我们需要对hithere.c进行一次更改,如列表 8-18 所示。

➊ #define get_salutation hithere_LTX_get_salutation
➋ #include "../../module.h"

   const char * get_salutation(void)
   {
      return "Hi there";
   }

示例 8-18: src/modules/hithere/hithere.c: 在使用 ltdl 时确保公共符号唯一

通过在➊位置定义 get_salutation 的替代版本,并在➋位置包含 module.h 头文件,我们还能够修改头文件中的原型,使其与修改后的函数名称版本匹配。由于 C 预处理器的工作方式,这种替换仅影响 module.h 中的函数原型,而不会影响引号中的符号字符串或类型定义。此时,你可能想回去查看 module.h 的写法,以证明这一点确实有效。

检查所有内容

你可以通过在 configure 命令行上使用 --disable-shared 选项来测试你的程序和模块,测试静态和动态共享库系统,如下所示:

   $ make clean
   --snip--
   $ autoreconf
   $ ./configure --disable-shared && make
   --snip--
   $ cd src
   $ ls -1p modules/hithere/.libs
➊ hithere.a
   hithere.la
   hithere.lai
   $
   $ ./jupiter
➋ Hi there, from ./jupiter!
   $
   $ cd ..
   $ make clean
   --snip--
   $ ./configure && make
   $ cd src
   $ ls -1p modules/hithere/.libs
   hithere.a
   hithere.la
   hithere.lai
   hithere.o
   hithere.so
   $
   $ ./jupiter
➌ Hi there, from ...jupiter!
   $

如你所见,➋ 和 ➌ 位置的输出都包含了 hithere 模块的问候语,但在➊位置的文件列表显示,在 --disable-shared 版本中,根本不存在共享库。看起来 ltdl 正在发挥它的作用。

注意

你可能已经注意到,示例中两次执行 jupiter 的输出有所不同。在第一次情况下,输出显示程序名称为 ./jupiter,而在第二次情况下,显示的是 ...jupiter。这是我尝试去除输出中由 Libtool 重定向共享库版本所造成的冗余信息——指向实际程序的 lt-jupiter——位于 jupiter /src/.libs 目录中。Libtool 使用一个包装器来链接到构建共享库的程序,以简化未安装程序找到其依赖的共享库。

Jupiter 代码库变得相当脆弱,因为我忽视了运行时如何找到共享库的问题。正如我之前提到的,你最终必须在实际程序中解决这个问题。但鉴于我已经完成了向你展示如何正确使用 Libtool ltdl 库的任务,我会把这个问题留给你作为练习。

摘要

使用共享库的决定带来了许多问题,如果你追求最大兼容性,你必须处理每一个问题。ltdl 库并不是解决所有问题的万能钥匙。它解决了一些问题,但也带来了其他问题。可以说,使用 ltdl 有其权衡,如果你不介意额外的维护工作,它是为你的可加载模块项目增加最大兼容性的一种好方法。

我希望通过花时间完成本书中的练习,你能够理解 Autotools 的基本概念,知道它们如何工作以及它们为你做了什么。此时,你应该非常熟悉如何将 autotool 应用到你自己的项目中——至少是在基础层面上。

在接下来的章节中,我将讨论一些额外的工具和实用程序,这些工具也被认为是 GNU 工具箱的一部分(还有一两个不是)。我还将向你展示如何将一个真实世界的项目从手动编码的构建系统转换为更加简洁、可能更加正确的 Autotools 构建系统。

第九章:使用 AUTOTEST 进行单元和集成测试

...学习不是知道;有学习者和被学者。记忆造就了前者,哲学造就了后者。

—亚历山大·仲马,《基督山伯爵》*

Image

测试是重要的。所有开发者都在某种程度上测试他们的软件,否则他们无法知道产品是否符合设计标准。在这个谱系的一端,作者编译并运行程序。如果程序呈现出他们设想的通用接口,他们就认为完成了。在另一端,作者编写了一套测试,尽可能地在不同条件下检验代码的各个部分,验证输出对于指定输入是正确的。努力追求这个谱系的最右端,我们会看到这样的人,他们字面上先写测试,然后迭代地添加和修改代码,直到所有测试通过。

在本章中,我不会尝试详细阐述测试的优点。我假设每个开发者都同意某种程度的测试是重要的,无论他们是那种编译-运行-发布类型的人,还是正统的测试驱动开发者。我还假设每个开发者对测试都有一定的反感,这种反感程度在这个谱系上有所不同。因此,我们在这里的目标是尽可能让别人做大部分的测试工作。在这种情况下,“别人”指的是 Autotools。

在第三章中,我们为 Jupiter 添加了一个手写的 makefile 测试。该测试的输出完全由我们放入src/Makefile中的make脚本控制:

$ make check
cd src && make check
make[1]: Entering directory '/.../jupiter/src'
cc -g -O0    -o jupiter main.c
./jupiter | grep "Hello from .*jupiter!"
Hello from ./jupiter!
*** All TESTS PASSED
make[1]: Leaving directory '/.../jupiter/src'
$

当我们在第四章和第五章中转向 Autoconf 时,变化不大。输出依然由我们手写的make脚本控制。我们只是将它移到了src/Makefile.in中。

然而,在第六章中,我们放弃了手写的 makefile 和模板,转而使用 Automake 更为简洁的Makefile.am文件。然后,我们需要弄清楚如何将我们手写的测试嵌入到 automake 脚本中。在这样做的过程中,我们对测试输出进行了些许升级:

$ make check
--snip--
PASS: greptest.sh
============================================================================
Testsuite summary for Jupiter 1.0
============================================================================
# TOTAL: 1
# PASS:    1
# SKIP:    0
# XFAIL: 0
# FAIL:    0
# XPASS: 0
# ERROR: 0
============================================================================
$

如果你在终端上运行这个代码,并且使用的是一个相对较新的 Automake 版本,你甚至会看到彩色的测试输出。

每个添加到TESTS变量中的测试都会在我们的src/Makefile.am模板中生成一个PASS:FAIL:行,并且汇总值会计算所有的测试结果。这是因为 Automake 有一个很好的内置测试框架,驱动它的是TESTS变量。如果你需要构建TESTS中指定的任何文件,你只需要像我们为简单的驱动脚本(greptest.sh)所做的那样创建一个规则。如果你的测试程序需要从源代码构建,你可以使用check_PROGRAM变量。Automake 的测试框架有一个小问题,如果测试分布在多个目录中,你会在make check时看到多次类似的显示,这可能会有点烦人,特别是在使用make -k(在遇到错误时继续)时,因为你可能没有意识到需要向上滚动以查看早期可能失败的测试输出。

除了TESTS变量,如果你将XFAIL_TESTS变量设置为TESTS中测试的一个子集,你可能还会看到XFAIL:XPASS:行中的一些输出。这些是预期会失败的测试。当这样的测试通过时,它们会列在XPASS:行中,作为意外通过。当它们失败时,它们会列在XFAIL:行中,作为预期失败

返回 77 的测试会增加SKIP:行中的计数,而返回 99 的测试会增加ERROR:行中的计数。我将在本章稍后提供有关测试返回的特殊 shell 代码的更多详细信息。

正如你现在可能已经猜到的那样,Autoconf 也包含了一个测试框架,叫做autotest,它提供了所有所需的基础设施,让你能够简单而轻松地指定一个测试来验证你代码的某些部分。结果以一致且易于理解的方式显示,失败的测试也能在独立的环境中轻松重现,并配有内置的调试环境。几乎让你想写测试了,不是吗?事实是,一个设计良好的测试框架,就像任何其他设计良好的工具一样,是一种乐趣。

此外,autotest 是可移植的——只要你使用可移植的脚本或代码编写你的测试部分,整个测试套件就可以 100%移植到任何可以运行configure脚本的系统。这并不像听起来那么难。你通常需要编写的 shell 脚本只是运行一个命令,而命令背后的代码使用 Autotools 提供的可移植性功能编写,并且是通过 Autotools 提供的构建过程生成的。

几年来,autotest 一直被文档描述为“实验性”。不过,无论如何,它的基本功能在这些年中变化不大,Autoconf 使用它来测试自己,正如我们在第一章中看到的那样。因此,是时候停止担心它是否会改变,开始将它用于预定的目的:让软件开发者的测试工作变得不那么繁琐,毕竟,软件开发者真正希望做的是写代码,让别人来担心测试。

作为理性生物,我们无法否认测试的重要性。我们能做的是利用好的工具,让我们能专注于代码本身,而让像 autotest 这样的框架来处理诸如结果格式化、成功/失败语义、用户提交的 bug 报告的数据收集以及可移植性等附加问题。正如我们在本章中将看到的那样,这就是 autotest 提供的价值。

本着透明的精神,我承认,对于像 Jupiter 这样的较小的测试套件,使用 autotest 是很难证明其合理性的。Automake 中内建的测试工具已经足够满足大多数小型项目的需求。较大的项目——例如 Autoconf 本身,它有超过 500 个单元和集成测试,测试覆盖整个项目目录结构中的功能,甚至包括已安装的组件——则是完全不同的情况。

Autotest 概述

autotest 使用和生成的文件有三个阶段。第一阶段是 GNU Autoconf 手册 所称的“准备分发”阶段。第二阶段发生在执行 configure 时,第三阶段发生在执行测试套件时。让我们逐一看看这些阶段。

第一阶段发生在构建分发档案时,实际上是生成可以在用户系统上运行的可执行测试程序的过程。可能会觉得奇怪,为什么这个过程必须在构建分发档案时进行;然而,Autoconf 必须安装在任何需要生成此程序的系统上,因此 testsuite 程序必须在构建分发档案时构建,以便将其包含在归档文件中供用户使用。在执行 distdistcheck 目标时,configure(以及使用 distcheck 时的 make check)会被执行;make check 封装了重建测试程序的规则,使用 autom4te —— Autoconf 缓存的 m4 驱动程序。测试程序在执行 dist 目标时构建,这是因为它被包括在 Automake 的 EXTRA_DIST 变量中,我将在本章结束时讲解这个内容。

图 9-1 显示了在执行 make dist(或 make distcheck)时,从维护者编写的源文件到 testsuite 程序的数据流。

Image

图 9-1:从维护者编写的输入文件到 testsuite 程序的数据流

在图的第二列顶部找到的 testsuite.at 文件,是由项目作者编写的主要测试文件。实际上,这是 autotest 所需的唯一维护者编写的文件。与 configure.ac 完全相同,这个文件包含了 shell 脚本,并掺杂了 M4 宏定义和调用。该文件通过 M4 宏处理器处理,由 autom4te 作为 m4 的驱动程序,生成位于最后一列顶部的 testsuite 程序,这是纯粹的 shell 脚本。这个过程发生在执行 autom4te 时,make check 读取我们编写的 Makefile.inMakefile.am 文件生成的 makefile,并驱动该过程。准备发布的概念来源于 check 目标在 make distcheck 时执行(这当然会构建分发档案);testsuite 程序会在这个过程中被添加到分发档案中。它是在 make dist 过程中构建的,make dist 并不会执行 make check,因为在所有列在 EXTRA_DIST 中的文件被构建之前,它们不能包含在分发档案中。

诸如此类的细节通常会被 Autotools 隐藏起来,但由于 autotest 仍被视为实验性的——意味着它尚未完全融入 Autotools 套件——因此一些额外的基础设施责任落在了我们这些维护者身上。我们将很快讨论这些细节。

Autoconf 手册建议,测试套件的作者可以将一组组相关的测试,称为测试组,放入单独的 .at 文件中。然后,testsuite.at 文件仅包含一系列 m4_include 指令,包含这些特定于组的 .at 文件。因此,M4 包含机制是通过它将可选的 test-1.attest-N.at 文件汇集到 testsuite.at 中,由 M4 进行处理。

package.m4local.at 文件是可选的维护者编写(或生成)的输入文件,在处理 testsuite.at 时,如果找到了它们,会自动由 autom4te 包含。前者包含有关测试套件的基本信息,这些信息会在控制台上显示,并嵌入由测试套件生成的错误报告中。后者,手册建议,是一种可选机制,我们可以选择使用,它可以帮助我们保持 testsuite.at 文件简洁,不包含全局定义、与组无关的测试,以及可能由实际测试使用的辅助宏定义和 shell 函数。我们将在本章后面讨论这些文件的具体内容。

configure 脚本用于 autotest 时,配置过程会生成额外的与 autotest 相关的产物。图 9-2 显示了在配置过程中,关于 autotest 的图示。

图片

图 9-2:在生成与测试相关的模板时,configure* 过程中数据流的示意图*

回想一下第二章中的内容,config.status 驱动配置过程中的文件生成部分。当为 autotest 设置 configure.ac 文件时,config.status 会生成 atconfig——一个设计为由 testsuite 在执行时调用的 shell 脚本。^(1) 它包含源代码树和构建树的变量,如 at_testdirabs_builddirabs_srcdirat_top_srcdir 等,以便在测试套件执行期间访问源代码和构建树中的文件及产物。

测试作者还可以选择创建一个名为 atlocal.in 的模板文件,允许他们根据需要将额外的 Autoconf 和项目特定的配置变量传递到测试环境中。该模板的产物是 atlocal——同样是一个 shell 脚本,设计为由 testsuite 调用(如果它存在)。如果你选择编写 atlocal.in,必须将其添加到 configure.ac 中传递给 AC_CONFIG_FILES 调用的标签列表中。稍后我们将在将 Jupiter 的 async_exec 标志导出到我们的测试套件时看到如何执行此操作。

注意

不要将 atlocal 与图 9-1 中的 local.at 混淆。图 9-2 中的 atlocal 文件,由 testsuite 在运行时调用,用于将配置变量从 configure 传递到测试环境,而 local.at 文件是由项目维护者直接编写的,包含在生成 testsuite 时由 autom4te 处理的额外测试代码。

图 9-3 展示了 testsuite 执行过程中的数据流。

Image

图 9-3:testsuite 脚本执行过程中的数据流

如前所述,testsuite 会获取 atconfigatlocal(如果存在)来访问源代码树和构建树信息以及其他与项目相关的变量,然后执行生成的测试。执行过程中,它会创建一个 testsuite.log 文件,包含每个测试执行的详细信息。你在屏幕上看到的每行文本代表一个测试的结果。^(2)

testsuite 程序会生成一个名为 testsuite.dir 的目录。在这个目录中,每个测试都会创建一个单独的子目录。测试套件不会删除失败测试的特定子目录;我们可以利用该目录结构的内容获取详细信息并调试问题。稍后我们将详细介绍这些目录中添加了什么内容。

testsuite 程序当然可以手动执行,但首先必须生成它。Autoconf 手册建议将生成 testsuite 程序的过程直接与 check 目标绑定,这样当执行 make check 时,testsuite 会被生成(如果它缺失或与其依赖关系不一致),然后再执行。

由于 testsuite 已被添加到分发包中,运行 make check 的最终用户将仅执行现有的 testsuite 程序,除非他们修改了 testsuite 依赖的某些文件,在这种情况下,make 会尝试重新生成 testsuite。如果没有安装 Autoconf,这个过程会失败。幸运的是,用户通常不需要修改分发包中 testsuite 的任何依赖项。

配置 Autotest

由于我这里的目标是教你如何使用这个框架,因此我选择的配置 Jupiter 用于 autotest 的方法是包含图 9-1 至 9-3 中所示的整个可选文件集。这使我们能够准确地探索所有内容如何一起工作。虽然对于像 Jupiter 这样规模的项目,这种方法可能不必要,但它确实能正确工作,而且以后可以随时删减。我将在本章末尾向你展示,如何删除一些内容,将 autotest 输入文件集精简到最少。

在我们编写测试之前,需要让 configure.ac 知道我们希望使用 autotest。这是通过在 configure.ac 中添加两个宏调用来实现的,如 列表 9-1 所示。

Git 标签 9.0

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.69])
AC_INIT([Jupiter],[1.0],[jupiter-bugs@example.org])
AM_INIT_AUTOMAKE
LT_PREREQ([2.4.6])
LT_INIT([dlopen])
AC_CONFIG_SRCDIR([src/main.c])
AC_CONFIG_HEADERS([config.h])

AC_CONFIG_TESTDIR([tests])
AC_CONFIG_FILES([tests/Makefile
                 tests/atlocal])

# Checks for programs.
AC_PROG_CC
AC_PROG_INSTALL
--snip--

列表 9-1: configure.ac: 将 autotest 集成到 configure.ac

这两个宏中的第一个,AC_CONFIG_TESTDIR,告诉 Autoconf 启用 autotest,并指定测试目录将命名为 tests。如果愿意,你可以在这里使用点(.)表示当前目录,但 GNU Autoconf 手册 建议使用单独的目录,以便更容易管理测试输出文件和目录。

注意

AC_CONFIG_TESTDIR 添加到 configure.ac 实际上是启用 autotest 所需的唯一更改,尽管为了使其更有用和更自动化,还需要更改 makefile 和添加支持文件。有趣的是,这个重要的细节在手册中没有直接提到,尽管它是微妙地暗示的。

第二行是标准的 Autoconf AC_CONFIG_FILES 实例化宏。我在这里使用了它的一个独立实例,用于从模板生成与测试相关的文件。

我们来看看这些文件中包含了什么。第一个是为tests目录生成的 makefile,它是从 Autoconf 的 Makefile.in 模板生成的,而该模板本身是从我们需要编写的 Automake Makefile.am 文件生成的。在这个 makefile 中,我们需要通过 make check 来生成并执行 testsuite。列表 9-2 展示了如何编写 tests/Makefile.am,以便 Automake 和 Autoconf 能够生成这样的 makefile。

➊ TESTSUITE = $(srcdir)/testsuite
➋ TESTSOURCES = $(srcdir)/local.at $(srcdir)/testsuite.at
➌ AUTOM4TE = $(SHELL) $(top_srcdir)/missing --run autom4te
➍ AUTOTEST = $(AUTOM4TE) language=autotest

➎ check-local: atconfig atlocal $(TESTSUITE)
           $(SHELL) '$(TESTSUITE)' $(TESTSUITEFLAGS)

➏ atconfig: $(top_builddir)/config.status
           cd $(top_builddir) && $(SHELL) ./config.status tests/$@

➐ $(srcdir)/package.m4: $(top_srcdir)/configure.ac
           $(AM_V_GEN) :;{ \
             echo '# Signature of the current package.' && \
             echo 'm4_define([AT_PACKAGE_NAME], [$(PACKAGE_NAME)])' && \
             echo 'm4_define([AT_PACKAGE_TARNAME], [$(PACKAGE_TARNAME)])' && \
             echo 'm4_define([AT_PACKAGE_VERSION], [$(PACKAGE_VERSION)])' && \
             echo 'm4_define([AT_PACKAGE_STRING], [$(PACKAGE_STRING)])' && \
             echo 'm4_define([AT_PACKAGE_BUGREPORT], [$(PACKAGE_BUGREPORT)])'; \
             echo 'm4_define([AT_PACKAGE_URL], [$(PACKAGE_URL)])'; \
           } >'$(srcdir)/package.m4'
➑ $(TESTSUITE): $(TESTSOURCES) $(srcdir)/package.m4
           $(AM_V_GEN) $(AUTOTEST) -I '$(srcdir)' -o $@.tmp $@.at; mv $@.tmp $@

列表 9-2: tests/Makefile.am: 使 make check 构建并运行 testsuite

在我们开始分析这个文件之前,我应该提到,这些内容取自 GNU Autoconf 手册 的第 19.4 节。我对它们做了一些修改,但本质上,这些行组成了将 autotest 与 Automake 结合的推荐方式的一部分。我们将在讨论 autotest 定向的 make 脚本的其他功能和需求时完成这个文件。

注意

这种缺乏更完整集成的情况,以及 Autoconf 可以配置为使用几个不同的测试驱动程序(例如 DejaGNU)的事实,可能是导致 autotest 仍处于实验模式的原因。虽然 Libtool 逐步向与 Automake 完全集成的方向发展,但 autotest 仍需要一些调整才能正确地集成到项目构建系统中。然而,一旦理解了这些需求,正确的集成其实是相当简单的。此外,正如我们所看到的,Automake 有自己的测试框架,这使得 Automake 的维护者没有足够的动力去全面支持 autotest。

列表 9-2 中的代码逐行来看非常简单——四个变量和四个规则。这些变量并非严格必要,但它们使命令行更短,并且减少了规则和命令中的重复。➍ 处的 TESTSUITE 变量让我们不必在每次使用时都加上 $(srcdir)/ 前缀。

注意

testsuite 程序是分发的,因此它应该在源树中构建。那些最终会进入分发归档的文件应该位于源目录结构中。此外,这些构建并分发的文件的内容应该是相同的,无论原始归档创建者或最终用户使用的配置选项有何不同。

➋ 处的 TESTSOURCES 变量使我们可以轻松地将额外的测试添加到 makefile 中。每个 .at 文件都会成为 testsuite 的依赖项,因此当其中一个文件发生变化时,testsuite 会被重新构建。

➌ 处的 AUTOM4TE 变量允许我们使用 Automake 的 missing 脚本包装 autom4te 的执行,当未找到 autom4te 时,它会打印出更友好的错误信息。这通常发生在没有安装 Autotools 的最终用户做出需要重建 testsuite 的操作时——例如修改 testsuite.at

注意

我们不能使用 Automake 的 maintainer-rules 选项来避免将这些规则写入分发归档的 Makefile,因为我们必须手动编写这些规则。

AUTOTEST 变量在 ➍ 处将 --language=autotest 选项添加到 autom4te 命令行中。实际上,Autoconf 包中并没有名为 autotest 的程序。如果我们必须界定这样一个工具的定义,它将是这个 AUTOTEST 变量的内容。

➎ 位置的 check-local 规则将 testsuite 的执行与 Automake 的 check 目标关联起来。像 check 这样的 Automake 标准目标有一个 -local 对应目标,你可以用它来补充 Automake 为基本目标生成的功能。如果 Automake 在 Makefile.am 中看到一个目标为 check-local 的规则,它会生成一个命令,在生成的 Makefilecheck 规则下运行 $(MAKE) check-local。这为你提供了一个钩子,允许你进入标准的 Automake 目标。我们将在 第十四章 和 第十五章 中更详细地讲解这些钩子,在那里我们将广泛使用它们,将一个真实世界的项目转换为使用基于 Autotools 的构建系统。

check-local 目标依赖于 atconfigatlocal$(TESTSUITE)。回想一下 图 9-3,atlocal 是由 testsuite 引用的脚本。它是通过我们在 清单 9-1 中添加到 configure.acAC_CONFIG_FILES 调用直接生成的,所以我们很快会讲解它的内容。这个规则的命令执行 '$(TESTSUITE)',并将 $(TESTSUITEFLAGS) 作为命令行参数。TESTSUITEFLAGS 的内容由用户定义,允许最终用户运行 make check TESTSUITEFLAGS=-v,例如,以便在执行调用 testsuite 的目标时启用 testsuite 的详细输出。

你还可以使用 TESTSUITEFLAGS 通过编号(例如,TESTSUITEFLAGS=2 testsuite)或如果你已经使用 AT_KEYWORDS 宏编写了测试,通过标签名来指定特定的测试组。此外,生成的 testsuite 程序还提供了几个命令行选项。你可以在 GNU Autoconf 手册 第 19.3 节中找到 testsuite 选项的完整文档。

注意

$(TESTSUITE) 周围的单引号允许 TESTSUITE 中的路径包含空格(如果需要)。这种技术可以并且应该在所有 Makefile 中使用,以处理路径中的空格问题。在本书中,我通常忽略了路径中空格的概念,以减少列表中的噪音,但你应该意识到,Makefile 可以正确地处理所有文件名和路径中的空格——无论是在目标和依赖关系中的,还是与规则相关的命令中的。

我之前提到过,atconfig 脚本(同样由 testsuite 引用)是由 AC_CONFIG_TESTDIR 自动生成的。问题在于,尽管 config.status 理解如何构建这个文件,但 Automake 并不知道它,因为它没有直接列出在 configure.ac 中任何实例化宏调用的列表中,因此我们需要在 Makefile.am 中添加一个明确的规则来创建或更新它。这就是在 清单 9-2 中 ➏ 位置的 atconfig 规则的作用。check-local 规则依赖于它,因此当执行 make check 时,如果 atconfig 缺失或比其依赖项 $(top_builddir)/config.status 旧,它的命令会被执行。

生成$(srcdir)/package.m4的规则中的命令在➐位置(注意这里只是一个命令)仅在文件缺失或比configure.ac旧时将文本写入目标文件。这是一个可选的输入文件(见图 9-1),其内容实际上是 autotest 以某种形式所需的。多个 M4 宏必须在输入数据中定义,autotest 会处理这些数据以创建测试套件,包括AT_PACKAGE_NAMEAT_PACKAGE_TARNAMEAT_PACKAGE_VERSIONAT_PACKAGE_STRINGAT_PACKAGE_BUGREPORTAT_PACKAGE_URL。这些变量可以直接在testsuite.at(或该文件包含的任何子文件)中定义,但从已经在configure.ac中找到的值生成这些信息更为合理,这样我们就不必维护两份相同的信息。这正是为什么如果在处理testsuite.at时找到package.m4autom4te会自动包含它。

等等——为什么不使用AC_CONFIG_FILESconfigure来生成这个文件呢?我们所做的只是生成一个包含配置变量的文本文件,这正是AC_CONFIG_FILES的作用。问题在于,AC_CONFIG_FILES和其他实例化宏总是将文件生成到构建树中,而package.m4必须放到源代码树中,才能被添加到分发归档中(不是因为它是用户可能启动的任何构建或执行过程的一部分,而是因为它是testsuite的源代码的一部分)。也许在未来的某个时刻,autotest 的完全集成将导致可以要求实例化宏将文件生成到源代码树中。在那之前,这就是我们所能使用的。

第四条也是最后一条规则,$(TESTSUITE),在➑位置,使用$(AUTOTEST)命令生成$(srcdir)/testsuite。因为$(TESTSUITE)check-local的一个依赖项,如果它不是最新的,就会被构建。autom4te程序在autotest 模式下执行时,接受-I选项来指定可能由testsuite.at或其任何包含文件包含的.at文件的包含路径。它还接受-o选项来指定输出文件testsuite。^(3)

注意

我在清单 9-2 中添加了$(AM_V_GEN),以便让我自定义的规则能够与 Autotools 的静默构建规则系统结合。任何以$(AM_V_GEN)为前缀的命令都会导致正常的命令输出在启用静默构建规则时被替换为GEN* target。有关此和其他影响构建输出的变量的更多细节,请参阅 GNU Automake 手册的第 21.3 节。*

总的来说,这一切使得我们能够在命令行中运行make check,以构建(如果需要)并执行$(srcdir)/testsuite

注意

在这个 Makefile.am 文件中,我们还需要做一些工作,以完全将 autotest 功能集成到 Automake 中。稍后我们将在本章中添加一些额外的管理规则和变量。为了清晰起见,此时我将内容限制为仅包含我们需要构建和运行测试套件的部分。

好的,我们已经创建了一个新目录并添加了一个新的 Makefile.am。现在,你应该自动开始思考,如果我们不将它链接到顶层 Makefile.amSUBDIRS 变量中,这个 Makefile.am 文件将如何被调用。你完全正确——这将是我们接下来的步骤。清单 9-3 展示了对顶层 Makefile.am 文件的修改。

SUBDIRS = common include libjup src tests

清单 9-3: Makefile.am: 添加测试子目录

注意

我最后添加了测试。这几乎总是像 tests 这样的目录的模式。为了测试系统,大多数(如果不是所有的话)其他目录必须先构建。

清单 9-1 中的第二个文件是 atlocal shell 脚本,如果存在,testsuite 会自动调用它,这可以用来将额外的配置变量传递到 testsuite 的运行时环境中。我们将在 Jupiter 项目中使用这个文件,将 async_exec 标志传递给 testsuite,以便它知道它正在测试的程序是否已启用 async-exec 特性。清单 9-4 展示了如何在 atlocal 模板 atlocal.in 中实现这一点。

async_exec=@async_exec@

清单 9-4: tests/atlocal.in: 生成 atlocal 的模板

现在,这对我们来说带来了一个小问题,因为 configure 尚未导出名为 async_exec 的替代变量。我们在第五章中写了一个 shell 脚本,使用了这个名称的 shell 变量,但请记住,我们只是用它来指示是否应该调用 AC_DEFINE 以将 ASYNC_EXEC 预处理器定义生成到 config.h.in 中。现在,我们需要使用 AC_SUBST 处理这个变量,以便生成一个同名的 Autoconf 替代变量。清单 9-5 突出了 configure.ac 中的单行添加,以实现这一点。

--snip--
  ------------------------------------------
  Unable to find pthreads on this system.
  Building a single-threaded version.
  ------------------------------------------])
       async_exec=no
   fi
fi

AC_SUBST([async_exec])
if test "x${async_exec}" = xyes; then
    AC_DEFINE([ASYNC_EXEC], 1, [async execution enabled])
fi
--snip--

清单 9-5: configure.ac: 使 autoconf 生成 async_exec 替代变量

关于清单 9-1 的最后一点评论:我们本可以将这些文件简单地添加到 configure.ac 底部现有的 AC_CONFIG_FILES 调用中,但这里使用单独的调用可以将与测试相关的项目聚集在一起。它还表明,AC_CONFIG_FILES 确实可以在 configure.ac 中多次调用,并且结果是累积的。

我们现在需要创建一组源.at文件,供autom4te使用,以生成我们的测试程序。这组文件可以像单个testsuite.at文件一样简单,或像图 9-1 中的示意图那样复杂,包括testsuite.at、一组特定测试组的.at文件以及一个local.at文件。这些文件将包含自动测试宏调用,并根据测试需求混合简单或复杂的 Shell 脚本。我们将从一个包含自动测试初始化代码的单行tests/local.at文件开始,如清单 9-6 所示。

AT_INIT

清单 9-6: tests/local.at:可以将testsuite的初始化代码添加到 local.at 文件中。

AT_INIT宏是autom4te所必需的,它必须出现在testsuite.at及其包含文件中呈现的翻译单元中的某个地方。这个单一的宏调用会展开成几百行的 Shell 脚本,定义了基本的测试框架以及与之相关的所有附加模板功能。

我们还需要在tests目录下创建一个空的testsuite.at文件。随着进展,我们会向其中添加内容:

$ touch tests/testsuite.at

我们现在已经为在 Jupiter 中生成和执行自动测试框架奠定了基础。每个使用自动测试的项目都必须按照我们目前展示的方式进行配置。对于较小的项目,一些可选的部分可以省略,这些内容将直接合并到testsuite.at中。我们将在完成自动测试的探索后讨论如何简化。目前,让我们先试一下:

$ autoreconf -i
--snip--
$ ./configure
--snip--
config.status: creating tests/Makefile
config.status: creating tests/atlocal
--snip--
config.status: executing tests/atconfig commands
-------------------------------------------------

Jupiter Version 1.0
--snip--
$ make check
--snip--
Making check in tests
make[1]: Entering directory '/.../jupiter/tests'
make    check-local
make[2]: Entering directory '/.../jupiter/tests'
:;{ \
  echo '# Signature of the current package.' && \
  echo 'm4_define([AT_PACKAGE_NAME], [Jupiter])' && \
  echo 'm4_define([AT_PACKAGE_TARNAME], [jupiter])' && \
  echo 'm4_define([AT_PACKAGE_VERSION], [1.0])' && \
  echo 'm4_define([AT_PACKAGE_STRING], [Jupiter 1.0])' && \
  echo 'm4_define([AT_PACKAGE_BUGREPORT], [jupiter-bugs@example.org])'; \
  echo 'm4_define([AT_PACKAGE_URL], [])'; \
} >'tests/package.m4'
/bin/bash ../missing --run autom4te --language=autotest \
    -I '.' -o testsuite.tmp testsuite.at
mv testsuite.tmp testsuite
/bin/bash './testsuite'
## ----------------------- ##
## Jupiter 1.0 test suite. ##
## ----------------------- ##

## ------------- ##
## Test results. ##
## ------------- ##

0 tests were successful.
--snip--
$

我们可以从configure的输出中看到,生成的文件已经按照预期创建在tests目录下。看起来,由AC_CONFIG_TESTDIR生成的代码已将tests/atconfig文件的生成作为command标签,而不是作为简单的模板文件,内部使用AC_CONFIG_COMMANDS

然后我们从make check的输出中看到,testsuite已经被构建并执行。我们目前还不能将testsuite纳入从distdistcheck目标生成的发行档案中,因为我们还没有将自动测试功能接入到 Automake 中。然而,当我们在本章末完成更改时,你会发现运行make check时,针对发行档案的内容将不会构建testsuite,因为它已经随档案一起发布(假设我们没有修改任何testsuite的依赖项)。

注意

make check输出的顶部附近,有一项有趣的内容由以make[1]:make[2]:开头的行高亮显示,其中make表示它两次进入了 jupiter/tests 目录。这是因为我们添加了check-local钩子,check目标在相同目录中递归调用$(MAKE) check-local作为命令。

很好,它能工作——至少在make check下是这样。但是目前它还什么都不做,只是在控制台上打印几行额外的文本。为了让它做一些有用的事情,我们需要添加一些测试。因此,我们的第一项任务是将最初基于 Automake 的jupiter执行测试从src/Makefile.am移入我们的 autotest 测试套件中。

添加测试

自动化测试将测试打包成称为测试组的集合。测试组的目的是允许组内的测试互相交互。例如,组内的第一个测试可能会生成一些数据文件,后续的测试会使用这些文件。

互相交互的测试更难调试,如果它们需要先运行其他测试才能复现问题,那么损坏的测试也更难复现。在追求完全覆盖时,多个测试组很难避免;理想的情况是每个测试组尽可能只包含一个测试。对于无法做到这一点的情况,测试组的存在是为了促进所需的交互。该功能的关键在于,同一测试组内的测试会在同一个临时目录中执行,允许初始测试生成的文件供后续测试查看和访问。

我们的单个测试不会遭遇这些问题——主要是因为我们还没有投入很多精力测试 Jupiter(如果我们实话实说,其实也没有太多代码需要测试)。目前,当你执行make check时,屏幕上会显示两组测试输出:

   $ make check
   --snip--
➊ make    check-TESTS
   make[2]: Entering directory '/.../jupiter/src'
   make[3]: Entering directory '/.../jupiter/src'
   PASS: greptest.sh
   ============================================================================
   Testsuite summary for Jupiter 1.0
   ============================================================================
   # TOTAL: 1
   # PASS:    1
   # SKIP:    0
   # XFAIL: 0
   # FAIL:    0
   # XPASS: 0
   # ERROR: 0
   --snip--
➋ Making check in tests
   make[1]: Entering directory '/.../jupiter/tests'
   make    check-local
   make[2]: Entering directory '/.../jupiter/tests'
   /bin/bash './testsuite'
   ## ----------------------- ##
   ## Jupiter 1.0 test suite. ##
   ## ----------------------- ##

   ## ------------- ##
   ## Test results. ##
   ## ------------- ##

   0 tests were successful.
   --snip--
   $

第一组测试(从➊开始)在* jupiter/src 目录中执行。这是我们最初基于grep的测试,检查jupiter的输出是否与模式匹配。正如你所见,Automake 中内置的基本测试框架并不差。我们希望通过 autotest 对这个框架进行改进。第二组测试(从➋开始)在 jupiter/tests *目录中执行,并涉及到 autotest。

使用 AT_CHECK 定义测试

我们在src/Makefile.am中使用的基于grep的测试是一个完美的示例,可以用在 autotest 框架提供的AT_CHECK宏中。以下是AT_CHECK宏系列的原型:

AT_CHECK(commands, [status = '0'], [stdout], [stderr], [run-if-fail], [run-if-pass])
AT_CHECK_UNQUOTED(commands, [status = '0'], [stdout], [stderr], [run-if-fail], [run-if-pass])

AT_CHECK执行commands,将返回的状态与status进行比较,并将stdoutstderr上的输出与stdoutstderr宏参数的内容进行比较。如果省略了status,自动化测试默认假设状态码为零的成功。如果commands返回的状态码与status中指定的预期状态码不符,则测试失败。为了忽略commands的状态码,应该在status参数中使用特殊命令ignore

无论如何,有一些状态码是即使是ignore也无法忽略的:commands返回的状态码 77(跳过)会导致自动化测试跳过当前测试组中其余的测试,而 99(硬错误)则会立即使自动化测试失败整个测试组。

status一样,stdoutstderr参数似乎是可选的,但外表可能会欺骗你。如果你什么都不传递给这些参数,这只是告诉 Autoconf,测试的stdoutstderr输出流预计为空。其他任何内容都会导致测试失败。那么我们如何告诉 autotest 我们不想检查输出呢?与status一样,我们可以在stdoutstderr中使用特殊命令,包括表 9-1 中列出的命令:

表 9-1: 允许在stdoutstderr参数中使用的特殊命令。

命令 描述
ignore 不检查此输出流,但会将其记录到测试组的日志文件中。
ignore-no-log 不检查也不记录此输出流。
stdout 将测试的stdout输出记录并捕获到文件stdout中。
stderr 将测试的stderr输出记录并捕获到文件stderr中。
stdout-nolog 将测试的stdout输出捕获到文件stdout中,但不记录日志。
stderr-nolog 将测试的stderr输出捕获到文件stderr中,但不记录日志。
expout 将测试的stdout输出与先前创建的文件expout进行比较,记录差异。
experr 比较测试的stderr输出与先前创建的文件experr,并记录差异。

run-if-failrun-if-pass参数允许你可选地指定应在测试失败或成功时分别执行的 shell 代码。

AT_CHECK_UNQUOTED的功能与AT_CHECK完全相同,唯一的区别是它首先对stdoutstderr进行 shell 扩展,然后再与commands的输出进行比较。由于AT_CHECK不会对stdoutstderr进行 shell 扩展,因此如果你在这些参数的文本中引用了任何 shell 变量,显然你需要使用AT_CHECK_UNQUOTED

使用 AT_SETUP 和 AT_CLEANUP 定义测试组

AT_CHECK宏必须在AT_SETUPAT_CLEANUP的调用之间调用,这两个宏定义了一个测试组,从而定义了测试组中测试执行的临时目录。这些宏的原型如下所示:

AT_SETUP(test-group-name)
AT_CLEANUP

如果你有使用过xUnit系列单元测试框架(如 JUnit、NUnit、CPPUnit 等)的经验,你可能已经对设置(setup)和清理(cleanup)或拆解(teardown)函数的用途有了相当清晰的认识。通常,setup函数会在每个测试集中的每个测试之前运行一些公共代码,而cleanupteardown函数会在测试集中的每个测试结束时执行一些公共代码。

Autotest 稍有不同——没有正式的设置或拆解功能由属于同一组的测试共享(尽管这种功能可以通过在testsuite.at中的测试组内,或在其包含的子文件中定义的 shell 函数进行模拟)。与xUnit框架一样,Autotest 将每个测试完全隔离运行,因为每个测试都在其自己的子 shell 中运行。一个测试唯一能影响后续测试的方式是通过共享同一个测试组,并且在文件系统中留下数据供后续测试检查并进行操作。

AT_SETUP只接受一个参数,test-group-name,这是我们要开始的测试组的名称,而且这个参数是必需的。AT_CLEANUP不接受任何参数。

我们将在一个新文件tests/jupiter.at中添加组设置和清理宏调用,并包装对AT_CHECK的调用,如清单 9-7 所示。

Git 标签 9.1

AT_SETUP([jupiter-execution])
AT_CHECK([../src/jupiter],,[Hello from ../src/.libs/lt-jupiter!])
AT_CLEANUP

清单 9-7: tests/jupiter.at: 添加我们的第一个测试组——尝试 #1

Libtool 在src目录中为任何使用 Libtool 共享库的可执行文件添加了一个包装脚本。这个包装脚本允许jupiter找到它试图使用的未安装的 Libtool 库。如第七章所述,这是一种 Libtool 提供的便捷机制,使我们不必在程序使用 Libtool 共享库并且尚未安装之前就进行测试。

最终结果是src/jupiter脚本执行了真正的jupiter程序,来自src/.libs/lt-jupiter。因为jupiter根据其argv[0]的内容显示其自身位置,我们需要预期它打印出这个路径。

然后,我们需要在当前空的testsuite.at文件中添加一个m4_include语句,以便包含* jupiter.at*,如清单 9-8 所示。

m4_include([jupiter.at])

清单 9-8: tests/testsuite.at: testsuite.at 中包含 jupiter.at

我们还需要将这个新源文件添加到tests/Makefile.am文件的TESTSOURCES变量中,使其成为testsuite的前提条件,如清单 9-9 所示。

--snip--
TESTSUITE = $(srcdir)/testsuite
TESTSOURCES = $(srcdir)/local.at $(srcdir)/testsuite.at \
   $(srcdir)/jupiter.at
AUTOM4TE = $(SHELL) $(top_srcdir)/missing --run autom4te
AUTOTEST = $(AUTOM4TE) --language=autotest
--snip--

清单 9-9: tests/Makefile.am: 将额外的源添加到TESTSOURCES

对于我们添加到测试套件中的每个测试,我们都会遵循这一做法。最终,testsuite.at中只会包含几次对m4_include的调用,每个测试组对应一次调用。执行这段代码会输出以下内容:

$ autoreconf -i
--snip--
$ ./configure
--snip--
$ make check
--snip--
/bin/bash './testsuite'
## ----------------------- ##
## Jupiter 1.0 test suite. ##
## ----------------------- ##
  1: jupiter-execution                                FAILED (jupiter.at:2)

## ------------- ##
## Test results. ##
## ------------- ##

ERROR: 1 test was run,
1 failed unexpectedly.
## -------------------------- ##
## testsuite.log was created. ##
## -------------------------- ##

Please send `tests/testsuite.log' and all information you think might help:

   To: <jupiter-bugs@example.org>
   Subject: [Jupiter 1.0] testsuite: 1 failed

You may investigate any problem if you feel able to do so, in which
case the test suite provides a good starting point. Its output may
be found below `tests/testsuite.dir'.
--snip--
$

注意

运行autoreconfconfigure仅仅是因为我们更新了 tests/Makefile.am 文件。如果我们只是修改了一个现有的.at 文件,而这个文件是由 tests/Makefile 中的check目标重新构建的,那么就不需要autoreconfconfigure了。

我在这里承认,我们的单个测试失败了,因为我故意将测试编写错误,以向你展示失败的测试是什么样子的。

虽然从输出中不容易看出,但 testsuite 在测试失败时会创建多个 testsuite.log 文件。第一个是位于 tests 目录下的主 testsuite.log 文件,这个文件总是会被创建,即使所有测试都通过,并且这个文件旨在发送给项目维护者作为错误报告。另一个同名的日志文件位于 tests/testsuite.dir 目录中的一个单独的编号目录下,用于记录失败的测试。每个这些目录的名称是失败的测试组的编号,测试组编号可以在输出中看到。虽然你只需要主 testsuite.log 文件,因为它包含了所有单独测试的 testsuite.log 文件的全部内容,但这个文件也包含了许多关于项目和测试环境的其他信息,维护者会希望看到这些信息,但对于我们来说,这些信息会造成干扰。

要准确查看我们的测试失败原因,首先让我们检查一下 testsuite**.log 文件的内容,该文件位于 tests/testsuite.dir/1 目录下:

$ cat tests/testsuite.dir/1/testsuite.log
#                             -*- compilation -*-
1\. jupiter.at:1: testing jupiter-execution ...
./jupiter.at:2: ../src/jupiter ➊
--- /dev/null     2018-04-21 17:27:23.475548806 -0600 ➋
+++ /.../jupiter/tests/testsuite.dir/at-groups/1/stderr 2018-06-01 16:08:04.391926296 -0600
@@ -0,0 +1 @@
+/.../jupiter/tests/testsuite.dir/at-groups/1/test-source: line 11: ../src/jupiter: ➌
    No such file or directory
--- -     2018-06-01 16:08:04.399436755 -0600 ➍
+++ /.../jupiter/tests/testsuite.dir/at-groups/1/stdout 2018-06-01 16:08:04.395926314 -0600
@@ -1 +1 @@
-Hello from ../src/.libs/lt-jupiter!
+
./jupiter.at:2: exit code was 127, expected 0 ➎
1\. jupiter.at:1: 1\. jupiter-execution (jupiter.at:1): FAILED (jupiter.at:2)
$

首先注意,autotest 尽可能将相关的源代码行写入 testsuite.log 文件。此时这对我们来说并没有太大帮助,但如果 testsuite.at 或其包含的文件很长且复杂,你会发现这些信息会非常有用。

在➊处,我们可以看到传递给 AT_CHECKcommands 参数以及将该参数传递给 jupiter.at 宏时所在的行号。

然而,现在事情开始变得有些模糊。stdoutstderr 参数在 AT_CHECK 中的作用就是提供一些比较文本,用来显示 commands 实际发送到这些输出流的内容。根据 Unix 一般哲学中避免重复已有功能的原则,autotest 的作者选择使用 diff 工具来进行这些比较。从➋到➌(包括)之间的日志行显示了 diff 工具在比较 原始 文件(/dev/null,因为我们没有在 stderr 参数中传递任何值)和 修改 文件时的 统一^(4) 输出——这个修改文件是在尝试执行 ../src/jupiter 时发送到 stderr 输出流的文本。

如果你不熟悉统一的 diff 输出,下面是简要说明。从➋开始的两行表示正在比较的对象。原始的,或减号(---)行表示比较的左侧,而修改的,或加号(+++)行表示比较的右侧。在这里,我们正在比较 /dev/null 和一个名为 /.../jupiter/tests/testsuite.dir/at-groups/1/stderr 的临时文件,该文件由 autotest 用来捕获执行 ../src/jupiter 时的 stderr 流。

下一行,以 @@ 开头和结尾,是一个 标记——diff 用来告诉我们两个文件中不匹配的部分。diff 显示的输出中可能有多个块。在这种情况下,由于输出文本非常短,只需要一个块来展示差异。

块标记中的数字代表两个范围,由空格分隔。第一个范围以减号(-)开头,表示与原始文件相关的范围,第二个范围以加号(+)开头,表示与修改后文件相关的范围。这里是我们当前讨论的那一行:

@@ -0,0 +1 @@

范围由两个整数值组成,这两个值由逗号(,)分隔,或者是一个值,默认第二个值为 1。在这个例子中,比较的范围在原始文件中从零开始,并且长度为零行,而第二个文件中的比较范围从第 1 行开始,长度为一行。这些范围是基于 1 的,也就是说,第 1 行是文件中的第一行。因此,第一个范围说明-0,0是一个特殊范围,意味着文件中没有内容。

跟随范围说明的行包含这些范围的完整文本,向我们展示实际的差异。原始文件的行首先被打印,每行前面带有一个减号,然后是修改后的文件行,每行前面带有一个加号。当修改行周围有足够的内容时,还会在这些行之前和之后添加额外的无前缀行,显示一些关于更改的上下文。在这种情况下,本节的全部内容是:

+/.../jupiter/tests/testsuite.dir/at-groups/1/test-source: line 11: ../src/jupiter:
  No such file or directory

由于原始文件是空的,如块标记中的-0,0范围所示,因此没有任何以减号开头的行。我们只看到一行修改后的文件行,以加号开头。

很显然,这些文件并不相同——我们原本期望 stderr 流中没有内容,但我们却得到了错误文本。Shell 在尝试执行 ../src/jupiter 时遇到错误——它无法找到该文件。如果你在 shell 提示符下尝试此操作,你将看到以下输出:

$ ../src/jupiter
bash: ../src/jupiter: No such file or directory
$

注意

显然,你不应该从 tests 目录,或任何与 src 目录平行的目录中执行此操作,否则它实际上会找到 jupiter (如果已构建)而不是显示此错误。

如果你将此行放入一个名为(随意)abc.sh的 shell 脚本中,并在 bash 命令行上执行该脚本,你将看到与 testsuite.log 中显示的格式匹配的输出:

$ bash abc.sh
abc.sh: line 2: ../src/jupiter: No such file or directory
$

我们可以在 testsuite.log 的 ➎ 位置看到,shell 返回了一个 127 状态码,表示某种错误。值 127 被 shell 用来表示执行错误——文件未找到或文件不可执行。

为了完整起见,我们也来看看 ➍ 和 ➎ 之间的几行。这是通过比较AT_CHECKstdout参数中指定的文本与jupiter(实际上是 shell,因为我们知道../src/jupiter没有找到)实际写入stdout的内容时,所看到的统一diff输出。在这种情况下,我们看到减号文本是我们指定的原始比较文本,而加号文本是一个换行符,因为这是 shell 发送到stdout的内容。完全展开后的块标记范围说明如下:

@@ -1,1 +1,1 @@

每个原始和修改后的源文件中都只有一行文本需要比较,但正如我们通过输出看到的,这些源文件中的文本完全不同。

那么发生了什么?

这个第一次尝试假设jupiter程序(或者更准确地说,是 Libtool 包装脚本)位于相对于tests目录的../src/jupiter。虽然这个假设是正确的,但我已经暗示过,每个测试组是在自己的临时目录中执行的,因此,这个相对路径在其他目录下无法正常工作也是完全合乎逻辑的。即使我们通过反复试验找出应该使用多少个父目录引用,它也会相当脆弱;如果我们从其他目录运行testsuite,它会失败,因为它过于依赖于相对于jupiter程序的特定位置来运行。

我们换个方式试试。我们将使用configure生成的变量到atconfig中。其中一个变量abs_top_builddir包含了顶层构建目录的绝对路径。因此,我们应该能够在任何地方成功地引用jupiter,方法是使用${abs_top_builddir}/src/jupiter

但是现在我们遇到了另一个问题:jupiter打印了它自己的路径,而我们刚决定通过 shell 变量来获取这个路径,所以我们还需要更改比较文本,以使用这个变量。然而,这个更改又引发了另一个问题——如果我们希望在宏进行比较之前,AT_CHECKstdout参数中的 shell 变量被展开,我们就需要将AT_CHECK更改为AT_CHECK_UNQUOTED。让我们通过修改jupiter.at,按列表 9-10 所示进行这些更改。

Git 标签 9.2

AT_SETUP([jupiter-execution])
AT_CHECK_UNQUOTED(["${abs_top_builddir}"/src/jupiter],,
                  [Hello from ${abs_top_builddir}/src/.libs/lt-jupiter!
])
AT_CLEANUP

列表 9-10: tests/jupiter.at: 添加我们的第一个测试组—尝试 #2

在这里,我们切换到使用AC_CHECK_UNQUOTED,并且我们将第一个参数中的jupiter程序路径和第三个参数中的比较文本更改为使用我们从atconfig继承的abs_top_builddir变量。

注意

stdout参数末尾的换行符是故意的,稍后会解释。

让我们试试看:

$ make check
--snip--
/bin/bash './testsuite'
## ----------------------- ##
## Jupiter 1.0 test suite. ##
## ----------------------- ##
  1: jupiter-execution                               ok

## ------------- ##
## Test results. ##
## ------------- ##

1 test was successful.
--snip--
$

再次说明,我只需要运行make。尽管我们的更改很大,甚至影响了我们在测试套件中调用的宏,但请记住,整个测试套件是通过make check生成的。我们只需要在更改了configure.ac或它生成的用于make check的文件模板,或者更改了Makefile.am文件时,才需要执行autoreconfconfigure

这个尝试取得了更好的结果,但为什么我们在列表 9-10 中的比较文本末尾会有一个额外的换行符呢?嗯,记得我们从jupiter发送到stdout的是什么吗?列表 9-11 提供了提示。

--snip--
    printf("Hello from %s!\n", (const char *)data);
--snip--

列表 9-11: common/print.c: jupiter发送到stdout的内容

比较文本是我们期望在jupiterstdout中找到的内容的精确副本,因此我们最好确保包括我们写的每一个字符;尾随的新行是数据流的一部分。

现在可能是移除src/Makefile.am中构建和运行 Automake 版本测试代码的好时机。按列表 9-12 所示修改src/Makefile.am

SUBDIRS = modules/hithere

bin_PROGRAMS = jupiter
jupiter_SOURCES = main.c module.h
jupiter_CPPFLAGS = -I$(top_srcdir)/include
jupiter_LDADD = ../libjup/libjupiter.la -dlopen modules/hithere/hithere.la

列表 9-12: src/Makefile.am: 移除测试后的文件更新后的完整内容

注意

我们在这里做的只是从文件的下半部分移除与测试相关的行。

单元测试与集成测试

总的来说,autotest 版本比我们在src/Makefile.am中由 Automake 测试框架执行时的版本要好一些。然而,添加新测试比我们在src/Makefile.amTESTS变量中所做的要简单一些。事实上,Automake 版本变得更简单的唯一方式是如果我们实际上编写测试程序并将其构建在check主干中。我们可能仍然需要在check主干中构建测试程序,但使用 autotest 时,调用它们并验证其输出是微不足道的。

如果你觉得 autotest 似乎更适合系统和集成测试,而不是单元测试,你的想法差不多。Autotest 旨在从外部测试你的项目,但它并不限于这类测试。从 autotest 的角度来看,任何可以从命令行调用的东西都可以成为测试。实际上,autotest 为你提供的是一个生成统一测试输出的框架,无论你使用什么类型的测试。

我多年来使用的一种单元测试方法是编写测试程序,其中我的测试程序的主源模块实际上会#include我正在测试的.c文件。这给了我调用被测试模块中的静态方法的选项,并提供了对该模块中定义的内部结构的直接访问。^(5)这种方法相当偏向 C 语言,但其他语言也有自己执行相同操作的方法。其核心思想是创建一个能够访问模块私有部分的测试程序,并以小块的方式进行功能验证。当你将这些小块组合在一起时,你可以确信每个小块都按设计工作;如果有问题,可能是在你将它们组合起来的方式上。

现在,让我们通过创建一个测试模块来为 Jupiter 添加一些单元测试,该模块测试common/print.c模块中的函数。创建一个名为test_print.c的文件,并将其放在common目录中,文件内容参见 Listing 9-13。

Git 标签 9.3

#define printf mock_printf
#include "print.c"

#include <stdarg.h>
#include <string.h>

static char printf_buf[512];

int mock_printf(const char * format, ... )
{
    int rc;
    va_list ap;
    va_start(ap, format);
    rc = vsnprintf(printf_buf, sizeof printf_buf, format, ap);
    va_end(ap);
    return rc;
}

int main(void)
{
    const char *args[] = { "Hello", "test" };
    int rc = print_it(args);
    return rc != 0 || strcmp(printf_buf, "Hello from test!\n") != 0;
}

Listing 9-13: common/test_print.c: 一个针对 print.c 模块的单元测试程序

第一行使用预处理器将所有对printf的调用重命名为mock_printf。第二行然后使用预处理器将#include直接引入print.ctest_print.c中。现在,print.c中的任何printf调用将重新定义为调用mock_printf——包括在系统头文件如stdio.h中定义的任何原型。^(6)

这里的想法是验证print_it函数是否实际打印了Hello from argument!\n并返回零给调用者。我们不需要任何输出——一个 Shell 返回码就足够表明print_it按设计工作。

我们也不需要这个模块的main例程接受任何命令行参数。不过,如果这里有多个测试,可能会方便一些,接受某种参数来告诉代码我们想运行哪个测试。

我们在这里做的其实很简单,就是直接调用print_it并传入一个短字符串,然后尝试验证print_it是否返回了零,并且实际将我们期望的内容传递给了printf。请注意,print_it是一个静态函数,这应该使其无法被其他模块访问,但由于我们在test_print.c的顶部包含了print.c,实际上我们将这两个源文件合并为一个翻译单元。

现在,让我们为这个测试程序编写构建代码。首先,我们需要在common/Makefile.am中添加一些行,以便在运行make check时构建test_print程序。按照 Listing 9-14 中所示的方式修改common/Makefile.am

noinst_LIBRARIES = libjupcommon.la
libjupcommon_la_SOURCES = jupcommon.h print.c

check_PROGRAMS = test_print
test_print_SOURCES = test_print.c

Listing 9-14: common/Makefile.am: test_print添加为check_PROGRAM

当我们执行 check 目标时,我们现在会在 common 目录中获得一个新的程序 test_print。现在,我们需要将此程序的调用添加到我们的测试套件中。在 tests 中创建一个名为 print.at 的新文件,如 清单 9-15 所示。

AT_SETUP([print])
AT_CHECK(["${abs_top_builddir}/common/test_print"])
AT_CLEANUP

清单 9-15: tests/print.at: 添加 print 测试

我们还需要为此测试向 testsuite.at 添加一个 m4_include 语句,如 清单 9-16 所示。

m4_include([jupiter.at])
m4_include([print.at])

清单 9-16: tests/testsuite.at: 将 print.at 添加到 testsuite.at

最后,我们需要将这个新的源文件添加到 tests/Makefile.am 中的 TESTSOURCES 变量,如 清单 9-17 所示。

--snip--
TESTSUITE = $(srcdir)/testsuite
TESTSOURCES = $(srcdir)/local.at $(srcdir)/testsuite.at \
  $(srcdir)/jupiter.at $(srcdir)/print.at
AUTOM4TE = $(SHELL) $(top_srcdir)/missing --run autom4te
AUTOTEST = $(AUTOM4TE) –language=autotest
--snip--

清单 9-17: tests/Makefile.am: 将 print.at 添加到 TESTSOURCES

由于 test_print 程序仅使用 shell 状态码来指示错误,因此在 AT_CHECK 中使用它是最简单的。你只需要提供第一个参数——程序本身的名称。如果 test_print 有多个测试,你可能会接受一个命令行参数(在同一参数内),用来指示你想运行哪个测试,然后添加多个 AC_CHECK 调用,每个调用运行 test_print 并带有不同的参数。

请注意,我们已经开始了一个新的测试组——正如我之前提到的,除非测试的性质使得它们必须在同一文件数据集上共同工作,否则你应该尽量将每个测试组限制为一个测试。

让我们试试吧。请注意,为了运行新的测试,我们实际上只需要执行 check 目标来更新并执行 testsuite。然而,由于我们已经将 print.at 依赖项添加到了 tests/Makefile.am 中,我们可能还应该运行 autoreconfconfigure。如果我们启用了维护者模式,额外的维护者模式规则会为我们处理这些:

$ autoreconf -i
--snip--
$ ./configure
--snip--
$ make check
--snip--
/bin/bash './testsuite'
## ----------------------- ##
## Jupiter 1.0 test suite. ##
## ----------------------- ##
  1: jupiter-execution                                                 ok
  2: print                                                             ok

## ------------- ##
## Test results. ##
## ------------- ##

All 2 tests were successful.
--snip--
$

管理细节

我在描述 tests/Makefile.am 内容时提到过,正如在 清单 9-2 中提到的,我们需要在该文件中添加一些额外的基础设施,以便完成与 Automake 的集成。让我们现在来处理这些细节。

我们已经看到,make (all) 和 make check 可以正常工作,构建我们的产品并构建和执行我们的测试套件。但我们忽略了 Automake 为我们配置的其他一些目标——具体来说,installcheckclean 以及与分发相关的目标,如 distdistcheck。这里有一个需要考虑的一般教训:每当我们向 Makefile.am 添加自定义规则时,我们需要考虑这些规则对 Automake 生成的标准目标的影响。

分发测试文件

有几个生成的文件需要分发。这些文件并不是 Automake 固有的知道的,因此,Automake 需要显式地告知这些文件。这是通过 Automake 识别的 EXTRA_DIST 变量来完成的,我们将在 tests/Makefile.am 文件顶部添加该变量,如 清单 9-18 所示。

Git 标签 9.4

EXTRA_DIST = testsuite.at local.at jupiter.at print.at \
  $(TESTSUITE) atconfig package.m4

TESTSUITE = $(srcdir)/testsuite
TESTSOURCES = $(srcdir)/local.at $(srcdir)/testsuite.at \
  $(srcdir)/jupiter.at $(srcdir)/print.at
--snip--

清单 9-18: tests/Makefile.am: 确保测试文件与EXTRA_DIST一起分发

在这里,我已经添加了所有测试套件源文件,包括testsuite.atlocal.atjupiter.atprint.at。我还添加了testsuite程序和我们通过非 Automake 机制生成的任何输入文件。这些文件包括atconfig,它是由AC_CONFIG_TESTDIR宏提供的代码生成的,以及package.m4,它是我们之前向此Makefile.am文件添加的自定义规则生成的。这里需要理解的是,向EXTRA_DIST添加文件会导致它们在执行make dist时被构建(如果需要)。

注意

我本来只想在EXTRA_DIST中使用$(TESTSOURCES),但是该变量中的源文件是为规则和命令格式化的。EXTRA_DIST由 Automake 解释时,旨在引用相对于源树中当前目录的文件列表。

提醒一下,我们分发.at文件,因为 GNU 通用公共许可证要求我们必须分发项目的源代码,而这些文件就是testsuite的源代码,就像configure.acconfigure的源代码一样。然而,即使您不使用 GPL,您仍然应该考虑分发项目中所有文件的首选编辑格式;毕竟,它是一个开源项目。

检查已安装的产品

我们写了一个check目标;支持GCS installcheck目标可能是个好主意,Automake 也支持这个目标。这是通过在此Makefile.am文件中添加installcheck-local目标来实现的,如清单 9-19 所示。

--snip--
check-local: atconfig atlocal $(TESTSUITE)
        $(SHELL) '$(TESTSUITE)' $(TESTSUITEFLAGS)

installcheck-local: atconfig atlocal $(TESTSUITE)
        $(SHELL) '$(TESTSUITE)' AUTOTEST_PATH='$(DESTDIR)$(bindir)' $(TESTSUITEFLAGS)
--snip--

清单 9-19: tests/Makefile.am: 支持已安装产品的测试

check-localinstallcheck-local之间唯一的区别是向testsuite添加了AUTOTEST_PATH命令行选项,指向在$(DESTDIR)$(bindir)中找到的jupiter副本,即它被安装的位置。AUTOTEST_PATH在调用AT_CHECK_UNQUOTED中的commands之前,会被添加到 shell 的PATH变量中;因此,您可以编写假设PATH包含已安装的jupiter副本路径的测试代码。然而,测试旨在同时在已安装或未安装的程序上执行,因此建议在测试命令中继续派生并使用程序的完整路径。

现在我们需要做出决定。当用户输入make check时,他们显然是想测试构建树中的jupiter副本。但当他们输入make installcheck时,肯定是想检查已安装的程序版本,无论是默认安装位置,还是用户通过使用命令行make变量,如DESTDIRprefixbindir,指定的位置。

这引出了一个新问题:当我们运行未安装的jupiter测试时,我们依赖于 Libtool 的包装脚本来确保jupiter能够找到libjupiter.so。一旦我们开始测试已安装的jupiter,我们将负责告诉jupiterlibjupiter.so的位置。如果jupiter安装在标准位置(如/usr/lib),系统会自然找到libjupiter.so。否则,我们必须设置LD_LIBRARY_PATH环境变量来指向它。

那么,我们如何编写测试,以确保在这两种情况中都能正确工作呢?一个有趣(但有问题)的方法见于 Listing 9-20。

AT_SETUP([jupiter-execution])

set -x
find_jupiter()
{
 jupiter="$(type -P jupiter)"
    LD_LIBRARY_PATH="$(dirname "${jupiter}")/../lib" export LD_LIBRARY_PATH
    compare="${jupiter}"
    if test "x${jupiter}" == x; then
      jupiter="${abs_top_builddir}/src/jupiter"
      compare="$(dirname “${jupiter}”)/.libs/lt-jupiter"
    fi
}

find_jupiter
AT_CHECK_UNQUOTED(["${jupiter}"],,
                  [Hello from ${compare}!
])
AT_CLEANUP

Listing 9-20: tests/jupiter.at:测试已安装和未安装的jupiter执行——尝试#1

find_jupiter Shell 函数通过使用 Shell 的type命令来尝试在PATH中定位jupiter。如果第一个结果为空,我们会回退到使用未安装版本的jupiter

该函数设置了两个 Shell 变量,jupitercomparejupiter变量是jupiter的完整路径。compare变量是从jupiter派生出来的,包含${jupiter}的值或者 Libtool 未安装版本的位置和名称。我们可以在这两种情况下将LD_LIBRARY_PATH设置为相对于jupiter所在位置的../lib目录,因为那里很可能是它被安装的地方^7。

这种方法的问题很多。首先,它并没有很好地处理jupiter应该安装但在指定或隐含的安装路径中找不到的情况。在这种情况下,代码会悄悄地回退到测试未安装版本——这可能不是你想要的结果。另一个问题是,find_jupiter会在PATH中找到jupiter的任何位置,即使该实例不是你打算测试的版本。但还有一个更加隐蔽的 bug:如果你执行make check,本意是测试未安装版本,但jupiter的已安装版本恰好出现在PATH中,那么系统会测试已安装的版本。

很遗憾的是,当命令行中未指定AUTOTEST_PATH时,它默认会有一个非空值,而这本来是一个很好的方法来区分make checkmake installcheck的使用。然而,AUTOTEST_PATH默认会是AC_CONFIG_TESTDIR指定目录的名称,而该目录的名称也恰好是${at_testdir}的值——这是由AC_CONFIG_TESTDIRatconfig中生成的变量。我们可以利用这一点,通过比较${AUTOTEST_PATH}${at_testdir}来区分make checkmake installcheck。如 Listing 9-21 所示,修改tests/jupiter.at

--snip--
set -x
find_jupiter()
{
  if test "x${AUTOTEST_PATH}" == "x${at_testdir}"; then
    jupiter="${abs_top_builddir}/src/jupiter"
    compare="$(dirname "${jupiter}")/.libs/lt-jupiter"
  else
    jupiter="${AUTOTEST_PATH}/jupiter"
    LD_LIBRARY_PATH="${AUTOTEST_PATH}/../lib" export LD_LIBRARY_PATH
    compare="${jupiter}"
  fi
}
jupiter=$(find_jupiter)
--snip--

Listing 9-21: tests/jupiter.at:更好的方法使用AUTOTEST_PATH

现在,当执行 make check 时,jupiter 变量将始终直接设置为构建树中的未安装版本(并且 compare 将设置为 .../.libs/lt-jupiter),但当进入 make installcheck 时,它将设置为 ${AUTOTEST_PATH}/jupitercompare 也将设置为相同的值)。此外,由于我们能够完全区分已安装和未安装的测试,因此我们只会为已安装版本的 jupiter 设置 LD_LIBRARY_PATH

如果 AUTOTEST_PATH 设置不正确,可能会发生这种情况(例如,当用户在 make 命令行中错误地设置 DESTDIRprefix 时),测试将失败,因为找不到 ${jupiter}

如果我需要添加额外的测试,且这些测试需要运行 jupiter 程序,那么这些行将是 local.at 的完美候选者。问题是,设计用于在测试中运行的 Shell 脚本 必须AT_SETUPAT_CLEANUP 的调用之间定义并执行;否则,它将在生成 testsuite 时从 autom4te 输出流中被省略。那么,local.at 到底如何对我们有用呢?嗯,你不能直接在 local.at 中编写 Shell 代码,但你可以定义可以在测试模块中调用的 M4 宏。让我们将 find_jupiter 功能移动到 local.at 中的宏定义,如 示例 9-22 所示。

Git 标签 9.5

AT_INIT

m4_define([FIND_JUPITER], [[set -x
if test "x${AUTOTEST_PATH}" == "x${at_testdir}"; then
  jupiter="${abs_top_builddir}/src/jupiter"
  compare="$(dirname ${jupiter})/.libs/lt-jupiter"
else
 LD_LIBRARY_PATH="${AUTOTEST_PATH}/../lib" export LD_LIBRARY_PATH
  jupiter="${AUTOTEST_PATH}/jupiter"
  compare="${jupiter}"
fi]])

示例 9-22: tests/local.at: find_jupiter 移动到 M4 宏

使用 Shell 函数可能在我们开始时是个好主意,但现在它有点多余,所以我修改了代码,直接设置了 jupiter 变量。注意调用 m4_define 的第二个 (value) 参数被原样设置为我们希望在宏被调用时生成的 Shell 脚本。

value 参数中第一行的 set -x 命令启用 Shell 调试输出,这样你就能看到该宏执行的内容,但前提是你在 make 命令行中设置了 TESTSUITEFLAGS=-v。这是生成到 testsuite.log 中输出的默认设置,因此你将能够看到宏调用实际执行的代码。

注意

你可能注意到 m4_define 的第二个参数中的代码有两组方括号。这在本例中并不是严格必要的,因为嵌入的代码片段中没有特殊字符。然而,如果有的话,使用双引号会允许特殊字符——如嵌入的方括号,甚至是之前定义的 M4 宏名称——被准确地生成,而不会受到 m4 工具的干扰。

现在按照 示例 9-23 中所示的修改 tests/jupiter.at

AT_SETUP([jupiter-execution])
FIND_JUPITER
AT_CHECK_UNQUOTED(["${jupiter}"],,
                  [Hello from ${compare}!
])
AT_CLEANUP

示例 9-23: tests/jupiter.at: 调用 FIND_JUPITER

有了这个更改,所有需要运行jupiter程序的测试可以仅通过调用FIND_JUPITER宏,然后在commands参数中执行${jupiter}来实现。正如你所看到的,可用的选项是无穷无尽的——它们仅受你对 shell 的掌握程度的限制。由于每个测试都在一个单独的子 shell 中运行,你可以随意设置任何环境变量,而不会影响后续的测试。

让我们尝试一下。注意,我们没有更改任何内容,除了.at文件,因此我们只需要运行make来查看我们更改的效果。首先,我们将使用DESTDIR安装到本地目录,以便查看make installcheck的工作方式:

   $ make install DESTDIR=$PWD/inst
   --snip--
   $ make check TESTSUITEFLAGS=-v
   --snip--
   Making check in tests
   make[1]: Entering directory '/.../jupiter/tests'
   make    check-local
   make[2]: Entering directory
     '/home/jcalcote/dev/book/autotools2e/book/sandbox/tests'
   /bin/bash './testsuite' -v
   ## ----------------------- ##
   ## Jupiter 1.0 test suite. ##
   ## ----------------------- ##
   1\. jupiter.at:1: testing jupiter-execution ...
   ++ test xtests == xtests
➊ ++ jupiter=/.../jupiter/src/jupiter
   +++ dirname /.../jupiter/src/jupiter
   ++ compare=/.../jupiter/src/.libs/lt-jupiter
   ++ set +x
   ./jupiter.at:3: "${jupiter}"
   1\. jupiter.at:1:    ok
   --snip--
   $ make installcheck DESTDIR=$PWD/inst TESTSUITEFLAGS=-v
   --snip--
   Making installcheck in tests
   make[1]: Entering directory '/.../jupiter/tests'
   /bin/bash './testsuite' AUTOTEST_PATH='/.../jupiter/inst/usr/local/bin' -v
   ## ----------------------- ##
   ## Jupiter 1.0 test suite. ##
   ## ----------------------- ##
   1\. jupiter.at:1: testing jupiter-execution ...
   ++ test x/.../jupiter/inst/usr/local/bin == xtests
   ++ LD_LIBRARY_PATH=/.../jupiter/inst/usr/local/bin/../lib
   ++ export LD_LIBRARY_PATH
➋ ++ jupiter=/.../jupiter/inst/usr/local/bin/jupiter
   ++ compare=/.../jupiter/inst/usr/local/bin/jupiter
   ++ set +x
   ./jupiter.at:3: "${jupiter}"
   1\. jupiter.at:1:    ok
   --snip--
   $

在这里,执行make check TESTSUITEFLAGS=-v向我们展示了在➊处jupiter是从构建树中被调用的,而compare被设置为 Libtool 二进制文件的路径,make installcheck DESTDIR=$PWD/inst TESTSUITEFLAGS=-v在➋处则表明jupiter是从我们指定的安装路径中调用的,compare也设置为相同的位置。LD_LIBRARY_PATH也在这个代码路径中被设置。

清理工作

testsuite程序有一个--clean命令行选项,可以清理tests目录中的所有测试遗留文件。为了将其接入clean目标,我们添加了一个clean-local规则,如清单 9-24 所示。

Git 标签 9.6

installcheck-local: atconfig atlocal $(TESTSUITE)
        $(SHELL) '$(TESTSUITE)' AUTOTEST_PATH='$(bindir)' $(TESTSUITEFLAGS)

clean-local:
        test ! -f '$(TESTSUITE)' || $(SHELL) '$(TESTSUITE)' --clean
        rm -rf atconfig

atconfig: $(top_builddir)/config.status
        cd $(top_builddir) && $(SHELL) ./config.status tests/$@

清单 9-24: tests/Makefile.am: 为测试添加对make clean的支持

如果testsuite存在,它会被要求在执行后进行清理。我还添加了一个命令来删除生成的atconfig脚本,因为如果在执行clean目标时没有删除这个文件,make distcheck会失败,而atconfig并不是由 Automake 或 Automake 监控的任何 Autoconf 代码生成的。此时,你可以尝试运行make distmake distcheck,看看它是否按预期工作。

注意

你可能认为clean-local目标是可选的,但它是必需的,这样当构建分发档案时,make distcheck不会因distcheck执行make clean后留下多余的文件而失败。

还要注意,我们不需要,也不应该,尝试清理生成到源代码树中的文件,比如package.m4testsuite本身。为什么不呢?就像configure一样,像testsuite这样的 autotest 产品位于我们手动编写的源文件和构建树中的产品文件之间。它们是从源代码构建的,但存储在源代码树中,并最终分发到档案中。

细节

我喜欢做的另一件事是,在调用AT_INIT之后,往我的local.at文件中添加一个AT_COLOR_TESTS的调用。用户可以始终通过向testsuite添加命令行参数(--color)来指定彩色测试输出,但使用这个宏可以让你默认启用彩色测试输出。按照清单 9-25 所示修改你的local.at文件。

Git 标签 9.7

AT_INIT
AT_COLOR_TESTS

m4_define([FIND_JUPITER], [set -x
if test "x${AUTOTEST_PATH}" == "x${at_testdir}"; then
--snip--

列表 9-25: tests/local.at: 将彩色测试输出设为默认值

你应该注意到,每次测试成功后,ok文本以及总结行All 2 tests were successful.现在都是绿色的。如果你经历了任何失败的测试,失败的测试后会显示FAILED (testsuite``.at:N),并且总结行将以红色显示如下:

ERROR: All 2 tests were run,
1 failed unexpectedly.

一种简化方法

我在开始时提到过,我们将看看如何为较小的项目简化这个系统。以下是我们可以省略的内容:

tests/local.at 将此文件的内容复制到testsuite.at的顶部并删除该文件。

tests/atlocal.in 假设你不需要将任何配置变量传递到测试环境中,请从configure.ac中的AC_CONFIG_FILES调用中移除对产品文件atlocal的引用,并删除此模板文件。

tests/.at* (由testsuite.at包含的子测试文件) 将这些文件的内容按顺序复制到testsuite.at并删除这些文件;移除原本用于包含这些文件的m4_include宏调用。

编辑tests/Makefile.am,从EXTRA_DISTTESTSOURCES变量中删除对前述文件(模板和生成的产品)的所有引用,以及check-localinstallcheck-local规则中的引用。

我不会删除package.m4规则,尽管你可以通过将生成的m4_define宏调用直接复制到testsuite.at的顶部来做到这一点。由于make使用 Autoconf 定义的变量生成此文件,生成的package.m4实例已经包含了替换tests/Makefile.am中命令中变量引用的值。在我看来,不需要在两个地方编辑这些信息的价值远远超过维护文件生成规则的开销。

总结

我们已经涵盖了 autotest 的基本内容,但在你的testsuite.at文件中,你还可以使用十多个或多或少有用的AT_*宏。GNU Autoconf Manual的第 19.2 节详细记录了它们。我已经向你展示了如何将 autotest 集成到 Automake 基础设施中。如果你选择在没有 Automake 的情况下使用 autotest,那么 Automake 的tests/Makefile.am和 Autoconf 的tests/Makefile.in之间会有所不同,因为你不能再依赖 Automake 为你做某些事情了。然而,如果你正在编写自己的Makefile.in模板,那么这些修改很快就会变得很明显。

我还向你展示了一种在 C 语言中创建单元测试的技术,它可以让你完全访问源模块的私有实现细节。在过去的项目中,我通过这种技术实现了接近 100% 的代码覆盖率,但我现在要提醒你一个警告:在这个层级上编写单元测试会使得改变应用程序的功能变得更加困难。修复一个 bug 还好,但进行设计更改通常需要禁用或完全重写与受影响代码相关的单元测试。在决定采用这种单元测试方式之前,最好对你的设计非常有信心。有人说,编写单元测试的一个主要价值就是你可以设置它们然后忘记它们——也就是说,你可以一次性编写测试,将它们集成到构建系统中,然后不再关注被测试代码是否有效。嗯,这大部分是对的,但如果你以后需要修改项目中一个经过充分测试的功能,除非你对测试代码进行了良好的注释,否则你会发现自己会想,当初写这个测试代码时到底在想什么。

尽管如此,我在晚上睡得更安稳,因为我知道我刚提交到公司代码库的代码已经经过全面测试。我也在与同事讨论围绕我那些经过充分测试的代码的 bug 时,更加自信。自动化测试帮助减少了这些项目中的工作量。

随着本章的结束,我们也来到了 Jupiter 项目的终点——这其实是件好事,因为我已经把Hello, world!的概念远远推得超过了任何人应该做到的程度。从现在开始,我们将专注于更多独立的主题和实际的例子。

第十章:使用 PKG-CONFIG 查找构建依赖

*人们在尝试设计完全防傻的东西时常犯的一个错误,就是低估了彻头彻尾的傻瓜的创造力。

—道格拉斯·亚当斯*,《银河系漫游指南》

Image

假设你的项目依赖于一个叫做 stpl 的库——一个第三方库。你的构建系统如何确定 libstpl.so 在终端用户系统上的安装位置?stpl 的头文件在哪里?你是不是仅仅假设它们在 /usr/lib/usr/include 中?如果你没有告诉 Autoconf 去别的地方找,这实际上就是 Autoconf 的工作方式,对于许多软件包来说,这也许没问题——将库和头文件安装到这些目录是一个常见的约定。

但如果它们没有安装在这些位置呢?或者它们可能是本地构建并安装到 /usr/local 目录树中。当你在项目中使用 stpl 时,应该使用哪些编译器和链接器选项?这些问题从一开始就困扰着开发人员,而 Autotools 并没有真正解决这个问题。Autoconf 期望你使用的任何库都被安装到“标准位置”,也就是预处理器和链接器自动查找头文件和库的目录。如果用户有这个库,但它安装在了其他位置,Autotools 就期望终端用户知道如何解释配置失败。事实上,有几个充分的理由解释了为什么许多库不会安装在这些标准位置。

此外,Autoconf 并不容易找到那些根本没有安装的库。例如,你可能刚刚构建了一个库包,而你希望另一个包从第一个包的构建目录结构中获取头文件和库。这是 Autoconf 可以做到的,但它需要终端用户在 configure 命令行中设置像 CPPFLAGSLDFLAGS 这样的变量。项目维护者可以通过提供用户指定的配置选项,利用 AC_ARG_ENABLEAC_ARG_WITH 宏来简化这一过程,特别是针对他们预期不容易在标准位置找到的库。但是,如果你的项目使用了大量的第三方库,那么确定哪些库对于用户来说特别棘手,实际上只是猜测。而且,用户通常不是程序员;我们不能指望他们有足够的背景知识来知道应该为选项值使用什么,即使我们提供了命令行选项来解决问题依赖。贯穿本章,我将这些问题称为 构建依赖问题

有一种工具通过使用软件设计中非常常见的策略——提供另一层间接性,优雅地解决了这些问题。它并不属于 GNU Autotools 的一部分。尽管如此,过去 20 年来它的使用已经变得如此广泛,以至于如果在任何讨论 Linux 构建系统的书籍中不描述 pkg-config,那将是一个疏忽。pkg-config 之所以如此有用,正是因为许多项目开始使用它——尤其是库项目,特别是那些安装到非标准位置的库项目。

在本章中,我们将讨论 pkg-config 的组件和功能,以及如何在你的项目中使用它。对于你自己的库项目,我们还将讨论如何在你的包安装到用户(或者我喜欢称他们为潜在贡献者)系统时,更新 pkg-config 数据库。

在我们开始之前,请允许我提前说明,本章中涉及到的很多文件系统对象可能在你的 Linux 版本中并不存在,或者可能以不同的形式或位置存在。这是我们所做的事情的本质——我们讨论的是包含库和头文件的包,这些文件可能在不同的 Linux 版本中位于不同的位置。我会尽量指出这些潜在的差异,以便在两套系统中出现不完全一致时,你不会感到过于惊讶。

pkg-config 概述

Pkg-config 是一个开源软件工具,由 freedesktop.org 项目维护。pkg-config 网站是 freedesktop.org 项目网站的一部分。pkg-config 项目是将 gnome-config 脚本——gnome 构建系统的一部分——转变为一个更通用工具的努力结果,而且它似乎得到了广泛应用。pkg-config 的灵感也来自于 gtk-config 程序。

关于 PKG-CONFIG 克隆的说明

freedesktop.org 的 pkg-config 项目已经存在很长时间,并且拥有一定数量的忠实用户。因此,现在有其他项目提供类似的功能——通常是完全相同的——与 pkg-config 项目类似。其中一个就是 pkgconf 项目(当你要求 yum 包管理器为你安装 pkg-config 时,Red Hat 的 Fedora Linux 似乎更偏向使用它)。

不要将这两者混淆。pkgconf 项目是原始 pkg-config 项目的现代克隆,声称具有更高的效率——就我而言,它很可能确实能够兑现这些声明。不管怎样,本章是关于 pkg-config 的。如果你发现某个项目提供类似于 pkg-config 的功能,并且你喜欢它,那么请随意使用它。我的目标是教你关于 pkg-config 的知识。如果这些内容帮助你理解如何使用 pkgconf 或其他 pkg-config 克隆项目,那你从本书中得到的正是我希望传达的内容。

然而,话虽如此,我不能涵盖不同 pkg-config 克隆之间所有细微的差异。如果你想跟随我的示例,但你的 Linux 版本无法为你安装原版 pkg-config 包,你总是可以通过浏览器访问www.freedesktop.org/wiki/Software/pkg-config,下载并安装原版 pkg-config 项目的 0.29.2 版本。

使用 pkg-config 非常简单,只需调用pkg-config命令行工具,并使用显示所需数据的选项。当你寻找库和头文件时,“所需数据”包括包的版本信息以及指定库和头文件位置的编译器和链接器选项。例如,要获取访问库头文件所需的 C 预处理器标志,只需在pkg-config命令行中指定--cflags选项,适用于该包的编译器选项就会显示在stdout中。这个输出可以捕捉并根据需要附加到你的配置脚本和 makefile 中的编译器命令行。

也许你会想,既然 Autoconf 已经提供了AC_CHECK_LIBAC_SEARCH_LIBS宏,为什么我们还需要像pkg-config这样的工具呢?首先,正如我之前提到的,Autoconf 的宏只会在“标准位置”查找库。你可以通过将搜索路径预加载到CPPFLAGS(使用-I选项)和LDFLAGS(使用-L选项)来欺骗这些宏,使其查找其他地方。然而,pkg-config 的设计目的是帮助你找到那些可能安装在只有用户的 pkg-config 安装才知道的位置的库;pkg-config 最棒的一点是,它知道如何在最终用户系统上找到那些用户自己都不知道的库和头文件。pkg-config 还可以告诉你的构建系统,在用户尝试静态链接到你的库时,需要哪些额外的依赖项。因此,它有效地将这些细节从用户那里隐藏开来,这就是我们所追求的用户体验。

然而,在使用 pkg-config 与 Autotools 时,有一些注意事项。在本书的第一版中,我曾建议使用与 pkg-config 一起发布的PKG_CHECK_MODULES附加 M4 宏来与 Autoconf 配合使用,这是一种很好的方法。但随着多年来的发现,我已修改了对此问题的看法,因为我发现,在一些相当常见的情况下,使用这个宏可能会带来比解决问题更多的问题。此外,pkg-config在 Shell 脚本中直接使用非常简单,因此用不透明的 M4 宏将其包装起来几乎没有意义。本章我们将更加详细地讨论这一主题,但我想先为接下来的示例展示一种使用模式。

深入探讨

让我们先来看一下pkg-config命令的--help选项的输出:

$ pkg-config --help
Usage:
  pkg-config [OPTION...]
--snip--
Application Options:
--snip--
  --modversion                               output version for package
--snip--
  --libs                                     output all linker flags
  --static                                   output linker flags for static linking
--snip--
  --libs-only-l                              output -l flags
  --libs-only-other                          output other libs (e.g. -pthread)
  --libs-only-L                              output -L flags
  --cflags                                   output all pre-processor and compiler flags
  --cflags-only-I                            output -I flags
  --cflags-only-other                        output cflags not covered by the cflags-only-I option
  --variable=NAME                            get the value of variable named NAME
  --define-variable=NAME=VALUE               set variable NAME to VALUE
  --exists                                   return 0 if the module(s) exist
  --print-variables                          output list of variables defined by the module
  --uninstalled                              return 0 if the uninstalled version of one or more
                                             module(s) or their dependencies will be used
  --atleast-version=VERSION                  return 0 if the module is at least version VERSION
  --exact-version=VERSION                    return 0 if the module is at exactly version VERSION
  --max-version=VERSION                      return 0 if the module is at no newer than version
                                             VERSION
  --list-all                                 list all known packages
  --debug                                    show verbose debug information
  --print-errors                             show verbose information about missing or conflicting
                                             packages (default unless --exists or
                                             --atleast/exact/max-version given on the command line)
--snip--
$

我这里只展示了我认为最有用的选项。还有十几个其他的选项,但这些是我们在 configure.ac 文件中会经常使用的选项。(我已经将较长的描述行进行了换行,因为 pkg-config 似乎认为每个人都有一个 300 列的显示器。)

让我们首先列出系统中 pkg-config 已知的所有模块。以下是我系统上的一些示例:

$ pkg-config --list-all
--snip--
systemd                        systemd - systemd System and Service Manager
fontutil                       FontUtil - Font utilities dirs
usbutils                       usbutils - USB device database
bash-completion                bash-completion - programmable completion for the bash shell
libcurl                        libcurl - Library to transfer files with ftp, http, etc.
--snip--
notify-python                  notify-python - Python bindings for libnotify
nemo-python                    Nemo-Python - Nemo-Python Components
libcrypto                      OpenSSL-libcrypto - OpenSSL cryptography library
libgdiplus                     libgdiplus - GDI+ implementation
shared-mime-info               shared-mime-info - Freedesktop common MIME database
libssl                         OpenSSL-libssl - Secure Sockets Layer and cryptography libraries
xbitmaps                       X bitmaps - Bitmaps that are shared between X applications
--snip--
xkbcomp                        xkbcomp - XKB keymap compiler
dbus-python                    dbus-python - Python bindings for D-Bus
$

pkg-config 通过让包的安装过程更新 pkg-config 数据库来“了解”一个包,这个数据库实际上只是一个知名目录,pkg-config 会检查该目录来解决查询。数据库条目只是以 .pc 扩展名结尾的纯文本文件。因此,让 pkg-config 在安装过程中了解你的库项目,其实并没有比生成并安装一个文本文件更难,Autoconf 可以帮助我们生成这个文件,稍后我们将看到这一点。

pkg-config 工具会在多个目录中查找这些文件。我们可以通过调用它并使用 --debug 选项,将输出(发送到 stderr)通过 grep 管道来查看它查找的目录和搜索顺序,方法如下:

$ pkg-config --debug |& grep directory
Cannot open directory #1 '/usr/local/lib/x86_64-linux-gnu/pkgconfig' in package search path: No
such file or directory
Cannot open directory #2 '/usr/local/lib/pkgconfig' in package search path: No such file or
directory
Cannot open directory #3 '/usr/local/share/pkgconfig' in package search path: No such file or
directory
Scanning directory #4 '/usr/lib/x86_64-linux-gnu/pkgconfig'
Scanning directory #5 '/usr/lib/pkgconfig'
Scanning directory #6 '/usr/share/pkgconfig'
$

pkg-config 在我的系统上尝试查找的前三个目录不存在。这些目录都位于 /usr/local 目录树中。我在这个系统上并没有构建和安装很多包,因此没有将任何 .pc 文件安装到 /usr/local 目录树中。

从输出中可以清楚地看到,.pc 文件必须安装到以下六个目录中的一个:/usr(/local)/lib/x86_64-linux-gnu/pkgconfig/usr(/local)/lib/pkgconfig/usr(/local)/share/pkgconfig。当你仔细思考时,你会认识到这些路径本质上就是 pkg-config 的 ${libdir}/**pkgconfig${datadir}/pkgconfig 目录,如果说 pkg-config 在安装时不需要在 /usr/usr/local 之间做选择的话。早期,这些确实就是 pkg-config 的库和数据安装路径,但不久之后,项目开发者就意识到 pkg-config 安装的位置与其应该在用户系统上搜索 .pc 文件的位置并无太大关系——这些文件可能分布在很多地方,取决于用户在系统上安装包的位置,而不是 pkg-config 安装的位置。

那么,如何处理安装到自定义位置的包或尚未安装的包呢?pkg-config 对这些情况也有解决方案。PKG_CONFIG_PATH 环境变量可以将用户指定的路径添加到 pkg-config 用来搜索数据文件的默认搜索路径之前。随着我们在 configure.ac 中介绍更多使用 pkg-config 命令的细节,我们将学习如何使用这一功能。

编写 pkg-config 元数据文件

如前所述,pkg-config 的.pc文件仅仅是简短的文本文件,它描述了构建和链接过程中的关键方面,供使用这些依赖包组件的消费端构建过程使用。

让我们看一下我系统上的一个示例.pc文件——它是libssl库的文件,属于 OpenSSL 包的一部分。首先,我们需要找到它:

$ pkg-config --variable pcfiledir libssl
/usr/lib/x86_64-linux-gnu/pkgconfig
$

--variable选项允许你查询变量的值,pcfiledir是 pkg-config 为每个.pc文件定义的一个变量。我将在本章后面介绍预定义变量的完整列表。pcfiledir变量显示了pkg-config发现的文件当前所在位置。这一变量的好处在于,它也可以在你的.pc文件中使用,提供一种类似重定位的机制。如果你的库和包含文件路径都相对于${pcfiledir}.pc文件中定义,你可以随意移动文件(只要你将它定位的库和头文件也移动到相同的相对位置)。

我在清单 10-1 中提供了我的libssl.pc文件的完整内容。

➊ prefix=/usr
   exec_prefix=${prefix}
   libdir=${exec_prefix}/lib/x86_64-linux-gnu
   includedir=${prefix}/include

➋ Name: OpenSSL-libssl
   Description: Secure Sockets Layer and cryptography libraries
   Version: 1.0.2g
   Requires.private: libcrypto
   Libs: -L${libdir} -lssl
   Libs.private: -ldl
   Cflags: -I${includedir}

清单 10-1: libssl.pc:一个示例 .pc 文件

.pc文件包含两种类型的实体:变量定义(以➊开始),它们可以使用类似 Bourne shell 的语法引用其他变量;以及键值对标签(以➋开始),它们定义了pkg-config可以返回的关于已安装包的数据类型。这些文件可以包含 pkg-config 规范所需的任何内容,也可以只是包含最少的信息。除了这些实体,.pc文件还可以包含注释——任何以井号(#)标记开头的文本。虽然这些类型的实体可以混合出现,但通常的约定是将变量定义放在最上面,接着是键值对。

变量的外观和作用类似于 shell 变量;定义格式为变量名,后跟等号(=),再后跟值。即使值包含空格,你也无需对其进行引号处理。

Pkg-config 提供了一些预定义的变量,可以在.pc文件中使用,也可以通过命令行访问(正如我们之前所见)。表 10-1 显示了这些变量。

表 10-1: pkg-config 识别的预定义变量

变量 描述
pc_path pkg-config用来查找.pc文件的默认搜索路径
pcfiledir .pc文件的安装位置
pc_sysrootdir 用户设置的系统根目录,默认值为/*
pc_top_builddir 执行pkg-config时用户的顶级构建目录的位置

在你查看足够多的.pc文件后,你可能会开始想,像prefixexec_prefixincludedirlibdirdatadir这些变量是否对 pkg-config 有特殊的意义。它们没有;它们只是用来相对定义这些路径,以减少重复。

键值对格式为一个知名关键词,后跟冒号(:)字符,然后是构成值部分的文本。值可以引用变量;引用未定义的变量仅会展开为空。这些值中也不需要使用引号。

键值对的键是知名且有文档记录的,尽管在文件中放置未知键不会影响pkg-config使用文件中其余数据的能力。表 10-2 中显示的键是知名的:

表 10-2: pkg-config 识别的键值对中的知名键

描述
Name 库或包的可读名称。
Description 包的简短可读描述。
URL 与包关联的 URL——可能是包的下载站点。
Version 包的版本字符串。
Requires 本包所需的包列表;可以指定特定版本。
Requires.private 本包所需的私有包列表。
Conflicts 可选字段,描述本包与哪些包存在冲突。
Cflags 应该与此包一起使用的编译器标志。
Libs 应该与此包一起使用的链接器标志。
Libs.private 本包所需的私有库的链接器标志。

信息字段

为了让pkg-config --exists命令返回零到 shell,你至少需要指定NameDescriptionVersion。为了完整起见,考虑在项目有 URL 时也提供一个 URL。

如果你不确定为什么某个特定的pkg-config命令没有按预期工作,可以使用--print-errors选项。在pkg-config通常会默默返回一个 shell 代码的地方,--print-errors会显示非零 shell 代码的原因:

$ cat test.pc
prefix=/usr
libdir=${prefix}/lib dir

Name: test
#Description: a test pc file
Version: 1.0.0
$
$ pkg-config --exists --print-errors test.pc
Package 'test' has no Description: field
$ echo $?
1
$

注意

--validate选项也会为已安装和未安装的.pc文件提供此信息。

一个明显的疏漏是缺乏显示属于某个包的名称和描述信息的选项。描述信息在使用--list-all选项时会显示;然而,即使在该列表中显示的包名称实际上也是.pc文件的基础名称,而不是文件中Name字段的值。尽管如此,正如前面所提到的,这三个字段——NameDescriptionVersion——是必需的;否则,pkg-config认为该包不存在。

功能字段

Version 字段的类别跨越了信息性到功能性,因为有一些 pkg-config 命令行选项可以利用该字段的值,为配置脚本提供有关包的数据。RequiresRequires.privateCflagsLibsLibs.private 字段也为配置脚本和 makefile 提供机器可读的信息。CflagsLibsLibs.private 直接提供了 C 编译器和链接器的命令行选项。通过使用不同的 pkg-config 命令行选项,可以访问这些工具命令行中要添加的选项。

虽然 pkg-config 在概念上很简单,但一些细节如果你没有足够的实践,可能会有些难以捉摸。接下来我们将更详细地讲解这些字段。

信息性字段是为人类阅读而设计的。例如,可以使用 --modversion 选项显示包的版本:

$ pkg-config --modversion libssl
1.0.2g
$

注意

不要将 --version 选项与 --modversion 选项混淆。如果你混淆了,它会默默返回 pkg-config 的版本,无论你在 --version 后指定什么模块。

然而,Version 字段也可以用来向配置脚本指示一个包的版本是否满足要求:

$ pkg-config --atleast-version 1.0.2 libssl && echo "libssl is good enough"
libssl is good enough
$ pkg-config --exists "libssl >= 2.0" || echo "nope - too old :("
nope - too old :(
$

注意

库版本检查与 Autoconf 的一般哲学相悖,后者检查的是所需功能而不是库的特定版本,因为某些发行版提供商会将功能回移植到旧版本的库,以便在不升级库的情况下在其发行版的目标版本上使用该功能(主要是为了方便,因为较新版本的库有时会带来新的依赖要求,这些要求可能会传播到多个级别)。这些示例仅用于向你展示 pkg-config 提供的功能的可能性。

在功能字段中,有些比其他的更为直观。我们将逐一讲解每个字段,从较简单的开始。Cflags 字段可能是最简单的理解,它只是提供了包含路径的附加和其他选项给 C 预处理器和编译器。这两个工具的所有选项都汇聚在这一字段中,但 pkg-config 提供了命令行选项,用于返回字段值的部分内容:

$ pkg-config --cflags xorg-wacom
-I/usr/include/xorg
$ pkg-config --cflags-only-other xorg-wacom
$

注意

这里需要注意的重要事项是,Cflags* 字段包含的是编译器命令行选项,而不是编译器命令行选项的部分。例如,要为你的库定义包含路径,确保 Cflags 中包含 -I 标志和路径,就像在编译器命令行中那样。*

影响 Cflags 字段输出的其他选项包括 --cflags-only-I--cflags-only-other。如你所见,pkg-config 区分了 -I 选项和其他选项;如果你指定了 --cflags-only-I,你只会看到 .pc 文件中的 -I 选项。

Libs字段提供了设置-L-l和其他面向链接器的选项的位置。例如,如果你的包提供了stpl库,即libstpl.so,你需要在Libs字段中添加-L/installed/lib/path-lstpl选项。Pkg-config 的--libs选项返回完整的值,并且与Cflags一样,有一些单独的选项(--libs-only-l--libs-only-L,和--libs-only-other),它们将返回Libs选项的子集。

比较难理解的是Libs.private字段的使用。该字段的文档说明它是“该包所需但不暴露给应用程序的库”。然而,实际上,虽然这些是构建包发布的库所需的库,它们也是包的消费者在静态链接到该包的库时所需的库。^(1)事实上,使用pkg-config--static命令行选项,配合--libs(或类似选项)选项,将会显示LibsLibs.private字段选项的组合。这是因为,静态链接库时,在链接阶段,所有从静态库中直接链接的代码所需的符号都必须被链接。

这是一个重要的概念,理解它的工作原理是正确编写项目的.pc文件的关键。从终端用户的角度考虑:他们想要编译某个项目,并且希望将其与你的库静态链接(当然,我们还必须假设你的项目会构建并安装一个静态版本的库)。为了做到这一点,编译器和链接器命令行上需要哪些选项和库,除了那些在链接到动态库时已经要求的选项,才能成功完成这个任务?这个问题的答案将告诉你在你项目的.pc文件中Libs.private字段应填入什么内容。

既然这些话题已经讲完,我们可以正式讨论RequiresRequires.private字段。这些字段中的值是其他 pkg-config 包的名称,并可以选择性地指定版本。如果你的包依赖于另一个由 pkg-config 管理的特定版本的包,只需要在Requires字段中指定该包,前提是它的CflagsLibs字段的值是用户的构建过程需要的,以便使用你包的共享库;如果它的CflagsLibs.private字段的值是用户构建静态库时需要的,则应将该包指定在Requires.private字段中。

通过理解RequiresRequires.private字段,我们现在可以看到,pkg-config 包所需的附加选项,通常会放在CflagsLibsLibs.private字段中,其实不需要放在这些字段中,因为你可以简单地通过名称(以及版本或版本范围)在RequiresRequires.private中引用该包。Pkg-config 会递归地查找并根据需要合并所有包字段中的选项。

如果你包所需的包不是由 pkg-config 管理的,你必须将通常会出现在.pc文件中的选项添加到你自己的CflagsLibsLibs.private字段中。

RequiresRequires.private字段中使用的版本规范与 RPM 版本规范相同。你可以使用>>==<=<。遗憾的是,这些字段只允许一个给定库的实例,这意味着你无法对所需包的版本同时应用上下界。清单 10-2 提供了一个使用版本范围的做法示例。

--snip--
Name: music
Description: A library for managing music files
Version: 1.0.0
Requires: chooser >= 1.0.1, player < 3.0
--snip--

清单 10-2:在Requires中指定版本和版本范围

Requires字段表示这里需要两个库:chooserplayerchooser的版本必须是 1.0.1 或更高,而player的版本必须低于 3.0。^(2)

最后,Conflicts字段只是让你作为包的作者定义与包冲突的其他包,字段的格式与RequiresRequires.private相同。对于这个字段,你可以多次提供相同的包,以便定义与包冲突的特定版本范围。

当你完成.pc文件的编写后,可以使用--validate选项来验证它:

$ pkg-config --validate test.pc
$

注意

你可以使用任何提供字段信息的pkg-config选项,这些信息可以来源于已安装的.pc文件(只需使用文件的基本名称),也可以来源于未安装的.pc文件(通过指定文件的完整名称),正如这个示例所示。

如果pkg-config能够检测到任何错误,它们会被显示出来。如果什么都没有显示,那就说明pkg-config至少能够正确解析你的文件,并且一些基本检查通过了。

使用 Autoconf 生成.pc文件

现在你已经理解了.pc文件的基本结构,我们来考虑一下如何利用配置脚本生成的配置数据来生成.pc文件。考虑 pkg-config 提供的信息类型,其中大部分是路径信息,而配置脚本的目的是管理所有这些路径,包括构建产品的安装位置。

例如,用户可能会在configure命令行中指定安装前缀。该前缀决定了包的包含文件和库在用户安装该包时将被放置在系统的哪个位置。.pc文件最好能够知道这些位置,而且,如果我们能提供一个自动更新该文件的构建系统,使其反映用户在configure命令行中指定的前缀路径,那将是非常方便的。

从 pc.in 模板生成 pc 文件

为了实现这一点,我们不会直接编写.pc文件。相反,我们将为 Autoconf 编写.pc.in模板文件,并将prefix变量的值设置为@prefix@,以便在configure.pc.in模板转换为可安装的.pc文件时,替换该引用为实际的配置前缀。

我们还可以将Version字段的值设置为@PACKAGE_VERSION@,该值由你传递给 Autoconf AC_INIT宏的值定义,位于configure.ac中。为了方便实验,在一个空目录中创建一个configure.ac文件,如示例 10-3 所示。

AC_INIT([test],[3.1])
AC_OUTPUT([test.pc])

示例 10-3: configure.ac: 生成 test.pc 文件,源自 test.pc.in

现在,在同一目录下创建一个test.pc.in文件,如示例 10-4 所示。

➊ prefix=@prefix@
   libdir=${prefix}/lib/test
   includedir=${prefix}/include/test

   Name: test
   Description: A test .pc file
➋ Version: @PACKAGE_VERSION@

   CFlags: -I${includedir} -std=c11
   Libs: -L${libdir} -ltest

示例 10-4: test.pc.in: 一个 .pc 模板文件

在这里,我们在➊和➋处指定了prefixVersion字段的值,作为 Autoconf 替换变量引用。

生成文件并检查结果:

   $ autoreconf -i
   $ ./configure --prefix=$HOME/test
   configure: creating ./config.status
   config.status: creating test.pc
   $
   $ cat test.pc
➊ prefix=/home/jcalcote/test
   libdir=${prefix}/lib/test
   includedir=${prefix}/include/test

   Name: test
   Description: A test .pc file
➋ Version: 3.1

   CFlags: -I${includedir} -std=c11
   Libs: -L${libdir} -ltest
   $
   $ pkg-config --cflags test.pc
   -std=c11 -I/home/jcalcote/test/include/test
   $

正如你在控制台输出的➊和➋处看到的,生成的test.pc文件中的 Autoconf 变量引用被替换为这些 Autoconf 变量的值。

使用 make 生成.pc 文件

使用 Autoconf 从模板生成.pc文件的缺点是,它限制了用户在运行make时更改前缀选择的能力。这个小问题可以通过编写Makefile.am规则来生成.pc文件来解决。请按照示例 10-5 中所示,修改之前实验中的configure.ac文件。

AC_INIT([test],[3.1])
AM_INIT_AUTOMAKE([foreign])
AC_OUTPUT([Makefile])

示例 10-5: configure.ac: 修改示例 10-3 以使用 make 生成 test.pc

接下来,添加一个Makefile.am文件,如示例 10-6 所示。

EXTRA_DIST = test.pc
%.pc : %.pc.in
        sed -e 's|[@]prefix@|$(prefix)|g'\
          -e 's|[@]PACKAGE_VERSION@|$(PACKAGE_VERSION)|' $< >$@

示例 10-6: Makefile.am: 添加 make 规则来生成 test.pc

清单 10-6 中的关键功能是封装在模式规则中,使用一个简单的 sed 命令将 .pc.in 文件转换为 .pc 文件。这个 sed 命令中唯一不同的部分是,在要替换的变量的前导 @ 符号周围使用了方括号。sed 将这些方括号视为多余的正则表达式语法,但它们对 Autoconf 的作用是阻止它将该序列解释为替换变量的开头字符。我们不希望 Autoconf 替换这个变量,而是希望 sedtest.pc.in 文件中查找这个序列。另一种解决方法是自己制定变量替换格式,但需要注意,这种语法在 Autotools 社区中用于此目的是相当常见的。

注意

模式规则是特定于 GNU make 的,因此不可移植。最近,Automake 邮件列表中有一些讨论,提到是否放宽要求生成可移植 make 语法的限制,而仅要求使用 GNU make,因为 GNU make 目前已经广泛移植。

在这个示例中,我已将 test.pc 添加到 Automake 的 EXTRA_DIST 变量中,以便在执行 make distdistclean 时构建它,但你也可以将 test.pc 作为任何目标的前提条件添加到你的 Makefile.am 文件中,以便在构建的该阶段使其可用(如果需要的话)。我们来试试:

$ autoreconf -i
configure.ac:2: installing './install-sh'
configure.ac:2: installing './missing'
$
$ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... /bin/mkdir -p
checking for gawk... gawk
checking whether make sets $(MAKE)... yes
checking whether make supports nested variables... yes
checking that generated files are newer than configure... done
configure: creating ./config.status
config.status: creating Makefile
$
$ make prefix=/usr dist
make    dist-gzip am__post_remove_distdir='@:'
make[1]: Entering directory '/home/jcalcote/dev/book/autotools2e/book/test'
sed -e 's|[@]prefix@|/usr|g'\
            -e 's|[@]PACKAGE_VERSION@|3.1|' test.pc.in >test.pc
--snip--
$
$ cat test.pc
prefix=/usr
--snip--

请注意,我在 make 命令行中添加了 prefix=/usr;因此,test.pc 是使用该值在 prefix 变量中生成的。

卸载的 .pc 文件

我在本章开头提到过,pkg-config 也能够处理解析未安装的库和头文件的引用。这里所说的 未安装,指的是已经构建但未安装的产品;它们仍然保留在另一个项目的构建输出目录中。现在我们来看看如何实现这一点。

要使用它,用户需要将 PKG_CONFIG_PATH 设置为指向包含所需软件包的 -uninstalled 变体 .pc 文件的目录。这里所说的“-uninstalled 变体”是指一个名为 test.pc.pc 文件,会有一个名为 test-uninstalled.pc-uninstalled 变体。该 -uninstalled 变体并未安装在 pkg-config 数据库目录中,而是仍然保留在用户已构建的第三方依赖项的项目源目录中。以下是一个示例:

$ ./configure PKG_CONFIG_PATH=$HOME/required/pkg
--snip--
$

注意

我在这里遵循的是 Autoconf 推荐的做法,即将环境变量作为参数传递给 configure。在环境中设置变量或在 configure 前的同一命令行中设置它也可以,但不推荐这样做,因为 configure 对这些通过其他方式设置的变量了解较少。

假设$HOME**/required/pkg是所需包解压和构建的目录,并且假设该目录中包含该包的(可能生成的).pc文件,并且该目录中有-uninstalled版本,则该文件将通过执行pkg-config工具时引用所需包名称的方式进行访问,这些执行来自我们configure脚本中的引用。

显然,你不希望安装任何带有-uninstalled后缀的.pc文件变体——它们仅设计用于在构建目录中以这种方式使用。或许不那么明显的是,带有-uninstalled后缀的.pc文件并不包含与已安装版本相同的所有选项。简而言之,它们的区别在于路径选项。-uninstalled版本应包含相对于头文件源位置和库构建位置的绝对路径,以便当选项传递给消费者工具时,它们能够在这些路径中找到产品(头文件和库)。

让我们试试。编辑你在示例 10-3 中创建的configure.ac文件,使其与示例 10-7 中显示的文件相同。

AC_INIT([test],[3.1])
AC_OUTPUT([test.pc test-uninstalled.pc])

示例 10-7: configure.ac: 生成 -uninstalled 版本的 test.pc

绝对路径可以通过使用适当的 Autoconf 替换变量来推导,例如在示例 10-8 中所示的@abs_top_srcdir@@abs_top_builddir@

➊ libdir=@abs_top_builddir@/lib/test
➋ includedir=@abs_top_srcdir@/include/test

   Name: test
   Description: A test .pc file
➌ Version: @PACKAGE_VERSION@

   CFlags: -I${includedir} -std=c11
   Libs: -L${libdir} -ltest

示例 10-8: test-uninstalled.pc: test.pc.in 的 -uninstalled 版本

这是来自示例 10-4 的.pc文件的-uninstalled版本。我已经删除了prefix变量,因为在这种情况下它已经没有意义。我已将${prefix}引用替换为在libdir pkg-config 变量中使用@abs_top_builddir@,在includedir pkg-config 变量中使用@abs_top_srcdir@,如图➊和➋所示。让我们试试:

$ autoreconf
$ ./configure
configure: creating ./config.status
config.status: creating test.pc
config.status: creating test-uninstalled.pc
$ pkg-config --cflags test.pc
-std=c11 -I/home/jcalcote/dev/book/autotools2e/book/temp/include/test
$

你可能会问,为什么这比在configure命令行中直接设置CFLAGS(或CPPFLAGS)和LDFLAGS要容易得多。嗯,一方面,记住PKG_CONFIG_PATH比记住所有可能需要的单个工具变量更容易。另一个原因是,这些选项被封装在最能理解它们的地方——即由所需软件包的作者编写的.pc文件中。最后,如果这些选项发生变化,你必须相应地改变你使用的单个变量,但PKG_CONFIG_PATH将保持不变。pkg-config 提供的额外间接层次将所有细节隐藏在你和你的高级用户以及贡献者之外。

在 configure.ac 中使用 pkg-config

我们已经看到 .pc 文件是如何构建的。现在,让我们来看看如何在 configure.ac 中使用这个功能。如前一节所述,--cflags 选项提供了编译器所需的 Cflags 字段,以便编译此包。让我们用之前看到的 libssl.pc 文件来试一试。我在 清单 10-1 中的相关部分已在 清单 10-9 中重现。

prefix=/usr
--snip--
includedir=${prefix}/include
--snip--
Cflags: -I${includedir}

清单 10-9: libssl.pc: .pc 文件的相关部分

当我们对这个 .pc 文件使用 --cflags 选项时,我们现在明白应该看到一个 -I 编译器命令行选项。

$ pkg-config --cflags libssl

$

然而,什么也没有打印出来。嗯,我们做错什么了吗?libssl.pc 文件告诉我们,如果我们将变量展开,我们应该看到类似于 -I/usr/include 的内容,对吧?实际上,pkg-config 正在做它应该做的事情——它正在打印出找到 libssl 头文件所需的附加命令行选项。我们不需要告诉编译器关于 /usr**/include 目录的事情,因为这是一个标准位置,pkg-config 知道这一点,并会自动省略这类选项。^(3)

让我们试试一个 Cflags 值包含非标准包含位置的 .pc 文件。请注意,我在这里使用 pkg-config 本身来查找其数据库目录的路径,因为在不同的 Linux 发行版上,这个路径是不同的,用来查找 xorg-wacom.pc 文件:

$ cat $(pkg-config --variable pcfiledir xorg-wacom)/xorg-wacom.pc
sdkdir=/usr/include/xorg

Name: xorg-wacom
Description: X.Org Wacom Tablet driver.
Version: 0.32.0
Cflags: -I${sdkdir}
$
$ pkg-config --cflags xorg-wacom
-I/usr/include/xorg
$

由于 /usr/include/xorg 不是一个标准的包含路径,因此会显示该路径的 -I 选项。^(4) 这意味着你可以在你的 .pc 文件中完整地记录包的需求,而不必担心在消费者的编译器和链接器命令行中添加冗余的无用定义。

那么,我们如何使用这个输出呢?其实没有什么比一个小的 shell 脚本更难的了,正如在 清单 10-10 中所示。

--snip--
LIBSSL_CFLAGS=$(pkg-config --cflags libssl)
--snip--

清单 10-10:使用 pkg-config 填充 CFLAGS configure.ac

使用美元括号符号(dollar-parens notation)可以将此 pkg-config 命令的输出捕获到 LIBSSL_CFLAGS 环境变量中。

注意

当然,你可以使用反引号(backticks)来代替我在 清单 10-10 中使用的美元括号符号来实现相同的目标。反引号格式较旧,并且略微具有更好的可移植性,但它的缺点是无法轻松嵌套。例如,你不能像 $(pkg-config --cflags $(cat libssl-pc-file.txt)) 这样使用反引号,而不进行大量的转义魔法。

链接器选项的访问方式类似:

$ pkg-config --libs libssl
-lssl
$

回到 列表 10-1 中提到的 libssl.pc 文件,我们确实可以看到 Libs 行包含了 -lssl。同时,正如我们刚刚发现的,-L 选项,指向一个标准的链接器位置,/usr/lib/x86_64-linux-gnu,被自动省略。我们可以按照 列表 10-11 中展示的方式,将其添加到我们的 configure.ac 文件中。

--snip--
LIBSSL_LIBS=$(pkg-config --libs libssl)
--snip--

列表 10-11:在 pkg-config 中填充 configure.ac 中的 LIBS

让我们将所有内容结合起来,填充编译 libssl 头文件并与 libssl 链接所需的所有变量。列表 10-12 展示了这可能是如何实现的。

--snip--
if pkg-config --atleast-version=1.0.2 libssl; then
  LIBSSL_CFLAGS=$(pkg-config --cflags libssl)
  LIBSSL_LIBS=$(pkg-config --libs libssl)
else
  m4_fatal([Requires libssl v1.0.2 or higher])
fi
--snip--
CFLAGS="${CFLAGS} ${LIBSSL_CFLAGS}"
LIBS="${LIBS} ${LIBSSL_LIBS}"
--snip--

列表 10-12:在 pkg-config 中使用 libssl 访问 configure.ac

难道还有比这更简单或更易读的方法吗?我怀疑。让我们看一个更多的例子——即静态链接到 libssl,这也要求(私下)链接 libcrypto

   $ cat $(pkg-config --variable pcfiledir libssl)/libssl.pc
   prefix=/usr
   exec_prefix=${prefix}
   libdir=${exec_prefix}/lib/x86_64-linux-gnu
   includedir=${prefix}/include

   Name: OpenSSL-libssl
   Description: Secure Sockets Layer and cryptography libraries
   Version: 1.0.2g
➊ Requires.private: libcrypto
   Libs: -L${libdir} -lssl
➋ Libs.private: -ldl
   Cflags: -I${includedir}
   $
   $ cat $(pkg-config --variable pcfiledir libcrypto)/libcrypto.pc
   prefix=/usr
   exec_prefix=${prefix}
   libdir=${exec_prefix}/lib/x86_64-linux-gnu
   includedir=${prefix}/include

   Name: OpenSSL-libcrypto
   Description: OpenSSL cryptography library
   Version: 1.0.2g
   Requires:
   Libs: -L${libdir} -lcrypto
➌ Libs.private: -ldl
   Cflags: -I${includedir}
   $
   $ pkg-config --static --libs libssl
➍ -lssl -ldl -lcrypto -ldl
   $

正如你在此控制台示例中看到的 ➊,libssl 私下要求由 pkg-config 管理的 libcrypto 包,这意味着链接到 libssl 共享库时不需要在链接命令行中添加 -lcrypto,但静态链接时确实需要这个额外的库选项。我们还可以在 ➋ 看到,libssl 私下要求一个非 pkg-config 管理的库 libdl.so

注意

你可能会发现你的 libssl.pc libcrypto.pc 文件的内容与我的有所不同,这取决于你所使用的 Linux 发行版和你安装的 openssl 版本。别担心这些差异——你的系统和你的 * .pc * 文件上的一切都会正常工作。这个例子中最重要的是理解我所解释的概念。

进入 libcrypto.pc 文件,我们在 ➌ 看到 libcrypto 还私下要求 libdl.so

在 ➍ 处,值得注意的是,pkg-config “足够智能” 能理解链接器的库排序要求,并将 -ldl 设置在输出行中,排在 -lssl-lcrypto 之后。^(5) 我们人类有时很难手动完成这些事情。幸运的是,当一个工具能够处理一切,且不需要我们担心它是如何完成的时,真是太好了。最终,我想强调的是,pkg-config 将选项的控制权牢牢掌握在最有可能理解这些选项如何指定和排序的人手中——我们依赖项的维护者。

pkg-config Autoconf 宏

正如我在本章开始时提到的,pkg-config 还提供了一组 Autoconf 扩展宏,存放在一个名为 pkg.m4 的文件中,并安装在 /usr**(/local)/share/aclocal 目录下,这也是 autoconf 查找 .m4 文件的位置,包含了你可以在 configure.ac 文件中使用的 Autoconf 标准宏。

为什么我在例子中没有使用这些宏?嗯,避免使用这些宏有几个原因,一个显而易见,另一个则更为微妙——甚至有些狡猾。显而易见的原因是,直接在 configure.ac 中使用 pkg-config 工具在 shell 脚本中是多么容易。为什么要试图将它封装在 M4 宏中呢?

至于第二个原因,回顾之前的讨论,autoconf 的输入是包含 configure.ac 文件内容的一个数据流,以及所有宏定义,这些宏定义允许 M4 将所有宏调用展开为 shell 脚本。这些宏定义成为输入流的一部分,因为 autoconf 在读取你的 configure.ac 文件之前,首先会读取 /usr**(/local)/share/aclocal 目录中的所有 .m4 文件。换句话说,autoconf 并不知道一个必需的 .m4 文件缺失。它仅仅期望在安装路径的 aclocal 目录中的 .m4 文件中找到 configure.ac 所需要的所有宏定义。因此,autoconf 无法告诉你输入流中是否缺少某个宏定义。它只是没有意识到 PKG_CHECK_MODULES 是一个宏,因此没有将它展开成有效的 shell 脚本。所有这些都发生在你运行 autoconf(或 autoreconf)时。当你接着尝试运行 configure 时,它会失败,并显示与实际问题相差甚远的错误信息,你仅凭这些信息无法知道它们的含义。

一幅图胜过千言万语,所以让我们尝试一个快速实验。在一个空目录中创建一个 configure.ac 文件,如 列表 10-13 所示。

AC_INIT([test],[1.0])
AN_UNDEFINED_MACRO()

列表 10-13:一个 configure.ac 文件,其中有一个未知的扩展

现在执行 autoconf,然后是 ./configure

$ autoconf
$ ./configure
./configure: line 1675: syntax error: unexpected end of file
$

注意,当 autoconfconfigure.ac 转换为 configure 时,你不会收到任何错误。这完全是合理的,因为 m4 作为一个基于文本的宏处理器,只会尝试解释数据流中已知的宏。其他所有内容都会直接传递到输出流,就好像它是实际的 shell 脚本一样。

当我们运行 configure 时,得到了一个关于意外文件结束的加密错误(在第 1675 行……来自一个只有两行的 configure.ac 文件)。实际上发生的情况是,你不小心开始定义了一个名为 AN_UNDEFINED_MACRO 的 shell 函数,但没有在大括号中提供该函数的主体。shell 认为这不对劲,并用它一贯简洁的方式告诉了你。

如果我们省略了 AN_UNDEFINED_MACRO 后面的圆括号,shell 会给出更具信息性的错误:

$ cat configure.ac
AC_INIT([test],[1.0])
AN_UNDEFINED_MACRO
$ ./configure
./configure: line 1674: AN_UNDEFINED_MACRO: command not found
$

至少这次,shell 告诉了我们问题项的名称,给了我们机会去 configure.ac 中查找它,并可能弄清楚出了什么问题。^(6)

关键是,当你认为自己在使用 pkg-config 宏,但 autoconf 在查找常规宏目录时没有找到 pkg.m4 时,就会发生这种情况。其实并没有太多启发。在我个人的谦虚意见中,你最好跳过那几百行不透明的宏代码,直接在 configure.ac 文件中使用 pkg-config

然而,autoconf 无法找到已安装的 pkg.m4 文件的原因却颇具启发性。有一个常见的原因是,你从你的发行版的包仓库中安装了 pkg-config 包(或者在操作系统安装时它被自动安装了),使用了 yumapt。但你从 GNU 网站下载、构建并安装了 Autoconf,因为你的发行版版本的 Autoconf 版本落后了四个版本,而你需要最新的版本。那么,pkg-config 的安装过程将 pkg.m4 安装到了哪里?(提示:/usr/share/aclocal。)autoconf 从哪里获取宏文件?(提示:/usr/local/share/aclocal。)当然,你可以通过将 /usr/share/aclocal/pkg.m4 复制到 /usr/local/share/aclocal 来轻松解决这个问题,一旦你遇到这个问题一两次,你就再也不会被它困住了。但是你的高级用户和贡献者将不得不经历同样的过程——或者你也可以直接告诉他们都买本书,去读 第十章。

总结

在本章中,我们讨论了将 Autoconf 与 pkg-config 一起使用的好处,如何从 Autoconf 模板生成 .pc 文件,如何从 configure.ac 文件中使用 pkg-config,以及 pkg-config 特性的一些细微差别。

你可以在官方的 pkg-config 网站上阅读一些关于正确使用 pkg-config 包的更多内容,网址是 www.freedesktop.org/wiki/Software/pkg-config。Dan Nicholson 在他个人页面上写了一篇简明易懂的教程,介绍如何使用 pkg-config,网址是 freedesktop.org (people.freedesktop.org/~dbn/pkg-config-guide.html)。这个页面也可以通过 pkg-config 网站上的链接访问。

pkg-config 手册页提供了有关如何正确使用 pkg-config 的更多信息,但老实说,除了由一些有胆识的个人撰写的博客文章外,几乎没有其他更多的资料。幸运的是,关于 pkg-config 其实没有太多需要搞明白的东西。它写得很好,文档也很完善(就软件而言),只有少数几个小问题,我已经在这里尝试覆盖了这些问题。

第十一章:国际化

*像所有伟大的旅行者一样,我看到的比记得的多,记得的比看到的多。

—本杰明·迪斯雷利,《维维安·格雷》*

Image

当谈到将软件提供到其他语言时,母语为英语的人往往有些傲慢——但谁能怪我们呢?从几乎童年起,我们通过在这个行业中所经历的每一件事,都被教导英语是唯一重要的语言——以至于我们现在甚至不再去思考这个问题。所有计算机科学相关的研究和学术讨论,任何有影响力的社区都是用英语进行的。就连我们的编程语言都有英语关键词。

有人可能反驳说,这是因为大多数编程语言都是由英语使用者发明的。其实并不完全是这样!例如,来自瑞士的尼古拉斯·维尔特(Niklaus Wirth),一位德语为母语的人,他发明或参与了多个重要编程语言的发明,包括欧拉(Euler)、帕斯卡尔(Pascal)和莫杜拉(Modula)。觉得这些不够有名吗?丹麦出生的比雅尼·斯特劳斯特鲁普(Bjarne Stroustrup)发明了 C++。出生并成长于荷兰的吉多·范罗苏姆(Guido van Rossum)发明了 Python。出生在丹麦,后来移居加拿大的拉斯穆斯·勒多夫(Rasmus Lerdorf)编写了 PHP。Ruby 则是由日本的松本行弘(Yukihiro Matsumoto)编写的。

我的观点是,开发者——即使是非英语母语的开发者——从未考虑过发明使用德语关键词的编程语言。为什么不呢?可能是因为如果真的有人这么做,几乎没有人会使用它们——甚至连德国人也不会。新的编程语言往往是在学术或企业研究环境中构思出来的,促进这些发明的优缺点讨论的行业期刊、论坛和标准化组织几乎都是用英语书写或管理的——当然,这也是出于实际考虑。没有人在说英语是最好的语言。而是我们需要一个共同的媒介来发布信息,英语作为地球上最广泛使用的语言之一,就自然而然地扮演了这个角色。

由于这种只讲英语的态度,我们错过了一个重要的方面,那就是有整个非英语使用者的社区,他们在理解完全用英语编写的应用程序时感到困难。对他们来说,使用这些应用程序就像对只懂英语的人来说,去看一个中文或俄文网页一样不舒服。

大公司通常会提供语言包,以便这些社区能够在其母语中使用软件产品。部分商业本地化产品非常全面,甚至支持更为复杂的阿拉伯语和亚洲语言。^(1)然而,大多数较小的商业和开源软件包作者甚至都不尝试,因为他们认为成本过高、困难重重,或者这些对于他们的社区或市场来说并不重要。第一个论点在企业界可能有一定的道理。让我们讨论一下我们解决这些问题的选项,也就是如何扩大我们的社区。

强制性免责声明

在深入讨论这个话题之前,我先明确声明,关于软件国际化和本地化的多卷作品完全可以(也应该)被写出来。这个话题实在是太庞大了。我不可能在几章内容里涵盖所有内容。我的目标是为一个看起来可能令人生畏的主题提供一个介绍。如果你已经熟悉这些概念,可能会对我没有涉及的材料感到厌烦。请理解,这些章节并不是为你们准备的,虽然你们或许能从中找到一些有价值的想法。实际上,这些章节是为那些在这个领域经验较少的初学者准备的。

在这一章中,我将涵盖 C 标准中的内容以及与 UTF-8 编码集兼容的部分,还会稍微超出一些。我还将涵盖GNU gettext库的主要部分,因为将gettext集成到 Autotools 项目中实际上是本章的重点,但我不会涉及第三方库和解决方案,尽管在适当的地方我会提到它们。我也不会讨论宽字符字符串操作和多字节到宽字符(以及反之)的转换;有很多资源详细讨论了这些话题。

我刚才提到,我将涵盖gettext主要部分,这意味着有些部分我将跳过,因为它们只在特定条件下使用。一旦你掌握了基础,随时可以从手册中获取其余内容。

说到手册,像许多软件手册一样,gettext 手册更多的是作为参考资料,而非为初学者设计的教程。你可能曾经尝试阅读 gettext 手册,打算通过这个渠道熟悉国际化和本地化,但读完后你可能会想,“要么这是一本糟糕的手册,要么我根本无法理解。”我曾经也有过这种感觉。如果是这样,你在某种程度上是对的。首先,很明显这本手册是由非英语母语的人编写的。难道这很奇怪吗?我们已经决定,一般来说,英语母语的人并不太关心这个话题。手册中使用的一些习语对英语使用者来说根本不熟悉,而且一些表达方式显然是外来的。不过,不谈出处,这本手册的组织结构也并不有利于那些想要熟悉这个话题的人。当我为这一章做研究时,我发现了几篇在线教程,它们对那些只想弄清楚从哪里入手的程序员来说,要比手册更有帮助。

那么,让我们首先从一些定义开始。

国际化 (I18n)

国际化,在文献中有时被称为 i18n,因为这样写更简便,2 是准备将软件包发布到其他语言或文化中的过程。这项准备工作包括以一种方式编写(或重构)软件,使其能够轻松配置以显示其他语言的人类可读文本,或者符合其他文化习俗和标准。我在这里提到的文本包括字符串、数字、日期和时间、货币值、邮政地址、称呼和问候、纸张大小、度量衡以及你能想到的任何其他在人类交流中可能会因语言和文化差异而有所不同的方面。

国际化特别强调不是将嵌入的文本从一种语言转换成另一种语言。而是通过为你的软件做准备,使得静态和生成的文本可以轻松地以目标语言显示,或者以符合目标文化规范的格式显示。例如,英国文化中的人们期望看到日期、小数数字和本地货币的显示方式与美国人不同,尽管两者都讲英语。所以国际化不仅仅包括语言支持,还包括一般的文化支持。

为了明确,这个准备工作不是为西班牙语使用者专门构建一个版本的应用程序。例如,这个话题留待 第十二章讨论,在那里我将讨论 本地化 的概念。而国际化是指设计或修改你的软件,使其 能够 被西班牙语使用者轻松使用。这意味着首先找到并标记出软件中应该翻译的字符串,找出代码中显示格式化时间、日期、货币、数字和其他区域特定内容的地方。然后,你需要使这些静态文本和文本生成代码可以根据全球或指定的区域设置进行配置。当然,这也意味着配置你的软件,使其能够识别当前的系统区域设置,并自动切换到该设置。

软件国际化有两个足够不同的领域,我们应该将它们分开讨论:

  • 动态、运行时生成的文本消息

  • 硬编码到应用中的静态文本消息

让我们先讨论生成的消息,因为在这方面,我们通常会从编程语言标准库中获得一些帮助。大多数此类库都提供某种形式的区域设置管理支持,C 语言也不例外。C++ 提供了同样的功能,只不过是面向对象的方式。^(3) 一旦你理解了 C 中可用的内容,C++ 版本就很容易自学,所以我们将在这里介绍 C 标准库提供的功能。

我还将向你介绍 POSIX 2008 和 X/Open 标准提供的扩展接口,因为正如我们将看到的,标准 C 库提供的功能虽然可以使用,但有些薄弱,而 POSIX 和 X/Open 标准的功能则相当广泛可用。最后,GNU 对 C 标准的扩展可以让你的应用在其他文化中脱颖而出,只要你愿意稍微偏离标准。

为动态消息加 Instrumentation 的源代码

标准 C 库提供了 setlocalelocaleconv 函数,这些函数由 locale.h 头文件公开,如 清单 11-1 中所示。

#include <locale.h>

char *setlocale(int category, const char *locale);
struct lconv *localeconv(void);

清单 11-1:标准 C 库 setlocalelocaleconv 函数的概要

setlocale 的任务是告诉标准 C 库在给定的库功能类别中使用哪个区域设置。此函数接受一个 category—一个枚举值,表示库中应从当前区域设置切换到新的目标 locale 的区域特定功能段。可用的标准类别枚举值如下。

LC_ALL

LC_ALL 代表所有类别。更改此类别的值将所有可用类别设置为指定的区域设置。这是最常见且推荐使用的值,除非你有非常具体的理由不将所有类别设置为相同的区域设置。

LC_COLLATE

更改LC_COLLATE会影响诸如strcollstrxfrm等排序函数的工作方式。不同的语言和文化依据不同的字符或字形顺序规则进行排序。设置排序区域会改变库中排序函数使用的规则。

LC_CTYPE

更改LC_CTYPE会影响在ctype.h中定义的字符属性函数的工作方式(isdigitisxdigit除外)。它还会影响这些函数的多字节和宽字符版本。

LC_MONETARY

更改LC_MONETARY会影响localeconv返回的货币格式信息(稍后在本节中讨论),以及由 X/Open 标准和 POSIX 扩展strfmon返回的结果字符串。

LC_NUMERIC

更改LC_NUMERIC会影响格式化输入和输出操作中使用的十进制点字符(如printfscanf函数)以及由localeconv返回的与十进制格式相关的值,以及由 X/Open 标准和 POSIX 扩展strfmon返回的结果字符串。

LC_TIME

更改LC_TIME会影响strftime格式化时间和日期字符串的方式。

setlocale的返回值是一个表示先前区域设置的字符串,或者如果所有类别的区域设置不相同,则是一个区域设置集合。如果你只对确定当前区域设置感兴趣,可以在locale参数中传递NULL,这样setlocale就不会更改任何内容。如果你已将某些类别独立设置为不同的区域值,则在传递LC_ALL时返回的字符串格式是由实现定义的,因此不像预期的那样有用。尽管如此,大多数实现会允许你将这个字符串传回setlocale,并使用LC_ALL将类别特定的区域重置为先前获取的状态。

一旦设置了所需的区域设置,可以调用localeconv函数,返回一个指向结构的指针,结构中包含当前区域的一些属性。为什么不是所有的属性?因为这个 API 的设计者——按理说是聪明的人——在创建它时可能正在服用止痛药。说真的,GNU C 库手册对此有一些解释:

setlocale函数一起,ISO C 的人发明了localeconv函数。这是一个设计极差的杰作。它使用起来代价高昂,无法扩展,并且由于它只能提供与LC_MONETARYLC_NUMERIC相关的信息,因此通常不易使用。然而,如果在特定情况下适用,仍然应该使用它,因为它非常便捷。^(4)

除了这些批评外,我还要补充一点:它不是线程安全的;在你访问它的同时,结构体的内容可能会被另一个线程(通过调用setlocale)修改。不过,规则明确规定了它如何被修改——只有通过传递非NULLlocale参数值的setlocale调用,才会修改它——因此它是可以使用的,但既不优雅也不完整。正如前面的摘录所示,如果你的应用程序不需要额外的信息,你应该尽量使用localeconv,因为它是 C 标准的一部分,因此具有极高的可移植性。

公正地说,localeconv返回的结构体中的字段是那些需要程序员直接干预才能正确使用的字段,考虑到 C 标准库提供的功能。例如,printf系列函数没有为特定地区的数字和货币值提供特别的格式说明符,因此与LC_NUMERICLC_MONETARY类别相关的信息必须以某种方式提供给开发者,才能在设计用于以特定地区格式打印数字和货币金额的程序中正确使用这些类别。当然,这也意味着,没有第三方库或 C 标准扩展,你将不得不编写一些繁琐的文本格式化函数,根据localeconv返回的规则变化其输出。

另一方面,LC_COLLATELC_TIMELC_CTYPE类别直接影响现有的标准库功能,因此程序员可能不需要直接访问这些库函数使用的地区信息属性。^(5)

设置和使用地区信息

C 和 C++ 标准要求所有标准库的实现都必须在每个进程中初始化为默认的“C”地区信息,这样所有没有明确选择地区信息的程序将以可预测和一致的方式运行。因此,国际化软件的第一步就是改变地区信息。最简单且一致的方法是在程序开始时的某个地方调用setlocale,并将category值设置为LC_ALL。但是,我们应该传递什么字符串作为locale参数呢?这就是这个函数的妙处——你根本不需要传递任何特定的地区字符串。传递一个空字符串将禁用默认地区信息,允许库选择当前主机上有效的环境地区信息。这使得用户可以决定你的程序如何显示时间和日期、十进制数字和货币值,以及如何进行排序和字符集管理。

示例 11-2 显示了一个程序的代码,该程序配置标准 C 库以使用主机环境的区域设置,并将从 localeconv 获取的标准区域设置属性显示到控制台。

注意

本章中的示例程序可以在名为 NSP-Autotools/gettext 的在线 GitHub 仓库中找到,地址为 github.com/NSP-Autotools/gettext/。* 本章中呈现的小型实用程序位于该仓库中的* small-utils 目录,并且提供了一个 makefile,默认情况下会构建它们。使用类似make lc的命令,例如,只构建 示例 11-2 中呈现的 lc 程序。

Git 标签 11.0

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <limits.h>
#include <locale.h>

static void print_grouping(const char *prefix, const char *grouping)
{
    const char *cg;
    printf("%s", prefix);
    for (cg = grouping; *cg && *cg != CHAR_MAX; cg++)
        printf("%c %d", cg == grouping ? ':' : ',', *cg);
    printf("%s\n", *cg == 0 ? " (repeated)" : "");
}

static void print_monetary(bool p_cs_precedes, bool p_sep_by_space,
        bool n_cs_precedes, bool n_sep_by_space,
        int p_sign_posn, int n_sign_posn)
{
    static const char * const sp_str[] =
    {
        "surround symbol and quantity with parentheses",
        "before quantity and symbol",
        "after quantity and symbol",
        "right before symbol",
        "right after symbol"
    };
 printf("     Symbol comes %s a positive (or zero) amount\n",
             p_cs_precedes ? "BEFORE" : "AFTER");
    printf("     Symbol %s separated from a positive (or zero) amount by a space\n",
             p_sep_by_space ? "IS" : "is NOT");
    printf("     Symbol comes %s a negative amount\n",
             n_cs_precedes ? "BEFORE" : "AFTER");
    printf("     Symbol %s separated from a negative amount by a space\n",
             n_sep_by_space ? "IS" : "is NOT");
    printf("     Positive (or zero) amount sign position: %s\n",
             sp_str[p_sign_posn == CHAR_MAX? 4: p_sign_posn]);
    printf("     Negative amount sign position: %s\n",
             sp_str[n_sign_posn == CHAR_MAX? 4: n_sign_posn]);
}

int main(void)
{
    struct lconv *lc;
    char *isym;

    setlocale(LC_ALL, "");    // enable environment locale
    lc = localeconv();        // obtain locale attributes

    printf("Numeric:\n");
    printf("  Decimal point: [%s]\n", lc->decimal_point);
    printf("  Thousands separator: [%s]\n", lc->thousands_sep);

    print_grouping("    Grouping", lc->grouping);

    printf("\nMonetary:\n");
    printf("  Decimal point: [%s]\n", lc->mon_decimal_point);
    printf("  Thousands separator: [%s]\n", lc->mon_thousands_sep);

    print_grouping("    Grouping", lc->mon_grouping);

    printf("    Positive amount sign: [%s]\n", lc->positive_sign);
    printf("    Negative amount sign: [%s]\n", lc->negative_sign);
    printf("    Local:\n");
    printf("      Symbol: [%s]\n", lc->currency_symbol);
    printf("      Fractional digits: %d\n", (int)lc->frac_digits);

    print_monetary(lc->p_cs_precedes, lc->p_sep_by_space,
            lc->n_cs_precedes, lc->n_sep_by_space,
            lc->p_sign_posn, lc->n_sign_posn);

    printf("  International:\n");
    isym = lc->int_curr_symbol;
    printf("    Symbol (ISO 4217): [%3.3s], separator: [%s]\n",
            isym, strlen(isym) > 3 ? isym + 3 : "");
    printf("    Fractional digits: %d\n", (int)lc->int_frac_digits);

#ifdef __USE_ISOC99
    print_monetary(lc->int_p_cs_precedes, lc->int_p_sep_by_space,
            lc->int_n_cs_precedes, lc->int_n_sep_by_space,
            lc->int_p_sign_posn, lc->int_n_sign_posn);
#endif
    return 0;
}

示例 11-2: lc.c: 一个程序,用于显示从 localeconv 获取的所有区域设置属性

struct lconv 结构包含 char *char 字段。char * 字段大多是指字符串,其值由当前区域设置决定。一些 char 字段用于表示布尔值,而其他则设计为小整数值。示例代码 示例 11-2 应该清楚地指示哪些是布尔值,哪些是小整数值。您编译器的标准库文档也应该能明确说明这一点。

唯一奇怪的是 groupingmon_grouping 字段,它们分别表示数字和货币值的分组方式,分组之间由相应的千位分隔符字符串分隔。groupingmon_grouping 字段是 char * 类型的字段,设计时并非作为字符串读取,而是作为小整数数组读取。它们以零或 CHAR_MAX(在 limits.h 中定义)为终止符。如果它们以零终止,最后的分组值将永远重复;否则,最后的分组将包含值中剩余的数字。

最后,注意对内部 print_monetary 例程的调用,该调用被包装在一个 __USE_ISOC99 检查中(在示例底部附近)。这些货币属性的国际化形式是通过 C99 标准加入的。现在每个人都应该使用 C99,因此通常不成问题。我添加了条件编译检查,因为对于这个实用程序来说,这样做是可能且合适的。对于一个试图使用这些字段的应用程序,您应该要求 C99 标准是构建该应用程序的必要条件。

从美国英语的 Linux 系统构建并执行此程序会生成以下控制台输出:

$ gcc lc.c -o lc
$ ./lc
Numeric:
  Decimal point: [.]
  Thousands separator: [,]
  Grouping: 3, 3 (repeated)

Monetary:
  Decimal point: [.]
  Thousands separator: [,]
  Grouping: 3, 3 (repeated)
  Positive amount sign: []
  Negative amount sign: [-]
  Local:
    Symbol: [$]
    Fractional digits: 2
    Symbol comes BEFORE a positive (or zero) amount
    Symbol is NOT separated from a positive (or zero) amount by a space
    Symbol comes BEFORE a negative amount
 Symbol is NOT separated from a negative amount by a space
    Positive (or zero) amount sign position: before quantity and symbol
    Negative amount sign position: before quantity and symbol
  International:
    Symbol (ISO 4217): [USD], separator: [ ]
    Fractional digits: 2
    Symbol comes BEFORE a positive (or zero) amount
    Symbol IS separated from a positive amount by a space
    Symbol comes BEFORE a negative amount
    Symbol IS separated from a negative amount by a space
    Positive (or zero) amount sign position: before quantity and symbol
    Negative amount sign position: before quantity and symbol
$

要更改环境区域设置,请将 LC_ALL 环境变量设置为您想使用的区域设置名称。您可以使用的值是系统中生成并安装的区域设置。

注意

你也可以使用与类别名称相同的环境变量来设置单独的 locale 类别。例如,要将 locale 更改为西班牙语(西班牙),但仅针对LC_TIME类别,你可以将LC_TIME环境变量设置为es_ES.utf8。这对所有前面定义的标准类别有效。^(6)

要查看可用的 locale,运行locale工具并使用-a选项,如下所示:

$ locale -a
C
C.UTF-8
en_AG
en_AG.utf8
en_AU.utf8
en_BW.utf8
en_CA.utf8
en_DK.utf8
en_GB.utf8
en_HK.utf8
en_US.utf8
en_ZA.utf8
en_ZM
en_ZM.utf8
en_ZW.utf8
ja_JP.utf8
POSIX
sv_SE.utf8
$

注意

我的示例控制台列表是在基于 Debian 的系统上执行的。如果你使用的是基于 Fedora 的发行版,例如,你应该预期会看到不同的结果,因为 Fedora 在安装语言包和locale工具的工作方式上有显著不同的默认功能。我将在本章稍后讨论与 Red Hat 相关的具体情况,只有在真正需要时才会涉及。

通常,Linux 的美国英语安装会配置多个以en开头的 locale。我在我的基于 Debian 的系统上还生成了瑞典语(sv_SE.utf8)和日语(ja_JP.utf8)locale,以展示当环境配置为非英语语言和文化时输出的示例。

注意

我在本章后面也使用了法语(fr_FR.utf8)locale。你可能希望通过你发行版提供的机制预先构建或预安装所有这些 locale,以便在你的系统上更容易跟随我的示例。当然,如果你不是以英语为母语的人,你可能已经默认使用了不同的 locale。在这种情况下,你可能还需要构建或安装en_US.utf8* locale——尽管不出所料,即使在非美国制造或销售的系统上,这个 locale 通常也是预安装的。*

你可能已经注意到前面列表中的CC.UTF-8POSIX locales。正如之前提到的,C locale 是未显式设置 locale 的程序的默认 locale。POSIX locale 目前被定义为C locale 的别名。

生成和安装 Locales

生成和安装 locale 的过程通常与发行版密切相关,但也有一些常见的实现方式。例如,在基于 Debian 或 Ubuntu 的系统上,你可以查看/usr/share/i18n/SUPPORTED文件,查看可以从系统上的源生成并安装的 locale:

$ cat /usr/share/i18n/SUPPORTED
aa_DJ.UTF-8 UTF-8
aa_DJ ISO-8859-1
aa_ER UTF-8
--snip--
zh_TW BIG5
zu_ZA.UTF-8 UTF-8
zu_ZA ISO-8859-1
$

在我的 Linux Mint 系统上,这个文件中有 480 个 locale 名称。locale 名称的一般格式,如 X/Open 标准所定义,如下所示:

language[_territory][.codeset][@modifier]

一个区域设置名称最多包含四个部分。第一部分,language,是必需的。其余部分,territorycodesetmodifier,是可选的。例如,使用 UTF-8 字符集的美国英语的区域设置名称是 en_US.utf8language 以两位字母的 ISO 639 语言代码表示。^(7) 例如,en 指的是英语,可以是美式英语、加式英语、英式英语或其他英语方言。

territory 部分表示语言的地区,采用两位字母的 ISO 3166 国家代码表示。^(8) 例如,US 代表美国,CA 代表加拿大,GB 代表英国。

点(.)后的部分表示 codeset 或字符编码,格式为标准 ISO 字符编码名称,如 UTF-8 或 ISO-8859-1。^(9) 最常见的字符编码是 UTF-8(在区域设置名称中表示为 utf8),因为它可以表示世界上所有字符。然而,它并不是高效地表示所有字符;一些语言不使用 utf8,因为在这种编码中它们需要多个字节来表示每个字符。

modifier 部分并不常用。^(10) 其中一个可能的用途是生成一个仅在大小写敏感性或其他不是标准区域设置属性的属性上有所不同的区域设置。例如,当设置 LC_MESSAGES=en@``boldquot 时,你会得到一个英语消息集,区别在于引用的文本是加粗的。另一个历史上常见的例子是 en_IE@eu``ro 区域设置,仅通过使用不同的货币符号来区分。可以说,使用特定修改符的区域设置应用的差异是为非常特殊的用例设计的。

要在基于 Debian 或 Ubuntu 的系统上生成并安装特定的区域设置,你可以在 /var/lib/locales/supported.d 目录下添加一个文件,文件中包含来自 SUPPORTED 的表示你要添加的区域设置的行。添加到 supported.d 目录中的文件名并不特别重要,尽管我建议不要使用与该目录结构中已有文件名称相差太远的名称。唯一重要的是该目录下存在一个文件,并且文件内容完全与 SUPPORTED 中的相应行一致。

例如,要添加 sv_SE.utf8,我会找到 SUPPORTED 中表示此语言的行,将该行添加到 supported.d 中的一个文件里,然后运行 locale-gen 程序,步骤如下:

$ cat /usr/share/i18n/SUPPORTED | grep sv_SE
sv_SE.UTF-8 UTF-8
sv_SE ISO-8859-1
sv_SE.ISO-8859-15 ISO-8859-15
$
$ echo "sv_SE.UTF-8 UTF-8" | sudo tee -a /var/lib/locales/supported.d/sv
[sudo] password for jcalcote: *****
sv_SE.UTF-8 UTF-8
$ sudo locale-gen
Generating locales (this might take a while)...
  en_AG.UTF-8... done
--snip--
  en_ZW.UTF-8... done
  a_JP.UTF-8... done
  sv_SE.UTF-8... done
Generation complete.
$
$ locale -a
C
C.UTF-8
en_AG
--snip--
ja_JP.utf8
POSIX
sv_SE.utf8
$

SUPPORTED 中的每一行包含一个语言环境数据库条目名称,后跟字符集名称。对于瑞典语,我们关注的条目是 sv_SE.UTF-8,字符集是 UTF-8。我选择添加一个名为 sv 的文件到 /var/lib /locales/supported.d 中。你可以向文件中添加任意多的行;每一行将被作为单独的语言环境处理。由于 /var/lib/locale 中的文件属于 root 用户,因此你需要具有 root 权限才能创建或写入它们。我使用了一个常见的小技巧,通过 teeecho 命令将我想要的行添加到 supported.d/sv 中,作为 root 用户。^(11) 当然,你也可以直接使用带 sudo 的文本编辑器。

要在基于 Red Hat 或 CentOS 的系统上生成语言环境,你可以以这种方式使用 localedef 工具:

$ localedef --list-archive
aa_DJ
aa_DJ.iso88591
aa_DJ.utf8
--snip--
sv_SE.utf8
--snip--
zu_ZA
zu_ZA.iso88591
zu_ZA.utf8
$
$ sudo localedef -i sv_SE -f UTF-8 sv_SE.UTF-8
$
$ locale -a | grep sv_SE.utf8
sv_SE.utf8
$

-i 选项在 localedef 命令行中表示输入文件,该文件来自 localedef --list-archive 命令的输出。-f 选项表示使用的字符集。

注意

我发现最近的 Red Hat(因此 CentOS)系统通常预装了许多语言环境。你可能会发现,通过使用 locale -a,你不需要生成任何语言环境。任何在 locale -a 中显示的内容都可以立即作为 LANGLC_* 环境变量中的语言环境使用。而 Fedora 系统则需要安装特定语言的语言包,即使该语言环境已显示在 locale -a 列表中。例如,瑞典语需要安装 glibc-langpack-sv。此外,Fedora 上似乎没有安装语言源。因此,localedef* 命令在该平台上无法使用,但安装语言包后会提供语言环境的预编译版本。*

现在我们已经可以使用瑞典语语言环境了,让我们看看当我们使用该语言环境时执行来自 Listing 11-2 中代码的 lc 程序时会显示什么:

$ LC_ALL=sv_SE.utf8 ./lc
Numeric:
  Decimal point: [,]
  Thousands separator: [ ]
  Grouping: 3, 3 (repeated)

Monetary:
  Decimal point: [,]
  Thousands separator: [ ]
  Grouping: 3, 3 (repeated)
  Positive amount sign: []
  Negative amount sign: [-]
  Local:
    Symbol: [kr]
    Fractional digits: 2
    Symbol comes AFTER a positive (or zero) amount
    Symbol IS separated from positive (or zero) amount by a space
    Symbol comes AFTER a negative value
    Symbol IS separated from negative value by a space
    Positive (or zero) amount sign position: before quantity and symbol
    Negative amount sign position: before quantity and symbol
  International:
    Symbol (ISO 4217): [SEK], separator: [ ]
    Fractional digits: 2
    Symbol comes AFTER a positive value
    Symbol IS separated from positive value by a space
    Symbol comes AFTER a negative value
    Symbol IS separated from negative value by a space
    Positive (or zero) amount sign position: before quantity and symbol
    Negative amount sign position: before quantity and symbol
$

不幸的是,正如我之前提到的,localeconv 只返回关于数字(LC_NUMERIC)和货币(LC_MONETARY)类别的信息,虽然听起来有点糟糕,但实际上其他类别几乎由库自动处理。无论如何,还有其他方式可以访问完整的语言环境属性,我们将在本章后面讨论。

格式化时间和日期以供显示

标准 C 库在后台安静地处理时间和日期,具体取决于你在传递给 strftime 的格式字符串中使用的格式说明符。以下是 strftime 的原型:

#include <time.h>

size_t strftime(char *s, size_t max, const char *format, const struct tm *tm);

简单来说,strftime函数将最多max字节放入由s指向的缓冲区。内容由format中的文本和格式说明符决定。在format中只能指定一个时间值格式,其值从tm中获得。由于这是一个标准库函数,你可以参考任何标准 C 库手册,了解格式说明符在此函数中的使用方式。

清单 11-3 提供了一个小程序的源代码,该程序以某种形式输出当前时间和日期,这种格式被所有语言和地区支持。^(12)

#include <stdio.h>
#include <locale.h>
#include <time.h>

int main(void)
{
    time_t t = time(0);
    char buf[128];

    setlocale(LC_ALL, "");  // enable environmental locale

    strftime(buf, sizeof buf, "%c", gmtime(&t));
    printf("Calendar time: %s\n", buf);
    return 0;
}

清单 11-3: td.c: 一个小程序,用于在环境语言环境中打印日历日期和时间

构建并执行这个程序会在控制台显示类似以下的输出;你的时间和日期可能与我的不同:

$ gcc td.c -o td
$ LC_ALL=C ./td
Calendar time: Tue Jul    2 03:57:56 2019
$ ./td
Calendar time: Tue 02 Jul 2019 03:57:58 AM GMT
$ LC_ALL=sv_SE.utf8 ./td
Calendar time: tis    2 jul 2019 03:57:59
$

我在第一次执行时设置了LC_ALL=C,以展示如何使用默认的 C 语言环境执行本地化程序。这对于测试你的国际化软件来说是一个很有用的调试工具。

注意

C 语言环境并不是“美国”语言环境。它被称为最简语言环境。如果你使用LC_ALL=C执行lc程序,你会发现许多选项为空。标准库期望并以适当方式处理这些空选项。

比较英文和瑞典文输出。日期和月份名称使用的是当地语言。对于七月,英文和瑞典文的月份名称恰好是一样的。然而,注意到日期和月份名称的大小写差异。在英文中,名称首字母大写,而在瑞典文中则不是。另一个区别是英文使用 12 小时制的 AM/PM 时间格式,而瑞典文使用 24 小时制时间格式。瑞典和 C 语言省略了日期前的零,而美国语言环境则没有。最后,美国时间后会跟随格林威治标准时间区(GMT)的名称,而瑞典只有一个时区——中欧时间(CET)——这一点反映在瑞典标准时间和日期格式的简洁性上。

所有这些差异都由环境语言环境定义,但快速浏览清单 11-3 中的代码,可以看到我仅在调用strftime时使用了%c格式说明符。有效的语言环境使得这个格式说明符根据具体语言环境输出通用的时间和日期信息。

但是,并非所有strftime接受的格式说明符都是如此有用。例如,使用像"%X %D"这样的格式字符串看似是一个不错的方法,但在所有区域设置中,它并不能产生正确的结果。%X说明符以特定区域设置的方式格式化时间,但%D则以非常美国英语的方式格式化日期。此外,完整的时间日期字符串在不同的区域设置中会有所不同,时间和日期部分的顺序也会不同。在本章后面,我将向你展示如何使用nl_langinfo来解决这些问题。

排序和字符类

现在让我们考虑那些不那么显而易见的类别——那些不会在struct lconv中返回的信息:LC_COLLATELC_CTYPE

LC_COLLATE影响strcollstrxfrm函数的工作方式。对于英语使用者来说,这些函数的内部运作更难以理解,因为在英语中,区域设置特定的字符比较恰好与它们在ASCII表中的字典顺序一致。

注意

原始的 美国信息交换标准代码(ASCII) 美国标准协会(ASA) 于 1963 年发明。 最初,它只包括美国英语的大写字母和数字。1967 年,它被修订为包括控制字符和小写字母。由于标准将代码长度限制为 7 位,它只包含 128 个字符,使用 0 到 127 的代码。这个 7 位限制是因为每个字节的第八位通常用于数据传输中的错误校正。1981 年,IBM 将 ASCII 代码纳入一个 8 位、256 字符的代码的下半部分,并将其命名为代码 页面 437,并将这个代码纳入了其 IBM PC 系列个人计算机的固件中。在本章中,当我提到ASCII 表时,实际上是指代码 页面 437。* 从技术上讲,ASCII 仍然只限于 128 个字符。

许多其他语言并非如此。例如,在英语和西班牙语中,带重音的元音会正确地排在其没有重音的对应元音之后,而在日语中,既没有元音也没有重音元音,因此它们按 ASCII 表中的序号值进行排序。由于所有带重音的元音位于 ASCII 表的上半部分,而所有没有重音的元音位于下半部分,因此应该很清楚,使用英语或西班牙语区域设置时,西班牙单词列表的排序顺序与任何基于不包含拉丁字母的语言的区域设置不同。

清单 11-4 包含一个简短的程序,使用 C 语言的qsort函数通过不同的比较例程对西班牙语单词列表进行排序。

#include <stdio.h>
#include <stdlib.h>
#include <locale.h>
#include <string.h>

#define ECOUNT(x) (sizeof(x)/sizeof(*(x)))

int lex_count = 0;
int loc_count = 0;

static int compare_lex(const void *a, const void *b)
{
    lex_count++;
    return strcmp(*(const char **)a, *(const char **)b);
}
static int compare_loc(const void *a, const void *b)
{
    loc_count++;
    return strcoll(*(const char **)a, *(const char **)b);
}

static void print_list(const char * const *list, size_t sz)
{
    for (int i = 0; i < sz; i++)
        printf("%s%s", i ? ", " : "", list[i]);
    printf("\n");
}

int main()
{
    const char *words[] = {"rana", "rastrillo", "radio", "rápido", "ráfaga"};

    setlocale(LC_ALL, "");    // enable environment locale

    printf("Unsorted                : ");
    print_list(words, ECOUNT(words));

    qsort(words, ECOUNT(words), sizeof *words, &compare_lex);

    printf("Lex (strcmp)        : ");
    print_list(words, ECOUNT(words));

    qsort(words, ECOUNT(words), sizeof *words, &compare_loc);

    printf("Locale (strcoll): ");
    print_list(words, ECOUNT(words));

    return 0;
}

清单 11-4: sc.c: 一个简短的程序,演示了不同区域设置下的排序顺序差异

首先,将未排序的words列表打印到控制台;接着,使用compare_lex函数通过qsortwords列表中的指针进行排序,compare_lex函数使用strcmp来确定每对单词中字母的排序顺序。strcmp函数不了解任何区域设置,它只是使用单词中字母在 ASCII 表中的顺序。然后,将排序后的列表打印到控制台。

接下来,再次对words调用qsort——这次使用compare_loc,该函数使用strcoll来确定单词对的排序顺序。strcoll函数使用当前区域设置来确定被比较单词中字母的相对顺序。然后,将重新排序的列表打印到控制台。

使用不同的区域设置构建并执行该程序会显示以下输出:

$ gcc sc.c -o sc
$ ./sc
Unsorted        : rana, rastrillo, radio, rápido, ráfaga,
Lex (strcmp)    : radio, rana, rastrillo, ráfaga, rápido,
Locale (strcoll): radio, ráfaga, rana, rápido, rastrillo,
$ LC_ALL=es_ES.utf8 ./sc
Unsorted        : rana, rastrillo, radio, rápido, ráfaga,
Lex (strcmp)    : radio, rana, rastrillo, ráfaga, rápido,
Locale (strcoll): radio, ráfaga, rana, rápido, rastrillo,
$ LC_ALL=ja_JP.utf8 ./sc
Unsorted        : rana, rastrillo, radio, rápido, ráfaga,
Lex (strcmp)    : radio, rana, rastrillo, ráfaga, rápido,
Locale (strcoll): radio, rana, rastrillo, ráfaga, rápido,
$

英语和西班牙语的重音元音排序方式相同。C区域设置,由使用strcmp获得的结果表示,始终严格按照 ASCII 表排序。然而,日语的排序方式与拉丁语言不同,因为日语没有假设如何排序其字母表中未包含的字符(无论是否带有重音)。

在内部,strcoll使用一种算法将比较字符串中的字符转换为数字值,这些数字值在当前区域设置下自然排序;然后,它使用strcmp函数比较这些字节数组。strcoll使用的算法可能相当复杂,因为对于每一对它比较的字符串,它会将这些字符串对中的区域特定的多字节字符序列转换为可以按字典顺序比较的字节序列,通过字符集的顺序值,然后使用strcmp内部比较这些字节序列。

如果你知道自己将比较相同的字符串或字符串集,那么先使用strxfrm函数可能会更高效,它暴露了strcoll在内部使用的转换算法。然后,你可以简单地对这些转换后的字符串使用strcmp,以获得与对未转换字符串使用strcoll时相同的排序结果。

Listing 11-5 通过将 Listing 11-4 中的内容转换为使用strxfrm处理words数组中的单词,演示了这一过程,并将转换后的单词写入一个足够大的二维数组,以容纳转换后的字符串。

   #include <stdio.h>
   #include <stdlib.h>
   #include <locale.h>
   #include <string.h>

   #define ECOUNT(x) (sizeof(x)/sizeof(*(x)))

➊ typedef struct element
   {
       const char *input;
 const char *xfrmd;
   } element;

   static int compare(const void *a, const void *b)
   {
       const element *e1 = a;
       const element *e2 = b;
    ➋ return strcmp(e1->xfrmd, e2->xfrmd);
   }

   static void print_list(const element *list, size_t sz)
   {
       for (int i = 0; i < sz; i++)
        ➌ printf("%s, ", list[i].input);
       printf("\n");
   }

   int main()
   {
       element words[] =
       {
           {"rana"}, {"rastrillo"}, {"radio"}, {"rápido"}, {"ráfaga"}
       };

       setlocale(LC_ALL, "");   // enable environment locale

       // point each xfrmd field at corresponding input field
       for (int i = 0; i < ECOUNT(words); i++)
        ➍ words[i].xfrmd = words[i].input;

       printf("Unsorted            : ");
       print_list(words, ECOUNT(words));

       qsort(words, ECOUNT(words), sizeof *words, &compare);

       printf("Lex (strcmp)        : ");
       print_list(words, ECOUNT(words));

       for (int i = 0; i < ECOUNT(words); i++)
       {
           char buf[128];
           strxfrm(buf, words[i].input, sizeof buf);
        ➎ words[i].xfrmd = strdup(buf);
       }

       qsort(words, ECOUNT(words), sizeof *words, &compare);

       printf("Locale (strxfrm/cmp): ");
       print_list(words, ECOUNT(words));

       return 0;
}

Listing 11-5: sx.c: 重写后的sc程序,使用strxfrm

这里有几个需要注意的地方。strxfrm函数返回一个以零终止的字节缓冲区,它看起来和行为像一个普通的 C 字符串。里面没有内置的空字符;它可以被标准 C 库中的其他字符串函数操作,但从人类可读性的角度来看,它不一定是易于理解的。由于这种奇怪的特性,转换缓冲区的内容只能在排序时用于比较目的。原始输入值必须用于显示。因此,我们需要跟踪并作为对进行排序,输入缓冲区和转换缓冲区的每对单词。element结构在 ➊ 处为我们管理这一点。

由于我们不再需要使用strcoll,我已经移除了compare_loc函数,并将compare_lex重命名为compare,同时修改了代码以比较传入的element结构中的xfrmd字段(在 ➋ 处)。但是需要注意的是,print_list函数仍然打印元素的input字段(在 ➌ 处)。之所以可行,是因为words数组已被转换为一个对数组,其中每个元素包含原始和转换后的单词。

为了使这段代码与sc.c中原始的main流程兼容,在设置本地化后,sx.c会遍历words(在 ➍ 处),将每个元素的xfrmd指针设置为与其input指针相同的值。这让我们能够看到在第一次调用qsort时使用strcmp对未转换字符串进行比较时发生了什么。

在 ➎ 处,在打印完第一次排序操作的结果后,程序再次遍历words,这次对每个输入字符串调用strxfrm,并将相应的xfrmd字段指向转换缓冲区bufstrdup副本。^(13)

构建并执行示例 11-5 中的代码应该能显示出与我们运行示例 11-4 中的代码时相同的输出:

$ gcc sx.c -o sx
$ ./sx
Unsorted            : rana, rastrillo, radio, rápido, ráfaga,
Lex (strcmp)        : radio, rana, rastrillo, ráfaga, rápido,
Locale (strxfrm/cmp): radio, ráfaga, rana, rápido, rastrillo,
$ LC_ALL=es_ES.utf8 ./sx
Unsorted            : rana, rastrillo, radio, rápido, ráfaga,
Lex (strcmp)        : radio, rana, rastrillo, ráfaga, rápido,
Locale (strxfrm/cmp): radio, ráfaga, rana, rápido, rastrillo,
$ LC_ALL=ja_JP.utf8 ./sx
Unsorted            : rana, rastrillo, radio, rápido, ráfaga,
Lex (strcmp)        : radio, rana, rastrillo, ráfaga, rápido,
Locale (strxfrm/cmp): radio, rana, rastrillo, ráfaga, rápido,
$

它稍微复杂一点——这种版本的价值在排序五个单词时不立即显现出来,但在排序数百个字符串时,尽管有分配和释放转换缓冲区的开销,相比在strcoll内转换字符串,节省的时间是显著的。

注意

此示例通过捷径突出显示strxfrm的重要点。一个真实的程序会检查strxfrm的结果,它返回转换所需的字节数(不包括终止的空字符)。如果返回值大于指定的缓冲区大小,程序应该重新分配内存并再次调用strxfrm。没有合理的方式可以预先确定任何给定区域和字符集所需的缓冲区大小。我将缓冲区设置得足够大,以应对几乎所有的可能性,因此为了代码可读性我跳过了此检查,但这不是推荐的做法。

现在让我们关注LC_CTYPE区域设置类别。更改此区域设置类别会影响大多数字符分类函数在ctype.h中的工作方式,包括isalnumisalphaisctrlisgraphislowerisprintispunctisspaceisupper(但特别不包括isdigitisxdigit)。它还会影响touppertolower的工作方式——有点像。事实上,ctype.h中的函数在国际化方面存在许多问题。问题在于它们依赖算法机制来转换字符大小写,当你坚持使用 ASCII 表时,这些机制工作得很好。然而,一旦你离开这个熟悉的领域,情况就变得不确定了。有时它们工作正常,有时则不然。让它们正常工作的最一致方法是使用宽字符,因为这些函数的宽字符版本在 C 和 C++标准中是较新的,UTF-16 和 UTF-32 字符集也允许类似的算法转换,以支持更广泛的字符集。然而,即使使用宽字符,仍然有一些情况算法方法无法正确转换,因为某些语言有三种形式的双字母(digraphs):小写、大写和标题式。对于这些情况,根本没有算法可以正确处理。

清单 11-6 中的源代码展示了如何正确地将西班牙单词从大写转换为小写的一种方法。

#include <stdio.h>
#include <locale.h>
#include <wctype.h>
#include <wchar.h>

int main()
{
    const wchar_t *orig = L"BAÑO";
    wchar_t xfrm[64];

    setlocale(LC_ALL, "");  // enable environment locale

    int i = 0;
    while (i < wcslen(orig))
    {
        xfrm[i] = towlower(orig[i]);
        i++;
    }
 xfrm[i] = 0;
        printf("orig: %ls, xfrm: %ls\n", orig, xfrm);

        return 0;
}

清单 11-6: ct.c: 使用宽字符转换西班牙单词

输出如下:

$ gcc ct.c -o ct
$ ./ct
orig: BAÑO, xfrm: baño
$

如果你将缓冲区更改为char类型并使用 UTF-8,这个程序就无法正常工作。使用宽字符时,几乎无法正常工作。如果你设置LC_ALL=C,它只会打印orig:,因为如果我们检查清单 11-6 中的printf返回值(我们应该这样做——尤其是在处理字符集转换时),我们会看到它返回-1,这是它在无法使用%ls将宽字符字符串转换为多字节字符串时的返回值。

与其详细讨论LC_CTYPE类别中哪些有效、哪些无效,不如直接说,如果你需要做大量这种类型的转换和字符分类,我强烈推荐使用像 IBM 的Unicode 国际组件(ICU)^(14)或 GNU libunistring^(15)这样的第三方库(简而言之,它们在所有情况下都会做正确的事情)。ICU 是一个庞大的库,有一定的学习曲线,但如果你需要它,这个努力是值得的。GNU libunistring 稍微容易理解一些,但它仍然提供了很多新的功能。如果你在使用 C++,也可以使用像Boost::locale^(16)这样的包装库,它使得访问 ICU 变得更简单,尽管Boost::locale本身比较复杂。

X/Open 和 POSIX 标准扩展

很遗憾,目前没有一个标准的 C 库函数能够像strftime根据区域设置格式化时间和日期那样格式化数字和货币金额。然而,X/Open 和 POSIX 标准提供了一个扩展,并在 GNU C 库中实现了——strfmon函数,其原型如下:

#include <monetary.h>

ssize_t strfmon(char *s, size_t max, const char *format, ...);

它的工作方式与strftime非常相似,将格式化后的值字符串放入由s指向的max大小的缓冲区中。format字符串的作用类似于printf系列函数中的格式字符串,以及strftime中的格式字符串。格式说明符是特定于此函数的,但与其他函数的格式说明符一样,都是以百分号(%)开始,并以格式字符结束。可以在百分号和格式字符之间使用几个支持的修饰符字符。两个有效的格式字符是i用于国际格式,n用于本地格式。

这个函数旨在格式化货币金额,并遵循所有由localeconv提供的LC_CURRENCY规则,但它也可以根据localeconv提供的LC_NUMERIC规则来格式化十进制数字。列表 11-7 提供了不使用任何特殊修饰符来格式化本地和国际货币格式以及格式化十进制数字的示例代码。与strftime不同,strfmon可以格式化多个值。

#include <stdio.h>
#include <locale.h>
#include <monetary.h>

int main()
{
    double amount = 12654.376;
    char buf[256];

    setlocale(LC_ALL, "");  // enable environment locale

    strfmon(buf, sizeof buf, "Local: %n, Int'l: %i, Decimal: %!6.2n",
            amount, amount, amount);
    printf("%s\n", buf);
    return 0;
}

列表 11-7: amount.c: 调用strfmon格式化货币和十进制数值的示例

让我们构建并执行这个程序,看看使用不同区域设置时显示的内容:

$ gcc amount.c -o amount
$ LC_ALL=C ./amount
Local: 12654.38, Int'l: 12654.38, Decimal: 12654.38
$ ./amount
Local: $12,654.38, Int'l: USD 12,654.38, Decimal: 12,654.38
$ LC_ALL=sv_SE.utf8 ./amount
Local: 12 654,38 kr, Int'l: 12 654,38 SEK, Decimal: 12 654,38
$ LC_ALL=ja_JP.utf8 ./amount
Local: ¥12,654, Int'l: JPY 12,654, Decimal: 12,654.38
$

所有由lc程序在列表 11-2 中显示的货币和数字类别的特性,都被strfmon以与标准strftime函数处理时间和日期特性相同的方式考虑。例如,在英语和日语中,货币符号显示在数值前面,而瑞典的货币符号krSEK则显示在数值后面。在瑞典(以及许多其他欧洲区域设置)中,小数分隔符是逗号,而日元值不显示小数部分。

十进制格式说明符中的感叹号(!)修饰符用于抑制货币符号的显示。通过显式指定格式精度,我们可以覆盖默认的日语区域设置特性,该特性指示货币值不应有小数部分。strfmon函数显然是为格式化货币值设计的,但正如我们在这里看到的,它同样可以用来格式化普通的十进制和整数数值。

克服 localeconv 的局限性

X/Open 和 POSIX 标准还提供了一个更好、更具功能性的localeconv版本,称为nl_langinfo。以下是该函数的原型:

#include <langinfo.h>

char *nl_langinfo(nl_item item);

这个接口相较于标准库接口有许多优势。首先,它更加高效,只在需要时获取和返回你请求的字段,而不是每次请求时都填充并返回整个区域设置属性结构。nl_langinfo函数用于获取由item指定的全局环境区域设置的单一属性。

如果你的应用程序需要同时管理多个区域设置,可以查看 POSIX 接口,用于在同一应用程序中管理多个离散的区域设置。我在这里不会详细讲解,因为它们管理的是与我已经展示给你的接口相同的区域设置类别。相反,请参阅 POSIX 2008 标准,了解与nl_langinfo_l函数相关的newlocaleduplocaleuselocalefreelocale函数的信息,其中nl_langinfo_l函数接受一个类型为locale_t的第二个参数,这个参数是由newlocale返回的。我会提到,uselocale函数可用于设置当前线程的区域设置。到目前为止,我提到的所有函数都由 GNU C 库实现。

GNU C 库还提供对额外类别的区域设置信息的支持,包括LC_MESSAGESLC_PAPERLC_NAMELC_ADDRESSLC_TELEPHONELC_MEASUREMENTLC_IDENTIFICATIONLC_MESSAGES类别已经通过 POSIX 标准化,是gettext的基础,我稍后会讨论。其他类别在 C 或 POSIX 中并未标准化,但它们已经被纳入 Linux 的许多方面,包括 X 窗口系统的 Linux 移植版,已经很难想象它们在可预见的未来会被替换或移除。因此,如果你不打算将软件移植到 GNU 工具以外的地方,我建议使用它们。

这些额外的类别无法通过localeconvstruct lconv结构访问。相反,你需要使用nl_langinfo来访问与这些类别相关的区域设置值。

示例 11-8 是与示例 11-2 相同的程序,不同之处在于这个版本使用nl_langinfo来显示通过该接口提供的区域设置信息。它的组织方式刻意与两种接口显示相同内容,并保持完全相同的格式。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <limits.h>
#include <stdint.h>
#include <locale.h>
#include <langinfo.h>

static void print_grouping(const char *prefix, const char *grouping)
{
    const char *cg;
    printf("%s", prefix);
    for (cg = grouping; *cg && *cg != CHAR_MAX; cg++)
        printf("%c %d", cg == grouping ? ':' : ',', *cg);
    printf("%s\n", *cg == 0 ? " (repeated)" : "");
}

static void print_monetary(bool p_cs_precedes, bool p_sep_by_space,
        bool n_cs_precedes, bool n_sep_by_space,
        int p_sign_posn, int n_sign_posn)
{
    static const char * const sp_str[] =
    {
        "surround symbol and quantity with parentheses",
        "before quantity and symbol",
        "after quantity and symbol",
        "right before symbol",
        "right after symbol"
    };
    printf("    Symbol comes %s a positive (or zero) amount\n",
            p_cs_precedes ? "BEFORE" : "AFTER");
    printf("    Symbol %s separated from a positive (or zero) amount by a space\n",
            p_sep_by_space ? "IS" : "is NOT");
    printf("    Symbol comes %s a negative amount\n",
            n_cs_precedes ? "BEFORE" : "AFTER");
    printf("    Symbol %s separated from a negative amount by a space\n",
            n_sep_by_space ? "IS" : "is NOT");
    printf("    Positive (or zero) amount sign position: %s\n",
            sp_str[p_sign_posn == CHAR_MAX? 4: p_sign_posn]);
    printf("    Negative amount sign position: %s\n",
            sp_str[n_sign_posn == CHAR_MAX? 4: n_sign_posn]);
}
#ifdef OUTER_LIMITS

#define ECOUNT(x) (sizeof(x)/sizeof(*(x)))

static const char *_get_measurement_system(int system_id)
{
    static const char * const measurement_systems[] = { "Metric", "English" };
    int idx = system_id - 1;
    return idx < ECOUNT(measurement_systems)
            ? measurement_systems[idx] : "unknown";
}

#endif

int main(void)
{
    char *isym;

    setlocale(LC_ALL, "");

    printf("Numeric\n");
    printf("  Decimal: [%s]\n", nl_langinfo(DECIMAL_POINT));
    printf("  Thousands separator: [%s]\n", nl_langinfo(THOUSANDS_SEP));

    print_grouping("  Grouping", nl_langinfo(GROUPING));

    printf("\nMonetary\n");
    printf("  Decimal point: [%s]\n", nl_langinfo(MON_DECIMAL_POINT));
    printf("  Thousands separator: [%s]\n", nl_langinfo(MON_THOUSANDS_SEP));
    printf("  Grouping");

    print_grouping("  Grouping", nl_langinfo(MON_GROUPING));

    printf("  Positive amount sign: [%s]\n", nl_langinfo(POSITIVE_SIGN));
    printf("  Negative amount sign: [%s]\n", nl_langinfo(NEGATIVE_SIGN));
    printf("  Local:\n");
    printf("    Symbol: [%s]\n", nl_langinfo(CURRENCY_SYMBOL));
    printf("    Fractional digits: %d\n", *nl_langinfo(FRAC_DIGITS));

    print_monetary(*nl_langinfo(P_CS_PRECEDES), *nl_langinfo(P_SEP_BY_SPACE),
            *nl_langinfo(N_CS_PRECEDES), *nl_langinfo(N_SEP_BY_SPACE),
            *nl_langinfo(P_SIGN_POSN), *nl_langinfo(N_SIGN_POSN));

    printf("  International:\n");
    isym = nl_langinfo(INT_CURR_SYMBOL);
    printf("    Symbol (ISO 4217): [%3.3s], separator: [%s]\n",
           isym, strlen(isym) > 3 ? isym + 3 : "");
    printf("    Fractional digits: %d\n", *nl_langinfo(INT_FRAC_DIGITS));

    print_monetary(*nl_langinfo(INT_P_CS_PRECEDES), *nl_langinfo(INT_P_SEP_BY_SPACE),
            *nl_langinfo(INT_N_CS_PRECEDES), *nl_langinfo(INT_N_SEP_BY_SPACE),
            *nl_langinfo(INT_P_SIGN_POSN), *nl_langinfo(INT_N_SIGN_POSN));
 printf("\nTime\n");
    printf("  AM: [%s]\n", nl_langinfo(AM_STR));
    printf("  PM: [%s]\n", nl_langinfo(PM_STR));
    printf("  Date & time format: [%s]\n", nl_langinfo(D_T_FMT));
    printf("  Date format: [%s]\n", nl_langinfo(D_FMT));
    printf("  Time format: [%s]\n", nl_langinfo(T_FMT));
    printf("  Time format (AM/PM): [%s]\n", nl_langinfo(T_FMT_AMPM));
    printf("  Era: [%s]\n", nl_langinfo(ERA));
    printf("  Year (era): [%s]\n", nl_langinfo(ERA_YEAR));
    printf("  Date & time format (era): [%s]\n", nl_langinfo(ERA_D_T_FMT));
    printf("  Date format (era): [%s]\n", nl_langinfo(ERA_D_FMT));
    printf("  Time format (era): [%s]\n", nl_langinfo(ERA_T_FMT));
    printf("  Alt digits: [%s]\n", nl_langinfo(ALT_DIGITS));

    printf("   Days (abbr)");
    for (int i = 0; i < 7; i++)
        printf("%c %s", i == 0 ? ':' : ',', nl_langinfo(ABDAY_1 + i));
    printf("\n");

    printf("  Days (full)");
    for (int i = 0; i < 7; i++)
        printf("%c %s", i == 0 ? ':' : ',', nl_langinfo(DAY_1 + i));
    printf("\n");

    printf("  Months (abbr)");
    for (int i = 0; i < 12; i++)
        printf("%c %s", i == 0 ? ':' : ',', nl_langinfo(ABMON_1 + i));
    printf("\n");

    printf("  Months (full)");
    for (int i = 0; i < 12; i++)
        printf("%c %s", i == 0 ? ':' : ',', nl_langinfo(MON_1 + i));
    printf("\n");

    printf("\nMessages\n");
    printf("  Codeset: %s\n", nl_langinfo(CODESET));

#ifdef OUTER_LIMITS

    printf("\nQueries\n");
    printf("  YES expression: %s\n", nl_langinfo(YESEXPR));
    printf("  NO expression:  %s\n", nl_langinfo(NOEXPR));

    printf("\nPaper\n");
    printf("  Height:  %dmm\n", (int)(intptr_t)nl_langinfo(_NL_PAPER_HEIGHT));
    printf("  Width:   %dmm\n", (int)(intptr_t)nl_langinfo(_NL_PAPER_WIDTH));
    printf("  Codeset: %s\n", nl_langinfo(_NL_PAPER_CODESET));

    printf("\nName\n");
    printf("  Format: %s\n", nl_langinfo(_NL_NAME_NAME_FMT));
    printf("  Gen:    %s\n", nl_langinfo(_NL_NAME_NAME_GEN));
    printf("  Mr:     %s\n", nl_langinfo(_NL_NAME_NAME_MR));
    printf("  Mrs:    %s\n", nl_langinfo(_NL_NAME_NAME_MRS));
    printf("  Miss:   %s\n", nl_langinfo(_NL_NAME_NAME_MISS));
    printf("  Ms:     %s\n", nl_langinfo(_NL_NAME_NAME_MS));
 printf("\nAddress\n");
    printf("  Country name:   %s\n", nl_langinfo(_NL_ADDRESS_COUNTRY_NAME));
    printf("  Country post:   %s\n", nl_langinfo(_NL_ADDRESS_COUNTRY_POST));
    printf("  Country abbr2:  %s\n", nl_langinfo(_NL_ADDRESS_COUNTRY_AB2));
    printf("  Country abbr3:  %s\n", nl_langinfo(_NL_ADDRESS_COUNTRY_AB3));
    printf("  Country num:    %d\n",
            (int)(intptr_t)nl_langinfo(_NL_ADDRESS_COUNTRY_NUM));
    printf("  Country ISBN:   %s\n", nl_langinfo(_NL_ADDRESS_COUNTRY_ISBN));
    printf("  Language name:  %s\n", nl_langinfo(_NL_ADDRESS_LANG_NAME));
    printf("  Language abbr:  %s\n", nl_langinfo(_NL_ADDRESS_LANG_AB));
    printf("  Language term:  %s\n", nl_langinfo(_NL_ADDRESS_LANG_TERM));
    printf("  Language lib:   %s\n", nl_langinfo(_NL_ADDRESS_LANG_LIB));
    printf("  Codeset:        %s\n", nl_langinfo(_NL_ADDRESS_CODESET));

    printf("\nTelephone\n");
    printf("  Int'l format:    %s\n", nl_langinfo(_NL_TELEPHONE_TEL_INT_FMT));
    printf("  Domestic format: %s\n", nl_langinfo(_NL_TELEPHONE_TEL_DOM_FMT));
    printf("  Int'l select:    %s\n", nl_langinfo(_NL_TELEPHONE_INT_SELECT));
    printf("  Int'l prefix:    %s\n", nl_langinfo(_NL_TELEPHONE_INT_PREFIX));
    printf("  Codeset:         %s\n", nl_langinfo(_NL_TELEPHONE_CODESET));

   printf("\nMeasurement\n");
   printf("  System:  %s\n",_get_measurement_system(
           (int)*nl_langinfo(_NL_MEASUREMENT_MEASUREMENT)));
   printf("  Codeset: %s\n", nl_langinfo(_NL_MEASUREMENT_CODESET));

   printf("\nIdentification\n");
   printf("  Title:       %s\n", nl_langinfo(_NL_IDENTIFICATION_TITLE));
   printf("  Source:      %s\n", nl_langinfo(_NL_IDENTIFICATION_SOURCE));
   printf("  Address:     %s\n", nl_langinfo(_NL_IDENTIFICATION_ADDRESS));
   printf("  Contact:     %s\n", nl_langinfo(_NL_IDENTIFICATION_CONTACT));
   printf("  Email:       %s\n", nl_langinfo(_NL_IDENTIFICATION_EMAIL));
   printf("  Telephone:   %s\n", nl_langinfo(_NL_IDENTIFICATION_TEL));
   printf("  Language:    %s\n", nl_langinfo(_NL_IDENTIFICATION_LANGUAGE));
   printf("  Territory:   %s\n", nl_langinfo(_NL_IDENTIFICATION_TERRITORY));
   printf("  Audience:    %s\n", nl_langinfo(_NL_IDENTIFICATION_AUDIENCE));
   printf("  Application: %s\n", nl_langinfo(_NL_IDENTIFICATION_APPLICATION));
   printf("  Abbr:        %s\n", nl_langinfo(_NL_IDENTIFICATION_ABBREVIATION));
   printf("  Revision:    %s\n", nl_langinfo(_NL_IDENTIFICATION_REVISION));
   printf("  Date:        %s\n", nl_langinfo(_NL_IDENTIFICATION_DATE));
   printf("  Category:    %s\n", nl_langinfo(_NL_IDENTIFICATION_CATEGORY));
   printf("  Codeset:     %s\n", nl_langinfo(_NL_IDENTIFICATION_CODESET));

#endif // OUTER_LIMITS

    return 0;
}

示例 11-8: nl.c: 使用 nl_langinfo 显示可用的区域设置信息

要构建这个代码,你需要在命令行中添加几个定义:_GNU_SOURCEOUTER_LIMITS。第一个定义属于 GNU C 库,允许nl.c访问 C99 之前 C 标准中没有的扩展国际货币字段。第二个是我自己的发明,允许你在没有 GNU C 库提供的扩展类别的情况下构建程序:

$ gcc -D_GNU_SOURCE -DOUTER_LIMITS nl.c -o nl
$ ./nl
Numeric
  Decimal: [.]
  Thousands separator: [,]
  Grouping: 3, 3 (repeated)

Monetary
  Decimal point: [.]
  Thousands separator: [,]
  Grouping    Grouping: 3, 3 (repeated)
  Positive amount sign: []
  Negative amount sign: [-]
  Local:
    Symbol: [$]
    Fractional digits: 2
    Symbol comes BEFORE a positive (or zero) amount
    Symbol is NOT separated from a positive (or zero) amount by a space
    Symbol comes BEFORE a negative amount
    Symbol is NOT separated from a negative amount by a space
    Positive (or zero) amount sign position: before quantity and symbol
    Negative amount sign position: before quantity and symbol
  International:
    Symbol (ISO 4217): [USD], separator: [ ]
    Fractional digits: 2
    Symbol comes BEFORE a positive (or zero) amount
    Symbol IS separated from a positive (or zero) amount by a space
    Symbol comes BEFORE a negative amount
    Symbol IS separated from a negative amount by a space
    Positive (or zero) amount sign position: before quantity and symbol
    Negative amount sign position: before quantity and symbol

Time
  AM: [AM]
  PM: [PM]
  Date & time format: [%a %d %b %Y %r %Z]
  Date format: [%m/%d/%Y]
  Time format: [%r]
  Time format (AM/PM): [%I:%M:%S %p]
  Era: []
  Year (era): []
  Date & time format (era): []
  Date format (era): []
  Time format (era): []
  Alt digits: []
  Days (abbr): Sun, Mon, Tue, Wed, Thu, Fri, Sat
  Days (full): Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
  Months (abbr): Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec
  Months (full): January, February, March, April, May, June, July, August,
September, October, November, December
Messages
  Codeset: UTF-8

Queries
  YES expression: ^[yY].*
  NO expression:  ^[nN].*

Paper
  Height:  279mm
  Width:   216mm
  Codeset: UTF-8

Name
  Format: %d%t%g%t%m%t%f
  Gen:
  Mr:       Mr.
  Mrs:      Mrs.
  Miss:     Miss.
  Ms:       Ms.

Address
  Country name:  USA
  Country post:  USA
  Country abbr2: US
  Country abbr3: USA
  Country num:   840
  Country ISBN:  0
  Language name: English
  Language abbr: en
  Language term: eng
  Language lib:  eng
  Codeset:       UTF-8

Telephone
  Int'l format:    +%c (%a) %l
  Domestic format: (%a) %l
  Int'l select:    11
  Int'l prefix:    1
  Codeset:         UTF-8

Measurement
  System:  English
  Codeset: UTF-8

Identification
  Title:         English locale for the USA
  Source:        Free Software Foundation, Inc.
  Address:       http://www.gnu.org/software/libc/
  Contact:
  Email:         bug-glibc-locales@gnu.org
  Telephone:
  Language:      English
  Territory:     USA
  Audience:
  Application:
 Abbr:
  Revision:        1.0
  Date:            2000-06-24
  Category:        en_US:2000
  Codeset:         UTF-8
$

上述输出中突出显示的部分显示了nl输出中的一部分,超出了 Listing 11-2 中的lc程序。额外的区域类别定义如下。

LC_MESSAGES

此类别提供一个额外的项目值,CODESET,它定义了该区域使用的字符集。该项目被归类为“消息”,因为它旨在帮助翻译应用程序代码中的静态文本消息。该值还可以作为环境变量在 Linux 系统上使用,以帮助选择要使用的静态消息目录。

LC_PAPER

纸张类别提供两个项目,_NL_PAPER_HEIGHT_NL_PAPER_WIDTH,它们返回该区域最常用打印纸张的纸张尺寸值(以毫米为单位)。这在格式化打印输出或自动选择纸张尺寸时非常有用——例如letterA04。请注意,从这些项目枚举值返回的指针值应像本地字大小整数值一样对待,而不是实际的指针。有关详细信息,请参阅 Listing 11-8 中的nl.c代码。

LC_NAME

名称类别提供关于格式化称呼(如先生、女士、小姐和女士)在该区域的相关信息。此类别中的项目允许你的软件自动选择如何以当前语言和地区陈述这些称呼。

LC_ADDRESS

地址类别提供返回地理信息的项目,例如国家名称、邮政编码以及两字母和三字母的国家名称缩写。它还返回该区域使用的语言名称和库。

LC_TELEPHONE

电话类别提供格式化说明符字符串,可以在printf系列函数中使用,以在当前区域常见的样式中显示电话号码。

LC_MEASUREMENT

测量类别提供一个项目,用于返回当前区域使用的度量系统。_NL_MEASUREMENT_MEASUREMENT项目返回一个字符串,其第一个字符是一个短整数值:0表示公制,1表示英制。

LC_IDENTIFICATION

标识类别实际上是区域元数据。也就是说,该类别的字段返回关于领土、作者以及创建当前区域所使用的过程的信息(例如,区域作者的姓名、电子邮件地址、电话号码等)。它还返回有关区域的版本信息。请注意,从_NL_ADDRESS_COUNTRY_NUM返回的指针值应被视为本地字大小整数值,而非指针。有关详细信息,请参阅 Listing 11-8 中的nl.c代码。

你可以通过 Linux 发行版中预装的locale命令行程序,使用-k选项访问相同的信息,如下所示:

$ locale -k LC_PAPER
height=279
width=216
paper-codeset="UTF-8"
$

您可以查询 GNU C 库的nl_langinfo函数,以获取当前区域设置下的各类时间和日期格式属性,如 AM 和 PM 字符串、各种更细粒度的格式说明符字符串以及一周的完整和缩写日期和月份。

GNU C 库的nl_langinfo实现甚至返回了正则表达式,用于匹配查询响应。YESEXPRNOEXPR项枚举值返回的正则表达式可以用来匹配软件提示的问题的答案。

静态消息源代码仪表化

在源代码中对区域设置特定的静态文本消息进行仪表化,也是软件国际化过程的一部分,因此我们将在此讨论静态文本显示消息的仪表化。然后我们将继续探讨如何在第十二章中生成和使用语言包,在那里我将讨论本地化。

到现在为止,应该很清楚,我们需要处理“greeting,来自progname!”这段静态文本(例如从 Jupiter 打印的文本)。我不会继续讨论 Jupiter,但它提供了一个简洁的示例,说明当区域设置改变时,我们的程序中需要做出变化。为翻译静态显示消息而对源代码进行仪表化的过程,涉及扫描源代码中所有可能在程序执行期间显示给用户的字符串文字,然后做一些操作,使得程序能够使用专门针对当前区域设置的版本来显示这些字符串。

有一些开源(以及几个第三方商业)库可以用来完成这项任务,但我们将专注于 GNU gettext 库。从软件的角度来看,gettext 库非常简单。它的最简单形式包含一个用于标记需要翻译的消息的函数和两个用于选择显示消息目录的函数。标记函数名为gettext,其原型显示在列表 11-9 中。

#include <libintl.h>

char *gettext(const char *msgid);

列表 11-9:gettext函数的原型

该函数接受一个消息标识符作为msgid参数,并将显示消息返回给用户。消息标识符可以是任何字符串,但通常是显示消息本身,使用 US-ASCII 编码。这样做的原因是,如果找不到消息目录,gettext会返回msgid值本身,程序将以相同的方式使用该值,就像如果找到了翻译后的消息一样。因此,gettext函数不会以任何可能导致程序在任何合理条件下无法正常工作的方式失败。

这种约定使得为现有程序添加国际化支持以及编写使用基于语言环境的消息目录的新程序变得非常简单。你只需要找到程序源文件中的所有静态文本消息,并将它们包装在 gettext 调用中。

有时,需要向翻译员提供比单纯的字符串更多的上下文信息。例如,当为菜单项提供消息 ID 时,例如 File 菜单中的 Open 子菜单选项,程序员可能已经告诉翻译员,他们已将整个菜单层级以 |File|Open 这样的格式提供给翻译员。当翻译员看到这个时,他们会知道只需要翻译最后一个竖线符号后面的部分。但如果当前语言环境没有翻译,消息 ID 将会是完整的字符串。在这种情况下,程序员必须编写代码来检查是否存在前导竖线。如果找到,只有最后一个竖线后的部分才应该显示。

Listing 11-10 中的代码展示了一个非常简短(且有些熟悉)的示例程序,使用了 gettext

#include <stdio.h>
#include <libintl.h>

#define _(x) gettext(x)

int main()
{
     printf(_("Hello, world!\n"));
     return 0;
}

Listing 11-10: gt.c: 一个简短的程序,演示了如何使用 gettext 库

printf 函数将 gettext 的返回值发送到 stdoutgettext 函数由 GNU C 库导出,因此使用时无需额外的库。当不使用 GNU C 时,只需链接 intl 库(共享对象或静态库)。

我们可以直接在 printf 中调用 gettext,但下划线(_)宏在国际化软件时是常用的惯用法,原因有二:首先,它减少了在现有代码库中使用 gettext 时的视觉干扰;其次,如果我们选择通过额外功能包装 gettext,或者决定使用 gettext 的其他变体(例如 dgettextdcgettext),它允许我们在一个地方进行替换。我在这里没有讨论这些变体,但你可以在 GNU C Library 手册中找到更多信息。^(17)

消息目录选择

消息目录的选择分为两个阶段:程序员阶段和用户阶段。程序员阶段由 textdomainbindtextdomain 函数处理。这些函数的原型(也由 GNU C 库导出)见 Listing 11-11。

#include <libintl.h>

char *textdomain(const char *domainname);
char *bindtextdomain(const char *domainname, const char *dirname);

Listing 11-11: textdomainbindtextdomain 的原型

textdomain 函数允许软件作者在程序的任何给定点确定正在使用的消息目录域。该域表示包含程序中部分消息的特定消息目录。从源代码中提取的所有属于特定域的字符串最终都会进入该域的消息目录。

一个包可能有多个域。域之间的典型边界,因此也就是消息目录之间的边界,是可执行模块——无论是程序还是库。例如,curl包安装了命令行curl程序和libcurl.so共享库。curl库设计为供curl程序和其他第三方程序及库使用。如果curl包进行了国际化,包的作者可能会决定为curl程序使用curl域,为库使用libcurl域,这样使用libcurl的第三方应用就不需要安装curl消息目录。

GNU C 库手册中使用的示例^(18)是libc本身使用libc作为域名,但使用libc的程序会使用自己的域名。简单来说,这些函数中的domainname参数直接对应一个消息目录文件名。

bindtextdomain中的dirname参数用于指定一个基础目录,在该目录中搜索已定义的消息目录结构,我稍后会讨论这个结构。通常,传递给此参数的值是 Automake datadir变量中的绝对路径,并以/locale结尾。回想一下,datadir默认包含$(prefix)/share,而prefix包含/usr/local,所以这里使用的完整路径将是/usr/local/share/locale。对于由发行版提供的包,prefix通常只是/usr,因此完整路径将变成/usr/share/locale。因此,维护者需要确保在软件中可以使用datadir(使用第三章中讨论的技术),并在传递给该参数的参数中引用它。

示例 11-12 展示了如何添加必要的代码来根据当前地区选择合适的消息目录。当然,我们必须先以通常的方式通过调用setlocale让程序了解当前地区。

#include <stdio.h>
#include <locale.h>
#include <libintl.h>

#ifndef LOCALE_DIR
# define LOCALE_DIR "/usr/local/share/locale"
#endif

#ifdef TEST_L10N
# include <stdlib.h>
# undef LOCALE_DIR
# define LOCALE_DIR getenv("PWD")
#endif

#define _(x) gettext(x)

int main()
{
     const char *localedir = LOCALE_DIR;

     setlocale(LC_ALL, "");
     bindtextdomain("gt", localedir);
     textdomain("gt");

     printf(_("Hello, world!\n"));

     return 0;
}

示例 11-12: gt.c: 启用当前地区并选择消息目录的增强功能

我在这里使用gt作为域名,因为这是程序的名称。如果这个程序是某个包的一部分,而包中的所有组件都使用相同的域名,那么包名可能是一个更好的选择。

传递给bindtextdomain第二个参数的目录名来源于将来config.h的包含。我们稍后会在将该程序集成到 Autotools 构建系统时添加它。如果在编译器命令行中定义了TEST_L10N,目录名将解析为PWD环境变量的值,从而允许我们在任何包含地区目录结构的位置测试程序。(我们将在第十二章中用更符合 Autotool 的机制替换这个临时方案。)

这就是为你的代码添加消息目录查找功能的全部内容。在下一部分,我将讨论如何生成和构建消息目录,这也是本地化软件包过程的一部分。我还将讲解gettext库的内部工作原理,该库允许用户在用户阶段选择应由其环境变量设置选择的消息目录。

总结

在本章中,我的目标是为你提供足够的背景知识,让你能够轻松地继续学习如何使你的软件项目国际化。我已经涵盖了 C 标准库中帮助你进行软件国际化的功能。

在下一章中,我们将继续探索这个话题,深入研究本地化。我们还将发现如何将这一切与 Autotools 结合,以便通过 Automake 生成的make命令构建和安装语言包。

第十二章:本地化

当我在解决问题时,我从不考虑美观。我只考虑如何解决问题。但当我完成后,如果解决方案不美观,我知道它是错误的。

—R. 巴克敏斯特·富勒*

图片

一旦你完成了国际化软件的重大工作,你就可以开始考虑为其他语言和文化构建消息目录。构建特定语言的消息目录被称为本地化。你正在为目标区域设置本地化你的软件。我们将在本章讨论 GNU 项目如何处理这些主题,包括如何将消息目录管理集成到 Autotools 构建系统中。

入门

消息目录必须存放在应用程序能够找到的位置。可能已经决定,应用程序应该将其特定语言的消息目录存储在每个项目选择的某个位置,但 Linux(以及 Unix,通常也是如此)长期以来一直通过约定悄悄地指导应用程序开发者。这些约定不仅避免了开发者反复做出相同的决策,而且尽可能地最大化了重用的潜力。为此,消息目录的约定位置是将它们放在系统数据目录下的公共目录中——GNU 编码标准所称的datadir——通常定义为$(prefix)/share.^(1) 一个特殊的目录$(datadir)/locale,存放所有应用程序的消息目录,并以一种为用户提供一些好功能的格式呈现。

语言选择

我在第十一章中提到过,当前语言的应用程序选择,以及由应用程序使用的消息目录,是分两阶段完成的。我已经讨论过程序员阶段。现在让我们转向用户阶段,在这个阶段,用户可以选择所选的消息目录。与选择区域设置类似,消息目录的选择可以通过使用环境变量来指示。以下环境变量用于选择应用程序将使用的消息目录:

  • LANGUAGE

  • LC_ALL

  • LC_xxx

  • LANG

到目前为止,我们只关注了LC_ALL变量,但实际上,应用程序的全局区域设置是通过首先检查LC_ALL,然后是特定类别的变量(例如LC_TIME),最后是LANG,按照这个顺序来选择的。换句话说,如果LC_ALL未设置或设置为空字符串,setlocale将查找LC_xxx变量(特别是LC_COLLATELC_CTYPELC_MESSAGESLC_MONETARYLC_NUMERICLC_TIME),并使用它们的值来确定使用哪些区域设置来处理关联的库功能。最后,如果这些都没有设置,则检查LANG。如果LANG未设置,则会得到一个实现定义的默认值,这个默认值并不总是与C区域设置相同。

除了setlocale使用的这些变量外,gettext函数首先会查看LANGUAGE,如果已设置,LANGUAGE会覆盖所有其他变量以选择消息目录。此外,LANGUAGE变量中设置的对选择标准有一定影响。在我们深入了解值的格式之前,先来看一下$(datadir)/locale下的目录结构。如果你自己查看这个目录,可能会看到类似这样的内容:

$ ls -1p /usr/share/locale
aa/
ab/
ace/
ach/
af/
all_languages
--snip--
locale.alias
--snip--
sr@ijekavian/
sr@ijekavianlatin/
sr@latin/
sr@Latn/
--snip--
zh_CN/
zh_HK/
zh_TW/
zu/
$

这些目录名的格式应该看起来很熟悉——它与“生成和安装语言环境”中定义的语言环境名称格式相同,见 第 303 页。在每个包含消息目录的语言环境目录下(这其实比较稀疏——应用程序本地化并不像你想象的那么普遍),你会找到一个名为LC_MESSAGES的目录,里面包含一个或多个消息对象.mo)文件,这些是已编译的消息目录。例如,这里是西班牙语(西班牙)的情况:

$ tree --charset=ASCII /usr/share/locale/es_ES/LC_MESSAGES
/usr/share/locale/es_ES/LC_MESSAGES
`-- libmateweather.mo

0 directories, 1 file
$

如果你检查区域独立的西班牙语语言环境目录 /usr/share/locale/es,你会看到更多的消息目录。大多数程序在翻译时并不会区分不同的区域语言环境:

$ tree --charset=ASCII /usr/share/locale/es/LC_MESSAGES
/usr/share/locale/es/LC_MESSAGES/
|-- apt.mo
|-- apturl.mo
|-- bash.mo
|-- blueberry.mo
|-- brasero.mo
--snip--
|-- xreader.mo
|-- xviewer.mo
|-- xviewer-plugins.mo
|-- yelp.mo
`-- zvbi.mo
$

如我之前提到的,消息对象文件的基本名称是拥有应用程序的域名。当你调用textdomainbindtextdomain时,你指定的domain会通过名称选择一个消息对象文件。在这个目录列表中,blueberry是使用blueberry.mo消息目录的应用程序的消息目录域名。

构建消息目录

gettext库提供了一组工具,帮助你从已国际化的源代码中构建消息目录,以供消息目录选择。图 11-1 描述了数据通过gettext工具的流动,从源代码到二进制消息对象。

Image

图 12-1:从源文件到消息对象文件的数据流

xgettext工具从编程语言源文件中提取消息,并构建一个可移植对象模板.pot)文件。每当源文件中的消息字符串发生更改或更新时,都会执行此操作。可能是现有消息被修改或删除,或者添加了新的消息。无论如何,.pot文件必须更新。.pot文件通常以包或程序使用的消息目录域名命名。

假设我们为法语语言环境在一个项目中创建了消息目录,那么此过程将在当前目录下生成文件,但我们将遵循一种常见的约定,将所有消息文件生成到项目根目录下的一个名为po的目录中:

project/
   po/
      fr.mo
      fr.po
      project.pot

我示例中po目录的内部结构是任意的——你可以告诉gettext工具如何命名输出文件,并可以将其放在任何你喜欢的位置。我选择这个结构是因为我们将在本章稍后将gettext与 Autotools 项目集成时使用这种结构。

让我们为第 11-12 节中gt程序的源代码生成一个.pot文件,见第 328 页:

$ mkdir po
$ cd po
$ xgettext -k_ -c -s -o gt.pot ../gt.c
$ cat gt.pot
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-07-12 17:22-0600\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"

#: ../gt.c:25
#, c-format
msgid "Hello, world!\n"
msgstr ""
$

xgettext工具被设计用于从许多不同编程语言的源文件中构建.pot文件,因此它接受--language-L命令行选项作为提示。然而,如果没有提供此类选项,它也会根据文件扩展名猜测语言。

因为xgettext被设计成解析许多不同类型的源文件,它有时需要帮助来定位我们希望它提取的消息。它假定要提取的文本与 C 语言中的gettext函数以某种方式相关联。对于其他语言的源文件,它会查找该函数名称的适当变体。不幸的是,当我们将gettext替换为下划线(_)宏名称时,我们给过程带来了麻烦。这时,可以使用--keyword-k)选项告诉xgettext在哪里查找要提取的消息文本。我们使用-k_使xgettext查找_而不是gettext。如果没有这个选项,xgettext将找不到任何消息来提取,因此不会生成.pot文件。^(2)

我还告诉它添加注释(-c)并在将信息添加到.pot文件时按顺序排序输出消息(-s)。如果你没有另行指定(使用-o选项),它会创建一个名为messages.po的文件。没有与命令行选项相关联的文件将被xgettext视为输入文件。

此时,虽然不是严格要求的,并且取决于你选择使用的工作流,你可能需要手动编辑gt.pot,以更新xgettext添加的占位符值。例如,你可能想要替换Project-Id-Version字段中的PACKAGEVERSION占位符字符串,并可能向Report-Msgid-Bugs-To字段添加电子邮件地址。这些可以通过使用--package-name--package-version--msgid-bugs-address命令行选项在生成时添加。还有一些其他选项,你可以在手册中查找。

从这个模板中,我们现在可以为不同的语言环境生成portable object.po)文件。msginit工具用于创建特定语言环境的.po文件的初始版本,而msgmerge用于更新之前通过msginit生成的现有.po文件。

让我们从我们的模板gt.pot创建一个法语fr.po文件:

$ msginit --no-translator --locale=fr
Created fr.po.
$ cat fr.po
# French translations for PACKAGE package.
# Copyright (C) 2019 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# Automatically generated, 2019.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-07-02 23:19-0600\n"
"PO-Revision-Date: 2019-07-02 23:19-0600\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=ASCII\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

#: ../gt.c:21
#, c-format
msgid "Hello, world!\n"
msgstr ""
$

如果你没有指定任何输入或输出文件,它会在当前目录中查找.pot文件,并根据你通过--locale-l)选项指定的语言环境派生输出文件名。我还添加了--no-translator选项,以抑制该工具的交互式功能。如果你不加这个选项,msginit会尝试在本地主机上找到你的电子邮件地址并使用它。如果它搞不清楚,它会停止并询问你使用哪个地址。

除了指定或隐含的.pot文件,它还会检查指定源文件附近的 makefile 和其他构建文件,以查看项目可能的名称。你在输出中看到的项目名称PROJECT是它在找不到项目名称时使用的默认名称,但你会惊讶于msginit在搜索项目名称时的彻底程度。

现在,这个.po文件还没有任何法语内容——希望事情能那么简单!不,您仍然需要手动将字符串从英语翻译成法语。那么这个.po文件与其源模板文件有什么不同呢?实质上,模板中与特定语言实现相关的所有内容已经填写,包括顶部注释中的标题和版权年份,以及PO-Revision-DateLast-TranslatorLanguage-TeamLanguagePlural-Forms字段。

该过程的下一步是实际翻译文件。通常,我会找一个英语水平不错的法语母语者,让他们帮我填写空白。由于gt的简单消息几乎不可能误用互联网翻译工具,所以我自己查找并将文件底部的msgstr字段设置为"Bonjour le monde!\n"

翻译完成后,.po文件会通过msgfmt工具生成地区特定的消息对象.mo)文件。让我们为fr.po做这个:

$ msgfmt -o fr.mo fr.po
$ ls -1p
fr.mo
fr.po
gt.pot
$

msgfmt有很多选项可以使用。对于我们的例子,默认的功能已经足够。尽管如此,我仍然指定了输出文件(使用-o),因为默认的输出文件是messages.mo,而我想明确说明这是法语的消息文件。

为了测试我们的法语消息目录,我们可以将fr.mo复制到/usr/local/share/locale/fr/LC_MESSAGES/gt.mo,然后以 root 身份执行gt,并将LANGUAGE变量设置为fr,但更简单的方法是使用我为gt添加的一个小技巧,这让我们可以构建一个将当前目录视为localedir的版本。

注意

我将输出文件命名为 fr.mo,但安装的消息文件必须根据项目或程序的消息域命名——在此案例中是 gt——因此,在安装时 fr.mo 应该重命名为 gt.mo。它被安装到localedir的特定语言子目录中,因此通过文件系统中的位置,.mo 文件的法语特性在安装后得以保持。

首先,让我们本地安装fr.mo文件,然后重新构建gt,使其查找当前目录而不是系统数据目录。然后,我们将以英语和法语地区设置运行gt,如下所示:

$ cd ..
$ mkdir -p fr/LC_MESSAGES
$ cp po/fr.mo fr/LC_MESSAGES/gt.mo
$ gcc -DTEST_L10N gt.c -o gt
$ ./gt
Hello, world!
$ LANGUAGE=french ./gt
Bonjour le monde!
$

这个控制台示例应该引发一些疑问:为什么我使用LANGUAGE而不是LC_ALL?我怎么能用french而不是fr作为LANGUAGE的值而不引发gt在寻找法语版本的gt.mo时的麻烦?

为了回答第一个问题,我在这里不能使用LC_*LANG变量,因为我的系统上没有安装任何法语区域,且这些变量仅用于设置区域,剩下的textdomainbindtextdomain则根据localeconv(或者更准确地说,是该结构的一个更广泛的内部形式)返回的结构来确定区域。由于我没有安装任何法语区域,setlocale无法根据LANGLC_*变量的值设置区域,因此它会将当前全局区域保持为系统默认的区域——在我的主机上是英语。因此,使用的语言将继续是英语。

对第二个问题的回答将我们带回到我在《语言选择》一节中所做的一个尚未被证明的陈述,在第 332 页中,我提到用户在LANGUAGE变量中设置的textdomainbindtextdomain使用的选择标准有一定的影响。gettext库允许用户在请求的区域在系统中不可用时选择后备消息目录。这是通过在LANGUAGE变量中比在其他变量中使用更不具体的值来实现的,后者是setlocale检查的变量。LANGUAGE支持的值格式可以完全复制LC_*LANG中要求的严格格式,但它也支持带有缺失组件的区域名称和语言别名。

首先,让我们考虑一下我所说的缺失组件是什么意思。回想一下区域名称的组件:

language[_territory][.codeset][@modifier]

bindtextdomain函数尝试在指定的区域目录中找到与此完整格式匹配的消息目录,该格式由LANGUAGE变量或localeconv提供的当前区域字符串指定。然后它会退回,首先去掉codeset,然后是codeset的标准化形式^3,接着是territory,最后是modifier。如果所有组件都被去除,我们将只剩下区域名称的language部分(或者在LANGUAGE中指定的任何其他随机文本)。如果仍然找不到匹配项,bindtextdomain会查看/usr/share/locale/locale.alias文件,寻找与LANGUAGE中值匹配的别名(在我的系统上,frenchfr_FR.ISO-8859-1的别名)。这个算法允许用户在选择消息目录时比较模糊,甚至在没有完全匹配当前区域的情况下,仍然能获取到一个相对接近其母语的消息目录。

将 gettext 与 Autotools 集成

到目前为止,本章中我一直在通过命令行使用gcc构建像gt这样的简单工具和程序。现在是时候将gt转变为一个 Autotools 项目,以便我们可以按照 GNU 项目推荐的方式添加本地语言支持(NLS)功能。实际上,走这条路是最好的,因为它允许那些喜欢做这类事情、并且喜欢你程序的翻译人员更轻松地为他们的语言添加消息目录。

本节中的信息大部分摘自GNU gettext 工具手册的第十三部分,“维护者视角”。^(4) 由于涉及 Autotools 以及gettext包本身,gettext手册的内容有些过时,但它在国际化和本地化的主题上组织得很好,且非常详细。事实上,它内容如此全面,以至于在掌握一些基础知识之前很难完全理解。我的目标是在本章中提供你需要的背景,以便你可以毫无畏惧地深入阅读gettext手册。实际上,本章仅轻微地涉及了手册中详细阐述的许多话题。

让我们将gt.c文件移动到项目的src目录中,并创建configure.acMakefile.am以及其他 GNU 要求的文本文件。假设你当前位于原始gt.c文件所在的目录,执行以下操作:

Git 标签 12.0

$ mkdir -p gettext/src
$ mv gt.c gettext/src
$ cd gettext
$ autoscan
$ mv configure.scan configure.ac
$ touch NEWS README AUTHORS ChangeLog
$

Makefile.am文件应与清单 12-1 中所示的文件相似。

bin_PROGRAMS = src/gt
src_gt_SOURCES = src/gt.c
src_gt_CPPFLAGS = -DLOCALE_DIR=\"$(datadir)/locale\"

清单 12-1: Makefile.am: 此 Automake 输入文件的初始内容

我添加了特定于目标的CPPFLAGS,以便我可以在编译命令行中传递LOCALE_DIR。我们还应该编辑我们的src/gt.c文件,并向其中添加config.h头文件,这样我们就可以访问我们在其中定义的LOCALE_DIR变量。清单 12-2 显示了我们需要进行的更改。你还可以删除TEST_L10N的临时代码;我们将不再需要这个,因为我们可以通过本地安装来测试 Autotools 构建的gt

#include "config.h"

#include <stdio.h>
#include <locale.h>
#include <libintl.h>

#ifndef LOCALE_DIR
# define LOCALE_DIR "/usr/local/share/locale"
#endif

#define _(x) gettext(x)
--snip--

清单 12-2: src/gt.c: 配置LOCALE_DIR所需的更改

现在编辑新的configure.ac文件,并进行清单 12-3 中所示的更改。

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.69])
AC_INIT([gt], [1.0], [gt-bugs@example.org])
AM_INIT_AUTOMAKE([subdir-objects])
AC_CONFIG_SRCDIR([src/gt.c])
AC_CONFIG_HEADERS([config.h])
AC_CONFIG_MACRO_DIRS([m4])

# Checks for programs.
AC_PROG_CC

# Checks for libraries.

# Checks for header files.
AC_CHECK_HEADERS([locale.h])

# Checks for typedefs, structures, and compiler characteristics.

# Checks for library functions.
AC_CHECK_FUNCS([setlocale])

AC_CONFIG_FILES([Makefile])

AC_OUTPUT

清单 12-3: configure.ac: autoscan生成的.scan文件所需的更改

请注意,在清单 12-3 中的AC_CHECK_HEADERS行中,删除了一些头文件引用。

到此为止,你应该能够执行autoreconf -i,然后执行configuremake来构建gt

$ mkdir m4
$ autoreconf -i
configure.ac:12: installing './compile'
configure.ac:6: installing './install-sh'
configure.ac:6: installing './missing'
Makefile.am: installing './INSTALL'
Makefile.am: installing './COPYING' using GNU General Public License v3 file
Makefile.am:     Consider adding the COPYING file to the version control system
Makefile.am:     for your code, to avoid questions about which license your project uses
Makefile.am: installing './depcomp'
$ ./configure && make
--snip--
configure: creating ./config.status
config.status: creating Makefile
config.status: creating config.h
config.status: config.h is unchanged
config.status: executing depfiles commands
make  all-am
make[1]: Entering directory '/.../gettext'
depbase=`echo src/gt.o | sed 's|[^/]*$|.deps/&|;s|\.o$||'`;\
gcc -DHAVE_CONFIG_H -I.      -g -O2 -MT src/gt.o -MD -MP -MF $depbase.Tpo -c -o src/gt.o src/gt.c &&\
mv -f $depbase.Tpo $depbase.Po
gcc  -g -O2   -o src/gt src/gt.o
make[1]: Leaving directory '/.../gettext'
$

注意

我在运行autoreconf之前创建了 m4 目录,因为autoreconf在找到AC_CONFIG_MACRO_DIRS时会抱怨 m4 目录不存在。它仍然可以工作,但会警告你该目录丢失。提前创建它只是为了减少噪音。*

在增强现有的 Autotools 项目以支持 NLS 和gettext时,第一步是将一堆gettext特定的文件添加到你的项目中。这其实是一个相当繁琐的过程,因此gettext团队创建了一个叫做gettextize的小工具,它工作得相当不错。当你运行gettextize时,它会进行一些小的分析,将一堆文件丢到项目的po目录中(如果还没有的话,它会创建一个),然后在控制台显示一个六到七步的过程。为了确保你不会忽略这个输出,它会等你按回车键终止程序,并在过程中获得你承诺阅读并执行这些步骤。不幸的是,这些指示有点过时——并非所有步骤都是必需的,而且如果你使用的是完整的 Autotools 套件,有些步骤甚至不适用。像许多与 Autotools 集成的程序一样,gettext是为了仅使用 Autoconf 的包和使用完整 Autotools 套件的程序而编写的。我会在接下来的内容中解释哪些步骤是重要的。

让我们从在我们的 gt 项目目录中运行gettextize开始:

Git 标签 12.1

   $ gettextize
➊ Creating po/ subdirectory
   Copying file ABOUT-NLS
   Copying file config.rpath
   Not copying intl/ directory.
   Copying file po/Makefile.in.in
   Copying file po/Makevars.template
   Copying file po/Rules-quot
   Copying file po/boldquot.sed
   Copying file po/en@boldquot.header
   Copying file po/en@quot.header
   Copying file po/insert-header.sin
   Copying file po/quot.sed
   Copying file po/remove-potcdate.sin
   Creating initial po/POTFILES.in
   Creating po/ChangeLog
   Copying file m4/gettext.m4
   Copying file m4/iconv.m4
   Copying file m4/lib-ld.m4
   Copying file m4/lib-link.m4
   Copying file m4/lib-prefix.m4
   Copying file m4/nls.m4
   Copying file m4/po.m4
   Copying file m4/progtest.m4
➋ Updating Makefile.am (backup is in Makefile.am~)
   Updating configure.ac (backup is in configure.ac~)
   Adding an entry to ChangeLog (backup is in ChangeLog~)

➌ Please use AM_GNU_GETTEXT([external]) in order to cause autoconfiguration
   to look for an external libintl.

➍ Please create po/Makevars from the template in po/Makevars.template.
   You can then remove po/Makevars.template.

➎ Please fill po/POTFILES.in as described in the documentation.

➏ Please run 'aclocal' to regenerate the aclocal.m4 file.
   You need aclocal from GNU automake 1.9 (or newer) to do this.
   Then run 'autoconf' to regenerate the configure file.

➐ You will also need config.guess and config.sub, which you can get from the
   CVS of the 'config' project at http://savannah.gnu.org/. The commands to fetch
   them are
   $ wget 'http://savannah.gnu.org/cgi-bin/viewcvs/*checkout*/config/config/
   config.guess'
   $ wget 'http://savannah.gnu.org/cgi-bin/viewcvs/*checkout*/config/config/
   config.sub'

➑ You might also want to copy the convenience header file gettext.h
   from the /usr/share/gettext directory into your package.
 It is a wrapper around <libintl.h> that implements the configure --disable-nls
   option.

   Press Return to acknowledge the previous 6 paragraphs.
   [ENTER]
   $

gettextize做的第一件事是创建一个po子目录(在➊位置)在我们的项目根目录中,如果需要的话。这里将保存所有与 NLS 相关的文件,并由一个特定于 NLS 的 makefile 进行管理,gettextize也提供了这个 makefile,正如你可以从输出的前几行中看到的第三条Copying file消息。

在将文件从系统的gettext安装文件夹复制到po目录后,接着它会更新根目录的Makefile.am文件和configure.ac(在➋位置)。Listings 12-4 和 12-5 展示了它对这些文件所做的更改。

bin_PROGRAMS = src/gt
src_gt_SOURCES = src/gt.c src/gettext.h
src_gt_CPPFLAGS = -DLOCALE_DIR=\"$(localedir)\"

SUBDIRS = po

ACLOCAL_AMFLAGS = -I m4

EXTRA_DIST = config.rpath

Listing 12-4: Makefile.am: gettextize对该文件的更改

一个SUBDIRS变量被添加(或者如果已存在则更新)到顶级Makefile.am文件中,以便make会处理po/Makefile,并且添加了AC_LOCAL_AMFLAGS以支持m4目录,这是gettextize本应添加的,如果我们没有先这样做的话。最后,gettextize会添加一个EXTRA_DIST变量,确保config.rpath能够被分发。

注意

添加AC_LOCAL_AMFLAGS = -I m4在 Automake 的较新版本中不再必要,因为它提供了AC_CONFIG_MACRO_DIRS宏,能够透明地处理这个包含指令用于aclocal

我手动将$(datadir)/locale改为 Autoconf 提供的$(localedir),并更新了src_gt_CPPFLAGS行。

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.
--snip--
# Checks for library functions.
AC_CHECK_FUNCS([setlocale])

AC_CONFIG_FILES([Makefile po/Makefile.in])

AC_OUTPUT

Listing 12-5: configure.ac: gettextize对该文件的更改

gettextizeconfigure.ac 做的唯一更改是将 po/**Makefile.in 文件添加到 AC_CONFIG_FILES 文件列表中。一个敏锐的读者会注意到这个引用的末尾有一个 .in,可能会认为 gettextize 出了错。然而,回头看看该工具复制的文件列表,我们会发现复制到 po 目录中的文件确实叫做 Makefile.in.in。Autoconf 首先处理这个文件,然后 gettext 工具再次处理它,去掉第二个 .in 后缀。

回到 gettextize 的输出,我们看到在 ➌ 处,gettextize 正在要求我们在 configure.ac 中添加一个宏调用,AM_GNU_GETTEXT([external])。这可能看起来很奇怪,因为它刚刚为我们编辑了 configure.ac。显示的文本在这一点上并不清楚,但实际上,曾经有一个完整的 gettext 运行时是根据需求添加到项目中的。这一行只是告诉我们,如果我们不打算使用这种内部版本的 gettext 库,我们应该通过在调用此宏时使用 external 选项来表明这一点,这样 configure 就会知道去项目外部查找 gettext 工具和库。实际上,使用 gettext 库的内部版本现在一般不再推荐——主要是因为 gettext 现在已经集成到了 libc 中(至少在 Linux 系统上是这样),因此每个人都可以轻松访问外部版本的 gettext。如果你使用的是另一种类型的系统,并且使用 GNU 工具,你应该安装 gettext 包,以便使用那个外部版本。

我还注意到,当我添加这个宏时,autoreconf 报告我使用了 AM_GNU_GETTEXT 但没有使用 AM_GETTEXT_VERSION,它会告诉构建系统可以与此项目一起使用的 gettext 最低版本。我也添加了这个宏,并设置了与我系统中 gettext --version 输出相对应的版本值。我本可以使用一个较低的版本值,以便让我的项目能够在其他可能较旧的系统上构建,但我必须做些研究以确保我使用的所有选项在我选择的版本下都是有效的。

列表 12-6 显示了对 configure.ac 的这次添加。

--snip--
# Checks for programs.
AC_PROG_CC
AM_GNU_GETTEXT_VERSION([0.19.7])
AM_GNU_GETTEXT([external])

# Checks for libraries.
--snip--

列表 12-6: configure.ac: 添加 AM_GNU_GETTEXT

下一步,在➍中,表示我们应该将 po/Makevars.template 复制到 po/Makevars 并编辑它以确保值是正确的。我说“复制”而不是“移动”,因为移除模板会导致它在下次运行 autoreconf -i 时被替换掉,所以对这点纠结没有意义。

列表 12-7 显示了该文件的精简版——我删除了注释,这样我们可以更容易地看到功能性内容,但注释非常详尽且非常有用,所以请一定查看完整的文件。

DOMAIN = $(PACKAGE)
subdir = po
top_builddir = ..
XGETTEXT_OPTIONS = --keyword=_ --keyword=N_
COPYRIGHT_HOLDER = John Calcote
PACKAGE_GNU = no
MSGID_BUGS_ADDRESS = gt-bugs@example.org
EXTRA_LOCALE_CATEGORIES =
USE_MSGCTXT = no
MSGMERGE_OPTIONS =
MSGINIT_OPTIONS =
PO_DEPENDS_ON_POT = no
DIST_DEPENDS_ON_UPDATE_PO = yes

列表 12-7: po/Makevars.template: 控制 NLS 构建的变量列表

我已突出显示我对 gt 版本文件所做的更改。如你所见,默认设置大多数情况下是没问题的。我将版权持有者从默认的Free Software Foundation更改为其他名称。我还指出,gt 不是 GNU 包——这里的默认值是空白,这意味着gettext会尝试在运行时确定该信息。

我为MSGID_BUGS_ADDRESS指定了一个值,这是生成的.pot文件中的一个值。po目录的 makefile 生成的值将是你在此处指定的电子邮件地址(或网页链接)。最后,我将PO_DEPENDS_ON_POT设置为no,因为否则,每当gt.pot文件发生细微变化时,所有特定语言环境的.po文件都会重新生成,而我更倾向于仅在创建分发包时生成我的项目中的.po文件。这是一个基于个人偏好的任意决定;如果你愿意,也可以选择保留其默认值yes

在➎处,我们看到要求向po/POTFILES.in添加一些文本。这是 Automake 要求在 makefile 中指定所有源文件的结果。我们被要求添加所有必须由xgettext处理的源文件,以提取消息。文件可以一行一行地添加,如果需要,该文件中可以使用以井号(#)开头的注释。Listing 12-8 突出显示了我对 gt 版本的po/POTFILES.in所做的更改。

# List of source files that contain translatable strings.
src/gt.c

Listing 12-8: po/POTFILES.in: 对该文件生成版本所做的更改

po/POTFILES.in中列出的文件应该相对于项目目录根目录。

在➏和➐列出的步骤,在新版本的 Autotools 中已经不再需要。configure脚本在执行时会自动运行aclocal并在必要时重新构建自身。config.subconfig.guess文件现在会由autoreconf根据使用gettext宏来自动安装。不幸的是,autoreconf安装的是与 Autoconf 一起提供的这些文件版本;它们可能已经过时,因此仍然建议找到并安装最新版本。如果需要的话,可以使用提供的wget命令从 GNU Savannah config仓库中获取这些文件的最新版本。如果gettext在使用autoreconf安装的版本时遇到问题,它可能无法识别你的平台。确保在完成这些步骤后,再运行autoreconf -i至少一次。

在➑处要求复制和使用gettext.h是可选的,但在我看来是有帮助的,因为它启用了由gettext Autoconf 宏添加的configure脚本选项,允许用户在从分发档案构建时禁用 NLS 处理。我将/usr/share/gettext/gettext.h复制到 gt 的src目录,并将其添加到gt程序在Makefile.am中的源文件列表中,如 Listing 12-9 所示。

bin_PROGRAMS = src/gt
src_gt_SOURCES = src/gt.c src/gettext.h
--snip--

Listing 12-9: Makefile.am: 将 src/gettext.h 添加到src_gt_SOURCES

让我们在进行所有这些更改后尝试构建:

$ autoreconf -i
Copying file ABOUT-NLS
Copying file config.rpath
Creating directory m4
Copying file m4/codeset.m4
Copying file m4/extern-inline.m4
Copying file m4/fcntl-o.m4
--snip--
Copying file po/insert-header.sin
Copying file po/quot.sed
Copying file po/remove-potcdate.sin
configure.ac:12: installing './compile'
configure.ac:13: installing './config.guess'
configure.ac:13: installing './config.sub'
configure.ac:6: installing './install-sh'
configure.ac:6: installing './missing'
Makefile.am: installing './depcomp'
$
$ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... /bin/mkdir -p
--snip--
checking for locale.h... yes
checking for setlocale... yes
checking that generated files are newer than configure... done
configure: creating ./config.status
config.status: creating Makefile
config.status: creating po/Makefile.in
config.status: creating config.h
config.status: executing depfiles commands
config.status: executing po-directories commands
config.status: creating po/POTFILES
config.status: creating po/Makefile
$
$ make
make    all-recursive
make[1]: Entering directory '/.../gettext'
Making all in po
make[2]: Entering directory '/.../gettext/po'
make gt.pot-update
make[3]: Entering directory '/.../gettext/po'
--snip--
case `/usr/bin/xgettext --version | sed 1q | sed -e 's,^[⁰-9]*,,'` in \
  ‘’ | 0.[0-9] | 0.[0-9].* | 0.1[0-5] | 0.1[0-5].* | 0.16 | 0.16.[0-1]*) \
    /usr/bin/xgettext --default-domain=gt --directory=.. \
      --add-comments=TRANSLATORS: --keyword=_ --keyword=N_    \
      --files-from=./POTFILES.in \
      --copyright-holder='John Calcote' \
      --msgid-bugs-address="$msgid_bugs_address" \
    ;; \
  *) \
    /usr/bin/xgettext --default-domain=gt --directory=.. \
      --add-comments=TRANSLATORS: --keyword=_ --keyword=N_    \
      --files-from=./POTFILES.in \
      --copyright-holder='John Calcote' \
      --package-name="${package_prefix}gt" \
      --package-version='1.0' \
      --msgid-bugs-address="$msgid_bugs_address" \
    ;; \
esac
--snip--
make[3]: Leaving directory '/.../gettext/po'
test ! -f ./gt.pot || \
  test -z "" || make
touch stamp-po
make[2]: Leaving directory '/.../gettext/po'
make[2]: Entering directory '/.../gettext'
gcc -DHAVE_CONFIG_H -I.  -DLOCALE_DIR=\"/usr/local/share/locale\"    -g -O2
-MT src/src_gt-gt.o -MD -MP -MF src/.deps/src_gt-gt.Tpo -c -o src/src_gt-gt.o
`test -f 'src/gt.c' || echo './'`src/gt.c
mv -f src/.deps/src_gt-gt.Tpo src/.deps/src_gt-gt.Po
gcc   -g -O2   -o src/gt src/src_gt-gt.o
make[2]: Leaving directory '/.../gettext'
make[1]: Leaving directory '/.../gettext'
$

注意

某些极端情况可能导致由autoreconf编写的文件被视为“本地修改”,这样没有-f--force标志就会生成错误。我建议你先尝试仅使用-i。如果你遇到像ABOUT-NLS之类的文件被本地修改的错误,那么重新执行时加上-f标志。只需注意,-f会覆盖你可能故意修改的某些文件。

如你从此输出中看到的那样,xgettext会对我们的源代码运行——特别是我们在po/POTFILES.in中提到的文件——每当我们进行构建时,如果构建时缺少任何文件。如果找到文件,它不会自动重新构建,但我稍后会提到一个手动的make目标。

什么应该提交?

我们为 gt 项目添加了很多新文件。在第 172 页的《关于实用脚本的一些话》中,我给出了关于应提交到源仓库的内容的哲学观点:从仓库中检出你的项目的人应该愿意担任维护者或开发者的角色,而不是用户。用户从发行包中构建,但维护者和开发者使用的是一套不同的工具。因此,从仓库检出源代码的人应该愿意使用 Autotools。

现在是考虑哪些新文件应该提交到 gt 的仓库的时候了。按照我的哲学,我只会提交那些实际上是项目资产的文件。任何在 Autotools 引导过程中(autoreconf -i)可以轻松重新生成或从其他来源重新复制的文件应该被排除在外。

gettextize工具运行一个名为autopoint的程序,它对启用了 NLS 的项目的作用就像autoreconf -i对 Autotools 项目的作用一样,按照需要将文件复制到项目目录结构中。我们之前添加到configure.ac中的AM_GETTEXT_*宏确保了适当的.m4文件被添加到m4目录,适当的 NLS 文件被添加到po目录,且(如果你使用的是内部版本的gettext库)gettext源代码和构建文件被添加到intl目录。实际上,autopoint是短语auto-po-intl-m4的缩写。更新版本的autoreconf能够识别autopoint,并且会在你使用启用 NLS 的项目时执行它,但前提是你向autoreconf提供了-i选项,因为autopoint仅安装缺失的文件,而文件安装是-i选项的一个功能。

因为 autopoint 会将所有需要的非资产文件安装到你的 po 目录中,所以你只需要提交该目录中你修改过的文件,包括 POTFILES.inMakevarsChangeLog、^(6) 以及,当然,你的 .po 文件。你不需要提交你的 .pot 文件,因为 po/Makefile 会根据源代码重新生成它(如果它缺失的话)。你不需要提交 .mo 文件,因为这些文件会在安装时直接从 .po 文件生成。除非你修改了 ABOUT-NLS 文件,否则你不需要提交它。你不需要提交 m4 目录中的任何文件,除非是你自己编写并添加的宏文件。你需要提交 src/gettext.h 文件,因为你是手动从系统的 gettext 安装目录中复制的那个文件。^(7)

这时我们在 gt 的目录结构中剩下以下文件:

$ tree --charset=ascii
.
|-- AUTHORS
|-- ChangeLog
|-- configure.ac
|-- COPYING
|-- INSTALL
|-- Makefile.am
|-- NEWS
|-- po
|    |-- ChangeLog
|    |-- Makevars
|    `-- POTFILES.in
|-- README
`-- src
    |-- gettext.h
    `-- gt.c

2 directories, 13 files
$

添加语言

让我们添加我们的法语 .po 文件:

Git 标签 12.2

2$ cd po
$ msginit --locale fr_FR.utf8
--snip--
$ echo "
fr" >>LINGUAS
$

虽然我们运行了 msginit,但无需指定输入和输出文件。相反,msginit 会自动发现并使用当前目录中的所有 .pot 文件作为输入文件,并且会根据指定的语言自动命名输出的 .po 文件。我们唯一需要使用的选项是 --locale 选项,用于指定应该生成 .po 文件的目标语言环境。

注意

这次我没有使用 --no-translator 选项,因为当我运行 msginit 时,我是以目标语言的翻译者身份进行操作。也就是说,运行 msginit 的人应该是该语言的翻译者。因此,这个人还应该愿意提供翻译联系信息,在这种方式下运行 msginit 时,他们可以在交互提示中输入他们的电子邮件地址。

我们还需要将所有支持的语言添加到 po 目录中的名为 LINGUAS 的文件中(这个新文件也应该被提交)。这告诉构建系统要支持哪些语言。我们实际上可能在 po 目录中有比当前支持的更多语言。LINGUAS 文件中的语言是那些在运行 make install 时,将生成并安装 .mo 文件的语言。LINGUAS 的格式相当松散;你只需要在语言之间留一些空白。你也可以使用以哈希符号开头的注释,如果你需要的话。

现在你会在 po 目录中找到一个名为 fr.po 的文件。当然,这个文件仍然需要由精通两种语言的人翻译。翻译后,内容应该类似于 示例 12-10 的样子。我已经更新了我的文件,填补了所有空白,可以这么说。

# French translations for gt package.
# Copyright (C) 2019 John Calcote
# This file is distributed under the same license as the gt package.
# John Calcote <john.calcote@gmail.com>, 2019.
#
msgid ""
msgstr ""
"Project-Id-Version: gt 1.0\n"
"Report-Msgid-Bugs-To: gt-bugs@example.org\n"
"POT-Creation-Date: 2019-07-02 01:17-0600\n"
"PO-Revision-Date: 2019-07-02 01:35-0600\n"
"Last-Translator: John Calcote <john.calcote@gmail.com>\n"
"Language-Team: French\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

#: src/gt.c:21
#, c-format
msgid "Hello, world!\n"
msgstr "Bonjour le monde!\n"

示例 12-10: po/fr.po: 已翻译的法语可移植对象文件

安装语言文件

安装语言文件并不比运行带有常规 Automake 提供的 install 目标的 make 命令更难:

$ sudo make install
--snip--
Making install in po
--snip--
$

你还可以在 make 命令行中使用 DESTDIR 变量来测试你的安装在本地临时目录中的效果,以查看哪些文件被安装。当然,在执行此操作时,你不需要使用 sudo,只要你在 DESTDIR 目录中有写权限即可。

测试并不像在 src 目录中执行程序那样简单,虽然也不是非常困难。问题在于整个 Linux NLS 系统是为了与已安装的语言文件一起工作而设计的。你需要将文件安装到一个本地前缀目录,例如 $PWD/root,例如:

$ ./configure --prefix=$PWD/root
--snip--
$ make
--snip--
$ make install
--snip--
$ cd root/bin
$ ./gt
Hello, world!
$ LANGUAGE=french ./gt
Bonjour le monde!
$

为什么这样有效?因为我们将基于 prefix 的语言目录传递给 makefile 中的 gt.c,并通过 gcc 命令行传递。因此,你使用的 prefix 告诉 gt NLS 文件将被安装到哪里。

注意

不要在 DESTDIR 变量上尝试此操作。prefix* 仍然会设置为 /usr/local,但 install 目标会将所有内容放入 $(DESTDIR)/$(prefix) 中。语言目录仅基于 prefix,这会欺骗已构建的软件,使其认为正在安装到 $(prefix) 中,同时允许打包者将安装过程本地化。*

手动 make 目标

gettext makefile 提供了一些目标,可以从 po 目录手动使用(实际上,它们只在 po 目录下有效)。如果你想手动更新某个 .pot 文件,可以运行 make domain.update-pot,其中 domain 是你在源代码中调用 textdomainbindtextdomain 时指定的 NLS 域名。

如果你想使用 msgmerge 更新翻译后的语言文件,该命令会将来自 .pot 文件的新消息合并到特定语言的 .po 文件中,你可以运行 make update-po。这将更新所有在 LINGUAS 中指定的语言的 .po 文件。

请注意,.mo 文件不是在构建时创建的,而是在安装时创建的。原因是,在安装之前它们是没有用的。如果你真的需要在不安装包的情况下获取 .mo 文件,你可以将其安装到本地前缀目录或 DESTDIR 临时目录,就像之前所描述的那样。

总结

在本章中,我只是浅尝辄止地介绍了如何为项目添加 NLS 支持这一主题。

我跳过了什么?例如,gettext 工具中有很多选项可以帮助本地化人员为程序构建语言文件,从而使软件能够合理地显示消息。

另外一个例子是,在 C 语言的典型printf语句中,你可能会提供一个英语格式字符串,如 "There are %d files in the '%s' directory." 在这个例子中,%d%s是用来替代计数和目录名的占位符,但在德语中,翻译后的字符串会变成类似于 "Im verzeichnis '%s' befinden sich %d dateien." 甚至一个不懂德语的程序员也能看出问题所在——格式说明符的顺序发生了变化。当然,一种解决方案是使用printf更新后的位置格式说明符。

还有许多其他问题你需要考虑;GNU gettext 工具手册是一个很好的起点。

第十三章:使用 Gnulib 达到最大可移植性

没有任何创作是由两个人完成的。在艺术、音乐、诗歌、数学、哲学中没有良好的合作。创作的奇迹一旦发生,群体可以建设和扩展它,但群体永远不会发明任何东西。

——约翰·斯坦贝克,《伊甸园之东》

Image

你知道那些你过去十年左右一直在使用的酷炫脚本语言吗——Python、PHP、Perl、JavaScript、Ruby 等等?这些语言的最酷功能之一,甚至一些像 Java 这样的编译语言,也具备通过 pip 和 maven 等工具访问社区提供的库功能的能力,来自 PEAR、RubyGems、CPAN 和 Maven Central 等仓库。

难道你不希望能在 C 和 C++ 中做类似的事情吗?你可以在 C 中体验这种感觉,使用 GNU 可移植性库 (Gnulib)^(1),以及它的伴随命令行工具 gnulib-tool。Gnulib 是一个旨在广泛可移植的源代码库,甚至可以移植到像 Windows 这样的平台,使用本地编译和基于 Cygwin 的编译方式(尽管 Gnulib 在 Cygwin 上的测试稍多于在本地 Windows 构建中的测试)。

Gnulib 中有数百个便携的工具函数,它们的设计目标只有一个——便于移植到许多不同的平台。本章将介绍如何开始使用 Gnulib,并如何充分发挥它的优势。

许可证警告

在我继续之前,我应该提到,Gnulib 的大部分源代码是根据 GPLv3+ 或 LGPLv3+ 许可证发布的。然而,一些 Gnulib 的源代码是根据 LGPLv2+ 许可证发布的,这可能会使得相关功能稍微更易接受。可以合理用于库中的 Gnulib 函数是根据 LGPLv2+ 或 LGPLv3+ 许可证发布的;其他的则是根据 GPLv3+ 许可证发布,或者是某种混合的“LGPLv3+ 和 GPLv2”许可(从最终的兼容性上看,它与 GPLv2 比与 LGPLv2 更兼容)。如果这让你感到困扰,你可以跳过这一章,但在完全放弃 Gnulib 之前,考虑检查你希望使用的功能的许可证,看看你的项目是否能够适应它。

由于 Gnulib 是以源代码格式分发的,并且设计上是要以这种格式纳入应用程序和库中,使用 Gnulib 就意味着将 GPL 和 LGPL 源代码直接纳入到你的源代码库中。至少,这意味着你需要使用 GPL 和 LGPL 许可证对部分代码进行授权。这也许能解释为什么 Gnulib 并不是特别流行,除了其他 GNU 软件包的维护者。

另一方面,如果你正在编写一个已经根据 GPL 许可证发布的开源程序,或者一个已经使用 LGPL 的开源库,那么你的项目非常适合使用 Gnulib。继续阅读。

开始使用

如前所述,Gnulib 以源代码格式发布。虽然你可以随时访问 Savannah git 仓库,在线浏览并下载单个文件,但更简单的方法是将 Gnulib 仓库克隆到本地工作区。Gnulib 仓库在根目录中提供了gnulib-tool工具,你可以用它将所需的源模块、伴随的 Autoconf 宏和构建脚本直接复制到你的项目中。

gnulib-tool工具可以直接在仓库的根目录下运行。为了方便访问,可以在你的PATH中某个位置创建到这个程序的软链接;这样你就可以在项目目录中运行gnulib-tool,将 Gnulib 模块添加到基于 Autotools 的项目中:

$ git clone https://git.savannah.gnu.org/git/gnulib.git
--snip--
$ ln -s $PWD/gnulib/gnulib-tool $HOME/bin/gnulib-tool
$

这就是使 Gnulib 在你的系统上以最有效的方式可用所需的一切。

注意

上游的 Gnulib 项目不做发布,而是直接将更改和修复直接合并到主分支中。本章中的编程示例是使用 Savannah Gnulib git 仓库中提交号为 f876e0946c730fbd7848cf185fc0dcc712e13e69 的 Gnulib 源代码编写的。如果你在构建本章代码时遇到问题,可能是因为自本书编写以来,Gnulib 源代码有所变化。尝试退回到这个 Gnulib 提交版本。

将 Gnulib 模块添加到项目

为了帮助你理解如何使用 Gnulib,让我们创建一个有实际用途的项目。我们将编写一个程序,用于将数据转换为 base64 字符串并反向转换,这在今天被广泛使用,而 Gnulib 提供了一个可移植的 base64 转换功能库。我们将从创建一个仅包含main函数的小程序开始,这个程序将作为驱动程序,稍后我们将添加 Gnulib 的 base64 转换功能。

注意

该项目的源代码在 NSP-Autotools GitHub 仓库中的 b64 目录,地址为 github.com/NSP-Autotools/b64/

Git 标签:13.0

$ mkdir -p b64/src
$ cd b64
$

编辑src/b64.c并添加 Listing 13-1 中显示的内容。

#include "config.h"
#include <stdio.h>

int main(void)
{
    printf("b64 - convert data to and from base64 strings.\n");
    return 0;
}

Listing 13-1:src/b64.c:驱动程序主源文件的初始内容

现在让我们运行autoscan以提供一个基础的configure.ac文件,将新的configure.scan文件重命名为configure.ac,然后为我们的项目创建一个Makefile.am文件。请注意,我在这里创建的是一个非递归的 Automake 项目,将单个源文件src/b64.c直接添加到顶层Makefile.am文件中。

由于我们没有创建“外部”项目,因此我们还需要添加标准的 GNU 文本文件(但如果你希望,可以在configure.ac中的AM_INIT_AUTOMAKE宏参数列表中添加foreign,以避免做这些修改):

$ autoscan
$ mv configure.scan configure.ac
$ echo "bin_PROGRAMS = src/b64
src_b64_SOURCES = src/b64.c" >Makefile.am
$ touch NEWS README AUTHORS ChangeLog
$

编辑新的configure.ac文件,并按照 Listing 13-2 中的更改进行修改。

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.69])
AC_INIT([b64], [1.0], [b 64-bugs@example.org])
AM_INIT_AUTOMAKE([subdir-objects])
AC_CONFIG_SRCDIR([src/b64.c])
AC_CONFIG_HEADERS([config.h])
AC_CONFIG_MACRO_DIRS([m4])
--snip--
AC_CONFIG_FILES([Makefile])

AC_OUTPUT

Listing 13-2:configure.ac:autoscan生成的 configure.ac 文件需要的更改

我已经在 AM_INIT_AUTOMAKE 宏中添加了 subdir-objects 选项,以创建一个非递归的 Automake 构建系统。我还添加了 AC_CONFIG_MACRO_DIRS 宏以保持系统的清晰。^(2)

此时,我们应该能够运行 autoreconf -i,然后执行 configuremake,以构建项目:

$ autoreconf -i
aclocal: warning: couldn't open directory 'm4': No such file or directory
configure.ac:12: installing './compile'
configure.ac:6: installing './install-sh'
configure.ac:6: installing './missing'
Makefile.am: installing './INSTALL'
Makefile.am: installing './COPYING' using GNU General Public License v3 file
Makefile.am:     Consider adding the COPYING file to the version control
system
Makefile.am:     for your code, to avoid questions about which license your
project uses
Makefile.am: installing './depcomp'
$
$ ./configure && make
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... /bin/mkdir -p
--snip--
config.status: creating Makefile
config.status: creating config.h
config.status: executing depfiles commands
make  all-am
make[1]: Entering directory '/.../b64'
depbase=`echo src/b64.o | sed 's|[^/]*$|.deps/&|;s|\.o$||'`;\
gcc -DHAVE_CONFIG_H -I.         -g -O2 -MT src/b64.o -MD -MP -MF $depbase.Tpo -c
-o src/b64.o src/b64.c &&\
mv -f $depbase.Tpo $depbase.Po
gcc    -g -O2 -o src/b64 src/b64.o
make[1]:   Leaving directory '/.../b64'
$
$ src/b64
$ b64 - convert data to and from base64 strings.
$

现在我们可以开始将 Gnulib 功能添加到该项目中。我们需要做的第一件事是使用gnulib-tool将 base64 模块导入到我们的项目中。假设你已经正确克隆了 Gnulib git 项目,并将 gnulib-tool 的软链接添加到你的 PATH 中的某个目录(如果该目录在你的 PATH 中,可以是 $HOME/bin),请从 b64 项目目录结构的根目录执行以下命令:

Git 标签 13.1

$ gnulib-tool --import base64
Module list with included dependencies (indented):
    absolute-header
  base64
    extensions
    extern-inline
    include_next
    memchr
    snippet/arg-nonnull
    snippet/c++defs
    snippet/warn-on-use
    stdbool
    stddef
    string
File list:
  lib/arg-nonnull.h
  lib/base64.c
  lib/base64.h
  --snip--
  m4/string_h.m4
  m4/warn-on-use.m4
  m4/wchar_t.m4
Creating directory ./lib
Creating directory ./m4
Copying file lib/arg-nonnull.h
Copying file lib/base64.c
Copying file lib/base64.h
--snip--
Copying file m4/string_h.m4
Copying file m4/warn-on-use.m4
Copying file m4/wchar_t.m4
Creating lib/Makefile.am
Creating m4/gnulib-cache.m4
Creating m4/gnulib-comp.m4
Creating ./lib/.gitignore
Creating ./m4/.gitignore
Finished.

You may need to add #include directives for the following .h files.
  #include "base64.h"

Don't forget to
  - add "lib/Makefile" to AC_CONFIG_FILES in ./configure.ac,
  - mention "lib" in SUBDIRS in Makefile.am,
  - mention "-I m4" in ACLOCAL_AMFLAGS in Makefile.am,
  - mention "m4/gnulib-cache.m4" in EXTRA_DIST in Makefile.am,
  - invoke gl_EARLY in ./configure.ac, right after AC_PROG_CC,
  - invoke gl_INIT in ./configure.ac.
$

在这个控制台示例中省略的列表,当使用一个有很多依赖于其他 Gnulib 模块的模块时,可能会变得相当长。base64 模块仅直接依赖于 stdboolmemchr 模块;然而,依赖关系列表显示了其他的传递性依赖关系。你可以通过检查模块在 MODULES 页面上的依赖列表,或者通过阅读你克隆的 Gnulib 仓库中的 modules/base64 文件,在决定是否使用该模块之前查看其直接依赖项。此页面可以在 gnu.org 找到。^(3)

base64 模块所需的一些传递性依赖项包括一些模块,旨在使 base64 更加可移植,适用于多种平台。例如,string 模块提供了一个包装器,用于你系统中的 string.h 头文件,提供了额外的常用字符串功能,或者修复了一些平台上的 bug。

从输出中可以看到,创建了两个目录——m4lib——然后一些支持的 M4 宏文件被添加到 m4 目录中,一些源代码和构建文件被添加到 lib 目录中。

注意

如果你在一个 git 仓库中工作,gnulib-tool* 会向* m4 lib 目录中添加 .gitignore 文件,这样当你运行类似 git add -A 的命令时,可以重新生成或重新复制的文件就不会被自动检查进去了。相反,你会看到只会添加* lib/.gitignore, m4/.gitignore m4/gnulib-cache.m4 这几个文件。所有其他文件可以在你使用所需的 Gnulib 模块配置项目后重新生成(或重新复制)。*

最后,在输出的末尾,gnulib-tool 会为你提供一些简洁的说明,告诉你如何使用你添加的 base64 模块。首先,根据这些说明,我们需要将 lib/Makefile 添加到我们在 configure.ac 中的 AC_CONFIG_FILES 列表中。稍后在同一列表中,我们会找到关于 configure.ac 更多一般修改的说明。示例 13-3 显示了我们应该根据这些说明对 configure.ac 进行的所有更改。

--snip--
# Checks for programs.
AC_PROG_CC
gl_EARLY

# Checks for libraries.

# Checks for header files.

# Initialize Gnulib.
gl_INIT

# Checks for typedefs, structures, and compiler characteristics.

# Checks for library functions.

AC_CONFIG_FILES([Makefile lib/Makefile])

AC_OUTPUT

示例 13-3:configure.ac:Gnulib 需要的更改

一些指令还指示了我们项目中顶层Makefile.am文件所需的更改。列表 13-4 突出了这些更改。

ACLOCAL_AMFLAGS = -I m4
EXTRA_DIST = m4/gnulib-cache.m4
SUBDIRS = lib

bin_PROGRAMS = src/b64
src_b64_SOURCES = src/b64.c

列表 13-4:Makefile.am:Gnulib 所需的更改

在做出这些更改之后,你的项目应该继续构建。我们需要运行autoreconf -i,以包括现在由我们添加到configure.ac中的 Gnulib 宏所要求的额外文件。

当我们导入 base64 模块时,gnulib-tool的输出指示我们可能需要添加一个base64.h的包含指令。目前,我们不需要这样的指令,因为我们的代码实际上并未使用 base64 的任何功能。我们即将进行更改,但每个模块都有自己的包含指令集,因此我接下来将展示的步骤仅与 base64 模块相关。其他模块也有类似的步骤,但会具体针对你选择使用的模块。每个模块的文档会告诉你如何访问该模块的公共接口——也就是说,应该包含哪些头文件。

虽然文档对此点的说明不是特别清晰,但实际上你不需要将任何模块特定的库链接到你的项目中,因为lib/Makefile.am文件会构建所有导入模块的源文件,并将结果对象添加到一个名为libgnu.a的静态库中。这是一个定制版的 Gnulib 库,仅包含你拉入项目中的模块。由于 Gnulib 是一个源代码库,因此不需要项目消耗 Gnulib 功能的二进制文件(除了lib目录中构建的那个)。因此,链接 Gnulib 功能的过程对于所有 Gnulib 模块都是相同的。

让我们将一些 base64 的功能添加到我们的项目中,看看实际使用该模块涉及哪些内容。根据列表 13-5 中的更改,对你的src/b64.c文件进行修改。

Git 标签 13.2

#include "config.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <errno.h>
#include <unistd.h>

#include "base64.h"

#define BUF_GROW 1024

static void exit_with_help(int status)
{
    printf("b64 – convert data TO and FROM base64 (default: TO).\n");
    printf("Usage: b64 [options]\n");
    printf("Options:\n");
    printf("  -d   base64-decode stdin to stdout.\n");
    printf("  -h   print this help screen and exit.\n");
    exit(status);
}

static char *read_input(FILE *f, size_t *psize)
{
    int c;
    size_t insize, sz = 0;
    char *bp = NULL, *cp = NULL, *ep = NULL;

    while ((c = fgetc(f)) != EOF)
    {
        if (cp >= ep)
        {
            size_t nsz = sz == 0 ? BUF_GROW : sz * 2;
            char *np = realloc(bp, nsz);
            if (np == NULL)
            {
                perror("readin realloc");
                exit(1);
            }
 cp = np + (cp - bp);
            bp = np;
            ep = np + nsz;
            sz = nsz;
        }
        *cp++ = (char) c;
    }
    *psize = cp - bp;
    return bp;
}

static int encode(FILE *f)
{
    size_t insize;
    char *outbuf, *inbuf = read_input(f, &insize);
    size_t outsize = base64_encode_alloc(inbuf, insize, &outbuf);
    if (outbuf == NULL)
    {
        if (outsize == 0 && insize != 0)
        {
            fprintf(stderr, "encode: input too long\n");
            return 1;
        }
        fprintf(stderr, "encode: allocation failure\n");
    }
    fwrite(outbuf, outsize, 1, stdout);
    free(inbuf);
    free(outbuf);
    return 0;
}

static int decode(FILE *f)
{
    size_t outsize, insize;
    char *outbuf, *inbuf = read_input(f, &insize);
    bool ok = base64_decode_alloc(inbuf, insize, &outbuf, &outsize);
    if (!ok)
    {
        fprintf(stderr, "decode: input not base64\n");
        return 1;
    }
    if (outbuf == NULL)
    {
        fprintf(stderr, "decode: allocation failure\n");
        return 1;
    }
    fwrite(outbuf, outsize, 1, stdout);
    free(inbuf);
    free(outbuf);
    return 0;
}
int main(int argc, char *argv[])
{
 int c;
    bool tob64 = true;

    while ((c = getopt(argc, argv, "dh")) != -1)
    {
        switch (c)
        {
            case 'd':
                tob64 = false;
                break;
            case 'h':
            default:                exit_with_help(c == 'h' ? 0 : 1);
        }
    }
    return tob64 ? encode(stdin) : decode(stdin);
}

列表 13-5:src/b64.c:集成 base64 功能所需的更改

我已在列表 13-5 中提供了整个文件,因为原始代码中只剩下几行。这程序旨在作为 Unix 过滤器,读取stdin中的输入数据并将输出数据写入stdout。要从文件读取和写入,只需使用命令行重定向。

我应该提到一些关于这个程序的重要事项。首先,它在read_input函数中使用了一个缓冲区增长算法。这个代码的大部分可以通过调用另一个 Gnulib 模块函数x2nrealloc来替代。在线文档对该方法的使用,甚至对它的存在,描述得很少——可能是因为 xalloc 接口已经以不同形式存在了很多年。你可以在 Gnulib 源代码的lib目录下找到xalloc.h头文件,里面有很多长注释,包含了许多函数的示例用法,包括x2nrealloc函数。

使用 xalloc 功能来满足所有内存分配需求的另一个优点是,它的分配函数会自动检查NULL返回值,并在内存分配失败时通过适当的错误信息中止程序。如果你希望对中止过程有更多控制,可以向代码中添加一个名为xalloc_die的函数(无参数,无返回值),如果它存在,xalloc 函数会调用它。你可以使用这个钩子在程序退出前执行任何必要的清理工作。为什么不让你来决定是否退出呢?你内存不足——你究竟能做什么?在今天多 TB 地址空间的世界中,这种内存不足的情况不常发生,但仍然需要进行检查。xalloc 函数使得进行这种检查变得不那么痛苦。

最后,与许多过滤器不同,如果你向这个程序输入一个包含 1GB 数据的文件,它可能会崩溃,因为它会将整个输入缓冲到一个已分配的内存块中,并在读取stdin数据时调整其大小。原因是默认的 base64 模块使用方式并不设计为处理流式数据。它要求事先准备好整个缓冲区。然而,有一个base64_encode_alloc_ctx方法,允许你以迭代方式编码输入文本的小块。我将这个任务留给你,读者,让你修改这个程序以使用这种 base64 模块的形式。

为了使这段代码正确构建,你需要按照示例 13-6 所示更改Makefile.am

ACLOCAL_AMFLAGS = -I m4
EXTRA_DIST = m4/gnulib-cache.m4
SUBDIRS = lib

bin_PROGRAMS = src/b64
src_b64_SOURCES = src/b64.c
src_b64_CPPFLAGS = -I$(top_builddir)/lib -I$(top_srcdir)/lib
src_b64_LDADD = lib/libgnu.a

示例 13-6:Makefile.am:在源代码中使用 base64 模块所需的更改

src_b64_CPPFLAGS指令将目录添加到编译器的包含搜索路径中,以便它能够找到通过选定的 Gnulib 模块添加的任何头文件。src_b64_LDADD指令将lib/libgnu.a追加到链接器命令行中。这两个指令到现在为止应该已经很熟悉了。

让我们构建并运行b64程序。正如我之前提到的,首先你需要运行autoreconf -i,以应用 Gnulib 对项目所做的任何更改。

$ autoreconf -i
--snip--
$ ./configure && make
--snip--
$ echo hi | src/b64
aGkK$ echo -n aGkK | src/b64 -d
hi
$

我使用echo将一些文本通过管道传递到b64过滤器,后者输出该文本的 base64 等效:“aGkK”。注意输出的末尾没有换行符。b64过滤器只输出输入数据的 base64 文本版本。然后,我使用echo -n将 base64 文本重新传入过滤器,使用-d标志解码回原始输入数据。输出是原始文本,包括一个终止的换行符。默认情况下,echo会将换行符附加到你输入的任何文本末尾;因此,原始的编码文本包括一个终止的换行符。-n选项告诉echo抑制换行符。如果不使用-n,解码将失败,并出现错误,提示输入数据不是有效的 base64 文本,因为echo附加了一个换行符,而这不是 base64 文本的一部分。

从 Gnulib 文档中并不清楚的一点是,按照“从不提交可以轻松再生的文件或数据”的一般原则,Gnulib 的 .gitignore 文件会阻止导入的模块源代码被提交到你的仓库。这样做有几个原因。首先,Gnulib 源代码已经存在于一个仓库中——那就是 Gnulib 本身的仓库。没有必要通过将它存储在每个使用它的仓库中来使 Gnulib 源代码在互联网上大量传播。

不将其存储在你的项目仓库中的另一个原因是,用户和维护者总是提供修复补丁。每次更新你的 Gnulib 工作区并构建项目时,你可能会得到你正在使用的模块的更好版本。

假设你今天的工作已经完成,并且你想将工作区恢复到一个干净的状态。你输入git clean -xfd,然后清除所有未暂存或未提交的内容。第二天,你回来并输入autoreconf -i,接着是configure && make,但你发现项目无法构建;m4lib目录中似乎有一些重要的文件丢失了。事实上,你发现,只有m4/gnulib-cache.m4文件作为一个微妙的提醒,告诉你项目曾经与 Gnulib 有关。

实际上,那个gnulib-cache.m4文件就是你真正需要的。它告诉gnulib-tool你已经导入了哪些模块。要重新获取所有内容,只需使用--update选项执行gnulib-tool。这会让gnulib-tool将所有相关的 Gnulib 文件的当前版本重新复制到你的项目中。

注意

使用 --update 选项与 gnulib-tool 并不会从远程仓库更新你的 Gnulib 工作区。相反,它仅仅更新你项目中使用的 Gnulib 模块,并用当前存在于 Gnulib 工作区中的文件替换这些模块。如果你真的想使用某个过去版本的 Gnulib 模块,你可以从过去检出一个 Gnulib 仓库的修订版本,然后运行 gnulib-tool --update 来从 Gnulib 工作区中拉取当前的文件集。

--update 选项也可以在你使用 git 更新了 Gnulib 工作区后,用来复制更新后的文件版本。

为了帮助你记住在使用 Gnulib 的项目中使用 gnulib-tool --update,Gnulib 手册建议你创建一个 bootstrap.sh 脚本(并标记为可执行),脚本中至少包含 列表 13-7 中显示的行。

Git 标签 13.3

#!/bin/sh
gnulib-tool --update
autoreconf -i

列表 13-7bootstrap.shb64 的项目引导脚本

如果 autoreconf 足够智能,能够注意到你使用了 Gnulib 模块并自动为你调用 gnulib-tool --update,那该有多好。我怀疑这是 Autoconf 未来版本中的一项功能。当前而言,然而,你需要记得在将项目仓库克隆到新的工作区或在你要求 git 将当前工作区恢复为干净状态之后,手动运行此命令来拉取 Gnulib 文件。

摘要

在本章中,我讨论了如何将 Gnulib 模块添加到基于 Autotools 的项目中。我相信我已经给了你足够的 Gnulib 资源,让你对它产生兴趣。只要你掌握了基础,Gnulib 手册写得很好,容易理解(虽然文档不是特别全面)。

下一步是你去 Gnulib 模块页面,浏览可用的功能。模块的头文件和源代码也可以从该页面以及仓库中的 moduleslib 目录中查看。随时可以查看它们。

维护者总是可以在文档方面获得帮助。 一旦你使用了一个模块并且变得熟悉它,看看它的文档是否需要更新,并考虑成为贡献者。你可以使用 Gnulib 邮件列表^(5)作为资源,无论是关于使用 Gnulib 的问题,还是文档和源代码的补丁^(6)。

第十四章:FLAIM:一个 AUTOTOOLS 示例

阿布内尔叔叔说……一个人开始提着猫的尾巴回家,是在获得一种永远有用的知识。

—马克·吐温,《汤姆·索亚在海外》

Image

到目前为止,在本书中,我已经带你快速浏览了 Autoconf、Automake 和 Libtool 的主要特性,以及与 Autotools 配合得好的其他工具。我尽力以一种既简洁易懂又便于记忆的方式进行解释——特别是如果你有时间和兴趣跟随我提供的示例进行实践。我始终认为,没有什么学习方式能比在实践中获得的学习更有效。

在本章及下一章中,我们将继续通过研究我用来将一个现有的、真实的开源项目从一个复杂的手写 makefile 转换为一个完整的 GNU Autotools 构建系统的过程,来学习更多关于 Autotools 的内容。我在这些章节中提供的示例说明了在转换过程中我做出的决策以及一些 Autotools 特性的具体应用,包括一些我在之前的章节中尚未展示的特性。这两章将通过展示解决实际问题的真实方案,完成我们对 Autotools 的学习。

我选择转换的项目叫做FLAIM,它代表着灵活适应性信息管理

什么是 FLAIM?

FLAIM 是一个高可扩展的数据库管理库,使用 C++编写,并建立在名为 FLAIM 工具包的自身轻量级可移植性层之上。有些读者可能会认识到 FLAIM 是 Novell^(1) eDirectory 和 Novell GroupWise 服务器使用的数据库。FLAIM 起源于 1980 年代末的 WordPerfect,并在 1994 年 Novell 和 WordPerfect 合并时成为 Novell 软件组合的一部分。Novell eDirectory 使用 FLAIM 的一个衍生版本来管理包含超过十亿个对象的目录信息库,GroupWise 则使用一个更早的衍生版本来管理各种服务器端数据库。

Novell 在 2006 年将 FLAIM 源代码作为开源项目发布,并采用 GNU 较宽松公共许可证(LGPL)版本 2^(2)。FLAIM 项目目前托管在SourceForge.net上,经过了 25 年的开发和在各种 WordPerfect 和 Novell 产品及项目中的巩固与完善。^(3)

为什么选择 FLAIM?

虽然 FLAIM 远不是一个主流的开源软件项目,但它具有若干特质,使其成为展示如何将项目转换为使用 Autotools 的完美示例。首先,FLAIM 当前是使用一个手写的 GNU Makefile 构建的,该 Makefile 包含超过 2000 行复杂的 make 脚本。FLAIM 的 Makefile 包含许多 GNU Make 特有的构造,因此只能使用 GNU Make 来处理这个 Makefile。多个(但几乎相同的)Makefile 被用来构建 flaimxflaimflaimsql 数据库库,以及 FLAIM 工具包(ftk),并在 Linux、各种 Unix 版本、Windows 和 NetWare 上构建几个实用程序和示例程序。

现有的 FLAIM 构建系统支持多种不同版本的 Unix 操作系统,包括 AIX、Solaris 和 HP-UX,以及 Apple 的 macOS。它还支持这些系统上的多种编译器。这些特性使得 FLAIM 成为该示例转换项目的理想选择,因为我可以向你展示如何在新的 configure.ac 文件中处理操作系统和工具集的差异。

现有的构建系统还包含许多标准 Automake 目标的规则,例如分发 tar 包。此外,它还提供了构建二进制安装包的规则,以及为能够构建和安装 RPM 包的系统提供的 RPM 规则。它甚至提供了构建 Doxygen^(4) 描述文件的目标,之后用于生成源代码文档。我将用几段话向你展示如何将这些类型的目标添加到 Automake 提供的基础设施中。

FLAIM 工具包是一个可移植性库,第三方项目可以独立地将其集成并使用。我们可以使用该工具包展示 Autoconf 如何将独立的子项目作为可选子目录管理在一个项目中。如果用户的构建机器上已经安装了 FLAIM 工具包,他们可以使用已安装的版本,或者选择覆盖为本地副本。另一方面,如果未安装工具包,则默认使用本地子目录版的工具包。

FLAIM 项目还提供了用于构建 Java 和 C# 语言绑定的代码,因此我将稍微探讨这些晦涩的领域。我不会深入讲解如何构建 Java 或 C# 应用程序,但我会介绍如何编写生成 Java 和 C# 程序及语言绑定库的 Makefile.am 文件。

FLAIM 项目很好地利用了单元测试。这些测试作为独立程序构建,可以在没有命令行选项的情况下运行,因此我可以轻松向你展示如何使用 Automake 的简单测试框架将实际的单元测试添加到新的 FLAIM Autotools 构建系统中。

FLAIM 项目及其原始构建系统采用了相当模块化的目录布局,使其转换为 Autotools 模块化构建系统变得相当简单。对目录树进行一次简单的 diff 工具比较就足够了。

后勤

当本书的第一版在 2010 年发布时,FLAIM 刚刚作为一个开源项目在SourceForge.net上发布,并使用 Subversion 管理其源代码库。从那时起,FLAIM 项目基本上变得不活跃。我所知道的没有人正在积极使用该代码库。由于我是唯一剩下的源代码维护者,我为 FLAIM 创建了一个 GitHub 仓库,专门用于本书第二版的第十四章和第十五章。您可以在 GitHub 的 FLAIM 项目下的 NSP-Autotools 区域找到这个仓库。^(5) 我已经更新了本章中的信息,以便与 FLAIM 在 git 仓库中的存储方式相关。

本章的源代码库与前几章的源代码库风格略有不同。我对 FLAIM SourceForge.net 项目所做的原始 Autotools 构建系统更改被埋藏在几十个无关的更改之下,并与之交织在一起。与其花费数小时将这些更改分离开来,以便为您提供 FLAIM 代码库的前后快照,不如直接选择将最终的 FLAIM 代码和其 Autotools 构建系统提交到 GitHub 项目中。^(6)

不要对 FLAIM 当前的活动状态感到灰心——它仍然为我们提供了在现实世界项目中学习 Autotools 构建系统技术的各种机会。

初步观察

首先,我要说明将 FLAIM 从 GNU makefile 转换为 Autotools 构建系统并不是一个简单的项目。这花费了我几周的时间,其中大部分时间都用来确定具体需要构建什么以及如何构建——换句话说,就是分析遗留的构建系统。我花费的另一大部分时间则是在转换那些位于 Autotools 功能边缘的方面。例如,我花了更多时间来转换用于构建 C#语言绑定的构建系统规则,而不是转换用于构建核心 C++库的规则。

这个转换项目的第一步是分析 FLAIM 现有的目录结构和构建系统。哪些组件实际上被构建,哪些组件依赖于其他组件?是否可以单独构建、分发和消费各个组件?这些组件级别的关系非常重要,因为它们通常决定了你如何布局项目的目录结构。

FLAIM 项目实际上是在其代码库中由多个小项目组成的一个大项目。这里有三个独立且不同的数据库产品:flaimxflaimflaimsql。flaim 子项目是最初的 FLAIM 数据库库,用于 eDirectory 和 GroupWise。xflaim 项目是为 Novell 内部项目开发的分层 XML 数据库;它针对基于路径的节点访问进行了优化。flaimsql 项目是 FLAIM 数据库之上的 SQL 层。它被写作一个独立的库,目的是优化 FLAIM 的底层 API 以支持 SQL 访问。这个项目是一个实验,坦白说,它还没有完全完成(但它可以编译)。

关键在于,这三种数据库库彼此独立且无关,没有相互依赖。由于它们可以彼此独立使用,因此实际上可以作为独立的发行版进行发布。你可以将它们看作是各自独立的开源项目。那么,这将成为我的主要目标之一:允许 FLAIM 开源项目轻松拆分为多个可以独立管理的开源项目。

FLAIM 工具包也是一个独立的项目。尽管它专门为 FLAIM 数据库库量身定制,仅提供数据库管理系统所需的系统服务抽象,但它完全依赖于自身,因此可以轻松地作为其他项目中可移植性的基础,而不带任何不必要的数据库负担。^(7)

原始 FLAIM 项目在其代码库中的布局如下:

$ tree -d --charset=ascii FLAIM
FLAIM
|-- flaim
|   |-- debian
|   |-- docs
|   |-- sample
|   |-- src
|   `-- util
|-- ftk
|   |-- debian
|   |-- src
|   `-- util
|-- sql
|   `-- src
--snip--
`-- xflaim
    |-- csharp
    --snip--
    |-- java
    --snip--
    |-- sample
    --snip--
    |-- src
    `-- util
--snip--

整个目录结构相当广泛,并且在某些地方稍显深入,包括由传统构建系统构建的显著实用程序、测试以及其他此类二进制文件。在深入到这个层级结构的过程中,我不得不停下来思考是否值得转换那个额外的实用程序或层。(如果我没有这么做,本章的长度会翻倍,实用性却会减半。)为此,我决定转换以下内容:

  • 数据库库

  • 单元和库接口测试

  • 各种util目录中找到的实用程序和其他此类高级程序

  • xflaim 库中找到的 Java 和 C# 语言绑定

我还会转换 C# 单元测试,但不会涉及 Java 单元测试,因为我已经在使用 Automake 的 JAVA 主文件转换 Java 语言绑定。由于 Automake 对 C# 没有支持,我不得不自己提供一切,因此我将转换整个 C# 代码库。这将提供一个编写完全不受支持的 Automake 产品类代码的示例。

入门

如前所述,我的第一个真正的设计决策是如何将原始的 FLAIM 项目组织成子项目。结果表明,现有的目录布局几乎是完美的。我在顶层的flaim目录中创建了一个主configure.ac文件,该目录就在版本库的根目录下。这个最上层的configure.ac文件充当每个四个下级项目(ftk、flaim、flaimsql 和 xflaim)的 Autoconf 控制文件。

我通过将工具包视为一个纯外部依赖项来管理 FLAIM 工具包的数据库库依赖关系,该依赖项由make变量FTKINCFTKLIB定义。我有条件地定义了这些变量,以指向多个不同的来源,包括已安装的库,甚至是用户指定的配置脚本选项中的位置。

添加 configure.ac 文件

在以下的目录布局中,我使用了注释列来指示每个configure.ac文件的位置。这些文件代表一个可能被打包并独立分发的项目。

$ tree -d --charset=ascii FLAIM
FLAIM                           configure.ac (flaim-projects)
|-- flaim                       configure.ac (flaim)
|   |-- debian
|   |-- docs
|   |-- sample
|   |-- src
|   `-- util
|-- ftk                         configure.ac (ftk)
|   |-- debian
|   |-- src
|   `-- util
|-- sql                         configure.ac (flaimsql)
|   `-- src
--snip--
`-- xflaim                      configure.ac (xflaim)
    |-- csharp
    --snip--
    |-- java
    --snip--
    |-- sample
    --snip--
    |-- src
    `-- util
--snip--

我的下一个任务是创建这些configure.ac文件。顶层文件非常简单,因此我手动创建了它。与项目相关的文件更复杂,因此我让autoscan工具为我完成大部分工作。Listing 14-1 展示了顶层的configure.ac文件。

   #                                               -*- Autoconf -*-
   # Process this file with autoconf to produce a configure script.

   AC_PREREQ([2.69])
➊ AC_INIT([flaim-projects], [1.0])
➋ AM_INIT_AUTOMAKE([-Wall -Werror foreign])
➌ AM_PROG_AR
➍ LT_PREREQ([2.4])
   LT_INIT([dlopen])

➎ AC_CONFIG_MACRO_DIRS([m4])
➏ AC_CONFIG_SUBDIRS([ftk flaim sql xflaim])
   AC_CONFIG_FILES([Makefile])
   AC_OUTPUT

Listing 14-1: configure.ac: Umbrella 项目的 Autoconf 输入文件

这个configure.ac文件简短而简单,因为它没有做太多工作;尽管如此,这里有一些新的重要概念。我在➊处发明了flaim-projects这个名称和版本号1.0。除非项目目录结构发生重大变化,或者维护者决定发布一个包含所有子项目的完整捆绑包,否则这些内容不太可能改变。

注意

对于你自己的项目,考虑使用AC_INIT宏的可选第三个参数。你可以在此处添加电子邮件或网址,指示用户可以在哪里提交 bug 报告。此参数的内容会显示在configure输出中。

像这样的 Umbrella 项目中最重要的方面是➏处的AC_CONFIG_SUBDIRS宏,这是本书中我尚未介绍的。该参数是一个以空格分隔的子项目列表,每个子项目都是一个完全符合GCS标准的独立项目。以下是该宏的原型:

AC_CONFIG_SUBDIRS(dir1[ dir2 ... dirN])

它允许维护者以与 Automake SUBDIRS 配置单个项目中的目录层次结构类似的方式设置项目层次结构。

因为这四个子项目包含所有实际的构建功能,所以这个configure.ac文件只是充当一个控制文件,将所有指定的配置选项传递给宏参数中给出的顺序中的每个子项目。必须首先构建 FLAIM 工具包项目,因为其他项目依赖于它。

Umbrella 项目中的 Automake

Automake 通常要求在顶层项目目录中存在几个文本文件,包括AUTHORSCOPYINGINSTALLNEWSREADMEChangeLog文件。最好在总项目中不必处理这些文件。实现这一目标的一种方法是干脆不在总项目中使用 Automake。我要么得为这个目录编写自己的Makefile.in模板,要么只用一次 Automake 来生成一个Makefile.in模板,然后将其与automake --add-missing(或autoreconf -i)添加的install-shmissing脚本一起提交到仓库中,作为项目的一部分。一旦这些文件到位,我就可以从主configure.ac文件中删除AM_INIT_AUTOMAKE

另一种选择是保留 Automake,并在AM_INIT_AUTOMAKE的宏可选参数中使用foreign选项(这是我在 ➋ 所做的)。这个参数包含一串以空格分隔的选项,告诉 Automake 如何替代特定的 Automake 命令行选项。当automake解析configure.ac文件时,它会记录下这些选项并启用它们,就像它们是从命令行传递的一样。foreign选项告诉 Automake,该项目不会完全遵循 GNU 标准,因此 Automake 不会要求常见的 GNU 项目文本文件。

我选择了两种方法中的后者,因为我可能会在某个时候想修改下属项目的列表,而不希望每次都手动调整生成的Makefile.in模板。我还在这个列表中传递了-Wall-Werror选项,这表明 Automake 应该启用所有 Automake 特有的警告并将其报告为错误。这些选项与用户的编译环境无关——仅与 Automake 处理相关。

为什么要添加 Libtool 宏?

为什么在 ➍ 包含这些昂贵的 Libtool 宏?好吧,尽管我在总项目中没有使用 Libtool,但低级项目期望包含项目提供所有必要的脚本,而LT_INIT宏提供了ltmain.sh脚本。如果你在总项目中没有初始化 Libtool,像autoreconf这样的工具(它实际上会在目录中查找,来判断当前项目是否为子项目)会因为找不到当前项目的configure.ac文件所需要的脚本而失败。

例如,autoreconf期望在 ftk 项目的顶级目录中找到名为../ltmain.sh的文件。注意这里对父目录的引用:autoreconf通过检查父目录发现,ftk 实际上是一个更大项目的子项目。为了避免多次安装所有辅助脚本,Autotools 生成代码,查找项目父目录中的脚本。这是为了减少将这些脚本安装到多项目包中的副本数量。^(8) 如果我在主项目中不使用LT_INIT,则无法在子项目中成功运行autoreconf,因为ltmain.sh脚本将不会出现在项目的父目录中。

添加宏子目录

➎处的AC_CONFIG_MACRO_DIRS宏表示一个子目录的名称,aclocal工具可以在该目录中找到所有特定于项目的 M4 宏文件。以下是原型:

AC_CONFIG_MACRO_DIRS(macro-dir)

本目录中的.m4宏文件最终通过m4_include语句引用,在aclocal生成的aclocal.m4文件中,autoconf会读取该文件。这个宏用一个包含单独宏或较小宏集合的目录替换了原来的acinclude.m4文件,每个宏都定义在各自的.m4文件中。^(9)

我通过AC_CONFIG_MACRO_DIRS参数指明,所有要添加到aclocal.m4中的本地宏文件都位于一个名为m4的子目录中。作为附带功能,当执行autoreconf -i时,然后执行带有各自add-missing选项的必要 Autotools 工具时,这些工具会注意到configure.ac中使用了此宏,并将任何缺少的系统宏文件添加到m4目录中。

我选择在这里使用AC_CONFIG_MACRO_DIRS的原因是,如果没有以这种方式启用宏目录选项,Libtool 将不会将其附加的宏文件添加到项目中。相反,它会抱怨应该将这些文件添加到acinclude.m4中。^(10)

由于这是一个相对复杂的项目,我希望 Autotools 为我完成这项工作,因此决定使用这个宏目录功能。未来的 Autotools 版本可能会要求使用这种形式,因为它被认为是将宏文件添加到aclocal.m4的更现代的方式,而不是使用单一的用户生成的acinclude.m4文件。

关于这个宏的最后一个思考:如果你在 Autoconf 手册中查找它,你是找不到的——至少目前找不到,因为它不是一个 Autoconf 宏,而是一个 Automake 宏。它的前缀是 AC_,因为最初的设计目标是未来某个版本的 Autoconf 会接管这个宏。它比它的单一前身功能更强大,后者在 Autoconf 手册中有文档,但在 Automake 出现之前,这个功能并不需要。事实上,我有相当可靠的消息来源(预发布的 Autoconf ChangeLog),表示当 Autoconf 2.70 发布时,这个宏的所有权将会转移。

我们在这里尚未讨论的一个项目是 ➌ 位置的 AM_PROG_AR 宏。这是一个较新的 Automake 宏。本书的第一版没有使用它。当我更新 Autotools 时,突然 autoreconf 报告需要它,所以我加上了它,问题就解决了。Autoconf 手册简单地说明,如果你想使用具有特殊接口(如 Microsoft lib)的归档器(ar),你需要它。事实上,真正发出抱怨的是 Libtool,它似乎习惯性地抱怨没有包含它认为你应该使用的其他 Autotools 特性。我添加了它以消除警告。

顶层 Makefile.am 文件

关于总项目的唯一其他要点是顶层 Makefile.am 文件,如清单 14-2 所示。

➊ ACLOCAL_AMFLAGS = -I m4

➋ EXTRA_DIST = README.W32 tools win32

➌ SUBDIRS = ftk flaim sql xflaim

➍ rpms srcrpm:
          for dir in $(SUBDIRS); do \
            (cd $$dir && $(MAKE) $(AM_MAKEFLAGS) $@) || exit 1; \
          done
  .PHONY: rpms srcrpm

清单 14-2: Makefile.am: 总项目 Automake 输入文件

根据 Automake 文档,在 ➊ 位置定义的 ACLOCAL_AMFLAGS 变量应该在任何使用 AC_CONFIG_MACRO_DIR(单数)作为 configure.ac 文件中的配置项的项目的顶层 Makefile.am 文件中定义。此行指定的标志告诉 aclocal 在执行时应该在哪里查找宏文件,这些规则是在 Makefile.am 中定义的。此选项的格式类似于 C 编译器命令行的 -I 指令;你也可以指定其他 aclocal 命令行选项。

当使用旧版 AC_CONFIG_MACRO_DIR 时,此变量曾经是必需的,但随着新版 AC_CONFIG_MACRO_DIRS 的出现,你不再需要此变量,因为它生成的代码使 Automake 能够理解应该传递给 aclocal 的选项。不幸的是,当 Libtool 看到你在 Makefile.am 文件中使用宏目录而没有这个变量时,它还是会在 autoreconf 时发出警告。我希望当 Autoconf 接管了这个新宏(当然,还需要 Libtool 的后续版本发布)时,这个噪音会消失。

Autotools 在两个不相关的地方使用这个变量。第一个是在生成的 make 规则中,用于根据各种输入源更新 aclocal.m4 文件。此规则及其支持的变量定义见于清单 14-3,这是从 Autotools 生成的 Makefile 中复制的代码片段。

ACLOCAL_M4 = $(top_srcdir)/aclocal.m4
ACLOCAL=${SHELL} .../flaim-ch8-10/missing --run aclocal-1.10
ACLOCAL_AMFLAGS = -I m4
$(ACLOCAL_M4): $(am__aclocal_m4_deps)
        cd $(srcdir) && $(ACLOCAL) $(ACLOCAL_AMFLAGS)

清单 14-3:用于更新 aclocal.m4 make 规则和所使用的变量,它们来自各种依赖项

ACLOCAL_AMFLAGS 定义也在执行 autoreconf 时使用,autoreconf 会扫描顶级 Makefile.am 文件中的此定义,并直接将该值传递给命令行中的 aclocal。请注意,autoreconf 不会对这个字符串进行变量扩展,因此如果你在文本中添加了 shell 或 make 变量引用,它们在 autoreconf 执行 aclocal 时将不会被扩展。

返回到清单 14-2,我在 ➋ 使用了 EXTRA_DIST 变量,以确保几个额外的顶级文件能够被分发——这些文件和目录是特定于 Windows 构建系统的。对于总体项目来说,这并不是至关重要的,因为我不打算在这个层次上创建分发包,但我喜欢做到完整。

➌ 处的 SUBDIRS 变量重复了 configure.ac 文件中 AC_CONFIG_SUBDIRS 宏的信息。我尝试创建一个 shell 替代变量,并用 AC_SUBST 导出它,但没有成功——当我运行 autoreconf 时,出现了一个错误,提示我应该在 AC_CONFIG_SUBDIRS 宏参数中使用字面量。

rpmssrcrpm 目标在 ➍ 处允许最终用户为基于 RPM 的 Linux 系统构建 RPM 包。这个规则中的 shell 命令只是将用户指定的目标和变量依次传递给每个低级项目,就像我们在第三章、第四章和第五章中用手写的 makefile 和 Makefile.in 模板所做的那样。

在以这种方式将控制权传递给低级 makefile 时,你应该努力遵循这个模式。传递 AM_MAKEFLAGS 的扩展使得低级 makefile 能够访问当前或父级 makefile 中定义的相同 make 标志。然而,你可以为这种递归的 make 代码添加更多功能。要查看 Automake 如何将控制权传递给低级 makefile 以处理它自己的目标,可以打开一个 Automake 生成的 Makefile.in 模板,并搜索文本 "$(am__recursive_targets):"。该目标下的代码准确地显示了 Automake 是如何做的。虽然初看起来很复杂,但这段代码实际上只执行了两项额外的任务。首先,它确保 make -k 的继续错误功能能够正常工作。其次,它确保如果 SUBDIRS 变量中包含当前目录(.),则会正确处理。

这让我想到了关于这段代码的最后一点:如果你选择以这种方式编写自己的递归目标(稍后我们将在讨论 FLAIM 构建系统转换时看到其他示例),你应该避免在SUBDIRS变量中使用点,或者增强 shell 代码以处理这种特殊情况。如果不这样做,用户在尝试构建这些目标时,可能会陷入无休止的递归循环。有关此主题的更广泛讨论,请参见第 505 页的“条目 2:实现递归扩展目标”。

FLAIM 子项目

我使用autoscan为 ftk 项目生成了一个起点。autoscan工具在查找信息时有些挑剔。如果你的项目没有一个名为Makefile的 makefile,或者如果你的项目已经包含了一个 Autoconf 的Makefile.in模板,autoscan将不会将任何关于所需库的信息添加到configure.scan输出文件中。它无法通过任何其他方式来确定这些信息,除非查看你旧的构建系统,而它只有在条件完全合适的情况下才会这么做。

鉴于 ftk 项目遗留的 makefile 的复杂性,我对autoscan解析它以获取库信息的能力印象深刻。示例 14-4 展示了结果中configure.scan文件的一部分。

--snip--
AC_PREREQ([2.69])
AC_INIT(FULL-PACKAGE-NAME, VERSION, BUG-REPORT-ADDRESS)
AC_CONFIG_SRCDIR([src/ftktext.cpp])
AC_CONFIG_HEADERS([config.h])

# Checks for programs.
AC_PROG_CXX
AC_PROG_CC
AC_PROG_INSTALL

# Checks for libraries.
# FIXME: Replace `main' with a function in `-lc':
AC_CHECK_LIB([c], [main])
# FIXME: Replace `main' with a function in...
AC_CHECK_LIB([crypto], [main])
--snip--
AC_CONFIG_FILES([Makefile])
AC_OUTPUT

示例 14-4:在 ftk 项目目录结构上运行autoscan时输出的一部分

FLAIM 工具包 configure.ac 文件

在修改并重命名这个configure.scan文件后,结果生成的configure.ac文件包含了许多新的构造,接下来几节我将讨论这些内容。为了便于讨论,我将这个文件分成了两部分,第一部分显示在示例 14-5 中。

   #                                               -*- Autoconf -*-
   # Process this file with autoconf to produce a configure script.
   AC_PREREQ([2.69])
➊ AC_INIT([FLAIMTK],[1.2],[flaim-users@lists.sourceforge.net])
➋ AM_INIT_AUTOMAKE([-Wall -Werror])
   AM_PROG_AR
   LT_PREREQ([2.4])
   LT_INIT([dlopen])

➌ AC_LANG([C++])

➍ AC_CONFIG_MACRO_DIRS([m4])
➎ AC_CONFIG_SRCDIR([src/flaimtk.h])
   AC_CONFIG_HEADERS([config.h])

   # Checks for programs.
   AC_PROG_CXX
   AC_PROG_INSTALL

   # Checks for optional programs.
➏ FLM_PROG_TRY_DOXYGEN

   # Configure options: --enable-debug[=no].
➐ AC_ARG_ENABLE([debug],
     [AS_HELP_STRING([--enable-debug],
       [enable debug code (default is no)])],
     [debug="$withval"], [debug=no])

   # Configure option: --enable-openssl[=no].
   AC_ARG_ENABLE([openssl],
     [AS_HELP_STRING([--enable-openssl],
       [enable the use of openssl (default is no)])],
     [openssl="$withval"], [openssl=no])

   # Create Automake conditional based on the DOXYGEN variable
➑ AM_CONDITIONAL([HAVE_DOXYGEN], [test -n "$DOXYGEN"])
   #AM_COND_IF([HAVE_DOXYGEN], [AC_CONFIG_FILES([docs/doxyfile])])
➒ AS_IF([test -n "$DOXYGEN"], [AC_CONFIG_FILES([docs/doxyfile])])
--snip--

示例 14-5:ftk/configure.ac:ftk 项目的 configure.ac 文件的前半部分

在➊处,你会看到我为autoscanAC_INIT宏中留下的占位符替换了真实值。在➋处,我添加了对AM_INIT_AUTOMAKELT_PREREQLT_INIT的调用,在➍处,我添加了对AC_CONFIG_MACRO_DIRS的调用。(暂时忽略AM_PROG_AR宏——我稍后会在本章中解释它。)

注意

这次我没有在AM_INIT_AUTOMAKE中使用foreign关键字。由于这是一个真正的开源项目,FLAIM 的开发者(或者至少应该)希望拥有这些文件。我使用了touch命令来创建 GNU 项目文本文件的空版本,^(11),除了COPYINGINSTALL这两个文件是autoreconf添加的。*

新的结构出现在 ➌ 处,它是 AC_LANG 宏,表示 Autoconf 在生成 configure 中的编译测试时应使用的编程语言(从而确定编译器)。我传递了 C++ 作为参数,这样 Autoconf 就会通过 CXX 变量使用 C++ 编译器来编译这些测试,而不是通过 CC 变量使用默认的 C 编译器。然后,我删除了 AC_PROG_CC 宏调用,因为该项目的源代码完全是用 C++ 编写的。

我将 ➎ 处的 AC_CONFIG_SRCDIR 文件参数改成了一个对我来说更合适的参数,而不是 autoscan 随机选择的那个。

FLM_PROG_TRY_DOXYGEN 宏在 ➏ 处是我编写的自定义宏。下面是它的原型:

FLM_PROG_TRY_DOXYGEN([quiet])

我将在 第十六章 中详细讲解这个宏是如何工作的。目前只需要知道它管理一个名为 DOXYGEN 的宝贵变量。如果该变量已经设置,这个宏什么也不做;如果该变量没有设置,它会扫描系统搜索路径,查找 doxygen 程序,如果找到了,就将该变量设置为程序名称。我会在介绍 xflaim 项目时解释 Autoconf 的宝贵变量。

在 ➐ 处,我使用 AC_ARG_ENABLEconfigure 的命令行解析器添加了几个配置选项。当我们讨论其他使用这些宏定义的变量的新结构时,我会更全面地讨论这些调用的细节。

Automake 配置特性

Automake 提供了我在 ➑ 处使用的 AM_CONDITIONAL 宏;它的原型如下:

AM_CONDITIONAL(variable, condition)

variable 参数是一个 Automake 条件名称,你可以在 Makefile.am 文件中使用它来测试相关条件。condition 参数是一个 shell 条件——一段 shell 脚本,可以用作 shell if-then 语句中的条件。事实上,这正是该宏内部如何使用 condition 参数的方式,所以它必须格式化为一个正确的 if-then 语句 条件 表达式:

if condition; then...

AM_CONDITIONAL 宏总是定义两个 Autoconf 替换变量,分别是 variable_TRUEvariable_FALSE。如果 condition 为真,variable_TRUE 为空,而 variable_FALSE 被定义为一个井号(#),表示在 makefile 中注释的开始。如果 condition 为假,这两个替换变量的定义会被反转;也就是说,variable_FALSE 为空,而 variable_TRUE 变成了井号。Automake 使用这些变量有条件地注释掉你在 Automake 条件语句中定义的 makefile 脚本部分。

这一实例的AM_CONDITIONAL定义了条件名HAVE_DOXYGEN,您可以在项目的Makefile.am文件中使用它,根据是否能够成功执行doxygen(通过DOXYGEN变量)有条件地执行某些操作。在Makefile.am中的条件为真时,make脚本中的任何行都以@variable_TRUE@为前缀,在 Automake 生成的Makefile.in模板中。相反,任何在 Automake 条件测试为假时找到的行都以@variable_FALSE@为前缀。当config.statusMakefile.in生成Makefile时,这些行根据条件的真假被注释掉(以井号为前缀)或不被注释掉。

使用AM_CONDITIONAL有一个注意事项:您不能在configure.ac文件中有条件地调用它(例如,在 shell 的if-then-else语句中)。您不能有条件地定义替换变量——您可以根据指定的条件不同地定义它们的内容,但这些变量本身在 Autoconf 创建configure脚本时要么已定义,要么未定义。由于 Automake 生成的模板文件是在用户执行configure之前很久就创建的,因此 Automake 必须能够依赖这些变量的存在,无论它们是如何定义的。

configure脚本中,您可能希望根据 Automake 条件的值执行其他 Autoconf 操作。这时,位于➒的(已注释的)Automake 提供的AM_COND_IF宏就发挥作用了。^(12) 它的原型如下:

AM_COND_IF(conditional-variable, [if-true], [if-false])

如果conditional-variable在先前调用AM_CONDITIONAL时被定义为真,则执行if-true shell 脚本(包括任何 Autoconf 宏调用)。否则,执行if-false shell 脚本。

现在假设,举个例子,您希望有条件地构建项目目录结构的一部分——例如,基于 Automake 条件HAVE_DOXYGEN构建xflaim/docs/doxygen目录。也许您在Makefile.am文件中的 Automake 条件语句内将该子目录附加到SUBDIRS变量中(我实际上正在做这件事,正如您将在第 388 页的“FLAIM 工具包 Makefile.am 文件”一节中看到的)。由于如果条件为假,make不会构建项目目录结构的这一部分,因此在配置过程中没有理由让config.status处理该目录中的doxyfile.in模板。因此,您可以在configure.ac文件中使用列出 14-6 中显示的代码。

--snip--
AM_CONDITIONAL([HAVE_DOXYGEN], [test -n "$DOXYGEN"])
AM_COND_IF([HAVE_DOXYGEN], [AC_CONFIG_FILES([docs/doxyfile])])
#AS_IF([test -n "$DOXYGEN"], [AC_CONFIG_FILES([docs/doxyfile])])
--snip--

列出 14-6: ftk/configure.ac: 使用AM_COND_IF有条件地配置模板

在此代码存在的情况下,如果用户系统上未安装doxygenconfigure将根本不会处理docs目录中的doxyfile.in模板。

注意

docs/Makefile.in模板不应该包含在这里,因为dist目标必须能够在执行诸如allclean等构建目标时,处理项目中的所有目录——无论它们是否是有条件构建的。因此,你不应在 configure.ac 中有条件地处理Makefile.in模板。然而,你当然可以有条件地处理其他类型的模板。

在➒行之后的那一行是使用M4sh(一种内置于 Autoconf 的宏库,旨在简化编写可移植的 Bourne shell 脚本)的替代方法来完成相同的事情。这里是原型:

AS_IF(test1, [run-if-true], ..., [run-if-false])

在第二个和最后一个参数之间省略的可选参数是testNrun-if-true参数的配对。最终,这个宏的工作方式就像一个if-then-elif...的 Shell 语句,带有用户指定数量的elif条件。

Listing 14-7 展示了 ftk 的configure.ac文件的后半部分。

   --snip--
   # Configure for large files, even in 32-bit environments
➊ AC_SYS_LARGEFILE
   # Check for pthreads
➋ AX_PTHREAD(
     [AC_DEFINE([HAVE_PTHREAD], [1],
       [Define if you have POSIX threads libraries and header files.])
     LIBS="$PTHREAD_LIBS $LIBS"
     CFLAGS="$CFLAGS $PTHREAD_CFLAGS"
     CXXFLAGS="$CXXFLAGS $PTHREAD_CXXFLAGS"])

➌ # Checks for libraries.
   AC_SEARCH_LIBS([initscr], [ncurses])
   AC_CHECK_HEADER([curses.h],,[echo "*** Error: curses.h not found - install
   curses devel package."; exit 1])
   AC_CHECK_LIB([rt], [aio_suspend])
   AS_IF([test "x$openssl" = xyes],
   ➍ [AC_DEFINE([FLM_OPENSSL], [1], [Define to use openssl])
      AC_CHECK_LIB([ssl], [SSL_new])
      AC_CHECK_LIB([crypto], [CRYPTO_add])
      AC_CHECK_LIB([dl], [dlopen])
      AC_CHECK_LIB([z], [gzopen])])

➎ # Checks for header files.
   AC_HEADER_RESOLV
   AC_CHECK_HEADERS([arpa/inet.h fcntl.h limits.h malloc.h netdb.h netinet/in.h
   stddef.h stdlib.h string.h strings.h sys/mount.h sys/param.h sys/socket.h sys/
   statfs.h sys/statvfs.h sys/time.h sys/vfs.h unistd.h utime.h])

   # Checks for typedefs, structures, and compiler characteristics.
   AC_CHECK_HEADER_STDBOOL
   AC_C_INLINE
   AC_TYPE_INT32_T
   AC_TYPE_MODE_T
   AC_TYPE_PID_T
   AC_TYPE_SIZE_T
   AC_CHECK_MEMBERS([struct stat.st_blksize])
   AC_TYPE_UINT16_T
   AC_TYPE_UINT32_T
   AC_TYPE_UINT8_T

   # Checks for library functions.
   AC_FUNC_LSTAT_FOLLOWS_SLASHED_SYMLINK
   AC_FUNC_MALLOC
   AC_FUNC_MKTIME
   AC_CHECK_FUNCS([atexit fdatasync ftruncate getcwd gethostbyaddr gethostbyname
   gethostname gethrtime gettimeofday inet_ntoa localtime_r memmove memset mkdir
   pstat_getdynamic realpath rmdir select socket strchr strrchr strstr])

   # Configure DEBUG source code, if requested.
➏ AS_IF([test "x$debug" = xyes],
     [AC_DEFINE([FLM_DEBUG], [1], [Define to enable FLAIM debug features])])

➐ --snip--

➑ AC_CONFIG_FILES([Makefile
                      docs/Makefile
                      obs/Makefile
 obs/flaimtk.spec
                      src/Makefile
                      util/Makefile
                      src/libflaimtk.pc])

   AC_OUTPUT

   # Fix broken libtool
   sed 's/link_all_deplibs=no/link_all_deplibs=yes/' libtool >libtool.tmp && \
     mv libtool.tmp libtool

➒ cat <<EOF

     FLAIM toolkit ($PACKAGE_NAME) version $PACKAGE_VERSION
     Prefix.........: $prefix
     Debug Build....: $debug
     Using OpenSSL..: $openssl
     C++ Compiler...: $CXX $CXXFLAGS $CPPFLAGS
     Linker.........: $LD $LDFLAGS $LIBS
     Doxygen........: ${DOXYGEN:-NONE}

   EOF

Listing 14-7: ftk/configure.ac: ftk 项目的 configure.ac 文件的后半部分

在➊处,我调用了AC_SYS_LARGEFILE宏。如果用户使用的是 32 位系统,该宏会确保向C 预处理器定义(以及可能的编译器选项)中添加适当的定义,从而强制使用 64 位文件寻址(也称为大文件)。有了这些变量,C 库中的大地址感知文件 I/O 函数就可以在项目源代码中使用。作为一个数据库系统,FLAIM 非常重视这个特性。

近几年,32 位通用计算机系统已经不再那么流行,因为像英特尔和微软这样的公司在关于未来版本产品的声明中表示,将不再支持 32 位地址空间。然而,由于市场压力,数百万现有的 32 位系统使得它们稍微放缓了这一言辞,回归了更务实的视角。尽管如此,32 位 PC 将在不久的将来退出历史舞台。即便如此,Linux 仍将继续在 32 位系统上运行,因为许多嵌入式系统仍然从使用更小、更节能的 32 位微处理器中获得显著的好处。

正确处理线程

在➋处有另一个新的构造体,AX_PTHREAD。在 Jupiter 项目中,我仅通过-lpthread链接器标志将jupiter程序与pthreads库链接起来。但坦白说,这不是使用pthreads的正确方式。

在存在多个执行线程的情况下,必须配置许多标准 C 库函数以使其以线程安全的方式运行。你可以通过确保一个或多个预处理器定义在所有标准库头文件被编译到程序中时可见来实现这一点。这些 C 预处理器定义必须在编译器命令行上定义,并且在编译器供应商之间没有标准化。

一些供应商为构建单线程和多线程程序提供完全不同的标准库,因为将线程安全性添加到库中会在某种程度上降低性能。编译器供应商(正确地)认为,通过为这些目的提供不同版本的标准库,他们是在帮你一个忙。在这种情况下,必须告诉链接器使用正确的运行时库。

不幸的是,每个供应商都有自己实现多线程的方式,从编译器选项到库名称再到预处理器定义。但这个问题有一个合理的解决方案:GNU Autoconf Archive^(13)提供了一个名为AX_PTHREAD的宏,它检查用户的编译器,并为各种平台提供正确的标志和选项。

这个宏非常简单易用:

AX_PTHREAD(action-if-found[, action-if-not-found])

它设置了几个环境变量,包括PTHREAD_CFLAGSPTHREAD_CXXFLAGSPTHREAD_LIBS。调用者需要通过向action-if-found参数添加 Shell 代码来正确使用这些变量。如果你的整个项目都是多线程的,事情就更简单了:你只需要将这些变量附加到标准的CFLAGSCXXFLAGSLIBS变量中,或者在这些变量内使用它们。FLAIM 项目的代码库完全是多线程的,所以我选择了这样做。

如果你检查ftk/m4目录中的ax_pthread.m4文件,你可能会期待看到一个大的case语句,用于设置已知平台和编译器组合的选项,但那并不是 Autoconf 的方式。

相反,该宏包含了一个已知的pthread编译器选项长列表,生成的configure脚本使用主机编译器依次用这些选项编译一个小的pthreads程序。编译器识别并能正确构建测试程序的标志将被添加到PTHREAD_CFLAGSPTHREAD_CXXFLAGS变量中。通过这种方式,AX_PTHREAD即使在未来编译器选项发生重大变化时,也有很大可能继续正常工作——这就是 Autoconf 的方式。

获取恰到好处的库

我删除了每个AC_CHECK_LIB宏调用上方的FIXME注释(见 Listing 14-4 中的configure.scan,page 378),这些注释位于 Listing 14-7 的➌位置。我开始用实际的库函数名称替换这些宏中的主要占位符,但随后我开始怀疑这些库是否都真的必要。我对autoscan的能力并不太担心,而是更关注原始 makefile 的真实性。在手写构建系统中,我偶尔会注意到作者会将一组库名称从一个 makefile 复制到另一个,直到程序可以在没有丢失符号的情况下构建成功。^(14)

我没有盲目地继续这种趋势,而是选择简单地注释掉所有 AC_CHECK_LIB 的调用,看看在构建过程中能走多远,然后根据需要逐个添加它们,以解决缺失的符号。除非你的项目需要消耗数百个库,否则这只需要几分钟的额外时间。我喜欢只链接对我的项目必要的库;这不仅能加速链接过程,而且在严格执行时,能为项目提供很好的文档。

configure.scan 文件包含了 14 个 AC_CHECK_LIB 的调用。事实证明,我的 64 位 Linux 系统上的 FLAIM 工具包只需要其中的三个——pthreadncursesrt——所以我删除了剩下的条目,并用 ncursesrt 库中的实际函数替换了占位符参数。回头看,这个策略似乎非常成功,因为我从 14 个库减少到了 2 个。第三个库是 POSIX 线程(pthreads)库,通过我在前一节中讨论的 AX_PTHREAD 宏来添加。

我还将 ncursesAC_CHECK_LIB 调用转换为 AC_SEARCH_LIBS,因为我怀疑未来的 FLAIM 平台可能会使用不同的库名称来实现 curses 功能。我希望为这些平台的构建系统做好准备,搜索额外的库。ncurses 库在大多数平台上是可选的,因此我添加了 AC_CHECK_HEADER 宏来检查 curses.h,并在 action-if-not-found(第三个)参数中显示用户应安装 curses-development 包的消息,同时以错误退出配置过程。这个规则是在配置阶段尽早发现问题,而不是在编译阶段。

维护者定义的命令行选项

接下来的四个库在 ➍ 处的 Autoconf 条件语句中进行了检查。这个语句基于最终用户使用 --enable-openssl 命令行参数,AC_ARG_ENABLE 提供了该参数(请参见 Listing 14-5 中的 ➐,在 第 379 页)。

我在这里使用了 AS_IF,而不是 shell 的 if-then 语句,因为如果条件语句中的任何宏调用需要额外的宏来展开才能正常工作,AS_IF 将确保这些依赖项首先在条件语句外部展开。AS_IF 宏不仅是 M4sh 库的一部分,也是 Autoconf 自动依赖框架的一部分(该框架在《Autoconf 与 M4》一书的 第 439 页中详细讨论)。

在这种情况下,openssl 变量根据 AC_ARG_ENABLE 提供的默认值以及最终用户的命令行选项被定义为 yesno

AC_DEFINE宏,在AS_IF的第一个参数中调用,确保 C 预处理器变量FLM_OPENSSLconfig.h头文件中被定义。接着,AC_CHECK_LIB宏确保将-lssl-lcrypto-ldl-lz字符串添加到LIBS变量中,但仅在openssl变量被设置为yes时才会添加。我们并不希望强制要求用户安装这些库,除非他们请求了需要这些库的功能。

你可以在处理维护者定义的命令行选项(如--enable-openssl)时变得尽可能复杂。但要小心:某些级别的自动化可能会让用户感到惊讶。例如,自动启用该选项,因为你的检查发现 OpenSSL 库已安装并且可以访问,这可能会让人感到有些不安。

我把所有头文件和库函数检查都留在了➎位置,正如autoscan所规定的,因为通过源代码进行简单的文本扫描来查找头文件和函数名称可能相当准确。

然而,请注意,autoscan并没有将 ftk 源代码中使用的所有头文件都放入AC_CHECK_HEADERS参数中。autoscan工具的算法简单但有效:它会添加所有源代码中条件性包含的头文件。这种方法假设任何你条件性包含的头文件可能会由于移植性问题,在不同平台上以不同的方式包含。虽然这种方法通常是正确的,但并不总是正确的,所以你应该查看每个添加的头文件,找到源代码中的条件性包含,并更智能地评估是否应该将其添加到configure.ac中的AC_CHECK_HEADERS

在这个项目中,一个很好的例子是stdlib.h的条件性包含。事实上,stdlib.h会在 Windows 构建时包含,也会在 Unix 构建时包含。但它不会在 NetWare 构建时包含。无论如何,AC_CHECK_HEADERS中并不需要检查它,有两个原因。首先,它在平台间已经得到广泛标准化;其次,这个构建系统是专为 Unix 系统设计的。^(15) 重点是,你应该仔细检查autoscan为你做了什么,以确定是否应该在你的项目中执行此操作。

在➏位置,我们看到基于debug变量内容的条件性(AS_IF)使用了AC_DEFINE。这是另一个环境变量,根据传递给configure的命令行参数的结果条件性地定义。--enable-debug选项将debug变量设置为yes,这最终启用了config.h中的FLM_DEBUG C 预处理器定义。FLM_OPENSSLFLM_DEBUG已经在 FLAIM 项目源代码中使用。以这种方式使用AC_DEFINE允许最终用户决定哪些功能被编译到库中。

我在 ➐ 处省略了一大块涉及编译器和工具优化的代码,这些代码将在下一章中呈现。这个代码在所有项目的 configure.ac 文件中都是相同的。

最后,我将对位于 ➑ 处的 docsobssrcutil 目录中的 makefile 以及 obs/flaimtk.specsrc/libflaimtk.pc 文件添加了对 AC_CONFIG_FILES 宏调用的引用,并在底部附近添加了我常用的 cat 语句,作为对我的配置状态的可视化验证。现在,只需忽略 cat 语句上方的 sed 命令。我将在《传递依赖》章节中详细介绍它,见 第 401 页。

FLAIM 工具包的 Makefile.am 文件

如果我们暂时忽略 Doxygen 和 RPM 特定目标的命令,ftk/Makefile.am 文件相对简单。Listing 14-8 显示了整个文件内容。

   ACLOCAL_AMFLAGS = -I m4

   EXTRA_DIST = GNUMakefile README.W32 debian netware win32

➊ if HAVE_DOXYGEN
     DOXYDIR = docs
   endif

   SUBDIRS = src util obs $(DOXYDIR)

➋ doc_DATA = AUTHORS ChangeLog COPYING INSTALL NEWS README

   RPM = rpm

➌ rpms srcrpm: dist
          (cd obs && $(MAKE) $(AM_MAKEFLAGS) $@) || exit 1
           rpmarch=`$(RPM) --showrc | \
             grep "^build arch" | sed 's/\(.*: \)\(.*\)/\2/'`; \
           test -z "obs/$$rpmarch" || \
             ( mv obs/$$rpmarch/* . && rm -rf obs/$$rpmarch )
           rm -rf obs/$(distdir)

➍ #dist-hook:
   #        rm -rf `find $(distdir) -name .svn`

   .PHONY: srcrpm rpms

Listing 14-8: ftk/Makefile.am: FLAIM 工具包顶层 makefile 的全部内容

在这个文件中,你会发现常见的 ACLOCAL_AMFLAGSEXTRA_DISTSUBDIRS 变量定义,但你也可以看到在 ➊ 处使用了 Automake 条件。if 语句允许我将另一个目录(docs)添加到 SUBDIRS 列表中,但前提是 configure 检测到 doxygen 程序可用。我在这里使用了一个单独的变量(DOXYDIR),但 Automake 条件也可以直接包围一个语句,通过 Automake += 运算符将目录名(doc)追加到 SUBDIRS 变量中。

注意

不要将 Automake 条件与 GNU Make 条件混淆,后者使用关键字 ifeqifneqifdef* 和 ifndef。如果你尝试在Makefile.am中使用一个 Automake 条件,而没有在configure.ac中添加相应的 AM_CONDITIONAL 语句,Automake 会对此发出警告。正确使用这个结构时,Automake 会在 make 看到它之前,将其转换为 make 可以理解的内容。*

另一个新的结构(至少在顶层 Makefile.am 文件中)是使用了 ➋ 处的 doc_DATA 变量。FLAIM 工具包在其顶层目录中提供了一些额外的文档文件,我希望将它们安装上。通过在 DATA 主要部分使用 doc 前缀,我告诉 Automake 希望将这些文件作为数据文件安装到 $(docdir) 目录,默认情况下最终解析到 $(prefix)/share/doc 目录中。

DATA 变量中提到的文件,如果它们没有 Automake 的特殊含义,默认不会自动分发(即不会被加入到分发的 tar 包中),因此你需要手动分发它们,方法是将它们添加到 EXTRA_DIST 变量中列出的文件中。

注意

我不需要在 EXTRA_DIST 中列出标准的 GNU 项目文本文件,因为它们总是会自动分发。然而,我确实需要在 doc_DATA 变量中提到这些文件,因为 Automake 不假设你想要安装哪些文件。

我将推迟讨论 ➌ 处的 RPM 目标,直到下一章。

Automake -hook 和 -local 规则

Automake 识别两种类型的集成扩展,我称之为 -local 目标和 -hook 目标。Automake 识别并遵循 -local 扩展,适用于以下标准目标:

all install-data installcheck
check install-dvi installdirs
clean install-exec maintainer-clean
distclean install-html mostlyclean
dvi install-info pdf
html install-pdf ps
info install-ps uninstall

在你的 Makefile.am 文件中将 -local 添加到这些目标中的任何一个,将导致相关命令在标准目标 之前 执行。Automake 通过为标准目标生成规则来实现这一点,使得 -local 版本成为其依赖项之一(如果存在的话)。^(16) 在《整理你的房间》(见 第 404 页)中,我将展示一个使用 clean-local 目标的示例。

-hook 目标稍有不同,它们在相应的标准目标执行 之后 执行。^(17) Automake 通过在标准目标命令列表的末尾添加另一个命令来实现这一点。该命令仅执行 $(MAKE) 在包含的 makefile 上,将 -hook 目标作为命令行目标。因此,-hook 目标以递归方式在标准目标命令的末尾执行。

以下标准的 Automake 目标支持 -hook 版本:

dist install-data uninstall
distcheck install-exec

Automake 会自动将所有现有的 -local-hook 目标添加到生成的 makefile 中的 .PHONY 规则中。

在本书的第一版中,我在 Makefile.am 中使用了 dist-hook 目标(现在已被注释掉)来调整分发目录,调整发生在构建后,但在 make 从其内容构建分发档案之前。rm 命令删除了由于我将整个目录添加到 EXTRA_DIST 变量中而成为分发目录一部分的多余文件和目录。当你将目录名称添加到 EXTRA_DIST 中时(在本例中为 debiannetwarewin32),这些目录中的所有内容都会被添加到分发中——甚至包括隐藏的仓库控制文件和目录。^(18)

清单 14-9 是生成的 Makefile 的一部分,展示了 Automake 如何将 dist-hook 融入到最终的 makefile 中。相关部分已被高亮显示。

--snip--
distdir: $(DISTFILES)
        ... # copy files into distdir
        $(MAKE) $(AM_MAKEFLAGS) top_distdir="$(top_distdir)" \
            distdir="$(distdir)" dist-hook
        ... # change attributes of files in distdir
--snip--
dist dist-all: distdir
        tardir=$(distdir) && $(am__tar) | GZIP=$(GZIP_ENV) gzip -c \
          >$(distdir).tar.gz
        $(am__remove_distdir)
--snip--
.PHONY: ... dist-hook ...
--snip--
dist-hook:
        rm -rf `find $(distdir) -name .svn`
--snip--

清单 14-9:定义 dist-hook 目标的结果,在 ftk/Makefile.am

注意

不要害怕深入生成的 makefile,查看 Automake 如何处理你的代码。虽然make命令中有相当多的丑陋 shell 代码,但大多数可以忽略。你通常更关心 Automake 生成的make规则,且很容易将它们分离出来。

设计 ftk/src/Makefile.am 文件

我现在需要在 FLAIM 工具包项目的srcutils目录中创建Makefile.am文件。在创建这些文件时,我希望确保所有原有功能都得以保留。基本上,这包括:

  • 正确构建 ftk 共享和静态库

  • 正确指定所有已安装文件的安装位置

  • 正确设置 ftk 共享库的版本信息

  • 确保所有剩余的未使用文件都被分发

  • 确保使用平台特定的编译器选项

在清单 14-10 中展示的模板应该涵盖大多数这些要点,因此我将在所有 FLAIM 库项目中使用它,并根据每个库的需求进行适当的增减。

EXTRA_DIST = ...

lib_LTLIBRARIES = ...
include_HEADERS = ...

xxxxx_la_SOURCES = ...
xxxxx_la_LDFLAGS = -version-info x:y:z

清单 14-10:src 和 utils 目录的框架 Makefile.am 文件

原始的GNUMakefile告诉我库的名称是libftk.so。这个名字在 Linux 上并不好,因为大多数三字母的库名已经被占用了。因此,我做出了一个决定,将ftk库重命名为flaimtk

清单 14-11 显示了最终的ftk/src/Makefile.am文件的大部分内容。

➊ EXTRA_DIST = ftknlm.h

➋ lib_LTLIBRARIES = libflaimtk.la
➌ include_HEADERS = flaimtk.h

➍ pkgconfigdir = $(libdir)/pkgconfig
   pkgconfig_DATA = libflaimtk.pc

➎ libflaimtk_la_SOURCES = \
   ftkarg.cpp \
   ftkbtree.cpp \
   ftkcmem.cpp \
   ftkcoll.cpp \
   --snip--
   ftksys.h \
   ftkunix.cpp \
   ftkwin.cpp \
   ftkxml.cpp

➏ libflaimtk_la_LDFLAGS = -version-info 0:0:0

清单 14-11:ftk/src/Makefile.am:整个文件内容,减去一些几十个源文件

我将 Libtool 库名称libflaimtk.la添加到lib_LTLIBRARIES列表中的➋,并将清单 14-10 中其余宏的xxxxx部分更改为libflaimtk。我本可以手动输入所有源文件,但在阅读原始 makefile 时,我注意到它使用了 GNU make的函数宏$(wildcard src/*.cpp)来从src目录的内容构建文件列表。这告诉我,src目录中的所有.cpp文件都是库所需的(或者至少会被使用)。为了将文件列表添加到Makefile.am中,我使用了一个简单的 shell 命令,将其拼接到Makefile.am文件的末尾(假设我在ftk/src目录中):

$ printf '%s \\\n' *.cpp >> Makefile.am

这让我在ftk/src/Makefile.am的底部得到了一个单列、以反斜杠结束、按字母顺序排列的所有.cpp文件列表。

注意

不要忘记在printf参数周围加上单引号,这对于防止在生成列表时,第一个反斜杠对 shell 作为转义字符的解释是必要的。不管怎么引用,printf都会正确理解并解析\n字符。

我将列表移到libflaimtk_la_SOURCES行下方➎,在等号后添加了一个反斜杠字符,并删除了最后一个文件后的反斜杠。另一种格式化技巧是简单地在每大约 70 个字符后用反斜杠和换行符包裹一行,但我更倾向于将每个文件放在单独的一行,特别是在转换过程的早期,这样我可以根据需要轻松地从列表中提取文件或添加文件。将文件放在单独的行上还能带来一个好处,即在查看拉取请求和其他diff风格的输出时,源文件列表更容易进行对比。

我必须手动检查src目录中的每个头文件,以确定它在项目中的使用情况。这里只有四个头文件,结果发现,FLAIM 工具包在 Unix 和 Linux 平台上唯一没有使用的是ftknlm.h,它是专门用于 NetWare 构建的。我将这个文件添加到➊位置的EXTRA_DIST列表中,以便进行分发;仅仅因为构建不使用它并不意味着用户不需要或不希望使用它。^(19)

(重新命名后的)flaimtk.h文件是唯一的公共头文件,因此我将它移入了➌位置的include_HEADERS列表。其他两个文件在库的构建过程中是内部使用的,因此我将它们保留在libflaimtk_la_SOURCES列表中。如果这是我自己的项目,我会将flaimtk.h移动到项目根目录下的include目录中,但记住,我的一个目标是尽量限制对目录结构和源代码的更改。移动这个头文件是一个哲学性的决定,我决定将其留给维护者来做出决定。^(20)

最后,我在原始 makefile 中注意到,ftk库的最后一个版本发布了 4.0 的接口版本。然而,由于我将库的名称从libftk更改为libflaimtk,我将此值重置为 0.0,因为它是一个不同的库。我在➏位置的libflaimtk_la_LDFLAGS变量中的-version-info选项里将x:y:z替换为0:0:0

注意

0:0:0的版本字符串是默认值,因此我本可以完全移除该参数并达到相同的效果。然而,包含它可以为新开发者提供一些关于如何在未来更改接口版本的见解。

我在➍位置添加了pkgconfigdirpkgconfig_DATA变量,以便为此项目提供支持安装 pkg-config 元数据文件的功能。有关 pkg-config 系统的更多信息,请参见第十章。

继续处理 ftk/util 目录

正确设计Makefile.am用于util目录时,需要再次检查原始的 makefile,以便支持更多的产品。快速浏览ftk/util目录后发现,那里只有一个源文件:ftktest.cpp。这看起来像是一个针对ftk库的测试程序,但我知道 FLAIM 的开发者在多种场景中都在使用它,除了仅仅用于测试构建。所以在这里,我需要做出一个设计决策:我应该把它作为一个普通程序还是作为一个检查程序来构建?

检查程序只有在执行make check时才会被构建,并且它们永远不会被安装。如果我希望ftktest作为常规程序构建,但不安装,我必须在程序列表变量中使用noinst前缀,而不是通常的bin前缀。

在任何情况下,我可能都希望将ftktest添加到make check期间执行的测试列表中,所以这里有两个问题:(1)我是否希望在make check期间自动运行ftktest,(2)我是否希望安装ftktest程序。考虑到 FLAIM 工具包是一个成熟的产品,我选择在make check期间构建ftktest并保持它未安装。

列表 14-12 展示了我的最终ftk/util/Makefile.am文件。

FTK_INCLUDE = -I$(top_srcdir)/src
FTK_LTLIB = ../src/libflaimtk.la

check_PROGRAMS = ftktest
ftktest_SOURCES = ftktest.cpp
ftktest_CPPFLAGS = $(FTK_INCLUDE)
ftktest_LDADD = $(FTK_LTLIB)

TESTS = ftktest

列表 14-12:ftk/util/Makefile.am:此文件的最终内容

我希望到现在为止你已经理解了TESTScheck_PROGRAMS之间的关系。坦率地说,check_PROGRAMS中列出的文件与TESTS中列出的文件之间没有任何关系。check目标只是确保在执行TESTS程序和脚本之前,check_PROGRAMS已经被构建。TESTS可以指任何不需要命令行参数即可执行的内容。这种职责分离使得系统非常简洁且灵活。

这就是 FLAIM 工具包库和工具的全部内容。我不知道你怎么想,但我宁愿维护这一小组简短的文件,而不愿意去维护一个单一的 2,200 行的 makefile!

设计 XFLAIM 构建系统

现在我完成了 FLAIM 工具包的工作,接下来我将进入 xflaim 项目。我选择从 xflaim 开始,而不是 flaim,因为它提供了最多的构建功能,可以转换为 Autotools,包括 Java 和 C#语言绑定(这些内容我将在下一章详细讨论)。在完成 xflaim 之后,覆盖其余的数据库项目就显得多余了,因为它们的过程是相同的,甚至更简单一些。不过,你可以在本书的 GitHub 仓库中找到其他构建系统文件。

我再次使用autoscan生成了configure.ac文件。在每个独立项目中使用autoscan非常重要,因为每个项目的源代码不同,因此会导致不同的宏被写入每个configure.scan文件中。^(21) 接着,我使用了在 FLAIM 工具包中使用的相同技术来创建 xflaim 的configure.ac文件。

XFLAIM 的 configure.ac 文件

在手动修改生成的configure.scan文件并将其重命名为configure.ac之后,我发现它在许多方面与工具包的configure.ac文件类似。它相当长,因此我只会展示列表 14-13 中的最重要区别。

   --snip--
➊ # Checks for optional programs.
   FLM_PROG_TRY_CSC
   FLM_PROG_TRY_CSVM
   FLM_PROG_TRY_JNI
   FLM_PROG_TRY_JAVADOC
   FLM_PROG_TRY_DOXYGEN

➋ # Configure variables: FTKLIB and FTKINC.
   AC_ARG_VAR([FTKLIB], [The PATH wherein libflaimtk.la can be found.])
   AC_ARG_VAR([FTKINC], [The PATH wherein flaimtk.h can be found.])
   --snip--
➌ # Ensure that both or neither is specified.
   if (test -n "$FTKLIB" && test -z "$FTKINC") || \
      (test -n "$FTKINC" && test -z "$FTKLIB"); then
     AC_MSG_ERROR([Specify both FTK library and include paths, or neither.])
   fi

   # Not specified? Check for FTK in standard places.
   if test -z "$FTKLIB"; then
   ➍ # Check for FLAIM toolkit as a sub-project.
      if test -d "$srcdir/ftk"; then
        AC_CONFIG_SUBDIRS([ftk])
        FTKINC='$(top_srcdir)/ftk/src'
        FTKLIB='$(top_builddir)/ftk/src'
      else
   ➎ # Check for FLAIM toolkit as a superproject.
        if test -d "$srcdir/../ftk"; then
          FTKINC='$(top_srcdir)/../ftk/src'
          FTKLIB='$(top_builddir)/../ftk/src'
        fi
      fi
    fi

➏ # Still empty? Check for *installed* FLAIM toolkit.
   if test -z "$FTKLIB"; then
     AC_CHECK_LIB([flaimtk], [ftkFastChecksum],
       [AC_CHECK_HEADERS([flaimtk.h])
          LIBS="-lflaimtk $LIBS"],
       [AC_MSG_ERROR([No FLAIM toolkit found. Terminating.])])
   fi

➐ # AC_SUBST command line variables from FTKLIB and FTKINC.
   if test -n "$FTKLIB"; then
     AC_SUBST([FTK_LTLIB], ["$FTKLIB/libflaimtk.la"])
     AC_SUBST([FTK_INCLUDE], ["-I$FTKINC"])
   fi

➑ # Automake conditionals
   AM_CONDITIONAL([HAVE_JAVA], [test "x$flm_prog_have_jni" = xyes])
   AM_CONDITIONAL([HAVE_CSHARP], [test -n "$CSC"])
   AM_CONDITIONAL([HAVE_DOXYGEN], [test -n "$DOXYGEN"])
   #AM_COND_IF([HAVE_DOXYGEN], [AC_CONFIG_FILES([docs/doxygen/doxyfile])])
   AS_IF([test -n "$DOXYGEN"], [AC_CONFIG_FILES([docs/doxygen/doxyfile])])
   --snip--
   AC_OUTPUT
   # Fix broken libtool
   sed 's/link_all_deplibs=no/link_all_deplibs=yes/' libtool >libtool.tmp && \
     mv libtool.tmp libtool

   cat <<EOF

     ($PACKAGE_NAME) version $PACKAGE_VERSION
     Prefix.........: $prefix
     Debug Build....: $debug
     C++ Compiler...: $CXX $CXXFLAGS $CPPFLAGS
     Linker.........: $LD $LDFLAGS $LIBS
     FTK Library....: ${FTKLIB:-INSTALLED}
     FTK Include....: ${FTKINC:-INSTALLED}
     CSharp Compiler: ${CSC:-NONE} $CSCFLAGS
     CSharp VM......: ${CSVM:-NONE}
     Java Compiler..: ${JAVAC:-NONE} $JAVACFLAGS
     JavaH Utility..: ${JAVAH:-NONE} $JAVAHFLAGS
     Jar Utility....: ${JAR:-NONE} $JARFLAGS
     Javadoc Utility: ${JAVADOC:-NONE}
     Doxygen........: ${DOXYGEN:-NONE}

   EOF

列表 14-13:xflaim/configure.ac:这个 Autoconf 输入文件中最重要的部分

首先,注意到我在➊处发明了几个更多的FLM_PROG_TRY_*宏。在这里,我正在检查以下程序的存在:C#编译器、C#虚拟机、Java 编译器、JNI 头文件和存根生成器、Javadoc 生成工具、Java 归档工具和 Doxygen。我为这些检查编写了单独的宏文件,并将它们添加到我的xflaim/m4目录中。

与工具包中使用的FLM_PROG_TRY_DOXYGEN宏一样,这些宏中的每一个都会尝试定位相关程序,但如果找不到程序,它们不会导致配置过程失败。我希望在这些程序可用时能使用它们,但不希望要求用户必须拥有它们才能构建基础库。

你会在➋处找到一个新宏,AC_ARG_VAR。像AC_ARG_ENABLEAC_ARG_WITH宏一样,AC_ARG_VAR允许项目维护者扩展configure脚本的命令行接口。然而,这个宏不同之处在于,它将一个公共变量,而不是命令行选项,添加到configure关心的公共变量列表中。在这个例子中,我添加了两个公共变量,FTKINCFTKLIB。它们将在configure的帮助文本中出现在“一些影响环境变量”部分下。GNU Autoconf 手册将这些变量称为珍贵的。我的所有FLM_PROG_TRY_*宏都在内部使用AC_ARG_VAR宏,使相关变量既是公共的,也是珍贵的。^(22)

注意

的代码行可以在 GitHub 仓库的 xflaim/m4/flm_ftk_search.m4 中找到到第十五章结束时,本章中的列表和 GitHub 仓库中的文件之间的所有差异都已解决。

从➌开始的大段代码实际上使用这些变量来设置构建系统中使用的其他变量。用户可以在环境中设置这些公共变量,或者可以通过这种方式在configure的命令行上指定它们:

$ ./configure FTKINC="$HOME/dev/ftk/include" ...

首先,我会检查FTKINCFTKLIB变量是否都指定了,或者都没有指定。如果只指定了其中一个,我必须因为出错而失败。用户不能只告诉我在哪里找到个工具包;我需要同时拥有头文件和库。^(23) 如果这两个变量都没有指定,我会在➍通过查找名为ftk的子目录来寻找它们。如果找到了,我将使用AC_CONFIG_SUBDIRS宏将该目录配置为一个由 Autoconf 处理的子项目。^(24) 我还会将这两个变量设置为指向 ftk 子项目中适当的相对位置。

如果没有找到ftk子目录,我会在父目录的➎查找。如果在那里找到了,我会相应地设置变量。这次,我不需要将找到的ftk目录配置为子项目,因为我假设 xflaim 项目本身是上层项目的一个子项目。如果我没有找到ftk,无论是作为子项目还是同级项目,我会在➏使用标准的AC_CHECK_LIBAC_CHECK_HEADERS宏来检查用户的主机是否安装了工具包库。在这种情况下,我只需要将-lflaimtk添加到LIBS变量中。在这种情况下,头文件将位于标准位置:通常是/usr(/local)/includeAC_CHECK_LIB的可选第三个参数的默认功能会自动将库引用添加到LIBS变量中,但由于我覆盖了这个默认功能,我必须手动将工具包库引用添加到LIBS中。

如果找不到库,我会放弃并显示一个错误信息,提示 xflaim 无法在没有 FLAIM 工具包的情况下构建。然而,在通过所有这些检查后,如果FTKLIB变量不再为空,我会在➐使用AC_SUBST来发布FTK_INCLUDEFTK_LTLIB变量,这些变量包含适用于预处理器和链接器命令行选项的FTKINCFTKLIB的衍生值。

注意

第十六章将➌到➑之间的大段代码转换成一个名为FLM_FTK_SEARCH的自定义 M4 宏。

➑处的剩余代码以类似于我在 ftk 项目中处理 Doxygen 的方式,调用AM_CONDITIONAL来处理 Java、C#和 Doxygen。这些宏配置为生成警告信息,指出如果找不到 Java 或 C#工具,xflaim 项目的这些部分将不会被构建,但无论如何,我允许构建继续进行。

创建 xflaim/src/Makefile.am 文件

我跳过了xflaim/Makefile.am文件,因为它与ftk/Makefile.am几乎相同。相反,我们将继续讨论xflaim/src/Makefile.am,这是我按照与ftk/src版本相同的设计原则编写的。它看起来与 ftk 的对应文件非常相似,唯一的例外是:根据原始构建系统的 makefile,Java 本地接口(JNI)和 C#本地语言绑定源文件直接编译并链接到xflaim共享库中。

这并不是一种不常见的做法,而且非常有用,因为它避免了为这些语言专门构建额外的库对象。基本上,xflaim共享库导出了这些语言的本地接口,然后这些接口被相应的本地封装器使用。^(25)

我现在暂时忽略这些语言绑定,但稍后当我完成整个 xflaim 项目时,我会重新关注如何将它们正确地集成到库中。除去这个例外,清单 14-14 中展示的Makefile.am文件与其 ftk 对应文件几乎一模一样。

if HAVE_JAVA
  JAVADIR = java
  JNI_LIBADD = java/libxfjni.la
endif

if HAVE_CSHARP
  CSDIR = cs
  CSI_LIBADD = cs/libxfcsi.la
endif

SUBDIRS = $(JAVADIR) $(CSDIR)

pkgconfigdir = $(libdir)/pkgconfig
pkgconfig_DATA = libxflaim.pc

lib_LTLIBRARIES = libxflaim.la
include_HEADERS = xflaim.h

libxflaim_la_SOURCES = \
 btreeinfo.cpp \
  f_btpool.cpp \
  f_btpool.h \
  --snip--
  rfl.h \
  scache.cpp \
  translog.cpp

libxflaim_la_CPPFLAGS = $(FTK_INCLUDE)
libxflaim_la_LIBADD = $(JNI_LIBADD) $(CSI_LIBADD) $(FTK_LTLIB)
libxflaim_la_LDFLAGS = -version-info 3:2:0

清单 14-14:xflaim/src/Makefile.am:xflaim 项目 src 目录的 Automake 输入文件

我根据configure.ac中相应 Automake 条件语句定义了SUBDIRS变量的内容。当执行make all时,SUBDIRS变量会根据条件递归到javacs子目录中。但当执行make dist时,一个隐藏的DIST_SUBDIRS变量(由 Automake 根据SUBDIRS变量的所有可能内容创建)引用了无论是条件性还是无条件附加到SUBDIRS中的所有目录。^(26)

注意

库接口版本信息是从原始 makefile 中提取的。

转向 xflaim/util 目录

xflaim 的util目录稍微复杂一些。根据原始 makefile,它生成了几个实用程序以及一个被这些工具使用的便捷库。

找出哪些源文件属于哪些工具,哪些根本没有被使用,稍微有些困难。xflaim/util目录中的几个文件并没有被任何工具使用。我们是否需要分发这些额外的源文件?我选择分发它们,因为它们已经被原始构建系统分发,并将它们添加到EXTRA_DIST列表中使得后来的人可以明显看出它们没有被使用。

清单 14-15 展示了xflaim/util/Makefile.am文件的一部分;缺失的部分是冗余的。

   EXTRA_DIST = dbdiff.cpp dbdiff.h domedit.cpp diffbackups.cpp xmlfiles

   XFLAIM_INCLUDE = -I$(top_srcdir)/src
   XFLAIM_LDADD = ../src/libxflaim.la

➊ AM_CPPFLAGS = $(XFLAIM_INCLUDE) $(FTK_INCLUDE)
   LDADD = libutil.la $(XFLAIM_LDADD)

 ## Utility Convenience Library

   noinst_LTLIBRARIES = libutil.la

   libutil_la_SOURCES = \
    flm_dlst.cpp \
    flm_dlst.h \
    flm_lutl.cpp \
    flm_lutl.h \
    sharutil.cpp \
    sharutil.h

   ## Utility Programs

   bin_PROGRAMS = xflmcheckdb xflmrebuild xflmview xflmdbshell

   xflmcheckdb_SOURCES = checkdb.cpp
   xflmrebuild_SOURCES = rebuild.cpp

   xflmview_SOURCES = \
    viewblk.cpp \
    view.cpp \
    --snip--
    viewmenu.cpp \
    viewsrch.cpp

   xflmdbshell_SOURCES = \
    domedit.h \
    fdomedt.cpp \
    fshell.cpp \
    fshell.h \
    xshell.cpp

   ## Check Programs

   check_PROGRAMS = \
    ut_basictest \
    ut_binarytest \
    --snip--
    ut_xpathtest \
    ut_xpathtest2

➋ check_DATA = copy-xml-files.stamp
   check_HEADERS = flmunittest.h

   ut_basictest_SOURCES = flmunittest.cpp basictestsrv.cpp
➌ --snip--
   ut_xpathtest2_SOURCES = flmunittest.cpp xpathtest2srv.cpp

   ## Unit Tests

   TESTS = \
    ut_basictest \
    --snip--
    ut_xpathtest2

   ## Miscellaneous rules required by Check Programs

➍ copy-xml-files.stamp:
           cp $(srcdir)/xmlfiles/*.xml .
           echo Timestamp > $@

➎ clean-local:
           rm -rf ix2.*
           rm -rf bld.*
           rm -rf tst.bak
           rm -f *.xml
           rm -f copy-xml-files.stamp

清单 14-15:xflaim/util/Makefile.am:xflaim 项目的 util 目录 Automake 输入文件

在这个例子中,您可以通过省略的部分看到,我省略了多个长文件列表和产品清单。这个 Makefile 构建了 22 个单元测试,但由于它们几乎完全相同,除了命名差异和构建源文件的不同,我只保留了其中两个的描述(见 ➌)。

我在 ➊ 处定义了文件全局的 AM_CPPFLAGSLDADD 变量,用于将 XFLAIMFTK 的头文件和库文件与这个 Makefile.am 文件中列出的每个项目关联起来。这样,我就不需要显式地将这些信息附加到每个产品上。

传递性依赖

然而,请注意,AM_CPPFLAGS 变量同时使用了 XFLAIM_INCLUDEFTK_INCLUDE 变量——xflaim 工具显然需要来自两组头文件的信息。那么,为什么 LDADD 变量没有引用 ftk 库呢?这是因为 Libtool 为您管理传递性依赖,并且它以非常便捷的方式做到这一点,因为某些系统没有本地的机制来管理传递性依赖。由于我通过 XFLAIM_LDADD 引用了 libxflaim.la,并且 libxflaim.lalibflaimtk.la 列为依赖项,Libtool 能够在工具程序的链接器命令行中为我提供传递性引用。

为了更清楚地了解这一点,请查看 libxflaim.la 的内容(在您的构建目录下的 xflaim/src 文件夹中——您需要先构建该项目;运行 autoreconf -i; ./configure && make)。您会在文件中间附近找到几行,内容看起来非常像 Listing 14-16 中的内容。

--snip--
# Libraries that this one depends upon.
dependency_libs=' .../flaim/build/ftk/src/libflaimtk.la -lrt -lncurses'
--snip--

Listing 14-16:xflaim/src 中的部分 libxflaim.la 文件内容,显示了依赖库。

这里列出了 libflaimtk.la 的路径信息,这样我们就不需要在 xflaim 工具的 LDADD 语句中显式指定它。^(27)

像 Libtool 一样,GNU 链接器和 Linux 加载器也能管理传递性依赖(TD)。这通过在适当的链接器命令行选项使用时,让 ld 将这些间接依赖合并到它生成的 ELF 二进制文件中来实现。Libtool 的机制依赖于对 .la 文件层级结构的递归搜索,而 Linux 的本地机制则是在构建时递归地搜索库层级,并将所有必需的库引用直接嵌入到构建的程序或库中。加载器随后在加载时看到并使用这些引用。使用这种本地 TD 管理的一个好处是,如果库在包的较新版本中更新,加载器将立即开始引用新库更新后的二级符号,而基于该库构建的项目将立即开始使用新版本的传递性依赖。

最近,一些发行版供应商决定在他们的平台上利用这个特性。问题在于,Libtool 的 TD 管理减少了使用ld(以及系统加载器)内部 TD 管理的优势——它在某种程度上妨碍了这一点。为了解决这个问题,这些供应商决定发布一个修改版的 Libtool,其中其 TD 管理功能被有效地禁用。结果是,你现在必须在链接器(libtool)命令行上明确指定所有直接和间接库,或者修改你的构建系统以使用非便携的本地 TD 管理链接器选项。

由于并非所有平台都支持本地 TD 管理,而 Libtool 的基于文本文件的方法完全是可移植的,我们通常会在没有本地 TD 管理系统的系统上依赖 Libtool 来正确处理间接依赖,尤其是在链接程序和库时。当你使用“被发行版破坏”的 Libtool 包来构建设计为利用 Libtool TD 管理功能的项目时,你的构建将在链接阶段失败,并出现“缺少 DSO”(动态共享对象)消息。^(28)

configure.ac中的sed命令会在libtool脚本中搜索link_all_deplibs=no文本,并将其替换为link_all_deplibs=yes。它出现了两次,sed命令会替换这两个实例。AC_OUTPUT执行config.status,它会在项目目录中生成libtool脚本,因此sed命令必须跟随AC_OUTPUT才能生效。

注意

即使在没有出现问题的系统上使用这个sed命令也不会有任何不良影响——sed* 只会在你的libtool脚本中找不到任何内容来替换。不过要注意的是,如果你的软件包被某个使用内部 TD 管理的 Linux 供应商选中进行分发,他们很可能会把这类命令“修补”掉。*

当然,另一种选择是完全放弃使用自动传递依赖管理,通过在每个程序或库的链接器命令行上明确指定你知道需要的所有链接依赖项来实现。实际上,Pkg-config 已经为你做了这件事,因此如果你能够依赖 pkg-config 来满足所有的库管理需求,那么你的项目就不会受到这个问题的影响。这可以通过在 flam、xflaim 和 flaimsql 项目中手动添加$(FTK_LTLIB)LDADD变量中来实现,具体可以参见 Listing 14-15 中的➊。

可以尝试通过注释掉configure.ac中的sed命令来进行测试,然后重新构建项目。假设你在一个修改过 Libtool 的平台上构建项目,那么在 flam 和 xflaim 项目尝试仅通过libflaim.lalibxflaim.la链接它们的工具时,构建将会失败。为了让它重新工作,按照之前提到的方式更新LDADD变量。

时间戳目标

在创建这个 Makefile 时,我遇到了一个没有预料到的小问题。至少有一个单元测试似乎要求在执行测试的目录中存在一些 XML 数据文件。测试失败了,当我深入调查时,我注意到它在尝试打开这些文件时失败了。稍微四处看看,我发现了xflaim/util/xmldata目录,其中包含了几十个 XML 文件。

我需要将这些文件复制到构建层次结构中的xflaim/util目录,然后才能运行单元测试。我知道以 check 为前缀的产品在执行 TESTS 之前会先构建,所以我想到我可以在 ➋ 处在 check_DATA PLV 中列出这些文件。check_DATA 变量引用一个名为copy-xml-files.stamp的文件,它是一个特殊类型的文件目标,称为stamp目标。它的目的是用一个单一的代表文件替代一组未指定的文件,或者一个非基于文件的操作。这个时间戳文件用于向构建系统指示所有 XML 数据文件已经被复制到util目录中。Automake 在它自己生成的规则中经常使用时间戳文件。

生成时间戳文件的规则 ➍ 也将 XML 数据文件复制到测试执行目录中。echo 语句仅仅创建一个名为copy-xml-files.stamp的文件,文件中包含一个单词:Timestamp。这个文件可以包含任何内容(甚至什么都不包含)。这里的重要点是文件存在并且有一个时间和日期与之相关联。make 工具利用这些信息来确定是否需要执行复制操作。在这种情况下,由于copy-xml-files.stamp没有依赖项,它的存在仅仅意味着 make 已经完成了操作。删除时间戳文件可以让 make 在下一次构建时执行复制操作。

这是一种介于真实基于文件的规则和伪目标之间的混合体。伪目标总是会被执行——它们不是实际的文件,因此 make 无法根据文件属性确定是否应该执行相关操作。基于文件的规则的时间戳可以与它们的依赖列表进行检查,以确定是否需要重新执行它们。像这样的时间戳规则只有在时间戳文件丢失时才会执行,因为没有依赖项可以与目标的时间和日期进行比较。^(29)

清理你的房间

所有放置在构建目录中的文件都应该在用户输入make clean时被清理掉。由于我将 XML 数据文件放入了构建目录,因此我也需要清理它们。列在DATA变量中的文件不会自动清理,因为DATA文件不一定是生成的。有时,DATA主变量用于列出需要安装的静态项目文件。我“创建”了一些 XML 文件和一个标记文件,所以在make clean时需要删除它们。为此,我在➎处添加了clean-local目标,以及其相关的rm命令。

注意

当删除从源树复制到构建树相应位置的文件时要小心——你可能会不小心删除源文件,尤其是当在源树内构建时。你可以在make命令中将$(srcdir)与“.”进行比较,看看用户是否在源树中构建。

还有另一种方法可以确保在执行clean目标时,使用你自己的make规则创建的文件能够被清理掉。你可以定义CLEANFILES变量,包含一个以空格分隔的文件列表(或通配符规范),以供删除。我在这个例子中使用了clean-local目标,因为CLEANFILES变量有一个限制:它不能删除目录,只能删除文件。每个删除通配符文件规范的rm命令都涉及到至少一个目录。我将在第十五章中展示如何正确使用CLEANFILES

无论你的单元测试清理得多么干净,你仍然可能需要编写clean规则来尝试清理中间的测试文件。这样,你的 makefile 将清理被中断的测试和调试运行留下的残余文件。^(30) 请记住,用户可能在源目录中构建。尽量让你的通配符尽可能具体,以免不小心删除源文件。

我在这里使用了 Automake 支持的clean-local目标,作为扩展clean目标的一种方式。如果存在,clean-local目标会作为依赖项(因此会在clean目标之前执行)。列表 14-17 展示了来自 Automake 生成的Makefile.in模板的相应代码,您可以看到这个基础设施是如何连接起来的。关键部分已被突出显示。

   --snip--
   clean: clean-am
➊ clean-am: clean-binPROGRAMS clean-checkPROGRAMS \
     clean-generic clean-libtool clean-local \
     clean-noinstLTLIBRARIES mostlyclean-am
   --snip--
➋ .PHONY: ... clean-local...
   --snip--
   clean-local:
           rm -rf ix2.*
           rm -rf bld.*
           rm -rf tst.bak
           rm -f *.xml
           rm -f copy-xml-files.stamp
   --snip--

列表 14-17:xflaim/util/Makefile.in: xflaim/util/Makefile.am 生成的清理规则

Automake 注意到我在Makefile.am中有一个名为clean-local的目标,因此它在➊处将clean-local添加到clean-am的依赖列表中,然后在➋处将其添加到.PHONY变量中。如果我没有编写clean-local目标,这些引用就不会出现在生成的Makefile中。

总结

好吧,这些就是基础知识。如果你跟随并理解了本章中的内容,那么你应该能够将几乎任何项目转换为使用基于 Autotools 的构建系统。有关此处涉及主题的更多细节,请参考 Autotools 手册。通常,知道一个概念的名称,以便你可以轻松地在手册或在线搜索中找到它,价值非常大。

在第十五章中,我将介绍将此项目转换的更奇特方面,包括构建 Java 和 C#代码的细节,添加特定编译器的优化标志和命令行选项,甚至使用用户定义的make目标在你的Makefile.am文件中构建 RPM 包。

第十五章:FLAIM 第二部分:突破极限

我们在大学里所做的,就是克服我们的小气思想。教育——要得到它,你必须呆在那儿,直到你能理解。

—罗伯特·李·弗罗斯特*^(1)

图片

有一个被广泛理解的原则是,无论你读了多少书,参加了多少讲座,或者在邮件列表上提了多少问题,你仍然会有一些没有答案的问题。据估计,今天全球约有一半人口可以上网。^(2) 从你的桌面上可以获取成千上万的千兆字节信息。然而,似乎每个项目都有一两个问题,足够与其他问题有所不同,即使是互联网搜索也常常无济于事。

为了减少学习 Autotools 可能带来的挫败感,本章继续进行 FLAIM 构建系统转换项目,通过解决 FLAIM 构建系统要求中一些不常见的功能来继续进行。我希望通过展示一些不常见问题的解决方案,您将能熟悉 Autotools 提供的底层框架。这样的熟悉将为您提供洞察力,使您能够将 Autotools 灵活地应用于您的独特需求。

xflaim 库提供 Java 和 C# 语言绑定。Automake 提供了构建 Java 源代码的基础支持,但目前没有内置支持构建 C# 源代码。在本章中,我将向您展示如何使用 Automake 内置的 Java 支持来构建 xflaim 中的 Java 语言绑定,然后我将向您展示如何为 C# 语言绑定编写您自己的 make 规则。

本章将通过讨论使用本地编译器选项、构建生成的文档,以及添加您自己的顶层递归 make 目标,来完成本章内容,并结束 FLAIM 转换项目。

使用 Autotools 构建 Java 源代码

GNU Automake 手册 介绍了两种构建 Java 源代码的方法。第一种是传统的、被广泛理解的方法,即将 Java 源代码编译成 Java 字节码,然后在 Java 虚拟机(JVM)中执行。第二种方法是较少人知的,通过使用 GNU 编译器工具套件的 GNU Java 编译器前端(gcj)将 Java 源代码直接编译成本地机器码。包含这些机器码的目标文件可以通过标准的 GNU 链接器链接成本地可执行程序。由于缺乏兴趣,并且由于 JVM 在多年来得到了极大的改进,GCJ 项目已经不再维护。因此,很可能所有对这一机制的支持很快会完全从 Autotools 中删除。

在本章中,我将重点讲解前者——使用 Automake 内置的 JAVA 主体从 Java 源文件构建 Java 类文件。我们还将探讨构建和安装 .jar 文件所需的扩展。

Autotools Java 支持

Autoconf 对 Java 的内置支持几乎没有,甚至可以说是没有。例如,它没有提供任何宏来定位终端用户环境中的 Java 工具。^(3) Automake 对构建 Java 类的内置支持非常有限,但如果你愿意花些时间去深入了解,使其工作并不难。最大的问题更多是概念性的,而非功能性的。你需要付出一些努力,将你对 Java 构建过程的理解与 Automake 设计者的理解对齐。

Automake 提供了一个内置的主体(JAVA)来构建 Java 源文件,但它并没有提供任何预配置的安装位置前缀来安装 Java 类。然而,通常安装 Java 类和 .jar 文件的位置是 $(datadir)/java 目录,因此,创建一个正确的前缀和使用 Automake 的前缀扩展机制,定义一个以 dir 结尾的变量就可以了,具体可以参见 Listing 15-1。

--snip--
javadir = $(datadir)/java
java_JAVA = file_a.java file_b.java ...
--snip--

Listing 15-1:在 Makefile.am 文件中定义 Java 安装目录

现在,你通常不希望安装 Java 源文件,而是希望安装 .class 文件,或者更可能是包含所有 .class 文件的 .jar 文件。这通常更有用,因此,定义 JAVA 主体时使用 noinst 前缀会更合适。此外,JAVA 主体列表中的文件默认不会分发,所以你甚至可能想使用 dist 超前缀,如 Listing 15-2 所示。

dist_noinst_JAVA = file_a.java file_b.java...

Listing 15-2:定义一个非安装的 Java 文件列表,这些文件会被分发

当你在一个包含 JAVA 主体的变量中定义 Java 源文件列表时,Automake 会生成一个 make 规则,该规则在一个命令中构建该文件列表,使用 Listing 15-3 中显示的语法。^(4)

--snip--
JAVAROOT = .
JAVAC = javac
CLASSPATH_ENV = CLASSPATH=$(JAVAROOT):$(srcdir)/$(JAVAROOT):\
  $${CLASSPATH:+":$$CLASSPATH"}
--snip--
all: all-am
--snip--
all-am: Makefile classnoinst.stamp $(DATA) all-local
--snip--
classnoinst.stamp: $(am__java_sources)
        @list1='$?'; list2=; if test -n "$$list1"; then \
        for p in $$list1; do \
          if test -f $$p; then d=; else d="$(srcdir)/"; fi; \
          list2="$$list2 $$d$$p"; \
        done; \
 ➊ echo '$(CLASSPATH_ENV) $(JAVAC) -d $(JAVAROOT) \
          $(AM_JAVACFLAGS) $(JAVACFLAGS) '"$$list2"; \
        $(CLASSPATH_ENV) $(JAVAC) -d $(JAVAROOT) \
          $(AM_JAVACFLAGS) $(JAVACFLAGS) $$list2; \
        else :; fi
     ➋ echo timestamp > $@
--snip--

Listing 15-3:这个长 shell 命令来自 Automake 生成的 Makefile 文件。

在这些命令中,你看到的大部分代码仅仅是为了将 $(srcdir) 前缀加到用户指定的 Java 源文件列表中的每个文件,以便正确支持 VPATH 构建。该代码使用 shell 的 for 语句将列表拆分为单独的文件,添加 $(srcdir) 前缀,然后重新组装列表。^(5)

实际上完成构建 Java 源文件工作的部分出现在底部附近的两行(实际上是四行折叠的行)^(6) ➊。

Automake 在➋处生成一个印记文件,因为单个$(JAVAC)命令会从.java文件生成多个.class文件。Automake 不会随机选择其中一个文件,而是生成并使用印记文件作为规则的目标,这会导致make忽略单个.class文件与其对应的.java文件之间的关系。也就是说,如果删除一个.class文件,makefile 中的规则不会导致其被重新构建。唯一能导致重新执行$(JAVAC)命令的方法是修改一个或多个.java文件,从而使它们的时间戳变得比印记文件更新,或者完全删除印记文件。

在构建环境和命令行中使用的变量包括JAVAROOTJAVACJAVACFLAGSAM_JAVACFLAGSCLASSPATH_ENV。每个变量可以在Makefile.am文件中指定。^(7) 如果未指定某个变量,则使用列表 15-3 中显示的默认值。

JAVA主变量中指定的所有.java文件将通过单个命令行编译,这在命令行长度有限的系统上可能会导致问题。如果遇到此类问题,您可以将 Java 项目拆分为多个 Java 源目录,或者编写自己的make规则来构建 Java 类。(在我讨论如何在“构建 C#源代码”一节中构建 C#代码时,第 418 页展示了如何编写这些自定义规则。)

CLASSPATH_ENV变量设置 Java CLASSPATH环境变量,使其包含$(JAVAROOT)$(srcdir)/$(JAVAROOT),以及任何可能由最终用户在环境中配置的类路径。

JAVAROOT变量用于指定项目构建树中项目的 Java 根目录的位置,Java 编译器将会在该位置找到生成的包目录层级的起点。

JAVAC变量默认包含javac,假设javac可以在系统路径中找到。AM_JAVACFLAGS变量可以在Makefile.am中设置,尽管该变量的非 Automake 版本(JAVACFLAGS)被视为用户变量,因此不应在 makefile 中设置。

这在一定程度上是可行的,但远远不够。在这个相对简单的 Java 项目中,我们仍然需要使用javah工具生成 Java 本地接口(JNI)头文件,并从 Java 源代码构建的.class文件生成.jar文件。不幸的是,Automake 提供的 Java 支持甚至无法处理这些任务,因此我们必须通过手动编写make规则来完成剩余工作。我们将首先使用 Autoconf 宏来确保我们拥有一个良好的 Java 构建环境。

使用 ac-archive 宏

GNU Autoconf Archive 提供了社区贡献的 Autoconf 宏,接近我们需要的功能,以确保我们拥有一个良好的 Java 开发环境。在这种情况下,我下载了最新的源代码包,并将我需要的.m4文件手动安装到xflaim/m4目录中。^(8)

然后我修改了这些文件(包括它们的名称),使其像我的FLM_PROG_TRY_DOXYGEN宏那样工作。我想定位任何现有的 Java 工具,但如果需要,我也希望能够继续没有这些工具的工作。虽然在过去的 10 年里情况有了很大的改善,但考虑到围绕 Java 工具在 Linux 发行版中的存在所涉及的政治问题,这种做法可能是明智的。

我在相应的 Java 相关的.m4文件中创建了以下宏:

  • FLM_PROG_TRY_JAVAC定义在flm_prog_try_javac.m4中。

  • FLM_PROG_TRY_JAVAH定义在flm_prog_try_javah.m4中。

  • FLM_PROG_TRY_JAVADOC定义在flm_prog_try_javadoc.m4中。

  • FLM_PROG_TRY_JAR定义在flm_prog_try_jar.m4中。

  • FLM_PROG_TRY_JNI定义在flm_prog_try_jni.m4中。

通过稍微多一点的努力,我也能够创建 C#宏,以完成相同的任务,处理 C#语言绑定:

  • FLM_PROG_TRY_CSC定义在flm_prog_try_csc.m4中。

  • FLM_PROG_TRY_CSVM定义在flm_prog_try_csvm.m4中。

列表 15-4 展示了 xflaim configure.ac文件中的一部分,调用了这些 Java 和 C#宏。

--snip--
# Checks for optional programs.
FLM_PROG_TRY_CSC
FLM_PROG_TRY_CSVM
FLM_PROG_TRY_JNI
FLM_PROG_TRY_JAVADOC
--snip--
# Automake conditionals.
AM_CONDITIONAL([HAVE_JAVA], [test "x$flm_prog_have_jni" = xyes])
AM_CONDITIONAL([HAVE_CSHARP], [test -n "$CSC"])
--snip--

列表 15-4: xflaim/configure.ac: 该文件中的一部分,负责查找 Java 和 C#工具

这些宏将CSCCSVMJAVACJAVAHJAVADOCJAR变量设置为它们各自的 C#和 Java 工具的位置,然后使用AC_SUBST将它们替换到 xflaim 项目的Makefile.in模板中。如果在执行configure脚本时用户的环境中已经设置了这些变量,它们的值将保持不变,从而允许用户覆盖宏原本会设置的值。

我在第十六章中讨论了这些宏的内部操作。

标准系统信息

使用来自 GNU Autoconf Archive 的宏时,你需要了解的唯一一个不太显眼的信息是,许多宏依赖于内置的 Autoconf 宏AC_CANONICAL_HOST。Autoconf 提供了一种方法,可以在宏定义之前自动扩展宏内部使用的任何宏,从而使所需的宏立即可用。然而,如果在某些宏(包括LT_INIT)之前没有使用AC_CANONICAL_HOSTautoreconf将生成大约十几个警告消息。

为了消除这些警告,我在我的 xflaim 级别的configure.ac文件中添加了AC_CANONICAL_TARGET,并将其置于AC_INIT调用之后。AC_CANONICAL_SYSTEM宏,以及它调用的宏(AC_CANONICAL_BUILDAC_CANONICAL_HOSTAC_CANONICAL_TARGET),旨在确保configure定义了适当的值,描述用户的构建、主机和目标系统,分别保存在$build$host$target环境变量中。由于我在这个构建系统中没有进行交叉编译,因此我只需要调用AC_CANONICAL_TARGET

这些变量包含构建、主机和目标 CPU、供应商及操作系统的规范值。类似这样的值对于扩展宏非常有用。如果宏可以假设这些变量已经正确设置,那么它可以节省在宏定义中重复的代码。

这些变量的值是通过辅助脚本config.guessconfig.sub计算得出的,这些脚本与 Autoconf 一起分发。config.guess脚本通过一系列uname命令来探测构建系统的信息,然后使用这些信息推导出 CPU、供应商和操作系统的规范值。config.sub脚本用于将用户在configure命令行上指定的构建、主机和目标信息重新格式化为规范值。除非你通过configure的命令行选项覆盖它们,否则主机和目标值默认为构建的值。这样的覆盖可能用于交叉编译。(有关 Autotools 框架中交叉编译的更详细说明,请参见第 517 页的“第 6 项:交叉编译”)

xflaim/java 目录结构

原始的 xflaim 源代码布局将 Java JNI 和 C#本地源代码放置在xflaim/src之外的目录结构中。JNI 源代码位于xflaim/java/jni,C#本地源代码位于xflaim/csharp/xflaim。虽然 Automake 可以生成规则来访问当前目录层次结构之外的文件,但将这些文件放置得离它们唯一真正属于的库如此遥远似乎是没有必要的。因此,在这种情况下,我打破了自己不重新安排文件的规则,并将这两个目录的内容移到了xflaim/src下。我将 JNI 目录命名为xflaim/src/java,将 C#本地源代码目录命名为xflaim/src/cs。以下图表展示了这一新的目录层次结构:

flaim
  xflaim
    src
      cs
        wrapper
      java
        wrapper
          xflaim

如你所见,我还在java目录下添加了一个wrapper目录,并在其中根植了 xflaim 包装器包层次结构。由于 Java xflaim 包装器类是 Java xflaim 包的一部分,它们必须位于名为xflaim的目录中。然而,构建过程发生在 wrapper 目录中。在wrapper/xflaim目录或其下的任何目录中都没有找到构建文件。

注意

无论你的包层次结构多深,你仍然将在wrapper* 目录中构建 Java 类,该目录就是该项目的JAVAROOT* 目录。Autotools Java 项目认为 JAVAROOT 目录是 Java 包的构建目录。*

xflaim/src/Makefile.am 文件

此时,configure.ac 文件尽其所能确保我拥有良好的 Java 构建环境,在这种情况下,我的构建系统将能够生成 JNI 包装类和头文件,并构建我的 C++ JNI 源文件。如果我的最终用户的系统未提供这些工具,他们将无法在该主机上构建或链接 JNI 语言绑定到 xflaim 库。

看一看 列表 15-5 中展示的 xflaim/src/Makefile.am 文件,检查与构建 Java 和 C# 语言绑定相关的部分。

if HAVE_JAVA
  JAVADIR = java
  JNI_LIBADD = java/libxfjni.la
endif

if HAVE_CSHARP
  CSDIR = cs
  CSI_LIBADD = cs/libxfcsi.la
endif

SUBDIRS = $(JAVADIR) $(CSDIR)
--snip--
libxflaim_la_LIBADD = $(JNI_LIBADD) $(CSI_LIBADD) $(FTK_LTLIB)
--snip--

列表 15-5:xflaim/src/Makefile.am:这个 makefile 中构建 Java 和 C# 源文件的部分

我已经解释了使用条件语句来确保只有在满足适当条件时,javacs 目录才会被构建。你现在可以看到这如何融入我到目前为止所创建的构建系统中。

请注意,我在条件中定义了两个新的库变量。如果我能构建 Java 语言绑定,java 子目录将被构建,JNI_LIBADD 变量将引用在 java 目录中构建的库。如果我能构建 C# 语言绑定,cs 子目录将被构建,CSI_LIBADD 变量将引用在 cs 目录中构建的库。在这两种情况下,如果 configure 没有找到所需的工具,对应的变量将保持未定义。当引用一个未定义的 make 变量时,它展开为空,因此在 libxflaim_la_LIBADD 中使用它没有问题。

构建 JNI C++ 源文件

现在请注意 xflaim/src/java/Makefile.am 文件,见 列表 15-6。

SUBDIRS = wrapper

XFLAIM_INCLUDE = -I$(srcdir)/..

noinst_LTLIBRARIES = libxfjni.la

libxfjni_la_SOURCES = \
 jbackup.cpp \
 jdatavector.cpp \
 jdb.cpp \
 jdbsystem.cpp \
 jdomnode.cpp \
 jistream.cpp \
 jniftk.cpp \
 jniftk.h \
 jnirestore.cpp \
 jnirestore.h \
 jnistatus.cpp \
 jnistatus.h \
 jostream.cpp \
 jquery.cpp

libxfjni_la_CPPFLAGS = $(XFLAIM_INCLUDE) $(FTK_INCLUDE)

列表 15-6:xflaim/src/java/Makefile.am:这个 makefile 构建 JNI 源文件。

再次强调,我希望 wrapper 目录在 xflaim 库之前先被构建(SUBDIRS 列表末尾的点是隐含的),因为 wrapper 目录将构建 JNI 便利库源文件所需的类文件和 JNI 头文件。构建这个目录不是条件性的。如果我已经到达构建层次结构的这一部分,我知道我拥有所有需要的 Java 工具。这个 Makefile.am 文件仅构建一个包含我的 JNI C++ 接口函数的便利库。

由于 Libtool 以相同的源代码构建共享库和静态库,因此这个便利库将成为 xflaim 共享库和静态库的一部分。原始的构建系统 makefile 已通过仅将 JNI 和 C# 本地接口对象链接到共享库中(在那里它们是有意义的)来考虑这一点。

注意

这些库被添加到共享和静态xflaim库中并不是一个真正的问题。只要这些对象中的函数和数据没有被引用,静态库中的对象在应用程序或链接到静态库的库中就不会被使用,尽管这在我新的构建系统中有些瑕疵。

Java 包装类和 JNI 头文件

最后,xflaim/src/java/wrapper/Makefile.am带我们进入了问题的核心。我尝试过许多不同的配置来构建 Java JNI 包装器,而这个配置总是表现得最为出色。列表 15-7 展示了包装目录的 Automake 输入文件。

   JAVAROOT = .

➊ jarfile = $(PACKAGE)jni-$(VERSION).jar
➋ jardir = $(datadir)/java
   pkgpath = xflaim
   jhdrout = ..

   $(jarfile): $(dist_noinst_JAVA)
           $(JAR) cvf $(JARFLAGS) $@ $(pkgpath)/*.class

➌ jar_DATA = $(jarfile)

   java-headers.stamp: $(classdist_noinst.stamp)
           @list=`echo $(dist_noinst_JAVA) | sed -e 's|\.java||g' -e 's|/|.|g'`;\
             echo "$(JAVAH) -cp . -jni -d $(jhdrout) $(JAVAHFLAGS) $$list"; \
             $(JAVAH) -cp . -jni -d $(jhdrout) $(JAVAHFLAGS) $$list
        ➍ @echo "JNI headers generated" > java-headers.stamp

➎ all-local: java-headers.stamp

➏ CLEANFILES = $(jarfile) $(pkgpath)/*.class java-headers.stamp\
    $(jhdrout)/xflaim_*.h

   dist_noinst_JAVA = \
    $(pkgpath)/BackupClient.java \
    $(pkgpath)/Backup.java \
    --snip--
    $(pkgpath)/XFlaimException.java \
    $(pkgpath)/XPathAxis.java

列表 15-7:xflaim/src/java/wrapper/Makefile.am:包装目录的Makefile.am文件

在文件的顶部,我将JAVAROOT变量设置为点(.),因为我希望 Automake 能够告诉 Java 编译器这里是包层次结构的起始点。JAVAROOT的默认值是$(top_builddir),这将错误地使包装类属于xflaim.src.java.wrapper.xflaim包。

我在➊创建了一个名为jarfile的变量,其值来自$(PACKAGE``_TARNAME)$(PACKAGE_VERSION)。 (回想一下第三章中,distdir变量的值也是这样得出的,从中得到了 tarball 的名称。)一个make规则指明了.jar文件应该如何构建。这里,我使用的是JAR变量,其值由configure脚本中的FLM_PROG_TRY_JNI宏计算得出。

我在➋定义了一个新的安装变量jardir,用于指定.jar文件的安装位置,并在➌处将该变量用作DATA主项的前缀。Automake 认为符合 Automake where_HOW模式(带有定义的wheredir)的文件,要么是架构独立的数据文件,要么是平台特定的可执行文件。以binsbinlibexecsysconflocalstatelibpkglib开头,或包含“exec”字符串的安装位置变量(以dir结尾)被视为平台特定的可执行文件,并在执行install-exec目标时安装。Automake 认为安装在其他位置的文件是数据文件,并在执行install-data目标时进行安装。诸如bindirsbindir等常见的安装位置已经被占用,但如果你想安装自定义的依赖架构的可执行文件,只需确保你的自定义安装位置变量包含“exec”字符串,如myspecialexecdir

我在➍使用另一个时间戳文件,在规则中从.class文件生成 JNI 头文件,原因与 Automake 在规则中使用时间戳文件来从.java源文件生成.class文件相同。

这是这个 Makefile 中最复杂的部分,所以我将其拆分成更小的部分。

该规则声明,stamp 文件依赖于dist_noinst_JAVA变量中列出的源文件。该命令是一个复杂的 Shell 脚本,它会从文件列表中剥离.java扩展名,并将所有的斜杠字符转换为点号。这样做的原因是javah工具需要的是类名列表,而不是文件名列表。$(JAVAH)命令接受这个完整的列表作为输入,以生成相应的 JNI 头文件列表。当然,最后一行会生成 stamp 文件。

最后,在➎处,我将java-headers.stamp目标与all目标挂钩,通过将它作为依赖项添加到all-local目标中。当在此 makefile 中执行all目标(所有 Automake 生成的 makefile 的默认目标)时,java-headers.stamp将与 JNI 头文件一起构建。

注意

最好将自定义规则目标作为依赖项添加到 Automake 提供的钩子和本地目标中,而不是直接将命令与这些钩子和本地目标关联。这样,单个任务的命令将保持独立,从而更易于维护。

我将.jar文件、所有.class文件、java-headers.stamp文件以及所有生成的 JNI 头文件添加到CLEANFILES变量中(➏),以便在执行make clean时,Automake 会将它们清理掉。再次强调,我可以在这里使用CLEANFILES变量,因为我并不打算删除任何目录。

编写任何自定义代码的最后一步是确保distcheck目标仍然有效,因为当我们生成自己的产品时,必须确保clean目标能够正确地删除它们。

最后,我应该提到,构建.jar文件的规则(在列表 15-7 顶部附近)依赖于通配符来选取xflaim目录中的所有.class文件。Autotools 故意避免使用此类通配符,原因有很多,其中一个非常合理的原因是你可能会无意中选中那些由先前构建生成、但在更改后不再与项目相关的文件,因为这些源文件已从项目中删除。对于 Java,指定应放入.jar文件中的确切.class文件的唯一方法是解析所有.java文件,并生成一个由这些源文件构建出的.class文件的列表。我在这里做出了一个判断,决定使用通配符值得冒着可能会引起的问题。我还在列表 15-7 底部的CLEANFILES变量中使用了通配符。当然,这里也存在相同的潜在问题——你可能会删除一个文件,而这个文件目前存在,但已不再与构建相关。

关于使用 JAVA 主目标的警告

使用 JAVA 主变量时,有一个重要的警告,那就是每个 Makefile.am 文件中只能定义一个 JAVA 主变量。其原因在于,一个 .java 文件可能会生成多个类,而要知道哪些类是由哪个 .java 文件生成的,唯一的办法就是让 Automake 解析 .java 文件(这显然不现实,并且也是像 Apache AntMaven 这样的构建工具被开发出来的主要原因)。为了避免这样做,Automake 只允许每个文件定义一个 JAVA 主变量,因此所有在给定构建目录中生成的 .class 文件都会安装到由单个 JAVA 主变量前缀指定的位置。^(10)

注意

我设计的系统在这种情况下能很好地工作,但好在我不需要安装我的 JNI 头文件,因为我无法从我的 Makefile.am 文件中知道它们叫什么!

到现在为止,你应该能看到 Autotools 在处理 Java 时遇到的问题。事实上,这些问题更多是与 Java 语言本身的设计问题相关,而不是 Autotools 设计中的问题,正如你将在下一节中看到的。

构建 C# 源代码

回到 xflaim/src/cs 目录,我们将讨论如何为 Automake 不支持的语言构建源代码:C#。清单 15-8 显示了我为 cs 目录编写的 Makefile.am 文件。

SUBDIRS = wrapper

XFLAIM_INCLUDE = -I$(srcdir)/..

noinst_LTLIBRARIES = libxfcsi.la

libxfcsi_la_SOURCES = \
 Backup.cpp \
 DataVector.cpp \
 Db.cpp \
 DbInfo.cpp \
 DbSystem.cpp \
 DbSystemStats.cpp \
 DOMNode.cpp \
 IStream.cpp \
 OStream.cpp \
 Query.cpp

libxfcsi_la_CPPFLAGS = $(XFLAIM_INCLUDE) $(FTK_INCLUDE)

清单 15-8:xflaim/src/cs/Makefile.am:cs 目录的 Automake 输入文件内容

毫不奇怪,这看起来与 xflaim/src/java 目录中的 Makefile.am 文件几乎相同,因为我正在从该目录中的 C++ 源文件构建一个简单的便捷库,正如我在 java 目录中所做的那样。与 Java 版本一样,这个 makefile 首先会构建一个名为 wrapper 的子目录。

清单 15-9 显示了 wrapper/Makefile.am 文件的完整内容。

   EXTRA_DIST = xflaim cstest sample xflaim.ndoc

   xfcs_sources = \
    xflaim/BackupClient.cs \
    xflaim/Backup.cs \
    --snip--
    xflaim/RestoreClient.cs \
    xflaim/RestoreStatus.cs

   cstest_sources = \
    cstest/BackupDbTest.cs \
    cstest/CacheTests.cs \
    --snip--
    cstest/StreamTests.cs \
    cstest/VectorTests.cs

   TESTS = cstest_script

   AM_CSCFLAGS = -d:mono -nologo -warn:4 -warnaserror+ -optimize+
   #AM_CSCFLAGS += -debug+ -debug:full -define:FLM_DEBUG

➊ all-local: xflaim_csharp.dll

   clean-local:
           rm -f xflaim_csharp.dll xflaim_csharp.xml cstest_script\
             cstest.exe libxflaim.so
           rm -f Output_Stream
           rm -rf abc backup test.*

   install-exec-local:
           test -z "$(libdir)" || $(MKDIR_P) "$(DESTDIR)$(libdir)"
           $(INSTALL_PROGRAM) xflaim_csharp.dll "$(DESTDIR)$(libdir)"

   install-data-local:
           test -z "$(docdir)" || $(MKDIR_P) "$(DESTDIR)$(docdir)"
           $(INSTALL_DATA) xflaim_csharp.xml "$(DESTDIR)$(docdir)"

   uninstall-local:
           rm -f "$(DESTDIR)$(libdir)/xflaim_csharp.dll"
           rm -f "$(DESTDIR)$(docdir)/xflaim_csharp.xml"

➋ xflaim_csharp.dll: $(xfcs_sources)
           @list1='$(xfcs_sources)'; list2=; if test -n "$$list1"; then \
             for p in $$list1; do \
               if test -f $$p; then d=; else d="$(srcdir)/"; fi; \
               list2="$$list2 $$d$$p"; \
             done; \
             echo '$(CSC) -target:library $(AM_CSCFLAGS) $(CSCFLAGS) -out:$@\
               -doc:$(@:.dll=.xml) '"$$list2";\
             $(CSC) -target:library $(AM_CSCFLAGS) $(CSCFLAGS) \
               -out:$@ -doc:$(@:.dll=.xml) $$list2; \
           else :; fi

   check_SCRIPTS = cstest.exe cstest_script

➌ cstest.exe: xflaim_csharp.dll $(cstest_sources)
          @list1='$(cstest_sources)'; list2=; if test -n "$$list1"; then \
             for p in $$list1; do \
               if test -f $$p; then d=; else d="$(srcdir)/"; fi; \
               list2="$$list2 $$d$$p"; \
             done; \
             echo '$(CSC) $(AM_CSCFLAGS) $(CSCFLAGS) -out:$@ '"$$list2"'\
               -reference:xflaim_csharp.dll'; \
             $(CSC) $(AM_CSCFLAGS) $(CSCFLAGS) -out:$@ $$list2 \
               -reference:xflaim_csharp.dll; \
          else :; fi

➍ cstest_script: cstest.exe
           echo "#!/bin/sh" > cstest_script
           echo "$(top_builddir)/libtool --mode=execute \
           ➎ -dlopen=../../libxflaim.la $(CSVM) cstest.exe" >> cstest_script
           chmod 0755 cstest_script

清单 15-9:xflaim/src/cs/wrapper/Makefile.am:C# makefile 的完整内容

Makefile.am 的默认目标是 all,与普通的非-Automake makefile 相同。同样,我通过实现 all-local 目标将我的代码挂钩到 all 目标,该目标依赖于名为 xflaim_csharp.dll 的文件。^(11)

C# 源文件通过 ➋ 处的 xflaim_csharp.dll 目标下的命令进行构建,而 xflaim_csharp.dll 二进制文件依赖于 xfcs_sources 变量中指定的 C# 源文件列表。此规则中的命令是从 Automake 生成的 java/wrapper/Makefile 中复制的,并经过稍微修改,以便从 C# 源文件构建 C# 二进制文件(如清单中所示)。这里的重点不是讲解如何构建 C# 源文件;重点在于通过在 ➊ 处创建 all-local 目标与您自己目标之间的依赖关系,默认目标会被自动构建。

这个Makefile.am文件还构建了一套用于评估 C#语言绑定的单元测试。该规则的目标是cstest.exe(➌),最终成为一个 C#可执行文件。规则说明,cstest.exe依赖于xflaim_csharp.dll和源文件。我再次复制了构建xflaim_csharp.dll的规则中的命令(如高亮显示),并对其进行了修改以构建 C#程序。

最终,在构建check目标时,Automake 生成的 makefile 将尝试执行TESTS变量中列出的脚本或可执行文件。这里的目的是确保在执行这些文件之前,所有必要的组件都已经构建完成。我通过定义check-local并使其依赖于我的测试代码目标,来将其与check目标关联起来。

➎处的cstest_script是一个仅用于在 C#虚拟机中执行* cstest.exe*二进制文件的 Shell 脚本。C#虚拟机位于CSVM变量中,该变量由FLM_PROG_TRY_CSVM宏生成的configure代码定义。

cstest_script仅依赖于cstest.exe程序。然而,xflaim库要么必须存在于当前目录中,要么必须在系统库搜索路径中。在这里,我们通过使用 Libtool 的execute模式,在执行 C#虚拟机之前将xflaim库添加到系统库搜索路径中,从而实现最大的可移植性。

手动安装

由于在这个示例中我自己完成所有工作,因此我必须编写自己的安装规则。清单 15-10 仅复制了清单 15-9 中的Makefile.am文件中的安装规则。

--snip--
install-exec-local:
        test -z "$(libdir)" || $(MKDIR_P) "$(DESTDIR)$(libdir)"
        $(INSTALL_PROGRAM) xflaim_csharp.dll "$(DESTDIR)$(libdir)"

install-data-local:
        test -z "$(docdir)" || $(MKDIR_P) "$(DESTDIR)$(docdir)"
        $(INSTALL_DATA) xflaim_csharp.xml "$(DESTDIR)$(docdir)"

uninstall-local:
        rm -f "$(DESTDIR)$(libdir)/xflaim_csharp.dll"
        rm -f "$(DESTDIR)$(docdir)/xflaim_csharp.xml"
--snip--

清单 15-10:xflaim/src/cs/wrapper/Makefile.am:该 makefile 的安装规则

根据GNU 编码标准中定义的规则,安装目标不依赖于它们所安装的二进制文件,因此,如果二进制文件尚未构建,我可能需要退出root账户,切换到我的用户账户并先使用make all构建二进制文件。

Automake 区分安装程序和安装数据。然而,只有一个uninstall目标。其基本原理似乎是,你可能希望在网络中的每台系统上执行install-exec操作,但只需进行一次共享的install-data操作。卸载产品时不需要这种区分,因为卸载数据多次通常是无害的。

再次清理

和往常一样,必须正确清理文件,以便使分发检查通过。clean-local目标很好地处理了这一点,如清单 15-11 所示。

--snip--
clean-local:
        rm -f xflaim_csharp.dll xflaim_csharp.xml cstest_script \
          cstest.exe libxflaim.so
        rm -f Output_Stream
        rm -rf abc backup test.*
--snip--

清单 15-11:xflaim/src/cs/wrapper/Makefile.am:该 makefile 中定义的清理规则

配置编译器选项

原始的 GNU Make 构建系统提供了许多命令行构建选项。通过在make命令行中指定一系列辅助目标,用户可以指示他们想要调试或发布构建、在 64 位系统上强制进行 32 位构建、在 Solaris 系统上生成通用的 SPARC 代码等等。这是一种即插即用的构建系统方法,在商业代码中非常常见。

在开源项目中,尤其是在基于 Autotools 的构建系统中,更常见的做法是省略这些固定的框架,允许用户在标准用户变量中设置自己的选项:CCCPPCXXCFLAGSCXXFLAGSCPPFLAGS等。^(12)

可能对于 Autotools 方法管理选项最有力的论点是,它是政策驱动的,商业软件供应商使用的固定框架可以很容易地通过更加灵活的政策驱动的 Autotools 框架来实现。例如,config.site文件可以用于为特定站点上进行的所有基于 Autotools 的构建提供站点范围的选项。在调用configure之前,可以使用一个简单的脚本来配置各种基于环境的选项,或者这些选项甚至可以在这样的脚本中直接传递给configuremake。Autotools 的政策驱动方法提供了灵活性,可以根据开发人员的需求进行配置,或者根据管理层的要求进行严格限制。

最终,我们希望 FLAIM 项目选项符合 Autotools 政策驱动的方法;然而,我不想失去确定原始 makefile 中硬编码本地编译器选项所涉及的研究工作。为此,我已经将部分选项添加回了configure.ac文件,这些选项是原始构建系统所支持的,但我也有一些选项没有添加。 清单 15-12 显示了这些努力的最终结果。此代码根据一些用户变量的内容,按需启用各种本地编译器选项、优化和调试功能。

   --snip--
   # Configure supported platforms' compiler and linker flags
➊ case $host in
     sparc-*-solaris*)
       LDFLAGS="$LDFLAGS -R /usr/lib/lwp"
       case $CXX in
         *g++*) ;;
         *)
           if "x$debug" = xno; then
             CXXFLAGS="$CXXFLAGS -xO3"
           fi
           SUN_STUDIO=`$CXX -V | grep "Sun C++"`
           if "x$SUN_STUDIO" = "xSun C++"; then
             CXXFLAGS="$CXXFLAGS -errwarn=%all -errtags\
               -erroff=hidef,inllargeuse,doubunder"
           fi ;;
     esac ;;

   *-apple-darwin*)
     AC_DEFINE([OSX], [1], [Define if building on Apple OSX.]) ;;

   *-*-aix*)
     case $CXX in
       *g++*) ;;
       *) CXXFLAGS="$CXXFLAGS -qstrict" ;;
     esac ;;

   *-*-hpux*)
     case $CXX in
       *g++*) ;;
       *)
         # Disable "Placement operator delete
 # invocation is not yet implemented" warning
         CXXFLAGS="$CXXFLAGS +W930" ;;
     esac ;;
  esac
  --snip--

清单 15-12:xflaim/configure.ac:此文件中启用特定编译器选项的部分

请记住,这段代码依赖于之前使用的AC_CANONICAL_SYSTEM(或AC_CANONICAL_TARGET)宏,该宏将buildhosttarget环境变量设置为规范的字符串值,以指示 CPU、供应商和操作系统。

在 清单 15-12 中,我在 ➊ 的 case 语句中使用了 host 变量来确定我为其构建的系统类型。这个 case 语句通过查找 host 中的子字符串来判断用户是否在 Solaris、Apple Darwin、AIX 或 HP-UX 上构建,这些子字符串在这些平台的所有变体中都是共同的。config.guessconfig.sub 文件是你在这里的好帮手。如果你需要为你的项目编写类似的代码,可以检查这些文件,找出你希望为其设置各种编译器和链接器选项的进程和系统的共同特征。

注意

在这些情况下(除了在 Apple Darwin 系统上定义 OSX 预处理器变量的情况),我实际上只是为本地编译器设置标志。GNU 编译器工具似乎能够处理任何代码,而无需额外的编译器选项。在这里值得再次强调的是,Autotools 的特性-存在方法设置选项再次胜出。当你不必为一个不断增长的支持主机和工具集列表支持大量的 case 语句时,维护工作会大大减少。

将 Doxygen 集成到构建过程中

我希望在构建过程中生成文档,如果可能的话。也就是说,如果用户已经安装了 doxygen,构建系统将使用它作为 make all 过程的一部分来生成 Doxygen 文档。

原始的构建系统同时有静态文档和生成的文档。静态文档应始终安装,但只有在主机上可用 doxygen 程序时,Doxygen 文档才可以构建。因此,我始终构建 docs 目录,但我使用 AM_CONDITIONAL 宏来有条件地构建 docs/doxygen 目录。

Doxygen 使用配置文件(通常称为 doxyfile)来配置数百个 Doxygen 选项。这个配置文件包含一些配置脚本已知的信息。这听起来像是一个使用 Autoconf 生成的文件的绝佳机会。为此,我编写了一个名为 doxyfile.in 的 Autoconf 模板文件,包含了一个正常的 Doxygen 输入文件会包含的大部分内容,并且有一些 Autoconf 替换变量引用。此文件中的相关行如 清单 15-13 所示。

--snip--
PROJECT_NAME                = @PACKAGE_NAME@
--snip--
PROJECT_NUMBER              = @PACKAGE_VERSION@
--snip--
STRIP_FROM_PATH             = @top_srcdir@
--snip--
INPUT                       = @top_srcdir@/src/xflaim.h
--snip--

清单 15-13:xflaim/docs/doxygen/doxyfile.in:此文件中包含 Autoconf 变量的行

这个文件中还有很多其他行,但它们与输出文件完全相同,所以为了节省空间和提高清晰度,我省略了它们。这里的关键是 config.status 会将这些替换变量替换为它们在 configure.ac 中定义的值,并由 Autoconf 本身定义。如果这些值在 configure.ac 中发生变化,生成的文件将会使用新值重新写入。我在 xflaim 的 configure.ac 文件中为 xflaim/docs/doxygen/doxyfile 添加了一个条件引用到 AC_CONFIG_FILES 列表中。就这么简单。

清单 15-14 显示了xflaim/docs/doxygen/Makefile.am文件。

➊ docpkg = $(PACKAGE_TARNAME)-doxy-$(PACKAGE_VERSION).tar.gz

➋ doc_DATA = $(docpkg)

➌ $(docpkg): doxygen.stamp
           tar chof - html | gzip -9 -c >$@

   doxygen.stamp: doxyfile
           $(DOXYGEN) $(DOXYFLAGS) $<
           echo Timestamp > $@

➍ install-data-hook:
           cd $(DESTDIR)$(docdir) && tar xf $(docpkg)

   uninstall-data-hook:
           cd $(DESTDIR)$(docdir) && rm -rf html

➎ CLEANFILES = doxywarn.txt doxygen.stamp $(docpkg)

   clean-local:
           rm -rf html

清单 15-14:xflaim/docs/doxygen/Makefile.am:此 Makefile 的完整内容

在这里,我在➊为包含 Doxygen 文档文件的 tar 包创建了一个包名称。这基本上与 xflaim 项目的分发 tar 包相同,只不过包名称后包含-doxy

我在➋定义了一个doc_DATA变量,该变量包含 Doxygen tar 包的名称。该文件将安装到$(docdir)目录,默认情况下是$(datarootdir)/doc/$(PACKAGE_TARNAME),而$(datarootdir)由 Automake 配置为$(prefix)/share,默认情况下如此。

注意

The DATA 主目标带来了显著的 Automake 功能——安装由系统自动管理。虽然我必须构建 Doxygen 文档包,但 DATA *主目标自动为我挂钩all目标,这样当用户执行makemake all时,我的包就会被构建。

我在➌使用另一个印章文件,因为 Doxygen 会从我的项目中的源文件生成数百个.html文件。与其尝试找出一个合理的方法来分配依赖关系,我选择生成一个印章文件,然后使用它来判断文档是否过时。^(13)

我还决定,将文档归档解压到包的doc目录会很不错。如果仅依赖 Automake,tar 包会在安装时被放入正确的目录,但仅此而已。我需要能够挂钩安装过程来完成这一操作,而这正是 Automake -hook目标的完美应用。我在➍使用install-data-hook目标,因为-hook目标允许你在被挂钩操作完成后执行额外的用户定义的 Shell 命令。同样,我使用uninstall-hook来删除在安装过程中提取.tar文件时创建的html目录。(卸载平台特定文件和平台无关文件之间没有区别,因此卸载文件时只有一个钩子。)

为了清理我生成的文件,我使用了➎处的CLEANFILES变量和一个clean-local规则,只是为了演示它可以被完成。

添加非标准目标

添加一个新的非标准目标与挂钩现有目标略有不同。首先,你不需要使用 AM_CONDITIONAL 和其他 Autoconf 测试来检查是否拥有所需的工具。相反,你可以直接从 Makefile.am 文件中进行所有条件测试,因为你控制与目标相关的整个命令集,尽管这并不是推荐的做法。(最好从 configure 脚本确保构建环境配置正确。)在某些情况下,如果 make 目标只能在特定条件下或特定平台上工作,最好在目标中提供检查,确保请求的操作实际上可以执行。

一开始,我在每个项目的根目录下创建了一个名为 obs 的目录,用来存放构建 RPM 包文件的 Makefile.am 文件。(OBSopenSUSE Build Service 的缩写,一个在线包构建服务。)^(14)

构建 RPM 包文件是通过一个配置文件完成的,这个配置文件叫做 spec 文件,它非常类似于用于为特定项目配置 Doxygen 的 doxyfile。与 doxyfile 一样,RPM spec 文件引用了 configure 知道的包信息。因此,我编写了一个 xflaim.spec.in 文件,在适当的地方添加了替换变量,然后将另一个文件引用添加到 AC_CONFIG_FILES 宏中。这使得 configure 可以将项目的信息替换到 spec 文件中。清单 15-15 显示了 xflaim.spec.in 文件中的相关部分。

Name: @PACKAGE_TARNAME@
BuildRequires: gcc-c++ libstdc++-devel flaimtk-devel gcc-java gjdoc fastjar
mono-core doxygen
Requires: libstdc++ flaimtk mono-core java >= 1.4.2
Summary: XFLAIM is an XML database library.
URL: http://sourceforge.net/projects/flaim/
Version: @PACKAGE_VERSION@
Release: 1
License: GPL
Vendor: Novell, Inc.
Group: Development/Libraries/C and C++
Source: %{name}-%{version}.tar.gz
BuildRoot: %{_tmppath}/%{name}-%{version}-build
--snip--

清单 15-15:x flaim/obs/xflaim.spec.in:此文件中展示了如何使用 Autoconf 变量的部分

请注意在这个清单中使用了变量 @PACKAGE_TARNAME@@PACKAGE_VERSION@。虽然在这个项目的生命周期中,tar 文件名不太可能发生变化,但版本号会经常变化。如果没有 Autoconf 替换机制,每当我更新 configure.ac 文件中的版本时,我必须记得更新这个版本号。清单 15-16 展示了 xflaim/obs/Makefile.am 文件,它实际上完成了构建 RPM 的工作。

   rpmspec = $(PACKAGE_TARNAME).spec

   rpmmacros =\
    --define="_rpmdir $${PWD}"\
    --define="_srcrpmdir $${PWD}"\
    --define="_sourcedir $${PWD}/.."\
    --define="_specdir $${PWD}"\
    --define="_builddir $${PWD}"
   RPMBUILD = rpmbuild
   RPMFLAGS = --nodeps --buildroot="$${PWD}/_rpm"

➊ rpmcheck:
           if ! ($(RPMBUILD) --version) >/dev/null 2>&1; then \
             echo "*** This make target requires an rpm-based Linux
   distribution."; \
             (exit 1); exit 1; \
           fi

   srcrpm: rpmcheck $(rpmspec)
           $(RPMBUILD) $(RPMFLAGS) -bs $(rpmmacros) $(rpmspec)

   rpms: rpmcheck $(rpmspec)
           $(RPMBUILD) $(RPMFLAGS) -ba $(rpmmacros) $(rpmspec)

   .PHONY: rpmcheck srcrpm rpms

清单 15-16:xflaim/obs/Makefile.am:该 makefile 的完整内容

构建 RPM 包非常简单,正如你所看到的。这个 makefile 提供的目标包括 srcrpmrpms。➊ 处的 rpmcheck 目标在内部使用,用于验证 RPM 是否可以在最终用户的环境中构建。

要查明低级别 Makefile.am 文件中哪些目标被顶级构建支持,请查看顶级 Makefile.am 文件。如 清单 15-17 所示,如果目标没有传递下来,那么该目标必须仅用于内部,在低级目录中。

--snip--
RPM = rpm

rpms srcrpm: dist
     ➊ (cd obs && $(MAKE) $(AM_MAKEFLAGS) $@) || exit 1
        rpmarch=`$(RPM) --showrc | grep "^build arch" | \
          sed 's/\(.*: \)\(.*\)/\2/'`; \
        test -z "obs/$$rpmarch" || \
          ( mv obs/$$rpmarch/* . && rm -rf /obs/$$rpmarch )
        rm -rf obs/$(distdir)
--snip--
.PHONY: srcrpm rpms

清单 15-17:xflaim/Makefile.am:如果目标没有传递下来,它就是一个内部目标。

正如你从清单 15-17 中➊处的命令可以看到,当用户从顶级构建目录中选择rpmssrcrpm时,命令会递归传递给obs/Makefile。其余的命令则只是删除 RPM 构建过程中留下的垃圾文件,这些垃圾文件在这一层次上更容易清除。(有机会构建一次 RPM 包,你就会明白我是什么意思!)

还要注意,这两个顶级 makefile 目标都依赖于dist目标,因为 RPM 构建过程需要分发的 tarball。将 tarball 添加为rpms目标的依赖项,可以确保在rpmbuild工具需要它时,分发 tarball 已经存在。

总结

在使用 Autotools 时,你需要管理许多细节——大多数细节,如开源软件界所说,可以等到下一次发布再处理!即使我将这段代码提交到 FLAIM 项目的代码库时,我也注意到有一些细节是可以改进的。这里的关键教训是,构建系统永远不可能完成。它应该随着时间推移而逐步改进,利用你时间表中的空闲时间进行优化。而且,做到这一点是很有回报的。

我已经向你展示了许多本书前面章节没有涉及到的新特性,当然还有许多更多的特性是本书无法涵盖的。要真正精通,建议你阅读 Autotools 的手册。到现在为止,你应该能够轻松地自己去获取这些额外的信息。

第十六章:使用 M4 宏处理器与 Autoconf

当你将一个复杂的想法拆解成小步骤,甚至傻乎乎的机器都能处理时,你自己也已经对这个想法有了深入的理解。

道格拉斯·亚当斯,《 Dirk Gently 的全能侦探社》

Image

M4 宏处理器易于使用,但难以理解。简单之处在于它只做一件事,而且做得非常好。我敢打赌,你或我只需几个小时就能用 C 程序编写 M4 的基本功能。与此同时,M4 的两个方面使得它很难立即理解。

首先,M4 在处理输入文本时引入的特殊情况使得很难立即掌握它的所有规则,尽管这些复杂性通过时间、耐心和实践可以轻松掌握。其次,M4 的基于栈的先序递归文本处理模型对于人类思维来说难以理解。人类倾向于广度优先处理信息,一次理解问题或数据集的完整层次,而 M4 以深度优先的方式处理文本。

本章涵盖了我认为编写 Autoconf 输入文件所需的最基本知识。我无法在本书的单章中充分介绍 M4,因此我将只讲解一些重点内容。欲了解更多细节,请阅读GNU M4 手册。^(1) 如果你已经有一些 M4 的经验,可以尝试手册中的例子,然后试着自己解决一些文本问题。通过少量的实验,你对 M4 的理解将会大大提升。

M4 文本处理

像许多其他经典的 Unix 工具一样,M4 是作为标准输入/输出(stdio)过滤器编写的。也就是说,它接受来自标准输入(stdin)的输入,处理后将其发送到标准输出(stdout)。输入文本以字节流的形式读取,并在处理前转换为标记。标记包括注释、名称、引用字符串和不属于注释、名称或引用字符串的单个字符。

默认的引用字符是反引号(`)和单引号(')。^(2) 使用反引号开始一个引用字符串,用单引号字符结束一个引用字符串:

`A quoted string'

M4 注释类似于引用字符串,每个注释都被当作一个单独的标记处理。每个注释由一个井号(#)和换行符(\n)限定。因此,所有跟在未引用的井号之后的文本,直到下一个换行符为止,都被视为注释的一部分。

注释不会像在其他编程语言的预处理器中那样从输出中删除,例如 C 语言的预处理器。相反,它们会被直接传递而不进行进一步处理。

以下示例包含五个标记:一个名称标记,一个空格字符标记,另一个名称标记,第二个空格字符标记,最后是一个单独的注释标记:

Two names # followed by a comment

名称是由字母、数字和下划线字符组成的任何序列,且不能以数字开头。因此,下面示例的第一行包含两个数字字符令牌,后面跟着一个名称令牌,而第二行仅包含一个名称令牌:

88North20th_street
_88North20th_street

请注意,空白字符(水平和垂直制表符、换页符、回车符、空格和换行符)不是名称的一部分,因此空白字符可能(并且通常会)作为名称或其他令牌的分隔符。然而,这些空白分隔符不会像计算机语言编译器的解析器那样被 M4 丢弃。它们会被直接从输入流传递到输出流,而不做进一步修改。

定义宏

M4 提供了多种内置宏,其中许多对正确使用此工具至关重要。例如,如果 M4 不提供定义宏的方式,获取任何有用的功能将非常困难。M4 的宏定义宏叫做 define

define 宏很简单,可以这样描述:

define(macro[, expansion])

define 宏至少需要一个参数,即使它是空的。如果你只提供一个参数,那么在输入文本中找到的宏名称实例将会被简单地删除:

   $ m4
   define(`macro')

   Hello macro world!
➊ Hello   world!
  <ctrl-d>$

请注意在 ➊ 处的输出文本中,Helloworld! 之间有两个空格。除了映射到已定义宏的名称外,所有其他令牌都会从输入流传递到输出流而不做修改,只有一个例外:每当从输入流中读取任何引用的文本(评论外),M4 会移除一层引号。

define 宏的另一个微妙之处是它的展开结果是空字符串。因此,前面定义的输出仅仅是输入字符串中定义后的回车符。

当然,名称是宏展开的候选项。如果在符号表中找到一个名称令牌,它将被宏展开所替代,如下例所示:

   $ m4
➊ define(`macro', `expansion')
➋
   macro ``quoted' macro text'
➌ expansion `quoted' macro text
   <ctrl-d>$

第二行输出在 ➌ 处显示,第一个令牌(名称 macro)被展开,围绕 ``quoted' macro text' 的外部引号被 M4 移除。宏定义后的空行 ➋ 是我在按下回车键后输入流中添加的换行符。由于这个换行符不是宏定义的一部分,M4 会直接将其传递到输出流中。当在输入文本中定义宏时,这可能会成为一个问题,因为你可能会在输出文本中得到一大堆空行,每个宏定义都会生成一个空行。幸运的是,有办法解决这个问题。例如,我可以简单地不输入那个换行符,如下所示:

$ m4
define(`macro', `expansion')macro
expansion
<ctrl-d>$

这解决了问题,但不需要天才也能看出,这可能会导致一些可读性问题。如果你不得不以这种方式定义宏,以免它们影响输出文本,那么你的输入文本中可能会有一些连贯的句子!

作为该问题的解决方案,M4 提供了另一个内置宏,称为dnl,^(3),它会导致所有输入文本直到并包括下一个换行符被丢弃。通常会在configure.ac中看到使用dnl,但在 Autoconf 处理configure.ac文件时,在.m4宏定义文件中使用它更为常见。

这是dnl正确使用的示例:

$ m4
define(`macro', `expansion')dnl
macro
expansion
<ctrl-d>$

有几十个内置的 M4 宏,它们提供了在 M4 中无法通过其他方式获得的功能。一些宏重新定义了 M4 中的基本行为。

例如,changequote宏用于将默认的引号字符从反引号和单引号更改为你想要的任何字符。Autoconf 在输入流的顶部附近使用类似以下的行,将 M4 的引号更改为左方括号和右方括号字符:

changequote(`[',`]')dnl

为什么 Autoconf 的设计者要这样做呢?嗯,在 Shell 代码中,经常会遇到不匹配的单引号对。在 Shell 代码中,反引号和单引号常用于使用相同字符同时开始和结束表达式的情况。这会让 M4 感到困惑,因为 M4 需要打开和关闭的引号字符彼此区分,以便正确处理其输入流。你可能还记得在第四章中提到,Autoconf 的输入文本是 Shell 脚本,这意味着 Autoconf 在读取每个输入文件时,很可能会遇到不匹配的 M4 引号对。这可能会导致非常难以追踪的错误,因为这些错误更多与 M4 相关,而与 Autoconf 无关。而输入的 Shell 脚本包含不匹配的方括号字符的可能性要小得多。

带参数的宏

宏也可以定义为接受参数,这些参数可以通过$1$2$3等在展开的文本中引用。传递的参数数量可以通过变量$#找到,而$@可以用来将一个宏调用的所有参数传递给另一个宏。当在宏调用中使用参数时,宏名称与左括号之间不能有空格。以下是一个定义并以各种方式调用的宏示例:

   $ m4
   define(`with2args', `The $# arguments are $1 and $2.')dnl
➊ with2args
   The 0 arguments are  and .
   with2args()
   The 1 arguments are  and .
➋ with2args(`arg1')
   The 1 arguments are arg1 and .
   with2args(`arg1', `arg2')
   The 2 arguments are arg1 and arg2.
   with2args(`arg1', `arg2', `arg3')
   The 3 arguments are arg1 and arg2.
➌ with2args (`arg1', `arg2')
   The 0 arguments are  and . (arg1, arg2)
   <ctrl-d>$

在这个例子中,第一次调用位于➊处,是一个没有参数的宏调用。第二次调用是一个带有一个空参数的宏调用。这样的调用将参数视为实际上传递了空参数。^(4) 在这两种情况下,宏展开为“这些 N 个参数是和” (注意最后两个词之间的双空格,以及最后一个词和句号之间的空格),但第一次调用中的“N”是 0,第二次调用中的“N”是 1。因此,第二次调用中的空括号带有一个空的单一参数。接下来的三个调用,从➋开始,分别传递一个、两个和三个参数。正如你从这三次调用的输出中所看到的,展开文本中引用缺失参数的地方被视为空,而没有对应引用的传递参数则会被忽略。

最后的调用,位于➌处,有些不同。注意它在宏名称和左括号之间包含了一个空格。这个调用的初始输出类似于第一次调用的输出,但在初始输出之后,我们发现原本预期的参数列表有一些轻微变化(引号丢失了)。这是一个没有参数的宏调用。由于它实际上并不是宏调用的一部分,M4 将参数列表仅视为输入流中的文本。因此,它会被直接复制到输出流中,去掉一层引号。

在宏调用中传递参数时,要注意参数周围的空白符。规则很简单:未加引号的前导空白符会从参数中移除,而尾随空白符则始终保留,无论是否加引号。当然,空白符这里指的是回车符和换行符,以及空格和制表符。以下是一个带有前导和尾随空白符变体的宏调用示例:

   $ m4
   define(`with3args', `The three arguments are $1, $2, and $3.')dnl
➊ with3args(arg1,
             arg2,
             arg3)
   The three arguments are arg1, arg2, and arg3.
➋ with3args(arg1
             ,arg2
             ,arg3
             )
   The three arguments are arg1
             , arg2
             , and arg3
             .
   <ctrl-d>$

在这个例子中,我故意省略了➊和➋处宏调用中参数周围的引号,以减少混淆。➊处的调用只有前导空白符,包括换行符和制表符,而➋处的调用只有尾随空白符。稍后我会讲解引号规则,到时你会清楚地看到引号是如何影响宏参数中的空白符的。

M4 的递归特性

现在我们考虑 M4 输入流的递归特性。每当宏定义扩展一个名称标记时,扩展文本会被推回到输入流中进行完全的重新处理。只要输入流中还有生成文本的宏调用,这种递归重新处理就会继续发生。

这是一个例子:

$ m4
define(`macro', `expansion')dnl
macro ``quoted' text'
expansion `quoted' text
<ctrl-d>$

在这里,我定义了一个名为macro的宏,然后在输入流中呈现这个宏名称,后面跟着附加的文本,其中一部分是加了引号的,一部分是加了双引号的。

M4 用于解析这个例子的过程如图 16-1 所示。

图片

图 16-1:M4 处理输入文本流的过程

在图的底部,M4 正在生成一串输出文本(扩展 `引用```'` `text`) from a stream of input text (```宏 引用' 文本'```).

The diagram above this line shows how M4 actually generates the output text from the input text. When the first token (macro) is read in the top line, M4 finds a matching symbol in the symbol table, pushes it onto the input stream on the second line, and then restarts the input stream. Thus, the very next token read is another name token (expansion). Since this name is not found in the symbol table, the text is sent directly to the output stream. The third line sends the next token from the input stream (a space character) directly to the output stream. Finally, in the fourth line, one level of quotes is removed from the quoted text (``引用' 文本'), and the result (`quoted' text) is sent to the output stream.

Infinite Recursion

As you might guess, there are some potentially nasty side effects of this process. For example, you can accidentally define a macro that is infinitely recursive. The expansion of such a macro would lead to a massive amount of unwanted output, followed by a stack overflow. This is easy to do:


$ m4

define(`macro', `这是一个宏')dnl

宏

这是一个这是一个这是一个这是一个这是一个这是一个... <ctrl-c>

$

This happens because the macro name expands into text containing the macro’s own name, which is then pushed back onto the input stream for reprocessing. Consider the following scenario: What would have been the result if I’d left the quotes off of the expansion text in the macro definition? What would have happened if I’d added another set of quotes around the expansion text? To help you discover the answers to these questions, let’s turn next to M4 quoting rules.

Quoting Rules

Proper quoting is critical. You have probably encountered situations where your invocations of Autoconf macros didn’t work as you expected. The problem is often a case of under-quoting, which means you omitted a required level of quotes around some text.

You see, each time text passes through M4, a layer of quotes is stripped off. Quoted strings are not names and are therefore not subject to macro expansion, but if a quoted string passes through M4 twice, the second time through, it’s no longer quoted. As a result, individual words within that string are no longer part of a string but instead are parsed as name tokens, which are subject to macro expansion. To illustrate this, enter the following text at a shell prompt:


$ m4

define(`def', `DEF')dnl

➊ define(`abc', `def')dnl

abc

DEF

➋ define(`abc', ``def'')dnl

abc

def

➌ define(`abc', ```def''')dnl
   abc
   `def'
   <ctrl-d>$

在这个例子中,第一次定义abc(见 ➊)时,它被加了引号。随着 M4 处理宏定义,它去除了一个引号层。因此,扩展文本以没有引号的形式存储在符号表中,但它会被推送回输入流,因此由于第一次宏定义,它被转换成了DEF

正如你所看到的,第二次定义abc(见 ➋)时,文本是双引号的,因此当定义被处理并且最外层的引号被去除时,我们期望符号表中的扩展文本至少包含一层引号,事实也是如此。那为什么我们没有看到输出文本周围的引号呢?记住,当宏被扩展时,扩展文本会被推送到输入流的前面,并按照通常的规则重新解析。因此,尽管第二次定义的文本在符号表中以引号形式存储,但当它在使用时重新处理时,第二层引号会在输入和输出流之间被去除。

在这个例子中,➊和➋的区别在于,➋的扩展文本被 M4 视为引号文本,而不是潜在的宏名称。引号在定义时被去除,但包含的文本不会再被视为进一步扩展,因为它仍然是被引号包围的。

abc的第三次定义(见 ➌)中,我们终于看到了可能期待的结果:输出文本的带引号版本。扩展文本以双引号的形式进入符号表,因为最外层的引号在定义处理过程中被去除。然后,当宏被使用时,扩展文本会重新处理,并且第二层引号会被去除,最终输出文本只保留一层引号。

如果在使用 Autoconf 中的宏(包括定义和调用)时牢记这些规则,你会发现更容易理解为什么有些事情没有按照你预期的方式工作。GNU M4 手册提供了一个简单的经验法则,用于在宏调用中使用引号:对于宏调用中的每一层嵌套括号,都使用一层引号。

Autoconf 和 M4

autoconf程序是一个相当简单的 Shell 脚本。脚本中大约 80%的 Shell 代码仅用于确保 Shell 足够功能强大,可以执行所需的任务。剩下的 20%用于解析命令行选项。脚本的最后一行执行autom4te程序,这是一个 Perl 脚本,它充当m4工具的包装器。最终,autom4te像这样调用m4

/usr/bin/m4 --nesting-limit=1024 --gnu --include=/usr/share/autoconf \
--debug=aflq --fatal-warning --debugfile=autom4te.cache/traces.0t \
--trace=AC_CANONICAL_BUILD ... --trace=sinclude \
--reload-state=/usr/share/autoconf/autoconf.m4f aclocal.m4 configure.ac

如你所见,M4 正在处理的三个文件是 /usr/share/autoconf/autoconf.m4faclocal.m4configure.ac,按此顺序。

注意

.m4f 扩展名的 Autoconf 宏文件表示一个 冻结的 M4 输入文件——一种原始 .m4 *文件的预编译版本。当一个冻结的宏文件被处理时,必须在 --reload-state 选项后指定,以便让 M4 知道它不是一个普通的输入文件。状态是通过 M4 在所有输入文件中逐步构建的,因此例如由 aclocal.m4 定义的任何宏,都可以在处理 configure.ac 时使用。

上面命令行中两个 --trace 选项之间的省略号是超过 100 个 --trace 选项的占位符。幸好 Shell 可以处理长命令行!

主 Autoconf 宏文件,autoconf.m4,只是包含了(使用m4_include宏)其他十几个 Autoconf 宏文件,并且按照正确的顺序排列,然后在离开 M4 处理用户输入(通过 configure.ac)之前做一些小的整理工作。aclocal.m4 文件是我们项目的宏文件,最初由 aclocal 工具构建,或者是为不使用 Automake 的项目手工编写的。当 configure.ac 被处理时,M4 环境已经配置了数百个 Autoconf 宏定义,这些宏可能在 configure.ac 中按需调用。这个环境不仅包括了公认的 AC_* 宏,还包括了一些 Autoconf 提供的低层宏,你可以用它们来编写你自己的宏。

其中一个低层宏是 m4sugar,^(5) 它提供了一个干净的命名空间,用于定义所有 Autoconf 宏,并对现有的 M4 宏做了若干改进和扩展。

Autoconf 以几种方式修改了 M4 环境。首先,如前所述,它将默认的引号字符从反引号和单引号字符更改为方括号字符。此外,它配置了 M4 内建宏,使得大多数宏都以 m4_ 为前缀,从而为 M4 宏创建了一个独特的命名空间。因此,M4 的 define 宏变成了 m4_define,以此类推。^(6)

Autoconf 提供了它自己版本的 m4_define,叫做 AC_DEFUN。你应该使用 AC_DEFUN 而不是 m4_define,因为它确保在调用你的宏时,某些对 Autoconf 重要的环境约束已经到位。AC_DEFUN 宏支持一个先决条件框架,因此你可以指定在调用你的宏之前,哪些宏必须已经被调用。这个框架通过使用 AC_REQUIRE 宏来访问,用以在你的宏定义开始时指明宏的要求,示例如下:

# Test for option A
# -----------------
AC_DEFUN([TEST_A],
[AC_REQUIRE([TEST_B])dnl
test "$A" = "yes" && options="$options A"])

使用AC_DEFUN和前提框架编写 Autoconf 宏的规则在GNU Autoconf Manual的第九章中有详细介绍。在编写自己的宏之前,请先阅读该手册的第八章和第九章。

编写 Autoconf 宏

为什么我们一开始要写 Autoconf 宏?其中一个原因是,项目的configure.ac文件可能包含多个类似的代码集合,我们需要configure脚本对多个目录或文件集执行相同的高级操作。通过将这个过程转换为宏,我们减少了configure.ac文件中的代码行数,从而减少了可能的故障点。另一个原因可能是,容易封装的configure.ac代码片段在其他项目或其他人那里也可能有用。

注意

GNU Autoconf Archive 提供了许多相关宏集,以解决常见的 Autoconf 问题。任何人都可以通过将他们的宏通过邮件提交给项目维护者来为该归档做贡献。该项目网站上经常提供免费的 tarball 版本。^(7)

简单文本替换

最简单的宏类型是直接替换文本而不做任何替换。一个很好的例子是 FLAIM 项目中的代码,flaim、xflaim 和 sql 项目的configure脚本尝试定位 ftk(FLAIM 工具包)项目库和头文件。由于我已经在第十四章中讨论了这段代码的操作,在这里我仅简要介绍它与编写 Autoconf 宏的关系,但为了方便,我提供了相关的configure.ac代码在清单 16-1 中。^(8)

--snip--
# Configure FTKLIB, FTKINC, FTK_LTLIB and FTK_INCLUDE
AC_ARG_VAR([FTKLIB], [The PATH wherein libflaimtk.la can be found.])
AC_ARG_VAR([FTKINC], [The PATH wherein flaimtk.h can be found.])

# Ensure that both or neither FTK paths were specified.
if { test -n "$FTKLIB" && test -z "$FTKINC"; } || \
   { test -z "$FTKLIB" && test -n "$FTKINC"; }; then
  AC_MSG_ERROR([Specify both FTKINC and FTKLIB, or neither.])
fi

# Not specified? Check for FTK in standard places.
if test -z "$FTKLIB"; then
  # Check for FLAIM toolkit as a sub-project.
  if test -d "$srcdir/ftk"; then
    AC_CONFIG_SUBDIRS([ftk])
    FTKINC='$(top_srcdir)/ftk/src'
    FTKLIB='$(top_builddir)/ftk/src'
  else
    # Check for FLAIM toolkit as a superproject.
 if test -d "$srcdir/../ftk"; then
      FTKINC='$(top_srcdir)/../ftk/src'
      FTKLIB='$(top_builddir)/../ftk/src'
    fi
  fi
fi

# Still empty? Check for *installed* FLAIM toolkit.
if test -z "$FTKLIB"; then
  AC_CHECK_LIB([flaimtk], [ftkFastChecksum],
    [AC_CHECK_HEADERS([flaimtk.h])
     LIBS="-lflaimtk $LIBS"],
    [AC_MSG_ERROR([No FLAIM toolkit found. Terminating.])])
fi

# AC_SUBST command line variables from FTKLIB and FTKINC.
if test -n "$FTKLIB"; then
  AC_SUBST([FTK_LTLIB], ["$FTKLIB/libflaimtk.la"])
  AC_SUBST([FTK_INCLUDE], ["-I$FTKINC"])
fi
--snip--

清单 16-1: xflaim/configure.ac: 来自 xflaim 项目的 ftk 搜索代码

这段代码在 flaim、xflaim 和 sql 中是相同的,尽管未来可能因为某些原因做修改,所以将它嵌入到所有三个configure.ac文件中是多余的且容易出错。

即使我们将这段代码转换为宏,我们仍然需要将宏文件的副本放入每个项目的m4目录中。然而,我们可以之后只编辑其中一个宏文件,并将其从权威位置复制到其他项目的m4目录中,或者甚至使用 Git 中的符号链接,而不是复制,以确保.m4文件只有一个副本。这比将所有代码嵌入到所有三个configure.ac文件中要更好。

通过将这段代码转换为宏,我们可以将其保留在一个地方,这样其中的部分内容就不会与与定位 FLAIM 工具包库和头文件无关的代码混淆。在项目的configure.ac文件的后期维护过程中,这种情况经常发生,因为为其他目的设计的额外代码会被插入到像这样的代码块之间。

让我们尝试将这段代码转换成一个宏。我们的第一次尝试可能会像 Listing 16-2 那样。(为了简洁起见,我省略了中间与原始代码相同的大部分内容。)

AC_DEFUN([FLM_FTK_SEARCH],
[AC_ARG_VAR([FTKLIB], [The PATH wherein libflaimtk.la can be found.])
AC_ARG_VAR([FTKINC], [The PATH wherein flaimtk.h can be found.])
--snip--
# AC_SUBST command line variables from FTKLIB and FTKINC.
if test -n "$FTKLIB"; then
  AC_SUBST([FTK_LTLIB], ["$FTKLIB/libflaimtk.la"])
 AC_SUBST([FTK_INCLUDE], ["-I$FTKINC"])
fi])

Listing 16-2: xflaim/m4/flm_ftk_search.m4: 封装 ftk 搜索代码的初次尝试

在这一轮中,我只是简单地将整个configure.ac代码序列原封不动地复制粘贴到对AC_DEFUN调用的macro-body参数中。AC_DEFUN宏由 Autoconf 定义,并提供了比 M4 提供的m4_define宏更多的功能。这些额外的功能严格来说与 Autoconf 提供的前提框架相关。

注意

请注意,AC_DEFUN必须使用(而不是m4_define),才能让aclocal在外部宏定义文件中找到宏定义。如果你的宏定义在外部文件中,必须使用AC_DEFUN,但是对于在configure.ac文件中定义的简单宏,可以使用m4_define*。

请注意在宏名称(FLM_FTK _SEARCH)和整个宏体周围使用了 M4 引号。为了说明在这个例子中不使用这些引号会遇到的问题,考虑一下 M4 在没有引号的情况下如何处理宏定义。如果宏名称没有加引号,通常不会造成太大问题,除非该宏已经被定义。如果宏已经定义,M4 会把宏名称当作一个没有参数的调用来处理,而在读取宏定义时,现有的定义会替换掉宏名称。(在这种情况下,由于宏名称的唯一性,几乎没有机会已经被定义,所以我可以不加引号使用宏名称,但保持一致性是一个好习惯。)

另一方面,宏体包含了相当多的文本,甚至包括 Autoconf 宏调用。如果我们不加引号地留下宏体,那么这些宏调用会在定义被读取时展开,而不是在宏后续使用时按预期展开。

由于有了引号,M4 会按提供的方式存储宏体,在读取定义时不会进行额外的处理,除了去掉最外层的引号。稍后,当宏被调用时,宏体文本会替换宏调用并插入输入流中,去除一层引号后,只有在这时嵌套的宏才会展开。

这个宏不需要任何参数,因为相同的文本在所有三个configure.ac文件中都是相同的。对configure.ac的影响是用宏名称替换掉整个代码块,如 Listing 16-3 所示。

   --snip--
   # Add jni.h include directories to include search path
   AX_JNI_INCLUDE_DIR
   for JNI_INCLUDE_DIR in $JNI_INCLUDE_DIRS; do
     CPPFLAGS="$CPPFLAGS -I$JNI_INCLUDE_DIR"
   done

➊ # Configure FTKLIB, FTKINC, FTK_LTLIB, and FTK_INCLUDE
   FLM_FTK_SEARCH
 # Check for Java compiler.
   --snip--

Listing 16-3: xflaim/configure.ac: 用新宏调用替换 ftk 搜索代码

在编写来自现有代码的宏时,考虑现有代码块的输入和该代码提供的输出。输入将成为可能的宏参数,输出将成为文档化的效果。在 Listing 16-3 中,我们没有输入,因此也没有参数,但这个代码的可记录效果是什么呢?

Listing 16-3 中宏调用上方的注释提到了这些效果。FTKLIBFTKINC 变量已经定义,而 FTK_LTLIBFTK_INCLUDE 变量则通过 AC_SUBST 定义并替换。

记录你的宏

一个合适的宏定义应该提供一个头部注释,记录可能的参数、结果和宏的潜在副作用,如 Listing 16-4 中所示。

# FLM_FTK_SEARCH
# --------------
# Define AC_ARG_VAR (user variables), FTKLIB, and FTKINC,
# allowing the user to specify the location of the flaim toolkit
# library and header file. If not specified, check for these files:
#
#   1\. As a sub-project.
#   2\. As a super-project (sibling to the current project).
#   3\. As installed components on the system.
#
# If found, AC_SUBST FTK_LTLIB and FTK_INCLUDE variables with
# values derived from FTKLIB and FTKINC user variables.
# FTKLIB and FTKINC are file locations, whereas FTK_LTLIB and
# FTK_INCLUDE are linker and preprocessor command line options.
#
# Author:   John Calcote <john.calcote@gmail.com>
# Modified: 2009-08-30
# License:  AllPermissive
#
AC_DEFUN([FLM_FTK_SEARCH],
--snip--

Listing 16-4: xflaim/m4/flm_ftk_search.m4: 为宏定义添加文档头部

这个头部注释记录了该宏的效果以及它的操作方式,给用户提供了清晰的概念,让他们了解调用该宏时会获得什么样的功能。GNU Autoconf 手册 表示,这种宏定义头部注释会从最终输出中去除;如果你在 configure 脚本中搜索注释头部的某些文本,你会发现它缺失了。

关于编码风格,GNU Autoconf 手册 建议,将宏体的闭合方括号引号和闭合括号单独放在宏定义的最后一行,并且注释中仅包含定义的宏名称,这是一种好的宏定义风格,如 Listing 16-5 中所示。

   --snip--
   AC_SUBST([FTK_INCLUDE], ["-I$FTKINC"])
➊ fi[]dnl
   ])# FLM_FTK_SEARCH

Listing 16-5: xflaim/m4/flm_ftk_search.m4: 建议的宏体闭合样式

GNU Autoconf 手册 还建议,如果你不喜欢使用这种格式时,生成的 configure 脚本中多出的回车符,可以在宏体的最后一行附加 []dnl 文本,如 Listing 16-5 中的 ➊ 所示。使用 dnl 会使尾随的回车符被忽略,而方括号则是空的 Autoconf 引号,在处理后续宏调用时会被剥离。方括号([])用于将 fidnl 分开,使 M4 能识别它们为两个独立的单词。

注意

GNU Autoconf 手册 定义了一个非常完整的宏及其包含文件的命名规范。我选择简单地为所有与项目严格相关的宏名称及其包含文件加上项目特定的前缀——在这种情况下是 FLM_ (flm_)。

M4 条件语句

现在你知道如何编写基本的 M4 宏,我们将考虑让 M4 决定应该使用哪些文本来替换你的宏调用,基于调用中传递的参数。

调用带有和不带参数的宏

请查看列表 16-6,这是我第一次尝试编写FLM_PROG_TRY_DOXYGEN宏,该宏首次在第十四章中使用。这个宏设计时有一个可选参数,但从第十四章的使用来看并不明显,因为 FLAIM 代码调用宏时没有传递参数。让我们来查看一下这个宏的定义。在这个过程中,我们将发现传递有参数和没有参数时调用它的含义。

   # FLM_PROG_TRY_DOXYGEN([quiet])
   # ------------------------------
   # FLM_PROG_TRY_DOXYGEN tests for an existing doxygen source
   # documentation program. It sets or uses the environment
   # variable DOXYGEN.
   #
   # If no arguments are given to this macro, and no doxygen
   # program can be found, it prints a warning message to STDOUT
   # and to the config.log file. If the quiet argument is passed,
   # then only the normal "check" line is displayed. Any other-token
   # argument is considered by autoconf to be an error at expansion
 # time.
   #
   # Makes the DOXYGEN variable precious to Autoconf. You can
   # use the DOXYGEN variable in your Makefile.in files with
   # @DOXYGEN@.
   #
   # Author: John Calcote <john.calcote@gmail.com>
   # Modified: 2009-08-30
   # License: AllPermissive
   #
   AC_DEFUN([FLM_PROG_TRY_DOXYGEN],
➊ [AC_ARG_VAR([DOXYGEN], [Doxygen source doc generation program])dnl
➋ AC_CHECK_PROGS([DOXYGEN], [doxygen])
➌ m4_ifval([$1],,
➍ [if test -z "$DOXYGEN"; then
     AC_MSG_WARN([doxygen not found - continuing without Doxygen support])
   fi])
   ])# FLM_PROG_TRY_DOXYGEN

列表 16-6: ftk/m4/flm_prog_try_doxygen.m4: 第一次尝试使用FLM_PROG_TRY_DOXYGEN

首先,我们在➊处看到对AC_ARG_VAR宏的调用,它用于使DOXYGEN变量对 Autoconf 变得重要。将变量标记为“重要”会使 Autoconf 在configure脚本的帮助文本中显示它,作为一个有影响力的环境变量。AC_ARG_VAR宏还将指定的变量变为 Autoconf 替代变量。在➋处,我们来到了这个宏的核心——对AC_CHECK_PROGS的调用。该宏在系统搜索路径中检查doxygen程序,但只有在第一个参数传递的变量为空时,才会查找程序(传递给第二个参数)。如果该变量不为空,AC_CHECK_PROGS会假设最终用户已经在用户环境中为该变量指定了正确的程序,且不执行任何操作。在这种情况下,如果在系统搜索路径中找到doxygen程序,DOXYGEN变量将被填充为doxygen。无论如何,Autoconf 会将对DOXYGEN变量的引用替换到模板文件中。(由于我们刚刚在DOXYGEN上调用了AC_ARG_VAR,这一步是多余的,但无害。)

在➌处对m4_ifval的调用引出了本节的重点。这是一个在 Autoconf 的m4sugar层中定义的条件宏——一个设计用来简化编写更高级别 Autoconf 宏的简单宏层。M4 条件宏旨在根据条件生成一段文本,如果条件为真,则生成一段文本,如果条件为假,则生成另一段文本。m4_ifval的作用是根据其第一个参数是否为空来生成文本。如果第一个参数不为空,则宏会生成第二个参数中的文本。如果第一个参数为空,则宏会生成第三个参数中的文本。

FLM_PROG_TRY_DOXYGEN宏在有或没有参数的情况下都能工作。如果没有传递参数,FLM_PROG_TRY_DOXYGEN将打印一条警告消息,指出如果系统搜索路径中没有doxygen程序,构建将继续进行,但没有 Doxygen 支持。另一方面,如果将quiet选项传递给FLM_PROG_TRY_DOXYGEN,则如果找不到doxygen程序,将不会打印任何消息。

在清单 16-6 中,如果第一个参数包含文本,m4_ifval将不会生成文本(第二个参数为空)。第一个参数是$1,它指代传递给FLM_PROG_TRY_DOXGEN的第一个参数的内容。如果我们的宏没有传递任何参数,$1将为空,m4_ifval将生成其第三个参数中所示的文本(在➍处)。另一方面,如果我们传递quiet(或者任何文本)给FLM_PROG_TRY_DOXYGEN$1将包含quietm4_ifval将不会生成任何内容。

第三个参数中的 Shell 代码(在➍处)检查在调用AC_CHECK_PROGS之后,DOXYGEN变量是否仍然为空。如果是,它调用AC_MSG_WARN来显示配置警告。

增加精确度

Autoconf 提供了一个名为m4_if的宏,它是 M4 内建的ifelse宏的重命名版本。m4_if宏的本质与m4sugarm4_ifval相似。清单 16-7 展示了如果没有m4sugar宏,我们如何使用ifelse来代替m4_ifval

--snip--
ifelse ([$1],,
[if test -z "$DOXYGEN"; then
AC_MSG_WARN([Doxygen program not found - continuing without Doxygen])
fi])
--snip--

清单 16-7: 使用ifelse代替m4_ifval

这些宏看起来在功能上是相同的,但这种相似性只是表面现象;参数的使用方式是不同的。在这种情况下,如果第一个参数($1)与第二个参数(空字符串)相同,则会生成第三个参数的内容([if test -z ...])。否则,会生成第四个(不存在的)参数的内容,因为省略的参数会被当作传递了空字符串来处理。因此,以下两个宏调用是等效的:

m4_ifval([$1],[a],[b]])
ifelse([$1],[],[b],[a])

FLM_PROG_TRY_DOXYGEN将其参数中的任何文本视为传递了quiet。为了便于未来对这个宏的扩展,我们应该限制该参数中允许的文本为有意义的内容;否则,用户可能会滥用这个参数,而我们将不得不支持他们为了向后兼容所传递的任何内容。m4_if宏可以帮助我们解决这个问题。这个宏非常强大,因为它可以接受任意数量的参数。以下是它的基本原型:

m4_if(comment)
m4_if(string-1, string-2, equal[, not-equal])
m4_if(string-1, string-2, equal-1, string-3, string-4, equal-2,
    ...[, not-equal])

如果只传递一个参数给m4_if,这个参数将被视为注释,因为m4_if对于一个参数几乎无法做任何事情。如果传递三个或四个参数,我在清单 16-7 中对ifelse的描述同样适用于m4_if。然而,如果传递五个或更多参数,则第四和第五个参数将成为第二个 else-if 条件的比较字符串。如果最后两个比较字符串不同,则在任意长度的三元组中生成最后一个参数。

我们可以使用m4_if来确保quietFLM_PROG_TRY_DOXYGEN接受的选项列表中唯一可接受的选项。清单 16-8 展示了一种可能的实现方式。

--snip--
m4_if([$1],,
[if test -z "$DOXYGEN"; then
    AC_MSG_WARN([doxygen not found - continuing without Doxygen support])
fi], [$1], [quiet],, [m4_fatal([Invalid option in FLM_PROG_TRY_DOXYGEN])])
--snip--

清单 16-8: 限制FLM_PROG_TRY_DOXYGEN允许的参数选项

在这种情况下,我们希望如果 doxygen 丢失,除非传入 quiet 选项作为宏的第一个参数,否则无论什么情况都要打印一条信息。在列表 16-8 中,我为 FLM_PROG_TRY_DOXYGEN 添加了一个功能,当传入的参数是除 quiet 或空字符串之外的任何内容时,它能够检测到这一情况并做出特定响应。列表 16-9 显示了通过展开 FLM_PROG_TRY_DOXYGEN 生成的伪代码。

if $1 == '' then
    Generate WARNING if no doxygen program is found
else if $1 == 'quiet' then
    Don't generate any messages
else
    Generate a fatal "bad parameter" error at autoconf (autoreconf) time
end

列表 16-9:伪代码用于列表 16-8 的 m4_if 宏使用

让我们仔细看看列表 16-8 中的内容。如果参数一([$1])和参数二([])相同,当未找到 doxygen 时,会生成一条警告信息。如果参数四([$1])和参数五([quiet])相同,则不会生成任何内容;否则,参数四和五不同,当执行到调用的 configure.ac 文件时,Autoconf 会生成一个致命错误(通过 m4_fatal)。一旦你了解它的工作原理,并且调试好 bug,整个过程就非常简单了——这也引出了我们的下一个话题。

问题诊断

人们在这一点上遇到的一个主要难题,并非缺乏对这些宏如何工作的理解,而是缺乏对细节的关注。在编写即使是一个简单的宏时,也有几个地方可能出问题。例如,你可能会遇到以下问题:

  • 宏名称和左括号之间的空格

  • 不平衡的括号或圆括号

  • 参数个数错误

  • 宏名称拼写错误

  • 错误引用的宏参数

  • 宏参数列表中缺少逗号

M4 对此类错误非常不宽容。更糟糕的是,它的错误信息有时比 make 的错误信息还要难以理解。^(9) 如果你遇到奇怪的错误,并且认为你的宏应该能正常工作,那么最好的诊断方法是仔细检查宏的定义,寻找可能存在的前置条件。这些错误很容易犯,最终大多数问题归结为这些条件的某种组合。

另一个非常有用的调试工具是 m4_traceonm4_traceoff 宏对。它们的宏签名如下:

m4_traceon([name, ...])
m4_traceoff([name, ...])

所有参数都是可选的。如果提供了参数,它们应该是一个逗号分隔的宏名称列表,当这些名称出现在输入流中时,M4 会将它们打印到输出流中。如果省略参数,M4 将打印它展开的每个宏的名称。

M4 中一个典型的追踪会话看起来像这样:

   $ m4
   define(`abc', `def')dnl
   define(`def', `ghi')dnl
   traceon(`abc', `def')dnl
   abc
➊ m4trace: -1- abc
   m4trace: -1- def
 ghi
   traceoff(`abc', `def')dnl
➋ m4trace: -1- traceoff
   <ctrl-d>$

输出行中➊和➋之间的数字表示嵌套级别,通常为 1。追踪功能的价值在于,你可以轻松看到被追踪的宏在生成的输出文本中的展开位置。M4 的追踪功能也可以通过命令行启用,使用 -t--trace 选项:

$ m4 --trace=abc

或者更适合本讨论的是:

$ autoconf --trace=FLM_PROG_TRY_DOXYGEN

后者的额外好处是允许你为追踪输出指定格式。要深入了解该选项格式部分的使用,尝试在命令行输入autom4te --help。有关 M4 追踪选项的更多信息,请参阅GNU M4 手册的第七章(特别是第 7.2 节)。

注意

Autotools 不仅仅依赖于追踪进行调试。许多 Autotools 及其支持工具在 configure.ac 中使用追踪,以收集在配置过程其他阶段使用的信息。(回想一下m4命令行上 100 多个追踪选项。)有关 Autoconf 中追踪的更多信息,请参阅GNU Autoconf 手册的第 3.4 节,标题为“使用autoconf来创建configure。”

总结

使用 M4 表面上看起来很简单,实际上却很复杂。表面上它似乎简单,但当你深入研究时,会发现有些使用方式几乎让人难以理解。然而,这些复杂性并非不可克服。随着你对 M4 的熟练掌握,你会发现你对某些问题的思考方式发生了变化。仅仅为了这个原因,掌握 M4 是值得的。这就像是在你的软件工程工具箱中加入了一件新工具。

我没有涵盖的一个强大的 M4 概念,但你应该了解的是迭代。通常,我们把迭代理解为循环,但 M4 没有实际的循环结构。相反,迭代是通过递归来管理的。有关详细信息,请参阅手册中关于forloopforeach宏的讨论。

由于 Autoconf 的基础就是 M4,精通 M4 将使你对 Autoconf 有比你想象的更深入的理解。你对 M4 了解得越多,你对 Autoconf 的理解也会越清晰。

第十七章:在 Windows 上使用 Autotools

“嗯,Steve,我认为有不止一种看待这个问题的方式。我觉得更像是我们都有一个富有的邻居,名叫 Xerox,我闯入了他的家偷了电视机,结果发现你已经偷走了它。”

—比尔·盖茨,引用自沃尔特·艾萨克森的《史蒂夫·乔布斯》

Image

Autoconf 生成的配置脚本包含数百行 Bourne shell 代码。如果这句话没有让你怀疑我们如何能在 Windows 上使用 Autotools,你可能需要重新读一遍,直到产生疑问。事实上,Autoconf 唯一能使用的方法是借助真实的 Bourne shell 和一些 Unix 工具的子集,如 grepawksed。因此,在我们开始之前,需要确保我们有一个合适的执行环境。

当我开始编写本书的第一版时,提供构建 Windows 软件所需环境的选项很少。而在过去的 10 年里,这个情况发生了变化。如今,开发者可以根据目标是要在 Linux 还是 Windows 上构建 Windows 应用程序,选择多种可用选项。

在过去的十年里,GNU 社区已将 Windows 视为比以前更重要的目标。最近,已经做出了大量努力,确保 GNU 源代码至少会考虑将 Windows 作为目标环境。这种态度的转变为 Cygwin 及其相关环境提供了重要的源代码级支持,从而确保将 GNU 软件包清晰地移植到 Windows 上。

环境选项

既然我们的目标是使用 GNU 工具,包括特别是 Autotools 来构建原生的 Windows 软件,我们自然需要考虑提供各种级别 POSIX 环境功能的系统。

在这个光谱的一端,我们有实际的 Linux 安装,可能以多种形式出现,包括裸机专用机器安装、在 KVM、Xen 或 VMware ESX 服务器上运行的虚拟机,或者在运行 Microsoft HyperV、VMware Workstation 或 Oracle 的 VirtualBox 的 Windows 机器上。也有在 Mac 上运行虚拟机的选项,macOS 本身也提供了相对符合 POSIX 标准的环境。我们也可以使用 Windows Subsystem for Linux(WSL)。

完整的 Linux 安装显然提供了最符合 POSIX 标准的环境来使用 GNU 工具构建软件。要在 Linux 系统上生成 Windows 软件,我们必须配置交叉编译。也就是说,我们必须构建那些不打算在构建系统上运行的软件。

在这个范围的另一端,我们有各种在 Windows 应用程序中运行的 POSIX 环境模拟器。这些“应用程序”几乎总是某种类型的 Bash shell,运行在某个 shell 宿主进程或终端中,但这些环境或多或少与真正的 Linux 构建环境兼容。我们今天可以选择的有 Cygwin、MinGW 和 MSys2。

一个最终的选择——我们不会花太多时间讨论——是跨平台编译 Windows 软件到其他类型的系统,包括大型主机和超级计算机。如果你想看到 Windows 程序快速编译,你应该看看它在配备 SSD 或 RAM 磁盘的 Cray XC50 上编译的过程。由于 GNU 软件几乎可以在任何具有 Bourne shell 的 Unix 系统上运行,我们可以在其上为任何平台进行交叉编译,包括 Windows。在 Linux 上完成交叉编译后,将这个过程转移到其他 POSIX 兼容平台相对简单。

工具链选项

一旦我们选择了环境,我们接下来需要选择一个工具链来为 Windows 构建本地软件。一般来说,你选择的环境会限制你的工具链选项。例如,如果你选择一个完整的 Linux 安装,那么你唯一的工具链选项就是安装一个 Windows 的交叉编译器——可能是mingw-w64。在你没有尝试之前不要轻视它——这个选项其实非常不错,因为它能够合理地构建 Windows 软件。

你会遇到的最大问题是必须将软件复制到 Windows 系统上进行测试的不便。事实上,将测试作为构建的一部分几乎是行不通的,因为你不能在你的构建机器上执行产品。^(1) 我曾见过通过在构建系统的测试阶段加入远程复制和执行步骤来进行此类交叉编译测试,但这样做往往会让构建变得脆弱,因为它需要额外的环境配置,而这些配置通常不属于正常的包构建过程的一部分。

开始

我将展示使用 GNU 工具构建 Windows 软件的完整选项列表。我们将首先使用 Windows 交叉编译器工具链在原生 Linux 上进行编译,然后查看 Windows 子系统 Linux,最后了解剩余的基于 Windows 的选项,按它们创建的顺序呈现。我们将首先查看 Windows 10 系统上的 Cygwin。接下来,我们将尝试 MinGW,最后结束于 MSys2。到本章结束时,你应该对这些过程非常熟悉。

对于基于 Windows 的系统,我假设你正在运行一个较新的 Windows 10 版本。我在我的 Linux Mint 系统的 Oracle VirtualBox 虚拟机中安装了 Windows 10 Build 1803(2018 年 4 月 30 日发布)。你可以选择这条路径,也可以选择使用“裸金属”安装(非虚拟)Windows 10。你选择运行 Windows 的方式以及选择的具体版本,实际上并不是这里的关键问题。

注意

本书的主要内容集中在自由和开源软件(FOSS)的使用上。微软 Windows 当然不是自由软件。你应该为你选择使用的任何 Windows 版本——或任何其他非自由软件——付费。^(2)

我也在我的 Windows 系统上安装了 Git for Windows^(3),并从 第十三章 克隆了 b64 项目和从 Savannah Git 服务器克隆了 Gnulib 项目。除非必要以确保它在给定环境中正常工作,否则我们不会对 b64 项目的源代码做任何重大修改。

当你为 Windows 安装 Git 时,你可以选择下载 32 位或 64 位版本,并且可以选择两种格式之一——安装程序或便携包。安装程序格式将 Git 以常规方式安装到你的 Windows 系统中,并可以通过 Windows 的已安装程序面板卸载。便携格式无需安装,可以直接从解压后的归档文件中运行。请选择适合你 Windows 系统的安装程序或便携包选项。

如果你选择使用安装程序,在安装过程中你会被询问如何处理源文件的行结束符。我一般避免选择第一个选项,即“检出”时使用 Windows 风格的行结束符,但“提交”时使用 Unix 风格的行结束符。如果你计划使用记事本作为编辑器(不推荐),你可能想选择这个选项。我通常选择“检出”和“提交”时保持一致。Git 不应该在源文件通过时修改它们。只需将你的编辑器配置为按你喜欢的方式识别和管理行结束符。

在 Linux 上为 Windows 进行交叉编译

既然我们已经在使用 Linux,让我们从这里开始调查相关选项。

安装 Windows 交叉工具链

我们首先需要做的是在我们的 Linux 系统上安装 Windows 交叉编译工具链(通常简称为“交叉工具链”或“交叉工具”)。最广泛使用的工具链是适用于 Linux 的 mingw-w64,它可以构建原生的 Windows 程序和库,这些程序和库看起来非常像是由微软工具生成的。

在我的 Linux Mint 系统上,我搜索了Linux Mint mingw-w64;搜索的第一个结果就是我要找的目标。通常,你可以使用系统的包管理器来查找并安装这个包,因为 mingw-w64 非常流行。在 CentOS 和其他基于 Red Hat 的系统上,可以尝试yum search mingw-w64。对于像 Ubuntu 和 Mint 这样的基于 Debian 的系统,可以尝试apt-cache search mingw-w64

当你运行这些包搜索时,请注意,你可能会返回一个很长的结果列表,其中包含几十个实际的包和一两个元包。最好选择其中一个元包,这样你就能一次性获得所有必需的实际包。我强烈建议你搜索一下你的发行版名称和mingw-w64,以了解使用包管理器安装哪个包。稍微做些前期研究可以为你后续省去很多麻烦。

例如,在我的基于 Debian 的系统上,我通过apt-cache搜索得到了这些结果:

$ apt-cache search mingw-w64
--snip--
g++-mingw-w64 - GNU C++ compiler for MinGW-w64
g++-mingw-w64-i686 - GNU C++ compiler for MinGW-w64 targeting Win32
g++-mingw-w64-x86-64 - GNU C++ compiler for MinGW-w64 targeting Win64
gcc-mingw-w64 - GNU C compiler for MinGW-w64
gcc-mingw-w64-base - GNU Compiler Collection for MinGW-w64 (base package)
gcc-mingw-w64-i686 - GNU C compiler for MinGW-w64 targeting Win32
gcc-mingw-w64-x86-64 - GNU C compiler for MinGW-w64 targeting Win64
--snip--
mingw-w64 - Development environment targeting 32- and 64-bit Windows
mingw-w64-common - Common files for Mingw-w64
mingw-w64-i686-dev - Development files for MinGW-w64 targeting Win32
mingw-w64-tools - Development tools for 32- and 64-bit Windows
mingw-w64-x86-64-dev - Development files for MinGW-w64 targeting Win64
--snip--
$

实际结果列表包含了数十个条目,但根据我快速的互联网搜索,我发现我真正需要安装的唯一包是mingw-w64(高亮显示);它是一个元包,引用了实际的包,这些包安装了用于生成 32 位和 64 位 Windows 软件的 GCC C 和 C++编译器;以及一个binutils包,包含了库管理器、链接器和其他常见的开发工具。有些包管理系统将这些包的集合分开,允许你选择单独安装gccg++,或者单独安装 32 位和 64 位代码生成器。在我的系统上安装这个包时,会显示以下输出:

$ sudo apt-get install mingw-w64
[sudo] password for jcalcote:
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following additional packages will be installed:
  binutils-mingw-w64-i686 binutils-mingw-w64-x86-64 g++-mingw-w64 g++-
mingw-w64-i686 g++-mingw-w64-x86-64 gcc-mingw-w64 gcc-mingw-w64-base
gcc-mingw-w64-i686 gcc-mingw-w64-x86-64
  mingw-w64-common mingw-w64-i686-dev mingw-w64-x86-64-dev
Suggested packages:
  gcc-7-locales wine wine64
The following NEW packages will be installed:
  binutils-mingw-w64-i686 binutils-mingw-w64-x86-64 g++-mingw-w64 g++-
mingw-w64-i686 g++-mingw-w64-x86-64 gcc-mingw-w64 gcc-mingw-w64-base
gcc-mingw-w64-i686 gcc-mingw-w64-x86-64
  mingw-w64 mingw-w64-common mingw-w64-i686-dev mingw-w64-x86-64-dev
0 upgraded, 13 newly installed, 0 to remove and 31 not upgraded.
Need to get 127 MB of archives.
After this operation, 744 MB of additional disk space will be used.
Do you want to continue? [Y/n] Y
--snip--
$

测试构建

一旦你找到了并安装了合适的交叉工具链,你就可以开始构建 Windows 软件了。我选择了一个简单但不琐碎的例子——来自第十三章的 b64 项目。它使用 Gnulib,因此它有一个便利库。Gnulib 的目标是可移植性,因此我们可以评估它在 Windows 可移植性方面的表现,至少是对 b64 使用的几个模块的评估。

要为另一个平台构建,你需要为交叉编译配置项目。有关使用 Autotools 进行交叉编译的完整说明,请参见第十八章中的第六部分。现在,只需知道你需要的配置选项是--build--host。第一个选项描述了你将构建软件的系统,第二个选项描述了生成的软件将要执行的系统。为了发现我们的构建平台,我们可以运行config.guess脚本,这个脚本是通过automake(通过autoreconf)安装到我们项目根目录中的。为了做到这一点,我们需要为常规构建启动项目,以便config.guess能够被安装。^(4) 让我们在b64目录下执行这个操作:

$ cd b64
$ ./bootstrap.sh
Module list with included dependencies (indented):
    absolute-header
  base64
    extensions
    extern-inline
--snip--
configure.ac:12: installing './compile'
configure.ac:20: installing './config.guess'
configure.ac:20: installing './config.sub'
configure.ac:6: installing './install-sh'
configure.ac:6: installing './missing'
Makefile.am: installing './depcomp'
$
$ ./config.guess
x86_64-pc-linux-gnu
$

运行config.guessconfigure确定--build选项默认值的方式,因此它总是正确的。确定我们应该为--host选项使用的值要稍微困难一点。我们需要找到交叉工具链的前缀,因为--host选项的值是configure用来找到正确工具链并设置我们的CCLD变量的。

这可以通过几种不同的方式来完成。你可以使用系统的包管理器来确定安装 mingw-w64 元包时安装了哪些文件,或者你可以查看/usr/bin目录,看看编译器的名称是什么——这种方法通常有效,并且对于这个工具链来说,确实有效,但有时交叉工具链会安装到完全不同的目录中,所以我将使用我的包管理器。你的包管理器有类似的选项,但如果你恰好使用的是基于 Debian 的系统,你可以直接按照我的方法操作:

$ dpkg -l | grep mingw
ii  binutils-mingw-w64-i686 ...
ii  binutils-mingw-w64-x86-64 ...
--snip--
ii  gcc-mingw-w64-i686 ...
ii  gcc-mingw-w64-x86-64 ... GNU C compiler for MinGW-w64 targeting Win64
ii  mingw-w64 ... Development environment targeting 32- and 64-bit Windows
--snip--
$ dpkg -L gcc-mingw-w64-x86-64
--snip--
/usr/lib/gcc/x86_64-w64-mingw32
/usr/lib/gcc/x86_64-w64-mingw32/5.3-win32
/usr/lib/gcc/x86_64-w64-mingw32/5.3-win32/libgcc_s_seh-1.dll
/usr/lib/gcc/x86_64-w64-mingw32/5.3-win32/libgcov.a
--snip--
$

第一个命令列出了我系统上所有已安装的软件包,并通过grep过滤列表,查找与mingw相关的任何内容。在基于 Red Hat 的系统中,等效的rpm命令是rpm -qa | grep mingw。我查找的软件包将与 GCC C 编译器和x86_64开发相关。它在你的系统上看起来可能非常相似,甚至完全一样。

第二个命令列出了该软件包安装的文件。在这里,我要查找的是编译器、标准 C 库、头文件和其他特定于目标的文件。等效的rpm命令是rpm -ql mingw64-gcc。^(5)我搜索的标签是x86_64-w64-mingw32。它的结构应该与我们执行./config.guess时之前打印的值相似(但内容不同)。这是应该在configure命令行中与--host选项一起使用的值。configure用它作为gcc的前缀,通过仔细检查软件包管理器的输出,你会发现确实有一个名为x86_64-w64-mingw32-gcc的程序被安装到你的/**usr/bin目录中。

现在让我们利用我们收集到的信息为 Windows 构建 b64。在b64目录中,创建一个名为w64的子目录(或者你喜欢的名字),并切换到该目录;这是我们将用来构建 64 位 Windows 版本 b64 的构建目录。运行../configure并使用目标 Windows 的选项,如下所示(假设我们仍然在b64目录中):^(6)

$ mkdir w64
$ cd w64
$ ../configure --build=x86_64-pc-linux-gnu --host=x86_64-w64-mingw32
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for x86_64-w64-mingw32-strip... x86_64-w64-mingw32-strip
checking for a thread-safe mkdir -p... /bin/mkdir -p
checking for gawk... gawk
checking whether make sets $(MAKE)... yes
checking whether make supports nested variables... yes
checking for x86_64-w64-mingw32-gcc... x86_64-w64-mingw32-gcc
checking whether the C compiler works... yes
checking for C compiler default output file name... a.exe
checking for suffix of executables... .exe
checking whether we are cross compiling... yes
checking for suffix of object files... o
--snip--
configure: creating ./config.status
config.status: creating Makefile
config.status: creating lib/Makefile
config.status: creating config.h
config.status: executing depfiles commands
$

我已将一些在交叉编译时configure的输出中的重要行标出。如果你在命令行中输入--host值时犯了错误,你将看到类似于以下的输出:

$ ../configure --build=x86_64-pc-linux-gnu --host=x86_64-w64-oops
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for x86_64-w64-oops-strip... no
checking for strip... strip
configure: WARNING: using cross tools not prefixed with host triplet
--snip--
$

警告信息告诉你,它无法找到一个名为x86_64-w64-oops-stripstrip程序(这并不令人惊讶)。大多数交叉工具链都附带了一个适当前缀的strip版本,因为这是binutils包中的工具之一,所以这是一个合理的测试。如果configure找不到带前缀的工具版本,它将回退到使用工具的基础名称,如果你的交叉工具正好是按基础名称命名并且只是存储在不同的目录(你应该已经将其添加到PATH中),那么这样做也许是完全可以的。

现在我们已经为交叉编译配置了构建,其他的一切都与常规的 Linux 构建完全相同。试试运行make

$ make
make  all-recursive
make[1]: Entering directory '/.../b64/w64'
Making all in lib
make[2]: Entering directory '/.../b64/w64/lib'
--snip--
make  all-recursive
make[3]: Entering directory '/.../b64/w64/lib'
make[4]: Entering directory '/.../b64/w64/lib'
depbase=`echo base64.o | sed 's|[^/]*$|.deps/&|;s|\.o$||'`;\
x86_64-w64-mingw32-gcc -DHAVE_CONFIG_H -I. -I../../lib -I..     -g -O2 -MT
base64.o -MD -MP -MF $depbase.Tpo -c -o base64.o ../../lib/base64.c &&\
mv -f $depbase.Tpo $depbase.Po
rm -f libgnu.a
x86_64-w64-mingw32-ar cr libgnu.a base64.o
x86_64-w64-mingw32-ranlib libgnu.a
make[4]: Leaving directory '/.../b64/w64/lib'
make[3]: Leaving directory '/.../b64/w64/lib'
make[2]: Leaving directory '/.../b64/w64/lib'
make[2]: Entering directory '/.../b64/w64'
x86_64-w64-mingw32-gcc -DHAVE_CONFIG_H -I. -I..  -I./lib -I../lib   -g -O2 -MT
src/src_b64-b64.o -MD -MP -MF src/.deps/src_b64-b64.Tpo -c -o src/src_b64-
b64.o `test -f 'src/b64.c' || echo '../'`src/b64.c
mv -f src/.deps/src_b64-b64.Tpo src/.deps/src_b64-b64.Po
x86_64-w64-mingw32-gcc  -g -O2   -o src/b64.exe src/src_b64-b64.o lib/libgnu.a
make[2]: Leaving directory '/.../b64/w64'
make[1]: Leaving directory '/.../b64/w64'
$
$ ls -1p src
b64.exe
src_b64-b64.o
$

我已经突出显示了那些指示我们使用mingw-w64交叉工具链来构建 b64 的行。src目录的列表显示了我们的 Windows 可执行文件b64.exe

为了完整起见,让我们将这个程序复制到 Windows 系统上并尝试一下。如前所述,我在我的 Linux 系统上安装了 Windows 10 的虚拟机,因此我可以直接从 Windows 映射的驱动器(在我的案例中是Z:)运行它:

Z:\...\b64\w64\src>dir /B
src_b64-b64.o
b64.exe
Z:\...\b64\w64\src>type ..\..\bootstrap.sh | b64.exe
IyEvYmluL3NoCmdudWxpYi10b29sIC0tdXBkYXRlCmF1dG9yZWNvbmYgLWkK
Z:\...\b64\w64\src>set /p="IyEvYmluL3NoCmdudWxpYi10b29sIC0tdXBkYXRlCmF1dG9yZWN
vbmYgLWkK" <nul | b64.exe -d
#!/bin/sh
gnulib-tool --update
autoreconf -i

Z:\...\b64\w64\src>

注意

不要担心set /p命令——它只是一个巧妙的方式,通过 Windows 控制台回显文本,而不带有结尾换行符,因为cmd.exeecho语句没有选项来抑制结尾换行符

我不会告诉你通过这种方式构建 Windows 软件时你永远不会遇到问题。你会遇到,但它们主要是与你的项目源代码直接调用的一些 POSIX 系统调用相关的移植问题。然而,我会说,遇到的任何问题,都会是你尝试使用 Microsoft 工具构建该软件包时所遇到问题的一个适当子集。除了你可能会发现的任何源代码移植问题(即使使用 Microsoft 工具,它们依然存在),你还需要解决手动配置的 Visual Studio 解决方案和项目文件或 Microsoft nmake文件中的问题。对于某些项目,能够访问 Microsoft 工具提供的额外精细调优是值得付出的额外努力。对于其他项目来说,这种调优并不那么重要;在 Linux 系统上为 Windows 构建这些项目工作得非常好。

Windows Subsystem for Linux

在我们离开 Linux 世界之前,让我们将 Windows Subsystem for Linux(WSL)作为一个选项来构建使用 GNU 工具的 Windows 软件。

你可以通过从 Windows 商店下载你想要使用的 Linux 版本来为 WSL 获取一个 Linux 发行版。然而,在此之前,你必须启用可选的 Windows Subsystem for Linux 功能。你可以通过Windows 功能面板(在 Cortana 搜索栏中输入windows features并选择顶部的结果)或从以管理员身份打开的 PowerShell 命令提示符来完成此操作。

Windows 特性 面板中,向下滚动,直到找到 Windows Subsystem for Linux 的条目,勾选关联的复选框并点击 确定。或者,在 PowerShell 提示符下(作为 管理员),输入以下命令并按照提示操作:

PS C:\Windows\system32> Enable-WindowsOptionalFeature -Online -FeatureName
 Microsoft-Windows-Subsystem-Linux

安装 Windows Subsystem for Linux 需要重新启动系统。

现在打开 Windows Store,搜索 “Windows Subsystem for Linux”,选择 “Run Linux on Windows” 的搜索结果,并选择你想安装的 Linux 版本。在我的系统上,安装 Ubuntu 18.04 版本时,下载了大约 215MB,并在开始菜单中安装了一个“Ubuntu 18.04”图标。

在首次执行时,终端窗口显示了系统正在安装的文本:

Installing, this may take a few minutes...
Please create a default UNIX user account. The username does not need to match
your Windows username.
For more information visit: https://aka.ms/wslusers
Enter new UNIX username: jcalcote
Enter new UNIX password:
Retype new UNIX password:
passwd: password updated successfully
Installation successful!
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

$

使用 mount 命令查看 Microsoft 如何集成 Windows 和 Linux 文件系统:

$ mount
rootfs on / type lxfs (rw,noatime)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,noatime)
proc on /proc type proc (rw,nosuid,nodev,noexec,noatime)
none on /dev type tmpfs (rw,noatime,mode=755)
devpts on /dev/pts type devpts (rw,nosuid,noexec,noatime,gid=5,mode=620)
none on /run type tmpfs (rw,nosuid,noexec,noatime,mode=755)
none on /run/lock type tmpfs (rw,nosuid,nodev,noexec,noatime)
none on /run/shm type tmpfs (rw,nosuid,nodev,noatime)
none on /run/user type tmpfs (rw,nosuid,nodev,noexec,noatime,mode=755)
binfmt_misc on /proc/sys/fs/binfmt_misc type binfmt_misc (rw,noatime)
C: on /mnt/c type drvfs (rw,noatime,uid=1000,gid=1000)
$

这里需要特别注意的关键点(已高亮)是,你的 Windows C: 驱动器已在该 Linux 安装中挂载到 /mnt/c 目录下。

另一方面,Linux 根文件系统被安装到你的 Windows 文件系统中的隐藏用户特定 AppData 目录下。例如,我在 C:\Users*你的用户名\AppData\Local\Packages\CanonicalGroupLimited.UbuntuonWindows_79rhkp1fndgsc\LocalState\rootfs* 中找到了我的 Ubuntu 18.04 安装的根文件系统。^(7)

如果你选择了基于 Debian 的发行版,首先使用 sudo apt-get update 更新系统软件源缓存。然后,你可以按照所选发行版的常规方式安装开发工具,如 GCC 和 Autotools:

$ sudo apt-get install gcc make autoconf automake libtool libtool-bin

如果你对 Ubuntu 和 apt 有所了解,你会发现 WSL Ubuntu 18.04 上的前述命令输出与原生安装的 Ubuntu 18.04 输出没有显著区别。因为你实际上是在 Windows 上运行 Ubuntu 18.04。

与普通的 Ubuntu 18.04 安装类似,如果你在主目录中创建一个 bin 目录,然后打开一个新的 Ubuntu 18.04 终端窗口,你会发现你的个人 bin 目录会出现在 PATH 的最前面。现在就这样做,以便你可以创建一个符号链接,~/bin/gnulib-tools,指向你在 Windows 上的 Gnulib 克隆中的 gnulib-tool,就像我们在 Linux 上构建 b64 时做的那样:

$ ln -s /mnt/c/Users/.../gnulib/gnulib-tool ~/bin/gnulib-tool

切换到 /mnt/c/Users/.../b64 目录并运行 ./bootstrap.sh,接着运行 ./configure && make 来构建 b64:

$ cd /mnt/c/Users/.../b64
$ ./bootstrap.sh
--snip--
$ ./configure && make
--snip--
$ cd src
$ ./b64 <../../../b64/bootstrap.sh
IyEvYmluL3NoCmdudWxpYi10b29sIC0tdXBkYXRlCmF1dG9yZWNvbmYgLWkK$
$ printf "IyEvYmluL3NoCmdudWxpYi10b29sIC0tdXBkYXRlCmF1dG9yZWNvbmYgLWkK" | ./
b64 -d
#!/bin/sh
gnulib-tool --update
autoreconf -i
$

太棒了!除了这是一个 Linux 程序,而不是一个 Windows 程序:

$ objdump -i b64
BFD header file version (GNU Binutils for Ubuntu) 2.30
elf64-x86-64
 (header little endian, data little endian)
  i386
elf32-i386
 (header little endian, data little endian)
  i386
--snip--
$

尝试从 Windows 命令提示符运行该程序将得到 Windows 等效的茫然无措。实际上,WSL 本质上是一个廉价的虚拟机客体,带有一些内置的文件系统集成。虽然如此,这并不意味着它没有用处。对于许多用途,将 Linux 与 Windows 紧密集成是非常便利的。

那我们该怎么办呢?我们的唯一选择是做和之前在本地 Linux 安装时做的相同的事情——安装 mingw-w64 并进行交叉编译。这个过程是相同的,因此我不会再详细重复。有关详细说明,请参阅“在 Linux 上为 Windows 进行交叉编译”部分,见第 454 页。

Cygwin

Cygwin 项目由 Cygnus Solutions 于 1995 年成立,目的是为该公司为各种嵌入式环境提供开发工具的工作,创建基于 GNU 软件的工具链。

Cygwin 的总体哲学是,GNU 软件包应该能够在 Windows 上编译运行,而无需对源代码进行任何修改。对支持公司来说,时间就是金钱,任何不花时间修改源代码的时间都意味着存入银行的钱。他们希望他们的工程师能使用这些环境中的 GNU 工具,而不是花时间将它们移植过去。

那么它们是如何做到这一点的呢?实际上,大多数 GNU 软件包都是用 C 语言编写的,并使用 C 标准库来访问它们所需的大多数系统功能。C 标准库——作为一种标准化的库——天生具有可移植性。此外,GNU 项目也致力于可移植性——至少在 Unix 的不同版本之间是这样。然而,仍然有一些 POSIX 系统功能,许多 GNU 软件包会利用它们,包括 POSIX 线程(pthreads)和像forkexecmmap这样的系统调用。虽然最近的 C 和 C++标准已经包括了线程 API,但其他一些系统调用则非常特定于 Unix 和 Linux。事实上,这些系统调用在 Windows 上没有直接的对应功能,要使用它们通常需要在调用者和 Windows API 之间加上一些适配器代码。

举个简单的例子,当你回到最基本的层面时,两个内核在创建进程方面的工作原理是根本不同的。Windows 使用 Win32 的CreateProcess函数来创建一个新进程并在单个步骤中加载一个程序。而 Unix 则使用forkexec系统调用,分别用来克隆现有进程并将克隆内容替换为另一个程序。

实际上,将fork-exec这对调用替换为调用CreateProcess是相对容易的。真正的难题出现在fork被独立于exec使用时,这种情况偶尔会发生。^(8) 事实上,无法让CreateProcess仅完成它的部分工作。^(9) 许多 GNU 程序不会在没有exec的情况下使用fork,但也有一些重要的程序会。将这些调用映射到 Windows API 上,即便是在最佳情况下,也是非常困难的,而在没有对源代码进行重大结构性修改的情况下,这几乎是不可能的。

因此,Cygnus 决定创建一个 POSIX 系统调用功能的 shim 库。这个库被称为 cygwin1.dll,通过 Cygwin 构建的程序会链接到这个库,并在运行时依赖它。更重要的是,每个标准库调用和大多数系统调用都会通过 cygwin1.dll,因此在没有现有工具的情况下移植到新平台是一个简单的过程。

你可以通过查看程序的依赖列表来检测一个 Windows 程序是否为 Cygwin 平台构建,只需查找 cygwin1.dll 即可。^(10) 但是,Cygwin 平台并不是 Cygwin 支持的唯一目标。mingw-w64 工具链已被移植到 Cygwin,并可以作为交叉编译器在 Cygwin 中使用,构建本地 Windows 软件,就像我们在 Linux 上所做的那样。

1999 年,Red Hat 收购了 Cygnus Solutions,从那时起,Cygwin 一直由多位 Red Hat 员工和外部志愿者维护。由于其成熟性,Cygwin 的软件包库非常庞大,其 Windows POSIX 环境是现有实现中最完整的之一。Cygwin 是将 GNU 和其他软件移植到 Windows 上使用最广泛的系统之一。

安装 Cygwin

要安装 Cygwin,请从 Cygwin 的官方网站下载安装程序,网址为 www.cygwin.com。安装程序名为 setup-x86_64.exe。Cygwin 的安装程序不使用 Windows 安装数据库;你只需删除其安装目录即可卸载 Cygwin。

Cygwin 安装程序的一个独特且有用的特点是,它将下载的软件包缓存到你选择的文件系统位置。这个缓存可以作为独立的安装源,供以后安装时使用。

运行安装程序时会出现一个安装向导,其首页如 图 17-1 所示。

Image

图 17-1:Cygwin64 安装程序的初始版权屏幕

点击 下一步 进入安装向导的第二页,见 图 17-2。

在此,你需要选择获取软件包的方式。你可以选择通过互联网或本地安装目录获取。你还可以选择从互联网下载文件,但不进行安装,这对于建立本地安装源、从相同的下载文件缓存中为多个系统安装软件很有用。选择 从互联网安装,然后点击 下一步 继续到下一页,见 图 17-3。

Image

图 17-2:Cygwin64 安装程序的安装类型屏幕

Image

图 17-3:Cygwin64 安装程序的安装位置屏幕

在这里,系统会询问你希望将 Cygwin 安装到哪里。默认位置是C:\cygwin64,建议你保持这个默认位置,尽管如果选择安装到非默认位置,Cygwin 在管理所需的系统更改方面要比其他一些安装程序做得更好。

如果你是 Windows 的高级用户,现在很可能会有强烈的想法,想将默认位置更改为 Windows 中更合理的位置。我劝你不要这样做。问题在于,你正试图将 Cygwin 视为一个应用程序,尽管它从技术上讲是一个应用程序,但它也可以被看作是类似于完整的 Linux 虚拟机安装。它提供了一个对 Windows 来说是外部的(外国的)开发环境,这使得它与 Windows 的操作系统在某种程度上类似。从这个角度来看,也许更容易理解为什么Cygwin64目录值得在硬盘上与Windows目录并排存在。

点击下一步进入下一个页面,如图 17-4 所示。

Image

图 17-4:Cygwin64 安装程序的本地软件包目录屏幕

现在需要选择一个本地软件包目录。这是存储下载的软件包文件的目录。请选择您 Windows 系统上一个合理的位置——例如您的下载目录。在该位置将创建一个Cygwin目录,并包含一个子目录,用于存储从每个互联网源下载的软件包。点击下一步进入下一个页面,如图 17-5 所示。

Image

图 17-5:Cygwin64 安装程序的代理设置屏幕

现在,你可以选择或修改代理设置。通常,你可以使用默认的系统代理设置。那些在工作或家庭环境中使用代理的人,通常已经习惯了为互联网应用程序配置此类设置,并知道如何处理这里的选项。点击下一步进入下一个页面,如图 17-6 所示。

Image

图 17-6:Cygwin64 安装程序的软件包下载源屏幕

选择一个软件包下载源。就像 Linux 发行版一样,你可以使用多个站点作为 Cygwin 的软件包源。选择一个地理位置接近你的站点,以确保安装速度最快,然后点击下一步开始下载软件包目录,如图 17-7 所示。

Image

图 17-7:Cygwin64 安装程序的软件包目录下载屏幕

从选定的源站点下载并解析软件包目录只需几秒钟,随后将显示软件包管理器主界面,如图 17-8 所示。

Image

图 17-8:Cygwin64 安装程序的软件包管理器屏幕

我已展开“所有根元素”和“开发”类别,以显示该类别中的前几个包,按字母顺序排序。在“开发”类别中,选择以下附加包,方法是点击“新建”列右侧的下拉箭头,并为每个包选择列表中可用的最高版本号(有少数例外情况):

  • autoconf2.5 (2.69-3)

  • automake (10-1)

  • automake1.15 (1.15.1-1)

  • binutils (2.29-1)

  • gcc-core (7.4.0-1)

  • gcc-g++ (7.4.0-1 - 可选)

  • libtool (2.4.6-6)

  • make (4.2.1-2)

注意

在浏览包列表时,您会注意到一些包已被预选中。请不要取消选择任何默认包。

这个列表中的每一项都包含一个基础包名称,后跟括号中的包版本。版本管理系统类似于标准的 Linux 发行版。上游源包的版本后缀为连字符,后面跟着打包者的版本。例如,autoconf2.5 包的源包版本是 2.69,而打包者的版本是 3。打包者的版本特定于发行版——在本例中是 Cygwin。

Cygwin 使用滚动发布机制,这意味着 Cygwin 包会在更新的源包版本可用时,或 Cygwin 维护者使用这些包时进行独立更新。我在这里列出的版本是写作时的当前版本。您当前的版本号可能更新。请选择最新的版本,而不是我列出的版本。您可以随时使用对话框顶部的搜索框快速查找列表中的包。选择了这些附加包后,点击 下一步 继续到下一页,如图 17-9 所示。

Image

图 17-9:Cygwin64 安装程序的下载确认界面

审核此处的列表以确保您选择了所需的包后,点击 下一步 开始下载过程,如图 17-10 所示。

Image

图 17-10:Cygwin64 安装程序的包下载进度界面

由于您选择仅安装少数几个包,因此这应该不会花费太长时间。过程完成后,点击 下一步 继续到下一个界面,如图 17-11 所示。

Image

图 17-11:Cygwin64 安装程序的图标选择界面

选择您希望在 Windows 系统上创建图标的位置。这里有两个复选框选项:桌面和开始菜单。如果您选择不添加任何图标,仍然可以通过在资源管理器窗口或命令提示符或 PowerShell 提示符中执行 C:\cygwin64\cygwin.bat 来运行 Cygwin 终端程序。

点击 完成 以关闭包管理器。当你想通过添加或删除软件包,或者更新现有软件包到更新版本时,只需再次运行 setup-x86_64.exe。^(11) 你需要重新经历所有相同的初始界面,但包管理器会记住你之前的选项和当前已安装的所有软件包,允许你根据需要修改现有配置。

打开 Cygwin 终端

第一次执行 Cygwin 终端时,表示骨架 .bashrc.bash_profile.inputrc.profile 文件已复制到 Cygwin 文件系统中的 /home/username 目录。

理解 Cygwin 文件系统的最佳方式是通过在终端中执行mount命令,查看 Cygwin 如何将你的 Windows 文件系统资源映射到它自己的文件系统中:

$ mount
C:/cygwin64/bin on /usr/bin type ntfs (binary,auto)
C:/cygwin64/lib on /usr/lib type ntfs (binary,auto)
C:/cygwin64 on / type ntfs (binary,auto)
C: on /cygdrive/c type ntfs (binary,posix=0,user,noumount,auto)
Z: on /cygdrive/z type vboxsharedfolderfs (binary,posix=0,user,noumount,auto)
$

Cygwin 自动将 C:\cygwin64C:\cygwin64\binC:\cygwin64\lib 分别挂载到 //usr/bin/usr/lib。它还会将你的所有 Windows 驱动器根目录自动挂载到 /cygdrive 目录下,目录名称对应驱动器字母。我将 Windows 操作系统安装在 *C:* 驱动器上,并通过 VirtualBox 的共享文件夹系统将 *Z:* 驱动器映射到我的 Linux 主机。因此,我可以从 Cygwin 的 POSIX 环境中完全访问我的 Windows 文件系统和 Linux 主机文件系统。^(12) 我也可以通过 C:\Cygwin64 目录从 Windows 访问 Cygwin 的整个文件系统。

测试构建

因为 Cygwin 提供了在其自己的 POSIX 环境中访问 Windows 环境的功能,所以你可以直接从 Cygwin shell 提示符运行之前安装的 Git for Windows 独立版本。一个更好的选择,但仅适用于 Cygwin 终端内的是,从其包管理器安装 Cygwin 版本的 git。为什么这个选项更好?因为 Cygwin 的 git 包比 Windows 版本更了解 Cygwin 的文件系统约定。例如,Windows 版本有时在 POSIX 环境中查看时会创建具有错误权限的文件。

与 MinGW 和 Msys2 不同,Cygwin 可以在 Cygwin 文件系统中正确管理符号链接。回想一下 第十三章,以及本章前面的内容,我们需要在 PATH 中的某个位置创建指向 gnulib-tool 工具的符号链接,以便 b64 的 bootstrap.sh 脚本能够找到 Gnulib。现在让我们在 Cygwin 终端中进行此操作。将以下命令中省略的部分填写为你克隆的 Gnulib 的正确路径:

$ ln -s /cygdrive/c/.../gnulib/gnulib-tool /usr/bin/gnulib-tool

此命令在 Cygwin 的 /usr/bin 目录中创建一个符号链接,指向你克隆的 Gnulib 工作区根目录中的 gnulib-tool 程序。

默认情况下,Cygwin 将符号链接创建为文本文件,并标记为 Windows 系统(S)属性,这使得它们在正常的 Windows 目录列出命令和 Windows 文件资源管理器中不可见。如果你检查 Cygwin 符号链接文件的内容,你会发现它包含一个魔术标记 !<symlink>,后面是以 UTF-16 格式(以小端字节顺序标记 0xFFFE 开头)表示的目标文件系统条目的路径。

你可以通过导出包含文本 winsymlinks:nativestrictCYGWIN 环境变量来配置 Cygwin 创建真正的 Windows 符号链接。然而,如果你这样做,你必须以管理员身份运行 Cygwin 终端,因为默认情况下,创建 Windows 原生符号链接需要管理员权限。Windows 10 的最近版本允许在不提升权限的情况下创建原生符号链接,前提是你愿意将系统切换到所谓的“开发者模式”。

话虽如此,Cygwin 自己的符号链接管理系统运行得非常好,只要解释链接的工具是为 Cygwin 平台构建的。事实上,要查看 Cygwin 符号链接文件的内容,你必须使用非 Cygwin 工具,因为 Cygwin 工具会直接跟随符号链接文件,而不是打开文件,即使在 Windows 命令提示符下也是如此!

现在,让我们为 Windows 构建 b64。我们将首先在 Cygwin 终端中切换到你在 Windows 系统上克隆的 b64 工作区,并运行 bootstrap.sh 脚本,以拉取我们的 Gnulib 依赖项并运行 autoreconf -i

$ cd /cygdrive/c/.../b64
$ ./bootstrap.sh
Module list with included dependencies (indented):
    absolute-header
  base64
--snip--
configure.ac:12: installing './compile'
configure.ac:20: installing './config.guess'
configure.ac:20: installing './config.sub'
configure.ac:6: installing './install-sh'
configure.ac:6: installing './missing'
Makefile.am: installing './depcomp'
$

现在,我们可以简单地运行 configuremake。我们将在一个子目录结构中执行此操作,这样我们以后可以重用这个工作区进行其他构建类型。注意,这里不需要指定 --build--host 选项来设置交叉编译。我们正在运行“本地”Cygwin 工具,这些工具会自动构建设计用于在主机平台上运行的 Cygwin 程序:

$ mkdir -p cw-builds/cygwin
$ cd cw-builds/cygwin
$ ../../configure
--snip--
checking for C compiler default output file name... a.exe
checking for suffix of executables... .exe
--snip--
checking build system type... x86_64-unknown-cygwin
checking host system type... x86_64-unknown-cygwin
--snip--
configure: creating ./config.status
config.status: creating Makefile
config.status: creating lib/Makefile
config.status: creating config.h
config.status: executing depfiles commands
$
$ make
make  all-recursive
make[1]: Entering directory '/cygdrive/c/.../cw-builds/cygwin'
--snip--
make[2]: Entering directory '/cygdrive/c/.../cw-builds/cygwin'
gcc -DHAVE_CONFIG_H -I. -I../../b64  -I./lib -I../../b64/lib   -g -O2 -MT src/
src_b64-b64.o -MD -MP -MF src/.deps/src_b64-b64.Tpo -c -o src/src_b64-b64.o
`test -f 'src/b64.c' || echo '../../b64/'`src/b64.c
mv -f src/.deps/src_b64-b64.Tpo src/.deps/src_b64-b64.Po
gcc  -g -O2   -o src/b64.exe src/src_b64-b64.o lib/libgnu.a
make[2]: Leaving directory '/cygdrive/c/.../cw-builds/cygwin'
make[1]: Leaving directory '/cygdrive/c/.../cw-builds/cygwin'
$

最后,我们将测试我们的新 b64.exe 程序,看它是否能在 Windows 上运行。虽然 Cygwin 终端看起来像 Linux,但它实际上只是一种访问 Windows 的类 Linux 方式,因此你可以从 Cygwin 终端执行 Windows 程序。这一点很好,因为它允许我们在测试过程中使用 Bash 版本的 echo,并通过 -n 选项来抑制默认的换行符:

$ cd src
$ ./b64.exe <../../bootstrap.sh
IyEvYmluL3NoCmdudWxpYi10b29sIC0tdXBkYXRlCmF1dG9yZWNvbmYgLWkK
$
$ echo -n "IyEvYmluL3NoCmdudWxpYi10b29sIC0tdXBkYXRlCmF1dG9yZWNvbmYgLWkK" |\
  ./b64 -d
#!/bin/sh
gnulib-tool --update
autoreconf -i
$

注意

我在此控制台列表中没有在命令上使用 .exe 扩展名来反转 base64 编码操作。我想展示的是,像 Windows 一样,Cygwin 不需要在可执行文件上使用扩展名。

如果你运行类似 Visual Studio 的 dumpbin.exe 或 Cygwin 的 cygcheck 工具,你会发现这个版本的 b64.exe 很依赖于 cygwin1.dll,它必须与你的程序一起发布。默认情况下,Cygwin 构建的是“Cygwin”软件——即设计用于在 Cygwin 平台上运行的软件,Cygwin 平台的重要组成部分是在 Windows 上的 cygwin1.dll

构建真正的 Windows 原生软件

你也可以安装 mingw-w64 工具链,并使用我们在“Linux 上交叉编译 Windows”一节中使用的相同技术进行编译。mingw-w64 工具链可以通过 Cygwin 包管理器获取,并且是我们在 Linux 上安装的同一工具链的 Cygwin 移植版本。

现在我们来做。再次运行setup-x86_64.exe程序,并跳过所有前置对话框,直到你到达包管理窗口。初始安装后,包管理器窗口显示的默认视图是你已安装软件包的待更新列表。根据你最初安装以来的时间长度,这个列表可能甚至是空的。从视图下拉框中选择完整选项,返回到完整的软件包列表。定位并选择(在开发类别下)以下要安装的软件包。你可能会看到比我列出的版本更新的选项;请选择最新的可用版本。你可以在搜索框中输入前缀(mingw64-)以缩小结果列表,找到你想要的软件包。

  • mingw64-i686-gcc-core (7.4.0-1)

  • mingw64-i686-gcc-g++ (7.4.0-1)

  • mingw64-x86_64-gcc-core (7.4.0-1)

  • mingw64-x86_64-gcc-g++ (7.4.0-1)

这两个软件包用于生成 32 位 Windows 软件,后两个用于生成 64 位 Windows 软件。点击下一步继续并安装这些额外的软件包。

注意

你可能会注意到在总结页面上,其他你没有明确选择的软件包也在安装。这是因为这四个软件包是元包,正如前面所描述的那样。如果你从最初安装 Cygwin 以来已经有一段时间,你可能还会看到之前安装的软件包的更新。

b64/cw-builds目录下为 32 位和 64 位的 mingw-w64 构建创建其他子目录:

$ pwd
/cygdrive/c/.../cw-builds
$ mkdir mingw32 mingw64
$ cd mingw32
$

现在开始使用 mingw-w64 交叉工具集的 i686 变体,在mingw32目录中构建 32 位 Windows 程序:

$ cd mingw32
$ ../../configure --build=x86_64-unknown-cygwin --host=i686-w64-mingw32
--snip--
checking for C compiler default output file name... a.exe
checking for suffix of executables... .exe
--snip--
checking build system type... x86_64-unknown-cygwin
checking host system type... i686-w64-mingw32
--snip--
$
$ make
make  all-recursive
--snip--
i686-w64-mingw32-gcc -DHAVE_CONFIG_H -I. -I../../b64  -I./lib -I../../lib  -g
-O2 -MT src/src_b64-b64.o -MD -MP -MF src/.deps/src_b64-b64.Tpo -c -o src/
src_b64-b64.o `test -f 'src/b64.c' || echo '../../'`src/b64.c
mv -f src/.deps/src_b64-b64.Tpo src/.deps/src_b64-b64.Po
i686-w64-mingw32-gcc  -g -O2   -o src/b64.exe src/src_b64-b64.o lib/libgnu.a
make[2]: Leaving directory '/cygdrive/c/.../cw-builds/mingw32'
make[1]: Leaving directory '/cygdrive/c/.../cw-builds/mingw32'
$

虽然看起来可能有些奇怪,但你必须在configure命令行中使用--build--host选项进行 Windows 的交叉编译。原因是 mingw-w64 工具链不是 Cygwin 的默认工具链。你实际上是在告诉configure在哪里找到你想使用的非默认工具。从某种角度看,这实际上是一个交叉编译,因为你在 Cygwin 平台上构建非 Cygwin 软件。

对 64 位构建做相同操作:

$ cd ../mingw64
$ ../../configure --build=x86_64-unknown-cygwin --host=x86_64-w64-mingw32
--snip--
checking for C compiler default output file name... a.exe
checking for suffix of executables... .exe
--snip--
checking build system type... x86_64-unknown-cygwin
checking host system type... x86_64-w64-mingw32
--snip--
$
$ make
make  all-recursive
--snip--
x86_64-w64-mingw32-gcc -DHAVE_CONFIG_H -I. -I../..  -I./lib -I../../lib   -g
-O2 -MT src/src_b64-b64.o -MD -MP -MF src/.deps/src_b64-b64.Tpo -c -o src/
src_b64-b64.o `test -f 'src/b64.c' || echo '../../'`src/b64.c
mv -f src/.deps/src_b64-b64.Tpo src/.deps/src_b64-b64.Po
x86_64-w64-mingw32-gcc  -g -O2   -o src/b64.exe src/src_b64-b64.o lib/libgnu.
amake[2]: Leaving directory '/cygdrive/c/.../cw-builds/mingw64'
make[1]: Leaving directory '/cygdrive/c/.../cw-builds/mingw64'
$

注意

可能会觉得奇怪,64 位版本的 gcc 被称为x86_64-w64-mingw32-gcc。那末尾的32是怎么回事?原因在于 mingw 最初是一个 32 位的 Windows 编译器,专门叫做 mingw32。mingw32 项目最终更名为 MinGW,但一旦工具和包名称被广泛使用,它们就很难再更改了。

分析软件

为了真正理解这些构建之间的差异,你需要获得一个工具,用来查看我们用这三套工具集生成的b64.exe文件。你可以运行与 Visual Studio 一起提供的dumpbin.exe工具,或者使用 Cygwin 的cygcheck工具,如果你愿意的话。我在 GitHub 上找到了一款非常不错的工具,名为Dependencies,由用户 lucasg 开发。^(13)

首先,让我们查看cygcheck工具对于这三种版本程序的输出。我们从cw-builds目录开始,这样我们可以轻松访问所有版本:

   $ pwd
   /cygdrive/c/Users/.../cw-builds
   $
➊ $ cygcheck cygwin/src/b64.exe
   C:\Users\...\cw-builds\cygwin\src\b64.exe
     C:\cygwin64\bin\cygwin1.dll
       C:\Windows\system32\KERNEL32.dll
         C:\Windows\system32\ntdll.dll
         C:\Windows\system32\KERNELBASE.dll
   $
➋ $ cygcheck mingw32/src/b64.exe
   C:\Users\...\cw-builds\mingw32\src\b64.exe
   $
➌ $ cygcheck mingw64/src/b64.exe
   C:\Users\...\cw-builds\mingw64\src\b64.exe
     C:\Windows\system32\KERNEL32.dll
       C:\Windows\system32\ntdll.dll
       C:\Windows\system32\KERNELBASE.dll
     C:\Windows\system32\msvcrt.dll
   $

这里所传达的概念是,一个库是直接依赖于它上方直接的库或程序,并且是链条中更高层次的祖先的间接依赖。在➊,Cygwin 版本显示了一个依赖层次结构,其中cygwin1.dll位于顶端,紧接着是b64.exe,其余的所有库都是它的直接或间接依赖。这意味着b64.exe所做的每个系统或库调用,实际上都是直接调用cygwin1.dll,然后由它代表调用其他库。

64 位的 mingw64 版本在➌显示了一个类似的层次结构,除了b64.exe程序直接依赖于kernel32.dllmsvcrt.dll。这显然是一个本地 Windows 程序。

我版本的cygcheck工具在处理 32 位本地 Windows 软件时存在一些问题。你可以在➋看到,工具只显示了程序b64.exe,没有显示任何库依赖。为了查看这个版本的真实细节,让我们切换到我之前提到的 Dependencies 程序。我已经将这三个版本的程序加载到一个 Dependencies 实例中,如图 17-12 所示。

Image

图 17-12:作为 32 位 mingw-w64 程序构建的b64.exe的模块和导出

在这里,你可以看到 32 位的 mingw-w64 版本确实有类似于 64 位 mingw-w64 版本的库依赖。32 位版本使用的是C:\Windows\SysWOW64\msvcrt.dll,而 64 位版本使用的是C:\Windows\system32\msvcrt.dllkernel32.dll也是一样的。

Cygwin 版本与 mingw-w64 版本之间还有一些细微的差别。举个简单的例子,Cygwin 版本从cygwin1.dll中导入getopt。你或许还记得我们在 b64 中使用了 POSIX getopt函数来解析命令行选项。但是,你不会在msvcrt.dll中找到getopt,那么它是从哪里来的呢?mingw-w64 工具链提供了这样的 POSIX 功能的静态库,它最终成为了b64.exe的一部分。

MinGW: 最小化 GNU 工具集(Minimalist GNU for Windows)

1998 年,Colin Peters 发布了最初版本的mingw32。后来,为了避免给 MinGW 只能生成 32 位软件的印象,版本号被去掉了。^(14)

MinGW 最初只提供了 GCC 的 Cygwin 移植版。不久之后,Jan-Jaap Van der Heijden 创建了 GCC 的原生 Windows 移植版,并添加了 binutils 包和 GNU make。自那时以来,MinGW 就成为了 Cygwin 的一个非常流行的替代品,主要因为它的核心目标是创建与 Microsoft 工具生成的软件非常相似的软件。对于合理可移植的 C 代码,除了 Windows 系统和 Visual Studio 运行时库(msvcrt.dll)之外,不需要其他任何库。请记住,mingw-w64 在 2013 年之前是不可用的,因此在超过 10 年的时间里,MinGW 是生成原生 Windows 代码的唯一可用开源选项。

这一概念是 MinGW 项目所倡导的理念的核心。MinGW 的目标是仅使用标准 C 库作为抽象层,并在必要时修改源代码,以使其他关键包能够在 MinGW 下使用。

然而,GNU 软件中有一大部分使用了 pthreads 库。为了适应这一主要的 GNU 软件包,MinGW 通过提供一个名为 pthreads-win32.dll 的库来采取务实的做法。这个库今天在软件的依赖列表中经常出现,以至于许多人并不将它与 MinGW 联系在一起。实际上,一些使用 Microsoft 工具编译的可移植软件甚至独立使用 pthreads-win32.dll 作为可移植的线程库,在 POSIX 和 Windows 环境中都依赖 POSIX 线程。^(15)

使用 MinGW 有一个主要的缺点,最近这个问题变得更加突出:MinGW 仍然只生成 32 位的原生 Windows 应用程序。微软和英特尔最近联合宣布,未来某个版本的 Windows 将只支持 64 位硬件。虽然 32 位软件通常可以在 64 位系统上运行,但随着时间推移,这种情况可能会发生变化。MinGW 的包库并未提供生成 64 位 Windows 目标代码的 GCC 编译器,但如果你愿意放弃 MinGW 的包管理器,第三方可以在 MinGW 平台上提供 mingw-w64。然而,这种做法是不被鼓励的,因为将第三方包添加到环境中可能会导致包管理器无法解决的依赖问题。

MinGW 社区多年来一直靠着它的名字存活,直到最近该项目才开始接受资金捐赠以帮助维护。或许额外的财务支持能激励社区推动这些重要的升级。

安装 MinGW

尽管 MinGW 仅生成 32 位 Windows 程序和库,但它仍然值得关注,因为使用它对于已经可移植的软件来说非常简单且高效。一旦你开始理解 MinGW 和 Msys 的工作原理,转向其他基于 Windows 的 POSIX 平台就变得微不足道,因为它们都基于某种类似 Msys 的环境。

我们将从安装 MinGW 开始,这个过程简单得不能再简单了。在你喜欢的浏览器中访问 www.mingw.org。点击顶部菜单栏中的 Downloads 标签。这条链接会带你进入 MinGW 项目的 osdn.net^(16) 下载页面。向下滚动一些(小心避免点击那些看起来像是合法下载按钮的巨大绿色广告链接)。在标有“Operating System: Windows”的灰色条下,点击带有 Windows 10 类似图标的小蓝色按钮。将 mingw-get-setup.exe 程序保存到你硬盘上的某个位置。

运行此程序时,会显示一个非常简单的基于对话框的安装程序界面,用于 MinGW 安装管理器设置工具,如 图 17-13 所示。

Image

图 17-13:MinGW 安装管理器设置工具展示的初始对话框

这个程序实际上安装了 MinGW 安装管理器,它是一个类似于 Cygwin 包管理器的工具,可以精细地控制安装或更新的 MinGW 组件。在安装管理器出现之前,更新 MinGW 的唯一选择是卸载现有的完整安装版本,然后重新从头开始安装新的版本,或者尝试升级,这样的操作往往是运气成分多。

尽管版权日期似乎过时,但根据最新更新(截至本书撰写时),该设置程序和安装管理器本身在 2017 年 9 月最后一次更新。安装管理器会保持软件包目录的最新状态,因此你始终可以访问到最新的 MinGW 软件包。例如,包含 GCC 8.2.0 的软件包在 2018 年 8 月上传。等你阅读本文时,它可能已经更新为更高版本。

继续点击 Install。你将看到一个选项页面,如 图 17-14 所示。

Image

图 17-14:MinGW 安装管理器设置工具展示的选项页面

与 Cygwin 类似,MinGW 希望被安装在系统驱动器根目录下的路径中,同样像 Cygwin 一样,你需要把 MinGW 当作一个虚拟化的操作系统。因此,它需要在 Windows 文件系统中一个特殊的地方。

另外,与 Cygwin 类似,你会发现 MinGW 不是通过 Windows 安装数据库来安装的,因此它不会出现在 Windows 已安装程序面板中。事实上,你只需删除 C:\MinGW 目录,就可以完全从你的 Windows 系统中移除 MinGW。

注意

如果你决定安装到其他位置,你需要仔细阅读 MinGW 网站上的初始安装说明,因为在安装后,你需要对 C:\MinGW\msys\1.0\etc 目录中的文件做额外的修改。

保持所有选项不变,然后点击 继续。你将看到下一个屏幕,这是下载进度页面,显示最新的安装管理器程序正在被下载到 C:\MinGW\libexec\mingw-get 目录中。 图 17-15 显示了该对话框的状态,一旦目录已从下载源更新,最新版本的安装管理器就会被下载并安装。

Image

图 17-15:MinGW 安装管理器设置工具的下载进度页面

点击 继续 打开安装管理器,如 图 17-16 所示。

Image

图 17-16:安装管理器主屏幕与软件包上下文菜单

在基本设置面板中(默认显示的),你看到的软件包实际上是元包,或者说是指向一大组实际包的包。要查看实际包,你可以选择左侧的 所有软件包 选项,然后滚动浏览右侧显示的列表。当你准备好继续时,返回到基本设置面板。

选择 mingw-developer-toolkit-bin 元包时,将自动选择 msys-base-bin 元包。这两个元包,再加上 mingw32-base-bin 元包,就是你将 C 程序编译为 32 位原生 Windows 程序所需的所有内容。选择这三个包,如 图 17-16 所示,然后点击 应用更改 选项,位于 安装 菜单中,如 图 17-17 所示。

Image

图 17-17:在安装管理器中应用所选更改

你将看到一个名为“待处理操作调度”的确认对话框,允许你应用已调度的更改,推迟这些更改以返回主窗口并修改当前列表,或者简单地丢弃所有更改。选择 应用,如 图 17-18 所示。

Image

图 17-18:安装管理器的待处理操作调度对话框

最后,你会看到下载包对话框,如 图 17-19 所示,其中显示了你选择下载的 112 个包,并且每个包都有一个进度条。

Image

图 17-19:安装管理器的下载包对话框

注意

由于 MinGW 下载站点在本写作后无疑会有所更新,因此你可能会看到在 图 17-18 显示的对话框底部出现不同数量的软件包待安装。

这可能需要一些时间,具体取决于你的互联网连接速度,所以下去吃点零食吧。

注意

如果你遇到任何包下载错误,只需点击 确定 以关闭错误对话框,等待剩余包的下载和安装成功完成,然后从 安装 菜单再次点击 应用更改,重新尝试下载和安装失败的包。只有失败的包会被重新下载。

一旦所有包下载完毕,它们将被安装到 C:\MinGW\msys\1.0 目录下的标准类 Unix 目录结构中。图 17-20 显示了安装管理器的应用已安排更改对话框,表示它已安装每个先前下载的包。

Image

图 17-20:安装管理器的应用已安排更改对话框

现在你可以关闭安装管理器程序了。准备好 MinGW 安装的最后一步是为 MinGW 终端创建一个方便的桌面快捷方式,它是一个稍显过时的 Bash shell 版本,已移植到 Windows 并在 Windows Console Host (conhost.exe) 进程中运行。MinGW 会在 C:\MinGW\msys\1.0\msys.bat 安装一个 Windows 批处理文件。执行该批处理文件启动提供 POSIX 构建环境的 MinGW 终端。我喜欢在桌面上为这个文件创建一个快捷方式,并将图标更改为指向同一目录下的 msys.ico 文件。

双击 msys.bat 文件,启动 MinGW 终端。你会发现,pwd 显示你仍然处于 /home/**username 目录,其中 username 是你的 Windows 系统用户名。

和 Cygwin 一样,理解文件系统的最佳方式是使用mount查看 MinGW 文件系统中的挂载点:

$ mount
C:\Users\...\AppData\Local\Temp on /tmp type user (binmode,noumount)
C:\MinGW\msys\1.0 on /usr type user (binmode,noumount)
C:\MinGW\msys\1.0 on / type user (binmode,noumount)
C:\MinGW on /mingw type user (binmode)
c: on /c type user (binmode,noumount)
d: on /d type user (binmode,noumount)
z: on /z type user (binmode,noumount)
$

这个输出看起来和 Cygwin 中的类似,但有一些区别。首先,MinGW 将你的 Windows 用户临时目录挂载为 /tmp。其次,/usr/ 都代表相同的 Windows 目录,C:\MinGW\msys\1.0。最后,C:\MinGW 本身被挂载到 /mingw 下。

Windows 驱动器的管理方式也略有不同。Windows 驱动器字母会像在 Cygwin 中一样显示在 MinGW 文件系统中,但它们直接列在根目录下,而不是作为单独的顶级目录。这里还有一个细微的区别是,我的 D: 驱动器被列出了。它是一个虚拟光驱,没有挂载介质。MinGW 即使没有介质也会显示它,而 Cygwin 只有在挂载了介质时才显示它。

如果你 cat 查看 /etc/fstab 文件的内容,你会看到前面大多数内容是硬编码的。唯一实际软配置的挂载点是 /mingw 路径:^(17)

$ cat /etc/fstab
# /etc/fstab -- mount table configuration for MSYS.
# Please refer to /etc/fstab.sample for explanatory annotation.

# MSYS-Portable needs this "magic" comment:
# MSYSROOT=C:/MinGW/msys/1.0
# Win32_Path                              Mount_Point
#-------------------------------------    -----------
C:/MinGW                                  /mingw
$

测试构建

现在我们准备尝试构建 b64 项目。首先,我们应该清理 b64 目录,以便在此环境中演示 bootstrap.sh,所以请从 MinGW 终端进入 b64 目录并使用 git 删除所有工件。然后,为测试 MinGW 创建一个构建目录结构:

$ cd /c/Users/.../Documents/dev/b64
$ git clean -xfd
--snip--
$ mkdir -p mgw-builds/mingw
$

在这一点上,为了不让你走向必然的失败,我先声明,你很快会遇到符号链接的问题。虽然 Cygwin 在其环境中没有问题地创建和使用符号链接,但 MinGW 就不一样了。如果你尝试从 /usr/bin/gnulib-tool 创建一个指向 .../gnulib/gnulib-tool 程序的符号链接,你会发现 ln -s 命令似乎执行了,但当你尝试运行 bootstrap.sh 时,它却找不到 Gnulib。仔细检查后,你会发现你以为创建的符号链接其实只是一个复制品。嗯,复制品是行不通的,因为 gnulib-tool 使用它在文件系统中的实际位置作为 Gnulib 仓库的基准,而在另一个位置的 gnulib-tool 复制品无法做到这一点。

为了解决这个问题,我们需要调整 b64 的 bootstrap.sh 程序,使用相对路径来引用实际的 gnulib-tool。我将 gnulib 克隆到 b64 旁边,所以我只需要更改 bootstrap.sh,使其引用 ../gnulib/gnulib-tool,而不再依赖系统 PATH 中的可访问路径。使用任何编辑器,按照清单 17-1 中的提示,在你的系统上进行类似的更改。

#!/bin/sh
../gnulib/gnulib-tool --update
autoreconf -i

清单 17-1: b64/bootstrap.sh: 允许 MinGW 找到 Gnulib 所需的更改

注意

在对 bootstrap.sh 做出这些更改后,你应该期待在运行 b64.exe 时看到不同的输出。

这将解决我们在 Gnulib 上遇到的问题,但这里还有另一个潜在问题。虽然 MinGW 可能拥有最新的 GCC 工具链,但它在 Autotools 上的更新没有那么及时。我们一直在使用 Autoconf 2.69 和 Automake 1.15.1,但截至本文写作时,MinGW 只提供 Autoconf 2.68 和 Automake 1.11.1。也许等你阅读本文时,这些工具已经更新,你就不必做出文中清单 17-2 中所示的更改。请在做这些更改之前,先检查一下你的 Autoconf 和 Automake 版本。

#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.68])
AC_INIT([b64], [1.0], [b64-bugs@example.com])
AM_INIT_AUTOMAKE([subdir-objects])
AC_CONFIG_SRCDIR([src/b64.c])
AC_CONFIG_HEADERS([config.h])
AC_CONFIG_MACRO_DIR([m4])

# Checks for programs.
AC_PROG_CC
AM_PROG_CC_C_O
--snip--
AC_OUTPUT

清单 17-2: b64/configure.ac: 与 Autoconf 2.68 配合使用所需的更改

高亮的行显示了需要进行的更改。首先,我们需要将 AC_PREREQ 中支持的最低版本降低,以允许 Autoconf 2.68 处理这个 configure.ac 文件。然后,我们需要将 Automake 宏 AC_CONFIG_MACRO_DIRS(复数形式)更改为它的 Autoconf 对应宏 AC_CONFIG_MACRO_DIR。这两个宏的功能相同,只不过 Automake 1.15.1 版本中的宏使我们能够省略在 Makefile.am 文件中使用 AC_LOCAL_AMFLAGS = -I m4。幸运的是,我已经把这一行加到了 Makefile.am 中,并且一直保留在那里,所以这个更改很容易。最后,Automake 1.15.1 将 AM_PROG_CC_C_O 的功能合并到 AM_INIT_AUTOMAKE 宏中,当启用 subdir-objects 选项时。如果回退到 Automake 1.11.1,我们需要改回旧的格式,显式地提到 AM_PROG_CC_C_O,并且它必须位于 AC_PROG_CC 之后。

经过这些更改后,我们可以最终运行 bootstrap.sh 来生成我们的 configure 脚本:

$ ./bootstrap.sh
Module list with included dependencies (indented):
    absolute-header
  base64
--snip--
configure.ac:13: installing `./compile'
configure.ac:21: installing `./config.guess'
configure.ac:21: installing `./config.sub'
configure.ac:6: installing `./install-sh'
configure.ac:6: installing `./missing'
lib/Makefile.am: installing `./depcomp'
$

现在进入之前创建的 mgw-builds/mingw 目录,并使用相对路径运行 configure 回到 b64

$ cd mgw-builds/mingw
$ ../../configure
--snip--
checking for C compiler default output file name... a.exe
checking for suffix of executables... .exe
checking whether we are cross compiling... no
--snip--
checking build system type... i686-pc-mingw32
checking host system type... i686-pc-mingw32
--snip--
configure: creating ./config.status
config.status: creating Makefile
config.status: creating lib/Makefile
config.status: creating config.h
config.status: executing depfiles commands

$ make
--snip--
make[2]: Entering directory `/c/Users/.../mgw-builds/mingw'
gcc -DHAVE_CONFIG_H -I. -I../..  -I./lib -I../../lib   -g -O2 -MT src/src_b64-
b64.o -MD -MP -MF src/.deps/src_b64-b64.Tpo -c -o src/src_b64-b64.o `test -f
'src/b64.c' || echo '../../'`src/b64.c
mv -f src/.deps/src_b64-b64.Tpo src/.deps/src_b64-b64.Po
gcc  -g -O2   -o src/b64.exe src/src_b64-b64.o lib/libgnu.a
make[2]: Leaving directory `/c/Users/.../mgw-builds/mingw'
make[1]: Leaving directory `/c/Users/.../mgw-builds/mingw'
$

DependenciesGUI.exe 中打开 b64.exe 会显示它是一个仅依赖 SysWOW64\kernel32.dllSysWOW64\MSVCRT.dll 的 32 位 Windows 程序。

Msys2

Msys2 由 OneVision Software 公司于 2013 年开发,采用“清洁室”技术,以便放宽 Cygwin 强加的开源许可要求,并为 Cygwin 和 MinGW 使用的过时 Msys 环境提供更现代的替代方案。

MSys2 使用的是 OneVision 64 位版本的 MinGW 工具链,称为 mingw-w64。然而,像 Cygwin 一样,Msys2 提供了一个名为 msys-2.0.dll 的 POSIX 系统级功能库。Msys2 提供了一个以这个库为基础实现的 C 标准库。你可以像检测 Cygwin 平台一样,检测一个 Windows 程序是否为 Msys2 平台构建。

因为 Msys2 在很大程度上是 Cygwin 的功能替代品,而且由于很多人已经习惯了 Cygwin 的工作方式,Msys2 在推广上遇到了一些困难,尽管它已经被一些关键用户使用,包括 Git for Windows。Msys2 被宣传为 Cygwin 的升级版,但实际上 Msys2 仅提供了与 Cygwin 相同的可移植性机制的不同实现。

区分 Msys2 和 Cygwin 的一个显著特点是,Msys2 的开源许可证比 Cygwin 更宽松。Cygwin 使用基于 GPL 的许可证,而 Msys2 仅使用标准的 3 款 BSD 许可证,这使得它成为使用 Linux 工具构建专有 Windows 软件的可行选项。

OneVision 系统中最重要的功能是 MinGW 编译器的 64 位移植版——这不仅仅是因为它运行在 64 位平台上(它确实如此),更因为它生成 64 位的 Windows 代码。可以说,当这个编译器发布时,跨编译 Windows 代码的世界得到了极大的扩展。此后,它已被移植到许多不同的平台上。

Msys2 会与 Windows 安装数据库连接,因此你可以通过 Windows 安装程序面板卸载 Msys2。

什么是 Msys?

“Msys” 这个术语多年来被错误使用。有人认为它意味着“Windows 上的 Unix”,或者至少是类似的意思。实际上,它所提供的只是潜力。最基本的 Msys 提供了一个 Unix 兼容的终端程序,一个类似 Bourne 的 shell(通常是 Bash),以及一套基础的实用程序。一些实现提供了更多的工具,而一些则提供了较少的工具。不管你使用的是哪种实现,Cygwin、MinGW 还是 Msys2,这些包中的 Msys 组件都可以让你通过安装额外的包来构建你喜欢的环境。

当 OneVision 创建 Msys2 时,公司的愿景是从小做起,让用户能够按照自己的需求构建环境。Msys2 版本的 Msys 预装的包非常少,而 Cygwin 预装了许多。Msys2 和 Cygwin 的终端窗口都基于mintty.exe,^(18) 而 MinGW 则基于conhost.exe(Windows 控制台宿主进程),你可以从外观和操作感受上判断这一点,因为 Cygwin 和 Msys2 的终端看起来非常相似,但与 MinGW 的终端有显著的不同。

安装 Msys2

Msys2 的安装过程非常简单。在你的网页浏览器中导航到 Msys2 的主页www.msys2.org,然后点击页面顶部的按钮,选择 32 位(msys2-i686-yyyymmdd.exe)或 64 位(msys2-x86_64-yyyymmdd.exe)版本的 Msys2 安装程序。^(19) 下载完成后,运行安装程序,你将看到一个对话框式的安装向导。欢迎页面如图 17-21 所示。

Image

图 17-21:Msys2 安装工具的欢迎页面

点击下一步进入下一页面,如图 17-22 所示。

Image

图 17-22:Msys2 安装工具的安装文件夹页面

选择安装位置。像 Cygwin 和 MinGW 一样,Msys2 也希望安装在系统驱动器的根目录下。我建议使用默认位置,原因和我对另外两个系统的建议相同。点击下一步进入下一页面,如图 17-23 所示。

Image

图 17-23:Msys2 安装工具的快捷方式页面

你可以选择 Windows 开始菜单文件夹,安装程序将在其中创建快捷方式。默认设置已经足够。点击下一步进入下一页面,开始安装,如图 17-24 所示。

Image

图 17-24:Msys2 安装工具的安装进度页面(显示详细信息)

安装完成后,点击下一步进入最后一页,如图 17-25 所示。

Image

图 17-25:Msys2 安装工具的最后一页

在此,你可以选择在安装完成后启动 Msys2。点击完成退出安装程序。你可以让安装程序在退出时自动启动 Msys2,或者转到 Windows 10 开始菜单,找到MSYS2 64bit文件夹,然后点击MSYS2 MSYS条目。这两种方式都会通过执行C:\msys64\msys2_shell.cmd脚本并带有-msys命令行选项来启动 Msys2 终端窗口。

与其他系统的安装程序不同,Msys2 安装程序不会从互联网上下载包。而是像 Linux 发行版发布一样,Msys2 安装一小部分基础包,这些包会随着时间的推移变得越来越过时,直到最终,Msys2 维护者发布了一个新的安装程序。因此,首先需要做的就是更新这些已安装的基础包。

Msys2 使用 Arch Linux 包管理器 Pacman 的 Windows 移植版,提供对移植到 Msys2 的包仓库的访问。基本安装提供的包比较少,需要先用 Pacman 更新才能安装更多的包。

打开终端窗口,我们将使用命令pacman -Syu来更新 Msys2 系统。Pacman 命令是大写的,命令的选项是小写的。-S命令是远程仓库的“同步”命令。此命令的-u选项会从远程仓库更新现有包。-y选项会在检查更新前更新仓库的目录。要获取命令的帮助,请运行pacman -h。要获取某个命令的可用选项的帮助,请在命令行中加上-h和该命令。例如,要获取-S命令的可用选项的帮助,请运行pacman -Sh

现在继续更新系统,首先下载最新的目录:

$ pacman -Syu
:: Synchronizing package databases...
 mingw32                                           530.8 KiB     495K/s 00:01
 mingw32.sig                                       119.0   B     116K/s 00:00
 mingw64                                           532.0 KiB     489K/s 00:01
 mingw64.sig                                       119.0   B     116K/s 00:00
 msys                                              178.2 KiB     655K/s 00:00
 msys.sig                                          119.0   B    0.00B/s 00:00
:: Starting core system upgrade...
warning: terminate other MSYS2 programs before proceeding
resolving dependencies...
looking for conflicting packages...

Packages (6) bash-4.4.023-1  filesystem-2018.12-1  mintty-1~2.9.5-1
             msys2-runtime-2.11.2-1  pacman-5.1.2-1  pacman-mirrors-20180604-2

Total Download Size:   19.04 MiB
Total Installed Size:  68.24 MiB
Net Upgrade Size:      11.96 MiB

:: Proceed with installation? [Y/n] Y

在提示时按 y(或直接按回车接受默认设置)继续更新 Msys2 系统。你会注意到更新的包数量很少。基础 Msys2 系统几乎什么都没有。核心系统包括 Bash,一个类似 Unix 的文件系统模拟器,位于 Windows 文件系统之上;mintty,Msys2 的 Msys 运行时;以及 Pacman 包管理器,因此 Msys2 安装程序安装过时的包并在首次使用时需要更新其实并不是问题。

Pacman 将下载并安装多个包,包括 Pacman:

:: Retrieving packages...
 msys2-runtime-2.11.2-1-x86_64                        2.5 MiB  1012K/s 00:03
 bash-4.4.023-1-x86_64                             1931.4 KiB  1003K/s 00:02
 filesystem-2018.12-1-x86_64                         46.3 KiB   242K/s 00:00
 mintty-1~2.9.5-1-x86_64                            296.7 KiB  1648K/s 00:00
 pacman-mirrors-20180604-2-any                       17.1 KiB  2.09M/s 00:00
 pacman-5.1.2-1-x86_64                               14.3 MiB  1010K/s 00:14
(6/6) checking keys in keyring
(6/6) checking package integrity
(6/6) loading package files
(6/6) checking for file conflicts
(6/6) checking available disk space
warning: could not get file information for opt/
:: Processing package changes...
(1/6) upgrading msys2-runtime
(2/6) upgrading bash
(3/6) upgrading filesystem
(4/6) upgrading mintty
(5/6) upgrading pacman-mirrors
(6/6) upgrading pacman
warning: terminate MSYS2 without returning to shell and check for updates again
warning: for example close your terminal window instead of calling exit

当你到达此步骤时,Pacman 本身需要更新,但它无法在运行时进行自我更新——这是 Windows 管理运行中可执行文件的方式所带来的一个结果。它会显示一条消息,提示你应该通过点击右上角的 X 关闭终端窗口,然后从 Windows 开始菜单重新启动 Msys2 并继续安装过程。

注意

如果你看到弹出框警告有正在运行的进程,只需点击确定关闭窗口即可。

再次运行pacman -Su(无需再次更新目录),继续更新过程,并在提示时按回车。这时,许多更多的包将会被更新。按 y(或直接按回车)继续,然后等待更新过程完成。

在继续安装其他包之前,先通过运行mount命令来查看文件系统:

$ mount
C:/msys64 on / type ntfs (binary,noacl,auto)
C:/msys64/usr/bin on /bin type ntfs (binary,noacl,auto)
C: on /c type ntfs (binary,noacl,posix=0,user,noumount,auto)
Z: on /z type vboxsharedfolderfs (binary,noacl,posix=0,user,noumount,auto)
$

你会注意到,Msys2 的基础安装目录C:\msys64已挂载在/下,而C:\msys64\usr\bin已挂载在/bin下。与其他系统一样,Windows 驱动器会以它们的驱动器字母挂载。Msys2 像 MinGW 一样从根目录挂载它们。(Cygwin 也可以配置成这样。)与 Cygwin 类似,Msys2 不会挂载没有媒体的光驱,所以你不会看到我的D:驱动器挂载在这里作为/d,但是如果我插入一个虚拟光盘并执行mount命令,它会出现在列表中。

安装工具

到此为止,Msys2 已经完全更新,并且准备好让你添加工具了。那么我们应该添加什么工具呢?我敢猜测,大多数没有明确目的的 Msys2 探索者在此时就会放弃。Msys2 Wiki 网站上有相当完整的文档,但你确实需要至少阅读引导材料,才能正确理解 Msys2。

虽然 Cygwin 和 MinGW 在每个步骤上都明确告诉你应该走哪条路,但 Msys2 只是提供了选择。你可以为 Msys2 环境构建仅限 POSIX 的软件,或者为 32 位或 64 位 Windows 应用程序构建原生应用程序。Msys2 不会通过预安装某个特定目标的软件来劝说你走某一条路。

Msys2 提供了三种不同的终端窗口快捷方式,每种快捷方式都配置了不同的命令行选项,以便为 Msys2 支持的三个目标之一构建软件:Msys2 原生应用程序、32 位 Windows 应用程序或 64 位 Windows 应用程序。这与交叉编译并没有本质区别;它只是针对特定的工具链,使用定制环境,而不是在configure命令行中使用选项。

显然,我们的目标并不是构建 Msys2 软件。相反,我们的目标是构建 Windows 软件,而 Msys2 完全支持这个目标。要了解实现目标需要安装哪些内容,可以阅读 Msys2 Wiki 页面上名为“创建包”的文章。^(20)根据该页面,以下是重要的 Pacman 组:

base-devel 所有目标所必需

msys2-devel 用于构建 Msys2 原生 POSIX 包

mingw-w64-i686-toolchain 用于构建原生 32 位 Windows 软件

mingw-w64-x86_64-toolchain 用于构建原生 64 位 Windows 软件

对于我们的目的来说,这些包中的第一个和最后一个就足够了。虽然这并不完全显而易见,但除非你安装了msys2-devel元包,否则你不会获得gccbinutils包。像 Cygwin 一样,Msys2 把它自己的平台视为拥有原生的 Msys2 工具链,生成的应用程序完全依赖于msys2.0.dll

Msys2 没有 GUI 包安装器,所以让我们从探索一些 Pacman 命令开始。像大多数包管理系统一样,Pacman 提供的包被分组为逻辑集合。-Sg选项可以显示包组列表:

$ pacman -Sg
kf5
mingw-w64-i686-toolchain
mingw-w64-i686
mingw-w64-i686-gimp-plugins
kde-applications
kdebase
mingw-w64-i686-qt4
mingw-w64-i686-qt
mingw-w64-i686-qt5
--snip--
$

要查看某个组包含哪些包,只需将组名添加到上一行命令的末尾:

$ pacman -Sg mingw-w64-i686-toolchain
mingw-w64-i686-toolchain mingw-w64-i686-binutils
mingw-w64-i686-toolchain mingw-w64-i686-crt-git
mingw-w64-i686-toolchain mingw-w64-i686-gcc
mingw-w64-i686-toolchain mingw-w64-i686-gcc-ada
mingw-w64-i686-toolchain mingw-w64-i686-gcc-fortran
mingw-w64-i686-toolchain mingw-w64-i686-gcc-libgfortran
mingw-w64-i686-toolchain mingw-w64-i686-gcc-libs
mingw-w64-i686-toolchain mingw-w64-i686-gcc-objc
mingw-w64-i686-toolchain mingw-w64-i686-gdb
--snip--
$

当你使用pacman -S安装一个包并给它一个组名时,它会显示组成员列表并询问你要安装哪些成员。如果你直接按回车,它会安装所有成员。--needed选项确保只下载尚未安装的包。如果没有这个选项,你将下载并安装目标组中已经安装的包:

$ pacman -S --needed base-devel mingw-w64-i686-toolchain \
    mingw-w64-x86_64-toolchain
:: There are 56 members in group base-devel:
:: Repository msys
   1) asciidoc  2) autoconf  3) autoconf2.13  4) autogen  ...
--snip--
Enter a selection (default=all):
warning: file-5.35-1 is up to date -- skipping
warning: flex-2.6.4-1 is up to date -- skipping
--snip--
:: There are 17 members in group mingw-w64-x86_64-toolchain:
:: Repository mingw64
   1) mingw-w64-x86_64-binutils  2) mingw-w64-x86_64-crt-git  ...
--snip--
Enter a selection (default=all):
--snip--
Total Download Size:    185.24 MiB
Total Installed Size:  1071.62 MiB

:: Proceed with installation? [Y/n] Y

按 y 以下载并安装你请求的包。这可能需要一些时间,所以我想现在是时候来点小吃了。

测试构建

一旦这些包安装完成,你的 Msys2 环境就可以使用了。让我们再次切换到b64目录,进行清理,然后为 Msys2 构建测试创建另一个构建目录。

如果你在本章中进行操作并刚刚完成 MinGW 测试,你的bootstrap.shconfigure.ac文件可能已经被修改。这些更改在当前环境下是可以正常工作的。不过,如果你决定还原,只需还原configure.ac的更改。bootstrap.sh文件应保持不变,继续使用相对路径引用gnulib-tool。如果你跳过了 MinGW 部分,你需要通过为gnulib-tool的执行添加 Gnulib 仓库工作区的相对路径来修改bootstrap.sh。Msys2 和 MinGW 一样,存在创建目标的副本而非工作符号链接的问题,因此在/usr/bin中创建gnulib-tool的符号链接在这里不起作用:^(21)

$ cd /c/Users/.../Documents/dev/b64
$ git clean -xfd
$ mkdir -p ms2-builds/mw32 ms2-builds/mw64
$ ./bootstrap.sh
Module list with included dependencies (indented):
    absolute-header
  base64
--snip--
configure.ac:12: installing './compile'
configure.ac:21: installing './config.guess'
configure.ac:21: installing './config.sub'
configure.ac:6: installing './install-sh'
configure.ac:6: installing './missing'
Makefile.am: installing './depcomp'
$

在我们可以构建 b64 之前,我们需要更换终端。你可能已经注意到,Msys2 在它创建的 Windows 开始菜单中的MSYS2 64bit文件夹里配置了三个快捷方式。到目前为止,我们一直使用MSYS2 MSYS快捷方式来启动 Msys2 终端。只要我们只是安装包或者要面向 Msys2 平台,这个方法是没问题的。

我们正在构建 64 位原生 Windows 软件,所以现在请打开一个 MinGW 64 位终端。然后,在该终端窗口中,切换到b64/ms2-builds/mw64目录并构建 b64:

$ cd /c/Users/.../Documents/dev/b64/ms2-builds/mw64
$ ../../configure
configure: loading site script /mingw64/etc/config.site
--snip--
checking for C compiler default output file name... a.exe
checking for suffix of executables... .exe
checking whether we are cross compiling... no
--snip--
checking build system type... x86_64-w64-mingw32
checking host system type... x86_64-w64-mingw32
--snip--
configure: creating ./config.status
config.status: creating Makefile
config.status: creating lib/Makefile
config.status: creating config.h
config.status: executing depfiles commands
$
$ make
make  all-recursive
make[1]: Entering directory '/c/Users/.../ms2-builds/mw64'
--snip--
make[2]: Entering directory '/c/Users/.../ms2-builds/mw64'
gcc -DHAVE_CONFIG_H -I. -I../../b64  -I./lib -I../../lib   -g -O2 -MT src/
b64-b64.o -MD -MP -MF src/.deps/b64-b64.Tpo -c -o src/b64-b64.o `test -f 'src/
b64.c' || echo '../../'`src/b64.c
mv -f src/.deps/b64-b64.Tpo src/.deps/b64-b64.Po
gcc  -g -O2   -o src/b64.exe src/b64-b64.o lib/libgnu.a
make[2]: Leaving directory '/c/Users/.../ms2-builds/mw64'
make[1]: Leaving directory '/c/Users/.../ms2-builds/mw64'
$

这些终端窗口之间的差异由以MSYS开头的各种环境变量的值定义。这些变量用于配置configure输出的第一行中引用的站点配置文件。有关如何以这种方式配置环境的详细信息,请参见 Msys2 维基。

通过b64.exe程序与DependenciesGUI.exe一起运行,我们可以看到它是一个真正的原生 64 位 Windows 程序,仅依赖于 Windows 系统和 Visual Studio 运行时库。

总结

经过刚才的快速巡览,你现在应该能毫不费力地使用 GNU 工具构建 Windows 软件了。就个人而言,我发现 Cygwin 和 Msys2 环境对于许多用途来说是最有用的。

Msys2 稍微现代一些,看起来更清新,但它们都可以作为 GNU 工具的良好通用平台。Msys2 还具有一个独特的优势,即在可能的情况下构建纯 Windows 软件,而 Cygwin(除非使用 mingw-w64 交叉工具)构建的应用程序虽然可以在 Windows 上运行,但依赖于 Cygwin 系统库。这对我来说不一定是一个障碍。如果还有其他因素并且 Cygwin 最终占据上风,那么我不介意额外的库,但内心的纯粹主义者倾向于希望没有不必要的第三方库。

Cygwin 的优势在于成熟度。它并不落后于 Msys2 到无法使用的程度,但它已经存在足够长的时间,拥有一些不错的功能,比如将一个有效的符号链接机制集成到其所有工具中,从而能够正确地模拟 POSIX 符号链接。它还提供了更多可供安装的包。

MinGW 稍微有些过时,确实需要支持 64 位 Windows 构建,但它简洁且小巧。凭借其新的包管理器,它与其他两个相比表现得相当不错。它真正需要进入主流的,只是拥抱mingw-w64包中的更新的 64 位代码生成器。将其从基于 conhost 的控制台升级为 mintty 也许会是个不错的选择。

第十八章:创建优秀项目的技巧和可重用解决方案目录

*经验是一个严厉的老师,因为她先给测试,然后才教课。

—Vernon Sanders Law^(1)*

Image

本章最初是作为一个可重用解决方案的目录——可以说是预设宏。但当我完成了之前的章节后,我意识到我需要拓宽我对 预设解决方案 的定义。与其仅仅列出有趣的宏,本章列出了几个不相关但对创建优秀项目非常重要的技巧。其中一些与 GNU Autotools 相关,但其他的只是涉及开源和自由软件项目的良好编程实践。

项目 1:将私有细节与公共接口隔离

有时,我遇到过设计不良的库接口,其中一个项目的 config.h 文件是该项目公共头文件所必须的。当消费者需要多个这样的库时,就会出现问题。应该包含哪个 config.h 文件呢?它们的名字相同,而且很可能提供了相似或相同的定义。

当你仔细考虑 config.h 的目的时,你会发现将其暴露在库的公共接口中(通过将其包含在任何公共头文件中)没有多大意义,因为它的目的是为库的特定构建提供平台特定的定义。另一方面,便携库的公共接口定义上是平台无关的。

接口设计是计算机科学中的一个相当通用的话题。这个项目专注于如何避免在公共接口中包含 config.h,并通过这一点,确保你永远不要安装 config.h

在为其他项目设计库时,你有责任避免将无用的垃圾从你的头文件污染到消费者的符号空间。我曾经参与过一个项目,它使用了来自另一个团队的库接口。该团队提供了 Windows 和 Unix 版本的库,头文件在这两个平台之间是可移植的。不幸的是,他们没有理解干净接口的定义。在他们的公共头文件中,某个地方有一段代码看起来像是 清单 18-1。

#ifdef _WIN32
# include <windows.h>
#else
typedef void * HANDLE;
#endif

清单 18-1:一个设计不良的公共头文件,暴露了平台特定的头文件

哎呀!他们真的需要仅仅为了定义 HANDLE 就包含 windows.h 吗?不需要,他们可能应该为其公共接口中的句柄对象使用一个不同的名称,因为 HANDLE 太过通用,容易与其他许多库的接口冲突。像 XYZ_HANDLE 或更具体的与 XYZ 库相关的名称会是更好的选择。

为了正确设计一个库,首先设计公共接口,尽可能少地暴露库的内部实现。现在,你需要确定合理的定义是什么,但这通常会在抽象和性能之间进行折衷。

在设计 API 时,从你想要暴露的库功能开始;设计能最大化易用性的函数。如果你发现自己在选择更简单的实现和更简单的用户体验之间犹豫不决,始终倾向于为消费者提供更易用的体验。他们会通过实际使用你的库来感谢你。当然,如果接口已经由软件标准定义,那么你的工作大部分已经完成。通常情况并非如此,你将不得不做出这些决策。

接下来,尝试抽象掉内部细节。不幸的是,C 语言并不容易做到这一点,因为你通常需要在公共 API 中传递包含内部实现细节的结构引用,而消费者不需要看到这些细节。(C++在这方面实际上更糟:C++类在同一个类定义中定义公共接口和私有实现细节。)

C 中的解决方案

在 C 语言中,解决这个问题的常见方法是为私有结构定义一个公共别名,通常是通过通用(void)指针。许多开发者不喜欢这种方法,因为它降低了接口的类型安全性,但类型安全性的丧失被接口抽象化的提升大大抵消了,正如示例 18-2 和 18-3 所示。

#include <abc_pub.h>

# include <config.h>

typedef struct
{
    /* private details */
} abc_impl;

int abc_func(abc * p)
{
    abc_impl * ip = (abc_impl *)p;
    /* use 'p' through 'ip' */
}

示例 18-2:一个私有 C 语言源文件的示例

typedef void abc;
int abc_func(abc * p);

示例 18-3: abc_pub.h:一个描述公共接口(API)的公共头文件

请注意,抽象化方便地减轻了在库的公共接口中包含大量非常私有定义的需求。^(2)

但有一种方法可以更好地利用语言语法。在 C 语言中,有一个鲜为人知、且使用更少的概念,叫做前向声明,它允许你在公共头文件中仅仅声明类型,而不必在那里实际定义它。示例 18-4 提供了一个使用前向声明来定义函数声明中使用的类型的库公共头文件的示例。

struct abc;
--snip--
int abc_func(struct abc * p);

示例 18-4:在公共头文件中使用前向声明

当然,使用struct abc假设你的公共接口中的其他函数返回该类型对象的指针,你可以将其传递给abc_func。如果用户需要在传递其地址之前填写结构体,那么这个机制显然不适用。相反,这里的使用仅仅是为了隐藏struct abc的内部实现。

C++中的解决方案

在 C++ 中也可以使用前向声明,但方式不同。在 C++ 中,前向声明更多用于最小化编译时头文件之间的相互依赖,而不是在公共接口中隐藏实现细节。然而,我们可以使用其他技术来将实现细节隐藏在用户之外。

在 C++ 中,使用接口抽象来隐藏实现细节可以通过几种不同的方式完成,其中包括使用虚拟接口和 PIMPL(私有实现)模式。

PIMPL 模式

在 PIMPL 模式中,实现细节通过指向一个私有实现类的指针隐藏,该指针作为公共接口类中的私有数据存储,正如 清单 18-5 和 18-6 中所示。

#include <abc_pub.h>

# include <config.h>

class abc_impl
{
    /* private details */
};

int abc::func(void)
{
    /* use 'pimpl' pointer */
}

清单 18-5:一个私有的 C++ 语言源文件,展示了 PIMPL 模式的正确使用

➊ class abc_impl;

   class abc {
   ➋ abc_impl * pimpl;
   public:
     int func(void);
};

清单 18-6: abc_pub.h: 公共头文件通过 PIMPL 模式仅暴露少量私有细节。

如前所述,C++ 语言也允许使用前向声明(如 ➊ 处的声明),用于任何仅通过引用或指针使用的类型(如 ➋ 所示),但在公共接口中从未实际取消引用。因此,私有实现类的定义不需要暴露在公共接口中,因为编译器可以愉快地编译公共接口头文件,而不需要私有实现类的定义。

这里的性能权衡通常涉及动态分配一个私有实现类的实例,然后通过这个指针间接访问类的数据,而不是直接在公共结构体中访问。请注意,所有内部细节现在都方便地隐藏了,因此公共接口不需要这些细节。

C++ 虚拟接口

使用 C++ 的另一种方法是定义一个公共的 接口 类,其方法被声明为 纯虚拟,接口由库内部实现。要访问该类的对象,消费者调用一个公共的 工厂 函数,该函数返回指向实现类的指针,并以接口定义的形式表示。清单 18-7 和 18-8 展示了 C++ 虚拟接口的概念。

#include <abc_pub.h>

# include <config.h>

class abc_impl : public abc {
    virtual int func(void) {
        int rv;
        // implementation goes here
        return rv;
   }
};

清单 18-7:一个私有的 C++ 语言源文件,实施了一个纯虚拟接口

   #define xyz_interface class

   xyz_interface abc {
   public:
     virtual int func(void) = 0;
   };

➊ abc * abc_instantiate( /* abc_impl ctor params */ );

清单 18-8: abc_pub.h: 一个公共的 C++ 语言头文件,仅提供接口定义

在这里,我使用 C++ 预处理器定义了一个新的关键字 xyz_interface。根据定义,xyz_interfaceclass 同义,因此可以互换使用。这里的理念是接口不会向消费者暴露任何实现细节。公共的 工厂 函数 abc_instantiate 在 ➊ 返回一个指向 abc_impl 类型新对象的指针,除了 abc 之外。因此,公共头文件中不需要显示任何内部内容给调用者。

可能看起来虚拟接口类的方法比 PIMPL 方法更高效,但实际上,大多数编译器将虚拟函数调用实现为由隐藏的 vptr 地址引用的函数指针表,存在于实现类中。因此,你最终还是会通过指针间接调用所有公共方法。你选择隐藏实现细节的技术更多是个人偏好的问题,而不是性能问题。^(3)

当我设计一个库时,我首先设计一个最小的,但完整的,功能接口,并尽可能多地将我的内部实现抽象化。我尽量在函数原型中只使用标准库的基本类型,然后仅包含那些由这些类型和定义所需的 C 或 C++ 标准头文件。这种技巧是我发现的创建高可移植性和可维护性接口的最快方法。

如果你仍然看不出这条建议的价值,那么让我给你再提供一个情景来思考。考虑一下当一个 Linux 发行版的打包者决定为你的库创建一个 devel 包时会发生什么——即,一个包含静态库和头文件的包,设计用于安装到目标系统的 /usr/lib/usr/include 目录中。你的库所需的每一个头文件必须安装到 /usr/include 目录中。如果你的库的公共接口需要包含你的 config.h 文件,那么扩展来说,你的 config.h 文件必须安装到 /usr/include 目录中。现在考虑一下当多个此类库需要安装时会发生什么。哪一份 config.h 文件会被保留?在 /usr/include 目录中只能存在一个 config.h 文件。

我在 Autotools 邮件列表上看到过一些讨论,支持在公共接口中发布 config.h 的必要性,并提供一些为包特定方式命名 config.h 的技巧。这些技巧通常涉及对该文件进行某种形式的后处理,以重命名其宏,以避免与其他包安装的 config.h 定义冲突。虽然这样做是可行的,并且在某些情况下有一些合理的理由(通常是涉及到一个无法修改的广泛使用的遗留代码库,修改它会破坏大量现有代码),但这些情况应该被视为例外,而不是常规,因为一个设计良好的项目不应该需要在其公共接口中暴露平台和项目特定的定义。

如果你的项目在公共接口中无法没有config.h,可以探索AC_CONFIG_HEADERS宏的细节。像所有实例化宏一样,这个宏接受一个输入文件列表。autoheader工具只会写入列表中的第一个输入文件,因此你可以手动创建第二个输入文件,包含你认为必须包含在公共接口中的定义。记得命名你的公共输入文件,以减少与其他包公共接口的冲突。

注意

此外,还可以探索 Autoconf 宏库中的AX_PREFIX_CONFIG_H宏(见“项目 8:使用 Autoconf 宏库”在第 528 页),它会为 config.h 中的所有项添加自定义前缀。

项目 2:实现递归扩展目标

扩展目标是你为实现某个构建目标而编写的make目标,Automake 并不会自动支持该目标。递归扩展目标是那种遍历你的项目目录结构,访问 Autotools 构建系统中的每个Makefile.am文件,并在扩展目标被创建时为每个文件提供执行操作的机会。

当你向构建系统中添加新的顶级目标时,你必须将其绑定到现有的 Automake 目标,或者将你自己的make代码添加到所需的目标中,来遍历 Automake 在你的构建系统中提供的子目录结构。

SUBDIRS变量用于递归遍历当前目录的所有子目录,并将请求的构建命令传递到这些目录中的 makefile。这对于那些必须基于配置选项构建的目标非常有效,因为在配置之后,SUBDIRS变量仅包含那些注定要被构建的目录。

然而,如果你需要在所有子目录中执行新的递归目标,不管SUBDIRS中是否排除了某些目录,你可以使用DIST_SUBDIRS变量。

有多种方法可以遍历构建层次结构,包括 GNU make特有语法提供的一些非常简单的一行命令。但最具可移植性的方法是使用 Automake 本身使用的技术,如清单 18-9 所示。

my-recursive-target:
      ➊ $(preorder_commands)
        for dir in $(SUBDIRS); do \
          ($(am__cd) $$dir && $(MAKE) $(AM_MAKEFLAGS) $@) || exit 1; \
        done
      ➋ $(postorder_commands)

.PHONY: my-recursive-target

清单 18-9:具有递归目标的 makefile(警告:SUBDIRS中不支持“.”)

在层次结构的某个点,你需要做一些有用的事情,而不仅仅是向下调用更低层次。➊处的preorder_commands宏可以用于在递归进入更低层次的目录之前做一些必要的操作。➋处的postorder_commands宏同样可以在从更低层次的目录返回后执行额外的操作。只需在需要进行某些前序或后序处理的 makefile 中定义这两个宏中的任何一个或两个,即可为my-recursive-target执行相应操作。

例如,如果你想生成一些文档,你可能会有一个名为doxygen的特殊目标。即使你可以在顶层目录中构建文档,仍然可能需要将文档的生成分发到项目层次结构中的各个目录。你可能会在项目中的每个Makefile.am文件中使用类似于清单 18-10 中所示的代码。

   # uncomment if doxyfile exists in this directory
➊ # postorder_commands = $(DOXYGEN) $(DOXYFLAGS) doxyfile

   doxygen:
           $(preorder_commands)
        ➋ for dir in $(SUBDIRS); do \
         ➌ ($(am__cd) $$dir && $(MAKE) $(AM_MAKEFLAGS) $@) || exit 1; \
          done
          $(postorder_commands)

.PHONY: doxygen

清单 18-10:为doxygen目录实现postorder_commands**

对于没有doxyfile的目录,你可以注释掉(或者更好的是,干脆省略)在➊处的postorder_commands宏定义。在这种情况下,doxygen目标将通过➋处的三行 Shell 代码无害地传播到构建树中的下一级。

在➌处的exit语句确保当低级 makefile 在递归目标上失败时,构建会终止,并将 Shell 错误代码(1)传播回每个父 makefile,直到到达顶层 Shell。这一点非常重要;如果没有它,构建可能会在失败后继续,直到遇到另一个错误。

注意

我选择不使用有些不太便携的-C make命令行选项来在运行子make操作之前更改目录。我还使用了一个名为am__cd的 Automake 宏来更改目录。这个宏被定义为考虑CDPATH环境变量的内容,以减少构建过程中不必要的输出噪音。你可以用cd(或chdir)来替代它。检查一个由 Automake 生成的 makefile,看看 Automake 是如何定义这个宏的。

如果你选择以这种方式实现一个完全递归的全局目标,你必须在项目中的每个Makefile.am文件中包含清单 18-10,即使该 makefile 与文档生成无关。如果不这样做,make将在该 makefile 上失败,因为其中不存在doxygen目标。命令可能什么都不做,但目标必须存在。

如果你想做一些更简单的事情,比如将一个目标传递到顶层目录下的单个子目录(例如,紧邻顶层的doc目录),那么生活就变得更轻松了。只需实现清单 18-11 和 18-12 中所示的代码。

doxygen:
     ➊ $(am__cd) doc && $(MAKE) $(AM_MAKEFLAGS) $@

.PHONY: doxygen

清单 18-11:一个将目标传播到单个子目录的顶层 makefile

doxygen:
        $(DOXYGEN) $(DOXYFLAGS) doxyfile

.PHONY: doxygen

清单 18-12: doc/Makefile.am: 处理新目标的代码

在清单 18-11 的顶层 makefile 中的➊处,Shell 语句只是将目标(doxygen)传递到目标目录(doc)。

注意

假设变量DOXYGENDOXYFLAGS已经存在,它们是通过某些宏或 Shell 代码在configure脚本中执行的。

Automake 的递归目标更为复杂,因为它们还支持make-k命令行选项,以便在发生错误后继续构建。此外,Automake 的递归目标实现支持在SUBDIRS变量中使用点(.),它代表当前目录。你也可以支持这些功能,但如果支持的话,你的标准递归make shell 代码将变得更为混乱。为了完整起见,清单 18-13 展示了支持这些功能的实现。将这个清单与清单 18-9 进行对比,突出显示的 shell 代码展示了这两者之间的差异。

my-recursive-target:
        $(preorder_commands)
        @failcom='exit 1'; \
        for f in x $$MAKEFLAGS; do \
          case $$f in \
            *=* | --[!k]*);; \
         ➊ *k*) failcom='fail=yes';; \
          esac; \
        done; \
        for dir in $(SUBDIRS); do \
 ➋ if test "$$dir" != .; then \
             ($(am__cd) $$dir && $(MAKE) $(AM_MAKEFLAGS) $@) || eval \
                $$failcom; \
           fi; \
         done
         $(postorder_commands)

.PHONY: my-recursive-target

清单 18-13:添加make -k和检查当前目录

在➊,case语句检查MAKEFLAGS环境变量中是否存在-k选项,并在找到该选项时将failcom shell 变量设置为一些无害的 shell 代码。如果未找到该选项,则failcom保持其默认值exit 1,然后在错误发生时插入该命令以退出。➋处的if语句仅跳过SUBDIRS中的点条目递归调用。如前所述,针对当前目录,递归目标的功能完全包含在$(preorder_commands)$(postorder_commands)宏扩展中。

我尝试在这个项目中向你展示,你可以根据自己的需要做更多或更少的递归目标实现。大多数实现其实只是命令中的 shell 代码。

项目 3:在包版本中使用仓库修订版本号

版本控制是每个项目中非常重要的一部分。它不仅保护知识产权,还允许开发者在经历了一系列错误后进行备份并重新开始。像 Git 和 Subversion 这样的版本控制系统的一个优势是,系统为每次更改分配一个唯一的修订版本号。这意味着任何项目源代码的发布都可以逻辑上与特定的仓库修订版本号相关联。本节介绍了一种技术,允许你将仓库修订版本号自动插入到包的 Autoconf 版本字符串中。

Autoconf 的AC_INIT宏的参数必须是静态文本。也就是说,它们不能是 shell 变量,Autoconf 会将尝试在这些参数中使用 shell 变量的行为标记为错误。除非你在配置过程中想要计算包版本号的某部分,否则这一点完全没有问题。

我曾经尝试在 AC_INITVERSION 参数中使用 shell 变量,以便在执行 configure 时将我的 Subversion 修订号替换到 VERSION 参数中。我花了几天时间试图弄清楚如何欺骗 Autoconf 让我在软件包的版本号中使用 shell 变量作为 修订 字段。最终,我发现了清单 18-14 中展示的技巧,并在我的 configure.ac 文件和顶层 Makefile.am 文件中实现了该技巧。

➊ SVNREV=`LC_ALL=C svnversion $srcdir 2>/dev/null`
➋ if ! svnversion || case $SVNREV in Unver*) true;; *) false;; esac;
  ➌ then SVNREV=`cat $srcdir/SVNREV`
  ➍ else echo $SVNREV>$srcdir/SVNREV
    fi
➎ AC_SUBST(SVNREV)

清单 18-14: configure.ac: 作为软件包版本的一部分实现动态修订号

在这里,shell 变量 SVNREV 在 ➊ 处被设置为 svnversion 命令的输出,该命令在项目顶层目录中执行。输出是一个原始的 Subversion 修订号——也就是说,如果代码在一个真实的 Subversion 工作区中执行,但这并不总是如此。

当用户从分发归档中执行此 configure 脚本时,Subversion 可能甚至没有安装在他的工作站上。即使安装了,顶层项目目录也来自归档,而不是 Subversion 仓库。为了处理这些情况,➋ 处的行会检查 svnversion 是否能执行,或者第一行的输出是否以 Unversioned directory 这几个字母开头,这是在非工作区目录上执行 svnversion 工具时的结果。

如果这两种情况之一为真,SVNREV 变量将在 ➌ 处从名为 SVNREV 的文件的内容中填充。该项目应配置为随分发归档一起发送 SVNREV 文件,该归档包含了清单 18-14 中的配置代码。必须这样做,因为如果 svnversion 生成了一个真实的 Subversion 仓库修订号,该值会立即通过 ➍ 处的 if 语句的 else 子句写入 SVNREV 文件。

最后,➎ 处对 AC_SUBST 的调用将 SVNREV 变量替换到模板文件中,包括项目的 makefile 文件。

在顶层的 Makefile.am 文件中,我通过将 SVNREV 文件添加到 EXTRA_DIST 列表中,确保它成为分发归档的一部分。因此,当维护者创建并发布分发归档时,它将包含一个 SVNREV 文件,其中记录了用于从该源代码生成归档的源树修订号。SVNREV 文件中的值也会在从该 tarball 中的源代码生成归档时使用(通过 make dist)。这是准确的,因为原始归档实际上是从这个特定修订的 Subversion 仓库生成的。

通常来说,项目的分发归档能否生成正确的分发归档并不特别重要,但一个由 Automake 生成的归档可以做到这一点,而无需进行此修改,因此它在此修改的情况下也应该能够做到。清单 18-15 突出了对顶层 Makefile.am 文件的相关更改。

EXTRA_DIST = SVNREV
distdir = $(PACKAGE)-$(VERSION).$(SVNREV)

第 18-15 节: Makefile.am: 配置为 SVN 修订号的顶层 makefile

在 第 18-15 节 中,distdir 变量控制由 Automake 生成的分发目录名称和归档文件名。在顶层的 Makefile.am 文件中设置此变量会影响分发归档的生成,因为该 Makefile.am 文件是最终生成的 Makefile 中包含此功能的位置。

注意

注意 SVNREV 文件名和SVNREV make* 变量 [$(SVNREV)] 在 第 18-15 节 中的相似性。尽管它们看起来相同,添加到 EXTRA_DIST 行的文本指的是顶层项目目录中的 SVNREV 文件,而添加到 distdir 变量的文本则指向一个 make 变量。

对于大多数目的,在顶层 Makefile.am 文件中设置 distdir 应该足够。然而,如果你需要在项目中的另一个 Makefile.am 文件中正确格式化 distdir,只需在该文件中也设置它即可。

本项中介绍的技术并不会在你提交新更改时自动重新配置项目以生成新的 SVNREV 文件(因此更改构建中使用的 Subversion 修订号)。我本可以通过一些恰当的 make 规则添加此功能,但那样会迫使每次构建时检查是否有提交^(4)。

第 18-16 节 显示了与 第 18-14 节 中的代码类似的代码,区别在于此代码适用于 Git,而非 Subversion。

GITREV=`git -C $srcdir rev-parse --short HEAD`
if [ -z "$GITREV" ];
  then GITREV=`cat $srcdir/GITREV`
  else echo $GITREV>$srcdir/GITREV
fi
AC_SUBST(GITREV)

第 18-16 节: configure.ac: 实现一个 Git 动态修订号

这个版本对我来说似乎更直观,因为 git 工具更好地利用了错误条件的正确输出通道——如果当前工作目录不是 Git 仓库,命令的输出会被发送到 stderr

当然,你还应该修改 第 18-15 节 中的代码,引用 GITREV 文件而不是 SVNREV 文件。

如果你已经在使用 Gnulib,另一个很好的选择是使用该库中的 version-gen 模块。此模块提供了许多与将版本号融入构建过程相关的好功能。

第 4 项: 确保你的分发包是干净的

你是否曾经下载并解压了一个开源包,然后尝试运行 ./configure && make,结果在这些步骤中的某一环节失败了?当你深入研究问题时,或许发现了归档中的缺失文件。可惜的是,这种情况发生在 Autotools 项目中,尽管 Autotools 使得确保这一点不发生变得非常简单。

为了确保你的分发归档始终干净和完整,请在新创建的归档上运行distcheck目标。不要满足于你相信你包的状态。让 Automake 来运行分发单元测试。我将这些测试称为单元测试,因为它们为分发包提供了与常规单元测试为源代码提供的相同测试功能。

你绝不会在没有运行单元测试的情况下进行代码更改并发布软件包,对吧?(如果是这样,那么你可以放心跳过这一部分。)同样,不要在没有运行构建系统单元测试的情况下发布归档——在发布新归档之前,先在你的项目上运行make distcheck。如果distcheck目标失败了,找出原因并修复它。这样做的回报是值得的。

第 5 项:黑客攻关 Autoconf 宏

有时你需要一个 Autoconf 并未完全提供的宏。这时候,知道如何复制和修改现有的 Autoconf 宏就很有帮助。^(5)

例如,以下是一个解决常见 Autoconf 邮件列表问题的方案。一个用户想使用AC_CHECK_LIB来捕获所需的库到LIBS变量中。问题在于这个库导出了 C++ 函数,而不是 C 函数。AC_CHECK_LIB在处理 C++ 时并不太友好,主要是因为AC_CHECK_LIB对符号的假设是基于 C 语言的链接方式,而这些假设并不适用于 C++ 符号。

例如,广为人知的(并且是标准化的)C 链接规则指出,导出的 C 链接符号(在 Intel 系统上也称为cdecl调用约定)是区分大小写的,并且会加上前导下划线,^(6)而使用 C++ 链接导出的符号则是通过非标准的、厂商定义的规则进行改编的。这些修饰符是基于函数签名——具体来说,是参数的数量和类型,以及函数所属的类和/或命名空间。但确切的规则并未在 C++ 标准中定义。

现在,停下来思考一下,在什么情况下你可能会有符号从库中导出,并使用 C++ 链接方式。导出 C++ 符号有两种方式。第一种是(无论是故意还是不小心)导出全局函数而没有在函数原型中使用extern "C"链接说明。第二种是导出整个类——包括公共和受保护的方法以及类数据。

如果你不小心忘记在全局函数中使用extern "C",那么,请停止这种做法。如果你故意这么做,那我想知道为什么?我能想到的唯一理由是你想导出一个给定函数名的多个重载。这似乎是一个相当微不足道的理由,阻止你的 C 开发者使用你的库。

如果你导出的是类,那就是另一个问题了。在这种情况下,你专门为 C++ 用户提供支持,而这给AC_CHECK_LIB带来了一个实际问题。

Autoconf 为AC_CHECK_LIB的定义提供了一个框架,允许在 C 和 C++之间进行差异处理。如果在调用AC_CHECK_LIB之前使用AC_LANG([C++])宏,你将生成一个特定于 C++的测试程序版本。但不要抱太大希望;当前的 C++版本实现仅仅是 C 版本的复制。我预计设计一个通用的 C++实现充其量是非常困难的。

但并非一切都失去希望。虽然实现一个通用的版本可能很困难,但作为项目的维护者,你可以轻松地使用AC_CHECK_LIB的测试代码编写一个特定于项目的版本。

首先,我们需要找到AC_CHECK_LIB宏的定义。对 Autoconf 宏目录(通常是/usr/(local/)share/autoconf/autoconf)进行grep搜索,应该很快就能在名为libs.m4的文件中找到AC_CHECK_LIB的定义。因为大多数宏定义都以注释头开始,头部包含一个井号和宏的名称及一个空格,因此以下方法应该有效:

$ cd /usr/share/autoconf/autoconf
$ grep "^# AC_CHECK_LIB" *.m4
libs.m4:# AC_CHECK_LIB(LIBRARY, FUNCTION,
$

AC_CHECK_LIB的定义如清单 18-17 所示。^(7)

# AC_CHECK_LIB(LIBRARY, FUNCTION,
#             [ACTION-IF-FOUND], [ACTION-IF-NOT-FOUND],
#             [OTHER-LIBRARIES])
--snip--
# freedom.
AC_DEFUN([AC_CHECK_LIB],
[m4_ifval([$3], , [AH_CHECK_LIB([$1])])dnl
AS_LITERAL_IF([$1], [AS_VAR_PUSHDEF([ac_Lib], [ac_cv_lib_$1_$2])],
    [AS_VAR_PUSHDEF([ac_Lib], [ac_cv_lib_$1''_$2])])dnl
AC_CACHE_CHECK([for $2 in -l$1], [ac_Lib],
    [ac_check_lib_save_LIBS=$LIBS
    LIBS="-l$1 $5 $LIBS"
  ➊ AC_LINK_IFELSE([AC_LANG_CALL([], [$2])],
         [AS_VAR_SET([ac_Lib], [yes])],
         [AS_VAR_SET([ac_Lib], [no])])
     LIBS=$ac_check_lib_save_LIBS])
     AS_VAR_IF([ac_Lib], [yes],
         [m4_default([$3], [AC_DEFINE_UNQUOTED(AS_TR_CPP(HAVE_LIB$1))
         LIBS="-l$1 $LIBS"
     ])],
 [$4])dnl
AS_VAR_POPDEF([ac_Lib])dnl
])# AC_CHECK_LIB

清单 18-17:AC_CHECK_LIB的定义,如在libs.m4 中找到的

这个明显的难题通过一点分析就能轻松解决。宏似乎接受最多五个参数(如注释头部所示),其中前两个是必需的。突出显示的部分是宏的定义——我们将把它复制到我们的configure.ac文件中并修改,以便与我们的 C++导出功能兼容。

回想一下第十六章,M4 宏定义参数的占位符类似于 Shell 脚本的占位符:一个美元符号后跟一个数字。第一个参数用$1表示,第二个用$2表示,依此类推。我们需要确定哪些参数对我们重要,哪些可以忽略。我们知道,大多数对AC_CHECK_LIB的调用只传递前两个参数。第三和第四个参数是可选的,仅仅存在于你希望根据是否在指定的库中找到所需的函数来更改宏的默认行为时。第五个参数允许你提供一个额外的链接器命令行参数列表(通常是附加的库和库目录引用),这些是正确链接所需库的必要条件,以确保测试程序不会因为多余的原因失败。

假设我们有一个 C++库,导出了一个类的公共数据和方法。我们的库名为fancy,类名为Fancy,我们感兴趣的方法名为execute——特别是接受两个整数参数的execute方法。因此,它的签名将是

Fancy::execute(int, int)

当使用 C 链接导出时,这样的函数仅以_execute(或在某些平台上直接为execute,没有前导下划线)呈现给链接器,但当它使用 C++链接导出时,由于供应商特定的名称修饰,情况就复杂了。

让链接器找到这个符号的唯一方法是使用完全相同的签名在编译后的源代码中声明它,但我们没有向AC_CHECK_LIB提供足够的信息,以便在测试代码中正确声明函数签名。以下是所需的声明,告诉编译器如何正确地修饰此方法的名称:

class Fancy { public: void execute(int,int); };

假设我们正在寻找一个具有 C 连接性的名为execute的函数,AC_CHECK_LIB宏会生成一个像清单 18-18 中所示的小型测试程序。我已经突出显示了我们的函数名,以便你可以轻松看到宏如何将其插入到生成的测试代码中。

/* confdefs.h. */
#define PACKAGE_NAME ""
#define PACKAGE_TARNAME ""
#define PACKAGE_VERSION ""
#define PACKAGE_STRING ""
#define PACKAGE_BUGREPORT ""
/* end confdefs.h. */

/* Override any GCC internal prototype to avoid an error.
   Use char because int might match the return type of a GCC
   builtin and then its argument prototype would still apply. */
#ifdef __cplusplus
extern "C"
#endif

char execute ();
int
main ()
{
return execute ();
    ;
    return 0;
}

清单 18-18:一个由 Autoconf 生成的检查全局 C 语言 execute 函数的示例

除了这两处使用指定函数名的地方外,所有对AC_CHECK_LIB的调用生成的测试程序都是相同的。这个宏为所有函数创建一个通用的原型,以便所有函数都以相同的方式处理。然而,显然并不是所有函数都不接受参数并返回一个字符,正如这段代码中所定义的那样。AC_CHECK_LIB实际上是在向编译器谎报函数的真实性质。测试只关心测试程序是否能够成功链接;它永远不会尝试执行它(在大多数情况下,这一操作会以灾难性的方式失败)。

对于 C++ 符号,我们需要生成一个不同的测试程序——一个不对我们导出的符号的签名做任何假设的程序。

回顾一下清单 18-17 中的➊,看起来AC_LANG_CALL宏与清单 18-18 中测试代码的生成有关系,因为AC_LANG_CALL的输出会直接生成到对AC_LINK_IFELSE的调用的第一个参数中;其第一个参数是要用链接器测试的源代码。事实证明,这个宏也是另一个宏AC_LANG_PROGRAM的高级封装。清单 18-19 展示了这两个宏的定义。^(8)

   # AC_LANG_CALL(C)(PROLOGUE, FUNCTION)
   # -----------------------------------
   # Avoid conflicting decl of main.
   m4_define([AC_LANG_CALL(C)],
➊ [AC_LANG_PROGRAM([$1
 m4_if([$2], [main], ,
   [/* Override any GCC internal prototype to avoid an error.
       Use char because int might match the return type of a GCC
       builtin and then its argument prototype would still apply. */
   #ifdef __cplusplus
   extern "C"
   #endif
➋ char $2 ();])], [return $2 ();])])

   # AC_LANG_PROGRAM(C)([PROLOGUE], [BODY])
   # --------------------------------------
   m4_define([AC_LANG_PROGRAM(C)],
➌ [$1
   m4_ifdef([_AC_LANG_PROGRAM_C_F77_HOOKS], [_AC_LANG_PROGRAM_C_F77_HOOKS])[]dnl
   m4_ifdef([_AC_LANG_PROGRAM_C_FC_HOOKS], [_AC_LANG_PROGRAM_C_FC_HOOKS])[]dnl
   int
   main ()
   {
   dnl Do *not* indent the following line: there may be CPP directives.
   dnl Don't move the `;' right after for the same reason.
➍ $2
     ;
     return 0;
   }])

清单 18-19:AC_LANG_CALLAC_LANG_PROGRAM的定义

在➊处,AC_LANG_CALL(C)会生成对AC_LANG_PROGRAM的调用,将PROLOGUE参数作为第一个参数传入。在➌处,这个序言(以$1的形式)会立即发送到输出流。如果传递给AC_LANG_CALL(C)的第二个参数(FUNCTION)不是main,则会为该函数生成一个 C 风格的函数原型。在➋处,文本return $2 ();作为BODY参数传递给AC_LANG_PROGRAM,该宏在 ➍ 处使用这段文本生成对函数的调用。(记住,这段代码只会被链接,而不会执行。)

对于 C++,我们需要能够定义更多的测试程序,以使其不对我们导出的符号的原型做任何假设,而AC_LANG_CALL对 C 来说过于具体,因此我们将使用更底层的宏AC_LANG_PROGRAM。示例 18-20 展示了如何改写AC_CHECK_LIB来处理来自名为fancy的库中的函数Fancy::execute(int, int)。我已在第 512 页的示例 18-17 中标出了我修改原始宏定义的地方。

   AC_PREREQ([2.59])
   AC_INIT([test], [1.0])

   AC_LANG([C++])

   # --- A modified version of AC_CHECK_LIB
   m4_ifval([], , [AH_CHECK_LIB([fancy])])dnl
➊ AS_VAR_PUSHDEF([ac_Lib], [ac_cv_lib_fancy_execute])dnl
➋ AC_CACHE_CHECK([whether -lfancy exports Fancy::execute(int,int)], [ac_Lib],
   [ac_check_lib_save_LIBS=$LIBS
 LIBS="-lfancy $LIBS"
➌ AC_LINK_IFELSE([AC_LANG_PROGRAM(
   [[class Fancy {
       public: void execute(int i, int j);
   };]],
   [[ MyClass test;
     test.execute(1, 1);]])],
     [AS_VAR_SET([ac_Lib], [yes])],
     [AS_VAR_SET([ac_Lib], [no])])
   LIBS=$ac_check_lib_save_LIBS])
   AS_VAR_IF([ac_Lib], [yes],
     [AC_DEFINE_UNQUOTED(AS_TR_CPP(HAVE_LIBFANCY))
     LIBS="-lfancy $LIBS"
   ],
   [])dnl
   AS_VAR_POPDEF([ac_Lib])dnl
   # --- End of modified version of AC_CHECK_LIB

   AC_OUTPUT

示例 18-20:将修改版的AC_CHECK_LIB黑客化到 configure.ac 中

在示例 18-20 中,我在➊和➋处分别用库名和函数名替换了参数占位符,并在➌处添加了由AC_LANG_PROGRAM生成的程序的前言和主体。我还删除了一些与AC_CHECK_LIB的可选参数相关的冗余文本,这些参数在我的版本中不需要。

这段代码比简单的AC_CHECK_LIB调用要长得多,理解起来也更困难,因此它迫切需要被转化为一个宏。我会把这个任务留给你作为练习。读完第十六章后,你应该能够轻松完成这项任务。还要注意,这个宏有很多优化的空间。随着你对 M4 的熟练度提升,你无疑会找到减少此宏大小和复杂性的方式,同时保持所需的功能。

提供特定库的 Autoconf 宏

这一条是关于在需要标准宏未提供的特殊功能时,修改 Autoconf 宏的内容。我使用的示例特别是关于在库中寻找特定函数的情况。这是一个更普遍问题的特殊案例:查找提供所需功能的库。

如果你是库的开发者,可以考虑提供可下载的 Autoconf 宏,用来测试库的存在性,或许还可以测试其中的特定版本功能。通过这样做,你可以使用户更容易确保他们的用户能够正确访问你的库。

这些宏不必具有通用性,因为它们是为特定库量身定制的。特定库的宏更容易编写,并且在测试库功能时可以更为彻底。作为作者,你更有可能理解库的各个版本的细微差别,因此你的宏可以准确地确定用户可能需要区分的库特征。

第 6 项:交叉编译

交叉编译发生在构建系统(即构建二进制文件的系统)和主机系统(即这些二进制文件将要运行的系统)类型不同时。例如,当我们在典型的 Intel x86 平台上运行 GNU/Linux 时为嵌入式系统构建 Motorola 68000 二进制文件,或者在运行 Solaris 的 DEC Alpha 系统上构建 Sparc 二进制文件时,我们就是在进行交叉编译。一个更常见的场景是使用 Linux 系统为旨在运行在嵌入式微控制器上的软件进行构建。

如果你正在构建的软件(如编译器或链接器)能够生成软件,情况会变得更加复杂。在这种情况下,目标系统代表了你的编译器或链接器最终将生成代码的系统。当这样的构建系统涉及三种不同的架构时,通常称之为加拿大交叉编译^(9)。在这种情况下,编译器或链接器是在架构 A 上构建的,运行在架构 B 上,并为架构 C 生成代码。另一种三系统构建类型,称为交叉到本地构建,涉及在架构 A 上构建编译器以在架构 B 上运行。在这种情况下,涉及三种架构,但主机架构和目标架构相同。一旦掌握了双系统交叉编译的概念,转向使用三系统交叉编译模式就相对简单了。

Autoconf 会生成配置脚本,试图猜测构建系统类型,并假定主机系统类型与其相同。除非通过命令行选项另行指定,否则configure假定处于非交叉编译模式。当没有指定构建或主机系统类型的命令行选项时,Autoconf 生成的配置脚本通常能够准确确定系统类型及其特征。

注意

GNU Autoconf 手册的第十四部分“手动配置”讨论了如何将 Autoconf 设置为交叉编译模式。不幸的是,编写适用于交叉编译的configure.ac文件所需的信息散布在手册中的各个部分。每个与交叉编译相关的宏都包含一段描述交叉编译模式对该宏影响的文字。可以通过搜索手册中的“cross-comp”来查找所有相关内容。

系统类型在GNU Autoconf 手册中通过一种包含 CPU、厂商和操作系统的三部分规范命名方式来定义,形式为cpu-vendor-os。但是,os部分本身可以是一个包含内核和系统类型的对(kernel-system)。如果你知道某个系统的规范名称,你可以在configure的三个参数中分别指定它,如下所示:

  • --build=build-type

  • --host=host-type

  • --target=target-type

这些configure命令行选项,配合正确的规范系统类型名称,允许你定义构建、主机和目标系统类型。(将主机系统类型定义为与你的构建系统类型相同是多余的,因为这是configure的默认情况。)

使用这些选项时,最具挑战性(也是文档最少)的一方面是确定在这些命令行选项中使用的适当规范系统名称。在GNU Autoconf 手册中,你不会找到任何告诉你如何构造一个合适规范名称的语句,因为规范名称并不是每种系统类型唯一的。例如,在大多数有效的交叉编译配置中,规范名称中的vendor部分会被忽略,因此可以设置为任何值。

当你在configure.ac文件中早期使用AC_CANONICAL_SYSTEM宏时,你会发现两个新的 Autoconf 助手脚本被添加到你的项目目录中(由automake --add-missing添加,autoreconf --install也会执行此操作)。具体来说,这些助手脚本是config.guessconfig.subconfig.guess的任务是通过启发式方法确定用户系统的规范名称——构建系统。你可以自己执行此程序来确定适合自己构建系统的规范名称。例如,在我的 64 位 Intel GNU/Linux 系统上,运行config.guess会得到以下输出:

$ /usr/share/automake-1.15/config.guess
x86_64-pc-linux-gnu
$

如你所见,config.guess不需要命令行选项,尽管有一些可用选项。(使用--help选项可以查看它们。)它的任务是猜测你的系统类型,主要基于uname工具的输出。这个猜测会作为默认的系统类型,但用户可以在configure命令行中覆盖它。在交叉编译时,你可以在--build命令行选项中使用这个值。^(10)

config.sub程序的任务是接受一个输入字符串,作为你所寻找的系统类型的别名,然后将其转换为适当的 Autoconf 规范名称。那么,什么是有效的别名呢?你可以在config.sub中搜索“Decode aliases”以获取一些线索。你可能会在某段代码上方找到一个注释,解释它的任务是解码某些CPU-COMPANY组合的别名。以下是我系统中执行的一些示例,你应该在你的系统上找到相同的结果:

$ /usr/share/automake-1.15/config.sub i386
i386-pc-none
$ /usr/share/automake-1.15/config.sub i386-linux
i386-pc-linux-gnu
$ /usr/share/automake-1.15/config.sub m68k
m68k-unknown-none
$ /usr/share/automake-1.15/config.sub m68k-sun
m68k-sun-sunos4.1.1
$ /usr/share/automake-1.15/config.sub alpha
alpha-unknown-none
$ /usr/share/automake-1.15/config.sub alpha-dec
alpha-dec-ultrix4.2
$ /usr/share/automake-1.15/config.sub sparc
sparc-sun-sunos4.1.1
$ /usr/share/automake-1.15/config.sub sparc-sun
sparc-sun-sunos4.1.1
$ /usr/share/automake-1.15/config.sub mips
mips-unknown-elf
$

如你所见,单独的 CPU 名称通常不足以让config.sub正确地确定所需主机系统的有用规范名称。

另外需要注意的是,有一些通用的关键字,有时可以提供足够的信息进行交叉编译,而不需要提供真实的厂商或操作系统名称。例如,unknown 可以作为厂商名称的替代项,而 none 有时适用于操作系统名称。显然,elf 也是一个有效的系统名称,并且在某些情况下,对于 configure 来说,仅凭它就足以决定使用哪种工具链。然而,通过简单地将合适的厂商名称附加到 CPU 上,您可以让 config.sub 相当准确地推测出该 CPU 和厂商组合最可能的操作系统,从而生成有用的标准系统类型名称。

最终,确定合适的标准系统类型名称的最佳方法是检查 config.sub,找到与您认为应该用于 CPU 和厂商名称的内容相近的条目,然后直接询问它。虽然这看起来像是在瞎猜,但如果您已经进入编写需要交叉编译的程序构建系统的阶段,您可能已经非常熟悉主机 CPU、厂商和操作系统的名称了。

在进行交叉编译时,您很可能会使用一些不同于通常在系统中使用的工具,或者至少会在您的常用工具中添加额外的命令行选项。这些工具通常作为软件包一起安装。另一个确定合适主机系统标准名称的线索是这些工具名称的前缀。Autoconf 处理交叉编译的方式并没有什么神秘之处。主机系统标准名称直接用于在系统路径中通过名称定位正确的工具。因此,您使用的主机系统标准名称必须与工具名称的前缀匹配。

现在让我们来看一个常见场景:在相同 CPU 架构的 64 位机器上构建 32 位代码。从技术上讲,这是一种交叉编译形式,而且通常比为完全不同的机器架构进行交叉编译更简单。许多 GNU/Linux 系统支持 32 位和 64 位执行。在这些系统上,您通常可以使用构建系统的工具链,通过特定的命令行选项来执行此任务。例如,要在 64 位 Intel 系统上为 32 位 Intel 系统构建 C 源代码,您只需使用以下 configure 命令行(我已突出显示与交叉编译相关的行):^(11)

   $ ./configure CPPFLAGS=-m32 LDFLAGS=-m32
   checking for a BSD-compatible install... /usr/bin/install -c
   checking whether build environment is sane... yes
   checking for a thread-safe mkdir -p... /bin/mkdir -p
   checking for gawk... gawk
   checking whether make sets $(MAKE)... yes
➊ checking build system type... x86_64-pc-linux-gnu
➋ checking host system type... x86_64-pc-linux-gnu
   checking for style of include used by make... GNU
   checking for gcc... gcc
   checking for C compiler default output file name... a.out
   checking whether the C compiler works... yes
➌ checking whether we are cross compiling... no
   checking for suffix of executables...
   checking for suffix of object files... o
   --snip--

注意 ➌,就 configure 来说,我们并没有进行交叉编译,因为我们没有给 configure 提供任何命令行选项,指示它使用不同于常规的工具链。如您在 ➊ 和 ➋ 中所看到的,构建系统和主机系统类型都是您期望的 64 位 GNU/Linux 系统类型。此外,由于我的系统是双模式系统,它可以执行使用这些标志编译的测试程序。它们将在 64 位 CPU 的 32 位模式下正常运行。

注意

许多系统要求你在gcc能够识别-m32标志之前安装 32 位工具。例如,Fedora 系统要求安装glibc-devel.i686包,而我的 Linux Mint(基于 Ubuntu)系统则要求我安装gcc-multilib包。

为了更确保在 Linux 系统上正确构建,你还可以使用linux32工具将你的 64 位系统的特性更改为 32 位系统,像这样:

$ linux32 ./configure CPPFLAGS=-m32 LDFLAGS=-m32
--snip--
checking whether we are cross compiling... no
--snip--
checking build system type... i686-pc-linux-gnu
checking host system type... i686-pc-linux-gnu
--snip--

我们在这里使用linux32,因为configure执行的一些子脚本可能会检查uname -m来确定构建机器的架构。linux32工具确保这些脚本能正确识别为 32 位 Linux 系统。你可以通过在linux32下运行uname来自己测试这一点:

$ uname -m
x86_64
$ linux32 uname -m
i686
$

为了让这种交叉编译在 Linux 双模式系统上工作,通常需要安装一个或多个 32 位开发包,如前所述。如果你的项目使用其他系统级服务,如图形桌面,那么你还需要这些库的 32 位版本。

现在让我们用更传统的方式(敢说是标准吗?)来做。我们不再将-m32添加到CPPFLAGSLDFLAGS变量中,而是手动在configure命令行中设置构建和主机系统类型,然后看看会发生什么。再次说明,我已经突出显示了与交叉编译相关的输出行:

   $ ./configure --build=x86_64-pc-linux-gnu --host=i686-pc-linux-gnu
   checking for a BSD-compatible install... /usr/bin/install -c
   checking whether build environment is sane... yes
   checking for a thread-safe mkdir -p... /bin/mkdir -p
   checking for gawk... gawk
   checking whether make sets $(MAKE)... yes
➊ checking for i686-pc-linux-gnu-strip... no
   checking for strip... strip
➋ configure: WARNING: using cross tools not prefixed with host triplet
   checking build system type... x86_64-pc-linux-gnu
   checking host system type... i686-pc-linux-gnu
   checking for style of include used by make... GNU
➌ checking for i686-pc-linux-gnu-gcc... no
   checking for gcc... gcc
   checking for C compiler default output file name... a.out
   checking whether the C compiler works... yes
   checking whether we are cross compiling... yes
   checking for suffix of executables...
   checking for suffix of object files... o
   --snip--

这个示例中的几行关键内容表明,就configure而言,我们正在进行交叉编译。交叉编译的构建环境是x86_64-pc-linux-gnu,而主机系统是i686-pc-linux-gnu

但是注意看➋处的WARNING文本。我的系统没有专门用于构建 32 位 Intel 二进制文件的工具链。这样的工具链包含了构建我的产品 64 位版本所需的所有相同工具,但 32 位版本的工具会以主机系统的标准系统名称作为前缀。如果你没有安装并在系统路径中可用的正确前缀工具链,configure将默认使用构建系统工具——那些没有前缀的工具。如果你的构建系统工具可以通过正确的命令行选项交叉编译到主机系统,并且你在CPPFLAGSLDFLAGS中也指定了这些选项,那么这样是可以正常工作的。

通常,你需要安装一个设计用于构建正确类型二进制文件的工具链。在这个例子中,可以通过创建软链接和简单的 shell 脚本来提供这些工具的版本,脚本会传递额外所需的标志。根据➊和➌处configure脚本的输出,我需要提供i686-pc-linux-gnu-前缀版本的stripgcc

通常,这些外部工具链会安装到一个辅助目录中,这意味着你需要将该目录添加到你的系统 PATH 变量中,以便 configure 可以找到它们。对于这个例子,我将它们创建在~/bin中。^(12) 我再次强调了与跨平台编译相关的输出文本:

   $ ln -s /usr/bin/strip ~/bin/i686-pc-linux-gnu-strip
   $ echo '#!/bin/sh
   > gcc -m32 "$@"' > ~/bin/i686-pc-linux-gnu-gcc
   $ chmod +x ~/bin/i686-pc-linux-gnu-gcc
   $ ./configure --build=x86_64-pc-linux-gnu --host=i686-pc-linux-gnu
   checking for a BSD-compatible install... /usr/bin/install -c
   checking whether build environment is sane... yes
   checking for a thread-safe mkdir -p... /bin/mkdir -p
   checking for gawk... gawk
   checking whether make sets $(MAKE)... yes
   checking for i686-pc-linux-gnu-strip... i686-pc-linux-gnu-strip
   checking build system type... x86_64-pc-linux-gnu
   checking host system type... i686-pc-linux-gnu
   checking for style of include used by make... GNU
   checking for i686-pc-linux-gnu-gcc... i686-pc-linux-gnu-gcc
   checking for C compiler default output file name... a.out
   checking whether the C compiler works... yes
   checking whether we are cross compiling... yes
   checking for suffix of executables...
   checking for suffix of object files... o
   checking whether we are using the GNU C compiler... yes
   checking whether i686-pc-linux-gnu-gcc accepts -g... yes
   --snip--
   $ make
   --snip--
➊ libtool: compile: i686-pc-linux-gnu-gcc -DHAVE_CONFIG_H -I. -I.. -g -O2
    -MT print.lo -MD -MP -MF .deps/print.Tpo -c print.c -fPIC -DPIC -o
   --snip--
   $

这次,configure 能够找到正确的工具。注意,➊ 处的编译器命令不再包含 -m32 标志。它仍然存在,但已隐藏在 i686-pc-linux-gnu-gcc 脚本中。就 Autotools 而言,i686-pc-linux-gnu-gcc 已经知道如何在 64 位系统上构建 32 位二进制文件。

跨平台编译并不适合普通的终端用户。作为开源软件开发者,我们使用像 Autotools 这样的工具包,确保我们的终端用户在构建和安装我们的软件包时,不需要成为软件开发专家。但跨平台编译需要一定的系统配置,这超出了 Autotools 通常对终端用户的预期。此外,跨平台编译通常应用于一些专业领域,例如工具链或嵌入式系统开发。这些领域的终端用户通常软件开发专家。

在一些地方,跨平台编译可以,并且可能应该,提供给普通终端用户。然而,我强烈建议你在你提供给用户的READMEINSTALL文档中,详细并明确地说明这些指令。

第 7 项:模拟 Autoconf 文本替换技术

假设你的项目构建了一个在启动时通过配置文本文件中的值进行配置的守护进程。守护进程如何在启动时知道在哪里找到这个文件?一种方法是简单地假设它位于/etc目录中,但一个写得好的程序会允许用户在构建软件时配置该位置。系统配置目录的路径是一个可变位置,其值可以在 configuremake allmake install 命令行中指定,如下例所示:

$ ./configure --sysconfdir=/etc
--snip--
$ make all sysconfdir=/usr/mypkg/etc
--snip--
$ sudo make install sysconfdir=/usr/local/mypkg/etc
--snip--

所有这些例子都利用了 Autotools 构建系统提供的命令行功能,因此在创建项目和项目构建源文件时,必须仔细考虑它们。让我们看一些例子,来说明如何做到这一点。

现在,有些情况是根本无法工作的。例如,你不能在构建程序时通过 makefile 将系统配置目录路径传递到 C 源代码中,然后期望它在你更改配置文件安装位置并在 make install 命令行上运行时仍然正常工作。大多数终端用户不会在命令行上传递任何内容,但你仍然需要确保他们可以通过 configuremake 命令行设置前缀目录。

本条目重点讨论的是如何将命令行前缀变量覆盖信息放置到代码和安装数据文件的适当位置,以尽可能晚地在构建过程中进行处理。

Autoconf 会在配置时将 AC_SUBST 变量中的文本替换为那些变量在 configure 中定义的值,但它不会将文本替换为原始值。在一个 Autotools 项目中,如果你用特定的 datadir 执行 configure,你会得到如下结果:

   $ ./configure --datadir=/usr/share
   --snip--
   $ grep "datadir =" Makefile
   pkgdatadir = $(datadir)/b64
➊ datadir = /usr/share
   $

如➊所示,你可以看到 configure 中 shell 变量 datadir 的值会根据 make 变量 datadirMakefile 中的命令行指令被精确替换。这里不显而易见的是,datadir 的默认值,无论是在 configure 脚本中,还是在替换后的 makefile 中,都是相对于构建系统中的其他变量的。通过不在 configure 命令行上覆盖 datadir,我们可以看到 makefile 中默认值包含了未展开的 shell 变量引用:

$ ./configure
--snip--
$ cat Makefile
--snip--
datadir = ${datarootdir}
datarootdir = ${prefix}/share
--snip--
prefix = /usr/local
--snip--
$

在第三章(参见示例 3-36)中,我们看到我们可以将命令行选项传递给预处理器,以允许我们在源代码中使用这些路径值。示例 18-21 通过在 CPPFLAGS 变量中传递 C 预处理器定义,展示了这一点,假设程序名为 myprog。^(13)

myprog_CPPFLAGS = -DSYSCONFDIR="\"@sysconfdir@\""

示例 18-21:将前缀变量推送到 C 源代码中的 Makefile.am Makefile.in

一个 C 源文件可能会包含示例 18-22 中显示的代码。

--snip--
#ifndef SYSCONFDIR
# define SYSCONFDIR "/etc"
#endif
--snip--
const char * sysconfdir = SYSCONFDIR;
--snip--

示例 18-22:在 C 源代码中使用预处理器定义的变量

Automake 对示例 18-21 中 Makefile.amMakefile.in 之间的行没有做特殊处理,但 configure 脚本会将 Makefile.in 中的这一行转换为在示例 18-23 中显示的 Makefile 行。

myprog_CPPFLAGS = -DSYSCONFDIR="\"${prefix}/etc\""

示例 18-23:configure 替换 @sysconfdir@ 后的 Makefile 行

make 在编译器命令行中传递这个选项时,它会解引用这些变量,从而生成以下输出命令行(这里只展示了部分):

libtool: compile: gcc ... -DSYSCONFDIR=\"/usr/local/etc\" ...

这种方法有一些问题。首先,在 configuremake 之间,你会丢失 sysconfdir 变量的解析,因为 configure${prefix}/etc 来替换 @sysconfdir@,而不是用 ${sysconfdir}。问题在于,你不能再在 make 命令行上设置 sysconfdir 的值。为了解决这个问题,可以直接在 CPPFLAGS 变量中使用 ${sysconfdir} make 变量,如示例 18-24 所示,而不是使用 Autoconf 的 @sysconfdir@ 替代变量。

myprog_CPPFLAGS = -DSYSCONFDIR="\"${sysconfdir}\""

示例 18-24:在 CPPFLAGS 中使用 make 变量,而不是 Autoconf 替代变量

你可以使用这种方法在configuremake命令行上都指定sysconfdir的值。在configure命令行上设置变量会在Makefile.in(以及随后生成的Makefile)中定义一个默认值,然后可以在make命令行中覆盖这个值。

make allmake install命令行上使用不同的值所带来的问题稍微微妙一些。考虑一下如果你做了以下操作会发生什么:

$ make sysconfdir=/usr/local/myprog/etc
--snip--
$ sudo make install sysconfdir=/etc
--snip--
$

在这里,你基本上是在欺骗编译器,当你告诉它你的配置文件将在构建期间安装到/usr/local/myprog/etc时。编译器会高兴地生成清单 18-22 中的代码,让它引用这个路径;然后第二个命令行会将你的配置文件安装到/etc,而你的程序将包含一个硬编码的错误路径。不幸的是,你几乎无法纠正这个问题,因为你允许用户在任何地方定义这些变量,并且GNU 编码标准指出make install不应该重新编译任何内容。

注意

有时,构建和安装过程会故意指定不同的安装路径。回想一下在《将项目集成到 Linux 发行版》中关于DESTDIR的讨论(参见第 67 页),其中 RPM 包会在临时目录中构建和安装,以便之后将构建的产品打包成 RPM,并安装到正确的位置。

尽管存在潜在的陷阱,但能够在make命令行上指定安装位置是一种强大的技术,然而这种方法仅在 makefile 中有效,因为它在 makefile 中的编译器命令行内 heavily 依赖于make变量替换。

如果你想替换一个已安装数据文件中的值,而这个数据文件没有经过make命令处理,你可以将数据文件转换为一个 Autoconf 模板,然后在该文件中简单地引用 Autoconf 替换变量。

事实上,我们在第十五章为 FLAIM 项目创建的doxyfile.in模板中做的就是这件事。然而,这只在 Doxygen 输入文件中有效,因为这些模板中使用的变量类始终由configure定义为完整的绝对或相对路径。也就是说,@srcdir@@top_srcdir@的值不包含任何额外的 shell 变量。这些变量不是安装目录(前缀)变量,除了prefix本身,其他前缀变量总是相对于其他前缀变量定义的。

你也可以在 makefile 中模拟Autoconf 替换变量的过程,从而允许在已安装的数据文件中使用替换变量。清单 18-25 展示了一个模板,其中你可能希望用在构建过程中通常在标准前缀变量中找到的路径信息替换变量。

# Configuration file for myprog
logdir = @localstatedir@/log
--snip--

清单 18-25:myprog 的配置文件模板示例,将被安装到$(sysconfdir)

这个模板用于程序配置文件,通常会安装在系统配置目录中。我们希望程序日志文件的位置,按照该配置文件中的指定,在安装时通过@localstatedir@的值来确定。不幸的是,configure会将这个变量替换成至少包含${prefix}的字符串,这在程序配置文件中并没有用处。清单 18-26 展示了一个Makefile.am文件,里面有一个额外的make脚本,通过对myprog.cfg.in中的变量进行替换,生成myprog.cfg文件。

   EXTRA_DIST = myprog.cfg.in
➊ sysconf_DATA = myprog.cfg

➋ edit = sed -e 's|@localstatedir[@]|$(localstatedir)|g'
➌ myprog.cfg: myprog.cfg.in Makefile
           $(edit) $(srcdir)/$@.in > $@.tmp
           mv $@.tmp $@

   CLEANFILES = myprog.cfg

清单 18-26:在 makefile 中使用sedmake变量替换到数据文件中

在这个Makefile.am文件中,我在➌定义了一个自定义的make目标,用来构建myprog.cfg数据文件。我还在➋定义了一个名为editmake变量,这个变量解析为一个部分的sed命令,用来将模板文件($(srcdir)/myprog.cfg.in)中的所有@localstatedir@替换为$(localstatedir)变量的值。由于make会递归地处理变量替换,直到所有的变量引用都被解析,因此以这种方式使用make可以确保最终输出中不会留下任何变量引用。在这个命令中,sed的输出被重定向到输出文件(myprog.cfg)。^(14)

这个示例中唯一不明显的代码是sed表达式中在尾随的@符号周围使用方括号,它们表示正则表达式语法,指示任何包含的字符都应该被匹配。由于只有一个包含的字符,这看起来像是一个无意义的复杂化,但这些方括号的目的是防止configure在对这个 makefile 进行 Autoconf 变量替换时,替换掉edit变量中的@localstatedir@。我们希望make使用这个变量,而不是configure

我在➊将myprog.cfg分配给sysconf_DATA变量,以将这一新规则的执行与 Automake 提供的框架绑定。Automake 会在构建文件之后(如果需要)将其安装到系统配置目录中。

DATA主文件中的文件作为依赖项被添加到all目标中,经过内部的all-am目标。如果myprog.cfg不存在,make会查找构建它的规则。由于我有这样的规则,make会在我构建all目标时简单地执行这个规则。

我将模板文件名myprog.cfg.in添加到了 Listing 18-26 顶部的EXTRA_DIST变量中,因为 Autoconf 和 Automake 都不了解这个文件。另外,我将生成的文件myprog.cfg添加到了列表底部的CLEANFILES变量中,因为在 Automake 看来,myprog.cfg是一个分发数据文件,不应该被make clean自动删除。

注意

这个例子展示了 Automake 不自动分发DATA主文件的一个充分理由。有时候这些文件是以这种方式构建的。如果自动分发构建数据文件,distcheck目标将失败,因为在构建之前,myprog.cfg 并不可以用于分发。

在这个例子中,我将myprog.cfg的构建过程与安装过程结合,通过将其添加到sysconf_DATA变量中,然后我在mydata.cfg.inmydata.cfg之间建立了一个依赖关系^(15),以确保在执行make all时正确构建安装文件。你还可以通过使用适当的-hook或自定义目标,将其与标准或自定义构建或安装目标结合。

讨论这个话题时,如果不提到 Gnulib 的configmake模块,那就不完整。如果你已经在使用 Gnulib,并且需要做一些像我在这一项目中讨论的事情,可以考虑使用configmake,它会创建一个configmake.h头文件,可以被你的源文件包含,以便通过 C 预处理器宏访问所有标准目录变量。它仅对 C 代码有用,因此对于非 C 源代码的使用场景(比如需要引用前缀变量路径的已安装配置文件),你仍然需要我在这里展示的技巧。

项目 8:使用 Autoconf 档案项目

在第 511 页的“项目 5:黑客 Autoconf 宏”中,我演示了一种黑客 Autoconf 宏的技术,提供了接近但并不完全相同于原始宏功能的功能。当你需要一个 Autoconf 没有提供的宏时,你可以自己编写,或者寻找别人已经编写的。这一项目讲的是第二个选项,开始寻找的一个完美地方是 Autoconf 档案项目。

截至目前,Autoconf Archive 源项目托管在 GNU Savannah 上。^(16) 原始的 ac-archive 项目是两个较旧项目合并的结果:一个由 Guido Draheim(位于ac-archive.sourceforge.net/)创建,另一个由 Peter Simon(位于http://auto-archive.cryp.to)创建。第一个网站至今仍在运行,尽管它显示了一个巨大的红色警告框,提示你应将更新提交到 GNU Autoconf 宏存档(位于 Savannah);第二个网站已被关闭。这两个项目之间有很长的历史,也有不少邮件列表上的激烈争论。最终,每个项目都将对方的大部分内容纳入了自己的项目中,但 Peter Simon 的项目最终被迁移到了 Savannah 仓库,当前主页位于www.gnu.org/software/autoconf-archive/。^(17)

存档中的价值在于,私有宏变为公开宏,而公开宏则被许多用户逐步改进。

截至目前,宏存档包含超过 500 个不随 Autoconf 分发的宏,包括在《正确使用线程》一文中讨论的AX_PTHREAD宏,见第 384 页。存档的最新版本可以从项目的 Savannah git 站点进行查看。该站点按类别、作者和开源许可证索引宏,允许你根据特定标准选择宏。你还可以通过名称搜索宏,或输入宏头部注释中可能出现的任何文本。

如果你发现自己需要一个 Autoconf 似乎没有提供的宏,可以查看 Autoconf Archive。

项目 9:使用增量安装技术

一些人要求make install足够智能,只安装那些尚未安装或比已安装版本更新的文件。

默认情况下,用户可以通过向install-sh传递-C命令行选项来使用此功能。最终用户可以通过在执行make install时使用以下语法直接启用此功能:

$ make install "INSTALL=/path/to/install-sh -C"

如果你认为你的用户会受益于此选项,可以考虑在项目随附的INSTALL文件中添加一些关于如何正确使用此功能的信息。你不觉得这种不需要你实现的功能很棒吗?

项目 10:使用生成的源代码

Automake 要求在项目的Makefile.am文件中静态定义所有源文件,但有时源文件的内容需要在构建时生成。

有两种方法可以处理项目中的生成源文件(更具体地说,是生成的头文件)。第一种方法是使用 Automake 提供的“拐杖”,供那些不关心make细节的开发人员使用。第二种方法是编写适当的依赖规则,让make理解源文件与产品之间的关系。我将首先讲解“拐杖”方法,然后我们再深入探讨如何在Makefile.am文件中进行适当的依赖管理。

使用 BUILT_SOURCES 变量

当你有一个作为构建过程一部分生成的头文件时,可以告诉 Automake 生成规则,确保在尝试构建产品之前总是先创建该文件。为此,请将头文件添加到 Automake 的BUILT_SOURCES变量中,如清单 18-27 所示。

bin_PROGRAMS = program
program_SOURCES = program.c program.h
nodist_program_SOURCES = generated.h
BUILT_SOURCES = generated.h
CLEANFILES = generated.h
generated.h: Makefile
        echo "#define generated 1" > $@

清单 18-27:使用BUILT_SOURCES处理生成的源文件

nodist_program_SOURCES变量确保 Automake 不会生成尝试分发此文件的规则;我们希望它在最终用户运行make时构建,而不是在分发包中提供。

如果没有用户提供的线索,Automake 生成的 makefile 无法知道generated.h的规则应该在编译program.c之前执行。我称BUILT_SOURCES为“拐杖”,因为它只是强制执行用于生成列出的文件的规则,并且仅在用户执行allcheck目标时执行。即使直接尝试构建program目标,如果没有执行BUILT_SOURCES规则,也不会执行这些规则。话虽如此,让我们看看背后发生了什么。

依赖管理

在 C 或 C++项目中,源文件分为两类:一种是明确在 makefile 中定义为依赖项的文件,另一种是通过例如预处理器包含间接引用的文件。

你可以将所有这些依赖直接硬编码到你的 makefile 中。例如,如果program.c包含program.h,并且program.h包含console.hprint.h,那么program.o实际上依赖于所有这些文件,而不仅仅是program.c。然而,普通的手工编写的 makefile 只会显式定义.c文件与程序之间的关系。为了实现真正准确的构建,make需要通过类似清单 18-28 中所示的规则来了解所有这些关系。

   program: program.o
           $(CC) $(CFLAGS) $(LDFLAGS) -o $@ program.o

➊ program.o: program.c program.h console.h print.h
           $(CC) -c $(CPPFLAGS) $(CFLAGS) -o $@ program.c

清单 18-28:描述文件之间完整关系的规则

program.oprogram.c之间的关系通常由一个隐式规则定义,因此清单 18-28 中➊的规则通常会被拆分为两个独立的规则,如清单 18-29 所示。

   program: program.o
           $(CC) $(CFLAGS) $(LDFLAGS) -o $@ program.o

➊ %.o: %.c
 $(CC) -c $(CPPFLAGS) $(CFLAGS) -o $@ $<

➋ program.o: program.h console.h print.h

清单 18-29:C 源文件的隐式规则,定义为 GNU make模式规则

在清单 18-29 中,GNU make 特定的 模式规则 在 ➊ 处告诉 make 关联的命令可以从一个以 .c 结尾、基名相同的文件生成一个以 .o 结尾的文件。^(18) 因此,每当 make 需要找到一个规则来生成作为某个规则依赖项列出的以 .o 结尾的文件时,它会搜索一个基名相同的 .c 文件。如果找到了,它会应用这个规则,从相应的 .c 文件重新构建 .o 文件,前提是 .c 文件的时间戳比现有的 .o 文件更新,或者 .o 文件丢失。

make 中有一套文档化的隐式模式规则,因此你通常不需要编写这样的规则。然而,你仍然需要以某种方式告诉 make .o 文件与任何包含的 .h 文件之间的间接^(19) 依赖关系。这些依赖关系不能仅通过内建规则来推断,因为这些文件之间没有基于文件命名约定的隐式关系,例如 .c.o 文件之间的关系。这些关系是手动编码到源文件和头文件中的包含关系。

正如我在第三章中提到的,编写这样的规则是繁琐且容易出错的,因为在开发过程中(甚至在维护过程中,虽然程度较轻),源文件和头文件之间的种种关系可能随时发生变化,每次更改时都必须小心地更新规则以保持构建的准确性。C 预处理器更适合自动为你编写和维护这些规则。

两遍系统

有两种方法可以使用预处理器来管理依赖关系。第一种方法是创建一个两遍系统,其中第一遍仅构建依赖关系,第二遍则根据这些依赖关系编译源代码。这是通过定义使用某些预处理器命令生成 make 依赖规则的规则来完成的,如清单 18-30 所示。^(20)

 program: program.o
           $(CC) $(CFLAGS) $(LDFLAGS) -o $@ program.o

   %.o: %.c
           $(CC) $(CPPFLAGS) -c $(CFLAGS) -o $@ $<

➊ %.d: %.c
           $(CC) -M $(CPPFLAGS) $< >$@

➋ sinclude program.d

清单 18-30:直接构建自动依赖关系

在清单 18-30 中,➊ 处的模式规则指定了与 .d.c 文件之间的关系,正如清单 18-29 中 ➊ 处所示的 .o.c 文件之间的关系。这里的 sinclude 语句在 ➋ 处告诉 make 包含另一个 makefile,并且 GNU make 足够聪明,不仅确保在分析主依赖关系图之前包含所有 makefile,还会查找构建它们的规则。^(21) 运行 make 时会产生如下输出:

   $ make
   cc -M program.c >program.d
   cc -c -o program.o program.c
   cc -o program program.o
   $
   $ cat program.d
   program.o: program.c /usr/include/stdio.h /usr/include/features.h \
   /usr/include/sys/cdefs.h /usr/include/bits/wordsize.h \
➊ --snip--
   /usr/include/bits/pthreadtypes.h /usr/include/alloca.h program.h \
   console.h print.h
   $
   $ touch console.h && make
   cc -c -o program.o program.c
   cc -o program program.o
   $

如你所见,生成program.d的规则首先被执行,因为make尝试包含该文件。➊位置省略的部分指的是在递归扫描包含的头文件集时遍历的许多系统头文件。该文件包含了类似于我们在列表 18-29 中的➋位置编写的依赖规则^(22)。 (我们手动编写的规则依赖列表中缺少对program.c的引用,因为它是多余的,尽管这样并不会造成问题。)从控制台示例中,你也可以看到,现在触碰这些包含的文件之一会正确地导致program.c源文件重新构建。

列表 18-30 中概述的机制存在的问题包括:整个源代码树必须被遍历两次:第一次是检查并可能生成依赖文件,然后再次编译任何修改过的源文件。

另一个问题是,如果一个头文件包含了另一个头文件,并且第二个头文件被修改了,目标文件会更新,但make包含的依赖文件却没有更新。下次修改第二级头文件时,既不会更新目标文件,也不会更新依赖文件。已删除的头文件也会引发问题:构建系统无法识别已删除的文件是被故意移除的,因此它会抱怨现有依赖关系中引用的文件丢失。

一次性完成

处理自动依赖关系的更高效方式是将依赖文件作为编译的副作用生成。列表 18-31 展示了如何通过使用非便携的-MMD GNU 扩展编译器选项(在列表中突出显示)来完成这一操作。

   program: program.o
           $(CC) $(CFLAGS) $(LDFLAGS) -o $@ program.o

   %.o: %.c
➊         $(CC) -MMD $(CPPFLAGS) -c $(CFLAGS) -o $@ $<

➋ sinclude program.d

列表 18-31:将生成依赖作为编译的副作用

在这里,我已删除第二个模式规则(原本在列表 18-30 中的➊位置),并在列表 18-31 中的➊位置为编译器命令行添加了-MMD选项。此选项告诉预处理器生成一个与当前编译的.c文件具有相同基本名称的.d文件。当make在干净的工作区执行时,➋位置的sinclude语句会静默地失败,未能包含缺失的program.d文件,但这并不重要,因为所有的目标文件第一次都会被构建。在随后的增量构建过程中,之前构建的program.d文件会被包含,并且它的依赖规则会在这些构建中生效。

正确构建的源文件

上述描述的一次性方法大致是 Automake 在可能的情况下用来管理自动依赖的方式。这种方法的问题通常出现在处理生成的源文件时,包括.c文件和.h文件。例如,让我们扩展清单 18-31 中的示例,增加一个名为generated.h的生成的头文件,该文件被program.h包含。清单 18-32 展示了这一修改的第一次尝试。清单 18-31 中的新增内容在此清单中有所高亮。

program: program.o
        $(CC) $(CFLAGS) $(LDFLAGS) -o $@ program.o

%.o: %.c
        $(CC) -MMD $(CPPFLAGS) -c $(CFLAGS) -o $@ $<

generated.h: Makefile
        echo "#define generated" >$@

sinclude program.d

清单 18-32:一个与生成的头文件依赖关系一起工作的 makefile

在这种情况下,当我们执行make时,我们发现缺乏初始依赖文件对我们不利:

$ make
cc -MMD -c -o program.o program.c
In file included from program.c:4:
program.h:3:23: error: generated.h: No such file or directory
make: *** [program.o] Error 1
$

因为没有初始的二级依赖信息,make不知道它需要执行generated.h规则的命令,因为generated.h只依赖于Makefile,而Makefile没有变化。为了解决在Makefile.am文件中的这个问题,我们可以像在清单 18-27 中第 531 页那样,将generated.h列入BUILT_SOURCES变量中。这样会将generated.h添加为allcheck目标的第一个依赖项,从而强制它们首先构建,以应对用户输入makemake allmake check的可能性。^(23)

处理这个问题的正确方法非常简单,并且在 makefile 和Makefile.am文件中每次都能奏效:在program.ogenerated.h之间编写一个依赖规则,如清单 18-33 中更新后的 makefile 所示。高亮的行包含了额外的规则。

program: program.o
        $(CC) $(CFLAGS) $(LDFLAGS) -o $@ program.o

%.o: %.c
        $(CC) -MMD $(CPPFLAGS) -c $(CFLAGS) -o $@ $<

program.o: generated.h
generated.h: Makefile
        echo "#define generated" >$@

sinclude program.d

清单 18-33:为生成的头文件添加硬编码依赖规则

新的规则告知makeprogram.ogenerated.h之间的关系:

   $ make
   echo "#define generated" >generated.h
   cc -MMD -c -o program.o program.c
   cc -o program program.o
   $
   $ make
   make: 'program' is up-to-date.
   $
➊ $ touch generated.h && make
   cc -MMD -c -o program.o program.c
   cc -o program program.o
   $
➋ $ touch Makefile && make
   echo "#define generated" >generated.h
   cc -MMD -c -o program.o program.c
   cc -o program program.o
   $

在这里,修改generated.h(在➊处)会导致program被更新。修改Makefile(在➋处)会首先重新创建generated.h

要在 Automake 的Makefile.am文件中实现清单 18-33 中显示的依赖规则,您将使用清单 18-34 中高亮的规则。

bin_PROGRAMS = program
program_SOURCES = program.c program.h
nodist_program_SOURCES = generated.h
program.$(OBJEXT): generated.h
CLEANFILES = generated.h
generated.h: Makefile
        echo "#define generated 1" > $@

清单 18-34:用一个合适的依赖规则替换BUILT_SOURCES

这与之前在清单 18-27 中第 531 页展示的代码完全相同,唯一的区别是我们将BUILT_SOURCES变量替换为了一个合适的依赖规则。此方法的优点在于它始终按预期工作;无论用户指定什么目标,generated.h都会在需要的时候被构建。^(24)

如果你尝试生成一个 C 源文件而不是头文件,你会发现其实你根本不需要额外的依赖规则,因为 .o 文件隐式地依赖于它们的 .c 文件。然而,你仍然必须在 nodist_program_SOURCES 变量中列出你生成的 .c 文件,以防止 Automake 尝试分发它。

注意

当你定义自己的规则时,你会抑制 Automake 可能为该产品生成的任何规则。在特定的目标文件的情况下,这通常不会成为问题,但在定义规则时请记住这个 Automake 的特性。

如你所见,正确管理生成的源文件所需要的仅仅是正确编写的依赖规则集以及适当的 nodist_*_SOURCES 变量。make 工具和 Autotools 提供了所需的框架,通过内建的 make 功能、宏和变量。你只需要将它们正确地组合在一起。例如,在 GNU Automake 手册 中,参见第 8.1.2 节,它讨论了程序链接。^(25) 本节提到 EXTRA_prog_DEPENDENCIES 变量,作为扩展 Automake 生成的特定目标的依赖图的一种机制。

项目 11:禁用不需要的目标

有时候,Autotools 为你做了太多的事情。以下是来自 Automake 邮件列表的一个例子:

我在我的一个项目中使用 automake 和 texinfo。该项目的文档充满了图片。正如你可能知道的,`make pdf' 会将 JPG 和 PNG 文件制作成 PDF 文档,而 `make dvi' 则需要 EPS 文件。然而,EPS 图像非常大(在这个案例中比 JPG 大 15 倍)。

问题是运行 `make distcheck' 会导致错误,因为应该存在的 EPS 图像并不存在,而 `make distcheck' 会尝试在所有地方运行 make dvi'。我希望能运行 `make pdf',或者至少禁用构建 DVI。有办法做到吗?

首先是一些背景信息:Automake 的 TEXINFOS 主要使多个文档目标可供最终用户使用,包括 infodvipspdfhtml。它还提供了多个安装目标,包括 install-infoinstall-dviinstall-psinstall-pdfinstall-html。在这些目标中,只有 info 会在执行 makemake all 时自动构建,只有 install-info 会在执行 make install 时执行。^(26)

然而,似乎 distcheck 目标也会构建至少 dvi 目标。刚才提到的问题是,海报没有提供构建 DVI 文档所需的封装 PostScript(EPS)图形文件,因此 distcheck 目标失败,因为它无法构建海报本不打算支持的文档。

要解决这个问题,你只需要提供你自己的版本的目标,它什么也不做,如清单 18-35 所示。

   --snip--
   info_TEXINFOS = zardoz.texi
➊ dvi: # do nothing for make dvi

清单 18-35:在 Makefile.am 中禁用 dvi 目标,该文件指定了 TEXINFOS 主项

在 ➊ 处添加一行代码后,make distcheck 又恢复了工作。现在,当它构建 dvi 目标时,它成功了,因为什么都没有做。

其他 Automake 主项也提供了多个附加目标。如果你只想支持这些目标中的一部分,你可以通过提供你自己的目标来有效地禁用不需要的目标。如果你希望在禁用重写时更加直言不讳,可以简单地包括一个 echo 语句,告诉用户你的包不提供 DVI 文档,但要小心不要执行任何可能失败的操作,否则用户会再次遇到相同的问题。

项目 12:注意制表符字符!

在过渡到 Automake 后,你不再使用原始的 makefile,因此为什么你还需要关注制表符字符呢?记住,Makefile.am 文件只是样式化的 makefile。最终,Makefile.am 文件中的每一行都会被 Automake 直接处理,然后转化为真正的 make 语法,或者直接复制到最终的 makefile 中。这意味着,在 Makefile.am 文件中,制表符字符是重要的。

参考这个来自 Automake 邮件列表的例子:

lib_LTLIBRARIES = libfoo.la
libfoo_la_SOURCES = foo.cpp
if WANT_BAR
  ➊ libfoo_la_SOURCES += a.cpp
else
  ➋ libfoo_la_SOURCES += b.cpp
endif

AM_CPPFLAGS = -I${top_srcdir}/include
libfoo_la_LDFLAGS = -version-info 0:0:0

我已经阅读了 autoconf 和 automake 手册,据我所见,上面的做法应该是可行的。然而,文件(a.cpp 或 b.cpp)[始终] 被添加到生成的 Makefile 的底部,因此在编译时未被使用。不管我尝试什么,我无法让上述代码生成正确的 makefile,但显然我做错了什么。

另一位发布者提供的答案简单而准确,尽管有些简洁到极点:

移除缩进。

这里的问题是,Automake 条件语句中的两行在 ➊ 和 ➋ 处用制表符字符进行了缩进。

你可能还记得在 第 380 页中“Automake 配置特性”部分,我讨论了 Automake 条件语句的实现,其中条件语句中的文本前缀是一个 Autoconf 替换变量,最终会转化为空字符串或哈希符号。这里的含义是,这些行在最终的 makefile 中基本上是保留原样或被注释掉。被注释的行对我们不重要,但你可以清楚地看到,如果 makefile 中未注释的行以制表符字符开头,Automake 会将它们视为命令,而不是定义,并将它们在最终的 makefile 中进行排序。当 make 处理生成的 makefile 时,它会试图将这些行解释为孤立命令。

注意

如果原始发布者使用空格来缩进条件语句,就不会遇到问题了。

故事的教训:注意制表符字符!

项目 13:打包选项

包维护者的最终目标是使最终用户的使用变得简单。系统级包通常没有这个问题,因为它们不依赖于任何不是操作系统核心部分的东西。但更高层次的包常常依赖于多个子包,其中有些比其他的更加普遍。

例如,考虑 Subversion 项目。如果你从 Subversion 项目网站下载最新的源代码包,你会发现它有两种版本。第一个包只包含 Subversion 源代码,但如果你解压并构建该项目,你会发现你需要下载并安装 Apache 运行时和运行时工具(aprapr-utils)包、zlib-devel包和sqlite-devel包。这时,你可以构建 Subversion,但为了通过 HTTPS 启用对仓库的安全访问,你还需要neonserfopenssl

Subversion 项目的维护者认为,社区采用 Subversion 足够重要,值得不遗余力地推进。所以,为了帮助你构建一个功能完备的 Subversion 包,他们提供了一个名为subversion-deps的第二个包,其中包含了 Subversion 一些重要依赖的源代码分发包^(27)。只需将subversion-deps源包解压到与你解压subversion源包相同的目录中。subversion-deps包的根目录仅包含子目录——每个子目录对应一个源级别的依赖。

你可以选择以相同的方式将源代码包添加到你的项目构建系统中。当然,如果你使用的是 Automake,过程会简单得多。你只需要调用AC_CONFIG_SUBDIRS来为构建树中包含附加项目的子目录配置子项目。AC_CONFIG_SUBDIRS会默默忽略缺失的子项目目录。我在第十四章中向你展示了这个过程的一个例子,当 FLAIM 工具包作为任何更高层次 FLAIM 项目目录中的子目录存在时,我将它构建为一个子项目。

你应该随你的包一起发布哪些包?关键在于确定哪些包是你的消费者最不可能自行找到的。

总结

希望这些解决方案——实际上,这本书——对你在开源项目中创建出色用户体验的探索有所帮助。我在本书开头提到,人们常常因为不了解 Autotools 的目的而讨厌它们。到现在为止,你应该已经对这个目的有了相当清晰的认识。如果你之前不愿意使用 Autotools,那么希望我已经给你足够的理由去重新考虑。

回忆一下阿尔伯特·爱因斯坦那句常被误引的名言:“一切事物都应该尽可能简单,但不能过于简单。”^(28) 并不是所有事物都能简化到任何人都能在少量培训下掌握的程度。尤其是当涉及到那些旨在为他人简化生活的过程时,这一点尤为明显。Autotools 提供了一个让专家—程序员和软件工程师—能够让开源软件更加易于终端用户访问的能力。说实话,这个过程并不简单,但 Autotools 力图将其简化到尽可能的程度。

posted @ 2025-11-25 17:05  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报