Что такое строгое правило сглаживания?

Отвечая на вопрос о распространенном неопределенном поведении в C, люди иногда ссылаются на строгое правило псевдонимов.
О чем они говорят?

Ответы

Ответ 1

Типичная ситуация, с которой вы сталкиваетесь со строгими проблемами псевдонимов, - это наложение структуры (например, сообщения устройства/сети) на буфер с размером слова в вашей системе (например, указатель на uint32_t или uint16_t s). Когда вы накладываете структуру на такой буфер или буфер на такую структуру с помощью приведения указателя, вы можете легко нарушить строгие правила наложения имен.

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

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-] строгий псевдоним в gcc))

  • Вы можете использовать char* для псевдонимов вместо вашего системного слова. Правила допускают исключение для char* (включая signed char и unsigned char). Всегда предполагалось, что char* псевдонимы других типов. Однако это не сработает по-другому: нет предположения, что ваша структура создает псевдоним буфера символов.

Начинающий будьте осторожны

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

Сноска

1 Типы, которые C 2011 6.5 7 разрешает доступ к lvalue:

  • тип, совместимый с эффективным типом объекта,
  • квалифицированная версия типа, совместимого с эффективным типом объекта,
  • тип, который является типом со знаком или без знака, соответствующим действующему типу объекта,
  • тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
  • агрегатный или объединенный тип, который включает один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегированного или автономного объединения), или
  • тип персонажа.

Ответ 2

Лучшее объяснение, которое я нашел, - Майк Актон, Понимание строго алиасинга. Он немного сосредоточился на разработке PS3, но в основном это просто GCC.

Из статьи:

"Строгое сглаживание - это предположение, сделанное компилятором C (или С++), что указатели на разузнавание объектов разных типов никогда не будут ссылаться на одно и то же место памяти (то есть псевдонимы друг друга.)"

Итак, в основном, если у вас есть int*, указывающий на некоторую память, содержащую int, а затем вы указываете a float* в эту память и используете ее как float, вы нарушаете правило. Если ваш код не соответствует этому, оптимизатор компилятора, скорее всего, нарушит ваш код.

Исключением из правила является char*, которому разрешено указывать любой тип.

Ответ 3

Это правило строгого сглаживания, которое содержится в разделе 3.10 стандарта С++ 03 (другие ответы дают хорошее объяснение, но никто не предоставил это правило):

Если программа пытается получить доступ к сохраненному значению объекта через значение l, отличного от одного из следующих типов, поведение undefined:

  • динамический тип объекта,
  • cv-квалифицированная версия динамического типа объекта,
  • тип, который является подписанным или неподписанным типом, соответствующим динамическому типу объекта,
  • тип, который является подписанным или неподписанным типом, соответствующим квитанционной версии динамического типа объекта,
  • совокупность или тип объединения, который включает один из вышеупомянутых типов среди своих членов (включая рекурсивно, член подгруппы или объединенный союз),
  • тип, который является (возможно, cv-квалифицированным) типом базового класса динамического типа объекта,
  • a char или unsigned char.

С++ 11 и C++ 14 (подчеркнуты изменения):

Если программа пытается получить доступ к сохраненному значению объекта через значение gl другого, чем одно из следующих типов, поведение undefined:

  • динамический тип объекта,
  • cv-квалифицированная версия динамического типа объекта,
  • тип, аналогичный (как определено в 4.4) для динамического типа объекта,
  • тип, который является подписанным или неподписанным типом, соответствующим динамическому типу объекта,
  • тип, который является подписанным или неподписанным типом, соответствующим квитанционной версии динамического типа объекта,
  • тип агрегата или объединения, который включает один из вышеупомянутых типов среди его элементов или нестатических членов данных (включая рекурсивно элемент или нестатический элемент данных субагрегата или содержащегося объединения),
  • тип, который является (возможно, cv-квалифицированным) типом базового класса динамического типа объекта,
  • a char или unsigned char.

Два изменения были небольшими: glvalue вместо lvalue и уточнение случая агрегата/объединения.

