C2017/Динамические библиотеки

Материал из iRunner Wiki

Содержание

Общие концепции

Преимущества

  • Обновления и исправления ошибок. Если нужно обновить код, который вынесен в динамическую библиотеку, то достаточно обновить только библиотеку, и все программы, что её используют, получат новую версию, их не надо пересобирать.
  • Экономия памяти. Если одну и ту же библиотеку использует несколько приложений, в оперативной памяти может храниться только один ее экземпляр, доступный этим приложениям. Пример — библиотека C/C++. Ею пользуются многие приложения. Если всех их скомпоновать со статически подключаемой версией этой библиотеки, то код таких функций, как sprintf, strcpy, malloc и др., будет многократно дублироваться в памяти. Но если они компонуются с динамической версией библиотеки C/C++, в памяти будет присутствовать лишь одна копия кода этих функций, что позволит гораздо эффективнее использовать оперативную память.
  • Экономия места на диске. Аналогично.
  • Общие данные. Библиотеки могут содержать такие ресурсы, как строки, значки и растровые изображения. Эти ресурсы доступны любым программам.
  • Расширение функциональности приложения. Библиотеки можно загружать в адресное пространство процесса динамически, что позволяет приложению, определив, какие действия от него требуются, подгружать нужный код. Поэтому одна компания, создав какое-то приложение, может предусмотреть расширение его функциональности за счет библиотек от других компаний.
  • Возможность использования разных языков программирования. У вас есть выбор, на каком языке писать ту или иную часть приложения. Так, пользовательский интерфейс — на одном, прикладную логику — на другом.
  • Реализация специфических возможностей. Определенная функциональность доступна только при использовании динамических библиотек. Например, в Windows отдельные виды ловушек (устанавливаемых вызовом SetWindowsHookEx и SetWinEventHook) можно задействовать при том условии, что функция уведомления ловушки размещена в DLL. Кроме того, расширение функциональности оболочки Windows возможно лишь за счет создания COM-объектов, существование которых допустимо только в DLL. Если говорить о UNIX, то примером специфической для динамических библиотек является возможность LD_PRELOAD.
  • Ускорение процесса сборки. Каждый раз при сборке анализируются изменения только в коде приложения, выполняется более лёгкая компоновка.
  • Разработка в большой команде. Если в процессе разработки программного продукта отдельные его модули создаются разными группами, то при использовании динамических библиотек таким проектом управлять проще.

Недостатки

  • Разработка в большой команде. Затрудняется внесение изменений в код, нужно постоянно думать об обратной совместимости.
  • Неудобства при сборке. Например, нужно собрать код программы с каким-то флагом компилятора, при этом приходится следить, чтобы все нужные библиотеки были также пересобраны с флагом и подхватились при запуске.
  • Неудобство развёртывания. Статически скомпонованная программа — один самодостаточный файл.
  • Dependency hell. Многочисленные проблемы разного вида. Например, две версии одной библиотеки, половина программ требуют первую, другая половина программ — вторую.
  • Потери производительности. Вызов функции из динамической библиотеки получается медленнее.

Файлы динамических (разделяемых) библиотек

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

В UNIX-системах библиотеки имеют расширение so (shared object), в Windows — расширение dll (dynamic link library).

Динамическая загрузка

Динамические библиотеки могут использоваться двумя способами:

  • динамическая компоновка (dynamic linking)
  • динамическая загрузка (dynamic loading)

При динамической загрузке программа сама загружает конкретную библиотеку, указывая путь к ней, затем находит в библиотеке нужную функцию по имени и вызывает её. Это частый паттерн использования в программах, которые поддерживают плагины.

Переадресация (relocation)

Разные программы имеют различные размеры и различный набор подгружаемых динамических библиотек, и если разделяемая библиотека отображается в адресное пространство различных программ, она будет иметь различные адреса. Это в свою очередь означает, что все функции и переменные в библиотеке будут на различных местах. Если все обращения к адресам относительные («значение +1020 байта отсюда») нежели абсолютные («значение в 0x102218BF»), то это не проблема, однако так бывает не всегда. В таких случаях всем абсолютным адресам необходимо прибавить подходящий офсет — это и есть relocation.

Это практически всегда скрыто от C/C++ программиста — очень редко проблемы компоновки вызваны трудностями переадресации.

