在 C 中具有严格别名和严格对齐的面向对象模式的最佳实践

Best practices for object oriented patterns with strict aliasing and strict alignment in C

我编写嵌入式 C 代码已经很多年了,新一代的编译器和优化在警告有问题代码的能力方面肯定变得更好了。

但是,至少有一个(根据我的经验,这很常见)用例继续引起悲伤,其中一个公共基类型在多个结构之间共享。考虑这个人为的例子:

#include <stdio.h>

struct Base
{
    unsigned short t; /* identifies the actual structure type */
};

struct Derived1
{
    struct Base b; /* identified by t=1 */
    int i;
};

struct Derived2
{
    struct Base b; /* identified by t=2 */
    double d;
};


struct Derived1 s1 = { .b = { .t = 1 }, .i = 42 };
struct Derived2 s2 = { .b = { .t = 2 }, .d = 42.0 };

void print_val(struct Base *bp)
{
    switch(bp->t)
    {
    case 1: 
    {
        struct Derived1 *dp = (struct Derived1 *)bp;
        printf("Derived1 value=%d\n", dp->i);
        break;
    }
    case 2:
    {
        struct Derived2 *dp = (struct Derived2 *)bp;
        printf("Derived2 value=%.1lf\n", dp->d);
        break;
    }
    }
}

int main(int argc, char *argv[])
{
    struct Base *bp1, *bp2;

    bp1 = (struct Base*) &s1;
    bp2 = (struct Base*) &s2;
    
    print_val(bp1);
    print_val(bp2);

    return 0;
}

根据 ISO/IEC9899,上面代码中的转换应该没问题,因为它依赖于与包含结构共享相同地址的结构的第一个成员。条款 6.7.2.1-13 是这样说的:

Within a structure object, the non-bit-field members and the units in which bit-fields
reside have addresses that increase in the order in which they are declared. A pointer to a
structure object, suitably converted, points to its initial member (or if that member is a
bit-field, then to the unit in which it resides), and vice versa. There may be unnamed
padding within a structure object, but not at its beginning.

从派生类型到基础类型的转换工作正常,但在 print_val() 中转换回派生类型会生成对齐警告。然而,众所周知这是安全的,因为它特别是上述条款的“反之亦然”部分。问题是编译器根本不知道我们已经通过其他方式保证该结构实际​​上是其他类型的实例。

使用标志 -std=c99 -pedantic -fstrict-aliasing -Wstrict-aliasing -Wcast-align=strict -O3 使用 gcc 版本 9.3.0 (Ubuntu 20.04) 编译时,我得到:

alignment-1.c: In function ‘print_val’:
alignment-1.c:30:31: warning: cast increases required alignment of target type [-Wcast-align]
   30 |         struct Derived1 *dp = (struct Derived1 *)bp;
      |                               ^
alignment-1.c:36:31: warning: cast increases required alignment of target type [-Wcast-align]
   36 |         struct Derived2 *dp = (struct Derived2 *)bp;
      |                               ^

clang 10中出现了类似的警告。

返工 1:指向指针的指针

在某些情况下用于避免对齐警告的方法(当指针已知对齐时,就像这里的情况一样)是使用中间指针到指针。例如:

struct Derived1 *dp = *((struct Derived1 **)&bp);

然而,这只是将对齐警告换成了严格的别名警告,至少在 gcc 上是这样:

alignment-1a.c: In function ‘print_val’:
alignment-1a.c:30:33: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
   30 |         struct Derived1 *dp = *((struct Derived1 **)&bp);
      |                                ~^~~~~~~~~~~~~~~~~~~~~~~~

如果将 done 转换为左值也是如此,即:*((struct Base **)&dp) = bp; 在 gcc 中也会发出警告。

值得注意的是,只有 gcc 抱怨这个 - clang 10 似乎在没有警告的情况下接受了这两种方式,但我不确定这是不是故意的。

返工 2:结构并集

另一种修改此代码的方法是使用联合。所以 print_val() 函数可以重写为:

