25 октября 2025 г.

Отладочный менеджер памяти VirtualMM

Здесь я рассказываю про отладочный менеджер памяти VirtualMM. Что это такое, когда его использовать, когда его не использовать, как его использовать, где скачать, как настроить, и так далее.

Текст этой статьи предполагает, что вы знакомы (хотя бы в общих чертах) с понятиями адресное пространство, страница памяти, резервирование (RESERVE) и выделение (COMMIT).

Содержание


Архитектура памяти в приложениях Delphi

Чтобы понять смысл (суть) отладочного менеджера VirtualMM, нам сначала нужно освежить в памяти основные знания по управлению памятью (*) в приложениях Delphi.

Как приложения Delphi выделяют память?

Мы уже много говорили про память в Delphi и Windows:
  1. Часть 1;
  2. Часть 2;
  3. Часть 3.
Напомним вкратце: в Delphi программах у нас есть стек для локальных переменных и блок (секция) для глобальных переменных, а также динамическая память (куча) для любых данных переменного размера. Несмотря на то, что в Delphi есть множество динамических данных (объекты, массивы, строки, интерфейсы, ...) - все они так или иначе вызывают функцию GetMem. Например, создание строки вызовет GetMem, передав ей символьный размер строки + размер служебного заголовка строки. Создание объекта вызовет GetMem, передав ей свойство .InstanceSize объекта. И так далее. Иными словами, любые динамические данные в Delphi являются надстройкой над функцией GetMem.

Как память выделяется на системном уровне?

Аналогично тому, как в Delphi есть базовая функция выделения памяти (GetMem), которую вызывают все остальные функции выделения памяти, в Windows тоже есть своя базовая функция выделения памяти: это - VirtualAlloc. На самом деле, у неё есть несколько вариантов, но для простоты мы их проигнорируем. В рамках этой статьи все функции "семейства VirtualAlloc" мы будем называть просто "функция VirtualAlloc".

Что такое менеджер памяти?

Если вы ничего не знаете про память в приложениях Delphi и Windows, вы могли бы предположить, что функция GetMem в Delphi просто вызывает функцию VirtualAlloc в Windows. Иными словами, это одна и та же функция. Но это не так: на самом деле, функция GetMem не вызывает функцию VirtualAlloc напрямую. Вместо этого, функция GetMem вызывает менеджер памяти в приложении Delphi. А вот уже менеджер памяти вызывает функцию VirtualAlloc.

Опять же, мы уже говорили про это.

Зачем приложения Delphi используют менеджер памяти?

Зачем вообще нужен менеджер памяти? Почему нельзя использовать функции управления памятью операционной системы напрямую? Т.е. почему функция GetMem не может просто вызвать функцию VirtualAlloc? Проблема состоит в том, что функция VirtualAlloc выделяет память с гранулярностью 64 Кб. Это означает, что нельзя выделить памяти меньше, чем 64 Кб (**). Т.е. если вы выделяете 8 байт под объект TObject через VirtualAlloc, то VirtualAlloc заберёт из вашего адресного пространства целых 64 Кб вместо 8 байт. И эти 64 Кб нельзя будет использовать (для выделения ещё одного блока памяти через VirtualAlloc), пока вы не освободите созданный объект TObject и не вернёте занятую память.

