понедельник, февраля 08, 2010

Heap corruption и работа с дебажной кучей

Немного расскажу про дебажную кучу, речь пойдет о Visual Studio 2008. В остальных версиях дебажная куча выглядит примерно так же. Про другие компиляторы не знаю.

Основная мысль - дебажная куча отличается от релизной, чтобы облегчить отладку. И, если в дебаге произойдет порча памяти, то ее можно отловить. Visual Studio выдает в таком случае окно с сообщением и пишет в Output что-то вроде


Heap corruption at address.
HEAP[MyProg.exe]: Heap block at 0AC6A400 modified at 0AC6A6EC
past requested size of 2e4

Итак, в чем состоят отличия и каким образом отлавливаются ошибки. Давайте я пример приведу, с примером удобнее.
class CBase 
{
public:
int BaseI;
int BaseJ;
};

class CDerived : public CBase
{
public:
int DerivedI;
};

int main()
{
CBase *pBase = new CBase;//(1)

pBase->BaseI = 3;
pBase->BaseJ = 4;//(2)

delete pBase;//(3)

return 0;
}


Как будет выглядеть память в точке (1). (Чтобы вывести окно с памятью, выберите Debug->Windows->Memory->Memory1).

0x00984ED8  cd cd cd cd cd cd cd cd fd fd fd fd 00 00 00 00


У меня экземпляр класса CBase расположился по адресу 0x00984ED8. Оба int'а, а это восемь байт, заполнены значением 0xCD, Clean Memory. Это значение по умолчанию.
Дальше четыре байта 0XFD, Fence Memory, она же "no mans land". Это такой заборчик, которым обрамляется свежевыделенная память. Перед адресом 0x00984ED8 стоят точно такие же четыре байта.

Точка (2).
0x00984ED8  03 00 00 00 04 00 00 00 fd fd fd fd 00 00 00 00


Мы записали значения 3 и 4 и они идут одно за другим. Младший байт идет первым, потому что я работают с little endian платформой.

Точка (3)
0x00984ED8  dd dd dd dd dd dd dd dd dd dd dd dd 00 00 00 00

Память после удаления заполняется значениями 0xDD, Dead Memory. После вызова функции HeapFree() будет заполнена значениями 0xFEEEFEEE.


Давайте теперь сымитируем багу и посмотрим как Visual Studio ее поймает. Это реальная бага, я ее упростила до синтетического примера. На самом деле кода было больше и он был размазан по нескольким функциям.


CBase *pBase = new CBase;//(1)

pBase->BaseI = 3;
pBase->BaseJ = 4;//(2)

CDerived* pDerived = static_cast<CDerived*>(pBase);

pDerived->DerivedI = 7;//(3)
delete pBase;


Итак, мы стали приводить по иерархии с помощью static_cast'а, вместо dynamic_cast'а. Что в итоге получили. В точках (1) и (2) программа выглядит все также. В точке (3) мы полезли в чужую память и затерли забор.
03 00 00 00 04 00 00 00 07 00 00 00 00 00 00 00


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

HEAP CORRUPTION DETECTED: after Normal block (#68) at 0x008D4ED8.
CRT detected that the application wrote to memory after end of heap buffer.


Еще различия между дебажной и релизной кучами приводят к тому, что программа начинает себя вести по-разному в дебаге и в релизе. Простой пример - дебажная "падает", релизная нет. Очень может быть, что "падение" - это не падение вовсе, а как раз сообщение о порче памяти. Почитайте повнимательнее, что там в окне написано.
Также народ жалуется, что дебажная версия работает сильно меденнее релизной. Причиной этого в том числе может быть дебажная куча. В интернетах пишут, что можно ее в дебаге отключить то ли с помощью переменной окружения _NO_DEBUG_HEAP, то ли NO_DEBUG_HEAP, в единицу ее надо выставить. Я пробовала ее отключить, куча осталась дебажной.

Ссылки по теме:
Win32 Debug CRT Heap Internals
Inside CRT: Debug Heap Management
Memory Management and the Debug Heap (MSDN)
Приведение типов в C++

14 коммент.:

Alexey комментирует...

Я правильно понял, что если мы к CDerived добавим еще одно поле и испортим память за забором, то дебажная куча ничего не сможет детектнуть?

Алёна комментирует...

2Alexey:

Я правильно понял, что если мы к CDerived добавим еще одно поле и испортим память за забором, то дебажная куча ничего не сможет детектнуть?

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

newmen комментирует...

спасибо.
очень занимательно ;)

aamonster комментирует...

4 байт забора не всегда хватает.
А иногда вообще хочется странного: чтобы рядом с каждым байтом/словом в куче стоял адрес, с которого туда последний раз писали...

Но вообще с такого рода ошибками не часто сталкиваюсь: приводить типы таким образом категорически не люблю (навскидку помню 1 случай, когда я так делал - так там RTTI для проверки использовалась), все больше обхожусь интерфейсами. Вот выход за границы массива - бывает (обычно - когда циклы по x и y, один делается копипастом с другого, и не правятся пределы).

А чаще - указатели на уже мёртвые объекты :-(. Кстати, надо посмотреть - может, есть возможность заставить дебаг-кучу до последнего не использовать заново освобожденные области памяти - чтобы адреса не повторялись... Ну и пользоваться какой-то утилитой, чтобы хранить для каждого выделенного адреса call stack.

yurikritski комментирует...

Если нужно просто под дебаггером посидеть и посмотреть, что в собственном коде происходит, то есть вариант — поставить опции на Release, затем отключить оптимизацию (/Od) и включить генерацию PDB (и в компиляторских, и в линкеровских опциях). Или попробовать в Debug-конфигурации заменить CRT-библиотеку на релизную.

Flint комментирует...

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

Andrey комментирует...

Очень занимательно и толково написано.
>так, мы стали приводить по иерархии >с помощью static_cast'а, вместо >dynamic_cast'а.
Ну тут dynamic_cast не причем, не нужно преобразовывать к наследнику если не выделена память для него.

Алёна комментирует...

2Andrey:

Очень занимательно и толково написано.

Спасибо :-)

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

Преобразование произошло по ошибке, это да. Но dynamic_cast в такой ситуации возвратит NULL и ошибка вылезет сразу. Никакого тихого затирания чужой памяти.
(в моих примерах надо добавить виртуальных функций, чтобы можно было использовать dynamic_cast).

Дима комментирует...

Давно не работал с С++, но неужели такие хаки как static_cast действительно необходимы? Это же неисчерпаемый источник вот таких загадочных и сложнейших в отладке ошибок.

Алёна комментирует...

2Дима:

Давно не работал с С++, но неужели такие хаки как static_cast действительно необходимы? Это же неисчерпаемый источник вот таких загадочных и сложнейших в отладке ошибок.

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

Дима комментирует...

2Алена:

Не знал про забор и Dead Memory значения. Просто пишешь о сложных вещах. Спасибо за статью)

dtjurev комментирует...

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

aamonster комментирует...

dtjurev, интересная тема...
Вообще возникает мысль для особо тяжелых случаев клепать какие-нибудь проверки на _penter/_pexit:
запустил первый раз без них, обнаружил, какой блок памяти портится (можно на момент освобождения, если хранить call stack для каждого выделенного блока). Дальше именно для этого блока (идентифицировать по call stack) разрешаем проверку на каждом _pexit, и вуаля - получаем конкретную функцию, которая его попортила.

Pavel комментирует...

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