Pull to refresh

Comments 51

Интересный подход. А как это так компилятор int (который, емнимс, на винде = 32бита) легко положил в 64битный регистр и сравнивает напрямую не ругаясь?!
Это обычные оптимизации, или мегажесть?
Это неопределённое поведение, возникшее из-за переполнения signed integer. Проявляется в Release. Собственно, про это статья и написана :)
gcc -O2 a.cpp
a.cpp: In function ‘int main()’:
a.cpp:16:18: warning: iteration 2147483647ul invokes undefined behavior [-Waggressive-loop-optimizations]
array[index++] = char(i) | 1;
^
a.cpp:15:3: note: containing loop
for (size_t i = 0; i != Count; i++)
^

При этом результат тот же в релизе с -O2
.L3:
movl %edx, %ecx # i, tmp90
orl $1, %ecx #, tmp90
movb %cl, (%rbx,%rdx) # tmp90, MEM[base: array_7, index: i_25, offset: 0B]
addq $1, %rdx #, i
cmpq %rax, %rdx # tmp95, i
jne .L3 #,
movabsq $5368709119, %rax #, tmp92
cmpb $0, (%rbx,%rax) #, MEM[(char *)array_7 + 5368709119B]

Без оптимизаций или с O1 — падает с segmentation fault, то есть честно 32бита выдерживает.

Выходит, релиз в VCC включает аггрессивные оптимизации, но совершенно молча.
Компилятор считает, что неопределенного поведения в программе нет. Точка.

В случае int и инкремента операция выполняется из предположения, что переполнения регистра не произойдет (иначе было бы неопределенное поведение, которого быть не должно).

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

P.S.: Если есть время, могу посоветовать послушать мой доклад на конференции C++ Siberia, где затрагивались в том числе и эти вопросы с позиции разработчика компилятора.

А что тогда «warning: iteration 2147483647ul invokes undefined behavior» означает?

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

Предворяю вопрос типа: «ну ёлки палки! варнинг он дать додумался, а правильно код скомпилировать не может! как так?». Это несколько разные вещи: дать варнинг и написать код в соответствии со стандартом.

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

Разработчики стоят перед выбором: писать условно-безопасный код или писать быстрый. Исторически, языки семейства Си идут по пути скорости.
Вот кстати нет. GCC совершенно честно и правильно поступает с -O1: ворнинга нет, переменная signed 32 bit, идёт переполнение.
С -O2 сообщает об UB, и транслирует как посчитал нужным, на что имеет право.

У меня жалоба на VC — она не пожаловалась, но оттранслировала себе на уме. В том числе, следует помнить, что «int» это «не меньше 32 бит» а не «ровно 32 бита» — поэтому решение вполне корректное, но UB.
…она не пожаловалась, но оттранслировала себе на уме
Повторюсь на всякий случай: это совершенно не означает, что VC такая бяка и не сказала о проблеме. Компилятор мог быть совершенно не в курсе.

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

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

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

Советую почитать вот эти статьи из блога LLVM:



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

Верно. Почему разместив 32 битную переменную в младшей части, код производит вычисления с 64 битной переменной, невзирая на мусор в старшей части?

000000013F6D102D  xor         ecx,ecx  
000000013F6D1036  mov         byte ptr [rcx+rbx],dl 

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

Простейший пример: знаковое переполнение считается неопределенным поведением. Поэтому код вида…

for (int i = 0; i < array.size(); i++) { do_smth(array[i]); }

…уже́ провоцирует неопределенное поведение, поскольку мы инкрементируем знаковую переменную. Компилятор считает, что в этом случае переполнения не произойдет. А поэтому имеет полное право закодировать инкремент как (на примере LLVM IR)

%i.next = i32 add nsw nuw %i.cur, 1

Спецификаторы nsw и nuw буквально означают «no signed wrap» и «no unsigned wrap».

В этом случае для хранения переменной индукции компилятор имеет полное право использовать 64 битный регистр и инкрементировать его. Разумеется, 32 битный регистр переполнится быстрее, чем 64 битный и наблюдаемое программистом поведение будет отличаться (разное количество итераций). Но отличие будет только в случае наличия переполнения, то есть при UB.

При отсутствии UB, наблюдаемое поведение будет идентично, как и положено.
Учите же, блин, матчасть! После первой инструкции старшая часть ecx — нулевая!

Для упрощения процессора в x86-64 нет 32-битных регистров. В принципе. Совсем. Вот 8-битные есть, 16-битные есть, даже 64-битные есть. А 32-битных — нет.

