Разбираемся в С изучая ассемблер

от автора

Перевод статьи Девида Альберта — Understanding C by learning assembly.

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

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

Примечание автора: Весь код из этой статьи был скомпилирован на процессоре x86_64 под Mac OS X 10.8.1 с использованием Clang 4.0 с отключенной оптимизацией (-o0).

Изучаем ассемблер с помощью GDB

Давайте начнем с дизассемблирования программы с помощью GDB и научимся читать исходные данные. Наберите следующий текст программы и сохраните его в файле simple.c:

int main(void) {    	    int a = 5;    	    int b = a + 6;    	    return 0; } 

Теперь скомпилируйте его в отладочном режиме и с отключенной оптимизацией и запустите GDB.

$ CFLAGS="-g -O0" make simple cc -g -O0 simple.c -o simple $ gdb simple 

Поставьте точку останова на функции main и продолжайте выполнение до тех пор, пока не дойдете до оператора return. Можно ввести число 2 после оператора next, что будет означать, что мы хотим выполнить его дважды подряд:

(gdb) break main (gdb) run (gdb) next 2 

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

(gdb) disassemble Dump of assembler code for function main: 0x0000000100000f50 <main+0>:    push   %rbp 0x0000000100000f51 <main+1>:    mov    %rsp,%rbp 0x0000000100000f54 <main+4>:    mov    $0x0,%eax 0x0000000100000f59 <main+9>:    movl   $0x0,-0x4(%rbp) 0x0000000100000f60 <main+16>:   movl   $0x5,-0x8(%rbp) 0x0000000100000f67 <main+23>:   mov    -0x8(%rbp),%ecx 0x0000000100000f6a <main+26>:   add    $0x6,%ecx 0x0000000100000f70 <main+32>:   mov    %ecx,-0xc(%rbp) 0x0000000100000f73 <main+35>:   pop    %rbp 0x0000000100000f74 <main+36>:   retq    End of assembler dump. 

По умолчанию команда disassemble выводит инструкции в синтаксисе AT&T, который совпадает с синтаксисом, используемым ассемблером GNU. Синтаксис AT&T имеет формат: mnemonic, source, destination. Где mnemonic — это понятные человеку имена инструкций. А source и destination являются операндами, которые могут быть непосредственными значениями, регистрами, адресами памяти или метками. В свою очередь, непосредственные значения — это константы, они имеют префикс $. Например, $0x5 соответствует числу 5 в шестнадцатеричном представлении. Имена регистров записываются с префиксом %.

Регистры

На изучение регистров стоит потратить некоторое время. Регистры — это места хранения данных, которые находятся непосредственно на центральном процессоре. С некоторыми исключениями, размер или ширина регистров процессора определяет его архитектуру. То же самое касается и 32-битных и 16-битных процессоров и т. д. Скорость доступа к регистрам очень высокая и именно из-за этого в них часто хранятся операнды арифметических и логических операций.

Семейство процессоров с архитектурой x86 имеет ряд специальных регистров и регистров общего назначения. Регистры общего назначения могут быть использованы для любых операций, и данные, хранящиеся в них, не имеют особого значения для процессора. С другой стороны, процессор в своей работе опирается на специальные регистры, и данные, которые хранятся в них, имеют определенное значение в зависимости от конкретного регистра. В нашем примере %eax и %ecx — регистры общего назначения, в то время как %rbp и %rsp — специальные регистры. Регистр %rbp — это указатель на адрес базового сегмента текущего стекового кадра, а %rsp — указатель на вершину текущего стекового кадра. Регистр %rbp всегда имеет высшее значение нежели %rsp, потому что стек всегда начинается со старшего адреса памяти и растет в сторону младших адресов. Если Вы не знакомы с понятием “стек вызовов”, то можете найти хорошее объяснение на Википедии.

