在Linux系统中程序的执行

1 进程的创建

进程是程序执行的实例,在Linux中,进程的创建通常由系统调用fork()完成。fork()创建一个新的进程(子进程),这个子进程是调用fork()的进程(父进程)的副本。子进程获得父进程的所有资源,包括文件描述符、环境变量等。linux系统中,所有用户进程的父进程是init进程,其PID为1,所有用户进程都是通过init进程直接创建或者派生出来。

pid_t pid = fork();
if (pid == 0) {
    // 子进程的代码
} else if (pid > 0) {
    // 父进程的代码
} else {
    // fork() 失败
}

2 可执行程序的加载

当用户在命令行输入一个可执行文件名时,shell 通过 fork 创建一个子进程,在子进程创建后,通过系统调用 exec() 系列函数将一个新的可执行程序加载到进程的地址空间。这些函数包括execl(), execp(), execv()等。exec()不创建新进程,而是用指定的可执行程序替换当前进程的内容。

2.1 exec系统调用的工作流程

这里以 execv 系统调用为例子。

execve 系统调用用于执行一个可执行文件。其原型如下:

int execve(const char *pathname, char *const argv[], char *const envp[]);
  • pathname: 可执行文件的路径。
  • argv[]: 参数列表。
  • envp[]: 环境变量列表。
  1. 进入内核态:当用户调用 execve 时,CPU 从用户态切换到内核态,内核开始处理系统调用。

  2. 检查文件格式:内核首先检查文件的格式,以确认它是一个有效的可执行文件。对于 ELF 文件,内核会检查 ELF 头部。

  3. 读取可执行文件头:内核读取可执行文件的 ELF 头部信息,包括段表、节表等,确定各个段的位置和属性。

  4. 创建新地址空间:内核为进程创建一个新的地址空间,并且丢弃旧的地址空间。这涉及以下操作:

    • 删除旧内存映射:旧的代码段、数据段、堆、栈等被释放。
    • 创建新内存映射:根据 ELF 文件的段表信息,内核将新的代码段、数据段、堆、栈等映射到进程的地址空间。
  5. 加载各个段:内核根据 ELF 文件的段表将代码段、数据段、BSS 段等加载到新创建的地址空间。具体步骤包括:

    • 代码段:映射为只读可执行。
    • 数据段:映射为读写。
    • BSS 段:初始化为零并映射为读写。
  6. 栈初始化:内核初始化进程的栈,并将 argvenvp 拷贝到栈中。同时,为进程设置初始栈指针。

  7. 设置程序入口点:内核将程序的入口地址(通常是 _start)设置为进程的指令指针(EIP/RIP),这是程序执行开始的地方。

  8. 文件描述符继承:通常情况下,子进程会继承父进程的文件描述符(如标准输入、输出、错误)。

内核完成所有上述步骤后,将 CPU 控制权交给新加载的程序:

  • 加载用户态寄存器:恢复进程的寄存器状态,包括栈指针、指令指针等。
  • 切换到用户态:CPU 切换到用户态,开始执行新程序的入口函数。

3 程序的执行和终止

3.1 程序的执行

程序加载后,Linux内核会将控制权交给新加载的程序,程序从入口函数(_start)开始执行,通常由 C 运行时库(crt0)初始化,然后调用 main 函数,例如C语言程序的入口点是main()函数。在执行过程中,程序通过系统调用与内核交互,例如文件操作、内存管理等。

