严格的别名规则是什么?


803

当询问C中常见的未定义行为时,人们有时会参考严格的别名规则。
他们在说什么?


12
@Ben Voigt:c ++和c的别名规则不同。为什么用c和标记这个问题c++faq
MikeMB

6
@MikeMB:如果您查看历史记录,您会发现我将标签保留为原来的样子,尽管其他一些专家试图将问题从现有答案中删除。此外,语言依赖性和版本依赖性是“什么是严格的别名规则?”答案的非常重要的部分。并且了解差异对于团队在C和C ++之间迁移代码,或编写用于两者的宏非常重要。
Ben Voigt 2015年

6
@Ben Voigt:实际上,据我所知,大多数答案仅与c有关,与c ++无关,而且问题的措词表示对C规则的关注(或者OP只是不知道,有区别)。当然,在大多数情况下,规则和基本思想是相同的,但是特别是在涉及工会的情况下,答案不适用于c ++。我有点担心,某些C ++程序员会寻找严格的别名规则,并且只会假设此处所述的所有内容也适用于C ++。
MikeMB 2015年

另一方面,我同意在发布了许多好的答案之后更改问题是有问题的,而且这个问题仍然是次要的。
MikeMB 2015年

1
@MikeMB:我想您会看到C专注于接受的答案,这使其对C ++不正确,是由第三方编辑的。该部分可能应该再次修订。
Ben Voigt 2015年

Answers:


561

遇到严格的别名问题的典型情况是,将结构(如设备/网络消息)覆盖在系统字长的缓冲区(如指向uint32_ts或uint16_ts 的指针)上。当您将结构覆盖到这样的缓冲区上,或者通过指针强制转换将缓冲区覆盖到这样的结构上时,您很容易违反严格的别名规则。

因此,在这种设置中,如果我想向某条消息发送消息,则必须有两个不兼容的指针指向同一块内存。然后,我可能会天真地编写如下代码(在具有的系统上sizeof(int) == 2):

typedef struct Msg
{
    unsigned int a;
    unsigned int b;
} Msg;

void SendWord(uint32_t);

int main(void)
{
    // Get a 32-bit buffer from the system
    uint32_t* buff = malloc(sizeof(Msg));

    // Alias that buffer through message
    Msg* msg = (Msg*)(buff);

    // Send a bunch of messages    
    for (int i =0; i < 10; ++i)
    {
        msg->a = i;
        msg->b = i+1;
        SendWord(buff[0]);
        SendWord(buff[1]);   
    }
}

严格的别名规则使该设置非法:取消引用对不具有兼容类型或C 2011 6.5第7 1 1允许的其他类型之一的对象进行别名的指针是未定义的行为。不幸的是,您仍然可以通过这种方式进行编码,也许会得到一些警告,可以很好地进行编译,但是在运行代码时只会出现奇怪的意外行为。

(GCC发出锯齿警告的能力似乎有些不一致,有时会给我们友好的警告,有时则不会。)

