четверг, 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-квалификаторы с переменных, которые реально являются константными, оптимизатор этого не простит.


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