Что такое строгое правило псевдонимов?
Когда спрашивали об обычном неопределенном поведении в C, души более просвещенные, чем я, ссылались на правило строгого наложения имен.
О чем они говорят?
11 ответов
Типичная ситуация, с которой вы сталкиваетесь со строгими проблемами псевдонимов, - это наложение структуры (например, сообщения устройства / сети) на буфер размера слова вашей системы (например, указатель на uint32_t
с или uint16_t
с). Когда вы накладываете структуру на такой буфер или буфер на такую структуру с помощью приведения указателя, вы можете легко нарушить строгие правила наложения имен.
Таким образом, при такой настройке, если я хочу отправить сообщение чему-либо, у меня должны быть два несовместимых указателя, указывающих на один и тот же кусок памяти. Я мог бы тогда наивно кодировать что-то вроде этого:
typedef struct Msg
{
unsigned int a;
unsigned int b;
} Msg;
void SendWord(uint32_t);
int main(void)
{
// Get a 32-bit buffer from the system
uint32_t* buff = malloc(sizeof(Msg));
// Alias that buffer through message
Msg* msg = (Msg*)(buff);
// Send a bunch of messages
for (int i =0; i < 10; ++i)
{
msg->a = i;
msg->b = i+1;
SendWord(buff[0]);
SendWord(buff[1]);
}
}
Строгое правило псевдонимов делает эту настройку недопустимой: разыменование указателя на псевдоним объекта, который не является совместимым типом или одним из других типов, разрешенных C 2011 6.5, пункт 71, является неопределенным поведением. К сожалению, вы все еще можете кодировать таким образом, возможно, получить несколько предупреждений, сделать так, чтобы он нормально компилировался, только для того, чтобы иметь странное неожиданное поведение при запуске кода.
(GCC выглядит несколько непоследовательным в своей способности давать псевдонимы предупреждений, иногда давая нам дружеское предупреждение, а иногда нет).
Чтобы понять, почему это поведение не определено, нам нужно подумать о том, какое правило строгого алиасинга покупает компилятор. По сути, с этим правилом не нужно думать о вставке инструкций для обновления содержимого buff
каждый проход цикла. Вместо этого, при оптимизации, с некоторыми досадно необоснованными предположениями о псевдонимах, он может опустить эти инструкции, загрузить buff[0]
а также buff[1
] в регистры ЦП один раз перед запуском цикла, и ускорить тело цикла. Перед введением строгого псевдонима компилятор должен был жить в состоянии паранойи, что содержимое buff
может измениться в любое время из любого места кем-либо. Таким образом, чтобы получить дополнительное преимущество в производительности, и при условии, что большинство людей не вводят указатели с каламбура, было введено строгое правило псевдонимов.
Имейте в виду, что если вы считаете, что пример надуман, это может произойти, даже если вы передаете буфер другой функции, выполняющей отправку за вас, если вместо этого у вас есть.
void SendMessage(uint32_t* buff, size_t size32)
{
for (int i = 0; i < size32; ++i)
{
SendWord(buff[i]);
}
}
И переписал наш предыдущий цикл, чтобы воспользоваться этой удобной функцией
for (int i = 0; i < 10; ++i)
{
msg->a = i;
msg->b = i+1;
SendMessage(buff, 2);
}
Компилятор может или не может быть достаточно умным, чтобы попытаться встроить SendMessage, и он может или не может решить загружать или не загружать бафф снова. Если SendMessage
является частью другого API, который компилируется отдельно, возможно, он содержит инструкции для загрузки содержимого баффа. С другой стороны, может быть, вы находитесь на C++, и это некая шаблонная реализация только для заголовков, которую компилятор считает встроенной. Или, может быть, это просто то, что вы написали в своем.c файле для вашего удобства. В любом случае неопределенное поведение все еще может возникнуть. Даже когда мы знаем о том, что происходит под капотом, это все равно является нарушением правила, поэтому не гарантируется четко определенное поведение. Так что простое включение в функцию, которая принимает наш буфер с разделителями слов, не обязательно поможет.
Так как мне обойти это?
Используйте союз. Большинство компиляторов поддерживают это, не жалуясь на строгий псевдоним. Это разрешено в C99 и явно разрешено в C11.
union { Msg msg; unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)]; };
Вы можете отключить строгое псевдонимы в вашем компиляторе ( f [no-] strict-aliasing в gcc))
Ты можешь использовать
char*
для псевдонимов вместо слова вашей системы. Правила допускают исключение дляchar*
(в том числеsigned char
а такжеunsigned char
). Всегда предполагается, чтоchar*
псевдонимы других типов. Однако это не сработает по-другому: нет предположения, что ваша структура псевдонимом является буфер символов.
Начинающий остерегаться
Это только одно потенциальное минное поле при наложении двух типов друг на друга. Вы также должны узнать о порядке байтов, выравнивании слов и о том, как правильно решать проблемы выравнивания с помощью структур упаковки.
сноска
1 Типы, которые C 2011 6.5 7 разрешает доступ к lvalue:
- тип, совместимый с эффективным типом объекта,
- квалифицированная версия типа, совместимого с эффективным типом объекта,
- тип, который является типом со знаком или без знака, соответствующим действующему типу объекта,
- тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
- тип агрегата или объединения, который включает в себя один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегата или автономного объединения), или
- тип персонажа.
Лучшее объяснение, которое я нашел, - Майк Актон, " Понимание строгого алиасинга". Он немного сфокусирован на разработке PS3, но в основном это только GCC.
Из статьи:
"Строгий псевдоним - это предположение, сделанное компилятором C (или C++), что разыменование указателей на объекты разных типов никогда не будет ссылаться на одну и ту же ячейку памяти (то есть псевдонимы друг друга)."
Так что в основном, если у вас есть int*
указывая на некоторую память, содержащую int
а затем вы указываете float*
в эту память и использовать его как float
Вы нарушаете правило. Если ваш код этого не учитывает, то оптимизатор компилятора, скорее всего, сломает ваш код.
Исключением из правила является char*
, которое разрешено указывать на любой тип.
Заметка
Это выдержка из моего "Что такое строгое правило алиасинга и почему нас это волнует?" записать.
Что такое строгий псевдоним?
В C и C++ псевдонимы связаны с тем, через какие типы выражений нам разрешен доступ к хранимым значениям. Как в C, так и в C++ стандарт определяет, какие типы выражений допускаются для псевдонимов и каких типов. Компилятору и оптимизатору разрешается предполагать, что мы строго следуем правилам алиасинга, отсюда и термин строгое правило алиасинга. Если мы пытаемся получить доступ к значению с использованием недопустимого типа, оно классифицируется как неопределенное поведение (UB). Когда у нас неопределенное поведение, все ставки отменены, результаты нашей программы перестают быть достоверными.
К сожалению, со строгими нарушениями псевдонимов, мы часто получаем ожидаемые результаты, оставляя возможность того, что будущая версия компилятора с новой оптимизацией нарушит код, который мы считали допустимым. Это нежелательно, и стоит понять строгие правила создания псевдонимов и избежать их нарушения.
Чтобы лучше понять, почему нас это волнует, мы обсудим проблемы, возникающие при нарушении строгих правил псевдонимов, так как наказание типа, так как обычные методы, используемые в наказании типа, часто нарушают строгие правила псевдонима и как правильно вводить игру слов.
Предварительные примеры
Давайте посмотрим на некоторые примеры, затем мы сможем поговорить о том, что конкретно говорится в стандарте (ах), рассмотрим некоторые дополнительные примеры, а затем посмотрим, как избежать строгого псевдонима и отследить нарушения, которые мы пропустили. Вот пример, который не должен удивлять ( живой пример):
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
Что может и не ожидаться, но совершенно верно, так как мы вызвали неопределенное поведение. Число с плавающей точкой не может правильно называть объект 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 имеет расширение, а также позволяет присваивать int без знака 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 Draft Standard
Проект стандарта C++ 17 в разделе 11 [basic.lval] гласит:
Если программа пытается получить доступ к сохраненному значению объекта через 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
Мы дошли до этой точки, и нам может быть интересно, зачем нам нужен псевдоним? Ответ обычно заключается в вводе слов, часто используемые методы нарушают строгие правила псевдонимов.
Иногда мы хотим обойти систему типов и интерпретировать объект как другой тип. Это называется типом паннинга, чтобы переосмыслить сегмент памяти как другой тип. Тип 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?
Стандартный метод для определения типов в C и C++ - это memcpy. Это может показаться немного сложным, но оптимизатор должен распознавать использование memcpy для обозначения типа, оптимизировать его и генерировать регистр для регистрации перемещения. Например, если мы знаем, что 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 или методу объединения для определения типов. Изучая сгенерированный код, мы видим, что он использует только регистр mov ( живой пример Compiler Explorer).
C++20 и bit_cast
В C++20 мы можем получить bit_cast ( реализация доступна по ссылке в предложении), который дает простой и безопасный способ ввода слов, а также может использоваться в контексте constexpr.
Ниже приведен пример того, как использовать bit_cast для ввода pun беззнакового целого типа с плавающей точкой ( смотрите в реальном времени):
std::cout << bit_cast<float>(0x447a0000) << "\n" ; //assuming sizeof(float) == sizeof(unsigned int)
В случае, когда типы To и From не имеют одинаковый размер, это требует от нас использования промежуточной структуры15. Мы будем использовать структуру, содержащую символьный массив sizeof( unsigned int) (предполагается, что 4-байтовое unsigned int) будет типом From, а unsigned int - типом To.:
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 будет сгенерировано предупреждение ( смотрите его вживую):
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, который в настоящее время находится в разработке. Это дезинфицирующее средство добавляет информацию проверки типов в сегмент теневой памяти и проверяет доступы, чтобы определить, не нарушают ли они правила псевдонимов. Инструмент потенциально должен быть в состоянии отследить все нарушения псевдонимов, но может иметь большие накладные расходы во время выполнения.
Это строгое правило псевдонимов, которое можно найти в разделе 3.10 стандарта C++03 (другие ответы дают хорошее объяснение, но ни один из них не содержит самого правила):
Если программа пытается получить доступ к сохраненному значению объекта через значение lvalue, отличное от одного из следующих типов, поведение не определено:
- динамический тип объекта,
- cv-квалифицированная версия динамического типа объекта,
- тип, который является типом со знаком или без знака, соответствующим динамическому типу объекта,
- тип, который является типом со знаком или без знака, соответствующим cv-квалифицированной версии динамического типа объекта,
- тип агрегата или объединения, который включает в себя один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегата или автономного объединения),
- тип, который является (возможно, квалифицированным cv) типом базового класса динамического типа объекта,
char
или жеunsigned char
тип.
ФормулировкиC++11 и C++14 (изменения подчеркнуты):
Если программа пытается получить доступ к сохраненному значению объекта через glvalue, отличный от одного из следующих типов, поведение не определено:
- динамический тип объекта,
- cv-квалифицированная версия динамического типа объекта,
- тип, подобный (как определено в 4.4) динамическому типу объекта,
- тип, который является типом со знаком или без знака, соответствующим динамическому типу объекта,
- тип, который является типом со знаком или без знака, соответствующим cv-квалифицированной версии динамического типа объекта,
- агрегатный или объединенный тип, который включает в себя один из вышеупомянутых типов среди своих элементов или нестатических элементов данных (включая, рекурсивно, элемент или нестатический элемент данных субагрегата или содержащего объединения),
- тип, который является (возможно, квалифицированным cv) типом базового класса динамического типа объекта,
char
или жеunsigned char
тип.
Два изменения были небольшими: glvalue вместо lvalue и прояснение случая совокупности / объединения.
Третье изменение дает более сильную гарантию (ослабляет строгое правило псевдонимов): новая концепция похожих типов, которые теперь безопасны для псевдонимов.
Также формулировка C (C99; ISO/IEC 9899:1999 6.5/7; точно такая же формулировка используется в ISO/IEC 9899:2011 §6.5 ¶7):
Объект должен иметь свое сохраненное значение, доступное только через выражение lvalue, которое имеет один из следующих типов 73) или 88):
- тип, совместимый с эффективным типом объекта,
- квалифицированная версия типа, совместимого с эффективным типом объекта,
- тип, который является типом со знаком или без знака, соответствующим действующему типу объекта,
- тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
- тип агрегата или объединения, который включает в себя один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегата или автономного объединения), или
- тип персонажа.
73) или 88) Целью этого списка является определение тех обстоятельств, при которых объект может или не может быть псевдонимом.
Строгое псевдонимы относятся не только к указателям, но и к ссылкам. Я написал статью об этом для вики-сайта Boost для разработчиков, и он был настолько хорошо принят, что я превратил его в страницу на своем консультационном веб-сайте. Это полностью объясняет, что это такое, почему это так сильно смущает людей и что с этим делать. Строгий Aliasing White Paper. В частности, это объясняет, почему объединения являются рискованным поведением для C++, и почему использование memcpy является единственным переносимым исправлением как для C, так и для C++. Надеюсь, это полезно.
Как добавление к тому, что уже написал Дуг Т., вот простой тестовый пример, который, вероятно, запускает его с помощью gcc:
check.c
#include <stdio.h>
void check(short *h,long *k)
{
*h=5;
*k=6;
if (*h == 5)
printf("strict aliasing problem\n");
}
int main(void)
{
long k[1];
check((short *)k,k);
return 0;
}
Компилировать с gcc -O2 -o check check.c
, Обычно (с большинством версий gcc, которые я пробовал) это выдает "проблему строгого алиасинга", потому что компилятор предполагает, что "h" не может быть тем же адресом, что и "k" в функции "check". Из-за этого компилятор оптимизирует if (*h == 5)
прочь и всегда вызывает printf.
Для тех, кого это интересует, код ассемблера x64, созданный gcc 4.6.3, работает на ubuntu 12.04.2 для x64:
movw $5, (%rdi)
movq $6, (%rsi)
movl $.LC0, %edi
jmp puts
Таким образом, условие if полностью ушло из ассемблерного кода.
Согласно обоснованию C89, авторы стандарта не хотели требовать, чтобы компиляторы давали такой код:
int x;
int test(double *p)
{
x=5;
*p = 1.0;
return x;
}
должно потребоваться перезагрузить значение x
между оператором присваивания и возврата, чтобы обеспечить возможность того, что p
может указывать на x
и присвоение *p
следовательно, может изменить значение x
, Идея о том, что компилятор должен иметь право предполагать, что в ситуациях, подобных описанным выше, не будет псевдонимов, не вызывала сомнений.
К сожалению, авторы C89 написали свое правило таким образом, что, если читать буквально, заставит даже следующую функцию вызвать неопределенное поведение:
void test(void)
{
struct S {int x;} s;
s.x = 1;
}
потому что он использует lvalue типа int
для доступа к объекту типа struct S
, а также int
не входит в число типов, которые могут быть использованы для доступа к struct S
, Поскольку было бы абсурдно рассматривать любое использование элементов структур и объединений, не относящихся к символьному типу, как неопределенное поведение, почти каждый признает, что существуют, по крайней мере, некоторые обстоятельства, когда lvalue одного типа может использоваться для доступа к объекту другого типа., К сожалению, Комитет по стандартам C не смог определить, каковы эти обстоятельства.
Большая часть проблемы является результатом отчета о дефектах № 028, в котором задан вопрос о поведении такой программы, как:
int test(int *ip, double *dp)
{
*ip = 1;
*dp = 1.23;
return *ip;
}
int test2(void)
{
union U { int i; double d; } u;
return test(&u.i, &u.d);
}
В отчете о дефектах № 28 говорится, что программа вызывает неопределенное поведение, поскольку действие записи члена объединения типа "double" и чтения одного типа "int" вызывает поведение, определяемое реализацией. Такие рассуждения бессмысленны, но формируют основу для правил эффективного типа, которые излишне усложняют язык, не делая ничего для решения исходной проблемы.
Вероятно, наилучшим способом решения исходной проблемы было бы рассматривать сноску о целях правила, как если бы она была нормативной, и сделать правило неосуществимым, за исключением случаев, когда на самом деле возникают конфликты при доступе с использованием псевдонимов. Учитывая что-то вроде:
void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
s.x = 1;
p = &s.x;
inc_int(p);
return s.x;
}
Там нет конфликта внутри inc_int
потому что все доступы к хранилищу доступны через *p
делаются с lvalue типа int
и нет конфликта в test
так как p
явно происходит от struct S
и в следующий раз s
используется, все обращения к этому хранилищу, которые когда-либо будут сделаны через p
уже произошло.
Если код был изменен немного...
void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
p = &s.x;
s.x = 1; // !!*!!
*p += 1;
return s.x;
}
Здесь существует конфликт псевдонимов между p
и доступ к s.x
в отмеченной строке, потому что в этот момент выполнения существует другая ссылка, которая будет использоваться для доступа к тому же хранилищу.
Если бы в отчете о дефектах 028 говорилось, что исходный пример вызвал UB из-за совпадения между созданием и использованием двух указателей, это сделало бы вещи более ясными без добавления "эффективных типов" или других подобных сложностей.
Пуннинг типов с помощью приведения указателей (в отличие от использования объединения) является основным примером нарушения строгого алиасинга.
Прочитав многие ответы, я чувствую необходимость что-то добавить:
Строгий псевдоним (который я опишу чуть позже) важен, потому что:
Доступ к памяти может быть дорогим (с точки зрения производительности), поэтому данные обрабатываются в регистрах ЦП, прежде чем они записываются обратно в физическую память.
Если данные в двух разных регистрах ЦП будут записаны в одно и то же пространство памяти, мы не можем предсказать, какие данные "выживут", когда мы кодируем в C.
В сборке, где мы кодируем загрузку и выгрузку регистров ЦП вручную, мы узнаем, какие данные остаются нетронутыми. Но С (к счастью) абстрагируется от этой детали.
Поскольку два указателя могут указывать на одно и то же место в памяти, это может привести к сложному коду, который обрабатывает возможные коллизии.
Этот дополнительный код работает медленно и снижает производительность, поскольку он выполняет операции чтения / записи дополнительной памяти, которые являются одновременно более медленными и (возможно) ненужными.
Правило строгого псевдонима позволяет нам избегать избыточного машинного кода в тех случаях, когда можно с уверенностью предположить, что два указателя не указывают на один и тот же блок памяти (см. Также restrict
ключевое слово).
Строгий псевдоним утверждает, что можно предположить, что указатели на разные типы указывают на разные места в памяти.
Если компилятор замечает, что два указателя указывают на разные типы (например, int *
и float *
), он будет предполагать, что адрес памяти отличается, и он не защитит от коллизий адресов памяти, что приведет к более быстрому машинному коду.
Например:
Давайте возьмем следующую функцию:
void merge_two_ints(int *a, int *b) {
*b += *a;
*a += *b;
}
Для того, чтобы справиться со случаем, в котором a == b
(оба указателя указывают на одну и ту же память), нам нужно упорядочить и протестировать способ загрузки данных из памяти в регистры ЦП, чтобы код мог выглядеть примерно так:
нагрузка
a
а такжеb
из памяти.добавлять
a
вb
,спасти
b
и перезагрузитеa
,(сохранить из регистра ЦП в память и загрузить из памяти в регистр ЦП).
добавлять
b
вa
,спасти
a
(из регистра процессора) в память.
Шаг 3 очень медленный, потому что ему нужен доступ к физической памяти. Тем не менее, это необходимо для защиты от случаев, когда a
а также b
указать на тот же адрес памяти.
Строгий псевдоним позволит нам предотвратить это, сообщив компилятору, что эти адреса памяти явно различаются (что в этом случае позволит еще более оптимизировать процесс, который не может быть выполнен, если указатели совместно используют адрес памяти).
Это можно сообщить компилятору двумя способами, используя разные типы для указания. то есть:
void merge_two_numbers(int *a, long *b) {...}
С использованием
restrict
ключевое слово. то есть:void merge_two_ints(int * restrict a, int * restrict b) {...}
Теперь, соблюдая правило строгого псевдонима, можно избежать шага 3, и код будет работать значительно быстрее.
На самом деле, добавив restrict
ключевое слово, вся функция может быть оптимизирована для:
нагрузка
a
а такжеb
из памяти.добавлять
a
вb
,сохранить результат как
a
и кb
,
Эта оптимизация не могла быть сделана раньше из-за возможного столкновения (где a
а также b
будет утроен, а не в два раза).
Строгий псевдоним не допускает использование разных типов указателей для одних и тех же данных.
Эта статья должна помочь вам понять проблему в деталях.
Технически в C++ строгое правило псевдонимов, вероятно, никогда не применимо.
Обратите внимание на определение косвенности ( оператор *):
Унарный оператор * выполняет косвенное обращение: выражение, к которому он применяется, должно быть указателем на тип объекта или указателем на тип функции, а результатом является lvalue, указывающее на объект или функцию, на которые указывает выражение.
Также из определения glvalue
Glvalue - это выражение, оценка которого определяет идентичность объекта, (...snip)
Таким образом, в любой четко определенной программной трассировке glvalue ссылается на объект. Так что так называемое правило строгого наложения не применяется никогда. Возможно, это не то, что хотели дизайнеры.