Дело о случайных вылетах

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

Примечание: слово «случайных» в заголовке означает, что вылет происходит в непредсказуемом месте — разном, в зависимости от приложения. Но в рамках одного конкретного приложения вылет всегда детерминирован (и, следовательно, воспроизводим).

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

Читать на сайте автора.

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

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

Довольно много людей обратили внимание на такую конструкцию:

try
  SomeVariable := TSomeClass.Create({...});
  // ...
finally
  SomeVariable.Free;
end;

«Это неправильно, правильно — так:»

Читать на сайте автора.

Задачка №23

Есть ли ошибка в этом коде?

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

var
Stream: TFileStream;
begin
Stream := nil;
try
// что-то делаем
Stream := TFileStream.Create({ ... });
// что-то делаем
finally
Stream.Free;
end;
try
try
Stream := TFileStream.Create({ ... });
except
Exit;
end;
// что-то делаем
finally
Stream.Free;
end;
end;

Читать на сайте автора.

Интерпретируйте результаты экспериментов правильно

К моему переводу статьи Руди о String и PChar добавили замечательный комментарий, на который мне хотелось бы ответить, но объём заблуждений не позволяет это сделать простым ответом в комментарии.

Текст из статьи, реакцией на который был написан комментарий, видимо, такой:

Это означает, что вы не должны использовать PChar для указания на строки, а затем изменять строку. Лучше всего избегать таких вещей:

// ПРЕДУПРЕЖДЕНИЕ: ПЛОХОЙ ПРИМЕР
var
S: string;
P: PChar;
begin
S := ParamStr(0); // например, нам вернули 'C:Test.exe';
P := PChar(S);
S := 'Something else';

Если S изменяется на 'Something else', указатель P не будет изменён и будет продолжать указывать на 'C:Test.exe'. Поскольку P не является строковой (в смысле String) ссылкой на этот текст, и у нас нет никакой другой переменной, ссылающейся на него, то его счётчик ссылок станет равным 0, и текст будет удалён из памяти. Это означает, что P теперь указывает на недоступную память.

Сам же комментарий:

Michael Shamrovsky
По факту, команда PChar(S), где S: String ничего плохого не сделает, даже если мы после присвоения переменной типа PChar, поменяем строку. Наша куча символов не измениться, и будет указывать на живую страницу.

var
S: String;
P: PChar;
begin
S := 'Hallo!';
P := PChar(S);
ShowMessage(P);
Delete(S, 2, 1);
ShowMessage(P);
end;

Результат — на экране дважды Hello!, хотя по Вашим словам будет ЖОПА!

Учим мат часть! В начале выделяется новая страница, в ней образуется копия строки но без лидирующих 4х байт, затем дается ссылка.

PChar это не просто преобразователь типов, но и копировщик строки!!!

Если вы хотите, вы можете самостоятельно выяснить, что не так с этим экспериментом. Считайте это очередной задачкой. Либо листайте ниже для ответа.

Оставим сладкое («дважды Hello!«) на конец, а пока давайте начнём со слона в комнате: «PChar это не просто преобразователь типов, но и копировщик строки!!!«. Это весьма сильное утверждение, которое легко проверить: достаточно посмотреть исходный код подпрограммы преобразования String к PChar. Для этого достаточно запустить программу, остановиться на строке присваивания и нажать F7 (вход в подпрограмму) — при условии, конечно, что у вас включены отладочные DCU для системного кода (Use Debug DCUs):

Как видим, ни о каком копировании здесь речи не идёт: происходит прямое копирование указателя. Для надёжности можно проверить в машинном отладчике, что никакого иного кода (кроме вызова _UStrToPWChar) в строке присвоения нет:

Упражнение: почему в машинном отладчике процедура называется @UStrToPWChar, а не _UStrToPWChar?

Хм, но если строка не копируется, а P указывает на S, то почему-же при изменении S не меняется P?

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

Как я узнал, что Delete делает копию? Отложим этот вопрос на потом, я подробно покажу это, а пока поверьте мне на слово.

Постойте, но почему Delete делает копию? И что, в таком случае, будет являться оригиналом строки? В какой переменной он хранится, этот оригинал? Неужели, в P и Михаил был прав?

