Зачем использовать глобальную таблицу смещений для символов, определенных в самой общей библиотеке?

Рассмотрим следующий исходный код простой общей библиотеки:

библиотека.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?

Примечание. Я считаю, что этот вопрос похож на это, однако ответ был неуместным, возможно, из-за отсутствия деталей.

Дело в том, что символы разделяемой библиотеки могут быть переопределены другими библиотеками. Таким образом, код может закончиться использованием нового символа в другой библиотеке. Делая его внешним (то есть общедоступным), вы разрешаете его переопределить. Я не помню точное название этой функции.

Margaret Bloom 09.04.2019 09:44

Разве это не нарушение правила ODR?

yggdrasil 09.04.2019 09:51

Я не помню точных деталей, но ODR — это вещь C++, а это механизм загрузчика. Каждая общая библиотека имеет только одно определение символа. На самом деле «переопределение» — не совсем правильный термин, но я не помню технического.

Margaret Bloom 09.04.2019 10:07

Хорошо, нашел. Символ может быть вставленный.

Margaret Bloom 09.04.2019 10:10

@MargaretBloom: Да, это сообщение в блоге, на которое я собирался ссылаться, чтобы узнать больше о динамическом связывании Linux/Unix. Неэффективный доступ к вашим собственным глобальным переменным и функциям в пределах разделяемая библиотека — вот почему вы хотите установить видимость ELF на hidden, если вам не нужно/не нужно, чтобы символ участвовал в интерпозиции символов, поэтому другие библиотеки, определяющие/использующие то же имя, имеют их собственная частная копия определения символа.

Peter Cordes 09.04.2019 10:15

@PeterCordes Возможно, стоит написать об этом краткий ответ со ссылкой на этот пост в блоге. Найти его, увы, не так-то просто.

Margaret Bloom 09.04.2019 10:19

@MargaretBloom: Если я доберусь до этого раньше, чем это сделает кто-то другой, тогда да, может быть :)

Peter Cordes 09.04.2019 10:19

@PeterCordes (и MargaretBloom) Спасибо за ссылку и объяснение, очень полезно. Если вы напишите это как ответ, я приму это.

yggdrasil 09.04.2019 10:32
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
8
8
2 253
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

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

Динамическая компоновка 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 оказывается рядом с двумя другими.

Peter Cordes 10.04.2019 03:51

Или вы говорите, что GOT охватывает все 4 ГБ адресного пространства, на которое можно ссылаться с помощью 32-битного указателя, включая все остальные разделы? (Поскольку 32-разрядные указатели переносятся на 4G, disp32 может достигать любого места во всех 4 ГБ из любой начальной точки. Вы получаете ограничение размера 2 ГБ на x86-64, где вы хотите иметь возможность достигать любого места из любого места с подписанным 32- битовое смещение добавлено к 64-битным указателям, так что вы не можете зацикливаться.)

Peter Cordes 10.04.2019 03:55

@PeterCordes Вы правы в том, что в i386 переменные на самом деле не расположены в одном непрерывном GOT, поскольку в этом нет необходимости. Я предполагал, что они основаны на сгенерированном коде и на том, как он работает на других платформах.

Ross Ridge 10.04.2019 04:30

@PeterCordes Спасибо за разъяснения. Я не уверен, что понимаю, почему i386 использует смещение от GOT, а не программный счетчик. Это потому, что относительная адресация ПК более громоздка на x86?

yggdrasil 10.04.2019 05:56

@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), так что это пропущенная оптимизация.

Peter Cordes 10.04.2019 06:06

Я предполагаю, что предполагается, что большинство функций захотят ссылаться на что-то относительно GOT, и, конечно, вам нужен только 1 регистр в качестве базового адреса PIC, и он укажет на GOT. (Традиционно EBX, но современный gcc может подключаться к другим регистрам, чтобы обеспечить лучшее распределение регистров.) К счастью, 32-разрядная версия x86 устарела, поэтому IDK, если кто-то хочет реализовать код в компиляторе, должен искать эту оптимизацию в функциях, которые обращаются только к закрытым статическим данные. Он сохраняет одну инструкцию add для каждой такой функции.

Peter Cordes 10.04.2019 06:09

@ТАК КАК. Я не думаю, что у i386 ELF есть релокации, которые заставляют это работать. Как сказал Питер Кордес, вы хотите использовать только один регистр в качестве базового регистра, а перемещения @GOT и @GOTOFF требуют, чтобы этот базовый регистр указывал на GOT. Вместо перемещений @GOTOFF вы можете сделать что-то вроде global_hidden - foo - 5[edx], но для этого потребуется, чтобы EDX не корректировался после вызова преобразователя, а global_visible@GOT требует скорректированного EDX.

Ross Ridge 10.04.2019 06:25

В этом есть смысл. Для функций, которые обращаются к чему-либо в GOT, это приведет к добавлению/вычитанию смещения GOT (или, что еще хуже, к потере еще одного базового регистра). Компилятор, по-видимому, просто делает предположение, что будет что-то, ссылающееся на GOT, даже если его нет.

yggdrasil 10.04.2019 07:17

@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, почему скрытый не может этого сделать.

Peter Cordes 11.04.2019 00:47

@ТАК КАК. и Росс: 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, чтобы доказать, что это может быть относительно любого известного адреса, не обязательно начала текущей инструкции.

Peter Cordes 11.04.2019 00:54

(забыл упомянуть для MIPS: linux-mips.org/wiki/PIC_code объясняет, что функции PIC вызываются с их собственным адресом в $25, также известным как $t9, на случай, если кого-то интересует «странный» daddu, который читается $25 или .cpload.)

Peter Cordes 11.04.2019 00:57

@PeterCordes Перемещение, которое отсутствует в i386 ELF, что позволило бы использовать смещение от счетчика программ вместо GOT, поскольку база позволяет вам получить доступ к записи GOT для переменной, используя нескорректированное возвращаемое значение тук. Что-то вроде mov _GLOBAL_OFFSET_TABLE_ - .Lpc_base + global_visible@GOT(%edx), %ebx, где .Lpc_base — это метка, указывающая на инструкцию после инструкции вызова преобразователя. Мой предыдущий комментарий уже касался того, что вы можете заменить @GOTOFF выражением, которое генерирует перемещение R_386_PC.

Ross Ridge 11.04.2019 08:06

В дополнение к подробностям в 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);

Другие вопросы по теме