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

1.进程的创建

在Linux系统中,系统启动之后的第一个0号进程由系统来创建,其余的进程都由已存在的进程来创建

Linux中有几个与创建进程相关的系统调用函数fork、vfork和clone,这三个系统调用底层都是通过do_fork函数创建进程,只是这三个调用函数的传递参数各有不同。 

SYSCALL_DEFINE0(fork)
{
    return _do_fork(&args);
}

SYSCALL_DEFINE0(vfork)
{
    return _do_fork(&args);
}

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
 {
    return _do_fork(&args)
}

fork():用于创建一个新的子进程,子进程是父进程的副本,拥有独立的地址空间。

do_fork()函数负责创建新进程,调用copy_process()函数复制父进程的各种资源,包括内存页表、打开的文件描述符、信号处理函数等,通过分配新的进程描述符task_struct来初始化子进程,将子进程添加到调度队列,等待被CPU调度执行。

long _do_fork(struct kernel_clone_args *args)
{
    //复制进程描述符和执行时所需的其他数据结构   
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);

    wake_up_new_task(p);//将子进程添加到就绪队列

    return nr;//返回子进程pid(父进程中fork返回值为子进程的pid)
}
static __latent_entropy struct task_struct *copy_process(
                    struct pid *pid
                    int trace,
                    int node,
                    struct kernel_clone_args *args)
{
    //复制进程描述符task_struct、创建内核堆栈等
    p = dup_task_struct(current, node);

    /* copy all the process information */
    shm_init_task(p);
…

// 初始化子进程内核栈和thread
retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p,
                 args->tls);

…
return p;//返回被创建的子进程描述符指针
}

vfork()也用于创建子进程,但子进程与父进程共享地址空间,子进程在调用exec()或exit()前不能修改任何数据。

vfork()与fork()的主要区别在于子进程与父进程共享地址空间,do_fork()函数在创建子进程时会设置CLONE_VM标志,表示共享内存空间,子进程在调用exec()或exit()前,不能修改任何数据,因为这会影响父进程,这种设计可以减少内存开销,适用于只需要执行exec()的场景。

clone()是一个更底层的系统调用,用于创建新的轻量级进程(线程)。它可以指定哪些资源在新进程与父进程间共享

clone()是一个更底层的系统调用,可以自定义新进程与父进程共享的资源,kernel_thread()函数基于clone()实现内核线程的创建,通过传入不同的标志(如CLONE_FS、CLONE_FILES等),可以控制哪些资源在新线程与父线程间共享,这种灵活性使得clone()可以用于实现各种形式的进程/线程创建。

程序的加载

进程创建之后,需要加载可执行程序到进程的地址空间。那么可执行程序是如何作为一个进程工作的?这就涉及可执行文件的格式、编译、链接和装载等相关知识。

ELF 目标文件格式
ELF(Executable and Linkable Format)即可执行的和可链接的格式, 是一个文件格式的标准,它指导了程序如何被加载到内存并执行。

可执行文件(Executable File):

这种类型的 ELF 文件包含了可以被 CPU 直接执行的机器码。当操作系统加载并执行这种文件时,它会被映射到内存并由 CPU 执行。典型的可执行 ELF 文件包括 Linux 系统中的用户态应用程序。

可重定位文件(Object File):

这种 ELF 文件包含了编译后的目标代码和符号信息,但未完成最终的链接和装载。目标文件通常由编译器生成,需要通过链接器进一步处理,生成可执行文件。目标文件包含了程序中各个模块的机器码和数据,以及符号信息等,供链接器使用。

动态链接库文件(Shared Library):

这种 ELF 文件包含了可以被多个程序共享使用的代码和数据。共享库在程序运行时被动态加载,可以减少内存占用和磁盘空间。共享库文件通常以 .so 为后缀,如 libc.so、libpthread.so 等。可执行文件用于运行应用程序。可重定位文件用于构建可执行文件或其他目标文件。动态链接库文件用于被多个程序动态链接和使用。

程序从源代码到可执行文件的编译步骤大致分为:预处理、编译、汇编、链接。

# 预处理

gcc -E hello.c -o hello.i

# 编译

gcc -S hello.i -o hello.s

编译器将高级语言源代码翻译为汇编语言,生成汇编代码文件。汇编器将汇编代码转换为目标文件,包含机器指令和符号信息。编译器通过优化技术,生成高效的机器指令序列。编译器还会执行类型检查、语法分析等工作,确保源代码的正确性。

# 汇编

gcc -c hello.s -o hello.o

汇编后形成的.o格式的文件就是ELF格式文件了。编译后生成的目标文件至少含有3个节区。

.text段,这部分包含了程序的机器代码,即CPU执行的指令。

.data段,这部分包含了程序中已经初始化的全局变量和静态变量的值。

.bss段,这部分用于存储未初始化的全局变量和静态变量。与 data不同,bss段在程序加载到内存时并不包含实际的数据值,而是只预留了足够的空间。这些变量的初始值通常是0。

