Урок 24. Фантомные ошибки

retweet
29.03.2010 13:00


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

Дело в том, что не так просто показать в примере, что приведенный 64-битный код приведет к ошибке при большом значении N:

      size_t N = ...
      for (int i = 0; i != N; ++i)
      {
      ...
      } 

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

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

Но есть и еще одна, куда более тонкая причина называть ошибки "потенциальными". Дело в том, проявит себя ошибка или нет, зависит не только от входных данных, но и от настроения оптимизатора компилятора. Большинство из рассмотренных в уроках ошибок хорошо проявляют себя в debug-версии, и "потенциальны" в release-версиях. Однако не всякую программу, собранную как debug, можно отлаживать на больших объемах данных. Возникает ситуация, когда debug-версия тестируется только на самых простых наборах данных. А нагрузочное тестирование и тестирование конечными пользователями на реальных данных, выполняется на release-версиях, где ошибки могут быть временно скрыты.

Впервые мы столкнулись с особенностями оптимизации компилятора Visual C++ 2005 при подготовке программы PortSample. Это проект, который входит в состав дистрибутива PVS-Studio и предназначен для демонстрации всех ошибок, которые диагностирует анализатор Viva64. Подробнее о проекте PortSample будет рассказано в следующем уроке. Примеры, которые содержатся в этом проекте, должны корректно работать в 32-битном режиме и приводить к ошибкам в 64-битном варианте. В отладочной версии все работало замечательно, а вот с release версией возникли затруднения. Тот код, который в 64-битном режиме должен был зависать или приводить к аварийному завершению программы - успешно работал! Причина оказалась в оптимизации. Решением стало дополнительное избыточное усложнение кода примеров и расстановка ключевых слов "volatile", которые вы сможете наблюдать в коде проекта PortSample.

То же самое относится и к Visual C++ 2008. Код будет конечно несколько разным, но все что будет написано здесь можно отнести как к Visual C++ 2005, так и к Visual C++ 2008.

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

Рассмотрим первый пример кода, который работает в release-режиме, хотя делать этого не должен:

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

Данный код корректно заполняет весь массив значениями, даже если размер массива гораздо больше INT_MAX. Теоретически это невозможно, поскольку переменная index имеет тип 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;
      size_t arraySize = ...;
      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 для хранения переменной index. В результате код может корректно обрабатывать массивы размером более 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++ выражения "index += volatileVar;" приводит к тому, что в коде начинают участвовать 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;" должно происходить переполнение.

Благодаря чуду массив все же корректно заполняется в release-варианте. Функция 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-битная программа может легко стать неработающей, после того как вы внесете в нее самые безобидные правки или соберете другой версией компилятора.

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

Авторы курса: Андрей Карпов (karpov@viva64.com), Евгений Рыжков (evg@viva64.com).

Правообладателем курса «Уроки разработки 64-битных приложений на языке Си/Си++» является ООО «Системы программной верификации». Компания занимается разработкой программного обеспечения в области анализа исходного кода программ. Сайт компании: http://www.viva64.com.

Контактная информация: e-mail: support@viva64.com, 300027, г. Тула, а/я 1800.