28 августа 2013 г.

Исправляем плохой дизайн API: функции обратного вызова без user-аргумента

Callback-функция (англ. call — вызов, англ. back — обратный) или функция обратного вызова в программировании — передача исполняемого кода в качестве одного из параметров другого кода.

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

Введение в callback-функции

К примеру, если вы хотите установить таймер с использованием 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;

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

Static и callback-методы вместо callback-функций

Поскольку обычно современные приложения строятся как совокупность классов - было бы неплохо изолировать функцию обратного вызова: сделать её не глобальной, а членом класса. Это легко сделать следующим образом, используя статические классовые методы:
type
  TForm1 = class(TForm)
    Edit1: TEdit;
    Memo1: TMemo;
    Button1: TButton;

  // Переходник
  strict private
    type
      PInternalEnumArgs = ^TInternalEnumArgs;
      TInternalEnumArgs = record
        Self: TForm1;
        Data: Pointer;
      end;
    class function InternalEnumWindowsCallback(Wnd: HWND; lpData: LPARAM): Bool; stdcall; static;

  // Hi-level интерфейс
  protected
    function EnumWindowsCallback(const AWnd: HWND; const lpData: Pointer): Boolean; virtual;
    function EnumWindows(const lpData: Pointer = nil): Boolean;
  end;

// ...

function TForm1.EnumWindows(const lpData: Pointer): Boolean;
var
  Args: TInternalEnumArgs;
begin
  Args.Self := Self;
  Args.Data := lpData;

  Result := WinAPI.Windows.EnumWindows(@InternalEnumWindowsCallback, @Args);
end;

class function TForm1.InternalEnumWindowsCallback(Wnd: HWND; lpData: LPARAM): Bool;
var
  Args: PInternalEnumArgs;
begin
  Args := Pointer(lpData);

  Result := Args.Self.EnumWindowsCallback(Wnd, Args.Data);
end;

function TForm1.EnumWindowsCallback(const AWnd: HWND; const lpData: Pointer): Boolean;
var
  WndClassName, WndText: String;
begin
  // Ваш код - он может работать как с lpData, так и с членами класса.
  // К примеру:

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

  Result := True;
end;
(подробнее про ключевое слово static можно почитать тут)

Здесь InternalEnumWindowsCallback служит в качестве переходника-адаптера, который конвертирует функцию stdcall в метод класса. Настоящий же callback (который и выполняет всю работу) содержится в обычном методе класса EnumWindowsCallback. Обратите внимание, что поскольку callback является методом, то он имеет доступ к свойствам и методам класса. Поэтому, как правило, callback-методы не используют user-параметры, а сами параметры читают напрямую из класса. Само собой, такой подход тем проще по сравнению с callback-функциями, чем больше параметров нужно передать и чем больше результата надо вывести - поскольку с callback-методами нам не нужно передавать каждое значение вручную через промежуточную запись. Тем не менее, в примере выше я сохранил параметр lpData для свободного использования: не исключена ситуация, когда какие-либо данные будет проще передать через параметр, а не свойства класса - как правило, это бывают локальные данные, которые рассчитываются внутри метода класса, но не сохраняются в полях класса. А если бы нам не нужно было сохранять функциональность lpData, то мы могли бы убрать тип TInternalEnumArgs, передавая Self напрямую как lpData.

Плохой дизайн API

Если дизайн API разрабатывает не слишком опытный программист, он может просто не подумать про необходимость наличия user-параметров для callback-функций (а равно как и множество других вещей). В результате у вас может быть на руках такой код:
type
  TCallback = function(FoundData: TData): BOOL; cdecl;

function RegisterCallback(Callback: TCallback): Integer; cdecl;
Как видите, здесь нет user-параметра, а единственный параметр для callback-функции представляет собственно данные для функции.

Решение в лоб: глобальные переменные

Само собой, поскольку разработчик API - не вы, то и изменить прототип функции обратного вызова вы не можете. Что же делать? Поскольку мы не можем передавать данные через локальный параметр, то остаётся только вариант с глобальным параметром:
var
  GMemo: TStrings;

function MyCallback(FoundData: TData): BOOL; cdecl;
begin
  GMemo.Lines.Add(DataToString(FoundData));
end;

function TForm1.Button1Click(Sender: TObject);
begin
  GMemo := Memo1;
  RegisterCallback(MyCallback);
end;
Конечно, мы можем сделать callback-функцию членом класса, как мы делали это выше (с помощью статических классовых методов), но надо понимать, что в этом варианте API callback-метод должен быть классовым, а не обычным, а переменная тоже должна быть классовой, а не полем класса - поскольку мы не можем передать Self через user-параметр. А следовательно, это будут всё те же глобальные функция и переменная, но немного замаскированные. Соответственно, обращаться к свойствам и методам класса из callback-а мы не сможем.

Данное решение можно рассматривать как "удовлетворительное", поскольку оно в принципе работает, но использует глобальные переменные - что плохо. Иногда это может быть допустимо, но часто нам необходимо вызывать callback-функции многопоточно или даже просто в несколько разных вызовов в рамках одного потока. В этом случае кажется, что надёжного способа идентификации нет?

Правильное решение: динамический код

