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 一张图看懂静态内存分析流程
下面这张图,是我自己总结的静态内存分析流程。每次拿到一个新项目,我都会按这个步骤走一遍:
这个流程我用了好几年,每次都能快速定位问题。说白了,静态内存分析就是“先看总数,再查明细,最后做决策”的三步走。
3.6 避坑指南
最后分享几个我踩过的坑:
- 别把 .rodata 当 RAM 占用:它虽然在符号表里,但实际在 Flash 里。用
readelf -S看段的地址范围,确认它在哪个区域。 - 注意对齐填充:结构体里的 padding 会悄悄增加 .bss 和 .data 的大小。我遇到过因为结构体成员顺序不对,白白多出 20% 内存的情况。
- 链接脚本也会影响布局:有些链接脚本会把 .data 的初始值放在 .text 后面,导致 .text 看起来比实际大。用
readelf -S看段的偏移和地址,才能算准。 - 调试信息不算在段里:
size命令默认不统计调试信息(.debug_* 段)。如果你发现 .elf 文件很大但 size 很小,八成是开了 -g 编译选项。
我的习惯:每次提交代码前,都会跑一遍 size 并把结果记在一个文本文件里。这样哪天内存突然涨了,翻翻历史记录就知道是哪次提交引入的问题。这招帮我省了不少排查时间。