Какова ожидаемая связь модулей 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или более поздней версии требуется для работы синтаксиса модуля, а флаг позволяет компилятору узнать, что мы компилируем модуль интерфейса модуля. /TParg говорит «обрабатывать исходный ввод как ввод 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. Кроме того, размещение этих символов в таблице экспорта по-прежнему оставляет вам проблему дополнительной косвенности после того, как неявная динамическая ссылка выполняется через таблицу адресов импорта.

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