Pull to refresh

Comments 60

Мы видим, что glibc typedef-ит int8_t и uint8_t для signed char и unsigned char соответственно

но, как всегда, есть нюанс. char это тип, отличный и от unsigned char, и от signed char. Более того, даже знаковость char не определена стандартом.

AliasedType is std::byte, (since C++17)char, or unsigned char: this permits examination of the object representation of any object as an array of bytes.

cppreference. Strict aliasing распространяется на signed char/int8_t!
Не освещен std::launder, который вроде как для отмены алиасинга появился.
UFO just landed and posted this here
Этот ответ предлагает reinterpret_cast с нарушением strict aliasing'а как один из примеров применения. Кто не прав?
Со слов автора, std::launder как раз «заставляет компилятор забыть откуда взялась память, отключая все связанные с этим оптимизации компилятора», в т.ч. и strict aliasing.
UFO just landed and posted this here
Мне тоже неочевидно, поэтому я и хотел бы видеть это в статье.
В cppreference тоже есть кусок с reinterpret_cast:
  alignas(Y) std::byte s[sizeof(Y)];
  Y* q = new(&s) Y{2};
  const int f = reinterpret_cast<Y*>(&s)->z; // Class member access is undefined behavior:
                                             // reinterpret_cast<Y*>(&s) has value "pointer to s"
                                             // and does not point to a Y object 
  const int g = q->z; // OK
  const int h = std::launder(reinterpret_cast<Y*>(&s))->z; // OK
Это всё можно просто проверить. Если в уже приведённом примере
int foo( float *f, int *i ) { 
    *i = 1;               
    *f = 0.f;            
   
   return *i;
}

добавить лишь reinterpret_cast с float* на int*
int foo( float *f, int *i ) { 
    *i = 1;               
    *f = 0.f; 
           
    int *p = reinterpret_cast<int*>(f);
    *p = 2;

   return *i;
}

то будет генерироваться код без strict aliasing.

Таким образом, дополнительная обёртка reinterpret_cast'а в std::launder принципиально ничего не добавит в подобных случаях.
Эээ, почему-то это вдруг будет код без алиасинга? Сам по себе reinterpret_cast не делает этот код не-UB.
Компилятор генерирует такой код:
foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 2
mov eax, dword ptr [rsi]
ret
Это вообще ничего не значит. Если в коде UB, то компилятор может генерировать любой код, в том числе ожидаемый человеком.
Попробуйте оптимизацию повключать, вполне возможно, что результат изменится.

Но даже если нет — это не показатель отсутствия UB.
Вы можете сами сходить по ссылке и попробовать повключать. Это результат c -O3. Сформулируйте что вы подразумеваете здесь под UB. То, что по адресу переменной float пишется значение int — это ещё не момент UB. Оно туда вполне определенно тупо запишется коль скоро размер позволяет. UB наступит когда из float будет читаться значение. Я же говорю про алиасинг в коде ф-ции foo.
Насколько я понимаю стандарт, UB наступает ровно в момент reinterpret_cast'a. Все, что происходит после неопределено никак.
То, что сейчас компилятор генерирует код, который делает что-то вменяемое — счастливая случайность.

Допустим, если слегка поменять типы, а архитектуру выбрать ARM Cortex-M3, то можно устроить вот такое:
char array[8] = {0};
double * d = reinterpret_cast<double *>(array);
*d = 8.8;
double dd = *d;

Тут с вероятностью 7/8 ошибка будет уже при записи 8.8 по указателю d, потому что на этой архитектуре доступ к double должен быть выровнен по границе 8 байт.

На мой взгляд, ваш код принципиально ничем не отличается и будет работать просто потому, что конкретная архитектура вам это позволяет сделать.
Но если копнуть, вполне может оказаться, что не всегда. Например, могут найтись такие значения int, которые будут соответствовать некорректным значениям для float (что-нибудь в районе двоичного представления NaN, например), что породит исключение. Или типа того.
Тут UB вызывается доступом по не выровненному указателю. Поучительный пример для x86 описывается здесь.
Собственно, единственная причина, по которой можно создать такой указатель — нарушение strict aliasing'a :) Вот в какой точно момент он нарушается — сразу при создании или при первом применении (не важно, на чтение или на запись) — тут судить не берусь, это уже нюансы.
Собственно, единственная причина, по которой можно создать такой указатель — нарушение strict aliasing'a :)
Ну, не стал бы так категорично утверждать. И в любом случае кошерное обертывание в std::launder в таких случаях (не выровненный доступ) не спасёт.
Ну, не стал бы так категорично утверждать.

А можете пример без нарушения придумать?

И в любом случае кошерное обертывание в std::launder в таких случаях (не выровненный доступ) не спасёт.

Ну, это понятно, да. Видимо, предполагается, что тут программист просто говорит компилятору — «спокуха, я знаю, что делать».
Собственно, единственная причина, по которой можно создать такой указатель — нарушение strict aliasing'a :)
Бремя доказательства лежит на утверждающем. Хотя может быть это и так.
Ну, я имел в виду конкретно ваш пример. Но я не могу придумать другого способа получить невалидный указатель, кроме как кастом из другого типа или другого «неправильного» доступа, через union, допустим.
union выравнивается по наиболее строгой границе своих членов как раз специально для правильного к ним доступа
Да, в union'e проблем с выравниваем действительно нет. Но вот доступ «не к тому» полю union'a — тоже UB, по тем же причинам, что и reinterpret_cast «не к тому» указателю.
UFO just landed and posted this here
Доказательство UB or not UB очень простое — см. стандарт, остальное от лукавого компилятора.
Тут с вероятностью 7/8 ошибка будет уже при записи 8.8 по указателю d, потому что на этой архитектуре доступ к double должен быть выровнен по границе 8 байт.

А ничего, что в cortex-M3 нет fpu в принципе? В cortex-M4 фпу бывает, но тоже только single precision.
Отсутствие fpu дает возможность кастовать к float'у, потому что float — 4 байта и читается обычной ldr, которая поддерживает невыровненный доступ. Поэтому я и писал про double :)
А вот double читается командой ldrd, которая поддерживает только выровненный доступ. Правда, я слегка приврал, надо, чтобы было выровнено по 4, не по 8, но не суть.
Пруф.

Вот на Cortex-M4F, где есть fpu, уже и float'ы трогать через кривой указатель нельзя, fpu в невыровненный доступ совсем не умеет.
и читается обычной ldr, которая поддерживает невыровненный доступ

Тут еще такой момент, что поддержку невыровненного доступа можно процессору отключить. static.docs.arm.com/ddi0403/ec/DDI0403E_c_armv7m_arm.pdf страница 601
Сказанного мной это вроде как не меняет.
Сказанное вами кстати хорошая иллюстрация того, чем 'должно быть' UB — компилятор честно сгенерировал код, а процессор не смог выполнить, например не смог невыровненный адрес считать. А не так, что если разыменовали указатель то теперь компилятор может считать, что он не равен NULL.
На мой взгляд UB ничем конкретным «не должно» быть.
UB возникает, когда программист нарушает некий контракт, на который рассчитывает компилятор (или код, или процессор — не суть).
И компилятор (или код, или процессор) просто продолжает работать, исходя из того, что контракт не нарушался.

Вот, допустим, есть у вас функция, которая принимает на вход нуль-терминированную строку. А вы ей подсунули строку без нуля на конце. И что она будет делать? Проверить, есть ли там нуль нельзя, длина ведь заранее неизвестна. Так что функция просто работает, как будто все в порядке.

Именно поэтому UB открывает дорогу оптимизациям — компилятор получает право делать далеко идущие выводы о том, как будет выполняться код, не проверяя этого ни на этапе компиляции, ни в рантайме. А просто рассчитывая, что программист соблюдает этот самый контракт.
UFO just landed and posted this here
reinterpret_cast для указателей сам по себе не генерирует никакого кода; это чисто compile-time директива (так по крайней мере пишут в описаниях). Если у вас есть указатель на область памяти достаточного размера и правильно выровненную для приёма некоторого значения, то вы говорите, что запись этого значения по этому указателю — UB?
UFO just landed and posted this here
Вы выходите за границы массива, это явный UB, не относящийся к теме, поэтому я просто хочу уточнить, а здесь по-вашему будет UB?
int arr[5];
arr + 1;

По сходным причинам, кстати, union-style cast — тоже UB.
UB возникает только когда вы пишите по одному полю, а читаете из другого.
UFO just landed and posted this here
Потому что по в одно поле можно записать представление, которое по другому полю может быть трапом? А может и не быть…
Можете пояснить будет ли мой случай нарушать strict aliasing. Пишу абстрактный вектор на C11, чтобы можно было использовать [] структура вектора в памяти такая:
+------+----------+---------+
| size | capacity | data… |
+------+----------+---------+

Пользовательский указатель указывает на data. В ряде функций, связанных с этим вектором необходимо получить size. Для этого необходимо от переданного пользовательского указателя отнять 16 байт и считать/записать size_t. Будет ли нарушать правило strict aliasing следующий код?
((size_t *)(vec))[-2] = size;
Да, если тип size_t будет несовместимый с эффективным типом data.
Одно время долго ломал голову над проблемой strict aliasing, некоторые писали что большинство кодовой базы на github нарушает это правило. Но после долгих раздумий я пришел к выводу, что единственный способ получить UB — это запись и чтение в одном и том же участке памяти в пределах одной функции (включая inline). А встретить такое в реальных проектах довольно сложно.

Для UB достаточно чтения с 2 указателей разного типа. А откуда инфа, что только в пределах 1 функции?! Или это просто наблюдения нескольких компиляторов. Если второе, то всё плохо. Вон, люди memcpy для заполнения памяти последовательностью использовали. Везде работало, а потом раз и перестало. С UB шутки плохи, ибо оно проявляется неожиданно и может долгое время не давать о себе знать.

Как может проявляться UB при чтении из 2 указателей? Компилятор просто выкинет весь код со вторым указателем? Все примеры нарушения strict aliasing что я видел обязательно содержат запись. Насчет одной функции, то каждая функция ведь компилируется отдельно, и затем в других участках кода просто производится ее вызов. Для гарантии от инлайна функции можно помещать в разные единицы компиляции, и конечно же не включать link-time optimization.
Например, у этих указателей могут быть разные требования к выравниванию данных. И при чтении из второго будет невыровненный доступ и исключение.
А при чем здесь strict aliasing? Тут уж программист должен следить за выравниванием.
Я вам привел пример, при котором чтение по указателю может привести к ошибке. Создать указатель, с помощью которого вы сможете сделать такое невыравненное чтение, можно только нарушив strict aliasing.
Если его не нарушать, то и за выравниванием следить не придется.
а у вас strict aliasing на сколько байт?

Извините, не понимаю вопрос. strict aliasing разрешает (если я правильно помню):


  • касты к char * и обратно
  • касты к void * и обратно
  • касты к signed/unsigned варианту текущего типа
  • касты к const варианту текущего типа и обратно

Вроде все. Все остальное — UB. Причем тут "на сколько байт"?

да просто вспомнился случай, как в одном проекте на одну странную платформу, писали код. Все вроде ОК, вот только в одном месте обнаружилось что printf float печатает, а double — нет (вернее печатает, но фигню всякую).
Поколупались — оказалось местный libc стек выравнивает (точных чисел не помню уже, пусть будет 4 и 8) не на 8 а на 4 байта.
Долго не страдали, решили даблы тут не печатать (вот такая платформа, что проще было забить).
Эээ, но printf же вроде вообще не умеет float печатать, только double.
mea culpa, точно — это был long double (и кстати long long там тоже был поломан).
Сейчас погуглил — нашел похожий случай (но точно другой, т.к. у нас была не виндовс)
А как же касты uintptr_t в указатель и обратно?
Ну, я что вспомнил, то и написал. Суть везде одна и та же, что можно кастовать указатель только к определенным типам и обратно. Нельзя взять произвольный тип и к нему кастовать.
У вас ошибка. strict alliasing позволяет кастовать к любому типу. Запрещено лишь разыменовывать указатель. Сам по себе cast к UB не приводит.
Да, виноват, не уточнил.
почему же сложно?
вот было у нас пара реальных проектов — перевести кодовую базу из проекта, в котором int был 16-битный на 32-битный. А потом другой проект — с 32-битной архитектуры на 64-битную архитектуру (а там указатели были на это завязаны).
во-вторых, будет у вас либа c ABI отличающемся от ABI принятой в вашем проекте (старая или проприетарная, от которой у вас только только хедер) — тоже легко словить интересное поведение в рантайме.
int foo( volatile float *f, volatile int *i ) { 

Теперь компилятор обязан произвести обе записи. Или...?

f и i не должны ссылаться на один и тот же блок памяти, ибо они имеют разный тип. volatile не убирает UB. А UB означает, что может произойти, что угодно.

Зато volatile вынуждает компилятор делать то что сказано — а именно произвести записи по переданным в функцию адресам.
После того, как произошло UB компилятор может не делать «то что сказано».

Почему-то не упомянули атрибут may_alias (GCC, Clang), который отключает анализ псевдонимов на совместимось типов, как один из самых простых способов произвести необходимые приведения типов без отключения -fstrict-aliasing:


/* This type lifts strict aliasing restrictions */
typedef
    uint64_t __attribute__((__may_alias__))
    uint64_aliased_t;

Это пригодилось для реализации быстрого копирования небольших выровненных массивов:


/* Aligned copy example;
 * dst and src pointers may have any effective type
 * (alignment issues aside)
 */
uint64_aliased_t *dst_ = dst;
uint64_aliased_t const *src_ = src;

for (size_t i = 0; i < size_; ++i)
    dst_[i] = src_[i];

Без этих атрибутов копирование, естественно, не работало для всех указателей dst и src, у которых эффективный тип был отличен от char. В частности, компилятор не выполнял копирование в том случае, когда один из указателей был получен в результате сведения (array type decay) массива alignas(8) uint32_t.

memcpy тоже быстро копирует. В GCC вроде он не функция, а built-in

Согласен, если в качестве единственного компилятора используется GCC (или Clang), то собственная реализация копирования памяти в большинстве случаев не потребуется. Но мне было необходимо поддерживать и другие компиляторы, у которых нет inline версии memcpy(), и для этих компиляторов использование своего копирования позволило увеличить производительность операции почти втрое. А чтобы код был один и тот же везде, для компиляторов, поддерживающих анализ совместимости типов, пришлось добавить атрибуты may_alias.

Sign up to leave a comment.