向量表、链接脚本、汇编启动代码、C 运行时库
2026/9/11 11:07:00 网站建设 项目流程

一、硬件上电后的一瞬间

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 等)提供的初始化函数,负责执行:

  1. .preinit_array中的函数指针(留给系统和 C 库)。
  2. .init_array中的函数指针(全局 C++ 对象的构造函数、__attribute__((constructor))修饰的 C 函数)。
  3. 若有.fini_array等后续处理也在此处设置。

执行完后,所有的全局/静态 C++ 对象都已经构造完毕,C 环境完全就绪。

调用mainbl 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 在低地址?

  1. 连续性
    启动代码需要先复制.data再清零.bss。两者在 SRAM 中地址连续(_sbss == _edata),可以让memcpy/循环直接从_sdata操作到_edata,然后继续从_sbss_ebss,逻辑清晰,不会出现间隙或碎片。

  2. 空间利用效率
    堆区通常紧接.bss之后向上增长,栈从顶部向下增长。将.data.bss整体放在 SRAM 的低端,堆和栈放在剩余的高端空间,可以最大化中间的自由内存供两者动态使用,避免静态数据段将可用空间分割。

  3. 链接器默认行为
    链接器按脚本中的> SRAM分配.data.bss时,由于是连续放置,自然形成.data在低地址、.bss在高地址。无需额外调整。

  4. 与常量和代码的相对位置
    常量区 (.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返回后才执行。


八、整体流程的时钟视角

从外部看,整个过程的时间顺序是:

  1. 上电复位(POR)→ 内部 RC 起振,CPU 运行。
  2. 硬件取向量(1 ~ 2 个周期)。
  3. Reset_Handler 前几条指令(关闭中断、FPU 等)→ 数十个周期。
  4. .data复制和.bss清零(取决于段大小,可能数千周期)。
  5. C 库初始化(调用构造函数)→ 依赖全局对象数量。
  6. main执行

在许多工程中,用户常会在main之前增加一个SystemInit()调用,配置外部时钟(HSE/PLL),这会消耗一段等待时钟稳定的时间(毫秒级),然后才真正进入用户逻辑。


九、特殊段与变体

  • .ccmram:有些 MCU 有核心耦合内存(CCM RAM),需要独立链接段,通常紧接.bss或位于另一地址空间,需单独初始化。
  • .noinit:不希望上电后被清零的变量(如掉电保存标志),可以放到保留区,链接脚本跳过清零操作。
  • 双核或双栈:使用 MSP 和 PSP 的 RTOS 系统,会在进入main后再初始化进程栈和线程模式,但启动时仍然沿用上述流程。

小结

硬件自动取向量main执行,背后是向量表、链接脚本、汇编启动代码、C 运行时库的精妙配合。你给出的内存布局描述——“高地址栈、堆、全局区(.bss 高 .data 低)、常量区、代码区”——正是这一配合在物理地址上的投影。理解了这个过程,就能从源头掌握嵌入式系统的运行基石,也为分析堆栈溢出、数据未初始化等问题奠定了坚实基础。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询