Использование DLL в программе на Visual C++

Автор: Александр Шаргин

Версия текста: 2.0

Многие знают, что существует два основных способа подключить DLL к программе - явный и неявный.

При неявном подключении (implicit linking) линкеру передается библиотека импорта (обычно имеет расширение lib), содержащая список переменных и функций DLL, которые могут использовать приложения. Обнаружив, что программа обращается хотя бы к одной из них, линкер добавляет в целевой exe-файл таблицу импорта. Таблица импорта содержит список всех DLL, которые использует программа, с указанием конкретных переменных и функций, к которым она обращается. Позже, когда exe-файл будет запущен, загрузчик проецирует все DLL, перечисленные в таблице импорта, на адресное пространство процесса; в случае неудачи весь процесс немедленно завершается.

При явном подключении (explicit linking) приложение вызывает функцию LoadLibrary, чтобы загрузить DLL, затем использует функцию GetProcAddress, чтобы получить указатели на требуемые функции (или переменные), а по окончании работы с ними вызывает FreeLibrary, чтобы выгрузить библиотеку и освободить занимаемые ею ресурсы.

Каждый из способов имеет свои достоинства и недостатки. В случае неявного подключения все библиотеки, используемые приложением, загружаются в момент его запуска и остаются в памяти до его завершения (даже если другие запущенные приложения их не используют). Это может привести к нерациональному расходу памяти, а также заметно увеличить время загрузки приложения, если оно использует очень много различных библиотек. Кроме того, если хотя бы одна из неявно подключаемых библиотек отсутствует, работа приложения будет немедленно завершена. Явный метод лишен этих недостатков, но делает программирование более неудобным, поскольку требуется следить за своевременными вызовами LoadLibrary и соответствующими им вызовами FreeLibrary, а также получать адрес каждой функции через вызов GetProcAddress.

В Visual C++ 6.0 появился еще один способ подключения DLL, сочетающий в себе почти все достоинства двух рассмотренных ранее методов - отложенная загрузка DLL (delay-load DLL). Отложенная загрузка не требует поддержки со стороны операционной системы (а значит будет работать даже под Windows 95), а реализуется линкером Visual C++ 6.0. При отложенной загрузке DLL загружается только тогда, когда приложение обращается к одной из содержащихся в ней функций. Это происходит незаметно для программиста (то есть вызывать LoadLibrary/GetProcAddress не требуется). После того как работа с функциями библиотеки завершена, ее можно оставить в памяти или выгрузить посредством функции __FUnloadDelayLoadedDLL. Вызов этой функции - единственная модификация кода, которую может потребоваться сделать программисту (по сравнению с неявным подключением DLL). Если требуемая DLL не обнаружена, приложение аварийно завершается, но и здесь ситуацию можно исправить, перехватив исключение с помощью конструкции __try/__except. Как видим, отложенная загрузка DLL - весьма удобное средство для программиста.

Теперь рассмотрим, как каждый из перечисленных методов используется на практике. Для этого будем считать, что у нас есть библиотека MyDll.dll, которая экспортирует переменную Var, функцию Function и класс Class. Их объявления содержатся в заголовочном файле MyDll.h, который выглядит следующим образом:

#ifdef MYDLL_EXPORTS
#define MYDLL_API __declspec(dllexport)
#else
#define MYDLL_API __declspec(dllimport)
#endif

// Переменная
extern MYDLL_API int Var;

// Функция
MYDLL_API void Function(int, int);

// Класс
class MYDLL_API Class
{
private:
    int a;

public:
    Class();
    ~Class();

    int GetA();
    void SetA(int _a=0);

    virtual void VirtualFunc();

    static void StaticFunc();
    static int StaticVar;
};

Кроме того, будем считать, что библиотека импорта содержится в файле MyDll.lib.

Неявное подключение

Это наиболее простой метод подключения DLL к нашей программе. Все, что нам нужно - это передать линкеру имя библиотеки импорта, чтобы он использовал ее в процессе сборки. Сделать это можно различными способами.