要了解为什么未定义此行为,我们必须考虑严格的别名规则会给编译器带来什么。基本上,使用此规则,不必考虑插入指令来刷新buff循环的每次运行的内容。取而代之的是,在进行优化时,使用一些令人讨厌的关于别名的非强制性假设,它可以省略这些指令,在循环运行之前一次将buff[0]和装载buff[1到CPU寄存器中,并加快循环的执行速度。在引入严格的别名之前,编译器必须处于一种妄想状态,即buff任何人都可以随时随地更改内容。因此,为了获得额外的性能优势,并假设大多数人不键入双关指针,便引入了严格的别名规则。

请记住,如果您认为该示例是人为设计的,那么即使您将缓冲区传递给另一个函数来进行发送(如果您有的话),甚至可能会发生这种情况。

void SendMessage(uint32_t* buff, size_t size32)
{
    for (int i = 0; i < size32; ++i) 
    {
        SendWord(buff[i]);
    }
}

并重写我们之前的循环以利用此便捷功能

for (int i = 0; i < 10; ++i)
{
    msg->a = i;
    msg->b = i+1;
    SendMessage(buff, 2);
}

编译器可能会或可能不会或足够聪明,无法尝试内联SendMessage,并且它可能会或可能不会决定再次加载或不加载buff。如果SendMessage是单独编译的另一个API的一部分,则可能有加载buff内容的说明。再说一遍,也许您使用的是C ++,这是一些编译器认为可以内联的仅模板头实现。或者,也许这只是您为了自己的方便而在.c文件中编写的内容。无论如何,仍可能会发生未定义的行为。即使我们知道幕后发生的事情,也仍然违反规则,因此无法保证明确定义的行为。因此,仅通过包装一个使用我们的单词分隔缓冲区的函数并不一定会有所帮助。

那么我该如何解决呢?

  • 使用工会。大多数编译器都支持此功能,而不会抱怨严格的别名。这在C99中允许,在C11中明确允许。

    union {
        Msg msg;
        unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)];
    };
    
  • 您可以在编译器中禁用严格别名(gcc中的f [no-] strict-aliasing

  • 您可以使用char*别名代替系统的单词。规则允许char*(包括signed charunsigned char)例外。始终假定char*别名为其他类型。但是,这将不能以其他方式起作用:没有假设您的结构别名为chars缓冲区。

初学者当心

当两种类型彼此叠加时,这只是一个潜在的雷区。您还应该了解字节序单词对齐以及如何通过正确打包结构来处理对齐问题。

脚注

1 C 2011 6.5 7允许左值访问的类型为:

  • 与对象的有效类型兼容的类型,
  • 与对象的有效类型兼容的类型的限定版本,
  • 类型是与对象的有效类型相对应的有符号或无符号类型,
  • 一种类型,是与对象的有效类型的限定版本相对应的有符号或无符号类型,
  • 在其成员(包括递归地,子集合或包含的联盟的成员)中包括上述类型之一的集合或联合类型,或
  • 字符类型。

16
我似乎在战斗之后来到..可能unsigned char*会用得更远char*吗?我倾向于使用unsigned char而不是将其char用作基础类型,byte因为我的字节未签名,并且我不希望签名行为古怪(特别是wrt溢出)
Matthieu M.2010年

30
@Matthieu:签名对别名规则没有影响,因此可以使用unsigned char *
Thomas Eding

22
从工会成员那里读到的内容与最后一个写入的成员不同,这是否是未定义的行为?
R. Martinho Fernandes

23
伯洛克,这个答案完全倒退。它显示为非法的示例实际上是合法的,并且显示为合法的示例实际上是非法的。
R. Martinho Fernandes

7
uint32_t* buff = malloc(sizeof(Msg));和随后的联合unsigned int asBuffer[sizeof(Msg)];缓冲区声明的大小将不同,而且都不正确。该malloc呼叫依靠4字节对齐引擎盖下(不这样做),并结合将是4次大于它需要......我明白,这是为了清晰,但它的错误我没有-the-更少...
不知情的2014年

233

我找到的最好的解释是Mike Acton的《理解严格的别名》。它只专注于PS3开发,但这基本上只是GCC。

从文章:

“严格的别名是由C(或C ++)编译器做出的假设,即取消引用指向不同类型对象的指针将永远不会引用相同的内存位置(即彼此别名)。”

因此,基本上,如果您int*指向某个包含an的内存int,然后将a float*指向该内存并将其用作a,float则会违反规则。如果您的代码不遵守此规定,则编译器的优化器很可能会破坏您的代码。

规则的例外是char*,它可以指向任何类型。


6
那么用两种不同类型的变量合法使用相同内存的规范方法是什么?还是每个人都只是复制?
jiggunjer 2015年

4
Mike Acton的页面存在缺陷。至少“通过工会铸造(2)”部分是完全错误的;他声称合法的代码不合法​​。
davmac 2015年

11
@davmac:C89的作者从不打算强迫程序员跳过障碍。我发现完全荒谬的想法是,应该以一种仅用于优化目的的方式来解释规则,以要求程序员编写冗余复制数据的代码,以期希望优化程序删除冗余代码。
超级猫

1
@curiousguy:“不能有工会”吗?首先,联合的原始/主要目的与别名完全没有任何关系。其次,现代语言规范明确允许使用联合进行别名。要求编译器注意使用了并集,这是一种特殊的处理方式。
AnT

5
@curiousguy:错。首先,联合背后的原始概念是,在给定的联合对象中,任何时候都只有一个成员对象“活动”,而其他成员对象根本不存在。因此,您似乎不会相信“同一地址上的不同对象”。其次,每个人都在谈论的别名违规行为是关于将一个对象作为另一个对象进行访问,而不是仅仅具有相同地址的两个对象。只要没有类型转换访问,就没有问题。那是最初的想法。后来,允许通过联合进行类型修剪。
AnT

133

这是严格的别名规则,可在C ++ 03标准的3.10节中找到(其他答案提供了很好的解释,但没有一个提供规则本身):

如果程序尝试通过以下类型之一以外的左值访问对象的存储值,则行为未定义:

  • 对象的动态类型,
  • 对象动态类型的cv限定版本,
  • 类型是与对象的动态类型相对应的有符号或无符号类型,
  • 一种类型,是与对象的动态类型的CV限定版本相对应的有符号或无符号类型,
  • 在其成员(包括递归地包括子集合或包含的联盟的成员)中包括上述类型之一的集合或联合类型,
  • 该类型是对象动态类型的(可能是cv限定的)基类类型,
  • 一个charunsigned char类型。

C ++ 11C ++ 14措辞(强调更改):

如果程序尝试通过以下类型之一以外的glvalue访问对象的存储值,则行为未定义:

  • 对象的动态类型,
  • 对象动态类型的cv限定版本,
  • 与对象的动态类型相似的类型(定义见4.4),
  • 类型是与对象的动态类型相对应的有符号或无符号类型,
  • 一种类型,是与对象的动态类型的CV限定版本相对应的有符号或无符号类型,
  • 集合或联合类型,在其元素或非静态数据成员(递归包括子集合或包含的联合的元素或非静态数据成员)中包括上述类型之一,
  • 该类型是对象动态类型的(可能是cv限定的)基类类型,
  • 一个charunsigned char类型。

有两个很小的变化:glvalue代替了lvalue,并且澄清了聚集/联合的情况。

第三个更改提供了更强的保证(放松了强大的别名规则):现在可以安全别名的类似类型的新概念。


同样是C措辞(C99; ISO / IEC 9899:1999 6.5 / 7; ISO / IEC 9899:2011§6.5¶7使用了完全相同的措辞):

对象只能通过具有以下类型73)或88)之一的左值表达式访问其存储值:

  • 与对象的有效类型兼容的类型,
  • 与对象的有效类型兼容的类型的合格版本,
  • 类型是与对象的有效类型相对应的有符号或无符号类型,
  • 类型是与对象的有效类型的限定版本相对应的有符号或无符号类型,
  • 在其成员(包括递归地,子集合或包含的联盟的成员)中包括上述类型之一的集合或联合类型,或
  • 字符类型。

