C undefined. Строгое правило сглаживания или неправильное выравнивание?

Я не могу объяснить поведение выполнения этой программы:

#include <string> 
#include <cstdlib> 
#include <stdio.h>

typedef char u8;
typedef unsigned short u16;

size_t f(u8 *keyc, size_t len)
{
    u16 *key2 = (u16 *) (keyc + 1);
    size_t hash = len;
    len = len / 2;

    for (size_t i = 0; i < len; ++i)
        hash += key2[i];
    return hash;
}

int main()
{
    srand(time(NULL));
    size_t len;
    scanf("%lu", &len);
    u8 x[len];
    for (size_t i = 0; i < len; i++)
        x[i] = rand();

    printf("out %lu\n", f(x, len));
}

Итак, когда он скомпилирован с -O3 с gcc и запускается с аргументом 25, он вызывает segfault. Без оптимизации он отлично работает. Я разобрал его: он векторизован, и компилятор предполагает, что массив key2 выровнен по 16 байт, поэтому он использует movdqa. Очевидно, это UB, хотя я не могу это объяснить. Я знаю о правиле строжайшего aliasing, и это не тот случай (я надеюсь), потому что, насколько мне известно, строгое правило псевдонимов не работает с char s. Почему gcc предполагает, что этот указатель выровнен? Clang отлично работает, даже с оптимизацией.

ИЗМЕНИТЬ

Я изменил unsigned char на char и удалил const, он все равно segfaults.

EDIT2

Я знаю, что этот код не очень хорош, но он должен работать нормально, насколько я знаю о правиле строжайшего aliasing. Где именно нарушение?

Ответ 1

Код действительно нарушает правило строгого сглаживания. Тем не менее, существует не только нарушение псевдонимов, а сбой не происходит из-за нарушения псевдонимов. Это происходит потому, что указатель unsigned short неправильно выровнен; даже сама преобразование имеет поведение undefined, если результат не согласован соответствующим образом.

C11 (черновик n1570) Приложение J.2:

1 Поведение undefined в следующих случаях:

....

  • Преобразование между двумя типами указателей вызывает результат, который неправильно выровнен (6.3.2.3).

С 6.3.2.3p7, говоря

[...] Если результирующий указатель неправильно выровнен [68] для ссылочного типа, поведение undefined. [...]

unsigned short имеет требование выравнивания 2 для вашей реализации (x86-32 и x86-64), которую вы можете протестировать с помощью

_Static_assert(_Alignof(unsigned short) == 2, "alignof(unsigned short) == 2");

Однако вы вынуждаете u16 *key2 указывать на неглавный адрес:

u16 *key2 = (u16 *) (keyc + 1);  // we've already got undefined behaviour *here*!

Существует множество программистов, которые настаивают на том, что неравномерный доступ гарантированно работает на практике на x86-32 и x86-64 повсюду, и на практике не было бы проблем - ну, все они неправы.

В основном происходит то, что компилятор замечает, что

for (size_t i = 0; i < len; ++i)
     hash += key2[i];

может выполняться более эффективно с помощью SIMD-инструкций, если оно соответствующим образом выровнено. Значения загружаются в регистры SSE с помощью MOVDQA, что требует согласования аргумента с 16 байтами:

Когда операнд источника или получателя является операндом памяти, операнд должен быть выровнен по 16-байтовой границе или будет генерироваться исключение общей защиты (#GP).

В случаях, когда указатель не был правильно выровнен при запуске, компилятор будет генерировать код, который будет суммировать первые 1-7 неподписанных шорт один за другим, пока указатель не будет выровнен с 16 байтами.

Конечно, если вы начинаете с указателя, указывающего на нечетный адрес, даже не добавляя 7 раз 2, он будет привязан к адресу, который выровнен до 16 байтов. Конечно, компилятор даже не сгенерирует код, который будет обнаруживать этот случай, так как "поведение undefined, если преобразование между двумя типами указателей вызывает результат, который неправильно выровнен" и игнорирует ситуация полностью с непредсказуемыми результатами, что означает, что операнд MOVDQA не будет правильно выровнен, что приведет к сбою программы.


Нетрудно проверить, что это может произойти, даже не нарушая правил строгого сглаживания. Рассмотрим следующую программу, состоящую из 2 единиц перевода (если оба f и его вызывающий помещены в одну единицу перевода, мой GCC достаточно умен, чтобы заметить, что мы используем здесь упакованную структуру, а сгенерировать код с помощью MOVDQA):

единица перевода 1:

#include <stdlib.h>
#include <stdint.h>

size_t f(uint16_t *keyc, size_t len)
{
    size_t hash = len;
    len = len / 2;

    for (size_t i = 0; i < len; ++i)
        hash += keyc[i];
    return hash;
}

единица перевода 2

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <inttypes.h>

size_t f(uint16_t *keyc, size_t len);

struct mystruct {
    uint8_t padding;
    uint16_t contents[100];
} __attribute__ ((packed));

int main(void)
{
    struct mystruct s;
    size_t len;

    srand(time(NULL));
    scanf("%zu", &len);

    char *initializer = (char *)s.contents;
    for (size_t i = 0; i < len; i++)
       initializer[i] = rand();

    printf("out %zu\n", f(s.contents, len));
}

Теперь скомпилируйте и соедините их вместе:

% gcc -O3 unit1.c unit2.c
% ./a.out
25
zsh: segmentation fault (core dumped)  ./a.out

Обратите внимание, что там нет нарушения псевдонимов. Единственная проблема заключается в невыложенном uint16_t *keyc.

С -fsanitize=undefined создается следующая ошибка:

unit1.c:10:21: runtime error: load of misaligned address 0x7ffefc2d54f1 for type 'uint16_t', which requires 2 byte alignment
0x7ffefc2d54f1: note: pointer points here
 00 00 00  01 4e 02 c4 e9 dd b9 00  83 d9 1f 35 0e 46 0f 59  85 9b a4 d7 26 95 94 06  15 bb ca b3 c7
              ^ 

Ответ 2

Легально указывать указатель на объект на указатель на char, а затем перебирать все байты из исходного объекта.

Когда указатель на char на самом деле указывает на объект (был получен с помощью предыдущей операции), законно преобразовать обратно в указатель на исходный тип, а для стандарта требуется, чтобы вы возвращали исходное значение.

Но преобразование произвольного указателя в char в указатель на объект и разыменование полученного указателя нарушает правило строгого псевдонима и вызывает поведение undefined.

Итак, в вашем коде следующая строка: UB:

const u16 *key2 = (const u16 *) (keyc + 1); 
// keyc + 1 did not originally pointed to a u16: UB