Урок 13. Паттерн 5. Адресная арифметика

24.01.2012

Мы специально выбрали номер "тринадцать" для этого урока, поскольку ошибки, связанные с адресной арифметикой в 64-битных системах, являются наиболее коварными. Надеемся, число 13 заставит вас быть внимательнее.

Суть паттерна: чтобы избежать ошибок в 64-битном коде используйте для адресной арифметики только memsize-типы.

Рассмотрим код:

unsigned short a16, b16, c16;
char *pointer;
...
pointer += a16 * b16 * c16;

Данный пример корректно работает с указателями, если значение выражения "a16 * b16 * c16" не превышает INT_MAX (2147483647). Такой код мог всегда корректно работать на 32-битной платформе. В рамках 32-битной архитектуры программе недоступен объем памяти для создания массива подобного размеров. На 64-битной архитектуре это ограничение снято, и размер массива легко может превысить INT_MAX элементов. Допустим, мы хотим сдвинуть значение указателя на 6.000.000.000 байт, и поэтому переменные a16, b16 и c16 имеют значения 3000, 2000 и 1000 соответственно. При вычислении выражения "a16 * b16 * c16" все переменные, согласно правилам языка Си++, будут приведены к типу int, а уже затем будет произведено их умножение. В ходе выполнения умножения произойдет переполнение. Некорректный результат выражения будет расширен до типа ptrdiff_t, и произойдет некорректное вычисление указателя.

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

short a16, b16, c16;
char *pointer;
...
pointer += static_cast<ptrdiff_t>(a16) *
           static_cast<ptrdiff_t>(b16) *
           static_cast<ptrdiff_t>(c16);

Если вы думаете, что злоключения ждут неаккуратные программы только на больших объемах данных, то мы вынуждены вас огорчить. Рассмотрим интересный код для работы с массивом, содержащим всего 5 элементов. Этот пример работоспособен в 32-битном варианте и не работоспособен в 64-битном:

int A = -2;
unsigned B = 1;
int array[5] = { 1, 2, 3, 4, 5 };
int *ptr = array + 3;
ptr = ptr + (A + B); //Invalid pointer value on 64-bit platform
printf("%i\n", *ptr); //Access violation on 64-bit platform

Давайте проследим, как происходит вычисление выражения "ptr + (A + B)":

  • Согласно правилам языка Си++ переменная A типа int приводится к типу unsigned.
  • Происходит сложение A и B. В результате мы получаем значение 0xFFFFFFFF типа unsigned.
  • Вычисляется выражение "ptr + 0xFFFFFFFFu".

Что из этого выйдет, будет зависеть от размера указателя на данной архитектуре. Если сложение будет происходить в 32-битной программе, то данное выражение будет эквивалентно "ptr - 1", и мы успешно распечатаем число "3". В 64-битной программе к указателю честным образом прибавится значение 0xFFFFFFFFu, в результате чего указатель окажется далеко за пределами массива. И при доступе к элементу по данному указателю нас ждут неприятности.

Для предотвращения подобной ситуации, как и в первом случае, рекомендуем использовать в арифметике с указателями только memsize-типы. Два варианта исправления кода:

ptr = ptr + (ptrdiff_t(A) + ptrdiff_t(B));
ptrdiff_t A = -2;
size_t B = 1;
...
ptr = ptr + (A + B);

Вы можете возразить и предложить следующий вариант исправления:

int A = -2;
int B = 1;
...
ptr = ptr + (A + B);

Да, такой код будет работать, но он плох по ряду причин:

  • Он будет приучать к неаккуратной работе с указателями. Через некоторое время вы можете забыть нюансы и по ошибке вновь сделать одну из переменных типа unsigned.
  • Использование не memsize-типов совместно с указателями потенциально опасно само по себе. Допустим, что в выражении с указателем участвует переменная Delta типа int, и это выражение совершенно корректно. Ошибка может крыться в вычислении самой переменной Delta, так как 32 бит может не хватить для необходимых вычислений, при работе с большими массивами данных. Использование memsize-типа для переменной Delta автоматически устраняет такую опасность.
  • Код, использующий при работе с указателями типы size_t, ptrdiff_t и другие memsize-типы, приводит к генерации более оптимального двоичного кода, о чем будет рассказано подробнее в одном из следующих уроков.

Индексация массивов

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

В программах, обрабатывающих большие объемы данных, могут встретиться ошибки связанные с индексацией больших массивов, или возникнуть вечные циклы. Следующий пример содержит сразу 2 ошибки:

const size_t size = ...;
char *array = ...;
char *end = array + size;
for (unsigned i = 0; i != size; ++i)
{
  const int one = 1;
  end[-i - one] = 0;
}

Первая ошибка заключается в том, что если размер обрабатываемых данных превысит 4 гигабайта (0xFFFFFFFF), то возможно возникновение вечного цикла, поскольку переменная 'i' имеет тип 'unsigned' и никогда не достигнет значения больше чем 0xFFFFFFFF. Мы специально пишем, что возникновение возможно, но не обязательно оно произойдет. Это зависит от того, какой код построит компилятор. Например, в отладочном (debug) режиме вечный цикл будет присутствовать, а в release-коде зацикливание исчезнет, так компилятор примет решение оптимизировать код, используя для счетчика 64-битный регистр, и цикл будет корректным. Все это добавляет путаницы, и код, который работал вчера, неожиданно может перестать работать на следующий день.

Вторая ошибка связана с проходом по массиву от конца к началу, для чего используются отрицательные значения индексов. Приведенный код работоспособен в 32-битном режиме, но при его запуске на 64-битной машине на первой же итерации цикла произойдет доступ за границы массива, и программа аварийно завершится. Рассмотрим причину такого поведения.

Хотя то, что написано ниже, аналогично примеру с "ptr = ptr + (A + B);", это повторение делается сознательно. Важно показать, что опасность скрывается даже в простых конструкциях и может выглядеть по-разному.

Согласно правилам языка Си++ на 32-битной системе выражение "-i - one" будет вычисляться следующим образом (на первом шаге i = 0):

  • Выражение "-i" имеет тип unsigned и имеет значение 0x00000000u.
  • Переменная 'one' будет расширена от типа 'int' до типа unsigned и будет равна 0x00000001u. Примечание: Тип int расширяется (согласно стандарту языка Си++) до типа 'unsigned', если он участвует в операции, где второй аргумент имеет тип unsigned.
  • Происходит операция вычитания, в котором участвуют два значения типа unsigned и результат выполнения операции равен 0x00000000u - 0x00000001u = 0xFFFFFFFFu. Обратите внимание, что результат имеет беззнаковый тип.

На 32-битной системе обращение к массиву по индексу 0xFFFFFFFFu эквивалентно использованию индекса -1. То есть end[0xFFFFFFFFu] является аналогом end[-1]. В результате происходит корректная обработка элемента массива. В 64-битной системе в последнем пункте картина будет иной. Произойдет расширение типа unsigned до знакового ptrdiff_t и индекс массива будет равен 0x00000000FFFFFFFFi64. В результате произойдет выход за рамки массива.

Для исправления кода необходимо использовать такие типы, как ptrdiff_t и size_t.

Чтобы окончательно убедить вас в необходимости использования только memsize-типов для индексации и в выражениях адресной арифметики, приведем еще один пример.

class Region {
  float *array;
  int Width, Height, Depth;
  float Region::GetCell(int x, int y, int z) const;
  ...
};
float Region::GetCell(int x, int y, int z) const {
  return array[x + y * Width + z * Width * Height];
}

Данный код взят из реальной программы математического моделирования, в которой важным ресурсом является объем оперативной памяти, и возможность на 64-битной архитектуре использовать более 4 гигабайт памяти существенно увеличивает вычислительные возможности. В программах данного класса для экономии памяти часто используют одномерные массивы, осуществляя работу с ними как с трехмерными массивами. Для этого существуют функции, аналогичные GetCell, обеспечивающие доступ к необходимым элементам. Но приведенный код будет корректно работать только с массивами, содержащими менее INT_MAX элементов. Причина - использование 32-битных типов int для вычисления индекса элемента.

Программисты часто допускают ошибку, пытаясь исправить код следующим образом:

float Region::GetCell(int x, int y, int z) const {
  return array[static_cast<ptrdiff_t>(x) + y * Width +
               z * Width * Height];
}

Они знают, что по правилам языка Си++ выражение для вычисления индекса будет иметь тип ptrdiff_t и надеются за счет этого избежать переполнения. Но переполнение может произойти внутри подвыражения "y * Width" или "z * Width * Height", так как для их вычисления по-прежнему используется тип int.

Если вы хотите исправить код, не изменяя типов переменных, участвующих в выражении, то вы можете явно привести каждую переменную к memsize-типу:

float Region::GetCell(int x, int y, int z) const {
  return array[ptrdiff_t(x) +
               ptrdiff_t(y) * ptrdiff_t(Width) +
               ptrdiff_t(z) * ptrdiff_t(Width) *
               ptrdiff_t(Height)];
}

Другое, более верное решение - изменить типы переменных на memsize-тип:

typedef ptrdiff_t TCoord;
class Region {
  float *array;
  TCoord Width, Height, Depth;
  float Region::GetCell(TCoord x, TCoord y, TCoord z) const;
  ...
};
float Region::GetCell(TCoord x, TCoord y, TCoord z) const {
  return array[x + y * Width + z * Width * Height];
}

Диагностика

Ошибки адресной арифметики хорошо диагностируются инструментом PVS-Studio. Анализатор предупреждает о потенциально опасных выражениях с помощью диагностических сообщений V102 и V108.

По возможности анализатор пытается понять, когда использование не memsize-типа в адресной арифметике безопасно и не выдавать в этом месте предупреждения. В результате, поведение анализатора иногда может показаться странным. В таких случаях мы просим не спешить и постараться разобраться. Рассмотрим следующий код:

char Arr[] = { '0', '1', '2', '3', '4' };
char *p = Arr + 2;
cout << p[0u + 1] << endl;
cout << p[0u - 1] << endl; //V108

Данный код исправно работает в 32-битном режиме и печатает на экране числа 3 и 1. При проверке этого кода мы получим предупреждение только на одну строку с выражением "p[0u - 1]". И это совершенно верно! Если вы скомпилируете и запустите данный пример в 64-битном режиме, то увидите, как на экране будет распечатано значение 3, после чего произойдет аварийное завершение программы.

Если вы уверены в корректности индексации, то вы можете изменить соответствующую настройку анализатора на вкладке настроек Settings: General или использовать фильтры. Также можно использовать явное приведение типов.

Авторы курса: Андрей Карпов (karpov@viva64.com), Евгений Рыжков (evg@viva64.com).

Правообладателем курса "Уроки разработки 64-битных приложений на языке Си/Си++" является ООО "Системы программной верификации". Компания занимается разработкой программного обеспечения в области анализа исходного кода программ. Сайт компании: http://www.viva64.com.

Контактная информация: e-mail: support@viva64.com, 300027, г. Тула, а/я 1800.