欢迎光临
我们一直在努力

【STM32】 程序从源码到运行:编译、烧录与启动全流程详解(下)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}


    从通电到 main:STM32 启动流程深度拆解    

一、复位的本质:不是"重新跑一遍",而是状态机归零

        很多人把复位理解为"程序从头开始执行",这是结果而非本质。

复位真正做的事情是:

        让内核、总线矩阵和绝大多数外设的时序逻辑回到一个确定的初始状态,同时把时钟系统打回默认时钟源。

        理解这一点,才能解释为什么复位后外设配置全部丢失、时钟变回 HSI、但后备域(RTC/备份寄存器)可以保持。

1.1 复位源全景

STM32 的复位源分两个层级,它们的作用范围不同:

层级复位源触发条件作用范围
系统复位 NRST 引脚 外部低电平 内核 + 几乎全部外设
系统复位 看门狗 IWDG/WWDG 计数器溢出未喂狗 同上
系统复位 软件复位 NVIC_SystemReset() 置 SYSRESETREQ 同上
系统复位 CSS HSE 失效时钟安全系统 同上
电源复位 POR/PDR 上电/掉电越过电源阈值 系统复位 + 后备域
电源复位 BOR 欠压 brown-out 同上
域复位 退出 Standby 等 低功耗模式 视系列而定

        所有复位源的"案底"都会锁存进 RCC_CSR 的标志位(POR、PIN、IWDG、WWDG、SFT、BOR、LPWR 等)。

        启动代码可以据此判断本次启动原因——例如区分"正常上电"和"看门狗恢复",做不同的故障记录;判断完写 RMVF 位清标志。

1.2 NRST 电路与上电时序

        NRST 是带内部弱上拉的施密特触发输入,低电平有效。

典型板级电路只需要一个 100 nF 电容和一个按键:

  • 上电瞬间:电容两端电压不能突变,NRST 被钳在低电平,芯片保持复位;随着电源通过内部上拉电阻给电容充电,NRST 缓慢抬升,越过阈值后复位释放。这个延迟保证了 VDD 已经稳定、Flash 已唤醒。
  • 运行中按键:按下即把 NRST 直接对地短路,电容迅速放电,产生干净的低脉冲。

上电的完整时间线其实是:

        VDD 爬坡 → POR/BOR 比较器判定电压合格 → 内部复位释放 → HSI 启动稳定 → 内核开始取指。

        所以"上电到第一条指令"的延迟由电源斜率、POR 延时和 HSI 启动时间共同决定,低功耗设计中这些参数都在数据手册电气章节给出。

1.3 看门狗为什么能"救场"

        程序跑飞(死循环、指针乱跳、死锁)时,CPU 已经无法正常执行喂狗逻辑,IWDG 由独立低速时钟(LSI)驱动,不受主时钟和软件故障影响,溢出后直接拉系统复位——这是一个独立于内核的硬件逃生通道。

        复位后一切重新开始,这就是看门狗作为最后可靠性保障的原理。


二、地址空间的障眼法:0x0 到底是什么

2.1 Cortex-M 的两条铁律

ARMv7-M 架构把复位定义为优先级最高(编号 –3)的特殊异常,并硬性规定复位序列的取指地址:

  • 0x00000000:存放初始 MSP 值(一个 32 位地址);
  • 0x00000004:存放复位向量(Reset_Handler 的地址)。

这是内核固化的行为,不可配置。但 STM32 的物理存储器地图是这样的:

物理存储器地址特性
主 Flash 0x08000000 起 可读写(按页擦除)、非易失
System Memory 0x1FFF0000(F4)/0x1FFFF000(F1) 出厂 ROM Bootloader,只读
SRAM 0x20000000 起 易失、可随机读写
外设区 0x40000000 起 AHB/APB 寄存器

矛盾出现了:

        内核要从 0x0 取向量,但物理上没有任何存储器在 0x0。

        解决办法就是地址别名(Alias)/ 重映射。

2.2 别名机制的硬件实现

 芯片内部的总线矩阵在地址解码阶段做了一层"转发":

        0x00000000 开头的启动窗口并不对应独立的物理存储,而是根据复位时锁存的 BOOT 配置,把访问请求路由到三个物理区之一:

BOOT0BOOT10x0 别名指向实际执行的代码
0 x 0x08000000 主 Flash 用户程序
1 0 System Memory ST 出厂 Bootloader(串口/DFU 救援)
1 1 0x20000000 SRAM 调试代码(掉电丢失)

