3、内存分析工具入门:使用size命令、nm、objdump分析固件内存布局

做嵌入式开发,说白了就是跟有限的内存资源打交道。你写了几万行代码,编译出来多大?哪些函数占了空间?全局变量都放在哪了?

我刚开始做MQTT客户端移植那会儿,就吃过这个亏。代码写完了,一烧录,芯片直接罢工。后来才发现,是.bss段太大,把RAM撑爆了。从那以后,我养成了一个习惯——每次编译完,先跑一遍内存分析工具。

今天咱们就聊聊三个最常用的工具:sizenmobjdump。它们能帮你把固件的内存布局看得清清楚楚。

3.1 size命令:一眼看穿固件大小

size 命令是最简单的。你编译完一个 .elf 文件,直接敲 size 就能看到三段信息:text、data、bss。

$ arm-none-eabi-size mqtt_client.elf
   text    data     bss     dec     hex filename
  45632    1284    10240   57156   df44 mqtt_client.elf

这三列分别代表什么?

  • text:代码段。存放你的函数指令、常量字符串。这部分通常烧写在Flash里。
  • data:已初始化的全局变量和静态变量。它们初始值在Flash,运行时拷贝到RAM。
  • bss:未初始化的全局变量和静态变量。运行时在RAM里,默认清零。

嗯,这里要注意一点:dec 列是 text+data+bss 的总和,但它不代表实际占用的Flash或RAM大小。为什么?因为 data 段既占Flash(存初始值),又占RAM(运行时)。实际Flash占用是 text+data,RAM占用是 data+bss。

实际内存占用公式:

Flash = text + data

RAM = data + bss

我个人习惯在 Makefile 里加一行编译后自动执行 size,这样每次编译完扫一眼,心里就有数了。

# Makefile 片段
$(TARGET).elf: $(OBJS)
    $(LD) $(LDFLAGS) -o $@ $^
    $(SIZE) $@

3.2 nm命令:谁在吃内存?逐个点名

size 只能看个总数。想知道具体哪个函数、哪个变量占了多少空间?那就得上 nm

$ arm-none-eabi-nm -S --size-sort mqtt_client.elf | head -20
00008000 00001234 T mqtt_publish
00009234 00000890 T mqtt_subscribe
00009ac4 00000456 T mqtt_connect
20000000 00001000 B mqtt_rx_buffer
20001000 00001000 B mqtt_tx_buffer
20002000 00000200 D mqtt_config

这里 -S 显示符号大小,--size-sort 按大小排序。输出格式是:地址、大小、符号类型、符号名。

符号类型怎么看?

  • T/t:代码段中的函数(T是全局,t是静态)
  • D/d:已初始化的数据段
  • B/b:未初始化的bss段
  • U:未定义符号(等着链接器去填)

我在项目中遇到过一件事:MQTT客户端的接收缓冲区设成了 8KB,但实际业务根本用不了那么多。用 nm 一查,mqtt_rx_buffer 赫然排在第一位。后来改成 2KB,RAM 瞬间省出 6KB。你想想看,这种优化是不是比抠代码效率高多了?

小技巧:nm -C 可以 demangle C++ 符号名,让函数名更可读。如果是 C 代码,不需要加这个选项。

3.3 objdump:反汇编看细节

objdump 是个瑞士军刀。它能反汇编、看段信息、看符号表。咱们做内存分析,主要用它的两个功能:

3.3.1 查看段详细信息

$ arm-none-eabi-objdump -h mqtt_client.elf

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         0000B264 08000000  08000000  00010000  2**2
  1 .data         00000504 20000000  0800B264  00020000  2**2
  2 .bss          00002800 20000504  20000504  00020504  2**2
  3 .rodata       00000120 0800B264  0800B264  0001B264  2**2

这里 VMA(虚拟地址)是运行时地址,LMA(加载地址)是烧录地址。对于 .text 和 .rodata,两者相同,都在Flash。对于 .data,VMA在RAM,LMA在Flash——启动代码会把它从Flash拷贝到RAM。

我曾经调试过一个诡异的问题:MQTT客户端在某种情况下发不出数据。反汇编一看,发现某个关键函数被优化掉了,因为链接器觉得它没被调用。用 objdump -d 反汇编确认后,加了个 __attribute__((used)) 才搞定。

3.3.2 反汇编特定函数

$ arm-none-eabi-objdump -d mqtt_client.elf | grep -A 50 "<mqtt_publish>:"
08000000 <mqtt_publish>:
 8000000:   b508        push    {r3, lr}
 8000002:   4b0c        ldr     r3, [pc, #48]
 8000004:   681b        ldr     r3, [r3, #0]
 8000006:   2b00        cmp     r3, #0
 8000008:   d001        beq.n   800000e <mqtt_publish+0xe>
 ...

反汇编能帮你确认编译器生成的代码是否符合预期。比如你写了个 memcpy,编译器有没有内联?函数调用开销多大?这些都能从汇编代码里看出来。

注意: 反汇编时加上 -S 选项,可以混排源代码和汇编,对照着看更直观。但前提是编译时加了 -g 调试信息。

3.4 实战:三步定位内存问题

好了,工具都认识了。咱们来个实战流程,看看怎么用这三个工具定位内存问题。

  1. 先用 size 看全局:编译完跑 size,确认 text、data、bss 是否在芯片规格内。如果超了,继续下一步。
  2. 再用 nm 找大户:按大小排序,找出占用最大的函数和变量。重点关注 bss 段的大数组、data 段的大结构体。
  3. 最后用 objdump 确认:对可疑的函数反汇编,看有没有冗余代码。或者查看段信息,确认链接脚本是否合理。

我记得有一次帮客户优化 MQTT 固件,他们抱怨 RAM 不够用。我按这个流程走了一遍,发现 .bss 里有个 4KB 的日志缓冲区,但产品量产时日志功能是关闭的。改成条件编译后,RAM 占用直接降了 30%。

核心思路: 先宏观,后微观。不要一上来就抠代码,先用工具找到瓶颈在哪。

3.5 常见陷阱与避坑指南

用这些工具时,有几个坑我踩过,分享给你:

  • 陷阱一:只看 size 不看链接脚本。size 显示的是默认布局,如果你的链接脚本自定义了段位置,实际占用可能不同。比如把 .rodata 放到 RAM 里,那 RAM 占用就要加上它。
  • 陷阱二:nm 显示的符号大小不包含子函数。比如一个函数调用了 memcpy,nm 只显示这个函数本身的指令大小,不包含 memcpy 的代码。要看整体,得用 bloatymap 文件。
  • 陷阱三:优化等级影响分析结果。用 -O0 编译出来的 size 可能比 -Os 大好几倍。我建议用最终发布的优化等级来分析。

我曾经在 -O0 下分析内存,发现一个函数占了 2KB,觉得太大了。结果改成 -Os 后,它只剩 400 字节。嗯,白忙活一场。所以记住:用发布配置做分析。

3.6 小结

这三个工具,size 看总数,nm 看明细,objdump 看细节。组合使用,基本能把固件的内存布局摸个底朝天。

下一章咱们会深入链接脚本,看看怎么通过修改链接脚本来精细控制内存布局。到时候这些工具还会派上用场。

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