Как размеры массивов C стали частью двоичного интерфейса библиотеки

Original author: Florian Weimer
  • Translation
Большинство компиляторов 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.

Как компоновщик получает фактический размер символа? Он смотрит на определение символа и использует информацию о размере, которую находит там. Это позволяет компилятору вычислить макет раздела данных и заполнить перемещения данных соответствующими смещениями.

Общие объекты ELF


Реализации 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.
Support the author
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 24

    0
    правильно ли я понял, что это относится только к инициализированными массивам? и если мы пользуем что-то вроде:
    test.h
    extern «C» int a[];
    int get_a_size();

    где-то в библиотеке
    test.c
    int a[4];
    int get_a_size() { return 4; }

    в каком-то методе
    { a[0] = 1; a[1] = 2; a[2] = 3; a[3] = 4; }

    то ничего плохого при изменении размера не случится?

    или для любого внешнего массива?
      0
      Надо экспериментировать. Массив в любом случае при загрузке будет перенесён из so-файла в основной модуль. Но будет ли загрузчик копировать область нулей? А если рядом с этим массивом ещё два, но уже инициализированных? Может ли загрузчик скопировать всю область одним разом, или скопирует только инициализированные, а нулевой пропустит? Неизвестно.
        0
        Не надо экспериментировать, надо доки читать. Инициализированные массивы скопируются, в не инициализированные глобальные массивы запишутся нули, в локальных не инициализированных будет мусор.
          0
          Доки на что? Стандарт C не описывает линковку так детально.
          Только если в вашем компиляторе есть гарантия, что неинициализированные массивы и инициализированные обязаны располагаться в разных секциях исполняемого файла, то это implementation-defined. В MSVC привязка к секциям настраивается опциями компилятора (впрочем, на windows нет этой проблемы с массивами).
            0
            Стандарт описывает свойства глобальных и локальных переменных и этого достаточно.
              0
              Проблема же не в свойствах, а в способе их достижения.

              Неинициализированный массив должен быть заполнен нулями, но как этого достичь — загрузить нули из so-файла, запросить у OS страницу через функцию, гарантированно отдающую чистую страницу, или выполнить код в загрузчике, который обнулит область — выбор из этих вариантов не регламентируется стандартом.
                0
                Неинициализированный массив
                Неинициализированный глобальный массив
                выбор из этих вариантов не регламентируется стандартом
                Потому что тут важен результат, а не способ его получения.
                  0
                  Неинициализированный глобальный массив
                  Считайте, что в моём комментарии так и написано. Это что-то меняет?
                  Потому что тут важен результат, а не способ его получения.
                  Ладно, похоже вы не поняли суть статьи.
                    0
                    Суть статьи:
                    1. Исполняемый файл или файл библиотеки содержит информацию о размере переменных ( и не только массивов).
                    2. Если ожидаемый размер переменной не совпадает с загружаемым, то произойдет ошибка загрузки приложения.
                      0
                      Ага, а из этого вы делаете вывод, что инициализированные переменные и не инициализированные по-разному обрабатываются загрузчиком. Ссылаетесь на «доки». Логический вывод непонятен.
                        0
                        Вот только пункт #2 не имеет ничего общего со статьёй: приложение таки запустится и, в некоторых случаях, даже может работать.
                  +2
                  Недостаточно. Всё, что говорит стандарт по вопросу, обсуждаемому в статье: если у вас описания переменной в разных исходных файлах отличаются — то может случиться всё, что угодно.

                  Это описание, как бы это сказать… несколько неполно. Это если матом не хочется ругаться.

                    0
                    Для ответа на этот вопрос:
                    Массив в любом случае при загрузке будет перенесён из so-файла в основной модуль. Но будет ли загрузчик копировать область нулей? А если рядом с этим массивом ещё два, но уже инициализированных? Может ли загрузчик скопировать всю область одним разом, или скопирует только инициализированные, а нулевой пропустит? Неизвестно.
                    стандарта на язык достаточно.

                    Что-то мне подсказывает, что если вы собираете приложение для одной библиотеки, а при запуске подсовываете другую, с измененным API, то это не совсем проблема компилятора.
                      0
                      при запуске подсовываете другую, с измененным API
                      Так это, по-вашему, undefined behavior, и может случиться всё, что угодно, или ситуация регулируется стандартом и нам поможет чтение «доки»?
                        0
                        Это вообще не имеет отношения к компилятору.
                          0
                          Изначально я написал, что процесс загрузки в данной ситуации не определён и требует экспериментов (с загрузчиком, не компилятором!) для прояснения. К чему были ваши ответы и отсылки к компилятору?
                  +1
                  Доки на что?

                  На системный ABI. Типа того.
                    +2
                    Это справочник. Вся необходимая информация там есть, но в ней слишком легко утонуть. Если хотите понять как устроены разделяемые библиотеки на ELF-системе — тучше почитать вот это.

                    Тогда станет понятно — что происходит и, главное, почему. Доку на системный ABI после этого можно читать уже разве что для понимания того, что данный ABI является вменяемым и устроен плюс-минус так же, как описано в той статье (там рассматривается только i386 архитектура по достаточно уважительной причине: статья таки 2002 года, а первый процессор AMD64 появился в 2003м).
                      0

                      Маленькое дополнение. Ещё есть ARM. В частности AArch64 примерно после 2011 года появился. Конкретно i386 сейчас уже можно рассматривать как устаревший для большинства задач. Тотже linux дропнул его где-то в 2012 году.

                        +2
                        Тотже linux дропнул его где-то в 2012 году.
                        Линукс дропнул поддержку 80386го процессора, а не i386 ABI. Который и сегодня «живее всех живых».

                        Да и неважно это для 99% статьи: там i386 в качестве примера используется, а не как что-то уникальное, только там существующее. AAarch64, в общих чертах, так же устроен.
              0
              Проблема касается любого внешнего массива и сводится к тому, что бинарник — по умолчанию собирается без опции -fPIC.

              Если вы его соберёте с опцией -fPIC — то всё будет работать независимо от размера массива.
              0
              если не стрипать ELF, то длину легко достать через libbfd открыв свой же экзешник:

              root@Shiva:~$ cat k.c
              int arr[4];
              root@Shiva:~$ cat k1.c

              #include <stdio.h>

              extern int arr[];

              int main()
              {
              printf("%p\n", arr);
              return 0;
              }
              root@Shiva:~$ gcc k1.c k.c
              root@Shiva:~$ nm --print-size --size-sort --radix=d a.out
              0000000002101264 0000000000000001 b completed.7697
              0000000000001760 0000000000000002 T __libc_csu_fini
              0000000000001776 0000000000000004 R _IO_stdin_used
              0000000002101280 0000000000000016 B arr
              0000000000001610 0000000000000035 T main
              0000000000001344 0000000000000043 T _start
              0000000000001648 0000000000000101 T __libc_csu_init
              root@Shiva:~$

                +1
                А если пострипать, то через readelf:

                $ readelf --dyn-syms a.out
                
                Symbol table '.dynsym' contains 12 entries:
                   Num:    Value          Size Type    Bind   Vis      Ndx Name
                     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
                     1: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTab
                     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
                     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
                     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
                     5: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (2)
                     6: 0000000000201028     0 NOTYPE  GLOBAL DEFAULT   23 _edata
                     7: 0000000000201048     0 NOTYPE  GLOBAL DEFAULT   24 _end
                     8: 0000000000201030    20 OBJECT  GLOBAL DEFAULT   24 external_array
                     9: 0000000000201028     0 NOTYPE  GLOBAL DEFAULT   24 __bss_start
                    10: 00000000000005c8     0 FUNC    GLOBAL DEFAULT   10 _init
                    11: 0000000000000794     0 FUNC    GLOBAL DEFAULT   14 _fini
                
                  0
                  это смотря как пострипать:

                  root@Shiva:~$ gcc k1.c k.c
                  root@Shiva:~$ readelf --dyn-syms -s a.out|grep arr
                  33: 0000000000200db8 0 OBJECT LOCAL DEFAULT 19 __frame_dummy_init_array_
                  39: 0000000000200dc0 0 NOTYPE LOCAL DEFAULT 19 __init_array_end
                  41: 0000000000200db8 0 NOTYPE LOCAL DEFAULT 19 __init_array_start
                  60: 0000000000201020 16 OBJECT GLOBAL DEFAULT 24 arr
                  root@Shiva:~$ strip -s a.out
                  root@Shiva:~$ readelf --dyn-syms -s a.out|grep arr
                  root@Shiva:~$


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

              Only users with full accounts can post comments. Log in, please.