构建稳定可移植软件的核心技术指南

本文深入探讨了如何编写能在多种系统上稳定运行且易于构建的软件,涵盖了从编程语言选择、标准库使用、POSIX接口到操作系统特性封装、第三方库集成及用户界面设计的完整技术栈。

构建稳定可移植软件的技巧

在经历了数年快速演变的编程语言之后,我开始欣赏稳定性。我希望我的程序能够在各种系统上轻松构建,只需最少的调整。我希望它们在未来环境变化时也能长期正常工作。 为了更清晰地思考稳定性,我们将一个运行中的程序划分为不同的层次。然后我们可以逐一检查每个层次的开发选择。

程序资源的同心圆

一个程序需要的功能越多,它就必须穿越更外层的层次。

修正: 应将操作系统列为最外层,而不是第三方库。库通常被设计为可跨操作系统移植。

层 0:编程语言

选择一种拥有多个实现和标准的语言。 每种语言都必须从某个地方开始,通常由单个人或小组实现。在这个阶段,语言发展迅速,公平地说,正是这个阶段推动了技术的进步。 然而,在单一实现阶段使用一种语言,意味着你需要投入一部分精力到语言本身的“研究项目”中。你将不得不处理破坏性变更(包括工具)和实验性的死胡同。 如果你喜欢新语言背后的理念,或者相信它是一个赢家,并且你早期的熟悉将会有回报,那么就去做吧!否则,请使用已经超越单一实现阶段的语言。这样你就可以专注于你的专业领域,而不是追赶语言的研究议程。 当人们为了新的环境和架构分叉语言时,语言就进入了下一个阶段。一些人增加功能,另一些人在他们的环境中发现困难。利益相关者通过标准化过程进行辩论并达成共识。最终结果是,标准(而非特定的软件工件)定义了语言并拥有最终决定权。 自然地,整个过程需要一段时间。标准化的语言通常会相当古老。它们会错过最新的想法,但会被充分理解。 以下是一些拥有标准的成熟语言:

  • Ada
  • C
  • Common Lisp
  • ECMAScript
  • Pascal
  • SQL

我最近一直在使用C语言,因为它具有可移植性、简单(但富有表现力)的抽象机器模型,以及与POSIX和基础库的深度兼容性。

避免或封装编译器语言扩展 如果你使用的是有标准的语言,请充分利用它。首先,选择一个特定的标准版本。旧版本通常得到更广泛的支持,但功能较少。在C语言世界中,我通常选择C99,因为它比C89有一些便利之处,并且几乎在所有地方都得到支持(尽管在Windows上只是部分支持)。 查阅你的编译器文档,看编译器是否能捕捉到意外使用的非标准行为。在clang或gcc中,将以下标志添加到你的Makefile中:

1
2
# 强制执行特定版本的标准
CFLAGS += -std=c99 -pedantic

根据需要将“c99”替换为其他版本。-pedantic 标志会拒绝所有使用禁止扩展的程序,以及一些不遵循ISO C的其他程序。 如果你确实想使用编译器扩展(例如gcc或clang中的扩展),请将它们封装在你自己的宏后面,以便代码保持可移植性。PostgreSQL项目在c.h中做了这种事情。这里有一个随机示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/*
 * 用 "pg_attribute_always_inline" 代替 "inline" 用于我们希望强制内联的函数,
 * 即使编译器的启发式方法会选择不内联。但如果可能,请不要在未优化的调试版本中强制内联。
 */
#if (defined(__GNUC__) && __GNUC__ > 3 && defined(__OPTIMIZE__)) || defined(__SUNPRO_C) || defined(__IBMC__)
/* GCC > 3、Sunpro 和 XLC 通过 __attribute__ 支持 always_inline */
#define pg_attribute_always_inline __attribute__((always_inline)) inline
#elif defined(_MSC_VER)
/* MSVC 为此有关键字 */
#define pg_attribute_always_inline __forceinline
#else
/* 否则,我们最多只能使用 "inline" */
#define pg_attribute_always_inline inline
#endif

