Pull to refresh

Comments 89

В регистре VAX предлагается бит статуса процессора,...

Немного не так - "Архитектура VAX содержит отдельный бит статуса в регистре состояния процессора"...

VAX это следующий этап развития архитектуры PDP-11

MMU - Блок управления памятью

Получается, на ассемблере программировать всегда определённо (чётко)?

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

Нет, потому что процессор не в вакууме существует.

См. например инструкцию HALT процессора Z80 в советских "гаражных" клонах Спектрума в режиме IM2.

Ну, что будет в регистрах процессора мы же знаем? А в железе что угодно может происходить.

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

И вот кстати да, содержимое R тоже не определено после HALT)

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

Неопределённые команды видимо недоступны просто так.

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

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

Например, у процессоров Pentium была знаменитая неопределённая машинная команда F0 0F C7 C8, которая фактически приводила к их зависанию (Pentium bug).

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

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

для этого придётся написать слегка необычно

Писать код через db не так уж и странно (скажем, если надо использовать новые инструкции, которые ещё не завезли в тулчейн).

которая фактически приводила к их зависанию (Pentium bug).

Так это именно bug, а не feature )

То, что процессор зависал - баг, а само наличие бессмысленного кода команды с разной шириной источника и приёмника - фича.

обычно результаты машинных команд предсказуемы

Обычно - да, но SPECTRE и MELTDOWN передают всем привет)

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

Только эти уязвимости влияют на состояние кешей процессора, а не регистров и памяти.

В целом да. На ассемблере у вас нет неопределенного поведения, у вас есть поведение, определяемое архитектурой. ADD двух чисел даст переполнение ровно так, как это описано в мануале на процессор. Проблема в том, что это определенное поведение может отличаться на x86 и ARM, поэтому код становится непереносимым

В пору вводить стандарт арифметики для процессоров)

IEEE 754 же )

А с целыми есть два более-менее распространённых варианта представления отрицателньых чисел - хотя найти сейчас живую машинку c ones' complement непросто.

Я про то, чтобы не было разницы в результате, например, сложения с переполнением. Хоть на arm, хоть на x86_64. Это были бы процессоры общего назначения, а специализированные - там можно делать все что угодно: свои компиляторы и прочее.

На arm и x86_64 (и прочих более-менее распространённых) в этом плане отличий нет )

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

"на величину большую или равную тому числу, которым оперируете" - сомнительная формулировка. 8-битное число может быть равно 256, а максимальный сдвиг 8.
Почему возникает неопределенное поведение после сдвига? После сдвига на максимальное количество бит, число должно стать равным нулю. На ассемблере сдвиг может быть арифметическим и логическим. На Си он всегда логический.

В большинстве процессоров операции сдвига используют счетчик не полностью - берут из него младшие 5 бит (6 в 64-битном режиме) в результате сдвиг на 64даст не ноль, как ожидается, а исходное число.

Верно. Сдвиг будет равен значению младших 5 или 6 бит.
The destination operand can be a register or a memory location. The count operand can be an immediate value or the CL register. The count is masked to 5 bits (or 6 bits if in 64-bit mode and REX.W is used). The count range is limited to 0 to 31 (or 63 if 64-bit mode and REX.W is used). A special opcode encoding is provided for a count of 1.

In 64-bit mode, the instruction’s default operation size is 32 bits and the mask width for CL is 5 bits. Using a REX prefix in the form of REX.R permits access to additional registers (R8-R15). Using a REX prefix in the form of REX.W promotes operation to 64-bits and sets the mask width for CL to 6 bits. See the summary chart at the beginning of this section for encoding data and limits.
IA-32 Architecture Compatibility
The 8086 does not mask the shift count. However, all other IA-32 processors (starting with the Intel 286 processor) do mask the shift count to 5 bits, resulting in a maximum count of 31. This masking is done in all operating modes (including the virtual-8086 mode) to reduce the maximum execution time of the instructions.


8-битное число может быть равно 256

это как?)

В военное время значение числа Пи достигает четырёх!

Я к своему удивлению не так давно узнал, что x86_64 процессор может из коробки делить 128-битное (целое) число на 64-битное, и умножать 64-битные с сохранением 128-битного произведения. В С/С++ такого без вспомогательных библиотек, как я понимаю, нет. И ещё. Процессор может выполнять циклический сдвиг, а в С/С++ (до С++20) такого нет.

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

Это на самом деле проблема системы типов C++ и многих других языков - там целочисленные арифметические операции проводятся над операндами одинакового размера и результат имеет тот же размер, что и операнды. Хотя чисто с математической точки зрения это абсурд, сложение двух 32-битных чисел даёт 33-битное число, а произведение двух 32-битных даёт 64-битное.

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

Он должен сохранять результат в два регистра, что в сумме и даст 128 бит. Но я не игрался, поэтому 100% гарантии не дам)

Да, более 64-х бит частное не дозволяется.

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

Это не так. Результат всегда int/unsigned/long/unsinged long/long long/unsigned long long в зависимости от операндов. Искать про integer promotion. А с плавающей точкой - результат всегда double.

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

Вот здесь можно это видеть. https://godbolt.org/z/ee5z1TKqh. В промежуточном коде операнды арифметических операций имеют однородные типы и результат имеет тот же тип. Там же видны операции, которые расширяют/преобразуют типы операндов по необходимости.

В интринсиках все давно доступно.

Которые simd или что-то от gcc?

Нашёл от Microsoft интринсики. Фактически, тот же ассемблер.

Думаю с вики лучше начать https://en.wikipedia.org/wiki/Intrinsic_function там ссылки на их вариации под разные компиляторы есть. Но это все же не ассемблер, скорее перенос логики его инструкций в обычные функции без вызова (например memcpy). В лучшем случае, с оптимизацией, они могут повторить этот же код и на асме, но это не точно.

Имеется в виду сдвиг на величину, большую или равную количеству бит в типе операнда. Для 8-битного unsigned char сдвиг на 8 или больше - это UB. Вы правы, формулировка в статье корявая

Разыменование нулевого указателя и тогда не допускалось (то есть, не поддерживалось и вряд ли могло принести какую-либо пользу) <...>


Уж где где, а на PDP-11 читать значение, ой разыменовывать указатель, по адресу 0 вообще не возбранялось. И никакого UB. Получили бы значение, обычно это КОП JMP на начало программы.
На этой машине вообще можно было читать откуда угодно из памяти/РВВ.

Да и на х86 в реальном режиме тоже. Получили бы первую запись таблицы прерываний.

Да и на х86 в реальном режиме

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

Нет, уже давно NULL это ((void*)0) по стандарту. Мамонты, где это не так, уж очень давно вымерли.

То, что " NULL это ((void*)0) по стандарту" никак не отменяет того, о чем я сказал. Этот ноль в исходнике не означает нули в битовом представлении. Даже если технически оказывается так, что репрезентация этого нуля в битах тоже нули, семантически - это разные нули. Литерал "ноль", как и nullptr, как и NULL - это лишь удобные записи для обозначения нулевого указателя, а конкретные биты компилятор сгенерит сам, вы напрямую не можете никак не это повлиять.

Еще одна ссылочка для понимания: https://c-faq.com/null/machnon0.html

А пример можно машины + компилятора, где у NULL ненулевое значение? :)

Я бы ещё понял, если бы вы сказали, что нулевым считается не только собственно адрес 0, но и некоторые малые околонулевые адреса (скажем, первые 64к в большинстве случаев и даже 4Gi в наркоманских ОС типа 64-битной солярки). Это позволяет а) отлавливать ситуации типа NULL[2]->x, которые всё равно останутся нулевыми и б) сравнивать с 0 только старшие биты указателей, что может быть несколько быстрее / энергоэффективнее.

Там по ссылке есть ссылки на примеры.

И да, я тут своего ничего не говорю. То, что я сказал, и то, что написано в FAQ - это просто пересказ человеческим языком того, что написано в стандарте.

По ссылке CDC, Cray и прочие примеры из 60-ых, которых давно на золото переплавили.

А я спрашиваю про современные, где хотя бы С90 поддерживается.

У нас как-то контекст съехал. Человек в начале ветки сказал, что нулевой адрес в PDP-11 (уже переплавленной на золото, кстати) мог использоваться для адресации. Я на это ответил, что концепции нулевого указателя и нулевого адреса - различны с точки зрения стандарта (т.е. на практике это означает, что если в какой-то реализации нулевой адрес использовать для адресации можно, то для представленяи нулевого указателя будет выбрано какое-то другое значение - не ноль, и такие примеры были). Далее вы попытались меня поправить, приведя в пример описание нулевого указателя из стандарта. На что я опять же вам ответил, что приведенная вами в пример запись относится к форме записи такого указателя в исходном коде, а не к его битовому представлению. А потом контекст зачем-то поехал в сторону "а покажи-как мне реализацию где сейчас не так". Но это уже out of topic. Во-первых потому, что речь изначально шла про древнюю технику, а во-вторых потому, что отсутствие подобных современных машин не опровергает ничего из вышесказанного.

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

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

Это тот случай, когда гугл переводит лучше. "Единица управления памятью", "семантика заворачивания"... нда.

Но и сама статья очень слабая. В статье даже не упоминается поведение, определяемое реализацией (а разница в спецэффектах может быть огромной). Приведение float* к int* как пример. Алиасинг упомянут, но в таких терминах, что мидл-разработчик на С даже не поймёт.

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

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

Касательно темы: как по мне это глупость - говорить про UB при разыменовании, например. Какое языку программирования C должно быть дело, что там разыменовывают с помощью mov dword ptr [rax]?

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

Касательно темы: как по мне это глупость - говорить про UB при разыменовании, например. Какое языку программирования C должно быть дело, что там разыменовывают с помощью mov dword ptr [rax]?


Касательно темы - UB в стандарте сформулировано таким образом (поведение не определено), что компилятор имеет право вести себя так, будто UB в коде нет, т.к. если UB произошло - то программа просто как-бы отменила всё ранее сделанное.

Последние лет 10 (1) - компиляторы стали этим злоупотреблять (2), - и просто помечать весь код, содержащий UB как недостижимый, что привело не только к выкидыванию самого кода с UB, но и к compile-time наложению условий на значение переменных, которые "таковы, что UB не произойдёт".

1) после того, как компиляторы стали сверхагрессивно инлайнить ф-ии
2) а уже не всегда возможно отделить "злоупотребление" от "разумных оптимизаций"

Не понимаю, зачем переводить весьма куцую статью, которая апеллирует ещё к дремучим пидипи и ваксам, если тема раскрыта почти никак, - тогда как существует, например, здоровенная "книга неопределённых поведений"?

Видов неопределённого поведения много. Элементарно: любое нарушение предусловия любой библиотеки, - и вуаля, поведение не определено.

Если сказано "memcpy не умеет работать с перехлёстом массивов, не делайте так", а вы сделали так, - вас ждут необычные сюрпризы. Скорее всего, до аппаратных защит вы не доиграетесь, но кучу мусора в данных получить рискуете.

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

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

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

Вот тут - ужасы нашего городка https://kodt-rsdn.livejournal.com/228358.html

А вы говорите - древний вакс...

Offtop:очень рад встретить вас тут. Помню, вот были времена на rsdn

Ниже в комментах и другой олд с рсдн проявился. Все мы так или иначе мигрируем по вселенным с единомышленниками.

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

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

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

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

Коллега прав, проблема останова существует только для бесконечной ленты. Я подробно рассматривал этот момент здесь: https://habr.com/ru/articles/926394/

Штош, был неправ. Спасибо что ткнули носом, отличная статья

Тем не менее, семантика многих языков программирования (денотационная, т.е. в отрыве от способа их реализации) не включает ограничения памяти, поэтому возвращаемся к тому, с чего начали :)

Хотя в семантике C++, наверное, объём памяти всё же подразумевается конечным в связи с фиксированной разрядностью указателей. Так что, немного неожиданным образом, наличие операции sizeof(void*) гарантирует решение проблемы останова.

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

В семантике C++ (стандартной библиотеки) есть файлы, в которых тоже может храниться состояние программы.

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

Позиция необязательна - для stdin/stdout она вообще смысла не имеет, не вижу препятствий к реализации потока с сохранением информации, с которым работают read/write, но не seek/tell.

Позиция необязательна - для stdin/stdout она вообще смысла не имеет

Но при помощи stdin/stdout нельзя сделать сохранение информации для дальнейшего чтения (не мутя перенаправление средствами ОС, которые выходят за рамки языка С++).

не вижу препятствий к реализации потока с сохранением информации, для которого опеределены read/write, но не seek/tell.

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

Я не настолько глубоко знаю C++, чтобы утверждать, что лазейки точно нет, но пока я её не вижу.

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

Как вариант - при исчерпании ресурсов текущих накопителей ждём, пока не добавят новый, после чего привязываем его к текущему файлу. Объём производства накопителей пока не останавливается - что будет дальше неизвестно, но по крайней мере жёстких лимитов нет. В любом случае это уже детали реализации за пределами семантики C++.

В любом случае это уже детали реализации за пределами семантики C++

О чём и речь.

что там "подробно рассматривать" то?
MT (как и любая процессорная архитектура) с конечной памятью - просто ОЧЕНЬ большой конечный автомат.

Справедливое утверждение, но на удивление мало людей это ясно понимают.

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

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

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

Сейчас будут квантовые компьютеры со своими ньюансами. И на них опять будет С с УБ =)

это я про "везде одинаковые"

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

уже есть.

"не используйте -О3"

Так как в нынешнем C++ правильно работать с отдельными битами double/float?

constexpr double f64v = 19880124.0; 
constexpr auto u64v = std::bit_cast<std::uint64_t>(f64v);
static_assert(std::bit_cast<double>(u64v) == f64v); // round-trip

Перевод просто ужасен. Оригинал достоин (такого) перевода. Оригинал не достоин перевода. (лингвистическое UB)

Потому что русский язык полон по Тьюрингу, Ершову, и даже Эйлеру и Канту, великим русским мыслителям! )))

Общеизвестно, что на С программировать сложнее, чем на таких языках, как Python.

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

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

Бывают ещё недоопределённые команды. Например, BSF, BSR в x86 при нуле на входе могут формально вернуть что угодно. Но в терминах C/C++ это не undefined, а unspecified behavior.

Хуже то, что автор оправдывает наплевательство авторов компиляторов на проблемы программистов, когда сам факт нарушения невозможно отследить из-за сложности кода и интерференции эффектов. Например, переполнение знакового при умножении на константу, когда эта константа определена за тридевять модулей. Изменение размера типа целого. Ещё можно много вариантов придумать. Как сказал один деятель на RSDN, перефразируя Маркса, "нет такой подлости и низости, на которую бы не пошли авторы GCC и Clang ради очередных 2% в никому не нужном синтетическом тесте". Вместо этого и, например, рассказов типа "а вы выключите оптимизацию", надо было дать возможность контекстно управлять наиболее критичными случаями UdB, как переполнения и алиасинг. Синтаксические механизмы для этого давно есть.

Раздел Что делать самая полезная часть статьи

Санитайзеры (-fsanitize=undefined), флаги (-fwrapv, -ftrapv) и статические анализаторы - это тот необходимый инструментарий, без которого писать на C/C++ в 2025 году - это просто безрассудство

и отвага!

Намного подробнее Путеводитель C++ программиста по неопределённому поведению. Лучшего не встречал.

Заголовок: "Подробно о неопределённом поведении в С и C++". Потом вся статья про C и C23 с мимолётным упоминанием std::vector<T>::at(size_t). А у этих языков разные неопределённые поведения, пересекающиеся, но ни подмножества одно другого.

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

Неверный перевод

shifting by an amount greater than or equal to the size of the number, is UB.

"большую или равную тому числу" != "greater than or equal to the size of the number".

std::vector<T>::at(size_t) тоесть уб в том случае если обращение в вектор по at в несуществующую ячейку вектора или в чем прикол, даже интересно

для меня пока магия вот какая, есть вектор 118 елементов, есть time считается по формуле с dt и опр множителем в сухом остатке она приводит к количеству фреймов.

меня прикольнула такая ситуация

делаю 118 кадров от 1 до 117 они в векторе )

я просто беру glm::clamp(time,1,600); и работает как-будто все 3 тыщи кадров в векторе, но в векторе ровно 118 кадров )

Sign up to leave a comment.

Articles