Таблица перемещений (relocation table) — это список указателей, созданный транслятором (компилятором или ассемблером) и хранимый в объектном или исполняемом файле. Каждая запись в таблице, или «fixup», является указателем на абсолютный адрес в объектном коде, который должен быть изменен, когда загрузчик перемещает программу так, чтобы она ссылалась на правильное местоположение. Fixup'ы предназначены для поддержки переноса программы в виде цельной единицы.

ASLR

ASLR (англ. address space layout randomization — «рандомизация размещения адресного пространства») — технология, применяемая в операционных системах, при использовании которой случайным образом изменяется расположение в адресном пространстве процесса важных структур данных, а именно образов исполняемого файла, подгружаемых библиотек, кучи и стека.

Технология ASLR создана для усложнения эксплуатации нескольких типов уязвимостей. Например, если при помощи переполнения буфера или другим методом атакующий получит возможность передать управление по произвольному адресу, ему нужно будет угадать, по какому именно адресу расположен стек, куча или другие структуры данных, в которые можно поместить шелл-код. Если не удастся угадать правильный адрес, приложение скорее всего аварийно завершится, тем самым лишив атакующего возможности повторной атаки и привлекая внимание системного администратора.

API и ABI

API: Application Program Interface

Это набор публичных типов, переменных, функций, которые вы делаете видимыми из вашего приложения или библиотеки.

В C и C++ API обычно поставляется в виде заголовочного файла (h) вместе с библиотекой.

С API работают люди, когда пишут код.

ABI: Application Binary Interface

Детали реализации этого интерфейса. Определяет такие вещи, как

  • Способ передачи параметров в функции (регистры, стек).
  • Кто извлекает параметры из стека (вызывающий код или вызываемый, caller/callee).
  • Как происходит возврат значений из функции.
  • Как реализован механизм исключений.
  • Декорирование имён в C++ (mangling).

ABI важно, когда приложение использует внешние библиотеки. Если при обновлении библиотеки ABI не меняется, то менять программу не надо. API может остаться тем же, но поменяется ABI. Две версии библиотеки, имеющие один ABI, называют binary compatible (бинарно совместимыми): старую версию библиотеки можно заменить на новую без проблем.

Иногда без изменений ABI не обойтись. Тогда приходится перекомпилировать зависящие программы. Если ABI библиотеки меняется, а API нет, то версии называют source compatible.

Разработчики библиотеки стараются поддерживать ABI стабильным. Новые функции и типы данных могут добавляться, но старые должны сохраняться.

Linux

Полностью статическая сборка

Рассмотрим такой простейший код:

#include <stdio.h>
 
int main() {
    puts("Hello, world!");
    return 0;
}

Даже такая программа использует динамические библиотеки. Вы можете просмотреть библиотеки, используемые приложением, через команду ldd.

$ gcc -static main.c
$ ldd a.out 
	linux-vdso.so.1 =>  (0x00007ffcecba0000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f324251d000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f32428e7000)
$ ldd a.out 
	linux-vdso.so.1 =>  (0x00007ffc99b1b000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4bb85f5000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f4bb89bf000)

Обратите внимание, что адреса меняются — это ASLR.

С помощью ключа -static компилятора gcc можно создать статически скомпонованный исполняемый файл.

$ gcc -static main.c
$ ldd a.out
	not a dynamic executable

Динамический загрузчик ld-linux.so

Когда операционная система загружает приложение, скомпонованное динамически, она должна найти и загрузить динамические библиотеки, необходимые для выполнения программы. В ОС Linux эту работу выполняет ld-linux.so.2.

$ ldd /bin/ls
	linux-vdso.so.1 =>  (0x00007fff3f3a5000)
	libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f0418ac7000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f04186fd000)
	libpcre.so.3 => /lib/x86_64-linux-gnu/libpcre.so.3 (0x00007f041848d000)
	libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f0418289000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f0418ce9000)
	libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f041806c000)

Когда запускается программа ls, ОС передаёт управление в ld-linux.so.2 вместо нормальной точки входа в приложение. В свою очередь ld-linux.so.2 ищет и загружает требуемые библиотеки, затем передаёт управление на точку старта приложения.