注意它们如何适应各种编译器并提供最终的回退方案。当然,在可能的情况下,避免使用扩展是最简单的选择。

层 1:标准库

学习它,并查阅标准 花时间学习你语言的标准库。它是免费的,无论你的程序到哪里都会包含它。在语言标准中阅读库函数,因为它们会在那里被涵盖。 获得标准库的知识有助于减少对不必要的第三方库的依赖。例如,ECMAScript世界充斥着试图补充ECMA标准实际或感知缺陷的微库。 单一实现语言的库大小是易实现性和易用性之间的权衡。像Go语言中的庞大库使得潜在的竞争对手实现者更难创造,从而减缓了向强大标准的进展。 要了解更多关于C标准库的信息,请参阅我的文章。

了解原理和陷阱 由于标准化组织避免破坏现有的代码库,并且由于稳定的语言变化缓慢,标准库中会存在一些奇怪或危险的函数。然而,这些危险是众所周知的,并在相关文献中有详细记载,这与新的、相对未经测试的系统中的危险不同。 以下是一些很好的C语言书籍:

  • Robert C. Seacord 的《The CERT C Coding Standard》(ISBN 978-0321984043)。通过标准库等内容说明了潜在的不安全性。列出了导致漏洞的真实代码。
  • P. J. Plauger 的《The Standard C Library》(ISBN 978-0131315099)。关于C89标准库的详尽细节。
  • Andrew Koenig 的《C Traps and Pitfalls》(ISBN 978-0201179286)。
  • Steve Summit 的《C Programming FAQs》(ISBN 978-0201845198)。我明白为什么这些在历史上是最常被问到的问题。我自己也问过其中的许多问题。

此外,C99标准附带了一份原理文档。它讨论了考虑过并被拒绝的替代设计。

层 2:POSIX

与竞争性C实现导致C标准类似,Unix战争导致了POSIX的产生。POSIX 指定了一个“最小公分母”接口,许多操作系统在不同程度上遵守它。

阅读规范,与man手册页进行比较 每当你使用C标准库之外的系统调用时,检查它们是否是POSIX的一部分,以及它们的官方描述是否与你本地的man手册页不同。The Open Group提供了可免费搜索的HTML版本的POSIX.1。截至撰写本文时,它是POSIX.1-2017(即POSIX.1-2008加上两份技术勘误)。

激活版本 要调用POSIX函数,你必须在包含头文件之前定义 _POSIX_C_SOURCE “功能测试”宏。通过使用以下值之一来选择特定的POSIX版本:

版本 发布年份 宏值
1 1988 (N/A)
2 1990 1
3 1992 2
4 1993 199309L
5 1995 199506L
6 2001 200112L
7 2008 200809L
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* line.c */
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h> /* ssize_t */

int main(void)
{
    char *line = NULL;
    size_t len = 0;
    ssize_t read;
    while ((read = getline(&line, &len, stdin)) != -1)
        printf("Length %zd: %s", read, line);
    free(line);
    return 0;
}
1
2
3
4
5
6
$ cc -std=c99 -pedantic -Werror -D_POSIX_C_SOURCE=200112L line.c -o line

line.c:10:17: error: implicit declaration of function 'getline' is invalid in C99 [-Werror,-Wimplicit-function-declaration]
        while ((read = getline(&line, &len, stdin)) != -1)
                       ^
1 error generated.

重要提示: 设置 _POSIX_C_SOURCE 将隐藏标准头文件中的非POSIX操作系统附加功能。最佳实践是将源文件分为符合POSIX的文件和(希望很少的)不符合的文件。在没有功能宏的情况下编译后者,并在最后将它们全部链接在一起。

