Как стать автором
Обновить

Комментарии 71

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

Но, нет худа без добра! Все это рождает философию, как подход к жизни. Кто-то сказал, однажды: «Если про какой-то баг знают все, и знают, как его обходить, то это уже не баг, это фича!» :-). Видимо, эта часть философии прижилась и въелась…

Естественно, в этом вся суть. Зачем настолько усложнять нечто столь простое, особенно, если это отлично вписывается в формулировку «могу проверить это постфактум на аппаратном уровне»?

Не такое уж это и "простое" дело, особенно если имеешь дело с DSP, где есть разные режимы арифметики, и соответственно разные флаги для случаев "переполнения" и "насыщения", и какой (или, скорее, какие) из них проверять - зависит от конкретного случая, да и в любом случае подобные проверки - штука не переносимая, логика проверок может отличаться в зависимости от конкретной модели DSP. В общем, статья производит впечатление какой-то попытки кавалерийского наскока с шашкой в стиле "да на самом деле все просто", хотя, если чуть отойти от x86 и вообще от процессоров общего назначения, то все не так уж и просто. В частности, те же "наивные" проверки на знаковое переполнение в стиле "сначала сложим/умножим, а потом проверим на смену знака", право на существование которых почему-то отстаивают некоторые люди из числа "недовольных непонятными усложнениями элементарной арифметики", в том же saturation mode просто не будут работать.

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

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

Компилятор оптимизирует код исходя из предположения, что UB в коде не происходит.

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

Ответственность за недопущение UB компилятор перекладывает на программиста.

Это у вас такая логика, это неправильная "буквальная" интерпретация UB, не учитывая того для чего UB в стандарте. Если программист поставил проверку, значит она для чего-то есть? Но компилятор пользуясь неопределённостями в стандарте интерпретирует так, чтобы её удалить. Из-за этого многие крупные и серьёзные проекты собираются с -fwrapv, что отключает эти спекуляции.

"Формально правильно, а по сути издевательство" (c) самизнаетекто

-O0

Буквально то, что вы написали выйдет. А -fwrapv на весь проект это какой-то бардак в головах. Я честно говоря редко сталкиваюсь с проблемой переполнения если сам её себе не придумал (используя всякие uint8_t). А мазать проверки везде это больно по перфу, но с таким отношением, вам в Java всякие (там кстати тоже РУКАМИ оверфлоу проверяют)

Да и лишняя ветка там, сям, вот уже кешик хорошо работает, спекулятивное выполнение ускоряется, пушка же. Процессоры с Pentium 4 стали ощутимо быстрей при том, что гоняли на 3.2 ГГц в 2003. Это же не просто про скорость тактов.

А вы при сборке отключаете оптимизацию dead-code elimination?

Подумалось вдруг: а ведь обычно всерьёз оптимизировать имеет смысл хорошо если 1% кода. Так почему бы места, где это можно делать (используя предположение, что программист не допускает UB), не помечать явно? Ну как уже сделано для проваливания в следующий кейс внутри switch.

Оптимизировать имеет смысл 100% кода. Всегда.

Ну а сама по себе идея "помечать" такие места ломает все возможные оптимизации, объяснять долго и бессмысленно

На тот момент (до обвала Twitter и, соответственно, блокировки аккаунта)
он успел пометить этот код хештегом #vulnerability и заявил, что из-за
вмешательства GCC код получается более опасным.

Забавно, я в 2020-м писал примерно то же, и даже заводил баг на GCC. Где меня просто послали, сославшись на UB. UB ведь это такое хорошее оправдание чтобы писать небезопасный компилятор. При том что Clang такое не делал (и до сих пор компилирует мой пример правильно), Clang не нужно указание "-fwrapv" чтобы не генерировать небезопасный код.

UB ведь это такое хорошее оправдание чтобы писать небезопасный компилятор.

Это неправда.

Разработчики GCC и Clang по сути делают то, что должен был с самого начала делать исключительно комитет по стандартизации, а именно — справочный образец (reference implementation) компилятора Си, который одновременно с этим являлся бы непосредственной формализацией семантики языка программирования. Потому что изложить её обычным человеческим языком на практике невозможно — документ такого объёма нельзя ни нормально (то есть однозначно) написать, ни нормально прочитать.

Давно показано, что из любого текста, в общем‑то, можно извлекать фактически бесконечное число разных смыслов, причём многие из них будут друг с другом вполне сопоставимы по своей логической состоятельности. Привлечение же специальной терминологии ситуацию не только не улучшает, но даже наоборот — любой сложный термин является лишь некоторым обобщением (абстракцией) над более простыми, чему неизбежно сопутствуют и невязки в его понимании. Собственно, именно эта проблема в своё время уже привела в философии к так называемому лингвистическому повороту — Витгенштейн, Коржибский и прочие ребята (как же я люблю аналитическую философию, вот они слева‑направо). К слову, здесь на Хабре вот совсем недавно были весьма занятные статьи на эту же тему: раз, два.

Поэтому винить разработчиков компиляторов не в чем — винить надо комитет. Не стреляйте в программиста — он играет, как сказали. Не говоря уже о том, что и в GCC, и в Clang издавна наворочено множество ключей и флагов компиляции буквально затем, чтобы люди могли при необходимости устранять идиотию стандартизаторов или хотя бы обходить её стороной.

А вот C++ уже едва ли что-то поможет, тут да, всё так.

должен был с самого начала делать исключительно комитет по стандартизации, а именно — справочный образец (reference implementation) компилятора Си, который одновременно с этим являлся бы непосредственной формализацией семантики языка программирования

Глупости. Так называемый справочный образец не будет существовать в вакууме, он будет разработан под конкретную архитектуру. Предположим (в контексте статьи) что такой образец был изначально разработан комитетом под архитектуру, где signed overflow приводит к wrap around. Должно ли это поведение всеми силами эмулироваться и на архитектурах, где вместо wrap around происходит saturation, даже если это будет неэффективно и в конечном счёте вредно? Очевидно, нет. То есть по-прежнему где-то должно быть описано, какая часть поведения "справочного образца" значима с точки зрения её соблюдения в остальных реализациях, а на какую часть поведения рассчитывать нельзя. То же самое UB, только в профиль.

Референсный компилятор вполне может компилить в байт-код по типу "ассемблера MIX" Кнута или p-code Вирта, на момент создания С оба уже были, а синтаксис Си в отличие от тех же плюсов разбирается любым стандартным (т.е. многократно проверенным) лексером/парсером. Дальше к этому можно прикрутить несложный неопитимизирующий бэкенд под конкретную архитектуру [процессора] и ОС. И уже этого франкентшейна можно использовать для тестирования корректности оптимизирующего компилятора.

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

Референсный компилятор вполне может компилить в байт-код по типу "ассемблера MIX" Кнута или p-code Вирта, на момент создания С оба уже были, а синтаксис Си в отличие от тех же плюсов разбирается любым стандартным (т.е. многократно проверенным) лексером/парсером.

О, спасибо большое — Вы у меня этот комментарий с языка сняли. :)

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

Если речь идёт о стандарте — то да, должно. Но тут надо определиться. Либо мы хотим стандартизировать язык программирования, и тогда документ надо писать по образцу A Commentary on the UNIX Operating System (то есть в виде неформальных поясняющих записок к формальному строгому описанию). Либо же мы идём по стопам условного Pascal, где за всю историю накопилось множество едва совместимых друг с другом диалектов (и да, я знаю, что для него тоже стандарт сделали, но на него всем было наплевать ещё тогда). Тоже вполне себе вариант, со своими плюсами. К слову — это весьма вероятное будущее Rust в свете появления gcc‑rs.

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

UB в таком случае начинает возникать естественно, в ходе дела, и перестаёт описываться произвольным образом. А не как сейчас в стандартах Си и C++: хачю штобы харошые праграмы на харошых мафынах роботале, а нихарошые на нихарошых нироботале, вотЪ! (и ладонью по столу).

То есть вы предлагаете эмулировать референсное поведение даже там, где это не нужно/не эффективно, я правильно понял? Ну есть уже такие языки, та же Java, с соответствующими плюсами и минусами и соответствующими областями применения.

Либо мы хотим стандартизировать язык программирования, и тогда документ надо писать по образцу A Commentary on the UNIX Operating System (то есть в виде неформальных поясняющих записок к формальному строгому описанию). Либо же мы идём по стопам условного Pascal, у которого за всю историю накопилось множество несовместимых друг с другом реализаций (и да, я знаю, что для него тоже стандарт сделали, но на него всем было наплевать ещё тогда).

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

UB в таком случае начинает возникать естественно, сам по себе

Что значит "естественно, сам по себе"? UB - это когда никакое конкретное поведение не гарантируется стандартом, всего-навсего.

