Перевод статьи Аллана О’Доннелла Learning C with GDB.
Исходя из особенностей таких высокоуровневых языков, как Ruby, Scheme или Haskell, изучение C может быть сложной задачей. В придачу к преодолению таких низкоуровневых особенностей C, как ручное управление памятью и указатели, вы еще должны обходиться без REPL. Как только Вы привыкнете к исследовательскому программированию в REPL, иметь дело с циклом написал-скомпилировал-запустил будет для Вас небольшим разочарованием.
Недавно мне пришло в голову, что я мог бы использовать GDB как псевдо-REPL для C. Я поэкспериментировал, используя GDB как инструмент для изучения языка, а не просто для отладки, и оказалось, что это очень весело.
Цель этого поста – показать Вам, что GDB является отличным инструментом для изучения С. Я познакомлю Вас с несколькими моими самыми любимыми командами из GDB, и продемонстрирую каким образом Вы можете использовать GDB, чтобы понять одну из сложных частей языка С: разницу между массивами и указателями.
Начнем с создания следующей небольшой программы на С – minimal.c:
Обратите внимание, что программа не делает абсолютно ничего, и даже не имеет ни одной команды printf. Теперь окунемся в новый мир изучения С используя GBD.
Скомпилируем эту программу с флагом -g для генерирования отладочной информации, с которой будет работать GDB, и подкинем ему эту самую информацию:
Теперь Вы должны молниеносно оказаться в командной строке GDB. Я обещал вам REPL, так получите:
Удивительно! print – это встроенная команда GDB, которая вычисляет результат С-ного выражения. Если Вы не знаете, что именно делает какая-то команда GDB, просто воспользуйтесь помощью – наберите help name-of-the-command в командной строке GDB.
Вот Вам более интересный пример:
Я упущу разъяснение того, почему 2147483648 == -2147483648. Главная суть здесь в том, что даже арифметика может быть коварная в С, а GDB отлично понимает арифметику С.
Теперь давайте поставим точку останова в функции main и запустим программу:
Программа остановилась на третьей строчке, как раз там, где инициализируется переменная i. Интересно то, что хотя переменная пока и не проинициализирована, но мы уже сейчас можем посмотреть ее значение, используя команду print:
В С значение локальной неинициализированной переменной не определено, поэтому полученный Вами результат может отличаться.
Мы можем выполнить текущую строку кода, воспользовавшись командой next:
Переменные в С – это непрерывные блоки памяти. При этом блок каждой переменной характеризуется двумя числами:
1. Числовой адрес первого байта в блоке.
2. Размер блока в байтах. Этот размер определяется типом переменной.
Одна из отличительных особенностей языка С в том, что у Вас есть прямой доступ к блоку памяти переменной. Оператор & дает нам адрес переменной в памяти, а sizeof вычисляет размер, занимаемый переменной памяти.
Вы можете поиграть с обеими возможностями в GDB:
Говоря нормальным языком, это значит, что переменная i размещается по адресу 0x7fff5fbff5b4 и занимает в памяти 4 байта.
Я уже упоминал выше, что размер переменной в памяти зависит от ее типа, да и вообще говоря, оператор sizeof может оперировать и самими типами данных:
Это означает, что по меньшей мере на моей машине, переменные типа int занимают четыре байта, а типа double – восемь байт.
В GDB есть мощный инструмент для непосредственного исследования памяти – команда x. Эта команда проверяет память, начиная с определенного адреса. Также она имеет ряд команд форматирования, которые обеспечиваю точный контроль над количеством байт, которые Вы захотите проверить, и над тем, в каком виде Вы захотите вывести их на экран. В случае каких либо трудностей, наберите help x в командной строке GDB.
Как Вы уже знаете, оператор & вычисляет адрес переменной, а это значит, что можно передать команде x значение &i и тем самым получить возможность взглянуть на отдельные байты, скрывающиеся за переменной i:
Флаги форматирования указывают на то, что я хочу получить четыре (4) значения, выведенные в шестнадцатеричном (hex) виде по одному байту (byte). Я указал проверку только четырех байт, потому что именно столько занимает в памяти переменная i. Вывод показывает побайтовое представление переменной в памяти.
Но с побайтовым выводом связана одна тонкость, которую нужно постоянно держать в голове – на машинах Intel байты хранятся в порядке “от младшего к старшему” (справа налево), в отличии от более привычной для человека записи, где младший байт должен был бы находиться в конце (слева направо).
Один из способов прояснить этот вопрос – это присвоить переменной i более интересное значение и опять проверить этот участок памяти:
Команда ptype возможно одна из моих самых любимых. Она показывает тип С-го выражения:
Типы в С могут становиться сложными, но ptype позволяет исследовать их в интерактивном режиме.
Массивы являются на удивление тонким понятием в С. Суть этого пункта в том, чтобы написать простенькую программу, а затем прогонять ее через GDB, пока массивы не обретут какой-то смысл.
Итак, нам нужен код программы с массивом array.c:
Скомпилируйте ее с флагом -g, запустите в GDB, и с помощь next перейдите в строку инициализации:
На этом этапе Вы сможете вывести содержимое переменной и выяснить ее тип:
Теперь, когда наша программа правильно настроена в GDB, первое, что стоит сделать – это использовать команду x для того, чтобы увидеть, как выглядит переменная a “под капотом”:
Это означает, что участок памяти для массива a начинается по адресу 0x7fff5fbff56c. Первые четыре байта содержат a[0], следующие четыре – a[1], и последние четыре хранят a[2]. Действительно, Вы можете проверить и убедится, что sizeof знает, что a занимает в памяти ровно двенадцать байт:
До этого момента массивы выглядят такими, какими и должны быть. У них есть соответствующий массивам типы и они хранят все значения в смежных участках памяти. Однако, в определенных ситуациях, массивы ведут себя очень схоже с указателями! К примеру, мы можем применять арифметические операции к a:
Нормальными словами, это означает, что a + 1 – это указатель на int, который имеет адрес 0x7fff5fbff570. К этому моменту Вы должны уже рефлекторно передавать указатели в команду x, итак посмотрим, что же получилось:
Обратите внимание, что адрес 0x7fff5fbff570 ровно на четыре единицы больше, чем 0x7fff5fbff56c, то есть адрес первого байта массива a. Учитывая, что тип int занимает в памяти четыре байта, можно сделать вывод, что a + 1 указывает на a[1].
На самом деле, индексация массивов в С является синтаксическим сахаром для арифметики указателей: a[i] эквивалентно *(a + i). Вы можете проверить это в GDB:
Итак, мы увидели, что в некоторых ситуациях a ведет себя как массив, а в некоторых – как указатель на свой первый элемент. Что же происходит?
Ответ состоит в следующем, когда имя массива используется в выражении в С, то оно “распадается (decay)” на указатель на первый элемент. Есть только два исключения из этого правила: когда имя массива передается в sizeof и когда имя массива используется с оператором взятия адреса &.
Тот факт, что имя a не распадается на указатель на первый элемент при использовании оператора &, порождает интересный вопрос: в чем же разница между указателем, на который распадается a и &a?
Численно они оба представляют один и тот же адрес:
Тем не менее, типы их различны. Как мы уже видели, имя массива распадается на указатель на его первый элемент и значит должно иметь тип int *. Что же касается типа &a, то мы можем спросить об этом GDB:
Говоря проще, &a – это указатель на массив из трех целых чисел. Это имеет смысл: a не распадается при передаче оператору & и a имеет тип int [3].
Вы можете проследить различие между указателем, на который распадается a и операцией &a на примере того, как они ведут себя по отношению к арифметике указателей:
Обратите внимание, что добавление 1 к a увеличивает адрес на четыре единицы, в то время, как прибавление 1 к &a добавляет к адресу двенадцать.
Указатель, на который на самом деле распадается a имеет вид &a[0]:
Надеюсь, я убедил Вас, что GDB – это изящная исследовательская среда для изучения С. Она позволяет выводить значение выражений с помощью команды print, побайтово исследовать память командой x и работать с типами с помощью команды ptype.
Если Вы планируете и далее экспериментировать с изучением С с помощью GDB, то у меня есть некоторые предложения:
1. Используйте GDB для работы над The Ksplice Pointer Challenge.
2. Разберитесь, как структуры хранятся в памяти. Как они соотносятся с массивами?
3. Используйте дизассемблерные команды GDB, чтобы лучше разобраться с программированием на ассемблере. Особенно весело исследовать, как работает стек вызова функции.
4. Зацените “TUI” режим GDB, который обеспечивает графическую ncurses надстройку над привычным GDB. На OS X, Вам вероятно придется собрать 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
Флаги форматирования указывают на то, что я хочу получить четыре (4) значения, выведенные в шестнадцатеричном (hex) виде по одному байту (byte). Я указал проверку только четырех байт, потому что именно столько занимает в памяти переменная 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 для того, чтобы увидеть, как выглядит переменная a “под капотом”:
(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, то есть адрес первого байта массива a. Учитывая, что тип 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 ведет себя как массив, а в некоторых – как указатель на свой первый элемент. Что же происходит?
Ответ состоит в следующем, когда имя массива используется в выражении в С, то оно “распадается (decay)” на указатель на первый элемент. Есть только два исключения из этого правила: когда имя массива передается в 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 имеет тип int [3].
Вы можете проследить различие между указателем, на который распадается 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 – это изящная исследовательская среда для изучения С. Она позволяет выводить значение выражений с помощью команды print, побайтово исследовать память командой x и работать с типами с помощью команды ptype.
Если Вы планируете и далее экспериментировать с изучением С с помощью GDB, то у меня есть некоторые предложения:
1. Используйте GDB для работы над The Ksplice Pointer Challenge.
2. Разберитесь, как структуры хранятся в памяти. Как они соотносятся с массивами?
3. Используйте дизассемблерные команды GDB, чтобы лучше разобраться с программированием на ассемблере. Особенно весело исследовать, как работает стек вызова функции.
4. Зацените “TUI” режим GDB, который обеспечивает графическую ncurses надстройку над привычным GDB. На OS X, Вам вероятно придется собрать GDB из исходников.
От переводчика: Традиционно для указания ошибок воспользуйтесь ЛС. Буду рад конструктивной критике.