Особенность процессоров семейства x86 в том, что они сохраняют полную совместимость с 16-битными процессорами 8086. В процессе перехода x86 архитектуры от 16-битной к 32-битной и в конце-концов к 64-битной, регистры были расширены и получили новые имена, чтобы сохранить совместимость с кодом, который был написан для более ранних процессоров.

Возьмем регистр общего назначения AX, который имеет ширину в 16 бит. Доступ к его старшему байту осуществляется по имени AH, а к младшему — по имени AL. Когда появился 32-битный 80386, расширенный (Extended) AX или EAX стал относиться к 32-битным регистрам, в то время как AX остался 16-битным и стал младшей половиной регистра EAX. Аналогичным образом, когда появилась x86_64, то был использован префикс “R” и EAX стал младшей половиной 64-битного регистра RAX. Ниже приведена диаграмма, основанная на статье из Википедии, чтобы проиллюстрировать вышеописанные связи:

|__64__|__56__|__48__|__40__|__32__|__24__|__16__|__8___| |__________________________RAX__________________________| |xxxxxxxxxxxxxxxxxxxxxxxxxxx|____________EAX____________| |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|_____AX______| |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|__AH__|__AL__| 
Назад к коду

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

0x0000000100000f50 <main+0>:    push   %rbp 0x0000000100000f51 <main+1>:    mov    %rsp,%rbp 

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

0x0000000100000f54 <main+4>:    mov    $0x0,%eax 

Соглашение о вызовах, в архитектуре x86 гласит, что возвращаемые функцией значения хранятся в регистре %eax, поэтому вышеуказанная инструкция предписывает нам вернуть 0 в конце нашей функции.

0x0000000100000f59 <main+9>:    movl   $0x0,-0x4(%rbp) 

Здесь у нас то, с чем мы раньше не встречались: -0x4(%rbp). Круглые скобки дают нам понять, что это адрес памяти. В этом фрагменте %rbp, так называемый базовый указатель, и -0x4, являющееся смещением. Это эквивалентно записи %rbp + -0x4. Поскольку стек растет вниз, то вычитание 4 из базового стекового кадра перемещает нас к собственно текущему кадру, где хранится локальна переменная. Это значит, что эта инструкция сохраняет 0 по адресу %rbp — 4. Мне потребовалось некоторое время, чтобы выяснить, для чего служит эта строчка, и как мне кажется Clang выделяет скрытую локальную переменную для неявно возвращаемого значения из функции main.

Вы также можете заметить, что mnemonic имеет суффикс l. Это означает, что операнд будет иметь тип long (32 бита для целых чисел). Другие возможные суффиксы — byte, short, word, quad, и ten. Если Вам попадется инструкция, не имеющая суффикса, то размер такой инструкции будет взят из регистра источника или регистра назначения. Например, в предыдущей строчке %eax имеет ширину 32 бита, поэтому инструкция mov на самом деле является movl.

0x0000000100000f60 <main+16>:   movl   $0x5,-0x8(%rbp) 

Теперь мы переходим в самую сердцевину нашей тестовой программы. Приведенная строка ассемблера — это первая строка на С в функции main, и она помещает число 5 в следующий доступный слот локальных переменных (%rbp — 0x8), на 4 байта ниже от нашей предыдущей локальной переменной. Это местоположение переменной a. Мы можем использовать GDB, чтобы проверить это:

(gdb) x &a 0x7fff5fbff768: 0x00000005 (gdb) x $rbp - 8 0x7fff5fbff768: 0x00000005 

Заметьте, что адрес памяти один и тот же. Также Вы можете обратить внимание, что GDB устанавливает значения для наших переменных, и так же как и во всех переменных в GDB, перед их именем стоит префикс $, в то время как префикс % используется в ассемблере от AT&T.

0x0000000100000f67 <main+23>:   mov    -0x8(%rbp),%ecx 0x0000000100000f6a <main+26>:   add    $0x6,%ecx 0x0000000100000f70 <main+32>:   mov    %ecx,-0xc(%rbp) 

