Статические и динамические библиотеки
Самый непосредственный способ построения любой программы — это объединение исходных кодов всех функций, их компиляция и компоновка всех необходимых элементов в один исполняемый модуль. Чтобы упростить процесс сборки, такие функции общего назначения, как ReportError, можно поместить в библиотеку. Этот подход применялся во всех представленных до сих пор примерах программ, хотя и касался всего лишь нескольких функций, большинство из которых предназначались для вывода сообщений об ошибках.
Эта монолитная, одномодульная модель отличается простотой, однако обладает и рядом недостатков.
• Исполняемый модуль может разрастаться до больших размеров, занимая большие объемы дискового пространства и физической памяти во время выполнения и требуя дополнительных усилий для организации управления модулем и передачи его пользователям.
• При каждом обновлении потребуется повторная сборка всей программы, даже если необходимые изменения незначительны или носят локальный характер.
• Каждый исполняемый модуль тех программ в системе, которые используют эти функции, будет иметь свои экземпляры функций, версии которых могут различаться. Подобная схема компоновки приводит к тому, что при одновременном выполнении нескольких таких программ будет напрасно расходоваться дисковое пространство и, что намного существеннее, физическая память.
• Для достижения наилучшей производительности в различных средах может потребоваться использование различных версий программы, в которых применяются различные методики. Так, функция Asc2Un в программе 2.4 (atou) и программе 5.3 (Asc2UnMM) реализована по-разному. Единственный способ выполнения программ, имеющих несколько различных реализаций, — это заранее принять решение относительно того, какую из двух версий запускать, исходя из свойств окружения.
Библиотеки DLL обеспечивают возможность элегантного решения этих и других проблем.
• Библиотечные функции не связываются во время компоновки. Вместо этого их связывание осуществляется во время загрузки программы (неявное связывание) или во время ее выполнения(явное связывание). Это позволяет существенно уменьшить размер модуля программы, поскольку библиотечные функции в него не включаются.
• DLL могут использоваться для создания общих библиотек (shared libraries). Одну и ту же библиотеку DLL могут совместно использовать несколько одновременно выполняющихся программ, но в память будет загружена только одна ее копия. Все программы отображают код DLL на адресные пространства своих процессов, хотя каждый поток будет иметь собственный экземпляр неразделяемого хранилища в стеке. Например, функция ReportError использовалась почти в каждом из приведенных ранее примеров программ, тогда как для всех программ было бы вполне достаточно ее единственной DLL-реализации.
• Новые версии программ или другие возможные варианты их реализации могут поддерживаться путем простого предоставления новой версии DLL, а все программы, использующие эту библиотеку, могут выполняться как новая версия без внесения каких бы то ни было дополнительных изменений.
• В случае явного связывания решение о том, какую версию библиотеки использовать, программа может принимать во время выполнения. Разные библиотеки могут быть альтернативными вариантами реализации одной и той же функции или решать совершенно иные задачи, как если бы они были независимыми программами. Библиотека выполняется в том же процессе и том же потоке, что и вызывающая программа.
Библиотеки DLL, иногда в ограниченном виде, используются практически в любой ОС. Так, в UNIX аналогичные библиотеки фигурируют под названием "разделяемых библиотек" (shared libraries). В Windows библиотеки DLL используются, помимо прочего, для создания интерфейсов ОС. Весь Windows API поддерживается одной DLL, которая для предоставления дополнительных услуг вызывает ядро Windows.
Один код DLL может совместно использоваться несколькими процессами Windows, но после его вызова он выполняется как часть вызывающего процесса или потока, Поэтому библиотека может использовать ресурсы вызывающего процесса, например дескрипторы файлов, и стек потока. Следовательно, DLL должны создаваться таким образом, чтобы обеспечивалась безопасная многопоточная поддержка (thread safety). (Более подробная информация относительно DLL и безопасной многопоточной поддержки содержится в главах 8, 9 и 10. Методы создания DLL, предоставляющих многопоточную поддержку, иллюстрируются программами 12.4 и 12.5.) Кроме того, DLL могут экспортировать переменные, а также точки входа функций.
Неявное связывание
Неявное связывание, или связывание во время загрузки (load-time linking) является простейшей из двух методик связывания. Порядок действий в случае использования Microsoft C++ следующий:
1. После того как собраны все необходимые для новой DLL функции, осуществляется сборка DLL, а не, например, консольного приложения.
2. В процессе сборки создается библиотечный .LIB-файл, играющий роль заглушки (stub) для фактического кода. Этот файл должен помещаться в каталог библиотек общего пользования, указанный в проекте.
3. В процессе сборки создается также .DLL-файл, содержащий исполняемый модуль. В типичных случаях этот файл размещается в том же каталоге, что и приложение, которое будет его использовать, и приложение загружает DLL в процессе своей инициализации. Вторым возможным местом расположения DLL является рабочий каталог, а далее ОС будет осуществлять поиск .DLL-файла в системном каталоге, каталоге Windows, а также в путях доступа, указанных в переменной окружения PATH.
4. В исходном коде DLL следует предусмотреть экспортирование интерфейсов функций, о чем рассказано ниже.
2. Управление потоками. Внутреннее устройство потока.
В DLL содержатся все функции Windows API. Три самые важные DLL:
Kernel32.dll (управление памятью, процессами и потоками), User32.dll
(поддержка пользовательского интерфейса, в том числе, функции, связанные с
созданием окон и передачей сообщений) и GDI32.dll (графика и вывод
текста).
В Windows есть другие DLL:
AdvAPI32.dll – содержит функции для защиты объектов, работы с
реестром и регистрации событий
ComDlg32.dll – стандартные диалоговые окна (вроде FileOpen и
FileSave)
ComCtl32.dll – поддерживает стандартные элементы управления
DLL нужно применять для реализации следующих возможностей:
1. Расширение функциональности приложения.
2. Возможность использования разных языков программирования.
3. Более простое управление проектом.
4. Экономия памяти..
5. Разделение ресурсов..
6. Упрощение локализации.
7. Решение проблем, связанных с особенностями различных
платформ..
8. Реализация специфических возможностей.
DLL и адресное пространство процесса.
DLL представляет собой набор модулей исходного кода, в каждом их
которых содержится определенное число функций, вызываемых приложением
или другим DLL. Причем в DLL обычно нет кода, предназначенного для
обработки циклов выборки сообщений и создания окон.
Файлы с исходным кодом компилируются и компонуются также, как и при
создании EXE-файла, но при компоновке нужно указать ключ /DLL.
Чтобы приложение (или другая DLL) могла вызывать функции,
содержащиеся в DLL, образ ее файла нужно сначала спроецировать на адресное
пространство вызывающего процесса. Это выполняется за счет неявного
связывания при загрузке, либо за счет явного – в период выполнения. Теперь
все функции DLL доступны всем потокам этого процесса. Когда поток
вызывает из DLL какую-то функцию, та считывает свои параметры из списка
потока и размещает в этом стеке собственные локальные переменные. Кроме
того, любые созданные кодом объекты принадлежат вызывающему потоку или
процессу – DLL ничем не владеет.
При проецировании образа DLL-файла на адресное пространство процесса
система создает также экземпляры глобальных и статических переменных.
Неявное связывание EXE – и DLL – модулей
Неявное связывание (implicit linking) - самый распространенный метод.
Исполняемый модуль (EXE) импортирует функции и переменные из DLL, а
DLL– модули экспортирует их в исполняемый модуль. DLL – также может
импортировать функции и переменные их других DLL.
Создание DLL-модуля
DLL может экспортировать переменные, функции или С++ классы в другие
модули.
При разработке DLL сначала создается заголовочный файл, который
включается во все модули исходного кода нашей DLL. Кроме того, его нужно
поставлять вместе со своей DLL, чтобы другие разработчики могли включать
его в свои модули исходного кода, которые импортируют наши функции или
переменные. Единый заголовочный файл, используемый при сборке DLL и
любых исполняемых модулей, существенно облегчает поддержку приложения.
При компиляции исходного файла DLL MYLIBAPI определяется как
__declspec (dllexport) до включения заголовочного файла MyLib.h. Такой
модификатор означает, что данная переменная, функция или C++ класс
экспортируется из DLL.
Также следует обратить внимание, что в файле MyLibFile1.cpp перед
экспортируемой переменной или функцией не ставится идентификатор
MYLIBAPI. Он здесь не нужен: проанализировав заголовочный файл,
компилятор запоминает, какие переменные и функции являются
экспортируемыми.
Идентификатор MYLIBAPI включает extern. Модификатор extern не даёт
компилятору искажать имена переменных или функции, и они становятся
доступными исполняемым модулям, написанным на С, С++ или любом другом
языке программирования. Этим модификатором можно пользоваться только в
коде С++, но ни в коем случае ни в коде на стандартном С.
Мы рассмотрели, как используется заголовочный файл в исходных файлах
DLL.
А в исходных файлах ЕХЕ-модуля MYLIBAPI определять не надо: включая
заголовочный файл, вы определяете этот идентификатор как –
DLLSPEC(DLLIMPORT), и при помещении исходного кода ЕХЕ-модуля
компилятор поймёт, что переменные и функции импортируются из DLL.
Что такое экспорт
Если перед переменной, прототипом функции или С++ классом указан
модификатор – _deсlspec(dllexport), компилятор Microsoft C/C++ встраивает
в конечный obj-файл дополнительную информацию. Она понадобится при
сборке DLL из OBJ-файлов.
Обнаружив такую информацию, компоновщик создает LIB-файл со списком
идентификаторов, экспортируемых из DLL. Этот LIB-файл нужен при сборке
любого EXE-модуля, ссылающегося на такие идентификаторы. Компоновщик
также вставляет в конечный DLL-файл таблицу экспортируемых
идентификаторов – раздел экспорта, в котором содержится список (в
алфавитном порядке) идентификаторов экспортируемых функций, переменных
и классов. Туда же помещается относительный виртуальный адрес (relative
virtual address, RVA) каждого идентификатора внутри DLL-модуля.
Создание EXE-модуля
Вот пример исходного кода EXE-модуля, который импортирует
идентификаторы, экспортируемые DLL, и ссылается на них внутри в процессе
выполнения.
//Модуль: MyExeFilel.cpp
//Сюда включаются стандартные заголовочные файлы Windows и
библиотеки С
#include <windows.h>
//включаем экспортируемые структуры данных, идентификаторы,
функции и переменные
#include “MyLib\MyLib.h”
….
int WINAPI WinMain(HINSTANCE hinstExe, HINSTANCE,
LPTSTR pszCmdLine, int)
{
int nLeft=10, nRight=25;
TCHAR sz[100];
wsprintf(sz, TEXT(“%d + %d = %d”), nLeft, nRight,
Add(nLeft, nRight));
MessageBox(NULL, sz, TEXT(“Calculation”), MB_OK);
wsprintf(sz, TEXT(“The result from the last Add is: %d”),
g_nResult);
MessageBox(NULL, sz, TEXT(“Last Result”), MB_OK);
return (0);
}
Создавая фалы исходного кода для EXE-модуля, нужно включить в них
заголовочный файл DLL, иначе импортируемые идентификаторы окажутся
неопределенными, и компилятор выдаст массу предупреждений об ошибках.
MYLIBAPI в исходных файлах EXE-модуля до заголовочного файла DLL не
определяется. Поэтому при компиляции приведенного выше кода MYLIBAPI за
счет заголовочного файла MyLib.h будет определен как __declspec(dllimport).
Встречая такой модификатор перед именем переменной, функции или С++
класса, компилятор понимает, что данный идентификатор импортируется из
какого-то DLL –модуля.
Далее компоновщик собирает все OBJ-модули в конечный EXE-модуль. Для
этого он должен знать, в каких DLL содержаться импортируемые
идентификаторы, на которые есть ссылки в коде. Информацию об этом он
получает из передаваемого ему LIB-файла (в котором указан список
идентификаторов, экспортируемых DLL)
Что такое импорт
Импортируя идентификатор, необязательно прибегать к
__declspec(dllimport) – можно использовать стандартное ключевое слово
extern языка С. Но компилятор создаст чуть более эффективный код, если ему
будет заранее известно, что идентификатор, на который мы ссылаемся,
импортируется из LIB-файла DLL –модуля.
Разрешая ссылки на импортируемые идентификаторы, компоновщик создаст
в конечном EXE-модуле раздел импорта (import section).
В нем перечисляются DLL, необходимые этому модулю, и идентификаторы,
на которые есть ссылки из всех используемых DLL.
Выполнение EXE-модуля
При запуске EXE-файла загрузчик операционной системы создает для его
процесса виртуальное пространство и проецирует на него исполняемый модуль.
Далее загрузчик анализирует раздел импорта и пытается спроецировать все
необходимые DLL на адресное пространство процесса.
Поскольку в разделе импорта указано только имя DLL (без пути), загрузчику
приходится самому искать ее на дисковых устройствах в компьютере
пользователя. Поиск DLL осуществляется в следующей последовательности:
1. Каталог, содержащий EXE-файл
2. Текущий каталог процесса
3. Системный каталог Windows
4. Основной каталог Windows
5. Каталоги, указанные в переменной окружения PATH
Проецируя DLL-модули на адресное пространство, загрузчик проверяет в
каждом из них раздел импорта. Если у DLL есть раздел импорта (что обычно и
бывает), загрузчик проецирует следующий DLL-модуль. При этом загрузчик
ведет учет загружаемых DLL, и проецирует их только один раз, даже если
загрузки этих DLL требуют и другие модули.
Найдя и спроецировав на адресное пространство процесса все необходимые
DLL-модули, загрузчик настраивает ссылки на импортируемые
идентификаторы. Для этого вновь просматривает разделы импорта в каждом
модуле, проверяя наличие указанного идентификатора в соответствующей
DLL.
Обнаружив идентификатор, загрузчик отыскивает его RVA и прибавляет к
виртуальному адресу, по которому данная DLL размещена в адресное
пространство процесса, а затем сохраняет полученный адрес в разделе импорта
EXE-модуля. И с этого момента ссылка в коде на импортируемый
идентификатор приводит к выборке его адреса из раздела импорта
вызывающего модуля, открывая таким образом доступ к импортируемой
переменной, функции или функции-члену С++ класса. Как только
динамические связи будут установлены, первичный поток процесса начинает
выполняться.
Загрузчик всех этих DLL и настройка ссылок занимает какое-то время.
Чтобы сократить время загрузки приложения нужно модифицировать базовые
адреса EXE- и DLL-модулей и провести их связывание.
Явная загрузка DLL и связывание идентификаторов
Чтобы поток мог вызвать функцию из DLL-модуля, DLL надо спроецировать
на адресное пространство процесса, которому принадлежит этот поток.
Делается это двумя способами:
1. Код приложения просто ссылается на идентификаторы, содержащиеся в
DLL, и тем самым заставляет загрузчик неявно загружать (и связывать)
нужную DLL при запуске приложения.
2. Явная загрузка и связывание требуемой DLL в период выполнения
приложения. Иначе говоря, поток явно загружает DLL в адресное
пространство процесса, получает виртуальный адрес необходимой DLL-
функции и взывает ее по этому адресу. Изящество этого подхода в том,
что все происходит в уже выполняемом приложении.
2. Управление потоками. Внутреннее устройство потока.
Управление потоками
Определение потока связано с последовательностью действий
процессора во время исполнения программы. Такая последовательность
выполнения инструкций программы называется потоком управления внутри
программы.
Программа является многопоточной, если в ней может одновременно
существовать несколько потоков. Потоки в этом случае называются
параллельными. Если в программе одновременно может существовать только
один поток, то такая программа называется однопотонной.
3. Идентификация потока.
Функции, используемые для получения идентификаторов (ID) и дескрипторов потоков, напоминают те, которые используются для аналогичных целей в случае процессов.
• GetCurrentThread — возвращает ненаследуемый псевдодескриптор вызывающего потока.
• GetCurrentThreadId — позволяет получить идентификатор потока, а не его дескриптор.
• GetThreadId — позволяет получить идентификатор потока, если известен его дескриптор; эта функция требует использования Windows Server 2003.
• OpenThread — создает дескриптор потока по известному идентификатору.
В программе JobShell (программа 6.3) нам очень пригодилась функция OpenProcess, и функция OpenThread может применяться для аналогичных целей.