Тем не менее, опытный программист может предложить гарантировано рабочий вариант, "добавляющий" user-параметр к существующему "плохому" API. Это вовсе не невыполнимая задача, как может показаться, но её решение достаточно нетривиально. Суть идеи в том, что user-параметр можно заменить на саму функцию обратного вызова, которая будет уникальна для каждого использования. Таким образом, вызов функции будет идентифицироваться не по параметру, а по тому, какая из функций вызвана.

Постановка задачи

Само собой, в предварительно скомпилированном файле невозможно иметь произвольное число функций для произвольных user-параметров. И именно поэтому это решение требует генерации кода на лету. К счастью, это не слишком сложная задача, поскольку вы можете воспользоваться услугами компилятора Delphi для генерации шаблона. Более того - вы можете даже не знать ассемблер. Но вам нужно иметь некоторое представление об устройстве памяти Windows.

Итак, пусть у нас есть следующее:
type
  TData                   = Integer; // просто для примера; это может быть что угодно: указатель, запись и т.п.
  TCallback               = function(FoundData: TData): BOOL; cdecl;
  TRegisterCallbackFunc   = function(Callback: TCallback): Integer; cdecl;
  TUnregisterCallbackFunc = procedure(Callback: TCallback); cdecl;
var
  RegisterCallback: TRegisterCallbackFunc;      // импортируется из DLL
  UnregisterCallback: TUnregisterCallbackFunc;  // импортируется из DLL
Это - наш "плохой" API. Поскольку сторонний API обычно располагается в отдельной DLL, то я сделал пример с переменной-функцией, а не обычной функцией. В любом случае, если у вас есть обычная функция (API лежит в отдельном модуле, есть dcu, но не pas), то этот случай легко сводится к примеру выше. Итак, наша задача: добавить в этот API поддержку user-параметра.

Создание мастер-шаблона

Шаг первый: пишем следующий код:
function RealCallback(FoundData: TData; Data: Pointer): BOOL; cdecl;
begin
  Result := True;
end;

type
  TRealCallbackFunc = function(FoundData: TData; Data: Pointer): BOOL; cdecl;
var
  GRealCallback: TRealCallbackFunc = RealCallback;

function InternalCallback(FoundData: TData): BOOL; cdecl;
begin
  Result := GRealCallback(FoundData, Pointer($12345678));
end;
Здесь: InternalCallback - это callback-функция, прототип которой полностью соответствует API. Именно её мы будем устанавливать в качестве callback-а. RealCallback - это модифицированная callback-функция, которая отличается от API лишь наличием дополнительного параметра: это и есть наш user-параметр. Хотя прототип RealCallback может быть произвольным, но для упрощения нашей жизни желательно, чтобы он был бы максимально похожим на InternalCallback. Сама InternalCallback должна просто вызывать RealCallback, передавая фиксированный указатель в качестве параметра. Значение $12345678 выбрано по той простой причине, что его легко будет увидеть. Вы можете использовать любое другое "волшебное" значение.

Функция RealCallback из InternalCallback вызывается не напрямую, а опосредованно - через глобальную переменную GRealCallback. Я поясню ниже зачем это сделано.

Итак, добавьте прямой вызов InternalCallback в ваш код:
procedure TForm1.Button1Click(Sender: TObject);
begin
  InternalCallback(0); // здесь: TData = Integer (для этого примера)
end;
и установите на него точку останова. Запустите проект, нажмите кнопку, встаньте на точку останова, зайдите в функцию InternalCallback (F7) и откройте CPU-отладчик (Ctrl + Alt + C или View / Debug Windows / CPU View). Вы увидите такой код:
Unit1.pas.38: begin
005B5C7C 55               push ebp
005B5C7D 8BEC             mov ebp,esp
005B5C7F 51               push ecx
Unit1.pas.39: Result := GRealCallback(FoundData, Pointer($12345678));
005B5C80 6878563412       push $12345678
005B5C85 8B4508           mov eax,[ebp+$08]
005B5C88 50               push eax
005B5C89 FF15A0395C00     call dword ptr [$005c39a0]
005B5C8F 83C408           add esp,$08
005B5C92 8945FC           mov [ebp-$04],eax
Unit1.pas.40: end;
005B5C95 8B45FC           mov eax,[ebp-$04]
005B5C98 59               pop ecx
005B5C99 5D               pop ebp
005B5C9A C3               ret
Выделите этот код и скопируйте куда-нибудь (не нужно его запускать!). Вы можете увидеть, что весь код состоит из трёх строк: begin (он же - пролог), вызов функции, end (он же - эпилог). Каждая строка помечена комментарием. Если вы не знаете ассемблер, то всё, что вам нужно знать: первый столбец это адреса инструкций. Эти адреса принадлежат вашему exe (ведь именно код exe мы вызвали по Button1Click). Второй столбец: hex-коды байтов кода. Т.е. это машинный код в чистом виде. Третий столбец - это ассемблерный код, соответствующий машинному коду. Самое замечательное в этом листинге - нам не нужен ассемблерный код, нам нужен лишь машинный код. Сейчас я поясню почему...

Теперь смотрим: в этой функции есть всего два переменных значения:
  1. Адрес вызываемой функции (RealCallback)
  2. Значение user-параметра
