 | Уровень сложности: средний Кэмерон Лэйрд, вице-президент, Phaseit,Inc.
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" на экране. Запуск того же приложения с инструментарием по управлению памятью создаст отчет о нарушении размерности массива в четвертой строке. Узнать об ошибке в программном обеспечении подобным образом (в нашем случае ошибка заключается в том, что четырнадцать символов были скопированы в место, отведенное только для пяти) значительно дешевле, нежели получить от заказчика жалобы на сбои. В этом и заключается польза инструментальных средств для работы с памятью.
Заключение
Программист С или С++ понимает, что проблемы в управлении памятью заслуживают особого внимания. После небольшой практики можно овладеть методиками, которые позволят исключить ошибки управления памятью. Нужно выучить проверенные приемы использования памяти, уметь находить потенциальные ошибки, и тогда техника, описанная в этой статье, станет частью повседневной работы программиста. Он сможет исправлять в своих приложениях потенциальные ошибки, на отладку которых ушли бы дни или недели.
Ресурсы Научиться
-
Techniques for memory debugging (EN): оригинал статьи.
-
Computer Emergency Response Team: служба Computer and Emergency Response Team является финансируемым государством центром исследований и разработок, который также публикует предупреждения об определенных уязвимостях программного обеспечения.(EN)
-
Почему хорошие программисты пользуются плохими методиками? (EN): статья ассистента профессора Родни Бейтса (Rodney Bates) о культуре программирования на С и переполнении буфера, написанная для ACM Queue.
-
Управление памятью изнутри (EN) (developerWorks, ноябрь 2004): обзор механизмов управления памятью для программистов Linux® и посвященная в основном С, но эта информация актуальна и для других языков.
-
Rational Purify: лучший фирменный инструмент для управления памятью.(EN)
-
Coverity, Incorporated: этот сайт предлагает программные продукты и сервисы, в том числе исходный код программы для статического анализа кода С или С++.(EN)
-
Memory hygiene in C and C++, Part 2: Commercial tools: эту статью я написал в 2004 году. У меня так же есть Web-страничка, посвященная отладчику работы с памятью.(EN)
-
AIX и UNIX: в разделе AIX и Unix сайта developerWorks размещена различная информация по всем аспектам системного администрирования AIX, которая поможет лучше изучить UNIX.
-
Новичок в AIX и UNIX?: страница AIX и UNIX для новичков.
-
AIX 5L Wiki: совместная разработка документации AIX.(EN)
- Другие статьи и учебные материалы, подготовленные Кэмероном Лердом (Cameron Laird):(EN)
-
Safari bookstore: электронная библиотека технической информации.(EN)
-
developerWorks technical events and webcasts: новости о последних событиях и Web-конференциях сообщества developerWorks.(EN)
-
Podcasts: аудиозаписи презентаций технических экспертов IBM.(EN)
- Разделы библиотеки информации по AIX и UNIX:(EN)
Получить продукты и технологии
-
IBM trial software: ознакомительные версии программного обеспечения для разработчика, которые можно загрузить со страницы developerWorks.(EN)
Обсудить
Об авторе  | |  | Кэмерон Лэйрд (Cameron Laird) - бывший обозревательэтого сайта и в течение длительного времени пишет для developerWorks. Он часто рассказывает про Open Source проекты, позволяющие его работодателям ускорить разработку технологий в области надежности и безопасности передачи информации. Кэмерон впервые начал использовать AIX двадцать лет назад, когда тот был все еще экспериментальным продуктом. Все это время Кэмерон был заинтересованным пользователем и разработчиком средств для отладки памяти. Вы можете связаться с ним по адресу claird@phaseit.net. |
Выскажите мнение об этой странице
|  |