habrahabr

Ужасное состояние двоичной совместимости Linux (и что с ним делать)

  • четверг, 3 апреля 2025 г. в 00:00:11
https://habr.com/ru/articles/893720/

Двоичная совместимость в Linux омрачена одним аспектом, который часто упускают из виду при рассмотрении выпуска ПО для Linux. В этой статье я расскажу, как можно увидеть этот аспект, как решать эту проблему при выпуске современного ПО и что же именно нужно сделать, чтобы устранить её полностью.

Введение

Наша компания разрабатывает множество продуктов, нативно работающих в Linux. Нам нравится гибкость и мощь, предоставляемые этой операционной системой разработчикам, но выпуск ПО для неё — это отдельный вопрос.

Linux — невероятно мощная платформа, но выпуск ПО для неё похож на прогулку по минному полю. В отличие от других операционных систем, Linux — это не просто одна система, а хаотическая смесь различных сервисов, библиотек и даже философий. Каждый дистрибутив решает задачи немного по-своему, из-за чего один и тот же исполняемый файл может безупречно работать в одной системе и полностью ломаться в другой.

Это вообще не должно быть проблемой, ведь само ядро Linux сохранило относительно стабильные системные вызовы. Но всё, что находится поверх них, постоянно меняется, ломая совместимость и сильно усложняя выпуск ПО, которое «просто работает». Если вы занимаетесь разработкой под Linux, то целевой для вас становится не одна платформа — вам приходится работать в экосистеме, эволюционировавшей почти без учёта двоичной совместимости.

Некоторые из сотрудников нашей компании, пришедшие в сферу VFX из разработки игр, сталкивались с этой проблемой и раньше. Выпуск игр для Linux всегда был кошмаром, но те же самые проблемы возникают в любой отрасли. В этой статье я объясню, почему мы считаем контейнеры неправильным решением, и расскажу, как мы собираем и выпускаем ПО для Linux, чтобы оно действительно работало. Также мы поговорим об источниках проблемы двоичной совместимости в Linux и мерах по её устранению.

Контейнеры

Инструменты наподобие Flatpak и AppImage пытаются упростить процесс выпуска исполняемых файлов при помощи создания «контейнеров» или, как мы недавно начали их называть, «сред Linux внутри Linux». Эти инструменты при помощи таких особенностей Linux, как пространства имён и chroot, полностью упаковывают среду Linux со всеми нужными зависимостями в единый автономный комплект. В крайних случаях при этом для одного приложения выпускается полное пользовательское пространство Linux.

Одна из самых больших трудностей таких контейнированных решений заключается в том, что часто они плохо работают с приложениями, которым требуется взаимодействие с остальной частью системы. Для доступа к API с аппаратным ускорением наподобие OpenGLVulkanVDPAU и CUDA приложение должно выполнять динамическую компоновку с библиотеками графических драйверов системы. Так как эти библиотеки находятся снаружи контейнера и их нельзя поставлять вместе с приложением, были разработаны различные техники «сквозного пропускания», часть из которых добавляет оверхед в среде исполнения (например, shimming библиотек). Так как контейнированные приложения изолированы от системы, они часто и ощущаются изолированными. Это создаёт проблемы согласованности: допустим, приложение может не распознать имя пользователя, папку home, системные настройки, параметры десктопного окружения или даже не иметь надлежащего доступа к файловой системе.

Чтобы обойти эти ограничения, многие контейнированные окружения используют протокол XDG Desktop Portal, добавляющий ещё один уровень сложности. Для этой системы требуется IPC (inter-process communication) через DBus лишь для того, чтобы предоставить приложениям базовые функции системы наподобие выбора файлов, открытия URL и чтения параметров системы. Этих проблем бы вообще не существовало, если бы приложение искусственным образом не поместили в «песочницу».

Мы не считаем, что наворачивание дополнительных слоёв — это приемлемое решение. Нам, разработчикам, следует остановиться и задаться вопросом, нужно ли продолжать строить эту Вавилонскую башню или стоит убрать часть этих абстракций и посмотреть на них под другим углом? Рано или поздно настаёт момент, когда правильным решением становится не увеличение, а уменьшение сложности.

Разумеется, в определённых условиях контейнированные решения приемлемы, но мы считаем, что выпуск нативных исполняемых файлов без контейнеров обеспечивает более интегрированный UX, который лучше соответствует ожиданиям пользователей.

Версионность

При компилировании приложения оно компонуется с конкретными версиями библиотек, присутствующими на машине для сборки. То есть версии в системе пользователя могут с ними не совпадать, вызывая проблемы совместимости. Предположим, что у пользователя установлены все необходимые библиотеки, но их версии не совпадают с теми, с которыми собиралось приложение. И вот здесь начинаются настоящие проблемы. Мы не можем продавать вместе с ПО машину, на которой оно было собрано. Как же нам обеспечить совместимость с версиями, установленными в системе пользователя?

