Какова ожидаемая связь модулей C++ и динамической связи?
Модули C++ TS предоставляют отличное средство для исключения препроцессора, улучшения времени компиляции и, как правило, поддержки гораздо более надежной, модульной разработки кода на C++, по крайней мере для не шаблонного кода.
Базовый механизм обеспечивает контроль над импортом и экспортом символов в обычных программах.
Однако существует серьезная проблема при разработке библиотек для двух видов динамической загрузки: загрузка во время запуска и загрузка во время выполнения. Эта проблема связана с экспортом символов из библиотеки, что часто обсуждается с точки зрения видимости.
Как правило, не все внешние символы блоков перевода, используемых для создания библиотеки динамических ссылок, должны быть видны пользователю. Кроме того, при загрузке во время выполнения, особенно с использованием концепции плагинов, один и тот же символ должен быть экспортирован из многих одновременно загружаемых библиотек.
На Windows использование языковых расширений
__declspec(dllexport)
__declspec(dllimport)
добавлены в исходный код в качестве атрибутов символов, а в последнее время в системах gcc и clang на платформах unix, использование
__attribute__((visibility("default")))
__attribute__((visibility("hidden")))
предназначены для поддержки предоставления и использования символов, предназначенных для обнародования библиотекой. Их использование является сложным и запутанным: в Windows макросы должны использоваться для экспорта символов во время компиляции библиотеки, но импортировать их при ее использовании. На платформах Unix видимость должна быть установлена по умолчанию как для экспорта, так и для импорта символов, компилятор решает сам, основываясь на том, найдено определение или нет: компилятор должен вызываться с
-fvisibility=hidden
переключатель. Атрибуты экспорта / импорта не требуются для статического связывания и, вероятно, должны быть преобразованы макросом в пустую строку. Создание кода и управление системой сборки таким образом, чтобы все это работало, особенно учитывая, что во время компиляции модулей перевода библиотеки в #include должен быть установлен правильный видимость символов, очень сложно, структура файла, требуемая в репозиториях, - беспорядок, исходный код замусорен с макросами и вообще.. все это катастрофа. Почти все репозитории с открытым исходным кодом НЕ ДОЛЖНЫ правильно экспортировать символы для динамического связывания, и большинство программистов понятия не имеют, что структура кода динамической библиотеки (с использованием двухуровневых пространств имен) сильно отличается от статического связывания.
Пример того, как это сделать (надеюсь, правильно) можно увидеть здесь:
https://github.com/calccrypto/uint256_t
Этот репозиторий имел 2 заголовка и 2 файла реализации, пользователь встроенной библиотеки видел бы 2 заголовка. Теперь имеется 7 заголовочных файлов и 2 файла реализации, и пользователь встроенной библиотеки увидит 5 заголовочных файлов (3 с расширением). include
чтобы указать, что они не должны быть непосредственно включены).
Поэтому после этого длинного объяснения возникает вопрос: поможет ли окончательная спецификация модулей C++ решить проблемы с экспортом и импортом символов для динамического связывания? Можем ли мы ожидать, что сможем разрабатывать для разделяемых библиотек, не загрязняя наш код специальными расширениями и макросами поставщика?
1 ответ
Модули не помогают вам с видимостью символов через границы DLL. Мы можем проверить это с помощью быстрого эксперимента.
// A.ixx
export module A;
export int f() { return 1; }
Здесь у нас есть простой файл интерфейса модуля, экспортирующий один символ в интерфейс модуля модуля (бывает, что он имеет то же имя, что и базовое имя файла, но это не обязательно). Давайте скомпилируем это так:
cl /c /std:c++20 /interface /TP A.ixx
The /c
флаг позволяет избежать вызова компоновщика (по умолчанию происходит автоматически),c++20
или более поздней версии требуется для работы синтаксиса модуля, а флаг позволяет компилятору узнать, что мы компилируем модуль интерфейса модуля. /TP
arg говорит «обрабатывать исходный ввод как ввод C++» и необходим, когда/interface
указано. Наконец, у нас есть входной файл.
Выполнение вышеуказанного создает файл интерфейсаA.ifc
и объектный файл. Обратите внимание, что нет файла импорта lib или файла exp, который можно было бы ожидать, если бы вы компилировали DLL.
Далее давайте напишем файл, который использует это.
// main.cpp
import A;
int main() { return f(); }
Чтобы скомпилировать это в исполняемый файл, мы можем использовать следующую команду:
cl /std:c++20 main.cpp A.obj
Наличие входа не является обязательным . Без него мы получим классическую ошибку компоновщика, заключающуюся в том, что это неразрешенный символ. Если мы запустим это, мы получимmain.exe
который статически связывает код в .
Что произойдет, если мы попытаемся скомпилировать в DLL? То есть, что если мы попытаемся создать DLL с компоновщиком из ? В ответ вы получаете DLL, но не импортируете lib или exp. Если вы попытаетесь запуститьlink /noentry /dll A.obj /out:A.dll
вы получите с ожидаемым/disasm
раздел (видимый через корзину), но нет экспортной таблицы.
Dump of file A.dll
File Type: DLL
0000000180001000: B8 01 00 00 00 mov eax,1
0000000180001005: C3 ret
Summary
1000 .rdata
1000 .text
Это дизассемблирование, в котором мы ожидаем, но проверка раздела экспорта с помощьюdumpbin /export A.dll
ничего не раскрывает. Причина, конечно, в том, что мы не экспортировали символ!
Если мы изменим исходный кодA.ixx
к следующему:
// A.ixx
export module A;
export __declspec(export) int f() { return 1; }
... мы можем повторить шаги (компилировать, связать), чтобы обнаружить, что на этот раз компоновщик создает файл импорта lib и exp, как мы и ожидали. Вызовdumpbin /exports A.lib
в созданной библиотеке импорта должно отображаться?f@@YAHXZ
присутствует символ.
Теперь мы можем снова связатьA.lib
(в отличие отA.obj
) с помощьюcl /std:c++20 main.cpp A.lib
для создания действительного исполняемого файла, на этот раз полагаясь на код вместо статического встраивания.
Мы можем проверить, что это действительно происходит, как и ожидалось в WinDbg.
Обратите внимание, что в нижней левой панели модулей присутствуетA.dll
. Обратите также внимание, что в представлении разборки в центре мы собираемся вызватьmain!f
. Ой, не хорошо. Хотя это правильно решает!A
модуль, он делает это через дополнительную косвенность в таблице адресов импорта, как показано здесь:
Это классическая проблема, которая возникает, когда вы забываете украсить функцию или символ символом__declspec(dllimport)
директива. Когда компилятор встречает символ без директивы, которую он не распознает, он выдает запись о перемещении, которая, как ожидается, будет разрешена во время компоновки. Вместе с этой записью он выдаетjmp
и неразрешенный адрес. Это классическая проблема, в которую я не буду вдаваться, но в результате у нас появляется дополнительная ненужная косвенность, поскольку ожидалось, что символ, распознанный как экспортированный из модуля, будет статически связан.
Оказывается, мы не можем исправить это легко. Если мы попытаемся добавить еще одно объявление вmain.cpp
или какую-либо другую единицу перевода, компоновщик будет жаловаться, что видитf
с «несовместимой связью dll». Единственный способ решить эту проблему — скомпилировать вторую версиюA
интерфейс модуля с украшениями (так же, как заголовки обычно имеют макросы, которые расширяются доdllexport
илиdllimport
в зависимости от ТУ, использующего заголовок).
Мораль этой истории заключается в том, что компоновка DLL и компоновка модуля хотя и не полностью противоречат друг другу, но и не особо совместимы. Модульexport
не включает экспортированные символы в таблицу экспорта, необходимую для разрешения символов через границы DLL. Кроме того, размещение этих символов в таблице экспорта по-прежнему оставляет вам проблему дополнительной косвенности после того, как неявная динамическая ссылка выполняется через таблицу адресов импорта.