在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 文件由多个部分组成,每个部分都有特定的用途。主要部分包括:
- ELF 头(ELF Header)位于文件的开头,包含了文件的基本信息,如文件类型、架构、入口点地址等。
- 程序头表(Program Header Table)描述了文件中各个程序段(Segment)的信息。程序段是加载到内存中的基本单位。常见的段类型包括:
PT_LOAD
:可加载段、PT_DYNAMIC
:动态链接信息、PT_INTERP
:解释器路径、PT_NOTE
:辅助信息。 - 节头表(Section Header Table)描述了文件中各个节(Section)的信息。节是文件中的逻辑单位,用于存储代码、数据、符号表等。常见的节类型包括:
.text
:代码段、.data
:数据段 、.bss
:未初始化数据段 、.symtab
:符号表、.strtab
:字符串表 - 节(Sections)
3. 程序的执行
在加载可执行文件后,内核会设置进程的入口点,并准备好用户态堆栈。然后,内核会将控制权交给用户态的程序入口点。
ELF 文件加载
当一个 ELF 可执行文件被加载到内存中执行时,操作系统会执行以下步骤:
- 读取 ELF 头:操作系统首先读取 ELF 头,以获取文件的基本信息和程序头表的偏移量。
- 读取程序头表:根据 ELF 头中的偏移量,操作系统读取程序头表,以获取各个程序段的信息。
- 加载程序段:操作系统根据程序头表中的信息,将各个
PT_LOAD
类型的程序段加载到内存中。 - 设置入口点:操作系统根据 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
函数主要执行以下步骤:
- 更新当前进程:将
rq->curr
更新为next
,即下一个要运行的进程。 - 切换地址空间:如果
next
进程没有自己的地址空间(即mm
为 NULL),则共享prev
进程的地址空间。否则, 切换地址空间。 - 保存和恢复上下文:保存当前进程的上下文,并加载下一个进程的上下文。
总结
Linux 系统中程序的执行过程涉及多个步骤,从进程的创建、可执行程序的加载、程序的执行到进程的调度。每个步骤都涉及多个内核函数和数据结构的协同工作。通过这些步骤,Linux 能够高效地管理和执行用户程序。