在构建过程中也使用POSIX POSIX不仅定义了前面讨论的库函数的接口,还定义了shell和常见工具的接口。如果你在构建中使用这些工具,那么你就不需要在目标机器上安装任何额外的软件来编译你的项目。 可能最常见的意外锁定来源是bashism和GNU对Make的扩展。对于脚本,使用sh,对于Makefile,使用(POSIX)make。太多项目不必要地使用GNU功能。事实上,学习Make功能的可移植子集可以带来更干净、更可靠的构建。 这本身就是一个完整的文章主题。Chris Wellons写了一篇很好的教程。另外,Andrew Oram的《Managing Projects with make》(ISBN 0-937175-90-0)是一本充满好建议的小书。

层 3:操作系统额外功能

操作系统包含超出POSIX的有用功能。例如,pthreads的扩展(设置读写器偏好或线程处理器亲和性)、访问专用硬件(如音频或图形)、替代的I/O接口和语义,以及用于安全的函数,如strlcpy或pledge。 使用这些功能进行可移植编程的三种方法是:

  1. 将它们封装在你自己的接口中,并条件编译其实现,或
  2. 作为项目的一部分构建一个静态兼容库(“libcompat”),以便在功能缺失时使用,或
  3. 链接到一个抽象了细节的第三方库。

我们稍后再讨论第三方库。现在让我们看看选项一。

检测操作系统功能 考虑生成随机数据的例子。它需要操作系统的帮助,因为POSIX只提供伪随机数。 我们将把Makefile分成两部分:

  • Makefile – 指定在所有系统上都成立的目标、依赖项和规则。
  • config.mk – 设置特定于本地系统的宏和构建标志。

Makefile将像这样包含 config.mk 的具体内容:

1
2
3
4
5
# 在 Makefile 内部...

# 设置通用选项,然后...

include config.mk

我们将使用一个配置脚本来生成 config.mk。开发人员将在首次构建之前运行该脚本来检测环境选项。配置脚本最原始的工作方式是尝试解析 uname 并根据看到的操作系统或发行版做出决定。更准确的方法是直接探测所需的操作系统C函数。 要检查一个C函数是否存在,我们可以直接尝试编译测试代码片段,看看是否成功。你可能认为这很麻烦,或者需要你的项目中充斥着测试代码,但实际上它相当优雅。 首先制作这个shell脚本辅助函数:

1
2
3
4
5
6
7
8
9
compiles ()
{
    stage="$(mktemp -d)"
    echo "$2" > "$stage/test.c"
    (cc -Werror "$1" -o "$stage/test" "$stage/test.c" >/dev/null 2>&1)
    cc_success=$?
    rm -rf "$stage"
    return $cc_success
}

compiles() 函数接受两个参数:一个可选的编译器标志,以及要尝试编译的源代码。 可移植性说明: 请注意 mktempcc 不符合POSIX标准。你可以使用POSIX原语编写自己的 mktemp 函数,但我想在这个例子中节省空间。对于 cc,规范提供了 c99(或在POSIX第4版中提供 c89)。然而,c99 工具不允许控制警告级别,我想指定将警告视为错误。cc 别名是一个常见的事实标准。 让我们使用辅助函数来检查操作系统的随机数生成器。BSD世界提供 arc4random_buf 来获取随机字节,而Linux提供 getrandom。配置脚本可以像这样检查每个功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if compiles "" "
    #include <stdint.h>
    #include <stdlib.h>
    int main(void)
    {
        void (*p)(void *, size_t) = arc4random_buf;
        return (intptr_t)p;
    }"
then
    echo "CFLAGS += -DHAVE_ARC4RANDOM" >> config.mk
fi

if compiles "-D_POSIX_C_SOURCE=200112L" "
    #include <stdint.h>
    #include <sys/types.h>
    #include <sys/random.h>
    int main(void)
    {
        ssize_t (*p)(void *, size_t, unsigned int) = getrandom;
        return (intptr_t)p;
    }"
then
    echo "CFLAGS += -DHAVE_GETRANDOM" >> config.mk
fi

