Linux 上糟糕的二进制兼容性现状以及如何解决

By Dale Weiler GitHub

摘要

在评估 Linux 上的软件交付时,经常会忽略一个问题,即 Linux 二进制兼容性问题。 本文将剖析如何得出该结论,如何在当今交付软件时解决该问题,以及实际解决该问题需要做些什么。

目录:

介绍

在 JangaFX,我们开发了几款在 Linux 上原生运行的产品。 我们喜欢 Linux 为我们的开发者提供的灵活性和强大功能,但是在 Linux 上交付软件是另一项完全不同的挑战。

Linux 是一个非常强大的平台,但谈到软件交付,它可能感觉像是一个雷区。 与其他操作系统不同,Linux 不是一个单一的系统,而是不同的服务、库,甚至是理念的混乱组合。 每个发行版都以略有不同的方式做事,这意味着在一个系统上完美运行的同一个可执行文件可能会在另一个系统上完全崩溃。

这甚至不应该成为一个问题。 Linux 内核本身已经维护了相对稳定的系统调用。 但是在其之上构建的一切都在不断变化,并且以破坏兼容性的方式进行,这使得交付“开箱即用”的软件变得非常令人沮丧。 如果您正在为 Linux 开发,那么您不是针对一个单一的平台,而是在一个几乎不关心二进制兼容性而演变的生态系统中进行导航。

我们中的一些人在进入 VFX 之前来自游戏行业,之前就处理过这个问题。 在 Linux 上发布游戏一直是一场噩梦,无论在哪个行业,同样的问题仍然存在。 在本文中,我们将解释为什么我们认为容器是错误的方法,我们如何构建和交付实际可用的 Linux 软件,我们认为是什么导致了 Linux 的二进制兼容性问题,以及需要做出哪些改变才能解决它。

本文的后半部分将深入探讨问题的本质以及如何解决。

容器

FlatpakAppImage 等工具以及类似的解决方案试图通过创建“容器”(或者正如我们最近称呼它们的那样,“Linux 内部的 Linux 环境”)来简化可执行文件的交付。 通过使用 namespaceschroots 等 Linux 功能,这些解决方案将整个 Linux 环境(包括所有必需的依赖项)打包到单个自包含的包中。 在极端情况下,这意味着仅为一个应用程序交付整个 Linux 用户空间。

这些容器化解决方案的主要挑战之一是,它们通常不能很好地与需要与系统其余部分交互的应用程序一起工作。 为了访问 OpenGLVulkanVDPAUCUDA 等硬件加速 API,应用程序必须动态链接到系统的图形驱动程序库。 由于这些库存在于容器外部且无法与应用程序一起交付,因此开发了各种“传递”技术来解决此问题,其中一些技术引入了运行时开销(例如,填充库)。 由于容器化应用程序与系统隔离,因此它们通常感觉也很孤立。 这会产生一致性问题,即应用程序可能无法识别用户的姓名、主目录、系统设置、桌面环境首选项,甚至无法正确访问文件系统。

为了解决这些限制,许多容器化环境依赖于 XDG Desktop Portal 协议,该协议引入了另一层复杂性。 该系统需要通过 DBus 进行 IPC(进程间通信),才能授予应用程序访问基本系统功能(如文件选择、打开 URL 或读取系统设置)的权限。如果应用程序不是人为地沙盒化,则不会存在这些问题。

我们不认为堆砌更多层是一个可以接受的解决方案。 作为工程师,我们需要停下来问自己:“我们是否应该继续添加这个巴别塔?”,或者现在是否应该剥离掉这些抽象并重新评估它们? 在某些时候,正确的解决方案不是增加复杂性,而是减少复杂性。

虽然容器化解决方案可以在某些条件下工作,但我们认为交付精简的原生可执行文件(不使用容器)可以提供更无缝和集成的体验,并且更符合用户的期望。

版本控制

当您编译应用程序时,它会链接到构建计算机上存在的特定库版本。 这意味着默认情况下,用户系统上的版本可能不匹配,从而导致兼容性问题。 让我们假设用户安装了所有必需的库,但版本与您的应用程序构建时使用的版本不匹配。 这才是真正的问题开始的地方。 在不交付用于部署应用程序的确切机器的情况下,您如何确保与用户系统上安装的版本兼容?

我们认为有两种方法可以解决这个问题,并且我们给它们起了自己的名字:

  1. 复制方法 – 这意味着捆绑构建机器中的所有库并将它们与您的应用程序一起交付。 这是 Flatpak 和 AppImage 背后的理念。 我们 JangaFX 使用这种方法。
  2. 放松方法 – 您不是依赖特定的或较新的库版本,而是链接到非常旧的版本,几乎可以保证它们在任何地方都兼容。 这最大限度地降低了用户系统上不兼容的风险。

第一种方法在用户的机器上可能不存在必要的库的情况下效果很好,但是对于无法交付的库(我们称之为“系统库”)则会失败。第二种方法对于系统库尤其有效,也是我们在 JangaFX 使用的方法。

系统库

Linux 机器上存在各种无法交付的库,因为它们是系统库。 这些库与系统本身相关联,无法在容器中提供。 通常,这些包括 GPU 的用户空间驱动程序、企业安装的安全组件,当然还有 libc 本身。

如果您曾经尝试分发 Linux 二进制文件,您可能遇到过如下错误消息:

/lib64/libc.so.6: version `GLIBC_2.18' not found

对于那些不知道的人,glibc (GNU C Library) 提供了 C 标准库、POSIX API 以及负责加载共享库的动态链接器本身。 GLIBC 是一个“系统库”的例子,它不能与你的应用程序捆绑在一起,因为它包括动态链接器本身。 这个链接器负责加载其他库,其中一些库可能也依赖于 GLIBC,但并非总是如此。 更复杂的是,由于 GLIBC 是一个动态库,它也必须加载自己。 这种自引用、先有鸡还是先有蛋的问题突出了 GLIBC 的复杂性和单体设计,因为它试图同时履行多个角色。 这种单体设计的一个很大的缺点是,升级 GLIBC 通常需要升级整个系统。 在本文的后面,我们将解释为什么需要改变这种结构才能真正解决 Linux 的二进制兼容性问题。

在你建议静态链接 GLIBC 之前,这不是一个选项。 GLIBC 依赖于动态链接来实现 NSS(名称服务切换)模块等功能,这些模块处理主机名解析、用户身份验证和网络配置以及其他动态加载的组件。 静态链接会破坏这一点,因为它不包括动态链接器,这就是 GLIBC 不正式支持它的原因。 即使您设法静态链接 GLIBC,或者使用了像 musl 这样的替代品,您的应用程序也无法在运行时加载任何动态库。 静态链接动态链接器本身是不可能的,原因将在后面解释。 简而言之,这将阻止您的应用程序动态链接到任何系统库。

我们的方法

由于我们的应用程序依赖于许多用户系统上可能未安装的非系统库,因此我们需要一种包含它们的方法。 最直接的方法是复制方法,我们将这些库与我们的应用程序一起交付。 但是,这否定了动态链接的好处,例如共享内存使用和系统范围的更新。 在这种情况下,将这些库静态链接到应用程序中是一个更好的选择,因为它完全消除了依赖项问题。 它还启用了其他优化,例如 LTO,并通过从包含的库中剥离未使用的组件来缩小包的大小。

相反,我们采用不同的方法:静态链接我们能做到的一切。 这样做时,如果依赖项在其静态库中嵌入另一个依赖项,则需要特别注意。 我们遇到了包含来自其他静态库(例如,libcurl)的目标文件的静态库,但我们仍然需要单独链接它们。 动态库可以方便地避免这种重复,但是对于静态库,您可能需要从存档中提取所有目标文件并手动删除嵌入的文件。 同样,像 libgcc 这样的编译器运行时默认使用动态链接。 我们建议使用 -static-libgcc

最后,在处理系统库时,我们使用放松方法。 我们不是要求系统库的精确或更新版本,而是链接到足够旧的版本,几乎可以在任何地方兼容。 这增加了用户的系统库将与我们的应用程序一起工作的可能性,减少了依赖项问题,而无需容器化或捆绑系统组件和 shim。

当链接到较旧的系统库时,我们建议的方法是获得相应的较旧的 Linux 环境。 您不需要在物理硬件上安装旧的 Linux 版本,甚至不需要设置完整的虚拟机。 相反,chroot 在现有的 Linux 安装中提供了一个轻量级的、隔离的环境,允许您针对较旧的系统进行构建,而无需完全虚拟化的开销。 具有讽刺意味的是,这表明容器一直是正确的解决方案,只是不是在运行时,而是在构建时。

为了实现这一点,我们使用 debootstrap,这是一个出色的脚本,可以从头开始创建最小的 Debian 安装。 Debian 特别适合这种方法,因为它具有稳定性并且长期支持旧版本,使其成为确保与旧系统库兼容的绝佳选择。

当然,一旦您有了较旧的 Linux 设置,您可能会发现它的二进制包工具链太旧而无法构建您的软件。 为了解决这个问题,我们从源代码编译一个现代的 LLVM 工具链,并使用它来构建我们的依赖项和我们的软件。 此过程的详细信息超出了本文的范围。

最后,我们使用 Python 脚本自动化整个 debootstrap 过程,我们在此处提供该脚本供参考。


#!/bin/env python3import os, subprocess, shutil, multiprocessing

PACKAGES =['build-essential']DEBOOSTRAP ='https://salsa.debian.org/installer-team/debootstrap.git'ARCHIVE ='http://archive.debian.org/debian'VERSION ='jessie'# Released in 2015
defchroot(pipe):try:  os.chroot('chroot')  os.chdir('/')
# Setup an environment for the chroot  env ={'HOME':'/root','TERM':'xterm','PATH':'/bin:/usr/bin:/sbin:/usr/sbin'}
# The Debian is going to be quite old and so the keyring keys will likely be# expired. To work around this we will replace the sources.list to contain# '[trusted=yes]'withopen('/etc/apt/sources.list','w')as fp:   fp.write(f'deb [trusted=yes] http://archive.debian.org/debian {VERSION} main\n')
# Update and install packages  subprocess.run(['apt','update'], env=env)  subprocess.run(['apt','install','-y',*PACKAGES], env=env)
## Script your Linux here, remember to pass `env=env` to subprocess.run.## We suggest downloading GCC 7.4.0, compiling from source, and installing# it since it's the minimum version required to compile the latest LLVM from# source. We then suggest downloading, compiling from source, and installing# the latest LLVM, which as of time of writing is 20.1.0.## You can then compile and install all other source packages your software# requires from source using this modern LLVM toolchain.## You can also enter the chroot with an interactive shell from this script# by uncommenting the following and running this script as usual.# subprocess.run(['bash'])#
# You can send messages to the parent with pipe.send()  pipe.send('Done')# This one has special meaning in mainexcept Exception as exception:  pipe.send(exception)
defmain():# We need to run as root to use 'mount', 'umount', and 'chroot'if os.geteuid()!=0:print('Script must be run as root')returnFalse
with multiprocessing.Manager()as manager:  mounts = manager.list()  pipe = multiprocessing.Pipe()defmount(parts):   subprocess.run(['mount',*parts])   mounts.append(parts[-1])
# Ensure we have a fresh chroot and clone of debootstrap  shutil.rmtree('chroot', ignore_errors=True)  shutil.rmtree('debootstrap', ignore_errors=True)  os.mkdir('chroot')
# Clone debootstrap  subprocess.run(['git','clone', DEBOOSTRAP])  subprocess.run(['debootstrap','--arch','amd64', VERSION,'../chroot', ARCHIVE],          env={**os.environ,'DEBOOTSTRAP_DIR':'.'},          cwd='debootstrap')
# Mount nodes needed for the chroot  mount(['-t','proc','/proc','chroot/proc'])  mount(['--rbind','/sys','chroot/sys'])  mount(['--make-rslave','chroot/sys'])  mount(['--rbind','/dev','chroot/dev'])  mount(['--make-rslave','chroot/dev'])
# Setup the chroot in a separate process  process = multiprocessing.Process(target=chroot, args=(pipe[1],))  process.start()try:whileTrue:    data = pipe[0].recv()ifisinstance(data, Exception):raise data
else:print(data)if data =='Done':breakfinally:   process.join()for umount inreversed(list(set(mounts))):    subprocess.run(['umount','-R', umount])    subprocess.run(['sync'])
if __name__ =='__main__':try:  main()except KeyboardInterrupt:print('Cancelled')

修复它

一般来说,大多数应用程序不会直接链接到系统库,而是在运行时加载用户机器上已经存在的库。 因此,虽然这些库被认为是系统组件,但它们通常除了 libc 本身之外很少有系统依赖项。 这就是导致 GLIBC (特别是 GLIBC )成为兼容性问题的真正根源的原因,它本质上是唯一直接链接到的系统组件。

仅在过去两年中,我们的团队就遇到了三个独立的 GLIBC 特有 的兼容性问题,每个问题都直接影响了我们的产品:

  1. https://sourceware.org/bugzilla/show_bug.cgi?id=29456
  2. https://sourceware.org/bugzilla/show_bug.cgi?id=32653
  3. https://sourceware.org/bugzilla/show_bug.cgi?id=32786

在我们看来,GLIBC 的核心问题在于它试图做的事情太多了。 这是一个庞大的单体系统,可以处理从系统调用和内存管理到线程甚至动态链接器的一切事务。 这种紧密的耦合就是为什么升级 GLIBC 通常意味着升级整个系统,一切都交织在一起。 如果将其分解为更小、更集中的组件,用户可以只更新更改的部分,而不是拖着整个系统一起更新。

更重要的是,将动态链接器与 C 库 本身分开将允许 libc 的多个版本共存,从而消除兼容性问题的主要来源。 这正是 Windows 处理它的方式,这也是 Windows 保持如此强大的二进制兼容性的原因之一。 您今天仍然可以运行数十年前的 Windows 软件,因为 Microsoft 不会强制所有内容都与一个不断变化的 libc 绑定。

当然,这不像仅仅拆分东西那么简单。 GLIBC 具有深入的跨领域问题,尤其是在线程、TLS(线程本地存储)和全局内存管理方面。

例如,即使您设法使两个版本的 GLIBC 共存,从一个版本返回分配的内存并尝试在另一个版本中释放它也可能会导致严重的问题。 它们的堆不会相互感知,可能会使用不同的分配策略,从而导致不可预测的失败。 为了避免这种情况,即使堆也可能需要分成自己的专用 libheap

我们认为更好的方法是将 GLIBC 分解为不同的库,如下所示:

这些库将相互感知并允许同一地址空间中存在多个版本。 这样,我们就不会最终陷入这种脆弱的混乱局面,即升级 GLIBC 会破坏一切。 实际结构看起来像:

此架构与 Windows 非常相似,其中 libsyscalllibdllibheaplibthread 的等效项都捆绑在一个 kernel32.dll 中。 此 DLL 已预先映射并自动加载到 Windows 上每个可执行文件的地址空间中。

静态链接libc

[Application]
  │
  ▼
[libc (static)]
  │
  ▼
[libdl (static)]
  ├── [libheap (dynamic)]
  └── [libthread (dynamic)]
     └── [libheap (dynamic)]

动态链接libc

[Application (interpreter entry)]
  │
  ▼
[libdl (program loader)]
  │
  ▼
[libc (dynamic)]
  ├── [libheap (dynamic)]
  ├── [libthread (dynamic)]
  │   └── [libheap (dynamic)]
  ▼
[Application (regular entry)]

比较表

场景| libdl (包含在) | libc (加载方式) | libthread (通过 libdl) | libheap (通过 libdl)
---|---|---|---|---
静态libc| 静态链接 | 静态链接 | libdl链接 | libdl链接
动态libc| 程序解释器 | libdl链接 | libdl链接 | libdl链接

此架构有效地将二进制兼容性问题减少到两个关键的 系统库libheaplibthread。 这些不能静态链接,因为它们管理对整个系统至关重要的共享资源。

原因很简单,堆内存必须在所有组件之间共享,以确保分配和释放之间的兼容性。 同样,TLS 和线程需要统一的系统范围的方法,因为它们涉及复杂的初始化和终结逻辑,特别是对于全局构造函数和析构函数。 但是,这些组件相对较小且稳定,这意味着它们进行的版本更新所需的更改更少。

质疑它

当然,这是一个非同小可的架构重构,这自然会引发一个问题:为什么 libc 以这种方式实现,而不是以这种替代方法实现?

撇开历史原因不谈,当您开始编写任何使用 libc 的代码时,尝试快速解决此问题会变得很困难。 这是一个简单的例子,说明了尝试支持多个版本的 libc 时出现的问题。

假设您有一个动态库,其中包含以下 C 代码。


#include<stdio.h>FILE*open_thing(){returnfopen("thing.bin","r");}

并且您的应用程序链接到此库并调用 open_thing。 您的应用程序将负责在返回的 FILE* 上调用 fclose。 如果您的代码链接到的 libc 版本与库链接到的版本不同,那么它将调用错误的 fclose 实现!

假设 libc 的编写方式使得返回的 FILE* 始终需要一个版本字段或一个指向包含 fclose (和其他函数)实现的 vtable 的指针,并且每个版本的 libc 都对此达成一致,以便它始终可以在此 ABI 边界上调用正确的 fclose。 这将解决这个兼容性问题,但是现在假设您的代码调用 fflush


// 定义在头文件 <stdio.h>中intfflush(FILE *fp);

除了它不刷新文件,而是传递 NULL


fflush(NULL);

如果您不熟悉 C 的 fflush 函数,将 NULL 传递给它需要刷新所有打开的文件(每个 FILE*)。 但是,在这种情况下,它只会刷新您的应用程序链接到的 libc 版本看到的文件,而不是其他 libc 版本打开的文件(例如,open_thing 使用的文件)。

为了正确处理这种情况,每个 libc 版本都需要一种跨所有其他 libc 实例(包括动态加载的实例)枚举文件的方法,确保每个文件都只被访问一次,而不会形成循环。 此枚举也必须是线程安全的。 此外,在枚举进行时,可能会在单独的线程上动态加载另一个 libc(例如,通过 dlopen),或者可能会打开一个新文件(例如,动态加载库中的全局构造函数调用 fopen)。

这种由 libc 拥有的事物的全局列表出现在多个地方。 例如:


// 定义在头文件 <stdlib.h>中intatexit(void(*func)(void));

注册由 func 指向的函数,以便在正常程序终止时(通过 exit() 或从 main() 返回)调用。 这些函数将以它们注册的相反顺序调用,即最后注册的函数将首先执行。 还有另一个变体叫做 at_quick_exit

这意味着在 libc 中,必须有一个通过 atexit 注册的函数列表,这些函数需要以相反的顺序执行。 为了使多个 libc 实现共存,任何处理 atexit 的系统不仅必须枚举和调用所有注册的函数,而且还必须建立它们在所有 libc 实例中插入的总体顺序。

本质上,一个 libc 拥有的任何资源都需要是可共享的并且可以从任何其他版本的 libc 访问。 事实证明,这很多东西。 为了我们的论点,我们实际上已经完成了所有标准的 C(而不是 POSIX)函数,这些函数产生或操作一个不透明实现的资源,在这种情况下需要仔细考虑。

头文件 | 函数 | 资源 | 注释
---|---|---|---
<fenv.h>| N/A | fexcept_t | 浮点环境异常需要在 libc 中保持稳定。
<fenv.h>| * | fexcept_t | 任何使用此类型的函数
<fenv.h>| fegetenv | fenv_t | 浮点环境需要在 libc 中保持稳定。
<fenv.h>| * | fenv_t | 任何使用此类型的函数
<locale.h>| localeconv | struct lconv | 通用初始序列需要在 libc 中保持稳定。
<math.h>| N/A | int | 数学定义需要在 libc 中具有一组稳定的整数值。
<setjmp.h>| N/A | jmp_buf | 通常由编译器定义
<setjmp.h>| * | jmp_buf | 通常由编译器内部函数定义
<signal.h>| N/A | int | 信号定义需要在 libc 中具有一组稳定的整数值。
<signal.h>| N/A | sig_atomic_t | 跨 libc 的稳定类型
<stdarg.h>| N/A | va_list | 通常由编译器定义
<stdarg.h>| va_start | va_list | 通常由编译器内部函数定义
<stdarg.h>| * | va_list | 任何使用此类型的函数或宏
<stdatomic.h>| * | _Atomic T | 跨 libc 的稳定类型
<stdatomic.h>| N/A | int | 原子定义需要在 libc 中具有一组稳定的整数值。
<stdatomic.h>| N/A | typedef | 许多 typedef 需要在 libc 中具有一组稳定的类型。
<stddef.h>| N/A | typedef | 许多 typedef 需要在 libc 中具有一组稳定的类型。
<stdint.h>| N/A | typedef | 许多 typedef 需要在 libc 中具有一组稳定的类型。
<stdint.h>| N/A | int | 许多定义需要在 libc 中具有一组稳定的类型。
<stdio.h>| * | FILE | 许多函数(任何采用 FILE* 或返回 FILE* 的函数)
<stdio.h>| N/A | typedef | 许多类型需要在 libc 中具有一组稳定的类型。
<stdio.h>| N/A | int | 许多定义需要在 libc 中具有一组稳定的整数值。
<stdio.h>| N/A | N/A | 字符串格式化的区域设置需要在 libc 中共享
<stdio.h>| stderr | N/A | 需要是一个扩展到函数调用的宏,例如 __stdio(STDERR_FILENO)
<stdio.h>| stdout | N/A | 需要是一个扩展到函数调用的宏,例如 __stdio(STDOUT_FILENO)
<stdio.h>| stdin | N/A | 需要是一个扩展到函数调用的宏,例如 __stdio(STDIO_FILENO)
<stdlib.h>| N/A | div_t | 需要在 libc 中具有稳定的定义。
<stdlib.h>| N/A | ldiv_t | 需要在 libc 中具有稳定的定义。
<stdlib.h>| N/A | lldiv_t | 需要在 libc 中具有稳定的定义。
<stdlib.h>| N/A | int | 许多定义需要在 libc 中具有一组稳定的整数值。
<stdlib.h>| call_once | once_flag | 需要在 libc 和 libthread 中保持稳定
<stdlib.h>| rand | N/A | 全局 PRNG 需要在 libc 中共享。
<stdlib.h>| srand | N/A | 全局 PRNG 需要在 libc 中共享。
<stdlib.h>| aligned_alloc | void* | 共享堆
<stdlib.h>| calloc | void* | 共享堆
<stdlib.h>| free | void* | 共享堆
<stdlib.h>| free_sized | void* | 共享堆
<stdlib.h>| free_aligned_size | void* | 共享堆
<stdlib.h>| malloc | void* | 共享堆
<stdlib.h>| realloc | void* | 共享堆
<stdlib.h>| atexit | N/A | 全局列表需要在 libc 中共享。
<stdlib.h>| at_quick_exit | N/A | 全局列表需要在 libc 中共享。
<string.h>| strcoll | N/A | LC_COLLATE 区域设置需要在 libc 中共享
<threads.h>| N/A | cnd_t | 任何不透明方法
<threads.h>| N/A | thrd_t | 任何不透明方法
<threads.h>| N/A | tss_t | 任何不透明方法
<threads.h>| N/A | mtx_t | 任何不透明方法
<threads.h>| * | cnd_t | 许多使用此类型的函数
<threads.h>| * | thrd_t | 许多使用此类型的函数
<threads.h>| * | tss_t | 许多使用此类型的函数
<threads.h>| * | mtx_t | 许多使用此类型的函数
<threads.h>| * | typedef | 许多类型需要在 libc 中具有一组稳定的类型。
<threads.h>| N/A | int | 许多定义需要在 libc 中具有一组稳定的整数值。
<threads.h>| call_once | once_flag | 参见上面的 <stdlib.h>
<time.h>| N/A | typedef | 许多类型需要在 libc 中具有一组稳定的类型。
<time.h>| N/A | struct tm | 通用初始序列需要在 libc 中保持稳定。
<uchar.h>| N/A | char8_t | 需要在 libc 中相同
<uchar.h>| N/A | char16_t | 需要在 libc 中相同
<uchar.h>| N/A | char32_t | 需要在 libc 中相同
<uchar.h>| * | char8_t | 许多使用此类型的函数
<uchar.h>| * | char16_t | 许多使用此类型的函数
<uchar.h>| * | char32_t | 许多使用此类型的函数
<uchar.h>| N/A | mbstate_t | 任何不透明方法
<uchar.h>| * | mbstate_t | 许多使用此类型的函数。
<wchar.h>| * | * | 本质上重复 <uchar.h>
<wctype.h>| N/A | wctrans_t | 需要在 libc 中相同
<wctype.h>| N/A | wctype_t | 需要在 libc 中相同
<wctype.h>| * | wctrans_t | 许多使用此类型的函数
<wctype.h>| * | wctype_t | 许多使用此类型的函数

大多数定义(常量)、ABI 公开的类型(和 typedef)应该在 libc 实现中保持稳定,以便使其可靠地工作。 由于这些都烘焙到可执行文件中,因此如果没有破坏任何东西,就没有真正的方法来修改或更改它们。 当讨论不透明的东西(列为“任何不透明方法”)时,我们建议将指向包含实现的 vtable 的指针作为类型中的第一个值附加,这样操作它的函数始终可以恢复正确的实现并通过 vtable 间接调度它。 其他方法(如使用版本字段)也可以在此处工作。

无论如何,libc 的某些方面引入了复杂性,特别是像 errnolocale 这样的全局和线程本地元素。 但是,通过精心设计的架构,可以有效地解决这些挑战。

来自 <stdlib.h> 的内存分配函数(callocmallocaligned_allocreallocfree)提出了另一个难题。 由于它们可以返回任何指针,因此跟踪它们并非易事。 一种可能的方法是将指向 vtable 的指针存储在分配标头中,允许每个分配引用其实现。 但是,这种方法会产生显著的性能和内存开销。 相反,我们建议将堆管理集中在专用的 libheap 中。 这也将包含 POSIX 扩展(如 posix_memalign)的实现。

当从标准 C 转移到 POSIX 时,事情变得更加有趣,这引入了独特的挑战,需要 libc 支持。 这些功能中的一些功能可能最好拆分为单独的库(例如,为什么 DNS 解析器在 libc 中?)。 在这些挑战中,setxid 脱颖而出。

对于那些不熟悉的人,POSIX 中的权限(例如 实际、有效和保存的用户/组 ID)应用于进程级别。 但是,Linux 将线程视为共享内存的独立进程,这意味着这些权限是按线程而不是按进程管理的。 为了符合 POSIX 语义,libc 必须中断每个线程,强制它们执行调用系统调用的代码来修改它们的线程本地权限。 这必须以原子方式完成,不会失败,并且保持 async-signal 安全。 这是一场实现噩梦