指针很复杂,或者:字节里有什么?(2018年)

2020-09-05 13:19:28

今年夏天,我再次全职研究Rust,并将再次为Rust/MIR开发一个“内存模型”(以及其他内容)。但是,在我可以谈论今年的想法之前,我必须花点时间消除“指针很简单:它们只是整数”的迷思。这两种说法都是错误的,至少在具有不安全特性的语言中是错误的,如Rust或C:指针既不是简单的,也不是(仅仅)整数。

我还想定义内存模型的一部分,在我们甚至可以讨论一些更复杂的部分之前,它必须是固定的:存储在内存中的数据到底是什么?它是按字节组织的,是最小的可寻址单元,也是可以访问的最小部分(至少在大多数平台上是这样),但是一个字节的可能值是什么?同样,事实证明“它只是一个8位整数”实际上并不能作为答案。

我希望在这篇文章结束时,你会同意我的这两个说法。:)。

“指针只是整数”有什么问题?让我们考虑以下示例:(我在这里使用C++代码主要是因为用C++编写不安全代码比用Rust编写更容易,而不安全代码才是这些问题真正出现的地方。C有所有相同的问题,不安全生锈也是如此。)。

Int test(){auto x=new int[8];auto y=new int[8];y[0]=42;int i=/*一些无副作用的计算*/;auto x_ptr=&;x[i];*x_ptr=23;return y[0];}。

如果能够将y[0]的最终读取优化为仅返回42,这将是有益的。此优化的理由是,写入指向x的x_ptr不能更改y。

然而,考虑到C++语言的低级程度,我们实际上可以通过将i设置为y-x来打破这一假设。由于&;x[i]与x+i相同,这意味着我们实际上向&;y[0]写入了23。

当然,这并不能阻止C++编译器进行这些优化,为此,标准声明我们的代码具有未定义的行为。

首先,不允许执行超出其开始的数组两端的指针算术(就像&;x[i]所做的那样)。我们的程序违反了此规则:x[i]在x之外,因此这是未定义的行为。需要明确的是:仅x_ptr的计算就已经是ub,我们甚至没有到达要使用此指针的部分!1。

但是我们还没有完成:这条规则有一个特殊的例外,我们可以利用它来发挥我们的优势。如果算术最终计算的指针刚好超过分配的末尾,那么这个计算就没有问题。(这个例外是允许计算通常类型的C++98迭代器循环的ve.end()所必需的。)。

Int test(){auto x=new int[8];auto y=new int[8];y[0]=42;auto x_ptr=x+8;//如果(x_ptr==&;y[0])*x_ptr=23;return y[0];}。

现在,假设x和y被分配在一起,y的地址越高,那么x_ptr实际上就指向y的开头!条件为真,写入发生。但是,由于超出边界的指针算法,仍然没有ub。

这似乎破坏了优化。但是,C++标准还有另一个诀窍来帮助编译器编写人员:它实际上不允许我们使用上面的x_ptr。根据该标准关于指针加法的规定,x_ptr指向“数组对象的最后一个元素之后的一个元素”。即使它们具有相同的地址,它也不指向另一个对象的实际元素。(至少,这是对LLVM优化此代码所基于的标准的常见解释。)。

这里的关键点是,仅仅因为x_ptr和&;y[0]指向相同的地址,并不能使它们成为相同的指针,也就是说,它们不能互换使用:&;y[0]指向y;x_ptr指向x结尾之后的第一个元素。如果我们将*x_ptr=23替换为*&;y[0]=0,即使这两个指针已经过相等测试,我们也会更改程序的含义。

仅仅因为两个指针指向相同的地址,并不意味着它们相等并且可以互换使用。

这听起来很微妙,但事实上,这仍然会导致llvm和GCC的编译错误。

另请注意,这个一过即结束规则并不是C/C++中唯一可以实现此效果的部分。另一个示例是C中的restricted关键字,它可以用来表示指针没有别名:

Int foo(int*restrict x,int*restriction y){*x=42;if(x==y){*y=23;}return*x;}int test(){int x;return foo(&;x,&;x);}。

调用test()会触发UB,因为foo中的两个访问不能有别名。在foo中将*y替换为*x会改变程序的含义,使其不再具有UB。因此,同样,即使x和y具有相同的地址,它们也不能互换使用。

那么,指针是什么呢?我不知道这个问题的全部答案。事实上,这是一个开放的研究领域。

这里要强调的一点是,我们只是在寻找指针的抽象模型。当然,在实际的机器上,指针是整数。但是,实际的机器也不做现代C++编译器所做的那种优化,所以它可以逃脱惩罚。如果我们用汇编语言编写上面的程序,就不会有UB,也不会进行优化。C++和Rust使用了更高级别的内存和指针视图,这限制了程序员进行优化的好处。如果我们用汇编语言编写上述程序,就不会有UB,也不会进行优化。C++和Rust使用了更高级别的内存和指针视图,这限制了程序员进行优化。这是另一个使用与真实机器不同的“虚拟机”用于规范目的的例子,这是我以前在博客中提到过的一个想法。

这里有一个简单的建议(实际上,这是CompCert和我的RustBelt工作中使用的指针模型,也是Miri实现指针的方式):指针是一对唯一标识分配的ID,以及分配的偏移量。

向指针加/从指针减整数只作用于偏移量,因此永远不会离开分配。只有当两者指向相同的分配(匹配C++)时,才允许从另一个指针中减去一个指针。2个。

结果证明(并且Miri展示了)这个模型可以让我们走得很远。我们总是记住指针指向哪个分配,因此我们可以区分指针“超过”一个分配的“末端”和指向另一个分配的开头的指针。这就是Miri如何检测我们的第二个示例(使用&;x[8])是UB的。

然而,我们对“字节”的定义还没有完成。要完全描述程序行为,我们还需要考虑另一种可能性:内存中的一个字节可能未初始化。因此,字节的最终定义(假设我们有一个指针类型指针)如下所示:

Uninit是我们用于已分配但尚未写入的所有字节的值。读取未初始化的内存是可以的,但实际上对这些字节执行任何操作(例如,在整数运算中使用它们)都是未定义的行为。

注意,LLVM也有一个名为undef的值,它用于未初始化的内存,其工作方式略有不同-然而,将Uninit编译为undef实际上是正确的(undef在某种意义上是“较弱的”),并且有人建议从LLVM中删除undef,而改用毒药。

你可能会想,为什么我们有一个特殊的Uninit值。难道我们不能为每个新分配的字节选择一些任意的b:u8,然后使用位(B)作为初始值吗?这确实也是一个选择。但是,首先,几乎所有的编译器都收敛到对未初始化的内存有一个标记值。如果不这样做,不仅在通过LLVM编译时会带来麻烦,还需要重新评估许多优化,以确保它们能正确地与这种改变的模型一起工作。关键是,几乎所有的编译器都已经收敛到对未初始化的内存有一个标记值。如果不这样做,不仅在通过LLVM编译时会带来麻烦,还需要重新评估许多优化,以确保它们能正确地与这种改变的模型一起工作。用任意值替换Uninit:任何实际遵守此值的操作都是UB。

Int test(){int x;if(conda())x=1;//大量难以分析的代码,这些代码在conda()不成立时肯定会返回,但不会更改x。使用(X);//希望将x优化为1。}。

有了Uninit,我们可以很容易地争论x是Uninit还是1,既然用1替换Uninit是可以的,那么优化就很容易被证明是合理的。然而,如果没有Uninit,x要么是“某个任意位模式”,要么是1,这样做同样的优化就变得更加困难了。3个。

最后,Uninit对于像miri这样的解释器来说也是一个更好的选择。这类解释器很难处理“只选择这些值中的任何一个”形式的操作(即非确定性操作),因为如果他们想要完全探索所有可能的程序执行,这意味着他们必须尝试每一个可能的值。使用Uninit而不是任意位模式意味着MIRI可以在一次执行中可靠地告诉您程序是否错误地使用了未初始化的值。

更新:自从写完这一节以来,我已经写了一整篇关于未初始化内存和“真实硬件”的文章,其中有更多的细节、示例和参考资料。/更新

我们已经看到,在像C++和Rust这样的语言中(与真实硬件不同),即使指针指向相同的地址,它们也可能是不同的,并且一个字节不仅仅是0..256中的一个数字。这也是为什么将C称为“可移植汇编”在1978年可能是合适的,但现在却是一个危险的误导性陈述。有了这一点,我想我们已经准备好在下一篇文章中查看我的“2018内存模型”(临时标题;)的初稿了。:)。

感谢@rkruppe和@Nagisa帮助寻找为什么需要Uninit的论点。如果您有任何问题,请随时在论坛上提问!

事实证明,i=y-x也是未定义的行为,因为人们只能将指针减去同一分配中的指针。但是,我们可以使用i=((Size_T)y-(Size_T)x)/sizeof(Int)来解决这个问题。↩。

正如我们已经看到的,C++标准实际上将这些规则应用于数组级别,而不是分配级别。但是,llvm会在每个分配级别应用该规则。/↩。

我们可以争辩说,当做出不确定的选择时,我们可以重新排序,但然后我们必须证明难以分析的代码不遵守x。Uninit避免了不必要的额外证明负担。-↩。

2018年7月24日发布在Ralf';的漫游上。评论?给我写封邮件或者在论坛里留言!