Я уже плакался по поводу исключения в x86-64 команд двоично-десятичной арифметики DAA/DAS и плакался по поводу отмены команды проверки целочисленного переполнения INTO. Теперь настала очередь плакаться по поводу выброшенной команды BOUND. Как говорится, леди и джентльмены, подставляйте свои жилетки и декольте. Начинаю плач.

Команда BOUND проверки выхода индекса за границы массива по-своему уникальна в архитектуре x86. Потому что она явным образом предназначена для поддержки процессором языков высокого уровня. В этой части, пожалуй, только она одна такая и есть. Команда имеет два операнда, в первом из них указывается регистр с текущим значением индекса, а во втором – адрес в памяти «кортежа» из пары границ. Если число со знаком в первом операнде не входит в диапазон, заданный вторым операндом – генерируется исключение. Обратите внимание, что это поддержка не только одного какого-нибудь Си, так как границы массива могут быть любые, даже отрицательные.

Я столкнулся с нужностью контроля индексов в те далекие времена, когда в ходу были еще перфокарты. Однажды никак не мог понять, почему моя программа делает что-то странное. Коллега предложил включить контроль индексов. В БЭСМ-Алгол для этого была стандартная перфокарта, с пробитым символом «ромбик» и словом КОНТ. Я прямо взвился от такого предложения: «я же все просмотрел, индексы везде в порядке!» Но эту стандартную карту все-таки подложил в колоду. И – опа! – ошибка сразу нашлась! Потому что, будучи молодым и наивным программистом, я смотрел в текст своей программы и видел в нем только то, что хотел видеть, а не то, что там написано в реальности.

Вскоре случилась еще одна история. В те золотые времена разработки носителя «Энергия» был у нас сотрудник, занимавшийся расчетом динамических нагрузок. Ему приходилось обсчитывать много вариантов, а каждый расчет занимал часа полтора. Сейчас такие задачи почему-то с оттенком пренебрежения называют «числодробилками». Но в те времена почти все программы были такими. Так вот, он заказывал на каждую ночь два часа работы на БЭСМ-6, а утром забирал результат. Однажды вечером прямо в машинный зал вошли два других сотрудника в надежде быстрее прогнать свои программы и случайно спихнули ожидавшую ввода колоду того, о ком я рассказываю. Колода упала на пол и рассыпалась. Ну как рассыпалась – только несколько перфокарт отлетело. Они ее подобрали, и оказалось, что одна из отлетевших карт – та самая карта с ромбиком и текстом КОНТ. Сотрудники сообразили, что хозяин колоды, конечно же, не использует ее для своей давно отлаженной программы, а просто хранит, чтобы не искать, если начнет что-то менять. Для этого перфокарты надо было помещать в самый конец колоды за последней значащей перфокартой, которая официально называлась «конец ввода по А3», а на жаргоне даже неприлично – «длинный конец» из-за длинного пробитого столбика дырок. Вот из конца колоды она и отлетела. Карта КОНТ была вставлена на свое место за «длинным концом», инцидент был исчерпан.

Однако на следующее утро хозяин колоды бегал и спрашивал, кто и что делал с его программой? Оказалось, что он понятия не имеет ни о каких картах с ромбиками. Пару лет назад ему один раз составили «паспорт» колоды (с картой КОНТ), и он ничего никогда там не трогал. А вчера КОНТ, получается, выкинули. В результате того, что контроль индексов был выключен, его программа выдала (правильный) результат не за полтора часа, а за несколько минут.

Поэтому с самого начала я воспринял, во-первых, важность контроля индекса, поскольку неверный индекс массива – самый простой способ испортить программу. А во-вторых, то, что к проверкам нужно относиться бережливо, поскольку это дорогие (длительные) операции.

