进程的加载 - 进程与线程(五)

背景:

  • Read the fucking source code! --By 鲁迅
  • A picture is worth a thousand words. --By 高尔基

说明:

  • busybox版本:1.7.0
  • Kernel版本:4.14
  • 使用工具:Source Insight 3.5, Visio

我们的源代码通过预处理,编译,汇编,链接后形成可执行文件,)那么当我们在cmd窗口敲出指令$test argv1 argv2\n 后,操作系统是怎么将我们的可执行文件加载并运行的呢?

首先知道,计算机的操作系统的启动程序是写死在硬件上的,每次计算机上电时,都将自动加载启动程序,之后的每一个程序,每一个应用,都是不断的 fork 出来的新进程。那么我们的可执行文件,以linux 系统为例,也是由shell 进程 fork 出一个新进程,在新进程中调用exec函数装载我们的可执行文件并执行。

1. execve()

当shell中敲入执行程序的指令之后,shell进程获取到敲入的指令,并执行execve()函数,该函数的参数是敲入的可执行文件名和形参,还有就是环境变量信息。execve()函数对进程栈进行初始化,即压栈环境变量值,并压栈传入的参数值,最后压栈可执行文件名。初始化完成后调用 sys_execve()

2. sys_execve()

该函数进行一些参数的检查与复制,而后调用 do_execve()

3. do_execve()

该函数在当前路径与环境变量的路径中寻找给定的可执行文件名,找到文件后读取该文件的前128字节。读取这128个字节的目的是为了判断文件的格式,每个文件的开头几个字节都是魔数,可以用来判断文件类型。读取了前128字节的文件头部后,将调用 search_binary_handle()

4. search_binary_handle()

该函数将去搜索和匹配合适的可执行文件装载处理程序。Linux 中所有被支持的可执行文件格式都有相应的装在处理程序。以Linux 中的ELF 文件为例,接下来将会调用elf 文件的处理程序:load_elf_binary()

5.  load_elf_binary()

该函数执行以下三个步骤:

a)创建虚拟地址空间:实际上指的是建立从虚拟地址空间到物理内存的映射函数所需要的相应的数据结构。(即创建一个空的页表)

b)读取可执行文件的文件头,建立可执行文件到虚拟地址空间之间的映射关系

c)将CPU指令寄存器设置为可执行文件入口(虚拟空间中的一个地址)

load_elf_binary()函数执行完毕,事实上装载函数执行完毕后,可执行文件真正的指令和数据都没有被装入内存中,只是建立了可执行文件与虚拟内存之间的映射关系,以及分配了一个空的页表,用来存储虚拟内存与物理内存之间的映射关系。

6. 程序返回到execve()中

此时从内核态返回到用户态,且寄存器的地址被设置为了ELF 的入口地址,于是新的程序开始启动,发现程序入口对应的页面并没有加载(因为初始时是空页面),则此时引发一个缺页错误,操作系统根据可执行文件和虚拟内存之间的映射关系,在磁盘上找到缺的页,并申请物理内存,将其加载到物理内存中,并在页表中填入该虚拟内存页与物理内存页之间的映射关系。之后程序正常运行,直至结束后回到shell 父进程中,结束回到 shell。

对于用户空间的一个进程,首先我们需要编写对应的.c/.h文件,然后经过编译器编译成二进制的可执行文件,装载到硬盘上开始执行,最终生成用户进程,这里面涉及到很多细节,本章主要针对这些内容进行深入学习,主要包括以下内容:

我们常见的一个应用场景是,在shell中输入命令,然后等待命令返回。如果以进程创建和终止的角度来看,shell首先会读取命令,解析命令,创建自建成并执行命令,然后父进程在等待子进程终止,其如下图示:

在这里插入图片描述

 BusyBox整体流程如下:

  1. 在busybox中先进入main函数
  2. 根据调用号进入ash_main(也就是busybox的shell)
  3. 进入cmdloop(1)中for循环
  4. 在parsecmd中解析标准输入
  5. 此时在控制台上输入./a_static执行(a_static为我的elf格式的应用程序)
  6. shell解析出命令退出parsecmd进入evaltree再进入evalcommand
  7. 调用forkshell创建子线程,该子线程用来执行a_static,而父进程我们这边被先调用
  8. 父进程进行waitpid系统调用进入wait4再进入do_wait,设置了局部变量wo->notask_error = -ECHILD;设置进程状态TASK_INTERRUPTIBLE
  9. 进入do_wait_thread因为有子进程所以进入wait_consider_task设置wait_consider_task=0退回do_wait
  10. 执行到标号notask处因为局部变量wo->notask_error = 0所以会进入进程调度,而父进程进入睡眠
  11. 子线程被调度后进入shellexec再进入tryexec再进行系统调用execve加载用户空间
  12. arm中通过swi xxx (xxx为系统调用号)进入软中断向量,再进入vector_swi,保存硬件上下文,此时根据 lr-4 也就是swi指令的后半部
  13. 确定系统调用号,根据sys_call_table中的调用号偏移找到入口函数这里是sys_execve并执行
  14. 此时我们的用户空间和父进程一样,TTB中的页表基地址是父进程的页表基地址
  15. 调用do_execve为线程申请用户空间
  16. 退出do_execve,这时我们有了新的用户线性区(我们这边打印出来有四个线性区)和页表,TTB也已经指向自己的页全局目录
  17.  线性区:
  18.         8000 88000    代码区
  19.         8f000 90000  数据区
  20.            90000 92000  堆区
  21.          be9b7000 be9d9000   栈+命令行参数+环境变量
  22. 退出sys_execve,pop出内核栈中的硬件上下文
  23. 执行完用户a_static程序后我们调用系统调用_exit(***)让进程进入僵尸态
  24. 进入SYSCALL_DEFINE1(exit, int, error_code)再进入do_exit进入exit_notify进入do_notify_parent唤醒父进程,退回exit_notify
  25. 设置current->exit_state=EXIT_ZOMBIE,退回do_exit
  26. 调度schedule
  27. 父进程由于被唤醒重新调度进入标号repeat执行,进入do_wait_thread进入wait_consider_task由于子进程tsk->exit_state == EXIT_ZOMBIE。所以调用wait_task_zombie会返回正值,回到do_wait
  28. 由于返回值>0进入标号end,不再进行睡眠和进程调度,直接退出返回用户态 
  29. 回到用户态shell又进行了waitpid调度(不知道为什么)由于没有子进程所以不会进行进程调度直接再次返回用户态
  30. 所以说进程发布waitpid调度 进入睡眠的必要条件是要有子进程。

  • 对于程序员如何从文本文件到可执行的程序
  • 操作系统如何完成对于可执行文件加载

1. 程序的编译

程序编译与形成静态二进制文件详细见博客:

2. 文件系统解析 busybox

./hello_world这个命令在linux内核系统中具体是怎么执行的。文件系统使用busybox,对应的linux内核调用过程。busybox中shell解析命令行并执行命令,主要流程如下:

int ash_main(int argc, char **argv)
{

 state4: /* XXX ??? - why isn't this before the "if" statement */
		cmdloop(1);

}

static int
cmdloop(int top)
{
	union node *n;
	struct stackmark smark;
	int inter;
	int numeof = 0;

    printf("1. cmdloop\n");
	TRACE(("cmdloop(%d) called\n", top));
	for (;;) {
		} else if (nflag == 0) {
			/* job_warning can only be 2,1,0. Here 2->1, 1/0->0 */
            printf("3. cmdloop\n");
			job_warning >>= 1;
			numeof = 0;
			evaltree(n, 0);
			printf("4. cmdloop\n");
		}

}


static void
evaltree(union node *n, int flags)
{
	int checkexit = 0;
	void (*evalfn)(union node *, int);
	unsigned isor;
	int status;
	if (n == NULL) {
		TRACE(("evaltree(NULL) called\n"));
		goto out;
	}


	case NCMD:
		evalfn = evalcommand;
		printf("2. evaltree ncmd switch\n");
 checkexit:
		if (eflag && !(flags & EV_TESTED))
			checkexit = ~0;
		goto calleval;

 calleval:
			evalfn(n, flags);
			break;
		}
}


static void
evalcommand(union node *cmd, int flags)
{
	static const struct builtincmd bltin = {
		"\0\0", bltincmd
	};
	struct stackmark smark;

	/* Execute the command. */
	switch (cmdentry.cmdtype) {
	default:
     	printf("2. cmdentry.cmdtype default.\n");
		/* Fork off a child process if necessary. */
		if (!(flags & EV_EXIT) || trap[0]) {
			INT_OFF;
			jp = makejob(cmd, 1);
			if (forkshell(jp, cmd, FORK_FG) != 0) {
				exitstatus = waitforjob(jp);
				INT_ON;
				break;
			}
        	printf("3. cmdentry.cmdtype forkshell.\n");

			FORCE_INT_ON;
		}
		listsetvar(varlist.list, VEXPORT|VSTACK);
		shellexec(argv, path, cmdentry.u.index);
		/* NOTREACHED */
}

命令执行过程中这里面会调用两个系统调用forkshell和shellexec,forkshell创建父子进程,其中父进程直接进入waitforjob工作,子进程执行shellexec完成应用程序的加载工作,然后修改进程描述中堆栈上面的PC指针进而是现在进程从内核态转向用户态的时候直接跳转到了加载程序的入口位置执行,而不再是主程序调用回复到用户态的那个执行位置了。下面是shellexec陷入系统调用处理流程:

static void
shellexec(char **argv, const char *path, int idx)
{
	char *cmdname;
	int e;
	char **envp;
	int exerrno;

	clearredir(1);
	envp = environment();
	if (strchr(argv[0], '/')
#if ENABLE_FEATURE_SH_STANDALONE
	 || find_applet_by_name(argv[0])
#endif
	) {
		tryexec(argv[0], argv, envp);
		e = errno;
	} else {
		e = ENOENT;
		while ((cmdname = padvance(&path, argv[0])) != NULL) {
			if (--idx < 0 && pathopt == NULL) {
				tryexec(cmdname, argv, envp);
				if (errno != ENOENT && errno != ENOTDIR)
					e = errno;
			}
			stunalloc(cmdname);
		}
	}

}

static void
tryexec(char *cmd, char **argv, char **envp)
{
	int repeated = 0;

 repeat:
#ifdef SYSV
	do {
		execve(cmd, argv, envp);
	} while (errno == EINTR);
#else
	execve(cmd, argv, envp);
#endif
}

execve函数是gcc编译工具提供的库文件实现glibc-2.3.6/sysdeps/unix/sysv/linux/execve.c中定义

int
__execve (file, argv, envp)
     const char *file;
     char *const argv[];
     char *const envp[];
{
#if __BOUNDED_POINTERS__
  {
    char *const *v;
    int i;
    char *__unbounded *__unbounded ubp_argv;
    char *__unbounded *__unbounded ubp_envp;
    char *__unbounded *__unbounded ubp_v;

    for (v = argv; *v; v++)
      ;
    i = v - argv + 1;
    ubp_argv = (char *__unbounded *__unbounded) alloca (sizeof (*ubp_argv) * i);
    for (v = argv, ubp_v = ubp_argv; --i; v++, ubp_v++)
      *ubp_v = CHECK_STRING (*v);
    *ubp_v = 0;

    for (v = envp; *v; v++)
      ;
    i = v - envp + 1;
    ubp_envp = (char *__unbounded *__unbounded) alloca (sizeof (*ubp_envp) * i);
    for (v = envp, ubp_v = ubp_envp; --i; v++, ubp_v++)
      *ubp_v = CHECK_STRING (*v);
    *ubp_v = 0;

    return INLINE_SYSCALL (execve, 3, CHECK_STRING (file), ubp_argv, ubp_envp);
  }
#else
  return INLINE_SYSCALL (execve, 3, file, argv, envp);
#endif
}
weak_alias (__execve, execve)

最终调用到INLINE_SYSCALL宏在头文件glibc-2.3.6/sysdeps/unix/sysv/linux/arm/sysdep.h中实现

/* Define a macro which expands into the inline wrapper code for a system
   call.  */
#undef INLINE_SYSCALL
#define INLINE_SYSCALL(name, nr, args...)				\
  ({ unsigned int _sys_result = INTERNAL_SYSCALL (name, , nr, args);	\
     if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (_sys_result, ), 0))	\
       {								\
	 __set_errno (INTERNAL_SYSCALL_ERRNO (_sys_result, ));		\
	 _sys_result = (unsigned int) -1;				\
       }								\
     (int) _sys_result; })

