понедельник, 20 января 2014 г.

Здравствуйте, я ошибка 217 и я вам ничего не скажу

Вероятно многие встречались с таким вот "партизаном" при старте или завершении приложения:


Очень информативное сообщение, сразу понятна причина ошибки, место и способ ее решения :)
Впрочем, если без шуток, что это вообще такое?
Конечно-же это исключение, но ни тип исключения, ни его описание нам не доступны - просто "Runtime error 217" и адрес, а дальше сами...

Если честно, раньше я как-то даже не задумывался по поводу данного исключения, т.к. в моих проектах оно явление редкое, пока однажды у целой череды пользователей не начала воспроизводится именно 217-я ошибка.
Впрочем, даже тогда я не пошел по правильному пути и просто добавил дополнительный уровень логирования в проект, по результатам которого достаточно оперативно нашел причину и исправил ее.
Но, по сути, я просто потратил свое время...

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



Теория и анализ проблемы


Без теории нам никуда, иначе можем уткнуться в пределы собственных знаний :)
Поэтому начнем, конечно, с теоретической части.

Для начала я немного освежил мои представления об ошибках в принципе, перечитав часть статьи "Обработка ошибок - глава 1.2.2" за авторством Александра Алексеева, откуда вынес информацию о том, что ошибка 217 будет отображена в том случае, если не инициализирован модуль SysUtils, причем это у Александра проиллюстрированно достаточно наглядно:


На основании данной картинки можно сделать грубый вывод: пока SysUtils жив - все исключения должны отображаться в нормальном виде, о чем идет отдельное упоминание:

Например, если вы видите сообщение о runtime-ошибке, то, судя по приведённой схеме, маловероятно, чтобы ошибка возникла в обработчиках событий на форме. Зато гораздо вероятнее, что она возникает, скажем, в какой-то секции finalization (которая выполняется после секции finalization модуля SysUtils) или в назначенной процедуре ExitProcessProc. Но, разумеется, причина ошибки может сидеть где угодно — в том числе и в упоминаемых обработчиках событий.

Ну что-ж давайте проверим, пишем код, в котором SysUtils должна быть финализирована позже модуля Unit1, в котором искусственно генерируем исключение:

unit Unit1;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs;

type
  TForm1 = class(TForm)
  private
    { Private declarations }
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

initialization

finalization

  raise Exception.Create('finalization exception');

end.

Билдим, запускаем, закрываем форму и... Runtime error 217.

Утверждение о том, что 217 отображается после финализации SysUtils полностью верное, но давайте-ка посмотрим на сам код финализации:

procedure FinalizeUnits;
...
begin
...
  Count := InitContext.InitCount;
  Table := InitContext.InitTable^.UnitInfo;
...
  try
    while Count > 0 do
    begin
      Dec(Count);
      InitContext.InitCount := Count;
      P := Table^[Count].FInit;
      if Assigned(P) then
...
        TProc(P)();
...
      end;
    end;
  except
    FinalizeUnits;  { try to finalize the others }
    raise;
  end;
end;

Смотрите что происходит: в процедуре FinalizeUnits вызываются все финализирующие процедуры, адреса которых расположены в массиве InitContext.InitTable^.UnitInfo в том порядке, в котором происходила их инициализация, т.е. самые первые расположены в начале массива (а финализация идет с конца).
Где-то в самом низу расположен и SysUtils + System, ну а мы, с нашим модулем Unit1 где-то в самом верху.
Но вдруг происходит исключение в нашем модуле и "бабах", порядок катарсиса нарушен.

После "бабах" FinalizeUnits вызывается повторно, пропуская наш модуль, вызвавший исключение, вследствие чего разрушается SysUtils и разные, встречающиеся по пути, class destructor-ы, до кучи грохается System с менеджером памяти (сидящий одним из первых в начале списка), после чего идет контрольный выстрел в лоб - RAISE, вот тут-то мы и приплыли - здравствуй 217.

А что если произойдет исключение в секции инициализации любого модуля?

Да все тоже самое:

procedure InitUnits;
...
begin
...
  try
...
  except
    FinalizeUnits;
    raise;
  end;
end;

Делаем вывод: любое необработанное исключение в секциях инициализации или финализации будет приводить к потере описания исключения и приводить к ошибке 217.

На этом с теорией, думаю, закончим.
Имея на руках понимание о причине возникновения Runtime error 217, попробуем получить на руки более привычный нам вариант сообщения об исключении.

Отключаем финализацию модулей


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

