跳转至

01-程序加载与进程创建

重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐⭐ 学习时间:2天 必须掌握:是


为什么学这一章?

当你双击运行一个程序,或者在命令行输入./program时,发生了什么?操作系统是如何将磁盘上的可执行文件变成一个运行的程序的?这一章将揭开这个神秘的过程。

学完这一章,你将能够: - ✅ 解释程序加载的完整过程 - ✅ 理解进程和程序的区别 - ✅ 掌握进程创建的关键步骤 - ✅ 使用工具观察进程创建过程


📖 核心概念

1. 程序 vs 进程

这是两个密切相关但截然不同的概念:

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    程序 vs 进程                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  程序(Program)                                              │
│  ├── 定义:存储在磁盘上的可执行文件                            │
│  ├── 特点:静态的、被动的、永久的                              │
│  ├── 存储:硬盘上的二进制文件                                  │
│  └── 示例:/bin/ls, hello.exe                                │
│                                                             │
│  进程(Process)                                              │
│  ├── 定义:正在运行的程序的实例                                │
│  ├── 特点:动态的、主动的、临时的                              │
│  ├── 存储:内存中的数据和代码                                  │
│  └── 示例:运行中的浏览器、游戏                                │
│                                                             │
│  类比:                                                       │
│  ├── 程序 = 菜谱(静态的文本)                                 │
│  └── 进程 = 烹饪过程(动态的活动)                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

进程的核心属性

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    进程的组成                                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 代码段(Text Segment)                                    │
│     └── 存储程序的机器指令                                     │
│                                                             │
│  2. 数据段(Data Segment)                                    │
│     ├── 已初始化数据:全局变量、静态变量                        │
│     └── 未初始化数据(BSS):未初始化的全局/静态变量             │
│                                                             │
│  3. 堆(Heap)                                               │
│     └── 动态分配的内存(malloc/new)                           │
│                                                             │
│  4. 栈(Stack)                                              │
│     └── 函数调用、局部变量、返回地址                           │
│                                                             │
│  5. 进程控制块(PCB)                                         │
│     ├── 进程ID(PID)                                         │
│     ├── 程序计数器(PC)                                       │
│     ├── 寄存器状态                                             │
│     ├── 内存信息                                               │
│     └── 打开的文件描述符                                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

2. 程序加载的完整流程

当你运行一个程序时,操作系统执行以下步骤:

Text Only
┌─────────────────────────────────────────────────────────────────────┐
│                    程序加载与执行流程                                │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  用户输入命令                                                        │
│  $ ./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)格式:

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    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文件类型

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    ELF文件类型                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  可重定位文件(.o)                                            │
│  ├── 编译后的目标文件                                          │
│  ├── 包含代码和数据,但地址未确定                               │
│  └── 需要链接才能执行                                          │
│                                                             │
│  可执行文件                                                   │
│  ├── 链接后的完整程序                                          │
│  ├── 包含确定的内存地址                                        │
│  └── 可以直接运行                                              │
│                                                             │
│  共享对象文件(.so)                                           │
│  ├── 动态链接库                                               │
│  ├── 在运行时被加载                                            │
│  └── 可以被多个程序共享                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

4. 进程创建的底层机制

fork()系统调用

C
#include <unistd.h>
#include <sys/types.h>

pid_t fork(void);

功能:创建当前进程的副本

特点: - 子进程获得父进程数据段、堆、栈的副本 - 父子进程从fork()返回处继续执行 - 父子进程拥有独立的地址空间

返回值: - 父进程:返回子进程的PID(> 0) - 子进程:返回0 - 失败:返回-1

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    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()加载新程序:

C
#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

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    exec()执行过程                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  进程A(Shell)                                               │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  代码                                               │   │
│  │  pid_t pid = fork();                                │   │
│  │  if (pid == 0) {                                    │   │
│  │      execl("/bin/ls", "ls", "-l", NULL);  ← 替换    │   │
│  │  }                                                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                         ↓                                   │
│              ┌──────────┴──────────┐                       │
│              ↓                     ↓                       │
│        ┌─────────┐           ┌─────────┐                   │
│        │  Shell  │           │   ls    │                   │
│        │  继续   │           │  程序   │                   │
│        │  运行   │           │  运行   │                   │
│        └─────────┘           └─────────┘                   │
│                                                             │
│  注意:exec()不创建新进程,只是替换当前进程的内存映像           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

