64-Битная Лошадь, Умеющая Считать

Статья посвящена особенностям поведения компилятора Visual C++ при генерации 64-битного кода и возможным ошибкам, связанным с этим.



Введение

Феномен «Умного Ганса», лошади г-на фон Остена, был описан в 1911 году [1].

Умный Ганс был известен тем, что умел читать и решать математические задачи, выстукивая ответ передним копытом.

Конечно, было много скептиков.

Поэтому способности Ганса проверила комиссия экспертов, которая установила, что лошадь продемонстрировала их без помощи господина фон Остена.

Но как мог существовать такой человек! - уровень интеллекта простой лошади? Психолог О.

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

Например, после того, как Ганса о чем-то спрашивали, люди смотрели на его переднее копыто, которым лошадь «отвечала».

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

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

Со стороны это всегда выглядело как правильный ответ на вопрос.

Вот был такой чудесный конь, который считал и решал задачи, хоть и не умел этого делать.

Цифровыми лошадками начала XXI века стали 64-битные программы, многие из которых тоже не умеют считать, хотя и успешно притворяются таковыми.

Рассмотрим это явление подробнее.



1. Возможные ошибки

Я автор и соавтор ряда статей, посвященных разработке 64-битных приложений.

На нашем сайте вы можете найти статьи: http://www.viva64.com/ru/articles/64-bit-development .

В этих статьях я стараюсь использовать термин «потенциальная ошибка» или «скрытая ошибка», а не просто «ошибка» [ 2 , 3 , 4 ].

Я объясняю это тем, что один и тот же код может считаться как правильным, так и неправильным, в зависимости от его назначения.

Простой пример — использование переменной int для индексации элементов массива.

Если мы используем эту переменную для доступа к массиву графических окон, то всё правильно.

Это не обязательно, да и работать с миллиардами окон не получится.

Но индексирование с использованием переменной int для элементов массива в 64-битных математических программах или базах данных вполне может создать проблему, когда количество элементов выходит за пределы диапазона 0.INT_MAX. Но есть и другая, гораздо более тонкая причина называть ошибки «потенциальными».

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

Я долгое время избегал этой темы, так как большинство этих ошибок хорошо проявляются в отладочной версии, а «потенциальны» только в релизных версиях.

Однако не каждую программу, скомпилированную как отладочная, можно отлаживать на больших объемах данных.

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

А нагрузочное тестирование и тестирование конечными пользователями на реальных данных выполняется на релизных версиях, где ошибки могут быть временно скрыты.

Поэтому я решил поделиться теми знаниями, которые у меня есть.

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

Вы говорите, все это для продвижения инструмента Viva64. Да, для этого, но всё же послушайте страшные истории, которые я вам сейчас расскажу.

Я люблю им рассказывать.



2. Как все началось

— Почему у вас в коде два одинаковых JMP подряд? - А если первый не сработает? Впервые с возможностями оптимизации компилятора Visual C++ 2005 я столкнулся при подготовке программы PortSample. Это проект, который входит в дистрибутив Viva64 и предназначен для демонстрации всех ошибок, которые диагностирует анализатор Viva64. Примеры, содержащиеся в этом проекте, должны корректно работать в 32-битном режиме и приводить к ошибкам в 64-битной версии.

В отладочной версии все работало отлично, а вот с релизной возникли трудности.

Код, который должен был зависать или аварийно завершать работу в 64-битном режиме, сработал успешно! Причина оказалась в оптимизации.

Решением было еще больше усложнить код примера и использовать «летучие» ключевые слова, которые в изобилии можно увидеть в проекте PortSample. То же самое касается и Visual C++ 2008. Код, конечно, будет несколько отличаться, но все, что будет написано в этой статье, можно отнести как к Visual C++ 2005, так и к Visual C++ 2008. И далее в статье никаких отличий не будет. сделал.

Если вам кажется, что это только хорошо, если какие-то ошибки не проявляются, то быстро прогоните эту мысль.

Код с такими ошибками становится крайне нестабильным.

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

На всякий случай подчеркну, что это не вина компилятора, а скрытые дефекты кода.

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



3. Фантомы

Глава будет длинной и скучной, поэтому начну с анекдота, который является ее кратким содержанием: Илья Муромец прошел через лес и вышел на поляну, где сидел Змей Горыныч.

Илья Муромец подбежал к Змею Горынычу и отрубил ему единственную голову.

И у Змея Горыныча вместо этой головы выросли две головы.

Илья отрезал две головы, выросло 4. Срубил 4, вырастил 8. И так продолжалось час, два, три.

А потом Илья Муромец срубил Змею Горынычу 32768 голов и Змей Горыныч умер, ибо он был 16-битный.

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

Давайте посмотрим на первый пример кода, который работает в режиме выпуска, хотя и не должен:

 
 int index = 0;
 size_t arraySize = .

; for (size_t i = 0; i != arraySize; i++) array[index++] = BYTE(i);

Этот код корректно заполняет значениями весь массив, даже если размер массива намного превышает INT_MAX. Теоретически это невозможно, поскольку индексная переменная имеет тип int. Через некоторое время из-за переполнения доступ к элементам должен осуществляться по отрицательному индексу.

Однако в результате оптимизации генерируется следующий код:

 
 0000000140001040  mov         byte ptr [rcx+rax],cl 
 0000000140001043  add         rcx,1 
 0000000140001047  cmp         rcx,rbx 
 000000014000104A  jne         wmain+40h (140001040h)
 
Как видите, используются 64-битные регистры и переполнения не происходит. Но давайте сделаем очень небольшую корректировку кода:
 
 int index = 0;
 for (size_t i = 0; i != arraySize; i++)
 {
   array[index] = BYTE(index);
   ++index;
 }
 
Будем считать, что так код выглядит красивее.

Согласитесь, что функционально он остался прежним.

Но результат будет существенным – программа выйдет из строя.

Давайте посмотрим на код, сгенерированный компилятором:

 
 0000000140001040  movsxd      rcx,r8d 
 0000000140001043  mov         byte ptr [rcx+rbx],r8b 
 0000000140001047  add         r8d,1 
 000000014000104B  sub         rax,1 
 000000014000104F  jne         wmain+40h (140001040h)
 
Происходит то же переполнение, которое должно было произойти в предыдущем примере.

Значение регистра r8d = 0x80000000 расширяется в rcx как 0xffffffff80000000. И как следствие — запись вне массива.

Давайте посмотрим на другой пример оптимизации и на то, как легко все испортить.

Пример:

 
 unsigned index = 0;
 for (size_t i = 0; i != arraySize; ++i) {
   array[index++] = 1;
   if (array[i] != 1) {
     printf("Error\n");
     break;
   }
 }
 
Код сборки:
 
 0000000140001040  mov         byte ptr [rdx],1 
 0000000140001043  add         rdx,1 
 0000000140001047  cmp         byte ptr [rcx+rax],1 
 000000014000104B  jne         wmain+58h (140001058h) 
 000000014000104D  add         rcx,1 
 0000000140001051  cmp         rcx,rdi 
 0000000140001054  jne         wmain+40h (140001040h) 
 
Компилятор решил использовать 64-битный регистр rdx для хранения индексной переменной.

В результате код может корректно обрабатывать массивы размером больше UINT_MAX. Но мир хрупок.

Достаточно немного усложнить код и он станет некорректным:

 
 volatile unsigned volatileVar = 1;
 .

unsigned index = 0; for (size_t i = 0; i != arraySize; ++i) { array[index] = 1; index += volatileVar; if (array[i] != 1) { printf("Error\n"); break; } }

Используя выражение «index += VolatVar;» вместо index++ приводит к тому, что в коде начинают участвовать 32-битные регистры, из-за чего возникают переполнения:
 
 0000000140001040  mov    ecx,r8d 
 0000000140001043  add    r8d,dword ptr [volatileVar (140003020h)] 
 000000014000104A  mov    byte ptr [rcx+rax],1 
 000000014000104E  cmp    byte ptr [rdx+rax],1 
 0000000140001052  jne    wmain+5Fh (14000105Fh) 
 0000000140001054  add    rdx,1 
 0000000140001058  cmp    rdx,rdi 
 000000014000105B  jne    wmain+40h (140001040h) 
 
Наконец, я приведу вам интересный, но большой пример.

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

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

 
 ptrdiff_t UnsafeCalcIndex(int x, int y, int width) {
   int result = x + y * width;
   return result;
 }
 .

int domainWidth = 50000; int domainHeght = 50000; for (int x = 0; x != domainWidth; ++x) for (int y = 0; y != domainHeght; ++y) array[UnsafeCalcIndex(x, y, domainWidth)] = 1;

Этот код не может корректно заполнить массив, состоящий из 50000*50000 элементов.

Это невозможно по той причине, что при вычислении «int result = x + y * width;» должно произойти переполнение.

Благодаря чуду массив по-прежнему заполняется корректно в релизной версии.

Функция UnsafeCalcIndex построена внутри цикла с использованием 64-битных регистров:

 
 0000000140001052  test        rsi,rsi 
 0000000140001055  je          wmain+6Ch (14000106Ch) 
 0000000140001057  lea         rcx,[r9+rax] 
 000000014000105B  mov         rdx,rsi 
 000000014000105E  xchg        ax,ax 
 0000000140001060  mov         byte ptr [rcx],1 
 0000000140001063  add         rcx,rbx 
 0000000140001066  sub         rdx,1 
 000000014000106A  jne         wmain+60h (140001060h) 
 000000014000106C  add         r9,1 
 0000000140001070  cmp         r9,rbx 
 0000000140001073  jne         wmain+52h (140001052h)
 
