翻译|指针很复杂,或者说:字节里究竟有什么?

本文原作者:Ralf Jung,原文地址:https://www.ralfj.de/blog/2018/07/24/pointers-and-bytes.html

今天夏天,我再次完全使用Rust开发,并致力于(除其他事项外)为Rust/MIR开发一个“内存模型”。不过,在谈论我今年的想法之前,我终于要花点时间打破“指针很简单:他们只是一些整数”的神话了。这句话的两部分都是错误的,至少在Rust或C等具有不安全特性的语言中是这样的:指针既不简单,也不(只是)整数。

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

我希望当你读完这篇文章时,你会同意我的这两种说法。😃

指针很复杂

“指针只是整数”这句话有什么问题?让我们考虑下面这个例子:

我在这里使用了C++来写示例代码,主要因为在C++中写不安全的代码要比在Rust中容易很多,而不安全代码正能体现出我所说的问题。这些问题在C和unsafe Rust中都存在。

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,那将会是非常有益的。C++编译器经常执行这样的优化,因为这些优化对生成高质量的汇编至关重要。这个优化的理由是:对x_ptr的写入操作指向x,并不能改变y的内容。

不过,由于C++是一门低级语言,我们实际上可以通过将i设置为y-x来打破上面的假设。当i被设为y-x时,由于&x[i]x+i相同,我们实际上将23写入了&y[0]

当然,C++编译器并不会因为我们的行为而不进行这些优化。为此,C++标准称我们的代码具有未定义行为

首先,执行指针运算(例如&x[i])时不允许超出数组的任意一端。我们的程序违反了这一规则:x[i]位于x之外,因此这是未定义行为(UB)。需要说明的是,仅仅是计算出x_ptr的过程就已经是UB了,哪怕我们甚至都还没有运行到真正使用它的部分!

但我们并非无计可施:这条规则有一个特殊的例外,我们可以利用它。如果指针运算最终得到的指针刚好位于数组最后一个元素的下一个位置,那么这种计算就是没有问题的。(这个例外对C++98的迭代器循环计算vec.end()是必要的。)

所以让我们来稍稍修改一下这段样例代码:

int test() {
    auto x = new int[8];
    auto y = new int[8];
    y[0] = 42;
    auto x_ptr = x+8; // 终点后的下一个
    if (x_ptr == &y[0])
      *x_ptr = 23;
    return y[0];
}

现在,想象一下xy是紧挨着分配的,而y的地址更高,那么x_ptr实际上正好指向了y的起点!条件为真,写入发生。尽管指针运算超出了边界,但没有出现UB。

这似乎会破坏优化的合法性。不过,C++标准还有另一个小trick来帮助编译器的开发者:我们并不可以实际使用上面的x_ptr。根据标准中关于指针加法的规定,x_ptr指向的是“数组最后一个元素的后一个元素”。也就是说,即使它们的地址形同,它也不会指向另一个对象的实际元素。(至少,这就是LLVM优化这段代码时所依据标准的一般解释。)

这里的关键是x_ptr&y[0]指向相同的地址,但这并不意味着它们是相同的指针。也就是说,它们的使用不能互换:&y[0]指向的是y的第一个元素,而x_ptr指向x的末尾。如果我们把*x_ptr = 23换成*&y[0],我们就改变了程序的语义,哪怕这里的两个指针早已被if语句证明为相等的。

这段话值得再重复一遍:

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

如果这听起来很微妙,那它就是真的。事实上,这仍然会导致LLVMGCC编译错误。

还需要注意的是,在C/C++中,这条“越界一个”的规则并不是唯一一个能体现这种效果的部分。另一个例子是C语言中的restrict关键字,它能表示指针之间不会别名(alias):

int foo(int *restrict x, int *restrict y) {
    *x = 42;
    if (x == y) {
        *y = 23;
    }
    return *x;
}

int test() {
    int x;
    return foo(&x, &x);
}

调用test()会触发UB,因为foo()中的两次访问必须不别名。用*x替换foo中的*y会改变程序的语义,使其不再有UB。所以,即使xy有相同的地址,它们也不能互换使用。

指针绝对不是整数。

简单的指针模型

那么,什么才指针呢?我并不知道完整的答案。事实上,这是一个亟待研究的领域。

