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