Есть 32-битные операции. В старшую половину соотвествующего регистра они пишут нуль.
Т.е. xor rcx, rcx и xor ecx, ecx делают одно и то же?
Не знал, спасибо за разъяснение.
Да, с точки зрения практического результата "xor rcx, rcx" и "xor ecx, ecx" ничем не отличаются. Но "xor ecx, ecx" на один байт короче :-)
А вот это уже неожиданно. Обычно же "родные" команды короче, а под чужую разрядность — с префиксом. Не приходилось писать на асм под x86-64, но под x86/IA32 писал очень много. Пришла пора подтянуться и попрактиковаться. Вот так один дилетантский вопрос может сподвигнуть к практике.
В случае с x86-64 "родные" команды — как раз 32-битные. Собственно потому Intel использовал одно время название IA-32e. Никто этой "гениальной идеи" не понял, так что навание не прижилось, но в каком-то смысле оно было ближе к истине.

Как я уже сказал 32-битных регистров архитектура не предусматривает вообще, но большинство инструкций — по умолчанию 32-битные. Исключения — jcc/jmp/call и push/pop. А вот при обращении к памяти — по умолчанию используется полный регистр.

P.S. У RISC'ов все инструкции имеют одинаковую длину, но при записи в "32-битный" регистр — старшая часть обнуляется. Собственно всё просто: когда расширяли 80286й до 80386го, то проще было сделать так, чтобы запись шла только в часть регистра. Банально меньше связей между частями процессора нужно. А потом — появилось переименование регистров. И вот тут выяснилось, что если писать не во весь регистр, а в его часть — то соотвествующему модулю работать сложнее и программы работают медленнее. Насколько я знаю все (или почти все) 64-битные архитектуры устроены так, что у них нет отдельно 32-битных и 64-битных регистров. Точно знаю что ARM так делает, MIPS и POWER.

P.P.S. По настоящему крышесносительное решение Intel принял, когда создавал AVX. Там регистры со 128-бит расширили до 256 бит. Но при этом SSE-интсрукции старшую половину не обнуляют! Вместо этого для всех инструкций завели AVX-"дубликата", который это делает. А также для большинства инструкций, конечно, есть и версия, которая работает со всем регистром. В документации категорически не рекомендуется смешивать SSE и AVX инструкции и есть специальная инструкция обнуляющая старшие половинки всех регистров. Вот объяснения — чего и сколько нужно выкурить, чтобы подобную архитектуру изобразить я не знаю до сих пор. Я бы ещё мог себе представить что произошло, если бы одна ревизия добавила "широкие" инструкции, а другая — "узкие с обнулением" (ну сглупили/не додумали/etc), но нет — это всё в одной ревизии добавилось...
Это вам повезло просто. GCC 4.8.4 ничего не выдаёт, программа не падает.

Я уже писал: неопределённое поведение — оно вообще инструкцией для программистов, а не для разработчиков компилятора, является.

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

Пожаловаться каждый раз, когда компилятор полагается на то, что в программе нет UB — раз плюнуть, но вы только представьте что будет, если каждый раз, когда компилятор какое-нибудь if (a + 3 > b + 2) превращает в (a + 1 > b) он будет жаловаться. Вы же с ума сойдёте!
Это очень простая оптимизация. И выглядит она следующим образом:
int index = 0; // лежит в INT_MIN..INT_MAX
for(size_t iter = 0; iter != FAR_AWAY_FROM_INT_MAX; ++iter)
  arr[index++] = char(iter); // мы ведь не добежим до INT_MAX+1, мамой клянёмся

то есть, сделаем не более INT_MAX итераций, то есть, условие цикла всегда истинно, то есть, можем крутить цикл вечно!

Кроме того, инвариант цикла — index == iter. Поэтому мы можем свободно выбирать между arr[index] и arr[iter] — поэтому гусь стреляет в минус-бесконечность, а вася бежит до плюс-бесконечности.

Похожий пример, как задрючить гуся: безо всякой стрельбы по памяти.
ideone.com/f07SyA
#include <iostream>
using namespace std;
 
int main() {
	int const F = 1000000000;
	int const N = (~0U >> 1) / F;
	int x = 0;
	cout << "N = " << N << endl; // N=2.
	for(int y = 0; y < N + 5; ++y) // 7 итераций, думаете вы...
	{
		cout << "x = " << x << " : y = " << y << endl;
		x += F; // ненене, мы клянёмся сделать только 2 итерации по-честному...
	}
}

Мы получим бесконечный цикл. А если как-то ещё пошевелить программу, то получим 2 итерации вместо 7. (Сейчас не помню, как это сделать, а экспериментировать лень).

Безо всякой 64-битности, заметьте!
> int const N = (~0U >> 1)
Сдвиг вправо отрицательного числа — UB.
А где это у меня отрицательное число? Буковка U там зачем стоит, как думаете?
совсем плохо с головой, угу.

