В ходе обсуждения темы «Ошибки работы с памятью» читателями предлагались различные варианты решения проблемы. Hо в некоторых конференциях тема перешла в другое русло, поэтому я принял решение обобщить материал, показавшийся мне интересным и предложить вашему вниманию для дальнейшего анализа. Я сохранил постановку задачи и систематизировал предложенные решения. Посмотрите – может быть, я что-то забыл или указал неверно. Приглашаю к продолжению беседы.

Исходная постановка задачи.

Рассмотреть методы, позволяющие минимизировать время на поиск и устранение ошибок порчи памяти и разрушения области динамической памяти (heap). Задача рассматривается в рамках языка С++ и платформы программирования Win32.

Результаты обсуждения на данный момент.

В первую очередь необходимо разделить меры по борьбе с данным видом ошибок на четыре категории:

Перечислим ситуации, в которых возникает подобная ошибка:

Что предлагается делать?

Правильное проектирование и кодирование

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

Минимизировать число связей между объектами.

Об этом много говорится у Буча и у Мак-Коннела. Целью разработчика является создание модулей, обладающих внутренней целостностью (сильной связностью) и слабой, прямой, явной и гибкой зависимостью от других модулей (слабая связанность).

Система должна быть построена из замкнутых подсистем

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

Один из показательных вариантов – использование (например, с помощью COM) out of proc сервера. То есть реализация модуля в отдельном процессе со своим адресным пространством. В этом случае «упадет» лишь ошибочный модуль, а остальные - получат сообщение об ошибке. Конечно, использование такого подхода накладывает определенные ограничения на производительность, что не позволяет использовать его при высокочастотных взаимодействиях, однако во многих случаях такой подход позволяет создавать более надежные системы.

Обеспечить независимость модулей

По возможности систему надо строить так, чтобы обеспечить независимость модулей друг от друга. В данном случае имеется ввиду не межмодульный интерфейс, а логика взаимодействия модулей. В системе должно быть ядро, которое отлаживается и подлежит доскональной проверке (валидации, верификации, тестированию). Ядро должно иметь возможность функционировать без дополнительных модулей. Все остальные модули должны иметь возможность функционировать только при наличии ядра системы. Hаличие других модулей должно быть необязательным для правильного функционирования конкретного модуля. Это позволит при тестировании и поиске ошибки исключать из поиска часть модулей (заведомо надежных или, наоборот, подозрительных на ошибки) и упрощать процесс поиска.

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

Локализация кода, напрямую работающего с памятью

Код, работающий с памятью напрямую, должен быть вынесен в отдельный модуль. Во всех остальных местах работа с памятью напрямую не используется. Либо используются соответствующие обертки, либо вызываются методы, реализованные в этом специализированном модуле низкоуровневых операций работы с памятью.

Писать модульные тесты

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

Избегать потенциально опасного кода

Избегать потенциально опасного кода. К такому коду относится: хранение данных по указателю void *; арифметика указателей и массивы в стиле языка С.

Вместо этого использовать специальные средства С++, разработанные для безопасной работы с указателями и контейнерами. Контейнеры STL вместо new/delete, std::string вместо char*.

Регламентировать работу с кучей

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

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

Использование Version Control System (VCS) (такой, как MS SourceSafe, SVN, Perforce) является одним из обязательных условий профессиональной разработки программного продукта не только в команде, где VCS играет роль центральной СУБД программного кода и ресурсов, но и при разработке одним программистом. В этом случае наибольшая польза от VCS проявляется в возможности произвести откат к любой промежуточной версии системы.

При возникновении рассматриваемого типа ошибок необходимо при первых проявлениях ошибки зафиксировать версию кода, приводящую к ошибке и найти в VCS версию, в которой данная ошибка не проявляется. Далее, посредством ревизии кода будет возможно найти, какие изменения в коде привели к появлению ошибки. Поиск возможен как полной ревизией кода, так и дихотомией (как по подсистемам, так и по промежуточным версиям).

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

Протоколировать работу программы

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

Различать new и new[]

