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

Linux 中的进程

进程的概念: 一个程序加载到内存后的一次执行。称为一个进程

Linux中进程的状态:

进程的五种状态
1. 创建态->就绪态: 主要是操作系统完成了一些进行所需要资源的分配。

进程控制块(Process Control Block,PCB):操作系统为每个进程创建并维护一个PCB,存储该进程的所有必要信息,如进程ID、进程状态、程序计数器、CPU寄存器信息、内存管理信息、打开的文件列表等。

内存空间:分配进程的代码段、数据段和堆栈段所需的内存空间。操作系统会为进程分配适当的地址空间,并进行相应的内存初始化。
I/O资源::分配进程需要的输入/输出资源,如打开文件描述符、输入/输出设备等。这些资源的具体分配情况依赖于进程在创建时的需求。
进程优先级:为进程设置初始优先级,以便操作系统调度程序在进行进程调度时能够正确处理。

Linux 中创建进程的两种方案:

// fork 创建一个父进程的副本,子进程继承父进程的地址和空间,文件描述符等方案。 类似于两个线程:
/*
功能:
创建一个新的子进程,子进程是调用 fork 的父进程的副本。

资源共享:
子进程与父进程共享文件描述符、信号处理器、当前工作目录、根目录等,但各自有独立的地址空间(即内存不共享)。

返回值:
fork 在父进程中返回子进程的 PID。
fork 在子进程中返回 0。
如果出现错误,fork 在父进程中返回 -1。
*/

#include <stdio.h>
#include <unistd.h>
int main() {
    pid_t pid;
    // 创建子进程
    pid = fork();
    if (pid < 0) {
        printf("fork failed");
        return 1;
    } else if (pid == 0) {
        // 子进程执行的代码
        printf("这是子进程. PID: %d\n", getpid());
    } else {
        // 父进程执行的代码
        printf("这是父进程. PID: %d\n", getpid());
        printf("子进程的PID: %d\n", pid);
    }
    return 0;
}
//clone 函数
/*
功能:
提供更高的灵活性和控制能力,可以创建一个新进程或新线程,允许指定哪些资源在新进程和父进程之间共享。

资源共享:
可以通过 clone 的标志参数(如 CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND 等)指定哪些资源在新进程和父进程之间共享。例如:
CLONE_VM:共享地址空间。
CLONE_FS:共享文件系统信息(根目录、当前工作目录等)。
CLONE_FILES:共享文件描述符表。
CLONE_SIGHAND:共享信号处理器。

返回值:
clone 在父进程中返回子进程的 PID。
如果出现错误,clone 返回 -1。
*/

#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define STACK_SIZE 1024*1024  // 栈的大小

int child_func(void* arg) {
    printf("这是子进程 PID: %d\n", getpid());
    return 0;
}
int main() {
    char* stack = malloc(STACK_SIZE);
    if (stack == NULL) {
        perror("malloc failed");
        return 1;
    }
    // 创建子进程
    pid_t pid = clone(child_func, stack + STACK_SIZE, SIGCHLD, NULL);
    if (pid == -1) {
        printf("clone failed");
        free(stack);
        return 1;
    }
    printf("这是父进程. PID: %d\n", getpid());
    printf("子进程 PID: %d\n", pid);
    // 等待子进程结束
    if (waitpid(pid, NULL, 0) == -1) {
        perror("waitpid failed");
        free(stack);
        return 1;
    }
    free(stack);
    return 0;
}

2.就绪态->运行态:CPU调度程序决定将CPU时间片分配给该进程
3. 运行态->阻塞态 :因为它正在等待某个事件的发生,这些事件可能包括I/O操作的完成、资源的可用性、特定条件的满足等。

等待I/O完成:进程发起I/O操作(如读写文件、网络通信等),需要等待操作系统完成实际的I/O操作。在此期间,进程会被阻塞,直到I/O操作完成。

等待资源:进程可能需要某些系统资源(如内存、设备等),如果资源当前不可用,进程会被阻塞,直到资源可用。

