C++11 и 64-битные ошибки

Андрей Карпов
Статей: 328



64-битные компьютеры давно и успешно используются. Большинство приложений стали 64-битными. Это позволяет им использовать больший объем памяти, а также получить прирост производительности за счёт архитектурных возможностей 64-битных процессоров. Создание 64-битных программ на языке Си/Си++ требует от программиста внимательности. Существует масса причин, из-за которых код 32-битной программы отказывается корректно работать после перекомпиляции для 64-битной системы. Про это написано много статей. Но сейчас нам интересен другой вопрос. Давайте посмотрим, позволяет ли использование новых возможностей, появившихся в C++11, облегчить жизнь программистов, создающих 64-битные программы.

Примечание. Первоначальна статья была опубликована в Software Developer's Journal (April 25, 2014). Статья публикуется с разрешения редакции.

Picture 1

Мир 64-битных ошибок

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

Время не стоит на месте, и вот уже программисты используют обновлённую версию языка C++, получившего названия C++11. На данный момент большинство нововведений, описанных в стандарте языка C++11, поддерживается современными компиляторами. Давайте посмотрим, могут ли эти нововведения как-то помочь программисту избежать 64-битных ошибок.

Статья будет построена следующим образом. Будет даваться краткое описание типичной 64-битной ошибки, и предлагаться способы, как её избежать, используя C++11. Сразу отметим, что далеко не всегда С++11 может хоть чем-то помочь. Защитить от ошибок может только аккуратное программирование. А новый стандарт лишь помогает в этом, но не решить все проблемы за программиста.

Магические числа

Речь идёт об использовании таких чисел, как 4, 32, 0x7FFFFFFF, 0xFFFFFFFF (подробнее). Плохо, если программист предположил, что размер указателя всегда равен 4 байтам и написал вот такой код:

int **array = (int **)malloc(n * 4);

Здесь стандарт C++11 нам помочь не может. Магические числа, это зло и единственный способ избежать ошибок - это стараться их не использовать.

Примечание. Да, malloc() это не C++, а старый добрый C. Намного лучше использовать оператор new или контейнер std::vector. Но сейчас это к делу не относится. Разговор про магические числа.

Впрочем, С++11 иногда помогает сократить число магических чисел. Некоторые магические числа в программе появляется из-за боязни (часто необоснованной), что компилятор плохо оптимизирует код. В этом случае, стоит обратить внимание на generalized constant expressions (сonstexpr).

Механизм constexpr гарантирует инициализацию выражений во время компиляции. При этом, можно объявить функции, которые гарантированно развернутся в константу на этапе компиляции. Пример:

constexpr int Formula(int a) {
  constexpr int tmp = a * 2;
  return tmp + 55;
}
int n = Formula(1);

Вызов функции Formula(1) превратится в число. Объяснение конечно слишком краткое. Подробнее про "constexpr" и другие нововведения можно прочитать, перейдя по ссылкам, приведённым в конце статьи.

Функции с переменным количеством аргументов

Речь идёт о неправильном использовании таких функций, как printf, scanf (подробнее). Пример:

size_t value = ....;
printf("%u", value);

Этот код корректно работает в 32-битной программе, но может распечатать некорректные значения, когда программа превратится в 64-битную.

Функции с переменным количеством аргументов - пережиток языка Си. Их недостаток в отсутствии контроля типов фактических аргументов. В Си++ уже давно пора отказаться от них. Есть масса других способов форматирования строк. Например, можно заменить printf на cout, а sprintf на boost::format или std::stringstream.

С языком C++11 жизнь стала ещё лучше. В C++11 появились шаблоны с переменным числом параметров (Variadic Templates). Это позволяет реализовывать вот такой безопасный вариант функции printf:

void printf(const char* s)
{
  while (s && *s) {
    if (*s=='%' && *++s!='%')
      throw runtime_error("invalid format: missing arguments");
    std::cout << *s++;
  }
}
template<typename T, typename... Args>
void printf(const char* s, T value, Args... args)
{
  while (s && *s) {
    if (*s=='%' && *++s!='%') {
      std::cout << value;
      return printf(++s, args...);
    }
    std::cout << *s++;
  }
}

Этот код просто "достает" первый аргумент, не являющийся форматной строкой, и затем вызывает себя рекурсивно. Когда таких аргументов больше не останется, будет вызвана первая (более простая) версия метода printf().

Тип Args... определяет так называемую "группу параметров" ("parameter pack"). По сути, это последовательность пар тип/значение, из которых вы можете "доставать" аргументы, начиная с первого. При вызове функции printf() с одним аргументом, будет выбран первый метод (printf(const char*)). При вызове функции printf() с двумя или более аргументами, будет выбран второй метод (printf(const char*, T value, Args... args)), с первым параметром s, вторым - value, и оставшиеся параметры (если они есть) будут запакованы в группу параметров args, для последующего использования. При вызове:

printf(++s, args...); 

Группа параметров args сдвигается на один, и следующий параметр может быть обработан в виде value. И так продолжается до тех пор, пока args не станет пустым (и будет вызвана первая версия метода printf()).

Некорректные операции сдвига

Числовой литерал 1 имеет тип int. Значит, его нельзя сдвигать более чем на 31 разряд (подробнее). Про это часто забывают, и в программах можно встретить вот такой код:

ptrdiff_t mask = 1 << bitNum;

Если, значение bitNum будем равно, скажем 40, то результат будет непредсказуем. Формально, это приведёт к undefined behavior (подробнее).

Может нам помочь C++11? К сожалению, ничем.

Рассинхронизация виртуальных функций

Пусть в базовом классе объявлена виртуальная функция:

int A(DWORD_PTR x);

А в классе наследнике есть функция:

int A(DWORD x);

В 32-битной программе типы DWORD_PTR и DWORD совпадают. Однако, в 64-битной программе, это уже два разных типа (подробнее). В результате, вызов функции A из базового класса будет приводить к различным результатам в 32-битной и 64-битной программе.

Бороться с подобными ошибками могут помочь новые ключевые слова, появившиеся в C++11.

Теперь у нас есть ключевое слово override, которое позволяет программисту явно выражать свои намерения насчет переопределения функций. Объявление функции с ключевым словом override является корректным, только если существует функция для переопределения.

Этот код не скомпилируется в 64-битном режиме и тем самым ошибка будет обезврежена:

struct X
{
  virtual int A(DWORD_PTR) { return 1; }
};
struct Y : public X
{
  int A(DWORD x) override { return 2; }
};

Смешанная арифметика

Это достаточно важная и обширная тема. Предлагаю познакомиться с соответствующим заделом "64-битных уроков": Смешанная арифметика.

Совсем кратко:

  • Программисты часто забывают, что результат перемножения и сложения двух переменных типа 'int', тоже имеет тип 'int'. При этом может возникнуть переполнение. И не важно, как потом используется результат умножения и сложения.
  • Опасно смешивать 32-битные и 64-битные типы данных. Последствия: неправильные условия, вечные циклы.

Рассмотрим несколько простых примеров про переполнение

char *p = new char[1024*1024*1024*5];

Программист пытается выделить массив 5 гигабайт памяти, но выделит гораздо меньше. Дело в том, что выражение "1024*1024*1024*5" имеет тип int. В результате произойдёт переполнение, и выражение будет равно 1073741824 (1 гигабайт). Затем, при передачи в оператор 'new', число 1073741824 будет расширено до типа size_t, но это не имеет значения (уже поздно).

Если проблема не понятна, то вот другой аналогичный пример:

unsigned a = 1024, b = 1024, c = 1024, d = 5;
size_t n = a * b * c * d;

Результат выражения помещается в переменную типа 'size_t'. Она способна хранить значения больше, чем UINT_MAX. Но при перемножении переменных типа 'unsigned' возникает переполнение и результат будет некорректен.

Почему мы называем всё это 64-битными ошибками? Дело в том, что в 32-битной программе невозможно выделить массив размером более 2 гигабайт. А значит, переполнения просто не возникают. Проявляют себя такие ошибки только в 64-битных программах, когда они начинают работать с большими объёмами памяти.

Теперь пара примеров про сравнение

size_t Count = BigValue;
for (unsigned Index = 0; Index < Count; ++Index)
{ ... }  

Это пример вечного цикла, если Count > UINT_MAX. Предположим, что на 32-битных системах этот код выполнял менее повторения, менее чем UINT_MAX раз. Но 64-битный вариант программы может обрабатывать больше данных, и ему может потребоваться большее количество итераций. Поскольку значения переменной Index лежат в диапазоне [0..UINT_MAX], то условие "Index < Count" всегда выполняется, что и приводит к бесконечному циклу.

Ещё один пример:

string str = .....;
unsigned n = str.find("ABC");
if (n != string::npos)

Этот код некорректен. Функция find() возвращает значение типа string::size_type. Всё будет отлично работать в 32-битной системе. Но давайте посмотрим, что произойдет в 64-битной программе.

В 64-битной программе string::size_type и unsigned перестают совпадать. Если подстрока не находится, функция find() возвращает значение string::npos, которое равно 0xFFFFFFFFFFFFFFFFui64. Это значение урезается до величины 0xFFFFFFFFu и помещается в 32-битную переменную. Вычисляется выражение: 0xFFFFFFFFu != 0xFFFFFFFFFFFFFFFFui64. Получается, что условие (n != string::npos) всегда истинно!

Может здесь как-то помочь C++11?

Ответ - и да, и нет.

В некоторых случаях, нам может помочь новое ключевое слово auto. А в некоторых оно может только запутать программиста. Поэтому, давайте внимательно разберёмся.

Если объявить "auto a = .....", то её тип будет вычислен автоматически. Очень важно не запутаться и не написать вот такой неправильный код: "auto n = 1024*1024*1024*5;".

Поговорим о ключевом слове auto. Рассмотрим следующий пример:

auto x = 7;

В данном случае тип переменной 'x' будет 'int', потому что именно такой тип имеет ее инициализатор. В общем случае мы можем написать:

auto x = expression;

И тип переменной 'x' будет равен типу значения, полученному в результате вычисления выражения.

Ключевое слово 'auto' для вывода типа переменной из ее инициализатора, наиболее полезно, когда точный тип выражения не известен, либо сложен в написании. Рассмотрим пример:

template<class T> void printall(const vector<T>& v)
{
  for (auto p = v.begin(); p!=v.end(); ++p)
    cout << *p << "\n";
}

В С++98, вам бы пришлось писать гораздо более длинный код:

template<class T> void printall(const vector<T>& v)
{
    for (typename vector<T>::const_iterator p = v.begin(); 
         p!=v.end(); ++p)
      cout << *p << "\n";
}

Очень полезное нововведение в языке C++11.

Вернемся к нашей проблеме. Выражение "1024*1024*1024*5" имеет тип 'int'. Так что для сейчас нам 'auto' никак не поможет.

Не поможет нам 'auto' и в случае цикла:

size_t Count = BigValue;
for (auto Index = 0; Index < Count; ++Index)

Стало лучше? Нет. Число 0 имеет тип 'int'. Значит переменная Index теперь будет иметь тип не 'unsigned', а "int'. Пожалуй, стало даже хуже.

Так есть ли хоть какой-то нам прок от 'auto'? Да, есть. Например, здесь:

string str = .....;
auto n = str.find("ABC");
if (n != string::npos)

Переменная 'n' будет иметь тип string::size_type. Теперь всё хорошо.

Вот наконец нам и пригодилось новое ключевое слово 'auto'. Однако, будьте аккуратны. Нужно понимать, что и зачем вы делаете. Не надо надеяться побороть все ошибки, связанные со смешанной арифметикой, используя повсеместно 'auto'. Это всего лишь один из инструментов, а не панацея.

Кстати, есть ещё один способ защититься от обрезания типа в рассмотренном ранее примере:

unsigned n = str.find("ABC");

Можно использовать новый формат инициализации переменных, который предотвращает сужение (narrowing) типов. Проблема заключается в том, что языки С и С++ неявно обрезают некоторые типы:

int x = 7.3;  // Ой!
void f(int);
f(7.3);  // Ой!

Однако списки инициализации С++11 не позволяют сужение (narrowing) типов:

int x0 {7.3}; //compilation error
int x1 = {7.3}; //compilation error
double d = 7;
int x2{d}; //compilation error

Для нас сейчас более интересны вот эти пример:

size_t A = 1;
unsigned X = A;
unsigned Y(A);
unsigned Q = { A }; //compilation error
unsigned W { A }; //compilation error

Представим, что код написан так:

unsigned n = { str.find("ABC") };
   или так
unsigned n{str.find("ABC")};

Этот код будет компилироваться в 32-битном режиме и перестанет компилироваться в 64-битном режиме.

Опять это не панацея от всех ошибок. Просто ещё один способ писать более надёжные программы.

Адресная арифметика

Проблема во многом схожа с тем, что мы рассмотрели в разделе "Смешанная арифметика". Отличие лишь в том, что переполнение возникает при работе с указателями (подробнее).

Рассмотрим пример:

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

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

Может здесь как-то помочь C++11? Нет.

Изменение типа массива и упаковка указателей

Иногда в программах необходимо (или просто удобно) представлять элементы массива в виде элементов другого типа (подробнее). Ещё бывает удобно хранить указатели в переменных целочисленного типа (подробнее).

Ошибки возникают из-за неправильных явных приведений типов. С новым стандартом С++11 здесь никакой взаимосвязи нет. Явные приведения типов всегда делались на свой собственный страх и риск.

Следует ещё упомянуть про работу с данными, находящимися в объединениях (union). Такая работа с данными является низкоуровневой и также зависит только от умений и знаний программиста (подробнее).

Сериализация и обмен данными

В проекте может возникнуть потребность создания совместимого формата данных. То есть один набор данных должен обрабатываться как 32-битной, так и 64-битной версией программы. Сложность заключается в том, что меняются размеры некоторых типов данных (подробнее).

Стандарт C++11 немного облегчил жизнь, введя типы фиксированного размера. Раньше программисты объявляли такие типы самостоятельно или использовали типы, объявленные в одной из системной библиотек.

Теперь есть следующие типы фиксированного размера:

  • int8_t
  • int16_t
  • int32_t
  • int64_t
  • uint8_t
  • uint16_t
  • uint32_t
  • uint64_t

Кроме размера изменяются выравнивание данных в памяти (data alignment). Это тоже может предоставить определённые сложности (подробнее).

Касательно этой темы, стоит упомянуть появление в С++11 нового ключевого слова 'alignment'. Теперь можно написать вот такой код:

// массив символов, выровнен для хранения типов double
alignas(double) unsigned char c[1024]; 
// выравнивание по 16 байтной границе
alignas(16) char[100];

Существует также оператор 'alignof', который возвращает выравнивание для указанного аргумента (аргумент должен быть типом). Пример:

constexpr int n = alignof(int);

Перегруженные функции

При переносе 32-битных программ на 64-битную платформу может наблюдаться изменение логики ее работы, связанное с использованием перегруженных функций. Если функция перекрыта для 32-битных и 64-битных значений, то обращение к ней с аргументом, например, типа size_t будет транслироваться в различные вызовы на различных системах (подробнее).

Я затрудняюсь ответить, можно ли использовать какое-то из новых свойств языка для борьбы с такими ошибками.

Проверки размеров типа

Бывают случаи, когда необходимо проверить размеры типов данных. Это нужно, чтобы не получить глючную программу после перекомпиляции кода для новой системы.

Часто это делают неправильным способом. Например, так:

assert(sizeof(unsigned) < sizeof(size_t));
assert(sizeof(short) == 2);

Плохой способ. Во-первых, программа всё равно компилируется. Во-вторых, эти проверки проявят себя только в отладочной версии.

Гораздо лучше останавливать компиляцию, если необходимые условия не выполняются. Для этого существует множество решений. Например, можно использовать макрос _STATIC_ASSERT, который доступен разработчикам, использующим Visual Studio. Пример использования:

_STATIC_ASSERT(sizeof(int) == sizeof(long));

C++11 стандартизировал способ, как остановить компиляцию, если что-то пошло не так. В язык введены утверждения времени компиляции (static assertions).

Статические утверждения (утверждения времени компиляции) содержат константное выражение и строковый литерал:

static_assert(expression, string);

Компилятор вычисляет выражение, и если результат вычисления равен false (т.е. утверждение нарушено), выводит строку в качестве сообщения об ошибке. Примеры:

static_assert(sizeof(size_t)>=8, 
  "64-bit code generation required for this library.");

struct S { X m1; Y m2; };
static_assert(sizeof(S)==sizeof(X)+sizeof(Y),
  "unexpected padding in S");

Заключение

Написание кода с максимальным использованием новых конструкций языка C++11 вовсе не гарантирует отсутствия 64-битных ошибок. Однако, язык представляет несколько новых возможностей, которые позволят сделать код более коротким и надёжным.

Дополнительные ресурсы

В статье не делалась попытка ознакомить читателя как с можно большим количеством нововведений в языке C++11. Для первого знакомства с новым стандартам можно порекомендовать следующие ресурсы:



Используйте PVS-Studio для поиска ошибок в C, C++ и C# коде

Предлагаем попробовать проверить код вашего проекта с помощью анализатора кода PVS-Studio. Одна найденная в нём ошибка скажет вам о пользе методологии статического анализа кода больше, чем десяток статей.

goto PVS-Studio;

Андрей Карпов
Статей: 328


А ты совершаешь ошибки в коде?

Проверь с помощью
PVS-Studio

Статический анализ
кода для C, C++ и C#

goto PVS-Studio;
На нашем сайте мы используем cookie для сбора информации технического характера.
Если вы не согласны, пожалуйста, покиньте сайт. Подробнее →
Больше не показывать