是什么让 glibc malloc 可以比较来自不同 "objects" 的指针?

What makes it possible for glibc malloc to compare pointers from different "objects"?

将指针与关系运算符(例如 <<=>=>)进行比较仅在指针都指向时才由 C 标准定义在同一聚合对象(结构、数组或联合)中。这实际上意味着

形状的比较
if (start_object <= my_pointer && my_pointer < end_object+1) {

可以变成

if (1) {

通过优化编译器。尽管如此,在 K&R,第 8.7 节 "Example—A Storage Allocator" 中,作者进行了与上述类似的比较。他们借口说

There is still one assumption, however, that pointers to different blocks returned by sbrk can be meaningfully compared. This is not guaranteed by the standard, which permits pointer comparisons only within an array. Thus this version of malloc is portable only among machines for which general pointer comparison is meaningful.

此外,似乎 the implementation of malloc used in glibc 做同样的事情!

更糟糕的是——我一开始偶然发现这个的原因是——对于一项学校作业,我应该实现一个基本的 malloc 类函数,并且作业说明要求我们使用K&R 代码,但我们必须将 sbrk 调用替换为对 mmap!

的调用

虽然比较来自不同 sbrk 调用的指针可能是未定义的,但它也只是有点可疑,因为您有某种心理直觉认为返回的指针应该来自同一内存区域。据我所知,不同 mmap 调用返回的指针 no 保证彼此远程相似,并且 consolidating/merging 跨 mmap 调用的内存块应该是高度非法的(而且 glibc 似乎避免了这种情况,诉诸于仅合并由 sbrk 或在 mmap 页面内部返回的内存,而不是跨越它们),但分配需要这个。

问题:有人可以照亮

  1. 比较来自对 sbrk 的不同调用的指针是否可以优化,并且
  2. 如果是这样,glibc 做了什么让他们逍遥法外。

考虑这样一个事实,即对 sbrk 的调用被定义为增加或减少某些区域(堆)中分配的字节数,对于某些进程,根据给定的 incr 参数一些 brk 地址。这实际上只是 brk 的包装器,它允许您调整堆的当前顶部。当您调用 brk(addr) 时,您是在告诉内核从 addr 的底部一直为您的进程分配 space(或者可能在 space 之间释放 space当前先前的高地址堆顶向下到新地址)。如果 incr == new_top - original_topsbrk(incr) 将完全等价。因此回答你的问题:

  1. 因为sbrk只是通过incr字节数调整堆(即一些连续的内存区域)的大小,比较sbrk的值只是一些连续内存区域中点的比较。这完全等同于比较数组中的点,因此根据 C 标准,这是一个定义明确的操作。因此,可以优化 sbrk 附近的指针比较调用。

  2. glibc 没有做任何特别的事情来“侥幸逃脱”——他们只是假设上面提到的假设成立(它确实成立)。事实上,如果他们正在检查使用 mmap 分配的内存块的状态,explicitly verifies mmap 的内存超出了使用 [=10] 分配的范围=].

编辑:我想更清楚地说明我的回答:这里的关键是没有未定义的行为! sbrk 被定义为在一些连续的内存区域中分配字节,它本身就是 C 标准指定的 'object'。因此,比较 'object' 中的指针是一个完全理智且定义明确的操作。这里的假设不是 glibc 正在利用未定义的指针比较,而是假设 sbrk 在某个连续区域增加/缩小内存。

语言律师的答案(我相信)可以在 C99 标准的 §6.5.8.5 中找到(或更准确地说来自 ISO/IEC 9899:TC3 委员会草案 — 2007 年 9 月 7 日 WG14/N1256 这几乎是相同的,但我手头没有原件)它有以下关于关系运算符的内容(即 <<=>>=):

When two pointers are compared, the result depends on the relative locations in the address space of the objects pointed to. If two pointers to object or incomplete types both point to the same object, or both point one past the last element of the same array object, they compare equal. If the objects pointed to are members of the same aggregate object, pointers to structure members declared later compare greater than pointers to members declared earlier in the structure, and pointers to array elements with larger subscript values compare greater than pointers to elements of the same array with lower subscript values. All pointers to members of the same union object compare equal. If the expression P points to an element of an array object and the expression Q points to the last element of the same array object, the pointer expression Q+1 compares greater than P. In all other cases, the behavior is undefined.

(C11 文本相同或接近相同)

这乍一看似乎没有帮助,或者至少表明每个实现都利用了未定义的行为。但是,我认为您可以合理化行为或使用变通方法。

指定的 C 指针要么是 NULL,要么是通过使用 & 获取对象的地址,或者通过指针算法,或者通过某些函数的结果派生的。在相关案例中,它们是由 sbrkmmap 系统调用的结果派生的。这些系统调用什么真的return?在寄存器级别,它们 return 一个大小为 uintptr_t(或 intptr_t)的整数。实际上是系统调用接口将它们转换为指针。我们知道指针和 uintptr_t(或 intptr_t)之间的转换根据类型双射的定义,我们知道我们可以将指针转换为 uintptr_t(例如)并比较它们,这将在指针上施加 well order relation 。维基百科 link 提供了更多信息,但这实质上将确保每个比较都得到明确定义以及其他有用的属性,例如 a<bb<c 暗示 a<c。 (我也不能选择一个完全任意的顺序,因为它需要满足 C99 §6.5.8.5 的其他要求,这几乎让我将 intptr_tuintptr_t 作为候选人。)

我们可以利用它来编写(可以说更好):

if ((uintptr_t)start_object <= (uintptr_t)my_pointer && (uintptr_t)my_pointer < (uintptr_t)(end_object+1)) {

这里有问题。您会注意到我转换为 uintptr_t 而不是 intptr_t。为什么这是正确的选择?事实上,为什么我不选择一个相当奇怪的顺序,比如反转位和比较?这里的假设是我选择了与内核相同的顺序,特别是我对 < 的定义(由顺序给出)是这样的,任何分配的内存块的开始和结束总是这样 start < end。在我所知道的所有现代平台上,没有 'wrap around'(例如,内核不会分配从 0xffff8000 开始到 0x00007ffff 结束的 32 位内存)——尽管请注意类似的环绕 has been exploited in the past.

C 标准指定指针比较在许多情况下会给出未定义的结果。但是,在这里您正在用系统调用 return 的整数构建自己的指针。因此,您可以比较整数,或者通过将指针转换回整数来比较指针(利用转换的双射性质)。如果你只是比较指针,你依赖于 C 编译器的指针比较的实现是健全的,它几乎肯定是,但不能保证。

我提到的可能性是不是太模糊以至于可以打折扣?不,让我们找一个它们可能很重要的平台示例:8086。可以想象一个 8086 编译模型,其中每个指针都是一个 'far' 指针(即包含一个段寄存器)。指针比较可以在段寄存器上执行 <>,并且仅当它们相等时才在偏移量上执行 <>。只要 C99 §6.5.8.5 中的所有结构都在同一段中,这将是完全合法的。但它不会像人们预期的那样在段之间工作,因为 1000:1234(等于内存地址中的 1010:1134)将看起来小于 1010:0123mmap 这里很可能 return 导致不同的段。类似地,人们可以想到另一种内存模型,其中段寄存器实际上是一个选择器,指针比较使用处理器比较指令来比较内存地址,如果使用了无效的选择器或段外的偏移量,该指令将中止。

你问了两个具体问题:

  1. Whether or not comparing pointers from different calls to sbrk may be optimised away, and

  2. If so, what glibc does that lets them get away with it.

在上面给出的公式中 start_object 等实际上是 void *,然后计算 可能 被优化掉(即可能做你想做的) 但不保证这样做,因为行为未定义。如果内核使用与演员表所暗示的相同的井序,演员表将保证这样做。

在回答第二个问题时,glibc 依赖于 C 编译器的行为,这在技术上不是必需的,但很有可能(根据上述)。

另请注意(至少在我面前的 K&R 中)代码中不存在您引用的行。警告与 header * 指针与 < 的比较有关(据我所知,void * 指针与 < 的比较始终是 UB),这可能派生来自单独的 sbrk() 个调用。

答案很简单。 C 库实现是在了解(或可能期望)C 编译器将如何根据官方规范处理具有未定义行为的某些代码的情况下编写的。

我可以举出很多例子;但是指针实际上指的是进程地址 space 中的一个地址,并且可以自由比较,C 库实现(至少 Glibc 如此)以及许多 "real world" 程序都依赖于该指针。虽然严格符合程序的标准不能保证这一点,但对于绝大多数现实世界 architectures/compilers 来说都是如此。另外,请注意脚注 67,关于指针与整数之间的转换:

The mapping functions for converting a pointer to an integer or an integer to a pointer are intended to be consistent with the addressing structure of the execution environment.

虽然这并没有严格授予比较任意指针的许可,但它有助于理解规则应该如何工作:作为一组在所有平台上肯定是一致的特定行为,而不是作为限制在指针的表示完全已知和理解时允许的内容。

您评论说:

if (start_object <= my_pointer && my_pointer < end_object+1) {

可以变成:

if (1) {

假设(你没有说明)my_pointer 是以某种方式从 start_object 的值或它分隔的对象的地址派生的 - 那么这严格来说是的,但这不是编译器在实践中进行的优化,除了 static/automatic 存储持续时间对象(即编译器知道不是动态分配的对象)的情况。

C 标准的作者认识到,在某些分段内存硬件平台上,尝试在不同分段中的对象之间执行关系比较可能会表现得很奇怪。如果试图比较指向可能位于不同段的对象的指针,标准的作者并没有说这样的平台不能有效地适应高效的 C 实现,而是允许这样的实现做他们认为合适的任何事情。

对于标准的作者来说,不相交对象之间的比较应该只在这种不能有效产生一致行为的分段内存系统上表现出奇怪的行为,这将被视为暗示这种系统不如任意指针之间的关系比较将产生一致排名的平台,标准的作者竭尽全力避免这种影响。相反,他们认为,由于没有理由针对普通平台的实现在此类比较中做任何奇怪的事情,因此无论标准是否强制要求,此类实现都会明智地处理它们。

不幸的是,一些对制作符合标准的编译器比制作有用的编译器更感兴趣的人已经决定,任何不是为了适应已经过时数十年的硬件限制而编写的代码应该考虑"broken"。他们声称他们的 "optimizations" 允许程序比其他方式更高效,但在许多情况下,"efficiency" 收益仅在编译器省略实际必要的代码的情况下才有意义。如果程序员绕过编译器的限制,生成的代码最终可能会比编译器一开始就没有理会 "optimization" 时效率低。