Какие утверждения справедливы относительно регистров общего назначения
Перейти к содержимому

Какие утверждения справедливы относительно регистров общего назначения

  • автор:

Вопросы теста: Программирование на языке ассемблера — Assembler — Ответ 13013525

Author24 — интернет-сервис помощи студентам

Народ, нужна помощь! Прохожу тест, ответил на все вопросы, нужно проверить на ошибки. Знающие люди, помогите пожалуйста.

Какие утверждения справедливы относительно флагового регистра
+ регистр имеет имя EFlags
+ регистр состоит из отдельных битов-флагов
+ регистр определяет текущее состояние выполняемой программы
+ регистр можно использовать для хранения любых данных

Какие имена регистров общего назначения являются правильными
+ EAX, ESP
+ BH, CL
+ EIP
+ A, B, C

Где может находиться элемент данных, заталкиваемый в стек командой PUSH
+в регистре общего назначения
+в именованной область памяти
+непосредственно в самой команде
+в другом стеке

Какие утверждения относительно передачи параметров являются правильными
+для передачи параметров можно использовать либо регистры, либо стек
+наиболее быстрый способ передачи параметров – через регистры
+параметры можно передавать только через стек
+параметры можно передавать только через регистры

Какие утверждения справедливы относительно доступа к параметрам, переданным подпрограмме через стек
+для доступа можно использовать только регистр EBP
+значение в регистре EBP в подпрограмме изменяться не должно
значения параметров можно вытолкнуть из стека с помощью команды POP
+для доступа можно использовать любой регистр общего назначения

Что может определять общедоступное имя, объявленное в модуле
+элемент данных
+подпрограмму
+регистр общего назначения
+управляющую директиву

Какие утверждения относительно создания ассемблерных программ для Windows являются правильными
+каждая ассемблерная программа использует набор API-функций
+параметры используемых API-функций передаются через стек
+все используемые API-функции объявляются как внешние
+все Windows-программы выполняются в реальном режиме работы процессора

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

Какие типы ошибок могут возникать при работе двухпроходного ассемблера
+повторное определение имени
+неопределенная команда
+неопределенное имя
+неправильный формат объектного модуля

Какие утверждения справедливы относительно понятия “внешнее имя”
+внешнее имя не определяется в модуле, но может в нем использоваться
+внешние имена объявляются в модуле директивой EXTRN
+внешнее имя может определять элемент данных или подпрограмму
+внешние имена обязательно должны присутствовать в каждом модуле

Какие утверждения справедливы относительно однопроходной схемы ассемблирования
+однопроходная схема имеет более сложную логику
+однопроходная схема использует более сложную структуру Таблицы Символических Имен
+однопроходная схема требует больше ресурсов памяти
+однопроходная схема применима только к одномодульным программам

Какие утверждения справедливы относительно входных модулей компоновщика
+каждый модуль использует свое локальное адресное пространство
+все модули должны иметь одинаковый формат
+каждый модуль должен иметь уникальное имя
+модули содержат полностью готовый к выполнению набор машинных команд

Какие директивы описания данных заданы правильно
+Data DW 1000, ?, 2000, ?
+Data DB ‘Some text’
+Data: DB 1, 2, 3
+DD DW 10000

Какие команды пересылки данных являются правильными
+MOV ЕAХ, ЕСХ
+MOV MyByte, AL
+MOV 100, MyDat2
+MOV А, В

Какие команды сложения являются правильными с точки зрения размещения операндов-слагаемых
+ADD регистр, память
+ADD память, регистр
+ADD константа, регистр
+ADD константа, память

Какие утверждения относительно умножения двухбайтовых целых чисел являются правильными
+один из сомножителей задается как операнд команды
+один из сомножителей всегда размещается в регистре AX
+результат умножения всегда размещается в паре регистров DX и AX
+для задания одного из сомножителей и результата можно использовать любые регистры

Какие утверждения справедливы относительно команд циклического сдвига
+команды имеют мнемонику ROL или ROR
+при сдвиге влево выталкиваемые биты заносятся в младшие разряды операнда
+при сдвиге вправо выталкиваемые биты заносятся в старшие разряды операнда
+команды можно использовать для быстрого умножения или деления чисел на степени двойки

Какие утверждения относительно коротких переходов являются правильными
+короткий переход задается однобайтовым знаковым смещением
+короткий переход позволяет перейти на 30-40 команд вперед или назад
+для явного задания короткого перехода можно использовать директиву SHORT
+по умолчанию переход всегда считается коротким

