精读 .map 文件:固件的竣工图(以 Cortex-M4 为例)
凌晨两点,产线值班工程师发来一个十六进制数:0x08001c58。背景是:那台跑了三天两夜的设备 HardFault 复位了一次,好在固件里留了后手——HardFault 处理函数把压栈的八个寄存器捞出来存进了备份寄存器,其中 PC 就是这个值(怎么捞的,见中断篇的压栈帧:R0、R1、R2、R3、R12、LR、PC、xPSR)。现在问你:这个地址,是哪个函数?
先排除一个错觉:烧进芯片的 .bin 里没有任何符号——那是一堆纯数据,地址 0x08001c58 在芯片里就是一堆字节,不叫任何名字。能对账的只有两份档案:链接时生成的 .elf(前提是你还留着、且没被 strip),或者 .map。链接脚本篇的实验二让你加过 -Wl,-Map=firmware.map,加完大概率就忘了——本篇就是把它打开。凌晨那位工程师一条 grep 就能出答案,前提是当年有人把这个文件归档了。
但多数人从没打开过 .map。理由充分:它动辄上万行,符号密密麻麻,像电话簿的电话簿。本篇目标是把它读薄:.ld 是内存布局的规划图,.map 就是兑现后的竣工图——规划图上写的每个门牌号,竣工图上都有一个实际数字,还附一份”哪些住户被拆迁了”的记录。读完你会发现,”这个地址是谁”“谁吃了我的 Flash”“我的函数去哪了”这一大类问题,从玄学变成了查表题。
L1:先让它读得懂——把 .map 读薄成四张表
你在第一层。目标:打开工程生成的 map 文件,不再当成”上万行的噪音”,能认出它的骨架。
生成方式先交代:链接参数加 -Wl,-Map=firmware.map(CMake 工程是 target_link_options(x PRIVATE -Wl,-Map=firmware.map))。它是个纯文本文件,跟着每次构建重新生成。跳过开头一大段——那几百行是链接器把你提交的 .ld 和它的内置规则合并后原样回显的全文,相当于试卷的题目部分,想确认”它到底在用哪份脚本”时再回来翻。
真正要看的是后面四张表:
| 区块 | 回答的问题 | 一句话比喻 |
|---|---|---|
| Archive member included | 哪些库成员被拉进来、被谁拉的 | 进货台账 |
| Memory Configuration | Flash/RAM 预算各是多少 | 仓库平面图 |
| Linker script and memory map | 每段、每个符号实际住哪、多大 | 主体:竣工图 |
| Discarded input sections | 垃圾场里都扔了什么 | 拆迁记录 |
给一份 STM32F407 工程的 map 主体,删到只剩骨架(真实文件里同样的结构会重复几百次):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Memory Configuration
Name Origin Length Attributes
FLASH 0x08000000 0x00100000 xr
RAM 0x20000000 0x00020000 xrw
Linker script and memory map
.isr_vector 0x08000000 0x390
*(.isr_vector)
.isr_vector 0x08000000 0x390 startup_stm32f407xx.o
.text 0x08000390 0x1a2c
*(.text .text.*)
.text.main 0x08000390 0xa0 main.o
0x08000390 main
.text.uart_send
0x08000430 0x38 uart.o
0x08000430 uart_send
.data 0x20000000 0x1d0 load address 0x08004dbc
*(.data)
.data 0x20000000 0x8 main.o
0x20000000 g_baud_rate
.bss 0x200001d0 0x20c
*(.bss .bss.*)
.bss.rx_buffer 0x200001d0 0x100 uart.o
0x200001d0 rx_buffer
读法就三条。第一,缩进即层级:顶格的 .text 一行是输出段(”这一片街区”),缩进的 .text.main 是输入段(”某栋楼”),再缩进的 main 是符号(”某住户”)——三级结构完全对应链接脚本篇里”分房方案 → 集装箱 → 门牌号”的层次。第二,每行前两个数是地址和大小:.text.main 那行读作”main 的代码从 0x08000390 起,占 0xa0 字节”。第三,load address 只在两个地址不一致时出现:.data 住 RAM(VMA 0x20000000)、初值存 Flash(LMA 0x08004dbc),map 用这行小字记下”一张房契两个地址”——.text 在 Flash 里 VMA 就是 LMA,所以没有这行注释。
有这三条,凌晨那个案子已经能结了:在 map 里找”小于等于 0x08001c58 的最大地址”所在的符号,就是出错函数——拿地址列对着 grep 就行。但先别急,下面有更顺手的工具。
到这里四张表认全了。但一堆”怎么用”悬着:垃圾场那四张表怎么帮人破案?printf 为什么吃了 8K Flash?两版固件之间胖了 4K,怎么查是谁长的?
L2:懂原理——四张表各断一类案
到这一层,问题变了:不再是”哪块是什么”,而是”出事的时候去哪张表、怎么读”。
案件一:地址是谁——把十六进制翻案成函数名
查地址是 map 最经典的用途,但先说清它的精度上限:map 只能定位到函数,不能定位到行。因为 map 里的最小条目是符号,而”函数内第几行”这种信息存在调试段(-g 编译进 .elf 的 DWARF)里。所以完整的工作流是个接力:
- 有
.elf(带调试信息):arm-none-eabi-addr2line -e firmware.elf -f -i 0x08001c58,直接吐出函数名和行号,-i还能把内联链展开; - 只有
.map:手工(或 grep)找”≤ 地址的最大符号”,精度到函数; - 两个都没有:对着芯片反汇编硬啃,祝你好运。
这里有个反直觉的事实值得专门记下:.map 是纯文本,永远可查;.elf 的符号却可能被 strip 掉。产线固件归档时常见做法是保留 .bin + .map 而不留 .elf——几 KB 的文本文件,放十年打开还是那回事。所以”map 是构建副产品、随手删掉”是个要吃亏的认知:它是随固件版本归档的交付物,丢了它,日后所有地址都对不了账。
案件二:我的函数去哪了——垃圾场断案
写了 static void calibrate(void),结果调试时断点打不上、map 里也搜不到。先猜再往下看:它消失在哪个环节? 消失有三个可能的位置,对应三种查法:
- 编译期就没了(
-O2下没人调用的 static 函数,GCC 直接警告并丢弃,.o里根本没有它)——map 的 Discarded input sections 里也找不到,搜了三个区块都无此人是这种”消失”的指纹; 链接期被 gc 裁了(
-ffunction-sections -fdata-sections+-Wl,--gc-sections,见链接脚本篇的攻防一节)——去 Discarded input sections,它躺在那:1 2 3 4
Discarded input sections .text.calibrate 0x08000000 0x4c sensor.o- 被内联了(函数还在,但不再以独立符号存在)——主体表里找不到它,但调用它的函数”胖”了,
addr2line -i还能顺着内联链找到它。
同一个”搜不到”,三种死因,map(配合一条 grep)就能区分前两种。这是 Discarded input sections 表的价值:--gc-sections 不是黑箱裁剪,每一刀都留了案底。
案件三:printf 为什么这么贵——进货台账断案
新手最震惊的一课:一个 hello world 编出来 8K+。账单就在第一张表:
1
2
3
4
5
6
7
8
9
Archive member included to satisfy reference by file (symbol)
libc_nano.a(libc_a-printf.o)
referenced by main.o(.text.main)
libc_nano.a(libc_a-vfprintf.o)
referenced by libc_nano.a(libc_a-printf.o)
libc_nano.a(libc_a-mallocr.o)
referenced by libc_nano.a(libc_a-vfprintf.o)
...
这张表是拉动链条:左边是”被拉进来的库成员”,右边是”谁引用了它、引的是哪个符号”。顺着读能看到多米诺骨牌怎么倒的——main 引用 printf,printf 拉进 vfprintf,vfprintf 又拉进 mallocr……一行 printf 在链接器眼里是一整条引用链。反过来用更有价值:裁剪时盯着这张表看哪些链可以断——换成极简的 iprintf(不拉 mallocr,但也不支持 %f)、重写 _write 走自己的 UART 输出,台账会立刻变短,省下的 Flash 都能在这张表上对账。
案件四:两版固件之间胖了 4K——对账三招
map 是文本文件的第二个好处在这显现:它能 diff。上一版归档了 old.map,这一版 new.map,diff 一下,哪段变大了、哪些新符号出现了、哪些库成员新被拉进来,一目了然——”这版为什么大了 4K”从回忆题变成算术题。
第三招是算栈余量。map 主体里找两个门牌号:.bss 结尾(或 ST 脚本里的 _end)和 _estack。比如 _end = 0x20001200、_estack = 0x20020000,中间的 0xEE00(约 59K)就是留给堆和栈的全部空间。注意这是”剩余预算”,不是”已用栈深”——实际用掉多少得靠启动文件篇提过的水印法在运行时量。链接期算预算,运行期算用量,两个数都要有。
顺带一提,map 还是验证 .ld 改动的最快手段。把 _estack 挪一挪、加个自定义段,重新链接后不用上板,grep 一下新值就知道脚本是否按预期生效——比烧录验证快两个数量级。
到这里四张表都会用了。但退一步看还有两个”设计层”的问题:.elf 里明明什么都有(nm、objdump 都能问它),为什么还要单独生成一份 map?这个上万行的文本文件,凭什么值得归档十年?
L3:想得透——为什么 ELF 之外还要一份 map
到这一层,问题变成:换个别的设计行不行?
本质:.map 到底是什么
去掉所有术语,.map 是链接器交房时的竣工报告:芯片里实际住了谁、每人住哪、多大,外加一份拆迁和进货记录。
和它对照,.ld 是设计图(”打算这么分”),.elf 是精装房(给烧录器和调试器住的机器格式)。三者的分工可以用一个测试题检验:“程序最终有多大”问 .elf,”程序打算多大”问 .ld,”谁把程序撑大的、又裁掉了谁”只有 map 能答。
这里藏着 map 存在的真正理由:它记录的不只是结果,还有过程。nm 能列出最终符号表,但——被 gc 裁掉的段在 ELF 里根本不存在;哪些库成员被拉进来、被谁引用,ELF 也不存这份台账;链接器实际采用了哪份脚本的哪个版本,ELF 更是无从谈起。ELF 只存世界现在的样子,map 存这个世界是怎么变成这样的。前两节的四个案子(尤其是案件二、三),换成任何 ELF 工具都破不了——不是 map 比 nm 强,是它记录的是另一种信息。
为什么是纯文本
第二个设计选择:这么重要的报告,为什么不做成交互式数据库,而是一个 grep 都嫌原始的文本文件?
因为这个格式的第一批用户是 1960 年代的程序员。链接器的祖先叫 linkage editor(IBM OS/360 时代的名字),那时它输出的地址对照表就印在纸上,程序员拿手指顺着十六进制列往下找——你现在用 grep 查 0x08001c58,和穿高领毛衣的前辈用钢笔在纸上划线,做的是同一件事。六十年的演化没有淘汰”文本 + 人眼 + 逐行对号”这个形态,因为它恰好对齐了真实需求:查账是偶尔为之的、跨工具的、要归档的——grep、diff、贴进工单、塞进 git、十年后 less 打开,纯文本在每一环都免维护。换成二进制数据库,任何一个环节都得先找一个能读它的工具。
回头看那个凌晨的案子,现在能看清全貌了:能救场的不是什么高端工具链,是一个几 KB、六十岁了、谁都能读的文本文件——外加一个当年记得把它归档的人。
动手环节:亲手摸到这些机制
光看不算数,来两件事。两个实验都基于一个能构建的 STM32 工程(GPIO 篇的裸机工程即可),构建参数带上 -ffunction-sections -fdata-sections -Wl,--gc-sections -Wl,-Map=firmware.map。
实验一:制造一次”消失”,看 map 怎么记录它。 在某个 .c 里加一个没人调用的函数:
1
static void calibrate(void) { for (volatile int i = 0; i < 1000; i++) {} }
先猜后验,分两问。第一问:-O2 下构建,grep calibrate firmware.map,能在哪个区块找到它?(提示:GCC 对”定义了却没人用”的 static 函数有专门的处罚。)第二问:降到 -O0 再构建,同样的 grep,这次它出现在哪?(提示:-O0 下编译器不删,但 --gc-sections 还在岗。)
预期答案:-O2 下三个区块都搜不到——编译期就没了,map 无案可记;-O0 下它出现在 Discarded input sections——这次轮到链接期的清洁工动手。同一个”搜不到”,两种死因,一行 grep 分清。这个手感值钱:下次断点打不上时,你立刻知道该怀疑编译器还是链接器。
实验二:让 printf 吃掉十几 K Flash,然后看着账单抓凶手。 确认工程里有 printf 调用(没有就加一句 printf("%d\n", 42);),构建一次,记下 arm-none-eabi-size firmware.elf 的 text,并归档这份 map 为 before.map。然后把打印改成浮点:
1
printf("%.2f\n", 3.14); /* newlib-nano 默认不支持 %f,需要 -u _printf_float */
链接参数再加 -u _printf_float(这是 newlib-nano 的开关:默认版 printf 为了省体积根本不带浮点),重新构建。
先猜:text 会涨多少——500 字节?2K?
猜完对账:arm-none-eabi-size 看涨幅(通常以十 K 计),然后 diff before.map firmware.map——你会看到 Archive member included 表里多出一串 libc_a-dtoa.o、libc_a-mprec.o 之类的新成员,每个十几行的拉动链。这就是案件三的实景:一行代码如何沿着引用链一路进货。顺便,-u _printf_float 这个”加一个符号就胖十 K”的开关,是 STM32 面试的经典题——现在你手里有账单了。
自测三题,能答出来说明真懂了:一是产线只归档了 .bin 和 .map(没有 .elf),HardFault 抓到 PC 值后 map 能把范围缩到多小?(想想 map 的最小条目是什么,答案在案件一。)二是 -O0 构建下”没人调用的 static 函数”和 -O2 构建下相比,map 里的记录差在哪?(两道工序谁在哪个环节动手,实验一。)三是同样查”程序里有哪些符号”,nm firmware.elf 就能做到,map 的不可替代性在哪?(ELF 存结果还是存过程,L3 第一节。)
收尾地图
把整篇收进一张速查表——出问题时翻 map 的哪张表:
| 症状 / 问题 | 去哪查 | 一句话理由 |
|---|---|---|
| 有个地址要翻案成函数名 | 主体表,找 ≤ 地址的最大符号 | 精度到函数;要行号得找带 -g 的 .elf |
| 两版固件之间胖了 N K | diff 两份 map | 文本格式天生可 diff |
| 断点打不上 / 函数搜不到 | Discarded input sections | 先分清编译期消失还是链接期被裁 |
| 库函数为什么被链进来 | Archive member included | 顺着拉动链找引用源头 |
| 栈还有多少预算 | _end 与 _estack 之差 | 链接期算预算,运行期水印法算用量 |
改了 .ld 想验证生效 | Memory Configuration + 符号新值 | 比”烧上去试试”快两个数量级 |
.data 的初值到底存哪 | 主体表的 load address 小字 | 一张房契两个地址 |
再留两张”知道就好、用到再来”的小抄:一是把 map 和 bin 一起归档进版本库或发布包(一个 tag 对一份 map),这是本篇一切断案的前提,成本是每个版本几 KB;二是 CI 里可以加一步 grep -c 'Discarded' firmware.map 之类的简单断言盯住体积异动,胖过阈值就让构建变红——把链接脚本篇“把错误往左移”的哲学再往左挪一格。
下一站,两条路任选。往断案的深处走:把本篇的原料(HardFault 压栈帧 + 归档的 map/elf)组装成完整的离线 coredump 工具链——自动符号化、自动定位行号,产线甩来一个十六进制数的夜里,你回一句函数名就行。往布局的深处走:bootloader 的双槽设计——那是要同时管两张竣工图的棋局,本篇的 diff、对账、查余量三招,到时候全是日常操作。查账的手感练出来了,这块板子上的每一个字节的来龙去脉,才算真正有了档案。