И вот, при переходе от x86 к x86-64 BOUND приказала долго жить. Я встречал в сети разные предположения, почему ее выкинули. Дескать, мешала предсказателю переходов. Но ведь генерация исключения – это вовсе не переход. Дескать, кодов не хватает, а тут занят код 62H. Тоже странно. Ну, заняли бы коды выброшенных команд PUSHAD/POPAD, их не так жалко. Дескать, границы, записанные в памяти, легко испортить вредоносной программой. Ага, а стек нельзя испортить вредоносной программой? И т.д. и т.п. Вплоть до аргумента: «ей никто не пользуется». Но это же замкнутый круг – выкинули, потому, что не пользуются, а пользоваться нельзя, потому, что выкинули.

Команде BOUND вообще как-то не везет. Например, в ее описании в официальной документации INTEL написана чушь собачья. Я не преувеличиваю. Вероятно, в результате технической ошибки пропало несколько слов и получилось, что текущее значение индекса должно быть «меньше или равно верхней границе плюс размер операнда». Прямо так английским по белому и написано «…and less than or equal to the upper bound plus the operand size in bytes».

Причем в переводе на русский в некоторых справочниках проявили творчество и заменили слово «плюс» на «сумму». Так и пишут, что индекс должен быть меньше или равен сумме верхней границы и размера операнда. Но легко убедиться, что никакого размера операнда к верхней границе в BOUND не прибавляется. Например, если граница указана в памяти 10, и индекс в регистре равен 11, BOUND даст исключение.

Но как бы я не плакался, в x86-64 никакой BOUND уже не предусмотрено и приходится жить без нее. И, как и в случае с INTO, встает вопрос: как быстрее и лучше проводить проверку выхода индекса за границы массива?

Еще в стародавние времена предлагался выход в том, чтобы задать допустимый диапазон индекса прямо в типе целой переменной, используемой для индекса. Например, если описать переменную как-нибудь как i int [1..100], указав допустимый диапазон, для использования как индекса массива с границами 1:100, то контроль индексов пойдет «сам собой». Правда, при этом проверки-то в программе все равно не исчезают. Хотя их может стать меньше, чем если проверять индекс при каждом обращении к массиву. Но тут есть и другая сложность. Например, при сортировке надо переставить i-й и i+1-й элементы массива. Даже если i и не выходит за границы массива, i+1 – может выйти, и заданный для i допустимый диапазон в этом случае не спасает. Конечно, можно завести переменную j типа i, присвоить j=i+1; и далее использовать индексы только как переменные, без выражений. Но это некрасиво, не наглядно, громоздко и требует лишних действий.

Поэтому я остановился на реализации проверки индекса при обращении к массиву, а не к переменной-индексу.

Поскольку массив – это набор одинаковых элементов, для доступа к элементу массива по индексу обязательно потребуется умножение индекса на размер элемента массива. В x86 есть удобная форма знакового умножения с тремя операндами: регистр, куда поместить результат, первый сомножитель (может быть переменной в памяти) и второй сомножитель-константа. Для доступа к элементам массива – это как раз то, что надо. Например, доступ к массиву x по индексу i в случае, если размер одного элемента массива 100 байт, осуществляется командами типа:

mov   esi, offset x    ;начало массива x
imul  eax, i,100       ;умножаем размер элемента на индекс
add   esi,eax          ;получаем в esi адрес i-ого элемента

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

Некоторая сложность в том, что в приведенном выше примере отдельно индекс не фигурирует, он оказался прямо внутри команды умножения. Но ведь можно проверять не сам индекс, как таковой, а уже результат умножения. И тогда сверять его с границами, тоже уже умноженными во время компиляции на размер элемента. Это исключает необходимость отдельной командой доставать значение индекса из переменной. Неудобно и то, что регистр, куда пишется результат умножения imul, может быть из-за распределения регистров компилятором любым, и придется передавать, например, через стек еще и сам этот регистр.

Поэтому я решил при необходимости контролировать индекс использовать «старую добрую» imul в форме с одним операндом-сомножителем, поскольку у нее второй сомножитель всегда ax/eax/rax, и результат всегда пишется в ax:dx/eax:edx/rax:rdx. В этом случае передавать значение индекса в системный вызов не требуется – он всегда после умножения будет в этой паре регистров. А передавать в этом случае системному вызову нужно только границы-константы. Причем, старшая часть произведения, попадающая в регистр dx/edx/rdx, вообще-то всегда должна быть нулевой или полностью заполненной единичными битами (при отрицательном индексе). И поэтому ее можно как константу не передавать, а только подразумевать.

