((size_t *)(vec))[-1] 是否违反严格别名?

Is ((size_t *)(vec))[-1] a violation of strict-aliasing?

C (https://github.com/eteran/c-vector/blob/master/vector.h) 中流行的基于宏的向量通用实现使用以下内存布局。

+------+----------+---------+
| size | capacity | data... |
+------+----------+---------+
                  ^
                  | user's 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)

但这不是违反严格别名吗?

这个库处理内存的方式违反严格的别名。

虽然在 C 标准中没有提到名称,但严格的别名基本上意味着您不能访问一种类型的 object,就好像它是另一种类型的 object 一样。这些规则在第 6.5 节第 6 和第 7 段中有详细说明:

6 The effective type of an object for an access to its stored value is the declared type of the object, if any. 87) If a value is stored into an object having no declared type through an lvalue having a type that is not a character type, then the type of the lvalue becomes the effective type of the object for that access and for subsequent accesses that do not modify the stored value. If a value is copied into an object having no declared type using memcpy or memmove , or is copied as an array of character type, then the effective type of the modified object for that access and for subsequent accesses that do not modify the value is the effective type of the object from which the value is copied, if it has one. For all other accesses to an object having no declared type, the effective type of the object is simply the type of the lvalue used for the access.

7 An object shall have its stored value accessed only by an lvalue expression that has one of the following types: 88)

  • a type compatible with the effective type of the object,
  • a qualified version of a type compatible with the effective type of the object,
  • a type that is the signed or unsigned type corresponding to the effective type of the object,
  • a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object,
  • an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union), or
  • a character type.

87) Allocated objects have no declared type.

88) The intent of this list is to specify those circumstances in which an object may or may not be aliased.

例如,以下违反了严格的别名:

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 分配了 space。在 malloc 之后,__p 指向的内存没有类型。然后它分配给 vec:

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

首先,__p 被定义为 size_t *,因此 &__p[2] 创建一个指向 2 object 类型 size_t 之后的位置的指针,强制转换指向 void * 的指针,并将其分配给 vec。此时,none 的分配内存已经有类型了。接下来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 类型的值,所以从 __p[1] 开始的 sizeof(size_t) 字节包含一个 size_t.

类型的 object

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.

类型的 object

所以现在内存看起来像这样:

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

现在,当用户使用 vector_push_back 时,它会这样做:

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

这与写入任何分配的内存相同space。

所以因为 __p[0]__p[1] 只能通过 size_t * 访问,所以不存在严格的别名冲突。

问题的一件事是对齐。从 malloc 返回的内存经过适当对齐以处理任何类型的数据。但是,在不使用 struct 的情况下在此分配的内存中创建不同的 object 时,这些 object 可能无法正确对齐。

让我们以一个系统为例,其中 intsize_t 的大小都是 2 个字节,并假设从 malloc 返回的内存块的偏移量为 0。现在我们创建一个 long long 类型的向量,其大小至少为 8 个字节。创建向量后,第一个 size_t 位于偏移量 0,第二个位于偏移量 2。这很好,因为每个偏移量都是大小的倍数。但是,这意味着向量数据从偏移量 4 开始。这不是 8 的倍数,因此类型 long long 的 object 会在此处错位。

可以通过创建 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

这确保了元数据 header 之后的内存可以正确对齐以供任何使用,并且可以安全地访问 sizecapacity 字段。

标准中会导致此类代码出现问题的部分不是 "strict aliasing rule",而是指针算法的规范。 +- 在指针上的行为仅在原始指针和结果都指向 "just past" 或 "same array object" 内部的情况下定义,但标准是关于 "array object" 由从另一种类型的指针转​​换而来的指针所标识的内容含糊不清。

给出例如

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 可能会在 ifreturn 之间递增。然而,下标的定义方式相当于:

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;
}

开始看起来与您正在做的非常相似。适用于低级内存管理的实现应该可以毫无问题地有效处理此类代码,但标准并未试图禁止专门用于不涉及低级内存管理的任务的实现做出可能呈现的假设他们不适合做的任务。

不存在别名问题,因为 object 开头的两个单元格始终作为 size_t.

访问

但是,该库存在对齐问题。它假定从 malloc 获得的指针被 2 * sizeof (size_t) 字节取代仍然适合任何 object 类型。

这在主流架构上很可能是正确的,但这不是 standard-defined 的保证。解决这个问题的一种方法是定义一些可以调整的常量,例如:

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

然后可以使用 (size_t *)((char *)(vec)-VEC_HEADER_SIZE) 获得两个单元格 header,然后可以使用 [0] 和 [1] 对其进行索引以获得两个 size_t 单元格。