LINUX.ORG.RU

О неопределённом поведении и багах оптимизатора clang на примере разбора цикла статей Криса Латтнера, разработчика clang.

 , , , ,


11

10

про ud2 - лень читать комменты - но кто-то должен был оставить вот этот цикл из трех постов:

http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html
http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_14.html
http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_21.html

stevejobs, спасибо за ссылки. С интересом почитал. Ответ получился довольно длинным, даже движок форума не хочет принимать его в таком виде в качестве простого камента, поэтому я решил, что он тянет на отдельную тему. Тем более, что здесь разбирается не какой-то мелкий баг/фича clang'а, а общий подход к созданию оптимизатора, основанный на изложении и анализе статей одного из разработчиков этого компилятора Криса Латтнера (Chris Lattner). Ну и ещё мне пришлось полностью переписать один из его примеров, чтоб он начал работать и иллюстрировать излагаемые им идеи. Если хочешь, можешь послать ему код, чтоб вставил в свою статью вместо своего, не рабочего. Я не возражаю.

А для тех, кто не в теме, это продолжение темы Вызов никогда не вызываемой функции.

Основная мысль автора цикла статей, как я понял, выражена ближе к концу 3-ей статьи в короткой фразе:

c) is a lot of work to implement.

Т. е. автор, по сути, соглашается, что они реализовали какую-то дичь с этими оптимизациями, объясняя некоторые причины: что де оптимизатор не знает, что на входе получает компилятор и даже не знает, что получает он сам на предыдущем проходе, ну и некоторые другие причины. А в конце говорит: но переделывать эту неудачную архитектуру нам лень, поэтому пользуйтесь тем, что есть. Что ж, fixed. В любом случае, статьи полезны, т. к. проливают свет на реальное (и довольно печальное) положение дел, связанных с оптимизациями в компиляторе clang.

Теперь разберу примеры из статей.

1. В первом примере автор пытается показать, почему к указателю на int нельзя обращаться как к указателю на float:

float *P;
 void zero_array() {
   int i;
   for (i = 0; i < 10000; ++i)
     P[i] = 0.0f;
 }

int main() {
  P = (float*)&P;  // cast causes TBAA violation in zero_array.
  zero_array();
}

Этот код — полная дичь. Он будет вылетать и с оптимизациями, и без них, потому что в переменную P (которая по умолчанию инициализируется 0), записывается её собственный адрес, а дальше, начиная с этого адреса, записываются ещё 40000 байт, которые непременно выдут за границы выделенной памяти.

Я немного переделал этот пример, чтоб он заработал, добавив сразу после P массив достаточного размера, в который и будут записываться числа (тоже undefined, но по факту работает с обоими компиляторами — clang и gcc), и заменив запись 0 на запись адреса &P+1, для чего ввёл объединение ufp, т. к. иначе на первой же итерации P будет указывать на 0, после чего на 2-й итерации произойдёт сегфолт. Ну и ещё добавил printf'ы для вывода информации и return, как того требует gcc и стандарт. Тут же отмечу, что мне пришлось слегка повозиться с unsigned *p, объявленным внутри функции print_array(). Сначала я сделал его глобальным, объявив до указателя float *P, и получил похожий на приведённый, но не совсем верный вывод: вместо ожидаемого 0x601268, 0x0, 0x0, 0x601268 программа без оптимизаций выдавала 0x601268, 0x0, 0x601268, 0x0. После установки watchpoint'а в дебагере выяснилось, что массив повторно модифицируется функцией print_array(). Просмотр адресов глобальных переменных &P и &p показал, что и clang, и gcc вставляют p после P и перед массивом arr, хотя в тексте программы она была объявлена первой. Видимо, компиляторы зачем-то сортируют переменные по типам и располагают указатели на float раньше указателей на unsigned. После переноса unsigned *p внутрь функции, всё стало работать, как и ожидалось. Вот мой рабочий (хоть и намеренно некорректный) вариант:

#include <stdio.h>

float *P;

float arr[10000];

union ufp
{
  float** p; float f;
} fp={&P+1};

void zero_array() {
   int i;
   for (i = 0; i < 10000; ++i)
     P[i] = fp.f;
 }

void print_array() {
   int i;
   unsigned *p;
   printf("&P==%p, P==%p\n", &P, P);
   for(i = -2; i < 10000; ++i)
    {
      p=(unsigned*)P;
      printf("&P[%i]==%p, P[%i]==%f (%p: 0x%X)\n", i, &P[i], i, P[i], p+i, *(p+i));
    }
}

int main() {
  P = (float*)&P;  // cast causes TBAA violation in zero_array.
  zero_array();
  //P=(float*)(&P+1); // restoring P for optimizer
  print_array();
  return 0;
}

При компиляции clang'ом без оптимизации:

clang -o zero_array zero_array.c

этот вариант выдаёт следующее:

&P==0x601260, P==0x601268
&P[-2]==0x601260, P[-2]==0.000000 (0x601260: 0x601268)
&P[-1]==0x601264, P[-1]==0.000000 (0x601264: 0x0)
&P[0]==0x601268, P[0]==0.000000 (0x601268: 0x0)
&P[1]==0x60126c, P[1]==0.000000 (0x60126c: 0x601268)
&P[2]==0x601270, P[2]==0.000000 (0x601270: 0x601268)
&P[3]==0x601274, P[3]==0.000000 (0x601274: 0x601268)
&P[4]==0x601278, P[4]==0.000000 (0x601278: 0x601268)
[skip]
&P[9994]==0x60ae90, P[9994]==0.000000 (0x60ae90: 0x601268)
&P[9995]==0x60ae94, P[9995]==0.000000 (0x60ae94: 0x601268)
&P[9996]==0x60ae98, P[9996]==0.000000 (0x60ae98: 0x601268)
&P[9997]==0x60ae9c, P[9997]==0.000000 (0x60ae9c: 0x601268)
&P[9998]==0x60aea0, P[9998]==0.000000 (0x60aea0: 0x601268)
&P[9999]==0x60aea4, P[9999]==0.000000 (0x60aea4: 0x601268)

и корректно завершается.

Если же включить оптимизацию:

clang -o zero_array -O2 zero_array.c

то получаем следующее:

$ ./zero_array
&P==0x601260, P==0x60126800601268
Ошибка сегментирования