73)或88)该列表的目的是指定对象可能会别名也可能不会别名的那些情况。


7
Ben,正如人们经常被引导到这里一样,为了完整起见,我也允许自己添加对C标准的引用。
科斯

1
查看C89基本原理cs.technion.ac.il/users/yechiel/CS/C++draft/rationale.pdf第3.3节,其中对此进行了讨论。
phorgan1 2012年

2
如果一个具有结构类型的左值,获取一个成员的地址,并将其传递给一个函数,该函数将其用作指向该成员类型的指针,则该函数将被视为访问该成员类型的对象(合法),还是结构类型的对象(禁止)?一个大量的代码假定是合法的访问结构,这样的方式,我想很多人会在这被理解为禁止这种行为的规则发牢骚,但目前还不清楚什么确切的规则。此外,对工会和机构的待遇相同,但对每个工会的明智规则应有所不同。
2015年

2
@supercat:结构规则的措辞方式,实际访问始终是原始类型。然后,通过引用原始类型进行访问是合法的,因为类型匹配,而通过引用包含结构类型的访问则是合法的,因为这是特别允许的。
Ben Voigt 2015年

2
@BenVoigt:除非通过联合完成访问,否则我认为通用的初始序列不起作用。请参阅goo.gl/HGOyoK以了解gcc在做什么。如果通过成员类型的左值访问联合类型的左值(不使用union-member-access运算符)是合法的,则wow(&u->s1,&u->s2)即使使用指针修改u,也需要合法,这将使大多数优化无效设计别名规则是为了方便。
超级猫

80

注意

这摘自我的“严格的混叠规则是什么,我们为什么要关心?” 写上去。

什么是严格的别名?

在C和C ++中,别名与允许我们通过哪些表达式类型访问存储的值有关。在C和C ++中,标准均指定允许使用哪种表达式类型作为别名的别名。允许编译器和优化器假定我们严格遵循别名规则,因此,术语“ 严格别名规则”。如果我们尝试使用不允许的类型访问值,则将其分类为未定义行为UB)。一旦我们具有不确定的行为,所有的赌注都将关闭,我们的程序结果将不再可靠。

不幸的是,在严格违反别名的情况下,我们通常会获得预期的结果,从而可能会出现带有新优化的编译器未来版本破坏我们认为有效的代码的可能性。这是不希望的,并且了解严格的别名规则以及如何避免违反它们是一个值得的目标。

要了解有关我们为何关心的更多信息,我们将讨论在违反严格的别名规则,类型修剪时会出现的问题,因为类型修剪中使用的常见技术通常会违反严格的别名规则以及如何正确键入pun。

初步例子

让我们看一些示例,然后我们可以确切讨论标准所说的内容,研究其他示例,然后看看如何避免严格的混叠并捕获我们错过的违规行为。这是一个不足为奇的示例实时示例):

int x = 10;
int *ip = &x;

std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";

我们有一个INT *指向占用内存由INT这是一个有效的别名。优化器必须假定通过ip进行分配可以更新x占用的值。

下一个示例显示了导致未定义行为的别名(实时示例):

int foo( float *f, int *i ) { 
    *i = 1;               
    *f = 0.f;            

   return *i;
}

int main() {
    int x = 0;

    std::cout << x << "\n";   // Expect 0
    x = foo(reinterpret_cast<float*>(&x), &x);
    std::cout << x << "\n";   // Expect 0?
}

在函数foo中,我们使用int *float *,在此示例中,我们调用foo并将两个参数设置为指向相同的内存位置,该内存位置在此示例中包含int。注意,reinterpret_cast告诉编译器将表达式视为具有其模板参数指定的类型。在这种情况下,我们告诉它将表达式&x视为具有float *类型。我们可以天真地期待第二的结果COUT0,但与优化使用支持-02 GCC和铛产生如下结果:

0
1

因为我们已经调用了未定义的行为,所以这可能不是预期的,但却是完全有效的。甲浮子不能有效别名一个INT对象。因此,优化器可以假定在解引用i时存储的常数1将是返回值,因为通过f进行的存储不能有效地影响int对象。将代码插入Compiler Explorer可以显示这正是正在发生的情况(实时示例):

foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1  
mov dword ptr [rdi], 0
mov eax, 1                       
ret

使用基于类型的别名分析(TBAA)的优化器假定将返回1,并将常数直接移到带有返回值的寄存器eax中。TBAA使用关于允许使用哪种类型的别名的语言规则来优化负载和存储。在这种情况下,TBAA知道float不能别名和int并优化了i的负载。

现在,到规则书

该标准确切说明允许和禁止我们做什么?标准语言不是很简单,因此我将为每一项尝试提供代码示例以说明其含义。

C11标准怎么说?