Мы считаем, что существует два способа решения этой проблемы. Дадим им названия:

  1. Репликация — объединение всех библиотек с машины для сборки и выпуск их вместе с приложением. Этой философии придерживаются Flatpak и AppImage. Наша компания не использует такой подход.

  2. Консервативность — вместо того, чтобы полагаться на специфичные или новые версии библиотек, мы выполняем компоновку с версиями настолько старыми, что совместимость с ними практически гарантирована на любой машине. Это минимизирует опасность несовместимости в пользовательской системе.

Первый подход хорошо срабатывает в ситуациях, когда необходимые библиотеки могут отсутствовать на машине пользователя, но терпит неудачу в случаях с библиотеками, которые невозможно поставить вместе с ПО (мы называем их «системными библиотеками»). Второе решение особенно эффективно для системных библиотек; именно его и использует наша компания.

Системные библиотеки

На машине с 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), но их всё равно приходилось компоновать отдельно. Этой дупликации можно удобным образом избежать благодаря динамическим библиотекам, однако в случае со статическими библиотеками может понадобиться вручную извлечь все объектные файлы из архива и удалить встроенные. Аналогично, среды исполнения компиляторов наподобие libgcc по умолчанию используют динамическую компоновку. Мы рекомендуем использовать -static-libgcc.

Когда дело касается системных библиотек, мы задействуем консервативное решение. Мы не требуем конкретных или более новых версий системных библиотек, а выполняем компоновку с настолько старыми версиями, что их совместимость практически гарантирована. Это повышает вероятность того, что системные библиотеки пользователя будут работать с нашим приложением, снижая проблемы с зависимостями без необходимости контейнеризации или пакетирования системных компонентов и shim.

Мы рекомендуем при компоновке для старых систем найти соответствующее старое окружение Linux. Необязательно устанавливать старую версию Linux на физическое оборудование или даже настраивать полную виртуальную машину — chroot предоставляет возможность создания легковесного изолированного окружения в готовом Linux. Это позволит вам выполнять сборку для старых систем без оверхеда полной виртуализации. Забавно, что контейнеры действительно оказались подходящим решением, только не в среде исполнения, а в среде сборки.

Для этого мы используем debootstrap — превосходный скрипт, создающий с нуля минимальную установку Debian. Дистрибутив Debian особенно хорошо подходит для такого решения благодаря своей стабильности и долговременной поддержке старых релизов, обеспечивающей совместимость со старыми системными библиотеками.

Разумеется, после настройки старого Linux может выясниться, что его тулчейны двоичных пакетов слишком устарели для сборки вашего ПО. Для решения этой проблемы мы компилируем из исходников современный тулчейн LLVM и используем его для сборки и зависимостей, и нашего ПО.

Затем мы автоматизируем весь процесс debootstrap при помощи скрипта на Python.

#!/bin/env python3
import 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

def chroot(pipe):
  try:
    os.chroot('chroot')
    os.chdir('/')

    # Настройка окружения для chroot
    env = {
      'HOME': '/root',
      'TERM': 'xterm',
      'PATH': '/bin:/usr/bin:/sbin:/usr/sbin'
    }

    # Debian будет достаточно старым, поэтому ключи со связки ключей, скорее всего,
    # будут просроченными. Чтобы решить эту проблему, мы изменим sources.list так,
    # чтобы он содержал '[trusted=yes]'
    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)

    #
    # Здесь скрипт для Linux, не забудьте передать subprocess.run `env=env`.
    #
    # Мы рекомендуем скачать GCC 7.4.0, скомпилировать из исходников и установить
    # его, потому что это минимальная версия, требуемая для компиляции последнего LLVM
    # из исходников. Далее мы советуем скачать, скомпилировать из исходников
    # и установить последний LLVM (20.1.0 на момент написания статьи).
    #
    # Затем можно скомпилировать и установить при помощи этого современного
    # тулчейна LLVM все остальные пакеты исходников, требуемые вашему ПО.
    #
    # Также из этого скрипта можно войти в chroot при помощи интерактивного шелла,
    # расскомментировав следующую строку и запустив скрипт обычным образом.
    #  subprocess.run(['bash'])
    #

    # Можно отправлять сообщения родителю при помощи pipe.send()
    pipe.send('Done') # Это имеет особое значение в main
  except Exception as exception:
    pipe.send(exception)