Кстати, такой же результат будет, если откомпилировать эту программу компилятором gcc с включённой оптимизацией (там только адреса будут немного другими). Избавиться от этой ошибки можно 2 способами:

  1. Закомментировав вызов print_array() в функции main().

    Очевидно, что в этом случае никакого вывода мы не получим.

  2. Раскомментировав в main() строчку
    P=(float*)(&P+1); // restoring P for optimizer

    Тогда мы получим такой вывод:

    &P==0x601260, P==0x601268
    &P[-2]==0x601260, P[-2]==0.000000 (0x601260: 0x601268)
    &P[-1]==0x601264, P[-1]==0.000000 (0x601264: 0x0)
    &P[0]==0x601268, P[0]==0.000000 (0x601268: 0x601268)
    &P[1]==0x60126c, P[1]==0.000000 (0x60126c: 0x601268)
    &P[2]==0x601270, P[2]==0.000000 (0x601270: 0x601268)
    &P[3]==0x601274, P[3]==0.000000 (0x601274: 0x601268)
    &P[4]==0x601278, P[4]==0.000000 (0x601278: 0x601268)
    [skip]
    &P[9994]==0x60ae90, P[9994]==0.000000 (0x60ae90: 0x601268)
    &P[9995]==0x60ae94, P[9995]==0.000000 (0x60ae94: 0x601268)
    &P[9996]==0x60ae98, P[9996]==0.000000 (0x60ae98: 0x601268)
    &P[9997]==0x60ae9c, P[9997]==0.000000 (0x60ae9c: 0x601268)
    &P[9998]==0x60aea0, P[9998]==0.000000 (0x60aea0: 0x0)
    &P[9999]==0x60aea4, P[9999]==0.000000 (0x60aea4: 0x0)
    

Проблема тут очевидна: в цикле в P[i] записывается адрес &P+1. Но указатель P указывает на самого себя благодаря присвоению P = (float*)&P. Соответственно, элемент P[0] находится по тому же адресу, что и P. Когда при 1-й итерации цикла мы записываем туда адрес следующего элемента, указатель P меняется. В 64-битной ОС размер указателя равен 8 байтам, а размер float — 4, т. е. P у нас теперь указывает на начало arr. Дальше мы записываем 1-ый элемент от нового начала массива, т. е. по сути 3-й элемент, пропуская таким образом 2 элемента.

Когда же мы включаем оптимизатор (и в clang, и в gcc), он записывает все двойные слова подряд, начиная с 0-ого (в P[-1] у нас 0 потому, что мы перезаписали его после вызова zero_array(), чтобы программа не вылетела при вызове print_array()). Поэтому в первых 2 элементах у нас записано число 0x601268 (если представлять его как беззнаковое целое длиной в 4 байта), но 1-ые 2 элемента одновременно являются адресом, на который указывает P, т. е. адресом 0x0060126800601268 (0x601268 повторенное 2 раза). Если ничего не выводить, то всё тоже проходит успешно. Но как только мы вызываем print_array() (не модифицировав этот дикий адрес), программа сразу пытается отобразить содержимое не валидного адреса 0x601268, а того самого 0x0060126800601268, которого в нашем адресном пространстве просто нет. И получает сегфолт.

Почему printf отображает значения с плавающей точкой, которые в целочисленном виде выглядят как 0x601268, нулями, а не NAN, как по идее должно бы было быть, я не знаю. Видимо, это баг стандартной библиотеки (надо будет послать багрепорт, если никто мне не объяснит, что они правы).

Кстати, оба компилятора по неведомым мне причинам при оптимизации (а я пробовал разные уровни оптимизации) почему-то вместо записи поля fp.f memset'ом продолжают генерить цикл, только более короткий, чем без оптимизации (ассемблерные листинги я тут приводить не буду, кому интересно, могут сами откомпилировать с опцией -S). Хотя при записи константы 0 компилятор clang с вкючённой оптимизацией вместо цикла вызывает memset (gcc и в этом случае генерит цикл).

На всякий случай укажу версии использованных компиляторов:

$ clang --version
Debian clang version 3.5.0-10 (tags/RELEASE_350/final) (based on LLVM 3.5.0)
Target: x86_64-pc-linux-gnu
Thread model: posix

$ gcc --version
gcc (Debian 4.9.2-10) 4.9.2
Copyright (C) 2014 Free Software Foundation, Inc.
Это свободно распространяемое программное обеспечение. Условия копирования
приведены в исходных текстах. Без гарантии каких-либо качеств, включая 
коммерческую ценность и применимость для каких-либо целей.

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

c) is a lot of work to implement.

Такие они, разработчики clang'а.

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

It is undefined behavior to cast an int* to a float* and dereference it (accessing the «int» as if it were a «float»).

Как это утверждение иллюстрируется данным примером?

Я уже не говорю о том, что оптимизировать циклы в memset нет никакой необходимости, потому что программист и сам может это сделать, сократив не только получившийся бинарник, но и исходник. А если программисту до этого нет дела, то почему компилятору должно быть дело? Тем более, если программист сделал цикл намеренно, то компилятору совсем незачем это исправлять. Думаю, именно поэтому gcc и не сворачивает циклы в memset. Я уже не говорю о том, что если уж вы сворачиваете их в memset, то будьте последовательны. Почему при заполнении массива константой 0 вместо цикла clang вызывает memset, а при заполнении того же массива одной не меняющейся переменной длиной в 4 байта оставляет цикл? Грош цена такой оптимизации.

Вот заменить вызов memset на ассемблерную команду rep stos действительно было бы полезно, но почему-то ни clang, ни gcc этого не делают.

2. Во втором примере автор цикла показывает, как смертельный указатель может запутать оптимизатор clang так, что тот сгенерит очередную фигню вместо исполняемого кода. Вот пример смертельного кода из 2-й статьи:

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

Очевидно, что если передать функции contains_null_check() NULL, то код будет непереносимым (undefined behavior). В защищённом режиме при попытке разыменования такого указателя произойдёт сегфолт. (UPD: практически аналогичная ошибка в ядре Linux 2.6.30 и 2.6.18 для Red Hat привела к серьёзной уязвимости, причём при разыменовании указателя система не падала.) Однако в реальном режиме такой код вполне законный. Более того, если мы рассматриваем язык си как системный язык, то в некоторых случаях без подобного кода в реальном режиме не обойтись. Что у нас лежит по адресу 0 в реальном режиме? — Указатель на обработчик 0-ого прерывания (деление на 0). А что если я хочу зарегистрировать свой обработчик? Для этого и существует неопределённое поведение: в одних системах оно работает так, а в других иначе. Но разработчики clang'а считают, что «неопределённое поведение» — это индульгенция на генерацию разного бреда вместо нормального кода.

