Как обнулить регистры в ассемблере

от admin

Введение в ассемблер

На кой мы изучаем ассемблер и работу процессора в курсе по C++? А вот тому есть сразу несколько причин:

  1. Когда мы будем говорить про языковые конструкции, мы обсудим не только то, как они работают, но ещё и почему именно так. Почему, например, лямбды без списка захвата приводятся к указателю на функцию, а со списком — нет? Без изучения основ архитектуры компьютера, мы не сможем ответить на этот вопрос, а с этим изучением просто не сможем представить себе, как может быть иначе.
  2. Есть более практический аспект: если вы что-то желаете ускорить, то высокоуровневые оптимизации (убрать ненужное действие, не считать что-то дважды) — это легко, а более низкоуровневые вообще нельзя сделать без знаний того, как внутри компьютер работает.
  3. Ещё основы архитектуры компьютера пригодятся при отладке. Если программа работает не так, то для понимания, где же именно ошибка, нам требуется понимать, что внутри процессора происходит.
  4. И наконец есть более философская полезность: если вы чем-то пользуетесь, то чем лучше вы разбираетесь, как оно работает, тем грамотнее можете этим пользоваться. То же самое с библиотеками, например (если библиотека что-то умеет, а вы не знаете об этом, вы начинаете городить костыли). Например, в x86 есть специальная инструкция с длинной арифметикой, и лучше бы писать такой код, чтобы компилятор догадался пользоваться ей.

Базовое представление об устройстве оперативной памяти.

В нашем курсе мы построим некоторую простую модель компьютера, а потом будет её изучать и уточнять по необходимости. В нашей простой модели оперативная память — массив чисел в диапазоне $[0:255]$. Вы умеете обращаться к этому массиву по индексу. Этот индекс называется адресом в памяти. Обращаться к конкретному биту вы не умеете, и притом даже не знаете, какой бит у числа в начале, а какой — в конце.

Как в этой модели исполняется программа? Следующим образом: у процессора есть регистр IP (instruction pointer), где записан адрес той инструкции, которую надо исполнять. При её исполнении значение в регистре увеличивается, тем самым начиная указывать на следующую инструкцию.

Ассемблер.

Помимо регистра IP , процессор имеет ещё 8 регистров, которые, в отличие от IP являются регистрами общего назначения (т.е. пользоваться ими вы можете как вам хочется). Эти регистры имеют название. Стоп, а зачем регистрам название? А потому что вам иногда надо прописывать процессорные инструкции руками, а запоминать последовательности байтов вы не хотите. И для этого были придуманы системы человеко-читаемых мнемоник, называемых ассемблером. При этом мнемоники различаются в зависимости от инструмента, которым вы пользуетесь, а не только в зависимости от системы. Впрочем, на одной архитектуре мнемоники в любом случае достаточно схожи.

Полезные инструменты.

Чтобы увидеть, во что ваш компилятор превращает ваш код, пишите g++ -S -masm=intel . , после чего вам создадут .s файл с ассемблерными командами. Или можно использовать https://godbolt.org, куда можно запихнуть разные компиляторы, параметры оптимизации и прочее, и сразу увидеть результат (причём понятнее, чем в g++ ). Ещё, кстати, на https://godbolt.org можно навести на команду и узнать информацию по ней. Если нужно больше информации об ассемблерной инструкции — зайдите на https://felixcloutier.com/x86/.

Регистры.

Итак, помимо регистра IP , процессор имеет ещё 8 регистров, которые, в отличие от IP являются регистрами общего назначения (т.е. пользоваться ими вы можете как вам хочется). Эти регистры имеют название AX , CX , DX , BX , SP , BP , SI , DI . Каждый из них имеет 16 битную разрядность, а последние 4 ещё и некий особый смысл, к которому скоро перейдём. Помимо них также есть следующее:

  • 8-битные версии регистров: AL , AH , BL , BH , CL , CH , DL , DH , SPL , BPL , DIL , SIL . Для регистров *X его старшие 8 бит называются *H , а младшие — *L . У оставшихся четырёх есть только младшие, получаемые дописыванием L к названию регистра.
  • 32-битные версии регистров (since 1985): EAX , EBX , . EDI . (Приставка " E " обозначает "extended".) Оригинальные 16-битные регистры являются младшей частью расширенных.
  • 64-битные версии регистров (since 2003): RAX , RBX , . RDI . (Приставка " R " обозначает "re-extended".) "Расширенные" 32-битные регистры являются младшей частью пере-расширенных.
  • Дополнительный набор регистров (x86-64): R8 , R9 , . R15 , R8D , R9D , . R15D , R8W , R9W , . R15W , R8B , R9B , . R15B . Регистры R* имеют 64-битную ширину, R*D (от слова "dword") — младшие 32 бита соответствующего регистра, R*W (от слова "word") — младшие 16, R*B (от слова "byte") — младшие 8.

Команда mov .

mov dst, src является простым присваиванием dst = src . В качестве её аргументов могут выступать регистры, константы или даже места в памяти. По поводу последнего: если вы хотите положить в регистр AX число десять, это пишется так: mov AX, 10 , а если значение в десятой ячейке памяти, то адрес берётся в квадратные скобки: mov AX, [10] . Также в квадратные скобки можно брать регистр.

Все эти разные типы аргументов команды mov на самом деле даже по-разному кодируются. Как пример, присваивание числа в регистр и регистра в регистр занимают 2 и 5 байтов соответственно:

Некоторые сочетания закодировать нельзя никак (например, mov [AX], [BX] ). А если детальнее, то возможны вот такие комбинации регистров ( reg ) и чисел ( imm ):

  • mov reg, reg
  • mov reg, imm
  • mov reg, [reg]
  • mov reg, [imm]
  • mov [reg], reg
  • mov [imm], reg
  • mov [reg], imm
  • mov [imm], imm

Возникает вопрос. Регистр AX шестнадцати-битный, а адрес указывает на байт. Значит при выполнении команды mov нужно откуда-то взять ещё 8 бит. А вот читается не только тот адрес, который запросили, но ещё и следующий. Но какой из прочитанных байт старший, а какой — младший? В разных архитектурах возможны разные варианты.

  • Если значения в меньшем адресе являются младшими разрядами, то это называется little-endian.
  • Если значения в большем адресе являются младшими разрядами, то это называется big-endian.

Пример: Если в ячейке с номером 100 записан байт 0x6C , а с номером 101 — 0xAA , то в LE при чтении ячейки [100] мы получим число 0xAA6C , а в BE — 0x6CAA .

В x86 используется только little-endian.

Команды арифметики.

Базовые бинарные операции.

Процессор умеет выполнять базовые бинарные операции, которые в ассемблере называются add , sub , and , or , xor . Все они работают как комбинация операции и присваивания (т.е. соответственно += , -= , &= , |= , ^= ). Совершать операции можно только с числами одинакового размера (как и с mov ). Для бинарных операций возможны такие комбинации регистров и чисел, как и для mov (см. выше).

Унарные операции.