Во-первых, можно непосредственно добавить файл MyDll.lib в проект посредством команды Project->Add to project->Files... Во-вторых, можно указать имя библиотеки импорта в опциях линкера. Для этого откройте окно настроек проекта (Project->Settings...) и добавьте в поле Object/Library modules на вкладке Link имя MyDll.lib. Наконец, можно встроить ссылку на библиотеку импорта прямо в исходный код программы. Для этого используется директива #pragma c ключем comment. В нашем случае необходимо вставить в программу строчку:

#pragma comment(lib,"MyDll.lib")

Теперь можно использовать в программе любые переменные, функции и классы, содержащиеся в DLL, как если бы они находились в статической библиотеке. Например:

#include <stdio.h>
#include "MyDll.h"

...

Var = 123;
printf("Var = %d\n", Var);

Function(0, 0);

Class c;
c.SetA(312);
printf("c.a = %d\n", c.GetA());
c.VirtualFunc();

Class::StaticVar = 231;
printf("Class::StaticVar = %d\n", Class::StaticVar);

Class::StaticFunc();

Явное подключение

Загрузка DLL

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

В нашем примере загрузка DLL выглядит так.

HMODULE hLib;
hLib = LoadLibrary("MyDll.dll");

Вызов функций

После того как библиотека загружена, адрес любой из содержащихся в ней функций можно получить с помощью GetProcAddress, которой необходимо передать дескриптор библиотеки и имя функции. Затем функцию из DLL можно вызывать, как обычно. Например:

void (*pFunction)(int, int);
(FARPROC &)pFunction = GetProcAddress(hLib, "Function");
pFunction(0, 0);

Обратите внимание на приведение указателя к ссылке на тип FARPROC. FARPROC - это указатель на функцию, которая не принимает параметров и возвращает int. Именно такой указатель возвращает функция GetProcAddress. Приведение типа необходимо, чтобы умиротворить компилятор, который строго следит за соответствием типов параметров оператора присваивания. Альтернативный подход заключается в использовании оператора typedef с последующим приведением значения, возвращаемого GetProcAddress, к указателю на функцию с нужным прототипом.

Доступ к переменным

Хотя это не всегда очевидно из документации, получить указатель на переменную из DLL можно, используя все ту же функцию GetProcAddress. В нашем примере это выглядит так.

int *pVar;
(FARPROC &)pVar = GetProcAddress(hLib, "Var");
*pVar = 123;

Использование классов

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

Однако предположим, что у нас нет доступа к исходным кодам библиотеки, содержащей класс, а использование других типов подключения DLL по каким-то причинам невозможно. Классом удастся воспользоваться и в этом случае, но для достижения цели придется проделать дополнительную работу.

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

Class *pc;
pc->VirtualFunc();

Приведенные выше рассуждения подсказывают решение проблемы. Коль скоро неявный вызов конструктра невозможен, мы можем вызвать его вручную, предварительно получив его адрес и выделив память под объект. Затем можно вызывать невиртуальные методы, получая их адреса с помощью GetProcAddress. Виртуальные методы можно вызывать, как обычно. Кроме того, необходимо не забыть вручную вызвать деструктор объекта, прежде чем выделенная для него память будет освобождена.

Продемонстрирую все сказанное на примере. Сначала мы выделяем память для объекта и вызываем для него конструктор. Память можно выделить как на стеке, так и в куче (с помощью оператора new). Рассмотрим оба варианта.

// Получаем указатель на конструктор
void (Class::*pConstructor)();
(FARPROC &)pConstructor = GetProcAddress(hLib, "Constructor");

// Создаём объект на стеке
char _c[sizeof(Class)];
Class &c = *(Class *)_c;

// Создаём объект в куче
char *_pc = new char[sizeof(Class)];
Class *pc = (Class *)_pc;

// Явно вызываем конструкторы для обоих объектов
(c.*pConstructor)();
(pc->*pConstructor)();

Обратите внимание на использование операторов .* и ->* для вызова функции-члена класса по указателю на нее. Этими операторами мы будем пользоваться и дальше.

