glibc 的严格别名规则和 strlen 实现

Strict aliasing rule and strlen implementation of glibc

一段时间以来,我一直在阅读有关严格别名规则的内容,但我开始感到非常困惑。首先,我已经阅读了这些问题和一些答案:

根据他们的说法(据我所知),使用指向另一种类型的指针访问 char 缓冲区违反了严格的别名规则。但是,strlen() 的 glibc 实现有这样的代码(删除了注释和 64 位实现):

size_t strlen(const char *str)
{
    const char *char_ptr;
    const unsigned long int *longword_ptr;
    unsigned long int longword, magic_bits, himagic, lomagic;

    for (char_ptr = str; ((unsigned long int) char_ptr 
             & (sizeof (longword) - 1)) != 0; ++char_ptr)
       if (*char_ptr == '[=10=]')
           return char_ptr - str;

    longword_ptr = (unsigned long int *) char_ptr;

    himagic = 0x80808080L;
    lomagic = 0x01010101L;

    for (;;)
    { 
        longword = *longword_ptr++;

        if (((longword - lomagic) & himagic) != 0)
        {
            const char *cp = (const char *) (longword_ptr - 1);

            if (cp[0] == 0)
                return cp - str;
            if (cp[1] == 0)
                return cp - str + 1;
            if (cp[2] == 0)
                return cp - str + 2;
            if (cp[3] == 0)
                return cp - str + 3;
        }
    }
}

longword_ptr = (unsigned long int *) char_ptr; 行显然将 unsigned long int 别名为 char。我不明白是什么让这成为可能。我看到代码处理对齐问题,所以没有问题,但我认为这与严格的别名规则无关。

第三个链接问题的公认答案是:

However, there is a very common compiler extension allowing you to cast properly aligned pointers from char to other types and access them, however this is non-standard.

我唯一想到的是-fno-strict-aliasing选项,是这样吗?我无法在 glibc 实现者所依赖的任何地方找到它的记录,并且评论以某种方式暗示这个转换是在没有任何顾虑的情况下完成的,就像很明显不会有任何问题一样。这让我觉得这确实很明显,我遗漏了一些愚蠢的东西,但我的搜索失败了。

在 ISO C 中,此代码将违反严格的别名规则。 (并且还违反了不能定义与标准库函数同名的函数的规则)。然而,这段代码不受 ISO C 规则的约束。标准库甚至不必用类 C 语言实现。该标准仅指定实现实现标准功能的行为。

在这种情况下,我们可以说实现是在类似 C 的 GNU 方言中,如果代码是使用作者预期的编译器和设置编译的,那么它将成功实现标准库函数。

潜在的假设可能是该函数是单独编译的,不可用于内联或其他跨函数优化。这意味着没有编译时信息在函数内部或外部流动。

该函数不会尝试通过指针修改任何内容,因此不存在冲突。

标准的措辞实际上比实际的编译器实现更奇怪:C 标准谈​​论声明的对象类型,但编译器只看到指向这些对象的指针。因此,当编译器看到从 char*unsigned long* 的强制转换时,它必须假设 char* 实际上是在别名声明类型为 unsigned long 的对象, 使转换正确。

提醒一句:我假设 strlen() 被编译成一个库,以后只链接到应用程序的其余部分。因此,优化器在编译时看不到函数的使用,迫使它假设转换为 unsigned long* 确实合法。如果您使用

调用 strlen()
short myString[] = {0x666f, 0x6f00, 0};
size_t length = strlen((char*)myString);    //implementation now invokes undefined behavior!

strlen() 中的转换是未定义的行为,如果编译器在编译 strlen() 本身时看到您的使用,则允许您的编译器去除几乎整个 strlen() 的主体。唯一允许 strlen() 在此调用中按预期行为的事实是,strlen() 被单独编译为一个库,对优化器隐藏未定义的行为,因此优化器必须承担强制转换编译时合法strlen()

因此,假设优化器无法调用 "undefined behavior",从 char* 转换为任何其他内容的危险的原因不是别名,而是对齐。在某些硬件上,如果您尝试访问未对齐的指针,就会发生奇怪的事情。硬件可能会从错误的地址加载数据,引发中断,或者只是非常缓慢地处理请求的内存加载。这就是为什么 C 标准通常声明此类转换为未定义行为的原因。

然而,您看到有问题的代码实际上明确地处理了对齐问题(包含 (unsigned long int) char_ptr & (sizeof (longword) - 1) 子条件的第一个循环)。之后, char* 被正确对齐以重新解释为 unsigned long*.

当然,所有这些并不真正符合 C 标准,但它符合编译此代码的编译器的 C 实现。如果 gcc 的人修改了他们的编译器来处理这段代码,glibc 的人就会大声抱怨它,以至于 gcc 会被改回以处理这种代码正确投射。

归根结底,标准 C 库实现必须简单地违反严格的别名规则才能正常工作并提高效率。 strlen() 只需要违反这些规则就可以提高效率,malloc()/free() 函数对必须能够使用声明类型为 Foo 的内存区域,并且把它变成声明类型 Bar 的内存区域。并且 malloc() 实现中没有 malloc() 调用会首先为对象提供声明的类型。 C语言的抽象简单到这个层次就崩溃了。

在编写别名规则时,标准的作者只考虑了有用的形式,因此应该在 所有 实现中强制执行。 C 实现针对多种目的,标准的作者没有试图指定编译器必须做什么才能适合任何特定目的(例如低级编程),或者就此而言,任何目的。

像上面这样依赖于低级结构的代码不应期望 运行 在没有声称适合低级编程的编译器上。另一方面,任何不支持此类代码的编译器都应被视为不适合低级编程。请注意,编译器可以采用基于类型的别名假设,并且仍然适用于低级编程 如果 他们做出合理的努力来识别常见的别名模式。一些编译器编写者非常重视既不适合常见的低级编码模式也不符合 C 标准的代码视图,但是 任何编写低级代码的人都应该简单地认识到那些编译器的 优化器不适合与低级代码一起使用。