>
>
>
Поиск ловушек в Си/Си++ коде при перено…

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

Евгений Рыжков
Статей: 125

Поиск ловушек в Си/Си++ коде при переносе приложений под 64-битную версию Windows

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

Введение

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

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

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

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

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

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

Просмотр кода

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

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

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

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

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

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

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

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

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

Намного лучше себя зарекомендовал метод черного ящика. Сюда же можно отнести юнит-тестирование (англ. unit test). Основная идея метода заключается в написании набора тестов для отдельных модулей и функций, проверяющих все основные режимы их работы. Ряд источников относят юнит-тестирование к методу белого ящика, поскольку оно основывается на знании устройства программы. Однако функции и модули следует рассматривать как черные ящики, так как юнит-тесты не должны учитывать внутреннее устройство функции. Обоснованием этому может служить методология разработки, когда тесты разрабатываются до начала написания самих функций, что способствует повышению контроля их функциональности с точки зрения спецификации.

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

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

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

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

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

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

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

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

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

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

ILP32

LP64

LLP64

ILP64

char

8

8

8

8

short

16

16

16

16

int

32

32

32

64

long

32

64

32

64

long long

64

64

64

64

size_t, ptrdiff_t

32

64

64

64

pointers

32

64

64

64

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

Для наглядности рассмотрим несколько примеров.

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

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

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

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

Рассмотрим другой пример:

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

С помощью этого простого примера можно проверить, какие модели данных умеет понимать используемый Вами статический анализатор. Проблема в том, что большинство из них рассчитаны только на модель данных LP64. Это вновь вызвано историей развития 64-битных систем. Именно модель данных LP64 на начальных этапах развития 64-битных систем получила наибольшую популярность и сейчас широко используется в Unix-мире. В этой модели данных тип long имеет размер 8 байт, а, значит, такой код полностью корректен. Но в 64-битных системах Windows, как уже упоминалось, реализована модель данных LLP64, где размер long остался 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].

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

Теперь поговорим о юнит-тестах. C ними на 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-битной версии программы со всей серьезностью.

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

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

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

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

Заключение

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

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

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

Библиографический список