Об исключенных командах или за что «списали» инструкцию INTO?

Первая публикация 23.01.2014

On the excluded commands, or What did they discard the INTO instruction for?

Введение

«Хотите отрезной рукав? Пожалуйста.
Хотите плиссированную юбку
с вытачками? Принимаю и это.
Но опускать линию талии? Не дам!»
Герцог из к/ф «Тот самый Мюнхгаузен»

Говорят, и это почти не шутка, для того, чтобы хорошо понять язык программирования, нужно написать транслятор с него. Тогда по аналогии, чтобы лучше понять систему команд процессора, нужно написать транслятор с ассемблера. Как раз некоторое время назад, автор занялся доработкой своего транслятора с ассемблера, вводя новый для себя режим x86-64. Поскольку этот ассемблер имеет специальные макросредства ввода новых команд [1], его переделка не стала сложной, хотя и потребовала добавления директивы «REX» для формирования кодов REX-префиксов. Но статья не об этом. При систематизации расширений системы команд в режиме x86-64 внимание привлек ряд «выбывших» (retired) команд, «уволенных в отставку» разработчиками архитектуры AMD64. Я не нашел в Интернете каких-либо документов, объясняющих это решение. Вероятно, самим разработчикам причина казалась вполне очевидной: данные команды «устарели» и практически нигде не используются. Рассмотрим, так ли это.

Исключенные команды

Согласно документации Intel [2] в список выбывших в режиме x86-64 команд вошли:
– команды «далекого» вызова и перехода CALLF и JMPF;
– команда запроса уровня привилегий ARPL;
– команды манипулирования сегментными регистрами DS, ES, SS;
– команды запоминания/восстановления всех регистров PUSHAD и POPAD;
– команда контроля выхода индекса массива за границы BOUND;
– команды двоично-десятичной арифметики AAA, AAS, AAM, AAD, DAA, DAS;
– команда контроля целочисленного переполнения INTO.

Наличие в этом списке команд CALLF и JMPF является формальностью. Команды «далекого» вызова и перехода возможны и в режиме x86-64, просто исключаются эти конкретные формы с кодами 9A и EA. На мой взгляд, и их можно было бы оставить как более короткие и разрешить добавку REX-префикса, но это уже придирки.

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

Исключение команд манипуляции сегментными регистрами DS, ES, SS, важных когда-то в эпоху MS-DOS, тоже не вызывает никаких сожалений, тем более, что разработчики архитектуры все-таки оставили возможность и в режиме x86-64 «раскрашивать» данные с помощью сегментных регистров FS и GS, что, например, может быть нужно при работе параллельных потоков в задаче.

Таким образом, остается рассмотреть ценность наличия команд запоминания и восстановления всех регистров, контроля индексов, двоично-десятичной арифметики и контроля целочисленного переполнения.

Команды запоминания и восстановления всех регистров PUSHAD и POPAD

На первый взгляд, кажется, что эти команды часто нужны. Но, например, проанализировав используемую системную библиотеку, я нашел лишь одно такое место, да и то в процедуре отладочной выдачи. Там это сделано для того, чтобы выдачу можно было вставить в любое место, даже посередине выражения, не влияя на среду. Т.е. команды запоминания и восстановления среды используются сейчас гораздо меньше, чем в эпоху MS-DOS. Поэтому в программах, хотя и громоздко перечислять имена всех регистров, но, к счастью, делать это нужно достаточно редко. Кроме того, в ассемблере [1] есть макрокоманда, позволяющая записывать несколько команд одной псевдоинструкцией и аналогичная ей макрокоманда восстановления, например:

PUSH EAX,EBX,ECX,EDX,ESI,EDI
POP EDI,ESI,EDX,ECX,EBX,EAX

Конечно, немного неудобно, но выбрасывание команд PUSHAD и POPAD – это мелочь, из-за которой не стоило бы писать статью.

Команда контроля индексов массива BOUND

