Коллекция примеров 64-битных ошибок в реальных программах — часть 1

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

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

    Я решил собрать примеры различных типов ошибок, которые мы сами обнаружили в реальных программах, о которых прочитали в интернете или о которых нам сообщили пользователи PVS-Studio. Итак, предлагаю вашему вниманию статью, представляющую собой коллекцию из 30 примеров 64-битных ошибок на языке Си и Си++.

    Продолжение статьи >>



    Введение


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

    Статья только демонстрирует различные виды 64-битных ошибок и не описывает методов их обнаружения и профилактики. Вы можете подробно познакомиться с методами диагностики и исправления дефектов в 64-битных программах, обратившись к следующим ресурсам:
    1. Курс по разработке 64-битных приложений на языке Си/Си++ [1];
    2. Что такое size_t и ptrdiff_t [2];
    3. 20 ловушек переноса Си++ — кода на 64-битную платформу [3];
    4. Учебное пособие по PVS-Studio [4];
    5. 64-битный конь, который умеет считать [5].
    Также вы можете познакомиться с демонстрационной версией инструмента PVS-Studio, в состав которой входит статический анализатор кода Viva64, выявляющий практически все описанные в статье ошибки. Демонстрационная версия доступна для скачивания по адресу: http://www.viva64.com/ru/pvs-studio/download/.

    Пример 1. Переполнение буфера


    struct STRUCT_1
    {
      int *a;
    };
    
    struct STRUCT_2
    {
      int x;
    };
    ...
    STRUCT_1 Abcd;
    STRUCT_2 Qwer;
    memset(&Abcd, 0, sizeof(Abcd));
    memset(&Qwer, 0, sizeof(Abcd));

    В программе объявлены два объекта типа STRUCT_1 и STRUCT_2, которые перед началом использования необходимо очистить (инициализировать все поля нулями). Реализуя инициализацию, программист решил скопировать похожу строчку и заменил в ней "&Abcd" на "&Qwer". Но при этом он забыл заменить «sizeof(Abcd)» на «sizeof(Qwer)».По удачному стечению обстоятельств размер структур STRUCT_1 и STRUCT_2 совпадал в 32-битной системе и код корректно работал долгое время.

    При переносе кода на 64-битную систему размер структуры Abcd увеличился и как следствие возникла ошибка переполнения буфера (см. рисунок 1).

    Picture 1

    Рисунок 1 — Схематичное пояснение примера переполнения буфера

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

    Пример 2. Лишние приведения типов


    char *buffer;
    char *curr_pos;
    int length;
    ...
    while( (*(curr_pos++) != 0x0a) && 
           ((UINT)curr_pos - (UINT)buffer < (UINT)length) );

    Код плох, но это реальный код. Его задача состоит в поиске конца строки, обозначенного символом 0x0A. Код не будет работать со строками длиннее INT_MAX символов, так как переменная length имеет тип int. Однако нас интересует другая ошибка, поэтому будем считать, что программа работает с небольшим буфером и использование типа int корректно.

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


    Picture 2


    Рисунок 2 — Некорректны вычисления при поиске терминального символа

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

    while(curr_pos - buffer < length && *curr_pos != '\r')
      curr_pos++;

    Пример 3. Некорректные #ifdef


    Часто в программах с длинной историей можно встретить участки кода, обернутые в конструкции #ifdef — -#else — #endif. При переносе программ на новую архитектуру, некорректно написанные условия могут привести к компиляции не тех фрагментов кода, как это планировалось разработчиками в прошлом (см. рисунок 3). Пример:
    #ifdef _WIN32 // Win32 code
      cout << "This is Win32" << endl;
    #else         // Win16 code
      cout << "This is Win16" << endl;
    #endif
    
    //Альтернативный некорректный вариант:
    #ifdef _WIN16 // Win16 code
      cout << "This is Win16" << endl;
    #else         // Win32 code
      cout << "This is Win32" << endl;
    #endif


    Picture 3


    Рисунок 3 — Два варианта — это слишком мало

    Полагаться на вариант #else в подобных ситуациях опасно. Лучше явно рассмотреть поведение для каждого случая (см. рисунок 4), а в ветку #else поместить сообщение об ошибке компиляции:

    #if   defined _M_X64 // Win64 code (Intel 64)
      cout << "This is Win64" << endl;
    #elif defined _WIN32 // Win32 code
      cout << "This is Win32" << endl;
    #elif defined _WIN16 // Win16 code
      cout << "This is Win16" << endl;
    #else
      static_assert(false, "Неизвестная платформа");
    #endif


    Picture 4


    Рисунок 4 — Проверяются все возможные пути компиляции

    Пример 4. Путаница с int и int*


    В старых программах, особенно на Си, не редки фрагменты кода, где указатель хранят в типе int. Однако иногда это делается не умышленно, а скорее по невнимательности. Рассмотрим пример, содержащий путаницу, возникшую с использованием типа int и указателем на тип int:

    int GlobalInt = 1;
    
    void GetValue(int **x)
    {
      *x = &GlobalInt;
    }
    
    void SetValue(int *x)
    {
      GlobalInt = *x;
    }
    
    ...
    int XX;
    GetValue((int **)&XX);
    SetValue((int *)XX); 

    В данном примере переменная XX используется в качестве буфера для хранения указателя. Этот код будет корректно работать в тех 32-битных системах, где размер указателя совпадает с размером типа int. В 64-битном системе этот код некорректен и вызов

    GetValue((int **)&XX);

    приведет к порче 4 байт памяти рядом с переменной XX (см. рисунок 5).
    Picture 5

    Рисунок 5 — Порча памяти рядом с переменной XX

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

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

    int *XX;
    GetValue(&XX);
    SetValue(XX);

    Пример 5. Использование устаревших функций


    Ряд API-функций, хотя и оставлен для совместимости, представляет собой опасность при разработке 64-битных приложений. Классическим примером является использование таких функций как SetWindowLong и GetWindowLong. В программах можно встретить код, подобный следующему:

    SetWindowLong(window, 0, (LONG)this);
    ...
    Win32Window* this_window = (Win32Window*)GetWindowLong(window, 0);

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

    LONG WINAPI SetWindowLong(HWND hWnd, int nIndex, LONG dwNewLong);
    LONG WINAPI GetWindowLong(HWND hWnd, int nIndex);

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

    Неприятность ошибки заключается в ее нерегулярном или даже крайне редком проявлении. Произойдет ошибка или нет, зависит от того, в какой области памяти создан объект, на который указывает указатель «this». Если объект создается в младших 4 гигабайтах адресного пространства, то 64-битная программа может корректно функционировать. Ошибка неожиданно может проявить себя через большой промежуток времени, когда из-за выделения памяти, объекты начнут создаваться за пределами первых четырех гигабайт.

    В 64-битной системе использовать функции SetWindowLong/GetWindowLong можно только в том случае, если программа действительно сохраняет некие значения типа LONG, int, bool и подобные им. Если необходимо работать с указателями, то следует использовать расширенные варианты функций: SetWindowLongPtr/GetWindowLongPtr. Хотя, пожалуй, следует порекомендовать в любом случае использовать новые функции, чтобы не спровоцировать в будущем новых ошибок.

    Примеры с функциями SetWindowLong и GetWindowLong являются классическими и приводятся практически во всех статьях посвященных разработке 64-битных приложений. Однако следует учесть, что этими функциями дело не ограничивается. Обратите внимания на: SetClassLong, GetClassLong, GetFileSize, EnumProcessModules, GlobalMemoryStatus (см. рисунок 6).

    Picture 6
    Рисунок 6 — Таблица с именами некоторых устаревших и современных функций

    Пример 6. Обрезание значений при неявном приведении типов


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

    bool Find(const ArrayOfStrings &arrStr)
    {
      ArrayOfStrings::const_iterator it;
      for (it = arrStr.begin(); it != arrStr.end(); ++it)
      {
        unsigned n = it->find("ABC"); // Truncation
        if (n != string::npos)
          return true;
      }
      return false;
    };

    Приведенная функция ищет текст «ABC» в массиве строк и возвращает true, в случае если хотя бы одна строка содержит последовательность «ABC». При компиляции 64-битной версии кода, эта функция всегда будет возвращать true.

    Константа «string::npos» в 64-битной системе имеет значение 0xFFFFFFFFFFFFFFFF типа size_t. При помещение этого значения в переменную «n» типа unsigned, происходит его обрезание до 0xFFFFFFFF. В результате условие " n != string::npos" всегда истинно, так как 0xFFFFFFFFFFFFFFFF не равно 0xFFFFFFFF (см. рисунок 7).


    Picture 7


    Рисунок 7 — Схематичное пояснение ошибки обрезания значения

    Исправление элементарно, достаточно прислушаться к предупреждениям компилятора:

    for (auto it = arrStr.begin(); it != arrStr.end(); ++it)
    {
      auto n = it->find("ABC");
      if (n != string::npos)
        return true;
    }
    return false;

    Пример 7. Необъявленные функции в Си


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

    В языке Си можно использовать функции без их предварительного объявления. Проанализируем связанный с этим интересный пример 64-битной ошибки. Для начала рассмотрим корректный вариант кода, в котором происходит выделение и использование трех массивов размером по гигабайту каждый:
    #include <stdlib.h>
    
    void test()
    {
      const size_t Gbyte = 1024 * 1024 * 1024;
      size_t i;
      char *Pointers[3];
    
      // Allocate
      for (i = 0; i != 3; ++i)
        Pointers[i] = (char *)malloc(Gbyte);
    
      // Use
      for (i = 0; i != 3; ++i)
        Pointers[i][0] = 1;
    
      // Free
      for (i = 0; i != 3; ++i)
        free(Pointers[i]);
    }

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

    Теперь удалим или закомментируем строчку "#include <stdlib.h>". Код по-прежнему будет собираться, но при запуске программы произойдет ее аварийное завершение. Если заголовочный файл «stdlib.h» не подключен, компилятор языка Си считает, что функция malloc вернет тип int. Первые два выделения памяти, скорее всего, пройдут успешно. При третьем обращении функция malloc вернет адрес массива за пределами первых 2-х гигабайт. Поскольку компилятор считает, что результат работы функции имеет тип int, он неверно интерпретирует результат и сохраняет в массиве Pointers некорректное значение указателя.

    Рассмотрим ассемблерный код, генерируемый компилятором Visual C++ для 64-битной Debug версии. Вначале приводится корректный код, который будет сгенерирован, когда присутствует объявление функции malloc (подключен файл «stdlib.h»):

    Pointers[i] = (char *)malloc(Gbyte);
    mov   rcx,qword ptr [Gbyte]
    call  qword ptr [__imp_malloc (14000A518h)]
    mov    rcx,qword ptr [i]
    mov    qword ptr Pointers[rcx*8],rax


    Теперь рассмотрим вариант некорректного кода, когда отсутствует объявление функции malloc:

    Pointers[i] = (char *)malloc(Gbyte);
    mov    rcx,qword ptr [Gbyte]
    call   malloc (1400011A6h)
    cdqe
    mov    rcx,qword ptr [i]
    mov    qword ptr Pointers[rcx*8],rax

    Обратите внимание на наличие инструкции CDQE (Convert doubleword to quadword). Компилятор посчитал, что результат содержится в регистре eax и расширил его до 64-битного значения, чтобы записать в массив Pointers. Соответственно старшие биты регистра rax будут потеряны. Если даже адрес выделенной памяти лежит в пределах первых четырех гигабайт, в случае, когда старший бит регистра eax равен 1 мы все равно получим некорректный результат. Например, адрес 0x81000000 превратится в 0xFFFFFFFF81000000.

    Пример 8. Останки динозавров в больших и старых программах


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

    Picture 1
    Рисунок 8 — Раскопки динозавра

    Есть атавизмы связанные и с 64-битностью. Вернее атавизмы, препятствующие работе современного 64-битного кода. Рассмотрим пример:

    // beyond this, assume a programming error
    #define MAX_ALLOCATION 0xc0000000 
    
    void *malloc_zone_calloc(malloc_zone_t *zone,
      size_t num_items, size_t size)
    {
      void *ptr;
      ...
    
      if (((unsigned)num_items >= MAX_ALLOCATION) ||
          ((unsigned)size >= MAX_ALLOCATION) ||
          ((long long)size * num_items >=
           (long long) MAX_ALLOCATION))
      {  
        fprintf(stderr,
          "*** malloc_zone_calloc[%d]: arguments too large: %d,%d\n",
          getpid(), (unsigned)num_items, (unsigned)size);
        return NULL;
      }
      ptr = zone->calloc(zone, num_items, size);
      ...
      return ptr;
    }

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

    Пример 9. Виртуальные функции


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

    С незапамятных времен в библиотеке MFC есть класс CWinApp, в котором имеется функция WinHelp:

    class CWinApp {
      ...
      virtual void WinHelp(DWORD dwData, UINT nCmd);
    };

    Для показа собственной справки в пользовательском приложении необходимо было эту функцию перекрыть:

    class CSampleApp : public CWinApp {
      ...
      virtual void WinHelp(DWORD dwData, UINT nCmd);
    };

    И все было прекрасно до тех пор, пока не появились 64-битные системы. Разработчикам MFC пришлось поменять интерфейс функции WinHelp (и некоторых других функций) так:

    class CWinApp {
      ...
      virtual void WinHelp(DWORD_PTR dwData, UINT nCmd);
    };

    В 32-битном режиме типы DWORD_PTR и DWORD совпадали, а вот в 64-битном нет. Естественно разработчики пользовательского приложения также должны сменить тип на DWORD_PTR, но чтобы это сделать, про это необходимо в начале узнать. В результате в 64-битной программе возникает ошибка, так как функция WinHelp в пользовательском классе не вызывается (см. рисунок 9).

    Picture 9

    Рисунок 9 — Ошибка, связанная с виртуальными функциями

    Пример 10. Магические числа в качестве параметров


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

    Picture 10
    Рисунок 10 — Магические числа опасные для 64-битных программ

    Продемонстрируем пример работы с функцией CreateFileMapping, встретившийся в одной из CAD-систем:
    HANDLE hFileMapping = CreateFileMapping(
      (HANDLE) 0xFFFFFFFF,
      NULL,
      PAGE_READWRITE,
      dwMaximumSizeHigh,
      dwMaximumSizeLow,
      name);

    Вместо корректной зарезервированной константы INVALID_HANDLE_VALUE используется число 0xFFFFFFFF. Это некорректно в Win64 программе, где константа INVALID_HANDLE_VALUE принимает значение 0xFFFFFFFFFFFFFFFF. Правильным вариантом вызова функции будет:

    HANDLE hFileMapping = CreateFileMapping(
      INVALID_HANDLE_VALUE,
      NULL,
      PAGE_READWRITE,
      dwMaximumSizeHigh,
      dwMaximumSizeLow,
      name);

    Примечание. Некоторые считают, что значение 0xFFFFFFFF при расширении до указателя превращается в 0xFFFFFFFFFFFFFFFF. Это не так. Согласно правилам языка Си/Си++ значение 0xFFFFFFFF имеет тип «unsigned int», так как не может быть представлено типом «int». Соответственно, расширяясь до 64-битного типа, значение 0xFFFFFFFFu превращается в 0x00000000FFFFFFFFu. А вот если написать так (size_t)(-1), то мы получим ожидаемое 0xFFFFFFFFFFFFFFFF. Здесь «int» вначале расширяется до «ptrdiff_t», а затем превращается в «size_t».

    Пример 11. Магические константы, обозначающие размер


    Другой частой ошибкой является использование магических чисел для задания размера объекта. Рассмотрим пример выделения и обнуления буфера:

    size_t count = 500;
    size_t *values = new size_t[count];
    // Будет заполнена только часть буфера
    memset(values, 0, count * 4);

    В данном случае в 64-битной системе выделяется больше памяти, чем затем заполняется нулевыми значениями (см. рисунок 11). Ошибка заключается в предположении, что размер типа size_t всегда равен четырем байтам.

    Picture 11

    Рисунок 11 — Заполнение только части массива

    Корректный вариант:
    size_t count = 500;
    size_t *values = new size_t[count];
    memset(values, 0, count * sizeof(values[0]));

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

    Пример 12. Переполнение стека


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

    Механизм использования стека отличается в различных операционных системах и компиляторах. Мы рассмотрим особенность использования стека в коде Win64 приложений, построенных компилятором Visual C++.

    При разработке соглашений по вызовам (calling conventions) в Win64 системах решили положить конец существованию различных вариантов вызова функций. В Win32 существовал целый ряд соглашений о вызове: stdcall, cdecl, fastcall, thiscall и так далее. В Win64 только одно «родное» соглашение по вызовам. Модификаторы подобные __cdecl компилятором игнорируются.

    Соглашение по вызовам на платформе x86-64 похоже на соглашение fastcall, существующее в x86. В x64-соглашении первые четыре целочисленных аргумента (слева направо) передаются в 64-битных регистрах, выбранных специально для этой цели:

    RCX: 1-й целочисленный аргумент
    RDX: 2-й целочисленный аргумент
    R8: 3-й целочисленный аргумент
    R9: 4-й целочисленный аргумент

    Остальные целочисленные аргументы передаются через стек. Указатель «this» считается целочисленным аргументом, поэтому он всегда помещается в регистр RCX. Если передаются значения с плавающей точкой, то первые четыре из них передаются в регистрах XMM0-XMM3, а последующие — через стек.

    Хотя аргументы могут быть переданы в регистрах, компилятор все равно резервирует для них место в стеке, уменьшая значение регистра RSP (указателя стека). Как минимум, каждая функция должна резервировать в стеке 32 байта (четыре 64-битных значения, соответствующие регистрам RCX, RDX, R8, R9). Это пространство в стеке позволяет легко сохранить содержимое переданных в функцию регистров в стеке. От вызываемой функции не требуется сбрасывать в стек входные параметры, переданные через регистры, но резервирование места в стеке при необходимости позволяет это сделать. Если передается более четырех целочисленных параметров, в стеке резервируется соответствующее дополнительное пространство.

    Описанная особенность приводит к существенному возрастанию скорости поглощения стека. Даже если функция не имеет параметров, то от стека все равно будет «откушено» 32 байта, которые затем никак не используются. Смысл использования такого неэкономного механизма связан в унификации и упрощение отладки.

    Обратим внимание еще на один момент. Указатель стека RSP должен перед очередным вызовом функции быть выровнен по границе 16 байт. Таким образом, суммарный размер используемого стека при вызове в 64-битном коде функции без параметров составляет 48 байт: 8 (адрес возврата) + 8 (выравнивание) + 32 (резерв для аргументов).

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

    Предсказать, будет потреблять 64-битная программа больше стека или меньше невозможно. В силу того, что Win64-программа может использовать в 2-3 раза больше стековой памяти, необходимо подстраховаться и изменить настройку проекта, отвечающую за размер резервируемого стека. Выберите в настройках проекта параметр Stack Reserve Size (ключ /STACK:reserve) и увеличьте размер резервируемого стека в три раза. По умолчанию этот размер составляет 1 мегабайт.

    Пример 13. Функция с переменным количеством аргументов и переполнение буфера


    Хотя использование функций с переменным количеством аргументов, таких как printf, scanf считается в Си++ плохим стилем, они по прежнему широко используются. Эти функции создают множество проблем при переносе приложений на другие системы, в том числе и на 64-битные системы. Рассмотрим пример:

    int x;
    char buf[9];

    sprintf(buf, "%p", &x);
    Автор кода не учел, что размер указателя в будущем может составить более 32 бит. В результате на 64-битной архитектуре данный код приведет к переполнению буфера (см. рисунок 12). Эту ошибку вполне можно отнести к использованию магического числа '9', но в реальном приложении переполнение буфера может возникнуть и без магических чисел.

    Picture 12

    Рисунок 12 — Переполнение буфера при работе с функцией sprintf

    Варианты исправления данного кода различны. Рациональнее всего провести рефакторинг кода с целью избавиться от использования опасных функций. Например, можно заменить printf на cout, а sprintf на boost::format или std::stringstream.

    Примечание. Эту рекомендацию часто критикуют разработчики под Linux, аргументируя тем, что gcc проверяет соответствие строки форматирования фактическим параметрам, передаваемым, например, в функцию printf. И, следовательно, использование printf безопасно. Однако они забывают, что строка форматирования может передаваться из другой части программы, загружаться из ресурсов. Другими словами, в реальной программе строка форматирования редко присутствует в явном виде в коде, и, соответственно, компилятор не может ее проверить. Если же разработчик использует Visual Studio 2005/2008/2010, то он не сможет получить предупреждение на код вида void *p = 0; printf("%x", p); даже используя ключи /W4 и /Wall.

    Пример 14. Функция с переменным количеством аргументов и неверный формат


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

    const char *invalidFormat = "%u";
    size_t value = SIZE_MAX;
    
    // Будет распечатано неверное значение
    printf(invalidFormat, value);

    В других случаях ошибка в строке форматирования будет критична. Рассмотрим пример, основанный на реализации подсистемы UNDO/REDO в одной из программ:
    // Здесь указатели сохранялись в виде строки
    int *p1, *p2;
    ....
    char str[128];
    sprintf(str, "%X %X", p1, p2);
    
    // В другой функции данная строка
    // обрабатывалась следующим образом:
    void foo(char *str)
    {
      int *p1, *p2;
      sscanf(str, "%X %X", &p1, &p2);
      // Результат - некорректное значение указателей p1 и p2.
      ...
    }

    Формат "%X" не предназначен для работы с указателями и как следствие подобный код некорректен сточки зрения 64-битных систем. В 32-битных системах он вполне работоспособен, хотя и не красив.

    Пример 15. Хранение в double целочисленных значений


    Нам не приходилось самим встречать подобную ошибку. Вероятно, это ошибка редка, но вполне реальна.
    Тип double, имеет размер 64-бита, и совместим со стандартом IEEE-754 на 32-битных и 64-битных системах. Некоторые программисты используют тип double для хранения и работы с целочисленными типами:
    size_t a = size_t(-1);
    double b = a;
    --a;
    --b;
    size_t c = b; // x86: a == c
                  // x64: a != c

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

    Picture 13
    Рисунок 13 — Количество значащих битов в типах size_t и double

    Вторая часть статьи.
    PVS-Studio
    Static Code Analysis for C, C++, C# and Java

    Comments 62

      +10
      Иллюстрации к статье классные.
        +9
        И сама статья тоже.
        –8
        Ничего не понял, но чувствую что статья крутая
          +41
          Здравствуй, сферический в вакууме хабравчанин 2010 года!
          +4
          Изумительная статья.
          Объяснено все так, что поймет даже начинающий программист.
          О большей половине ошибок я и не догадывался до сегодняшнего дня.
          • UFO just landed and posted this here
              +8
              Си++ — программисты с мегабайтами унаследованного кода улыбаются.

              У нас вот тут клиент с программным комплексом, состоящим из проектов, собираемых с помощью VC1, VC2, VC3, VC4,…! И всего в сумме 5 миллионов строк кода. И он думает, как бы из всего этого сделать проект для VS2010 и собрать 64-битную версию.

              Переслать ему что-ли Ваш комментарий. Сказать что актуальных проблем у него нет и пусть не волнуется понапрасну…
              :)
                +2
                Полностью поддерживаю. Я с ужасом жду того момента, когда мы будем переводить наш софт(в районе миллиона строк C/C++) на 64 бита. Видимо, нам попадутся всё ошибки из вашей статьи. За статью жирный плюс!
                • UFO just landed and posted this here
                    +11
                    вы его предупредили, что счет тоже будет 64-битный?
                      0
                      т.е. int не будет 64 битным или будет? Я что-то думал, что int/uint тоже будут на 64 бита, хоть это иногда и не нужно. А может можно директиву компилятору какую написать вроде #define true false, в смысле #typedef int int64?
                      PS: Сам на Си только курсовики и лабы делал.
                        0
                        В Win64 тип int точно не будет 64-битным. В unix мире существуют системы, где int 64-битный. Но и там размер int в основном равен 32-битам. Вообще размеры типов зависят от используемой модели данных.

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

                        Вы возможно имели в виду
                        typedef int64 int?
                        Если да, то это будет неработоспособно по ряду причин.
                  +3
                  Знаете, а это первая нормальная статья о переходе на 64 бита…

                  Прошлые были полным ойёйёй.
                    –1
                    да ну. За большую часть ошибок, надо отрывать руки. Вообще. На корню.

                    Далее, не говорится что для размеров стоит использовать size_t;

                    Далее, не говориться что на нормальных архитектурах

                    while(curr_pos - buffer < length && *curr_pos != '\r')
                      curr_pos++;


                    приведет к сегфолу, ибо верить что ты имеешь доступ к любой памяти, это крайне наивно!
                      +2
                      Да, за большинство из этих ошибок надо отрывать много чего, однако есть тонны кода с такими ошибками, который работает и перестанет это делать будучи собранным под х64

                      Речь ведь не о характеристике давно уволенных кодеров, а про «что с этим делать?», имхо тоже статья лучшая из этой тематики на данный момент
                        0
                        Гм, вы понимаете почему код, который я привел не будет работать?

                        Далее. если человек путает sizeof(int) и sizeof(int*) то это уже повод задуматься.
                          0
                          Почему? :)

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

                          А то по вашему получается, что в С++ теперь нельзя разыменовывать указатели без какого-то непонятного шаманства.
                            +1
                            фикус в том, что есть нормальные архитектуры, где доступ к не выровненной памяти не возможен. В интеле он просто сильно медленее!
                              0
                              Даже с учетом этого приведенный код будет работать, потому что char выравнивается на 1 байт. И даже если бы там были не char, а что-то еще оно бы все равно работало, потому как типы обычно выравниваются на размер типа.
                                0
                                char *a = malloc(24 * sizeof(char));

                                при sizeof(char) == 1 мы получаем аж целых 24 байта. В случае powerpc мы сможем обратиться только к адрессам выравненым по смещению кратному dword. И данный код работать не будет, увы.
                                  0
                                  т.е. в PowerPC нет понятия «байт» с точки зрения процесора?

                                  я имею в виду, что на х86 в ассемблере можно писать

                                  mov bl, byte ptr[address]    ; выравнивание на 1 байт
                                  mov bx, word ptr[address]    ; выравнивание на 2 байта
                                  mov ebx, dword ptr[address]  ; выравнивание на 4 байта 
                                  


                                  до х64 ассемблера я не дожил, но думаю там тоже как-то так… а как в power pc?

                                  з.ы. кстати, конструкция с циклом при компилировании в х86 вообще должна превратиться в что-то вроде «repne scasb»
                                    0
                                    интел и вообще x86 я не считаю за приличную архитектуру. Популярную, да. Но не приличную!
                                      +1
                                      Так расскажите, все-таки, как вы конец строки в PowerPC ищите?
                                        0
                                        надо использовать нормальные функции. например length и strstr или strchr

                                        Если надо циклам, то я бы переводил строку к long и делал бы логические операции с несколькими шаблонами ;)
                                          0
                                          да, смещал потом просто указатель на long. Обычно dword это и есть sizeof(long)
                                            0
                                            Да… без нормальных функций это некрасиво выглядит… Впрочем, возможно, оно быстрее работает, чем у Intel, интеловский ассемблер действительно перегружен командами…
                                              0
                                              Согласись, что мемантически использовать функцие для этого более правильно. Легче читать.

                                              + ты не завязан на архитектуру.

                                              А смысл статьи про перенос: делайте абстракции и используйте функции. И по возможности используйте готовые функции. И не используйте знания, какие-то. Они могут измениться.
                                                0
                                                Естественно :)

                                                Я бы также добавил про смысл: избегайте массивов в си стиле и непонятных операций с типами. В с++ есть STL. Всякие malloc и free в коде использоваться не должны, разве что в таких вещах, как Small Object Allocator, но таких мест в программе обычно очень мало, да и ошибки там отлавливаются очень быстро.
                                                  0
                                                  А если я пишу на чистом Си?
                                                    0
                                                    Чем более низкоуровневый язык, тем больше нужно знать о целевом железе, это нормально
                                                      +1
                                                      не нормально когда пишут не зная, увы
                                                    0
                                                    Для аллокаторов есть Boost, который вот-вот в стандарт включат, так что кроме случаев хардкорной оптимизации в программе вообще не должно быть прямой работы с malloc (тем более что в рамках плюсов все равно лучше использовать new)
                                                      0
                                                      Нету в бусте Small Object Allocator, он в Loki :)

                                                      Вообще, я аллокаторы для примера привел… Вообще все «низкоуровневые» функции имеет смысл скрывать за абстракциями, только долго это, кода лишнего требует… Поясню, под «низкоуровневыми» я понимаю все, что платформо-зависимо, в т.ч. API.
                                  0
                                  Если char это 1 байт (что, и на powerpc благо так), то проблем с выравниванием не будет. Они проявляются только для более длинных типов, там попытка лезть в невыровненную память приведет к Bus Error.

                                  Т.е. ваш пример все-таки работать будет, а вот
                                  int* aligned = new int[2];
                                  *(reinterpret_cast<int*>(reinterpret_cast<char*>(aligned) + 1)) = 0xDEADBEEF;
                                  

                                  упадет
                                0
                                >> Далее. если человек путает sizeof(int) и sizeof(int*) то это уже повод задуматься.

                                Прошу пояснить, о чем Вы? Быть может Вы какую то опечатку/ошибку в статье заметили? А то не понятно о чем речь.
                                  +1
                                  Я про программиста, для которого это писалось.
                          +9
                          Чего только люди не придумают, лишь бы языками со строгой типизацией не пользоваться…
                            +1
                            Спасибо, очень интересно. Жду продолжения.

                            P.S. И это при том, что я питонщик… :-)
                              +3
                              15 пример реален, подтверждаю ;)
                              много проблем делалают функции с переменным количеством аргументов и 0 в конце вместо (Some_Object*)0 вроде бы у вас в статьях это уже было.
                              портирование становится еще интерестней, когда необходимо поддерживать несколько платформ, к примеру x86_64 linux и win64
                              от майкрософта бывают также сказочные приветы из прошлого. пример ничего общего с 64битностью не имеет, но все же: переносил приложение из VS6 на 2008 и atol начал возвращать LONG_MAX, хотя в VS6 могло вернуть значение до ULONG_MAX
                                +2
                                Статья хорошая, но, по большому-то счету, к х64 имеет ну очень опосредованное отношение.
                                Да, эти баги стопудово вылезут при попытке компилять в х64.
                                Но эти же баги вероятнее всего вылезут при просто попытке модифицировать программу. Так что, ИМХО, статья больше относится к разряду «не делайте так ни-ког-да».

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

                                В любом случае, труд весьма приятный, особенно для облегчения понимая сути бяк для тех, кто ее (суть) еще не понимает
                                  0
                                  Хорошая статья, засадные ошибки.
                                  В Примере 6 ожидал увидеть после самой ошибки
                                  find -> string::npos ->… -> FFUUUUUUUUUUUUUU :)
                                    0
                                    Статья хороша для новичков, для себя ничего нового не нашёл. Все примеры из разряда тип стал больше и указатель тоже. Вообще за приведение указателя к инту по рукам бить надо. Ну и вообще в коде не должно быть никаких 9 и прочей магии.
                                      0
                                      Сорри, прочитал, блин, все, кроме последнего. На 15й я и не обратил внимание, спасибо. Вот это действительно интересная штука, хотя у вас тоже некорректно, ибо однажды приведя целое число к любому вещественному типу потом их сравнивать посредством == нельзя. Точнее может и можно, но лучше так не делать.
                                        0
                                        А давайте разведем холивар про строгое сравнивание вещественных?
                                        Еще как можно их сравнивать, только очень, очень осторожно и с пониманием дела
                                          0
                                          Если 14 из 15 примеров о том как записать указатель в инт, или передеть в функцию long long int вместо инта, то понимания дела у целевой аудитории статьи, к сожалению, нет.
                                            0
                                            Согласен.
                                            Собственно, повторюсь про отрывание рук по самую задницу, потому что из плеч такие золотые руки расти не могут =)
                                      +1
                                      Статья хорошая. Все понятно и просто написано. Классные иллюстрации.

                                      Как раз сейчас занимаюсь переводом 32 в 64. Конечно объемы не миллионы строк, а чуть меньше 10 тысяч…
                                      Предупрежден, значит вооружен.
                                        0
                                        Статья понравилась. В ближайшее время вряд ли будет необходимость переводить наш код в 64-битный вариант, но лучше знать заранее.
                                          0
                                          УРРА!!! Я снова вижу эти тексты, спасибо Андрей2008!
                                            0
                                            Ой, что-то в статье ничего не говорится про опенмп. Может и там есть различного рода подводные камни?
                                              0
                                              А VivaMP продается плохо. Из-за этого рассказывать про 64-битные камни намного полезней.
                                                0
                                                Спасибо за кристально честный ответ. Но, кстати, тему многопоточности в современном мире можно неплохо раскрывать и дальше. А 64-бит все одно на самом деле: указатели, магические числа, а остальное или явные баги и undefined behavior.
                                            –1
                                            Единственный вопрос который вызывает эта статья — «Ну и зачем весь этот геморрой в приложении, которое замечательно работает на 32 битах?!»
                                              0
                                              Для того, чтобы дать приложению возможность использовать более 3Гб памяти. Да, это далеко не всем приложениям надо, но если уж надо…
                                              0
                                              Может, это я такой, что предупреждён, вооружён и использую static_cast<size_t>(-1LL) как специальное значение беззнакового целого?

                                              А может, примеры родом из «детского прошлого» середины 90-х годов, когда у большинства проггеров буквально кружилась голова от «плоской» модели памяти…
                                                0
                                                P.S. Кружилась в «хорошем» смысле — выделяй сколько хочешь памяти, если вдруг надо оптимизировать на ассемблере — регистры ds и es мучить уже не надо…
                                                  +1
                                                  numeric_limits<size_t>::max() как-то поправильнее будет :)
                                                    0
                                                    Спасибо. STL большой, всё сразу не схватишь…
                                                +2
                                                предлагаю познакомиться с продолжением статьи: часть 2.
                                                  0
                                                  Аплодирую стоя! Вы Мужик! :)

                                                  P.S. Иллюстрации мега-зачетные!
                                                  0
                                                  большое спасибо, очень интересно, прочитал и всё понял

                                                  Only users with full accounts can post comments. Log in, please.