(ИЛИ каламбур типизации, неопределенное поведение и выравнивание, о мой Бог!)
Друзья, до запуска нового потока по курсу «Разработчик С++», остается совсем немного времени. Пришло время опубликовать перевод второй части материала, в которой рассказывается о том, что такое каламбур типизации.
Что такое каламбур типизации?
Мы дошли до того момента, когда мы можем задаться вопросом, зачем нам вообще могут понадобиться псевдонимы? Обычно для реализации каламбуров типизации, т.к. зачастую используемые методы нарушают правила строгого алиасинга.
Иногда мы хотим обойти систему типов и интерпретировать объект как другой тип. Переинтерпретация сегмента памяти в качестве другого типа называется каламбуром типизации (type punning). Каламбуры типизации полезны для задач, которым требуется доступ к базовому представлению объекта для просмотра, транспортировки или манипулирования предоставленными данными. Типичные области, в которых мы можем встретить использование каламбуров типизации: компиляторы, сериализация, сетевой код и т.д.
Традиционно это достигалось путем взятия адреса объекта, приведения его к указателю типа, к которому мы хотим проинтерпретировать, и затем доступа к значению, или другими словами, с помощью псевдонимов. Например:
int x = 1 ;
// В языке C
float *fp = (float*)&x ; // Недопустимый алиасинг
//В языке C++
float *fp = reinterpret_cast<float*>(&x) ; // Недопустимый алиасинг
printf( “%fn”, *fp ) ;
Как мы видели ранее, это недопустимый алиасинг, этим мы вызовем неопределенное поведение. Но традиционно компиляторы не использовали правила строгого алиасинга, и этот тип кода обычно просто работал, а разработчики, к сожалению, привыкли допускать такие вещи. Распространенный альтернативный метод каламбура типизации — через объединения (union), что допустимо в C, но вызовет неопределенное поведение в C ++ (см. пример):
union u1
{
int n;
float f;
} ;
union u1 u;
u.f = 1.0f;
printf( "%dn”, 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("%in", j = *(reinterpret_cast<short*>(&a)));
printf("%in", j = *(reinterpret_cast<int*>(&f)));
хотя он не поймает этот дополнительный случай (пример):
int *p;
p=&a;
printf("%in", 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( "%dn", *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("%in", j = *((short*)&a));
printf("%in", j = *((int*)&f));
int *p;
p=&a;
printf("%in", 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 — Дмитрий Шебордаев.
Автор: MaxRokatansky