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

Было несколько ошибок из-за строгого алиасинга, и я решил попытаться исправить их все. При детальном рассмотрении того, что это такое, иногда кажется, что GCC не выдает предупреждения, а также что некоторые вещи невозможно реализовать. По крайней мере, насколько я понимаю, все, что ниже, сломано. Итак, я неправильно понимаю, есть ли правильный способ сделать все эти вещи, или какой-то код просто должен технически нарушать правило и хорошо покрываться системными тестами?

Ошибки были из некоторого кода, где буферы char и unsigned char были смешаны, например. как показано ниже:

size_t Process(char *buf, char *end)
{
    char *p = buf;
    ProcessSome((unsigned char**)&p, (unsigned char*)end);
    //GCC decided p could not be changed by ProcessSome and so always returned 0
    return (size_t)(p - buf);
}

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

size_t Process(char *buf, char *end)
{
    unsigned char *buf2 = (unsigned char *)buf;
    unsigned char *p = buf2;
    unsigned char *end2 = (unsigned char*)end;
    ProcessSome(&p, end2);
    return (size_t)(p - buf2);
}

Также есть куча других мест, которые вроде бы работают без предупреждений

//contains a unsigned char* of data. Possibly from the network, disk, etc.
//the buffer contents itself is 8 byte aligned.
const Buffer *buffer = foo();
const uint16_t *utf16Text = (const uint16_t*)buffer->GetData();//const unsigned char*
//... read utf16Text. Does not even seem to ever be a warning


//also seems to work fine
size_t len = CalculateWorstCaseLength(...);
Buffer *buffer = new Buffer(len * 2);
uint16_t *utf16 = (uint16_t*)buffer->GetData();//unsigned char*
len = DoSomeProcessing(utf16, len, ...);
buffer->Truncate(len * 2);
send(buffer);

А некоторые с...

struct Hash128
{
    unsigned char data[16];
};
...
size_t operator ()(const Hash128 &hash)
{
    return *(size_t*)hash.data;//warning
}

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

int *x = fromsomewhere();//aligned to 16 bytes, array of 4
__m128i xmm = _mm_load_si128((__m128*i)x);
__m128i xmm2 = *(__m128i*)x;

Глядя на другие API, кажется, что есть различные случаи, которые, насколько я понимаю, нарушают правило (не сталкивался со специфичным для Linux/GCC, но уверен, что где-то он будет).

  1. CoCreateInstance Имеет выходной параметр void**, требующий явного приведения указателя. В Direct3D тоже есть что-то подобное.

  2. LARGE_INTEGER — это объединение, которое, вероятно, будет иметь чтение/запись для разных членов (например, какой-то код может использовать высокий/низкий, тогда какой-то другой может читать int64).

  3. Я помню, как реализация CPython довольно успешно приводит PyObject* к множеству других вещей, которые в начале имеют такое же распределение памяти.

  4. Многие реализации хеширования, которые я видел, приводят входной буфер к uint32_t*, а затем, возможно, используют uint8_t для обработки 1-3 байтов в конце.

  5. Почти каждая реализация распределителя памяти, которую я видел, использует char* или unsigned char*, который затем должен быть приведен к желаемому типу (возможно, через возвращенный void*, но внутри для выделения, по крайней мере, это был char)


person Will    schedule 18.07.2013    source источник
comment
Действительно ли ваш первый пример демонстрирует поведение, всегда возвращающее ноль, или он просто похож на код, который это делает? Я не смог воспроизвести это поведение.   -  person Vaughn Cato    schedule 18.07.2013
comment
Примерно так, просто разные имена функций. Это было с gcc 4.4.5 на модифицированной корпоративной красной шляпе, ориентированной на x64. Однако вся партия была встроена, поэтому это может быть очень специфичным для того, как GCC решил оптимизировать общую вещь.   -  person Will    schedule 18.07.2013


Ответы (2)


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

size_t Process(char *buf, char *end)
{
    char *p = buf;
    ProcessSome((unsigned char**)&p, (unsigned char*)end);
    //GCC decided p could not be changed by ProcessSome and so always returned 0
    return (size_t)(p - buf);
}