мозг мне взорвали, спасибо. O_o
Я, может, немного затупляю, но почему с unsigned нет undefined behavior?

* Edit: а, тупо в спецификации так написано? Ок… А почему такая разница между signed/unsigned?
Потому что переполнение unsigned значений разрешено в стандарте. Оно все равно будет некорректным с точки зрения программиста, но определенным с точки зрения компилятора.
Ну да, я понял, что разрешено. А почему такое различие между казалось бы схожими типами? Чем это обусловлено?
Реализаций signed integer существует много, и у каждой своё поведение при переполнении, а стандарт Си не мог быть привязан только к одной из них.
Я смотрел этот документ и даже сделал себе пару пометок, о том, что можно добавить в анализатор.

Но в целом, ответ нет. Обоснование:

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

2. Там описано много плохих паттернов. Но далеко не каждый плохой паттерн — это ошибка. Нам не нравится выдавать просто рекомендации. От этого анализатор быстро портится. Посмотрит человек первые 20 предупреждений, а там что-то в духе: класс плохо назван, локальных переменных много и т.п. Скажет — ага, понятно. И удалит анализатор. Хотя среди всего этого мусора были полезные предупреждения. Поэтому мы ориентируемся именно на поиск ошибок, а не на выдачу рекомендаций по улучшению.
Ну можно какую-нибудь галочку типа «выдавать помимо ошибок ещё и рекомендации» :-)
Человек при знакомстве не глядя включит все галочки, а потом будет «а, понятно…».
Вел один проект, который собирался с полным выводов ворнингов в GCC: pedantic, wall, weffc++… Там во многих местах компилятор ругался на ерунду. Я просто дефайнами отключил ворнинги в нужных участках кода. Почему не поступать как-нибудь в этом роде?
Отключить то не проблема. И, кстати, в PVS-Studio есть масса механизмов для этого. Можно писать комментарии в специальных местах, можно использовать глобальные комментарии для макросов и иных повторяющихся конструкций, есть специальный #ifdef, есть база разметки неинтересных сообщений (для быстрого внедрения анализатора), и так далее.

Но всё это не решает проблему знакомства с инструментом. А он крайне важна. Мы знаем это и на своем опыте знаем, и у Coverity в статье читали. Если потенциальный пользователь в первых 10 сообщениях не увидит настоящую ошибку, то с большой вероятностью он не будет использовать инструмент. Но даже если он продолжит, дело плохо. У человека снижается внимательность. Если 15-ое сообщение укажет на ошибку, он с большой вероятностью посчитает его ложным.

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

С мелкими понятно — их проверяю целиком, и веду список «реальные» и «нереальные», раскладывая по разным папкам.
Всё, что в «нереальных» — просто исключаю фильтрами на будущее.
Всё, что нашлось в реальных — правлю, примерно прикидывая % ложных, чтобы оценить когда надо править код, а когда подавлять.

Например, на PVS у меня получилось:
DISABLE_CHECKS=«V122|V813|V128|V690|V112|V616»

Может оно и полезно, но не в этой жизни.

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

… вот только в итоге реально было 2 фикса, и мне не удалось найти хорошего примера в истории изменений, где бы анализатор смог найти что-то, что не было бы поймано на первом же ревью и/или тестами.
Этот код корректно работает, если собрать 32-битную версию программы.

Этот код точно такой же некорректный и для 32-битной версии.
Надо всегда помнить, что int не может быть короче short int'а (по стандарту C89), но легко может быть равен ему.
Поэтому перебор миллиарда символов там тоже может привести к переполнению на ЛЮБОМ компиляторе.
Так много человек незамечают, что пример в самом начале статьи использовать не совсем верно… И так мало человек отвечают правильно на вопрос когда его кто-то задает на собеседованиях, в том числе сами «собеседователи»…
Дело в том, что это выражение ( i = i++ + ++i) всегда определЁнно, хотя порядок вычисления операндов сложения не определён :)
например, пусть в i у нас 1, сначала вычисляется левый операнд:
1 + 3 = 4
сначала вычисляется правый операнд:
2 + 2 = 4
и тд для всех целых
Не вводите людей в заблуждение и разберитесь для начала сами.
Для того, чтобы понять где и почему вы неправы нужно немного знать про то как устроен не только C, но и ассемблер. Вот сколько у вас тут операций, по вашему происходит? Три? Как бы не так: восемь (а может и больше: скомпилируйте программу с -O0 — сами увидите)!

i++
A1. Прочитать значение i из памяти в регистр α.
A2. Увеличить значение регистра α.
A3. Положить значение в память из регистра α.