3.2 进程的终止

  1. 进程终止的触发方式

    • 正常终止:进程完成了它的任务,调用 exit 系统调用(例如 exit(0))。
    • 错误终止:进程遇到不可恢复的错误,调用 exit 系统调用并传递非零退出码。
    • 信号:进程收到终止信号(如 SIGTERM, SIGKILL),可能是由其他进程(例如 kill 命令)或系统触发的。
    • 异常:进程执行过程中发生异常(如段错误 SIGSEGV)导致异常终止。
  2. 退出码(Exit Code):进程退出时会返回一个退出码(exit code),通常用来表明进程的终止状态。0 表示正常终止,非零值表示不同的错误或异常情况。

  3. 清理资源

    • 关闭文件描述符:关闭所有由进程打开的文件描述符。
    • 释放内存:释放进程占用的内存,包括堆、栈、以及其他动态分配的内存。
    • 注销内核对象:如信号量、共享内存段、消息队列等。
  4. 处理父进程:当一个进程终止时,内核会发送一个 SIGCHLD 信号给其父进程,以通知父进程子进程已经终止。

    • 父进程调用 wait 系统调用:父进程可以通过调用 waitwaitpid 获取子进程的退出状态,并清理子进程的资源。如果父进程没有处理该信号,子进程将进入僵尸状态。
    • 清理僵尸进程:当子进程终止但其父进程未调用 wait 清理它时,子进程的进程描述符会保留在系统中,称为僵尸进程。僵尸进程占用系统资源但不再执行任何代码。父进程应当及时调用 wait 清理僵尸进程,或者通过进程组和作业控制来确保正确的资源释放。
    • 对于孤儿进程:当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程(init进程)收养,并由 1 号进程对它们完成状态收集工作。

4 进程的调度

在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

  • 运行状态(Running):该时刻进程占用 CPU;
  • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;

当然,进程还有另外两个基本状态:

  • 创建状态(New):进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

在这里插入图片描述

Linux内核使用进程调度算法来管理多个进程的执行。调度器根据调度策略决定哪个进程在什么时候执行。Linux使用的调度算法包括完全公平调度器(CFS)。调度器通过时钟中断周期性地检查当前运行的进程是否需要切换(即上下文切换)。

// 一个简化的调度流程
while (true) {
    struct task_struct *next = pick_next_task();
    switch_to(next);
}

什么时候会发生 CPU 调度呢?通常有以下情况:

  1. 当进程从运行状态转到等待状态;
  2. 当进程从运行状态转到就绪状态;
  3. 当进程从等待状态转到就绪状态;
  4. 当进程从运行状态转到终止状态;

其中发生在 1 和 4 两种情况下的调度称为「非抢占式调度」,2 和 3 两种情况下发生的调度称为「抢占式调度」。

非抢占式的意思就是,当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程。

抢占式调度,顾名思义就是进程正在运行的时,可以被打断,使其把 CPU 让给其他进程。那抢占的原则一般有三种,分别是时间片原则、优先权原则、短作业优先原则。

4.1 上下文切换

上下文切换是指CPU从一个进程切换到另一个进程执行。在上下文切换过程中,内核保存当前进程的状态(如寄存器、程序计数器等),然后加载下一个进程的状态。上下文切换开销较大,频繁的上下文切换会影响系统性能。

4.2 进程调度算法

进程调度算法也称 CPU 调度算法,毕竟进程是由 CPU 调度的。当 CPU 空闲时,操作系统就选择内存中的某个「就绪状态」的进程,并给其分配 CPU。

调度算法影响的是等待时间(进程在就绪队列中等待调度的时间总和),而不能影响进程真在使用 CPU 的时间和 I/O 时间。

常见的调度算法:

  • 先来先服务调度算法
  • 最短作业优先调度算法
  • 高响应比优先调度算法
  • 时间片轮转调度算法
  • 最高优先级调度算法
  • 多级反馈队列调度算法

5 小结

总结来说,Linux系统中程序的执行过程可以分为以下几个关键步骤:

  1. 进程创建:通过fork()创建子进程。
  2. 可执行程序加载:通过exec()加载新的程序到进程地址空间。
  3. 程序执行:从入口点开始执行程序代码,过程中通过系统调用与内核交互。
  4. 进程调度:内核调度器管理多个进程的执行,决定哪个进程在什么时候运行,通过上下文切换实现进程之间的切换。
  5. 进程终止:进程调用 exit() 系统调用退出或收到终止信号退出。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值