1 декабря 2015 г.

Дело о неработающем ShowMessage

Новый "раздел" статей в блоге: показываем как можно применять возможности отладчика на практике.

На форуме человек задал вопрос: "почему не работает ShowMessage". Код в вопросе был такой:
library Project2;

uses
  Vcl.Dialogs;

begin
  ShowMessage('Test');
end.
При загрузке этой библиотеки через LoadLibrary мы должны увидеть сообщение:


Но этого не происходит, ShowMessage просто тихо ничего не делает.

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

Итак, как же нам решить это загадочное дело?

Для начала нужно создать т.н. тестовый проект. В нашем случае мы создадим новую DLL, впишем в неё код выше. Также мы создадим новое приложение VCL Forms, добавим одну кнопку и напишем такой обработчик:
procedure TForm1.Button1Click(Sender: TObject);
begin
  LoadLibrary('Project2.dll');
end;
Запустим приложение вне отладчика через Ctrl + Shift + F9 (Run / Run Without Debugging) и нажмём на кнопку. Нам нужно убедится, что, действительно, ничего не происходит. Т.е. нам нужно воспроизвести поведение.

Если мы не видим проблемного поведения (т.е. сообщение будет всё же показано), значит, мы не смогли воспроизвести проблему. Возможно, поведение программы меняется под отладчиком? Пробуем запустить под отладчиком. Возможно, дело в версии Delphi? Пробуем другую версию Delphi. Возможно, дело в версии Windows? Пробуем другую версию ОС. И так далее. Мы подбираем условия окружения при которых мы надёжно воспроизводим ошибку.

(Забегая вперёд, скажу, что для воспроизведения проблемы нужна Delphi 2007 и выше, Windows Vista и выше)

Как только мы воспроизвели "плохое" поведение под отладчиком - нужно настроить проекты для отладки. Для этого откроем Project / Options каждого проекта (exe и DLL) и включим (если они не включены) следующие опции:
  • Compiling \ Stack Frames
  • Compiling \ Range Checking
  • Compiling \ все опции в категории Debugging
  • Compiling \ Use Debug DCUs
  • Linking \ Debug Information (старое название: TD32 Debug Info)
  • Linking \ Include remote debug symbols
  • Linking \ Map file = Detailed
и выключим опцию Compiling \ Optimization.

Конечно, не все эти опции нужно включать для нашего примера, но тут, что называется, "лишним не будет". Главные опции для нашего случая это Compiling \ Use Debug DCUs - т.к. мы собираемся отлаживать код RTL/VCL (ShowMessage) и Linking \ Debug Information - т.к. мы будем отлаживать DLL и EXE.

Кроме того, на вкладке Delphi Compiler мы сбросим Output Directory и Unit Output Directory в ".\" (без кавычек) - что приведёт к выводу всех файлов в ту же папку где лежат исходники (вместо обычной подпапки \Win32\Debug).

Сделаем Project / Build каждому проекту (напомню, что простого Project / Compile недостаточно если вы меняете опции проекта, но не его исходный код).

Теперь, откроем проект DLL и установим точку останова:


Теперь используем Run / Parameters и укажем для какого исполняемого файла нужно запускать DLL (это делать не нужно, если вместо проекта DLL вы открываете проект EXE):


Запускаем проект. Вы увидите, что точка останова становится недействительной:


Это - нормально. Ведь DLL не загружена в процесс EXE. Если бы это была точка останова на код в EXE - тогда, да, это было бы не нормально, что-то пошло не так.

Когда вы нажмёте на кнопку в приложении, DLL будет загружена вызовом LoadLibrary, после чего точка останова снова станет действительной. Затем LoadLibrary вызовет DllMain нашей библиотеки с "причиной" = DLL_PROCESS_ATTACH. Для события DLL_PROCESS_ATTACH RTL Delphi автоматически вызывает секции initialization всех модулей из DLL, а также секцию begin/end .dpr файла (и, наоборот, для DLL_PROCESS_DETACH вызываются секции finalization всех модулей). В результате мы встаём на нашей точке останова:


В окне стека вызовов (View / Debug Windows / Call Stack) вы можете видеть, что нас вызвала LoadLibrary, которую, в свою очередь, вызвали мы из Button1Click. К сожалению, отладчик IDE не может полностью реконструировать стек, пропуская некоторые вызовы. Он также не знает где взять отладочную информацию для системных DLL.

В любом случае, мы нажимаем F7 (Run / Step Into), чтобы войти в ShowMessage:


