程序是怎样运行的

> 关注公众号【高性能架构探索】,第一时间获取干货;回复【pdf】,免费获取计算机经典书籍

 

如何启动程序

  • 双击(windows系统下),或者在shell终端上执行./a.out
  • 在shell终端上运行可执行程序的标准流程:
    • 启动终端仿真器应用程序
    • 输入可执行文件所在的相对路径或者绝对路径
    • 如果该可执行程序需要输入参数的话,还需要输入参数

比如,我们在终端上输入

ls --version

就会出现如下结果。ps 在此处,我们可以人为ls为可执行程序的名称,--version 是该程序需要的参数。

ls (GNU coreutils) 8.4
Copyright (C) 2010 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Richard M. Stallman and David MacKenzie.

进入bash: /dev/tty

完整性检查

首先,我们从shell的主函数开始,该函数在shell.c文件中。在主函数执行之前,主要做了以下准备工作:

  • 检查并尝试打开/dev/tty
  • 检查shell是否在调试模式下运行
  • 分析命令行参数
  • 读取shell环境
  • 加载.bashrc、.profile和其他配置文件等。

构建运行环境

在做完上述完整性检查之后,最终会执行reader_loop函数,该函数,定义在eval.c中,主要作用是读取给定的程序名称和参数。然后从execute_cmd.c调用execute_command函数,依次调用以下函数链, 不同的检查,例如我们是否需要启动subshell,是否内置bash函数等等。

reader_loop
-> execute_command
--> execute_command_internal
----> execute_simple_command
------> execute_disk_command
--------> shell_execve

众所周知,Linux的实现语言是c,shell也是其一个应用,也有自己的main函数。 进入main函数后,在基本的初始化操作之后,最终进入reader_loop函数。 reader_loop会调用execute_command来等待用户输入命令行参数,在用户输入参数之后,将调用execute_command_internal函数。 execute_command_internal函数是shell源码中执行命令的实际操作函数。他需要对作为操作参数传入的具体命令结构的value成员进行分析,并针对不同的value类型,再调用具体类型的命令执行函数进行具体命令的解释执行工作。

具体来说:如果value是simple,则直接调用execute_simple_command函数进行执行,execute_simple_command再根据命令是内部命令或磁盘外部命令分别调用execute_builtin和execute_disk_command来执行,其中,execute_disk_command在执行外部命令的时候调用make_child函数fork子进程执行外部命令。

如果value是其他类型,则调用对应类型的函数进行分支控制。举例来说,如果是value是for_commmand,即这是一个for循环控制结构命令,则调用execute_for_command函数。在该函数中,将枚举每一个操作域中的元素,对其再次调用execute_command函数进行分析。即execute_for_command这一类函数实现的是一个命令的展开以及流程控制以及递归调用execute_command的功能。 在上述整个调用流程串的最后一步是shell_execve。 该函数最终会调用系统函数execve,其声明如下:

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

在该函数中,有三个参数,分别是:

  • filename可执行文件的名称
  • 可执行文件所需的参数
  • 可执行文件所在的环境变量 在该函数中,最终就是运行可执行程序,这一步操作,是在kernel中操作的。

进入内核: execve系统调用

execve系统调用实现

该函数定义在fs/exec.c中,其声明如下:

SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
      return do_execve(getname(filename), argv, envp);
}

execve的实现在这里非常简单,只调用了do_execve函数,其参数为execve的参数。 而do_execve函数的定义如下:

int do_execve(struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
    struct user_arg_ptr argv = { .ptr.native = __argv };
    struct user_arg_ptr envp = { .ptr.native = __envp };
    return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

通过上述代码,我们可以看到,在do_execve中,最终调用了do_execveat_common,其除了使用do_execve中的参数之外,还有额外的两个参数。 下面是do_execveat_common的具体代码(此处我们去掉了一些不必要放入判断代码)

static int do_execveat_common(int fd, struct filename *filename,
         struct user_arg_ptr argv,
         struct user_arg_ptr envp,
         int flags)
{
 struct linux_binprm *bprm;
 int retval;

 if (IS_ERR(filename))
  return PTR_ERR(filename);

 ...
 current->flags &= ~PF_NPROC_EXCEEDED;

 bprm = alloc_bprm(fd, filename);
 if (IS_ERR(bprm)) {
  retval = PTR_ERR(bprm);
  goto out_ret;
 }

 retval = count(argv, MAX_ARG_STRINGS);
 bprm->argc = retval;

 retval = count(envp, MAX_ARG_STRINGS);
 bprm->envc = retval;

 retval = bprm_stack_limits(bprm);
 
 retval = copy_string_kernel(bprm->filename, bprm);
 bprm->exec = bprm->p;

 retval = copy_strings(bprm->envc, envp, bprm);
 
 retval = copy_strings(bprm->argc, argv, bprm);
 
 retval = bprm_execve(bprm, fd, filename, flags);
 putname(filename);
 return retval;
}

安全性检查

第一个参数AT_FDCWD是当前目录的文件描述符,第五个参数是标志。 我们稍后会看到。 do_execveat_common函数检查文件名指针并返回它是否为NULL。 在此之后,它检查当前进程的标志,表明未超出正在运行的进程的限制:

if (IS_ERR(filename))
    return PTR_ERR(filename);

if ((current->flags & PF_NPROC_EXCEEDED) &&
atomic_read(&current_user()->processes) > rlimit(RLIMIT_NPROC)) {
    retval = -EAGAIN;
    goto out_ret;
}

current->flags &= ~PF_NPROC_EXCEEDED;

如果这两项检查成功,我们将在当前进程的标志中取消设置PF_NPROC_EXCEEDED标志,以防止执行程序失败。 在下一步中,我们调用在kernel/fork.c中定义的unshare_files函数,并取消共享当前任务的文件,并检查此函数的结果:

retval = unshare_files(&displaced);
if (retval)
    goto out_ret;

调用此函数的目的旨在消除执行二进制文件的文件描述符的潜在泄漏。 在下一步中,我们开始准备由struct linux_binprm结构(在include/linux/binfmts.h头文件中定义)表示的bprm。

二进制参数准备

struct linux_binprm

linux_binprm结构用于保存加载二进制文件时使用的参数。 例如,它包含vm_area_struct,表示将在给定地址空间中连续间隔内的单个内存区域,将在该空间中加载应用程序。mm字段,它是二进制文件的内存描述符,指向内存顶部的指针以及许多其他不同的字段。

分配内存

在do_execveat_common函数中,执行alloc_bprm函数,最终会调用如下:

bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
    goto out_files;

准备工作

retval = prepare_bprm_creds(bprm);
if (retval)
    goto out_free;

check_unsafe_exec(bprm);
current->in_execve = 1;

初始化linux_binprm中的cred结构变量,该结构变量中包含任务的实际uid,任务的实际guid,虚拟文件系统操作的uid和guid等。 然后,对check_unsafe_exec函数的调用将当前进程设置为in_execve状态。

计算命令行参数和环境变量

bprm->argc = count(argv, MAX_ARG_STRINGS);
if ((retval = bprm->argc) < 0)
    goto out;

bprm->envc = count(envp, MAX_ARG_STRINGS);
if ((retval = bprm->envc) < 0)
    goto out;

在上述代码中,MAX_ARG_STRINGS是头文件中定义的上限宏,它表示传递给execve系统调用的最大字符串数。 MAX_ARG_STRINGS的值:

`#define MAX_ARG_STRINGS 0x7FFFFFFF`

设置

完成所有这些操作后,我们调用do_open_execat函数,该函数

  • 搜索并打开磁盘上的可执行文件并检查,
  • 从noexec挂载点绕过标志0加载二进制文件(我们需要避免从不包含proc或sysfs等可执行二进制文件的文件系统中执行二进制文件),
  • 初始化文件结构并返回此结构上的指针。 接下来,我们可以在此之后看到对sched_exec的调用。 sched_exec函数用于确定可以执行新程序的最小负载处理器,并将当前进程迁移到该处理器。
file = do_open_execat(fd, filename, flags);
retval = PTR_ERR(file);
if (IS_ERR(file))
    goto out_unmark;

sched_exec();

之后,我们需要检查给出可执行二进制文件的文件描述符。 我们尝试检查二进制文件的名称是否从/符号开始,或者给定的可执行二进制文件的路径是否相对于调用进程的当前工作目录进行了解释,或者文件描述符为AT_FDCWD。 如果这些检查之一成功,我们将设置二进制参数文件名:

bprm->file = file;

if (fd == AT_FDCWD || filename->name[0] == '/') {
    bprm->filename = filename->name;
}

否则,如果文件名称为空,则将文件名设置为/dev/fd/%d (即/dev/fd/文件描述符),否则将文件名重新设置为/dev/fd/%d/文件名(其中,fd指向可执行文件的文件描述符)

else {
    
    if (filename->name[0] == '\0')
        pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d", fd);
    else
        pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d/%s",    fd, filename->name);
        
    if (!pathbuf) {
        retval = -ENOMEM;
        goto out_unmark;
    }
    
    bprm->filename = pathbuf;
}

bprm->interp = bprm->filename;

需要注意的是,我们不仅设置了bprm-> filename,还设置了bprm-> interp,它将包含程序解释器的名称。 现在,我们只是在此处写相同的名称,但是稍后将使用程序解释器的真实名称对其进行更新,其具体取决于程序的二进制格式。

准备内存相关信息

retval = bprm_mm_init(bprm);
if (retval)
     goto out_unmark;

其中,bprm_mm_init的定义如下:

static int bprm_mm_init(struct linux_binprm *bprm)
{
 int err;
 struct mm_struct *mm = NULL;

 bprm->mm = mm = mm_alloc();
 err = -ENOMEM;
 if (!mm)
  goto err;

 /* Save current stack limit for all calculations made during exec. */
 task_lock(current->group_leader);
 bprm->rlim_stack = current->signal->rlim[RLIMIT_STACK];
 task_unlock(current->group_leader);

 err = __bprm_mm_init(bprm);
 if (err)
  goto err;

 return 0;

err:
 if (mm) {
  bprm->mm = NULL;
  mmdrop(mm);
 }

 return err;
}

在函数bprm_mm_init中,其功能主要是初始化mm_struct 和 vm_area_struct结构。

读取二进制(ELF)文件

调用prepare_binprm函数将inode的uid填充到linux_binprm结构中,并从二进制可执行文件中读取128个字节。 我们只从可执行文件中读取前128个,因为我们需要检查可执行文件的类型。 我们将在后续步骤中阅读可执行文件的其余部分。

retval = prepare_binprm(bprm);
if (retval < 0)
    goto out;

准备好linux_bprm结构后,我们通过调用copy_strings_kernel函数将可执行二进制文件的文件名,命令行参数和环境变量从内核复制到linux_bprm:

retval = copy_strings_kernel(1, &bprm->filename, bprm);
if (retval < 0)
    goto out;

retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
    goto out;

retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
    goto out;

并将指针设置为我们在bprm_mm_init函数中设置的新程序堆栈的顶部bprm-> exec = bprm-> p; 堆栈的顶部将包含程序文件名,我们将该文件名存储到linux_bprm结构的exec字段中。

处理参数结构

通过调用exec_binprm函数来存储当前当前任务所在进程的pid

retval = exec_binprm(bprm);
if (retval < 0)
    goto out;

在exec_binprm函数中,也会调用search_binary_handler。 当前,Linux内核支持以下二进制格式:

  • binfmt_script: 支持从#!开始的解释脚本。 线;
  • binfmt_misc: 根据Linux内核的运行时配置,支持不同的二进制格式;
  • binfmt_elf: 支持elf格式;
  • binfmt_aout: 支持a.out格式;
  • binfmt_flat: 支持平面格式;
  • binfmt_elf_fdpic: 支持elf FDPIC二进制文件;
  • binfmt_em86: 支持在Alpha机器上运行的Intel elf二进制文件。 因此,search_binary_handler尝试调用load_binary函数并将linux_binprm传递给该函数。 如果二进制处理程序支持给定的可执行文件格式,它将开始准备可执行二进制文件的前期工作。该函数定义如下:
int search_binary_handler(struct linux_binprm *bprm)
{
    ...
    ...
    ...
    list_for_each_entry(fmt, &formats, lh) {
    retval = fmt->load_binary(bprm);
    
    if (retval < 0 && !bprm->mm) {
        force_sigsegv(SIGSEGV, current);
        return retval;
    }
}

return retval;

在load_binary中检查linux_bprm缓冲区中的魔数(每个elf二进制文件的头中都包含魔数,我们从可执行二进制文件中读取了前128个字节),如果不是elf二进制,则退出。

运行

完整性检测

如果给定的可执行文件为elf格式,则load_elf_binary继续并检查可执行文件的体系结构和类型,并在体系结构错误且可执行文件不可执行,不可共享时退出:

if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
    goto out;
if (!elf_check_arch(&loc->elf_ex))
    goto out;

设置地址空间和依赖

尝试加载描述段的程序头表。 从磁盘上读取与我们的可执行二进制文件链接的程序解释器和库,并将其加载到内存中。

elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
if (!elf_phdata)
    goto out;

程序解释器指定在可执行文件的.interp部分(在大多数情况下,对于x86_64,链接器为– /lib64/ld-linux-x86-64.so.2)。 它设置堆栈并将elf二进制文件映射到内存中的正确位置,映射了bss和brk部分,并做了许多其他不同的事情来准备要执行的可执行文件。 在执行load_elf_binary的最后,我们调用start_thread函数并将三个参数传递给该函数:

    start_thread(regs, elf_entry, bprm->p);
    retval = 0;
out:
    kfree(loc);
out_ret:
    return retval;

这些参数是:

  • 新任务的寄存器集
  • 新任务入口点的地址
  • 新任务的堆栈顶部地址 从函数名称可以理解,它启动了一个新线程,但事实并非如此。 start_thread函数只是准备新任务的寄存器以准备运行。下面是定义:
void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
    start_thread_common(regs, new_ip, new_sp,
    __USER_CS, __USER_DS, 0);
}

通过上面代码,我们能够看到,在start_thread函数中,最终还是调用了start_thread_common函数。

开始执行

static void
start_thread_common(struct pt_regs *regs, unsigned long new_ip,
unsigned long new_sp,
unsigned int _cs, unsigned int _ss, unsigned int _ds)
{
    loadsegment(fs, 0);
    loadsegment(es, _ds);
    loadsegment(ds, _ds);
    load_gs_index(0);
    regs->ip = new_ip;
    regs->sp = new_sp;
    regs->cs = _cs;
    regs->ss = _ss;
    regs->flags = X86_EFLAGS_IF;
    force_iret();
}

start_thread_common函数将fs段寄存器填充为零,并将es&ds填充数据段寄存器的值。之后,我们将新值设置为指令指针,cs段等。在start_thread_common函数的末尾,我们可以看到force_iret宏,该宏通过iret指令强制返回系统调用。

然后,创建了在用户空间中运行的新线程,随后可以从exec_binprm返回,再次处于do_execveat_common中。 exec_binprm完成执行后,释放之前分配的结构的内存,然后返回。

从execve系统调用处理程序返回后,将开始执行程序。之所以可以这样做,是因为之前配置了所有与上下文相关的信息。

如我们所见,execve系统调用不会将控制权返回给进程,但是调用者进程的代码,数据和其他段只是被程序段所覆盖。 应用程序的退出将通过退出系统调用实现。

至此,整个程序从开始运行到退出,整个流程完。

 

> 关注公众号【高性能架构探索】,第一时间获取干货;回复【pdf】,免费获取计算机经典书籍

posted @ 2021-10-21 18:12  高性能架构探索  阅读(586)  评论(0编辑  收藏  举报