Напомню, данная команда предполагает, что в регистр засылается индекс массива (со знаком), а адресная часть указывает на память, содержащую пару границ, на попадание внутрь которых содержимое данного регистра и проверяется. В случае выхода индекса из этих границ генерируется специальное исключение INT 5. Однако иногда возникают некоторые трудности с применением этой команды. Рассмотрим простейший пример доступа к элементу двумерного массива на (так любимом автором) языке PL/1 [3]:

DCL X(1:100,1:100) FLOAT(53);
DCL (I,J) FIXED(31);
X(I,J)=1e0;

Транслятор (в режиме без контроля индексов) преобразует это в следующие команды:

imul…….edi,I,800
mov…….eax,J
lea……….edi,X+0FFFFFCD8h[edi+eax*8]
mov……..esi,offset const_1e0
movsd
movsd

Видно, что старший индекс I появляется в регистрах, так сказать, не в «чистом» виде, а уже как результат умножения на предыдущую размерность. Кроме этого, вместо индекса-переменной в программе может быть выражение. Поэтому часто эффективнее (без отдельной загрузки только для контроля) проверять правильность не самого индекса, а некоторого предвычисленного значения. Но в таком случае предварительно надо проверять на переполнение и результат умножения. Причем нежелательно при переполнении вызывать исключительную ситуацию с другим номером, чтобы не вводить программиста в заблуждение относительно первопричины ошибки. Несмотря на такие тонкости, использование инструкции BOUND позволяет на аппаратном уровне выполнить две проверки за один раз. Отсутствие этой команды потребует или вставлять собственные процедуры проверок и раздувать код программы или, оставив в программе запрещенную теперь BOUND, обрабатывать исключение от нее самой и эмулировать ее работу. Но в этом случае каждая проверка будет вызывать исключение, а раньше – только редкий реальный выход индекса за допустимые рамки. Как говорится, почувствуйте разницу. Хотя признаюсь, в своих программах по историческим причинам я не использую BOUND, а применяю прямую проверку результата умножения в паре регистров EDX:EAX. Этот несколько архаичный прием позволяет отказаться от дополнительной проверки переполнения, поскольку сравнение идет сразу с «расширенными» границами, также умноженными на предыдущую размерность. Тогда вот во что выливается предыдущий пример в режиме с проверкой индексов, но без использования BOUND:

mov…….eax,800
imul…….I
mov…….edi,eax
push……800
push……80000
call………?serv3
mov…….eax,8
imul…….J
add……..edi,eax
push……8
push……800
call………?serv3
lea……….edi,X+0FFFFFCD8h[edi]
mov……..esi,offset const_1e0
movs
movs

Внутри вставляемого транслятором вызова системной подпрограммы ?SERV3 значение в EDX:EAX проверяется на попадание в диапазон двух 8-байтных значений, передаваемых через стек. Причем для компактности кода транслятор выбирает эту подпрограмму в случае, когда старшие байты результата умножения границ на размерность равны нулю, и через стек передает ей только младшие байты:

public…..?serv3:
push…….0 ;добавляем ноль как старшие байты
;—- не ниже нижней границы ? —-
sub…..[esp]+0ch,eax
sbb…..[esp]+000,edx
jl……..@
cmp….d ptr [esp]+0ch,0
jnz……err
;—- не выше верхней границы ? —-
@: mov…..d ptr [esp],0
sub…………[esp]+08h,eax
sbb…………[esp]+000,edx
jl…………….err
;—- контроль прошел нормально —-
add…..esp,4 ;выбросили ноль из стека
ret…….8
;—- исключение по выходу индекса за границы —-
err: …

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

Команды двоично-десятичной арифметики