Проблема здесь в том, что вы пытаетесь смотреть на char* так, как будто это unsigned char*. Это не гарантировано. Учитывая, что приведение ясно видно, g++ немного тупит, не отключая автоматически строгий анализ псевдонимов, но технически это покрывается стандартом.

In

size_t Process(char *buf, char *end)
{
    unsigned char *buf2 = (unsigned char *)buf;
    unsigned char *p = buf2;
    unsigned char *end2 = (unsigned char*)end;
    ProcessSome(&p, end2);
    return (size_t)(p - buf2);
}

с другой стороны, все преобразования включают char* и unsigned char*, оба из которых могут быть псевдонимами чего угодно, поэтому для выполнения этой работы требуется компилятор.

Что касается остального, вы не говорите, какой тип возврата у buffer->GetData(), поэтому трудно сказать. Но если это char*, unsigned char* или void*, код полностью допустим (за исключением отсутствующего приведения во втором использовании buffer->GetData()). Пока все приведения включают char*, unsigned char* или void* (игнорируя квалификаторы const), компилятор должен предположить, что существует возможное сглаживание: когда исходный указатель имеет один из этих типов, он мог быть создается посредством приведения указателя к целевому типу, и язык гарантирует, что вы можете преобразовать любой указатель в один из этих типов и обратно в исходный тип и восстановить то же значение. (Конечно, если char* изначально не было uint16_t, у вас могут возникнуть проблемы с выравниванием, но компилятор обычно не может этого знать.)

Что касается последнего примера, вы не указываете тип hash.data, поэтому трудно сказать; если это char*, void* или unsigned char*, язык гарантирует ваш код (технически, при условии, что указатель char был создан путем преобразования size_t*; на практике, при условии, что указатель достаточно выровнен и байты, на которые указывают, не образуют значение захвата для size_t).

В общем: единственный действительно гарантированный способ "каламбурить" - это memcpy. В противном случае приведения указателя, такие как вы делаете, гарантированы до тех пор, пока они относятся к void*, char* или unsigned char* или из них, по крайней мере, в отношении алиасинга. (Из-за одного из них могут возникнуть проблемы с выравниванием или доступ к значению треппинга, если вы разыменуете его.)

Обратите внимание, что вы можете получить дополнительные гарантии от других стандартов. Poix требует что-то вроде:

void (*pf)();
*((void**)&pf) = ...

работать, например. (Как правило, немедленное приведение и разыменование будет работать, даже с g++, если вы не делаете ничего другого в функции, где может иметь значение псевдоним.)

И все компиляторы, которые я знаю, иногда позволяют использовать union для каламбура типов. (И, по крайней мере, некоторые из них, включая g++, потерпят неудачу при допустимом использовании union в других случаях. Правильная обработка union сложна для автора компилятора, если union не виден.)

