2、静态内存分析:编译期内存布局

上一章我们聊了嵌入式 GUI 的内存到底花在哪。这一章,咱们来点实在的——在代码还没跑起来之前,就能把固件大小算个七七八八。说白了,就是看编译器给你生成的 .text、.data、.bss 这三个段。

我刚开始做嵌入式 GUI 的时候,总觉得内存分析是后期的事。直到有一次,一个菜单动画在 64KB RAM 的芯片上直接崩了,我才意识到——编译期就能看出来的问题,千万别拖到运行时。

3.1 三个段,三种命运

一个编译好的固件,内存布局大致长这样:

段名 存放内容 占用 Flash? 占用 RAM? 运行时是否变化
.text 代码指令、常量字符串、只读数据 ✅ 是 ❌ 否 不变
.data 已初始化的全局/静态变量 ✅ 是(初始值) ✅ 是(运行时拷贝) 可读写
.bss 未初始化的全局/静态变量 ❌ 否 ✅ 是 可读写

嗯,这里要注意:.data 段在 Flash 里存了一份初始值,启动时会被拷贝到 RAM 里。所以它占的是双份空间。而 .bss 段在 Flash 里不占地方,只占 RAM,启动时由启动代码清零。

核心结论:对于嵌入式 GUI 来说,.bss 段的大小往往决定了你的 UI 能开多少个缓冲区。我见过一个项目,就因为 .bss 里多放了两个 320×240 的帧缓冲,直接超了 RAM 上限。

3.2 用 size 命令快速摸底

拿到一个编译好的 .elf 文件,我最先干的事就是敲 size 命令。它就像体检时的身高体重秤,一眼就能看出固件的大致体量。

$ arm-none-eabi-size build/gui_app.elf
   text    data     bss     dec     hex filename
  45232    1284    8192   54708    D5B4 build/gui_app.elf

这行输出什么意思?

  • text = 45232 字节:代码 + 常量,全在 Flash 里
  • data = 1284 字节:已初始化的全局变量,Flash 和 RAM 各占一份
  • bss = 8192 字节:未初始化的全局变量,只占 RAM
  • dec = 54708 字节:总和,但注意这不是 Flash 或 RAM 的单独总量

我个人习惯用这个公式快速估算:

Flash 占用 = .text + .data
RAM 占用   = .data + .bss

拿上面的例子算一下:

  • Flash = 45232 + 1284 = 46516 字节(约 45.4 KB)
  • RAM = 1284 + 8192 = 9476 字节(约 9.3 KB)

小技巧:如果你发现 .bss 段特别大,八成是某个全局数组或者帧缓冲没有初始化。我曾经接手过一个 GUI 项目,.bss 占了 64KB,查了半天发现是一个 320×240×16bit 的帧缓冲没赋初值——其实它只需要 150KB 的 RAM,但 .bss 里就占了 150KB 的声明空间。

3.3 用 readelf 深入细节

size 只能看个总数,真要排查具体是哪个变量、哪个函数在吃内存,就得用 readelf 了。这工具就像 CT 扫描,能把固件内部的结构看得清清楚楚。

查看段详细信息

$ arm-none-eabi-readelf -S build/gui_app.elf

Section Headers:
  [Nr] Name       Type      Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]            NULL      00000000 000000 000000 00      0   0  0
  [ 1] .text      PROGBITS  08000000 010000 00B0B0 00  AX  0   0  4
  [ 2] .rodata    PROGBITS  0800B0B0 01B0B0 001234 00   A  0   0  4
  [ 3] .data      PROGBITS  20000000 020000 000504 00  WA  0   0  4
  [ 4] .bss       NOBITS    20000504 020504 002000 00  WA  0   0  4
  [ 5] .stack     NOBITS    20002504 020504 000400 00  WA  0   0  4

这里有几个关键信息:

  • Addr:段的加载地址。.text 和 .rodata 在 Flash 地址空间(0x0800xxxx),.data、.bss、.stack 在 RAM 地址空间(0x2000xxxx)
  • Size:段的大小,十六进制。比如 .bss 的 Size = 0x002000 = 8192 字节
  • Flg:段的权限。A=可寻址,X=可执行,W=可写

注意:.rodata 段虽然存的是只读数据,但它通常被链接到 Flash 区域,不占 RAM。很多新手会把 .rodata 误认为是 RAM 占用,其实它只吃 Flash。

查看符号表,定位“内存大户”

想知道哪个全局变量占了多少 .bss?哪个函数占了多少 .text?用 readelf -s 看符号表:

$ arm-none-eabi-readelf -s build/gui_app.elf | sort -k 3 -n -r | head -10

   Num:    Value  Size Type    Bind   Vis      Ndx Name
   123: 20000000  4096 OBJECT  GLOBAL DEFAULT    3 frame_buffer_0
   124: 20001000  4096 OBJECT  GLOBAL DEFAULT    3 frame_buffer_1
    45: 08001234  2048 FUNC    GLOBAL DEFAULT    1 lv_draw_arc
    67: 20002000  1024 OBJECT  GLOBAL DEFAULT    4 ui_button_states
    12: 08000000   512 FUNC    GLOBAL DEFAULT    1 reset_handler

看到没?frame_buffer_0 和 frame_buffer_1 各占 4096 字节,都在 .data 段(Ndx=3)。这两个就是 GUI 的双缓冲。如果你发现 .bss 或 .data 异常大,用这个命令一查,元凶立马现形。

我有个习惯:每次编译完,都会跑一遍 readelf -s | sort -k 3 -n -r | head -20,看看前 20 个“内存大户”是谁。这比等到运行时崩溃再回头查,要省心得多。

3.4 实战:分析一个 GUI 固件

咱们拿一个实际的 LVGL 工程来演示。假设芯片是 STM32F429,Flash 2MB,RAM 256KB。

$ arm-none-eabi-size build/lvgl_demo.elf
   text    data     bss     dec     hex filename
 245632    4096   65536  315264   4CF80 build/lvgl_demo.elf

算一下:

  • Flash = 245632 + 4096 = 249728 字节(约 244 KB)
  • RAM = 4096 + 65536 = 69632 字节(约 68 KB)

嗯,Flash 还剩很多,但 RAM 已经用了 68KB,芯片总共才 256KB,看起来还行。但别急,咱们用 readelf 看看 .bss 里到底是谁:

$ arm-none-eabi-readelf -s build/lvgl_demo.elf | grep "OBJECT" | sort -k 3 -n -r | head -5

   Num:    Value  Size Type    Bind   Vis      Ndx Name
   201: 20000000 32768 OBJECT  GLOBAL DEFAULT    4 lv_disp_buf_1
   202: 20008000 32768 OBJECT  GLOBAL DEFAULT    4 lv_disp_buf_2
   105: 20010000  4096 OBJECT  GLOBAL DEFAULT    4 lv_indev_data
    88: 20011000  1024 OBJECT  GLOBAL DEFAULT    4 lv_theme_default
    12: 20011400   512 OBJECT  GLOBAL DEFAULT    4 system_tick

看到了吧?两个显示缓冲区各占 32KB,加起来 64KB,占了 .bss 的绝大部分。这就是 GUI 双缓冲的代价。

实战建议:如果你的 RAM 紧张,可以考虑:

  • 把双缓冲改成单缓冲 + 局部刷新
  • 或者把缓冲区放到外部 SRAM/SDRAM
  • 或者降低色彩深度(比如从 16bit 降到 8bit)

这些改动,在编译期就能通过 size 和 readelf 看到效果,不用等到烧录运行。

3.5 一张图看懂静态内存分析流程

下面这张图,是我自己总结的静态内存分析流程。每次拿到一个新项目,我都会按这个步骤走一遍:

静态内存分析流程图 静态内存分析流程 1. 编译固件 2. size 命令摸底 3. 判断是否异常? 否 记录基线 是 4. readelf 定位元凶 5. 优化决策 优化后重新编译 → 回到步骤2,对比 size 变化,直到满足目标

这个流程我用了好几年,每次都能快速定位问题。说白了,静态内存分析就是“先看总数,再查明细,最后做决策”的三步走。

3.6 避坑指南

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

  • 别把 .rodata 当 RAM 占用:它虽然在符号表里,但实际在 Flash 里。用 readelf -S 看段的地址范围,确认它在哪个区域。
  • 注意对齐填充:结构体里的 padding 会悄悄增加 .bss 和 .data 的大小。我遇到过因为结构体成员顺序不对,白白多出 20% 内存的情况。
  • 链接脚本也会影响布局:有些链接脚本会把 .data 的初始值放在 .text 后面,导致 .text 看起来比实际大。用 readelf -S 看段的偏移和地址,才能算准。
  • 调试信息不算在段里:size 命令默认不统计调试信息(.debug_* 段)。如果你发现 .elf 文件很大但 size 很小,八成是开了 -g 编译选项。

我的习惯:每次提交代码前,都会跑一遍 size 并把结果记在一个文本文件里。这样哪天内存突然涨了,翻翻历史记录就知道是哪次提交引入的问题。这招帮我省了不少排查时间。