等待信号或消息:在使用信号、消息队列、管道、共享内存等进程间通信机制时,进程可能会等待其他进程发送的数据或信号。
等待互斥锁或信号量:进程可能需要访问临界区或共享资源,而这些资源被其他进程锁定。进程会等待互斥锁或信号量释放。
等待特定事件:进程可能等待某些特定的事件发生,如某个定时器超时、特定条件变量被满足等。

4. 阻塞态->就绪态: 它正在等待的事件已经发生,或者等待的条件已经满足。

3 中的条件都得到实现或者满足

5. 运行态->终止态:进程执行完成了或者发生了致命错误。

进程执行完成:进程正常完成了它的全部任务,运行到程序的末尾,或者通过调用 exit() 函数显式地退出。

#include <stdio.h>
#include <stdlib.h>
int main() {
    printf("hello world\n");
    // 正常终止进程
    exit(0);
}

运行时错误:进程在执行过程中遇到无法处理的错误,如除零错误、非法内存访问(段错误)等。这些错误会导致操作系统终止进程。

#include <stdio.h>
int main() {
    int *p = NULL;
    // 触发段错误,导致进程异常终止
    printf("%d\n", *p);
    return 0;
}

可执行程序的加载(C语言举例)

编写程序

在我们运行程序之前,我们首先要做的第一件事情,就是编写程序。当我们编写程序的时候我们用我们的编译器(这里我们用GCC)来编译我们的程序。
	1. 编译器首先会对我们的C程序进行预处理:处理所有的#开头的预编译指令,将#define 展开,将#include “文件”里面的内容插入到当前文件中。删除所有的注释。
	2. 编译器进行编译 ,主要是生成 .s 结尾的汇编语言的文件
	3. 然后根据编译器的指令集,将代码进行汇编,主要是生成可以执行的机器代码 (.o 结尾的文件)
	4. 链接,将我们要使用的一些库链接起来,这里可能大家会好奇我们第一步在预处理的时候不是已经将其他头文件的内容加载过来了吗?是的,但是头文件一般都是声明但是没有实现,所以这里我们将已经编译好的一些文件来链接起来这样我们就形成了一个可以执行的文件啦。

运行程序

当我们的程序编译后之后,我们来进行程序的运行。当我们使用 ./main 运行程序的时候。我们的程序是怎么运行的呢?这里程序进行运行,就产生了一个进程,这个 进程是怎么产生的呢?

main 函数所在的进程是通过操作系统启动一个新的用户空间进程来创建的。这个过程通常是由操作系统的启动流程和加载机制完成的。
1.计算机启动时,硬件会加载引导程序(bootloader),通常是 GRUB 或类似的引导程序。引导程序负责启动操作系统。
2.引导程序加载操作系统内核映像到内存,并将控制权转移给内核。内核启动后,它会初始化系统资源,设置基本的硬件和软件环境。这个时候被称为内核的初始化。
3.内核在初始化过程中会启动一个用户空间进程,通常是 init 进程。init 进程负责启动系统上的其他用户空间进程,并维护系统的运行状态。它还启动终端会话和登录服务,允许用户登录并启动他们的会话。
4. 用户登录:用户通过终端(例如通过 getty 和 login)登录系统。登录后,用户的 shell (例如 bash)被启动。用户可以在 shell 中启动各种命令和应用程序。
5.当用户在 shell 中输入命令时,shell 会创建一个子进程来执行该命令。例如,用户可以输入 ./main 来运行他们编写的程序。Shell 使用 fork() 创建一个子进程,然后在子进程中调用 execve() 来加载和执行用户程序。

execve() 系统调用的功能是启动一个指定的新程序。它将新程序的代码和数据加载到当前进程的内存空间中,替换掉当前进程的堆、栈以及其它所有内存段。随后,新程序将从其初始化代码开始执行,并最终调用其 main 函数。在这个过程中,原始进程的执行被完全接管,而进程ID(PID)保持不变。

