[Перевод] Микрокод внутри сопроцессора Intel 8087: обмен регистрами

В 1980 году Intel представила 8087 – сопроцессор, который ускорял операции с плавающей точкой до 100 раз. Этот чип оказал огромное влияние на дальнейшее развитие процессоров, и сегодня большинство из них используют стандарт вычислений с плавающей точкой, впервые реализованный в 8087.
Для точного вычисления квадратных корней, тангенсов, экспонент и других функций 8087 использует довольно сложные алгоритмы. Внутри чипа они реализованы на низком уровне в виде микрокода. Я состою в группе Opcode Collective, которая занимается реверс-инжинирингом этого микрокода. В этой статье я подробно разберу микрокод одной из инструкций 8087 – FXCH – и покажу, как он работает. Инструкция FXCH меняет местами два регистра с плавающей точкой. Казалось бы, что может быть проще, но внутри всё устроено интереснее: для реализации этой операции микрокоду требуется 14 микроинструкций.

Чтобы исследовать микрокод, я вскрыл корпус 8087 и с помощью микроскопа сделал снимок кристалла в высоком разрешении. В центре расположено большое ПЗУ микрокода, где хранятся микроинструкции, управляющие работой чипа. Слева находится блок выполнения микрокода: он последовательно выполняет микроинструкции, обрабатывает переходы и вызовы подпрограмм. Нижнюю половину чипа занимает тракт данных (datapath) – схемы, непосредственно выполняющие вычисления с плавающей точкой. Он разделён на 16-разрядный тракт для порядка числа и 64-разрядный тракт для его дробной, или значащей, части (significand).

В этой статье нас прежде всего будут интересовать временные и стековые регистры, выделенные красным. В чипе есть два временных и восемь стековых регистров; каждый хранит порядок и дробную часть числа. Кроме того, у каждого регистра есть два бита тега, которые указывают тип хранящегося в нём значения. Схема управления стеком справа отслеживает положение вершины стека при добавлении и извлечении значений.
Микрокод 8087
Для выполнения такой инструкции 8087, как вычисление арктангенса, требуются сотни внутренних шагов. Они реализованы в микрокоде: каждая микроинструкция задаёт отдельный шаг алгоритма. (Здесь важно не путать два уровня инструкций: инструкции ассемблера, которыми пользуется программист, и недокументированные низкоуровневые микроинструкции внутри чипа.) В ПЗУ микрокода хранятся 1648 микроинструкций, реализующих набор инструкций 8087. Каждая микроинструкция имеет длину 16 бит и выполняет простую операцию: например, пересылает данные между внутренними регистрами, складывает два значения или сдвигает данные. Вместе с Opcode Collective я занимаюсь реверс-инжинирингом микроинструкций, чтобы полностью разобраться в устройстве микрокода (ссылка).
Микроинструкции 8087 устроены довольно сложно: в них много особых случаев и специализированной логики, поэтому ниже я дам лишь упрощённый обзор. Каждая микроинструкция состоит из 16 бит, как показано на схеме. Первые три бита задают её тип, а от него зависит смысл остальных битов.
Первый тип отвечает за пересылку данных из одного внутреннего регистра в другой; два поля задают источник и приёмник. Ещё три бита используются для различных особых случаев.
Следующий тип задаёт операцию сдвига: баррельный сдвигатель смещает значение влево или вправо. Третий тип микроинструкций задействует сумматор-вычитатель; его также можно использовать в цикле для умножения или деления.
Четвёртый тип включает различные микроинструкции управления арифметикой: они настраивают сумматор, задают режим округления и выполняют другие подобные операции. Микроинструкции дальнего перехода и дальнего вызова выполняют переход или вызов подпрограммы по целевому микроадресу из фиксированного списка. Поле условия позволяет выполнять условные переходы и вызовы по множеству разных условий, а последний бит инвертирует условие. Локальный переход позволяет условно перейти к расположенной рядом микроинструкции.
Наконец, микроинструкции прочих типов выполняют самые разные действия – от возврата из подпрограммы и возбуждения исключения до завершения выполнения микрокода.

Как значения хранятся внутри 8087
8087 поддерживает разные типы данных: числа с плавающей точкой нескольких форматов, целые числа и двоично-десятичные значения. Но внутри чипа всё хранится в виде 80-битных чисел с плавающей точкой. Такое число состоит из трёх частей: 64-битной значащей части, 15-битного порядка и бита знака. В чипе есть два отдельных тракта данных: один для значащей части, второй – для порядка и знака.
Для хранения чисел во время вычислений в чипе предусмотрено восемь регистров – верхний ряд на схеме ниже. Однако организованы они необычно: в виде стека, куда числа помещаются и откуда затем извлекаются. Вместо обращения, скажем, к регистру № 3 программа обращается к третьему регистру от вершины стека, обозначенному как ST(3). При добавлении или извлечении значений содержимое ST(3) меняется. Предполагалось, что стековая архитектура позволит улучшить набор инструкций, упростить разработку компиляторов и сделать вызовы функций эффективнее, однако на практике эти ожидания оправдались не полностью.

Многие инструкции 8087 работают с вершиной стека. Например, инструкция вычисления квадратного корня заменяет значение на вершине стека его квадратным корнем. Но что делать, если нужно вычислить квадратный корень для значения где-то в середине стека? Для этого и нужна инструкция FXCH, которой посвящена эта статья. Она меняет местами значение на вершине стека и значение в указанной позиции, тем самым давая доступ к элементам внутри стека.
Для дальнейшего разбора важна ещё одна особенность 8087: каждому значению в стеке регистров соответствует тег, который показывает, что именно находится в регистре: обычное значение, специальное значение, ноль или пустое место. «Нормальное» число с плавающей точкой получает тег valid. Бесконечность, NaN или денормализованное значение помечаются как special. Нулевое значение получает тег zero. Наконец, если регистр пуст, например после извлечения его значения из стека, ему присваивается тег empty. 8087 использует теги для оптимизации работы и обнаружения ошибок.(примеч.1) Например, если программист извлечёт из стека слишком много значений, а затем попытается прочитать стековый регистр с тегом empty, 8087 возбудит исключение «недопустимая операция».
Восемь стековых регистров доступны программисту, но у 8087 есть и временные регистры для внутренних операций. Для этой статьи важны два из них: tmpA и tmpB. Как и стековые регистры, каждый из них имеет разрядность 80 бит и два дополнительных бита тега.
Микрокод FXCH
Теперь разберём, как работает микрокод инструкции обмена FXCH. Эта инструкция меняет местами регистр на вершине стека и регистр в заданной позиции стека. Если один из них пуст, возникает исключение «недопустимая операция», а отсутствующее значение или значения заменяются специальным значением Not a Number (NaN).
Ниже приведён микрокод этой инструкции, состоящий из 14 микроинструкций.(примеч.2) Первая микроинструкция выполняет пересылку: источником служит значение на вершине стека ST(0), а приёмником – временный регистр A. Выбор источника приводит к тому, что 64-битная значащая часть попадает на шину дробной части, 16-битные порядок и знак – на шину порядка, а два бита тега передаются в схему обработки тегов. Выбор tmpA в качестве приёмника приводит к сохранению значений с шин во временном регистре.
Таким образом, закодированные в микроинструкции биты задают нужную пересылку. Третья микроинструкция устроена аналогично, но работает уже с регистром внутри стека – ST(i), где индекс задаётся машинной инструкцией.
Точка входа FXCH:
#0200 ST(0) -> tmpA *прочитать вершину стека*
#0201 nop *подождать один такт*
#0202 ST(i) -> tmpB *прочитать указанный стековый регистр*
#0203 if !(tmpA or tmpB empty) jmp #0210 *перейти, если оба регистра существуют*
#0204 set invalid exception *возбудить исключение «недопустимая операция»*
#0205 if (unmasked) jmp #0213 *если исключение не замаскировано, завершить*
#0206 if !(tmpA empty) jmp #0208 *проверить, пуст ли tmpA*
#0207 NaN -> tmpA *если пуст, записать NaN в tmpA*
#0208 if !(tmpB empty) jmp #0210 *проверить, пуст ли tmpB*
#0209 NaN -> tmpB *если пуст, записать NaN в tmpB*
Здесь сходятся обычный сценарий и ветка обработки ошибки:
#0210 tmpB -> ST(0) *сохранить tmpB на вершине стека*
#0211 nop *подождать один такт*
#0212 tmpA -> ST(i) *сохранить tmpA в указанном стековом регистре*
#0213 RNI *конец процедуры: Run Next Instruction*
#0214 nop *не используется*
#0215 nop *не используется*
#0216 nop *не используется*Следующая микроинструкция, относительный переход по микроадресу #0203, показывает другой тип микроинструкций – условный переход. В ней задаётся условие: в данном случае проверяется, пуст ли хотя бы один из временных регистров. (Аппаратная логика проверяет связанные с ними биты тегов и определяет, есть ли среди них тег empty.) Отдельный бит микроинструкции инвертирует условие. Наконец, в ней задано смещение +6, поэтому переход выполняется на #0210. Преимущество относительного смещения перед полным микроадресом в том, что для его хранения достаточно всего шести бит.
Если хотя бы один регистр пуст, следующая микроинструкция вызывает исключение «недопустимая операция». Как я покажу в следующем разделе, 8087 можно настроить так, чтобы при исключении он либо генерировал прерывание, либо продолжал вычисления. Следующая микроинструкция выполняет условный переход, проверяя, было ли исключение незамаскированным, то есть было ли сгенерировано прерывание. Если да, выполнение микрокода завершается, а обработкой прерывания занимается основной процессор 8086.
Если исключение было замаскировано, микрокод заменяет отсутствующие значения специальным значением Not a Number: сначала проверяет tmpA, затем tmpB. Источник NaN заставляет схему выставить на шине порядка единицы во всех разрядах, а на шине дробной части – нули, кроме двух старших битов. Именно такой битовый шаблон представляет значение Not a Number.(примеч.3)
На микроадресе #0210 ветка обработки пустого регистра сходится с обычной веткой, после чего содержимое временных регистров записывается обратно в стековые. Именно здесь и происходит сам обмен: tmpA и tmpB записываются в противоположные позиции стека относительно тех, откуда были прочитаны. Наконец, RNI обозначает конец процедуры микрокода. Блок выполнения микрокода останавливается, а 8087 готовится к следующей инструкции.
Интересно устроены микроинструкции nop (no operation). Между каждой парой чтений или записей стековых регистров стоит по одному nop – вероятно, из-за временных ограничений самих регистров. В конце процедуры микрокода перед началом следующей находятся ещё три nop. Похоже, это просто неиспользуемое место в ПЗУ микрокода. Возможно, во время разработки микрокод FXCH удалось сократить на три слова, и в результате здесь остался такой зазор.
Исключения
В 8087 реализована довольно сложная система исключений для обработки различных проблем. Исключения делятся на шесть категорий: недопустимая операция, операция с денормализованным значением, деление на ноль, переполнение, underflow и потеря точности. Исключение «недопустимая операция» возникает, например, при попытке извлечь квадратный корень из отрицательного числа или выполнить операцию над пустым регистром. Переполнение возникает, если значение слишком велико и не может быть представлено в доступном формате, а underflow – если оно слишком мало.
Исключение деления на ноль возникает при попытке деления на ноль.(примеч.4) Исключение потери точности возникает, если число невозможно точно представить в формате с плавающей точкой, что случается чрезвычайно часто. Наконец, исключение для денормализованного значения возникает, если число настолько близко к нулю, что его уже нельзя представить с полной точностью.
Что происходит при возникновении исключения? 8087 позволяет программисту отдельно выбрать поведение для каждого типа исключений. Первый вариант – сгенерировать прерывание CPU, чтобы проблему обработало программное обеспечение. Например, программа может попытаться обойти ошибку, записать её в журнал или просто завершить работу. Второй вариант – «замаскировать» исключение. Тогда 8087 продолжает вычисление наиболее разумным способом.
Например, при переполнении результат заменяется бесконечностью, а при недопустимой операции – специальным значением Not a Number (NaN). При исключении потери точности, например для 1/3, результат округляется. Разработчики 8087 уделили много внимания тому, чтобы после замаскированного исключения вычисления продолжались как можно корректнее; в руководстве разбору всевозможных особых случаев посвящено несколько страниц.(примеч.5)
Обработка исключений в 8087 разделена между микрокодом и аппаратной логикой. Например, если микрокод FXCH обнаруживает пустой регистр, он выполняет микроинструкцию set invalid exception. Она устанавливает защёлку, сигнализирующую об исключении «недопустимая операция». В управляющем регистре 8087 есть шесть битов маски – по одному для каждого типа исключения; они блокируют прерывания соответствующего типа. Аппаратная логика сопоставляет сигналы триггеров исключений с битами маски в управляющем регистре и флагами исключений в регистре состояния, чтобы определить, возникло ли новое незамаскированное исключение. Если да, схема 8087 отправляет прерывание процессору 8086.
Если же прерывание замаскировано, выполнение микрокода продолжается. В случае FXCH микрокод заменяет значения из пустых регистров на Not a Number. Наконец, процедура завершается командой RNI. Она запускает множество аппаратных действий, но для нас важно одно: состояние триггеров исключений копируется в регистр состояния. Так устанавливается соответствующий бит исключения, который программа при необходимости может проверить. При запуске следующей инструкции 8087 триггеры исключений сбрасываются. Поскольку работой триггеров, регистра состояния, управляющего регистра и линии прерывания занимается аппаратная логика, сам микрокод можно сделать проще и компактнее.
Извлечение микрокода
ПЗУ микрокода 8087 содержит 26 368 бит, в которых закодированы 1648 16-битных микроинструкций. Для своего времени это было очень большое ПЗУ, поэтому, чтобы уместить его на кристалле, Intel применила особую конструкцию, хранившую по два бита на транзистор – вдвое больше, чем обычное ПЗУ. Это полуаналоговое ПЗУ использует транзисторы четырёх размеров, создающих четыре уровня напряжения. Компараторы преобразуют каждый уровень напряжения в пару битов.

Чтобы извлечь микрокод, я сделал снимки ПЗУ в высоком разрешении после удаления металлического слоя. Gloriouscow с помощью нейросети классифицировал транзисторы по размеру. (Полное изображение и отдельные транзисторы можно посмотреть здесь.) Следующим шагом нужно было понять, как сопоставить транзисторы с битами. Можно было бы ожидать, что сетка транзисторов напрямую соответствует сетке битов микрокода. Но из-за различных аппаратных оптимизаций строки и столбцы переставлены и местами зеркально отражены. Разобраться в этом удалось, изучив схему. В результате мы получили микрокод в виде таблицы нулей и единиц.
Следующим шагом было понять, что именно означает этот микрокод. В случае процессора 8086 патент довольно подробно описывал устройство микрокода и аппаратной части, но патент на 8087 практически ничего не говорил о микрокоде. Поэтому смысл микроинструкций мы восстанавливали сразу несколькими способами: изучали схему, искали закономерности в микрокоде и рассуждали, как могли быть реализованы конкретные инструкции.
Микрокод вообще редко бывает простым, а у 8087 он особенно запутанный. Чип находился на пределе возможностей своего времени, поэтому разработчикам приходилось использовать множество особых случаев и различных ухищрений. Например, некоторые условные переходы имеют побочные эффекты и заодно обновляют регистры. Другие микроинструкции устанавливают триггеры, которые меняют поведение последующих операций. Мы всё ещё продолжаем работу и пока не до конца понимаем реализацию микроинструкций на аппаратном уровне.
Примечания и ссылки
1. Обычно теги скрыты от программиста, но получить к ним доступ можно с помощью специальных операций. Программист может прочитать теги 8087, выгрузив состояние сопроцессора в память; теги хранятся в 16-битном «слове тегов».
2. Исходный микрокод 8087, декодированный Smartest Blob, доступен здесь. Для наглядности я немного изменил формат его представления.
3. Некорректное значение 8087 обозначает специальным значением Not a Number (NaN). Формат допускает множество представлений NaN: это любое значение, у которого все биты порядка равны 1, значащая часть ненулевая (нулевая значащая часть обозначает отрицательную бесконечность), а знак может быть любым. Для недопустимой операции 8087 использует одно конкретное значение NaN, называемое real indefinite. Для внутреннего 80-битного вещественного числа у него установлены два старших бита значащей части, остальные равны нулю, а все биты порядка и бит знака установлены в 1. (См. страницы 87 (S-73) и 90 (S-76).) В 32- и 64-битных вещественных форматах для NaN используется немного другой битовый шаблон: в этих форматах старшая единица значащей части подразумевается, поэтому для значения real indefinite явно устанавливается только один её бит.
4. Деление на ноль обычно вызывает исключение деления на ноль, но 0 ÷ 0 приводит к исключению «недопустимая операция», а деление бесконечности на ноль считается допустимым и даёт бесконечность. Это лишь одна из причин, почему микрокод получился настолько сложным.
5. Подробнее об исключениях 8087 см. в 8086 Family Numerics Supplement. Система исключений описана на странице 32 (S-18), флаги исключений и их маски – на странице 24 (S-10), а подробности обработки исключений – на странице 89 (S-75).
Читайте также
Схемотехника стека сопроцессора Intel 8087 для чисел с плавающей запятой: реверс-инжиниринг
Два бита на транзистор: ПЗУ микрокода повышенной плотности в FPU-сопроцессоре Intel 8087
Декодирование инструкций в сопроцессоре с плавающей точкой Intel 8087
Анализ кристалла 8087: быстрый битовый шифтер математического сопроцессора
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.