Если вы не видите эту картину, а вместо этого отладчик просто перешёл на следующую после ShowMessage строку (с end) - т.е. F7 (Step Into) сработала как F8 (Step Over) - то это означает, что у отладчика нет отладочной информации о модулях RTL/VCL. Это происходит потому, что опция Use Debug DCUs не была включена. А если она была включена, значит, она не возымела действия. Последнее может происходить в двух случаях:
  1. Вы не сделали проекту DLL Project / Build (а, например, просто запустили его);
  2. Ваш проект использует пакеты времени выполнения (run-time packages, BPL).

Окей, как только вы разобрались с проблемами, продолжаем нажимать F7...



Вот первая интересная функция. Мы видим, что при выполнении некоторых условий: Vista+ (TOSVersion.Check(6)), UseLatestCommonDialogs, ComCtl32.dll V6+ (IsNewCommonCtrl) и использовании стиля по умолчанию (StyleServices.IsSystemStyle), вызывается DoTaskMessageDlgPosHelp, в противном случае - DoMessageDlgPosHelp. Быстро глянем в DoTaskMessageDlgPosHelp:
function DoTaskMessageDlgPosHelp(const Instruction, Msg: string; DlgType: TMsgDlgType;
  Buttons: TMsgDlgButtons; HelpCtx: Longint; X, Y: Integer;
  const HelpFileName: string): Integer;
var
  DefaultButton: TMsgDlgBtn;
begin
  if mbOk in Buttons then DefaultButton := mbOk else
    if mbYes in Buttons then DefaultButton := mbYes else
      DefaultButton := mbRetry;
  Result := DoTaskMessageDlgPosHelp(Instruction, Msg, DlgType, Buttons, HelpCtx,
    X, Y, HelpFileName, DefaultButton);
end;

// ...

function DoTaskMessageDlgPosHelp(const Instruction, Msg: string; DlgType: TMsgDlgType;
  Buttons: TMsgDlgButtons; HelpCtx: Longint; X, Y: Integer;
  const HelpFileName: string; DefaultButton: TMsgDlgBtn): Integer;
// ...
begin
  Application.ModalStarted;
  LTaskDialog := TTaskMessageDialog.Create(nil);
  try
    // ...

    // Show dialog and return result
    Result := mrNone;
    if LTaskDialog.Execute then
      Result := LTaskDialog.ModalResult;
  finally
    LTaskDialog.Free;
    Application.ModalFinished;
  end;
end;
и DoMessageDlgPosHelp:
function DoMessageDlgPosHelp(MessageDialog: TForm; HelpCtx: Longint; X, Y: Integer;
  const HelpFileName: string): Integer;
begin
  with MessageDialog do
    try
      HelpContext := HelpCtx;
      HelpFile := HelpFileName;
      if X >= 0 then Left := X;
      if Y >= 0 then Top := Y;
      if (Y < 0) and (X < 0) then Position := poScreenCenter;
      Result := ShowModal;
    finally
      Free;
    end;
end;
Отсюда видно, что в зависимости от набора условий ShowMessage реализуется либо через вызовы TaskDialog API, либо через обычную VCL-форму (создаётся в CreateMessageDialog). Что-то похожее мы уже делали.

Итак, нажмём один раз F8 (Run / Step Over), чтобы выполнить строчку с if и посмотреть, куда мы встанем:


Окей, т.е. в нашем случае ShowMessage будет реализован через Task Dialog API. Заходим внутрь по F7 (Step Into), затем ещё раз (входим в DoTaskMessageDlgPosHelp). Функция DoTaskMessageDlgPosHelp настраивает диалог, а затем его вызывает. Нам интересно, что происходит в момент вызова диалога, поэтому весь код настройки мы проходим по F8 (Step Over) - вплоть до вызова if LTaskDialog.Execute then. Поскольку в теле DoTaskMessageDlgPosHelp есть цикл (да и в целом это не самая короткая функция) - можно пролистать код вниз и установить новую точку останова на строчку с LTaskDialog.Execute, после чего запустить программу через F9 (Run / Run). Отладчик выполнит код настройки и встанет на точке останова:


Заходим в LTaskDialog.Execute по F7 (Step Into):


Метод Execute - динамический (dynamic), поэтому он вызывается не напрямую. Этот код ищет адрес метода в таблице DMT и сохраняет его в регистр ESI, после чего делает на него переход (JMP). Мы могли бы (как и выше с LTaskDialog.Execute) установить точку останова на строчку JMP ESI, но вызов динамического метода - частая операция. Мы бы не хотели вставать на этой точке останова каждый раз, когда мы проходим по F8 (Step Over) вызовы других динамических методов. Поэтому мы установим курсор на строчку JMP ESI и нажмём F4 (Run / Run to Cursor), после чего нажмём F7 (Step Into) и, наконец, попадём внутрь метода Execute:


Проходим метод по F8 (Step Over) или используем F4 (Run to Cursor) и заходим по F7 (Step Into) в Result := Execute(LParentWnd). Как и ранее, Execute - метод динамический, поэтому используем F4 (Run to Cursor) на JMP ESI и F7 (Step Into) для входа в унаследованную реализацию:


Несколько раз повторим эти операции, путешествуя по реализациям Execute, пока не окажемся в самой нижней TCustomTaskDialog.DoExecute:


Как и выше с DoTaskMessageDlgPosHelp, TCustomTaskDialog.DoExecute сначала производит настройку, а затем вызывает интересующий нас кусок:

function TCustomTaskDialog.DoExecute(ParentWnd: HWND): Boolean;
// ...
begin
  // ...
  try
    Result := TaskDialogIndirect(LTaskDialog, {$IFNDEF CLR}@{$ENDIF}LModalResult,
      {$IFNDEF CLR}@{$ENDIF}LRadioButton, {$IFNDEF CLR}@{$ENDIF}LVerificationChecked) = S_OK;
    FModalResult := LModalResult;
    if Result then
  // ...
end;
Здесь нас интересует вызов TaskDialogIndirect, поэтому ставим курсор на строчку Result := TaskDialogIndirect(...) = S_OK; и жмём F4 (Run to Cursor). Далее нажимаем F7 (Step Into).


Мы ожидали, что TaskDialogIndirect - функция Windows, для неё у нас нет исходного кода (даже с включенной опцией Use Debug DCUs), поэтому F7 (Step Into) сработает как F8 (Step Over). Но, как мы видим, в Delphi TaskDialogIndirect - это переходник-обманка, которая динамически ("по запросу") загружает "настоящую" TaskDialogIndirect (и сохраняет её в глобальной переменной _TaskDialogIndirect). Это (скрытие реализации под "известным" именем) - подводный камень при отладке, т.к. мы можем не предположить, что за вызовом TaskDialogIndirect скрывается какой-то "наш" код и пропустить его пройдя вызов TaskDialogIndirect по F8 (Step Over).

Если вы попались на эту удочку и выполнили TaskDialogIndirect по F8 (Step Over), то вы увидели, что Result стал равен False, а сообщения на экране не появилось. Т.е. TaskDialogIndirect вернула какой-то код ошибки, который код RTL/VCL успешно проигнорировал. Вы хотите узнать этот код. Для этого вы устанавливаете точку останова на строчку Result := TaskDialogIndirect(...) = S_OK - это интересующий нас участок кода. Ничего больше нас уже не интересует, поэтому все прочие точки останова (View / Debug Windows / Breakpoints) можно удалить.

(Подсказка: сначала удалите все старые точки останова, а лишь затем устанавливайте новую точку останова на строчку Result := TaskDialogIndirect(...) = S_OK, а не наоборот.)

Снимите приложение по F2 (Run / Program Reset) и запустите снова. Щёлкните по кнопке - и вы должны встать сразу на строчке Result := TaskDialogIndirect(...) = S_OK, минуя все предыдущие шаги:


Вызовите CPU отладчик через Ctrl + Alt + C (View / Debug Windows / CPU Windows / Entire CPU):


Вы увидите машинный код, в который была скомпилирована строка исходного кода, на которой вы стоите. Вызов любой функции будет происходить в три шага:
  1. Подготовка аргументов
  2. Непосредственный вызов (передача управления)
  3. Чтение/сохранение/анализ результата
Обратите внимание, что п1 может включать в себя и другие вызовы функций (например, код DoExecute(GetParentWindow) сначала вызовет GetParentWindow, а лишь затем - DoExecute). Нас же интересует только п2. Несложно сообразить, что п2 будет последним вызовом функции среди всего кода, сгенерированного для этой строки.

Вызов другой функции на ассемблере - это инструкция call, поэтому нас интересует последняя инструкция call в строчках машинного кода между двумя жирными строками (Vcl.Dialogs.pas.5703: Result := ... и Vcl.Dialogs.pas.5705: FModalResult := ...). В данном случае это 04BF2AE0 E88751E7FF call $04a67c6c.

Вы можете нажимать F8 (Step Over), чтобы пройтись по строкам машинного кода вплоть до этой строки, или же вы можете установить курсор на эту строчку и нажать F4 (Run to Cursor). В любом случае вы встанете на этой строке:


Нажмите F8 (Step Over) ещё раз, чтобы выполнить эту функцию.

Результат функции будет помещён в регистр EAX - любая функция всегда возвращает результат через EAX. Но даже, если вы это не знаете, про это можно догадаться, т.к. п3 из списка выше ("чтение/сохранение/анализ результата") первым делом проверяет регистр EAX (test eax,eax).