Какие особенности имеет реализация вложенных циклов с известным числом повторений
+циклы можно реализовывать как с помощью команды LOOP, так и без нее
+при использовании команды LOOP приходится сохранять и восстанавливать содержимое регистра CX
+без использования команды LOOP для хранения счетчиков числа повторений можно использовать разные регистры
+для реализации вложенных циклов можно использовать специальную команду DBLLOOP

Какие команды необходимы для реализации цикла с предусловием
+команда сравнения
+команда условного перехода на конец цикла
+команда безусловного перехода на начало цикла
+команда условного перехода на начало цикла

Меню пользователя @ Relic1996

Исключения в Windows x64. Как это работает. Часть 1

Ранее мы обсуждали прикладное применение механизма обработки исключений вне среды Windows. Теперь мы более подробно рассмотрим, как это работает в Windows x64. Материал будет описан последовательно, начиная с самых основ. Поэтому многое может оказаться вам знакомым, и в этом случае такие моменты можно просто пропустить.

Реализация механизма находится в папке exceptions хранилища git по этому адресу.

1. Функция, её пролог, тело, эпилог и кадр функции

Любая функция имеет пролог, тело и эпилог. Подробнее остановимся на прологе и эпилоге, т.к. с самим телом никаких вопросов не возникает, поскольку именно ради него все и затевается.

В прологе функции располагается код, выполняющий предварительные действия, которые необходимы перед работой тела функции. В них входит сохранение регистров общего назначения, значения которых могли быть установлены вызывающей функцией, выделение памяти в стеке для локальных переменных функции, установление указателя кадра (frame pointer) и сохранение XMM регистров процессора. В прологе установлены строгие правила по отношению к действиям, которые он может выполнять, и их последовательности. Сначала, если требуется, пролог сохраняет первые 4 параметра в области регистровых параметров (более подробно об этой области и всем, что с ней связано, будет написано в разделе 3), затем заталкиваются регистры общего назначения, выделяется память в стеке, опционально устанавливается указатель кадра функции и сохраняются XMM регистры процессора. Любое из перечисленных действий может отсутствовать, но описанный порядок выполнения строго соблюдается. Такие строгие правила позволяют анализировать действия эпилога по его программному коду, о чем будет рассказано более подробно ниже. Рисунок 1 иллюстрирует пролог функции, которая сохраняет первые 4 переданных параметра, сохраняет три регистра общего назначения, выделяет память и сохраняет XMM регистр.

Рисунок 1

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

Сохранённые регистры общего назначения, выделенная память в стеке и сохранённые регистры XMM все вместе формируют так называемый кадр (frame) функции, который есть у каждой вызванной функции. Ниже, на рисунке 2, представлен стек, состоящий из трёх кадров. Первый кадр — это кадр функции, в контексте которой произошло исключение. Для краткости на рисунке отражена только та область кадра, которая заталкивается процессором в момент исключения. Второй кадр — это кадр обработчика исключения, который состоит из пустого кода ошибки (пусть в данном примере исключение было вызвано делением на ноль, которое не заталкивает в стек код ошибки, и наш обработчик, как и обработчик Windows, для единообразия формирует пустой код), сохранённых регистров RAX, RCX, RDX, R8, R9, R10, R11, сохранённых регистров XMM0, XMM1, XMM2, XMM3, XMM4, XMM5 и адреса возврата. Эти сохраняемые регистры общего назначения и XMM регистры перечислены неспроста, об этом мы ещё поговорим в разделе 3. Третий кадр — это кадр функции, которую вызвал обработчик исключения. Её кадр состоит из сохранённых регистров RBP, RBX, XMM и выделенного пространства для локальных переменных функции. Стрелкой указано направление роста стека.

Рисунок 2

Функция может иметь указатель кадра. В таком случае доступ к кадру выполняется через этот указатель. В первую очередь это нужно в тех случаях, когда в процессе выполнения функции выделяемое пространство в стеке может динамически изменяться (т.е. выделение памяти в стеке дополнительно выполняется в теле функции, а не в прологе). А поскольку это влечёт за собой изменение указателя стека, то он не будет указывать на кадр функции. В случае если функция не имеет указателя кадра, она не может динамически выделять память в стеке, следовательно, указатель стека статичен и является также указателем кадра функции. Рисунок 3 иллюстрирует такой пролог. После сохранения всех регистров и выделения памяти, в теле функции вызывается функция, которая в RAX возвращает размер структуры, этот размер выделяется в стеке и далее указатель стека используется как указатель буфера, в который считываются данные.

Рисунок 3

