Как работает экспорт функций в DLL: механизмы, способы и практические правила

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

Ниже разобрано, как устроен этот механизм на уровне таблицы экспорта, какие способы объявления экспорта существуют, чем они отличаются, что происходит с именами функций при компиляции C++ и как всё это выглядит со стороны вызывающего кода.

Что происходит внутри: таблица экспорта

DLL в Windows — это исполняемый модуль формата PE (Portable Executable). Внутри такого файла есть служебные структуры, среди которых таблица экспорта. Это по сути справочник: он перечисляет имена доступных снаружи символов (функций и данных), их адреса относительно начала загруженного модуля и, при желании автора, порядковые номера.

Когда программа запускается и система подгружает нужную DLL, либо когда код явно вызывает LoadLibrary, адреса функций ещё неизвестны вызывающей стороне. Их получают двумя путями:

  • Неявное связывание (implicit linking). Программа компилируется вместе с библиотекой импорта (.lib), и загрузчик при старте процесса сам находит DLL и разрешает адреса всех используемых функций. Это удобно, но требует, чтобы DLL была найдена при запуске.
  • Явное связывание (explicit linking). Код сам вызывает LoadLibrary для загрузки модуля и GetProcAddress для получения адреса конкретной функции по её имени. Так работают плагины и расширяемые приложения: набор модулей может быть неизвестен на момент сборки основной программы.

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

Порядковые номера вместо имён

Каждому экспорту можно назначить порядковый номер (ordinal). Вызов по номеру работает быстрее, чем поиск по строке, и скрывает реальные имена функций. Минус очевиден: номера — часть контракта. Если в новой версии DLL порядок изменится, все зависимые программы сломаются. На практике экспорт по ординалам используют редко, в основном там, где важна компактность или намеренно скрывается API.

Способы объявить экспорт

__declspec(dllexport)

Наиболее распространённый способ в Visual C++ — атрибут __declspec(dllexport) перед объявлением функции или переменной. Компилятор помещает такой символ в таблицу экспорта. Поскольку один и тот же заголовочный файл используется и самой DLL, и её потребителями, обычно вводят макрос:

Условный пример типовой схемы:

  • в настройках проекта DLL определяется макрос вида BUILDING_DLL;
  • макрос экспорта раскрывается как dllexport при сборке библиотеки и как dllimport при подключении заголовка клиентом;
  • клиентский код просто включает заголовок и линкуется с .lib.

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

DEF-файл

Альтернатива — файл определения модуля (.def) с секцией EXPORTS, где вручную перечисляются экспортируемые символы. Этот способ даёт несколько возможностей, которых нет у dllexport:

  • назначить функциям фиксированные порядковые номера;
  • экспортировать символ под другим именем без изменения кода;
  • экспортировать недекорированные имена C++-функций, если вы точно контролируете соглашение о вызовах;
  • не трогать исходный код при добавлении или переименовании экспорта.

Оба способа можно комбинировать, но смешивать их без необходимости не стоит: проще поддерживать один источник правды о списке экспорта.

Автоматический экспорт всего

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

Декорирование имён: главная ловушка C++

Компилятор C++ кодирует в имя экспортируемого символа пространство имён, класс, параметры и соглашение о вызовах — это называется декорированием (mangling). Оно нужно для поддержки перегрузки функций. В результате функция Add(int, int) в таблице экспорта может выглядеть как длинная строка вроде ?Add@@YAHHH@Z, а не просто Add.

Последствия для практики:

  • GetProcAddress по имени «Add» не найдёт такую функцию — нужно передавать точное декорированное имя, которое зависит от компилятора и его версии;
  • DLL, собранная одним компилятором C++, часто несовместима по именам с клиентом другого компилятора;
  • декорированные имена нестабильны между версиями инструментария, поэтому опираться на них в долгоживущем API нельзя.

Стандартное решение — помечать экспортируемые функции как extern «C». Это отключает декорирование для них (с оговоркой: суффиксы от соглашения о вызовах могут сохраняться, например @N у stdcall-функций на 32-битных платформах). Плата за это — невозможность перегружать такие функции по параметрам: одно имя C-линковки должно соответствовать одному символу.

Соглашения о вызовах

Соглашение о вызовах (cdecl, stdcall, fastcall и другие) определяет, кто очищает стек и как передаются аргументы. И экспортёр, и потребитель обязаны использовать одинаковое соглашение, иначе возможны порча стека и падения, причём иногда только на определённых путях выполнения. Для 64-битного кода проблема практически снята: там действует единое системное соглашение о вызовах, и выбор вручную почти не нужен. Для 32-битных DLL согласованность критична.

Сравнение способов экспорта

Критерий dllexport в коде DEF-файл
Где описан экспорт Прямо в заголовках рядом с объявлениями Отдельный файл проекта
Контроль порядковых номеров Ограниченный Полный
Переименование экспорта Требует правки кода Правкой одной строки в DEF
Риск рассинхронизации Низкий: имя следует за объявлением Есть: DEF может устареть относительно кода
Подходит для чистого C-API Да, вместе с extern «C» Да, включая недекорированные имена

Для новых проектов чаще выбирают dllexport с макросом-переключателем: меньше ручной синхронизации. DEF-файл берут, когда нужны ординалы, псевдонимы или работа с унаследованным кодом.

Что видит потребитель: импорт