Поскольку TaskDialogIndirect возвращает HRESULT, то в EAX будет лежать искомый код ошибки (в виде HRESULT).

В любом случае, возвращаясь к коду TaskDialogIndirect - здесь нас интересует вызов _TaskDialogIndirect, но мы не знаем по какой ветке пойдёт выполнение, поэтому мы нажимаем F8 (Step Over), пока это не станет ясно (как оказывается, мы идём по ветке else). Дойдя до Result := _TaskDialogIndirect мы (на всякий случай) нажимаем F7 (Step Into):


В этот раз F7 (Step Into) сработала как F8 (Step Over) - т.к. мы вызвали функцию Windows (для которой у нас нет исходного кода). В данном случае мы можем увидеть, что результат вызова (значение Result типа HRESULT) равно -2147024809. Для этого вы можете просто навести мышью на слово Result в редакторе кода - и IDE покажет значение Result во всплывающей подсказке. Или вы можете использовать окно локальных переменных (View / Debug Windows / Local Variables). Или вы можете щёлкнуть правой кнопкой по Result и выбрать Debug / Evaluate/Modify из всплывающего меню.

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

Итак, мы вроде как нашли причину, почему ShowMessage ничего не показывает. Потому что она вызывает TaskDialogIndirect, а она, в свою очередь, завершается с ошибкой номер -2147024809. Но что это за ошибка номер -2147024809?

Для этого мы запустим утилиту Error Lookup из состава EurekaLog. Если вы используете EurekaLog, то эта утилита у вас уже есть - её можно запустить через меню Пуск (Пуск / Программы / EurekaLog / Tools / Error Lookup) или через меню IDE Delphi (Tools / EurekaLog / Error Lookup). Если EurekaLog у вас нет, то Error Lookup можно установить бесплатно автономно - скачав её с сайта - вас интересует "Freeware EurekaLog Tools Pack".

Итак, скормим -2147024809 в Error Lookup:


Как вы можете видеть, -2147024809 - это ошибка HRESULT с кодом $80070057 = E_INVALIDARG (причём это спроецированная на HRESULT Win32 ошибка с кодом 87 = ERROR_INVALID_PARAMETER - что можно проверить запустив поиск ошибки 87). Итак, TaskDialogIndirect ругается на неверные аргументы. Уже в этот момент мы должны понять, что что-то идёт сильно не так. Предположительно отлаженный код RTL/VCL должен вызывать предположительно отлаженный код Windows, так что ошибки вида "неверный аргумент" возникать в принципе не должно, если только в функцию не просочатся "неверные" данные от нас лично.

Но какой именно аргумент не нравится TaskDialogIndirect? Жаль, что системные функции Windows не используют исключения - с кодами ошибок у нас нет указания на аргумент, который не понравился функции. У нас есть два вектора атаки:
  1. Окно-родитель (ParentWnd) устанавливается динамически самим RTL/VCL и не приходит от нашего кода. Возможно, TaskDialogIndirect не понравилось окно?
  2. Известно, что вызов ShowMessage в экспортируемой функции работает успешно. Мы можем сравнить, чем отличаются аргументы между успешным и не успешным вызовами.
Чтобы проверить первую гипотезу, мы установим точку останова на (вторую) строчку Result := _TaskDialogIndirect(...) в TaskDialogIndirect и удалим все прочие точки останова (как и ранее, это удобнее делать наоборот: сначала удалить все точки останова, потом добавить новую). Перезапустим программу, щёлкнем по кнопке и остановимся на точке. Проанализируем аргументы функции _TaskDialogIndirect (наводите на них мышь или используйте Evaluate/Modify). Вы увидите, что окно-родитель передаётся в pTaskConfig.hwndParent. Нам нужно сбросить это значение в ноль (и для начала стоит выяснить, что ноль (NULL) является допустимым аргументом - это так, мы проверили это по документации). Чтобы изменить это значение, удобнее всего вызвать Evaluate/Modify из окна локальных переменных:


Или щёлкните правой по pTaskConfig в редакторе кода и вызовите Evaluate/Modify из контекстного меню, затем допишите ".hwndParent" (без кавычек) в поле Expression и нажмите Evaluate.

Чтобы сбросить это значение, введите 0 в поле New value и нажмите Modify. Теперь значение обнулено:


Выполните функцию (по F8). Результат оказывается тем же самым (функция завершается с ошибкой E_INVALIDARG). Т.е. дело не в окне-родителе.

Для второй гипотезы нам нужно выписать все аргументы функции. Для этого удобно развернуть окно локальных переменных на всю высоту и развернуть в нём все под узлы. Альтернативно можно также открыть несколько окон Evaluate/Modify:


Просто сделайте скриншот экрана.

Теперь нам нужно вызвать ShowMessage из экспортируемой функции. Для этого снимите выполняющуюся программу и измените текст DLL так:
library Project2;

uses
  Vcl.Dialogs;

{$R *.res}

procedure Test;
begin
  ShowMessage('Test');
end;

exports
  Test;

begin
  ShowMessage('Test');
end.
Затем сохраните изменения, скомпилируйте, закройте проект DLL, откройте проект EXE и измените его код:
procedure TForm1.Button1Click(Sender: TObject);
var
  Lib: HMODULE;
  Test: procedure;
begin
  Lib := LoadLibrary('Project2.dll');
  Win32Check(Lib <> 0);
  Test := GetProcAddress(Lib, 'Test');
  Win32Check(Assigned(Test));
  Test;
end;
Мы пишем тестовый пример, поэтому мы можем наплевать на правильное освобождение ресурсов, но нам важна правильная обработка ошибок, т.к. она облегчает диагностику.

Сохраните и перекомпилируйте проект EXE. Запустите проект и нажмите на кнопку. Убедитесь, что теперь сообщение показывается (это происходит из экспортируемой функции). Закройте проект EXE и откройте проект DLL.

Вернитесь к нашей TaskDialogIndirect. Чтобы это быстро сделать - откройте список точек останова (View / Debug Windows / Breakpoints) и дважды щёлкните по (единственной) установленной точке останова - среда должна перенести вас на (вторую) строчку Result := _TaskDialogIndirect(...) внутри WinAPI.CommCtrl.TaskDialogIndirect (да, надеюсь, в вашей среде были включены autosave-опции). Напомню, нас интересует второй вызов ShowMessage и, следовательно, второй вызов TaskDialogIndirect. Несложно догадаться, что во второй раз переменная _TaskDialogIndirect будет уже присвоена, поэтому выполнение пойдёт по первой ветке:
function TaskDialogIndirect(const pTaskConfig: TTaskDialogConfig;
  pnButton: PInteger; pnRadioButton: PInteger; pfVerificationFlagChecked: PBOOL): HRESULT;
begin
  if Assigned(_TaskDialogIndirect) then
    // Выполняется из Test:
    Result := _TaskDialogIndirect(pTaskConfig, pnButton, pnRadioButton,
      pfVerificationFlagChecked)
  else
  begin
    InitComCtl;
    Result := E_NOTIMPL;
    if ComCtl32DLL <> 0 then
    begin
      @_TaskDialogIndirect := GetProcAddress(ComCtl32DLL, 'TaskDialogIndirect');
      if Assigned(_TaskDialogIndirect) then
        // Выполняется из DllMain:
        Result := _TaskDialogIndirect(pTaskConfig, pnButton, pnRadioButton, 
          pfVerificationFlagChecked)
    end;
  end;
end;
Что ж, установим точку останова на (первую) строчку Result := _TaskDialogIndirect(...) (и уберём точку останова со (второй) строчки Result := _TaskDialogIndirect(...)) и запустим программу. Мы встанем на точке, после чего мы можем "заскриншотить" аргументы вызова _TaskDialogIndirect и сравнить их с нашим скриншотом первого вызова (из DllMain).

Внезапно оказалось, что аргументы обоих вызовов полностью идентичны (но выполнение успешно лишь в одном случае, а в другом оно проваливается с ошибкой E_INVALIDARG). Следовательно, дело не в аргументах самой TaskDialogIndirect, а в аргументах какой-то другой функции, которую вызывает TaskDialogIndirect - опять же, поскольку системные функции Windows используют коды ошибок, а не исключения, то мы никак не можем идентифицировать точную функцию, вернувшую ошибку.

На этом часто исследование можно считать законченным - у нас нет исходного кода Windows и мы не можем отлаживать её код. В таких случаях нам остаётся только перебор входных значений "угадыванием". Но в данном случае мы получаем ошибку E_INVALIDARG. Проверка аргументов - предположительно, что-то такое, что должно выполняться в начале функции, не слишком глубоко внутрь. Поэтому есть шанс, что мы сможем понять в чём дело, не потратив на это много времени.

(Вздох)

Открываем CPU-отладчик:


Проходим по строчкам до вызова call и заходим в него по F7 (Step Into):


Как вы можете видеть, системная функция TaskDialogIndirect, на самом деле, очень короткая и является лишь переходником к чему-то другому (видимо, аналогично тому, как функция TaskDialog является переходником к TaskDialogIndirect). В машинном коде не видно каких либо проверок (условных JMP), поэтому можно смело дойти до call и снова войти в него:


В этот раз ситуация интереснее. Код виден большой, длинный, есть какие-то проверки (cmp, test), переходы (jz, jnz).

Любая функция заканчивается инструкцией ret (в крайне редких случаях функция может безусловно передать управление на другую функцию вместо возврата управления). $CC (int 3) является просто заполнителем свободного места между функциями (в системных DLL, Delphi же использует в качестве заполнителя случайный мусор). Иными словами, сейчас мы вошли в начало некоторой функции.

Однако не всегда ret обозначает конец функции. Это также может быть частью блока try/except/finally.

Что ещё отличается - перешли мы на какую-то внутреннюю функцию в ComCtl32.dll. Эта функция не экспортируется и не имеет имени. Поэтому отладчик IDE не может показать её имя в стеке вызовов (и показывает только адрес и DLL), а также не может обозначить её начало и имя в CPU-отладчике. Тем не менее, существует способ понять, где же мы оказались. Дело в том, что Microsoft даёт вам доступ к отладочной информации (но не исходному коду) системных DLL. К сожалению, Delphi не умеет использовать эту информацию. Зато её умеет использовать утилита Address Lookup из EurekaLog (или EurekaLog Tools Pack). Ей на вход нужны имя DLL и смещение адреса в ней от начала, а на выход она даст вам human-readable информацию об этом адресе. Заметьте, что абсолютный адрес (в нашем случае - $7244550A) не имеет никакого смысла для Address Lookup. Абсолютные адреса имеют смысл только в рамках того процесса, в котором они получены. В другом процессе эти адреса могут быть иными. Поэтому, чтобы идентифицировать место в коде, Address Lookup вместо одного абсолютного адреса использует пару: базовый адрес + смещение = абсолютный адрес.

Итак, абсолютный адрес у нас есть - это $7244550A. Как получить смещение? Для этого откройте View / Debug Windows / Modules и отсортируйте список загруженных DLL по имени (Name):


Найдите в списке DLL, соответствующую нашему адресу. В данном случае это будет ComCtl32.dll, что указывается в стеке вызовов. Альтернативно, вы можете отсортировать список по Base Address и взять максимальный адрес, который будет меньше нашего адреса. В обоих случаях вы найдёте строчку с ComCtl32.dll, откуда узнаете, что её базовый адрес - $72370000. Запускаем калькулятор Windows, переключаем его в режим "Программист", а также меняем режим на HEX, и: $7244550A - $72370000 = $000D550A. Именно это значение ($D550A) и будет смещением кода внутри (от начала) ComCtl32.dll. И именно это значение нужно скормить Address Lookup:


(Примечание: по умолчанию никакой настройки Address Lookup для использования сервера отладочной информации Microsoft выполнять не нужно, но если у вас что-то не работает - вот инструкция)

Окей, оказывается TaskDialogIndirect вызывает CTaskDialog.Show - что является методом Show класса CTaskDialog.

Что теперь? Теперь я предлагаю пройтись по коду с F8 (Step Over), внимательно следя за тем, что происходит в коде. В частности - какие переходы срабатывают. Тут можно придерживаться разных стратегий. Мы можем запустить две среды и две сессии отладки и отлаживать успешный и не успешный вызовы TaskDialogIndirect одновременно, сравнивая выполнение и находя отличия. Это гарантировано даст вам результат, но уж больно трудоёмко. Можно сообразить, что функция TaskDialogIndirect вернула нам код ошибки как результат (в EAX), поэтому мы можем проследить по машинному коду, откуда пришло это значение ("отладкой задом-наперёд").

Можно также сообразить, что функция состоит непосредственно из кода, а также из вызовов других функций. Мы уже знаем (выяснили выше), что проблема - не в проверке аргументов непосредственно в самой TaskDialogIndirect, проблема в какой-то другой функции, которую вызывает TaskDialogIndirect. Есть ненулевая вероятность, что TaskDialogIndirect вызывает другую функцию, которая также возвращает HRESULT. Следовательно, мы можем следить за появлением известного кода ошибки ($80070057) в регистре EAX после вызова функций (call). Поэтому мы просто выполняем код CTaskDialog.Show по F8 (Step Over), пока не увидим $80070057 в EAX:


Окей, а вот и наша под-функция. Нам не пришлось идти слишком далеко (кажется, это третья вызываемая функция). Теперь мы можем установить точку останова на call $7248d137 и перезапустить программу, после чего остановиться на точке и войти в под-функцию по F7. Это снова будет какая-то внутренняя безымянная функция ComCtl32.dll. Снова используем калькулятор: $7248D137 - $72370000 = $11D137 и снова используем Address Lookup:


Немного странное имя, но ОК, предположим. В принципе, это имеет смысл, если учесть, что мы вызываем код из DllMain, которая имеет известные ограничения.