Справочная страница (man) к ld-linux.so.2 даёт высокоуровневое описание работы динамического компоновщика. По сути это рантайм-компонент компоновщика (ld), который отыскивает и загружает в память динамические библиотеки, используемые приложением. Обычно динамический компоновщик неявно задаётся в процессе компоновки. Спецификация ELF предоставляет функциональность динамической компоновки. Компилятор GCC включает в исполняемые файлы специальный заголовок (program header) под названием INTERP, он указывает путь к динамическому компоновщику.

$ readelf -l a.out 

Elf file type is EXEC (Executable file)
Entry point 0x400430
There are 9 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x00000000000001f8 0x00000000000001f8  R E    8
  INTERP         0x0000000000000238 0x0000000000400238 0x0000000000400238
                 0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

Спецификация гласит, то если присутствует заголовок PT_INTERP, то ОС должна создать образ процесса интерпретатора вместо приложения. Управление передаётся интерпретатору, который отвечает за загрузку динамических библиотек. Спецификация закладывает достаточную гибкость.

linux-vdso.so.1

В те времена, когда процессоры с архитектурой x86 только появились, взаимодействие пользовательских приложений со службами операционной системы осуществлялось с помощью прерываний. По мере создания более мощных процессоров эта схема взаимодействия становилась узким местом системы. Во всех процессорах, начиная с Pentium® II, Intel® реализовала механизм быстрых системных вызовов (Fast System Call), в котором вместо прерываний используются инструкции SYSENTER и SYSEXIT, ускоряющие выполнение системных вызовов.

Библиотека linux-vdso.so.1 является виртуальной библиотекой, или виртуальным динамически разделяемым объектом (VDSO), который размещается только в адресном пространстве отдельной программы. В более ранних системах эта библиотека называлась linux-gate.so.1. Эта виртуальная библиотека содержит всю необходимую логику, обеспечивающую для пользовательских приложений наиболее быстрый доступ к системным функциям в зависимости от архитектуры процессора – либо через прерывания, либо (для большинства современных процессоров) через механизм быстрых системных вызовов.

Система нумерации версий

Во всём UNIX-мире принята система нумерации вида major.minor.patchlevel:

  • Мажорная версия библиотеки изменяется всякий раз, когда у неё меняется ABI.
  • Минорная версия изменяется при добавлении в библиотеку новой функциональности без изменения ABI.
  • Patchlevel изменяется при исправлении ошибок без добавления новой функциональности.

Смена мажорной версии библиотеки -- это всегда событие, переход на неё -- это всегда трудозатраты.

Пример

Пример динамической загрузки

#include <stdlib.h>
#include <stdio.h>
#include <dlfcn.h>
 
int main(int argc, char **argv) {
    void *handle;
    double (*cosine)(double);
    char *error;
 
    handle = dlopen ("/lib/x86_64-linux-gnu/libm.so.6", RTLD_LAZY);
    if (!handle) {
        fputs (dlerror(), stderr);
        exit(1);
    }
 
    cosine = dlsym(handle, "cos");
    if ((error = dlerror()) != NULL)  {
        fputs(error, stderr);
        exit(1);
    }
 
    printf ("%f\n", (*cosine)(2.0));
    dlclose(handle);
}

Сборка выполняется так:

gcc cos.c -ldl

Решение проблемы перемещения

int myglob = 42;
 
int Foo(int a, int b)
{
    myglob += a;
    return b + myglob;
}

Скомпилируем объектный файл:

$ objdump -M intel foo.o -d

foo.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <Foo>:
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
   7:	89 75 f8             	mov    DWORD PTR [rbp-0x8],esi
   a:	8b 15 00 00 00 00    	mov    edx,DWORD PTR [rip+0x0]        # 10 <Foo+0x10>
  10:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  13:	01 d0                	add    eax,edx
  15:	89 05 00 00 00 00    	mov    DWORD PTR [rip+0x0],eax        # 1b <Foo+0x1b>
  1b:	8b 15 00 00 00 00    	mov    edx,DWORD PTR [rip+0x0]        # 21 <Foo+0x21>
  21:	8b 45 f8             	mov    eax,DWORD PTR [rbp-0x8]
  24:	01 d0                	add    eax,edx
  26:	5d                   	pop    rbp
  27:	c3                   	ret    

RIP — регистр instruction pointer, указывает на следующую инструкцию.

Адресация относительно RIP была введена в x86-64 в "длинном" режиме и используется по умолчанию. В старом x86 такая адресация применялась только для инструкций перехода call, jmp, ..., а теперь стала применяться в гораздо большем числе инструкций.

