全局指针变量在内存中是如何存储的?

4
假设我们有一个简单的代码:
int* q = new int(13);

int main() {
    return 0;
}

显然,变量q是全局的并且被初始化了。根据这个问题的回答,我们期望q变量将存储在程序文件中的已初始化数据段(.data)中,但它是一个指针,因此它的值(即堆段中的地址)将在运行时确定。那么程序文件中的数据段中存储的值是多少呢?

我的尝试:
我认为编译器会在数据(data)段为变量q分配一些空间(通常为64位地址的8个字节),但没有有意义的值。然后,在main函数代码之前,编译器会在文本(text)段中放置一些初始化代码,以在运行时初始化q变量。汇编代码可能类似于:

     ....
     mov  edi, 4
     call operator new(unsigned long)
     mov  DWORD PTR [rax], 13  // rax: 64 bit address (pointer value)

     // offset : q variable offset in data segment, calculated by compiler
     mov  QWORD PTR [ds+offset], rax // store address in data segment
     ....
main:
     ....

有什么想法吗?

1
当使用gcc进行链接时,默认的起始点是_start。这就是初始化代码所在的位置,然后它将调用main函数。因此,那些没有使用clib但要与gcc进行链接的汇编编程人员,必须在其代码开头放置_start:标签,而那些链接默认clib的人则从他们的源文件中的main:开始(在二进制文件中,起始点是来自库的_start:)。 :) - Ped7g
1
具体来说,_start 不是一个函数,因此您不能在 C 或 C++ 中编写 _start。在汇编中,您不必编写函数,可以编写任意代码,因此您可以自己编写 _start - Dietrich Epp
明白了,谢谢Ped7g和Dietrich Epp。 - Mehran Torki
1
“int *q”将进入“.bss”而不是“.data”部分,因为它只由构造函数在运行时初始化。在可执行文件的数据段中没有必要有8个字节。 - Peter Cordes
2个回答

3

是的,这基本上就是它的工作原理。

请注意,在ELF中,.data.bss.text实际上是节,而不是段。您可以通过运行编译器自己查看汇编代码:

c++ -S -O2 test.cpp

通常您会看到一个main函数,以及一些在该函数外的初始化代码。程序入口点(您的C ++运行时的一部分)将调用初始化代码,然后调用main。初始化代码还负责运行诸如构造函数之类的东西。

谢谢,你能简要提一下段和节的区别吗? - Mehran Torki
2
在链接时,使用段来生成二进制代码,将目标文件转化为可执行的文件。在运行时,使用段来将二进制代码加载进内存。段没有名称。 - Dietrich Epp
@DietrichEpp:谈论文本段(链接器放置.text.rodata和其他各种东西的地方)、数据段(.data)或BSS是相当标准的。虽然在ELF中这不是官方部分,因为readelf -a输出仅显示节到段映射中的编号段。 - Peter Cordes
1
@MehranTorki:如果你在想,Linux中的ELF段与x86段寄存器没有任何关系。尽管它们都来自类似的历史概念。 - Peter Cordes

2

int *q将放在.bss而不是.data部分,因为它只是由非常量初始化器在运行时初始化(所以这只有在C++中合法,而不是在C中)。对于它来说,在可执行文件的数据段中没有必要有8个字节。

编译器通过将初始化函数的地址放入一个初始化程序数组中来安排初始化函数在调用main之前运行的CRT(C运行时)启动代码来运行。

在Godbolt编译器资源管理器上,您可以看到初始化函数的汇编码,而不会出现所有指令的噪音。请注意,寻址模式只是对q进行简单的RIP相对访问。由于这是一个链接时间常量,即使.text.bss部分最终位于不同的段中,链接器也会在此时填充正确的RIP偏移量。

Godbolt的编译器噪声过滤对我们来说并不理想。一些指示符是相关的,但许多指示符则不是。下面是一个手动选择的混合物,其中包含了使用Godbolt的“过滤指令”选项未选中的gcc6.2-O3汇编输出,只包括int* q = new int(13);语句。(不需要同时编译main,我们不会链接可执行文件)。

# gcc6.2 -O3 output
_GLOBAL__sub_I_q:      # presumably stands for subroutine
    sub     rsp, 8           # align the stack for calling another function
    mov     edi, 4           # 4 bytes
    call    operator new(unsigned long)   # this is the demangled name, like from objdump -dC
    mov     DWORD PTR [rax], 13
    mov     QWORD PTR q[rip], rax      # clang uses the equivalent `[rip + q]`
    add     rsp, 8
    ret

    .globl  q
    .bss
q:
    .zero   8      # reserve 8 bytes in the BSS

没有对ELF数据(或其他任何数据)段基址的引用。

同时绝对没有段寄存器覆盖。ELF段与x86段没有任何关系。(而且默认的段寄存器是DS,因此编译器不需要发出[ds:rip+q]之类的指令。尽管如此,有些反汇编程序可能会明确显示DS,即使在指令上没有段覆盖前缀。)


这就是编译器在调用main()之前安排它被调用的方式:

    # the "aw" sets options / flags for this section to tell the linker about it.
    .section        .init_array,"aw"
    .align 8
    .quad   _GLOBAL__sub_I_q       # this assembles to the absolute address of the function.

CRT启动代码有一个循环,它知道.init_array部分的大小,并依次使用内存间接call指令调用每个函数指针。 .init_array部分标记为可写入,因此它进入数据段。我不确定是什么将其写入。也许CRT代码在调用它们后通过清零指针来标记它们已完成?
Linux中有一个类似的机制用于在动态库中运行初始化程序,这是由ELF解释器在执行动态链接时完成的。这就是为什么您可以从手写汇编创建的动态链接二进制文件的_start中调用printf()或其他glibc stdio函数,而如果您不调用正确的init函数,则静态链接二进制文件会失败。 (有关构建定义自己的_start或只有main()的静态或动态二进制文件的更多信息,请参见此Q&A)。

我使用指针变量编译了我的问题代码,也编译了没有指针变量的版本,结果是data部分大小发生了变化。我使用size命令进行了检查,因此它必须存储在data部分中。 - Mehran Torki
1
@MehranTorki:我刚试了一下。一个空的.cpp文件编译成一个0/0/0测试/数据/bss的.o文件。gcc5.2 -O3将int* q = new int(13);编译成一个.o文件,size显示文本段有80个字节,数据段有8个字节,bss有8个字节。bss中的8个字节是为了q而存在的,我可以通过objdump -D看到这一点(它将符号显示为.bss部分的一部分)。readelf -a证实了这一点:q在第3个部分,即.bss中。我还没有弄清楚究竟是什么进入了.data,但它绝对不是q本身的存储空间。 - Peter Cordes
1
@MehranTorki:哦,我刚才想通了:**.init_array区段应该放在数据段里面**。readelf -a显示它的标志是“WA”(可写、已分配),这意味着它是非零可写数据,因此需要放在数据段中。它的大小为8个字节。 - Peter Cordes
这真的很有挑战性 :) 感谢你的努力。顺便问下,我有两个问题:1. 为什么要使用rip 寄存器来访问存储 rax?2. “ELF 段与 x86 段无关” 这句话我没太理解,数据段中的变量如何在没有 ds 寄存器的情况下访问?你能给我提供一个进一步阅读的链接吗?抱歉,我对这个主题几乎是新手。谢谢。 - Mehran Torki
非常感谢,您的意思是cs寄存器不用于访问静态数据,而是使用RIP相对寻址,我理解得对吗? - Mehran Torki
显示剩余3条评论

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