一、硬件上电后的一瞬间
1. 向量表基址与取指
ARM Cortex‑M 内核规定:
- 上电或复位后,CPU 自动从地址
0x00000000读取初始 MSP(主堆栈指针),从地址0x00000004读取复位向量(Reset_Handler的入口地址)。 - 这两个操作用硬件完成,不依赖任何软件。
但很多 MCU(如 STM32)的 Flash 起始地址是0x08000000,因此芯片会根据启动模式(BOOT0/BOOT1)将0x00000000别名映射到内部 Flash、系统存储器或 SRAM。上电后默认映射为 Flash:0x00000000别名区指向0x08000000。
于是硬件实际读到的是 Flash 开头的两个 32 位字:
0x08000000 : 初始栈顶值 (Stack Top) 0x08000004 : Reset_Handler 地址2. 栈指针的初始值细节
初始栈顶值通常由链接脚本定义为_estack,它等于SRAM 的结束地址 + 1(因为 Cortex‑M 的栈是满递减的,初始 SP 指向栈区最高地址的下一个字)。例如 SRAM 位于0x20000000 - 0x2001FFFF,则_estack = 0x20020000。
此时 CPU 内部:
- MSP =
0x20020000 - PC =
Reset_Handler地址(Flash 中的某条指令)
接下来,CPU 以特权线程模式,使用 MSP 开始执行汇编代码。
二、Reset_Handler 启动代码(逐条分析)
以典型的 STM32 启动文件为例(简化但关键步骤保留):
.section .text.Reset_Handler .weak Reset_Handler .type Reset_Handler, %function Reset_Handler: /* 1. 关闭全局中断,保证初始化过程不被干扰 */ cpsid i /* 2. 使能 FPU(如果芯片有,在 M4/M7 上常见) */ ldr r0, =0xE000ED88 /* CPACR 地址 */ ldr r1, [r0] orr r1, r1, #(0xF << 20) /* 设置 CP10、CP11 为全访问,使能 FPU */ str r1, [r0] dsb isb /* 3. 初始化 .data 段(从 Flash 复制到 SRAM) */ ldr r0, =_sidata /* Flash 中 .data 的加载地址 */ ldr r1, =_sdata /* SRAM 中 .data 的运行起始地址 */ ldr r2, =_edata /* SRAM 中 .data 的结束地址 */ subs r2, r2, r1 /* 计算 .data 段大小 */ beq copy_data_done /* 若没有 .data 则跳过 */ copy_data_loop: ldrb r3, [r0, #1]! /* 每次复制一个字节(或可改为半字/字复制) */ strb r3, [r1, #1]! subs r2, r2, #1 bne copy_data_loop copy_data_done: /* 4. 清零 .bss 段 */ ldr r0, =_sbss ldr r1, =_ebss movs r2, #0 subs r1, r1, r0 beq zero_bss_done zero_bss_loop: strb r2, [r0, #1]! /* 按字节零填充,实际可优化为按字 */ subs r1, r1, #1 bne zero_bss_loop zero_bss_done: /* 5. 调用 C 运行环境初始化 */ bl __libc_init_array /* 6. 调用用户 main */ bl main /* 7. 若 main 返回,进入死循环 */ b .详细解释每一步
关闭中断:cpsid i将 PRIMASK 置 1,屏蔽所有可配置优先级的中断(NMI 和 HardFault 除外)。这是因为.data复制和.bss清零期间如果发生中断,可能会访问尚未初始化的全局变量,导致错误。
FPU 初始化:在 M4/M7 中,CPACR寄存器的 CP10/CP11 控制 FPU 访问。必须在此处打开,否则后续浮点运算会引发 HardFault。此处代码置于.data复制之前,因为复制过程中可能不会用到浮点,但习惯上尽早打开。
.data复制:
_sidata是闪存中.data段的加载地址 (LMA),紧接在.rodata(常量区)之后。_sdata和_edata是 SRAM 中.data段的虚拟地址 (VMA)的起止。- 循环将初始化值从 Flash 复制到 RAM。
.bss清零:
_sbss起始于_edata处(.bss紧接.data后面)。_ebss是.bss结束地址。- 循环写入 0。
__libc_init_array:
这是 C 库(newlib/glibc 等)提供的初始化函数,负责执行:
.preinit_array中的函数指针(留给系统和 C 库)。.init_array中的函数指针(全局 C++ 对象的构造函数、__attribute__((constructor))修饰的 C 函数)。- 若有
.fini_array等后续处理也在此处设置。
执行完后,所有的全局/静态 C++ 对象都已经构造完毕,C 环境完全就绪。
调用main:bl main跳转到用户入口,传递的 R0/R1 参数通常被忽略(裸机环境下main常定义为int main(void))。
捕获返回:main返回后进入死循环,防止 PC 跑飞。实际工程中可能添加软件复位等处理。
三、链接脚本如何决定内存布局
一个典型的链接脚本(部分)如下,展示了各个段在 Flash 和 SRAM 中的分配:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } /* 定义栈顶(SRAM 最高地址) */ _estack = ORIGIN(SRAM) + LENGTH(SRAM); /* 例如 0x20020000 */ SECTIONS { /* 代码段,位于 Flash 低地址 */ .text : { KEEP(*(.vectors)) /* 向量表必须放在最前面 */ *(.text*) /* 程序代码 */ *(.rodata*) /* 只读数据,即常量区 */ . = ALIGN(4); _etext = .; /* 代码结束,可作为 _sidata 的参考 */ } > FLASH /* .data 段的 LMA 在 Flash,VMA 在 SRAM */ .data : { _sdata = .; /* SRAM 中 .data 起始 */ *(.data*) . = ALIGN(4); _edata = .; /* SRAM 中 .data 结束 */ } > SRAM AT> FLASH /* 加载地址在 Flash 中的 .data 初始化值,紧接 .text 后 */ _sidata = LOADADDR(.data); /* 通常就是 _etext 或其后 */ /* .bss 段,紧接 .data 之后 */ .bss : { _sbss = .; /* SRAM 中 .bss 起始 */ *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; /* SRAM 中 .bss 结束 */ } > SRAM /* 堆区定义(可选) */ .heap : { __heap_base = .; . = . + _min_heap_size; /* 保留最小堆空间 */ __heap_limit = .; } > SRAM /* 栈区不占据静态空间,通常只定义栈顶符号,从高地址向下增长 */ /* 栈底由 __heap_limit 和 _estack 之间的空隙自动形成 */ }从该脚本可看出:
- 代码区(.text)和常量区(.rodata)位于 Flash 低地址,
.rodata紧接.text后。 .data的 VMA处于 SRAM 的低地址区域,从0x20000000开始。.bss紧接.data之后,位于 SRAM 较高地址。- 堆紧接
.bss之后,通过__heap_base和__heap_limit划定范围(若启用动态内存)。 - 栈未在链接脚本中占用静态空间,其边界由
__initial_sp(即_estack,SRAM 顶部)和堆区保留空间的上界自然确定。栈向下增长,堆向上增长,最大化利用自由空间。
四、从高地址到低地址的精确内存图
假设 SRAM 范围0x20000000 - 0x2001FFFF(128 KB),Flash 范围0x08000000 - 0x080FFFFF(1 MB)。那么从高地址(SRAM 顶端)到低地址(Flash 底端)的映射为:
高地址(SRAM末端) +---------------------------+ <-- _estack = 0x20020000 (初始 SP) | | | 栈区 (Stack) | 向下增长 (SP 递减) | | +---------------------------+ <-- 堆顶(动态变化) | | | 堆区 (Heap) | 向上增长 (malloc 分配时递增) | | +---------------------------+ <-- __heap_base(紧接 .bss) | | | .bss 段 (BSS) | 未初始化/零初始化的全局/静态变量 | | +---------------------------+ <-- _sbss (_edata 的值) | | | .data 段 (Data) | 已初始化全局/静态变量,内容从 Flash 复制 | | +---------------------------+ <-- _sdata (通常 = 0x20000000) ... SRAM 低地址 ... ... Flash 区域 ... +---------------------------+ <-- 0x08000000 + 偏移(紧接 .data 的 LMA 后) | .data 初始值 (LMA) | 存放在 Flash,由启动代码复制 +---------------------------+ | .rodata (常量区) | const 变量、字符串字面量 +---------------------------+ | .text (代码区) | Reset_Handler, main 等所有指令 +---------------------------+ | 向量表 (Vector Table) | 初始 SP, Reset_Handler, 异常向量 +---------------------------+ <-- 0x08000000 (Flash 基址) 低地址从高地址向低地址观察的顺序正是:栈区 → 堆区 → 全局区(.bss 高 .data 低) → 常量区 → 代码区。
五、为什么 .bss 在高地址,.data 在低地址?
连续性
启动代码需要先复制.data再清零.bss。两者在 SRAM 中地址连续(_sbss == _edata),可以让memcpy/循环直接从_sdata操作到_edata,然后继续从_sbss到_ebss,逻辑清晰,不会出现间隙或碎片。空间利用效率
堆区通常紧接.bss之后向上增长,栈从顶部向下增长。将.data和.bss整体放在 SRAM 的低端,堆和栈放在剩余的高端空间,可以最大化中间的自由内存供两者动态使用,避免静态数据段将可用空间分割。链接器默认行为
链接器按脚本中的> SRAM分配.data和.bss时,由于是连续放置,自然形成.data在低地址、.bss在高地址。无需额外调整。与常量和代码的相对位置
常量区 (.rodata) 和代码区 (.text) 位于 Flash,与 SRAM 的地址物理分开,所以高低关系只适用于 SRAM 和 Flash 组合的整个地址空间。若将两者看作统一地址空间的连续区间(许多哈佛架构的芯片确实在同一 4GB 地址空间映射),那么 Flash 通常映射在低地址,SRAM 映射在高地址,因而形成 SRAM 高位到 Flash 低位的顺序。
六、堆和栈的初始化与生长方向
- 栈(MSP)初始值在向量表中给出,通常是 SRAM 最高地址的下一字。压栈时,SP 先递减再存储,这是“满递减”栈。栈区向低地址生长。
- 堆由 C 库管理,
_sbrk()函数使用链接脚本定义的__heap_base和__heap_limit来分配空间。堆的起始地址在.bss之后,每次分配内存时,堆指针向上移动(递增),即向高地址生长。 - 因此,堆向上、栈向下,两者之间的空隙是它们共用的动态区域。如果堆栈碰撞(越界重叠),就会发生不可预知的行为,这是嵌入式系统中常见的内存问题。
七、C 运行时库初始化的深入细节
__libc_init_array的具体行为(以 newlib 为例):
void__libc_init_array(void){size_tcount,i;// 调用 .preinit_array 中的函数count=__preinit_array_end-__preinit_array_start;for(i=0;i<count;++i)__preinit_array_start[i]();// 调用 .init_array 中的函数(C++ 构造函数)count=__init_array_end-__init_array_start;for(i=0;i<count;++i)__init_array_start[i]();}链接时,编译器收集所有标记为constructor的函数指针、C++ 全局对象的构造函数,并放入.init_array段。这些数组的起止符号通常由链接脚本提供:
__preinit_array_start = .; *(.preinit_array*) __preinit_array_end = .; __init_array_start = .; *(.init_array*) __init_array_end = .;有些工具链还会处理.fini_array(析构函数),但它们由atexit注册,通常在main返回后才执行。
八、整体流程的时钟视角
从外部看,整个过程的时间顺序是:
- 上电复位(POR)→ 内部 RC 起振,CPU 运行。
- 硬件取向量(1 ~ 2 个周期)。
- Reset_Handler 前几条指令(关闭中断、FPU 等)→ 数十个周期。
.data复制和.bss清零(取决于段大小,可能数千周期)。- C 库初始化(调用构造函数)→ 依赖全局对象数量。
main执行。
在许多工程中,用户常会在main之前增加一个SystemInit()调用,配置外部时钟(HSE/PLL),这会消耗一段等待时钟稳定的时间(毫秒级),然后才真正进入用户逻辑。
九、特殊段与变体
.ccmram:有些 MCU 有核心耦合内存(CCM RAM),需要独立链接段,通常紧接.bss或位于另一地址空间,需单独初始化。.noinit:不希望上电后被清零的变量(如掉电保存标志),可以放到保留区,链接脚本跳过清零操作。- 双核或双栈:使用 MSP 和 PSP 的 RTOS 系统,会在进入
main后再初始化进程栈和线程模式,但启动时仍然沿用上述流程。
小结
从硬件自动取向量到main执行,背后是向量表、链接脚本、汇编启动代码、C 运行时库的精妙配合。你给出的内存布局描述——“高地址栈、堆、全局区(.bss 高 .data 低)、常量区、代码区”——正是这一配合在物理地址上的投影。理解了这个过程,就能从源头掌握嵌入式系统的运行基石,也为分析堆栈溢出、数据未初始化等问题奠定了坚实基础。