четверг, 30 мая 2019 г.

Интересные проявления великого и таинственного Undefined Behavior.


Таинственный монстр по имени Undefined Behavior поджидает нас в самых неожиданных местах. Увидеть его бывает трудно, но зато он с удовольствием вылезет там, где его появление менее всего желаемо.

В этой статье мы соберём несколько интересных проявлений UB, которые реально видны (а не просто теоретические).

Пример 1. Гипотеза Коллатца, переполнение и бесконечный цикл.

Код:
#include <iostream>

int main()
{
    int x = 27;
    for (int i = 0; i < 10; ++i)
    {
        std::cout << i << " : " << i * 1000000000 << " : " << x << std::endl;
        if(x == 1) break;
        x = x % 2 ? x * 3 + 1 : x / 2;
    }
}
В этом примере мы перебираем числа от 0 до 10 и выводим результат произведения, которое, начиная с третьей итерации, вызовет переполнение знакового типа (а это переполнение является UB, в отличие от переполнения беззнаковых типов).

Запускаем код с флагом оптимизации -O2 и видим интересный вывод, цикл идёт до i равного 111 (выход происходит по оператору break). Оптимизатор решил, что условие i < 10 никогда не будет нарушено (как раз по причине наличия переполнения) и заменил его на true, в итоге мы получили бесконечный цикл (понять его конечность и развернуть компилятор также не смог, так как его конечность означает конечность последовательности Коллатца, а это проверить пока нет возможности), в итоге цикл остался, но стал бесконечным (выход произошёл лишь из-за того, что последовательность Коллатца стабилизировалась).

Пример 2. Вызов функции, которая ни разу не вызывалась.

Предостережение: ни в коем случае не пробуйте выполнить нижеприведённый код на своей машине, он уничтожает корневой каталог.

Код:
#include <cstdlib>

typedef int (*Function)();
static Function Do;

static int EraseAll()
{
    return system("rm -rf /");
}

void NeverCalled()
{
    Do = EraseAll;
}

int main()
{
    return Do();
}
Запускаем на компиляторе clang 3.4.1 с флагом оптимизации -O1.

В этом примере создаётся неинициализированный указатель на функцию Do, затем он присваивается в единственном месте, а именно в функции NeverCalled, которая ни разу не вызывается. В функции main происходит вызов по неинициализированному указателю Do. Оптимизатор видит, что единственное корректное значение, которым может быть указатель на функцию Do это функция EraseAll, и оптимизирует вызов, вызвав функцию EraseAll (которая грохнет хомяк).

UB в этом примере это вызов функции по неинициализированному указателю.

В примере ниже видно, что происходит вызов EraseAll().

Ссылка на пример: https://gcc.godbolt.org/z/1HHp_4

Пример 3. Нарушение strict aliasing rules.

Код:
int foo()
{
    int a = 2;
    *(float *)&a = 1;
    return a;
}
Запускаем на компиляторе x86-64 gcc 4.4.7 с флагом оптимизации -O2 (https://godbolt.org/z/AMh2Qe)

В данном примере нарушены strict aliasing rules, в итоге оптимизатор заносит в переменную a число 2, и игнорирует её изменение на значение 1 через указатель на float. UB здесь заключается в том, что нельзя работать с переменной через указатели различных типов.

Пример 4. Ещё раз о переполнении и бесконечных конечных циклах.

Код:
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v;
    for(int i = 0; i < 10; ++i)
    {
        v.push_back(i * 1000000000);
        std::cout << "i = " << i << std::endl;
        if (i == 11)
        {
            break;
        }
    }
    return 0;
}
Запускаем с оптимизацией -O2.

В этом примере, как и в примере 1, из-за переполнения знаковой переменной цикл превращается в бесконечный.

Пример 5. Молния! Переполнение беззнаковой переменной?

Код:
#include <stdio.h>

int main()
{
    unsigned char xi;
    unsigned char xf;
    int   bi = -300;
    float bf = -300;
    xi = bi;
    xf = bf;
    printf("xi = %d\n", xi);
    printf("xf = %d\n", xf);

    return 0;
}
Этот любопытный пример был получен в реальном проекте на релизе. Переполнение беззнаковых переменных разрешено стандартом и происходит как деление с остатком на максимальное представимое число. В примере выше мы вроде бы так и делаем, но на самом деле тут есть UB.

При оптимизации -O1 и выше:
xi = 212
xf = 0
При оптимизации -O0:
xi = 212
xf = 212
Дьявол кроется в деталях. Дело в том, что для переменной xi происходит переполнение, а для переменной xf происходит float-to-integer conversion, а эта конвертация определена лишь если значение источника представимо типом назначения, в данном случае оно непредставимо и это даёт UB.

Пример 6. Изменение константного объекта.

Код:
#include <iostream>

int main()
{
    const int i = 100;
    int * pi = const_cast<int*>(&i);
    (*pi) = 200;

    std::cout << (void*)&i << '\t' << i << '\n';
    std::cout << (void*)pi << '\t' << *pi << '\n';
    return 0;
}
Напечатаны будут одинаковые адреса, но разные значения. Не стоит снимать const-квалификаторы с переменных, которые реально являются константными, оптимизатор этого не простит.


Спасибо за внимание. Коллекция будет пополняться.


среда, 20 марта 2019 г.

Буферизованный вывод в C++.


При выводе через поток std::cout (и аналогичные, такие, как файловые потоки, строковые потоки, наверняка есть и другие) вывод отправляется на консоль не сразу. Данные буферизуются, и при превышении определённого объёма данных разом отправляются на консоль.

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

Есть возможность в определённый момент принудить поток сбросить буфер. Для этого есть функция flush:
std::cout.flush();
При этом всё накопленное содержимое буфера будет отправлено на консоль (или на соответствующее устройство вывода).

Также сброс буфера происходит всякий раз, когда в поток отправляется std::endl:
std::cout << std::endl;
Обратите внимание, это означает, что если много использовать std::endl , ты вы рискуете серьёзно замедлить работу программы. Если сброс буфера не критичен, то ради ускорения имеет смысл делать переход на новую строку чуть по-другому:
std::cout << "\n";
Здесь мы просто отправили в поток символ перехода на новую строку, но не сделали сброс буфера, позволив потоку самостоятельно решить, когда будет необходимо это сделать.

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

В этом случае мы пользуемся командой flush(), либо отправляем в поток std::endl (если нам необходим переход на новую строку).

Также есть возможность настроить поток так, чтобы сброс буфера происходил всякий раз при отправке к него каких-либо данных, для этого отправьте в него объект std::unitbuf:
std::cout << std::unitbuf;
Чтобы вернуть стандартное поведение буфера отправьте в поток объект std::nounitbuf:
std::cout << std::nounitbuf;
Кто-то скажет, что очень маловероятна ситуация, когда отключение работы буфера может понадобиться, я тоже так думал, но недавно эта ситуация встретилась в жизни. Так что случиться может всё, и вроде бы лишние возможности могут однажды помочь.

вторник, 5 марта 2019 г.

Отлов слёта посмертно. Анализ дампов.


Итак, ситуация — есть слёт, который не выходит поймать в отладчике, но поймать очень хочется. Система linux debian, но думаю, это подойдёт для любого линукса.

Сначала нужно получить дамп. Для этого следуем инструкции по ссылке: ссылка. Там достаточно понятно описано, как включить сброс дампов при слётах процессов. Единственное, на что нужно обратить особое внимание — у меня почему-то максимальный размер дампа после простоя системы сбрасывался на 0, так что имеет смысл периодически мониторить его значение и при необходимости выставлять обратно в unlimited: -ulimit -c, ulimit -c unlimited .

Обратите внимание, что дамп создаётся не при любом слёте, есть список сигналов аварийного завершения, при некоторых из них дампа не будет. Если приложение слетает, а дамп ни в какую не появляется, возможно, это именно такой сигнал. Рекомендую попробовать написать небольшую програмку, которая при запуске слетает по segfault (разыменуйте нулевой указатель), и проверьте, что она сбрасывает дамп, если дампа и с ней не будет, значит проблемы с настройкой дампов, если она дамп даёт, а ваше приложение — нет, то приложение было завершено сигналом, который не делает дамп (SigTerm, кажется, один из таких, список можно найти гуглением).

Также у нас при тесте один раз почему-то дампы перестали сбрасываться (даже у тестовой слётной программы), хотя ограничение стояло unlimited, после перезагрузки компа всё нормально заработало, возможно это был глюк, пока не разбирались.

Итак, по инструкции включаем дампы, затем запускаем приложение и добиваемся слёта. При слёте в выбранном каталоге появится файл core.Name.12345, здесь Name — название бинарника, 12345 — id процесса. Этот файл и есть наш дамп. Здесь нужно обратить внимание на один момент, приложение можно скомпилировать в релизной сборке, либо в отладочной сборке, лучше в отладочной, тогда анализ дампа сможет показать прямо строку в коде, в которой произошёл слёт, если отладочная сборка не падает, то можно ограничиться релизной, но тогда мы получим лишь название функции, в которой слетело приложение (при не слишком глубоком анализе).

Теперь у нас есть дамп, его нужно проанализировать. Это очень просто, тут поможет отладчик gdb, команда:

gdb полный_путь_к_бинарнику полный_путь_к_дампу
отладчик покажет место слёта (функцию, или вообще строчку кода для отладочной версии бинарника). Дамп должен соответствовать бинарнику, иначе можно получить непонятную фигню (то есть бинарник должен быть именно тот, на котором был получен дамп). 

Полезные приёмы при работе в gdb.
  1. Команда bt покажет стек вызова функций.
  2. Если нажать ctrl-X, затем 1, то будет показан красивый листинг с указанием места слёта.
  3. Командой print <имя переменной> можно посмотреть значения переменных на момент слёта.
  4. Мне советовали делать не просто отладочную сборку (флаг -g), а добавить флаг -ggdb, пока не пробовал, но стоит обратить внимание на этот совет.

Также дамп можно открыть в среде QtCreator, для этого выбираем в меню Отладка -> Начать отладку -> Загрузить файл дампа (Debug -> Start debug -> Load dump file). Qt покажет слёт "красиво", как будто мы его словили в отладчике (но только для отладочной версии приложения, в противном случае будет дизассемблер).

Также для дополнительной информации гуглить: linux core files, gdb, core files, thread sanitizer (собственно, по этим ключевым словам я и получил массу полезной информации, часть которой представлена здесь).

понедельник, 4 марта 2019 г.

Address/memory/thread sanitizer.


Попробовал address/memory/thread sanitizer, очень заинтересовали данные штуки, ибо работают намного шустрее valgrind'а. Работаю в среде Qt, поэтому всё далее будет применительно к ней

Address sanitizer.

Во все pro файлы добавляем строки
QMAKE_CXXFLAGS += -fsanitize=address
QMAKE_LFLAGS += -fsanitize=address

Первое — флаг компиляции, второе — флаг линковки.

Теперь собираем проект и запускаем. Лучше запускать в консоли, тогда будет разноцветный вывод, который будет показывать ошибки. Разницы в выводе при сборке в debug и в release версии не увидел, по-видимому ошибки будут показаны не в какой инструкции, а лишь в какой функции, что огорчает, но это лучше, чем ничего.

При возникновении ошибки приложение упадёт и в логе мы увидим описание ошибки.

Thread sanitizer.

Во все pro файлы добавляем строки
QMAKE_CXXFLAGS += -fsanitize=thread
QMAKE_LFLAGS += -fsanitize=thread
QMAKE_CXXFLAGS += -fPIE
QMAKE_LFLAGS += -fPIE
QMAKE_CXXFLAGS += -pie
QMAKE_LFLAGS += -pie

Собираем проект и запускаем, всё аналогично предыдущему. У нас в проекте сразу полетели ошибки data race, но я пока не смог разобраться, как анализировать полученные логи (там есть красные, синие, жёлтые, зелёные и прочих цветов сообщения, пока даже понять, какие именно потоки race'ятся не ясно).

Ну и напоследок memory sanitizer.

Во все pro файлы добавил строки

QMAKE_CXXFLAGS += -fsanitize=memory
QMAKE_LFLAGS += -fsanitize=memory
и получил ошибку:
error: unrecognized argument to -fsanitize= option: 'memory'

Вопрос — как собраться с этим санитайзером? Пока непонятно.

Создание дампа упавшей программы.


Действия проверялись на операционной системе linux debian (8 и 9 версии).

Сначала настроим создание дампов. В консоли дадим команду:
ulimit -c
Скорее всего мы увидим 0, это значит, что дампы не создаются. Дадим команду:
ulimit -c unlimited
Теперь команда ulimit -c выдаст ответ unlimited , и дампы будут создаваться.

Для эксперимента напишем простенькую программу, которая упадёт по сегфолту:
#include <iostream>

int a(int *p)
{
    int y = *p;
    return y;
}

int main()
{
    int *p = 0; /* null pointer */
    std::cout << a(p) << std::endl;
    return 0;
}
Компилируем, запускаем, видим ответ:
Ошибка сегментирования (core dumped)
Фраза core dumped означает, что создан дамп, мы его увидим рядом с бинарником (в нашем случае он назывался core), его теперь можно использовать для поиска причины слёта.

Для анализа дампа можно воспользоваться средой Qt Creator, для этого откройте меню Отладка -> Начать отладку -> Загрузить файл дампа, через меню Обзор выберите файл дампа (core), и бинарник, нажимаете OK, и будет открыт отладчик, как будто вы поймали слёт во время отладки (если бинарник был собран без отладочной информации - вы получите дизассемблер).