[Перевод] Знакомимся с программированием на ассемблере x86

image-loader.svg


Архитектура x86 лежит в сердце процессоров, на которых уже более двух десятилетий работают наши домашние компьютеры и удаленные серверы. Умение читать и писать код на низкоуровневом языке ассемблера — это очень весомый навык. Он позволяет создавать более быстрый код, использовать недоступные в Си возможности машин и выполнять реверс-инжиниринг скомпилированного кода.

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

В этом руководстве я помогу вам сформировать устойчивое понимание x86 ISA на основе базовых принципов. Здесь мы сосредоточимся на построении понятной ментальной модели происходящего, не акцентируясь на деталях (что заняло бы много времени и оказалось скучным чтивом). Если вы хотите в итоге применять эти знания, то лучше будет заиметь под рукой список инструкций ЦПУ, а также параллельно изучать какое-нибудь другое руководство, которое научит писать и компилировать простые функции. В отличие от другой документации, которая обычно вываливает на вас всю информацию скопом, свой урок я начну с уже знакомой всем территории и буду постепенно повышать его сложность.

Для понимания содержания статьи вам потребуется навык работы с двоичными числами, некоторый опыт в программировании на императивном языке (С/С++/Java/Python/ и т.д.), а также понимание принципа работы указателей памяти (С/С++). При этом знать внутреннее устройство ЦПУ или иметь опыт работы с ассемблером не обязательно.

Содержание


  1. Инструменты и тестирование
  2. Базовая среда выполнения
  3. Базовые арифметические инструкции
  4. Регистр флагов и операции сравнения
  5. Работа с памятью
  6. Переходы, метки и машинный код
  7. Стек
  8. Соглашение о вызовах
  9. Повторяемые строковые инструкции
  10. Плавающая точка и SIMD
  11. Виртуальная память
  12. 64-битный режим
  13. Сравнение с другими архитектурами
  14. Обобщение
  15. Дополнительные материалы


1. Инструменты и тестирование


Параллельно с чтением будет полезно также писать и тестировать ваши собственные программы ассемблера. Проще всего это делать под Linux (менее удобно под Windows). Вот образец функции на ассемблере:

.globl myfunc
myfunc:
    retl


Сохраните ее в файле my-asm.s и скомпилируйте командой gcc -m32 -c -o my-asm.o my-asm.s. Пока что выполнить этот код у нас возможности нет, потому что для этого потребуется либо взаимодействие с программой Си, либо написание шаблонного кода для взаимодействия с ОС для обработки начала/вывода/остановки/и т.д. По меньшей мере, возможность скомпилировать код дает нам способ убедиться в синтаксической верности наших программ ассемблера.

Имейте ввиду, что в моем руководстве используется синтаксис AT&T, а не Intel. Отличаются они только нотацией, внутренние же принципы работы остаются одинаковыми. При этом всегда можно механически перевести программу из одного синтаксиса в другой, так что беспокоиться особо не о чем.

2. Базовая среда выполнения


image-loader.svg


В ЦПУ х86 есть восемь 32-битных универсальных регистров. По историческим причинам они имеют следующие названия: {eax, ecx, edx, ebx, esp, ebp, esi, edi}. (В других архитектурах ЦПУ они называются просто r0, r1, …, r7). Каждый из них может содержать любое 32-битное целочисленное значение. Вообще, в архитектуре x86 есть более сотни регистров, но мы разберем только необходимые нам.

Если говорить в общих чертах, то ЦПУ последовательно выполняет набор инструкций в порядке, указанном в исходном коде. Чуть позже мы увидим, как код может сойти с линейного маршрута, когда будем разбирать такие принципы, как if-then, циклы и вызовы функций.

image-loader.svg