А не как сейчас в стандартах Си и C++: хачю штобы харошые праграмы на харошых мафынах лаботали, а нихарошые на нихарошых нилаботали, вотЪ! (и ладонью по столу).

Это тоже глупость, ничего подобного в стандартах C/C++ нет. Нет никаких "хороших" и "нехороших" машин, есть определенное/гарантированное поведение, и есть не определенное. То, что некорректно написанная программа, эксплуатирующая неопределенное поведение, иногда работает - не более чем случайность. Например, одна архитектура может "простить" обращение по невыровненному адресу, и все-таки загрузить значение в регистр, хоть и за бОльшее количество тактов, а другая этого не прощает. Это не значит, что первая мафына "харошая", а вторая "нихарошая". Это в любом случае UB, потому что по стандарту лайфтайм объекта начинается только с момента когда "storage with the proper alignment and size for type T is obtained" (emphasis mine). Что произойдет, если ты обратился к объекту, у которого лайфтайм по определению не начался потому, что требования к выравниванию не соблюдены, является undefined behavior. Может, сработает, а может, и нет, никаких гарантий на тему того, что именно произойдет, не дается. Потому что давать гарантии следует осторожно - как только ты их даешь, то ты отнимаешь свободу у разработчика компилятора под конкретную архитектуру, и он вынужден предоставлять эти гарантии даже если это означает дикую неэффективность. Как другой пример на данную тему в рамках данной статьи - заставлять компилятор на архитектурах с "насыщением" в случае знакового переполнения эмулировать "закольцовку" значения в стиле INT_MAX + 1 == INT_MIN, на них это будет дико неэффективно и по большому счету бессмысленно.

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

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

Чтение невыровненных данных тоже предсказуемо, зависит от того, поддерживает ли это процессор или нет. x86 всегда поддерживал, старые ARM < v6 не поддерживают. Более того, есть архитектуры где невыровненные данные работают быстро, и для таких архитектур намеренно (под "#if defined") написан код что читает/записывает зная что указатель может быть невыровненным.

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

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

То есть вы предлагаете эмулировать референсное поведение даже там, где это не нужно/не эффективно, я правильно понял?

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

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

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

Что значит "естественно, сам по себе"? UB - это когда никакое конкретное поведение не гарантируется стандартом, всего-навсего.

Это означает, что в reference implementation места, требующие явной поддержки со стороны исполнительного устройства, становятся видны сразу, и по ним уже можно ориентироваться, что стандартизировать, а что оставить на волю implementation-defined / unspecified / undefined поведения. А не как сейчас, когда, скажем, в C23 realloc(ptr, 0) вдруг превратился в самый настоящий UB, а malloc(0) как был, так и остался implementation-defined, и всё это произошло по щучьему велению.

Вам же объяснили, что вы написали некорректный код. Вы считаете, что компилятор не имеет права удалять не имеющие для него смысла проверки. Компилятор считает, что если нет проверки на overflow специально, то проверять на оверфлоу не нужно.

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

Что-бы что? И да, не на всех платформах где собирается С, INT_MAX() + 1 это INT_MIN(), стандартизовать это = привести эмбедеров в очередной зоопарк своих компиляторов и своих ассемблерных вставок.

Когда-то видел крутой пример, сильно более обоснованный, где удалило проверку на overflow в функции hash и она стала отдавать негативные значения потому-что чек:

if(value < 0) val *= -1;, не имел смысл для ситуации когда число только увеличивается.

Фикс был кстати val &= 0xEF'FF'FF'FF.

Что вообще другое и делает по другому)

А еще поломает работу многим людям, кто завязывался на это поведение

Кому поломает, тем кто ищет уязвимости?

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

Вообще модно это называется Dead-code-elimination, а всякие истории про удаление проверок на отрицательное это просто расширение этого понятия.

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

В случае, если ты не ССЗБ, то на жизнь никак не сказывается.

int32_t i = x * 0x1ff / 0xffff

Ну для меня очевидно, что в результате этой операции i станет меньше x, но больше 0. Поэтому вопрос скорее в том, умножать ли сначала или делить константы. Учитывая что i всё равно потом будет приводиться к int32_t, то потери в приведении неизбежны и вопрос только в точности. Будь я бы компилятором, то переделал бы код в

int32_t i = x * 0.0077973601892119

и выкинул бы проверку

i >= 0

PS: я, кстати, часто не отдаю на откуп компиляторов подсчёт констант и делаю всё сам заранее, но у меня и не программы, а так, развлечение.

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

Я в таких случаях делаю