def main():
  # Для использования 'mount', 'umount' и 'chroot' нужно запускать под рутом
  if os.geteuid() != 0:
    print('Script must be run as root')
    return False

  with multiprocessing.Manager() as manager:
    mounts = manager.list()
    pipe = multiprocessing.Pipe()
    def mount(parts):
      subprocess.run(['mount', *parts])
      mounts.append(parts[-1])

    # Проверяем, что у нас есть свежий chroot и клон debootstrap
    shutil.rmtree('chroot', ignore_errors=True)
    shutil.rmtree('debootstrap', ignore_errors=True)
    os.mkdir('chroot')

    # Клонируем debootstrap
    subprocess.run(['git', 'clone', DEBOOSTRAP])
    subprocess.run(['debootstrap', '--arch', 'amd64', VERSION, '../chroot', ARCHIVE],
                    env={**os.environ, 'DEBOOTSTRAP_DIR': '.'},
                    cwd='debootstrap')

    # Точки монтирования, необходимые для 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'])

    # Настраиваем chroot в отдельном процессе
    process = multiprocessing.Process(target=chroot, args=(pipe[1],))
    process.start()
    try:
      while True:
        data = pipe[0].recv()
        if isinstance(data, Exception):
          raise data
        else:
          print(data)
          if data == 'Done':
            break
    finally:
      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, и каждая из них напрямую влияла на наши продукты:

  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, и это одна из причин такой хорошей двоичной совместимости её приложений. Мы можем запускать написанное десятки лет назад ПО, потому что Microsoft не привязывает всё к единственной постоянно меняющейся libc.

Разумеется, не всё так просто. GLIBC имеет глубокие проблемы перекрёстного сечения, особенно с потоками, TLS (Thread-Local Storage) и управлением глобальной памятью.

Например, если бы мы смогли заставить сосуществовать две версии GLIBC, возврат распределённой памяти из одной и попытка освободить её в другой с большой вероятностью привели бы к серьёзным проблемам. Их кучи не подозревали бы о наличии друг друга, потенциально используя разные стратегии распределения и вызывая непредсказуемые сбои. Чтобы избежать этого, вероятно, понадобилось бы отделить кучу в выделенную libheap.

Мы считаем, что лучше разбить GLIBC на отдельные библиотеки, примерно так:

  • libsyscall обрабатывает выполнение системных вызовов и ничего больше. Представляется только как статическая библиотека. Используется libheaplibthread и libc для получения доступа к коду общего системного вызова. Так как она статическая, она встроена во все три. Во всём остальном можно считать, что этой библиотеки не существует.

  • libdl (динамический компоновщик) — автономный компоновщик, загружающий общие библиотеки. Статически выполняет компоновку только с libsyscall. Это действительно автономная библиотека, не зависящая ни от чего. Предоставляется и как статическая, и как динамическая библиотека. При статистической компоновке с ней всё равно можно выполнять динамическую загрузку. Просто внутри исполняемого файла будет находиться динамический компоновщик.

  • libheap — единая куча, общая для всех перечисленных ниже библиотек. Предоставляется только как динамическая. С ней невозможна статическая компоновка.

  • libthread работает с потоками и TLS, компонуется с libheap. Предоставляется только как динамическая библиотека. С ней невозможна статическая компоновка.

  • libc компонуется с libthread, а значит, транзитивно и с libheap и libdl. Предоставляется и как статическая, и как динамическая. При статической компоновке она компонует libdl статически. Компоновка libthread и libheap всегда выполняется динамически, однако через включённую libdl в случае статической компоновки или через загрузчик программы libdl в случае динамической.

Эти библиотеки знают о существовании друг друга и позволяют сосуществовать в одном адресном пространстве множественным версиям. Благодаря этому не возникает хаос, при котором апгрейд GLIBC может всё поломать. Структура должна выглядеть примерно так

Эта архитектура очень похожа на Windows, в которой эквиваленты libsyscalllibdllibheap и libthread объединены в одну kernel32.dll. В Windows эта DLL автоматически загружается в адресное пространство каждого исполняемого файла.

Статически компонуемая libc

  • Приложение статически компонует libc и libdl (это не загрузчик программы).

  • Приложение приступает к выполнению и динамически компонует libheap и libthread при помощи встроенной libdl.

[Приложение]
   │
   ▼
[libc (статическая)]
   │
   ▼
[libdl (статическая)]
   ├── [libheap (динамическая)]
   └── [libthread (динамическая)]
          └── [libheap (динамическая)]
  • libc и libdl встроены в исполняемый файл, то есть выполнение запускает само приложение.

  • Встроенная libdl динамически загружает libthread и libheap.

Динамически компонуемая libc

  • Приложение запускает выполнение через интерпретатор программы (libdl).

  • libdl (загрузчик программы) загружает приложение и выполняет ресолвинг зависимостей.

  • Приложение динамически компонует libclibheap и libthread.

[Приложение (точка входа интерпретатора)]
   │
   ▼
[libdl (загрузчик программы)]
   │
   ▼
[libc (динамическая)]
   ├── [libheap (динамическая)]
   ├── [libthread (динамическая)]
   │      └── [libheap (динамическая)]
   ▼
[Приложение (обычная точка входа)]

Сравнительная таблица

Сценарий

libdl (способ включения)

libc (способ загрузки)

libthread (через libdl)

libheap (через libdl)

Статическая libc

Статическая компоновка

Статическая компоновка

Компонуется libdl

Компонуется libdl

Динамическая libc

Интерпретатор программы

Компонуется 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* всегда требует поля версии или указателя на vtable, содержащую реализацию fclose (и других функций), а все версии libc согласованы в этом, поэтому она всегда может вызывать правильную функцию через эту границу ABI. Это решит проблему совместимости; но теперь представим, что наш код вызывает fflush.

// Определяется в заголовке <stdio.h>
int fflush(FILE *fp);

Только она не сбрасывает файл, а передаёт NULL.

fflush(NULL);

На случай, если вы не знакомы с функцией fflush языка C, скажу, что передача ей NULL требует сброса всех открытых файлов (каждого FILE*). Однако в этой ситуации она сбросит только файлы, видимые версии libc, с которой компоновалось ваше приложение, но не те, которые были открыты другими версиями libc (например, как тот, который был открыт open_thing).

Чтобы обработать ситуацию корректно, каждой версии libc понадобится способ перечисления файлов во всех остальных экземплярах libc, включая динамически загруженные, и чтобы при этом каждый файл посещался только один раз без образования циклов. Кроме того, такое перечисление должно быть потокобезопасным. К тому же, в процессе выполнения перечисления в отдельном потоке может динамически загрузиться ещё одна libc (например, через dlopen) или быть открыт новый файл (например, глобальный конструктор в динамически загруженной библиотеке, вызывающей fopen).

Этот глобальный список элементов, которыми владеет libc, встречается во множестве мест. Возьмём для примера такой код:

// Определяется в заголовке <stdlib.h>
int atexit(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

Определения Math должны иметь стабильное множество целочисленных значений для всех 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

Определения Atomic должны иметь стабильное множество целочисленных значений для всех 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 добавляют сложность; в частности, это глобальные и локальные для потоков элементы наподобие errno и locale. Однако при создании тщательно продуманной архитектуры можно эффективно решать эти трудности.

Ещё одну сложность представляют функции распределения памяти из <stdlib.h> (callocmallocaligned_allocrealloc и free). Так как они могут возвращать любой указатель, их отслеживание оказывается нетривиальной задачей. Одно из решений заключается в хранении указателя на vtable в заголовке распределения, что позволит каждому распределению ссылаться на свою реализацию. Однако такая методика влечёт за собой существенный оверхед производительности и памяти. Вместо ней мы предлагаем централизовать управление кучей в выделенной libheap. Она также будет содержать реализацию расширений POSIX наподобие posix_memalign.

При переходе от стандартного C к POSIX всё становится ещё интереснее: появляются уникальные проблемы, требующие поддержки libc. Часть этой функциональности, вероятно, лучше выделить в отдельные библиотеки (например, зачем в libc нужен DNS-ресолвер?). Однако среди этих трудностей особо выделяется setxid.

Дело в том, что разрешения в POSIX, например реальные, эффективные и сохраняемые ID пользователей/групп, применяются на уровне процессов. Однако Linux обращается с потоками как с независимыми процессами, использующими общую память; то есть эти разрешения обрабатываются для каждого потока, а не процесса. Для соответствия семантике POSIX libc должна прерывать каждый поток, заставляя его выполнять код, совершающий системный вызов для изменения его разрешения, локальные относительно потока. Это необходимо делать атомарно, без сбоев и в то же время с сохранением безопасности асинхронных сигналов. Реализация этого была бы настоящим кошмаром и серьёзной трудностью. Что ещё более важно, правильная реализации критична для безопасности.

В конечном итоге это означает, что libc должна отслеживать каждый поток и предоставлять возможность синхронно выполнять код во всех потоках. Для решения этой задачи мы предлагаем консолидировать работу с потоками, TLS и необходимые механизмы согласования с POSIX в единую libthread.

Есть множество других сложностей, которые мы не рассматривали, а также множество альтернативных возможностей реализации. Главное здесь то, что эти проблемы решаемы, только они требуют существенных изменений архитектуры. Для этого придётся с нуля переосмыслить данный аспект пользовательского пространства Linux, взяв за основной архитектурный принцип двоичную совместимость. Разработчики GLIBC никогда не пытались это сделать всерьёз. Пока кто-нибудь не решит, что с него достаточно, и не примется за устранение проблемы, двоичная совместимость в Linux останется нерешённой проблемой; а мы уверены, что эта проблема стоит того, чтобы её решать.