8、BSS段清零:为什么全局变量默认是0?

这个问题,我估计每个C语言开发者都想过。

你写个 int g_count;,没赋初值,结果它默认就是0。为什么?

嗯,答案就在BSS段。今天我们就把它彻底说透。

8.1 一个让我翻过车的问题

先讲个我自己的经历。

几年前做一款工业控制器,RAM空间只有64KB。我定义了一个全局数组:

// 全局变量,未初始化
uint8_t buffer[4096];

当时想当然地认为,这4096字节会被自动清零。结果第一次上电,系统跑飞了。

排查了半天,发现是启动代码里BSS段清零的循环写错了,只清了前1024字节。后面的数据全是随机值。

从那以后,我每次看启动代码,都会特别留意BSS段清零这部分。

8.2 什么是BSS段?

BSS是"Block Started by Symbol"的缩写。这个名字有点古老,但意思很明确:

它专门存放那些未初始化初始化为0的全局变量和静态变量。

举个例子:

int g_a;              // 未初始化 → BSS段
int g_b = 0;          // 初始化为0 → BSS段
int g_c = 100;        // 初始化为非0 → 数据段(.data)
static int s_d;       // 静态未初始化 → BSS段
const int g_e = 50;   // 常量 → 只读数据段(.rodata)

你想想看,如果每个未初始化的全局变量都要在可执行文件里占空间,那文件得多大?

BSS段的设计就是为了解决这个问题——它只记录需要多大的空间,但不存储具体数据。

8.3 为什么非要清零?

说白了,这是C语言标准规定的。

C标准说:所有未显式初始化的全局变量和静态变量,生命周期开始时必须是0。

为什么这么规定?我个人理解有两点:

  • 安全性:未初始化的变量如果包含随机值,很容易引发难以排查的bug
  • 可预测性:程序的行为应该是确定的,不能因为链接顺序不同就得到不同的初值

我在项目中遇到过一种情况:某同事在RTOS的任务栈里定义了一个大数组,忘了清零就直接用。结果每次重启,设备表现都不一样。查了三天,最后发现是栈上的残留数据在作怪。

8.4 BSS段清零到底谁在做?

答案是:启动代码(startup code)。

在main函数执行之前,CPU会先跑一段汇编或C写的启动代码。这段代码里,BSS段清零是核心任务之一。

典型的流程是这样的:

// 启动代码中的BSS段清零(简化版)
extern uint32_t __bss_start;
extern uint32_t __bss_end;

void clear_bss(void)
{
    uint32_t *p = &__bss_start;
    while (p < &__bss_end)
    {
        *p++ = 0;
    }
}

这里的 __bss_start__bss_end 是链接脚本里定义的符号,标明了BSS段的起始和结束地址。

关键点:BSS段清零是在main之前完成的。所以你在main里看到全局变量是0,其实启动代码已经默默帮你干完活了。

8.5 链接脚本里的BSS段

链接脚本(.ld文件)里,BSS段通常是这样定义的:

.bss :
{
    __bss_start = .;
    *(.bss)
    *(.bss.*)
    *(COMMON)
    __bss_end = .;
} > RAM

我来解释一下:

  • __bss_start__bss_end 就是清零循环的边界
  • *(.bss) 收集所有目标文件的BSS段
  • *(COMMON) 处理未初始化的全局变量(某些编译器会放在COMMON段)

我曾经踩过一个坑:某个芯片的链接脚本里,BSS段结束地址算错了,导致清零范围多覆盖了后面的堆空间。结果malloc出来的内存全是0,排查了好久才发现是启动代码越界了。

8.6 为什么不用.data段来存0?

这个问题问得好。

.data段是用来存放有初始值的全局变量的。如果我把 int g = 0 放在.data段,可执行文件里就得为这个0值占一个位置。

但BSS段不需要——它只记录大小,不存数据。对于嵌入式系统来说,这能省下不少Flash空间。

举个例子:

变量定义 存放段 可执行文件大小影响
int g_a[1024]; BSS 几乎无影响(只记录大小)
int g_b[1024] = {0}; BSS 几乎无影响(编译器优化)
int g_c[1024] = {1}; .data 增加4096字节

你看,同样是1024个int,放在BSS段几乎不占文件空间,放在.data段就要实打实占4KB。

小技巧:如果你在嵌入式项目里发现可执行文件异常大,可以检查一下是不是不小心把大数组放在了.data段。改成BSS段(不初始化或初始化为0)就能显著减小文件体积。

8.7 清零的性能代价

BSS段清零不是免费的。

如果你的BSS段很大(比如几十KB甚至几MB),启动时的清零操作会占用不少时间。

我记得有一次做快速启动项目,要求上电后50ms内进入main。结果BSS段有128KB,清零就花了30ms。最后不得不优化清零算法,用DMA来加速。

常见的清零优化手段:

  • 按字清零:用32位或64位指针,不要逐字节清零
  • 使用DMA:让DMA控制器在后台做清零,CPU可以提前干别的
  • 延迟清零:某些场景下,可以等真正用到那块内存时再清零

注意:延迟清零要小心。如果某个全局变量在main之前就被中断服务程序访问了,而它还没被清零,那结果就是未定义的。我建议除非你非常清楚整个启动流程,否则还是老老实实一次性清零。

8.8 一个完整的BSS段清零流程图

下面这张图展示了从芯片上电到main函数执行之间,BSS段清零在整个启动流程中的位置:

启动流程中的BSS段清零 芯片上电 / 复位 设置堆栈指针 (SP) 初始化 .data 段(从Flash拷贝) ★ BSS段清零(关键步骤) 将 __bss_start 到 __bss_end 全部写0 调用 C 运行时初始化 进入 main() 函数 硬件自动完成 汇编代码 汇编/C代码 本文重点 C代码 用户程序

8.9 不同编译器下的差异

虽然原理相同,但不同编译器的实现细节有差异:

编译器 BSS段清零方式 备注
GCC (ARM) 汇编循环,按字清零 使用 __bss_start/__bss_end 符号
IAR 调用 __iar_zero_init 函数 支持分段清零,可跳过某些段
Keil 调用 __main 中的初始化函数 __main 不是用户main,是启动代码
SDCC (51) 在 crt0 中逐字节清零 8位单片机,效率较低

我个人比较喜欢GCC的方式——简单直接,没有隐藏的魔法。你打开启动代码,一眼就能看到清零循环。

8.10 避坑指南

最后,分享几个我踩过的坑:

  • 坑1:BSS段和堆重叠。有些链接脚本把堆放在BSS段后面,如果清零范围算错了,堆就被清零了。malloc出来的内存全是0,排查起来很迷惑。
  • 坑2:未初始化的局部变量。记住,BSS段只管全局和静态变量。局部变量在栈上,不会自动清零。我见过有人误以为局部变量默认是0,结果出了bug。
  • 坑3:const变量放在BSS段。const变量应该放在.rodata段,如果误放在BSS段,清零操作会尝试写只读内存,触发硬件异常。

总结一下:BSS段清零是C运行时初始化的核心环节。它保证了全局变量默认是0,这是C标准的要求,也是程序可靠性的基础。理解这个过程,能帮你写出更健壮的嵌入式代码,也能在出问题时快速定位。

嗯,关于BSS段清零,今天就聊到这里。下次你看到全局变量是0的时候,可以想想——背后是启动代码在默默付出。


公众号:蓝海资料掘金营,微信deep3321