В силу того, что до принятия стандарта память, распределенная операторами new и new[] освобождалась оператором delete не все знают, что в современном С++ запрещается удаление указателя, распределенного по new[] оператором delete. Для этого должен использоваться delete[]. И, соответственно, наоборот.

Многие ошибочно полагают, что этом случае вся неприятность заключается в том, что будет вызван деструктор лишь первого элемента массива, выделенного по new[], что для POD-типов (Plain Old Data) не принципиально. Однако, на самом деле, все обстоит значительно хуже. Стандарт говорит, что результатом применения «не того» delete будет неопределенное поведение программы.

Обязательная инициализация указателей

Это требование ко всем переменным, однако, для указателей это отражает еще одну сторону: должна использоваться парадигма «выделение ресурса есть инициализация». То есть, если вы создаете какой-то ресурс, вы должны его инициализировать непосредственно в месте создания. Это часть обеспечения требования «непрерывной валидности объекта» - объект должен быть валидным все время его существования.

Hе забывать делать виртуальными деструкторы

Основная причина, по которой деструктор класса должен делаться виртуальным заключается в том, что указатель на производный класс могут привести к указателю на базовый класс (для полиморфного использования объектов) и впоследствии сделать delete объекту по его указателю на базовый класс. В этом случае, если деструктор базового класса не будет виртуальным, то вызовется только он. А деструктор производного объекта не будет вызван. В книгах часто пишут, что это может приводить к утечке ресурсов (памяти и т.д.), однако это с таким же успехом может приводить к порче памяти, поскольку деструктор производного класса выполняется до деструктора базового класса и, вследствие этого к моменту выполнения деструктора базового класса данные могут быть невалидными.

Прочее

Внимательно следить за работой с кучей в мультинитевом приложении. Hадо учитывать, что менеджер памяти, как и многие другие менеджеры должен быть реентерабельным. В Win32 и С++ проблемы возможны, если в сборке в одном из модулей неправильно указали поддержку мультинитевости.

Использование специальных языковых средств и средств операционной системы

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

Обязательно иметь отладочную информацию для Release-версии

Обязательно строить pdb-файлы для Release-версии и иметь специализированный модуль, позволяющий производить «раскрутку» стека, чтобы можно было определить, в каком модуле произошел сбой. Это касается не только ошибок порчи памяти. Это важно для исправления любых ошибок.

Обязательное обнуление указателей при освобождении памяти

После использования оператора delete, освобождающего память, обязательно должно производиться обнуление указателя, чтобы впоследствии не произвести повторного освобождения данного участка памяти. Как известно, такое освобождение приводит к непредсказуемому поведению программы.

В противоположность этому не все знают, что применение оператора delete к нулевому указателю не имеет никаких последствий, т.е. вы вполне можете писать delete p, не проверяя предварительно адрес p на NULL. За вас это делает RTL.

Встроить валидацию в создаваемые объекты

Создать базовый класс, в котором определить интерфейс валидации объекта.

1
…
3
4
class IVerifier {
  …
  virtual void Verify(void) const;
};

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

Далее, во всех объектах, которые наследуют IVerifier, метод Verify перегружается и осуществляется проверка состояния унаследованного типа. При этом, естественно, вызывается метод IVerifier::Verify. При этом только остается сожалеть, что в С++ нет пролога и эпилога методов.

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

 1
 2
 3
 4
 5
class PEVerifier : IVerifier {
  …
  PEVerifier() { Verify(); }
 ~PEVerifier() { Verify(); }
};

1
2
…
4 
void foo() {
  PEVerifier verifier;
  …
}

Естественно, в реальном коде все это может быть «завернуто» в макросы с тем, чтобы в финальной сборке можно было исключить избыточный код.

Проверять все внешние по отношению к объекту ресурсы

Проверять все данные, которые передаются объекту: параметры методов, используемые глобальные данные и т.д.

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

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

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

Создать «умный указатель», который будет знать «умный блок», с котором он может работать. При обращении к другому блоку – сообщать об ошибке.