Далее мы помещаем переменную a в %ecx, один из наших регистров общего назначения, добавляем к ней число 6 и сохраняем результат в %rbp — 0xc. Это вторая строчка функции main. Вы могли уже догадаться, что адрес %rbp — 0xc соответствует переменной b, которую мы тоже можем проверить с помощью GDB:

(gdb) x &b 0x7fff5fbff764: 0x0000000b (gdb) x $rbp - 0xc 0x7fff5fbff764: 0x0000000b 

Остальное в функции main — это просто процесс уборки, который еще называют эпилогом.

0x0000000100000f73 <main+35>:   pop    %rbp 0x0000000100000f74 <main+36>:   retq 

Мы достаем старый базовый указатель и помещаем его обратно в %rbp, а затем инструкция retq перебрасывает нас к адресу возвращения, который тоже хранится в стековом кадре.

Итак, до этого момента мы использовали GDB для дизассемблирования небольшой программы на С, прошли через чтение синтаксиса ассемблера от AT&T и раскрыли тему регистров и операндов адресов памяти. Также мы использовали GDB для проверки места хранения локальных переменных по отношению к %rbp. Теперь используем приобретенный опыт для объяснения принципов работы статических локальных переменных.

Разбираемся в статических локальных переменных

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

/* static.c */ #include <stdio.h> int natural_generator() {         int a = 1;         static int b = -1;         b += 1;         return a + b; }  int main() {         printf("%d\n", natural_generator());         printf("%d\n", natural_generator());         printf("%d\n", natural_generator());          return 0; } 

Когда вы скомпилируете и запустите эту программу, то она выведет три первых натуральных числа:

$ CFLAGS="-g -O0" make static cc -g -O0    static.c   -o static $ ./static 1 2 3 

Но как это работает? Чтобы это выяснить, перейдем в GDB и посмотрим на ассемблерный код. Я удалил адресную информацию, которую GDB добавляет в дизассемблерный вывод и теперь все помещается на экране:

$ gdb static (gdb) break natural_generator (gdb) run (gdb) disassemble Dump of assembler code for function natural_generator: push   %rbp mov    %rsp,%rbp movl   $0x1,-0x4(%rbp) mov    0x177(%rip),%eax     # 0x100001018 <natural_generator.b> add    $0x1,%eax mov    %eax,0x16c(%rip)     # 0x100001018 <natural_generator.b> mov    -0x4(%rbp),%eax add    0x163(%rip),%eax     # 0x100001018 <natural_generator.b> pop    %rbp retq    End of assembler dump. 

Первое, что нам нужно сделать, это выяснить, на какой инструкции мы сейчас находимся. Сделать это мы можем путем изучения указателя инструкции или счетчика команды. Указатель инструкции — это регистр, который хранит адрес следующей инструкции. В архитектуре x86_64 этот регистр называется %rip. Мы можем получить доступ к указателю инструкции с помощью переменной $rip, или, как альтернативу, можем использовать архитектурно независимую переменную $pc:

(gdb) x/i $pc 0x100000e94 <natural_generator+4>:  movl   $0x1,-0x4(%rbp) 

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

Поскольку знать следующую инструкцию — это очень полезно, то мы заставим GDB показывать нам следующую инструкцию каждый раз, когда программа останавливается. В GDB 7.0 и более позднем, Вы можете просто выполнить команду set disassemble-next-line on, которая показывает все инструкции, исполняющиеся в следующей строчке программного кода. Но я использую Mac OS X, который поставляется с версией GDB 6.3, так что мне придется пользоваться командой display. Эта команда аналогична x, за исключением того, что она показывает значение выражения после каждой остановки программы:

(gdb) display/i $pc 1: x/i $pc  0x100000e94 <natural_generator+4>:  movl   $0x1,-0x4(%rbp) 

