在Linux系统中程序是如何执行的?

程序的执行过程在Linux这个全球广为使用的开源操作系统中,是一项复杂且精密的操作。它包括进程创建、可执行文件的加载、程序的实际运行以及进程调度等多个关键步骤。本文将深入探讨这一过程,揭示Linux系统下程序执行的奥秘。

一、进程的创建

在Linux系统中,进程的创建主要通过两种方式实现:使用fork系统调用和使用exec系列系统调用。以下是详细描述:

1. fork系统调用

fork是创建新进程的基础系统调用。新进程(子进程)是调用fork的进程(父进程)的一个副本。子进程继承父进程的几乎所有属性,但有独立的进程ID。fork的具体步骤如下:

fork的执行步骤
  1. 父进程调用fork
    • 父进程在用户态调用fork函数。
  2. 内核分配资源
    • 内核为子进程分配新的进程控制块(PCB),包括唯一的进程ID。
    • 子进程的PCB内容是父进程PCB的副本。
  3. 复制内存空间
    • 内核复制父进程的虚拟内存空间给子进程,包括代码段、数据段、堆和栈。
    • 在现代操作系统中,通常使用写时复制(Copy-On-Write)技术,减少实际的内存复制。
  4. 子进程就绪
    • 子进程进入就绪状态,等待调度器调度运行。
  5. 返回值
    • 父进程的fork调用返回子进程的PID。
    • 子进程的fork调用返回0。

        fork调用示例

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

int main() {
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        printf("This is the child process with PID: %d\n", getpid());
    } else {
        printf("This is the parent process with child PID: %d\n", pid);
    }

    return 0;
}

2. exec系列系统调用

exec系列系统调用用于将当前进程的内存空间替换为另一个程序的内容,从而执行新程序。exec并不会创建新进程,而是用新程序替换当前进程。

exec的执行步骤
  1. 当前进程调用exec
    • 当前进程在用户态调用exec函数,如execl, execp, execv, execle, execve等。
  2. 加载新程序
    • 内核读取新程序的可执行文件(通常是ELF格式)。
    • 内核清空当前进程的内存空间。
  3. 设置新进程环境
    • 内核分配新的虚拟内存空间并加载新程序的代码段、数据段等。
    • 设置新的程序入口点(通常是_start符号)。
  4. 执行新程序
    • 控制权转移到新程序的入口点,开始执行新程序。
exec调用示例
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Before exec\n");

    // 用另一个程序替换当前进程
    execl("/bin/ls", "ls", "-l", (char *)NULL);

    // 如果exec成功,下面的代码不会执行
    perror("exec failed");
    return 1;
}

3. 使用fork和exec创建新进程

通常在实际应用中,forkexec结合使用来创建新进程并执行新程序:

fork + exec示例
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // 在子进程中执行新程序
        execl("/bin/ls", "ls", "-l", (char *)NULL);
        // 如果exec成功,下面的代码不会执行
        perror("exec failed");
        return 1;
    } else {
        // 父进程等待子进程结束
        wait(NULL);
        printf("Child process finished\n");
    }

    return 0;
}

4. 进程创建中的其他细节

写时复制(Copy-On-Write, COW)

fork系统调用中,写时复制技术用于优化内存复制效率。父子进程最初共享同一份物理内存,只有在某个进程尝试写入时,才会复制对应的内存页。

信号处理

父进程和子进程可以通过信号进行通信。例如,父进程可以发送SIGKILL信号终止子进程,子进程可以发送SIGCHLD信号通知父进程它已经终止。

进程调度

新创建的进程由内核调度器管理。调度器决定何时以及哪个进程获得CPU时间,确保所有进程公平运行。

5. 高级进程创建:vfork

vforkfork的一个特殊版本,专为在子进程立即调用exec的情况下优化性能。vfork创建的子进程共享父进程的地址空间,直到子进程调用execexit。使用vfork时,父进程会阻塞,直到子进程调用execexit,以确保父进程不修改共享的地址空间。

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

int main() {
    pid_t pid = vfork();

    if (pid == -1) {
        perror("vfork failed");
        return 1;
    } else if (pid == 0) {
        // 在子进程中执行新程序
        execl("/bin/ls", "ls", "-l", (char *)NULL);
        // 如果exec成功,下面的代码不会执行
        perror("exec failed");
        _exit(1);  // 使用_exit而不是return
    } else {
        // 父进程在子进程调用exec或exit之前会阻塞
        printf("Child process started\n");
    }

    return 0;
}

二、可执行文件的加载

让我们通过一个具体的例子来详细描述这一过程:

#include <stdio.h>

int main() {
    printf("Hello, world!\n");
    return 0;
}