void print_val(struct Base *bp)
{
    union Ptr
    {
        struct Base b;
        struct Derived1 d1;
        struct Derived2 d2;
    } *u;

    u = (union Ptr *)bp;
...

可以使用联合访问各种结构。虽然这工作正常,但转换为联合仍被标记为违反对齐规则,就像原始示例一样。

alignment-2.c:33:9: warning: cast from 'struct Base *' to 'union Ptr *' increases required alignment from 2 to 8 [-Wcast-align]
    u = (union Ptr *)bp;
        ^~~~~~~~~~~~~~~
1 warning generated.

返工 3:指针并集

按如下方式重写函数在 gcc 和 clang 中都能干净地编译:

void print_val(struct Base *bp)
{
    union Ptr
    {
        struct Base *bp;
        struct Derived1 *d1p;
        struct Derived2 *d2p;
    } u;

    u.bp = bp;

    switch(u.bp->t)
    {
    case 1:
    {
        printf("Derived1 value=%d\n", u.d1p->i);
        break;
    }
    case 2:
    {
        printf("Derived2 value=%.1lf\n", u.d2p->d);
        break;
    }
    }
}

关于这是否真的有效,似乎存在相互矛盾的信息。特别是,https://cellperformance.beyond3d.com/articles/2006/06/understanding-strict-aliasing.html 上的一篇较早的别名文章明确指出类似的构造是无效的(请参阅 link 中的通过联合 (3) 进行转换)。

在我的理解中,因为联合的指针成员都共享一个公共基类型,这实际上并不违反任何别名规则,因为所有对 struct Base 的访问实际上都是通过一个对象完成的type struct Base - 无论是通过取消引用 bp 联合成员还是通过访问 d1pd2pb 成员对象。无论哪种方式,它都通过 struct Base 类型的对象正确访问成员 - 据我所知,没有别名。

具体问题

  1. 返工 3 中建议的指针联合是否是一种可移植、安全、符合标准且可接受的方法?
  2. 如果不是,是否有一种方法 完全可移植且符合标准,并且不依赖于任何 platform-defined/compiler-specific 行为或选项?

在我看来,由于这种模式在 C 代码中相当常见(在没有真正的 OO 构造的情况下,如 C++),以一种可移植的方式执行此操作应该更直接,而不会以一种形式收到警告或另一个。

提前致谢!

更新:

使用中间体 void* 可能是执行此操作的“正确”方法:

struct Derived1 *dp = (void*)bp;

这当然有效,但它确实允许任何转换,无论类型兼容性如何(我认为 C 的较弱类型系统从根本上归咎于此,我真正想要的是 C++ 和 static_cast<> 运算符)

但是,我关于严格别名规则的基本问题(误解?)仍然存在:

为什么使用联合类型 and/or 指针到指针违反严格的别名规则?换句话说,在 main 中所做的(获取 b 成员的地址)与在 print_val() 中所做的除了 direction 之外的根本不同转换?两者都会产生相同的情况 - 两个指向同一内存的指针,它们是不同的结构类型 - struct Base*struct Derived1*.

在我看来,如果这在任何方面都违反了严格的别名规则,那么引入中间 void* 转换不会改变根本问题。

您可以通过首先转换为 void * 来避免编译器警告:

struct Derived1 *dp = (struct Derived1 *) (void *) bp;

(转换为 void * 后,在上述声明中自动转换为 struct Derived1 *,因此您可以删除转换。)

使用指向指针的指针或联合重新解释指针的方法不正确;它们违反了别名规则,因为 struct Derived1 *struct Base * 不适合相互别名。不要使用那些方法。

(由于 C 2018 6.2.6.1 28,它说“......所有指向结构类型的指针都应具有相同的表示和对齐要求......”,可以提出重新解释一个指向 - C 标准支持通过联合将 a 结构作为另一个结构。脚注 49 说:“相同的表示和对齐要求意味着作为函数参数、return 函数值和联合成员的可互换性。”然而,充其量这是 C 标准中的一个问题,应尽可能避免。)

Why does using a union type and/or pointer-to-pointer violate strict aliasing rules? In other words what is fundamentally different between what is done in main (taking address of b member) and what is done in print_val() other than the direction of the conversion? Both yield the same situation - two pointers that point to the same memory, which are different struct types - a struct Base* and a struct Derived1*.

It would seem to me that if this were violating strict aliasing rules in any way, the introduction of an intermediate void* cast would not change the fundamental problem.

严格的别名冲突发生在指针的别名中,而不是结构的别名中。

如果您有一个 struct Derived1 *dp 或一个 struct Base *bp 并且您使用它来访问内存中实际存在 struct Derived1 或 [=24= 的位置],则不存在别名冲突,因为您正在通过对象类型的左值访问对象,这是别名规则允许的。

但是,这个问题建议为指针设置别名。在*((struct Derived1 **)&bp);中,&bp就是有一个struct Base *的位置。这个astruct Base *的地址转换为astruct Derived1 **的地址,然后*形成一个struct Derived1 *类型的左值。然后该表达式用于使用 struct Derived1 * 类型访问 struct Base *。在别名规则中没有匹配项;它列出的用于访问 struct Base * 的类型中的 none 是 struct Derived1 *.

Linux 内核为所描述的概念提供了一个有趣的替代方案。 它基于嵌入的思想。

struct Base {
  int x;
};

struct Derived {
  struct Base base; // base is embedded into Derived
  int y;
};

从指针到派生的转换很容易:

struct Derived derived;
struct Base* base = &derived.base;

反向转换是使用container_of宏完成的。 这个宏有点棘手,但它可以简化从指向 Base.

的指针减去 Derivedbase 成员的偏移量
(struct Derived *)(void*)((char*)base - offsetof(struct Derived, base))

转换为 char* 是必不可少的,因为:

  • 允许指针运算结果为offsetof()
  • 因为 char* 可以为任何东西起别名,代码在严格的别名规则下是正确的
  • 通过 void* 的转换用于静音 -Wcast-align=strict,因为生成的对象将正确对齐

示例用法:

struct Derived* derived = container_of(base, struct Derived, base);

这种方法很有吸引力,因为:

  • 指针操作是一个简单的减法,不需要解引用任何指针,没有额外的缓存未命中
  • 基础class可以位于派生结构中的任何地方
  • 轻松支持多重继承
  • 可以在不破坏现有代码的情况下修改结构布局

借助函数指针,可以稳健地实现多态性。

#include <stdio.h>
#include <stddef.h>

#define container_of(ptr, type, member) \
  (type*)(void*)((char*)ptr - offsetof(type, member))

struct Person {
  int age;
  void (*greet)(struct Person *);
};

struct NamedPerson {
  char name[32];
  struct Person base;
};

void NamedPerson_greet(struct Person *p) {
   struct NamedPerson *np = container_of(p, struct NamedPerson, base); 
   printf("Hello!. My name is %s, and I'm %d years old.\n",
          np->name, np->base.age);
}

struct NamedPerson George = {
  .name = "George",
  .base.age = 42,
  .base.greet = NamedPerson_greet,
};

int main() {
  struct Person *person = &George.base;
  person->greet(person); // Hello, my name is George ...
}

在启用所有别名警告的情况下,在迂腐模式下编译时没有任何警告。

gcc prog.c -std=c99 -Wall -pedantic -fstrict-aliasing -Wstrict-aliasing -Wcast-align=strict -O3

需要说明的是,原始代码是正确的,不需要重新编写;唯一的问题是不美观的警告。

问题的其余部分,以及到目前为止的答案,都集中在如何修改代码以诱使编译器不发出警告。

恕我直言,最好直接处理不需要的警告,而不是破坏代码。因为损坏的代码更难阅读和理解;编译器的未来版本可能会改变触发警告的方式。

这方面的方法包括:

  • 完全禁用该警告
  • 有条件地为执行安全操作的代码部分禁用该警告(可能借助宏或内联函数)
  • 过滤编译器输出(例如通过 grep -v)以移除警告