Рассмотрим следующий исходный код простой общей библиотеки:
библиотека.cpp:
static int global = 10;
int foo()
{
return global;
}
При компиляции с опцией -fPIC
в clang получается такая сборка объекта (x86-64):
foo(): # @foo()
push rbp
mov rbp, rsp
mov eax, dword ptr [rip + global]
pop rbp
ret
global:
.long 10 # 0xa
Поскольку символ определен внутри библиотеки, компилятор использует относительную адресацию ПК, как и ожидалось: mov eax, dword ptr [rip + global]
Однако, если мы изменим static int global = 10;
на int global = 10;
, сделав его символом с внешней связью, в результате получится следующая сборка:
foo(): # @foo()
push rbp
mov rbp, rsp
mov rax, qword ptr [rip + global@GOTPCREL]
mov eax, dword ptr [rax]
pop rbp
ret
global:
.long 10 # 0xa
Как вы можете видеть, компилятор добавил слой косвенности с таблицей глобальных смещений, что кажется совершенно ненужным в данном случае, поскольку символ по-прежнему определяется в той же библиотеке (и исходном файле).
Если бы символ был определен в разделяемой библиотеке еще один, GOT был бы необходим, но в этом случае он кажется излишним. Почему компилятор все еще добавляет этот символ в GOT?
Примечание. Я считаю, что этот вопрос похож на это, однако ответ был неуместным, возможно, из-за отсутствия деталей.
Разве это не нарушение правила ODR?
Я не помню точных деталей, но ODR — это вещь C++, а это механизм загрузчика. Каждая общая библиотека имеет только одно определение символа. На самом деле «переопределение» — не совсем правильный термин, но я не помню технического.
Хорошо, нашел. Символ может быть вставленный.
@MargaretBloom: Да, это сообщение в блоге, на которое я собирался ссылаться, чтобы узнать больше о динамическом связывании Linux/Unix. Неэффективный доступ к вашим собственным глобальным переменным и функциям в пределах разделяемая библиотека — вот почему вы хотите установить видимость ELF на hidden
, если вам не нужно/не нужно, чтобы символ участвовал в интерпозиции символов, поэтому другие библиотеки, определяющие/использующие то же имя, имеют их собственная частная копия определения символа.
@PeterCordes Возможно, стоит написать об этом краткий ответ со ссылкой на этот пост в блоге. Найти его, увы, не так-то просто.
@MargaretBloom: Если я доберусь до этого раньше, чем это сделает кто-то другой, тогда да, может быть :)
@PeterCordes (и MargaretBloom) Спасибо за ссылку и объяснение, очень полезно. Если вы напишите это как ответ, я приму это.
Таблица глобальных смещений служит двум целям. Один из них — позволить динамическому компоновщику «вставить» определение переменной, отличное от исполняемого файла или другого общего объекта. Во-вторых, разрешить генерацию независимого от позиции кода для ссылок на переменные на определенных процессорных архитектурах.
Динамическая компоновка ELF рассматривает весь процесс, исполняемый файл и все общие объекты (динамические библиотеки) как разделяющие одно глобальное пространство имен. Если несколько компонентов (исполняемых или общих объектов) определяют один и тот же глобальный символ, то динамический компоновщик обычно выбирает одно определение этого символа, и все ссылки на этот символ во всех компонентах относятся к этому одному определению. (Однако разрешение динамических символов ELF является сложным, и по разным причинам разные компоненты могут в конечном итоге использовать разные определения одного и того же глобального символа.)
Чтобы реализовать это, при построении разделяемой библиотеки компилятор будет обращаться к глобальным переменным косвенно через GOT. Для каждой переменной будет создана запись в GOT, содержащая указатель на переменную. Как показывает код вашего примера, компилятор будет использовать эту запись для получения адреса переменной вместо того, чтобы пытаться получить к ней прямой доступ. Когда общий объект загружается в процесс, динамический компоновщик определяет, были ли какие-либо глобальные переменные заменены определениями переменных в другом компоненте. Если это так, эти глобальные переменные будут обновлять свои записи GOT, чтобы указывать на замещающую переменную.
Используя «скрытые» или «защищенные» атрибуты видимости ELF, можно предотвратить замену глобального определенного символа определением в другом компоненте и, таким образом, устранить необходимость использования GOT на определенных архитектурах. Например:
extern int global_visible;
extern int global_hidden __attribute__((visibility("hidden")));
static volatile int local; // volatile, so it's not optimized away
int
foo() {
return global_visible + global_hidden + local;
}
при компиляции с помощью -O3 -fPIC
с портом x86_64 GCC генерирует:
foo():
mov rcx, QWORD PTR global_visible@GOTPCREL[rip]
mov edx, DWORD PTR local[rip]
mov eax, DWORD PTR global_hidden[rip]
add eax, DWORD PTR [rcx]
add eax, edx
ret
Как видите, только global_visible
использует GOT, а global_hidden
и local
не используют его. «Защищенная» видимость работает аналогичным образом, она предотвращает замену определения, но делает его видимым для динамического компоновщика, чтобы другие компоненты могли получить к нему доступ. «Скрытая» видимость полностью скрывает символ от динамического компоновщика.
Необходимость сделать код перемещаемым, чтобы разрешить загрузку общих объектов по разным адресам в разных процессах, означает, что статически выделенные переменные, независимо от того, имеют ли они глобальную или локальную область действия, не могут быть доступны напрямую с помощью одной инструкции на большинстве архитектур. Единственное известное мне исключение — это 64-битная архитектура x86, как вы видите выше. Он поддерживает операнды памяти, которые относятся к ПК и имеют большие 32-битные смещения, которые могут достигать любой переменной, определенной в том же компоненте.
На всех других архитектурах, с которыми я знаком, доступ к переменным в зависимости от положения требует нескольких инструкций. Как именно, сильно зависит от архитектуры, но часто это связано с использованием GOT. Например, если вы скомпилируете приведенный выше пример кода C с портом x86_64 GCC, используя параметры -m32 -O3 -fPIC
, вы получите:
foo():
call __x86.get_pc_thunk.dx
add edx, OFFSET FLAT:_GLOBAL_OFFSET_TABLE_
push ebx
mov ebx, DWORD PTR global_visible@GOT[edx]
mov ecx, DWORD PTR local@GOTOFF[edx]
mov eax, DWORD PTR global_hidden@GOTOFF[edx]
add eax, DWORD PTR [ebx]
pop ebx
add eax, ecx
ret
__x86.get_pc_thunk.dx:
mov edx, DWORD PTR [esp]
ret
GOT используется для доступа ко всем трем переменным, но если присмотреться, global_hidden
и local
обрабатываются иначе, чем global_visible
. В последнем случае доступ к указателю на переменную осуществляется через GOT, а в первых двух переменных доступ к ним осуществляется непосредственно через GOT. Это довольно распространенный трюк среди архитектур, где GOT используется для всех ссылок на позиционно-независимые переменные.
32-битная архитектура x86 здесь исключительна в одном отношении, поскольку она имеет большие 32-битные смещения и 32-битное адресное пространство. Это означает, что через базу GOT можно получить доступ к любому месту в памяти, а не только к самой GOT. Большинство других архитектур поддерживают только гораздо меньшие смещения, что делает максимальное расстояние, которое может быть от базы GOT, намного меньше. Другие архитектуры, использующие этот трюк, помещают только небольшие (локальные/скрытые/защищенные) переменные в саму GOT, большие переменные хранятся вне GOT, и GOT будет содержать указатель на переменную, как и в случае с глобальными переменными с нормальной видимостью.
В вашем примере PIC i386 переменные не являются выделенный внутри GOT, а просто ссылаются на него. GCC просит компоновщика вычислить смещение от GOT к local
с помощью local@GOTOFF
. Мы можем увидеть это на Godbolt godbolt.org/z/0Zu-RM, взглянув на директивы: local
определяется в .data
, а не в каком-либо специальном разделе. (Я использовал -g0
, чтобы я мог смотреть на директивы без беспорядка директив отладки.) И я сделал другие переменные определенными, а не внешними. global_visible
оказывается рядом с двумя другими.
Или вы говорите, что GOT охватывает все 4 ГБ адресного пространства, на которое можно ссылаться с помощью 32-битного указателя, включая все остальные разделы? (Поскольку 32-разрядные указатели переносятся на 4G, disp32 может достигать любого места во всех 4 ГБ из любой начальной точки. Вы получаете ограничение размера 2 ГБ на x86-64, где вы хотите иметь возможность достигать любого места из любого места с подписанным 32- битовое смещение добавлено к 64-битным указателям, так что вы не можете зацикливаться.)
@PeterCordes Вы правы в том, что в i386 переменные на самом деле не расположены в одном непрерывном GOT, поскольку в этом нет необходимости. Я предполагал, что они основаны на сгенерированном коде и на том, как он работает на других платформах.
@PeterCordes Спасибо за разъяснения. Я не уверен, что понимаю, почему i386 использует смещение от GOT, а не программный счетчик. Это потому, что относительная адресация ПК более громоздка на x86?
@AS: x86-64 добавил режим адресации относительно ПК только для 64-битного режима. Он недоступен в 32-битном режиме. Но да, хороший момент, если мы удалим global_visible
, тогда вообще не будет необходимости вычислять адрес GOT, gcc должен пропустить этот шаг и просто использовать mov eax, local-.Lpc_base[edx]
для ссылки на него относительно обратного адреса преобразователя, который считывает EIP в EDX (т.е. поставить метку .Lpc_base
на инструкции после call
). Но вместо этого он по-прежнему добавляет смещение GOT (godbolt.org/z/Yek-6q), так что это пропущенная оптимизация.
Я предполагаю, что предполагается, что большинство функций захотят ссылаться на что-то относительно GOT, и, конечно, вам нужен только 1 регистр в качестве базового адреса PIC, и он укажет на GOT. (Традиционно EBX, но современный gcc может подключаться к другим регистрам, чтобы обеспечить лучшее распределение регистров.) К счастью, 32-разрядная версия x86 устарела, поэтому IDK, если кто-то хочет реализовать код в компиляторе, должен искать эту оптимизацию в функциях, которые обращаются только к закрытым статическим данные. Он сохраняет одну инструкцию add
для каждой такой функции.
@ТАК КАК. Я не думаю, что у i386 ELF есть релокации, которые заставляют это работать. Как сказал Питер Кордес, вы хотите использовать только один регистр в качестве базового регистра, а перемещения @GOT и @GOTOFF требуют, чтобы этот базовый регистр указывал на GOT. Вместо перемещений @GOTOFF вы можете сделать что-то вроде global_hidden - foo - 5[edx]
, но для этого потребуется, чтобы EDX не корректировался после вызова преобразователя, а global_visible@GOT
требует скорректированного EDX.
В этом есть смысл. Для функций, которые обращаются к чему-либо в GOT, это приведет к добавлению/вычитанию смещения GOT (или, что еще хуже, к потере еще одного базового регистра). Компилятор, по-видимому, просто делает предположение, что будет что-то, ссылающееся на GOT, даже если его нет.
@RossRidge: я посмотрел на ISA, отличную от x86 (MIPS и MIPS64), чтобы узнать, что вы говорили о выделении в GOT. gcc этого не делает (по крайней мере, по умолчанию). godbolt.org/z/J_UnFd показывает, что все 3 переменные используют цепочку из 2 загрузок в каждой. Но static local
использует ld $4,%got_page(local)($5)
для получения базового указателя и lw $4,%got_ofst(local)($4)
для использования непосредственного смещения относительно этой страницы. Все остальные загружают указатель из GOT специально для этой переменной, но local
может совместно использовать один и тот же указатель на полученную страницу со многими другими, давая кэш-попадания. IDK, почему скрытый не может этого сделать.
@ТАК КАК. и Росс: ELF позволяет перемещать относительно ПК со смещением, как для rel32. Таким образом, мы можем написать .Lpc_base: nop;nop; mov $sym1 - .Lpc_base, %eax
и успешно собрать его; разбирается как b8 03 00 00 00 mov $0x3,%eax 3: R_386_PC32 sym1
. (Важно, чтобы мы определили .Lpc_base
в текущем файле; mov $sym1 - sym2, %eax
не будет собираться.) Сначала я поместил пару инструкций nop
, чтобы доказать, что это может быть относительно любого известного адреса, не обязательно начала текущей инструкции.
(забыл упомянуть для MIPS: linux-mips.org/wiki/PIC_code объясняет, что функции PIC вызываются с их собственным адресом в $25
, также известным как $t9
, на случай, если кого-то интересует «странный» daddu
, который читается $25
или .cpload
.)
@PeterCordes Перемещение, которое отсутствует в i386 ELF, что позволило бы использовать смещение от счетчика программ вместо GOT, поскольку база позволяет вам получить доступ к записи GOT для переменной, используя нескорректированное возвращаемое значение тук. Что-то вроде mov _GLOBAL_OFFSET_TABLE_ - .Lpc_base + global_visible@GOT(%edx), %ebx
, где .Lpc_base
— это метка, указывающая на инструкцию после инструкции вызова преобразователя. Мой предыдущий комментарий уже касался того, что вы можете заменить @GOTOFF выражением, которое генерирует перемещение R_386_PC.
В дополнение к подробностям в Ross Ridge ответ.
Это внешняя и внутренняя связь. Без static
эта переменная имеет внешнюю связь и, следовательно, доступна из любой другой единицы перевода. Любая другая единица перевода может объявить ее как extern int global;
и получить к ней доступ.
External linkage. The name can be referred to from the scopes in the other translation units. Variables and functions with external linkage also have language linkage, which makes it possible to link translation units written in different programming languages.
Any of the following names declared at namespace scope have external linkage unless the namespace is unnamed or is contained within an unnamed namespace (since C++11):
- variables and functions not listed above (that is, functions not declared static, namespace-scope non-const variables not declared static, and any variables declared extern);
Дело в том, что символы разделяемой библиотеки могут быть переопределены другими библиотеками. Таким образом, код может закончиться использованием нового символа в другой библиотеке. Делая его внешним (то есть общедоступным), вы разрешаете его переопределить. Я не помню точное название этой функции.