C - поведение преобразования между двумя указателями
Обновление 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);
}
Какая интерпретация верна?
Ответы
Интерпретация 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
флаг
Стандарт не требует, чтобы реализации учитывали возможность того, что код когда-либо будет наблюдать значение в 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
.