为什么在汇编/C中使用.data而不是在.bss中预留空间并在运行时初始化变量?

5
首先:我知道有很多网页(包括stackoverflow上的讨论)讨论了数据声明中.bss和.data之间的差异,但是我有一个具体的问题,在这些页面上没有找到答案,所以我在这里问:-)。
我是汇编的初学者,如果问题很蠢,请原谅:-)。
我正在学习x86 64位Linux操作系统上的汇编语言(但我认为我的问题更普遍,可能与操作系统/架构无关)。
我觉得.bss和.data部分的定义有点奇怪。我总是可以在.bss中声明一个变量,然后在我的代码(.text部分)中移动一个值到这个变量中,对吧?那么为什么我要在.data部分声明一个变量,如果我知道在这个部分声明的变量会增加我的可执行文件的大小呢?
我也可以在C编程环境中提出这个问题:为什么我应该在声明变量时初始化它,而不是声明一个未初始化的变量,然后在代码开头为它赋值更有效率呢?
我想我的内存管理方法是天真和不正确的,但我不明白为什么。

小知识 - bss 代表符号开始的块。一些汇编语言也有 bes 表示符号结束的块(这对于内存堆栈类型的使用是有意义的)。 - rcgldr
3个回答

8

.bss是用于放置初始化为0的静态数据的地方,例如C语言全局作用域下的int x;。对于静态/全局(static storage class),这与int x = 0;相同1

.data是用于放置非零初始化静态数据的地方,例如int x = 2;。如果将其放在BSS中,则需要运行时静态“构造函数”来初始化BSS位置。就像C++编译器为static const int prog_starttime = __rdtsc();所做的那样。(即使它是const,但初始化程序不是编译时常量,因此无法放入.rodata)


对于大多数为零或填充相同值(使用memset/rep stosd)的大数组,带有运行时初始化程序的.bss是有意义的,但在实践中,使用char buf[1024000] = {1};会将近1MB的几乎全部都是零的数据放入.data中,现代编译器会这样做。

否则,使用.bss并不更加高效。一个mov dword [myvar], imm32指令至少需要8个字节,在可执行文件中的成本是静态初始化在.data中的两倍。此外,还必须执行初始化程序。


相比之下,section .rodata(或Windows上的.rdata)是编译器放置字符串字面量、FP常数以及static const int x = 123;的地方。(实际上,x通常会作为立即数内联到编译单元中使用它的任何地方,从而让编译器优化掉任何静态存储。但是,如果您获取其地址并将&x传递给函数,则编译器需要在内存中存在它,并且这将在.rodata中)


注1: 在函数内部,如果编译器没有将其优化或者转换为寄存器,那么int x;将位于堆栈上,当编译为像x86这样具有堆栈的常规寄存器机器时。


我也可以在C编程的环境中提出这个问题

在C语言中,优化编译器会在函数内将int x; x=5;int x=5;基本相同。没有涉及到静态存储。查看实际的编译器输出通常很有启发性:请参见如何从GCC/clang汇编输出中去除“噪音”?.

在全局范围外,你不能编写像x=5;这样的代码。你可以在main函数的顶部这样做,然后你会欺骗编译器生成更糟糕的代码。
static int x = 5;的函数内部,初始化只会发生一次。(在编译时)。如果你使用static int x; x=5;,每次进入函数时静态存储空间都会被重新初始化,除非你有其他需要静态存储类的原因,否则你最好不要使用static。(例如,在函数返回后仍然有效地返回指向x的指针。)

1
好的!非常感谢你详尽的回答,现在我理解得更清楚了。 我会思考你所说的,并在必要时发布一个新问题 :-). - Louis

1
我将通过举例来说明,尽管标签为x86,但ARM更易于阅读等方面 - 功能上是相同的。
引导程序
.globl _start
_start:
    ldr r0,=__bss_start__
    ldr r1,=__bss_end__
    mov r2,#0
bss_fill:
    cmp r0,r1
    beq bss_fill_done
    strb r2,[r0],#1
    b bss_fill
bss_fill_done:
    /* data copy would go here */
    bl main
    b .

这段代码可能存在漏洞,效率肯定不高,但是为了演示目的而存在。
C 代码
unsigned int ba;
unsigned int bb;
unsigned int da=5;
unsigned int db=0x12345678;
int main ( void )
{
    ba=5;
    bb=0x88776655;
    return(0);
}

我也可以使用汇编语言,但是在汇编语言中,.bss、.data等不像在编译后的代码中那么有意义。