# 链接,-static为静态链接

gcc hello.o -o hello -static

链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,链接器负责将多个目标文件和库链接为最终的可执行文件。

链接从过程上讲分为符号解析和重定位两部分;链接器会解析各个目标文件中的符号引用,通过符号表找到对应的定义位置。重定位是把程序的逻辑地址空间变换成进程线性地址空间的过程,也就是链接时对目标程序中指令和数据的地址修改的过程。

根据链接时机的不同,又分为静态链接和动态链接两种。静态链接。在编译链接时直接将需要的执行代码复制到最终可执行文件中。动态链接。在编译时不直接复制可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统。对于动态链接的库,链接器只记录库的名称,而不会把库代码直接链接进可执行文件。链接器还会处理一些特殊段,如 .init 和 .fini 段,用于程序的初始化和清理。

装载过程:

操作系统的 exec() 系统调用负责装载可执行文件。Shell会调用execve系统调用接口函数将命令行参数和环境变量传递给可执行程序的main函数。execve系统调用接口函数的函数原型如下:

int execve(const char *filename, char *const argv[],char *const envp[]);

接下来内核首先识别 ELF 文件格式,并解析程序头表中的信息。根据程序头表,内核将代码段、数据段等映射到进程的虚拟地址空间。对于动态链接的库,内核会在运行时通过 ld-linux.so 动态加载并链接。最后设置程序入口点 _start,开始执行可执行程序

Linux 进程调度

调度器的作用

Linux 内核中的调度器是负责决定哪个进程在 CPU 上运行的关键组件。调度器会根据各种算法和策略,如 CFS(Completely Fair Scheduler),选择下一个要执行的进程。

调度器的主要职责包括:

维护就绪队列,记录所有可运行的进程。

根据进程的优先级、CPU 使用时间等因素,选择最合适的进程进行调度。

在进程切换时保存当前进程的上下文,并恢复下一个进程的上下文。

调度策略

Linux 调度器支持多种调度策略,满足不同应用场景的需求:

  1. 时间片轮转(Round-Robin)调度:这是最简单的调度策略,每个进程轮流获得一个固定长度的时间片。当进程的时间片用完时,它会被暂时挂起,调度器会选择下一个进程运行。这种方式可以保证所有进程获得公平的 CPU 使用时间。

  2. 优先级调度:每个进程都有一个优先级值,优先级越高的进程越优先获得 CPU 时间。调度器会根据进程的优先级动态调整它们的 CPU 使用时间。这种策略可以确保关键进程能够优先得到处理。

  3. 公平调度(Completely Fair Scheduler, CFS):CFS 让所有进程获得公平的 CPU 使用时间,即每个进程的 CPU 使用时间与其权重成正比。CFS 会根据进程的 CPU 使用时间动态调整其在就绪队列中的位置,以实现公平调度。

  4. 实时调度:Linux 支持两种实时调度策略:FIFO 和 Round-Robin。实时进程具有较高的优先级,可以获得更多的 CPU 时间。实时调度可以满足低延迟和确定性的需求,适用于需要快速响应的应用程序。

  5. 组调度:Linux 支持将进程归类到不同的调度组,以更细粒度地控制资源分配。组调度可以限制组内 CPU 和内存的使用,为不同类型的工作负载提供隔离。容器技术就是基于这种分层的组调度机制实现的。

进程状态

Linux 进程可能处于以下几种状态:

1.就绪(Running/Runnable):

进程已准备就绪,等待 CPU 调度。调度器会将这些进程纳入调度队列,等待分配 CPU 时间。

2.等待(Waiting):

进程正在等待某个事件发生,如 I/O 操作完成、定时器到期等。调度器会暂时将这些进程从就绪队列中移除,直到相应事件发生。

3.停止(Stopped):

进程已停止运行,通常由于收到停止信号。调度器不会对这些进程进行调度,直到进程恢复运行。

4.僵尸(Zombie):

进程已终止,但父进程还未回收它的资源。这种进程处于"死亡"状态,调度器也不会对其进行调度。

调度器会密切监视进程状态的变化,动态地将进程加入或移出就绪队列。只有处于"就绪"状态的进程,才会被纳入调度考虑范围。

对于"等待"状态的进程,调度器会将其从就绪队列中移除,以避免占用 CPU 资源。一旦相应的事件发生,进程就会重新进入就绪状态,等待调度器的调度。

而对于"停止"和"僵尸"状态的进程,调度器则完全不会对其进行调度,因为这些进程已经无法正常运行。

优先级和时间片

Linux 进程有 140 个优先级级别,从 -20(最高)到 119(最低)。系统进程和实时进程占用较高的优先级,普通进程使用较低的优先级。用户可以使用 nice 命令调整进程的优先级

每个进程在 CPU 上都会获得一个时间片。时间片用完后,进程会被暂时挂起,调度器会选择下一个进程运行。CFS 调度器会根据进程的优先级动态调整时间片长度。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值