编译生成可执行文件:

gcc -o hello hello.c

执行程序:

./hello
1. Shell解析命令

Shell找到./hello文件,并调用execve系统调用。

2. execve系统调用
  1. 打开文件:内核打开./hello文件。
  2. 读取ELF头:内核读取并解析ELF头,检查文件格式。
  3. 创建新进程内存映像
    • 清空当前进程内存映像。
    • 分配新进程的虚拟地址空间。
  4. 加载段到内存
    • 使用mmap.text段映射到只读且可执行的内存区域。
    • .data段映射到读写内存区域。
    • 初始化.bss段(清零)。
  5. 设置堆栈
    • 设置用户态堆栈,拷贝命令行参数和环境变量。
  6. 动态链接
    • 如果使用动态链接库,加载动态链接器ld-linux.so
    • 动态链接器加载所有依赖的共享库,进行符号解析和重定位。
  7. 设置入口点:将程序计数器设置为程序的入口点地址。
  8. 启动新程序:控制权转移到新程序的入口点,开始执行新程序。

最终,程序输出"Hello, world!",并返回0,内核清理进程资源,返回退出状态给父进程(shell)

三、进程调度

在Linux系统中,进程调度是内核的重要功能之一,负责在多个进程之间分配CPU时间,以确保系统的高效运行和公平性。以下是Linux进程调度的详细描述:

1. 调度器概述

调度器是内核的一部分,它决定了哪个进程在何时运行。Linux内核采用了多种调度算法,以满足不同的系统需求,包括实时性能、交互响应和批处理效率。

2. 调度策略

Linux内核主要有以下几种调度策略:

完全公平调度器(CFS)

CFS是Linux的默认调度器,设计目的是实现“理想处理器共享模型”。它基于红黑树实现,具有以下特点:

  • 公平性:每个进程得到的CPU时间接近于它的权重(优先级)。
  • 动态优先级调整:通过实时计算进程的虚拟运行时间,实现动态优先级调整。
  • O(log N)复杂度:通过红黑树维护进程队列,调度操作复杂度为O(log N)。
实时调度策略

Linux支持两种实时调度策略,适用于对响应时间有严格要求的应用:

  • SCHED_FIFO(先入先出):
    • 实时进程按照优先级和到达顺序运行。
    • 只有在主动放弃CPU或被更高优先级进程抢占时才会停止运行。
  • SCHED_RR(时间片轮转):
    • 基于SCHED_FIFO,增加了时间片轮转机制。
    • 每个实时进程在其优先级范围内轮转运行。
其他策略
  • SCHED_BATCH:适用于非交互式的批处理任务,降低调度频率以提高吞吐量。
  • SCHED_IDLE:适用于优先级最低的进程,只在系统空闲时运行。

3. 调度算法

完全公平调度器(CFS)

CFS的核心概念是虚拟运行时间(vruntime),它表示进程实际运行时间的加权值。CFS的主要步骤如下:

  1. 维护红黑树

    • 进程根据其vruntime值插入红黑树。
    • 最左节点(vruntime最小的进程)最先被调度。
  2. 计算vruntime

    • vruntime = 实际运行时间 / 权重
    • 优先级高的进程(权重大)增长vruntime较慢,优先级低的进程增长较快。
  3. 选择下一个运行进程

    • 调度器选择红黑树最左节点的进程运行。
    • 当一个进程用完其时间片时,更新其vruntime并重新插入红黑树。
实时调度算法
  • SCHED_FIFO

    • 按优先级队列组织进程,高优先级进程先运行。
    • 当前进程主动放弃CPU或被更高优先级进程抢占时,调度器选择下一个进程。
  • SCHED_RR

    • 在SCHED_FIFO基础上,每个进程分配一个固定时间片。
    • 时间片用完时,进程放到队列末尾,调度器选择下一个进程。

4. 调度过程

调度过程由内核中的scheduler函数实现,主要包括以下步骤:

  1. 时间片用尽

    • 当前运行进程的时间片用尽时,触发调度。
    • 更新当前进程的vruntime或轮转其在队列中的位置。
  2. 中断处理

    • 时钟中断(如定时器中断)会定期触发调度。
    • I/O中断可能会唤醒被阻塞的进程,触发调度。
  3. 进程状态切换

    • 进程从等待状态转变为就绪状态时,可能会触发调度。
    • 例如,等待I/O完成的进程被唤醒。
  4. 负载平衡

    • 多核系统中,调度器会尝试将进程负载均衡分配到各个CPU核心上。
    • 通过负载均衡算法,将运行队列中的进程迁移到空闲或负载较轻的核心。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值