Если бы Кардинал Ришелье был программистом, он бы сказал: «Дайте мне шесть строк кода, написанных рукой самого профессионального C-программиста в мире, и я найду в них лазейку для вызова неопределённого поведения.

Никто не может написать безошибочный код на С или C++. И я говоря об этом как человек, который пишет на этих языках почти каждый день около 30 лет. Я слушаю подкасты по C++. Я смотрю выступления про C++ на конференциях. Мне нравится читать и писать на этом языке.

C++ послужил нам сполна, но на дворе 2026 год, и современная рабочая среда явно отличается от среды 1985 (C++) или 1972 (С).

И я далеко не первый, кто об этом заговорил. Помню ещё с десять лет назад читал статью какого-то известного человека, в которой он утверждал, что использование C++ вполне обоснованно можно подвести под нарушение закона Сарбейнза-Оксли (SOX). И хотя с остальной его критикой я не был согласен (как и с тем, что он путал «its» и «it’s»), конкретно с этим пунктом я никогда не спорил.

Мало того, со временем я всё больше убеждался в его истинности. На деле в С для возникновения неопределённого поведения (undefined behaviour, UB) есть гораздо больше возможных причин, чем вы могли предполагать.

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

А ведь есть и другие, более тонкие и нелогичные.

Дело не в оптимизациях

Похоже, некоторые считают, что достаточно компилировать код без включения оптимизаций, и тогда UB не страшно. Они верят в какую-то изначальную враждебность компилятора, который только и думает — «Ага! Неопределённое поведение! Я могу делать всё, что захочу!» — и рассчитывают, что отключение оптимизаций его остановит.

Это не так.

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

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

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

UB повсюду

Я не стану пытаться перечислить все варианты неопределённого поведения, а просто аргументированно покажу, что оно повсюду. И если никому не под силу сделать всё идеально, то как вообще можно винить в этом программистов? Мой главный посыл в том, что ВЕСЬ нетривиальный код на С и C++ содержит в себе UB.

Обращение к объекту с неправильным выравниванием

Возьмём для примера такой код:

int foo(const int* p) {
   return *p;
}

Если эту функцию вызвать с неправильно выровненным указателем (вероятно, с адресом, кратным sizeof(int), но кто знает), то это UB (пункт 6.3.2.3 стандарта C23).

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

На архитектуре SPARC это гарантированно приводило к SIGBUS.

Естественно, на x86/amd64 (далее просто x86) такой код проблем не вызовет. Да что тут говорить, это даже наверняка будет операцией атомарного чтения. Архитектура x86 известна своей крайней снисходительностью к ошибкам синхронизации кэша.

Итак, здесь мы имеем три случая:

  • помощь со стороны ядра (для некоторых инструкций чтения на Alpha);

  • сбой (другие инструкции чтения на Alpha и SPARC);

  • всё в порядке (x86).

А что с ARM, RISC-V и другими архитектурами? Что насчёт будущих? В какой-нибудь будущей архитектуре могут даже появиться специальные регистры для указателей на int, которые вообще не заполняют младшие биты, потому что таких указателей существовать не может.

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

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

Или вот другой вариант:

void set_it(std::atomic<int>* p) {
        p->store(123);
}
int get_it(std::atomic<int>* p) {
        return p->load();
}

Будет ли эта операция атомарной, если объект невыровнен? Му*— вопрос поставлен неверно. Это UB. (Хотя, да, на практике это вполне может обернуться проблемой с атомарностью).

*Прим. пер.: Иероглиф 無 (му) часто используется в дзэн-буддизме и обозначает отсутствие, небытие или пустоту. В текущем контексте он используется автором как образный способ указать на бессмысленность вопроса.

Если вам этих подтверждений мало, попробуйте представить, что произойдёт, если объект, который вы рассчитывали прочитать атомарно, окажется на стыке двух страниц. Только не задумывайтесь слишком сильно, а то вам может показаться, что «это нормально». Но это не так. Это неопределённое поведение.

По правде говоря, оно возникло даже раньше.

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

Вот пример:

bool parse_packet(const uint8_t* bytes) {
        const int* magic_intp = (const int*)bytes;   // UB!
        int magic_raw = foo(magic_intp);  // Возможен сбой на SPARC.
        int magic = ntohl(magic_raw); // Здесь всё в порядке.
        […]
}

Проблема в приведении, а не в foo().

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