看到了吗?还不错。这些代码片段不仅测试函数是否存在,还检查它们的类型签名。注意第二个例子是如何使用POSIX编译以获取 ssize_t 类型的,而第一个例子故意没有标记为符合POSIX,因为这样做会隐藏BSD放在 stdlib.h 中的额外函数 arc4random_buf

将操作系统功能封装在你自己的接口后面 将非可移植函数的使用隔离在一个独立的翻译单元中,并在此基础上导出你自己的接口,这是很有帮助的。这样,在一个地方设置条件编译,或者在将来重构,就更直接了。 让我们继续上一节中生成随机字节的例子。在我们完成了操作系统特性检测的繁重工作之后,我们可以将不同的操作系统接口封装在我们自己的函数后面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdint.h>
#include <stdlib.h>
#ifdef HAVE_GETRANDOM
#include <sys/random.h>
#endif

void get_random_bytes(void *buf, size_t n)
{
#if defined HAVE_ARC4RANDOM  /* BSD */
    arc4random_buf(buf, n);
#elif defined HAVE_GETRANDOM /* Linux */
    getrandom(buf, n, 0);
#else
#error OS does not provide recognized function to get entropy
#endif
}

当相应的函数存在时,Makefile使用CFLAGS定义 HAVE_ARC4RANDOMHAVE_GETRANDOM。代码可以直接使用 #ifdef。注意 #else 情况下的 #error,以便在不支持的平台上以清晰的消息编译失败。 我们追求的可移植性程度会导致权衡。例如:我们可以添加回退到读取 /dev/random 的功能。上一节的配置脚本可以检查该设备是否存在:

1
2
3
if test -c /dev/random; then
    echo "CFLAGS += -DHAVE_DEVRANDOM" >> config.mk
fi

利用这些信息,我们可以在 get_random_bytes() 中添加另一个 #elif,这样它就有可能工作在更多系统上。然而,在这种情况下,增加的可移植性需要改变接口。由于在 /dev/random 上使用 fopen()fread() 可能会失败,我们的函数将需要返回 bool。目前我们调用的操作系统函数不可能失败,所以 void 返回类型是可以的。

在多个操作系统和硬件上进行测试 当然,可移植性的真正测试是在多个操作系统、编译器和硬件架构上构建和运行。这可能会揭示出令人惊讶的假设。尽早并频繁地测试可移植性,可以更容易地保持程序的良好状态。 例如,PostgreSQL项目维护着一堆不同的机器,称为“构建农场”。构建农场成员各自拥有自己的操作系统、编译器和架构。团队在这些机器上编译每个新功能,并在那里运行测试套件。 仅仅关注架构,我们就可以在构建农场中看到令人印象深刻的多样性:

  • ARM: v6, v7, ARM64
  • IA-64
  • IBM Z
  • MIPS
  • PA-RISC
  • PowerPC,包括大端序和小端序
  • SPARC
  • x86,包括 i686 和 i386
  • x86-64

即使你没有打算在这些架构上运行,在那里测试也会带来更好的代码。(请参阅我的文章《C Portability Lessons from Weird Machines》。) Begriffs 构建农场? 我一直在考虑组建一个构建农场,并提供付费的持续集成服务。如果你对此感兴趣,请给我发邮件。我认为这个项目是件好事,如果有足够的订阅,我可以支付电力和硬件成本。

层 4:第三方库

许多语言都有自己的应用程序级包管理器,但C语言没有独占的包管理器。这门语言有太多的历史,并且跨越了太多的环境,无法锁定在这一点上。相反,人们从源代码构建依赖项,或者使用操作系统的包管理器。

使用 pkg-config 进行构建 链接到库需要知道它们的路径、名称和编译器设置。此外,我们还想知道安装了哪个版本以及它是否在可接受的范围内。由于C语言没有应用程序级包管理器,我们需要使用另一个工具来发现已安装的库。 寻找并构建依赖库的最跨平台方法是使用 pkg-config。该工具允许你查询系统包,无论它们是如何安装的。为了与 pkg-config 兼容,每个库 foo 都提供一个 libfoo.pc 文件,其中包含如下键值对:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
prefix=/usr/local
exec_prefix=${prefix}
includedir=${prefix}/include
libdir=${exec_prefix}/lib

