单片机启动流程与 .s 文件详解

本文从基础开始,彻底拆解单片机从上电复位到 main() 函数的完整启动流程,涵盖硬件行为、启动文件(.s)逐行解读、中断向量表、堆栈初始化及常见问题。

文末附 startup_stm32f407xx.s 全部代码。

1、基础概念

1.1、字节、字、对齐

术语 大小 说明
字节 (Byte) 8 位 最小可寻址单位
半字 (Half-word)) 16 位(2 字节) Cortex-M 架构支持
字 (Word) 32 位(4 字节) CPU 一次处理的典型数据大小

对齐:一个字的起始地址必须是4 的倍数(即地址末尾两位为 00)。

合法字地址:0x00000000、0x00000004、0x00000008

非法字地址:0x00000001、0x00000002、0x00000003

1.1.1、为什么需要对齐?

CPU 硬件设计为从对齐地址一次读取 4 个字节,不对齐会触发异常或降低性能。

1.2、栈 (Stack) 与堆 (Heap)

1.2.1、栈: 先进后出 LIFO

用途:存放局部变量、函数返回地址、中断现场。

生长方向:向下(从高地址向低地址)。

管理方式:自动(函数调用时压栈,返回时出栈)。

寄存器:SP(栈指针)指向当前栈顶(最低地址的有效数据)。

初始化:SP 必须指向栈空间的最高地址(因为第一次压栈会先减 SP)

总结作用:

保存局部变量(自动变量)

函数调用时保存返回地址

中断发生时保存现场(寄存器、状态字)

传递函数参数(在某些调用约定中)

1.2.2、堆:

用途:动态分配内存(malloc、new)。

生长方向:向上(从低地址向高地址)。

管理方式:手动(程序员分配和释放)。

需要知道堆的起始地址和结束地址。

1.3、中断与向量表

中断:外设或内部事件请求CPU 暂停当前任务,去执行一段特定的代码(中断服务函数,ISR)。

向量表:一张存储在内存起始位置的表格,每一项是一个4 字节的函数地址。当某个中断发生时,硬件自动从表中取出对应的函数地址,然后跳转执行。

向量表的前两项是固定的:

第0 项:初始栈指针(MSP)

第1 项:复位向量(Reset_Handler 的地址)

1.4、程序的内存分布(.data, .bss, .text)

C标准规定:未初始化的全局变量默认值为 0,所以 .bss 段必须清零。

1.5、关键寄存器速览 (SP, PC, LR)

1.6、汇编语言

启动文件使用ARM汇编语言。主要伪指令如下:

1.7、

2、硬件复位后自动做了什么

2.1、启动模式选择(BOOT引脚)

STM32上电后,通过BOOT0和BOOT1引脚决定从哪里执行代码:

复位后,硬件根据这两个引脚的电平,配置内部总线矩阵,将0x00000000 的访问重定向到对应的物理地址。
注意:这种重映射是纯硬件完成的,不需要任何软件参与。

2.2、两个固定地址

0x00000000 和 0x00000004

Cortex-M 处理器上电或复位时,硬件逻辑会执行以下两步(不可改变):

2.3、硬件行为详解

第1步:从地址0x00000000读出的值,必须是一个合法的RAM地址(通常指向栈空间的最高地址)。这个值被自动写入MSP寄存器。之后任何函数调用、压栈操作都会使用这个栈指针。

第2步:从地址0x00000004读出的值,必须是可执行代码的地址(通常是Reset_Handler函数的入口)。CPU将PC设为该地址,然后开始取指执行。

在STM32 中,默认情况下: 0x00000000~ 0x07FFFFFF 是一个别名区域(Alias)。

这个区域并不对应真正的独立存储器,而是通过系统配置寄存器(SYSCFG) 或 启动引脚(BOOT) 选择映射到:

1、主Flash(0x08000000)

2、系统存储器(内置Bootloader,0x1FFF0000)

3、内部SRAM(0x20000000)

2.4、为什么是这两个地址?地址映射原理

为什么固定:这是ARM公司设计的硬件规范,所有Cortex-M芯片都遵守。

理论上,ARM 可以修改内核设计让复位向量指向 0x08000000,但这样做会破坏 Cortex-M 的统一性。

所有Cortex-M 芯片(无论哪个厂家)都遵循相同的复位向量地址规范,这样工具链(编译器、链接器)才能通用。

地址映射:Cortex-M内核复位后固定从0x00000000取指,但Flash实际物理地址是0x08000000。 STM32内部硬件会自动将0x08000000映射到0x00000000,实现"地址重映射"。

后续参考链接:单片机启动流程与 .s 文件详解

3、启动和中断向量表的关联