#undef INTERNAL_SYSCALL_DECL
#define INTERNAL_SYSCALL_DECL(err) do { } while (0)

#undef INTERNAL_SYSCALL
#define INTERNAL_SYSCALL(name, err, nr, args...)		\
  ({ unsigned int _sys_result;					\
     {								\
       register int _a1 asm ("a1");				\
       LOAD_ARGS_##nr (args)					\
       asm volatile ("swi	%1	@ syscall " #name	\
		     : "=r" (_a1)				\
		     : "i" (SYS_ify(name)) ASM_ARGS_##nr	\
		     : "memory");				\
       _sys_result = _a1;					\
     }								\
     (int) _sys_result; })

最终会调用到arm的switch指令进入软中断转入操作系统中断处理中进行处理。

3 程序启动

我们启动程序一般都是在命令行中,其实是在与shell打交道,然后shell帮我们启动程序,并传递相关参数。strace工具能够追踪一个程序执行的系统调用,因而我们构造一个简单的空程序,并在命令行执行: strace ./a.out -a -b

在这里插入图片描述

可见,shell启动程序时执行的第一个系统调用为execve,在glibc库函数中的exec函数族: execl, execlp, execle, execv, execvp, execvpe最终即是调用得该系统调用。

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);
}

第一个参数是要被执行的程序的路径,第二个参数则向程序传递了命令行参数,第三个参数则向程序传递环境变量[1]。当进入sys_execve()系统调用时,在中断处理程序中调用了do_execve()[2]:(路径:fs/exec.c)

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中的参数之外,还有额外的两个参数。

static int do_execveat_common(int fd, struct filename *filename,
                  struct user_arg_ptr argv,
                  struct user_arg_ptr envp,
                  int flags)
{
    char *pathbuf = NULL;
    struct linux_binprm *bprm;
    struct file *file;
    struct files_struct *displaced;
    int retval;
    //1. 检查文件名指针释放为空,如果为空,就直接返回
    if (IS_ERR(filename))                 
        return PTR_ERR(filename);
    //2. 检查当前进程的标志,表明未超出正在运行的进程的限制
    if ((current->flags & PF_NPROC_EXCEEDED) &&
        atomic_read(&current_user()->processes) > rlimit(RLIMIT_NPROC)) {
        retval = -EAGAIN;
        goto out_ret;
    }
    //3.如果两项检查成功,我们将当前进程的标志取消设置PF_NPROC_EXCEEDED,以防止程序执行失败
    current->flags &= ~PF_NPROC_EXCEEDED;
    //4. 取消共享当前任务的文件,并检查此函数结果
    retval = unshare_files(&displaced);
    if (retval)
        goto out_ret;

    retval = -ENOMEM;
    //5, 二进制参数准备,内核申请struct linux_binprm结构
    bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
    if (!bprm)
        goto out_files;
    //6. 准备工作,初始化化linux_binprm的cred结构变量,该结构变量中包含任务的实际uid,任务
         的实际guid,虚拟文件系统操作的uid和gudid
    retval = prepare_bprm_creds(bprm);
    if (retval)
        goto out_free;
    //7. 将当前进程设置为in_execve状态
    check_unsafe_exec(bprm);
    current->in_execve = 1;
    //8. 核心函数,打开可执行文件
    file = do_open_execat(fd, filename, flags);
    retval = PTR_ERR(file);
    if (IS_ERR(file))
        goto out_unmark;
    //9. 用于确定可以执行新程序的最小负载处理器,并将当前进程迁移到该处理器
    sched_exec();
    //10. 检查给出的二进制文件的文件描述符
    bprm->file = file;
    // 我们尝试检查二进制文件的名称是否从/符号开始,或者给定的可执行二进制文件的路径是否相对于
       调用进程
    // 的当前工作目录进行了解释,或者文件描述符为AT_FDCWD。 如果这些检查之一成功,我们将设置二
       进制参数     
    // 文件名
    if (fd == AT_FDCWD || filename->name[0] == '/') {
        bprm->filename = filename->name;
    } else {
     // 否则,如果文件名称为空,则将文件名设置为/dev/fd/%d (即/dev/fd/文件描述符),否则将文件
        名重新设置为/dev/fd/%d/文件名(其中,fd指向可执行文件的文件描述符)
        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;
        }
        //11. 根据获取的信息,填充structlinux_binprm结构体中的file、filename、interp成员
        if (close_on_exec(fd, rcu_dereference_raw(current->files->fdt)))
            bprm->interp_flags |= BINPRM_FLAGS_PATH_INACCESSIBLE;
        bprm->filename = pathbuf;
    }
    bprm->interp = bprm->filename;

    //12. 调用bprm_mm_init()创建进程的内存地址空间,为新程序初始化内存管理.
    retval = bprm_mm_init(bprm);
    if (retval)
        goto out_unmark;
    //计算命令行参数和环境变量
    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;
    // 13. 读取二进制文件,调用prepare_binprm函数将inode的uid填充到linux_binprm结构中,并从
       二进制可        //执行文件中读取128个字节, 我们只从可执行文件中读取前128个,因为我们需
       要检查可执行文件的类型
    retval = prepare_binprm(bprm);
    if (retval < 0)
        goto out;
    //14. 准备好linux_bprm结构后,我们通过调用copy_strings_kernel函数将可执行二进制文件的文
      件名,命令行参数和环境变量从内核复制到linux_bprm
    retval = copy_strings_kernel(1, &bprm->filename, bprm);
    if (retval < 0)
        goto out;

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

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

    would_dump(bprm, bprm->file);

    //15. 通过调用exec_binprm函数来存储当前当前任务所在进程的pid
    retval = exec_binprm(bprm);
    if (retval < 0)
        goto out;

    //16. 执行成功,做一些清理工作
    current->fs->in_exec = 0;
    current->in_execve = 0;
    acct_update_integrals(current);
    task_numa_free(current);
    free_bprm(bprm);
    kfree(pathbuf);
    putname(filename);
    if (displaced)
        put_files_struct(displaced);
    return retval;

out:
    if (bprm->mm) {
        acct_arg_size(bprm, 0);
        mmput(bprm->mm);
    }

out_unmark:
    current->fs->in_exec = 0;
    current->in_execve = 0;

out_free:
    free_bprm(bprm);
    kfree(pathbuf);

out_files:
    if (displaced)
        reset_files_struct(displaced);
out_ret:
    putname(filename);
    return retval;
}
  • 调用kzalloc()分配一份structlinux_binprm结构体
  • 调用open_exec()查找并打开二进制文件
  • 调用sched_exec()找到最小负载的CPU,用来执行该二进制文件
  • 根据获取的信息,填充structlinux_binprm结构体中的file、filename、interp成员
  • 调用bprm_mm_init()创建进程的内存地址空间,为新程序初始化内存管理.
  • 填充struct linux_binprm结构体中的argc、envc成员
  • 调用prepare_binprm()检查该二进制文件的可执行权限;最后,kernel_read()读取二进制文件的头128字节
  • 调用copy_strings_kernel()从内核空间获取二进制文件的路径名称
  • 调用copy_string()从用户空间拷贝环境变量和命令行参数

至此,二进制文件已经被打开,struct linux_binprm结构体中也记录了重要信息, 内核开始调用exec_binprm执行可执行程序。下面来重点看看内存相关信息的处理接口bprm_mm_init

static int bprm_mm_init(struct linux_binprm *bprm)
{
    int err;
    struct mm_struct *mm = NULL;
    //申请mm_struct
    //mm_alloc我们在进程创建的时候已经分析过了,值得注意的是,它会调用mm_init()来为
    //进程的用户空间建立PGD->PMD映射
    bprm->mm = mm = mm_alloc();
    err = -ENOMEM;
    if (!mm)
        goto err;
    //初始化mm_struct 和 vm_area_struct结构
    err = __bprm_mm_init(bprm);
    if (err)
        goto err;

    return 0;

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

    return err;
}

重点是在__bprm_mm_init():

static int __bprm_mm_init(struct linux_binprm *bprm)

{

int err = -ENOMEM;

struct vm_area_struct *vma = NULL;

struct mm_struct *mm = bprm->mm;

//分配一个VMA

bprm->vma = vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);

if (!vma)

goto err;

down_write(&mm->mmap_sem);

vma->vm_mm = mm;

//STACK_TOP_MAX:进程用户空间的最高值

//对应进程的栈顶

vma->vm_end = STACK_TOP_MAX;

vma->vm_start = vma->vm_end - PAGE_SIZE;

vma->vm_flags = VM_STACK_FLAGS;

vma->vm_page_prot = vm_get_page_prot(vma->vm_flags);

//将VM插入mm表示的进程空间结构

err = insert_vm_struct(mm, vma);

if (err) {

up_write(&mm->mmap_sem);

goto err;

}

mm->stack_vm = mm->total_vm = 1;

up_write(&mm->mmap_sem);

//bprm->p:用户栈的栈指针

bprm->p = vma->vm_end - sizeof(void *);

return 0;

err:

if (vma) {

bprm->vma = NULL;

kmem_cache_free(vm_area_cachep, vma);

}

return err;

}

内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干(当前Linux内核中是128)字节(实际上就是填充ELF文件头,下面的分析可以看到),然后调用另一个函数exec_binprm()

3.2. ELF文件格式

Linux下标准的可执行文件格式是ELF.ELF(Executable and Linking Format)是一种对象文件的格式,用于定义不同类型的对象文件(Object files)中都放了什么东西、以及都以什么样的格式去放这些东西。它自最早在 System V 系统上出现后,被 UNIX 世界所广泛接受,作为缺省的二进制文件格式来使用。

但是linux也支持其他不同的可执行程序格式, 各个可执行程序的执行方式不尽相同, 因此linux内核每种被注册的可执行程序格式都用linux_bin_fmt来存储, 其中记录了可执行程序的加载和执行函数。同时我们需要一种方法来保存可执行程序的信息, 比如可执行文件的路径, 运行的参数和环境变量等信息,即linux_bin_prm结构

struct linux_binprm {
    char buf[BINPRM_BUF_SIZE];                      // 保存可执行文件的头128字节
#ifdef CONFIG_MMU
    struct vm_area_struct *vma;                     //内存相关vm_area_structc初始化
    unsigned long vma_pages;
#else
# define MAX_ARG_PAGES    32
    struct page *page[MAX_ARG_PAGES];
#endif
    struct mm_struct *mm;                            //内存相关mm_struct初始化
    unsigned long p; /* current top of mem */
    unsigned int
        cred_prepared:1,/* true if creds already prepared (multiple
                 * preps happen for interpreters) */
        cap_effective:1;/* true if has elevated effective capabilities,
                 * false if not; except for init which inherits
                 * its parent's caps anyway */
#ifdef __alpha__
    unsigned int taso:1;
#endif
    unsigned int recursion_depth; /* only for search_binary_handler() */
    struct file * file;                            // 要执行的文件
    struct cred *cred;    /* new credentials */
    int unsafe;        /* how unsafe this exec is (mask of LSM_UNSAFE_*) */
    unsigned int per_clear;    /* bits to clear in current->personality */
    int argc, envc;
    const char * filename;    /* Name of binary as seen by procps,要执行的文件的名称*/
    const char * interp;    /* Name of the binary really executed. Most
                   of the time same as filename, but could be
        different for binfmt_{misc,script} *///要执行的文件的真实名称,通常和filename相同
    unsigned interp_flags;
    unsigned interp_data;
    unsigned long loader, exec;
}