Name: libfoo
Description: The foo library
Version: 1.2.3
Cflags: -I${includedir}/foo
Libs: -L${libdir} -lfoo

pkg-config 可执行文件可以查询此元数据并为你的Makefile提供标志。在你的配置脚本中这样调用它:

1
2
3
4
5
6
7
8
9
# 检查是否安装了足够新版本的库
pkg-config --print-errors 'libfoo >= 1.0'

# 将标志保存到 config.mk
cat >> config.mk <<-EOF
    CFLAGS += $(pkg-config --cflags libfoo)
    LDFLAGS += $(pkg-config --libs-only-L libfoo)
    LDLIBS += $(pkg-config --libs-only-l libfoo)
EOF

注意 LDLIBSLDFLAGS 的区别。LDLIBS 是需要在构建行的最后出现的选项。默认的POSIX make后缀规则没有提到 LDLIBS,但这里有一个你可以使用的规则:

1
2
.c:
    $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LDLIBS)

有时操作系统会包含额外的功能,并将其打包成你可以在其他操作系统上使用的可移植库。在这种情况下,你可以有条件地使用 pkg-config。 例如,OpenBSD 派生出了 LibreSSL 项目(一个更易用的OpenSSL)。OpenBSD 在内部包含了这些功能。在配置脚本中只需进行操作系统检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# LibreSSL
case "$(uname -s)" in
    OpenBSD)
        # 操作系统自带
        echo 'LDLIBS += -ltls' >> config.mk
        ;;
    *)
        # 需要安装包
        pkg-config --print-errors 'libtls >= 2.5.0'
        cat >> config.mk <<-EOF
            CFLAGS += $(pkg-config --cflags libtls)
            LDFLAGS += $(pkg-config --libs-only-L libtls)
            LDLIBS += $(pkg-config --libs-only-l libtls)
        EOF
esac

有关 pkg-config 的更多信息,请参阅 Dan Nicholson 的指南。

补充标准库的不足 C 标准库没有通用的集合。你必须编写自己的链表、树和哈希表。真正的程序员™可能喜欢这样,但我不喜欢。 POSIX 通过 search.h 中的接口提供有限的支持:

  • 二叉树。这个接口对我来说有效,尽管 twalk() 不包含向回调函数传递辅助数据的参数。回调函数需要为此查阅全局或线程局部变量。实现质量也可能有所不同,可能与树的平衡方式/是否平衡有关。
  • 队列。用于从双链表(可能是循环链表)插入或删除的基本函数。它接受 void*,但期望结构的头两个成员是指向相同结构类型的指针(前向和后向指针)。
  • 哈希表。不必要的约束接口。它在隐藏内存中创建一个哈希表。你可以销毁该表并稍后再创建一个,但在调用栈中的任何地方都永远不能同时拥有多个活动表。显然不是线程安全的,但这似乎只是它的问题中最小的一个。

要超越这一点,你将不得不使用第三方库。许多知名库似乎相当臃肿(GLib, tbox, Apache Portable Runtime)。我发现了一个更小、更干净的库,简称为 C Algorithms。尚未在项目中使用它,但它看起来稳定且经过了充分测试。我还在本地用添加的严格C99标志构建了该库,没有收到任何警告。 另外两个多年来被广泛使用的稳定库(代码片段?)是 Uthash 和 BSD 的 queue(3)(可以查看 OpenBSD 的 queue.h,或 FreeBSD 的变体)。 Uthash 这样描述自己:

“任何 C 结构都可以使用 uthash 存储在哈希表中。只需向结构中添加一个 UT_hash_handle,并选择结构中的一个或多个字段作为键。然后使用这些宏来存储、检索或删除哈希表中的项。”

