Принципы кодирования инструкций Intel x86(-64) или "ехал префикс через префикс"

Введение

С давних пор меня интересовало то как процессоры Intel x86 кодируют свои инструкции.
Будучи в детстве владельцем клона ZX Spectrum я уже тогда сталкивался с таблицами кодов инструкций его процессора Z80, как например тут: clrhome.org/table/
В таком виде очень хорошо просматривается принцип кодирования этих инструкций — наглядно видно как они упорядочены и по каким битам раскиданы.
Но вот для x86 таких таблиц как то не удавалось найти, а то как эти коды пояснялись в руководствах от самого Intel было несистематизировано и поэтому не воспринималось.
Однако пару месяцев назад я наконец то наткнулся на табличный вид однобайтовых инструкций от i8086 до i386, поразглядывал его и проникся тем что тут и как кодируется.
Более того — в процессе этого обзорного ознакомления я проникся еще тем как эволюционировала система команд x86 с поколениями процессоров и решил вкратце эти вехи законспектировать тут. Это ни в коем случае не полное справочное руководство, но скорее обзорное знакомство вместе с историческим экскурсом которое возможно поможет кому то быстро понять основные принципы кодирования инструкций x86 перед более углубленным изучением по таблицам.

Почти вся важная информация взята из следующих ссылок:
Ссылка на табличную (и неполную) форму: sparksandflames.com/files/x86InstructionChart.html
Полное описание инструкций 32–битного x86: ref.x86asm.net/coder32.html
Полное описание инструкций 64–битного x86–64: ref.x86asm.net/coder64.html

Intel 8086

Откроем табличную форму (первую ссылку). В ней описаны все возможные однобайтовые коды инструкций x86 начиная от 16–битного 8086 вплоть до 32–битного 80386.
Все коды мы будем рассматривать в 16–ричной форме — так удобнее. Перед такими числами я буду ставить знак доллара, например $1F.
По сути таблица получается упорядочена в строки которые соответствуют первой 16–ричной цифре байта и в колонки которые соответствуют его второй цифре. Т.е. код инструкции $XY соответствует пересечению строки X и столбца Y.

Если взять 8–битные процессоры типа MOS 6502 или i8080, то их системы команд стараются уложить код инструкции процессора в один байт. Это как раз такая таблица и всего 256 возможных вариантов инструкций.
Из–за этого этим системам команд присуща довлеющая асимметричность. Как правило есть главный регистр именуемый аккумулятором и он неявно предполагается первым операндом в большинстве команд которым операнды нужны.
Но у такого подхода есть много недостатков — иллюзорная краткость кода инструкции ограниченная одним байтом на практике заставляет прописывать много инструкций перекладывающих значения и результаты из аккумулятора в память и другие регистры.
Поэтому экономия на байтах получается довольно злобно–буратинистая и выходит боком.
Другой пример — 16–битная архитектура PDP–11 (в СССР известная в ПК серии БК–0010) которая полюбилась программистам как раз за высококачественную симметричность и равноправность регистров общего назначения во всех операциях. Но шло это за ту цену, что коды инструкций всегда были 16–битные, т.е. двухбайтовые.

Когда Intel разрабатывали 16–битный процессор 8086 они совместили оба подхода: по возможности коды инструкций были однобайтовыми, но там где надо они становились двухбайтовыми.
Вернёмся к таблице — в ней светло–зеленым цветом обозначены однобайтовые инструкции.
Например ряд $40–4F весь заполнен инструкциями «INC reg» и «DEC reg», где reg — это один из восьми 16–битных регистров общего назначения архитектуры i8086: AX, BX, CX, DX, SP, BP, SI и DI.
Эти инструкции увеличивают или уменьшают соответствующий регистр на единицу — сама инструкция при этом занимает всего один байт и за ней сразу же идёт следующая инструкция.
Многие инструкции после опкода содержат некоторые данные. Так, например, ряд $70–7F содержит 16 вариантов инструкций условного перехода которые после кода содержат байт смещения на которое надо перейти (относительно текущей инструкции) при срабатывании условия. Таким образом при однобайтовом опкоде сама инструкция занимает два байта.

