程序启动过程
我看网上很少讲一个程序到运行的过程,那我写一篇,目前我也在学习,就把目前知道的给大家分享一下。但是还是需要研究这块的有一定的虚拟内存,页表的基础,不过也可以直接现场百度,也不影响阅读。知识串起来也就通了。
程序的加载、运行,在磁盘,物理内存,在虚拟内存中所发生的动作,使用的工具,具体的实现方法有哪些?
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 先 |
磁盘侧 | 仅读取 ELF 文件的头部信息(文件头、程序头表),验证文件格式合法性,不读取代码和数据。 |
虚拟内存侧 | 清空旧进程的全部虚拟地址映射,销毁旧页表,为新程序准备空白的地址空间。 |
物理内存侧 | 几乎不分配业务内存,仅内核自身创建进程描述符、页表等数据结构。 |
实现机制 |
|
观测工具 |
|
阶段 2:构建布局 —— 解析程序头,建立虚拟地址映射
这一步只建立「地址规划」和「磁盘关联」,不加载任何真实数据到物理内存。
维度 | 具体动作 |
核心动作 | 内核读取 ELF 的程序头表(Program Header),按加载段的权限、大小,在虚拟地址空间中分配对应区间,并在页表中登记映射关系。 |
磁盘侧 | 读取程序头表,记录每个加载段在文件中的偏移、长度、权限。 |
虚拟内存侧 | 从低地址到高地址完成布局: 1. 只读加载段: 2. 读写加载段: 4. mmap 映射区:动态库、文件映射、匿名共享内存 5. 栈区:从高地址向低地址增长 6. 顶端为内核空间,用户态不可访问 |
页表状态 | 页表项不指向物理内存,而是记录「该虚拟页 → 对应磁盘文件的某某字节偏移」,并标记为「未驻留(Not Present)」。 特殊处理: |
物理内存侧 | 仍然没有程序的代码和数据,仅页表本身占用少量物理内存。 |
实现机制 | 虚拟内存分配器,页表初始化,文件映射( |
观测工具 |
查看程序头与加载段; 查看进程虚拟地址布局; 可视化虚拟内存分布。 |
阶段 3:首次执行 —— 缺页中断,载入第一页代码
当 CPU 开始从程序入口地址取指执行时,第一次真正的内存加载才会发生。
维度 | 具体动作 |
触发条件 | CPU 输出虚拟地址 → MMU 查页表 → 发现标记为「未驻留」 → 触发缺页异常(Page Fault)→ 陷入内核态处理。 |
磁盘侧 | 内核根据页表记录的文件偏移,从磁盘 ELF 文件中读取对应 4KB(一页)的机器指令数据。 |
虚拟内存侧 | 页表项从「虚拟地址→磁盘偏移」更新为「虚拟地址→物理页地址」,设置只读 + 可执行权限。 |
物理内存侧 | 分配 1 个空闲物理页帧,将磁盘读取的机器指令写入该物理页。 |
处理完成 | 内核退出异常,返回用户态,CPU 重新执行刚才的指令,此时 MMU 可以正常翻译地址,直接从物理内存取指执行。 |
实现机制 | 请求调页,MMU 缺页异常,块设备 IO 读取。 |
观测工具 |
统计缺页中断次数; 查看主次缺页数量。 |
关键结论:程序启动到第一条指令执行前,物理内存里没有任何该程序的代码;代码是执行到哪里,才加载到哪里。
阶段 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 换入。
程序退出
- 释放所有物理页帧,归还操作系统;
- 清空页表,销毁虚拟地址空间;
- 关闭打开的文件,释放进程相关内核数据结构。
核心工具汇总表
工具 | 用途 | 常用命令 |
| 分析 ELF 文件结构、段、程序头 |
|
| 反汇编、查看段属性 |
|
| 查看进程虚拟地址空间布局、权限、映射关系 |
|
| 可视化进程虚拟内存分布 |
|
| 跟踪系统调用,观察 exec、mmap、brk 等 |
|
| 统计缺页中断、性能事件 |
|
| 查看物理内存占用(RSS)、虚拟内存大小(VIRT) |
|
关键认知澄清
- 不是 “全量加载再运行”:程序不是整个拷进内存才开始执行,而是边执行边加载,启动速度和程序总大小无关,只和入口代码量有关。
- 物理内存远小于磁盘大小:一个 100MB 的程序,运行时可能只用到几 MB 物理内存,没执行到的代码永远不会载入。
- 多进程共享极大节省内存:系统里运行 100 个 bash,代码段物理内存只有一份,不是 100 份。
- 虚拟地址空间是 “规划”,不是 “占用”:虚拟地址大不代表物理内存占用多,只有真正访问过、触发过缺页的页面,才会占用物理内存。