注意三点深层事实:

  • BOOT 只在复位释放瞬间采样一次,运行中拨引脚无效,下次复位才生效;
  • 映射是纯地址解码,不复制任何数据。Flash 启动模式下,同一份向量表在 0x0 和 0x08000000 两个地址都能访问;
  • 新系列(F7/H7/G4/L4 等)引入选项字节 nBOOT_SEL、BOOT_LOCK:当 BOOT_LOCK 置位时外部 BOOT0 引脚被忽略,强制 Flash 启动,这也是为什么很多新芯片 BOOT0 不接线也能正常跑。
  • 2.3 易混点:MEMRMP 与 VTOR 不是一回事

    这是工程中高频混淆的两个机制:

    • MEMRMP(F4 起 SYSCFG,F1 在 AFIO_MAPR):操作的是总线级地址解码,改变 0x0 别名区的物理指向,影响所有主设备(包括 DMA)对该地址的访问;
    • SCB->VTOR:操作的是内核取异常向量的基地址,只影响 CPU 响应异常时去哪里取向量,不改变总线映射。

    典型组合用法:

            Flash 启动后,应用程序在 SystemInit 里把 VTOR 设为 0x08000000,之后即使别名映射改变,中断也能正确找到向量表。


    三、复位序列:硬件在第一条指令前替你做了什么

    复位释放后,不需要任何软件指令,硬件自动完成以下动作,这在 ARMv7-M 手册里称为 Reset Sequence:

  • 从 0x0 读取一个 32 位字 → 装入 MSP;
  • 从 0x4 读取复位向量 → 装入 PC;
  • 向量 bit0 必须为 1:硬件把它解释为 Thumb 状态标志,装入 PC 时 bit0 自动清 0(取指地址半字对齐),同时 EPSR.T 置 1。如果向量 bit0 为 0,取指即触发 HardFault(INVSTATE);
  • 复位作为特殊异常不压栈——因为不存在"被中断的前一个上下文"需要保存;
  • CPU 以 Thread 模式、特权级、MSP 启动;NVIC 中所有普通中断使能位为 0,所以启动阶段只有 NMI/HardFault 能打断;时钟使用 HSI。
  • 设计哲学:为什么硬件要代劳这些

    对比经典的 ARM7TDMI(ARMv4T):

            它复位后 PC 直接为 0,栈指针要软件初始化,IRQ 进入后软件读取向量、软件保存现场、手动判断中断源。

            而 Cortex-M 把异常模型整体硬件化——向量表自带 MSP、NVIC 自动向量化、硬件自动压栈。

    带来的收益是:

            启动代码大幅简化、中断延迟确定且极短(无需软件保存现场)、C 程序可以"第一时间"安全运行。这是微控制器定位下对确定性和易用性的刻意设计。


    四、启动文件深度剖析:硬件与 C 世界之间的契约

    启动文件(startup_stm32xxxx.s)不是普通的汇编源码,它同时承担三个角色:

            向链接器声明符号、向内核提供向量表、向 C 程序提供入口。

    4.1 栈与堆的声明

    Stack_Size EQU 0x00000400
    AREA STACK, NOINIT, READWRITE, ALIGN=3
    Stack_Mem SPACE Stack_Size
    __initial_sp
    Heap_Size EQU 0x00000200
    AREA HEAP, NOINIT, READWRITE, ALIGN=3
    __heap_base
    Heap_Mem SPACE Heap_Size
    __heap_limit

       ALIGN=3 表示 2³=8 字节对齐;

       NOINIT 表示上电不预初始化(反正 .bss 流程会处理/栈不需要初值);

       __initial_sp 是链接器计算出的栈顶符号,正是向量表第一项引用的值。

    4.2 向量表:DCD 构建的一张函数指针表

    AREA RESET, DATA, READONLY
    EXPORT __Vectors
    __Vectors DCD __initial_sp ; 0x00: MSP 初值
    DCD Reset_Handler ; 0x04: 复位向量
    DCD NMI_Handler ; 0x08
    DCD HardFault_Handler ; 0x0C
    …
    DCD SysTick_Handler ; 系统异常末尾
    DCD WWDG_IRQHandler ; 外部中断开始
    …

       DCD 就是分配一个 4 字节常量。

            从 C 视角看,向量表就是一张位于 Flash 起始处的函数指针数组,只不过第 0 项不是函数地址而是栈顶。

    4.3 弱符号:没写中断函数为什么也能编译链接

    每个中断向量都以 [WEAK] 声明别名:

    Default_Handler PROC
    EXPORT WWDG_IRQHandler [WEAK]
    …
    B . ; 死循环,停在原地
    ENDP

    [WEAK] 是弱定义:

            你在 C 文件中写了同名函数,强符号覆盖弱符号,向量自动指向你的 ISR;没写,则所有未实现中断都落到 Default_Handler 的 B . 死循环。

            这就是"中断没开函数也能烧录,但一旦触发就死机"的底层原因——调试时看到 PC 停在 Default_Handler,即说明触发了未实现的中断。

    4.4 Reset_Handler 逐行拆解

    Reset_Handler PROC
    EXPORT Reset_Handler [WEAK]
    IMPORT SystemInit
    IMPORT __main
    LDR R0, =SystemInit
    BLX R0
    LDR R0, =__main
    BX R0
    ENDP

    整个复位处理程序短得惊人:

            调 SystemInit(VTOR、底层时钟),然后 BX 跳进 C 库的 __main。

            注意是 BX 而非 BL——不打算返回,没有返回地址需要保存。

    4.5 两条工具链路径差异

    环节Keil + ARMCC(armclang)GCC + STM32CubeIDE
    数据搬运 隐藏在 C 库 __main 的分散加载中 Reset_Handler 里手写拷贝/清零循环
    库入口 __main → __rt_entry __libc_init_array 后直接 bl main
    链接描述 分散加载文件 .sct 链接脚本 .ld
    C++ 构造 __rt_lib_init 自动调用 __libc_init_array 遍历 init_array

    GCC 启动文件中的搬运逻辑本质是两段循环:

    CopyDataInit:
    LDR R4, [R2, R3] ; 从 _sidata 读
    STR R4, [R0, R3] ; 写到 _sdata
    ADDS R3, #4
    LoopCopyDataInit:
    ADDS R4, R0, R3
    CMP R4, R1
    BCC CopyDataInit
    FillZerobss:
    MOVS R3, #0
    STR R3, [R2]
    ADDS R2, #4
    LoopFillZerobss:
    CMP R2, R4
    BCC FillZerobss


    五、C 运行环境:镜像态与运行态的分离

    5.1 为什么必须有"运行时初始化"

            编译产物是一份静态镜像,躺在 Flash 里;

            而 C 程序的运行需要一个动态执行态。

    两者的根本矛盾在于:

    • 全局变量必须可写,但 Flash 只能按页擦除、不能像 RAM 一样随机写;
    • 函数运行需要栈,而栈在 RAM;
    • 未初始化变量 C 标准保证初值为 0,但 RAM 上电内容是随机的。

    所以启动期必须完成"镜像 → 运行态"的转换,这就是分散加载存在的意义。

    5.2 段(Section)全景

    段存放位置(链接时)运行位置内容启动动作
    .isr_vector Flash Flash 向量表 无需搬运
    .text Flash Flash 机器指令 原地取指
    .rodata Flash Flash const、字符串字面量 原地读取
    .data Flash(初值) SRAM 有非零初值的全局/静态变量 拷贝
    .bss 不占镜像空间 SRAM 零初始化全局/静态变量 清零
    heap — SRAM malloc 动态分配 库初始化边界
    stack — SRAM 栈帧 MSP 已就位

    5.3 链接脚本符号:软件与内存布局的接口

    GCC .ld 中的关键符号必须和启动汇编严格对应:

    _estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶,RAM 最高地址 */
    _sidata = LOADADDR(.data); /* .data 在 Flash 的加载地址 */
    _sdata = .; … _edata = .; /* .data 在 RAM 的起止 */
    _sbss = .; … _ebss = .; /* .bss 在 RAM 的起止 */

        LOADADDR(.data) 取的是加载地址(LMA,在 Flash),而 .data 的运行地址(VMA)在 RAM——LMA/VMA 分离正是数据搬运的本质:同一份字节,烧录时在 Flash,运行时在 RAM。

    5.4 __main 与 __rt_entry 内部

    ARMCC 下 __main 不是一个函数,而是 C 库入口的统称,依次执行:

  • 解压/展开代码与 RW 数据(Keil 支持 RW 压缩以省 Flash);
  • 按分散加载描述拷贝 .data、清零 .bss(ZI 段);
  • 进入 __rt_entry:初始化栈与堆(__user_initial_stackheap)、__rt_lib_init 初始化 C 库并执行 C++ 全局对象构造函数;
  • 调用用户 main();main 返回后进入 exit() 流程。
  •         嵌入式工程中 main 绝不返回;

            若真的返回,默认行为是进入库退出路径(裸机通常表现为死循环或 semihosting 退出)。


    六、栈机制深度专题

    6.1 满递减栈与寄存器分工

    Cortex-M 实现的是满递减(Full Descending):

            SP 始终指向最后一个已占用字。

            PUSH 先 SP−4 再存储,POP 先读取再 SP+4。

    两个栈指针通过 CONTROL 寄存器切换:

    • MSP:复位默认;Handler 模式(任何异常/中断)下硬件强制使用;
    • PSP:仅 Thread 模式可用;RTOS 中任务线程用 PSP,内核异常仍用 MSP,实现任务栈与内核栈隔离。

    6.2 异常硬件栈帧

            异常进入时,硬件按固定顺序把 8 个寄存器压当前栈(见信息图③):xPSR、PC、LR、R12、R3、R2、R1、R0。

            设计上特意压 R0, R3 和 R12,是因为它们在 AAPCS 中是**调用者保存(caller-saved)**寄存器,而 R4R11 是被调用者保存,ISR 作为"被调用方"按需自行保存即可。

            异常结束时硬件弹出栈帧,并依据 EXC_RETURN 值(如 0xFFFFFFF9 返回 MSP/Handler、0xFFFFFFFD 返回 PSP/Thread)决定返回哪个栈和模式。        

            CCR.STKALIGN 默认可保证异常时 SP 8 字节对齐,必要时插入一个 padding 字;带 FPU 的 M4/M7 启用惰性压栈后还会额外保存 S16~S31 与 FPSCR(约 66 字节)。

    6.3 AAPCS 视角下的函数栈帧

    `        C 函数调用时,R0R3 传前 4 个参数、R0 返回结果;

            超出的参数走栈;

            R4R11 若被使用必须由函数自己 PUSH 保存。

    一个典型栈帧从高到低包含:

            调用者 R4~R7、局部变量、传给子函数的溢出参数。

            这也是为什么"局部变量地址"一定落在 RAM 的栈区。

    6.4 栈大小怎么定,溢出怎么查

    工程估算法:

  • 画出最深调用链(含中断嵌套),逐函数估算栈帧(局部变量 + 保存寄存器,按 8 字节对齐);
  • 每个中断 ISR 至少预留硬件栈帧 32 字节 + 自己的局部变量;嵌套层数乘进去;
  • 带 FPU 的 ISR 加约 66 字节;
  • 留 20%~30% 余量。
  • 检测手段:

    • 栈水印法:启动时把栈区填成固定魔术值(如 0xA5A5A5A5),运行后统计未被踩过的最深位置,实测高水位;
    • MPU 保护:在栈底设一个不可访问区域,溢出即触发 MemManage Fault;
    • 链接期检查:Keil 可生成 map 文件查看栈/堆与 .bss 是否撞上。

    6.5 再谈 0x0 与 NULL

      NULL 是 C 标准定义的空指针宏(通常 (void*)0),语义是"不指向任何对象",解引用它属于未定义行为。

            而地址 0 在 STM32 上是有效别名区,存放向量表。

            两者只是数值恰好相同,分属语言语义和硬件地址两个层面。

            对 0 地址做 *(uint32_t*)0 读取在硬件上完全合法——启动硬件自己就是这么读 MSP 的。


    七、进入 HAL:从库初始化到主循环

    7.1 HAL_Init 做了什么

    • 配置 Flash 预取指、ART Accelerator/I-Cache/D-Cache(视系列);
    • 配置 SysTick 产生 1 ms 中断,提供 HAL 时基(HAL_GetTick、HAL_Delay、所有带 timeout 的 API);
    • 设置 NVIC 优先级分组 4:4 位全部用于抢占优先级,无子优先级拆分。

    7.2 SystemClock_Config:时钟树的重建

            复位后系统跑 HSI(精度差、频率低)。

    该函数完成:

            选 HSE/HSI 作 PLL 源 → PLL 倍频 → AHB/APB1/APB2 分频 → 按新频率设置 Flash 等待周期(WS)(频率越高,Flash 读取需要的等待状态越多,否则取指出错)→ 切换系统时钟源并回读标志确认稳定。

    7.3 外设初始化与主循环

    MX_GPIO_Init、MX_USARTx_Init 等顺序执行:

            先 __HAL_RCC_xxx_CLK_ENABLE() 开时钟,再写模式/复用/波特率等寄存器——顺序不能反,时钟未开时写外设寄存器无效。

    最后进入 while(1),系统转为"主循环 + 中断驱动"模型:

            后台跑轮询逻辑,前台由 NVIC 异步响应事件,ISR 用 MSP 压栈、执行、出栈返回。


    八、进阶视角

    8.1 Bootloader 跳转 App 的标准动作

    typedef void (*p_jump_func)(void);
    static void JumpToApp(uint32_t app_addr)
    {
    uint32_t app_sp = *(uint32_t *)app_addr;
    p_jump_func app_entry = (p_jump_func)(*(uint32_t *)(app_addr + 4));

    HAL_RCC_DeInit();
    HAL_DeInit();
    SysTick->CTRL = 0;
    SysTick->LOAD = 0;
    SysTick->VAL = 0;
    __disable_irq();

    SCB->VTOR = app_addr;
    __set_MSP(app_sp);
    __enable_irq();
    app_entry();
    }

    关键点:

            跳转前关中断并复位外设/SysTick(否则 App 里残留 ISR 会直接炸)、重设 VTOR、用向量表第一项重装 MSP、再按第二项跳转。

            App 起始地址必须与它的链接脚本偏移(如 0x08008000)和中断向量偏移一致。

    8.2 RTOS 启动时栈的切换

    FreeRTOS 流程:

       main 里创建任务后调 vTaskStartScheduler → 每个任务有独立栈,任务控制块保存 PSP 初值 → SVC/PendSV 中把 CONTROL.SPSEL 切到 PSP、加载任务栈帧 → 中断仍走 MSP。

            所以裸机单栈模型在 RTOS 下变成"一内核栈 + N 任务栈",这正是 MSP/PSP 双栈设计的用武之地。

    8.3 启动失败排查清单

    现象高概率原因定位方向
    调试器读不到 IDCODE 接线/共地/SWCLK 太高/调试口被关 降频、查线、System Memory 救援
    一启动就 HardFault 向量 bit0 错、栈顶非法、启动期中断 查 SCB CFSR、向量表、栈大小
    全局变量值随机 .data 未搬运/链接符号不匹配 查 .ld 与启动文件符号
    跑一会死机 栈溢出撞 .bss/heap 栈水印、map 文件
    主频上不去/取指异常 Flash WS 未随频率设置 查 latency 配置
    进中断出不来 未实现 ISR 落 Default_Handler 看 PC 位置、补中断函数

    九、高频面试题

    1. 从上电到 main 的完整链路?

    面试口述版

            "整个过程分硬件自动和软件初始化两大阶段。

            上电后首先由 POR/BOR 完成电源复位,HSI 时钟启动;

            复位释放时硬件采样 BOOT 引脚确定启动模式,并把 0x0 地址别名映射到对应的物理存储器。

            然后 CPU 硬件自动完成两件事:从 0x0 取栈顶值装入 MSP,从 0x4 取复位向量装入 PC,Thumb 位置 1。

            接下来执行启动文件里的 Reset_Handler,先调 SystemInit 做 VTOR 和底层初始化,再跳进 C 库入口 __main;

            __main 负责分散加载——把 .data 初值从 Flash 拷到 RAM、.bss 清零、初始化堆和 C 库,

            最后才调用用户的 main,里面依次是 HAL_Init、SystemClock_Config、外设初始化和 while 循环。"

    分点记忆版

    阶段关键动作
    1. 电源复位 POR/BOR 判定电压 → 系统复位 → HSI 成为默认时钟
    2. 硬件取指 采样 BOOT → 0x0 别名映射 → 硬件装 MSP(0x0)、装 PC(0x4)
    3. 启动文件 Reset_Handler → SystemInit → __main(GCC 为手写搬运循环)
    4. C 库与用户 拷贝 .data、清零 .bss、建堆 → 调 main → HAL_Init/时钟/外设

    回答时主动埋的 3 个加分点(引导面试官追问)

  • "向量表第一项不是函数,是 MSP 初值"——体现懂硬件复位序列;
  • "__main 是 C 库入口,不是用户 main"——体现懂分散加载;
  • "CPU 逻辑上从 0x0 取指,物理上读的是 0x08000000 的 Flash"——体现懂地址别名。
  • 追问预案(大概率接着问)

    • 为什么硬件先装 MSP? 第一条指令后的函数调用和中断都要压栈,SP 必须先有效;
    • .data 和 .bss 区别? .data 有非零初值、从 Flash 拷贝;.bss 零初始化、启动清零;
    • 复位为什么不压栈? 没有被中断的前一个上下文需要保存;
    • BOOT 引脚作用? 决定 0x0 别名指向 Flash、System Memory 还是 SRAM。

    2. 向量表第 0、1 项分别是什么?为什么第 0 项是栈顶而不是函数?

    • 第 0 项:MSP 初值(__initial_sp,栈顶地址,如 0x20000400);
    • 第 1 项:Reset_Handler 地址(复位向量)。

    原因是 Cortex-M 的复位序列设计为"先建栈、后取指":

            第一条指令执行后立刻会发生函数调用(BL 压 LR)和可能的中断(硬件压栈),所以 SP 必须在任何指令之前就有效。

            硬件把向量表第 0 项直接加载进 MSP,不需要一条初始化指令。

            它装的是数据地址(栈顶)而非可执行入口,所以不是函数指针。

    3. 0x0 地址与 NULL 空指针的关系?

    两者只是数值恰好相同,属于两个不同层面:

    • NULL:C 语言语义,(void*)0 表示"不指向任何有效对象",解引用属于未定义行为;
    • 0x0 物理地址:STM32 上是真实有效的别名区,复位后经 BOOT 映射到 Flash/System Memory/SRAM,存放向量表。硬件没有"空"概念,地址 0 和其他地址一样被解码访问,启动硬件自己就在读 0x0。

    对比:

            在带 MMU 的 Linux 上访问 0 地址会因页表无映射触发缺页异常,那是软件页表机制;MCU 无 MMU,0 地址物理上始终有效。

    4. 地址别名(MEMRMP)与向量表重定位(VTOR)的区别?

    维度MEMRMP(SYSCFG/AFIO)VTOR(SCB)
    所在层级 总线矩阵地址解码 内核异常控制
    作用 改变 0x0 别名区指向的物理存储器 决定异常时去哪里取向量表
    影响对象 所有总线主设备(CPU、DMA) 仅 CPU 取向量动作
    典型修改者 启动硬件/系统软件 应用程序、Bootloader

    记忆法:

            MEMRMP 管"地址落到哪块物理区",VTOR 管"中断向量从哪张表取"。

            两者互不替代,实际工程常同时使用——硬件映射保证启动取指,VTOR 设 0x08000000 保证运行后中断定位。

    5. 复位后 CPU 的模式、特权级、栈、中断、时钟各是什么状态?

    • 处理器模式:Thread(线程模式);
    • 特权级别:Privileged(特权级);
    • 栈指针:MSP(PSP 未启用);
    • 指令集状态:Thumb(EPSR.T=1),只支持 Thumb/Thumb-2;
    • 中断状态:NVIC 所有 ISER 为 0,普通中断全部禁用;PRIMASK、FAULTMASK、BASEPRI 均为 0;仅 NMI、HardFault 可响应;
    • 时钟:系统时钟回退到 HSI(F1 为 8 MHz,F4 为 16 MHz),PLL 关闭;
    • 全部外设寄存器回到数据手册标注的复位默认值。

    6. 复位异常为什么不压栈?

            压栈的唯一目的是保存"被打断的执行上下文",以便异常返回后恢复。

            但复位发生时,根本不存在前一个正在运行的程序、也没有需要恢复的寄存器状态——所有内容都要从零初始化。

            而且复位是优先级最高(–3)的异常,没有"返回被中断代码"这一说,硬件直接从向量表加载 MSP/PC 开始全新执行,所以不压栈。

    7. MSP 与 PSP 的区别?RTOS 如何使用?

    • MSP(主栈指针):复位后默认栈;Handler 模式(任何异常/中断)下硬件强制使用;
    • PSP(进程栈指针):仅 Thread 模式可用,通过 CONTROL.SPSEL 切换。

    RTOS 用法:

            每个任务创建时分配独立任务栈并在 TCB 中记录其 PSP 初值;

            任务运行在 Thread 模式、使用 PSP;

            中断和内核代码仍走 MSP。

            任务切换时 PendSV 把当前寄存器压入旧任务的 PSP 栈、从新任务的 PSP 栈恢复。        

            这样任务栈彼此隔离、大小可按任务定制,内核栈与任务栈形成故障域分离。

    8. 异常进入时硬件压入哪 8 个寄存器?为什么是这 8 个?

    按地址高到低:xPSR、PC、LR、R12、R3、R2、R1、R0,共 32 字节。

    选择依据是 AAPCS 调用约定:

    • **R0~R3、R12 是调用者保存(caller-saved)**寄存器,ISR 作为"被调用方"可以直接使用它们而不负责保存,所以硬件必须替被中断代码预先保存;
    • R4~R11 是被调用者保存(callee-saved),ISR 若用到会自己 PUSH,硬件不必代劳;
    • PC 保存异常返回地址,xPSR 保存异常前的 ALU 标志与状态,LR 保留调用链信息。

    这套设计让 ISR 能用普通 C 函数约定直接编写,同时把硬件栈帧压到最小,缩短中断延迟。

    9. EXC_RETURN 的作用?

            异常进入时硬件把一个特殊值装入 LR,即 EXC_RETURN,异常返回(BX LR)时硬件靠它判断"回到什么模式、用哪个栈、是否含 FPU 帧",而不是把它当普通地址跳转。

    常见值:

    值含义
    0xFFFFFFF1 返回 Handler 模式,MSP
    0xFFFFFFF9 返回 Thread 模式,MSP
    0xFFFFFFFD 返回 Thread 模式,PSP
    0xFFFFFFE1/E9/ED 对应三种组合的"非基础帧"(含 FPU 扩展栈)

    它解决了"异常返回路径有多种组合"的硬件识别问题,全部由内核自动生成、自动解析。

    10. .data 和 .bss 的区别?谁在什么时候初始化?

    项.data.bss
    变量类型 有非零初值的全局/静态变量 未初始化或初值为 0 的全局/静态变量
    运行位置 RAM RAM
    Flash 占用 初值镜像占 Flash(LMA) 不占镜像,只预留 RAM
    启动动作 从 Flash 拷贝初值 整段写 0

    执行时机都在 main 之前:

            ARMCC 由 __main 的分散加载完成;

            GCC 由 Reset_Handler 中的 CopyDataInit/FillZerobss 循环完成。

           注意局部变量不属于这两个段,运行时在栈上动态分配,不参与启动初始化。

    11. LMA 与 VMA 分别是什么?

    • VMA(Virtual/Run Memory Address):段的运行地址,链接器按此解析符号、重定位,CPU 运行时访问的地址;
    • LMA(Load Memory Address):段在镜像中的加载地址,即烧录到 Flash 的位置。

            .text/.rodata 的 LMA=VMA(都在 Flash,原地执行);

            .data 的 VMA 在 RAM、LMA 在 Flash(_sidata),二者分离正是启动搬运的原因——复位后代码把字节从 LMA 复制到 VMA,程序才能按链接地址正确访问变量。

    12. __main 和用户 main 的区别?__rt_entry 做什么?

    • __main(双下划线):ARM 编译器 C 库提供的入口,不是用户代码。负责执行分散加载:拷贝 RW(.data)、清零 ZI(.bss)、必要时解压镜像;
    • 完成后进入 __rt_entry,它依次:初始化栈与堆(__user_initial_stackheap)→ 调 __rt_lib_init 初始化 C 库、执行 C++ 全局对象构造 → 注册退出处理 → 最后调用用户 main();
    • main 返回后进入 exit() 路径。裸机 main 不应返回。

    GCC 下没有 __main 符号,等价工作由启动汇编和 __libc_init_array 显式完成。

    13. 弱符号 [WEAK] 机制如何工作?

    弱符号是链接器规则:

            同名的强符号会覆盖弱符号,没有强符号时弱符号生效。

            启动文件把每个中断向量以 [WEAK] 导出并指向统一的 Default_Handler(B . 死循环)。

    • 用户在 C 文件写了同名 ISR(强符号)→ 链接器选用强符号,向量表项自动指向用户函数;
    • 没写 → 保留弱符号,中断触发后落入 Default_Handler 原地死循环。

    价值:启动文件无需为每个用户定制,未实现的中断有确定性兜底,避免跳转到随机地址。

    14. ARMCC 与 GCC 启动流程的核心差异?

    功能完全等价,差异在"库代劳 vs 手写显式":

    环节ARMCC(Keil)GCC(CubeIDE)
    内存布局 分散加载文件 .sct 链接脚本 .ld
    数据搬运/清零 隐藏在 C 库 __main 内 Reset_Handler 手写两段循环
    库/C++ 初始化 __rt_entry、__rt_lib_init __libc_init_array
    入口跳转 BX __main bl main

    15. 如何估算栈大小?栈溢出有哪些检测手段?

    估算方法:

  • 找出主调用链最深路径,逐函数累加局部变量、保存寄存器、溢出参数,按 8 字节对齐向上取整;
  • 每个中断 ISR 计:硬件栈帧 32 字节(启用 FPU 加约 66 字节)+ ISR 局部变量 + 其调用链;
  • 按最坏情况下的中断嵌套组合(不是简单乘总 ISR 数,而是实际可能同时嵌套的集合)累加;
  • 留 20%~30% 余量。
  • 检测手段:

    • 栈水印法:启动时把栈填魔术值(0xA5A5A5A5),运行后检查未被改写的位置,得到高水位;
    • MPU 保护:栈底设不可访问区,溢出触发 MemManage Fault;
    • 金丝雀(Canary):栈帧边界放哨兵值,返回前检查;
    • RTOS 钩子:FreeRTOS 的 vApplicationStackOverflowHook;
    • 静态 map 文件:查看栈、堆、.bss 的链接边界是否碰撞。

    16. Bootloader 跳转 App 的完整步骤?为什么要重装 MSP?

    步骤:

  • 停止/复位外设与 SysTick,HAL_RCC_DeInit、HAL_DeInit,关闭外设时钟;
  • __disable_irq() 关中断,防止跳转途中触发 ISR;
  • 从 App 起始地址读第 0 项(栈顶),__set_MSP 重装;
  • 设 SCB->VTOR = App 基址,重定位向量表;
  • 从 App 基址 +4 取复位向量,函数指针跳转;App 内部再按需开中断。
  • 重装 MSP 的原因:

            Bootloader 与 App 是分别独立链接的两个程序,各自有独立栈空间。

            若沿用 Bootloader 的 MSP,App 的函数调用和中断压栈会写到 Bootloader 栈上,边界错误且可能与 Bootloader 残留数据互相破坏。

            只有按 App 向量表重装 MSP,App 才运行在自己的合法栈域内。

    17. RCC_CSR 能提供什么信息?

    该寄存器锁存本次复位的来源标志,主要包括:

    • PORRSTF:上电/掉电复位;
    • PINRSTF:NRST 引脚复位;
    • BORRSTF:欠压复位;
    • SFTWRSTF:软件复位;
    • IWDGRSTF / WWDGRSTF:独立/窗口看门狗复位;
    • LPWRRSTF:低功耗复位。

            上电时 PORRSTF 与 PINRSTF 常同时置位(NRST 在上电过程也经历低电平)。

            写 RMVF 位可一次性清除全部标志。软件据此做复位原因统计、故障日志和差异化初始化。

    18. 看门狗复位的硬件独立性体现在哪里?

    以 IWDG 为例:

    • 它由**独立的 LSI 低速时钟(约 32 kHz)**驱动,不依赖系统主时钟(HSE/HSI/PLL);
    • 计数器是独立硬件时序逻辑,即使 HSE 损坏、PLL 失锁、CPU 取指跑飞或进入死循环,计数器仍照常递减;
    • 溢出后直接产生系统复位信号,不需要 CPU 执行任何指令。

            因此它是一条独立于内核和主时钟之外的"最后逃生通道",这正是软件看门狗(依赖内核调度)在高安全等级场景不被信任的原因。

    19. 为什么时钟配置里要设 Flash 等待周期?

            Flash 的物理访问速度远低于 CPU 最高频率(一次读取需要数十 ns,对应频率远低于 168 MHz 这类系统时钟)。

            当 CPU 时钟周期短于 Flash 访问时间时,一个周期内 Flash 来不及输出有效指令/数据,必须插入若干等待状态(Wait State, WS/Latency),让总线等待 Flash 读完成。

            WS 数量按"供电电压范围 + 目标频率"查手册表格确定(如 F4 在 3.3V、168 MHz 需 5 个 WS),并配合预取指、ART 加速器、I-Cache 补偿等待损失。

            配错 WS 会取指/读数据错误,表现为随机 HardFault 或跑飞。

    20. 触发未实现的中断会发生什么?

    由于启动文件中所有未实现的中断向量都以弱符号指向 Default_Handler,其代码是 B .(跳转到自身的死循环),所以中断一旦触发:

  • 硬件正常压栈并跳到该向量;
  • PC 卡在 Default_Handler 无限循环,中断标志若未清除还可能反复进入;
  • 系统表面"死机",调试器暂停时能看到 PC 停在 Default_Handler,从而判定是哪个未实现中断(结合向量表序号/NVIC 活动位)。
  • 排查方法:

            实现对应 ISR 清除中断标志,或在调试器中查看 SCB->ICSR 和 NVIC IABR 定位活跃中断源。

    整条启动链可以浓缩成三条主线:

  • 地址线:BOOT 采样决定 0x0 别名指向哪块物理存储器,解决"内核铁律与物理地址不符"的矛盾;
  • 控制线:硬件从别名区取走 MSP 与 PC,把内核置于确定的特权/Thumb/禁中断状态,启动文件再经 SystemInit 和 __main 完成 C 运行环境(.data 搬运、.bss 清零、栈堆就绪);
  • 执行线:HAL 重建时钟与外设后进入主循环,异常事件由硬件栈帧和 NVIC 确定性地接管。
  • 21.为什么Flash 固定从 0x08000000 开始 ?

    一、根源:Cortex-M 的 4GB 地址地图是架构固定的

    ARMv7-M 把 32 位寻址空间(4GB)硬性划分成若干功能区,所有厂商一致:

    地址范围区域用途
    0x00000000–0x07FFFFFF Code 区·启动别名区(128MB) 复位取指窗口,可重映射
    0x08000000–0x1FFFFFFF Code 区·Flash 原生区 片内 Flash 就挂在 0x08000000
    0x20000000–0x3FFFFFFF SRAM 区 片内 RAM(含 bit-band)
    0x40000000–0x5FFFFFFF 外设区 AHB/APB 寄存器
    0x60000000–0x9FFFFFFF 外部 RAM FSMC/FMC 外接存储器
    0xA0000000–0xBFFFFFFF 外部设备 外部器件接口
    0xE0000000–0xFFFFFFFF 系统区 NVIC、SCB、SysTick 等私有外设

            也就是说,0x08000000 是架构表中"代码 Flash 区"的起跑线,厂商只能在这个区域里挂 Flash,不能自定地址。

    二、为什么 Flash 不直接放在 0x0,而要偏移到 0x08000000

    因为 0x0 开头的 128MB 必须承担一个特殊职责——可重映射的启动别名区:

    • 复位时内核铁律要从 0x0 取 MSP、0x4 取复位向量;
    • 但启动源有三种(主 Flash、System Memory、SRAM),需要根据 BOOT 引脚把 0x0 动态接到不同物理区;
    • 如果某块 Flash 固定占死 0x0,就无法实现这种切换。

    所以架构设计成:

            0x0 是"会变的别名窗口",0x08000000 才是 Flash"不变的原生地址"。

            Flash 启动模式下,硬件把 0x0 映射到 0x08000000,二者指向同一份内容。

    三、澄清一个常见误区

            严格说,复位时 CPU 逻辑上从 0x0 取指,但经过别名映射,物理上读到的就是 0x08000000 处的向量表。

            而程序的链接地址(VMA)、SCB->VTOR、函数和全局变量的绝对地址,全部按 0x08000000 解析——因为这个地址不依赖 BOOT 映射,任何模式下都稳定有效,所以它才是程序真正的"出生地址"。

    四、这样设计的好处
  • 启动灵活:0x0 可映射,支持 Flash/Bootloader/SRAM 三种启动;
  • 地址稳定:Flash 原生地址固定,链接器写死 0x08000000 即可,代码位置相关也能正确运行;
  • 软件可移植:统一地址地图让调试器、RTOS、链接脚本在所有 Cortex-M 芯片上按同一套约定工作。
  • 补充:

            并非所有 Cortex-M 芯片都强制 0x08000000,例如 NXP LPC 部分系列把 Flash 原生放在 0x0;但 STM32 遵循 ARM 规范,Flash 固定从 0x08000000 开始。


    赞(0)
    未经允许不得转载:171主机测评 » 【STM32】 程序从源码到运行:编译、烧录与启动全流程详解(下)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址