ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

嵌入式开发必知:Keil MDK Map文件从入门到实战

嵌入式开发必知:Keil MDK Map文件从入门到实战 1. 为什么要看懂map文件三个最常见的真实场景搞嵌入式开发特别是一直用MDK Keil的朋友肯定都遇见过这类场景程序写完点了编译Output窗口冒出一行“Program Size: Code12345 RO-data678 RW-data90 ZI-data1234”你瞟了一眼觉得差不多烧进去也能跑就没当回事。直到某天芯片Flash只剩4KB而你加了一个新功能后编译直接报错提示超出芯片容量或者程序莫名其妙跑飞你想确认某个函数到底编译到了什么地址却对着工程文件不知道从哪看起。这个时候你才真正意识到天天见面的那几行编译输出其实藏着太多信息而它背后的map文件才是真正的“底账”。map文件其实是链接器在编译链接完成后输出的一份清单它记录了整个工程的所有函数、变量、常量最终被放在哪个地址占了多大空间段是怎么排布的甚至库函数调用了哪些外部依赖。很多朋友用了好几年Keil却几乎没有完整打开过一份map文件原因很简单文件太大、内容太乱、不知道看哪里。我最初也这样直到有次排查一个“神秘”RAM溢出问题在map文件里翻到了玄机才意识到这份文件的价值远超想象。这篇博文从实际使用角度出发把三件事讲透怎么配置Keil生成一份“料足”的map文件、怎么从map文件里读出编译后的内存占用明细以及怎么通过map文件和反汇编联动找到任意函数的入口地址。不管你用的是STM32、GD32还是其它ARM Cortex-M内核芯片也不管你是刚入门的新手还是已经写了几年固件的工程师只要还在用MDK Keil这份经验就能直接拿来用。2. 编译前必须做的设置让map文件一次到位很多人找不到map文件或者打开后只有薄薄两页内容看一眼就没了原因基本都一样工程默认的Listing选项没有把该勾的项勾上。 Keil的map文件生成并不是默认全开的它分成好几个信息块只有在你勾选了对应选项后链接器才会把对应内容写进去。2.1 Listing选项卡的完整配置方法打开Options for Target快捷键AltF7切到Listing选项卡这里就是map文件的“开关集中营”。左侧有一个“Ac出 Listing”相关区域具体分两栏左边是C Compiler的Listing右边是Linker的Listing。我们要的是右边Linker这一栏。默认情况下Keil只勾选了Image Size和Object Symbol Table两个不太关键的选项而真正有用的Memory Map、Call graph、Cross reference这些默认是不全开的。我的习惯是一律全勾不要犹豫。具体来说Image Size生成镜像大小信息这个默认就有保留。Object Symbol Table对象符号表保留。Memory Map输出内存映射表这个非常核心必须勾选没它你根本看不到函数变量具体在哪个地址。Call graph输出函数调用图勾上后map文件里会多一个Cross Reference段落方便排查函数相互调用关系建议打开。Cross reference交叉引用信息能列出每个符号被哪些文件引用了在改代码、删模块时特别有用建议打开。设置完这些点击OK退出然后重新编译整个工程。注意是重新编译最好先Rebuild一下因为map文件是链接阶段的产物只编译增量文件可能不会刷新map。别忘了如果你改了Linker选项卡里的某些内存布局配置比如分散加载文件也要确认map文件更新过。Keil的map文件在每次链接成功后会覆盖写入但如果你看的是旧文件可能就是你上一次成功编译的结果。2.2 map文件的位置与打开方式编译完成后map文件默认生成在工程目录下的Listings子文件夹里文件名与工程名一致后缀是.map。 比如你的工程叫uart_bootloader那文件就是uart_bootloader.map。我可以负责任地说找map文件这个动作就劝退了不少人因为很多人习惯用Keil自带的工程树根本不看工程目录结构。打开方式推荐用Notepad、VS Code或者任何支持大文件快速滚动的文本编辑器。不建议用Windows自带的记事本直接打开大map文件文件如果超过几十MB记事本扛不住滚动都卡。我自己的习惯是先用VS Code打开利用它的搜索功能定位关键行效率比肉眼翻页高很多。还有一种更快的路径在Keil的Output窗口编译完成后那一行Program Size信息下方的链接命令其实包含了一个中间文件路径但不用管它直接用我上面说的目录找就行。如果你改了输出目录文件位置会跟着变但一般默认在Listings。2.3 为什么不用默认map文件就“不完整”有朋友可能会问我没改任何设置编译后也有map文件啊而且也能看地址为啥非要全勾道理很简单默认状态下的map文件只有镜像大小和各目标文件的符号表摘要缺少Memory Map和Cross reference这些明细。你只能看到“有这个函数”但看不到它具体放在了哪个可执行区域、紧接着哪个变量、区域内部怎么对齐而这些信息恰恰是分析内存占用和函数地址时最关键的。就好比你要盘库一份清单只写了“仓库里有100箱货”另一份写着“100箱货A区30箱B区70箱每箱编号从L01到L100”。后者才是你真正需要的。全勾之后map文件会膨胀文件变大有心理准备但对分析问题来说信息越全越好。等到后面讲函数入口地址查找时你会发现如果没开Memory Map有些定位手段根本玩不起来。3. map文件正文到底写了什么从头翻到尾的阅读指南很多人第一次打开map文件看到一堆分段标题像什么“”分隔线脑子嗡的一下就大了。其实map文件结构是固定的只要知道每块是什么阅读就变成了“按图索骥”。 下面按顺序拆一遍。3.1 文件头部编译时间、工具链与目标类型map文件最开头一般有工程名、链接日期时间、编译器版本、操作系统信息接着是分配的加载区域Load Region和执行区域Execution Region概要。这一段的价值在于确认你手里的map文件是不是当前工程的尤其是你打开了一个旧map文件而心里没底的时候先看时间。接下来会有一个“ *** Target ***”之类的块列出芯片型号、内部Flash和RAM的起始地址与容量。比如STM32F103C8T6Flash是0x08000000开始、容量64KBRAM是0x20000000开始、容量20KB。这块对应的是你在Target选项卡里配置的芯片型号和内存范围。如果这里显示的内存跟你手里的芯片对不上基本就是选错型号或者改了分散加载文件导致的赶紧回头查配置。3.2 Image Symbol Table函数和变量的地址地图这一块是重头戏也是很多人找函数入口地址的第一站。它会把工程里的所有全局符号按类别列出来每一行包含符号名、值也就是地址、所属段、类型、对象文件。值那一列就是关键——函数的入口地址、全局变量的地址就在这里。举个例子你搜“main”会看到类似main 0x08000231 Code RO main.o这句的意思就是main函数被编译到0x08000231保存在Code段来自main.o。要注意0x08000231这个地址它不是一个4字节对齐的地址最低位是1这是ARM Cortex-M的Thumb指令特性表示这个地址对应Thumb状态。实际取指令时CPU会忽略最低位所以真实入口是0x08000230。这个细节在后面看反汇编时特别重要不然你会产生“地址怎么不对齐”的困惑。除了函数全局变量也会列在这里类型可能是Data、Zero等对应RO、RW、ZI区域。比如一个全局数组uint8_t buffer[256]会显示在Data段地址在RAM区大小256字节一眼就能定位它在RAM里的具体落脚点。3.3 Memory Map of the image可执行区段的内存排布明细这个段落会把每个执行区域Execution Region包含的所有输入段按地址顺序列出来从加载地址到运行地址都有。每行大概是Load Region LR_IROM1 (Base: 0x08000000, Size: 0x00001234, Max: 0x00010000, ABSOLUTE) Execution Region ER_IROM1 (Exec base: 0x08000000, Load base: 0x08000000, Size: 0x00001234, Max: 0x00010000, ABSOLUTE)后面跟着一堆对象文件里的具体段比如startup_stm32f103xe.o (RESET, First)system_stm32f103xe.o ( .data, First) 等等。这段的阅读价值在于你能清晰看到哪些东西被塞进了Flash的哪些区域RAM里的RW数据段、ZI数据段分别从哪里开始、到哪里结束。如果代码里用了分散加载文件这里会分成多个加载区域每个加载区域的内容独立排列排查“数据被放到了奇怪地址”的问题时这里就是依据。3.4 Regional and Section Limits区域占用统计汇总在Memory Map之后编译器会列出一个粗体标题为“Regional and Section Limits”的段落把每个执行区域的最终统计列出来。比如“ER_IROM1”区域的总大小、已用大小、剩余空间RW区域、ZI区域各自占了多少还有“Maximum Stack Usage”之类的栈使用信息如果勾选了相关选项。这一段是估算Flash和RAM占用最直观的地方但很多刚接触map的人反而忽略它更愿意盯着编译输出窗口那几行数字。其实编译窗口只给了Code、RO-data、RW-data、ZI-data四个汇总值而map文件这一段会细化到各个执行区域还能看出每个区域有没有超限的风险。3.5 Cross Reference谁被谁调用的关系网如果勾选了交叉引用map文件尾部会有“Cross Reference”段落列出每个符号被哪些地方引用了。格式类似于SomeFunction 0x08000345 SomeModule:SomeLine意思是SomeFunction在SomeModule的某一行被引用。这个不怎么用来算内存但在重构代码、删减废弃函数时很省事能快速找出“这个函数到底还有没有人在用”避免误删。4. 算清内存账Flash和RAM的占用拆分定位嫌疑对象聊到内存占用很多人停留在“编译窗口那四个数字”的层面。 CodeRO-data是烧进Flash的部分RW-data和ZI-data是运行时RAM占用的大头这个说法没错但到实际调优时你需要的是更细的粒度到底是哪个.c文件里的哪个数组吃掉了RAM 是不是某个库函数把Flash塞爆了这时候map文件就是唯一的线索源。4.1 Flash总占用怎么精确统计先复习一下基本公式Flash占用 Code RO-data。Code是机器码RO-data是只读常量比如const数组、字符串字面量。在map文件的“Image Symbol Table”和“Memory Map”里你能看到RO段的详细排布。如果想知道每个源文件各占多少Flash看“Image Symbol Table”这个块是不够的因为它是按符号列不是按文件聚合。更好的办法是看“Memory Map of the image”中的执行区域展开部分或者借助“**Regional and Section Limits”段落里的输入段聚合信息。文本段.text会列出每个.o文件的代码尺寸比如.text main.o 0x00000124 ... .text uart.o 0x00000256 ...这样就清楚“哪个模块是Flash大户”了。单文件代码量异常大优先怀疑这个文件里有没有误用大数组、内联函数封装过度、或者模板展开过狠。另一个实用方法直接用工具链的尺寸报告。在Keil安装目录的ARM/ARMCC/bin或ARM/ARMCLANG/bin下有fromelf.exe命令行执行fromelf.exe -z -v 你的axf文件路径可以输出每个段占用的详细尺寸信息粒度比map文件更细还能按段归并非常推荐。4.2 RAM占用怎么拆到变量级别RAM占用主要看RW-data初值非0的全局变量和ZI-data初值为0的全局变量、栈、堆。编译窗口只告诉你总大小但map文件的“Image Symbol Table”里每个带地址的Data/Zero符号都单独列出来了。你可以搜索自己怀疑的大数组直接看到它的起始地址和占用空间。比如你觉得某个协议缓冲区太大了搜索它的名字会看到类似protocol_rx_buf 0x200001A0 Data 1024 protocol.o这说明这个数组从0x200001A0开始占了1024字节。如果你的RAM总大小是20KB光这一个数组就占了1/20心里马上有数。更妙的是你能通过地址范围判断这个数组是不是跟相邻变量“靠得太近”从而排查越界隐患。ZI数据还包含栈空间。在startup文件里Stack_Size和Heap_Size定义多少ZI区就相应增加多少。如果map文件显示ZI-data总量很大先别急着怀疑全局变量先算一下栈和堆占的比重。很多“RAM不够用”的假象其实就是启动文件里栈尺寸配得太大实际根本用不到那么多。4.3 结合分散加载文件看区域利用率如果工程用了分散加载文件.sct那就不能只看默认的ER_IROM1/ER_IROM2了。map文件会按分散加载描述符列出多个执行区域比如把外部Flash映射成XIP区、把关键数据放到DTCM等场景。这种情况下每个区域在“Regional and Section Limits”里都有单独的Size和Max。你可以在链接脚本里给每个区域设置最大容量比如ER_IROM1 0x08000000 0x00010000 { ; 64KB *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) }如果链接时某个区域超了Keil会直接报错并提示“region ER_IROM1 overflowed by xxx bytes”这个数字就是超出的字节数。但如果你想提前预估就得在map文件里看已用Size和Max的对比。4.4 一个实际排查案例谁偷走了2KB RAM有一次我做一个小产品芯片RAM只有8KB功能加得多了编译后发现RAM占用已经逼近极限。但看常量定义我觉得没开那么多大数组怎么RAM就快满了打开map文件在符号表里按地址排序逐个看RAM区符号很快就发现一个奇怪的512字节数组来自一个蓝牙协议栈库那是我早期为了试功能拉进来的后来主逻辑没用到但编译脚本一直没删。这个数组在代码里根本没被引用但因为它被声明成全局的链接器还把它留在了镜像里。删掉这个依赖后RAM瞬间少了512字节。这种问题如果只看编译窗口那四个数字根本定位不到只有map文件能直接把“占用地址-符号名-对象文件”三要素摆到你面前。所以我的习惯是只要工程RAM快满了就打开map文件按ZI-data和RW-data区域把符号表拉一遍挨个审视保准能找到惊喜。5. 找函数入口地址Map文件 反汇编联动实操函数入口地址这个需求最常见于以下场景用调试器定位某个函数是否被正确链接到了预期Flash区域、做固件升级时检查烧录地址是否和链接地址一致、或者解析崩溃现场时PC指针停在某个地址需要反查是哪个函数。map文件能完成“地址到函数名”的映射而配合反汇编还能直接看到入口处的机器码。5.1 从map文件里直接找函数地址最直接的方法用文本编辑器打开map文件搜索函数名。例如一个函数叫BSP_Uart_Init在“Image Symbol Table”区域搜到BSP_Uart_Init 0x08000A2C Code RO bsp_uart.o那入口地址就是0x08000A2C。拿到这个地址你可以直接在调试器的Memory窗口输入这个地址看这里的机器码或者在反汇编窗口输入这个地址跳过去看汇编指令。这里有个容易踩的坑是Thumb位。Cortex-M内核是Thumb指令集函数地址最低位通常为1表示Thumb模式。比如你搜一个函数得到0x08000A2D不要怀疑“这个地址怎么是奇数”它就是0x08000A2C的真正入口加上Thumb标志位。调试器一般会自动处理这个位但如果你用第三方工具或者自己写上位机解析符号记得把这个位清掉。 另外有些函数因为被声明为static且未被引用链接器优化后根本没有生成符号map里可能搜不到这是正常的。5.2 用fromelf导出反汇编地址和指令一次看全map文件只给你地址但如果你想看这个函数入口处的指令长什么样就得靠反汇编。Keil自带的fromelf工具能直接从axf文件里生成反汇编文本比在调试器里截图更高效fromelf.exe --text -c -d -o output.txt 工程.axf参数说明--text表示输出文本格式-c输出反汇编代码包含地址、机器码和指令-d输出数据段内容生成的output.txt就是一份带地址的完整反汇编清单。你可以在里面搜索BSP_Uart_Init会看到0x08000A2C: b510 PUSH {r4, lr} 0x08000A2E: 4604 MOV r4, r0这样一来函数入口处的第一条指令是什么都清清楚楚。很多人用调试器看反汇编但调试器只能在运行到断点时看当前函数的上下文用fromelf生成的静态反汇编能让你在没接开发板的情况下离线分析整个固件排查启动流程、分析崩溃PC指针非常方便。5.3 调试器里如何快速定位到map给出的地址有时候你已经拿到map里的函数地址接下来想在Keil的调试界面里直接看这个函数方法也很简单进入Debug模式后按快捷键“CtrlT”打开Disassembly窗口在地址栏输入0x08000A2C回车就跳过去了。 如果你的工程加载了符号文件也可以直接在“Find in Disassembly”搜索函数名效果一样。如果是看变量地址就在Watch窗口或Memory窗口输入地址比如全局变量RxBuffer的地址是0x200001A4在Memory窗口地址栏填0x200001A4回车就能实时看到这块内存的数据调试通信协议时非常好用。5.4 利用Map文件生成函数调用表和固件符号表map文件不只是给人看的它完全可以当符号表用。有些团队会写脚本自动化解析map文件提取所有函数地址生成自定义的日志系统或者做自动化测试时根据地址调用指定函数。比如用Python写个简单脚本读入map文件中的Image Symbol Table按类型过滤出Code段符号输出成CSVimport re with open(project.map, r, errorsignore) as f: lines f.readlines() symbols [] capture False for line in lines: if Image Symbol Table in line: capture True continue if capture and line.startswith(): break if capture: m re.match(r\s(\S)\s(0x[0-9A-Fa-f])\s(\S)\s(\S)\s(\S), line) if m and m.group(3) Code: symbols.append((m.group(1), int(m.group(2), 16), m.group(5))) for name, addr, obj in symbols: print(f{addr:#010x} {name} {obj})这段脚本会把所有Code段符号的地址提取出来。 真到固件里需要动态解析函数地址、做Patch或者生成调用表时这个脚本就是基础工具。类似的思路也可以扩展到RAM区的变量符号生成内存分布图方便做低内存占用的专项优化。6. 高频问题与排查技巧实录6.1 编译后map文件没有变化或不生成最常见的原因是勾选了Listing选项后没有做全量Rebuild。链接器只有在链接阶段才会更新map文件如果你只编译了单个文件链接环节可能都跳过了map文件自然不变。 解决方法是点一下“Rebuild”按钮强制全量编译链接。另一个原因是工程输出路径被修改过map文件生成到了非默认目录。可以在Options for Target的Listing选项卡最下方看到“Listing File Path”文本框上面写了map文件的完整输出路径直接从这里复制路径去找高效准确。6.2 搜索函数名却搜不到任何记录如果map文件里确实有函数名但搜索不到可能的原因有几个。 第一函数被优化掉了比如一个static函数定义了但没被调用编译器直接删掉map里自然没有。第二函数内联了C编译器把短函数内联到调用处符号表里只剩被内联后的调用位置。第三函数名被C的name mangling改写了此时搜原始函数名可能搜不到要搜修饰后的名字。排查技巧先确认编译优化等级O0优化下几乎所有函数都会保留O2优化下短函数大概率被内联。如果实在纠结某个函数是否存在用fromelf反汇编出来搜索是更保险的办法。6.3 程序里看到的函数地址和map文件不一致这种“不一致”多半是Thumb标志位导致的。你用调试器查看某个函数时断点地址显示的是不含Thumb位的真实地址而map文件显示的是最低位为1的Thumb标志地址两者相差1。 这不是错误只需要理解这个机制即可。另外如果芯片支持VTOR向量表偏移或者你用了BootloaderApp架构App里的函数链接地址和实际运行地址可能被分散加载文件重新映射过。这时候map文件里看到的是链接地址实际运行地址取决于加载器怎么搬移数据调试时要根据Cortex-M内核的分区规则换算一下。6.4 map文件很大打开卡顿怎么处理工程模块多、开启了宏量交叉引用之后map文件十几MB甚至几十MB都很正常。建议用支持大文件的编辑器打开比如VS Code、Notepad。如果只是想快速看某个符号直接在编辑器里CtrlF搜索不要整篇滚动。还有一个效率很高的技巧用grep类命令行工具直接过滤Windows下可以用PowerShell的Select-String或者直接安装一个轻量的grep工具。比如要找出所有Code符号执行Select-String -Path project.map -Pattern Code\sRO一次就能把所有Code段符号行过滤出来比编辑器搜索还快。6.5 想要把“最大栈使用”也统计出来除了看函数和变量嵌入式开发中栈的使用情况也很要命。Keil的map文件如果开启了“Stack Usage”统计会在“Regional and Section Limits”之后出现“Maximum Stack Usage”相关段落列出每个函数的栈占用以及调用路径的累计栈开销注意Keil对递归函数的栈分析是无能为力的递归函数不会给出精确的最大栈深。如果map文件里没有这一块检查Listing选项卡中Linker的“Stack Usage”是否勾选。另外ARMC编译器的堆栈分析是静态的不会考虑中断嵌套和动态开辟的栈空间所以在实际项目里建议在静态分析结果基础上加足够的余量。6.6 链接报错“region overflowed”时怎么从map里找元凶这是最让人头疼的报错之一。报错信息会告诉你哪个区域溢出、溢出多少字节比如“ER_IROM1 overflowed by 4096 bytes”。 此时打开map文件看“Regional and Section Limits”里对应区域的Size和Max差值就能确认情况。然后去“Memory Map of the image”看这个区域里哪些输入段占了最大比例。通常Flash溢出找大头都是看哪个.o文件的.text段大RAM溢出就看RW和ZI数据里哪个符号大。 结合我之前说的方法把符号表按占用空间排序快速列出Top 10基本一眼就能锁定嫌疑对象。有时候一个const数组被误放到RW段就会白白吃掉宝贵的RAM这种问题map文件里看得很清楚符号类型是Data而不是Const地址落在RAM区而不是Flash区。6.7 保存map文件与git compare的联动习惯最后分享一个我养成的习惯每个重要版本的编译产物我都会把map文件连同axf、hex一起归档。 这样一旦后续出现“这版没问题那版出问题”的对比需求可以直接用文本比对工具对比两个版本的map文件快速看出函数地址变化、模块增删、内存占用变化。特别是在排查链接器优化导致的诡异问题时这种对比比重新编译一个旧代码分支快得多。我个人在实际操作中的体会是map文件不是用来“读”的而是用来“查”的。 它就像产品出厂时的装箱单平时堆在角落积灰但一旦你要搞清楚“哪个零件占了地方、这个零件放在哪个货架”这份清单就是唯一的权威依据。别怕它大也别嫌它乱把上面这几个段落看懂配合fromelf和文本工具你就能在几十秒内从这个“大块头”里挖出想要的信息。
返回列表