(ИЛИ каламбур типизации, неопределенное поведение и выравнивание, о мой Бог!)

Друзья, до запуска нового потока по курсу «Разработчик С++», остается совсем немного времени. Пришло время опубликовать перевод второй части материала, в которой рассказывается о том, что такое каламбур типизации.

Что такое каламбур типизации?

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



Иногда мы хотим обойти систему типов и интерпретировать объект как другой тип. Переинтерпретация сегмента памяти в качестве другого типа называется каламбуром типизации (type punning). Каламбуры типизации полезны для задач, которым требуется доступ к базовому представлению объекта для просмотра, транспортировки или манипулирования предоставленными данными. Типичные области, в которых мы можем встретить использование каламбуров типизации: компиляторы, сериализация, сетевой код и т.д.
Традиционно это достигалось путем взятия адреса объекта, приведения его к указателю типа, к которому мы хотим проинтерпретировать, и затем доступа к значению, или другими словами, с помощью псевдонимов. Например:

int x =  1 ;

// В языке C
float *fp = (float*)&x ;  // Недопустимый алиасинг

//В языке C++
float *fp = reinterpret_cast<float*>(&x) ;  // Недопустимый алиасинг

printf( “%f\n”, *fp ) ;

Как мы видели ранее, это недопустимый алиасинг, этим мы вызовем неопределенное поведение. Но традиционно компиляторы не использовали правила строгого алиасинга, и этот тип кода обычно просто работал, а разработчики, к сожалению, привыкли допускать такие вещи. Распространенный альтернативный метод каламбура типизации — через объединения (union), что допустимо в C, но вызовет неопределенное поведение в C ++ (см. пример):

union u1
{
  int n;
  float f;
} ;

union u1 u;
u.f = 1.0f;

printf( "%d\n”, u.n );  // UB(undefined behaviour) в C++ “n is not the active member”

Это недопустимо в C ++, и некоторые считают, что объединения предназначены исключительно для реализации вариантных типов, и считают, что использование объединений для каламбуров типизации является злоупотреблением.

Как правильно реализовать каламбур?

Стандартный благословенный метод для каламбуров типизации в C и C ++ — memcpy. Это может показаться немного сложным, но оптимизатор должен распознавать использование memcpy для каламбура, оптимизировать его и генерировать регистр для регистрации перемещения. Например, если мы знаем, что int64_t имеет тот же размер, что и double:

static_assert( sizeof( double ) == sizeof( int64_t ) );  // C++17 не требует сообщения

Мы можем использовать memcpy:

void func1( double d ) {
  std::int64_t n;
  std::memcpy(&n, &d, sizeof d); 
  //…

При достаточном уровне оптимизации любой приличный современный компилятор генерирует код, идентичный ранее упомянутому методу reinterpret_cast или методу объединения для получения каламбура. Изучая сгенерированный код, мы видим, что он использует только регистр mov (пример).

Каламбур типизации и массивы

Но что, если мы хотим реализовать каламбур массива unsigned char в серию unsigned int и затем выполнить операцию с каждым значением unsigned int? Мы можем использовать memcpy, чтобы превратить массив unsigned char во временный тип unsinged int. Оптимизатору все равно удастся увидеть все через memcpy и оптимизировать как временный объект, так и копию, и работать непосредственно с базовыми данными, (пример):

// Простая операция, возвращающая значение обратно
int foo( unsigned int x ) { return x ; }

// Предположим, что len кратно sizeof(unsigned int) 
int bar( unsigned char *p, size_t len ) {
  int result = 0;

  for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
    unsigned int ui = 0;                                 
    std::memcpy( &ui, &p[index], sizeof(unsigned int) );

    result += foo( ui ) ;
  }

  return result;
}

В этом примере мы берем char*p, предполагаем, что он указывает на несколько фрагментов sizeof(unsigned int)-данных, интерпретируем каждый фрагмент данных как unsigned int, вычисляем foo() для каждого фрагмента каламбура, суммируем это в result и возвращаем окончательное значение.

Сборка для тела цикла показывает, что оптимизатор превращает тело в прямой доступ к базовому массиву unsigned char как unsigned int, добавляя его непосредственно в eax:

add     eax, dword ptr [rdi + rcx]

Тот же код, но с использованием reinterpret_cast для реализации каламбура (нарушает строгий алиасинг):

// Предположим, что len кратно sizeof(unsigned int) 
int bar( unsigned char *p, size_t len ) {
 int result = 0;

 for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
   unsigned int ui = *reinterpret_cast<unsigned int*>(&p[index]);

   result += foo( ui );
 }

 return result;
}

C ++ 20 и bit_cast

В C++20 у нас есть bit_cast, который дает простой и безопасный способ интерпретирования, а также может использоваться в контексте constexpr.

Ниже приведен пример того, как использовать bit_cast для интерпретирования беззнакового целого числа в float (пример):

std::cout << bit_cast<float>(0x447a0000) << "\n" ; //предполагая, что sizeof(float) == sizeof(unsigned int)

В случае, когда типы To и From не имеют одинакового размера, это требует от нас использования промежуточной структуры. Мы будем использовать структуру, содержащую символьный массив кратный sizeof(unsigned int) (предполагается 4-байтовый unsigned int) в качестве типа From, а unsigned int — в качестве типа To .:

struct uint_chars {
 unsigned char arr[sizeof( unsigned int )] = {} ;  // Полагая sizeof( unsigned int ) == 4
};

// Полагая len кратное 4 
int bar( unsigned char *p, size_t len ) {
 int result = 0;

 for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
   uint_chars f;
   std::memcpy( f.arr, &p[index], sizeof(unsigned int));
   unsigned int result = bit_cast<unsigned int>(f);

   result += foo( result );
 }

 return result ;
}

К сожалению, нам нужен этот промежуточный тип — это текущее ограничение bit_cast.

Alignment

В предыдущих примерах мы видели, что нарушение правил строгого алиасинга может привести к исключению хранилищ во время оптимизации. Нарушение строгого алиасинга также может привести к нарушению требованиям выравнивания. Как в стандартах C, так и в C ++ говорится, что к объектам предъявляются требования по выравниванию, которые ограничивают место, где объекты могут быть размещены (в памяти) и, следовательно, доступны. C11 раздел 6.2.8 Выравнивание объектов гласит:

Полные типы объектов имеют требования выравнивания, которые накладывают ограничения на адреса, по которым могут быть размещены объекты этого типа. Выравнивание — это определенное реализацией целочисленное значение, представляющее число байтов между последовательными адресами, по которым данный объект может быть размещен. Тип объекта накладывает требование выравнивания на каждый объект этого типа: более строгое выравнивание можно запросить с помощью ключевого слова _Alignas.

Стандарт проекта C ++17 в разделе 1 [basic.align]:

Типы объектов имеют требования к выравниванию (6.7.1, 6.7.2), которые накладывают ограничения на адреса, по которым может быть размещен объект этого типа. Выравнивание — это определенное реализацией целочисленное значение, представляющее число байт между последовательными адресами, по которым данный объект может быть размещен. Тип объекта накладывает требование выравнивания на каждый объект этого типа; Более строгое выравнивание может быть запрошено с помощью спецификатора выравнивания (10.6.2).

И C99, и C11 явно указывают на то, что преобразование, которое приводит к невыровненному указателю, является неопределенным поведением, раздел 6.3.2.3. Указатели говорит:
Указатель на объект или неполный тип может быть преобразован в указатель на другой объект или неполный тип. Если результирующий указатель не правильно выровнен для указательного типа, поведение не определено. …
Хотя C++ не такой очевидный, я считаю, что этого предложения из пункта 1 [basic.align] достаточно:
… Тип объекта накладывает требование выравнивания на каждый объект этого типа; …
Пример

Итак, давайте предположим:

  • alignof(char) и alignof(int) равны 1 и 4 соответственно
  • sizeof(int) составляет 4

Таким образом интерпретация массива char размера 4 как int нарушает строгий алиасинг, а также может нарушать требования выравнивания, если массив имеет выравнивание в 1 или 2 байта.

char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; // Может быть размещен на с интервалом в 1 или 2 байта
int x = *reinterpret_cast<int*>(arr);   // Undefined behavior невыровненный указатель

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

alignas(alignof(int)) char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; 
int x = *reinterpret_cast<int*>(arr);

Атомарность

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

Отлов нарушений строгого алиасинга

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

gcc с использованием флагов -fstrict-aliasing и -Wstrict-aliasing может отлавливать некоторые случаи, хотя и не без ложных срабатываний/неприяностей. Например, следующие случаи сгенерируют предупреждение в gcc (пример):

int a = 1;
short j;
float f = 1.f; // Первоначально не инициализирован, но ядро TIS обнаружило, что к нему обращаются с неопределенным значением ниже

printf("%i\n", j = *(reinterpret_cast<short*>(&a)));
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));

хотя он не поймает этот дополнительный случай (пример):

int *p;

p=&a;
printf("%i\n", j = *(reinterpret_cast<short*>(p)));

Хотя clang разрешает эти флаги, он, по-видимому, на самом деле не реализует предупреждения.

Еще один инструмент, который у нас есть, — ASan, который может улавливать не выровненную запись и хранение. Хотя они не является прямыми нарушениями строгого алиасинга, это довольно распространенный их результат. Например, следующие случаи будут генерировать ошибки времени выполнения при сборке с помощью clang с использованием -fsanitize=address

int *x = new int[2];               // 8 байт: [0,7].
int *u = (int*)((char*)x + 6);     // вне зависимости от выравнивания xэтоне будет выровненным адресом
*u = 1;                            // Доступ к диапазону [6-9]
printf( "%d\n", *u );              // Доступ к диапазону [6-9]

Последний инструмент, который я порекомендую, специфичен для C++ и, по сути, не только инструмент, но и практика кодирования, не допускающая приведение в стиле C. И gcc, и clang будут производить диагностику для приведения в стиле C с использованием -Wold-style-cast. Это заставит любые неопределенные каламбуры типизации использовать reinterpret_cast. В общем случае reinterpret_cast должен быть маячком для более тщательного анализа кода.
Также проще выполнять поиск в базе кода по reinterpret_cast, чтобы выполнить аудит.

Для C у нас есть все инструменты, которые уже описаны, и у нас также есть tis-interpreter, статический анализатор, который исчерпывающе анализирует программу для большого подмножества языка C. Учитывая C-версии предыдущего примера, где использование -fstrict-aliasing пропускает один случай (пример)

int a = 1;
short j;
float f = 1.0 ;

printf("%i\n", j = *((short*)&a));
printf("%i\n", j = *((int*)&f));
    
int *p; 

p=&a;
printf("%i\n", j = *((short*)p));

TIS-интерпретатор может перехватить все три, следующий пример вызывает TIS-ядро в качестве TIS-интерпретатора (выходные данные редактируются для краткости):

./bin/tis-kernel -sa example1.c 
...
example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing
              rules by accessing a cell with effective type int.
...

example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by
              accessing a cell with effective type float.
              Callstack: main
...

example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by
              accessing a cell with effective type int.

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

Заключение

Мы узнали о правилах алиасинга в C и C++, что означает, что компилятор ожидает, что мы строго следуем этим правилам, и принимаем последствия их невыполнения. Мы узнали о некоторых инструментах, которые помогут нам выявить некоторые злоупотребления псевдонимами. Мы видели, что обычное использование алиасинга — это каламбур типизации. Мы также научились правильно его реализовывать.

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

