Как защищать границы массива без команды BOUND

61ef931ef8de2068a7f157742a240fd6.jpg

Я уже плакался по поводу исключения в 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, разумеется, можно функционально заменить совокупностью нескольких других команд. Хотя это раздувает код. Можно также оставить их в коде и обрабатывать исключения от этих команд, как от запрещенных. Хотя это замедляет выполнение. Обидно также, что прекрасная идея поддержки процессором языков высокого уровня не находит понимания.

© Habrahabr.ru