linux内核对所支持的每种可执行的程序类型都有个struct linux_binfmt的数据结构,定义如下

struct linux_binfmt {
    struct list_head lh;
    struct module *module;
    int (*load_binary)(struct linux_binprm *);
    int (*load_shlib)(struct file *);
    int (*core_dump)(struct coredump_params *cprm);
    unsigned long min_coredump;    /* minimal dump size */
};

成员    描述
load_binary    通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境,普通程序加载
load_shlib    用于动态的把一个共享库捆绑到一个已经在运行的进程, 这是由uselib()系统调用激活的,主要用于动态加载,即动态库
core_dump    主要用于程序错误的情况下输出共享转储,该转存储随后可以通过调试器(gdb)分析,以便解决问题
所有的linux_binfmt对象都处于一个链表中, 第一个元素的地址存放在formats变量中, 可以通过调用register_binfmt()和unregister_binfmt()函数在链表中插入和删除元素, 在系统启动期间, 为每个编译进内核的可执行格式都执行registre_fmt()函数. 当实现了一个新的可执行格式的模块正被装载时, 也执行这个函数, 当模块被卸载时, 执行unregister_binfmt()函数.

4 处理参数结构

通过调用exec_binprm函数来存储当前当前任务所在进程的pid,下面需要识别该二进制文件的格式并最终运行该文件

static int exec_binprm(struct linux_binprm *bprm)
{
    pid_t old_pid, old_vpid;
    int ret;

    /* Need to fetch pid before load_binary changes it */
    old_pid = current->pid;
    rcu_read_lock();
    old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
    rcu_read_unlock();

    ret = search_binary_handler(bprm);
    if (ret >= 0) {
        audit_bprm(bprm);
        trace_sched_process_exec(current, old_pid, bprm);
        ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
        proc_exec_connector(current);
    }

    return ret;
}

调用函数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;
    }
}

在load_binary中检查linux_bprm缓冲区中的魔数(每个elf二进制文件的头中都包含魔数,我们从可执行二进制文件中读取了前128个字节),如果不是elf二进制,则退出。根据可执行文件的类型(如shell,a.out,ELF等),查找到相应的处理函数(系统为每种文件类型创建了一个struct linux_binfmt,并把其串在一个链表上,执行时遍历这个链表,找到相应类型的结构。如果要自己定义一种可执行文件格式,也需要实现这么一个handler)。然后执行相应的load_binary()函数开始加载可执行文件。

5. 加载ELF

linux内核启动时将ELF格式注册到内核可支持的文件格式链表中,也就是通过register_binfmt 函数将定义的elf_format结构体添加到链表中。该结构体如下:

static struct linux_binfmt elf_format = {
    .module        = THIS_MODULE,
    .load_binary    = load_elf_binary,
    .load_shlib    = load_elf_library,
    .core_dump    = elf_core_dump,
    .min_coredump    = ELF_EXEC_PAGESIZE,
};

当我们执行一个可执行程序的时候, 内核会list_for_each_entry遍历所有注册的linux_binfmt对象, 对其调load_binrary方法来尝试加载, 直到加载成功为止。上面代码可以看倒,ELF中加载程序即为load_elf_binary,内核中已经注册的可运行文件结构linux_binfmt会让其所属的加载程序load_binary逐一前来认领需要运行的程序binary,如果某个格式的处理程序发现相符后,便执行该格式映像的装入和启动。

**第一步,填充并且检查目标程序ELF头部**

    /* Get the exec-header */
//1. 获取到exec文件的'elf header'保存到loc->elf_ex bprm->buf[]中实现读取了exec文件头256字节的内容
    loc->elf_ex = *((struct elfhdr *)bprm->buf);

    retval = -ENOEXEC;
    /* First of all, some simple consistency checks */
//2. 检查elf header中的magic number是否合法
    if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
        goto out;
//3. 检查文件类型是否是可以执行的exe或者so
    if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
        goto out;
//4. 检查文件的架构类型和当前环境是否符合
    if (!elf_check_arch(&loc->elf_ex))
        goto out;
    if (!bprm->file->f_op->mmap)
        goto out;
//5. 根据exec文件'elf header'中的信息,读出 'program header table' 保存到elf_phdata
    elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
    if (!elf_phdata)
        goto out;

首先是填充文件头,使用内核之前对bprm->buf填充的128个字节信息。然后比较了文件头的前4个字节,查看是否是标准的ELF文件魔数(”\177ELF”),然后还需要确认该文件是可执行文件还是动态链接库文件,也就是代码中的ET_EXEC和ET_DYN。

第二步,通过load_elf_phdrs加载目标程序的程序头表

static struct elf_phdr *load_elf_phdrs(struct elfhdr *elf_ex,
                       struct file *elf_file)
{
    struct elf_phdr *elf_phdata = NULL;
    int retval, size, err = -1;

    /*
     * If the size of this structure has changed, then punt, since
     * we will be doing the wrong thing.
     */
    if (elf_ex->e_phentsize != sizeof(struct elf_phdr))
        goto out;

    /* Sanity check the number of program headers... */
    if (elf_ex->e_phnum < 1 ||
        elf_ex->e_phnum > 65536U / sizeof(struct elf_phdr))
        goto out;

    /* ...and their total size. */
    size = sizeof(struct elf_phdr) * elf_ex->e_phnum;
    if (size > ELF_MIN_ALIGN)
        goto out;

    elf_phdata = kmalloc(size, GFP_KERNEL);
    if (!elf_phdata)
        goto out;

    /* Read in the program headers */
    retval = kernel_read(elf_file, elf_ex->e_phoff,
                 (char *)elf_phdata, size);
    if (retval != size) {
        err = (retval < 0) ? retval : -EIO;
        goto out;
    }

    /* Success! */
    err = 0;
out:
    if (err) {
        kfree(elf_phdata);
        elf_phdata = NULL;
    }
    return elf_phdata;
}

该函数有两个参数,elf_ex表示需要程序头表需要被加载的二进制映像的ELF头部;elf_file表示这个打开的ELF二进制映像文件。函数首先检查该文件是否包含至少一个段,且所有段的大小之和是否超过64k。如果符合条件,调用kernel_read读入程序头表。

第三步,处理解释器段