C11标准说,在节以下6.5表达式第7段

一个对象只能通过具有以下类型之一的左值表达式访问其存储值:88) —与该对象的有效类型兼容的类型,

int x = 1;
int *p = &x;   
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int

—与对象的有效类型兼容的类型的限定版本,

int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int

—一种类型,它是与对象的有效类型相对应的有符号或无符号类型,

int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to 
                     // the effective type of the object

gcc / clang有一个扩展,并且即使它们不是兼容类型,允许将unsigned int *分配给int *

—是与对象的有效类型的限定版本相对应的有符号或无符号类型的类型,

int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type 
                     // that corresponds with to a qualified verison of the effective type of the object

—集合或联合类型,其成员中包括上述类型之一(递归地包括子集合或包含的联合的成员),或

struct foo {
  int x;
};

void foobar( struct foo *fp, int *ip );  // struct foo is an aggregate that includes int among its members so it can
                                         // can alias with *ip

foo f;
foobar( &f, &f.x );

—字符类型。

int x = 65;
char *p = (char *)&x;
printf("%c\n", *p );  // *p gives us an lvalue expression of type char which is a character type.
                      // The results are not portable due to endianness issues.

C ++ 17草案标准怎么说

[basic.lval]第11段中的C ++ 17标准草案说:

如果程序尝试通过以下类型之一以外的glvalue访问对象的存储值,则行为未定义:63 (11.1)—对象的动态类型,

void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0};        // Placement new changes the dynamic type of the object to int
std::cout << *ip << "\n";        // *ip gives us a glvalue expression of type int which matches the dynamic type 
                                  // of the allocated object

(11.2)—对象的动态类型的cv限定版本,

int x = 1;
const int *cip = &x;
std::cout << *cip << "\n";  // *cip gives us a glvalue expression of type const int which is a cv-qualified 
                            // version of the dynamic type of x

(11.3)—与对象的动态类型类似的类型(定义见7.5),

(11.4)—一种类型,是与对象的动态类型相对应的有符号或无符号类型,

// Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
  si = 1;
  ui = 2;

  return si;
}

(11.5)—一种类型,是与对象的动态类型的CV限定版本相对应的有符号或无符号类型,

signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing

(11.6)—聚合或联合类型,在其元素或非静态数据成员(包括递归地包括子聚合或所包含的联合的元素或非静态数据成员)中包括上述类型之一,

struct foo {
 int x;
};

// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
 fp.x = 1;
 ip = 2;

 return fp.x;
}

foo f; 
foobar( f, f.x ); 

(11.7)—一种类型,它是对象的动态类型的(可能具有cv限定的)基类类型,

struct foo { int x ; };

struct bar : public foo {};

int foobar( foo &f, bar &b ) {
  f.x = 1;
  b.x = 2;

  return f.x;
}

(11.8)— char,unsigned char或std :: byte类型。

int foo( std::byte &b, uint32_t &ui ) {
  b = static_cast<std::byte>('a');
  ui = 0xFFFFFFFF;                   

  return std::to_integer<int>( b );  // b gives us a glvalue expression of type std::byte which can alias
                                     // an object of type uint32_t
}

值得一提的是,上面的列表中未包括带符号的char,这与C表示字符类型的显着区别。

什么是Type Punning

我们到了这一点,我们可能想知道,为什么要别名呢?答案通常是键入pun,通常所使用的方法违反严格的别名规则。

有时我们想绕过类型系统,并将对象解释为其他类型。这称为type punning,用于将内存段重新解释为另一种类型。对于需要访问对象的基础表示形式以进行查看,传输或操作的任务,类型修剪非常有用。我们发现使用的类型修剪的典型领域是编译器,序列化,网络代码等。

传统上,这是通过获取对象的地址,将其转换为我们要重新解释为该类型的指针,然后访问该值或换句话说通过别名来实现的。例如:

int x =  1 ;

// In C
float *fp = (float*)&x ;  // Not a valid aliasing

// In C++
float *fp = reinterpret_cast<float*>(&x) ;  // Not a valid aliasing

printf( "%f\n", *fp ) ;

如前所述,这不是有效的别名,因此我们正在调用未定义的行为。但是传统上,编译器没有利用严格的别名规则,这种类型的代码通常只能工作,不幸的是,开发人员已经习惯了这种方式。一种类型为punning的常见替代方法是通过联合,该联合在C中有效,但在C ++中为未定义行为请参见实时示例):

union u1
{
  int n;
  float f;
} ;

union u1 u;
u.f = 1.0f;

printf( "%d\n”, u.n );  // UB in C++ n is not the active member

这在C ++中无效,并且某些人认为联合的目的仅仅是实现变体类型,并且觉得使用联合进行类型修剪是一种滥用。

我们如何正确键入Pun?

在C和C ++中进行类型修剪的标准方法是memcpy。这看起来似乎有点费劲,但是优化器应该认识到使用memcpy进行类型修剪并对其进行优化,并生成一个寄存器来记录移动。例如,如果我们知道int64_tdouble大小相同:

static_assert( sizeof( double ) == sizeof( int64_t ) );  // C++17 does not require a message

我们可以使用memcpy

void func1( double d ) {
  std::int64_t n;
  std::memcpy(&n, &d, sizeof d); 
  //...

在足够的优化级别上,任何体面的现代编译器都会生成与前面提到的用于punning类型的reinterpret_cast方法或union方法相同的代码。检查生成的代码,我们看到它仅使用了mov(实时Compiler Explorer示例)。

C ++ 20和bit_cast

在C ++ 20中,我们可能会获得bit_cast在proposal的链接中可用的实现),该类型提供了一种简单安全的方法来进行pun-pun操作,并可以在constexpr上下文中使用。

以下是如何使用bit_cast将pun 无符号int类型为pun 进行浮动的示例(请现场观看):

std::cout << bit_cast<float>(0x447a0000) << "\n" ; //assuming sizeof(float) == sizeof(unsigned int)

如果ToFrom类型的大小不同,则需要我们使用中间struct15。我们将使用一个包含sizeof(unsigned int)字符数组的结构(假定4字节unsigned int)作为From类型,而unsigned int作为To类型。

struct uint_chars {
 unsigned char arr[sizeof( unsigned int )] = {} ;  // Assume sizeof( unsigned int ) == 4
};

// Assume len is a multiple of 4 
int bar( unsigned char *p, size_t len ) {
 int result = 0;

 for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
   uint_chars f;
   std::memcpy( f.arr, &p[index], sizeof(unsigned int));
   unsigned int result = bit_cast<unsigned int>(f);

   result += foo( result );
 }

 return result ;
}

不幸的是,我们需要这种中间类型,但这是bit_cast的当前约束。

捕捉严格的混叠违规

我们没有很多很好的工具来捕获C ++中的严格别名,我们拥有的工具将捕获某些情况下的严格别名冲突以及某些情况下的装入和存储未对齐。

使用标志-fstrict-aliasing-Wstrict-aliasing的 gcc 可以捕获某些情况,尽管并非没有假阳性/阴性。例如,以下情况将在gcc中生成警告(实时查看):

int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught 
               // it was being accessed w/ an indeterminate value below

printf("%i\n", j = *(reinterpret_cast<short*>(&a)));
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));

尽管它不会捕获这种额外的情况(请现场直播):

int *p;

p=&a;
printf("%i\n", j = *(reinterpret_cast<short*>(p)));

尽管clang允许使用这些标志,但显然并没有实际实现警告。

我们提供给我们的另一个工具是ASan,它可以捕获未对齐的负载和存储。尽管这些不是直接的严格混叠违例,但是它们是严格混叠违例的常见结果。例如,以下情况在使用-fsanitize = address与clang 一起构建时会生成运行时错误

int *x = new int[2];               // 8 bytes: [0,7].
int *u = (int*)((char*)x + 6);     // regardless of alignment of x this will not be an aligned address
*u = 1;                            // Access to range [6-9]
printf( "%d\n", *u );              // Access to range [6-9]

我将推荐的最后一个工具是C ++特定的,而不是严格的工具,而是编码实践,不允许C样式转换。gcc和clang都将使用-Wold-style-cast为C样式转换生成诊断。这将强制所有未定义类型的双关语使用reinterpret_cast,通常rerefly_cast应该是进行更仔细代码审查的标志。在您的代码库中搜索reinterpret_cast来执行审核也更加容易。

对于C,我们已经涵盖了所有工具,并且我们还拥有tis解释器,这是一个静态分析器,可以对程序的大部分C语言进行详尽的分析。给定较早示例的C版本,其中使用-fstrict-aliasing会遗漏一种情况(现场直播

int a = 1;
short j;
float f = 1.0 ;

printf("%i\n", j = *((short*)&a));
printf("%i\n", j = *((int*)&f));

int *p; 

p=&a;
printf("%i\n", j = *((short*)p));

tis-interpeter可以捕获全部三个,以下示例将tis-kernal用作tis-解释器(为简洁起见,对输出进行了编辑):

./bin/tis-kernel -sa example1.c 
...
example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing
              rules by accessing a cell with effective type int.
...

example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by
              accessing a cell with effective type float.
              Callstack: main
...

example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by
              accessing a cell with effective type int.

最后是目前正在开发中的TySan。该清理程序在影子内存段中添加类型检查信息,并检查访问是否违反别名规则。该工具可能应该能够捕获所有混叠违规行为,但可能会有较大的运行时开销。


评论不作进一步讨论;此对话已转移至聊天
巴尔加夫(Bhargav Rao)

3
如果可以的话,+ 10,也很好地编写和解释,也来自编译器作者和程序员双方……唯一的批评:上面有一些反例,以查看该标准禁止的内容,这很明显,这很明显种:-)
加百利

2
很好的答案。我仅感到遗憾的是,最初的示例是用C ++给出的,这使得像我这样只了解或关心C而又不知道reinterpret_cast可能做什么或cout意味着什么的人很难遵循。(可以提到C ++,但是最初的问题是关于C和IIUC的,这些示例可以同样有效地用C编写。)
Gro-Tsen

关于类型puning:所以,如果我将某种类型X的数组写入文件,然后从该文件中读取该数组到void *指向的内存中,那么我将该指针转换为数据的真实类型以便使用它-未定义的行为?
Michael IV

44

