C - поведение преобразования между двумя указателями

Dec 11 2020

Обновление 2020-12-11: Спасибо @ "Some programmer dude" за предложение в комментарии. Моя основная проблема заключается в том, что наша команда реализует механизм хранения динамического типа. Мы выделяем несколько буферов массива char [PAGE_SIZE] с выравниванием по 16 для хранения динамических типов данных (фиксированной структуры нет). По соображениям эффективности мы не можем выполнять байтовое кодирование или выделять дополнительное пространство для использования memcpy.

Поскольку выравнивание было определено (т.е. 16), остальное - использовать приведение указателя для доступа к объектам указанного типа, например:

int main() {
    // simulate our 16-aligned malloc
    _Alignas(16) char buf[4096];

    // store some dynamic data:
    *((unsigned long *) buf) = 0xff07;
    *(((double *) buf) + 2) = 1.618;
}

Но наша команда оспаривает, является ли эта операция неопределенным поведением.


Я читал много похожих вопросов, например

  • Почему -Wcast-align не предупреждает о преобразовании char * в int * на x86?
  • Как преобразовать массив char в int в невыровненной позиции?
  • C неопределенным поведением. Строгое правило алиасинга или неправильное выравнивание?
  • SEI CERT C CS EXP36-C

Но это отличается от моей интерпретации стандарта C. Я хочу знать, не ошибаюсь ли я в этом.

Основная путаница связана с разделом 6.3.2.3 # 7 C11:

Указатель на тип объекта может быть преобразован в указатель на другой тип объекта. Если результирующий указатель неправильно выровнен 68) для указанного типа, поведение не определено.

68) В общем, понятие «правильно выровненный» является транзитивным: если указатель на тип A правильно выровнен для указателя на тип B, который, в свою очередь, правильно выровнен для указателя на тип C, то указатель на тип A правильно выровнен для указателя на тип C.

Относится ли полученный указатель к объекту указателя или значению указателя ?

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


Интерпретация A: объект-указатель

Мои мысли таковы: указатель сам по себе является объектом. Согласно 6.2.5 # 28 , разные указатели могут иметь разные требования к представлению и выравниванию. Следовательно, согласно 6.3.2.3 # 7 , пока два указателя имеют одинаковое выравнивание, они могут быть безопасно преобразованы без неопределенного поведения, но нет гарантии, что они могут быть разыменованы. Выразите эту идею в программе:

#include <stdio.h>

int main() {
    char buf[4096];

    char *pc = buf;
    if (_Alignof(char *) == _Alignof(int *)) {
        // cast safely, because they have the same alignment requirement?
        int *pi = (int *) pc; 
        printf("pi: %p\n", pi);
    } else {
        printf("char * and int * don't have the same alignment.\n");
    }
}

Интерпретация B: значение указателя

Однако, если в стандарте C11 говорится о значении указателя для ссылочного типа, а не об объекте указателя . Проверка выравнивания приведенного выше кода бессмысленна. Выразите эту идею в программе:

#include <stdio.h>

int main() {
    char buf[4096];

    char *pc = buf;
    
    /*
     * undefined behavior, because:
     * align of char is 1
     * align of int is 4
     * 
     * and we don't know whether the `value` of pc is 4-aligned.
     */
    int *pi = (int *) pc;
    printf("pi: %p\n", pi);
}

Какая интерпретация верна?

Ответы

6 dbush Dec 11 2020 at 01:36

Интерпретация B верна. Стандарт говорит об указателе на объект, а не о самом объекте. «Результирующий указатель» относится к результату приведения, а приведение не дает lvalue, поэтому оно относится к значению указателя после приведения.

Принимая код в вашем примере, предположим , что intдолжен быть выровнен по границе 4 байта, то есть его адрес должен быть кратен 4. Если адрес bufбудет 0x1001затем преобразовать этот адрес int *является недопустимым , так как значение указателя не выровнена. Если адрес bufбудет 0x1000затем преобразовать его int *в силе.

Обновлять:

Добавленный вами код решает проблему с выравниванием, так что в этом отношении все в порядке. Однако у него есть другая проблема: он нарушает строгий псевдоним.

Определенный вами массив содержит объекты типа char. Приводя адрес к другому типу и впоследствии разыменуя преобразованный тип типа, вы получаете доступ к объектам одного типа как к объектам другого типа. Это не допускается стандартом C.

Хотя термин «строгий псевдоним» не используется в стандарте, его концепция описана в параграфах 6 и 7 раздела 6.5:

6 Действующим типом объекта для доступа к его сохраненному значению является объявленный тип объекта, если таковой имеется. 87) Если значение сохраняется в объекте, не имеющем объявленного типа, через lvalue, имеющее тип, не являющийся символьным типом, то тип lvalue становится эффективным типом объекта для этого доступа и для последующих доступов, которые не являются изменить сохраненное значение. Если значение копируется в объект без объявленного типа с помощью memcpyили memmove, или копируется как массив символьного типа, то эффективным типом измененного объекта для этого доступа и для последующих доступов, которые не изменяют значение, является действующий тип. объекта, из которого копируется значение, если оно есть. Для всех других обращений к объекту, не имеющему объявленного типа, эффективный тип объекта - это просто тип lvalue, используемого для доступа.

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

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

...

87) Выделенные объекты не имеют объявленного типа.

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

В вашем примере вы пишете unsigned longи doubleповерх charобъектов. Ни один из этих типов не удовлетворяет условиям пункта 7.

Кроме того, арифметика указателя здесь недействительна:

 *(((double *) buf) + 2) = 1.618;

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

Так почему же это проблема для charмассива, а не для возвращаемого буфера malloc? Потому что память, возвращенная из, mallocне имеет эффективного типа, пока вы не сохраните в ней что-нибудь, как это описано в параграфе 6 и сноске 87.

Итак, со строгой точки зрения стандарта, вы делаете неопределенное поведение. Но в зависимости от вашего компилятора вы можете отключить строгий псевдоним, чтобы это сработало. Если вы используете gcc, вам нужно передать -fno-strict-aliasingфлаг

1 supercat Dec 11 2020 at 05:09

Стандарт не требует, чтобы реализации учитывали возможность того, что код когда-либо будет наблюдать значение в a T*, которое не выровнено для типа T. В clang, например, при нацеливании на платформы, чьи «большие» инструкции загрузки / сохранения не поддерживают невыровненный доступ, преобразование указателя в тип, выравниванию которого он не удовлетворяет, а затем его использование memcpyможет привести к тому, что компилятор создаст код, который завершится ошибкой, если указатель не выровнен, даже если memcpyв противном случае сам по себе не налагал бы никаких требований к выравниванию.

Например, при нацеливании на ARM Cortex-M0 или Cortex-M3:

void test1(long long *dest, long long *src)
{
    memcpy(dest, src, sizeof (long long));
}
void test2(char *dest, char *src)
{
    memcpy(dest, src, sizeof (long long));
}
void test3(long long *dest, long long *src)
{
    *dest = *src;
}

clang будет генерировать как для test1, так и для test3 код, который не сработает, если srcили destне будет выровнен, но для test2него будет генерироваться код, который больше и медленнее, но который будет поддерживать произвольное выравнивание исходного и целевого операндов.

Безусловно, даже при clang преобразование невыровненного указателя в объект long long*, как правило, не вызовет ничего странного само по себе, но факт, что такое преобразование приведет к появлению UB, освобождает компилятор от любой ответственности за обработку регистр невыровненного указателя в test1.