在很久之前学习stm32的时候了解到了程序的内存布局是什么样的一种情况(如.text代码段 、.data数据段、.bss段的地址分配),在接触了Linux之后我也很好奇在Linux系统中它的布局又是什么一种情况呢?和单片机进行比较又会有什么差异呢,这个帖子将进行一个概述总结,发现问题我会持续进行修改补充。
目录
1. 程序文件格式:ELF(Executable and Linkable Format)的 “元数据载体” 作用
2. 加载触发:execve 系统调用的 “进程替换” 逻辑
3. 动态链接:ld.so 处理共享库的 “重定位” 与 “符号解析”
4. 程序启动:从_start 到 main 的 “用户态初始化”
(2)STM32 程序的加载与执行流程:硬件绑定与静态初始化
1. 程序文件格式:.bin/.hex 的 “纯二进制” 本质
4. 启动代码:汇编级的 “RAM 初始化” 与 “段准备”
一、程序的内存布局是什么?
程序运行时,CPU 需要知道:
-
指令(代码)存在哪里执行
-
全局变量、常量、堆内存和栈分别放在哪里
-
如何区分只读数据和可修改数据
这个存储结构,就是我们常说的 程序的内存布局。
它通常分为以下几个段:
-
.text 段:存放可执行的机器指令(代码)。
-
.data 段:存放已初始化的全局变量和静态变量。
-
.bss 段:存放未初始化的全局变量和静态变量,运行时被清零。
-
堆 (Heap):用于 malloc/new 动态分配的内存,运行时向上增长。
-
栈 (Stack):用于局部变量和函数调用,运行时向下增长。
-
常量区:放字符串字面量和只读变量。
二、底层硬件与内存管理基础的差异
这是两者内存布局差异的根源:
- Linux 系统:运行在具有MMU(内存管理单元) 的处理器上(如 x86、ARM Cortex-A),支持虚拟地址空间。程序看到的地址是虚拟地址,由 OS 通过 MMU 映射到物理地址,实现内存隔离、共享和保护。
- STM32 单片机:基于无 MMU 的处理器(如 ARM Cortex-M),仅使用物理地址。内存空间固定(Flash 和 RAM 的地址硬编码),无虚拟地址映射,程序直接操作物理内存。
三、核心内存段的地址分配与存储方式对比
程序的核心内存段包括.text
(代码段)、.data
(初始化数据段)、.bss
(未初始化数据段)、堆(heap)、栈(stack),两者的分配逻辑差异显著:
1. .text
段(代码段)
- 作用:存放程序指令(机器码),只读(执行时不需要修改)。
场景 | 存储位置 | 地址特性 | 核心特点 |
---|---|---|---|
Linux 系统 | 物理内存(可共享) | 虚拟地址(如 0x400000 起),由 OS 动态映射;多个进程可共享相同物理页(代码相同的情况下)。 | 依赖 OS 加载器(如ld.so )解析 ELF 文件,将.text 映射为只读虚拟地址,防止意外修改(写操作触发段错误)。 |
STM32 单片机 | 片内 Flash(程序存储器) | 固定物理地址(如 STM32 的 Flash 起始地址为0x08000000 ),由 linker script 硬编码。 | 单片机代码必须从 Flash 执行(Flash 是只读执行区),地址固定且不可动态修改,无共享机制(仅单个程序运行)。 |
2. .data
段(初始化数据段)
- 作用:存放初始化的全局变量和静态变量(如
int a = 10;
),需要可读可写。
场景 | 存储与加载逻辑 | 地址特性 |
---|---|---|
Linux 系统 | 程序加载时,OS 从 ELF 文件中读取.data 的初始化值,映射到可写虚拟地址(进程私有,写时复制)。 | 虚拟地址(如 0x600000 起),物理页由 OS 动态分配,与.text 段地址分离。 |
STM32 单片机 | 初始化值存储在 Flash 中(节省 RAM),上电后由启动代码(startup_stm32.s )复制到 RAM 中(因为 RAM 可写,Flash 只读)。 | RAM 物理地址(如 STM32 的 RAM 起始地址0x20000000 ),地址由 linker script 固定。 |
3. .bss
段(未初始化数据段)
- 作用:存放未初始化的全局变量和静态变量(默认初始化为 0,如
int b;
),可读可写。
场景 | 存储与加载逻辑 | 地址特性 |
---|---|---|
Linux 系统 | ELF 文件中不存储.bss 内容(仅记录大小)不占用实际大小,OS 加载时自动将对应物理页清零,映射到可写虚拟地址。 | 虚拟地址与.data 段相邻(共享可写属性),大小由程序编译时确定。 |
STM32 单片机 | Flash 中不存储具体的值(节省空间),上电后由启动代码直接在 RAM 中清零(地址由 linker script 分配)。 | RAM 物理地址(紧跟.data 段之后),大小固定(编译时确定,不可动态扩展)。 |
4. 堆(heap)与栈(stack)
- 堆:用于动态内存分配(如
malloc
/new
),地址从低到高增长; - 栈:用于函数调用、局部变量和临时数据,地址从高到低增长。
区域 | Linux 系统 | STM32 单片机 |
---|---|---|
栈 | - 虚拟地址,从高地址向低地址增长; - 大小动态调整(默认几 MB,可通过 ulimit 配置上限);- 由内核自动管理,溢出时触发栈保护机制(如栈金丝雀)。 | - 物理地址,从 RAM 的高地址向低地址增长(地址由链接脚本指定,如_estack = 0x20005000 ,即 RAM 上限);- 大小固定(在链接脚本中显式指定,如 STACK_SIZE = 0x400 即 1KB);- 无硬件保护,溢出会直接覆盖堆或其他 RAM 区域(导致程序崩溃)。 |
堆 | - 虚拟地址,从低地址向高地址增长(与栈之间有 “空隙”,避免冲突); - 由 malloc/free 通过内核系统调用(brk/sbrk 或mmap )动态分配,可申请 GB 级内存;- 内核维护内存池,支持碎片整理(通过伙伴系统等算法)。 | - 物理地址,位于.bss 段之后(如_end = .; HEAP_START = _end; HEAP_SIZE = 0x800 );- 大小固定(链接脚本中指定,如 512KB RAM 中堆可能仅分配 8KB); - 动态分配依赖简单算法(如 FreeRTOS 的 pvPortMalloc 用内存池,或裸机中用heap4.c 等),无碎片整理,易因溢出 / 碎片导致分配失败。 |
注意:Linux 的堆和栈虽然通常分布在虚拟内存空间的两端,中间留有一定“空隙”避免冲突,但这个空隙大小并非固定。在内存紧张时,如果堆和栈相向增长并相遇,进程会因段错误(Segmentation fault
)而崩溃(野指针一样会导致段错误,我就遇到不少次~~~~~)。
此外,Linux 程序的 ELF 文件包含 Program Header Table(程序头表) 和 Section Header Table(节表):
-
内核在加载程序时仅依赖 程序头表(定义
.text
、.data
、.bss
等在内存的映射方式和权限); -
节表更多用于调试和链接过程,加载时不会使用。
上面提到了Linux的ELF文件,这里的ELF 文件(Executable and Linkable Format,可执行与可链接格式 )是 Linux 以及类 UNIX 系统上,用于可执行文件、目标文件、共享库和核心转储文件的标准文件格式,其中就包含了程序的内存布局信息,并且通过不同的段(Segment)和节(Section)来组织程序的代码、数据和其他相关信息。可执行程序就是以ELF格式存储在存储器上面的(比如stm32的flash存储器),各个段在 ELF 文件中都有对应的表示,并且在程序加载到内存时会被映射到相应的内存区域。
ELF 文件的结构组成
- ELF 头(ELF Header):位于文件开头,包含了 ELF 文件的基本信息,像是文件的类型(可执行文件、共享库、目标文件等)、机器架构(x86、ARM 等 )、ELF 头的大小、程序入口点地址、段表(Program Header Table)和节表(Section Header Table)的位置及大小等。通过这些信息,操作系统能快速判断该文件是否符合可加载和执行的条件。
- 程序头表(Program Header Table):描述了文件中各段(Segment)在内存中的布局信息,比如代码段(.text)、数据段(.data)等。每个表项记录了对应段在文件中的偏移量、大小、在内存中的虚拟地址、读 / 写 / 执行权限等。在程序加载时,操作系统的加载器会依据这些信息,将相应的段映射到合适的内存位置。
- 节表(Section Header Table):包含了文件中各个节(Section)的信息,节是编译过程中产生的逻辑单元,像
.text
(存放机器码)、.data
(存放初始化的全局变量和静态变量)、.bss
(存放未初始化的全局变量和静态变量)、.rodata
(存放只读数据,比如常量字符串)等。节表记录了每个节的名称、类型、在文件中的偏移量、大小等,主要用于链接过程以及调试。- 各个节(Sections) :是 ELF 文件实际存放内容的部分,除了上述常见的代码和数据相关节,还有像
.rel.text
(用于代码段的重定位信息)、.symtab
(符号表,记录程序中定义和引用的符号信息,比如函数名、变量名等 )等。ELF 文件的作用
- 支持程序的链接:在软件开发过程中,通常会有多个源文件,经过编译后生成多个目标文件。链接器会根据 ELF 文件中节表和符号表等信息,将这些目标文件以及所依赖的库文件链接成一个可执行文件或共享库。在链接过程中,会处理符号的引用和重定位,确保程序在运行时能正确访问函数和变量。
- 方便程序的加载:当用户执行一个程序时,Linux 系统的加载器(比如
execve
系统调用会调用相关的加载机制 )会读取 ELF 文件。通过 ELF 头获取基本信息,依据程序头表将文件中的各个段映射到内存中合适的虚拟地址空间,并设置好相应的权限,从而让程序能够在内存中正确运行。- 利于调试和分析:调试器可以借助 ELF 文件中的节表、符号表、调试信息节(比如
.debug_info
等 )来获取程序的结构信息、变量和函数的位置等,方便开发人员进行调试,定位程序中的错误。
四、内存保护与安全性差异
-
Linux 系统:依赖 MMU 实现内存保护:
.text
段映射为 “只读”,写操作触发SIGSEGV
(段错误);- 用户态程序无法访问内核地址空间,防止越权操作;
- 进程间内存隔离(虚拟地址独立),避免相互干扰。
-
STM32 单片机:无 MMU,内存保护能力极弱:
- 仅部分型号支持简单 MPU(内存保护单元),但功能有限(如划分读写区域);
- 非法访问(如写 Flash 地址、栈溢出覆盖
.data
)不会触发 “错误提示”,直接导致程序跑飞或数据损坏; - 无进程隔离(仅单程序运行),任何错误都可能影响整个系统。
五、链接脚本的角色:“手动配置” vs “自动生成”
链接脚本(.ld 文件)是内存布局的 “蓝图”,两者对链接脚本的依赖程度天差地别:
- Linux 系统:
链接脚本由编译器(如 GCC)默认生成(ld --verbose
可查看),用户几乎无需修改。其核心作用是 “定义段的逻辑顺序”(如.text 在前、.data 在后),但不指定具体物理地址(由内核加载时动态分配虚拟地址)。
例如,默认链接脚本中仅定义段的排列顺序:
.text : { *(.text) }
.rodata : { *(.rodata) }
.data : { *(.data) }
.bss : { *(.bss) }
- STM32 单片机:
链接脚本必须手动编写或精细配置,因为其核心作用是 “将各段绑定到物理硬件地址”(Flash 和 RAM 的起始地址、大小是固定的)。例如,STM32 的典型链接脚本会明确指定:
/* 定义Flash和RAM的物理地址与大小 */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K /* Flash起始地址0x08000000,大小64KB */
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K /* RAM起始地址0x20000000,大小20KB */
/* 绑定各段到硬件地址 */
.text : {
KEEP(*(.isr_vector)) /* 中断向量表必须放在Flash起始位置 */
*(.text) /* 代码段紧随向量表之后 */
} > FLASH
.data : {
*(.data) /* 数据段放在RAM起始位置 */
} > RAM AT > FLASH /* "AT > FLASH"表示初始化值存储在Flash中 */
.bss : {
*(.bss) /* 未初始化数据段在.data之后 */
} > RAM
若链接脚本配置错误(如.data 段超出 RAM 大小),程序会直接无法运行(如启动后复位、变量值异常)。
需要注意的是,段的排列顺序(如 .data
是否紧跟 .text
,.bss
是否在 .data
之后)并不是硬性规定,而是 由链接脚本中的 SECTIONS 定义决定的。
-
常见做法是
.text
→.rodata
→.data
→.bss
→ 堆 → 栈,但程序员完全可以根据需求调整。 -
如果修改了段的顺序或大小,启动代码(尤其 Reset_Handler 中的数据拷贝与清零逻辑)必须同步修改。
六、二者加载与执行流程的差异
(1)Linux 程序的加载与执行流程:动态抽象与多层协作
Linux 程序(如可执行文件、脚本)的启动是操作系统、加载器、链接器多层协作的结果,核心目标是在多任务环境中安全、高效地为程序分配资源并完成初始化。流程可拆解为 4个关键阶段:
1. 程序文件格式:ELF(Executable and Linkable Format)的 “元数据载体” 作用
Linux 程序以 ELF 格式存储(可执行文件、共享库.so、目标文件.o 均为此格式),其核心价值是通过标准化结构记录程序运行所需的全部元数据,供加载器和链接器解析。ELF 文件包含 3 个关键部分:
- 文件头(ELF Header):记录程序类型(可执行 / 共享库)、目标架构(x86/ARM)、入口点地址(程序开始执行的虚拟地址)、段表偏移量等基础信息。
- 程序头表(Program Header Table):描述程序加载到内存后的 “段” 信息(如.text、.data、.rodata 的虚拟地址、大小、权限(读 / 写 / 执行)、在文件中的偏移量)。
- 节表(Section Header Table):描述编译时的 “节” 信息(如函数、变量所在的具体节,用于调试和链接)。
例如,一个 ELF 可执行文件的段表会明确标注:
.text段虚拟地址0x400500,大小0x2000,权限R-X(只读可执行),在文件中偏移0x500
,加载器正是通过这些信息完成内存映射。
2. 加载触发:execve 系统调用的 “进程替换” 逻辑
用户通过
./program
或exec
系列函数启动程序时,最终会触发execve
系统调用,其核心作用是 “替换当前进程的内存空间”(销毁原有代码 / 数据,加载新程序)。具体步骤包括:
- 参数校验:内核检查 ELF 文件合法性(魔数
7f 45 4c 46
是否正确、架构是否匹配当前 CPU)。- 虚拟地址空间初始化:清空当前进程的虚拟地址空间(释放原有页表映射),保留进程 ID、文件描述符等非内存属性。
- 段映射:根据 ELF 程序头表,为每个段分配虚拟地址并映射物理内存:
- 对
.text
、.rodata
等只读段:内核从 ELF 文件中读取数据,映射到具有 “读 / 执行” 权限的虚拟页(支持多个进程共享同一份物理页,节省内存)。- 对
.data
段:内核读取 ELF 中存储的初始化值,映射到 “读 / 写” 权限的虚拟页(采用 “写时复制” 机制,初始时可能共享物理页,修改时才分配独立页)。- 对
.bss
段:内核不读取文件(ELF 中仅记录大小),直接分配 “读 / 写” 虚拟页并清零。- 栈初始化:在用户虚拟地址空间的高地址分配栈空间,压入命令行参数(argv)、环境变量(envp)等信息(供程序启动时读取)。
3. 动态链接:ld.so 处理共享库的 “重定位” 与 “符号解析”
若程序依赖共享库(如
libc.so
),加载过程需动态链接器(ld.so
,通常路径/lib64/ld-linux-x86-64.so.2
)介入,解决 “共享库地址不确定” 的问题。具体步骤:
- 启动 ld.so:
execve
完成段映射后,会将程序入口点设置为ld.so
的入口(而非程序的_start
),优先执行动态链接器。- 共享库加载:
ld.so
解析程序的.dynamic
段(记录依赖的共享库列表),递归加载所有依赖的.so
文件(同样通过段表映射到虚拟地址)。- 重定位(Relocation):修正程序中引用共享库符号的地址(如
printf
函数的调用地址)。由于共享库加载地址不固定(ASLR 机制导致),编译时无法确定绝对地址,因此 ELF 中会记录 “重定位表”(.rel.plt 等),ld.so
根据实际加载地址更新这些引用(将 “符号偏移” 替换为 “实际虚拟地址”)。- 初始化共享库:执行共享库的构造函数(如
__init_array
中的函数),完成库的初始化。
4. 程序启动:从_start 到 main 的 “用户态初始化”
动态链接完成后,
ld.so
跳转到程序的_start
符号(ELF 文件头中定义的入口点),进入用户态初始化流程:
- _start 函数:由编译器默认生成(如 GCC 的
crt1.o
目标文件),负责准备main
函数的参数:
- 从栈中读取
argc
(参数个数)、argv
(参数数组)、envp
(环境变量数组)。- 调用
__libc_start_main
(glibc 库函数),完成更复杂的初始化:注册atexit
清理函数、设置线程局部存储(TLS)、初始化 stdio 缓冲区等。- 调用 main 函数:
__libc_start_main
最终调用用户编写的main(argc, argv)
,程序正式开始执行。
5. 核心特点:动态性与隔离性
- 地址动态分配:每次启动程序,虚拟地址可能因 ASLR(地址空间布局随机化)而不同,增强安全性。
- 资源按需加载:大程序的段可 “懒加载”(仅在访问时才映射物理页),节省内存。
- 进程隔离:每个程序运行在独立的虚拟地址空间,崩溃不影响其他进程。
(2)STM32 程序的加载与执行流程:硬件绑定与静态初始化
STM32 程序的启动完全依赖硬件物理特性和编译时静态配置,无操作系统介入(裸机或轻量 RTOS),流程可拆解为 4 个关键阶段,每一步都与硬件地址强绑定。
1. 程序文件格式:.bin/.hex 的 “纯二进制” 本质
STM32 程序最终以二进制文件形式存在,核心作用是直接映射到 Flash 物理地址,无复杂元数据(与 ELF 的 “动态描述” 不同)。常见格式:
- .bin:纯二进制数据,按物理地址顺序存储程序指令和数据(如 Flash 起始地址
0x08000000
对应.bin 文件的第 0 字节)。- .hex:在.bin 基础上增加地址信息和校验和(如
:020000040800F2
表示从地址0x08000000
开始写入数据),方便烧录工具定位写入位置。两种格式均不包含 “段表”“入口点” 等动态信息,因为程序的地址和执行顺序完全由硬件和链接脚本固定。
2. 程序加载:烧录工具的 “物理地址写入”
STM32 无 “加载器”,程序进入 Flash 的过程是通过烧录工具手动写入物理地址:
- 烧录原理:STM32 的 Flash 控制器支持通过 JTAG/SWD 接口写入数据,烧录工具(如 ST-Link Utility、OpenOCD)根据程序文件的地址信息,将二进制数据逐字节写入 Flash 的对应物理地址(如
.text
段写入0x08000000
开始的区域,.data
的初始化值写入0x08000000 + .text长度
的区域)。- 写入限制:必须严格匹配 Flash 的物理特性(如扇区大小、擦除粒度),例如写入前需先擦除对应扇区(Flash 的 “先擦后写” 特性)。
3. 上电启动:CPU 从中断向量表开始的 “硬件引导”
STM32 上电(或复位)后,CPU(ARM Cortex-M 系列)的启动流程由硬件架构强制规定,无需软件介入:
- 读取中断向量表:Cortex-M 架构规定,CPU 复位后会从 “0 地址” 读取两个关键值:
- 向量表第 0 项(地址
0x00000000
):栈顶地址(MSP,主栈指针),CPU 会将此值加载到栈指针寄存器(SP),初始化栈空间。- 向量表第 1 项(地址
0x00000004
):复位函数地址(Reset_Handler),CPU 会跳转到该地址执行。- 地址映射:STM32 硬件设计将 “0 地址” 映射到 Flash 起始地址
0x08000000
(可通过 BOOT 引脚配置为映射到 SRAM 或系统存储器,但默认是 Flash),因此实际读取的是 Flash 中的向量表(这也是.isr_vector
段必须放在 Flash 起始地址的原因)。
4. 启动代码:汇编级的 “RAM 初始化” 与 “段准备”
复位函数(Reset_Handler)是一段汇编代码(位于
startup_stm32xxxx.s
启动文件中),负责在进入main
前完成硬件初始化,核心任务是将程序从 “存储状态” 转为 “运行状态”:
- 初始化栈和堆:根据链接脚本定义的栈顶(
_estack
)和堆范围(_Heap_Size
),设置栈指针和堆起始地址(仅做地址标记,无动态分配)。- 复制.data 段到 RAM:
.data
段的初始化值存储在 Flash 中(地址_sidata
),但运行时需在 RAM 中读写,因此启动代码会执行循环:ldr r0, =_sdata ; RAM中.data段的起始地址 ldr r1, =_edata ; RAM中.data段的结束地址 ldr r2, =_sidata ; Flash中.data初始化值的起始地址 copy_loop: cmp r0, r1 ; 判断是否复制完成 beq copy_end ldr r3, [r2], #4 ; 从Flash读取4字节 str r3, [r0], #4 ; 写入RAM b copy_loop copy_end:
- 清零.bss 段:
.bss
段在 RAM 中但无初始化值,启动代码会将其清零(避免使用随机值):ldr r0, =_sbss ; RAM中.bss段的起始地址 ldr r1, =_ebss ; RAM中.bss段的结束地址 mov r2, #0 clear_loop: cmp r0, r1 ; 判断是否清零完成 beq clear_end str r2, [r0], #4 ; 写入0 b clear_loop clear_end:
- 初始化硬件外设:部分启动代码会调用
SystemInit
函数(如配置系统时钟、关闭外设等),确保硬件处于可用状态。- 跳转到 main 函数:完成初始化后,通过
bl main
指令跳转到用户编写的main
函数,程序正式开始执行。这一拷贝与清零过程由 复位向量表指向的 Reset_Handler 显式完成,而非硬件自动完成。
.data
的初始化值存储在 Flash 中,Reset_Handler 启动时逐字复制到 RAM 对应位置。
.bss
在 Flash 中不占空间,Reset_Handler 通过循环清零 RAM 中的.bss
区域。因此,若链接脚本修改了
.data
或.bss
的位置与大小,Reset_Handler 也必须保持一致,否则变量会出现错误值或程序跑飞。5. 核心特点:静态性与硬件依赖性
- 地址固定:所有段的地址在编译时由链接脚本确定,运行时无法修改(无动态映射)。
- 无动态链接:所有代码(包括库函数)必须静态链接到程序中(如使用
-static
编译),不存在共享库。- 启动速度快:无复杂的加载和链接过程,上电到
main
执行通常仅需几十微秒(适合实时场景)。
总结:
Linux 的加载执行是 “软件抽象层主导的动态适配”,适应复杂多任务环境;STM32 是“硬件特性主导的静态执行”,适应资源有限的嵌入式实时场景。理解这一差异,是调试 “程序启动失败” 问题的关键(Linux 查权限 / 依赖,STM32 查链接脚本 / Flash 烧录)