Его анализ заключался в следующем: общая инициализация обработчика исключений производится в процедуре InitExceptions модуля SysUtils, а финализация вызовом DoneExceptions.

Если каким либо образом отключить вызов DoneExceptions плюс не дать разрушиться менеджеру памяти, заблокировав вызов блока финализации System - на руки мы получим сообщение об исключении в приемлимом виде.

Как вариант решения был предложен следующий код, который нужно подключить к файлу проекта самым первым модулем (будет работать начиная с D2005 и выше):

unit suShowExceptionsInInitializeSections;

interface

uses
  SysUtils;

implementation

uses
  Windows;

//Получение структуры PackageInfo нашего приложения
//В System она находится в переменной InitTable, но не видна из других модулей
function GetInitTable: PackageInfo;
var
  Lib: PLibModule;
  TypeInfo: PPackageTypeInfo;
begin
  Result := nil;

  Lib := LibModuleList;

  if not Assigned(Lib) then
    Exit;

  //Если загружено несколько модулей (BPL пакетов), то выходим,
  //я не изучал как работает механизм загрузки/выгрузки BPL, поэтому на всякий
  //случай выходим
  if Assigned(Lib^.Next) then
    Exit;

  Typeinfo := Lib^.TypeInfo;
  if Assigned(TypeInfo) then
  begin
    //Мы имеем TPackageTypeInfo
    //Теперь по нему можно получить PackageInfo
    //Воспользуемся особенностями компилятора.
    //В IDA видно, что ссылка TypeInfo указывает на середину структуры
    //PackageInfo программы
    //Поэтому для того что бы вычислить PackageInfo нужно вычесть из адреса
    //TypeInfo смещение этого поля
    Result := PackageInfo(PByte(TypeInfo) - (LongWord(@PackageInfoTable(nil^).TypeInfo)));
  end;
end;

//Отключить секцию финализации для всех модулей
procedure DisableAllFinalization;
var
  Loop: Integer;
  OldProtect: LongWord;
  InitTable: PackageInfo;
  Table: PUnitEntryTable;
begin
  InitTable := GetInitTable;

  if Assigned(InitTable) then
  begin
    Table := InitTable^.UnitInfo;
    if Assigned(Table) then
      //Разрешаем изменять структуру в которой хранятся ссылки на инициализаю/финализацию всех юнитов
      if VirtualProtect(Table, SizeOf(PackageUnitEntry) * InitTable^.UnitCount, 
        PAGE_READWRITE, OldProtect) then
        for Loop := 0 to InitTable^.UnitCount - 1 do
          Table^[Loop].FInit := nil;
  end;
end;

initialization

finalization
  //Сейчас идет финализация всех модулей, модуль SysUtils создан раньше, поэтому
  //он еще не финализирован. Наша задача здесь не дать ему финализироваться,
  //Как и другим модулям которые он использует (интересует только System),
  //это нужно для правильной отработки обработчиков исключений.

  //Сюда мы можем попасть по двум причинам
  //1. Произошел Exception во время инициализации каком-то модуля
  //2. Нормальное завершение программы
  //
  //Мы не будем определять причину, так как процесс все равно завершается, а ОС
  //сама освободит занятые ресурсы после смерти процесса.
  //Но нужно иметь ввиду, данную технику использовать в DLL нельзя, что бы не
  //допускать утечек памяти
  if IsLibrary then
    Exit;

  //Мы не можем выборочно заблокировать финализацию юнитов по их имени
  //так как нет соответствующих данных в RTTI. Тем не менее, мы можем отключить
  //финализацию всех юнитов, которые идут в списке до этого
  //модуля. Таким образом если данный модуль расположить первым в DPR файле,
  //то мы минимизируем утечки.
  //Вычислять адрес процедуры финализации данного юнита не обязательно,
  //ведь к моменту выполнения данного кода уже финализированы все следующие юниты.
  //Поэтому просто заблокируем финализцию всех оставшихся
  DisableAllFinalization;
end.

Если честно - аплодировал стоя.
Вот он: хак в самом грязном виде как он есть - такие вещи могут делать только те, кто действительно понимает, чем это грозит :)
И данный модуль вывел работу нашего IT отдела примерно на три часа - это была жесткая дискуссия :)

Но, впрочем, давайте разберем логику работы данного кода:
Суть его проста, необходимо выйти на данные о загруженных модулях (включая BPL) в том виде, в котором их понимает Delphi приложение. Это было сделано посредством доступа к началу однонаправленного списка структур TLibModule. Первым элементом списка будет структура, описывающая текущий образ, откуда нам нужно всего-то и получить данные о структуре UnitInfo, которая содержит в себе данные как о количестве инициализированных модулей, так и об адресах их процедур инициализации и финализации в виде записи PackageUnitEntry.

Блокирование финализации модулей происходит посредством присвоения параметру FInit значения nil у каждой записи PackageUnitEntry.

При обниливании данного параметра FinalizeUnits не сможет произвести вызов обработчика и в итоге тот самый raise, о котором я писал выше, сможет достаточно корректно произвести отображение возникшего исключения.


Но вот дальше все сложнее.

Пытаемся причесать хорошую мысль


Идея здравая и причины понятны, но вот как-же так, ресурсы все-же не освобождены, FastMem перестанет нормально работать (она собирает утечки как раз при финализации), да и совместимости маловато, к примеру, как я и сказал выше, под Delphi 7 данный код вообще работать не сможет.

После первого часа обсуждений в IT отделе мы даже умудрились прийти и к такому выводу: "да и хрен с ними с SysUtils и System - что-то критичного они за собой не несут".
А потом, опять начали спорить - ну не устраивал нас этот подход, вроде все хорошо, но не аккуратненько как-то :)

Рассматривались даже варианты прямого сплайсинга блоков финализации и до кучи деструктора Exception - но дополнительный хак, на уже существующий хак не устраивал вообще никого :)

И тут, сидя в отладчике и прогоняя код по 70-му разу пришла мысля.
Дык эта... а как вообще выводится сообщение о произошедшем исключении? :)

А выводится оно посредством передачи управления на ExceptHandler, в коде которого нет ничего секретного.
А что мы делаем убирая финализацию модулей?
Правильно, заставляем вызваться его-же.

Попробуем-ка проэмулировать вызов ExceptHandler.
Пишем тестовый юнит и подключаем его к проекту самым первым:

unit Test;

interface

uses
  SysUtils;

var
  E: Exception;

implementation
initialization
finalization
  E := AcquireExceptionObject;
  if E <> nil then
  begin
    ShowException(E, ExceptAddr);
    E.Free;
    Halt(1);
  end;
end.

Запускаем на выполнение и...



Получилось.

Встроившись в цикл финализации, мы отобразили произошедшее исключение и продолжили финализацию дальше вызовом Halt(1).

В итоге задача решена, грамотно и документировано, и совместимо с Delphi 7, но...

А не развить ли идею?


Есть такое понятие, как "наведенные ошибки", т.е. ошибки произошедшие из-за того что перед ними тоже произошла ошибка.

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

Тоже самое может произойти и в процедурах инициализации/финализации, причем исключение, произошедшее в финализации скроет от нас саму причину.

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

uses
  Classes;

var
  Logger: TFileStream;

const
  StartLog: AnsiString = 'Начало работы приложения' + sLineBreak;
  EndLog: AnsiString = 'Работа приложения завершена' + sLineBreak;

implementation

initialization

  Logger := TFileStream.Create('A:\MyLog,txt', fmCreate);
  Logger.WriteBuffer(StartLog[1], Length(StartLog));

finalization

  Logger.WriteBuffer(EndLog[1], Length(EndLog));
  Logger.Free;

end.

Мало у кого в системе присутствует диск "А" поэтому результатом этого кода будет либо "Runtime error 216" (именно 216, а не 217), либо, если подключим код из предыдущей главы:
Exception EAccessViolation in module Project2.exe at 001B1593.
Access violation at address 005B1593 in module 'Project2.exe'. Read of address 00000000.
А ведь причина то кроется в самом первом исключении, которое нами не отображается и с наскока разобраться в причине ошибки не получится.

Для того чтобы исправить эту несправедливость, можно немного причесать код и довести его до вот такого состояния:

unit ShowExceptSample;

interface

uses
  SysUtils,
  Classes;

implementation

type
  PRaiseFrame = ^TRaiseFrame;
  TRaiseFrame = packed record
    NextRaise: PRaiseFrame;
    ExceptAddr: Pointer;
    ExceptObject: TObject;
    ExceptionRecord: PExceptionRecord;
  end;

var
  // Указатель на вершину списка исключений
  CurrentRaiseList: Pointer = nil;