Если пролог выделяет область в стеке размером, превышающую одну страницу (т.е. больше 4Кб), тогда вероятно, что такое выделение будет охватывать больше одной виртуальной страницы памяти и, следовательно, такое выделение должно быть проверено перед фактическим его выполнением. С этой целью пролог функции вызывает специальную функцию, выполняющую данную проверку. Имя функции _chkstk. Также эта функция не изменяет значений регистров, в которых передаются параметры (об этих регистрах подробно будет написано в разделе 3). На рисунке 4 изображён пример пролога функции, который выделяет 4Кб памяти в стеке.

Рисунок 4

Эпилог выполняет противоположные по отношению к прологу действия: восстанавливает XMM регистры и регистры общего назначения, которые были сохранены после выделения памяти в стеке, освобождает память в стеке (а если использовался указатель кадра, то и динамически выделенную в том числе), выталкивает регистры общего назначения, выполняет возврат в вызывающую функцию или передаёт управление на начало текущей функции, либо другой функции. На рисунке 5 изображён эпилог, соответствующий прологу из примера на рисунке 1. Из рисунка видно, что выполняются действия, противоположные действиям пролога. Также обратите внимание на тот факт, что переданные параметры не восстанавливаются, объяснение этому вы найдёте в разделе 3.

Рисунок 5

У эпилога, как и у пролога, есть строгие правила в отношении используемых инструкций процессора. Если функция не использовала указатель кадра, то память в стеке, как отражено в предыдущем примере, освобождается посредствам add rsp, константа инструкции, а если использовала, то посредствам lea rsp, [указатель кадра + константа] . Затем следуют инструкции выталкивания регистров общего назначения из стека, инструкция возврата или инструкция безусловного перехода на другую функцию или на начало текущей функции. На рисунке 6 изображён эпилог, соответствующий прологу из примера на рисунке 3. Обратите внимание на то, что вместо инструкции ret используется jmp для вызова другой функции.

Рисунок 6

Что же касается инструкций перехода, то только ограниченный набор из них допускается. Несмотря на то, что эпилог сначала восстанавливает XMM регистры и регистры общего назначения, началом эпилога, при раскрутке, считается освобождение памяти из стека через add rsp, константа или lea rsp, [указатель кадра + константа] инструкции. Объяснение этому будет дано в третьей части данной статьи, а первые сведения о раскрутке будут приведены в следующей части данной статьи.

Все вышеописанное относительно эпилога справедливо для функций, версия структуры UNWIND_INFO которых равна 1 (подробно об UNWIND_INFO будет написано в следующей части данной статьи). Выполнял ли процессор эпилог функции в момент прерывания/исключения, определяется по коду самой функции. Это возможно, поскольку, как уже было неоднократно отмечено, на действия пролога и эпилога наложен строгий порядок действий, а на эпилог ещё и ограничения, касающиеся используемых им инструкций процессора. Структуры UNWIND_INFO версии 2 могут также описывать расположение эпилога функции. Об этом мы более детально поговорим в следующей части данной статьи, здесь стоит только упомянуть, что эпилоги функций, которые описываются структурами UNWIND_INFO версии 2, могут после выталкивания регистров общего назначения освобождать 8 байт из стека, о которых мы уже говорили во время обсуждения пролога. Такое же освобождение 8 байт из стека после выталкивания регистров общего назначения не ожидается от эпилогов функций, которые описываются структурами UNWIND_INFO версии 1. Следовательно, в существующих Windows-реализациях проверка наличия этого освобождения в программном коде эпилога функций, которые описываются структурами UNWIND_INFO версии 1, не выполняется. В прилагаемой к статье реализации данного механизма такая проверка также не выполняется.

Как минимум, функция имеет один эпилог.

2. Типы функций

Есть два типа функций: кадровые функции (frame function) и простые функции (leaf function). Кадровые функции — это те, которые имеют свой кадр в стеке и они не имеют никаких ограничений по части их действий. Они могут вызывать другие функции, выделять память в стеке, сохранять и использовать любые регистры процессора. Если функция не вызывает других функций, то её стек не имеет ограничений в выравнивании, если вызывает, то стек должен быть выровнен по 16-байтной границе. Также у кадровой функции есть соответствующие записи по раскрутке её кадра (об этом мы поговорим подробнее в следующей части данной статьи).

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

3. Соглашение о вызовах