这里需要强调我们仅仅是在为指针寻找一个抽象的模型。当然,在实际机器上,指针就是整数。但是,实际机器也不会像现代的C++编译器一样进行优化,所以它可以不用考虑那些情况。如果我们直接用汇编写上述的程序,就不会有UB,也不会有优化。C++和Rust对内存和指针采用了一种更“高层次”的视角,并且为了便于优化限制了程序员的很多行为。当正式地描述程序员在这些语言中可以做什么、不可以做什么时,正如我们所看到的那样,将指针视为整数的模型就不成立了。因此,我们必须寻找别的模型。这也是使用不同于真实机器的“虚拟机”来进行规范的另一个例子,我以前曾在博客中讨论过这个想法

下面是一个简单的提案:指针是由某种唯一标识分配的ID和分配的偏移量组成的一对值(事实上,这就是CompCert和我在RustBelt的工作中使用的指针模型,也是miri实现指针的方式)。如果用Rust来定义的话,我们可以这样写:

struct Pointer {
    alloc_id: usize,
    offset: isize,
}

【向/从】指针【添加/减去】一个整数只是对偏移量起作用,因此永远不会离开分配区。只有当指针指向同一个分配时,才允许从另一个指针减去一个指针(和C++相匹配)。

事实证明(miri也证明了)这个模型可以让我们走得很远。我们总是能记得指针指向的是哪个分配区,因此我们可以将一个指向分配区的“一端”的指针同另一个分配区的起点指针区分开来。这就是miri检测出第二个示例(有&x[8]的那个)是UB的方法。

这个模型的崩溃

在这个模型中,指针不是整数,但至少也很简单。然后,一旦考虑到指针到整数的转换,这个简单的模型就开始崩溃了。在miri中,将指针转换为整数实际上什么都不需要做,我们现在只有一个整数变量(也就是说它的类型表示它是一个整数),而它的值是一个指针(也就是一个分配-偏移的数对)。然而,将这个“整数”乘以2会导致错误,因为将一个抽象指针乘以2的含义是完全不清楚的。

我必须澄清,在定义语言语义时,这并不是一个好的解决方案。但对于解释器来说,它却很好用。这是最懒惰的做法,我们之所以这么做,是因为我们并不知道除了这样还能怎么解决这个问题(除非完全禁止这样的转换——但这样一来,miri上能跑的程序就会变少很多):在我们的抽象机器中,没有一个统一的“地址空间”供所有分配使用,我们无法将每个指针映射到一个不同的整数。每个分配都只是由一个(不可观察的)ID标识。我们现在可以开始丰富这个模型,为每个分配添加诸如基址这样的额外数据,并在将整数转换回指针时在某种程度上使用这些数据……但是这就变得非常复杂了,而且讨论这种模型也并非这篇文章的重点。本文的重点是讨论这种模型的必要性。如果你感兴趣,我建议你阅读一下这篇论文,它探讨了上述添加基址的想法。

长话短说,指针到整数的转换是混乱的,并且很难给出正式的定义,因为还要考虑到上面讨论过的优化问题。一方面,需要从高层次的视角来实现优化;另一方面,又需要从低层次的视角来解释指针到整数的互相转换,这两者之间存在冲突。在miri中,我们大多数情况下会忽略这个问题,并根据我们所用的简单模型尽可能多地进行优化。C++或Rust等语言的完整定义当然不能走这种捷径,它必须区解释这里到底发生了什么。据我所知,目前还没有令人满意的解决方案出现,但学术上的研究已经在逐步接近了。

这绝不是一份关于C语言的学术研究的详尽清单。我不知道还有哪些工作在关注整数-指针转换和优化的相互作用,但其他值得关注的C语言形式化的工作包括KCCRobbert Krebber的博士论文Cerberus

这就是为什么指针也不简单。

从指针到字节

我希望我提出了一个令人信服的观点,即在正式为C++或Rust(的不安全部分)等低级语言制定标准时,整数并不是唯一需要考虑的数据。但是,这意味着从内存中加载字节这样的简单操作不能仅仅返回一个u8。试想一下,我们实现memcpy的方法是将源数据的每个字节依次加载到某个局部变量v中,然后将其存储到目标内。但如果该字节是指针的一部分呢?当指针是分配-偏移数对时,它的第一个字节是什么?我们必须说明v的值是多少,所以我们必须找到某种方法来回答这个问题。(并且这与上一节中提到的乘法问题是两个完全无关的问题。我们只是假设存在某种抽象类型Pointer。)

我们不能将指针的一个字节表示为0到256之间的一个元素。从本质上来说,如果我们使用一个简单的内存模型,指针额外的“隐藏”部分(使其不仅仅是一个整数的部分)就会在其被存储到内存并再次加载时丢失。我们必须解决这个问题,因此我们必须扩展我们对“字节”的概念,以容纳那个额外的状态。所以,现在一个字节要么是0到256之间的一个元素(“raw bits”),要么就是某个抽象指针的第n个字节。如果我们要在Rust中实现我们的内存模型,那么实现结果可能会是这样的:

enum ByteV1 {
  Bits(u8),
  PtrFragment(Pointer, u8),
}

例如,PtrFragment(ptr, 0)表示ptr的第一个字节。这样,memcpy就能把指针“拆解”成内存中代表该指针的各个字节,并分别复制它们。在32位架构上,表示ptr的完整值由以下4个字节构成:

[PtrFragment(ptr, 0), PtrFragment(ptr, 1), PtrFragment(ptr, 2), PtrFragment(ptr, 3)]

这种表示法支持对指针执行所有字节级别的“数据移动”操作,这对memcpy来说已经足够了。

未初始化的内存

然而,我们还没有完成对“字节”的定义。为了完整地描述程序的行为,我们需要考虑另一种可能性:内存中的一个字节也可能未被初始化。因此,我们最终对字节的定义看起来应该是这样的(假设Pointer类型表示指针):

enum Byte {
  Bits(u8),
  PtrFragment(Pointer, u8),
  Uninit,
}

我们用Uninit来表示那些已经被分配,但还没有被写入过的字节。读取未初始化的字节是没问题的,但实际使用这些字节就是未定义行为了(例如,用它们进行算术运算)。

这和LLVM称为poison的特殊值的规则非常相似。需要注意的是,LLVM也有一个名为undef的值,它用于表示未初始化的内存,其工作原理略有不同——不过,将我们的Uninit编译为undef实际上是正确的(undef在某种意义上“较弱”一些),并且也有提案呼吁undef从LLVM中移除,改用poison

你也许会想知道我们到底为什么需要这个特殊的Uninit值。难道我们不能给每个新分配的字节任意赋一个b: u8,然后用Bits(b)来作为初始值吗?这确实也是一种选择。不过,首先,几乎所有的编译器都倾向于给未初始化的内存设置一个哨兵值。如果不这样做,不仅会给通过LLVM进行编译带来麻烦,而且还需要重新评估许多优化措施,以确保在改变后的模型中仍然能正常工作。关键在于,在编译过程中,用任何值来替换Uninit都是安全的,因为无论如何,任何实际观测该值的操作都是UB。

举个例子,下面的C代码在使用了Uninit后变得更容易优化了:

int test() {
    int x;
    if (condA()) x = 1;
    // 许多难以分析的代码,当condA()不成立时一定会返回,但不会改变x
    use(x); // 我们想要将x优化为1
}

因为有了Uninit,我们可以轻易地指出x不是1就是Uninit,并且由于把Uninit替换为1是可接受的,我们可以很容易地证明这种优化没有问题。然而,不使用Uninit的情况下,x要么是“某些任意的位模式”,要么是1,进行同样的优化就变得难以证明其合理性。

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

结语

我们已经看到,在C++和Rust等语言中(和真实硬件不同),即使指针指向相同的地址,它们之间也可能是不同的,并且字节也不仅仅是0..256中的一个数字。这也就是为什么在1978年,称C语言为“可移植汇编”可能是恰当的,当在今天却变成了一种危险的误导。有了这些,我想我们就可以在下一篇文章中看看我的“2018内存模型”(暂定标题;))的初稿了。

感谢@rkruppe和@nagisa帮助我们找到需要Uninit的理由。如果您有任何问题,请随时在论坛上提问

posted @ 2024-04-14 23:19  Cinea  阅读(9)  评论(0编辑  收藏  举报