// Функция возвращяет текущее исключение со стека
function GetNextException: Pointer;
begin
  if CurrentRaiseList = nil then CurrentRaiseList := RaiseList;
  if CurrentRaiseList <> nil then
  begin
    Result := PRaiseFrame(CurrentRaiseList)^.ExceptObject;
    PRaiseFrame(CurrentRaiseList)^.ExceptObject := nil;
    CurrentRaiseList := PRaiseFrame(CurrentRaiseList)^.NextRaise;
  end
  else
    Result := nil;
end;

var
  ExceptionStack: TList;
  E: Exception;  

initialization

finalization

  // Смотрим, есть ли вообще исключения?
  E := GetNextException;

  if E <> nil then
  begin
    ExceptionStack := TList.Create;
    try

      // если есть, собираем о них информацию
      while E <> nil do
      begin
        ExceptionStack.Add(E);
        E := GetNextException;
      end;

      // и отображаем их в том порядке, в котором они произошли
      while ExceptionStack.Count > 0 do
      begin
        E := ExceptionStack[ExceptionStack.Count - 1];
        ExceptionStack.Delete(ExceptionStack.Count - 1);
        ShowException(E, ExceptAddr);
        E.Free;
      end;
    finally
      ExceptionStack.Free;
    end;

    // финализируем все что осталось
    Halt(1);
  end;
end.

Здесь идея проста, функция GetNextException по сути повторяет вызов AcquireExceptionObject, но после своего вызова не теряет ссылку на следующее в очереди исключение, а запоминает адрес следующего фрейма во внешней переменной.
После чего все исключения заносятся в список (самое последнее будет первым в списке) и выводятся программисту с соблюдением очередности, в результате чего нам будет сразу понятно, что сначала произошло вот это:


И уже только после него пошли всякие там AV.

Теперь по поводу остальных кодов ошибок.
Почему я начал именно  с "Runtime error 217"?
Ну потому что она наиболее легко воспроизводима, а так технически, используя выше приведенный модуль, мы получим на руки вполне нормальное описание всех возможных Runtime ошибок, коих в наличии у нас вон сколько:

  reMap: array [TRunTimeError] of Byte = (
    0,   { reNone }
    203, { reOutOfMemory }
    204, { reInvalidPtr }
    200, { reDivByZero }
    201, { reRangeError }
{   210    Abstract error }
    215, { reIntOverflow }
    207, { reInvalidOp }
    200, { reZeroDivide }
    205, { reOverflow }
    206, { reUnderflow }
    219, { reInvalidCast }
    216, { reAccessViolation }
    218, { rePrivInstruction }
    217, { reControlBreak }
    202, { reStackOverflow }
    220, { reVarTypeCast }
    221, { reVarInvalidOp }
    222, { reVarDispatch }
    223, { reVarArrayCreate }
    224, { reVarNotArray }
    225, { reVarArrayBounds }
{   226    Thread init failure }
    227, { reAssertionFailed }
    0,   { reExternalException not used here; in SysUtils }
    228, { reIntfCastError }
    229, { reSafeCallError }
    235, { reMonitorNotLocked }
    236  { reNoMonitorSupport }
{$IFDEF PC_MAPPED_EXCEPTIONS}
{   230   Reserved by the compiler for unhandled exceptions }
{$ENDIF PC_MAPPED_EXCEPTIONS}
{$IF defined(PC_MAPPED_EXCEPTIONS) or defined(STACK_BASED_EXCEPTIONS)}
{   231   Too many nested exceptions }
{$ENDIF}
{$IF Defined(LINUX) or Defined(MACOS)}
{   232   Fatal signal raised on a non-Delphi thread }
    ,
    233 { reQuit }
{$ENDIF LINUX or MACOS}
{$IFDEF POSIX}
    ,
    234  { reCodesetConversion }
{$ENDIF POSIX}
    ,
    237, { rePlatformNotImplemented }
    238  { reObjectDisposed }
);

Итог


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

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

А если нет — значит буду вторым :)

Отдельный респект соавтору и вдохновителю данной статьи - Виктору Федоренкову.

Удачи.

---

© Виктор Федоренков
© Александр (Rouse_) Багель
Январь, 2014

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

  1. Круто! Большое спасибо за отличную статью! Обязательно возьму ваш модуль ShowExceptSample на вооружение. :)

    ОтветитьУдалить
  2. Спасибо за статью. Было бы еще здорово если бы такие вещи сами разработчики Delphi делали. Все таки показ последней ошибки с описанием ее гораздо лучше воспринимается чем невнятные Error.

    ОтветитьУдалить
  3. Спасибо за статью, отличный материал...

    ОтветитьУдалить
  4. Аплодировал сидя.

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