fbpx

Каталог статей

Каталог статей для размещения статей информационного характера

Как выучить

Проект “Наюки

Фундаментальное введение в программирование на ассемблере x86

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

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

Необходимыми условиями для чтения этого руководства являются работа с двоичными числами, умеренный опыт программирования на императивном языке (C/C++/Java/Python/etc.) и концепция указателей памяти (C/C++). Вам не нужно знать внутреннее устройство процессоров или иметь опыт работы с языком ассемблера.

Содержание

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

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

Сохраните его в файле с именем my-asm.s и скомпилируйте его командой: gc c-m3 2-c -o my-asm.o my-asm.s. На данный момент нет возможности запустить этот код, потому что для этого потребуется либо взаимодействие с программой на C, либо написание кода для взаимодействия с ОС для обработки запуска/печати/остановки/etc. По крайней мере, возможность компиляции кода дает вам возможность проверить синтаксическую правильность ваших программ на ассемблере.

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

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

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

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

На самом деле существует восемь 16-битных и восемь 8-битных регистров, которые являются составными частями регистра e< ax , cx , dx , bx , sp , bp , si , di >. При изменении значения 16-битного или 8-битного регистра старшие биты, принадлежащие полному 32-битному регистру, остаются неизменными.< eax , ecx , . edi >3. Основные арифметические инструкции< al , cl , dl , bl , ah , ch , dh , bh >Самые основные арифметические инструкции x86 работают с двумя 32-битными регистрами. Первый операнд действует как источник, а второй операнд действует как источник и как место назначения. Например: addl %ecx, %eax – в нотации языка Си это означает: eax = eax + ecx; , где eax и ecx имеют тип uint32_t. Многие инструкции соответствуют этой важной схеме – например:< ax , cx , dx , bx >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-битный регистр cl для счета сдвига. Например: shll %cl, %ebx означает ebx = ebx. ~Многие арифметические инструкции могут принимать в качестве первого операнда непосредственное значение. Непосредственное значение является фиксированным (не переменным) и кодируется в самой инструкции. Непосредственные значения имеют префикс $ . Например:

movl $0xFF, %esi означает esi = 0xFF; .

addl $-2, %edi означает edi = edi + (-2); .

3; .

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

Отступление

shrl $3, %edx means edx = edx >>Сейчас самое время поговорить об одном принципе программирования на ассемблере: Не каждая желаемая операция может быть непосредственно выражена одной инструкцией. В типичных языках программирования, которые использует большинство людей, многие конструкции являются составными и адаптируемыми к различным ситуациям, а арифметика может быть вложенной. Однако в языке ассемблера вы можете написать только то, что позволяет набор инструкций. Проиллюстрируем это примерами:

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

Вы можете сложить два 32-битных регистра за одну инструкцию, но вы не можете сложить три 32-битных регистра – вам придется разбить это на две инструкции.

Вы не можете добавить 16-битный регистр к 32-битному регистру. Нужно написать одну инструкцию для выполнения 16-32-битного расширительного преобразования, и еще одну инструкцию для выполнения сложения.

При выполнении сдвига битов счетчик сдвига должен быть либо жестко закодированным непосредственным значением, либо регистром cl . Это не может быть никакой другой регистр. Если счетчик сдвига находился в другом регистре, то его значение нужно сначала скопировать в cl.

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

Арифметические инструкции, такие как 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 в беззнаковом или в знаковом режиме. Аналогично, testl %eax, %ebx вычисляет eax & ebx во временном месте и устанавливает флаги соответственно. Чаще всего инструкция после сравнения представляет собой условный переход (об этом позже).

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

5. Адресация памяти, чтение, запись

Сам по себе центральный процессор не делает компьютер очень полезным. Наличие только 8 регистров данных сильно ограничивает возможности вычислений, поскольку вы не можете хранить много информации. Чтобы дополнить центральный процессор, у нас есть оперативная память, которая является большой системной памятью. По сути, оперативная память представляет собой огромный массив байтов – например, 128 Мбайт оперативной памяти – это 134 217 728 байтов, в которых можно хранить любые значения.

