Изучаем С используя GDB

в 8:15, , рубрики: gdb, переводы, метки: ,

Перевод статьи Аллана О’Доннелла Learning C with GDB.

На фоне таких высокоуровневых языков, как Ruby, Scheme или Haskell, изучение C может оказаться настоящим испытанием. В придачу к преодолению высокоуровневых особенностей C, таких как ручное управление памятью и указатели, вы еще должны обходиться без REPL. Как только Вы привыкнете к исследующему программированию в REPL, иметь дело с циклом написал-скомпилировал-запустил будет для Вас небольшим разочарованием.

С недавнего времени, мне пришла в голову идея, что я мог бы использовать GDB как псевдо-REPL для C. Я немного поэкспериментировал, используя GDB как инструмент для изучения языка, а не просто отладки С, и это принесло мне много веселья.

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

Введение в GDB

Начнем с сознания следующей небольшой программы на С — minimal.c:

int main()
{
    int i = 1337;
    return 0;
}

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

Скомпилируем эту программу с флагом -g для генерирования отладочной информации, с которой будет работать GDB, и подсунем ему эту самую информацию:

$ gcc -g minimal.c -o minimal
$ gdb minimal

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

(gdb) print 1 + 2
$1 = 3

Удивительно! print — это встроенная команда GDB, которая вычисляет результат С-ного выражения. Если Вы не знаете, что именно делает какая-то команда GDB, просто воспользуйтесь помощью — наберите help name-of-the-command в командной строке GDB.

Вот Вам более интересный пример:

(gbd) print (int) 2147483648
$2 = -2147483648

Я упущу разъяснение того, почему 2147483648 == -2147483648. Главная суть здесь в том, что даже арифметика может быть коварная в С, а GDB отлично понимает арифметику С.

Теперь давайте поставим точку останова в функции main и запустим программу:

(gdb) break main
(gdb) run

Программа остановилась на третьей строчке, как раз там, где инициализируется переменная i. Интересно то, что хотя переменная пока и не проинициализирована, но мы уже сейчас можем посмотреть ее значение, используя команду print:

(gdb) print i
$3 = 32767

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

Мы можем выполнить текущую строчку кода, воспользовавшись командой next:

(gdb) next
(gdb) print i
$4 = 1337

Исследуем память используя команду x

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

1. Числовой адрес первого байта в блоке.
2. Размер блока в байтах. Этот размер определяется типом переменной.

Одна из особенностей языка С в том, что у Вас есть прямой доступ к блоку памяти переменной. Оператор & дает нам адрес переменной в памяти, а sizeof вычисляет размер занимаемой переменной памяти.

Вы можете поиграть с обеими возможностями в GDB:

(gdb) print &i
$5 = (int *) 0x7fff5fbff584
(gdb) print sizeof(i)
$6 = 4

Говоря нормальным языком, это значит, что переменная i размещается по адресу 0x7fff5fbff5b4 и занимает в памяти 4 байта.

Я уже упоминал выше, что размер переменной зависит от ее типа, да и вообще говоря, оператор sizeof может оперировать и самими типами данных:

(gdb) print sizeof(int)
$7 = 4
(gdb) print sizeof(double)
$8 = 8

Это означает, что на моей машине, по меньшей мере, переменные типа int занимают четыре байта, а типа double — восемь байт.

В GDB есть мощный инструмент для исследования памяти — команда x. Эта команда проверяет память, начиная с определенного адреса. К тому же, она имеет несколько команд форматирования, которые обеспечиваю точный контроль над тем, сколько байт Вы хотите проверить, и в каком виде вывести их на экран. Если сомневаетесь, то наберите help x в командной строке GDB.

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

(gdb) x/4xb &i
0x7fff5fbff584: 0x39    0x05    0x00    0x00

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

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

Один из способов прояснить этот вопрос — это присвоить переменной i более интересное значение и опять проверить этот участок памяти:

(gdb) set var i = 0x12345678
(gdb) x/4xb &i
0x7fff5fbff584: 0x78    0x56    0x34    0x12

Исследуем память с командой ptype

Команда ptype возможно одна из моих самых любимых. Она показывает тип С-го выражения:

(gdb) ptype i
type = int
(gdb) ptype &i
type = int *
(gdb) ptype main
type = int (void)

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

Указатели и массивы

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

Итак, нам нужен код программы с массивом array.c:

int main()
{
    int a[] = {1, 2, 3};
    return 0;
}

Скомпилируйте ее с флагом -g, запустите в GDB, и с помощь next перейдите в строчку инициализации:

$ gcc -g arrays.c -o arrays
$ gdb arrays
(gdb) break main
(gdb) run
(gdb) next

В этот момент у Вас появится возможность вывести содержимое переменной и выяснить ее тип:

