{{notification.text}}

MirGames

Это первая и пробная статья, поэтому любая критика (конструктивная и
обоснованная) будет восприниматься сугубо положительно.

Сразу предупреждаю «сторожил», что некоторые «прописные истины» озвученные в
данной статье хоть внешне и очевидны, являются наиболее частыми ошибками и
приведены тут не просто так.

Посвящена статья будет управлению ресурсами в игровых движках, разрабатываемых
самостоятельно. При этом будет подразумеваться, что основной код движка находится в динамической библиотеке (пример динамической библиотеки – d3dx9_28.dll). В случае, если движок полностью компилируется в исполняемый файл (пример – game.exe)
приведенный ниже материал все равно не останется бесполезным.

Разные «кучи» памяти

Первая и главная особенность в управлении ресурсами в подобных приложениях – это
хранение данных приложения и библиотеки в разных «кучах» память. Что это означает ?

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

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

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

Более того, частой ошибкой (и самой распространенной) является
использование строк «в стиле си (указатель на char: char* для С/С++ и PChar для Паскаля)» в приложении и библиотеке без контроля управления. Примерная ситуация – это назначение имен некоторым объектам, или указание путей загрузки текстур, моделей и т.д.

Например, из приложения мы передаем имя некоторого объекта (модели, текстуры и т.д.) в нашу библиотеку, в которой реализован игровой движок, в библиотеке происходит простое присвоения параметра имени как присвоение одного указателя на char другому указателю, а где-то дальше в коде приложения мы меняем данную переменную, или вообще удаляем её. При этом любое обращение по имени к объекту выдаст нам ошибку «Access Violation» в случае если переменная удалена или неверный результат, если переменная изменена.

Самым простым решением будет не простое присвоение указателей, а выделение памяти в библиотеке под имя объекта и копирование данных в область вновь выделенных данных.

Таким образом – библиотека управляет лишь теми ресурсами, что выделила сама, через свой менеджер памяти. Кроме этого, для Паскаля, не стоит производить присваивание переменных типа PChar объектам типа String простым преобразованием вида:

var
  str : String;
  pch : PChar;
…
  pch := 'My String'; // эта строка в приложении
  str := String(pch); // эта строка в библиотеке

Так как в этом случае более чем вероятно, что обращение к переменной str будет
ошибочно.

Первое правило управление ресурсами
Всегда копируйте данные, переданные из приложения в библиотеку, или наоборот, в ту «кучу» памяти, которой оперирует библиотека или приложение.

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

ООП при создании динамических библиотек

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

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

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

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

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

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

В общих чертах код интерфейса будет выглядеть примерно следующим образом:


// header.hpp
#ifndef HEADER_H
#define HEADER_H

#include <window.h>

#ifdef BUILD_DLL
    #define DLL_EXPORT __declspec (dllexport) __stdcall
#else
    #define DLL_EXPORT __declspec (dllimport) __stdcall
#endif

interface _Message:
{
    virtual void __stdcall Message();
};

#ifndef BUILD_DLL
    extern "C"
    {
        void __stdcall CreateInterface(_Message** cm)
    }
#endif

#endif /* HEADER_H */


// mclass.hpp
#ifndef MCLASS_H
#define MCLASS_H

#include "header.hpp"

class CMessage: public _Message
{
public:
    CMessage();
    ~CMessage();
    virtual void __stdcall Message();
    virtual CMessage* __stdcall GetInterface();
};

extern CMessage* g_Message;

#endif /* MCLASS_H */


// mclass.cpp
#include "mclass.hpp"

CMessage* g_Message;

CMessage::CMessage()
{
}

CMessage::~CMessage()
{
}

void __stdcall CMessage::Message()
{
    MessageBox(0, "Message from library", "Info", MB_OK | MB_ICONWARNING);
}

CMessage* __stdcall CMessage::GetInterface()
{
    return this;
}

// lib.cpp
#define BUILD_DLL