Это все произошло потому, что функция UnsafeCalcIndex проста и ее легко встроить.

Если ее немного усложнить или компилятор решит, что встраивать ее не стоит, то появится ошибка, которая проявится на больших объемах данных.

Немного модифицируем (усложним) функцию UnsafeCalcIndex. Обратите внимание, что логика функции совершенно не изменилась:

 
 ptrdiff_t UnsafeCalcIndex(int x, int y, int width) {
   int result = 0;
   if (width != 0)
     result = y * width;
   return result + x;
 }
 
Результатом является аварийное завершение работы программы при выходе за границы массива:
 
 0000000140001050  test        esi,esi 
 0000000140001052  je          wmain+7Ah (14000107Ah) 
 0000000140001054  mov         r8d,ecx 
 0000000140001057  mov         r9d,esi 
 000000014000105A  xchg        ax,ax 
 000000014000105D  xchg        ax,ax 
 0000000140001060  mov         eax,ecx 
 0000000140001062  test        ebx,ebx 
 0000000140001064  cmovne      eax,r8d 
 0000000140001068  add         r8d,ebx 
 000000014000106B  cdqe             
 000000014000106D  add         rax,rdx 
 0000000140001070  sub         r9,1 
 0000000140001074  mov         byte ptr [rax+rdi],1 
 0000000140001078  jne         wmain+60h (140001060h) 
 000000014000107A  add         rdx,1 
 000000014000107E  cmp         rdx,r12 
 0000000140001081  jne         wmain+50h (140001050h)
 
Я думаю, тебе уже скучно.

Мне жаль.

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



4. Диагностика потенциальных ошибок

Программа — это последовательность обработки ошибок.

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

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

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

Как и в случае с лошадью, первое впечатление может быть обманчивым.

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

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

Обратите внимание, что это необходимое, но совершенно не достаточное условие.

Если в тестах не используются наборы данных, которые, например, не используют большой объем оперативной памяти, то ошибка может не проявиться ни в релизной, ни в отладочной версии [ 5 ].

Необходимо расширять юнит-тесты, расширять наборы данных для нагрузочного и ручного тестирования.

Необходимо заставить алгоритмы обрабатывать новые комбинации данных, доступные только в 64-битных системах [ 6 ].

Альтернативный способ диагностики 64-битных ошибок — использование инструментов статического анализа.

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

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

Людей пугает список из тысяч и десятков тысяч предупреждений.

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

Это будут точно такие же фантомы, описанные ранее.

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

В дальнейшем вам будет достаточно использовать статический анализ только вновь созданного кода, что не отнимет много времени.

Из набора инструментов для поиска 64-битных фантомов я конечно же предложу инструмент, который мы разрабатываем — Вива64 .

Кстати, скоро этот инструмент станет частью PVS-Studio, который объединит все наши инструменты статического анализа.

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

Должно быть названо Гимпел ПК-Линт И Парасофт С++Тест .

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

Все еще существует Проверка кода Абраксаса , в новой версии которого (14.5) также реализованы функции диагностики 64-битных ошибок, но более подробной информации у меня нет.

Заключение

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

Спасибо за внимание.



Библиография

  1. Роджер Р.

    Хок.

    40 исследований, которые потрясли психологию.

    4-й межд. ред. СПб: Прайм-Евросайн, 2006, ISBN: 5-93878-237-6.

  2. Андрей Карпов.

    64 бит, /Wp64, Visual Studio 2008, Viva64 и все, все, все.

    http://www.viva64.com/art-1-1-253695945.html

  3. Андрей Карпов, Евгений Рыжков.

    Статический анализ кода для проверки 64-битных приложений.

    http://www.viva64.com/art-1-1-1630333432.html

  4. Андрей Карпов.

    7 шагов для переноса программы на 64-битную систему.

    http://www.viva64.com/art-1-1-1148261225.html

  5. Андрей Карпов, Евгений Рыжков.

    20 подводных камней при переносе кода C++ на 64-битную платформу.

    http://www.viva64.com/art-1-1-1958348565.html

  6. Андрей Карпов, Евгений Рыжков.

    Поиск ловушек в коде C/C++ при портировании приложений на 64-битную версию Windows. http://www.viva64.com/art-1-1-329725213.html

  7. Андрей Карпов.

    Сравнение диагностических возможностей анализаторов при проверке 64-битного кода.

    http://www.viva64.com/art-1-1-1441719613.html

Теги: #64-бит #x64 #64-бит #64-битное программирование #Intel 64 #Intel 64
Вместе с данным постом часто просматривают: