Примечание
Это выдержка из моего «Что такое строгое правило алиасинга и почему нас это волнует?» описание.
Что такое строгий псевдоним?
В C и C ++ псевдонимы связаны с тем, через какие типы выражений нам разрешен доступ к хранимым значениям. Как в C, так и в C ++ стандарт определяет, какие типы выражений допускаются для псевдонимов и каких типов. Компилятор и оптимизатор могут предполагать, что мы строго следуем правилам алиасинга, отсюда и термин строгое правило алиасинга . Если мы пытаемся получить доступ к значению с использованием недопустимого типа, оно классифицируется как неопределенное поведение ( UB ). Если у нас неопределенное поведение, все ставки отменены, результаты нашей программы перестают быть достоверными.
К сожалению, со строгими нарушениями псевдонимов, мы часто получаем ожидаемые результаты, оставляя возможность того, что будущая версия компилятора с новой оптимизацией нарушит код, который мы считали действительным. Это нежелательно, и стоит понять строгие правила создания псевдонимов и избежать их нарушения.
Чтобы лучше понять, почему нас это волнует, мы обсудим проблемы, возникающие при нарушении строгих правил псевдонимов, так как типизирование наказаний часто применяется, так как обычные методы, используемые в типах наказаний, часто нарушают строгие правила псевдонимов и как правильно вводить pun.
Предварительные примеры
Давайте посмотрим на некоторые примеры, затем мы сможем поговорить о том, что конкретно говорится в стандарте (ах), рассмотрим некоторые дополнительные примеры, а затем посмотрим, как избежать строгого наложения псевдонимов и выявить нарушения, которые мы пропустили. Вот пример, который не должен удивлять ( живой пример ):
int x = 10;
int *ip = &x;
std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";
У нас есть int *, указывающий на память, занятую int , и это допустимый псевдоним. Оптимизатор должен предположить, что присвоения через ip могут обновить значение, занимаемое x .
В следующем примере показан псевдоним, который приводит к неопределенному поведению ( живой пример ):
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *i;
}
int main() {
int x = 0;
std::cout << x << "\n"; // Expect 0
x = foo(reinterpret_cast<float*>(&x), &x);
std::cout << x << "\n"; // Expect 0?
}
В функции foo мы берем int * и float *, в этом примере мы вызываем foo и устанавливаем оба параметры, указывающие на ту же область памяти, которая в этом примере содержит int . Обратите внимание, что reinterpret_cast говорит компилятору обрабатывать выражение так, как если бы оно имело тип, определенный его параметром шаблона. В этом случае мы говорим ему обрабатывать выражение & x , как если бы оно имело тип float *. Мы можем наивно ожидать, что результат второй cout будет 0 , но при включенной оптимизации с использованием -O2 и gcc, и clang дают следующий результат:
0
1
Что может и не ожидаться, но совершенно верно, так как мы вызвали неопределенное поведение. float не может корректно использовать псевдоним int объекта. Поэтому оптимизатор может предположить, что константа 1 , сохраненная при разыменовании i , будет возвращаемым значением, так как сохранение через f не может корректно повлиять на int объект. Подсоединение кода в Compiler Explorer показывает, что это именно то, что происходит ( живой пример ):
foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret
Оптимизатор, использующий Анализ псевдонимов на основе типов (TBAA) предполагает, что будет возвращено 1 и непосредственно перемещает постоянное значение в регистр eax , который несет возврат значение. TBAA использует правила языков о том, какие типы разрешены для псевдонимов для оптимизации загрузки и хранения. В этом случае TBAA знает, что float не может использовать псевдоним и int , и оптимизирует загрузку i .
Теперь к Книге правил
Что именно стандарт говорит, что нам разрешено и не разрешено делать? Стандартный язык не является простым, поэтому для каждого элемента я постараюсь предоставить примеры кода, которые демонстрируют значение.
Что говорит стандарт C11?
Стандарт C11 гласит следующее в разделе 6.5 Выражения параграф 7 :
Объект должен иметь свое сохраненное значение, доступ к которому осуществляется только через выражение lvalue, имеющее один из следующих типов: 88)
- тип, совместимый с эффективным типом объекта,
int x = 1;
int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int
- квалифицированная версия типа, совместимого с эффективным типом объекта,
int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int
- тип, представляющий собой тип со знаком или без знака, соответствующий действующему типу объекта,
int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to
// the effective type of the object
gcc / clang имеет расширение и , а также , что позволяет присваивать unsigned int * int *, даже если они несовместимы типы.
- тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type
// that corresponds with to a qualified verison of the effective type of the object
- агрегатный или объединенный тип, который включает в себя один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегата или автономного объединения), или
struct foo {
int x;
};
void foobar( struct foo *fp, int *ip ); // struct foo is an aggregate that includes int among its members so it can
// can alias with *ip
foo f;
foobar( &f, &f.x );
- тип символа.
int x = 65;
char *p = (char *)&x;
printf("%c\n", *p ); // *p gives us an lvalue expression of type char which is a character type.
// The results are not portable due to endianness issues.
Что говорит проект стандарта C ++ 17
Проект стандарта C ++ 17 в разделе [basic.lval] пункт 11 гласит:
Если программа пытается получить доступ к сохраненному значению объекта через glvalue другого, чем один из следующих типов, поведение не определено: 63
(11.1) - динамический тип объекта,
void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0}; // Placement new changes the dynamic type of the object to int
std::cout << *ip << "\n"; // *ip gives us a glvalue expression of type int which matches the dynamic type
// of the allocated object
(11.2) - cv-квалифицированная версия динамического типа объекта,
int x = 1;
const int *cip = &x;
std::cout << *cip << "\n"; // *cip gives us a glvalue expression of type const int which is a cv-qualified
// version of the dynamic type of x
(11.3) - тип, аналогичный (как определено в 7.5) динамическому типу объекта,
(11.4) - тип, который является типом со знаком или без знака, соответствующим динамическому типу объекта,
// Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
si = 1;
ui = 2;
return si;
}
(11.5) - тип, представляющий собой тип со знаком или без знака, соответствующий cv-квалифицированной версии динамического типа объекта,
signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing
(11.6) - агрегатный или объединенный тип, который включает один из вышеупомянутых типов среди своих элементов или нестатических элементов данных (включая, рекурсивно, элемент или нестатический элемент данных субагрегата или содержащего объединения),
struct foo {
int x;
};
// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
fp.x = 1;
ip = 2;
return fp.x;
}
foo f;
foobar( f, f.x );
(11.7) - тип, который является (возможно, cv-квалифицированным) типом базового класса динамического типа объекта,
struct foo { int x ; };
struct bar : public foo {};
int foobar( foo &f, bar &b ) {
f.x = 1;
b.x = 2;
return f.x;
}
(11.8) - тип char, unsigned char или std :: byte.
int foo( std::byte &b, uint32_t &ui ) {
b = static_cast<std::byte>('a');
ui = 0xFFFFFFFF;
return std::to_integer<int>( b ); // b gives us a glvalue expression of type std::byte which can alias
// an object of type uint32_t
}
Стоит отметить, что знаковый символ не включен в приведенный выше список, это существенное отличие от C , в котором указано тип символа .
Что такое Type Punning
Мы дошли до этой точки, и нам может быть интересно, зачем нам нужен псевдоним? Обычно ответом является type pun , часто используемые методы нарушают строгие правила наложения имен.
Иногда мы хотим обойти систему типов и интерпретировать объект как другой тип. Это называется type punning , чтобы переосмыслить сегмент памяти как другой тип. Тип punning полезен для задач, которым требуется доступ к базовому представлению объекта для просмотра, транспортировки или манипулирования. Типичные области, в которых мы находим использование типов ввода: компиляторы, сериализация, сетевой код и т. Д.
Традиционно это было достигнуто путем взятия адреса объекта, приведения его к указателю типа, который мы хотим переосмыслить, с последующим доступом к значению или, другими словами, с помощью псевдонимов. Например:
int x = 1 ;
// In C
float *fp = (float*)&x ; // Not a valid aliasing
// In C++
float *fp = reinterpret_cast<float*>(&x) ; // Not a valid aliasing
printf( "%f\n", *fp ) ;
Как мы видели ранееэто неверный псевдоним, поэтому мы вызываем неопределенное поведение. Но традиционно компиляторы не пользовались преимуществами строгих правил псевдонимов, и этот тип кода обычно просто работал, разработчики, к сожалению, привыкли делать такие вещи. Распространенный альтернативный метод для обозначения типов - через объединения, который допустим в C, но неопределенное поведение в C ++ ( см. Пример в реальном времени ):
union u1
{
int n;
float f;
} ;
union u1 u;
u.f = 1.0f;
printf( "%d\n”, u.n ); // UB in C++ n is not the active member
Это недопустимо в C ++, и некоторые считают, что целью объединений является исключительно реализация реализаций типов, и считают, что использование объединений для наказания типов является злоупотреблением.
Как правильно печатать Pun?
Стандартный метод для type punning в C и C ++ - memcpy . Это может показаться немного сложным, но оптимизатор должен распознавать использование memcpy для типа punning , оптимизировать его и генерировать регистр для регистрации перемещения. Например, если мы знаем, что int64_t имеет тот же размер, что и double :
static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17 does not require a message
мы можем использовать memcpy :
void func1( double d ) {
std::int64_t n;
std::memcpy(&n, &d, sizeof d);
//...
При достаточном уровне оптимизации любой приличный современный компилятор генерирует код, идентичный ранее упомянутому методу reinterpret_cast или union для type punning . Изучая сгенерированный код, мы видим, что он использует только регистр mov ( live Compiler Explorer Пример ).
C ++ 20 и bit_cast
В C ++ 20 мы можем получить bit_cast ( реализация доступна в ссылке из предложения ), которая дает простой и безопасный способ ввода слов, а также может использоваться в контекст constexpr.
Ниже приведен пример использования bit_cast для ввода каламбура unsigned int до float , ( смотрите вживую ):
std::cout << bit_cast<float>(0x447a0000) << "\n" ; //assuming sizeof(float) == sizeof(unsigned int)
В случае, когда типы To и From не имеют одинакового размера, нам необходимо использовать промежуточную структуру struct15. Мы будем использовать структуру, содержащую массив символов sizeof (unsigned int) ( предполагает, что 4-байтовое целое число без знака int ) будет иметь тип From и unsigned int как To type.:
struct uint_chars {
unsigned char arr[sizeof( unsigned int )] = {} ; // Assume sizeof( unsigned int ) == 4
};
// Assume len is a multiple of 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 .
Ловить строгие алиасинговые нарушения
У нас не так много хороших инструментов для отслеживания строгого псевдонима в C ++, у нас есть инструменты для выявления случаев строгого нарушения псевдонимов и некоторых случаев неправильной загрузки и хранения.
gcc с использованием флагов -fstrict-aliasing и -Wstrict-aliasing может выявить некоторые случаи, хотя и без ложных срабатываний / отрицаний. Например, следующие случаи будут генерировать предупреждение в gcc ( see it live ):
int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught
// it was being accessed w/ an indeterminate value below
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 bytes: [0,7].
int *u = (int*)((char*)x + 6); // regardless of alignment of x this will not be an aligned address
*u = 1; // Access to range [6-9]
printf( "%d\n", *u ); // Access to range [6-9]
Последний инструмент, который я порекомендую, специфичен для C ++ и не только инструмент, но и практика кодирования, не допускающая приведение в стиле C. Как gcc, так и clang будут производить диагностику приведения в стиле C, используя -Wold-style-cast . Это заставит любые неопределенные каламбуры типа использовать reinterpret_cast, в общем случае reinterpret_cast должен быть флагом для более тщательного анализа кода. Также проще выполнить поиск в базе кода для reinterpret_cast, чтобы выполнить аудит.
Для C у нас есть все инструменты, которые уже рассмотрены, и у нас также есть TIS-интерпретатор, статический анализатор, который исчерпывающе анализирует программу для большого подмножества языка 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-interpeter способен перехватить все три, в следующем примере tis-kernal вызывается как 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 , который в настоящее время находится в разработке. Это дезинфицирующее средство добавляет информацию о проверке типов в сегменте теневой памяти и проверяет доступы, чтобы определить, не нарушают ли они правила псевдонимов. Инструмент потенциально должен быть в состоянии отследить все нарушения псевдонимов, но может иметь большие накладные расходы во время выполнения.