严格的别名不仅指指针,还影响引用,我为boost开发者Wiki撰写了一篇有关它的论文,并且受到广泛好评,以至于我将其变成了我的咨询网站上的一个页面。它完全解释了它是什么,为什么使人们如此困惑以及如何处理。严格的别名白皮书。特别是,它解释了为什么工会是C ++的危险行为,以及为什么使用memcpy是跨C和C ++的唯一可移植修订。希望这会有所帮助。


3
严格的别名不仅指指针,还影响引用。 ”实际上,它指的是lvalues。“ 使用memcpy是唯一可修复的便携式 ”听!
curiousguy 2011年

5
好纸。我的看法:(1)这种别名“问题”是对不良编程的过度反应-试图保护不良程序员免受不良习惯的影响。如果程序员有良好的习惯,那么这种混叠就很麻烦了,可以安全地关闭检查。(2)编译器端优化仅应在众所周知的情况下进行,并且在有疑问时应严格遵循源代码;简而言之,强迫程序员编写代码以满足编译器的特性是错误的。使它成为标准的一部分甚至更糟。
slashmais

4
@slashmais(1)“ 是对不良编程的过度反应 ”废话。这是对不良习惯的拒绝。你做吧?您为价格付出:没有为您的保证!(2)知名案例?哪个?严格的别名规则应该是“众所周知的”!
curiousguy

5
@curiousguy:清除了一些混淆之后,很明显带有别名规则的C语言使程序无法实现与类型无关的内存池。某些程序可以使用malloc / free获得,但是其他程序则需要更好地适合手头任务的内存管理逻辑。我不知道C89原理为什么要使用这样一个笨拙的别名规则的示例,因为他们的示例使该规则看起来似乎不会对执行任何合理的任务造成任何重大困难。
超级猫2015年

5
@curiousguy,大多数编译器套件都包括-fstrict-aliasing作为-O3的默认值,而这种隐藏的契约是强迫从未听说过TBAA并像系统程序员那样编写代码的用户使用的。我并不是说对系统程序员不屑一顾,但是这种优化应该留在-O3的默认选择之外,对于那些知道TBAA的人来说应该是选择加入的优化。查看编译器“错误”,发现它是违反TBAA的用户代码,尤其是在用户代码中追踪源代码违规行为,这并不是一件有趣的事情。
kchoi

34

作为Doug T.已经写过的附录,这是一个简单的测试用例,可能会用gcc触发它:

检查

#include <stdio.h>

void check(short *h,long *k)
{
    *h=5;
    *k=6;
    if (*h == 5)
        printf("strict aliasing problem\n");
}

int main(void)
{
    long      k[1];
    check((short *)k,k);
    return 0;
}

用编译gcc -O2 -o check check.c。通常(对于我尝试过的大多数gcc版本),这会输出“严格的别名问题”,因为编译器认为“ h”不能与“ check”函数中的“ k”相同。因此,编译器优化了if (*h == 5)距离并始终调用printf。

对于那些对此感兴趣的人是gcc 4.6.3生成的x64汇编代码,该代码在x64的ubuntu 12.04.2上运行:

movw    $5, (%rdi)
movq    $6, (%rsi)
movl    $.LC0, %edi
jmp puts

因此,if条件完全脱离了汇编代码。


如果您在check()中添加第二个short * j并使用它(* j = 7),那么优化将消失,因为如果h和j并非实际指向相同的值,则ggc不会。是的,优化真的很聪明。
philippe lhardy 2013年

2
为了使事情变得更加有趣,请使用不兼容但具有相同大小和表示形式的类型的指针(在某些系统上使用eg long long*int64_t* 表示正确)。可能希望有一个精明的编译器能够识别a long long*int64_t*,如果它们存储相同,则可以访问相同的存储,但是这种处理不再流行。
超级猫

Grr ... x64是Microsoft的约定。请改用amd64或x86_64。
SS安妮

Grr ... x64是Microsoft的约定。请改用amd64或x86_64。
SS安妮

17

通过指针强制类型转换(与使用联合相对)是打破严格别名的一个主要示例。


1
有关引号,请参见我的回答,尤其是脚注,但是尽管起初措辞不佳,但始终允许在C中使用通过联合的类型修剪。您我想澄清您的答案。
沙菲克·雅格慕

@ShafikYaghmour:C89明确允许实现者选择在哪些情况下他们会或将不会有用地识别通过联合进行类型调整的情况。例如,如果程序员在写入和读取之间进行了以下任一操作,则实现可以指定对于一种类型的写入然后对另一种类型的读取被识别为类型校正:(1)评估包含以下内容的左值:联合体类型[如果在顺序的正确位置进行,则采用成员的地址将具有资格;] (2)将一种类型的指针转​​换为另一种类型的指针,然后通过该ptr访问。
超级猫

@ShafikYaghmour:实现还可以指定例如,整数和浮点值之间的类型修剪仅在代码执行了fpsync()以fp写入和以int读取或以int反向读取之间的指令的情况下才能可靠地工作[在具有单独的整数和FPU管道和缓存的实现中,这样的指令可能很昂贵,但是却不如让编译器对每个联合访问执行这种同步那样昂贵]。或者一种实现可以指定结果值将永远不可用,除非在使用“公共初始序列”的情况下。
超级猫

@ShafikYaghmour:在C89下,实现可以禁止大多数形式的类型修饰,包括通过联合,但是指向联合的指针和指向其成员的指针之间的等效性意味着在没有明确禁止它的实现中允许类型修饰。
超级猫

17

根据C89的基本原理,该标准的作者不希望要求编译器提供以下代码:

int x;
int test(double *p)
{
  x=5;
  *p = 1.0;
  return x;
}

应该要求重新x赋值在赋值和返回语句之间的值,以考虑p可能指向的可能性x,并且赋值*p可能因此改变的值x。编译器应有权假定在上述情况下不会出现混淆的观点是无争议的。

不幸的是,C89的作者编写规则的方式是,即使按字面意义阅读,也可以使以下函数调用未定义行为:

void test(void)
{
  struct S {int x;} s;
  s.x = 1;
}

因为它使用类型的左值int访问类型的对象struct S,并且int不在可用于访问类型的类型之中struct S。因为将所有对结构和联合的非字符类型成员的使用都视为未定义行为是荒谬的,所以几乎每个人都认识到至少在某些情况下,一种类型的左值可用于访问另一种类型的对象。不幸的是,C标准委员会未能定义这些情况。

大多数问题是由于缺陷报告#028导致的,该报告询问程序的行为,例如:

int test(int *ip, double *dp)
{
  *ip = 1;
  *dp = 1.23;
  return *ip;
}
int test2(void)
{
  union U { int i; double d; } u;
  return test(&u.i, &u.d);
}

缺陷报告#28指出该程序调用了Undefined Behavior,因为编写“ double”类型的并集成员并读取“ int”类型之一的行为将调用“ Implementation-Defined”行为。这种推理是荒谬的,但却构成了有效类型规则的基础,该规则不必要地使语言复杂化,而无助于解决原始问题。

解决原始问题的最佳方法可能是将有关规则目的的脚注视为规范,并使其无法执行,除非在实际涉及使用别名的冲突访问的情况下。给出类似的东西:

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   s.x = 1;
   p = &s.x;
   inc_int(p);
   return s.x;
 }

之内没有冲突,inc_int因为对通过它访问的存储的所有访问*p都使用一个lvalue类型完成int,并且也没有冲突,test因为p它明显是从派生的struct S,并且下次s使用该存储时,将对该存储进行所有访问通过p将已经发生。

如果代码稍作更改...

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   p = &s.x;
   s.x = 1;  //  !!*!!
   *p += 1;
   return s.x;
 }

此处,标记行之间的别名p访问之间存在别名冲突,s.x因为在执行该点时存在另一个引用,引用将用于访问同一存储

如果Defect Report 028表示原始示例调用UB是因为两个指针的创建和使用之间存在重叠,这将使事情变得更加清晰,而无需添加“有效类型”或其他此类复杂性。


好吧,阅读某种或多或少的“标准委员会可以做什么”的提案很有趣,这些提案在不引入太多复杂性的情况下实现了其目标。
jrh '18

1
@jrh:我认为这将非常简单。认识到1.对于混叠函数或循环的一个特定执行过程中发生,两个不同的指针或左值,必须使用在执行期间,以解决冲突的中fashon同一存储; 2.认识到在一个指针或左值是从另一个指针中新近地派生出来的情况下,对第二个指针的访问就是对第一个指针的访问;3.认识到该规则不适用于实际上不涉及别名的情况。
超级猫

1
编译器识别出刚派生的左值的确切情况可能是实现质量问题,但是任何远程像样的编译器都应该能够识别gcc和clang故意忽略的形式。
超级猫

11

阅读许多答案后,我觉得有必要添加一些内容:

严格的别名(稍后将描述)很重要,因为

  1. 内存访问可能很昂贵(从性能角度而言),这就是为什么在将数据写回物理内存之前先在CPU寄存器中对其进行操作

  2. 如果将两个不同的CPU寄存器中的数据写入相同的内存空间,那么当我们用C编写代码时,我们无法预测哪些数据将“存活”

    在汇编中,我们手动编码CPU寄存器的加载和卸载,我们将知道哪些数据保持不变。但是C(非常感谢)将这个细节抽象了。

由于两个指针可以指向内存中的同一位置,因此这可能导致处理可能的冲突的复杂代码

这种额外的代码很慢,并且会降低性能,因为它会执行额外的内存读/写操作,这些操作既慢又(可能)不必要。

严格别名规则可以让我们避免多余的机器代码在它的情况下应该是安全的假设,两个指针没有指向同一个内存块(另见restrict关键字)。

严格别名表示可以安全地假定指向不同类型的指针指向内存中的不同位置。

如果编译器注意到两个指针指向不同的类型(例如an int *和a float *),则它将假定内存地址不同,并且无法防止内存地址冲突,从而导致机器代码更快。

例如

让我们假设以下功能:

void merge_two_ints(int *a, int *b) {
  *b += *a;
  *a += *b;
}

为了处理这种情况a == b(两个指针都指向相同的内存),我们需要排序并测试将数据从内存加载到CPU寄存器的方式,因此代码可能最终如下所示:

  1. 从内存加载a和加载b

  2. 添加ab

  3. 保存 b重新加载 a

    (从CPU寄存器保存到内存,然后从内存加载到CPU寄存器)。

  4. 添加ba

  5. 保存a(从CPU寄存器)到存储器。

步骤3非常慢,因为它需要访问物理内存。但是,需要防止实例指向ab指向相同的内存地址。