如果需要动态链接,则需要寻找和处理器解释段

    for (i = 0; i < loc->elf_ex.e_phnum; i++) {
//1.  找到PT_INTERP segment
        if (elf_ppnt->p_type == PT_INTERP) {        
            /* This is the program interpreter used for
             * shared libraries - for now assume that this
             * is an a.out format binary
             */
            retval = -ENOEXEC;
            if (elf_ppnt->p_filesz > PATH_MAX || 
                elf_ppnt->p_filesz < 2)
                goto out_free_ph;

            retval = -ENOMEM;
            elf_interpreter = kmalloc(elf_ppnt->p_filesz,
                          GFP_KERNEL);
            if (!elf_interpreter)
                goto out_free_ph;
//2. 读出PT_INTERP segment的内容
            retval = kernel_read(bprm->file, elf_ppnt->p_offset,
                         elf_interpreter,
                         elf_ppnt->p_filesz);
            if (retval != elf_ppnt->p_filesz) {
                if (retval >= 0)
                    retval = -EIO;
                goto out_free_interp;
            }
            /* make sure path is NULL terminated */
            retval = -ENOEXEC;
            if (elf_interpreter[elf_ppnt->p_filesz - 1] != '\0')
                goto out_free_interp;
//3. open interpreter文件,得到操作句柄
            interpreter = open_exec(elf_interpreter);
            retval = PTR_ERR(interpreter);
            if (IS_ERR(interpreter))
                goto out_free_interp;

            /*
             * If the binary is not readable then enforce
             * mm->dumpable = 0 regardless of the interpreter's
             * permissions.
             */
            would_dump(bprm, interpreter);

            /* Get the exec headers */
// 4.  读取interpreter文件的'elf header'保存到interp_elf_ex
            retval = kernel_read(interpreter, 0,
                         (void *)&loc->interp_elf_ex,
                         sizeof(loc->interp_elf_ex));
            if (retval != sizeof(loc->interp_elf_ex)) {
                if (retval >= 0)
                    retval = -EIO;
                goto out_free_dentry;
            }

            break;
        }

通过遍历每个段,找到PT_INTERP类型的段,也即解释器段,找到就说明运行过程中需要动态链接。同样也是通过kernel_read函数将解释器段的内容读入缓冲区。readelf命令可以查看到程序的解释器段其实就是一个字符串,也就是解释器的文件名,比如“/lib/ld-linux.so.2”。再调用open_exec()函数根据这个文件名打开解释器文件,和前面一样,再读入128个字节,也就是解释器映像的头部。

可以使用readelf -l查看program headers, 其中的INTERP段标识了我们程序所需要的解释器,该过程只是动态链接需要解释器,静态链接不需要解释器。

在这里插入图片描述

第四步,检查并读取解释器程序头表

如果需要加载解释器, 前面经过一趟for循环已经找到了需要的解释器信息elf_interpreter, 他也是当作一个ELF文件, 因此跟目标可执行程序一样, 我们需要load_elf_phdrs加载解释器的程序头表program header table

    if (elf_interpreter) {//检查解释器头的信息
        retval = -ELIBBAD;
        /* Not an ELF interpreter */
        if (memcmp(loc->interp_elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
            goto out_free_dentry;
        /* Verify the interpreter has a valid arch */
        if (!elf_check_arch(&loc->interp_elf_ex))
            goto out_free_dentry;

        /* 读入解释器的程序头 */
        interp_elf_phdata = load_elf_phdrs(&loc->interp_elf_ex,
                           interpreter);
        if (!interp_elf_phdata)
            goto out_free_dentry;

        /* Pass PT_LOPROC..PT_HIPROC headers to arch code */
        elf_ppnt = interp_elf_phdata;
        for (i = 0; i < loc->interp_elf_ex.e_phnum; i++, elf_ppnt++)
            switch (elf_ppnt->p_type) {
            case PT_LOPROC ... PT_HIPROC:
                retval = arch_elf_pt_proc(&loc->interp_elf_ex,
                              elf_ppnt, interpreter,
                              true, &arch_state);
                if (retval)
                    goto out_free_dentry;
                break;
            }
    }

至此我们已经把目标执行程序和其所需要的解释器都加载初始化, 并且完成检查工作, 也加载了程序头表program header table, 下面开始加载程序的段信息

第五步,装入目标程序段segment

这段代码从目标映像的程序头中搜索类型为PT_LOAD的段(Segment)。在二进制映像中,只有类型为PT_LOAD的段才是需要装入的。当然在装入之前,需要确定装入的地址,只要考虑的就是页面对齐,还有该段的p_vaddr域的值(上面省略这部分内容)。确定了装入地址后,就通过elf_map()建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址。

for(i = 0, elf_ppnt = elf_phdata;
    i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {

    /*  5.1   搜索PT_LOAD的段, 这个是需要装入的 */
    if (elf_ppnt->p_type != PT_LOAD)
        continue;


        /* 5.2  检查地址和页面的信息  */
        
        // ......
        ///

     /*  5.3  虚拟地址空间与目标映像文件的映射
     确定了装入地址后,
     就通过elf_map()建立用户空间虚拟地址空间
     与目标映像文件中某个连续区间之间的映射,
     其返回值就是实际映射的起始地址 */
    error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
            elf_prot, elf_flags, total_size);

    }

这里需要整合的连接是:

进程内存管理初探_宋宝华的博客-CSDN博客

mmap源码分析_mmap源码解析_404-not-found的博客-CSDN博客


static unsigned long elf_map(struct file *filep, unsigned long addr,
    map_addr = vm_mmap(filep, addr, total_size, prot, type, off);
        unsigned long vm_mmap(struct file *file, unsigned long addr,
    	unsigned long len, unsigned long prot,
    	unsigned long flag, unsigned long offset)
        {
	        if (unlikely(offset + PAGE_ALIGN(len) < offset))
		        return -EINVAL;
	        if (unlikely(offset_in_page(offset)))
		        return -EINVAL;

	        return vm_mmap_pgoff(file, addr, len, prot, flag, offset >> PAGE_SHIFT);
        }

            do_mmap_pgoff
                return do_mmap(file, addr, len, prot, flags, 0, pgoff, populate);
                    	addr = get_unmapped_area(file, addr, len, pgoff, flags);
                            get_area = current->mm->get_unmapped_area;

调用elf_map的流程如下,最后设计一个重要接口get_unmapped_area,这个接口实在arch_pick_mmap_layout初始化中赋值

一、两种进程地址空间的布局介绍

1、在x86_32,虚拟地址空间从0到0xc0000000,每个用户进程有3GB可用。TASK_UNMAPPED_BASE一般起始于0x4000000(即1GB)。这意味着堆只有1GB的空间可供使用,继续增长则进入到mmap区域。这时mmap区域是自底向上扩展的。


2、使用固定值限制栈的最大长度。由于栈是有界的,因此安置内存映射的区域可以在栈末端的下方立即开始。这时mmap区是自顶向下扩展的。由于堆仍然位于虚拟地址空间中较低的区域并向上增长,因此mmap区域和堆可以相对扩展,直至耗尽虚拟地址空间中剩余的区域

二、进程的内存布局选择的工作由arch_pick_mmap_layout完成。其中arch_get_unmapped_area()完成从低地址向高地址创建新的映射,而arch_get_unmapped_area_topdown()完成从高地址向低地址创建新的映射。

kernel/msm-4.19/arch/arm64/mm/mmap.c

/*
 * This function, called very early during the creation of a new process VM
 * image, sets up which VM layout function to use:
 */
void arch_pick_mmap_layout(struct mm_struct *mm, struct rlimit *rlim_stack)
{
    unsigned long random_factor = 0UL;

    if (current->flags & PF_RANDOMIZE)
        random_factor = arch_mmap_rnd();

    /*
     * Fall back to the standard layout if the personality bit is set, or
     * if the expected stack growth is unlimited:
     */
    if (mmap_is_legacy(rlim_stack)) {
        mm->mmap_base = TASK_UNMAPPED_BASE + random_factor;
        mm->get_unmapped_area = arch_get_unmapped_area;
    } else {
        mm->mmap_base = mmap_base(random_factor, rlim_stack);
        mm->get_unmapped_area = arch_get_unmapped_area_topdown;
    }
}

三、arch_pick_mmap_layout什么时候调用

<4>[ 167.681299][02-14 21:01:39] Call trace:
<4>[ 167.681313][02-14 21:01:39] dump_backtrace+0x0/0x188
<4>[ 167.681320][02-14 21:01:39] show_stack+0x14/0x20
<4>[ 167.681329][02-14 21:01:39] dump_stack+0xc4/0xfc
<4>[ 167.681337][02-14 21:01:39] arch_pick_mmap_layout+0x198/0x1a8
<4>[ 167.681345][02-14 21:01:39] setup_new_exec+0x54/0x190
<4>[ 167.681354][02-14 21:01:39] load_elf_binary+0x328/0x1508
<4>[ 167.681359][02-14 21:01:39] search_binary_handler+0xac/0x218
<4>[ 167.681364][02-14 21:01:39] __do_execve_file+0x6ec/0x9d8
<4>[ 167.681369][02-14 21:01:39] set_binfmt+0x8c/0x1d8
<4>[ 167.681375][02-14 21:01:39] __arm_smccc_hvc+0x154/0x49c
<4>[ 167.681381][02-14 21:01:39] __arm_smccc_hvc+0x98/0x49c
<4>[ 167.681388][02-14 21:01:39] el0_svc+0x8/0xc



作者:牛逼人物888
链接:https://www.jianshu.com/p/60b1f7c85f8d
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

第七步,填入程序的入口地址

完成了目标程序和解释器的加载, 同时目标程序的各个段也已经加载到内存了, 我们的目标程序已经准备好了要执行了, 但是还缺少一样东西, 就是我们程序的入口地址, 没有入口地址, 操作系统就不知道从哪里开始执行内存中加载好的可执行映像

    if (elf_interpreter) {
        unsigned long interp_map_addr = 0;
        //入口地址是解释器映像的入口地址
        elf_entry = load_elf_interp(&loc->interp_elf_ex,
                        interpreter,
                        &interp_map_addr,
                        load_bias, interp_elf_phdata);
        if (!IS_ERR((void *)elf_entry)) {
            /*
             * load_elf_interp() returns relocation
             * adjustment
             */
            interp_load_addr = elf_entry;
            elf_entry += loc->interp_elf_ex.e_entry;
        }
        if (BAD_ADDR(elf_entry)) {
            retval = IS_ERR((void *)elf_entry) ?
                    (int)elf_entry : -EINVAL;
            goto out_free_dentry;
        }
        reloc_func_desc = interp_load_addr;

        allow_write_access(interpreter);
        fput(interpreter);
        kfree(elf_interpreter);
    } else {
        //入口地址是目标程序的入口地址
        elf_entry = loc->elf_ex.e_entry;
        if (BAD_ADDR(elf_entry)) {
            retval = -EINVAL;
            goto out_free_dentry;
        }
    }


如果需要装入解释器,就通过load_elf_interp装入其映像, 并把将来进入用户空间的入口地址设置成load_elf_interp()的返回值,即解释器映像的入口地址。而若不装入解释器,那么这个入口地址就是目标映像本身的入口地址。

第八步,文件参数环境变量等必要信息

在完成装入,启动用户空间的映像运行之前,还需要为目标映像和解释器准备好一些有关的信息,这些信息包括常规的argc、envc等等,还有一些“辅助向量(Auxiliary Vector)”。这些信息需要复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。

    retval = create_elf_tables(bprm, &loc->elf_ex,
              load_addr, interp_load_addr);
    if (retval < 0)
        goto out;
    /* N.B. passed_fileno might not be initialized? */
    current->mm->end_code = end_code;
    current->mm->start_code = start_code;
    current->mm->start_data = start_data;
    current->mm->end_data = end_data;
    current->mm->start_stack = bprm->p;


第九步,通过start_thread准备进入新的程序入口

start_thread()这个宏操作会将eip和esp改成新的地址,就使得CPU在返回用户空间时就进入新的程序入口。如果存在解释器映像,那么这就是解释器映像的程序入口,否则就是目标映像的程序入口。那么什么情况下有解释器映像存在,什么情况下没有呢?如果目标映像与各种库的链接是静态链接,因而无需依靠共享库、即动态链接库,那就不需要解释器映像;否则就一定要有解释器映像存在。

static inline void start_thread_common(struct pt_regs *regs, unsigned long pc)
{
    memset(regs, 0, sizeof(*regs));
    regs->syscallno = ~0UL;
    regs->pc = pc;
}


完成这个功能的是start_thread(),start_thread()并不启动一个线程,而只是用来修改了pt_regs中保存的PC等寄存器的值,使其指向加载的应用程序的入口。这样当内核操作结束,返回用户态的时候,接下来执行的就是应用程序了。

6 从ELF入口到main函数
从上面的过程,我们知道了一个新的进程,是如何执行到可执行的入口地址呢?对于这个入口地址,是我们的main函数吗?

我们还是以一个简单的hello world为例

#include <stdio.h>
int main() {
    printf("hello, world!\n");
    return 0;
}

通过gcc编译后,生成了一个ELF可执行文件,通过readelf指令,可以实现对ELF文件的分析,这里可以看到ELF文件的入口地址是0x530

在这里插入图片描述

随后,我们通过返回表,看一下位于0x530入口地址的地方是什么函数?

在这里插入图片描述

可以看到,入口地址是一个_start的函数,并不是我们的main函数,在_start的结尾,调用了__libc_start_main函数,而这个函数位于libc.so中。

其实,在进入main函数之前,还有一个重要的工作要做,这就是:C/C++运行时库的初始化。上面的**__libc_start_main** 就是在完成这一工作。

在通过GCC进行编译时,编译器将自动完成运行时库的链接,将我们的main函数封装起来,由它来调用。glibc是开源的,我们可以在GitHub上找到这个项目的libc-start.c文件,一窥 __libc_start_main 的真面目,我们的main函数正是被它在调用。

在这里插入图片描述

7. 总结

简短的说,整个在shell中键入./hello执行应用程序的过程为:当前shell进程fork出一个子进程(子shell),子进程使用execve来脱离和父进程的关系,加载hello文件(ELF格式)到内存中。如果hello使用了动态链接库,就需要加载动态链接器(或者叫程序解释器),进一步加载hello使用到的动态链接库到内存,并重定位以供hello调用,从hello的入口地址开始执行test。

在这里插入图片描述

今天我们来思考一个简单的问题,一个程序是如何在 Linux 上执行起来的?

我们就拿全宇宙最简单的 Hello World 程序来举例。

#include <stdio.h>
int main()
{
   printf("Hello, World!\n");
   return 0;
}

我们在写完代码后,进行简单的编译,然后在 shell 命令行下就可以把它启动起来。

# gcc main.c -o helloworld
# ./helloworld
Hello, World!

那么在编译启动运行的过程中都发生了哪些事情了呢?今天就让我们来深入地了解一下。

好文推荐:

什么是Linux内核,如何搞懂Linux内核?(Linux内核学习笔记合集来了!)

字节终面:CPU 是如何读写内存的?

全网最牛Linux内核分析--Intel CPU体系结构

一文让你读懂Linux五大模块内核源码,内核整体架构设计(超详细)

嵌入式前景真的好吗?那有点悬!

一文教你如何使用GDB+Qemu调试Linux内核

Linux内核必读五本书籍(强烈推荐)

全网独一无二Linux内核Makefle系统文件详解(一)(纯文字代码)

带你深度了解Linux内核架构和工作原理!

如何读懂GDB底层实现原理(从这几点入手~)

一文彻底理解Memory barrier(内存屏障)

一篇文带你搞懂,虚拟内存、内存分页、分段、段页式内存管理(超详细)

一、理解可执行文件格式

源代码在编译后会生成一个可执行程序文件,我们先来了解一下编译后的二进制文件是什么样子的。

我们首先使用 file 命令查看一下这个文件的格式。

# file helloworld
helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ...

file 命令给出了这个二进制文件的概要信息,其中 ELF 64-bit LSB executable 表示这个文件是一个 ELF 格式的 64 位的可执行文件。x86-64 表示该可执行文件支持的 cpu 架构。

LSB 的全称是 Linux Standard Base,是 Linux 标准规范。其目的是制定一系列标准来增强 Linux 发行版的兼容性。

ELF 的全称是 Executable Linkable Format,是一种二进制文件格式。Linux 下的目标文件、可执行文件和 CoreDump 都按照该格式进行存储。

ELF 文件由四部分组成,分别是 ELF 文件头 (ELF header)、Program header table、Section 和 Section header table。

接下来我们分几个小节挨个介绍一下。

1.1 ELF 文件头

ELF 文件头记录了整个文件的属性信息。原始二进制非常不便于观察。不过我们有趁手的工具 - readelf,这个工具可以帮我们查看 ELF 文件中的各种信息。

我们先来看一下编译出来的可执行文件的 ELF 文件头,使用 --file-header (-h) 选项即可查看。

# readelf --file-header helloworld
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x401040
  Start of program headers:          64 (bytes into file)
  Start of section headers:          23264 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         11
  Size of section headers:           64 (bytes)
  Number of section headers:         30
  Section header string table index: 29

ELF 文件头包含了当前可执行文件的概要信息,我把其中关键的几个拿出来给大家解释一下。

  • Magic:一串特殊的识别码,主要用于外部程序快速地对这个文件进行识别,快速地判断文件类型是不是 ELF
  • Class:表示这是 ELF64 文件
  • Type:为 EXEC 表示是可执行文件,其它文件类型还有 REL(可重定位的目标文件)、DYN(动态链接库)、CORE(系统调试 coredump文件)
  • Entry point address:程序入口地址,这里显示入口在 0x401040 位置处
  • Size of this header:ELF 文件头的大小,这里显示是占用了 64 字节

以上几个字段是 ELF 头中对 ELF 的整体描述。另外 ELF 头中还有关于 program headers 和 section headers 的描述信息。

  • Start of program headers:表示 Program header 的位置
  • Size of program headers:每一个 Program header 大小
  • Number of program headers:总共有多少个 Program header
  • Start of section headers: 表示 Section header 的开始位置。
  • Size of section headers:每一个 Section header 的大小
  • Number of section headers: 总共有多少个 Section header

  

1.2 Program Header Table

在介绍 Program Header Table 之前我们展开介绍一下 ELF 文件中一对儿相近的概念 - Segment 和 Section。

ELF 文件内部最重要的组成单位是一个一个的 Section。每一个 Section 都是由编译链接器生成的,都有不同的用途。例如编译器会将我们写的代码编译后放到 .text Section 中,将全局变量放到 .data 或者是 .bss Section中。

但是对于操作系统来说,它不关注具体的 Section 是啥,它只关注这块内容应该以何种权限加载到内存中,例如读,写,执行等权限属性。因此相同权限的 Section 可以放在一起组成 Segment,以方便操作系统更快速地加载。

由于 Segment 和 Section 翻译成中文的话,意思太接近了,非常不利于理解。所以本文中我就直接使用 Segment 和 Section 原汁原味的概念,而不是将它们翻译成段或者是节,这样太容易让人混淆了。

Program headers table 就是作为所有 Segments 的头信息,用来描述所有的 Segments 的。 。

使用 readelf 工具的 --program-headers(-l)选项可以解析查看到这块区域里存储的内容。

# readelf --program-headers helloworld
Elf file type is EXEC (Executable file)
Entry point 0x401040
There are 11 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
     FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
     0x0000000000000268 0x0000000000000268  R      0x8
  INTERP         0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
     0x000000000000001c 0x000000000000001c  R      0x1
   [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
     0x0000000000000438 0x0000000000000438  R      0x1000
  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
     0x00000000000001c5 0x00000000000001c5  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000402000 0x0000000000402000
     0x0000000000000138 0x0000000000000138  R      0x1000
  LOAD           0x0000000000002e10 0x0000000000403e10 0x0000000000403e10
     0x0000000000000220 0x0000000000000228  RW     0x1000
  DYNAMIC        0x0000000000002e20 0x0000000000403e20 0x0000000000403e20
     0x00000000000001d0 0x00000000000001d0  RW     0x8
  NOTE           0x00000000000002c4 0x00000000004002c4 0x00000000004002c4
     0x0000000000000044 0x0000000000000044  R      0x4
  GNU_EH_FRAME   0x0000000000002014 0x0000000000402014 0x0000000000402014
     0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
     0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000002e10 0x0000000000403e10 0x0000000000403e10
     0x00000000000001f0 0x00000000000001f0  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00   
   01     .interp 
   02     .interp .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   03     .init .plt .text .fini 
   04     .rodata .eh_frame_hdr .eh_frame 
   05     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   06     .dynamic 
   07     .note.gnu.build-id .note.ABI-tag 
   08     .eh_frame_hdr 
   09   
   10     .init_array .fini_array .dynamic .got

上面的结果显示总共有 11 个 program headers。

对于每一个段,输出了 Offset、VirtAddr 等描述当前段的信息。Offset 表示当前段在二进制文件中的开始位置,FileSiz 表示当前段的大小。Flag 表示当前的段的权限类型, R 表示可都、E 表示可执行、W 表示可写。

在最下面,还把每个段是由哪几个 Section 组成的给展示了出来,比如 03 号段是由“.init .plt .text .fini” 四个 Section 组成的。

1.3 Section Header Table

和 Program Header Table 不一样的是,Section header table 直接描述每一个 Section。这二者描述的其实都是各种 Section ,只不过目的不同,一个针对加载,一个针对链接。

使用 readelf 工具的 --section-headers (-S)选项可以解析查看到这块区域里存储的内容。

# readelf --section-headers helloworld
There are 30 section headers, starting at offset 0x5b10:

Section Headers:
  [Nr] Name              Type             Address           Offset
    Size              EntSize          Flags  Link  Info  Align
  ......
  [13] .text             PROGBITS         0000000000401040  00001040
    0000000000000175  0000000000000000  AX       0     0     16
  ......
  [23] .data             PROGBITS         0000000000404020  00003020
    0000000000000010  0000000000000000  WA       0     0     8
  [24] .bss              NOBITS           0000000000404030  00003030
    0000000000000008  0000000000000000  WA       0     0     1
  ......  
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

结果显示,该文件总共有 30 个 Sections,每一个 Section 在二进制文件中的位置通过 Offset 列表示了出来。Section 的大小通过 Size 列体现。

在这 30 个Section中,每一个都有独特的作用。我们编写的代码在编译成二进制指令后都会放到 .text 这个 Section 中。另外我们看到 .text 段的 Address 列显示的地址是 0000000000401040。回忆前面我们在 ELF 文件头中看到 Entry point address 显示的入口地址为 0x401040。这说明,程序的入口地址就是 .text 段的地址。

另外还有两个值得关注的 Section 是 .data 和 .bss。代码中的全局变量数据在编译后将在在这两个 Section 中占据一些位置。如下简单代码所示。

//未初始化的内存区域位于 .bss 段
int data1 ;   

//已经初始化的内存区域位于 .data 段
int data2 = 100 ;  

//代码位于 .text 段
int main(void)
{
 ...
}

1.4 入口进一步查看

接下来,我们想再查看一下我们前面提到的程序入口 0x401040,看看它到底是啥。我们这次再借助 nm 命令来进一步查看一下可执行文件中的符号及其地址信息。-n 选项的作用是显示的符号以地址排序,而不是名称排序。

# nm -n helloworld
     w __gmon_start__
     U __libc_start_main@@GLIBC_2.2.5
     U printf@@GLIBC_2.2.5
......               
0000000000401040 T _start
......
0000000000401126 T main

通过以上输出可以看到,程序入口 0x401040 指向的是 _start 函数的地址,在这个函数执行一些初始化的操作之后,我们的入口函数 main 将会被调用到,它位于 0x401126 地址处。

二、用户进程的创建过程概述

在我们编写的代码编译完生成可执行程序之后,下一步就是使用 shell 把它加载起来并运行之。一般来说 shell 进程是通过fork+execve来加载并运行新进程的。一个简单加载 helloworld 命令的 shell 核心逻辑是如下这个过程。

// shell 代码示例
int main(int argc, char * argv[])
{
 ...
 pid = fork();
 if (pid==0){ // 如果是在子进程中
  //使用 exec 系列函数加载并运行可执行文件
  execve("helloworld", argv, envp);
 } else {
  ...
 }
 ...
}

shell 进程先通过 fork 系统调用创建一个进程出来。然后在子进程中调用 execve 将执行的程序文件加载起来,然后就可以调到程序文件的运行入口处运行这个程序了。

在上一篇文章《Linux进程是如何创建出来的?》中,我们详细介绍过了 fork 的工作过程。这里我们再简单过一下。

这个 fork 系统调用在内核入口是在 kernel/fork.c 下。

//file:kernel/fork.c
SYSCALL_DEFINE0(fork)
{
 return do_fork(SIGCHLD, 0, 0, NULL, NULL);
}

在 do_fork 的实现中,核心是一个 copy_process 函数,它以拷贝父进程(线程)的方式来生成一个新的 task_struct 出来。

//file:kernel/fork.c
long do_fork(...)
{
 //复制一个 task_struct 出来
 struct task_struct *p;
 p = copy_process(clone_flags, stack_start, stack_size,
    child_tidptr, NULL, trace);

 //子任务加入到就绪队列中去,等待调度器调度
 wake_up_new_task(p);
 ...
}

在 copy_process 函数中为新进程申请 task_struct,并用当前进程自己的地址空间、命名空间等对新进程进行初始化,并为其申请进程 pid。

//file:kernel/fork.c
static struct task_struct *copy_process(...)
{
 //复制进程 task_struct 结构体
 struct task_struct *p;
 p = dup_task_struct(current);
 ...

 //进程核心元素初始化
 retval = copy_files(clone_flags, p);
 retval = copy_fs(clone_flags, p);
 retval = copy_mm(clone_flags, p);
 retval = copy_namespaces(clone_flags, p);
 ...

 //申请 pid && 设置进程号
 pid = alloc_pid(p->nsproxy->pid_ns);
 p->pid = pid_nr(pid);
 p->tgid = p->pid;
 ......
}

执行完后,进入 wake_up_new_task 让新进程等待调度器调度。

不过 fork 系统调用只能是根据当的 shell 进程再复制一个新的进程出来。这个新进程里的代码、数据都还是和原来的 shell 进程的内容一模一样。

要想实现加载并运行另外一个程序,比如我们编译出来的 helloworld 程序,那还需要使用到 execve 系统调用。

三. Linux 可执行文件加载器

其实 Linux 不是写死只能加载 ELF 一种可执行文件格式的。它在启动的时候,会把自己支持的所有可执行文件的解析器都加载上。并使用一个 formats 双向链表来保存所有的解析器。其中 formats 双向链表在内存中的结构如下图所示。

我们就以 ELF 的加载器 elf_format 为例,来看看这个加载器是如何注册的。在 Linux 中每一个加载器都用一个 linux_binfmt 结构来表示。其中规定了加载二进制可执行文件的 load_binary 函数指针,以及加载崩溃文件 的 core_dump 函数等。其完整定义如下

//file:include/linux/binfmts.h
struct linux_binfmt {
 ...
 int (*load_binary)(struct linux_binprm *);
 int (*load_shlib)(struct file *);
 int (*core_dump)(struct coredump_params *cprm);
};

其中 ELF 的加载器 elf_format 中规定了具体的加载函数,例如 load_binary 成员指向的就是具体的 load_elf_binary 函数。这就是 ELF 加载的入口。

//file:fs/binfmt_elf.c
static struct linux_binfmt elf_format = {
 .module  = THIS_MODULE,
 .load_binary = load_elf_binary,
 .load_shlib = load_elf_library,
 .core_dump = elf_core_dump,
 .min_coredump = ELF_EXEC_PAGESIZE,
};

加载器 elf_format 会在初始化的时候通过 register_binfmt 进行注册。

//file:fs/binfmt_elf.c
static int __init init_elf_binfmt(void)
{
 register_binfmt(&elf_format);
 return 0;
}

而 register_binfmt 就是将加载器挂到全局加载器列表 - formats 全局链表中。

//file:fs/exec.c
static LIST_HEAD(formats);

void __register_binfmt(struct linux_binfmt * fmt, int insert)
{
 ...
 insert ? list_add(&fmt->lh, &formats) :
   list_add_tail(&fmt->lh, &formats);
}

Linux 中除了 elf 文件格式以外还支持其它格式,在源码目录中搜索 register_binfmt,可以搜索到所有 Linux 操作系统支持的格式的加载程序。

# grep -r "register_binfmt" *
fs/binfmt_flat.c: register_binfmt(&flat_format);
fs/binfmt_elf_fdpic.c: register_binfmt(&elf_fdpic_format);
fs/binfmt_som.c: register_binfmt(&som_format);
fs/binfmt_elf.c: register_binfmt(&elf_format);
fs/binfmt_aout.c: register_binfmt(&aout_format);
fs/binfmt_script.c: register_binfmt(&script_format);
fs/binfmt_em86.c: register_binfmt(&em86_format);

将来在 Linux 在加载二进制文件时会遍历 formats 链表,根据要加载的文件格式来查询合适的加载器。

四、execve 加载用户程序

具体加载可执行文件的工作是由 execve 系统调用来完成的。

该系统调用会读取用户输入的可执行文件名,参数列表以及环境变量等开始加载并运行用户指定的可执行文件。该系统调用的位置在 fs/exec.c 文件中。

//file:fs/exec.c
SYSCALL_DEFINE3(execve, const char __user *, filename, ...)
{
 struct filename *path = getname(filename);
 do_execve(path->name, argv, envp)
 ...
}

int do_execve(...)
{
 ...
 return do_execve_common(filename, argv, envp);
}

execve 系统调用到了 do_execve_common 函数。我们来看这个函数的实现。

//file:fs/exec.c
static int do_execve_common(const char *filename, ...)
{
 //linux_binprm 结构用于保存加载二进制文件时使用的参数
 struct linux_binprm *bprm;

 //1.申请并初始化 brm 对象值
 bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
 bprm->file = ...;
 bprm->filename = ...;
 bprm_mm_init(bprm)
 bprm->argc = count(argv, MAX_ARG_STRINGS);
 bprm->envc = count(envp, MAX_ARG_STRINGS);
 prepare_binprm(bprm);
 ...

 //2.遍历查找合适的二进制加载器
 search_binary_handler(bprm);
}

这个函数中申请并初始化 brm 对象的具体工作可以用下图来表示。

在这个函数中,完成了一下三块工作。

第一、使用 kzalloc 申请 linux_binprm 内核对象。该内核对象用于保存加载二进制文件时使用的参数。在申请完后,对该参数对象进行各种初始化。
第二、在 bprm_mm_init 中会申请一个全新的 mm_struct 对象,准备留着给新进程使用。
第三、给新进程的栈申请一页的虚拟内存空间,并将栈指针记录下来。
第四、读取二进制文件头 128 字节。

我们来看下初始化栈的相关代码。

//file:fs/exec.c
static int __bprm_mm_init(struct linux_binprm *bprm)
{
 bprm->vma = vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
 vma->vm_end = STACK_TOP_MAX;
 vma->vm_start = vma->vm_end - PAGE_SIZE;
 ...

 bprm->p = vma->vm_end - sizeof(void *);
}

在上面这个函数中申请了一个 vma 对象(表示虚拟地址空间里的一段范围),vm_end 指向了 STACK_TOP_MAX(地址空间的顶部附近的位置),vm_start 和 vm_end 之间留了一个 Page 大小。也就是说默认给栈申请了 4KB 的大小 。最后把栈的指针记录到 bprm->p 中。

另外再看下 prepare_binprm,在这个函数中,从文件头部读取了 128 字节。之所以这么干,是为了读取二进制文件头为了方便后面判断其文件类型。

//file:include/uapi/linux/binfmts.h
#define BINPRM_BUF_SIZE 128

//file:fs/exec.c
int prepare_binprm(struct linux_binprm *bprm)
{
 ......
 memset(bprm->buf, 0, BINPRM_BUF_SIZE);
 return kernel_read(bprm->file, 0, bprm->buf, BINPRM_BUF_SIZE);
}

在申请并初始化 brm 对象值完后,最后使用 search_binary_handler 函数遍历系统中已注册的加载器,尝试对当前可执行文件进行解析并加载。

在 3.1 节我们介绍了系统所有的加载器都注册到了 formats 全局链表里了。函数 search_binary_handler 的工作过程就是遍历这个全局链表,根据二进制文件头中携带的文件类型数据查找解析器。找到后调用解析器的函数对二进制文件进行加载。

//file:fs/exec.c
int search_binary_handler(struct linux_binprm *bprm)
{
 ...
 for (try=0; try<2; try++) {
  list_for_each_entry(fmt, &formats, lh) {
   int (*fn)(struct linux_binprm *) = fmt->load_binary;
   ...
   retval = fn(bprm);

   //加载成功的话就返回了
   if (retval >= 0) {
    ...
    return retval;
   }
   //加载失败继续循环以尝试加载
   ...
  }
 }
}

在上述代码中的 list_for_each_entry 是在遍历 formats 这个全局链表,遍历时判断每一个链表元素是否有 load_binary 函数。有的话就调用它尝试加载。

回忆一下 3.1 注册可执行文件加载程序,对于 ELF 文件加载器 elf_format 来说, load_binary 函数指针指向的是 load_elf_binary。

//file:fs/binfmt_elf.c
static struct linux_binfmt elf_format = {
 .module  = THIS_MODULE,
 .load_binary = load_elf_binary,
 ......
};

那么加载工作就会进入到 load_elf_binary 函数中来进行。这个函数很长,可以说所有的程序加载逻辑都在这个函数中体现了。我根据这个函数的主要工作,分成以下 5 个小部分来给大家介绍。

在介绍的过程中,为了表达清晰,我会稍微调一下源码的位置,可能和内核源码行数顺序会有所不同。

4.1 ELF 文件头读取

在 load_elf_binary 中首先会读取 ELF 文件头。

文件头中包含一些当前文件格式类型等数据,所以在读取完文件头后会进行一些合法性判断。如果不合法,则退出返回。

//file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
 //4.1 ELF 文件头解析
 //定义结构题并申请内存用来保存 ELF 文件头
 struct {
  struct elfhdr elf_ex;
  struct elfhdr interp_elf_ex;
 } *loc;
 loc = kmalloc(sizeof(*loc), GFP_KERNEL);

 //获取二进制头
 loc->elf_ex = *((struct elfhdr *)bprm->buf);

 //对头部进行一系列的合法性判断,不合法则直接退出
 if (loc->elf_ex.e_type != ET_EXEC && ...){
  goto out;
 }
 ...
}

4.2 Program Header 读取

在 ELF 文件头中记录着 Program Header 的数量,而且在 ELF 头之后紧接着就是 Program Header Tables。所以内核接下来可以将所有的 Program Header 都读取出来。

//file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
 //4.1 ELF 文件头解析

 //4.2 Program Header 读取
 // elf_ex.e_phnum 中保存的是 Programe Header 数量
 // 再根据 Program Header 大小 sizeof(struct elf_phdr)
 // 一起计算出所有的 Program Header 大小,并读取进来
 size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr);
 elf_phdata = kmalloc(size, GFP_KERNEL);
 kernel_read(bprm->file, loc->elf_ex.e_phoff,
     (char *)elf_phdata, size);
 
 ...
}

4.3 清空父进程继承来的资源

在 fork 系统调用创建出来的进程中,包含了不少原进程的信息,如老的地址空间,信号表等等。这些在新的程序运行时并没有什么用,所以需要清空处理一下。

具体工作包括初始化新进程的信号表,应用新的地址空间对象等。

//file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
 //4.1 ELF 文件头解析
 //4.2 Program Header 读取

 //4.3 清空父进程继承来的资源
 retval = flush_old_exec(bprm);
 ...

 current->mm->start_stack = bprm->p;
}

在清空完父进程继承来的资源后(当然也就使用上了新的 mm_struct 对象),这之后,直接将前面准备的进程栈的地址空间指针设置到了 mm 对象上。这样将来栈就可以被使用了。

4.4 执行 Segment 加载

接下来,加载器会将 ELF 文件中的 LOAD 类型的 Segment 都加载到内存里来。使用 elf_map 在虚拟地址空间中为其分配虚拟内存。最后合适地设置虚拟地址空间 mm_struct 中的 start_code、end_code、start_data、end_data 等各个地址空间相关指针。

我们来看下具体的代码:

//file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
 //4.1 ELF 文件头解析
 //4.2 Program Header 读取
 //4.3 清空父进程继承来的资源

 //4.4 执行 Segment 加载过程
 //遍历可执行文件的 Program Header
 for(i = 0, elf_ppnt = elf_phdata;
  i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {

  //只加载类型为 LOAD 的 Segment,否则跳过
  if (elf_ppnt->p_type != PT_LOAD)
   continue;
  ...

  //为 Segment 建立内存 mmap, 将程序文件中的内容映射到虚拟内存空间中
  //这样将来程序中的代码、数据就都可以被访问了
  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
    elf_prot, elf_flags, 0);

  //计算 mm_struct 所需要的各个成员地址
  start_code = ...;
  start_data = ...
  end_code = ...;
  end_data = ...;
  ...
 }

 current->mm->end_code = end_code;
 current->mm->start_code = start_code;
 current->mm->start_data = start_data;
 current->mm->end_data = end_data;
 ...
}

其中 load_bias 是 Segment 要加载到内存里的基地址。这个参数有这么几种可能

  • 值为 0,就是直接按照 ELF 文件中的地址在内存中进行映射
  • 值为对齐到整数页的开始,物理文件中可能为了可执行文件的大小足够紧凑,而不考虑对齐的问题。但是操作系统在加载的时候为了运行效率,需要将 Segment 加载到整数页的开始位置处。

4.5 数据内存申请&堆初始化

因为进程的数据段需要写权限,所以需要使用 set_brk 系统调用专门为数据段申请虚拟内存。

//file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
 //4.1 ELF 文件头解析
 //4.2 Program Header 读取
 //4.3 清空父进程继承来的资源
 //4.4 执行 Segment 加载过程
 //4.5 数据内存申请&堆初始化
 retval = set_brk(elf_bss, elf_brk);
 ......
}

在 set_brk 函数中做了两件事情:第一是为数据段申请虚拟内存,第二是将进程堆的开始指针和结束指针初始化一下。

//file:fs/binfmt_elf.c
static int set_brk(unsigned long start, unsigned long end)
{
 //1.为数据段申请虚拟内存
 start = ELF_PAGEALIGN(start);
 end = ELF_PAGEALIGN(end);
 if (end > start) {
  unsigned long addr;
  addr = vm_brk(start, end - start);
 }

 //2.初始化堆的指针
 current->mm->start_brk = current->mm->brk = end;
 return 0;
}