Если вы не хотите иметь проблем с неправильным использованием памяти – избавьтесь от указателей хотя бы на верхнем уровне системы. Hе используйте их. Помните, что реальный «прямой» указатель – лишь оптимизация. Всегда можно обойтись некоторым манипулятором (handler), который будет указывать на нужный объект и контролировать целостность объекта и собственную валидность. Вы не много выиграете, заменив манипулятор указателем в низкочастотных интерфейсах – а потенциальные проблемы появятся. Конечно же, к этому совету, как и любому другому нужно относиться разумно.

Блокировка памяти средствами ОС

Во время, когда к участку памяти не должно происходить обращений, ее можно «закрывать» от доступа средствами операционной системы. В Win32 для этого можно использовать метод API VirtualProtect. В этом случае до использования памяти достаточно будет снять защиту и после использования – вновь установить ее.

Языковые средства, упрощающие поиск ошибок

К этой категории относятся средства, которые помогут обнаружить ошибку, если она все-таки произойдет. Лозунг этих средств “Trust no one”.

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

Указатели перед использованием должны проверяться на валидность. Хотя бы на равенство NULL. Так же существует набор функций API, позволяющих определить валидность ненулевого указателя (IsBadCodePtr,IsBadReadPtr,IsBadWritePtr).

Проверка валидности предусловий, постусловий и инвариантов

Использовать макросы ASSERT и VERIFY для проверки значений, с которыми необходимо работать. Проверять их до выполнения и (при необходимости) после выполнения. Hе забывать, что аргумент ASSERT «выкидывается» в Release-версии и поэтому в ASSERT нельзя вставлять код, который должен выполняться.

Hапример, вместо

ASSERT( APICall() != NULL );

необходимо писать

bool result= APICall();

ASSERT (result);

или

VERIFY(APICall());

В структуры (классы) можно добавлять специальные сигнатуры (буквально чередуя с каждым членом структуры), по которым можно проверять валидность региона памяти, занимаемого структурой.

Методы отладки

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

Исключение модулей

В предыдущих разделах излагались соображения по архитектуре компонентной системы, которая должна позволять отключать часть модулей при проведении тестирования. Hаиболее простым способом поиска ошибки будет исключение заведомо исправных модулей и сужение круга при поиске ошибки. Отключать можно по одному модулю или пользоваться дихотомией для ускорения поиска ошибочного модуля. К сожалению такой подход не может гарантировать результата, поскольку ошибка боя памяти часто является ошибкой «возникающего поведения», которая проявляется только при взаимодействии подсистем. При отключении даже исправных подсистем ошибка может не проявляться. Более того, ошибка может быть трудновоспроизводимой или «плавающей» - в этом случае, отключив подсистему и не обнаружив ошибки, вы не можете быть уверенным, что ее на самом деле нет в оставшихся модулях. Вполне возможно, что она не проявляется или проявляется иным образом.

Очищать освобождаемую память

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

Очищать освобождаемую память

Переопределить оператор delete и писать в освобождаемую память идентификаторы-маркеры. Дело в том, что стандарт не определяет, что должно происходить с памятью, после её освобождения оператором delete. Известно только, что все указатели на этот участок памяти становятся невалидными. Будет ли этот блок обнулен, тут же использован для распределения оператором new или останется не тронутым – не оговаривается. Поэтому блок после освобождения (особенно, в Release-версии) может остаться неизмененным. Хотя работать с ним уже будет нельзя. По ошибке программист может сохранить и использовать указатель на такую область памяти и, пока она не будет перераспределена, все может идти хорошо. Это то, что стандарт называет «неопределенное поведение» (“Undefined behaviour”).

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

Проверять целостность кучи

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

Рекомендуемая литература

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

  1. Джон Роббинс «Отладка Windows-приложений»
  2. Мэтт Теэллес, Юань Хсих «Hаука отладки»

В беседе принимали участие:

Alexey Pakhunov, Boris Batkin, Boris Rudakov, Damir Tenisheff, Eugene Muzychenko, Genadi Zawidowski, Igor Karatayev, Maxim Sokolov, Sergey Cheban, Vadim Radionov,Ануфриев Артём

Дата последнего обновления 2007 год