По факту мы имеем восемь 16-битных и восемь 8-битных регистров, являющихся частью восьми 32-битных универсальных регистров. Эти элементы происходят из 16-битной эпохи процессоров x86, но все еще иногда применяются в 32-битном режиме. 16-битные регистры называются {ax, cx, dx, bx, sp, bp, si, di} и представляют младшие 16 бит соответствующих 32-битных регистров {eax, ecx, ..., edi} (префикс e означает «расширенный»). 8-битные регистры именуются {al, cl, dl, bl, ah, ch, dh, bh} и представляют младшие и старшие 8 бит регистров {ax, cx, dx, bx}. Когда значение 16-битного или 8-битного регистра изменяется, старшие биты, принадлежащие полному 32-битному регистру остаются неизменными.

3. Базовые арифметические инструкции


Основные арифметические инструкции x86 оперируют с 32-битными регистрами. Первый операнд выступает в качестве источника, а второй в качестве источника и точки назначения. Например: addl %ecx, %eax — в нотации Си означает: eax = eax + ecx;, где eax и ecx имеют тип uint_32.

Этой важной схеме следуют многие инструкции, например:

  • xorl %esi, %ebp означает ebp = ebp ^ esi;.
  • subl %edx, %ebx означает ebx = ebx - edx;.
  • andl %esp, %eax означает eax = eax & esp;.


Некоторые инструкции получают в качестве аргумента только один регистр, например:

  • notl %eax означает eax = ~eax;.
  • incl %ecx означает ecx = ecx + 1;.


Инструкции сдвига и вращения получают 32-битный регистр со сдвигаемым значением и фиксированный 8-битный регистр сl, указывающий количество сдвигов.

Например: shll %cl, %ebx означает ebx = ebx << cl;.

Многие арифметические инструкции могут получать в качестве первого операнда непосредственное значение. Это значение является фиксированным (не переменным) и кодируется в саму инструкцию.

Непосредственные значения сопровождаются приставкой $. Вот примеры:

  • movl $0xFF, %esi означает esi = 0xFF;.
  • addl $-2, %edi означает edi = edi + (-2);.
  • shrl $3, %edx означает edx = edx >> 3;.


Обратите внимание, что инструкция movl копирует значение из первого аргумента во второй (она не производит конкретно «перемещение», но называется именно так). В случае регистров, например movl %eax, %ebx, это означает копирование значения регистра eax в ebx (что приводит к перезаписи имеющегося значения ebx).

Сейчас будет кстати разобрать один из принципов программирования на ассемблере: «Не каждая желаемая операция может быть непосредственно выражена в одной инструкции. В типичных языках программирования многие конструкции являются компонуемыми и подстраиваются под разные ситуации, а арифметика может быть вложенной. Тем не менее в ассемблере можно прописать только то, что позволяет набор инструкций. Покажу на примерах:
  • Нельзя складывать две непосредственные константы, хотя в Си это допускается. В ассемблере мы либо вычисляем значение во время компиляции, либо выражаем его как последовательность инструкций.
  • В одной инструкции можно сложить содержимое двух 32-битных регистров, но нельзя сложить значения трех — потребуется разбить такую инструкцию на две.
  • Нельзя прибавлять содержимое 16-битного регистра к содержимому 32-битного. Нужно будет написать одну инструкцию для выполнения преобразования из 16 в 32 бита, и еще одну для выполнения сложения.
  • При выполнении битового сдвига количество сдвигов должно быть либо жестко прописанным непосредственным значением, либо задаваться регистром cl. Если количество сдвигов находилось в другом регистре, тогда это значение нужно сначала скопировать в cl.


Из всего этого следует, что вам не нужно стараться угадывать или изобретать несуществующие синтаксические конструкции (такие как addl %eax, %ebx, %ecx). Также, если вам не удается найти необходимую инструкцию в огромном списке поддерживаемых, тогда нужно реализовать ее вручную как последовательность имеющихся инструкций (и, возможно, выделить регистры для хранения промежуточных значений).

4. Регистр флагов и операции сравнения


image-loader.svg


Среди прочих, нам доступен 32-битный регистр eflags, который неявно считывается или записывается во многих инструкциях. Другими словами, его значение играет роль в выполнении инструкции, но сам этот регистр в коде ассемблера не упоминается.