Наводящий вопрос: а где, собственно, хранится сам текст 'Hallo!'? В строку он же должен попасть откуда-то.

Возможно, я открою тайну, но: сложные константы хранятся в специальном сегменте данных программы. Поскольку константы не должны меняться, то этот сегмент имеет атрибуты «только для чтения» (read only). Это означает, что любая попытка записать что-то в этот участок памяти приведёт к возбуждению исключения EAccessViolation. Вот почему Delete делает копию строки — потому что оригинальную строку изменить нельзя.

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

Ну, давайте посмотрим, как мы можем это подтвердить. Вспомним, что у строк типа String есть счётчик ссылок. Он увеличивается на 1 при копировании строк и уменьшается при выходе переменной за область видимости. Свежесозданные строки имеют счётчик ссылок равный 1, а удаляются из памяти, когда счётчик опускается до нуля. Соответственно, если счётчик равен 1, то на строку ссылается одна переменная (и тогда её можно спокойно модифицировать), а если счётчик больше 1, то на строку ссылается две или более переменных (и тогда строку модифицировать нельзя, надо сначала сделать копию).

Как мы можем узнать счётчик ссылок?

Если у вас относительно новая Delphi, то в ней должна быть функция StringRefCount, а если очень старая — то нужно вспомнить внутреннее устройство строк. Строка является указателем, который указывает на начало данных строки (символов). Непосредственно перед этими данными лежит служебный заголовок — в котором, помимо всего прочего, хранится счётчик ссылок строки. Служебный заголовок представлен записью (record) TStrRec в модуле System.

Таким образом, если у нас есть строка S, то, чтобы узнать её счётчик ссылок, нужно преобразовать её в указатель (например: PAnsiChar(S) в старых Delphi или PByte(S) — в новых; PAnsiChar/PByte здесь нужен только чтобы иметь адресную арифметику), затем отступить на размер заголовка ( - SizeOf(TStrRec)), разыменовать указатель (Pointer(...)^), обозначить, что это — заголовок (TStrRec(...)) и, наконец, прочитать счётчик ссылок (.refCnt). Итого, вы можете добавить в Watches такое выражение:

TStrRec(Pointer(PByte(S) - SizeOf(TStrRec))^).refCnt

где S — это ваша переменная типа String.

Примечание: если вы захотите использовать просто StringRefCount вместо этого страшного выражения, то вам нужно будет вставить вызов этой функции в любое место — этого будет достаточно, чтобы Delphi поместила машинный код функции в вашу программу. По умолчанию эта функция нигде не вызывается, её код в вашу программу не попадает, соответственно, вызывать то, чего нет, — нельзя. Ах, да, галку «Allow side effects and function calls» надо будет ещё установить в свойствах Watch-а.

Посмотрим же, чему будет равен счётчик ссылок. Запустим программу. Сначала строка не инициализирована, т.е. указывает на nil, и счётчика ссылок у неё просто нет.

Но после присвоения:

Счётчик ссылок стал равен… минус единице. Несложно сообразить, что -1 в данном случае является специальным флагом, который указывает, что память для строки не была выделена в куче (в противном случае счётчик ссылок имел бы значение просто 1), т.е. что строка является константой. Именно на этот признак смотрит Delete, когда принимает решение о необходимости копировать строку.

Пройдёмся дальше по коду. До вызова Delete всё скучно, нет никаких изменений, счётчик ссылок по-прежнему равен -1. А вот после вызова Delete:

Счётчик становится равным 1 — именно потому, что Delete сделала копию строки. Т.е. выделила в куче новый блок памяти, счётчик ссылок которого будет равен 1. Чтобы он мог опуститься до 0, когда S выйдет из поля видимости. И при этом память, которую выделила Delete, будет освобождена.

А что же P? А P мы не меняли. Она по-прежнему указывает на «старую S«, которая, как мы помним является константой. Таким образом, в памяти программы будет две строки: одна — 'Hallo!' (в области памяти для констант, на неё указывает P), а другая — 'Hllo!' (в динамической куче, на неё указывает S). Как видим, никакого копирования памяти P не выполняет. Михаил ошибся.

Вот ещё пара экспериментов, которые можно провести: во-первых, попробуйте записать что-то в P:

var
S: String;
P: PChar;
begin
S := 'Hallo!';
P := PChar(S);
ShowMessage(P);
Delete(S, 2, 1);
ShowMessage(P);
P^ := 'A'; // - добавили; должно изменить первый символ строки
ShowMessage(P); // - добавили
end;

Если вы запустите этот код, то получите исключение EAccessViolation при попытке записи в P. Происходит это именно потому, что P указывает на read-only область памяти констант, куда невозможно произвести запись. Если бы P указывала на строку S (которая размещена в read-write памяти кучи), то операция записи в P была бы успешна.

Во-вторых, вы можете зайти в Delete по F7:

Как вы можете видеть, Delete первым действием проверяет, нужно ли копировать строку:

И строка копируется, если её счётчик ссылок отличен от 1 (т.е. в том числе — когда он равен минус единице).

Вот, собственно, и всё. Правильно трактуйте результаты своих экспериментов.

P.S.
Возможно, если бы Михаил дочитал статью до конца, он бы и сам догадался:

Я не рассказал всё, что известно, и даже, возможно, немного переврал правду (например, не любая строка типа String управляется счётчиком ссылок – к примеру, строковые константы всегда имеют счётчик ссылок равный –1), но эти мелкие детали не столь важны для общей, большой картины и не оказывают влияния на использование и взаимодействие между String-ми и PChar-ми.

P.P.S.
Упражнение: чем отличается модифицирование строки указанными способами?

var
S: String;
P: PChar;
begin
S := { ... };
P := S;

// Способ 1:
S[1] := 'A';

// Способ 2:
P^ := 'A';
end;

Читать на сайте автора.

Дело о зависании 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 он был исправлен.)

Читать на сайте автора.

Windows Error Reporting и Delphi

Windows Error Reporting (сокращённо: WER) — это набор технологий, встроенных в Windows, который собирает информацию о сбое в приложениях при их вылетах (а также о сбоях ядра) и отправляет её на сервера Microsoft. Разработчик программного обеспечения по этой информации может разработать и опубликовать соответствующее обновление. Затем конечный пользователь, отправляя отчёт, увидит, что для этой ошибки в программе доступно исправление, сможет скачать его и обновить программу.

В этой статье я хотел бы посмотреть на его историю, концепцию и как вы можете использовать его на практике для своих приложений Delphi (или, наоборот, не использовать).
в сообщениях об ошибках. Например, «Runtime error 217 at 004060CD» или «Access Violation at address 005D2500…». К сожалению, Delphi сделана в этом отношении не совсем грамотно: она показывает абсолютный адрес, а не смещение. В итоге получается, что если ошибка произошла в .exe — вам (возможно) повезло: .exe (почти) всегда грузится по фиксированному адресу $00400000. Если же исключение произошло в DLL — вы пролетели, если только вам сильно не повезёт (каким-то образом вы узнаете базовый адрес DLL, или же DLL на вашей машине окажется загруженной по тому же адресу).

 

Читать на сайте автора.

Как создать "часовые" значения для указателей в Windows

Это перевод On generating sentinel pointer values in Windows. Автор: Реймонд Чен.

Предположим, что вам нужно несколько т.н. «часовых» значений. К примеру, пусть ваша функция работает

Читать на сайте автора.

Как-то раз клиент сообщил об ошибке на карте в Flight Simulator

Это перевод That time a customer reported an error in the map used by Flight Simulator. Автор: Реймонд Чен.

Игра Microsoft Flight Simulator гордится своей точностью

Читать на сайте автора.

Задачка №22

Предновогодняя задачка на внимательность.

Объясните этот скриншот:

Здесь реализована обычная QuickSort сортировка массива. Известно, что иногда она портит данные. Как такое может быть?

Стандартные условия: ошибка у вас перед глазами, внешнего воздействия нет.

Ответ будет опубликован после праздников.

Читать на сайте автора.

Сомнительная уязвимость: атакуем каталог приложения, чтобы обмануть… самого себя?

Это перевод Dubious security vulnerability: Attacking the application directory in order to fool yourself? Автор: Реймонд Чен.

Нам отправили примерно такой отчёт о найденной уязвимости в

Читать на сайте автора.