При хранении значения длиннее байта, значение кодируется в little endian. Например, если 32-битный регистр содержит значение 0xDEADBEEF и этот регистр нужно сохранить в памяти, начиная с адреса 10, то значение байта 0xEF попадет в адрес 10 оперативной памяти, 0xBE – в адрес 11, 0xAD – в адрес 12 и, наконец, 0xDE – в адрес 13. При чтении значений из памяти действует то же правило – байты по младшим адресам памяти загружаются в младшие части регистра.< ebx or eax == ebx or eax >Само собой разумеется, что в процессоре есть инструкции для чтения и записи памяти. В частности, вы можете загрузить или сохранить один или несколько байтов по любому адресу памяти. Самое простое, что вы можете сделать с памятью, – это прочитать или записать один байт:

movb (%ecx), %al означает al = *ecx; . (это считывает байт по адресу памяти ecx в 8-битный регистр al)

movb %bl, (%edx) означает *edx = bl; . (это записывает байт в bl в байт по адресу памяти edx).

(В иллюстративном коде на языке Си, al и bl

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

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

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

Формат адреса – offset ( base , index , scale ), где offset – целочисленная константа (может быть положительной, отрицательной или нулевой), base и index – 32-битные регистры (но некоторые комбинации не допускаются), а scale – либо . Например, если массив содержит серию 64-битных целых чисел, мы будем использовать scale = 8, поскольку каждый элемент имеет длину 8 байт.

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

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

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

Каждая инструкция на языке ассемблера может иметь префикс из нуля или более меток. Эти метки будут полезны, когда нам нужно будет перейти к определенной инструкции. Примеры:

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

Хотя jmp является безусловной, у нее есть родственные инструкции, которые смотрят на состояние eflags , и либо переходят к метке, если условие выполнено, либо переходят к следующей нижележащей инструкции. К инструкциям условного перехода относятся: ja (переход, если больше), jle (переход, если меньше или равно), jo (переход, если переполнение), jnz (переход, если ненулевое значение) и так далее. Всего их 16, и некоторые имеют синонимы – например, jz (jump if zero) – то же самое, что je (jump if equal), ja (jump if above) – то же самое, что jnbe (jump if not below or equal). Пример использования условного перехода:

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

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

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

7. Стек

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

В x86 стек растет вниз, от больших адресов памяти к меньшим. Например, “затолкать” 32-битное значение в стек означает сначала уменьшить esp на 4, затем взять 4-байтовое значение и сохранить его, начиная с адреса esp . “Выталкивание” значения выполняет обратную операцию – загружает 4 байта, начиная с адреса esp (либо в данный регистр, либо отбрасывает), затем увеличивает esp на 4.

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

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

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

Когда мы компилируем код на языке Си, он переводится в код на ассемблере и, в конечном итоге, в машинный код. Соглашение о вызове определяет, как функции Си получают аргументы и возвращают значение, b

В 32-разрядной системе x86 в Linux соглашение о вызове называется cdecl. Вызывающая функция (родитель) помещает аргументы справа налево в стек, вызывает целевую функцию (callee/child), получает возвращаемое значение в eax , и вынимает аргументы. Например:

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

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

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

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

Что касается SSE, 128-битный регистр xmm может интерпретироваться по-разному в зависимости от выполняемой инструкции: как шестнадцать байтов, как восемь 16-битных слов, как четыре 32-битных двойных слова или числа с плавающей точкой одинарной точности, или как два 64-битных четверных слова или числа с плавающей точкой двойной точности. Например, одна инструкция 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-битный режим

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

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

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

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

14. Резюме

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

Я надеюсь, что этого руководства было достаточно, чтобы вы поняли, как в целом работает архитектура набора инструкций x86. Я не смог охватить в этой статье вводного уровня бесчисленное множество деталей – например, полное описание написания базовой функции, отладки распространенных ошибок, эффективного использования SSE/AVX, работы с сегментацией, системных структур данных, таких как таблицы страниц и дескрипторы прерываний, обсуждение привилегий и безопасности, и многое другое. Но, имея твердую ментальную модель работы процессора x86, вы теперь в

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

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