单片机复位之后,cpu默认从0地址偏移4字节(一般是中断向量表)去寻找reset_hander函数的地址(从编译生成的hex文件结合map文件可以看出来)执行;

在reset_hander函数实现中,一般会初始化sram、初始化系统时钟、最终执行到 __main, 这个时候就会开始执行唯一的 int main(void) 函数了。

调试环境,调试器(比如keil或者iar)会把core寄存器中PC指针(R15)直接指向main函数的地址。

3.1、入口地址

由此可以看出:任何程序,无论芯片中烧录了多少段代码(boot、app),这些程序的入口地址都是中断向量表的reset_handler(比如boot跳转app,也是需要执行app的中断向量表的偏移4个字节地址的reset_handler)。

4、启动文件(.s)逐行拆解

以startup_stm32f407xx.s (MDK-ARM工具链) 为例。

4.1、文件概述

文件注释明确说明四个任务:设置初始SP、设置初始PC、建立向量表、跳转到__main。

4.2、栈定义


Stack_Size EQU 0x2000

EQU,定义了一个常量Stack_Size,值为 0x2000(十六进制)即 8192 字节 = 8 KB。

AREA STACK, NOINIT, READWRITE, ALIGN=3

AREA:创建名为STACK的内存段,属性NOINIT(不初始化)、READWRITE(可读可写)、ALIGN=3(8字节对齐)。

Stack_Mem SPACE Stack_Size

SPACE指令保留一块内存空间,大小为 Stack_Size 字节。

Stack_Mem是一个标签(label),它标记这块保留空间的起始地址(也就是栈的最低地址,因为栈向下生长,起始地址是栈底)。

__initial_sp

这是一个标签,它出现在SPACE 指令的后面。因为 SPACE 分配了 Stack_Size 字节,所以 __initial_sp 指向这块空间的末尾地址(最高地址)。

4.3、栈的作用和功能是什么?

栈是RAM 中的一块特殊内存区域,由 CPU 自动管理。它的主要功能:

1、保存局部变量

函数内部定义的局部变量(非static)都存放在栈上。函数执行时分配,函数返回时自动释放。

2、保存函数返回地址

当调用一个函数时,CPU 会自动将下一条指令的地址(返回地址)压入栈中。函数执行完后,从栈中弹出该地址,继续执行调用者代码。(即所谓的先进后出)

3、保存寄存器现场

发生中断时,CPU 会自动将当前的一些寄存器(如 PC、LR、R0-R3、PSR 等)压入栈,中断服务函数执行完后弹出恢复。这叫"现场保护"。

4、传递函数参数

当参数过多或无法用寄存器传递时,剩余的参数会通过栈传递。

4.4、堆定义


Heap_Size EQU 0x1000

定义常量Heap_Size 为 0x1000(十六进制),即 4096 字节 = 4 KB。

AREA HEAP, NOINIT, READWRITE, ALIGN=3

创建一个名为HEAP 的内存段,属性NOINIT(不初始化)、READWRITE(可读可写)、ALIGN=3(8字节对齐)。

__heap_base

一个标签(符号),标记堆的起始地址。

Heap_Mem SPACE Heap_Size

分配Heap_Size 字节的保留空间,并将起始地址命名为 Heap_Mem。

(实际上__heap_base 和 Heap_Mem 指向同一个地址,这里重复定义是为了语义清晰)。

__heap_limit

一个标签,紧跟在分配的空间之后,标记堆的结束地址(即堆末尾的下一个字节,通常用于边界检查)。

堆的管理方式

分配:调用malloc(size) 或 new,堆管理器从空闲链表中找一块足够大的内存返回。

释放:调用free(ptr) 或 delete,将内存归还给堆管理器,以便后续重用。

碎片问题:反复分配释放可能导致内存碎片,使得大块分配失败。

4.5、中断向量表(核心)

这里有注释其实可以看到,复位的时候,中断向量表需要在0地址。 这样bootrom启动后才可以找到用户代码。


AREA RESET, DATA, READONLY

作用:定义一个内存段(section),名字叫 RESET,属性为 DATA(数据段)、READONLY(只读)。

为什么只读:中断向量表的内容(函数地址)在程序运行过程中一般不会改变,所以放在只读的Flash 中,节省 RAM 且安全。

为什么叫RESET:这个段专门存放复位和中断向量表,链接脚本通常会把 RESET 段放在 Flash 的最开头(地址 0x08000000),以便硬件复位时能找到。

EXPORT __Vectors

EXPORT __Vectors_End

EXPORT __Vectors_Size

EXPORT:将后面的符号(标签)导出,使其可以被其他目标文件(比如 C 文件或链接脚本)引用。

__Vectors:向量表的起始地址标签。

__Vectors_End:向量表的结束地址标签。