(gdb) print a
$1 = {1, 2, 3}
(gdb) ptype a
type = int [3]

Теперь, когда наша программа правильно настроена в GDB, первое, что стоит сделать — это использовать команду x для того, чтобы, так сказать, заглянуть “под капот”:

(gdb) x/12xb &a
0x7fff5fbff56c: 0x01  0x00  0x00  0x00  0x02  0x00  0x00  0x00
0x7fff5fbff574: 0x03  0x00  0x00  0x00

Это означает, что участок памяти для массива a начинается по адресу 0x7fff5fbff56c. Первые четыре байта содержат a[0], следующие четыре — a[1], и последние четыре хранят a[2]. Действительно, Вы можете проверить и убедится, что sizeof знает, что a занимает в памяти ровно двенадцать байт:

(gdb) print sizeof(a)
$2 = 12

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

(gdb) print a + 1
$3 = (int *) 0x7fff5fbff570

Нормальными словами, это означает, что a + 1 — это указатель на int, который содержи адрес 0x7fff5fbff570. К этому моменту Вы должны уже рефлекторно передать указатель в команду x, итак посмотрим, что же получилось:

(gdb) x/4xb a + 1
0x7fff5fbff570: 0x02  0x00  0x00  0x00

Обратите внимание, что адрес 0x7fff5fbff570 ровно на четыре единицы больше, чем 0x7fff5fbff56c, то есть адрес первого байта массива. Учитывая, что тип int занимает в памяти четыре байта, можно сделать вывод, что a + 1 указывает на a[1].

На самом деле, индексация массивов в С является синтаксическим эквивалентом арифметики указателей: a[i] эквивалентно *(a + i). Вы можете проверить это в GDB:

(gdb) print a[0]
$4 = 1
(gdb) print *(a + 0)
$5 = 1
(gdb) print a[1]
$6 = 2
(gdb) print *(a + 1)
$7 = 2
(gdb) print a[2]
$8 = 3
(gdb) print *(a + 2)
$9 = 3

Итак, мы увидели, что в некоторых ситуациях a ведет себя как массив, а в некоторых — как указатель на свой первый элемент. Что же происходит? Ответ состоит в следующем, когда имя массива используется в выражении в С, то оно “превращается” в указатель на первый элемент. Есть только два исключения из этого правила: когда имя массива передается в sizeof и когда имя массива используется с оператором взятия адреса &.

Тот факт, что имя a не превращается в указатель на первый элемент при использовании оператора &, порождает интересный вопрос: в чем же разница между тем на что указывает a и &a?

Численно они оба представляют один и тот же адрес:

(gdb) x/4xb a
0x7fff5fbff56c: 0x01  0x00  0x00  0x00
(gdb) x/4xb &a
0x7fff5fbff56c: 0x01  0x00  0x00  0x00

Тем не менее, типы их различны. Как мы уже знаем, имя массива расценивается как указатель на его первый элемент и значит имеет тип int *. Что же касается типа &a, то мы можем спросить об этом GDB:

(gdb) ptype &a
type = int (*)[3]

Говоря проще, &a — это указатель на массив из трех целых чисел. Это имеет смысл: a не преобразуется при передаче оператору & и a является массивом из трех целых чисел.

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

(gdb) print a + 1
$10 = (int *) 0x7fff5fbff570
(gdb) print &a + 1
$11 = (int (*)[3]) 0x7fff5fbff578

Обратите внимание, что добавление 1 к a увеличивает адрес на четыре единицы, в то время, как прибавление 1 к &a добавляет к адресу двенадцать.

Указатель, к которому на самом деле приводится a имеет вид &a[0]:

(gdb) print &a[0]
$11 = (int *) 0x7fff5fbff56c

Заключение

Надеюсь, я убедил Вас, что GDB — это изящная среда для изучения С. Она позволяет выводить значение выражений, побайтово исследовать память и работать с типами с помощью команды ctype.

Если Вы планируете и далее экспериментировать с изучением С с помощью GDB, то у меня есть некоторые предложения:

1. Используйте GDB для работы над The Ksplice Pointer Challenge.
2. Разберитесь, как структуры хранятся в памяти. Как они соотносятся с массивами?
3. Используйте дизассемблерные команды GDB, чтобы лучше разобраться с программированием на ассемблере. Особенно весело исследовать, как работает стек вызова функции.
4. Освойте GUI режим, который обеспечивает графический интерфейс над привычным GDB. На OS X, Вам вероятно придется собрать GDB из исходников.

От переводчика: Традиционно для указания ошибок воспользуйтесь ЛС. Буду рад конструктивной критике.

Автор: desperius

Источник

Поделиться

* - обязательные к заполнению поля