Вебинар: Использование статических анализаторов кода при разработке безопасного ПО - 19.12
В очередной раз убедился, что программисты пишут программы совершенно безалаберно. И работают они не благодаря их заслугам, а благодаря удачному стечению обстоятельств и заботе разработчиков компиляторов в Microsoft или Intel. Да, да, именно они заботятся и в нужный момент подставляют костылики нашим кривобоким программкам.
Читайте далее байтораздирающую историю про класс CString и дочь его, функцию Format.
Молитесь, молитесь на компиляторы и их разработчиков. Они столько сил прилагают, чтобы наши программы работали, несмотря на многие недостатки и даже ошибки. Причем эта их работа трудна и не видна. Они - благородные рыцари кодирования и ангелы-покровители для всех нас.
Я знал, что в Microsoft существует отдел, который занимается вопросами обеспечения максимальной совместимости новых версий операционных систем со старыми приложениями. В их базе более 10000 наиболее известных старых программ, которые должны обязательно работать в новых версиях Windows. Именно благодаря таким усилиям я недавно смог без проблем поиграть в Heroes of Might and Magic II (игра 1996 года) под управлением 64-битной Windows Vista. Думаю, игра успешно запустится и в Windows 7. Вот интересные заметки Алексея Пахунова на тему совместимости [1, 2, 3], очень рекомендую почитать.
Но видимо существуют еще и отделы, которые занимаются тем, чтобы помочь нашему ужасному коду на Си/Си++ работать, работать и работать. Начну эту историю с самого начала.
Я участвую в разработке инструмента PVS-Studio для анализа исходного кода приложений. Тихо, товарищи, тихо - это не реклама. В этот раз это точно богоугодное дело, ибо мы начали создавать бесплатный статический анализатор общего назначения. Пока даже до альфа-версии далеко, но работы потихоньку идут и когда-нибудь я сделаю про этот анализатор пост. Заговорил я об этом потому, что мы начали собирать наиболее интересные типовые ошибки и учиться их диагностировать.
Множество ошибок связано с использованием в программах эллипсисов. Теоретическая справка:
Существуют функции, в описании которых невозможно указать число и типы всех допустимых параметров. Тогда список формальных параметров завершается эллипсисом (...), что означает: "и, возможно, еще несколько аргументов". Например: int printf(const char* ...);
Одной такой неприятной, но легко диагностируемой ошибкой является передача в функцию с переменным количеством аргументов объекта типа класс, вместо указателя на строку. Вот как выглядит пример этой ошибки:
wchar_t buf[100];
std::wstring ws(L"12345");
swprintf(buf, L"%s", ws);
Такой код приведет к формированию в буфере белиберды или к аварийному завершению программы. В реальной программе конечно код будет более запутанный, поэтому просьба - не надо писать комментарии о том, что в отличие от Visual C++, компилятор GCC проверит аргументы и предупредит. Строки могут поступать из ресурсов или других функций и проверить ничего не удастся. Здесь же диагностика проста - в функцию формирования строки передается объект класса, что и приводит к ошибке.
Корректный вариант кода должен выглядеть так:
wchar_t buf[100];
std::wstring ws(L"12345");
swprintf(buf, L"%s", ws.c_str());
Именно из-за того, что в функции с переменным количеством аргументов можно передать все что угодно их и не рекомендуют использовать практически во всех книгах по программированию на языке Си++. Вместо этого предлагается использовать безопасные механизмы, например, boost::format. Однако рекомендации рекомендациями, а кода с разными printf, sprintf, CString::Format огромное количество и мы с ним будем жить еще очень долго. Именно поэтому мы и реализовали диагностическое правило, выявляющее подобные опасные конструкции.
Давайте разберемся теоретически, в чем неверен приведенный выше код. Оказывается он некорректен дважды.
Теоретическая справка про POD типы:
POD это аббревиатура от "Plain Old Data", что можно перевести как "Простые данные в стиле Си". К POD-типам относятся:
Соответственно, класс std::wstring к POD-типам не относится, так как у него есть конструкторы, базовый класс и так далее.
При этом если вы передаете в эллипсис объект, не являющимся POD типом, то это приводит к неопределенному поведению. Таким образом, по крайней мере, теоретически, мы никак не можем корректно передать объект типа std::wstring в качестве эллипсис аргумента.
Та же самая картина у нас должна наблюдаться и с функций Format из класса CString. Некорректный вариант код:
CString s;
CString arg(L"OK");
s.Format(L"Test CString: %s\n", arg);
Корректный вариант кода:
s.Format(L"Test CString: %s\n", arg.GetString());
Или как предлагается в MSDN [4] для получения указателя на строку можно использовать явный оператор приведения LPCTSTR, реализованный в классе CString. Пример корректного кода из MSDN:
CString kindOfFruit = "bananas";
int howmany = 25;
printf("You have %d %s\n", howmany, (LPCTSTR)kindOfFruit);
Итак, вроде бы все прозрачно и понятно. Как сделать правило тоже ясно. Будем обнаруживать опечатки при использовании функций с переменным количеством аргументов.
Это и было и сделано. И вот здесь я был шокирован результатом. Оказывается большинство разработчиков вообще никогда не задумываются над этими проблемами и спокойно пишут код вида:
class CRuleDesc
{
CString GetProtocol();
CString GetSrcIp();
CString GetDestIp();
CString GetSrcPort();
CString GetIpDesc(CString strIp);
...
CString CRuleDesc::GetRuleDesc()
{
CString strDesc;
strDesc.Format(
_T("%s all network traffic from <br>%s "
"on %s<br>to %s on %s <br>for the %s"),
GetAction(), GetSrcIp(), GetSrcPort(),
GetDestIp(), GetDestPort(), GetProtocol());
return strDesc;
}
//---------------
CString strText;
CString _strProcName(L"");
...
strText.Format(_T("%s"), _strProcName);
//---------------
CString m_strDriverDosName;
CString m_strDriverName;
...
m_strDriverDosName.Format(
_T("\\\\.\\%s"), m_strDriverName);
//---------------
CString __stdcall GetResString(UINT dwStringID);
...
_stprintf(acBuf, _T("%s"),
GetResString(IDS_SV_SERVERINFO));
//---------------
// Думаю понятно,
// что примеры можно приводить и приводить.
А некоторые и задумываются, но забываются. И поэтому так трогательно смотрится код следующего вида:
CString sAddr;
CString m_sName;
CString sTo = GetNick( hContact );
sAddr.Format(_T("\\\\%s\\mailslot\\%s"),
sTo, (LPCTSTR)m_sName);
И таких примеров в проектах, на которых мы тестируем PVS-Studio, оказалась столько, что стало не понятно, как это вообще может быть. А, тем не менее, это все замечательно работает, в чем я смог убедиться, написав тестовую программу и попробовав различные варианты использования CString.
В чем же дело? Видимо разработчики компиляторов не выдержали бесконечных вопросов почему программы индусов, использующие CString не работают и обвинений в "глючности компилятора, который неверно работает со строками". И они тихо совершили священный ритуал экзорцизма, изгнав зло из CString. Они сделали невозможное возможным. А именно класс CString реализован специальным хитрым образом, так, чтобы его можно было передавать в функции вида printf, Format.
Сделано это достаточно хитро и кто интересуется, то может почитать исходный код класса CStringT. Я вдаваться в подробности не буду. Отмечу только важный момент. Специальная реализация CString не достаточна, теоретически передача не POD-типа приводит к непредсказуемому поведению. Так вот разработчики Visual C++, а вместе с ними и Intel C++ сделали так, что непредсказуемое поведение представляет из себя всегда корректный результат. :) Ведь правильная работа программы вполне себе подмножество непредсказуемого поведения. :)
А еще я теперь начинаю задумываться над некоторыми странными особенностями поведения компилятора при построении 64-битных программ. Есть подозрение, что разработчики компилятора сознательно делают поведение программы не теоретическим, а практическим (работоспособным), в тех простых случаях, когда они распознают некоторый паттерн. Наиболее понятным примером может быть паттерн цикла. Пример некорректного кода:
size_t n = BigValue;
for (unsigned i = 0; i < n; i++) { ... }
Теоретически, если значение n > UINT_MAX больше, то должен возникнуть бесконечный цикл. Однако в Release версии он не возникает, так как для переменной "i" используется 64-битный регистр. Конечно, если код будет посложнее, то бесконечный цикл возникнет, но хотя бы в ряде случаев программе повезет. Подробнее я писал про это в статье "64-битный конь, который умеет считать" [6].
Раньше я думал, что такое неожиданно удачное поведение программы связано исключительно с особенностями оптимизации Release версий. Однако теперь я в этом не уверен. Возможно, это сознательная попытка хотя бы иногда сделать неработоспособную программу работоспособной. Конечно, я не знаю, причина в оптимизации или в заботе большого брата, но это волне повод пофилософствовать. :) Ну а кто знает, тот вряд ли скажет. :)
Уверен, что есть и другие моменты, когда компилятор подставляет руку программам калекам. Если попадется что-то еще интересно, обязательно расскажу.
Желаю вам безглючного кода!
0