文章

精读 .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 ConfigurationFlash/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 里也搜不到。先猜再往下看:它消失在哪个环节? 消失有三个可能的位置,对应三种查法:

  1. 编译期就没了(-O2 下没人调用的 static 函数,GCC 直接警告并丢弃,.o 里根本没有它)——map 的 Discarded input sections 里也找不到,搜了三个区块都无此人是这种”消失”的指纹;
  2. 链接期被 gc 裁了(-ffunction-sections -fdata-sections + -Wl,--gc-sections,见链接脚本篇的攻防一节)——去 Discarded input sections,它躺在那:

    1
    2
    3
    4
    
    Discarded input sections
    
     .text.calibrate
                    0x08000000       0x4c sensor.o
    
  3. 被内联了(函数还在,但不再以独立符号存在)——主体表里找不到它,但调用它的函数”胖”了,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 Kdiff 两份 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、对账、查余量三招,到时候全是日常操作。查账的手感练出来了,这块板子上的每一个字节的来龙去脉,才算真正有了档案。

本文由作者按照 CC BY 4.0 进行授权