7 июня 2019 г.

Разработка API (контракта) для своей DLL

Или: не создавайте своих DLL, не прочитав эту статью!


Это статья по мотивам вопросов на форумах: "Как мне вернуть строку из DLL?", "Как передать и вернуть массив записей?", "Как передать в DLL форму?".

Чтобы вам не тратить половину жизни на разобраться - в этой статье я принесу всё на блюдечке.

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

Важное примечание: статью нужно читать последовательно. Примеры кода приводятся только как примеры, на каждом шаге (пункте) статьи код примеров добавляется новыми подробностями. Например, в самом начале статьи нет обработки ошибок, указываются "классические" способы (типа, использования GetLastError, соглашения sdtcall и т.д.), которые по ходу статьи заменяются на более адекватные. Сделано так по той причине, чтобы "новые" ("необычные") конструкции не вызывали вопросов. Иначе при пришлось бы к каждому примеру вставлять примечание вида: "вот это обсуждается в том пункте ниже, а вот то - в этом вот". В любом случае в конце статьи есть ссылка на уже готовый код, написанный с учётом всего сказанного в статье. Можете просто его брать и использовать. А статья объясняет зачем и почему. Если вам не интересно "зачем и почему" - листайте в конец к заключению и ссылке на скачивание примера.


Содержание



Общие понятия

Когда вы разрабатываете свою DLL, вы должны придумать прототипы экспортируемых из неё функций ("заголовки"), а также основанный на них контракт (правила вызова). Всё вместе это образует API вашей DLL. API или Application Programming Interface (программный интерфейс приложения) - это описание способов, которыми один код может взаимодействовать с другим, это средство интеграции приложений.

Когда вы разрабатываете свою DLL, вы должны определить в каких условиях она будет использоваться:
  1. Могут ли её использовать приложения, написанные на другом языке программирования (например, Microsoft Visual C++) - "универсальная DLL";
  2. Или же библиотеку DLL смогут использовать только приложения, написанные на том же языке - "Delphi DLL".
Это принципиальный момент, решить который вы должны в первую очередь: ещё до того как начнёте писать код и даже проектировать API вашей DLL. Дело в том, что во втором случае ("Delphi DLL") вы можете использовать все возможности вашего языка программирования при создании API. К примеру, для Delphi это означает возможность использовать строки, объекты (в частности - формы, компоненты), динамические массивы, специализированные простые типы (Extended, множества и т.п.) - в общем, всё то, что не существует в других языках. Также это означает возможность обмениваться памятью, делать прозрачную обработку ошибок (межмодульные исключения).

Если вы пойдёте этим путём, то вам следует рассмотреть использования run-time пакетов (BPL) вместо DLL. BPL-пакеты - это специализированные DLL, которые специально "заточены" под использование только в Delphi, что предоставляет вам множество "плюшек". Но об этом чуть позже.

Если же вы разрабатываете "универсальную DLL", то вы не можете использовать возможности вашего языка, которые не существуют в других языках программирования. И в этом случае вы можете использовать только "общеизвестные" типы данных. Но об этом также ниже.

Эта статья - в основном про "универсальные DLL" в Windows.

Что вам необходимо будет создать при разработке API вашей DLL:
  1. Заголовочники, заголовочные файлы (headers) - набор исходных файлов, которые содержат объявления структур и функций, использующихся в API. Как правило, не содержат реализации. Заголовочные файлы предоставляются на нескольких языках - как правило, это язык, на котором написана программа (в нашем случае - Delphi), C++ (как стандарт) и некоторыми дополнительными (Basic и т.п.). Все эти файлы эквивалентны и просто представляют собой перевод из одного языка программирования на другой. Чем больше языков будет в комплекте - тем лучше. Если вы не предоставите заголовочные файлы для какого-то языка, то программисты на этом языке не смогут использовать вашу DLL, пока они сами не переведут файлы с предоставляемого языка (Delphi или C++) на их язык. Т.е. отсутствие заголовочников на каком-то языке - это не красный "стоп", но достаточное препятствие.
  2. Документация - представляет собой словесное описание API и должна указывать дополнительные правила, не заложенные в синтаксисе заголовочников. К примеру, то, что такую-то функцию можно вызвать, передав ей число - это информация из заголовочников. А то, что перед вызовом этой функции нужно вызвать другую функцию - это информация из документации. В такой документации как минимум должно быть формальное описание API - перечисление всех функций, методов, интерфейсов и типов данных с объяснениями "как" и "зачем" (т.н. Reference). Дополнительно, документация может содержать неформальное описание процесса использования DLL (guide, how-to и т.п.). В простейших случаях документация пишется прямо в заголовочниках (комментариях), но чаще всего это файл (или файлы) в формате chm, html или pdf.
SDK (Software Development Kit) - набор из заголовочников + документации. SDK - это то, что необходимо стороннему разработчику для использования вашей DLL. SDK - это то, что вы должны создать и публично распространять для всех желающих использовать вашу DLL.


Типы данных

Если вы хотите получить "универсальную DLL", то вы не можете использовать в вашем API специфичные для Delphi типы данных, потому что они не имеют аналога в других языках. Например, string, array of, TObject, TForm (и вообще - любые объекты и уж тем более компоненты) и т.п.

Что можно использовать? Целочисленные типы (Integer, Cardinal, Int64, UInt64, NativeInt, NativeUInt, Byte, Word и т.п.; я бы не рекомендовал использовать Currency, если только он вам действительно нужен), вещественные (Single и Double; я бы рекомендовал избегать типов Extended и Comp, если только они действительно вам нужны и иначе никак), TDateTime (алиас для системного OLEDate), перечислимые и subrange-типы (с некоторыми оговорками), символьные типы (AnsiChar и WideChar - но не Char), строки (только в виде WideString/BSTR), логический тип (BOOL, но не Boolean), интерфейсы (interface), в методах которых используются допустимые типы, записи (record) из вышеуказанных типов, а также указатели на них (в том числе указатели на массивы из вышеуказанных типов, но не динамические массивы). Массивы как правило передаются двумя параметрами: указателем на первый элемент массива и числом элементов в массиве.

Как узнать, какой тип можно использовать, а какой - нет? Относительно простое правило - если вы не видите тип в этом списке, и типа нет в модуле Windows (модуле Winapi.Windows, начиная с Delphi XE2), то этот тип использовать нельзя. Если же тип перечислен мною выше или находится в модуле Windows/Winapi.Windows - используйте его. Это достаточно грубое правило, но для начала - сойдёт.

В случае использования записей (record) - вам нужно указать выравнивание данных. Используйте или ключевое слово packed (без выравнивания) или указывайте директиву {$A8} (выравнивание на 8 байт) в начале файла заголовочников.

В случае использования перечислимых типов (Color = (clRed, clBlue, clBlack);) - добавьте в начало заголовочников директиву {$MINENUMSIZE 4} (размер перечислимого типа не меньше 4 байт).


Строковые данные и кодировки

Если вам нужно передавать в DLL строки или возвращать из DLL строки - используйте только тип BSTR. Почему?
  1. Тип BSTR есть во всех языках программирования.
    Примечание: по историческим причинам в Delphi тип BSTR называется WideString. Поэтому, чтобы содержимое ваших Delphi-заголовочников было бы более понятным разработчикам на других языках - добавьте в их начало такой код:
    type
      BSTR = WideString;
    и в дальнейшем используйте тип BSTR.
  2. Тип BSTR (WideString) относится к автоматическим типам Delphi, т.е. вам не нужно выделять и освобождать память вручную. За вас всё автоматически сделает компилятор;
  3. Тип BSTR имеет фиксированную кодировку: Unicode. Т.е. у вас не будет проблем с неверной кодовой страницей, приводящей к "кракозябрам";
  4. Магия компилятора Delphi позволяет просто присваивать BSTR (через оператор присваивания := ) любым строкам Delphi и наоборот. Все необходимые преобразование будут сделаны автоматически под капотом языка, не нужно вызывать никаких функций преобразования;
  5. Память для строк BSTR всегда выделяется через один и тот же менеджер памяти, поэтому у вас никогда не будет проблем с передачей памяти между исполняемыми модулями (см. ниже);

Если по каким-то причинам вы не можете использовать BSTR, то застрелитесь, пожалуйста используйте PWideChar:
  1. Не используйте PAnsiChar, потому что на дворе 2019 год, а не 1995. При использовании PAnsiChar вы получаете кучу головной боли с кодировками;
  2. Не используйте PChar, потому что он определён не однозначно: это может быть или PAnsiChar или PWideChar (в зависимости от версии компилятора).
Аналогично использованию системного имени BSTR вместо Delphi-имени WideString, и для PWideChar тоже можно сделать так:
type
  LPWSTR = PWideChar;
и далее использовать LPWSTR. LPWSTR - это имя системного типа данных, который в Delphi называется PWideChar.

Конечно же, при использовании LPWSTR/PWideChar вы получаете кучу минусов:
  1. Вам нужно вручную выделять и освобождать память для PWideChar, что увеличивает шансы на проблемы с утечками памяти;
  2. Хотя в некоторых случаях вы можете делать прямые присваивания (например, PWideChar в строку), но чаще - нет. Вам придётся вызывать функции преобразования и/или функции выделения/копирования памяти;
  3. Память для строк PWideChar выделяется как обычно (без специально выделенного менеджера памяти), т.е. у вас есть проблема с передачей памяти через границу модуля (см. ниже);
  4. У PWideChar нет поля для длины. Поэтому если вы хотите передавать строки с #0 внутри и/или вы хотите передавать большие строки, то вам придётся явно передавать длину строки вместе со строкой (два параметра вместо одного).
Читать далее: String и PChar.

ANSI и Unicode

Из вышесказанного напрямую следует, что все ваши экспортируемые функции должны быть в Unicode. Не надо, глядя на Windows API, делать два варианта функций (с суффиксами -A и -W) - делайте один вариант (без суффикса, просто Unicode). Даже если вы разрабатываете на ANSI версии Delphi (Delphi 7) - не надо делать ANSI-варианты экспортируемых функций. Сейчас не 1995 год.


Общий менеджер памяти

(и почему его не нужно использовать)

В языках программирования динамическая память выделяется и освобождается специальным кодом в программе - т.н. менеджером памяти. К примеру, в Delphi менеджер памяти реализует функции вида GetMem и FreeMem. Все прочие способы управления памятью (New, SetLength, TForm.Create и т.д.) являются переходниками (т.е. где-то внутри они вызывают GetMem и FreeMem).

Проблема заключается в том, что каждый исполняемый модуль (будь это DLL или exe) имеют свой собственный код менеджера памяти, и, к примеру, менеджер памяти Delphi не знает ничего про менеджер памяти Microsoft C++ (и наоборот). Поэтому, если вы выделите память в Delphi и, к примеру, попытаетесь передать её в код Visual C++, то ничего хорошего не произойдёт. Более того, даже если вы выделите память в Delphi DLL и вернёте её в Delphi exe, то всё будет ещё хуже: в обоих исполняемых модулях используется два разных, но однотипных менеджера памяти. Менеджер памяти exe посмотрит на память и ему покажется, что это его память (ведь она выделена аналогичным менеджером памяти), он попытается её освободить, но только испортит этим данные учёта.