Когда потом объектый файл статически линкуется в исполняемый файл, нули превращаются в ненули:

0000000000400571 <Foo>:
  400571:	55                   	push   rbp
  400572:	48 89 e5             	mov    rbp,rsp
  400575:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
  400578:	89 75 f8             	mov    DWORD PTR [rbp-0x8],esi
  40057b:	8b 15 b7 0a 20 00    	mov    edx,DWORD PTR [rip+0x200ab7]        # 601038 <myglob>
  400581:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  400584:	01 d0                	add    eax,edx
  400586:	89 05 ac 0a 20 00    	mov    DWORD PTR [rip+0x200aac],eax        # 601038 <myglob>
  40058c:	8b 15 a6 0a 20 00    	mov    edx,DWORD PTR [rip+0x200aa6]        # 601038 <myglob>
  400592:	8b 45 f8             	mov    eax,DWORD PTR [rbp-0x8]
  400595:	01 d0                	add    eax,edx
  400597:	5d                   	pop    rbp
  400598:	c3                   	ret    
  400599:	0f 1f 80 00 00 00 00 	nop    DWORD PTR [rax+0x0]

Но это статическая линковка, а с динамической сложнее.

Есть два подхода:

  • Load-time relocation
  • Position independent code (PIC)

Load-time relocation

На x86-64 метод не применяется.

  • Замедление на стадии загрузки.
  • text-сегмент получается разным в разных копиях библиотеки, то есть не может разделяться между библиотеками, теряется преимущество экономии памяти.
  • text-сегмент доступен на запись (лишняя угроза безопасности)

Position Independent Code

https://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries/

https://eli.thegreenplace.net/2011/11/11/position-independent-code-pic-in-shared-libraries-on-x64

Пример использования внешней библиотеки

C2017/Чтение_zip-архива

LD_PRELOAD

Windows

Несмотря на то что общие принципы разделяемых библиотек примерно одинаковы как на платформах UNIX, так и на Windows, всё же есть несколько существенных различий.

Экспортируемые символы

Самое большое отличие заключается в том, что в библиотеках Windows символы не экспортируются автоматически. В UNIX все символы всех объектных файлов, которые были подлинкованы к разделяемой библиотеке, видны пользователю этой библиотеки. В Windows программист должен явно делать некоторые символы видимыми, т. е. экспортировать их.

Есть три способа экспортировать символ и Windows DLL (и все эти три способа можно перемешивать в одной и той же библиотеке).

Первый способ

В исходном коде объявить символ как __declspec(dllexport), примерно так:

__declspec(dllexport) int my_exported_function(int x, double y)

Второй способ

При выполнении команды компоновщика использовать опцию LINK.EXE export:symbol_to_export

LINK.EXE /dll /export:my_exported_function

Третий способ

Скормить компоновщику файл определения модуля (DEF) (используя опцию /DEF:def_file), включив в этот файл секцию EXPORT, которая содержит символы, подлежащие экспортированию.

EXPORTS
  my_exported_function
    my_other_exported_function

Как приходится иметь дело с C++, первая из этих опций становится самой простой, так как в этом случае компилятор берёт на себя обязательства позаботиться о декорировании имён.


.LIB и другие относящиеся к библиотеке файлы

Мы подошли ко второй трудности, связанной с библиотеками Windows: информация об экспортируемых символах, которые компоновщик должен связать с остальными символам, не содержится в самом DLL. Вместо этого данная информация содержится в соответствующем .LIB файле.

.LIB файл, ассоциированный с DLL, описывает какие (экспортируемые) символы находятся в DLL вместе с их расположением. Любой бинарник, который использует DLL, должен обращаться к .LIB файлу, чтобы связать символы корректно.

Чтобы сделать всё ещё более запутанным, расширение .LIB также используется для статических библиотек.

На самом деле существует целый ряд различных файлов, которые могут относиться каким-либо образом к библиотекам Windows. Наряду с .LIB файлом, а также (опциональным) .DEF файлом Вы можете увидеть все нижеперечисленные файлы, ассоциированные с вашей Windows-библиотекой.

Файлы на выходе компоновки

  • library.DLL: собственно код библиотеки; этот файл нужен (во время исполнения) любому бинарнику, использующему библиотеку.
  • library.LIB: файл «импортирования библиотеки», который описывает, где и какой символ находится в результирующей DLL. Этот файл генерируется, если только DLL экспортирует некоторые её символы. Если символы не экспортируются, то смысла в .LIB файле нет. Этот файл нужен во время компоновки.
  • library.EXP: «Экспорт файл» компилируемой библиотеки, который нужен, если имеет место компоновка бинарников с циклической зависимостью.
  • library.ILK: Если опция /INCREMENTAL была применена во время компоновки, которая активирует инкрементную компоновку, то этот файл содержит в себе статус инкрементной компоновки. Он нужен для будущих инкрементных компоновок с этой библиотекой.
  • library.PDB: Если опция /DEBUG была применена во время компоновки, то этот файл является программной базой данных, содержащей отладочную информацию для библиотеки.
  • library.MAP: Если опция /MAP была применена во время компоновки, то этот файл содержит описание внутреннего формата библиотеки.

Файлы на входе компоновки

  • library.LIB: Файл «импорта библиотеки», которые описывает где и какие символы находятся в других DLL, которые нужны для компоновки.
  • library.LIB: Статическая библиотека, которая содержит коллекцию объектов, необходимых при компоновке. Обратите внимание на неоднозначное использование расширения .LIB
  • library.DEF: Файл «определений», который позволяет управлять различными деталями скомпонованной библиотеки, включая экспорт символов.
  • library.EXP: Файл экспорта компонуемой библиотеки, который может сигнализировать, что предыдущее выполнение LIB.EXE уже создало файл .LIB для библиотеки. Имеет значение при компоновке бинарников с циклическими зависимостями.
  • library.ILK: Файл состояния инкрементной компоновки; см. выше.
  • library.RES: Файл ресурсов, который содержит информацию о различных GUI-виджетах, используемых исполняемым файлом. Эти ресурсы включаются в конечный бинарник.

Это является большим отличием по сравнению с UNIX, где почти вся информация, содержащаяся в этих всех дополнительных файлах, просто добавляется в саму библиотеку.

Импортируемые символы

Вместе с требованием к DLL явно объявлять экспортируемые символы, Windows также разрешает бинарникам, которые используют код библиотеки, явно объявлять символы, подлежащие импортированию. Это не является обязательным, но даёт некоторую оптимизацию по скорости, вызванную историческими свойствами 16-битной Windows.

Calling an imported function, the naive way

В LIB-файле для фунции FunctionName генерирутеся "заглушка", которая выглядит как

jmp [__imp__FunctionName]

Здесь __imp__FunctionName является записью в таблице импортированных функций. То есть заглушка считывает адрес из таблицы импортированных адресов (IAT) и выполняет переход на тот код.

За счёт двухступенчатого процесса получается лишняя потеря производительности.

Если писать dllimport, компилятор будет генерировать код для непрямого вызова через таблицу IAT прямо по месту. Это уменьшает число индирекций и позволяет компилятору локально (в вызывающей функции) закешировать целевой адрес. Хотя при наличии LTO это уже не актуально.

Объявляем символ как __declspec(dllimport) в исходном коде примерно так:

__declspec(dllimport) int function_from_some_dll(int x, double y);
__declspec(dllimport) extern int global_var_from_some_dll;

При этом индивидуальное объявление функций или глобальных переменных в одном заголовочном файле является хорошим тоном программирования на C. Это приводит к некоторому ребусу: код в DLL, содержащий определение функции/переменной, должен экспортировать символ, но любой другой код, использующий DLL, должен импортировать символ.

Стандартный выход из этой ситуации — это использование макросов препроцессора.

#ifdef EXPORTING_XYZ_DLL_SYMS
#define XYZ_LINKAGE __declspec(dllexport)
#else
#define XYZ_LINKAGE __declspec(dllimport)
#endif
 
XYZ_LINKAGE int xyz_exported_function(int x);
XYZ_LINKAGE extern int xyz_exported_variable;

Файл с исходниками в DLL, который определяет функцию и переменную гарантирует, что переменная препроцессора EXPORTING_XYZ_DLL_SYMS определена (посредством #define) до включения соответствующего заголовочного файла и таким образом экспортирует символ. Любой другой код, который включает этот заголовочный файл, не определяет этот символ и таким образом импортирует его.

Циклические зависимости

Ещё одной трудностью, связанной с использованием DLL, является тот факт, что Windows относится строже к требованию, что каждый символ должен быть разрешён во время компоновки. В UNIX вполне возможно скомпоновать разделяемую библиотеку, которая содержит неразрешённые символы, т. е. символы, определение которых неведомо компоновщику. В этой ситуации любой другой код, использующий эту разделяемую библиотеку, должен будет предоставить определение незразрешённых символов, иначе программа не будет запущена. Windows не допускает такой распущенности.

Для большинства систем — это не проблема. Выполняемые файлы зависят от высокоуровевых библиотек, высокоуровневые библиотеки зависят от библиотек низкого уровня, и всё компонуется в обратном порядке — сначала библиотеки низкого уровня, потом высокого, а затем и выполняемый файл, который зависит от всех остальных

Однако, если имеет место циклическая зависимость между бинарниками, тогда всё немного усложняется. Если X.DLL нуждается в символе из Y.DLL, а Y.DLL нуждается в символе из X.DLL, тогда необходимо решить задачу про курицу и яйцо: какая бы библиотека ни компоновалась бы первой, она не сможет найти разрешение ко всем символам.

Windows предоставил обходной приём примерно следующего содержания. Сначала имитируем компоновку библиотеки X. Запускаем LIB.EXE (не LINK.EXE), чтобы получить файл X.LIB точно такой же, какой был бы получен с LINK.EXE. При этом X.DLL не будет сгенерирован, но вместо него будет получен файл X.EXP. Компонуем библиотеку Y как обычно, используя X.LIB, полученную на предыдущем шаге, и получаем на выходе как Y.DLL, так и Y.LIB. В конце концов компонуем библиотеку X теперь уже полноценно. Это происходит почти как обычно, используя дополнительно файл X.EXP, полученный на первом шаге. Обычное в этом шаге то, что компоновщик использует Y.LIB и производит X.DLL. Необычное — компоновщик пропускает шаг создания X.LIB, так как этот файл был уже создан на первом шаге, чему свидетельствует наличие .EXP файла.

Но несомненно лучше всё же реорганизовать библиотеки таким образом, чтоб избежать любых циклических зависимостей…

Relocation

DLL в Microsoft Windows используют вариант E8 инструкции CALL (относительный, смещение относительно следующей команды). Эти инструкции не нужно изменять при загрузке DLL.

Некоторые глобальные переменные (например, массивы строковых литералов, таблицы виртуальных функций) содержат адрес объекта в секции данных; поэтому сохраненный адрес в глобальной переменной необходимо обновить, чтобы он соответствовал адресу, по которому была загружена DLL. Динамический загрузчик вычисляет адрес, на который ссылается глобальная переменная, и сохраняет значение в глобальной переменной; это вызывает copy-on-write страницы памяти, содержащей такую ​​глобальную переменную. Страницы с кодом и страницы с глобальными переменными, которые не содержат указателей на код или глобальные данные, остаются разделяемыми между процессами. Эта операция должна выполняться в любой ОС, которая может загружать динамическую библиотеку по произвольному адресу.

В Windows Vista и более поздних версиях Windows перемещение DLL и исполняемых файлов выполняется диспетчером памяти ядра, который разделяет relocated-библиотеки между несколькими процессами. Образы всегда перемещаются с их предпочтительных базовых адресов, обеспечивая рандомизацию размещения адресного пространства (ASLR).

Версии Windows до Vista требуют, чтобы системные DLL-файлы были предварительно привязаны к неконфликтным фиксированным адресам во время компоновки, чтобы избежать релокации образов во время выполнения. Перемещение времени выполнения в этих более старых версиях Windows выполняется загрузчиком DLL в контексте каждого процесса, и результирующие перемещенные части каждого изображения больше не могут делиться между процессами.

Динамические библиотеки и язык C++

Поддержка бинарной совместимости

Что делать можно

  • Можно добавить новую функцию, новый невиртуальный метод класса, новый конструктор и новые статические поля.
  • Можно добавлять новые перегрузки к существующим перегруженным функциям, создавать новые перегруженные функции или перегружать private-методы.
  • Можно добавлять новые виртуальные функции в конец класса, не имеющего наследников.
  • Можно изменить тело любой непоинлайненной функции (т. е. не определённой в публичном заголовочном файле).
  • Можно добавлять новые классы.
  • Можно произвольным образом изменять, добавлять и удалять friend declarations.
  • Можно переименовывать private-поля классов.
  • Можно делать всё, что угодно, с классами и функциями, не являющимися частью API (размещёнными в cpp-файле и имеющими static или обёрнутыми в анонимный namespace).

Что делать не стоит

  • Можно изменить тело поинлайненной функции или унести её в .cpp. Однако функциональность менять при этом нельзя (даже исправлять ошибки), поскольку собранные с библиотекой бинарники продолжат пользоваться старой версией функции.
  • Можно переопределить виртуальный метод, определённый в базовом классе. Однако функциональность менять при этом, опять же, нельзя, т. к. в ряде мест компилятор может заменять виртуальный вызов прямым (напр. Derived d; d.foo();).
  • Можно удалить невиртуальный закрытый (private) метод класса (или private static поле). Но перед этим нужно убедиться, что метод никогда за время жизни библиотеки не вызывался (или поле не использовалось) ни из одной inline-функции.
  • Можно изменять значения по умолчанию для функций и методов; однако уже собранные бинарники продолжат передавать старые значения по умолчанию.
  • Можно добавлять перегруженные варианты существующих неперегруженных public-методов. Это сохраняет бинарную совместимость, но код, который брал адрес такой функции (auto x = &MyClass::method) перестанет компилироваться.

Чего делать нельзя

  • Нельзя удалять существующие классы, являющиеся частью API.
  • У существующих классов нельзя удалять открытые, защищённые или виртуальные методы.
  • Нельзя как-либо менять существующую иерархию классов (т. е. изменять, добавлять, удалять или переупорядочивать базовые классы).
  • Нельзя как-либо менять список шаблонных параметров для существующих шаблонных классов и функций.
  • Нельзя менять сигнатуру существующих функций и методов.
  • Нельзя добавлять виртуальные методы в середину класса.
  • Нельзя добавлять виртуальные методы в класс, не имеющий виртуальных функций или виртуального деструктора.
  • Нельзя добавлять виртуальные методы в класс, имеющий наследников.
  • Нельзя добавлять, переупорядочивать или удалять поля существующих классов.

Идиома PImpl (pointer to implementation)

GotW #24

Другое, более общее название — opaque pointer (непрозрачный указатель).

/* PublicClass.h */
 
class PublicClass {
public:
    PublicClass();                              // Constructor
    PublicClass(const PublicClass&);            // Copy constructor
    PublicClass(PublicClass&&);                 // Move constructor
    PublicClass& operator=(const PublicClass&); // Copy assignment operator
    ~PublicClass();                             // Destructor
 
    // Other operations...
 
private:
    struct CheshireCat;                         // Not defined here
    unique_ptr<CheshireCat> d_ptr;              // opaque pointer
};
/* PublicClass.cpp */
 
#include "PublicClass.h"
 
struct PublicClass::CheshireCat {
    int a;
    int b;
};
 
PublicClass::PublicClass()
    : d_ptr(new CheshireCat()) {
    // do nothing
}
 
PublicClass::PublicClass(const PublicClass& other)
    : d_ptr(new CheshireCat(*other.d_ptr)) {
    // do nothing
}
 
PublicClass::PublicClass(PublicClass&& other) = default;
 
PublicClass& PublicClass::operator=(const PublicClass &other) {
    *d_ptr = *other.d_ptr;
    return *this;
}
 
PublicClass::~PublicClass() = default;

Есть плюсы:

  • реализация скрыта,
  • добавление новых полей в в приватную структуру не ломает бинарную совместимость,
  • ускоряется компиляция.

Минусы также очевидны:

  • усложняется код,
  • замедляются все вызовы за счёт лишнего разыменования указателя.

Похожий принцип можно использовать и на чистом C, эмулируя ООП и инкапсуляцию:

/* obj.h */
 
struct obj;
 
/*
 * The compiler considers struct obj an incomplete type. Incomplete types
 * can be used in declarations.
 */
 
size_t obj_size(void);
 
void obj_setid(struct obj *, int);
 
int obj_getid(struct obj *);
/* obj.c */
 
#include "obj.h"
 
struct obj {
    int id;
};
 
/*
 * The caller will handle allocation.
 * Provide the required information only
 */
 
size_t obj_size(void) {
    return sizeof(struct obj);
}
 
void obj_setid(struct obj *o, int i) {
    o->id = i;
}
 
int obj_getid(struct obj *o) {
    return o->id;
}