22 мая 2017 г.

Дело о зависании Delphi 7

Очередное детективное расследование. В этот раз мы разбираемся, почему Delphi 7 наглухо виснет при старте.

К нам (в техподдержку EurekaLog) поступил очередной запрос, который касался Delphi 7. Для его решения мне требовалось запустить проект клиента в Delphi 7. Однако, когда я запустил Delphi 7 в нашей тестовой виртуальной машине (на Windows 10 Creators Update) - она зависла при старте. Висит сплэш-скрин, грузится процессор, ничего не происходит.

Таким образом, прежде чем решать проблему с клиентом, нужно решить проблему с самой Delphi 7.

Я проверил, что Delphi 6 и Delphi 2005 (ближайшие смежные соседи Delphi 7) запускаются отлично. Не так давно система Windows 10 на тестовой виртуальной машине была обновлена до Creators Update, что (в очередной раз) сломало регистрацию Delphi 6 (похоже, она привязывается к сборке ОС?). Возможно, что что-то случилось и с Delphi 7?

Быстрое гугление по симптомам (Delphi 7 hangs on Windows 10 Creators Update) не принесло результатов. Похоже, что с проблемой никто не сталкивался. Что-ж, программисты мы или где?

Полная загрузка процессора говорит о том, что у нас не зависание (deadlock), а live lock. Если бы у нас было зависание - мы могли бы воспользоваться уже известными трюками. Но у нас нет зависания, Delphi 7 чем-то занята. Поэтому трюки из статьи по ссылке нам не очень-то помогут (но вам никто не запрещает попробовать).

Итак, запускаем целевое приложение (Delphi 7) и даём ему повиснуть. Запускаем лучшую IDE всех времён и народов (Delphi XE) и вызываем команду Attach to process:


Выбираем наше зависшее приложение и не забываем установить галочку "Pause after attach":


Примечание: если вы собираетесь отлаживать проблему в той же IDE, которой вы хотите производить отладку, то вы можете идентифицировать целевой процесс по PID-у (Process ID), предварительно проверив его через менеджер процессов типа Process Explorer.

IDE подключится к целевому процессу и встанет на паузу. Вы должны увидеть что-то такое:


Примечание: поскольку мы отлаживаем Delphi 7, которая не имеет отладочной информации, то мы сможем работать только с машинным CPU-отладчиком, а стек вызова сможет показывать только подпрограммы из системных DLL и BPL-пакетов (по экспорту). Если же вы отлаживаете современную IDE или свою собственную программу, то отладочная информация у вас будет - или из .jdbg файлов для IDE или из .dcu для вашей программы. Тогда вы сможете использовать и обычный высокоуровневый отладчик (включая анализ переменных, их имена, имена подпрограмм в стеке вызовов и т.п.).

Итак, перед вами - служебный поток отладчика IDE, который тот внедрил в целевую программу, чтобы остановить её. Этот служебный код не отражает никакой реальной работы самой целевой программы. Чтобы начать работу с самой программой, вам нужно сначала переключиться в какой-либо из её рабочих потоков. Для этого посмотрите на окно Threads:


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

Чтобы нам было проще ориентироваться - мы можем назначить (произвольное) имя каждому потоку. Для этого щёлкните правой по потоку и выберите Name thread:


Введите какое-нибудь понятное вам описание потока. Например, "Debugger Thread" или даже "Главный поток".

Далее, дважды щёлкните по следующему потоку. В моём случае следующие четыре потока были одинаковы:


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

Первый по счёту поток - главный, что также видно по его (большому) стеку:


Наконец, последний поток:


Судя по всему, это фоновый парсер, который выполняет разбор кода в редакторе кода и, возможно, его подсветку. Наличие такого потока говорит нам о том, что среда загрузилась довольно далеко, прежде чем зависнуть. Мы также можем увидеть, что в настоящее время поток спит (Sleep/ZwDelayExecution на вершине стека) - вероятнее всего, в ожидании ввода пользователя. Таким образом, мы также можем его игнорировать.

Итого:


Из всех этих потоков нас пока интересует только главный поток. Начнём его препарировать. Нам известно, что в целевой программе произошло какое-то зацикливание (live lock). Для начала нам нужно определить примерное место. Для этого можно установить точку останова в каком-либо "подозрительном" месте стека вызова. Например:


В данном случае на стеке виден цикл обработки сообщений от Application.ProcessMessages. Я установил точку останова на первую строчку в целевой программе, не относящуюся к системе (user32) или пакетам RTL/VCL. Ставить точку останова нужно сразу же после вызова (call) подпрограммы.

Снимаем программу с паузы (Run / Run) и... ничего не происходит. Точка останова не срабатывает. Следовательно, проблема не в обмене оконными сообщениями. Мы никак не можем выйти из обработки единственного оконного сообщения.

Заходим с другой стороны:


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

Выяснилось, что программа останавливается в единственном месте - тут:


Это единственное место, и оно находится на самой вершине стека. Это говорит нам о том, что зацикливание, вероятно, связано с, буквально, бесконечным локальным циклом, а не какой-то высокоуровневой ошибкой логики.

Пройдёмся немного по шагам (через "Step Over"), увидим вот это:


Выполнив эту строчку, получим:


Т.е. в конце стоит безусловный переход на начало этого же блока кода. Что это напоминает? Конечно же, цикл вида:
while True do
begin
  // ...
  if Condition then 
    Break;
  // ...
end;

Иными словами, что-то пошло не так и условие выхода из цикла никогда не выполняется, цикл крутится бесконечно (ну или пока целевая программа не вылетит с ошибкой типа нехватки памяти, чтения/записи недопустимой памяти и т.п.).

Кстати, в этот момент нам наконец-то станет известно точное имя подпрограммы, где находится этот цикл:


В этот момент мы можем схитрить и просто открыть файл Controls.pas из папки Source установленной Delphi 7, чтобы найти там метод TDockTree.LoadFromStream. Но это не спортивно и мне не удастся показать несколько приёмов отладки.

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

Для этого, нам сначала нужно установить точку останова также и на вызывающем:


Таким образом, как только мы успешно выйдем из цикла - мы встанем на этой точке останова, что и скажет нам о том, что мы успешно продолжили выполнение.

Чтобы не нарушить естественных ход кода программы, нам нужно найти конец цикла и условие, по которому мы могли бы выйти. В данном случае:


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

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



(Хотя в данном случае было бы быстрее просто использовать команду "Increment Register")

Выполним команду проверки регистра EAX и увидим, что переход станет активным:


Что ж, запустим программу снова и... остановимся на ровно той же точке останова. Это говорит нам о том, что мы, вероятно, неверно определили конец цикла. Иными словами:
while True do
begin
  // ...
  if Condition1 then // - переключили это условие
    Continue; 
  // ...
  if Condition2 then // - а надо было - это
    Break;
  // ...
end;

Чтобы найти нужную границу, лучше всего начать с конца метода и найти пути, ведущие к его выходу. Затем переключить все условия, стоящие на этом пути.

Конец метода можно опознать по команде ret, а также по finally-блоку от try (набор pop и mov fs:[eax],edx). Как правило, "волшебные" finally-блоки стоят в строке с end в конце метода, чтобы освободить ресурсы под локальные переменные с автоматическим временем жизни, либо как явный finally-блок в коде программы перед end.

В данном случае:


Горизонтальными линиями я отметил границы цикла while, а также условие с break, которое перебросит нас за цикл while, в конец метода.

Посмотрим, что же это за условие. Остановимся на команде сравнения и посмотрим, с чем сравнивается значение регистра EAX. Для этого воспользуемся окном Memory и командой Goto Address:


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

Получим:


Чтобы не запутаться в little/big-endian - удобно переключить отображение на размер, соответствующий нашим данным. В этом случае - 4 байта (a.k.a. DWORD):



Окей, идёт сравнение с $FFFFFFFF - что есть -1 для знакового целочисленного типа. Мы уже выяснили, что это - что-то глобальное (константа или переменная). Поскольку константы целочисленных типов сохраняются непосредственно в коде (на них не производится ссылка через адрес), то у нас, скорее всего, идёт сравнение с глобальной переменной. Ну или с таким:
const
  SomeConst: Integer = -1;