Арифметические инструкции вроде addl обычно обновляют eflags на основе результата вычислений. Инструкция устанавливает или снимает флаги вроде переноса (CF), переполнения (OF), знаковый (SF), четности (PF), нуля (ZF) и т.д. Некоторые инструкции считывают эти флаги — например, adcl складывает два числа и использует флаг переноса в качестве третьего операнда: adcl %ebx, %eax означает eax = eax + ebx + cf;. Некоторые инструкции устанавливают на основе флага регистр — например, setz %al устанавливает 8-битный регистр al на 0, если ZF неактивен, или на 1, если ZF установлен. Некоторые инструкции напрямую влияют на один бит флага, например cld, очищающая флаг направления (DF).

Инструкции сравнения влияют на eflags, не меняя никаких универсальных регистров. Например, cmpl %eax, %ebx выполнит сравнение значений двух регистров путем их вычитания в неименованной временной области и установит флаги согласно результату, что позволит вам в беззнаковом или знаковом режиме понять, является ли eax < ebx либо eax == ebx, либо eax > ebx. Аналогичным образом, testl %eax, %ebx вычисляет eax & ebx во временной области и устанавливает соответствующие флаги. В большинстве случаев инструкция, следующая за сравнением, является условным переходом (рассмотрим позже).

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

5. Работа с памятью


Одного только процессора для эффективной работы компьютера будет недостаточно. Наличие всего 8 регистров данных сильно ограничивает объем вычислений ввиду невозможности хранения большого количества информации. Для увеличения вычислительного потенциала процессора у нас есть ОЗУ, представляющее обширную системную память. По сути, ОЗУ представляет собой огромный массив байт — например, 128МиБ ОЗУ — это 134,217,728 байт, в которых можно хранить значения.

image-loader.svg


При сохранении значения размером больше байта оно кодируется в прямом порядке байтов. Например, если 32-битный регистр содержит значение 0xDEADBEEF, и этот регистр нужно сохранить в памяти по адресу 10, тогда значение байта 0xEF отправляется в адрес ОЗУ 10, 0xBE в адрес 11, 0xAD в адрес 12, а 0xDE в адрес 13. То же правило работает и при считывании значений из памяти — байты из нижних адресов памяти загружаются в нижние части регистра.

image-loader.svg


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

  • movb (%ecx), %al означает al = *ecx;. (считывает байт по адресу памяти ecx в 8-битный регистр al).
  • movb %bl, (%edx) означает *edx = bl;. (записывает байт из bl в байт по адресу памяти edx).
  • (в типичном коде Си регистры al и bl имеют тип uint8_t, а ecx и edx приводятся из uint32_t в uint8_t*.)


Далее, многие арифметические инструкции могут получать один операнд памяти (никогда два). Например:

  • addl (%ecx), %eax означает eax = eax + (*ecx);. (считывает из памяти 32 бита).
  • addl %ebx, (%edx) означает *edx = (*edx) + ebx;. (считывает и записывает 32 в память).


Режимы адресации


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

Это будет проще показать, чем объяснять:

  • movb (%eax,%ecx), %bh означает bh = *(eax + ecx);.
  • movb -10(%eax,%ecx,4), %bh означает bh = *(eax + (ecx * 4) - 10);.


Здесь формат адреса смещение (основа, индекс, масштаб), где смещение — это целочисленная константа (может быть положительной, отрицательной или нулевой), основа и индекс — это 32-битные регистры (но некоторые комбинации запрещены), а масштаб — это одно из значений {1,2,4,8}. К примеру, если массив содержит набор 64-битный целых, то мы будем использовать масштаб равный 8, поскольку каждый элемент имеет длину 8 байт.

Режимы адресации памяти допустимы везде, где разрешен операнд памяти. Таким образом, если вы можете написать sbbl %eax, (%eax), и вам нужна возможность индексации, то вы определенно можете написать sbbl %eax, (%eax,%eax,2). Также имейте ввиду, что вычисляемый адрес является временным значением, которое не сохраняется ни в каком регистре. Это хорошо, потому что если вы захотите вычислить этот адрес явно, то нужно будет выделить под него регистр, а наличие всего 8 универсальных регистров не позволяет особо разгуляться, когда вам нужно сохранять и другие переменные.

Есть одна специальная инструкция, которая использует адресацию памяти, но по факту к ней не обращается. Инструкция leal (загрузка действительного адреса) вычисляет заключительный адрес памяти согласно режиму адресации и сохраняет результат в регистре. К примеру, leal 5(%eax,%ebx,8), %ecx означает ecx = eax + ebx*8 + 5;. Заметьте, что это полностью арифметическая операция, которая не включает разыменовывание адреса памяти.

6. Переходы, метки и машинный код


Каждую инструкцию в ассемблере можно предварить нужным числом меток. Эти метки пригодятся, когда потребуется перейти к определенной инструкции. Вот несколько примеров:

foo:  /* Метка */
negl %eax  /* Одна метка */

addl %eax, %eax  /* Нет меток */

bar: qux: sbbl %eax, %eax  /* Две метки */


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

top: incl %ecx
jmp top


Несмотря на то, что jmp условия не имеет, у нее есть родственные инструкции, которые смотрят на состояние eflags и переходят либо к метке (при выполнении условия), либо к очередной инструкции ниже. К инструкциям с условным переходом относятся: ja (перейти, если больше), jle (перейти, если меньше либо равно), jo (перейти, если переполнение), jnz (перейти, если не нуль) и так далее.

Всего таких инструкций 16, и у некоторых есть синонимы — например jz (перейти, если нуль) равнозначна je (перейти, если равно), ja (перейти, если больше) равнозначна jnbe (перейти, если не меньше или равно).

Вот пример использования условного перехода:

jc skip  /* Если флаг переноса активен, перейти */
/* В противном случае выполнить это */
notl %eax
/* Неявно попадает в следующую инструкцию */
skip:
adcl %eax, %eax


Адреса меток фиксируются в коде при его компиляции, но также можно переходить к произвольному адресу памяти, вычисляемому в среде выполнения. В частности, можно перейти к значению регистра: jmp *%ecx, по сути, означает «скопировать значение ecx в eip».

image-loader.svg


Теперь самое время обсудить принцип, касающийся инструкций и выполнения, о котором я заикнулся еще в первом разделе. В ассемблере каждая инструкция в конечном итоге преобразуется в 1–15 байт машинного кода, после чего эти машинные инструкции компонуются вместе, образуя исполняемый файл. У процессора есть 32-битный регистр eip (расширенный указатель инструкции), который во время выполнения программы хранит адрес памяти текущей обрабатываемой инструкции. Имейте ввиду, что есть очень мало способов для считывания/записи регистра eip, в связи с чем он работает не так, как 8 основных универсальных регистров. При каждом выполнении инструкции процессор знает ее длину в байтах и продвигает eip на это число, чтобы он указывал на следующую инструкцию.

Пока мы говорим о машинном коде, стоит добавить, что ассемблер на деле не является самым нижним уровнем, до которого может добраться программист. Самым фундаментом выступает сырой двоичный машинный код. (Инсайдеры Intel имеют доступ к еще более низким уровням, таким как отладка пайплайна и микрокод –, но обычным программистам туда не попасть). Писать машинный код вручную — задача не из легких (да и вообще, писать на ассемблере уже непросто), но это дает пару выгодных возможностей. При написании машинного кода можно кодировать некоторые инструкции альтернативными способами (например, использовать удлиненную последовательность байт, которая будет иметь тот же эффект при выполнении), а также намеренно генерировать недействительные инструкции для проверки поведения ЦПУ (не все ЦПУ обрабатывают ошибки одинаково).

7. Стек