Судя по компьютерным форумам, не все представляют, как именно используются команды двоично-десятичной арифметики и главное, зачем они вообще введены. Иногда говорят, что это нужно для арифметики чисел, записанных в виде текста, чтобы не переводить в двоичный вид и обратно. Это не так. Данные команды работают не с текстовым представлением чисел, а со специальным двоично-десятичным кодом (BCD). Основное его назначение – точное представление чисел с дробной частью, представленной в виде десятичной дроби. В таком виде можно проводить арифметические вычисления без округлений и потери точности, а это важно в некоторых научных и особенно финансово-экономических расчетах с их сложными процентами. Тогда зачем ввели целых 6 команд и два BCD-формата (упакованный и неупакованный)? Дело в том, что пытались найти компромисс между скоростью и размером чисел в памяти. Арифметика чисел в неупакованном BCD-формате проще и быстрее (используя команды AAA, AAS, AAM, AAD), зато число из 15 цифр с учетом знака занимает целых 16 байт. Во времена дорогой памяти это казалось слишком много. Наоборот, упакованный BCD-формат довольно «плотный». Число из 15 цифр занимает 8 байт. Сравните с 8-байтным числом в формате IEEE 754, где имеется лишь чуть больше десятичных цифр мантиссы 253 ~15.955, но при этом представление числа приближенное, а в BCD-формате – точное. По сравнению с неупакованным BCD-форматом, в упакованном формате сложнее вычислять умножение и деление. Поэтому и есть только команды DAA и DAS, а DAM и DAD никогда не существовало. Создатели используемого мною PL/1 сделали выбор в пользу упакованного BCD-формата, хотя в ретроспективе (память стала гораздо дешевле) возможно это не оптимальное решение. Но при существующем положении дел меня задевает исключение именно DAA и DAS. Для примера вот текст самой простой системной подпрограммы сложения двух BCD-чисел в стеке, вызов которой генерируется транслятором:

public……..?dadop:………;вызов подставляется транслятором PL/1
lea…………..esi,[esp]+4….;начало 1-го BCD-числа
mov…………ecx,8…………..;максимальная длина числа в байтах
clc
lea……………edi,[esi+ecx] ;начало 2-го BCD-числа
;—- цикл сложения двух BCD-чисел в стеке —-
m:……………lodsb
adc…………..al,[edi]………..;сложение с коррекцией
daa
stosb…………………………….;запись ответа
loop………….m
;—- проверка на переполнение —-
and……………al,0f0h……….;выделили последнюю цифру ?
jz………………@
cmp…………..al,90h…………;отрицательный не переполнился ?
jnz……………..?dover………..;overflow для объекта типа decimal
;—- выход с очисткой стека —-
@:…..pop……ecx
mov……………esp,esi………..;очистка стека
jmp…………….ecx

Здесь складываются BCD-числа с точностью 15 десятичных цифр. Они могут иметь дробную часть, тогда за положением десятичной точки результата следит сам транслятор. При этом ничто не мешает организовать сложение с точностью, например, 150 или даже 1500 цифр. Для этого в данной подпрограмме надо лишь заменить константу 8 на значение 76 или 751. В этом главная особенность и смысл существования всего аппарата двоично-десятичной арифметики: возможность вычислений без округлений с любой заданной точностью. Такой аппарат для финансово-экономических расчетов появился во времена Кобола, т.е. очень давно используется и давно отработан. Вернемся к режиму x86-64. Исключение команд двоично-десятичной арифметики выглядит здесь какой-то «экономией на спичках». Например, взять те же DAA и DAS. Они однобайтные, не имеют параметров и (редкий случай!) не имеют исключительных ситуаций. Т.е. обрабатываются в процессоре самым простым образом. Зачем же было отключать несложную и эффективную аппаратную поддержку целого класса объектов и задач? Неужели только ради убирания нескольких примитивных инструкций? На фоне существования десятков различных типов команд, вроде SSE4, такое упрощение ничтожно. Причем эти инструкции все равно остаются в процессоре в 32-разрядном режиме. Теперь придется эмулировать их в соответствии с алгоритмами из официальной документации. Хорошо хоть флаг AF (перенос из младшей тетрады) разработчики сохранили в режиме x86-64, хотя там уже нет команд, его использующих. Без этого флага эмуляция стала бы еще более громоздкой.

Команда контроля целочисленного переполнения

Выбрасывание команды INTO в режиме x86-64 явилось для меня очень неприятным открытием. Хотя данная инструкция самая простая для эмуляции и может быть заменена всего одной командой условного перехода по флагу переполнения, главное преимущество существующей инструкции именно в том, что она аппаратная. Так же, как и в случае BOUND, эмуляция INTO приведет к появлению или ветвлений в программе или к потоку исключений, что резко снижает эффективность выполнения. А существующая аппаратная реализация просто декодирует байт EC и в подавляющем большинстве случаев пропускает его, почти не нагружая конвейеры, предсказатель переходов и прочие внутренние элементы процессора. Удобна эта инструкция и для работы транслятора. Он просто дописывает к кодам команд, требующим контроля переполнения, байт с кодом EC и дело с концом.

Например, сейчас в одном из основных проектов из числа тех, которые я сопровождаю, данная инструкция встречается в разных модулях более 3600 раз (при общей длине команд проекта 1.531.453 байт) и является штатным средством контроля правильности вычислений. Таким образом, увеличив длину кодов программы всего лишь на 0.2%, я получаю постоянный аппаратный контроль без ощутимого уменьшения скорости исполнения. Т.е. я вообще не могу заметить изменение скорости выполнения от использования данных команд. Вероятно, оно тоже составляет малые доли процента. И вспоминаю я об этом постоянном контроле только тогда, когда он срабатывает. За много лет эксплуатации программ десятки реальных случаев срабатываний исключений от инструкции INTO не раз помогали быстрее разбираться с ошибками, поскольку не давали ошибочным результатам распространяться по программе и замаскировать истинную причину ошибки.

Заключение

Итак, развитие архитектуры процессоров объективно приводит к тому, что некоторые команды и приемы программирования становятся излишними, и в новых условиях, например, в режиме x86-64, их можно исключить. Однако, с моей точки зрения, в ряде случаев с водой выплеснули и ребенка. Решение исключить в режиме x86‑64 инструкции DAA, DAS, BOUND и INTO следует признать недальновидным. Эти команды эффективно и простыми средствами осуществляли аппаратную поддержку таких действий программиста, которые остаются актуальными и в новых условиях. Конечно, работу исключенных инструкций всегда возможно описать оставшимися командами. Но таким путем со временем можно дойти и до машины Тьюринга. Рассмотренные инструкции уменьшают необходимость применения условных переходов в программе, чему в архитектуре х86 всегда придавалось особое значение. Об этом, например, свидетельствует наличие целой группы команд типа CMOV. Непонятно и какой выигрыш в архитектуре ожидали получить разработчики. «Освободившиеся» коды операций, за исключением кода ARPL, в режиме x86-64 никак не используются.

Есть и еще одна сторона этого дела, вроде и не имеющая отношения к технике. Лично я испытываю психологический дискомфорт оттого, что исчезают команды, к которым привык и которые постоянно используются в программах. Их удобство, эффективность, а потому и целесообразность была многократно доказана и в учебной и в справочной литературе, и на практике. И вдруг некто, не снисходя до объяснения причин в доступной литературе, объявляет их лишними. Это выглядит бездоказательно. А свои доказательства я привел в данной статье. Получается, что «совершенствование» архитектуры приводит не к улучшению, а к деградации эффективности выполнения ряда действий, которые много лет исправно «несли службу» в тысячах программ. Особенно обидна потеря старых удобных средств на фоне бесконечного добавления все новых и новых команд, общее число которых в архитектуре x86-64 по моим подсчетам уже перевалило за 630.

Если верить Википедии, такая же судьба сначала постигла и команды LAHF/SAHF, но потом они были все-таки возвращены. Было бы правильным вернуть и команды DAA, DAS, BOUND, а особенно INTO в архитектуру x86-64.

Литература

  1. Караваев Д.Ю. О специальных макросредствах в трансляторе с языка ассемблера RSDN Magazine #3, 2012
  2. Intel 64 and IA-32 Architectures Software Developer’s Manual Volume 2B. Order Number:253667-027US, April 2008
  3. Караваев Д.Ю. К вопросу о совершенствовании языка программирования RSDN Magazine #4, 2011
0

Автор публикации

не в сети 1 неделя

admin

3
Комментарии: 28Публикации: 173Регистрация: 13-06-2019
Авторизация
*
*

два × 1 =

Регистрация
*
*
*

четырнадцать + одиннадцать =

Генерация пароля

пять × 1 =

Перевести »
Прокрутить вверх
Scroll to Top