程序启动过程

程序启动过程

我看网上很少讲一个程序到运行的过程,那我写一篇,目前我也在学习,就把目前知道的给大家分享一下。但是还是需要研究这块的有一定的虚拟内存,页表的基础,不过也可以直接现场百度,也不影响阅读。知识串起来也就通了。

程序的加载、运行,在磁盘,物理内存,在虚拟内存中所发生的动作,使用的工具,具体的实现方法有哪些?

1. 两个视角的区分

很多人混淆「段」的概念,本质是两个不同阶段的视角:

  • 链接视角(Section).text / .rodata / .data / .bss等,是编译器、链接器组织代码数据的细粒度单位,用于编译链接;
  • 加载视角(Segment):内核加载程序时,会把权限相同的多个 section 合并成一个加载段(Segment),比如.text + .rodata合并为「只读加载段」,.data + .bss合并为「读写加载段」。

我们讲加载流程时,内核看到的是 Segment,而不是单个的 Section。

2. 三大底层支撑机制

  • 请求调页(Demand Paging):用到哪一页,才从磁盘载入哪一页,不提前全量加载;
  • 写时复制(COW, Copy-On-Write):共享页面被修改时,才单独拷贝一份私有副本,读时共享、写时分离;
  • MMU + 页表:虚拟地址与物理地址的翻译桥梁,也是缺页中断、权限检查的硬件执行者。

完整流程分阶段详解

阶段 1:触发启动 ——execve系统调用替换进程映像

这是程序运行的起点,由 Shell 或父进程触发。

维度

具体动作

用户态触发

Shell 先fork创建子进程,再调用execve("./app", argv, envp)系统调用,用新程序替换子进程的全部地址空间。

磁盘侧

仅读取 ELF 文件的头部信息(文件头、程序头表),验证文件格式合法性,不读取代码和数据。

虚拟内存侧

清空旧进程的全部虚拟地址映射,销毁旧页表,为新程序准备空白的地址空间。

物理内存侧

几乎不分配业务内存,仅内核自身创建进程描述符、页表等数据结构。

实现机制

execve系统调用,ELF 加载器(内核中的binfmt_elf模块)。

观测工具

strace ./app跟踪系统调用;readelf -h app查看 ELF 文件头。


阶段 2:构建布局 —— 解析程序头,建立虚拟地址映射

这一步只建立「地址规划」和「磁盘关联」,不加载任何真实数据到物理内存。

维度

具体动作

核心动作

内核读取 ELF 的程序头表(Program Header),按加载段的权限、大小,在虚拟地址空间中分配对应区间,并在页表中登记映射关系。

磁盘侧

读取程序头表,记录每个加载段在文件中的偏移、长度、权限。

虚拟内存侧

从低地址到高地址完成布局:

1. 只读加载段:.text + .rodata,虚拟地址连续,权限「只读 + 可执行」

2. 读写加载段:.data + .bss,虚拟地址连续,权限「可读可写」3. 堆区:初始大小,从低地址向高地址增长

4. mmap 映射区:动态库、文件映射、匿名共享内存

5. 栈区:从高地址向低地址增长

6. 顶端为内核空间,用户态不可访问

页表状态

页表项不指向物理内存,而是记录「该虚拟页 → 对应磁盘文件的某某字节偏移」,并标记为「未驻留(Not Present)」

特殊处理:.bss段不关联磁盘,标记为「匿名零页」,访问时直接分配全零物理页。

物理内存侧

仍然没有程序的代码和数据,仅页表本身占用少量物理内存。

实现机制

虚拟内存分配器,页表初始化,文件映射(mmap内核实现)。

观测工具

readelf -l app

查看程序头与加载段;cat /proc/<pid>/maps

查看进程虚拟地址布局;pmap <pid>

可视化虚拟内存分布。


阶段 3:首次执行 —— 缺页中断,载入第一页代码

当 CPU 开始从程序入口地址取指执行时,第一次真正的内存加载才会发生。

维度

具体动作

触发条件

CPU 输出虚拟地址 → MMU 查页表 → 发现标记为「未驻留」 → 触发缺页异常(Page Fault)→ 陷入内核态处理。

磁盘侧

内核根据页表记录的文件偏移,从磁盘 ELF 文件中读取对应 4KB(一页)的机器指令数据。

虚拟内存侧