Стек — это область памяти, адресуемая регистром esp. В x86 ISA есть ряд инструкций для управления стеком. Несмотря на то, что всю эту функциональность можно реализовать посредством movl, addl, … и т.д. с помощью других регистров, подход с использованием инструкций стека оказывается более идиоматичным и кратким.

В архитектуре x86 стек растет вниз, от больших адресов памяти в направлении меньших. К примеру, добавление 32-битного значения в стек подразумевает уменьшение esp на 4 с последующим помещением этого 4-байтового значения в область памяти, начиная с адреса esp. Извлечение значения подразумевает обратные операции — загрузку 4 байтов, начинающихся с адреса esp (либо в заданный регистр, либо отбрасывание), и увеличение esp на 4.

Стек очень важен для вызовов функций. Инструкция call подобна jmp, за исключением того, что перед переходом она сначала помещает в стек адрес следующей инструкции. Таким образом, можно вернуться к выполнению инструкции retl, которая извлекает адреса в eip. Кроме того, стандартное соглашение о вызовах в Си помещает некоторые или все аргументы функций в стек.

Имейте ввиду, что память стека можно использовать для чтения/записи регистра eflags и считывания регистра eip. Обращаться к этим двум регистрам неудобно, поскольку они не могут быть использованы в типичной movl или в арифметических инструкциях.

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


Когда мы компилируем код Си, он переводится в код ассемблера и в последствии в машинный код. Соглашение о вызовах определяет то, как функции Си получают аргументы и возвращают значения, помещая значения в стек и/или в регистры. Это соглашение применяется к функции Си, вызывающей другую функцию Си, фрагменту кода ассемблера, вызывающему функцию Си, либо функции Си, вызывающей функцию ассемблера. (Оно не применяется к фрагменту кода ассемблера, вызывающему произвольный фрагмент кода ассемблера; в этом случае ограничения отсутствуют).

В 32-битной системе x86 под Linux соглашение о вызовах называется cdecl. Вызывающий функцию компонент справа налево помещает аргументы в стек, вызывает целевую функцию, получает возвращаемое значение в eax и извлекает аргументы из стека.

Например:

int main(int argc, char **argv) {
  print("Hello", argc);
  /*
  Вышеприведенный вызов print() преобразуется в код ассемблера:
  
  pushl %registerContainingArgc
  pushl $ADDRESS_OF_HELLO_STRING_CONSTANT
  call print
  // Получение результата в %eax
  popl %ecx  // Извлечение аргумента str
  popl %ecx  // Извлечение аргумента foo
  */
}

int print(const char *str, int foo) {
  ....
  /*
  В ассемблере эти 32-битные значения существуют в стеке:
    0(%esp) содержит адрес очередной инструкции вызывающего.
    4(%esp) содержит значение аргумента str (указатель char).
    8(%esp) содержит значение аргумента foo (знаковое целое).
  Прежде, чем функция выполнит retl, ей нужно поместить какое-нибудь число в %eax в качестве возвращаемого.
  */
}


9. Повторяемые строковые инструкции


Есть ряд инструкций, которые упрощают обработку длинных последовательностей байтов/слов и неформально относятся к разряду «строковых» инструкций. Каждая такая инструкция использует в качестве адресов памяти регистры esi и edi и автоматически инкрементирует/декрементирует их после выполнения инструкции. Например, movsb %esi, %edi означает *edi = *esi; esi++; edi++; (копирует один байт). (По факту esi и edi инкрементируются, если DF равен 0; если же он равен 1, то они декрементируются). К примерам других строковых инструкций относятся cmpsb, scasb, stosb.

Строковую инструкцию можно изменить с помощью приставки rep (сюда же относятся repe и repne), чтобы она выполнялась ecx раз (при автоматическом уменьшении ecx). К примеру, rep movsb %esi, %edi означает:

while (ecx > 0) {
  *edi = *esi;
  esi++;
  edi++;
  ecx--;
}


Эти строковые инструкции и приставки rep привносят в ассемблер некоторые итерируемые составные операции. Они отражают часть парадигмы дизайна CISC, где для программистов считается нормальным писать код прямо на ассемблере, и предоставляют более высокоуровневые возможности для упрощения работы. (Однако современным решением считается писать код на Си или даже более высокоуровневом языке, а генерацию муторного кода ассемблера поручать компилятору).

10. Плавающая точка и SIMD


Математический сопроцессор x87 имеет восемь 80-битных регистров с плавающей точкой (но вся функциональность x87 сейчас уже встроена в основной ЦПУ x86), и у процессора x86 также есть восемь 128-битных регистров xmm для инструкций SSE. У меня мало опыта работы с FP/x87, так что по этой теме вам стоит обратиться к другим руководствам. Стек в x87 FP работает несколько странным образом, и сегодня удобнее выполнять арифметику с плавающей точкой, используя вместо этого регистры xmm и инструкции SSE/SSE2.

Регистр xmm можно интерпретировать по-разному, в зависимости от выполняемой инструкции: как 16-байтовые значения, как 16-битные слова, как четыре 32-битных двойных слова или числа одинарной точности с плавающей точкой. Например, одна инструкция SSE копирует 16 байтов (128 бит) из памяти в регистр xmm, а другая инструкция SSE складывает содержимое двух регистров xmm, рассматривая каждый как восемь параллельных 16-битных слов. Идея SIMD состоит в выполнении одной инструкции для одновременной обработки большого количества данных, что оказывается быстрее, чем обработка каждого значения по-отдельности, поскольку запрос и выполнение каждой инструкции вносит определенную нагрузку.

Отмечу, что все операции SSE/SIMD можно эмулировать с меньшей скоростью, используя базовые скалярные операции (например, 32-битную арифметику, рассмотренную в разделе 3). Осторожный программист может создать прототип программы с использованием скалярных операций, оценить ее корректность и постепенно преобразовать под использование более скоростных инструкций SSE, обеспечив получение тех же результатов.

11. Виртуальная память


До этого момента мы предполагали, что когда инструкция запрашивает считывание из/запись в адрес памяти, то это будет адрес, обрабатываемый ОЗУ. Но, если мы добавим в промежутке переводящий слой, то сможем выполнять интересные действия. Этот принцип известен как виртуальная память, пейджинг и под другими именами.

Основная идея в том, что у нас есть таблица страниц, которая описывает, с чем сопоставлена каждая страница (блок) из 4096 байтов 32-битного виртуального адресного пространства. Например, если страница ни с чем не сопоставлена, то попытка считать/записать адрес памяти на эту страницу вызовет прерывание/исключение/ловушку. Либо, к примеру, тот же виртуальный адрес 0x08000000 можно сопоставить с другой страницей физической ОЗУ в каждом запущенном процессе приложения. Кроме того, каждый процесс может иметь собственный набор страниц и никогда не видеть содержимое других процессов или ядра операционной системы. Принцип пейджинга, по большому счету, относится к сфере написания ОС, но его поведение иногда затрагивает и разработчиков приложений, поэтому им стоит о нем знать.

Имейте ввиду, что отображение адресов не обязательно должно происходить по схеме 32 бита в 32 бита. Например, 32 бита виртуального адресного пространства можно сопоставить с 36 битами области физической памяти (PAE). Либо 64-битное виртуальное адресное пространство можно сопоставить с 32 битами области физической памяти на компьютере, имеющем всего 1ГиБ ОЗУ.

12. 64-битный режим


image-loader.svg


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

Из наиболее очевидного — 8 универсальных регистров были расширены до 64 бит. Новые регистры получили имена {rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi}, а старые 32-битные {eax, ..., edi} теперь занимают младшие 32 бита вышеупомянутых 64-битных регистров.

Также появилось восемь новых 64-битных регистров {r8, r9, r10, r11, r12, r13, r14, r15}, и общее число универсальных регистров дошло до 16. Это существенно снижает нагрузку при работе с большим числом переменных. У новых регистров также есть подрегистры — например, 64-битный r9 содержит 32-битный r9d, 16-битный r9w и 8-битный r9l. Кроме того, нижний байт {rsp, rbp, rsi, rdi} теперь адресуется как {spl, bpl, sil, dil}.

Арифметические инструкции могут оперировать с 8-, 16-, 32- или 64-битными регистрами. При работе с 32-битными верхние 32 бита очищаются на нуль, но при меньшей ширине операнда все старшие биты остаются неизменными. Многие нишевые инструкции из 64-битного набора были удалены — например, связанные с BCD, большинство инструкций, задействующих 16-битные сегментные регистры, а также добавляющие/извлекающие 32-битные значения из стека.

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

Рассмотренное соглашение о вызовах существенно упрощает извлечение аргументов функций в коде ассемблера, потому что первые ~6 аргументов помещаются не в стек, а в регистры. В остальном принцип работы остался прежним. (Хотя для программистов систем архитектура x86–64 представляет новые режимы, возможности, новые проблемы и новые кейсы для обработки).

13. Сравнение с другими архитектурами


Принцип работы архитектур ЦПУ RISC в некоторых аспектах отличен от x86. Память затрагивают только явные инструкции загрузки/сохранения, обычные арифметические этого не делают. Инструкции имеют фиксированную длину, а именно 2 или 4 байта каждая. Операции с памятью обычно нужно объединять, например загрузка 4-байтового слова должна содержать адрес памяти, кратный 4.

Для сравнения, в x86 ISA операции с памятью встраиваются в арифметические инструкции, инструкции кодируются как последовательности байтов переменной длины, и почти всегда допускается невыравненное обращение к памяти. Кроме того, если в x86 есть полный набор 8-, 16- и 32-битных арифметических операций ввиду обратной совместимости, то архитектуры RISC обычно являются просто 32-битными. Для работы с более короткими значениями они загружают байт или слово из памяти, расширяют его значение на полный 32-битный регистр, выполняют арифметические операции в 32 битах и в завершении сохраняют нижние 8 или 16 бит в памяти. К популярным RISC ISA относятся ARM, MIPS и RISC-V.

Архитектуры VLIW позволяют явно выполнять несколько параллельных подинструкций. К примеру, можно написать add a, b; sub c, d на одной строке, потому что у процессора есть два независимых арифметических блока, работающих одновременно. Процессоры x86 тоже могут выполнять несколько инструкций параллельно (суперскалярная обработка), но инструкции в этом случае не прописываются явно — ЦПУ внутренне анализирует параллелизм в потоке инструкций и распределяет допустимые инструкции по нескольким блокам выполнения.

14. Обобщение


Разбор архитектуры процессоров x86 мы начали с их рассмотрения как простой машины, которая содержит пару регистров и последовательно следует списку инструкций. Мы познакомились с базовыми арифметическими операциями, которые можно выполнять на этих регистрах. Далее мы узнали о переходе к различным участкам кода, о сравнении и условных переходах. После мы разобрали принцип работы ОЗУ как огромного адресуемого хранилища данных, а также поняли, как можно использовать режимы адресации x86 для лаконичного вычисления адресов. В завершении мы кратко рассмотрели принцип работы стека, соглашение о вызовах, продвинутые инструкции, перевод адресов виртуальной памяти и отличия режима x86–64.

Надеюсь, этого руководства было достаточно, чтобы вы сориентировались в общем принципе устройства архитектуры x86. В эту ознакомительную статью мне не удалось вместить очень много деталей — полноценное написание простой функции, отладку распространенных ошибок, эффективное использование SSE/AVX, работу с сегментацией, знакомство с системными структурами данных вроде таблиц страниц и дескрипторов прерываний, да и многое другое. Тем не менее теперь у вас есть устойчивое представление о работе процессора x86, и вы можете приступить к изучению более продвинутых уроков, попробовать написать код с пониманием происходящего внутри и даже решиться полистать чрезвычайно подробные руководства Intel по ЦПУ.

15. Дополнительные материалы

image-loader.svg

© Habrahabr.ru