一、 我一开始的疑问

最近在学习 STM32G030C8T6 的极简裸机工程,工程里主要有 Startup.smain.cstm32g030xx.h 等文件。看着 Startup.s 这个文件,我脑子里冒出了一堆问题:

  1. .s 后缀是不是决定了它就是启动文件?
  2. 文件里定义的 __VectorsRESET 这些名字,是不是 MCU 硬件认识的“魔法”名字?
  3. 向量表第一项 __initial_sp 是不是把“栈”放进了 Flash?
  4. 中断函数名 USART1_IRQHandler 是怎么和硬件对应起来的?

带着这些疑问,我翻手册、看链接脚本、调试反汇编,终于把这条启动链路给理清了。下面就是我的学习笔记,希望能帮到和我有同样困惑的初学者。

二、 .s 文件不等于启动文件

我一开始以为:文件后缀是 .s(汇编文件),并且名字叫 Startup.s,所以它就是启动文件。

后来发现.s 后缀只说明它是汇编源文件,需要交给汇编器处理。一个 .s 文件之所以能作为启动文件,不是因为它的名字或后缀,而是因为它提供了 MCU 上电后执行的第一段代码所必需的关键内容:

  1. 初始栈顶地址 (__initial_sp)
  2. 中断向量表 (__Vectors)
  3. 复位处理函数 (Reset_Handler)

并且,最关键的一步是:链接器要把这些内容最终放到 MCU 复位后会去读取的 Flash 起始地址(通常是 0x08000000)。

所以,更准确的理解是Startup.s 是一个包含了启动代码的汇编源文件。它的“启动”属性是由其内容和最终的链接位置共同决定的。

三、 AREA RESET, DATA, READONLY 是给链接器看的

启动文件里通常有这样一段代码:

; Startup.s 片段
                AREA    RESET, DATA, READONLY
                EXPORT  __Vectors
                EXPORT  __Vectors_End
                EXPORT  __Vectors_Size

__Vectors       DCD     __initial_sp               ; 栈顶地址
                DCD     Reset_Handler              ; 复位向量
                DCD     NMI_Handler
                DCD     HardFault_Handler
                ; ... 更多中断向量

我一开始以为RESET 是 MCU 硬件认识的某个特殊字段,一看到它就知道这是启动代码。

后来发现AREA RESET, DATA, READONLY 这句汇编指令是给链接器看的,而不是给 MCU 看的。它的作用是定义了一个名为 RESET 的只读数据段

那么,RESET 这个名字有什么特殊之处呢?这取决于链接器的默认规则

以 Keil (ARM Compiler) 为例,它的默认链接规则是:优先将名为 RESET 的段放置到 IROM1 区域的起始地址。而在 Keil 的 Target 配置里,IROM1 的起始地址通常被设置为 0x08000000(STM32 内部 Flash 的起始地址)。

这条链路的逻辑是:

  1. Startup.s 定义了一个名为 RESET 的段。
  2. 链接器按照默认规则,把这个 RESET 段放到了 0x08000000
  3. 标签 __Vectors 位于 RESET 段的开头,所以 __Vectors 的最终地址也就是 0x08000000

结论RESET 这个名字对 Keil/链接器有意义(决定了存放位置),但对 MCU 硬件没有意义。如果你把 AREA RESET, ... 改成 AREA RESETT, ...,链接器就不再认为它是那个特殊的启动段,可能不会把它放到 0x08000000,程序自然就无法启动了。

四、 __Vectors 不是 MCU 硬件认识的名字

我一开始以为__Vectors 这个标签名是某种硬件约定,MCU 会去寻找这个名字。

后来发现__Vectors 只是一个普通的汇编标签,用来标记向量表开始的位置。MCU 硬件根本不认识 __Vectors 这个名字。

MCU 硬件只认死理,遵循 Cortex-M 内核规定的固定启动规则

上电复位后,从启动地址读取第一个 32 位数,装入 SP 寄存器(作为初始栈顶);再读取第二个 32 位数,装入 PC 寄存器(作为程序入口,即复位向量)。

对于从内部 Flash 启动的 STM32,这个启动地址就是 0x08000000

所以,只要确保 0x08000000 开始存放的数据,其格式符合 Cortex-M 向量表的规定(第一个字是 SP,第二个字是复位入口地址),MCU 就会把它当作向量表来使用。

因此__Vectors 能成为向量表,不是因为它的名字特殊,而是因为它(所标记的数据)最终被链接器放到了 0x08000000 这个关键地址,并且里面的数据顺序符合硬件规则。

五、 向量表第 0 项和第 1 项

理解了硬件规则,再看向量表开头就非常清晰了:

__Vectors       DCD     __initial_sp               ; 第 0 项:初始栈顶地址值
                DCD     Reset_Handler              ; 第 1 项:复位处理函数地址

DCD 指令可以理解为“在当前位置分配一个 32 位(4字节)的字,并初始化为后面的值”。

所以,这段汇编的意思是:

  • __Vectors 标签处(即 0x08000000),存放 __initial_sp 这个符号对应的值(一个32位地址)。
  • 紧接着的下一个地址(0x08000004),存放 Reset_Handler 这个符号对应的值(另一个32位地址,即函数入口)。

MCU 上电后的动作:

  1. 0x08000000 取出第一个 32 位数,装入 SP 寄存器(设置好栈顶)。
  2. 0x08000004 取出第二个 32 位数,装入 PC 寄存器(程序计数器)。
  3. CPU 开始从 PC 指向的地址执行,也就是跳转到 Reset_Handler 函数。

这里第一个 32 位数,更准确的叫法是 “初始栈顶地址 (Initial SP)”,而不是“栈尾”。

六、 栈在 RAM,向量表在 Flash

这里有一个容易混淆的点:向量表在 Flash 里,但栈是在 RAM 里的。它们是怎么联系起来的?

启动文件里通常还有另一段定义栈的代码:

Stack_Size      EQU     0x00000400

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

这段代码定义了一个名为 STACK 的段,属性是 NOINIT, READWRITE,这意味着它最终会被链接到 RAM 区域。SPACE 指令在 STACK 段内分配了 Stack_Size(例如 1KB)的未初始化空间。标签 __initial_sp 紧跟在 Stack_Mem 空间之后,因此它指向了这段 RAM 空间的末尾地址(高地址端)。因为 Cortex-M 的栈是满递减的(向低地址增长),所以初始 SP 必须指向栈空间的最高地址。

关键__initial_sp 是一个地址值,它代表 RAM 中栈顶的地址(例如 0x20000600)。向量表第一项 DCD __initial_sp,并不是把“栈”这个实体放进 Flash,而是把 “RAM 中栈顶的地址值” 这个数字写进了 Flash 的向量表。

例如,最终在 0x08000000 这个 Flash 地址存放的值可能是 0x20000600。MCU 上电后,只是把这个值 0x20000600 读出来,赋值给 SP 寄存器。真正的栈空间(1KB 的 Stack_Mem)仍然在 RAM 里。

七、 中断函数和硬件是怎么对应起来的

向量表里除了前两项,后面还有很多项,对应着各种中断。

                DCD     NMI_Handler
                DCD     HardFault_Handler
                ; ...
                DCD     USART1_IRQHandler
                ; ...

我一开始以为:硬件中断发生时,MCU 会去“查找”名为 USART1_IRQHandler 的函数。

后来发现:硬件根本不认识函数名!中断响应的流程是这样的:

  1. 固定映射:Cortex-M 内核规定了向量表前 16 项是系统异常(如复位、NMI、硬错误等)。从第 16 项开始,是外部中断(IRQ0, IRQ1, …)。芯片厂商(如 ST)会为每个外设(如 USART1)分配一个固定的 IRQ 编号(例如 USART1 是 IRQ27)。
  2. 查表跳转:当 USART1 中断发生时,硬件逻辑会根据 IRQ 编号(27)计算出它在向量表中的位置:向量表基地址 + (16 + 27) * 4
  3. 读取地址:硬件去这个地址读取一个 32 位的数值,这个值就是中断服务程序的入口地址。
  4. 跳转执行:CPU 跳转到这个地址开始执行。

DCD USART1_IRQHandler 这条指令的作用是:让汇编器/链接器把 USART1_IRQHandler 这个符号解析成它对应的函数地址,然后把这个地址值填到向量表对应的位置(比如第 43 项)。

所以:硬件只认向量表中的位置和该位置存放的地址值。函数名 (USART1_IRQHandler) 只是给程序员和链接器看的符号。

八、 默认中断函数为什么经常是死循环

在启动文件里,经常能看到这样的默认中断函数:

HardFault_Handler    PROC
                     EXPORT  HardFault_Handler          [WEAK]
                     B       .
                     ENDP

USART1_IRQHandler    PROC
                     EXPORT  USART1_IRQHandler          [WEAK]
                     B       .
                     ENDP

B . 是一条汇编指令,意思是“跳转到当前位置”,也就是一个死循环

为什么这么设计?这是一种安全的兜底逻辑
[WEAK] 表示这是一个弱定义。如果用户在工程的其他地方(比如在 main.cstm32g0xx_it.c 里)用 C 语言重新定义了同名的函数:

void USART1_IRQHandler(void) {
    // 用户自己的串口中断处理代码
    if(USART1->ISR & USART_ISR_RXNE) {
        // 读取数据...
    }
}

那么,链接器会优先使用用户写的这个强定义,而忽略启动文件里的弱定义。这样,向量表里填的就是用户函数的地址。

如果用户没有写自己的中断处理函数,那么向量表里填的就是启动文件里那个弱符号的地址(B .)。一旦这个中断发生,CPU 就会跳转到那个死循环里。这比让程序跑飞到未知地址要安全,也方便在调试时(程序卡住)发现问题。

九、 最终启动链路总结

让我们把上面所有的点串联起来,看看从点击“下载”到程序运行,到底发生了什么:

在这里插入图片描述

  1. 配置决定位置:我们在 IDE(如 Keil)中配置了 Flash 的起始地址 (0x08000000)。
  2. 汇编定义段Startup.s 通过 AREA RESET, ... 定义了一个特殊的段。
  3. 链接器摆放:链接器根据规则,将 RESET 段放到配置的 Flash 起始地址。
  4. 硬件读取:MCU 上电后,机械地从 0x080000000x08000004 读取数据,设置 SP 和 PC。
  5. 软件接管:CPU 跳转到 Reset_Handler,由它执行必要的初始化(如初始化 RAM),最后跳转到用户的 main 函数。

十、 最终修正后的理解

回顾我最开始的误解:

  • 误解:MCU 认识 RESET__Vectors 这些名字。
  • 正解
    1. 链接器认识 RESET 这个段名,并据此决定把这段代码/数据放到哪里(0x08000000)。
    2. MCU 硬件不认识 RESET,也不认识 __Vectors。它只遵循 Cortex-M 内核的硬件规则:从固定的启动地址读取向量表。
    3. __Vectors 只是程序员和链接器用来标记向量表起始位置的一个标签。

再压缩成一句话

RESET 段名决定了链接器把它放到哪里;0x08000000 的最终位置决定了 MCU 会不会把它当作向量表;__Vectors 只是这个位置上的一个标签名。

希望这篇笔记能帮你拨开 STM32 启动过程的迷雾。理解了这个底层链路,再看启动文件、链接脚本甚至调试时的反汇编,都会清晰很多。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