Мы уже прошли пролог функции, который рассматривали ранее, поэтому начнем срезу с третьей инструкции. Она соответствует первой строчке кода, которая присваивает 1 переменной a. Вместо команды next, которая переходит к следующей строчке кода, мы будем использовать nexti, который переходит к следующей ассемблерной инструкции. Теперь исследуем адрес %rbp — 0x4, чтобы проверить гипотезу о том, что переменная a хранится именно здесь:

(gdb) nexti 7           b += 1; 1: x/i $pc  mov   0x177(%rip),%eax # 0x100001018 <natural_generator.b> (gdb) x $rbp - 0x4 0x7fff5fbff78c: 0x00000001 (gdb) x &a 0x7fff5fbff78c: 0x00000001 

И мы видим, что адреса одинаковые, как мы и ожидали. Следующая инструкция более интересная:

mov    0x177(%rip),%eax     # 0x100001018 <natural_generator.b> 

Здесь мы ожидали увидеть выполнение инструкций строки static int b = -1;, но это выглядит существенно иначе, нежели то, с чем мы встречались раньше. С одной стороны, нет никаких ссылок на стековый кадр, где мы ожидали увидеть локальные переменные. Нет даже -0x1! В место этого, у нас есть инструкция, которая загружает что-то из адреса 0x100001018, находящегося где-то после указателя инструкции, в регистр %eax. GDB дает нам полезный комментарий с результатом вычисления операнда памяти, намекая на то, что по этому адресу размещается natural_generator.b. Давайте выполним инструкцию и разберемся, что происходит:

(gdb) nexti (gdb) p $rax $3 = 4294967295 (gdb) p/x $rax $5 = 0xffffffff 

Несмотря на то, что дизассемблер показывает как получателя регистр %eax, мы выводим $rax, поскольку GDB задает переменные для полной ширины регистра.

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

(gdb) p (int)$rax $11 = -1 

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

(gdb) x/d 0x100001018 0x100001018 <natural_generator.b>:  -1 (gdb) x/d &b 0x100001018 <natural_generator.b>:  -1 

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

С таким подходом, вещи начинают обретать смысл. После сохранения b в %eax, мы переходим к следующей строчке кода, где мы увеличиваем b. Это соответствует следующим инструкциям:

add    $0x1,%eax mov    %eax,0x16c(%rip)     # 0x100001018 <natural_generator.b> 

Здесь мы добавляем 1 к %eax и записываем результат обратно в память. Давайте выполним эти инструкции и посмотрим на результат:

(gdb) nexti 2 (gdb) x/d &b 0x100001018 <natural_generator.b>:  0 (gdb) p (int)$rax $15 = 0 

Следующие две инструкции устанавливают возвращение результата a + b:

mov    -0x4(%rbp),%eax add    0x163(%rip),%eax     # 0x100001018 <natural_generator.b> 

Здесь мы загружаем переменную a в %eax, а затем добавляем b. На данном этапе мы ожидаем, что в %eax хранится значение 1. Давайте проверим:

(gdb) nexti 2 (gdb) p $rax $16 = 1 

Регистр %eax используется для хранения значения, возвращаемого функцией natural_generator, и мы ожидаем на эпилог, который очистит стек и приведет к возвращению:

pop    %rbp retq 

Мы разобрались, как переменная b инициализируется. Теперь давайте посмотрим, что происходит, когда функция natural_generator вызывается повторно:

(gdb) continue Continuing. 1  Breakpoint 1, natural_generator () at static.c:5 5           int a = 1; 1: x/i $pc  0x100000e94 <natural_generator+4>:  movl   $0x1,-0x4(%rbp) (gdb) x &b 0x100001018 <natural_generator.b>:  0 

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

Заключение

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

От переводчика: Низкоуровневое программирование — не мой профиль, поэтому если допустил какие-то неточности, буду рад узнать о них в ЛС.

ссылка на оригинал статьи http://habrahabr.ru/post/183376/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *