[Перевод] Как размеры массивов C стали частью двоичного интерфейса библиотеки
Большинство компиляторов C позволяют получить доступ к массиву extern
с неопределёнными границами, например:
extern int external_array[];
int
array_get (long int index)
{
return external_array[index];
}
Определение external_array может находиться в другой единице трансляции и выглядеть так:
int external_array[3] = { 1, 2, 3 };
Вопрос в том, что произойдет, если это отдельное определение изменится так:
int external_array[4] = { 1, 2, 3, 4 };
Или так:
int external_array[2] = { 1, 2 };
Сохранится ли двоичный интерфейс (при условии, что существует механизм, позволяющий приложению определять размер массива во время выполнения)?
Любопытно, что на многих архитектурах увеличение размера массива нарушает совместимость двоичного интерфейса (ABI). Уменьшение размера массива также может вызвать проблемы совместимости. В этой статье мы более подробно рассмотрим совместимость ABI и объясним, как избежать проблем.
Чтобы понять, как размер массива становится частью двоичного интерфейса, нам сначала нужно изучить ссылки в разделе данных исполняемого файла. Конечно, детали зависят от конкретной архитектуры, и здесь мы сосредоточимся на архитектуре x86–64.
Архитектура x86–64 поддерживает адресацию относительно счётчика программы, то есть доступ к переменной глобального массива, как в показанной ранее функции array_get
, можно скомпилировать в одну инструкцию movl
:
array_get:
movl external_array(,%rdi,4), %eax
ret
Из этого ассемблер создаёт объектный файл, в котором инструкция помечена как R_X86_64_32S
.
0000000000000000 :
0: mov 0x0(,%rdi,4),%eax
3: R_X86_64_32S external_array
7: retq
Такое перемещение указывает компоновщику (ld
), чем заполнить соответствующее расположение переменной external_array
во время компоновки при создании исполняемого файла.
У этого два важных последствия.
- Поскольку смещение переменной определяется во время компоновки, во время выполнения нет накладных расходов на его определение. Единственная цена — сам доступ к памяти.
- Для определения смещения необходимо знать размеры всех переменных данных. В противном случае было бы невозможно вычислить формат раздела данных во время компоновки.
Для реализаций C, ориентированных на Executable and Link Format (ELF), как в GNU/Linux, ссылки на переменные extern
не содержат размеров объектов. В примере array_get
размер объекта неизвестен даже компилятору. Фактически, весь файл с ассемблером выглядит так (опуская только информацию о раскрутке с -fno-asynchronous-unwind-tables
, которая технически требуется для соответствия psABI):
.file "get.c"
.text
.p2align 4,,15
.globl array_get
.type array_get, @function
array_get:
movl external_array(,%rdi,4), %eax
ret
.size array_get, .-array_get
.ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)"
.section .note.GNU-stack,"",@progbits
В этом файле ассемблера вообще нет информации о размере для external_array
: единственная ссылка на символ находится в строке с инструкцией movl
, а единственные числовые данные в инструкции — размер элемента массива (подразумеваемый movl
с умножением на 4).
Если ELF требуются размеры для неопределённых переменных, то будет даже невозможно скомпилировать функцию array_get
.
Как компоновщик получает фактический размер символа? Он смотрит на определение символа и использует информацию о размере, которую находит там. Это позволяет компилятору вычислить макет раздела данных и заполнить перемещения данных соответствующими смещениями.
Реализации C для ELF не требуют от программиста добавлять разметку исходного кода, чтобы указать, находится функция или переменная в текущем объекте (который может быть библиотекой или основным исполняемым файлом) или в другом объекте. Об этом позаботятся компоновщик и динамический загрузчик.
В то же время для исполняемых файлов было желание не снижать производительность путём изменения модели компиляции. Это означает, что при компиляции исходного кода для основной программы (тто есть без -fPIC
, а в данном конкретном случае и без -fPIE
) функция array_get
компилируется в точно такую же последовательность команд, перед введением динамических общих объектов. Кроме того, не имеет значения, определена ли переменная external_array
в самом основном исполняемом файле или какой-либо общий объект загружается отдельно во время выполнения. Инструкции, созданные компилятором, одинаковы в обоих случаях.
Как это возможно? В конце концов, общие объекты ELF не зависят от позиции. Они загружаются по непредсказуемым, рандомизированным адресам во время выполнения. Тем не менее, компилятор генерирует последовательность машинного кода, которая требует, чтобы эти переменные располагались с фиксированным смещением, вычисленным во время компоновки, задолго до запуска программы.
Дело в том, что эти фиксированные смещения использует только один загруженный объект (основной исполняемый файл). Все остальные объекты (сам динамический загрузчик, библиотека рантайма C и любая другая библиотека, используемая программой) компилируются и компонуются как объекты, полностью независимые от позиции (PIC). Для таких объектов компилятор загружает фактический адрес каждой переменной из таблицы глобальных смещений (GOT). Мы можем увидеть этот окольный путь, если скомпилируем пример array_get
с -fPIC
, что приведёт к такому ассемблерному коду:
array_get:
movq external_array@GOTPCREL(%rip), %rax
movl (%rax,%rdi,4), %eax
ret
В результате адрес переменной external_array
больше не является жёстко закодированным и может быть изменён во время выполнения путём соответствующей инициализации записи GOT. Это означает, что во время выполнения определение external_array
может находиться в том же общем объекте, другом общем объекте или основной программе. Динамический загрузчик найдёт соответствующее определение на основе правил поиска символов ELF и свяжет неопределённую ссылку на символ с его определением, обновив запись GOT на его фактический адрес.
Вернёмся к исходному примеру, где функция array_get
находится в основной программе, поэтому адрес переменной указан напрямую. Ключевая идея, реализованная в компоновщике, заключается в том, что основная программа предоставит определение переменной external_array
, даже если она фактически определена в общем объекте во время выполнения. Вместо указания на исходное определение переменной в общем объекте, динамический загрузчик выберет копию переменной в разделе данных исполняемого файла.
Это имеет два важных последствия. Прежде всего, напомним, что external_array
определяется так:
int external_array[3] = { 1, 2, 3 };
Здесь есть инициализатор, который должен применяться к определению в основном исполняемом файле. Для этого в основном исполняемом файле помещается ссылка на перемещённую копию (copy relocation) символа. Команда readelf -rW
показывает её как перемещение R_X86_64_COPY
.
Relocation section '.rela.dyn' at offset 0x408 contains 3 entries: Offset Info Type Symbol's Value Symbol's Name + Addend 0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0 0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0 0000000000404020 0000000300000005 R_X86_64_COPY 0000000000404020 external_array + 0
Как и другие перемещения, перемещение копии обрабатывается динамическим загрузчиком. Он включает в себя простую, поразрядную операцию копирования. Целевой объект копии определяется смещением перемещения (0000000000404020
в примере). Источник определяется во время выполнения на основе имени символа (external_array
) и его значения. При создании копии динамический загрузчик также будет смотреть на размер символа, чтобы получить количество байт, которые необходимо скопировать. Чтобы всё это стало возможным, символ external_array
автоматически экспортируется из исполняемого файла как определённый символ, чтобы он был виден динамическому загрузчику во время выполнения. Таблица динамических символов (.dynsym
) отражает это, как показано командой readelf -sW
:
Symbol table '.dynsym' contains 4 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2) 2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 3: 0000000000404020 12 OBJECT GLOBAL DEFAULT 22 external_array
Откуда берется информация о размере объекта (12 байт, в этом примере)? Компоновщик открывает все общие объекты, ищет его определение и берёт информацию о размере. Как и раньше, это позволяет компоновщику вычислить макет раздела данных, чтобы можно было использовать фиксированные смещения. Опять же, размер определения в основном исполняемом файле фиксирован и не может изменяться во время выполнения.
Динамический компоновщик также перенаправляет символьные ссылки в общих объектах на перемещённую копию в основном исполняемом файле. Это гарантирует, что во всей программе существует только одна копия переменной, как того требует семантика языка C. В противном случае, если переменная изменяется после инициализации, обновления из основного исполняемого файла не будут видны динамическим общим объектам и наоборот.
Что произойдёт, если мы изменим определение external_array
в общем объекте, не связывая (или перекомпилируя) основную программу? Сначала рассмотрим добавление элемента массива.
int external_array[4] = { 1, 2, 3, 4 };
Это выдаст предупреждение от динамического загрузчика в рантайме:
main-program: Symbol `external_array' has different size in shared object, consider re-linking
Основная программа по-прежнему содержит определение external_array
с пространством только для 12 байт. Это означает, что копия является неполной: копируются только первые три элемента массива. В результате доступ к элементу массива extern_array[3]
не определён. Этот подход влияет не только на основную программу, но и на весь код в процессе, потому что все ссылки на extern_array
были перенаправлены на определение в основной программе. Это включает в себя общий объект, который предоставляет определение extern_array
. Вероятно, он не готов встретить ситуацию, когда исчез элемент массива в своём собственном определении.
Как насчёт изменения в противоположном направлении, удаления элемента?
int external_array[2] = { 1, 2 };
Если программа избегает доступа к элементу массива extern_array[2]
, поскольку она каким-то образом обнаруживает уменьшенную длину массива, то это будет работать. После массива есть немного неиспользуемой памяти, но это не сломает программу.
Это означает, что мы получаем следующее правило:
- Добавление элементов в переменную глобального массива нарушает двоичную совместимость.
- Удаление элементов может нарушить совместимость, если нет механизма, который позволяет избежать доступа к удалённым элементам.
К сожалению, предупреждение динамического загрузчика выглядит более безобидным, чем на самом деле, а для удалённых элементов предупреждения вообще нет.
Обнаружить изменения ABI довольно легко с помощью таких инструментов, как libabigail.
Самый простой способ избежать этой ситуации — реализовать функцию, которая возвращает адрес массива:
static int local_array[3] = { 1, 2, 3 };
int *
get_external_array (void)
{
return local_array;
}
Если определение массива нельзя сделать статическим из-за того, как оно используется в библиотеке, вместо этого мы можем скрыть его видимость, а также предотвратить его экспорт и, следовательно, избежать проблемы усечения:
int local_array[3] __attribute__ ((visibility ("hidden"))) =
{ 1, 2, 3 };
Всё значительно сложнее, если переменная массива экспортируется по соображениям обратной совместимости. Поскольку массив из библиотеки усекается, то старая основная программа с более коротким определением массива не сможет предоставить доступ к полному массиву для нового клиентского кода, если он используется с тем же глобальным массивом. Вместо этого функция доступа может использовать отдельный (статический или скрытый) массив или, возможно, отдельный массив для добавленных элементов в конце. Недостатком является то, что невозможно сохранить всё в непрерывном массиве, если переменная массива экспортируется для обратной совместимости. Дизайн дополнительного интерфейса должен отражать это.
С помощью управления версиями символов можно экспортировать несколько версий с разными размерами, никогда не изменяя размер в определённой версии. Используя эту модель, новые связанные программы всегда будут использовать последнюю версию, предположительно, с наибольшим размером. Поскольку версия и размер символа фиксируются редактором ссылок одновременно, они всегда согласованы. Библиотека GNU C использует такой подход для исторических переменных sys_errlist
и sys_siglist
. Однако это по-прежнему не обеспечивает единый непрерывный массив.
Учитывая все обстоятельства, функция доступа (например, функция get_external_array
выше) — наилучший подход для избежания этой проблемы совместимости ABI.