Применение isxdigit() к char

bool bar(char ch) {
        return isxdigit(ch);
}

isxdigit() — это простая функция, которая получает символ и возвращает 1, если это шестнадцатеричная цифра: 0–9 или a–f. Она также может принимать значение EOF. Так, хорошо. А какое у EOF значение? Из раздела 7.4p1 стандарта C23 мы знаем, что это int, и можем сделать вывод, что его нельзя представить в виде unsigned char.

Поэтому isxdigit() получает int, а не char. Но любое значение типа char помещается в int, так что здесь проблем быть не должно. Приведение из char в int корректно, и если верить разделу 6.3.1.3, то всё в порядке, так?

Нет. Если bar() вызвать со значением вне диапазона 0–127, а в вашей архитектуре char является знаковым (что согласно параграфу 20 раздела 6.2.5 стандарта C23 определяется реализацией), то целое число окажется отрицательным.

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

int isxdigit(int c) {
        if (c == EOF) {
                return false;
        }
        return some_array[c];
}

Приведение float к int

int milliseconds(float seconds) {
        int tmp = (int)(seconds  1000.0); / НЕПРАВИЛЬНО */
        return tmp + 1; /* тоже НЕПРАВИЛЬНО (знаковое переполнение — это UB) */
}

Когда конечное значение реального типа с плавающей запятой преобразуется в целочисленный тип […] Если целую часть значения нельзя представить целочисленным типом, поведение не определено.

— 6.3.1.4

Кроме того, ввиду отсутствия явных правил в стандарте, это UB также возникнет, если float окажется бесконечным или NaN.

Так как же сравнивать float с INT_MAX? Нужно ли приводить float к int? Нет, это вызовет то самое UB, которого мы хотим избежать. Тогда приводить INT_MAX к float? А откуда вы знаете, что его получится выразить точно? Что, если при приведении INT_MAX к float значение округлится так, что перестанет влезать в int, и тогда ваше сравнение потеряет всякий смысл?

Может, сработает следующий вариант? Так вы упустите некоторые реально большие значения, но вдруг это не страшно?

int milliseconds(float seconds) {
        const float ftmp = seconds * 1000.0f;
        if (!isfinite(ftmp)) {
                // Или другой способ обработки ошибки.
                return 0;
        }
        if ((float)(INT_MIN + 1000) > ftmp) {
                // Или другой способ обработки ошибки.
                return 0;
        }
        if ((float)(INT_MAX - 1000) < ftmp) {
                // Или другой способ обработки ошибки.
                return 0;
        }
        // Теперь преобразование безопасно.
        const int tmp = (int)ftmp;
        if (INT_MAX == tmp) {
                // Или другой способ обработки ошибки.
                return 0;
        }
        // Теперь можно безопасно прибавить единицу.
        return tmp + 1;
}

А ведь я всего лишь хотел превратить float в int. :-(

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

Хранение объектов по нулевому адресу

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

Согласно разделу 6.3.2.3, целочисленная константа нуль (которая преобразуется в указатель) и nullptr являются «константой нулевого указателя» (я буду называть её просто NULL). При этом стандарт C не указывает, что конкретный указатель NULL должен ссылаться именно на физический нулевой адрес, поскольку стандарт описывает абстрактную машину C, а не реальное аппаратное обеспечение».

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

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

Это также значит, что вы не можете рассчитывать на создание нулевого указателя с помощью memset(&ptr, 0, sizeof(ptr));. Нельзя инициализировать структуры таким образом и думать, что указатели в их полях окажутся нулевыми. Причём это уже касается большинства программистов.

К слову, в некоторых старых машинах использовались ненулевые указатели NULL.

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

Опять же, в разделе 6.3.2.3 говорится, что NULL не равен «любому объекту или функции». Значит, это UB:

void (*func_ptr)() = NULL;
func_ptr();

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

А что вообще значит «из всех нулей»? На 16-битной x86 это будет 0000:0000? Или CS:0000?

Переменные аргументы и типы (например, printf с %ld вместо %lld)

Здесь получаем UB:

execl("/bin/sh", "sh", "-c", "date", NULL);     /* НЕПРАВИЛЬНО */
execl("/bin/sh", "sh", "-c", "date", 0);     /* НЕПРАВИЛЬНО */

А вот здесь уже нет:

execl("/bin/sh", "sh", "-c", "date", (char*)NULL);

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

И здесь тоже UB:

uint64_t blah = 123;
printf("%ld\n", blah);  /* НЕПРАВИЛЬНО */

Должно быть так:

uint64_t blah = 123;
printf("%"PRIu64"\n", blah);

Так как же тогда выводить uid_t? Как вариант, вы можете привести его к uintmax_t и вывести с помощью PRIuMAX. Но уверены ли вы, что uid_t беззнаковый? Думаю, в худшем случае вы получите на выходе бессмысленное число вместо -1.

Деление на нуль — это UB

Уверен, вы и так это знали. Но учитывали ли вы сопутствующие аспекты безопасности? Нередко бывает, что знаменатель поступает из непроверенных источников.

И здесь ещё много других нюансов. Стандарт C23 содержит 283 вхождения слова «undefined», и это без учёта тех случаев, которые не задокументированы.

Бонус: реализация без UB

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

Статья и так уже затянулась, поэтому ограничусь парой примеров:

unsigned char a = 0xff;
unsigned char b = 1;
unsigned char zero = 0;
bool overflowed = (a + b) == zero;
// overflowed примет значение 0, а не 1.

unsigned char a = 0x80;
uint64_t b = a << 24;     // Бонусное UB(?)
// Теперь b равна 18446744071562067968 (ffffffff80000000), а не 2147483648 (0x80000000).
// Даже при том, что все наши переменные беззнаковые.

LLM здесь справляются лучше нас

Покажите LLM ЛЮБОЙ код на C, попросите найти в нём UB, и она справится. Причём на сегодня окажется права почти в каждом случае.

Честно говоря, мне стало немного не по себе, когда ИИ корректно нашёл неопределённое поведение в моём коде, и тогда я решил таким же образом прощупать зрелую и скрупулёзно написанную OpenBSD. Я выбрал первый инструмент, какой пришёл мне на ум — find — и он выдал целую кучу предупреждений.

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

Так что же нам делать?

Мы не можем просто взять и выбросить кодовые базы на С и C++. Но и оставлять их в таком глубоко уязвимом состоянии тоже не вариант.

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

Хотя эта идея тоже не нова и озарением не является.

Тем не менее в 2026 году написание кода на С или С++ без анализа на UB с помощью LLM уже следует расценивать как нарушение SOX, да и просто как банальную безответственность. Если уж разработчики OpenBSD более 30 лет не могут отыскать эти проблемы, то каковы шансы у всех остальных?

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

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

Комментарии (34)


  1. linashop
    24.05.2026 09:19

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


  1. geher
    24.05.2026 09:19

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

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

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

    Тем не менее в 2026 году написание кода на С или С++ без анализа на UB с помощью LLM уже следует расценивать как нарушение SOX, да и просто как банальную безответственность.

    Скрытая реклама ЛММLLM?

    Кстати, увы, но означенные LLM не таки всегда находят UB, а если находят, то не всегда там, где они на самом деле есть.


    1. KanuTaH
      24.05.2026 09:19

      Скрытая реклама ЛММLLM?

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


    1. cpud47
      24.05.2026 09:19

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

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

      Существенная часть выбрасываний "мёртвого кода" связана с тем, что этот код оставила после себя предыдущая оптимизация. Вы вряд ли захотите видеть все такие предупреждения. Но если всё же захотите, то есть optimization report, например беглый гуглинг говорит, что `clang -Rpass=dead-code` делает то, что Вы просили...

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

      Хуже того, большинство странных оптимизаций (в присутствии уб) происходит после инлайнинга — как раз то место, где происходит большое количество "хороших" оптимизаций. Условно:

      int foo(T* ptr) {
        int id = ptr->id;
        update(ptr);
        update(ptr);
        return id;
      }
      
      void update(T* ptr) {
        if (ptr == 0) panic("...");
        update_or_make_bad_things_if_null(ptr);
      }

      Поэтому увы, как решать сложившуюся проблему с уб — не очень понятно (ну кроме тестов/санитайзеров/стат анализа).


  1. SilverTrouse
    24.05.2026 09:19

    Можно скомпилировать код с использованием с++26 и уже в коде будет меньше UB.


    1. slonopotamus
      24.05.2026 09:19

      Осталось где-то найти компилятор с поддержкой c++26. Потому что их не существует.


  1. 100h
    24.05.2026 09:19

    Каким образом я могу обратиться к адресу 0, используя язык программирования Rust? Существуют ли на 100% безопасные и предсказуемые подходы?


    1. DarthVictor
      24.05.2026 09:19

      Каким образом я могу обратиться к адресу 0

      Абсолютному или относительному?


      1. 100h
        24.05.2026 09:19

        physical address 0x00


        1. cpud47
          24.05.2026 09:19

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


  1. SIISII
    24.05.2026 09:19

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

    Возьмём, например, целочисленный знаковый тип размером 2 байта. Как известно, все современные "нормальные" платформы (а существуют ли современные "ненормальные", я, честно говоря, не знаю) для представления целых чисел используют дополнительный код. Соответственно, имея 16 бит, мы имеем диапазон от -32768 до +32767. Прибавление единицы к максимальному положительному значению технически даёт максимальное отрицательное: +32767 + 1 = -32768. И здесь возможны ровно два технически обоснованных результата:

    • операция сложения молча выполняется и даёт указанный абсолютно предсказуемый и технически корректный результат;

    • фиксируется возникновение переполнения, что, в конечном итоге, приводит к исключению (неважно, выполняется ли это чисто аппаратными средствами -- скажем, мэйнфреймы IBM умеют ловить такое переполнение, если это разрешено соответствующим битом маски, а на IA-32 aka x86 надо явным образом анализировать флаг OF с помощью команды INTO или же условным переходом).

    На мой взгляд, подобные ситуации не должны объявляться как UB на уровне языка и компилятора, т.е. стандарт языка должен указывать, а компиляторы должны реализовывать генерацию кода, выполняющего требуемую операцию, не взирая на возможное (или даже гарантированно возникающее) переполнение -- они, разве что, должны выдать предупреждение, если видят, что переполнение возникнет. А ещё лучше иметь на уровне стандарта (и, естественно, конкретных компиляторов) некую прагму, которая позволяет стандартным образом включать и отключать контроль переполнения во время выполнения программы -- даже если такой контроль, как в случае с IA-32, влечёт за собой потенциальное снижение производительности из-за необходимости выполнения лишних команд. Тогда ситуация остаётся управляемой для программиста: он знает, где переполнение допустимо, а где -- нет, и может соответствующим образом настраивать поведение программы стандартным образом.

    То же самое касается и многих других случаев UB. Скажем, для невыровненных указателей тоже можно предусмотреть прагму, заставляющую включать код их проверки, причём даже на платформах, где технически невыровненные доступы разрешены и работают корректно (такая проверка упрощает отлов невыровненных указателей, которые станут проблемой на других платформах), и прагму, обязывающую компилятор сгенерировать код, корректно работающий с невыровненными указателями (для IA-32 эта прагма де-факто ничего делать не будет, а вот на ряде других платформ при её использовании будет формироваться другой, более громоздкий код -- скажем, побайтовая загрузка четырёхбайтовой величины с последующей "сборкой" в регистре процессора). Ну и т.д. и т.п. Но вместо этого, как мы знаем, UB трактуется просто как право компилятора творить любую дичь, в том числе выкидывать сомнительный для него код.


    1. rukhi7
      24.05.2026 09:19

      С UB везде и постоянно происходит подмена понятий, потому что никто не задумывается где это поведение не определено. А не определено оно в стандарте, а не в конкретном компиляторе для конкретной платформы! Конкретный компилятор на конкретной платформе пользуется возможностями машинного языка этой конкретной платформы. Стандарт пишется для того чтобы эти возможности ЛЮБОЙ аппаратной платформы использовались самым эффективным образом, именно поэтому стандарт избегает регламентировать поведение там где это может повлиять на эффективность кода для самых распространенных конструкций программирования.

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

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


      1. geher
        24.05.2026 09:19

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

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


        1. SIISII
          24.05.2026 09:19

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


        1. rukhi7
          24.05.2026 09:19

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


    1. ruomserg
      24.05.2026 09:19

      Вот ровно поэтому я ворчу, что разработчики стандарта злоупотребляют UB. И это - влияние C++, где сделали последовательно несколько глупостей:

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

      • Построили все это на SFINAE (т.е. компилятор генерирует тучи версий класса/функции - и потом отбраковывает их потому что они не компилируются)

      • С ужасом посмотрели на размеры исполняемых файлов, и пошли давать права компилятору вырезать куски кода просто потому что он нашел способ как признать этот код ненужным. Последнее особенно опасно - потому что многие UB проявляются только при определенных данных. Но компилятор выкидывает куски кода - как только сможет доказать что при КАКОМ-ТО сочетании входных данных будет UB.

      Имхо, решение очень простое. Помимо UB - стандарт языка определяет такое понятие как "platform dependent". Это означает что в тонком случае - разработчик компилятора на определенной платформе ОБЯЗАН выбрать какое-то поведение, описать его - и далее ему следовать. При этом, на другой платформе - это поведение может быть другим. Но по-большому счету, "C" - используется в двух ипостасях:

      • Как язык программирования общего назначения - и тогда можно забыть про UB потому что вы даже не лезите в эту область (а еще лучше - возьмите Java/Kotlin и не сношайте себе и людям мозг!)

      • Как высокоуровневый ассемблер для конкретной платформы. И в этом случае, нас видимо не интересует 100% совместимость. Тот код, который я написал несколько лет назад для AVR - никогда не заработает на STM32. Не потому что у меня там UB, а потому что там куча всего еще прибита гвоздями к платформе. И невозможно простой перекомпиляцией это решить. А значит, дело компилятора - трудолюбиво производить код, а не заниматься широкой интерпретацией намерений программиста - ибо если человек что-то написал, наверное это зачем-то нужно. И должен быть наверное закрытый список оптимизаций которые компилятор может сам выполнять. И они должны быть достаточно тривиальными, чтобы не вызывать проблем. А если нужно что-то нетривиальное - ну что ж, значит придется оптимизировать как раньше - ручками в коде, вроде никто от этого не умирал...

      И еще - комитет по стандартизации C - ИМХО поставил себе цель, о котрой его никто не просил: создать такое подмножество языка, которое исполнялось бы с одинаковым результатом и примерно одной производительностью - на любой из существующих (или даже не существующих) платформ. С учетом зоопарка этих платформ - такое подмножество языка стремительно вырожается в ноль. Но по-пути убиваются подмножества языка (через объявление UB) - которые имели смысл для конкретных (и очень распространенных платформ). Те же signed/unsigned для платформы x86 - прекрасно определены, никакой неопределенности - просто генерируй команды процессора и будет счастье... Ну и кто мешал определить это как platform dependent, а не как UB ? Никто, кроме гордыни авторов стандарта...


    1. Pand5461
      24.05.2026 09:19

      Предложение хорошее, но поезд компиляторостроения, похоже, слишком далеко уже ушёл.

      Вот именно для случая с целыми, что вы описали, недавно была статья: https://habr.com/ru/companies/pvs-studio/articles/276657/

      С учётом UB при переполнении знаковых целых компилятор имеет полное право для операций с короткими целыми использовать полноширинные регистры, поэтому +32767 + 1 может быть по факту +32768, потому что под капотом операция выполнилась в 32 или 64 битах. И, если судить по вышеуказанной статье, такое поведение компиляторов можно на деле встретить.


  1. Andrey2008
    24.05.2026 09:19

    Тем, кто хочет ещё глубже занырнуть в UB - Путеводитель C++ программиста по неопределённому поведению :)


  1. d3d14
    24.05.2026 09:19

    Удивлен, что в статье не призвали переписать на Rust.


    1. Dhwtj
      24.05.2026 09:19

      В safe Rust UB по построению недостижимо


  1. tatapstar
    24.05.2026 09:19

    Почему для C и C++ постоянно упоминают компиляторы и их работу?

    На С и С++ не писал, но в других языках более высокоуровневых к компилятору (к его поведению) никогда не обращался. Как бы прихожу уже на все готовое


    1. JediPhilosopher
      24.05.2026 09:19

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

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

      Плюс это в C/C++ мире произошло разделение, есть несколько крупных вендоров компиляторов под разные платформы, не связанных друг с другом ничем кроме стандарта. Есть MSVC, есть GCC, есть кто там, CLang или еще кто-то, я уже не помню. И все они примерно равны. Вроде больше нигде такого нет, в Java, дотнете, питоне, джаваскрипте есть по одной популярной референсной реализации, а все остальные узкоспециализированные и малоиспользуемые. В таком случае гораздо проще все стандартизировать и не оставить пустых мест.

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


  1. mastan
    24.05.2026 09:19

    Обращение к объекту с неправильным выравниванием

    Возьмём для примера такой код:

    int foo(const int* p) {   return *p;}

    Так, а в чём в данном случае вина именно С/С++? Какой язык предотвратит невыровненный доступ к памяти?

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

    Есть примеры куда более доступные чем эти древние оси и архитектуры. Вполне современные чипы Cortex-M0/M0+, встречающиеся например в некоторых контролерах STM32 в наши дни, делают HardFault на невыровненной памяти.

    Приведение float к int

    И это не имеет отношения к языку. Переполнения возможны на любом языке и задача программиста учитывать это. Я даже более страшную вещь скажу, банальное умножение int на int в 99% случаев двух взятых произвольных int'ов вызовет переполнение.

    Деление на нуль — это UB

    И это тоже универсально.

    Переменные аргументы и типы (например, printf с %ld вместо %lld)

    В плюсах есть libfmt/std::format, которые с синтаксисом похожим на printf проверяют аргументы на этапе компиляции.


  1. Sobakaa
    24.05.2026 09:19

    Получается на ллм раз или в попу раст?


  1. MasterMentor
    24.05.2026 09:19

    Хабетс пишет: «В C всё является UB, компилятор сошел с ума». Но это ложь. Язык дал ему все инструменты безопасности, но он их игнорирует.

    Современные стандарты C (C23) и C++ давно дали легальные, безопасные инструменты. Вместо опасных кастов указателей, от которых Хабетс страдает в своем коде, есть std::span или встроенные макросы выравнивания. Вместо наивных проверок знакового переполнения есть функции безопасной арифметики (ckd_add, ckd_mul), встроенные в стандарт на уровне ключевых слов.

    – Концепция Профилей (Profiles): Сейчас в комитете по стандартизации ISO активно обсуждается внедрение так называемых «профилей безопасности» (Safety Profiles). Идея в том, чтобы программист мог прямо в коде написать #pragma safety или аналогичную директиву. Включая её, вы добровольно говорите компилятору: «В этом модуле UB запрещено, проверяй меня жестко».

    – Внедрение типов-оберток: Вместо изменения базовых типов вроде int или *pointer, комитет добавляет в стандарт безопасные альтернативы (например, std::span, функции безопасной арифметики). Хочешь безопасности - используй их, не хочешь - пиши по старинке на свой страх и риск.

    Undefined Behavior - это прямое воплощение закона формальной логики Ex falso sequitur quodlibet (Из лжи следует что угодно) в программировании.

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

    В серьезных отраслях (авиация, космонавтика, автомобильный софт) существуют жесткие стандарты программирования, такие как MISRA C или AUTOSAR. Там правило номер один полный, абсолютный запрет на использование любых конструкций, способных вызвать UB.

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

    https://github.com/sakura1083841400/MISRA-C/

    https://www.autosar.org/search

    Guidelines for the use of the C++14 language in critical and safety-related systems https://www.autosar.org/fileadmin/standards/R19-03/AP/AUTOSAR_RS_CPP14Guidelines.pdf

    UB (неопределенное поведение) в той или иной форме есть во всех языках программирования, за исключеним математических (Coq, Agda, итд). В управляемых языках это называют не UB, а «неожиданным / неочевидным поведением» (unexpected behavior).

    На Хабре есть десятки статей на эту тему (например, циклы статей «Занимательная Java» или «Тонкости и ловушки C#»). Вот пара «идиотских», но 100% законных примеров, от которых у практикующих программистов «дергается глаз»:

    ***
    1. Java: фокусы кэширования и автобоксинга
    В Java есть прекрасный «безопасный» механизм автоматического превращения примитивов в объекты (автобоксинг). Смотрим на официальный, компилируемый 
    
    Integer a = 127;
    Integer b = 127;
    System.out.println(a == b); // Выведет: true
    
    Integer x = 128;
    Integer y = 128;
    System.out.println(x == y); // Выведет: false!
    
    Почему это «законное безумие»? С точки зрения любого вменяемого человека, 128 == 128. Но согласно спецификации Java (JLS), объекты классов-оберток сравниваются по ссылкам (==), а не по значению.Однако, чтобы язык не тормозил, создатели Java встроили в рантайм кэш для чисел от -128 до 127. Для чисел в этом диапазоне Java возвращает один и тот же объект из кэша (поэтому 127 == 127 — это true, ссылки совпали). А для числа 128 создаются два разных объекта в памяти.Программист пишет абсолютно safe-код, тестирует его на маленьких числах — всё работает. Программа выходит в релиз, числа растут до 128, и логика приложения тихо, без ошибок ломается, потому что 128 != 128.
    
    2. C#: Коварная ленивость LINQ и замыкания
    Классический пример из C# (до версии C# 5.0 это была главная боль, но и сейчас на эти грабли наступают в других циклах).
    
    var actions = new List<Action>();
    
    for (int i = 0; i < 5; i++) {
        actions.Add(() => Console.WriteLine(i));
    }
    
    foreach (var action in actions) {
        action(); 
    }
    // Ожидание: 0, 1, 2, 3, 4
    // Реальность: 5, 5, 5, 5, 5!
    
    Код абсолютно безопасен (safe), никаких указателей. Но компилятор C# делает здесь законное замыкание (closure). Вместо того чтобы скопировать значение переменной i в лямбда-выражение на каждом шаге цикла, компилятор захватывает ссылку на саму переменную i. Когда цикл завершается, значение переменной i становится равным 5.
    И когда мы позже вызываем наши сохраненные функции, они все смотрят на одну и ту же ячейку памяти, где лежит 5. Для человека это идиотизм, а для компилятора C# — строгое выполнение стандарта.
    
    и так далее
    

    PS Сам Том - никогда не проектировал и не разработывал ни языки, ни компляторы, это типичный самоучка-“юниксоид” из 1990-х. Посмотрите на код Тома. Если так писать, - то “UB” точно не обберёшься. https://github.com/ThomasHabets/arping/blob/arping-2.x/src/arping.c


    1. PatientZero
      24.05.2026 09:19

      Зачем этот слоп на Хабре?


      1. MasterMentor
        24.05.2026 09:19

        А чем "слоп" отличается от знаний?
        Психоз вокруг С++ очень похож на психоз gotoненавистничества - Двухминуток ненависти (англ. Two Minutes Hate), когда не умеющие писать код граждане клеймили во всех бедах goto. Теперь козлом отпущения назначен Си.

        PS Я хотя бы код Хабетса открыл и посмотрел как он пишет. Типичный прикладник, без квалификации в области разработки языков или компиляторов.

        И вы, к примеру, в Яндекс отправьте на ревью и его код и эту феноменальную статью. Уверен, узнаете много интересного.


    1. MasterMentor
      24.05.2026 09:19

      И ещё интересно было бы узнать, что Том думает об этих флагах компилятора? ну, зачем они? что делают?

      -fsanitize=undefined
      -fsanitize=thread
      -fsanitize=memory
      -fsanitize=address
      


  1. Newpson
    24.05.2026 09:19

    Деление на нуль — это UB

    только для целочисленного деления. В случае чисел с плавающей точкой существуют ±inf и NaN.


    1. SIISII
      24.05.2026 09:19

      Бесконечности и НаНы не везде существуют, если рассматривать всю совокупность имеющихся платформ.


    1. haqreu
      24.05.2026 09:19

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


    1. TimurZhoraev
      24.05.2026 09:19

      всё зависит от реализации команды DIV, для x86 генерирует int 0 и не меняет регистры, ARM возвращает 0, DSP/MIPS иногда (!) дают 0 или 0xFFF... максимум. Для плавающей запятой ещё есть число EPS, которое можно инъецировать в любые сомнительные дроби чтобы можно было досчитать симуляцию с гигавольтами и тераамперами.


  1. TimurZhoraev
    24.05.2026 09:19

    "С" сейчас - это самый совершенный аппаратно-независимый ассемблер, включая диалекты OpenMP/CL/CUDA. Вот тебе память вот тебе калькулятор, далее управляй сам, флажками, агентами, счётчиками - это всё явно и более чем работает. Это вы ещё UB в Verilog/VHDL не заглядывали, особенно когда тайминги и прочие аппаратно-зависимые фишки лезут. Так что ручное, псевдоручное и вайб-управление памятью это то что нужно для bare metal проектов. На всё остальное - Питон.


  1. ImagineTables
    24.05.2026 09:19

    На Linux Alpha это иногда приводило к вызову аппаратного исключения […] На архитектуре SPARC это гарантированно приводило к SIGBUS.

    Ну и где они теперь?

    Кстати, SPARC делала Sun. Может, поэтому они и изобрели Java, где нет адресной арифметики?