类似 malloc 函数的严格别名的原因

How reason about strict-aliasing for malloc-like functions

据我所知,在三种情况下别名是可以的

  1. 仅限定符或符号不同的类型可以互为别名。
  2. struct 或 union 类型可以为包含在其中的类型设置别名。
  3. 将 T* 转换为 char* 是可以的。 (不允许相反)

这些在阅读 John Regehrs blog posts 中的简单示例时是有意义的,但我不确定如何推理更大示例的别名正确性,例如类似 malloc 的内存安排。

我正在阅读 Per Vognsens re-implementation of Sean Barrets stretchy buffers。它使用类似 malloc 的模式,其中缓冲区在它之前具有关联的元数据。

typedef struct BufHdr {
    size_t len;
    size_t cap;
    char buf[];
} BufHdr;

通过指针减去偏移量访问元数据b:

#define buf__hdr(b) ((BufHdr *)((char *)(b) - offsetof(BufHdr, buf)))

这是原始 buf__grow 函数的稍微简化的版本,它扩展了缓冲区和 returns buf 作为 void*.

void *buf__grow(const void *buf, size_t new_size) { 
     // ...  
     BufHdr *new_hdr;  // (1)
     if (buf) {
         new_hdr = xrealloc(buf__hdr(buf), new_size);
     } else {
         new_hdr = xmalloc(new_size);
         new_hdr->len = 0;
     }
     new_hdr->cap = new_cap;
     return new_hdr->buf;
}

用法示例(buf__grow 隐藏在宏后面,但这里为清楚起见它是公开的):

int *ip = NULL;
ip = buf__grow(ip, 16);
ip = buf__grow(ip, 32);

在这些调用之后,我们在堆上有 32 + sizeof(BufHdr) 字节的大内存区域。我们有 ip 指向那个区域,我们有 new_hdrbuf__hdr 在执行的不同点指向它。

问题

这里是否存在严格的别名违规? AFAICT,ip 和某些 BufHdr 类型的变量不应被允许指向同一内存。

或者是 buf__hdr 没有创建左值这一事实意味着它没有使用与 ip 相同的内存别名吗? new_hdr 包含在 buf__growip 不是 "live" 的事实意味着它们也没有别名吗?

如果 new_hdr 在全球范围内,这会改变一切吗?

C 编译器是跟踪存储类型还是只跟踪变量类型?如果有存储,比如buf__grow中分配的内存区域没有任何变量指向它,那么该存储的类型是什么?只要没有与该内存关联的变量,我们是否可以自由地重新解释该存储?

Is there a strict-aliasing violation here? AFAICT, ip and some variable of type BufHdr shouldn't be allowed to point to the same memory.

重要的是要记住,只有当您对内存位置进行值访问时才会发生严格的别名冲突,并且编译器认为存储在该内存位置的内容属于不同类型。因此,谈论指针的类型并不重要,重要的是谈论它们指向的任何内容的有效类型

分配的内存块没有声明类型。适用的是C11 6.5/6:

The effective type of an object for an access to its stored value is the declared type of the object, if any. 87)

注释 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.

这意味着一旦我们访问分配的内存块,存储在那里的任何内容的有效类型就会成为我们存储在那里的任何内容的类型。

在您的情况下,第一次访问发生在 new_hdr->len = 0;new_hdr->cap = new_cap; 行,使这些地址处的数据成为有效类型 size_t

buf 仍未访问,因此该部分内存还没有有效类型。你 return new_hdr->buf 并设置一个 int* 指向那里。


接下来会发生的事情,我想是 buf__hdr(ip)。在该宏中,指针被转换为 (char *),然后发生一些指针减法:

(b) - offsetof(BufHdr, buf) // undefined behavior

这里我们正式得到了未定义的行为,但出于与严格别名完全不同的原因。 b 不是指向与 b 之前存储的相同数组的指针。相关部分是加法运算符的规范 6.5.6:

For subtraction, one of the following shall hold:
— both operands have arithmetic type;
— both operands are pointers to qualified or unqualified versions of compatible complete object types; or
— the left operand is a pointer to a complete object type and the right operand has integer type.

前两个显然不适用。在第三种情况下,我们没有指向一个完整的对象类型,因为 buf 还没有得到一个有效的类型。据我了解,这意味着我们违反了约束,我在这里不完全确定。但是,我非常确定违反了以下内容,6.5.6/9:

When two pointers are subtracted, both shall point to elements of the same array object, or one past the last element of the array object; the result is the difference of the subscripts of the two array elements. The size of the result is implementation-defined, and its type (a signed integer type) is ptrdiff_t defined in the <stddef.h> header. If the result is not representable in an object of that type, the behavior is undefined

所以这绝对是一个错误。


如果我们忽略那部分,实际访问 (BufHdr *) 是没问题的,因为 BufHdr 是一个结构 ("aggregate") 包含访问对象的有效类型 (2x size_t).而这里是第一次访问buf的内存,得到有效类型char[](灵活数组成员)。

没有严格的别名违规,除非您在调用上述宏后去访问 ip 作为 int.


If new_hdr were in global scope, would that change things?

不,指针类型无关紧要,只有pointed-at对象的有效类型。

Do the C compiler track the type of storage or only the types of variables?

如果它希望像 gcc 一样进行优化,它需要跟踪对象的有效类型,假设不会发生严格的别名违规。

Are we free to reinterpret that storage as long as there is no variable associated with that memory?

是的,您可以使用任何类型的指针指向它 - 因为它是分配的内存,所以在您进行值访问之前它不会获得有效类型。

标准没有定义任何意味着一种类型的左值可以用来派生可以用来访问存储的第二种类型的左值,除非后者有一个字符类型。甚至像这样基本的东西:

union foo { int x; float y;} u = {0};
u.x = 1;

调用 UB,因为它使用 int 类型的左值来访问与 union foofloat 类型的对象关联的存储。另一方面,该标准的作者可能认为,由于没有编译器编写者会笨到使用左值类型规则作为不以有用方式处理上述内容的理由,因此没有必要尝试制定明确的规则强制要求他们这样做。

如果编译器保证不 "enforce" 规则,除非以下情况:

  1. 在函数或循环的特定执行期间修改对象;
  2. 两个或多个不同类型的左值用于在此类执行期间访问存储;和
  3. 在这种执行过程中,没有一个左值明显地主动从另一个派生出来

这样的保证足以让 malloc() 实现不会出现 "aliasing" 相关问题。虽然我怀疑标准的作者可能希望编译器编写者自然地支持这样的保证,无论它是否被强制要求,除非使用 -fno-strict-aliasing 标志,否则 gcc 和 clang 都不会这样做。

不幸的是,当在缺陷报告 #028 中被要求澄清 C89 规则的含义时,委员会的回应是建议通过取消引用指向联合成员的指针形成的左值将主要表现得像直接由成员形成的左值-access 运算符,除了如果直接在联合成员上完成将调用实现定义的行为的操作,如果在指针上完成则应调用 UB。在编写 C99 时,委员会决定 "clarify" 将该原则编入 C99 的 "Effective Type" 规则,而不是承认派生类型的左值可用于访问父对象的任何情况 [遗漏有效类型规则无法纠正!]。