ПРИМЕЧАНИЕ

Как правило, имена функций, экспортируемых из DLL, искажаются линкером. Поэтому вместо понятного имени, такого как "Constructor", получается совершенно нечитабельное имя вида "??0Class@@QAE@XZ". В рассматриваемом примере я назначил переменным и функциям нормальные имена при помощи def-файла следующего содержания:

LIBRARY MyDll
EXPORTS
Constructor = ??0Class@@QAE@XZ          PRIVATE
Destructor  = ??1Class@@QAE@XZ          PRIVATE
Function    = ?Function@@YAXHH@Z        PRIVATE
GetA        = ?GetA@Class@@QAEHXZ       PRIVATE
SetA        = ?SetA@Class@@QAEXH@Z      PRIVATE
StaticFunc  = ?StaticFunc@Class@@SAXXZ  PRIVATE
StaticVar   = ?StaticVar@Class@@2HA     PRIVATE
Var         = ?Var@@3HA                 PRIVATE

Невиртуальные методы класса вызываются так же, как и конструктор, например:

void (Class::*pSetA)(int = 0);
(FARPROC &)pSetA = GetProcAddress(hLib, "SetA");

(pc->*pSetA)(312);

int (Class::*pGetA)();
(FARPROC &)pGetA = GetProcAddress(hLib, "GetA");

printf("c.a = %d\n", (c.*pGetA)());

Виртуальные методы вызываются непосредственно (как это делается для обычных классов). Хотя DLL и экспортирует их, явно получать их адреса с помощью GetProcAddress не требуется. Отсюда следует вывод: если все методы класса являются виртуальными, использование объектов класса из явно подключаемой библиотеки практически ничем не отличается от использования объектов любого другого класса. Разница только в том, что конструктор и деструктор для таких объектов придется вызывать вручную.

В нашем примере виртуальная функция вызывается так.

c.VirtualFunc();
pc->VirtualFunc();

После того, как работа с объектом завершена, его нужно уничтожить, вызвав для него деструктор. Если объект был создан на стеке, деструктор необходимо вызвать до его выхода из области видимости, иначе возможны неприятные последствия (например, утечки памяти). Если объект был распределен при помощи new, его необходимо уничтожить перед вызовом delete. В нашем примере это выглядит так.

// Получаем указатель на деструктор
void (Class::*pDestructor)();
(FARPROC &)pDestructor = GetProcAddress(hLib, "Destructor");

// Уничтожаем объект, созданный на стеке
(c.*pDestructor)();

// Уничтожаем объект, созданный в куче
(pc->*pDestructor)();
delete [] _pc;

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

int *pStaticVar;
(FARPROC &)pStaticVar = GetProcAddress(hLib, "StaticVar");

*pStaticVar = 231;

void (*pStaticFunc)();
(FARPROC &)pStaticFunc = GetProcAddress(hLib, "StaticFunc");

pStaticFunc();

Выгрузка библиотеки

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

FreeLibrary(hLib);

Отложенная загрузка

Использование отложенной загрузки

Чтобы линкер мог встроить в программу функцию отложенной загрузки, ему необходимо передать библиотеку импорта DLL, а также статическую библиотеку Delayimp.lib, в которой содержатся вспомогательные функции механизма отложенной загрузки. Сделать это можно любым из способов, которые обсуждались в разделе о неявном подключении. Кроме того, нужно передать линкеру ключ /DELAYLOAD:<dll name>, сообщающий о нашем желании отложить загрузку DLL до фактического обращения к одной из ее функций. Этот ключ можно добавить в настройки проекта или встроить прямо в исходный код программы, используя директиву #pragma. Вот как это выглядит в нашем примере.

#pragma comment(lib, "MyDll.lib")
#pragma comment(lib, "Delayimp.lib")
#pragma comment(linker, "/DELAYLOAD:MyDll.dll")

