Обнаружение прерываний при переносе кода C и C++ в 64-разрядную версию Windows

Абстрактный

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

Введение

Появление 64-битных процессоров — очередной шаг в эволюции компьютерных технологий. Однако получить все преимущества нового 64-битного оборудования можно только при использовании нового набора инструкций и регистров. Для программ, написанных на C/C++, это означает необходимость их перекомпиляции. Во время этой операции изменяются размеры типов данных, что вызывает непредвиденные ошибки при работе этих приложений на 64-битных системах [1].

Проблемы, возникающие при преобразовании кода, характерны в основном для тех приложений, которые написаны на низкоуровневых языках программирования, таких как C и C++. В языках с четко структурированной системой типов (например, .NET Framework) таких проблем, как правило, не возникает.

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

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

Существующие подходы к тестированию приложений

Существуют различные подходы к обеспечению корректности кода приложений, некоторые из них можно автоматизировать, а другие нет. Те, которые нельзя автоматизировать, — это ручная проверка кода, тестирование белого ящика, ручное тестирование и т. д. Статические анализаторы кода и тестирование черного ящика — это методы, которые можно автоматизировать. Рассмотрим эти методы подробно.

Обзор кода

Самый старый, проверенный и надежный подход к поиску ошибок — проверка кода. Этот метод основан на командном чтении кода с соблюдением некоторых правил и рекомендаций [2]. К сожалению, эту практику нельзя использовать для широкого тестирования современных программных систем из-за их большого размера. Хотя этот метод дает наилучшие результаты, он не всегда используется в условиях современных жизненных циклов разработки программного обеспечения, где очень важным фактором является срок разработки и выпуска продукта. Поэтому код-ревью выглядит как редкие встречи, целью которых является обучение новых и менее опытных сотрудников написанию качественного кода, а не проверка работоспособности некоторых модулей. Это очень хороший способ повышения квалификации программиста, но его нельзя рассматривать как полноценное средство обеспечения качества.

Статические анализаторы кода

Статические анализаторы кода помогают разработчикам, которые осознают необходимость регулярной проверки кода, но не имеют на это достаточно времени [3]. Их основная цель — уменьшить объем кода, который должен быть проверен программистом, и, таким образом, сократить время проверки. Статические анализаторы кода — это большой класс программ, которые реализованы для разных языков программирования и имеют различный набор функций — от простейшего выравнивания кода до сложного анализа потенциально опасных мест. Систематизированное использование статических анализаторов позволяет значительно улучшить качество кода и найти множество ошибок. Подход статического анализа имеет много сторонников и много интересных статей об этом подходе. Преимущество такого подхода в том, что его можно использовать без учета сложности и размера разрабатываемого программного решения.

Анализаторы динамического кода

Динамический анализ кода — это анализ программного обеспечения, выполняемый при выполнении программ на реальном или виртуальном процессоре. Под динамическим анализом часто понимают исследование программного кода с целью его оптимизации. Но мы будем рассматривать динамический анализ как метод тестирования программы.

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

Метод белого ящика

Метод тестирования белого ящика заключается в выполнении максимального количества доступных ветвей кода с помощью отладчика или других средств. Чем больше достигается покрытие кода, тем полнее проводится тестирование. Под методом тестирования белого ящика также иногда понимают простую отладку с целью найти определенную ошибку. Полное тестирование всего программного кода методом белого ящика давно стало невозможным из-за огромного размера кода современных программ. В настоящее время метод тестирования белого ящика удобен на этапе обнаружения ошибки и выяснения причины, вызвавшей ее. У метода тестирования белого ящика есть противники, отрицающие эффективность отладки программ в реальном времени. Основная причина в том, что возможность наблюдать за работой программы и одновременно вносить в нее изменения — неприемлемый подход в программировании, основанный на большом количестве правок кода методом «проб и ошибок». Мы не будем касаться этих споров, но упомянем, что метод тестирования белого ящика в любом случае является очень дорогим способом улучшения качества больших и сложных программных систем.

Метод черного ящика

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

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

Ручное тестирование

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

Выводы по методам испытаний

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

Особенности тестирования и проверки 64-битных приложений

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

Использование статических анализаторов кода

Как ни странно, статические анализаторы оказались плохо подготовленными к обнаружению ошибок в 64-битных программах, несмотря на все их большие возможности, длительный период разработки и практику использования. Рассмотрим ситуацию на примере анализа кода C++ как области, где чаще всего используются статические анализаторы. Многие статические анализаторы следуют набору правил, связанных с обнаружением кода, который ведет себя некорректно при переносе на 64-битные системы. Но делают это довольно несогласованно и неполно. Это стало особенно очевидно, когда началась широкая разработка приложений для 64-битной версии операционной системы Windows в среде Microsoft Visual C++ 2005.

Это можно объяснить тем, что большинство тестов основано на достаточно старых материалах по исследованию проблем конвертации программ на 64-битных системах с точки зрения языка Си. В результате некоторые конструкции, появившиеся в языке C++, не учитывались с точки зрения контроля переносимости и не учитывались в анализаторах [4]. Кроме того, не были учтены и некоторые другие изменения. Например, размер оперативной памяти, который сильно вырос, и использование разных моделей данных в разных компиляторах. Модель данных — это соотношение размеров базовых типов в языке программирования (см. табл. 1). В 64-битных Unix-системах используются модели данных LP64 или ILP64, а в Windows — модель LLP64. Подробнее о моделях данных можно узнать в источнике [5].

Таблица 1. Размеры типов данных в разных моделях данных.

Чтобы наглядно это увидеть, рассмотрим несколько примеров.

double *BigArray;
int Index = 0;
while (...)
  BigArray[Index++] = 3.14;

Получить диагностическое предупреждение по такому коду с помощью статического анализа сложно. Это не удивительно. Приведенный код ни о чем не заставит рядового разработчика заподозрить, так как он привык использовать в качестве индексов для массивов переменные типов int и unsigned. К сожалению, данный код не будет работать на 64-битной системе, если размер массива BigArray превышает размер четырех Гб элементов. В этом случае произойдет переполнение переменной Index и результат выполнения программы будет неверным. Правильным вариантом является использование типа size_t в программировании для Windows x64 (модель данных LLP64) или типа size_t/unsigned long в программировании для Linux (модель данных LP64).

Причина, по которой статические анализаторы не могут диагностировать такой код, вероятно, заключается в том, что вряд ли кто-то предполагал, что могут быть массивы из более чем 4 миллиардов элементов, в то время, когда исследовались вопросы миграции на 64-битные системы. А 4 миллиарда элементов типа double — это 4*8=32 ГБ памяти для одного массива. Огромный размер, особенно если учесть время — 1993–1995-е годы. Именно в этот период происходило большинство вопросов и дискуссий, посвященных использованию 64-битных систем.

В результате никто не обратил внимания на возможную некорректную индексацию при использовании типа int, а в дальнейшем проблемы миграции мало исследовались.

Давайте рассмотрим другой пример.

char *pointer;     
long g=(long)(pointer);

С помощью этого простого примера вы можете проверить, какие модели данных понимает используемый вами статический анализатор. Проблема в том, что большинство из них предназначены только для модели данных LP64. Опять же это связано с историей развития 64-битных систем. Именно модель данных LP64 приобрела наибольшую популярность на первых этапах развития 64-битных систем и в настоящее время широко используется в Unix-мире. Длинный тип в этой модели данных имеет размер 8 байт, а это означает, что этот код абсолютно корректен. Однако 64-битные системы Windows используют модель данных LLP64, и в этой модели размер длинного типа остается 4-байтным, а данный код неверен. В таких случаях в Windows используются типы LONG_PTR или ptrdiff_t.

К счастью, данный код будет определен как опасный даже компилятором Microsoft Visual C++ 2005. Но вы всегда должны помнить о таких ловушках при использовании статических анализаторов.

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

Для Unix-систем с моделью данных LP64 таким анализатором может быть один из таких известных инструментов, как Gimpel Software PC-Lint или Parasoft C++test, а для Windows с моделью LLP64 — специализированный анализатор Viva64 [6 ].

Использование метода черного ящика

Теперь поговорим о юнит-тестах. Разработчики, использующие их на 64-битных системах, тоже столкнутся с неприятными моментами. Стремясь сократить время выполнения тестов, стараются использовать как можно меньше вычислений и данных, обрабатываемых при их разработке. Например, когда разрабатывается тест с функцией поиска элементов массива, не имеет значения, будет ли он обрабатывать 100 или 10 000 000 элементов. Сто заданий будет достаточно, и по сравнению с обработкой 10 000 000 заданий тест будет выполнен намного быстрее. Но если вы хотите разработать полноценные тесты для проверки этой функции на 64-битной системе, вам потребуется обработать более 4 миллиардов элементов! Вам кажется, что если функция работает со 100 элементами, то она будет работать и с миллиардами? Нет. Вот пример кода, который вы можете попробовать на 64-битной системе.

bool FooFind(char *Array, char Value,
             size_t Size)
{
  for (unsigned i = 0; i != Size; ++i)
    if (i % 5 == 0 && Array[i] == Value)
      return true;
  return false;
}       
#ifdef _WIN64
  const size_t BufSize = 5368709120ui64;
#else
  const size_t BufSize = 5242880;
#endif
int _tmain(int, _TCHAR *) {
  char *Array =
    (char *)calloc(BufSize, sizeof(char));
  if (Array == NULL)
    std::cout << "Error allocate memory";
  if (FooFind(Array, 33, BufSize))
    std::cout << "Find";
  free(Array);
}

Некорректность кода заключается в возникновении бесконечного цикла, поскольку переменная счетчика i не превысит значение UINT_MAX и не будет выполняться условие i != Size.

Как видно из примера, не стоит полагаться на старые наборы юнит-тестов, если ваша программа начинает обрабатывать большой объем данных на 64-битной системе. Следует расширить тесты с учетом обработки большого объема данных.

К сожалению, недостаточно создавать новые тесты. Здесь мы сталкиваемся с проблемой скорости выполнения модифицированного набора тестов, которые охватывают обработку большого объема данных. Первое последствие — вы не сможете добавить такие тесты в набор тестов, запускаемых программистом в процессе разработки. При добавлении их в ночные тесты также могут возникнуть трудности. Суммарное время выполнения всех испытаний может увеличиться на одну-две ступени, а то и больше. В результате тест может длиться более 24 часов. Следует помнить об этом и очень серьезно относиться к переделке тестов для 64-битной версии программы.

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

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

Использование метода белого ящика

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

Вывод

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

Обобщая проблемы разработки и тестирования 64-битных систем, хотелось бы напомнить несколько ключевых моментов:

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

использованная литература

Статья опубликована с разрешения автора.