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

在Linux系统中,程序的执行过程涉及多个步骤,包括进程的创建、可执行程序的加载、程序的执行和进程的调度。以下是详细的阐述:

1. 进程的创建

进程的创建通常通过系统调用fork()clone()来实现。fork() 创建一个新的进程,该进程是调用进程的副本。clone() 提供了更细粒度的控制,可以选择性地共享资源。

fork() 系统调用

fork() 系统调用会创建一个新的进程,该进程是调用进程的副本。新进程被称为子进程,调用进程被称为父进程。子进程会继承父进程的地址空间、文件描述符等资源。

pid_t fork(void);

在内核中,fork() 调用最终会调用 do_fork() 函数,其中copy_process() 函数负责创建并初始化新的进程描述符 task_struct

pid_t do_fork(unsigned long clone_flags,
              unsigned long stack_start,
              unsigned long stack_size,
              int __user *parent_tidptr,
              int __user *child_tidptr)
{
    // 省略部分代码
    struct task_struct *p;
    p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, 0);
    // 省略部分代码
    return p->pid;
}

clone() 系统调用
clone() 系统调用可以控制两个进程之间是否共享虚拟地址空间,文件描述符表以及信号句柄表等。也可以通过这些系统调用将子进程放到不同的命名空间中。

 #define _GNU_SOURCE
 #include <sched.h>
 
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
    /* pid_t *parent_tid, void *tls, pid_t *child_tid */ );

2. 可执行程序的加载

当一个进程需要执行一个新的可执行文件时,会调用 execve() 系统调用。execve() 会替换当前进程的地址空间,并加载新的可执行文件。

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

在内核中,execve() 调用最终会调用 do_execve() 函数,search_binary_handler() 函数会根据可执行文件的格式选择合适的二进制处理程序(如 ELF 处理程序)来加载可执行文件。

int do_execve(struct filename *filename,
              const char __user *const __user *__argv,
              const char __user *const __user *__envp)
{
    // 省略部分代码
    retval = search_binary_handler(bprm);
    // 省略部分代码
    return retval;
}
ELF 文件结构

ELF(Executable and Linkable Format)是一种通用的文件格式,用于可执行文件、目标代码、共享库和核心转储(core dumps)。ELF 文件由多个部分组成,每个部分都有特定的用途。主要部分包括:

  1. ELF 头(ELF Header)位于文件的开头,包含了文件的基本信息,如文件类型、架构、入口点地址等。
  2. 程序头表(Program Header Table)描述了文件中各个程序段(Segment)的信息。程序段是加载到内存中的基本单位。常见的段类型包括:PT_LOAD:可加载段、PT_DYNAMIC:动态链接信息、PT_INTERP:解释器路径、PT_NOTE:辅助信息。
  3. 节头表(Section Header Table)描述了文件中各个节(Section)的信息。节是文件中的逻辑单位,用于存储代码、数据、符号表等。常见的节类型包括:.text:代码段、 .data:数据段 、.bss:未初始化数据段 、.symtab:符号表、.strtab:字符串表
  4. 节(Sections)

3. 程序的执行

在加载可执行文件后,内核会设置进程的入口点,并准备好用户态堆栈。然后,内核会将控制权交给用户态的程序入口点。

ELF 文件加载

当一个 ELF 可执行文件被加载到内存中执行时,操作系统会执行以下步骤:

  1. 读取 ELF 头:操作系统首先读取 ELF 头,以获取文件的基本信息和程序头表的偏移量。
  2. 读取程序头表:根据 ELF 头中的偏移量,操作系统读取程序头表,以获取各个程序段的信息。
  3. 加载程序段:操作系统根据程序头表中的信息,将各个 PT_LOAD 类型的程序段加载到内存中。
  4. 设置入口点:操作系统根据 ELF 头中的入口点地址,将控制权转移到该地址,开始执行程序。

内核会调用 load_elf_binary() 函数来加载:

int load_elf_binary(struct linux_binprm *bprm)
{
    // 省略部分代码
    elf_entry = elf_map(bprm->file, load_bias + elf_phdata[i].p_vaddr, elf_phdata[i].p_filesz, elf_prot, elf_flags, elf_phdata[i].p_offset);
    // 省略部分代码
    start_thread(regs, elf_entry, bprm->p);
    return 0;
}

start_thread() 函数会设置进程的入口点和堆栈指针:

#define start_thread(regs, new_ip, new_sp) \
    do { \
        regs->ip = new_ip; \
        regs->sp = new_sp; \
    } while (0)

4. 进程的调度

进程的调度由内核中的调度器负责。调度器决定哪个进程在何时运行。Linux 使用完全公平调度器(CFS)来管理进程调度。

调度器的核心函数

调度器的核心函数是 schedule()

void __sched schedule(void)
{
    struct task_struct *prev, *next;
    struct rq *rq;
    // 省略部分代码
    prev = rq->curr;
    next = pick_next_task(rq, prev);
    // 省略部分代码
    context_switch(rq, prev, next);
}

pick_next_task() 函数选择下一个要运行的进程,context_switch() 函数负责切换进程上下文。

1. pick_next_task

pick_next_task 函数负责从就绪队列中选择下一个要运行的进程。Linux内核使用完全公平调度器(CFS)来管理进程调度。CFS 通过维护一个红黑树来管理就绪进程,并根据进程的虚拟运行时间来选择下一个要运行的进程。在这个实现中,pick_next_task 函数从红黑树 tasks_timeline 中选择最左边的节点(即虚拟运行时间最小的进程),并返回该进程。

2. context_switch

context_switch 函数负责进行进程上下文切换。上下文切换包括保存当前进程的上下文(如寄存器状态、堆栈指针等),并加载下一个进程的上下文。context_switch 函数主要执行以下步骤:

  1. 更新当前进程:将 rq->curr 更新为 next,即下一个要运行的进程。
  2. 切换地址空间:如果 next 进程没有自己的地址空间(即 mm 为 NULL),则共享 prev 进程的地址空间。否则, 切换地址空间。
  3. 保存和恢复上下文:保存当前进程的上下文,并加载下一个进程的上下文。

总结

Linux 系统中程序的执行过程涉及多个步骤,从进程的创建、可执行程序的加载、程序的执行到进程的调度。每个步骤都涉及多个内核函数和数据结构的协同工作。通过这些步骤,Linux 能够高效地管理和执行用户程序。

  • 16
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值