一、程序到运行时
不少的开发者都不明白或者说不关心,自己编写好的程序是如何被加载到计算机中,并开始运行行起来。更多的人可能朦胧的知道一些大概的流程,但真正一想,又发现说不清楚。
程序的运行时,其实就是将二进制代码加载到内存的过程,然后由cpu再根据实际的情况进行二进制指令的调用。其中包括资源、库等相关运行需求的定位加载。当这一切都准备完成时,程序就形成了进程(可能是一个,也可能是多个),并由操作系统进行调度,从而展现出来。
在linux中的可执行文件一般是ELF文件,所以,就以这种文件的加载进行分析说明。
二、整体步骤
对于一个程序如何加载到内存,一般分为以下几步:
- shell解析
在linux中启动程序的一般情况为"./app arg1 arg2",此时,操作系统中运行的shell正通过wait等系统调用在等待前台进程。当发现有程序执行时,会对相关的命令进行解析以及参数或变量等的设置 - 系统fork或clone创建新的进程
父shell在完成上述后,调用系统fork或clone来创建一个子进程,用来执行程序。它采用写时复制,父子共用内存页面,只有一方发生写入时才会真正的展开新内存的创建。同时,会通过重定向等完成文件描述符的继承和处理 - 内核执行execve执行指定的ELF文件及相关参数
子进程执行execve,用来创建自己的内存地址空间等。下面将详细分析
三、内核装载elf
内核中通过execve加载elf文件的过程,是程序装载到内存的全过程。主要包括:
-
文件类型处理
即通过文件中的标记来判断是脚本或可执行程序,如果是脚本则根据标记加载特定的解释器
-
elf文件检查
内核通过加载elf文件的头,用来检查平台架构、文件类型、入口地址、魔数等。这个如果有一些安全工程相关的文件处理的经验的很容易理解。同时内核还会检查路径、权限以及各种LSM(内核安全)策略、属性等,并根据设置进行相关的处理
-
创建地址空间
上述执行成功后,原来通过fork创建的子进程的堆、栈等用户态内存会被清除,内核根据elf文件重新建立自己的内存布局。保留进程id、父子关系等进程属性的前提下,将相关程序计数器、地址空间、用户栈等指向新加载的程序
-
内存映射
利用elf_map等机制将ELF的PT_LOAD段映射。内核根据每个段的以下字段建立虚拟内存区域:
- p_vaddr:目标虚拟地址
- p_offset:文件偏移
- p_filesz:文件中实际存在的数据大小
- p_memsz:运行时所需的内存大小
- p_flags:读、写、执行权限
不过需要说明的是,虚拟内存空间的逻辑布局不是完全固定的,它会受到架构、内核策略以及ASLR、PIE等的影响。大家有兴趣可以使用pmap(或/proc/PID/maps)等工具查看相关的内容。
-
分页加载
映射成功后,进程的虚拟页加载时可能没有对应的物理页,会在首次访问时产生缺页异常并开始进行二者的映射,重新执行指令。也就是说,不会一次性把所有的内容复制到内存中。这个对于计算机专业的同学们来说,在操作系统中应该学习过。虚拟内存和段页的相关机制,此处不再展开
-
加载动态库
根据ELF中的PT_INTERP来查找、定位、加载动态库,并根据情况决定是否延迟加载绑定。静态库由内核直接加载执行
-
建立栈
内核会在新程序的栈中放到相关的参数如页面的大小、ELF头信息以及相关的栈顶、寄存器初始化等的处理。并且对栈保护的随机值和ABI状态进行管理
-
处理内存权限及地址
NX、RELRO、栈保护和控制流保护等机制由编译器、链接器、动态链接器、CPU与内核共同实现。ASLR会随机化栈、堆、共享库、mmap区域以及PIE主程序基址。这里特别说明一下ASLR(Address Space Layout Randomization,地址空间布局随机化),它是操作系统的一项安全防护技术。即随机化进程关键内存区域的起始地址,防止攻击者利用已知的内存地址轻松执行恶意代码
-
返回用户态
在完成上面的一系列操作后,CPU将从新的加载的程序入口开始,进行到主程序的main入口。在进入main前,静态、全局及部分库对象会被初始化
-
用户态内存的处理
程序需要自行处理的内存将在此时进行,包括各种动态内存的创建等
基本上通过上述的步骤,一个程序的内容包括其自身的内存需求就会完整的加载到虚拟内存中,供进程自身调度使用。
四、总结
开发者在关注程序本身的同时,还需要掌握计算机体系中的相关内容。否则,所谓的优化和效率的提升,就是无源之水。代码最终会加载到内存中进行调用,而不是单纯的代码决定一切。所以不但要明白写代码的语言,更要明白代码运行的环境的原理,守和能够真正的把二者结合起来,开发出更优秀的程序。