上周同事拿着三个烧录界面过来问我:一个是 STM32 的 Keil 下载配置,IROM 起始写着0x08000000;一个是 ESP32-C3 的flash_args,bootloader 那行写的是0x0;还有一个用了 IAP 升级的老项目,脱机烧录器上只让填一个0x6000。他怀疑是不是有人填错了,或者某个工具在瞎来。其实三个数字都不算错,它们只是站在三个不同的坐标系里说话——烧录地址这件事,搞清楚"原点在哪",比记住某个具体数字重要得多。这篇文章就把这三种常见写法的来历拆开讲,从 Cortex-M 的地址空间划分,到外挂 SPI Flash 的物理偏移,再到 bootloader 预留区算出来的分界线,顺带把填错地址之后板子会表现出什么症状也一并列出来,不管你是刚上手 STM32 的新人,还是天天跟 ESP 系列打交道的老手,都能在里面找到自己遇到过的那个坑。
1. 烧录地址的三个坐标原点:芯片布局、介质偏移、工具约定
先把最容易混淆的地方点破:"烧录地址"从来不是一个绝对概念,它永远依附于某个原点。同一条数据,你说它在 0x08000000,他说它在 0x6000,我说它在 0x10000,很可能三个人说的都是同一块 Flash 上的同一个字节,只是原点选得不一样。理解这一点之后,后面所有看上去"莫名其妙"的地址都会变得顺理成章。
我一般把烧录地址分成三类来理解:芯片地址空间里的绝对地址、外挂存储介质里的相对偏移、以及工具界面上要求你填的约定值。这三类之间可以互相换算,但换算的桥是怎么搭的,很多人从来没想过,所以一旦换芯片、换工具、换工程,立刻就懵。
1.1 绝对地址:芯片手册里画出来的那张 memory map
Cortex-M 系列的内核地址空间是 4GB,ARM 把它切成若干块,主要几块大致是这样:
| 地址区间 | 用途 |
|---|---|
| 0x00000000 - 0x1FFFFFFF | Code 区,放代码和只读数据 |
| 0x20000000 - 0x3FFFFFFF | SRAM 区 |
| 0x40000000 - 0x5FFFFFFF | 外设区 |
| 0x60000000 - 0x9FFFFFFF | 外部 RAM / 外部设备 |
| 0xA0000000 - 0xDFFFFFFF | 外部设备 |
| 0xE0000000 - 0xE00FFFFF | 私有外设区(NVIC、SysTick、SCB 等) |
0x08000000 就是一个绝对地址,它落在 Code 区里面。STM32 把片内 Flash 放在这个位置,所以你打开 STM32F103 的参考手册,Memory mapping 那一节会看到 Flash 从 0x08000000 开始,一直到 0x0807FFFF(512KB 版本)。而 GD32、AT32、MM32、HK32、APM32 这些国产兼容系列,绝大多数也把 Flash 放在 0x08000000,原因很简单:软件生态要兼容,地址一致,链接脚本和烧录配置就不用改。
这里有个细节值得多说一句:**为什么 ST 不干脆把 Flash 放在 0x00000000,非要挪到 0x08000000?**答案是 0x00000000 那块要留着做"重映射/别名"区。Cortex-M 上电后,内核会固定从 0x00000000 取初始 MSP(主栈指针),从 0x00000004 取复位向量。也就是说,不管你从哪块存储器启动,那块存储器的开头必须先出现在 0x00000000 处。如果 Flash 自己就占着 0x00000000,那系统存储器(出厂 bootloader)和 SRAM 启动就没地方放了。把 Flash 放到 0x08000000,启动时再根据 BOOT 引脚把其中一段镜像到 0x00000000,一套硬件地址译码就能支持三种启动源,这是很典型的设计取舍。
1.2 相对偏移:外挂存储介质从 0 开始数
第二类地址的典型代表就是 ESP32 系列的0x0、0x1000、0x8000、0x10000。ESP32 芯片内部没有 Flash,程序存在外面那颗 SPI Flash 芯片里(常见的 W25Q32、W25Q128 之类)。既然是一颗独立的存储芯片,它的地址空间就是从 0 开始的,4MB 的片子地址范围是 0x000000 到 0x3FFFFF,你能填的地址最大也就这么点。
所以对 ESP32 来说,烧录地址 = 数据在这颗外挂 Flash 里的物理偏移,从 0 开始数,跟 CPU 怎么访问它没有直接关系。这也是为什么很多人第一次看 ESP32 的烧录参数会不适应:STM32 那边动不动就是 0x08000000,这边怎么才 0x1000?不是少了几个零,是两套坐标系根本不是一回事。
而 0x6000 这类数字,本质上也是偏移。它很少作为一个"独立存在"的地址出现,绝大多数情况下它是0x08006000 的简写——某些脱机烧录器、串口 ISP 工具、或者自己写的上位机,会要求你填一个"相对 Flash 基址的偏移量",工具内部再自己补上基址。0x08000000 + 0x6000 = 0x08006000,这个位置正好是很多 IAP 工程里 bootloader 和 App 的分界线。
1.3 工具界面上那个空,问的其实是"原点在哪"
真正让人迷惑的地方在于:不同工具对这个输入框的叫法完全不同,但它基本不会告诉你原点是什么。下面这张表是我这些年攒下来的对照,遇到不认识的工具就照着套:
| 工具 / 场景 | 输入框叫法 | 实际原点 |
|---|---|---|
| Keil MDK(Target 选项) | IROM1 Start | 芯片绝对地址空间 |
| IAR(.icf 文件) | ROM region 起始地址 | 芯片绝对地址空间 |
| STM32CubeProgrammer / ST-Link Utility | Start address | 芯片绝对地址空间 |
| J-Flash | Base address | 芯片绝对地址空间 |
| esptool.py write_flash | 紧跟其后的那一串十六进制 | 外挂 Flash 物理偏移 |
| Flash Download Tool | 每个 bin 一行地址 | 外挂 Flash 物理偏移 |
| 部分脱机烧录器 | Offset / 偏移 | 相对 Flash 基址的偏移 |
| 自定义串口 ISP 上位机 | Addr | 看代码,两种都有可能 |
提示:拿到一个不熟悉的工具,先别急着填数字。找一下它的说明或者源码里那个地址是怎么用的——是直接丢给编程算法当目标地址,还是先加了一个基址再做运算。这一步花两分钟,能省下两小时的排查。
另外还有一个经常被忽略的事实:如果你烧的是 .hex 或 .elf,大多数工具根本不给你填地址的机会。因为 Intel HEX 文件里自带扩展线性地址记录,ELF 里有 LOAD 段的 VMA,工具直接读文件就知道该往哪写。只有烧 .bin 的时候才需要手工指定地址,因为 .bin 是一个纯粹的数据流,里面没有任何地址信息。这一点在后面的排错章节里还会再提到,它是很多"烧进去了但跑不起来"问题的根源。
2. 0x08000000 不是 ARM 定的:STM32 的 Flash 落点与别名机制
很多人把 0x08000000 当成"ARM 规定"或者"Cortex-M 的 Flash 起始地址",这是个挺普遍的误解。ARM 只规定了 Code 区从 0x00000000 到 0x1FFFFFFF 这 512MB 的粗划分,至于片内 Flash 具体落在 Code 区的哪个偏移上,是各家芯片厂商自己定的。STM32 选了 0x08000000,NXP 的 LPC 系列选了 0x00000000,Kinetis 也是 0x00000000,Nordic 的 nRF52 同样是 0x00000000,i.MX RT 的 FlexSPI 则是 0x60000000。这不是谁对谁错,纯粹是设计选择。
2.1 0x08000000 这个数是怎么被挑出来的
STM32 把 Code 区大致劈成三块用:0x00000000 附近留给重映射和别名,0x08000000 开始给片内 Flash,0x1FFF0000 附近给系统存储器(出厂固化的 bootloader)和选项字节。这个划分有一个很实际的好处:别名区和 Flash 物理区互不重叠,所以重映射逻辑只需要把 Flash 的前一段挪到 0x00000000 就行,不需要做复杂的地址偏移运算,地址译码电路能简单很多。
至于为什么是 0x08000000 而不是 0x04000000 或者 0x0C000000,说实话没有必须的道理,就是给 Flash 预留了足够大的地址窗口(0x08000000 到 0x08FFFFFF 有 16MB 的余量),同时前面留出 128MB 给别名和其它用途。厂商定完就固化进手册了,后面所有工具链、链接脚本、烧录算法都跟着这个数走。
2.2 同一个芯片,为什么有人填 0x00000000 也能烧进去
这块经常会引发争论。有人说"我在 ST-Link Utility 里填 0x00000000,一样烧成功了啊",也有人说"我填 0 就报错"。两种说法都对,区别在于芯片型号和当时的启动配置。
以 STM32F1 为例,当 BOOT0 = 0 时,主 Flash 会被别名到 0x00000000。这时候你对 0x00000000 写入,硬件实际写到的就是 Flash 的第一页(也就是 0x08000000 处)。所以在 F1 上填 0 和填 0x08000000,效果是一样的。但在 F4、F7、H7 这些型号上,别名行为受选项字节(nSWBOOT0、nBOOT0 等)的影响,情况会复杂一些,某些配置下 0x00000000 并不指向主 Flash,写进去就是无效操作。
我的建议很直接:老老实实填 0x08000000,不要为了省几个字符去用别名地址。原因有三个。第一,别名行为跟 BOOT 引脚和选项字节耦合,换个板子、改个配置就可能失效,代码不可移植。第二,如果工程里用了 IAP 或者 OTA,App 的地址本来就在 0x0800xxxx 这一带,混用两套地址写出来极容易出错。第三,调试的时候你用调试器看变量、看反汇编,显示的地址都是 0x0800xxxx,填 0 反而要自己在脑子里做一次换算,纯属给自己添麻烦。
2.3 换芯片就换地址:几种主流落点对照
跨平台移植的时候,最先要确认的就是这个基址。下面这张表是我实际项目中碰到过的,可以当速查用:
| 芯片 / 系列 | 片内 Flash 基址 | 备注 |
|---|---|---|
| STM32 F0/F1/F3/F4/F7/H7/L4/G0/G4 | 0x08000000 | 主流,覆盖面最广 |
| GD32 / AT32 / MM32 / HK32 / APM32 | 0x08000000 | 与 STM32 兼容 |
| NXP LPC8xx / LPC17xx / Kinetis | 0x00000000 | Flash 直接落在 0 处 |
| Nordic nRF52 | 0x00000000 | SoftDevice 也从 0 开始 |
| NXP i.MX RT | 0x60000000 | FlexSPI 映射区 |
| 经典 ESP32 | 无片内 Flash | 地址全看外挂 Flash 偏移 |
| TI MSP430 | 0x0000 - 0xFFFF | 16 位地址空间,另有一套规则 |
顺手提一句 0x1FFF0000 附近的系统存储器。它在 STM32 上是出厂固化的 bootloader,用于串口/USB 下载,不是给你放应用的。有些新手看到这个地址在手册里被标成 "System memory",误以为可以往里烧程序,结果把出厂 bootloader 覆盖掉,之后就很难再通过串口恢复了。这块区域通常是只读或者受保护状态,真想烧进去,工具也会拦你。
3. 0x0 和 0x1000 的世界:外挂 Flash 的"地址"其实是偏移
聊完 STM32 那套绝对地址,再回头看 ESP32 系列的地址就会顺很多。ESP32 的 ROM 代码是芯片出厂时就固化好的,上电后它没法像 STM32 那样靠硬件地址译码把 Flash 映射到 0,而是靠软件流程:ROM 里的 first stage bootloader 主动去 SPI Flash 的固定偏移处读数据,校验镜像头,然后把它加载起来执行。既然是从存储芯片里主动读,那用的自然是存储芯片自己的地址,也就是从 0 开始的物理偏移。
3.1 ESP32 系列的烧录地址为什么看起来这么"小"
经典 ESP32 的默认烧录布局大概是这样:
| 内容 | Flash 内偏移 | 说明 |
|---|---|---|
| 二级 bootloader | 0x1000 | 由 ROM 代码加载 |
| 分区表 | 0x8000 | 描述各分区的名字、类型、偏移、大小 |
| OTA 数据初始化 | 0xE000 | 双 OTA 分区时才会烧 |
| 应用固件 app | 0x10000 | 程序主体 |
| NVS 参数区 | 0x9000 | 由分区表描述 |
这些数字没有一个超过 0x100000 的,因为 4MB 的 Flash 一共也就 0x400000 这么大。对比 STM32 那边的 0x08000000,很容易产生"是不是少写了几个 0"的错觉。其实这里有一个很好用的判断方法:如果地址小于 0x1000000,八成是外挂存储介质的偏移;如果地址落在 0x08000000 或 0x1FFF0000 这类位置,那就是芯片绝对地址空间。
3.2 同一份数据,CPU 看到的是 0x400D0000,烧录器看到的是 0x10000
这是理解 ESP32 地址体系最关键的一点。app 那个 bin 文件,你烧的时候填 0x10000,因为它在 Flash 芯片里的物理位置就是 0x10000。但程序真正跑起来的时候,CPU 取指令的地址是 0x400D0000 附近的(经典 ESP32,Xtensa 内核的 IROM 映射区),或者 0x42000000 附近(ESP32-C3、ESP32-S3 这些较新的型号)。
中间差的那一段,是 MMU 干的活:ROM 或者二级 bootloader 会把 Flash 上的某段区域映射到 CPU 的 IROM/DROM 地址空间,然后 CPU 才能像访问内存一样直接取指令。所以同一份字节,在烧录器眼里是"Flash 偏移 0x10000",在调试器眼里是"地址 0x400D0000"。两个都对,只是观察者不同。
| 视角 | 看到的地址 | 用途 |
|---|---|---|
| 烧录工具 / esptool | 0x10000 | 决定写到 Flash 芯片的哪个位置 |
| CPU 取指令 | 0x400D0000(ESP32)/ 0x42000000(C3/S3) | 执行时的虚拟地址 |
| CPU 读常量 | 0x3F400000 附近 | 数据段的映射地址 |
| 分区 API | 0x10000 | 通过 esp_partition 接口做读写 |
这个双重地址体系带来的实际影响是:你不能拿链接脚本里的地址去烧录,也不能拿烧录地址去反汇编找函数。做 OTA 的时候尤其要注意,升级包里记录的必须是 Flash 偏移,而不是代码里的虚拟地址。
3.3 为什么 ESP32-C3 和 S3 把 bootloader 挪到了 0x0
这是很多人换芯片时踩的第一个坑。经典 ESP32 的二级 bootloader 在 0x1000,但到了 ESP32-C3、ESP32-S3 这些基于 RISC-V 的新型号上,二级 bootloader 的偏移变成了0x0。原因是 ROM 引导流程不同:Xtensa 时代留了 0x1000 这个偏移(历史上给引导头和兼容内容留位置),而 C3/S3 的 ROM 代码直接从 Flash 的 0 地址开始校验镜像头,所以编译系统干脆把 bootloader 放到 0x0。
如果你的工程是从经典 ESP32 移植到 C3 的,出现invalid header: 0xffffffff这类报错,第一个要检查的就是这个偏移有没有跟着改。用 ESP-IDF 的idf.py flash一般不会错,因为构建系统会自己算;但如果你习惯用 Flash Download Tool 手动勾 bin,就很容易把老工程里的 0x1000 照抄过来。
3.4 怎么快速确认自己该填哪几个地址
分享几个我常用的确认路径,按可靠程度排序:
- 看
idf.py flash打印出来的 esptool 命令行。它会完整打印write_flash后面跟着的一串"地址 文件"对,这是最权威的答案,因为是构建系统根据芯片型号和分区表算出来的。 - 看工程目录下的
build/flash_args。里面的内容跟上面命令行一致,但没有被终端滚动刷掉的风险,适合复制留档。 - 看
build/partition_table/partition-table.csv。分区的名字、类型、偏移、大小都在这里,想知道 app 到底在 0x10000 还是别处,一眼就能看到。 - 读回来验证。
esptool.py --chip esp32c3 read_flash 0x8000 0x1000 pt.bin把分区表读出来,再用gen_esp32part.py解析,就能确认板子上实际烧的是什么。 - 确认芯片型号。
esptool.py --chip esp32c3 flash_id会打印芯片类型和 Flash 容量,型号选错的话,整套地址都会错位,症状是烧录时反复打印Invalid head of packet或者Failed to connect。
注意:
--chip参数一定要和实际芯片对上。我见过有人拿 ESP32 的参数去烧 ESP32-C3,结果 esptool 把 bootloader 写到 0x1000,而 C3 的 ROM 在 0x0 找不到有效镜像,串口就一直在打印rst:0x3循环重启。
4. 0x6000 是怎么冒出来的:IAP 分区与偏移写法
前面把 0x08000000 和 0x0/0x1000 讲清楚了,剩下 0x6000 这个"异类"。它其实是三种情况里最容易解释的,因为它基本上就是一个换算后的中间值。
4.1 0x6000 大多数时候是 0x08006000 的简写
0x6000 换算成十进制是 24576,也就是 24KB。这个数字在 IAP(在应用中编程)方案里出现频率极高:bootloader 占掉 Flash 开头的一段,App 从后面开始放。如果 bootloader 编译出来的体积在 20KB 上下,那么找一个"整齐的、对齐到擦除扇区边界"的分界点,0x6000 就非常合适。
它出现在工具界面上的形态通常有两种。一种是芯片绝对地址 0x08006000,出现在 Keil 的 IROM 起始、链接脚本的 ORIGIN 里;另一种是偏移 0x6000,出现在脱机烧录器或者串口 ISP 上位机的"起始地址"框里。这两种写法指的是同一个位置,判断方法是看工具要不要你另外选芯片型号——如果选了型号,那它大概率会自己补基址,你填的就是偏移。
4.2 bootloader 预留区到底该留多大
这是 IAP 设计里最需要认真算的一个数,留小了以后加功能就装不下,留大了浪费 Flash。我的做法是分三步:
第一步,量实际体积。编译 bootloader 工程,用arm-none-eabi-size看 text + data 之和:
arm-none-eabi-size build/bootloader.elf # text data bss dec hex filename # 18432 128 2048 20608 5080 build/bootloader.elftext + data = 18560 字节,约 18.1KB。
第二步,按擦除粒度对齐。STM32F1 的 Flash 页大小是 1KB,STM32F4 是扇区制(16KB/64KB/128KB 不等),STM32G0/L4 常见 2KB 页。分界点必须落在页/扇区的起始边界上,否则擦除 App 区的时候会把 bootloader 的尾巴一起擦掉。18.1KB 向上对齐到 1KB 边界是 19KB,但这不够保险,因为 bootloader 以后还会加日志、加校验、加协议。
第三步,留一倍以上的余量。我一般的经验值是"当前体积 × 1.5,再向上取到整齐值"。19KB × 1.5 ≈ 28.5KB,往上取到 0x8000(32KB)有点奢侈,取到 0x6000(24KB)又偏紧。这时候就要看 App 还能不能放下——如果整个片子只有 128KB,那 0x6000 是更现实的取舍。
所以 0x6000 这个数不是凭空来的,它是"实际体积 + 页对齐 + 容量约束"三者折中的结果。你在别人的工程里看到它,基本能反推出对方 bootloader 大概 15-20KB 这个量级。
4.3 一份完整的地址账本长什么样
以 256KB Flash 的 STM32F103RC 为例,一个能跑双区 OTA 的布局大概是这样:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 0x6000(24KB) | 跳转、升级协议、Flash 擦写 |
| 升级参数区 | 0x08006000 | 0x2000(8KB) | 升级标志、版本号、CRC、重试计数 |
| App A | 0x08008000 | 0x1C000(112KB) | 当前运行区 |
| App B | 0x08024000 | 0x1C000(112KB) | 备份区 / 新固件下载区 |
| 结束 | 0x08040000 | - | 恰好是 256KB 边界 |
这张表有几个地方值得核对。第一,App A 的起始 0x08008000 落在 1KB 页边界上,安全。第二,A 和 B 大小一致,这样升级时可以直接做整块搬运,不用处理长度不一致的分支逻辑。第三,末尾精确落在 0x08040000,没有浪费。如果哪一项对不上,比如 App B 的结束地址超过了 0x08040000,那就说明分配时算错了,编译能过,但运行时写 Flash 会越界或者报错。
4.4 改了起始地址之后,这几个地方必须同步
这是我见过最容易漏的一组连带修改。把 App 从 0x08000000 挪到 0x08008000,至少要在下面这些地方各改一遍:
- Keil Target 里的 IROM1 Start(或者 IAR 的
.icf里 ROM region 起始)。 - 链接脚本 / 分散加载文件里的
ORIGIN = 0x08008000。 system_stm32f1xx.c里的VECT_TAB_OFFSET,它决定中断向量表重定位的偏移量。- 启动代码里的
SCB->VTOR设置,有些工程是在main之前手写的。 - Bootloader 跳转代码里的
APP_ADDR宏。 - 生成
.bin之后的烧录地址,Keil 的fromelf或者objcopy出来的 bin 是不带地址的。 - OTA 升级包里的地址记录字段,上位机和服务端都得同步改。
第 3 和第 4 条是最容易漏的,因为漏了以后程序能进main,什么都不报错,直到第一个中断到来时才飞掉。表现是串口打印了几行启动日志,然后卡死;或者跑几毫秒之后进 HardFault。很多人会去怀疑时钟配置、怀疑外设初始化,其实问题在向量表。
跳转代码这边也顺手贴一段可以直接抄的:
#define APP_ADDR 0x08008000U typedef void (*pFunction)(void); void jump_to_app(void) { uint32_t stack_top = *(__IO uint32_t *)APP_ADDR; uint32_t reset_vec = *(__IO uint32_t *)(APP_ADDR + 4U); /* 粗略校验:栈顶应该落在 SRAM 区,复位向量应该是奇数地址 */ if ((stack_top & 0x2FFE0000U) != 0x20000000U) { return; /* 该地址上没有合法镜像,直接返回,别跳 */ } if ((reset_vec & 0x1U) == 0U) { return; /* Thumb 状态下复位向量最低位必须为 1 */ } __disable_irq(); /* 跳转前关中断 */ HAL_DeInit(); /* 关掉用过的外设 */ SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; SCB->VTOR = APP_ADDR; /* 向量表搬到 App 区 */ __set_MSP(stack_top); ((pFunction)reset_vec)(); }这段代码里那个"校验栈顶和复位向量"的小技巧非常好用,它能挡住大部分"镜像没烧进去却硬跳"的场面。一个合法的 Cortex-M 镜像,头 4 字节是初始 MSP,一定在 0x20000000 区;紧接着 4 字节是复位向量,Thumb 模式下最低位一定是 1。两个条件都不满足,说明目标地址上是空的或者填的是别的数据,直接返回比跳过去暴毙强得多。
5. 拿到陌生板子的定位流程与填错地址后的症状对照
上面聊的都是原理,最后说说实操。真正影响效率的不是"知不知道 0x08000000",而是"拿到一块没见过的板子,怎么在十分钟内确定该往哪烧"。
5.1 五步定位法
我一般的顺序是这样的。
**第一步,查手册的 Memory mapping 章节。**注意不是看第一页的框图,而是专门找 "Memory mapping" 或者 "Embedded Flash memory" 那一节,里面会明确写出 Flash 的起始地址和容量。这一步能定下 90% 的答案。
第二步,确认启动方式。STM32 看 BOOT0/BOOT1 引脚或者选项字节;ESP 系列看 eFuse 里的启动模式;有些板子有拨码开关。启动方式决定的是0x00000000 处出现的到底是哪块存储器,跟烧录地址本身有关联但不等同。
第三步,翻官方例程或者构建脚本。这一步往往比手册还快。STM32 就看 CubeMX 生成的链接脚本,ESP 就看flash_args,Nordic 就看nrfconnect的工程配置。官方既然能跑,抄它的地址一定不会错。
第四步,看自己编译产物的链接脚本。打开.ld、.icf或者.sct,找到FLASH区域的ORIGIN,这就是当前工程认定的 Flash 基址。如果这个值和手册对不上,说明工程是从别的芯片改过来的,还没改干净。
第五步,读回来验证。这条是兜底。用烧录工具把目标地址起始的 256 字节读出来看看:
- 全是
0xFF:说明这个位置是空的,还没烧过。 - 开头两个 32 位字是
0x2000xxxx和0x0800xxxx | 1:大概率是合法的 Cortex-M 镜像。 - 开头是
0xE9之类的魔数:可能是 ESP 系列的镜像头。 - 全是乱码但又不是合法镜像:可能烧了别的东西进去,或者读过界了。
这五步走下来,基本没有定不下来的地址。
5.2 从 .hex 和 .elf 反推地址
如果你手上只有一个别人给的.hex,不知道它本来就是往哪个地址烧的,其实可以从文件内容上直接读出来。Intel HEX 的每一行都是一条记录,:开头,紧接着是长度、地址、类型、数据、校验和。其中的04类型记录就是扩展线性地址记录,它给出高 16 位地址:
:020000040800F2 :1000000000100020...第一条记录:02表示数据长度 2 字节,0000是记录地址(对类型 04 无意义),04是记录类型,0800是数据,F2是校验和。把0800左移 16 位就是0x08000000。也就是说,后面那些没带高地址的记录,实际地址都要加上这个0x08000000的基址。
.elf更直接,用readelf看 LOAD 段:
arm-none-eabi-readelf -l build/app.elf | grep LOAD输出里的VirtAddr就是运行时地址。或者用objdump -h看.isr_vector段的 VMA,那个就是向量表的实际落点,通常也是整个镜像的起始。
而.bin什么都没有。这解释了那个老生常谈的问题:为什么 .hex 直接烧就行,.bin 非得填一个地址。因为.hex自带地址信息,工具能自己解析;.bin是一段裸数据流,工具完全不知道你想把它放到哪,只能由你来告诉它。
5.3 填错地址之后的症状对照
排错最怕的是症状和原因之间没有明显的因果联系,只能一个个试。下面这张表是我这些年攒下来的经验,看到对应症状可以直接往原因上想:
| 现象 | 可能原因 |
|---|---|
| 烧录时报 Verify failed / 校验不通过 | 地址越界(超出 Flash 容量)或该区域有读保护 |
| 烧录成功,复位后毫无反应,读回来还是 0xFF | 烧到了别名区或无效区域,实际写入没生效 |
| 能进 main,串口有打印,一进中断就卡死 | 向量表重定位没做(VTOR 或 VECT_TAB_OFFSET 没改) |
| 上电就跑飞,连 main 都进不去 | 跳转前没关中断、没设 MSP,或者 App 地址本身是错的 |
| 程序能跑,但 OTA 一直失败 | App 区地址和升级包里记录的地址不一致 |
| esptool 反复打印 Invalid head of packet | --chip 型号选错,导致整套地址错位 |
| 能烧能跑,但重启后回到旧版本 | OTA 参数区地址和备份区地址搞混了 |
| 编译通过,运行时写 Flash 直接 HardFault | 链接脚本的 LENGTH 超出了实际芯片容量 |
我特别想强调"能进 main,一进中断就卡死"这一条。这个症状太有迷惑性了,因为它看起来像是时钟或者外设的问题,很多人会去查 NVIC 优先级、查 SysTick 配置,查半天。判断方法很简单:在main里点个灯,看灯亮不亮;亮,说明启动没问题;然后开一个定时器中断,如果立刻死,那就是向量表的事。改地址之后十次有八次是这里出问题。
5.4 几个我实际踩过的坑
坑一:用 .bin 烧录时忘了填地址。STM32CubeProgrammer 打开.bin会问你起始地址,如果随手点了确定,默认就是 0。这时候如果芯片的别名区正好指向 Flash,你可能烧成功了但完全不知道;如果没指向,就是白忙一场。现在我的习惯是,只要用.bin,先把地址框里的默认值清掉,手动敲一遍 0x08000000 再点下载。
坑二:换芯片型号但没改链接脚本的 LENGTH。从 C8T6(64KB)换成 RCT6(256KB)的时候,地址不变,但链接脚本里的LENGTH = 64K如果不改,后面 192KB 就浪费了。反过来,从大容量换到小容量,如果LENGTH没改小,编译和链接都不会报错,烧录时才会在尾部报校验失败——这时候才反应过来是容量问题。
坑三:脱机烧录器的偏移模式和绝对地址模式混用。有些便宜的量产烧录器,UI 上只有一个"起始地址"输入框,但它内部其实是偏移模式。你在里面填 0x08000000,它一加基址就变成了 0x100000000,然后各种报错。遇到这种情况,把地址填成 0x6000 反而正常了——这也是"0x6000 从哪来"的一个真实来源。
坑四:ESP32 的 flash_args 抄了别人的工程。别人的工程可能用了自定义分区表,app 的偏移不是默认的 0x10000。照抄参数的结果是 bootloader 正常、分区表正常、app 烧到了错的地方,串口一直打印invalid header或者partition table not found。正确做法是读自己的partition-table.csv,别抄。
坑五:把系统存储器当成应用区。前面提过,STM32 的 0x1FFF0000 附近是出厂 bootloader,有人把程序烧进去,覆盖掉之后串口下载功能就废了。虽然大多数情况工具会拦,但有些老工具确实能写进去,写完就再也连不上,只能靠 SWD 救。
说到底,烧录地址这个问题之所以让人困惑,是因为它背后藏着三套不同的坐标系,而工具从来不会主动告诉你它用的是哪一套。把"原点在哪"这个问题问清楚——是芯片的绝对地址空间,还是外挂存储介质的物理偏移,还是某个基址的相对偏移——剩下的事情就只是加减法了。我现在拿到任何一块新板子,第一件事不是打开烧录工具,而是先找到它的 memory map 或者构建脚本,把基址确认下来,然后再动手。这一步花的时间,比事后排查"为什么烧进去跑不起来"要少得多。