因为程序初始化的时候,堆上还是空的。所以堆指针初始化的时候,堆的开始地址 start_brk 和结束地址 brk 都设置成了同一个值。

4.6 跳转到程序入口执行

在 ELF 文件头中记录了程序的入口地址。如果是非动态链接加载的情况,入口地址就是这个。

但是如果是动态链接,也就是说存在 INTERP 类型的 Segment,由这个动态链接器先来加载运行,然后再调回到程序的代码入口地址。

# readelf --program-headers helloworld
......
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
     FileSiz            MemSiz              Flags  Align
  INTERP         0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
     0x000000000000001c 0x000000000000001c  R      0x1
   [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

对于是动态加载器类型的,需要先将动态加载器(本文示例中是 ld-linux-x86-64.so.2 文件)加载到地址空间中来。

加载完成后再计算动态加载器的入口地址。这段代码我展示在下面了,没有耐心的同学可以跳过。反正只要知道这里是计算了一个程序的入口地址就可以了。

//file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
 //4.1 ELF 文件头解析
 //4.2 Program Header 读取
 //4.3 清空父进程继承来的资源
 //4.4 执行 Segment 加载
 //4.5 数据内存申请&堆初始化
 //4.6 跳转到程序入口执行

 //第一次遍历 program header table
 //只针对 PT_INTERP 类型的 segment 做个预处理
 //这个 segment 中保存着动态加载器在文件系统中的路径信息
 for (i = 0; i < loc->elf_ex.e_phnum; i++) {
  ...
 }

 //第二次遍历 program header table, 做些特殊处理
 elf_ppnt = elf_phdata;
 for (i = 0; i < loc->elf_ex.e_phnum; i++, elf_ppnt++){
  ...
 }

 //如果程序中指定了动态链接器,就把动态链接器程序读出来
 if (elf_interpreter) {
  //加载并返回动态链接器代码段地址
  elf_entry = load_elf_interp(&loc->interp_elf_ex,
      interpreter,
      &interp_map_addr,
      load_bias);
  //计算动态链接器入口地址
  elf_entry += loc->interp_elf_ex.e_entry;
 } else {
  elf_entry = loc->elf_ex.e_entry;
 }

 //跳转到入口开始执行
 start_thread(regs, elf_entry, bprm->p);
 ...
}