Решение этой проблемы просто - нужно использовать правило: кто выделяет память, тот её и освобождает.

Добиться выполнения этого правила можно разными способами. Часто упоминаемый способ: использование т.н. общего менеджера памяти или менеджера общей памяти (shared memory manager). Суть способа заключается в том, что несколько модулей "договариваются" использовать один и тот же менеджер памяти.

Когда вы создаёте DLL - вам об этой особенности сообщает комментарий в начале .dpr файла DLL:
{ Important note about DLL memory management: ShareMem must be the
  first unit in your library's USES clause AND your project's (select
  Project-View Source) USES clause if your DLL exports any procedures or
  functions that pass strings as parameters or function results. This
  applies to all strings passed to and from your DLL--even those that
  are nested in records and classes. ShareMem is the interface unit to
  the BORLNDMM.DLL shared memory manager, which must be deployed along
  with your DLL. To avoid using BORLNDMM.DLL, pass string information
  using PChar or ShortString parameters. }
Что переводится как:
Важное примечание об управлении памятью DLL: модуль ShareMem должен быть указан первым элементом в конструкции USES вашей библиотеки И конструкции USES вашего проекта (используйте Project-View Source), если ваша DLL экспортирует какие-либо процедуры или функции, которые передают строки в качестве параметров или результатов функции. Это относится ко всем строкам, передаваемым в и из вашей DLL - даже к тем, которые вложены в записи и классы. ShareMem - это интерфейсный модуль для общего менеджера памяти BORLNDMM.DLL, который должен распространяться вместе с вашей DLL. Чтобы избежать использования BORLNDMM.DLL, передавайте строковую информацию с помощью параметров типа PChar или ShortString.
Это - чудовищно неправильный комментарий:
  1. Комментарий говорит о необходимости использования общего менеджера памяти, как если бы это был единственный способ решения проблемы обмена памяти - что в корне неверно (см. ниже);
  2. Комментарий говорит только о строках, хотя описываемая проблема применима к любым данным с динамическим выделением памяти: объектам, динамическим массивам, указателям;
  3. Комментарий никак не упоминает, что делать с не строковыми данными;
  4. Использование общего менеджера памяти никак не коррелирует с использованием отдельной выделенной DLL. Это - всего лишь одна из возможных реализаций;
  5. Комментарий требует использовать PChar для избежания описываемой проблемы - что также неправильно (см. выше про кодировки);
  6. Комментарий требует использовать ShortString - что, опять же, неверно с точки зрения "универсальной DLL" (ShortString - тип, специфичный для Delphi). Хотя, это уже придирка, поскольку использование Delphi-строк и Delphi DLL в качестве общего менеджера памяти и так уже ставит крест на "универсальной DLL".
К сожалению, этот комментарий "от самих создателей Delphi" породил огромное количество мифов и плохих практик.

Что же не так с использованием общего менеджера памяти?
  1. Другие языки программирования ничего не знают про менеджер памяти Delphi;
  2. А раз вы ориентируетесь только на Delphi, то зачем вам DLL? Собирайте программу с пакетами выполнения (BPL) - этим вы автоматически получите:
    • Общий менеджер памяти в rtl.bpl;
    • Гарантию совместимости структуры объектов, поскольку все модули будут собираться одним компилятором;
    • Отсутствие дублирования RTL и VCL (ошибки типа "TForm не совместим с TForm", два объекта Application и т.д.);
    • Беспроблемную обработку ошибок с исключениями.
  3. Общий менеджер памяти сильно затрудняет поиск утечек памяти, поскольку модуль может загрузиться, выделить память, выгрузиться, а созданная утечка будет найдена только во время финализации менеджера памяти при выходе из программы.
Суммируя: общий менеджер памяти - это костыль. Не надо его использовать. А что нужно использовать? См. разделы ниже.

Подробнее о менеджерах памяти и общих менеджерах памяти.


Управление памятью в API DLL

Итак, как же вам передать память из DLL в вызывающего и наоборот? Есть несколько способов.

Как неправильно?

Для начала - как делать не следует.

Во-первых, не надо "делать как в Delphi": не используйте общий менеджер памяти - по причинам, указанным выше.

Во-вторых, не надо "делать как в Windows". Многие смотрят на Windows API и делают так же. Но при этом они упускают, что этот API создавался хорошо если в 1995 году, а ведь многие функции идут ещё от 16-битных Windows. Те окружение и условия, для которых создавались эти функции, сегодня уже не существуют. Сегодня есть гораздо более простые и удобные способы.

Например, вот типичная Windows функция:
function GetUserName(lpBuffer: PWideChar; var nSize: DWORD): BOOL; stdcall;

Параметры

lpBuffer
Указатель на буфер для получения имени пользователя. Если этот буфер недостаточно велик, чтобы вместить полное имя пользователя, функция завершается ошибкой.

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

Если lpBuffer слишком мал, функция завершается ошибкой и GetLastError возвращает ERROR_INSUFFICIENT_BUFFER. Тогда этот параметр будет содержать требуемый размер буфера, включая завершающий нулевой символ.
Чтобы получить результат от такой функции Windows, её надо вызвать дважды. Сначала вы вызываете функцию, чтобы определить размер буфера, затем вы выделяете буфер, и только потом вызываете функцию снова. А если данные поменяются в это время? Функции снова не хватит места. Таким образом, чтобы надёжно получить полные данные, вам придётся писать цикл. Это же ужас. Не надо так делать.

Строки

Со строками всё просто - используйте BSTR (который WideString). Это мы подробно разобрали выше.

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

Во всех прочих случаях вам нужно использовать один из трёх способов ниже.

Системный менеджер памяти

Выполнить правило "кто выделяет память, тот её и освобождает" можно следующим образом: попросить выделять и освобождать память третью сторону, про которую знают и вызываемый и вызывающий. К примеру, такой третьей стороной может быть любой системный менеджер памяти. Именно так и работает BSTR/WideString. Вот несколько вариантов, которые вы можете использовать:
  1. Системная куча процесса: Все эти функции выделяют память из одной и той же динамической кучи процесса. Несколько вариантов функций появились по историческим причинам.
  2. COM-подобные менеджеры памяти: И снова: все эти функции сегодня эквивалентны. Несколько вариантов функций появились по историческим причинам.
  3. VirtualAlloc и VirtualFree.
Примечание: функции менеджера памяти COM и оболочки (Shell) можно вызывать сразу, без инициализации COM/OLE.

Довольно большой список. Что же из этого лучше использовать?
  • VirtualAlloc/VirtualFree выделяют память с гранулярностью в 64 Кб, поэтому их использовать нужно только если вам нужно обмениваться данными огромных размеров;
  • GlobalAlloc/GlobalFree и LocalAlloc/LocalFree совсем уж устарели и имеют бо́льшие накладные расходы, нежели HeapAlloc/HeapFree, поэтому их использовать не нужно;
Остаются HeapAlloc/HeapFree и COM. Вариант с Heap вполне может быть вариантом по умолчанию. Менеджеры памяти COM могут быть более привычны некоторым языкам программирования. Кроме того, там есть уже готовый интерфейс менеджера памяти (см. ниже). В общем, тут выбор скорее вкуса, особой разницы нет.

Вот пример, как это могло бы выглядеть в коде. В DLL (упрощённый код без обработки ошибок):
uses
  ActiveX; // или uses OLE2;

function GetDynData(const AFlags: DWORD; out AData: Pointer; out ADataSize: DWORD): BOOL; stdcall;
var
  P: array of Something;
begin
  P := { ... готовим данные ... };

  ADataSize := Length(P) * SizeOf(Something);
  AData := CoTaskMemAlloc(ADataSize);
  Move(Pointer(P)^, AData^, ADataSize);

  Result := True;
end;
В exe:
uses
  ActiveX; // или uses OLE2;

var
  P: array of Something;  
  Data: Pointer;
  DataSize: DWORD;
begin
  GetDynData(0, Data, DataSize);

  SetLength(P, DataSize div SizeOf(Something));
  Move(Data^, Pointer(P)^, DataSize);
  CoTaskMemFree(Data);
  
  // Работаем с P
end;
Примечание: это только пример. В реальных приложениях вы можете (на стороне вызываемого) как готовить данные сразу в возвращаемом буфере (при условии, что вам наперёд известен его размер), так и (на стороне вызывающего) работать с возвращёнными данными напрямую, не копируя их в буфер другого типа.

Разумеется, при этом в вашем SDK должна быть документация по функции GetDynData, где будет явно сказано, что возвращаемую память нужно освобождать вызовом CoTaskMemFree, например так:

GetDynData

Возвращает XYZ.

Синтаксис
function GetDynData(
  const AFlags: DWORD;
  out AData: Pointer;
  out ADataSize: DWORD
): BOOL; stdcall;

Параметры
AFlags [входной]
ТипDWORD
Необязательные флаги: ...

AData [выходной]
ТипPointer
Указатель на запрошенные данные размером ADataSize. После завершения работы с данными вызывающий должен удалить их вызовом CoTaskMemFree.

ADataSize [выходной]
ТипDWORD
Размер данных AData в байтах.

Возвращаемое значение
Если функция успешна, то возвращается True.
Если функция завершается неудачно, то возвращается False. В этом случае код ошибки можно получить вызвав GetLastError.

Примечания
...

Примеры
Пример использования можно увидеть в статье Пример получения данных.

Требования
Версия DLL
1
Заголовочный файл
MyDll.pas


Примечание: разумеется, вызовы CoTaskMemAlloc/CoTaskMemFree вы можете заменить на HeapAlloc/HeapFree или любые другие, удобные вам.

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

Выделенные функции

Другой вариант - обернуть ваш предпочитаемый менеджер памяти в экспортируемую функцию. Соответственно, в документации к функции должно быть указано, что для освобождения памяти нужно вызывать не CoTaskMemFree (или что вы там использовали), а вашу функцию-обёртку. Тогда вы сможете просто возвращать подготовленные данные сразу, без копирования. Например, в DLL (упрощённый код без обработки ошибок):
function GetDynData(const AFlags: DWORD; out AData: Pointer; out ADataSize: DWORD): BOOL; stdcall;
var
  P: array of Something;
begin
  P := { ... готовим данные ... };

  ADataSize := Length(P) * SizeOf(Something);
  Pointer(AData) := Pointer(P); // копируем указатель, не копируем данные
  Pointer(P) := nil; // блокируем автоматическую очистку 

  Result := True;
end;

procedure DynDataFree(var AData: Pointer); stdcall;
var
  P: array of Something;
begin
  if AData = nil then
    Exit;

  Pointer(P) := Pointer(AData); // и снова: копируем только указатель
  AData := nil; 

  Finalize(P); // подходящая функция очистки 
  // (в данном случае - этот вызов опционален)
end;
В exe:
var
  P: array of Something;  
  Data: Pointer;
  DataSize: DWORD;
begin
  GetDynData(0, Data, DataSize);

  SetLength(P, DataSize div SizeOf(Something));
  Move(Data^, Pointer(P)^, DataSize);
  DynDataFree(Data);
  
  // Работаем с P
end;
Примечание: заметьте, что мы не можем просто скопировать указатель в массив на стороне вызывающего, поскольку контракт GetDynData ничего не говорит про совместимость возвращаемых данных с динамическим массивом Delphi. Действительно, DLL может быть написана на MS Visual C++, в котором нет динамических массивов.

Как и в предыдущем случае, этот контракт также должен быть явно закреплён в документации вашего SDK:
AData [выходной]
ТипPointer
Указатель на запрошенные данные размером ADataSize. После завершения работы с данными вызывающий должен удалить их вызовом DynDataFree.
Заметьте, что используя функцию-обёртки вы сможете снизить число копирования данных, поскольку теперь вам не нужно копировать данные на стороне вызываемого, ибо вы используете один и тот же менеджер памяти в вычислениях и для возврата данных. Минусом же этого способа является необходимость написания дополнительных функций-обёрток. Иногда вы можете обойтись одной общей функцией-обёрткой, общей для всех экспортируемых функций. Но чаще вам потребуется индивидуальная функция очистки для каждой экспортируемой функции (возвращающей данные).

Если вы будете использовать одну универсальную функцию для очистки, то вы можете возвращать её в виде интерфейса IMalloc. Это будет более привычно для знакомых с основами COM. Но это также позволит вам не только возвращать память вызывающему, но и принимать от него память с передачей владения. Например:
uses
  ActiveX; // или Ole2

type
  TAllocator = class(TInterfacedObject, IMalloc)
    function Alloc(cb: Longint): Pointer; stdcall;
    function Realloc(pv: Pointer; cb: Longint): Pointer; stdcall;
    procedure Free(pv: Pointer); stdcall;
    function GetSize(pv: Pointer): Longint; stdcall;
    function DidAlloc(pv: Pointer): Integer; stdcall;
    procedure HeapMinimize; stdcall;
  end;

{ TAllocator }

function TAllocator.Alloc(cb: Integer): Pointer;
begin
  Result := AllocMem(cb);
end;

function TAllocator.Realloc(pv: Pointer; cb: Integer): Pointer;
begin
  ReallocMem(pv, cb);
  Result := pv;
end;

procedure TAllocator.Free(pv: Pointer);
begin
  FreeMem(pv);
end;

function TAllocator.DidAlloc(pv: Pointer): Integer;
begin
  Result := -1;
end;

function TAllocator.GetSize(pv: Pointer): Longint;
begin
  Result := -1;
end;

procedure TAllocator.HeapMinimize;
begin
  // ничего не делает
end;

function GetMalloc(out AAllocator: IMalloc): BOOL; stdcall;
begin
  AAllocator := TAllocator.Create;
  Result := True;
end;

//_______________________________________

function GetDynData(const AOptions: Pointer; out AData: Pointer; out ADataSize: DWORD): BOOL; stdcall;
var
  P: array of Something;
begin
  P := { ... готовим данные с учётом AOptions ... };
  // предполагаем, что AOptions нам передали с правом владения
  FreeMem(AOptions); 

  ADataSize := Length(P) * SizeOf(Something);
  AData := GetMem(ADataSize);
  Move(Pointer(P)^, Pointer(AData)^, ADataSize);

  Result := True;
end;
В exe:
var
  A: IMalloc;
  Options: Pointer;
  P: array of Something;
  Data: Pointer;
  DataSize: DWORD;
begin
  GetMalloc(A);

  Options := A.Alloc({ размер опций });
  { подготовка Options }
  GetDynData(Options, Data, DataSize);
  // Не освобождаем Options, так как передали владение в GetDynData

  SetLength(P, DataSize div SizeOf(Something));
  Move(Data^, Pointer(P)^, DataSize);
  A.Free(Data);

  // Работаем с P
end;
Примечание: конечно, это немного бессмысленный пример, потому что в данном конкретном случае нет никакой необходимости передавать права на владение AOptions в функцию GetDynData: вызывающий может и сам очистить память, тогда вызываемый может не освобождать память. Но это только пример. В реальных приложениях вам может понадобится держать AOptions внутри DLL дольше вызова функции. В примере показано, как это можно реализовать, обернув менеджер памяти в интерфейс.

Если вы реализуете метод TAllocator.GetSize, то параметр ADataSize можно будет убрать.

Интерфейсы

Вместо использования системного менеджера памяти и специальных экспортируемых функций (два способа выше) гораздо удобнее использовать интерфейсы по следующим причинам:
  1. Интерфейс - это запись с указателями на функции, аналог класса с виртуальными функциями. Благодаря этому каждый метод автоматически становится функцией-обёрткой из предыдущего пункта, т.е. всегда работает с правильным менеджером памяти. Иными словами, нет необходимости ни использовать фиксированный сторонний менеджер памяти, ни вводить функции-обёртки;
  2. Интерфейсы понимают все языки программирования;
  3. Интерфейсами можно передавать сложные данные (объекты);
  4. Интерфейсы относятся к типам с автоматической очисткой, не надо явно вызывать функции очистки;
  5. Интерфейсы можно легко модифицировать, расширяя их в будущих версиях DLL;
  6. Способ реализации интерфейсов в Delphi с помощью магии компилятора позволяет легко реализовать правильную обработку ошибок (см. ниже следующий раздел).
Прошлый пример можно реализовать на интерфейсах примерно так, вот DLL:
type
  IData = interface
  ['{C79E39D8-267C-4726-98BF-FF4E93AE1D44}']
    function GetData: Pointer; stdcall;
    function GetDataSize: DWORD; stdcall;

    property Data: Pointer read GetData; 
    property DataSize: DWORD read GetDataSize; 
  end;

  TData = class(TInterfacedObject, IData) 
  private
    FData: Pointer;
    FDataSize: DWORD;
  protected
    function GetData: Pointer; stdcall;
    function GetDataSize: DWORD; stdcall;
  public
    constructor Create(const AData: Pointer; const ADataSize: DWORD);
  end;

constructor TData.Create(const AData: Pointer; const ADataSize: DWORD);
begin
  inherited Create;
  if ADataSize > 0 then
  begin
    GetMem(FData, ADataSize);
    Move(AData^, FData^, ADataSize);
  end;
end;

function TData.GetData: Pointer; stdcall;
begin
  Result := FData;
end;

function TData.GetDataSize: DWORD; stdcall;
begin
  Result := FDataSize;
end;

//________________________________

function GetDynData(const AFlags: DWORD; out AData: IData): BOOL; stdcall;
var
  P: array of Something;
begin
  P := { ... готовим данные ... };

  AData := TData.Create(Pointer(P), Length(P) * SizeOf(Something)); 

  Result := True;
end;
В exe:
var
  P: array of Something;  
  Data: IData;
begin
  GetDynData(0, Data);

  SetLength(P, Data.DataSize div SizeOf(Something));
  Move(Data^, Data.Data^, Data.DataSize);
  
  // Работаем с P
end;
В данном случае мы сделали один универсальный интерфейс IData, который можно написать один раз и использовать во всех функциях. Хотя при этом не придётся писать код для каждой функции, но при этом также получается копирование данных на стороне вызываемого, а также отсутствие типизации. Вот как мог бы выглядеть улучшенный вариант, DLL:
type
  IData = interface
  ['{C79E39D8-267C-4726-98BF-FF4E93AE1D44}']
    function GetData: Pointer; stdcall;
    function GetDataSize: DWORD; stdcall;

    property Data: Pointer read GetData; 
    property DataSize: DWORD read GetDataSize; 
  end;

  TSomethingArray = array of Something;

  TSomethingData = class(TInterfacedObject, IData) 
  private
    FData: TSomethingArray;
    FDataSize: DWORD;
  protected
    function GetData: Pointer; stdcall;
    function GetDataSize: DWORD; stdcall;
  public
    constructor Create(var AData: TSomethingArray);
  end;

constructor TSomethingData.Create(var AData: TSomethingArray);
begin
  inherited Create;
  FDataSize := Length(AData) * SizeOf(Something);
  if FDataSize > 0 then
  begin
    Pointer(FData) := Pointer(AData);
    Pointer(AData) := nil;
  end;
end;

function TSomethingData.GetData: Pointer; stdcall;
begin
  Result := Pointer(FData);
end;

function TSomethingData.GetDataSize: DWORD; stdcall;
begin
  Result := FDataSize;
end;

function GetDynData(const AFlags: DWORD; out AData: IData): BOOL; stdcall;
var
  P: TSomethingArray;
begin
  P := { ... готовим данные ... };

  AData := TSomethingData.Create(P); 

  Result := True;
end;
В этом случае внешняя обёртка (т.е. интерфейс) остаётся без изменений, меняется только код DLL. Поэтому код вызывающего (в exe) также не меняется. Но если менять контракт (интерфейс), то можно сделать и так:
type
  ISomethingData = interface
  ['{CF8DF791-1E8D-4363-94A2-9FF035A9015A}']
    function GetData: Pointer; stdcall;
    function GetDataSize: DWORD; stdcall;
    function GetCount: Integer; stdcall;
    function GetItem(const AIndex: Integer): Something; stdcall;

    property Data: Pointer read GetData; 
    property DataSize: DWORD read GetDataSize; 
    property Count: Integer read GetCount;
    property Items[const AIndex: Integer]: Something read GetItem; default; 
  end;

  TSomethingArray = array of Something;

  TSomethingData = class(TInterfacedObject, ISomethingData) 
  private
    FData: TSomethingArray;
    FDataSize: DWORD;
  protected
    function GetData: Pointer; stdcall;
    function GetDataSize: DWORD; stdcall;
    function GetCount: Integer; stdcall;
    function GetItem(const AIndex: Integer): Something; stdcall;
  public
    constructor Create(var AData: TSomethingArray);
  end;

constructor TSomethingData.Create(var AData: TSomethingArray);
begin
  inherited Create;
  FDataSize := Length(AData) * SizeOf(Something);
  if FDataSize > 0 then
  begin
    Pointer(FData) := Pointer(AData);
    Pointer(AData) := nil;
  end;
end;

function TSomethingData.GetData: Pointer; stdcall;
begin
  Result := Pointer(FData);
end;

function TSomethingData.GetDataSize: DWORD; stdcall;
begin
  Result := FDataSize;
end;

function TSomethingData.GetCount: Integer; stdcall; 
begin
  Result := Length(FData);
end;

function TSomethingData.GetItem(const AIndex: Integer): Something; stdcall;
begin
  Result := FData[AIndex];
end;

function GetDynData(const AFlags: DWORD; out AData: ISomethingData): BOOL; stdcall;
var
  P: TSomethingArray;
begin
  P := { ... готовим данные ... };

  AData := TSomethingData.Create(P); 

  Result := True;
end;
Тогда на стороне exe можно будет делать так:
var
  Data: ISomethingData;
begin
  GetDynData(0, Data);

  // Работаем с Data, например:
  for X := 0 to Data.Count do
    AddToList(Data[X]);
end;
В общем, тут довольно широкие возможности, можно делать почти как хотите. Причём даже если вы сначала сделали контракт через IData, то позже вы можете добавить ISomethingData, просто расширив интерфейс наследованием. При этом старые клиенты вашей DLL версии 1 будут использовать IData, а клиенты версии 2 могут запросить более удобный ISomethingData.

Как вы видите из кода выше: интерфейсы тем полезнее, чем сложнее возвращаемые данные. Сложные объекты очень просто возвращать интерфейсами, когда как для возврата простого блока памяти получается очень много писанины кода.

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

Примечание: код выше - просто пример. В реальном коде вам нужно добавить обработку ошибок и вынести определения интерфейсов IData/ISomethingData в отдельные файлы (заголовочники вашего SDK).


Обработка ошибок

(и соглашение вызова)

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

Как правило, минимальными блоками, подвергаемым контролю, являются функция или процедура (подпрограмма). Каждая подпрограмма выполняет определённую задачу. И мы можем ожидать различный уровень "успешности" выполнения этой задачи: выполнение задачи было успешно или же при её выполнении возникла ошибка. Для написания надёжного кода нам совершенно необходим способ обнаружения ошибочных ситуаций — как мы определим, что в функции возникла ошибка? И реагирования на них — так называемое, "восстановление после ошибок" (т.е.: что мы будем делать при возникновении ошибки?). Традиционно, для обработки ошибок используется два основных способа: коды ошибок и исключения.

Коды ошибок

(и почему их не надо использовать)

Коды ошибок — это, пожалуй, самый простой способ реагирования на ошибки. Суть его проста: подпрограмма должна вернуть какой-либо признак успешности выполнения поставленной задачи. Тут есть два варианта: либо она вернёт простой признак (успешно/неуспешно), либо же она вернёт статус выполнения (иначе говоря — "описание ошибки"), т.е. некий код (число) одной из нескольких заранее определённых ситуаций: неверно заданы параметры функции, файл не найден и т.п. В первом случае может существовать дополнительная функция, которая возвращает статус выполнения последней вызванной функции. При таком подходе ошибки, обнаруженные в функции, обычно передаются выше (в вызывающую функцию). Каждая функция должна проверять результаты вызовов других функций на наличие ошибок и выполнять соответствующую обработку. Чаще всего обработка заключается в простой передаче кода ошибки ещё выше, в "более верхнюю" вызывающую функцию. Например: функция A вызывает B, B вызывает C, C обнаруживает ошибку и возвращает код ошибки в B. B проверяет возвращаемый код, видит, что возникла ошибка, и возвращает код ошибки в A. A проверяет возвращаемый код и выдает сообщение об ошибке (либо решает сделать что-нибудь еще).

Например, вот типичная функция Windows API:

RegisterClassEx

Регистрирует оконный класс для использования в функциях CreateWindow или CreateWindowEx.

Синтаксис
function RegisterClassEx(
  const AClass: TWndClassEx
): ATOM; stdcall;

Параметры
AClass [входной]
ТипTWndClassEx
Указатель на запись WNDCLASSEX. Вы должны заполнить эту запись до передачи в функцию.

Возвращаемое значение
Если функция завершается успешно, возвращаемое значение является атомом класса, который однозначно идентифицирует регистрируемый класс. Этот атом может использоваться только функциями CreateWindow, CreateWindowEx, GetClassInfo, GetClassInfoEx, FindWindow, FindWindowEx и UnregisterClass, а также методом IActiveIMMap.FilterClientWindows.

Если функция завершается ошибкой, возвращаемое значение равно нулю. Чтобы получить расширенную информацию об ошибке, вызовите GetLastError.

Примечания
... вырезано ...

Примеры
Пример использования можно увидеть в статье Using Window Classes.

Требования
Минимальная версия ОС
Windows 95
Заголовочный файл
Winuser.h
Библиотека
User32.dll

Это - типичный способ обработки ошибок в классическом API Windows. При этом используются т.н. Win32 error codes. Это - обычное число типа DWORD. Коды ошибок закреплены и объявлены в модуле Windows. За отсутствие ошибки принимается значение ERROR_SUCCESS или NO_ERROR равное 0. Для всех ошибок определены константы, начинающиеся (обычно) со слова ERROR_, например:
  { Incorrect function. }
  ERROR_INVALID_FUNCTION = 1;   { dderror }

  { The system cannot find the file specified. }
  ERROR_FILE_NOT_FOUND = 2;

  { The system cannot find the path specified. }
  ERROR_PATH_NOT_FOUND = 3;

  { The system cannot open the file. }
  ERROR_TOO_MANY_OPEN_FILES = 4;

  { Access is denied. }
  ERROR_ACCESS_DENIED = 5;

  { The handle is invalid. }
  ERROR_INVALID_HANDLE = 6;
  // ... и т.п.
Описание Win32-ошибки можно получить через функцию FormatMessage. В Delphi для этой системной функции с кучей параметров имеется (конкретно для нашего случая) более удобная для использования оболочка: функция SysErrorMessage. Она, по переданному ей коду ошибки Win32, возвращает его описание. Кстати, обратите внимание, что сообщения возвращаются локализованными. Т.е. если у вас русская Windows, то сообщения будут на русском. Если английская — на английском.

Суммируя сказанное, вызывать такие функции приходится примерно так:
{ готовим WndClass }
ClassAtom := RegisterClassEx(WndClass);
if ClassAtom = 0 then
begin
  // произошла ошибка с причиной из GetLastError
  Application.MessageBox(
    PChar('Произошла ошибка: ' + SysErrorMessage(GetLastError)), 
    PChar('Ошибка'), MB_OK or MB_ICONSTOP);
  Exit;
end;
// ... продолжение нормального выполнения

Как и в случае с управлением памятью - и в этом случае не надо следовать примеру Windows. Подобный стиль давно уже устарел. И вот что с ним не так (это не полный список):
  1. Чтобы вызвать функцию требуется два вызова: сама функция и GetLastError (добавьте к этому необходимость дважды вызывать саму функцию для получения от неё памяти - получается вообще страшный ужас аж в четыре вызова функций вместо одного);
  2. Вам требуется явно писать проверку вида if что-то then ошибка. И если вы забудете написать этот код, то получите баг: ваша программа будет продолжать выполнение при ошибке. Вероятно, портя данные и затрудняя локализацию бага (видимая проблема случится позже);
    • Подобные if-проверки также сильно визуально засоряют код;
  3. Если при ошибке вам нужно освободить какие-то ресурсы, да ещё если их несколько и вызовов функций тоже несколько, то правильный код для освобождения ресурсов может стать весьма нетривиальным;
  4. Вы никак не передадите дополнительную информацию. Например, никак не укажете какой именно аргумент неверен, или доступа к какому файлу у вас нет;
    • Вы никак не узнаете, какая именно функция завершилась неудачно: была ли это вызываемая вами функция, или, быть может, какая-то другая функция, которую могла вызвать вызываемая вами;
  5. Отладчик никак не уведомит вас о проблеме (хотя, гипотетически, вы можете поставить точку останова на GetLastError).

Несмотря на все минусы, у кодов ошибок есть и плюс: поскольку это просто числа, то они понятны любому языку программирования. Т.е. коды ошибок совместимы между разными языками.

Исключения

(и почему их не надо использовать)

Исключения лишены многих минусов кодов ошибок:
  1. Исключения не нужно явно проверять, ситуация по умолчанию - реакция на ошибку;
    • Программа не "засоряется" кодом проверки, он выносится отдельно;
  2. Легко освобождать ресурсы (через try-finally);
  3. Исключения легко расширять, наследовать, добавлять дополнительные поля, делать вложенные исключения;
  4. Отладчик уведомит вас о возникновении исключений;
    • Вы можете назначить свой код для диагностики исключений (т.н. трейсер исключений).

Но несмотря на все плюсы, у исключений есть один существенный минус, который перечёркивает все плюсы (применительно к API DLL).

Вспомните как возбуждаются исключения в Delphi:
var
  E: Exception;
begin
  E := EMyExceptionClass.Create('Something');
  raise E;
end;
Я разделил типичную строку "raise EMyExceptionClass.Create('Something');" на две, чтобы проблема стала ещё более очевидной. Мы создаём объект Delphi (исключение) и "кидаем" (throw) его. И тот, кто хочет обработать это исключение, делает так:
except
  on E: EMyException do
  begin
    ShowMessage(E.Message);
  end; // - здесь E удаляется
end;
Это означает, что объект Delphi передаётся от вызываемого (DLL), где исключение возбуждается, в вызывающего (exe), где исключение обрабатывается. Как мы узнали ранее (см. раздел "Типы данных") это - проблема. Другие языки программирования не знают что такое объект Delphi, как его прочитать, как его удалить. Даже сама Delphi не всегда это знает (например, если исключение возбуждается кодом, собранном на Delphi 7, а ловится кодом, собранном на Delphi XE, или наоборот). В других языках программирования используются похожие конструкции: исключение представлено объектом. Соответственно, Delphi код понятия не имеет как нужно работать с объектами на других языках.

Иными словами, исключения не нужно использовать по причине языковой несовместимости.

Следствие 1: исключения не должны покидать вашу DLL.

Следствие 2: вы должны ловить все исключения в своих экспортируемых функциях.

Следствие 3: все экспортируемые функции обязаны иметь глобальную конструкцию try-except.
function GetDynData(const AFlags: DWORD; out AData: IData): BOOL; stdcall;
begin
  try
    // ... непосредственно код функции, полезная нагрузка ... 
    Result := True;
  except
    // ... обработка исключений ...
    Result := False;
  end;
end;

Что использовать можно и нужно

(и какое использовать соглашение вызова)

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

В Delphi есть встроенная магия компилятора, которая может обернуть любую функцию в скрытый блок try-except с автоматическим (скрытым) вызовом функции обработки. И есть магия компилятора, которая работает наоборот: по возвращаемому коду ошибки автоматически возбуждает подходящее исключение.

Прежде чем мы познакомимся с этим волшебством, нам нужно познакомится с кодами ошибок в виде типа HRESULT. HRESULT — это тоже число, но теперь уже типа Integer. HRESULT уже не просто код ошибки, он состоит из нескольких частей, подробно рассматривать которые мы не будем, но достаточно сказать, что они включают в себя собственно код ошибки (то, что раньше было Win32 кодом), признак успешности или ошибки, идентификатор возбудителя ошибки. Коды ошибок начинаются с префикса E_ (например, E_FAIL, E_UNEXPECTED, E_ABORT или E_ACCESSDENIED), а коды успеха - с S_ (например, S_OK или S_FALSE). Легко определить успешность кода HRESULT можно сравнив его с нулём: ошибочные коды HRESULT должны быть меньше нуля.

Выделение признака успеха/ошибки означает, что теперь нет необходимости функции возвращать только этот признак (через BOOL), а сам код ошибки - через отдельную функцию (GetLastError). Теперь функция может вернуть всю информацию сразу, за один вызов:
function GetDynData(const AFlags: DWORD; out AData: IData): HRESULT; stdcall;
begin
  try
    // ... непосредственно код функции, полезная нагрузка ... 
    Result := S_OK;
  except
    // ... обработка исключений ...
    Result := E_FAIL; // какой-то из кодов ошибок
  end;
end;

Вместе с введением HRESULT был придуман и интерфейс IErrorInfo, который позволяет ассоциировать с возвращаемым HRESULT дополнительную информацию: произвольное описание, GUID возбуждающего (интерфейса), место возбуждения ошибки (произвольная строка), справку. Вам даже не нужно реализовывать этот интерфейс, в системе уже есть готовый объект - возвращаемый функцией CreateErrorInfo.

Наконец, в Delphi есть уже упоминаемая магия компилятора, которая может упростить написание такого кода. Для этого функция должна иметь соглашение вызова stdcall и возвращать HRESULT. Если раньше функция возвращала какой-то Result, то его надлежит сделать последним out-параметром, например:
// Было:
function GetDynData(const AFlags: DWORD): IData;

// Стало:
function GetDynData(const AFlags: DWORD; out AData: IData): HRESULT; stdcall;
Если функция удовлетворяет этим требованиям, то вы можете объявить её так:
function GetDynData(const AFlags: DWORD): IData; safecall;
Этот вариант будет двоично эквивалентен (т.е. полностью совместим) такому:
function GetDynData(const AFlags: DWORD; out AData: IData): HRESULT; stdcall;

Объявив функцию как safecall вы включите для неё магию компилятора, а именно:
  1. Возвращаемый результат будет автоматически преобразован в последний out-параметр;
  2. Функция будет скрыто передавать HRESULT (и, возможно, IErrorInfo);
  3. Вызов функции будет обёрнут в if-проверку возвращаемого кода. При получение ошибочного HRESULT будет возбуждено исключение:
    var
      Data: IData;
    begin
      Data := GetDynData(Flags); // возбуждает исключение при ошибке
      // выполнение продолжается только при успешном вызове
  4. Сама функция будет обёрнута в скрытый блок try-except, преобразующий исключение в HRESULT (и, возможно, в IErrorInfo):
    function GetDynData(const AFlags: DWORD): IData; safecall;
    begin 
      // ... сам код функции, полезная нагрузка ...
    end; // - скрытый блок try-except
Как вы видите, с такой поддержкой компилятора - можно писать код почти как если бы это была обычная функция в обычном модуле Delphi. И что самое вкусное - подобный подход могут использовать и другие языки программирования. Конечно, в них может не быть подходящей магии компилятора, но они вполне способны принять HRESULT у stdcall функции и проанализировать его (возможно, вместе с IErrorInfo).

Как правильно работать с safecall

Теперь, когда мы описали плюсы safecall, то настала пора для ложки дёгтя. Дело в том, что "из коробки" магия safecall работает в минимальном режиме. И чтобы получить от неё максимальную выгоду, нам нужно сделать дополнительные действия. К счастью, их нужно сделать один раз и можно использовать повторно в дальнейшем.

Пункт номер один: простые экспортируемые функции:
procedure DoSomething; safecall;
begin
  // ... код функции
end;

exports
  DoSomething;
Дело в том, что для обычных функций компилятор не позволяет произвести настройку процесса конвертирования исключения в HRESULT, всегда возвращая фиксированный код и теряя информацию. Поэтому вместо экспортируемых функций нужно использовать интерфейсы с методами. Т.е. было:
procedure DoSomething; safecall;
begin
  // ... сам код функции, полезная нагрузка ...
end;

function GetDynData(const AFlags: DWORD): IData; safecall;
begin 
  // ... сам код функции, полезная нагрузка ...
end;

function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
begin 
  // ... сам код функции, полезная нагрузка ...
end;

exports
  DoSomething,
  GetDynData,
  DoSomethingElse;
Стало:
type
  IMyDLL = interface 
  ['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
    procedure DoSomething; safecall;
    function GetDynData(const AFlags: DWORD): IData; safecall;
    function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
  end;

  TMyDLL = class(TInterfacedObject, IMyDLL)
  protected
    procedure DoSomething; safecall;
    function GetDynData(const AFlags: DWORD): IData; safecall;
    function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
  end;

procedure TMyDLL.DoSomething; safecall;
begin
  // ... сам код функции, полезная нагрузка ...
end;

function TMyDLL.GetDynData(const AFlags: DWORD): IData; safecall;
begin 
  // ... сам код функции, полезная нагрузка ...
end;

function TMyDLL.DoSomethingElse(AOptions: IOptions): BSTR; safecall;
begin 
  // ... сам код функции, полезная нагрузка ...
end;

function GetFunctions(out AFunctions: IMyDLL): HRESULT; stdcall;
begin
  try
    AFunctions := TMyDLL.Create;
    Result := S_OK;
  except
    on E: Exception do
      Result := HandleSafeCallException(E, ExceptAddr);
  end;
end;

exports
  GetFunctions;
где HandleSafeCallException - наша функция, которую мы опишем ниже.

Как видите, все экспортируемые функции мы поместили в единый интерфейс (объект) - это позволит нам настроить/контролировать процесс преобразования исключений в HRESULT. При этом DLL экспортирует единственную функцию, которую нам пришлось описать вручную, без safecall - что также позволило нам контролировать процесс преобразования. Не забываем, что она двоично совместима с safecall, поэтому, если вы хотите использовать эту DLL в Delphi, то вы можете делать так:
function GetFunctions: IMyDLL; safecall; external 'MyDLL.dll';
и это будет прекрасно работать.

Что касается объектов, то при возбуждении исключения в safecall методе компилятор вызывает виртуальный метод TObject.SafeCallException, который по умолчанию не делает ничего полезного, и который мы можем заменить на свой метод:
type
  TMyDLL = class(TInterfacedObject, IMyDLL)
  protected
    procedure DoSomething; safecall;
    function GetDynData(const AFlags: DWORD): IData; safecall;
    function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
  public
    function SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult; override;
  end;

function TMyDLL.SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult; 
begin
  Result := HandleSafeCallException(ExceptObject, ExceptAddr);
end;

Далее, когда код вызывает safecall метод, компилятор заворачивает вызов метода в обёртку CheckAutoResult, которая (в случае ошибочного кода) возбуждает исключение через глобальную функцию-переменную SafeCallErrorProc, которую, опять же, мы можем заменить на свою:
procedure RaiseSafeCallException(ErrorCode: HResult; ErrorAddr: Pointer);
begin
  // ... наш код ...
end;

initialization
  SafeCallErrorProc := RaiseSafeCallException;
end.
Теперь нам осталось сделать так, чтобы наши HandleSafeCallException и RaiseSafeCallException работали бы парой и делали что-то полезное.

Для начала нам потребуются две вспомогательные функции-обёртки:
uses
  ActiveX; // или Ole2

function SetErrorInfo(const ErrorCode: HRESULT; const ErrorIID: TGUID; 
  const Source, Description, HelpFileName: WideString;
  const HelpContext: Integer): HRESULT;
var
  CreateError: ICreateErrorInfo;
  ErrorInfo: IErrorInfo;
begin
  Result := E_UNEXPECTED;
  if Succeeded(CreateErrorInfo(CreateError)) then
  begin
    CreateError.SetGUID(ErrorIID);
    if Source <> '' then
      CreateError.SetSource(PWideChar(Source));
    if HelpFileName <> '' then
      CreateError.SetHelpFile(PWideChar(HelpFileName));
    if Description <> '' then
      CreateError.SetDescription(PWideChar(Description));
    if HelpContext <> 0 then
      CreateError.SetHelpContext(HelpContext);
    if ErrorCode <> 0 then
      Result := ErrorCode;
    if CreateError.QueryInterface(IErrorInfo, ErrorInfo) = S_OK then
      ActiveX.SetErrorInfo(0, ErrorInfo);
  end;
end;

procedure GetErrorInfo(out ErrorIID: TGUID; out Source, Description, HelpFileName: WideString; out HelpContext: Longint);
var
  ErrorInfo: IErrorInfo;
begin
  if ActiveX.GetErrorInfo(0, ErrorInfo) = S_OK then
  begin
    ErrorInfo.GetGUID(ErrorIID);
    ErrorInfo.GetSource(Source);
    ErrorInfo.GetDescription(Description);
    ErrorInfo.GetHelpFile(HelpFileName);
    ErrorInfo.GetHelpContext(HelpContext);
  end
  else
  begin
    FillChar(ErrorIID, SizeOf(ErrorIID), 0);
    Source := '';
    Description := '';
    HelpFileName := '';
    HelpContext := 0;
  end;
end;
Как несложно сообразить, они предназначены для передачи и получения дополнительной информации вместе с HRESULT.

Далее нам необходим способ как-то передавать имя класса исключения. Делать это можно разными способами. Например, передавать его непосредственно в HRESULT. Для этого его нужно закодировать. Например, так:
uses
  ComObj, // для типов EOleSysError и EOleException
  VarUtils; // для ESafeArrayError

const
  // Идентификатор нашей системы передачи исключений
  ThisDllIID: TGUID   = '{AA76E538-EF3C-4F35-9914-B4801B211A6D}';
  // "Customer" бит, всегда равен 0 в кодах Microsoft
  CUSTOMER_BIT        = 1 shl 29;
  // Delphi использует это значение для передачи EAbort
  // Подразумевается, что сам E_Abort должен приводить
  // к показу сообщения "операция прервана",
  // а EAbort - обрабатываться молча
  EAbortRaisedHRESULT = HRESULT(E_ABORT or CUSTOMER_BIT);

function Exception2HRESULT(const E: TObject): HRESULT;

  function NTSTATUSFromException(const E: EExternal): DWORD;
  begin
    // ...
  end;

begin
  if E = nil then
    Result := E_UNEXPECTED
  else
  if not E.InheritsFrom(Exception) then
    Result := E_UNEXPECTED
  else
  if E.ClassType = Exception then
    Result := E_FAIL
  else
  if E.InheritsFrom(ESafecallException) then
    Result := E_FAIL
  else
  if E.InheritsFrom(EAssertionFailed) then
    Result := E_UNEXPECTED
  else
  if E.InheritsFrom(EAbort) then
    Result := EAbortRaisedHRESULT
  else
  if E.InheritsFrom(EOutOfMemory) then
    Result := E_OUTOFMEMORY
  else
  if E.InheritsFrom(ENotImplemented) then
    Result := E_NOTIMPL
  else
  if E.InheritsFrom(ENotSupportedException) then
    Result := E_NOINTERFACE
  else
  if E.InheritsFrom(EOleSysError) then
    Result := EOleSysError(E).ErrorCode
  else
  if E.InheritsFrom(ESafeArrayError) then
    Result := ESafeArrayError(E).ErrorCode
  else
  if E.InheritsFrom(EOSError) then
    Result := HResultFromWin32(EOSError(E).ErrorCode)
  else
  if E.InheritsFrom(EExternal) then
    if Failed(HRESULT(EExternal(E).ExceptionRecord.ExceptionCode)) then
      Result := HResultFromNT(Integer(EExternal(E).ExceptionRecord.ExceptionCode))
    else
      Result := HResultFromNT(Integer(NTSTATUSFromException(EExternal(E))))
  else
    Result := MakeResult(SEVERITY_ERROR, FACILITY_ITF, Hash(E.ClassName)) or CUSTOMER_BIT;
end;
Здесь мы проверяем на несколько специальных предопределённых классов, а также у нас есть возможность прямо передавать коды Win32 и коды аппаратных исключений. Для всех прочих (специфичных для Delphi) классов исключений мы используем хэш от имени класса вместе с FACILITY_ITF. В качестве хэша можно использовать, к примеру, SDBM - это очень простая хэш-функция с хорошей рандомизацией результата. Конечно, вы можете использовать любой другой способ - например, просто вручную выделить и зафиксировать коды для каждого класса исключения.
HRESULT с типами FACILITY_NULL и FACILITY_RPC имеют универсальное значение, поскольку они определены Microsoft. HRESULT с FACILITY_ITF определяются функцией или методом интерфейса, из которых они возвращаются. Это означает, что одно и то же 32-битное значение в FACILITY_ITF, но возвращаемое двумя разными интерфейсами, может иметь разный смысл. Таким образом, Microsoft может определять несколько универсальных кодов ошибок, в то же время позволяя другим программистам определять новые коды ошибок, не опасаясь конфликта. Соглашение распределении кодов выглядит следующим образом:
  • HRESULT с типами, отличных от FACILITY_ITF, могут быть определены только Microsoft;
  • HRESULT с типом FACILITY_ITF определяются исключительно разработчиком интерфейса или функции, которая возвращает HRESULT. Чтобы избежать конфликтующих HRESULT, тот, кто определяет интерфейс, отвечает за координацию и публикацию кодов HRESULT, связанных с этим интерфейсом;
  • Все HRESULT, определяемые Microsoft, имеют значение кода ошибки в диапазоне $0000-$01FF. Хотя вы можете использовать любой код вместе с FACILITY_ITF, но рекомендуется использовать значения в диапазоне $0200-$FFFF. Эта рекомендация предназначена для уменьшения путаницы с кодами Microsoft.
Вот почему в коде выше мы также определили ThisDllIID - это идентификатор "интерфейса", который задаёт смысл для возвращаемых кодов с типом FACILITY_ITF. Это значение нужно передавать как ErrorIID в SetErrorInfo, определённую выше.

29-й "Customer" бит изначально был зарезервированным битом, который в дальнейшем был выделен для использования в качестве флага, указывающего определён ли код Microsoft (0) или сторонним разработчиком (1). В некотором роде этот бит дублирует FACILITY_ITF. Обычно даже сторонние разработчики используют только FACILITY_ITF. В данном же случае мы его ставим для уменьшения возможных проблем с плохим кодом (который не учитывает GUID интерфейса).

С обратной конвертацией (код в исключение) всё немного сложнее, нам потребуются таблицы для поиска класса исключения по коду. Простая реализация может выглядеть так:
function HRESULT2Exception(const E: HRESULT): Exception;

  function MapNTStatus(const ANTStatus: DWORD): ExceptClass;
  begin
    // ...
  end;

  function MapException(const ACode: DWORD): ExceptClass;
  begin
    // ...
  end;

var
  NTStatus: DWORD;
  ErrorIID: TGUID;
  Source: WideString;
  Description: WideString;
  HelpFileName: WideString;
  HelpContext: Integer;
begin
  if GetErrorInfo(ErrorIID, Source, Description, HelpFileName, HelpContext) then
  begin
    if Pointer(StrToInt64Def(Source, 0)) <> nil then
      ErrorAddr := Pointer(StrToInt64(Source));
  end
  else
    Description := SysErrorMessage(DWORD(E));

  if (E = E_FAIL) or (E = E_UNEXPECTED) then
    Result := Exception.Create(Description)
  else
  if E = EAbortRaisedHRESULT then
    Result := EAbort.Create(Description)
  else
  if E = E_OUTOFMEMORY then
  begin
    OutOfMemoryError;
    Result := nil;
  end
  else
  if E = E_NOTIMPL then
    Result := ENotImplemented.Create(Description)
  else
  if E = E_NOINTERFACE then
    Result := ENotSupportedException.Create(Description)
  else
  if HResultFacility(E) = FACILITY_WIN32 then
  begin
    Result := EOSError.Create(Description);
    EOSError(Result).ErrorCode := HResultCode(E);
  end
  else
  if E and FACILITY_NT_BIT <> 0 then
  begin
    // Получаем класс исключения по коду
    NTStatus := Cardinal(E) and (not FACILITY_NT_BIT);
    Result := MapNTStatus(NTStatus).Create(Description);

    // На всякий случай делаем заглушку для ExceptionRecord
    ReallocMem(Pointer(Result), Result.InstanceSize + SizeOf(TExceptionRecord));
    EExternal(Result).ExceptionRecord := Pointer(NativeUInt(Result) + Cardinal(Result.InstanceSize));
    FillChar(EExternal(Result).ExceptionRecord^, SizeOf(TExceptionRecord), 0);

    EExternal(Result).ExceptionRecord.ExceptionCode := cDelphiException;
    EExternal(Result).ExceptionRecord.ExceptionAddress := ErrorAddr;
  end
  else
  if (E and CUSTOMER_BIT <> 0) and
     (HResultFacility(E) = FACILITY_ITF) and
     CompareMem(@ThisDllIID, @ErrorIID, SizeOf(ErrorIID)) then
    Result := MapException(HResultCode(E)).Create(Description)
  else
    Result := EOleException.Create(Description, E, Source, HelpFileName, HelpContext);
end;
В целом код достаточно прямолинеен, за исключением аппаратных исключений. Для них мы делаем эмуляцию.

Также замечу, что поле Source интерфейса IErrorInfo должно указывать на место, в котором возникла ошибка. Поле это произвольно и определяется разработчиком интерфейса (т.е. опять же по GUID). В данном случае мы просто туда пишем адрес исключения. Но, к примеру, если вы используете трейсер исключений, то можете писать туда стек вызовов.

Тогда с указанными выше вспомогательными функциями, наши HandleSafeCallException и RaiseSafeCallException становятся тривиальными:
function HandleSafeCallException(ExceptObj: TObject; ErrorAddr: Pointer): HRESULT;
var
  ErrorMessage: String;
  HelpFileName: String;
  HelpContext: Integer;
begin
  if ExceptObj is Exception then
    ErrorMessage := Exception(ExceptObj).Message
  else
    ErrorMessage := SysErrorMessage(DWORD(E_FAIL));
  if ExceptObj is EOleException then
  begin
    HelpFileName := EOleException(ExceptObj).HelpFile;
    HelpContext := EOleException(ExceptObj).HelpContext;
  end
  else
  begin
    HelpFileName := '';
    if ExceptObj is Exception then
      HelpContext := Exception(ExceptObj).HelpContext
    else
      HelpContext := 0;
  end;

  Result := SetErrorInfo(Exception2HRESULT(ExceptObj), ThisDllIID,
    '$' + IntToHex(NativeUInt(ErrorAddr), SizeOf(ErrorAddr) * 2), ErrorMessage,
    HelpFileName, HelpContext);
end;

procedure RaiseSafeCallException(ErrorCode: HResult; ErrorAddr: Pointer);
var
  E: Exception;
begin
  E := HRESULT2Exception(ErrorCode, ErrorAddr);
  raise E at ErrorAddr;
end;
Примечание: в нашей модели мы не используем поля для справки интерфейса IErrorInfo.

Надо заметить, что если интерфейс использует вместе с HRESULT и IErrorInfo, то ему также следует реализовывать ещё и интерфейс ISupportErrorInfo. Некоторые языки программирования этого требуют. Вызывая ISupportErrorInfo.InterfaceSupportsErrorInfo, клиентская сторона может определить, что объект поддерживает дополнительную информацию.

И последний момент - в реализации Delphi для Windows 32-бита есть неприятный баг, которого нет в 64-битном RTL, а также на других платформах. Исправление этого бага включено в примеры кода по ссылке в конце статьи.

Если у вас возникают проблемы с отладкой вашей DLL, то я разобрал этот вопрос в этой статье.


Обход проблем DllMain

DllMain - это специальная функция в DLL, которая вызывается системой, когда DLL загружается в процесс, выгружается из него (а также подключается/отключается к/от потока). К примеру, секции initialization и finalization ваших Delphi модулей выполняются именно внутри DllMain.

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

Решение проблемы заключается в том, чтобы не делать ничего в DllMain (читай: не пишите кода в секциях initialization и finalization модулей когда вы создаёте DLL).

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

Вот шаблон кода:
// В заголовочниках:
type
  IMyDll = interface
  ['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
    procedure InitDLL(AOptional: IUnknown = nil); safecall;
    procedure DoneDLL; safecall;
    // ...
  end;

// В DLL:

type
  TInitFunc = procedure(const AOptional: IUnknown);
  TDoneFunc = procedure;
  TInitDoneFunc = record
    Init: TInitFunc;
    Done: TDoneFunc; 
  end;

procedure RegisterInitFunc(const AInitProc: TInitFunc; const ADoneFunc: TDoneFunc = nil);

// ...

var
  InitDoneFuncs: array of TInitDoneFunc;

procedure RegisterInitFunc(const AInitProc: TInitFunc; const ADoneFunc: TDoneFunc);
begin
  SetLength(InitDoneFuncs, Length(InitDoneFuncs) + 1);
  InitDoneFuncs[High(InitDoneFuncs)].Init := AInitProc;
  InitDoneFuncs[High(InitDoneFuncs)].Done := ADoneFunc;
end;

procedure TMyDLL.InitDLL(AOptional: IUnknown); safecall;
var
  X: Integer;
begin
  for X := 0 to High(InitDoneFuncs) do
    if Assigned(InitDoneFuncs[X].Init) then
      InitDoneFuncs.Init(AOptional);
end;

procedure TMyDLL.DoneDLL; safecall;
var
  X: Integer;
begin
  for X := 0 to High(InitDoneFuncs) do
    if Assigned(InitDoneFuncs[X].Done) then
      InitDoneFuncs.Done;
end;

// В модулях:

procedure InitUnit(const AOptional: IUnknown);
begin
  // ... код, который вы обычно помещаете в секцию initialization
end;

procedure DoneUnit;
begin
  // ... код, который вы обычно помещаете в секцию finalization
end;

initialization
  RegisterInitFunc(InitUnit, DoneUnit);
end;
Параметр AOptional сделан с заделом на будущее. В коде выше он не используется, но в дальнейшем (в следующей версии DLL) вы можете использовать его, чтобы передать параметры инициализации. IUnknown - это базовый интерфейс, от которого наследуются все другие интерфейсы (т.е. некий аналог TObject для интерфейсов).

Надеюсь, этот код достаточно понятен. Разумеется, его надо разносить по разным модулям и секциям. Интерфейс - в заголовочники, объявление RegisterInitFunc - в interface общего модуля DLL, вызывать её нужно из секции initialization других модулей.

Разумеется, в документации вашего SDK должны быть слова о том, что использующий вашу DLL обязан вызвать метод InitDLL сразу после загрузки вашей DLL функцией LoadLibrary и вызвать метод DoneDLL непосредственно перед выгрузкой DLL функцией FreeLibrary:
var
  DLL: HMODULE;
  DLLApi: IMyDll;
begin
  DLL := LoadLibrary('MyDLL.dll');
  Win32Check(DLL <> 0);
  try
    DLLApi.InitDLL(nil);

    // работа с DLL, например, вызов DLLApi.GetDynData

  finally
    DLLApi.DoneDLL;
    DLLApi := nil;
    FreeLibrary(DLL);
  end;
end;

Больше подробностей и примеров о DllMain:
  1. DllMain и жизнь до родов
  2. DllMain - страшилка на ночь
  3. Несколько причин, чтобы не делать ничего страшного в своей DllMain
  4. Ещё причины, почему не надо делать ничего страшного в DllMain: случайная блокировка


Callback-функции

(функции обратного вызова)

Callback-функция (англ. call — вызов, англ. back — обратный) или функция обратного вызова в программировании — передача исполняемого кода в качестве одного из параметров другого кода. К примеру, если вы хотите установить таймер с использованием Windows API, вы можете вызвать функцию SetTimer, передав в неё указатель на свою функцию, которая и будет callback-функцией. Система будет вызывать вашу функцию каждый раз, когда срабатывает таймер:
procedure MyTimerHandler(Wnd: HWND; uMsg: UINT; idEvent: UINT_PTR; dwTime: DWORD); stdcall;
begin
  // Будет вызвана через 100 мс.
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  SetTimer(Handle, 1, 100, @MyTimerHandler);
end;
Вот ещё пример: если вы хотите найти все окна на рабочем столе, вы можете использовать функцию EnumWindows:
function MyEnumFunc(Wnd: HWND; lpData: LPARAM): Bool; stdcall;
begin
  // Вызывается для каждого найденного окна в системе
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  EnumWindows(@MyEnumFunc, 0);
end;
Поскольку функция обратного вызова обычно выполняет ту же задачу, что и код, который её устанавливает, то получается, что обоим кускам кода нужно работать с одними и теми же данными. Следовательно, данные от устанавливающего кода необходимо как-то передать в функцию обратного вызова. Для этой цели в функциях обратного вызова предусматриваются т.н. user-параметры: это либо указатель, либо целое число (обязательно типа Native(U)Int, но не просто (U)Int), который никак не используются самим API и прозрачно передаются в callback-функцию. Либо (в редких случаях) это может быть какое-то значение, уникально идентифицирующее вызов функции.

К примеру, в SetTimer есть idEvent, а в EnumWindows есть lpData. Мы можем использовать эти параметры, чтобы передать произвольные данные. Вот, к примеру, как можно найти все окна заданного класса:
type
  PEnumArgs = ^TEnumArgs;
  TEnumArgs = record
    ClassName: String;
    Windows: TStrings;
  end;

function FindWindowsOfClass(Wnd: HWND; lpData: LPARAM): Bool; stdcall;
var
  Args: PEnumArgs;
  WndClassName, WndText: String;
begin
  Args := Pointer(lpData);

  SetLength(WndClassName, Length(Args.ClassName) + 2);
  SetLength(WndClassName, GetClassName(Wnd, PChar(WndClassName), Length(WndClassName)));
  if WndClassName = Args.ClassName then
  begin
    SetLength(WndText, GetWindowTextLength(Wnd) + 1);
    SetLength(WndText, GetWindowText(Wnd, PChar(WndText), Length(WndText)));
    Args.Windows.Add(Format('%8x : %s', [Wnd, WndText]));
  end;

  Result := True;
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Args: TEnumArgs;
begin
  // В Edit можно вводить значения типа 
  // 'TForm1', 'IME', 'MSTaskListWClass', 'Shell_TrayWnd', 'TTOTAL_CMD', 'Chrome_WidgetWin_1'
  Args.ClassName := Edit1.Text;  
  Args.Windows   := Memo1.Lines;

  Memo1.Lines.BeginUpdate;
  try
    Memo1.Lines.Clear;
    EnumWindows(@FindWindowsOfClass, LPARAM(@Args));
  finally
    Memo1.Lines.EndUpdate;
  end;
end;

Примечание: вот ещё один пример как не надо делать - не надо делать как в Windows. Если вам нужно просто получить список чего-то - не надо делать функцию обратного вызова, просто верните список в массиве (завернув его в интерфейс или передавая как блок памяти). Функцию обратного вызова нужно использовать только если список создаётся долго, а вам не нужны все элементы. Тогда функция обратного вызова может вернуть признак "стоп", не завершая создание полного списка до конца.

Примечание: неким аналогом user-параметров являются свойства Tag и Data, хотя их использование не всегда бывает идеологически верным (правильно: создать класс-наследник).

Из вышесказанного следует вывод: если в вашем API нужно сделать функцию обратного вызова, то она обязана иметь пользовательский параметр размера Pointer, который не будет использоваться вашим API. Например:
// Неправильно!
type
  TNotifyMeProc = procedure; safecall;

  IMyDllAPI = interface 
    // ...
    procedure NotifyMe(const ANotifyEvent: TNotifyMeProc); safecall;
  end;
// Правильно
type
  TNotifyMeProc = procedure(const AUserArg: Pointer); safecall;

  IMyDllAPI = interface 
    // ...
    procedure NotifyMe(const ANotifyEvent: TNotifyMeProc; const AUserArg: Pointer = nil); safecall;
  end;
А если вы забудете это сделать - вызывающему придётся использовать уродские хаки, чтобы обойти ваш кривой API.

Разумеется, вместо функции + параметр можно использовать просто интерфейс:
// Правильно
type
  INotifyMe = interface
  ['{07FA30E4-FE9B-4ED2-8692-1E5CFEE4CF3F}']
    procedure Notify; safecall;
  end;

  IMyDllAPI = interface 
    // ...
    procedure NotifyMe(const ANotifyEvent: INotifyMe); safecall;
  end;
Это - предпочтительней, потому что и обработка ошибок через safecall в интерфейсах проще, и интерфейс может содержать сколько угодно много параметров, и с объектами (формой) удобнее интегрироваться. Например:
type
  TForm1 = class(TForm, INotifyMe)
    // ...
    procedure Notify; safecall;
  private
    FAPI: IMyDllAPI;
  public
    function SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult; override;
  end;

// ...

procedure TForm1.FormCreate(Sender: TObject); 
begin
  // ... загружаем DLL, получаем API

  FAPI.NotifyMe(Self); // просим дёрнуть нас при событии
end;

procedure TForm1.Notify; 
begin
  ShowMessage('Событие произошло');
end;


Прочие правила

  1. Если вы не только разрабатываете, но и используете DLL, то загружайте DLL правильно;
  2. Если по какой-то причине вы не используете safecall, то не возвращайте сложные типы через Result, делайте out-параметр. Проблема в том, что Delphi и MS Visual C++ расходятся во мнении как трактовать возвращаемый по ссылке stdcall-функцией результат: как var или как out. Соответственно, для safecall такой проблемы нет, поскольку Result у неё - всегда Integer (HRESULT) - простой тип, для которого var и out эквивалентны;
  3. Все интерфейсы API должны иметь уникальный IID/GUID (интерфейсы вне API (не упомянутые в заголовочниках) могут не иметь GUID, хотя я бы рекомендовал всегда указывать IID). Создать GUID для использования в качестве IID (Interface ID) вы можете нажав Ctrl + Shift + G в редактора кода Delphi - эта комбинация вставит выражение вида ['{C5DBE4DC-B4D7-475B-9509-E43193796633}'] (разумеется, каждый раз с уникальным GUID) прямо под курсор в редакторе;
  4. Как только вы опубликовали какой-то тип (интерфейс), т.е. выпустили "на волю" вашу DLL с этим интерфейсом - вы не должны его изменять. Если вам нужно его расширить или изменить - вы вводите новый интерфейс (новую версию интерфейса), но не меняете старый
    // Было:
    type
      IMyDLL = interface
      ['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
        procedure InitDLL(AOptional: IUnknown = nil); safecall;
        procedure DoneDLL; safecall;
        function GetDynData(const AFlags: DWORD): IData; safecall;
      end;
      
    // После публикации - так нельзя:
    type
      IMyDLL = interface
      ['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
        procedure InitDLL(AOptional: IUnknown = nil); safecall;
        procedure DoneDLL; safecall;
        procedure DoSomething; safecall; // добавили
        function GetDynData(const AFlags: DWORD): IData; safecall;
      end;
    
    // Так - можно:
    type
      IMyDLLv1 = interface
      ['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
        procedure InitDLL(AOptional: IUnknown = nil); safecall;
        procedure DoneDLL; safecall;
        function GetDynData(const AFlags: DWORD): IData; safecall;
      end;
    
      IMyDLLv2 = interface(IMyDLLv1)
      ['{69E77989-64DC-4177-975C-487818598C70}']
        procedure DoSomething; safecall; // добавили
      end;
  5. Если функция или метод возвращает интерфейс, то не надо делать так:
    // Неправильно!
    function GetSomething: ISomething; safecall;
    
    // ...
    
    var
      Something: ISomething;
    begin
      Something := GetSomething;
    Конечно, в начале это удобное решение: вы можете вызывать функции "как обычно" и даже сцеплять их в цепочки вида Control.GetPicture.GetImage.GetColorInfo.GetBackgroundColor. Однако такое положение дел будет существовать только в самой первой версии системы. Как только вы начнёте развивать систему, у вас начнут появляться новые интерфейсы. В не столь отдалённом будущем у вас будет куча продвинутых интерфейсов, а базовые интерфейсы, которые были в программе изначально, в момент её рождения, будут реализовывать лишь тривиально-неинтересные функции. Итого, очень часто вызывающему коду будут нужны новые интерфейсы, а не оригинальные. Что это значит? Это значит, что коду нужно вызвать оригинальную функцию, получить оригинальный интерфейс, затем запросить у него новый (через Supports/QueryInterface) и лишь затем использовать новый интерфейс. Получается не так удобно, даже скорее неудобно: имеем тройной вызов (оригинальный + конвертация + нужный). Лучшее решение заключается в том, чтобы вызывающий код указывал бы вызываемой функции, какой интерфейс его интересует: новый или старый:
    // Правильно
    procedure GetSomething(const AIID: TGUID; out Intf); safecall;
    
    // ... 
    
    var
      Something: ISomething;
    begin
      GetSomething(ISomething, Something);
  6. Если объект реализует интерфейс, то в вашем коде не должно быть переменных этого класса. Т.е.:
    type
      TSomeObject = class(TSomeOtherClass, ISomeInterface)
      // ... 
      end;
    
    var
      Obj: TSomeObject; // - так нельзя!
      Obj: ISomeInterface; // - так можно
    begin
      Obj := TSomeObject.Create;
      // ...
  7. Если вы реализуете расширение интерфейса наследованием - не забудьте явно перечислить все его предки в реализующем объекте. Например:
    type
      ISomeInterfaceV1 = interface
      ['{A80A78ED-5836-49C4-B6C2-11F531103FE7}']
        procedure A;
      end;
     
      ISomeInterfaceV2 = interface(ISomeInterfaceV1) // ISomeInterfaceV2 наследуется от ISomeInterfaceV1
      ['{EBDD52A1-489B-4564-998E-09FCCF923F48}']
        procedure B;
      end;
     
      // Неправильно!
      TObj = class(TInterfacedObject, ISomeInterfaceV2) // указан ISomeInterfaceV2, но не ISomeInterfaceV1
      protected
        procedure A; 
        procedure B;
      end;
    
    var
      SI1: ISomeInterfaceV1;
      SI2: ISomeInterfaceV2;
    begin
      Supports(SI2, ISomeInterfaceV1, SI1);
      Assert(Assigned(SI1)); // утверждение сработает, т.к. SI1 = nil (вызов Supports выше вернул False)
    end;
    Правильно делать так:
      // Правильно
      TObj = class(TInterfacedObject, ISomeInterfaceV1, ISomeInterfaceV2)
      // ...
  8. Не нужно делать реализацию методов интерфейса виртуальными:
    type
      ISomeInterfaceV1 = interface
      ['{C25F72B0-0BC9-470D-8F43-6F331473C83C}']
        procedure A;
      end;
    
      TObj = class(TInterfacedObject, ISomeInterfaceV1)
      protected
        // Неправильно!
        procedure A; virtual;
      end;
    Делайте так:
      TObj = class(TInterfacedObject, ISomeInterfaceV1)
      protected
        // Правильно
        procedure A;
      end;
  9. Не помечайте интерфейсные параметры модификатором const:
    // Неправильно!
    procedure DoSomething(const AArg: ISomething); safecall;
    
    // Правильно
    procedure DoSomething(AArg: ISomething); safecall;
  10. Если в будущем вы будете расширять API DLL, то вам следует следовать правилам расширения и обратной совместимости;
  11. Прочие негласные правила.
  12. Check-список для проверки вашего API.


Заключение

Скачать пример DLL API можно тут. В архиве - группа из двух проектов (DLL и использующее её приложение). DLL реализует пример API с функциями-примерами. В папке SDK лежит SDK, состоящий из:
  • Заголовочника SampleDLLHeaders.pas;
  • Документации в формате CHM и PDF (а также исходники в виде проекта Help&Manual);
  • А также специфичного для Delphi файла поддержки DelphiSupport.pas.
Разработчики на других языках программирования могут использовать SampleDLLHeaders.pas, а разработчики Delphi - SampleDLLHeaders.pas и DelphiSupport.pas.

Заголовочники представлены только в виде Delphi кода. Перевод на другие языки программирования оставлен в качестве домашнего задания. Автоматизировать перевод можно с помощью библиотеки типов (TLB), как описано здесь.

Модуль DelphiSupport.pas может подключаться как в DLL, так и в приложения, её использующие. Он содержит:
  • Обработку safecall (вместе с исправлением RSP-24652);
  • Базовый объект TBaseObject для реализации интерфейсов с поддержкой обработки safecall и отладочными проверками;
  • Готовые классы: аллокатор TMalloc и враппер TNotify;
  • Функцию RegisterInitFunc для регистрации инициализации модулей в DLL;
  • Функцию LoadDLL для правильной загрузки DLL.

В API DLL имеются функции-примеры:
  • Возвращающая массив строк GetData;
  • Возвращающая динамическую память GetMemory;
  • Функция обратного вызова, устанавливаемая NotifyMe;
  • Тест ошибок/исключений TryAbort, TryAccessViolation и т.д.

Вызывающее приложение показывает как "загрузку-использование-выгрузку", так и "загрузку, использование, выгрузку".

16 комментариев :

  1. Чьорт, это может быть интересной темой, но не в двадцатый же раз >‿< (ладно, извиняюсь).

    С памятью не упомянут один интересный способ, который на самом-то деле очень часто используется (и является упрощённым аналогом IMalloc).
    Если DLL решает какую-то одну задачу и является реентерабельной (не имеющей глобального состояния), в ней обычно есть понятие «главного» объекта в рамках этой задачи: в Lua это function lua_newstate(...): lua_State, в Newton — function NewtonCreate(...): PNewtonWorld.

    Эти функции имеют параметр-коллбэк realloc: function(p: pointer; sz: SizeUint[; userParam: pointer]): pointer, который DLL обязуется использовать для работы с памятью: как минимум — возвращаемой пользователю, а в идеале — всей.
    Да, это усложняет написание DLL, в Delphi и вовсе «идеальный» вариант невозможен для аллокаций, выполняемых под капотом — строк/массивов/классов.
    GetMem(sz) — синоним realloc(nil, sz), а FreeMem(p) — синоним realloc(p, 0), так что достаточно одной функции.

    Достоинства:
    1) Как и с IMalloc, пользователь может спокойно освобождать память, полученную от DLL, через FreeMem (или что он там напишет в реализации realloc).
    2) Вид коллбэка может быть заточен под специфику DLL. Например, Lua использует type lua_Alloc = function(userParam: pointer; p: pointer; oldSize, newSize: SizeUint): pointer, где при выделении нового блока (когда p = nil) oldSize кодирует, для какой разновидности объектов выделяется память. Эта информация может использоваться в кастомных аллокаторах и для отладки.

    Для ошибок, к слову, можно использовать тот же подход с коллбэком. Со стороны пользователя MyDLL это будет выглядеть примерно так:
    procedure CustomThrow(msg: PWideChar; code: uint32); stdcall;
    begin
        raise Exception.CreateFmt('%s (%u)', [widestring(msg), code]);
    end;

    state := MyDLL_CreateState(..., @CustomThrow);
    MyDLL_DoSomething(state, ...); // при ошибке бросит исключение через CustomThrow

    А со стороны разработчика:
    procedure MyDLL_DoSomething(state: PMyDLL_State; ...); stdcall;
    begin
        try
            state^.internal.DoSomething(state, ...);
        except
            on E: Exception do state^.customThrow(E.Message, 42);
        end;
    end;

    (На самом деле сложнее, т. к. такая customThrow потенциально срезает «чужеродные» кадры стека, принадлежащие DLL, в обход нормальной финализации — так что придётся руками скопировать сообщение в локальный array[0 .. max] of widechar и вызывать customThrow уже в самом конце в спокойной обстановке, когда никаких активных блоков обработки исключений и авто-объектов в стеке гарантированно нет. Но это решаемо, и что главное, это проблема разработчика DLL, а не пользователя.)

    Впрочем, с таким вот noreturn-коллбэком уже спорно. Я видел похожий приём разве что в libpng, которая далека от идеала проектирования DLL, ну и сам иногда добавляю в сторонние библиотеки, хотя обычно с другой целью — прерывать операцию, бросая что-то типа EAbort, изнутри библиотек, которые такой возможности изначально не предусматривали.

    ОтветитьУдалить
  2. Так всё таки нужно помечать интерфейсные параметры модификатором const или нет? Я видел обсуждение Вашей статьи на SQL.RU, там утверждается наоборот - const должен быть указан.
    Так где правда?

    ОтветитьУдалить
    Ответы
    1. Хотите API с защитой "от дурака" - убирайте const. Хотите экономить на спичках - добавляйте const.

      Удалить
  3. Пару дней бились с проблемой: было приложение на Delphi 7 и dll для этого приложения. Перевели на Delpi XE3. При работе под Win 10 все в порядке. А под Win XP и Win 7 приложение намертво зависает на FreeLibrary. Причем, разрядность ОС не важна. Перепробовал все что можно, выбросил большую часть кода (формочки и т.п.), опасаясь, что где-то там косяки с памятью. Не помогло. Заработало после удаления ShareMem :)
    Детально разбираться в причинах времени нет, так что просто поставил в памяти галочку: долой ShareMem.

    ОтветитьУдалить
  4. Статья шикарная! Спасибо Вам за такую понятную статью, в которой все последовательно, логично и разложено по полочкам. Узнал для себя много нового и многое стало понятно. Переписал много кода, после прочтения статьи. И жаль, что в последнее время Вы так мало пишите статей. Ваш блог, один из не многих, где можно найти толковую информацию по Делфи на русском языке, а такой детальности и глубину погружения в какую-то область, я не встречал нигде. Пожалуйста, пишите побольше статей.

    ОтветитьУдалить
  5. Огромная благодарность за статью! Очень интересно, доступно и позновательно.

    ОтветитьУдалить
  6. BSTR, CoTaskMemAlloc, возвращение интерфейсов из DLL, осталось унаследоваться от IDispatch и получится COM DLL.

    ОтветитьУдалить
    Ответы
    1. Это к тому, что надо сразу COM делать? Или наоборот?

      В COM там и отложенная выгрузка, и регистрация, и апартаменты, и контексты, и много ещё чего.

      Удалить
  7. почему-то ссылка на архив не работает

    ОтветитьУдалить
    Ответы
    1. У меня работает. Может, браузер не понимает, что от него хотят? Попробуйте открыть новое окно и вставить ссылку вручную: https://files.gunsmoker.ru/files/downloads/SampleDllAPI.zip

      Удалить
    2. Там была ссылка http://, я изменил на https:// - возможно, проблема была в этом? Типа, настройки безопасности браузера, не дают открыть небезопасную ссылку с безопасной страницы?

      Удалить
  8. в архиве ошибка в SampleDLLHeaders.pas в строке 61 стоит:
    procedure NotifyMe(ANotifunction GetMemory(out ADataSize: DWORD): Pointer; safecall;fier: INotify); safecall;

    ОтветитьУдалить
  9. по всей видимости правильно так:
    procedure NotifyMe(ANotifier: INotify); safecall;
    function GetMemory(out ADataSize: DWORD): Pointer; safecall;

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку.

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.

Примечание. Отправлять комментарии могут только участники этого блога.