Методы отладки использования памяти

Разоблачение величайшего мифа о проблеме языка С

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

Кэмерон Лэйрд, вице-президент, Phaseit,Inc.

Кэмерон Лэйрд (Cameron Laird) - бывший обозревательэтого сайта и в течение длительного времени пишет для developerWorks. Он часто рассказывает про Open Source проекты, позволяющие его работодателям ускорить разработку технологий в области надежности и безопасности передачи информации. Кэмерон впервые начал использовать AIX двадцать лет назад, когда тот был все еще экспериментальным продуктом. Все это время Кэмерон был заинтересованным пользователем и разработчиком средств для отладки памяти. Вы можете связаться с ним по адресу claird@phaseit.net.



01.02.2008

Введение

Ошибки памяти в программах, написанных на С и С++, плохи тем, что они повсеместны и могут иметь серьезные последствия. Множество серьезных предупреждений о проблемах безопасности от Computer Emergency Response Team (см. Ресурсы) и разработчиков программного обеспечения являются одним из следствий простых ошибок памяти. Программисты на С обсуждали этот тип ошибок с конца 70-х годов, но они часто встречались и в 2007 году. Ситуация усугубляется тем, что множество программистов С и С++ рассматривают ошибки памяти как неконтролируемые и роковые несчастья, которые можно только исправлять по мере их возникновения, но не предотвращать.

Это совсем не так. Эта статья покажет, что возможно понять все особенности грамотного программирования распределения памяти:

Важность правильного управления памятью

Программы с ошибками памяти, написанные на С и С++, являются причиной возникновения проблем. Если в них происходит утечка памяти, то они выполняются постепенно все медленнее и медленнее и в конечном итоге зависают; если они переписывают содержимое памяти - они не защищены и легко взламываются . Действие известного червя Морриса в 1988 году было основано на переполнении буфера, а недавно обнаруженные бреши в системе безопасности Flash Player'а и других распространенных программ также связаны с переполнением буфера. "Самая главная брешь в защите компьютера - переполнение буфера", - писал Родни Бейтс (Rodney Bates) в 2004 году.

Многие другие универсальные языки, такие как Java™, Ruby, Haskell, C#, Perl, Smalltalk и тому подобные, широко применяются в ситуациях, где могли бы использоваться С или С++, и каждый из них имеет энтузиастов и значительные выгоды. В компьютерном фольклоре бытует мнение, что большинство преимуществ в удобстве и простоте использования других языков по сравнению с С и С++ - это простота управления памятью. Программирование, связанное с управлением памяти, очень важно, но его правильное применение весьма трудно на практике из-за того, что сейчас доминируют другие подходы в программировании, такие как объектно-ориентированный, функциональный, высокоуровневый, декларативный и другие.

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

Из-за всех этих причин аспекты программирования памяти на С и С++ заслуживают внимательного анализа. Давайте посмотрим, что можно сделать на примерах выбранных нами языков С и С++.

Какие встречаются ошибки в управлении памятью

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

  • утечка памяти;
  • неправильное выделение памяти, включая множественное освобождение памяти функциейfree() и неинициализированные ссылки;
  • указатели, указывающие на несуществующий объект;
  • выход за пределы массива.

Это полный список. Даже с учетом объектно-ориентированного С++ список возможных проблем сильно не изменится: модель управления памятью и ссылки в С и С++ одинаковы, независимо от того, является ли тип данных простым, структурой языка C или классом С++. Большинство кода ниже написано на чистом С, а код для С++ в большинстве случаев оставлен для самостоятельных упражнений.

Утечки памяти

Утечки памяти происходят, когда ресурсы системы выделяются для программы, но последняя никогда не возвращает их. Ниже представлен пример такой утечки: (см. листинг 1):

Листинг 1. Простая потенциальная потеря памяти в "куче" и перезапись буфера.
	void f1(char *explanation)
	{
	    char *p1;

	    p1 = malloc(100);
            (void) sprintf(p1,
                           "The f1 error occurred because of '%s'.",
                           explanation);
            local_log(p1);
	}

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

В программировании на С и С++ недостаточно следить за использованием malloc() или new. ВВ начале этого раздела были упомянуты именно средства, а не конкретно память из-за примеров, подобных следующему (см. листинг 2). Файловые дескрипторы (FILE handles) могут не быть похожими на блоки памяти, но работать с ними нужно так же аккуратно, как и с памятью:

Листинг 2. Возможная потеря памяти в "куче" из-за неправильного управления ресурсами
	int getkey(char *filename)
	{
	    FILE *fp;
	    int key;

	    fp = fopen(filename, "r");
	    fscanf(fp, "%d", &key);
	    return key;
        }

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

Неправильное выделение памяти

Проще бороться с неправильным выделением памяти (см. пример из листинга 3):

Пример 3. Неинициализированный указатель
	void f2(int datum)
	{
	    int *p2;

                /* ошибка!  никто не инициализировал указатель p2. */
            *p2 = datum;
	       ...
        }

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

Вот еще пример к этому разделу. Память может высвобождаться чаще путем вызова free(), нежели заново выделяться с помощью malloc() (см. листинга 4):

Листинг 4. Два ошибочно выполненных высвобождения памяти
	/* выделяем память один раз, освобождаем дважды. */
	void f3()
	{
	    char *p;

	    p = malloc(10);
	     ...
            free(p);
	     ...
            free(p);
        }

        /* выделяем ноль раз, освобождаем один раз. */
	void f4()
	{
	    char *p;

                /* внимание! на этой строчке указатель p все еще не инициализирован. */
	    free(p);
	}

Эти ошибки также не угрожают тяжелыми последствиями. Хотя стандарт С не определяет поведение в этих ситуациях, обычно в готовых продуктах эти ошибки либо игнорируются, либо помечаются быстро и четко; как упомянутые ранее, эти ошибки безвредны.

Указатели, указывающие на несуществующие объекты

Указатели, указывающие на несуществующие объекты (далее - зависшие указатели) вызывают больше неприятностей. Зависшие указатели возникают, когда программист использует ресурсы памяти после того, как их освободили (см. листинг 5):

Листинг 5. Зависшие указатели
       void f8() 
       {
	   struct x *xp;

	   xp = (struct x *) malloc(sizeof (struct x));
	   xp.q = 13;
	   ...
	   free(xp);
	   ...
	       /* проблема!  нет никакой гарантии, что
		  блок памяти, на который ссылается указатель xp,
		  не будет перезаписан. */
	   return xp.q;
       }

Традиционной отладкой зависшие указатели трудно локализовать. Их трудно повторно воспроизвести по следующим причинам:

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

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

Нарушение размерности массива

Не такой уж безопасной ошибкой является нарушение размерности массива – ошибка, стоящая последней в списке главных ошибок в управлении памятью. Посмотрите снова листинг 1, что случится, если длина explanation превысит 80? Трудно однозначно сказать, что случится, но в любом случае от этого не стоит ждать ничего хорошего. Более определенно, С копирует строку, которая не умещается в 100 символов, под которые отведена память. В любых общих реализациях не влезающие в отведенные 100 байт символы перепишут другие данные в памяти. Размещение данных в памяти достаточно сложное и трудно воспроизвести, поэтому любые признаки, симптомы ошибки в реализации было бы трудно связать с определенной ошибкой в исходном коде. Нарушение размерности массивов - ошибка, входящая в число ошибок, причиняющих миллионные убытки.

Стратегии программирования использования памяти

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

Стиль программирования

Самое важное и, как я заметил, единственное, чему не придают особого значения другие авторы - общепринятые стандарты программирования. Функции и методы, которые влияют на ресурсы, особенно память, необходимо подробно комментировать. Вот некоторые примеры (см. листинг 6).

Листинг 6. Пример кода, в котором подробно описано управление ресурсами памяти
        /********
        * ...
        * каждая функция, вызывающая protected_file_read(), принимает на себя
        * ответственность рано или поздно закрыть возвращенное значение, 
		* если оно не NULL, с помощью fclose().
        *
        ********/
        FILE *protected_file_read(char *filename)
        {
        FILE *fp;

        fp = fopen(filename, "r");
	    if (fp) {
		...
	    } else {
		...
	    }
	    return fp;
	}

        /*******
	 * ...
	 * возвращенное из get_message значение ведет на фиксированную область
	 * памяти. НЕ НУЖНО её освобождать через free(), не забудьте создать копию,
	 * если нужно сохранить значение...
	 *
	 ********/
	char *get_message()
	{
	    static char this_buffer[400];

            ...
	    (void) sprintf(this_buffer, ...);
	    return this_buffer;
        }


        /********
	 * хотя эта функция использует память из "кучи"
	  и может временно
	 * увеличить общий объем используемой памяти,
	  она аккуратно убирает за собой.
	 * 
	 ********/
        int f6(char *item1)
	{
	    my_class c1;
	    int result;
            ...
	    c1 = new my_class(item1);
	    ...
            result = c1.x;
	    delete c1;
	    return result;
	}
	/********
	 * ...
	 * так как f8() согласно документации 
	 возвращает значение, которое необходимо
	 * вернуть в кучу, а метод f7() внутри 
	 себя вызывает f8(), то любой код, вызывающий
	 * f7() должен вызвать free() 
	 чтобы освободить память, 
	 * используемую возвращенным значением.
	 *
	 ********/
	int *f7()
	{
	    int *p;

	    p = f8(...);
	    ...
	    return p;
	}

