Искусство отладки с помощью GDB, DDD и Eclipse

Норман Мэтлофф, Питер Джей Зальцман

2008

перевод В.Айсин

Предисловие

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

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

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

Эта книга не является ни прославленным руководством пользователя, ни абстрактным трактатом по когнитивной теории процесса отладки. Наоборот, это нечто промежуточное между этими двумя жанрами. С одной стороны, мы действительно даем информацию о том, как выполнять определенные команды в GDB, DDD и Eclipse; но, с другой стороны, мы излагаем и часто используем некоторые общие принципы процесса отладки.

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

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

Глава 1 «Некоторые предварительные сведения для начинающих и профессионалов» представляет собой обзор. У многих опытных программистов может возникнуть соблазн пропустить ее, но мы призываем их прочитать ее, поскольку мы излагаем ряд простых, но содержательных общих рекомендаций, которые мы рекомендуем для процесса отладки.

Затем глава 2, «Остановка, чтобы осмотреться», посвящена рабочей лошадке отладки — точкам останова, обсуждаются все тонкости — установка, удаление и отключение точек останова; переход от одной точки останова к другой; просмотр подробной информации о точках останова; и так далее.

Как только вы достигнете точки останова, что тогда? Глава 3 «Проверка и установка переменных» посвящена этому вопросу. Наш выполняющийся пример касается кода, который обходит дерево. Ключевым моментом является удобное отображение содержимого узла в дереве при достижении точки останова. Здесь GDB действительно проявляет себя блестяще, предоставляя несколько очень гибких функций, которые позволяют эффективно отображать интересующую информацию каждый раз, когда программа приостанавливается. И мы представляем особенно интересную функцию DDD для графического отображения деревьев и других связанных структур данных.

Глава 4 «Когда программа выходит из строя» описывает ужасные ошибки выполнения, возникающие из-за ошибок сегментации. Сначала мы представляем материал о том, что происходит на нижних уровнях, включая распределение памяти для программы и совместную роль оборудования и операционной системы. Читатели с хорошими системными знаниями могут просмотреть этот материал, но мы полагаем, что многие другие получат пользу от приобретения этой основы. Затем мы переходим к основным файлам — как они создаются, как их использовать для «вскрытия» и так далее. Мы завершаем главу расширенным примером сеанса отладки, в котором несколько ошибок приводят к сбоям сегментации.

Мы выбрали «Отладка в контексте множественных действий» в качестве названия главы 5, чтобы подчеркнуть, что мы рассматриваем не только параллельное программирование, но и сетевой код. Программирование сети клиент/сервер считается параллельной обработкой, причем даже наши инструменты используются параллельно — например, два окна, в которых мы используем GDB, одно для клиента, другое для сервера. Поскольку сетевой код включает в себя системные вызовы, мы дополняем наши инструменты отладки переменной errno C/C++ и командой strace Linux. Следующая часть главы 5 посвящена программированию потоков. И здесь мы снова начинаем с обзора инфраструктуры: разделения времени, процессов и потоков, условий гонки и так далее. Мы представляем технические детали работы с потоками в GDB, DDD и Eclipse и снова обсуждаем некоторые общие принципы, о которых следует помнить, например, случайность времени, в котором происходит переключение контекста потоков. Заключительная часть главы 5 посвящена параллельному программированию с использованием популярных пакетов MPI и OpenMP. Закончим расширенным примером в контексте OpenMP.

Глава 6 «Специальные темы» охватывает некоторые важные темы. Инструмент отладки не сможет вам помочь, если ваш код даже не компилируется, поэтому мы обсудим некоторые подходы к решению этой проблемы. Затем лечим проблему сбоя компоновки из-за отсутствия библиотек; мы еще раз почувствовали, что здесь полезно дать некоторую «теорию» — например, типы библиотек и то, как они связаны с вашим основным кодом. А как насчет отладки программ с графическим интерфейсом? Для простоты мы здесь придерживаемся настройки «полу-GUI», то есть curses программирования, и покажем, как заставить GDB, DDD и Eclipse взаимодействовать с событиями в вашем окне curses.

Как отмечалось ранее, процесс отладки можно значительно улучшить за счет использования дополнительных инструментов, некоторые из которых мы представляем в главе 7 «Другие инструменты». У нас есть дополнительное освещение errno и strace, некоторые материалы по lint и советы по эффективному использованию текстового редактора.

Хотя в книге основное внимание уделяется C/C++, другие языки рассматриваются в главе 8 «Использование GDB/DDD/Eclipse для других языков», где рассматриваются Java, Python, Perl и язык ассемблера.

Приносим извинения, если каким-то образом пропустили любимую читателями тему отладки, но мы рассмотрели материал, который сочли полезным в нашем собственном программировании.

Мы в большом долгу перед многими сотрудниками No Starch Press, которые помогали нам в этом проекте на протяжении длительного времени. Мы особенно благодарим основателя и редактора фирмы Билла Поллока. Он с самого начала верил в этот необычный проект и был удивительно терпим к нашим многочисленным задержкам.

Дэниел Якобовиц проделал поистине блестящую работу по рецензированию рукописи, предоставив множество бесценных советов. Нил Чинг, якобы нанятый для редактирования, на самом деле оказался «звонарем» со степенью в области компьютерных наук! Он поднял ряд важных моментов, касающихся ясности наших технических дискуссий. Качество книги значительно улучшилось благодаря отзывам, которые мы получили от Дэниела и Нила. Конечно, следует сделать обычную оговорку о том, что любые ошибки являются нашими собственными.

Норм говорит: Я хочу сказать xie xie и todah rabah моей жене Гамис и дочери Лоре, двум замечательным людям, с которыми мне повезло быть родственником. Их подход к решению проблем, искрометный юмор и joie de vivre (радость жизни, фр.) пронизывают эту книгу, несмотря на то, что они не прочитали в ней ни слова. Я также благодарю многих студентов, которых я преподавал на протяжении многих лет, которые учат меня так же, как я учу их, и которые заставляют меня чувствовать, что я все-таки выбрал правильную профессию. Я всегда стремился «изменить ситуацию» и надеюсь, что эта книга хоть в какой-то степени сделает это.

Комментарий Пита: Я благодарен Николь Карлсон, Марку Киму и Ронде Зальцман за то, что они потратили много часов на чтение глав, внесение исправлений и предложений только по той причине, что вы читаете в данный момент. Я также хотел бы поблагодарить людей из группы пользователей Linux в Дэвисе, которые на протяжении многих лет отвечали на мои вопросы. Знакомство с вами сделало меня умнее. Todah идет к Эвелин, которая во всех отношениях улучшила мою жизнь. Особого упоминания заслуживает Джорди («J-Train» из Сан-Франциско), который самоотверженно использовал вес своего кошачьего тела, чтобы страницы не улетали, всегда держал мое место в тепле и следил за тем, чтобы комната никогда не была пустой. По тебе очень скучают каждый день. Мурлыкай, малыш. Привет мама! Посмотрите, что я сделал!

Норм Мэтлофф и Пит Зальцман

9 июня 2008

Глава 1
Некоторые предварительные сведения для начинающих и профессионалов

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

В первых нескольких разделах мы представим обзор процесса отладки и роли инструментов отладки, а затем рассмотрим расширенный пример во вводном сеансе отладки в разделе 1.7.

1.1 Инструменты отладки, используемые в этой книге

В этой книге мы изложили основные принципы отладки, иллюстрируя их в контексте следующих инструментов отладки:

GDB

Наиболее часто используемым инструментом отладки среди Unix-программистов является GDB, GNU Project Debugger, разработанный Ричардом Столлманом, видным лидером движения за программное обеспечение с открытым исходным кодом, сыгравшим ключевую роль в разработке Linux.

В большинстве систем Linux должен быть предустановлен GDB. Если это не так, вам необходимо загрузить пакет компилятора GCC.

DDD

В связи с растущей в последнее время популярностью графических пользовательских интерфейсов (GUI) был разработан ряд отладчиков на основе графического пользовательского интерфейса, которые работают под Unix. Большинство из них являются интерфейсами GUI для GDB: пользователь вводит команды через графический интерфейс, который, в свою очередь, передает их GDB. Одним из них является DDD, Data Display Debugger.

Если в вашей системе еще не установлен DDD, вы можете его загрузить. Например, в системах Fedora Linux команда

yum install ddd

позаботится обо всем процессе за вас. В Ubuntu Linux можно использовать аналогичную команду apt-get.

Eclipse

Некоторые читатели могут использовать интегрированные среды разработки (IDE). IDE — это больше, чем просто инструмент отладки; она объединяет редактор, инструмент сборки, отладчик и другие средства разработки в один пакет. В этой книге в качестве примера IDE используется очень популярная система Eclipse. Как и DDD, Eclipse работает поверх GDB или другого отладчика.

Вы можете установить Eclipse с помощью yum или apt-get, как указано выше, или просто загрузить файл .zip и распаковать его в подходящий каталог, скажем, /usr/local.

В этой книге мы используем Eclipse версии 3.3.

1.2 Фокус на языке программирования

Наша основная точка зрения в этой книге — программирование на C/C++, и большинство наших примеров будут в этом контексте. Однако в главе 8 мы обсудим другие языки.

1.3 Принципы отладки

Хотя отладка — это скорее искусство, чем наука, существуют определенные принципы, которыми руководствуется ее практика. Некоторые из них мы обсудим в этом разделе.

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

1.3.1 Сущность отладки: принцип подтверждения

Следующее правило составляет суть отладки:

Фундаментальный принцип подтверждения

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

Другой способ сказать это:

Сюрпризы – это хорошо!

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

1.3.2 Какую ценность имеет инструмент отладки принципа подтверждения?

Классический метод отладки состоит в том, чтобы просто добавить в программу код трассировки для вывода значений переменных во время выполнения программы, например, с помощью инструкций printf() или cout. Вы можете спросить: «Разве этого недостаточно? Зачем использовать такие инструменты отладки, как GDB, DDD или Eclipse?»

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

Напротив, при использовании графических инструментов отладки, таких как DDD и Eclipse, все, что вам нужно сделать, чтобы проверить значение переменной, — это навести указатель мыши на экземпляр этой переменной на дисплее кода, и вам будет показано ее текущее значение. Зачем утомлять себя еще больше, чем необходимо, и дольше, чем необходимо, во время ночного сеанса отладки, делая это с помощью инструкций printf()? Сделайте себе одолжение и сократите количество времени, которое вам придется потратить, и утомительные процедуры, которые вам придется пережить, используя инструмент отладки.

Вы также получаете гораздо больше от инструмента отладки, чем просто возможность просматривать переменные. Во многих ситуациях отладчик может указать приблизительное местоположение ошибки. Предположим, например, что ваша программа падает или крашится из-за ошибки сегментации (segmentation fault), то есть ошибки доступа к памяти. Как вы увидите в нашем примере сеанса отладки далее в этой главе, GDB/DDD/Eclipse может немедленно сообщить вам местоположение ошибки сегментации, которое обычно находится в месте ошибки или рядом с ним.

Аналогично, отладчик позволяет вам устанавливать точки наблюдения (watchpoints), которые могут сказать вам, в какой момент во время выполнения программы значение определенной переменной достигает подозрительного значения или диапазона. Эту информацию может быть трудно получить, глядя на выходные данные вызовов printf().

1.3.3 Другие принципы отладки

Начните с малого

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

Используйте подход «сверху вниз»

Вы, вероятно, знаете об использовании нисходящего (top-down) или модульного (modular) подхода к написанию кода: ваша основная программа не должна быть слишком длинной и состоять в основном из вызовов функций, выполняющих существенную работу. Если одна из этих функций длинная, вам следует рассмотреть возможность ее разделения на более мелкие модули.

Вы должны не только писать код сверху вниз, но и отлаживать код сверху вниз.

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

Используйте инструмент отладки, чтобы определить место ошибки сегментации.

Самый первый шаг, который вы делаете при возникновении ошибки сегментации, — это запуск программы в отладчике и воспроизведение ошибки сегментации. Отладчик сообщит вам строку кода, в которой произошла ошибка. Затем вы можете получить дополнительную полезную информацию, вызвав функцию обратной трассировки (backtrace) отладчика, которая отображает последовательность вызовов функций, ведущих к вызову функции, в которой произошла ошибка.

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

Определите местоположение бесконечного цикла, выдав прерывание

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

Используйте двоичный поиск

Вы, наверное, видели двоичный поиск в контексте отсортированных списков. Скажем, например, что у вас есть массив x[] из 500 чисел, расположенных в порядке возрастания, и вы хотите определить, куда вставить новое число y. Начните со сравнения y с x[250]. Если y окажется меньше этого элемента, вы затем сравните его с x[125], но если y больше x[250], тогда следующее сравнение будет с x[375]. В последнем случае, если y меньше x[375], вы затем сравниваете его с x[312], который находится на полпути между x[250] и x[375] и так далее. Вы будете продолжать сокращать пространство поиска пополам на каждой итерации и быстро находить точку вставки.

Этот принцип можно применять и при отладке. Предположим, вы знаете, что значение, хранящееся в определенной переменной, испортилось где-то в течение первых 1000 итераций цикла. Один из способов, который может помочь вам отследить итерацию, где значение впервые становится неправильным, — это использовать точку наблюдения, продвинутый метод, который мы обсудим в разделе 1.5.3. Другой подход — использовать двоичный поиск, в данном случае во времени, а не в пространстве. Сначала вы должны проверить значение переменной на 500-й итерации; если на данный момент все еще в порядке, вы должны проверить значение на 750-й итерации и так далее.

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

1.4 Текстовые и графические инструменты отладки и компромисс между ними

Графические интерфейсы пользователя, обсуждаемые в этой книге, DDD и Eclipse, служат интерфейсом для GDB для C и C++, а также для других отладчиков. Хотя графические интерфейсы привлекательны и могут быть более удобными, чем текстовая GDB, наша точка зрения в этой книге будет заключаться в том, что как текстовые, так и графические отладчики (включая IDE) полезны в разных контекстах.

1.4.1 Краткое сравнение интерфейсов

Чтобы быстро получить представление о различиях между текстовыми и графическими инструментами отладки, давайте рассмотрим ситуацию, которую мы будем использовать в качестве рабочего примера в этой главе. Программа в примере — insert_sort. Она компилируется из исходного файла ins.c и выполняет сортировку вставкой.

1.4.1.1 GDB: обычный текст

Чтобы инициировать сеанс отладки этой программы с помощью GDB, вы должны ввести

$ gdb insert_sort

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

(gdb)
1.4.1.2 DDD: инструмент отладки графического интерфейса

Используя DDD, вы начнете сеанс отладки, набрав

$ ddd insert_sort

в командной строке Unix. Появится окно DDD, после чего вы сможете отправлять команды через графический интерфейс.

Типичный вид окна DDD показан на рисунке 1-1. Как видите, окно DDD размещает информацию в различных подокнах:

Вот краткий пример того, как типичная команда отладки передается в отладчик под каждым типом пользовательского интерфейса. При отладке insert_sort вы можете захотеть приостановить выполнение программы — чтобы установить точку останова (breakpoint) — в строке 16 (скажем) функции get_args(). (Вы увидите полный исходный код для insert_sort в разделе 1.7.) Чтобы организовать это в GDB, вам нужно ввести

(gdb) break 16

в приглашении GDB.

Рисунок 1-1. Вид окна DDD

Полное имя команды — break, но GDB допускает сокращения, если нет двусмысленности, и большинство пользователей GDB напечатали бы здесь b 16. Чтобы облегчить понимание тем, кто плохо знаком с GDB, мы сначала будем использовать полные имена команд, а к сокращениям перейдем позже в книге, когда команды станут более знакомыми.

Используя DDD, вы должны просмотреть окно исходного текста, щелкнуть в начале строки 16, а затем щелкнуть значок Break в верхней части экрана DDD. Вы также можете щелкнуть правой кнопкой мыши в начале строки и выбрать Set Breakpoint (установить точку останова). Еще один вариант — просто дважды щелкнуть строку кода в любом месте слева от начала строки. В любом случае DDD подтвердит выбор, отобразив на этой строке небольшой знак остановки, как показано на рисунке 1-2. Таким образом, вы можете сразу увидеть точки останова.

1.4.1.3 Eclipse: отладчик с графическим интерфейсом и многое другое

Теперь на рисунке 1-3 представлена общая среда Eclipse. В терминологии Eclipse мы сейчас находимся в перспективе Debug. Eclipse — это общая среда для разработки множества различных видов программного обеспечения. Каждый язык программирования имеет свой собственный подключаемый графический интерфейс — перспективу — внутри Eclipse. Действительно, для одного и того же языка может существовать несколько конкурирующих точек зрения. В нашей работе с Eclipse в этой книге мы будем использовать перспективу C/C++ для разработки на C/C++, перспективу Pydev для написания программ на Python и т. д. Существует также перспектива Debug для фактической отладки (с некоторыми функциями, специфичными для языка), и это то, что вы видите на рисунке.

Рисунок 1-2. Набор точек останова

Рисунок 1-3. Среда Eclipse

Перспектива C/C++ является частью плагина CDT. За кулисами CDT вызывает GDB, как и в случае с DDD.

Детали этого рисунка в целом аналогичны тому, что мы описали для DDD выше. Перспектива разбита на окна с вкладками, называемые представлениями (views). Вы можете увидеть исходный файл ins.c слева; есть представление Variables для проверки значений переменных (на рисунке пока нет); есть консольное представление, функция которого очень похожа на одноименное подокно в DDD; и так далее.

Вы можете устанавливать точки останова и так далее визуально, как в DDD. Например, на рисунке 1-4 строка

for (i = 0; i < num_inputs; i++)

в окне исходного файла имеет синий символ в левом поле, символизирующий наличие точки останова.

Рисунок 1-4. Удаление точки останова в Eclipse

1.4.1.4 Eclipse против DDD

Eclipse также имеет некоторые вспомогательные средства, отсутствующие в DDD. Например, в правой части обратите внимание на представление Outline, в котором перечислены переменные, функции и т. д. Например, если вы щелкнете запись для своей функции scoot_over(), курсор в представлении исходного файла переместится к этой функции. Более того, если вы временно перейдете из перспективы Debug обратно в перспективу C/C++, где вы выполняете редактирование и компиляцию для этого проекта (не показано), представление Outline также будет в вашем распоряжении. Это может быть весьма полезно в больших проектах.

Eclipse также лучше интегрирует процессы редактирования и компиляции. Если у вас есть ошибки компиляции, они четко обозначены в редакторе. Это можно сделать с помощью редактора Vim, который оба автора этой книги предпочитают IDE, но IDE делает это гораздо лучше.

С другой стороны, вы можете видеть, что Eclipse, как и большинство IDE, занимает значительное место на вашем экране (да и на страницах этой книги!). Этот режим Outline занимает драгоценное место на экране независимо от того, часто вы его используете или нет. Конечно, вы можете скрыть структуру, щелкнув X в ее правом углу (и если вы хотите вернуть ее, выберите Window | Show Views | Outline), что освобождает некоторое пространство, а также вы можете перетаскивать вкладки в разные места в окне Eclipse. Но в целом эффективное использование экранного пространства в Eclipse может быть затруднено.

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

Напротив, GDB в основном прозрачен для пользователей Eclipse, и хотя старая поговорка «Незнание — счастье» часто применима, прозрачность означает, что вы теряете легкий доступ к действиям по экономии труда, которые становятся возможными благодаря прямому использованию GDB. На момент написания этой статьи определенный пользователь все еще мог напрямую получить доступ к GDB, щелкнув поток GDB в Debug, а затем используя консоль, хотя и без подсказок GDB. Однако эта «недокументированная функция» может не сохраниться в будущих версиях.

1.4.1.5 Преимущества графического интерфейса

Интерфейсы GUI, предоставляемые DDD и Eclipse, более привлекательны визуально, чем GDB. Они также имеют тенденцию быть более удобными. Например, предположим, что вы больше не хотите, чтобы выполнение приостанавливалось в строке 16 функции get_args(), то есть вы хотите очистить точку останова. В GDB вы можете очистить точку останова, набрав

(gdb) clear 16

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

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

В Eclipse вы должны перейти в представление Breakpoints, выделить точки останова, которые хотите удалить, а затем навести курсор мыши на серый крестик, который символизирует операцию Remove Selected Breakpoints (удалить выбранные точки останова) (см. рисунок 1-4). Альтернативно вы можете щелкнуть правой кнопкой мыши синий символ точки останова в окне исходного кода и выбрать Toggle Breakpoint (переключить точку останова).

Одна из задач, в которой графические интерфейсы являются явными победителями, — это пошаговое выполнение кода. Гораздо проще и приятнее делать это с помощью DDD или Eclipse, а не GDB, поскольку вы можете наблюдать за своим перемещением по коду в окне исходного кода графического интерфейса. Следующая строка исходного кода, которая будет выполняться, обозначена стрелкой, как показано для DDD на рисунке 1-5. В Eclipse следующая строка выделяется зеленым цветом. Таким образом, вы можете с первого взгляда определить, где вы находитесь относительно других интересующих вас операторов программы.

1.4.1.6 Преимущества GDB

Таким образом, графические интерфейсы имеют много преимуществ перед текстовой GDB. Тем не менее, основанный на этом примере общий вывод о том, что графические интерфейсы лучше GDB, был бы неоправданным.

Молодые программисты, которые выросли, используя графические интерфейсы во всем, что они делают в Интернете, естественно, предпочитают графические интерфейсы GDB, как и многие их старшие коллеги. С другой стороны, у GDB есть и определенные преимущества:

Для тех, кто не привык к объему набора текста, необходимому для GDB, по сравнению с удобными операциями мыши в графическом интерфейсе, следует отметить, что GDB включает в себя некоторые устройства экономии набора текста, которые делают его текстовый характер более приемлемым. Ранее мы упоминали, что большинство команд GDB имеют короткие сокращения, и большинство людей используют их вместо полных форм. Кроме того, комбинации клавиш CTRL-P и CTRL-N позволяют пролистывать предыдущие команды и редактировать их при желании. Простое нажатие клавиши ENTER повторяет последнюю введенную команду (что очень полезно при многократном выполнении следующей команды для пошагового выполнения кода по одной строке за раз), и существует команда определения, которая позволяет пользователю определять сокращения и макросы. Подробности об этих функциях будут представлены в главах 2 и 3.

1.4.1.7 Итог: каждый имеет свою ценность

Мы считаем, что GDB и графические интерфейсы являются важными инструментами, и в этой книге будут представлены примеры GDB, DDD и Eclipse. Мы всегда начинаем рассмотрение любой конкретной темы с GDB, поскольку это общее между этими инструментами, а затем показываем, как материал распространяется на графические интерфейсы.

1.4.2 Компромиссы

Начиная с версии 6.1, GDB предлагает компромисс между текстовым и графическим взаимодействием с пользователем в форме режима под названием TUI (пользовательский интерфейс терминала). В этом режиме GDB разделяет экран терминала на аналоги окна исходного текста и консоли DDD; вы можете следить за ходом выполнения вашей программы в первом случае, одновременно вводя команды GDB во втором. Альтернативно вы можете использовать другую программу CGDB, которая предлагает аналогичную функциональность.

1.4.2.1 GDB в режиме TUI

Чтобы запустить GDB в режиме TUI, вы можете либо указать опцию -tui в командной строке при вызове GDB, либо ввести CTRL-X-A из GDB, находясь в режиме, отличном от TUI. Последняя команда также выводит вас из режима TUI, если вы в данный момент находитесь в нем.

В режиме TUI окно GDB разделено на два подокна — одно для команд GDB и одно для просмотра исходного кода. Предположим, вы запускаете GDB в режиме TUI на insert_sort, а затем выполняете пару команд отладки. Экран GDB может выглядеть так:

   11
   12    void get_args(int ac, char **av)
   13    { int i;
   14
   15      num_inputs = ac - 1;
*  16      for (i = 0; i < num_inputs; i++)
 > 17         x[i] = atoi(av[i+1]);
   18    }
   19
   20    void scoot_over(int jj)
   21    { int k;
   22
   23          for (k = num_y-1; k > jj; k++)

File: ins.c     Procedure: get_args    Line: 17      pc: 0x80484b8
--------------------------------------------------------------------------
(gdb) break 16
Breakpoint 1 at 0x804849f: file ins.c, line 16.
(gdb) run 12 5 6
Starting program: /debug/insert_sort 12 5 6

Breakpoint 1, get_args (ac=4, av=0xbffff094) at ins.c:16
(gdb) next
(gdb)

Нижнее подокно показывает именно то, что вы бы увидели, если бы использовали GDB без TUI. Здесь в этом подокне отображаются следующие вещи:

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

Мы можем перейти к другим частям кода, используя клавиши со стрелками вверх и вниз для прокрутки. Если вы не находитесь в режиме TUI, вы можете использовать клавиши со стрелками для прокрутки предыдущих команд GDB, чтобы изменить или повторить их. В режиме TUI клавиши со стрелками предназначены для прокрутки подокна исходного кода, а предыдущие команды GDB можно прокручивать с помощью CTRL-P и CTRL-N. Кроме того, в режиме TUI область кода, отображаемую в подокне исходного кода, можно изменить с помощью команды GDB list. Это особенно полезно при работе с несколькими исходными файлами.

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

1.4.2.2 CGDB

Еще один интерфейс GDB, который вы, возможно, захотите рассмотреть, — это CGDB, доступный по адресу http://cgdb.sourceforge.net/. CGDB также предлагает компромисс между текстовым подходом и подходом с графическим интерфейсом. Как и графические интерфейсы, он служит интерфейсом для GDB. Это похоже на концепцию TUI на основе терминала, но с дополнительными преимуществами, заключающимися в том, что он цветной, и вы можете просматривать подокно исходного кода и устанавливать точки останова непосредственно там. Также кажется, что обновление экрана обрабатывается лучше, чем GDB/TUI.

Вот несколько основных команд и соглашений CGDB:

1.5 Основные операции отладчика

Здесь мы даем обзор основных типов операций, которые предлагает отладчик.

1.5.1 Шаги по исходному коду

Ранее вы видели, что для запуска программы в GDB вы используете команду запуска, а в DDD вы нажимаете кнопку Run. Подробности будут представлены позже: вы увидите, что Eclipse работает аналогичным образом.

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

Breakpoints (контрольные точки)

Как упоминалось ранее, инструмент отладки приостанавливает выполнение вашей программы в указанных точках останова. В GDB это делается с помощью команды break вместе с номером строки; в DDD вы щелкаете правой кнопкой мыши в любом месте пробела в соответствующей строке и выбираете Set Breakpoint; в Eclipse вы дважды щелкаете по полю слева от строки.

Single-stepping (пошаговое выполнение)

Команда GDB next, которая также упоминалась ранее, сообщает GDB выполнить следующую строку, а затем сделать паузу. Команда step аналогична, за исключением того, что при вызове функции она будет входить в функцию, тогда как команда next приводит к следующей паузе в выполнении, происходящей на строке, следующей за вызовом функции. В DDD есть соответствующие пункты меню Next и Step, а в Eclipse есть значки Step Over (шаг с обходом) и Step Into (шаг с заходом), которые выполняют то же самое. (В лругих отладчиках также существует опция Step Out (шаг с выходом) — для выхода из функции. Прим. пер.)

Resume operation (возобновить работу)

В GDB команда continue сообщает отладчику возобновить выполнение и продолжать его до тех пор, пока не будет достигнута точка останова. В DDD есть соответствующий пункт меню, и в Eclipse для него есть значок Resume.

Temporary breakpoints (временные точки останова)

В GDB команда tbreak аналогична команде break, но она устанавливает точку останова, которая действует только до тех пор, пока указанная строка не будет достигнута в первый раз. В DDD это можно сделать, щелкнув правой кнопкой мыши в любом месте пустого пространства нужной строки в окне исходного текста, а затем выбрав Set Temporary Breakpoint (установить временную точку останова). В Eclipse выделите нужную строку в окне исходного кода, затем щелкните правой кнопкой мыши и выберите Run to Line (выполнить до строки).

GDB также имеет команды until и finish, которые создают специальные виды одноразовых точек останова. DDD имеет соответствующие пункты меню Until и Finish в окне команд, а Eclipse — Step Return. Они обсуждаются в главе 2.

Типичная схема отладки выполнения программы выглядит следующим образом (на примере GDB): После достижения точки останова вы некоторое время перемещаетесь по коду по одной строке или пошагово с помощью команд GDB next и step. Это позволяет вам внимательно изучить состояние и поведение программы вблизи точки останова. Когда вы закончите с этим, вы можете указать отладчику продолжать выполнение программы без паузы до тех пор, пока не будет достигнута следующая точка останова, с помощью команды continue.

1.5.2 Проверка переменных

После того, как отладчик приостановит выполнение нашей программы, вы можете дать команды для отображения значений переменных программы. Это могут быть локальные переменные, глобальные переменные, элементы массивов и структур C, переменные-члены в классах C++ и т. д. Если обнаруживается, что переменная имеет неожиданное значение, это обычно является важным ключом к определению местоположения и характера ошибки. DDD может даже отображать массивы в виде графиков, которые могут с первого взгляда выявить подозрительные значения или тенденции, возникающие внутри массива.

Самый простой тип отображения переменных — это просто печать текущего значения. Например, предположим, что вы установили точку останова в строке 37 функции insert() в файле ins.c. (Опять же, полный исходный код приведен во вводном сеансе отладки, но подробности вас пока не интересуют.) Достигнув этой строки, вы можете проверить значение локальной переменной j в этой функции. В GDB вы можете использовать команду печати:

(gdb) print j

В DDD это еще проще: вы просто наводите указатель мыши на любой экземпляр j в окне исходного текста, а затем значение j будет отображаться на секунду или две в маленьком желтом прямоугольнике, называемом подсказкой значения (value tip) — возле указателя мыши. См. рисунок 1-5, где проверяется значение переменной new_y. В Eclipse все работает точно так же, как показано на рисунке 1-6, где мы запрашиваем значение num_y.

Рисунок 1-5. Проверка переменной в DDD

Рисунок 1-6. Проверка переменной в Eclipse

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

1.5.3 Выдача «Сводки по всем пунктам» для изменений переменной

Точка наблюдения (watchpoint) сочетает в себе понятия точки останова (breakpoint) и проверки переменных. Самая базовая форма дает отладчику указание приостанавливать выполнение программы всякий раз, когда изменяется значение указанной переменной.

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

(gdb) watch z

Когда вы запускаете программу, GDB приостанавливает выполнение всякий раз, когда изменяется значение z. В DDD вы можете установить точку наблюдения, щелкнув любой экземпляр z в окне исходного текста, а затем щелкнув значок Watch в верхней части окна DDD.

Более того, вы можете устанавливать точки наблюдения на основе условных выражений. Скажем, например, что вы хотите найти первую точку выполнения программы, в которой значение z превышает 28. Этого можно добиться, установив точку наблюдения на основе выражения (z > 28). В GDB вы должны ввести

(gdb) watch (z > 28)

В DDD вы должны ввести эту команду в консоли DDD. Напомним, что в C выражение (z > 28) имеет логический тип и принимает значение true или false, где false представлено 0, а true представлено любым ненулевым целым числом, обычно 1. Когда z сначала принимает значение, большее, чем 28, значение выражения (z > 28) изменится с 0 на 1, и GDB приостановит выполнение программы.

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

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

1.5.4 Перемещение вверх и вниз по стеку вызовов

Во время вызова функции информация времени выполнения, связанная с вызовом, сохраняется в области памяти, известной как кадр стека (stack frame). Фрейм содержит значения локальных переменных функции и ее параметров, а также запись местоположения, из которого была вызвана функция. Каждый раз, когда происходит вызов функции, создается новый кадр и помещается в стек, поддерживаемый системой; кадр наверху стека представляет выполняющуюся в данный момент функцию, он извлекается из стека и освобождается при выходе из функции.

Например, предположим, что вы приостанавливаете выполнение примера программы insert_sort во время выполнения функции insert(). Данные в текущем кадре стека будут указывать, что вы попали туда через вызов функции в определенном месте, которое оказывается внутри функции process_data() (которая вызывает insert()). В кадре также будет храниться текущее значение единственной локальной переменной метода insert(), которое, как вы увидите позже, равно j.

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

(gdb) frame 1

При выдаче команды GDB frame кадр выполняющейся в данный момент функции имеет номер 0, ее родительский кадр (то есть кадр стека вызывающего функции) имеет номер 1, родительский кадр имеет номер 2 и так далее. Команда GDB up переносит вас к следующему родительскому элементу в стеке вызовов (например, к кадру 1 из кадра 0), а команда down переносит вас в другом направлении. Такие операции очень полезны, поскольку значения локальных переменных в некоторых из более ранних кадров стека могут дать вам подсказку о том, что вызвало ошибку.

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

Команда GDB backtrace покажет вам весь стек, то есть всю коллекцию кадров, существующих в данный момент.

Аналогичная операция в DDD вызывается нажатием кнопки Status | Backtrace; появится окно, показывающее все кадры, и вы сможете щелкнуть тот, который хотите проверить. Интерфейс DDD также имеет кнопки Up и Down, нажимая на которые можно вызвать команды GDB up и down.

В Eclipse стек постоянно виден в самой перспективе отладки. На рисунке 1-7 обратите внимание на вкладку Debug в верхнем левом углу. Вы увидите, что в данный момент мы находимся во фрейме 2, в функции get_args(), которую мы вызывали из фрейма 1 в main(). Какой бы кадр ни был выделен, он отображается в исходном окне, поэтому вы можете отобразить любой кадр, щелкнув его в стеке вызовов.

Рисунок 1-7. Перемещение внутри стека в Eclipse

1.6 Онлайн помощь

В GDB доступ к документации можно получить с помощью команды help. Например,

(gdb) help breakpoints

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

В DDD и Eclipse множество материалов можно получить, щелкнув Help.

1.7 Вводный сеанс отладки

Теперь мы представим полный сеанс отладки. Как уже упоминалось, пример программы находится в исходном файле ins.c и выполняет сортировку вставкой. Конечно, это не эффективный метод сортировки, но простота кода позволяет хорошо иллюстрировать операции отладки. Вот код:

//
// insertion sort, several errors
//
// usage: insert_sort num1 num2 num3 ..., where the numi are the numbers to
// be sorted

int x[10], // input array
    y[10], // workspace array
    num_inputs, // length of input array
    num_y = 0; // current number of elements in y

void get_args(int ac, char **av)
{  int i;

   num_inputs = ac - 1;
   for (i = 0; i < num_inputs; i++)
      x[i] = atoi(av[i+1]);
}

void scoot_over(int jj)
{  int k;

   for (k = num_y-1; k > jj; k++)
      y[k] = y[k-1];
}

void insert(int new_y)
{  int j;

   if (num_y = 0) { // y empty so far, easy case
      y[0] = new_y;
      return;
   }
   // need to insert just before the first y
   // element that new_y is less than
   for (j = 0; j < num_y; j++) {
      if (new_y < y[j]) {
         // shift y[j], y[j+1],... rightward
         // before inserting new_y
         scoot_over(j);
         y[j] = new_y;
         return;
      }
   }
}

void process_data()
{
   for (num_y = 0; num_y < num_inputs; num_y++)
      // insert new y in the proper place
      // among y[0],...,y[num_y-1]
      insert(x[num_y]);
}

void print_results()
{  int i;

   for (i = 0; i < num_inputs; i++)
      printf("%d\n",y[i]);
}

int main(int argc, char ** argv)
{  get_args(argc,argv);
   process_data();
   print_results();
}

Ниже приведено псевдокодовое описание программы. Вызовы функций обозначаются операторами call, а псевдокод для каждой функции отображается с отступом под вызовами:

call main():
   set y array to empty
   call get_args():
      get num_inputs numbers x[i] from command line
   call process_data():
      for i = 1 to num_inputs
         call insert(x[i]):
            new_y = x[i]
            find first y[j] for which new_y < y[j]
            call scoot_over(j):
               shift y[j], y[j+1], ... to right,
                  to make room for new_y
         set y[j] = new_y

Давайте скомпилируем и запустим код:

$ gcc -g -Wall -o insert_sort ins.c

Важно: вы можете использовать опцию -g в GCC, чтобы указать компилятору сохранить таблицу символов — то есть список адресов памяти, соответствующих переменным и строкам кода вашей программы — в сгенерированном исполняемом файле, который здесь называется insert_sort. Это абсолютно важный шаг, который позволяет вам обращаться к именам переменных и номерам строк в исходном коде во время сеанса отладки. Без этого шага (а что-то подобное пришлось бы сделать, если бы вы использовали компилятор, отличный от GCC), вы не могли бы, например, попросить отладчик «остановиться на строке 30» или «напечатать значение x».

Теперь давайте запустим программу. Следуя принципу «Начинай с малого» из «Других принципов отладки», сначала попробуйте отсортировать список, состоящий всего из двух чисел:

$ insert_sort 12 5
(execution halted by user hitting ctrl-C)

Программа не завершила работу и не вывела никаких результатов. Очевидно, она зашла в бесконечный цикл, и нам пришлось прервать ее, нажав CTRL-C. В этом нет никаких сомнений: что-то не так.

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

1.7.1 Подход GDB

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

Сначала запустите отладчик GDB на insert_sort:

$ gdb insert_sort -tui

Теперь ваш экран будет выглядеть так:

    63      {  get_args(argc,argv);
    64         process_data();
    65         print_results();
    66      }
    67
    68
    69
 File: ins.c    Procedure: ??    Line: ??      pc: ??
--------------------------------------------------------------------------
(gdb)

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

Если вы не запрашиваете режим TUI при вызове GDB, вы получите только приветственное сообщение и приглашение GDB без верхнего подокна для исходного кода вашей программы. Затем вы можете войти в режим TUI, используя команду GDB CTRL-X-A. Эта команда включает и выключает режим TUI и полезна, если вы хотите, например, временно выйти из режима TUI, чтобы было удобнее читать онлайн-справку GDB, или чтобы вы могли просмотреть больше истории команд GDB на одном экране.

Теперь запустите программу из GDB, введя команду run вместе с аргументами командной строки вашей программы, а затем нажмите CTRL-C, чтобы приостановить ее. Экран теперь выглядит так:

    46
    47 void process_data()
    48 {
    49 for (num_y = 0; num_y < num_inputs; num_y++)
    50 // insert new y in the proper place
    51 // among y[0],...,y[num_y-1]
  > 52 insert(x[num_y]);
    53 }
    54
    55 void print_results()
    56 { int i;
    57
    58 for (i = 0; i < num_inputs; i++)
    59 printf("%d\n",y[i]);
    60 } .
 File: ins.c Procedure: process_data Line: 52 pc: 0x8048483
--------------------------------------------------------------------------
(gdb) run 12 5
Starting program: /debug/insert_sort 12 5

Program received signal SIGINT, Interrupt.
0x08048483 in process_data () at ins.c:52
(gdb)

Это говорит о том, что когда вы остановили программу, insert_sort находилась в функции process_data(), а строка 52 в исходном файле ins.c должна была быть выполнена.

Мы нажимаем CTRL-C в случайное время и останавливаемся в случайном месте кода. Иногда полезно приостановить и перезапустить программу, которая перестала отвечать два или три раза, нажимая continue между сочетаниями клавиш CTRL-C, чтобы каждый раз видеть, где вы останавливаетесь.

Теперь строка 52 является частью цикла, который начинается со строки 49. Является ли этот цикл бесконечным? Не похоже, что цикл должен работать бесконечно, но принцип подтверждения гласит, что вы должны проверить это, а не просто предполагать. Если цикл не завершается из-за того, что вы каким-то образом неправильно установили верхнюю границу переменной num_y, то после того, как программа проработает некоторое время, значение num_y будет огромным. Так ли это? (Опять же, похоже, что это не так, но вам нужно убедиться в этом.) Давайте проверим текущее значение num_y, попросив GDB распечатать его.

(gdb) print num_y
$1 = 1

Вывод этого запроса к GDB показывает, что значение num_y равно 1. Метка $1 означает, что это первое значение, которое вы попросили GDB распечатать. (Значения, обозначенные $1, $2, $3 и т. д., вместе называются историей значений (value history) сеанса отладки. Они могут быть очень полезны, как вы увидите в последующих главах.) Итак, похоже, что мы находимся только на второй итерации цикла в строке 49. Если бы этот цикл был бесконечным, его вторая итерация уже прошла бы давным давно.

Итак, давайте подробнее рассмотрим, что происходит, когда num_y равен 1. Скажите GDB остановиться в методе insert() во время второй итерации цикла в строке 49, чтобы вы могли осмотреться и попытаться выяснить, что происходит не так в этом месте и в это время в программе:

(gdb) break 30
Breakpoint 1 at 0x80483fc: file ins.c, line 30.
(gdb) condition 1 num_y==1

Первая команда устанавливает точку останова в строке 30, то есть в начале метода insert(). В качестве альтернативы вы могли бы указать эту точку останова с помощью команды break insert, что означает разрыв на первой строке метода insert() (здесь это строка 30). Эта последняя форма имеет преимущество: если вы измените код программы так, чтобы функция insert() больше не начиналась со строки 30 файла ins.c, ваша точка останова останется действительной, если она указана с использованием имени функции, но не если указана с использованием строки число.

Обычно команда прерывания приостанавливает выполнение каждый раз, когда программа достигает указанной строки. Однако вторая команда здесь, condition 1 num_y==1, делает эту точку останова условной: GDB приостановит выполнение программы в точке останова 1 только тогда, когда условие num_y==1 выполняется.

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

Мы могли бы объединить команды break и condition в один шаг, используя команду break if следующим образом:

(gdb) break 30 if num_y==1

Затем снова запустите программу, используя команду run. Вам не нужно заново формулировать аргументы командной строки, если вы просто хотите повторно использовать старые. В данном случае это именно тот случай, поэтому вы можете просто ввести команду run. Поскольку программа уже запущена, GDB спрашивает нас, хотите ли вы перезапустить ее с самого начала, и вы отвечаете «да».

Теперь экран будет выглядеть так:

    24            y[k] = y[k-1];
    25      }
    26
    27      void insert(int new_y)
    28      {  int j;
    29
 *> 30         if (num_y = 0) { // y empty so far, easy case
    31            y[0] = new_y;
    32            return;
    33         }
    34         // need to insert just before the first y
    35         // element that new_y is less than
    36         for (j = 0; j < num_y; j++) {
    37            if (new_y < y[j]) {
    38               // shift y[j], y[j+1],... rightward               .
 File: ins.c    Procedure: insert    Line: 30      pc: 0x80483fc
--------------------------------------------------------------------------
(gdb) condition 1 num_y==1
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n)
Starting program: /debug/insert_sort 12 5

Breakpoint 1, insert (new_y=5) at ins.c:30
(gdb)

Мы снова применяем принцип подтверждения: поскольку num_y равно 1, строку 31 следует пропустить и выполнение должно перейти к строке 36. Но нам нужно это подтвердить, поэтому мы даем команду next, чтобы перейти к следующей строке:

    24            y[k] = y[k-1];
    25      }
    26
    27      void insert(int new_y)
    28      {  int j;
    29
 *  30         if (num_y = 0) { // y empty so far, easy case
    31            y[0] = new_y;
    32            return;
    33         }
    34         // need to insert just before the first y
    35         // element that new_y is less than
  > 36         for (j = 0; j < num_y; j++) {
    37            if (new_y < y[j]) {
    38               // shift y[j], y[j+1],... rightward               .
 File: ins.c    Procedure: insert    Line: 36      pc: 0x8048406
--------------------------------------------------------------------------
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n)
Starting program: /debug/insert_sort 12 5

Breakpoint 1, insert (new_y=5) at ins.c:30
(gdb) next
(gdb)

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

    39               // before inserting new_y
    40               scoot_over(j);
    41               y[j] = new_y;
    42               return;
    43            }
    44         }
  > 45      }
    46
    47      void process_data()
    48      {
    49         for (num_y = 0; num_y < num_inputs; num_y++)
    50            // insert new y in the proper place
    51            // among y[0],...,y[num_y-1]
    52            insert(x[num_y]);
    53      }                                                          .
 File: ins.c    Procedure: insert    Line: 45       pc: 0x804844d
--------------------------------------------------------------------------
The program being debugged has been started already.
Start it from the beginning? (y or n)
Starting program: /debug/insert_sort 12 5

Breakpoint 1, insert (new_y=5) at ins.c:30
(gdb) next
(gdb) next
(gdb)

Посмотрите, где сейчас находится стрелка в верхнем подокне — мы перешли прямо со строки 37 на строку 45! Это настоящий сюрприз. Мы не выполнили ни одной итерации цикла. Однако помните, что сюрпризы — это хорошо, потому что они подсказывают вам, где находятся ошибки.

Единственный способ, при котором цикл в строке 36 мог вообще не выполнять итераций, — это если условие j < num_y в строке 36 не выполнялось, даже когда j было равно 0. Однако вы знаете, что num_y равно 1, потому что вы сейчас находитесь в этой функции. после наложения условия num_y==1 на точку останова. Или, по крайней мере, вы думаете, что знаете это. Опять же, вы это не подтвердили. Проверьте это сейчас:

(gdb) print num_y
$2 = 0

Разумеется, условие num_y==1 выполнялось, когда вы вводили функцию insert(), но, очевидно, с тех пор num_y изменилось. Каким-то образом num_y стало 0 после того, как вы вошли в эту функцию. Но как?

Как упоминалось ранее, принцип подтверждения не говорит вам, что это за ошибка, но дает нам подсказки о том, где она может находиться. В этом случае вы теперь обнаружили, что это место находится где-то между строками 30 и 36. И вы можете сузить этот диапазон еще больше, потому что вы видели, что строки с 31 по 33 были пропущены, а строки с 34 по 35 являются комментариями. Другими словами, загадочное изменение значения num_y произошло либо в строке 30, либо в строке 36.

Сделав небольшой перерыв — часто лучшая стратегия отладки! — мы внезапно понимаем, что ошибка — это классическая ошибка, которую часто допускают начинающие (и, как ни странно, опытные) программисты на языке C: в строке 30 мы использовали = вместо ==, превратив тест на равенство в присваивание.

Видите ли вы, как таким образом возникает бесконечный цикл? Ошибка в строке 30 создает ситуацию постоянных качелей, в которой часть num_y++ строки 49 неоднократно увеличивает num_y от 0 до 1, в то время как ошибка в строке 30 неоднократно возвращает значение этой переменной обратно в 0.

Итак, исправим эту унизительную ошибку (а какие из них не унизительны?), перекомпилируем и попробуем запустить программу еще раз:

$ insert_sort 12 5
5
0

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

Вспомните из псевдокода, что здесь должна делать ваша программа: Изначально массив y пуст. Предполагается, что первая итерация цикла в строке 49 помещает 12 в y[0]. Затем во второй итерации число 12 должно быть сдвинуто на одну позицию в массиве, чтобы освободить место для вставки числа 5. Вместо этого число 5, похоже, заменило число 12.

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

    24            y[k] = y[k-1];
    25      }
    26
    27      void insert(int new_y)
    28      {  int j;
    29
 *> 30         if (num_y == 0) { // y empty so far, easy case
    31            y[0] = new_y;
    32            return;
    33         }
    34         // need to insert just before the first y
    35         // element that new_y is less than
    36         for (j = 0; j < num_y; j++) {
    37            if (new_y < y[j]) {
    38               // shift y[j], y[j+1],... rightward               .
 File: ins.c    Procedure: insert    Line: 30      pc: 0x80483fc
--------------------------------------------------------------------------
The program being debugged has been started already.
Start it from the beginning? (y or n)

`/debug/insert_sort' has changed; re-reading symbols.
Starting program: /debug/insert_sort 12 5

Breakpoint 1, insert (new_y=5) at ins.c:30
(gdb)

Обратите внимание на строку, которая объявляет

`/debug/insert_sort' has changed; re-reading symbols.

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

Опять же, тот факт, что нам не пришлось выходить из GDB перед перекомпиляцией нашей программы, является большим удобством по нескольким причинам. Во-первых, вам не нужно заново формулировать аргументы командной строки; вы просто набираете run, чтобы перезапустить программу. Во-вторых, GDB сохраняет установленную вами точку останова, поэтому вам не нужно вводить ее снова. Здесь у вас есть только одна точка останова, но обычно их несколько, и тогда это становится настоящей проблемой. Эти удобства избавляют вас от необходимости печатать и, что более важно, избавляют вас от практических отвлекающих факторов и позволяют лучше сосредоточиться на фактической отладке.

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

Теперь давайте попробуем еще раз пройтись по коду. Как и раньше, программа должна пропустить строку 31, но, надеюсь, на этот раз она дойдет до строки 37, в отличие от ситуации ранее. Давайте проверим это, дважды выполнив следующую команду:

    31            y[0] = new_y;
    32            return;
    33         }
    34         // need to insert just before the first y
    35         // element that new_y is less than
    36         for (j = 0; j < num_y; j++) {
  > 37            if (new_y < y[j]) {
    38               // shift y[j], y[j+1],... rightward
    39               // before inserting new_y
    40               scoot_over(j);
    41               y[j] = new_y;
    42               return;
    43            }
    44         }
    45      }                                                          .
 File: ins.c    Procedure: insert    Line: 37      pc: 0x8048423
--------------------------------------------------------------------------
`/debug/insert_sort' has changed; re-reading symbols.
Starting program: /debug/insert_sort 12 5

Breakpoint 1, insert (new_y=5) at ins.c:30
(gdb) next
(gdb) next
(gdb)

Мы действительно достигли строки 37.

На данный момент мы считаем, что условие в if в строке 37 должно выполняться, поскольку new_y должно быть равно 5, а y[0] должно быть равно 12 с первой итерации. Выходные данные GDB подтверждают первое предположение. Давайте проверим последнее:

(gdb) print y[0]
$3 = 12

Теперь, когда это предположение также подтверждено, введите команду next, которая приведет вас к строке 40. Предполагается, что функция scoot_over() переместит число 12 в следующую позицию массива, чтобы освободить место для числа 5. Вы должны проверить, так ли это на самом деле. Здесь вы стоите перед важным выбором. Вы можете снова ввести команду next, что приведет к остановке GDB на строке 41; функция scoot_over() будет выполнена, но GDB не остановится внутри этой функции. Однако, если бы вы вместо этого ввели команду step, GDB остановился бы на строке 23, и это позволило бы вам выполнить одношаговую операцию в scoot_over().

Следуя нисходящему подходу к отладке, описанному в разделе 1.3.3, мы выбираем команду next вместо step в строке 40. Когда GDB останавливается на строке 41, вы можете посмотреть на y, чтобы убедиться, что функция выполнила свою работу правильно. Если эта гипотеза подтвердится, вы избежите трудоемкой проверки детальной работы функции scoot_over(), которая никак не способствовала бы исправлению текущей ошибки. Если вам не удалось убедиться, что функция работает правильно, вы можете снова запустить программу в отладчике и ввести функцию с помощью step, чтобы проверить детальную работу функции и, возможно, определить, где она идет не так.

Итак, когда вы дойдете до строки 40, введите next, получив

    31            y[0] = new_y;
    32            return;
    33         }
    34         // need to insert just before the first y
    35         // element that new_y is less than
    36         for (j = 0; j < num_y; j++) {
    37            if (new_y < y[j]) {
    38               // shift y[j], y[j+1],... rightward
    39               // before inserting new_y
    40               scoot_over(j);
  > 41               y[j] = new_y;
    42               return;
    43            }
    44         }
    45      }                                                          .
 File: ins.c   Procedure: insert    Line: 41      pc: 0x8048440
--------------------------------------------------------------------------
(gdb) next
(gdb) next
(gdb)

Правильно ли scoot_over() сместил 12? Давай проверим:

(gdb) print y
$4 = {12, 0, 0, 0, 0, 0, 0, 0, 0, 0}

Очевидно нет. Проблема действительно заключается в scoot_over(). Давайте удалим точку останова в начале метода insert() и поместим ее в scoot_over(), опять же с условием, что мы остановимся на ней во время второй итерации строки 49:

(gdb) clear 30
Deleted breakpoint 1
(gdb) break 23
Breakpoint 2 at 0x80483c3: file ins.c, line 23.
(gdb) condition 2 num_y==1

Теперь снова запустите программу:

    15         num_inputs = ac - 1;
    16         for (i = 0; i < num_inputs; i++)
    17         x[i] = atoi(av[i+1]);
    18      }
    19
    20      void scoot_over(int jj)
    21      {  int k;
    22
 *> 23         for (k = num_y-1; k > jj; k++)
    24            y[k] = y[k-1];
    25      }
    26
    27      void insert(int new_y)
    28      {  int j;
    29                                                                 .
 File: ins.c    Procedure: scoot_over    Line: 23      pc: 0x80483c3
--------------------------------------------------------------------------
(gdb) condition 2 num_y==1
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n)
Starting program: /debug/insert_sort 12 5

Breakpoint 2, scoot_over (jj=0) at ins.c:23
(gdb)

Еще раз следуйте принципу подтверждения: подумайте о том, чего вы ожидаете, а затем попытайтесь подтвердить, что это действительно произойдет. В этом случае функция должна переместить 12 на следующую позицию в массиве y, а это означает, что цикл в строке 23 должен пройти ровно одну итерацию. Давайте пройдемся по программе, неоднократно вводя команду next, чтобы подтвердить это ожидание:

    15         num_inputs = ac - 1;
    16         for (i = 0; i < num_inputs; i++)
    17         x[i] = atoi(av[i+1]);
    18      }
    19
    20      void scoot_over(int jj)
    21      {  int k;
    22
  * 23         for (k = num_y-1; k > jj; k++)
    24            y[k] = y[k-1];
  > 25      }
    26
    27      void insert(int new_y)
    28      {  int j;
    29                                                                 .
 File: ins.c    Procedure: scoot_over    Line: 25      pc: 0x80483f1
--------------------------------------------------------------------------
The program being debugged has been started already.
Start it from the beginning? (y or n)
Starting program: /debug/insert_sort 12 5

Breakpoint 2, scoot_over (jj=0) at ins.c:23
(gdb) next
(gdb) next
(gdb)

Здесь мы снова получаем сюрприз: мы сейчас находимся на строке 25, даже не касаясь строки 24 — цикл не выполнил ни одной итерации, ни одной итерации, которую мы ожидали. Видимо ошибка в строке 23.

Как и в случае с предыдущим циклом, который неожиданно не выполнил ни одной итерации своего тела, должно быть, условие цикла не было выполнено в самом начале цикла. Так ли это в данном случае? Условие цикла в строке 23: k > jj. Мы также знаем из этой строки, что начальное значение k равно num_y-1, и мы знаем из нашего условия точки останова, что последняя величина равна 0. Наконец, экран GDB сообщает нам, что jj равен 0. Таким образом, условие k > jj не было выполнено, когда начался цикл.

Таким образом, мы неверно указали либо условие цикла k > jj, либо инициализацию k = num_y-1. Учитывая, что 12 должно было переместиться из y[0] в y[1] на первой и единственной итерации цикла, то есть строка 24 должна была выполняться с k = 1, мы понимаем, что инициализация цикла неверна. Должно было быть k = num_y.

Исправьте ошибку, перекомпилируйте программу и запустите ее еще раз (вне GDB):

$ insert_sort 12 5
Segmentation fault

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

scanf("%d",x);

вместо

scanf("%d",&x);

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

Чтобы воспользоваться этим, вам нужно запустить insert_sort в GDB и воссоздать ошибку сегментации. Сначала удалите точку останова. Как было показано ранее, для этого вам нужно указать номер строки точки останова. Возможно, вы уже помните это, но найти его легко: либо прокрутите окно TUI (используя клавиши со стрелками вверх и вниз), ища строки, отмеченные звездочками, либо используйте команду GDB info break. Затем удалите точку останова с помощью команды clear:

(gdb) clear 30

Теперь снова запустите программу в GDB:

    19
    20      void scoot_over(int jj)
    21      {  int k;
    22
    23         for (k = num_y; k > jj; k++)
  > 24            y[k] = y[k-1];
    25      }
    26
    27      void insert(int new_y)
    28      {  int j;
    29
    30         if (num_y == 0) { // y empty so far, easy case
    31            y[0] = new_y;                                        .
 File: ins.c    Procedure: scoot_over    Line: 24      pc: 0x8048538
--------------------------------------------------------------------------
Start it from the beginning? (y or n)

`/debug/insert_sort' has changed; re-reading symbols.
Starting program: /debug/insert_sort 12 5

Program received signal SIGSEGV, Segmentation fault.
0x08048538 in scoot_over (jj=0) at ins.c:24
(gdb)

Как и было обещано, GDB сообщает нам, где именно произошла ошибка сегментации, в строке 24, и, конечно же, очевидно, что здесь задействован индекс массива, а именно k. Либо k было достаточно большим, чтобы превысить количество элементов в y, либо k-1 было отрицательным. Очевидно, что первым делом необходимо определить значение k:

(gdb) print k
$4 = 584

Ого! В коде размер y был рассчитан только на 10 элементов, поэтому это значение k действительно выходит за пределы допустимого диапазона. Теперь нам нужно выяснить причину.

Прежде всего определите итерацию большого цикла в строке 49, во время которой произошла ошибка сегментации.

(gdb) print num_y
$5 = 1

(gdb) напечатайте num_y $5 = 1

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

k++

(напомним, что вы ранее проверяли две другие части этой строки), это, должно быть, виновник. Сделав еще один перерыв, чтобы прочистить голову, мы с некоторым смущением понимаем, что это должно было быть k--.

Исправьте эту строку и еще раз перекомпилируйте и запустите программу:

$ insert_sort 12 5
5
12

Вот это прогресс! Но работает ли программа для большего набора данных? Давайте попробуем один:

$ insert_sort 12 5 19 22 6 1
1
5
6
12
0
0

Теперь вы можете начать видеть свет в конце туннеля. Большая часть массива сортируется правильно. Первое число в списке, которое не отсортировано правильно, — 19, поэтому установите точку останова в строке 36, на этот раз с условием new_y == 19:[1]

(gdb) b 36
Breakpoint 3 at 0x804840d: file ins.c, line 36.
(gdb) cond 3 new_y==19

Затем запустите программу в GDB (обязательно используйте те же аргументы: 12 5 19 22 6 1). Когда вы достигаете точки останова, вы подтверждаете, что массив y был отсортирован правильно до этой точки:

    31            y[0] = new_y;
    32            return;
    33         }
    34         // need to insert just before the first y
    35         // element that new_y is less than
 *> 36         for (j = 0; j < num_y; j++) {
    37            if (new_y < y[j]) {
    38               // shift y[j], y[j+1],... rightward
    39               // before inserting new_y
    40               scoot_over(j);
    41               y[j] = new_y;
    42               return;
    43            }                                                    .
 File: ins.c    Procedure: insert    Line: 36      pc: 0x8048564
--------------------------------------------------------------------------
Start it from the beginning? (y or n)

Starting program: /debug/insert_sort 12 5 19 22 6 1

Breakpoint 2, insert (new_y=19) at ins.c:36
   (gdb) p y
   $1 = {5, 12, 0, 0, 0, 0, 0, 0, 0, 0}
   (gdb)

Все идет нормально. Теперь попробуем определить, как программа поглощает 19. Будем проходить код построчно. Обратите внимание: поскольку 19 не меньше 5 или 12, мы не ожидаем, что условие в операторе if в строке 37 будет выполнено. Нажав несколько раз n, мы оказываемся на строке 45:

    35         // element that new_y is less than
  * 36         for (j = 0; j < num_y; j++) {
    37            if (new_y < y[j]) {
    38               // shift y[j], y[j+1],... rightward
    39               // before inserting new_y
    40               scoot_over(j);
    41               y[j] = new_y;
    42               return;
    43            }
    44         }
  > 45      }
    46
    47      void process_data()
 File: ins.c    Procedure: insert    Line: 45      pc: 0x80485c4
--------------------------------------------------------------------------
(gdb) n
(gdb) n
(gdb) n
(gdb) n
(gdb) n
(gdb)

Мы находимся на строке 45, собираемся выйти из цикла, а с 19 вообще ничего не сделали! Некоторая проверка показывает, что наш код не был написан для важного случая, а именно случая, когда new_y больше, чем любой элемент, который мы обработали до сих пор — оплошность, также обнаруженная в комментариях к строкам 34 и 35:

// need to insert just before the first y
// element that new_y is less than

(нужно вставить только перед первым y элемент, который меньше чем new_y)

Чтобы справиться с этим случаем, добавьте следующий код сразу после строки 44:

// one more case:  new_y > all existing y elements
y[num_y] = new_y;

Затем перекомпилируйте и попробуйте еще раз:

$ insert_sort 12 5 19 22 6 1
1
5
6
12
19
22

Это правильный результат, и последующее тестирование также дает правильные результаты.

  1. Самое время начать использовать общепринятые сокращения для команд. К ним относятся b для break, ib для info break, cond для condition, r для run, n для next, s для step, c для continue, p для print и bt для backtrace.

1.7.2 Тот же сеанс в DDD

Давайте посмотрим, как описанный выше сеанс GDB выполнялся бы в DDD. Конечно, нет необходимости повторять все шаги; просто сосредоточьтесь на отличиях от GDB.

Запуск DDD аналогичен запуску GDB. Скомпилируйте исходный код с помощью GCC с опцией -g, а затем введите

$ ddd insert_sort

для вызова DDD. В GDB вы запускаете выполнение программы с помощью команды запуска, включая аргументы, если таковые имеются. В DDD вы нажимаете Program | Run, после чего вы увидите экран, показанный на рисунке 1-8.

Рисунок 1-8. Команда запуска DDD

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

В сеансе отладки GDB мы некоторое время запускали нашу программу в отладчике, а затем приостанавливали ее, используя CTRL-C, чтобы исследовать кажущийся бесконечным цикл. В DDD мы приостанавливаем работу программы, нажимая Interrupt в меню команд. Экран DDD теперь выглядит так, как показано на рисунке 1-9. Поскольку DDD действует как интерфейс для GDB, этот щелчок мыши преобразуется в операцию CTRL-C в GDB, которую можно увидеть в консоли.

Рисунок 1-9. После прерывания

Следующим шагом в описанном выше сеансе GDB была проверка переменной num_y. Как было показано ранее в разделе «Основные операции отладчика», в DDD это можно сделать, наведя указатель мыши на любой экземпляр num_y в окне исходного кода.

Таким же образом вы также можете проверять целые массивы. Например, в какой-то момент сеанса GDB вы распечатали весь массив y. В DDD вы просто перемещаете указатель мыши на любой экземпляр y в окне исходного кода. Если вы наведете курсор на y в выражении y[j] в строке 30, отобразится так, как показано на рисунке 1-10. Рядом с этой строкой появилось поле подсказки значения, показывающее содержимое y.

Рисунок 1-10. Проверка массива

Следующим действием в сеансе GDB была установка точки останова в строке 30. Мы уже объясняли, как устанавливать точки останова в DDD, но как насчет установки условия на точку останова, как это необходимо в данном случае? Вы можете установить условие, щелкнув правой кнопкой мыши значок знака остановки в строке точки останова и выбрав Properties. Появится всплывающее окно, как показано на рисунке 1-11. Затем введите свое условие, num_y==1.

Рисунок 1-11. Наложение условия на точку останова

Чтобы затем повторно запустить программу, нажмите Run в меню команд. Как и в случае с командой run GDB без аргументов, эта кнопка запускает программу с последним предоставленным набором аргументов.

Аналогами команд n и s GDB в DDD являются кнопки Next и Step в меню команд. Аналогом клавиши c GDB является кнопка Cont.

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

1.7.3 Сессия в Eclipse

Теперь давайте посмотрим, как описанный выше сеанс GDB выполнялся бы в Eclipse. Как и в нашей презентации о DDD, нет необходимости повторять все шаги; мы просто сосредоточимся на отличиях от GDB.

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

Мы предполагаем, что вы уже создали свой проект C/C++[2].

При первом запуске/отладке программы вам потребуются конфигурации запуска и отладки. Они определяют имя вашего исполняемого файла (и к какому проекту он принадлежит), его аргументы командной строки (если есть), его специальную переменную среду оболочки (если есть), выбранный вами отладчик и так далее. Конфигурация запуска используется для запуска программы вне отладчика, а конфигурация отладки — внутри отладчика. Обязательно создайте обе конфигурации в следующем порядке:

  1. Выберите Run | Open Run Dialog.

  2. Щелкните правой кнопкой мыши C/C++ Local Applications и выберите New.

  3. Выберите вкладку Main и заполните конфигурацию запуска, имена проекта и исполняемых файлов (Eclipse, вероятно, предложит их вам), а также установите флажок Connect process input and output to a terminal, если у вас есть терминальный ввод-вывод.

  4. Если у вас есть аргументы командной строки или специальные переменные среды, перейдите на вкладку Arguments или Environment и заполните нужные параметры.

  5. Выберите вкладку Debugger, чтобы узнать, какой отладчик используется. Вам, вероятно, не придется это трогать, но приятно понимать, что существует базовый отладчик, возможно, GDB.

  6. Нажмите Apply (если потребуется) и Close, чтобы завершить создание конфигурации запуска.

  7. Начните создавать конфигурацию отладки, выбрав Run | Open Debug Dialog. Eclipse, вероятно, повторно использует информацию, которую вы предоставили в конфигурации запуска, как показано на рисунке 1-12, или вы можете изменить ее, если хотите. Снова нажмите Apply (если потребуется) и Close, чтобы завершить создание конфигурации отладки.

Рисунок 1-12. Диалоговое окно конфигурации отладки

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

Чтобы начать сеанс отладки, необходимо перейти в проекцию Debug, выбрав Window | Open Perspective | Debug. (Существуют различные ярлыки, которые мы предоставим вам для изучения.)

Когда вы впервые выполняете действие запуска или отладки, вы делаете это через Run | Open Run Dialog или Run | Open Debug Dialog еще раз, в зависимости от обстоятельств, чтобы указать, какую конфигурацию использовать. Однако после этого просто выберите Run | Run или Run | Debug, любой из которых повторно запустит последнюю конфигурацию отладки.

Фактически, в случае отладки есть более быстрый способ запуска отладки: щелкнуть значок Debug прямо под надписью Navigate (см. рисунок 1-13). Однако обратите внимание: всякий раз, когда вы запускаете новый запуск отладки, вам необходимо завершить существующие, щелкнув красный квадрат Terminate; один находится на панели инструментов представления Debug, а другой — в представлении Console. В представлении Debug также имеется значок с двойным крестиком Remove All Terminated Launches (удалить все завершенные запуски).

Рисунок 1-13. Начало отладки

На рисунке 1-13 показан экран, который появляется после запуска отладки. Можно установить начальную строку в диалоговых окнах отладки Eclipse, но обычно они по умолчанию размещают автоматическую точку останова в первой исполняемой строке кода. На рисунке это видно по символу точки останова в левом поле строки.

{ get_args(argc,argv);

Эта строка также выделена, поскольку именно ее вы собираетесь выполнить. Выполните ее, щелкнув значок Resume на панели инструментов представления Debug (над полем, которое появилось в окне, потому что вы навели указатель мыши на этот значок).

Напомним, что в примере сеанса GDB первая версия программы имела бесконечный цикл, и программа зависала. Здесь, конечно, вы увидите тот же симптом, но без вывода данных в представлении консоли. Вам нужно убить программу. Однако вы не хотите делать это, щелкнув один из красных квадратов Terminate, потому что это также приведет к завершению вашего основного сеанса GDB. Вы хотите остаться в GDB, чтобы посмотреть, где вы находитесь в коде, т. е. где находится бесконечный цикл, проверить значения переменных и так далее. Поэтому вместо операции Terminate выберите Suspend, щелкнув значок справа от Resume на панели инструментов представления Debug. (В литературе по Eclipse эту кнопку иногда называют Pause, поскольку ее символ аналогичен символу операции паузы в медиаплеерах.)

После нажатия кнопки Suspend ваш экран будет выглядеть так, как показано на рисунке 1-14. Вы увидите, что непосредственно перед этой операцией Eclipse собирался выполнить строку

for (j = 0; j < num_y; j++) {

Рисунок 1-14. Программа приостановлена

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

Вспомните еще раз наш сеанс GDB выше. После исправления пары ошибок в вашей программе возникла ошибка сегментации. На рисунке 1-15 показан экран Eclipse в этот момент.

Рисунок 1-15. Ошибка сегментации

Произошло следующее: мы нажали Resume, поэтому наша программа работала, но внезапно остановилась на строке

y[k] = y[k-1];

из-за ошибки сегментации. Как ни странно, Eclipse не объявляет об этом на вкладке Problems, но делает это на вкладке Debug, при этом сообщение об ошибке

(Suspended'SIGSEGV' received. Description: Segmentation fault.)

снова отображается на рисунке 1-15.

На этой вкладке вы видите, что ошибка произошла в функции scoot_over(), которая была вызвана из insert(). Вы можете снова запросить значения переменных и обнаружить, например, что k = 544 — это выход за пределы диапазона, как в примере с GDB.

В примере с GDB вы также устанавливаете условные точки останова. Напомним, что в Eclipse точка останова устанавливается двойным щелчком по левому краю нужной строки. Чтобы сделать эту точку останова условной, щелкните правой кнопкой мыши символ точки останова для этой строки и выберите Breakpoint Properties... | New | Common и заполните условие в диалоговом окне. Диалоговое окно показано на рисунке 1-16.

Рисунок 1-16. Создание условной точки останова

Вспомните также, что во время сеанса GDB вы иногда запускали свою программу вне GDB, в отдельном окне терминала. Вы можете легко сделать это и в Eclipse, выбрав Run | Run. Результаты, как обычно, будут отображаться в представлении Console.

  1. Поскольку эта книга посвящена отладке, а не управлению проектами, мы не будем здесь много говорить о создании и сборке проектов в Eclipse. Однако вкратце можно сказать, что вы создаете проект следующим образом: выберите File | New | Project; выберите проект C (или C++); введите название проекта; выберите Executable | Finish. Makefile создается автоматически. Вы создаете (т. е. компилируете и линкуете) свой проект, выбирая Project | Build Project.

1.8 Использование файлов запуска

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

Однако вам может потребоваться выйти из GDB до завершения отладки. Если вы уходите на перерыв или на день и не можете оставаться в системе, вам необходимо выйти из GDB. Чтобы не потерять настройки, вы можете поместить свои команды для точек останова и других настроек в файл запуска GDB, и тогда они будут загружаться автоматически при каждом запуске GDB.

Файлы запуска GDB по умолчанию называются .gdbinit. Вы можете разместить один файл в своем домашнем каталоге для общих целей, а другой — в каталоге, содержащем конкретный проект, для целей, специфичных для этого проекта. Например, вы можете поместить команды для установки точек останова в файле запуска в последнем каталоге. В файле .gdbinit в домашнем каталоге вы можете сохранить некоторые разработанные вами макросы общего назначения, как обсуждалось в главе 2.

GDB считывает файл запуска в вашем домашнем каталоге перед загрузкой исполняемого файла. Итак, если бы у вас была команда в файле .gdbinit вашего домашнего каталога, например

break g

если сказать прервать функцию g(), то при запуске GDB всегда будет жаловаться, что ему не известна эта функция. Однако эта строка будет в порядке в файле запуска вашего локального каталога проекта, поскольку локальный файл запуска считывается после загрузки исполняемого файла (и его таблицы символов). Обратите внимание, что эта особенность GDB подразумевает, что лучше не размещать проекты программирования в своем домашнем каталоге, поскольку вы не сможете поместить информацию, специфичную для проекта, в .gdbinit.

Вы можете указать файл запуска во время вызова GDB. Например,

$ gdb -command=z x

говорит запустить GDB с исполняемым файлом x, сначала прочитав команды из файла z. Кроме того, поскольку DDD — это всего лишь интерфейс для GDB, вызов DDD также вызовет файлы запуска GDB.

Наконец, вы можете настроить DDD различными способами, выбрав Edit | Preferences. Для Eclipse последовательность выглядит так: Window | Preferences.

Глава 2
Остановитесь, чтобы осмотреться

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

Если бы все, что мог сделать отладчик, — это запустить программу, от него было бы мало пользы. Мы, конечно, могли бы сделать то же самое, но более эффективно. Полезность отладчика заключается в том, что мы можем дать ему указание приостановить выполнение программы. После приостановки отладчик дает нам возможность проверить переменные, проследить путь выполнения и многое другое.

2.1 Механизмы паузы

Есть три способа дать указание GDB приостановить выполнение вашей программы:

Как ни странно (на первый взгляд), все три механизма в документации GDB называются точками останова (breakpoints). Это может быть связано с тем, что они имеют много одинаковых атрибутов и команд. Например, вы узнаете о команде удаления GDB, которая, как сказано в справке, удаляет точку останова:

(gdb) help delete
Delete some breakpoints or auto-display expressions.
Arguments are breakpoint numbers with spaces in between.
To delete all breakpoints, give no argument.

Однако опытный пользователь GDB знает, что справочная информация на самом деле означает, что команда delete удаляет точки останова, точки наблюдения и точки перехвата!

2.2 Обзор точек останова

Точка останова подобна растяжке внутри программы: вы устанавливаете точку останова в определенном «месте» внутри вашей программы, и когда выполнение достигает этой точки, отладчик приостанавливает выполнение программы (и, в случае текстового отладчика, такого как GDB, предоставляет вам командную строку).

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

Ниже показан фрагмент сеанса отладки, чтобы проиллюстрировать, что происходит, когда GDB прерывается на строке кода. Во фрагменте мы перечисляем часть исходного кода, ставим точку останова в строке 35 программы, а затем запускаем программу. GDB достигает точки останова и делает паузу.

(gdb) list
30
31             /* Get the size of file in bytes */
32             if ((fd = open(c.filename, O_RDONLY)) == -1)
33                     (void) die(1, "Can't open file.");
34             (void) stat(c.filename, &fstat);
35             c.filesize = fstat.st_size;
36
(gdb) break 35
Breakpoint 1 at 0x8048ff3: file bed.c, line 35.
(gdb) run
Starting program: binary_editor/bed

Breakpoint 1, main (argc=1, argv=0xbfa3e1f4) at bed.c:35
35              c.filesize = fstat.st_size;
(gdb)

Давайте внесем ясность в то, что здесь произошло: GDB выполнил строки с 30 по 34, но строка 35 еще не выполнилась. Это может сбить с толку, поскольку многие люди думают, что GDB отображает строку кода, которая была выполнена последней, хотя на самом деле он показывает, какая строка кода должна быть выполнена. В этом случае GDB сообщает нам, что строка 35 — это следующая строка исходного кода, которую необходимо выполнить. Когда выполнение GDB достигает точки останова в строке 35, вы можете считать, что GDB сидит и ждет между строками 34 и 35 исходного кода.

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

2.3 Отслеживание точек останова

Каждой создаваемой точке останова (включая точки останова, точки наблюдения и точки перехвата) присваивается уникальный целочисленный идентификатор, начиная с 1. Этот идентификатор используется для выполнения различных операций над точкой останова. Отладчик также включает в себя средства вывода списка всех точек останова и их свойств.

2.3.1 Списки точек останова в GDB

Когда вы создаете точку останова, GDB сообщает вам присвоенный ей номер. Например, точка останова, установленная в этом примере

(gdb) break main
Breakpoint 2 at 0x8048824: file efh.c, line 16.

был присвоен номер 2. Если вы когда-нибудь забудете, какой номер был присвоен какой точке останова, вы можете напомнить себе с помощью команды info breakpoints:

(gdb) info breakpoints
Num Type           Disp Enb Address    What
1   breakpoint     keep y   0x08048846 in Initialize_Game at efh.c:26
2 breakpoint       keep y   0x08048824 in main at efh.c:16
      breakpoint already hit 1 time
3 hw watchpoint    keep y              efh.level
4 catch fork       keep y

Мы увидим, что эти идентификаторы используются для выполнения различных операций над точками останова. Чтобы сделать это более конкретным, можно привести очень быстрый пример.

В предыдущем разделе вы видели команду delete. Вы можете удалить точку останова 1, точку наблюдения 3 и точку перехвата 4, используя команду delete с идентификаторами этих точек останова:

(gdb) delete 1 3 4

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

2.3.2 Списки точек останова в DDD

Пользователи DDD в основном выполняют операции управления точками останова с помощью интерфейса point-and-click (укажи и щелкни), поэтому идентификаторы точек останова менее важны для пользователей DDD, чем для пользователей GDB. Выбор Source | Breakpoints откроет окно Breakpoints and Watchpoints со списком всех ваших точек останова, как показано на рисунке 2-1.

Рисунок 2-1. Просмотр точек останова в DDD

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

Обратите внимание, что при желании вы можете держать это окно Breakpoints and Watchpoints открытым постоянно, перетаскивая его в удобную часть экрана.

2.3.3 Списки точек останова в Eclipse

Перспектива Debug включает представление Breakpoints. Например, на рисунке 2-2 вы видите, что в настоящее время у вас есть две точки останова: в строках 30 и 52 файла ins.c.

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

Рисунок 2-2. Просмотр точек останова в Eclipse

2.4 Установка точек останова

Инструменты отладки обычно предлагают различные механизмы для установки точек останова.

2.4.1 Установка точек останова в GDB

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

Существует много разных способов указать точку останова в GDB; вот некоторые из наиболее распространенных методов:

break function

Установите точку останова на входе (первая исполняемая строка) функции function(). Вы видели пример этого в разделе 2.3.1; команда

(gdb) break main

устанавливает точку останова при входе в main().

break line_number

Установите точку останова в строке line_number текущего активного файла исходного кода. Для многофайловых программ это либо файл, содержимое которого вы последний раз просматривали с помощью команды list, либо файл, содержащий функцию main(). Вы видели пример этого в разделе 2.2; команда

(gdb) break 35

устанавливает точку останова в строке 35 файла bed.c.

break filename:line_number

Установите точку останова в строке line_number файла исходного кода filename. Если filename отсутствует в вашем текущем рабочем каталоге, вы можете указать относительный или полный путь, чтобы помочь GDB найти файл, например:

(gdb) break source/bed.c:35
break filename:function

Установите точку останова при входе функции function() в файле filename. Использование этой формы может потребоваться для перегруженных функций или для программ, использующих статические функции с одинаковыми именами, например:

(gdb) break bed.c:parseArguments

Как мы увидим, когда точка останова установлена, она остается в силе до тех пор, пока вы ее не удалите, не отключите или не закроете GDB. Однако временная точка останова (temporary breakpoint) — это точка останова, которая автоматически удаляется после первого достижения. Временная точка останова устанавливается с помощью команды tbreak, которая принимает аргументы того же типа, что и команда break. Например, tbreak foo.c:10 устанавливает временную точку останова в строке 10 файла foo.c.

Необходимо сделать комментарий к функциям с таким же именем. C++ позволяет перегружать функции (определять функции с тем же именем). Даже C позволяет вам сделать это, если вы используете квалификатор static для объявления функций с областью действия файла. Использование break function установит точку останова для всех функций с тем же именем. Если вы хотите установить точку останова в конкретном экземпляре функции, вам нужно быть недвусмысленным, например, указав номер строки в файле исходного кода в команде break.

Местоположение, в котором GDB фактически устанавливает точку останова, может отличаться от того, где вы запросили ее размещение. Это может несколько сбить с толку людей, плохо знакомых с GDB, поэтому давайте рассмотрим короткий пример, демонстрирующий эту особенность. Рассмотрим следующую короткую программу test1.c:

int main(void)
{
   int i;
   i = 3;

   return 0;
}

Скомпилируйте эту программу без оптимизации и попробуйте установить точку останова при входе в функцию main(). Вы могли бы подумать, что точка останова будет размещена в верхней части функции — либо в строке 1, либо в строке 2, либо в строке 3. Это были бы хорошие предположения относительно местоположения точки останова, но они ошибочны. Точка останова фактически установлена в строке 4.

$ gcc -g3 -Wall -Wextra -o test1 test1.c
$ gdb test1
(gdb) break main
Breakpoint 1 at 0x6: file test1.c, line 4.

Строка 4 вряд ли является первой строкой main(), так что же произошло? Как вы уже догадались, одна из проблем заключается в том, что эта строка является исполняемой. Напомним, что GDB на самом деле работает с инструкциями машинного языка, но благодаря волшебству расширенной таблицы символов GDB создает иллюзию работы со строками исходного кода. Обычно этот факт не имеет большого значения, но в данной ситуации он становится важным. На самом деле, объявление i генерирует машинный код, но это не тот код, который GDB считает полезным для наших целей отладки[1]. Поэтому, когда вы указали GDB на прерывание в начале функции main(), он установил точку останова в строке 4.

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

$ gcc -O9 -g3 -Wall -Wextra -o test1 test1.c
$ gdb test1
(gdb) break main
Breakpoint 1 at 0x3: file test1.c, line 6.

Мы попросили поставить точку останова в начале main(), но GDB поместил ее в последнюю строку main(). Что же случилось? Ответ тот же, что и раньше, но GCC взял на себя более активную роль. При включенной оптимизации GCC заметил, что, хотя мне было присвоено значение, оно никогда не использовалось. Поэтому, стремясь создать более эффективный код, GCC просто оптимизировал строки 3 и 4, убрав их из существования. GCC никогда не создавал машинные инструкции для этих строк. Таким образом, первая строка исходного кода, генерирующая машинные инструкции, оказывается последней строкой main(). Это одна из причин, по которой никогда не следует оптимизировать код, пока не завершится его отладка[2].

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

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

  1. Вы можете просмотреть машинный код, сгенерированный GCC, с помощью опции -S.
  2. На самом деле, некоторые отладчики могут действительно поперхнуться при работе с исполняемыми файлами, скомпилированными с включенной оптимизацией. GDB известен как один из немногих отладчиков, которые могут отлаживать оптимизированный код, но, как вы видели, результаты могут быть сомнительными.

2.4.2 Установка точек останова в DDD

Чтобы установить точки останова с помощью DDD, найдите строку кода, в которой вы хотите установить точку останова, в окне исходного кода. Наведите курсор на пустое место в этой строке и щелкните правой кнопкой мыши, чтобы открыть всплывающее меню. Перетащите указатель мыши вниз, пока не будет выделен вариант Set Breakpoint, а затем отпустите кнопку мыши. Вы должны увидеть красный знак остановки рядом со строкой кода, в которой вы установили точку останова. Если вы не делаете ничего особенного с точкой останова, например, не делаете ее условной (что будет обсуждаться в разделе 2.10), можно просто дважды щелкнуть заданную строку.

Если вы экспериментировали с DDD, возможно, вы заметили, что когда вы нажимаете правую кнопку рядом со строкой кода, во всплывающем меню появляется пункт Set Temporary Breakpoint. Вот как вы устанавливаете временную точку останова (точку останова, которая исчезает после первого достижения) с помощью DDD, который вызывает команду GDB tbreak.

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

2.4.3 Установка точек останова в Eclipse

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

insert(x[num_y]);

на рисунке 2-2.

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

2.5 Расширенный пример GDB

Информации было много, поэтому краткий пример установки точек останова, которому вы можете следовать, вполне оправдан. Рассмотрим следующий многофайловый код C:

main.c

#include <stdio.h>
void swap(int *a, int *b);

int main(void)
{
   int i = 3;
   int j = 5;

   printf("i: %d, j: %d\n", i, j);
   swap(&i, &j);
   printf("i: %d, j: %d\n", i, j);

   return 0;
}

swapper.c

void swap(int *a, int *b)
{
   int c = *a;
   *a = *b;
   *b = c;
}

Скомпилируйте этот код и запустите GDB на исполняемом файле:

$ gcc -g3 -Wall -Wextra -c main.c swapper.c
$ gcc -o swap main.o swapper.o
$ gdb swap

Примечание

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

Установка точки останова в main() очень распространена при запуске сеанса отладки. Мы устанавливаем точку останова в первой строке этой функции[3].

(gdb) break main
Breakpoint 1 at 0x80483f6: file main.c, line 6.

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

(gdb) break swapper.c:1
Breakpoint 2 at 0x8048454: file swapper.c, line 1.
(gdb) break swapper.c:swap
Breakpoint 3 at 0x804845a: file swapper.c, line 3.
(gdb) break swap
Note: breakpoint 3 also set at pc 0x804845a.
Breakpoint 4 at 0x804845a: file swapper.c, line 3.

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

Давайте рассмотрим пример. Хотя точки останова были установлены в swapper.c, на самом деле мы не указали код из этого файла. Следовательно, фокус по-прежнему сосредоточен на main.c. Вы можете убедиться в этом, установив точку останова в строке 6. Когда вы устанавливаете эту точку останова без имени файла, GDB установит точку останова в строке 6 текущего активного файла:

(gdb) break 6
Breakpoint 5 at 0x8048404: file main.c, line 6.

Конечно же, фокус имеет main.c: при запуске GDB в файле, содержащем main(), устанавливается точка останова, заданная только по номеру строки. Вы можете изменить фокус, указав код из swapper.c:

(gdb) list swap
1   void swap(int *a, int *b)
2   {
3      int c = *a;
4      *a = *b;
5      *b = c;
6   }

Давайте проверим, что swapper.c теперь имеет фокус, попытавшись установить еще одну точку останова в строке 6:

(gdb) break 6
Breakpoint 6 at 0x8048474: file swapper.c, line 6.

Да, точка останова была установлена в строке 6 файла swapper.c. Далее установите временную точку останова в строке 4 файла swapper.c:

(gdb) tbreak swapper.c:4
Breakpoint 7 at 0x8048462: file swapper.c, line 4.

Наконец, используйте команду info breakpoints, которая была представлена в разделе 2.3.1, чтобы восхититься всеми установленными вами точками останова:

(gdb) info breakpoints
Num Type        Disp Enb Address    What
1 breakpoint    keep y   0x080483f6 in main at main.c:6
2 breakpoint    keep y   0x08048454 in swap at swapper.c:1
3 breakpoint    keep y   0x0804845a in swap at swapper.c:3
4 breakpoint    keep y   0x0804845a in swap at swapper.c:3
5 breakpoint    keep y   0x08048404 in main at main.c:9
6 breakpoint    keep y   0x08048474 in swap at swapper.c:6
7 breakpoint    del  y   0x08048462 in swap at swapper.c:4

Намного позже, когда вы закончите сеанс GDB, используйте команду quit, чтобы выйти из GDB:

(gdb) quit
$
  1. Напомним, что точка останова может находиться не строго в первой строке main(), но она будет близко. Однако, в отличие от нашего предыдущего примера, эта строка является исполняемой, поскольку она присваивает значение i.

2.6 Сохранение точек останова

Выше мы сказали «намного позже», чтобы подчеркнуть, что вам не следует выходить из GDB во время сеанса отладки. Например, когда вы нашли и исправили одну ошибку, но остались другие ошибки, вам не следует выходить из GDB, а затем снова входить в него, чтобы использовать новую версию вашей программы. Это было бы ненужной проблемой, и, что более важно, вам пришлось бы повторно вводить точки останова.

Если вы не выйдете из GDB при изменении и перекомпиляции вашего кода, то в следующий раз, когда вы дадите команду запуска GDB, GDB почувствует, что ваш код изменился, и автоматически перезагрузит новую версию.

Однако обратите внимание, что ваши точки останова могут «переместиться». Например, рассмотрим следующую простую программу:

main()
{  int x,y;
   x = 1;
   y = 2;
}

Компилируем, входим в GDB и устанавливаем точку останова в строке 4:

(gdb) l
1       main()
2       {  int x,y;
3          x = 1;
4          y = 2;
5       }
(gdb) b 4
Breakpoint 1 at 0x804830b: file a.c, line 4.
(gdb) r
Starting program: /usr/home/matloff/Tmp/tmp1/a.out

Breakpoint 1, main () at a.c:4
4          y = 2;

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

main()
{  int x,y;
   x = 1;
   x++;
   y = 2;
}

Затем перекомпилируйте (опять же помните, что вы не вышли из GDB) и снова введите команду запуска GDB:

(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
`/usr/home/matloff/Tmp/tmp1/a.out' has changed; re-reading symbols.

Starting program: /usr/home/matloff/Tmp/tmp1/a.out

Breakpoint 1, main () at a.c:4
4          x++;

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

у = 2;

к

х++;

Если вы присмотритесь повнимательнее, вы увидите, что точка останова на самом деле вообще не сдвинулась; она была в строке 4 и до сих пор находится в строке 4. Но эта строка больше не содержит оператора, в котором вы изначально установили точку останова. Таким образом, вам нужно будет переместить точку останова, удалив эту и установив новую. (В DDD сделать это гораздо проще; см. раздел 2.7.5.)

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

Для GDB и DDD ответ в некоторой степени — да. Вы можете поместить точки останова в файл запуска .gdbinit в каталоге, где находится исходный код (или в каталоге, из которого вы вызываете GDB).

Если вы используете Eclipse, вам повезло, потому что все ваши точки останова будут автоматически сохранены и восстановлены в следующем сеансе Eclipse.

2.7 Удаление и отключение точек останова

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

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

В этом разделе рассматривается удаление и отключение точек останова. Все сказанное относится и к точкам наблюдения.

2.7.1 Удаление точек останова в GDB

Если рассматриваемая точка останова действительно больше не нужна (возможно, эта конкретная ошибка была исправлена!), вы можете удалить эту точку останова. Есть две команды, которые используются для удаления точек останова в GDB. Команда delete используется для удаления точек останова на основе их идентификатора, а команда clear используется для удаления точек останова с использованием того же синтаксиса, который вы используете для создания точек останова, как описано в разделе 2.4.1.

delete breakpoint_list

Удаляет точки останова, используя их числовые идентификаторы (которые описаны в разделе 2.3). Это может быть одно число, например delete 2, которое удаляет вторую точку останова, или список чисел, например delete 2 4, который удаляет вторую и четвертую точки останова.

delete

Удаляет все точки останова. GDB попросит вас подтвердить эту операцию, если вы не введете команду set confirm off, которую также можно поместить в ваш файл запуска .gdbinit.

clear

Очищает точку останова на следующей инструкции, которую выполнит GDB. Этот метод полезен, когда вы хотите удалить точку останова, которой только что достиг GDB.

clear function
clear filename:function
clear line_number
clear filename:line_number

Очищают точку останова в зависимости от ее местоположения и работают так же, как и их аналоги break.

Например, предположим, что вы установили точку останова на входе foo() с помощью:

(gdb) break foo
Breakpoint 2 at 0x804843a: file test.c, line 22.

Вы можете удалить эту точку останова либо с помощью

(gdb) clear foo
Deleted breakpoint 2

либо с помощью

(gdb) delete 2
Deleted breakpoint 2

2.7.2 Отключение точек останова в GDB

Каждую точку останова можно включить или отключить. GDB приостановит выполнение программы только тогда, когда она достигнет включенной точки останова; он игнорирует отключенные точки останова. По умолчанию точки останова начинают свою работу как включенные.

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

Вы отключаете точку останова с помощью команды disable breakpoint-list и включаете точку останова с помощью команды enable breakpoint-list, где breakpoint-list — это разделенный пробелами список одного или нескольких идентификаторов точек останова. Например,

(gdb) disable 3

отключит третью точку останова. Сходным образом,

(gdb) enable 1 5

включит первую и пятую точки останова.

Выдача команды disable без каких-либо аргументов отключит все существующие точки останова. Аналогично, команда enable без аргументов включит все существующие точки останова.

Существует также команда enable once, которая приведет к отключению точки останова после того, как она в следующий раз заставит GDB приостановить выполнение. Синтаксис:

enable once breakpoint-list

Например, enable once 3 приведет к отключению точки останова 3 в следующий раз, когда GDB остановит выполнение вашей программы. Она очень похожа на команду tbreak, но при обнаружении точки останова она ее отключает, а не удаляет.

2.7.3 Удаление и отключение точек останова в DDD

Удалить точки останова в DDD так же просто, как и установить их. Наведите курсор на красный знак остановки и щелкните правой кнопкой мыши, как если бы вы установили точку останова. Одним из вариантов во всплывающем меню будет Delete Breakpoint. Удерживая нажатой правую кнопку мыши, перетащите указатель мыши вниз, пока эта опция не будет выделена. Затем отпустите кнопку, и вы увидите, как красный знак остановки исчезнет, указывая на то, что точка останова была удалена.

Отключение точек останова с помощью DDD очень похоже на их удаление. Щелкните правой кнопкой мыши и удерживайте красный знак остановки и выберите Disable Breakpoint. Красный знак остановки станет серым, указывая на то, что точка останова все еще существует, но на данный момент отключена.

Другой вариант — использовать окно точек останова и наблюдения DDD, показанное на рисунке 2-1. Вы можете щелкнуть там запись точки останова, чтобы выделить ее, а затем выбрать Delete, Disable или Enable.

Фактически, вы можете выделить несколько записей в этом окне, наведя на них указатель мыши, как показано на рисунке 2-3. Таким образом, вы можете удалить, отключить или включить несколько точек останова одновременно.

Рисунок 2-3. Удаление/отключение/включение нескольких точек останова в DDD

После нажатия кнопки Delete в окне точек останова экран будет выглядеть так, как показано на рисунке 2-4. Конечно же, двух старых точек останова больше нет.

Рисунок 2-4. Две точки останова удалены.

2.7.4 Удаление и отключение точек останова в Eclipse

Как и в случае с DDD, вы можете удалить или отключить точку останова в Eclipse, щелкнув правой кнопкой мыши символ точки останова в соответствующей строке. Появится всплывающее меню, показанное на рисунке 2-5. Обратите внимание, что опция Toggle означает удаление точки останова, а Disable/Enable означает очевидное.

Рисунок 2-5. Удаление/отключение точек останова в Eclipse

Подобно окну точек останова в DDD, в Eclipse имеется представление Breakpoints, которое вы можете увидеть в правой верхней части рисунка 2-5. В отличие от случая DDD, в котором нам нужно было запросить окно Breakpoints и Watchpoints, представление Breakpoints в Eclipse автоматически отображается в перспективе Debug. (Однако вы можете скрыть его, если у вас мало места на экране, щелкнув X в его правом углу. Если вы хотите вернуть его позже, выберите Window | Show Views | Breakpoints.)

Одним из приятных аспектов представления Eclipse Breakpoints является то, что вы можете щелкнуть двойной значок X (Remove All Breakpoints). Необходимость в этом возникает чаще, чем можно предположить. В некоторые моменты длительного сеанса отладки вы можете обнаружить, что ни одна из установленных ранее точек останова больше не полезна, и поэтому вы захотите удалить их все.

2.7.5 «Перемещение» точек останова в DDD

У DDD есть еще одна замечательная функция: перетаскивание точек останова. Щелкните левой кнопкой мыши и удерживайте знак остановки точки останова в окне исходного кода. Пока вы удерживаете левую кнопку нажатой, вы можете перетащить точку останова в другое место исходного кода. «За кулисами» происходит то, что DDD удаляет исходную точку останова и устанавливает новую точку останова с теми же атрибутами. В результате вы обнаружите, что новая точка останова полностью эквивалентна старой точке останова, за исключением ее числового идентификатора. Конечно, вы можете сделать это и с помощью GDB, но DDD ускоряет процесс.

Рисунок 2-6. «Перемещение» точки останова в DDD

Рисунок 2-6 иллюстрирует это — вот снимок, сделанный во время операции перемещения точки останова. Точка останова была на строке

if (new_y < y[i]) {

который мы решили переместить на строку

scoot_over(j);

Мы щелкнули знак остановки на старой строке и перетащили его на новую строку. На картинке мы еще не отпустили кнопку мыши, поэтому исходный знак остановки все еще был на месте, а на новой строке появился «полый» знак остановки. После того, как мы отпустим кнопку мыши, знак остановки на старой строке исчезнет, а знак на новой строке заполнится.

Почему это так полезно? Прежде всего, если вы находитесь в ситуации, когда вы хотите удалить одну точку останова и добавить другую, обе операции выполняются одним махом. Но что еще более важно, как отмечалось ранее в разделе 2.6, когда вы добавляете или удаляете строки исходного кода, номера некоторых оставшихся строк изменяются, таким образом «перемещая» существующие точки останова на строки, в которых вы их не планировали. В GDB это требует выполнения действий по удалению точки останова и новой точке останова на каждой из задействованных точек останова. Это утомительно, особенно если к некоторым из них были приложены условия.

А вот в DDD значок остановки можно просто перетащить на новые места — очень красиво и удобно.

2.7.6 Отмена/повтор действий с точками останова в DDD

Одна из действительно приятных функций DDD — Undo/Redo, доступ к которой осуществляется нажатием кнопки Edit. В качестве примера рассмотрим ситуацию, показанную на рисунках 2-3 и 2-4. Предположим, вы внезапно поняли, что не хотите удалять эти две точки останова — возможно, вы просто хотите их отключить. Вы можете выбрать Edit | Undo Delete, как показано на рисунке 2-7. (Вы также можете нажать Undo в меню команд, но использование Edit имеет то преимущество, что DDD напомнит нам, что будет отменено.)

Рисунок 2-7. Шанс восстановить две точки останова в DDD

2.8 Подробнее о просмотре атрибутов точки останова

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

2.8.1 GDB

Как вы видели в разделе 2.3.1, каждой создаваемой вами точке останова присваивается уникальный целочисленный идентификатор. Первой установленной вами точке останова присваивается значение «1», а каждой новой точке останова после этого присваивается целое число, большее, чем ранее присвоенный идентификатор. Каждая точка останова также имеет ряд атрибутов, которые контролируют и настраивают ее поведение. Используя уникальные идентификаторы, вы можете настроить атрибуты каждой точки останова индивидуально.

Вы можете использовать команду info breakpoints (сокращенно i b), чтобы получить список всех установленных вами точек останова вместе с их атрибутами. Вывод info breakpoints будет выглядеть примерно так:

(gdb) info breakpoints
Num Type           Disp Enb Address    What
1  breakpoint      keep y   0x08048404 in main at int_swap.c:9
       breakpoint already hit 1 time
2  breakpoint      keep n   0x08048447 in main at int_swap.c:14
3  breakpoint      keep y   0x08048460 in swap at int_swap.c:20
       breakpoint already hit 1 time
4  hw watchpoint   keep y              counter

Давайте подробно рассмотрим этот вывод информационных точек останова:

Идентификатор (Num):

Уникальный идентификатор точки останова.

Тип (Type):

В этом поле указывается, является ли точка останова точкой останова, точкой наблюдения или точкой перехвата (breakpoint, watchpoint или catchpoint).

Расположение (Disp):

Каждая точка останова имеет расположение, которое указывает, что произойдет с точкой останова после того, как она в следующий раз заставит GDB приостановить выполнение программы. Возможны три расположения:

keep Точка останова не изменится после следующего достижения. Это расположение по умолчанию вновь созданных точек останова.

del Точка останова будет удалена после следующего ее достижения. Это расположение назначается любой точке останова, которую вы создаете с помощью команды tbreak (см. раздел 2.4.1).

dis Точка останова будет отключена при следующем достижении. Это устанавливается с помощью команды enable once (см. раздел 2.7.2).

Статус включения (Enb):

В этом поле указывается, включена или отключена точка останова в данный момент.

Адрес (Address):

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

Местоположение (What):

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

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

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

2.8.2 DDD

Как вы видели на рисунке 2-1, окно Breakpoints and Watchpoints DDD предоставляет ту же информацию, что и команда info breakpoints GDB. Однако оно более удобно, чем GDB, поскольку вы можете отображать это окно постоянно (т. е. сбоку от экрана), избегая таким образом ввода команды каждый раз, когда вы хотите просмотреть свои точки останова.

2.8.3 Eclipse

Опять же, как было показано ранее (рисунок 2-2), представление Breakpoints в Eclipse постоянно отображает ваши точки останова и их свойства. Eclipse здесь немного менее информативен, чем DDD, поскольку он не сообщает вам, сколько раз была достигнута точка останова (этой информации нет даже в окне Properties).

2.8 Возобновление выполнения

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

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

Существует три класса методов возобновления выполнения. Первый предполагает «пошаговое» выполнение вашей программы с step и next, выполнение только следующей строки кода и последующую паузу. Второй состоит в использовании continue, который заставляет GDB безоговорочно возобновлять выполнение программы до тех пор, пока не достигнет другой точки останова или пока программа не завершится. Последний класс методов включает в себя условия: возобновление с помощью команд finish или until. В этом случае GDB возобновит выполнение, и программа будет работать до тех пор, пока не будет выполнено какое-либо заранее определенное условие (например, не будет достигнут конец функции), не будет достигнута другая точка останова или программа не завершится.

Мы рассмотрим каждый метод возобновления выполнения по очереди для GDB, а затем покажем, как выполнять такие операции в DDD и Eclipse.

2.9.1 В GDB

Мы начнем этот раздел с обсуждения различных способов возобновления выполнения после приостановки GDB в точке останова.

2.9.1.1 Одношаговый с step и next

Как только GDB останавливается в точке останова, команды next (сокращенно n) и step (сокращенно s) используются для пошагового выполнения вашего кода. После срабатывания точки останова и паузы GDB вы можете использовать next и step для выполнения только следующей строки кода. После того, как строка будет выполнена, GDB снова сделает паузу и выдаст командную строку. Давайте посмотрим на это в действии. Рассмотрим программу swapflaw.c:

/* swapflaw.c: A flawed function that swaps two integers. */
#include <stdio.h>
void swap(int a, int b);

int main(void)
{
   int i = 4;
   int j = 6;

   printf("i: %d, j: %d\n", i, j);
   swap(i, j);
   printf("i: %d, j: %d\n", i, j);

   return 0;
}

void swap(int a, int b)
{
   int c = a;
   a = b;
   b = c;
}

Листинг 2-1. swapflaw.c

Мы установим точку останова при входе в main() и запустим программу в GDB.

$ gcc -g3 -Wall -Wextra -o swapflaw swapflaw.c
$ gdb swapflaw
(gdb) break main
Breakpoint 1 at 0x80483f6: file swapflaw.c, line 7.
(gdb) run
Starting program: swapflaw
Breakpoint 1, main () at swapflaw.c:7
7               int i = 4;

GDB теперь находится в строке 7 программы, а это означает, что строка 7 еще не выполнена. Мы можем использовать следующую команду для выполнения только этой строки кода, оставив нас непосредственно перед строкой 8:

(gdb) next
8               int j = 6;

Мы воспользуемся шагом для выполнения следующей строки кода, строки 8, которая переместит нас к строке 10.

(gdb) step
10              printf("i: %d, j: %d\n", i, j);

Мы видим, что и next, и step выполняют следующую строку кода. Итак, большой вопрос: «Чем эти команды отличаются?» Кажется, что они обе выполняют следующую строку кода. Разница между этими двумя командами заключается в том, как они обрабатывают вызовы функций: next выполнит функцию без паузы внутри нее, а затем сделает паузу на первом операторе, следующем за вызовом. step, с другой стороны, приостановится на первом операторе внутри функции.

Вызов swap() появляется в строке 11. Давайте посмотрим на эффект next и step параллельно.

Использование step:

(gdb) step
i: 4, j: 6
11      swap(i, j);
(gdb) step
swap (a=4, b=6) at swapflaw.c:19
19      int c = a;
(gdb) step
20      a = b;
(gdb) step
21      b = c;
(gdb) step
22      }
(gdb) step
main () at swapflaw.c:12
12      printf("i: %d, j: %d\n", i, j);
(gdb) step
i: 4, j: 6
14      return 0;

Использование next:

(gdb) next
i: 4, j: 6
11      swap(i, j);
(gdb) next
12      printf("i: %d, j: %d\n", i, j);
(gdb) next
i: 4, j: 6
14      return 0;

Команда step работает так, как вы и ожидаете. Она выполнила printf() в строке 10, затем вызов swap() в строке 11[4], а затем начала выполнять строки кода внутри swap(). Это называется входом в (into) функцию. Как только мы пройдем все строки swap, step вернет нас к main().

Напротив, похоже, что next никогда не покидал main(). Это основное различие между двумя командами. next считает вызов функции одной строкой кода и выполняет всю функцию за одну операцию, которая называется обходом (over) функции.

Однако не обманывайтесь; это может выглядеть так, как будто next пропускает тело swap(), но на самом деле он ничего не перешагивает. GDB молча выполнял каждую строку swap(), не показывая нам подробностей (хотя он показывает любой вывод на экран, который может вывести swap()) и не предлагая нам выполнить отдельные строки из функции.

Разница между входом в (into) функцию (что делает step) и обходом (over) функции (что делает next) — настолько важная концепция, что, рискуя углубиться в суть вопроса, мы продемонстрируем разницу между next и step с помощью диаграммы, которая показывает выполнение программы с помощью стрелок.

Рисунок 2-8 иллюстрирует поведение команды step. Представьте, что программа приостанавливается на первом операторе printf(). На рисунке показано, куда нас приведет каждый оператор шага:

Рисунок 2-8. step заходит в функцию.

Рисунок 2-9 иллюстрирует то же самое, но вместо этого используется next.

Рисунок 2-9 next обходит функцию.

Используете ли вы next или step, на самом деле зависит от того, что вы пытаетесь сделать. Если вы находитесь в части кода, где нет вызовов функций, не имеет значения, какую из команд вы используете. В этом случае команды полностью эквивалентны.

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

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

И команда next, и команда step принимают необязательный числовой аргумент, который указывает количество дополнительных строк, которые необходимо пройти next или step. Другими словами, next 3 — это то же самое, что набрать next три раза подряд (или ввести next один раз с последующим двойным нажатием клавиши ENTER)[5]. На рисунке 2-10 показано, что делает next 3:

Рисунок 2-10. next с отсчетом

  1. Вы можете задаться вопросом, почему step не привел вас к первой строке функции printf(). Причина в том, что GDB не останавливается внутри кода, для которого у него нет отладочной информации (т. е. таблицы символов). Функция printf(), связанная с библиотекой C, является примером такого кода.
  2. Пользователи Vim должны чувствовать себя как дома с концепцией указания счетчика для данной команды.
2.9.1.2 Возобновление выполнения программы с помощью continue

Второй метод возобновления выполнения — с помощью команды continue, сокращенно c. В отличие от step и next, которые выполняют только одну строку кода, эта команда заставляет GDB возобновлять выполнение вашей программы до тех пор, пока не сработает точка останова или программа не завершится.

Команда continue может принимать необязательный целочисленный аргумент n. Это число сообщает GDB игнорировать следующие n точек останова. Например, continue 3 сообщает GDB возобновить выполнение программы и игнорировать следующие три точки останова.

2.9.1.3 Возобновление выполнения программы с помощью finish

После срабатывания точки останова команды next и step используются для выполнения программы построчно. Иногда это может быть болезненным усилием. Например, предположим, что GDB достиг точки останова внутри функции. Вы проверили несколько переменных и собрали всю информацию, которую хотели получить. На этом этапе вас не интересует пошаговое выполнение оставшейся части функции. Вы хотите вернуться к вызывающей функции, где находился GDB до того, как вы вошли в вызываемую функцию. Однако установка сторонней точки останова и использование continue кажется расточительным, если все, что вам нужно, — это пропустить оставшуюся часть функции. Вот тут и наступает очередь finish.

Команда finish (сокращенно fin) инструктирует GDB возобновить выполнение до тех пор, пока не завершится текущий кадр стека. На русском языке это означает, что если вы находитесь в функции, отличной от main(), команда finish заставит GDB возобновить выполнение до тех пор, пока функция не завершится. Рисунок 2-11 иллюстрирует использование finish.

Рисунок 2-11. finish возобновляет выполнение до тех пор, пока не завершится текущая функция.

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

Может показаться, что finish выполняет каждую строку кода, переводя вас в конец функции, но это так. GDB выполняет каждую строку без паузы[6], за исключением демонстрации вывода программы.

Другое распространенное использование finish — это случай, когда вы случайно вошли в функцию, которую хотели перейти (другими словами, вы использовали step, когда собирались использовать next). В этом случае использование finish возвращает вас туда, где вы были бы, если бы использовали next.

Если вы находитесь внутри рекурсивной функции, finish поднимет вас только на один уровень рекурсии вверх. Это связано с тем, что каждый вызов сам по себе считается вызовом функции, поскольку каждый из них имеет свой собственный кадр стека. Если вы хотите полностью выйти из рекурсивной функции находясь на высоком уровне рекурсии, более подходящей будет временная точка останова вместе с continue или использование команды until. Мы обсудим until далее.

  1. Если есть какие-либо промежуточные точки останова, finish приостановится на них.
2.9.1.4 Возобновление выполнения программы с помощью until

Напомним, что команда finish завершает выполнение текущей функции без дальнейших пауз внутри функции (за исключением любых промежуточных точек останова). Аналогичным образом, команда until (сокращенно просто u) обычно используется для завершения выполнения цикла без дальнейших пауз внутри цикла, за исключением любых промежуточных точек останова внутри цикла. Рассмотрим следующий фрагмент кода.

...previous code...

int i = 9999;
while (i--) {
   printf("i is %d\n", i);
   ... lots of code ...
}

...future code...

Предположим, что GDB остановлен в точке останова в операторе while, вы проверили несколько переменных и теперь хотите выйти из цикла для отладки «future code».

Проблема в том, что i настолько велико, что использование next для завершения цикла займет целую вечность. Вы не можете использовать finish, потому что эта команда пройдет прямо за «future code» и выведет нас из функции. Вы можете установить временную точку останова в future code и использовать continue; однако это именно та ситуация, которую должен решить until.

Использование until выполнит оставшуюся часть цикла, оставив GDB на паузе на первой строке кода, следующей за циклом. На рисунке 2-12 показано, что будет делать использование until:

Рисунок 2-12. until приводит нас к следующей по величине строке исходного кода.

Конечно, если GDB встретит точку останова перед выходом из цикла, он все равно остановится там: если бы в операторе printf() на рисунке 2-12 была точка останова, вы наверняка захотели бы ее отключить.

Руководство пользователя GDB дает официальное определение until как:

Выполнять до тех пор, пока программа не достигнет исходной строки, превышающей текущую [на единицу].

Однако документация также предупреждает, что это может быть немного нелогично. Чтобы продемонстрировать почему, рассмотрим следующую программу:

#include <stdio.h>

int main(void)
{
   int i;

   for (i=0; i<10; ++i)
      printf("hello world!");

   return 0;
}

Листинг 2-2. ntil-anomaly.c

Мы установим точку останова при входе в main(), запустим программу и будем использовать ее до оператора return.

$ gdb until-anomaly
Using host libthread_db library "/lib/tls/libthread_db.so.1".
(gdb) break main
Breakpoint 1 at 0x80483b4: file until-anomaly.c, line 7.
(gdb) run
Starting program: until-anomaly

Breakpoint 1, main () at until-anomaly.c:7
7          for (i=0; i<10; ++i)
(gdb) until
8             printf("hello world!");
(gdb) until
7          for (i=0; i<10; ++i)
(gdb) until
10         return 0;
(gdb)

Ого! Используя until, GDB перешел от строки 7 к строке 8, а затем обратно к строке 7. Разве это не означает, что строка исходного кода 7 больше, чем строка исходного кода 8? На самом деле это так. Возможно, вы уже догадываетесь об ответе, поскольку это, кажется, общая тема. GDB в конечном итоге работает с машинными инструкциями. Хотя конструкция for написана с тестом цикла в верхней части тела, GCC скомпилировал программу с условием в нижней части тела цикла. Поскольку условие связано со строкой 7 исходного кода, похоже, что GDB пошел назад в исходном коде. Фактически, что на самом деле делает until, так это выполняет код до тех пор, пока не достигнет машинной инструкции, адрес памяти которой выше текущего, а не до тех пор, пока она не достигнет большего номера строки в исходном коде.

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

Если вам интересно и вы знаете язык ассемблера вашей машины, вы можете быстро просмотреть машинный код, используя команду disassemble GDB, а затем команду p/x $pc, чтобы распечатать текущее местоположение[7]. Это покажет вам, что сделает until. Но это всего лишь причуда, и с практической точки зрения это не проблема. Если вы находитесь в конце цикла и выполнение команды until приводит к возврату к началу цикла, просто выполните команду until во второй раз, и вы выйдете из цикла по своему желанию.

Команда until также может принимать в качестве аргумента местоположение в исходном коде. Фактически, она принимает те же аргументы, что и команда break, которая обсуждалась в разделе 2.4.1. Возвращаясь к листингу 2-1, если GDB запускает точку останова при входе в main(), все это эквивалентные способы удобного выполнения программы до входа в swap():

  1. Вы также можете просмотреть машинный код, используя опцию -S GCC. В результате будет создан файл языка ассемблера с суффиксом .s, показывающий код, созданный компилятором. Обратите внимание, что при этом создается только файл языка ассемблера, а не исполняемый файл.

2.9.2 В DDD

Мы начнем этот раздел с обсуждения различных способов возобновления выполнения после приостановки DDD в точке останова.

2.9.2.1 Стандартные операции

В DDD есть кнопки next и step в меню команд. Кроме того, вы можете выполнить step и next с помощью функциональных клавиш F5 и F6 соответственно.

Если вы хотите использовать next или step с аргументом в DDD, то есть выполнить то же, что вы сделали бы в GDB, через

(gdb) next 3

вам нужно будет использовать сам GDB в окне консоли DDD.

В DDD есть кнопка continue в меню команд, но опять же, если вы хотите выполнить continue с аргументом, используйте консоль GDB. Вы можете щелкнуть левой кнопкой мыши по окну исходного кода, чтобы вызвать опцию «continue until here», которая действительно устанавливает временную точку останова (см. раздел 2.4.1) в этой строке исходного кода. Точнее, «continue until here» означает «продолжить до этой точки, но также остановиться на любых промежуточных точках останова».

В GDB можно было бы добиться того же эффекта, что и finish через next с числовым аргументом, так что использование finish будет лишь незначительно удобнее. Но в DDD использование finish — это явная победа, требующая одного щелчка мыши в меню команд.

Если вы используете DDD, вы можете выполнить команду until, нажав кнопку Until в меню команд или щелкнув левой кнопкой в меню Program | Until или использовать сочетание клавиш F7. Из всех этих вариантов один вам обязательно покажется удобным! Как и многие другие команды GDB, если вы хотите использовать until с аргументом, вам нужно будет передать их непосредственно GDB в окне консоли DDD.

2.9.2.2 Undo/Redo

Как отмечалось в разделе 2.7.6, DDD имеет бесценную функцию Undo/Redo. В этом разделе мы показали, как отменить случайное удаление точки останова. Но также эту функцию можно использовать для таких действий, как Run, Next, Step и т. д.

Рассмотрим, например, ситуацию, изображенную на рисунке 2-13. Мы достигли точки останова при вызове swap() и намеревались выполнить операцию Step, но случайно нажали Next. Но нажав Undo, вы можете откатить время назад, как показано на рисунке 2-14. DDD напоминает вам, что вы что-то отменили, отображая курсор текущей строки в виде контура вместо сплошного зеленого цвета, который он обычно использует.

Рисунок 2-13. Упс!

Рисунок 2-14. Помещаем зубную пасту обратно в тюбик

2.9.3 В Eclipse

Аналогами step и next в Eclipse являются значки Step Into (шаг с заходом) и Step Over (шаг с обходом). Значок Step Into виден в представлении Debug (вверху слева) на рисунке 2-15; указатель мыши временно вызвал появление метки значка. Значок Step Over (шаг с обходом) находится справа от него. Обратите внимание, что следующим оператором, который вы выполните, будет вызов get_args(), поэтому нажатие Step Into приведет к следующей паузе выполнения, происходящей на первом операторе внутри этой функции, тогда как выбор Step Over будет означать, что следующая пауза будет в вызов process_data().

Рисунок 2-15. Значок Step Into в Eclipse

В Eclipse есть значок Step Return (рядом с Step Over), который выполняет finish. В нем нет ничего, что точно соответствовало бы until. Его Run to Line (вызывается щелчком мыши по целевой строке, а затем щелчком правой кнопкой мыши в окне исходного кода) обычно выполняет то, что вы хотите от until.

2.10 Условные точки останова

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

Это похоже на то, как работают точки наблюдения, но с важным отличием. Если у вас есть подозрения, что переменная получает ложное значение, предпочтительнее использовать условную точку останова, чем точку наблюдения. Точка наблюдения будет прерываться всякий раз, когда эта переменная меняет значение. Условная точка останова прерывается только в коде предполагаемой проблемы, и то только тогда, когда переменная принимает ложное значение. В этом смысле точки наблюдения хороши, когда вы не знаете, откуда переменная получает свое ложное значение. Это особенно полезно для глобальных переменных или локальных переменных, которые постоянно передаются между функциями. Но в большинстве других случаев удачно расположенная условная точка останова более полезна и удобна.

2.10.1 GDB

Синтаксис установки условной точки останова:

break break-args if (condition)

где break-args — это любой из аргументов, которые вы можете передать в команду break, чтобы указать местоположение точки останова, как описано в разделе 2.4.1, а condition — это логическое выражение, как определено в разделе 2.12.2. Круглые скобки вокруг condition не являются обязательными. Они могут заставить некоторых программистов Си чувствовать себя как дома, но с другой стороны, вы можете предпочесть лаконичный вид.

Например, вот как бы вы прервали функцию main(), если бы пользователь ввел в программу несколько аргументов командной строки[8]:

break main if argc > 1

Условное прерывание чрезвычайно полезно, особенно в конструкциях циклов, в которых что-то плохое происходит при определенном значении индексной переменной. Рассмотрим следующий фрагмент кода:

for (i=0; i<=75000; ++i) {
   retval = process(i);
   do_something(retval);
}

Предположим, вы знаете, что ваша программа выходит из строя, когда i становится 70 000. Вы хотите прерваться в начале цикла, но не хотите переходить к следующим 69 999 итерациям. Именно здесь условное прерывание действительно проявляется. Вы можете установить точку останова в верхней части цикла, но только тогда, когда i равно 70 000, используя следующее:

break if (i == 70000)

Конечно, того же эффекта можно было бы добиться, набрав, скажем, continue 69999, но это было бы менее удобно.

Условное прерывание также чрезвычайно гибко. Вы можете сделать гораздо больше, чем просто проверить переменную на равенство или неравенство. Что можно использовать в condition? Практически любое выражение можно использовать в допустимом условном операторе C. Все, что вы используете, должно иметь логическое значение, то есть true (ненулевое) или false (ноль). Это включает в себя:

Действуют правила порядка приоритета, поэтому вам может потребоваться использовать круглые скобки вокруг таких конструкций, как (x & y) == 0.

Кроме того, если вы используете библиотечную функцию в выражении GDB, и библиотека не была скомпилирована с символами отладки (что почти наверняка и имеет место), единственными возвращаемыми значениями, которые вы можете использовать в условиях точки останова, являются значения типа int. Другими словами, без отладочной информации GDB предполагает, что возвращаемое значение функции — целое число. Если это предположение неверно, возвращаемое значение функции будет интерпретировано неверно.

(gdb) print cos(0.0)
$1 = 14368

К сожалению, приведение типов тоже не помогает:

(gdb) print (double) cos(0.0)
$2 = 14336

Если ваша тригонометрия заржавела, косинус 0 равен 1.

Можно установить условия для обычных точек останова, чтобы превратить их в условные точки останова. Например, если вы установили точку останова 3 как безусловную, но теперь хотите добавить условие i == 3, просто введите

(gdb) cond 3 i == 3

Если позже вы захотите удалить условие, но сохранить точку останова, просто введите

(gdb) cond 3
  1. При условии, что вы объявили argc и argv в качестве аргументов функции main(). (Конечно, если вы объявили их с другими именами, скажем, ac и av, используйте их.) Кстати, обратите внимание, что программа всегда получает хотя бы один аргумент — имя самой программы, на которое указывает argv[0], который мы здесь не считаем «аргументом пользователя».

2.10.2 DDD

Вы можете установить условные точки останова с помощью DDD, используя семантику GDB в окне консоли. Или используйте DDD следующим образом. Установите нормальную (т. е. безусловную) точку останова в том месте вашего кода, где вы хотите, чтобы была условная точка останова. Щелкните правой кнопкой мыши и удерживайте красный знак остановки, чтобы открыть меню, и выберите Properties. Появится всплывающее окно с текстовым полем ввода с надписью Condition. Введите условие в это поле, нажмите Apply, а затем нажмите Close. Точка останова теперь является условной точкой останова.

Это показано на рисунке 2-16. Мы видим условие j == 0 в точке останова 4. Кстати, знак остановки в этой строке теперь будет содержать вопросительный знак, чтобы напомнить нам, что это условная точка останова.

Рисунок 2-16. Наложение условия на точку останова в DDD

2.10.3 Eclipse

Чтобы сделать точку останова условной, щелкните правой кнопкой мыши символ точки останова для этой строки и выберите Breakpoint Properties... | Common, и заполните условие в диалоговом окне. Диалоговое окно показано на рисунке 2-17.

Рисунок 2-17. Наложение условия на точку останова в Eclipse

2.11 Списки команд точки останова

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

Фактически, вы можете сделать это с помощью «списков команд точек останова». Мы будем использовать команду printf GDB для иллюстрации списков команд. Вы еще не были с ней официально знакомы, но printf в основном работает в GDB так же, как и в C, но круглые скобки не являются обязательными.

Вы устанавливаете списки команд с помощью commands:

commands breakpoint-number
...
commands
...
end

где breakpoint-number — это идентификатор точки останова, к которой вы хотите добавить команды, а commands — это список всех допустимых команд GDB, разделенных символами новой строки. Вы вводите команды одну за другой, а затем вводите end, чтобы обозначить, что вы закончили ввод команд. После этого всякий раз, когда GDB прерывает работу в этой точке останова, он выполняет любые команды, которые вы ему дали. Давайте посмотрим на пример. Рассмотрим следующую программу:

#include <stdio.h>
int fibonacci(int n);

int main(void)
{
   printf("Fibonacci(3) is %d.\n", fibonacci(3));

   return 0;
}

int fibonacci(int n)
{
   if ( n <= 0 || n == 1 )
      return 1;
   else
      return fibonacci(n-1) + fibonacci(n-2);
}

Листинг 2-3. fibonacci.c

Нам хотелось бы посмотреть, какие значения передаются в функцию fibonacci() и в каком порядке. Однако вы не хотите вставлять операторы printf() и перекомпилировать код. Во-первых, это было бы бестактно в книге по отладке, не так ли? Но что еще более важно, потребуется время, чтобы вставить код и перекомпилировать/слинковать, а затем удалить этот код и перекомпилировать/слинковать после того, как вы исправите эту конкретную ошибку, особенно если ваша программа большая. Более того, это загромождает ваш код операторами, не связанными с кодом, что затрудняет чтение в процессе отладки.

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

Сначала установите точку останова вверху функции fibonacci(). Этой точке останова будет присвоен идентификатор 1, поскольку это первая установленная вами точка останова. Затем установите команду в точке останова 1 для печати переменной n.

$ gdb fibonacci
(gdb) break fibonacci
Breakpoint 1 at 0x80483e0: file fibonacci.c, line 13.
(gdb) commands 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>printf "fibonacci was passed %d.\n", n
>end
(gdb)

Теперь запустите программу и посмотрите, что произойдет.

(gdb) run
Starting program: fibonacci

Breakpoint 1, fibonacci (n=3) at fibonacci.c:13
13              if ( n <= 0 || n == 1 )
fibonacci was passed 3.
(gdb) continue
Continuing.

Breakpoint 1, fibonacci (n=2) at fibonacci.c:13
13              if ( n <= 0 || n == 1 )
fibonacci was passed 2.
(gdb) continue
Continuing.

Breakpoint 1, fibonacci (n=1) at fibonacci.c:13
13              if ( n <= 0 || n == 1 )
fibonacci was passed 1.
(gdb) continue
Continuing.

Breakpoint 1, fibonacci (n=0) at fibonacci.c:13
13              if ( n <= 0 || n == 1 )
fibonacci was passed 0.
(gdb) continue
Continuing.

Breakpoint 1, fibonacci (n=1) at fibonacci.c:13
13              if ( n <= 0 || n == 1 )
fibonacci was passed 1.
(gdb) continue
Continuing.
Fibonacci(3) is 3.

Program exited normally.
(gdb)

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

(gdb) commands 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>silent
>printf "fibonacci was passed %d.\n", n
>end
(gdb)

И вот результат:

(gdb) run
Starting program: fibonacci
fibonacci was passed 3.
(gdb) continue
Continuing.
fibonacci was passed 2.
(gdb) continue
Continuing.
fibonacci was passed 1.
(gdb) continue
Continuing.
fibonacci was passed 0.
(gdb) continue
Continuing.
fibonacci was passed 1.
(gdb) continue
Continuing.
Fibonacci(3) is 3.

Program exited normally.
(gdb)

Хорошо. И последняя возможность для демонстрации: если последней командой в списке команд является continue, GDB автоматически продолжит выполнение программы после завершения команд в списке команд:

(gdb) command 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>silent
>printf "fibonacci was passed %d.\n", n
>continue
>end
(gdb) run
Starting program: fibonacci
fibonacci was passed 3.
fibonacci was passed 2.
fibonacci was passed 1.
fibonacci was passed 0.
fibonacci was passed 1.
Fibonacci(3) is 3.

Program exited normally.
(gdb)

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

Сначала давайте определим макрос, который назовем print_and_go:

(gdb) define print_and_go
Redefine command "print_and_go"? (y or n) y
Type commands for definition of "print_and_go".
End with a line saying just "end".
>printf $arg0, $arg1
>continue
>end

Чтобы использовать его, как указано выше, введите:

(gdb) commands 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>silent
>print_and_go "fibonacci() was passed %d\n" n
>end

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

Вы можете получить список всех макросов, набрав show user.

Списки команд очень полезны, но их также можно комбинировать с условным прерыванием, и это мощный инструмент. При таком условном вводе/выводе у вас может даже возникнуть соблазн выбросить C и просто использовать GDB в качестве предпочтительного языка программирования. Шучу, конечно.

Списки команд в DDD аналогичны условным точкам останова в DDD. Сначала установите точку останова. Щелкните правой кнопкой мыши красный знак остановки и выберите Properties. Появится всплывающее окно. Большое подокно будет справа (если вы не видите большое подокно, щелкните левой кнопкой мыши кнопку Edit, которая переключает видимость окна команд). Вы можете вводить свои команды прямо в это окно. Также есть кнопка Record. Если вы щелкните эту кнопку правой кнопкой мыши, вы сможете ввести свои команды в консоль GDB.

Похоже, что в Eclipse нет функции списка команд.

2.12 Точки наблюдения

Точка наблюдения (watchpoint) — это особый вид точки останова, которая, как и обычная точка останова, представляет собой инструкцию, сообщающую GDB приостановить выполнение вашей программы. Разница в том, что точки наблюдения не «живут» в строке исходного кода. Вместо этого точка наблюдения — это инструкция, которая сообщает GDB приостановить выполнение всякий раз, когда выражение меняет значение[9]. Это выражение может быть довольно простым, например, имя переменной:

(gdb) watch i

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

(gdb) watch (i | j > 12) && i > 24 && strlen(name) > 6

Вы можете думать о точке наблюдения как о «привязанной» к выражению; когда значение этого выражения изменится, GDB приостановит выполнение программы.

Хотя точки наблюдения и точки останова управляются одинаково, между ними есть важное различие. Точка останова связана с местом в исходном коде. Поскольку ваш код не меняется, нет риска, что строка кода «выходит за рамки». Поскольку в C действуют жесткие правила области видимости, вы можете наблюдать только за той переменной, которая существует и находится в области видимости. Как только переменная больше не существует ни в одном кадре стека вызовов (когда функция, содержащая локальную переменную, возвращает значение), GDB автоматически удаляет точку наблюдения.

Например, в разделе 2.5 мы рассмотрели программу под названием swap. В этой программе для временного хранения использовалась локальная переменная c. Вы не сможете установить наблюдение за c, пока GDB не достигнет строки 3 файла swapper.c, где определена c. Кроме того, если вы установили точку наблюдения за c, она будет автоматически удалена, как только GDB вернется из swapper(), поскольку c больше не будет существовать.

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

  1. Выражения будут обсуждаться более подробно в разделе 2.12.2.

2.12.1 Установка точек наблюдения

Если переменная var существует и находится в области видимости, вы можете установить для нее точку наблюдения с помощью команды

watch var

что приведет к прерыванию GDB при каждом изменении значения переменной var. Именно так многие люди думают о точках наблюдения, потому что это просто и удобно; Однако в этой истории есть еще кое-что. Что на самом деле делает GDB, так это прерывает работу, если место в памяти (memory location) для var меняет значение. Обычно не имеет значения, считаете ли вы точку наблюдения наблюдающей за переменной или за адресом переменной, но это может быть важно в особых случаях, например, при работе с указателями на указатели.

Давайте рассмотрим один пример сценария, в котором точки наблюдения будут очень полезны. Предположим, у вас есть две переменные типа int, x и y, и где-то в коде вы выполняете p = &y, хотя хотели сделать p = &x. Это может привести к загадочному изменению значения y где-то в коде. Фактическое расположение возникшей ошибки может быть хорошо скрыто, поэтому точка останова может оказаться не очень полезной. Однако, установив точку наблюдения, вы можете мгновенно узнать, когда и где y меняет значение.

Это еще не все. Вы не ограничены наблюдением за переменной. Фактически, вы можете наблюдать выражение, включающее переменные. Всякий раз, когда выражение меняет значение, GDB прерывается. В качестве примера рассмотрим следующий код:

#include <stdio.h>
int i = 0;

int main(void)
{
   i = 3;
   printf("i is %d.\n", i);

   i = 5;
   printf("i is %d.\n", i);

   return 0;
}

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

(gdb) break main
Breakpoint 1 at 0x80483b4: file test2.c, line 6.
(gdb) run
Starting program: test2

Breakpoint 1, main () at test2.c:6

Теперь, когда i находится в области видимости, установите точку наблюдения и прикажите GDB продолжить выполнение программы. Мы вполне ожидаем, что i > 4 станет истинным в строке 9.

(gdb) watch i > 4
Hardware watchpoint 2: i > 4
(gdb) continue
Continuing.
Hardware watchpoint 2: i > 4

Old value = 0
New value = 1
main () at test2.c:10

Разумеется, GDB останавливается между строками 9 и 10, где выражение i > 4 изменило значение с 0 (ложь) на 1 (истина).

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

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

2.12.2 Выражения

Мы видели пример использования выражения (expression) с командой GDB watch. Оказывается, существует довольно много команд GDB, таких как print, которые также принимают аргументы выражения. Поэтому, наверное, стоит упомянуть о них немного подробнее.

Выражение в GDB может содержать множество вещей:

Итак, если вы отлаживали программу на Фортране-77 и хотели знать, когда переменная i стала больше 4, вместо использования watch i > 4, как вы делали в последнем разделе, вы должны использовать watch i .GT. 4.

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

  1. На момент написания этой статьи в официальном Руководстве пользователя GNU GDB указано, что макросы препроцессора нельзя использовать в выражениях; однако это неправда. Если вы скомпилируете программу с опцией GCC -g3, в выражениях можно будет использовать макросы препроцессора.

Глава 3
Проверка и настройка переменных

В главе 1 вы узнали, что в GDB вы можете распечатать значение переменной с помощью команды print, а в DDD и Eclipse это можно сделать, переместив указатель мыши на экземпляр переменной в любом месте исходного кода. Но как GDB, так и графические интерфейсы предлагают гораздо более мощные способы проверки переменных и структур данных, как мы увидим в этой главе.

3.1 Наш основной пример кода

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

//  bintree.c: routines to do insert and sorted print of a binary tree

#include <stdio.h>
#include <stdlib.h>

struct node {
   int val;             // stored value
   struct node *left;   // ptr to smaller child
   struct node *right;  // ptr to larger child
};

typedef struct node *nsp;

nsp root;

nsp makenode(int x)
{
   nsp tmp;

   tmp = (nsp) malloc(sizeof(struct node));
   tmp->val = x;
   tmp->left = tmp->right = 0;
   return tmp;
}

void insert(nsp *btp, int x)
{
   nsp tmp = *btp;

   if (*btp == 0) {
      *btp = makenode(x);
      return;
   }

   while (1)
   {
      if (x < tmp->val) {

         if (tmp->left != 0) {
            tmp = tmp->left;
         } else {
            tmp->left = makenode(x);
            break;
         }
      } else {

         if (tmp->right != 0) {
            tmp = tmp->right;
         } else {
            tmp->right = makenode(x);
            break;
         }
      }
   }
}

void printtree(nsp bt)
{
   if (bt == 0) return;
   printtree(bt->left);
   printf("%d\n",bt->val);
   printtree(bt->right);
}

int main(int argc, char *argv[])
{  int i;
   root = 0;
   for (i = 1; i < argc; i++)
      insert(&root, atoi(argv[i]));
   printtree(root);
}

В каждом узле все элементы левого поддерева меньше значения в данном узле, а все элементы правого поддерева больше или равны значению в данном узле. Функция insert() создает новый узел и помещает его в нужное положение в дереве. Функция printtree() отображает элементы любого поддерева в порядке возрастания номеров, а функция main() запускает тест, распечатывая весь отсортированный массив[1].

В приведенных здесь примерах отладки предположим, что вы случайно закодировали второй вызов makenode() в методе insert() как

tmp->left = makenode(x);

вместо

tmp->right = makenode(x);

Если вы запустите этот ошибочный код, что-то сразу пойдет не так:

$ bintree 12 8 5 19 16
16
12

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

  1. Кстати, обратите внимание на typedef в строке 12, nsp. Это означает указатель структуры узла (node struct pointer), но наш издатель думает, что это No Starch Press.

3.2 Расширенная проверка и настройка переменных

Наш пример с деревом более сложен, и поэтому необходимы более сложные методы. Мы рассмотрим некоторые из них здесь.

3.2.1 Проверка в GDB

В предыдущих главах вы использовали базовую команду print GDB. Как вы можете использовать ее здесь? Что ж, основная работа, очевидно, выполняется в функции insert(), так что было бы неплохо начать с нее. При запуске GDB в цикле while в этой функции вы можете выполнять набор из трех команд GDB каждый раз, когда достигаете точки останова:

(gdb) p tmp->val
$1 = 12
(gdb) p tmp->left
$2 = (struct node *) 0x8049698
(gdb) p tmp->right
$3 = (struct node *) 0x0

(Вспомним главу 1, что выходные данные GDB обозначаются $1, $2 и т. д., причем эти величины вместе называются историей значений (value history). Мы обсудим их далее в разделе 3.4.1.)

Здесь вы обнаружите, что узел, на который в данный момент указывает tmp, содержит 12 с ненулевым левым указателем и нулевым правым указателем. Конечно, фактическое значение левого указателя, то есть фактический адрес памяти, вероятно, не представляет здесь прямого интереса, но важен тот факт, что указатель ненулевой или нулевой. Дело в том, что вы видите, что в настоящее время существует левое поддерево ниже 12, но нет правого поддерева.

Первое улучшение: распечатайте структуру целиком.

Было бы довольно утомительно продолжать вводить эти три команды print каждый раз, когда мы достигаем точки останова. Вот как мы могли бы сделать то же самое с помощью одной команды print:

(gdb) p *tmp
$4 = {val = 12, left = 0x8049698, right = 0x0}

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

Второе улучшение: используйте команду display GDB.

Печать p *tmp выше сэкономит время и усилия. Каждый раз, когда вы достигаете точки останова, вам нужно будет ввести только одну команду GDB, а не три. Но если вы знаете, что будете вводить это каждый раз при достижении точки останова, вы можете сэкономить еще больше времени и усилий, используя команду display GDB, сокращенно disp. Эта команда говорит GDB автоматически печатать указанный элемент каждый раз, когда происходит пауза в выполнении (из-за точки останова, команд next или step и т. д.):

(gdb) disp *tmp
1: *tmp = {val = 12, left = 0x8049698, right = 0x0}
(gdb) c
Continuing.

Breakpoint 1, insert (btp=0x804967c, x=5) at bintree.c:37
37            if (x < tmp->val) {
1: *tmp = {val = 8, left = 0x0, right = 0x0}

Как видно здесь, GDB автоматически печатает *tmp после достижения точки останова, поскольку вы выполнили команду display.

Конечно, переменная в списке отображения будет отображаться только в то время, когда она находится в области видимости.

Третье улучшение: используйте команду commands GDB.

Предположим, вы хотите просмотреть значения в дочерних узлах, когда находитесь в данном узле. Вспоминая команду commands GDB из главы 1, вы можете сделать что-то вроде этого:

(gdb) b 37
Breakpoint 1 at 0x8048403: file bintree.c, line 37.
(gdb) commands 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>p tmp->val
>if (tmp->left != 0)
 >p tmp->left->val
 >else
 >printf "%s\n", "none"
 >end
>if (tmp->right != 0)
 >p tmp->right->val
 >else
 >printf "%s\n", "none"
 >end
>end

Обратите внимание, что в этом примере у команды печати GDB есть более мощный родственник, printf(), с форматированием, аналогичным форматированию его тезки на языке C.

Вот образец результирующего сеанса GDB:

Breakpoint 1, insert (btp=0x804967c, x=8) at bintree.c:37
37            if (x < tmp->val)
$7 = 12
none
none
(gdb) c
Continuing.

Breakpoint 1, insert (btp=0x804967c, x=5) at bintree.c:37
37            if (x < tmp->val)
$6 = 12
$7 = 8
none
(gdb) c
Continuing.

Breakpoint 1, insert (btp=0x804967c, x=5) at bintree.c:37
37            if (x < tmp->val)
$8 = 8
none
none

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

Четвертое улучшение: используйте команду call GDB.

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

for (i = 1; i < argc; i++) {
   insert(&root,atoi(argv[i]));
   printtree(root);
}

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

Вместо этого было бы неплохо сделать то же самое изнутри GDB. Вы можете сделать это с помощью команды call GDB. Например, вы можете установить точку останова на строке 57, в конце метода insert(), а затем сделать следующее:

(gdb) commands 2
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>printf "*********** current tree ***********"
>call printtree(root)
>end

Пример результирующего сеанса GDB:

Breakpoint 2, insert (btp=0x8049688, x=12) at bintree.c:57
57 }
******** current tree ********
12
(gdb) c
Continuing.

Breakpoint 2, insert (btp=0x8049688, x=8) at bintree.c:57
57 }
*********** current tree ***********
8
12
(gdb) c
Continuing.

Breakpoint 2, insert (btp=0x8049688, x=5) at bintree.c:57
57 }
*********** current tree ***********
5
8
12
(gdb) c
Continuing.

Breakpoint 2, insert (btp=0x8049688, x=19) at bintree.c:57
57 }
*********** current tree ***********
19
12

Обратите внимание, что это показывает, что первым элементом данных, вызвавшим проблемы, был номер 19. Эта информация позволит вам очень быстро обнаружить ошибку. Вы перезапустите программу с теми же данными, установив точку останова в начале метода insert(), но с условием x == 19, а затем исследуете, что там происходит.

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

(gdb) commands 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>end

3.2.2 Проверка в DDD

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

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

Вспомним иллюстрацию команды display GDB:

(gdb) disp *tmp

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

Некоторым аналогом является команда Display в DDD. Если вы щелкнете правой кнопкой мыши по любому экземпляру переменной, скажем root в этом примере, в окне исходного кода появится всплывающее меню, как показано на рисунке 3-1. Как видите, здесь у вас есть несколько вариантов просмотра root. Опции Print root и Print *root работают точно так же, как их аналоги в GDB, и фактически их выходные данные появляются в консоли DDD (где команды GDB отображаются/вводятся). Но в данном случае наиболее интересным вариантом является Display *root. Результат выбора этого варианта после достижения точки останова в строке 48 исходного кода показан на рисунке 3-2.

Рисунок 3-1. Всплывающее окно для просмотра переменной

Рисунок 3-2. Отображение узла

Появилось новое окно DDD — окно данных, с узлом, соответствующим root. Пока что это не что иное, как просто графический аналог команды display GDB. Но что здесь действительно приятно, так это то, что вы можете переходить по ссылкам дерева! Например, чтобы следовать левой ветви дерева, щелкните правой кнопкой мыши поле left отображаемого корневого узла. (В данный момент вы не будете делать этого на узле right, поскольку ссылка равна 0.) Затем выберите пункт Display *() во всплывающем меню, и теперь DDD будет выглядеть так, как показано на рисунке 3-3. Итак, DDD представляет вам рисунок дерева (или этой его части) точно так же, как вы бы написали сами на доске — очень круто!

Рисунок 3-3. Следование по ссылкам

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

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

$ ddd --separate bintree

затем появятся отдельные окна — исходного кода, консоли и данных, размер которых вы можете изменить по своему желанию.

Если вы хотите удалить часть или все содержимое окна данных во время сеанса DDD, существует множество способов сделать это. Например, вы можете щелкнуть элемент правой кнопкой мыши и затем выбрать опцию Undisplay.

3.2.3 Проверка в Eclipse

Как и в случае с DDD, чтобы проверить скалярную переменную в Eclipse, просто переместите указатель мыши на любой экземпляр переменной в окне исходного кода. Обратите внимание, что это должен быть независимый скаляр, а не, например, скаляр внутри struct. Это показано на рисунке 3-4. Здесь мы успешно запросили значение x, но если бы мы переместили указатель мыши на часть val в tmp->val в той же строке, это не показало бы нам, что там находится.

Рисунок 3-4. Проверка скалярной переменной в Eclipse

На этом этапе вы можете использовать представление Variables Eclipse, которое вы можете увидеть в верхней правой части рисунка 3-5. Щелкните треугольник рядом с tmp, чтобы направить его вниз, затем прокрутите строку вниз и обнаружите, что отображается tmp->val. (Оказывается, их 12.)

Рисунок 3-5. Проверка поля структуры в Eclipse

И вы можете продолжить этот процесс. Щелкнув треугольник слева, вы увидите экран, показанный на рисунке 3-6, где вы увидите, что tmp->left->val равно 8.

Рисунок 3-6. Следующие ссылки-указатели в Eclipse

По умолчанию в представлении Variables глобальные переменные не отображаются. В программе здесь есть одна, root. Вы можете добавить это в представление Variables, щелкнув правой кнопкой мыши в этом представлении, выбрав Add Global Variables, установив флажок для root в появившемся всплывающем окне, а затем нажав OK.

3.2.4 Проверка динамических массивов

Как обсуждалось в главе 1, в GDB вы можете распечатать весь массив, скажем, объявленный как

int x[25];

набрав

(gdb) p x

Но что, если бы массив был создан динамически, скажем так?

int *x;
...
x = (int *) malloc(25*sizeof(int));

Если вы хотите распечатать массив в GDB, вы не можете ввести

(gdb) p x

Это просто выведет адрес массива. Вы также не могли бы напечатать

(gdb) p *x

Это приведет к распечатке только одного элемента массива, x[0]. Вы по-прежнему можете распечатать отдельные элементы, как в команде p x[5], но вы не сможете распечатать весь массив, просто используя команду print для x.

3.2.4.1 Решения в GDB

В GDB эту проблему можно решить, создав искусственный массив (artificial array). Рассмотрим код

int *x;

main()
{
   x = (int *) malloc(25*sizeof(int));
   x[3] = 12;
}

Тогда вы могли бы сделать что-то вроде этого:

Breakpoint 1, main () at artif.c:6
6          x = (int *) malloc(25*sizeof(int));
(gdb) n
7          x[3] = 12;
(gdb) n
8       }
(gdb) p *x@25
$1 = {0, 0, 0, 12, 0 <repeats 21 times>}

Как видите, общая форма такова:

*pointer@number_of_elements

GDB также позволяет использовать приведения типов, когда это необходимо, например:

(gdb) p (int [25]) *x
$2 = {0, 0, 0, 12, 0 <repeats 21 times>}
3.2.4.2 Решения в DDD

Как всегда, вы можете использовать метод GDB, в данном случае искусственные массивы, через консоль DDD.

Другой вариант — распечатать или отобразить диапазон памяти (см. раздел 3.2.7 ниже).

3.2.4.3 Решения в Eclipse

Здесь вы можете использовать команду Eclipse Display as Array (отобразить как массив).

Например, давайте немного расширим наш предыдущий пример:

int *x;

main()
{
   int y;
   x = (int *) malloc(25*sizeof(int));
   scanf("%d%d",&x[3],&x[8]);
   y = x[3] + x[8];
   printf("%d\n",y);
}

Допустим, в данный момент вы находитесь на задании в y. Сначала вы должны перенести x в представление Variables, щелкнув правой кнопкой мыши в этом представлении и выбрав x. Затем снова щелкните правой кнопкой мыши x в представлении Variables и выберите Display As Array. В появившемся всплывающем окне вы должны заполнить поля Start Index (начальный индекс) и Length (длина), скажем, значениями 0 и 25, чтобы отобразить весь массив. Экран теперь будет таким, как на рисунке 3-7. Вы можете увидеть массив в представлении переменных, показанный как

(0,0,0,1,0,0,0,0,2,0,<repeats 16 times>)

Рисунок 3-7. Отображение динамического массива в Eclipse

Значения 1 и 2 были получены в результате ввода в программу и видны в представлении консоли.

А как насчет С++?

Чтобы проиллюстрировать ситуацию с кодом C++, приведем C++-версию примера двоичного дерева, использованного ранее:

// bintree.cc: routines to do insert and sorted print of a binary tree in C++

#include <iostream.h>

class node {
   public:
      static class node *root; // root of the entire tree
      int val; // stored value
      class node *left; // ptr to smaller child
      class node *right; // ptr to larger child
      node(int x); // constructor, setting val = x
      static void insert(int x); // insert x into the tree
      static void printtree(class node *nptr); // print subtree rooted at *nptr
};

class node *node::root = 0;

node::node(int x)

{
   val = x;
   left = right = 0;
}

void node::insert(int x)
{
   if (node::root == 0) {
      node::root = new node(x);
      return;
   }
   class node *tmp=root;
   while (1)
   {
      if (x < tmp->val)
      {

         if (tmp->left != 0) {
            tmp = tmp->left;
         } else {
            tmp->left = new node(x);
            break;
         }
      } else {

         if (tmp->right != 0) {
            tmp = tmp->right;
         } else {
            tmp->right = new node(x);
            break;
         }
      }
   }
}

void node::printtree(class node *np)
{
   if (np == 0) return;
   node::printtree(np->left);
   cout << np->val << endl;
   node::printtree(np->right);
}

int main(int argc, char *argv[])
{
   for (int i = 1; i < argc; i++)
      node::insert(atoi(argv[i]));
   node::printtree(node::root);
}

Вы по-прежнему компилируете как обычно, обязательно указывая компилятору сохранить таблицу символов в исполняемом файле[2].

Те же команды GDB работают, но с несколько другим выводом. Например, снова распечатав содержимое объекта, на который указывает tmp, в методе insert(), вы получите следующий результат:

(gdb) p *tmp
$6 = {static root = 0x8049d08, val = 19, left = 0x0, right = 0x0}

Это аналогично случаю с программой на C, за исключением того, что теперь также выводится значение статической переменной node::root (что и должно быть, поскольку она является частью класса).

Конечно, вы должны иметь в виду, что GDB требует, чтобы вы указывали переменные в соответствии с теми же правилами области видимости, которые использует C++. Например:

(gdb) p *root
Cannot access memory at address 0x0
(gdb) p *node::root
$8 = {static root = 0x8049d08, val = 12, left = 0x8049d18, right = 0x8049d28}

Нам нужно было указать root через его полное имя node::root.

GDB и DDD не имеют встроенных браузеров классов, но команда ptype GDB удобна для быстрого просмотра структуры класса или структуры, например:

(gdb) ptype node
type = class node {
  public:
    static node *root;
    int val;
    node *left;
    node *right;

    node(int);
    static void insert(int);
    static void printtree(node*);
}

В DDD можно щелкнуть правой кнопкой мыши имя класса или переменной, а затем выбрать What Is во всплывающем меню, чтобы получить ту же информацию.

Eclipse имеет представление Outline, поэтому вы можете легко получить информацию о классе таким способом.

  1. Однако в этом отношении могут возникнуть различные проблемы. См. руководство GDB относительно форматов исполняемых файлов. В приведенных здесь примерах используется компилятор G++ (оболочка C++ для GCC) с опцией -g, указывающей, что таблица символов должна быть сохранена. Мы также попробовали опцию -gstabs, которая сработала, но с несколько менее желательными результатами.

3.2.6 Мониторинг локальных переменных

В GDB вы можете получить список значений всех локальных переменных в текущем кадре стека, вызвав команду info locals.

В DDD вы даже можете отобразить локальные переменные, щелкнув Data | Display Local Variables. Это приведет к тому, что часть окна данных DDD будет посвящена отображению локальных значений (обновляется по мере прохождения программы). Похоже, что в GDB нет прямого способа сделать это, хотя вы можете сделать это для каждой точки останова, включив команду info locals в процедуру commands для каждой точки останова, в которой вы хотите, чтобы локальные значения автоматически распечатывались.

Как вы видели, Eclipse отображает локальные переменные в представлении Variables.

3.2.7 Непосредственное исследование памяти

В некоторых случаях вам может потребоваться просмотреть память по заданному адресу, а не по имени переменной. Для этой цели GDB предоставляет команду x («examine»). В DDD выбираются Data | Memory: определяет начальную точку и количество байтов, а также выбирает между печатью и отображением. В DDD вы выбираете Data | Memory, указываете начальную точку и количество байт, а также выбираете между Print и Display. В Eclipse есть представление Memory, в котором вы можете создавать Memory Monitors.

Обычно это полезно в основном в контексте языка ассемблера и подробно обсуждается в главе 8.

3.2.8 Расширенные параметры печати и отображения

Команды print и display позволяют указать альтернативные форматы. Например,

(gdb) p/x y

отобразит переменную y в шестнадцатеричном формате, а не в десятичном. Другими часто используемыми форматами являются c для символов, s для строк и f для чисел с плавающей запятой.

Вы можете временно отключить отображаемый элемент. Например,

(gdb) dis disp 1

временно отключает пункт 1 в списке отображения. Если вы не знаете номера позиций, вы можете проверить их с помощью команды info disp. Чтобы повторно включить элемент, используйте команду enable, например:

(gdb) enable disp 1

Чтобы полностью удалить отображаемый элемент, используйте команду undisplay, например:

(gdb) undisp 1

3.3 Установка переменных из GDB/DDD/Eclipse

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

В GDB вы можете очень легко устанавливать значения, например:

(gdb) set x = 12

изменит текущее значение x на 12.

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

В Eclipse перейдите в представление Variables, щелкните правой кнопкой мыши переменную, значение которой вы хотите установить, и выберите Change Value. Всплывающее окно позволит вам ввести новое значение.

Вы можете установить аргументы командной строки для вашей программы с помощью команды GDB set args. Однако у этого метода нет никаких преимуществ перед методом, описанным в главе 1, который просто использует новые аргументы при вызове команды run GDB. Эти два метода полностью эквивалентны. Дело не в том, например, что

(gdb) set args 1 52 19 11

немедленно изменит argv[1] на 1, argv[2] на 52 и т.д. Эти изменения не произойдут до тех пор, пока вы в следующий раз не дадите команду run.

В GDB есть команда info args, которую вы можете использовать для проверки аргументов текущей функции. DDD обеспечивает это, когда вы нажимаете Data | Display Arguments.

3.4 Собственные переменные GDB

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

3.4.1 Использование истории значений

Выходные значения команды print GDB обозначаются $1, $2 и т. д., причем эти величины вместе называются историей значений. Их можно использовать для создания ярлыков в будущих командах print, которые вы будете вводить.

Например, рассмотрим пример bintree из нашего основного примера кода. Часть сеанса GDB может выглядеть так:

(gdb) p tmp->left
$1 = (struct node *) 0x80496a8
(gdb) p *(tmp->left)
$2 = {val = 5, left = 0x0, right = 0x0}
(gdb) p *$1
$3 = {val = 5, left = 0x0, right = 0x0}

Здесь произошло следующее: после того, как мы распечатали значение указателя tmp->left и обнаружили, что оно не равно нулю, мы решили распечатать то, на что указывал этот указатель. Мы сделали это дважды: сначала обычным способом, а затем через историю значений.

В этой третьей печати мы ссылались на $1 в истории значений. Если бы мы не использовали традиционную печать, мы могли бы использовать специальную переменную истории $:

(gdb) p tmp->left
$1 = (struct node *) 0x80496a8
(gdb) p *$
$2 = {val = 5, left = 0x0, right = 0x0}

3.4.2 Вспомогательные переменные

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

(gdb) set $q = p

и с этого момента делайте такие вещи, как

(gdb) p *$q

Переменная $q здесь называется вспомогательной переменной (convenience variable).

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

int w[4] = {12,5,8,29};

main()

{
   w[2] = 88;
}

В GDB вы можете сделать что-то вроде


Breakpoint 1, main () at cv.c:7
7          w[2] = 88;
(gdb) n
8       }
(gdb) set $i = 0
(gdb) p w[$i++]
$1 = 12
(gdb)
$2 = 5
(gdb)
$3 = 88
(gdb)
$4 = 29

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

(gdb) p w[$i++]

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

Примечание

Вы можете выбрать практически любое имя для вспомогательной переменной, за некоторыми естественными исключениями. Например, вы не можете использовать вспомогательную переменную с именем $3, поскольку она зарезервирована для элементов в истории значений. Кроме того, вам не следует использовать имена регистров, если вы работаете на языке ассемблера. Например, для машин на базе Intel x86 одно из имен регистров — EAX, а в GDB оно называется $eax; вам не стоит выбирать это имя для вспомогательной переменной, если вы работаете на уровне ассемблера.

Глава 4
Когда программа выключается

Говорят, что C — язык низкого уровня. Частично это означает, что большая часть управления памятью для прикладной программы остается на усмотрение программиста. Хотя этот подход может быть весьма эффективным, он также возлагает на программиста большую ответственность.

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

В общем, программная ошибка может привести к одной из двух вещей:

4.1 Справочный материал: Управление памятью

Что на самом деле происходит, когда программа выходит из строя? Мы объясним это здесь и покажем, как это связано с поиском ошибки, приводящей к сбою.

4.1.1 Почему происходит сбой программы?

Говоря языком мира программирования, программа выходит из строя, когда из-за ошибки она внезапно и ненормально прекращает свое выполнение. Безусловно, наиболее распространенной причиной сбоя является попытка программы получить доступ к ячейке памяти без разрешения на это. Аппаратное обеспечение это почувствует и выполнит переход к операционной системе (ОС). На платформах семейства Unix, которые находятся в центре нашего внимания здесь и в большей части этой книги, операционная система обычно объявляет, что программа вызвала ошибку сегментации, обычно называемую seg fault, и прекращает выполнение программы. В системах Microsoft Windows соответствующий термин означает общая ошибка защиты (general protection fault). Каким бы ни было имя, для возникновения этой ошибки оборудование должно поддерживать виртуальную память, и ОС должна использовать ее. Хотя это является стандартом для сегодняшних компьютеров общего назначения, читателю нужно иметь в виду, что это часто не относится к небольшим компьютерам специального назначения, таким как встроенные компьютеры, используемые для управления машинами.

Чтобы эффективно использовать GDB/DDD для устранения ошибок сегментации, важно точно понимать, как возникают ошибки доступа к памяти. На следующих нескольких страницах мы представим краткое руководство о роли виртуальной памяти (ВМ) во время выполнения программ. Наше особое внимание будет уделено тому, как проблемы виртуальных машин связаны с ошибками сегментации. Таким образом, даже если вы изучали виртуальную машину на курсах по вычислительной технике, этот фокус может дать вам некоторые новые идеи, которые помогут вам справиться с ошибками сегментации в вашей работе по отладке.

4.1.2 Расположение программы в памяти

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

На платформах Unix набор выделенных для программы виртуальных адресов обычно выглядит примерно так, как показано на рисунке 4-1.

Рисунок 4-1. Схема памяти программ

Здесь виртуальный адрес 0 находится внизу, а стрелки показывают направление роста двух компонентов, кучи и стека, съедающих свободную область по мере своего роста. Роли различных частей следующие:

Давайте немного изучим это. Рассмотрим следующий код:

int q[200];

int main( void )
{
   int i, n, *p;
   p = malloc(sizeof(int));
   scanf("%d", &n);
   for (i = 0; i < 200; i++)
      q[i] = i;
   
   printf("%x  %x  %x  %x  %x\n", main, q, p, &i, scanf);

   return 0;
}

Сама программа мало что делает, но мы написали ее как инструмент для неформального исследования структуры виртуального адресного пространства. Для этого давайте запустим ее:

% a.out
5
80483f4  80496a0  9835008  bfb3abec  8048304

Вы можете видеть, что приблизительные местоположения текстового раздела, раздела данных, кучи, стека и динамически связанных функций — 0x080483f4, 0x080496a0, 0x09835008, 0xbfb3abec и 0x08048304 соответственно.

Вы можете получить точную информацию о структуре памяти программы в Linux, просмотрев файл карт (maps) процесса. Номер процесса — 21111, поэтому мы посмотрим на соответствующий файл /proc/21111/maps:

$ cat /proc/21111/maps
009f1000-009f2000 r-xp 009f1000 00:00 0          [vdso]
009f2000-00a0b000 r-xp 00000000 08:01 4116750    /lib/ld-2.4.so
00a0b000-00a0c000 r-xp 00018000 08:01 4116750    /lib/ld-2.4.so
00a0c000-00a0d000 rwxp 00019000 08:01 4116750    /lib/ld-2.4.so
00a0f000-00b3c000 r-xp 00000000 08:01 4116819    /lib/libc-2.4.so
00b3c000-00b3e000 r-xp 0012d000 08:01 4116819    /lib/libc-2.4.so
00b3e000-00b3f000 rwxp 0012f000 08:01 4116819    /lib/libc-2.4.so
00b3f000-00b42000 rwxp 00b3f000 00:00 0
08048000-08049000 r-xp 00000000 00:16 18815309   /home/matloff/a.out
08049000-0804a000 rw-p 00000000 00:16 18815309   /home/matloff/a.out
09835000-09856000 rw-p 09835000 00:00 0          [heap]
b7ef8000-b7ef9000 rw-p b7ef8000 00:00 0
b7f14000-b7f16000 rw-p b7f14000 00:00 0
bfb27000-bfb3c000 rw-p bfb27000 00:00 0          [stack]

Вам не обязательно все это понимать. Дело в том, что на этом дисплее вы можете видеть свои разделы текста и данных (из файла a.out), а также кучу и стек. Вы также можете увидеть, где размещена библиотека C (для вызовов scanf(), malloc() и printf()) (из файла /lib/libc-2.4.so). Вам также следует узнать поле разрешений, формат которого аналогичен знакомым разрешениям для файлов, отображаемым ls, и указывает, например, такие привилегии, как rw-p. Последнее будет объяснено в ближайшее время.

4.1.3 Понятие страниц

Виртуальное адресное пространство, показанное на рисунке 4-1, концептуально простирается от 0 до 2w-1, где w — размер слова вашей машины в битах. Конечно, ваша программа обычно будет использовать лишь небольшую часть этого пространства, и ОС может зарезервировать часть пространства для своей работы. Но ваш код с помощью указателей может сгенерировать адрес в любом месте этого диапазона. Часто такие адреса будут неверными из-за «энтомологических условий», то есть из-за ошибок (bugs, т.е. жуков) в вашей программе!

Это виртуальное адресное пространство рассматривается как организованное в блоки, называемые страницами (pages). На оборудовании Pentium размер страницы по умолчанию составляет 4096 байт. Физическая память (как ОЗУ, так и ПЗУ) также рассматривается как разделенная на страницы. Когда программа загружается в память для выполнения, ОС сохраняет некоторые страницы программы на страницах физической памяти. Говорят, что эти страницы являются резидентными (resident), а остальные хранятся на диске.

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

Чтобы управлять всем этим, ОС поддерживает таблицу страниц (page table) для каждого процесса. (Таблицы страниц Pentium имеют иерархическую структуру, но здесь для простоты мы предполагаем только один уровень, и большая часть этого обсуждения не будет касаться конкретно Pentium.) Каждая виртуальная страница процесса имеет запись в таблице, которая включает в себя следующую информацию:

Обратите внимание, что ОС не выделяет программе частичные страницы. Например, если общий размер запускаемой программы составляет около 10 000 байт, при полной загрузке она будет занимать три страницы памяти. Она не просто занимала бы около 2,5 страниц, поскольку страницы — это наименьшая единица памяти, которой управляет система VM. Это важный момент, который следует понимать при отладке, поскольку он подразумевает, что некоторые ошибочные обращения к памяти со стороны программы не будут вызывать ошибки сегментации, как вы увидите ниже. Другими словами, во время сеанса отладки вы не можете сказать что-то вроде: «Эта строка исходного кода, должно быть, в порядке, поскольку она не вызвала ошибку сегментации».

4.1.4 Подробности о роли таблицы страниц

Помните о виртуальном адресном пространстве, показанном в листинге выше, и продолжайте считать, что размер страницы составляет 4096 байт. Тогда виртуальная страница 0 включает байты виртуального адресного пространства с 0 по 4095, инструменты отладки, используемые в этой книге, включают байты с 4096 по 8191 и так далее.

Как уже упоминалось, когда мы запускаем программу, ОС создает таблицу страниц, которую она использует для управления виртуальной памятью процесса, выполняющего код программы. (Обзор процессов ОС представлен в материале о потоках в главе 5.) При каждом запуске этого процесса регистр таблицы страниц оборудования будет указывать на эту таблицу.

Концептуально говоря, каждая страница виртуального адресного пространства процесса имеет запись в таблице страниц (на практике для сжатия таблицы можно использовать различные трюки). Эта запись таблицы страниц хранит различную информацию, связанную со страницей. Данные, представляющие интерес в отношении ошибок сегментации, — это права доступа (access permissions) к странице, которые аналогичны разрешениям доступа к файлам: чтение, запись и выполнение. Например, запись таблицы страниц для страницы 3 укажет, имеет ли ваш процесс право читать данные с этой страницы, право записывать в нее данные и право выполнять на ней инструкции (если страница содержит машинный код).

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

Во время выполнения программы генерируемые ею адреса будут виртуальными. Когда программа пытается получить доступ к памяти по определенному виртуальному адресу, скажем, y, аппаратное обеспечение преобразует его в номер виртуальной страницы v, который равен y, разделенному на 4096 (где при делении используется целочисленная арифметика, отбрасывая остаток). Затем оборудование проверит запись v в таблице страниц, чтобы определить, соответствуют ли разрешения для страницы выполняемой операции. Если они совпадают, оборудование получит фактический номер физической страницы нужного места из этой записи таблицы, а затем выполнит запрошенную операцию с памятью. Но если запись в таблице показывает, что запрошенная операция не имеет надлежащего разрешения, аппаратное обеспечение выполнит внутреннее прерывание. Это приведет к переходу к процедуре обработки ошибок ОС. Обычно ОС затем объявляет о нарушении доступа к памяти и прекращает выполнение программы (т. е. удаляет ее из таблицы процессов и из памяти).

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

int x[100];

и предположим, что ваш код содержит оператор

x[i] = 3;

Напомним, что в C/C++ выражение x[i] эквивалентно (и на самом деле означает) *(x+i), то есть содержимому ячейки памяти, на которую указывает адрес x+i. Если смещение i равно, скажем, 200000, то это, скорее всего, приведет к созданию адреса виртуальной памяти y, который находится за пределами набора страниц, назначенных ОС для раздела данных программы, где компилятор и компоновщик разместили для хранения массив x[]. Тогда при попытке операции записи произойдет ошибка сегментации.

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

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

call sink

вместо

call sunk

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

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

z = sink(5);

если sink объявлен как переменная. Но эта ошибка может легко возникнуть при использовании указателей на функции. Рассмотрим такой код:

int f(int x)
{
   return x*x;
}

int (*p)(int);

int main( void )
{
   p = f;
   u = (*p)(5);
   printf("%d\n", u);

   return 0;
}

Если бы вы забыли утверждение p = f; тогда p будет равно 0, и вы попытаетесь выполнить инструкции, находящиеся на странице 0, странице, для которой у вас нет разрешения на выполнение (или другого разрешения) (вспомните рисунок 4-1).

4.1.5 Незначительная ошибка доступа к памяти может не вызвать ошибку сегментации

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

int q[200];

main()
{
   int i;
   for (i = 0; i < 2000; i++) {
      q[i] = i;
   }
}

Обратите внимание, что программист, очевидно, допустил опечатку в цикле, установив 2000 итераций вместо 200. Компилятор C не уловит это во время компиляции, а машинный код, сгенерированный компилятором, не будет проверять во время выполнения, находится ли индекс массива за пределами допустимых значений. (Это значение GCC по умолчанию, хотя он также предлагает опцию -fmudflap, которая обеспечивает такую проверку индекса во время выполнения.)

Во время выполнения вполне вероятно возникновение ошибки сегментации. Однако время возникновения ошибки может вас удивить. Ошибка вряд ли появится в «естественное» время, то есть когда i = 200; скорее, это, вероятно, произойдет намного позже.

Чтобы проиллюстрировать это, мы запустили эту программу на ПК с Linux под управлением GDB, чтобы удобно запрашивать адреса переменных. Оказалось, что ошибка сегментации произошла не при i = 200, а при i = 728. (Ваша система может давать другие результаты, но принципы будут одинаковыми.) Давайте разберемся, почему.

Из запросов к GDB мы обнаружили, что массив q[] заканчивается по адресу 0x80497bf; то есть последний байт q[199] находился в этой ячейке памяти. Принимая во внимание размер страницы Intel в 4096 байт и 32-битный размер слова этой машины, виртуальный адрес разбивается на 20-битный номер страницы и 12-битное смещение. В нашем случае q[] заканчивался номером виртуальной страницы 0x8049 = 32841, смещение 0x7bf = 1983. Таким образом, на странице памяти, на которой был выделен q, все еще оставалось 4096 – 1984 = 2112 байт. Это пространство может содержать 2112/4 = 528 целочисленных переменных (поскольку каждая из них имеет ширину 4 байта на используемой здесь машине), и наш код обрабатывал его так, как если бы он содержал элементы q в «позициях» с 200 по 727.

Этих элементов q[], конечно, не существует, но компилятор не жаловался. Аппаратное обеспечение тоже не сделало этого, поскольку запись все еще выполнялась на страницу, для которой у нас наверняка были права на запись (поскольку некоторые из реальных элементов q[] лежали на ней, а q[] размещался в сегменте данных). Только когда i стало 728, q[i] начал ссылаться на адрес на другой странице. В данном случае это была страница, для которой у нас не было разрешения на запись (или какого-либо другого разрешения); оборудование виртуальной памяти обнаружило это и вызвало ошибку сегментации.

Поскольку каждая целочисленная переменная хранится в 4 байтах, эта страница содержит 528 (2112/4) дополнительных «фантомных» элементов, которые код обрабатывает как принадлежащие массиву q[]. Итак, хотя мы и не собирались этого делать, все же разрешен доступ к q[200], q[201] и так далее, вплоть до элемента 199 + 528 = 727, то есть q[727] — без возникновения ошибки сегментации! Только когда вы пытаетесь получить доступ к q[728], вы сталкиваетесь с новой страницей, для которой у вас могут быть или не быть необходимые права доступа. Здесь этого не произошло, и поэтому программа дала сбой. Однако по счастливой случайности следующей странице могли быть назначены соответствующие привилегии, и тогда фантомных элементов массива было бы еще больше.

Мораль: как говорилось ранее, из-за отсутствия ошибки сегментации мы не можем заключить, что операция с памятью правильна.

4.1.6 Ошибки сегментации и сигналы Unix

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

Сигналы указывают на исключительные условия и передаются во время выполнения программы, чтобы позволить ОС (или вашему собственному коду) реагировать на различные события. Сигнал в процессе может быть выдан базовым аппаратным обеспечением системы (как в случае с SIGSEGV или SIGFPE), операционной системой (как в случае с SIGTERM или SIGABRT) или другим процессом (как в случае SIGUSR1 или SIGUSR2), либо он может даже быть отправленным самим процессом (через вызов библиотеки raise()).

Самый простой пример сигнала — нажатие CTRL-C на клавиатуре во время работы программы. Нажатие (или отпускание) любой клавиши на клавиатуре генерирует аппаратное прерывание, которое запускает процедуру ОС. Когда вы нажимаете CTRL-C, ОС распознает эту комбинацию клавиш как специальный шаблон и выдает сигнал SIGINT для процесса на управляющем терминале. В просторечии говорят, что ОС «посылает сигнал процессу». Мы будем использовать эту фразу, но важно понимать, что на самом деле в процесс ничего не «отправляется». Все, что происходит, это то, что ОС записывает сигнал в свою таблицу процессов, так что в следующий раз, когда процесс, получающий сигнал, получит квант времени в ЦП, будет выполнена соответствующая функция обработчика сигнала, как описано ниже. (Однако, учитывая предполагаемую срочность сигналов, ОС может также решить предоставить принимающему процессу следующий интервал времени раньше, чем это было бы в противном случае.)

Существует множество различных типов сигналов, которые могут быть выданы в процессе. В Linux вы можете просмотреть весь список сигналов, набрав

man 7 signal

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

Каждый сигнал имеет свой собственный обработчик сигнала (signal handler), который представляет собой функцию, которая вызывается, когда этот конкретный сигнал возникает в процессе. Возвращаясь к нашему примеру CTRL-C, когда SIGINT активируется, ОС устанавливает текущую инструкцию процесса в начало обработчика сигнала для этого конкретного сигнала. Таким образом, когда процесс возобновится, он выполнит обработчик.

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

Некоторые обработчики сигналов невозможно переопределить, но во многих случаях вы можете написать свой собственный обработчик, чтобы заменить обработчик по умолчанию, предоставляемый ОС. В Unix это делается с помощью системных вызовов signal() или sigaction()[1]. Ваша пользовательская функция-обработчик может, например, игнорировать сигнал или даже попросить пользователя выбрать образ действий.

Просто ради развлечения мы написали программу, которая иллюстрирует, как можно написать собственный обработчик сигналов и с помощью signal() вызывать или переопределять обработчик ОС по умолчанию или игнорировать сигнал. Мы выбрали SIGINT, но вы можете сделать то же самое для любого сигнала, который можно поймать. Программа также демонстрирует использование метода raise().

#include <signal.h>
#include <stdio.h>

void my_sigint_handler( int signum )
{
   printf("I received signal %d (that's 'SIGINT' to you).\n", signum);
   puts("Tee Hee! That tickles!\n");
}

int main(void)
{
   char choicestr[20];
   int choice;

   while ( 1 )
   {
      puts("1. Ignore control-C");
      puts("2. Custom handle control-C");
      puts("3. Use the default handler control-C");
      puts("4. Raise a SIGSEGV on myself.");
      printf("Enter your choice: ");

      fgets(choicestr, 20, stdin);
      sscanf(choicestr, "%d", &choice);

      if ( choice == 1 )
         signal(SIGINT, SIG_IGN); // Ignore control-C
      else if ( choice == 2 )
         signal(SIGINT, my_sigint_handler);
      else if ( choice == 3 )
         signal(SIGINT, SIG_DFL);
      else if ( choice == 4 )
         raise(SIGSEGV);
      else
         puts("Whatever you say, guv'nor.\n\n");
   }

   return 0;
}

Когда программа совершает нарушение доступа к памяти, в процессе генерируется сигнал SIGSEGV. Обработчик ошибок сегментации по умолчанию завершает процесс и записывает на диск «core file», который мы вскоре объясним.

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

Однако специальные обработчики сигналов могут вызвать осложнения при использовании GDB/DDD/Eclipse. Независимо от того, используется ли он сам по себе или через графический интерфейс DDD, GDB останавливает процесс при появлении любого сигнала. В случае приложений, которые работают подобно только что упомянутому программному обеспечению параллельной обработки, это означает, что GDB будет очень часто останавливаться по причинам, не связанным с вашей работой по отладке. Чтобы справиться с этим, вам нужно будет указать GDB не останавливаться при возникновении определенных сигналов, используя команду handle.

  1. Есть две функции, которые используются для переопределения обработчиков сигналов по умолчанию, поскольку Linux, как и другие Unix-системы, соответствует множеству стандартов. Функция signal(), которую проще использовать, чем sigaction(), соответствует стандарту ANSI, тогда как функция sigaction() более сложна, но также более универсальна и соответствует стандарту POSIX.

4.1.7 Другие типы исключений

Помимо ошибок сегментации, существуют и другие источники сбоев. Исключения с плавающей запятой (floating-point exceptions) (FPE) вызывают появление сигнала SIGFPE. Хотя этот сигнал называется исключением «с плавающей запятой», он также охватывает исключения целочисленной арифметики, такие как условия переполнения и деления на ноль. В системах GNU и BSD обработчикам FPE передается второй аргумент, указывающий причину FPE. Обработчик по умолчанию игнорирует SIGFPE при некоторых обстоятельствах, например, при переполнении с плавающей запятой, и завершает процесс при других обстоятельствах, например, при делении целого числа на ноль.

Ошибка шины (bus error) возникает, когда ЦП обнаруживает аномальное состояние на своей шине во время выполнения машинных инструкций. Различные архитектуры имеют разные требования к тому, что должно происходить на шине, и точная причина аномалии зависит от архитектуры. Некоторые примеры ситуаций, которые могут вызвать ошибку шины, включают следующее:

4.2 Core-файлы

Как упоминалось ранее, некоторые сигналы указывают на то, что продолжать процесс нецелесообразно или даже невозможно. В этих случаях действием по умолчанию является преждевременное завершение процесса и запись файла, называемого core file (core-файл), который в просторечии называется дампом ядра (dumping core). Запись core-файлов может быть запрещена вашей оболочкой (подробности см. в разделе 4.2.2).

Если core-файл создается во время работы вашей программы, вы можете открыть отладчик, скажем, GDB, для этого файла, а затем продолжить свои обычные операции с GDB.

4.2.1 Как создаются core-файлы

Core-файл содержит подробное описание состояния программы на момент ее завершения: содержимое стека (или, если программа многопоточная, стеки для каждого потока), содержимое регистров процессора (опять же, с одним набором значений регистров для каждого потока, если программа многопоточная), значения статически размещенных переменных программы (глобальных и static переменных) и так далее.

Создать core-файл очень легко. Вот код, который генерирует его:

int main(void)
{
   abort();

   return 0;
}

Листинг 4-1. abort.c

Функция abort() заставляет текущий процесс получать сигнал SIGABRT, а обработчик сигнала по умолчанию для SIGABRT завершает программу и выгружает дамп ядра. Вот еще одна короткая программа, которая выгружает дамп ядра. В этой программе мы намеренно разыменовываем NULL-указатель:

int main(void)
{
   char *c = 0;
   printf("%s\n", *c);

   return 0;
}

Листинг 4-2. sigsegv.c

Давайте сгенерируем core-файл. Скомпилируйте и запустите sigsegv.c:

$ gcc -g -W -Wall sigsegv.c -o sigsegv
$ ./sigsegv
Segmentation fault (core dumped)

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

$ file core
core: ELF 32-bit LSB core file Intel 80386, version 1 (SYSV), SVR4-style,
SVR4-style, from 'sigsegv'

4.2.2 Ваша оболочка может подавить создание core-файла

Во многих, если не в большинстве случаев, процесс отладки не задействует core-файлы. Если в сегменте программы произошел сбой, программист просто открывает отладчик, например GDB, и запускает программу еще раз, чтобы воссоздать ошибку. По этой причине, а также потому, что core-файлы имеют тенденцию быть большими, большинство современных оболочек имеют механизмы, которые в первую очередь предотвращают запись core-файлов.

В bash вы можете управлять созданием core-файлов с помощью команды ulimit:

ulimit -c n

где n — максимальный размер core-файла в килобайтах. Любой core-файл размером более nKB не будет записан. Если вы не укажете n, оболочка отобразит текущее ограничение на core-файлы. Если вы хотите разрешить core-файл любого размера, вы можете использовать

ulimit -c unlimited

Для пользователей tcsh и csh команда limit контролирует размеры core-файлов. Например,

limit coredumpsize 1000000

сообщит оболочке, что вы не хотите создавать core-файл, если его размер превышает миллион байт.

Если вы не получили core-файл после запуска sigsegv, проверьте текущие ограничения core-файла, используя ulimit -c для bash или limit -c для tcsh или csh.

Зачем вам вообще нужен core-файл? Поскольку вы можете просто перезапустить программу, в которой произошла ошибка сегментации, из GDB и воссоздать ошибку сегментации, зачем вообще беспокоиться о core-файлах? Ответ заключается в том, что в некоторых ситуациях, например в следующих, это предположение не оправдано:

Однако обратите внимание: если исходный код программы недоступен или если исполняемый файл не был скомпилирован с расширенной таблицей символов, или даже если мы не планируем отлаживать исполняемый файл, core-файлы просто бесполезны.

4.3 Расширенный пример

В этом разделе мы представляем подробный пример отладки ошибок сегментации.

Ниже приведен код C, который может быть частью реализации управляемого типа строки, аналогичного строкам C++. Код, содержащийся в исходном файле cstring.c, реализует тип CString; однако он пронизан ошибками, очевидными и незаметными. Наша цель — найти все эти ошибки и исправить их.

CString — это типизированный псевдоним для структуры, содержащей указатель на хранилище символьной строки вместе с переменной, в которой хранится длина строки. Реализованы некоторые служебные функции, полезные для обработки строк:

Init_CString()
Принимает в качестве аргумента строку C старого стиля и использует ее для инициализации новой CString.
Delete_CString()
Структуры CString размещаются в куче, и их память должна быть освобождена, когда она больше не нужна. Эта функция занимается сборкой мусора.
Chomp()
Удаляет и возвращает последний символ CString.
Append_Chars_To_CString()
Добавляет строку в стиле C к CString.

Наконец, main() — это наша функция драйвера для проверки реализации CString.

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

#include <stdio.h>
#define STRSIZE 22

int main(void)
{
   char s1[] = "brake";
   char *s2 = "breakpoints";
   char logo[STRSIZE];

   snprintf(logo, STRSIZE, "%c %s %d %s.", 'I', s1, 2+2, s2);

   puts(logo);
   return 0;
}

Эта программа напишет строку «I brake 4 breakpoints» в массив символов logo, готовый к печати на наклейке на бампер.

Теперь вот реализация нашей CString:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
   char *str;
   int   len;
} CString;

CString *Init_CString(char *str)
{
   CString *p = malloc(sizeof(CString));
   p->len = strlen(str);
   strncpy(p->str, str, strlen(str) + 1);
   return p;
}

void Delete_CString(CString *p)
{
   free(p);
   free(p->str);
}

// Removes the last character of a CString and returns it.
//
char Chomp(CString *cstring)
{
   char lastchar = *( cstring->str + cstring->len);
   // Shorten the string by one
   *( cstring->str + cstring->len) = '0';
   cstring->len = strlen( cstring->str );

   return lastchar;
}

// Appends a char * to a CString
//
CString *Append_Chars_To_CString(CString *p, char *str)
{
   char *newstr = malloc(p->len + 1);
   p->len = p->len + strlen(str);

   // Create the new string to replace p->str
   snprintf(newstr, p->len, "%s%s", p->str, str);
   // Free old string and make CString point to the new string
   free(p->str);
   p->str = newstr;

   return p;
}

int main(void)
{
   CString *mystr;
   char c;

   mystr = Init_CString("Hello!");
   printf("Init:\n str: `%s' len: %d\n", mystr->str, mystr->len);
   c = Chomp(mystr);
   printf("Chomp '%c':\n str:`%s' len: %d\n", c, mystr->str, mystr->len);
   mystr = Append_Chars_To_CString(mystr, " world!");
   printf("Append:\n str: `%s' len: %d\n", mystr->str, mystr->len);

   Delete_CString(mystr);

   return 0;
}

Изучите код и попытайтесь угадать, каким должен быть результат. Затем скомпилируйте и запустите его.

$ gcc -g -W -Wall cstring.c -o cstring
$ ./cstring
Segmentation fault (core dumped)

Упс! Первое, что нам нужно сделать, это выяснить, где произошла ошибка сегментации. Тогда мы сможем попытаться выяснить, почему это произошло.

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

Пока Милтон открывает Wordpad, мы воспользуемся GDB для проверки основного файла:

$ gdb cstring core
Core was generated by `cstring'.
Program terminated with signal 11, Segmentation fault.
#0 0x400a9295 in strncpy () from /lib/tls/libc.so.6

(gdb) backtrace
#0 0x400a9295 in strncpy () from /lib/tls/libc.so.6
#1 0x080484df in Init_CString (str=0x80487c5 "Hello!") at cstring.c:15
#2 0x080485e4 in main () at cstring.c:62

Согласно выводам обратной трассировки, ошибка сегментации произошла в строке 15 в Init_CString() во время вызова strncpy(). Даже не глядя на код, мы уже знаем, что вполне вероятно, что мы передали NULL-указатель в strncpy() в строке 15.

На данный момент Милтон все еще пытается решить, куда вставить первый из многих вызовов printf().

4.3.1 Первая ошибка

GDB сообщил нам, что ошибка сегментации произошла в строке 15 в Init_CString(), поэтому мы изменим текущий кадр на тот, который предназначен для вызова Init_CString().

(gdb) frame 1
#1 0x080484df in Init_CString (str=0x80487c5 "Hello!") at cstring.c:15
15             strncpy(p->str, str, strlen(str) + 1);

Мы применим принцип подтверждения, просматривая каждый из аргументов-указателей, переданных в strncpy(), а именно: str, p и p->str, и проверяя, являются ли их значения такими, какими, по нашему мнению, они должны быть. Сначала мы печатаем значение str:

(gdb) print str
$1 = 0x80487c5 "Hello!"

Поскольку str является указателем, GDB дает нам его значение в виде шестнадцатеричного адреса 0x80487c5. А поскольку str является указателем на char и, следовательно, адресом символьной строки, GDB также сообщает нам значение этой строки: «Hello!» Это также было ясно из результатов обратной трассировки, которые мы видели выше, но нам все равно следует проверить. Итак, str не равен NULL и указывает на действительную строку, и пока все в порядке.

Теперь давайте обратим внимание на другие аргументы-указатели, p и p->str:

(gdb) print *p
$2 = {
  str = 0x0,
  len = 6
}

Проблема теперь ясна: p->str, который также является указателем на строку, имеет значение NULL. Это объясняет ошибку сегментации: мы пытались выполнить запись в ячейку 0 в памяти, которая для нас закрыта.

Но что может привести к тому, что p->str (указатель на строку в структуре CString) станет NULL? Ну, взглянем на код,

(gdb) list Init_CString
5       typedef struct {
6               char *str;
7               int   len;
8       } CString;
9
10
11      CString *Init_CString(char *str)
12      {
13              CString *p = malloc(sizeof(CString));
14              p->len = strlen(str);
15              strncpy(p->str, str, strlen(str) + 1);
16              return p;
17      }
18

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

Мы перезапустим программу из GDB, установим временную точку останова при входе в Init_CString() и будем выполнять эту функцию построчно, просматривая значение p->str.

(gdb) tbreak Init_CString
Breakpoint 1 at 0x804849b: file cstring.c, line 13.
(gdb) run

Breakpoint 1, Init_CString (str=0x80487c5 "Hello!") at cstring.c:13
13              CString *p = malloc(sizeof(CString));
(gdb) step
14              p->len = strlen(str);
(gdb) print p->str
$4 = 0x0
(gdb) step
15              strncpy(p->str, str, strlen(str) + 1);

Вот в чем проблема: мы собираемся совершить ошибку сегментации, потому что следующая строка кода разыменовывает p->str, а p->str все еще имеет значение NULL. Теперь мы используем маленькие серые клеточки, чтобы выяснить, что произошло.

Когда мы выделили память для p, мы получили достаточно памяти для нашей структуры: указатель для хранения адреса строки и целое число для хранения длины строки, но мы не выделили память для хранения самой строки. Мы допустили распространенную ошибку, объявив указатель и не указывая ни на что! Что нам нужно сделать, так это сначала выделить достаточно памяти для хранения str, а затем заставить p->str указывать на эту вновь выделенную память. Вот как мы можем это сделать (нам нужно добавить единицу к длине строки, потому что strlen() не учитывает завершающий '\0'):

CString *Init_CString(char *str)
{
   // Allocate for the struct
   CString *p = malloc(sizeof(CString));
   p->len = strlen(str);
   // Allocate for the string
   p->str = malloc(p->len + 1);
   strncpy(p->str, str, strlen(str) + 1);
   return p;
}

Кстати, Милтон только что закончил вставлять вызовы printf() в свой код и собирается перекомпилировать его. Если ему повезет, он узнает, где произошел сбой сегментации. Если нет, ему придется добавить еще больше вызовов printf().

4.3.2 Не покидайте GDB во время сеанса отладки

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

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

Итак, на экране обычно имеется одно окно для GDB (или DDD) и одно окно для редактора. Мы также либо откроем третье окно для ввода команд компилятора, либо, что еще лучше, выполним их через редактор. Например, если вы используете редактор Vim, вы можете ввести следующую команду, которая сохранит изменения и перекомпилирует программу за один шаг:

: make

(Мы предполагаем, что вы установили переменную autowrite Vim, используя set autowrite, в файле запуска Vim. Эта функция Vim также переместит ваш курсор к первому сообщенному предупреждению или ошибке компиляции, если таковые имеются, и вы можете продолжить туда и обратно по списку ошибок компиляции с помощью команд Vim :cnext и :cprev. Конечно, все это станет проще, если вы поместите короткие псевдонимы для этих команд в файл запуска Vim.)

4.3.3 Вторая и третья ошибки

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

(gdb) run
     The program being debugged has been started already.
     Start it from the beginning? (y or n) y

`cstring' has changed; re-reading symbols.

Starting program: cstring

Init:
  str: `Hello!' len: 6
Chomp '':
  str:`Hello!0' len: 7
Append:
  str: `Hello!0 world' len: 14

Program exited normally.
(gdb)

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

(gdb) tbreak Chomp
Breakpoint 2 at 0x8048523: file cstring.c, line 32.
(gdb) run
Starting program: cstring

Init:
  str: `Hello!' len: 6

Breakpoint 1, Chomp (cstring=0x804a008) at cstring.c:32
32              char lastchar = *( cstring->str + cstring->len);
(gdb)

Последним символом строки должен быть '!'. Давайте подтвердим это.

(gdb) print lastchar
$1 = 0 '\0'

Мы ожидали, что последний символ будет «!», но вместо этого это нулевой символ. Похоже, что это, вероятно, ошибка «off by one» (ошибка на единицу). Давайте разберемся. Мы можем визуализировать строку следующим образом:

pointer offset: 0 1 2 3 4 5 6
cstring->str:   H e l l o ! \0
string length:  1 2 3 4 5 6

Последний символ строки хранится по адресу cstring->str + 5, но поскольку длина строки представляет собой количество символов, а не индекс, адрес cstring->str + cstring->len указывает на одно место массива после последнего символа, где находится завершающий NULL вместо того места, куда мы хотели бы, чтобы он указывал. Мы можем решить эту проблему, изменив строку 31 с

char lastchar = *( cstring->str + cstring->len);

на

char lastchar = *( cstring->str + cstring->len - 1);

В этой части кода скрывается третья ошибка. После вызова Chomp() строка «Hello!» стала «Hello!0» (вместо «Hello»). В следующей строке GDB, строке 33, мы хотим сократить строку, заменив ее последний символ завершающим нулевым символом:

*( cstring->str + cstring->len) = '0';

Мы сразу видим, что эта строка содержит ту же проблему, которую мы только что исправили в строке 31: мы неправильно ссылаемся на последний символ строки. Более того, теперь, когда наши глаза натренированы на эту строку кода, кажется, что мы сохраняем в этом месте символ '0', который не является нулевым символом. Мы хотели поместить '\0' в конец строки. После внесения этих двух исправлений строка 33 выглядит так:

*( cstring->str + cstring->len - 1) = '\0';

На этом этапе Милтон, наш коллега, использующий printf(), обнаружил первую ошибку сегментации и сейчас исправляет проблему распределения памяти в Init_CString(). Вместо того, чтобы переходить к ошибкам, которые мы только что исправили в Chomp(), ему придется удалить все вызовы printf() и перекомпилировать программу. Как неудобно!

4.3.4 Четвертая ошибка

Давайте внесем исправления, обсуждаемые в предыдущем разделе, перекомпилируем код и снова запустим программу:

(gdb) run
`cstring' has changed; re-reading symbols.
Starting program: cstring

Init:
  str: `Hello!' len: 6
Chomp '!':
  str:`Hello' len: 5
Append:
  str: `Hello world' len: 12

Program received signal SIGSEGV, Segmentation fault.
0xb7f08da1 in free () from /lib/tls/libc.so.6
(gdb)

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

(gdb) backtrace
#0 0xb7f08da1 in free () from /lib/tls/libc.so.6
#1 0x0804851a in Delete_CString (p=0x804a008) at cstring3.c:24
#2 0x08048691 in main () at cstring3.c:70
(gdb)

Судя по строке 3 результатов обратной трассировки, наше предположение неверно: программа действительно аварийно завершилась в методе Delete_CString(). Это не означает, что у нас также нет ошибки в Append_Chars_To_CString(), но наша непосредственная ошибка, та, которая вызвала ошибку сегментации, находится в Delete_CString(). Именно поэтому мы используем здесь GDB для проверки наших ожиданий — он полностью устраняет любые догадки при поиске места ошибки сегментации. Как только наш друг, использующий printf(), дойдет до этого момента в своей отладке, он поместит код трассировки не в ту функцию!

К счастью, функция Delete_CString() короткая, поэтому мы сможем быстро найти причину ошибки.

(gdb) list Delete_CString
20
21   void Delete_CString(CString *p)
22   {
23      free(p);
24      free(p->str);
25   }
26

Сначала мы освобождаем память p, затем освобождаем память p->str. Это считается не такой уж и незаметной ошибкой. После освобождения p нет никакой гарантии, что p->str указывает на правильное место в памяти; он может указывать куда угодно. В данном случае этим «где угодно» была память, к которой мы не могли получить доступ, что привело к ошибке сегментации. Исправление состоит в том, чтобы изменить порядок вызовов free():

void Delete_CString(CString *p)
{
   free(p->str);
   free(p);
}

Кстати, Милтон слишком расстроился, пытаясь отследить ошибку отклонения на единицу в Chomp(), которую мы так легко исправили. Это он сейчас звонит нам по телефону и просит о помощи.

4.3.5 Пятая и шестая ошибки

Исправляем, перекомпилируем и еще раз запускаем код.

(gdb) run
`cstring' has changed; re-reading symbols.
Starting program: cstring

Init:
  str: `Hello!' len: 6
Chomp '!':
  str:`Hello' len: 5
Append:
  str: `Hello world' len: 12

Program exited normally.
(gdb)

После операции добавления в строке отсутствует восклицательный знак, который должен быть в "Hello world!". Любопытно, что заявленная длина строки 12 верна, даже если строка неверна. Наиболее логично искать эту ошибку в Append_Chars_To_CString(), поэтому мы разместим там точку останова:

(gdb) tbreak Append_Chars_To_CString
Breakpoint 3 at 0x8048569: file cstring.c, line 45.
(gdb) run
Starting program: cstring

Init:
  str: `Hello!' len: 6
Chomp '!':
  str:`Hello' len: 5

Breakpoint 1, Append_Chars_To_CString (p=0x804a008, str=0x8048840 " world!") at cstring.c:45
45              char *newstr = malloc(p->len + 1);

Строка C newstr должна быть достаточно большой, чтобы вместить как p->str, так и str. Мы видим, что вызов malloc() в строке 45 не выделяет достаточно памяти; он выделяет достаточно места только для p->str и завершающего нуля. Строку 45 следует изменить на

char *newstr = malloc(p->len + strlen(str) + 1);

После внесения исправлений и перекомпиляции мы получим следующий результат:

(gdb) run
`cstring' has changed; re-reading symbols.
Starting program: cstring

Init:
  str: `Hello!' len: 6
Chomp '!':
  str:`Hello' len: 5
Append:
  str: `Hello world' len: 12

Наше исправление не устранило ошибку, которую мы имели в виду. Мы обнаружили и исправили «тихую ошибку» (silent bug). Не заблуждайтесь: это была ошибка, и тот факт, что она не проявилась как ошибка сегментации, был чисто вопросом удачи. Весьма вероятно, что оставшаяся ошибка все еще находится в Append_Chars_To_CString(), поэтому мы установим там еще одну точку останова:

(gdb) tbreak Append_Chars_To_CString
Breakpoint 4 at 0x8048569: file cstring.c, line 45.
(gdb) run
Starting program: cstring

(gdb) run
Init:
  str: `Hello!' len: 6
Chomp '!':
  str:`Hello' len: 5

Breakpoint 1, Append_Chars_To_CString (p=0x804a008, str=0x8048840 " world!") at cstring.c:45
45              char *newstr = malloc(p->len + strlen(str) + 1);
(gdb) step
46              p->len = p->len + strlen(str);

В строке 46 показано, почему длина строки верна, хотя сама строка неверна: сложение правильно вычисляет длину p->str, объединенной с str. Здесь нет проблем, поэтому мы сделаем шаг вперед.

(gdb) step
49              snprintf(newstr, p->len, "%s%s", p->str, str);

В следующей строке кода, строке 49, мы формируем новую строку. Мы ожидаем, что newsstr будет содержать фразу "Hello world!" после этого шага. Давайте применим принцип подтверждения и проверим это.

(gdb) step
51              free(p->str);
(gdb) print newstr
$2 = 0x804a028 "Hello world"

В строке, построенной в строке 51 кода, отсутствует восклицательный знак, поэтому ошибка, вероятно, возникает в строке 49, но что это может быть? При вызове snprintf() мы запросили копирование не более p->len байт в newstr. Было подтверждено, что значение p->len равно 12, а текст «Hello world!» имеет 12 символов. Мы не указали snprintf() копировать завершающий нулевой символ в исходной строке. Но разве мы не должны были получить искаженную строку с восклицательным знаком в последней позиции и без нуля?

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

Поэтому строку 45 необходимо изменить. Вот полная, исправленная функция:

CString *Append_Chars_To_CString(CString *p, char *str)
{
   char *newstr = malloc(p->len + strlen(str) + 1);
   p->len = p->len + strlen(str);

   // Create the new string to replace p->str
   snprintf(newstr, p->len + 1, "%s%s", p->str, str);
   // Free old string and make CString point to the new string
   free(p->str);
   p->str = newstr;

   return p;
}

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

(gdb) run
`cstring' has changed; re-reading symbols.
Starting program: cstring

Init:
  str: `Hello!' len: 6
Chomp '!':
  str:`Hello' len: 5
Append:
  str: `Hello world!' len: 12

Program exited normally.
(gdb)

Выглядит неплохо!

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

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

Глава 5
Отладка в контексте нескольких действий

Отладка — сложная задача, и она становится еще более сложной, когда неправильно работающее приложение пытается координировать несколько одновременных действий; Программирование сети клиент/сервер, программирование с использованием потоков и параллельная обработка являются примерами этой парадигмы. В этой главе представлен обзор наиболее часто используемых методов мультипрограммирования и предложены некоторые советы о том, как бороться с ошибками в такого рода программах, уделяя особое внимание использованию GDB/DDD/Eclipse в процессе отладки.

5.1 Отладка сетевых программ клиент/сервер

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

Наш пример состоит из следующей пары клиент/сервер. Клиентское приложение позволяет пользователю проверять загрузку компьютера, на котором работает серверное приложение, даже если у пользователя нет учетной записи на последнем компьютере. Клиент отправляет серверу запрос на получение информации — в данном случае запрос о нагрузке на серверную систему с помощью команды Unix w — по сетевому соединению. Затем сервер обрабатывает запрос и возвращает результаты, захватывая выходные данные w и отправляя их обратно по соединению. В общем, сервер может принимать запросы от нескольких удаленных клиентов; для простоты в нашем примере предположим, что существует только один экземпляр клиента.

Код сервера показан ниже:

// srvr.c
// a server to remotely run the w command
// user can check load on machine without login privileges
// usage: svr

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

#define WPORT 2000
#define BUFSIZE 1000 // assumed sufficient here

int clntdesc, // socket descriptor for individual client
    svrdesc;  // general socket descriptor for server

char outbuf[BUFSIZE]; // messages to client

void respond()
{  int fd,nb;

   memset(outbuf,0,sizeof(outbuf)); // clear buffer
   system("w > tmp.client"); // run 'w' and save results
   fd = open("tmp.client",O_RDONLY);
   nb = read(fd,outbuf,BUFSIZE); // read the entire file
   write(clntdesc,outbuf,nb); // write it to the client
   unlink("tmp.client"); // remove the file
   close(clntdesc);
}

int main()
{  struct sockaddr_in bindinfo;

   // create socket to be used to accept connections
   svrdesc = socket(AF_INET,SOCK_STREAM,0);
   bindinfo.sin_family = AF_INET;
   bindinfo.sin_port = WPORT;
   bindinfo.sin_addr.s_addr = INADDR_ANY;
   bind(svrdesc,(struct sockaddr *) &bindinfo,sizeof(bindinfo));

   // OK, listen in loop for client calls
   listen(svrdesc,5);

   while (1) {
      // wait for a call
      clntdesc = accept(svrdesc,0,0);
      // process the command
      respond();
   }
}

Вот код для клиента:

// clnt.c

// usage: clnt server_machine

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <string.h>
#include <unistd.h>

#define WPORT 2000 // server port number
#define BUFSIZE 1000

int main(int argc, char **argv)
{  int sd,msgsize;

   struct sockaddr_in addr;
   struct hostent *hostptr;
   char buf[BUFSIZE];

   // create socket

   sd = socket(AF_INET,SOCK_STREAM,0);
   addr.sin_family = AF_INET;
   addr.sin_port = WPORT;
   hostptr = gethostbyname(argv[1]);
   memcpy(&addr.sin_addr.s_addr,hostptr->h_addr_list[0],hostptr->h_length);

   // OK, now connect
   connect(sd,(struct sockaddr *) &addr,sizeof(addr));

   // read and display response
   msgsize = read(sd,buf,BUFSIZE);
   if (msgsize > 0)
      write(1,buf,msgsize);
   printf("\n");
   return 0;
}

Для тех, кто не знаком с программированием клиент/сервер, вот обзор того, как работают программы:

В строке 41 серверного кода вы создаете сокет, который представляет собой абстракцию, аналогичную файловому дескриптору; точно так же, как файловый дескриптор используется для выполнения операций ввода-вывода над объектом файловой системы, он читает и записывает сетевое соединение через сокет. В строке 45 сокет привязан к определенному номеру порта, произвольно выбранному как 2000. (Приложения пользовательского уровня, такие как это, ограничены номерами портов 1024 и выше.) Этот номер идентифицирует «почтовый ящик» в системе сервера, в который клиенты отправляют запросы для обработки для данного конкретного приложения.

Сервер «открывается для работы», вызывая метод listen() в строке 48. Затем он ожидает поступления клиентского запроса, вызывая метод accept() в строке 52. Этот вызов блокируется до тех пор, пока не поступит запрос. Затем он возвращает новый сокет для связи с клиентом. (При наличии нескольких клиентов исходный сокет продолжает принимать новые запросы даже во время обслуживания существующего запроса, поэтому необходимы отдельные сокеты. Это потребует реализации сервера в многопоточном режиме.) Сервер обрабатывает клиент запрос с помощью функции respond() и отправляет информацию о загрузке машины клиенту, локально вызывая команду w и записывая результаты в сокет в строке 32.

Клиент создает сокет в строке 24, а затем использует его в строке 31 для подключения к порту 2000 сервера. В строке 34 он считывает информацию о загрузке, отправленную сервером, а затем распечатывает ее.

Вот как должен выглядеть вывод клиента:

$ clnt laura.cs.ucdavis.edu
 13:00:15 up 13 days, 39 min,  7 users,  load average: 0.25, 0.13, 0.09
USER     TTY      FROM              LOGIN@   IDLE   JCPU   PCPU WHAT
matloff  :0       -                14Jun07 ?xdm?  25:38   0.15s -/bin/tcsh -c /
matloff  pts/1    :0.0             14Jun07 17:34   0.46s  0.46s -csh
matloff  pts/2    :0.0             14Jun07 18:12   0.39s  0.39s -csh
matloff  pts/3    :0.0             14Jun07 58.00s  2.18s  2.01s /usr/bin/mutt
matloff  pts/4    :0.0             14Jun07  0.00s  1.85s  0.00s clnt laura.cs.u
matloff  pts/5    :0.0             14Jun07 20.00s  1.88s  0.02s script
matloff  pts/7    :0.0             19Jun07  4days 22:17   0.16s -csh

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

addr.sin_port = WPORT;

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

Вывод клиента теперь будет

$ clnt laura.cs.ucdavis.edu

$

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

Давайте осмотримся, используя GDB. Сначала проверьте, действительно ли клиенту удалось подключиться к серверу. Установите точку останова при вызове метода connect() и запустите программу:

(gdb) b 31
Breakpoint 1 at 0x8048502: file clnt.c, line 31.
(gdb) r laura.cs.ucdavis.edu
Starting program: /fandrhome/matloff/public_html/matloff/public_html/Debug
/Book/DDD/clnt laura.cs.ucdavis.edu

Breakpoint 1, main (argc=2, argv=0xbf81a344) at clnt.c:31
31         connect(sd,(struct sockaddr *) &addr,sizeof(addr));

Используйте GDB для выполнения метода connect() и проверьте возвращаемое значение на наличие ошибки:

(gdb) p connect(sd,&addr,sizeof(addr))
$1 = -1

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

Кстати, обратите внимание, что при ручном выполнении вызова connect() вам необходимо удалить приведение. При сохранении приведения вы получите сообщение об ошибке:

(gdb) p connect(sd,(struct sockaddr *) &addr,sizeof(addr))
No struct type named sockaddr.

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

Также обратите внимание, что если попытка connect() в сеансе GDB была успешной, вы не могли бы продолжить выполнение строки 31. Попытка открыть уже открытый сокет является ошибкой.

Вам пришлось бы пропустить строку 31 и сразу перейти к строке 34. Вы могли бы сделать это с помощью команды jump GDB, выдав jump 34, но в целом вам следует использовать эту команду с осторожностью, так как это может привести к пропуску некоторых машинных инструкций, которые необходимы далее в коде. Итак, если попытка подключения увенчалась успехом, вы, вероятно, захотите перезапустить программу.

Попробуем отследить причину сбоя, проверив аргумент addr в вызове connect():

(gdb) p addr
...
connect(3, {sa_family=AF_INET, sin_port=htons(1032),
sin_addr=inet_addr("127.0.0.1")}, 16) = -1 ECONNREFUSED (Connection refused)
...

Ага! Значение htons(1032) указывает порт 2052 (см. ниже), а не ожидаемый порт 2000. Это говорит о том, что вы либо неверно указали порт, либо вообще забыли его указать. Если вы проверите, вы быстро обнаружите, что дело было в последнем.

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

#include <errno.h>

которая в нашей системе создает глобальную переменную errno, значение которой можно распечатать из кода или из GDB:

(gdb) p errno
$1 = 111

Из файла /usr/include/linux/errno.h вы обнаружите, что этот номер ошибки кодирует ошибку отказа в соединении (connection refused).

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

Другой подход — использовать strace, который отслеживает все системные вызовы, выполняемые программой:

$ strace clnt laura.cs
...
connect(3, {sa_family=AF_INET, sin_port=htons(1032),
sin_addr=inet_addr("127.0.0.1")}, 16) = -1 ECONNREFUSED (Connection refused)
...

Это дает вам две важные части информации по цене одной. Во-первых, вы сразу видите, что произошла ошибка ECONNREFUSED. Во-вторых, вы также видите, что порт был htons(1032), который имеет значение 2052. Последнее значение можно проверить, введя команду типа

(gdb) p htons(1032)

из GDB, который показывает значение 2052, что явно не 2000, как ожидалось.

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

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

write(clntdesc,outbuf,nb); // write it to the client

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

В действительно сложных случаях сетевой отладки можно использовать программу Ethereal с открытым исходным кодом для отслеживания отдельных пакетов TCP/IP.

5.2 Отладка многопоточного кода

Поточное программирование стало довольно популярным. В Unix наиболее распространенным пакетом потоков является стандарт POSIX Pthreads, поэтому мы будем использовать его для нашего примера в этом разделе. Принципы аналогичны и для других пакетов потоков.

5.2.1 Обзор процессов и потоков

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

Каждый экземпляр запущенной программы представляется ОС как процесс (process) (в терминологии Unix) или задача (task) (в Windows). Таким образом, множественные вызовы одной программы, выполняемые одновременно (например, одновременные сеансы текстового редактора vi), представляют собой отдельные процессы. Процессы должны «по очереди» работать на машине с одним процессором. Для конкретики предположим, что «повороты», называемые временными интервалами (timeslices), имеют длину 30 миллисекунд.

После того, как процесс проработает 30 миллисекунд, аппаратный таймер генерирует прерывание, которое запускает ОС. Мы говорим, что процесс был прерван (preempted). ОС сохраняет текущее состояние прерванного процесса, чтобы его можно было возобновить позже, а затем выбирает следующий процесс, которому нужно указать временной интервал. Это называется переключением контекста (context switch), поскольку среда выполнения ЦП переключилась с одного процесса на другой. Этот цикл повторяется бесконечно.

Очередь может закончиться раньше. Например, когда процессу необходимо выполнить ввод/вывод, он в конечном итоге вызывает функцию в ОС, которая выполняет аппаратные операции низкого уровня; например, вызов функции библиотеки C scanf() приводит к вызову системного вызова read() ОС Unix, который взаимодействует с драйвером клавиатуры. Таким образом, процесс передает свою очередь операционной системе, и очередь завершается раньше.

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

Немного подробнее: ОС поддерживает таблицу процессов (process table), в которой перечислена информация обо всех текущих процессах. Грубо говоря, каждый процесс в таблице помечен как находящийся либо в состоянии Run, либо в состоянии Sleep. Давайте рассмотрим пример, в котором работающая программа достигает точки, в которой ей необходимо прочитать ввод с клавиатуры. Как только что было отмечено, это завершит ход процесса. Поскольку теперь процесс ожидает завершения ввода-вывода, ОС помечает его как находящийся в состоянии сна, что делает его непригодным для использования временных интервалов. Таким образом, нахождение в состоянии сна означает, что процесс заблокирован в ожидании какого-либо события. Когда это событие, наконец, произойдет позже, ОС изменит свое состояние в таблице процессов обратно на Run.

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

Более того, нахождение в состоянии Run не означает, что процесс действительно выполняется на ЦП; скорее, это просто означает, что он готов к работе, то есть имеет право на временной интервал процессора. При переключении контекста ОС выбирает процесс, которому в следующий раз будет включен ЦП, из числа тех, которые в данный момент находятся в состоянии Run, в соответствии с таблицей процессов. Процедура планирования, используемая ОС для выбора нового контекста, гарантирует, что любой данный процесс будет продолжать получать временные интервалы и, таким образом, в конечном итоге завершится, но неизвестно, какие временные интервалы он получит. Таким образом, точное время, когда спящий процесс действительно «пробуждается» после того, как произошло событие, которого он ожидает, является случайным, как и точная скорость продвижения процесса к завершению.

Поток (thread) во многом похож на процесс, за исключением того, что он спроектирован так, чтобы занимать меньше памяти и занимать меньше времени на создание и переключение между ними, чем процессы. Действительно, потоки иногда называют «легковесными» процессами и, в зависимости от системы потоков и среды выполнения, они могут даже быть реализованы как процессы операционной системы. Подобно программам, которые порождают процессы для выполнения работы, многопоточное приложение обычно выполняет процедуру main(), которая создает один или несколько дочерних потоков. Родитель, main(), также является потоком.

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

В системе Linux вы можете просмотреть все процессы и потоки, которые в данный момент есть в системе, выполнив команду ps axH.

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

5.2.2 Базовый пример

Мы не будем усложнять задачу и в качестве примера будем использовать следующий код для поиска простых чисел. В программе используется классическое Решето Эратосфена. Чтобы найти все простые числа от 2 до n, сначала перечислим все числа, затем вычеркнем все числа, кратные 2, затем все числа, кратные 3, и так далее. Все числа, оставшиеся в конце, являются простыми числами.

// finds the primes between 2 and n; uses the Sieve of Eratosthenes,
// deleting all multiples of 2, all multiples of 3, all multiples of 5,
// etc.; not efficient, e.g. each thread should do deleting for a whole
// block of values of base before going to nextbase for more

// usage: sieve nthreads n
// where nthreads is the number of worker threads

#include <stdio.h>
#include <math.h>
#include <pthread.h>

#define MAX_N 100000000
#define MAX_THREADS 100

// shared variables
int nthreads, // number of threads (not counting main())
   n, // upper bound of range in which to find primes
   prime[MAX_N+1], // in the end, prime[i] = 1 if i prime, else 0
   nextbase; // next sieve multiplier to be used

int work[MAX_THREADS]; // to measure how much work each thread does,
                       // in terms of number of sieve multipliers checked

// lock index for the shared variable nextbase
pthread_mutex_t nextbaselock = PTHREAD_MUTEX_INITIALIZER;

// ID structs for the threads
pthread_t id[MAX_THREADS];

// "crosses out" all multiples of k, from k*k on
void crossout(int k)
{  int i;

   for (i = k; i*k <= n; i++) {
      prime[i*k] = 0;
   }
}

// worker thread routine
void *worker(int tn) // tn is the thread number (0,1,...)
{  int lim,base;

   // no need to check multipliers bigger than sqrt(n)
   lim = sqrt(n);

   do {
      // get next sieve multiplier, avoiding duplication across threads
      pthread_mutex_lock(&nextbaselock);
      base = nextbase += 2;
      pthread_mutex_unlock(&nextbaselock);
      if (base <= lim) {
         work[tn]++; // log work done by this thread
         // don't bother with crossing out if base is known to be
         // composite
         if (prime[base])
            crossout(base);
      }
      else return;
   } while (1);
}

main(int argc, char **argv)
{  int nprimes, // number of primes found
      totwork,  // number of base values checked
      i;
   void *p;

   n = atoi(argv[1]);
   nthreads = atoi(argv[2]);
   for (i = 2; i <= n; i++)
      prime[i] = 1;
   crossout(2);
   nextbase = 1;
   // get threads started
   for (i = 0; i < nthreads; i++) {
      pthread_create(&id[i],NULL,(void *) worker,(void *) i);
   }

   // wait for all done
   totwork = 0;
   for (i = 0; i < nthreads; i++) {
      pthread_join(id[i],&p);
      printf("%d values of base done\n",work[i]);
      totwork += work[i];
   }
   printf("%d total values of base done\n",totwork);

   // report results
   nprimes = 0;
   for (i = 2; i <= n; i++)
      if (prime[i]) nprimes++;
   printf("the number of primes found was %d\n",nprimes);

}

В этой программе есть два аргумента командной строки: верхняя граница n диапазона, который нужно проверить на наличие простых чисел, и nthreads — количество рабочих потоков, которые мы хотим создать.

Здесь main() создает рабочие потоки, каждый из которых является вызовом функции worker(). Работники совместно используют три элемента данных: переменную верхней границы n; переменная, определяющая следующее число, кратные которому необходимо исключить из диапазона 2..n, nextbase; и массив prime[], который записывает для каждого числа в диапазоне 2..n независимо от того, было ли оно исключено. Каждый вызов неоднократно извлекает еще не обработанный множитель исключения, базу, а затем удаляет все кратные базе из диапазона 2..n. После создания рабочих процессов функция main() использует pthread_join(), чтобы дождаться, пока все эти потоки завершат свою работу, прежде чем возобновить свою работу, после чего она подсчитывает оставшиеся простые числа и выдает отчет. Отчет включает не только количество простых чисел, но и информацию о том, сколько работы выполнил каждый рабочий поток. Эта оценка полезна для балансировки нагрузки (load balancing) и оптимизации производительности в многопроцессорной системе.

Каждый экземпляр worker() извлекает следующее значение base, выполняя следующий код (строки 49–51):

pthread_mutex_lock(&nextbaselock);
base = nextbase += 2;
pthread_mutex_unlock(&nextbaselock);

Здесь глобальная переменная nextbase обновляется и используется для инициализации значения локальной переменной экземпляра worker() base; затем воркер вычеркивает значения, кратные base, в массиве prime[]. (Обратите внимание, что мы начали с исключения всех чисел, кратных 2, в начале main(), и после этого нам нужно учитывать только нечетные значения для базы.)

Как только исполнитель узнает значение base, которое он будет использовать, он может безопасно вычеркнуть кратные base из общего массива prime[], поскольку ни один другой воркер не будет использовать это значение base. Однако нам необходимо разместить операторы защиты (guard statements) вокруг операции обновления общей переменной nextbase, от которой зависит base (строка 26). Напомним, что любой рабочий поток может быть вытеснен в непредсказуемое время другим рабочим потоком, который будет находиться в непредсказуемом месте кода worker(). В частности, может случиться так, что текущий рабочий процесс будет прерван в середине оператора.

base = nextbase += 2;

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

Заключение кода, который манипулирует общей переменной, известной как критическая секция (critical section), с помощью защитных операторов, предотвращает это. Вызовы pthread_mutex_lock() и pthread_mutex_unlock() гарантируют, что существует не более одного потока, выполняющего вложенный фрагмент программы. Они говорят ОС разрешить потоку войти в критический раздел только в том случае, если в данный момент его не выполняет другой поток, и не вытеснять этот поток до тех пор, пока он не завершит весь раздел. (Переменная блокировки (lock variable) nextbaselock используется внутри системы потоков для обеспечения этого «взаимного исключения».)

К сожалению, слишком легко не распознать и/или должным образом защитить критические разделы в многопоточном коде. Давайте посмотрим, как можно использовать GDB для отладки ошибок такого рода в программе Pthreads. Предположим, мы забыли включить оператор разблокировки:

pthread_mutex_unlock(&nextbaselock);

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

Скомпилируйте программу, не забудьте включить флаги -lpthread -lm для связывания библиотек Pthreads и math (последняя необходима для вызова sqrt()). Затем запустите код в GDB с n = 100 и nthreads = 2:

(gdb) r 100 2
Starting program: /debug/primes 100 2
[New Thread 16384 (LWP 28653)]
[New Thread 32769 (LWP 28676)]
[New Thread 16386 (LWP 28677)]
[New Thread 32771 (LWP 28678)]

Каждый раз, когда создается новый поток, GDB объявляет об этом, как показано здесь. Чуть позже мы разберемся, какой из потоков является каким.

Программа зависает, и вы прерываете ее нажатием CTRL-C. Сеанс GDB теперь выглядит так:

(gdb) r 100 2
Starting program: /debug/primes 100 2
[New Thread 16384 (LWP 28653)]
[New Thread 32769 (LWP 28676)]
[New Thread 16386 (LWP 28677)]
[New Thread 32771 (LWP 28678)]

Program received signal SIGINT, Interrupt.
[Switching to Thread 32771 (LWP 28678)]
0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0

В такой момент очень важно знать, что делает каждый поток, и это можно определить с помощью команды GDB info threads:

(gdb) info threads
* 4 Thread 32771 (LWP 28678) 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0
  3 Thread 16386 (LWP 28677) 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0
  2 Thread 32769 (LWP 28676) 0x420db1a7 in poll () from /lib/i686/libc.so.6
  1 Thread 16384 (LWP 28653) 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0

Звездочка означает, что вы сейчас находитесь в потоке 4. Давайте посмотрим, что происходит с этим потоком:

(gdb) bt
#0 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0
#1 0x4005adb8 in __pthread_wait_for_restart_signal () from /lib/i686/libpthread.so.0
#2 0x4005d190 in __pthread_alt_lock () from /lib/i686/libpthread.so.0
#3 0x40059d77 in pthread_mutex_lock () from /lib/i686/libpthread.so.0
#4 0x0804855f in worker (tn=1) at Primes.c:49
#5 0x40059881 in pthread_start_thread () from /lib/i686/libpthread.so.0
#6 0x40059985 in pthread_start_thread_event () from /lib/i686/libpthread.so.0

(Это работает в реализации Pthreads в LinuxThreads, но может быть невозможно на некоторых других платформах.)

Ага — в кадрах 3 и 4 вы видите, что этот поток находится в строке 49 исходного кода и пытается получить блокировку и войти в критическую секцию:

pthread_mutex_lock(&nextbaselock);

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

Что делают другие потоки? Вы можете проверить стек любого потока, переключившись на этот поток и затем введя команду bt:

(gdb) thread 3
[Switching to thread 3 (Thread 16386 (LWP 28677))]#0 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0
(gdb) bt
#0 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0
#1 0x4005adb8 in __pthread_wait_for_restart_signal () from /lib/i686/libpthread.so.0
#2 0x4005d190 in __pthread_alt_lock () from /lib/i686/libpthread.so.0
#3 0x40059d77 in pthread_mutex_lock () from /lib/i686/libpthread.so.0
#4 0x0804855f in worker (tn=0) at Primes.c:49
#5 0x40059881 in pthread_start_thread () from /lib/i686/libpthread.so.0
#6 0x40059985 in pthread_start_thread_event () from /lib/i686/libpthread.so.0

Напомним, что мы создали два рабочих потока. Выше вы видели, что поток 4 был одним из них (кадр 4 из его вывода bt), а теперь из кадра 4 вывода вы видите, что поток 3 является другим. Вы также видите, что поток 3 также пытается получить блокировку (кадр 3).

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

(gdb) thread 2
[Switching to thread 2 (Thread 32769 (LWP 28676))]#0 0x420db1a7 in poll () from /lib/i686/libc.so.6
(gdb) bt
#0 0x420db1a7 in poll () from /lib/i686/libc.so.6
#1 0x400589de in __pthread_manager () from /lib/i686/libpthread.so.0
#2 0x4005962b in __pthread_manager_event () from /lib/i686/libpthread.so.0

Итак, поток 2 — это менеджер потоков. Это внутренняя функция пакета Pthreads. Это определенно не рабочий поток, что частично подтверждает наши ожидания о том, что рабочих потоков всего два. Проверяем поток 1,

(gdb) thread 1
[Switching to thread 1 (Thread 16384 (LWP 28653))]#0 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0
(gdb) bt
#0 0x4005ba35 in __pthread_sigsuspend () from /lib/i686/libpthread.so.0
#1 0x4005adb8 in __pthread_wait_for_restart_signal () from /lib/i686/libpthread.so.0
#2 0x40058551 in pthread_join () from /lib/i686/libpthread.so.0
#3 0x080486aa in main (argc=3, argv=0xbfffe7b4) at Primes.c:83
#4 0x420158f7 in __libc_start_main () from /lib/i686/libc.so.6

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

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

5.2.3 Вариант

Что, если бы вы вообще не осознали необходимость защиты обновления общей переменной nextbase? Что бы произошло в предыдущем примере, если бы вы исключили обе операции: разблокировки и блокировки?

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

Но давайте посмотрим поближе. Инструкция

base = nextbase += 2;

компилируется как минимум в две инструкции машинного языка. Например, при использовании компилятора GCC на компьютере Pentium под управлением Linux приведенный выше оператор C преобразуется в следующие инструкции языка ассемблера (полученные путем запуска GCC с опцией -S и последующего просмотра полученного файла .s):

addl $2, nextbase
movl nextbase, %eax
movl %eax, -8(%ebp)

Этот код увеличивает значение nextbase на 2, затем копирует значение nextbase в регистр EAX и, наконец, копирует значение EAX в то место в стеке рабочего процесса, где хранится его локальная переменная base.

Предположим, у вас есть только два рабочих потока, значение nextbase равно, скажем, 9, а интервал времени текущего вызова worker() заканчивается сразу после выполнения машинной инструкции

addl $2, nextbase

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

movl nextbase, %eax
movl %eax, -8(%ebp)

Конечно, значение nextbase теперь равно 13. Таким образом, первый воркер устанавливает значение своей локальной переменной base равным 13 и приступает к исключению кратных этому значению, а не значению 11, которое он установил во время своего последнего временного интервала. Ни один из рабочих ничего не делает с числами, кратными 11. В конечном итоге вы не только ненужно дублируете работу, но и пропускаете необходимую работу!

Как можно обнаружить такую ошибку с помощью GDB? Предположительно, «симптом» заключался в том, что количество зарегистрированных простых чисел было слишком большим. Таким образом, вы можете заподозрить, что значения base иногда пропускаются. Чтобы проверить эту гипотезу, вы можете разместить точку останова сразу после строки

base = nextbase += 2;

Повторно выдавая команду GDB continue (c) и отображая значение base,

(gdb) disp base

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

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

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

5.2.4 Сводка команд потоков GDB

Вот краткое описание использования команд GDB, связанных с потоками:

5.2.5 Команды потоков в DDD

В DDD выберите Status | Threads, и появится всплывающее окно, отображающее все потоки в виде info threads GDB, как показано на рисунке 5-1. Вы можете щелкнуть поток, чтобы переключить на него фокус отладчика.

Рисунок 5-1. Окно потоков

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

Похоже, что в DDD нет способа сделать точку останова специфичной для потока, как вы это сделали с командой GDB break 88 thread 3 выше. Вместо этого вы отправляете такую команду GDB через консоль DDD.

5.2.6 Команды потоков в Eclipse

Прежде всего обратите внимание, что файл makefile по умолчанию, созданный Eclipse, не будет включать аргумент командной строки -lpthread для GCC (и не будет включать аргументы для любых других необходимых вам специальных библиотек). Если хотите, вы можете изменить makefile напрямую, но проще поручить Eclipse сделать это за вас. В перспективе C/C++ щелкните правой кнопкой мыши имя проекта и выберите Properties; направьте треугольник рядом с C/C++ Build вниз; выберите Settings | Tool Settings; направьте треугольник рядом с GCC C Linker вниз и выберите Libraries | Add (последний — зеленый значок плюсика); и заполните флаги вашей библиотеки без -l (например, заполнив m вместо -lm). Затем создайте свой проект.

Вспомним из главы 1, что Eclipse постоянно отображает список потоков, а не запрашивает его, как в DDD. Более того, вам не нужно запрашивать операцию обратной трассировки, как это делается в DDD; стек вызовов отображается в списке потоков. Это показано на рисунке 5-2. Как и выше, мы запустили программу на некоторое время, а затем прервали ее, щелкнув значок Suspend справа от Resume. Список потоков находится в представлении Debug, которое обычно находится в верхней левой части экрана, но здесь отображается в развернутом виде, поскольку мы нажали Maximize на вкладке Debug. (Мы можем нажать Restore, чтобы вернуться к стандартному макету.)

Рисунок 5-2. Отображение потоков в Eclipse

Мы видим, что поток 3 был запущен в момент прерывания; он получил сигнал SIGINT, который является сигналом прерывания (CTRL-C). Мы также видим, что соответствующий системный вызов был вызван функцией pthread_join(), которая, в свою очередь, была вызвана функцией main(). Из того, что мы видели об этой программе ранее, мы знаем, что это действительно основной поток.

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

Возможно, мы захотим установить точку останова, применимую только к определенному потоку. Для этого нам нужно сначала дождаться создания потока. Затем, когда выполнение приостанавливается из-за предыдущей настройки точки останова или прерывания, как указано выше, мы щелкаем правой кнопкой мыши символ точки останова так же, как если бы мы делали точку останова условной, но на этот раз выбираем Filtering. Появится всплывающее окно, подобное изображенному на рисунке 5-3. Мы видим, что эта точка останова в настоящее время применяется ко всем трем потокам. Если мы хотим, чтобы она применялась, например, только к потоку 2, мы должны снять флажки рядом с записями для двух других потоков.

Рисунок 5-3. Установка точки останова для конкретного потока в Eclipse

5.3 Отладка параллельных приложений

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

Термин shared memory (общая память) означает именно это: все несколько процессоров имеют доступ к некоторой общей физической памяти. Код, выполняющийся на одном процессоре, взаимодействует с кодом, выполняющимся на других, путем чтения и записи в эту общую память, подобно тому, как потоки в многопоточном приложении взаимодействуют друг с другом через общее адресное пространство. (Действительно, поточное программирование стало стандартным способом написания кода приложения для систем с общей памятью.)

Напротив, в среде message passing (передачи сообщений) код, выполняющийся на каждом ЦП, может получить доступ только к локальной памяти этого ЦП и связываться с другими, отправляя строки байтов, называемые сообщениями, через среду связи. Обычно это какая-то сеть, использующая либо протокол общего назначения, такой как TCP/IP, либо специализированную программную инфраструктуру, предназначенную для приложений передачи сообщений.

5.3.1 Системы передачи сообщений

Сначала мы обсудим передачу сообщений, используя в качестве примера популярный пакет Message Passing Interface (MPI). Здесь мы используем реализацию MPICH, но те же принципы применимы к LAM и другим реализациям MPI.

Давайте снова рассмотрим программу поиска простых чисел:

#include <mpi.h>

// MPI sample program; not intended to be efficient; finds and reports
// the number of primes less than or equal to n

// Uses a pipeline approach: node 0 looks at all the odd numbers (i.e.,
// we assume multiples of 2 are already filtered out) and filters out
// those that are multiples of 3, passing the rest to node 1; node 1
// filters out the multiples of 5, passing the rest to node 2; node 2
// filters out the rest of the composites and then reports the number
// of primes

// the command-line arguments are n and debugwait

#define PIPE_MSG 0  // type of message containing a number to
                    // be checked
#define END_MSG 1  // type of message indicating no more data will
                   // be coming

int nnodes, // number of nodes in computation
    n, // find all primes from 2 to n
    me; // my node number

init(int argc,char **argv)
{  int debugwait;  // if 1, then loop around until the
                   // debugger has been attached

   MPI_Init(&argc,&argv);
   n = atoi(argv[1]);
   debugwait = atoi(argv[2]);

   MPI_Comm_size(MPI_COMM_WORLD,&nnodes);
   MPI_Comm_rank(MPI_COMM_WORLD,&me);

   while (debugwait) ;
}

void node0()
{  int i,dummy,
       tocheck; // current number to check for passing on to next node
   for (i = 1; i <= n/2; i++) {
      tocheck = 2 * i + 1;
      if (tocheck > n) break;
      if (tocheck % 3 > 0)
         MPI_Send(&tocheck,1,MPI_INT,1,PIPE_MSG,MPI_COMM_WORLD);
   }
   MPI_Send(&dummy,1,MPI_INT,1,END_MSG,MPI_COMM_WORLD);
}

void node1()
{  int tocheck, // current number to check from node 0
       dummy;
   MPI_Status status; // see below

   while (1) {
      MPI_Recv(&tocheck,1,MPI_INT,0,MPI_ANY_TAG,
         MPI_COMM_WORLD,&status);
      if (status.MPI_TAG == END_MSG) break;
      if (tocheck % 5 > 0)
         MPI_Send(&tocheck,1,MPI_INT,2,PIPE_MSG,MPI_COMM_WORLD);
   }
   // now send our end-of-data signal, which is conveyed in the
   // message type, not the message itself
   MPI_Send(&dummy,1,MPI_INT,2,END_MSG,MPI_COMM_WORLD);
}

void node2()
{  int tocheck, // current number to check from node 1
       primecount,i,iscomposite;
   MPI_Status status;

   primecount = 3;  // must account for the primes 2, 3 and 5, which
                    // won't be detected below
   while (1) {
      MPI_Recv(&tocheck,1,MPI_INT,1,MPI_ANY_TAG,
         MPI_COMM_WORLD,&status);
      if (status.MPI_TAG == END_MSG) break;
      iscomposite = 0;
      for (i = 7; i*i <= tocheck; i += 2)
         if (tocheck % i == 0) {
            iscomposite = 1;
            break;
         }
      if (!iscomposite) primecount++;
   }
   printf("number of primes = %d\n",primecount);
}

main(int argc,char **argv)
{  init(argc,argv);
   switch (me) {
      case 0: node0();
         break;
      case 1: node1();
         break;
      case 2: node2();
   };
   MPI_Finalize();
}

Как поясняется в комментариях в начале программы, здесь наше Решето Эратосфена работает на трёх узлах параллельной системы и работает конвейерно. Первый узел начинается с нечетных чисел и удаляет все кратные 3, передавая оставшиеся значения; второй узел принимает выходные данные первого и удаляет все числа, кратные 5; а третий узел принимает выходные данные второго узла, удаляет остальные непростые числа и сообщает количество оставшихся простых чисел.

Здесь конвейеризация достигается за счет того, что каждый узел передает одно число за раз следующему. (Гораздо большей эффективности можно достичь, передавая группы чисел в каждом сообщении MPI, тем самым уменьшая накладные расходы на связь.) При отправке числа на следующий узел узел отправляет сообщение типа PIPE_MSG. Когда у узла больше нет номеров для отправки, он указывает на это, отправляя сообщение типа END_MSG.

В качестве примера отладки предположим, что мы забыли включить последнее уведомление в первый узел, то есть мы забыли строку 46 в коде node0():

MPI_Send(&dummy,1,MPI_INT,1,END_MSG,MPI_COMM_WORLD);

Программа зависнет на «нисходящих» узлах. Давайте посмотрим, как мы можем отследить эту ошибку. (Имейте в виду, что некоторые номера строк в сеансе GDB ниже будут отличаться на 1 от номеров в приведенном выше листинге.)

Вы запускаете прикладную программу MPICH, вызывая сценарий с именем mpirun на одном узле системы. Затем сценарий запускает прикладную программу на каждом узле через SSH. Здесь мы сделали это в сети из трех машин, которые мы назовем Node 0, Node 1 и Node 2, с n, равным 100. Ошибка приводит к зависанию программы на последних двух узлах. Программа также зависает на первом узле, поскольку ни один экземпляр программы MPI не завершит работу, пока все не выполнят функцию MPI_FINALIZE().

Мы хотели бы использовать GDB, но поскольку мы использовали mpirun для вызова приложения на каждом из трех узлов, а не запускали его непосредственно на узлах, мы не можем запускать GDB напрямую. Однако GDB позволяет вам динамически присоединять отладчик к уже запущенному процессу, используя номер процесса. Итак, давайте запустим ps на узле 1, чтобы определить номер процесса, выполняющего там наше приложение:

$ ps ax
...
 2755 ?     S     0:00 tcsh -c /home/matloff/primepipe node 1 3
 2776 ?     S     0:00 /home/matloff/primepipe node1 32812   4
 2777 ?     S     0:00 /home/matloff/primepipe node1 32812   4

Программа MPI выполняется как процесс 2776, поэтому мы присоединяем GDB к программе на Node 1:

$ gdb primepipe 2776
...
0xffffe002 in ?? ()

Это не очень информативно! Итак, давайте посмотрим, где мы находимся:

(gdb) bt
#0 0xffffe002 in ?? ()
#1 0x08074a76 in recv_message ()
#2 0x080748ad in p4_recv ()
#3 0x0807ab46 in MPID_CH_Check_incoming ()
#4 0x08076ae9 in MPID_RecvComplete ()
#5 0x0806765f in MPID_RecvDatatype ()
#6 0x0804a29f in PMPI_Recv ()
#7 0x08049ce8 in node1 () at PrimePipe.c:56
#8 0x08049e19 in main (argc=8, argv=0xbffffb24) at PrimePipe.c:94
#9 0x420156a4 in __libc_start_main () from /lib/tls/libc.so.6

В кадре 7 мы видим, что программа зависает в строке 56, ожидая приема от Node 0.

Далее было бы полезно узнать, какой объем работы был выполнен функцией node1(), выполняющейся на Node 1. Все только началось или уже почти закончено? Мы можем оценить прогресс, определив последнее обработанное значение проверяемой переменной:

(gdb) frame 7
#7 0x08049ce8 in node1 () at PrimePipe.c:56
56         MPI_Recv(&tocheck,1,MPI_INT,0,MPI_ANY_TAG,
(gdb) p tocheck
$1 = 97

Примечание

Сначала нам нужно было перейти к кадру стека для node1(), используя команду frame GDB.

Это указывает на то, что Node 1 находится в конце выполнения, поскольку 97 должно быть последним числом, которое Node 0 передает ему для простой проверки. Итак, в настоящее время мы ожидаем сообщение от Node 0 типа END_MSG. Тот факт, что программа зависает, предполагает, что Node 0 мог не отправлять такое сообщение, что, в свою очередь, заставило бы нас проверить, было ли оно отправлено. Таким образом, мы надеемся, что сможем быстро обнаружить ошибку, которая заключалась в случайном пропуске строки 46.

Кстати, имейте в виду, что при вызове GDB командой

$ gdb primepipe 2776

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

В этом примере ошибка привела к зависанию программы. Подход к отладке подобной параллельной программы несколько иной, если признаком является неправильный вывод. Предположим, например, что в строке 71 мы неправильно инициализировали primecount равным 2 вместо 3. Если мы попытаемся следовать той же процедуре отладки, программы, работающие на каждом узле, завершат выполнение и выйдут слишком быстро, чтобы вы могли подключить GDB. (Правда, мы могли бы использовать очень большое значение n, но обычно лучше сначала отладить простые случаи.) Нам нужно какое-то устройство, которое можно использовать, чтобы заставить программы ждать и дать вам возможность подключить GDB. Это цель строки 34 в функции init().

Как видно из исходного кода, значение debugwait берется из командной строки, предоставленной пользователем, где 1 означает ожидание, а 0 означает отсутствие ожидания. Если мы укажем 1 для значения debugwait, то когда каждый вызов программы достигнет строки 34, она останется там. Это дает нам время подключить GDB. Затем мы можем выйти из бесконечного цикла и приступить к отладке. Вот что мы делаем на Node 0:

node1:~$ gdb primepipe 3124
...
0x08049c53 in init (argc=3, argv=0xbfffe2f4) at PrimePipe.c:34
34         while (debugwait) ;
(gdb) set debugwait = 0
(gdb) c
Continuing.

Обычно мы боимся бесконечных циклов, но здесь мы намеренно создали их, чтобы облегчить отладку. Мы делаем то же самое на Node 1 и Node 2, а на последнем мы также пользуемся возможностью установить точку останова в строке 77, прежде чем продолжить:

[matloff@node3 ~]$ gdb primepipe 2944
34         while (debugwait) ;
(gdb) b 77
Breakpoint 1 at 0x8049d7d: file PrimePipe.c, line 77.
(gdb) set debugwait = 0
(gdb) c
Continuing.

Breakpoint 1, node2 () at PrimePipe.c:77
77            if (status.MPI_TAG == END_MSG) break;
(gdb) p tocheck
$1 = 7
(gdb) n
78            iscomposite = 0;
(gdb) n
79            for (i = 7; i*i <= tocheck; i += 2)
(gdb) n
84            if (!iscomposite) primecount++;
(gdb) n
75            MPI_Recv(&tocheck,1,MPI_INT,1,MPI_ANY_TAG,
(gdb) p primecount
$2 = 3

На этом этапе мы замечаем, что primecount должно быть равно 4, а не 3 (простые числа до 7 — это 2, 3, 5 и 7), и таким образом мы нашли место ошибки.

5.3.2 Системы с общей памятью

А как насчет параллельного программирования с общей памятью? Здесь у нас есть отдельные случаи для настоящих машин с общей памятью и программно-распределенных настроек общей памяти.

5.3.2.1 Настоящая общая память

Как упоминалось ранее, в реальной среде с общей памятью прикладные программы часто разрабатываются с использованием потоков. Тогда применим наш материал из раздела 5.2 по отладке с помощью GDB/DDD.

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

В разделе 5.4 мы представляем расширенный пример отладки приложения OpenMP.

5.3.2.2 Программно-распределенные системы с общей памятью

Цены на машины с двухъядерными процессорами сейчас доступны обычным потребителям, но крупномасштабные системы с общей памятью и множеством процессоров по-прежнему стоят сотни тысяч долларов. Популярная и недорогая альтернатива — network of workstations (сеть рабочих станций) (NOW). Архитектуры NOW используют базовую библиотеку, которая создает иллюзию общей памяти. Библиотека, которая в значительной степени прозрачна для прикладного программиста, участвует в сетевых транзакциях, которые поддерживают согласованность копий общих переменных на разных узлах.

Этот подход называется software distributed shared memory (программно-распределенной общей памятью) (SDSM). Наиболее широко используемой библиотекой SDSM является Treadmarks, разработанная и поддерживаемая Университетом Райса. Еще один отличный пакет — JIAJIA, доступный в Китайской академии наук (http://www-users.cs.umn.edu/~tianhe/paper/dist.htm).

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

Многие SDSM основаны на страницах (page based), то есть они полагаются на базовое оборудование виртуальной памяти на узлах. Действия сложны, но мы можем дать краткий обзор. Рассмотрим переменную X, которая должна использоваться всеми узлами NOW. Программист указывает на это намерение, совершая определенный вызов библиотеки SDSM, которая, в свою очередь, выполняет определенный системный вызов Unix, запрашивающий у ОС замену собственного обработчика ошибок сегментации функцией библиотеки SDSM для ошибок страниц, связанных со страницей, содержащей X. SDSM устроен таким образом, что только узлы NOW с действительными копиями X имеют соответствующие страницы памяти, помеченные как резидентные. Когда доступ к X осуществляется на каком-либо другом узле, возникает ошибка страницы, и базовое программное обеспечение SDSM извлекает правильное значение из узла, на котором оно имеется.

Опять же, не обязательно знать точную работу системы SDSM; скорее, важно просто понять, что существует базовый механизм на основе виртуальной машины, который используется для поддержания согласованности локальных копий общих данных на узлах NOW. Если вы этого не сделаете, вы будете озадачены, когда попытаетесь отладить код приложения SDSM. Будет казаться, что отладчик загадочным образом останавливается из-за несуществующих ошибок сегментации, поскольку инфраструктура SDSM намеренно генерирует ошибки сегментации, и когда прикладная программа SDSM запускается под инструментом отладки, инструмент обнаруживает их. Как только вы это поймете, проблем не будет вообще — в GDB вы просто введете команду continue, чтобы возобновить выполнение при возникновении одной из этих странных пауз.

У вас может возникнуть соблазн приказать GDB не останавливаться и не выдавать предупреждающие сообщения при возникновении ошибок сегментации, используя команду GDB

handle SIGSEGV nostop noprint

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

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

Ситуация с SDSM аналогична ситуации с передачей сообщений в одном смысле — необходимость иметь переменную, подобную debugwait в приведенном выше примере MPI, которая позволяет приостановить работу программы на всех узлах, давая вам возможность подключить GDB на каждом узле и пошагово выполнять программу с самого начала.

5.4 Расширенный пример

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

5.4.1 Обзор OpenMP

OpenMP, по сути, представляет собой интерфейс параллельного программирования более высокого уровня для операций управления потоками. Количество потоков задается через переменную среды OMP_NUM_THREADS. Например, в оболочке C вы вводите

% setenv OMP_NUM_THREADS 4

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

Код приложения состоит из C с вкраплениями директив OpenMP. Каждая директива применяется к следующему за ней блоку, ограниченному левой и правой фигурными скобками. Самая базовая директива

#pragma omp parallel

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

Еще одна очень распространенная директива OpenMP:

#pragma omp barrier

Это определяет «точку встречи» для всех потоков. Когда какой-либо поток достигает этой точки, он блокируется до тех пор, пока туда не доберутся все остальные потоки.

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

#pragma omp single

#pragma omp сингл

Сразу после такого блока возникает подразумеваемый барьер.

Существует множество других директив OpenMP, но в приведенном здесь примере используется только одна:

#pragma omp critical

Как следует из названия, при этом создается критическая секция, в которой в любой момент времени разрешен только один поток.

5.4.2 Пример программы OpenMP

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

Ниже приведен исходный файл dijkstra.c. Он генерирует случайные длины ребер среди заданного числа вершин, а затем находит минимальные расстояния от вершины 0 до каждой из остальных вершин.

// dijkstra.c

// OpenMP example program: Dijkstra shortest-path finder in a
// bidirectional graph; finds the shortest path from vertex 0 to all
// others

// usage: dijkstra nv print

// where nv is the size of the graph, and print is 1 if graph and min
// distances are to be printed out, 0 otherwise

#include <omp.h> // required
#include <values.h>

// including stdlib.h and stdio.h seems to cause a conflict with the
// Omni compiler, so declare directly
extern void *malloc();
extern int printf(char *,...);

// global variables, shared by all threads
int nv, // number of vertices
   *notdone, // vertices not checked yet
   nth, // number of threads
   chunk, // number of vertices handled by each thread
   md, // current min over all threads
   mv; // vertex which achieves that min

int *ohd, // 1-hop distances between vertices; "ohd[i][j]" is
          // ohd[i*nv+j]
   *mind; // min distances found so far

void init(int ac, char **av)
{  int i,j,tmp;
   nv = atoi(av[1]);
   ohd = malloc(nv*nv*sizeof(int));
   mind = malloc(nv*sizeof(int));
   notdone = malloc(nv*sizeof(int));
   // random graph
   for (i = 0; i < nv; i++)
      for (j = i; j < nv; j++) {
         if (j == i) ohd[i*nv+i] = 0;
         else {
            ohd[nv*i+j] = rand() % 20;
            ohd[nv*j+i] = ohd[nv*i+j];
         }
      }
   for (i = 1; i < nv; i++) {
      notdone[i] = 1;
      mind[i] = ohd[i];
   }
}

// finds closest to 0 among notdone, among s through e; returns min
// distance in *d, closest vertex in *v
void findmymin(int s, int e, int *d, int *v)
{  int i;
   *d = MAXINT;
   for (i = s; i <= e; i++)
      if (notdone[i] && mind[i] < *d) {
         *d = mind[i];
         *v = i;
      }
}

// for each i in {s,...,e}, ask whether a shorter path to i exists, through
// mv
void updatemind(int s, int e)
{  int i;
   for (i = s; i <= e; i++)
      if (notdone[i])
         if (mind[mv] + ohd[mv*nv+i] < mind[i])
            mind[i] = mind[mv] + ohd[mv*nv+i];
}

void dowork()
{
   #pragma omp parallel
   {  int startv,endv, // start, end vertices for this thread
         step, // whole procedure goes nv steps
         mymv, // vertex which attains that value
         me = omp_get_thread_num(),
         mymd; // min value found by this thread
      #pragma omp single
      { nth = omp_get_num_threads(); chunk = nv/nth;
         printf("there are %d threads\n",nth); }
      startv = me * chunk;
      endv = startv + chunk - 1;
      // the algorithm goes through nv iterations
      for (step = 0; step < nv; step++) {
         // find closest vertex to 0 among notdone; each thread finds
         // closest in its group, then we find overall closest
         #pragma omp single
         { md = MAXINT;
            mv = 0;
         }
         findmymin(startv,endv,&mymd,&mymv);
         // update overall min if mine is smaller
         #pragma omp critical
         { if (mymd < md)
              { md = mymd; }
         }
         #pragma omp barrier
         // mark new vertex as done
         #pragma omp single
         { notdone[mv] = 0; }
         // now update my section of ohd
         updatemind(startv,endv);
      }
   }
}

int main(int argc, char **argv)
{  int i,j,print;
   init(argc,argv);
   // start parallel
   dowork();
   // back to single thread
   print = atoi(argv[2]);
   if (print) {
      printf("graph weights:\n");
      for (i = 0; i < nv; i++) {
         for (j = 0; j < nv; j++)
            printf("%u ",ohd[nv*i+j]);
         printf("\n");
      }
      printf("minimum distances:\n");
      for (i = 1; i < nv; i++)
         printf("%u\n",mind[i]);
   }
}

Давайте рассмотрим, как работает алгоритм. Начните со всех вершин, кроме вершины 0, которая в данном случае является вершинами с 1 по 5, в наборе «not done» (не выполнено). На каждой итерации алгоритма выполните следующие действия:

  1. Найдите «not done» вершину v, ближайшую к вершине 0, по известным путям. Эта проверка является общей для всех потоков, при этом каждый поток проверяет одинаковое количество вершин. Эту работу выполняет функция findmymin().

  2. Затем переместите v в набор «done» (готово).

  3. Для всех оставшихся вершин i в наборе «not done» проверьте, короче ли текущий путь от 0 до v по лучшему известному на данный момент пути, а затем от v до i за один переход, чем текущее кратчайшее расстояние от 0 до i. Если да, обновите это расстояние соответствующим образом. Функция, выполняющая эти действия, — updatemind().

Итерация продолжается до тех пор, пока набор «not done» не станет пустым.

Поскольку директивы OpenMP требуют предварительной обработки, всегда существует потенциальная проблема, связанная с потерей исходных номеров строк, имен переменных и функций. Чтобы увидеть, как решить эту проблему, мы обсудим два разных компилятора. Сначала мы рассмотрим компилятор Omni (http://www.hpcc.jp/Omni/), а затем GCC (требуется версия 4.2 или более поздняя).

Компилируем наш код под Omni следующим образом:

$ omcc -g -o dij dijkstra.c

Скомпилировав программу и запустив ее в четыре потока, мы обнаруживаем, что она не работает должным образом:

$ dij 6 1
there are 4 threads
graph weights:
0 3 6 17 15 13
3 0 15 6 12 9
6 15 0 1 2 7
17 6 1 0 10 19
15 12 2 10 0 3
13 9 7 19 3 0
minimum distances:
3
6
17
15
13

Анализ графика вручную показывает, что правильные минимальные расстояния должны составлять 3, 6, 7, 8 и 11.

Далее мы запускаем программу в GDB. Здесь очень важно понимать последствия того, что OpenMP работает через директивы. Хотя номера строк, имена функций и т. д. в большинстве обсуждаемых здесь компиляторов сохраняются, между ними есть некоторые расхождения. Посмотрите, что происходит, когда мы пытаемся установить точку останова в исполняемом файле dij в начале сеанса GDB:

(gdb) tb main
Breakpoint 1 at 0x80492af
(gdb) r 6 1
Starting program: /debug/dij 6 1
[Thread debugging using libthread_db enabled]
[New Thread -1208490304 (LWP 11580)]
[Switching to Thread -1208490304 (LWP 11580)]
0x080492af in main ()
(gdb) l
1       /tmp/omni_C_11486.c: No such file or directory. in /tmp/omni_C_11486.c

Мы обнаруживаем, что точки останова нет в исходном файле. Вместо этого он находится в коде инфраструктуры OpenMP Omni. Другими словами, main() здесь — это метод main() Omni, а не ваш собственный. Компилятор Omni изменил имя нашей функции main() на _ompc_main().

Чтобы установить точку останова в main(), мы набираем

(gdb) tb _ompc_main
Breakpoint 2 at 0x80491b3: file dijkstra.c, line 114.

и проверяем это, продолжив:

(gdb) c
Continuing.
[New Thread -1208493152 (LWP 11614)]
[New Thread -1218983008 (LWP 11615)]
[New Thread -1229472864 (LWP 11616)]
_ompc_main (argc=3, argv=0xbfab6314) at dijkstra.c:114
114
init(argc,argv);

Хорошо, есть знакомая строка init(). Конечно, мы могли бы дать команду

(gdb) b dijkstra.c:114

Обратите внимание на создание трех новых потоков, всего их четыре.

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

Итак, как вы отследите ошибку(и)? К отладке этой программы естественно подходить к проверке результатов в конце каждой итерации. Основные результаты находятся в наборе «not done» (в массиве notdone[]) и в текущем списке наиболее известных расстояний от 0 до остальных вершин, то есть массиве mind[]. Например, после первой итерации набор «not done» должен состоять из вершин 2, 3, 4 и 5, причем вершина 1 была выбрана на этой итерации.

Вооружившись этой информацией, давайте применим принцип подтверждения и проверим notdone[] и mind[] после каждой итерации цикла for в dowork().

Мы должны быть осторожны с тем, где именно мы устанавливаем точки останова. Хотя естественным местом для этого кажется строка 108, в самом конце основного цикла алгоритма это может быть не так хорошо, поскольку GDB будет останавливаться там для каждого потока. Вместо этого выберите размещение точки останова внутри блока single OpenMP, чтобы он останавливался только для одного потока.

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

(gdb) b 92 if step >= 1
Breakpoint 3 at 0x80490e3: file dijkstra.c, line 92.
(gdb) c
Continuing.
there are 4 threads

Breakpoint 3, __ompc_func_0 () at dijkstra.c:93
93               { md = MAXINT;

Давайте подтвердим, что первая итерация выбрала правильную вершину (вершину 1) для удаления из набора «not done»:

(gdb) p mv
$1 = 0

В конце концов, гипотеза не подтверждается. Проверка кода показывает, что в строке 100 мы забыли установить mv. Исправляем, чтобы это выглядело как

{ md = mymd; mv = mymv; }

Итак, перекомпилируем и снова запускаем программу. Как отмечалось ранее в этом разделе (и в других местах этой книги), очень полезно не выходить из GDB при повторном запуске программы. Мы могли бы запустить программу в другом окне терминала, но для разнообразия давайте воспользуемся другим подходом. Мы временно отключаем наши точки останова, введя команду dis, затем запускаем перекомпилированную программу из GDB, а затем снова включаем точки останова, используя ena:

(gdb) dis
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
`/debug/dij' has changed; re-reading symbols.
Starting program: /debug/dij 6 1
[Thread debugging using libthread_db enabled]
[New Thread -1209026880 (LWP 11712)]
[New Thread -1209029728 (LWP 11740)]
[New Thread -1219519584 (LWP 11741)]
[New Thread -1230009440 (LWP 11742)]
there are 4 threads
graph weights:
0 3 6 17 15 13
3 0 15 6 12 9
6 15 0 1 2 7
17 6 1 0 10 19
15 12 2 10 0 3
13 9 7 19 3 0
minimum distances:
3
6
17
15
13

Program exited with code 06.
(gdb) ena

Мы все еще получаем неправильные ответы. Давайте еще раз проверим ситуацию на этой точке останова:

(gdb) r
Starting program: /debug/dij 6 1
[Thread debugging using libthread_db enabled]
[New Thread -1209014592 (LWP 11744)]
[New Thread -1209017440 (LWP 11772)]
[New Thread -1219507296 (LWP 11773)]
[New Thread -1229997152 (LWP 11774)]
there are 4 threads
[Switching to Thread -1209014592 (LWP 11744)]

Breakpoint 3, __ompc_func_0 () at dijkstra.c:93
93               { md = MAXINT;
(gdb) p mv
$2 = 1

По крайней мере, mv теперь имеет правильное значение. Давайте проверим mind[]:

(gdb) p *mind@6
$3 = {0, 3, 6, 17, 15, 13}

Обратите внимание: поскольку мы создали массив mind[] динамически с помощью malloc(), мы не могли использовать команду печати GDB в ее обычной форме. Вместо этого мы использовали функцию искусственного массива GDB.

В любом случае, mind[] по-прежнему неверен. Например, mind[3] должно быть 3 + 6 = 9, но это 17. Давайте проверим код, который обновляет mind[]:

(gdb) b 107 if me == 1
Breakpoint 4 at 0x8049176: file dijkstra.c, line 107.
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /debug/dij 6 1
[Thread debugging using libthread_db enabled]
[New Thread -1209039168 (LWP 11779)]
[New Thread -1209042016 (LWP 11807)]
[New Thread -1219531872 (LWP 11808)]
[New Thread -1230021728 (LWP 11809)]
there are 4 threads
[Switching to Thread -1230021728 (LWP 11809)]

Breakpoint 4, __ompc_func_0 () at dijkstra.c:107
107              updatemind(startv,endv);

Сначала убедитесь, что startv и endv имеют разумные значения:

(gdb) p startv
$4 = 1
(gdb) p endv
$5 = 1

Размер чанка всего 1? Давайте посмотрим:

(gdb) p chunk
$6 = 1

Проверив вычисление на чанк, мы понимаем, что нам нужно количество потоков, чтобы равномерно разделить nv. Последний имеет значение 6, которое не делится на число наших потоков, равное 4. Мы делаем пометку для себя, чтобы позже вставить некоторый код, ловящий ошибки, а на данный момент уменьшаем количество потоков до 3.

Еще раз: для этого нам не нужно выходить из GDB. GDB наследует переменные среды при первом вызове, но значения этих переменных также можно изменить или установить внутри GDB, что мы и делаем здесь:

(gdb) set environment OMP_NUM_THREADS = 3

Теперь давайте запустим еще раз:

(gdb) dis
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /debug/dij 6 1
[Thread debugging using libthread_db enabled]
[New Thread -1208707392 (LWP 11819)]
[New Thread -1208710240 (LWP 11847)]
[New Thread -1219200096 (LWP 11848)]
there are 3 threads
graph weights:
0 3 6 17 15 13
3 0 15 6 12 9
6 15 0 1 2 7
17 6 1 0 10 19
15 12 2 10 0 3
13 9 7 19 3 0
minimum distances:
3
6
7
15
12

Program exited with code 06.
(gdb) ena

Упс, все те же неправильные ответы! Продолжаем проверять процесс обновления для mind[]:

(gdb) r
Starting program: /debug/dij 6 1
[Thread debugging using libthread_db enabled]
[New Thread -1208113472 (LWP 11851)]
[New Thread -1208116320 (LWP 11879)]
[New Thread -1218606176 (LWP 11880)]
there are 3 threads
[Switching to Thread -1218606176 (LWP 11880)]

Breakpoint 4, __ompc_func_0 () at dijkstra.c:107
107              updatemind(startv,endv);
(gdb) p startv
$7 = 2
(gdb) p endv
$8 = 3

Хорошо, это правильные значения startv и endv в случае me = 1. Итак, входим в функцию:

(gdb) s
[Switching to Thread -1208113472 (LWP 11851)]

Breakpoint 3, __ompc_func_0 () at dijkstra.c:93
93               { md = MAXINT;
(gdb) c
Continuing.
[Switching to Thread -1218606176 (LWP 11880)]
updatemind (s=2, e=3) at dijkstra.c:69
69         for (i = s; i <= e; i++)

Обратите внимание, что из-за переключения контекста между потоками мы не сразу ввели updatemind(). Теперь проверим случай i = 3:

(gdb) tb 71 if i == 3
Breakpoint 5 at 0x8048fb2: file dijkstra.c, line 71.
(gdb) c
Continuing.
updatemind (s=2, e=3) at dijkstra.c:71
71               if (mind[mv] + ohd[mv*nv+i] < mind[i])

Как обычно, мы применяем принцип подтверждения:

(gdb) p mv
$9 = 0

Ну, это большая проблема. Напомним, что на первой итерации mv оказывается равным 1. Почему здесь 0?

Через некоторое время мы понимаем, что эти переключения контекста должны были быть большим намеком. Взгляните еще раз на вывод GDB выше. Поток с системным идентификатором 11851 уже находился в строке 93 — другими словами, он уже находился на следующей итерации основного цикла алгоритма. Фактически, когда мы нажали c, чтобы продолжить, он даже выполнил строку 94, которая равна

mv = 0;

Этот поток перезаписал предыдущее значение mv, равное 1, так что поток, обновляющий mind[3], теперь использует неправильное значение mv. Решение состоит в том, чтобы добавить еще один барьер:

updatemind(startv,endv);
#pragma omp barrier

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

Вышеизложенное было основано на компиляторе Omni. Как уже упоминалось, начиная с версии 4.2, GCC также обрабатывает код OpenMP. Все, что вам нужно сделать, это добавить флаг -fopenmp в командную строку GCC.

В отличие от Omni, GCC генерирует код таким образом, что GDB с самого начала фокусируется на вашем собственном исходном файле. Таким образом, выдавая команду

(gdb) b main

в самом начале сеанса GDB действительно приведет к установке точки останова в собственной функции main(), в отличие от того, что мы видели для компилятора Omni.

Однако на момент написания этой статьи основным недостатком GCC является то, что символы локальных переменных, находящихся внутри параллельного блока OpenMP (называемых частными переменными в терминологии OpenMP), не будут видны в GDB. Например, команда

(gdb) p mv

которую вы ввели для кода, сгенерированного Omni выше, будет работать для кода, сгенерированного GCC, но команда

(gdb) p startv

завершится сбоем в коде, сгенерированном GCC.

Конечно, есть способы обойти это. Например, если вы хотите узнать значение startv, вы можете запросить значение s с помощью updatemind(). Надеемся, эта проблема будет решена в следующей версии GCC.

Глава 6
Специальные темы

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

6.1 Что, если он даже не компилируется и не загружается?

Какими бы бесценными ни были GDB, DDD и Eclipse, они не смогут вам помочь, если ваша программа даже не компилируется. В этом разделе мы дадим вам несколько советов, как действовать в этой ситуации.

6.1.1 Фантомные номера строк в сообщениях о синтаксических ошибках

Иногда компилятор сообщает вам, что у вас есть синтаксическая ошибка в строке x, хотя на самом деле строка x совершенно правильна, а настоящая ошибка находится в более ранней строке.

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

// bintree.c: routines to do insert and sorted print of a binary tree

#include <stdio.h>
#include <stdlib.h>

struct node {
   int val;            // stored value
   struct node *left;  // ptr to smaller child
   struct node *right; // ptr to larger child
};

typedef struct node *nsp;

nsp root;

nsp makenode(int x)
{
   nsp tmp;

   tmp = (nsp) malloc(sizeof(struct node));
   tmp->val = x;
   tmp->left = tmp->right = 0;
   return tmp;
}

void insert(nsp *btp, int x)
{
   nsp tmp = *btp;

   if (*btp == 0) {
      *btp = makenode(x);
      return;
   }

   while (1)
   {
      if (x < tmp->val) {

         if (tmp->left != 0) {
            tmp = tmp->left;
         } else {
            tmp->left = makenode(x);
            break;
         }

      } else {

         if (tmp->right != 0) {
            tmp = tmp->right;
         } else {
            tmp->right = makenode(x);
            break;
         }

   }
}

void printtree(nsp bt)
{
   if (bt == 0) return;
   printtree(bt->left);
   printf("%d\n",bt->val);
   printtree(bt->right);
}

int main(int argc, char *argv[])
{
   int i;

   root = 0;
   for (i = 1; i < argc; i++)
      insert(&root, atoi(argv[i]));
   printtree(root);
}

Запуск компиляции в GCC дает

$ gcc -g bintree.c
bintree.c: In function `insert':
bintree.c:75: parse error at end of input

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

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

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

...
   tmp->val = x;
   tmp->left = tmp->right = 0;
   return tmp;
}

// void insert(nsp *btp, int x)
// {
//    nsp tmp = *btp;
//
//    if (*btp == 0) {
//       *btp = makenode(x);
//       return;
//    }
//
//    while (1)
//    {
//       if (x < tmp->val) {
//
//          if (tmp->left != 0) {
//             tmp = tmp->left;
//          } else {
//             tmp->left = makenode(x);
//             break;
//          }
//
//       } else {
//
//          if (tmp->right != 0) {
//             tmp = tmp->right;
//          } else {
//             tmp->right = makenode(x);
//             break;
//          }
//
//    }
// }

void printtree(nsp bt)
{
   if (bt == 0) return;
...

Примечание

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

Сохраните файл и перезапустите GCC:

$ gcc -g bintree.c
/tmp/ccg0LDCS.o: In function `main':
/home/matloff/public_html/matloff/public_html/Debug/Book/DDD/bintree.c:72:
undefined reference to `insert'
collect2: ld returned 1 exit status

Не отвлекайтесь на тот факт, что компоновщик LD пожаловался, что не может найти функцию insert(). В конце концов, вы знали, что это произойдет, поскольку закомментировали эту функцию. Вместо этого интересно то, что нет жалоб на синтаксическую ошибку, как это было раньше. Итак, вы действительно подтвердили, что синтаксическая ошибка находится где-то в методе insert(). Теперь раскомментируйте строки этой функции (опять же, желательно использовать шорткат текстового редактора, например «Отменить») и сохраните файл. Кроме того, чтобы убедиться, что вы все восстановили правильно, перезапустите GCC, чтобы убедиться, что синтаксическая ошибка появляется снова (здесь не показано).

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

Для этого сначала закомментируйте примерно половину функции. Разумный способ сделать это — просто закомментировать цикл while. Затем перезапустите GCC:

$ gcc -g bintree.c
$

Ага! Сообщение об ошибке исчезло, значит, проблема с синтаксисом должна быть где-то внутри цикла. Итак, вы сузили проблему до этой половины функции, а теперь сократите и эту область пополам. Для этого закомментируйте код else:

void insert(nsp *btp, int x)
{
   nsp tmp = *btp;

   if (*btp == 0) {
      *btp = makenode(x);
      return;
   }

   while (1)
   {
      if (x < tmp->val) {

         if (tmp->left != 0) {
            tmp = tmp->left;
         } else {
            tmp->left = makenode(x);
            break;
         }

      } // else {
//
//         if (tmp->right != 0) {
//            tmp = tmp->right;
//         } else {
//            tmp->right = makenode(x);
//            break;
//         }
//    }
//
}

Повторно запустив GCC, вы обнаружите, что проблема появляется снова:

$ gcc -g bintree.c
bintree.c: In function `insert':
bintree.c:75: parse error at end of input

Итак, синтаксическая ошибка находится либо в блоке if, либо в конце функции. К этому времени вы сузили проблему до семи строк кода, так что, вероятно, вы сможете найти проблему путем визуального осмотра; оказывается, что мы случайно опустили закрывающую скобку во внешнем if-then-else.

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

6.1.2 Отсутствующие библиотеки

Иногда GCC — на самом деле LD, компоновщик, который вызывается GCC в процессе сборки вашей программы — сообщит вам, что он не может найти одну или несколько функций, вызываемых вашим кодом. Обычно это происходит из-за того, что GCC не сообщает о местонахождении библиотеки функций. Многие, если не большинство, читателей этой книги хорошо разбираются в этой теме, но для тех, кто не разбирается, в этом разделе мы предоставим краткое введение. Обратите внимание, что наше обсуждение здесь применимо в основном к Linux и в различной степени к другим операционным системам семейства Unix.

6.1.2.1 Пример

В качестве примера давайте воспользуемся следующим очень простым кодом, состоящим из основной программы в файле a.c,

// a.c

int f(int x);

main()
{
   int v;
   scanf("%d",&v);
   printf("%d\n",f(v));
}

и подпрограммы в файле z/b.c:

// b.c

int f(int x)
{
   return x*x;
}

Если вы попытаетесь скомпилировать a.c без каких-либо попыток компоновать код в b.c, то LD, конечно, будет жаловаться:

$ gcc -g a.c
/tmp/ccIP5WHu.o: In function `main':
/debug/a.c:9: undefined reference to `f'
collect2: ld returned 1 exit status

Мы могли бы перейти в каталог z, скомпилировать b.c и затем связать объектный файл:

$ cd z
$ gcc -g -c b.c
$ cd ..
$ gcc -g a.c z/b.o

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

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

Примечание

В системах Unix именам файлов статических библиотек принято давать суффикс .a, обозначающий archive. Кроме того, любой библиотеке принято давать имя, начинающееся с lib.

$ gcc -g -c b.c
$ ar rc lib88.a b.o

Команда ar здесь создает библиотеку lib88.a из любых функций, которые она находит в файле b.o. Затем вы должны скомпилировать свою основную программу:

$ gcc -g a.c -l88 -Lz

Опция -l здесь является шорткатом, имеющим тот же эффект, что и

$ gcc -g a.c lib88.a -Lz

который предписывает GCC сообщить LD, что ему нужно будет найти функции в библиотеке lib88.a (или в динамическом варианте, как вы увидите ниже).

Опция -L предписывает GCC сообщить LD о необходимости поиска в каталогах, отличных от текущего (и каталогов поиска по умолчанию), при поиске ваших функций. В данном случае говорится, что z — такой каталог.

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

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

$ gcc -fPIC -c b.c
$ gcc -shared -o lib88.so b.o

Это создаст динамическую библиотеку lib88.so. (В Unix для именования динамических библиотек используется суффикс .so, shared object, за которым, возможно, следует номер версии.) Ссылка на него, как вы делали для статического случая:

$ gcc -g a.c -l88 -Lz

Однако теперь это работает немного по-другому. Если в статическом случае функции, вызываемые из библиотеки, станут частью нашего исполняемого файла a.out, то теперь a.out будет просто содержать запись о том, что эта программа использует библиотеку lib88.so. И что важно, в этой записи даже не указано, где находится эта библиотека. Единственная причина, по которой GCC (опять же, на самом деле LD) хотел взглянуть на lib88.so во время компиляции, заключалась в том, чтобы получить информацию о библиотеке, которая ему нужна для ссылки.

Сама ссылка появится во время выполнения. Операционная система выполнит поиск lib88.so, а затем свяжет его с вашей программой. Возникает вопрос о том, где ОС выполняет этот поиск.

Прежде всего, воспользуемся командой ldd, чтобы проверить, какие библиотеки нужны программе, и где, если вообще есть, ОС их находит:

$ ldd a.out
        lib88.so => not found
        libc.so.6 => /lib/tls/libc.so.6 (0x006cd000)
        /lib/ld-linux.so.2 (0x006b0000)

Программе нужна библиотека C, которую она находит в каталоге /lib/tls, но ОС не может найти lib88.so. Последняя находится в каталоге /Debug/z, но этот каталог не является частью обычного пути поиска ОС.

Один из способов исправить это — добавить /Debug/z к этому пути поиска:

% setenv LD_LIBRARY_PATH ${LD_LIBRARY_PATH}:/Debug/z

Если вы хотите добавить несколько каталогов, соедините их имена через двоеточия в качестве разделителей. (Это для оболочки C или TC.) Для bash введите команды

$ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/Debug/z
$ export LD_LIBRARY_PATH

Давайте убедимся, что это работает:

$ ldd a.out
lib88.so => /Debug/z/lib88.so (0xf6ffe000)
libc.so.6 => /lib/tls/libc.so.6 (0x006cd000)
/lib/ld-linux.so.2 (0x006b0000)

Существуют и другие подходы, но они выходят за рамки этой книги.

6.1.2.2 Использование библиотек в программном обеспечении с открытым исходным кодом

Программное обеспечение с открытым исходным кодом стало довольно популярным, особенно среди пользователей Linux. Однако иногда возникает проблема: сценарий сборки, обычно называемый configure, который поставляется с исходным кодом, не может найти некоторые необходимые библиотеки. Попытки решить проблему путем установки переменной среды LD_LIBRARY_PATH могут оказаться неудачными. Поскольку это подпадает под обсуждаемую здесь тему «отсутствующих библиотек» и обычно не документируется в исходных пакетах, краткое примечание об этом может оказаться полезным.

Часто корень проблемы кроется в программе, вызываемой configure, с именем pkgconfig. Последняя будет получать информацию о библиотеках из определенных файлов метаданных, имеющих суффикс .pc, где префикс является именем библиотеки. Например, файл libgcj.pc будет содержать расположение файлов библиотеки libgcj.so*.

Каталог по умолчанию, в котором pkgconfig ищет файлы .pc, зависит от местоположения самого pkgconfig. Например, если программа находится в /usr/bin, она будет искать в /usr/lib. Этого будет недостаточно, если необходимая библиотека в /usr/local/lib. Чтобы решить эту проблему, установите переменную среды PKG_CONFIG_PATH. В оболочке C или TC вы должны ввести команду оболочки

% setenv PKG_CONFIG_PATH /usr/lib/pkgconfig:/usr/local/lib/pkgconfig

6.2 Отладка программ с графическим интерфейсом

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

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

Соответственно, мы выбрали самый простой пример — библиотеку curses. Это настолько просто, что многие люди могут вообще не считать ее графическим интерфейсом (один студент назвал ее «текстовым графическим интерфейсом»), но это поможет донести суть.

6.2.1 Отладка программ curses

Библиотека curses позволяет программисту писать код, который будет перемещать курсор по экрану, менять цвета символов или инвертировать их, вставлять и удалять текст и так далее.

Например, текстовые редакторы, такие как Vim и Emacs, запрограммированы на curses. В Vim нажатие клавиши j приведет к перемещению курсора на одну строку вниз. Ввод dd приведет к удалению текущей строки, каждая из строк ниже нее переместится на одну строку вверх, а строки над ней останутся неизменными. Эти действия достигаются вызовами функций библиотеки curses.

Чтобы использовать curses, вы должны включить эту инструкцию в свой исходный код.

#include <curses.h>

и вы должны создать ссылку на библиотеку curses:

gcc -g sourcefile.c -lcurses

Давайте возьмем приведенный ниже код в качестве примера. Он запускает команду Unix ps ax для вывода списка всех процессов. В любой момент времени строка, на которой в данный момент находится курсор, будет выделена. Вы можете перемещать курсор вверх и вниз, нажимая клавиши u и d и так далее. Полный список команд смотрите в комментариях в коде.

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

// psax.c; illustration of curses library

// read this code in a "top-down" manner: first these comments and the global
// variables, then main(), then the functions called by main()

// runs the shell command 'ps ax' and saves the last lines of its output,
// as many as the window will fit; allows the user to move up and down
// within the window, with the option to kill whichever process is
// currently highlighted

// usage: psax

// user commands:

//    'u':  move highlight up a line
//    'd':  move highlight down a line
//    'k':  kill process in currently highlighted line
//    'r':  re-run 'ps ax' for update
//    'q':  quit

// possible extensions: allowing scrolling, so that the user could go
// through all the 'ps ax' output, not just the last lines; allow
// wraparound for long lines; ask user to confirm before killing a
// process

#define MAXROW 1000
#define MAXCOL 500

#include <curses.h> // required

WINDOW *scrn; // will point to curses window object

char cmdoutlines[MAXROW][MAXCOL];  // output of 'ps ax' (better to use
                                   // malloc())
int ncmdlines,  // number of rows in cmdoutlines
    nwinlines,  // number of rows our "ps ax" output occupies in the
                // xterm (or equiv.) window
    winrow, // current row position in screen
    cmdstartrow, // index of first row in cmdoutlines to be displayed
    cmdlastrow; // index of last row in cmdoutlines to be displayed

// rewrites the line at winrow in bold font
highlight()
{
   int clinenum;
   attron(A_BOLD);  // this curses library call says that whatever we
                    // write from now on (until we say otherwise)
                    // will be in bold font
// we'll need to rewrite the cmdoutlines line currently displayed
// at line winrow in the screen, so as to get the bold font
clinenum = cmdstartrow + winrow;
mvaddstr(winrow,0,cmdoutlines[clinenum]);
attroff(A_BOLD); // OK, leave bold mode
refresh(); // make the change appear on the screen
}

// runs "ps ax" and stores the output in cmdoutlines
runpsax()
{
   FILE *p; char ln[MAXCOL]; int row,tmp;
   p = popen("ps ax","r");  // open UNIX pipe (enables one program to read
                            // output of another as if it were a file)
   for (row = 0; row < MAXROW; row++) {
      tmp = fgets(ln,MAXCOL,p); // read one line from the pipe
      if (tmp == NULL) break; // if end of pipe, break
      // don't want stored line to exceed width of screen, which the
      // curses library provides to us in the variable COLS, so truncate
      // to at most COLS characters
      strncpy(cmdoutlines[row],ln,COLS);
      cmdoutlines[row][MAXCOL-1] = 0;
   }
   ncmdlines = row;
   close(p); // close pipe
}

// displays last part of command output (as much as fits in screen)
showlastpart()
{
   int row;
   clear(); // curses clear-screen call
   // prepare to paint the (last part of the) 'ps ax' output on the screen;
   // two cases, depending on whether there is more output than screen rows;
   // first, the case in which the entire output fits in one screen:
   if (ncmdlines <= LINES) {  // LINES is an int maintained by the curses
                              // library, equal to the number of lines in
                              // the screen
      cmdstartrow = 0;
      nwinlines = ncmdlines;
   }
   else { // now the case in which the output is bigger than one screen
      cmdstartrow = ncmdlines - LINES;
      nwinlines = LINES;
   }
   cmdlastrow = cmdstartrow + nwinlines - 1;
   // now paint the rows to the screen
   for (row = cmdstartrow, winrow = 0; row <= cmdlastrow; row++,winrow++)
      mvaddstr(winrow,0,cmdoutlines[row]);  // curses call to move to the
                                            // specified position and
                                            // paint a string there
   refresh();  // now make the changes actually appear on the screen,
               // using this call to the curses library
   // highlight the last line
   winrow--;
   highlight();
}

// moves cursor up/down one line
updown(int inc)
{
   int tmp = winrow + inc;
   // ignore attempts to go off the edge of the screen
   if (tmp >= 0 && tmp < LINES) {
      // rewrite the current line before moving; since our current font
      // is non-BOLD (actually A_NORMAL), the effect is to unhighlight
      // this line
      mvaddstr(winrow,0,cmdoutlines[winrow]);
      // highlight the line we're moving to
      winrow = tmp;
      highlight();
   }
}

// run/re-run "ps ax"
rerun()
{
   runpsax();
   showlastpart();
}

// kills the highlighted process
prockill()
{
   char *pid;
   // strtok() is from C library; see man page
   pid = strtok(cmdoutlines[cmdstartrow+winrow]," ");
   kill(atoi(pid),9);  // this is a UNIX system call to send signal 9,
                       // the kill signal, to the given process
   rerun();
}

main()
{
   char c;
   // window setup; next 3 lines are curses library calls, a standard
   // initializing sequence for curses programs
   scrn = initscr();
   noecho(); // don't echo keystrokes
   cbreak(); // keyboard input valid immediately, not after hit Enter
   // run 'ps ax' and process the output
   runpsax();
   // display in the window
   showlastpart();
   // user command loop
   while (1) {
      // get user command
      c = getch();
      if (c == 'u') updown(-1);
      else if (c == 'd') updown(1);
      else if (c == 'r') rerun();
      else if (c == 'k') prockill();
      else break; // quit
   }
   // restore original settings
   endwin();
}

Запустив программу, вы обнаружите, что изображение выглядит нормально, но когда вы нажимаете клавишу u, чтобы курсор поднялся на строку, это работает неправильно, как вы видите на рисунке 6-1.

Рисунок 6-1. Программа работает в окне терминала

Вывод ps ax находится в порядке возрастания номера процесса, но внезапно вы видите, что процесс 2270 отображается после 7162. Давайте отследим ошибку.

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

6.2.1.1 Использование GDB

Итак, запустите GDB, но нам нужно сделать еще одну вещь, связанную с последним пунктом. Мы должны указать GDB, чтобы программа выполнялась в другом окне терминала, отличном от того, в котором работает GDB. Мы можем сделать это с помощью команды GDB tty. Сначала мы переходим к другому окну, в котором будет выполняться программный ввод-вывод, и запускаем там команду Unix tty, чтобы определить идентификатор этого окна. В этом случае выходные данные этой команды сообщают нам, что окно имеет номер терминала dev/pts/8, поэтому мы набираем

(gdb) tty /dev/pts/8

в окне GDB. С этого момента весь ввод с клавиатуры и вывод на экран программы будет осуществляться в окне выполнения.

И последнее, прежде чем мы начнем: мы должны ввести что-то вроде

sleep 10000

в окне выполнения, чтобы ввод с клавиатуры в это окно поступал в программу, а не в оболочку.

Примечание

Есть и другие способы решения проблемы отделения вывода GDB от вывода программы. Например, мы могли бы сначала запустить выполнение программы, а затем запустить GDB в другом окне, прикрепив его к работающей программе.

Далее устанавливаем точку останова в начале функции updown(), так как ошибка возникает при попытке переместить курсор вверх. Затем мы набираем run, и программа начнет выполняться в окне выполнения. Нажмите клавишу u в этом окне, и GDB остановится на точке останова.

(gdb) r
Starting program: /Debug/psax
Detaching after fork from child process 3840.

Breakpoint 1, updown (inc=-1) at psax.c:103
103     { int tmp = winrow + inc;

Сначала давайте подтвердим, что переменная tmp имеет правильное значение.

(gdb) n
105        if (tmp >= 0 && tmp < LINES)
(gdb) p tmp
$2 = 22
(gdb) p LINES
$3 = 24

Переменная winrow показывает текущее положение курсора в окне. Это место должно быть в самом конце окна. LINES имеет значение 24, поэтому winrow должно быть 23, поскольку нумерация начинается с 0. Если inc равен -1 (поскольку мы перемещали курсор вверх, а не вниз), показанное здесь значение tmp, 22, подтверждается.

Теперь перейдем к следующей строке.

(gdb) n
109           mvaddstr(winrow,0,cmdoutlines[winrow]);
(gdb) p cmdoutlines[winrow]
$4 = " 2270 ?        Ss     0:00 nifd -n\n", '\0' <repeats 464 times>

И действительно, вот она, строка для процесса 2270. Мы быстро понимаем, что строка

mvaddstr(winrow,0,cmdoutlines[winrow]);

в исходном коде должна быть

mvaddstr(winrow,0,cmdoutlines[cmdstartrow+winrow]);

Как только мы это исправим, программа будет работать нормально.

Когда мы закончим, нажмите CTRL-C в окне выполнения, чтобы завершить команду sleep и снова сделать оболочку пригодной для использования.

Обратите внимание: если что-то пойдет не так и программа завершится преждевременно, в этом окне выполнения могут сохраниться некоторые нестандартные настройки терминала, например режим cbreak. Чтобы это исправить, перейдите в это окно и нажмите CTRL-J, затем введите слово reset, затем снова нажмите CTRL-J.

6.2.1.2 Использование DDD

А что насчет ДДД? Опять же, вам понадобится отдельное окно для выполнения программы. Организуйте это, выбрав View | Execution Window, и DDD откроет всплывающее окно выполнения. Обратите внимание: вы не вводите команду sleep в это окно, поскольку DDD делает это за вас. Экран теперь будет выглядеть так, как показано на рисунке 6-2.

Рисунок 6-2. Присоединение к работающей программе в DDD

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

6.2.1.3 Использование Eclipse

Прежде всего обратите внимание: при сборке проекта вам нужно указать Eclipse использовать флаг -lcurses в make-файле, процедура которого была показана в главе 5.

Здесь вам также понадобится отдельное окно выполнения. Вы можете сделать это при настройке диалогового окна отладки. После настройки диалогового окна запуска как обычно и выбора Run | Open Debug Dialog, мы пойдем немного другим путем, чем тот, который мы делали до сих пор. Обратите внимание, что на рисунке 6-3 помимо обычного варианта C/C++ Local Application имеется также вариант C/C++ Attach to Local Application. Последнее означает, что вы хотите, чтобы Eclipse использовал возможность GDB присоединяться к уже запущенному процессу (обсуждается в главе 5). Щелкните правой кнопкой мыши C/C++ Attach to Local Application, выберите New и действуйте, как раньше.

Рисунок 6-3. Присоединение к работающей программе в Eclipse

Когда вы запускаете фактическую отладку, сначала запустите программу в отдельном окне оболочки. (Не забывайте, что программа, вероятно, находится в каталоге вашей рабочей области Eclipse.) Затем выберите Run | Open Debug Dialog, как вы обычно делаете при первом запуске отладки; в этом случае Eclipse откроет всплывающее окно со списком процессов и попросит вас выбрать тот, к которому вы хотите подключить GDB. Это показано на рисунке 6-4, где показано, что ваш процесс psax имеет идентификатор 12319 (обратите внимание на программу, работающую в другом окне, частично скрытом здесь). Щелкните этот процесс, затем нажмите OK, что приведет к ситуации, изображенной на рисунке 6-5.

Рисунок 6-4. Выбор процесса для подключения GDB

Рисунок 6-5. Остановлено в ядре

На этом рисунке вы можете видеть, что мы остановились во время системного вызова. Eclipse сообщает вам, что у него нет исходного кода для текущей инструкции, но это ожидаемо и не является проблемой. На самом деле сейчас самое время установить точки останова в исходном файле psax.c. Сделайте это, а затем щелкните значок Resume. Eclipse будет работать до тех пор, пока не достигнет первой точки останова, а затем вы сможете выполнять отладку как обычно.

Глава 7
Другие инструменты

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

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

7.1 Правильное использование текстового редактора

Лучший метод отладки — не допускать ошибок в программировании! Простое использование редактора, поддерживающего программирование, является одним из наиболее игнорируемых аспектов «предварительной отладки».

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

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

7.1.1 Подсветка синтаксиса

Vim использует подсветку синтаксиса (syntax highlighting) для отображения частей программного файла разными цветами или шрифтами, так что элементы кода, такие как ключевые слова, идентификаторы типов, локальные переменные и директивы препроцессора, имеют каждый свой собственный цвет и схему шрифтов. Редактор выбирает цвет и схему шрифтов, просматривая расширение имени файла, чтобы определить используемый вами язык. Например, если имя файла заканчивается на .pl (что указывает на сценарий Perl), вхождения слова die (которое является именем функции Perl) подсвечиваются, тогда как если имя файла заканчивается на .c, они не выделяются.

Лучшим названием для подсветки синтаксиса было бы лексическое выделение, поскольку редактор обычно не анализирует синтаксис слишком внимательно. Он не может сказать вам, что вы предоставили неправильное количество аргументов или аргументы неправильного типа при вызове функции. Вместо этого он понимает (например), что такие слова, как bless и foreach, являются ключевыми словами Perl, а fmt и dimension — ключевыми словами Fortran, и отображает их соответствующим образом.

Несмотря на это, подсветка синтаксиса по-прежнему очень полезна для выявления простых, но легко допускаемых ошибок. Например, на нашем компьютере цвет по умолчанию для идентификаторов типов, таких как ключевые слова FILE или float в C, — зеленый. Как только ваш глаз привыкнет к шрифту и цветовой схеме, вы заметите несоответствие цвета, возникающее при написании имени типа с ошибкой, и автоматически исправите ошибку без необходимости проходить ненужный цикл компиляции.

Пример использования подсветки синтаксиса для проверки ключевых слов, который нравится нам (авторам), можно найти в make-файлах. Ключевое слово patsubst — очень полезная команда текстового поиска и замены в make-файлах. Одно из наиболее распространенных применений — создание списка файлов .o из файлов .c исходного кода проекта:

TARGET = CoolApplication
OBJS = $(patsubst %.c, %.o, $(wildcard *.c))

$(TARGET): $(OBJS)

Один из авторов так и не может вспомнить, patsubst ли это, pathsubst или patsub. Зная, что ключевые слова make-файла отображаются светлым цветом (желтым), сможете ли вы определить, какая версия строки, показанной ниже, неверна? Даже если вы не умеете писать make-файлы, одна только подсветка синтаксиса должна прояснить ситуацию[1]!

OBJS = $(patsub %.c, %.o $(wildcard *.c))
OBJS = $(patsubst %.c, %.o $(wildcard *.c))

Рисунок 7-1. Подсветка синтаксиса обнаруживает мою ошибку в make-файле.

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

if (fp == NULL) {
     puts("This was a "bad" file pointer.");
     exit(1);
}

Рисунок 7-2. Подсветка синтаксиса выявляет распространенную ошибку.

и вот еще одна иллюстрация подобной ошибки. Постарайтесь позволить цвету указать вам на ошибку.

fprintf(fp, "argument %d is \"%s\".\n, i, argv[i]);
printf("I just wrote \"%s\" which is argument %d\n", argv[i], i);

Рисунок 7-3. Подсветка синтаксиса выявляет еще одну распространенную ошибку.

Вы можете обнаружить, что некоторые цвета в схеме подсветки синтаксиса трудно читаемы. Если да, вы можете отключить подсветку, набрав следующее:

: syntax off

Команда на повторное включение, конечно,

: syntax on

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

  1. Для книги этот рисунок необходимо было преобразовать в оттенки серого. На практике выявить ошибку будет еще проще.

7.1.2 Соответствующие скобки

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

mytype *myvar;
if ((myvar = (mytype *)malloc(sizeof(mytype))) == NULL) {
   exit(-1);
}

Примечание

В этом разделе слово «скобка» относится к круглым, квадратным и фигурным скобкам: (), [] и {} соответственно.

Быстро: сбалансированы ли круглые скобки[2]? Вам когда-нибудь приходилось находить несбалансированные скобки в длинных блоках кода с большим количеством условных операторов? Или пробовали использовать TeX? (Мы содрогаемся при мысли о некоторых наших прошлых файлах LaTeX с отсутствующими фигурными скобками!) Тогда вы должны согласиться с нами, что именно для этого и нужны компьютеры — избавить нас от такой утомительной работы! У Vim есть несколько замечательных функций, которые могут помочь.

Опция showmatch полезна при программировании, но в противном случае она может раздражать. Вы можете использовать автокоманды для установки этой опции только во время программирования. Например, чтобы настроить showmatch только для сеансов редактирования файлов исходного кода C/C++, вы можете поместить такие строки в свой файл .vimrc (дополнительную информацию см. в файлах справки Vim):

au BufNewFile,BufRead *.c set showmatch
au BufNewFile,BufRead *.cc set showmatch

Что, если в ваш код попадет несбалансированная скобка или, что еще хуже, вам нужно будет уловить несбалансированные скобки в чужом спагетти-коде? Ранее упомянутая команда редактора % ищет символы сбалансированной группировки. Например, если вы поместите курсор на левую квадратную скобку [ и наберете % в командном режиме, Vim переместит курсор на следующий символ ]. Если вы поместите курсор на правую фигурную скобку } и вызовете %, Vim переместит курсор на предыдущий соответствующий символ {. Таким образом, вы можете убедиться не только в том, что любая круглая, фигурная или квадратная скобка имеет соответствующего партнера, но также и в том, что партнер соответствует семантически. Вы даже можете определить другие совпадающие пары «скобок», например разделители комментариев HTML <!-- и -->, используя команду Vim matchpair. Дополнительную информацию смотрите на страницах справки Vim.

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

7.1.3 Vim и Makefiles

Утилита make управляет компиляцией и сборкой исполняемых файлов в системах Linux/Unix, и небольшие усилия по ее использованию могут принести программисту большие дивиденды. Однако это также открывает новые возможности для ошибок. В Vim есть несколько функций, которые действительно могут помочь в процессе отладки, если вы используете make. Рассмотрим следующий фрагмент make-файла:

all: yerror.o main.o
   gcc -o myprogram yerror.o main.o

yerror.o: yerror.c yerror.h
   gcc -c yerror.c

main.o: main.c main.h
   gcc -c main.c

В этом make-файле есть ошибка, но ее трудно заметить. make очень требователен к форматированию. Командная строка цели должна начинаться с символа табуляции, а не с пробелов. Если вы введете команду set list из Vim, вы сразу увидите, что не так:

all: yerror.o main.o$
^Igcc -o myprogram yerror.o main.o$
$
yerror.o: yerror.c yerror.h$
^Igcc -c yerror.c$
$
main.o: main.c main.h$
   gcc -c main.c$

В режиме list Vim отображает непечатаемые символы. По умолчанию символ конца строки отображается как $, а управляющие символы отображаются с помощью символа каретки (^); таким образом, символ табуляции CTRL-I отображается как ^I. Следовательно, вы можете отличать пробелы от табуляции, и ошибка очевидна: командная строка для цели main.o make начинается с пробелов.

Вы можете контролировать то, что отображается, используя опцию listchars в Vim. Например, если вы хотите изменить символ конца строки на = вместо $, вы можете использовать :set listchars=eol:=.

7.1.4 Makefiles и предупреждения компилятора

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

:make arguments

из командного режима Vim запустит команду make и передаст ей arguments.

Становится еще лучше. Когда вы создаете программу в Vim, редактор фиксирует все сообщения, выдаваемые компилятором. Он понимает синтаксис вывода GCC и знает, когда возникает предупреждение или ошибка компилятора. Давайте посмотрим на это в действии. Рассмотрим следующий код:

#include <stdio.h>

int main(void)
{
   printf("There were %d arguments.\n", argc);

   if (argc .gt. 5) then
      print *, 'You seem argumentative today';
   end if

   return 0;
}

Листинг 7-1. main.c

Похоже, кто-то параллельно кодировал на Фортране и Си! Предположим, вы сейчас редактируете файл main.c и хотите собрать программу. Введите команду :make из Vim и просмотрите все сообщения об ошибках (рисунок 7-4).

Теперь, если вы нажмете ENTER или пробел, вы вернетесь к редактированию программы, но с курсором, расположенным на строке, которая сгенерировала первое предупреждение или ошибку (в данном случае сообщение о том, что argc не был объявлен), как показано на рисунке 7-5.

:!make 2>&1| tee /tmp/v243244/1
gcc -std=c99 -W -Wall main.c -o main
main.c: In function 'main':
main.c:12: error: 'argc' undeclared (first use in this function)
main.c:12: error: (Each undeclared identifier is reported only once
main.c:12: error: for each function it appears in.)
main.c:14: error: expected identifier before numeric constant
main.c:14: error: 'then' undeclared (first use in this function)
main.c:15: error: expected ';' before 'print'
main.c:15:18: warning: character constant too long for its type
main.c:16: error: 'end' undeclared (first use in this function)
main.c:16: error: expected ';' before 'if'
make: *** [main] Error 1
(3 of 12): error: 'argc' undeclared (first use in this function)
Press ENTER or type command to continue 

Рисунок 7-4. Сообщения об ошибках

#include <stdio.h>

int main(void)
{
   printf("There were %d arguments.\n", argc);
   if (argc .gt. 5) then
      print 'You seem argumentative today';
   end if 

   return 0; 
}

Рисунок 7-5. Курсор установлен на первой ошибке.

После исправления ошибки есть два способа перейти к следующей ошибке:

7.1.5 Заключительные мысли о текстовом редакторе как IDE

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

Если вы используете Vim, мы рекомендуем Vi IMproved—Vim Стива Уаллина (New Riders, 2001). Книга подробно и хорошо написана. (К сожалению, она была написана для Vim 6.0, а в Vim 7.0 и более поздних версиях такие функции, как свертывание, не рассматриваются.) Нашей целью было просто дать представление о том, что Vim может сделать для программиста, но книга Стива — отличный ресурс. чтобы узнать подробности.

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

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

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

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

Многие из опций предупреждения компилятора, такие как ключ -Wtraditional в GCC, вероятно, являются излишними, за исключением особых ситуаций. Однако даже не думайте об использовании GCC без использования каждый раз -Wall. Например, одна из наиболее распространенных ошибок, которые допускают начинающие программисты на языке C, иллюстрируется следующим утверждением:

if (a = b)
   printf("Equality for all!\n");

Это действительный код C, и GCC его с радостью скомпилирует. Переменной a присваивается значение b, и это значение используется в условии. Однако почти наверняка это не то, что хотел сделать программист. Используя переключатели -Wall GCC, вы, по крайней мере, получите предупреждение о том, что этот код может быть ошибкой:

$ gcc try.c
$ gcc -Wall try.c
   try.c: In function `main':
   try.c:8: warning: suggest parentheses around assignment used as truth value

GCC предлагает заключить присваивание a = b в круглые скобки перед использованием его в качестве значения истинности точно так же, как вы это делаете, когда присваиваете значение и выполняете сравнение: if ((fp = fopen("myfile", "w") ) == NULL). GCC, по сути, спрашивает: «Вы уверены, что хотите здесь присваивание a = b вместо проверки на равенство a == b

Вы всегда должны использовать возможности вашего компилятора для проверки ошибок, и если вы преподаете программирование, вы должны потребовать, чтобы ваши ученики тоже использовали их, чтобы привить хорошие привычки. Пользователи GCC всегда должны использовать -Wall, даже для самого маленькоой программы «Hello, world!». Мы обнаружили, что разумно также использовать -Wmissing-prototypes и -Wmissing-declarations. Действительно, если у вас есть свободные 10 минут, просмотр справочной страницы GCC и чтение раздела с предупреждениями компилятора — отличный способ провести время, особенно если вы собираетесь в значительной степени программировать под Unix.

7.3 Отчеты об ошибках в C

Отчеты об ошибках в C выполняются с помощью старого механизма errno. Хотя errno имеет почтенный возраст и некоторые недостатки, в целом справляется со своей задачей. Вы можете задаться вопросом, зачем вообще нужен механизм сообщения об ошибках, ведь большинство функций C имеют удобное возвращаемое значение, которое сообщает, был ли вызов успешным или неудачным. Ответ заключается в том, что возвращаемое значение может предупредить вас о том, что функция сделала не то, что вы от нее хотели, но оно может сказать, а может и не сказать, почему так произошло. Чтобы сделать это более конкретным, рассмотрим этот фрагмент кода:

FILE *fp
fp = fopen("myfile.dat", "r");
retval = fwrite(&data, sizeof(DataStruct), 1, fp);

Предположим, вы проверяете функцию retval и обнаруживаете, что она равна нулю. На странице руководства вы видите, что fwrite() должен возвращать количество записанных элементов (не байтов или символов), поэтому retval должно быть равно 1. Сколькими различными причинами может произойти сбой fwrite()? Много! Во-первых, файловая система может быть заполнена или у вас нет прав на запись в файл. Однако в этом случае в коде есть ошибка, которая приводит к сбою функции fwrite(). Сможете ли вы ее найти[3]? Система отчетов об ошибках, такая как errno, может предоставить диагностическую информацию, которая поможет вам выяснить, что произошло в подобных случаях. (Операционная система также может сообщать об определенных ошибках.)

  1. Мы открыли файл в режиме чтения, а затем попытались записать в него.

7.3.1 Использование errno

При неудачных системных и библиотечных вызовах обычно устанавливается глобально определенная целочисленная переменная с именем errno. В большинстве систем GNU/Linux errno объявлен в /usr/include/errno.h, поэтому, включив этот заголовочный файл, вам не нужно объявлять extern int errno в своем собственном коде.

При сбое системного или библиотечного вызова errno присваивается значение, указывающее тип сбоя. Вам предстоит проверить значение errno и предпринять соответствующие действия. Рассмотрим следующий код:

#include <stdio.h>
#include <errno.h>
#include <math.h>

int main(void)
{
   double trouble = exp(1000.0);
   if (errno) {
      printf("trouble: %f (errno: %d)\n", trouble, errno);
      exit(-1);
   }

   return 0;
}

Листинг 7-2. double-trouble.c

В нашей системе exp(1000.0) больше, чем может хранить double, поэтому присваивание приводит к переполнению числа с плавающей запятой. Из выходных данных вы видите, что значение errno 34 указывает на ошибку переполнения с плавающей запятой:

$ ./a.out
trouble: inf (errno: 34)

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

Во-первых, код, использующий errno, может быть не полностью переносимым. Например, стандарт ISO C определяет лишь несколько кодов ошибок, а стандарт POSIX определяет гораздо больше. Вы можете увидеть, какие коды ошибок определяются какими стандартами, на странице руководства errno. Более того, в стандартах не указаны числовые значения, например 34, для кодов ошибок. Они предписывают символические коды ошибок, которые представляют собой макроконстанты, имена которых начинаются с префикса E и которые определены в файле заголовка errno (или в файлах, включенных в заголовок errno). Единственное, что одинаково в их значениях на разных платформах, — это то, что они ненулевые. Таким образом, вы не можете предполагать, что определенное значение всегда указывает на одно и то же состояние ошибки[4]. Вы всегда должны использовать символические имена для ссылки на значения errno.

В дополнение к значениям errno ISO и POSIX конкретные реализации библиотеки C, такие как glibc GNU, могут определять еще больше значений errno. В GNU/Linux раздел errno info-страницы libc[5] является каноническим источником всех доступных значений errno на этой платформе: ISO, POSIX и glibc. Вот некоторые коды ошибок, которые мы извлекли из /usr/include/asm/errno.h с машины GNU/Linux:

#define EPIPE        32 /* Broken pipe */
#define EDOM         33 /* Math arg out of domain of func */
#define ERANGE       34 /* Math result not representable */
#define EDEADLK      35 /* Resource deadlock would occur */
#define ENAMETOOLONG 36 /* File name too long */
#define ENOLCK       37 /* No record locks available */
#define ENOSYS       38 /* Function not implemented */

Далее следует запомнить несколько важных фактов об использовании errno. errno может быть установлен любой библиотечной функцией или системным вызовом, независимо от того, завершился ли он неудачно или успешно! Поскольку даже успешные вызовы функций могут установить errno, вы не можете полагаться на то, что errno сообщит вам, произошла ли ошибка. Вы можете только полагаться на него, чтобы узнать, почему произошла ошибка. Поэтому самый безопасный способ использования errno заключается в следующем[6]:

  1. Выполнить вызов библиотеки или системной функции.

  2. Использовать возвращаемое значение функции, чтобы определить, произошла ли ошибка.

  3. Если произошла ошибка, использовать errno, чтобы определить причину.

В псевдокоде:

retval = systemcall();

if (retval indicates an error) {
  examine_errno();
  take_action();
}

Это подводит нас к man-страницам. Предположим, вы пишете код и хотите включить некоторую проверку ошибок после вызова ptrace(). На втором этапе предлагается использовать возвращаемое значение ptrace(), чтобы определить, произошла ли ошибка. Если вы похожи на нас, то у вас нет в памяти возвращаемых значений ptrace(). Что вы можете сделать? На каждой странице руководства есть раздел с названием «Return Value» (Возвращаемое значение). Вы можете быстро перейти к нему, набрав man function name и выполнив поиск возвращаемого значения строки.

Хотя у errno есть некоторые недостатки, есть и хорошие новости.

В библиотеке GNU C ведется обширная работа по сохранению ошибки errno при входе в функцию, а затем восстановлению ее исходного значения, если вызов функции завершится успешно. Похоже, что glibc очень старается не записывать errno для успешных вызовов функций. Однако мир — это не GNU (пока), поэтому переносимый код не должен полагаться на этот факт.

Кроме того, хотя просмотр документации каждый раз, когда вы хотите узнать, что означает тот или иной код ошибки, утомляет, есть две функции, которые упрощают интерпретацию кодов ошибок: perror() и strerror(). Они делают одно и то же, но по-разному. Функция perror() принимает строковый аргумент и не имеет возвращаемого значения:

#include <stdio.h>
void perror(const char *s);

Аргумент perror() — это строка, предоставленная пользователем. Когда вызывается функция perror(), она печатает эту строку, за которой следует двоеточие и пробел, а затем описание типа ошибки на основе значения errno. Вот простой пример использования perror():

int main(void)
{
   FILE *fp;

   fp = fopen("/foo/bar", "r");

   if (fp == NULL)
     perror("I found an error");

   return 0;
}

Листинг 7-3. perror-example.c

Если в вашей системе нет файла /foo/bar, вывод будет выглядеть следующим образом:

$ ./a.out
I found an error: No such file or directory

Вывод функции perror() соответствует стандартной ошибке. Помните об этом, если хотите перенаправить вывод ошибок вашей программы в файл.

Другая функция, которая помогает переводить коды ошибок в описательные сообщения, — это strerror():

#include <string.h>
char *strerror(int errnum);

Эта функция принимает значение errno в качестве аргумента и возвращает строку, описывающую ошибку. Вот пример использования strerror():

int main(void)
{
   close(5);
   printf("%s\n", strerror(errno));
   return 0;
}

Листинг 7-4. strerror-example.c

int main(void) { close(5); printf("%s\n", strerror(errno)); вернуть 0; }

Вот вывод этой программы:

$ ./a.out
Bad file descriptor
  1. Например, некоторые системы различают EWOULDBLOCK и EAGAIN, но GNU/Linux этого не делает.
  2. В дополнение к информационным страницам libc вы можете просмотреть заголовочные файлы вашей системы, чтобы проверить значения ошибок. Это не только безопасно и естественно, но и поощряется!
  3. Использование errno в листинге 7-2 не является хорошей практикой.

7.4 Лучше жить с помощью strace и ltrace

Важно понимать разницу между библиотечными функциями и системными вызовами. Библиотечные функции относятся к более высокому уровню, полностью выполняются в пользовательском пространстве и предоставляют программисту более удобный интерфейс к функциям, которые выполняют реальную работу — системным вызовам. Системные вызовы работают в режиме ядра от имени пользователя и предоставляются ядром самой операционной системы. Библиотечная функция printf() может выглядеть как очень общая функция печати, но на самом деле все, что она делает, это форматирует данные, которые вы ей передаете, в строки и записываете строковые данные с помощью низкоуровневого системного вызова write(), который затем отправляет данные в файл, связанный со стандартным выводом вашего терминала.

Утилита strace распечатывает каждый системный вызов, выполняемый вашей программой, вместе с его аргументами и возвращаемым значением. Хотите узнать, какие системные вызовы выполняет printf()? Это просто! Напишите программу «Hello, world!», но запустите ее так:

$ strace ./a.out

Разве вас не впечатляет то, как усердно работает ваш компьютер, просто чтобы распечатать что-то на экране?

Каждая строка вывода трассировки соответствует одному системному вызову. Большая часть вывода strace показывает вызовы mmap() и open() с такими именами файлов, как ld.so и libc. Это связано с такими вещами системного уровня, как отображение дисковых файлов в памяти и загрузка общих библиотек. Скорее всего, вас это все не волнует. Для наших целей нас интересуют ровно две строки в конце вывода:

write(1, "hello world\n", 12hello world) = 12
_exit(0) = ?

Эти строки иллюстрируют общий формат вывода strace:

Вот и все, но какой обилие информации! Вы также можете увидеть ошибки. Например, в нашей системе мы получаем следующие строки:

open("/etc/ld.so.preload", O_RDONLY) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY)   = 3

Первый вызов open() пытается открыть файл с именем /etc/ld.so.preload. Возвращаемое значение вызова open() (который должен быть неотрицательным дескриптором файла) равно -1, что указывает на какую-то ошибку. strace услужливо сообщает нам, что ошибка, вызвавшая сбой open(), — это ENOENT: файл /etc/ld.so.preload не существует.

strace сообщает нам, что второй вызов open() вернул значение 3. Это действительный дескриптор файла, поэтому, очевидно, вызов для открытия файла /etc/ld.so.cache увенчался успехом. Соответственно, во второй строке вывода трассировки коды ошибок отсутствуют.

Кстати, не беспокойтесь о подобных ошибках. То, что вы видите, связано с динамической загрузкой библиотеки и на самом деле не является ошибкой как таковой. Файл ld.so.preload можно использовать для переопределения общих библиотек системы по умолчанию. Поскольку у меня нет желания возиться с такими вещами, этого файла просто не существует в моей системе. По мере того, как вы приобретете опыт работы со strace, вы будете все лучше и лучше фильтровать такого рода «шум» и концентрироваться на тех частях вывода, которые вас действительно интересуют.

В strace есть несколько опций, которые вам придется использовать в тот или иной момент, поэтому мы кратко опишем их здесь. Если вы посмотрите на полный вывод strace программы «Hello, world!», вы можете заметить, что strace может быть немного… многословным. Гораздо удобнее сохранить весь этот вывод в файл, чем пытаться просмотреть его на экране. Одним из способов, конечно, является перенаправление stderr, но вы также можете использовать ключ -o logfile, чтобы заставить strace записывать весь свой вывод в файл журнала. Кроме того, strace обычно усекает строки до 32 символов. Иногда это может скрыть важную информацию. Чтобы заставить strace обрезать строки по N символов, вы можете использовать опцию -s N. Наконец, если вы запускаете strace в программе, которая разветвляет дочерние процессы, вы можете записать выходные данные strace для отдельных дочерних процессов в файл с именем LOG.xxx с помощью переключателя -o LOG -ff, где xxx — ID дочернего процесса.

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

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

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

open(umovestr: Input/output error 0, O_RDONLY) = -1 EFAULT (Bad address)

и запуск ltrace дал еще больше подсказок:

fopen(NULL, "r")         = 0

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

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

  1. Вы можете быть удивлены возвращаемым значением функции exit() со знаком вопроса. Все, что говорит здесь strace, это то, что _exit возвращает void.

7.5 Статические проверки кода: lint и друзья

Существует ряд бесплатных и коммерческих инструментов, которые сканируют ваш код без его компиляции и предупреждают вас об ошибках, возможных ошибках и отклонениях от строгих стандартов кодирования на C. Они называются статическими средствами проверки кода (static code checkers). Каноническая программа проверки статического кода для C, написанная С.К.Джонсоном в конце 1970-х годов, называлась lint. Он был написан главным образом для проверки вызовов функций, поскольку ранние версии C не поддерживали прототипирование. lint породил множество производных статических программ проверки. Одна такая программа проверки, написанная Дэйвом Эвансом с факультета компьютерных наук Университета Вирджинии, называлась lclint и была популярна в современных системах, таких как Linux. В январе 2002 года Дэйв переименовал lclint в splint, чтобы подчеркнуть повышенное внимание к безопасному программированию (а также потому, что splint легче произносить, чем lclint).

Цель splint — помочь вам написать максимально защищенную, безопасную и безошибочную программу. splint, как и его предшественники, может быть очень требователен к тому, что считать хорошим кодом[8]. В качестве упражнения попробуйте найти в следующем коде что-нибудь, что может вызвать предупреждение:

int main(void)
{
   int i;

   scanf("%d", &i);

   return 0;
}

Листинг 7-5. scan.c

Когда этот код выполняется через splint, он предупреждает, что вы отбрасываете возвращаемое значение scanf()[9].

$ splint test.c
Splint 3.0.1.6 --- 11 Feb 2002

test.c: (in function main)
test.c:8:2: Return value (type int) ignored: scanf("%d", &i)
   Result returned by function call is not used. If this is intended, can cast result to (void) to eliminate message. (Use -retvalint to inhibit warning)

   Finished checking --- 1 code warning

Все предупреждения splint имеют фиксированный формат. В первой строке предупреждения splint указывается имя файла и функция, в которой возникает предупреждение. В следующей строке указаны номер строки и позиция предупреждения. После этого следует описание предупреждения, а также инструкции по подавлению такого рода предупреждений. Как вы можете видеть здесь, вызов splint -retvalint test.c отключает все предупреждения об отбрасывании возвращаемых значений целочисленной функции. В качестве альтернативы, если вы не хотите отключать все отчеты об отброшенных возвращаемых значениях int, вы можете подавить предупреждение только для этого вызова scanf(), приведя scanf() к void. То есть замените scanf("%d", &i); на (void) scanf("%d", &i);. (Есть еще один способ подавить это предупреждение, используя аннотации, о которых заинтересованный читатель может узнать из документации splint.)

  1. Многие программисты считают знаком чести, когда splint сообщает об отсутствии предупреждений. Когда это происходит, код объявляется безворсовым (lint free).
  2. Возвращаемое значение функции scanf() — это количество присвоенных входных элементов.

7.5.1 Как использовать splint

splint поставляется с ошеломляющим количеством переключателей, но по мере его использования вы почувствуете, какие из них обычно соответствуют вашим потребностям. Многие переключатели по своей природе являются логическими — они включают или выключают функцию. Этим типам переключателей предшествует +, чтобы включить их, или -, чтобы их выключить. Например, -retvalint отключает отчет об отброшенных возвращаемых значениях int, а +retvalint включает этот отчет (это поведение по умолчанию).

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

$ splint +weak *.c

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

+weak Слабая проверка, обычно для неаннотированного кода C.

+standard Режим по умолчанию

+checks Умеренно строгая проверка

+strict Абсурдно строгая проверка

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

7.5.2 Последние слова

splint поддерживает многие, но не все расширения библиотеки C99. Он также не поддерживает некоторые изменения языка C99. Например, splint не знает ни о сложных типах данных, ни об определении переменной int в инициализаторе цикла for.

splint распространяется под лицензией GNU GPL. Его домашняя страница находится по адресу http://www.splint.org/.

7.6 Отладка динамически выделяемой памяти

Как вы, возможно, знаете, динамически выделяемая память (dynamically allocated memory) (DAM) — это память, которую программа запрашивает из кучи с помощью таких функций, как malloc() и calloc()[10]. Динамически выделяемая память обычно используется для структур данных, таких как двоичные деревья и связанные списки, а также работает «за кулисами», когда вы создаете объект в объектно-ориентированном программировании. Даже стандартная библиотека C использует DAM для своих внутренних целей. Возможно, вы также помните, что динамическую память необходимо освободить, когда вы с ней закончите работать[11].

Проблемы DAM, как известно, трудно обнаружить, и они делятся на несколько общих категорий:

Эти ошибки могут не привести к очевидному сбою вашей программы. Давайте обсудим это немного дальше. Чтобы сделать обсуждение более конкретным, вот пример, иллюстрирующий эти проблемы:

int main( void )
{
   int *a = (int *) malloc( 3*sizeof(int) ); // malloc return not checked
   int *b = (int *) malloc( 3*sizeof(int) ); // malloc return not checked

   for (int i = -1; i <= 3; ++i)
      a[i] = i; // a bad write for i = -1 and 3

   free(a);
   printf("%d\n", a[1]); // a read from freed memory
   free(a); // a double free on pointer a

   return 0; // program ends without freeing *b.
}

Листинг 7-6. memprobs.c

Первая проблема называется утечкой памяти (memory leak). Например, рассмотрим следующий код:

int main( void )
{
   ... lots of previous code ...

   myFunction();

   ... lots of future code ...
}

void myFunction( void )
{
   char *name = (char *) malloc( 10*sizeof(char) );
}

Листинг 7-7. Пример утечки памяти

Когда myFunction() выполняется, вы выделяете память для 10 значений char. Единственный способ обратиться к этой памяти — использовать адрес, возвращаемый функцией malloc(), который вы сохранили в переменной-указателе name. Если по какой-то причине вы потеряете адрес — например, name выходит за пределы области видимости при выходе из myFunction(), и вы не сохранили копию его значения где-либо еще — тогда у вас не будет возможности получить доступ к выделенной памяти, и, в частности, у вас нет возможности ее освободить.

Но именно это и происходит в коде. Динамически выделенная память не просто исчезает или выходит за пределы области видимости, как это делает хранилище для переменной, выделяемой стеком, такой как name, и поэтому каждый раз, когда вызывается функция myFunction(), она поглощает память на 10 переменных типа char, которая затем никогда не освобождается. Конечным результатом является то, что доступное пространство кучи становится все меньше и меньше. Вот почему эта ошибка называется утечкой памяти (memory leak).

Утечки памяти уменьшают объем памяти, доступной программе. В большинстве современных систем, таких как GNU/Linux, эта память освобождается операционной системой, когда приложение с утечкой памяти завершает работу. В старых системах, таких как Microsoft DOS и Microsoft Windows 3.1, утечка памяти теряется до перезагрузки операционной системы. В любом случае утечки памяти приводят к снижению производительности системы из-за увеличения подкачки. Со временем они могут привести к сбою программы с утечкой или даже всей системы.

Вторая проблема, возникающая при использовании динамически выделяемой памяти, заключается в том, что вызов malloc() может завершиться неудачей. Есть много способов, которыми это может произойти. Например, ошибка в вычислении может привести к тому, что запрос на количество DAM окажется слишком большим или отрицательным. Или, возможно, системе действительно не хватает памяти. Если вы не осознаете, что это произошло, и продолжаете пытаться читать и писать в то, что вы ошибочно считаете действительным DAM, в и без того неприятной ситуации развиваются осложнения. Это тип нарушения прав доступа, о котором мы вскоре поговорим. Однако, чтобы избежать этого, вы всегда должны проверять, возвращает ли malloc() указатель, отличный от NULL, и пытаться корректно выйти из программы, если это не так.

Третья и четвертая проблемы называются ошибками доступа (access errors). Обе, по сути, являются версиями одного и того же: программа пытается прочитать или записать адрес памяти, который ей недоступен. Третья проблема связана с доступом к адресу памяти выше или ниже сегмента DAM. Четвертая проблема связана с доступом к адресу памяти, который раньше был доступен, но был освобожден до попытки доступа.

Двойной вызов free() в одном и том же сегменте DAM в просторечии называется двойным освобождением (double free). Библиотека C имеет структуры управления внутренней памятью, которые описывают границы каждого выделенного сегмента DAM. Когда вы дважды вызываете функцию free() для одного и того же указателя на динамическую память, структура управления памятью программы повреждается, что может привести к сбою программы или, в некоторых случаях, может позволить хакеру использовать ошибку для создания переполнения буфера. В каком-то смысле это тоже нарушение прав доступа, но уже для самой библиотеки C, а не программы.

Нарушение прав доступа приводит к одному из двух событий: программа может выйти из строя, возможно, записав core-файл[12] (обычно после получения сигнала об ошибке сегментации), или, что гораздо хуже, она может продолжить выполнение, что приведет к повреждению данных.

Из этих двух последствий первое бесконечно более желательно. Фактически, существует ряд доступных инструментов, которые заставят вашу программу выделять сбой и сбрасывать ядро всякий раз, когда обнаруживается какая-либо проблема с DAM, вместо того, чтобы рисковать альтернативой!

Вы можете задаться вопросом: «С какой стати я должен хотеть, чтобы моя программа выявляла ошибки?» Вам следует смириться с мыслью, что если обработка DAM в вашем коде содержит ошибки, то в случае сбоя это очень хорошо, потому что другой вариант — периодическое, загадочное и невоспроизводимое плохое поведение. Повреждение памяти может оставаться незамеченным в течение долгого времени, прежде чем его последствия станут ощутимы. Часто проблема проявляется в частях вашей программы, которые находятся довольно далеко от ошибки, и ее отслеживание может стать кошмаром. Если этого было недостаточно, повреждение памяти может привести к нарушениям безопасности. Приложения, которые, как известно, вызывают переполнение буфера и двойное освобождение, в некоторых случаях могут быть использованы злоумышленниками для запуска произвольного кода и несут ответственность за многие уязвимости безопасности операционной системы.

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

Короче говоря, выявление проблем DAM как можно скорее имеет чрезвычайно важное значение.

  1. В оставшейся части этого раздела мы будем ссылаться только на malloc(), но на самом деле мы имеем в виду malloc() и его аналоги, такие как calloc() и realloc().
  2. Одним из заметных исключений является функция alloca(), которая запрашивает динамическую память из текущего кадра стека, а не из кучи. Память в кадре автоматически освобождается при возвращении функции. Таким образом, вам не нужно освобождать память, выделенную функцией alloca().
  3. В просторечии это называется сбросом ядра (dumping core).

7.6.1 Стратегии обнаружения проблем DAM

В этом разделе мы обсудим Electric Fence, библиотеку, которая обеспечивает «ограждение» (fence) выделенных адресов памяти. Доступ к памяти за пределами этих границ обычно приводит к ошибке сегментации и дампу ядра. Мы также обсудим два инструмента GNU, mtrace() и MALLOC_CHECK_, которые добавляют перехватчики к стандартным функциям распределения libc для ведения записей о выделенной в данный момент памяти. Это позволяет libc выполнять проверку памяти, которую вы собираетесь прочитать, записать или освободить. Имейте в виду, что при использовании нескольких программных инструментов, каждый из которых использует перехваты для вызовов функций, связанных с кучей, необходима осторожность, поскольку одно средство может установить один из своих перехватчиков поверх ранее установленного перехватчика[13].

  1. На самом деле, вы можете безопасно использовать mtrace() и MALLOC_CHECK_ вместе, поскольку mtrace() старается сохранить любые существующие перехватчики, которые находит.

7.6.2 Electric Fence

Electric Fence, или EFence, — это библиотека, написанная Брюсом Перенсом в 1988 году и выпущенная под лицензией GNU GPL, когда он работал в Pixar. При связывании с вашим кодом программа немедленно выделяет ошибку и выгружает ядро[14] при возникновении любого из следующих событий:

Давайте посмотрим, как использовать Electric Fence для выявления проблем с malloc(). Рассмотрим программу outOfBound.c:

int main(void)
{
   int *a = (int *) malloc( 2*sizeof(int) );

   for (int i=0; i<=2; ++i) {
      a[i] = i;
      printf("%d\n ", a[i]);
   }

   free(a);
   return 0;
}

Листинг 7-8. outOfBound.c

Хотя программа содержит типичную ошибку malloc(), она, вероятно, скомпилируется без предупреждений. Скорее всего, он даже заработает без проблем[15]:

$ gcc -g3 -Wall -std=c99 outOfBound.c -o outOfBound_without_efence -lefence
$ ./outOfBound_without_efence
0
1
2

Мы смогли записать за пределы последнего элемента массива a[]. Сейчас все выглядит нормально, но это означает лишь то, что эта ошибка проявляется непредсказуемо и ее будет трудно обнаружить в дальнейшем.

Теперь мы свяжем outOfBound с EFence и запустим его. По умолчанию EFence перехватывает операции чтения и записи только за пределами последнего элемента динамически выделяемой области. Это означает, что outOfBound должен выдавать ошибку при попытке записи в a[2]:

$ gcc -g3 -Wall -std=c99 outOfBound.c -o outOfBound_with_efence -lefence
$ ./outOfBound_with_efence
  Electric Fence 2.1 Copyright (C) 1987-1998 Bruce Perens.
0
1
Segmentation fault (core dumped)

И действительно, EFence обнаружил операцию записи после последнего элемента массива.

Случайный доступ к памяти перед первым элементом массива (например, указание «элемента» a[-1]) встречается реже, но это, безусловно, может произойти в результате ошибочных вычислений индекса. EFence предоставляет глобальное int с именем EF_PROTECT_BELOW. Если для этой переменной установлено значение 1, EFence обнаруживает только выход за границы массива слева (underruns) и не проверяет переполнение массива справа (overruns):

extern int EF_PROTECT_BELOW;

double myFunction( void )
{
   EF_PROTECT_BELOW = 1; // Check from below

      int *a = (int *) malloc( 2*sizeof(int) );

      for (int i=-2; i<2; ++i) {
         a[i] = i;
         printf("%d\n", a[i]);
      }

   ...
}

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

Для большей точности вам следует запустить программу дважды с использованием EFence: один раз в режиме по умолчанию для проверки переполнения динамической памяти и второй раз с EF_PROTECT_BELOW, установленным в 1, для проверки выхода за пределы памяти слева[16].

Помимо EF_PROTECT_BELOW, EFence имеет несколько других глобальных целочисленных переменных, которые вы можете установить, чтобы контролировать его поведение:

EF_DISABLE_BANNER

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

EF_PROTECT_BELOW

Как уже говорилось, EFence по умолчанию проверяет наличие переполнения DAM. Установка для этой переменной значения 1 заставит EFence проверять наличие выхода за пределы памяти слева (underruns).

EF_PROTECT_FREE

По умолчанию EFence не будет проверять доступ к уже освобожденному DAM. Установка этой переменной в 1 включает защиту освобожденной памяти.

EF_FREE_WIPES

По умолчанию Efence не будет изменять значения, хранящиеся в освобожденной памяти. Установка для этой переменной ненулевого значения приводит к тому, что EFence заполняет сегменты динамически выделяемой памяти значением 0xbd перед их освобождением. Это позволяет EFence легче обнаружить неправильный доступ к освобожденной памяти.

EF_ALLOW_MALLOC_0

По умолчанию EFence перехватывает любой вызов функции malloc(), имеющий аргумент 0 (т. е. любой запрос на нулевые байты памяти). Смысл в том, что написание чего-то вроде char *p = (char *) malloc(0); вероятно, это ошибка. Однако если по какой-то причине вы действительно хотите передать ноль в функцию malloc(), то установка для этой переменной ненулевого значения приведет к тому, что EFence будет игнорировать такие вызовы.

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

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

В качестве демонстрации мы установим переменную среды EF_DISABLE_BANNER для подавления печати баннерной страницы EFence. (Как упоминалось ранее, вам не следует этого делать; делайте то, что я говорю, а не то, что я делаю!) Если вы используете Bash, выполните

$ export EF_DISABLE_BANNER=1

Пользователи оболочки C должны выполнить

% setenv EF_DISABLE_BANNER 1

Затем повторно запустите пример из листинга 7-8 и убедитесь, что баннер отключен.

Еще один трюк — установить переменные EFence из GDB во время сеанса отладки. Это работает, поскольку переменные EFence являются глобальными; однако это также означает, что программа должна выполняться, но приостановиться.

  1. Если вы запускаете программу, связанную с EFence, из GDB, а не вызываете ее из командной строки, программа выделит ошибку без дампа ядра. Это желательно, поскольку core-файлы исполняемых файлов, связанных с EFence, могут быть довольно большими, и вам все равно не понадобится core-файл, потому что вы уже будете внутри GDB и будете смотреть на файл исходного кода и номер строки, где произошла ошибка сегментации.
  2. Это не означает, что переполнение malloc() не нанесет ущерб вашему коду! Этот пример создан, чтобы показать, как вы используете EFence. В реальной программе запись за пределы массива может вызвать серьезные проблемы!
  3. Если вы хотите быть очень осторожными, прочитайте разделы «Выравнивание слов и обнаружение переполнения» (Word-Alignment and Overrun Detection) и «Инструкции по отладке вашей программы» (Instructions for Debugging Your Program) на странице руководства EFence.

7.6.3 Отладка проблем DAM с помощью инструментов библиотеки C GNU

Если вы работаете на платформе GNU, например GNU/Linux, существуют некоторые функции, специфичные для библиотеки GNU C, подобные EFence, которые вы можете использовать для обнаружения и устранения проблем с динамической памятью. Мы кратко обсудим их здесь.

7.6.3.1 Переменная среды MALLOC_CHECK_

Библиотека GNU C предоставляет переменную среды оболочки с именем MALLOC_CHECK_, которую можно использовать, как и EFence, для обнаружения нарушений доступа к DAM, но для ее использования не нужно перекомпилировать программу. Настройки и их эффекты следующие:

0

Вся проверка DAM отключена (это также происходит, если переменная не определена).

1

Диагностическое сообщение выводится на стандартный поток ошибок при обнаружении повреждения кучи.

2

Программа немедленно прерывает работу и выгружает ядро при обнаружении повреждения кучи.

3

Комбинированные эффекты 1 и 2[17].

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

$ export MALLOC_CHECK_=3

Хотя MALLOC_CHECK_ удобнее использовать, чем EFence, у него есть несколько серьезных недостатков. Во-первых, MALLOC_CHECK_ сообщает о проблеме с динамической памятью только при следующем выполнении функции, связанной с кучей (например, malloc(), realloc() или free()) после несанкционированного доступа к памяти. Это означает, что вы не только не знаете исходный файл и номер строки проблемного кода, но часто даже не знаете, какой указатель является проблемной переменной. Для иллюстрации рассмотрим этот код:

int main(void)
{
   int *p = (int *) malloc(sizeof(int));
   int *q = (int *) malloc(sizeof(int));

   for (int i=0; i<400; ++i)
      p[i] = i;

   q[0] = 0;

   free(q);
   free(p);
   return 0;
}

Листинг 7-9. malloc-check-0.c

Программа прерывается на строке 11, когда проблема действительно возникает в строке 7. Изучение основного файла может привести вас к выводу, что проблема связана с q, а не с p:

$ MALLOC_CHECK_=3 ./malloc-check-0
malloc: using debugging hooks
free(): invalid pointer 0x8049680!
Aborted (core dumped)
$ gdb malloc-check-0 core
Core was generated by `./malloc-check-0'.
Program terminated with signal 6, Aborted.
Reading symbols from /lib/libc.so.6...done.
Loaded symbols for /lib/libc.so.6
Reading symbols from /lib/ld-linux.so.2...done.
Loaded symbols for /lib/ld-linux.so.2
#0 0x40046a51 in kill () from /lib/libc.so.6
(gdb) bt
#0 0x40046a51 in kill () from /lib/libc.so.6
#1 0x40046872 in raise () from /lib/libc.so.6
#2 0x40047986 in abort () from /lib/libc.so.6
#3 0x400881d2 in _IO_file_xsputn () from /lib/libc.so.6
#4 0x40089278 in free () from /lib/libc.so.6
#5 0x080484bc in main () at malloc-check-0.c:13

Возможно, с этим недостатком можно смириться при отладке 14-строчных программ, но при работе с реальным приложением он может стать серьезной проблемой. Тем не менее, знание о том, что проблема DAM вообще существует, является полезной информацией.

Во-вторых, это означает, что если после возникновения ошибки доступа не вызывается никакая функция, связанная с кучей, MALLOC_CHECK_ вообще не сообщит об ошибке.

В-третьих, сообщения об ошибках MALLOC_CHECK_ кажутся не очень значимыми. Хотя в предыдущем листинге программы была ошибка переполнения массива, сообщение об ошибке было просто «invalid pointer» (неверный указатель). Технически верно, но бесполезно.

Наконец, MALLOC_CHECK_ отключена для программ setuid и setgid, поскольку эта комбинация функций может использоваться для эксплойта безопасности. Ее можно снова включить, создав файл /etc/suid-debug. Содержимое этого файла не имеет значения, имеет значение только его существование.

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

  1. Это недокументировано в системе авторов. Спасибо Gianluca Insolvibile за то, что прочитал исходники glibc и нашел эту опцию!
7.6.3.2 Использование функции mcheck()

Альтернативой средству MALLOC_CHECK_ для обнаружения проблем DAM является функция mcheck(). Мы нашли этот метод более удовлетворительным, чем MALLOC_CHECK_. Прототипом mcheck() является

#include <mcheck.h>
int mcheck (void (*ABORTHANDLER) (enum mcheck_status STATUS))

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

Аргумент *ABORTHANDLER — это указатель на предоставленную пользователем функцию, которая вызывается при обнаружении несогласованности в DAM. Если вы передаете NULL в mcheck(), используется обработчик по умолчанию. Как и MALLOC_CHECK_, этот обработчик по умолчанию выводит сообщение об ошибке на стандартный вывод и вызывает abort() для создания основного файла. В отличие от MALLOC_CHECK_, сообщение об ошибке полезно. Например, выход за конец динамически выделенного сегмента в следующем примере:

int main(void)
{
   mcheck(NULL);
   int *p = (int *) malloc(sizeof(int));
   p[1] = 0;
   free(p);
   return 0;
}

Листинг 7-10. mcheckTest.c

выдает сообщение об ошибке, показанное здесь:

$ gcc -g3 -Wall -std=c99 mcheckTest.c -o mcheckTest -lmcheck
$ ./mcheckTest
memory clobbered past end of allocated block
Aborted (core dumped)

Другие типы проблем имеют аналогичные описательные сообщения об ошибках.

7.6.3.3 Использование mtrace() для обнаружения утечек памяти и двойного освобождения

Функция mtrace() является частью библиотеки GNU C и используется для обнаружения утечек памяти и двойного освобождения в программах на C и C++. Ее использование включает в себя пять шагов:

  1. Установить для переменной среды MALLOC_TRACE допустимое имя файла. Это имя файла, в который mtrace() помещает свои сообщения. Если для этой переменной не установлено допустимое имя файла или для файла не установлены разрешения на запись, mtrace() ничего не сделает.

  2. Включить заголовочный файл mcheck.h.

  3. Вызовать mtrace() в верхней части вашей программы. Его прототипом является

    #include <mcheck.h>
    void mtrace(void);
    
  4. Запустить программу. Если будут обнаружены какие-либо проблемы, они будут задокументированы в нечитаемой для человека форме в файле, на который указывает MALLOC_TRACE. Кроме того, по соображениям безопасности mtrace() ничего не делает с setuid или setgid исполняемых файлов.

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

Обратите внимание, что существует также вызов muntrace(), который используется для остановки трассировки памяти, но информационная страница glibc рекомендует не использовать его. Библиотека C, которая также может использовать DAM для вашей программы, уведомляется о завершении вашей программы только после возврата из функции main() или выполнения вызова функции exit(). Память, которую библиотека C использует для вашей программы, не освобождается, пока это не произойдет. Вызов muntrace() до освобождения этой памяти может привести к ложным срабатываниям.

Давайте рассмотрим простой пример. Вот код, иллюстрирующий обе проблемы, которые обнаруживает mtrace(). В следующем коде мы никогда не освобождаем память, выделенную в строке 6 и на которую указывает p, а в строке 10 мы вызываем функцию free() для указателя q, даже если он не указывает на динамически выделенную память.

int main(void)
{
   int *p, *q;

   mtrace();
   p = (int *) malloc(sizeof(int));
   printf("p points to %p\n", p);
   printf("q points to %p\n", q);

   free(q);
   return 0;
}

Листинг 7-11. mtrace1.c

Компилируем эту программу и запускаем ее, предварительно установив переменную MALLOC_TRACE.

$ gcc -g3 -Wall -Wextra -std=c99 -o mtrace1 mtrace1.c
$ MALLOC_TRACE="./mtrace.log" ./mtrace1
p points to 0x8049a58
q points to 0x804968c

Если посмотреть содержимое mtrace.log, то это вообще не имеет смысла. Однако запуск Perl-скрипта mtrace() дает понятный результат:

$ cat mtrace.log
= Start
@ ./mtrace1:(mtrace+0x120)[0x80484d4] + 0x8049a58 0x4
@ ./mtrace1:(mtrace+0x157)[0x804850b] - 0x804968c
p@satan$ mtrace mtrace.log
- 0x0804968c Free 3 was never alloc'd 0x804850b

Memory not freed:
-----------------
   Address     Size     Caller
0x08049a58      0x4  at 0x80484d4

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

- 0x0804968c Free 3 was never alloc'd
/home/p/codeTests/mtrace1.c:15

Memory not freed:
-----------------
   Address     Size     Caller
0x08049a58      0x4  at /home/p/codeTests/mtrace1.c:11

Вот это мы хотели увидеть!

Подобно утилитам MALLOC_CHECK_ и mcheck(), mtrace() не предотвратит сбой вашей программы. Он просто проверяет наличие проблем. Если ваша программа выйдет из строя, некоторые выходные данные mtrace() могут быть потеряны или искажены, что может привести к появлению загадочных отчетов об ошибках. Лучший способ справиться с этим — обнаружить и обработать ошибки сегментации, чтобы дать mtrace() возможность корректно завершить работу. Следующий пример иллюстрирует, как это сделать.

void sigsegv_handler(int signum);

int main(void)
{
   int *p;

   signal(SIGSEGV, sigsegv_handler);
   mtrace();
   p = (int *) malloc(sizeof(int));

   raise(SIGSEGV);
   return 0;
}

void sigsegv_handler(int signum)
{
   printf("Caught sigsegv: signal %d. Shutting down gracefully.\n", signum);
   muntrace();
   abort();
}

Листинг 7-12. mtrace2.c

Глава 8
Использование GDB/DDD/Eclipse для других языков

GDB и DDD широко известны как отладчики для программ C/C++, но их можно использовать и для разработки на других языках. Eclipse изначально был разработан для разработки на Java, но у него есть плагины для многих других языков. В этой главе показано, как использовать эту многоязычную возможность.

GDB/DDD/Eclipse не обязательно являются «лучшими» отладчиками для какого-либо конкретного языка. Для конкретных языков доступно большое количество отличных инструментов отладки. Однако мы хотим сказать, что было бы неплохо иметь возможность использовать один и тот же интерфейс отладки независимо от того, на каком языке вы пишете, будь то C, C++, Java, Python, Perl или другие языки/отладчики, с которыми можно использовать эти инструменты. DDD был «портирован» на все из них.

Например, рассмотрим Python. Интерпретатор Python включает собственный простой текстовый отладчик. Опять же, существует ряд отличных отладчиков графического пользовательского интерфейса и IDE, специфичных для Python, но другой вариант — использовать DDD в качестве интерфейса для встроенного отладчика Python. Это позволяет вам добиться удобства графического пользовательского интерфейса, при этом используя интерфейс, знакомый вам по программированию на C/C++ (DDD).

Как достигается многоязычность этих инструментов?

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

8.1 Java

В качестве примера давайте рассмотрим прикладную программу, которая манипулирует связным списком. Здесь объекты класса Node представляют узлы в связанном списке чисел, которые сохраняются в порядке возрастания значения ключа. Сам список является объектом класса LinkedList. Тестовая программа TestLL.java считывает числа из командной строки, создает связанный список, состоящий из этих чисел в отсортированном порядке, а затем распечатывает отсортированный список. Вот исходные файлы:

// usage: [java] TestLL list_of_test_integers

// simple example program; reads integers from the command line,
// storing them in a linear linked list, maintaining ascending order,
// and then prints out the final list to the screen

public class TestLL
{
   public static void main(String[] Args) {
      int NumElements = Args.length;
      LinkedList LL = new LinkedList();
      for (int I = 1; I <= NumElements; I++) {
         int Num;
         // do C's "atoi()", using parseInt()
         Num = Integer.parseInt(Args[I-1]);
         Node NN = new Node(Num);
         LL.Insert(NN);
      }
      System.out.println("final sorted list:");
      LL.PrintList();
   }
}

ТестLL.java

// LinkedList.java, implementing an ordered linked list of integers

public class LinkedList
{
   public static Node Head = null;

   public LinkedList() {
      Head = null;
   }

   // inserts a node N into this list
   public void Insert(Node N) {
      if (Head == null) {
         Head = N;
         return;
      }
      if (N.Value < Head.Value) {
         N.Next = Head;
         Head = N;
         return;
      }
      else if (Head.Next == null) {
         Head.Next = N;
         return;
      }
      for (Node D = Head; D.Next != null; D = D.Next) {
         if (N.Value < D.Next.Value) {
            N.Next = D.Next;
            D.Next = N;
            return;
         }
      }
   }

   public static void PrintList() {
      if (Head == null) return;
      for (Node D = Head; D != null; D = D.Next)
         System.out.println(D.Value);
   }
}

LinkedList.java

// Node.java, class for a node in an ordered linked list of integers

public class Node
{
   int Value;
   Node Next; // "pointer" to next item in list

   // constructor
   public Node(int V) {
      Value = V;
      Next = null;
   }
}

Node.java

В коде есть ошибка. Давайте попробуем найти его.

8.1.1 Прямое использование GDB для отладки Java

Java обычно рассматривается как интерпретируемый язык, но с помощью компилятора GNU GCJ вы можете скомпилировать исходный код Java в собственный машинный код. Это позволяет вашим Java-приложениям работать намного быстрее, а также означает, что вы можете использовать GDB для отладки. (Убедитесь, что у вас установлена GDB версии 5.1 или более поздней.) GDB, напрямую или через DDD, более мощный, чем JDB, отладчик, входящий в состав Java Development Kit. Например, JDB не позволяет вам устанавливать условные точки останова, что, как вы видели, является основным методом отладки GDB. Таким образом, вы не только выиграете от необходимости изучать на один отладчик меньше, но и получите лучшую функциональность.

Сначала скомпилируйте приложение в собственный машинный код:

$ gcj -c -g Node.java
$ gcj -c -g LinkedList.java
$ gcj -g --main=TestLL TestLL.java Node.o LinkedList.o -o TestLL

Эти строки аналогичны обычным командам GCC, за исключением опции -main=TestLL, которая указывает класс, функция которого main() должна быть точкой входа для выполнения программы. (Мы скомпилировали два исходных файла по одному. Мы обнаружили, что это необходимо для того, чтобы GDB правильно отслеживал исходные файлы.) Запуск программы на тестовом вводе дает следующее:

$ TestLL 8 5 12
final sorted list:
5
8

Каким-то образом пропал вход 12. Давайте посмотрим, как использовать GDB для поиска ошибки. Запустите GDB как обычно и сначала сообщите ему, чтобы он не останавливался и не выводил объявления на экран, когда сигналы Unix генерируются операциями сборки мусора Java. Такие действия доставляют неудобства и могут помешать вам использовать GDB в один шаг.

(gdb) handle SIGPWR nostop noprint
Signal        Stop      Print   Pass to program Description
SIGPWR        No        No      Yes             Power fail/restart
(gdb) handle SIGXCPU nostop noprint
Signal        Stop      Print   Pass to program Description
SIGXCPU       No        No      Yes             CPU time limit exceeded

Теперь, поскольку первой очевидной жертвой ошибки стало число 12 во входных данных, давайте установим точку останова в начале метода Insert(), при условии, что значение ключа узла будет равно 12:

(gdb) b LinkedList.java:13 if N.Value == 12
Breakpoint 1 at 0x8048bb4: file LinkedList.java, line 13.

В качестве альтернативы вы можете попробовать команду

(gdb) b LinkedList.java:Insert if N.Value == 12

Однако, хотя в дальнейшем это сработает, на данный момент класс LinkedList еще не загружен.

Теперь запустите программу в GDB:

(gdb) r 8 5 12
Starting program: /debug/TestLL 8 5 12
[Thread debugging using libthread_db enabled]
[New Thread -1208596160 (LWP 12846)]
[New Thread -1210696800 (LWP 12876)]
[Switching to Thread -1208596160 (LWP 12846)]

Breakpoint 1, LinkedList.Insert(Node) (this=@47da8, N=@11a610) at LinkedList.java:13
13            if (Head == null) {
Current language: auto; currently java

Вспоминая принцип подтверждения, давайте подтвердим, что значение, которое будет вставлено, равно 12:

(gdb) p N.Value
$1 = 12

Теперь давайте пройдемся по коду:

(gdb) n
17            if (N.Value < Head.Value) {
(gdb) n
22            else if (Head.Next == null) {
(gdb) n
26            for (Node D = Head; D.Next != null; D = D.Next) {
(gdb) n
27               if (N.Value < D.Next.Value) {
(gdb) p D.Next.Value
$2 = 8
(gdb) n
26            for (Node D = Head; D.Next != null; D = D.Next) {
(gdb) n
12         public void Insert(Node N) {
(gdb) n
33         }
(gdb) n
TestLL.main(java.lang.String[]) (Args=@ab480) at TestLL.java:12
12            for (int I = 1; I <= NumElements; I++) {

Хм, это не хорошо. Мы прошли через всю функцию insert(), не вставив 12.

Присмотревшись немного внимательнее, вы увидите, что в цикле, начинающемся со строки 26 файла LinkedList.java, мы сравнили вставляемое значение, 12, с двумя значениями, которые сейчас находятся в списке, 5 и 8, и обнаружили, что в в обоих случаях новое значение было больше. Собственно, в этом и заключается ошибка. Мы не рассматривали случай, когда вставляемое значение больше, чем все значения, уже имеющиеся в списке. Нам нужно добавить код после строки 31, чтобы справиться с этой ситуацией:

else if (D.Next.Next == null)
   D.Next.Next = N;
   return;
}

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

8.1.2 Использование DDD с GDB для отладки Java

Эти шаги будут намного проще и приятнее, если вы используете DDD в качестве интерфейса к GDB (опять же при условии, что исходный код скомпилирован с использованием GCJ). Запустите DDD как обычно:

$ ddd TestLL

(Игнорируйте сообщение об ошибке временного файла.)

Исходный код не появляется сразу в окне исходного текста DDD, поэтому вы можете начать с использования консоли DDD:

(gdb) handle SIGPWR nostop noprint
(gdb) handle SIGXCPU nostop noprint
(gdb) b LinkedList.java:13
Breakpoint 1 at 0x8048b80: file LinkedList.java, line 13.
(gdb) cond 1 N.Value == 12
(gdb) r 8 5 12

В этот момент появляется исходный код, и вы находитесь в точке останова. Затем вы можете продолжить операции DDD как обычно.

8.1.3 Использование DDD в качестве графического интерфейса для JDB

DDD можно использовать непосредственно с отладчиком JDB из пакета Java Development Kit. Команда

$ ddd -jdb TestLL.java

запустит DDD, который затем вызовет JDB.

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

8.1.4 Отладка Java в Eclipse

Если вы изначально загрузили и установили версию Eclipse, предназначенную для разработки на C/C++, вам понадобится подключаемый модуль JDT (Java Development Tools).

Основные операции аналогичны описанным ранее для C/C++, но обратите внимание на следующее:

На рисунке 8-1 показана типичная сцена отладки Java в Eclipse. Обратите внимание, что, как и в C/C++, в представлении Variables мы отображаем значения узла N, указывая треугольник вниз рядом с N.

Рисунок 8-1. Отладка Java в Eclipse

8.2 Perl

Мы будем использовать следующий пример textcount.pl, который вычисляет статистику по текстовым файлам:

#! /usr/bin/perl

# reads the text file given on the command line, and counts words,
# lines, and paragraphs

open(INFILE,@ARGV[0]);

$line_count = 0;
$word_count = 0;
$par_count = 0;

$now_in_par = 0; # not inside a paragraph right now

while ($line = <INFILE>) {
   $line_count++;
   if ($line ne "\n") {
      if ($now_in_par == 0) {
         $par_count++;
         $now_in_par = 1;
      }
      @words_on_this_line = split(" ",$line);
      $word_count += scalar(@words_on_this_line);
   }
   else {
      $now_in_par = 0;
   }
}

print "$word_count $line_count $par_count\n";

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

In this chapter we set out some basic principles of debugging, both general and also with regard to the GDB and DDD debuggers. At least one of our ``rules'' will be formal in nature, The Fundamental Principle of Debugging.


Beginners should of course read this chapter carefully, since the material here will be used throughout the remainder of the book.

Professionals may be tempted to skip the chapter. We suggest, though, that they at least skim through it. Many professionals will find at least some new material, and in any case it is important that all readers begin with a common background.

Вверху есть одна пустая строка, две после Debugging и одна перед Professionals. Результат выполнения нашего кода Perl в этом файле должен быть таким, как показано ниже:

$ perl textcount.pl test.txt
102 14 3

Теперь предположим, что мы забыли условие else:

else {
   $now_in_par = 0;
}
\end{Code}

The output would then be

\begin{Code}
$ perl textcount.pl test.txt
102 14 1

Количество слов и строк правильное, но количество абзацев неверное.

8.2.1 Отладка Perl через DDD

Perl имеет собственный встроенный отладчик, который вызывается с помощью опции -d в командной строке:

$ perl -d myprog.pl

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

$ddd textcount.pl

DDD автоматически замечает, что это сценарий Perl, вызывает отладчик Perl и устанавливает зеленую стрелку «вы здесь» в первой исполняемой строке кода.

Теперь мы указываем аргумент командной строки test.txt, щелкнув Program | Run и заполните раздел Run with Arguments всплывающего окна, как показано на рисунке 8-2. (Вы можете получить сообщение в консоли DDD вроде «... do not know how to create a new TTY ...». Просто проигнорируйте его.) Альтернативно, мы могли бы установить аргумент «вручную», просто набрав команду отладчика Perl.

@ARGV[0] = "test.txt"

в консоли DDD.

Рисунок 8-2. Установка аргументов командной строки

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

$words_on_this_line[0] eq "Debugging."

станет истинным. Давайте поместим точку останова в начале цикла while. Мы делаем это точно так же, как и для программ C/C++: щелкаем строку правой кнопкой мыши и выбираем Set Breakpoint.

Мы также должны наложить вышеуказанное условие на эту точку останова. Снова нажмите Source | Breakpoints, убедившись, что данная точка останова выделена во всплывающем окне Breakpoints and Watchpoints, а затем щелкаем значок Props. Затем заполняем нужное условие. См. Рисунок 8-3.

Рисунок 8-3. Установка условия для точки останова

Затем мы выбираем Program | Run. (Мы не выбираем Run Again, так как это, по-видимому, вводит пользователя во внутренние компоненты Perl.) Мы перемещаем указатель мыши на экземпляр переменной $words_on_this_line, и появляется обычное желтое окно DDD, отображающее значение этой переменной. Таким образом, мы подтверждаем, что условие точки останова выполняется, как и должно быть. См. Рисунок 8-4.

Рисунок 8-4. Прибытие в точку останова

Нажав несколько раз Next, чтобы пропустить пустые строки в текстовом файле, вы заметите, что мы также пропускаем строку

$par_count++;

которая должна была увеличить количество абзацев. Возвращаясь назад, мы видим, что это было вызвано тем, что переменная $now_in_par равна 0, и, развивая это наблюдение, мы вскоре понимаем, как исправить ошибку.

Точку останова также можно отключить, включить или удалить в DDD, выбрав Source | Breakpoints, выделив точку останова и щелкнув нужный вариант.

Если вы измените исходный файл, вы должны уведомить DDD о необходимости обновления, выбрав File | Restart.

8.2.2 Отладка Perl в Eclipse

Для разработки кода Perl в Eclipse вам понадобится пакет PadWalker Perl, который можно загрузить с CPAN, и плагин EPIC Eclipse для Perl.

Опять же, основные операции такие же, как мы описали ранее для C/C++, но обратите внимание на следующее:

На рисунке 8-5 показан типичный экран отладки Perl. Обратите внимание, что мы добавили ключевое слово my к глобальным переменным.

Рисунок 8-5. Экран отладки Perl

8.3 Python

Давайте возьмем в качестве примера tf.py, который подсчитывает слова, строки и абзацы в текстовом файле, как и в нашем примере Perl выше.

class textfile:
   ntfiles = 0 # count of number of textfile objects
   def __init__(self,fname):
      textfile.ntfiles += 1
      self.name = fname # name
      self.fh = open(fname)
      self.nlines = 0 # number of lines
      self.nwords = 0 # number of words
      self.npars = 0 # number of words
      self.lines = self.fh.readlines()
      self.wordlineparcount()
   def wordlineparcount(self):
      "finds the number of lines and words in the file"
      self.nlines = len(self.lines)
      inparagraph = 0
      for l in self.lines:
         w = l.split()
         self.nwords += len(w)
         if l == '\n':
            if inparagraph:
               inparagraph = 0
         elif not inparagraph:
            self.npars += 1
            inparagraph = 1

   def grep(self,target):
      "prints out all lines in the file containing target"
      for l in self.lines:
         if l.find(target) >= 0:
            print l
         print i

def main():
   t = textfile('test.txt')
   print t.nwords, t.nlines, t.npars

if __name__ == '__main__':
   main()

Обязательно включите в исходный код две строки, показанные в конце программы:

if __name__ == '__main__':
   main()

Примечание

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

8.3.1 Отладка Python в DDD

Базовым отладчиком Python является PDB (pdb.py), текстовый инструмент. Его полезность значительно расширяется за счет использования DDD в качестве графического интерфейса.

Однако перед тем, как начать, нужно позаботиться еще кое о чем. Чтобы обеспечить DDD правильный доступ к PDB, Ричард Вольф написал PYDB (pydb.py), слегка модифицированную версию PDB. Затем вы запустили бы DDD с параметром -pydb. Но Python развивался, и первоначальная PYDB перестала работать корректно.

Хорошее решение разработал Рокки Бернштейн. На момент написания этой статьи летом 2007 года его модифицированная (и значительно расширенная) PYDB должна была быть включена в следующую версию DDD. Альтернативно вы можете использовать патч, любезно предоставленный Ричардом Вольфом. Вам понадобятся следующие файлы:

Поместите файлы в какой-нибудь каталог, скажем, /usr/bin, дайте им разрешение на выполнение и создайте файл с именем pydb, состоящий из одной строки.

/usr/bin/pydb.py

Убедитесь, что вы также предоставили разрешение на выполнение файла pydb. Как только это будет сделано, вы сможете использовать DDD для программ Python. Затем запустите DDD:

$ ddd --pydb

Примечание

Если появится всплывающее окно с сообщением «PYDB couldn’t start», возможно, у вас проблемы с путем — например, у вас может быть другой файл с именем pydb. Просто убедитесь, что файл для DDD будет первым в вашем пути поиска.

Затем добавьте исходный файл tf.py, выбрав File | Open Source и дважды щелкнуть имя файла или набрав

file tf.py

в консоли. Приглашение (Pdb) может изначально не отображаться, но все равно введите команду.

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

w = l.split()

Чтобы запустить программу, щелкните Program | Run, задайте все аргументы командной строки во всплывающем окне Run, а затем нажмите Run. После нажатия кнопки Run вам нужно будет дважды нажать Continue. Это связано с работой базового отладчика PDB/PYDB. (Если вы забудете это сделать, PDB/PYDB напомнит вам об этом в консоли DDD.)

Если вы вносите изменения в исходный код, введите команду file в консоли или выберите Source | Reload Source. Ваши точки останова из последнего запуска будут сохранены.

8.3.2 Отладка Python в Eclipse

Опять же, процедуры для Python аналогичны процедурам для других языков. Это, конечно, иллюстрирует ценность наличия одного инструмента для использования на нескольких языках: как только вы научитесь использовать инструмент на одном языке, его будет довольно легко использовать на другом. Основные моменты, специфичные для Python, которые следует учитывать:

Одним из основных преимуществ Eclipse перед DDD является то, что PDB, базовый механизм отладки, используемый DDD, не работает с многопоточными программами, тогда как Eclipse работает.

8.4 Отладка SWIG-кода

SWIG (Simplified Wrapper and Interface Generator) — популярный инструмент с открытым исходным кодом для взаимодействия Java, Perl, Python и ряда других интерпретируемых языков с C/C++. Он включен в большинство дистрибутивов Linux, а также его можно загрузить из Интернета. Он позволяет вам писать большую часть кода приложения на интерпретируемом языке и включать определенные разделы, написанные вами на C/C++, например, для повышения производительности.

Возникает вопрос, как запустить GDB/DDD с таким кодом. Здесь мы представим небольшой пример с использованием Python и C. Код C будет управлять очередью «первым пришел — первым ушел» (first in, first out) (FIFO):

// fifo.c, SWIG example; manages a FIFO queue of characters

char *fifo; // the queue

int nfifo = 0, // current length of the queue
   maxfifo; // max length of the queue

int fifoinit(int spfifo) // allocate space for a max of spfifo elements
{  fifo = malloc(spfifo);
   if (fifo == 0) return 0; // failure
   else {
      maxfifo = spfifo;
      return 1; // success
   }
}

int fifoput(char c) // append c to queue
{  if (nfifo < maxfifo) {
      fifo[nfifo] = c;
      return 1; // success
   }
   else return 0; // failure
}

char fifoget() // delete head of queue and return
{  char c;
   if (nfifo > 0) {
      c = fifo[0];
      memmove(fifo,fifo+1,--nfifo);
      return c;
   }
   else return 0; // assume no null characters ever in queue
}

Помимо файла .c, SWIG также требует файл интерфейса, в данном случае fifo.i. Он состоит из глобальных символов, перечисленных один раз в стиле SWIG и один раз в стиле C:

%module fifo

%{extern char *fifo;
extern int nfifo,
           maxfifo;
extern int fifoinit(int);
extern int fifoput(char);
extern char fifoget(); %}

extern char *fifo;
extern int nfifo,
           maxfifo;
extern int fifoinit(int);
extern int fifoput(char);
extern char fifoget();

Чтобы скомпилировать код, сначала запустите swig, который сгенерирует дополнительный файл .c и файл Python. Затем используйте GCC и ld для создания динамической библиотеки общих объектов .so. Вот Makefile:

_fifo.so: fifo.o fifo_wrap.o
        gcc -shared fifo.o fifo_wrap.o -o _fifo.so

fifo.o fifo_wrap.o: fifo.c fifo_wrap.c
        gcc -fPIC -g -c fifo.c fifo_wrap.c -I/usr/include/python2.4

fifo.py fifo_wrap.c: fifo.i
        swig -python fifo.i

Библиотека импортируется в Python как модуль, как показано в тестовой программе:

# testfifo.py

import fifo

def main():
   fifo.fifoinit(100)
   fifo.fifoput('x')
   fifo.fifoput('c')
   c = fifo.fifoget()
   print c
   c = fifo.fifoget()
   print c

if __name__ == '__main__': main()

Результатом этой программы должны быть «x» и «c», но мы получаем пустой результат:

$ python testfifo.py




$

Чтобы использовать GDB, помните, что фактическая программа, которую вы запускаете, — это интерпретатор Python, python. Итак, запустите GDB в интерпретаторе:

$ gdb python

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

import fifo

выполняется интерпретатором Python. К счастью, мы все равно можем попросить GDB остановиться на функции в библиотеке:

(gdb) b fifoput
Function "fifoput" not defined.
Make breakpoint pending on future shared library load? (y or [n]) y

Breakpoint 1 (fifoput) pending.

Теперь запустите интерпретатор, аргументом которого является тестовая программа testfifo.py:

(gdb) r testfifo.py
Starting program: /usr/bin/python testfifo.py
Reading symbols from shared object read from target memory...(no debugging
symbols found)...done.
Loaded system supplied DSO at 0x164000
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
[Thread debugging using libthread_db enabled]
[New Thread -1208383808 (LWP 15912)]
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
Breakpoint 2 at 0x3b25f8: file fifo.c, line 18.
Pending breakpoint "fifoput" resolved
[Switching to Thread -1208383808 (LWP 15912)]

Breakpoint 2, fifoput (c=120 'x') at fifo.c:18
18      { if (nfifo < maxfifo) {

Теперь вы можете делать то, что вы уже хорошо знаете:

(gdb) n
19            fifo[nfifo] = c;
(gdb) p nfifo
$1 = 0
(gdb) c
Continuing.

Breakpoint 2, fifoput (c=99 'c') at fifo.c:18
18      { if (nfifo < maxfifo) {
(gdb) n
19           fifo[nfifo] = c;
(gdb) p nfifo
$2 = 0

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

fifo[nfifo++] = c;

Как только вы внесете это изменение, код будет работать нормально.

8.5 Язык ассемблера

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

Возьмем в качестве примера код в файле testff.s:

# the subroutine findfirst(v,w,b) finds the first instance of a value v
# in a block of w consecutive words of memory beginning at b, returning
# either the index of the word where v was found (0, 1, 2, ...) or -1 if
# v was not found; beginning with _start, we have a short test of the
# subroutine

.data  # data segment
x:
      .long   1
      .long   5
      .long   3
      .long   168
      .long   8888
.text  # code segment
.globl _start  # required
_start:  # required to use this label unless special action taken
      # push the arguments on the stack, then make the call
      push $x+4  # start search at the 5
      push $168  # search for 168 (deliberately out of order)
      push $4  # search 4 words
      call findfirst
done:
      movl %edi, %edi  # dummy instruction for breakpoint
findfirst:
      # finds first instance of a specified value in a block of words
      # EBX will contain the value to be searched for
      # ECX will contain the number of words to be searched
      # EAX will point to the current word to search
      # return value (EAX) will be index of the word found (-1 if not found)
      # fetch the arguments from the stack
      movl 4(%esp), %ebx
      movl 8(%esp), %ecx
      movl 12(%esp), %eax
      movl %eax, %edx # save block start location
      # top of loop; compare the current word to the search value
top: cmpl (%eax), %ebx
      jz found
      decl %ecx  # decrement counter of number of words left to search
      jz notthere  # if counter has reached 0, the search value isn't there
      addl $4, %eax  # otherwise, go on to the next word
      jmp top
found:
      subl %edx, %eax  # get offset from start of block
      shrl $2, %eax  # divide by 4, to convert from byte offset to index
      ret
notthere:
      movl $-1, %eax
      ret

Это язык ассемблера Intel для Linux, использующий синтаксис AT&T, но пользователи, знакомые с синтаксисом Intel, должны найти простой для понимания код. (Команда GDB set disassembly-flavor intel заставит GDB отображать весь вывод своей команды disassemble в синтаксисе Intel, который, например, похож на синтаксис, используемый компилятором NASM. Кстати, поскольку это платформа Linux, программа работает в 32-битном режиме процессора Intel.)

Как указано в комментариях, подпрограмма findfirst находит первое вхождение указанного значения в указанном блоке последовательных слов памяти. Возвращаемым значением подпрограммы является индекс (0, 1, 2, ...) слова, в котором значение было найдено, или -1, если оно не было найдено.

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

address of the start of the block to be searched
number of words in the block
value to be searched
return address

Примечание

Стеки Intel растут вниз, то есть к адресу 0 в памяти. Слова с меньшими адресами отображаются на рисунке ниже.

Чтобы внести ошибку, которую мы можем найти с помощью GDB, мы намеренно поменяли местами элементы в последовательности вызова в «основной» программе:

push $x+4  # start search at the 5
push $168  # search for 168 (deliberately out of order)
push $4  # search 4 words

Вместо этого инструкции, предшествующие вызову, должны быть

push $x+4  # start search at the 5
push $4  # search 4 words
push $168  # search for 168

Точно так же, как вы используете опцию -g при компиляции кода C/C++ для использования с GDB/DDD, здесь, на уровне сборки, вы используете -gstabs:

$ as -a --gstabs -o testff.o testff.s

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

Затем линкуем:

$ ld testff.o

В результате создается исполняемый файл с именем по умолчанию a.out.

Давайте запустим этот код под GDB:

(gdb) b done
Breakpoint 1 at 0x8048085: file testff.s, line 18.
(gdb) r
Starting program: /debug/a.out
Breakpoint 1, done () at testff.s:18
18              movl %edi, %edi # dummy for breakpoint
Current language: auto; currently asm
(gdb) p $eax
$1 = -1

Как вы можете видеть здесь, к регистрам можно обращаться через префиксы со знаком доллара, в данном случае $eax для регистра EAX. К сожалению, значение в этом регистре равно -1, что указывает на то, что желаемое значение 168 не найдено в указанном блоке.

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

(gdb) b findfirst
Breakpoint 2 at 0x8048087: file testff.s, line 25.
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /debug/a.out
Breakpoint 2, findfirst () at testff.s:25
25              movl 4(%esp), %ebx
(gdb) x/4w $esp
0xbfffd9a0:   0x08048085   0x00000004   0x000000a8   0x080490b4

Стек, конечно, является частью памяти, поэтому для его проверки вы должны использовать команду GDB x, которая проверяет память. Здесь мы попросили GDB отобразить четыре слова, начиная с места, указанного указателем стека ESP (обратите внимание, что на изображении стека выше показаны четыре слова). Команда x отобразит память в порядке возрастания адресов. Это именно то, что вам нужно, поскольку на архитектуре Intel, как и на многих других, стек растет в сторону 0.

На изображении стека, показанном выше, вы видите, что первое слово должно быть адресом возврата. Это ожидание можно проверить разными способами. Одним из подходов было бы использование команды disassemble GDB, которая перечисляет инструкции языка ассемблера (обратно преобразованные из машинного кода) и их адреса. Зайдя в подпрограмму, вы сможете проверить, соответствует ли содержимое первого слова стека адресу функции, следующей за вызовом.

Вы увидите, что это так. Однако вы обнаружите, что второе число, 4, которое должно быть значением для поиска (168), на самом деле является размером блока поиска (4). Из этой информации вы быстро поймете, что мы случайно поменяли местами две инструкции push перед вызовом.

Обновления

Посетите http://www.nostarch.com/debugging.htm для получения обновлений, исправлений и другой информации.

(Исходный код также доступен здесь: debugging.zip.)

Колофон

В The Art of Debugging используются шрифты New Baskerville, Futura, The Sans Mono Condensed и Dogma. Книга была набрана с помощью пакета LATEX 2ε nostarch Борисом Вейцманом (2008/06/06 v1.3 Typesetting books for No Starch Press).