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段清零在整个启动流程中的位置:
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