Статья посвящена особенностям поведения компилятора 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 = . |
Однако в результате оптимизации генерируется следующий код:
0000000140001040 mov byte ptr [rcx+rax],cl 0000000140001043 add rcx,1 0000000140001047 cmp rcx,rbx 000000014000104A jne wmain+40h (140001040h) |
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) |
В результате код может корректно обрабатывать массивы размером больше UINT_MAX. Но мир хрупок.
Достаточно немного усложнить код и он станет некорректным:
volatile unsigned volatileVar = 1; . |
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 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. Обратите внимание, что логика функции совершенно не изменилась:
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-битных ошибок, но более подробной информации у меня нет.
Заключение
Буду рад, если статья поможет вам легче осваивать новые платформы, зная, какие скрытые проблемы могут возникнуть.Спасибо за внимание.
Библиография
- Роджер Р.
Хок.
40 исследований, которые потрясли психологию.
4-й межд. ред. СПб: Прайм-Евросайн, 2006, ISBN: 5-93878-237-6.
- Андрей Карпов.
64 бит, /Wp64, Visual Studio 2008, Viva64 и все, все, все.
- Андрей Карпов, Евгений Рыжков.
Статический анализ кода для проверки 64-битных приложений.
- Андрей Карпов.
7 шагов для переноса программы на 64-битную систему.
- Андрей Карпов, Евгений Рыжков.
20 подводных камней при переносе кода C++ на 64-битную платформу.
- Андрей Карпов, Евгений Рыжков.
Поиск ловушек в коде C/C++ при портировании приложений на 64-битную версию Windows. http://www.viva64.com/art-1-1-329725213.html
- Андрей Карпов.
Сравнение диагностических возможностей анализаторов при проверке 64-битного кода.
-
Законно Ли Переход В Широкоэкранный Режим?
19 Oct, 24