Один из разделов статьи Overview of cross-architecture portability problems я посвятил проблемам, возникающим из-за использования 32-битного типа time_t. Это архитектурное решение, до сих пор влияющее на использующие glibc системы с Gentoo, приведёт к тому, что у 32-битных приложений в 2038 году начнут возникать ужасные сбои: они будут получать ошибку -1 вместо текущего времени и не смогут выполнять stat() файлов. Одним словом, возникнет полный хаос.
Считается, что решением будет переход на 64-битный тип time_t. Musl уже перешёл на него, а glibc поддерживает его как опцию. Многие другие дистрибутивы, например Debian, совершили этот переход. К сожалению, дистрибутивам на основе исходников, например Gentoo, сделать это не так просто. Поэтому мы по-прежнему обсуждаем эту проблему и экспериментируем, пытаясь понять, как пользователи максимально безопасно могли бы выполнить апгрейд.
К сожалению, это совершенно нетривиально. Во-первых, мы говорим о переломном изменении ABI — ситуация «всё или ничего». Если в API библиотеки используется time_t, то всё связанное с ней должно использовать ту же ширину типа. В этом посте я бы хотел подробно рассмотреть данный вопрос: почему это плохо и что мы можем сделать, чтобы повысить безопасность.
Вернёмся к поддержке больших файлов
Прежде чем мы перейдём к time64, как я вкратце буду его называть, нам нужно вернуться немного назад и поговорить о похожей проблеме: поддержке больших файлов (Large File Support).
Если вкратце, изначально 32-битные архитектуры определяли два важных связанных с файлами типа, имевших ширину 32 бита: off_t использовался для задания смещения файлов (со знаком для поддержки относительных смещений) и ino_t для задания номеров inode. Это имело два последствия: невозможность открытия файлов размером более 2 ГиБ и невозможность открытия файлов, номера inode которых превосходили диапазон беззнакового 32-битного integer.
Чтобы решить эту проблему, была предложена поддержка больших файлов. Она заключалась в замене этих двух типов их 64-битными вариантами; в glibc она по-прежнему остаётся опциональной. В этом случае мы не сделали скачок и не совершили всемирный переход. Пакеты начали обеспечивать поддержку LFS апстрим, одновременно беря на себя устранение любых поломок ABI в этом процессе. Хотя это сделали многие пакеты, проблему нельзя считать решённой.
Важно здесь то, что для поддержки time64 в glibc требуется использование LFS. Это логично — если уж мы будем ломать всё, то можем и решить сразу две проблемы.
О каких ABI мы говорим?
Если говорить простыми словами, здесь у нас есть три возможные под-ABI:
Исходный ABI с 32-битными типами,
LFS: 64-битные off_t и ino_t, 32-битный time_t,
time64: LFS + 64-битный time_t.
Важно здесь то, что единичная сборка glibc остаётся совместимой со всеми тремя вариантами. Однако несовместимыми становятся библиотеки, в API которых используются эти типы.
Сегодня 32-битные системы по большей мере используют смесь первого и второго ABI — последние содержат в себе пакеты, включившие LFS явным образом. Для дальнейшего развития мы должны сосредоточиться на третьем варианте. Нас не волнует обеспечение 32-битного time_t системам с полным LFS.
Почему изменение ABI — это плохо?
Проблема в том, что мы заменяем 32-битный тип 64-битным. В отличие от случая с LFS, glibc не предоставляет никакого переходного API, который можно было бы использовать для включения новых функций с сохранением обратной совместимости: возникает ситуация «всё или ничего».
Рассмотрим структуры. Если структура содержит time_t с его естественным 32-битным размещением, то свободного места для расширения типа не существует. Все поля неизбежно придётся смещать, чтобы освободить место для нового типа. Приведём тривиальный пример:
struct {
int a;
time_t b;
int c;
};
При 32-битном time_t смещение c равно 8. При 64-битном типе оно равно 16. Если перемешать двоичные файлы с разной шириной time_t, то они неизбежно прочитают или запишут не те поля! Или даже выполнят чтение или запись out of bounds!
Давайте просто взглянем на размер struct stat как примера структуры, использующей типы, связанные и с файлами, и со временем. В чисто 32-битном glibc x86 она имеет длину 88 байтов. С LFS её длина составляет 96 байтов (расширены поля размера и номера inode). С LFS + time64 её длина 108 байтов (расширены три метки времени).
Однако нам даже не нужно использовать структуры. Ведь мы говорим о x86, где параметры функции передаются в стек. Если один из параметров — это time_t, то позиции всех параметров в стеке меняются, и мы сталкиваемся с той же проблемой! Рассмотрим следующий прототип:
extern void foo(int a, time_t b, int c);
Допустим, мы вызываем его как foo(1, 2, 3). С 32-битными типами вызов выглядит так:
pushl $3
pushl $2
pushl $1
call foo@PLT
Однако с 64-битным time_t он становится таким:
pushl $3
pushl $0
pushl $2
pushl $1
call foo@PLT
Между «старыми» b и c вставляется 32-битное значение (ноль). Повторюсь, если мы смешаем оба типа двоичных файлов, то им не удастся правильно считывать параметры!
Так что да, это серьёзная проблема. И пока не существует реальной защиты, предотвращающей смешение этих ABI. Так что можно получить поломку в среде исполнения, потенциально даже создающую проблемы с безопасностью.
Можете не верить мне на слово и воспроизвести это самостоятельно на x86/amd64. Возьмём наиболее вероятный случай: программа с time32, компонуемая с библиотекой, пересобранной под time64:
$ cat >libfoo.c <<EOF
#include <stdio.h>
#include <time.h>
void foo(int a, time_t b, int *c) {
printf("a = %d\n", a);
printf("b = %lld", (long long) b);
printf("%s", ctime(&b));
printf("c = %d\n", *c);
}
EOF
$ cat >foo.c <<EOF
#include <stddef.h>
#include <time.h>
extern void foo(int a, time_t b, int *c);
int main() {
int three = 3;
foo(1, time(NULL), &three);
return 0;
}
EOF
$ cc -m32 libfoo.c -shared -o libfoo.so
$ cc -m32 foo.c -o foo -Wl,-rpath,. libfoo.so
$ ./foo
a = 1
b = 1727154919
Tue Sep 24 07:15:19 2024
c = 3
$ cc -m32 -D_FILE_OFFSET_BITS=64 -D_TIME_BITS=64 \
libfoo.c -shared -o libfoo.so
$ ./foo
a = 1
b = -34556652301432063
Thu Jul 20 06:16:17 -1095054749
c = 771539841
Кроме того, эти проблемы усугубляет упор Gentoo на исходники. Обычно двоичный дистрибутив пересобирает все двоичные пакеты, поэтому пользователь апгрейдит систему единым, относительно атомарным способом. Да, если кто-то использует сторонние репозитории или локально собрал программы, ссылающиеся на системные библиотеки, то могут возникнуть проблемы, но процесс относительно прост.
С другой стороны, в Gentoo рассматривается пересборка @world с поломкой ABI в отдельных местах. Во-первых, мы говорим о длительных периодах времени между пересборкой двух пакетов, когда они действительно могут смешивать несовместимые ABI. Во-вторых, есть умеренный риск того, что какая-то пересборка завершится неудачно, и ваша система выполнит переход наполовину, а решить это простым способом не удастся. Кроме того, есть реальный риск того, что циклические зависимости сделают пересборку невозможной — пересборка зависимости поломает инструменты этапа сборки, что помешает пересборке. Вот это настоящий кошмар.
Что можно сделать, чтобы повысить безопасность?
В основном мы сейчас думаем над тремя идеями, которые слабо связаны друг с другом, но неизбежно будут зависеть одна от другой:
Смена кортежа платформы (CHOST) для новых ABI, чтобы чётко отличать их от базового 32-битного ABI.
Изменение libdir для новых ABI, что, по сути, позволит устанавливать пересобранные библиотеки независимо от исходных версий.
Добавление различения ABI на двоичном уровне, чтобы двоичные файлы, использующие разные под-ABI, не могли компоноваться друг с другом.
Ниже мы подробно рассмотрим каждое из этих изменений. Стоит отметить, что все использованные здесь значения — это просто примеры, они необязательно будут присутствовать в готовом решении.
Смена кортежа платформы
Кортеж платформы (на который обычно ссылаются через переменную CHOST) идентифицирует платформу, которая будет целевой для тулчейна. Например, он используется как часть путей установки GCC/binutils, по сути, допуская одновременную установку тулчейнов для нескольких целевых платформ. В clang его можно использовать для переключения между поддерживаемыми целевыми платформами кросскомпиляции и для управления стандартными значениями, чтобы соответствовать заданному ABI. В Gentoo он также используется для уникальной идентификации ABI с целью поддержки multilib. Поэтому мы требуем, чтобы никакие два ABI, которые можно установить одновременно, не имели одного кортежа.
Кортеж состоит из четырёх частей, разделённых дефисами: архитектура, поставщик, операционная система и libc. Значение вендора может быть произвольным, но остальные три значения в определённой степени ограничены. Вот некоторые из частично эквивалентных кортежей, используемых для 32-битной платформы x86:
i386-pc-linux-gnu
i686-pc-linux-gnu
i686-unknown-linux-gnu
В прошлом для внедрения новых ABI использовались два подхода. Или менялось поле вендора, или в поле libc добавлялась дополнительная спецификация ABI. Например, раньше Gentoo использовал два разных вида кортежей для ABI ARM с аппаратным модулем обработки чисел с плавающей запятой:
armv7a-hardfloat-linux-gnueabi
armv7a-unknown-linux-gnueabihf
Первый подход использовался, чтобы избежать проблем несовместимости, вызванных изменением других полей кортежа. Однако с их устранением и нормализацией апстрима на втором решении Gentoo начал использовать его.
При обсуждении ABI с time64 на поверхность всплыла та же дилемма: должны ли мы просто «ненадлежащим образом» использовать для этого поле поставщика или изменить поле libc и исправить пакеты? Основное различие заключается в том, что первый вариант «чище», потому что даунстрим-решение ограничено дистрибутивом Gentoo, а при втором обычно возникают вопросы взаимодействия. Следовательно, варианты выглядят так:
i686-gentoo_t64-linux-gnu
i686-pc-linux-gnut64
armv7a-gentoo_t64-linux-gnueabihf
armv7a-unknown-linux-gnueabihft64
К счастью, изменение кортежа не должно потребовать особо много патчинга. Тулчейн GNU и система сборки GNU игнорируют всё, что идёт после «gnu» в поле libc. Clang потребует патчинга, но апстрим с большой вероятностью примет наши патчи, а нам они всё равно понадобятся, ведь патчи позволят clang автоматически выбирать нужный ABI на основании кортежа.
Изменение libdir
Термин «libdir» означает базовое имя каталога установки библиотеки. Благодаря наличию разных libdir, а значит, и отдельных каталогов установки библиотек, можно собирать multilib-системы, то есть устанавливать в одной системе различные вариации ABI библиотек, позволяя выполнять исполняемые файлы для разных ABI. Например, это позволит запускать 32-битные исполняемые файлы x86 на системах amd64.
Значения libdir в общем случае определяются в ABI. Естественно, базовое значение — это обычная lib. Исторически сложилось (так как 32-битные архитектуры появились раньше), что обычно 32-битные платформы (arm, ppc, x86) используют lib, а более современные 64-битные (amd64, arm64, ppc64) используют lib64, даже если конкретная архитектура никогда не поддерживала multilib в Gentoo.
Архитектуры, поддерживающие множественные ABI, также определяют различные libdir. Например, дополнительная ABI x32 в x86 использует libx32. ABI MIPS n32 использует lib32 (а базовая lib определяет ABI o32).
Сейчас мы обдумываем замену значения libdir на вариации 32-битных ABI для time64, например, с lib на libt64. Это позволит устанавливать пересобранные библиотеки отдельно от старых библиотек, обеспечивая следующие преимущества:
Снижение риска того, что исполняемые time64 случайно будут скомпонованы с библиотеками time32,
Обеспечение возможности для функции Portage preserved-libs сохранения библиотек time32 после того, как соответствующие пакеты будут пересобраны для time64, и до того, как будут пересобраны их обратные зависимости,
Опциональная возможность использования профилей multilib time32 + time64, что можно будет использовать для сохранения совместимости с пересобранными приложениями time32, компонуемыми с системными библиотеками.
На мой взгляд, второй пункт — это огромное преимущество. Как говорилось выше, мы обсуждали такую миграцию, которая в течение долгого срока будет ломать исполняемые файлы в продакшен-системах и потенциально поломает инструменты этапа сборки, мешая дальнейшей пересборке. Сохранив исходные библиотеки, мы минимизируем риск реальной поломки, потому что имеющиеся исполняемые файлы будут продолжать использовать библиотеки time32, пока их не пересоберут и не скомпонуют с библиотеками time64.
Смена libdir определённо потребует патчинга тулчейна. Возможно, мы также рассмотрим возможность применения glibc для особых случаев, так как одно и то же множество библиотек glibc валидно для всех обсуждавшихся нами под-ABI. Однако, вероятно, нам понадобится отдельный исполняемый файл ld.so, так как ему потребуется загружать библиотеки из правильной libdir, а затем нам нужно будет настроить .interp в исполняемых файлах time64, чтобы они ссылались на ld.so time64.
Стоит отметить, что из-за структуры multilib в Gentoo для правильной поддержки multilib требуется и уникальный кортеж платформы для ABI, так что этот конкретный аспект зависит от изменения кортежа.
Обеспечение двоичной несовместимости
В общем случае не допускается смешение двоичных файлов, использующих разные ABI. Например, если попытаться скомпоновать 64-битную программу с 32-битной библиотекой, то компоновщик запротестует:
$ cc foo.c libfoo.so
/usr/lib/gcc/x86_64-pc-linux-gnu/14/../../../../x86_64-pc-linux-gnu/bin/ld: libfoo.so: error adding symbols: file in wrong format
collect2: error: ld returned 1 exit status
Аналогично, динамический компоновщик откажется использовать 32-битную библиотеку с 64-битной программой:
$ ./foo
./foo: error while loading shared libraries: libfoo.so: wrong ELF class: ELFCLASS32
Для этого используется несколько механизмов. Как показано выше, архитектуры с 32-битными и 64-битными ABI используют два отдельных класса ELF (ELFCLASS32 и ELFCLASS64). Кроме того, некоторые архитектуры используют разные идентификаторы машин (EM_386 или EM_X86_64, EM_PPC или EM_PPC64). 32-битный ABI на x86 «злоупотребляет» этим, объявляя свои двоичные файлы как ELFCLASS32 + EM_X86_64 (таким образом отделяя их от ELFCLASS32 + EM_386 и от ELFCLASS64 + EM_X86_64).
И ARM, и MIPS используют поле флагов (это битовое поле с флагами архитектуры), чтобы различать разные ABI (hardfloat и softfloat, n32 ABI на MIPS…). Также оба имеют специальную секцию атрибутов, и компоновщик отказывается компоновать несовместимые объектные файлы.
Возможно, будет желательно реализовать схожий механизм для систем time32 и time64. К сожалению, это нетривиальная задача. Похоже, для этого не существует универсального механизма. Кроме того, нам нужно решение, охватывающее достаточное количество различных архитектур. Думается, самое разумное решение сейчас — это добавление новой секции примечаний ELF специально для этой функции и реализация её полной поддержки тулчейном.
Однако, что бы мы ни решили сделать, нужно учитывать то, что пользователь может захотеть отключить это. В частности, для приличного количества уже собранного ПО исходники недоступны, и оно может продолжать корректно работать с системными библиотеками, при условии, что не будет вызывать API, использующие time_t. Лекарство, безальтернативно не позволяющее им работать, может оказаться хуже болезни.
С другой стороны, можно без особого хакинга создать нефатальную QA-проверку этого при условии, если мы будем использовать отдельные libdir. Мы можем опознавать исполняемые файлы time64 по их секции .interp, указывающей на динамический загрузчик в соответствующей libdir, а затем проверять, что программы time32 не будут загружать никаких библиотек из libt64, и что программы time64 не будут загружать никаких библиотек напрямую из lib.
А что насчёт старых собранных приложений?
Пока мы говорили только о пакетах, собираемых из исходников. Однако всё ещё существует приличное количество старых приложений, обычно проприетарных, доступных только в виде уже собранных двоичных файлов, в частности для архитектур x86 и PowerPC. Такие пакеты столкнутся с двумя проблемами: во-первых, с проблемами совместимости с системными библиотеками, во-вторых, с самой проблемой y2k38.
Что касается проблемы совместимости, то у нас уже есть достаточно хорошее решение. Так как нам уже нужно обеспечивать их работу на amd64, у нас есть готовая структура multilib, а также необходимые механизмы для сборки различных версий библиотек. На самом деле, учитывая то, что основная цель multilib — это совместимость со старым ПО, непонятно, есть ли вообще смысл выполнять переход multilib amd64 на использование time64 для 32-битных двоичных файлов. Как бы то ни было, мы с лёгкостью можем дополнить наши механизмы multilib так, чтобы они отличали обычную целевую платформу abi_x86_32 от abi_x86_t64 (и, вероятно, нам всё равно нужно это сделать), а затем создать новые профили x86 multilib, которые будут поддерживать оба ABI.
Со второй частью гораздо сложнее. Очевидно, как только мы минуем точку y2k38, все 32-битные программы, как использующие системные библиотеки, так и не использующие, просто начнут ломаться ужасным образом. Один из вариантов решения — применение faketime для управления системными часами. Другой — запуск перемещённой в прошлое VM.
Заключение
С наступлением 2038 года 32-битные приложения, использующие 32-битный time_t, перестанут работать. Сейчас очевидно, что единственный способ двигаться дальше — это пересборка этих приложений с 64-битным time_t (и заодно внедрение LFS). К сожалению, это нетривиальная задача, потому что она требует изменений в ABI, а смешение программ и библиотек с time32 и time64 может привести к ужасным багам среды исполнения.
Пока мы не определились с конкретными подробностями, но предложенные изменения связаны с тремя идеями, которые в определённой степени можно реализовать по отдельности: смена кортежа платформы (CHOST), смена libdir и предотвращение случайного смешения двоичных файлов с time32 и time64.
Смена кортежа — это по большей мере формальный способ различить сборки для обычного ABI с time32 (например, i686-pc-linux-gnu) от тех, целевой платформой для которых является конкретно time64 (например, i686-pc-linux-gnut64). Выполнить это будет относительно безопасно и просто, с минимальным объёмом исправлений. Например, будет необходимо обновить clang, чтобы он принимал новые кортежи.
Из этих изменений самым важным, вероятно, станет смена libdir, поскольку благодаря функции Portage preserved-libs она обеспечит возможность перехода без поломок. Если вкратце, то библиотеки time64 будут устанавливаться в новый libdir (e.g. libt64), а исходные библиотеки с time32 останутся в lib, пока пересобираются использующие их приложения. К сожалению, реализовать это будет чуть сложнее — для этого потребуются изменения в тулчейне и обеспечение корректного соблюдения libdir всем ПО. Дополнительная сложность заключается в том, что если внести только это изменение, то динамический загрузчик не будет игнорировать библиотеки с time32, если, например, где-то будет инъецирован -Wl,-rpath,/usr/lib.
Также достаточно важен и сложен вопрос несовместимости. В идеале нам бы хотелось, чтобы компоновщик не пытался случайно компоновать библиотеки с time32 с программами с time64, а динамический загрузчик не пытался их загрузить. К сожалению, пока мы не смогли придумать реалистичный способ сделать это, не требующий внесения серьёзных изменений в тулчейн. С другой стороны, должно быть не так сложно писать QA-проверки для выявления случайного смешения на этапе сборки.
Все эти три идеи должны позволить нам обеспечить чистый и относительно безопасный переход для 32-битных систем Gentoo, использующих glibc. Однако они решают только проблемы пакетов, собранных из исходников. Таким способом нельзя устранить проблемы уже собранных 32-битных приложений, в частности, проприетарного ПО наподобие старых игр. И даже если связанные с time64 изменения не поломают их, сломав совместимость ABI с системными библиотеками, то это сделает 2038 год. Увы, здесь, похоже, нет хорошего решения, за исключением того или иного способа имитации системного времени.
Разумеется, всё это лишь приблизительный черновик. После экспериментов, обсуждений и выпуска патчей многое может измениться.
Благодарности
Я бы хотел поблагодарить за вычитку и рекомендации, а также за труд по реализации поддержки time64 в Gentoo Арсена Арсеновича, Андреаса Хуттела, Сэма Джеймса и Александра Монакова.
Исправление на 30 сентября 2024 года
К сожалению, изложенные в статье идеи были слишком оптимистичными. Я совершенно упустил из виду то, что все libdir перечислены в ld.so.conf, поэтому мы не можем полагаться на прописывание пути libdir внутри ld.so itself. Оглядываясь назад, я думаю, что должен быть это предвидеть, ведь мы уже модифицировали эти пути для специального префикса LLVM, который тоже требовал особой обработки.
По сути, это означает, что смена libdir, вероятно, должна зависеть от пункта про несовместимость двоичных файлов. В целом, нам нужно достичь трёх основных целей:
Динамический загрузчик должен иметь возможность различать двоичные файлы с time32 и time64. Для программ с time32 он должен загружать только библиотеки с time32; для программ с time64 он должен загружать только библиотеки с time64. В обоих случаях мы должны предполагать, что оба типа библиотек будут находиться по пути.
Для обратной совместимости мы должны предполагать, что все двоичные файлы, не имеющие явной маркировки time64, используют time32.
Поэтому все новые собираемые двоичные файлы должны иметь явную маркировку time64. В том числе и двоичные файлы, собранные не в средах C, например в Rust, даже если они никак не взаимодействуют с ABI time_t. В противном случае, эти двоичные файлы будут постоянно зависеть от библиотек time32.
Достижение всех этих целей потребует больших усилий. Похоже, ни одного из рассмотренных нами хаков будет недостаточно для этого, поэтому, вероятно, потребуется объём усилий, сравнимый с патчингом множества тулчейнов для различных языков программирования. Естественно, мы не сможем это сделать локально в Gentoo, поэтому это потребует совместной работы различных проектов. И всё это нужно для архитектур, которые в большинстве своём считаются легаси, а иногда и вообще не поддерживаются.
Разумеется, ещё один вопрос заключается в том, действительно ли эти другие тулчейны будут создавать корректные исполняемые файлы с time64. В конце концов, если они не адаптированы специально для соответствия _TIME_BITS так, как это делают программы на C, они, вероятно, будут задавать конкретную ширину time_t и ломаться при её изменении. Однако это уже проблема для решения в апстриме, косвенно затрагивающая обсуждаемые здесь проблемы.
Кроме того, возникнет серьёзная несовместимость. Все двоичные файлы, не маркированные явным образом как time64, будут использовать библиотеки с time32, даже они если используют ABI time64. Gentoo не сможет запускать сторонние исполняемые файлы, если только они не пропатчены правильной маркировкой.
Возможно, более оптимальным решением станет занижение наших требований. Вместо того, чтобы различать двоичные файлы с time32 и time64, мы можем инъецировать RPATH во все исполняемые файлы с time64, напрямую задав в них libdir с time64. Конечно, это не помешает динамическому загрузчику использовать библиотеки time32, но поможет выполнить переход без серьёзных проблем с безопасностью.
Или же мы можем подойти к проблеме с другой стороны. Вместо того, чтобы навсегда менять libdir для библиотек с time64, мы можем изменить его временно для библиотек с time32. Для этого понадобится инъецировать RPATH во все существующие программы и переименовать libdir. Новособранные библиотеки с time64 будут устанавливаться обратно в старый libdir, а у новособранных программ с time64 не будет RPATH, заставляющего использовать библиотеки с time32. Очевидное преимущество такого решения в том, что оно останется полностью совместимым с другими дистрибутивами, уже совершившими этот переход.
Как видите, ситуация стремительно развивается. Каждый день приносит новые проблемы и новые идеи для их решения.