Этот блог предназначен для программистов, которые хотят глубоко понять принципы языков программирования, используя C (в качестве среды). Мы собираемся сначала повторить введение и посмотреть, как в этой статье будут использоваться различные термины, а затем ответим, как различные функции в C реализованы на машинном уровне (в коде сборки) по частям, то есть в форме вопросов.
Введение
В этом задании мы увидим работу языка C с использованием ассемблерного кода. Для этого мы можем использовать отладчик GDB или сохранить ассемблерный код программы C в другой файл.
Отладчик
Для отладчика мы можем скомпилировать программу C как:
Теперь отладчик запустится, и мы сможем начать с установки точек останова для функций (например, main или любой другой функции).
Здесь команда run используется для запуска отладчика и остановки на первой точке останова. Затем команда «дизассемблировать» используется для сброса ассемблерного кода этой конкретной функции. Теперь, если мы хотим перейти к следующей точке останова, мы можем сделать:
Файл с расширением ".s"
Чтобы сохранить код сборки в файл, мы можем сделать
Это сохранит соответствующий ассемблерный код программы sample.c в файл образца .s.
Основная информация
Звонок и возвращение: Это происходит следующим образом:
Реализация call и ret:
Здесь первая инструкция выделила 4 байта в стеке, переместив ESP (указатель стека) на 1 позицию в стеке (поскольку стек реализован в памяти сверху вниз) и сохранила в нем значение src. Вторая инструкция перемещает значение из ESP в dest и освобождает занимаемое им пространство, перемещая на 1 место назад. Инструкция вызова вызывает инструкцию push и переходит к адресу функции. Операция ret вызывает операцию pop, адресатом которой является EIP (указатель инструкции).
Передача параметров в функции:
ESP - это текущий указатель стека, который будет изменяться каждый раз, когда слово или адрес помещается в стек или выводится из него. Но так как вызываемый объект выполняется, ESP может измениться (например, при подготовке к вызову другой функции), поэтому вызываемый объект может ссылаться на параметры как смещения относительно ESP с высокой вероятностью ошибок.
Решение: используйте EBP как фиксированную точку отсчета для доступа к параметрам.
EBP - это более удобный способ для компилятора отслеживать параметры функции и локальные переменные, чем использование ESP напрямую.
Обычно (и это может варьироваться от компилятора к компилятору) все аргументы вызываемой функции помещаются в стек (обычно в порядке, обратном тому, в каком они были объявлены в прототипе функции, но это может быть разным). Затем вызывается функция, которая помещает адрес возврата (EIP) в стек.
Необходимо сохранить старое значение EBP
Перед перезаписью регистра EBP
- Callee выполняет «пролог»
- pushl% ebp
- movl% esp,% ebp
Перед возвратом вызываемый должен восстановить прежние значения ESP и EBP.
- Калли исполняет «эпилог»
- movl% ebp,% esp
- попл% ebp
- Ret
Сохранение локальных переменных:
Локальные переменные:
Недолговечный, поэтому не требует постоянного места в памяти.
Размер известен заранее, поэтому не нужно размещать в куче.
Итак, функция просто использует верхнюю часть стека.
Храните локальные переменные в верхней части стека.
Локальные переменные исчезают после возврата из функции.
При входе в функцию старое значение EBP помещается в стек, а для EBP устанавливается значение ESP. Затем ESP уменьшается (поскольку стек увеличивается вниз в памяти), чтобы выделить место для локальных переменных и временных файлов функции.
- Пример: выделить память для двух целых чисел
- subl $ 4,% esp!
- subl $ 4,% esp
(или эквивалентно менее 8 долларов США,% esp)
С этого момента во время выполнения функции аргументы функции располагаются в стеке с положительными смещениями от EBP (поскольку они были помещены до вызова функции), а локальные переменные располагаются с отрицательными смещениями от EBP. (потому что они были размещены в стеке после входа в функцию).
На локальные переменные ссылаются как на отрицательные смещения относительно EBP:
- -4 (% ebp)
- -8 (% ebp)
Вот почему EBP называется указателем кадра, потому что он указывает на центр кадра вызова функции. Когда вы возвращаетесь из функции, все локальные переменные в стеке выходят из области видимости. Вы делаете это, устанавливая указатель стека обратно на базовый указатель (который был «предыдущей» вершиной перед вызовом функции). После выхода все, что нужно сделать функции, - это установить для ESP значение EBP (которое освобождает локальные переменные из стека и выставляет запись EBP наверху стека), затем выталкивает старое значение EBP из стека, а затем функция возвращается (вставляя адрес возврата в EIP)
Обработка регистров:
Семейство x86 имеет регистры как общего, так и специального назначения. Регистры общего назначения могут использоваться для любых операций, и их значение не имеет особого значения для ЦП. С другой стороны, ЦП полагается на регистры специального назначения для своей собственной работы, и значения, хранящиеся в них, имеют определенное значение в зависимости от регистра.
- EAX, EBX, ECX, EDX - регистры общего назначения
- ESP, EBP - регистры специального назначения.
Возврат значения:
Вызываемая функция возвращает значение вызывающей функции в зависимости от типа возвращаемого значения как:
Целочисленный тип или указатель:
- Сохранить возвращаемое значение в EAX
- char, short, int, long, указатель
Тип с плавающей точкой:
- Сохранить возвращаемое значение в регистре с плавающей запятой
Состав:
- Сохранить возвращаемое значение в стеке
Вопросы
Вопрос 1
Возьмем пример программы на языке C, в которой основная функция вызывает любую другую функцию с 3 параметрами вызова по значению. Узнайте, как и когда значения фактических параметров передаются формальным параметрам в вызываемой функции. Также укажите, когда и где основная функция (или любая другая функция) копирует адрес возврата в вызываемую функцию.
Решение
Мы создаем программу на языке C (с тремя переменными a, b, c) для вывода значения операции (a + b-c), которое вычисляется в другой функции.
Здесь main передает три параметра вызова по значению функции fun. Мы сохраняем ассемблерный код программы first.c в другом файле first.s, используя вышеупомянутую команду
Ассемблерный код first.c:
Теперь мы можем разделить наш вопрос на 4 части:
- Как значения фактических параметров передаются формальным параметрам в вызываемой функции.
- Когда значения фактических параметров передаются формальным параметрам в вызываемой функции.
- Когда основная функция (или любая другая функция) копирует адрес возврата в вызываемую функцию.
- Где основная функция (или любая другая функция) копирует адрес возврата в вызываемую функцию.
Начиная с кода сборки,
Приведенный выше код сохраняет значение RBP в стеке, сохраняет значение RSP в RBP и выделяет 16 байтов для стека в качестве пространства для локальных переменных и временных файлов функции.
Здесь хранятся фактические параметры и их значения, сохраняемые в регистрах сохранения вызывающего абонента. Мы можем проверить это с помощью gdb:
Здесь мы устанавливаем точку останова для основной функции, запускаем отладчик, мы находимся на первой строке исходной программы, затем мы переходим к оператору возврата основной функции с помощью next, где 4 обозначает количество строк исходного кода, по которым нужно двигаться вперед, чтобы достичь возврата. Теперь x & a покажет значение переменной a, а x $ rbp - 12 покажет значение по адресу rbp-12. Таким образом, мы можем сказать, что фактическая переменная хранится в этом месте.
Здесь первый и второй аргументы функции хранятся в регистрах сохранения вызываемого объекта ESI и EDI, а третий аргумент сохраняется в EDX. Итак, это шаг, на котором фактические параметры main передаются в регистры сохранения вызываемого объекта. Теперь вызов fun помещает EIP, который сохраняет адрес следующей инструкции (т. Е. Адрес возврата для функции), в стек развлечения и переходит к fun (обсуждается в Введение). Итак, это был шаг, на котором основная функция (или любая другая функция) копирует адрес возврата вызываемой функции во вновь выделенное пространство в стеке вызываемой функции.
Это пролог функции. (Текущий RBP сохраняется в стек, а затем перемещается в местоположение текущего RSP).
Здесь значения фактических параметров вызывающей функции сохраняются в локальных переменных вызываемой функции регистрами сохранения вызываемого объекта.
После этого шага выделяется локальная переменная g, выполняются базовые операции сложения и вычитания, а значение g возвращается в EAX.
Вопрос 2
Повторите вопрос 1 сначала в C (используя указатели), а затем в C ++ (используя ссылочную переменную), сделав один из параметров передаваемым по ссылке. Обратите внимание на изменение версии сборки.
Решение
Для этого вопроса мы делаем две программы: second.c и second.cpp
Во втором. c мы передаем адрес третьей переменной функции и получаем как указатель в func, а во второй. cpp , мы передаем третью переменную по ссылке.
Сохраняя ассемблерные коды этих программ, мы обнаруживаем, что оба кода абсолютно одинаковы, строка за строкой.
Код сборки для функции Main.
Код сборки для func.
Начиная с кода сборки,
Здесь первые две строки являются прологом, третьи строки выделяют больше места в стеке, а строки 4–6 создают канарейку, помещают ее в стек и стирают. Canary: когда программа выполняет запись в адрес памяти в стеке вызовов программы за пределами предполагаемого буфера фиксированной длины, происходит переполнение буфера стека. Таким образом, мы сохраняем в стеке канареечное значение, которое сообщает, переполнен ли предшествующий ему буфер в памяти, чтобы мы могли завершить текущую программу, прежде чем двигаться дальше, чтобы предотвратить ее неправильное поведение.
Затем в коде выполняются те же действия, что и в первом вопросе.
В func изначально значения локальных переменных хранятся в стеке.
Хотя этот блок новый.
Здесь он сначала сохраняет адрес третьей переменной (в настоящее время хранящейся в $ RBP - 16) в RAX. Затем выполняется разыменование RAX (доступ к значению по адресу, указанному в EAX) и сохранение вычисленного значения (% RAX) -1 в EDX. Затем он сначала перемещает адрес, присутствующий в $ RBP - 16, в RAX. Затем он сохраняет значение в EDX по значению по адресу -EAX (т. Е. Обновляется исходная переменная). Затем он выполняет операцию сложения с первыми двумя переменными.
Затем обновленное значение третьей переменной доступно в main, добавляется к возвращенной сумме и печатается.
Вопрос 3
Как компиляторы C / C ++ обрабатывают динамические массивы с фиксированным стеком и динамические массивы стека?
Решение
Мы пишем программу на C, которая выделяет два массива: один с фиксированным стеком динамически, а другой - динамически.
Здесь, в функции fsd (), массив a выделяется фиксированным стеком динамически, то есть размер массива известен во время компиляции, но для этого выделяется память. массив во время выполнения.
а в функции sd () массив a выделяется стеком динамически, то есть размер массива неизвестен во время компиляции и известен только во время выполнения, и впоследствии память также выделяется во время выполнения.
Ниже приведены коды сборки для двух функций:
Здесь мы видим, что нет кода, показывающего выделение массива, поскольку размер уже известен, и выполнено только выделение памяти.
Здесь мы видим, что в основном значение, переданное в sd (), сохраняется в EDI (поскольку EDI получает первые аргументы функций).
В sd () мы видим, что длина массива, представленного в EDI (переданном основной функцией), хранится локально в $ RBP - 36. Затем есть несколько строк кода для защиты от переполнения стека буфера. (как обсуждалось).
После этого выполняется множество операций, которые нелегко отследить. Итак, мы видим, что компиляторы C / C ++ по-разному обрабатывают динамические массивы с фиксированным стеком и динамические массивы стека.
Давайте обсудим некоторые операции, используемые в этой программе:
Salq: сдвиг влево. Содержимое rax сдвигается на 63 бита влево. Поскольку в регистре всего 64 бита, остается только один бит: бывший младший бит (позиция 0), теперь он находится в позиции 63 (при подсчете от младшего бита к старшему).
Sarq: арифметический сдвиг вправо. Это означает, что он сдвигает данное число вправо, но вставляет 0 или 1 слева, в зависимости от значения MSB (это позволяет сдвиг битов для чисел со знаком), в отличие от логического сдвига, который всегда вставляет 0 бит.
Cltq: в C это обычно представляет собой приведение подписанного int к long.
Divq S: беззнаковое деление% rdx:% rax на S.
Коэффициент хранится в% rax.
Остаток хранится в% rdx.
Имул С: полное произведение% rax со знаком С.
Результат сохраняется в% rdx:% rax.
Вопрос 4
Создайте несколько динамических переменных кучи в C / C ++ и наблюдайте за разницей в адресах разных динамических переменных кучи, а также сравните их со статическими и динамическими переменными стека.
Решение
Пример программы CPP:
Здесь мы объявляем две динамические переменные кучи как указатели и присваиваем им значения.
Соответствующий ассемблерный код для 4th.cpp.
Теперь давайте посмотрим адреса переменных (здесь указатели) a и b с помощью gdb.
Таким образом, здесь ясно, что, в отличие от стека, переменные хранятся по непоследовательным адресам памяти, поскольку куча действует как пул области хранения, которая распределяет память случайным образом.
Но, если мы увидим динамические переменные стека, которые использовались в предыдущих вопросах, локальные переменные хранились в% RBP - 4,% RBP - 8,% RBP - 12 и т. Д. В последовательных местах, поскольку стек включает в себя линейное и последовательное распределение объем памяти.
Статические переменные хранятся в сегменте данных.
Вопрос 5
Прокомментируйте, как компилятор C / C ++ использует стек для реализации рекурсивной программы.
Решение
Мы пишем программу на языке C, чтобы найти сумму первых N натуральных чисел с помощью рекурсии.
Шестой.c
Параметр n передается функции foo, которая рекурсивно передается самой себе, пока n не станет равным нулю.
Ассемблерный код, соответствующий sixth.c:
Итак, в ассемблерном коде основной функции мы видим, что после пролога фактический параметр 10 передается функции через EDI.
Здесь, после пролога, формальные параметры сохраняются как локальные переменные, и рекурсивно вызывается .L2, пока значение в EDI не станет равным нулю.
В .L2 значение в EDI уменьшается на 1 и снова вызывает функцию foo с уменьшенным значением в качестве параметра.
и после того, как все значение в EDI станет нулевым,
происходит обратное отслеживание, и сумма в EAX обновляется в .L2.
Итак, на этом мы заканчиваем этот урок. Ваше здоровье!! Вы прошли долгий путь!