__Vectors_Size:向量表的大小(字节数),稍后会通过计算得到。

导出这些符号是为了让链接器知道向量表的位置,也方便在C 代码中(如果必要)获取向量表信息。

__Vectors DCD __initial_sp

__Vectors:标签,表示向量表的起始地址。它被 EXPORT 导出。

DCD:Define Constant Double-word --- 分配 4 字节(一个 word),并填入后面的值。这里填入的是 __initial_sp 的值(栈顶地址)。这一项是向量表的第 0 项,放在地址 0x00000000。硬件复位时会从这里读取初始栈指针。

DCD Reset_Handler

向量表的第1 项,地址 0x00000004。硬件复位时从这里读取复位函数的入口地址,并跳转执行。

下面这些都是系统异常(内核级别的异常),每个占4 字节,对应固定的异常编号。

NMI:不可屏蔽中断,通常用于紧急事件(如掉电检测)。
HardFault:硬错误,当其他错误无法处理时触发(如访问非法地址、栈溢出等)。
MemManage:存储器管理错误(MPU 保护违规)。
BusFault:总线错误(比如访问不存在的地址)。
UsageFault:用法错误(比如未对齐访问、执行未定义指令)。
这些异常的处理函数地址由芯片厂商规定位置,不能随意改变。

4.6、什么是中断向量表?

中断向量表是一张函数地址列表,存放在内存的最开头(地址 0 附近)。每个地址对应一个异常或中断的处理函数。当特定事件(比如按键按下、定时器溢出)发生时,硬件会自动从这张表中取出对应的函数地址,然后跳过去执行。

4.7、为什么需要它?

CPU 不知道你的中断处理函数放在内存的哪个位置。通过这张表,硬件可以快速找到正确的函数。

统一、固定的查表机制,使得中断响应非常快(硬件直接取地址,不需要软件判断)。

4.8、谁提供这张表?

由程序员在启动文件中用 DCD 指令填写。

由链接器将它放置在 Flash 的起始地址。

CPU 复位后,硬件就默认这张表在地址 0。

4.9、Reset_Handler 复位函数


Reset_Handler PROC

Reset_Handler:一个标签(label),表示这个函数的起始地址。这个标签会被放在中断向量表的第 1 项(地址0x00000004)。硬件复位后,程序计数器(PC)会指向这个地址,从这里开始执行。

PROC:汇编伪指令,表示一个函数(procedure)的开始。与后面的ENDP 配对使用,用于标记函数的范围。它不影响生成的机器码,但有助于调试器和汇编器生成正确的符号信息。

EXPORT Reset_Handler [WEAK]

EXPORT:将符号Reset_Handler 导出,使其可以被其他目标文件(比如链接脚本或 C 文件)引用。

[WEAK]:表示弱定义 。意思是:如果其他目标文件中有一个同名的强符号(比如你在 C 文件中写了void Reset_Handler(void) {...}),链接器会优先使用那个强符号,而忽略这个弱定义。
通常我们不会在C 中重新定义 Reset_Handler,所以这个弱定义生效。 弱定义的好处是允许用户覆盖默认行为(例如需要先执行某些特殊初始化再进入标准流程)。

IMPORT SystemInit

IMPORT:声明一个外部符号SystemInit,它不在本文件中定义,而是来自其他目标文件(通常是 system_stm32f4xx.c 或库文件)。链接器会在链接时找到这个符号的实际地址,并填入所有引用它的地方。

SystemInit的作用:配置系统时钟(如 PLL)、初始化 Flash 等待周期、设置向量表偏移等。这是运行 C 代码前的必要步骤。如果没有这一步,CPU 可能运行在慢速内部时钟(HSI),某些外设无法正常工作。

IMPORT __main

同样导入外部符号__main。

注意:__main不是你的 main 函数,它是 C 标准库的初始化入口函数。

__main负责:

复制.data 段(已初始化的全局变量)从 Flash 到 RAM。

清零.bss 段(未初始化的全局变量)。

初始化堆(设置__heap_base 和 __heap_limit)。

最后调用你的main() 函数。

如果不经过__main 直接跳转到 main,全局变量不会初始化,程序行为不可预测。

LDR R0, =SystemInit

LDR:Load Register,将数据加载到寄存器。这里有一个等号=,表示伪指令,意思是"将 SystemInit 的地址加载到 R0"。其实就是执行这个函数。

总结:

Reset_Handler是复位后的第一个函数,它先调用 SystemInit 配置系统时钟,再跳转到 __main 完成 C 环境初始化,最终进入你的 main() 函数;PROC/ENDP 定义函数边界,EXPORT [WEAK] 允许用户覆盖,IMPORT 引入外部符号,LDR 加载地址,BLX 调用并返回,BX 最终跳转不返回。

