程序的执行过程在Linux这个全球广为使用的开源操作系统中,是一项复杂且精密的操作。它包括进程创建、可执行文件的加载、程序的实际运行以及进程调度等多个关键步骤。本文将深入探讨这一过程,揭示Linux系统下程序执行的奥秘。
一、进程的创建
在Linux系统中,进程的创建主要通过两种方式实现:使用fork
系统调用和使用exec
系列系统调用。以下是详细描述:
1. fork系统调用
fork
是创建新进程的基础系统调用。新进程(子进程)是调用fork
的进程(父进程)的一个副本。子进程继承父进程的几乎所有属性,但有独立的进程ID。fork
的具体步骤如下:
fork的执行步骤
- 父进程调用fork:
- 父进程在用户态调用
fork
函数。
- 父进程在用户态调用
- 内核分配资源:
- 内核为子进程分配新的进程控制块(PCB),包括唯一的进程ID。
- 子进程的PCB内容是父进程PCB的副本。
- 复制内存空间:
- 内核复制父进程的虚拟内存空间给子进程,包括代码段、数据段、堆和栈。
- 在现代操作系统中,通常使用写时复制(Copy-On-Write)技术,减少实际的内存复制。
- 子进程就绪:
- 子进程进入就绪状态,等待调度器调度运行。
- 返回值:
- 父进程的
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的执行步骤
- 当前进程调用exec:
- 当前进程在用户态调用
exec
函数,如execl
,execp
,execv
,execle
,execve
等。
- 当前进程在用户态调用
- 加载新程序:
- 内核读取新程序的可执行文件(通常是ELF格式)。
- 内核清空当前进程的内存空间。
- 设置新进程环境:
- 内核分配新的虚拟内存空间并加载新程序的代码段、数据段等。
- 设置新的程序入口点(通常是
_start
符号)。
- 执行新程序:
- 控制权转移到新程序的入口点,开始执行新程序。
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创建新进程
通常在实际应用中,fork
和exec
结合使用来创建新进程并执行新程序:
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
vfork
是fork
的一个特殊版本,专为在子进程立即调用exec
的情况下优化性能。vfork
创建的子进程共享父进程的地址空间,直到子进程调用exec
或exit
。使用vfork
时,父进程会阻塞,直到子进程调用exec
或exit
,以确保父进程不修改共享的地址空间。
#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系统调用
- 打开文件:内核打开
./hello
文件。 - 读取ELF头:内核读取并解析ELF头,检查文件格式。
- 创建新进程内存映像:
- 清空当前进程内存映像。
- 分配新进程的虚拟地址空间。
- 加载段到内存:
- 使用
mmap
将.text
段映射到只读且可执行的内存区域。 - 将
.data
段映射到读写内存区域。 - 初始化
.bss
段(清零)。
- 使用
- 设置堆栈:
- 设置用户态堆栈,拷贝命令行参数和环境变量。
- 动态链接:
- 如果使用动态链接库,加载动态链接器
ld-linux.so
。 - 动态链接器加载所有依赖的共享库,进行符号解析和重定位。
- 如果使用动态链接库,加载动态链接器
- 设置入口点:将程序计数器设置为程序的入口点地址。
- 启动新程序:控制权转移到新程序的入口点,开始执行新程序。
最终,程序输出"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的主要步骤如下:
-
维护红黑树:
- 进程根据其vruntime值插入红黑树。
- 最左节点(vruntime最小的进程)最先被调度。
-
计算vruntime:
- vruntime = 实际运行时间 / 权重
- 优先级高的进程(权重大)增长vruntime较慢,优先级低的进程增长较快。
-
选择下一个运行进程:
- 调度器选择红黑树最左节点的进程运行。
- 当一个进程用完其时间片时,更新其vruntime并重新插入红黑树。
实时调度算法
-
SCHED_FIFO:
- 按优先级队列组织进程,高优先级进程先运行。
- 当前进程主动放弃CPU或被更高优先级进程抢占时,调度器选择下一个进程。
-
SCHED_RR:
- 在SCHED_FIFO基础上,每个进程分配一个固定时间片。
- 时间片用完时,进程放到队列末尾,调度器选择下一个进程。
4. 调度过程
调度过程由内核中的scheduler
函数实现,主要包括以下步骤:
-
时间片用尽:
- 当前运行进程的时间片用尽时,触发调度。
- 更新当前进程的vruntime或轮转其在队列中的位置。
-
中断处理:
- 时钟中断(如定时器中断)会定期触发调度。
- I/O中断可能会唤醒被阻塞的进程,触发调度。
-
进程状态切换:
- 进程从等待状态转变为就绪状态时,可能会触发调度。
- 例如,等待I/O完成的进程被唤醒。
-
负载平衡:
- 多核系统中,调度器会尝试将进程负载均衡分配到各个CPU核心上。
- 通过负载均衡算法,将运行队列中的进程迁移到空闲或负载较轻的核心。