Linux 系统中程序的执行

进程的创建

如何创建新进程?

fork 系统调用

fork 系统调用可以用来创建新进程,它会创建一个与原进程几乎完全相同的子进程。子进程会复制父进程的所有内容,包括内存、文件描述符等,但它们有不同的 PID 。

pid_t pid = fork();
if (pid < 0) {
    // fork failed
} else if (pid == 0) {
    // Child process
} else {
    // Parent process
}
vfork 系统调用

vforkfork 类似,但它在创建子进程时不会复制父进程的地址空间,而是让子进程共享父进程的地址空间。这种方法减少了资源开销,但容易出现错误,使用时需要格外谨慎。

pid_t pid = vfork();
if (pid < 0) {
    // vfork failed
} else if (pid == 0) {
    // This is the child process
    execl("/bin/ls", "ls", NULL);
    _exit(0); // If exec fails, call _exit to avoid corrupting parent's memory
} else {
    // This is the parent process
}
clone 系统调用

clone 系统调用提供了更细粒度的控制,它允许自定义指定创建的新进程是否共享父进程的地址空间、文件描述符、信号处理等。clonepthread 库用来实现线程的基础。

int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);

clone 使用示例:

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int child_func(void *arg) {
    printf("Hello from the child process!\n");
    return 0;
}

int main() {
    char *stack = malloc(1024 * 1024);
    if (!stack) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }

    pid_t pid = clone(child_func, stack + 1024 * 1024, SIGCHLD, NULL);
    if (pid == -1) {
        perror("clone");
        exit(EXIT_FAILURE);
    }

    printf("Child process created with PID %d\n", pid);
    wait(NULL);
    free(stack);
    return 0;
}

创建进程时系统做了什么?

当一个进程被创建时,系统会进行一系列操作来初始化新进程。

  1. 分配进程描述符:内核首先会为新进程分配一个进程描述符(task_struct),用于存储进程的所有信息,如 PID、状态、优先级、内存管理信息等。
  2. 复制进程上下文:根据创建方法的不同,内核会复制或共享父进程的资源。
    • 地址空间fork 会复制整个地址空间,vfork 共享父进程地址空间,直到子进程执行 exec 系统调用。
    • 文件描述符表:新进程通常继承父进程的文件描述符表,可以选择共享或复制。
    • 信号处理表:子进程通常继承父进程的信号处理设置。
    • 线程信息:如果使用 clone ,可以选择共享线程信息。
  3. 分配唯一的 PID:接下来内核会为新进程分配一个唯一的进程 ID ,即 PID 。PID 是进程在系统中的唯一标识。
  4. 初始化内核栈:每个进程都有自己独立的内核栈,用于在内核态运行时保存上下文信息。内核会自动为新进程分配和初始化内核栈。
  5. 设置 CPU 上下文:接下来内核会设置新进程的 CPU 上下文,如程序计数器、寄存器等。对于 fork ,新进程的程序计数器会指向父进程调用 fork 后的下一条指令。
  6. 将进程加入调度队列:最后,内核会将新进程加入调度队列,等待被调度运行。

可执行程序的加载

如何加载可执行程序?

加载可执行程序主要使用的系统调用是 exec 系列,包括 execlexecpexecleexecvexecve 等。该系列系统调用具有不同的参数形式,但核心功能相同,即将新的可执行文件加载到当前进程的地址空间,并执行它。

