2. 静态内存分配:全局变量与静态变量的内存布局、BSS段与数据段、栈空间分配与溢出检测

好,咱们今天聊聊静态内存分配。说实话,这是嵌入式系统里最基础、也最容易被忽视的一块。很多刚入行的朋友觉得内存分配嘛,不就是定义几个变量?其实不然。我在项目里见过太多因为静态内存布局没搞明白,导致系统跑着跑着就崩了的案例。

2.1 全局变量与静态变量的内存布局

先说说全局变量和静态变量。它们俩在内存里的待遇是一样的——都待在静态存储区。你想想看,不管是全局变量还是用 static 修饰的局部变量,它们的生命周期都是整个程序运行期间。

我个人习惯把这类变量分成两类:

  • 已初始化的:比如 int g_counter = 10;
  • 未初始化的:比如 static char buffer[1024];

这两类在内存里的位置可不一样。已初始化的放在数据段(Data Segment),未初始化的放在 BSS 段。为什么这么分?说白了就是为了节省存储空间。你想想,如果每个未初始化的变量都在可执行文件里占个位置,那文件得多大?

关键点:全局变量和静态变量在程序启动时就已经分配好内存了,运行期间地址固定不变。这就是「静态分配」这个名字的由来。

2.2 BSS段与数据段

好,咱们深入看看这两个段。我在调试一个通信协议栈时,曾经被这两个段坑过一次,印象特别深。

数据段(.data)

数据段存放的是那些在编译时就确定了初始值的全局变量和静态变量。比如:

// 这些变量会放在 .data 段
int system_tick = 0;           // 全局变量,初始值0
static int task_count = 5;     // 静态变量,初始值5
const char* version = "v2.1";  // 字符串常量指针

这些变量在可执行文件里是实实在在占着空间的。程序加载时,会把它们的初始值从 Flash 拷贝到 RAM 里。

BSS段(.bss)

BSS 段存放的是未初始化或初始值为 0 的全局变量和静态变量。比如:

// 这些变量会放在 .bss 段
int rx_buffer[1024];           // 未初始化
static char log_buffer[512];   // 未初始化
int flag = 0;                  // 初始值为0

嗯,这里要注意:BSS 段在可执行文件里不占空间!它只记录一个「需要多大空间」的信息。程序启动时,C 运行时库会把这个区域全部清零。

我的经验:在资源紧张的 MCU 上,我建议尽量把大数组放在 BSS 段。因为数据段会占用 Flash 空间,而 BSS 段不会。我曾经把一个 4KB 的查找表从数据段挪到 BSS 段,省出了宝贵的 Flash 空间。

内存布局示意图

一个典型的嵌入式程序内存布局是这样的:

高地址
+------------------+
|      栈区        |  ← 向下增长
+------------------+
|        ↓         |
|     空闲区域      |
|        ↑         |
+------------------+
|      堆区        |  ← 向上增长
+------------------+
|      BSS段       |  ← 未初始化全局/静态变量
+------------------+
|     数据段       |  ← 已初始化全局/静态变量
+------------------+
|     只读数据段    |  ← 字符串常量、const变量
+------------------+
|      代码段      |  ← 程序指令
+------------------+
低地址

2.3 栈空间分配与溢出检测

栈这个东西,说白了就是函数调用的「临时工」。每次调用函数,局部变量、函数参数、返回地址都往栈上压。函数返回时再弹出。

为什么会发生栈溢出?我遇到过最典型的情况是:一个中断服务函数里用了大数组,加上多层函数嵌套调用,栈空间就不够用了。

// 危险的写法——容易导致栈溢出
void isr_handler(void) {
    char big_buffer[2048];  // 2KB 局部变量!
    // 处理数据...
}

void deep_function(int level) {
    char local[128];
    if (level > 0) {
        deep_function(level - 1);  // 递归调用
    }
}

警告:我曾经在一个项目中,因为中断函数里定义了一个 1KB 的局部数组,加上主循环里也有大数组,导致系统运行几小时后随机崩溃。排查了两天才发现是栈溢出。从那以后,我对局部变量的大小特别敏感。

栈溢出检测方法

我常用的检测方法有这么几种:

  1. 栈填充法:启动时用特定模式(比如 0xDEADBEEF)填充整个栈区。定期检查填充值是否被覆盖。
  2. 栈指针监控:在任务切换或中断入口处检查栈指针是否超出安全范围。
  3. 硬件 MPU/MMU:如果芯片支持,可以设置栈区边界的内存保护单元,溢出时触发异常。

这里给个简单的栈填充检测代码:

// 栈溢出检测示例
#define STACK_SIZE     4096
#define STACK_FILL     0xDEADBEEF

uint32_t stack_area[STACK_SIZE / 4];

void stack_init(void) {
    for (int i = 0; i < STACK_SIZE / 4; i++) {
        stack_area[i] = STACK_FILL;
    }
}

uint32_t stack_check(void) {
    uint32_t used = 0;
    for (int i = 0; i < STACK_SIZE / 4; i++) {
        if (stack_area[i] != STACK_FILL) {
            used++;
        }
    }
    return used * 4;  // 返回已使用的字节数
}

避坑指南:我曾经在 FreeRTOS 项目里,给每个任务分配了 512 字节的栈空间。看起来够用对吧?结果有个任务里调用了 sprintf,这个函数内部会申请不小的临时空间。最后我把栈空间加到 1024 字节才解决问题。所以我的建议是:给栈空间留 30%-50% 的余量。

2.4 实际项目中的建议

说了这么多,总结几条我在实际项目中的做法:

  • 大数组尽量用静态分配:超过 256 字节的缓冲区,我一般定义为全局或静态变量,放在 BSS 段。
  • 中断函数里别用大局部变量:中断栈通常比主栈小得多。实在要用,考虑用静态变量。
  • 定期检查栈使用率:在产品开发阶段,我会在空闲任务里打印栈使用情况,做到心里有数。
  • 链接脚本里明确各段地址:我习惯在链接脚本里给栈区、堆区、BSS段、数据段都分配固定的地址范围,方便调试。

嗯,静态内存分配这块就聊到这儿。说白了,它不像动态分配那么灵活,但胜在确定性和可靠性。在嵌入式实时系统里,确定性往往比灵活性更重要。你想想看,一个飞行控制器敢用 malloc 吗?不敢的。所以,把静态内存分配搞明白,是每个嵌入式工程师的必修课。