execve() 经常与 fork() 系统调用联合使用,以便在一个已存在的进程中启动一个全新的程序。这种组合通常的工作流程是:首先通过 fork() 创建一个子进程,然后在该子进程中调用 execve(),使其转变为执行特定程序的进程。例如,在命令行界面(Shell)中,当用户输入命令来启动一个程序时,Shell 会首先 fork() 自身,接着在新创建的子进程中调用 execve(),从而加载并运行用户指定的程序。这种方式允许用户在命令行中启动新的程序,而无需退出当前的Shell会话。

execve()这个函数主要做了什么呢? 运行在内核态

**1. 检查文件类型:**内核读取二进制文件的头部信息,通常是ELF(Executable and Linkable Format)文件格式。在ELF文件头中包含了关于二进制文件的各种信息,如文件类型、架构、入口点地址、程序头表(Program Header Table)等,
这里先说一下ELF的三种类型:

1.1 可移动目标文件(Relocatable Object File):
	这些文件是由汇编器生成的 .o 文件。链接器以一个或多个可移动目标文件作为输入,经过链接处理后,生成一个可执行的目标文件(Executable Object File)或者一个可共享的目标文件(Shared Object File)。使用 ar 工具可以将多个 .o 可移动目标文件归档成一个 .a 静态库文件。
1.2 可执行目标文件(Executable Object File):
	诸如 vi 文本编辑器、gdb 调试工具、mplayer 音乐播放软件等都属于可执行目标文件。另一类可执行脚本(例如 shell 脚本)虽然不是可执行目标文件,但其使用的解释器(例如 bash shell 程序)却是可执行目标文件。
1.3 可共享目标文件(Shared Object File):
	这些文件通常称为动态库文件,即 .so 文件。如果使用静态库生成可执行程序,每个生成的可执行程序都会包含一份库代码的拷贝。
ELF文件通常包含:

ELF Header(ELF头):文件的开始部分,包含关于文件总体结构的信息。
描述文件类型、目标体系结构、入口地址、节头部表和程序头部表的偏移量等。

Program Header Table(程序头表):描述文件中各段的信息,这些段是进程运行时需要加载到内存中的部分。每个表项定义了一个段的类型、文件中位置、内存地址、大小、权限等信息。

Section Header Table(节头表):描述文件中各节的信息,这些节通常用于链接和调试。
每个表项定义了一个节的类型、文件中位置、大小等信息。

.text节:存储程序的可执行指令。
.data节:存储已初始化的全局和静态变量。
.bss节:存储未初始化的全局和静态变量,在加载时会被初始化为零。
.rodata节:存储只读数据,如字符串字面量。
.symtab和.strtab节:分别存储符号表和字符串表,用于链接和调试。
.rel.text和.rel.data节:存储重定位信息,指示链接器如何修改可执行文件或共享库以正确引用地址。
在这里插入图片描述
在这里插入图片描述
2. 解析程序头表:
内核通过解析ELF文件的程序头表来了解不同的段(Segment)的具体信息。每个段描述了要加载到内存中的部分,如代码段(.text)、数据段(.data)、只读数据段(.rodata)等。
3. 分配内存:
内核为二进制文件中的各个段分配内存空间。具体来说,内核会为代码段分配可执行内存,为数据段分配读写内存等。主要是为进程提供运行时所需的内存,用于存储数据、堆、栈等。

4. 映射文件段到内存:
内核将二进制文件的各个段映射到进程的地址空间中。这通常涉及将文件中的一部分内容复制到内存中,并为需要的内存区域创建相应的虚拟内存映射。
5. 处理动态链接:
如果二进制文件依赖于动态库,内核或动态链接器(如ld.so)会解析这些依赖项,并将所需的动态库加载到内存中。动态链接器会修正二进制文件中的符号,使其能够正确引用动态库中的函数和变量。
6. 启动进程:
内核设置好所有必要的信息后,将控制权交给新的进程。

