Сообщений 0    Оценка 16 [+0/-1]         Оценить  
Система Orphus

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

Автор: Караваев Дмитрий Юрьевич
Опубликовано: 23.01.2014
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Исключенные команды
Команды запоминания и восстановления всех регистров PUSHAD и POPAD
Команда контроля индексов массива BOUND
Команды двоично-десятичной арифметики
Команда контроля целочисленного переполнения
Заключение
Литература

Введение

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

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

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

Согласно документации Intel [2] в список выбывших в режиме x86-64 команд вошли:

Наличие в этом списке команд 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, эмуляция приведет к появлению или ветвлений в программе или к потоку исключений, что резко снижает эффективность выполнения. А существующая аппаратная реализация просто декодирует байт 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    Оценка 16 [+0/-1]         Оценить