ARTICLE DETAIL

资讯详情

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

STM32启动流程详解:从复位向量到main函数的完整链路

STM32启动流程详解:从复位向量到main函数的完整链路 1. 项目概述当“Hello World”在 STM32 上根本跑不起来时你得先搞懂 CPU 看见的第一行代码是谁写的“CPU 不认识 main()”——这句话乍一听像程序员的黑色幽默但放在 WeAct STM32F411 这类 Cortex-M4 微控制器上它不是段子而是铁一般的硬件事实。我第一次把写好的int main(void) { while(1) { GPIO_ToggleBits(GPIOA, GPIO_Pin_5); } }编译烧录进板子LED 却纹丝不动串口也毫无输出。用 ST-Link 调试器连上去单步发现程序指针压根没跳进main而是在一个叫Reset_Handler的函数里反复打转。那一刻我才真正意识到在嵌入式世界里“main 函数是程序入口”这个 C 语言常识其实是编译器和启动代码联手给你营造的温柔幻觉CPU 本身只认地址、认向量、认机器码它连“函数”这个词的英文拼写都不知道。这个标题直指嵌入式开发最底层的认知断层我们天天写main()却极少追问——CPU 上电瞬间从 Flash 第一个字节开始取指令执行那第一个字节到底是什么它怎么知道该跳去哪谁负责把.data段从 Flash 复制到 RAM谁清零.bss谁调用SystemInit()初始化时钟谁最终才把控制权交到你的main()手里WeAct STM32F411 是一块基于 ARM Cortex-M4 内核的国产高性价比开发板它没有操作系统没有 shell没有动态链接器一切都要从“上电复位”这个物理事件开始推演。本文不讲抽象理论只带你一帧一帧拆解从 VDD 上电、复位引脚拉低、PC 寄存器加载初始值、到main()函数第一行 C 代码被执行之间的完整链路。你会看到.text段如何被映射到 0x08000000中断向量表为何必须严格对齐__main符号注意这不是你写的main如何触发 C 运行时初始化以及为什么你在 Keil 或 GCC 下勾选“Use MicroLIB”或“Newlib Nano”会彻底改变整个启动流程。如果你曾被“HardFault”卡住数小时或疑惑“为什么加了printf就跑飞”或者只是单纯想撕开嵌入式开发那层“自动运行”的糖衣——这篇文章就是为你写的。它适合所有正在用 STM32 做项目、但对启动过程只有模糊概念的工程师、学生和爱好者无论你用的是 HAL 库、LL 库还是裸机寄存器操作。2. 启动流程全景图CPU 上电后执行的每一步都是硬件与软件精密合谋的结果2.1 上电复位硬件世界的“零点时刻”CPU 的“生命”始于一个物理信号——复位Reset。对于 WeAct STM32F411CEU6这是 WeAct 最常见的型号其复位源有多个上电复位POR、掉电复位PDR、外部复位引脚NRST、看门狗复位、软件复位等。但所有这些复位的终极效果只有一个强制 CPU 内部状态机回到初始态并将程序计数器PC寄存器设置为一个预定义的、由芯片设计者硬编码的地址。这个地址就是整个软件世界的“奇点”。ARM Cortex-M 系列内核包括 M0/M3/M4/M7遵循 ARMv7-M 架构规范其复位向量地址被固定为0x00000004。注意这不是 Flash 的起始地址0x08000000也不是 RAM 的起始地址0x20000000而是一个绝对的、由内核逻辑决定的偏移量。为什么是 0x00000004因为 Cortex-M 的中断向量表Interrupt Vector Table, IVT是一个固定结构其第一个条目索引 0存放的是初始堆栈指针Initial Stack Pointer, MSP的值第二个条目索引 1即地址 0x00000004存放的才是复位处理程序Reset Handler的入口地址。CPU 上电后做的第一件事就是从地址 0x00000000 读取一个 32 位字将其作为 MSP 的初始值再从地址 0x00000004 读取一个 32 位字将其作为 PC 的初始值然后开始取指执行。这个过程完全由硬件完成不依赖任何软件也不需要任何编译器参与。提示这里有个关键细节常被忽略——MSP 的初始值必须是一个有效的、指向 RAM 中合法栈空间的地址。如果这个值错误比如指向了未初始化的 Flash 区域CPU 在执行第一条指令时就会触发 HardFault因为压栈操作会失败。这也是为什么启动文件startup_stm32f411xe.s里.stack段的定义必须精确匹配你实际分配的 RAM 大小。2.2 向量表重映射从“影子地址”到“真实地址”的桥梁问题来了CPU 硬件要求从 0x00000000 开始读取向量表但 WeAct STM32F411 的 Flash 存储器物理地址是从 0x08000000 开始的。0x00000000 这个地址在芯片上对应什么答案是它通常映射到 Flash 的起始位置但这并非绝对。STM32F4 系列支持向量表重映射Vector Table Remap通过配置 SYSCFG-MEMRMP 寄存器可以将 0x00000000 这个地址空间动态地“指向”Flash、SRAM 或 System Memory内置 Bootloader。对于绝大多数 WeAct 开发板默认配置是Boot from Main Flash memory这意味着 0x00000000 被重映射到了 0x08000000。因此CPU 从 0x00000000 读取 MSP实际上是从 Flash 的 0x08000000 地址读取从 0x00000004 读取 Reset Handler 地址实际上是从 Flash 的 0x08000004 地址读取。这就是为什么你的固件二进制文件.bin 或 .hex的前 8 个字节如此重要它们必须是正确的 MSP 初始值和 Reset Handler 地址。如果你用objdump -h your.elf查看节区信息会发现.isr_vector段即中断向量表的 VMAVirtual Memory Address被链接器脚本如 STM32F411xE_FLASH.ld强制指定为 0x08000000确保它被放置在 Flash 的最开头。注意向量表重映射不是一次性设置就完事。它在系统复位后立即生效且在运行时可以通过软件修改但修改后必须执行SCB-VTOR 新地址;来更新内核的向量表偏移寄存器VTOR否则 CPU 仍会从旧地址取向量。这在实现 IAPIn-Application Programming或双 Bank 固件升级时至关重要。2.3 启动文件Startup Code汇编写的“总导演”当 CPU 从 0x08000004 取到 Reset Handler 的地址并跳转过去后它就进入了由开发者或更准确地说由工具链提供者编写的启动代码Startup Code。对于 STM32F411这个文件通常是startup_stm32f411xe.sKeil/ARMCC或startup_stm32f411xe.SGCC。它是一段纯汇编代码是整个 C/C 程序运行前的“奠基仪式”。它的核心任务不是执行业务逻辑而是为高级语言创造一个可运行的环境。这个过程可以分解为四个不可分割的阶段栈与堆的初始化定义.stack和.heap段设置 MSP主栈指针和 PSP进程栈指针如果使用的初始值。这部分代码会计算出栈顶地址通常是 RAM 末尾并将其写入 MSP。数据段复制Copy Down将存储在 Flash 中的已初始化数据.data段复制到其在 RAM 中的运行时地址。因为.data段里的变量如int global_var 10;需要在 RAM 中才能被修改而 Flash 只能读不能写。BSS 段清零Zero Out将 RAM 中未初始化或显式初始化为零的全局/静态变量.bss段所在内存区域全部置零。这是 C 标准要求的int uninit_var;的值必须是 0。C 运行时初始化与main调用调用SystemInit()由 ST 提供初始化时钟、Flash 等和__main由编译器提供执行更底层的 C 库初始化如浮点单元、I/O 流缓冲区等最后才bl main把控制权交给你的 C 代码。这四步环环相扣缺一不可。我曾经在一个项目中因为链接脚本里.data段的 LMALoad Memory Address和 VMAVirtual Memory Address配置反了导致Copy Down阶段把 Flash 里的数据复制到了错误的 RAM 地址结果main()一运行就访问非法内存HardFault 直接炸裂。所以理解启动文件就是理解你的程序“活下来”的第一道门槛。3. 核心细节解析.data、.bss、__main与SystemInit()的真实面目3.1.data与.bssRAM 里的“两块自留地”在嵌入式开发中.data和.bss是两个最基础、也最容易被误解的内存段。它们的存在直接源于冯·诺依曼架构下“程序与数据分离”的物理限制。.data段Initialized Data存放所有在源代码中被显式赋予初值的全局变量和静态变量。例如int g_initialized 42; // 存放在 .data 段 const char str[] Hello; // 存放在 .rodata 段只读数据 static float s_pi 3.14159f; // 存放在 .data 段这些变量的初始值必须被“固化”在非易失性存储器Flash中因为上电后 RAM 是随机值。但它们的“工作场所”必须在 RAM因为程序要随时修改它们。因此链接器脚本会为.data段分配两个地址LMALoad Memory Address即它在 Flash 中的存储位置和 VMAVirtual Memory Address即它在 RAM 中的运行时位置。启动代码中的Copy Down步骤就是一条memcpy指令将 LMA 处的一段 Flash 数据原封不动地拷贝到 VMA 处的 RAM 空间。.bss段Block Started by Symbol存放所有未初始化或显式初始化为零的全局变量和静态变量。例如int g_uninitialized; // 存放在 .bss 段 static char buffer[1024]; // 存放在 .bss 段 int g_zero 0; // 存放在 .bss 段编译器优化.bss段的特殊之处在于它在 Flash 中不占用任何空间因为它的内容全是零如果把几千字节的零都存进 Flash纯粹是浪费宝贵的存储资源。链接器只需要记录.bss段的起始地址VMA和长度SIZE启动代码在Zero Out阶段只需用一个循环或memset将这段 RAM 区域全部清零即可。这也是为什么.bss段的大小直接影响你的 RAM 使用量但它对 Flash 占用为零。实操心得在 WeAct STM32F411 的 512KB Flash 和 128KB RAM 限制下.bss段的大小是你调试内存溢出尤其是栈溢出的关键线索。如果你的程序在main()之前就 HardFault第一反应不是检查main()而是用arm-none-eabi-size your.elf命令查看.bss和.stack的总和是否超过了 128KB。我见过太多人因为定义了一个uint8_t big_array[100000];放在全局导致.bss直接吃掉 100KB RAM栈空间被挤占殆尽。3.2__main编译器藏在幕后的“大管家”很多初学者会混淆main()和__main。前者是你写的 C 函数后者是编译器ARMCC 或 GCC提供的一个符号它不是一个函数而是一个入口点标签entry point label指向一段由编译器生成的、高度优化的 C 运行时初始化代码。当你在 Keil MDK 中选择 “Use MicroLIB” 时__main会链接到 ARM 提供的精简版 C 库MicroLIB的初始化例程当你选择 “Use Standard Peripheral Library” 或在 GCC 下使用 Newlib__main则会链接到更完整的标准库初始化流程。__main的核心职责包括初始化 C 库的内部状态如stdin/stdout/stderr的文件描述符。设置浮点运算单元FPU的控制寄存器对于 Cortex-M4这一步至关重要否则浮点指令会触发 UsageFault。初始化atexit()注册的函数列表。如果启用了 C还会调用全局对象的构造函数__cpp_initialize__。最关键的是__main会再次调用SystemInit()如果它尚未被调用过并最终bl main。所以在标准的启动流程中SystemInit()实际上会被调用两次一次在汇编启动代码里为了尽快让系统时钟稳定以便后续的 Flash 操作和外设初始化另一次在__main里为了确保 C 库的时钟感知正确。这是一个设计上的冗余但也是为了兼容性和健壮性。注意__main是 ARMCC 工具链的约定。在 GCC 工具链如 arm-none-eabi-gcc中对应的符号是__libc_init_array或__do_global_ctors其功能类似但实现细节不同。这也是为什么跨工具链移植代码时启动流程的细节必须重新审视。3.3SystemInit()让 CPU 从“慢动作”切换到“全速档”SystemInit()是 ST 官方标准外设库SPL或 HAL 库中提供的一个 C 函数位于system_stm32f4xx.c文件中。它的唯一使命就是在main()执行之前将芯片的时钟树Clock Tree配置到用户期望的工作频率。对于 WeAct STM32F411其最高主频为 100MHz但出厂默认的 HSI内部高速 RC 振荡器只有 16MHz。如果不调用SystemInit()你的 CPU 将以 16MHz 运行所有外设如 UART、SPI、ADC的波特率和采样率都会严重偏离预期。SystemInit()的核心逻辑是配置 RCCReset and Clock Control寄存器。它会启用 HSE外部高速晶振WeAct 板载 8MHz。配置 PLL锁相环倍频系数将 8MHz 输入倍频至 100MHz例如HSE8MHz, PLLM8, PLLN100, PLLP2 → 100MHz。将 PLL 的输出SYSCLK作为系统时钟源。配置 AHB、APB1、APB2 总线的预分频器确保各外设总线时钟HCLK, PCLK1, PCLK2符合其最大工作频率要求例如APB1 ≤ 50MHz。这个函数是“半自动”的。它内部有一个#if defined (HSE_VALUE)的宏开关其默认值是 80000008MHz。如果你的 WeAct 板子焊接的是 12MHz 晶振而你没有在stm32f4xx.h中修改HSE_VALUE那么SystemInit()计算出的 PLL 参数将是错误的最终 SYSCLK 可能远低于 100MHz甚至导致 PLL 锁定失败RCC_CR 寄存器的 PLLRDY 位永远不置位程序卡死在while(__HAL_RCC_GET_FLAG(RCC_FLAG_PLLRDY) RESET)这一行。实操心得我建议在SystemInit()调用之后立刻添加一行__NOP();并用调试器单步观察RCC_CFGR寄存器的 SW[1:0] 位是否确实变成了10表示 PLL 作为系统时钟源以及RCC_CFGR的 HPRE、PPRE1、PPRE2 位是否符合你的配置。这是验证时钟初始化是否成功的最直接方法。4. 实操过程手把手构建一个“无库”启动工程亲眼见证 CPU 如何走向main()4.1 工程搭建从零开始拒绝任何“魔法”为了彻底理解启动过程我强烈建议你亲手搭建一个“裸机”Bare-Metal工程不使用 HAL、不使用 SPL、不使用任何中间件。我们将使用 GCC 工具链arm-none-eabi-gcc和 OpenOCD 进行调试。整个过程分为三步编写启动文件、编写链接脚本、编写最小main()。第一步编写startup_stm32f411xe.s.syntax unified .cpu cortex-m4 .fpu softvfp .thumb .global g_pfnVectors .global Default_Handler /* 中断向量表 */ g_pfnVectors: .word _estack /* Top of Stack */ .word Reset_Handler /* Reset Handler */ .word NMI_Handler /* NMI Handler */ .word HardFault_Handler /* Hard Fault Handler */ /* ... 后续向量省略需填满 84 个条目 */ .word 0 /* Reserved */ /* 栈定义 */ .section .stack,aw,%nobits .align 3 .equ Stack_Size, 0x400 .globl __stack_start__ .globl __stack_end__ __stack_start__: .space Stack_Size __stack_end__: /* 代码段 */ .section .text.Reset_Handler .thumb_func Reset_Handler: /* 1. 初始化栈指针 */ ldr sp, __stack_end__ /* 2. 复制 .data 段 */ ldr r0, _sidata /* Source address (Flash) */ ldr r1, _sdata /* Destination address (RAM) */ ldr r2, _edata /* End address (RAM) */ movs r3, #0 b CopyDataLoop CopyData: ldr r4, [r0], #4 str r4, [r1], #4 CopyDataLoop: cmp r1, r2 blt CopyData /* 3. 清零 .bss 段 */ ldr r0, _sbss ldr r1, _ebss movs r2, #0 b ZeroBssLoop ZeroBss: str r2, [r0], #4 ZeroBssLoop: cmp r0, r1 blt ZeroBss /* 4. 调用 SystemInit */ bl SystemInit /* 5. 调用 main */ bl main /* 6. 死循环 */ b . /* 所有未定义中断的默认处理 */ .thumb_func Default_Handler: b . /* 弱引用定义允许用户在 C 文件中重写 */ .weak NMI_Handler .thumb_set NMI_Handler,Default_Handler .weak HardFault_Handler .thumb_set HardFault_Handler,Default_Handler /* ... 其他弱引用 */这个汇编文件是整个工程的“心脏”。它定义了向量表、栈空间并实现了Copy Down和Zero Out的核心逻辑。注意ldr r0, _sidata这样的伪指令它告诉汇编器_sidata是一个符号其地址将在链接时确定。第二步编写STM32F411xE_FLASH.ld链接脚本/* 定义内存布局 */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K } /* 定义入口点 */ ENTRY(Reset_Handler) SECTIONS { /* 向量表必须放在 Flash 起始 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) /* 保留向量表 */ . ALIGN(4); } FLASH /* 代码段 */ .text : { . ALIGN(4); *(.text) *(.text.*) . ALIGN(4); _etext .; } FLASH /* 只读数据段 */ .rodata : { . ALIGN(4); *(.rodata) *(.rodata.*) . ALIGN(4); } FLASH /* 已初始化数据段 (.data) */ .data : AT (ADDR(.text) SIZEOF(.text)) { . ALIGN(4); _sdata .; *(.data) *(.data.*) . ALIGN(4); _edata .; } RAM /* 未初始化数据段 (.bss) */ .bss : { . ALIGN(4); _sbss .; *(.bss) *(.bss.*) *(COMMON) . ALIGN(4); _ebss .; } RAM /* 栈和堆 */ ._user_heap_stack : { . ALIGN(4); . . 0x400; /* 1KB heap */ . ALIGN(4); } RAM /* 定义一些有用的符号 */ PROVIDE ( _estack 0x20000000 128K ); PROVIDE ( _sidata LOADADDR(.data) ); }这个链接脚本是“地图”它告诉链接器arm-none-eabi-ld每个代码和数据段应该放在哪里。AT (...)关键字定义了.data段的 LMA加载地址而RAM定义了它的 VMA虚拟地址。PROVIDE语句则向汇编代码提供了_estack,_sidata等符号使其能正确寻址。第三步编写main.c#include stm32f411xe.h // 声明在 startup.s 中定义的符号 extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss; // 简单的 GPIO 初始化PA5 为 LED void GPIO_Init(void) { RCC-AHB1ENR | RCC_AHB1ENR_GPIOAEN; // 使能 GPIOA 时钟 GPIOA-MODER | GPIO_MODER_MODER5_0; // PA5 推挽输出 } // 简单的 SysTick 初始化1ms tick void SysTick_Init(void) { SysTick-LOAD 100000 - 1; // 100MHz / 1000 100000 SysTick-VAL 0; SysTick-CTRL SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk; } int main(void) { // 1. 初始化时钟此函数在 startup.s 中已调用一次此处为演示 // SystemInit(); // 2. 初始化外设 GPIO_Init(); SysTick_Init(); // 3. 主循环 while(1) { GPIOA-ODR ^ GPIO_ODR_ODR_5; // 翻转 PA5 for(volatile int i0; i100000; i); // 简单延时 } }这个main.c是整个故事的“主角”但它登场之前所有的“舞台布景”栈、数据、时钟都已被启动代码和SystemInit()搭建完毕。4.2 编译与调试用调试器“透视”启动全过程编译命令如下假设所有文件在同一目录# 1. 编译汇编文件 arm-none-eabi-gcc -mcpucortex-m4 -mthumb -mfpuvfp -mfloat-abihard \ -c startup_stm32f411xe.s -o startup_stm32f411xe.o # 2. 编译 C 文件 arm-none-eabi-gcc -mcpucortex-m4 -mthumb -mfpuvfp -mfloat-abihard \ -c main.c -o main.o # 3. 链接 arm-none-eabi-gcc -mcpucortex-m4 -mthumb -mfpuvfp -mfloat-abihard \ -T STM32F411xE_FLASH.ld -nostdlib -o firmware.elf \ startup_stm32f411xe.o main.o # 4. 生成二进制文件 arm-none-eabi-objcopy -O binary firmware.elf firmware.bin编译完成后用 OpenOCD 和 GDB 进行调试# 终端1启动 OpenOCD openocd -f interface/stlink-v2.cfg -f target/stm32f4x.cfg # 终端2启动 GDB arm-none-eabi-gdb firmware.elf (gdb) target remote :3333 (gdb) monitor reset halt (gdb) load (gdb) stepi # 单步执行从 Reset_Handler 开始此时GDB 会停在Reset_Handler的第一条指令ldr sp, __stack_end__。你可以用info registers查看sp寄存器的值确认它是否被设置为0x20000000 128K 0x20020000。然后按nnext逐行执行观察r0,r1,r2寄存器的变化亲眼见证.data是如何被复制.bss是如何被清零的。当执行到bl main时再用step进入main()你就完成了从“CPU 上电”到“C 语言世界”的完整穿越。实操心得在main()里第一行加一个__NOP();然后在 GDB 中break *mainrun程序会停在这里。此时用x/10xw 0x20000000命令查看 RAM 起始处的 10 个字你会发现它们已经被Zero Out过程清零了。这种“眼见为实”的调试比读一百页文档都管用。5. 常见问题与排查技巧实录那些让你抓狂的 HardFault其实都有迹可循5.1 HardFault 的黄金排查法从寄存器到堆栈的逆向追踪HardFault 是嵌入式开发者的头号敌人而它绝大多数时候都诞生于启动流程的某个环节。下面是我总结的、经过上百次实战验证的“黄金排查五步法”第一步看SCB-HFSRHardFault Status Register当 HardFault 发生时首先读取SCB-HFSR寄存器。如果其最低位FORCED为 1说明这是一个强制性的 HardFault原因可能是 MemManage、BusFault 或 UsageFault 被提升而来。此时你需要接着看SCB-CFSRConfigurable Fault Status Register。第二步看SCB-CFSR定位故障类型CFSR是一个 32 位寄存器其高 16 位是 MemManage、BusFault、UsageFault 的状态位。最关键的三个位是CFSR[BIT16](MMARVALID)如果为 1SCB-MMFAR寄存器里存着发生内存管理错误的地址。CFSR[BIT17](BFARVALID)如果为 1SCB-BFAR寄存器里存着发生总线错误的地址。CFSR[BIT25](UNALIGNED)如果为 1说明发生了未对齐访问例如用ldr指令读取一个非 4 字节对齐的地址。第三步看SCB-BFAR/SCB-MMFAR如果BFARVALID或MMARVALID为 1直接读取BFAR或MMFAR。这个地址就是“罪魁祸首”。它可能指向一个非法的 Flash 地址比如0xFFFFFFFF说明你的函数指针被初始化为 NULL然后被调用了。一个超出 RAM 范围的地址比如0x20030000而你的 RAM 只有 128KB说明.bss或栈溢出了。一个未使能时钟的外设寄存器地址比如0x40020000GPIOA说明你忘了调用RCC-AHB1ENR | ...。第四步看SCB-SHCSRSystem Handler Control and State Register这个寄存器告诉你哪个系统异常被触发了。如果USGFAULTENA位为 0而CFSR显示是 UsageFault那说明这个 Fault 被禁用了它会“升级”为 HardFault。这通常是因为你在SystemInit()之前就试图使用了 FPU 指令而 FPU 时钟尚未开启。第五步看SCB-ICSRInterrupt Control and State Register和堆栈ICSR[BIT30](VECTACTIVE) 显示当前正在执行的中断服务程序编号。如果它是 3HardFault那就对了。此时最关键的一步是手动回溯堆栈。在 GDB 中执行info registers找到sp栈指针的值然后用x/20xw $sp查看栈顶的 20 个字。根据 ARM AAPCSARM Architecture Procedure Call Standard规则栈顶的前几个字通常是r0-r3, r12, lr, pc, xPSR。其中lr链接寄存器就是发生 Fault 时上一级函数的返回地址pc就是 Fault 发生时的程序计数器。这两个地址就是你代码中出问题的精确位置。提示在startup_stm32f411xe.s中可以在Default_Handler里加入一段“死循环前的寄存器快照”代码将r0-r12, lr, pc, xPSR的值保存到一个全局数组中这样即使没有调试器也能通过读取 RAM 来分析 Fault 原因。5.2 “CPU 不认识 main()” 的典型场景与解决方案标题中的这句话在实际开发中会以多种具体形式出现。以下是我在 WeAct STM32F411 项目中遇到过的、最具代表性的三种场景场景一“程序根本不运行LED 也不闪”现象烧录固件后板子没有任何反应用 ST-Link Utility 也无法连接显示“Target not found”。原因最常见的是向量表地址错误。你的.isr_vector段没有被链接到0x08000000或者g_pfnVectors符号没有被正确导出。也可能是__stack_start__地址超出了 RAM 范围导致ldr sp, ...指令加载了一个非法地址CPU 在第一条
返回列表