Но вернёмся к статье. Автор описывает 2 варианта поведения оптимизатора.

  1. В первом варианте сначала проверяется избыточный код, а затем избыточные проверки. Выглядит это примерно так:
    void contains_null_check_after_DCE(int *P) {
      //int dead = *P;     // deleted by the optimizer.
      if (P == 0)
        return;
      *P = 4;
    }
    

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

    Далее идёт проверка избыточности проверок и делается правильный вывод о том, что проверка P на равенство 0 нужна. Она остаётся. Всё работает, как и задумывалось (и даже не падает в защищённом режиме на радость быдлокодерам).

  2. Во втором варианте оптимизатор сначала проверяет проверки программиста на избыточность, а затем выпиливает ненужные переменные:
    void contains_null_check_after_RNCE(int *P) {
      int dead = *P;
      if (false)  // P was dereferenced by this point, so it can't be null 
        return;
      *P = 4;
    }
    

    Здесь оптимизатор почему-то решил, что раз *P разыменовывается без проверки, то он априори 0 быть не может и проверять его необходимости нет. А то, что программист мог ошибиться, разработчикам оптимизатора даже в голову не приходит. Как и то, что помимо защищённого режима есть ещё и реальный. А бывают ещё компиляторы для разных контроллеров и встроенных специализированных систем, где разыменовывать 0 указатели бывает нужно и иногда даже необходимо. Или clang такие системы не поддерживает? И никогда не сможет поддержать с подобным подходом, ориентированным на работу только защищённых многозадачных ОС.

    Но вернёмся к статье. На следующем этапе выпиливается переменная dead и проверка на 0 и остаётся:

    void contains_null_check_after_RNCE_and_DCE(int *P) {
      *P = 4;
    }
    

    Если раньше программа корректно работала в реальном режиме, а в защищённом падала, то теперь в реальном режиме вектор 0-ого прерывания перезаписывается адресом 4. В результате при любой ошибке деления компьютер намертво зависает (хотя реальный режим clang, как я понимаю, не поддерживает и никогда не сможет поддержать с таким шикарным легаси).

3. Третий пример я разбирать не буду, т. к. согласен с автором, что оптимизация «x > x+1 всегда false» может быть полезна при использовании макросов. А для проверки переполнения существуют константы MAX_*.

4. Четвёртый пример — почти из поста Вызов никогда не вызываемой функции. Его уже разобрали по полочкам, сломали все копья, какие только можно было сломать, в т. ч. и я, поэтому здесь повторяться не буду. Единственно, скажу, что мне было непонятно, зачем заменять вызов функции по 0-ому адресу с неизбежным сегфолтом на недопустимую инструкцию ud2. Автор поясняет во 2-й статье:

2. Clang has an experimental -fcatch-undefined-behavior mode that inserts runtime checks to find violations like shift amounts out of range, some simple array out of range errors, etc. This is limited because it slows down the application's runtime and it can't help you with random pointer dereferences (like Valgrind can), but it can find other important bugs. Clang also fully supports the -ftrapv flag (not to be confused with -fwrapv) which causes signed integer overflow bugs to trap at runtime (GCC also has this flag, but it is completely unreliable/buggy in my experience). Here is a quick demo of -fcatch-undefined-behavior:

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

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

И напоследок 2 эпические цитаты. Первая из начала 1-ой статьи. Она очень понравилась dzidzitop:

It turns out that C is not a «high level assembler» like many experienced C programmers (particularly folks with a low-level focus) like to think

Вот оно что оказывается. Си — это та же ява, чуть более быстрая и более опасная. А для написания системных вещей переходите на настоящий ассемблер! Кен Томпсон гомерически хохочет и Деннис Ритчи переворачивается в гробу.

А вторая из 3-ей, заключительной статьи:

Ultimately, undefined behavior is valuable to the optimizer because it is saying «this operation is invalid - you can assume it never happens».

В вольном пересказе это обозначает: «Вау! Неопределённое поведение! Ворочу куда хочу!»

UPD: Вот хочу добавить сюда ещё несколько ответов на вопросы, на которые приходится отвечать по всему треду одно и то же:

1. То, что ub обозначает «делай, что хочешь!», мягко говоря, неправда. Вот, что написано в стандарте C99:

3.4.3

1 undefined behavior

behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this International Standard imposes no requirements

2 NOTE Possible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message).

3 EXAMPLE An example of undefined behavior is the behavior on integer overflow.

Т. е. в стандарте чётко прописаны 3 действия, из которых разработчики компиляторов могут выбрать любое:

1. Игнорировать ситуацию. Смотрим в Ожегове, что обозначает слово «игнорировать»:

Умышленно не заметить, не принять во внимание.

Т. е. «игнорировать» — это сделать вид, что всё нормально и пройти мимо, а не модифицировать или удалять такой код, и уж тем более делать на его основе какие-то бредовые предположения.

2. Компилировать и выполнять такой код в соответствии с документацией, с выводом предупреждающих сообщений или без них. Т. е. напишите явно в документации, что если мы встречаем 0 указатель, то делаем то-то и то-то, и делайте. Но не в тихую.

3. Прерывать компиляцию и/или выполнение с обязательным выводом диагностических сообщений.

И всё. Ни о каком «что хочешь» в стандарте речи не идёт. Вот тут мне в каментах подсказали, что «with unpredictable results» обозначает «что хочешь». Но на самом деле это обозначает лишь то, что результаты могут быть непредсказуемыми, а совсем не то, что компилятор может делать всё, что угодно (хотя разработчикам таких компиляторов подобная трактовка очень удобна).

Ну и тот же человек считает, что «это notes», а значит неважно, что там написано. Но т. н. неопределённое поведение при переполнении целого — вообще example из того же пункта:

EXAMPLE An example of undefined behavior is the behavior on integer overflow.

И больше я нигде никаких упоминаний об ub при арифметическом переполнении не нашёл. Про переполнение при сдвигах — нашёл. А в других случаях — нет. Но все почему-то на этот example ссылаются.

2. Многие говорят, что быдлокодеры должны страдать. Но серьёзные уязвимости, связанные с ub, а точнее с непредсказуемой реакцией компилятора на ub, в разное время обнаруживались в ядре Linux, во FreeBSD и в GDK-Pixbuf, затрагивающая Chromium, Firefox и VLC. Подробнее см. в этом комментарии, чтоб не раздувать и без того длинный верхний пост. Здесь только скажу, что уязвимость в ядре Linux связана с ошибкой, идентичной со 2-м примером из разбираемых статей.

3. Автор статей и многие в этом треде утверждают, что автоматически отыскать такие ошибки очень сложно и дорого, а то и вовсе невозможно. Но это тоже не так. В Интернете я нашёл такой пример си++ программы с ub:

#include <iostream>
int main()
{
    for (int i = 0; i < 300; i++)
        std::cout << i << " " << i * 12345678 << std::endl;
}

Программа из-за переполнения временного результата на 174-й итерации при использовании ключа оптимизации -O2 в g++ попадает в бесконечный цикл.

Запустив компиляцию, я получил следующие предупреждения (причём безо всяких опций -W что-то_там):

$ g++ -o infinity_loop -O2 infinity_loop.cpp
infinity_loop.cpp: В функции «int main()»:
infinity_loop.cpp:5:38: предупреждение: iteration 174u invokes undefined behavior [-Waggressive-loop-optimizations]
         std::cout << i << " " << i * 12345678 << std::endl;
                                      ^
infinity_loop.cpp:4:5: замечание: containing loop
     for (int i = 0; i < 300; i++)
     ^

А ведь здесь случай куда менее очевидный, чем простое разыменование NULL-указателя.

4. Наконец, на Хабре я вычитал, что стандартный макрос

#define offsetof(st, m) ((size_t)(&((st *)0)->m))

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

UPD 2: Вот тут Sorcerer в комментарии кинул ссылку на письмо Линуса Торвальдса в рассылке от 12 января 2009 года, где он пишет о том, что думает о некоторых оптимизациях. Приведу несколько фрагментов этого письма в своём переводе:

Type-based aliasing — это тупость. Это такая невероятная тупость, что даже не смешно. Оно испорчено. И gcc взял испорченную концепцию и настолько её раздул, следуя букве-закона, что получилась бессмысленная вещь.

[skip]

Это НЕНОРМАЛЬНО. Это так невероятно безумно, что люди, которые делают это, просто должны избавиться от своего убожества, прежде чем они смогут восстановить. Но реальные gcc программисты действительно думали, что это имеет смысл, потому что стандарт это позволяет и даёт компилятору максимальную свободу, — потому что он может делать теперь вещи БЕЗУСЛОВНО АБСУРДНЫЕ.

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

[skip] И если кто-то жалуется, что компилятор невменяемый, компиляторщики скажут «ня, ня, разработчики стандарта сказали, что так можно», с абсолютным отсутствием анализа, имеет ли оно СМЫСЛ.

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

[skip]

Угадайте, что произойдёт, если вы имеете такой безумный склад ума и пытаетесь сделать безопасный код без alias'а, — вы займёте лишнее пространство на стеке.

По факту, Linux использует -fno-strict-aliasing из-за чертовски веской причины: потому что в gcc понятие «strict aliasing» является огромной зловонной кучей д-рьма. Linux использует этот флаг не потому, что Linux исполняется быстро и свободно, он использует этот флаг, потому что _не_ использует тот безумный флаг.

Type-based aliasing неприемлемо тупо для начала, и gcc вознёс этот идиотизм до совершенно новых высот, фактически не обращая внимания даже на статически видимый aliasing.

Линус

Оригинал (на английском):

Type-based aliasing is _stupid_. It's so incredibly stupid that it's not even funny. It's broken. And gcc took the broken notion, and made it more so by making it a «by-the-letter-of-the-law» thing that makes no sense.

[skip]

That's INSANE. It's so incredibly insane that people who do that should just be put out of their misery before they can reproduce. But real gcc developers really thought that it makes sense, because the standard allows it, and it gives the compiler the maximal freedom - because it can now do things that are CLEARLY NONSENSICAL.

And to compiler people, being able to do things that are clearly nonsensical seems to often be seen as a really good thing, because it means that they no longer have to worry about whether the end result works or not - they just got permission to do stupid things in the name of optimization.

[skip] And if somebody complains that the compiler is insane, the compiler people would say «nyaah, nyaah, the standards people said we can do this», with absolutely no introspection to ask whether it made any SENSE.

Anyway, once you start doing stupid things like that, and once you start thinking that the standard makes more sense than a human being using his brain for 5 seconds, suddenly you end up in a situation where you can move stores around wildly, and it's all 'correct'.

[skip]

Guess what happens if you have that kind of insane mentality, and you then try to make sure that they really don't alias, so you allocate extra stack space.

The fact is, Linux uses -fno-strict-aliasing for a damn good reason: because the gcc notion of «strict aliasing» is one huge stinking pile of sh*t. Linux doesn't use that flag because Linux is playing fast and loose, it uses that flag because _not_ using that flag is insane.

Type-based aliasing is unacceptably stupid to begin with, and gcc took that stupidity to totally new heights by making it actually more important than even statically visible aliasing.

Linus

И ещё спасибо anonymous'у за камент с ещё одним сообщением на ту же тему того же автора от 26 февраля 2003 года.

Ну и от себя добавлю, что не только Линусу не нравится aliasing. Microsoft тоже не спешит реализовывать его в своём Visual C++. Т. е. не нравится это тем, кто помимо разработки компиляторов создаёт и другой софт с использованием этого компилятора, например ОС. А те, кто создают только компиляторы для сферических программистов в вакууме, рьяно эту фичу реализуют, хоть их и никто не заставляет.

Ну и напоследок оставлю несколько полезных ссылок на память:

Стандарт C11 (последний) (pdf), Стандарт C99 (pdf),

http: //read.pudn.com/downloads133/doc/565041/ANSI_ISO%2B9899-1990%2B%5B1%5D.pdf (Стандарт C89) (pdf),

http: //web.archive.org/web/20030222051144/http: //home.earthlink.net/~bobbitts/c89.txt (Стандарт C89) (txt),

https: //web.archive.org/web/20170325025026/http: // www .open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4660.pdf (Стандарт C++17) (последний) (pdf),

Стандарт C++14 (pdf), Стандарт C++11 (pdf),

бумажный перевод стандарта C++17, выполненный Зуевым и Чуприновым, Москва, 2016, на основе Working Draft, Standard for Programming Language C++ от 22 мая 2015 года (номер документа n4527) за 4945 руб. (надеюсь, что эту ссылку не сочтут за рекламу, т. к. к авторам я никакого отношения не имею), а здесь можно скачать начало перевода (предисловие и содержание), ну и ещё торрент-ссылку видел на эту книгу, но здесь её публиковать не буду,

статья на Хабре (из песочницы) от 2014 г. Неопределенное поведение в C++, ещё одна статья там же от 2016 года Находим ошибки в коде компилятора GCC с помощью анализатора PVS-Studio, Разыменовывание нулевого указателя приводит к неопределённому поведению и Про C++ алиасинг, ловкие оптимизации и подлые баги. Это так, ссылки на заметку.

Некоторые ссылки парсер ЛОР'а не принял, поэтому мне пришлось разделить их пробелами, превратив в текст, который можно скопировать в адресную строку браузера, удалив пробелы. Там, где http встречается дважды в 1 строке — не ошибка, а именно такие ссылки.

★★

Последнее исправление: aureliano15 (всего исправлений: 2)

«Вау! Неопределённое поведение! Ворочу куда хочу!»

Но ведь это действительно так!

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

У погроммиста есть голова на плечах, а если он сам написал лажу - его проблемы.

timdorohin ★★★★
()
Ответ на: комментарий от timdorohin

Но ведь это действительно так!

Это не так. Но чтобы более конкретно ответить на это утверждение, мне придётся скопипастить верхний пост. Проще дать ссылку.

А если вкратце, то повторю основные тезисы, перечисленные там:

1. Неопределённое поведение нужно для того, чтоб программы работали на любом железе без костылей (например, в цикле статей совершенно справедливо упоминается разная реализация сдвигов на разном железе), в разных системах без костылей (например, защищённый режим Linux и реальный DOS), ну и чтобы дать некоторую свободу компиляторам оптимизировать код. Но оптимизировать, а не портить!

2. Если мы исходим из того, что любое неопределённое поведение запрещено, и компилятор имеет право реагировать на такое поведение как ему вздумается, в т. ч. совершенно неадекватно, то зачем оно тогда вообще нужно? Просто запретить любые неопределённости, как это сделано в яве, и проблем не будет ни у программистов, ни у разработчиков компиляторов. Только тогда мы получим 2-ую яву и о си придётся забыть.

3. Иногда т. н. неопределённое поведение позволяет получить серьёзный выигрыш в производительности, а иногда без него просто не обойтись, как, например, при написании обработчика 0-ого прерывания в реальном режиме. Не нужна эффективность? Не нужно писать системных программ? Так переходите на яву, она проще, безопаснее и практически 100% переносима между всеми платформами, на которых может выполняться. Но зачем ещё одна ява?

У погроммиста есть голова на плечах, а если он сам написал лажу - его проблемы.

Да. Но одна из задач компилятора — указывать программисту на его ошибки, а не прятать их.

aureliano15 ★★
() автор топика
Ответ на: комментарий от aureliano15

UB всегда было и остается специфическим для конкретной реализации (а порой и версии) компилятора и целевой архитектуры.

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

Когда идем ниже — тут уже как высокоуровневый ассемблер работаем. Но никто не гарантирует, что никто ничего не сломает в новой версии компилятора.

timdorohin ★★★★
()
Ответ на: комментарий от timdorohin

Но никто не гарантирует, что никто ничего не сломает в новой версии компилятора.

Никто вообще ничего в этой жизни не гарантирует. Нет гарантий, что любая мало-мальски сложная программа будет работать безупречно. Скорее наоборот, с вероятностью 90% можно предсказать, что у любой нетривиальной программы обнаружатся проблемы и уязвимости. Но это не значит, что не надо стремиться к совершенству.

Так же и с компиляторами. Естественно, если мы хотим оптимизировать код, то его придётся перестроить. Либо полностью отказаться от оптимизации, как это делали когда-то пользователи gcc, когда оптимизация на этом компиляторе лагала. Но разработчики компиляторов должны подходить к этому с разумным консерватизмом, а не по принципу: раз стандарт разрешает, то будем генерить бред вместо кода!

aureliano15 ★★
() автор топика
Ответ на: комментарий от aureliano15

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

Это противоположное по смыслу решение. Если ты сделаешь define all behavior как в яве то компилятор сможет делать меньше оптимизаций и соответственно твой код будет медленнее.

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

pftBest ★★★★
()

Вот хорошее выступление по теме, от еще одного разработчика clang, советую посмотреть

https://youtu.be/yG1OZ69H_-o

pftBest ★★★★
()
Ответ на: комментарий от pftBest

Если ты сделаешь define all behavior как в яве то компилятор сможет делать меньше оптимизаций

Почему? В яве как раз очень много возможностей для оптимизации. Например, благодаря тому, что программист не может получить указатель на какой либо объект (будь то класс или объект встроенного типа), компилятор и ява-машина могут реализовывать его как хотят. Можно, например, все целые типы сделать одинаковой (максимальной) длины, а потом обрезать. В этом смысле у компилятора куда больше свободы, чем в си.

С другой стороны, зачем заниматься самообманом? Если NULL-указатель использовать нельзя, то проще и честнее запретить любые NULL-указатели и генерировать ран-тайм ошибку с аварийным завершением программы, чем генерить непонятно что, якобы оптимизируя.

соответственно твой код будет медленнее.

А код действительно будет выполняться медленнее. Но не из-за недостатка свободы для компилятора, а из-за недостатка свободы программиста. Потому что эффективность программы на 90% определяется программистом и дай бог если на 10% — компилятором.

А если ты добавляешь в язык больше UB, то ты даешь больше свободы компилятору

А если я введу в язык больше прямых запретов, то у компилятора будет ещё больше свободы. Чего стоит запрет указателей. Компиляторы сразу смогут представлять любые структуры как угодно, выравнивать их внутри как им нравится и т. д., лишь бы внешний интерфейс сохранялся.

А если запретить досрочное прерывание циклов break'ом и циклы по условию, которое заранее не определено (т. е. оставить только for с обязательным указанием всех 3 условий в виде констант), то компилятор гораздо чаще сможет предсказывать число итераций и оптимизировать код.

и он сможет делать больше оптимизаций

И, кстати, к вопросу о нужности большинства оптимизаций. Насколько необходимо, например, сворачивать цикл, заполняющий массив 0, в memset, если это может сделать программист?

И многие другие оптимизации программист тоже может сделать. Например, заинлайнить функцию. Не всё, конечно, сделать просто. Например, заменить тот же memset на rep stos хоть и можно, но делает программу зависимой от ассемблера для данной архитектуры, или надо создавать ifdef'ы для разных архитектур. Но как раз такую полезную и казалось бы простую оптимизацию ни clang, ни gcc не выполняют.

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

https://youtu.be/yG1OZ69H_-o

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

aureliano15 ★★
() автор топика
Ответ на: комментарий от aureliano15

Иногда т. н. неопределённое поведение позволяет получить серьёзный выигрыш в производительности

Значит вы пишете говнокод. Приведите пример.

а иногда без него просто не обойтись, как, например, при написании обработчика 0-ого прерывания в реальном режиме.

Ну и нахрена вам прерывания в реальном режиме?

(например, в цикле статей совершенно справедливо упоминается разная реализация сдвигов на разном железе

Сдвигать знаковые числа - UB по определению. Сдвиг беззнаковых безопасен. Если вам надо сдвинуть знаковое число - вы что-то делаете не так.

PPP328 ★★★★★
()

It turns out that C is not a «high level assembler» like many experienced C programmers (particularly folks with a low-level focus) like to think

Корень проблем сишников типа ТС и ckotinko - то что в 2017 до них начинает доходить то что написано в C89. C был макроассемблером в 70-е, и тут мелькал говнокод который тогда писали. Вам примерно в то время.

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

anonymous
()
Ответ на: комментарий от PPP328

Иногда т. н. неопределённое поведение позволяет получить серьёзный выигрыш

Приведите пример.

Вот пример программы с неопределённым поведением (по стандарту нельзя полагаться на то, что при целочисленном переполнении результат сложения будет меньше любого из операндов):

uint64_t a=var1, b=var2, c;

if((c=a+b)<a) // неопределённое поведение.
  printf("Переполнение!\n");
else
  printf("%lu\n", c);

А вот пример той же программы, написанной в соответствии со стандартом:

uint64_t a=var1, b=var2, c, a1, a2, b1, b2, c1, c2;

a1=a&0xFFFFFFFF;
a2=(a&0xFFFFFFFF00000000L)>>32;
b1=b&0xFFFFFFFF;
b2=(b&0xFFFFFFFF00000000L)>>32;
c1=a1+b1;
c2=a2+b2;

if(c2&0x100000000L)
  printf("Переполнение!\n");
else if(c1&0x100000000L)
 {
  c1&=0xFFFFFFFF;
  c2++;
  if(c2&0x100000000L)
   {
     printf("Переполнение!\n");
     return;
   }
  printf("%lu\n", c=(c2<<32)|c1);
 }
else
 printf("%lu\n", c=(c2<<32)|c1);

Ну и нахрена вам прерывания в реальном режиме?

Конкретно мне сейчас не нужны. А кому-то нужны, разработчикам GRUB'а, например. Может и мне когда-то понадобятся.

Сдвигать знаковые числа - UB по определению.

А вот и нет. Не знаковые, а только отрицательные и только влево. Сдвиг отрицательных чисел вправо «зависит от реализации», что не совсем ub.

Сдвиг беззнаковых безопасен.

И опять нет. Любое переполнение при сдвиге является ub. Поэтому, если вам надо сдвинуть беззнаковую переменную, значение которой на этапе компиляции неизвестно, никогда не делайте так:

var<<=n

А только так:

mask=UINT_MAX;
mask>>=n;
var&=mask;
var<<=n;

Только этот способ гарантирует, что при сдвиге влево не произойдёт переполнения.

А ещё лучше переходите на яву, потому что если будете так писать, то код на яве получится намного быстрее.

aureliano15 ★★
() автор топика
Ответ на: комментарий от aureliano15

по стандарту нельзя полагаться на то, что при целочисленном переполнении результат сложения будет меньше любого из операндов

У тебя беззнаковые числа, поэтому можно. Результат будет взят по модулю (UINT64_MAX + 1), поэтому он будет меньше любого из операндов.

proud_anon ★★★★★
()
Ответ на: комментарий от aureliano15

И опять нет. Любое переполнение при сдвиге является ub.

Почитайте стандарт, переполнение беззнаковых - не UB.

PPP328 ★★★★★
()
Ответ на: комментарий от aureliano15

А вот и нет. Не знаковые, а только отрицательные и только влево. Сдвиг отрицательных чисел вправо «зависит от реализации», что не совсем ub.

Вы не правы. Все сдвиговые действия со знаковыми числами UB потому что как вы сказали «зависят от реализации».UB включает в себя множество случаев с «зависят от реализации».

PPP328 ★★★★★
()

Для этого и существует неопределённое поведение: в одних системах оно работает так, а в других иначе. Но разработчики clang'а считают, что «неопределённое поведение» — это индульгенция на генерацию разного бреда вместо нормального кода.

Ты путаешь undefined behavior с implementation-defined behavior. Undefined behavior — это индульгенция на генерацию бреда. Implementation-defined behavior (например, результат каста указателя в целое число) должно быть определено в документации к компилятору.

Что же касается обращения к адресу 0 в реальном режиме, то по стандарту, ЕМНИП, pointer == 0 истинно если и только если pointer == NULL. Но стандарт, ЕМНИП, отнюдь не утверждает, что ((int*) sizeof(int)) - 1 == NULL в обязательном порядке.

proud_anon ★★★★★
()
Последнее исправление: proud_anon (всего исправлений: 1)
Ответ на: комментарий от aureliano15

Но оптимизировать, а не портить!

бесполезно. они не понимают.

проще уже форкнуть компилятор, или написать свой(ибо даже llvm уже давно стал необслуживаемой кучей говна), чем с этими дебилами спорить. у них принципиально иное понимание UB - у нас это «результат неопределен», у них «делаю что хочу». причем хотя бы уведомить что включился режим «делаю что хочу» они тоже не считают нужным.

ckotinko ☆☆☆
()
Ответ на: комментарий от ckotinko

Ну так не пишите говно. Если вы пишете с UB вы даете команду компилятору «делай что хочешь». Не хотите так - пишите как тут уже предложили на жабке, там нет UB.

PPP328 ★★★★★
()
Ответ на: комментарий от PPP328

Стандарт, ЕМНИП, даже не утверждает что NULL == 0.

Не, по-моему, вот это он как раз утверждает. См. в черновике пункт 6.3.2.3:

An integer constant expression with the value 0, or such an expression cast to type void *, is called a null pointer constant. If a null pointer constant is converted to a pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal to a pointer to any object or function.

proud_anon ★★★★★
()
Ответ на: комментарий от PPP328

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

соответственно, UB состоит только и только в том, что компилятор может генерить либо один либо второй тип сдвига. на практике же генерится сдвиг со знаком для int и без знака для uint.

ckotinko ☆☆☆
()
Ответ на: комментарий от PPP328

Вот не помню. Но, с другой стороны, компиляторы, приспособленные для реального режима, обычно и NULL разыменовывают вполне определённым образом.

proud_anon ★★★★★
()
Ответ на: комментарий от ckotinko

Про реализацию сдвига вправо претензий нет. Есть претензии на то что на другой архитектуре отрицательные числа необязательно представлены как дополнение до 2.

PPP328 ★★★★★
()
Ответ на: комментарий от PPP328

Иногда т. н. неопределённое поведение позволяет получить серьёзный выигрыш в производительности

Значит вы пишете говнокод. Приведите пример.

циклы. хотя там оптимизация копеечная и съедается тем фактом, что IPC все равно возле единицы танцует.

ckotinko ☆☆☆
()
Ответ на: комментарий от PPP328

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

таких архитектур нету со времен транзисторных эвм, которые целые залы занимали.

и не будет, потому что никто не будет городить два АЛУ вместо одного. 2-complement тупо дешевле в транзисторах.

ckotinko ☆☆☆
()
Последнее исправление: ckotinko (всего исправлений: 1)
Ответ на: комментарий от ckotinko

Вы хоть документацию читайте *хоть иногда*:

C99 defines three acceptable methods for negative value representation.

Sign Bit <..> One's complement <..> Two's complement <..>

PPP328 ★★★★★
()
Ответ на: комментарий от PPP328

в циклах for(int i = 0; i < n; i++) разворачивается в обратный счет от n до 0. это канонический пример, если что.

https://kristerw.blogspot.ru/2016/02/how-undefined-signed-overflow-enables.html

Loop analysis and optimization The canonical example of why undefined signed overflow helps loop optimizations is that loops like

for (int i = 0; i <= m; i++)

are guaranteed to terminate for undefined overflow. This helps architectures that have specific loop instructions, as they do in general not handle infinite loops.

But undefined signed overflow helps many more loop optimizations. All analysis such as determining number of iteration, transforming induction variables, and keeping track of memory accesses are using everything in the previous sections in order to do its work. In particular, the set of loops that can be vectorized are severely reduced when signed overflow is allowed.

этим в частности занимается плагин llvm «loop strength reduction»

ckotinko ☆☆☆
()
Ответ на: комментарий от PPP328

C99 defines three acceptable methods for negative value representation.

Sign Bit <..> One's complement <..> Two's complement <..>

мне похеру на документацию, которая ссылается на музейные раритеты. вы не сможете мне назвать ни одной современной машины(включая те, которые еще работают типа i386) которые не являются 2-complement. ни восьмибитных, ни каких.

ckotinko ☆☆☆
()
Ответ на: комментарий от ckotinko

В `for (int i = 0; i <= m; i++)` UB нет. Его принудительно создает компилятор в процессе своих вычислений. Компилятор это делать строго с тем расчетом что ему известна реализация знакового представления на данной платформе.

PPP328 ★★★★★
()

Мне кажется абсурдным, что компилятор молча прожевывает UB, а оптимизатор начинает молча творить дичь по мотивам. Все прочее несущественно.

kawaii_neko ★★★★
()
Ответ на: комментарий от PPP328

с тем расчетом что ему известна реализация знакового представления на данной платформе

Many early computers, including the CDC 6600, the LINC, the PDP-1, and the UNIVAC 1107, used ones' complement notation. Successors of the CDC 6600 continued to use ones' complement until the late 1980s, and the descendants of the UNIVAC 1107 (the UNIVAC 1100/2200 series) still do, but the majority of modern computers use two's complement.

давайте просто признаем, что мы тянем совместимость с PDP-11. не надо вот этих рассказов про то, что у нас тут очень сложная магия. просто мы тянем legacy, которое уже 20 лет как сдохло и на этом основании генерим говнокод.

и этот null-pointer он же не с потолка взялся, а из-за несовместимости pdp11 с interdata и vax. вот откуда появилось undefined behavior - это означало мы честно обращаемся в нулевой адрес, а там уже - в зависимости от процессора. где-то 0 всегда вернут, где-то память прочитают, где-то в регистр попадёшь.

потом эти процы испарились, но папуасы на островах продолжили свой карго-культ, и развили его в религию UB.

ckotinko ☆☆☆
()
Ответ на: комментарий от kawaii_neko

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

ckotinko ☆☆☆
()
Ответ на: комментарий от ckotinko

мне похеру на документацию

Вот потому вы и неуч, который кричит когда его пристыдили за UB вего коде.

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

«Назовите мне овощ красный! Но помидоры не называть!» А ничего, что си не ограничен i386 архитектурами?

The most recent example I can find is the UNISYS 2200 series, based on UNIVAC, with ones-complement arithmetic. The various models were produced between 1986 and 1997 but the OS was still in active development as late as 2015.

Even on 2 complement machine, you may have more variety than you think. Two examples: some don't have a sign preserving right shift, just a right shift which introduce zeros; a common feature in DSP is saturating arithmetic, there assigning an out of range value will clip it at the maximum, not just drop the high order bits.

Выкусил?

PPP328 ★★★★★
()
Ответ на: комментарий от aureliano15

написанной в соответствии со стандартом
0xFFFFFFFF00000000L

На 32 битах это не влезает в long, а на x86_64 эта константа больше, чем LONG_MAX. Так что с «соответствием стандартам» тут не очень хорошо.

if((c=a+b)<a) // неопределённое поведение.

А существует ли в действительности подобный мразотный оптимизатор, который вырезает такие проверки? Я-то как раз люблю подобными конструкциями от integer overflow защищаться.

kawaii_neko ★★★★
()
Ответ на: комментарий от PPP328

a common feature in DSP is saturating arithmetic

тут такое дело. я просто работаю с SSE, NEON, и даже с экзотикой типа K1986ВМ14. я даже свой проц потихоньку пилю, и этот вопрос вполне знаю на gate level. а ты тут такой модный мне рассказываешь про .... кстати а про что?

Как я понимаю, гики в 2015м пилят древнее ненужно, железо для которого скончалось аж в 1997м. Кстати я тебе заранее скажу что скончалось оно так поздно скорее всего только и только потому, что оно было в банках. А там вообще довольно долго могут за legacy цепляться, вон кобол до сих пор не сдох. Ну отлично, удачи гикам.

Что касается арифметики с переполнением. Ты точно уверен, что вот этот случай играет тебе на руку? просто в SSE2 есть команды paddsb, paddusb, paddsw, paddusw, и т.д. Они именно с saturation, о чем говорит буковка s. но они 2-complement. Собственно, там в АЛУ стоит проверка перед выходом, чему равен перенос и старший бит. из них выводится флаг переполнения - точно также как он выводится в x86 - там в eflags есть такой. если он = 1, то все биты заменяются на старший бит.

так вот. это именно что SSE-инструкции. в NEON есть такие же, но ты их без intrinsicов не вызовешь. а intrinsicи не оптимизируются.

зачем? там обычные числа, 2-compelement. кого ты хочешь обмануть.

ckotinko ☆☆☆
()
Ответ на: комментарий от PPP328

Потому что писать надо без UB.

Ага, а потом поменяли тип переменной с uint32_t на uint64_t, и ищи-свищи по всем исходникам подобные конструкции.

kawaii_neko ★★★★
()
Ответ на: комментарий от PPP328

Или пиши на java

Нет, спасибо.

//$ cat HelloWorld.java
import java.io.*;

public class HelloWorld {
  public static void main(String[] args) throws IOException {
    System.out.println("Hello world");
  }
}

Я лучше буду опираться на implementation-defined behaviour и требовать от упырей внятной диагностики по UB.

kawaii_neko ★★★★
()
Ответ на: комментарий от aureliano15

Но оптимизировать, а не портить!

А это вообще можно описать достаточно формально, чтобы по описанию можно было построить оптимизатор? Кому-то выбрасывание сравнения x и x+1, где x — число со знаком, кажется нормальным, а для кого-то это портит код.

Я не представляю, как можно было бы формализировать выражение «не портить». Вероятно, нужно создать новый документ, дополняющий стандарт на Си, в котором запретить неопределённое поведение вообще. И делать компиляторы уже на основе него.

Правда, это будет уже другой язык.

i-rinat ★★★★★
()
Ответ на: комментарий от i-rinat

формально, было бы достаточно если бы компилятор говорил: ой, здесь я включаю UB. разрабы llvm(а вовсе не шланга) врут, когда говорят что они не могут сказать где включили UB - llvm как раз копирует debug data вместе с llvm::Value, но сука не копирует другие ассоциированные данные. это то, на что я напоролся, пытаясь сделать из llvm компилятор для своего проца. он гад четко находит debug data (файл + номер строки) и только их копирует.

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

ckotinko ☆☆☆
()
float *P;
void zero_array() {
  int i;
  for (i = 0; i < 10000; ++i)
    P[i] = 0.0f;
}

int main() {
  P = (float*)&P;  // cast causes TBAA violation in zero_array.
  zero_array();
}

Этот код — полная дичь. Он будет вылетать и с оптимизациями, и без них, потому что <...>

Этот код не должен работать вообще. Это иллюстрация.

Оптимизатор предполагает, что P указывает только на массив float. А это значит, он не может указывать на то место, где хранится само значение P. Поэтому в zero_array можно предположить, что P не поменяется. Поэтому в машинном коде можно загрузить значение P в регистр, и не смотреть на каждой итерации в ту память, где лежит значение P. Можно даже цикл на memset заменить, ведь это уже эквивалентно.

Теперь допустим, что P может указывать на всё, что угодно, хоть на себя, например так: P = (float*)&P. Первая же запись в P[0] меняет P, поэтому его нужно перечитать из памяти. Но компилятор предполагает, что P не может указывать на P, потому что P это float *, а не float **, поэтому этого никогда не случится. Поэтому на второй итерации цикла P можно не читать из памяти, а взять предыдущее значение. И вот из-за этого поведение скопилированного кода расходится с ожиданием человека.

Если не делать предположений о том, что float ** не может стать float * и наоборот, цикл в zero_array нельзя оптимизировать. Ведь P может быть изменена где-то в другой единице компиляции. Компилятор не имеет возможность проанализировать всю программу, потому что у него нет доступа ко всей программе. Поэтому нужно выбирать между оптимизациями и соответствием ожиданиям программиста. Которые расходятся со стандартом и вообще у всех разные.

i-rinat ★★★★★
()
Ответ на: комментарий от ckotinko

формально, было бы достаточно если бы компилятор говорил: ой, здесь я включаю UB

Не понимаю, зачем. Выводить это в stdout всё?

i-rinat ★★★★★
()
Ответ на: комментарий от timdorohin

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

Iron_Bug ★★★★★
()
Ответ на: комментарий от Iron_Bug

Это безусловно правда. Но мне бы как пользователю компилятора хотелось, чтобы компилятор кидал варнинги на все UB в коде. Отчасти на это сейчас есть undefined sanitizer.

zamazan4ik ★★
()
Ответ на: комментарий от zamazan4ik

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

Iron_Bug ★★★★★
()
Ответ на: комментарий от i-rinat

в stderr если быть точным. если совсем быть точным, то в llvm есть класс чтоб срать в логи. компилятор в лице своих плагинов именно что может сказать: здесь я заdiscardил одно из возможных значений потому что посчитал его невозможным изза UB. он должен это сделать.

еще раз: все эти UB растут из 80х годов, когда они были именно UB изза того, что на PDP11 в нулевых адресах лежали регистры, причем по нулю лежал регистр где всегда 0, на VAX и interdata там была память, а где-то еще там управляющие регистры лежали и записав туда можно было получить наказалити.

ckotinko ☆☆☆
()
Ответ на: комментарий от zamazan4ik

Отчасти на это сейчас есть undefined sanitizer

И это роспись в том что статично задача сейчас не решается.

anonymous
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.