int32_t i = muldiv(x, 0x1ff, 0xffff)

где muldiv внутри приводит к int64_t, потом умножает, потом делит.

Минутка занудства: muldiv не "вначале приводит к int64_t", а просто умножает, получая результат двойной длины (на x86, собственно, инструкция умножения так и устроена).

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

А, простите, не пришло в голову, что он самописный, а не библиотечный.

ЗЫ: емнип компилятор и ваш код с приведением типа должен соптимизировать в то же самое.

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

Я сейчас больше на другом языке пишу, там есть типы с фиксированной точкой. Так вот если результат, например, типа 15,0 (15 знаков, 0 после запятой), то все потенциально опасные вычисления сначала присваиваются в промежуточную переменную максимального для данного типа размера (63,0, там, кстати, явно декларируется, что все промежуточные результаты вне зависимости от размера операндов, всегда имеют тип максимального размера). Потом размер результат проверяется на не выход за границы и только потом уже присваивается выходной переменной нужного типа. Т.е. примерно так:

dcl-s minVal packed(15: 0) inz(*loval); // минимальная граница
dcl-s maxVal packed(15: 0) inz(*hival); // максимальная граница
dcl-s resVal packed(15: 0);             // результат
dcl-s intVal packed(63: 0);             // промежуточный результат

intVal = ... // вычисления

select;
  when intVal in %range(minVal: maxVal); // безопасно
    resVal = intVal; // переполнения не будет

  when intVal < minVal; // переполнение "вниз"
    resVal = minVal;    // тут же еще выствить ошибку

  wnen intVal > maxVal; // переполнение "вверх"
    resVal = maxVal;    // тут же еще выставить ошибку

endsl;

*loval/*hival очень удобные штуки - для компилятора это значит "минимальное/максимальное для данного типа значение. В данном случае это -9999...999 (15 9-к) и +9999...999 (15 9-к) соответственно.

Но в общем и целом тут UB практически нет.

Что-то банковское? (десятичные типы данных не часто встретишь)

Да. Основная логика на центральных серверах.

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

Суть в том, что если вы объявили packed(3:0) (три цифры) а потом пытаетесь в него запихать 99999 - получите системное исключение по переполнению. И нормальным является проверять прежде чем что-то куда-то присваивать. Об этом уже даже не думаешь специально, оно само так пишется.

У вас прямо скажем совсем не мейнстрим, а нечто совсем противоположное.

Сначала присказка. Когда архитектура х86 переходила на 64 бита, из набора инструкций было выкинуто буквально пару штук совсем уж редких и никому ненужных. Про LAHF/SAHF вы, возможно, даже слышали — эти 8-битные инструкции сохраняли/загружали младший байт регистра флагов в аккумулятор (мнемоника от Load AH, F), вот только в х86 регистр флагов уже был 16-битным. Соответственно, эти инструкции смысла не имели, а остались только для совместимости с intel 8080/85 (не двоичной, а текстовой). Так вот, эти две инструкции потом пришлось воскрешать из-за вмвари, которая их таки зачем-то использовала.

Теперь сказка: ещё одной выброшенной инструкцией оказалась INTO. Эта однобайтовая инструкция проверяла флаг целочисленного переполнения и, в случае оного, выбрасывала, ну, исключение переполнения (INT 3). Т.е. делала ровно то, что вы описываете, и всё этой одной однобайтовой инструкцией.

Но оказалось, что это совершенно, абсолютно никому не нужно.

У вас прямо скажем совсем не мейнстрим, а нечто совсем противоположное.

Естественно. Причем, очень даже специфическое противоположное.

Но оказалось, что это совершенно, абсолютно никому не нужно.

Вопрос спорный. Типичная для нас ситуация. Обрабатываем 50 000 000 блоков данных. В фоновом режиме (т.е. оно там где-то само что-то делает тихонько в batch job). И в одном из блоков возникло переполнение.

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

Но. Мы также не можем остановить обработку 50 000 000 блоков просто потому что в одном из них "что-то пошло не так".

Посему нормальным является всегда возвращать результат и статус операции. у 49 999 999 статусы будут "ок, результат корректный". У одного - "не ок, было переполнение результат некорректный". Но обработаются все 50 000 000. Тот, где "не ок" будет занесен в лог с максимальной детализацией и затем пойдет на ручной разбор - где, что и почему случилось. Делается это через ручной контроль, или делается это через перехват исключений путем monitor ... on-error ... endmon (аналог try ... catch), но любое нештатное поведение должно быть зафиксировано и разобрано потом руками.

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

Так и живем.

НЛО прилетело и опубликовало эту надпись здесь

О каких библиотеках идет речь? У нас есть язык. Им и пользуемся.

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

Речь о том, что тут не должно быть UB. Должна быть явная ошибка, которая будет зафиксирована так или иначе. Причем ошибка такая, которая однозначно диагностируется - где и почему. Но никак не UB когда внешне все ок, но результат недостоверный. Который может приводить к инцидентам типа "Луна-25" (не говорю что там было это, но такое возможно теоретически).

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

PS: я вообще в работе в основном с 8-битными системами общаюсь, у меня там свои проблемы.

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

Помнится, много лет назад у Wacom C был ключ, разрешающий подобные оптимизации.

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

вы же написали "на месте компилятора"

и в контексте чего я это написал? в коде где уже есть деление для 32 бит? Я работаю с железом где вообще нет деления и умножения. Вы сочинили какой-то кейс который "кладет на лопатки" мои слова, тогда и я могу начать сочинять до бесконечности. И я не утверждал что моё решение закрывает все возможные кейсы на планете Земля, я только написал мой вариант решения проблемы для сферического коня в вакууме (для меня вся эта статья - точно такой же конь сферический).

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

Вообще, UB давно уже стало "визитной карточкой" С/С++. И это печально.

Включаешь оптимизацию - работа становится нестабильной

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

Несколько лет назад оптимизация lto в gcc на arm была очень нестабильной - то странные ошибки при компиляции выдывала, то программа компилировалась и не работала. Но в последних версиях вроде бы починили.

Когда-то давно я проиграл в поинтер-арифметикс и у меня ключ активации контроллера на стороне заказчика считывал какой-то кусок прошивки вместо серийников и куда-то писал (тоже мимо). И все было хорошо, а UB он был)) По моей вине.

Вот от того, в каком порядке в итоговый бинарь соберется из объектников зависило вообще взлетит ли он. Чаще всего проблема вообще не возникала, иногда ломало строки в логах. И происходила прочая несуразица.

За всю мою практику... каждый раз... ошибка сидела перед монитором)

стоило включить оптимизацию, как она начинала падать в самых неожиданных местах

А причину в итоге удалось выяснить? У меня ровно

то же самое (в другом языке) наблюдалось

Падение оптимизированного кода тоже происходило

в непредсказуемом месте

Причем, как потом выяснилось, совсем не там, где была ошибка - что и мешало отловить глюк

Три года бились всем миром, пока наконец один мудрый человек не показал на стек FPU, после чего внезапно все заработало. Как оказалось, оптимизатор "забывал" вынуть из FPU-стека результат вычисления, потому что он нигде не использовался. Замена локальной переменной на глобальную вынудила его этот результат вынимать - после чего глюк исчез.

Как шахматы - трагедия одного темпа, так и программирование - это

трагедия одного символа

В моем случае оказалось достаточно заменить

Local_tmpVar=...

на

Global_tmpVar=...,

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

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

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

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

На нашей платформе такое, к счастью, невозможно - сразу получишь исключение "выход за границу аллоцированного объекта"

Ну значит не вылизано там было ничего, статические/рантайм анализаторы включайте

Статья хорошая, а пример не показательный!

статья хорошая, а перевод ужасен

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

А по ссылке:

It turned out that signed integer division and sign extension is harder to get right than I naïvely thought. However, more test cases helped to figure out the corner cases either not covered, or causing UB. If you are already using this library, please update!

Или, кажется деление интов это не тривиальная таска как в JS. А когда ты думаешь на уровне процессоров и совместимости все немного сложнее, кто бы мог подумать!?

А в репо кстати куча компайл-тайм (не рантайм) эзотерики на темплейтах где занимаются промоутом размерчиков до следующей размерности. А такие штуки:

if (val > 0xffff'ffff'fffffffful) {
throw "integral constant too large";
}

https://github.com/PeterSommerlad/PSsimplesafeint/blob/main/include/psssafeint.h#L74

Вообще пугают своей идеей. Почему он должен ему что-то там гарантировать на комайл тайме (если выражение по сути всегда false) и бросок const char* это сильно. Учитывая обрезание до u64 в месте вызова из-за семантики функции, вообще не понятно зачем оно такое. А на рантайме проблема остаётся. Автор малограмотен в терминах С++?

>Давида Свободы

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

Автор зачем-то пинает пример с переполнением инта которое средствами С++/С не очень то и детектируется, и не понятно, какой вариант стандартизовать вообще, в то время когда есть простой в написании и еще более дремучий вопрос:

-5 % 2 = ?

Ответ: Зависит от платформы и компилятора и процессора и погоды на луне. Был приятно удивлен увидев в ответе 0xfeadafaf на esp32, и всегда позитивные числа на esp8266

А дело раскрылось просто, автор фанат Rust, и он как и множество "нетоксичных" адептов этого замечательного языка, набегает на C++ сообщество со своим "Апасна UB" и приводит свои слабенькие примеры убеждая что в расте то, все как надо.

Бтв, там тоже никакой магии, просто у них стандартизован чек на переполнение. https://doc.rust-lang.org/std/primitive.i32.html#method.checked_add

В мире крестовиков вообще все не слава богу, надеюсь "стандартизуют" методы для арифметики с риском переполнения, а не __gnu/clang конструкции.

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

НЛО прилетело и опубликовало эту надпись здесь

Иронично)

Хорошо, что есть более опытные комментаторы, действительно баран и чему-то научился, что в consteval throw будет делать компайл тайм ошибку. Спасибо.

Кто сказал? Кто в стандарте гарантирует, что диапазон std::uint64_t достаточен unsigned long long?

Получается библиотека работает только в случае если unsigned long long > 64bit. Или у ull разные размеры во время сборки и непосредственно на runtime?

нет времени читать, надо разоблачать

Ну а про разоблачение, это про автора "JeanHeyd Meneide" который исходный автор статьи. Он приводит пример runtime переполнения int, приносит библиотеку, которая делает проверки на compile-time. "Автор малограмотен в терминах С++?" имелось ввиду про исходного автора, не библиотеки.

В общем, буду учится выражаться корректней) Спасибо)

Это consteval функция, бросок char* это отличный способ выдать ошибку на компиляции, никаких проблем здесь нет. На рантайме этой функции не существует

На самом деле дело не только в проверке i >= 0. Например, если заменить тип i на uint32_t, то программа будет писать, что "tab[4294963943] looks safe because 4294963943 is between [0;512]", то есть результат сравнения i < sizeof(tab) не соответствует действительности.

Так UB никуда не девалось же из-за того, что i стало беззнаковым.

Это правда, но автор в статье пишет:

«Но оптимизатор предполагает, что переполнения знаковых целых произойти не может, поскольку число уже положительное (что в данном случае гарантирует проверка x < 0, плюс умножение на константу). В конце концов, GCC берёт этот код, выдаёт его вам на‑гора и фактически удаляет проверку i >= 0, а заодно и всё, что она подразумевает.»

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

Переполнение беззнаковых ведь не UB?

Беззнаковым оно станет позже, при присвоении результата выражения в i. x же все еще знаковый, и операция x * 0x1ff - это все еще операция со знаком.

Я было предположил, что икс тоже беззнаковым будет, но ведь даже это не работает, т.к. integer promotion, да.

Языкам C/C++ давным давно пора на свалку истории. От ошибок в С коде происходит смещение пространства/времени и настолько дикая и аномальная хрень, чтобы объяснить которую нужно отправлять десяток гуру С экстрасенсов и они будут пол года разбираться, что вообще происходит, особенно если приложение толстый монолит, который работает по нагрузкой. Говорят, Rust пришёл всех спасти, но посмотрим.

Я бы не стал так категорично, но на мой взгляд будущее за специализацией. Отказ от одного "микроскопа", которым можно и микробов рассматривать и гвозди забивать в пользу специализированных инструментов для каждой области с возможностью их интеграции как это сделано в LLVM или IBM'овской ILE.

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

А есть опция компилятора "покажи все UB, которые нашёл"?

У gcc\clang есть рантаймовые asan, tsan, ubsan, которые замедляют выполнение. У msvc только asan.

UB на то и UB, что нет никаких гарантий, в том числе гарантий того, что их найдёт компилятор. А раз так, то какой смысл показывать неизвестную долю от неизвестного количества UB?

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

Нет. Компилятор должен только обеспечивать, что корректная программа без UB будет исполняться в соответствии с гарантиями стандарта языка. За пределами гарантий он может что-то делать или не делать, так что неопределённость может не только быть замечена и, например, эксплуатироваться для оптимизации кода, но и не замечена и оставаться на усмотрение создателей процессоров и/или операционных систем, авторов реализации стандартной библиотеки и т.д. и т.п.

Да, Вы, правы, спасибо за пояснение.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории