指针很复杂,还是一个字节存储了什么?

哈Ha! 我向您介绍“指针复杂,或者:字节中有什么?”一文的翻译。 Ralf Jung的作者。


今年夏天,我将再次全职从事Rust的工作,并且我将(除其他事项外)再次为Rust / MIR创建“内存模型”。 但是,在谈论我的想法之前,我最后必须消除“指针很简单:它们只是数字”这一神话。 至少在具有不安全功能的语言(例如Rust或C)中,该语句的两个部分都是错误的:指针不能称为素数或(普通)数。


我还想讨论在讨论更复杂的部分之前需要解决的内存模型部分:数据以什么形式存储在内存中? 存储器由字节,最小可寻址单元和可访问的最小元素组成(至少在大多数平台上),但是可能的字节值是多少? 再次证明,“它只是一个8位数字”不适合作为答案。


我希望阅读完这篇文章后,您将同意我的两种说法。


指针很复杂


“指针是规则数”有什么问题? 让我们看下面的示例:(我在这里使用C ++,因为用C ++编写不安全的代码比用Rust编写更容易,不安全的代码只是出现问题的地方。不安全的Rust和C都有同样的问题,和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赋值yx来违反此假设。 由于&x [i]与x + i相同,我们在&y [0]中写23。


当然,这不会阻止C ++编译器进行此类优化。 为了解决这个问题,该标准指出我们的代码具有UB


首先,如果指针超出数组的任何边界,则不允许对指针执行算术运算(如&x [i]的情况)。 我们的程序违反了此规则:x [i]超出x,因此它是UB。 换句话说,即使计算 x_ptr的值也是UB,所以我们甚至都没有到达要使用此指针的地方。


(事实证明,i = yx也是UB,因为只允许减去指向同一内存分配的指针 。但是,我们可以写成i =((size_t)y-(size_t)x)/ sizeof(int)来绕过这是一个限制。)


但是我们还没有完成:该规则是我们可以利用的唯一例外。 如果算术运算恰好在数组末尾之后计算指向该地址的指针的值,则说明一切正常。 (对于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]; } 

现在想象一下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中的strict关键字,可用于表示指针不重叠(不相等):


 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中的两次内存访问不应在同一地址进行。 在foo中用* x替换* y,我们将更改程序的值,并且不再调用UB。 再说一次:尽管x和y具有相同的地址,但不能互换使用。


指针绝对不仅仅是数字。


简单指针模型


那么什么是指针呢? 我不知道完整的答案。 实际上,这是一个开放的研究领域。


重要的一点:这里我们看一个抽象的指针模型 。 当然,在实际计算机上,指针就是数字。 但是,一台真正的计算机无法执行现代C ++编译器所做的优化。 如果我们用汇编器编写以上程序,则不会有UB,也不会进行优化。 C ++和Rust对内存和指针采取了更为“高级”的方法,从而限制了程序员对编译器的依赖。 当有必要正式描述程序员在这些语言中可以做什么和不能做什么时,数字的指针模型将被粉碎,因此我们需要寻找其他东西。 这是出于规范目的而使用不同于真实计算机的“虚拟机”的另一个示例,这是我之前写的一个想法。


这是一个简单的句子(实际上, CompCert使用了这种指针模型, RustBelt使用了我的工作 ,以及miri解释器实现指针的方式 ):指针是一对ID的一对,唯一地标识一个内存区域(分配),并且偏移量相对于这个区域。 如果您在Rust中编写此代码:


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

从指针向指针加(减)数字的操作仅影响偏移量,因此指针永远不会离开存储区。 减法指针只有在它们属于相同的内存区域(根据C ++ )时才有可能。


(正如我们所看到的,C ++标准将这些规则应用于数组,而不是内存区域。但是,LLVM在区域级别将它们应用。)


事实证明(并且miri显示了相同的内容),该模型可以很好地为我们服务。 我们总是记住该指针属于哪个内存区域,因此我们可以区分一个内存区域的先后指针与该指针到另一区域的开头。 因此miri可能会发现我们的第二个示例(带有&x [8])具有UB。


我们的模型正在崩溃


在我们的模型中,指针虽然不是数字,但至少很简单。 但是,一旦您记住指针到数字的转换,该模型就会在我们眼前瓦解。 在miri中,将指针强制转换为数字实际上没有任何作用,我们只是得到一个数值变量(即其类型表示它是一个数字),其是指针(即一对存储区和偏移量)。 但是,将此数字乘以2会导致错误,因为完全不清楚“将抽象指针乘以2”是什么意思。


我必须澄清:在定义语言的语义时,这不是一个好的解决方案。 但是,这对于口译员来说效果很好。 这是最简单的方法,我们之所以选择它,是因为它不清楚如何进行其他操作(除非根本不支持这种缩减,但是在miri的支持下,它可以运行更多程序):在我们的抽象机中没有单个“地址空间”,所有分配的内存区域都将位于其中,并且所有指针都映射到特定的不同数字。 每个存储区都由一个(隐藏的)ID标识。 现在,我们可以开始向模型中添加其他数据,例如每个内存区域的基地址,并以某种方式使用它将数字带回指针...此时,该过程实际上变得非常复杂,无论如何,对此进行讨论模型不是写文章的目的。 其目的是讨论对这种模型的需求。 如果您有兴趣,我建议您阅读本文档 ,其中详细介绍了上述添加基址的想法。


简而言之,鉴于上述优化,指向彼此的指针和数字的转换会造成混淆,并且难以正式确定。 优化所需的高级方法与描述强制转换为数字的指针所需的低级方法之间存在冲突,反之亦然。 在大多数情况下,我们只是简单地忽略了miri中的这个问题,并尽可能使用我们使用的简单模型来尽可能多地进行处理。 当然,不能像这样简单的方式对C ++或Rust等语言进行完整的定义,它应该解释真正发生的事情。 据我所知,没有合适的解决方案,但是学术研究正在逼近真相


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


从指针到字节


我希望我已经提出了令人信服的论点,即如果我们要正式描述C ++或Rust的(不安全)部分这样的低级语言,数字不是唯一要考虑的数据类型。 但是,这意味着像从内存中读取字节这样的简单操作不能仅返回u8。 想象一下,我们通过依次将源的每个字节读入某个局部变量v,然后将该值存储在目标位置中来实现memcpy 。 但是,如果此字节是指针的一部分怎么办? 如果指针是一对存储区ID和偏移量,那么它的第一个字节是什么? 我们需要说v的值等于多少,所以我们将不得不以某种方式回答这个问题。 (这是与上一节中的乘法问题完全不同的问题。我们仅假设存在某种抽象类型的Ponter。)


我们不能将指针的字节表示为范围为0..256的值(注意:此后打开0,未打开256)。 通常,如果我们使用朴素的内存表示模型,则在将指针写入内存并从中重新读取时,指针的多余“隐藏”部分(使它不仅仅是一个数字)将丢失。 我们将不得不解决此问题,为此,我们将不得不扩展“字节”的概念以表示此附加状态。 因此,该字节现在要么是范围0..256(“原始位”)的值, 要么是某个抽象指针的第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)] 

此表示形式支持在字节级别通过指针移动数据的所有操作,这对于内存而言已经足够。 不完全支持算术或位运算; 如上所述,这将需要更复杂的指针表示。


未初始化的内存


但是,我们还没有完成对“字节”的定义。 为了全面描述程序的行为,我们需要考虑另一种选择:内存中的字节可以未初始化 。 最后一个字节的定义将如下所示(假设我们有一个用于指针的Pointer类型):


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

我们将Uninit值用于分配的内存中尚未写入任何值的所有字节。 可以读取未初始化的内存而不会出现问题,但是具有这些字节的任何其他操作 (例如,数值算术)都会导致UB。


关于特殊毒物值,这与LLVM规则非常相似。 请注意,LLVM 具有一个undef值,该值用于未初始化的内存,并且工作方式略有不同。 但是,将我们的Uninit编译为undef是正确的(在某种程度上,undef比较“弱”),并且建议从LLVM中删除undef并改用中毒


您可能想知道为什么我们根本没有一个特殊的Uninit值。 为什么不为每个新字节选择任意b:u8,然后使用位(b)作为初始值? 这确实是一种选择。 但是,首先,所有编译器都使用未初始化内存的特殊值来使用该方法。 不遵循这种方法不仅意味着会通过LLVM导致编译问题,而且还会审查所有优化并确保它们在此修改后的模型中正常工作。 这里的关键点:您可以始终用其他任何值安全地替换Uninit:任何接收该值的操作在任何情况下都将导致UB。


例如,使用Uninit更容易优化此C代码:


 int test() { int x; if (condA()) x = 1; //     ,       ,  condA() //  ,      x. use(x); //  x = 1. } 

使用Uninit,我们可以轻松地说出x的值是Uninit还是1,并且由于用1代替Uninit是可行的,因此可以很容易地说明优化。 如果没有Uninit,x要么是“某种任意位模式”,要么是1,并且相同的优化很难解释。


(我们可以争辩说,当我们做出不确定的选择时,我们可以交换操作,但是随后我们将需要证明难以分析的代码不会以任何方式使用x。Uninit可以通过不必要的证据来避免这种麻烦。)


最后,对于像miri这样的口译员来说,Uninit是最佳选择。 这样的解释器在诸如“仅选择这些值中的任何一个”(即非确定性操作)之类的操作中存在问题,因为它们倾向于遍历程序执行的所有可能路径,这意味着它们需要尝试所有可能的值。 使用Uninit代替任意位模式意味着miri可以在一个程序运行后告诉您程序是否错误地使用了未初始化的值。


结论


我们看到,在C ++和Rust等语言(与实际计算机不同)中,即使指针指向相同的地址,它们的指针也可能不同,并且字节不只是0..256范围内的数字。 因此,如果1978年C语言可以成为“便携式汇编程序”,那么现在这是一个非常错误的陈述。

Source: https://habr.com/ru/post/zh-CN460953/


All Articles