Отвечая на вопрос о распространенном неопределенном поведении в C , люди иногда ссылаются на строгое правило псевдонимов.
О чем они говорят?
Отвечая на вопрос о распространенном неопределенном поведении в C , люди иногда ссылаются на строгое правило псевдонимов.
О чем они говорят?
Ответы:
Типичная ситуация, когда вы сталкиваетесь со строгими проблемами псевдонимов, - это наложение структуры (например, сообщения устройства / сети) на буфер размера слова вашей системы (например, указатель на uint32_t
s или uint16_t
s). Когда вы накладываете структуру на такой буфер или буфер на такую структуру с помощью приведения указателя, вы можете легко нарушить строгие правила наложения имен.
Таким образом, при такой настройке, если я хочу отправить сообщение чему-либо, мне нужно иметь два несовместимых указателя, указывающих на один и тот же кусок памяти. Затем я мог бы наивно кодировать что-то вроде этого (в системе с sizeof(int) == 2
):
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, пункт 7 1, является неопределенным поведением. К сожалению, вы все еще можете кодировать таким образом, возможно, получить несколько предупреждений, сделать так, чтобы он нормально компилировался, только для того, чтобы иметь странное неожиданное поведение при запуске кода.
(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:
unsigned char*
быть использован далеко char*
вместо? Я склонен использовать, unsigned char
а не char
в качестве базового типа, byte
потому что мои байты не подписаны, и я не хочу, чтобы странность поведения со знаком (особенно в отношении переполнения)
unsigned char *
в порядке.
uint32_t* buff = malloc(sizeof(Msg));
и последующие unsigned int asBuffer[sizeof(Msg)];
объявления буферов объединения будут иметь разные размеры, и ни один из них не будет правильным. malloc
Вызов полагается на выравнивании 4 байта под капотом (не делать) , а объединение будет в 4 раза больше , чем это должно быть ... Я понимаю , что это для ясности , но это ошибка мне ни-the меньше ...
Лучшее объяснение, которое я нашел, - Майк Актон, « Понимание строгого алиасинга» . Он немного сфокусирован на разработке PS3, но в основном это только GCC.
Из статьи:
«Строгий псевдоним - это предположение, сделанное компилятором C (или C ++), что разыменование указателей на объекты разных типов никогда не будет ссылаться на одну и ту же ячейку памяти (то есть псевдонимы друг друга).»
Таким образом, в основном, если у вас есть int*
указатель на некоторую память, содержащую, int
а затем вы указываете float*
на эту память и используете ее как float
нарушающую правило. Если ваш код не соблюдает это, оптимизатор компилятора, скорее всего, сломает ваш код.
Исключением из правила является a char*
, которому разрешено указывать на любой тип.
Это строгое правило псевдонимов, которое можно найти в разделе 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) Целью этого списка является определение тех обстоятельств, при которых объект может или не может быть псевдонимом.
wow(&u->s1,&u->s2)
он должен был бы быть законным, даже если указатель используется для изменения u
, и это отрицало бы большинство оптимизаций, которые Правило алиасинга было разработано для облегчения.
Это выдержка из моего «Что такое строгое правило алиасинга и почему нас это волнует?» записать.
В 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
Что может и не ожидаться, но совершенно правильно, так как мы вызвали неопределенное поведение. Число с плавающей запятой не может правильно называть объект 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 говорит следующее в разделе 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 в разделе 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, который говорит о типе символа .
Мы дошли до этой точки, и нам может быть интересно, зачем нам нужен псевдоним? Ответ обычно заключается в вводе слов , часто используемые методы нарушают строгие правила наложения имен.
Иногда мы хотим обойти систему типов и интерпретировать объект как другой тип. Это называется типом паннинга , чтобы переосмыслить сегмент памяти как другой тип. Тип 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 ++, и некоторые считают, что объединение предназначено исключительно для реализации типов вариантов, и считают, что использование объединений для наказания типов является злоупотреблением.
Стандартный метод для определения типов в 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 ( реализация доступна по ссылке в предложении ), который дает простой и безопасный способ ввода слов, а также может использоваться в контексте 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, который в настоящее время находится в разработке. Это дезинфицирующее средство добавляет информацию о проверке типов в сегменте теневой памяти и проверяет доступы, чтобы определить, не нарушают ли они правила псевдонимов. Инструмент потенциально должен быть в состоянии отследить все нарушения псевдонимов, но может иметь большие накладные расходы во время выполнения.
reinterpret_cast
может сделать или что cout
может означать. (Можно упомянуть C ++, но первоначальный вопрос был о C и IIUC, эти примеры можно было бы так же правильно написать на C.)
Строгое псевдонимы относятся не только к указателям, но и к ссылкам, я написал статью об этом для вики для продвинутых разработчиков, и он был настолько хорошо принят, что я превратил его в страницу на своем консультационном веб-сайте. Это полностью объясняет, что это такое, почему это так сильно смущает людей и что с этим делать. Строгий 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 полностью ушло из ассемблерного кода.
long long*
и int64_t
*). Можно было бы ожидать, что здравомыслящий компилятор должен признать, что long long*
и int64_t*
может получить доступ к одному и тому же хранилищу, если они хранятся одинаково, но такое обращение уже не модно.
Выделение типов с помощью приведения указателей (в отличие от использования объединения) является основным примером нарушения строгого алиасинга.
fpsync()
директиву между записью в виде fp и чтением в виде int или наоборот [в реализациях с отдельными целочисленными и конвейерами и кэшами FPU такая директива может быть дорогой, но не такой дорогой, как компилятор, выполняющий такую синхронизацию при каждом доступе к объединению]. Или реализация может указывать, что полученное значение никогда не будет пригодным для использования, за исключением случаев, когда используются общие начальные последовательности.
Согласно обоснованию C89, авторы стандарта не хотели требовать, чтобы компиляторы давали такой код:
int x;
int test(double *p)
{
x=5;
*p = 1.0;
return x;
}
должно потребоваться перезагрузить значение x
между оператором присваивания и возврата, чтобы учесть возможность, на которую он p
может указывать x
, и присваивание, которое *p
может впоследствии изменить значение x
. Идея о том, что компилятор должен иметь право предполагать, что в ситуациях, подобных описанным выше, не будет псевдонимов, не вызывает сомнений.
К сожалению, авторы C89 написали свое правило таким образом, что, если читать буквально, заставит даже следующую функцию вызывать Undefined Behavior:
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.
В сборке, где мы кодируем загрузку и выгрузку регистров ЦП вручную, мы узнаем, какие данные остаются нетронутыми. Но C (к счастью) абстрагируется от этой детали.
Поскольку два указателя могут указывать на одно и то же место в памяти, это может привести к сложному коду, который обрабатывает возможные коллизии .
Этот дополнительный код медленен и снижает производительность, поскольку он выполняет операции чтения / записи дополнительной памяти, которые являются одновременно более медленными и (возможно) ненужными.
Строгое правило сглаживания позволяет избежать избыточного кода машины в тех случаях , в которых он должен быть с уверенностью предположить , что два указателя не указывают на тот же блок памяти (смотри также restrict
ключевое слово).
Строгий псевдоним утверждает, что можно предположить, что указатели на разные типы указывают на разные места в памяти.
Если компилятор заметит, что два указателя указывают на разные типы (например, a int *
и a 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
было бы утроено, а не удвоено).
b
(не перезагружаем) и перезагружаем a
. Надеюсь, теперь стало понятнее.
restrict
, но я думаю, что последний в большинстве случаев будет более эффективным, а ослабление некоторых ограничений register
позволит ему заполнить некоторые случаи, где restrict
это не поможет. Я не уверен, что когда-либо было «важно» рассматривать Стандарт как полностью описывающий все случаи, когда программисты должны ожидать, что компиляторы будут распознавать свидетельства псевдонимов, а не просто описывать места, где компиляторы должны предполагать псевдонимы, даже когда нет конкретных доказательств его существования .
restrict
ключевое слово минимизирует не только скорость операций, но и их количество, что может быть значимым ... Я имею в виду, в конце концов, самая быстрая операция - вообще не операция :)
Строгий псевдоним не позволяет использовать разные типы указателей для одних и тех же данных.
Эта статья должна помочь вам понять проблему в деталях.
int
структура, которая содержит int
).
Технически в C ++ строгое правило псевдонимов, вероятно, никогда не применимо.
Обратите внимание на определение косвенности ( оператор * ):
Унарный оператор * выполняет косвенное обращение: выражение, к которому он применяется, должно быть указателем на тип объекта или указателем на тип функции, а результатом является lvalue, указывающее на объект или функцию, на которые указывает выражение .
Также из определения glvalue
Glvalue - это выражение, оценка которого определяет идентичность объекта, (... snip)
Таким образом, в любой четко определенной программной трассировке glvalue ссылается на объект. Так что так называемое правило строгого наложения не применяется никогда. Возможно, это не то, что хотели дизайнеры.
int foo;
, к чему обращается выражение lvalue *(char*)&foo
? Это объект типа char
? Этот объект появляется одновременно foo
? Будет ли запись для foo
изменения сохраненного значения вышеупомянутого объекта типа char
? Если так, есть ли какое-либо правило, которое позволило char
бы получить доступ к сохраненному значению объекта типа , используя lvalue типа int
?
int i;
четыре объекта каждого символьного типа in addition to one of type
int ? I see no way to apply a consistent definition of "object" which would allow for operations on both
* (char *) & i` и i
. Наконец, в Стандарте нет ничего, что позволяло бы даже volatile
квалифицированному указателю получать доступ к аппаратным регистрам, которые не соответствуют определению «объекта».
c
иc++faq
.