Является ли ((size_t *) (vec)) [-1] нарушением строгого алиасинга?

Популярная основанная на макросах общая реализация вектора в C (https://github.com/eteran/c-vector/blob/master/vector.h) использует следующую схему памяти.

+------+----------+---------+
| size | capacity | data... |
+------+----------+---------+
                  ^
                  | user pointer

Это позволяет использовать очень удобный API, где пользователь получает вектор, просто объявив указатель требуемого типа.

float *vf = NULL;
VEC_PUSH_BACK(vf, 3.0);

int *vi = NULL;
size_t sz = VEC_CAPACITY(vi);

Внутри, библиотека получает доступ к размеру и емкости, как это

#define VEC_CAPACITY(vec) \
    ((vec) ? ((size_t *)(vec))[-1] : (size_t)0)

Но разве это не нарушение строгого алиасинга?

Ответы

Ответ 1

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

Хотя в стандарте C имя не упоминается по имени, строгое псевдонимы в основном означают, что вы не можете получить доступ к объекту одного типа, как если бы это был объект другого типа. Эти правила изложены в параграфах 6 и 7 раздела 6.5:

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

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

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

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

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

Например, следующее нарушает строгий псевдоним:

float x = 3.14;
unsigned int *i = (unsigned int *)&x;
printf("value of x: %f, representation of x: %08x\n", x, *i);

Потому что он пытается прочитать float, как если бы он был int.

Работа векторной библиотеки не пытается это сделать.

Давайте посмотрим, как вектор создается библиотекой:

#define vector_grow(vec, count) \
do {                                                                                    \
    if(!(vec)) {                                                                        \
        size_t *__p = malloc((count) * sizeof(*(vec)) + (sizeof(size_t) * 2));          \
        assert(__p);                                                                    \
        (vec) = (void *)(&__p[2]);                                                      \
        vector_set_capacity((vec), (count));                                            \
        vector_set_size((vec), 0);                                                      \
    } else {                                                                            \
        size_t *__p1 = &((size_t *)(vec))[-2];                                          \
        size_t *__p2 = realloc(__p1, ((count) * sizeof(*(vec))+ (sizeof(size_t) * 2))); \
        assert(__p2);                                                                   \
        (vec) = (void *)(&__p2[2]);                                                     \
        vector_set_capacity((vec), (count));                                            \
    }                                                                                   \
} while(0)

И предположим, это называется так:

int *v = NULL;
vector_grow(v, 10);

Поскольку v имеет значение NULL, часть макроса if вводится. Он выделяет место для 10 int плюс 2 size_t. Сразу после malloc память, на которую указывает __p, не имеет типа. Затем он присваивает vec:

(vec) = (void *)(&__p[2]);

Во-первых, __p определяется как size_t *, поэтому &__p[2] создает указатель на местоположение после 2 объектов типа size_t, приводит этот указатель к void * и назначает его vec. На данный момент ни одна из выделенной памяти еще не имеет типа. Следующий vector_set_capacity называется:

#define vector_set_capacity(vec, size)   \
do {                                     \
    if(vec) {                            \
        ((size_t *)(vec))[-1] = (size);  \
    }                                    \
} while(0)

Сначала выполняется приведение vec к size_t *, который является исходным типом __p, и индексирует элемент -1. Это верно, потому что ((size_t *)(vec))[-1] совпадает с __p[1]. Теперь здесь записано значение типа size_t, поэтому байты sizeof(size_t), начиная с __p[1], содержат объект типа size_t.

Аналогично для vector_set_size:

#define vector_set_size(vec, size)      \
do {                                    \
    if(vec) {                           \
        ((size_t *)(vec))[-2] = (size); \
    }                                   \
} while(0)

((size_t *)(vec))[-2] - это то же самое, что и __p[0], и запись в него также создает объект типа size_t.

Итак, теперь память выглядит так:

+--------+----------+---------+
| size_t | size_t   | untyped |
+--------+----------+---------+
^        ^          ^
|        |          |
__p[0]   __p[1]     __p[2]==vec

Теперь, когда пользователь использует vector_push_back, он делает это:

vec[vector_size(vec)] = (value);

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

Так как доступ к __p[0] и __p[1] возможен только через size_t *, строгого нарушения псевдонимов не существует.

Однако одной проблемой является выравнивание. Память, возвращаемая из malloc, соответствующим образом выровнена для обработки данных любого типа. Однако при создании другого объекта в этой выделенной памяти без использования struct эти объекты могут быть неправильно выровнены.

Давайте возьмем в качестве примера систему, в которой int и size_t имеют размер 2 байта, и предположим, что блок памяти, возвращенный из malloc, имеет смещение 0. Теперь мы создадим вектор типа long long, размером не менее 8 байт. После создания вектора первый size_t находится со смещением 0, а второй со смещением 2. Это нормально, потому что смещение каждого кратно размеру. Однако это означает, что векторные данные начинаются со смещения 4. Это не кратно 8, поэтому объект типа long long будет смещен здесь.

Проблема выравнивания может быть решена путем создания объединения max_align_t и структуры из двух size_t:

union vector_meta {
    struct {
        size_t size;
        size_t capacity;
    }
    max_align_t align[2];
};

Тогда vec будет создан так:

union vector_meta *__p = malloc((count) * sizeof(*(vec)) + (sizeof(union vector_meta)));
assert(__p);
(vec) = (void *)(&__p[1]);

И вы можете получить доступ к размеру и емкости как:

((union vector_meta *)vec)[-1].size
((union vector_meta *)vec)[-1].capacity

Это обеспечивает правильное выравнивание памяти после заголовка метаданных для любого использования и возможность безопасного доступа к полям size и capacity.

Ответ 2

Нет проблем с наложением, потому что две ячейки в начале объекта всегда доступны как size_t.

Однако в библиотеке есть проблема с выравниванием. Предполагается, что указатель, полученный из malloc, который смещается на байты 2 * sizeof (size_t), все еще соответствующим образом выровнен для любого типа объекта.

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

#define VEC_HEADER_SIZE (2*sizeof(size_t)) // redefine if insufficient for alignment

Затем можно получить заголовок из двух ячеек, используя (size_t *)((char *)(vec)-VEC_HEADER_SIZE), который затем можно проиндексировать, используя [0] и [1], чтобы получить две ячейки size_t.

Ответ 3

Часть Стандарта, которая может вызвать проблемы с этим типом кода, является не "правилом строгого алиасинга", а спецификацией арифметики указателей. Поведение + и - в указателях определяется только в тех случаях, когда исходный указатель и результат будут указывать внутри или "только что" за "одним и тем же объектом массива", но Стандарт довольно расплывчат в отношении того, что ". объект массива "идентифицируется указателем, который приведен из указателя другого типа.

Учитывая, например,

struct foo { int length; int dat[10]; };
void test(struct foo *p, int index)
{
  if (index < p->length) p->dat[index]++;
  return p->length;
}

Стандарт не требует, чтобы реализация допускала возможность того, что index может быть -1, p->dat-1 может давать адрес p->length, и, следовательно, p->length может увеличиваться между if и return. Способ подписки определяется, однако код будет эквивалентен следующему:

struct foo { int length; int dat[10]; };
void test(struct foo *p, int index)
{
  int *pp = p->dat;
  if (index < p->length) pp[index]++;
  return p->length;
}

что в свою очередь эквивалентно:

struct foo { int length; int dat[10]; };
void test(struct foo *p, int index)
{
  int *pp = (int*)&p->dat;
  if (index < p->length) pp[index]++;
  return p->length;
}

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