Что ж, повторим наш алгоритм: будем проходить код по F8 (Step Over), следя за появлением "волшебного кода" ($80070057) в регистре EAX после вызовов функций (call). В этот раз нам придётся пройти довольно много кода, но в итоге мы находим ещё один вызов:


(Кстати, обратите внимание, это как раз одна из функций, которая заканчивается на jmp, а не ret.)

Устанавливаем точку останова и перезапускаем программу (предыдущую точку останова можно удалить, либо просто пропускать/игнорировать при выполнении - главное, не запутаться).


Ой. В отличие от прошлого раза, где под-функция возвращала HRESULT, в этот раз HRESULT приходит от GetLastError, а сама проблемная функция вызывается ранее и, вероятно, возвращает просто 0 (False).

Само собой, искать 0 в EAX - гиблое дело, ибо там он будет ну очень часто. Вместо этого можно поступить двумя способами:
  1. Логично предположить, что провалившаяся функция вызывается непосредственно перед вызовом GetLastError, т.е. надо проверить только предыдущий call;
  2. Можно вместо EAX следить за GetLastError.
И хотя в нашем случае вполне подходит вариант 1, давайте посмотрим на вариант 2. Для этого откройте окно Watch List (View / Debug Windows / Watches), щёлкните по нему правой кнопкой мыши и выберите Add Watch во всплывающем меню:


Впишите "GetLastError" (без кавычек) в Expression, включите Allow side effects and function calls и переключите отображение в Hexadecimal. Allow side effects and function calls необходима, чтобы отладчик вообще вычислял бы выражение с "GetLastError". По умолчанию отладчик не будет вычислять выражения для отладки, если это потенциально может изменить состояние программы. В нашем случае мы знаем, что вызов GetLastError - "безопасен", поэтому мы явно указываем это отладчику. Переключение же вида в Hexadecimal необходимо по той причине, что функция GetLastError возвращает код ошибки в виде числа (DWORD/Cardinal), даже хотя в нашем случае этим числом является HRESULT. Переключение режима позволит нам увидеть $80070057, а не -2147024809 (похоже, отладчик Delphi не различает знаковые и беззнаковые типы в Watch-ах).

В любом случае:


Перезапустите программу и снова пройдитесь по коду, как мы это делали выше, только теперь вместо EAX смотрите и за EAX, и за GetLastError в Watch List.

Так или иначе вы находите проблемный вызов:


Установите точку останова, перезапуститесь, войдите:


Можно предположить, что этот странный код является заглушкой-переходником. "Входим" в jmp (не важно - по F8 или по F7, jmp это безусловный переход, а не вызов функции):


...и снова переходник! Входим в call:


...и ещё один! Снова входим в call:


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


Что ж, функция это довольно большая и длинная. Есть куча переходов, вызовов, даже ret-ы, и даже GetLastError будет меняться. Но нам не нужно анализировать весь этот код. Несложно догадаться, что функция должна найти адрес целевой функции (загрузить DLL, сделать туда GetProcAddress), после чего сохранить результат в переменную (регистр/память) и сделать на него переход (не вызов! т.к. для вызова нужно формировать параметры, про которые функция не в курсе). Т.е. нам нужно дойти до jmp на опосредованное значение (т.е. не на фиксированный адрес типа $7244559С, а на, скажем, регистр).

Более того, если вы были действительно внимательны, то заметили, что этот jmp у нас уже есть - посмотрите выше на функции-переходники: по адресу $723F86EE как раз лежит jmp, который переходит на EAX (результат функции). Т.е. нам нужно только установить там точку останова и запустить программу по F9 (Run / Run). Ну или через F4 (Run to Cursor).


А вот и наша функция - некая InitGadgets из DUser.dll. Описание DUser.dll сообщает, что это - "Windows DirectUser Engine". Это недокументированная, внутренняя DLL Windows.

Окей, продолжаем выполнение дальше. Я, кстати, рекомендую поставить точку останова на начало InitGadgets - когда мы пропустили так много кода (особенно - внутреннего кода), лучше иметь надёжную точку для отката (точку останова в экспортируемой функции), на всякий пожарный. В любом случае, немного пройдясь по коду, следя за EAX и GetLastError, вы быстро найдёте следующее звено:


$6716С0EС - $67160000 (база для DUser.dll) = $C0EC =


ResourceManager.InitContextNL. И буквально чуть-чуть далее:


Опа! И кто же это такой? ;)