Вот и все. Теперь можно использовать функции и классы DLL прозрачно, как и в случае с неявным подключением. Единственная проблема возникает с переменными: их невозможно использовать напрямую. Дело в том, что при обращении к одной из функций в DLL мы на самом деле вызываем функцию __delayLoadHelper, которая и выполняет загрузку DLL (если она еще не загружена), затем получает адрес функции с помощью GetProcAddress и перенаправляет все последующие вызовы функции по этому адресу. Но при обращении к переменной вызова функции не происходит, а значит использовать __delayLoadHelper не удается.

Проблема решается путем явного использования GetProcAddress при работе с переменными. Если DLL еще не загружена, ее придется загрузить явно с помощью LoadLibrary. Но если мы уже обращались к ее функциям и точно знаем, что она находится в памяти, мы можем получить ее дескриптор с помощью функции GetModuleHandle, которой необходимо передать имя DLL. В нашем примере это выглядит так.

Function(0, 0);

HMODULE hLib = GetModuleHandle("MyDll.dll");

int *pVar;
(FARPROC &)pVar = GetProcAddress(hLib, "Var");

*pVar = 123;
printf("Var = %d\n", *pVar);

Выгрузка библиотеки

Итак, мы установили, что при использовании отложенной загрузки DLL грузится в память, когда происходит обращение к одной из ее функций. Но в последствии нам может потребоваться выгрузить ее, чтобы не занимать зря системные ресурсы. Специально для этого предназначена функция __FUnloadDelayLoadedDLL, объявленная в файле Delayimp.h. Если вы планируете использовать ее, вам нужно задать еще один ключ линкера - /DELAY:UNLOAD. Например:

#include <Delayimp.h>
#pragma comment(linker, "/Delay:unload")

...

// Используем функции из библиотеки MyLib.dll

__FUnloadDelayLoadedDLL("MyLib.dll");

Имя, которое вы передаете функции __FUnloadDelayLoadedDLL, должно в точности соответствовать имени, заданному в ключе /DELAYLOAD. Если, к примеру, передать ей "MYLIB.DLL" или "mylib.dll", библиотека останется в памяти.

ПРЕДУПРЕЖДЕНИЕ

Не используйте FreeLibrary, чтобы выгрузить DLL с отложенной загрузкой.

Обработка исключений

Как я уже говорил, в случае ошибки функция __delayLoadHelper возбуждает исключение. Если нужная DLL не обнаружена, возбуждается исключение с кодом VcppException(ERROR_SEVERITY_ERROR, ERROR_MOD_NOT_FOUND). Если в DLL не обнаружена требуемая функция, исключение будет иметь код VcppException(ERROR_SEVERITY_ERROR, ERROR_PROC_NOT_FOUND).

ПРИМЕЧАНИЕ

VcppException - это макрос, который используется для формирования кода ошибки в подсистеме Visual C++. Первый параметр задает "степень серьезности" ошибки, а второй - код исключения.

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

// Фильтр исключений
LONG WINAPI DelayLoadFilter(DWORD code)
{
    switch(code)
    {
        case VcppException(ERROR_SEVERITY_ERROR, ERROR_MOD_NOT_FOUND):
        case VcppException(ERROR_SEVERITY_ERROR, ERROR_PROC_NOT_FOUND):
            return EXCEPTION_EXECUTE_HANDLER;
    }

    return EXCEPTION_CONTINUE_SEARCH;
}

...

__try
{
    // Основная программа: работаем с DLL отложенной загрузки.
}
__except(DelayLoadFilter(GetExceptionCode()))
{
    // Обработчик исключения
    switch(GetExceptionCode())
    {
    case VcppException(ERROR_SEVERITY_ERROR, ERROR_MOD_NOT_FOUND):
        MessageBox(NULL, "DLL not found", "Error", MB_OK);
        break;

    case VcppException(ERROR_SEVERITY_ERROR, ERROR_PROC_NOT_FOUND):
        MessageBox(NULL, "Function in DLL not found", "Error", MB_OK);
        break;
    }
}

