做嵌入式开发的朋友,大概率都遇到过这样的场景:编译完一个工程,输出目录里一堆文件,.axf、.elf、.bin、.hex、.sct、.ld…… 后缀五花八门,看着就头大。
很多人只知道 “这个是用来烧录的,那个是用来调试的”,但到底它们是怎么来的?彼此之间有什么关系?为什么 Keil 和 GCC 生成的文件后缀还不一样?
今天我们就从嵌入式软件的完整编译流程出发,把这些文件的来龙去脉一次性讲清楚,看完你再也不会搞混这些后缀了。
先看总览:从代码到运行的完整流程
不管你用的是 Keil MDK,还是 GCC ARM 工具链,整个从写代码到芯片运行的逻辑完全一致,区别只是不同工具链对产物的命名不一样。
接下来我们就顺着这个流程,一步步拆解每个阶段的文件到底是什么。
第一步:编译阶段,生成.o目标文件
一切的起点,是我们自己写的代码:.c源文件和.h头文件,这部分大家都很熟悉,就不多说了。
编译器(比如 Keil 的armcc,或者 GCC 的arm-none-eabi-gcc)会把每个.c文件单独进行编译,把 C 代码转换成处理器能看懂的机器码,最终生成一个对应的.o文件,也就是目标文件(Object File)。
这里要注意,这个阶段的编译是 “单文件” 的:每个.c文件只关心自己的代码,不管其他文件的函数、变量在哪里。所以生成的.o文件里,所有的地址都是相对地址,函数调用、变量访问都还没有绑定到最终的绝对内存地址。
简单来说,.o文件就是一个 “半成品”,它只包含了当前这个源文件的机器码,还需要下一步的链接操作,把所有的半成品拼起来,分配最终的地址。
第二步:链接的 “导航图”——.sctvs.ld链接脚本
有了一堆.o半成品,接下来就要进入链接阶段了。链接器要做的事情,就是把所有的.o文件、还有用到的库文件整合到一起,给所有的函数、变量分配最终的绝对地址,把零散的模块拼成一个完整的程序。
但问题来了:链接器怎么知道,哪些代码要放到 Flash 里?哪些数据要放到 RAM 里?Flash 的起始地址是多少?RAM 有多大?有些要搬到 RAM 里运行的代码,要怎么处理?
这就是链接脚本的作用了!它就是给链接器看的 “导航图”,告诉链接器整个芯片的内存布局,以及各个代码段、数据段要放到哪个地址。
而这里,Keil 和 GCC 就出现了第一个命名差异:
Keil MDK 用的是
.sct文件:全称是 Scatter-Loading Description File,也就是分散加载描述文件。GCC ARM 用的是
.ld文件:全称是 Linker Script,也就是链接器脚本。
它们的核心功能完全一致,都是用来定义内存布局、指导链接器工作的,只是语法和表述方式不一样:
.sct文件用Load Region(加载域,也就是数据烧录到 Flash 的地址)和Execution Region(执行域,也就是程序运行时的地址)的概念,把加载地址和运行地址明确分离开,比如你要把一部分代码从 Flash 加载到 RAM 里运行,在 sct 里可以很清晰地定义出来。
.ld文件的语法更偏向声明式,类似 C 语言的风格,先定义MEMORY区域(比如 Flash 和 RAM 的起始地址和大小),然后在SECTIONS里把各个段(.text代码段、.data数据段等)分配到对应的内存区域里。
举个最简单的例子,STM32F103 的默认链接配置:
在 sct 里,你会看到类似这样的定义:
LR_IROM1 0x08000000 0x00080000 { ; 加载域,Flash的起始地址和大小 ER_IROM1 0x08000000 0x00080000 { ; 执行域,代码运行在Flash *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { ; 数据运行在RAM .ANY (+RW +ZI) } }而在 ld 文件里,对应的定义是这样的:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 64K } SECTIONS { .text : { *(.text) /* 其他代码段 */ } > FLASH .data : { *(.data) /* 其他数据段 */ } > RAM AT > FLASH }不管语法怎么变,核心都是一件事:告诉链接器,内存怎么分,代码放哪里,数据放哪里。没有这个文件,链接器根本不知道怎么把零散的.o文件拼成一个能在芯片上运行的程序。
第三步:完整版可执行文件 ——.axfvs.elf
有了链接脚本的指导,链接器就可以开始工作了,把所有的.o文件整合起来,分配好地址,最终生成一个完整的可执行文件。
这里,Keil 和 GCC 又出现了第二个命名差异:
Keil MDK 生成的是
.axf文件:全称是 ARM eXecutable Format,ARM 可执行格式。GCC ARM 生成的是
.elf文件:全称是 Executable and Linkable Format,可执行链接格式。
很多人会问,这两个有什么区别?其实答案很简单:.axf本质上就是.elf格式的 ARM 扩展!
它们的底层都是标准的 ELF 格式,只是.axf在标准 ELF 的基础上,额外增加了一些 ARM 特有的调试信息、重定位信息,用来适配 Keil 的调试工具链。
这两个文件有个共同的特点:它们非常 “完整”,甚至有点 “臃肿”。
它们里面除了真正要运行的机器码(代码段、初始化数据段)之外,还包含了:
完整的符号表:所有函数、变量的名字和地址
源码行号映射:把机器码的地址对应到源代码的行号
调试信息:变量的类型、函数的调用关系等等开发阶段需要的信息
所以这两个文件的体积通常都很大:比如一个小的 STM32 工程,最终的烧录固件可能只有 10KB,但.axf或者.elf文件可能有几百 KB,多出来的大部分都是调试信息。
那它们的用途是什么?它们是给调试用的!
比如你在 Keil 里点击 Debug,下载到芯片里的就是这个.axf文件;你用 J-Link 的 J-Scope 监控变量,也需要加载这个文件。只有有了这些调试信息,你才能在 IDE 里看到源代码、下断点、单步执行、查看变量的值 —— 如果没有这些信息,你只能看到一堆二进制的机器码,根本没法调试。
但是,这些调试信息只有开发阶段才有用,量产烧录的时候,我们根本不需要这些东西,它们只会浪费 Flash 的空间。所以我们还需要下一步,把这些多余的信息去掉,生成精简的烧录固件。
第四步:烧录用的精简固件 ——.binvs.hex
为了得到可以烧录到芯片里的精简固件,我们会用工具(Keil 的fromelf,或者 GCC 的objcopy)把.axf/.elf里的调试信息、符号表这些没用的东西全部去掉,只保留真正要烧录到 Flash 里的机器码和初始化数据。
最终生成的,就是我们最常用的两种烧录文件:.bin和.hex。
这两个文件的区别,很多人一直搞不清,其实一句话就能说清楚:一个是纯二进制,一个是带地址信息的文本格式。
.bin:纯二进制固件
.bin是最纯粹的二进制文件,它把所有要烧录的有效数据,按地址从小到大的顺序,直接排列起来,没有任何额外的信息。
它的优点很明显:体积最小,没有任何多余的开销,10KB 的固件就是 10KB 的文件,一点都不浪费。
但是它有个很大的限制:它默认你的固件的所有地址是连续的。
举个例子:如果你的固件所有的代码和数据,都是从0x08000000开始,连续的 10KB 空间,那 bin 文件完全没问题,烧录器把这 10KB 的数据从0x08000000开始写进去就行。
但如果你的固件有非连续的地址呢?比如:
你的 Bootloader 在
0x08000000,App 在0x08008000,还有一部分配置数据在0x08010000,中间空了很多区域。或者你用了分散加载,把一部分数据放到了 Flash 的其他位置。
这时候 bin 文件就处理不了了:因为它是连续的,它会把从最低地址到最高地址之间的所有空间,不管你有没有用到,都打包进去,导致文件变得巨大,而且烧录的时候还会把中间空的区域也擦写,这显然不是我们想要的。
.hex:带地址的通用固件
.hex全称是 Intel HEX 文件,是一种文本格式的固件文件。
它的每一行都是一条记录,里面包含了:这部分数据的起始地址、数据长度、具体的数据,还有校验和。
比如你打开一个 hex 文件,会看到类似这样的内容:
每一行开头的:是标记,然后是长度、地址、类型、数据、校验和。
这种格式的好处是什么?它可以处理非连续的地址!
比如刚才的例子,它可以先写0x08000000开始的 Bootloader,然后跳过中间的空区域,再写0x08008000开始的 App,然后再写0x08010000开始的配置数据。中间的空区域不需要管,也不会占用文件的空间。
而且它自带校验和,烧录器可以自动校验每一行的数据有没有出错,可靠性更高。
当然,它也有缺点:因为是文本格式,每个字节的二进制要转成两个十六进制字符,还要加上地址、校验这些额外的信息,所以它的体积会比 bin 文件大一点,通常会大 30% 左右,但对于现在的存储来说,这点差距完全可以忽略。
所以总结一下:
如果你的固件地址是连续的,用 bin 没问题,体积小一点。
如果你有非连续的地址,或者你不确定,直接用 hex 就对了,兼容性更好,这也是为什么大部分开发工具默认生成 hex 文件的原因。
最后:烧录与运行
拿到了 bin 或者 hex 文件,我们就可以把它烧录到芯片的 Flash 里了。
等芯片上电之后,它会从固定的入口地址(比如 STM32 的0x08000000)开始取指令,我们的程序就正式跑起来了。
到这里,整个从代码到运行的流程就走完了,所有的文件我们也都拆解清楚了。
一张表总结所有文件
最后,我们把所有的文件整理成一
文件后缀 | 对应工具链 | 所属阶段 | 核心作用 |
| 通用 | 源码阶段 | 开发者编写的源代码 |
| 通用 | 编译阶段 | 单个源文件编译后的可重定位目标文件 |
| Keil MDK | 链接配置 | Keil 的分散加载描述文件,定义内存布局 |
| GCC ARM | 链接配置 | GCC 的链接器脚本,定义内存布局 |
| Keil MDK | 链接输出 | Keil 的完整可执行文件,含调试信息,用于调试 |
| GCC ARM | 链接输出 | GCC 的标准可执行文件,含调试信息,用于调试 |
| 通用 | 固件输出 | 纯二进制精简固件,用于连续地址的烧录 |
| 通用 | 固件输出 | Intel HEX 格式固件,支持非连续地址,通用烧录格式 |
常见疑问解答
1. 为什么调试用 axf/elf,烧录用 bin/hex?
因为调试需要符号表、行号映射这些调试信息,才能让你看到源码、下断点、查看变量,这些信息只有 axf/elf 里有。而烧录只需要真正的机器码,调试信息没用,去掉之后体积更小,烧录更快。
2. 为什么 Keil 和 GCC 的文件后缀不一样?
这是历史原因,Keil 用的是 ARM 的早期工具链,所以用了自己的一套命名,比如 axf、sct;而 GCC 用的是 Unix 体系下的标准命名,比如 elf、ld。但它们的核心逻辑是完全一样的,只是工具不同,所以后缀不同。
3. 能不能把 axf/elf 直接烧录到芯片里?
理论上可以,但是完全没必要,因为里面有大量没用的调试信息,会浪费很多 Flash 空间,而且很多烧录器也不支持直接烧录 elf/axf 格式。
看完这些,是不是突然发现,这些乱七八糟的后缀,其实就是编译流程不同阶段的产物而已?搞懂了整个流程,不管是 Keil 还是 GCC,不管是什么后缀,你都能一眼看明白它是干嘛的,再也不会搞混了。