五、总结

看起来简简单单的一行 helloworld 代码,但是要想把它运行过程理解清楚可却需要非常深厚的内功的。

本文首先带领大家认识和理解了二进制可运行 ELF 文件格式。在 ELF 文件中是由四部分组成,分别是 ELF 文件头 (ELF header)、Program header table、Section 和 Section header table。

Linux 在初始化的时候,会将所有支持的加载器都注册到一个全局链表中。对于 ELF 文件来说,它的加载器在内核中的定义为 elf_format,其二进制加载入口是 load_elf_binary 函数。

一般来说 shell 进程是通过 fork + execve 来加载并运行新进程的。执行 fork 系统调用的作用是创建一个新进程出来。不过 fork 创建出来的新进程的代码、数据都还是和原来的 shell 进程的内容一模一样。要想实现加载并运行另外一个程序,那还需要使用到 execve 系统调用。

在 execve 系统调用中,首先会申请一个 linux_binprm 对象。在初始化 linux_binprm 的过程中,会申请一个全新的 mm_struct 对象,准备留着给新进程使用。还会给新进程的栈准备一页(4KB)的虚拟内存。还会读取可执行文件的前 128 字节。

接下来就是调用 ELF 加载器的 load_elf_binary 函数进行实际的加载。大致会执行如下几个步骤:

  • ELF 文件头解析
  • Program Header 读取
  • 清空父进程继承来的资源,使用新的 mm_struct 以及新的栈
  • 执行 Segment 加载,将 ELF 文件中的 LOAD 类型的 Segment 都加载到虚拟内存中
  • 为数据 Segment 申请内存,并将堆的起始指针进行初始化
  • 最后计算并跳转到程序入口执行

当用户进程启动起来以后,我们可以通过 proc 伪文件来查看进程中的各个 Segment。

# cat /proc/46276/maps
00400000-00401000 r--p 00000000 fd:01 396999                             /root/work_temp/helloworld
00401000-00402000 r-xp 00001000 fd:01 396999                             /root/work_temp/helloworld
00402000-00403000 r--p 00002000 fd:01 396999                             /root/work_temp/helloworld
00403000-00404000 r--p 00002000 fd:01 396999                             /root/work_temp/helloworld
00404000-00405000 rw-p 00003000 fd:01 396999                             /root/work_temp/helloworld
01dc9000-01dea000 rw-p 00000000 00:00 0                                  [heap]
7f0122fbf000-7f0122fc1000 rw-p 00000000 00:00 0 
7f0122fc1000-7f0122fe7000 r--p 00000000 fd:01 1182071                    /usr/lib64/libc-2.32.so
7f0122fe7000-7f0123136000 r-xp 00026000 fd:01 1182071                    /usr/lib64/libc-2.32.so
......
7f01231c0000-7f01231c1000 r--p 0002a000 fd:01 1182554                    /usr/lib64/ld-2.32.so
7f01231c1000-7f01231c3000 rw-p 0002b000 fd:01 1182554                    /usr/lib64/ld-2.32.so
7ffdf0590000-7ffdf05b1000 rw-p 00000000 00:00 0                          [stack]
......

虽然本文非常的长,但仍然其实只把大体的加载启动过程串了一下。如果你日后在工作学习中遇到想搞清楚的问题,可以顺着本文的思路去到源码中寻找具体的问题,进而帮助你找到工作中的问题的解。

最后提一下,细心的读者可能发现了,本文的实例中加载新程序运行的过程中其实有一些浪费,fork 系统调用首先将父进程的很多信息拷贝了一遍,而 execve 加载可执行程序的时候又是重新赋值的。所以在实际的 shell 程序中,一般使用的是 vfork。其工作原理基本和 fork 一致,但区别是会少拷贝一些在 execve 系统调用中用不到的信息,进而提高加载性能。

本文基于Linux™系统对进程创建与加载进行分析,文中实现了Linux库函数fork、exec,剖析内核态执行过程,并进一步展示进程创建过程中进程控制块字段变化信息及ELF文件加载过程。

一、初识Linux进程

  进程这个概念是针对系统而不是针对用户的,对用户来说,他面对的概念是程序。当用户敲入命令执行一个程序的时候,对系统而言,它将启动一个进程。但和程序不同的是,在这个进程中,系统可能需要再启动一个或多个进程来完成独立的多个任务。简单介绍下进程的结构。

  1.1 Linux下的进程查看

  我们可以使用$ps命令来查询正在运行的进程,比如$ps -eo pid,comm,cmd,下图为执行结果:

(-e表示列出全部进程,-o pid,comm,cmd表示我们需要PID,COMMAND,CMD信息)

每一行代表了一个进程。每一行又分为三列。第一列PID(process IDentity)是一个整数,每一个进程都有一个唯一的PID来代表自己的身份,进程也可以根据PID来识别其他的进程。第二列COMMAND是这个进程的简称。第三列CMD是进程所对应的程序以及运行时所带的参数。

  (第三列有一些由中括号[]括起来的。它们是kernel的一部分功能,显示为进程的样子主要是为了方便操作系统管理。)

  我们看第一行,PID为1,名字为init。这个进程是执行/bin/init这一文件(程序)生成的。当Linux启动的时候,init是系统创建的第一个进程,这一进程会一直存在,直到我们关闭计算机。

1.2 Linux下进程的结构

  Linux下一个进程在内存里有三部分的数据,就是"代码段"、"堆栈段"和"数据段"。其实学过汇编语言的人一定知道,一般的CPU都有上述三种段寄存器,以方便操作系统的运行。这三个部分也是构成一个完整的执行序列的必要的部分。

  "代码段",顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用相同的代码段。"堆栈段"存放的就是子程序的返回地址、子程序的参数以及程序的局部变量。而数据段则存放程序的全局变量,常数以及动态数据分配的数据空间(比如用malloc之类的函数取得的空间)。系统如果同时运行数个相同的程序,它们之间就不能使用同一个堆栈段和数据段。

1.3 Linux进程描述符

  在Linux中每一个进程都由task_struct 数据结构来定义.task_struct就是我们通常所说的PCB.它是对进程控制的唯一手段也是最有效的手段. 当我们调用fork() 时,系统会为我们产生一个task_struct结构。然后从父进程,那里继承一些数据, 并把新的进程插入到进程树中,以待进行进程管理。

以下是进程描述符的源码:

struct task_struct {
  volatile long state;
  unsigned long flags;
  int sigpending;
   mm_segment_taddr_limit;
  volatile long need_resched;
  int lock_depth;
  long nice;
  unsigned long policy;
  struct mm_struct *mm;
  int processor;
  unsigned long cpus_runnable, cpus_allowed;
  struct list_head run_list;
  unsigned longsleep_time;
  struct task_struct *next_task, *prev_task;
  struct mm_struct *active_mm;
  struct list_headlocal_pages;
  unsigned int allocation_order, nr_local_pages;
  struct linux_binfmt *binfmt;
  int exit_code, exit_signal;
  int pdeath_signal;
  unsigned long personality;
  int did_exec:1;
  pid_t pid;
  pid_t pgrp;
  pid_t tty_old_pgrp;
  pid_t session;
  pid_t tgid;
  int leader;
  struct task_struct*p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr;
  struct list_head thread_group;
  struct task_struct *pid hash_next;
  struct task_struct **pid hash_pprev;
  wait_queue_head_t wait_chldexit;
   struct completion *vfork_done;
   unsigned long rt_priority;
   unsigned long it_real_value, it_prof_value, it_virt_value;
   unsigned long it_real_incr, it_prof_incr, it_virt_value;
   struct timer_listreal_timer;
   struct tmstimes;
  unsigned long start_time;
  long per_cpu_utime[NR_CPUS],per_cpu_stime[NR_CPUS];
  uid_t uid,euid,suid,fsuid;
  gid_t gid,egid,sgid,fsgid;
  int ngroups;
  gid_t groups[NGROUPS];
  kernel_cap_tcap_effective, cap_inheritable, cap_permitted;
  int keep_capabilities:1;
  struct user_struct *user;
  struct rlimit rlim[RLIM_NLIMITS];
  unsigned shortused_math;
  charcomm[16];
  int link_count, total_link_count;
  struct tty_struct*tty;
  unsigned int locks;
  struct sem_undo*semundo;
  struct sem_queue *semsleeping;
  struct thread_struct thread;
  struct fs_struct *fs;
  struct files_struct *files;
  spinlock_t sigmask_lock;
  struct signal_struct *sig;
  sigset_t blocked;
  struct sigpendingpending;
  unsigned long sas_ss_sp;
  size_t sas_ss_size;
  int (*notifier)(void *priv);
  void *notifier_data;
  sigset_t *notifier_mask;
  u32 parent_exec_id;
  u32 self_exec_id;
  spinlock_t alloc_lock;
  void *journal_info;
};

主要结构分析:

volatile long state; 说明了该进程是否可以执行,还是可中断等信息

unsigned long flags; Flage 是进程号,在调用fork()时给出

int sigpending; 进程上是否有待处理的信号

mm_segment_taddr_limit; 进程地址空间,区分内核进程与普通进程在内存存放的位置不同(0-0xBFFFFFFF foruser-thead 0-0xFFFFFFFF forkernel-thread)

volatile long need_resched;调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度

struct mm_struct *mm; 进程内存管理信息

pid_tpid; 进程标识符,用来代表一个进程

pid_tpgrp; 进程组标识,表示进程所属的进程组

task_struct的数据成员mm指向关于存储管理的struct mm_struct结构。它包含着进程内存管理的很多重要数据,如进程代码段、数据段、未未初始化数据段、调用参数区和进程。

【腾讯文档】Linux内核源码技术学习路线+视频教程代码资料

Linux内核源码技术学习路线+视频教程代码资料​docs.qq.com/doc/DWmNMckNQc21ZbENE正在上传…重新上传取消

二、 如何创建一个进程

