Главная » Хабрахабр » [Перевод] Что такое Strict Aliasing и почему нас должно это волновать? Часть 2

[Перевод] Что такое Strict Aliasing и почему нас должно это волновать? Часть 2

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

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

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

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

Переинтерпретация сегмента памяти в качестве другого типа называется каламбуром типизации (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 ++, и некоторые считают, что объединения предназначены исключительно для реализации вариантных типов, и считают, что использование объединений для каламбуров типизации является злоупотреблением.

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

Это может показаться немного сложным, но оптимизатор должен распознавать использование memcpy для каламбура, оптимизировать его и генерировать регистр для регистрации перемещения. Стандартный благословенный метод для каламбуров типизации в C и C ++ — 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 (пример).

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

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

// Простая операция, возвращающая значение обратно
int foo( unsigned int 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

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

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

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

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

3. И C99, и C11 явно указывают на то, что преобразование, которое приводит к невыровненному указателю, является неопределенным поведением, раздел 6. 3. 2. Указатели говорит:

Если результирующий указатель не правильно выровнен для указательного типа, поведение не определено. Указатель на объект или неполный тип может быть преобразован в указатель на другой объект или неполный тип. …

Хотя 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 (пример): gcc с использованием флагов -fstrict-aliasing и -Wstrict-aliasing может отлавливать некоторые случаи, хотя и не без ложных срабатываний/неприяностей.

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

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 — Дмитрий Шебордаев.
Вот и подошел к концу перевод довольно большого материала, первую часть которого можно прочитать тут.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] История транзистора, часть 2: из горнила войны

Другие статьи цикла: История реле История электронных компьютеров История транзистора Горнило войны подготовило почву для появления транзистора. С 1939 по 1945 года технические знания из области полупроводников невероятно сильно разрослись. И тому была одна простая причина: радар. Самая важная технология ...

Что можно сделать через разъем OBD в автомобиле

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