Но скорее всего - глобальная переменная. Т.е. возможны два варианта: либо в переменную записали что-то не то, либо в данных для цикла отсутствует ожидаемое значение.

В любом случае, мы установили, что для выхода из цикла необходимо, чтобы EAX был равен $FFFFFFFF (вместо его текущего значения: нуля). Окей, остановимся непосредственно перед выполнением проверки и изменим значение EAX:


(Опять же, в данном случае было бы быстрее просто использовать команду Decrement Register)

Запускаем программу на выполнение - и программа останавливается на точке останова в вызывающем. Окей, из цикла мы вышли. Продолжаем выполнение - программа снова останавливается на нашей первой точке остановка (в LoadFromStream). Т.е. проблемный метод вызывается несколько раз. Повторим действия по искусственному выходу из цикла while. В итоге целевая программа всё же запускается:


Как вы можете видеть, есть артефакты панелей IDE (что, видимо, также вызвало Access Violation). Но, главное, что мы узнали - при старте IDE открывает какой-то старый проект и стопорится при загрузке настроек расположения окон и панелей. Вероятно, эти настройки повреждены. И есть ненулевая вероятность, что эти настройки хранятся в настройках проекта.

Удаляем старый проект, запускаем Delphi 7 - ура! Работает!

Дело закрыто.

Но в чём же была проблема? Давайте посмотрим исходный код. Открываем Controls.pas из Delphi 7 (не Delphi XE):


Ищем в нём TDockTree.LoadFromStream:


И видим:


А вот, похоже, и наша глобальная переменная из проверки, которую мы меняли, чтобы выйти из цикла while. Как мы можем видеть, -1 - это значение по умолчанию и, следовательно, не является ошибочным значением. Т.е. с переменной всё в порядке, проблема была только в данных.

Смотрим текст метода:


Я выделил границы цикла while и условие выхода из него (которое мы меняли).

Упражнение/домашнее задание: видите ли вы баг в TDockTree.LoadFromStream, который привёл к зацикливанию?

Подсказка: этот пост помечен тэгом "обработка ошибок".

(Этот баг исправлен в RAD Studio 10.2 Tokyo, но мне лень смотреть, в какой именно версии Delphi он был исправлен.)

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

  1. А не проще было б монитором посмотреть что дёргается при старте?

    ОтветитьУдалить
    Ответы
    1. Вариант вполне возможный, да. Если есть конкретное подозрение.

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

    ОтветитьУдалить
    Ответы
    1. Подсказка: чем .Read отличается от .ReadBuffer?

      Удалить
    2. ReadBuffer поднимет исключение. Собственно он и делает то, что требуется - анализирует значение, возвращенное методом Read )

      Удалить
  3. Извините. Не понял, что надо сделать-то, что бы пофиксить это зависание? В TDockTree.LoadFromStream поменять Read на ReadBuffer? А дальше что? Что-то перекомпилировать?

    ОтветитьУдалить
    Ответы
    1. Как обычно исправляют баг в программе? Правят исходный код программы и перекомпилируют исправленную версию. Исходного кода IDE Delphi 7 у нас нет. Следовательно, исправить этот баг мы не можем. Мы можем только его обойти (удалив "плохой" файл проекта).

      P.S. На самом деле, IDE использует код из run-time пакета RTL, поэтому можно пересобрать стандартный пакет RTL. Ну и, конечно же, всегда можно сделать прямую правку машинного кода в .exe/.dll/.bpl.

      Удалить
    2. Спасибо. Подскажите тогда, как пересобрать RTL? Ведь он собирается во время установки Delphi? Чтобы собрать принудительно, видимо, есть какой-то механизм? К сожалению не знаком с этими внутренностями. А эти зависы уже достали. Проект открывается через раз. Или есть какой-то рецепт как найти этот пресловутый "плохой" файл проекта?

      Удалить
    3. А с чего вы взяли, что ваше зависание вызвано в точности тем же кодом, что в моём случае?

      Удалить
    4. Симптомы похожи. При запуске проекта Delphi бесконечно перебирает окна проекта.

      Удалить

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

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

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

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

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

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