Но вот что если нам надо выполнить двухоперандную инструкцию и при этом иметь высокую ортогональность? i8086 в таких случаях расширяет опкод инструкции до двух байт и второй байт называется «modR/M».
Рассмотрим это на примере первых четырёх опкодов $00–03 — инструкции «ADD X, Y», т.е. сложения X и Y с занесением результата в X. Такие опкоды окрашены в таблице тёмно–зеленым — это значит, что сразу после основного опкода идёт байт modR/M сообщая какие у инструкции аргументы.
Во первых опкоды $00 и $02 работают с байтами (суффиксы «b» у аргументов в таблице), а опкоды $01 и $03 — со словами (суффиксы «v»). Это первое что решает главный опкод — с байтом или словом мы работаем. В i8086 слово всегда величиная 16–битная (или двухбайтовая).
Во вторых опкоды $00 и $01 работают с порядком «E, G», а $02 и $03 с порядком «G, E». Это второе что выбирает основной опкод — порядок аргументов.
В чём тут дело: все двухоперандные инструкции i8086 имеют одним из аргументов регистр общего назначения (РОН), а вторым — РОН или ячейку памяти. Т.е. ячейка памяти в аргументах может встречаться только один раз. В таблице аргумент обозначенный литерой «E» это как раз возможная ячейка памяти. Поэтому где она возможно будет употребляться — первым или вторым аргументом — важно. Если оба аргумента регистры — это уже не имеет значения и просто получается две разных возможности закодировать одну и ту же инструкцию.
Итак, один из четырёх опкодов «ADD» сообщает нам с байтом или словом мы работаем и первым или вторым аргументом стоит возможно ячейка памяти. Следом за ним идёт байт «modR/M».
Этот байт имеет следующее битовое представление:
mmGGGEEE

GGG это всегда тут код регистра общего назначения, восемь вариантов по возрастанию: AX, CX, BX, DX, SP, BP, SI, DI.
Заметьте, что CX и BX как будто бы перепутаны местами — мнемонические обозначения не совпали с физическими номерами, ведь CX подразумевает «Counter/Счётчик», а BX «Base/База».
Если mm=0, то и EEE — это тоже номер РОН.
Но если mm=1,2 или 3, то EEE кодирует адрес ячейки памяти, всех возможных комбинаций следующей формулы (квадратные скобки здесь служат обозначением косвенного обращения, а круглые скобки окружают опциональные варианты):
[ (BX|BP) + (SI|DI) + (offs16|offs8) ]

Т.е. или BX или BP в сумме с или SI или DI плюс или 16–битное или 8–битное смещение. При этом любые из слагаемых могут отсутствовать, но хотя бы один регистр должен быть.
Однако из этой схемы выбивается случай который должен был бы кодироваться как [ BP ] — эта комбинация запрещена и вместо неё срабатывает [ addr16 ], т.е. непосредственный адрес ячейки памяти в инструкции который следует за байтом modR/M.
Это весьма разумно, т.к. такой режим адресации нужен, а режим адресации [ BP ] во первых можно закодировать как [ BP + 0 ], но и это как правило не требуется, т.к. по [ BP + 0 ] лежит и не локальная переменная и не аргумент функции, а адрес возврата из неё — и адресовать его через BP такой конструкцией просто не нужно (BP по дизайну архитектуры предназначен для ссылок на локальные переменные и аргументы функции). Логично и элегантно.
Все эти варианты можно посмотреть по ссылке под заголовком «MOD R/M 16».