Рассмотрим простой пример. Пусть имеется массив с границами от 1 до 100 и размером каждого элемента 100 байт. Требуется присвоить некоторое значение i-ому элементу. Под индекс выделяется целочисленная переменная размером 8 байт.

dcl a(1:100) char(100);
dcl i fixed(63);
a(i)='12345678';

При этом получаются вот такие команды без контроля индекса (?SMCCM - это системный вызов заполнения строки):

486B3D0028000064       imul q rdi,I,100
BEE8000000             mov  q rsi,offset @000000E8h
488DBF8C000000         lea    rdi,A+0FFFFFF9Ch[rdi]
B064                   mov    al,100
B108                   mov    cl,8
E800000000             call   ?SMCCM

То же действие, но с контролем индекса:

6A6458                 movsxq rax,100
48F72D00280000         imul q I
6A64                   push   100      ;нижняя граница
6810270000             push   10000    ;верхняя граница
E800000000             call   ?SERV3   ;системный вызов контроля индекса
BEE8000000             mov  q rsi,offset @000000E8h
488DB88C000000         lea    rdi,A+0FFFFFF9Ch[rax]
B064                   mov    al,100
B108                   mov    cl,8
E800000000             call   ?SMCCM

В системный вызов ?SERV3 передаются через стек две границы, умноженные на размер элемента, при этом в RAX:RDX находится произведение индекса i на размер элемента.

Если бы нижняя граница массива начиналась с нуля, использовалась бы другая разновидность системного вызова контроля индекса, поскольку передавать нижнюю всегда нулевую границу не требуется:

6A6458                 movsxq rax,100
48F72D68280000         imul q I
6810270000             push   10000		;верхняя граница
E800000000             call   ?SERV4	;системный вызов контроля
BEE8000000             mov  q rsi,offset @000000E8h
488DB8F0000000         lea    rdi,A[rax]
B064                   mov    al,100
B108                   mov    cl,8
E800000000             call   ?SMCCM

Внутри этих системных вызовов контроля производится сравнение числа, записанного в rax:rdx на попадание в диапазон, переданный константами через стек:

PUBLIC ?SERV3:
      PUSH      0               ;ДОБАВЛЯЕМ НОЛЬ
;---- НЕ НИЖЕ НИЖНЕЙ ГРАНИЦЫ ----
      SUB       [RSP]+18H,RAX
      SBB       [RSP]+000,RDX
      JL        @
      CMP       Q PTR [RSP]+18H,0
      JNZ       ERR
;---- НЕ ВЫШЕ ВЕРХНЕЙ ГРАНИЦЫ ----
@:    MOV       Q PTR [RSP],0
      SUB       [RSP]+10H,RAX
      SBB       [RSP]+000,RDX
      JL        ERR
;---- КОНТРОЛЬ ПРОШЕЛ ----
      ADD       RSP,8           ;ВЫБРОСИЛИ НОЛЬ ИЗ СТЕКА
      RET       16

PUBLIC ?SERV4:
      PUSH      0               ;ДОБАВЛЯЕМ НУЛИ
      PUSH      0
;---- НЕ НИЖЕ НИЖНЕЙ ГРАНИЦЫ ----
      SUB       [RSP]+08H,RAX
      SBB       [RSP]+000,RDX
      JL        @
      CMP       Q PTR [RSP]+08H,0
      JNZ       ERR
;---- НЕ ВЫШЕ ВЕРХНЕЙ ГРАНИЦЫ ----
@:    MOV       Q PTR [RSP],0
      SUB       [RSP]+18H,RAX
      SBB       [RSP]+000,RDX
      JL        ERR
