1. 为什么STM32上电后不是直接跑main函数?——从冷启动到C环境的完整穿越
你手里的那块STM32开发板,按下复位键或接通电源的瞬间,芯片内部到底发生了什么?它不会像PC那样先加载BIOS再进操作系统,也不会跳进你写的main函数就开干。它走的是一条被严格定义、由硬件和固件共同铺就的“启动窄道”:从复位向量取址、栈指针初始化、数据段搬运、BSS清零,再到调用__main(ARM标准库入口),最终才把控制权交给你写的main。这条路径看似只有几十行汇编,却决定了整个系统能否可靠启动、外设是否能正确初始化、甚至影响后续RTOS任务调度的时序精度。
我带过不少刚从51单片机转过来的开发者,第一反应都是:“我把main里LED闪烁代码写好了,一上电怎么不亮?”——问题往往不出在main,而出在main之前那不到200字节的启动代码里。比如某次调试一个基于STM32F407的电机驱动板,上电后程序卡死在HardFault,查了三天才发现是启动文件中.data段拷贝地址配置错了,导致全局变量初始化失败,后续调用某个结构体成员时触发总线异常。这种问题不会报错,没有提示,只有一片沉默的黑暗。
这篇文章不讲抽象概念,不堆寄存器手册截图,而是带你逐行反汇编、逐指令跟踪、逐内存区域验证,还原真实开发环境中从VDD上电那一刻起,芯片内部每一步动作的物理意义与软件映射。你会看到:
- 复位后PC为何指向0x08000004而不是0x08000000;
- SP初始值是从哪里读出来的,为什么必须是RAM地址且需4字节对齐;
- .data段是如何从Flash搬到SRAM的,搬运过程中若发生Flash读取错误会怎样;
- BSS段清零是用MOV还是用循环STR,不同编译器生成的代码有何差异;
- __main到底做了哪些事,它和你写的main之间还隔着几层“翻译官”。
适合谁看?如果你正在调试启动失败、HardFault频发、全局变量值异常、或者想彻底搞懂链接脚本(.ld)里那些SECTION定义的真实作用,那么这篇就是为你写的。哪怕你目前只会用CubeMX点点点生成工程,只要愿意打开startup_stm32f407xx.s文件跟着我一行行读下去,你就能建立起对嵌入式启动本质的肌肉记忆——这不是知识,是手感。
2. 启动流程全景图:硬件复位→向量表→启动代码→C运行时→main
2.1 硬件复位阶段:电源稳定、时钟就绪、内核归零
STM32的上电启动,始于一个物理事件:VDD引脚电压越过芯片规定的复位阈值(典型值1.8V,具体见对应型号DS手册第6章“Electrical Characteristics”)。这个过程不是瞬时完成的,而是存在一个上电复位时间(tRST),通常为1~10ms,由内部POR(Power-On Reset)电路监测并维持复位信号有效。在这段时间内,所有数字逻辑单元、寄存器、状态机都处于强制复位态,CPU内核停止取指,总线挂起,SRAM内容不可靠(可能为随机值)。
提示:很多初学者忽略外部复位电路设计。若使用RC复位电路,需确保R×C时间常数大于tRST,并留有20%余量。实测某项目因R=10k、C=100nF导致t=1ms,恰好卡在临界点,高温环境下偶发启动失败。
当POR检测到VDD稳定后,会释放内部复位信号,同时触发系统复位(System Reset)。此时,ARM Cortex-M内核执行以下硬性操作:
- 将程序计数器(PC)强制加载地址0x00000000处的字(32位);
- 将栈指针(SP)强制加载地址0x00000004处的字;
- 清除所有通用寄存器(R0–R12)、链接寄存器(LR)、程序状态寄存器(xPSR);
- 进入线程模式(Thread Mode),使用主栈指针(MSP)。
注意:这里说的“0x00000000”是别名地址(Alias Address),实际映射取决于BOOT引脚状态和向量表偏移寄存器(VTOR)。对于绝大多数应用,BOOT0=0,BOOT1=x,芯片从主闪存(Main Flash)启动,此时0x00000000被重映射到Flash首地址(如STM32F407为0x08000000)。这是理解后续向量表位置的关键前提。
2.2 向量表定位:不是固定在0x00000000,而是可重映射的“导航地图”
ARM Cortex-M规定,复位后PC从0x00000000取值,但该地址本身不存储代码,而是一个向量表(Vector Table)的起始位置。向量表本质是一组32位地址的连续数组,每个元素对应一种异常/中断的处理函数入口。标准定义如下(ARMv7-M Architecture Reference Manual, Section B1.5.3):
| 偏移 | 名称 | 描述 |
|---|---|---|
| 0x00 | Initial SP Value | 主栈指针初始值(MSP) |
| 0x04 | Reset Handler | 复位处理函数地址(即启动代码入口) |
| 0x08 | NMI Handler | 不可屏蔽中断处理函数地址 |
| 0x0C | HardFault Handler | 硬件故障处理函数地址 |
| ... | ... | ... |
关键点在于:这个向量表可以放在任意支持的内存区域——Flash、SRAM,甚至系统存储器(System Memory,用于DFU)。其物理位置由向量表偏移寄存器(VTOR)决定。默认情况下,VTOR=0,向量表位于0x00000000(即Flash首地址);但若你在代码中执行SCB->VTOR = 0x20000000;,则向量表就移到了SRAM起始处。
STM32的启动模式由BOOT0和BOOT1引脚电平组合决定(参考RM0090第2.4节“Boot configuration”):
- 主闪存启动(BOOT0=0):最常用模式,向量表位于Flash首地址(0x08000000),复位后0x00000000自动重映射至此。
- 系统存储器启动(BOOT0=1, BOOT1=0):用于ISP下载,向量表位于系统存储器(通常是ROM中的Bootloader)。
- 内置SRAM启动(BOOT0=1, BOOT1=1):向量表位于SRAM首地址(0x20000000),仅用于调试或特殊场景。
注意:向量表必须满足两个硬性条件:1)起始地址必须是0x200的整数倍(即256字节对齐);2)整个表长度至少为(异常数量+1)×4字节。STM32F4系列最多支持96个中断,加上16个系统异常,共112项,故最小向量表长度为448字节(0x1C0)。
2.3 启动代码执行:从汇编到C的“桥梁搭建”
当CPU从向量表0x04处读取到复位处理函数地址(例如0x0800012C),就开始执行这段位于startup_stm32f407xx.s中的汇编代码。这段代码是整个启动流程的“心脏起搏器”,其核心任务不是实现业务逻辑,而是为C语言运行环境(C Runtime)搭建基础。我们以GCC工具链生成的标准启动文件为例,逐段拆解:
.section .text.Reset_Handler .weak Reset_Handler .global Reset_Handler Reset_Handler: /* 1. 初始化栈指针(SP) */ ldr sp, =_estack /* 加载栈顶地址到SP */ /* 2. 搬运.data段:从Flash复制到SRAM */ ldr r0, =_sdata /* data段在SRAM中的起始地址 */ ldr r1, =_edata /* data段在SRAM中的结束地址 */ ldr r2, =_sidata /* data段在Flash中的源地址 */ movs r3, #0 /* 循环计数器清零 */ /* 循环拷贝 */ copy_loop: cmp r3, r1 /* 比较当前地址与结束地址 */ bhs copy_done /* 若>=,则拷贝完成 */ ldr r4, [r2, r3] /* 从Flash源地址读取一个字 */ str r4, [r0, r3] /* 存入SRAM目标地址 */ adds r3, r3, #4 /* 地址+4(32位) */ b copy_loop copy_done: /* 3. 清零.bss段 */ ldr r0, =_sbss /* bss段起始地址 */ ldr r1, =_ebss /* bss段结束地址 */ movs r2, #0 /* 清零值 */ bss_loop: cmp r0, r1 /* 比较当前地址与结束地址 */ bhs bss_done /* 若>=,则清零完成 */ str r2, [r0] /* 存入0 */ adds r0, r0, #4 /* 地址+4 */ b bss_loop bss_done: /* 4. 调用C库初始化函数__main */ bl __main /* 跳转到ARM C库入口 */ /* 5. 若__main返回(正常不应发生),进入死循环 */ b .这段代码的精妙之处在于:它完全不依赖任何C运行时特性(如malloc、printf),仅使用最基础的ARM指令,却完成了C语言赖以生存的三大基础设施:
- 栈空间就绪:通过
ldr sp, =_estack将SP设置为链接脚本中定义的SRAM最高地址(如0x2001FFFF),确保后续函数调用、局部变量分配有地方可用; - 已初始化数据就位:
.data段存放的是带有初始值的全局/静态变量(如int flag = 1;),这些值必须从Flash(只读)复制到SRAM(可读写)才能被修改; - 未初始化数据归零:
.bss段存放的是未显式初始化的全局/静态变量(如int buffer[1024];),C标准要求其初始值为0,故需在运行时主动清零。
实操心得:我曾在一个低功耗项目中发现,启动代码拷贝.data段时,若Flash处于深度睡眠模式,首次读取会触发唤醒延迟,导致拷贝时间超预期。解决方案是在拷贝前插入一条
__DSB(); __ISB();指令,确保流水线同步,并在链接脚本中将.data段放置在Flash中访问延迟更小的区域(如Bank1而非Bank2)。
2.4 C运行时接管:__main的隐藏工作与main的真正起点
当启动代码执行bl __main后,控制权移交给了ARM标准C库(ARM C Library, akaarmlibc)。很多人误以为__main就是直接跳转到main(),其实它是一个多阶段初始化引擎,其内部流程可简化为:
- 堆栈检查与初始化:验证SP是否在合法RAM范围内,若非法则触发
__rt_stackheap_init进行堆栈重定向(极少用到); - 数据段二次校验:再次确认
.data段拷贝完整性(CRC校验可选启用); - C++全局对象构造:若项目含C++代码,此处调用
__cpp_initialize执行全局对象的构造函数; - C库环境准备:初始化
stdin/stdout/stderr文件句柄(若使用半主机semihosting)、设置locale、初始化浮点单元(FPU)状态; - 最终跳转:调用用户定义的
main()函数。
这个过程在ARM文档中称为“C library initialization”,其源码位于ARM工具链的__main.o中,不可见但可追溯。你可以通过在main()开头添加断点,然后回溯调用栈,清晰看到__main → __rt_entry → main的调用链。
关键提醒:
__main执行期间,所有中断默认是关闭的(PRIMASK=1)。这意味着如果你在main()之前(如全局变量初始化函数中)尝试使能NVIC或配置SysTick,会立即触发HardFault。务必等到main()开始执行后再开启中断——这是无数新手踩过的深坑。
3. 核心细节深度解析:链接脚本、向量表、内存布局三位一体
3.1 链接脚本(.ld):内存地图的“宪法”,一切布局的源头
启动流程的物理实现,全部依赖于链接脚本(Linker Script)的精确描述。它不是可有可无的配置文件,而是告诉链接器“代码放哪、数据放哪、栈在哪、堆在哪”的唯一法律依据。以STM32F407VG(512KB Flash, 128KB SRAM)为例,其典型链接脚本片段如下:
/* 定义内存区域 */ MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } /* 定义输出段 */ SECTIONS { /* 向量表必须放在Flash最前端,且256字节对齐 */ .isr_vector : { . = ALIGN(256); _isr_vector = .; KEEP(*(.isr_vector)) /* 保留startup文件中的向量表 */ . = ALIGN(4); } > FLASH /* 代码段(.text)紧随向量表之后 */ .text : { *(.text) /* 所有.text段 */ *(.rodata) /* 只读数据 */ *(.rodata*) /* 所有.rodata子段 */ } > FLASH /* 已初始化数据段(.data):在Flash中存源,在SRAM中存目标 */ .data : AT (ADDR(.text) + SIZEOF(.text)) { _sdata = .; /* SRAM中.data起始地址 */ *(.data) /* 拷贝到SRAM的目标 */ _edata = .; /* SRAM中.data结束地址 */ } > RAM /* 未初始化数据段(.bss):仅在SRAM中分配空间 */ .bss : { _sbss = .; /* SRAM中.bss起始地址 */ *(.bss) /* 所有.bss段 */ *(COMMON) /* COMMON符号(未定义的全局变量) */ _ebss = .; /* SRAM中.bss结束地址 */ } > RAM /* 栈顶地址:SRAM末尾 */ _estack = ORIGIN(RAM) + LENGTH(RAM); }这段脚本定义了三个核心要素:
- MEMORY块:声明芯片物理内存的起始地址与大小,
FLASH (rx)表示可读可执行,RAM (rwx)表示可读可写可执行; - SECTIONS块:定义各代码/数据段的布局规则,其中
.data : AT (...)语法是关键——它指定.data段的加载地址(Load Address)在Flash中(紧跟.text之后),而运行地址(Run Address)在RAM中(> RAM); - 符号定义:
_sdata,_edata,_sbss,_ebss,_estack等符号,正是启动代码中ldr r0, =_sdata所引用的地址,它们由链接器在链接时计算并填入。
计算实例:假设.text段总长为0x8A00字节,则
.data的加载地址(AT)为0x08000000 + 0x8A00 = 0x08008A00,而其运行地址(> RAM)为0x20000000。启动代码正是从0x08008A00读取数据,写入0x20000000开始的SRAM区域。
3.2 向量表内容实锤:不只是地址,更是系统状态的“快照”
向量表不是一堆静态地址,而是系统启动时内核状态的实时快照。我们用objdump -d your_project.elf反汇编,提取向量表前8项(复位至MemManage):
$ arm-none-eabi-objdump -d build/your_project.elf | grep "08000000\|08000004\|08000008\|0800000c\|08000010\|08000014\|08000018\|0800001c" ... 08000000 <_isr_vector>: 0: 2001ffff .word 0x2001ffff # MSP初始值 = 0x2001ffff (SRAM末地址) 4: 0800012d .word 0x0800012d # Reset Handler地址 8: 08000131 .word 0x08000131 # NMI Handler地址 c: 08000135 .word 0x08000135 # HardFault Handler地址 10: 08000139 .word 0x08000139 # MemManage Handler地址 14: 0800013d .word 0x0800013d # BusFault Handler地址 18: 08000141 .word 0x08000141 # UsageFault Handler地址 1c: 00000000 .word 0x00000000 # Reserved观察第一项0x2001ffff:这正是链接脚本中_estack的值,即SRAM最高地址(0x20000000 + 128K - 1 = 0x2001FFFF)。它被直接加载到SP寄存器,成为整个C运行时的栈顶。第二项0x0800012d,则是Reset_Handler函数在Flash中的绝对地址。
深度验证:你可以在Keil或STM32CubeIDE中,全速运行程序后暂停,查看寄存器窗口中的SP值,它应该精确等于
0x2001ffff(或你链接脚本中定义的_estack值)。若不等,说明向量表未被正确加载,或VTOR被意外修改。
3.3 内存布局全景图:从0x00000000到0x2001FFFF的每一寸土地
将链接脚本、向量表、启动代码三者叠加,可绘制出完整的内存布局图。下表以STM32F407为例,展示从Flash首地址到SRAM末地址的关键区域分布:
| 地址范围 | 区域名称 | 大小 | 内容说明 | 访问属性 | 启动阶段作用 |
|---|---|---|---|---|---|
| 0x08000000–0x080000FF | .isr_vector | 256B | 向量表(112项×4B) | Flash, RO | 提供复位/中断入口地址 |
| 0x08000100–0x08008A00 | .text | ~35KB | 编译后的机器码、常量字符串 | Flash, RX | CPU执行主体代码 |
| 0x08008A00–0x08009200 | .data(Load) | 2KB | .data段在Flash中的副本 | Flash, RO | 启动时作为拷贝源 |
| 0x20000000–0x200007FF | .data(Run) | 2KB | .data段在SRAM中的运行体 | SRAM, RW | 全局变量实际存储地 |
| 0x20000800–0x200027FF | .bss | 8KB | 未初始化全局变量占位区 | SRAM, RW | 启动时被清零 |
| 0x20002800–0x2001FFFF | Heap & Stack | ~112KB | 动态内存池、函数调用栈 | SRAM, RW | malloc、局部变量、函数参数 |
这个布局不是随意的。.isr_vector必须紧贴Flash首地址,因为硬件复位后PC硬编码从0x00000000取值;.data的加载地址必须在Flash中,因为它需要在启动时被读取;而.data和.bss的运行地址必须在SRAM中,因为它们需要被写入。任何一项错位,都会导致启动失败或运行时崩溃。
实操陷阱:某次我将
.data段的运行地址错误地定义为> FLASH,结果启动代码试图向Flash地址写入数据,触发了写保护异常(UsageFault),程序卡死。排查时用J-Link Commander执行mem32 0x20000000 4命令读取SRAM起始4字节,发现全是0xFFFFFFFF,立刻意识到.data根本没被拷贝过去。
4. 实操全流程:从新建工程到逐指令跟踪的完整验证
4.1 准备工作:构建可调试的最小化工程
不要用CubeMX生成的“大而全”工程来学习启动流程,那会淹没核心逻辑。我们需要一个极简环境:
- 新建裸机工程:使用STM32CubeIDE,选择“Empty Project”,不勾选任何中间件;
- 禁用HAL库初始化:在
main.c中注释掉HAL_Init()和SystemClock_Config()调用; - 编写最小main:
int main(void) { // 仅点亮一个LED,不涉及任何外设初始化 RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; // 使能GPIOA时钟 GPIOA->MODER |= GPIO_MODER_MODER5_0; // PA5推挽输出 while(1) { GPIOA->ODR ^= GPIO_ODR_ODR_5; // 翻转PA5 for(volatile int i=0; i<100000; i++); } }- 确保启动文件正确:检查
Core/Startup/startup_stm32f407xx.s是否被包含在编译中,且Reset_Handler被正确链接。
这样做的好处是:启动流程结束后,程序会立即进入main(),而main()中只有最基础的寄存器操作,排除了HAL库、时钟配置等干扰因素,便于聚焦启动本身。
4.2 第一步:在Reset_Handler入口打下第一个断点
编译工程,连接ST-Link,点击“Debug”。在调试界面中,不要急于运行,而是做三件事:
- 打开Disassembly视图:右键菜单选择“Show Disassembly”,确保能看到汇编代码;
- 定位Reset_Handler:在“Expressions”或“Registers”视图中,找到PC寄存器,其值应为
0x0800012D(或你工程中的实际Reset_Handler地址); - 在Reset_Handler第一行设断点:即
ldr sp, =_estack这一行。
点击“Resume”(F8),程序会在ldr sp, =_estack处暂停。此时,查看寄存器窗口:
- SP值:应为
0x00000000(未初始化); - PC值:应为
0x0800012D; - Memory视图:跳转到
0x2001FFFF地址,观察该地址附近内存——应为随机值(因SRAM上电未清零)。
验证技巧:在Memory视图中,右键选择“Fill Memory”,将
0x20000000–0x20000010区域填充为0xAA。然后重启调试,再次停在Reset_Handler入口,观察0x20000000处是否仍为0xAA。若是,说明.bss清零尚未执行;若已变0x00,则说明启动代码已被修改或链接脚本异常。
4.3 第二步:单步执行,见证SP、.data、.bss的诞生
保持在Reset_Handler断点,按Step Over(F6)逐条执行:
- 执行
ldr sp, =_estack后:SP寄存器值变为0x2001FFFF,栈指针就绪; - 执行完
copy_loop循环后:切换到Memory视图,输入_sdata(如0x20000000),观察该地址起始的几个字(如4个),其值应与Flash中_sidata地址(如0x08008A00)处的值完全一致——这就是.data段拷贝完成; - 执行完
bss_loop循环后:再次查看_sbss(如0x20000800)地址,其值应全为0x00000000——.bss清零完成。
此时,你可以安全地在bl __main指令处设断点,然后“Resume”,程序将进入C库初始化流程。在__main内部,你无法单步(因是库函数),但可在main()第一行设断点,确认控制权最终到达你的代码。
关键观察:在
bl __main执行前,打开“Peripherals → Core Peripherals → System Control Block”,查看VTOR寄存器。其值应为0x08000000,证明向量表仍在Flash首地址。若为其他值,说明你的代码或库在启动前修改了VTOR,需警惕。
4.4 第三步:制造故障,反向验证启动逻辑的脆弱性
最好的学习方式是亲手破坏它。我们故意引入一个经典错误,观察现象:
- 修改链接脚本:将
.data的运行地址从> RAM改为> FLASH; - 重新编译;
- 全速运行。
现象:程序在str r4, [r0, r3](向Flash写入)指令处触发HardFault。查看HardFault的HFSR寄存器(0xE000ED2C),其FORCED位被置1,CFSR寄存器(0xE000ED28)的IBUSERR位被置1,明确指示“指令总线错误”——即试图向不可写区域(Flash)执行写操作。
故障复现价值:这个实验让你深刻理解,启动代码中每一个
ldr、str指令,都依赖于链接脚本定义的地址属性。硬件不会容忍逻辑错误,它只给出冰冷的异常信号。而读懂这些信号,正是嵌入式工程师的核心能力。
5. 常见问题与硬核排查技巧:来自十年现场调试的血泪总结
5.1 “程序不启动,LED完全不亮”——电源与复位链路排查表
这是最绝望的场景,连调试器都连不上。请按此顺序逐项检查:
| 检查项 | 方法 | 正常现象 | 异常表现与对策 |
|---|---|---|---|
| VDD电压 | 万用表测VDD引脚对GND | 3.3V±5%(或设计值) | 低于3.0V:检查LDO输入、电容焊锡、PCB短路;高于3.6V:检查LDO型号、反馈电阻 |
| 复位信号(NRST) | 示波器测NRST引脚 | 上电后约10ms高电平,然后稳定高 | 持续低电平:检查外部复位电路R/C值、MCU NRST引脚是否被其他芯片拉低;持续高电平:检查复位电路是否开路、MCU是否损坏 |
| 晶振起振 | 示波器探头(10x)触碰OSC_IN | 正弦波,频率=标称值,峰峰值>1Vpp | 无波形:检查晶振负载电容(通常20pF)、焊点虚焊、晶振型号(注意FT、FA等后缀代表不同负载);波形畸变:检查PCB走线是否过长、是否靠近噪声源 |
| BOOT引脚 | 万用表测BOOT0/BOOT1对GND | BOOT0=0V, BOOT1=3.3V(主闪存模式) | BOOT0=3.3V:检查上拉电阻是否误焊、跳线帽是否插错;BOOT0=浮动:加10kΩ下拉电阻 |
独家技巧:若怀疑晶振问题,可临时将
RCC_CR寄存器的HSEON位清零(通过SWD接口强制写),让MCU切到内部HSI(16MHz),若此时能启动,则100%确认是外部晶振故障。这是我在产线快速定位不良板的绝招。
5.2 “启动后卡在HardFault,但没进HardFault_Handler”——向量表与栈溢出双杀
现象:程序在main()之前就死掉,调试器显示PC停在0xFFFFFFF9或类似非法地址。这通常意味着:
- 向量表地址错误:VTOR指向了非法内存,导致复位后PC从错误地址取指;
- 栈溢出:SP初始值超出SRAM范围,
push指令写入非法地址触发BusFault。
排查步骤:
- 检查VTOR:在调试器中读取
SCB->VTOR(地址0xE000ED08),确认其值为0x08000000(或你设定的向量表地址); - 检查SP值:在Reset_Handler第一行,查看SP寄存器,确认其值在
0x20000000–0x2001FFFF范围内; - 检查向量表内容:在Memory视图中,查看
VTOR地址处的前4字节(SP初始值),应为有效的RAM地址;第5–8字节(Reset_Handler地址),应为0x08xxxxxx格式的Flash地址。
血泪教训:某次我将
_estack定义为ORIGIN(RAM) + LENGTH(RAM) + 0x100,导致SP初始值超出SRAM,push {r0-r3}时写入了非法地址,触发BusFault。但因为BusFault_Handler未定义,系统又跳转到向量表第12项(地址0x0000002C),那里是0x00000000,于是PC变成0x00000000,无限循环。最终通过查看CFSR的IBUSERR位锁定问题。
5.3 “全局变量值始终为0,但代码里明明赋了初值”——.data段拷贝失效的四大元凶
这是.data段相关最典型的症状。原因及排查方法:
| 元凶 | 如何验证 | 解决方案 |
|---|---|---|
链接脚本中.data的AT地址错误 | 查看objdump -h your.elf,确认.data的LMA(加载地址)是否在Flash中,VMA(虚拟地址)是否在RAM中 | 修正.data : AT (xxx)语法,确保LMA指向Flash,VMA指向RAM |
启动代码中_sidata符号未正确定义 | 在Disassembly中,查看ldr r2, =_sidata指令,其加载的地址是否为Flash中.data源地址 | 检查链接脚本中是否遗漏*(.data)或*(.data*),确保所有.data子段被收集 |
| Flash读取权限被禁用 | 在调试器中,尝试`mem3 |