All streams
Search
Write a publication
Pull to refresh

Comments 26

Вопрос - а почему это именно Undefined, а не Implementation Defined Behavior ?! В существующей трактовке (правда, этим славятся C++ компиляторы - в C это не так явно выражено) - UB это снятие любых тормозов компилятора, и возможность приписать коду с UB любые свойства, удобные для дальнейшей оптимизации. Опять же, C++ предпочитает в силу тяжелой судьбы оптимизировать код методом удаления - поэтому код с UB обычно приводит к исчезновению целых кусков того, что вы написали в исходнике...

ИМХО тут в чистом виде Implementation Defined, а не UB. Конструкция легальная, код должен быть сгенерирован - а уж сработает он или нет - читайте руководство к своему компилятору и процессору...

Например, в стандарте C11 есть такое: A pointer to an object or incomplete type may be converted to a pointer to a different object or incomplete type. If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined

Аааа... вот где собака порылась!

Вы не правильно перевели фразу с английского.

If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined

Это не имеет ничего общего с кратностью адреса. Более правильным переводом для aligned for reference type - будет: "сочетается с используемым типом".

В вашем первом примере у вас

int *p = (int *)(argv[0] + argc);

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

Если вам хочется, то можно легко увидеть работу с адресами не кратными четырем на таком примере:

struct __attribute__((packed)) mystruct {
   int i1;
   char c;
   int i2;
};

int main(int argc, char **argv) {
    struct mystruct a = {1, 'A', 2};
    printf("&a.i1=%p a.i1=%d\n", &a.i1, a.i1);
    printf("&a.c=%p a.c=%c\n", &a.c, a.c);
    printf("&a.i2=%p a.i2=%d\n", &a.i2, a.i2);
    return 0;
}

Вы удивитесь, но адрес `a.i2` в этом примере не кратен четырём, но всё работает.

Статья как раз о том что так ("берем адрес указывающий на первый символ строки (которая содержит имя исполняемого файла), прибавляем к нему единицу и пытаемся прочитать как число") делать не надо, а надо использовать memcpy (который обычно прекрасно оптимизируется) или __attribute__(packed)

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

Как обычно: потому что UB компилятор воспринимает, как контракт с программистом ("я никогда не буду этого делать"), и может, исходя из этого контракта, применять какие-то оптимизации. Нет никаких других причин, чтобы что-то было UB, а не implementation-defined.

Причина простая, на каком-то железе это тупо упадет

Это было бы implementation-specific. Описано, что на этом железе падает – значит, падает.

Но штука именно в том, что программист обязан дать компилятору гарантию, что UB не будет. Например, есть у нас указатель, мы проверяем его младший бит и если тот равен 1 – выполняем какой-то код. Ok, работает. А теперь добавляем после if разыменование этого указателя – фигак, и наш код волшебным образом вырезало, потому что теперь указатель "гарантированно" кратен 4 (или 8).

А был бы implementation-defined – прога бы выполнила наш код, а потом упала.

в каких-то случаях проверить что адрес выравнен (потратить немного тактов на это) действительно может иметь смысл если дальше идут "тяжелые" вычисления, это делается например здесь https://github.com/torvalds/linux/blob/c8bc81a52d5a2ac2e4b257ae123677cf94112755/arch/riscv/lib/csum.c#L306 . Но встраивать это в стандарт C никто не собирается, потому что это обяжет компиляторы добавлять такие проверки везде, даже там где данные выравнены (приходят такими извне), но просто доказать это на этапе компиляции невозможно

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

if (p&1) puts("bad pointer");
x = *p;

имеет право не напечатать bad pointer перед падением.

И вот этого я уже не понимаю. Есть код, который написал разработчик. Задача компилятора - сгенерировать эквивалентный ему машинный код! Это же даже не цикл, где можно вынести инвариант за тело, и сэкономить много машинных тактов. И не assert который можно выключить в продакшн-версии... Если компилятору даются такие широкие права по интерпретации намерений программиста (зачем?!) - надо вводить в язык новое ключевое слово, которое заставляет компилятор сгенерировать код следующего оператора (или блока) "как тут написано": verbatim if(p&1)...)

