May 5th, 2020

Создание библиотеки классов (.dll): теория

Начало тут:
1. библиотека классов;
2. создание библиотеки классов (в тексте);
3. создание библиотеки классов (объектный файл);
4. создание библиотеки классов (.lib).

Пост создан 5 мая 2020 г., исправлен и дополнен: 7 мая 2020 г.

Ненадолго вернемся к основам. Возвращаться будем очень глубоко, приблизительно и грубо. И излагаться всё это будет в моем понимании, которое может быть местами неточным, специфическим (моя сфера интересов: язык программирования C++, операционные системы «Windows», приложения для настольных компьютеров), а где-то, возможно, вообще ошибочным (но я надеюсь, что ошибок нет).

Процессор

Сердце компьютера — это процессор, он и выполняет все программы. У любого процессора есть фиксированный список команд (система команд). Так как процессор принимает на вход только поток нулей и единиц, каждая команда представляет собой последовательность нулей и единиц фиксированной длины.

Машинный язык

Получается, на самом нижнем (близком к процессору) уровне программа представляет из себя последовательность команд, а каждая команда представляет из себя последовательность нулей и единиц, которые называют машинными инструкциями. А язык программы машинных инструкций — машинный язык (машинный код). Понятно, что все команды на машинном языке представлены в двоичном коде (последовательности нулей и единиц).

ОЗУ, ПЗУ, операционная система

Откуда процессор берет программу на машинном языке (двоичный код) для выполнения? Из оперативной памяти (оперативное запоминающее устройство, ОЗУ).

Как и откуда программа на машинном языке (двоичный код) попадает в оперативную память? Изначально программа на машинном языке (двоичный код) хранится в исполняемом файле .exe на накопителе данных (постоянное запоминающее устройство, ПЗУ). Например, на жестком диске компьютера. Когда пользователь запускает исполняемый файл на выполнение, операционная система извлекает программу на машинном языке (двоичный код) из исполняемого файла на жестком диске и помещает (загружает) в оперативную память, откуда ее получает и выполняет процессор.

Компиляция (сборка)

Откуда берется исполняемый файл .exe, содержащий программу на машинном языке (двоичный код)? Он является результатом компиляции (перевода, «сборки») программы на языке высокого (далёкого от процессора) уровня (C++, Паскаль и тому подобных) в программу на языке низкого (близкого к процессору) уровня (машинный язык). То есть программа условно на человеческом языке (язык программирования высокого уровня) переводится (компилируется) в программу условно на компьютерном языке (языке низкого уровня).

Как происходит компиляция (перевод, «сборка») программы на языке высокого уровня в программу на машинном языке? Эту работу выполняет специальная программа, которая называется «компилятором». Для каждого языка высокого уровня нужен свой компилятор. Также для каждого языка высокого уровня существует куча компиляторов, написанных разными отдельными людьми или организациями (например, для языка C++ существуют компиляторы «Microsoft Visual C++» (MSVC), «GNU Compiler Collection» (GCC), «Minimalist GNU for Windows» (MinGW) и так далее).

Этапы компиляции, компоновщик

Обычно компиляцию (сборку) делят на три этапа: обработка директив препроцессора, трансляция, компоновка (связывание). Трансляция — это, собственно, и есть перевод с языка высокого уровня (исходный текст программы, исходные файлы .cpp и .h) на машинный язык (двоичный код, объектные файлы .obj). Часть компилятора, выполняющая последний этап, компоновку, называется компоновщиком (по-английски «linker», что дословно означает «связыватель»). На вход компоновщика поступают объектные файлы .obj, а на выходе получается исполняемый файл .exe.

Зачем нужен компоновщик и что он связывает?

При трансляции программы на языке высокого уровня в программу на машинном языке компилятор составляет списки (таблицы) идентификаторов («символов») различных программных сущностей (функций, переменных, структур данных и так далее). Для каждого объектного файла .obj составляются таблицы экспорта и импорта таких символов (идентификаторов). То есть, если компилятор в процессе трансляции встречает в программе обращение к некой, к примеру, функции, он записывает ее идентификатор в таблицу импорта. Если же компилятор встречает определение некой функции, он помещает ее адрес в таблицу экспорта. После этого компоновщик ищет связи между всеми элементами таблиц импорта и таблиц экспорта и связывает их. Если все связи найдены успешно, формируется исполняемый файл .exe с итоговой программой на машинном языке. Если какие-то связи не найдены, компилятор выдает сообщение об ошибке компоновки.

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

Статическое и динамическое связывание,
жизненный цикл программы

Статическим связыванием называется описанная выше работа компоновщика на этапе компиляции программы. Почему для этого понятия используется прилагательное «статический»? Жизненный цикл программы состоит из разных этапов (фаз), которые грубо можно разделить на две группы: до запуска пользователем исполняемого файла программы (проектирование, написание, компиляция [compile time] и тому подобные) и после его запуска (загрузка в оперативную память [load time], исполнение [runtime] и тому подобные). Пока программа не запущена на выполнение, считается, что она находится в состоянии покоя, то есть в статическом состоянии. Отсюда связывание во время статического состояния программы назвали статическим связыванием.

«Динамическое» связывание — аналогичная вышеописанной работа по связыванию элементов таблиц экспорта и таблиц импорта идентификаторов («символов»), но не на этапе покоя (создания) программы, а на этапе ее запуска и выполнения («динамический» этап существования программы).

Отсюда же существование понятий «статической» библиотеки функций или библиотеки классов (ее связывание с нашей программой происходит на этапе компиляции) и «динамической» (динамически подключаемой) библиотеки (DLL) (ее связывание с нашей программой происходит после запуска нашей программы на выполнение).

Между статическим и динамическим связыванием есть еще одно отличие. Статическое связывание выполняет на этапе компиляции программы (compile time) компоновщик, входящий в состав компилятора. А динамическое связывание на этапе загрузки программы в оперативную память (load time) или на этапе выполнения программы (runtime) выполняет компоновщик, являющийся частью операционной системы. Это совершенно разные программы.

Именно потому, что динамическое связывание выполняет компоновщик, являющийся частью операционной системы, эта область выходит за рамки стандарта языка программирования C++ и компания «Microsoft» вынуждена (а, может, ей того и надо, см. принцип «EEE») вводить нестандартные модификаторы.

Виды динамического связывания

Описанное выше динамическое связывание DLL и нашей программы, использующей DLL, подразделяется на два вида:
а) динамическое связывание на этапе загрузки (load time) нашей программы в оперативную память;
б) динамическое связывание на этапе выполнения (runtime) нашей программы.

В другой терминологии те же виды динамического связывания называют:
а) неявное связывание;
б) явное связывание.

Почему они называются «неявным» и «явным» связыванием? Чтобы связать DLL и нашу программу, использующую эту DLL, на этапе создания DLL необходимо пометить «экспортируемые» программные сущности (функции, классы, переменные и т.п.), которые будут использоваться прикладными программами. А на этапе создания нашей программы, использующей DLL, необходимо либо пометить «импортируемые» программные сущности и тогда компоновщик операционной системы неявно (незаметно для программиста) произведет динамическое связывание DLL и нашей программы на этапе загрузки (load time) нашей программы в оперативную память; либо программист явно (то есть самостоятельно, посредством вызова функций типа LoadLibrary и GetProcAddress) произведет загрузку нужной DLL и получение адреса нужной функции из этой DLL для последующего вызова этой функции по ее адресу на этапе выполнения (runtime) нашей программы.