int execl(const char *path, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execlp(const char *file, const char *arg, ...);
int execv(const char *path, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

系统如何完成加载?

当进程调用 exec 系列系统调用时,系统会执行一系列步骤来加载并启动新的可执行程序。

  1. 验证可执行文件:系统首先检查提供的文件路径,确保可执行文件存在并且进程有权限访问和执行它。如果检查过程出现失败,exec 调用会返回错误。文件系统负责查找和读取可执行文件,内核通过文件系统接口(VFS,虚拟文件系统)执行路径解析,检查文件权限,以及后续的读取文件头部信息。
  2. 读取可执行文件头:系统首先读取可执行文件的头部,确定文件类型和格式。Linux 主要支持 ELF(Executable and Linkable Format)格式。
  3. 释放当前进程资源:在加载新程序前,系统会释放当前进程的资源,包括:
    • 当前的内存映射(即卸载当前地址空间)。
    • 关闭不需要的文件描述符。
    • 解除现有的共享内存段、文件映射等。
  4. 创建新的地址空间:接下来系统会为新的可执行文件创建新的地址空间,主要包括以下几个部分:
    • 代码段:加载程序的代码段到内存。
    • 数据段:加载初始化的数据段。
    • BSS 段:为未初始化的数据段分配内存。
    • 堆栈段:设置用户堆栈。
  5. 映射共享库:如果可执行文件依赖共享库(动态链接),系统就会加载这些共享库,并将它们映射到进程的地址空间。动态链接器( ld.so )负责解析和绑定这些库函数的地址。
  6. 设置程序入口点:接下来系统会根据可执行文件头部信息设置程序的入口点,即将程序计数器设置为入口点地址。这样就能确保当控制转移到用户态时,CPU 能从正确位置开始执行新程序。
  7. 传递参数和环境变量:最后系统会将 exec 调用传递的参数和环境变量复制到新进程的用户堆栈中。这些参数通常包括命令行参数( argv )和环境变量( envp )。

程序的执行

当程序被加载后,CPU 从可执行文件的入口点开始执行程序指令。这个过程由操作系统内核管理,确保程序能正确执行。

用户模式与内核模式

Linux 进程在两种模式下运行:用户模式和内核模式。用户模式用于执行用户应用程序,而内核模式用于执行系统调用和操作系统服务。通过中断和系统调用,进程可以从用户模式切换到内核模式。

进程的调度

调度算法

Linux 内核中最常用的调度算法是完全公平调度器(Completely Fair Scheduler, CFS)。

顾名思义,CFS 的设计理念是每个进程应当平等地占有 CPU 时间,其核心概念是虚拟运行时间(vruntime),即每个进程已运行的时间加权值。CFS 基于进程的 vruntime 进行调度,优先选择 vruntime 最小的进程运行。

CFS 使用红黑树来维护所有可运行进程的 vruntime 。红黑树是一种自平衡二叉搜索树,能够高效地执行插入、删除和查找操作。

vruntime 是根据实际运行时间 runtime 计算的,并且根据进程的优先级进行加权。优先级较高进程的 vruntime 增长较慢,优先级较低进程的 vruntime 增长较快。具体计算公式如下:

vruntime += delta_exec * load_weight / load_avg;
  • delta_exec:进程实际运行的时间片。
  • load_weight:进程的权重。
  • load_avg:系统的平均负载。

上下文切换

概述

在进行进程调度时,一定会涉及上下文切换。上下文切换是指 CPU 从一个进程切换到另一个进程的过程,涉及保存当前进程的状态,与加载下一个进程的状态。

上下文切换触发条件
  • 进程时间片耗尽
  • 进程阻塞或等待 I/O
  • 可抢占条件下高优先级进程唤醒
  • 进程终止
上下文切换的步骤
  1. 保存当前进程状态
    • 保存 CPU 寄存器:包括通用寄存器、程序计数器(PC)、堆栈指针(SP)等。
    • 保存内核栈:保存当前内核栈中的内容。
    • 保存页表:保存当前进程的页表信息。
  2. 切换内核栈:每个进程有自己独立的内核栈,因此要对内核栈进行切换。切换内核栈是通过修改 CPU 的堆栈指针实现的。
  3. 加载下一个进程状态
    • 加载 CPU 寄存器:加载下一个进程的通用寄存器、程序计数器、堆栈指针等。
    • 加载内核栈:加载下一个进程的内核栈内容。
    • 加载页表:加载下一个进程的页表信息,使 CPU 正确访问进程地址空间。

进程终止

进程何时终止?

进程可以通过多种方式终止,包括正常终止和异常终止。

正常终止

进程正常终止的方式包括:

  1. 从主函数返回

    int main() {
        // Program code
        return 0;  // 或者 return 1; 表示退出状态
    }
    
  2. 调用 exit 函数

    int main() {
        // Program code
        exit(EXIT_SUCCESS);  // 或者 exit(EXIT_FAILURE);
    }
    
  3. 调用 _exit_Exit 函数:与 exit 类似,但不执行标准 I/O 缓冲区的刷新和其他清理操作。

    int main() {
        // Program code
        _exit(EXIT_SUCCESS);
    }
    
异常终止

进程也可能因异常情况而终止,包括:

  1. 收到终止信号:如 SIGKILLSIGSEGV 等。

    kill(pid, SIGKILL);  // 发送SIGKILL信号
    
  2. 运行时错误:如段错误、非法指令等。

  3. 调用 abort 函数:通常用于在程序检测到严重错误时终止自身。

    int main() {
        // Program code
        abort();  // 终止进程
    }
    

进程终止后系统会做什么?

当进程终止时,系统会进行一系列操作来清理进程资源,并更新系统状态。主要步骤包括:

  1. 关闭文件描述符:系统会关闭进程打开的所有文件描述符,包括普通文件、套接字、管道等。
  2. 释放内存:系统会释放进程使用的所有内存资源,包括:
    • 用户空间内存:堆、栈、数据段、代码段等。
    • 内核空间资源:内核栈、内核数据结构等。
  3. 删除进程描述符:系统会从进程表中删除进程描述符,并释放相关数据结构。
  4. 发送信号:若有父进程,系统会向进程的父进程发送 SIGCHLD 信号,通知父进程子进程已终止。
  5. 进程僵尸状态:在父进程调用 wait 系列系统调用之前,子进程的终止状态和部分信息会保留在系统中,这时进程处于僵尸状态。这样可以确保父进程能够获取子进程的退出状态。
  6. 清理和回收:父进程调用 waitwaitpid 获取子进程的退出状态后,系统会彻底清理和回收子进程的所有资源。
  • 20
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值