Мы заметили явно "наше" проблемное значение $80070057, но не заметили, а как же мы попали в эту строчку. Очевидно, произошла какая-то проверка, которая отправила "хороших" - дальше по коду, а "плохих" завернула на эту ветку. Нам нужно перезапустить программу и пройти InitContextNL заново, внимательно следя на условными переходами - не отправит ли кто нас на адрес $67187А91. И вот мы находим проверку (мы уже так близки к разгадке!):


А вот и переход. Как мы видим, InitContextNL вызывает какую-то функцию, та возвращает ей, видимо, TRUE (1 в EAX), что не нравится InitContextNL, и она переходит на установку жёстко зашитого кода ошибки. Немного странно: т.е. функция по ESI не завершается с ошибкой (иначе она вернула бы FALSE), вместо этого функция возвращает какую-то информацию. Возможно, мы не так близки к разгадке, как думали...


Смотрим, $6716BC60 - это:


WinNT.IsInsideLoaderLock! Тайна раскрыта!

Давайте реконструируем стек вызовов:
  • Button1Click
  • LoadLibrary
  • DllMain
  • ShowMessage
  • TaskDialogIndirect
  • CTaskDialog.Show
  • InitThread
  • InitGadgets
  • ResourceManager.InitContextNL
  • WinNT.IsInsideLoaderLock
WinNT.IsInsideLoaderLock возвращает True - это действительно так, ведь мы находимся внутри DllMain, т.е. критическая секция загрузчика ОС занята нами. Это значение трактуется как ошибка методом InitContextNL класса ResourceManager, который и возвращает искомый код ошибки E_INVALIDARG ($80070057) - даже хотя ошибка не имеет отношения к аргументам. Ну и несложно догадаться, что далее этот код ошибки всплывает до вызова TaskDialogIndirect внутри ShowMessage (где и успешно игнорируется).

Иными словами, Task Dialog API явно проверяет, не вызывают ли его из DllMain, и если да - то отказывается работать.

Это не указано явно в документации к Task Dialog API, но указано опосредовано в описании DllMain:
Calling functions that require DLLs other than Kernel32.dll may result in problems that are difficult to diagnose.
Окей, тайна раскрыта, создан тикет.

P.S. Но что насчёт "сообщение показывается в других программах" и "всегда показывается в экспортируемой функции"? Ну, с экспортируемой функцией всё просто - её вызывают вне DllMain и блокировка загрузчика ОС не удерживается, так что WinNT.IsInsideLoaderLock возвращает False, и проверка в InitContextNL проходит. Т.е. TaskDialogIndirect выполняется успешно и показывает сообщение. А насчёт других программ: можно предположить (и потом проверить), что "другие программы" не используют ComCtl32.dll версии 6, а используют ComCtl32.dll версии 5 - т.е. они не используют т.н. "XP манифест" и темы. Следовательно, когда эти программы загружают нашу DLL, в MessageDlgPosHelp условие не выполняется, и выполнение идёт по второй ветке, вызывая DoMessageDlgPosHelp вместо DoTaskMessageDlgPosHelp. Т.е. сообщение показывается через VCL-форму, а Task Dialog API не участвует.

P.P.S. Кстати, если кому интересно: DUser.WinNT.IsInsideLoaderLock просто вызывает RtlIsThreadWithinLoaderCallout, которую вы можете импортировать из ntdll.dll. RtlIsThreadWithinLoaderCallout имеет следующий прототип:
function RtlIsThreadWithinLoaderCallout: BOOL; stdcall;

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

  1. В Delphi 7 всё работает - при вызове LoadLibrary сообщение показывается.

    Проект и скомпилированные файлы приложил:
    http://rghost.ru/7tDLMKJdp

    ОтветитьУдалить
    Ответы
    1. В Delphi 7 нет Task Dialog API, поэтому и нет причин не работать.

      Удалить
  2. А отлаживать компоненты Windows намного удобнее в Visual Studio. Сейчас всем доступна полная версия в виде Community Edition. Она на лету подгрузит отладочную информацию с Microsoft Symbol Server и покажет все в красивом и удобном виде.
    Однажды был чем-то похожий случай - в XP все работало, а в 7-ке возникали случайные ошибки. Было интересно заглянуть в недра ole32.dll и узнать про сообщения WM_PAINT во время ожидания вызова COM-сервера.

    ОтветитьУдалить
    Ответы
    1. А что, сообщение как-то обрывало вызов?

      Удалить
    2. Использовался наследник компонента TVirtualStringTree, который подгружал данные на лету, во время обработки WM_PAINT. И не ожидал, что во время обработки WM_PAINT оно еще раз придет. Подробнее.

      Удалить
  3. Титаническое расследование, узнал кучу полезных приемов, но всего этого можно было бы избежать, если следовать простому правилу не пихать ничего существенного в DLLMain ))

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

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

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

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

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

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

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