;---- КОНТРОЛЬ ПРОШЕЛ ----
      ADD       RSP,16          ;ВЫБРОСИЛИ ДВА НУЛЯ ИЗ СТЕКА
      RET       8

Может показаться, что проверка старшей половины произведения в регистре RDX избыточна. Вряд ли на практике индекс сможет превысить 263. Однако в результате ошибок программы в переменной-индексе может оказаться и просто мешанина из нулей и единиц. Поэтому все-таки надежнее проверить и старшую половину произведения. На всякий случай.

Разумеется, есть случаи, когда контроль индексов не требуется. Например, если в программе есть цикл с заголовком

do i=lbound(a) to hbound(a);

и внутри этого цикла в изменение индекса i никто не вмешивается, то использование встроенных функций lbound и hbound гарантирует, что индекс не выйдет за пределы границ массива. Проблема в том, что для доказательства этого требуется кропотливый анализ программы компилятором. Для простых компиляторов такой полный анализ часто недостижим.

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

Например, массивы имеют разные размеры элементов, но одинаковые границы:

dcl a(1:100) char(100);
dcl b(1:100) char(50);
dcl i fixed(63);
a(i)=b(i);

Тогда достаточно только одной проверки индекса, что и сокращает код и ускоряет выполнение:

6A6458                 movsxq rax,100
48F72D803B0000         imul q I
488BF8                 mov  q rdi,rax
6A64                   push   100       ;нижняя граница
6810270000             push   10000     ;верхняя граница
E800000000             call   ?SERV3    ;контроль индекса
486B35803B000032       imul q rsi,I,50	;умножение уже без контроля
488DB6C6270000         lea    rsi,B+0FFFFFFCEh[rsi]
488DBF84000000         lea    rdi,A+0FFFFFF9Ch[rdi]
B064                   mov    al,100
B132                   mov    cl,50
E800000000             call   ?SMCCM

Количество различных команд в x86-64 уже приблизилось к тысяче. В частности, не так давно добавлены команды, связанные с контролем допустимости адресов BNDCL, BNDCU, BNDCN, BNDLDX, BNDMK, BNDMOV, BNDSTX в аббревиатурах которых угадывается слово BOUND (и, кстати, тоже генерирующих исключения при выходе проверяемого адреса из допустимого диапазона), а вот самую простую команду BOUND, единственную команду поддержки языковых конструкций высокого уровня, работавшую еще со времен 80286 на всех типах процессоров, почему-то посчитали ненужной. Хотя, на мой взгляд, рациональнее было бы просто расширить ее префиксом REX до 64-х разрядных значений.

Как и в случае DAA/DAS и INTO, команду BOUND, разумеется, можно функционально заменить совокупностью нескольких других команд. Хотя это раздувает код. Можно также оставить их в коде и обрабатывать исключения от этих команд, как от запрещенных. Хотя это замедляет выполнение. Обидно также, что прекрасная идея поддержки процессором языков высокого уровня не находит понимания.

Комментарии (1)


  1. marsianin
    23.06.2022 07:27
    +6

    Выбросить инструкцию BOUND было очевидно хорошим решением по двум причинам: (1) она реализуется неприлично длинной последовательностью микрокода и (2) компиляторы её не используют.

    (1) легко заметить, если посмотреть на семантику. Как минимум, нужно две загрузки из памяти, два сравнения и условная генерация исключения. При этом, пользователь не может никак это оптимизировать, храня границы диапазона в регистрах. Плюс, генерация исключения сама по себе очень дорогая: сброс конвейера и туча обращений в память. Дешевле эту операцию сделать с помощью пары CMP и пары Jcc.

    (2) в соответствии со стандартами языков C и C++, на которых пишут большинство критичного к производительности софта, выход за границы массива это неопределённое поведение. Соответственно, компилятор имеет право предполагать, что таких ситуаций в программе никогда не происходит. Следовательно, эти проверки просто не нужны. В частности, и GCC, и clang/llvm их вставляют только если очень хорошо попросить специальной опцией, и при этом часть оптимизаций идёт лесом.