Т.е. если вы создаёте 100 объектов по, скажем, 20 байт (очень простые объекты, вы их наследовали от TObject), то вместо двух килобайт (20 байт * 100 = 2 Кб) вы занимаете уже почти 6.5 Мб (64 * 100 = 6'400 Кб) - на несколько порядков больше!

Именно эту проблему и решает менеджер памяти: он выделяет один большой кусок памяти через VirtualAlloc (например, 1 Мб), а затем размещает в этом блоке несколько маленьких выделений памяти, которые поступают от функции GetMem. Таким образом, в одном блоке размером 1 Мб менеджер памяти сможет разместить около 50'000 объектов размером 20 байт.

Далее по тексту я буду говорить занятая/свободная память (без кавычек), имея в виду по настоящему занятую (свободную) память, которая выделяется и освобождается через функции семейства VirtualAlloc/VirtualFree.
Я буду говорить "занятая"/"свободная" память (в кавычках), имея в виду память, которая выделена (через функцию VirtualAlloc), но логически помечена как занятая или свободная через функции GetMem/FreeMem.


В чём состоит проблема с менеджером памяти?

Допустим вы выделили память для объекта с помощью функции VirtualAlloc, поработали с объектом, а потом освободили его (через функцию VirtualFree). Если теперь по ошибке вы попытаетесь что-то сделать с (уже освобождённым) объектом - вы получите исключение Access Violation, потому что вы обратитесь к недоступной памяти (памяти, которая не была выделена). Например:
var
  P: Pointer;
begin
  P := VirtualAlloc({...}); // выделяем память для P
  P^ := {...};              // что-то делаем (работаем) с P
  VirtualFree(P);           // закончили работу, освободили память
  
  // ОШИБКА: обращаемся к уже освобождённой памяти
  P^ := {...};              // эта строка ВСЕГДА возбудит исключение Access Violation
end;
Это - хорошо. Ошибки при работе с памятью сразу же видно. Мы обнаруживаем их немедленно в месте их возникновения.

Изменится ли что-то, если мы не используем функцию VirtualAlloc, а используем менеджер памяти (функцию GetMem)? Да, конечно изменится:
  GetMem(P, {...}); // на самом деле: не выделяет память
  FreeMem(P);       // на самом деле: не освобождает память
Ведь функции менеджера памяти не выделяют и не освобождают память по-настоящему. Вместо этого они возвращают указатель на середину какого-то (уже выделенного) блока памяти и запоминают этот участок как "занятый", а в конце просто помечают его как "свободный". Иными словами:
var
  P: Pointer;
begin
  GetMem(P, {...}); // "выделяем" память для P
  P^ := {...};      // что-то делаем (работаем) с P
  FreeMem(P);       // закончили работу, "освободили" память

  // ОШИБКА: обращаемся к уже освобождённой памяти
  P^ := {...};      // эта строка выполняется успешно, поскольку эта память всё ещё выделена
end;
Поскольку память в действительности не освобождается (а лишь логически помечается как "свободная"), то мы можем успешно обратиться к ней и после логического освобождения объекта. Это и есть проблема при использовании менеджера памяти: ранее очевидная ошибка при работе с памятью теперь скрыта.


Как ищут ошибки памяти в Delphi приложениях?

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


Запись после удаления

Если мы рассмотрим пример выше, то проблема заключается в том, что блок памяти, который логически "свободный", меняет своё содержимое:
  FreeMem(P);       // блок памяти теперь "свободен"
  P^ := {...};      // "свободный" блок памяти изменился
Как менеджер памяти может обнаружить эту проблему? Ну, например, менеджер памяти может заполнить "свободный" блок каким-либо известным шаблоном (например, байтом $CC). Тогда, если менеджер памяти будет выделять новый блок памяти и увидит, что "свободная" память изменилась (где-то не содержит байтов $CC), то налицо ошибка записи в "освобождённую" память. Например:
var
  P: Pointer;
begin
  GetMem(P, {...});
  P^ := {...};     
  FreeMem(P);      

  // ОШИБКА: обращаемся к уже освобождённой памяти
  P^ := {...};      // эта строка выполняется успешно
  
  GetMem(B, {...}); // возбудит ошибку "память была испорчена",
  // поскольку менеджер памяти увидит, что память, ранее занимаемая P,
  // была изменена
end;
Как вы можете видеть, хотя проблема при работе с памятью может быть обнаружена, это случится не в момент, когда произошла запись, а намного позднее. Что сильно осложняет диагностику памяти. Это - проблема от использования отладочного менеджера памяти.


Переполнение буфера

Другой частой ошибкой является запись за пределы выделенной памяти. Например:
var
  Buffer: PInteger;
begin
  GetMem(Buffer, Count * SizeOf(Integer)); // "выделили" память для Count штук Integer
  for X := 0 to Count do                   // ошибка: должно быть Count - 1
  begin
    Buffer^ := 0;                          // запишет данные вне выделенного буфера на последнем шаге
    Inc(Buffer);
  end;
Здесь мы выделяем память для Count элементов, но при этом обнуляем Count + 1 элемент (на один больше, чем это необходимо). Это означает, что мы будем записывать в "свободную" память, которая располагается за "выделенным" нам блоком памяти. Проблема заключается в том, что иногда эта память может быть не "свободна", а быть "занята" другими данными. Таким образом, мы испортим какой-то другой блок памяти, который располагается сразу за нашим блоком памяти. Эта ошибка называется переполнением буфера.

Как отладочный менеджер памяти может помочь нам с этой проблемой? Например, он может "выделять" больше памяти, чем вы запросили. Скажем, вы просите блок памяти в 20 байт, а менеджер памяти "выделяет" 28 байт. 4 байта с каждой стороны он резервирует для шаблона (например, тех же байт $CC). И если при "освобождении" памяти в этой зарезервированной области будет что-то иное - значит, память была перезаписана. Например:
var
  Buffer: PInteger;
begin
  GetMem(Buffer, Count * SizeOf(Integer)); // "выделили" память для Count штук Integer
  for X := 0 to Count do                   // ошибка: должно быть Count - 1
  begin
    Buffer^ := 0;                          // здесь: переполнение буфера при X = Count                         
    Inc(Buffer);
  end;
  { ... ещё что-то делаем с Buffer }
  FreeMem(Buffer);                         // возбудит ошибку "переполнение буфера", 
  // поскольку менеджер памяти увидит, что память сразу за нашим блоком была изменена
И снова мы видим всё ту же самую проблему: ошибка будет обнаружена намного позже её возникновения.


Вызов методов удалённого объекта

В память можно не только писать. Часто память - это объект. Что будет, если мы попробуем вызвать метод уже "удалённого" объекта?

Ну, метод может быть обычным (статическим) или динамическим (виртуальным).

Вызов статического метода удалённого объекта

Статический метод по сути ничем не отличается от обычной функции: у него фиксированный адрес. Следовательно, вызов статического метода не зависит от данных объекта. Поэтому "выделен" объект или "освобождён" - не имеет никакого значения. Например:
var
  L: TList;
begin
  L := TList.Create;  // "создали" объект
  L.Free;             // "освободили" объект
  
  I := L.IndexOf(P);  // выполнится успешно и вернёт корректный результат
Поскольку при вызове метода IndexOf не используются данные объекта L, то менеджер памяти никак не может повлиять на вызов метода. Даже если менеджер памяти очистит объект, метод всё равно будет вызван. Выполнение кода продолжится дальше.

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

Заметим, что мы говорим только лишь о вызове метода. Как этот метод будет выполняться с уже удалённым объектом - отдельный вопрос. Мы рассмотрим его ниже.

Вызов виртуального метода удалённого объекта

Чтобы вызвать виртуальный метод, код должен прочитать адрес метода из данных объекта. Если объект "освобождён", и его данные после "освобождения" не изменялись, то никто не помешает вызвать виртуальный метод. Например:
var
  S: TStringList;
begin
  S := TString.Create; // "создали" объект
  // Работаем с S
  S.Free;              // "освободили" объект
  
  I := S.Count;        // выполнится успешно и вернёт корректный результат
Здесь адрес виртуального метода GetCount записан в объекте S. При "удалении" объекта S занимаемая им память продолжает оставаться доступной, поэтому вызов метода происходит успешно.

Как отладочный менеджер памяти может поймать эту проблему? Ну, например он может записать другой адрес виртуального метода в память, ранее занимаемую объектом S. И если кто-то попытается вызвать виртуальный метод объекта после его удаления, он вызовет отладочную функцию менеджера памяти, которая возбудит ошибку:
var
  S: TStringList;
begin
  S := TString.Create; // "создали" объект
  // Работаем с S
  S.Free;              // "освободили" объект
  
  I := S.Count;        // возбудит ошибку "вызов метода удалённого объекта"
Заметим, что в этой ситуации отладочный менеджер памяти оказался способен отловить ошибку немедленно в момент её возникновения, а не позже, как это было в других примерах выше.


Чтение после удаления

В память можно не только писать. Из неё ещё можно и читать. Например:
var
  L: TList;
begin
  L := TList.Create;  // "создали" объект
  L.Free;             // "освободили" объект
  
  I := L.Count;       // выполнится успешно и вернёт корректный результат
Если бы память объекта была освобождена (через функцию VirtualFree), то любая попытка записать и прочитать данные (поля) объекта немедленно возбудила бы ошибку Access Violation. Но поскольку объект лишь "освобождается" (через функцию FreeMem), то его данные всё ещё доступны, поэтому попытки прочитать и записать (изменить) данные (поля) объекта будут успешны.

Как отладочный менеджер памяти может помочь с этой проблемой? Ну, он не очень-то может помочь. Да, он может заполнить "освобождённый" объект каким-то отладочным шаблоном (байтом $CC, например), но это никак не помешает коду читать и записывать данные объекта:
var
  L: TList;
begin
  L := TList.Create;  // "создали" объект
  L.Free;             // "освободили" объект
  
  I := L.Count;       // выполнится успешно, но вернёт неверный результат ($CCCC'CCCC или -858'993'460)
Единственный шанс здесь - надеяться, что прочитанные данные будут ошибочны настолько, что вызовут в итоге какое-то иное исключение. Обычно это происходит, когда из объекта читается адрес чего либо.

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


Повторно используемая память

Посмотрим на такой пример:
var
  S1, S2: TStringList;
begin
  S1 := TStringList.Create; // "создали" объект
  // Работаем с S1
  S1.Free;                  // "освободили" объект

  S2 := TStringList.Create; // "создали" объект

  I := S1.Count;            // логически - ошибка, но всегда будет выполнена успешно, поскольку S1 = S2
Здесь мы "удаляем" объект, но тут же "создаём" точно такой же. При этом адреса обоих объектов будут одинаковы, т.е. S1 = S2, поскольку новый объект был размещён на месте старого объекта. После чего мы обращаемся к первому "удалённому" объекту. Технически получается, что S1.Count будет то же самое, что и S2.Count. Поэтому, хотя это и логическая ошибка в коде, но такой код будет выполняться без ошибок, работая с S2 вместо S1.

Отладочный менеджер памяти не может обнаружить данную проблему по очевидным причинам.


Что такое отладочный менеджер VirtualMM

Как мы можем избавиться от описанных выше недостатков отладочных менеджеров памяти Delphi? Для этого нужно понять источник проблем отладочных менеджеров памяти. Проблемы в них возникают из-за программного управления памятью. Это означает, что все проблемы с памятью ищутся с помощью пользовательского кода.

В отличие от менеджеров памяти Delphi, система (VirtualAlloc) использует аппаратное управление памятью. Это означает, что проблемы с памятью обнаруживаются самим CPU.

Таким образом, один из способов устранить недостатки отладочных менеджеров памяти Delphi - перейти с программного управления памятью на аппаратное. Именно это и делает отладочный менеджер памяти VirtualMM: грубо говоря, он заменяет функцию GetMem на VirtualAlloc. В частности, его название образовано от слов "Virtual" - что ссылается на функцию VirtualAlloc, и "MM" - что означает Memory Manager.

VirualMM представляет собой Pascal-код. Он поддерживает IDE начиная с Delphi 6 и заканчивая последней доступной (RAD Studio 13 Florence на момент написания этой заметки). Более ранние версии Delphi (5 и ниже) не поддерживаются из-за ограничений компилятора.

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

Вспомним, что необходимость в менеджерах памяти изначально появились в Delphi из-за системной гранулярности выделения памяти в 64 Кб. Как VirtualMM решает эту проблему?
  1. Если ваше приложение 64-битное, то проблемы почти нет: адресное пространство для 64 бит составляет 8 Тб, что позволяет выделить не более 134'217'728 блоков памяти (размером от 1 байта до 64 Кб).
  2. Если ваше приложение 32-битное, то тут всё намного сложнее: адресное пространство для 32 бит составляет 2 Гб (4 Гб макс), что позволяет выделить всего 32'768 блоков памяти максимум (размером от 1 байта до 64 Кб) - что ужасно мало.
Поэтому в менеджере памяти VirtualMM есть понятие "маленьких блоков". Дело в том, что 64 Кб - это гранулярность выделения памяти. Иными словами, нельзя выделить память, начинающуюся на адрес не кратный 64 Кб. Т.е. можно выделить память, начинающуюся на 65'536 (64к) и 131'072 (128к), но нельзя - на любое другое значение между ними - например, 70'000. Однако в рамках этих 64 Кб память может быть выделена или освобождена с гранулярностью в 4 Кб (****). Иными словами, мы можем выделить блок памяти в 64 Кб, а затем разбить его на 16 частей по 4 Кб. Таким образом, в адресном пространстве в 2 Гб мы сможем выделить уже 524'288 блоков памяти (размером от 1 байта до 4 Кб). Конечно, это не так хорошо, как 134'217'728 блоков памяти в 64-битном приложении, но это уже достаточно для практической работы 32-битных приложений - при условии, что они не будут выделять слишком много памяти.

Таким образом, если запрашиваемый размер блока меньше размера страницы (4 Кб), то VirtualMM отнесёт его к "маленьким блокам". Эти блоки будут сгруппированы вместе в одном пуле.

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

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


Где скачать VirtualMM?

Отладочный менеджер памяти VirtualMM входит в состав EurekaLog. Вы можете найти его в подпапке \Extras в папке с установленной EurekaLog. Если у вас нет EurekaLog, то его можно также скачать отдельно с сайта EurekaLog.

Нужно сказать, что концепции менеджера памяти VirtualMM были реализованы в другом отладчном менеджере памяти: SafeMM - представленном Марком Эддингтоном на конференции DelphiLive. SafeMM был представлен как часть материалов конференции DelphiLive, поэтому скачать напрямую его было невозможно. Сейчас материалы конференции недоступны, но его исходный код был выложен на Code Central. Позднее исходный код был адаптирован к более новым версиям Delphi (на тот момент). Тем не менее, SafeMM был написан как "proof of concept", не поддерживался и не развивался. Поэтому если вы ищете где скачать SafeMM - вы можете скачать VirtualMM вместо него. У VirtualMM больше возможностей.


Как установить VirtualMM?

VirtualMM не имеет специального установщика и распространяется:
  1. Либо вместе с EurekaLog. И тогда "установка VirtualMM" заключается в "установке EurekaLog". После установки VirtualMM можно будет найти в подпапке \Extras в папке с установленной EurekaLog. Эта папка уже будет указана в путях поиска установленных IDE, ничего больше делать не потребуется.
  2. Либо как ZIP-архив с файлами исходного кода Delphi. Чтобы "установить" VirtualMM в таком виде, вы просто распаковываете архив в любую удобную вам папку и указываете эту папку в путях поиска проекта, в котором вы хотите использовать VirtualMM:
    Щёлкните по картинке для увеличения


Как добавить (подключить) VirtualMM в проект?

Просто напишите VirtualMM первым модулем в .dpr файле вашего проекта, например:
program Project1;

uses
  VirtualMM, // - добавлено
  Vcl.Forms,
  Unit1 in 'Unit1.pas' {Form1};

{$R *.res}

begin
  Application.Initialize;
  Application.MainFormOnTaskbar := True;
  Application.CreateForm(TForm1, Form1);
  Application.Run;
end.
Очень важно указывать VirtualMM именно первым в списке uses. Если указать его не первым, то сначала будет инициализироваться какой-то другой код, который наверняка начнёт выделять память через штатный менеджер памяти. Поэтому когда очередь инициализации дойдёт до VirtualMM, он не сможет установить себя в качестве менеджера памяти, поскольку память уже выделяется через штатный менеджер памяти.

Если при компиляции проекта у вас будет ошибка типа:
[dcc32 Fatal Error] Project1.dpr(4): F2613 Unit 'VirtualMM' not found
то это означает, что вы не добавили папку с исходным кодом VirtualMM в пути поиска вашего проекта (или IDE) - см. раздел "Как установить VirtualMM?" выше.

При сборке проекта в окно сообщений будет выведено:
[DCC Warning] VirtualMMOptions.inc(51): W1054 WARNING: VirtualMM is ON, do not use this build on production
Это - дополнительное предупреждение-напоминание о том, что вы собрали программу с VirtualMM. Напоминание нужно на тот случай, чтобы вы по ошибке не отправили этот вариант программы в релиз. Иными словами, это - нормально. Так и должно быть.


Как настроить VirtualMM?

VirtualMM состоит из трёх файлов:
  1. VirtualMM.pas - основной исходный код. Вы подключаете этот модуль в проект (см. выше). Этот файл не нужно редактировать.
  2. VirtualMMDefs.inc - содержит условные символы, необходимые для правильной компиляции во всех поддерживаемых IDE (начиная с Delphi 6 и заканчивая последней доступной IDE, которой на момент написания заметки является RAD Studio 13 Florence). Этот файл не нужно редактировать.
  3. VirtualMMOptions.inc - содержит пользовательские опции, позволяющие менять поведение менеджера памяти. Именно этот файл нужно редактировать для настройки VirtualMM.
Опция включается, если вы снимаете комментарий с её строки, например:
{$DEFINE USE_SMALL_BLOCKS}
Опция выключается, если вы комментируете её строку, например:
// {$DEFINE USE_SMALL_BLOCKS}

В частности, VirtualMM поддерживает следующие опции:
  1. USE_SMALL_BLOCKS (включена по умолчанию): включает поддержку "маленьких блоков", как мы обсуждали выше. Включение этой опции позволяет вам экономнее расходовать память, что необходимо для работы в 32-битных приложениях. 64-битные приложения имеют огромное адресное пространство, поэтому проблема исчерпания блоков в этом случае не стоит так остро. Нужно заметить, что "маленькие блоки" - это компромисс, костыль, если угодно. Не все виды проверок могут быть реализованы с "маленькими блоками", как мы это обсуждали выше. Рекомендации по применению: включайте опцию для 32-битных приложений, выключайте для 64-битных. Включайте, если 64-битное приложение начинает вылетать с нехваткой памяти.
  2. PROTECT_OVERFLOW (включена по умолчанию): указывает VirtualMM, чтобы он защищал от переполнения буфера. В этом случае вся выделяемая память будет выровнена таким образом, чтобы после конца выделенного блока начиналась бы недоступная память. Таким образом, запись за пределы выделенного буфера немедленно выбросит исключение Access Violation. Выключайте эту опцию только для специальных случаев (см. ниже). Только одна из опций PROTECT_* может быть включена одновременно.
  3. PROTECT_UNDERFLOW: указывает VirtualMM, чтобы он защищал от переполнения буфера с другой стороны. В этом случае вся выделяемая память будет выровнена таким образом, чтобы непосредственно перед началом выделенного блока начиналась бы недоступная память. Таким образом, запись за пределы выделенного буфера немедленно выбросит исключение Access Violation. Включайте эту опцию только чтобы найти проблему с записью перед блоком. Такие проблемы достаточно редки, обычно происходит наоборот. Только одна из опций PROTECT_* может быть включена одновременно.
  4. PROTECT_RANDOM: указывает VirtualMM, чтобы он случайно включал PROTECT_OVERFLOW или PROTECT_UNDERFLOW (при каждом выделении памяти). Требуется крайне редко - только если у вас несколько проблем с переполнением буфера (с обеих сторон блоков памяти). Вероятно, лучшим решением будет поиск проблем по очереди: сначала PROTECT_UNDERFLOW, затем PROTECT_OVERFLOW (или наоборот). Только одна из опций PROTECT_* может быть включена одновременно.
  5. ALLOCATE_TOP_DOWN (включена по умолчанию): указывает VirtualMM выделять память сверху вниз. Включение этой опции немного замедляет работу, но позволяет быстрее отловить ошибки преобразования Integer-Pointer. Обычно выключать эту опцию не следует. Заметим, что 32-битное приложение должно быть помечено как high-address awared (флаг IMAGE_FILE_LARGE_ADDRESS_AWARE должен быть установлен), чтобы эта опция приносила пользу.
  6. CATCH_USE_AFTER_FREE: это специальная опция, которая позволяет находить проблему доступа к освобождённой памяти в условиях частого выделения памяти. Мы подробнее обсудим её ниже. Включение этой опции указывает VirtualMM никогда не освобождать память (*). Как вы можете себе представить, включение этой опции приведёт к катастрофическому росту занимаемой памяти. Особенно если вы часто выделяете и освобождаете память. По этой причине эта опция бесполезна в 32-битных приложениях: 32-битное приложение вылетит с ошибкой "не хватает памяти" раньше, чем оно дойдёт до проблемы. Включайте эту опцию только в 64-битных приложениях и только для поиска проблем доступа к освобождённой памяти, которые иначе не локализуются.
  7. NeverUninstall: указывает VirtualMM не удалять себя при выходе из приложения. Эта опция нужна только для совместимости с некоторыми старыми IDE, у которых есть баг: попытка освободить память уже после "завершения" приложения.
  8. VirtualMMAlign (16 по умолчанию): задаёт выравнивание всей выделяемой памяти. Иными словами, гранулярность выделения. Это значение должно быть кратно 8 байтам для 32-битных приложений и 16 байтам для 64-битных приложений: т.е. 8 (только 32-бит), 16, 24 (только 32-бит), 32, 40 (только 32-бит), 48 и т.д. Большие значения приведут к повышенному расходу памяти, поскольку часть памяти будет потрачена впустую на заполнение промежутков между блоками. Кроме того, большие значения также сильно ухудшат поиск переполнения буфера (опция PROTECT_OVERFLOW) - см. обсуждение ниже. Мы рекомендуем установить минимально возможное значение, с которым сможет работать ваше приложение. Для этой опции также есть два специальных значения: 1 для полного отключения выравнивания (формально запрещено в Delphi, приложение при этом может сломаться) и 0 для использования динамического выравнивания, получаемого от функции System.GetMinimumBlockAlignment.


Какие проблемы решает VirtualMM?

Рассмотрим как VirtualMM может помочь нам диагностировать проблемы, про которые мы говорили выше.

Обращение к уже освобождённой памяти

У нас были такие примеры:
var
  P: Pointer;
begin
  P := VirtualAlloc({...}); // выделяем память для P
  P^ := {...};              // что-то делаем (работаем) с P
  VirtualFree(P);           // закончили работу, освободили память
  
  // ОШИБКА: обращаемся к уже освобождённой памяти
  P^ := {...};              // эта строка ВСЕГДА возбудит исключение Access Violation с VirtualMM
end;
и
var
  L: TList;
begin
  L := TList.Create;  // "создали" объект
  L.Free;             // "освободили" объект
  
  I := L.Count;       // эта строка ВСЕГДА возбудит исключение Access Violation с VirtualMM
Поскольку VirtualMM действительно освобождает память при "освобождении" памяти, то память после "удаления" оказывается недоступной, так что любая попытка обратиться к такой памяти приведёт к возбуждению исключения Access Violation - будь это запись, или (что интереснее) чтение. Заметим, что VirtualMM позволит обнаружить проблемы с памятью сразу, на месте, а не сильно позднее - как это было бы с обычным отладочным менеджером памяти. Кроме того заметим, что VirtualMM позволит отловить и попытки чтения из уже удалённой памяти - в то время как типичные отладочные менеджеры памяти обычно не могут помочь с этой проблемой.

Переполнение буфера

У нас был такой пример:
var
  Buffer: PInteger;
begin
  GetMem(Buffer, Count * SizeOf(Integer)); // "выделили" память для Count штук Integer
  for X := 0 to Count do                   // ошибка: должно быть Count - 1
  begin
    Buffer^ := 0;                          // последний шаг возбудит исключение Access Violation с VirtualMM в режиме PROTECT_OVERFLOW
    Inc(Buffer);
  end;

Если опция USE_SMALL_BLOCKS выключена

Если в VirtualMM включена опция PROTECT_OVERFLOW (или эта опция была выбрана опцией PROTECT_RANDOM), то VirtualMM разместит одну недоступную страницу сразу после блока памяти, так что попытка прочитать или записать за блоком выбросит исключение Access Violation.

Разумеется, если была включена опция PROTECT_UNDERFLOW (или эта опция была выбрана опцией PROTECT_RANDOM), то VirtualMM разместит одну недоступную страницу непосредственно перед блоком памяти. А поскольку гранулярность страниц памяти составляет 4 Кб, то конец выделенного блока, скорее всего, не попадёт ровно на границу страниц. Это значит, что в таком случае код будет выполнен успешно, без возбуждения исключения Access Violation. Исключение возникнет только если вы продолжите "шагать" дальше и дойдёте до конца текущей страницы.

Тут есть один тонкий момент. В Delphi действует соглашение, что любой менеджер памяти, будь он штатный или нештатный, обязан возвращать память, выровненную минимум на 8 байт. Можно и больше (например, 32). Но не меньше 8. Например, Delphi 7 выравнивает память на 8 байт, а RAD Studio 13 Florence - на 16 байт. А VirtualMM выравнивает на сколько вы скажете. Может на 8, может на 16, может на 32. Но по умолчанию - 16 (см. опцию VirtualMMAlign выше).

К чему это мы говорим? Очевидно, что если вы выровняете начало блока на некоторую границу, то конец этого блока придётся как попадёт. Например, если вы выравниваете на 8 байт, а выделяете блок размером 1 байт, то вы можете выделить его по адресу, ну пусть будет, 16k - 8, но тогда блок памяти будет занимать адреса с 16к - 8 до 16к - 7 (1 байт ровно) - что на 7 байт меньше, чем ближайшая граница страницы (16к). Это значит, что эти семь байт "заполнения" будут иметь те же аппаратные атрибуты защиты, что и выделенный блок памяти, т.е. будут доступны для чтения и записи. Иными словами, переполнение буфера размером до 7 байт не может быть обнаружено немедленно. А вот переполнение буфера на 8 и больше байт уже затронет следующую страницу памяти (без доступа), что и вызовет исключение Access Violation.

По этой причине нужно использовать минимально возможную гранулярность выделения памяти.

Тем не менее, VirtualMM всегда размещает защитные значения перед и после блока памяти (если там есть вышеупомянутое пустое место), так что хотя попытка прочитать сразу за блоком будет всегда успешна, но попытка записи за блоком будет обнаружена при освобождении блока памяти. Таким образом, VirtualMM поведёт себя как обычный отладочный менеджер памяти. При этом будет возбуждено исключение EAssertionFailed с сообщением ReleaseLargeBlock: Block Overflow (или "Block Underflow"). Вообще, любые Assert-исключения из VirtualMM говорят о проблеме с памятью: они указывают, кто кто-то нарушил (перезаписал) какие-то защитные или управляющие структуры (заголовки) блоков памяти, т.е. это явная ошибка записи по неверному адресу. На всякий случай ещё раз уточним, что речь сейчас идёт только о доступе в окно для выравнивания блоков памяти. Попытки доступа вне окна выравнивания будут обнаружены немедленно (аппаратно).

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

Если опция USE_SMALL_BLOCKS включена

Если в VirtualMM включена опция PROTECT_OVERFLOW (или эта опция была выбрана опцией PROTECT_RANDOM) и размер блока будет меньше размера страницы (4 Кб), то VirtualMM разместит защитное значение после блока памяти, так что хотя попытка прочитать за блоком будет всегда успешна, но попытка записи за блоком будет обнаружена при освобождении блока памяти. Таким образом, VirtualMM поведёт себя как обычный отладочный менеджер памяти. При этом будет возбуждено исключение EAssertionFailed с сообщением ReleaseSmallBlock: Block Overflow (или "Block Underflow").

Если же размер блока будет больше размера страницы (4 Кб), то VirtualMM поведёт себя как и в случае "Если опция USE_SMALL_BLOCKS выключена" выше, т.е. разместит защитные страницы и обнаружит чтение/запись за блоком немедленно (с указанной выше поправкой на окно для выравнивание блока памяти).

Вызов статического метода удалённого объекта

Поскольку вызов статического метода никак не зависит от данных объекта, VirtualMM никак не сможет помочь с этим случаем. Однако, поскольку вызываемый статический метод наверняка что-то делает с объектом (иначе он был бы функцией, а не методом), то первая же попытка что-то прочитать или записать в объект выбросит исключение Access Violation. Например:
var
  L: TList;
begin
  L := TList.Create;  // "создали" объект
  L.Free;             // "освободили" объект
  
  I := L.IndexOf(P);  // всегда возбудит Access Violation внутри метода с VirtualMM
Таким образом, VirtualMM поможет обнаружить проблему максимально близко к место её возникновения.

Вызов динамического метода удалённого объекта

С вызовом виртуальных методов всё проще: память объекта не будет доступна после "удаления" объекта, поэтому вызов виртуального метода всегда возбудит исключение Access Violation при попытке прочитать из объекта адрес метода. Например:
var
  S: TStringList;
begin
  S := TStringList.Create; // "создали" объект
  // Работаем с S
  S.Free;                  // "освободили" объект
  
  I := S.Count;            // всегда возбудит Access Violation при вызова виртуального метода с VirtualMM

Повторно используемая память

У нас был такой код:
var
  S1, S2: TStringList;
begin
  S1 := TStringList.Create; // "создали" объект
  // Работаем с S1
  S1.Free;                  // "освободили" объект

  S2 := TStringList.Create; // "создали" объект

  I := S1.Count;            // логически - ошибка, но всегд будет выполнена успешно, поскольку S1 = S2
Может ли VirtualMM что-то сделать в этом случае? Да, может. Но только в 64-битных приложениях.

Для этого вам нужно включить опцию CATCH_USE_AFTER_FREE. Включение этой опции заставит VirtualMM никогда не освобождать память при "освобождении". Это означает, что если мы тут же "создадим" второй объект (S2 в примере выше), он никогда не будет размещён по тому же адресу, что и первый объект (S1 в примере выше), поскольку память для первого объекта будет навечно занятой. Иными словами, S1 больше не равно S2 и, следовательно, вызов S1.Count выбросит исключение Access Violation, поскольку память S1 будет недоступной.

Как вы можете себе представить: никогда не освобождать память - это очень агрессивная стратегия, которая может сработать только если у вас есть просто ОГРОМНОЕ количество свободной памяти. В частности, включение опции CATCH_USE_AFTER_FREE в 32-битных приложениях бессмысленно, поскольку 32-битное приложение вылетит практически сразу же с ошибкой нехваткой памяти, не дойдя до кода с проблемой. Но даже в 64-битных приложениях имеет смысл постараться сделать так, чтобы ошибка памяти, которую вы пытаетесь поймать, происходила бы как можно раньше.
var
  S1, S2: TStringList;
begin
  S1 := TStringList.Create; // "создали" объект
  // Работаем с S1
  S1.Free;                  // "освободили" объект

  S2 := TStringList.Create; // "создали" объект

  I := S1.Count;            // всегда возбудит Access Violation при вызова виртуального метода с VirtualMM при включённой опции CATCH_USE_AFTER_FREE
Рекомендация по опции: держите её выключенной. Если вы можете найти ошибку без включения этой опции - так и поступайте. Включайте эту опцию только в 64-битных приложениях и только если не можете найти ошибку иначе.


Когда нужно использовать VirtualMM?

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

Реже, но также часто это будут ситуации с записью в "освобождённую" память. Основной причиной для выбора VirtualMM в этих случаях будет слишком позднее (после возникновения) нахождение проблемы отладочными менеджерами памяти.


Когда НЕ нужно использовать VirtualMM?

Поскольку VirtualMM - достаточно особенный отладочный менеджер памяти, его никогда не нужно использовать в релизе по следующим причинам:
  1. Медленная работа из-за необходимости делать вызов в ядро на каждое "выделение" и "освобождение" памяти;
  2. Большой расход памяти:
    • Округление вверх до размера страницы (4 Кб);
    • Размещение защитных страниц (переполнение буфера);
    • Память только растёт, но не уменьшается при включении опции CATCH_USE_AFTER_FREE.
Именно поэтому при сборке проекта с VirtualMM в окно сообщений будет выведено:
[DCC Warning] VirtualMMOptions.inc(51): W1054 WARNING: VirtualMM is ON, do not use this build on production
Это сделано специально, чтобы вы случайно не скомпилировали свою программу для релиза с VirtualMM.


Использование VirtualMM совместно с EurekaLog

Хотя VirtualMM входит в состав EurekaLog, он никак не связан с EurekaLog и не имеет с ней общего исходного кода. Это самостоятельный (изолированный) продукт, который также можно скачать отдельно.

В EurekaLog есть фильтр (надстройка) для менеджера памяти, которая выполняет некоторые проверки. Эти проверки во многом дублируются VirtualMM, поэтому хотя их можно использовать совместно, но большого смысла в этом нет. Ведь суть VirtualMM в том, чтобы размещать аппаратную защиту по границам блоков памяти, а включая проверки памяти в EurekaLog, вы отодвинете эти границы для размещения в них отладочных данных EurekaLog - что, как несложно догадаться, ухудшит способность VirtualMM сообщать о проблеме с памятью немедленно (в момент её возникновения). Поскольку такая способность и является основной причиной, почему вы хотели бы использовать VirtualMM, то этой способности не следует мешать. Т.е. если вы используете VirtualMM в проекте с EurekaLog, то все проверки памяти в EurekaLog нужно выключать (опцию "Enable extended memory manager" нужно выключить, а опцию "When memory is released" переключить в "Do nothing").

С другой стороны заметим, что VirtualMM не является заменой проверкам памяти в EurekaLog:
  • Во-первых, в EurekaLog есть поиск утечек памяти - чего нет в VirtualMM;
  • Во-вторых, EurekaLog может сообщать о проблемах с памятью более доступным способом (например, для ошибок повторного освобождения памяти будет показано два стека вызова);
  • В-третьих, EurekaLog заточена для отчётов с машин пользователей (релиза), а VirtualMM можно использовать лишь для разработки.

Заметим, что ошибки при работе с памятью VirtualMM никогда не сообщает явно как "исключение типа ошибка такая-то": это всегда будет либо EAccessViolation либо EAssertionFailed - в отличие от EurekaLog, которая всегда старается сообщить точную информацию, например, EUseAfterFree, EBufferOverflowError или EDoubleFreeError. Собственно, суть использования VirtualMM как раз таки состоит в аппаратной защите (т.е. исключении EAccessViolation). При этом в некоторых случаях исключения EAccessViolation/EAssertionFailed будут возбуждаться внутри кода самого VirtualMM (т.е. менеджера памяти). Например, когда ему передают неверный или испорченный блок памяти. Проблема тут в том, что отладчик Delphi не всегда способен правильно построить стек вызовов, если вы находитесь внутри функции менеджера памяти. Например, если VirtualMM обнаруживает переполнение буфера при освобождении блока памяти, он выбросит исключение EAssertionFailed с 'Block Overflow', но IDE может показать неполный или обрезанный стек вызовов, например:
  • KERNELBASE.RaiseException
  • @Assert
  • VirtualMM.ReleaseLargeBlock или VirtualMM.ReleaseSmallBlock
  • VirtualMM.VirtualFreeMem
  • @FreeMem
  • и здесь стек вызова или заканчивается или содержит функции дальше по стеку, а та, которая непосредственно вызывает FreeMem - пропущена.
Надо ли говорить, что это сильно осложняет поиск проблемы?

Да, если вы хорошо владеете отладчиком, вы можете построить стек вызова вручную. Но это умеют далеко не все, и это достаточно сложная операция. Или вы можете воспользоваться EurekaLog: EAccessViolation/EAssertionFailed - это обычные исключения, которые будут пойманы EurekaLog и, если вы нигде не глушите их кодом типа:
try
  FreeMem(P);
except
  // Ничего не делать
end;
то они будут обработаны EurekaLog, которая сможет показать отчёт об исключении с полным стеком (если вы не меняли способ трассировки стека на какой-либо frame-метод).

Таким образом, рекомендованный порядок работы:
  1. Стадия DEBUG/Development:
    1. Вы выключаете все проверки памяти в EurekaLog (или отключаете EurekaLog вообще, хотя это не рекомендуется по указанной выше причине);
    2. Вы добавляете в проект VirtualMM и настраиваете его;
    3. Вы ищете и исправляете ошибки с памятью в приложении (например, стрессовое/нагрузочное тестирование).
  2. Стадия RELEASE/Production:
    1. Вы удаляете из проекта VirtualMM;
    2. Вы включаете и настраиваете проверки памяти в EurekaLog;
    3. Вы проверяете работу приложения (тестовые исключения и тестовые ошибки с памятью);
    4. Вы публикуете (deploy) приложение;
    5. Вы собираете отчёты о проблемах в приложении, исправляете найденные проблемы. Если при этом находится ошибка при работе с памятью, которую не удаётся диагностировать - вы снова используете VirtualMM (при отладке).


Что делать, если приложение вылетает с нехваткой памяти при использовании VirtualMM?

Как мы уже сказали много раз: использование VirtualMM приводит к повышенному расходу памяти. Если ваше приложение возбуждает исключение EOutOfMemory, как вы можете это исправить?

Выполните эти действия по списку сверху вниз, пока ошибка не исчезнет:
  1. Переведите приложение на 64 бита. Это самый надёжный способ;
  2. Поменяйте местами код так, чтобы ошибка при работе с памятью вылезала бы как можно раньше. Уберите весь несущественный код;
  3. Отключите опцию CATCH_USE_AFTER_FREE;
  4. Включите опцию USE_SMALL_BLOCKS;
  5. Пересмотрите логику работы приложения. Выделяйте меньше блоков памяти. Например, вместо однотипных небольших блоков используйте динамический массив.


Примечания

(*) Говоря про память, мы сильно упрощаем вещи. Под словом "память" мы понимаем адресное пространство приложения. При этом для простоты обсуждения мы не делаем различий между зарезервированной (RESERVE) и выделенной (COMMIT) памятью. Например: слова "выделили память" может означать как "зарезервировали память", так и "выделили память" - в зависимости от контекста.
(**) 64 Кб - это значение по умолчанию на многих системах. Однако это значение может быть и иным в вашем конкретном случае. Оно получается из поля dwAllocationGranularity структуры SYSTEM_INFO. Везде в тексте говорится про 64 Кб, но вы должны понимать, что это не константа.
(***) Поскольку VirtualMM тоже является отладочным менеджером памяти, здесь и далее под "отладочные менеджеры памяти" мы будем иметь в виду только "типичные" или "все прочие" менеджеры памяти, не включая VirtualMM: чтобы не писать каждый раз исключение "типичные отладочные менеджеры памяти кроме VirtualMM" - будем писать просто "отладочные менеджеры памяти" имея в виду это исключение.
(****) 4 Кб - это значение по умолчанию на многих системах. Однако это значение может быть и иным в вашем конкретном случае. Оно получается из поля dwPageSize структуры SYSTEM_INFO. Везде в тексте говорится про 4 Кб, но вы должны понимать, что это не константа.

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

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

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

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

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

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

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

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