Первые 4 параметра передаются функции через регистры. Если их больше, то остальные передаются через стек. Также, вызывающей функцией для первых 4 параметров выделяется область в стеке, называемая областью регистровых параметров (register parameters area или home location). Вызванная функция может использовать эту область для сохранения параметров, как это делал пролог из рисунка 1, либо в любых других целях. Даже если функция принимает меньше 4 параметров или не принимает их вообще, область регистровых параметров всегда выделяется в стеке. Параметры, передаваемые через стек, располагаются в области, называемой областью стековых параметров (stack parameters area). Эта область, в отличие от области регистровых параметров, может отсутствовать, а её размер равен размеру всех параметров, которые она включает. Один параметр в области регистровых и стековых параметров всегда занимает 8 байт. Если же размер параметра больше 8, то вместо него передаётся указатель на него. Если же размер параметра меньше 8 байт, то старшие неиспользуемые байты в соответствующих областях игнорируются. Ниже на рисунке 7 изображены вызовы двух функций, одна из которых принимает 6 параметров, а другая 1, слева и справа от стрелки направления роста стека соответственно.

Рисунок 7

На дне стека всегда располагается область регистровых параметров, выше которой следует область стековых параметров. В случае вызова функции адрес возврата будет располагаться сразу ниже области регистровых параметров. В разделе 2 было упомянуто, что если функция вызывает другие функции, то её стек должен быть выровнен по 16-байтной границе. На этой 16-байтной границе всегда начинается область регистровых параметров.

Первые 4 параметра передаются через регистры RCX, RDX, R8 и R9, если это целое число или пользовательский тип, размер которого 1, 2, 4 или 8 байт. В противном случае передаётся указатель на соответствующий параметр. Для строк и массивов всегда передаётся их указатель. Если параметр является числом с плавающей точкой, то для его передачи используются XMM0, XMM1, XMM2, XMM3 регистры при условии, что размер параметра не превышает 8 байт, иначе передаётся указатель на него. Если передаётся указатель на параметр вместо самого параметра, то сам параметр размещается во временной памяти на 16-байтной границе. На рисунке 8 представлены примеры передачи параметров в функции.

Рисунок 8

Когда используется XMM для передачи параметра, используется тот XMM регистр, который по номеру соответствует одному из регистров RCX, RDX, R8 или R9. Например, на рисунке 8, параметр 3 функции func1 несёт в себе число с плавающей точкой, в этом случае будет использоваться XMM2 регистр. Если бы этот параметр был бы целым числом, как в функции func2, тогда использовался бы регистр R8.

Функция возвращает результат через RAX или XMM0. Числа с плавающей точкой и вектора размером до 16 байт (например, _m128) возвращаются в XMM0. Целые числа и пользовательские типы, размер которых 1, 2, 4 или 8 байт, возвращаются в RAX. Если возвращаемое значение меньше 8 байт, то старшие неиспользуемые байты не определены. Во всех остальных случаях первый параметр функции является указателем на область, куда возвращается значение, а в RAX возвращается этот указатель. Также следует отметить, что в таком случае передаваемые параметры сдвигаются на один параметр вправо, т.е. первый параметр будет передаваться не в RCX, а в RDX регистре, а 4-й параметр будет передаваться не в R9, а в стеке. На рисунке 9 представлены примеры возврата результата.

Рисунок 9

C++ компилятор накладывает дополнительные ограничения на пользовательские типы. Если результат возвращается не статичной функцией (которая является членом класса, структуры и т.д.), или сам тип имеет конструктор, деструктор, оператор присваивания, приватные или защищённые нестатичные члены, нестатичные члены типа ссылка, унаследованного родителя, виртуальные функции или члены, содержащие любое из перечисленного, то результат возвращается не в RAX, а в область памяти, указатель на которую передан в первом параметре.

Регистры RBX, RBP, RDI, RSI, RSP, R12, R13, R14 и R15 считаются постоянными (nonvolatile или callee-saved), т.е. вызываемая функция должна сохранять их перед использованием и восстанавливать перед возвратом, а вызывающая функция может полагаться на значения этих регистров после вызова функций.

Регистры RAX, RCX, RDX, R8, R9, R10 и R11 считаются непостоянными (volatile или caller-saved), т.е. вызываемая функция не должна сохранять их перед использованием, а вызывающая функция не должна полагаться на значения этих регистров после вызова функций. По этой причине эпилог, изображённый на рисунке 5, соответствующий прологу из примера на рисунке 1, не восстанавливает регистры RCX, RDX, R8, R9, сохранённые прологом. И по этой же причине обработчик исключения, упомянутый в разделе 1, сохраняет только их, т.к. эти регистры не восстанавливаются вызываемыми функциями перед возвратом.