person James Kanze    schedule 18.07.2013
comment
Я всегда благоговею перед людьми, которые могут следить за правилами псевдонимов. +1 :) - person jalf; 18.07.2013
comment
буфер всегда содержит беззнаковый массив символов. GetData просто возвращает (const) unsigned char*. Хэш — это просто структура с беззнаковыми символьными данными[16]. Обновлены примеры кода. Что касается CPython, я помню, что адреса сокетов делают что-то подобное и с sockaddr. - person Will; 18.07.2013
comment
Что позволяет компилятору предположить, что p нельзя изменить в первом примере? Несмотря на то, что &p приводится к unsigned char **, может ли компилятор предположить, что этот указатель никогда не будет приведен обратно к char ** во время вызова ProcessSome? - person Vaughn Cato; 18.07.2013
comment
@VaughnCato Типы unsigned char** и char** не связаны между собой. Правила более или менее такие же, как и для int* и double*. - person James Kanze; 18.07.2013
comment
@WillNewbery Что касается приведения CPython, существуют специальные правила, которые применяются, когда начальные последовательности нескольких разных структур POD идентичны. Это сделано из соображений совместимости с C; это классический способ реализации эквивалента наследования в C (именно это и делает CPython). Предположительно, g++ также справится с этим правильно. Обратите внимание, что это применимо только в случаях, когда рассматриваемые типы являются полными, а в C++ — для типов POD. И я смог найти ссылку на него только для членов союза в стандарте, хотя я видел, что он используется только с указателями. - person James Kanze; 18.07.2013
comment
Означает ли это, что unsigned char **p2 = (unsigned char **)&p; char **p3 = (char **)p2; ++*p3; является UB? (где p — это char *, как в первом примере) - person Vaughn Cato; 18.07.2013
comment
@VaughnCato Да. Единственными исключениями являются указатели на void и на типы символов. Ни unsigned char*, ни char* не являются void или символьным типом, поэтому особых исключений, связанных с указателями на них, не существует. - person James Kanze; 18.07.2013
comment
Я думал, что это связано с требованиями к выравниванию. Например, как описано в N3242, 5.2.10p7. Конечно, char * и unsigned char * (как объекты) имеют одинаковые требования к выравниванию. - person Vaughn Cato; 18.07.2013
comment
быть немного тупым Это то же самое, что и Линус, и я думаю, что это ужасное предложение: что, если компилятор разрешает что-то в нескольких случаях, потому что оптимизатор позволяет ему видеть, что вы делаете, но то при банальном изменении кода (возможно где-то еще) больше не разрешает? - person curiousguy; 03.09.2016
comment
единственный действительно гарантированный способ каламбура — это memcpy. Многие люди не понимают, что это невероятно полезный факт. Все, что может memcpy, memmove может сделать лучше. Например, вы можете memmove из блока памяти в себя получить бесплатное надежное решение проблемы строгого алиасинга. - person Aaron McDaid; 14.09.2016
comment
@JamesKanze: правило гарантий Common Initial Sequence значительно предшествовало представлению о том, что компиляторы могут налагать ограничения на псевдонимы на основе типов. Не было необходимости явно указывать, что эти гарантии применяются к доступу на основе указателей, поскольку для компиляторов было бы непрактично поддерживать такие гарантии с указателями на вещи, которые могут быть членами объединения, не поддерживая их также для структур, которые не были. - person supercat; 26.09.2017

На указатели char/unsigned char не распространяются строгие правила псевдонимов.

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

Таким образом, некоторые из ваших примеров действительны (а некоторые являются UB в соответствии с языком, но четко определены компилятором).

Но да, есть много кода, который нарушает правила псевдонимов. Также обратите внимание, что MSVC не выполняет оптимизацию на основе строгого сглаживания, поэтому особенно код, написанный для Windows, может быть склонен к нарушению строгих правил сглаживания.

person jalf    schedule 18.07.2013
comment
Итак, если char/unsigned char всегда исключены (я думал, что это просто преобразование T* в char*, а не наоборот), что не так с моим преобразованием хэша 128->size_t и с исходным процессом (который GCC на самом деле сломал? на релизных сборках)? Кроме того, которые действительно UB? - person Will; 18.07.2013
comment
Уф, все хорошие вопросы. Я не могу наизусть выучить правила алиасинга. Они ужасно сложны, как вы, кажется, знаете. :) Извиняюсь. Если вспомню, попробую поискать позже - person jalf; 18.07.2013
comment
Однако стоит отметить, что строгие предупреждения GCC о псевдонимах являются непоследовательными. Правильное обнаружение всех нарушений алиасинга вычислительно невозможно. Таким образом, GCC предупреждает вас о случаях, которые он может обнаружить, но отсутствие предупреждений о псевдонимах не означает, что в вашем коде нет нарушений псевдонимов. - person jalf; 18.07.2013
comment
@WillNewbery В/из char*, unsigned char* и void* гарантированно сохраняется значение: вы можете преобразовать указатель в char*, а затем преобразовать его обратно в исходный тип, и вы гарантированно получите то же значение. И если компилятор не может увидеть исходное преобразование, а только char*, он должен предположить, что char* могло появиться в результате такого преобразования. - person James Kanze; 18.07.2013
comment
@WillNewbery В вашем первом примере вы не конвертируете в/из char*, а из char** в unsigned char**. Никаких там гарантий. - person James Kanze; 18.07.2013
comment
@jalf С помощью g++ вы можете отключить анализ псевдонимов, используя -fno-strict-aliasing (что я бы рекомендовал в любом коде, где вы вводите каламбур). В документации для этой опции также указаны ограничения того, что компилятор поддерживает в отношении объединений. - person James Kanze; 18.07.2013