++i:
B1. Прочитать значение i.
B2. Увеличить значение регистра β.
B3. Записать значение регистра β в память.

i++ + ++i:
C1. Сложить значение регистра α после шага A1, но до шага A2 со значением регистра β после шага B2, положив значение в регистр γ.
C2. Записать значение γ в память.

Никто не мешает компилятору, скажем, взять и выполнить операции в такой последовательности:
A1, B1, B2, C1, С2, B3, A2, A3.
В результате i будет равно 2.

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

Соотвественно в переносимой программе их быть не должно и компилятор имеет право на это опираться.
Нет. Ваше утверждение эквивалетно, что неопределено уже a=a+a. А это не так. В данном примере неопределенно только выполнение поярдка вычисления левого и правого операнда для операции сложения. Приравнивание уже вполне определенно будет принимать значение операции сложения. Именно поэтому хотя порядок неопределен, значение определено.
Нет, не эквивалентно, так как есть Sequence point перед присвоением результата.
В случае же с пре/пост инкрементами внутри вычисления их нет.
В данном примере неопределенно только выполнение поярдка вычисления левого и правого операнда для операции сложения.
В данном примере не определено в какой последовательности произойдёт отставка трёх операций. Реализация, которая «выносит» все пост-и-прединкременты из выражений (и выполняет все принкременты до «основного» выражения, а все постинкременты — после) — абсолютно законна.

Почитайте хотя бы википедию.
Можете не метать бисер перед свиньями. Не оценят.

Мне уже тут этот товарищ объяснил в личке, что он из вселенной, где подобное выражение имеет вполне определённое значение, ну а если в нашей вселейнной с этим несогласны авторы стандартов C и C++, компиляторов gcc и clang, редакторы Википедии и прочие — ну дык это значит, что нужно поправить статью в Википедии, исправить стандарт и компиляторы. Делов-то.

P.S. И ведь если я правильно понял автор этого комментария не только пишет на C, но и интервьюирует людей, которые потом будут писать на C! И ведь всё это потом кто-то использует… возможно даже я… как страшно жить.
Может он явист в душе? Вот там всё чётко по стандарту и такое выражение имеет точное значение всегда. Но писать такое в коде — не уважать никого.
Умеет ли PVS ловить такие ситуации, связанные с допущениями компилятора о невыходе за границу диапазона?
Ведь UB легко отловить даже вот так
ideone.com/5M0MeA
#include <iostream>
using namespace std;

int main() {
	int const F = 1000000000;

	int x;
	for(int i=0; i<5; ++i) {
		x = i*F;
	}
	cout << "OK: " << x << endl; // -294967296 = F*4, што?!

	x = 0;
	for(int i=0; i<5; ++i) {
		x += F;
	}
	cout << "OK: " << x << endl; // 705032704 = F*5, што?!!!

	for(int i=0; i<5; ++i) {
		cout << (i*F) << endl; // печатаем... печатаем...
	}
	cout << "OK: " << x << endl; // nevermore!
}
Такое — нет. Неблагодарное занятие.
А компиляторы, заррразы, отлавливают! Только трактуют в свою пользу :)))
Да, знаменитый bug 33498, который превращает функцию с простым переполнением в «убийцу».

Хорошо, что есть -fwrapv и плохо, что -ftrapv работает через пень-колоду…
Кстати, начиная с версии 4.8 GCC выдаёт таки предупреждение

8 : warning: iteration 31u invokes undefined behavior [-Waggressive-loop-optimizations]

Clang генерирует "честный" код, но не выдаёт никаких предупреждений. (Но начиная с версии 3.7.0 разворачивает цикл полностью).
Самая интересная на этот счет ошибка которая мне попалась бага с OS X sysctl и HW_USERMEM параметром.
Суть бага в том что наше приложение проверяло на старте доступное кол-во памяти с помощью вот этого.
Но у нас кроссплатформенное приложение и потом для мака разработка велась не всегда и использовались мак мини в котороых памяти было не очень много. Но тут появился мак бук про с 16 гигами и начались не понятные невозможности стартануть приложение которое просто падало на старте, ассертов в том месте не было, просто закрывалось приложение.
То есть приложение не запускается, ребут — уже запускается, через время опять не хочет, потом опять нормально.
Это какой-то ужас был. Но все-таки копнул поглубже и что выяснилось:
HW_USERMEM у OS X возвращает всегда 4 байтный инт, варианта с 8 — нет, например как у FreeBSD и в итоге, при вызове sysctl с HW_USERMEM
возвращался переполненный инт, иногда отрицательный, иногда нет.
В общем, починил выкинув данную проверку, так как на реальных тачках для нашего приложения она не очень имела смысл.
Sign up to leave a comment.