Весь остальной текст статичен и не зависит ни он чего, т.е. он будет ровно тем же самым в любых случаях: для любых user-параметров, для любых callback-функций. Это означает, что если мы хотим сами генерировать функции, подобные InternalCallback, то мы можем просто скопировать весь код целиком и просто подставить в него два числа: адрес функции и адрес параметра.

User-параметр легко увидеть, поскольку мы использовали волшебное значение $12345678. Адрес функции увидеть сложнее (если вы не знакомы с ассемблером), но можно догадаться, что он зашифрован в этой строке:
005B5C89 FF15A0395C00     call dword ptr [$005c39a0]
Почему?
  1. Слово call намекает на "вызов".
  2. Адрес $005C39A0 явно лежит недалеко от адресов $005B5C7C-$005B5C9A, т.е. это какой-то код в exe.
Поскольку наш Паскаль-код вызывает функцию не напрямую, а через глобальную переменную, то легко предположить, что $005C39A0 - это адрес не самой функции, а адрес указателя на функцию.

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

Вот и всё, что касается разбора шаблона. Обратите внимание, что мы не использовали никаких особенных знаний, кроме умения пользоваться отладчиком и здравого смысла.

Тогда приступаем к кодированию. Для начала надо скопировать наш шаблон в массив байтов для дальнейшего использования в коде программы. Для этого я сначала выписал весь машинный код (второй столбец), одновременно заменив адрес указателя на функцию на (новое) волшебное значение для упрощения отладки:
55
8BEC
51
6878563412    // $12345678 - user-параметр
8B4508
50
FF15FFEEDDCC  // $CCDDEEFF - указатель на функцию
83C408
8945FC
8B45FC
59
5D
C3
Напоминаю, что не нужно руками выписывать эти коды - выделите в CPU-отладчике мышью нужные строки и нажмите Ctrl + C - это скопирует выделенные строки в буфер обмена. Затем перейдите в редактор кода и вставьте текст из буфера, после чего удалите три строки с комментариями, а также первый и последний столбцы, оставив только машинный код.

Обратите внимание, что x86 является little-endian архитектурой - что означает, что все числа "записываются наоборот".

После этого я убрал разделители строк, объединив весь машинный код в длинный поток байтов:
558BEC5168785634128B450850FF15FFEEDDCC
83C4088945FC8B45FC595DC3
(я разбил на две строки для упрощения читабельности в блоге, в редакторе это одна строка)

После чего я вставил #$ через каждые два символа и оформил эту константу как строку:
const
  CallbackTemplate: RawByteString =
    #$55#$8B#$EC#$51#$68#$78#$56#$34#$12#$8B#$45#$08#$50#$FF#$15#$FF#$EE#$DD#$CC +
    #$83#$C4#$08#$89#$45#$FC#$8B#$45#$FC#$59#$5D#$C3;
Почему строку? Ну, это проще и короче, чем настоящий массив байтов: не надо вставлять пробелы, запятые, не надо указывать размерность массива. Кроме того, для строк есть готовая функция поиска и замены (мы увидим позднее почему это важно). Строка однобайтовая, без кодировки (RawByteString - это AnsiString в старых версиях Delphi) - поэтому эта строка является массивом байтов.

Теперь неплохо бы проверить, что мы нигде не ошиблись. Измените обработчик нажатия на кнопку следующим образом:
procedure TForm1.Button1Click(Sender: TObject);
begin
  TCallback(Pointer(CallbackTemplate))(0);
end;
Этой странной строкой мы говорим, что указатель CallbackTemplate (а любая строка - это указатель; для статического массива вам потребовалось бы брать указатель явно через @) следует трактовать не как строку, а как функцию типа TCallback. Ну и эту функцию, стало быть, надо вызвать. Вот более длинная версия того же кода:
procedure TForm1.Button1Click(Sender: TObject);
var
  Template: AnsiString;
  Ptr: Pointer;
  CB: TCallback;
begin
  Template := CallbackTemplate;
  Ptr := Pointer(Template);
  CB := TCallback(Ptr);
  CB(0);
end;
Установите точку останова на вызов функции (в любом варианте - длинном или коротком), запустите программу, нажмите кнопку, встаньте на точке останова. Не нажимайте F7: поскольку для функции закодированной в CallbackTemplate отсутствует исходный код, то компилятор выполнит всю функцию целиком за один проход - что приведёт к Access Violation, поскольку оба наших указателя ($12345678 и $CCDDEEFF) указывают в космос. Вместо этого вызовите CPU отладчик (Ctrl + Alt + C или View / Debug Windows / CPU View) и несколько раз нажмите F7 уже в нём - пока вас не перебросит к знакомому коду:
005B5C74 55               push ebp
005B5C75 8BEC             mov ebp,esp
005B5C77 51               push ecx
005B5C78 6878563412       push $12345678
005B5C7D 8B4508           mov eax,[ebp+$08]
005B5C80 50               push eax
005B5C81 FF15FFEEDDCC     call dword ptr [$ccddeeff]
005B5C87 83C408           add esp,$08
005B5C8A 8945FC           mov [ebp-$04],eax
005B5C8D 8B45FC           mov eax,[ebp-$04]
005B5C90 59               pop ecx
005B5C91 5D               pop ebp
005B5C92 C3               ret 
Убедитесь, что этот фрагмент точно совпадает с исходным кодом (разве что с изменённым адресом на $CCDDEEFF). Если совпадает, то вы всё сделали верно, шаблон готов. Если нет - исправьте и будьте в дальнейшем более внимательны.

Динамическая генерация кода

Следующий шаг - нам необходимо создавать реальные функции обратного вызова (с реальными адресами) по шаблону CallbackTemplate. Собственно, сделать это очень просто - достаточно просто заменить адреса функций и код готов. Есть только небольшая особенность: в архитектуре x86 любой исполняемый код должен располагаться в странице памяти, имеющей атрибут выполнения (EXECUTE). Если мы просто выделим память (GetMem/AllocMem или просто используем строку, массив и другие данные), то это будут "данные": у них будет доступ на чтение (READ), запись (WRITE), но не выполнение (EXECUTE). Поэтому попытка вызова этого кода приведёт к Access Violation.

Примечание: на ранних процессорах архитектуры x86 атрибуты "чтение" и "выполнение" были эквивалентными. Поэтому, хотя технически ставить равенство между ними никогда не было верным, некоторые воспользовались этой особенностью реализации и передавали управление на код в сегментах данных. Повторим: это никогда не было корректным, но это работало на старых процессорах. Теперь этот код будет вылетать. См. также: DEP.

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

Суммируя сказанное, вот подходящий код:
unit FixCallbacks;

interface

// Функция динамически генерирует код для вызова
// функции ACallback с параметром AUserParam по известному шаблону машинного кода ATemplate
// Шаблон ATemplate должен использовать значения $12345678 и $CCDDEEFF как заглушки
// для передачи AUser и указателя на ACallback соответственно
// Результат работы функции можно передавать в "плохой" API
function AllocTemplate(const ATemplate: RawByteString; const ACallback, AUserParam: Pointer): Pointer;

// Функция освобождает шаблон, созданный функцией AllocTemplate
// В неё необходимо передать те же параметры, что и в AllocTemplate
// Функция вернёт указатель, который необходимо передать в функцию дерегистрации "плохого" API
function DisposeTemplate(const ACallback, AUser: Pointer): Pointer;

implementation

uses
  Winapi.Windows, 
  System.SysUtils, 
  System.Classes;

var
  // Список всех динамически сгенерированных кусков кода
  KnownTemplates: TThreadList;

function AllocTemplate(const ATemplate: RawByteString; const ACallback, AUserParam: Pointer): Pointer;

  procedure StrReplace(var ATemplate: RawByteString; const ASource, ADest: NativeUInt);
  var
    X: Integer;
  begin
    for X := 1 to Length(ATemplate) - SizeOf(ASource) do
      if PNativeUInt(@ATemplate[X])^ = ASource then
      begin
        PNativeUInt(@ATemplate[X])^ := ADest;
        Break;
      end;
  end;

var
  OrgPtr: Pointer;
  OrgSize: Cardinal;
  Ptr: PPointer;
  ActualTemplate: RawByteString;
  Dummy: Cardinal;
begin
  // Шаг первый: выделяем ресурсы

  // Взяли шаблон
  ActualTemplate := ATemplate;
  // Выделили память для динамической генерации кода
  // Добавили размер указателя, т.к. нам нужно куда-то сохранять указатель на ACallback
  // Второй указатель нужен для удобства: в него мы сохраним user-параметр (см. DisposeTemplate ниже)
  // Атрибуты страницы: чтение + запись - поскольку сначала нам нужно записать туда код
  OrgSize := Length(ATemplate) + SizeOf(Pointer) * 2;
  OrgPtr := VirtualAlloc(nil, OrgSize, MEM_COMMIT or MEM_RESERVE, PAGE_READWRITE);
  Win32Check(Assigned(OrgPtr));
  Ptr := OrgPtr;

  // Шаг второй: готовим данные

  // Подменяем в шаблоне заглушки на реальные данные
  // Блок начнётся с самого указателя на функцию, а машинный код пойдёт за ним,
  // поэтому в качестве "указателя на указатель на функцию" будет выступать сам Ptr
  StrReplace(ActualTemplate, $12345678, NativeUInt(AUserParam));
  StrReplace(ActualTemplate, $CCDDEEFF, NativeUInt(Ptr));

  // Шаг третий: копируем данные

  // Раз блок начинается с указателя на функцию, то сначала сохраняем указатель
  Ptr^ := ACallback;
  Inc(Ptr);
  // Затем - user-параметр
  Ptr^ := AUserParam;
  Inc(Ptr);
  // Наконец, копируем готовый машинный код в место его выполнения (сразу за указателем)
  Move(Pointer(ActualTemplate)^, Ptr^, Length(ActualTemplate));

  // Шаг четвёртый: делаем код кодом

  // Меняем атрибуты страницы на "чтение + выполнение"
  // "Чтение" необходимо по той причине, что мы храним в этом же блоке указатель на функцию -
  // а его (указатель) читает код. Соответственно, без доступа на чтение код вылетит с AV.
  Win32Check(VirtualProtect(OrgPtr, OrgSize, PAGE_EXECUTE_READ, Dummy));
  // Не забываем про многоядерные процессоры: надо указать, что мы модифицировали исполняемый код
  Win32Check(FlushInstructionCache(GetCurrentProcess, OrgPtr, OrgSize));

  // Шаг пятый: возвращаем результат

  Result := Ptr;
  // Сохраняем результат в списке сгенерированных callback-функций.
  // Это необходимо, чтобы DisposeTemplate могла найти динамический шаблон по статическому
  KnownTemplates.Add(Ptr);
end;

function DisposeTemplate(const ACallback, AUser: Pointer): Pointer;
// AllocTemplate возвращает указатель на машинный код -
// что на SizeOf(Pointer) * 2 ( = SizeOf(TSavedTemplate)) байт дальше начала блока памяти.
// Соответственно, нам сначала нужно найти реальный указатель блока памяти.
type
  PSavedTemplate = ^TSavedTemplate;
  TSavedTemplate = packed record
    Callback, User: Pointer;
  end;
var
  List: TList;
  X: Integer;
  SavedTemplate: PSavedTemplate;
begin
  Result := nil;
  if ACallback = nil then
    Exit;

  SavedTemplate := nil;

  // Ищем динамический шаблон по статическому
  // Необходимо заблокировать список на время работы, т.к. у нас не атомарная операция
  List := KnownTemplates.LockList;
  try
    for X := 0 to List.Count - 1 do
    begin
      SavedTemplate := List[X];

      // Ещё раз: мы храним сдвинутые указатели, начало блока находится на два указателя ранее
      Dec(SavedTemplate);

      // Нашли шаблон?
      if (SavedTemplate.Callback = ACallback) and
         (SavedTemplate.User = AUser) then
      begin
        // Вернём указатель на динамический шаблон для его разрегистрации
        Result := List[X];
        // Сначала удаляем его из списка...
        List.Delete(X);
        Break;
      end
      else
        SavedTemplate := nil;
    end;
  finally
    KnownTemplates.UnlockList;
  end;

  if SavedTemplate = nil then
    Exit;

  // ...а затем - освобождаем память
  Win32Check(VirtualFree(SavedTemplate, 0, MEM_RELEASE));
end;

initialization
  KnownTemplates := TThreadList.Create;
finalization
  FreeAndNil(KnownTemplates);
end.
Данный модуль предлагает две универсальные функции для динамической генерации кода переходников callback-функций. Логика кода достаточно понятно расписана в комментариях, поэтому я не буду её описывать отдельно.

С этими функциями мы можем переделать интерфейсный модуль для "плохого" API следующим образом:
// ...

interface

// ...

type
  TData                   = Integer; // осталось как было

  // Новый тип - как замена TCallback, но с добавленным lpUser
  TRealCallback           = function(FoundData: TData; const lpUser: Pointer): BOOL; cdecl;

// Новые функции - как замена старых, но с добавленным lpUser
function  RegisterCallback(const Callback: TRealCallback; const lpUser: Pointer): Integer;
procedure UnregisterCallback(const Callback: TRealCallback; const lpUser: Pointer);

implementation

uses 
  FixCallbacks; // подключаем "волшебные" функции
 
// Старые декларации скрыли в implementation
type
  TCallback               = function(FoundData: TData): BOOL; cdecl;
  TRegisterCallbackFunc   = function(Callback: TCallback): Integer; cdecl;
  TUnregisterCallbackFunc = procedure(Callback: TCallback); cdecl;
var
  InternalRegisterCallback: TRegisterCallbackFunc;
  InternalUnregisterCallback: TUnregisterCallbackFunc;

function RegisterCallback(const Callback: TRealCallback; const lpUser: Pointer): Integer;
var
  UniqueCallback: TCallback;
begin
  UniqueCallback := TCallback(AllocTemplate(CallbackTemplate, @Callback, lpUser));
  Result := InternalRegisterCallback(UniqueCallback);
end;

procedure UnregisterCallback(const Callback: TRealCallback; const lpUser: Pointer);
begin
  InternalUnregisterCallback(TCallback(DisposeTemplate(@Callback, lpUser)));
end;

// ...
И тогда мы можем написать такой код:
function MyCallback(FoundData: TData; const lpUser: Pointer): BOOL; cdecl;
var
  Self: TForm1;
begin
  Self := TForm1(lpUser);
  Self.Memo1.Lines.Add(IntToStr(FoundData));

  Result := True;
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  RegisterCallback(MyCallback, Pointer(Self));
end;

procedure TForm1.Button2Click(Sender: TObject);
begin
  UnregisterCallback(MyCallback, Pointer(Self));
end;
Вуаля! Магия!

Обратите внимание, что функции AllocTemplate и DisposeTemplate являются универсальными и никак не зависят от вашего кода. Чтобы проиллюстрировать эту универсальность - давайте перепишем наш пример поиска окон заданного класса через EnumWindows так, чтобы он не использовал бы user-параметр функции EnumWindows. Для этого нам нужно составить шаблон. Пишем код:
function RealFindWindowsOfClass(Wnd: HWND; lpData: LPARAM; User: Pointer): Bool; stdcall;
begin
  Result := True;
end;

type
  TFindWindowsOfClassFunc = function(Wnd: HWND; lpData: LPARAM; User: Pointer): Bool; stdcall;
var
  GFindWindowsOfClass: TFindWindowsOfClassFunc = RealFindWindowsOfClass;

function FindWindowsOfClass(Wnd: HWND; lpData: LPARAM): Bool; stdcall;
begin
  Result := GFindWindowsOfClass(Wnd, lpData, Pointer($12345678));
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  FindWindowsOfClass(0, 0);
end;
Получаем такой ассемблерный листинг:
Unit44.pas.40: begin
005B5CE4 55               push ebp
005B5CE5 8BEC             mov ebp,esp
005B5CE7 51               push ecx
Unit44.pas.41: Result := GFindWindowsOfClass(Wnd, lpData, Pointer($12345678));
005B5CE8 6878563412       push $12345678
005B5CED 8B450C           mov eax,[ebp+$0c]
005B5CF0 50               push eax
005B5CF1 8B4508           mov eax,[ebp+$08]
005B5CF4 50               push eax
005B5CF5 FF15A0395C00     call dword ptr [$005c39a0]
005B5CFB 8945FC           mov [ebp-$04],eax
Unit44.pas.42: end;
005B5CFE 8B45FC           mov eax,[ebp-$04]
005B5D01 59               pop ecx
005B5D02 5D               pop ebp
005B5D03 C20800           ret $0008
Вырезаем машинный код и делаем его константой (не забыв заменить 005C39A0 на CCDDEEFF):
const
  EnumWndTemplate: RawByteString =
   #$55#$8B#$EC#$51#$68#$78#$56#$34#$12#$8B#$45#$0C#$50#$8B#$45#$08#$50#$FF#$15#$FF#$EE#$DD#$CC +
   #$89#$45#$FC#$8B#$45#$FC#$59#$5D#$C2#$08#$00;
Всё готово, можно вызывать:
function FindWindowsOfClass(Wnd: HWND; lpData: LPARAM; User: Pointer): Bool; stdcall;
var
  Self: TForm1;
  WndClassName, WndText: String;
begin
  Self := TForm1(User);

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

  Result := True;
end;

procedure TForm1.Button1Click(Sender: TObject);
const
  EnumWndTemplate: RawByteString =
   #$55#$8B#$EC#$51#$68#$78#$56#$34#$12#$8B#$45#$0C#$50#$8B#$45#$08#$50#$FF#$15#$FF#$EE#$DD#$CC +
   #$89#$45#$FC#$8B#$45#$FC#$59#$5D#$C2#$08#$00;
var
  CustomizedCallback: Pointer;
begin
  CustomizedCallback := AllocTemplate(EnumWndTemplate, @FindWindowsOfClass, Pointer(Self));
  try
    Memo1.Lines.BeginUpdate;
    try
      Memo1.Lines.Clear;
      EnumWindows(CustomizedCallback, 0);
    finally
      Memo1.Lines.EndUpdate;
    end;
  finally
    DisposeTemplate(@FindWindowsOfClass, Pointer(Self));
  end;
end;
Вот и всё, что я хотел сегодня сказать.

ВАЖНОЕ ПРИМЕЧАНИЕ

Подход, описанный в этой статье, является хаком. Это означает, что это - костыль, обходной путь, способ заставить код работать хоть как-то. Это не оправдание для написания "плохого" кода API. Если у вас есть контроль над прототипами callback-функций - измените их! Введите поддержку user-параметров.

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

P.S. Глобальный список в KnownTemplates не является обязательным. Вполне можно обойтись без него. Для этого вызывающий должен сохранять результат вызова AllocTemplate, а затем передавать его в DisposeTemplate (вместо тех же аргументов, как это сделано сейчас). Тогда список был бы не нужен, потому что DisposeTemplate смогла бы извлечь указатель на освобождаемый блок из своего аргумента. Этот сценарий удобен в случае кода как в примере с EnumWindows: нам достаточно передать CustomizedCallback в DisposeTemplate - и это совершенно не накладно. Почему же я сделал список? Потому что такой подход накладывает обязанность хранения указателя на вызывающего. Посмотрите сценарий с интерфейсным модулей для "плохого" API. Если мы хотим в точности сохранить интерфейс кода, то мы не можем передать вызывающему указатель. Вот почему нам и потребовался список. Если же вы готовы пойти на изменение интерфейса заголовочных модулей "плохого" API, то вы вполне можете отказаться от списка. Тогда DisposeTemplate будет выглядеть так:
// ATemplate - указатель, возвращённый функцией AllocTemplate
procedure DisposeTemplate(ATemplate: Pointer);
begin
  if ATemplate = nil then
    Exit;

  ATemplate := Pointer(NativeUInt(ATemplate) - SizeOf(Pointer) * 2);
  if ATemplate = nil then
    Exit;

  Win32Check(VirtualFree(ATemplate, 0, MEM_RELEASE));
end;
А из функции AllocTemplate надо будет убрать строку с добавлением в KnownTemplates, после чего все упоминания KnownTemplates можно также удалить (а заодно - удалить модуль Classes из uses).

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

  1. Это ведь работает в 32-х битах? А в 64-х битах аналог не пробовали делать? Или я недопонял?

    ОтветитьУдалить
  2. С 64 битами всё было бы то же самое (с поправкой на размер указателя), если бы не одно НО: в x86-64 по умолчанию используется относительная адресация вместо абсолютной (как это имеет место быть в x86-32). Это означает, что компилятор Delphi будет генерировать код с использованием относительной адресации. Поэтому мастер-шаблон необходимо подправлять ручками. Весь остальной код остаётся неизменным, вопрос только в правильном составлении шаблона.

    ОтветитьУдалить
  3. Ну я собственно про шаблон и спрашивал :-) Сами не пробовали?
    Я просто размышляю на тему "обёрток" под 64-бита, которые у меня используются. Понятное дело, что они не работают. Вот думаю - чем их заменять. То ли IfDef и анонимные функции, то ли всё же удастся обёртки переписать.

    Про обёртки подробнее тут - http://18delphi.blogspot.ru/2013/03/blog-post_5929.html

    Может быть вы что подскажете?

    ОтветитьУдалить
  4. Лучше всего забыть про ассемблер и указатели на локальные функции.

    Из-за особенностей модели вызова в x86-64 любая внутренняя функция имеет скрытый параметр - указатель на локальные переменные её контейнера (т.е. некий аналог Self). Соответственно, двоичный прототип локальной функции попросту не совпадёт с требуемым для callback-а.

    ОтветитьУдалить
  5. "Лучше всего забыть про ассемблер и указатели на локальные функции."

    Да это-то понятно. Но уж очень много переделывать.

    "Из-за особенностей модели вызова в x86-64 любая внутренняя функция имеет скрытый параметр - указатель на локальные переменные её контейнера (т.е. некий аналог Self). "

    А это знание ничем помочь не может?

    ОтветитьУдалить
  6. "Лучше всего забыть про ассемблер и указатели на локальные функции."

    А вы же сами тут ассемблер приводите :-) Нет тут небольшого нарушения логики?

    ОтветитьУдалить
  7. Я не уверен, что понял в чём состоит проблема.

    ОтветитьУдалить
  8. "Из-за особенностей модели вызова в x86-64 любая внутренняя функция имеет скрытый параметр - указатель на локальные переменные её контейнера (т.е. некий аналог Self)"

    В 32-х битах ведь этим аналогом регистр ebp является? Так? Собственно мои "обёртки" его и корректируют?

    Я просто с 64-хбитной архитектурой - слабо знаком.

    ОтветитьУдалить
  9. "Я не уверен, что понял в чём состоит проблема."

    Жаль. Ну ладно. Спасибо!

    ОтветитьУдалить
  10. Да, примерно так. x86-64 передаёт RBP явно, как параметр.

    ОтветитьУдалить
  11. "Да, примерно так. x86-64 передаёт RBP явно, как параметр."

    Ну примерно понятно. Ладно. Буду думать. Спасибо!

    ОтветитьУдалить
  12. >>> Я просто с 64-хбитной архитектурой - слабо знаком.

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

    ОтветитьУдалить
  13. Смотрите, если вы хотите поддержку 64 бит - вам нужно переделывать код. Местами - весьма существенно. И раз так - стоит ли переделывать его на ещё один вариант ассемблерного кода? А если завтра будете пытаться запустить код на ARM (Android/iOS)?

    В общем, моя мысль следующая: если уж вы возьмётесь за переделывание - то почему бы не переделать сразу как следует: без ассемблера и с передачей параметров, либо классом/объектом (читай: записью)?

    ОтветитьУдалить
  14. "Лучше не связываться с этим, не изучив архитектуру. Там много разных мелочей. "
    Ну да. Не хочется если честно сильно в это влезать. Пыл уже не тот.

    "В общем, моя мысль следующая: если уж вы возьмётесь за переделывание - то почему бы не переделать сразу как следует: без ассемблера и с передачей параметров, либо классом/объектом (читай: записью)?"

    Ну в целом - я конечно к этому склоняюсь.

    ОтветитьУдалить
  15. >>> А вы же сами тут ассемблер приводите :-) Нет тут небольшого нарушения логики?

    Я добавил в текст примечание в конце.

    ОтветитьУдалить
  16. "Я добавил в текст примечание в конце."

    Да - там всё логично.

    ОтветитьУдалить
  17. А подход на ассемблере, включая x64? Чем тут не устроил
    http://www.sql.ru/forum/1032828/hochu-izbavitsya-ot-globalnoy-peremennoy

    ?

    ОтветитьУдалить
  18. Это про MakeObjectInstance? Она предназначена для более узкой задачи: конвертировать оконную процедуру.

    Откуда следует:
    1. Она не потокобезопасна (поскольку с окнами VCL работает только в главном потоке, то нет необходимости делать служебные функции потокобезопасными).
    2. Она крайне сильно привязана к прототипу оконной функции (в её исходном коде есть явная ссылка на прототип stdcall функции с 4-мя параметрами, которые упаковываются в TMessage - см. StdWndProc). Соответственно, целевой метод может быть исключительно вида procedure TApplication.WndProc(var Message: TMessage);
    3. Каждый вызов MakeObjectInstance выделяет блок памяти, который не освобождается. Все блоки памяти освобождаются в конце работы программы.

    Откуда видно, что MakeObjectInstance не приемлема для указанной задачи в общем случае.

    А так идея у MakeObjectInstance та же самая (дин. генерация кода), только что вместо простого push user-параметр там используется несколько "черезжопное" на мой взгляд решение.

    К тому же смысл поста - показать, как что-то можно сделать своими руками.

    ОтветитьУдалить
  19. Я так и не понял, почему простой, понятный любому школьнику код с глобальной переменной - это плохо, а костыль, не работающий в х64, требущий нетривиальных знаний асма - это хорошо. Единственный недостаток глобальной переменной - это неработоспособность в многопоточном ПО, но для этого есть threadvar

    ОтветитьУдалить
  20. «jack128 комментирует...
    Я так и не понял, почему простой, понятный любому школьнику код с глобальной переменной - это плохо, а костыль, не работающий в х64, требущий нетривиальных знаний асма - это хорошо.»
    -- Ну, не совсем...
    Представьте себе, если Вам придётся вызывать некоторую функцию F, которая передаст управление в функцию G в качестве call-back, с передачей аргумента P через глобальную переменную.
    Что будет, если в процессе выполнения G управление снова будет передано в F, а она, в свою очередь снова инициирует выполнение G?
    - Ничего хорошего, поскольку первый и второй вызов G будут использовать одну и ту же область памяти для работы с параметром P, востребованных в разных ситуациях (первый и второй вызовы F).
    Плохи оба решения. Но соглашусь, что второе - хуже :-)
    IMHO шаманства следует избегать, код приобретать только в исходниках, чтобы иметь возможность внести необходимые изменения.

    К варианту с "динамическим кодом" IMHO следует прибегать только в самых крайних случаях, до которых лучше не доводить.
    Подробно - здесь: http://ru.wikipedia.org/wiki/Реентерабельность.

    ОтветитьУдалить
  21. Очень интересно! Но не может не всплывать вопрос.
    Между "глобальными переменными" и "классовыми методами" (так и хочется их назвать - статическими" существует ещё один промежуточный вариант.

    Глобальная переменная-ссылка на экземпляр.
    Данные выскребаются по ссылки для конкретного вызова в коде callback-а, а вот в ссылку можно задвинуть любой объект из пула.

    ОтветитьУдалить
  22. >>> костыль, не работающий в х64, требущий нетривиальных знаний асма

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

    >>> Единственный недостаток глобальной переменной

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

    ОтветитьУдалить
  23. Не могу сообразить, как сделать шаблон под Win64. Что-то не так с адресами.

    ОтветитьУдалить
  24. Достаточно всего лишь явно задавать значение для функции. Например, так:

    function InternalCallback(FoundData: TData): BOOL; cdecl;
    var
    RealCallback: Pointer;
    begin
    RealCallback := Pointer($90ABCDEF);
    Result := TRealCallbackFunc(RealCallback)(FoundData, Pointer($12345678));
    end;

    Соответственно, в шаблоне в явном виде будут числа $12345678 (для параметра) и $90ABCDEF (для адреса функции) - вот их и заменяйте. Аналогичное справедливо и для ARM.

    P.S. Для 64-битного шаблона имеет смысл использовать удвоенные волшебные значения: какой-нибудь $1122334455667788 и $9900AABBCCDDFF - проще будет определять их границы в машинном коде. Не забывайте только про little endian.

    ОтветитьУдалить
  25. Случайно нашёл статью Гуглом по "MakeObjectInstance", странно что я раньше её не видел. Спасибо огромное, очень познавательно!
    А не могли бы пояснить чуть подробнее каким образом работает MakeObjectInstance? Ковырял исходный код Делфи7, нашёл MakeObjectInstance, и как-то до сих пор не до конца понимаю что она делает...

    ОтветитьУдалить
    Ответы
    1. Я рассматриваю варианты как сделать связку класс + окно. То есть надо создать класс (ООП), при создании делать CreateWindowEx(), и при этом как-то связать оконную функцию и экземпляр класса. Чтоб внутри WndProc было ясно которому объекту класса пришло сообщение.
      Пока нашёл варианты:
      * GWL_USERDATA/GWL_ID через lpParam
      * SetProp() через lpParam
      * какая-то странная подмена WndProc и глобальная переменная что я наблюдаю в Делфи7.VCL.Controls
      Что бы вы посоветовали делать, а что не делать? Спасибо!

      Удалить
    2. Я не особо эксперт в GUI, но я бы делал через GWL_USERDATA:
      - При создании: SetWindowLongPtr(Wnd, GWL_USERDATA, LPARAM(Self));
      - В оконной процедуре: YourObject := TYourClass(Pointer(GetWindowLongPtr(Wnd, GWL_USERDATA))); if Assigned(YourObject) then Result := YourObject.DialogProc(Msg, wParam, lParam);
      - При уничтожении: SetWindowLongPtr(Wnd, GWL_USERDATA, 0);

      MakeObjectInstance работает примерно так же, как описано в этой статье (AllocTemplate) - только MakeObjectInstance не универсальна, а жёстко привязана к конкретному прототипу. Иными словами, она тоже динамически создаёт машинный код (выделяет память с EXECUTE), пишет туда вызов метода объекта, передавая параметры. Сам объект извлекается из константы, записанной в этом же динамически сгенерированном коде.

      Удалить
    3. Большое спасибо!
      Непонятно тока почему когда практически все всюду делают через GWL_USERDATA или на крайний случай SetProp - почему VCL решили так вот раскорячиться-то?))
      Ещё и вот: https://habrahabr.ru/post/217189/

      Удалить
    4. Я без понятия, не эксперт в GUI. Возможно - исторически, возможно - хотели оставить поле свободным для прикладных программистов.

      Удалить

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

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

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

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

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

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