Этот блог предназначен для программистов, которые хотят глубоко понять принципы языков программирования, используя 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.

Итак, на этом мы заканчиваем этот урок. Ваше здоровье!! Вы прошли долгий путь!