#include "header.hpp"
#include "mclass.hpp"

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
    switch (fdwReason)
    {
        case DLL_PROCESS_ATTACH:
            // attach to process
            // return FALSE to fail DLL load
            break;

        case DLL_PROCESS_DETACH:
            // detach from process
            break;

        case DLL_THREAD_ATTACH:
            // attach to thread
            break;

        case DLL_THREAD_DETACH:
            // detach from thread
            break;
    }
    return TRUE; // succesful
}

extern "C"
{
    void __stdcall CreateInterface(CMessage** cm)
    {
        if (!g_Message)
            g_Message = new CMessage;
        *cm = g_Message->GetInterface();
    }
};

// exec_c.cpp
#include "header.hpp"

int main()
{
    _Message* g_m;
    CreateInterface(&g_m);
    g_m->Message();
}

Для Паскале-язычных сред привожу только код исполняемого файла:

// header.pas
unit header;

interface

uses
  Windows;
  
type
  IMessage = interface(IUnknown)
    procedure _Message; stdcall;
  end;
  PMessage = ^IMessage;

procedure CreateInterface(out cm: IMessage); stdcall; external 'lib.dll';

implementation

end.


// exec_pas.dpr
program exec;
uses
  Windows,
  header in 'header.pas';

var
  g_m : IMessage;

begin
  CreateInterface(g_m);
  g_m._Message;
end.

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

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

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

Ну хорошо, как экспортировать интерфейс из библиотеки вроде бы разобрались, перейдем к следующему вопросу. В какой же момент, и главное как удалить объект, который мы экспортировали из динамической библиотеки. Удалять в приложении нельзя, поэтому необходимо как-то сообщить библиотеке, что она должна очистить ресурсы. А если приложение у нас завершается аварийно, или пользователь библиотеки забыл вызвать функцию удаления ресурсов?

Вот тут и стоит вспомнить, что динамической библиотеке как при присоединении к
процессу, так и при отстыковке от процесса отсылаются сообщения, воспринимаемые
функцией DllMain. Для программистов на С/С++ несколько проще, достаточно лишь в теле обработки сообщения DLL_PROCESS_DETACH написать код удаления ресурсов для гарантированной очистки выделенной памяти.

А как быть Паскале-язычным программистам? А точно также, только процедура DllMain от них по умолчанию скрыта.

Для Делфи достаточно лишь чуть-чуть видоизменить текст программы:

library lib;

procedure DllEntry(dwReason: DWORD);
begin
  case dwReason of
    DLL_PROCESS_ATTACH:
      begin
      end;
    DLL_PROCESS_DETACH:
      begin
      end;
    DLL_THREAD_ATTACH:
      begin
      end;
    DLL_THREAD_DETACH:
      begin
      end;
  end;

end;

begin
  @DllProc := @DllEntry;
  DllEntry(DLL_PROCESS_ATTACH);
end.

А вот для FreePascal придется немного больше повозиться, потому что там нет единой
точки входа в библиотеку:

type
  TDLL_Process_Entry_Hook = function (dllparam : longint) : longbool;
  TDLL_Entry_Hook = procedure (dllparam : longint);

const
  Dll_Process_Attach_Hook : TDLL_Process_Entry_Hook = nil;
  Dll_Process_Detach_Hook : TDLL_Entry_Hook = nil;
  Dll_Thread_Attach_Hook : TDLL_Entry_Hook = nil;
  Dll_Thread_Detach_Hook : TDLL_Entry_Hook = nil;

Хорошо, как удалять ресурсы при завершении приложения мы разобрались, а как удалить класс, с которым связан используемый интерфейс, в процессе выполнения программы?

Одно из самых простых и эффективных решений – это заставить класс удалять самого
себя если он больше не нужен, а для определения «нужности» вести подсчет количества ссылок.

Полный код динамической библиотеки, и двух исполняемых файлов (один на С/С++,
второй на Делфи7) можно найти в архиве к статье. Хочу лишь сказать, что таким образом мы получаем подобие технологии СОМ от Микрософта, а значит следует учитывать особенности Делфи, в частности нет необходимости вызывать _Release самостоятельно, компилятор сам сгенерирует код вызова, точно так же как и для _AddRef.

Исходные коды и примеры к статье
04.05.06 05:42