完整的进程创建流程

Text Only
Shell执行命令的完整流程:

┌─────────┐    fork()    ┌─────────┐    exec()    ┌─────────┐
│  Shell  │ ───────────→ │ 子进程  │ ───────────→ │ 新程序  │
│         │              │ (Shell  │              │         │
│         │              │  副本)  │              │         │
└─────────┘              └─────────┘              └─────────┘
    │                        │                        │
    │                        │                        │
    │                        ↓                        │
    │                   等待子进程                     │
    │                   结束(wait)                    │
    │                        │                        │
    │                        ↓                        │
    │                   ┌─────────┐                   │
    └───────────────────│ 子进程  │←──────────────────┘
       回收资源,继续   │ 结束    │    exit()
       等待下一条命令   └─────────┘

🧪 动手实验

实验1:观察ELF文件结构

目的:理解可执行文件的内部结构

步骤

  1. 创建测试程序

    C++
    // hello.cpp
    #include <iostream>
    int global_var = 42;
    int uninit_var;
    
    int main() {
        int local_var = 10;
        static int static_var = 20;
        std::cout << "Hello, World!" << std::endl;
        return 0;
    }
    

  2. 编译

    Bash
    g++ -o hello hello.cpp
    

  3. 查看ELF文件头

    Bash
    readelf -h hello
    

  4. 查看程序头表(段信息)

    Bash
    readelf -l hello
    

  5. 查看节头表(节信息)

    Bash
    readelf -S hello
    

  6. 查看符号表

    Bash
    readelf -s hello | head -20
    

思考问题: - 入口点地址是什么? - 有哪些段需要加载到内存? - 代码段和数据段的权限有什么不同?

实验2:观察进程创建

目的:理解fork()的工作原理

步骤

  1. 创建测试程序

    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;
    }
    

  2. 编译运行

    Bash
    g++ -o fork_test fork_test.cpp
    ./fork_test
    

  3. 观察输出,理解:

  4. 父子进程的PID关系
  5. fork()的返回值
  6. 父子进程的执行顺序

实验3:使用strace跟踪系统调用

目的:观察程序加载过程中的系统调用

步骤

  1. 跟踪程序执行的系统调用

    Bash
    strace -o trace.log ./hello
    

  2. 查看关键系统调用

    Bash
    # 查看execve(加载程序)
    grep execve trace.log  # grep文本搜索:按模式匹配行
    
    # 查看mmap(内存映射)
    grep mmap trace.log | head -10  # |管道:将前一命令的输出作为后一命令的输入
    
    # 查看open(打开文件)
    grep open trace.log | head -10
    

  3. 分析输出,找到:

  4. execve()调用
  5. mmap()映射ELF段
  6. 打开动态链接器

实验4:观察进程内存布局

目的:理解进程的虚拟地址空间

步骤

  1. 创建测试程序

    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;
    }
    

  2. 编译运行

    Bash
    g++ -o memory_layout memory_layout.cpp
    ./memory_layout
    

  3. 在另一个终端查看内存映射

    Bash
    cat /proc/<PID>/maps
    

思考问题: - 代码段、数据段、栈的地址范围是什么? - 堆和栈的增长方向是什么?


💡 核心要点总结

关键概念

  1. 程序 vs 进程
  2. 程序:静态的可执行文件
  3. 进程:动态的运行实例

  4. 程序加载流程

  5. 解析命令 → 创建进程 → 分配内存 → 加载文件 → 设置环境 → 开始执行

  6. ELF文件格式

  7. ELF Header:文件元信息
  8. Program Header:加载信息
  9. Section Header:节的详细信息

  10. 进程创建

  11. fork():创建子进程
  12. exec():加载新程序
  13. wait():等待子进程结束

程序加载的完整流程图

Text Only
用户命令
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(内核空间)。


📚 扩展阅读

  1. 《程序员的自我修养》 - 第6章:可执行文件的装载与进程
  2. 《深入理解计算机系统》 - 第7章:链接,第8章:异常控制流
  3. Linux手册:man 5 elf, man 2 fork, man 3 exec

🎯 下一步

学习 02-内存布局详解,深入了解进程的虚拟地址空间结构。