Написанный вами фильтр исключений может также получить дополнительную информацию с помощью функции GetExceptionInformation. Эта функция возвращает указатель на структуру EXCEPTION_POINTERS. В ней содержится поле ExceptionRecord - указатель на структуру EXCEPTION_RECORD. А структура EXCEPTION_RECORD в свою очередь содержит поле ExceptionInformation[0], в которое __delayLoadHelper помещает указатель на структуру DelayLoadInfo, содержащую дополнительную информацию. Эта структура объявлена следующим образом (файл Delayimp.h).

typedef struct DelayLoadProc {
    BOOL                fImportByName;
    union {
        LPCSTR          szProcName;
        DWORD           dwOrdinal;
        };
    } DelayLoadProc;

typedef struct DelayLoadInfo {
    DWORD               cb;         // size of structure
    PCImgDelayDescr     pidd;       // raw form of data (everything is there)
    FARPROC *           ppfn;       // points to address of function to load
    LPCSTR              szDll;      // name of dll
    DelayLoadProc       dlp;        // name or ordinal of procedure
    HMODULE             hmodCur;    // the hInstance of the library we have loaded
    FARPROC             pfnCur;     // the actual function that will be called
    DWORD               dwLastError;// error received (if an error notification)
    } DelayLoadInfo, * PDelayLoadInfo;

В частности, вы можете извлечь из нее имя DLL (поле szDll), а также имя или порядковый номер функции, вызов которой привел к исключению (поле dlp).

Функции-ловушки

Если возможностей обработки исключений вам недостаточно, вы можете пойти еще дальше и вмешаться в работу __delayLoadHelper, используя функции-ловушки. В Visual C++ предусмотрено две таких функции: одна из них получает уведомления, вторая - сообщения об ошибках. Чтобы функция __delayLoadHelper могла обращаться к ним, их адреса нужно записать в глобальные переменные __pfnDliNotifyHook и __pfnDliFailureHook соответственно. Иногда обе функции объединяют в одну.

Функции-ловушки должны иметь следующий прототип:

FARPROC WINAPI DliHook
(
    unsigned        dliNotify,
    PDelayLoadInfo  pdli
);

Первый параметр функции содержит код уведомления или ошибки, второй - указатель на уже знакомую нам структуру DelayLoadInfo. Все возможные коды уведомления описаны в файле Delayimp.h при помощи следующего перечисления:

enum {
    dliStartProcessing,             // used to bypass or note helper only
    dliNotePreLoadLibrary,          // called just before LoadLibrary, can
                                    //  override w/ new HMODULE return val
    dliNotePreGetProcAddress,       // called just before GetProcAddress, can
                                    //  override w/ new FARPROC return value
    dliFailLoadLib,                 // failed to load library, fix it by
                                    //  returning a valid HMODULE
    dliFailGetProc,                 // failed to get proc address, fix it by
                                    //  returning a valid FARPROC
    dliNoteEndProcessing,           // called after all processing is done, no
                                    //  no bypass possible at this point except
                                    //  by longjmp()/throw()/RaiseException.
    };

В качестве примера приведу текст функции-ловушки, которая подменяет вызов функции SomeFunc на вызов функции YetAnotherFunc.

FARPROC WINAPI DliHook(unsigned dliNotify, PDelayLoadInfo pdli)
{
    if(dliNotify != dliNotePreGetProcAddress || pdli->dlp.fImportByName == 0)
        return 0;

    if(!strcmp(pdli->dlp.szProcName, "SomeFunc"))
        return GetProcAddress(pdli->hmodCur, "YetAnotherFunc");

    return 0;
}

PfnDliHook __pfnDliNotifyHook = DliHook;

Ошибка в Delayimp.lib

И последнее замечание. Иногда при попытке слинковать программу с библиотекой Delayimp.lib линкер выдает ошибку Access Violation и аварийно завершается. Это связано с тем, что в некоторых дистрибутивах Visual C++ распространяется поврежденный файл Delayimp.lib. Если у вас возникла такая проблема, загрузите корректную версию файла здесь и скопируйте его в каталог %Visual Studio Folder%\Vc98\Lib\.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.