Экспорт функций в DLL — это механизм, благодаря которому библиотека объявляет наружу список своих функций, а другие программы и библиотеки могут находить их по имени или порядковому номеру и вызывать. Если функция не экспортирована, она остаётся внутренней: загрузчик её не видит, и связаться с ней извне невозможно. Главный практический вывод: экспорт — это явное действие разработчика, а не автоматическое свойство любой функции в библиотеке.
Ниже разобрано, как устроен этот механизм на уровне таблицы экспорта, какие способы объявления экспорта существуют, чем они отличаются, что происходит с именами функций при компиляции C++ и как всё это выглядит со стороны вызывающего кода.
- Что происходит внутри: таблица экспорта
- Порядковые номера вместо имён
- Способы объявить экспорт
- __declspec(dllexport)
- DEF-файл
- Автоматический экспорт всего
- Декорирование имён: главная ловушка C++
- Соглашения о вызовах
- Сравнение способов экспорта
- Что видит потребитель: импорт
- Как проверить, что реально экспортировано
- Типичные ошибки и их последствия
- Проектирование стабильного экспортируемого API
- Сценарии: какой подход выбрать
- Частые вопросы
- Можно ли экспортировать переменные, а не только функции?
- Почему функция есть в DLL, но GetProcAddress её не находит?
- Обязательно ли поставлять .lib вместе с DLL?
- Чем экспорт отличается от точки входа DllMain?
- Что делать дальше
Что происходит внутри: таблица экспорта
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 подсказывает компилятору, что обращение идёт через таблицу импорта, и позволяет генерировать более эффективный код. При явном связывании заголовок необязателен, но программист должен сам:
- загрузить модуль через LoadLibrary и проверить результат;
- получить адрес функции через GetProcAddress по точному имени экспорта;
- привести полученный указатель к корректному типу функции с правильным соглашением о вызовах;
- вызывать функцию и после завершения работы освободить библиотеку через 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-файлов — это занимает минуты и снимает большинство будущих проблем интеграции. Для плагинов и сторонних потребителей дополнительно предусмотрите функцию версии и парные функции управления памятью.