Простые унарные операции inc , dec , not , neg также работают как операция с присваиванием (то есть соответственно, x = x + 1 , x = x — 1 , x =

x , x = -x ). Понятно, что в случае, когда мы оперируем с ячейкам памяти, надо явно уточнить, с числами какого размера производится операция. В ассемблере это пишется, например, так inc byte [addr] . byte подразумевает 8 бит. Вместо него можно написать word , dword или qword , подразумевая соответственно 2, 4 и 8 байтов.

Умножение и деление.

Умножение принимает один аргумент, умножая его на регистр AL / AX / EAX / RAX . Результат умножения занимает вдвое больше знаков, чем аргумент и записывается либо в регистр AX (для 8-битного), либо в пары регистров DX:AX , EDX:EAX , RDX:RAX (для 16-, 32- и 64-битного соответственно). DX — старшая половина разрядов.

Несложно заметить, что в отличие от сложения и вычитания, умножение и деление разное для знаковых и беззнаковых чисел. Поэтому существуют два разных умножения ( mul и imul ) и два разных деления ( div и idiv ). Первое для беззнаковых чисел, второе — для знаковых.

Деление работает схожим образом. Оно принимает делимое из AX / DX:AX / EDX:EAX / RDX:RAX , делитель как аргумент, а частное возвращает в регистр AL / AX / EAX / RAX . Но также деление возвращает остаток, и его оно возвращает в AH / DX / EDX / RDX .

Вопрос: как поделить друг на друга числа одинакового размера? Ну, нужно преобразовать, например, 16-битное число в 32-битное. В случае, если числа беззнаковые, надо просто обнулить DX (обычно регистр обнуляется при помощи xor DX, DX , потому что эта инструкция занимает памяти, нежели mov DX, 0 ). Те, кто знают, как работает дополнение до 2, также знают, что нужно делать в знаковом случае. Нужно заполнить DX знаковым битом AX . Для этого в x86 есть специальная инструкция cwd . Для того, чтобы сделать то же самое с EDX и RDX , есть инструкции cdq и cqo соответственно.

Хорошо, про деление мы знаем уже почти всё, кроме того, что будет, если мы поделим на 0. Или если результат в нужные регистры не поместится. Обе эти ситуации приводят к системным прерываниям, которые работают следующим образом: процессор имеет массив IDTR (Interrupt Descriptor Table Register), в котором для каждого типа прерывания сказано, что с ним делать. Этот массив при запуске подготавливает операционная система.

Сдвиги.

  • SHL / SAL — сдвиг влево
  • SHR — логический сдвиг вправо (оставляет на пустых местах нули)
  • SAR — арифметический сдвиг справа (на пустые места пихает знаковый бит).

В C++ для беззнаковых используется SHL и SHR , а для знаковых — SAL и SAR .

Оптимизации компилятора.

Давайте забьём в https://godbolt.org следующий код:

В результате увидим нечто неизвестное:

Несложно догадаться, что обе функции получают свои аргументы в регистрах EDI и ESI , а возвращают — в EAX . Но непонятно, что такое LEA .

Команда LEA .

LEA расшифровывается как "load effective address" и вторым её аргументом всегда является адрес памяти. Именно этот адрес присваивается в первый аргумент инструкции. Здесь она используется как альтернатива ADD + MOV .

Как несложно заметить, в качестве адреса можно писать сумму значений в регистрах. А что ещё можно? А вот что: [reg+i*reg+const] где i равно 1 , 2 , 4 или 8 .

Еще LEA не трогает флаги, в отличие от ADD . Что такое флаги — смотри дальше. Детальнее про LEA можно почитать здесь (первые 2 ответа).

Как избегается оптимизируется деление:

Деление занимает много больше тактов, чем другие арифметические операции, а во время его вычисления весь конвейер стоит. Компиляторы стараются избегать операции деления, если это возможно. Например, мы знаем, что беззнаковое деление или умножение на $2^k$ можно легко заменить на сдвиг на $k$. Поэтому следующий код на C++:

Может быть оптимизирован компилятором до

А вот просто сдвинуть знаковое число вместо деления нельзя, потому что у этих двух операций округления в разные стороны. Поэтому тот же код, но с сигнатурой int foo(int a) скомпилируется так:

А что будет с беззнаковым делением на 3? А тут есть другие хаки:

Почему это лучше? Как мы уже обсудили, деление дорогое, а константу можем посчитать при компиляции, получая выигрыш в эффективности.

Почему это работает? Потому что при арифметике с переполнением деление на константу можно выполнить через умножение: $$\frac a3=\frac/3)><2^<33>> = (a\cdot2863311531) \gg 33$$ А в общем случае компиляторы пытаются подобрать $n$ (равное тут $33$), чтобы такая замена работала как требуется. Больше подобного рода трюков можно почитать в книжке Hacker’s Delight.

Control-flow.

Мы уже понимаем, что компилятор делает с арифметическими выражениями, а значит линейный код мы уже можем перевести на ассемблер руками. А вот ветвления и циклы — пока нет нет. Как они идейно работают? У процессора есть команды, которые называются branch’ами. Самая простая из них — jmp (своего рода goto ). Её аргументом является число, которое нужно прибавить к IP , чтобы перейти на адрес новой инструкции. В ассемблере, однако, это вместо этого пишется метка, по которой и осуществляется переход.

Помимо jmp есть conditional branch (то есть переход, если выполнено какое-то условие). Осуществляются они комбинацией команды cmp и какой-то из команд je , jg , jl или подобной. Первая команда (пока непонятным нам образом) сравнивает два регистра, а потом вторая получает результат этого сравнения и совершает переход только в определённом случае. В таблице ниже перечислены условия для каждой из команд:

Команда Эквивалент Расшифровка
je left == right jump if equal
jg (signed)left > (signed)right jump if greater
jl (signed)left < (signed)right jump if less
ja (unsigned)left > (unsigned)right jump if above
jb (unsigned)left < (unsigned)right jump if below
jne left != right jump if not equal
jng (signed)left <= (signed)right jump if not greater
jnl (signed)left >= (signed)right jump if not less
jna (unsigned)left <= (unsigned)right jump if not above
jnb (unsigned)left >= (unsigned)right jump if not below

Регистры флагов (FLAGS Registers).

Теперь давайте всё же поговорим, как работает cmp ? Для этого нам нужно поговорить о такой штуке как регистр флагов. Он содержит, собственно, битовые флаги. Из их большого набора нас интересуют

  • CF — carry flag.
  • ZF — zero flag.
  • SF — sign flag.
  • OF — overflow flag.

В процессе своей работы разные инструкции устанавливают различные флаги (какие-то инструкции на определённые флаги не влияют, у каких-то инструкций эти флаги значат свой, поэтому читайте документацию). А мы рассмотрим, как с ними работают инструкции add и sub . Они выставляют:

  • ZF — если результат равен 0.
  • SF — если результат отрицательный.
  • CF — если произошёл перенос в сложении/заимствование в вычитании беззнаковых чисел.
  • OF — если знаковая операция вызвала переполнение.

С флагами можно взаимодействовать не только при помощи уже обсуждённых инструкций условного перехода, но и, например, при помощи команды adc , которая выполняет сложение, но также добавляет к результату CF.

Или есть ещё команды перехода, основанные на флагах:

  • jz — jump if ZF.
  • js — jump if SF.
  • jc — jump if CF.
  • jo — jump if OF.
  • jnz — jump if not ZF.
  • jns — jump if not SF.
  • jnc — jump if not CF.
  • jno — jump if not OF.

Как работают арифметические условные переходы.

cmp устанавливает флаги также, как это делаем вычитание. Отсюда посмотрим, какие флаги проверяются какими условными переходами.

Про je , jb и ja всё понятно. je выполняется при ZF, jb — при CF, ja — если ZF и CF оба ложны. А что с jl и jg , сейчас обсудим.

Давайте рассмотрим числа из 3 бит простоты и наглядности ради, и пометим цветом те места, где CMP задаёт определённые флаги. ZF — очевидно.

Теперь посмотрим на SF. Если мы вычитаем из маленького числа число чуть побольше, то результат будет отрицательным. Но если мы, например, из $-8$ вычитаем что-то, то происходит переполнение, и результат получается положительным. Аналогично если мы из положительного числа вычитаем большое отрицательное, может произойти переполнение в обратную сторону, и результат станет отрицательным. Учитывая это, имеем, что SF задан в этой области:

А ещё мы уже обсудили переполнение, и можем сказать, где задан OF:

Ветвление Условие
je ZF
jg (SF == OF) && !ZF
jl SF != OF
ja !CF && !ZF
jb CF

Кстати, несмотря на то, что в ассемблере есть je и jz , по сути они делают одно и то же и даже одинаковым набором байт обозначаются (поэтому если вы будете ассемблировать-дизассемблировать код, учтите, что в коде ASM je может замениться на jz или наоборот).

Помимо cmp есть ещё одна инструкция, которая не делает ничего, кроме расстановки флагов — test . Она делает побитовое "И" аргументам и ставит:

  • ZF, если результат равен нулю.
  • SF, если результат отрицательный.
  • CF, никогда (всегда ставится в false ).
  • OF, никогда (всегда ставится в false ).

Иногда ни test , ни cmp не нужны, потому что многие инструкции ставят флаги, и иногда так, как вам нужно. Пример:

Работа с функциями.

Основы работы со стеком.

Вопрос на засыпку: как реализовать рекурсию? Да и в принципе, вызов функций? Мы выходим только из той функции, в которую зашли последней. Это, барабанная дробь, стек. Стек — это кусочек памяти, его вершина имеет меньший адрес, а дно — больший. На вершину стека указывает регистр SP , название которого так и расшифровывается — stack pointer.

Процессор предоставляет две базовые функции работы со стеком — push и pop . Несложно догадаться, что push src равносильно

Ещё есть две "более высокоуровневые" инструкции — call и ret . Первая принимает своим единственным аргументом метку (адрес функции, которую нужно вызвать), кладёт на стек адрес следующей инструкции и делает переход по метке. Вторая берёт со стека адрес и переходит по нему. Несложно заметить, что именно так функции и работают. Честно написать, чему эти инструкции равносильны, невозможно, потому что нельзя лёгким образом получить доступ к IP .

Передача параметров в функцию.

Быстрее и проще всего положить параметры в регистры (например, EDI и ESI ). Но регистров у нас конечное число, поэтому если параметров много, то может не хватить. В таком случае параметры можно передавать через стек. И чтобы получить их, мы делаем mov RAX, [RSP+8] или mov RAX [RSP+16] . Но после возвращения из функции, у нас параметры всё ещё на стеке, надо, чтобы кто-то его почистил. Можем сделать так, чтобы вызывающая функция почистила стек, прибавляя константу к RSP каждый раз после вызова. Но не лучше ли запихнуть это в вызываемую функцию? Ну, идейно да, но надо куда-то сохранить возвращаемое значение. В x86 вообще есть ret n , который берёт значение, сдвигает RSP на n и возвращается. Это называется «вызываемая функция чистит стек». Осталось понять, что делать с локальными переменными? А, их можно положить выше на стек после адреса возврата.

Хорошо, а кто определяет, как передавать параметры? А этим занимается ABI — application binary interface. ABI содержит вообще всю информацию, необходимую для взаимодействия, например, вас с операционной системой.

  • Выравнивание и размеры типов данных.
  • Соглашение о передаче параметров, о том, кто чистит стек, о том, в каком порядке параметры на стек кладутся.
  • Как выполняются системные вызовы.

Всё это — ABI. Обычно он привязан к ОС.

Windows из данного правила является исключением: в нём есть целая табличка о том, какой из миллиона ( cdecl , stdcall , thiscall , fastcall , . ) способов передавать параметры, что делает. И можно в коде прямо на функции явно написать, какой способ использовать. Даже свой собственный можете сделать.

А кто, кстати, обычно чистит стек? Вызывающая функция. Почему так, это же, вроде как, неудобно? Потому что существуют variadic-функции. Когда вы делаете printf , компилятор просто пихает параметры на стек, а вызываемая функция не знает, сколько их. Поэтому чистит вызывающая, которая знает.

Системные вызовы.

syscall — интерфейс взаимодействия процесса программы с ядром ОС. Например, это чтение/запись в файла/терминала, завершение программы с кодом ошибки и т.д.

Стековый фрейм.

Можно заметить, что под GCC код

с флагами -O2 -fno-omit-frame-pointer компилируется во что-то такое:

Нам интересна обёртка push RBP , mov RBP, RSP , . pop RBP . Зачем это? Посмотрим, что делает этот код. Он сохраняет старое значение RBP на стек. А при вызове рекурсивной функции в RBP будет RSP . То есть во внутренней функции мы запихаем на стек адрес первой. И так далее. То есть на стеке есть односвязный список значений RBP от нового к старому. Поэтому пойдя по RBP , можем напечатать весь стек (это называется unwindраскрутка стека). Буквально так очень давно работали отладчики. В наше время этот режим менее актуален, поэтому генерировать эти инструкции избыточно. Вместо этого компиляторы вместе с кодом генерируют отладочную информацию как раз о том, какие команды каким строкам кода соответствуют, как раскручивать стек и подобное. gcc генерирует стековый фрейм только с некоторым ключом. MSVC генерирует его по умолчанию. Возможно, это потому, что рандомная опубликованная exe’шка может в отладочных данных содержать секретики. Их вы публиковать не хотите, поэтому только стековые фреймы. К чему это всё для нас? Если вы пользуетесь какими-то инструментами, которые снимают стек (отладчик или профилировщик), убедитесь, что у вас есть отладочные символы, и отлаживаете вы тоже согласно им. Либо и то, и другое по стековому фрейму.

Выделение памяти:

К локальным переменным обращаемся через RSP , лежат на стеке. Размер стека — переменная на уровне операционной системы (вроде).

Обратим внимание на то, что если изменить размер массива на 112, то этот код скомпилируется в:

Почему 136 изменилось на 120 ? Этот эффект называется выравниванием (alignment).

В качестве основной единицы работы с памятью используется машинное слово, размер которого обычно составляет несколько байт. Так называемый unaligned access сложен в реализации на аппаратном уровне, поэтому обращения по произвольному адресу либо не поддерживаются (и вызывают исключение процессора), либо поддерживаются, но работают медленно. Обычно компилятор выравнивает данные по границам машинных слов, в нашем случае 8 + 16 * k .

Name already in use

hse-caos-2020 / 07-asm1 / README.md

  • Go to file T
  • Go to line L
  • Copy path
  • Copy permalink
  • Open with Desktop
  • View raw
  • Copy raw contents Copy raw contents

Copy raw contents

Copy raw contents

Ассемблер x86, часть 1

Основное чтение: Р. Э. Брайант, Д. Р. О’Халларон. Компьютерные системы: архитектура и программирование. Глава 3.

Мы будем использовать AT&T синтаксис для записи инструкций ассемблера x86. Для компиляции программ будем использовать gas, а точнее gcc.

Исходный файл с программой на ассемблере должен иметь суффикс .S (буква S должна быть заглавной!). Файл будет обрабатываться препроцессором, затем ассемблером.

На 64-битной Ubuntu может быть не установлена поддержка компиляции программ для 32-битной архитектуры i386. В этом случае даже простейшая программа, приведенная ниже, не скомпилируется. Чтобы установить 32-битные библиотеки выполните команду:

Если используется файл simpleio_i686.S или simpleio_x86_64.S , этот файл нужно просто добавить в командную строку. Например,

Все примеры будут рассматриваться для архитектуры x86 (i386).

x86 имеет 8 32-битных регистров общего назначения, которые называются %eax , %ebx , %ecx , %edx , %esi , %edi , %ebp , %esp . Регистр %esp — указатель стека и всегда используется в этих целях. Регистр %ebp — указатель фрейма, то есть области данных текущей функции. Он может использоваться и как регистр общего назначения, но мы этого делать не будем. Таким образом для вычислений остается 6 регистров %eax , . %edi . Из них регистр %eax используется для возврата 32-битного значения из функции, а пара регистров %eax , %edx — для возврата 64-битного значения. В любом случае вызываемая функция может испортить регистры %eax , %ecx , %edx , но состояние остальных регистров она обязана сохранить. Итого для хранения промежуточных результатов вычислений осталось три регистра: %ebx , %esi , %edi .

В некоторых случаях необходимо использовать младшие байты 32-битных регистров, работать с которыми можно как с 8-битовыми регистрами %al , %bl , %cl , %dl . При манипуляциях с младшим байтом остальные байты регистра не изменяются.

Инструкция имеет вид:

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

OPCODE — мнемоника операции. Полный набор инструкций x86 очень велик, мы будем использовать маленькое подмножество.

ARGS — аргументы операции. У двухадресной операции операнд-приемник результата всегда пишется вторым.

Переслать (присвоить, скопировать) содержимое регистра %eax’ в регистр %ebx`.

Увеличить значение %esi на 1.

Вычесть число 4 из значения %edi и сохранить результат обратно в %edi .

Операции умножения оставляют результат — 64-битное значение в паре регистров %eax (младшая часть) и %edx (старшая часть).

Вызов/возврат из подпрограмм

Сначала мы будем использовать для ввода-вывода маленькую библиотеку simpleio_x86.S (или simpleio_x64.S).

Простейшая программа на ассемблере будет выглядеть так:

Чтение целого числа со стандартного потока ввода:

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

Вывод целого числа на стандартный поток вывода:

перед вызовом в регистр %eax должно быть помещено выводимое число.

Вывод символа \n:

Чтение 64-битного числа со стандартного потока ввода:

По возращению в регистрах %eax (младшие 32 бита) и %edx (старшие 32 бита) находится считанное число. Если произошла ошибка преобразования или был достигнут конец файла, флаг CF устанавливается, а при успешном чтении сбрасывается.

Вывод 64-битного целого числа на стандартный поток вывода:

перед вызовом в регистры %eax (младшие 32 бита), %edx (старшие 32 бита) должно быть помещено выводимое число.

Вариант библиотеки simpleio_x64.S для x64 следует стандартному соглашению о вызовах.

Большинство арифметических инструкций в результате вычисления результата инструкции устанавливают арифметические флаги слова состояния процесса. Флаг ZF устанавливается, если в результате операции был получен нуль. Флаг SF устанавливается, если в результате операции было получено отрицательное число. Флаг CF устанавливается, если в результате выполнения операции произошел перенос из старшего бита результата. Например, для сложения CF устанавливается если результат сложения двух беззнаковых чисел не может быть представлен 32-битным беззнаковым числом. Флаг OF устанавливается, если в результате выполняния операции произошло переполнение знакового результата. Например, при сложении OF устанавливается, если результат сложения двух знаковых чисел не может быть представлен 32-битным знаковым числом.

Обратите внимание, что и сложение addl , и вычитание subl устанавливают одновременно и флаг CF, и флаг OF. Сложение и вычитание знаковых и беззнаковых чисел выполняется совершенно одинаково, и поэтому используется одна инструкция и для знаковой, и для беззнаковой операции.

Безусловный переход выполняется с помощью инструкции jmp

Условные переходы проверяют комбинации арифметических флагов:

Секции исполняемого файла

Это секция кода. В ней размещается код программы и данные только на чтение.

Это секция данных. Данные в этой секции можно модифицировать

Определяет строку, завершающуюся нулем.

Выделяет пространство размера SIZE, заполненное байтом FILL, например,

Выделяет место для глобального массива типа int из 64 элементов.

Выделяет место для глобальной 32-битной переменной с начальным значеним 20.

Выделяет место для глобальной 64-битной переменной с начальным значением -1.

Как правило, определение данных должно быть помечено. Например,

Затем метка str1 может использоваться в программе:

в этом случае адрес памяти, по которому размещается строка str1 будет загружен в регистр %esi .

Очистка и установка значения регистра

Чтобы очистить (обнулить) регистр, обычно используется инструкция xorl .

Чтобы загрузить константное значение (в частности, метка — это константное значение, равное адресу размещения соответствующей инструкции в памяти) используется непосредственный аргумент, например:

Непосредственный аргумент может быть первым операндом у двухадресных инструкций ( addl , etc).

Учебное пособие для курса ВМК «Архитектура ЭВМ и язык ассемблера»: часть 1, часть 2.

Записки программиста

Шпаргалка по основным инструкциям ассемблера x86/x64

12 октября 2016

В прошлой статье мы написали наше первое hello world приложение на асме, научились его компилировать и отлаживать, а также узнали, как делать системные вызовы в Linux. Сегодня же мы познакомимся непосредственно с ассемблерными инструкциями, понятием регистров, стека и вот этого всего. Ассемблеры для архитектур x86 (a.k.a i386) и x64 (a.k.a amd64) очень похожи, в связи с чем нет смысла рассматривать их в отдельных статьях. Притом акцент я постараюсь делать на x64, попутно отмечая отличия от x86, если они есть. Далее предполагается, что вы уже знаете, например, чем стек отличается от кучи, и объяснять такие вещи не требуется.

Регистры общего назначения

Регистр — это небольшой (обычно 4 или 8 байт) кусочек памяти в процессоре с чрезвычайно большой скоростью доступа. Регистры делятся на регистры специального назначения и регистры общего назначения. Нас сейчас интересуют регистры общего назначения. Как можно догадаться по названию, программа может использовать эти регистры под свои нужды, как ей вздумается.

На x86 доступно восемь 32-х битных регистров общего назначения — eax, ebx, ecx, edx, esp, ebp, esi и edi. Регистры не имеют заданного наперед типа, то есть, они могут трактоваться как знаковые или беззнаковые целые числа, указатели, булевы значения, ASCII-коды символов, и так далее. Несмотря на то, что в теории эти регистры можно использовать как угодно, на практике обычно каждый регистр используется определенным образом. Так, esp указывает на вершину стека, ecx играет роль счетчика, а в eax записывается результат выполнения операции или процедуры. Существуют 16-и битные регистры ax, bx, cx, dx, sp, bp, si и di, представляющие собой 16 младших бит соответствующих 32-х битных регистров. Также доступны и 8-и битовые регистры ah, al, bh, bl, ch, cl, dh и dl, которые представляют собой старшие и младшие байты регистров ax, bx, cx и dx соответственно.

Рассмотрим пример. Допустим, выполняются следующие три инструкции:

Значения регистров после записи в eax значения 0 x AABBCCDD:

Значения после записи в регистр al значения 0 x EE:

Значения регистров после записи в ax числа 0 x 1234:

Как видите, ничего сложного.

Примечание: Синтаксис GAS позволяет явно указывать размеры операндов путем использования суффиксов b (байт), w (слово, 2 байта), l (длинное слово, 4 байта), q (четверное слово, 8 байт) и некоторых других. Например, вместо команды mov $ 0xEE , % al можно написать movb $ 0xEE , % al , вместо mov $ 0x1234 , % ax — movw $ 0x1234 , % ax , и так далее. В современном GAS эти суффиксы являются опциональными и я лично их не использую. Но не пугайтесь, если увидите их в чужом коде.

На x64 размер регистров был увеличен до 64-х бит. Соответствующие регистры получили название rax, rbx, и так далее. Кроме того, регистров общего назначения стало шестнадцать вместо восьми. Дополнительные регистры получили названия r8, r9, …, r15. Соответствующие им регистры, которые представляют младшие 32, 16 и 8 бит, получили название r8d, r8w, r8b, и по аналогии для регистров r9-r15. Кроме того, появились регистры, представляющие собой младшие 8 бит регистров rsi, rdi, rbp и rsp — sil, dil, bpl и spl соответственно.

Про адресацию

Как уже отмечалось, регистры могут трактоваться, как указатели на данные в памяти. Для разыменования таких указателей используется специальный синтаксис:

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

В одной команде можно указывать адрес и смешение (как положительное, так и отрицательное) относительно него:

Эта запись означает «возьми rsp, прибавь к нему 8, прочитай 8 байт по получившемуся адресу и положи их в rax». Таким образом, в rax будет записан адрес строки, представляющей собой первый аргумент программы, то есть, имя исполняемого файла.

При работе с массивами бывает удобно обращаться к элементу с определенным индексом. Соответствующий синтаксис:

Читается так: «посчитай rcx*8 + rsp + 16, и поменяй местами 8 байт (размер регистра) по получившемуся адресу и значение регистра rax». Другими словами, rsp и 16 все так же играют роль смещения, rcx играет роль индекса в массиве, а 8 — это размер элемента массива. При использовании данного синтаксиса допустимыми размерами элемента являются только 1, 2, 4 и 8. Если требуется какой-то другой размер, можно использовать инструкции умножения, бинарного сдвига и прочие, которые мы рассмотрим далее.

Наконец, следующий код тоже валиден:

.data
msg :
. ascii "Hello, world!\n"
. text

. globl _start
_start :
# обнуление rcx
xor % rcx , % rcx
mov msg ( ,% rcx , 8 ) , % al
mov msg , % ah

В смысле, что можно не указывать регистр со смещением или вообще какие-либо регистры. В результате выполнения этого кода в регистры al и ah будет записан ASCII-код буквы H, или 0 x 48.

В этом контексте хотелось бы упомянуть еще одну полезную ассемблерную инструкцию:

Инструкция lea очень удобна, так как позволяет сразу выполнить умножение и несколько сложений.

Fun fact! На x64 в байткоде инструкций никогда не используются 64-х битовые смещения. В отличие от x86, инструкции часто оперируют не абсолютными адресами, а адресами относительно адреса самой инструкции, что позволяет обращаться к ближайшим +/- 2 Гб оперативной памяти. Соответствующий синтаксис:

Сравним длины опкодов «обычного» и «относительного» mov ( objdump -d ):

Как видите, «относительный» mov еще и на один байт короче! Что это за регистр такой rip мы узнаем чуть ниже.

Для записи же полного 64-х битового значения в регистр предусмотрена специальная инструкция:

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

Арифметические операции

Рассмотрим основные арифметические операции:

# инциализируем значения регистров
mov $ 123 , % rax
mov $ 456 , % rcx

# инкремент: rax = rax + 1 = 124
inc % rax

# декремент: rax = rax — 1 = 123
dec % rax

# сложение: rax = rax + rcx = 579
add % rcx , % rax

# вычитание: rax = rax — rcx = 123
sub % rcx , % rax

# изменение знака: rcx = — rcx = -456
neg % rcx

Здесь и далее операндами могут быть не только регистры, но и участки памяти или константы. Но оба операнда не могут быть участками памяти. Это правило применимо ко всем инструкциям ассемблера x86/x64, по крайней мере, из рассмотренных в данной статье.

В данном примере инструкция mul умножает al на cl, и сохраняет результат умножения в пару регистров al и ah. Таким образом, ax примет значение 0 x 12C или 300 в десятичной нотации. В худшем случае для сохранения результата перемножения двух N-байтовых значений может потребоваться до 2*N байт. В зависимости от размера операнда результат сохраняется в al:ah, ax:dx, eax:edx или rax:rdx. Притом в качестве множителей всегда используется первый из этих регистров и переданный инструкции аргумент.

Знаковое умножение производится точно так же при помощи инструкции imul. Кроме того, существуют варианты imul с двумя и тремя аргументами:

mov $ 123 , % rax
mov $ 456 , % rcx

# rax = rax * rcx = 56088
imul % rcx , % rax

# rcx = rax * 10 = 560880
imul $ 10 , % rax , % rcx

Инструкции div и idiv производят действия, обратные mul и imul. Например:

mov $ 0 , % rdx
mov $ 456 , % rax
mov $ 123 , % rcx

# rax = rdx:rax / rcx = 3
# rdx = rdx:rax % rcx = 87
div % rcx

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

Это далеко не все арифметические инструкции. Например, есть еще adc (сложение с учетом флага переноса), sbb (вычитание с учетом займа), а также соответствующие им инструкции, выставляющие и очищающие соответствующие флаги (ctc, clc), и многие другие. Но они распространены намного меньше, и потому в рамках данной статьи не рассматриваются.

Логические и битовые операции

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

Так, например, выглядит вычисление простейшего логического выражения:

mov $ 0 , % rax # a = false
mov $ 1 , % rbx # b = true
mov $ 0 , % rcx # c = false

# rdx := a || !(b && c)
mov % rcx , % rdx # rdx = c
and % rbx , % rdx # rdx &= b
not % rdx # rdx =

rdx
or % rax , % rdx # rdx |= a
and $ 1 , % rdx # rdx &= 1

Заметьте, что здесь мы использовали по одному младшему биту в каждом из 64-х битовых регистров. Таким образом, в старших битах образуется мусор, который мы обнуляем последней командой.

Еще одна полезная инструкция — это xor (исключающее или). В логических выражениях xor используется нечасто, однако с его помощью часто происходит обнуление регистров. Если посмотреть на опкоды инструкций, то становится понятно, почему:

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

В данном контексте также следует вспомнить инструкции побитового сдвига, тестирования битов (bit test) и сканирования битов (bit scan):

# положим что-нибудь в регистр
movabs $ 0xc0de1c0ffee2beef , % rax

# сдвиг влево на 3 бита
# rax = 0x0de1c0ffee2beef0
shl $ 4 , % rax

# сдвиг вправо на 7 бит
# rax = 0x001bc381ffdc57dd
shr $ 7 , % rax

# циклический сдвиг вправо на 5 бит
# rax = 0xe800de1c0ffee2be
ror $ 5 , % rax

# циклический сдвиг влево на 5 бит
# rax = 0x001bc381ffdc57dd
rol $ 5 , % rax

# положить в CF (см далее) значение 13-го бита
# CF = !!(0x1bc381ffdc57dd & (1 << 13)) = 0
bt $ 13 , % rax

# то же самое + установить бит (bit test and set)
# rax = 0x001bc381ffdc77dd, CF = 0
bts $ 13 , % rax

# то же самое + сбросить бит (bit test and reset)
# rax = 0x001bc381ffdc57dd, CF = 1
btr $ 13 , % rax

# то же самое + инвертировать бит (bit test and complement)
# rax = 0x001bc381ffdc77dd, CF = 0
btc $ 13 , % rax

# найти самый младший ненулевой байт (bit scan forward)
# rcx = 0, ZF = 0
bsf % rax , % rcx

# найти самый старший ненулевой байт (bit scan reverse)
# rdx = 52, ZF = 0
bsr % rax , % rdx

# если все биты нулевые, ZF = 1, значение rdx неопределено
xor % rax , % rax
bsf % rax , % rdx

Еще есть битовые сдвиги со знаком (sal, sar), циклические сдвиги с флагом переноса (rcl, rcr), а также сдвиги двойной точности (shld, shrd). Но используются они не так уж часто, да и утомишься перечислять вообще все инструкции. Поэтому их изучение я оставляю вам в качестве домашнего задания.

Условные выражения и циклы

Выше несколько раз упоминались какие-то там флаги, например, флаг переноса. Под флагами понимаются биты специального регистра eflags / rflags (название на x86 и x64 соответственно). Напрямую обращаться к этому регистру при помощи инструкций mov, add и подобных нельзя, но он изменяется и используется различными инструкциями косвенно. Например, уже упомянутый флаг переноса (carry flag, CF) хранится в нулевом бите eflags / rflags и используется, например, в той же инструкции bt. Еще из часто используемых флагов можно назвать zero flag (ZF, 6-ой бит), sign flag (SF, 7-ой бит), direction flag (DF, 10-ый бит) и overflow flag (OF, 11-ый бит).

Еще из таких неявных регистров следует назвать eip / rip, хранящий адрес текущей инструкции. К нему также нельзя обращаться напрямую, но он виден в GDB вместе с eflags / rflags, если сказать info registers , и косвенно изменяется всеми инструкциям. Большинство инструкций просто увеличивают eip / rip на длину этой инструкции, но есть и исключения из этого правила. Например, инструкция jmp просто осуществляет переход по заданному адресу:

В результате значение rax будет равно единице, так как первая инструкция inс будет пропущена. Заметьте, что адрес перехода также может быть записан в регистре:

Впрочем, на практике такого кода лучше избегать, так как он ломает предсказание переходов и потому менее эффективен.

Примечание: GAS позволяет давать меткам цифирные имена типа 1: , 2: , и так далее, и переходить к ближайшей предыдущей или следующей метке с заданным номером инструкциями вроде jmp 1b и jmp 1f . Это довольно удобно, так как иногда бывает трудно придумать меткам осмысленные имена. Подробности можно найти здесь.

Условные переходы обычно осуществляются при помощи инструкции cmp, которая сравнивает два своих операнда и выставляет соответствующие флаги, за которой следует инструкция из семейства je, jg и подобных:

je 1f # перейти, если равны (equal)
jl 1f # перейти, если знаково меньше (less)
jb 1f # перейти, если беззнаково меньше (below)
jg 1f # перейти, если знаково больше (greater)
ja 1f # перейти, если беззнаково больше (above)

Существует также инструкции jne (перейти, если не равны), jle (перейти, если знаково меньше или равны), jna (перейти, если беззнаково не больше) и подобные. Принцип их именования, надеюсь, очевиден. Вместо je / jne часто пишут jz / jnz, так как инструкции je / jne просто проверяют значение ZF. Также есть инструкции, проверяющие другие флаги — js, jo и jp, но на практике они используются редко. Все эти инструкции вместе взятые обычно называют jcc. То есть, вместо конкретных условий пишутся две буквы «c», от «condition». Здесь можно найти хорошую сводную таблицу по всем инструкциям jcc и тому, какие флаги они проверяют.

Помимо cmp также часто используют инструкцию test:

Fun fact! Интересно, что cmp и test в душе являются теми же sub и and, только не изменяют своих операндов. Это знание можно использовать для одновременного выполнения sub или and и условного перехода, без дополнительных инструкций cmp или test.

Еще из инструкций, связанных с условными переходами, можно отметить следующие.

Инструкция jrcxz осуществляет переход только в том случае, если значение регистра rcx равно нулю.

Инструкции семейства cmovcc (conditional move) работают как mov, но только при выполнении заданного условия, по аналогии с jcc.

Инструкции setcc присваивают однобайтовому регистру или байту в памяти значение 1, если заданное условие выполняется, и 0 иначе.

Сравнить rax с заданным куском памяти. Если равны, выставить ZF и сохранить по указанному адресу значение указанного регистра, в данном примере rcx. Иначе очистить ZF и загрузить значение из памяти в rax. Также оба операнда могут быть регистрами.

Инструкция cmpxchg8b главным образом нужна в x86. Она работает аналогично cmpxchg, только производит compare and swap сразу 8-и байт. Регистры edx:eax используются для сравнения, а регистры ecx:ebx хранят то, что мы хотим записать. Инструкция cmpxchg16b по тому же принципу производит compare and swap сразу 16-и байт на x64.

Важно! Примите во внимание, что без префикса lock все эти compare and swap инструкции не атомарны.

Инструкция loop уменьшает значение регистра rcx на единицу, и если после этого rcx != 0 , осуществляет переход на заданную метку. Инструкции loopz и loopnz работают аналогично, только условия более сложные — (rcx != 0) && (ZF == 1) и (rcx != 0) && (ZF == 0) соответственно.

Не нужно быть семи пядей во лбу, чтобы изобразить при помощи этих инструкций конструкцию if-then-else или циклы for / while, поэтому двигаемся дальше.

«Строковые» операции

Рассмотрим следующий кусок кода:

В регистры rsi и rdi кладутся адреса двух строк. Командой cld очищается флаг направления (DF). Инструкция, выполняющая обратное действие, называется std. Затем в дело вступает инструкция cmpsb. Она сравнивает байты (%rsi) и (%rdi) и выставляет флаги в соответствии с результатом сравнения. Затем, если DF = 0, rsi и rdi увеличиваются на единицу (количество байт в том, что мы сравнивали), иначе — уменьшаются. Аналогичные инструкции cmpsw, cmpsl и cmpsq сравнивают слова, длинные слова и четверные слова соответственно.

Инструкции cmps интересны тем, что могут использоваться с префиксом rep, repe (repz) и repne (repnz). Например:

Префикс rep повторяет инструкцию заданное в регистре rcx количество раз. Префиксы repz и repnz делают то же самое, но только после каждого выполнения инструкции дополнительно проверяется ZF. Цикл прерывается, если ZF = 0 в случае c repz и если ZF = 1 в случае с repnz. Таким образом, приведенный выше код проверяет равенство двух буферов одинакового размера.

Аналогичные инструкции movs перекладывает данные из буфера, адрес которого указан в rsi, в буфер, адрес которого указан в rdi (легко запомнить — rsi значит source, rdi значит destination). Инструкции stos заполняет буфер по адресу из регистра rdi байтами из регистра rax (или eax, или ax, или al, в зависимости от конкретной инструкции). Инструкции lods делают обратное действие — копируют байты по указанному в rsi адресу в регистр rax. Наконец, инструкции scas ищут байты из регистра rax (или соответствующих регистров меньшего размера) в буфере, адрес которого указан в rdi. Как и cmps, все эти инструкции работают с префиксами rep, repz и repnz.

На базе этих инструкций легко реализуются процедуры memcmp, memcpy, strcmp и подобные. Интересно, что, например, для обнуления памяти инженеры Intel рекомендуют использовать на современных процессорах rep stosb , то есть, обнулять побайтово, а не, скажем, четверными словами.

Работа со стеком и процедуры

Со стеком все очень просто. Инструкция push кладет свой аргумент на стек, а инструкция pop извлекает значение со стека. Например, если временно забыть про инструкцию xchg, то поменять местами значение двух регистров можно так:

Существуют инструкции, помещающие на стек и извлекающие с него регистр rflags / eflags:

А так, к примеру, можно получить значение флага CF:

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

Процедуры, как правило, «создаются» при помощи инструкций call и ret. Инструкция call кладет на стек адрес следующей инструкции и передает управление по указанному в аргументе адресу. Инструкция ret читает со стека адрес возврата и передает по нему управление. Например:

someproc :
# типичный пролог процедуры
# для примера выделяем 0x10 байт на стеке под локальные переменные
# rbp — указатель на фрейм стека
push % rbp
mov % rsp , % rbp
sub $ 0x10 , % rsp

# тут типа какие-то вычисления .
mov $ 1 , % rax

# типичный эпилог процедуры
add $ 0x10 , % rsp
pop % rbp

# выход из процедуры
ret

_start :
# как и в случае с jmp, адрес перехода может быть в регистре
call someproc
test % rax , % rax
jnz error

Примечание: Аналогичный пролог и эпилог можно написать при помощи инструкций enter $ 0x10 , $ 0 и leave . Но в наше время эти инструкции используются редко, так как они выполняются медленнее из-за дополнительной поддержки вложенных процедур.

Как правило, возвращаемое значение передается в регистре rax или, если его размера не достаточно, записывается в структуру, адрес которой передается в качестве аргумента. К вопросу о передаче аргументов. Соглашений о вызовах существует великое множество. В одних все аргументы всегда передаются через стек (отдельный вопрос — в каком порядке) и за очистку стека от аргументов отвечает сама процедура, в других часть аргументов передается через регистры, а часть через стек, и за очистку стека от аргументов отвечает вызывающая сторона, плюс множество вариантов посередине, с отдельными правилами касательно выравнивания аргументов на стеке, передачи this, если это ООП язык, и так далее. В общем случае для произвольно взятой архитектуры, компилятора и языка программирования соглашение о вызовах может быть вообще каким угодно.

Для примера рассмотрим ассемблерный код, сгенерированный CLang 3.8 для простой программки на языке C под x64. Так выглядит одна из процедур:

Дизассемблерный листинг (при компиляции с -O0 , комментарии мои):

# типичный пролог процедуры
# регистр rsp не изменяется, так как процедура не вызывает никаких
# других процедур
400950: 55 push %rbp
400951: 48 89 e5 mov %rsp,%rbp

# инициализация локальных переменных:
# -0x08(%rbp) — const unsigned char *data (8 байт)
# -0x10(%rbp) — const size_t data_len (8 байт)
# -0x14(%rbp) — unsigned int hash (4 байта)
# -0x18(%rbp) — int i (4 байта)
400954: 48 89 7d f8 mov %rdi,-0x8(%rbp)
400958: 48 89 75 f0 mov %rsi,-0x10(%rbp)
40095c: c7 45 ec 4b 43 41 48 movl $0x4841434b,-0x14(%rbp)
400963: c7 45 e8 00 00 00 00 movl $0x0,-0x18(%rbp)

# rax := i. если достигли data_len, выходим из цикла
40096a: 48 63 45 e8 movslq -0x18(%rbp),%rax
40096e: 48 3b 45 f0 cmp -0x10(%rbp),%rax
400972: 0f 83 28 00 00 00 jae 4009a0 <hash+0x50>

# eax := (hash << 5) + hash
400978: 8b 45 ec mov -0x14(%rbp),%eax
40097b: c1 e0 05 shl $0x5,%eax
40097e: 03 45 ec add -0x14(%rbp),%eax

# eax += data[i]
400981: 48 63 4d e8 movslq -0x18(%rbp),%rcx
400985: 48 8b 55 f8 mov -0x8(%rbp),%rdx
400989: 0f b6 34 0a movzbl (%rdx,%rcx,1),%esi
40098d: 01 f0 add %esi,%eax

# hash := eax
40098f: 89 45 ec mov %eax,-0x14(%rbp)

# i++ и перейти к началу цикла
400992: 8b 45 e8 mov -0x18(%rbp),%eax
400995: 83 c0 01 add $0x1,%eax
400998: 89 45 e8 mov %eax,-0x18(%rbp)
40099b: e9 ca ff ff ff jmpq 40096a <hash+0x1a>

# возвращаемое значение (hash) кладется в регистр eax
4009a0: 8b 45 ec mov -0x14(%rbp),%eax

# типичный эпилог
4009a3: 5d pop %rbp
4009a4: c3 retq

Здесь мы встретили две новые инструкции — movs и movz. Они работают точно так же, как mov, только расширяют один операнд до размера второго, знаково и беззнаково соответственно. Например, инструкция movzbl (%rdx,%rcx,1),%esi читайт байт (b) по адресу (%rdx,%rcx,1) , расширяет его в длинное слово (l) путем добавления в начало нулей (z) и кладет результат в регистр esi.

Как видите, два аргумента были переданы процедуре через регистры rdi и rsi. По всей видимости, используется конвенция под названием System V AMD64 ABI. Утверждается, что это стандарт де-факто под x64 на *nix системах. Я не вижу смысла пересказывать описание этой конвенции здесь, заинтересованные читатели могут ознакомиться с полным описанием по приведенной ссылке.

Заключение

Само собой разумеется, в рамках одной статьи, описать весь ассемблер x86/x64 не представляется возможным (более того, я не уверен, что сам знаю его прямо таки весь). Как минимум, за кадром остались такие темы, как операции над числами с плавающей точкой, MMX-, SSE- и AVX-инструкции, а также всякие экзотические инструкции вроде lidt, lgdt, bswap, rdtsc, cpuid, movbe, xlatb, или prefetch. Я постараюсь осветить их в следующих статьях, но ничего не обещаю. Следует также отметить, что в выводе objdump -d для большинства реальных программ вы очень редко увидите что-то помимо описанного выше.

Еще интересный топик, оставшийся за кадром — это атомарные операции, барьеры памяти, спинлоки и вот это все. Например, compare and swap часто реализуется просто как инструкция cmpxchg с префиксом lock. По аналогии реализуется атомарный инкремент, декремент, и прочее. Увы, все это тянет на тему для отдельной статьи.

В качестве источников дополнительной информации можно рекомендовать книгу Modern X86 Assembly Language Programming, и, конечно же, мануалы от Intel. Также довольно неплоха книга x86 Assembly на wikibooks.org.

Из онлайн-справочников по ассемблерным инструкциям стоит обратить внимание на следующие:

А знаете ли вы ассемблер, и если да, то находите ли это знание полезным?

Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.

Регистры процессора

Начиная с модели 80386 процессоры Intel предоставляют 16 основных регистров для пользовательских программ и ещё 11 регистров для работы с мультимедийными приложениями (MMX) и числами с плавающей точкой (FPU/NPX). Все команды так или иначе изменяют содержимое регистров. Как уже говорилось, обращаться к регистрам быстрее и удобнее, чем к памяти. Поэтому при программировании на языке Ассемблера регистры используются очень широко.

В этом разделе мы рассмотрим основные регистры процессоров Intel. Названия и состав/количество регистров для других процессоров могут отличаться. Итак, основные регистры процессоров Intel.

Таблица 2.1. Основные регистры процессора.

Название Разрядность Основное назначение
EAX 32 Аккумулятор
EBX 32 База
ECX 32 Счётчик
EDX 32 Регистр данных
EBP 32 Указатель базы
ESP 32 Указатель стека
ESI 32 Индекс источника
EDI 32 Индекс приёмника
EFLAGS 32 Регистр флагов
EIP 32 Указатель инструкции (команды)
CS 16 Сегментный регистр
DS 16 Сегментный регистр
ES 16 Сегментный регистр
FS 16 Сегментный регистр
GS 16 Сегментный регистр
SS 16 Сегментный регистр

Регистры EAX, EBX, ECX, EDX – это регистры общего назначения. Они имеют определённое назначение (так уж сложилось исторически), однако в них можно хранить любую информацию.

Регистры EBP, ESP, ESI, EDI – это также регистры общего назначения. Они имеют уже более конкретное назначение. В них также можно хранить пользовательские данные, но делать это нужно уже более осторожно, чтобы не получить «неожиданный» результат.

Регистр флагов и сегментные регистры требуют отдельного описания и будут более подробно рассмотрены далее.

Пока для вас здесь слишком много непонятных слов, но со временем всё прояснится)))

Когда-то процессоры были 16-разрядными, и, соответственно, все их регистры были также 16-разрядными. Для совместимости со старыми программами, а также для удобства программирования некоторые регистры разделены на 2 или 4 «маленьких» регистра, у каждого из которых есть свои имена. В таблице 2.2 перечислены такие регистры.

Вот пример такого регистра.

Из этого следует, что вы можете написать в своей программе, например, такие команды: Обе команды поместят в регистр AX число 1. Разница будет заключаться только в том, что вторая команда обнулит старшие разряды регистра EAX, то есть после выполнения второй команды в регистре EAX будет число 1. А первая команда оставит в старших разрядах регистра EAX старые данные. И если там были данные, отличные от нуля, то после выполнения первой команды в регистре EAX будет какое-то число, но не 1. А вот в регистре AX будет число 1. Сложно? Ну это пока… Со временем вы к таким вещам привыкните.

Мы пока не говорили о разрядах (битах). Эту тему мы обсудим в разделах, посвящённых системам счисления. А сейчас пока вам достаточно знать, что нулевой разряд (бит) – это младший бит. Он крайний справа. Старший бит – крайний слева. Номер старшего бита зависит от разрядности числа/регистра. Например, в 32-разрядном регистре старшим битом является 31-й бит (потому что отсчёт начинается с 0, а не с 1).

Ниже приведён список регистров общего назначения, которые можно поделить описанным выше способом и при этом к «половинкам» и «четвертинкам» этих регистров можно обращаться в программе как к отдельному регистру.

Таблица 2.2. «Делимые» регистры..

Регистр Старшие разряды Имена 16-ти и 8-ми битных регистров
31…16 15…8 7…0
EAX . AX
AH AL
EBX . BX
BH BL
ECX . CX
CH CL
EDX . DX
DH DL
ESI . SI
EDI . DI
EBP . BP
ESP . SP
EIP . IP

На этом мы закончим наше краткое знакомство с регистрами. Если вам пока не всё понятно – просто прочитайте этот раздел, чтобы более-менее представлять себе, что такое регистры. По мере приобретения новых знаний вы можете вернуться к этому разделу и уже на новом уровне воспринять эту информацию. А в следующем разделе мы коротко опишем процесс выполнения команды.

Читать:
Как сделать задержку в python

Похожие статьи