MEMORY
{
    rom : ORIGIN = 0x08000000, LENGTH = 0x1000
    ram : ORIGIN = 0x20000000, LENGTH = 0x1000
}
SECTIONS
{
    .text : { *(.text*) } > rom
    .rodata : { *(.rodata*) } > ram
    __bss_start__ = .;
    .bss : { *(.bss*) } > ram
    __bss_end__ = .;
    __data_start__ = .;
    .data : { *(.data*) } > ram
    __data_end__ = .;
}

使用的链接脚本。
结果:
Disassembly of section .text:

08000000 <_start>:
 8000000:   e59f001c    ldr r0, [pc, #28]   ; 8000024 <bss_fill_done+0x8>
 8000004:   e59f101c    ldr r1, [pc, #28]   ; 8000028 <bss_fill_done+0xc>
 8000008:   e3a02000    mov r2, #0

0800000c <bss_fill>:
 800000c:   e1500001    cmp r0, r1
 8000010:   0a000001    beq 800001c <bss_fill_done>
 8000014:   e4c02001    strb    r2, [r0], #1
 8000018:   eafffffb    b   800000c <bss_fill>

0800001c <bss_fill_done>:
 800001c:   eb000002    bl  800002c <main>
 8000020:   eafffffe    b   8000020 <bss_fill_done+0x4>
 8000024:   08000058    stmdaeq r0, {r3, r4, r6}
 8000028:   20000008    andcs   r0, r0, r8

0800002c <main>:
 800002c:   e3a00005    mov r0, #5
 8000030:   e59f1014    ldr r1, [pc, #20]   ; 800004c <main+0x20>
 8000034:   e59f3014    ldr r3, [pc, #20]   ; 8000050 <main+0x24>
 8000038:   e59f2014    ldr r2, [pc, #20]   ; 8000054 <main+0x28>
 800003c:   e5810000    str r0, [r1]
 8000040:   e5832000    str r2, [r3]
 8000044:   e3a00000    mov r0, #0
 8000048:   e12fff1e    bx  lr
 800004c:   20000004    andcs   r0, r0, r4
 8000050:   20000000    andcs   r0, r0, r0
 8000054:   88776655    ldmdahi r7!, {r0, r2, r4, r6, r9, r10, sp, lr}^

Disassembly of section .bss:

20000000 <bb>:
20000000:   00000000    andeq   r0, r0, r0

20000004 <ba>:
20000004:   00000000    andeq   r0, r0, r0

Disassembly of section .data:

20000008 <db>:
20000008:   12345678    eorsne  r5, r4, #120, 12    ; 0x7800000

2000000c <da>:
2000000c:   00000005    andeq   r0, r0, r5

显然,在最后你可以看到四个变量的存储,它们是.bss和.data,正如预期的那样。

但这里有一个人们试图解释的区别。

应该有代码来清零.bss,这是一种浪费周期的做法,一些编译器开始警告使用未初始化的变量,这是好的,但无论如何,.bss都有一些清零代码。 .data也可能有一些复制代码,我没有完成这个示例以展示它是如何工作的,您可以告诉链接器脚本,.data在ram中,但将其副本放在rom中,并具有两个地址和大小/结束,rom数据开始和ram数据开始,然后从rom复制到ram。

因此,.data与.bss的成本差异在于,对于.data,您已经分配了内存,通过操作系统加载程序或自己的引导程序,该数据可能需要再次复制,也可能不需要。

20000008 <db>:
20000008:   12345678

对于.bss

20000000 <bb>:
20000000:   00000000    andeq   r0, r0, r0

再次提到操作系统加载程序和/或构建方法(在这种情况下,将 .data 放在 .bss 之后,并且至少有一个 .data 项,如果你将其 objcopy -O binary,你会得到在 .bin 文件中被清零的数据,而不需要填充那些 .bss 数据,这取决于加载程序和目标文件)。

因此,存储空间是相等的,但是 .bss 的额外成本是

 800002c:   e3a00005    mov r0, #5
 8000030:   e59f1014    ldr r1, [pc, #20]   ; 800004c <main+0x20>

 800003c:   e5810000    str r0, [r1]

 800004c:   20000004

并且

 8000034:   e59f3014    ldr r3, [pc, #20]   ; 8000050 <main+0x24>
 8000038:   e59f2014    ldr r2, [pc, #20]   ; 8000054 <main+0x28>

 8000040:   e5832000    str r2, [r3]

 8000050:   20000000
 8000054:   88776655

第一个需要一条指令将5存入寄存器,一条指令获取地址和一个内存周期将5存入内存。第二个更昂贵,因为它需要一条带有内存周期的指令来获取数据,然后是一条获取地址的指令,最后是存储,所有这些都是内存周期。另一个答案试图争辩说你没有静态成本,因为它们是立即数,但是变长指令集中的这些立即数是存在并像固定长度一样从内存中读取的,它不是单独的内存周期,而是预取的一部分,但它仍然是静态存储。区别在于您至少需要一个内存周期将值存储在内存中(.bss和.data意味着全局,因此需要将其存储到内存中)。由于这些是链接的,变量的地址需要由链接器放置在固定长度risc指令集附近的池中,在类似x86的cisc中,它将嵌入到移动立即数到寄存器中,无论哪种方式,地址的静态存储和值的静态存储,x86与arm相比,x86将使用更少的字节的指令执行两个指令的任务,arm需要三个指令和三个单独的内存周期。功能上是相同的。现在,在完全控制下(裸机)违反期望但可以节省您的开销。
.globl _start
_start:
    ldr sp,=0x20002000
    bl main
    b .



unsigned int ba;
unsigned int bb;
int main ( void )
{
    ba=5;
    bb=0x88776655;
    return(0);
}
MEMORY
{
    rom : ORIGIN = 0x08000000, LENGTH = 0x1000
    ram : ORIGIN = 0x20000000, LENGTH = 0x1000
}
SECTIONS
{
    .text : { *(.text*) } > rom
    .rodata : { *(.rodata*) } > ram
    .bss : { *(.bss*) } > ram
}


Disassembly of section .text:

08000000 <_start>:
 8000000:   e59fd004    ldr sp, [pc, #4]    ; 800000c <_start+0xc>
 8000004:   eb000001    bl  8000010 <main>
 8000008:   eafffffe    b   8000008 <_start+0x8>
 800000c:   20002000    andcs   r2, r0, r0

08000010 <main>:
 8000010:   e3a00005    mov r0, #5
 8000014:   e59f1014    ldr r1, [pc, #20]   ; 8000030 <main+0x20>
 8000018:   e59f3014    ldr r3, [pc, #20]   ; 8000034 <main+0x24>
 800001c:   e59f2014    ldr r2, [pc, #20]   ; 8000038 <main+0x28>
 8000020:   e5810000    str r0, [r1]
 8000024:   e5832000    str r2, [r3]
 8000028:   e3a00000    mov r0, #0
 800002c:   e12fff1e    bx  lr
 8000030:   20000004    andcs   r0, r0, r4
 8000034:   20000000    andcs   r0, r0, r0
 8000038:   88776655    ldmdahi r7!, {r0, r2, r4, r6, r9, r10, sp, lr}^

Disassembly of section .bss:

20000000 <bb>:
20000000:   00000000    andeq   r0, r0, r0

20000004 <ba>:
20000004:   00000000    andeq   r0, r0, r0

我认为在之前的示例中删除了堆栈初始化。

没有必要使(特定于工具链的)链接器脚本复杂化,也没有必要在引导程序中初始化任何内存,而是在代码中初始化变量。虽然这样做在 .text 空间方面更加昂贵,但更易于编写和维护,并且如果需要,更容易移植等。但是,如果有人想拿这段代码并添加一个 .data 项或假设一个 .bss 项被清零,则会打破已知的规则/假设。

另一个快捷方式,比如树莓派裸机:

.globl _start
_start:
    ldr sp,=0x8000
    bl main
    b .

unsigned int ba;
unsigned int bb;
unsigned int da=5;
int main ( void )
{
    return(0);
}

MEMORY
{
    ram : ORIGIN = 0x00008000, LENGTH = 0x1000
}
SECTIONS
{
    .text : { *(.text*) } > ram
    .rodata : { *(.rodata*) } > ram
    .bss : { *(.bss*) } > ram
    .data : { *(.data*) } > ram
}



Disassembly of section .text:

00008000 <_start>:
    8000:   e3a0d902    mov sp, #32768  ; 0x8000
    8004:   eb000000    bl  800c <main>
    8008:   eafffffe    b   8008 <_start+0x8>

0000800c <main>:
    800c:   e3a00000    mov r0, #0
    8010:   e12fff1e    bx  lr

Disassembly of section .bss:

00008014 <bb>:
    8014:   00000000    andeq   r0, r0, r0

00008018 <ba>:
    8018:   00000000    andeq   r0, r0, r0

Disassembly of section .data:

0000801c <da>:
    801c:   00000005    andeq   r0, r0, r5

hexdump -C so.bin
00000000  02 d9 a0 e3 00 00 00 eb  fe ff ff ea 00 00 a0 e3  |................|
00000010  1e ff 2f e1 00 00 00 00  00 00 00 00 05 00 00 00  |../.............|
00000020

存在一个 .data 项和在链接脚本中定义 .bss 之后以及二进制文件作为整体被GPU复制到RAM中,包括 .text、.bss、.data 等。.bss 的清零是一个免费的好处,我们不需要为 .bss 添加额外的代码,如果我们有更多的 .data 并且正在使用它,我们也会得到一个免费的 .data 初始化/复制。

这些都是边角情况,但确实展示了你所考虑的为什么要将变量清零,我可以随后在 .text 中更改或最终更改的原因。这也延伸到为什么要在启动时烧录清零该部分,为什么要复杂化链接器脚本,GNU 链接器脚本最好是让人头痛的,必须非常小心才能正确使用,当然,一旦你弄对了,每个工具链项目的每个版本都不需要太多工作来查看它是否仍然有效。

为了正确地执行,.bss会消耗指令和执行时间,包括单独的内存总线周期。但是无论如何,对于.bss都应该有链接器脚本和引导代码。同样对于.data也是如此,但除非基于rom/flash,否则.data的源和目标很可能是相同的,复制发生在加载程序中(操作系统将二进制文件从rom/flash/disk复制到内存),并且不需要额外的复制,除非你在链接器脚本中强制执行它。
根据其他问题中的评论,“正确”是基于假设的,.data项目需要按编译代码中定义的方式显示,历史上发现.bss是特定于工具链的,规范说什么我需要查找,使用的工具链的版本可能会因为并非所有今天使用的工具链都在不断维护以符合当前的标准而不同。一些人有限制其项目为那些具有最新工具的奢侈品,而许多人则没有。
这里展示的快捷方式类似于手动调整汇编语言而不是使用编译器提供的内容,你需要自己决定,如果不小心可能会有风险,但是如果项目需要,可以在启动时获得相当可观的性能提升。对于非专业工作,不建议使用此类方法。请注意,使用全局变量也会引起使用者的争议。如果不使用全局变量,则仍需处理本地全局变量,即我所说的本地静态变量,它们属于此类别。
unsigned int more_fun ( unsigned int, unsigned int );
void fun ( unsigned int x )
{
    static int ba;
    static int da=0x12345678;
    ba+=x;
    da=more_fun(ba,da);
}
int main ( void )
{
    return(0);
}

0000800c <fun>:
    800c:   e59f2028    ldr r2, [pc, #40]   ; 803c <fun+0x30>
    8010:   e5923000    ldr r3, [r2]
    8014:   e92d4010    push    {r4, lr}
    8018:   e59f4020    ldr r4, [pc, #32]   ; 8040 <fun+0x34>
    801c:   e0803003    add r3, r0, r3
    8020:   e5941000    ldr r1, [r4]
    8024:   e1a00003    mov r0, r3
    8028:   e5823000    str r3, [r2]
    802c:   ebfffff6    bl  800c <fun>
    8030:   e5840000    str r0, [r4]
    8034:   e8bd4010    pop {r4, lr}
    8038:   e12fff1e    bx  lr
    803c:   0000804c    andeq   r8, r0, r12, asr #32
    8040:   00008050    andeq   r8, r0, r0, asr r0

00008044 <main>:
    8044:   e3a00000    mov r0, #0
    8048:   e12fff1e    bx  lr

Disassembly of section .bss:

0000804c <ba.3666>:
    804c:   00000000    andeq   r0, r0, r0

Disassembly of section .data:

00008050 <da.3667>:
    8050:   12345678    eorsne  r5, r4, #120, 12    ; 0x7800000

无论是本地静态变量还是本地全局变量,它们仍然会存储在.data或.bss中。

1
写入立即操作数(即编译时常量)到内存位置的指令大小必然大于常量本身的大小。如果所有常量都是不同值,那么需要为不同的值使用不同的指令,这些指令的总大小将大于值的总大小。此外,执行这些指令会有运行时性能开销。如果常量相同,则可以使用循环来初始化所有对应的变量。循环本身确实比常量的总大小要小得多。在这种情况下,您可以使用类似于malloc的东西分配一个区域,然后使用循环来初始化分配的区域,而不是分配许多静态变量来保存相同的常量。这可以显著减少对象文件的大小并提高性能。
考虑一个将一些页面初始化为某个常量的操作系统,或者不同的页面可能会被初始化为不同的常量。这些页面可以由操作系统在后台线程中准备好。当程序请求已初始化为特定常量的页面时,操作系统可以简单地将其已经初始化的页面之一映射到其页表中,从而避免在运行时执行循环的需要。事实上,Windows操作系统始终将所有回收的页面初始化为全零位的常量值。这既是安全功能又是性能增强功能。

静态变量通常在编译时要么不进行初始化,要么被初始化为零。例如C和C++等某些语言要求运行时将未初始化的静态变量初始化为零。如何高效地将页面初始化为零?例如,在对象文件的入口点中,C运行时可以发出一系列指令或循环来将所有未初始化的静态变量初始化为指定的编译时常量。但这样每个对象文件都需要这些指令,空间利用率更高效的做法是委托操作系统按需(在Linux上)或主动(在Windows上proactively)执行此初始化。

ELF可执行文件格式将bss节定义为包含未初始化变量的对象文件部分。因此,bss节只需要指定所有变量的总大小,而数据节还需要指定每个变量的值。没有要求操作系统应该将bss节初始化为零或任何其他值,但通常情况下确实如此。此外,尽管C/C++要求运行时将所有未明确初始化为零/空的静态变量初始化,但语言标准并没有为零/空定义特定的位模式。只有当语言实现和bss实现匹配时,才能在bss节中分配未初始化的静态变量。
当Linux加载ELF二进制文件时,它将bss节映射到一个专用的零页面上,并标记为写时复制(参见:写时复制如何工作)。因此,将该页面初始化为零不会产生额外开销。在某些情况下,bss可能只占据一页的一部分(例如参见 系统调用后Gnu汇编器数据段值损坏),这种情况下,使用movb/incq/decl/jnz循环将那部分明确地初始化为全0位。
一个假设的操作系统可以将bss段的每个字节初始化为0000_0001b。在C的假设实现中,NULL指针的位模式可能是(多个字节的)0000_0010b。在这种情况下,默认初始化的静态指针变量和数组可以在bss段中分配,而无需在C程序中进行任何init循环。但是除非它们恰好在C源代码中显式初始化为与位模式匹配的值,否则任何其他值,例如整数数组,都需要一个init循环。
(C允许实现定义的非零对象表示形式用于NULL指针,但整数更受限制。C规则要求静态存储类变量在没有明确初始化的情况下隐式初始化为0。并且unsigned char必须是2进制的,没有填充。在源代码中将0作为指针的初始化器映射到NULL位模式,不像使用memcpyunsigned char零复制到对象表示中。)

Visual Studio / Microsoft的32/64位工具集可以清空.bss变量。Microsoft的16位工具集则会留下未初始化的.bss空间,而.data中声明为“mydata db 20 dup(?)”的变量将在汇编时从内存中取出任何内容,有时会得到源代码片段。 - rcgldr
当变量被显式初始化时,它们会进入.data区域,那么如何在16位工具集中不初始化变量。自XP以来的所有Windows版本中都有一个零页面线程用于清零页面,无论是为了.bss,.data还是其他任何目的,从用户空间分配非零初始化的页面都是不可能的。 - Hadi Brais
我的评论中提到了 Microsoft 16 位工具集,通常与 MSDOS 6.22 一起使用。.bss 没有被初始化。并且正如我之前所评论的那样,一个声明为“?”的数据值应该是未初始化的,通常用于 .bss,但在 .data 中,“?”对于 MASM(5.x、6.x aka ML.EXE)仅仅是在汇编时获取内存中的任何内容。 - rcgldr
1
在 C 的假想实现中,零/空的位模式可能是 0000_0010b。这不是实用的。NULL指针表示是实现定义的,但要求无符号字符具有正常的二进制2进制,并且没有填充(即有 2^CHAR_BIT 可能的值)。如果 0 的底层比特模式实际上不是 0,那么几乎所有东西都必须被模拟,包括对象表示的 memcpy。 - Peter Cordes
1
我认为Linux只在与.data段位于同一页的小BSS中使用__clear_user和简单的movq循环。我们知道的BSS数组所有页面都是写时复制映射到同一个物理零页面,因此它们肯定不是分别用零写入每个虚拟页面的。我很惊讶没有rep movsb替代__clear_user。相对于movq循环,平衡点可能是128或256字节左右,并且避免了清除操作的分支缺失。 - Peter Cordes
显示剩余4条评论

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