4.10、堆栈初始化(__user_initial_stackheap)

4.11、两大核心原理

4.11.1、核心一:硬件从固定地址取 SP 和 PC

CPU上电后,强制从地址0x00000000取栈顶,从0x00000004取第一条指令地址。

为什么这样设计:

硬件无法预知程序需要多大栈、代码从哪开始。通过固定地址读取配置,把灵活性交给软件。

程序员要做的事:

在启动文件中定义__initial_sp和Reset_Handler,并确保它们被放在向量表头两项。

如果放错会怎样:

栈顶指向非法地址或复位入口指向非代码区-> HardFault。

4.11.2、核心二:C 语言需要提前初始化全局变量和堆栈

在main()执行前,必须完成.data段复制、.bss段清零、堆栈设置。

为什么需要:

已初始化的全局变量初值存在Flash,必须搬到RAM才能使用。

未初始化的全局变量默认0,必须把对应RAM区域清零。

堆栈必须知道边界,否则malloc和函数调用会崩溃。

谁来负责:

C标准库的__main函数。启动文件必须在Reset_Handler中跳转到__main,而不是直接跳到main。

如果跳过__main会怎样:

全局变量值为随机数,malloc无法使用,程序出现问题。

4.12、单片机启动流程总结

4.12.1、硬件自动阶段(复位序列)

内核硬件强制从地址0x00000000 读取 4 字节,写入主栈指针(MSP)。

从地址0x00000004 读取 4 字节(reset_handler),写入程序计数器(PC)。

这两个地址的内容由启动文件中的向量表提供,并通过芯片内部的地址映射(Flash 物理地址0x08000000 别名到 0x00000000)实现

4.12.2、启动代码阶段(Reset_Handler)

CPU 跳转到Reset_Handler 函数(汇编实现)。

首先调用SystemInit(),完成系统时钟、Flash 等待周期等关键初始化。

随后跳转到C 标准库入口__main(非用户 main)。

4.12.3、C 运行时环境初始化(__main)

将.data 段(已初始化全局/静态变量)从 Flash 复制到 RAM。

将.bss 段(未初始化全局/静态变量)在 RAM 中清零。

根据是否使用微库(__MICROLIB),通过__user_initial_stackheap 函数或直接使用导出的 __heap_base/__heap_limit 符号初始化堆管理器。

4.12.4、进入用户应用程序

__main最后调用用户定义的 main() 函数,从此应用程序获得控制权。

若使能了中断,当外设或内核异常发生时,硬件自动查向量表并跳转到对应的处理函数;

未实现的中断将陷入弱定义死循环(B .)。

4.13、__main原型为什么找不到

因为这个函数并不是由你编写的源代码编译而来,而是编译器(Arm Compiler)在链接阶段自动生成的。它不存在于任何 .c 或 .h 文件中,而是以二进制形式直接嵌入到链接后的可执行文件里。

__main 实际上是用汇编实现的。

ARM 官方文档中指出,__main 是 C 库的入口点,它负责执行以下标准操作:

将 RO(只读)和 RW(可读写)段从加载地址复制到执行地址

解压压缩的数据段

将 ZI(零初始化)段清零

最后跳转到 __rt_entry

4.14、

相关推荐
金色光环1 小时前
【DSP学习笔记】 F28335中断系统理解-基于普中DSP28335开发攻略
笔记·单片机·学习·dsp开发
iCxhust1 小时前
8086/8088单板机VSCode集中环境开发编译(第二版整理)
ide·vscode·嵌入式硬件·编辑器·嵌入式·微机原理·8086最小系统
时光の尘1 小时前
【嵌入式大厂面经】·IIC常见考点(持续更新中···)
arm开发·单片机·嵌入式硬件·mcu·物联网·iot
三佛科技-187366133972 小时前
AIP7550GD893.TR是什么芯片?200mA/30V低压差线性稳压器芯片分析
单片机·嵌入式硬件
高翔·权衡之境2 小时前
主题3:天线与耦合——近场与远场
网络·嵌入式硬件·物联网·软件工程·信息与通信
踏着七彩祥云的小丑2 小时前
嵌入式测试学习第 7 天:PCB电路板基础、走线、焊盘、贴片/直插
单片机·嵌入式硬件
飞凌嵌入式2 小时前
飞凌嵌入式率先推出RK3572核心板 | 新一代八核AIoT平台,新品强势来袭!
科技·嵌入式硬件·嵌入式
LCG元3 小时前
STM32实战:基于STM32F103的Modbus RTU通信(从机实现)
stm32·单片机·嵌入式硬件
爱喝纯牛奶的柠檬3 小时前
【已验证】STM32 LoRa 环境监测与远程控制系统
arm开发·stm32·单片机·嵌入式硬件