Подобно регистрам общего назначения, регистры XMM0 — XMM5 считаются непостоянными, а регистры XMM6 — XMM15 постоянными.

Заключение

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

помогите пожалуйста тестами по «Программирование на языке ассемблера» по несколько ответов .. за вознаграждение

Вопрос 12
Какие утверждения относительно переменной Адрес Размещения Модуля, используемой компоновщиками, являются правильными
переменная используется для перехода от локальных адресных пространств модулей к общему адресному пространству

начальное значения переменной равно нулю

после обработки очередного модуля значение переменной увеличивается на байтовый размер модуля

переменная используется только двухпроходными компоновщиками

Вопрос 13
Выберите правильные форматы ассемблерных команд
метка: код_команды операнд1, операнд2

код_команды операнд1; комментарий

код_команды метка операнд

операнд1, операнд2 код_кома

Лучший ответ

Вопрос 1
каждый регистр имеет свое имя — да
регистры имеют длину 4 байта — нет, смотря какой архитектуры процессор. от 1 и более байтов
все регистры являются универсальными и никогда не имеют никакой специализации — нет. даже в наиболее ортогональной системе PDP-11 такого нет.
все регистры можно разбить на однобайтовые и двухбайтовые составляющие — нет и снова нет.

Вопрос 2
первые три

Вопрос 3
PUSH — непосредственный операнд или в архитектурах, где в стек может сохранен только аккумулятор
PUSHA, PUSHF — есть такие у x86
PUSHВ — такой не припоминаю ни у одного процессора или мкконтроллера. ежели написать через пробел PUSH В, то у 8080/8085 есть такая для сохранения регистровой пары ВС

Вопрос 4
последние три

Вопрос 5
первые два

Вопрос 6
первые два

Вопрос 13
не один неправильный. должно:
метка: МНЕМОНИКА_команды операнд1, операнд2
МНЕМОНИКА_команды операнд1; комментарий
последние два совсем не в тему

зы: где мое вознаграждение?

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

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

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

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

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

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

int main(void)

Теперь скомпилируйте его в отладочном режиме и с отключенной оптимизацией и запустите 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 : push %rbp 0x0000000100000f51 : mov %rsp,%rbp 0x0000000100000f54 : mov $0x0,%eax 0x0000000100000f59 : movl $0x0,-0x4(%rbp) 0x0000000100000f60 : movl $0x5,-0x8(%rbp) 0x0000000100000f67 : mov -0x8(%rbp),%ecx 0x0000000100000f6a : add $0x6,%ecx 0x0000000100000f70 : mov %ecx,-0xc(%rbp) 0x0000000100000f73 : pop %rbp 0x0000000100000f74 : retq End of assembler dump. 

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

Регистры

На изучение регистров стоит потратить некоторое время. Регистры — это места хранения данных, которые находятся непосредственно на центральном процессоре. С некоторыми исключениями, размер или ширина регистров процессора определяет его архитектуру. Поэтому, если у вас есть 64-битный CPU, то его регистры будут иметь ширину в 64 бита. То же самое касается и 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 : push %rbp 0x0000000100000f51 : mov %rsp,%rbp 

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

0x0000000100000f54 : mov $0x0,%eax 

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

0x0000000100000f59 : 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 : 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 : mov -0x8(%rbp),%ecx 0x0000000100000f6a : add $0x6,%ecx 0x0000000100000f70 : 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 : pop %rbp 0x0000000100000f74 : retq 

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

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

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

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

/* static.c */ #include int natural_generator() < int a = 1; static int b = -1; b += 1; return a + b; >int main()

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

$ 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 add $0x1,%eax mov %eax,0x16c(%rip) # 0x100001018 mov -0x4(%rbp),%eax add 0x163(%rip),%eax # 0x100001018 pop %rbp retq End of assembler dump. 

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

(gdb) x/i $pc 0x100000e94 : 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 : movl $0x1,-0x4(%rbp) 

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

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

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

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

mov 0x177(%rip),%eax # 0x100001018

Здесь мы ожидали увидеть выполнение инструкций строки 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 : -1 (gdb) x/d &b 0x100001018 : -1 

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

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

add $0x1,%eax mov %eax,0x16c(%rip) # 0x100001018

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

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

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

mov -0x4(%rbp),%eax add 0x163(%rip),%eax # 0x100001018

Здесь мы загружаем переменную 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 : movl $0x1,-0x4(%rbp) (gdb) x &b 0x100001018 : 0 

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

Заключение

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

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

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

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