12 мая 2015 г.

Ответ на задачку №18

Ответ на задачку №18.

Начнём с затравки. Это действительно просто:
procedure TForm1.Button1Click(Sender: TObject);
var
  Wnd: HWND;

  function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
  begin
    if AWnd = Wnd then
      Caption := 'OK';
    Result := True;
  end;

begin
  Wnd := Handle;
  EnumWindows(@EnumWindowsProc, 0);
end;
Вопрос: каким образом EnumWindowsProc получает доступ к локальной переменной Wnd и свойству Caption?

Если грубо и просто, то для обращения к переменной Wnd функция EnumWindowsProc говорит: "возьми первую переменную у моего вызывающего". Иными словами, EnumWindowsProc производит доступ к переменной на стеке относительно своего вызова.

Это будет работать нормально, если EnumWindowsProc вызвать напрямую из Button1Click: конструкция "возьми первую переменную у моего вызывающего" обратится к Wnd (первая переменная) из Button1Click (который является вызывающим для EnumWindowsProc) - что нам и нужно.

Но что произойдёт, если EnumWindowsProc вызовет EnumWindows? В этом случае вызывающим для EnumWindowsProc будет не Button1Click, а EnumWindows. Иными словами, конструкция "возьми первую переменную у моего вызывающего" обратится не к Wnd из Button1Click, а к некоторой локальной переменной из EnumWindows - что приведёт к чтению мусора.

Аналогичное справедливо и для поля Caption.

Почему компилятор такое допускает? Ну, вообще-то, он этого не допускает. Код вида
EnumWindows(EnumWindowsProc, 0);
не скомпилируется, он сгенерирует ошибку "E2094 Local procedure/function 'EnumWindowsProc' assigned to procedure variable". Компилятор не даст вам передать локальную подпрограмму в качестве callback-а и не даст сохранить её во временную переменную. Однако, указывая оператор @, вы отбрасываете информацию о типе (указатель на подпрограмму) и используете нетипизированный указатель. Разумеется, нетипизированный указатель совместим с чем угодно - вот почему компилятор пропускает фрагмент вида
EnumWindows(@EnumWindowsProc, 0);
К сожалению, на самом деле, ошибка "Local procedure/function 'EnumWindowsProc' assigned to procedure variable" будет сгенерирована для функций типа BeginThread, но не CreateThread и не EnumWindows. Дело в том, что CreateThread и EnumWindows используют крайне неудачный дизайн прототипа: вместо настоящего процедурного параметра эти подпрограммы объявляют параметр как простой нетипизированный указатель. Поэтому для этих функций нет иного выхода как передавать callback через взятие указателя.

Окей, теперь переходим к более сложному, часть два:
procedure TForm1.Button1Click(Sender: TObject);

  function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
  var
    Wnd: HWND;
  begin
    Wnd := HWND(AParam);
    if AWnd = Wnd then
      Result := True
    else
      Result := False;
  end;

var
  Wnd: HWND;
begin
  Wnd := Handle;
  EnumWindows(@EnumWindowsProc, LPARAM(Wnd));
end;
Кажется, что в этом коде нет проблем? Мы не используем поля объекта и локальные переменные выше по стеку вызовов? И, вроде бы, этот код работает?

Да, это задачка с подвохом. На самом деле, проблема всё ещё есть.

Попробуйте скомпилировать этот код для x64. Запустите его. Ничего не замечаете? Почему-то AWnd становится равен AParam, AParam становится случайным числом, а настоящее окно AWnd и вовсе пропадает. Что происходит?

В отличие от кода x32, который для доступа к переменным просто смещается по стеку на заранее известное смещение от собственных локальных переменных, код x64 иначе производит доступ к локальным переменным: для всех локальных подпрограмм передаётся новый (скрытый) параметр - указатель на базу стека вызывающей подпрограммы. Иными словами, наша EnumWindowsProc, на самом деле, выглядит так:
function EnumWindowsProc(const ARBP: Pointer; const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
Разумеется, это происходит для всех локальных подпрограмм - вне зависимости от того, обращаются ли они к локальным переменным вне их личной зоны видимости или нет (в противном случае получилось бы несовпадение сигнатур функций в зависимости от того, кто кого вызывает).

В целом, ситуация крайне похожа на то, как методы объекта принимают скрытый первый параметр Self.

Вот вам и проблема. "Настоящий" AWnd сходит за (скрытый) ARBP - который никак не используется функцией и потому игнорируется. "Настоящий" AParam располагается на месте AWnd, ну а на место AParam приходится какой-то мусор из регистра R8. Напомню, что единое соглашение вызова x64 (на x64 игнорируются модификаторы модели вызова register, stdcall и т.д.) передаёт первые параметры в регистрах RCX, RDX, R8 и R9, а пятый и последующие параметры передаются через стек).

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

Мораль истории: не используйте @ при передачи параметров-функций. Не используйте локальные подпрограммы в качестве callback-ов. Что также подтверждается документацией:
Nested procedures and functions (routines declared within other routines) cannot be used as procedural values, nor can predefined procedures and functions.
Задачка была на знание справки Delphi ;)

См. также:

Далее была бонус-задачка, которая изначально не планировалась, но была оперативно написана по горячему обсуждению решения части два:
function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): Boolean; stdcall;
var
  Wnd: HWND;
begin
  Wnd := HWND(AParam);
  if AWnd = Wnd then
    Result := True
  else
    Result := False;
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Wnd: HWND;
begin
  Wnd := Handle;
  EnumWindows(@EnumWindowsProc, LPARAM(Wnd));
end;
Кажется, что уж в этом-то коде нет проблем? Функция теперь глобальная, не обращается к чужим локальным данным, т.е. сигнатура должна быть корректной?

Заметьте, что прототип функции EnumWindowsProc не совпадает с тем, что требует EnumWindows. Дело в том, что функция EnumWindowsProc объявлена как возвращающая значение Boolean. В то время как оригинальная функция требует BOOL.

Чем Boolean отличается от BOOL? Вспомним: размером. Boolean занимает один байт, а BOOL - четыре байта.

В частности, это означает, что присваивание Result := False (для случая когда Result имеет тип Boolean) обнулит только младший байт выходного регистра (EAX/RAX), а остальные три/семь байтов останутся без изменения! Почти наверняка в EAX/RAX - не ноль. Например, этот регистр используется как буфер при работе с параметрами подпрограммы, которые (параметры) - не ноль, более того - обычно больше 255. Таким образом, установка младшего байта в ноль не обнулит регистр. Иными словами (настоящий) Result (который BOOL) никогда не станет False - даже хотя Result (в виде Boolean) принимает значение False.

Примечание: увидеть это поведение под отладчиком можно скомпилировав программу без оптимизации.

Упражнение: почему модификаторы const не являются проблемой в прототипе?

Для тех, кому недостаточно "грубо и просто" - можно почитать детальный анализ задачи в блоге _Rouse.

Комментариев нет:

Отправить комментарий

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

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

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

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

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

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