页表项从「虚拟地址→磁盘偏移」更新为「虚拟地址→物理页地址」,设置只读 + 可执行权限。

物理内存侧

分配 1 个空闲物理页帧,将磁盘读取的机器指令写入该物理页。

处理完成

内核退出异常,返回用户态,CPU 重新执行刚才的指令,此时 MMU 可以正常翻译地址,直接从物理内存取指执行。

实现机制

请求调页,MMU 缺页异常,块设备 IO 读取。

观测工具

perf stat ./app

统计缺页中断次数;ps -o maj_flt,min_flt <pid>

查看主次缺页数量。

关键结论:程序启动到第一条指令执行前,物理内存里没有任何该程序的代码;代码是执行到哪里,才加载到哪里


阶段 4:持续运行 —— 按需加载 + 写时复制

程序运行过程中,不同区域的缺页处理逻辑不同,对应之前讲的各个段特性:

1. 只读区域(.text/.rodata)
  • 首次访问:触发缺页,从磁盘载入物理页,设置只读权限;
  • 跨进程共享:多个进程运行同一个程序时,代码页物理内存只有一份,所有进程共享,极大节省内存;
  • 全程只读:不会触发写操作,一旦写入直接触发段错误。
2. 已初始化数据区(.data)
  • 首次读取:和只读段一样,从磁盘载入初始值,多进程共享同一份物理页;
  • 首次写入:触发 ** 写时复制(COW)** 异常 → 内核分配一个新的物理页 → 把原页内容拷贝到新页 → 页表指向新私有页 → 标记为可写 → 再完成写入操作。
  • 结果:读时共享节省内存,写时私有保证进程隔离。
3. 匿名内存区(.bss/ 堆 / 栈)
  • 共同特点:不关联任何磁盘文件,初始值全为 0;
  • 首次访问:缺页时内核直接分配一个全零物理页,不产生磁盘 IO,属于「次缺页」;
  • 堆扩展:调用malloc空间不足时,通过brk/sbrk系统调用扩大堆区的虚拟地址范围;
  • 栈扩展:访问到栈边界时,内核自动向下扩展栈的虚拟空间,分配物理页。
4. 内存映射区(动态库 /mmap 文件)
  • 动态库(.so)的加载逻辑和主程序完全一致:只读代码段共享、读写数据段写时复制;
  • 普通文件 mmap:读写逻辑同上,修改后可通过msync同步回磁盘。

阶段 5:稳态运行与内存回收

正常执行

常用代码和数据都已载入物理内存,MMU 直接地址翻译,无中断,CPU 全速执行。

内存不足时
  • 文件页(text/rodata/ 文件映射):直接丢弃,因为磁盘上有原始副本,下次用到再重新读入;
  • 匿名页(堆 / 栈 /bss):换出到 swap 交换分区,腾出物理内存,下次访问时再从 swap 换入。
程序退出
  • 释放所有物理页帧,归还操作系统;
  • 清空页表,销毁虚拟地址空间;
  • 关闭打开的文件,释放进程相关内核数据结构。

核心工具汇总表

工具

用途

常用命令

readelf

分析 ELF 文件结构、段、程序头

readelf -h文件头;

readelf -S段表;

readelf -l加载段

objdump

反汇编、查看段属性

objdump -d反汇编代码段

cat /proc/<pid>/maps

查看进程虚拟地址空间布局、权限、映射关系

cat /proc/$$/maps查看当前 shell

pmap

可视化进程虚拟内存分布

pmap -x <pid>

strace

跟踪系统调用,观察 exec、mmap、brk 等

strace ./app

perf

统计缺页中断、性能事件

perf stat -e faults ./app

ps / top

查看物理内存占用(RSS)、虚拟内存大小(VIRT)

top按进程查看


关键认知澄清

  1. 不是 “全量加载再运行”:程序不是整个拷进内存才开始执行,而是边执行边加载,启动速度和程序总大小无关,只和入口代码量有关。
  2. 物理内存远小于磁盘大小:一个 100MB 的程序,运行时可能只用到几 MB 物理内存,没执行到的代码永远不会载入。
  3. 多进程共享极大节省内存:系统里运行 100 个 bash,代码段物理内存只有一份,不是 100 份。
  4. 虚拟地址空间是 “规划”,不是 “占用”:虚拟地址大不代表物理内存占用多,只有真正访问过、触发过缺页的页面,才会占用物理内存。