01-程序加载与进程创建¶
重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐⭐ 学习时间:2天 必须掌握:是
为什么学这一章?¶
当你双击运行一个程序,或者在命令行输入./program时,发生了什么?操作系统是如何将磁盘上的可执行文件变成一个运行的程序的?这一章将揭开这个神秘的过程。
学完这一章,你将能够: - ✅ 解释程序加载的完整过程 - ✅ 理解进程和程序的区别 - ✅ 掌握进程创建的关键步骤 - ✅ 使用工具观察进程创建过程
📖 核心概念¶
1. 程序 vs 进程¶
这是两个密切相关但截然不同的概念:
┌─────────────────────────────────────────────────────────────┐
│ 程序 vs 进程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 程序(Program) │
│ ├── 定义:存储在磁盘上的可执行文件 │
│ ├── 特点:静态的、被动的、永久的 │
│ ├── 存储:硬盘上的二进制文件 │
│ └── 示例:/bin/ls, hello.exe │
│ │
│ 进程(Process) │
│ ├── 定义:正在运行的程序的实例 │
│ ├── 特点:动态的、主动的、临时的 │
│ ├── 存储:内存中的数据和代码 │
│ └── 示例:运行中的浏览器、游戏 │
│ │
│ 类比: │
│ ├── 程序 = 菜谱(静态的文本) │
│ └── 进程 = 烹饪过程(动态的活动) │
│ │
└─────────────────────────────────────────────────────────────┘
进程的核心属性¶
┌─────────────────────────────────────────────────────────────┐
│ 进程的组成 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 代码段(Text Segment) │
│ └── 存储程序的机器指令 │
│ │
│ 2. 数据段(Data Segment) │
│ ├── 已初始化数据:全局变量、静态变量 │
│ └── 未初始化数据(BSS):未初始化的全局/静态变量 │
│ │
│ 3. 堆(Heap) │
│ └── 动态分配的内存(malloc/new) │
│ │
│ 4. 栈(Stack) │
│ └── 函数调用、局部变量、返回地址 │
│ │
│ 5. 进程控制块(PCB) │
│ ├── 进程ID(PID) │
│ ├── 程序计数器(PC) │
│ ├── 寄存器状态 │
│ ├── 内存信息 │
│ └── 打开的文件描述符 │
│ │
└─────────────────────────────────────────────────────────────┘
2. 程序加载的完整流程¶
当你运行一个程序时,操作系统执行以下步骤:
┌─────────────────────────────────────────────────────────────────────┐
│ 程序加载与执行流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 用户输入命令 │
│ $ ./hello │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 步骤1:Shell解析命令 │ │
│ │ ├── 解析命令行参数 │ │
│ │ └── 查找可执行文件路径 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 步骤2:创建进程 │ │
│ │ ├── 调用fork()创建子进程 │ │
│ │ ├── 分配进程控制块(PCB) │ │
│ │ └── 分配唯一的进程ID(PID) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 步骤3:分配虚拟地址空间 │ │
│ │ ├── 创建页表 │ │
│ │ ├── 设置代码段、数据段、堆、栈的地址范围 │ │
│ │ └── 建立虚拟地址到物理地址的映射 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 步骤4:加载可执行文件 │ │
│ │ ├── 读取ELF文件头 │ │
│ │ ├── 根据Program Header加载各段到内存 │ │
│ │ │ ├── 代码段(.text)→ 映射到内存 │ │
│ │ │ ├── 数据段(.data)→ 映射到内存 │ │
│ │ │ └── BSS段 → 分配零初始化内存 │ │
│ │ └── 处理动态链接(如果需要) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 步骤5:设置执行环境 │ │
│ │ ├── 设置命令行参数(argc, argv) │ │
│ │ ├── 设置环境变量 │ │
│ │ ├── 初始化标准输入输出(stdin, stdout, stderr) │ │
│ │ └── 设置程序入口点(_start → main) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 步骤6:开始执行 │ │
│ │ ├── 跳转到程序入口点 │ │
│ │ ├── 执行启动代码(C运行时库初始化) │ │
│ │ ├── 调用main函数 │ │
│ │ └── 程序开始执行用户代码 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 程序运行中... │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 步骤7:程序结束 │ │
│ │ ├── main函数返回 │ │
│ │ ├── 执行清理代码(C++析构函数、atexit注册的函数) │ │
│ │ ├── 刷新缓冲区、关闭文件 │ │
│ │ └── 调用exit系统调用,进程终止 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 操作系统回收资源 │
│ ├── 释放内存 │
│ ├── 关闭文件描述符 │
│ └── 清理进程控制块 │
│ │
└─────────────────────────────────────────────────────────────────────┘
3. ELF文件格式详解¶
Linux/Unix系统的可执行文件使用ELF(Executable and Linkable Format)格式:
┌─────────────────────────────────────────────────────────────┐
│ ELF文件结构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ELF Header(ELF头部) │
│ ├── Magic Number: 0x7F 'E' 'L' 'F' │
│ ├── 文件类型:可执行文件/目标文件/共享库 │
│ ├── 目标架构:x86-64 / ARM / RISC-V │
│ ├── 入口点地址:程序开始执行的地址 │
│ └── Program Header和Section Header的位置 │
│ │
│ Program Header Table(程序头表) │
│ ├── 描述如何创建进程映像 │
│ ├── 每个段(Segment)的加载信息 │
│ │ ├── 类型:LOAD(加载到内存)/ DYNAMIC(动态链接) │
│ │ ├── 文件偏移:在文件中的位置 │
│ │ ├── 虚拟地址:加载到内存的地址 │
│ │ ├── 文件大小:在文件中占用的空间 │
│ │ └── 内存大小:在内存中占用的空间 │
│ └── 权限:读/写/执行 │
│ │
│ Section Header Table(节头表) │
│ ├── 描述每个节(Section)的信息 │
│ ├── .text:代码段 │
│ ├── .data:已初始化数据段 │
│ ├── .bss:未初始化数据段 │
│ ├── .rodata:只读数据段 │
│ ├── .symtab:符号表 │
│ ├── .strtab:字符串表 │
│ └── ... │
│ │
│ 实际数据 │
│ ├── 代码(机器指令) │
│ ├── 数据(全局变量等) │
│ └── 其他信息(符号表、重定位表等) │
│ │
└─────────────────────────────────────────────────────────────┘
ELF文件类型¶
┌─────────────────────────────────────────────────────────────┐
│ ELF文件类型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 可重定位文件(.o) │
│ ├── 编译后的目标文件 │
│ ├── 包含代码和数据,但地址未确定 │
│ └── 需要链接才能执行 │
│ │
│ 可执行文件 │
│ ├── 链接后的完整程序 │
│ ├── 包含确定的内存地址 │
│ └── 可以直接运行 │
│ │
│ 共享对象文件(.so) │
│ ├── 动态链接库 │
│ ├── 在运行时被加载 │
│ └── 可以被多个程序共享 │
│ │
└─────────────────────────────────────────────────────────────┘
4. 进程创建的底层机制¶
fork()系统调用¶
功能:创建当前进程的副本
特点: - 子进程获得父进程数据段、堆、栈的副本 - 父子进程从fork()返回处继续执行 - 父子进程拥有独立的地址空间
返回值: - 父进程:返回子进程的PID(> 0) - 子进程:返回0 - 失败:返回-1
┌─────────────────────────────────────────────────────────────┐
│ fork()执行过程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 父进程 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 代码 │ │
│ │ int main() { │ │
│ │ pid_t pid = fork(); ← 创建子进程 │ │
│ │ if (pid == 0) { │ │
│ │ // 子进程代码 │ │
│ │ } else if (pid > 0) { │ │
│ │ // 父进程代码 │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────┴──────────┐ │
│ ↓ ↓ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 父进程 │ │ 子进程 │ │
│ │ PID: 100│ │ PID: 101│ │
│ │ fork() │ │ fork() │ │
│ │ 返回101 │ │ 返回0 │ │
│ └─────────┘ └─────────┘ │
│ │
│ 注意:子进程是父进程的副本,但PID不同 │
│ │
└─────────────────────────────────────────────────────────────┘
exec()系列函数¶
fork()创建子进程后,通常需要exec()加载新程序:
#include <unistd.h>
int execl(const char *path, const char *arg, ...); // 指针:存储变量的内存地址
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
功能:用新程序替换当前进程的地址空间
特点: - 不创建新进程,只是替换当前进程的内容 - 如果成功,不会返回(被新程序替代) - 如果失败,返回-1
┌─────────────────────────────────────────────────────────────┐
│ exec()执行过程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 进程A(Shell) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 代码 │ │
│ │ pid_t pid = fork(); │ │
│ │ if (pid == 0) { │ │
│ │ execl("/bin/ls", "ls", "-l", NULL); ← 替换 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────┴──────────┐ │
│ ↓ ↓ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Shell │ │ ls │ │
│ │ 继续 │ │ 程序 │ │
│ │ 运行 │ │ 运行 │ │
│ └─────────┘ └─────────┘ │
│ │
│ 注意:exec()不创建新进程,只是替换当前进程的内存映像 │
│ │
└─────────────────────────────────────────────────────────────┘
完整的进程创建流程¶
Shell执行命令的完整流程:
┌─────────┐ fork() ┌─────────┐ exec() ┌─────────┐
│ Shell │ ───────────→ │ 子进程 │ ───────────→ │ 新程序 │
│ │ │ (Shell │ │ │
│ │ │ 副本) │ │ │
└─────────┘ └─────────┘ └─────────┘
│ │ │
│ │ │
│ ↓ │
│ 等待子进程 │
│ 结束(wait) │
│ │ │
│ ↓ │
│ ┌─────────┐ │
└───────────────────│ 子进程 │←──────────────────┘
回收资源,继续 │ 结束 │ exit()
等待下一条命令 └─────────┘
🧪 动手实验¶
实验1:观察ELF文件结构¶
目的:理解可执行文件的内部结构
步骤:
-
创建测试程序
-
编译
-
查看ELF文件头
-
查看程序头表(段信息)
-
查看节头表(节信息)
-
查看符号表
思考问题: - 入口点地址是什么? - 有哪些段需要加载到内存? - 代码段和数据段的权限有什么不同?
实验2:观察进程创建¶
目的:理解fork()的工作原理
步骤:
-
创建测试程序
C++// fork_test.cpp #include <iostream> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { std::cout << "Parent process PID: " << getpid() << std::endl; pid_t pid = fork(); if (pid < 0) { std::cerr << "Fork failed!" << std::endl; return 1; } else if (pid == 0) { // 子进程 std::cout << "Child process PID: " << getpid() << ", Parent PID: " << getppid() << std::endl; sleep(2); std::cout << "Child process exiting..." << std::endl; return 0; } else { // 父进程 std::cout << "Parent process, child PID: " << pid << std::endl; int status; wait(&status); // 等待子进程结束 std::cout << "Child process exited with status: " << WEXITSTATUS(status) << std::endl; } return 0; } -
编译运行
-
观察输出,理解:
- 父子进程的PID关系
- fork()的返回值
- 父子进程的执行顺序
实验3:使用strace跟踪系统调用¶
目的:观察程序加载过程中的系统调用
步骤:
-
跟踪程序执行的系统调用
-
查看关键系统调用
-
分析输出,找到:
- execve()调用
- mmap()映射ELF段
- 打开动态链接器
实验4:观察进程内存布局¶
目的:理解进程的虚拟地址空间
步骤:
-
创建测试程序
C++// memory_layout.cpp #include <iostream> // 引入头文件 #include <unistd.h> int global_init = 42; // 已初始化全局变量 int global_uninit; // 未初始化全局变量 void func() { int local = 10; // 局部变量 std::cout << "Local variable address: " << &local << std::endl; } int main() { std::cout << "PID: " << getpid() << std::endl; std::cout << "Global init address: " << &global_init << std::endl; std::cout << "Global uninit address: " << &global_uninit << std::endl; func(); std::cout << "Check /proc/" << getpid() << "/maps" << std::endl; std::cout << "Press Enter to exit..." << std::endl; std::cin.get(); return 0; } -
编译运行
-
在另一个终端查看内存映射
思考问题: - 代码段、数据段、栈的地址范围是什么? - 堆和栈的增长方向是什么?
💡 核心要点总结¶
关键概念¶
- 程序 vs 进程
- 程序:静态的可执行文件
-
进程:动态的运行实例
-
程序加载流程
-
解析命令 → 创建进程 → 分配内存 → 加载文件 → 设置环境 → 开始执行
-
ELF文件格式
- ELF Header:文件元信息
- Program Header:加载信息
-
Section Header:节的详细信息
-
进程创建
- fork():创建子进程
- exec():加载新程序
- wait():等待子进程结束
程序加载的完整流程图¶
用户命令
↓
Shell解析
↓
fork()创建子进程
↓
分配虚拟地址空间
↓
加载ELF文件
├── 读取ELF Header
├── 映射代码段
├── 映射数据段
└── 处理动态链接
↓
设置执行环境
├── 命令行参数
├── 环境变量
└── 标准IO
↓
跳转到入口点
↓
执行启动代码
↓
调用main()
↓
程序运行
❓ 常见问题¶
Q1:fork()后,父子进程的变量是共享的吗?
A:fork()时,子进程获得父进程内存的副本。虽然初始值相同,但后续修改互不影响(写时复制技术)。
Q2:为什么需要exec()?直接用fork()不行吗?
A:fork()只是复制当前进程,exec()才能加载新程序。Shell需要fork()创建子进程,然后用exec()执行用户命令。
Q3:ELF文件中的段(Segment)和节(Section)有什么区别?
A: - 节(Section):编译时的逻辑组织,如.text、.data等 - 段(Segment):运行时的加载单位,一个段可能包含多个节 - 链接器使用节,加载器使用段
Q4:进程的虚拟地址空间有多大?
A:在64位Linux系统上,理论上是2^64字节,但实际可用的是128TB(用户空间)+ 128TB(内核空间)。
📚 扩展阅读¶
- 《程序员的自我修养》 - 第6章:可执行文件的装载与进程
- 《深入理解计算机系统》 - 第7章:链接,第8章:异常控制流
- Linux手册:man 5 elf, man 2 fork, man 3 exec
🎯 下一步¶
学习 02-内存布局详解,深入了解进程的虚拟地址空间结构。