У нас есть стандартные уже готовые совместимые методы для интерпретирования типов. Иногда для отладочных сборок эти методы должны быть бесплатными абстракциями. У нас есть несколько инструментов для выявления строгих нарушений алиасинга, но для C ++ они будут отлавливать лишь небольшую часть случаев, а для C с помощью tis-интерпретатора мы сможем отследить большинство нарушений.

Спасибо тем, кто оставил отзыв об этой статье: JF Bastien, Кристофер Ди Белла, Паскаль Куок, Мэтт П. Дзюбински, Патрис Рой и Олафур Вааге
Конечно, в конце концов, все ошибки принадлежат автору.

Вот и подошел к концу перевод довольно большого материала, первую часть которого можно прочитать тут. А мы традиционно приглашаем вас на день открытых дверей, который уже 14 марта проведет руководитель отдела разработки технологий в Rambler&Co — Дмитрий Шебордаев.

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


  1. Amomum
    13.03.2019 18:41
    +2

    А что же все-таки делает std::launder?


  1. kovserg
    13.03.2019 19:47
    +1

    Вот странно: все белые люди пытаются скрыть особенности (костыли) архитектуры и изолировать сущности разной природы, а C++ упорно делает обратное. Что ведёт к повышению сложности на ровном месте. Мазохизм короче.

    ps:

    alignas(alignof(int)) char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; 
    int x = *reinterpret_cast<int*>(arr);
    

    Этот код вообще будет странно работать при 16-битных char и sizeof(int)=2


    1. kachsheev
      14.03.2019 14:17

      В таких случаях не является ли панацеей использовать строго определённые размеры типов, вроде псевдонимов из stdint.h?


    1. KanuTaH
      14.03.2019 15:38

      Нужно же на чем-то писать то, что будет «скрывать особенности архитектуры и изолировать сущности разной природы», необходимость работать с архитектурой и с этими сущностями «разной природы» все равно по большому счету никуда не девается. C и C++ как раз для этого и нужны. Хотите изолироваться по максимуму — пишите на Java или C#.


  1. danfe
    14.03.2019 10:09

    gcc с использованием флагов -fstrict-aliasing и -Wstrict-aliasing может отлавливать некоторые случаи, хотя и не без ложных срабатываний/неприяностей.
    Реальность, к сожалению, такова, что очень много кода ломается при строгом алиасинге разными неочевидными способами. Любую более-менее сложную программу, задействующую какие-либо преобразования типов, не стоит собирать без ключа -fno-strict-aliasing. Во многих операционных системах он включен по умолчанию (часть дефолтных C[XX]FLAGS, например, во FreeBSD), Linux тоже с ним собирается и т.д.


    1. vassabi
      14.03.2019 10:57

      в общем — если вам надо работать над побитовым преобразования типов (или еще чем-то подобным), то:
      1) подумайте, как переписать код, чтобы обойтись без него (интересно, как много людей у которых задачи вынуждают писать приведение указателя на флоаты в указатели на инты ?)
      2) напишите отдельную функцию на С для вашей платформы, с нужными ключами компиляции и т.д., и линкуйтесь с ней.


      1. OldFisher
        14.03.2019 11:26

        Это-то ладно, главная проблема в том, как найти в своём коде все места, где такое преобразование уже сделано?


  1. danfe
    14.03.2019 11:13

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


    1. vassabi
      14.03.2019 12:09

      Иногда (глядя на очередное неявное преобразование в коде стажера) посещает мысль, что надо сделать такой ключ компилятора, который вообще запрещает все неявные преобразования типов (особенно в булеан) и все тайпкасты.
      И принудительно его добавлять его в школьных\студенческих лабах и у стажеров. (ОК, для матолимпиад и кода мидл+ — можно без него)


      1. Wyrd
        14.03.2019 13:38
        -1

        И чтоб оно автоматически уровень пишущего код определяло!


      1. kovserg
        14.03.2019 13:47

        Да, иногда на C++ может поднять настроение :)

        struct A { double x; };
        struct B : A { double y; };
        
        void cpp() {
          A* a=new B[2];
          a[1].x=1.0;
          delete[] a;
        }
        


    1. Dave_by
      14.03.2019 14:21

      Мне вот требуется сериализация флоатов в хмл. При том нужна полная точность, как записал так и прочитал, поэтому я не делаю перевод в десятичную запись и обратно, а кастую в (std::uint32_ t)&f, потом пишу его в шестнадцатиричным текстом в хмл. Подскажете более элегантный- strict'ный способ это сделать?


      1. danfe
        14.03.2019 14:40

        Вообще-то флоаты сериализуются несколько иначе, но если вам портабельность не нужна, то ответ есть в самой статье — memcpy(3) с ассертом, что размеры типов именно такие, как вы ожидаете.


        1. Dave_by
          14.03.2019 20:15

          Портабельность мне как раз нужна. Современные компиляторы поддерживают IEEE 754, там флоаты совместимы и в бинарном формате. Во всяком случае мой код успешно обменивается с x86/msvc и arm/clang. Но за ссылку спасибо.


          1. mpa4b
            16.03.2019 14:56

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


      1. KanuTaH
        14.03.2019 21:02

        а кастую в (std::uint32_ t)&f

        Это UB на ровном месте.

        Type aliasing

        Whenever an attempt is made to read or modify the stored value of an object of type DynamicType through a glvalue of type AliasedType, the behavior is undefined unless one of the following is true:

        AliasedType and DynamicType are similar.
        AliasedType is the (possibly cv-qualified) signed or unsigned variant of DynamicType.
        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.

        Отсюда. Там дальше разъясняется, что такое «similar», если кратко — uint32_t и float не similar. Вам везет, что у вас это ПОКА работает.


    1. Sixshaman
      14.03.2019 15:13

      И как же, например, писать бинарные файлы без каста в char*?


      1. danfe
        14.03.2019 15:17

        An exception to the strict aliasing rules is made for pointers to character types, so it is always OK to inspect an object’s representation via an array of chars. This is necessary to make memcpy-like functions work properly. Извините, лень переводить на русский.


  1. mikeus
    15.03.2019 16:59

    Strict aliasig rule определяет, какие два различных указателя могут ссылаться на один и тот же объект, а какие — в нормальном случае нет. Когда компилятор видит в обрабатываемом участке кода использование двух различных указателей, для которых согласно правилу допустимо, что они могут указывать на один и тот же участок памяти, то он действует аккуратно, без оптимизаций; в остальных случаях считается, что производится работа с различными объектами и выполняется соответствующая оптимизация генерируемого кода.
    На этом собственно основная содержательная история со strict aliasing заканчивается, и к взаимному кастингу указателей она имеет лишь опосредованное отношение.

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

    int foo( float *f, int *i ) {
        *i = 1 ;
        *f = 0.f ;
    
       return *i ;
    }
    добавить
    int foo( float *f, int *i ) {
        *i = 1 ;
        *f = 0.f ;
    
       char *c = reinterpret_cast<char*>(i);
       *c = 2;
        
       return *i ;
    }
    то компилятор будет обрабатывать код как если бы сигнатура была int foo( float *f, char *i ) Т.е. согласно strict aliasing rule эти два указателя — входных параметра могут ссылаться на один и тот же объект.

    Ещё не всегда очевидный пример такого же рода:
    struct s1 { float d1; };
    struct s2 { float d2; float d3; };
    struct s3 { char* p3; };
    
    float bar(s1 *p1, s2 *p2, uintptr_t p) {
      p1->d1 = 10;
      p2->d2 = 20;
    
      s3* r = reinterpret_cast<s3*>(p);
      *r->p3 = 30;
    
      return p1->d1;
    }


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