Linux 二进制兼容性的糟糕现状(以及如何解决)
大家好!我是大聪明-PLUS!

Linux 中的二进制兼容性受到一个在考虑 Linux 软件版本时经常被忽视的因素的阻碍。在本文中,我将解释如何解决这个问题,如何在发布现代软件时解决这个问题,以及如何彻底消除这个问题。
介绍
我们公司开发了许多基于 Linux 原生运行的产品。我们欣赏 Linux 操作系统为开发者带来的灵活性和强大功能,但发布基于 Linux 的软件则是另一回事。
Linux 是一个极其强大的平台,但为其发布软件却如同穿越雷区。与其他操作系统不同,Linux 并非一个单一的系统,而是各种服务、库甚至理念的混杂组合。每个发行版处理问题的方式略有不同,这意味着同一个可执行文件可能在一个系统上完美运行,而在另一个系统上却完全崩溃。
这根本不成问题,因为 Linux 内核本身保留了相对稳定的系统调用。但内核之上的一切都在不断变化,破坏了兼容性,并极大地增加了发布“正常运行”软件的难度。如果您正在为 Linux 开发,那么您的目标平台不止一个——您身处的生态系统在不断发展,却很少考虑二进制兼容性。
我们公司的一些员工之前从游戏开发转到视觉特效部门,也遇到过这个问题。在 Linux 上发布游戏一直是一场噩梦,但任何行业都会遇到同样的问题。在本文中,我将解释为什么我们认为容器是错误的解决方案,并描述我们如何为 Linux 构建和发布软件,使其真正发挥作用。我们还将讨论 Linux 中二进制兼容性问题的根源以及如何解决这些问题。
容器
Flatpak和AppImage等工具试图通过创建“容器”(或者我们最近开始称之为“Linux 中的 Linux 环境”)来简化可执行文件的发布流程。这些工具利用命名空间 和 chroot等 Linux 特性,将完整的 Linux 环境及其所有必要的依赖项打包到一个独立的软件包中。在极端情况下,甚至可以为单个应用程序发布完整的 Linux 用户空间。
此类容器化解决方案面临的最大挑战之一是,它们通常无法与需要与系统其他部分交互的应用程序良好兼容。要访问OpenGL、 Vulkan、 VDPAU 和CUDA 等硬件加速 API,应用程序必须动态链接到系统的图形驱动程序库。由于这些库位于容器外部,无法随应用程序一起提供,因此人们开发了各种“直通”技术,其中一些会增加运行时开销(例如,库填充)。由于容器化应用程序与系统隔离,它们常常会感到孤立。这会造成一致性问题:例如,应用程序可能无法识别用户名、主文件夹、系统设置、桌面环境设置,甚至无法正确访问文件系统。
为了规避这些限制,许多容器环境使用XDG 桌面门户协议,这又增加了一层复杂性。该系统需要通过DBus进行 IPC(进程间通信) ,才能为应用程序提供基本的系统功能,例如选择文件、打开 URL 和读取系统设置。如果应用程序没有被人为地沙盒化,这些问题就不会存在。
我们认为增加层级不是一个可接受的解决方案。我们开发人员需要停下来问问自己:是否值得继续建造这座巴别塔,还是应该移除一些抽象,从不同的角度看待它们?迟早,正确的解决方案是降低复杂性,而不是增加复杂性。
虽然在某些情况下容器化解决方案当然是可以接受的,但我们相信发布没有容器的本机可执行文件可以提供更加集成的用户体验,从而更好地满足用户的期望。
版本控制
编译应用程序时,它会与构建计算机上的特定库版本进行链接。这意味着用户系统上的版本可能不匹配,从而导致兼容性问题。假设用户安装了所有必需的库,但它们的版本与构建应用程序时使用的版本不匹配。这才是真正的问题所在。我们不能将构建该软件的计算机与软件一起出售。我们如何确保与用户系统上安装的版本兼容?
我们认为有两种方法可以解决这个问题。我们来分别说一下:
-
复制 是指将构建机器中的所有库合并,并将它们与应用程序一起发布。Flatpak 和 AppImage 遵循此理念。我们不使用这种方法。
-
保守 ——我们不依赖特定或新的库版本,而是链接到老版本,这样几乎可以保证在任何机器上都能兼容。这最大限度地降低了用户系统不兼容的风险。
第一种方法在用户机器缺少所需库的情况下效果很好,但在处理无法随软件安装的库(我们称之为“系统库”)时会失效。第二种解决方案对于系统库特别有效;我们公司就是采用这种方案。
系统库
Linux 机器上有许多库不能随软件一起发布,因为它们是系统库。它们与系统本身绑定,无法通过容器发布。通常,这些库包括用户空间 GPU 驱动程序、企业安全库,当然还有libc本身。
如果您曾经尝试分发 Linux 二进制文件,您可能遇到过如下错误消息:
/lib64/libc.so.6: version `GLIBC_2.18' not found
如果您不熟悉,glibc (GNU C 库)提供了标准 C 库、POSIX API、负责加载共享库的动态链接器以及它本身。
GLIBC 是一个“系统库”的例子,它无法与应用程序捆绑,因为它本身包含动态链接器。该链接器负责加载其他库,其中一些库可能也依赖于 GLIBC,但并非总是如此。更复杂的是,由于 GLIBC 是一个动态库,它也会加载自身。这种自引用的“先有鸡还是先有蛋”的问题凸显了 GLIBC 的复杂性和单片架构,它试图同时履行多种职责。这种单片架构的一个严重缺陷是,升级 GLIBC 通常需要升级整个系统。下面,我们将解释为什么必须更改这种结构才能完全解决 Linux 中的二进制兼容性问题。
有些人可能建议静态链接 GLIBC,但我认为这不是一个选择。GLIBC 需要动态链接诸如处理主机名解析、身份验证和网络配置的NSS模块以及其他动态加载的组件。静态链接会破坏所有这些功能,因为它不包含动态链接器,因此 GLIBC 不正式支持它。即使您设法静态链接 GLIBC 或使用musl之类的替代方案,您的应用程序也无法在运行时加载动态库。由于以下原因,静态链接动态链接器本身是不可能的。简而言之,它会完全阻止您的应用程序与任何系统库进行动态链接。
我们的解决方案
由于我们的应用程序使用了许多用户系统上可能未安装的非系统库,因此我们需要以某种方式将它们包含在内。最简单的解决方案是使用复制方案: 将这些库随应用程序一起发布。然而,这会失去动态链接的优势,例如共享内存和系统范围的更新。在这种情况下,静态链接这些库会更好,因为它可以完全消除兼容性问题。它还可以实现额外的优化,例如LTO,并通过从包含的库中剥离未使用的组件来减小软件包大小。
相反,您可以尝试另一种方法: 静态链接所有可以链接的内容。执行此操作时,请特别小心,确保依赖项不会将另一个依赖项内联到其静态库中。我们遇到过许多静态库包含来自其他静态库(例如 libcurl)的目标文件,但它们仍然必须单独链接。使用动态库可以方便地避免这种重复,但对于静态库,您可能需要手动从存档中提取所有目标文件并删除任何内联文件。同样,像 .py 这样的编译器运行时libgcc 默认使用动态链接。我们建议使用-static-libgcc.py。
在系统库方面,我们采取了保守的做法。我们不要求特定或较新版本的系统库,而是链接到一些较旧的版本,以确保其兼容性。这增加了用户的系统库与我们的应用程序兼容的可能性,从而减少了依赖问题,而无需对系统组件和垫片进行容器化或打包。
在为旧系统构建时,我们建议找到合适的旧版 Linux 环境。无需在物理硬件上安装旧版 Linux,甚至无需设置完整的虚拟机 ——chrooting 能够在现有 Linux 中创建一个轻量级的隔离环境。这让您无需承担完全虚拟化的开销即可为旧系统构建。讽刺的是,容器实际上是一个合适的解决方案,尽管它不适用于运行时环境,而是适用于构建环境。
为此,我们使用debootstrap,这是一个优秀的脚本,可以从头开始创建 Debian 的精简安装。Debian 尤其适合此解决方案,因为它稳定性高,并且长期支持旧版本,确保与旧系统库兼容。
当然,在设置了较旧的 Linux 系统后,您可能会发现其二进制软件包工具链过于陈旧,无法构建您的软件。为了解决这个问题,我们从源代码编译了一个现代的 LLVM 工具链,并使用它来构建依赖项和我们的软件。
然后,我们使用 Python 脚本自动化整个 debootstrap 过程。
#!/bin/env python3
import os, subprocess, shutil, multiprocessingPACKAGES = [ 'build-essential' ]
DEBOOSTRAP = 'https://salsa.debian.org/installer-team/debootstrap.git'
ARCHIVE = 'http://archive.debian.org/debian'
VERSION = 'jessie' # Released in 2015def chroot(pipe):try:os.chroot('chroot')os.chdir('/')env = {'HOME': '/root','TERM': 'xterm','PATH': '/bin:/usr/bin:/sbin:/usr/sbin'}with open('/etc/apt/sources.list', 'w') as fp:fp.write(f'deb [trusted=yes] http://archive.debian.org/debian {VERSION} main\n')subprocess.run(['apt', 'update'], env=env)subprocess.run(['apt', 'install', '-y', *PACKAGES], env=env)pipe.send('Done') except Exception as exception:pipe.send(exception)def main():if os.geteuid() != 0:print('Script must be run as root')return Falsewith multiprocessing.Manager() as manager:mounts = manager.list()pipe = multiprocessing.Pipe()def mount(parts):subprocess.run(['mount', *parts])mounts.append(parts[-1])shutil.rmtree('chroot', ignore_errors=True)shutil.rmtree('debootstrap', ignore_errors=True)os.mkdir('chroot')subprocess.run(['git', 'clone', DEBOOSTRAP])subprocess.run(['debootstrap', '--arch', 'amd64', VERSION, '../chroot', ARCHIVE],env={**os.environ, 'DEBOOTSTRAP_DIR': '.'},cwd='debootstrap')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'])process = multiprocessing.Process(target=chroot, args=(pipe[1],))process.start()try:while True:data = pipe[0].recv()if isinstance(data, Exception):raise dataelse:print(data)if data == 'Done':breakfinally:process.join()for umount in reversed(list(set(mounts))):subprocess.run(['umount', '-R', umount])subprocess.run(['sync'])if __name__ == '__main__':try:main()except KeyboardInterrupt:print('Cancelled')
更正
一般来说,大多数应用程序不会直接链接系统库,而是在运行时加载用户计算机上可用的库。因此,尽管这些库被视为系统组件,但它们通常除了 libc 之外还依赖其他几个系统库。这正是 libc (尤其是GLIBC)成为兼容性问题的真正根源的原因:它本质上是唯一直接链接的组件。
仅在过去两年中,我们的团队就遇到了三个与 GLIBC 直接相关的兼容性问题,每个问题都直接影响了我们的产品:
我们认为,GLIBC 的主要问题 在于它试图做太多事情。它是一个庞大的单片系统,处理从系统调用、内存管理、线程到动态链接器的所有事情。由于这种紧密耦合,升级GLIBC 通常需要升级整个系统:所有东西都交织在一起。如果将库分解成更小的组件,用户就可以升级更改的部分,而不必拖着整个系统到处跑。
更重要的是,将动态链接器与C 库本身分离,将允许多个版本的 libc共存,从而消除兼容性问题的主要根源。这正是Windows 的工作原理,也是其应用程序具有如此出色二进制兼容性的原因之一。我们可以运行几十年前编写的软件,因为微软并没有将所有东西都绑定到一个不断变化的 libc 上。
当然,事情并没有那么简单。GLIBC 存在着深层次的跨领域问题,尤其是在线程、TLS(线程本地存储)和全局内存管理方面。
例如,如果我们能够共存两个版本的 GLIBC,那么从一个版本回收已分配的内存,并尝试在另一个版本中释放它可能会导致严重的问题。它们的堆彼此之间无法感知,可能会使用不同的分配策略,从而导致不可预测的崩溃。为了避免这种情况,可能需要将堆分离成一个专用的libheap。
我们认为最好将 GLIBC 拆分成单独的库,如下所示:
-
libsyscall 仅处理系统调用的执行,不处理其他任何事情。它仅作为静态库提供。它被
libheap、libthread和libc用来访问公共系统调用代码。由于它是静态的,因此它内置于这三个库中。否则,该库可以被视为不存在。 -
libdl(动态链接器) 是一个独立的链接器,用于加载共享库。它仅与 进行静态链接
libsyscall。它是一个真正独立的库,独立于任何其他库。它以静态库和动态库的形式提供。静态链接时,仍然可以动态加载。动态链接器将仅包含在可执行文件中。 -
libheap 是下列所有库共享的单个堆。它仅作为动态堆提供。无法进行静态链接。
-
libthread 与线程和 TLS 兼容,并与 链接
libheap。它仅以动态库的形式提供。无法进行静态链接。 -
libc 与 链接
libthread,因此也与libheap和传递性地链接libdl。它以静态和动态方式提供。静态链接时,它会进行libdl静态链接。libthread和 的链接libheap始终以动态方式执行,但在静态链接的情况下通过包含库进行,而 在动态链接的情况下libdl通过程序加载器进行。libdl
这些库彼此感知彼此的存在,并允许多个版本在同一地址空间中共存。这可以避免 GLIBC 升级可能造成混乱的情况。其结构大致如下。
这种架构与 Windows 非常相似,其中
libsyscall、libdl和libheap的等效项libthread被组合成一个kernel32.dll。在 Windows 中,此 DLL 会自动加载到每个可执行文件的地址空间中。
静态链接的 libc
-
该应用程序是静态链接的
libc(libdl这不是程序加载器)。 -
应用程序开始执行并
libheap使用libthread内置的 libdl 动态链接。 -
libc并libdl嵌入在可执行文件中,这意味着执行会启动应用程序本身。 -
内置
libdl动态加载libthread和libheap。
动态链接的 libc
-
应用程序通过程序解释器(
libdl)开始执行。 -
libdl(程序加载器)加载应用程序并执行依赖关系解析。 -
应用程序动态地组成
libc、libheap和libthread。
比较表
| 设想 | libdl(包含方法) | libc(加载方法) | libthread(通过 libdl) | libheap(通过 libdl) |
|---|---|---|---|---|
| 静态 libc | 静态链接 | 静态链接 | 链接 libdl | 链接 libdl |
| 动态库 | 程序解释器 | 链接 libdl | 链接 libdl | 链接 libdl |
这种架构本质上将整个二进制兼容性问题归结为两个关键的系统库: libheap 和libthread。它们无法静态链接,因为它们管理着对整个系统至关重要的共享资源。
原因很简单:堆内存必须在所有组件之间共享,以确保分配和释放之间的一致性。同样,TLS 和线程需要统一的系统级方法,因为它们涉及复杂的初始化和终止逻辑,尤其是全局构造函数和析构函数。然而,这些组件相对较小且稳定,这意味着它们很少需要进行版本升级的更改。
疑虑
当然,这需要大量的投资来改变架构,因此自然会出现一个问题:为什么libc要以这种方式实现,而不是按照其他方法实现?
抛开历史原因,一旦开始使用 编写任何代码,解决这个问题就会变得非常困难libc。下面是一个简单的例子,说明了在尝试实现对 的多版本支持时出现的问题libc。
假设您有一个包含以下 C 代码的动态库。
#include <stdio.h>
FILE* open_thing() {return fopen("thing.bin", "r");
}
您的应用程序链接到此库并调用open_thing。您的应用程序负责调用fclose 返回的FILE*。如果您的代码链接到的 版本与libc库链接的版本不同,它将调用错误的实现fclose!
但是,假设它的libc编写方式是,返回值FILE* 始终需要一个版本字段或指向包含实现fclose (以及其他函数)的虚表的指针,并且所有版本都libc同意这一点,那么它就始终可以跨越此 ABI 边界调用正确的函数。这将解决兼容性问题;但现在想象一下我们的代码调用fflush……
int fflush(FILE *fp);
只是它不会重置文件,而是传递 NULL。
fflush(NULL);
如果您不熟悉这个fflush C 函数,那么传入 NULL 会导致所有打开的文件(每个FILE*)都被刷新。但是,在这种情况下,它只会刷新libc应用程序所链接的 版本可见的文件,而不会刷新其他版本打开的文件libc(例如 所打开的版本open_thing)。
为了优雅地处理这种情况,每个版本都libc需要一种方法来枚举所有其他实例libc(包括动态加载的实例)中的文件,并确保每个文件只被访问一次而不会产生循环。此外,这种枚举必须是线程安全的。此外,在枚举运行时,可能会动态加载另一个文件libc(例如,通过dlopen),或者打开一个新文件(例如,通过调用 的动态加载库中的全局构造函数fopen)。
这个全局元素拥有列表libc在很多地方出现。以下面的代码为例:
int atexit(void (*func)(void));
使用 的函数引用的寄存器
func应在程序正常终止(使用exit()或从 返回main())时调用。函数将按照其注册的逆序调用,即最后注册的函数将首先执行。
该函数还有另一个版本,称为
at_quick_exit。
这意味着,内部某处libc必须有一个通过注册的函数列表atexit,这些函数必须以相反的顺序执行。为了使多个实现共存libc,所有处理atexit 系统不仅必须列出并调用所有注册函数,还必须为它们在所有实例中的插入建立一个全局顺序libc。
本质上,一个版本的 所拥有的任何资源都libc必须与 的任何其他版本共享并可访问libc。这需要付出相当大的努力。为了证实这一点,我们审查了所有具有不透明实现的标准 C(非 POSIX)函数列表,这些函数创建或操作资源并需要特别注意。
| 标题 | 功能 | 资源 | 笔记 |
|---|---|---|---|
| <fenv.h> | 不适用 | fexcept_t | 浮点环境异常对于所有来说都必须是稳定的 |
| <fenv.h> | * | fexcept_t | 任何使用此类型的函数 |
| <fenv.h> | 费格滕夫 | fenv_t | 浮点环境异常对于所有来说都必须是稳定的 |
| <fenv.h> | * | fenv_t | 任何使用此类型的函数 |
| <locale.h> | localeconv | 结构 lconv | 对于所有 ,整体初始序列必须稳定 |
| <数学.h> | 不适用 | 整数 | 数学的定义必须对所有都有一组稳定的整数值 |
| <setjmp.h> | 不适用 | 跳转缓冲区 | 通常由编译器决定 |
| <setjmp.h> | * | 跳转缓冲区 | 通常由编译器内置函数定义 |
| <信号.h> | 不适用 | 整数 | 该信号决定了所有 都有一组稳定的整数值的必要性 |
| <信号.h> | 不适用 | sig_atomic_t | 适合所有人的稳定型 |
| <stdarg.h> | 不适用 | va_list | 通常由编译器决定 |
| <stdarg.h> | va_start | va_list | 通常由编译器内置函数定义 |
| <stdarg.h> | * | va_list | 任何使用此类型的函数或宏 |
| <stdatomic.h> | * | _原子T | 适合所有人的稳定型 |
| <stdatomic.h> | 不适用 | 整数 | 原子定义必须对所有都有一组稳定的整数值 |
| <stdatomic.h> | 不适用 | 类型定义 | 许多人 |
| <stddef.h> | 不适用 | 类型定义 | 许多人 |
| <stdint.h> | 不适用 | 类型定义 | 许多人 |
| <stdint.h> | 不适用 | 整数 | 许多定义必须具有一组适用于所有类型的稳定类型 |
| <stdio.h> | * | 文件 | 许多功能(所有接收 |
| <stdio.h> | 不适用 | 类型定义 | 许多类型必须具有一组稳定的类型 |
| <stdio.h> | 不适用 | 整数 | 许多定义必须对所有有一组稳定的整数值 |
| <stdio.h> | 不适用 | 不适用 | 字符串格式的区域设置必须对所有 |
| <stdio.h> | 标准错误 | 不适用 | 必须是扩展为函数调用的宏,例如 |
| <stdio.h> | 标准输出 | 不适用 | 必须是扩展为函数调用的宏,例如 |
| <stdio.h> | 标准输入 | 不适用 | 必须是扩展为函数调用的宏,例如 |
| <stdlib.h> | 不适用 | div_t, | 必须对每个人都有一个稳定的定义 |
| <stdlib.h> | 不适用 | ldiv_t, | 必须对每个人都有一个稳定的定义 |
| <stdlib.h> | 不适用 | lldiv_t | 必须对每个人都有一个稳定的定义 |
| <stdlib.h> | 不适用 | 整数 | 许多定义必须对所有有一组稳定的整数值 |
| <stdlib.h> | 调用一次 | once_flag | 必须对每个人都稳定 |
| <stdlib.h> | 兰特 | 不适用 | 全局 PRNG 必须对所有 都通用 |
| <stdlib.h> | 斯兰德 | 不适用 | 全局 PRNG 必须对所有 都通用 |
| <stdlib.h> | aligned_alloc | 空白* | 普通桩 |
| <stdlib.h> | 卡洛克 | 空白* | 普通桩 |
| <stdlib.h> | 自由的 | 空白* | 普通桩 |
| <stdlib.h> | 自由尺寸 | 空白* | 普通桩 |
| <stdlib.h> | 空闲对齐大小 | 空白* | 普通桩 |
| <stdlib.h> | malloc | 空白* | 普通桩 |
| <stdlib.h> | 重新分配 | 空白* | 普通桩 |
| <stdlib.h> | 退出 | 不适用 | 全球清单应该对每个人都适用 |
| <stdlib.h> | 快速退出 | 不适用 | 全球清单应该对每个人都适用 |
| <string.h> | 斯特科尔 | 不适用 | 区域设置 |
| <线程.h> | 不适用 | cnd_t | 任何不透明的方法 |
| <线程.h> | 不适用 | thrd_t | 任何不透明的方法 |
| <线程.h> | 不适用 | tss_t | 任何不透明的方法 |
| <线程.h> | 不适用 | mx_t | 任何不透明的方法 |
| <线程.h> | * | cnd_t | 许多函数都使用此类型 |
| <线程.h> | * | thrd_t | 许多函数都使用此类型 |
| <线程.h> | * | tss_t | 许多函数都使用此类型 |
| <线程.h> | * | mx_t | 许多函数都使用此类型 |
| <线程.h> | * | 类型定义 | 许多类型都需要有一组稳定的类型 |
| <线程.h> | 不适用 | 整数 | 许多定义必须对所有有一组稳定的整数值 |
| <线程.h> | 调用一次 | once_flag | 请参阅 |
| <时间.h> | 不适用 | 类型定义 | 许多类型都需要有一组稳定的类型 |
| <时间.h> | 不适用 | 结构 tm | 对于所有 ,整体初始序列必须稳定 |
| <uchar.h> | 不适用 | char8_t | 对于每个人来说都应该是一样的。 |
| <uchar.h> | 不适用 | char16_t | 对于每个人来说都应该是一样的。 |
| <uchar.h> | 不适用 | char32_t | 对于每个人来说都应该是一样的。 |
| <uchar.h> | * | char8_t | 许多函数都使用此类型 |
| <uchar.h> | * | char16_t | 许多函数都使用此类型 |
| <uchar.h> | * | char32_t | 许多函数都使用此类型 |
| <uchar.h> | 不适用 | mbstate_t | 任何不透明的方法 |
| <uchar.h> | * | mbstate_t | 许多函数都使用这种类型。 |
| <wchar.h> | * | * | 本质上是重复 |
| <wctype.h> | 不适用 | wctrans_t | 对于每个人来说都应该是一样的。 |
| <wctype.h> | 不适用 | wctype_t | 对于每个人来说都应该是一样的。 |
| <wctype.h> | * | wctrans_t | 许多函数都使用此类型 |
| <wctype.h> | * | wctype_t | 许多函数都使用此类型 |
为了使其可靠地工作,大多数定义(常量)和 ABI 公开的类型(和typedef)必须在所有实现中保持稳定libc。由于它们被嵌入到可执行文件中,因此我们无法在不破坏现有功能的情况下对其进行修改或更改。在讨论不透明元素(列为“任何不透明方法”)时,我们建议将指向包含实现的虚表的指针作为类型中的第一个值。这确保访问它的函数始终能够恢复正确的实现并通过虚表执行间接调度。其他使用版本字段的方法也可能适用于此。
然而,某些方面libc会增加复杂性,尤其是全局和线程特定的元素,例如errno 和locale。不过,通过精心设计,这些挑战可以得到有效解决。
<stdlib.h>内存分配函数(calloc、 malloc、 和aligned_alloc) 带来了另一个复杂问题。由于它们可以返回任何指针,因此跟踪它们并非易事。一种解决方案是在分配头中存储指向虚表的指针,从而允许每个分配引用其自身的实现。然而,这种技术会带来显著的性能和内存开销。因此,我们建议将堆管理集中到一个专用的 中。这个 .vtable 还将包含 POSIX 扩展的实现,例如。reallocfreelibheapposix_memalign
从标准 C 到 POSIX 的过渡变得更加有趣:出现了一些需要支持的独特问题libc。其中一些功能可能放在单独的库中会更好(例如,为什么我们libc需要 DNS 解析器?)。然而,在这些困难中,有一个问题尤为突出setxid。
问题在于,POSIX 权限(例如实际ID、有效ID和已保存的用户组ID)在进程级别适用。然而,Linux 将线程视为共享内存的独立进程;也就是说,这些权限是按线程而不是按进程处理的。为了符合 POSIX 语义,libc每个线程都必须被中断,强制其执行代码,该代码会进行系统调用来修改其线程本地权限。这必须以原子方式完成,不会崩溃,同时还要保持异步信号安全。实现这一点将是一场真正的噩梦和一项重大挑战。更重要的是,正确的实现对于安全至关重要。
最终,这意味着它libc必须跟踪每个线程,并提供跨所有线程同步执行代码的能力。为了解决这个问题,我们建议将线程管理、TLS 和必要的 POSIX 协商机制整合到一个libthread.
还有许多其他复杂因素我们尚未考虑,以及众多替代的实现方案。关键在于,这些问题是可以解决的,但需要进行重大的架构变革。这需要彻底重新思考 Linux 用户空间的这一方面,并将二进制兼容性作为核心架构原则。GLIBC 的开发人员从未认真尝试过这一点。除非有人觉得他们已经受够了并采取行动来解决这个问题,否则 Linux 中的二进制兼容性将一直是一个悬而未决的问题,而我们相信这是一个值得解决的问题。