2.1 Linux下的进程控制

  在传统的Linux环境下,有两个基本的操作用于创建和修改进程:函数fork()用来创建一个新的进程,该进程几乎是当前进程的一个完全拷贝;函数族exec( )用来启动另外的进程以取代当前运行的进程。

  关于fork()与execl(),去年写过一篇文章对部分源码进行过分析:system()和execv()函数使用详解

2.2 fork()

  一个进程在运行中,如果使用了fork,就产生了另一个进程。下面就看看如何具体使用fork,这段程序演示了使用fork的基本框架:

#include <stdio.h>

void main()
{
    int i;
    if ( fork() == 0 ) 
    {
       /* 子进程程序 */
       for ( i = 1; i <1000; i ++ ) 
          printf("This is child process\n");
    }
    else 
    {
       /* 父进程程序*/
       for ( i = 1; i <1000; i ++ ) 
         printf("This is origin process\n");
    }
}

运行结果如下:

从上图可以看出父进程和子进程并发运行,内核能够以任意方式交替运行它们,这里是父进程先运行,然后是子进程。但是在另外一个系统上运行时不一定是这个顺序。

使用fork函数创建的子进程从父进程的继承了全部进程的地址空间,包括:进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设置、进程优先级、进程组号、当前工作目录、根目录、资源限制、控制终端等。

fork创建子进程,首先调用int80中断,然后将系统调用号保存在eax寄存器中,进入内核态后调用do_fork(),实际上是创建了一份父进程的拷贝,他们的内存空间里包含了完全相同的内容,包括当前打开的资源,数据,当然也包含了程序运行到的位置,也就是说fork后子进程也是从fork函数的位置开始往下执行的,而不是从头开始。而为了判别当前正在运行的是哪个进程,fork函数返回了一个pid,在父进程里标识了子进程的id,在子进程里其值为0,在我们的程序里就根据这个值来分开父进程的代码和子进程的代码。

一旦使用fork创建子进程,则进程地址空间中的任何有效地址都只能位于唯一的区域,这些区域不能相互覆盖。编写如下代码进行测试:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

struct con {
        int a;
};

int main() {
        pid_t pid;
        struct con s;
        s.a = 2;
        struct con* sp = &s;
        pid = fork();
        if (pid > 0) {
                printf("parent show %p, %p, a =  %d\n", sp, &sp->a, sp->a);
                sp->a = 1;
                sleep(10);
                printf("parent show %p, %p, a = %d\n", sp, &sp->a, sp->a);
                printf("parent exit\n");
        }
        else {
                printf("child show %p, %p, a = %d\n", sp, &sp->a, sp->a);
                sp->a = -1;
                printf("child change a to %d\n", sp->a);
        }
        return 0;
}

获得结果如下:

从上面的分析可以看出进程copy过程中,fork就是基于写时复制,只读代码段是可以同享的,一般CPU都是以"页"为单位来分配内存空间的,每一个页都是实际物理内存的一个映像,象INTEL的CPU,其一页在通常情况下是 4086字节大小,而无论是数据段还是堆栈段都是由许多"页"构成的,fork函数复制这两个段,物理空间上两个进程的数据段和堆栈段都还是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别,系统就将有区别的" 页"从物理上也分开。系统在空间上的开销就可以达到最小。

2.3 exec( )函数族

  下面我们来看看一个进程如何来启动另一个程序的执行。在Linux中要使用exec函数族。系统调用execve()对当前进程进行替换,替换者为一个指定的程序,其参数包括文件名(filename)、参数列表(argv)以及环境变量(envp)。exec函数族当然不止一个,但它们大致相同,在 Linux中,它们分别是:execl,execlp,execle,execv,execve和execvp,下面以execve为例。

  一个进程一旦调用exec类函数,它本身就"死亡"了,execve首先调用int80中断,然后将系统调用号保存在eax寄存器中,调用sys_exec,将可执行程序加载到当前进程中,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下的,就是进程号,也就是说,对系统而言,还是同一个进程,不过已经是另一个程序了。(不过exec类函数中有的还允许继承环境变量之类的信息。)

  那么如果我的程序想启动另一程序的执行但自己仍想继续运行的话,怎么办呢?那就是结合fork与exec的使用。下面一段代码显示如何启动运行其它程序:

#include <stdio.h>
#include <unistd.h>
int main(){
  if(!fork())
    execve("./test",NULL,NULL);
  else
    printf("origin process!\n");
  return 0;
}

输出结果如下:

  原始进程和execve创建的新进程,并发运行,exec函数在当前进程的上下文中加载并运行一个新的程序,并且不返回创建进程的函数。

  接下来,我们分析一下execve函数执行过程中,以及可执行程序的加载过程,在内核中execve()系统调用相应的入口是sys_execve(),函数首先通过 pt_regs参数检查赋值在执行该系统调用时,用户态下的CPU寄存器在核心态的栈中的保存情况。通过这个参数,sys_execve可以获得保存在用户空间的以下信息:可执行文件路径的指针(regs.ebx中)、命令行参数的指针(regs.ecx中)和环境变量的指针(regs.edx中)。

struct pt_regs {     
     long ebx;
     long ecx;
     long edx;
     long esi; 
     long edi;
     long ebp;
     long eax;
     int xds;
     int xes;
     long orig_eax;
     long eip;
     int xcs;
     long eflags;
     long esp;
     int xss;
}

然后调用do_execve函数,首先查找被执行的文件,读取前128个字节,确实加载的可执行文件的类型,然后调用search_binary_handle()搜索和匹配合适的可执行文件装载处理过程,elf调用load_elf_binary();

struct linux_binprm{     
     char buf[BINPRM_BUF_SIZE]; //保存可执行文件的头128字节               struct page *page[MAX_ARG_PAGES];
     struct mm_struct *mm;     unsigned long p;    //当前内存页最高地址
     int sh_bang;
     struct file * file;     //要执行的文件
     int e_uid, e_gid;    //要执行的进程的有效用户ID和有效组ID
     kernel_cap_t cap_inheritable, cap_permitted, cap_effective;     void *security;
     int argc, envc;     //命令行参数和环境变量数目
     char * filename;    //要执行的文件的名称
     char * interp;        //要执行的文件的真实名称,通常和filename相同
     unsigned interp_flags;
     unsigned interp_data;
     unsigned long loader, exec;
 };

load_elf_binary()加载过程如下:

  a.检查ELF可执行文件的有效性,比如魔数(开头四个字节,elf文件为0x7F),段“Segment”的数量;

  b.寻找动态链接.interp段,设置动态连接器的路径;

  c.根据elf可执行文件的程序头表的描述,对elf文件进行映射;

  d.初始化elf进程环境,比如启动时候的edx寄存器地址是DT_FINI的地址;

  e.将系统调用的返回地址修改为elf可执行文件的入口点,就是e_entry所存的地址。对于动态链接的elf可执行文件就是动态连接器。

  加载完成后返回do_execve返回到exeve(),从内核态转化为用户态并返回e步所在更改的程序入口地址。即eip存储器直接跳转到elf程序的入口地址,新进程执行。

三、 进程虚拟地址空间与可执行程序格式

  从操作系统来看,一进程最关键的特征是它拥有独立的虚拟地址空间,一般情况下,创建过程如下:

  ①创建一个独立的虚拟空间。

  ②读取可执行文件头,并且简历虚拟空间与可执行文件的映射关系。

  ③将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

  在讨论地址空间,进程描述符以及ELF文件格式的之前,我们先介绍一点预备知识,由于第一节已经介绍了进程描述符的部分信息,在这里介绍下ELF文件格式:

在第二节使用execve时,我们使用了test可执行程序进行测试,代码如下:

#include <stdio.h>

int main(int argc, char const *argv[])
{
    printf("%s\n","execve the new process!");
    return 0;
}

描述“Segment”的结构叫程序头,它描述了ELF文件该如何被操作系统映射到进程的虚拟空间:

上图共有5个Segment。从装载的角度看,我们只关心两个LOAD和DYNAMIC,其他Segment在装载过程中只具有辅助作用,映射过程中,根据读写执行权限映射到不同的虚拟内存区域

    第四行LOAD表示代码段,具有可读可执行权限,被映射到虚拟地址0x08048000,长度为0x005c4字节的虚拟存储区域中。

    第五行LOAD表示长度为0x100个字节的数据段,具有可读可写权限,被映射到开始于虚拟地址0x08049f08处,长度为0x0011c字节的虚拟存储区域中。

    DYNAMIC字段表示的是动态链接器所需要的基本信息,具有可读可写权限,被映射到开始于虚拟地址0x08049f14处,长度为0x000e8字节的虚拟存储区域中。

  在第二节中执行如下命令后,ELF文件正式开始加载工作,执行第二节中的加载过程:

execve("./test",NULL,NULL);

文件在加载过程中是以elf可执行文件的形式加载,加载过程初始化时,根据elf段头部表信息,初始化bss段、代码段和数据段的起始地址和终止地址。

  然后调用mm_release释放掉当前进程所占用的内存(old_mm),并且将当前进程的内存空间替换成bprm->mm所指定的页面,而这块空间,便是新进程在初始化时暂时向内核借用的存储空间,当这段空间读取到目前进程的mm以后,事实上也就完成了旧进程到新进程的替换。这个时候bprm->mm这块内核空间也就完成了它的使命,于是被置为NULL予以回收。(bprm为中保存了读取128字节elf文件头)。

  mm指向关于存储管理的struct mm_struct结构,其包含在task_struct中。

然后加载段地址到虚拟内存地址,映射如下:

然后另一部分段映射到数据区,关系如下:

到这里,对于elf文件的载入(包括之前对可执行文件运行环境准备工作)的分析基本上可以告一段落了。

四、进程创建中动态链接库的表现形式

  动态链接的基本思想是把程序按照模块拆分,运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。多个动态链接库均以ELF文件存储,执行过程中以依赖树的关系存在,并以深度优先的方式加载动态链接库,最终将可执行程序返回给用户。

  我们通过以下实例来测试动态链接库在虚拟地址及ELF文件的中表现形式:

/* Lib.h */
#ifndef LIB_H
#define LIB_H

void lab(int i)

#endif

/* Lib.c */
#include <stdio.h>

void lab(int i){
    printf("Printing from lib.so %d\n", i);  sleep(-1);
}

/* dyn.c*/
#include "lib.h"

int main(){
    lab(1);
    return 0;
}

使用gcc编译生成一个共享对象文件,然后链接dyn.c程序,生成可执行文件dyn:

gcc -fPIC -shared -o lib.so lib.c
gcc -o dyn dyn.c ./lib.so

  运行并查看进程的虚拟地址空间分布:

整个进程的虚拟地址空间中,多出了几个文件的映射。dyn与lib.so一样,都被系统映射到进程的虚拟地址空间,地址与长度均不相同。由第二节可知,在映射完可执行文件之后,操作系统会先启动一个动态链接器。

  动态链接器的的位置由ELF文件中的“.interp”段决定,而段“.dynamic”为动态链接提供了:依赖哪些共享对象、动态链接符号表的位置,动态链接重定位表的位置、共享对象初始化代码的地址等。可通过readelf查看".dynamic" 段的内容:

动态链接过程需要动态符号表来确定函数的定义和引用关系,还需要重定位表来修正导入符号的引用。初始化完成后堆栈中保存了动态连接器所需要的一些辅助信息数组(其中包括程序入口地址,程序表头地址,程序表头项数及大小)。动态链接库最后被映射到进程地址空间的共享库区域段。

完成重定位和初始化后,所有准备工作结束,所需要的共享对象也都已经装载并且链接完成。最后将进程的控制权转交给dyn程序的入口并开始执行。

https://www.cnblogs.com/akira90/archive/2013/05/30/3109374.html
  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值