BSD 队列代码从 20 世纪 90 年代起就被使用和改进。它提供宏来创建和操作单向链表、简单队列、列表和尾队列。man手册页相当不错。 OpenBSD 和 FreeBSD 代码库中的功能有所不同。我使用 OpenBSD 版本,但它的功能稍少一些。特别是,FreeBSD 添加了 STAILQ(单向尾队列)和列表交换操作。曾经有一个用于循环队列的 CIRCLEQ,但由于使用了可疑的编码实践而被移除。 Uthash 和 Queue 都是包含宏的头文件,你可以将它们纳入你的项目中,通过 #include 引入,而不是链接库。总的来说,我认为“仅头文件的库”是不可取的,因为它们滥用了翻译单元的概念,使目标文件膨胀,并使调试更加困难。然而,我使用过这些库,它们确实运行良好。

用户界面

程序需要的 UI 功能越少,其可移植性就越高,出错的机会就越少。(你的命令行应用真的需要输出一个 emoji 火箭飞船或原地动画文本旋转器吗?)

最低的共同标准是 C 语言中的标准 I/O 库,或其他语言中的等价物。读写文本,假装成电传打字机。

下一个复杂层次是静态输出,但有一个可以修改的输入行(就像更高级的电传打字机,可以在发送前编辑一行)。不同的终端对行内编辑的支持方式不同,你应该使用一个库来处理它。经典的是 GNU readline。Readline 提供以下功能:

  • 移动文本光标(vi 和 emacs 模式)
  • 搜索命令历史记录
  • 控制 kill ring
  • 使用制表符补全

但其许可证是严格的 GPL,甚至不是 LGPL。还有更宽松的类似库,如 libedit(需要 ncurses),或 linenoise(限于 VT100 终端/模拟器)。 再向上一个层次是文本用户界面(TUI),整个屏幕是你的画布,但你用文本在上面绘制。历史上,终端控制代码差异很大,因此诞生了一个标准的编程接口:X/Open Curses。最流行的实现是 ncurses,它也添加了一些非标准扩展。 Curses 处理以下任务:

  • 终端能力检测
  • “原始”模式键盘输入
  • 光标移动
  • 绘制线条
  • 高亮、下划线
  • 插入和删除行与字符
  • 状态行
  • 区域清除
  • 窗口
  • 颜色

为了不再假装计算机是 70 年代的古老设备,你可以使用跨平台的 SDL2 库。它提供了对音频、键盘、鼠标、游戏杆和图形硬件的低级访问。平台支持确实令人印象深刻。从 Unix、Mac 和 Windows 到移动设备和网页渲染,无所不包。 最后,对于具有小部件的经典原生桌面应用程序,最稳定和可移植的选择可能是 Motif。界面有些朴素,但它可以在任何地方运行,并且不会在你身上改变或崩溃。 Motif 小部件示例 《Motif Programming Manual》(免费下载)在介绍中这样说道:

“那么为什么选择 motif?因为它长期以来一直是:所有 UNIX 平台上通用的原生窗口工具包,得到了所有主要操作系统供应商的完全支持。它仍然是唯一真正工业级的工具包,能够支持大规模和长期的项目。其他一切都是不完美的:要么不成熟或功能不完整,要么功能规范在每个版本中都以非向后兼容的方式改变,要么存在性能问题。也许它不能真正跨 UNIX 系统移植,或者它不能与桌面上用任何其他工具包编写的软件完全 ICCCM 兼容,或者存在政治斗争,因为各种团体试图为了自己的目的控制规范。……使用 motif,你知道你在哪里:它稳定、健壮、有专业支持,而且一切正常。”

参考手册也可供下载。 我有点怀疑它是否能得到 macOS 的支持,但我尝试了 hello world 示例,并且,确实,它在 XQuartz 上运行良好。我认为使用 Motif 而不是像 GTK 这样的庞然大物是有价值的。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计