C语言中指针的“底层”工作原理是什么?

8

请看这个简单的程序:

int main(void)
{
    char p;
    char *q;

    q = &p;

    return 0;
}

如何确定&p?编译器是提前计算所有此类引用,还是在运行时完成?如果在运行时完成,是否有变量表或类似的东西来查找这些内容?操作系统是否跟踪它们并仅需询问操作系统即可?

在正确的解释上下文中,我的问题可能甚至没有意义,因此请随意纠正我。


4
你应该查看编译器生成的机器代码。如果你不理解机器代码,任何答案对你来说都没有太大意义,所以这是你开始学习的事情。 - Kerrek SB
1
感谢您的建议,@Kerrek,我完全忘记了有关这些事情的汇编指令。对于任何未来的读者,这里是使用objdump获取汇编代码的链接:https://dev59.com/sHM_5IYBdhLWcg3wp00X - Chris Middleton
6个回答

6
“&p”是如何确定的?编译器是否在运行时计算所有这些引用,还是提前完成?
这是编译器的实现细节。不同的编译器可以选择不同的技术,具体取决于它们生成代码的操作系统类型和编译器编写者的个人偏好。
让我为您描述一下现代操作系统(例如Windows)通常如何处理此问题。
当进程启动时,操作系统会给进程一个虚拟地址空间,比如说2GB。其中1MB的部分被设置为主线程的"堆栈"。堆栈是一个内存区域,在当前堆栈指针"下面"的所有内容都是"正在使用"的,而在它上面的该1MB的空间则是"自由"的。操作系统如何选择虚拟地址空间中的哪个1MB块作为堆栈,则是Windows的实现细节。
(附注:自由空间是在堆栈的"顶部"或"底部",有效空间是向"上"还是向"下"增长也是实现细节。不同芯片的不同操作系统有不同的处理方式。假设堆栈从高地址到低地址增长。)
操作系统确保在调用"main"函数时,寄存器"ESP"包含堆栈中有效和自由部分之间的分界点地址。
(附注:ESP是第一个有效点的地址还是第一个自由点的地址也是实现细节。)
编译器为"main"函数生成代码,通过从堆栈中减去偏移量来"推"堆栈指针。如果堆栈向下增长,则减少五个字节。这是因为需要一个字节用于"p"和四个字节用于"q"。所以堆栈指针发生了变化;现在有五个更多的"有效"字节和五个更少的"自由"字节。
假设"q"是现在在"ESP"到"ESP+3"的内存,"p"是现在在"ESP+4"的内存。要将"p"的地址分配给"q",编译器生成的代码将四字节的值"ESP+4"复制到位置"ESP"到"ESP+3"的地址中。
(附注:请注意,编译器很可能安排堆栈,使得所有其地址被使用的内容都在"ESP+offset"值上,而该值可以被四整除。一些芯片要求地址可被指针大小整除。再次说明,这是实现细节。)

如果你不理解地址作为值和地址作为存储位置之间的区别,请搞清楚。在不理解这个关键区别的情况下,你将无法成功使用C语言。

这是一种可能的工作方式,但正如我所说的,不同的编译器可以根据自己的判断选择不同的工作方式。


非常感谢您抽出时间编写这篇文章。您将许多不同方面很好地联系在了一起。有一点小建议:如果您想要与我上面发布的程序相呼应,或许应该将其更改为“p占用一个字节,而q占用四个字节”。 - Chris Middleton
1
@AmadeusDrZaius:你说得对,我错过了pchar的事实。 - Eric Lippert
2
@AmadeusDrZaius:非常感谢!为了更深入地了解,我建议您编译一些程序禁用所有优化,然后在调试器中检查生成的汇编代码。您很快就会看到编译器如何发挥其魔力。 - Eric Lippert

5
编译器无法在编译时完全知道 p 的完整地址,因为一个函数可以被不同的调用者多次调用,而 p 可以具有不同的值。
当然,编译器必须知道如何在运行时计算 p 的地址,不仅是为了取地址运算符,而且还为了生成与 p 变量一起工作的代码。在常规体系结构上,像 p 这样的局部变量是在堆栈上分配的,即相对于当前堆栈帧地址的固定偏移量位置上。
因此,q = &p 这一行只是将当前堆栈帧中 p 的地址存储到另一个在堆栈上分配的本地变量 q 中。
请注意,通常编译器所知道或不知道的是实现相关的。例如,优化编译器可能会在分析其操作没有可观察效果后优化掉整个 main。上述内容是在假设主流体系结构和编译器、以及可能被多个调用者调用的非静态函数(除 main 外)的情况下编写的。