Так кодируется большое количество инструкций в i8086, но есть еще один вариант использования байта modR/M — в инструкциях однооперандных.
Что, если нам нужно, например, инкрементировать или декрементировать ячейку памяти?
Тут получается, что поле GGG из modR/M как бы и не нужно. И инженеры Intel воспользовались этим — и во многих однооперандных инструкциях зачастую код инструкции переползает в поле GGG байта modR/M.
В таблице такие группы инструкций обозначены как #2, #3, #4 и #5.
Здесь нам уже понадобится открыть ссылку ref.x86asm.net/coder32.html и прокрутить до опкода $FE или открыть эту ссылку: ref.x86asm.net/coder32.html#xFE
У этого опкода появляются числа в колонке «o». Это значит, что в поле «GGG» ожидается это число и тогда инструкция поведёт себя или как INC или как DEC.
Таким образом опкод $FE это инкремент/декремент байта с байтом modR/M, а что именно — инкремент или декремент решает поле «GGG» в байте modR/M.
А опкод $FF — это инкремент/декремент слова с байтом modR/M. Но не только! Если присмотреться к этой таблице, то видно что в опкод $FF еще прописали некоторые варианты инструкций CALL, JMP и PUSH (насколько я понял, это сделали не в i8086, а позднее).
Таким же образом сделаны инструкции побитовой прокрутки (группа #2) и умножения/деления (группа #3) которые предполагают первым аргументом аккумулятор. И некоторые другие.

Тут же еще стоит упомянуть, что в i8086 инструкции могли предваряться однобайтовыми префиксами — это была группа префиксов переопределения сегментных регистров (CS:, DS:, ES: и SS:), префиксы автоматического повторения строковых инструкций (REP и REPE) и ESC-префиксы для работы с сопроцессором.

Intel 80286

Этот процессор ввёл защищенный режим при полном сохранении 16–битности и сейчас, имхо, немного программистов вообще знают что за причудливый это был зверь изнутри (под ним работали Windows 1.x-3.x).
Но нам тут интересен главным образом вот какой момент: для управления защищённым режимом «двойке» потребовалось ввести с пару десятков административных инструкций.
И помещать их в основной пул однобайтовых инструкций было уже по существу некуда.
И вот тут обратите внимание на опкод $0F из первой таблицы — он там помечен как «TWO BYTE».

Если присмотреться к логике опкодов здесь должна была бы находиться инструкция «POP CS» — извлечение из стека регистра сегмента данных. С точки зрения практики такая инструкция не имела пользы — она прыгнула бы в иной сегмент по текущему смещению IP, что есть бред. Поэтому эту инструкцию в i8086 запретили использовать. Так вот в 80286 её использовали как префикс двухбайтовой инструкции. Административные инструкции стали записываться как $0F XX и с тех пор префикс $0F — стал весьма коренной вещью в системе команд x86.

Ниже мы к нему еще много раз вернёмся…

Intel 80386

Эпоха 32 бит сильно изменила ландшафт архитектуры x86. Во первых — регистры были расширены до 32 бит, а главное — они стали по настоящему равноправны. Какой ценой?
В режиме 32 бит как правило ОС настраивала процессор так, что по умолчанию то что было 16–битным словом в i8086 здесь трактовалось как 32–битное двойное слово.
Т.е. все операнды в таблице обозначаемые через суффикс «v» по умолчанию в 32–битных ОС стали 32–битными.
Регистры превращались соответственно в 32–битные eAX, eBX, eCX, eDX, eSP, eBP, eSI, eDI.
Чтобы переключить команду в режим одинарного 16–битного слова был заведён байтовый префикс $66 (OPSIZE). Он вставляется перед инструкцией и временно меняет ей битность слова с 32 бит на 16 или обратно (смотря какой сейчас режим выставлен по умолчанию).
Есть аналогичный префикс $67 (ADSIZE) для разрядности адресов, но если я правильно понял он широко не использовался в отличие от $66.

Что же касается неравноправности регистров — она проявлялась в адресациях ячеек памяти — только ограниченный круг регистров и в ограниченных комбинациях мог участвовать в косвенных адресациях в i8086.
В i386 эти ограничения были сняты за счёт модификации смысла байта modR/M.
Во что оно превратилось можно посмотреть там же по первой ссылке под заголовком «MOD R/M 32».
Главное отличие: большинство комбинаций аргумента EEE в modR/M теперь описывается как
[ reg (+ offs32|offs8) ]

Т.е. комбинация из любого РОН плюс смещения из байта или слова, где смещение может отсутствовать.
Точно так же как и в i8086 комбинация [ eBP ] изменена на непосредственный адрес: [ addr ].
Но главное, что все косвенные адресации с регистром eSP изменены на адресацию с использованием байта SIB.
Это значит, что если в байте modR/M выбран такой режим, то сразу за ним следует еще один новый байт SIB который еще сильнее уточняет режим адресации!
Байт SIB (Scale–Index–Base) кодируется битово как
SSIIIBBB,

где III и BBB — коды РОН индекса и базы, а SS кодирует множитель перед индексом — четыре варианта: 1, 2, 4, 8.
Таким образом вместе с байтом SIB в i386 самый сложный режим адресации выглядит как:
[ (1|2|4|8 * Index) + (Base) + (Offs8|Offs32) ]

Т.е. любой РОН помноженный на константу 1,2,4,8 плюс любой другой РОН и плюс смещение из байта или слова. Любой компонент опять таки может отсутствовать.

Однако и тут не обошлось без особых случаев.
Во первых — если в качестве индекса ставится регистр ESP, то индекс обнуляется. ESP не может фигурировать как индекс. Т.е. может, но ведёт это себя так как будто бы индекса просто нет.
Во вторых — если в качестве базы указан EBP без смещения (наличие смещения тут берется из байта modR/M), то он заменяется на disp32. Т.е. то же самое правило о [ eBP ] что было при чистом modR/M без байта SIB с байтом SIB срабатывает так же.
Таким образом, если в бинарной форме как бы закодировано [ SCALE * ESP + EBP ], то на самом деле ведёт это себя как [ disp32 ] (ESP просто отваливается, а EBP без смещения сам превращается в disp32).

Так же в i386 появляется большое число всяких новых вспомогательных регистров и инструкций по работе с ними — все они падают в префикс $0F.

От 80486 до Pentium

От i486 до поздних Pentium–ов базовые принципы архитектуры i386 не менялись, а только набухала система команд новыми фичами и возможностями.

Так, например команды условного перемещения CMOV — все падают в опкоды $0F 40 — $0F 4F.

В Pentium MMX появляется система команд MMX — все эти команды резонно падают под префикс $0F точно так же.

Далее появляется расширение SSE1 — в нём одного только префикса $0F перестаёт хватать. И тогда перед этим префиксом стали помещать еще два новых–старых префикса.
Во первых — префикс $66 который в i386 менял разрядность слова в SSE1 мог уже изменить смысл инструкции.
Во вторых — префикс $F3 который в i8086 означал префикс REP для строковых инструкций в SSE1 менял поведение инструкций с векторного на скалярное.
Т.е. инструкция в опкодовой своей части уже могла выглядеть как три байта, например инструкция MOVSS имеет опкод $F3 0F 10 за которым следует байт modR/M.
Ах да — в таких расширениях системы команд как FPU/MMX и SSE кодировка регистров в байте modR/M вида eAX, eBX, eCX сменялась на соответствующие регистры расширений — регистры FPU (от вершины стека) или регистры MMX/SSE если mm=0. Если же mm>0, то кодировка возвращается к стандартной адресации памяти через возможные индексные и базовые целочисленные регистры и смещения.

Далее появляется SSE2 и во первых использует опять таки старый–новый префикс $F2 (REPNE) как признак скалярности уже для своих инструкций двойной точности. Во вторых с помощью префикса $66 менял смысл некоторых инструкций SSE1 с одинарной на двойную точность. И в третьих с помощью этого же префикса $66 менял смысл целочисленных инструкций MMX перенаправляя их на свои регистры XMM (в MMX регистры были 64–битные, XMM же — 128–битные).

SSE3 и SSE4.1 продолжили эту традицию и таким образом комплексная инструкция 32–битных x86 может состоять из целых четырёх байт — пред–префиксов $66, $F2 или $F3, префикса $0F и конкретизирующего опкода. И это не считая того, что могут быть еще уточняющие байты modR/M+SIB!
Я уже даже не вспоминаю о префиксах типа LOCK.

Intel x86–64 (64 бита)

Переход в 64 бита тоже не дался легко.
Ну, как понятно, регистры теперь могут быть не только 16–битными или 32–битными, но еще и 64–битными. Тогда вместо буквы E в начале они называются как RAX, RBX, RCX и т.д.
Но что еще важнее: что общее количество РОН повышено с восьми до шестнадцати. Новые регистры называются R8–R15 для 64–битных значений. R8d–R15d для 32–битных, R8w–R15w для 16–битных и R8b–R15b для байт.
При этом система команд преимущественно сохранилась в неизменном виде.
Как же не меняя байты modR/M и SIB адресовать эти новые регистры?

С помощью нового префикса — REX!

Префикс REX (Register EXtension) занимает целых 16 слотов изначальных однобайтовых опкодов $40–4F. Если вернуться в первую таблицу, то мы увидим, что это все варианты INC/DEC над регистрами общего назначения.
Да, эти инструкции в x86–64 выпилили заменив префиксом REX. В битовом представлении он выглядит как
0100WRXB,

где WRXB — это биты расширяющие инструкцию и её байты modR/M+SIB.
Бит W равен 1 если мы работаем с 64–битным регистром или ячейкой памяти. Т.е. как только мы выполняем 64–битную целочисленную инструкцию — мы обязаны добавить к ней байт REX (если же нужно 16 бит, то по старинке используется префикс $66).
Бит R расширяет поле GGG из modR/M до четырёх бит — т.е. шестнадцати РОН.
Бит X раширяет поле Index из SIB так же до шестнадцати РОН.
И, наконец, бит B расширяет или поле Base из SIB, если он есть, или поле EEE из modR/M.
Таким образом как только в инструкции в любом месте встречается регистр из расширенного набора — она обязана будет иметь префикс REX.

Так же в x86–64 опять немного модифицировался смысл байта modR/M. Тот случай когда в modR/M как бы закодирован [ RBP ] и который на самом деле означает [ addr32 ] здесь изменён на [ RIP + offs32 ], т.е. работу по смещению относительно текущего счётчика инструкций.
Такого режима работы с данными очень не хватало в x86 для качественной реализации так называемого Position Independed Code (PIC). Здесь же такой режим вменён как бы по умолчанию.
Чтобы вернуться к обычному [ addr32 ] тут надо просто активировать байт SIB и выбрать такой режим в нём — здесь уже такая подмена не сработает.

Префиксы VEX и EVEX

Эти справочные материалы останавливаются на SSE 4.1.
Но уже в далёком (ох время летит) 2008 году Intel предлагает новый пакет векторных регистров и инструкций — AVX.
И с AVX им приходит в голову очевидная мысль — что–то префиксов развелось как собак нерезанных. Давайте ка их… причешем!
И так рождается следующая мысл — префикс VEX: en.wikipedia.org/wiki/VEX_prefix

Если вкратце, то префикс VEX запрессовывает в неиспользованные в x64 опкоды $C4 и $C5 (ранее в i386 это были команды LDS и LES — быстрой загрузки длинного адреса в пару из РОН и сегментного регистра) сразу несколько интересных бит:
а) биты из префикса REX (как будто бы он присутствует)
б) признак того что как будто бы присутствуют префиксы $F2, $F3 или $66 (один из них).
б) признак что как будто бы присутствует префиксы $0F или $0F 38 или $0F 3A (последние два введены в SSE 4.1 опять таки за недостатком свободных префиксов и опкодов).
в) и в плюс к тому биты нужные к тому чтобы расширить modR/M под много новых векторных регистров собственно AVX
Есть короткая и полная формы VEX–префикса:
короткая: $C5 XX
полная: $C4 XX XX

Т.е., четыре байта префиксов из выше заменили на три байта в худшем случае…
А еще через пять лет в 2013 году Intel предлагает новое расширение к AVX — AVX–512 и… префикса VEX перестало хватать.
И был предложен префикс EVEX из четырёх уже байт: en.wikipedia.org/wiki/EVEX_prefix
Он уже использует для своего старта опкод $62 (ранее в i386 инструкция BOUND) + 3 байта битовых полей которые покрывают и AVX и AVX–512.

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

0 комментариев

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.