严格的别名可以通过告诉编译器这些内存地址明显不同来防止这种情况发生(在这种情况下,这将允许进一​​步优化,如果指针共享一个内存地址则无法执行)。

  1. 这可以通过两种方式告诉编译器,方法是使用不同的类型指向。即:

    void merge_two_numbers(int *a, long *b) {...}
  2. 使用restrict关键字。即:

    void merge_two_ints(int * restrict a, int * restrict b) {...}

现在,通过满足“严格别名”规则,可以避免步骤3,并且代码的运行速度将大大提高。

实际上,通过添加restrict关键字,可以将整个功能优化为:

  1. 从内存加载a和加载b

  2. 添加ab

  3. 将结果保存到ab

由于可能会发生冲突,因此以前无法完成此优化(其中ab将变为三倍而不是两倍)。


带有restrict关键字的步骤3,是否应该将结果仅保存为“ b”?听起来好像求和结果也将存储在“ a”中。是否需要重新加载“ b”?
NeilB

1
@NeilB-是的,您是对的。我们只是保存b(而不是重新加载)并重新加载a。我希望现在更加清楚。
Myst

基于类型的别名在之前可能已经提供了一些好处restrict,但是我认为后者在大多数情况下会更有效,并且放宽一些约束register将使它能够填补restrict无济于事的某些情况。我不确定将标准完全描述为程序员应该期望编译器认识到别名的所有情况,而不是仅仅描述编译器必须假定别名的地方(即使没有特定证据存在的情况)是否“重要” 。
超级猫

请注意,尽管从主RAM加载非常慢(并且如果后续操作取决于结果,则可能会使CPU内核长时间停滞),但是从L1高速缓存加载非常快,写入最近正在写入的高速缓存行也是如此以相同的核心。因此,除了对地址的第一次读取或写入之外,所有其他操作通常都将相当快:reg / mem addr访问之间的差异小于缓存/未缓存的mem addr之间的差异。
curiousguy19年

@curiousguy-尽管您是正确的,但在这种情况下,“快速”是相对的。L1缓存可能仍比CPU寄存器慢一个数量级(我认为要慢10倍以上)。另外,该restrict关键字不仅使操作速度减到最少,而且使操作次数减到最少,这可能是有意义的……我的意思是,毕竟,最快的操作根本没有任何操作:)
Myst

6

严格的别名不允许不同的指针类型指向相同的数据。

本文应帮助您详细了解此问题。


4
您可以在引用之间以及引用和指针之间使用别名。参见我的教程dbp-consulting.com/tutorials/StrictAliasing.html
phorgan1 2011年

4
允许具有指向同一数据的不同指针类型。通过一个指针类型写入相同的内存位置并通过另一指针类型读取相同的内存位置时,就会出现严格的别名。另外,允许使用某些不同的类型(例如int和包含的结构int)。
MM

-3

从技术上讲,在C ++中,严格的别名规则可能永远都不适用。

请注意间接的定义(*运算符):

一元*运算符执行间接操作:应用该表达式的表达式应该是指向对象类型的指针,或者是指向函数类型的指针,并且结果是指向表达式所指向的对象或函数的左值

同样从glvalue的定义

glvalue是一个表达式,其求值确定对象的身份,(... snip)

因此,在任何定义良好的程序跟踪中,glvalue均指对象。因此,所谓的严格别名规则永远不会适用。这可能不是设计师想要的。


4
C标准使用术语“对象”来指代许多不同的概念。其中包括专门用于某种目的的字节序列,对字节序列的不必要的排他性引用,可以从中写入或读取特定类型的值,或实际上具有在某些情况下已经或将要访问。我认为没有任何明智的方式来定义术语“对象”,这与标准使用它的所有方式都一致。
超级猫

@supercat不正确。尽管有您的想象力,但实际上是相当一致的。在ISO C中,它定义为“执行环境中的数据存储区域,其内容可以表示值”。在ISO C ++中,有一个类似的定义。您的评论与答案甚至没有任何关系,因为您提到的所有内容都是表示对象内容表示方式,而答案说明了与对象身份紧密相关的一种表达式的C ++概念(glvalue)。并且所有别名规则基本上与身份相关,但与内容无关。
FrankHB

1
@FrankHB:如果声明int foo;,则左值表达式访问什么*(char*)&foo?那是类型的对象char吗?该对象是否与同时存在foo?是否会写以foo更改上述类型的对象的存储值char?如果是这样,是否有任何规则允许char使用类型为lvalue的类型访问对象的存储值int
超级猫

@FrankHB:在没有6.5p7的情况下,可以简单地说,每个存储区域同时包含可以容纳在该存储区域中的所有类型的所有对象,并且访问该存储区域将同时访问所有这些对象。但是,以这种方式解释在6.5p7中使用术语“对象”将禁止使用非字符类型的左值做很多事情,这显然是荒谬的结果,并且完全违反了规则的目的。此外,“对象”的无处不在比6.5p6其他使用这个概念有一个静态编译时类型,但是...
supercat

1
sizeof(int)为4,声明是否int i;创建每个字符类型为in addition to one of type int ? I see no way to apply a consistent definition of "object" which would allow for operations on both *(char *)&i`和的四个对象i。最后,标准中没有任何内容允许甚至是volatile合格的指针访问不符合“对象”定义的硬件寄存器。
超级猫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.