我明白了。我想我只是忘记了这个,但基本上它会在汇编代码中使用lea(加载有效地址),偏移量由类型确定? - Chris Middleton
1
@AmadeusDrZaius 偏移量将在编译时确定;例如,变量 p 可能会得到偏移量 0,变量 q 得到偏移量 4。存储在寄存器中的变量根本不会有偏移量等。如果您了解汇编语言,请查看汇编输出 - 在 GCC 下,您可以使用 gcc -S foo.c 来获取它。 - user4815162342

2
这实际上是一个极其困难的问题,无法给出完整的普遍性答案,因为虚拟内存地址空间布局随机化重定位使情况变得非常复杂。
简短的答案是,编译器基本上处理相对于某个“基址”的偏移量,该基址在程序运行时由运行时加载器决定。你的变量 pq 将会出现在堆栈的“底部”非常接近(虽然堆栈的底部通常在虚拟内存中非常高并且向下增长)。

感谢提供链接。 - Chris Middleton

1

p 是一个具有自动存储的变量。它只在所在的函数存在的同时存在。每次调用该函数时,内存都会从堆栈中获取,因此其地址可能会发生变化,在运行时无法确定。


1
你是指自动存储期吗? - ouah
@ouah 是的。自动持续时间/存储。谢谢。 - Fiddling Bits

1
在任何函数中,函数参数和局部变量都分配在堆栈上,在它调用当前函数的位置(程序计数器)之后。这些变量在堆栈上如何分配,以及从函数返回时如何释放,由编译器在编译时处理。
例如,在此情况下,p(1个字节)可以首先分配在堆栈上,然后是q(32位架构需要4个字节)。代码将p的地址赋给q。然后,p的地址自然地从堆栈指针的最后一个值加上或减去5。嗯,类似于这样,这取决于堆栈指针的值如何更新以及堆栈是向上还是向下增长。
返回值如何传回调用函数是我不确定的事情,但我猜想它通过寄存器而不是堆栈传递。因此,当调用返回时,底层汇编代码应该释放p和q,在寄存器中放置零,然后返回到调用函数的最后位置。当然,在这种情况下,它是主函数,所以它更加复杂,会导致操作系统终止进程。但在其他情况下,它只是返回到调用函数。
在 ANSI C 中,所有的局部变量都应该放在函数顶部,并在进入函数时分配到堆栈中,返回函数时释放。在 C++ 或 C 的后续版本中,当局部变量也可以在块内(如 if-else 或 while 语句块)声明时,情况会变得更加复杂。在这种情况下,当进入块时,局部变量被分配到堆栈上,离开块时释放。
在所有情况下,局部变量的地址始终是一个固定的数字,从堆栈指针中添加或减去(由编译器相对于包含块计算),变量的大小由变量类型确定。
然而,在 C 中,静态局部变量和全局变量是不同的。它们在内存中分配了固定的位置,因此有一个固定的地址(或相对于进程边界的固定偏移量),这是由连接器计算的。
第三种是使用 malloc/new 和 free/delete 在堆上分配的内存。如果我们也包括这个,我认为这个讨论会太长了。
话虽如此,我的描述仅适用于典型的硬件架构和操作系统。所有这些都还取决于各种各样的事情,正如 Emmet 所提到的那样。

1

本地变量的地址在编译时无法完全计算。局部变量通常分配在堆栈中。每个函数调用时,都会分配一个堆栈帧——一个连续的内存块,用于存储所有局部变量。堆栈帧的物理位置在编译时无法预测,在运行时才会被确定。每个堆栈帧的开头通常在专用处理器寄存器(如Intel平台上的ebp)中存储。

同时,编译器在编译时预先确定了堆栈帧的内部内存布局,即编译器决定了局部变量在堆栈帧内的布局方式。这意味着编译器知道每个局部变量在堆栈帧内的局部偏移量。

将所有这些放在一起,我们得到一个局部变量的确切绝对地址是堆栈帧本身的地址(运行时组件)和该变量在该帧内的偏移量(编译时组件)的总和。

这基本上就是所编译代码的内容。

q = &p;

好的。它将获取当前栈帧寄存器的值,加上一些编译时常量(p的偏移量),并将结果存储在q中。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接