Затем же, зачем компилятор имеет право выкинуть код для

if (0&p) puts("bad pointer")

В моем примере компилятор знает, что 0&p - всегда 0, так и в вашем он тоже знает, что p&1 - всегда 0, потому у типа int есть требования по выравниванию.

Хотите, чтобы компилятор не учитывал требования по выравниванию - скажите об этом компилятору явно:

if (((char *)p)&1) puts("bad pointer")

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

Вот я не понимаю логику этого решения. Сказать - unaligned access является implementation defined - понимаю. Дальше разработчик компилятора для конкретной платформы должен будет выбрать и задокументировать поведение в такой ситуации, и дальше консистентно его придерживаться. Это никак не ограничивает язык на конкретной платформе, ибо мы не диктуем единственно-правильное поведение (которое действительно на каком-то железе может быть очень дорого, или даже невозможно! реализовать). Например, разработчик компилятора может написать что для int * всегда генерируются машинные команды aligned access. Если там будет невыравненное значение - произойдет прерывание. Или он может добавить платформ-зависимые средства (ключ компиляции, дополнительный атрибут или intrinsic) чтобы сгенерировать медленный но всегда валидный код для unalgned access. Суть в том - что находясь на конкретной платформе - вы можете прочитать как это тут будет работать! И оно так будет работать всегда, а если изменится - то опять же об этом будет написано в документации.

А UB - это очень сильное утверждение: так никогда не может быть. И поэтому код с UB эквивалентен любому другому куску кода (в том числе пустому)... И дальше начинаются странные оптимизации, когда от изменения одного символа в исходном коде - у вас из объектного файла пропадает половина программы... Опять-таки, это больше бочка в сторону C++, а C был всегда более консервативен - и компилировал то, что программист написал, а не "абстрактную программу которая эквивалентна написанному программистом, но только лучше" (c). Плюс - что происходит с кодом UB - нигде не документируется, и компилятор может (формально - оставаясь той же версии) сегодня компилировать так, а завтра - этак...

И я не понимаю - с какой целью это сделано ? Тяжелое наследие гонок компиляторов за наносекундами в бенчмарках ?

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

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

Ну и даже в чистом C, насколько я помню, UB меньше, чем в C++ (но всё равно хватает).

А на крестиках и на це ваш выбор – писать без UB.

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

Я пытаюсь понять - какие возможности для оптимизации закрывает unaligned access как implementation defined behavior ? Язык программирования С - делался как "переносимый ассемблер". Соответственно, он должен определить подмножество операций, которые гарантированно одинаково выполняются на всех поддерживаемых платформах. А все странности и ограничения платформ - оставьте разработчику компилятора, там наверное разберутся что с этим делать ? А встраивать это как UB прямо в язык (то есть гарантировать что такая конструкция нигде не является корректной и ее можно как хочешь оптимизировать) - я не понимаю...

Много лет прошло, уже давно не "переносимый ассемблер". Вот и...

А разве результатом implementation-defined behavior может быть падение? Кажется, тут предполагается только результат, который в итоге даст программе успешно завершиться.

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

В этом смысле, я вижу три вида конструкций:

  • Для которых язык предусматривает определенный результат (i++ увеличивает i на 1). И даже если вы работаете на какой-то странной платформе где можно только прибавлять два и вычитать один, то вам придется сделать неэффективную кодогенерацию - но i++ должно увеличить переменную на один, а не на два и не на ноль.

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

  • Которые синтаксически валидны, но с точки зрения языка не имеют смысла - это как раз UB.

Первая категория - абсолютно понятна. Это - собственно то, для чего создается переносимый язык. Вторая - тоже понятна. Это то - чего нельзя предусмотреть в языке (если мы настаиваем на компиляции в машинный код). Третья категория мне понятна мало. Особенно - как именно решается: UB это - или implementation defined! И уже совсем мне непонятно - в какой момент (а главное - зачем?!) UB стало пониматься разработчиками компиляторов как карт-бланш на применение любых оптимизаций (из которых самая лучшая - кончено же выкидывание кода из генерации, несмотря на то что программист его в исходном файле буквами написал!)...

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

Разделение на implementation defined и undefined behavior (а еще есть unspecified и local-specific behavior) сделано на основе опыта разарботчиков стандартов (а у тех опыт разработчиков компиляторов). Где поведение нельзя сделать единообразным, но можно и осмысленно зафиксировать для конкретной платформы - там implementation defined. Там где оно точно не упадет, но результат неизвестен - стандарт описывает возможные варианты, а какой сработает - заранее сказать нельзя, и фиксировать это в документации не особо осмысленно - unspecified. Implementation defined - это частный случай unspecified, кстати, тогда, когда можно осмысленно требовать фиксации поведения.

А последний случай - когда разнообразие существующих вариантов не позволяет гарантировать, что возможно вообще (с приемлемым уровнем оверхеда) реализовать что-то осмысленное - там undefined. Это типа деления на ноль - какой тут результат может получиться? Ну или невыровненный доступ - слишком дорого генерировать код для некоторых платформ, который это реализует. Это место объявляют undefined. Это вообще-то то, что стандартом запрещено, просто это может случиться в runtime, поэтому компилятор это не отловит, а runtime проверки - опять очень дорого.

А дальше логика разработчика комплиятора - если он знает, что чего-то в программе может не случиться - он это может использовать для оптимизации. Вот как if (0&p) ... известно, что не исполниться, то что после if, так зачем генерировать код. Так и ограничения на defined behavior с точки зрения компилятора - это такие же известные заранее результаты.

На Intel процессорах можно через флажки включить BUS ERROR для невыровненных данных, я когда раньше писал код для Sparc процессоров, пользовался этим, чтоб не попасть впросак.

#include <stdio.h>

int main()
{
# if defined(__i386__)
    /* Enable Alignment Checking on x86 */
    __asm__("pushf\norl $0x40000,(%esp)\npopf");
# else
    /* Enable Alignment Checking on x86_64 */
    __asm__("pushf\norl $0x40000,(%rsp)\npopf");
# endif

    int a = 0xffffff;
    char *c = (char*)&a;
    c++;
    int *p = (int*)c;
    *p = 10;  //Bus error as memory accessed by p is not 4 or 8 byte aligned
    printf ("%x\n", *p);
}

Всё никак руки не дойдут найти то место в коде UBSAN где отключается (или почему оно там не детектится) проверка alignment на x86_64. Если собрать с -fsanitize=undefined под arm64 (или riscv64), то UBSAN сообщает о misaligned address.

Потому что alignment на большинстве платформ не является обязательным. На x86_64 он желателен с точки зрения работы с кешами, но вовсе не обязателен.

А то что UBSAN вам сообщает - это не undefined behavior а слегка усложненная оптимизация работы с памятью. К стандарту языка это вообще никак не относится.

Да, некоторые платформы не могут использовать не-кратные адреса. Но как и у автора статьи у вас идёт подмена типов - а это и есть источник ошибки. При нормальном написании программы - char данные читаем через char*, а int данные через int* - даже на Spark не будет проблем. Но если вы принудительно путаете типы данных - ну увы...

попробовал локально на ubuntu 24.04 clang-19 из реп ubuntu и clang-21 из реп apt.llvm.org, в обоих случаях ubsan не отрабатывает. спасибо, посмотрю в чем дело. кстати, на gcc 15.2 (на godbolt) ubsan тоже молчит

Здравствуйте. А на каких основаниях сделан вывод что пример с атриубтом packed не является UB? Я в стандарте не нашел ниодного даже упоминания про этот атрибут, т.е. максимум чем это может быть это implementation-defined (т.к. добавляется расширениями компиляторов)?. Но если я не прав, то пожалуйста поясните, буду благодарен.

да, это не стандарт, а gnu extension. если не хочется играть с огнем (и потом переписывать это под другой компилятор, не поддерживающий этот gnu extension или реализующий его по-другому), то тогда можно использовать memcpy, главное убедиться что он оптимизируется (c FORTIFY_SOURCE в примере с векторизацией этого не происходит)

Sign up to leave a comment.

Articles