Третье изменение делает более сильную гарантию (ослабляет правило сильного aliasing): новая концепция подобных типов, которые теперь безопасны для псевдонимов.


Кроме того, формулировка C (C99; ISO/IEC 9899: 1999 6.5/7, точно такая же формулировка используется в ISO/IEC 9899: 2011 §6.5 ¶7):

Объект должен иметь сохраненное значение, доступное только с помощью значения lvalue выражение, имеющее один из следующих типов 73) или 88):

  • тип, совместимый с эффективным типом объекта,
  • квалифицированная версия типа, совместимого с эффективным типом объект,
  • тип, который является подписанным или неподписанным типом, соответствующим эффективный тип объекта,
  • тип, который является подписанным или неподписанным типом, соответствующим квалифицированная версия эффективного типа объекта,
  • тип агрегата или объединения, который включает один из вышеупомянутых типы среди своих членов (в том числе, рекурсивно, член subaggregate или contains union) или
  • тип символа.

73) или 88) Цель этого списка - указать те обстоятельства, при которых объект может или не может быть сглажен.

Ответ 4

Заметка

Это выдержка из моего "Что такое строгое правило алиасинга и почему нас это волнует?" записать.

Что такое строгий псевдоним?

В 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 = адрес

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, который в настоящее время находится в разработке. Это дезинфицирующее средство добавляет информацию о проверке типов в сегменте теневой памяти и проверяет доступы, чтобы определить, не нарушают ли они правила псевдонимов. Инструмент потенциально должен быть в состоянии отследить все нарушения псевдонимов, но может иметь большие накладные расходы во время выполнения.

Ответ 5

Строгое сглаживание не относится только к указателям, оно также влияет на ссылки, я написал статью об этом для повышения вики-разработчика и так хорошо воспринял, что превратил его в страницу на моем консультационном веб-сайте. Он полностью объясняет, что это такое, почему он так много путает людей и что с этим делать. Строгий алиасинг. В частности, это объясняет, почему профсоюзы являются рискованным поведением для С++, и почему использование memcpy - единственное исправление, переносимое как на C, так и на С++. Надеюсь, что это будет полезно.

Ответ 6

В качестве дополнения к тому, что писал Дуг Т., здесь это простой тестовый пример, который, вероятно, запускает его с помощью 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" в функции "проверка". Из-за этого компилятор оптимизирует 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 полностью исчезло из кода ассемблера.

Ответ 7

Тип punning с помощью указателей (в отличие от использования объединения) является основным примером нарушения строгой псевдонимы.

Ответ 8

В соответствии с обоснованием C89, авторы Стандарта не хотели требовать, чтобы компиляторы задавали такой код:

int x;
int test(double *p)
{
  x=5;
  *p = 1.0;
  return x;
}

необходимо перезагрузить значение x между оператором присваивания и возврата, чтобы позволить возможность p указывать на x, а присвоение *p может, следовательно, изменить значение of x. Понятие о том, что компилятор должен иметь право предполагать, что в подобных ситуациях не будет сглаживания, не противоречиво.

К сожалению, авторы C89 написали свое правило таким образом, что, если бы его прочитали буквально, он сделал бы даже следующую функцию invoke Undefined Behavior:

void test(void)
{
  struct S {int x;} s;
  s.x = 1;
}

потому что он использует lvalue типа int для доступа к объекту типа struct S, а int не относится к типам, которые можно использовать для доступа к struct S. Поскольку было бы абсурдно относиться ко всем использованиям элементов и объединений несимвольных типов как Undefined Behavior, почти все признают, что существуют, по крайней мере, некоторые обстоятельства, когда lvalue одного типа может использоваться для доступа к объекту другой тип. К сожалению, Комитет по стандартам С не смог определить, каковы эти обстоятельства.

Большая часть проблемы является результатом отчета об ошибке № 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" указано, что программа вызывает Undefined "Поведение", потому что действие записи члена объединения типа "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 из-за перекрытия между созданием и использованием двух указателей, это сделало бы вещи более понятными, не добавляя "Эффективные типы" или другую такую ​​сложность.

Ответ 9

После прочтения многих ответов я чувствую необходимость добавить что-то:

Строгое сглаживание (которое я опишу немного) важно, потому что:

  • Доступ к памяти может быть дорогостоящим (с точки зрения производительности), поэтому данные обрабатываются в регистре CPU, прежде чем они будут возвращены в физическую память.

  • Если данные в двух разных регистрах ЦП будут записаны в одно и то же пространство памяти, , мы не можем предсказать, какие данные "выживут" , когда мы с кодом на C.

    В сборке, где мы будем вручную загружать и выгружать регистры процессора, мы будем знать, какие данные остаются нетронутыми. Но C (к счастью) абстрагирует эту деталь.

Поскольку два указателя могут указывать на одно и то же место в памяти, это может привести к сложному коду, который обрабатывает возможные конфликты.

Этот дополнительный код медленный и повреждает производительность, поскольку он выполняет дополнительные операции чтения/записи в памяти, которые медленнее и (возможно) не нужны.

Правило Strict aliasing позволяет избежать избыточного машинного кода в случаях, когда должно быть безопасным предположить, что два указателя не указывают на один и тот же блок памяти (см. также restrict ключевое слово).

Строгое сглаживание позволяет с уверенностью предположить, что указатели на разные типы указывают на разные местоположения в памяти.

Если компилятор замечает, что два указателя указывают на разные типы (например, int * и a float *), предполагается, что адрес памяти отличается и не будет защищен от столкновений с адресами памяти, что приводит к ускорению машинного кода.

Например:

Предположим, что следующая функция:

void merge_two_ints(int *a, int *b) {
  *b += *a;
  *a += *b;
}

Чтобы обрабатывать случай, в котором a == b (оба указателя указывают на одну и ту же память), нам нужно заказать и протестировать способ загрузки данных из памяти в регистры процессора, поэтому код может закончиться это:

  • загрузите a и b из памяти.

  • добавить a в b.

  • сохранить b и перезагрузить a.

    (сохранение из регистров CPU в память и загрузка из памяти в регистр CPU).

  • добавить b в a.

  • сохранить a (из регистра CPU) в память.

Шаг 3 очень медленный, потому что ему нужно получить доступ к физической памяти. Однако для защиты от экземпляров, где a и b указывают на один и тот же адрес памяти, требуется защита.

Строгое сглаживание позволит нам предотвратить это, сообщив компилятору, что эти адреса памяти отличаются друг от друга (что в этом случае позволит еще больше оптимизировать, что не может быть выполнено, если указатели разделяют адрес памяти).

  • Это можно передать компилятору двумя способами, используя разные типы, чтобы указать на. то есть:.

    void merge_two_numbers(int *a, long *b) {...}
    
  • Использование ключевого слова restrict. то есть:.

    void merge_two_ints(int * restrict a, int * restrict b) {...}
    

Теперь, выполнив правило Strict Aliasing, можно избежать шага 3, и код будет работать значительно быстрее.

Фактически, добавив ключевое слово restrict, вся функция может быть оптимизирована для:

  • загрузите a и b из памяти.

  • добавить a в b.

  • сохранить результат как до a, так и b.

Эта оптимизация не могла быть выполнена раньше, из-за возможного столкновения (где a и b были бы утроены, а не удвоены).

Ответ 10

Строгое сглаживание не позволяет использовать разные типы указателей для одних и тех же данных.

Эта статья должна помочь вам разобраться в проблеме подробно.

Ответ 11

Технически в C++ правило строгого сглаживания, вероятно, никогда не применимо.

Обратите внимание на определение оператора косвенности (*):

Оператор унарного * выполняет косвенное обращение: выражение, к которому оно применяется, должно быть указателем на тип объекта или указателем на тип функции, а результатом является lvalue, относящееся к объекту или функции, к которой относится выражение.

Также из определения glvalue

Значение gl - выражение, оценка которого определяет идентичность объекта, (... snip)

Таким образом, в любой четко определенной трассировке программы glvalue ссылается на объект. Таким образом, так называемое строгое правило сглаживания не применяется. Возможно, это не то, что хотели дизайнеры.