Со стороны клиента зеркальная картина. При неявном связывании достаточно включить заголовок и указать библиотеку импорта; атрибут dllimport подсказывает компилятору, что обращение идёт через таблицу импорта, и позволяет генерировать более эффективный код. При явном связывании заголовок необязателен, но программист должен сам:

  1. загрузить модуль через LoadLibrary и проверить результат;
  2. получить адрес функции через GetProcAddress по точному имени экспорта;
  3. привести полученный указатель к корректному типу функции с правильным соглашением о вызовах;
  4. вызывать функцию и после завершения работы освободить библиотеку через FreeLibrary.

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

Как проверить, что реально экспортировано

Полагаться на предположения здесь опасно: расхождение между ожидаемым и фактическим списком экспорта — самая частая причина ошибок «точка входа не найдена». Проверить фактическое состояние можно несколькими способами:

  • утилитами из состава инструментов разработки, показывающими экспорты PE-файла (например, dumpbin с ключом /exports в экосистеме Visual Studio);
  • сторонними просмотрщиками PE-файлов, которые отображают имена, ординалы и адреса;
  • программно: загрузить DLL через LoadLibrary и попытаться получить каждую нужную функцию через GetProcAddress, логируя результаты.

Если имя в списке отличается от ожидаемого — почти всегда виновато декорирование C++ или лишнее соглашение о вызовах. Если функции нет вовсе — проверьте, действительно ли применён dllexport к этому объявлению и попала ли соответствующая единица трансляции в сборку.

Типичные ошибки и их последствия

  • Забытый extern «C» у C++-функции. Клиент ищет простое имя, а в таблице лежит декорированное. Результат — нулевой указатель от GetProcAddress. Решение: пометить экспорт extern «C» или сверять точное декорированное имя.
  • Несовпадение соглашений о вызовах в 32-битном коде. Может работать «почти нормально» и падать эпизодически, что сильно затрудняет диагностику. Решение: единое соглашение, зафиксированное в заголовке.
  • Экспорт объектов C++ и классов через границу DLL. Раскладка объектов, реализация STL и обработка исключений зависят от компилятора и его настроек. Передача таких объектов между модулями, собранными разными инструментами, ненадёжна. Решение: строить границу на C-подобном API или фабричных функциях, возвращающих указатели на абстрактные интерфейсы.
  • Изменение списка или порядка экспорта без учёта потребителей. Особенно болезненно при использовании ординалов. Решение: относиться к списку экспорта как к версиионируемому контракту.
  • Передача ресурсов через границу модулей. Память, выделенная в одной DLL, желательно должна освобождаться той же DLL, поскольку CRT у разных модулей может быть своим. Нарушение приводит к повреждению кучи. Решение: предоставлять парные функции выделения/освобождения в самой библиотеке.
  • Отсутствие проверки результатов LoadLibrary и GetProcAddress. Отсутствующая DLL или переименованная функция превращаются в мгновенное падение вместо понятного сообщения об ошибке.

Проектирование стабильного экспортируемого API

Хорошая практика для библиотек, которые будут жить долго и использоваться разными командами:

  • держать публичный интерфейс минимальным: экспортировать только то, что является контрактом;
  • использовать extern «C» для плоского набора функций либо фабрики, возвращающие указатели на интерфейсы с виртуальными методами;
  • фиксировать соглашение о вызовах в объявлениях явно;
  • избегать передачи типов STL, исключений и RTTI через границу, если нет гарантии единого компилятора и настроек;
  • предусмотреть функцию получения версии библиотеки — это упрощает диагностику у потребителей;
  • при выпуске новых версий сохранять существующие имена и сигнатуры, добавляя новое, а не меняя старое.

Сценарии: какой подход выбрать

  • Внутренняя библиотека для своих приложений, единый компилятор. Достаточно dllexport/dllimport с общим заголовком и неявным связыванием — минимум ручной работы.
  • Плагинная система. Явное связывание через LoadLibrary/GetProcAddress, узкий extern «C»-интерфейс из нескольких обязательных функций (инициализация, версия, освобождение). Основное приложение ничего не знает о плагинах на этапе сборки.
  • Библиотека для сторонних разработчиков с неизвестными компиляторами. Чистый C-API, недекорированные имена, при необходимости DEF-файл для контроля имён, документированное соглашение о вызовах.
  • Совместимость с унаследованным ПО, привязанным к ординалам. DEF-файл с зафиксированными номерами и запретом их пересмотра без мажорной смены версии.

Частые вопросы

Можно ли экспортировать переменные, а не только функции?

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

Почему функция есть в DLL, но GetProcAddress её не находит?

Почти всегда причина — расхождение имени: декорирование C++, суффикс соглашения о вызовах или опечатка. Сверьте строку поиска с фактическим списком экспортов модуля.

Обязательно ли поставлять .lib вместе с DLL?

Только для неявного связывания. При явном связывании через LoadLibrary и GetProcAddress библиотека импорта не нужна — достаточно самой DLL и знания имён экспорта.

Чем экспорт отличается от точки входа DllMain?

DllMain — служебная функция, которую система вызывает при загрузке и выгрузке модуля; она не предназначена для вызова клиентами и не требует экспорта. Экспортируемые функции — это публичный API, который вызывают потребители.

Что делать дальше

Главный принцип: экспорт — это контракт, который вы проектируете сознательно. Определите минимальный набор функций, закройте их extern «C», зафиксируйте соглашение о вызовах и общий заголовок с макросом dllexport/dllimport. Затем соберите DLL и сразу сверьте фактическую таблицу экспорта утилитой просмотра PE-файлов — это занимает минуты и снимает большинство будущих проблем интеграции. Для плагинов и сторонних потребителей дополнительно предусмотрите функцию версии и парные функции управления памятью.

PEFile.ru