Эти элементы стиля должны стать частью стиля написания программ. Вот еще несколько подходов к проблеме памяти:

  • специализированные библиотеки;
  • языки;
  • утилиты;
  • аппаратный контроль.

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

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

Проверка

В дополнение к стандартам программирования существует проверка (inspection). Любые методики самостоятельны, но особенно они действенны в комплексе. Внимательный профессиональный программист на С или С++ может проанализировать даже незнакомый исходный код и быстро увидеть проблемы в управлении памятью. После небольшой практики и с соответствующими навыками текстового поиска любой программист сможет быстро научиться оценивать правильность тела программы для сбалансированного использования *alloc() и free() или new и delete. При таком просмотре кода часто вылезают проблемы как в листинге 7.

Листинг 7. Проблемная утечка памяти
	static char *important_pointer = NULL;
	void f9()
	{
	    if (!important_pointer) 
		important_pointer = malloc(IMPORTANT_SIZE);
            ...
	    if (condition)
		    /* ошибка!  мы только что потеряли ссылку на значение, 
		       уже находящееся по указателю important_pointer. */
		important_pointer = malloc(DIFFERENT_SIZE);
            ...
        }

Поверхностное использование автоматических инструментов во время выполнения программы не обнаружит утечку памяти, которая происходит в случае, если условие condition окажется истинным. Тщательное исследование кода может учитывать подобные условия и позволяет обоснованно корректировать предположения. Я повторю то, что писал о стиле: в то время как в большинстве описания проблем, связанных с памятью, в качестве решения предлагается использовать инструменты для обнаружения ошибок и средства языков программирования, я считаю, что наибольшие выгоды приносят мягкие изменения, вносимые разработчиками. Любые улучшения в стиле и проверка помогают программисту понять диагностику, производимую автоматическими инструментами.

Статический автоматический анализ синтаксиса кода

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

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

Библиотеки для управления памятью

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

Из-за всех этих причин я призываю С и С++ программистов начинать с поиска в своем собственном коде проблем с использованием памяти. Сделав это, можно переходить к использованию библиотек.

Несколько библиотек делают возможным написание стандартного С или С++ кода с гарантированным улучшением управления памятью. Джонатан Бартлет (Jonathan Bartlett) описывает главных кандидатов в обзоре 2004 года для developerWorks, доступном в разделе Ресурсы ниже. Библиотеки предназначены для работы с различными проблемами памяти, так что очень трудно сравнить их непосредственно; общими рубриками являются собирание "мусора" ( garbage collection), интеллектуальные указатели (smart pointers) и интеллектуальные контейнеры (smart containers). Грубо говоря, библиотеки автоматизируют большую часть управления памятью, поэтому программист делает меньше ошибок

У меня сложные чувства к библиотекам для управления памятью. Они должны работать, но их успешное применение в проектах я видел реже, чем ожидал, особенно когда использовался язык С. У меня еще нет хорошей теории, объясняющей эти разочаровывающие результаты. Работа библиотеки должна быть столь же хорошей, как ручное управление памятью, но это скользкая тема, особенно в ситуациях, где библиотеки для сборки мусора, кажется, замедляют работу. Мое окончательное мнение: программисты С++ воспринимают интеллектуальные указатели лучше, чем программисты С.

Инструментальные средства для управления памятью.

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

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

Рынок программных инструментальных средств включает в себя IBM Rational® Purify, Electric Fence и другие инструментальные средства с открытым исходным кодом. Некоторые их них хорошо работают с AIX и другими операционными системами.

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

Листинг 8. Эталонная ошибка
	int main()
	{
	    char p[5];
	    strcpy(p, "Hello, world.");
	    puts(p);
	}

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

Заключение

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

Ресурсы

Научиться

Получить продукты и технологии

  • IBM trial software: ознакомительные версии программного обеспечения для разработчика, которые можно загрузить со страницы developerWorks.(EN)

Обсудить

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



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

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=AIX и UNIX
ArticleID=285483
ArticleTitle=Методы отладки использования памяти
publish-date=02012008