Преамбула


Использование динамически связываемых библиотек (DLL), как известно, предполагает один из двух способов подключения: связывание во время загрузки (load-time linking) и связывание во время выполнения (run-time linking). В последнем случае нужно использовать предоставляемый операционной системой API для загрузки нужного модуля (библиотеки) и поиска в нем адреса необходимой процедуры. Существует множество оберток, но, к сожалению, все встречавшиеся мне сильно усложнены и перегружены лишним кодом. Предлагаемое решение изначально предназначено для вызова функций, хранящихся в DLL из исполняемых модулей (EXE), отличается относительной простотой реализации, и (что гораздо более важно) простотой использования в клиентском коде.

Решение с использованием чистого Win32 API выглядит примерно так (пратически, это повторение фрагмента из MSDN):

typedef int (__cdecl *some_proc_t)(LPWSTR);

HINSTANCE hlib = LoadLibrary(_T("some.dll"));
myproc_t proc_addr = NULL;
int result = -1;

if (hlib) {
    proc_addr = (some_proc_t) GetProcAddress(hlib, "SomeProcName");
    if (proc_addr) {
        result = proc_addr(L"send some string to DLL function");
        printf("Successfully called DLL procedure with result %d", result);
    }
    FreeLibrary("some.dll");
}

В этой статье предлагается простая в использовании обертка над этими системными вызовами. Пример использования:

ntprocedure<int(LPWSTR)> some_proc_("SomeProcName", _T("some.dll"));
try {
    int result = some_proc_(L"send some string to DLL function");
    printf("Successfully called DLL procedure with result %d", result);
} catch (...) {
    printf("Failed to call DLL procedure");
}

Как видно из листинга, все, что нужно сделать — создать объект ntprocedure с шаблонными параметрами, которые соответствуют типу вызываемой функции, передав в конструкторе её имя и имя библиотеки.

Реализация


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

Файл common.h
#pragma once

#include "tchar.h"
#include <string>

#define NS_BEGIN_(A) namespace A {
#define NS_BEGIN_A_ namespace {
#define NS_END_ }

#define NO_EXCEPT_ throw()
#define THROW_(E) throw(E)

#define PROHIBITED_ = delete

//=============================================================================
typedef std::basic_string<
  TCHAR, std::char_traits<TCHAR>, std::allocator<TCHAR> > tstring;


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

R operator () (Args ... args)

В действительности, в нашем случае можно поступить гораздо проще, а именно, использовать оператор приведения вместо оператора вызова функции. Если T является типом указателя на функцию, а address является переменной, в которой хранится ее адрес, можно определить следующий оператор:

operator T()
{
    return reinterpret_cast<T>(address);
}

Ниже приводится полный код заголовочного файла «ntprocedure.h».

Файл ntprocedure.h
#pragma once

#include "common.h"

#include <memory>
#include <string>
#include <type_traits>

NS_BEGIN_(ntutils)
NS_BEGIN_(detail)

class ntmodule;

class ntprocedure_base {

  ntprocedure_base(const ntprocedure_base&) PROHIBITED_;
  void operator=(const ntprocedure_base&) PROHIBITED_;

public:

  ntprocedure_base(const std::string& a_proc_name, const tstring& a_lib_name);
  // Constructor.

  virtual ~ntprocedure_base() = 0;
  // Destructor.

  FARPROC WINAPI address();
  // Get the procedure address.

  const std::string& name() const;
  // Get the procedure name.

private:

  std::string m_name;
  std::shared_ptr<ntmodule> m_module;
};

NS_END_

template<typename T> class ntprocedure : public detail::ntprocedure_base {
public:

  typedef typename std::remove_pointer<T>::type callable_t;
  typedef callable_t *callable_ptr_t;

  ntprocedure(const std::string& a_proc_name, const tstring& a_lib_name)
  : ntprocedure_base(a_proc_name, a_lib_name),
    m_function(nullptr)
  {    
  }
  // Constructor.

  virtual ~ntprocedure()
  {
  }
  // Destructor.

  operator callable_ptr_t()
  {
    if (!m_function) {
      m_function = reinterpret_cast<callable_ptr_t>(address());
    }

    return m_function;
  }
  // Return stored function to invoke.

private:

  callable_ptr_t m_function;    
};

NS_END_


Пара моментов, которые заметил внимательный читатель — адрес процедуры хранится в переменной m_function и вычисляется один раз, и второй момент — в базовом классе хранится разделяемый указатель на объект класса ntmodule. Нетрудно догадаться, что он хранит информацию о загруженном модуле. Использование shared_ptr позволяет автоматически выгрузить модуль после уничтожения всех объектов-процедур, которые его используют.

Файл ntmodule.h
#pragma once

#include "common.h"
#include "resource_ptr.h"

#include <list>
#include <memory>

NS_BEGIN_(ntutils)
NS_BEGIN_(detail)

class ntmodule : public std::enable_shared_from_this<ntmodule> {

  ntmodule(const ntmodule&) PROHIBITED_;
  void operator=(const ntmodule&) PROHIBITED_;

public:

  typedef std::list<ntmodule*> container_t;

  ntmodule(const tstring& a_name);
  // Constructor.

  ~ntmodule();
  // Destructor.

  const tstring& name() const;
  // Get the module name.      

  FARPROC WINAPI address(const std::string& a_name);
  // Get the procedure address.

  std::shared_ptr<ntmodule> share();
  // Share this object.

  static container_t& cached();
  // Return the reference to the cache.

private:

  tstring m_name;
  hmodule_ptr m_handle;
};

NS_END_
NS_END_


Рассмотрим определение класса ntmodule:

Файл ntmodule.cpp
#include "stdafx.h"
#include "ntmodule.h"

#include "ntprocedure.h"
#include <cassert>
#include <exception>

ntutils::detail::ntmodule::ntmodule(const tstring& a_name)
: m_name(a_name)
{
  assert(!a_name.empty());

  cached().push_back(this);
}

ntutils::detail::ntmodule::~ntmodule()
{
  cached().remove(this);
}

const tstring& ntutils::detail::ntmodule::name() const
{
  return m_name;
}

FARPROC WINAPI ntutils::detail::ntmodule::address(
  const std::string& a_name
)
{
  assert(!a_name.empty());

  if (!m_handle) {
    m_handle.reset(::LoadLibrary(m_name.c_str()));    
  }

  if (!m_handle) {
    std::string err("LoadLibrary failed");
    throw std::runtime_error(err);
  }

  return m_handle ? ::GetProcAddress(m_handle, a_name.c_str()) : 0;
}

std::shared_ptr<ntutils::detail::ntmodule>
ntutils::detail::ntmodule::share()
{
  return shared_from_this();
}

ntutils::detail::ntmodule::container_t&
ntutils::detail::ntmodule::cached()
{
  static container_t* modules = new container_t;
  return *modules;
}


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

Файл ntprocedure.cpp
#include "stdafx.h"

#include "ntmodule.h"
#include "ntprocedure.h"

#include <cassert>
#include <exception>

ntutils::detail::ntprocedure_base::ntprocedure_base(
  const std::string& a_proc_name, const tstring& a_lib_name
)
: m_name(a_proc_name),
  m_module(nullptr)
{
  assert(!a_proc_name.empty());
  assert(!a_lib_name.empty());

  for (auto module : ntmodule::cached()) {
    // Perform case insensitive comparison:
    if (!lstrcmpi(module->name().c_str(), a_lib_name.c_str())) {
      m_module = module->share();
      break;
    }
  }

  if (!m_module) {
    m_module = std::make_shared<ntmodule>(a_lib_name);
  }
}

ntutils::detail::ntprocedure_base::~ntprocedure_base()
{
}

FARPROC WINAPI ntutils::detail::ntprocedure_base::address()
{
  FARPROC addr = m_module->address(m_name);
  if (!addr) {
    std::string err("GetProcAddress failed");
 
    throw std::runtime_error(err);
  }

  return addr;
}

const std::string& ntutils::detail::ntprocedure_base::name() const
{
  return m_name;
}


В конструкторе ntprocedure_base происходит поиск нужного модуля в статическом списке по его имени. Если такой модуль найден, то вызов module->share() создает разделяемый указатель на основе имеющегося в списке указателя, если же такого модуля еще нет, создается новый объект.

Обратите внимание, что для каждого впервые используемого нами модуля мы вызываем LoadLibrary(), не полагаясь на функцию GetModuleHandle() и уже потом контролируем созданные объекты посредством shared_ptr. Это делает безопасным использование созданной обертки совместно в одном проекте с кодом, использующим непосредственные вызовы LoadLibrary() и FreeLibrary().

На этом все. Ах, да, в коде фигурирует тип resouce_ptr. Это ничто иное, как RAII-обертка над такими типами, как HANDLE, HMODULE и так далее. Для тех, кому интерено, привожу реализацию:

Файл resource_ptr.h
#pragma once

#include "common.h"
#include "windows.h"

#include <cassert>
#include <memory>

NS_BEGIN_(ntutils)

template<typename HTag_>
struct resource_close {
  void operator()(typename HTag_::handle_t) const NO_EXCEPT_;
};

struct handle_tag {
  typedef HANDLE resource_t;
};

struct hmodule_tag {
  typedef HMODULE resource_t;
};

template<> struct resource_close<handle_tag> {
  
  void operator()(handle_tag::resource_t a_handle) const NO_EXCEPT_
  {
    bool status = !!::CloseHandle(a_handle);
    assert(status);
  }

};

template<> struct resource_close<hmodule_tag> {

  void operator()(hmodule_tag::resource_t a_handle) const NO_EXCEPT_
  {
    bool status = !!::FreeLibrary(a_handle);
    assert(status);
  }

};

template<
  typename RTag_,
  typename RTag_::resource_t RInvalid_,
  typename RFree_ = resource_close<RTag_>
>
class resource_ptr {

  typedef typename RTag_::resource_t resource_t;
  typedef RFree_ deletor_t;

  resource_ptr(const resource_ptr&) PROHIBITED_;
  void operator=(const resource_ptr&) PROHIBITED_;

public:

  resource_ptr() NO_EXCEPT_
  : m_resource(RInvalid_)
  {
  }
  resource_ptr(resource_t a_resource) NO_EXCEPT_
  : m_resource(a_resource)
  {  
  }
  // Constructor.

  explicit operator bool() const NO_EXCEPT_
  {
    return m_resource && m_resource != RInvalid_;
  }
  // Operator bool().

  operator const resource_t&() const NO_EXCEPT_
  {
    return m_resource;
  }
  // Get the stored handle value.

  void reset(resource_t a_resource = resource_t()) NO_EXCEPT_
  {
    resource_t old = m_resource;
    m_resource = a_resource;
    if (old != resource_t() && old != RInvalid_) {
      m_deletor(old);
    }
  }

  ~resource_ptr() NO_EXCEPT_
  {
    if (m_resource != resource_t() && m_resource != RInvalid_) {
      m_deletor(m_resource);
    }
  }
  // Destructor.

private:

  resource_t m_resource;
  deletor_t m_deletor;
};

typedef resource_ptr<handle_tag, INVALID_HANDLE_VALUE> handle_ptr;
typedef resource_ptr<hmodule_tag, NULL> hmodule_ptr;

NS_END_


На этом точно все. Спасибо за внимание, буду рад услышать ваши комментарии!

Комментарии (6)


  1. gurux13
    14.09.2015 18:34
    +2

    Комментарии? Их есть у меня.

    1. Каждое создание функции приводит к линейному поиску среди списка модулей. unordred_map?
    2. Кажется, это всё не потокобезопасно
    3. По процедуре нельзя получить её модуль. Это не даёт возможности управлять вызовами LoadLibrary/FreeLibrary, а это может быть важно.
    4. FreeLibrary очень уж далеко от LoadLibrary
    5. Почему-то, если FreeLibrary упало, то assert(), а LoadLibrary проверяется, судя по всему, в момент вызова процедуры. Если LoadLibrary вернул NULL, получится вызов нулевого указателя. Это не UB, случаем?


    А в целом — идея интересная, особенно оператор приведения типа — в голову такое не приходило.


  1. boov
    14.09.2015 20:01
    +1

    Справедливости ради, стоит отметить, что кроме двух упомянутых в статье способов работы с dll, есть еще один, так называемый delay load режим. По сути это нечто среднее между load-time и run-time режимами.


    1. khim
      14.09.2015 23:07

      А как он выглядит? Как человеку, работавшему в основном в Linux'е мне бы хотелось видеть что-нибудь подобное вот этому:

      extern void __cxa_finalize (void *) TARGET_ATTRIBUTE_WEAK;
      ...
      if (__cxa_finalize)
          __cxa_finalize (__dso_handle);
      
      Всё. Если функция нашлась — её можно вызвать. Напрямую и без всяких обёрток. Не нашлась — точно так же можно проверить, что это случилось — и тоже напрямую и без всякого C++.

      P.S. Вообще же меня поражает насколько Linux и Windows протовиположны: в Linux «внизу» — чёткая архитектура, всё удобно, красиво и причёсано, но чем ближе «к человеку» тем более растрёпанным всё выглядит — CLI уже не так удобны как API, а GUI часто вообще что-то с чем-то. В Windows — всё равно наоборот: «сверху» всё красиво и причёсано, но стоит копнуть глубже, так обнаруживаются такие многоуровныевые «костыли» на «костылях», что волосы дыбом встают. Нет в жизни совершенства, увы :-(


      1. boov
        15.09.2015 08:51

        К сожалению не так удобно.
        Фактически в данном режиме в месте вызова импортируемой функции будет вызываться хелпер-функция __delayLoadHelper или __delayLoadHelper2 (зависит от версии ms studio). Их реализация содержится в библиотеке delayimp.lib (потребуется с ней слинковаться), ее код лежит здесь — vc/include/delayhlp.cpp.
        По нему видно, что данный хелпер осуществляет вызов LoadLibrary/GetProcAddress (это делается только один раз).
        В случае неудачи — не найден модуль или не найдена фунция — будет брошено исключение посредством RaiseException.
        Соответственно его можно перехватить и определить причину ошибки.


        1. khim
          15.09.2015 13:17

          Ясно. Да, во многих случаях это будет работать, но во многих — приведёт к видимым для человека проблемам. Типичный пример — какой-нибудь TWAIN: если у нас установлены соответствующие библиотеки, то хочется показать соответствующую кнопочки, но если в момент загрузки программы начать TWAIN инициализировать, то там может пойти деятельность не то, что просто замедляющая работу, а вообще нервирующая: сканер включится, что-то делать начнёт, etc.

          Но да, во многих случаях такой подход годится. Будем иметь ввиду :-)


  1. OlegMax
    15.09.2015 15:01
    +1

    1. Не потокобезопасно, причем существование общего кэша не очевидно из интерфейса библиотеки.
    2. Указание имени модуля рядом с именем процедуры, по моим ощущуениям, не совсем C++ style — избыточный runtime overhead. Если используются несколько процедур из одного модуля, то имя модуля все равно будет задаваться переменной или константой, так почему бы эту переменную не сделать схожей с ntmodule? Таким образом убирается поиск в кэше и добавляется управление временем жизни хэндла.