程序的运行

我们将进程创建后,会有程序的入口,其实也就是指令的入口,cpu会根据程序计数器,一条一条的执行。

取指令(Fetch):CPU从内存中取出指令。这是通过程序计数器(PC)来实现的,PC寄存器存储着当前要执行的指令的地址。
解码指令(Decode):指令取出后,CPU的指令解码器会解析这条指令,确定它要执行的操作类型以及需要哪些操作数。
执行指令(Execute):根据解码的结果,CPU的执行单元会执行指令。这可能包括算术运算、逻辑运算、数据传输等。
访存和写回(Memory Access and Write Back):如果指令需要从内存中读取数据或将数据写入内存,CPU会执行这些操作。执行完毕后,结果可能会被写回寄存器或内存。
更新程序计数器(Update PC):执行完一条指令后,程序计数器会更新,指向下一条指令的地址,以便CPU继续执行程序。

程序的调度

1. 先来先服务(FCFS, First-Come, First-Served)

概念:最简单的调度算法,按照进程到达的顺序进行调度。
算法描述
进程按到达时间进入队列。
CPU始终执行队列头部的进程,直到该进程完成或进入等待状态。
队列中的下一个进程被选中执行。
优点:实现简单、易于理解。
缺点:可能导致“短进程等待长进程”现象(即“长进程占用”问题),平均等待时间较长。

2. 短作业优先(SJF, Shortest Job First)

概念:优先执行预计运行时间最短的进程。
算法描述
所有进程按照各自的预估运行时间排序。
每次从队列中选取预计运行时间最短的进程执行。
优点:平均等待时间较短,对CPU利用率高。
缺点:难以准确预测进程的执行时间,可能导致“长进程饥饿”现象。

3. 最短剩余时间优先(SRTF, Shortest Remaining Time First)

概念:SJF的抢占式版本,随时调度当前剩余运行时间最短的进程。
算法描述
进程到达时计算其剩余运行时间。
如果新到达进程的剩余时间比当前执行进程的剩余时间短,则中断当前进程并调度新进程。
优点:比SJF更灵活,适合动态环境。
缺点:需要频繁上下文切换,开销较大。

4. 时间片轮转(RR, Round Robin)

概念:将CPU时间分成固定大小的时间片,按循环顺序调度进程。
算法描述
每个进程按顺序分配一个时间片。
时间片用尽时,进程返回队列尾部,等待下一轮调度。
优点:公平,所有进程都有机会获得CPU时间,响应时间较短。
缺点:时间片设置过大会退化为FCFS,设置过小则频繁切换影响效率。

5. 优先级调度(Priority Scheduling)

概念:按优先级高低调度进程,高优先级进程先执行。
算法描述:
每个进程分配一个优先级。
总是选择优先级最高的进程执行。
可以是非抢占式或抢占式。
优点:灵活,可以实现不同级别的服务质量。
缺点:可能导致低优先级进程长期得不到执行(饥饿现象)。

6. 多级队列调度(Multilevel Queue Scheduling)

概念:多个队列,每个队列有不同的优先级和调度算法。
算法描述:
进程根据特性分配到不同队列(例如,交互型进程、批处理进程等)。
各队列独立调度,优先级队列优先。
优点:适合分类处理不同类型的进程。
缺点:复杂,进程迁移成本高。

7. 多级反馈队列调度(Multilevel Feedback Queue Scheduling)

概念:多级队列的扩展,进程可在队列间动态调整。
算法描述
初始所有进程进入最高优先级队列。
如果一个进程在其队列时间片内未完成,则降低其优先级,移动到下一级队列。
优先级高的队列先调度。
优点:灵活性高,能适应不同进程需求,避免饥饿现象。
缺点:复杂度高,需要合理设置队列和时间片。

参考

[1]: 计算机操作系统 南京大学
[2]: 庖丁解牛Linux
[3]: 程序员的自我修养

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值